Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6573c08f37 | ||
|
|
dcfde89448 | ||
|
|
3016bbdfa2 | ||
|
|
630d7ff82e | ||
|
|
6874781340 | ||
|
|
be214acfd1 |
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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", () =>
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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"
|
||||
@@ -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.64.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.64.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.64.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.64.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/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -380,14 +380,6 @@ const createRspackConfig = ({
|
||||
resolve: {
|
||||
extensions: [".ts", ".js", ".json"],
|
||||
alias: {
|
||||
// @formatjs/bigdecimal uses BigInt (Safari 14, Chrome 67), which
|
||||
// breaks the NumberFormat and PluralRules polyfills
|
||||
...(!latestBuild && {
|
||||
"@formatjs/bigdecimal$": path.join(
|
||||
paths.root_dir,
|
||||
"src/resources/polyfills/bigdecimal.ts"
|
||||
),
|
||||
}),
|
||||
"lit/static-html$": "lit/static-html.js",
|
||||
"lit/decorators$": "lit/decorators.js",
|
||||
"lit/directive$": "lit/directive.js",
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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, [
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
@@ -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))
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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,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;
|
||||
@@ -76,7 +63,8 @@ const generateSumStatistics = (
|
||||
maxDiff: number | ((start: Date, end: Date) => 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) {
|
||||
@@ -110,7 +98,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;
|
||||
@@ -173,9 +162,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 +199,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 +239,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 +280,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,
|
||||
@@ -346,9 +329,8 @@ 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[]> = {};
|
||||
|
||||
@@ -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>.
|
||||
@@ -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>
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -45,7 +45,6 @@
|
||||
"@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/lint": "6.9.7",
|
||||
@@ -63,7 +62,13 @@
|
||||
"@formatjs/intl-numberformat": "9.4.3",
|
||||
"@formatjs/intl-pluralrules": "6.3.15",
|
||||
"@formatjs/intl-relativetimeformat": "12.3.15",
|
||||
"@home-assistant/webawesome": "3.14.0-ha.1",
|
||||
"@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",
|
||||
"@lit-labs/motion": "1.1.0",
|
||||
@@ -80,7 +85,7 @@
|
||||
"@swc/helpers": "0.5.23",
|
||||
"@tsparticles/engine": "4.4.0",
|
||||
"@tsparticles/preset-links": "4.4.0",
|
||||
"@versatiles/style": "7.0.1",
|
||||
"@versatiles/style": "6.4.0",
|
||||
"@vibrant/color": "4.0.4",
|
||||
"@vvo/tzdb": "6.201.1",
|
||||
"@webcomponents/scoped-custom-element-registry": "0.0.10",
|
||||
@@ -92,25 +97,24 @@
|
||||
"cropperjs": "1.7.0",
|
||||
"culori": "4.0.2",
|
||||
"date-fns": "4.4.0",
|
||||
"decimal.js": "10.6.0",
|
||||
"deep-clone-simple": "1.1.1",
|
||||
"deep-freeze": "0.0.1",
|
||||
"echarts": "6.1.0",
|
||||
"echarts-extension-chart2music": "0.1.1",
|
||||
"element-internals-polyfill": "3.0.2",
|
||||
"fullcalendar": "7.1.3",
|
||||
"fuse.js": "7.5.0",
|
||||
"hls.js": "1.7.3",
|
||||
"home-assistant-js-websocket": "9.7.1",
|
||||
"idb-keyval": "6.3.0",
|
||||
"intl-messageformat": "12.1.3",
|
||||
"js-yaml": "5.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",
|
||||
"marked": "18.1.0",
|
||||
"memoize-one": "6.0.0",
|
||||
"nanoid": "6.0.2",
|
||||
"node-vibrant": "4.0.4",
|
||||
@@ -123,7 +127,6 @@
|
||||
"sortablejs": "1.15.7",
|
||||
"stacktrace-js": "2.0.2",
|
||||
"superstruct": "2.0.2",
|
||||
"temporal-polyfill": "1.0.6",
|
||||
"tinykeys": "4.0.1",
|
||||
"weekstart": "2.0.0",
|
||||
"workbox-cacheable-response": "7.4.1",
|
||||
@@ -146,16 +149,16 @@
|
||||
"@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",
|
||||
"@playwright/test": "1.64.0",
|
||||
"@rsdoctor/rspack-plugin": "1.6.5",
|
||||
"@rspack/core": "2.2.9",
|
||||
"@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",
|
||||
@@ -164,24 +167,25 @@
|
||||
"@types/leaflet": "1.9.22",
|
||||
"@types/leaflet.markercluster": "1.5.6",
|
||||
"@types/lodash.merge": "4.6.9",
|
||||
"@types/luxon": "3.7.6",
|
||||
"@types/node": "24.19.2",
|
||||
"@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.4",
|
||||
"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",
|
||||
"eslint": "10.13.0",
|
||||
"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",
|
||||
@@ -194,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",
|
||||
@@ -213,16 +217,16 @@
|
||||
"terser-webpack-plugin": "5.6.1",
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.71.2",
|
||||
"typescript-eslint": "8.71.1",
|
||||
"vite": "8.3.4",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "5.0.5",
|
||||
"vitest": "5.0.4",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
"webpackbar": "7.0.0",
|
||||
"workbox-build": "7.4.1"
|
||||
},
|
||||
"packageManager": "[email protected]4.0",
|
||||
"packageManager": "[email protected]1.2",
|
||||
"volta": {
|
||||
"node": "24.22.0"
|
||||
"node": "24.21.0"
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ overrides:
|
||||
lit-html: 3.3.3
|
||||
clean-css: 5.3.3
|
||||
"@lit/reactive-element": 2.1.2
|
||||
"@fullcalendar/daygrid": 6.1.21
|
||||
tslib: 2.8.1
|
||||
|
||||
patchedDependencies:
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg width="36" height="16" viewBox="0 0 36 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="16" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
<rect x="20" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 252 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="36" height="16" viewBox="0 0 36 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="16" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
<rect x="20" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 252 B |
@@ -0,0 +1,5 @@
|
||||
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="16" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
<rect x="20" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
<rect x="40" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 330 B |
@@ -0,0 +1,5 @@
|
||||
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="16" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
<rect x="20" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
<rect x="40" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 330 B |
@@ -0,0 +1,6 @@
|
||||
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="16" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
<rect x="20" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
<rect x="40" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
<rect x="60" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 408 B |
@@ -0,0 +1,6 @@
|
||||
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="16" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
<rect x="20" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
<rect x="40" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
<rect x="60" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 408 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="36" height="16" viewBox="0 0 36 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="36" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 174 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="36" height="16" viewBox="0 0 36 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="36" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 174 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="36" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
<rect x="40" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 252 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="36" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
<rect x="40" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 252 B |
@@ -0,0 +1,5 @@
|
||||
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="36" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
<rect x="40" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
<rect x="60" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 330 B |
@@ -0,0 +1,5 @@
|
||||
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="36" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
<rect x="40" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
<rect x="60" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 330 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="56" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 174 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="56" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 174 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="56" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
<rect x="60" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 252 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="56" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
<rect x="60" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 252 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="76" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 174 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="76" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 174 B |
@@ -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",
|
||||
|
||||
@@ -45,168 +45,69 @@ export const formatNumericDuration = (
|
||||
return null;
|
||||
};
|
||||
|
||||
const DURATION_FIELDS = [
|
||||
["days", "day"],
|
||||
["hours", "hour"],
|
||||
["minutes", "minute"],
|
||||
["seconds", "second"],
|
||||
["milliseconds", "millisecond"],
|
||||
] as const satisfies readonly (readonly [keyof HaDurationData, string])[];
|
||||
|
||||
type DurationFormatOptions = {
|
||||
style: "long" | "narrow" | "digital";
|
||||
} & Partial<Record<`${keyof HaDurationData}Display`, "always" | "auto">>;
|
||||
|
||||
type DurationFormatter = (duration: HaDurationData) => string;
|
||||
|
||||
// Throws like Intl.DurationFormat on fractions, NaN and mixed signs
|
||||
const isNegativeDuration = (duration: HaDurationData) => {
|
||||
const values = DURATION_FIELDS.map(([field]) => Number(duration[field] ?? 0));
|
||||
const negative = values.some((value) => value < 0);
|
||||
if (
|
||||
!values.every((value) => Number.isInteger(value)) ||
|
||||
(negative && values.some((value) => value > 0))
|
||||
) {
|
||||
throw new RangeError("Invalid duration");
|
||||
}
|
||||
return negative;
|
||||
};
|
||||
|
||||
const fallbackUnitsFormat = (
|
||||
language: string,
|
||||
options: DurationFormatOptions
|
||||
): DurationFormatter => {
|
||||
const style = options.style === "narrow" ? "narrow" : "long";
|
||||
const list = new Intl.ListFormat(language, { type: "unit", style });
|
||||
const unitFormats = DURATION_FIELDS.map(
|
||||
([, unit]) =>
|
||||
new Intl.NumberFormat(language, {
|
||||
style: "unit",
|
||||
unit,
|
||||
unitDisplay: style,
|
||||
})
|
||||
);
|
||||
return (duration) => {
|
||||
const negative = isNegativeDuration(duration);
|
||||
const parts: string[] = [];
|
||||
DURATION_FIELDS.forEach(([field], index) => {
|
||||
const value = Math.abs(duration[field] ?? 0);
|
||||
if (value || options[`${field}Display`] === "always") {
|
||||
// Like Intl.DurationFormat, only the first unit shows the sign
|
||||
parts.push(
|
||||
unitFormats[index].format(negative && !parts.length ? -value : value)
|
||||
);
|
||||
}
|
||||
});
|
||||
return list.format(parts);
|
||||
};
|
||||
};
|
||||
|
||||
const fallbackDigitalFormat = (
|
||||
language: string,
|
||||
options: DurationFormatOptions
|
||||
): DurationFormatter => {
|
||||
const list = new Intl.ListFormat(language, { type: "unit", style: "short" });
|
||||
const dayFormat = new Intl.NumberFormat(language, {
|
||||
style: "unit",
|
||||
unit: "day",
|
||||
unitDisplay: "short",
|
||||
});
|
||||
const hourFormat = new Intl.NumberFormat(language, { useGrouping: false });
|
||||
const twoDigitFormat = new Intl.NumberFormat(language, {
|
||||
minimumIntegerDigits: 2,
|
||||
maximumFractionDigits: 3,
|
||||
useGrouping: false,
|
||||
});
|
||||
return (duration) => {
|
||||
const sign = isNegativeDuration(duration) ? "-" : "";
|
||||
const days = Math.abs(duration.days ?? 0);
|
||||
const hours = Math.abs(duration.hours ?? 0);
|
||||
const minutes = Math.abs(duration.minutes ?? 0);
|
||||
const seconds =
|
||||
Math.abs(duration.seconds ?? 0) +
|
||||
Math.abs(duration.milliseconds ?? 0) / 1000;
|
||||
let time = `${twoDigitFormat.format(minutes)}:${twoDigitFormat.format(seconds)}`;
|
||||
if (hours || options.hoursDisplay !== "auto") {
|
||||
time = `${hourFormat.format(hours)}:${time}`;
|
||||
}
|
||||
return days
|
||||
? list.format([sign + dayFormat.format(days), time])
|
||||
: sign + time;
|
||||
};
|
||||
};
|
||||
|
||||
// The Intl.DurationFormat polyfill needs BigInt, which some legacy browsers lack
|
||||
const createDurationFormat = (
|
||||
language: string,
|
||||
options: DurationFormatOptions
|
||||
): DurationFormatter => {
|
||||
if ("DurationFormat" in Intl) {
|
||||
const durationFormat = new Intl.DurationFormat(language, options);
|
||||
return (duration) => durationFormat.format(duration);
|
||||
}
|
||||
return options.style === "digital"
|
||||
? fallbackDigitalFormat(language, options)
|
||||
: fallbackUnitsFormat(language, options);
|
||||
};
|
||||
|
||||
const formatDurationLongMem = memoizeOne((locale: FrontendLocaleData) =>
|
||||
createDurationFormat(locale.language, {
|
||||
style: "long",
|
||||
})
|
||||
const formatDurationLongMem = memoizeOne(
|
||||
(locale: FrontendLocaleData) =>
|
||||
new Intl.DurationFormat(locale.language, {
|
||||
style: "long",
|
||||
})
|
||||
);
|
||||
|
||||
export const formatDurationLong = (
|
||||
locale: FrontendLocaleData,
|
||||
duration: HaDurationData
|
||||
) => formatDurationLongMem(locale)(duration);
|
||||
) => formatDurationLongMem(locale).format(duration);
|
||||
|
||||
const formatDurationNarrowMem = memoizeOne((locale: FrontendLocaleData) =>
|
||||
createDurationFormat(locale.language, {
|
||||
style: "narrow",
|
||||
})
|
||||
const formatDurationNarrowMem = memoizeOne(
|
||||
(locale: FrontendLocaleData) =>
|
||||
new Intl.DurationFormat(locale.language, {
|
||||
style: "narrow",
|
||||
})
|
||||
);
|
||||
|
||||
export const formatDurationNarrow = (
|
||||
locale: FrontendLocaleData,
|
||||
duration: HaDurationData
|
||||
) => formatDurationNarrowMem(locale)(duration);
|
||||
) => formatDurationNarrowMem(locale).format(duration);
|
||||
|
||||
const formatDigitalDurationMem = memoizeOne((locale: FrontendLocaleData) =>
|
||||
createDurationFormat(locale.language, {
|
||||
style: "digital",
|
||||
hoursDisplay: "auto",
|
||||
})
|
||||
const formatDigitalDurationMem = memoizeOne(
|
||||
(locale: FrontendLocaleData) =>
|
||||
new Intl.DurationFormat(locale.language, {
|
||||
style: "digital",
|
||||
hoursDisplay: "auto",
|
||||
})
|
||||
);
|
||||
|
||||
export const formatDurationDigital = (
|
||||
locale: FrontendLocaleData,
|
||||
duration: HaDurationData
|
||||
) => formatDigitalDurationMem(locale)(duration);
|
||||
) => formatDigitalDurationMem(locale).format(duration);
|
||||
|
||||
export const DURATION_UNITS = ["min", "h", "d"] as const;
|
||||
|
||||
type DurationUnit = (typeof DURATION_UNITS)[number];
|
||||
|
||||
const formatDurationDayMem = memoizeOne((locale: FrontendLocaleData) =>
|
||||
createDurationFormat(locale.language, {
|
||||
style: "narrow",
|
||||
daysDisplay: "always",
|
||||
})
|
||||
const formatDurationDayMem = memoizeOne(
|
||||
(locale: FrontendLocaleData) =>
|
||||
new Intl.DurationFormat(locale.language, {
|
||||
style: "narrow",
|
||||
daysDisplay: "always",
|
||||
})
|
||||
);
|
||||
|
||||
const formatDurationHourMem = memoizeOne((locale: FrontendLocaleData) =>
|
||||
createDurationFormat(locale.language, {
|
||||
style: "narrow",
|
||||
hoursDisplay: "always",
|
||||
})
|
||||
const formatDurationHourMem = memoizeOne(
|
||||
(locale: FrontendLocaleData) =>
|
||||
new Intl.DurationFormat(locale.language, {
|
||||
style: "narrow",
|
||||
hoursDisplay: "always",
|
||||
})
|
||||
);
|
||||
|
||||
const formatDurationMinuteMem = memoizeOne((locale: FrontendLocaleData) =>
|
||||
createDurationFormat(locale.language, {
|
||||
style: "narrow",
|
||||
minutesDisplay: "always",
|
||||
})
|
||||
const formatDurationMinuteMem = memoizeOne(
|
||||
(locale: FrontendLocaleData) =>
|
||||
new Intl.DurationFormat(locale.language, {
|
||||
style: "narrow",
|
||||
minutesDisplay: "always",
|
||||
})
|
||||
);
|
||||
|
||||
export const formatDuration = (
|
||||
@@ -228,7 +129,7 @@ export const formatDuration = (
|
||||
days,
|
||||
hours,
|
||||
};
|
||||
return formatDurationDayMem(locale)(input);
|
||||
return formatDurationDayMem(locale).format(input);
|
||||
}
|
||||
case "h": {
|
||||
const hours = Math.floor(value);
|
||||
@@ -237,7 +138,7 @@ export const formatDuration = (
|
||||
hours,
|
||||
minutes,
|
||||
};
|
||||
return formatDurationHourMem(locale)(input);
|
||||
return formatDurationHourMem(locale).format(input);
|
||||
}
|
||||
case "min": {
|
||||
const minutes = Math.floor(value);
|
||||
@@ -246,7 +147,7 @@ export const formatDuration = (
|
||||
minutes,
|
||||
seconds,
|
||||
};
|
||||
return formatDurationMinuteMem(locale)(input);
|
||||
return formatDurationMinuteMem(locale).format(input);
|
||||
}
|
||||
default:
|
||||
throw new Error("Invalid duration unit");
|
||||
|
||||
@@ -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);
|
||||
@@ -1,95 +0,0 @@
|
||||
import type {
|
||||
DataTableFiltersValue,
|
||||
DataTableFiltersValues,
|
||||
} from "../../data/data_table_filters";
|
||||
import { dataTableQueryParamConfig } from "./data-table-query-params";
|
||||
import {
|
||||
decodeQueryParams,
|
||||
hasQueryParams,
|
||||
type QueryParamConfig,
|
||||
type QueryParamValues,
|
||||
type SearchParamsSource,
|
||||
} from "./query-params";
|
||||
|
||||
// The filters of the helpers table. Search, sorting and grouping are kept by
|
||||
// the data table, see data-table-query-params.
|
||||
export const helpersFilterQueryParamConfig = {
|
||||
list: [
|
||||
"floor",
|
||||
"area",
|
||||
"device",
|
||||
"label",
|
||||
"category",
|
||||
"assistants",
|
||||
// Read until 2027.5, replaced with `assistants` on the next write
|
||||
"voice_assistant",
|
||||
],
|
||||
} as const satisfies QueryParamConfig;
|
||||
|
||||
export type HelpersFilterQueryParams = QueryParamValues<
|
||||
typeof helpersFilterQueryParamConfig
|
||||
>;
|
||||
|
||||
const list = (value: DataTableFiltersValue): string[] | undefined =>
|
||||
Array.isArray(value) ? value : undefined;
|
||||
|
||||
const floorAreas = (
|
||||
value: DataTableFiltersValue
|
||||
): Record<string, string[]> | undefined =>
|
||||
value && !Array.isArray(value) ? value : undefined;
|
||||
|
||||
export const helpersFilterQueryParams = (
|
||||
filters: DataTableFiltersValues
|
||||
): HelpersFilterQueryParams => ({
|
||||
floor: floorAreas(filters["ha-filter-floor-areas"])?.floors,
|
||||
area: floorAreas(filters["ha-filter-floor-areas"])?.areas,
|
||||
device: list(filters["ha-filter-devices"]),
|
||||
label: list(filters["ha-filter-labels"]),
|
||||
category: list(filters["ha-filter-categories"]),
|
||||
assistants: list(filters["ha-filter-voice-assistants"]),
|
||||
});
|
||||
|
||||
// What a link says to show, undefined when it has no table params so what was
|
||||
// picked before stays. A link replaces the search and the filters, ones left
|
||||
// behind would hide part of what it shows. The table applies its sorting and
|
||||
// grouping.
|
||||
export interface HelpersLink {
|
||||
search: string;
|
||||
filters: DataTableFiltersValues;
|
||||
}
|
||||
|
||||
export const helpersLinkFromUrl = (
|
||||
searchParams: SearchParamsSource
|
||||
): HelpersLink | undefined => {
|
||||
if (
|
||||
!hasQueryParams(searchParams, helpersFilterQueryParamConfig) &&
|
||||
!hasQueryParams(searchParams, dataTableQueryParamConfig)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const params = decodeQueryParams(searchParams, helpersFilterQueryParamConfig);
|
||||
|
||||
const { search } = decodeQueryParams(searchParams, dataTableQueryParamConfig);
|
||||
|
||||
const values = (key: keyof HelpersFilterQueryParams): string[] =>
|
||||
params[key]?.filter(Boolean) ?? [];
|
||||
|
||||
const floors = values("floor");
|
||||
const areas = values("area");
|
||||
const assistants = values("assistants");
|
||||
|
||||
return {
|
||||
search: search ?? "",
|
||||
filters: {
|
||||
"ha-filter-floor-areas":
|
||||
floors.length || areas.length ? { floors, areas } : undefined,
|
||||
"ha-filter-devices": values("device"),
|
||||
"ha-filter-labels": values("label"),
|
||||
"ha-filter-categories": values("category"),
|
||||
"ha-filter-voice-assistants": assistants.length
|
||||
? assistants
|
||||
: values("voice_assistant"),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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,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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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,
|
||||
};
|
||||
|
||||
@@ -1504,10 +1504,7 @@ export class HaDataTable extends LitElement {
|
||||
}
|
||||
|
||||
.mdc-data-table {
|
||||
background: var(
|
||||
--ha-data-table-background,
|
||||
var(--data-table-background-color)
|
||||
);
|
||||
background-color: var(--data-table-background-color);
|
||||
border-radius: var(--ha-border-radius-sm);
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
@@ -1765,6 +1762,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 */
|
||||
|
||||
@@ -1779,6 +1780,7 @@ export class HaDataTable extends LitElement {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
background-color: var(--primary-background-color);
|
||||
}
|
||||
|
||||
.group-header ha-icon-button {
|
||||
@@ -1837,8 +1839,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;
|
||||
@@ -1861,25 +1863,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);
|
||||
}
|
||||
@@ -1929,10 +1912,7 @@ export class HaDataTable extends LitElement {
|
||||
}
|
||||
|
||||
.mdc-data-table__row:has(.group-header) {
|
||||
background: var(
|
||||
--ha-data-table-background,
|
||||
var(--primary-background-color)
|
||||
);
|
||||
background-color: var(--primary-background-color);
|
||||
}
|
||||
|
||||
.mdc-data-table__table.auto-height .scroller {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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,
|
||||
@@ -54,6 +52,22 @@ import "./ha-markdown";
|
||||
import "./input/ha-input";
|
||||
import type { HaInput } from "./input/ha-input";
|
||||
|
||||
interface AssistMessage {
|
||||
who: string;
|
||||
text: string;
|
||||
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
|
||||
@@ -639,9 +653,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();
|
||||
@@ -733,9 +744,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 +770,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 {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import WaCheckbox from "@home-assistant/webawesome/dist/components/checkbox/checkbox";
|
||||
import { css, type CSSResultGroup } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { customElement } from "lit/decorators";
|
||||
|
||||
/**
|
||||
* Home Assistant checkbox component
|
||||
@@ -40,13 +40,9 @@ import { customElement, property } from "lit/decorators";
|
||||
* @attr {boolean} disabled - Disables the checkbox.
|
||||
* @attr {boolean} indeterminate - Draws the checkbox in an indeterminate state.
|
||||
* @attr {boolean} required - Makes the checkbox a required field.
|
||||
* @attr {("brand"|"neutral"|"danger"|"warning"|"success")} variant - Sets the checked color variant. "brand" is default.
|
||||
*/
|
||||
@customElement("ha-checkbox")
|
||||
export class HaCheckbox extends WaCheckbox {
|
||||
@property({ reflect: true })
|
||||
variant: "brand" | "neutral" | "success" | "warning" | "danger" = "brand";
|
||||
|
||||
/**
|
||||
* Returns the configured checkbox value, independent of checked state.
|
||||
*
|
||||
@@ -82,19 +78,15 @@ export class HaCheckbox extends WaCheckbox {
|
||||
);
|
||||
--checked-icon-color: var(
|
||||
--ha-checkbox-checked-icon-color,
|
||||
var(--checkbox-on-fill-color)
|
||||
var(--wa-color-brand-on-loud)
|
||||
);
|
||||
|
||||
--wa-form-control-activated-color: var(
|
||||
--ha-checkbox-checked-background-color,
|
||||
var(--checkbox-fill-color)
|
||||
var(--ha-color-fill-primary-loud-resting)
|
||||
);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
|
||||
--checkbox-fill-color: var(--ha-color-fill-primary-loud-resting);
|
||||
--checkbox-fill-color-hover: var(--ha-color-fill-primary-loud-hover);
|
||||
--checkbox-on-fill-color: var(--wa-color-brand-on-loud);
|
||||
|
||||
--checked-icon-scale: var(--ha-checkbox-checked-icon-scale, 0.9);
|
||||
--wa-form-control-required-content: var(
|
||||
--ha-checkbox-required-marker,
|
||||
@@ -106,30 +98,6 @@ export class HaCheckbox extends WaCheckbox {
|
||||
);
|
||||
}
|
||||
|
||||
:host([variant="neutral"]) {
|
||||
--checkbox-fill-color: var(--ha-color-fill-neutral-loud-resting);
|
||||
--checkbox-fill-color-hover: var(--ha-color-fill-neutral-loud-hover);
|
||||
--checkbox-on-fill-color: var(--ha-color-on-neutral-loud);
|
||||
}
|
||||
|
||||
:host([variant="success"]) {
|
||||
--checkbox-fill-color: var(--ha-color-fill-success-loud-resting);
|
||||
--checkbox-fill-color-hover: var(--ha-color-fill-success-loud-hover);
|
||||
--checkbox-on-fill-color: var(--ha-color-on-success-loud);
|
||||
}
|
||||
|
||||
:host([variant="warning"]) {
|
||||
--checkbox-fill-color: var(--ha-color-fill-warning-loud-resting);
|
||||
--checkbox-fill-color-hover: var(--ha-color-fill-warning-loud-hover);
|
||||
--checkbox-on-fill-color: var(--ha-color-on-warning-loud);
|
||||
}
|
||||
|
||||
:host([variant="danger"]) {
|
||||
--checkbox-fill-color: var(--ha-color-fill-danger-loud-resting);
|
||||
--checkbox-fill-color-hover: var(--ha-color-fill-danger-loud-hover);
|
||||
--checkbox-on-fill-color: var(--ha-color-on-danger-loud);
|
||||
}
|
||||
|
||||
[part~="base"] {
|
||||
align-items: center;
|
||||
gap: var(--ha-space-2);
|
||||
@@ -190,11 +158,11 @@ export class HaCheckbox extends WaCheckbox {
|
||||
label:has(input:indeterminate:not(:disabled)):hover [part~="control"] {
|
||||
background-color: var(
|
||||
--ha-checkbox-checked-background-color-hover,
|
||||
var(--checkbox-fill-color-hover)
|
||||
var(--ha-color-fill-primary-loud-hover)
|
||||
);
|
||||
border-color: var(
|
||||
--ha-checkbox-checked-background-color-hover,
|
||||
var(--checkbox-fill-color-hover)
|
||||
var(--ha-color-fill-primary-loud-hover)
|
||||
);
|
||||
}
|
||||
`,
|
||||
|
||||
@@ -80,9 +80,6 @@ export class HaControlCircularSlider extends LitElement {
|
||||
@property({ type: String, attribute: "high-label" })
|
||||
public highLabel?: string;
|
||||
|
||||
@property({ attribute: false })
|
||||
public formatValue?: (value: number) => string;
|
||||
|
||||
@property({ type: Number }) public value?: number;
|
||||
|
||||
@property({ type: Number }) public low?: number;
|
||||
@@ -565,22 +562,13 @@ export class HaControlCircularSlider extends LitElement {
|
||||
aria-valuemin=${this.min}
|
||||
aria-valuemax=${this.max}
|
||||
aria-valuenow=${
|
||||
value != null ? this._steppedValue(value) : undefined
|
||||
}
|
||||
aria-valuetext=${ifDefined(
|
||||
value != null && this.formatValue
|
||||
? this.formatValue(this._steppedValue(value))
|
||||
this._localValue != null
|
||||
? this._steppedValue(this._localValue)
|
||||
: undefined
|
||||
)}
|
||||
}
|
||||
aria-disabled=${this.disabled}
|
||||
aria-readonly=${this.readonly}
|
||||
aria-label=${ifDefined(
|
||||
id === "low"
|
||||
? (this.lowLabel ?? this.label)
|
||||
: id === "high"
|
||||
? (this.highLabel ?? this.label)
|
||||
: this.label
|
||||
)}
|
||||
aria-label=${ifDefined(this.lowLabel ?? this.label)}
|
||||
@keydown=${this._handleKeyDown}
|
||||
@keyup=${this._handleKeyUp}
|
||||
/>
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import { ResizeController } from "@lit-labs/observers/resize-controller";
|
||||
import type { ContextType } from "@lit/context";
|
||||
import { mdiMinus, mdiPlus } from "@mdi/js";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { consume } from "../common/decorators/consume";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { conditionalClamp } from "../common/number/clamp";
|
||||
import { formatNumber } from "../common/number/format_number";
|
||||
import { blankBeforeUnit } from "../common/translations/blank_before_unit";
|
||||
import { internationalizationContext } from "../data/context";
|
||||
import type { FrontendLocaleData } from "../data/translation";
|
||||
import "./ha-svg-icon";
|
||||
|
||||
@@ -48,10 +45,6 @@ export class HaControlNumberButton extends LitElement {
|
||||
|
||||
@query("#input") _input!: HTMLDivElement;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n?: ContextType<typeof internationalizationContext>;
|
||||
|
||||
private _hideUnit = new ResizeController(this, {
|
||||
callback: (entries) => {
|
||||
const width = entries[0]?.contentRect.width;
|
||||
@@ -167,9 +160,7 @@ export class HaControlNumberButton extends LitElement {
|
||||
class="button minus"
|
||||
type="button"
|
||||
tabindex="-1"
|
||||
aria-label=${ifDefined(
|
||||
this._i18n?.localize("ui.components.number-buttons.decrement")
|
||||
)}
|
||||
aria-label="decrement"
|
||||
@click=${this._handleMinusButton}
|
||||
.disabled=${
|
||||
this.disabled || (this.min != null && this._value <= this.min)
|
||||
@@ -181,9 +172,7 @@ export class HaControlNumberButton extends LitElement {
|
||||
class="button plus"
|
||||
type="button"
|
||||
tabindex="-1"
|
||||
aria-label=${ifDefined(
|
||||
this._i18n?.localize("ui.components.number-buttons.increment")
|
||||
)}
|
||||
aria-label="increment"
|
||||
@click=${this._handlePlusButton}
|
||||
.disabled=${
|
||||
this.disabled || (this.max != null && this._value >= this.max)
|
||||
|
||||
@@ -71,12 +71,11 @@ export class HaControlSelectMenu extends LitElement {
|
||||
>
|
||||
${this._renderIcon()}
|
||||
<div class="content">
|
||||
<p
|
||||
id="label"
|
||||
class="label ${classMap({ "visually-hidden": this.hideLabel })}"
|
||||
>
|
||||
${this.label}
|
||||
</p>
|
||||
${
|
||||
this.hideLabel
|
||||
? nothing
|
||||
: html`<p id="label" class="label">${this.label}</p>`
|
||||
}
|
||||
${selectedText ? html`<p class="value">${selectedText}</p>` : nothing}
|
||||
</div>
|
||||
${this._renderArrow()}
|
||||
@@ -236,17 +235,6 @@ export class HaControlSelectMenu extends LitElement {
|
||||
font-size: var(--ha-font-size-m);
|
||||
}
|
||||
|
||||
.content .visually-hidden {
|
||||
position: absolute;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
height: 1px;
|
||||
width: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.select-anchor:focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--control-select-menu-focus-color);
|
||||
}
|
||||
|
||||
@@ -346,8 +346,7 @@ export class HaControlSelect extends LitElement {
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: clip;
|
||||
unicode-bidi: plaintext;
|
||||
text-overflow: ellipsis;
|
||||
hyphens: manual;
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
}
|
||||
|
||||
@@ -16,8 +16,8 @@ import { customElement } from "lit/decorators";
|
||||
*
|
||||
* @remarks
|
||||
* **Button Styling Guidance:**
|
||||
* - `primaryAction` slot: Use the default `appearance="accent"`
|
||||
* - `secondaryAction` slot: Use `appearance="plain"`
|
||||
* - `primaryAction` slot: Use `variant="accent"`
|
||||
* - `secondaryAction` slot: Use `variant="plain"`
|
||||
*/
|
||||
@customElement("ha-dialog-footer")
|
||||
export class HaDialogFooter extends LitElement {
|
||||
|
||||
@@ -1,266 +0,0 @@
|
||||
import type { ContextType } from "@lit/context";
|
||||
import {
|
||||
mdiDotsHorizontal,
|
||||
mdiFilterVariantRemove,
|
||||
mdiPuzzle,
|
||||
mdiRobot,
|
||||
mdiScriptText,
|
||||
} from "@mdi/js";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { createRef, ref } from "lit/directives/ref";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import memoizeOne from "memoize-one";
|
||||
import {
|
||||
FilterPanelController,
|
||||
filterPanelStyles,
|
||||
} from "../common/controllers/filter-panel-controller";
|
||||
import { consume } from "../common/decorators/consume";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { computeStateDomain } from "../common/entity/compute_state_domain";
|
||||
import { stringCompare } from "../common/string/compare";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import {
|
||||
formattersContext,
|
||||
internationalizationContext,
|
||||
statesContext,
|
||||
} from "../data/context";
|
||||
import type { LogbookCauseGroup } from "../data/logbook";
|
||||
import { LOGBOOK_CAUSE_GROUPS, logbookUserCauseKey } from "../data/logbook";
|
||||
import type { User } from "../data/user";
|
||||
import "./ha-expansion-panel";
|
||||
import "./ha-icon-button";
|
||||
import "./ha-svg-icon";
|
||||
import "./item/ha-list-item-option";
|
||||
import type { HaListItemOption } from "./item/ha-list-item-option";
|
||||
import "./list/ha-list-selectable";
|
||||
import type { HaListSelectable } from "./list/ha-list-selectable";
|
||||
import "./user/ha-user-badge";
|
||||
|
||||
interface PersonUser {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const GROUP_ICONS: Record<LogbookCauseGroup, string> = {
|
||||
automation: mdiRobot,
|
||||
script: mdiScriptText,
|
||||
integration: mdiPuzzle,
|
||||
other: mdiDotsHorizontal,
|
||||
};
|
||||
|
||||
@customElement("ha-filter-logbook-causes")
|
||||
export class HaFilterLogbookCauses extends LitElement {
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
@state()
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@consume({ context: statesContext, subscribe: true })
|
||||
@state()
|
||||
private _states!: ContextType<typeof statesContext>;
|
||||
|
||||
@consume({ context: formattersContext, subscribe: true })
|
||||
@state()
|
||||
private _formatters!: ContextType<typeof formattersContext>;
|
||||
|
||||
@property({ attribute: false }) public value?: string[];
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public expanded = false;
|
||||
|
||||
@query("ha-list-selectable") private _list?: HaListSelectable;
|
||||
|
||||
private _content = createRef<HTMLElement>();
|
||||
|
||||
private _panel = new FilterPanelController(this, this._content);
|
||||
|
||||
protected render() {
|
||||
const count = this.value?.length ?? 0;
|
||||
|
||||
return html`
|
||||
<ha-expansion-panel
|
||||
left-chevron
|
||||
.expanded=${this.expanded}
|
||||
@expanded-changed=${this._expandedChanged}
|
||||
>
|
||||
<div slot="header" class="header">
|
||||
${this._localize("ui.components.logbook.cause_filter.caption")}
|
||||
${
|
||||
count
|
||||
? html`<div class="badge">${count}</div>
|
||||
<ha-icon-button
|
||||
.path=${mdiFilterVariantRemove}
|
||||
.label=${this._localize(
|
||||
"ui.components.subpage-data-table.clear_filter"
|
||||
)}
|
||||
@click=${this._clearFilter}
|
||||
></ha-icon-button>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
</ha-expansion-panel>
|
||||
${this._panel.showContent ? this._renderContent() : nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderContent() {
|
||||
const users = this._personUsers(
|
||||
this._states,
|
||||
this._formatters,
|
||||
this._i18n.locale.language
|
||||
);
|
||||
const value = this.value ?? [];
|
||||
|
||||
return html`<div class="content" ${ref(this._content)}>
|
||||
<ha-list-selectable
|
||||
multi
|
||||
controlled
|
||||
aria-label=${this._localize("ui.components.logbook.cause_filter.caption")}
|
||||
@ha-list-item-selected=${this._handleItemToggled}
|
||||
@ha-list-item-deselected=${this._handleItemToggled}
|
||||
>
|
||||
${repeat(
|
||||
users,
|
||||
(user) => user.id,
|
||||
(user) => {
|
||||
const key = logbookUserCauseKey(user.id);
|
||||
return html`<ha-list-item-option
|
||||
appearance="checkbox"
|
||||
selection-position="end"
|
||||
.value=${key}
|
||||
.selected=${value.includes(key)}
|
||||
>
|
||||
<ha-user-badge slot="start" .user=${user as User}></ha-user-badge>
|
||||
<span slot="headline">${user.name}</span>
|
||||
</ha-list-item-option>`;
|
||||
}
|
||||
)}
|
||||
${LOGBOOK_CAUSE_GROUPS.map(
|
||||
(group) =>
|
||||
html`<ha-list-item-option
|
||||
appearance="checkbox"
|
||||
selection-position="end"
|
||||
.value=${group}
|
||||
.selected=${value.includes(group)}
|
||||
>
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${GROUP_ICONS[group]}
|
||||
></ha-svg-icon>
|
||||
<span slot="headline"
|
||||
>${this._localize(
|
||||
`ui.components.logbook.cause_filter.${group}`
|
||||
)}</span
|
||||
>
|
||||
</ha-list-item-option>`
|
||||
)}
|
||||
</ha-list-selectable>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private _personUsers = memoizeOne(
|
||||
(
|
||||
states: ContextType<typeof statesContext>,
|
||||
formatters: ContextType<typeof formattersContext>,
|
||||
language: string | undefined
|
||||
): PersonUser[] =>
|
||||
Object.values(states)
|
||||
.filter(
|
||||
(stateObj) =>
|
||||
computeStateDomain(stateObj) === "person" &&
|
||||
stateObj.attributes.user_id
|
||||
)
|
||||
.map((stateObj) => ({
|
||||
id: stateObj.attributes.user_id as string,
|
||||
name: formatters.formatEntityName(stateObj),
|
||||
}))
|
||||
.sort((a, b) => stringCompare(a.name, b.name, language))
|
||||
);
|
||||
|
||||
private _expandedChanged(ev) {
|
||||
this.expanded = ev.detail.expanded;
|
||||
}
|
||||
|
||||
private _handleItemToggled(ev: CustomEvent<number>) {
|
||||
const option = this._list?.items[ev.detail] as HaListItemOption | undefined;
|
||||
const key = option?.value;
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
const value = new Set(this.value ?? []);
|
||||
if (!value.delete(key)) {
|
||||
value.add(key);
|
||||
}
|
||||
this.value = [...value];
|
||||
fireEvent(this, "data-table-filter-changed", {
|
||||
value: this.value.length ? this.value : undefined,
|
||||
items: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
private _clearFilter(ev: Event) {
|
||||
ev.preventDefault();
|
||||
this.value = undefined;
|
||||
fireEvent(this, "data-table-filter-changed", {
|
||||
value: undefined,
|
||||
items: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
filterPanelStyles,
|
||||
css`
|
||||
ha-list-selectable {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
ha-list-selectable::part(base) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.header ha-icon-button {
|
||||
margin-inline-start: auto;
|
||||
margin-inline-end: 8px;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
margin-inline-start: 8px;
|
||||
margin-inline-end: initial;
|
||||
min-width: 16px;
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
font-size: var(--ha-font-size-xs);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
background-color: var(--primary-color);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
text-align: center;
|
||||
padding: 0px 2px;
|
||||
color: var(--text-primary-color);
|
||||
}
|
||||
ha-user-badge {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font-size: var(--ha-font-size-xs);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-filter-logbook-causes": HaFilterLogbookCauses;
|
||||
}
|
||||
}
|
||||
@@ -162,6 +162,7 @@ export class HaFilterPane extends LitElement {
|
||||
height: 56px;
|
||||
flex-shrink: 0;
|
||||
padding: 0 16px;
|
||||
background: var(--primary-background-color);
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
|
||||
@@ -106,8 +106,7 @@ export class HaForm extends LitElement implements HaFormElement {
|
||||
) => string | undefined;
|
||||
|
||||
@property({ attribute: false }) public localizeValue?: (
|
||||
key: string,
|
||||
values?: Record<string, string>
|
||||
key: string
|
||||
) => string;
|
||||
|
||||
@property({ attribute: false }) public context?: Record<string, any>;
|
||||
|
||||
@@ -43,8 +43,7 @@ export class HaObjectSelector extends LitElement {
|
||||
@property({ type: Boolean }) public required = true;
|
||||
|
||||
@property({ attribute: false }) public localizeValue?: (
|
||||
key: string,
|
||||
values?: Record<string, string>
|
||||
key: string
|
||||
) => string;
|
||||
|
||||
@query("ha-yaml-editor", true) private _yamlEditor?: HaYamlEditor;
|
||||
@@ -80,45 +79,37 @@ export class HaObjectSelector extends LitElement {
|
||||
return this.selector.object?.fields?.[schema.name]?.description || "";
|
||||
};
|
||||
|
||||
private _formatField(item: any, field: string): string {
|
||||
const fields = this.selector.object!.fields!;
|
||||
const value = formatSelectorValue(
|
||||
this.hass,
|
||||
item[field],
|
||||
fields[field]?.selector
|
||||
);
|
||||
const translationKey = this.selector.object!.translation_key;
|
||||
if (!value || !this.localizeValue || !translationKey) {
|
||||
return value;
|
||||
}
|
||||
return (
|
||||
this.localizeValue(`${translationKey}.fields.${field}.overview_label`, {
|
||||
value,
|
||||
}) || value
|
||||
);
|
||||
}
|
||||
|
||||
private _renderItem(item: any, index: number) {
|
||||
const fields = this.selector.object!.fields!;
|
||||
const preferredLabel = this.selector.object!.label_field;
|
||||
const hasValidLabelField = preferredLabel && preferredLabel in fields;
|
||||
|
||||
const label = hasValidLabelField
|
||||
? this._formatField(item, preferredLabel!)
|
||||
: Object.keys(fields)
|
||||
.map((key) => this._formatField(item, key))
|
||||
? formatSelectorValue(
|
||||
this.hass,
|
||||
item[preferredLabel!],
|
||||
fields[preferredLabel!]?.selector
|
||||
)
|
||||
: Object.entries(fields)
|
||||
.map(([key, field]) =>
|
||||
formatSelectorValue(this.hass, item[key], field.selector)
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
|
||||
let description = "";
|
||||
|
||||
const descriptionField = this.selector.object!.description_field;
|
||||
if (
|
||||
descriptionField &&
|
||||
descriptionField in fields &&
|
||||
fields[descriptionField]?.selector
|
||||
) {
|
||||
description = this._formatField(item, descriptionField);
|
||||
if (descriptionField && descriptionField in fields) {
|
||||
const descriptionSelector = fields[descriptionField]?.selector;
|
||||
|
||||
description = descriptionSelector
|
||||
? formatSelectorValue(
|
||||
this.hass,
|
||||
item[descriptionField],
|
||||
descriptionSelector
|
||||
)
|
||||
: "";
|
||||
}
|
||||
|
||||
const reorderable = this.selector.object!.multiple || false;
|
||||
|
||||
@@ -93,10 +93,7 @@ export class HaSelector extends LitElement {
|
||||
@property() public helper?: string;
|
||||
|
||||
@property({ attribute: false })
|
||||
public localizeValue?: (
|
||||
key: string,
|
||||
values?: Record<string, string>
|
||||
) => string;
|
||||
public localizeValue?: (key: string) => string;
|
||||
|
||||
@property() public placeholder?: any;
|
||||
|
||||
|
||||
@@ -773,16 +773,12 @@ export class HaServiceControl extends LitElement {
|
||||
filter_entity: targetEntities || undefined,
|
||||
}));
|
||||
|
||||
private _localizeValueCallback = (
|
||||
key: string,
|
||||
values?: Record<string, string>
|
||||
) => {
|
||||
private _localizeValueCallback = (key: string) => {
|
||||
if (!this._value?.action) {
|
||||
return "";
|
||||
}
|
||||
return this.hass.localize(
|
||||
`component.${computeDomain(this._value.action)}.selector.${key}`,
|
||||
values
|
||||
`component.${computeDomain(this._value.action)}.selector.${key}`
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { ensureArray } from "../common/array/ensure-array";
|
||||
@@ -10,6 +10,8 @@ import type { HaEntityPickerEntityFilterFunc } from "../data/entity/entity";
|
||||
import { entityTypeFilterFunc } from "../data/entity/entity_type";
|
||||
import type { EntitySources } from "../data/entity/entity_sources";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import "./ha-filter-entity-types";
|
||||
import "./ha-filter-integrations";
|
||||
import "./ha-target-picker";
|
||||
|
||||
/**
|
||||
@@ -37,16 +39,6 @@ export const countTargets = (target: HassServiceTarget): number =>
|
||||
0
|
||||
);
|
||||
|
||||
/** Sets one filter from the value of its `data-table-filter-changed` event. */
|
||||
export const setSourceFilter = <T extends SourceFilters>(
|
||||
filters: T,
|
||||
key: keyof T,
|
||||
value: DataTableFiltersValue
|
||||
): T => ({
|
||||
...filters,
|
||||
[key]: Array.isArray(value) && value.length ? value : undefined,
|
||||
});
|
||||
|
||||
/** Number of filters that have at least one option selected. */
|
||||
export const countSourceFilters = (filters: SourceFilters): number =>
|
||||
Object.values(filters).filter((value) => value?.length).length;
|
||||
@@ -100,8 +92,8 @@ export const applySourceFilters = (
|
||||
};
|
||||
|
||||
/**
|
||||
* Picks the targets a page shows. Meant to be placed in an `ha-filter-pane`,
|
||||
* above the filters the page narrows them down with.
|
||||
* Picker for what a page shows: the targets to include, narrowed down by
|
||||
* entity type and integration. Meant to be placed in an `ha-filter-pane`.
|
||||
*
|
||||
* The pages resolve every entity of a target, secondary ones included, so the
|
||||
* target picker counts them too.
|
||||
@@ -112,7 +104,6 @@ export class HaSourcesPicker extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public value: HassServiceTarget = {};
|
||||
|
||||
/** The filters the page applies, so the picker counts what they keep. */
|
||||
@property({ attribute: false }) public filters: SourceFilters = {};
|
||||
|
||||
@property({ attribute: false })
|
||||
@@ -125,6 +116,10 @@ export class HaSourcesPicker extends LitElement {
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
// Only one filter panel is expanded at a time, so that the expanded one can
|
||||
// use the height that is left in the pane.
|
||||
@state() private _expandedFilter?: keyof SourceFilters;
|
||||
|
||||
protected render() {
|
||||
const noTargets = countTargets(this.value) === 0;
|
||||
|
||||
@@ -149,16 +144,72 @@ export class HaSourcesPicker extends LitElement {
|
||||
.disabled=${this.disabled}
|
||||
@value-changed=${this._targetsChanged}
|
||||
></ha-target-picker>
|
||||
<div
|
||||
class=${classMap({ filters: true, expanded: !!this._expandedFilter })}
|
||||
>
|
||||
<ha-filter-entity-types
|
||||
.value=${this.filters.types}
|
||||
.expanded=${this._expandedFilter === "types"}
|
||||
@data-table-filter-changed=${this._typesChanged}
|
||||
@expanded-changed=${this._typesExpanded}
|
||||
></ha-filter-entity-types>
|
||||
<ha-filter-integrations
|
||||
.value=${this.filters.integrations}
|
||||
.expanded=${this._expandedFilter === "integrations"}
|
||||
@data-table-filter-changed=${this._integrationsChanged}
|
||||
@expanded-changed=${this._integrationsExpanded}
|
||||
></ha-filter-integrations>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _activeFilter = memoizeOne(sourceFilterFunc);
|
||||
|
||||
protected firstUpdated() {
|
||||
// The filter panels label themselves with keys from the config panel.
|
||||
this.hass.loadFragmentTranslation("config");
|
||||
}
|
||||
|
||||
private _targetsChanged(ev: CustomEvent) {
|
||||
ev.stopPropagation();
|
||||
fireEvent(this, "value-changed", { value: ev.detail.value || {} });
|
||||
}
|
||||
|
||||
private _typesChanged(ev: CustomEvent) {
|
||||
this._filterChanged("types", ev);
|
||||
}
|
||||
|
||||
private _integrationsChanged(ev: CustomEvent) {
|
||||
this._filterChanged("integrations", ev);
|
||||
}
|
||||
|
||||
private _filterChanged(key: keyof SourceFilters, ev: CustomEvent) {
|
||||
ev.stopPropagation();
|
||||
const value = ev.detail.value as DataTableFiltersValue;
|
||||
fireEvent(this, "source-filters-changed", {
|
||||
value: {
|
||||
...this.filters,
|
||||
[key]: Array.isArray(value) && value.length ? value : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _typesExpanded(ev: CustomEvent) {
|
||||
this._filterExpanded("types", ev);
|
||||
}
|
||||
|
||||
private _integrationsExpanded(ev: CustomEvent) {
|
||||
this._filterExpanded("integrations", ev);
|
||||
}
|
||||
|
||||
private _filterExpanded(key: keyof SourceFilters, ev: CustomEvent) {
|
||||
if (ev.detail.expanded) {
|
||||
this._expandedFilter = key;
|
||||
} else if (this._expandedFilter === key) {
|
||||
this._expandedFilter = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
/* The sections are laid out by the pane, so that an expanded filter
|
||||
panel can use the height that is left. */
|
||||
@@ -190,28 +241,28 @@ export class HaSourcesPicker extends LitElement {
|
||||
ha-target-picker.no-padding-top {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 0 auto;
|
||||
border-top: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
/* An expanded panel sizes itself to the space that is left over, but
|
||||
keeps room for its list when the targets fill the pane. */
|
||||
.filters.expanded {
|
||||
flex: 1 1 auto;
|
||||
min-height: min(360px, 100%);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
/** Lays out the filters that follow the picker in the pane. */
|
||||
export const sourceFiltersStyles = css`
|
||||
.filters {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 0 auto;
|
||||
border-top: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
/* An expanded panel sizes itself to the space that is left over, but
|
||||
keeps room for its list when the targets fill the pane. */
|
||||
.filters.expanded {
|
||||
flex: 1 1 auto;
|
||||
min-height: min(360px, 100%);
|
||||
}
|
||||
`;
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-sources-picker": HaSourcesPicker;
|
||||
}
|
||||
interface HASSDomEvents {
|
||||
"source-filters-changed": { value: SourceFilters };
|
||||
}
|
||||
}
|
||||
@@ -17,10 +17,7 @@ import { UNIT_KM } from "../../common/const";
|
||||
import { transform } from "../../common/decorators/transform";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { computeStateDomain } from "../../common/entity/compute_state_domain";
|
||||
import {
|
||||
findFirstActiveZone,
|
||||
getEntityLocation,
|
||||
} from "../../common/entity/get_entity_location";
|
||||
import { getEntityLocation } from "../../common/entity/get_entity_location";
|
||||
import { supportsVectorMaps } from "../../common/map/base-layer";
|
||||
import type {
|
||||
MapCircleHandle,
|
||||
@@ -69,7 +66,6 @@ import {
|
||||
statesContext,
|
||||
uiContext,
|
||||
} from "../../data/context";
|
||||
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
|
||||
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
|
||||
import { ensureMapTilesToken } from "../../data/map_tiles";
|
||||
import "../ha-tooltip";
|
||||
@@ -82,6 +78,7 @@ import type {
|
||||
ThemeMode,
|
||||
} from "../../types";
|
||||
import { FLOATING_LIFT, floatingMarkerFootprint } from "./ha-entity-marker";
|
||||
import { computeZoneTrackerState } from "../../data/zone";
|
||||
|
||||
declare global {
|
||||
// for fire event
|
||||
@@ -531,19 +528,8 @@ export class HaMap extends ReactiveElement {
|
||||
} else if (this._loaded && oldStates && this.entities) {
|
||||
// Check if any state has changed
|
||||
for (const entity of this.entities) {
|
||||
const entityId = getEntityId(entity);
|
||||
const stateObj = this._states[entityId];
|
||||
const inZones: string[] | undefined = stateObj?.attributes.in_zones;
|
||||
if (
|
||||
oldStates[entityId] !== stateObj ||
|
||||
(stateObj &&
|
||||
["person", "device_tracker"].includes(
|
||||
computeStateDomain(stateObj)
|
||||
) &&
|
||||
Array.isArray(inZones) &&
|
||||
inZones.some(
|
||||
(zoneId) => oldStates[zoneId] !== this._states[zoneId]
|
||||
))
|
||||
oldStates[getEntityId(entity)] !== this._states[getEntityId(entity)]
|
||||
) {
|
||||
this._drawEntities();
|
||||
autoFitRequired = !this._pauseAutoFit;
|
||||
@@ -1361,6 +1347,8 @@ export class HaMap extends ReactiveElement {
|
||||
this._watchRegistry();
|
||||
|
||||
const computedStyles = getComputedStyle(this);
|
||||
// A person's state is "home" for the home zone, the zone name otherwise
|
||||
const zoneByState: Record<string, string> = {};
|
||||
this._zonePositions = {};
|
||||
this._zoneRadii = {};
|
||||
for (const entity of this.entities) {
|
||||
@@ -1371,6 +1359,7 @@ export class HaMap extends ReactiveElement {
|
||||
computeStateDomain(stateObj) === "zone" &&
|
||||
(this.renderPassive || !stateObj.attributes.passive)
|
||||
) {
|
||||
zoneByState[computeZoneTrackerState(stateObj)] = stateObj.entity_id;
|
||||
if (
|
||||
typeof stateObj.attributes.latitude === "number" &&
|
||||
typeof stateObj.attributes.longitude === "number"
|
||||
@@ -1529,22 +1518,6 @@ export class HaMap extends ReactiveElement {
|
||||
typeof entity !== "string" && (entity.selected ?? false);
|
||||
entityMarker.floating = true;
|
||||
|
||||
let zoneId: string | undefined;
|
||||
if (["person", "device_tracker"].includes(computeStateDomain(stateObj))) {
|
||||
const inZones: string[] | undefined = stateObj.attributes.in_zones;
|
||||
if (Array.isArray(inZones) && inZones.length > 0) {
|
||||
const activeZone = findFirstActiveZone(inZones, states);
|
||||
if (activeZone && this._zonePositions[activeZone.entity_id]) {
|
||||
zoneId = activeZone.entity_id;
|
||||
}
|
||||
} else if (
|
||||
!Array.isArray(inZones) ||
|
||||
!["not_home", UNKNOWN, UNAVAILABLE].includes(stateObj.state)
|
||||
) {
|
||||
zoneId = this._zoneContaining(position, gpsAccuracy);
|
||||
}
|
||||
}
|
||||
|
||||
const clusterData: ClusterData = {
|
||||
entityId,
|
||||
title,
|
||||
@@ -1554,7 +1527,11 @@ export class HaMap extends ReactiveElement {
|
||||
unit: entityMarker.entityUnit ?? "",
|
||||
color: entityColor,
|
||||
selected: entityMarker.selected,
|
||||
zoneId,
|
||||
zoneId: ["person", "device_tracker"].includes(
|
||||
computeStateDomain(stateObj)
|
||||
)
|
||||
? (zoneByState[stateObj.state] ?? this._zoneContaining(position))
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const accuracy =
|
||||
@@ -1614,26 +1591,16 @@ export class HaMap extends ReactiveElement {
|
||||
);
|
||||
}
|
||||
|
||||
private _zoneContaining(
|
||||
position: MapLatLng,
|
||||
gpsAccuracy = 0
|
||||
): string | undefined {
|
||||
private _zoneContaining(position: MapLatLng): string | undefined {
|
||||
let found: string | undefined;
|
||||
let foundRadius = Infinity;
|
||||
let foundDistance = Infinity;
|
||||
for (const [zoneId, radius] of Object.entries(this._zoneRadii)) {
|
||||
if (this._states[zoneId]?.attributes.passive) {
|
||||
continue;
|
||||
}
|
||||
const distance = distanceMeters(position, this._zonePositions[zoneId]);
|
||||
if (
|
||||
distance - gpsAccuracy < radius &&
|
||||
(radius < foundRadius ||
|
||||
(radius === foundRadius && distance < foundDistance))
|
||||
radius < foundRadius &&
|
||||
distanceMeters(position, this._zonePositions[zoneId]) <= radius
|
||||
) {
|
||||
found = zoneId;
|
||||
foundRadius = radius;
|
||||
foundDistance = distance;
|
||||
}
|
||||
}
|
||||
return found;
|
||||
|
||||
@@ -28,12 +28,7 @@ import type {
|
||||
TraceExtended,
|
||||
} from "../../data/trace";
|
||||
import type { TargetSelector } from "../../data/selector";
|
||||
import {
|
||||
getDataFromPath,
|
||||
getNestedStepConfig,
|
||||
getTracedEntityId,
|
||||
isTriggerPath,
|
||||
} from "../../data/trace";
|
||||
import { getDataFromPath, isTriggerPath } from "../../data/trace";
|
||||
import { getTraceTriggers } from "../../data/trace-tree";
|
||||
import type { TriggerDescriptions } from "../../data/trigger";
|
||||
import { getDeviceTarget } from "../../panels/config/automation/target/get_device_target";
|
||||
@@ -225,12 +220,20 @@ export class HaTracePathDetails extends LitElement {
|
||||
const data: ActionTraceStep[] = paths[curPath];
|
||||
|
||||
// Extract details from this.selected.config child properties used to add 'alias' (to headline), describeCondition and 'entity_id' (to result)
|
||||
const nestedPath = curPath.substring(this.selected.path.length + 1);
|
||||
const currentDetail = getNestedStepConfig(
|
||||
this.selected.config,
|
||||
nestedPath
|
||||
);
|
||||
const tracedEntityId = getTracedEntityId(currentDetail, nestedPath);
|
||||
const nestPath = curPath
|
||||
.substring(this.selected.path.length + 1)
|
||||
.split("/");
|
||||
let currentDetail: unknown = this.selected.config;
|
||||
for (const part of nestPath) {
|
||||
if (typeof currentDetail !== "object" || currentDetail === null) {
|
||||
break;
|
||||
}
|
||||
const child = (currentDetail as Record<string, unknown>)[part];
|
||||
if (child === undefined || typeof child === "string") {
|
||||
break;
|
||||
}
|
||||
currentDetail = child;
|
||||
}
|
||||
|
||||
parts.push(
|
||||
data.map((trace, idx) => {
|
||||
@@ -325,8 +328,14 @@ export class HaTracePathDetails extends LitElement {
|
||||
: html`<pre>${dump(rest)}</pre>`
|
||||
}
|
||||
${
|
||||
tracedEntityId
|
||||
? html`<pre>entity: ${tracedEntityId}</pre>`
|
||||
typeof currentDetail === "object" &&
|
||||
currentDetail !== null &&
|
||||
"entity_id" in currentDetail &&
|
||||
currentDetail.entity_id &&
|
||||
curPath
|
||||
.substring(this.selected.path.length + 1)
|
||||
.includes("entity_id")
|
||||
? html`<pre>entity: ${currentDetail.entity_id}</pre>`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
@@ -667,10 +676,7 @@ export class HaTracePathDetails extends LitElement {
|
||||
}
|
||||
|
||||
ha-tab-group {
|
||||
background: var(
|
||||
--ha-panel-toolbar-background,
|
||||
var(--ha-panel-background, var(--primary-background-color))
|
||||
);
|
||||
background-color: var(--primary-background-color);
|
||||
border-top: 1px solid var(--divider-color);
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
import type {
|
||||
ConversationChatLogAssistantDelta,
|
||||
ConversationChatLogToolResultDelta,
|
||||
PipelineRunEvent,
|
||||
} from "./assist_pipeline";
|
||||
import type { ChatLogToolResult } from "./chat_log";
|
||||
|
||||
export interface AssistMessage {
|
||||
who: string;
|
||||
text: string;
|
||||
thinking: string;
|
||||
thinking_expanded?: boolean;
|
||||
tool_calls: Record<
|
||||
string,
|
||||
{
|
||||
tool_name: string;
|
||||
tool_args: Record<string, unknown>;
|
||||
result?: ChatLogToolResult;
|
||||
}
|
||||
>;
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
export interface AssistMessageProcessor {
|
||||
readonly continueConversation: boolean;
|
||||
readonly hassMessage: AssistMessage;
|
||||
addMessage: () => void;
|
||||
setError: (error: string) => void;
|
||||
processEvent: (event: PipelineRunEvent) => void;
|
||||
}
|
||||
|
||||
type ChatLogDelta =
|
||||
| Partial<ConversationChatLogAssistantDelta>
|
||||
| ConversationChatLogToolResultDelta;
|
||||
|
||||
const STREAMING_ELLIPSIS = "…";
|
||||
|
||||
const stripStreamingEllipsis = (text: string): string =>
|
||||
text.endsWith(STREAMING_ELLIPSIS) ? text.slice(0, -1) : text;
|
||||
|
||||
const joinWithBreak = (previous: string, next: string): string => {
|
||||
if (previous === "" || next === "") {
|
||||
return previous + next;
|
||||
}
|
||||
if (
|
||||
/\r?\n\r?\n$/.test(previous) ||
|
||||
/^\r?\n\r?\n/.test(next) ||
|
||||
(/\r?\n$/.test(previous) && /^\r?\n/.test(next))
|
||||
) {
|
||||
return previous + next;
|
||||
}
|
||||
return /\r?\n$/.test(previous) || /^\r?\n/.test(next)
|
||||
? `${previous}\n${next}`
|
||||
: `${previous}\n\n${next}`;
|
||||
};
|
||||
|
||||
const newAssistantMessage = (): AssistMessage => ({
|
||||
who: "hass",
|
||||
text: STREAMING_ELLIPSIS,
|
||||
thinking: "",
|
||||
tool_calls: {},
|
||||
error: false,
|
||||
});
|
||||
|
||||
export const createAssistMessageProcessor = ({
|
||||
addMessage,
|
||||
requestUpdate,
|
||||
}: {
|
||||
addMessage: (message: AssistMessage) => void;
|
||||
requestUpdate: () => void;
|
||||
}): AssistMessageProcessor => {
|
||||
let currentDeltaRole = "";
|
||||
// The role and the content of a new message can arrive in separate deltas.
|
||||
let pendingMessageBoundary = false;
|
||||
// Once finalized, a trailing ellipsis is part of the reply and is kept.
|
||||
let streamingMarker = true;
|
||||
let latestSegment = "";
|
||||
let continueConversation = false;
|
||||
let hassMessage: AssistMessage = newAssistantMessage();
|
||||
|
||||
const isMessageEmpty = (message: AssistMessage) =>
|
||||
streamingMarker &&
|
||||
message.text === STREAMING_ELLIPSIS &&
|
||||
!message.thinking &&
|
||||
Object.keys(message.tool_calls).length === 0;
|
||||
|
||||
const finalizeStreamingText = () => {
|
||||
if (streamingMarker) {
|
||||
hassMessage.text = stripStreamingEllipsis(hassMessage.text);
|
||||
}
|
||||
streamingMarker = false;
|
||||
};
|
||||
|
||||
const progressToNextMessage = () => {
|
||||
if (isMessageEmpty(hassMessage)) {
|
||||
return;
|
||||
}
|
||||
finalizeStreamingText();
|
||||
hassMessage = newAssistantMessage();
|
||||
streamingMarker = true;
|
||||
latestSegment = "";
|
||||
addMessage(hassMessage);
|
||||
};
|
||||
|
||||
const isAssistantDelta = (
|
||||
_delta: ChatLogDelta
|
||||
): _delta is Partial<ConversationChatLogAssistantDelta> =>
|
||||
currentDeltaRole === "assistant";
|
||||
|
||||
const isToolResultDelta = (
|
||||
_delta: ChatLogDelta
|
||||
): _delta is ConversationChatLogToolResultDelta =>
|
||||
currentDeltaRole === "tool_result";
|
||||
|
||||
// The response speech only contains the final assistant message, so merge
|
||||
// it into the streamed text instead of replacing it.
|
||||
const applyFinalResponse = (response: string) => {
|
||||
finalizeStreamingText();
|
||||
const streamed = hassMessage.text;
|
||||
if (streamed === "") {
|
||||
hassMessage.text = response;
|
||||
} else if (latestSegment.trim() !== response.trim()) {
|
||||
hassMessage.text = joinWithBreak(streamed, response);
|
||||
}
|
||||
};
|
||||
|
||||
const setError = (error: string) => {
|
||||
progressToNextMessage();
|
||||
hassMessage.text = error;
|
||||
hassMessage.error = true;
|
||||
streamingMarker = false;
|
||||
requestUpdate();
|
||||
};
|
||||
|
||||
const 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 (delta.role === "assistant") {
|
||||
pendingMessageBoundary = true;
|
||||
latestSegment = "";
|
||||
}
|
||||
}
|
||||
|
||||
if (isAssistantDelta(delta)) {
|
||||
if (delta.content) {
|
||||
let text = stripStreamingEllipsis(hassMessage.text);
|
||||
if (pendingMessageBoundary) {
|
||||
text = joinWithBreak(text, delta.content);
|
||||
pendingMessageBoundary = false;
|
||||
} else {
|
||||
text += delta.content;
|
||||
}
|
||||
latestSegment += delta.content;
|
||||
hassMessage.text = text + STREAMING_ELLIPSIS;
|
||||
streamingMarker = true;
|
||||
}
|
||||
if (delta.thinking_content) {
|
||||
hassMessage.thinking += delta.thinking_content;
|
||||
}
|
||||
if (delta.tool_calls) {
|
||||
for (const toolCall of delta.tool_calls) {
|
||||
hassMessage.tool_calls[toolCall.id] = toolCall;
|
||||
}
|
||||
}
|
||||
requestUpdate();
|
||||
} else if (isToolResultDelta(delta)) {
|
||||
if (hassMessage.tool_calls[delta.tool_call_id]) {
|
||||
hassMessage.tool_calls[delta.tool_call_id].result = delta.result;
|
||||
requestUpdate();
|
||||
}
|
||||
}
|
||||
} else if (event.type === "intent-end") {
|
||||
continueConversation = event.data.intent_output.continue_conversation;
|
||||
const response = event.data.intent_output.response.speech.plain?.speech;
|
||||
if (
|
||||
event.data.intent_output.response.response_type === "error" &&
|
||||
response
|
||||
) {
|
||||
setError(response);
|
||||
return;
|
||||
}
|
||||
if (response) {
|
||||
applyFinalResponse(response);
|
||||
} else {
|
||||
finalizeStreamingText();
|
||||
}
|
||||
pendingMessageBoundary = false;
|
||||
requestUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
get continueConversation() {
|
||||
return continueConversation;
|
||||
},
|
||||
get hassMessage() {
|
||||
return hassMessage;
|
||||
},
|
||||
addMessage: () => {
|
||||
addMessage(hassMessage);
|
||||
},
|
||||
setError,
|
||||
processEvent,
|
||||
};
|
||||
};
|
||||
@@ -91,7 +91,7 @@ export type ManagerStateEvent =
|
||||
export type BackupSubscriptionEvent = ManagerStateEvent | UploadBackupEvent;
|
||||
|
||||
export const subscribeBackupEvents = (
|
||||
hass: Pick<HomeAssistant, "connection">,
|
||||
hass: HomeAssistant,
|
||||
callback: (event: BackupSubscriptionEvent) => void,
|
||||
preCheck?: () => boolean | Promise<boolean>
|
||||
) =>
|
||||
@@ -103,20 +103,6 @@ export const subscribeBackupEvents = (
|
||||
{ preCheck }
|
||||
);
|
||||
|
||||
export const getBackupState = async (
|
||||
hass: Pick<HomeAssistant, "connection">
|
||||
): Promise<BackupManagerState> => {
|
||||
let state: BackupManagerState = "idle";
|
||||
const unsub = await subscribeBackupEvents(hass, (event) => {
|
||||
// Skip replayed upload progress events, only the manager state matters
|
||||
if (!("agent_id" in event)) {
|
||||
state = event.manager_state;
|
||||
}
|
||||
});
|
||||
unsub();
|
||||
return state;
|
||||
};
|
||||
|
||||
export const DEFAULT_MANAGER_STATE: ManagerStateEvent = {
|
||||
manager_state: "idle",
|
||||
};
|
||||
@@ -22,9 +22,10 @@ export interface CalendarEvent {
|
||||
title: string;
|
||||
start: string;
|
||||
end?: string;
|
||||
color?: string;
|
||||
contrastColor?: string;
|
||||
className?: string;
|
||||
backgroundColor?: string;
|
||||
borderColor?: string;
|
||||
textColor?: string;
|
||||
classNames?: string[];
|
||||
calendar: string;
|
||||
eventData: CalendarEventData;
|
||||
[key: string]: any;
|
||||
@@ -288,12 +289,12 @@ export const normalizeSubscriptionEventData = (
|
||||
start: eventStart,
|
||||
end: eventEnd,
|
||||
title: eventData.summary,
|
||||
color: calendar.backgroundColor,
|
||||
// FullCalendar reads undefined as the text "undefined", and empty as unset
|
||||
contrastColor: calendar.textColor ?? "",
|
||||
backgroundColor: calendar.backgroundColor,
|
||||
borderColor: calendar.backgroundColor,
|
||||
textColor: calendar.textColor,
|
||||
// Only a tentative event is drawn differently. A confirmed event and one
|
||||
// without a status look the same, as most integrations report none.
|
||||
className: eventData.status === "tentative" ? "tentative" : undefined,
|
||||
classNames: eventData.status === "tentative" ? ["tentative"] : undefined,
|
||||
calendar: calendar.entity_id,
|
||||
eventData: normalizedEventData,
|
||||
};
|
||||
|
||||
@@ -1,255 +0,0 @@
|
||||
import { generateUuidV4 } from "../common/util/uuid";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
const STORAGE_KEY_SESSION = "tools-debug-console-session";
|
||||
|
||||
// Tabs tell each other which sessions they use, over this channel.
|
||||
const SESSION_CHANNEL = "tools-debug-console-sessions";
|
||||
|
||||
// How long a tab waits for another tab to say it uses a session already.
|
||||
const CLAIM_TIMEOUT = 100;
|
||||
|
||||
type SessionMessage =
|
||||
| { type: "claim"; sessionId: string; ticket: number }
|
||||
| { type: "taken"; sessionId: string };
|
||||
|
||||
// Per user, so accounts taking turns in the same browser tab share nothing.
|
||||
const sessionIds = new Map<string, Promise<string>>();
|
||||
|
||||
// The sessions this tab uses, once claimed.
|
||||
const ownSessionIds = new Set<string>();
|
||||
|
||||
const sessionChannel = (() => {
|
||||
try {
|
||||
const channel = new BroadcastChannel(SESSION_CHANNEL);
|
||||
channel.addEventListener("message", (ev: MessageEvent<SessionMessage>) => {
|
||||
if (ev.data.type === "claim" && ownSessionIds.has(ev.data.sessionId)) {
|
||||
channel.postMessage({
|
||||
type: "taken",
|
||||
sessionId: ev.data.sessionId,
|
||||
} satisfies SessionMessage);
|
||||
}
|
||||
});
|
||||
return channel;
|
||||
} catch (_err: unknown) {
|
||||
// Without it, a tab opened from another one shares its session.
|
||||
return undefined;
|
||||
}
|
||||
})();
|
||||
|
||||
/**
|
||||
* Return if another open tab uses this session already, or claims it at the
|
||||
* same time. Then the tab with the lowest ticket keeps it; both see the claim
|
||||
* of the other, so exactly one of them does.
|
||||
*/
|
||||
const isSessionTaken = (sessionId: string): Promise<boolean> => {
|
||||
const channel = sessionChannel;
|
||||
if (!channel) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
const ticket = Math.random();
|
||||
return new Promise((resolve) => {
|
||||
const done = (taken: boolean) => {
|
||||
window.clearTimeout(timeout);
|
||||
channel.removeEventListener("message", onMessage);
|
||||
resolve(taken);
|
||||
};
|
||||
const onMessage = (ev: MessageEvent<SessionMessage>) => {
|
||||
const message = ev.data;
|
||||
if (message.sessionId !== sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
message.type === "taken" ||
|
||||
(message.type === "claim" && message.ticket < ticket)
|
||||
) {
|
||||
done(true);
|
||||
}
|
||||
};
|
||||
const timeout = window.setTimeout(() => done(false), CLAIM_TIMEOUT);
|
||||
|
||||
channel.addEventListener("message", onMessage);
|
||||
channel.postMessage({
|
||||
type: "claim",
|
||||
sessionId,
|
||||
ticket,
|
||||
} satisfies SessionMessage);
|
||||
});
|
||||
};
|
||||
|
||||
const claimSessionId = async (userId: string): Promise<string> => {
|
||||
const storageKey = `${STORAGE_KEY_SESSION}-${userId}`;
|
||||
let sessionId: string | undefined;
|
||||
try {
|
||||
sessionId = window.sessionStorage.getItem(storageKey) ?? undefined;
|
||||
} catch (_err: unknown) {
|
||||
// Without storage, the session lasts until the page reloads.
|
||||
}
|
||||
|
||||
// A tab opened from another one gets a copy of its session storage. When
|
||||
// that tab is still open, this one starts a session of its own.
|
||||
if (!sessionId || (await isSessionTaken(sessionId))) {
|
||||
sessionId = generateUuidV4();
|
||||
try {
|
||||
window.sessionStorage.setItem(storageKey, sessionId);
|
||||
} catch (_err: unknown) {
|
||||
// Kept for this page load only.
|
||||
}
|
||||
}
|
||||
|
||||
ownSessionIds.add(sessionId);
|
||||
return sessionId;
|
||||
};
|
||||
|
||||
/**
|
||||
* The console session of this browser tab. The backend keeps the session for
|
||||
* a while after disconnecting, so a reload continues where it left off.
|
||||
*/
|
||||
const getSessionId = (userId: string): Promise<string> => {
|
||||
let sessionId = sessionIds.get(userId);
|
||||
if (!sessionId) {
|
||||
sessionId = claimSessionId(userId);
|
||||
sessionIds.set(userId, sessionId);
|
||||
}
|
||||
|
||||
return sessionId;
|
||||
};
|
||||
|
||||
/** Claim the session of this tab ahead of time, so commands do not wait. */
|
||||
export const prepareDebugConsoleSession = (userId: string) => {
|
||||
getSessionId(userId);
|
||||
};
|
||||
|
||||
const callDebugConsole = async <T>(
|
||||
hass: HomeAssistant,
|
||||
message: { type: string; [key: string]: unknown }
|
||||
) =>
|
||||
hass.callWS<T>({
|
||||
...message,
|
||||
session_id: await getSessionId(hass.user!.id),
|
||||
});
|
||||
|
||||
/** Return if the user has to accept the warning before using the console. */
|
||||
export const getDebugConsoleInfo = (hass: Pick<HomeAssistant, "callWS">) =>
|
||||
hass.callWS<{ warning_required: boolean }>({ type: "debug_console/info" });
|
||||
|
||||
export const acceptDebugConsoleWarning = (
|
||||
hass: Pick<HomeAssistant, "callWS">
|
||||
) => hass.callWS({ type: "debug_console/warning/accept" });
|
||||
|
||||
export type DebugConsoleValue =
|
||||
| { type: "none" }
|
||||
| { type: "boolean"; value: boolean }
|
||||
| { type: "number"; value: string }
|
||||
| { type: "string"; value: string; truncated: boolean }
|
||||
| { type: "text"; value: string; truncated: boolean }
|
||||
| {
|
||||
type: "object";
|
||||
class: string;
|
||||
preview: string;
|
||||
/** Unique per backend session, so stale IDs never find another object. */
|
||||
object_id: string;
|
||||
length?: number;
|
||||
};
|
||||
|
||||
export interface DebugConsoleExecuteResult {
|
||||
logs?: DebugConsoleValue[][];
|
||||
/** Set when the code printed more lines than the console keeps. */
|
||||
logs_truncated?: boolean;
|
||||
result?: DebugConsoleValue | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type DebugConsoleChild =
|
||||
{ key: string; value: DebugConsoleValue } | { key: string; getter: true };
|
||||
|
||||
/**
|
||||
* Looks into objects of the console, for components that do not hold the
|
||||
* hass object.
|
||||
*/
|
||||
export interface DebugConsoleObjects {
|
||||
inspect(objectId: string): Promise<DebugConsoleInspectResult>;
|
||||
getAttribute(
|
||||
objectId: string,
|
||||
attribute: string
|
||||
): Promise<DebugConsoleGetAttributeResult>;
|
||||
}
|
||||
|
||||
export interface DebugConsoleInspectResult {
|
||||
children: DebugConsoleChild[];
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
export type DebugConsoleGetAttributeResult =
|
||||
{ value: DebugConsoleValue } | { error: string };
|
||||
|
||||
export const executeDebugConsole = (hass: HomeAssistant, code: string) =>
|
||||
callDebugConsole<DebugConsoleExecuteResult>(hass, {
|
||||
type: "debug_console/execute",
|
||||
code,
|
||||
});
|
||||
|
||||
/** Return if code needs more input before it runs, like in the Python REPL. */
|
||||
export const checkDebugConsoleInput = (
|
||||
hass: Pick<HomeAssistant, "callWS">,
|
||||
code: string
|
||||
) =>
|
||||
hass.callWS<{ incomplete: boolean }>({
|
||||
type: "debug_console/check_input",
|
||||
code,
|
||||
});
|
||||
|
||||
/** Stop the code this tab is running, which then answers it stopped. */
|
||||
export const stopDebugConsole = (hass: HomeAssistant) =>
|
||||
callDebugConsole(hass, { type: "debug_console/stop" });
|
||||
|
||||
export const inspectDebugConsole = (hass: HomeAssistant, objectId: string) =>
|
||||
callDebugConsole<DebugConsoleInspectResult>(hass, {
|
||||
type: "debug_console/inspect",
|
||||
object_id: objectId,
|
||||
});
|
||||
|
||||
export const getDebugConsoleAttribute = (
|
||||
hass: HomeAssistant,
|
||||
objectId: string,
|
||||
attribute: string
|
||||
) =>
|
||||
callDebugConsole<DebugConsoleGetAttributeResult>(hass, {
|
||||
type: "debug_console/get_attribute",
|
||||
object_id: objectId,
|
||||
attribute,
|
||||
});
|
||||
|
||||
export interface DebugConsoleCompleteResult {
|
||||
/** The part of the line the matches complete. */
|
||||
token: string;
|
||||
matches: string[];
|
||||
}
|
||||
|
||||
/** Complete the code being typed, which is the text up to the cursor. */
|
||||
export const completeDebugConsole = (hass: HomeAssistant, code: string) =>
|
||||
callDebugConsole<DebugConsoleCompleteResult>(hass, {
|
||||
type: "debug_console/complete",
|
||||
code,
|
||||
});
|
||||
|
||||
export interface DebugConsoleSignature {
|
||||
name: string;
|
||||
parameters: string[];
|
||||
/** Index of the parameter the argument being typed goes to. */
|
||||
active: number | null;
|
||||
returns: string | null;
|
||||
doc: string;
|
||||
}
|
||||
|
||||
/** Return the signature of the call the code up to the cursor is in. */
|
||||
export const getDebugConsoleSignature = (hass: HomeAssistant, code: string) =>
|
||||
callDebugConsole<{ signature: DebugConsoleSignature | null }>(hass, {
|
||||
type: "debug_console/signature",
|
||||
code,
|
||||
});
|
||||
|
||||
export const clearDebugConsole = (hass: HomeAssistant) =>
|
||||
callDebugConsole(hass, { type: "debug_console/clear" });
|
||||
@@ -1,11 +1,7 @@
|
||||
import type { Connection, UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import { isComponentLoaded } from "../common/config/is_component_loaded";
|
||||
import { atLeastVersion } from "../common/config/version";
|
||||
import type {
|
||||
HomeAssistantApi,
|
||||
HomeAssistantConfig,
|
||||
LogFileDisabledReason,
|
||||
} from "../types";
|
||||
import type { HomeAssistant, LogFileDisabledReason } from "../types";
|
||||
import type { HassioAddonInfo } from "./hassio/addon";
|
||||
import { supervisorUrl } from "./hassio/common";
|
||||
|
||||
@@ -15,12 +11,12 @@ export interface LogProvider {
|
||||
addon?: HassioAddonInfo;
|
||||
}
|
||||
|
||||
const hasSupervisorCoreLogDownload = (config: HomeAssistantConfig): boolean =>
|
||||
isComponentLoaded(config.config, "hassio") &&
|
||||
atLeastVersion(config.config.version, 2025, 10);
|
||||
const hasSupervisorCoreLogDownload = (hass: HomeAssistant): boolean =>
|
||||
isComponentLoaded(hass.config, "hassio") &&
|
||||
atLeastVersion(hass.config.version, 2025, 10);
|
||||
|
||||
export const fetchErrorLog = (api: HomeAssistantApi) =>
|
||||
api.callApi<string>("GET", "error_log");
|
||||
export const fetchErrorLog = (hass: HomeAssistant) =>
|
||||
hass.callApi<string>("GET", "error_log");
|
||||
|
||||
export const subscribeIntegrationLog = (
|
||||
connection: Connection,
|
||||
@@ -35,17 +31,17 @@ export const subscribeIntegrationLog = (
|
||||
{ resubscribe: false }
|
||||
);
|
||||
|
||||
export const getErrorLogDownloadUrl = (config: HomeAssistantConfig) =>
|
||||
hasSupervisorCoreLogDownload(config)
|
||||
export const getErrorLogDownloadUrl = (hass: HomeAssistant) =>
|
||||
hasSupervisorCoreLogDownload(hass)
|
||||
? supervisorUrl("core/logs/latest")
|
||||
: "/api/error_log";
|
||||
|
||||
export const getCoreLogFileDownloadUnavailableReason = (
|
||||
config: HomeAssistantConfig
|
||||
hass: HomeAssistant
|
||||
): LogFileDisabledReason | undefined => {
|
||||
if (hasSupervisorCoreLogDownload(config)) {
|
||||
if (hasSupervisorCoreLogDownload(hass)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return config.config.logging?.log_file_disabled_reason ?? undefined;
|
||||
return hass.config.logging?.log_file_disabled_reason ?? undefined;
|
||||
};
|
||||
@@ -12,6 +12,8 @@ import type { ESPHomeDeviceCapabilities, ESPHomeSerialProxy } from "./esphome";
|
||||
import type { ESPHomeFrontendUserData } from "./frontend";
|
||||
import type { SerialPortUsage } from "./usb";
|
||||
|
||||
export const MUSIC_ASSISTANT_ADDON_SLUG = "d5369777_music_assistant";
|
||||
|
||||
export const MUSIC_ASSISTANT_DOCS_URL = "https://music-assistant.io/";
|
||||
|
||||
export type ESPHomeCapabilityId =
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { HomeAssistant, HomeAssistantApi, PanelInfo } from "../../types";
|
||||
import type { HomeAssistant, PanelInfo } from "../../types";
|
||||
import type { SupervisorArch } from "../supervisor/supervisor";
|
||||
import type { AddonState } from "./addon";
|
||||
import { supervisorUrl, type HassioResponse } from "./common";
|
||||
@@ -157,25 +157,25 @@ export const fetchHassioInfo = async (
|
||||
method: "get",
|
||||
});
|
||||
|
||||
export const fetchHassioBoots = async (api: HomeAssistantApi) =>
|
||||
api.callApi<HassioResponse<HassioBoots>>("GET", `hassio/host/logs/boots`);
|
||||
export const fetchHassioBoots = async (hass: HomeAssistant) =>
|
||||
hass.callApi<HassioResponse<HassioBoots>>("GET", `hassio/host/logs/boots`);
|
||||
|
||||
export const fetchHassioLogsLegacy = async (
|
||||
api: HomeAssistantApi,
|
||||
hass: HomeAssistant,
|
||||
provider: string
|
||||
) =>
|
||||
api.callApi<string>(
|
||||
hass.callApi<string>(
|
||||
"GET",
|
||||
`hassio/${provider.includes("_") ? `addons/${provider}` : provider}/logs`
|
||||
);
|
||||
|
||||
export const fetchHassioLogs = async (
|
||||
api: HomeAssistantApi,
|
||||
hass: HomeAssistant,
|
||||
provider: string,
|
||||
range?: string,
|
||||
boot = 0
|
||||
) =>
|
||||
api.callApiRaw(
|
||||
hass.callApiRaw(
|
||||
"GET",
|
||||
`hassio/${provider.includes("_") ? `addons/${provider}` : provider}/logs${boot !== 0 ? `/boots/${boot}` : ""}`,
|
||||
undefined,
|
||||
@@ -187,13 +187,13 @@ export const fetchHassioLogs = async (
|
||||
);
|
||||
|
||||
export const fetchHassioLogsFollow = async (
|
||||
api: HomeAssistantApi,
|
||||
hass: HomeAssistant,
|
||||
provider: string,
|
||||
signal: AbortSignal,
|
||||
lines = 100,
|
||||
boot = 0
|
||||
) =>
|
||||
api.callApiRaw(
|
||||
hass.callApiRaw(
|
||||
"GET",
|
||||
`hassio/${provider.includes("_") ? `addons/${provider}` : provider}/logs${boot !== 0 ? `/boots/${boot}` : ""}/follow?lines=${lines}`,
|
||||
undefined,
|
||||
@@ -202,7 +202,7 @@ export const fetchHassioLogsFollow = async (
|
||||
);
|
||||
|
||||
export const fetchHassioLogsFollowSkip = async (
|
||||
api: HomeAssistantApi,
|
||||
hass: HomeAssistant,
|
||||
provider: string,
|
||||
signal: AbortSignal,
|
||||
cursor: string,
|
||||
@@ -210,7 +210,7 @@ export const fetchHassioLogsFollowSkip = async (
|
||||
lines = 100,
|
||||
boot = 0
|
||||
) =>
|
||||
api.callApiRaw(
|
||||
hass.callApiRaw(
|
||||
"GET",
|
||||
`hassio/${provider.includes("_") ? `addons/${provider}` : provider}/logs${boot !== 0 ? `/boots/${boot}` : ""}/follow`,
|
||||
undefined,
|
||||
|
||||
@@ -710,16 +710,6 @@ export const isNumericEntity = (
|
||||
isNumericSensorDeviceClass(currentState.attributes.device_class)) ||
|
||||
numericStateFromHistory != null;
|
||||
|
||||
// Non-numeric domains that computeHistory gives a unit, so they get a line chart
|
||||
const LINE_CHART_DOMAINS = ["climate", "humidifier", "water_heater", "zone"];
|
||||
|
||||
export const isTimelineEntity = (
|
||||
domain: string,
|
||||
currentState: HassEntity | undefined
|
||||
): boolean =>
|
||||
!isNumericEntity(domain, currentState, undefined) &&
|
||||
!LINE_CHART_DOMAINS.includes(domain);
|
||||
|
||||
export const mergeHistoryResults = (
|
||||
historyResult: HistoryResult,
|
||||
ltsResult?: HistoryResult,
|
||||
|
||||
@@ -329,15 +329,3 @@ export const filterLogbookCompatibleEntities = (entity) => {
|
||||
(domain === "sensor" && isNumericEntity(domain, entity, undefined));
|
||||
return !continuous;
|
||||
};
|
||||
|
||||
export type LogbookCauseGroup =
|
||||
"automation" | "script" | "integration" | "other";
|
||||
|
||||
export const LOGBOOK_CAUSE_GROUPS: LogbookCauseGroup[] = [
|
||||
"automation",
|
||||
"script",
|
||||
"integration",
|
||||
"other",
|
||||
];
|
||||
|
||||
export const logbookUserCauseKey = (userId: string) => `user/${userId}`;
|
||||
@@ -1,63 +0,0 @@
|
||||
import type { Connection } from "home-assistant-js-websocket";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
export interface LoRaWANReception {
|
||||
gateway_id: string;
|
||||
rssi: number | null;
|
||||
snr: number | null;
|
||||
}
|
||||
|
||||
export interface LoRaWANConnection {
|
||||
entry_id: string;
|
||||
domain: string;
|
||||
title: string;
|
||||
configuration_url: string | null;
|
||||
}
|
||||
|
||||
export interface LoRaWANDevice {
|
||||
dev_eui: string;
|
||||
name: string;
|
||||
manufacturer: string;
|
||||
model: string;
|
||||
application_id: string;
|
||||
device_id: string | null;
|
||||
connection: LoRaWANConnection;
|
||||
support:
|
||||
| "supported"
|
||||
| "missing_identity"
|
||||
| "integration_not_configured"
|
||||
| "unsupported_model";
|
||||
last_uplink: string | null;
|
||||
last_join: string | null;
|
||||
last_status: string | null;
|
||||
receptions: LoRaWANReception[];
|
||||
battery_level: number | null;
|
||||
external_power_source: boolean | null;
|
||||
downlink_margin: number | null;
|
||||
}
|
||||
|
||||
export interface LoRaWANDevices {
|
||||
devices: LoRaWANDevice[];
|
||||
connections: LoRaWANConnection[];
|
||||
}
|
||||
|
||||
export const fetchLoRaWANDevices = (hass: HomeAssistant) =>
|
||||
hass.callWS<LoRaWANDevices>({ type: "lorawan/devices/list" });
|
||||
|
||||
export const subscribeLoRaWANDevices = (
|
||||
connection: Connection,
|
||||
listener: (snapshot: LoRaWANDevices) => void
|
||||
) =>
|
||||
connection.subscribeMessage<LoRaWANDevices>(listener, {
|
||||
type: "lorawan/devices/subscribe",
|
||||
});
|
||||
|
||||
export const strongestLoRaWANReception = (receptions: LoRaWANReception[]) =>
|
||||
receptions.reduce<LoRaWANReception | undefined>(
|
||||
(strongest, reception) =>
|
||||
reception.rssi !== null &&
|
||||
(strongest?.rssi == null || reception.rssi > strongest.rssi)
|
||||
? reception
|
||||
: strongest,
|
||||
undefined
|
||||
);
|
||||
@@ -266,65 +266,6 @@ export const getDataFromPath = (
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Follows a trace path relative to a step config (for example
|
||||
* `choose/0/conditions/1`) and returns the deepest config it reaches. A
|
||||
* template condition written as a plain string is returned as that string.
|
||||
*/
|
||||
export const getNestedStepConfig = (config: unknown, path: string): unknown => {
|
||||
let currentDetail = config;
|
||||
const parts = path.split("/");
|
||||
for (const [index, part] of parts.entries()) {
|
||||
if (typeof currentDetail !== "object" || currentDetail === null) {
|
||||
break;
|
||||
}
|
||||
// Core traces each entity of a state condition one level deeper than the
|
||||
// config, as `entity_id/<index>`. The condition itself is the deepest step.
|
||||
if (part === "entity_id") {
|
||||
break;
|
||||
}
|
||||
// Core traces the conditions of an `if` action as `if/condition/<index>`,
|
||||
// while the config keeps them directly in `if`, as a list or as a single
|
||||
// condition.
|
||||
if (part === "condition" && parts[index - 1] === "if") {
|
||||
continue;
|
||||
}
|
||||
// A list of conditions or actions can also be written as a single item,
|
||||
// which Core traces as index 0.
|
||||
if (!Array.isArray(currentDetail) && /^\d+$/.test(part)) {
|
||||
if (part !== "0") {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const child = (currentDetail as Record<string, unknown>)[part];
|
||||
if (child === undefined) {
|
||||
break;
|
||||
}
|
||||
currentDetail = child;
|
||||
}
|
||||
return currentDetail;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the entity a condition trace step such as `entity_id/1` checked.
|
||||
* Core traces each entity of a condition with a list of entities separately.
|
||||
*/
|
||||
export const getTracedEntityId = (
|
||||
config: unknown,
|
||||
path: string
|
||||
): string | undefined => {
|
||||
const match = /(?:^|\/)entity_id\/(\d+)$/.exec(path);
|
||||
if (!match || typeof config !== "object" || config === null) {
|
||||
return undefined;
|
||||
}
|
||||
const entityIds = (config as { entity_id?: unknown }).entity_id;
|
||||
const entityId = Array.isArray(entityIds)
|
||||
? entityIds[Number(match[1])]
|
||||
: entityIds;
|
||||
return typeof entityId === "string" ? entityId : undefined;
|
||||
};
|
||||
|
||||
// It is 'trigger' if manually triggered by the user via UI
|
||||
export const isTriggerPath = (path: string): boolean =>
|
||||
path === "trigger" || strStartsWith(path, "trigger/");
|
||||
|
||||
@@ -229,7 +229,6 @@ export const checkForEntityUpdates = async (
|
||||
};
|
||||
|
||||
// When updating, and entity does not support % show "Installing"
|
||||
// When updating, and entity does support % but has not reported it yet, show "Preparing"
|
||||
// When updating, and entity does support % show "Installing (xx%)"
|
||||
// When update available, show "Update available"
|
||||
// When the latest version is skipped, show the latest version
|
||||
@@ -246,14 +245,6 @@ export const computeUpdateStateDisplay = (
|
||||
// downgrading firmware (installed_version is newer than latest_version).
|
||||
// Show the installing status regardless of state in that case.
|
||||
if (updateIsInstalling(stateObj)) {
|
||||
// The install has not started yet, for example while a backup is
|
||||
// created before the update.
|
||||
if (
|
||||
supportsFeature(stateObj, UpdateEntityFeature.PROGRESS) &&
|
||||
attributes.update_percentage == null
|
||||
) {
|
||||
return options.localize("ui.card.update.preparing");
|
||||
}
|
||||
const supportsProgress =
|
||||
supportsFeature(stateObj, UpdateEntityFeature.PROGRESS) &&
|
||||
attributes.update_percentage !== null;
|
||||
|
||||
@@ -310,16 +310,23 @@ export interface ZWaveJSSetRawConfigParamData {
|
||||
value_format: number;
|
||||
}
|
||||
|
||||
export type ZWaveJSSetConfigParamStatus = "accepted" | "queued";
|
||||
|
||||
export interface ZWaveJSSetRawConfigParamResult {
|
||||
status: ZWaveJSSetConfigParamStatus;
|
||||
}
|
||||
|
||||
export interface ZWaveJSSetConfigParamResult extends ZWaveJSSetRawConfigParamResult {
|
||||
export interface ZWaveJSSetConfigParamResult {
|
||||
value_id: string;
|
||||
status: number;
|
||||
}
|
||||
|
||||
export type ZWaveJSSetConfigParamStatus = "accepted" | "queued" | "error";
|
||||
|
||||
// Maps the numeric SetValueStatus from zwave-js.
|
||||
export const computeSetConfigParamStatus = (
|
||||
status: number
|
||||
): ZWaveJSSetConfigParamStatus => {
|
||||
if (status === 254 || status === 255) {
|
||||
return "accepted";
|
||||
}
|
||||
return status === 1 ? "queued" : "error";
|
||||
};
|
||||
|
||||
export interface ZwaveJSNodeConfigParameterUpdate {
|
||||
id: string;
|
||||
value: number | null;
|
||||
@@ -809,7 +816,7 @@ export const setZwaveNodeRawConfigParameter = (
|
||||
value: number,
|
||||
value_size: number,
|
||||
value_format: number
|
||||
): Promise<ZWaveJSSetRawConfigParamResult> => {
|
||||
): Promise<ZWaveJSSetConfigParamResult> => {
|
||||
const data: ZWaveJSSetRawConfigParamData = {
|
||||
type: "zwave_js/set_raw_config_parameter",
|
||||
device_id,
|
||||
|
||||
@@ -721,20 +721,11 @@ class DataEntryFlowDialog extends DirtyStateProviderMixin<
|
||||
return;
|
||||
}
|
||||
this._progress = undefined;
|
||||
let catchingUp = true;
|
||||
// Fetching re-runs the current step, which external steps can treat as the user returning,
|
||||
// and only progress tasks can finish before the subscription is in place
|
||||
let fetchPending = this._step?.type === "progress";
|
||||
const unsubs = [
|
||||
subscribeDataEntryFlowProgressed(this.hass.connection, (ev) => {
|
||||
if (ev.data.flow_id !== this._step?.flow_id) {
|
||||
return;
|
||||
}
|
||||
if (catchingUp) {
|
||||
// Two concurrent fetches can both advance the flow, so let the catch-up below fetch again
|
||||
fetchPending = true;
|
||||
return;
|
||||
}
|
||||
this._processStep(
|
||||
this._params!.flowConfig.fetchFlow(this.hass, this._step.flow_id)
|
||||
);
|
||||
@@ -745,54 +736,9 @@ class DataEntryFlowDialog extends DirtyStateProviderMixin<
|
||||
this._progress = Math.ceil(ev.data.progress * 100);
|
||||
}),
|
||||
];
|
||||
const unsubscribe = async () => {
|
||||
this._unsubDataEntryFlowProgress = async () => {
|
||||
(await Promise.all(unsubs)).map((unsub) => unsub());
|
||||
};
|
||||
this._unsubDataEntryFlowProgress = unsubscribe;
|
||||
|
||||
// The backend fires the progressed event even if we have not subscribed yet,
|
||||
// so fetch the flow once in case its progress task already finished
|
||||
try {
|
||||
await Promise.all(unsubs);
|
||||
while (fetchPending) {
|
||||
fetchPending = false;
|
||||
const step = this._step;
|
||||
if (
|
||||
this._unsubDataEntryFlowProgress !== unsubscribe ||
|
||||
!step ||
|
||||
(step.type !== "external" && step.type !== "progress")
|
||||
) {
|
||||
break;
|
||||
}
|
||||
// A failed fetch only ends the loop if no progressed event arrived while it was in flight
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const current = await this._params!.flowConfig.fetchFlow(
|
||||
this.hass,
|
||||
step.flow_id
|
||||
).catch(() => undefined);
|
||||
if (
|
||||
current &&
|
||||
this._step === step &&
|
||||
!(
|
||||
current.type === step.type &&
|
||||
"step_id" in current &&
|
||||
current.step_id === step.step_id &&
|
||||
// Progress steps can move on to another task without changing step_id
|
||||
(current.type !== "progress" ||
|
||||
(step.type === "progress" &&
|
||||
current.progress_action === step.progress_action))
|
||||
)
|
||||
) {
|
||||
this._progress = undefined;
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await this._processStep(current);
|
||||
}
|
||||
}
|
||||
} catch (_err) {
|
||||
// Keep waiting for the progressed event
|
||||
} finally {
|
||||
catchingUp = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _focusFormStep = async (): Promise<void> => {
|
||||
|
||||
@@ -145,8 +145,8 @@ export const showConfigFlowDialog = (
|
||||
);
|
||||
},
|
||||
|
||||
renderShowFormStepFieldLocalizeValue(hass, step, key, values) {
|
||||
return hass.localize(`component.${step.handler}.selector.${key}`, values);
|
||||
renderShowFormStepFieldLocalizeValue(hass, step, key) {
|
||||
return hass.localize(`component.${step.handler}.selector.${key}`);
|
||||
},
|
||||
|
||||
renderShowFormStepSubmitButton(hass, step) {
|
||||
|
||||
@@ -81,8 +81,7 @@ export interface FlowConfig {
|
||||
renderShowFormStepFieldLocalizeValue(
|
||||
hass: HomeAssistant,
|
||||
step: DataEntryFlowStepForm,
|
||||
key: string,
|
||||
values?: Record<string, string>
|
||||
key: string
|
||||
): string;
|
||||
|
||||
renderShowFormStepSubmitButton(
|
||||
|
||||
@@ -141,11 +141,8 @@ export const showOptionsFlowDialog = (
|
||||
);
|
||||
},
|
||||
|
||||
renderShowFormStepFieldLocalizeValue(hass, _step, key, values) {
|
||||
return hass.localize(
|
||||
`component.${configEntry.domain}.selector.${key}`,
|
||||
values
|
||||
);
|
||||
renderShowFormStepFieldLocalizeValue(hass, _step, key) {
|
||||
return hass.localize(`component.${configEntry.domain}.selector.${key}`);
|
||||
},
|
||||
|
||||
renderShowFormStepSubmitButton(hass, step) {
|
||||
|
||||
@@ -144,11 +144,8 @@ export const showSubConfigFlowDialog = (
|
||||
);
|
||||
},
|
||||
|
||||
renderShowFormStepFieldLocalizeValue(hass, _step, key, values) {
|
||||
return hass.localize(
|
||||
`component.${configEntry.domain}.selector.${key}`,
|
||||
values
|
||||
);
|
||||
renderShowFormStepFieldLocalizeValue(hass, _step, key) {
|
||||
return hass.localize(`component.${configEntry.domain}.selector.${key}`);
|
||||
},
|
||||
|
||||
renderShowFormStepSubmitButton(hass, step) {
|
||||
|
||||
@@ -348,15 +348,11 @@ class StepFlowForm extends LitElement {
|
||||
private _errorCallback = (error: string) =>
|
||||
this.flowConfig.renderShowFormStepFieldError(this.hass, this.step, error);
|
||||
|
||||
private _localizeValueCallback = (
|
||||
key: string,
|
||||
values?: Record<string, string>
|
||||
) =>
|
||||
private _localizeValueCallback = (key: string) =>
|
||||
this.flowConfig.renderShowFormStepFieldLocalizeValue(
|
||||
this.hass,
|
||||
this.step,
|
||||
key,
|
||||
values
|
||||
key
|
||||
);
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ContextType } from "@lit/context";
|
||||
import { mdiCheck, mdiChevronDown, mdiOpenInNew } from "@mdi/js";
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
@@ -7,7 +8,6 @@ import { classMap } from "lit/directives/class-map";
|
||||
import { isComponentLoaded } from "../../common/config/is_component_loaded";
|
||||
import { consume } from "../../common/decorators/consume";
|
||||
import { transform } from "../../common/decorators/transform";
|
||||
import { debounce } from "../../common/util/debounce";
|
||||
import type { HASSDomCurrentTargetEvent } from "../../common/dom/fire_event";
|
||||
import type { LocalizeKeys } from "../../common/translations/localize";
|
||||
import "../../components/ha-alert";
|
||||
@@ -20,7 +20,11 @@ import "../../components/ha-spinner";
|
||||
import "../../components/ha-svg-icon";
|
||||
import "../../components/item/ha-list-item-button";
|
||||
import "../../components/list/ha-list-nav";
|
||||
import { fetchConfigFlowInProgress } from "../../data/config_flow";
|
||||
import {
|
||||
fetchConfigFlowInProgress,
|
||||
subscribeConfigFlowInProgress,
|
||||
type ConfigFlowInProgressMessage,
|
||||
} from "../../data/config_flow";
|
||||
import {
|
||||
apiContext,
|
||||
configContext,
|
||||
@@ -33,7 +37,6 @@ import {
|
||||
uiContext,
|
||||
} from "../../data/context";
|
||||
import type { DataEntryFlowProgress } from "../../data/data_entry_flow";
|
||||
import { UNAVAILABLE } from "../../data/entity/entity";
|
||||
import {
|
||||
fetchESPHomeDeviceCapabilities,
|
||||
type ESPHomeDeviceCapabilities,
|
||||
@@ -48,12 +51,19 @@ import {
|
||||
getESPHomeAudioControls,
|
||||
getESPHomeSetupCapabilityIds,
|
||||
isESPHomeSerialConfigured,
|
||||
MUSIC_ASSISTANT_ADDON_SLUG,
|
||||
MUSIC_ASSISTANT_DOCS_URL,
|
||||
type ESPHomeAudioControls,
|
||||
type ESPHomeCapabilityId,
|
||||
type ESPHomeCapabilityStatus,
|
||||
type ESPHomeSetupStatus,
|
||||
} from "../../data/esphome_setup";
|
||||
import {
|
||||
fetchHassioAddonsInfo,
|
||||
installHassioAddon,
|
||||
startHassioAddon,
|
||||
} from "../../data/hassio/addon";
|
||||
import { extractApiErrorMessage } from "../../data/hassio/common";
|
||||
import {
|
||||
listSerialIntegrations,
|
||||
listSerialPortsWithUsage,
|
||||
@@ -64,11 +74,9 @@ import { haStyle, haStyleDialog } from "../../resources/styles";
|
||||
import type { HomeAssistant, HomeAssistantUI } from "../../types";
|
||||
import { documentationUrl } from "../../util/documentation-url";
|
||||
import { getWsErrorMessage } from "../../util/ws-error";
|
||||
import {
|
||||
loadConfigFlowDialog,
|
||||
showConfigFlowDialog,
|
||||
} from "../config-flow/show-dialog-config-flow";
|
||||
import { showConfigFlowDialog } from "../config-flow/show-dialog-config-flow";
|
||||
import { DialogMixin } from "../dialog-mixin";
|
||||
import { showAlertDialog } from "../generic/show-dialog-box";
|
||||
import type { ESPHomeDeviceSetupDialogParams } from "./show-dialog-esphome-device-setup";
|
||||
|
||||
type SetupView =
|
||||
@@ -81,6 +89,9 @@ const SERIAL_PORT_TYPE_LABELS: Record<ESPHomeSerialPortType, LocalizeKeys> = {
|
||||
USB_SERIAL: "ui.panel.config.devices.esphome.setup_serial_port_usb_serial",
|
||||
};
|
||||
|
||||
/** Add-on discovery on slow hardware can take longer than a few seconds. */
|
||||
const MUSIC_ASSISTANT_DISCOVERY_TIMEOUT_MS = 60_000;
|
||||
|
||||
/** Same delay ha-entity-toggle uses to resync a switch the state did not follow. */
|
||||
const SWITCH_RESYNC_MS = 2000;
|
||||
|
||||
@@ -101,6 +112,21 @@ const CAPABILITY_DESCRIPTION_KEYS: Record<ESPHomeCapabilityId, LocalizeKeys> = {
|
||||
serial: "ui.panel.config.devices.esphome.setup_capability_serial_description",
|
||||
};
|
||||
|
||||
const hassioMusicAssistantFlow = (
|
||||
messages: ConfigFlowInProgressMessage[]
|
||||
): DataEntryFlowProgress | undefined => {
|
||||
for (const message of messages) {
|
||||
if (
|
||||
message.type !== "removed" &&
|
||||
message.flow.handler === "music_assistant" &&
|
||||
message.flow.context.source === "hassio"
|
||||
) {
|
||||
return message.flow;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
@customElement("dialog-esphome-device-setup")
|
||||
class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParams>(
|
||||
LitElement
|
||||
@@ -182,9 +208,14 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
|
||||
|
||||
private _loadStarted = false;
|
||||
|
||||
private _openingMusicAssistantFlow = false;
|
||||
private _musicAssistantDiscovery?: {
|
||||
unsub?: UnsubscribeFunc;
|
||||
timer?: number;
|
||||
resolve?: (flow: DataEntryFlowProgress | undefined) => void;
|
||||
};
|
||||
|
||||
public disconnectedCallback() {
|
||||
this._finishMusicAssistantDiscovery(undefined);
|
||||
this.params?.dialogClosedCallback?.();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
@@ -208,19 +239,12 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
|
||||
this._loadStarted = true;
|
||||
this._capabilities = this.params.capabilities;
|
||||
this._load();
|
||||
} else if (this._loadStarted && this._capabilities?.serial_proxies.length) {
|
||||
if (changedProps.has("_configEntries")) {
|
||||
this._refreshSerialPorts();
|
||||
} else if (
|
||||
changedProps.has("_states") &&
|
||||
this._unavailableEntityCount(this._states) !==
|
||||
this._unavailableEntityCount(
|
||||
changedProps.get("_states") as ContextType<typeof statesContext>
|
||||
)
|
||||
) {
|
||||
// The device reconnected or dropped, which changes its scanned ports
|
||||
this._refreshSerialPortsDebounced();
|
||||
}
|
||||
} else if (
|
||||
this._loadStarted &&
|
||||
changedProps.has("_configEntries") &&
|
||||
this._capabilities?.serial_proxies.length
|
||||
) {
|
||||
this._refreshSerialPorts();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -492,7 +516,7 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
|
||||
? html`
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
@click=${this._setupMusicAssistant}
|
||||
@click=${this._installMusicAssistant}
|
||||
>
|
||||
${localize(
|
||||
"ui.panel.config.devices.esphome.setup_install_music_assistant"
|
||||
@@ -588,25 +612,14 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
|
||||
return this._configEntries.find((entry) => entry.entry_id === entryId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Undefined when USB usage has not been loaded. A port of a connected device
|
||||
* is always in the scan, so a missing or absent port means it is offline.
|
||||
* Without the usb integration nothing is scanned, so a missing port says nothing.
|
||||
*/
|
||||
private _serialPortUsage(url: string) {
|
||||
/** Undefined when USB usage has not been loaded. An empty array is a real scan. */
|
||||
private _serialConsumers(url: string) {
|
||||
if (!this._serialPorts) {
|
||||
return undefined;
|
||||
}
|
||||
const usage = this._serialPorts.find((port) => port.device === url);
|
||||
return {
|
||||
consumers: usage?.consumers ?? [],
|
||||
offline: usage
|
||||
? !usage.present
|
||||
: Boolean(
|
||||
this._hassConfig &&
|
||||
isComponentLoaded(this._hassConfig.config, "usb")
|
||||
),
|
||||
};
|
||||
return (
|
||||
this._serialPorts.find((port) => port.device === url)?.consumers ?? []
|
||||
);
|
||||
}
|
||||
|
||||
private _renderSerialPortList() {
|
||||
@@ -618,8 +631,7 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
|
||||
return html`
|
||||
<ul class="ports">
|
||||
${ports.map((port) => {
|
||||
const usage = this._serialPortUsage(port.url);
|
||||
const consumers = usage?.consumers;
|
||||
const consumers = this._serialConsumers(port.url);
|
||||
return html`
|
||||
<li>
|
||||
<span class="port-text">
|
||||
@@ -640,31 +652,23 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
|
||||
}
|
||||
</span>
|
||||
${
|
||||
usage === undefined
|
||||
consumers === undefined
|
||||
? nothing
|
||||
: usage.offline
|
||||
: consumers.length
|
||||
? html`
|
||||
<span class="chip">
|
||||
<span class="chip active">
|
||||
${localize(
|
||||
"ui.panel.config.devices.esphome.setup_serial_offline"
|
||||
"ui.panel.config.devices.esphome.setup_serial_configured"
|
||||
)}
|
||||
</span>
|
||||
`
|
||||
: usage.consumers.length
|
||||
? html`
|
||||
<span class="chip active">
|
||||
${localize(
|
||||
"ui.panel.config.devices.esphome.setup_serial_configured"
|
||||
)}
|
||||
</span>
|
||||
`
|
||||
: html`
|
||||
<ha-button size="s" @click=${this._setupSerialPort}>
|
||||
${localize(
|
||||
"ui.panel.config.devices.esphome.setup_action"
|
||||
)}
|
||||
</ha-button>
|
||||
`
|
||||
: html`
|
||||
<ha-button size="s" @click=${this._setupSerialPort}>
|
||||
${localize(
|
||||
"ui.panel.config.devices.esphome.setup_action"
|
||||
)}
|
||||
</ha-button>
|
||||
`
|
||||
}
|
||||
</li>
|
||||
`;
|
||||
@@ -829,27 +833,6 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
|
||||
}
|
||||
}
|
||||
|
||||
/** ESPHome marks the entities of a disconnected device unavailable. */
|
||||
private _unavailableEntityCount(
|
||||
states: ContextType<typeof statesContext> | undefined
|
||||
): number {
|
||||
if (!this.params || !this._entities || !states) {
|
||||
return 0;
|
||||
}
|
||||
const deviceId = this.params.deviceId;
|
||||
return Object.values(this._entities).filter(
|
||||
(entity) =>
|
||||
entity.device_id === deviceId &&
|
||||
states[entity.entity_id]?.state === UNAVAILABLE
|
||||
).length;
|
||||
}
|
||||
|
||||
private _refreshSerialPortsDebounced = debounce(() => {
|
||||
if (this.isConnected) {
|
||||
this._refreshSerialPorts();
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
private async _refreshSerialPorts() {
|
||||
const capabilities = this._capabilities;
|
||||
if (!capabilities) {
|
||||
@@ -882,6 +865,7 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
|
||||
|
||||
private _showChecklist = () => {
|
||||
this._audioFlowId += 1;
|
||||
this._finishMusicAssistantDiscovery(undefined);
|
||||
this._view = "checklist";
|
||||
this._installingAudio = false;
|
||||
this._installStatus = undefined;
|
||||
@@ -1001,32 +985,143 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
|
||||
}
|
||||
}
|
||||
|
||||
/** The integration's config flow installs and starts the Music Assistant app. */
|
||||
private _setupMusicAssistant = async (ev: Event) => {
|
||||
private _installMusicAssistant = async (ev: Event) => {
|
||||
ev.stopPropagation();
|
||||
if (this._openingMusicAssistantFlow) {
|
||||
if (!this._api || !this._i18n || !this._connection) {
|
||||
return;
|
||||
}
|
||||
const flowId = this._audioFlowId;
|
||||
// The flow dialog only covers this button once its chunk has loaded
|
||||
this._openingMusicAssistantFlow = true;
|
||||
this._view = "audio-working";
|
||||
this._installingAudio = true;
|
||||
this._error = undefined;
|
||||
this._installStatus = this._i18n.localize(
|
||||
"ui.panel.config.devices.esphome.setup_installing_music_assistant"
|
||||
);
|
||||
try {
|
||||
await loadConfigFlowDialog();
|
||||
const { addons } = await fetchHassioAddonsInfo(this._api);
|
||||
if (!this._audioFlowCurrent(flowId)) {
|
||||
return;
|
||||
}
|
||||
const addon = addons.find(
|
||||
(item) => item.slug === MUSIC_ASSISTANT_ADDON_SLUG
|
||||
);
|
||||
if (!addon) {
|
||||
await installHassioAddon(this._api.callWS, MUSIC_ASSISTANT_ADDON_SLUG);
|
||||
}
|
||||
if (!this._audioFlowCurrent(flowId)) {
|
||||
return;
|
||||
}
|
||||
if (!addon || addon.state !== "started") {
|
||||
this._installStatus = this._i18n.localize(
|
||||
"ui.panel.config.devices.esphome.setup_starting_music_assistant"
|
||||
);
|
||||
await startHassioAddon(this._api.callWS, MUSIC_ASSISTANT_ADDON_SLUG);
|
||||
}
|
||||
if (!this._audioFlowCurrent(flowId)) {
|
||||
return;
|
||||
}
|
||||
this._installStatus = this._i18n.localize(
|
||||
"ui.panel.config.devices.esphome.setup_discovering_music_assistant"
|
||||
);
|
||||
await this._openMusicAssistantFlow(flowId);
|
||||
} catch (err: unknown) {
|
||||
if (!this._audioFlowCurrent(flowId) || !this._i18n) {
|
||||
return;
|
||||
}
|
||||
this._view = "audio-offer";
|
||||
await showAlertDialog(this, {
|
||||
title: this._i18n.localize(
|
||||
"ui.panel.config.devices.esphome.setup_error_music_assistant"
|
||||
),
|
||||
text: extractApiErrorMessage(err),
|
||||
});
|
||||
} finally {
|
||||
this._openingMusicAssistantFlow = false;
|
||||
if (this._audioFlowCurrent(flowId)) {
|
||||
this._installingAudio = false;
|
||||
}
|
||||
}
|
||||
if (!this._audioFlowCurrent(flowId) || this._view !== "audio-offer") {
|
||||
};
|
||||
|
||||
private async _openMusicAssistantFlow(flowId: number) {
|
||||
if (!this._connection) {
|
||||
return;
|
||||
}
|
||||
const flow = await this._waitForHassioMusicAssistantFlow();
|
||||
if (!this._audioFlowCurrent(flowId)) {
|
||||
return;
|
||||
}
|
||||
const continueSetup = ({ flowFinished }: { flowFinished: boolean }) => {
|
||||
if (!this._audioFlowCurrent(flowId)) {
|
||||
return;
|
||||
}
|
||||
if (!flowFinished) {
|
||||
this._installingAudio = false;
|
||||
this._installStatus = undefined;
|
||||
this._view = "audio-offer";
|
||||
return;
|
||||
}
|
||||
void this._continueAfterMusicAssistant(flowId);
|
||||
};
|
||||
if (flow) {
|
||||
showConfigFlowDialog(this, {
|
||||
continueFlowId: flow.flow_id,
|
||||
dialogClosedCallback: continueSetup,
|
||||
});
|
||||
return;
|
||||
}
|
||||
showConfigFlowDialog(this, {
|
||||
startFlowHandler: "music_assistant",
|
||||
dialogClosedCallback: ({ entryId }) => {
|
||||
if (entryId) {
|
||||
void this._continueAfterMusicAssistant(flowId);
|
||||
}
|
||||
},
|
||||
dialogClosedCallback: continueSetup,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
private _finishMusicAssistantDiscovery(
|
||||
flow: DataEntryFlowProgress | undefined
|
||||
) {
|
||||
const pending = this._musicAssistantDiscovery;
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
this._musicAssistantDiscovery = undefined;
|
||||
if (pending.timer !== undefined) {
|
||||
window.clearTimeout(pending.timer);
|
||||
}
|
||||
pending.unsub?.();
|
||||
pending.resolve?.(flow);
|
||||
}
|
||||
|
||||
private _waitForHassioMusicAssistantFlow(): Promise<
|
||||
DataEntryFlowProgress | undefined
|
||||
> {
|
||||
const connection = this._connection;
|
||||
if (!connection) {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const pending: NonNullable<typeof this._musicAssistantDiscovery> = {
|
||||
resolve,
|
||||
};
|
||||
this._musicAssistantDiscovery = pending;
|
||||
pending.timer = window.setTimeout(
|
||||
() => this._finishMusicAssistantDiscovery(undefined),
|
||||
MUSIC_ASSISTANT_DISCOVERY_TIMEOUT_MS
|
||||
);
|
||||
void subscribeConfigFlowInProgress(connection, (messages) => {
|
||||
const flow = hassioMusicAssistantFlow(messages);
|
||||
if (flow) {
|
||||
this._finishMusicAssistantDiscovery(flow);
|
||||
}
|
||||
})
|
||||
.then((unsub) => {
|
||||
if (this._musicAssistantDiscovery !== pending) {
|
||||
unsub();
|
||||
return;
|
||||
}
|
||||
pending.unsub = unsub;
|
||||
})
|
||||
.catch(() => this._finishMusicAssistantDiscovery(undefined));
|
||||
});
|
||||
}
|
||||
|
||||
private async _refreshZWaveFlows() {
|
||||
if (!this._connection) {
|
||||
@@ -1265,8 +1360,6 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
|
||||
font-size: var(--ha-font-size-xs);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
line-height: 1;
|
||||
background: var(--ha-color-fill-neutral-normal-resting);
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
.chip.active {
|
||||
background: var(--success-color);
|
||||
|
||||
@@ -42,8 +42,6 @@ import type {
|
||||
export class HaMoreInfoUpdateBackup extends LitElement {
|
||||
@property({ attribute: false }) public stateObj?: UpdateEntity;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
@@ -128,7 +126,7 @@ export class HaMoreInfoUpdateBackup extends LitElement {
|
||||
slot="end"
|
||||
.checked=${this._createBackup}
|
||||
@change=${this._createBackupChanged}
|
||||
.disabled=${this.disabled || updateIsInstalling(this.stateObj)}
|
||||
.disabled=${updateIsInstalling(this.stateObj)}
|
||||
></ha-switch>`
|
||||
}
|
||||
</ha-row-item>
|
||||
|
||||
@@ -1,35 +1,18 @@
|
||||
import type { ContextType } from "@lit/context";
|
||||
import type { HassConfig, UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
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 { BINARY_STATE_OFF } from "../../../common/const";
|
||||
import {
|
||||
consumeEntityRegistryEntry,
|
||||
consumeLocalize,
|
||||
} from "../../../common/decorators/consume-context-entry";
|
||||
import { transform } from "../../../common/decorators/transform";
|
||||
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
|
||||
import { supportsFeature } from "../../../common/entity/supports-feature";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import { sanitizeHttpUrl } from "../../../common/url/sanitize-http-url";
|
||||
import { closestWithProperty } from "../../../common/dom/ancestors-with-property";
|
||||
import "../../../components/ha-alert";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-markdown";
|
||||
import "../../../components/ha-spinner";
|
||||
import "../../../components/progress/ha-progress-bar";
|
||||
import type { ManagerState } from "../../../data/backup_manager";
|
||||
import { subscribeBackupEvents } from "../../../data/backup_manager";
|
||||
import {
|
||||
apiContext,
|
||||
configContext,
|
||||
connectionContext,
|
||||
formattersContext,
|
||||
internationalizationContext,
|
||||
} from "../../../data/context";
|
||||
import { apiContext, formattersContext } from "../../../data/context";
|
||||
import { UNAVAILABLE, UNKNOWN } from "../../../data/entity/entity";
|
||||
import type { EntityRegistryDisplayEntry } from "../../../data/entity/entity_registry";
|
||||
import type { UpdateEntity } from "../../../data/update";
|
||||
import {
|
||||
latestVersionIsSkipped,
|
||||
@@ -38,21 +21,11 @@ import {
|
||||
updateIsInstalling,
|
||||
updateReleaseNotes,
|
||||
} from "../../../data/update";
|
||||
import type {
|
||||
CurrentUser,
|
||||
HomeAssistantApi,
|
||||
HomeAssistantConfig,
|
||||
HomeAssistantFormatters,
|
||||
} from "../../../types";
|
||||
import type { HomeAssistantApi, HomeAssistantFormatters } from "../../../types";
|
||||
import { showAlertDialog } from "../../generic/show-dialog-box";
|
||||
import { showToast } from "../../../util/toast";
|
||||
import { showLogsDialog } from "../../../panels/config/logs/show-dialog-logs";
|
||||
import "../components/update/ha-more-info-update-backup";
|
||||
import type { HaMoreInfoUpdateBackup } from "../components/update/ha-more-info-update-backup";
|
||||
|
||||
// Dialogs that reflect their open state to the `open` attribute.
|
||||
const DIALOG_ELEMENTS = new Set(["ha-adaptive-dialog", "ha-dialog"]);
|
||||
|
||||
@customElement("more-info-update")
|
||||
class MoreInfoUpdate extends LitElement {
|
||||
@property({ attribute: false }) public stateObj?: UpdateEntity;
|
||||
@@ -68,109 +41,15 @@ class MoreInfoUpdate extends LitElement {
|
||||
@consume({ context: apiContext, subscribe: true })
|
||||
private _api!: HomeAssistantApi;
|
||||
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@state()
|
||||
@consumeEntityRegistryEntry({ entityIdPath: ["stateObj", "entity_id"] })
|
||||
private _entityEntry?: EntityRegistryDisplayEntry;
|
||||
|
||||
@consume({ context: connectionContext, subscribe: true })
|
||||
private _connection!: ContextType<typeof connectionContext>;
|
||||
|
||||
@consume({ context: configContext, subscribe: true })
|
||||
@transform<HomeAssistantConfig, HassConfig>({
|
||||
transformer: ({ config }) => config,
|
||||
})
|
||||
private _config!: HassConfig;
|
||||
|
||||
@state()
|
||||
@consume({ context: configContext, subscribe: true })
|
||||
@transform<HomeAssistantConfig, CurrentUser | undefined>({
|
||||
transformer: ({ user }) => user,
|
||||
})
|
||||
private _user?: CurrentUser;
|
||||
|
||||
@state() private _releaseNotes?: string | null;
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
@state() private _installError?: string;
|
||||
|
||||
@state() private _backupState: ManagerState = "idle";
|
||||
|
||||
@state() private _markdownLoading = true;
|
||||
|
||||
@query("ha-more-info-update-backup")
|
||||
private _backupElement?: HaMoreInfoUpdateBackup;
|
||||
|
||||
private _backupEventsUnsub?: Promise<UnsubscribeFunc>;
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this._subscribeBackupEvents();
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this._backupEventsUnsub?.then(
|
||||
(unsub) => unsub(),
|
||||
() => undefined
|
||||
);
|
||||
this._backupEventsUnsub = undefined;
|
||||
this._backupState = "idle";
|
||||
}
|
||||
|
||||
private _subscribeBackupEvents(): void {
|
||||
if (
|
||||
this._backupEventsUnsub ||
|
||||
!isComponentLoaded(this._config, "backup") ||
|
||||
!isComponentLoaded(this._config, "hassio")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const backupEventsUnsub = subscribeBackupEvents(
|
||||
this._connection,
|
||||
(event) => {
|
||||
this._backupState = event.manager_state;
|
||||
}
|
||||
);
|
||||
|
||||
backupEventsUnsub.catch(() => {
|
||||
// The update can still be attempted; a failure is shown inline.
|
||||
if (this._backupEventsUnsub === backupEventsUnsub) {
|
||||
this._backupEventsUnsub = undefined;
|
||||
}
|
||||
});
|
||||
this._backupEventsUnsub = backupEventsUnsub;
|
||||
}
|
||||
|
||||
private _backupInProgressMessage(): string | undefined {
|
||||
// The Supervisor only holds back the updates it manages.
|
||||
if (this._entityEntry?.platform !== "hassio") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
switch (this._backupState) {
|
||||
case "create_backup":
|
||||
case "receive_backup":
|
||||
case "restore_backup":
|
||||
return this._localize(
|
||||
`ui.dialogs.more_info_control.update.backup_in_progress.${this._backupState}`,
|
||||
{
|
||||
backups_link: html`<a href="/config/backup"
|
||||
>${this._localize(
|
||||
"ui.dialogs.more_info_control.update.backup_in_progress.backups_link"
|
||||
)}</a
|
||||
>`,
|
||||
}
|
||||
);
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (
|
||||
!this._localize ||
|
||||
@@ -182,7 +61,6 @@ class MoreInfoUpdate extends LitElement {
|
||||
}
|
||||
|
||||
const releaseUrl = sanitizeHttpUrl(this.stateObj.attributes.release_url);
|
||||
const backupInProgressMessage = this._backupInProgressMessage();
|
||||
|
||||
return html`
|
||||
<div class="content">
|
||||
@@ -282,36 +160,7 @@ class MoreInfoUpdate extends LitElement {
|
||||
<div class="footer">
|
||||
<ha-more-info-update-backup
|
||||
.stateObj=${this.stateObj}
|
||||
.disabled=${!!backupInProgressMessage}
|
||||
></ha-more-info-update-backup>
|
||||
${
|
||||
backupInProgressMessage
|
||||
? html`<ha-alert alert-type="info">
|
||||
${backupInProgressMessage}
|
||||
</ha-alert>`
|
||||
: this._installError
|
||||
? html`<ha-alert alert-type="error">
|
||||
${this._localize(
|
||||
"ui.dialogs.more_info_control.update.install_failed",
|
||||
{ error: this._installError }
|
||||
)}
|
||||
${
|
||||
this._canShowSupervisorLogs()
|
||||
? html`<ha-button
|
||||
slot="action"
|
||||
appearance="plain"
|
||||
size="small"
|
||||
@click=${this._handleShowSupervisorLogs}
|
||||
>
|
||||
${this._localize(
|
||||
"ui.dialogs.more_info_control.update.supervisor_logs"
|
||||
)}
|
||||
</ha-button>`
|
||||
: nothing
|
||||
}
|
||||
</ha-alert>`
|
||||
: nothing
|
||||
}
|
||||
<div class="actions">
|
||||
${
|
||||
this.stateObj.state === BINARY_STATE_OFF &&
|
||||
@@ -346,10 +195,7 @@ class MoreInfoUpdate extends LitElement {
|
||||
<ha-button
|
||||
@click=${this._handleInstall}
|
||||
.loading=${updateIsInstalling(this.stateObj)}
|
||||
.disabled=${
|
||||
updateButtonIsDisabled(this.stateObj) ||
|
||||
!!backupInProgressMessage
|
||||
}
|
||||
.disabled=${updateButtonIsDisabled(this.stateObj)}
|
||||
>
|
||||
${this._localize(
|
||||
"ui.dialogs.more_info_control.update.update"
|
||||
@@ -394,7 +240,7 @@ class MoreInfoUpdate extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private async _handleInstall(): Promise<void> {
|
||||
private _handleInstall(): void {
|
||||
const installData: Record<string, any> = {
|
||||
entity_id: this.stateObj!.entity_id,
|
||||
};
|
||||
@@ -410,107 +256,7 @@ class MoreInfoUpdate extends LitElement {
|
||||
installData.version = this.stateObj!.attributes.latest_version;
|
||||
}
|
||||
|
||||
this._installError = undefined;
|
||||
|
||||
try {
|
||||
await this._api.callService(
|
||||
"update",
|
||||
"install",
|
||||
installData,
|
||||
undefined,
|
||||
false
|
||||
);
|
||||
} catch (err: any) {
|
||||
const message = await this._localizeInstallError(err);
|
||||
|
||||
if (this._isShown()) {
|
||||
this._installError = message;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// The dialog was closed, or is closing, while the update was installing.
|
||||
const toastHost = document.querySelector("home-assistant");
|
||||
|
||||
if (toastHost) {
|
||||
showToast(toastHost, {
|
||||
message: this._localize(
|
||||
"ui.dialogs.more_info_control.update.install_failed",
|
||||
{ error: message }
|
||||
),
|
||||
duration: 10000,
|
||||
action: this._canShowSupervisorLogs()
|
||||
? {
|
||||
text: this._localize(
|
||||
"ui.dialogs.more_info_control.update.supervisor_logs"
|
||||
),
|
||||
action: () =>
|
||||
this._showSupervisorLogs(toastHost as HTMLElement),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Supervisor errors only say "Check Supervisor logs for details".
|
||||
private _canShowSupervisorLogs(): boolean {
|
||||
return (
|
||||
this._entityEntry?.platform === "hassio" && Boolean(this._user?.is_admin)
|
||||
);
|
||||
}
|
||||
|
||||
private _handleShowSupervisorLogs(): void {
|
||||
this._showSupervisorLogs(this);
|
||||
}
|
||||
|
||||
// The toast outlives this element, so it opens the dialog from its own host.
|
||||
private _showSupervisorLogs(host: HTMLElement): void {
|
||||
showLogsDialog(host, {
|
||||
provider: "supervisor",
|
||||
title: this._localize(
|
||||
"ui.dialogs.more_info_control.update.supervisor_logs"
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
private _isShown(): boolean {
|
||||
if (!this.isConnected) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// The dialog keeps its content mounted while its close animation runs.
|
||||
let element = closestWithProperty(this, "open", false);
|
||||
|
||||
while (element) {
|
||||
if (DIALOG_ELEMENTS.has(element.localName)) {
|
||||
return element.hasAttribute("open");
|
||||
}
|
||||
|
||||
element = closestWithProperty(element, "open", false);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async _localizeInstallError(err: any): Promise<string> {
|
||||
if (err?.translation_domain && err?.translation_key) {
|
||||
const localize = await this._i18n.loadBackendTranslation(
|
||||
"exceptions",
|
||||
err.translation_domain
|
||||
);
|
||||
|
||||
const localizedMessage = localize(
|
||||
`component.${err.translation_domain}.exceptions.${err.translation_key}.message`,
|
||||
err.translation_placeholders
|
||||
);
|
||||
|
||||
if (localizedMessage) {
|
||||
return localizedMessage;
|
||||
}
|
||||
}
|
||||
|
||||
return err?.message ?? String(err);
|
||||
this._api.callService("update", "install", installData);
|
||||
}
|
||||
|
||||
private _handleSkip(): void {
|
||||
@@ -583,13 +329,6 @@ class MoreInfoUpdate extends LitElement {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.footer ha-alert {
|
||||
display: block;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: var(--ha-space-2) var(--ha-space-4) 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
|
||||
@@ -811,7 +811,12 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
|
||||
}
|
||||
></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
`ui.dialogs.more_info_control.${deviceType}_info`
|
||||
"ui.dialogs.more_info_control.device_or_service_info",
|
||||
{
|
||||
type: this.hass.localize(
|
||||
`ui.dialogs.more_info_control.device_type.${deviceType}`
|
||||
),
|
||||
}
|
||||
)}
|
||||
</ha-dropdown-item>
|
||||
`
|
||||
|
||||