Compare commits

..
171 changed files with 2103 additions and 8882 deletions

No files matched your search

+1 -1
View File
@@ -58,7 +58,7 @@ Readiness and invalidation:
Repository-owned contracts:
- Consult the public [frontend contributor documentation](https://contributors.home-assistant.io/frontend/) for documented architecture, data flow, design, and development workflows.
- Consult the public [frontend developer documentation](https://developers.home-assistant.io/docs/frontend/) for documented architecture, data flow, design, and development workflows.
- For new leaf components, load `ha-frontend-contexts` and verify they consume narrow contexts instead of introducing a broad `hass` property; containers and external APIs may still require `hass`.
- Verify backend assumptions against the owning Core, Supervisor, or WebSocket implementation, and component assumptions against the exported component contract.
- Prefer canonical repository helpers and test setup over duplicate local implementations.
+1 -1
View File
@@ -105,4 +105,4 @@ To help with the load of incoming pull requests:
Below, some useful links you could explore:
-->
[docs-repository]: https://github.com/home-assistant/home-assistant.io
[perfect-pr]: https://contributors.home-assistant.io/start/pull-requests/#before-you-open-it
[perfect-pr]: https://developers.home-assistant.io/docs/review-process/#creating-the-perfect-pr
+1 -1
View File
@@ -34,7 +34,7 @@ runs:
hashFiles('.nvmrc', 'package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', 'patches/**') }}
- name: Setup Node
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: ".nvmrc"
@@ -192,8 +192,8 @@ export default async function checkPullRequestStandards({
`\n\n` +
`Please complete the ` +
`[PR template](https://github.com/home-assistant/frontend/blob/dev/.github/PULL_REQUEST_TEMPLATE.md?plain=1) ` +
`and see the [contributor docs](https://contributors.home-assistant.io/start/pull-requests/#fill-out-the-template) ` +
`for more on creating a great pull request.`;
`and see the [developer docs](https://developers.home-assistant.io/docs/review-process) ` +
`for more on creating a great pull request (see point 6).`;
if (existing) {
await withRetry("comment update", () =>
+2 -2
View File
@@ -134,7 +134,7 @@ jobs:
is-test: true
rspack-cache: readonly
- name: Upload bundle stats
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: frontend-bundle-stats
path: build/stats/*.json
@@ -142,7 +142,7 @@ jobs:
- name: Check entrypoint bundle size budget
run: pnpm run check-bundlesize
- name: Upload frontend build
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: frontend-build
path: hass_frontend/
+2 -2
View File
@@ -32,12 +32,12 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
with:
languages: javascript-typescript
build-mode: none
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
with:
category: "/language:javascript-typescript"
+17 -17
View File
@@ -38,7 +38,7 @@ jobs:
name: Prepare container dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble
image: mcr.microsoft.com/playwright:v1.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/
+2 -2
View File
@@ -104,7 +104,7 @@ jobs:
key: rspack-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
- name: Upload build artifacts
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: wheels
path: dist/home_assistant_frontend*.whl
@@ -137,7 +137,7 @@ jobs:
run: tar -czvf translations.tar.gz translations
- name: Upload translations
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: translations
path: translations.tar.gz
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
python-version: ${{ env.PYTHON_VERSION }}
- name: Verify version
uses: home-assistant/actions/helpers/verify-version@cd6f3635a740c84d8a14f6121a90068b8ece6bf6 # master
uses: home-assistant/actions/helpers/verify-version@06749dd8c0b54f350bc69c8752456cee498808a3 # master
- name: Setup Node and install
uses: ./.github/actions/setup
+1 -1
View File
@@ -1 +1 @@
24.22.0
24.21.0
+1 -1
View File
@@ -41,5 +41,5 @@ Contributions that do not follow this policy will be closed. Repeated violations
---
The canonical version of this policy is published at
<https://contributors.home-assistant.io/start/ai-policy/>. In case of differences,
<https://developers.home-assistant.io/docs/ai_policy>. In case of differences,
the published version applies.
+2 -2
View File
@@ -6,12 +6,12 @@ This is the repository for the official [Home Assistant](https://home-assistant.
- [View demo of Home Assistant](https://demo.home-assistant.io/)
- [More information about Home Assistant](https://home-assistant.io)
- [Frontend development instructions](https://contributors.home-assistant.io/frontend/development/)
- [Frontend development instructions](https://developers.home-assistant.io/docs/frontend/development/)
## Development
- Initial setup: `script/setup`
- Development: [Instructions](https://contributors.home-assistant.io/frontend/development/)
- Development: [Instructions](https://developers.home-assistant.io/docs/frontend/development/)
- Production build: `pnpm build`
- Gallery: `cd gallery && script/develop_gallery`
+1 -1
View File
@@ -4,7 +4,7 @@ import { castContext } from "../cast_context";
export const castDemoLovelace: () => LovelaceConfig = () => {
const touchSupported =
castContext.getDeviceCapabilities()?.touch_input_supported;
castContext.getDeviceCapabilities().touch_input_supported;
return {
views: [
{
+2 -2
View File
@@ -19,7 +19,7 @@ const playerStylesAdded = false;
let controls: HTMLElement | null;
const setTouchControlsVisibility = (visible: boolean) => {
if (!castContext.getDeviceCapabilities()?.touch_input_supported) {
if (!castContext.getDeviceCapabilities().touch_input_supported) {
return;
}
controls =
@@ -51,7 +51,7 @@ const playDummyMedia = (viewTitle?: string) => {
clearTimeout(timeOut);
timeOut = undefined;
}
if (castContext.getDeviceCapabilities()?.touch_input_supported) {
if (castContext.getDeviceCapabilities().touch_input_supported) {
timeOut = window.setTimeout(() => playDummyMedia(viewTitle), 540000); // repeat every 9 minutes to keep it active (gets deactivated after 10 minutes)
}
};
+2 -68
View File
@@ -142,45 +142,7 @@ export class HaDemo extends HomeAssistantAppEl {
(localize?: LocalizeFunc) => localize ?? this.hass!.localize
);
mockLovelace(hass, localizePromise, [
{
id: "music",
url_path: "music",
require_admin: false,
show_in_sidebar: true,
icon: "mdi:music",
title: "Music",
mode: "storage",
},
{
id: "cameras",
url_path: "cameras",
require_admin: false,
show_in_sidebar: true,
icon: "mdi:cctv",
title: "Cameras",
mode: "storage",
},
{
id: "garden",
url_path: "garden",
require_admin: false,
show_in_sidebar: true,
icon: "mdi:flower",
title: "Garden",
mode: "storage",
},
{
id: "lovelace-advanced",
url_path: "lovelace-advanced",
require_admin: false,
show_in_sidebar: false,
icon: "mdi:cog",
title: "Advanced",
mode: "yaml",
filename: "lovelace-advanced.yaml",
},
]);
mockLovelace(hass, localizePromise);
mockAuth(hass);
mockTranslations(hass, localizePromise);
mockHistory(hass);
@@ -210,35 +172,7 @@ export class HaDemo extends HomeAssistantAppEl {
mockAreaRegistry(hass);
mockDeviceRegistry(hass, demoDevices);
mockFloorRegistry(hass);
mockLabelRegistry(hass, [
{
label_id: "kitchen",
name: "Kitchen",
icon: null,
color: "red",
description: "Everything in the kitchen",
created_at: 1735689600,
modified_at: 1735689600,
},
{
label_id: "garden",
name: "Garden",
icon: "mdi:flower",
color: "green",
description: null,
created_at: 1735689600,
modified_at: 1735689600,
},
{
label_id: "battery-powered",
name: "Battery powered",
icon: null,
color: "orange",
description: "Runs on batteries",
created_at: 1735689600,
modified_at: 1735689600,
},
]);
mockLabelRegistry(hass);
mockUsagePrediction(hass);
mockEntityRegistryDisplay(hass);
mockEntityRegistry(hass, [
-8
View File
@@ -42,12 +42,4 @@ export const mockBlueprint = (hass: MockHomeAssistant) => {
hass.mockWS("blueprint/list", (msg: { domain: BlueprintDomain }) =>
msg.domain === "script" ? scriptBlueprints : automationBlueprints
);
hass.mockWS(
"blueprint/delete",
(msg: { domain: BlueprintDomain; path: string }) => {
const blueprints =
msg.domain === "script" ? scriptBlueprints : automationBlueprints;
delete blueprints[msg.path];
}
);
};
-2
View File
@@ -1,4 +1,3 @@
import { lorawanFixtures } from "./lorawan/fixtures";
import { bluetoothFixtures } from "./bluetooth/fixtures";
import { infraredFixtures } from "./infrared/fixtures";
import { matterFixtures } from "./matter/fixtures";
@@ -16,7 +15,6 @@ import { zwaveJsFixtures } from "./zwave_js/fixtures";
// data to mock. Each owns its own fixtures, so they can be added and removed
// one at a time.
const INTEGRATIONS: ConnectivityFixtures[] = [
lorawanFixtures,
bluetoothFixtures,
serialFixtures,
modbusFixtures,
-2
View File
@@ -1,4 +1,3 @@
import { mockLoRaWAN } from "./lorawan/mock";
import type { MockHomeAssistant } from "../../../../src/fake_data/provide_hass";
import { mockBluetooth } from "./bluetooth/mock";
import { mockMatter } from "./matter/mock";
@@ -12,7 +11,6 @@ import { mockZwaveJs } from "./zwave_js/mock";
// The WebSocket mocks, code-split into the config panel chunk.
const MOCKS = [
mockLoRaWAN,
mockBluetooth,
mockSerial,
mockModbus,
@@ -1,135 +0,0 @@
import { manifest } from "../../manifest";
import {
configEntry,
device,
registryEntry,
withRegistryLinks,
} from "../helpers";
import type { ConnectivityFixtures } from "../types";
export const CHIRPSTACK_ENTRY = "mock-lorawan-chirpstack";
export const TTN_ENTRY = "mock-lorawan-ttn";
export const SENSECAP_ENTRY = "mock-lorawan-sensecap";
const REGISTRY_ENTRIES = [
registryEntry(
"sensor.lorawan_greenhouse_temperature",
"lorawan-greenhouse",
SENSECAP_ENTRY,
"sensecap_lorawan",
"Temperature"
),
registryEntry(
"sensor.lorawan_greenhouse_humidity",
"lorawan-greenhouse",
SENSECAP_ENTRY,
"sensecap_lorawan",
"Humidity"
),
registryEntry(
"sensor.lorawan_study_carbon_dioxide",
"lorawan-study",
SENSECAP_ENTRY,
"sensecap_lorawan",
"Carbon dioxide"
),
];
export const lorawanFixtures: ConnectivityFixtures = {
components: [
"lorawan",
"sensecap_lorawan",
"chirpstack",
"thethingsnetwork_lorawan",
],
commands: ["lorawan/"],
manifests: [
manifest("lorawan", "LoRaWAN", { integration_type: "helper" }),
manifest("sensecap_lorawan", "SenseCAP", {
integration_type: "device",
dependencies: ["lorawan"],
}),
manifest("chirpstack", "ChirpStack", { integration_type: "hub" }),
manifest("thethingsnetwork_lorawan", "The Things Network", {
integration_type: "hub",
}),
],
configEntries: [
{
type: "hub",
entry: configEntry(CHIRPSTACK_ENTRY, "chirpstack", "Home ChirpStack"),
},
{
type: "hub",
entry: configEntry(
TTN_ENTRY,
"thethingsnetwork_lorawan",
"The Things Network"
),
},
{
type: "device",
entry: configEntry(SENSECAP_ENTRY, "sensecap_lorawan", "SenseCAP"),
},
],
devices: [
device(
"lorawan-greenhouse",
"Greenhouse",
"Seeed Studio",
"S2101",
SENSECAP_ENTRY,
{
config_entries: [SENSECAP_ENTRY, CHIRPSTACK_ENTRY],
identifiers: [
["sensecap_lorawan", `${CHIRPSTACK_ENTRY}:0000000000000001`],
],
}
),
device(
"lorawan-study",
"Study air quality",
"Seeed Studio",
"S2103",
SENSECAP_ENTRY,
{
config_entries: [SENSECAP_ENTRY, TTN_ENTRY],
identifiers: [["sensecap_lorawan", `${TTN_ENTRY}:0000000000000002`]],
}
),
],
entityRegistryEntries: REGISTRY_ENTRIES,
entities: () =>
withRegistryLinks(REGISTRY_ENTRIES, {
"sensor.lorawan_greenhouse_temperature": {
entity_id: "sensor.lorawan_greenhouse_temperature",
state: "24.3",
attributes: {
friendly_name: "Greenhouse temperature",
device_class: "temperature",
state_class: "measurement",
unit_of_measurement: "°C",
},
},
"sensor.lorawan_greenhouse_humidity": {
entity_id: "sensor.lorawan_greenhouse_humidity",
state: "58",
attributes: {
friendly_name: "Greenhouse humidity",
device_class: "humidity",
state_class: "measurement",
unit_of_measurement: "%",
},
},
"sensor.lorawan_study_carbon_dioxide": {
entity_id: "sensor.lorawan_study_carbon_dioxide",
state: "652",
attributes: {
friendly_name: "Study carbon dioxide",
device_class: "carbon_dioxide",
state_class: "measurement",
unit_of_measurement: "ppm",
},
},
}),
};
-124
View File
@@ -1,124 +0,0 @@
import type { LoRaWANDevice } from "../../../../../src/data/lorawan";
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
import { minutesAgo } from "../helpers";
import { emitInitial } from "../subscription";
import { CHIRPSTACK_ENTRY, TTN_ENTRY } from "./fixtures";
const devices = (): LoRaWANDevice[] => {
const base: LoRaWANDevice = {
dev_eui: "0000000000000001",
name: "Greenhouse",
manufacturer: "Seeed Studio",
model: "S2101",
application_id: "home-sensors",
device_id: "lorawan-greenhouse",
connection: {
entry_id: CHIRPSTACK_ENTRY,
domain: "chirpstack",
title: "Home ChirpStack",
configuration_url: "https://www.chirpstack.io/",
},
support: "supported",
last_uplink: minutesAgo(2),
last_join: minutesAgo(2400),
last_status: minutesAgo(64),
receptions: [{ gateway_id: "home-indoor", rssi: -67, snr: 9.5 }],
battery_level: 87,
external_power_source: false,
downlink_margin: 12,
};
return [
base,
{
...base,
dev_eui: "0000000000000002",
name: "Study air quality",
model: "S2103",
device_id: "lorawan-study",
application_id: "demo-home",
connection: {
entry_id: TTN_ENTRY,
domain: "thethingsnetwork_lorawan",
title: "The Things Network",
configuration_url: "https://eu1.cloud.thethings.network/console",
},
receptions: [
{ gateway_id: "brooklyn-rooftop", rssi: -103, snr: -2.5 },
{ gateway_id: "brooklyn-community", rssi: -110, snr: -5.2 },
],
battery_level: 64,
last_uplink: minutesAgo(4),
last_status: minutesAgo(140),
},
{
...base,
dev_eui: "24E124128E123456",
name: "Irrigation controller",
manufacturer: "Milesight",
model: "UC511",
device_id: null,
support: "integration_not_configured",
battery_level: 93,
last_uplink: minutesAgo(7),
receptions: [{ gateway_id: "home-indoor", rssi: -83, snr: 6.8 }],
},
{
...base,
dev_eui: "A84041FFFF123456",
name: "Garage power meter",
manufacturer: "",
model: "",
device_id: null,
support: "missing_identity",
battery_level: null,
external_power_source: true,
last_uplink: minutesAgo(1),
receptions: [{ gateway_id: "home-indoor", rssi: -75, snr: 8.1 }],
},
{
...base,
dev_eui: "2CF7F1C071500199",
name: "New soil sensor",
model: "S2105",
device_id: null,
support: "unsupported_model",
last_uplink: null,
last_join: null,
last_status: null,
receptions: [],
battery_level: null,
external_power_source: null,
downlink_margin: null,
},
{
...base,
dev_eui: "2CF7F1C071500166",
name: "Garden shed",
device_id: null,
support: "integration_not_configured",
battery_level: 18,
last_uplink: minutesAgo(180),
last_status: minutesAgo(600),
receptions: [{ gateway_id: "home-indoor", rssi: -112, snr: -7.5 }],
},
];
};
export const mockLoRaWAN = (hass: MockHomeAssistant) => {
const deviceList = devices();
const snapshot = {
devices: deviceList,
connections: [
...new Map(
deviceList.map((device) => [
device.connection.entry_id,
device.connection,
])
).values(),
],
};
hass.mockWS("lorawan/devices/list", () => snapshot);
hass.mockWS("lorawan/devices/subscribe", (_msg, _hass, onChange) =>
emitInitial(() => onChange?.(snapshot))
);
};
-33
View File
@@ -190,39 +190,6 @@ export const removeEntityRegistryEntry = (
hass.mockEvent("entity_registry_updated");
};
export const removeLabelFromEntityRegistry = (
hass: MockHomeAssistant,
labelId: string
) => {
const withoutLabel = (labels: string[]) =>
labels.filter((id) => id !== labelId);
for (const [key, entry] of retiredEntries) {
if (entry.labels.includes(labelId)) {
retiredEntries.set(key, { ...entry, labels: withoutLabel(entry.labels) });
}
}
if (!entries.some((entry) => entry.labels.includes(labelId))) {
return;
}
entries = entries.map((entry) =>
entry.labels.includes(labelId)
? { ...entry, labels: withoutLabel(entry.labels) }
: entry
);
const entities = { ...hass.entities };
for (const entry of entries) {
const entity = entities[entry.entity_id];
if (entity?.labels.includes(labelId)) {
entities[entry.entity_id] = {
...entity,
labels: withoutLabel(entity.labels),
};
}
}
hass.updateHass({ entities });
hass.mockEvent("entity_registry_updated");
};
// Adds an entry like core does when an integration creates an entity
export const addEntityRegistryEntry = (
hass: MockHomeAssistant,
+2 -19
View File
@@ -1,6 +1,5 @@
import type { LabelRegistryEntry } from "../../../src/data/label/label_registry";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import { removeLabelFromEntityRegistry } from "./entity_registry";
let labels: LabelRegistryEntry[] = [];
@@ -10,22 +9,6 @@ export const mockLabelRegistry = (
hass: MockHomeAssistant,
data: LabelRegistryEntry[] = []
) => {
labels = [...data];
hass.mockWS("config/label_registry/list", () =>
labels.map((label) => ({ ...label }))
);
hass.mockWS(
"config/label_registry/delete",
(msg: { label_id: string }, currentHass: MockHomeAssistant) => {
const index = labels.findIndex(
(label) => label.label_id === msg.label_id
);
if (index === -1) {
throw new Error(`Label ${msg.label_id} not found`);
}
labels.splice(index, 1);
removeLabelFromEntityRegistry(currentHass, msg.label_id);
hass.mockEvent("label_registry_updated");
}
);
labels = data;
hass.mockWS("config/label_registry/list", () => data);
};
+2 -16
View File
@@ -1,5 +1,4 @@
import type { LocalizeFunc } from "../../../src/common/translations/localize";
import type { LovelaceDashboard } from "../../../src/data/lovelace/dashboard";
import type { LovelaceInfo } from "../../../src/data/lovelace/resource";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import {
@@ -12,14 +11,10 @@ import "../custom-cards/ha-demo-card";
import "../custom-cards/ha-demo-next-card";
import { mockMap } from "./map";
let dashboards: LovelaceDashboard[] = [];
export const mockLovelace = (
hass: MockHomeAssistant,
localizePromise: Promise<LocalizeFunc>,
data: LovelaceDashboard[] = []
localizePromise: Promise<LocalizeFunc>
) => {
dashboards = data;
hass.mockWS("lovelace/config", ({ url_path }) => {
if (url_path === "map") {
mockMap(hass);
@@ -39,16 +34,7 @@ export const mockLovelace = (
);
hass.mockWS("lovelace/config/save", () => Promise.resolve());
hass.mockWS("lovelace/resources", () => Promise.resolve([]));
hass.mockWS("lovelace/dashboards/list", () =>
dashboards.map((dashboard) => ({ ...dashboard }))
);
hass.mockWS("lovelace/dashboards/delete", (msg: { dashboard_id: string }) => {
const index = dashboards.findIndex((d) => d.id === msg.dashboard_id);
if (index === -1) {
throw new Error(`Dashboard ${msg.dashboard_id} not found`);
}
dashboards.splice(index, 1);
});
hass.mockWS("lovelace/dashboards/list", () => Promise.resolve([]));
};
customElements.whenDefined("hui-root").then(() => {
+18 -36
View File
@@ -1,10 +1,8 @@
import { TZDate } from "@date-fns/tz";
import {
addDays,
addHours,
addMinutes,
addMonths,
constructFrom,
differenceInHours,
endOfDay,
} from "date-fns";
@@ -15,18 +13,6 @@ import type {
} from "../../../src/data/recorder";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
const getFirstDate = (
start: Date,
period: "5minute" | "hour" | "day" | "month"
): Date => {
if (period === "day" || period === "month") {
return constructFrom(start, start);
}
// Core returns UTC-aligned buckets whose start is at or after the request.
const interval = (period === "hour" ? 60 : 5) * 60 * 1000;
return constructFrom(start, Math.ceil(start.getTime() / interval) * interval);
};
const getNextDate = (
currentDate: Date,
period: "5minute" | "hour" | "day" | "month"
@@ -47,7 +33,8 @@ const generateMeanStatistics = (
maxDiff: number
): StatisticValue[] => {
const statistics: StatisticValue[] = [];
let currentDate = getFirstDate(start, period);
let currentDate = new Date(start);
currentDate.setMinutes(0, 0, 0);
const now = new Date();
while (end > currentDate && currentDate < now) {
const delta = Math.random() * maxDiff;
@@ -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[]> = {};
+1 -1
View File
@@ -33,4 +33,4 @@ We want to make it as easy for designers to contribute as it is for developers.
## Developers
Everything you need to get started developing can be found in our <a href="https://contributors.home-assistant.io/frontend/" rel="noopener noreferrer" target="_blank">Home Assistant contributor docs</a>.
Everything you need to get started developing can be found in our <a href="https://developers.home-assistant.io" rel="noopener noreferrer" target="_blank">Home Assistant Developer Docs</a>.
+1 -1
View File
@@ -271,7 +271,7 @@ const CONFIGS = [
### ha-alert
You can use our [\`ha-alert\`](https://contributors.home-assistant.io/design/components/alert/) component in markdown content rendered in the Home Assistant Frontend.
You can use our [\`ha-alert\`](https://design.home-assistant.io/#components/ha-alert) component in markdown content rendered in the Home Assistant Frontend.
<ha-alert alert-type="error">This is an error alert — check it out!</ha-alert>
<ha-alert alert-type="warning">This is a warning alert — check it out!</ha-alert>
+1 -1
View File
@@ -32,7 +32,7 @@ It is similar to the core frontend dev.
- `"mounts": ["source=/path/to/hass/frontend,target=/workspaces/frontend,type=bind,consistency=cached"]`
- use the dev container
- start the dev server with following optional env vars:
- `SUPERVISOR_HOST` to have real supervisor data, you can [setup a supervisor remote API access](https://contributors.home-assistant.io/os/supervisor/core-connection/#get-access-to-the-supervisor-api) and set the host of your supervisor. e.g.: `SUPERVISOR_HOST=192.168.0.20:8888`
- `SUPERVISOR_HOST` to have real supervisor data, you can [setup a supervisor remote API access](https://developers.home-assistant.io/docs/supervisor/development/#supervisor-api-access) and set the host of your supervisor. e.g.: `SUPERVISOR_HOST=192.168.0.20:8888`
- `SUPERVISOR_TOKEN` the supervisor api token you get from the Remote API proxy Addon Logs
- `FRONTEND_PATH` the path inside your container should be `/workspaces/frontend`
- example: `SUPERVISOR_TOKEN=abc123 SUPERVISOR_HOST=192.168.0.20:8888 FRONTEND_PATH=/workspaces/frontend go run main.go http.go mdns.go`
+1 -1
View File
@@ -6,7 +6,7 @@ export default {
],
"*.{json,css,md,markdown,html,ya?ml}": "prettier --cache --write",
"translations/*/*.json": (files) =>
'printf "%s\n" "Translation files should not be added or modified here. Instead, make the necessary modifications in src/translations/en.json. Other languages are managed externally. Please see https://contributors.home-assistant.io/translate/ for details." ' +
'printf "%s\n" "Translation files should not be added or modified here. Instead, make the necessary modifications in src/translations/en.json. Other languages are managed externally. Please see https://developers.home-assistant.io/docs/translations/ for details." ' +
files.join(" ") +
" >&2 && exit 1",
"pnpm-lock.yaml": () => "pnpm dedupe",
+20 -21
View File
@@ -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,12 +62,12 @@
"@formatjs/intl-numberformat": "9.4.3",
"@formatjs/intl-pluralrules": "6.3.15",
"@formatjs/intl-relativetimeformat": "12.3.15",
"@fullcalendar/core": "6.1.22",
"@fullcalendar/daygrid": "6.1.22",
"@fullcalendar/interaction": "6.1.22",
"@fullcalendar/list": "6.1.22",
"@fullcalendar/luxon3": "6.1.22",
"@fullcalendar/timegrid": "6.1.22",
"@fullcalendar/core": "6.1.21",
"@fullcalendar/daygrid": "6.1.21",
"@fullcalendar/interaction": "6.1.21",
"@fullcalendar/list": "6.1.21",
"@fullcalendar/luxon3": "6.1.21",
"@fullcalendar/timegrid": "6.1.21",
"@home-assistant/webawesome": "3.14.0-ha.0",
"@lezer/common": "1.5.3",
"@lezer/highlight": "1.2.5",
@@ -86,7 +85,7 @@
"@swc/helpers": "0.5.23",
"@tsparticles/engine": "4.4.0",
"@tsparticles/preset-links": "4.4.0",
"@versatiles/style": "6.4.1",
"@versatiles/style": "6.4.0",
"@vibrant/color": "4.0.4",
"@vvo/tzdb": "6.201.1",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
@@ -108,14 +107,14 @@
"home-assistant-js-websocket": "9.7.1",
"idb-keyval": "6.3.0",
"intl-messageformat": "12.1.3",
"js-yaml": "5.5.0",
"js-yaml": "5.4.3",
"leaflet": "1.9.4",
"leaflet.markercluster": "1.5.3",
"lit": "3.3.3",
"lit-html": "3.3.3",
"luxon": "3.7.2",
"maplibre-gl": "6.14.0",
"marked": "18.1.1",
"marked": "18.1.0",
"memoize-one": "6.0.0",
"nanoid": "6.0.2",
"node-vibrant": "4.0.4",
@@ -150,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",
@@ -174,7 +173,7 @@
"@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",
@@ -186,7 +185,7 @@
"eslint-import-resolver-webpack": "0.13.11",
"eslint-plugin-import-x": "4.17.1",
"eslint-plugin-lit": "2.3.1",
"eslint-plugin-lit-a11y": "5.1.3",
"eslint-plugin-lit-a11y": "5.1.1",
"eslint-plugin-unused-imports": "4.4.1",
"eslint-plugin-wc": "3.1.0",
"fancy-log": "2.0.0",
@@ -199,7 +198,7 @@
"gulp-rename": "2.1.0",
"html-minifier-terser": "7.2.0",
"husky": "9.1.7",
"jsdom": "30.1.3",
"jsdom": "30.1.2",
"jszip": "3.10.2",
"license-checker-rseidelsohn": "5.0.1",
"lightningcss": "1.33.0",
@@ -218,16 +217,16 @@
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.71.2",
"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"
}
}
+389 -417
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -26,7 +26,7 @@ overrides:
lit-html: 3.3.3
clean-css: 5.3.3
"@lit/reactive-element": 2.1.2
"@fullcalendar/daygrid": 6.1.22
"@fullcalendar/daygrid": 6.1.21
tslib: 2.8.1
patchedDependencies:
@@ -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

+1 -1
View File
@@ -29,7 +29,7 @@ const ALLOWED_LICENSES = new Set([
"(MIT OR CC0-1.0)",
"(MIT OR Apache-2.0)",
"(MIT AND Zlib)",
"PSF-2.0", // argparse - Python Software Foundation License (permissive)
"Python-2.0", // argparse - Python Software Foundation License (permissive)
"Public Domain",
"W3C-20150513", // wicg-inert - W3C Software and Document License (permissive)
"Unlicense",
-27
View File
@@ -1,27 +0,0 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { computeDomain } from "./compute_domain";
export const ALERT_DOMAINS = [
"alarm_control_panel",
"binary_sensor",
"cover",
"lock",
];
export const DEFAULT_ALERT_COLOR = "red";
export const computeAlertActiveStates = (entityId: string): string[] => {
switch (computeDomain(entityId)) {
case "alarm_control_panel":
return ["triggered"];
case "cover":
return ["open"];
case "lock":
return ["jammed", "unlocked", "open"];
default:
return ["on"];
}
};
export const isAlertActive = (stateObj: HassEntity): boolean =>
computeAlertActiveStates(stateObj.entity_id).includes(stateObj.state);
@@ -1,82 +0,0 @@
import { parsePlainText } from "zrender/lib/graphic/helper/parseText.js";
const FONT_FAMILY = "Roboto, Noto, sans-serif";
// zrender's overflow "break" only wraps at these characters, see isWordBreakChar
const WORD_BREAK =
/[ ,&?/;\]]|[^\u0020-\u024F\u0370-\u10FF\u1200-\u13FF\u1E00-\u206F]/;
interface FitLabelOptions {
/** Font size the label is measured at, and the largest size returned */
fontSize: number;
width: number;
height: number;
/** Height of the label at `fontSize` before ECharts wraps it */
unwrappedHeight: number;
}
/**
* Largest font size at which an ECharts label with `overflow: "break"`,
* wrapped at `width`, fits in `height` without breaking a word.
*/
export function fitLabelFontSize(
text: string,
{ fontSize: baseFontSize, width, height, unwrappedHeight }: FitLabelOptions
) {
const font = `${baseFontSize}px ${FONT_FAMILY}`;
const measure = (label: string, wrapWidth?: number) =>
parsePlainText(
label,
{ font, width: wrapWidth, overflow: "break" },
undefined,
undefined
);
// ECharts measures at the final font size, leave room for rounding
const fitWidth = Math.max(width - 1, 1);
const unwrappedContentHeight = measure(text).contentHeight;
const words = text.split(WORD_BREAK).filter(Boolean);
// a label at a smaller font wraps like one at baseFontSize in a wider box
const wrapWidthAt = (fontSize: number) =>
(fitWidth * baseFontSize) / fontSize;
const maxFontSize = (height / unwrappedHeight) * baseFontSize;
const fits = (fontSize: number) => {
const wrapWidth = wrapWidthAt(fontSize);
if (words.some((word) => measure(word, wrapWidth).lines.length > 1)) {
return false;
}
const wrappedHeight =
unwrappedHeight +
(measure(text, wrapWidth).contentHeight - unwrappedContentHeight);
// exact at maxFontSize when the label doesn't wrap
return fontSize <= maxFontSize * (unwrappedHeight / wrappedHeight);
};
let low = 0;
let high = Math.min(baseFontSize, maxFontSize);
if (fits(high)) {
low = high;
} else {
for (let i = 0; i < 10; i++) {
const mid = (low + high) / 2;
if (fits(mid)) {
low = mid;
} else {
high = mid;
}
}
}
// zrender moves a space that doesn't fit to the next line, indenting it.
// Shrinking further only widens the wrap, so the label still fits.
const spaceWidth = measure(" ").contentWidth;
const indents = (fontSize: number) =>
text
.split("\n")
.some((paragraph) =>
measure(paragraph, wrapWidthAt(fontSize)).lines.some(
(line, i) => i > 0 && line.startsWith(" ")
)
);
let fontSize = low;
for (let i = 0; i < 3 && indents(fontSize); i++) {
fontSize = (fitWidth * baseFontSize) / (wrapWidthAt(fontSize) + spaceWidth);
}
return fontSize;
}
+47 -29
View File
@@ -280,7 +280,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
private _pendingUpdate?: Set<PropertyKey>;
private _legendItemCount?: number;
private _pendingOptions?: HaECOption;
private _pendingZoom?: [number, number, boolean];
@@ -292,6 +292,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
this._tooltipHiddenWhilePanning = false;
this._pendingSetup = false;
this._pendingUpdate = undefined;
this._pendingOptions = undefined;
this._pendingZoom = undefined;
// The observers are about to be torn down, so nothing would correct a stale
// value if this element is reattached inside a hidden container.
@@ -444,6 +445,11 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
if (!this._pendingUpdate) {
this._pendingUpdate = new Set();
}
if (prop === "options" && !this._pendingUpdate.has(prop)) {
// The one previous value a replay reads, and it has to stay the options
// the chart currently renders, not those of a later deferred change.
this._pendingOptions = changedProps.get(prop) as HaECOption | undefined;
}
this._pendingUpdate.add(prop);
}
}
@@ -460,8 +466,10 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
return;
}
const pending = this._pendingUpdate!;
const previousOptions = this._pendingOptions;
this._pendingUpdate = undefined;
this._applyChartUpdate(pending);
this._pendingOptions = undefined;
this._applyChartUpdate(pending, previousOptions);
}
public willUpdate(changedProps: PropertyValues): void {
@@ -477,6 +485,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
if (invisible) {
this._pendingSetup = true;
this._pendingUpdate = undefined;
this._pendingOptions = undefined;
} else {
this._setupChart();
}
@@ -494,10 +503,16 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
}
return;
}
this._applyChartUpdate(changedProps);
this._applyChartUpdate(
changedProps,
changedProps.get("options") as HaECOption | undefined
);
}
private _applyChartUpdate(changedProps: { has: (prop: string) => boolean }) {
private _applyChartUpdate(
changedProps: { has: (prop: string) => boolean },
previousOptions: HaECOption | undefined
) {
let chartOptions: ECOption = {};
if (changedProps.has("data") || changedProps.has("_hiddenDatasets")) {
chartOptions.series = this._getSeries();
@@ -517,28 +532,20 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
if (changedProps.has("options")) {
chartOptions = { ...chartOptions, ...this._createOptions() };
this._disposeSonification();
if (this._compareCustomLegendOptions(previousOptions, this.options)) {
// custom legend changes may require a resize to layout properly
this._shouldResizeChart = true;
this._resizeAnimationDuration = 250;
}
} else if (this._isTouchDevice && changedProps.has("_isZoomed")) {
chartOptions.dataZoom = this._getDataZoomConfig();
}
let legendChanged = false;
if (changedProps.has("options") || changedProps.has("data")) {
const legendItemCount = this._getLegendItems()?.length;
legendChanged = legendItemCount !== this._legendItemCount;
this._legendItemCount = legendItemCount;
}
if (Object.keys(chartOptions).length > 0) {
this._setChartOptions(chartOptions);
if (chartOptions.series || changedProps.has("_isZoomed")) {
this._updateSankeyRoam();
}
}
if (legendChanged) {
// Within a fixed height the host does not resize when the legend grows.
// Without animation, setOption has already fired "finished" by now.
this._shouldResizeChart = true;
this._resizeAnimationDuration = 250;
this.updateComplete.then(() => this._resizeChartIfNeeded());
}
}
protected render() {
@@ -825,6 +832,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
this._loading = true;
this._pendingSetup = false;
this._pendingUpdate = undefined;
this._pendingOptions = undefined;
try {
// The connection holds a reference to the chart instance, so it cannot
// outlive it. Focusing the chart again reconnects.
@@ -875,14 +883,13 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
const sankeySeries = series?.find((s: any) => s.type === "sankey");
const zoomed = Math.abs(sankeySeries.zoom - 1) > 1e-6;
this._isZoomed = zoomed;
// Before setOption, which lays the labels out again
fireEvent(this, "chart-sankeyroam", { zoom: sankeySeries.zoom });
if (!zoomed) {
// Reset center when fully zoomed out
this.chart!.setOption({
series: [{ id: sankeySeries.id, center: null }],
});
}
fireEvent(this, "chart-sankeyroam", { zoom: sankeySeries.zoom });
// Clear cached emphasis states so labels don't revert to pre-zoom sizes
this.chart!.dispatchAction({ type: "downplay" });
});
@@ -995,7 +1002,6 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
...this._createOptions(),
series: this._getSeries(),
});
this._legendItemCount = this._getLegendItems()?.length;
this._updateSankeyRoam();
if (this._pendingZoom) {
const [start, end, silent] = this._pendingZoom;
@@ -1452,7 +1458,6 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
if (this._isZoomed) {
this._handleZoomReset();
} else {
fireEvent(this, "chart-sankeyroam", { zoom: 2 });
this.chart.setOption({
series: sankeySeries.map((s: any) => ({
id: s.id,
@@ -1497,19 +1502,15 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
}
private _handleZoomReset() {
const option = this.chart?.getOption();
const sankeySeries = (option?.series as any[])?.filter(
(s: any) => s.type === "sankey"
);
// Before the dataZoom action and setOption, which lay the labels out again
if (sankeySeries?.length) {
fireEvent(this, "chart-sankeyroam", { zoom: 1 });
}
// A dataZoom action reruns the layout of every series, graphs included
if (this._isZoomed) {
this.chart?.dispatchAction({ type: "dataZoom", start: 0, end: 100 });
}
// Reset sankey roam zoom
const option = this.chart?.getOption();
const sankeySeries = (option?.series as any[])?.filter(
(s: any) => s.type === "sankey"
);
if (sankeySeries?.length) {
this.chart?.setOption({
series: sankeySeries.map((s: any) => ({
@@ -1519,6 +1520,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
})),
});
this._isZoomed = false;
fireEvent(this, "chart-sankeyroam", { zoom: 1 });
}
this._getGraphRoams().forEach(({ series, zoom, x, y }) => {
this.chart!.dispatchAction({
@@ -1870,6 +1872,22 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
this._resizeAnimationDuration = undefined;
}
private _compareCustomLegendOptions(
oldOptions: HaECOption | undefined,
newOptions: HaECOption | undefined
): boolean {
const oldLegends = ensureArray(
oldOptions?.legend || []
) as LegendComponentOption[];
const newLegends = ensureArray(
newOptions?.legend || []
) as LegendComponentOption[];
return (
oldLegends.some((l) => l.show && l.type === "custom") !==
newLegends.some((l) => l.show && l.type === "custom")
);
}
static styles = css`
:host {
display: block;
-2
View File
@@ -32,7 +32,6 @@ export interface NetworkNode {
borderColor?: string;
borderWidth?: number;
};
label?: { position: "left" | "right" | "top" | "bottom" };
fixed?: boolean;
/**
* Distance from the center, where 0 is the center and 1 is the edge
@@ -316,7 +315,6 @@ export class HaNetworkGraph extends LitElement {
symbolSize: node.symbolSize || 30,
symbol: node.symbol || "circle",
itemStyle: node.itemStyle || {},
label: node.label,
fixed: node.fixed,
x: node.x,
y: node.y,
+9 -16
View File
@@ -9,7 +9,6 @@ import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
import SankeyChart from "../../resources/echarts/components/sankey/install";
import type { HaECOption } from "../../resources/echarts/echarts";
import { measureTextWidth } from "../../util/text";
import { fitLabelFontSize } from "./fit-label-font-size";
import "./ha-chart-base";
import "./ha-chart-tooltip-marker";
import "../ha-alert";
@@ -238,10 +237,8 @@ export class HaSankeyChart extends LitElement {
distance: LABEL_DISTANCE,
minMargin: LABEL_MIN_MARGIN,
overflow: "break",
lineOverflow: this.vertical ? "truncate" : "none",
},
labelLayout: (params) => {
// params.rect is already zoomed, only the gap slack needs scaling
if (this.vertical) {
// reduce the label font size so the longest word fits on one line
const longestWord = params.text
@@ -260,7 +257,7 @@ export class HaSankeyChart extends LitElement {
: longest;
}, "");
const wordWidth = measureTextWidth(longestWord, FONT_SIZE) || 1;
const availableWidth = params.rect.width + 6 * this._currentZoom;
const availableWidth = (params.rect.width + 6) * this._currentZoom;
// minMargin is applied as padding on the label box, so words must
// fit in the inner wrap width or overflow:break splits them.
const wrapWidth = Math.max(availableWidth - LABEL_MIN_MARGIN, 1);
@@ -269,25 +266,21 @@ export class HaSankeyChart extends LitElement {
(wrapWidth / wordWidth) * FONT_SIZE
);
return {
fontSize,
// Safari draws a 0px canvas font at its default size, so hide
// unreadable labels by dropping all of their lines instead.
height: fontSize > 1 ? undefined : 0,
fontSize: fontSize > 1 ? fontSize : 0,
width: availableWidth,
align: "center",
dy: -2, // shift up or the lowest row labels may be cut off
};
}
const availableHeight = params.rect.height + 8 * this._currentZoom; // account for the margin
const availableHeight = (params.rect.height + 8) * this._currentZoom; // account for the margin
const fontSize = Math.min(
(availableHeight / params.labelRect.height) * FONT_SIZE,
FONT_SIZE
);
return {
// labelRect is measured before the width is applied, so unwrapped
fontSize: fitLabelFontSize(params.text, {
fontSize: FONT_SIZE,
width: labelSpace,
height: availableHeight,
unwrappedHeight: params.labelRect.height,
}),
fontSize,
lineHeight: fontSize,
width: labelSpace,
height: params.rect.height,
};
+6 -21
View File
@@ -1762,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 */
@@ -1835,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;
@@ -1859,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);
}
@@ -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") {
-5
View File
@@ -40,11 +40,6 @@ export class HaAnsiToHtml extends LitElement {
return html`<pre class=${classMap({ wrap: !this.wrapDisabled })}></pre>`;
}
public disconnectedCallback(): void {
super.disconnectedCallback();
CSS.highlights?.delete("search-results");
}
protected firstUpdated(_changedProperties: PropertyValues<this>): void {
super.firstUpdated(_changedProperties);
+4 -16
View File
@@ -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}
/>
+3 -14
View File
@@ -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)
+5 -17
View File
@@ -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);
}
+1 -2
View File
@@ -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>;
-254
View File
@@ -1,254 +0,0 @@
import type { ContextType } from "@lit/context";
import { mdiArrowCollapseDown } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { consume } from "../common/decorators/consume";
import { debounce } from "../common/util/debounce";
import { internationalizationContext } from "../data/context";
import "./ha-ansi-to-html";
import type { HaAnsiToHtml } from "./ha-ansi-to-html";
import "./ha-button";
import "./ha-svg-icon";
const SCROLL_FOLLOW_MARGIN = 40;
@customElement("ha-log-viewer")
export class HaLogViewer extends LitElement {
@property({ type: Number, attribute: "max-lines" }) public maxLines = 5000;
@property() public filter = "";
@property({ type: Boolean, attribute: "wrap-disabled" }) public wrapDisabled =
false;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@state() private _newLogsIndicator = false;
@state() private _noMatches = false;
@query(".log") private _logElement!: HTMLElement;
@query("ha-ansi-to-html") private _ansiToHtml!: HaAnsiToHtml;
private _lines: string[] = [];
private _followFrame?: number;
private _ready = false;
public get lines(): string[] {
return this._lines;
}
public addLines(lines: string[]) {
const newLines = lines
.flatMap((line) => line.split("\n"))
.slice(-this.maxLines);
if (!newLines.length) {
return;
}
const overflow = this._lines.length + newLines.length > this.maxLines;
this._lines = this._lines.concat(newLines).slice(-this.maxLines);
if (!this._ready) {
return;
}
const follow = this._isScrolledToBottom();
if (overflow) {
this._trimLines(this._lines.length - newLines.length);
}
this._renderLines(newLines);
if (follow) {
this._followBottom();
} else {
this._newLogsIndicator = true;
}
}
public clear() {
this._cancelFollowBottom();
this._lines = [];
this._newLogsIndicator = false;
this._noMatches = false;
if (this._ready) {
this._ansiToHtml.clear();
}
}
public scrollToBottom() {
this._newLogsIndicator = false;
if (this._ready) {
this._logElement.scrollTo(0, this._logElement.scrollHeight);
}
}
public disconnectedCallback() {
super.disconnectedCallback();
this._cancelFollowBottom();
this._debouncedFilter.cancel();
}
protected render() {
return html`
<div class="log" @scroll=${this._handleScroll}>
<slot></slot>
${
this._noMatches
? html`<div class="no-matches">
${this._i18n.localize("ui.components.log_viewer.no_matches")}
</div>`
: nothing
}
<ha-ansi-to-html ?wrap-disabled=${this.wrapDisabled}></ha-ansi-to-html>
</div>
<ha-button
class="new-logs-indicator ${classMap({
visible: this._newLogsIndicator,
})}"
size="s"
appearance="filled"
.disabled=${!this._newLogsIndicator}
@click=${this.scrollToBottom}
>
<ha-svg-icon .path=${mdiArrowCollapseDown} slot="start"></ha-svg-icon>
${this._i18n.localize("ui.components.log_viewer.scroll_down_button")}
<ha-svg-icon .path=${mdiArrowCollapseDown} slot="end"></ha-svg-icon>
</ha-button>
`;
}
protected async firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
await this._ansiToHtml.updateComplete;
this._renderLines(this._lines);
this._ready = true;
this.scrollToBottom();
}
protected updated(changedProps: PropertyValues<this>) {
super.updated(changedProps);
if (
changedProps.has("filter") &&
changedProps.get("filter") !== undefined
) {
this._debouncedFilter();
}
}
private _renderLines(lines: string[]) {
if (!this.filter) {
this._ansiToHtml.parseLinesToColoredPre(lines);
return;
}
this._ansiToHtml.filterLines("");
this._ansiToHtml.parseLinesToColoredPre(lines);
this._applyFilter();
}
private _debouncedFilter = debounce(() => {
this._applyFilter();
if (!this.filter) {
this.scrollToBottom();
}
}, 150);
private _applyFilter() {
this._noMatches =
this._lines.length > 0 && !this._ansiToHtml.filterLines(this.filter);
}
private _trimLines(keptLines: number) {
const logElement = this._logElement;
const scrollTopBeforeTrim = logElement.scrollTop;
const scrollHeightBeforeTrim = logElement.scrollHeight;
this._ansiToHtml.trimLines(keptLines);
const removedHeight = scrollHeightBeforeTrim - logElement.scrollHeight;
logElement.scrollTop = Math.max(0, scrollTopBeforeTrim - removedHeight);
}
private _isScrolledToBottom(): boolean {
const log = this._logElement;
return (
log.scrollHeight - log.scrollTop - log.clientHeight < SCROLL_FOLLOW_MARGIN
);
}
private _handleScroll() {
if (this._newLogsIndicator && this._isScrolledToBottom()) {
this._newLogsIndicator = false;
}
}
private _followBottom() {
this._cancelFollowBottom();
const logElement = this._logElement;
const scrollTopBeforeFrame = logElement.scrollTop;
this._followFrame = requestAnimationFrame(() => {
this._followFrame = undefined;
if (logElement.scrollTop === scrollTopBeforeFrame) {
logElement.scrollTop = logElement.scrollHeight;
}
});
}
private _cancelFollowBottom() {
if (this._followFrame !== undefined) {
cancelAnimationFrame(this._followFrame);
this._followFrame = undefined;
}
}
static styles = css`
:host {
position: relative;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
border: 1px solid var(--divider-color);
border-radius: var(--ha-border-radius-md);
overflow: hidden;
}
.log {
flex: 1;
min-height: 0;
padding: var(--ha-space-2) var(--ha-space-2) var(--ha-space-8);
box-sizing: border-box;
overflow: auto;
overflow-anchor: none;
direction: ltr;
font-family: var(--ha-font-family-code);
font-size: var(--ha-font-size-s);
}
.no-matches {
color: var(--secondary-text-color);
padding: var(--ha-space-2);
}
.new-logs-indicator {
overflow: hidden;
position: absolute;
bottom: 4px;
inset-inline-start: 4px;
height: 0;
transition: height 0.4s ease-out;
}
.new-logs-indicator.visible {
height: 32px;
}
@media (prefers-reduced-motion: reduce) {
.new-logs-indicator {
transition: none;
}
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-log-viewer": HaLogViewer;
}
}
@@ -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;
+1 -4
View File
@@ -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;
+2 -6
View File
@@ -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}`
);
};
+14 -47
View File
@@ -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;
+1 -1
View File
@@ -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>
) =>
-255
View File
@@ -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" });
+2
View File
@@ -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 =
-63
View File
@@ -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
);
+15 -8
View File
@@ -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,
@@ -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) {
+2 -6
View File
@@ -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";
@@ -19,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,
@@ -46,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,
@@ -62,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 =
@@ -79,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;
@@ -99,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
@@ -180,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();
}
@@ -483,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"
@@ -832,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;
@@ -951,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) {
@@ -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,19 +21,11 @@ import {
updateIsInstalling,
updateReleaseNotes,
} from "../../../data/update";
import type {
HomeAssistantApi,
HomeAssistantConfig,
HomeAssistantFormatters,
} from "../../../types";
import type { HomeAssistantApi, HomeAssistantFormatters } from "../../../types";
import { showAlertDialog } from "../../generic/show-dialog-box";
import { showToast } from "../../../util/toast";
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;
@@ -66,102 +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() 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 ||
@@ -173,7 +61,6 @@ class MoreInfoUpdate extends LitElement {
}
const releaseUrl = sanitizeHttpUrl(this.stateObj.attributes.release_url);
const backupInProgressMessage = this._backupInProgressMessage();
return html`
<div class="content">
@@ -273,22 +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 }
)}
</ha-alert>`
: nothing
}
<div class="actions">
${
this.stateObj.state === BINARY_STATE_OFF &&
@@ -323,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"
@@ -371,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,
};
@@ -387,77 +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,
});
}
}
}
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 {
@@ -530,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;
+6 -1
View File
@@ -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>
`
@@ -179,11 +179,8 @@ export const showRepairsFlowDialog = (
);
},
renderShowFormStepFieldLocalizeValue(hass, _step, key, values) {
return hass.localize(
`component.${issue.domain}.selector.${key}`,
values
);
renderShowFormStepFieldLocalizeValue(hass, _step, key) {
return hass.localize(`component.${issue.domain}.selector.${key}`);
},
renderShowFormStepSubmitButton(hass, step) {
+1 -1
View File
@@ -396,7 +396,7 @@ export class HassTabsSubpage extends LitElement {
}
:host(:not([narrow])) #toolbar-icon {
min-width: 48px;
min-width: 40px;
}
ha-menu-button,
@@ -458,16 +458,12 @@ export class HaPlatformCondition extends LitElement {
this.requestUpdate("_checkedKeys");
}
private _localizeValueCallback = (
key: string,
values?: Record<string, string>
) => {
private _localizeValueCallback = (key: string) => {
if (!this.condition?.condition) {
return "";
}
return this.hass.localize(
`component.${getConditionDomain(this.condition.condition)}.selector.${key}`,
values
`component.${getConditionDomain(this.condition.condition)}.selector.${key}`
);
};
@@ -1,8 +1,7 @@
import "@home-assistant/webawesome/dist/components/divider/divider";
import { mdiClose, mdiPlus } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, queryAll } from "lit/decorators";
import { customElement, property, query } from "lit/decorators";
import { ensureArray } from "../../../../../common/array/ensure-array";
import { fireEvent } from "../../../../../common/dom/fire_event";
import "../../../../../components/ha-icon-button";
@@ -28,25 +27,10 @@ export class HaConversationTrigger
@query("#option_input", true) private _optionInput?: HaInput;
@queryAll("ha-input.option") private _options!: NodeListOf<HaInput>;
public static get defaultConfig(): ConversationTrigger {
return { trigger: "conversation", command: "" };
}
protected updated(changedProps: PropertyValues<this>) {
super.updated(changedProps);
if (changedProps.has("trigger")) {
this._validateOptions();
}
}
private async _validateOptions() {
const options = [...this._options];
await Promise.all(options.map((option) => option.updateComplete));
options.forEach((option) => option.reportValidity());
}
protected render() {
const { command } = this.trigger;
const commands = command ? ensureArray(command) : [];
@@ -57,12 +41,14 @@ export class HaConversationTrigger
(option, index) => html`
<ha-input
class="option"
iconTrailing
.index=${index}
.value=${option}
.validationMessage=${this.hass.localize(
"ui.panel.config.automation.editor.triggers.type.conversation.no_punctuation"
)}
auto-validate
validateOnInitialRender
pattern=${PATTERN}
@change=${this._updateOption}
>
@@ -448,16 +448,12 @@ export class HaPlatformTrigger extends LitElement {
this.requestUpdate("_checkedKeys");
}
private _localizeValueCallback = (
key: string,
values?: Record<string, string>
) => {
private _localizeValueCallback = (key: string) => {
if (!this.trigger?.trigger) {
return "";
}
return this.hass.localize(
`component.${getTriggerDomain(this.trigger.trigger)}.selector.${key}`,
values
`component.${getTriggerDomain(this.trigger.trigger)}.selector.${key}`
);
};
@@ -131,7 +131,7 @@ class HaBackupConfigData extends LitElement {
}
private _hasLocalAddons(addons: BackupAddonItem[]): boolean {
return addons.some((addon) => addon.slug.startsWith("local_"));
return addons.some((addon) => addon.slug === "local");
}
private _estimateBackupSize = memoizeOne(
@@ -29,7 +29,6 @@ interface ProgressSegment {
label: string;
state: SegmentState;
flex: number;
progress?: number;
}
const HA_STAGES: CreateBackupStage[] = ["home_assistant"];
@@ -140,29 +139,12 @@ export class HaBackupOverviewProgress extends LitElement {
private _computeAgentPercent(agentId: string): number | undefined {
const progress = this.uploadProgress[agentId];
// 0 bytes only means the upload started; the agent may never report more.
if (
!progress ||
progress.total_bytes === 0 ||
progress.uploaded_bytes === 0
) {
if (!progress || progress.total_bytes === 0) {
return undefined;
}
return Math.round((progress.uploaded_bytes / progress.total_bytes) * 100);
}
private _computeUploadPercent(): number | undefined {
// Follow the slowest location still uploading. Finished ones are left out:
// already-written locations report 100% at once, and a location that
// doesn't report progress is only known once it has finished.
const percents = Object.keys(this.uploadProgress)
.map((agentId) => this._computeAgentPercent(agentId))
.filter(
(percent): percent is number => percent !== undefined && percent < 100
);
return percents.length ? Math.min(...percents) : undefined;
}
private _getStageGroupIndex(stage: CreateBackupStage): number {
return STAGE_ORDER.findIndex((group) => group.includes(stage));
}
@@ -187,9 +169,6 @@ export class HaBackupOverviewProgress extends LitElement {
: null;
const currentGroupIndex = stage ? this._getStageGroupIndex(stage) : -1;
const uploadProgress = this._isUploadStage
? this._computeUploadPercent()
: undefined;
const isHassio = isComponentLoaded(this.hass.config, "hassio");
if (isHassio) {
@@ -222,7 +201,6 @@ export class HaBackupOverviewProgress extends LitElement {
),
state: this._getSegmentState(3, currentGroupIndex),
flex: 5,
progress: uploadProgress,
},
{
label: this.hass.localize(
@@ -256,7 +234,6 @@ export class HaBackupOverviewProgress extends LitElement {
),
state: this._getSegmentState(3, currentGroupIndex),
flex: 5,
progress: uploadProgress,
},
{
label: this.hass.localize(
@@ -335,18 +312,8 @@ export class HaBackupOverviewProgress extends LitElement {
active: segment.state === "active",
completed: segment.state === "completed",
pending: segment.state === "pending",
determinate: segment.progress !== undefined,
})}"
>
${
segment.progress !== undefined
? html`<div
class="segment-fill"
style="width: ${segment.progress}%"
></div>`
: nothing
}
</div>
></div>
<span class="segment-label">${segment.label}</span>
</div>
`
@@ -363,12 +330,9 @@ export class HaBackupOverviewProgress extends LitElement {
return nothing;
}
// Only upload events tell which locations this backup goes to.
const agents = this.agents.filter(
(agent) => agent.agent_id in this.uploadProgress
);
const hasProgress = Object.keys(this.uploadProgress).length > 0;
if (agents.length === 0) {
if (!hasProgress) {
return nothing;
}
@@ -380,7 +344,7 @@ export class HaBackupOverviewProgress extends LitElement {
}
>
<ha-list-base class="agent-list">
${agents.map((agent) => {
${this.agents.map((agent) => {
const name = computeBackupAgentName(
this.hass.localize,
agent.agent_id,
@@ -498,18 +462,6 @@ export class HaBackupOverviewProgress extends LitElement {
.segment-bar.completed {
background-color: var(--primary-color);
}
.segment-bar.determinate {
background-color: var(--divider-color);
animation: none;
overflow: hidden;
}
.segment-fill {
height: 100%;
border-radius: inherit;
background-color: var(--primary-color);
transition: width 0.3s ease;
animation: pulse 1.5s ease-in-out infinite;
}
.segment-label {
font-size: var(--ha-font-size-xs);
color: var(--secondary-text-color);
@@ -530,10 +482,6 @@ export class HaBackupOverviewProgress extends LitElement {
.segment-bar.active {
animation: none;
}
.segment-fill {
animation: none;
transition: none;
}
}
.agent-list-wrapper {
display: grid;
@@ -10,7 +10,7 @@ import {
} from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, html } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { storage } from "../../../common/decorators/storage";
import type {
@@ -21,11 +21,9 @@ import { fireEvent } from "../../../common/dom/fire_event";
import { navigate } from "../../../common/navigate";
import type { LocalizeFunc } from "../../../common/translations/localize";
import { extractSearchParam } from "../../../common/url/search-params";
import { hasRejectedItems } from "../../../common/util/promise-all-settled-results";
import type {
DataTableColumnContainer,
RowClickedEvent,
SelectionChangedEvent,
SortingChangedEvent,
} from "../../../components/data-table/ha-data-table";
import "../../../components/ha-button";
@@ -51,7 +49,6 @@ import {
showConfirmationDialog,
} from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-tabs-subpage-data-table";
import type { HaTabsSubpageDataTable } from "../../../layouts/hass-tabs-subpage-data-table";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant, Route } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url";
@@ -137,30 +134,16 @@ class HaBlueprintOverview extends LitElement {
private _usageCountRequest = 0;
@state() private _usageCountsLoading = false;
@state() private _selected: string[] = [];
@state() private _deletedFullpaths: string[] = [];
@query("hass-tabs-subpage-data-table", true)
private _dataTable!: HaTabsSubpageDataTable;
private _processedBlueprints = memoizeOne(
(
blueprints: Record<string, Blueprints>,
localize: LocalizeFunc,
usageCounts: Record<string, number>,
deleted: string[]
usageCounts: Record<string, number>
): BlueprintMetaDataPath[] => {
const deletedFullpaths = new Set(deleted);
const result: any[] = [];
Object.entries(blueprints).forEach(([type, typeBlueprints]) =>
Object.entries(typeBlueprints).forEach(([path, blueprint]) => {
const fullpath = `${type}/${path}`;
if (deletedFullpaths.has(fullpath)) {
return;
}
if ("error" in blueprint) {
result.push({
name: blueprint.error,
@@ -331,13 +314,6 @@ class HaBlueprintOverview extends LitElement {
}
}
protected willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
if (changedProps.has("blueprints") && this._deletedFullpaths.length) {
this._deletedFullpaths = [];
}
}
protected updated(changedProps: PropertyValues<this>) {
super.updated(changedProps);
if (changedProps.has("blueprints")) {
@@ -357,8 +333,7 @@ class HaBlueprintOverview extends LitElement {
.data=${this._processedBlueprints(
this.blueprints,
this.hass.localize,
this._usageCounts,
this._deletedFullpaths
this._usageCounts
)}
id="fullpath"
.noDataText=${this.hass.localize(
@@ -367,9 +342,6 @@ class HaBlueprintOverview extends LitElement {
has-fab
clickable
@row-click=${this._handleRowClicked}
selectable
.selected=${this._selected.length}
@selection-changed=${this._handleSelectionChanged}
.appendRow=${html`<div
class="mdc-data-table__cell"
style="width: 100%; text-align: center;"
@@ -406,14 +378,6 @@ class HaBlueprintOverview extends LitElement {
.path=${mdiHelpCircleOutline}
@click=${this._showHelp}
></ha-icon-button>
<ha-assist-chip
slot="selection-bar"
.label=${this.hass.localize(
"ui.panel.config.blueprint.overview.delete_selected"
)}
.disabled=${this._usageCountsLoading || !this._selected.length}
@click=${this._removeSelected}
></ha-assist-chip>
<ha-button slot="fab" size="l" @click=${this._addBlueprintClicked}>
<ha-svg-icon slot="start" .path=${mdiDownload}></ha-svg-icon>
${this.hass.localize(
@@ -468,14 +432,12 @@ class HaBlueprintOverview extends LitElement {
}
const request = ++this._usageCountRequest;
this._usageCountsLoading = true;
const usageCounts: Record<string, number> = {};
const blueprintList = this._processedBlueprints(
this.blueprints,
this.hass.localize,
{},
this._deletedFullpaths
{}
);
await Promise.all(
@@ -501,7 +463,6 @@ class HaBlueprintOverview extends LitElement {
if (request === this._usageCountRequest) {
this._usageCounts = usageCounts;
this._usageCountsLoading = false;
}
}
@@ -509,8 +470,7 @@ class HaBlueprintOverview extends LitElement {
const blueprint = this._processedBlueprints(
this.blueprints,
this.hass.localize,
this._usageCounts,
this._deletedFullpaths
this._usageCounts
).find((b) => b.fullpath === ev.detail.id)!;
if (blueprint.error) {
showAlertDialog(this, {
@@ -535,8 +495,7 @@ class HaBlueprintOverview extends LitElement {
const blueprint = this._processedBlueprints(
this.blueprints,
this.hass.localize,
this._usageCounts,
this._deletedFullpaths
this._usageCounts
).find((item) => item.fullpath === fullpath);
if (!blueprint || blueprint.error) {
return;
@@ -693,102 +652,6 @@ class HaBlueprintOverview extends LitElement {
fireEvent(this, "reload-blueprints");
};
private _handleSelectionChanged = (
ev: HASSDomEvent<SelectionChangedEvent>
): void => {
this._selected = ev.detail.value;
};
private _removeSelected = async () => {
const rows = this._processedBlueprints(
this.blueprints,
this.hass.localize,
this._usageCounts,
this._deletedFullpaths
);
const selectedRows = rows.filter((row) =>
this._selected.includes(row.fullpath)
);
const usageChecks = await Promise.allSettled(
selectedRows.map((row) =>
findRelated(this.hass, `${row.type}_blueprint`, row.path)
)
);
if (usageChecks.some((check) => check.status === "rejected")) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.blueprint.overview.delete_check_failed_title"
),
text: this.hass.localize(
"ui.panel.config.blueprint.overview.delete_check_failed"
),
});
return;
}
const deletable = selectedRows.filter((_row, index) => {
const check = usageChecks[index];
return (
check.status === "fulfilled" &&
!check.value.automation?.length &&
!check.value.script?.length
);
});
if (!deletable.length) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.blueprint.overview.delete_none_deletable_title"
),
text: this.hass.localize(
"ui.panel.config.blueprint.overview.delete_none_deletable"
),
});
return;
}
const allDeletable = deletable.length === selectedRows.length;
let deleteFailed = false;
showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.blueprint.overview.confirm_delete_selected_title"
),
text: this.hass.localize(
allDeletable
? "ui.panel.config.blueprint.overview.confirm_delete_selected"
: "ui.panel.config.blueprint.overview.confirm_delete_selected_partly",
allDeletable
? { count: deletable.length }
: { deletable: deletable.length, selected: selectedRows.length }
),
confirmText: this.hass.localize("ui.common.delete"),
dismissText: this.hass.localize("ui.common.cancel"),
destructive: true,
action: async () => {
const results = await Promise.allSettled(
deletable.map((row) => deleteBlueprint(this.hass, row.type, row.path))
);
const deleted = new Set(
deletable
.filter((_row, index) => results[index].status === "fulfilled")
.map((row) => row.fullpath)
);
this._deletedFullpaths = [...this._deletedFullpaths, ...deleted];
deleteFailed = hasRejectedItems(results);
this._dataTable.clearSelection();
fireEvent(this, "reload-blueprints");
},
}).then((confirmed) => {
if (confirmed && deleteFailed) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.blueprint.overview.delete_failed_title"
),
text: this.hass.localize(
"ui.panel.config.blueprint.overview.delete_failed"
),
});
}
});
};
private _handleSortingChanged(ev: CustomEvent) {
this._activeSorting = ev.detail;
}
-10
View File
@@ -41,7 +41,6 @@ import {
mdiZWave,
} from "@mdi/js";
import memoizeOne from "memoize-one";
import { haLora } from "../../resources/ha-icons";
import type { PageNavigation } from "../../layouts/hass-tabs-subpage";
import type { HomeAssistant } from "../../types";
import { mdiMqttLogo } from "../../resources/mqtt-logo-svg";
@@ -168,15 +167,6 @@ export const configSections: Record<string, PageNavigation[]> = {
translationKey: "knx",
adminOnly: true,
},
{
path: "/config/lorawan",
iconPath: haLora,
iconViewBox: "0 0 250 166",
iconColor: "#009fe3",
component: "lorawan",
translationKey: "lorawan",
adminOnly: true,
},
{
path: "/config/mqtt",
iconPath: mdiMqttLogo,
@@ -5,10 +5,7 @@ import {
mdiLocationExit,
mdiRefresh,
} from "@mdi/js";
import type {
HassEntities,
UnsubscribeFunc,
} from "home-assistant-js-websocket";
import type { HassEntities } from "home-assistant-js-websocket";
import type { TemplateResult, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -16,16 +13,11 @@ import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { supportsFeature } from "../../../common/entity/supports-feature";
import { caseInsensitiveStringCompare } from "../../../common/string/compare";
import "../../../components/animation/ha-fade-in";
import "../../../components/ha-alert";
import "../../../components/ha-button";
import "../../../components/ha-card";
import "../../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import "../../../components/ha-spinner";
import type { ManagerState } from "../../../data/backup_manager";
import { subscribeBackupEvents } from "../../../data/backup_manager";
import type { EntitySources } from "../../../data/entity/entity_sources";
import { fetchEntitySourcesWithCache } from "../../../data/entity/entity_sources";
import { extractApiErrorMessage } from "../../../data/hassio/common";
@@ -52,7 +44,6 @@ import {
} from "../../../data/update";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-subpage";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../../types";
import { showToast } from "../../../util/toast";
import "../dashboard/ha-config-updates";
@@ -69,15 +60,13 @@ const SYSTEM_KEY = "__system__";
const APPS_KEY = "__apps__";
@customElement("ha-config-section-updates")
class HaConfigSectionUpdates extends SubscribeMixin(LitElement) {
class HaConfigSectionUpdates extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@state() private _showSkipped = false;
@state() private _backupState?: ManagerState;
@state() private _supervisorInfo?: HassioSupervisorInfo;
@state() private _entitySources?: EntitySources;
@@ -140,62 +129,6 @@ class HaConfigSectionUpdates extends SubscribeMixin(LitElement) {
this.requestUpdate();
}
protected hassSubscribe(): Promise<UnsubscribeFunc>[] {
if (!this._watchesBackups()) {
return [];
}
return [
subscribeBackupEvents(this.hass, (event) => {
this._backupState = event.manager_state;
}).catch(() => {
// Show the page anyway; a blocked update reports its own error.
this._backupState = "idle";
return () => undefined;
}),
];
}
// The Supervisor only holds back the updates it manages.
private _watchesBackups(): boolean {
return (
isComponentLoaded(this.hass.config, "backup") &&
isComponentLoaded(this.hass.config, "hassio")
);
}
private _backupStateLoading(): boolean {
return this._backupState === undefined && this._watchesBackups();
}
private _backupInProgressAlert() {
switch (this._backupState) {
case "create_backup":
case "receive_backup":
case "restore_backup":
return html`<ha-alert
alert-type="info"
.title=${this.hass.localize(
`ui.panel.config.updates.backup_in_progress.title.${this._backupState}`
)}
>
${this.hass.localize(
`ui.panel.config.updates.backup_in_progress.${this._backupState}`,
{
backups_link: html`<a href="/config/backup"
>${this.hass.localize(
"ui.panel.config.updates.backup_in_progress.backups_link"
)}</a
>`,
}
)}
</ha-alert>`;
default:
return undefined;
}
}
protected render(): TemplateResult {
const installableUpdates = this._filterInstallableUpdateEntities(
this.hass.states,
@@ -220,8 +153,6 @@ class HaConfigSectionUpdates extends SubscribeMixin(LitElement) {
this.hass.locale.language
);
const backupInProgressAlert = this._backupInProgressAlert();
return html`
<hass-subpage
back-path="/config/system"
@@ -276,143 +207,108 @@ class HaConfigSectionUpdates extends SubscribeMixin(LitElement) {
}
</ha-dropdown>
</div>
${
this._backupStateLoading()
? html`
<ha-fade-in class="loading" .delay=${1000}>
<ha-spinner></ha-spinner>
</ha-fade-in>
`
: html`
<div class="content">
${
backupInProgressAlert &&
groups.some(
(group) =>
group.key === APPS_KEY || group.key === SYSTEM_KEY
)
? backupInProgressAlert
: nothing
}
${groups.map(
(group) => html`
<ha-card outlined>
<div class="card-content">
<div class="card-header">
<div class="title" role="heading" aria-level="2">
${group.title}
</div>
${
group.showUpdateButton
? html`
<ha-button
appearance="plain"
size="s"
.group=${group}
.disabled=${
(!!backupInProgressAlert &&
group.key === APPS_KEY) ||
group.entities.every((entity) =>
updateIsInstalling(entity)
)
}
@click=${this._updateGroup}
>
${this.hass.localize(
group.entities.length > 1
? "ui.panel.config.updates.update_all"
: "ui.common.update"
)}
</ha-button>
`
: nothing
}
</div>
<ha-config-updates
.narrow=${this.narrow}
.updateEntities=${group.entities}
showAll
></ha-config-updates>
</div>
</ha-card>
`
)}
${
skippedUpdates.length
? html`
<ha-card outlined>
<div class="card-content">
<div class="card-header">
<div
class="title"
role="heading"
aria-level="2"
>
${this.hass.localize(
"ui.panel.config.updates.title_skipped",
{
count: skippedUpdates.length,
}
)}
</div>
</div>
<ha-config-updates
.narrow=${this.narrow}
.updateEntities=${skippedUpdates}
showAll
></ha-config-updates>
</div>
</ha-card>
`
: nothing
}
${
notInstallableUpdates.length
? html`
<ha-card outlined>
<div class="card-content">
<div class="card-header">
<div
class="title"
role="heading"
aria-level="2"
>
${this.hass.localize(
"ui.panel.config.updates.title_not_installable",
{
count: notInstallableUpdates.length,
}
)}
</div>
</div>
<ha-config-updates
.narrow=${this.narrow}
.updateEntities=${notInstallableUpdates}
showAll
></ha-config-updates>
</div>
</ha-card>
`
: nothing
}
${
groups.length +
notInstallableUpdates.length +
skippedUpdates.length
? nothing
: html`
<ha-card outlined>
<div class="no-updates">
${this.hass.localize(
"ui.panel.config.updates.no_updates"
<div class="content">
${groups.map(
(group) => html`
<ha-card outlined>
<div class="card-content">
<div class="card-header">
<div class="title" role="heading" aria-level="2">
${group.title}
</div>
${
group.showUpdateButton
? html`
<ha-button
appearance="plain"
size="s"
.group=${group}
.disabled=${group.entities.every((entity) =>
updateIsInstalling(entity)
)}
</div>
</ha-card>
`
}
@click=${this._updateGroup}
>
${this.hass.localize(
group.entities.length > 1
? "ui.panel.config.updates.update_all"
: "ui.common.update"
)}
</ha-button>
`
: nothing
}
</div>
<ha-config-updates
.narrow=${this.narrow}
.updateEntities=${group.entities}
showAll
></ha-config-updates>
</div>
`
}
</ha-card>
`
)}
${
skippedUpdates.length
? html`
<ha-card outlined>
<div class="card-content">
<div class="card-header">
<div class="title" role="heading" aria-level="2">
${this.hass.localize(
"ui.panel.config.updates.title_skipped",
{
count: skippedUpdates.length,
}
)}
</div>
</div>
<ha-config-updates
.narrow=${this.narrow}
.updateEntities=${skippedUpdates}
showAll
></ha-config-updates>
</div>
</ha-card>
`
: nothing
}
${
notInstallableUpdates.length
? html`
<ha-card outlined>
<div class="card-content">
<div class="card-header">
<div class="title" role="heading" aria-level="2">
${this.hass.localize(
"ui.panel.config.updates.title_not_installable",
{
count: notInstallableUpdates.length,
}
)}
</div>
</div>
<ha-config-updates
.narrow=${this.narrow}
.updateEntities=${notInstallableUpdates}
showAll
></ha-config-updates>
</div>
</ha-card>
`
: nothing
}
${
groups.length + notInstallableUpdates.length + skippedUpdates.length
? nothing
: html`
<ha-card outlined>
<div class="no-updates">
${this.hass.localize("ui.panel.config.updates.no_updates")}
</div>
</ha-card>
`
}
</div>
</hass-subpage>
`;
}
@@ -620,16 +516,6 @@ class HaConfigSectionUpdates extends SubscribeMixin(LitElement) {
ha-config-updates {
margin-bottom: 8px;
}
ha-alert {
display: block;
max-width: 600px;
margin: 0 auto var(--ha-space-6);
}
.loading {
display: flex;
justify-content: center;
padding: var(--ha-space-8) 0;
}
.card-content {
display: flex;
@@ -62,9 +62,13 @@ export class HaDeviceCard extends LitElement {
return html`
<ha-card
outlined
.header=${this.hass.localize(
`ui.panel.config.devices.${this.device.entry_type || "device"}_info_heading`
)}
.header=${this.hass.localize("ui.panel.config.devices.device_info", {
type: this.hass.localize(
`ui.panel.config.devices.type.${
this.device.entry_type || "device"
}_heading`
),
})}
>
<div class="card-content">
${
@@ -1,58 +0,0 @@
import type { PropertyValues } from "lit";
import { LitElement, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../../../../components/ha-expansion-panel";
import type { DeviceRegistryEntry } from "../../../../../../data/device/device_registry";
import type { LoRaWANDevice } from "../../../../../../data/lorawan";
import { subscribeLoRaWANDevices } from "../../../../../../data/lorawan";
import { SubscribeMixin } from "../../../../../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../../../../../types";
import "../../../../integrations/integration-panels/lorawan/lorawan-device-details";
@customElement("ha-device-info-lorawan")
export class HaDeviceInfoLoRaWAN extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public device!: DeviceRegistryEntry;
@state() private _device?: LoRaWANDevice;
protected hassSubscribeRequiredHostProps = ["device"];
private _devices: LoRaWANDevice[] = [];
protected willUpdate(changedProperties: PropertyValues<this>) {
super.willUpdate(changedProperties);
if (changedProperties.has("device")) {
this._device = this._devices.find(
(device) => device.device_id === this.device.id
);
}
}
protected hassSubscribe() {
return [
subscribeLoRaWANDevices(this.hass.connection, ({ devices }) => {
this._devices = devices;
this._device = devices.find(
(device) => device.device_id === this.device.id
);
}),
];
}
protected render() {
return this._device
? html`<ha-expansion-panel
.header=${this.hass.localize("ui.panel.config.lorawan.device_info")}
><lorawan-device-details
.device=${this._device}
></lorawan-device-details
></ha-expansion-panel>`
: nothing;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-device-info-lorawan": HaDeviceInfoLoRaWAN;
}
}
@@ -1511,22 +1511,6 @@ export class HaConfigDevicePage extends LitElement {
deviceInfo: TemplateResult[]
) {
const domains = integrations.map((int) => int.domain);
if (
this.hass.user?.is_admin &&
this.manifests.some(
(manifest) =>
domains.includes(manifest.domain) &&
manifest.dependencies?.includes("lorawan")
)
) {
import("./device-detail/integration-elements/lorawan/ha-device-info-lorawan");
deviceInfo.push(
html`<ha-device-info-lorawan
.hass=${this.hass}
.device=${device}
></ha-device-info-lorawan>`
);
}
if (domains.includes("zha")) {
import("./device-detail/integration-elements/zha/ha-device-info-zha");
deviceInfo.push(html`
-5
View File
@@ -196,11 +196,6 @@ class HaPanelConfig extends HassRouterPage {
load: () =>
import("./integrations/integration-panels/zha/zha-config-dashboard-router"),
},
lorawan: {
tag: "lorawan-router",
load: () =>
import("./integrations/integration-panels/lorawan/lorawan-router"),
},
mqtt: {
tag: "mqtt-config-panel",
load: () =>
+213 -17
View File
@@ -4,18 +4,25 @@ import {
type UnsubscribeFunc,
} from "home-assistant-js-websocket";
import type { CSSResultGroup } from "lit";
import { mdiCircle, mdiWrap, mdiWrapDisabled } from "@mdi/js";
import {
mdiArrowCollapseDown,
mdiCircle,
mdiWrap,
mdiWrapDisabled,
} from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import type { HASSDomTargetEvent } from "../../../common/dom/fire_event";
import { consume } from "../../../common/decorators/consume";
import { debounce } from "../../../common/util/debounce";
import "../../../components/ha-alert";
import "../../../components/ha-ansi-to-html";
import type { HaAnsiToHtml } from "../../../components/ha-ansi-to-html";
import "../../../components/ha-button";
import "../../../components/ha-dialog";
import "../../../components/ha-dialog-footer";
import "../../../components/ha-icon-button";
import "../../../components/ha-log-viewer";
import type { HaLogViewer } from "../../../components/ha-log-viewer";
import "../../../components/ha-svg-icon";
import "../../../components/input/ha-input-search";
import type { HaInputSearch } from "../../../components/input/ha-input-search";
@@ -33,6 +40,12 @@ import type { LiveLogDialogParams } from "./show-dialog-live-log";
// eslint-disable-next-line no-control-regex
const ANSI_ESCAPE = /\x1b\[[0-9;]*m/g;
// Stay close to the bottom to keep following new lines
const SCROLL_FOLLOW_MARGIN = 40;
// Keep only the newest lines so memory stays bounded
const DEFAULT_LOG_LINE_LIMIT = 5000;
@customElement("dialog-live-log")
class DialogLiveLog extends DialogMixin<LiveLogDialogParams>(LitElement) {
@state()
@@ -51,16 +64,29 @@ class DialogLiveLog extends DialogMixin<LiveLogDialogParams>(LitElement) {
@state() private _live = false;
@state() private _newLogsIndicator = false;
@state() private _search = "";
@state() private _wrapLines = true;
@query("ha-log-viewer") private _logViewer?: HaLogViewer;
@state() private _noMatches = false;
@query(".log") private _logElement?: HTMLElement;
@query("ha-ansi-to-html") private _ansiToHtml?: HaAnsiToHtml;
private _lines: string[] = [];
// Number of stored lines already rendered in the view
private _shownCount = 0;
private _unsub?: Promise<UnsubscribeFunc>;
private _resubscribeOnReady = false;
private _followFrame?: number;
connectedCallback() {
super.connectedCallback();
const connection = this._connection.connection;
@@ -121,39 +147,153 @@ class DialogLiveLog extends DialogMixin<LiveLogDialogParams>(LitElement) {
}
this._unsub = undefined;
this._resubscribeOnReady = false;
this._cancelFollowBottom();
this._lines = [];
this._shownCount = 0;
this._lineCount = 0;
this._logViewer?.clear();
this._newLogsIndicator = false;
this._noMatches = false;
this._ansiToHtml?.clear();
this._subscribe();
};
private _unsubscribe() {
this._cancelFollowBottom();
this._debounceSearch.cancel();
// _subscribe reports subscription errors, so only ignore them here
this._unsub?.then((unsub) => unsub()).catch(() => undefined);
this._unsub = undefined;
this._live = false;
}
private _isScrolledToBottom(): boolean {
const log = this._logElement;
return (
!log ||
log.scrollHeight - log.scrollTop - log.clientHeight < SCROLL_FOLLOW_MARGIN
);
}
private _handleScroll() {
if (this._newLogsIndicator && this._isScrolledToBottom()) {
this._newLogsIndicator = false;
}
}
private _scrollToBottom() {
this._newLogsIndicator = false;
this._logElement?.scrollTo(0, this._logElement.scrollHeight);
}
private _followBottom() {
this._cancelFollowBottom();
const logElement = this._logElement;
const scrollTopBeforeFrame = logElement ? logElement.scrollTop : 0;
this._followFrame = requestAnimationFrame(() => {
this._followFrame = undefined;
if (logElement && logElement.scrollTop === scrollTopBeforeFrame) {
logElement.scrollTop = logElement.scrollHeight;
}
});
}
private _cancelFollowBottom() {
if (this._followFrame !== undefined) {
cancelAnimationFrame(this._followFrame);
this._followFrame = undefined;
}
}
private _addLines(lines: string[]) {
const logViewer = this._logViewer;
if (!logViewer) {
const newLines = lines
.flatMap((line) => line.split("\n"))
.slice(-DEFAULT_LOG_LINE_LIMIT);
if (!newLines.length) {
return;
}
logViewer.addLines(lines);
this._lineCount = logViewer.lines.length;
const follow = this._isScrolledToBottom();
this._lines = this._lines.concat(newLines);
this._trimLines();
this._lineCount = this._lines.length;
this._showNewLines(follow);
}
private _trimLines() {
const excessLineCount = this._lines.length - DEFAULT_LOG_LINE_LIMIT;
if (excessLineCount <= 0) {
return;
}
const logElement = this._logElement;
const scrollTopBeforeTrimming = logElement ? logElement.scrollTop : 0;
const scrollHeightBeforeTrimming = logElement ? logElement.scrollHeight : 0;
this._lines.splice(0, excessLineCount);
this._shownCount = Math.max(0, this._shownCount - excessLineCount);
if (this._ansiToHtml) {
this._ansiToHtml.trimLines(this._shownCount);
}
if (logElement) {
const removedHeight =
scrollHeightBeforeTrimming - logElement.scrollHeight;
logElement.scrollTop = Math.max(
0,
scrollTopBeforeTrimming - removedHeight
);
}
}
private _showNewLines(follow: boolean) {
const newLines = this._lines.slice(this._shownCount);
if (!newLines.length) {
return;
}
this._shownCount = this._lines.length;
this._renderLines(newLines);
if (follow) {
this._followBottom();
} else {
this._newLogsIndicator = true;
}
}
private _renderLines(lines: string[]) {
const ansiToHtml = this._ansiToHtml;
if (!ansiToHtml) {
return;
}
if (!this._search) {
ansiToHtml.parseLinesToColoredPre(lines);
return;
}
// ha-ansi-to-html re-filters all lines after every added line while a
// search is set, so search once after adding them
ansiToHtml.filterLines("");
ansiToHtml.parseLinesToColoredPre(lines);
this._noMatches =
this._shownCount > 0 && !ansiToHtml.filterLines(this._search);
}
private _searchChanged(ev: HASSDomTargetEvent<HaInputSearch>) {
this._search = ev.target.value ?? "";
this._debounceSearch();
}
private _debounceSearch = debounce(() => {
if (this._ansiToHtml) {
this._noMatches =
this._shownCount > 0 && !this._ansiToHtml.filterLines(this._search);
}
if (!this._search) {
this._scrollToBottom();
}
}, 150);
private _toggleLineWrap() {
this._wrapLines = !this._wrapLines;
}
private _download() {
const text = (this._logViewer?.lines ?? [])
.join("\n")
.replace(ANSI_ESCAPE, "");
const text = this._lines.join("\n").replace(ANSI_ESCAPE, "");
const timeString = new Date().toISOString().replace(/:/g, "-");
const url = URL.createObjectURL(new Blob([text], { type: "text/plain" }));
fileDownload(
@@ -202,10 +342,7 @@ class DialogLiveLog extends DialogMixin<LiveLogDialogParams>(LitElement) {
></ha-icon-button>
</div>
<div class="log-container">
<ha-log-viewer
.filter=${this._search}
?wrap-disabled=${!this._wrapLines}
>
<div class="log" @scroll=${this._handleScroll}>
${
this._lineCount
? nothing
@@ -216,7 +353,38 @@ class DialogLiveLog extends DialogMixin<LiveLogDialogParams>(LitElement) {
)}
</div>`
}
</ha-log-viewer>
${
this._noMatches
? html`<div class="waiting">
${this._i18n.localize(
"ui.panel.config.integrations.config_entry.live_log.no_matches"
)}
</div>`
: nothing
}
<ha-ansi-to-html
?wrap-disabled=${!this._wrapLines}
></ha-ansi-to-html>
</div>
<ha-button
class="new-logs-indicator ${classMap({
visible: this._newLogsIndicator,
})}"
size="s"
appearance="filled"
.disabled=${!this._newLogsIndicator}
@click=${this._scrollToBottom}
>
<ha-svg-icon
.path=${mdiArrowCollapseDown}
slot="start"
></ha-svg-icon>
${this._i18n.localize("ui.panel.config.logs.scroll_down_button")}
<ha-svg-icon
.path=${mdiArrowCollapseDown}
slot="end"
></ha-svg-icon>
</ha-button>
${
this._live
? html`<div class="live-indicator">
@@ -263,9 +431,34 @@ class DialogLiveLog extends DialogMixin<LiveLogDialogParams>(LitElement) {
position: relative;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
border: 1px solid var(--divider-color);
border-radius: var(--ha-border-radius-md);
overflow: hidden;
}
.log {
/* Fills the dialog when it is full screen on narrow screens */
flex: 1 1 60vh;
min-height: 0;
padding: var(--ha-space-2) var(--ha-space-2) var(--ha-space-8);
box-sizing: border-box;
overflow: auto;
overflow-anchor: none;
direction: ltr;
font-family: var(--ha-font-family-code);
font-size: var(--ha-font-size-s);
}
.new-logs-indicator {
overflow: hidden;
position: absolute;
bottom: 4px;
inset-inline-start: 4px;
height: 0;
transition: height 0.4s ease-out;
}
.new-logs-indicator.visible {
height: 32px;
}
@keyframes breathe {
from {
@@ -292,6 +485,9 @@ class DialogLiveLog extends DialogMixin<LiveLogDialogParams>(LitElement) {
width: 14px;
}
@media (prefers-reduced-motion: reduce) {
.new-logs-indicator {
transition: none;
}
.live-indicator ha-svg-icon {
animation: none;
}
@@ -162,7 +162,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
@state() private _logInfo?: IntegrationLogInfo;
@state() private _searchParms = new URLSearchParams(
__DEMO__ ? window.location.search : window.location.hash.substring(1)
window.location.hash.substring(1)
);
@state() private _filter = "";
@@ -177,9 +177,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
private _configEntryRows!: NodeListOf<HaConfigEntryRow>;
private _handleHashChange = () => {
this._searchParms = new URLSearchParams(
__DEMO__ ? window.location.search : window.location.hash.substring(1)
);
this._searchParms = new URLSearchParams(window.location.hash.substring(1));
};
private _domainConfigEntries = memoizeOne(
@@ -1080,8 +1080,8 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
minmax(min(300px, 100%), 1fr)
);
gap: var(--ha-space-4);
padding: var(--ha-space-6) var(--ha-space-4) 0;
margin: 0;
padding: var(--ha-space-4);
margin: var(--ha-space-2);
}
.empty-message {
margin: auto;
@@ -1166,7 +1166,10 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
position: relative;
}
h1 {
margin: var(--ha-space-6) var(--ha-space-4) 0;
margin-top: 8px;
margin-left: 16px;
margin-inline-start: 16px;
margin-inline-end: initial;
}
ha-dropdown ha-icon-button {
color: var(--primary-text-color);
@@ -1,33 +0,0 @@
import type { LoRaWANConnection } from "../../../../../data/lorawan";
export const connectionLabels = (connections: LoRaWANConnection[]) => {
const connectionsByTitle = new Map<string, LoRaWANConnection[]>();
for (const connection of connections) {
const matching = connectionsByTitle.get(connection.title);
if (matching) {
matching.push(connection);
} else {
connectionsByTitle.set(connection.title, [connection]);
}
}
const usedLabels = new Set(connectionsByTitle.keys());
const labels = new Map<string, string>();
for (const [title, matching] of connectionsByTitle) {
if (matching.length === 1) {
labels.set(matching[0].entry_id, title);
continue;
}
let index = 1;
for (const connection of matching.sort((a, b) =>
a.entry_id.localeCompare(b.entry_id)
)) {
let label: string;
do {
label = `${title} (${index++})`;
} while (usedLabels.has(label));
usedLabels.add(label);
labels.set(connection.entry_id, label);
}
}
return labels;
};
@@ -1,145 +0,0 @@
import type { ContextType } from "@lit/context";
import { mdiClose, mdiOpenInNew } from "@mdi/js";
import { LitElement, css, html, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import { consume } from "../../../../../common/decorators/consume";
import type { HASSDomCurrentTargetEvent } from "../../../../../common/dom/fire_event";
import "../../../../../components/ha-dialog";
import "../../../../../components/ha-dialog-footer";
import "../../../../../components/ha-icon-next";
import "../../../../../components/ha-icon-button";
import "../../../../../components/ha-icon-button-prev";
import "../../../../../components/ha-svg-icon";
import "../../../../../components/item/ha-list-item-button";
import "../../../../../components/list/ha-list-nav";
import {
connectionContext,
internationalizationContext,
uiContext,
} from "../../../../../data/context";
import type { LoRaWANConnection } from "../../../../../data/lorawan";
import { DialogMixin } from "../../../../../dialogs/dialog-mixin";
import { brandsUrl } from "../../../../../util/brands-url";
@customElement("dialog-lorawan-add-device")
class DialogLoRaWANAddDevice extends DialogMixin<{
connections: LoRaWANConnection[];
}>(LitElement) {
@state() private _selected?: LoRaWANConnection;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@state()
@consume({ context: uiContext, subscribe: true })
private _ui!: ContextType<typeof uiContext>;
@consume({ context: connectionContext, subscribe: true })
private _connection!: ContextType<typeof connectionContext>;
protected render() {
if (!this.params) return nothing;
const selected = this._selected;
return html`<ha-dialog
open
type="alert"
class=${selected ? "" : "connection-picker"}
.headerTitle=${this._i18n.localize(
selected
? "ui.panel.config.lorawan.add_device"
: "ui.panel.config.lorawan.add_device_dialog.choose_connection"
)}
.headerSubtitle=${selected?.title}
>
${selected ? html`<ha-icon-button-prev slot="headerNavigationIcon" .label=${this._i18n.localize("ui.common.back")} @click=${this._back}></ha-icon-button-prev>` : html`<span slot="headerNavigationIcon"></span>`}
<ha-icon-button
slot="headerActionItems"
.path=${mdiClose}
.label=${this._i18n.localize("ui.common.close")}
@click=${this.closeDialog}
></ha-icon-button>
${
selected
? html`<p>
${this._i18n.localize(
selected.configuration_url
? "ui.panel.config.lorawan.add_device_dialog.instructions"
: "ui.panel.config.lorawan.add_device_dialog.no_console"
)}
</p>`
: html`
<ha-list-nav
.ariaLabel=${this._i18n.localize("ui.panel.config.lorawan.connection")}
>
${this.params.connections.map(
(connection) =>
html`<ha-list-item-button
.connection=${connection}
@click=${this._selectConnection}
>
<img
slot="start"
alt=""
width="32"
height="32"
src=${brandsUrl({ domain: connection.domain, type: "icon", darkOptimized: this._ui.themes.darkMode }, this._connection.hassUrl())}
/>
<span slot="headline">${connection.title}</span>
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>`
)}
</ha-list-nav>
`
}
${
selected?.configuration_url
? html`<ha-dialog-footer slot="footer">
<a
slot="primaryAction"
href=${selected.configuration_url}
target="_blank"
rel="noopener noreferrer"
>
${this._i18n.localize("ui.panel.config.lorawan.add_device_dialog.open_console")}
<ha-svg-icon .path=${mdiOpenInNew}></ha-svg-icon>
</a>
</ha-dialog-footer>`
: nothing
}
</ha-dialog>`;
}
private _selectConnection(
ev: HASSDomCurrentTargetEvent<
HTMLElement & { connection: LoRaWANConnection }
>
) {
this._selected = ev.currentTarget.connection;
}
private _back() {
this._selected = undefined;
}
static styles = css`
.connection-picker {
--dialog-content-padding: 0;
}
a {
display: inline-flex;
align-items: center;
gap: var(--ha-space-2);
color: var(--primary-color);
}
a ha-svg-icon {
--mdc-icon-size: 18px;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"dialog-lorawan-add-device": DialogLoRaWANAddDevice;
}
}
Loaded 100 of 171 files, more files were not shown because too many files have changed in this diff. Show more