mirror of
https://github.com/home-assistant/frontend.git
synced 2026-10-19 04:11:00 +00:00
Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1801441779 | ||
|
|
2cdb57b00f | ||
|
|
a6fe937d45 | ||
|
|
ffb78bd21f | ||
|
|
bb436667d5 | ||
|
|
aca2ba2745 | ||
|
|
807b037650 | ||
|
|
277ae199e5 | ||
|
|
e0aece68aa | ||
|
|
d6449e5707 | ||
|
|
54e123c469 | ||
|
|
54f674fd47 | ||
|
|
8a3e7e8e62 | ||
|
|
b8dbf2e419 | ||
|
|
a23759bf63 | ||
|
|
6f2f62a602 | ||
|
|
dd1b392b1f | ||
|
|
b399dac788 | ||
|
|
b0f498b5d3 | ||
|
|
2d71337e12 | ||
|
|
c7064d6dd3 | ||
|
|
7daae8a8cb | ||
|
|
cf2e49aaad | ||
|
|
545f518a02 | ||
|
|
9e98e6cdb9 | ||
|
|
dba7a5f238 | ||
|
|
b453b05e6a | ||
|
|
73d41a7c6a | ||
|
|
26a796e308 | ||
|
|
52a12f08ce | ||
|
|
a83511ec2f | ||
|
|
03447fb4a1 | ||
|
|
f2210e9081 | ||
|
|
0fdbac3703 | ||
|
|
23d622958d | ||
|
|
d7e9877c4e | ||
|
|
5f13af579d | ||
|
|
a83d32f7c9 | ||
|
|
7009352285 | ||
|
|
1c6135efc3 | ||
|
|
9b9900ef05 | ||
|
|
e401e69d09 | ||
|
|
7c74f45e5e | ||
|
|
05848f0df4 | ||
|
|
bfa14296d7 | ||
|
|
ba3722b983 |
No files matched your search
@@ -58,7 +58,7 @@ Readiness and invalidation:
|
||||
|
||||
Repository-owned contracts:
|
||||
|
||||
- Consult the public [frontend developer documentation](https://developers.home-assistant.io/docs/frontend/) for documented architecture, data flow, design, and development workflows.
|
||||
- Consult the public [frontend contributor documentation](https://contributors.home-assistant.io/frontend/) for documented architecture, data flow, design, and development workflows.
|
||||
- For new leaf components, load `ha-frontend-contexts` and verify they consume narrow contexts instead of introducing a broad `hass` property; containers and external APIs may still require `hass`.
|
||||
- Verify backend assumptions against the owning Core, Supervisor, or WebSocket implementation, and component assumptions against the exported component contract.
|
||||
- Prefer canonical repository helpers and test setup over duplicate local implementations.
|
||||
|
||||
@@ -105,4 +105,4 @@ To help with the load of incoming pull requests:
|
||||
Below, some useful links you could explore:
|
||||
-->
|
||||
[docs-repository]: https://github.com/home-assistant/home-assistant.io
|
||||
[perfect-pr]: https://developers.home-assistant.io/docs/review-process/#creating-the-perfect-pr
|
||||
[perfect-pr]: https://contributors.home-assistant.io/start/pull-requests/#before-you-open-it
|
||||
@@ -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 [developer docs](https://developers.home-assistant.io/docs/review-process) ` +
|
||||
`for more on creating a great pull request (see point 6).`;
|
||||
`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.`;
|
||||
|
||||
if (existing) {
|
||||
await withRetry("comment update", () =>
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
name: Prepare container dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.64.0-noble
|
||||
image: mcr.microsoft.com/playwright:v1.64.1-noble
|
||||
options: --user 1001
|
||||
defaults:
|
||||
run:
|
||||
@@ -155,7 +155,7 @@ jobs:
|
||||
- prepare-container-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.64.0-noble
|
||||
image: mcr.microsoft.com/playwright:v1.64.1-noble
|
||||
options: --user 1001 --ipc=host
|
||||
defaults:
|
||||
run:
|
||||
@@ -207,7 +207,7 @@ jobs:
|
||||
- prepare-container-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.64.0-noble
|
||||
image: mcr.microsoft.com/playwright:v1.64.1-noble
|
||||
options: --user 1001 --ipc=host
|
||||
defaults:
|
||||
run:
|
||||
@@ -261,7 +261,7 @@ jobs:
|
||||
- prepare-container-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.64.0-noble
|
||||
image: mcr.microsoft.com/playwright:v1.64.1-noble
|
||||
options: --user 1001 --ipc=host
|
||||
defaults:
|
||||
run:
|
||||
|
||||
+1
-1
@@ -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://developers.home-assistant.io/docs/ai_policy>. In case of differences,
|
||||
<https://contributors.home-assistant.io/start/ai-policy/>. In case of differences,
|
||||
the published version applies.
|
||||
@@ -6,12 +6,12 @@ This is the repository for the official [Home Assistant](https://home-assistant.
|
||||
|
||||
- [View demo of Home Assistant](https://demo.home-assistant.io/)
|
||||
- [More information about Home Assistant](https://home-assistant.io)
|
||||
- [Frontend development instructions](https://developers.home-assistant.io/docs/frontend/development/)
|
||||
- [Frontend development instructions](https://contributors.home-assistant.io/frontend/development/)
|
||||
|
||||
## Development
|
||||
|
||||
- Initial setup: `script/setup`
|
||||
- Development: [Instructions](https://developers.home-assistant.io/docs/frontend/development/)
|
||||
- Development: [Instructions](https://contributors.home-assistant.io/frontend/development/)
|
||||
- Production build: `pnpm build`
|
||||
- Gallery: `cd gallery && script/develop_gallery`
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { castContext } from "../cast_context";
|
||||
|
||||
export const castDemoLovelace: () => LovelaceConfig = () => {
|
||||
const touchSupported =
|
||||
castContext.getDeviceCapabilities().touch_input_supported;
|
||||
castContext.getDeviceCapabilities()?.touch_input_supported;
|
||||
return {
|
||||
views: [
|
||||
{
|
||||
|
||||
@@ -19,7 +19,7 @@ const playerStylesAdded = false;
|
||||
let controls: HTMLElement | null;
|
||||
|
||||
const setTouchControlsVisibility = (visible: boolean) => {
|
||||
if (!castContext.getDeviceCapabilities().touch_input_supported) {
|
||||
if (!castContext.getDeviceCapabilities()?.touch_input_supported) {
|
||||
return;
|
||||
}
|
||||
controls =
|
||||
@@ -51,7 +51,7 @@ const playDummyMedia = (viewTitle?: string) => {
|
||||
clearTimeout(timeOut);
|
||||
timeOut = undefined;
|
||||
}
|
||||
if (castContext.getDeviceCapabilities().touch_input_supported) {
|
||||
if (castContext.getDeviceCapabilities()?.touch_input_supported) {
|
||||
timeOut = window.setTimeout(() => playDummyMedia(viewTitle), 540000); // repeat every 9 minutes to keep it active (gets deactivated after 10 minutes)
|
||||
}
|
||||
};
|
||||
|
||||
+68
-2
@@ -142,7 +142,45 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
(localize?: LocalizeFunc) => localize ?? this.hass!.localize
|
||||
);
|
||||
|
||||
mockLovelace(hass, localizePromise);
|
||||
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",
|
||||
},
|
||||
]);
|
||||
mockAuth(hass);
|
||||
mockTranslations(hass, localizePromise);
|
||||
mockHistory(hass);
|
||||
@@ -172,7 +210,35 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
mockAreaRegistry(hass);
|
||||
mockDeviceRegistry(hass, demoDevices);
|
||||
mockFloorRegistry(hass);
|
||||
mockLabelRegistry(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,
|
||||
},
|
||||
]);
|
||||
mockUsagePrediction(hass);
|
||||
mockEntityRegistryDisplay(hass);
|
||||
mockEntityRegistry(hass, [
|
||||
|
||||
@@ -42,4 +42,12 @@ export const mockBlueprint = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("blueprint/list", (msg: { domain: BlueprintDomain }) =>
|
||||
msg.domain === "script" ? scriptBlueprints : automationBlueprints
|
||||
);
|
||||
hass.mockWS(
|
||||
"blueprint/delete",
|
||||
(msg: { domain: BlueprintDomain; path: string }) => {
|
||||
const blueprints =
|
||||
msg.domain === "script" ? scriptBlueprints : automationBlueprints;
|
||||
delete blueprints[msg.path];
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { lorawanFixtures } from "./lorawan/fixtures";
|
||||
import { bluetoothFixtures } from "./bluetooth/fixtures";
|
||||
import { infraredFixtures } from "./infrared/fixtures";
|
||||
import { matterFixtures } from "./matter/fixtures";
|
||||
@@ -15,6 +16,7 @@ import { zwaveJsFixtures } from "./zwave_js/fixtures";
|
||||
// data to mock. Each owns its own fixtures, so they can be added and removed
|
||||
// one at a time.
|
||||
const INTEGRATIONS: ConnectivityFixtures[] = [
|
||||
lorawanFixtures,
|
||||
bluetoothFixtures,
|
||||
serialFixtures,
|
||||
modbusFixtures,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { mockLoRaWAN } from "./lorawan/mock";
|
||||
import type { MockHomeAssistant } from "../../../../src/fake_data/provide_hass";
|
||||
import { mockBluetooth } from "./bluetooth/mock";
|
||||
import { mockMatter } from "./matter/mock";
|
||||
@@ -11,6 +12,7 @@ import { mockZwaveJs } from "./zwave_js/mock";
|
||||
|
||||
// The WebSocket mocks, code-split into the config panel chunk.
|
||||
const MOCKS = [
|
||||
mockLoRaWAN,
|
||||
mockBluetooth,
|
||||
mockSerial,
|
||||
mockModbus,
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
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",
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { LoRaWANDevice } from "../../../../../src/data/lorawan";
|
||||
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
|
||||
import { minutesAgo } from "../helpers";
|
||||
import { emitInitial } from "../subscription";
|
||||
import { CHIRPSTACK_ENTRY, TTN_ENTRY } from "./fixtures";
|
||||
|
||||
const devices = (): LoRaWANDevice[] => {
|
||||
const base: LoRaWANDevice = {
|
||||
dev_eui: "0000000000000001",
|
||||
name: "Greenhouse",
|
||||
manufacturer: "Seeed Studio",
|
||||
model: "S2101",
|
||||
application_id: "home-sensors",
|
||||
device_id: "lorawan-greenhouse",
|
||||
connection: {
|
||||
entry_id: CHIRPSTACK_ENTRY,
|
||||
domain: "chirpstack",
|
||||
title: "Home ChirpStack",
|
||||
configuration_url: "https://www.chirpstack.io/",
|
||||
},
|
||||
support: "supported",
|
||||
last_uplink: minutesAgo(2),
|
||||
last_join: minutesAgo(2400),
|
||||
last_status: minutesAgo(64),
|
||||
receptions: [{ gateway_id: "home-indoor", rssi: -67, snr: 9.5 }],
|
||||
battery_level: 87,
|
||||
external_power_source: false,
|
||||
downlink_margin: 12,
|
||||
};
|
||||
return [
|
||||
base,
|
||||
{
|
||||
...base,
|
||||
dev_eui: "0000000000000002",
|
||||
name: "Study air quality",
|
||||
model: "S2103",
|
||||
device_id: "lorawan-study",
|
||||
application_id: "demo-home",
|
||||
connection: {
|
||||
entry_id: TTN_ENTRY,
|
||||
domain: "thethingsnetwork_lorawan",
|
||||
title: "The Things Network",
|
||||
configuration_url: "https://eu1.cloud.thethings.network/console",
|
||||
},
|
||||
receptions: [
|
||||
{ gateway_id: "brooklyn-rooftop", rssi: -103, snr: -2.5 },
|
||||
{ gateway_id: "brooklyn-community", rssi: -110, snr: -5.2 },
|
||||
],
|
||||
battery_level: 64,
|
||||
last_uplink: minutesAgo(4),
|
||||
last_status: minutesAgo(140),
|
||||
},
|
||||
{
|
||||
...base,
|
||||
dev_eui: "24E124128E123456",
|
||||
name: "Irrigation controller",
|
||||
manufacturer: "Milesight",
|
||||
model: "UC511",
|
||||
device_id: null,
|
||||
support: "integration_not_configured",
|
||||
battery_level: 93,
|
||||
last_uplink: minutesAgo(7),
|
||||
receptions: [{ gateway_id: "home-indoor", rssi: -83, snr: 6.8 }],
|
||||
},
|
||||
{
|
||||
...base,
|
||||
dev_eui: "A84041FFFF123456",
|
||||
name: "Garage power meter",
|
||||
manufacturer: "",
|
||||
model: "",
|
||||
device_id: null,
|
||||
support: "missing_identity",
|
||||
battery_level: null,
|
||||
external_power_source: true,
|
||||
last_uplink: minutesAgo(1),
|
||||
receptions: [{ gateway_id: "home-indoor", rssi: -75, snr: 8.1 }],
|
||||
},
|
||||
{
|
||||
...base,
|
||||
dev_eui: "2CF7F1C071500199",
|
||||
name: "New soil sensor",
|
||||
model: "S2105",
|
||||
device_id: null,
|
||||
support: "unsupported_model",
|
||||
last_uplink: null,
|
||||
last_join: null,
|
||||
last_status: null,
|
||||
receptions: [],
|
||||
battery_level: null,
|
||||
external_power_source: null,
|
||||
downlink_margin: null,
|
||||
},
|
||||
{
|
||||
...base,
|
||||
dev_eui: "2CF7F1C071500166",
|
||||
name: "Garden shed",
|
||||
device_id: null,
|
||||
support: "integration_not_configured",
|
||||
battery_level: 18,
|
||||
last_uplink: minutesAgo(180),
|
||||
last_status: minutesAgo(600),
|
||||
receptions: [{ gateway_id: "home-indoor", rssi: -112, snr: -7.5 }],
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const mockLoRaWAN = (hass: MockHomeAssistant) => {
|
||||
const deviceList = devices();
|
||||
const snapshot = {
|
||||
devices: deviceList,
|
||||
connections: [
|
||||
...new Map(
|
||||
deviceList.map((device) => [
|
||||
device.connection.entry_id,
|
||||
device.connection,
|
||||
])
|
||||
).values(),
|
||||
],
|
||||
};
|
||||
hass.mockWS("lorawan/devices/list", () => snapshot);
|
||||
hass.mockWS("lorawan/devices/subscribe", (_msg, _hass, onChange) =>
|
||||
emitInitial(() => onChange?.(snapshot))
|
||||
);
|
||||
};
|
||||
@@ -190,6 +190,39 @@ export const removeEntityRegistryEntry = (
|
||||
hass.mockEvent("entity_registry_updated");
|
||||
};
|
||||
|
||||
export const removeLabelFromEntityRegistry = (
|
||||
hass: MockHomeAssistant,
|
||||
labelId: string
|
||||
) => {
|
||||
const withoutLabel = (labels: string[]) =>
|
||||
labels.filter((id) => id !== labelId);
|
||||
for (const [key, entry] of retiredEntries) {
|
||||
if (entry.labels.includes(labelId)) {
|
||||
retiredEntries.set(key, { ...entry, labels: withoutLabel(entry.labels) });
|
||||
}
|
||||
}
|
||||
if (!entries.some((entry) => entry.labels.includes(labelId))) {
|
||||
return;
|
||||
}
|
||||
entries = entries.map((entry) =>
|
||||
entry.labels.includes(labelId)
|
||||
? { ...entry, labels: withoutLabel(entry.labels) }
|
||||
: entry
|
||||
);
|
||||
const entities = { ...hass.entities };
|
||||
for (const entry of entries) {
|
||||
const entity = entities[entry.entity_id];
|
||||
if (entity?.labels.includes(labelId)) {
|
||||
entities[entry.entity_id] = {
|
||||
...entity,
|
||||
labels: withoutLabel(entity.labels),
|
||||
};
|
||||
}
|
||||
}
|
||||
hass.updateHass({ entities });
|
||||
hass.mockEvent("entity_registry_updated");
|
||||
};
|
||||
|
||||
// Adds an entry like core does when an integration creates an entity
|
||||
export const addEntityRegistryEntry = (
|
||||
hass: MockHomeAssistant,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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[] = [];
|
||||
|
||||
@@ -9,6 +10,22 @@ export const mockLabelRegistry = (
|
||||
hass: MockHomeAssistant,
|
||||
data: LabelRegistryEntry[] = []
|
||||
) => {
|
||||
labels = data;
|
||||
hass.mockWS("config/label_registry/list", () => data);
|
||||
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");
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
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 {
|
||||
@@ -11,10 +12,14 @@ 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>
|
||||
localizePromise: Promise<LocalizeFunc>,
|
||||
data: LovelaceDashboard[] = []
|
||||
) => {
|
||||
dashboards = data;
|
||||
hass.mockWS("lovelace/config", ({ url_path }) => {
|
||||
if (url_path === "map") {
|
||||
mockMap(hass);
|
||||
@@ -34,7 +39,16 @@ export const mockLovelace = (
|
||||
);
|
||||
hass.mockWS("lovelace/config/save", () => Promise.resolve());
|
||||
hass.mockWS("lovelace/resources", () => Promise.resolve([]));
|
||||
hass.mockWS("lovelace/dashboards/list", () => 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);
|
||||
});
|
||||
};
|
||||
|
||||
customElements.whenDefined("hui-root").then(() => {
|
||||
|
||||
+36
-18
@@ -1,8 +1,10 @@
|
||||
import { TZDate } from "@date-fns/tz";
|
||||
import {
|
||||
addDays,
|
||||
addHours,
|
||||
addMinutes,
|
||||
addMonths,
|
||||
constructFrom,
|
||||
differenceInHours,
|
||||
endOfDay,
|
||||
} from "date-fns";
|
||||
@@ -13,6 +15,18 @@ 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"
|
||||
@@ -33,8 +47,7 @@ const generateMeanStatistics = (
|
||||
maxDiff: number
|
||||
): StatisticValue[] => {
|
||||
const statistics: StatisticValue[] = [];
|
||||
let currentDate = new Date(start);
|
||||
currentDate.setMinutes(0, 0, 0);
|
||||
let currentDate = getFirstDate(start, period);
|
||||
const now = new Date();
|
||||
while (end > currentDate && currentDate < now) {
|
||||
const delta = Math.random() * maxDiff;
|
||||
@@ -63,8 +76,7 @@ const generateSumStatistics = (
|
||||
maxDiff: number | ((start: Date, end: Date) => number)
|
||||
): StatisticValue[] => {
|
||||
const statistics: StatisticValue[] = [];
|
||||
let currentDate = new Date(start);
|
||||
currentDate.setMinutes(0, 0, 0);
|
||||
let currentDate = getFirstDate(start, period);
|
||||
let sum = initValue;
|
||||
const now = new Date();
|
||||
while (end > currentDate && currentDate < now) {
|
||||
@@ -98,8 +110,7 @@ const generateCurvedStatistics = (
|
||||
metered: boolean
|
||||
): StatisticValue[] => {
|
||||
const statistics: StatisticValue[] = [];
|
||||
let currentDate = new Date(start);
|
||||
currentDate.setMinutes(0, 0, 0);
|
||||
let currentDate = getFirstDate(start, period);
|
||||
let sum = initValue;
|
||||
const hours = differenceInHours(end, start) - 1;
|
||||
let i = 0;
|
||||
@@ -162,9 +173,9 @@ const statisticsFunctions: Record<
|
||||
period === "day" ? 17 : 504
|
||||
);
|
||||
}
|
||||
const morningEnd = new Date(start.getTime() + 10 * 60 * 60 * 1000);
|
||||
const morningEnd = addHours(start, 10);
|
||||
const morningLow = generateSumStatistics(start, morningEnd, period, 0, 0.7);
|
||||
const eveningStart = new Date(start.getTime() + 20 * 60 * 60 * 1000);
|
||||
const eveningStart = addHours(start, 20);
|
||||
const morningFinalVal = morningLow.length
|
||||
? morningLow[morningLow.length - 1].sum!
|
||||
: 0;
|
||||
@@ -199,8 +210,8 @@ const statisticsFunctions: Record<
|
||||
period === "day" ? 17 : 504
|
||||
);
|
||||
}
|
||||
const morningEnd = new Date(start.getTime() + 9 * 60 * 60 * 1000);
|
||||
const eveningStart = new Date(start.getTime() + 20 * 60 * 60 * 1000);
|
||||
const morningEnd = addHours(start, 9);
|
||||
const eveningStart = addHours(start, 20);
|
||||
const highTarif = generateSumStatistics(
|
||||
morningEnd,
|
||||
eveningStart,
|
||||
@@ -239,9 +250,12 @@ const statisticsFunctions: Record<
|
||||
period === "day" ? 17 : 504
|
||||
);
|
||||
}
|
||||
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 productionStart = addHours(start, 9);
|
||||
const productionEnd = addHours(start, 21);
|
||||
const dayEnd = constructFrom(
|
||||
end,
|
||||
Math.min(endOfDay(productionEnd).getTime(), end.getTime())
|
||||
);
|
||||
const production = generateCurvedStatistics(
|
||||
productionStart,
|
||||
productionEnd,
|
||||
@@ -280,9 +294,12 @@ const statisticsFunctions: Record<
|
||||
period === "day" ? 17 : 504
|
||||
);
|
||||
}
|
||||
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 productionStart = addHours(start, 7);
|
||||
const productionEnd = addHours(start, 23);
|
||||
const dayEnd = constructFrom(
|
||||
end,
|
||||
Math.min(endOfDay(productionEnd).getTime(), end.getTime())
|
||||
);
|
||||
const production = generateCurvedStatistics(
|
||||
productionStart,
|
||||
productionEnd,
|
||||
@@ -329,8 +346,9 @@ export const mockRecorder = (mockHass: MockHomeAssistant) => {
|
||||
mockHass.mockWS(
|
||||
"recorder/statistics_during_period",
|
||||
({ statistic_ids, start_time, end_time, period }, hass): Statistics => {
|
||||
const start = new Date(start_time);
|
||||
const end = end_time ? new Date(end_time) : new Date();
|
||||
// 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 statistics: Record<string, StatisticValue[]> = {};
|
||||
const cache: Record<string, StatisticValue[]> = {};
|
||||
|
||||
@@ -33,4 +33,4 @@ We want to make it as easy for designers to contribute as it is for developers.
|
||||
|
||||
## Developers
|
||||
|
||||
Everything you need to get started developing can be found in our <a href="https://developers.home-assistant.io" rel="noopener noreferrer" target="_blank">Home Assistant Developer Docs</a>.
|
||||
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>.
|
||||
@@ -271,7 +271,7 @@ const CONFIGS = [
|
||||
|
||||
### ha-alert
|
||||
|
||||
You can use our [\`ha-alert\`](https://design.home-assistant.io/#components/ha-alert) component in markdown content rendered in the Home Assistant Frontend.
|
||||
You can use our [\`ha-alert\`](https://contributors.home-assistant.io/design/components/alert/) component in markdown content rendered in the Home Assistant Frontend.
|
||||
|
||||
<ha-alert alert-type="error">This is an error alert — check it out!</ha-alert>
|
||||
<ha-alert alert-type="warning">This is a warning alert — check it out!</ha-alert>
|
||||
|
||||
@@ -32,7 +32,7 @@ It is similar to the core frontend dev.
|
||||
- `"mounts": ["source=/path/to/hass/frontend,target=/workspaces/frontend,type=bind,consistency=cached"]`
|
||||
- use the dev container
|
||||
- start the dev server with following optional env vars:
|
||||
- `SUPERVISOR_HOST` to have real supervisor data, you can [setup a supervisor remote API access](https://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_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_TOKEN` the supervisor api token you get from the Remote API proxy Addon Logs
|
||||
- `FRONTEND_PATH` the path inside your container should be `/workspaces/frontend`
|
||||
- example: `SUPERVISOR_TOKEN=abc123 SUPERVISOR_HOST=192.168.0.20:8888 FRONTEND_PATH=/workspaces/frontend go run main.go http.go mdns.go`
|
||||
|
||||
@@ -6,7 +6,7 @@ export default {
|
||||
],
|
||||
"*.{json,css,md,markdown,html,ya?ml}": "prettier --cache --write",
|
||||
"translations/*/*.json": (files) =>
|
||||
'printf "%s\n" "Translation files should not be added or modified here. Instead, make the necessary modifications in src/translations/en.json. Other languages are managed externally. Please see https://developers.home-assistant.io/docs/translations/ 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://contributors.home-assistant.io/translate/ for details." ' +
|
||||
files.join(" ") +
|
||||
" >&2 && exit 1",
|
||||
"pnpm-lock.yaml": () => "pnpm dedupe",
|
||||
|
||||
+14
-13
@@ -45,6 +45,7 @@
|
||||
"@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",
|
||||
@@ -85,7 +86,7 @@
|
||||
"@swc/helpers": "0.5.23",
|
||||
"@tsparticles/engine": "4.4.0",
|
||||
"@tsparticles/preset-links": "4.4.0",
|
||||
"@versatiles/style": "6.4.0",
|
||||
"@versatiles/style": "6.4.1",
|
||||
"@vibrant/color": "4.0.4",
|
||||
"@vvo/tzdb": "6.201.1",
|
||||
"@webcomponents/scoped-custom-element-registry": "0.0.10",
|
||||
@@ -114,7 +115,7 @@
|
||||
"lit-html": "3.3.3",
|
||||
"luxon": "3.7.2",
|
||||
"maplibre-gl": "6.14.0",
|
||||
"marked": "18.1.0",
|
||||
"marked": "18.1.1",
|
||||
"memoize-one": "6.0.0",
|
||||
"nanoid": "6.0.2",
|
||||
"node-vibrant": "4.0.4",
|
||||
@@ -149,16 +150,16 @@
|
||||
"@bundle-stats/plugin-webpack-filter": "4.22.3",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@gfx/zopfli": "1.0.15",
|
||||
"@html-eslint/eslint-plugin": "0.66.1",
|
||||
"@html-eslint/eslint-plugin": "0.67.0",
|
||||
"@lokalise/node-api": "16.6.0",
|
||||
"@octokit/auth-oauth-device": "8.0.5",
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@playwright/test": "1.64.0",
|
||||
"@playwright/test": "1.64.1",
|
||||
"@rsdoctor/rspack-plugin": "1.6.5",
|
||||
"@rspack/core": "2.2.8",
|
||||
"@rspack/core": "2.2.9",
|
||||
"@rspack/dev-server": "2.2.1",
|
||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||
"@types/chromecast-caf-receiver": "6.0.26",
|
||||
"@types/chromecast-caf-receiver": "6.0.27",
|
||||
"@types/chromecast-caf-sender": "1.0.11",
|
||||
"@types/color-name": "2.0.0",
|
||||
"@types/culori": "4.0.1",
|
||||
@@ -173,7 +174,7 @@
|
||||
"@types/sortablejs": "1.15.9",
|
||||
"@types/tar": "7.0.87",
|
||||
"@typescript/native": "npm:[email protected]",
|
||||
"@vitest/coverage-v8": "5.0.3",
|
||||
"@vitest/coverage-v8": "5.0.5",
|
||||
"babel-loader": "10.1.1",
|
||||
"babel-plugin-polyfill-corejs3": "1.0.0",
|
||||
"browserslist": "4.29.3",
|
||||
@@ -185,7 +186,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.1",
|
||||
"eslint-plugin-lit-a11y": "5.1.3",
|
||||
"eslint-plugin-unused-imports": "4.4.1",
|
||||
"eslint-plugin-wc": "3.1.0",
|
||||
"fancy-log": "2.0.0",
|
||||
@@ -198,7 +199,7 @@
|
||||
"gulp-rename": "2.1.0",
|
||||
"html-minifier-terser": "7.2.0",
|
||||
"husky": "9.1.7",
|
||||
"jsdom": "30.1.2",
|
||||
"jsdom": "30.1.3",
|
||||
"jszip": "3.10.2",
|
||||
"license-checker-rseidelsohn": "5.0.1",
|
||||
"lightningcss": "1.33.0",
|
||||
@@ -217,16 +218,16 @@
|
||||
"terser-webpack-plugin": "5.6.1",
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.71.1",
|
||||
"typescript-eslint": "8.71.2",
|
||||
"vite": "8.3.4",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "5.0.3",
|
||||
"vitest": "5.0.5",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
"webpackbar": "7.0.0",
|
||||
"workbox-build": "7.4.1"
|
||||
},
|
||||
"packageManager": "[email protected]1.2",
|
||||
"packageManager": "[email protected]4.0",
|
||||
"volta": {
|
||||
"node": "24.21.0"
|
||||
"node": "24.22.0"
|
||||
}
|
||||
}
|
||||
Generated
+407
-390
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,27 @@
|
||||
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);
|
||||
@@ -280,7 +280,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
|
||||
private _pendingUpdate?: Set<PropertyKey>;
|
||||
|
||||
private _pendingOptions?: HaECOption;
|
||||
private _legendItemCount?: number;
|
||||
|
||||
private _pendingZoom?: [number, number, boolean];
|
||||
|
||||
@@ -292,7 +292,6 @@ 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.
|
||||
@@ -445,11 +444,6 @@ 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);
|
||||
}
|
||||
}
|
||||
@@ -466,10 +460,8 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
return;
|
||||
}
|
||||
const pending = this._pendingUpdate!;
|
||||
const previousOptions = this._pendingOptions;
|
||||
this._pendingUpdate = undefined;
|
||||
this._pendingOptions = undefined;
|
||||
this._applyChartUpdate(pending, previousOptions);
|
||||
this._applyChartUpdate(pending);
|
||||
}
|
||||
|
||||
public willUpdate(changedProps: PropertyValues): void {
|
||||
@@ -485,7 +477,6 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
if (invisible) {
|
||||
this._pendingSetup = true;
|
||||
this._pendingUpdate = undefined;
|
||||
this._pendingOptions = undefined;
|
||||
} else {
|
||||
this._setupChart();
|
||||
}
|
||||
@@ -503,16 +494,10 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
this._applyChartUpdate(
|
||||
changedProps,
|
||||
changedProps.get("options") as HaECOption | undefined
|
||||
);
|
||||
this._applyChartUpdate(changedProps);
|
||||
}
|
||||
|
||||
private _applyChartUpdate(
|
||||
changedProps: { has: (prop: string) => boolean },
|
||||
previousOptions: HaECOption | undefined
|
||||
) {
|
||||
private _applyChartUpdate(changedProps: { has: (prop: string) => boolean }) {
|
||||
let chartOptions: ECOption = {};
|
||||
if (changedProps.has("data") || changedProps.has("_hiddenDatasets")) {
|
||||
chartOptions.series = this._getSeries();
|
||||
@@ -532,20 +517,28 @@ 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() {
|
||||
@@ -832,7 +825,6 @@ 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.
|
||||
@@ -883,13 +875,14 @@ 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" });
|
||||
});
|
||||
@@ -1002,6 +995,7 @@ 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;
|
||||
@@ -1458,6 +1452,7 @@ 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,
|
||||
@@ -1502,15 +1497,19 @@ 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) => ({
|
||||
@@ -1520,7 +1519,6 @@ 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({
|
||||
@@ -1872,22 +1870,6 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
this._resizeAnimationDuration = undefined;
|
||||
}
|
||||
|
||||
private _compareCustomLegendOptions(
|
||||
oldOptions: HaECOption | undefined,
|
||||
newOptions: HaECOption | undefined
|
||||
): boolean {
|
||||
const oldLegends = ensureArray(
|
||||
oldOptions?.legend || []
|
||||
) as LegendComponentOption[];
|
||||
const newLegends = ensureArray(
|
||||
newOptions?.legend || []
|
||||
) as LegendComponentOption[];
|
||||
return (
|
||||
oldLegends.some((l) => l.show && l.type === "custom") !==
|
||||
newLegends.some((l) => l.show && l.type === "custom")
|
||||
);
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
|
||||
@@ -32,6 +32,7 @@ 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
|
||||
@@ -315,6 +316,7 @@ 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,
|
||||
|
||||
@@ -237,8 +237,10 @@ 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
|
||||
@@ -257,7 +259,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);
|
||||
@@ -266,14 +268,17 @@ export class HaSankeyChart extends LitElement {
|
||||
(wrapWidth / wordWidth) * FONT_SIZE
|
||||
);
|
||||
return {
|
||||
fontSize: fontSize > 1 ? fontSize : 0,
|
||||
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,
|
||||
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
|
||||
|
||||
@@ -1762,10 +1762,6 @@ 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 */
|
||||
|
||||
@@ -1839,8 +1835,8 @@ export class HaDataTable extends LitElement {
|
||||
.mdc-data-table__header-cell > * {
|
||||
transition: var(--float-start) 0.2s ease;
|
||||
}
|
||||
.mdc-data-table__header-cell--numeric > span {
|
||||
transition: none;
|
||||
.mdc-data-table__header-cell--numeric ha-svg-icon {
|
||||
transition: var(--float-end) 0.2s ease;
|
||||
}
|
||||
.mdc-data-table__header-cell ha-svg-icon {
|
||||
top: -3px;
|
||||
@@ -1863,6 +1859,25 @@ 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 = isNaN(valA) ? undefined : Number(valA);
|
||||
valB = isNaN(valB) ? undefined : Number(valB);
|
||||
valA = valA == null || isNaN(valA) ? undefined : Number(valA);
|
||||
valB = valB == null || isNaN(valB) ? undefined : Number(valB);
|
||||
} else if (column.type === "ip") {
|
||||
return sort * ipCompare(valA, valB);
|
||||
} else if (typeof valA === "string" && typeof valB === "string") {
|
||||
|
||||
@@ -43,7 +43,10 @@ import {
|
||||
findAvailableLanguage,
|
||||
getTranslation,
|
||||
} from "../util/common-translation";
|
||||
import { documentationUrl } from "../util/documentation-url";
|
||||
import {
|
||||
documentationUrl,
|
||||
SECURE_CONNECTION_DOCUMENTATION_PATH,
|
||||
} from "../util/documentation-url";
|
||||
import "./ha-alert";
|
||||
import "./ha-markdown";
|
||||
import "./input/ha-input";
|
||||
@@ -51,7 +54,7 @@ import type { HaInput } from "./input/ha-input";
|
||||
|
||||
interface AssistMessage {
|
||||
who: string;
|
||||
text: string | TemplateResult;
|
||||
text: string;
|
||||
thinking: string;
|
||||
thinking_expanded?: boolean;
|
||||
tool_calls: Record<
|
||||
@@ -539,31 +542,18 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre>
|
||||
this._conversation = [...this._conversation!, message];
|
||||
}
|
||||
|
||||
private async _showNotSupportedMessage() {
|
||||
private _showNotSupportedMessage() {
|
||||
const documentationLink = `[${this._localize(
|
||||
"ui.components.secure_connection_required.documentation_link"
|
||||
)}](${documentationUrl(this._config, SECURE_CONNECTION_DOCUMENTATION_PATH)})`;
|
||||
this._addMessage({
|
||||
who: "hass",
|
||||
text:
|
||||
// New lines matter for messages
|
||||
// prettier-ignore
|
||||
html`${this._localize(
|
||||
"ui.dialogs.voice_command.not_supported_microphone_browser"
|
||||
)}
|
||||
|
||||
${this._localize(
|
||||
"ui.dialogs.voice_command.not_supported_microphone_documentation",
|
||||
{
|
||||
documentation_link: html`<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href=${documentationUrl(
|
||||
this._config,
|
||||
"/docs/configuration/securing/#remote-access"
|
||||
)}
|
||||
>${this._localize(
|
||||
"ui.dialogs.voice_command.not_supported_microphone_documentation_link"
|
||||
)}</a>`,
|
||||
}
|
||||
)}`,
|
||||
text: `${this._localize(
|
||||
"ui.components.secure_connection_required.microphone"
|
||||
)}\n\n${this._localize(
|
||||
"ui.components.secure_connection_required.learn_more",
|
||||
{ documentation_link: documentationLink }
|
||||
)}`,
|
||||
thinking: "",
|
||||
tool_calls: {},
|
||||
});
|
||||
@@ -583,13 +573,31 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre>
|
||||
}
|
||||
this._stt_binary_handler_id = undefined;
|
||||
this._audioBuffer = [];
|
||||
|
||||
const microphoneError = await this._audioRecorder.start();
|
||||
|
||||
if (microphoneError) {
|
||||
this._audioBuffer = undefined;
|
||||
this._processing = false;
|
||||
this._addMessage({
|
||||
who: "hass",
|
||||
text: this._localize(
|
||||
`ui.dialogs.voice_command.microphone_${microphoneError}`
|
||||
),
|
||||
thinking: "",
|
||||
tool_calls: {},
|
||||
error: true,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const userMessage: AssistMessage = {
|
||||
who: "user",
|
||||
text: "…",
|
||||
thinking: "",
|
||||
tool_calls: {},
|
||||
};
|
||||
await this._audioRecorder.start();
|
||||
|
||||
this._addMessage(userMessage);
|
||||
|
||||
@@ -602,7 +610,9 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre>
|
||||
if (event.type === "run-start") {
|
||||
this._stt_binary_handler_id =
|
||||
event.data.runner_data.stt_binary_handler_id;
|
||||
this._audio = new Audio(event.data.tts_output!.url);
|
||||
this._audio = new Audio(
|
||||
this._connection.hassUrl(event.data.tts_output!.url)
|
||||
);
|
||||
this._audio.play();
|
||||
this._audio.addEventListener("ended", () => {
|
||||
this._unloadAudio();
|
||||
@@ -616,7 +626,11 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre>
|
||||
);
|
||||
this._audio.addEventListener("error", () => {
|
||||
this._unloadAudio();
|
||||
showAlertDialog(this, { title: "Error playing audio." });
|
||||
showAlertDialog(this, {
|
||||
title: this._localize(
|
||||
"ui.dialogs.voice_command.error_playing_audio"
|
||||
),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -666,7 +680,9 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre>
|
||||
);
|
||||
} catch (err: any) {
|
||||
await showAlertDialog(this, {
|
||||
title: "Error starting pipeline",
|
||||
title: this._localize(
|
||||
"ui.dialogs.voice_command.error_starting_pipeline"
|
||||
),
|
||||
text: err.message || err,
|
||||
});
|
||||
this._stopListening();
|
||||
|
||||
@@ -80,6 +80,9 @@ 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;
|
||||
@@ -562,13 +565,22 @@ export class HaControlCircularSlider extends LitElement {
|
||||
aria-valuemin=${this.min}
|
||||
aria-valuemax=${this.max}
|
||||
aria-valuenow=${
|
||||
this._localValue != null
|
||||
? this._steppedValue(this._localValue)
|
||||
: undefined
|
||||
value != null ? this._steppedValue(value) : undefined
|
||||
}
|
||||
aria-valuetext=${ifDefined(
|
||||
value != null && this.formatValue
|
||||
? this.formatValue(this._steppedValue(value))
|
||||
: undefined
|
||||
)}
|
||||
aria-disabled=${this.disabled}
|
||||
aria-readonly=${this.readonly}
|
||||
aria-label=${ifDefined(this.lowLabel ?? this.label)}
|
||||
aria-label=${ifDefined(
|
||||
id === "low"
|
||||
? (this.lowLabel ?? this.label)
|
||||
: id === "high"
|
||||
? (this.highLabel ?? this.label)
|
||||
: this.label
|
||||
)}
|
||||
@keydown=${this._handleKeyDown}
|
||||
@keyup=${this._handleKeyUp}
|
||||
/>
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
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 } from "lit/decorators";
|
||||
import { customElement, property, query, state } 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";
|
||||
|
||||
@@ -45,6 +48,10 @@ 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;
|
||||
@@ -160,7 +167,9 @@ export class HaControlNumberButton extends LitElement {
|
||||
class="button minus"
|
||||
type="button"
|
||||
tabindex="-1"
|
||||
aria-label="decrement"
|
||||
aria-label=${ifDefined(
|
||||
this._i18n?.localize("ui.components.number-buttons.decrement")
|
||||
)}
|
||||
@click=${this._handleMinusButton}
|
||||
.disabled=${
|
||||
this.disabled || (this.min != null && this._value <= this.min)
|
||||
@@ -172,7 +181,9 @@ export class HaControlNumberButton extends LitElement {
|
||||
class="button plus"
|
||||
type="button"
|
||||
tabindex="-1"
|
||||
aria-label="increment"
|
||||
aria-label=${ifDefined(
|
||||
this._i18n?.localize("ui.components.number-buttons.increment")
|
||||
)}
|
||||
@click=${this._handlePlusButton}
|
||||
.disabled=${
|
||||
this.disabled || (this.max != null && this._value >= this.max)
|
||||
|
||||
@@ -106,7 +106,8 @@ export class HaForm extends LitElement implements HaFormElement {
|
||||
) => string | undefined;
|
||||
|
||||
@property({ attribute: false }) public localizeValue?: (
|
||||
key: string
|
||||
key: string,
|
||||
values?: Record<string, string>
|
||||
) => string;
|
||||
|
||||
@property({ attribute: false }) public context?: Record<string, any>;
|
||||
|
||||
@@ -20,6 +20,7 @@ import "./ha-button";
|
||||
import "./ha-dropdown";
|
||||
import type { HaDropdownSelectEvent } from "./ha-dropdown";
|
||||
import "./ha-dropdown-item";
|
||||
import "./ha-secure-connection-alert";
|
||||
import "./ha-spinner";
|
||||
import "./input/ha-input";
|
||||
import type { HaInput } from "./input/ha-input";
|
||||
@@ -163,15 +164,15 @@ class HaQrScanner extends LitElement {
|
||||
: nothing
|
||||
}
|
||||
</div>`
|
||||
: html`<ha-alert alert-type="warning">
|
||||
${
|
||||
!window.isSecureContext
|
||||
? this._localize(
|
||||
"ui.components.qr-scanner.only_https_supported"
|
||||
)
|
||||
: this._localize("ui.components.qr-scanner.not_supported")
|
||||
}
|
||||
</ha-alert>
|
||||
: html`${
|
||||
!window.isSecureContext
|
||||
? html`<ha-secure-connection-alert
|
||||
feature="camera"
|
||||
></ha-secure-connection-alert>`
|
||||
: html`<ha-alert alert-type="warning">
|
||||
${this._localize("ui.components.qr-scanner.not_supported")}
|
||||
</ha-alert>`
|
||||
}
|
||||
<p>${this._localize("ui.components.qr-scanner.manual_input")}</p>
|
||||
<div class="row">
|
||||
<ha-input
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { ContextType } from "@lit/context";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { consume } from "../common/decorators/consume";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import { configContext } from "../data/context";
|
||||
import {
|
||||
documentationUrl,
|
||||
SECURE_CONNECTION_DOCUMENTATION_PATH,
|
||||
} from "../util/documentation-url";
|
||||
import "./ha-alert";
|
||||
|
||||
export type SecureConnectionFeature =
|
||||
"microphone" | "camera" | "push_notifications";
|
||||
|
||||
@customElement("ha-secure-connection-alert")
|
||||
class HaSecureConnectionAlert extends LitElement {
|
||||
@property() public feature!: SecureConnectionFeature;
|
||||
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
|
||||
@state()
|
||||
@consume({ context: configContext, subscribe: true })
|
||||
private _config!: ContextType<typeof configContext>;
|
||||
|
||||
protected render() {
|
||||
if (!this._localize || !this._config) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-alert alert-type="warning">
|
||||
${this._localize(
|
||||
`ui.components.secure_connection_required.${this.feature}`
|
||||
)}
|
||||
${this._localize(
|
||||
"ui.components.secure_connection_required.learn_more",
|
||||
{
|
||||
documentation_link: html`<a
|
||||
href=${documentationUrl(
|
||||
this._config,
|
||||
SECURE_CONNECTION_DOCUMENTATION_PATH
|
||||
)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>${this._localize(
|
||||
"ui.components.secure_connection_required.documentation_link"
|
||||
)}</a
|
||||
>`,
|
||||
}
|
||||
)}
|
||||
</ha-alert>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-secure-connection-alert": HaSecureConnectionAlert;
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,8 @@ export class HaObjectSelector extends LitElement {
|
||||
@property({ type: Boolean }) public required = true;
|
||||
|
||||
@property({ attribute: false }) public localizeValue?: (
|
||||
key: string
|
||||
key: string,
|
||||
values?: Record<string, string>
|
||||
) => string;
|
||||
|
||||
@query("ha-yaml-editor", true) private _yamlEditor?: HaYamlEditor;
|
||||
@@ -79,37 +80,45 @@ 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
|
||||
? formatSelectorValue(
|
||||
this.hass,
|
||||
item[preferredLabel!],
|
||||
fields[preferredLabel!]?.selector
|
||||
)
|
||||
: Object.entries(fields)
|
||||
.map(([key, field]) =>
|
||||
formatSelectorValue(this.hass, item[key], field.selector)
|
||||
)
|
||||
? this._formatField(item, preferredLabel!)
|
||||
: Object.keys(fields)
|
||||
.map((key) => this._formatField(item, key))
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
|
||||
let description = "";
|
||||
|
||||
const descriptionField = this.selector.object!.description_field;
|
||||
if (descriptionField && descriptionField in fields) {
|
||||
const descriptionSelector = fields[descriptionField]?.selector;
|
||||
|
||||
description = descriptionSelector
|
||||
? formatSelectorValue(
|
||||
this.hass,
|
||||
item[descriptionField],
|
||||
descriptionSelector
|
||||
)
|
||||
: "";
|
||||
if (
|
||||
descriptionField &&
|
||||
descriptionField in fields &&
|
||||
fields[descriptionField]?.selector
|
||||
) {
|
||||
description = this._formatField(item, descriptionField);
|
||||
}
|
||||
|
||||
const reorderable = this.selector.object!.multiple || false;
|
||||
|
||||
@@ -93,7 +93,10 @@ export class HaSelector extends LitElement {
|
||||
@property() public helper?: string;
|
||||
|
||||
@property({ attribute: false })
|
||||
public localizeValue?: (key: string) => string;
|
||||
public localizeValue?: (
|
||||
key: string,
|
||||
values?: Record<string, string>
|
||||
) => string;
|
||||
|
||||
@property() public placeholder?: any;
|
||||
|
||||
|
||||
@@ -773,12 +773,16 @@ export class HaServiceControl extends LitElement {
|
||||
filter_entity: targetEntities || undefined,
|
||||
}));
|
||||
|
||||
private _localizeValueCallback = (key: string) => {
|
||||
private _localizeValueCallback = (
|
||||
key: string,
|
||||
values?: Record<string, string>
|
||||
) => {
|
||||
if (!this._value?.action) {
|
||||
return "";
|
||||
}
|
||||
return this.hass.localize(
|
||||
`component.${computeDomain(this._value.action)}.selector.${key}`
|
||||
`component.${computeDomain(this._value.action)}.selector.${key}`,
|
||||
values
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ export type ManagerStateEvent =
|
||||
export type BackupSubscriptionEvent = ManagerStateEvent | UploadBackupEvent;
|
||||
|
||||
export const subscribeBackupEvents = (
|
||||
hass: HomeAssistant,
|
||||
hass: Pick<HomeAssistant, "connection">,
|
||||
callback: (event: BackupSubscriptionEvent) => void,
|
||||
preCheck?: () => boolean | Promise<boolean>
|
||||
) =>
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
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" });
|
||||
@@ -12,8 +12,6 @@ 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 =
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
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
|
||||
);
|
||||
+8
-15
@@ -310,22 +310,15 @@ export interface ZWaveJSSetRawConfigParamData {
|
||||
value_format: number;
|
||||
}
|
||||
|
||||
export interface ZWaveJSSetConfigParamResult {
|
||||
value_id: string;
|
||||
status: number;
|
||||
export type ZWaveJSSetConfigParamStatus = "accepted" | "queued";
|
||||
|
||||
export interface ZWaveJSSetRawConfigParamResult {
|
||||
status: ZWaveJSSetConfigParamStatus;
|
||||
}
|
||||
|
||||
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 ZWaveJSSetConfigParamResult extends ZWaveJSSetRawConfigParamResult {
|
||||
value_id: string;
|
||||
}
|
||||
|
||||
export interface ZwaveJSNodeConfigParameterUpdate {
|
||||
id: string;
|
||||
@@ -816,7 +809,7 @@ export const setZwaveNodeRawConfigParameter = (
|
||||
value: number,
|
||||
value_size: number,
|
||||
value_format: number
|
||||
): Promise<ZWaveJSSetConfigParamResult> => {
|
||||
): Promise<ZWaveJSSetRawConfigParamResult> => {
|
||||
const data: ZWaveJSSetRawConfigParamData = {
|
||||
type: "zwave_js/set_raw_config_parameter",
|
||||
device_id,
|
||||
|
||||
@@ -145,8 +145,8 @@ export const showConfigFlowDialog = (
|
||||
);
|
||||
},
|
||||
|
||||
renderShowFormStepFieldLocalizeValue(hass, step, key) {
|
||||
return hass.localize(`component.${step.handler}.selector.${key}`);
|
||||
renderShowFormStepFieldLocalizeValue(hass, step, key, values) {
|
||||
return hass.localize(`component.${step.handler}.selector.${key}`, values);
|
||||
},
|
||||
|
||||
renderShowFormStepSubmitButton(hass, step) {
|
||||
|
||||
@@ -81,7 +81,8 @@ export interface FlowConfig {
|
||||
renderShowFormStepFieldLocalizeValue(
|
||||
hass: HomeAssistant,
|
||||
step: DataEntryFlowStepForm,
|
||||
key: string
|
||||
key: string,
|
||||
values?: Record<string, string>
|
||||
): string;
|
||||
|
||||
renderShowFormStepSubmitButton(
|
||||
|
||||
@@ -141,8 +141,11 @@ export const showOptionsFlowDialog = (
|
||||
);
|
||||
},
|
||||
|
||||
renderShowFormStepFieldLocalizeValue(hass, _step, key) {
|
||||
return hass.localize(`component.${configEntry.domain}.selector.${key}`);
|
||||
renderShowFormStepFieldLocalizeValue(hass, _step, key, values) {
|
||||
return hass.localize(
|
||||
`component.${configEntry.domain}.selector.${key}`,
|
||||
values
|
||||
);
|
||||
},
|
||||
|
||||
renderShowFormStepSubmitButton(hass, step) {
|
||||
|
||||
@@ -144,8 +144,11 @@ export const showSubConfigFlowDialog = (
|
||||
);
|
||||
},
|
||||
|
||||
renderShowFormStepFieldLocalizeValue(hass, _step, key) {
|
||||
return hass.localize(`component.${configEntry.domain}.selector.${key}`);
|
||||
renderShowFormStepFieldLocalizeValue(hass, _step, key, values) {
|
||||
return hass.localize(
|
||||
`component.${configEntry.domain}.selector.${key}`,
|
||||
values
|
||||
);
|
||||
},
|
||||
|
||||
renderShowFormStepSubmitButton(hass, step) {
|
||||
|
||||
@@ -348,11 +348,15 @@ class StepFlowForm extends LitElement {
|
||||
private _errorCallback = (error: string) =>
|
||||
this.flowConfig.renderShowFormStepFieldError(this.hass, this.step, error);
|
||||
|
||||
private _localizeValueCallback = (key: string) =>
|
||||
private _localizeValueCallback = (
|
||||
key: string,
|
||||
values?: Record<string, string>
|
||||
) =>
|
||||
this.flowConfig.renderShowFormStepFieldLocalizeValue(
|
||||
this.hass,
|
||||
this.step,
|
||||
key
|
||||
key,
|
||||
values
|
||||
);
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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";
|
||||
@@ -20,11 +19,7 @@ import "../../components/ha-spinner";
|
||||
import "../../components/ha-svg-icon";
|
||||
import "../../components/item/ha-list-item-button";
|
||||
import "../../components/list/ha-list-nav";
|
||||
import {
|
||||
fetchConfigFlowInProgress,
|
||||
subscribeConfigFlowInProgress,
|
||||
type ConfigFlowInProgressMessage,
|
||||
} from "../../data/config_flow";
|
||||
import { fetchConfigFlowInProgress } from "../../data/config_flow";
|
||||
import {
|
||||
apiContext,
|
||||
configContext,
|
||||
@@ -51,19 +46,12 @@ 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,
|
||||
@@ -74,9 +62,11 @@ 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 { showConfigFlowDialog } from "../config-flow/show-dialog-config-flow";
|
||||
import {
|
||||
loadConfigFlowDialog,
|
||||
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 =
|
||||
@@ -89,9 +79,6 @@ 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;
|
||||
|
||||
@@ -112,21 +99,6 @@ 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
|
||||
@@ -208,14 +180,9 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
|
||||
|
||||
private _loadStarted = false;
|
||||
|
||||
private _musicAssistantDiscovery?: {
|
||||
unsub?: UnsubscribeFunc;
|
||||
timer?: number;
|
||||
resolve?: (flow: DataEntryFlowProgress | undefined) => void;
|
||||
};
|
||||
private _openingMusicAssistantFlow = false;
|
||||
|
||||
public disconnectedCallback() {
|
||||
this._finishMusicAssistantDiscovery(undefined);
|
||||
this.params?.dialogClosedCallback?.();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
@@ -516,7 +483,7 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
|
||||
? html`
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
@click=${this._installMusicAssistant}
|
||||
@click=${this._setupMusicAssistant}
|
||||
>
|
||||
${localize(
|
||||
"ui.panel.config.devices.esphome.setup_install_music_assistant"
|
||||
@@ -865,7 +832,6 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
|
||||
|
||||
private _showChecklist = () => {
|
||||
this._audioFlowId += 1;
|
||||
this._finishMusicAssistantDiscovery(undefined);
|
||||
this._view = "checklist";
|
||||
this._installingAudio = false;
|
||||
this._installStatus = undefined;
|
||||
@@ -985,143 +951,32 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
|
||||
}
|
||||
}
|
||||
|
||||
private _installMusicAssistant = async (ev: Event) => {
|
||||
/** The integration's config flow installs and starts the Music Assistant app. */
|
||||
private _setupMusicAssistant = async (ev: Event) => {
|
||||
ev.stopPropagation();
|
||||
if (!this._api || !this._i18n || !this._connection) {
|
||||
if (this._openingMusicAssistantFlow) {
|
||||
return;
|
||||
}
|
||||
const flowId = this._audioFlowId;
|
||||
this._view = "audio-working";
|
||||
this._installingAudio = true;
|
||||
this._error = undefined;
|
||||
this._installStatus = this._i18n.localize(
|
||||
"ui.panel.config.devices.esphome.setup_installing_music_assistant"
|
||||
);
|
||||
// The flow dialog only covers this button once its chunk has loaded
|
||||
this._openingMusicAssistantFlow = true;
|
||||
try {
|
||||
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),
|
||||
});
|
||||
await loadConfigFlowDialog();
|
||||
} finally {
|
||||
if (this._audioFlowCurrent(flowId)) {
|
||||
this._installingAudio = false;
|
||||
}
|
||||
this._openingMusicAssistantFlow = false;
|
||||
}
|
||||
};
|
||||
|
||||
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,
|
||||
});
|
||||
if (!this._audioFlowCurrent(flowId) || this._view !== "audio-offer") {
|
||||
return;
|
||||
}
|
||||
showConfigFlowDialog(this, {
|
||||
startFlowHandler: "music_assistant",
|
||||
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);
|
||||
dialogClosedCallback: ({ entryId }) => {
|
||||
if (entryId) {
|
||||
void this._continueAfterMusicAssistant(flowId);
|
||||
}
|
||||
})
|
||||
.then((unsub) => {
|
||||
if (this._musicAssistantDiscovery !== pending) {
|
||||
unsub();
|
||||
return;
|
||||
}
|
||||
pending.unsub = unsub;
|
||||
})
|
||||
.catch(() => this._finishMusicAssistantDiscovery(undefined));
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private async _refreshZWaveFlows() {
|
||||
if (!this._connection) {
|
||||
|
||||
@@ -42,6 +42,8 @@ import type {
|
||||
export class HaMoreInfoUpdateBackup extends LitElement {
|
||||
@property({ attribute: false }) public stateObj?: UpdateEntity;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
@@ -126,7 +128,7 @@ export class HaMoreInfoUpdateBackup extends LitElement {
|
||||
slot="end"
|
||||
.checked=${this._createBackup}
|
||||
@change=${this._createBackupChanged}
|
||||
.disabled=${updateIsInstalling(this.stateObj)}
|
||||
.disabled=${this.disabled || updateIsInstalling(this.stateObj)}
|
||||
></ha-switch>`
|
||||
}
|
||||
</ha-row-item>
|
||||
|
||||
@@ -1,18 +1,35 @@
|
||||
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 { consumeLocalize } from "../../../common/decorators/consume-context-entry";
|
||||
import {
|
||||
consumeEntityRegistryEntry,
|
||||
consumeLocalize,
|
||||
} from "../../../common/decorators/consume-context-entry";
|
||||
import { transform } from "../../../common/decorators/transform";
|
||||
import { supportsFeature } from "../../../common/entity/supports-feature";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import { 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 { apiContext, formattersContext } from "../../../data/context";
|
||||
import type { ManagerState } from "../../../data/backup_manager";
|
||||
import { subscribeBackupEvents } from "../../../data/backup_manager";
|
||||
import {
|
||||
apiContext,
|
||||
configContext,
|
||||
connectionContext,
|
||||
formattersContext,
|
||||
internationalizationContext,
|
||||
} 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,
|
||||
@@ -21,11 +38,19 @@ import {
|
||||
updateIsInstalling,
|
||||
updateReleaseNotes,
|
||||
} from "../../../data/update";
|
||||
import type { HomeAssistantApi, HomeAssistantFormatters } from "../../../types";
|
||||
import type {
|
||||
HomeAssistantApi,
|
||||
HomeAssistantConfig,
|
||||
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;
|
||||
@@ -41,15 +66,102 @@ 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 ||
|
||||
@@ -61,6 +173,7 @@ class MoreInfoUpdate extends LitElement {
|
||||
}
|
||||
|
||||
const releaseUrl = sanitizeHttpUrl(this.stateObj.attributes.release_url);
|
||||
const backupInProgressMessage = this._backupInProgressMessage();
|
||||
|
||||
return html`
|
||||
<div class="content">
|
||||
@@ -160,7 +273,22 @@ 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 &&
|
||||
@@ -195,7 +323,10 @@ class MoreInfoUpdate extends LitElement {
|
||||
<ha-button
|
||||
@click=${this._handleInstall}
|
||||
.loading=${updateIsInstalling(this.stateObj)}
|
||||
.disabled=${updateButtonIsDisabled(this.stateObj)}
|
||||
.disabled=${
|
||||
updateButtonIsDisabled(this.stateObj) ||
|
||||
!!backupInProgressMessage
|
||||
}
|
||||
>
|
||||
${this._localize(
|
||||
"ui.dialogs.more_info_control.update.update"
|
||||
@@ -240,7 +371,7 @@ class MoreInfoUpdate extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _handleInstall(): void {
|
||||
private async _handleInstall(): Promise<void> {
|
||||
const installData: Record<string, any> = {
|
||||
entity_id: this.stateObj!.entity_id,
|
||||
};
|
||||
@@ -256,7 +387,77 @@ class MoreInfoUpdate extends LitElement {
|
||||
installData.version = this.stateObj!.attributes.latest_version;
|
||||
}
|
||||
|
||||
this._api.callService("update", "install", installData);
|
||||
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);
|
||||
}
|
||||
|
||||
private _handleSkip(): void {
|
||||
@@ -329,6 +530,13 @@ class MoreInfoUpdate extends LitElement {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.footer ha-alert {
|
||||
display: block;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: var(--ha-space-2) var(--ha-space-4) 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
|
||||
@@ -811,12 +811,7 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
|
||||
}
|
||||
></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
"ui.dialogs.more_info_control.device_or_service_info",
|
||||
{
|
||||
type: this.hass.localize(
|
||||
`ui.dialogs.more_info_control.device_type.${deviceType}`
|
||||
),
|
||||
}
|
||||
`ui.dialogs.more_info_control.${deviceType}_info`
|
||||
)}
|
||||
</ha-dropdown-item>
|
||||
`
|
||||
|
||||
@@ -179,8 +179,11 @@ export const showRepairsFlowDialog = (
|
||||
);
|
||||
},
|
||||
|
||||
renderShowFormStepFieldLocalizeValue(hass, _step, key) {
|
||||
return hass.localize(`component.${issue.domain}.selector.${key}`);
|
||||
renderShowFormStepFieldLocalizeValue(hass, _step, key, values) {
|
||||
return hass.localize(
|
||||
`component.${issue.domain}.selector.${key}`,
|
||||
values
|
||||
);
|
||||
},
|
||||
|
||||
renderShowFormStepSubmitButton(hass, step) {
|
||||
|
||||
@@ -396,7 +396,7 @@ export class HassTabsSubpage extends LitElement {
|
||||
}
|
||||
|
||||
:host(:not([narrow])) #toolbar-icon {
|
||||
min-width: 40px;
|
||||
min-width: 48px;
|
||||
}
|
||||
|
||||
ha-menu-button,
|
||||
|
||||
@@ -458,12 +458,16 @@ export class HaPlatformCondition extends LitElement {
|
||||
this.requestUpdate("_checkedKeys");
|
||||
}
|
||||
|
||||
private _localizeValueCallback = (key: string) => {
|
||||
private _localizeValueCallback = (
|
||||
key: string,
|
||||
values?: Record<string, string>
|
||||
) => {
|
||||
if (!this.condition?.condition) {
|
||||
return "";
|
||||
}
|
||||
return this.hass.localize(
|
||||
`component.${getConditionDomain(this.condition.condition)}.selector.${key}`
|
||||
`component.${getConditionDomain(this.condition.condition)}.selector.${key}`,
|
||||
values
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
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 } from "lit/decorators";
|
||||
import { customElement, property, query, queryAll } from "lit/decorators";
|
||||
import { ensureArray } from "../../../../../common/array/ensure-array";
|
||||
import { fireEvent } from "../../../../../common/dom/fire_event";
|
||||
import "../../../../../components/ha-icon-button";
|
||||
@@ -27,10 +28,25 @@ 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) : [];
|
||||
@@ -41,14 +57,12 @@ 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,12 +448,16 @@ export class HaPlatformTrigger extends LitElement {
|
||||
this.requestUpdate("_checkedKeys");
|
||||
}
|
||||
|
||||
private _localizeValueCallback = (key: string) => {
|
||||
private _localizeValueCallback = (
|
||||
key: string,
|
||||
values?: Record<string, string>
|
||||
) => {
|
||||
if (!this.trigger?.trigger) {
|
||||
return "";
|
||||
}
|
||||
return this.hass.localize(
|
||||
`component.${getTriggerDomain(this.trigger.trigger)}.selector.${key}`
|
||||
`component.${getTriggerDomain(this.trigger.trigger)}.selector.${key}`,
|
||||
values
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -131,7 +131,7 @@ class HaBackupConfigData extends LitElement {
|
||||
}
|
||||
|
||||
private _hasLocalAddons(addons: BackupAddonItem[]): boolean {
|
||||
return addons.some((addon) => addon.slug === "local");
|
||||
return addons.some((addon) => addon.slug.startsWith("local_"));
|
||||
}
|
||||
|
||||
private _estimateBackupSize = memoizeOne(
|
||||
|
||||
@@ -29,6 +29,7 @@ interface ProgressSegment {
|
||||
label: string;
|
||||
state: SegmentState;
|
||||
flex: number;
|
||||
progress?: number;
|
||||
}
|
||||
|
||||
const HA_STAGES: CreateBackupStage[] = ["home_assistant"];
|
||||
@@ -139,12 +140,29 @@ export class HaBackupOverviewProgress extends LitElement {
|
||||
|
||||
private _computeAgentPercent(agentId: string): number | undefined {
|
||||
const progress = this.uploadProgress[agentId];
|
||||
if (!progress || progress.total_bytes === 0) {
|
||||
// 0 bytes only means the upload started; the agent may never report more.
|
||||
if (
|
||||
!progress ||
|
||||
progress.total_bytes === 0 ||
|
||||
progress.uploaded_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));
|
||||
}
|
||||
@@ -169,6 +187,9 @@ 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) {
|
||||
@@ -201,6 +222,7 @@ export class HaBackupOverviewProgress extends LitElement {
|
||||
),
|
||||
state: this._getSegmentState(3, currentGroupIndex),
|
||||
flex: 5,
|
||||
progress: uploadProgress,
|
||||
},
|
||||
{
|
||||
label: this.hass.localize(
|
||||
@@ -234,6 +256,7 @@ export class HaBackupOverviewProgress extends LitElement {
|
||||
),
|
||||
state: this._getSegmentState(3, currentGroupIndex),
|
||||
flex: 5,
|
||||
progress: uploadProgress,
|
||||
},
|
||||
{
|
||||
label: this.hass.localize(
|
||||
@@ -312,8 +335,18 @@ export class HaBackupOverviewProgress extends LitElement {
|
||||
active: segment.state === "active",
|
||||
completed: segment.state === "completed",
|
||||
pending: segment.state === "pending",
|
||||
determinate: segment.progress !== undefined,
|
||||
})}"
|
||||
></div>
|
||||
>
|
||||
${
|
||||
segment.progress !== undefined
|
||||
? html`<div
|
||||
class="segment-fill"
|
||||
style="width: ${segment.progress}%"
|
||||
></div>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
<span class="segment-label">${segment.label}</span>
|
||||
</div>
|
||||
`
|
||||
@@ -330,9 +363,12 @@ export class HaBackupOverviewProgress extends LitElement {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const hasProgress = Object.keys(this.uploadProgress).length > 0;
|
||||
// Only upload events tell which locations this backup goes to.
|
||||
const agents = this.agents.filter(
|
||||
(agent) => agent.agent_id in this.uploadProgress
|
||||
);
|
||||
|
||||
if (!hasProgress) {
|
||||
if (agents.length === 0) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
@@ -344,7 +380,7 @@ export class HaBackupOverviewProgress extends LitElement {
|
||||
}
|
||||
>
|
||||
<ha-list-base class="agent-list">
|
||||
${this.agents.map((agent) => {
|
||||
${agents.map((agent) => {
|
||||
const name = computeBackupAgentName(
|
||||
this.hass.localize,
|
||||
agent.agent_id,
|
||||
@@ -462,6 +498,18 @@ 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);
|
||||
@@ -482,6 +530,10 @@ export class HaBackupOverviewProgress extends LitElement {
|
||||
.segment-bar.active {
|
||||
animation: none;
|
||||
}
|
||||
.segment-fill {
|
||||
animation: none;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
.agent-list-wrapper {
|
||||
display: grid;
|
||||
@@ -532,6 +584,9 @@ export class HaBackupOverviewProgress extends LitElement {
|
||||
font-size: var(--ha-font-size-s);
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
ha-list-item-base::part(end) {
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
ha-list-item-base::part(supporting-text) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { storage } from "../../../common/decorators/storage";
|
||||
import type {
|
||||
@@ -21,9 +21,11 @@ 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";
|
||||
@@ -49,6 +51,7 @@ 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";
|
||||
@@ -134,16 +137,30 @@ 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>
|
||||
usageCounts: Record<string, number>,
|
||||
deleted: string[]
|
||||
): 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,
|
||||
@@ -314,6 +331,13 @@ 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")) {
|
||||
@@ -333,7 +357,8 @@ class HaBlueprintOverview extends LitElement {
|
||||
.data=${this._processedBlueprints(
|
||||
this.blueprints,
|
||||
this.hass.localize,
|
||||
this._usageCounts
|
||||
this._usageCounts,
|
||||
this._deletedFullpaths
|
||||
)}
|
||||
id="fullpath"
|
||||
.noDataText=${this.hass.localize(
|
||||
@@ -342,6 +367,9 @@ 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;"
|
||||
@@ -378,6 +406,14 @@ 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(
|
||||
@@ -432,12 +468,14 @@ 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(
|
||||
@@ -463,6 +501,7 @@ class HaBlueprintOverview extends LitElement {
|
||||
|
||||
if (request === this._usageCountRequest) {
|
||||
this._usageCounts = usageCounts;
|
||||
this._usageCountsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -470,7 +509,8 @@ class HaBlueprintOverview extends LitElement {
|
||||
const blueprint = this._processedBlueprints(
|
||||
this.blueprints,
|
||||
this.hass.localize,
|
||||
this._usageCounts
|
||||
this._usageCounts,
|
||||
this._deletedFullpaths
|
||||
).find((b) => b.fullpath === ev.detail.id)!;
|
||||
if (blueprint.error) {
|
||||
showAlertDialog(this, {
|
||||
@@ -495,7 +535,8 @@ class HaBlueprintOverview extends LitElement {
|
||||
const blueprint = this._processedBlueprints(
|
||||
this.blueprints,
|
||||
this.hass.localize,
|
||||
this._usageCounts
|
||||
this._usageCounts,
|
||||
this._deletedFullpaths
|
||||
).find((item) => item.fullpath === fullpath);
|
||||
if (!blueprint || blueprint.error) {
|
||||
return;
|
||||
@@ -652,6 +693,102 @@ 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;
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ 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";
|
||||
@@ -167,6 +168,15 @@ 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,7 +5,10 @@ import {
|
||||
mdiLocationExit,
|
||||
mdiRefresh,
|
||||
} from "@mdi/js";
|
||||
import type { HassEntities } from "home-assistant-js-websocket";
|
||||
import type {
|
||||
HassEntities,
|
||||
UnsubscribeFunc,
|
||||
} 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";
|
||||
@@ -13,11 +16,16 @@ 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";
|
||||
@@ -44,6 +52,7 @@ 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";
|
||||
@@ -60,13 +69,15 @@ const SYSTEM_KEY = "__system__";
|
||||
const APPS_KEY = "__apps__";
|
||||
|
||||
@customElement("ha-config-section-updates")
|
||||
class HaConfigSectionUpdates extends LitElement {
|
||||
class HaConfigSectionUpdates extends SubscribeMixin(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;
|
||||
@@ -129,6 +140,62 @@ class HaConfigSectionUpdates extends 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,
|
||||
@@ -153,6 +220,8 @@ class HaConfigSectionUpdates extends LitElement {
|
||||
this.hass.locale.language
|
||||
);
|
||||
|
||||
const backupInProgressAlert = this._backupInProgressAlert();
|
||||
|
||||
return html`
|
||||
<hass-subpage
|
||||
back-path="/config/system"
|
||||
@@ -207,108 +276,143 @@ class HaConfigSectionUpdates extends LitElement {
|
||||
}
|
||||
</ha-dropdown>
|
||||
</div>
|
||||
<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)
|
||||
)}
|
||||
@click=${this._updateGroup}
|
||||
>
|
||||
${
|
||||
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(
|
||||
group.entities.length > 1
|
||||
? "ui.panel.config.updates.update_all"
|
||||
: "ui.common.update"
|
||||
"ui.panel.config.updates.no_updates"
|
||||
)}
|
||||
</ha-button>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
<ha-config-updates
|
||||
.narrow=${this.narrow}
|
||||
.updateEntities=${group.entities}
|
||||
showAll
|
||||
></ha-config-updates>
|
||||
</div>
|
||||
</ha-card>
|
||||
`
|
||||
}
|
||||
</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>
|
||||
`;
|
||||
}
|
||||
@@ -516,6 +620,16 @@ class HaConfigSectionUpdates extends 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,13 +62,9 @@ export class HaDeviceCard extends LitElement {
|
||||
return html`
|
||||
<ha-card
|
||||
outlined
|
||||
.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`
|
||||
),
|
||||
})}
|
||||
.header=${this.hass.localize(
|
||||
`ui.panel.config.devices.${this.device.entry_type || "device"}_info_heading`
|
||||
)}
|
||||
>
|
||||
<div class="card-content">
|
||||
${
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
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,6 +1511,22 @@ 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`
|
||||
|
||||
@@ -196,6 +196,11 @@ 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: () =>
|
||||
|
||||
@@ -664,8 +664,13 @@ export class HaConfigEntryRow extends LitElement {
|
||||
const locale_key = result.require_restart
|
||||
? "reload_restart_confirm"
|
||||
: "reload_confirm";
|
||||
showAlertDialog(this, {
|
||||
text: this.hass.localize(
|
||||
// The row can move between sections when the entry state changes on reload
|
||||
const host =
|
||||
(this.isConnected
|
||||
? this
|
||||
: document.querySelector<HTMLElement>("home-assistant")) ?? this;
|
||||
showToast(host, {
|
||||
message: this.hass.localize(
|
||||
`ui.panel.config.integrations.config_entry.${locale_key}`
|
||||
),
|
||||
});
|
||||
|
||||
@@ -162,7 +162,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
|
||||
@state() private _logInfo?: IntegrationLogInfo;
|
||||
|
||||
@state() private _searchParms = new URLSearchParams(
|
||||
window.location.hash.substring(1)
|
||||
__DEMO__ ? window.location.search : window.location.hash.substring(1)
|
||||
);
|
||||
|
||||
@state() private _filter = "";
|
||||
@@ -177,7 +177,9 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
|
||||
private _configEntryRows!: NodeListOf<HaConfigEntryRow>;
|
||||
|
||||
private _handleHashChange = () => {
|
||||
this._searchParms = new URLSearchParams(window.location.hash.substring(1));
|
||||
this._searchParms = new URLSearchParams(
|
||||
__DEMO__ ? window.location.search : window.location.hash.substring(1)
|
||||
);
|
||||
};
|
||||
|
||||
private _domainConfigEntries = memoizeOne(
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
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;
|
||||
};
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { ContextType } from "@lit/context";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { consume } from "../../../../../common/decorators/consume";
|
||||
import "../../../../../components/ha-dialog";
|
||||
import "../../../../../components/ha-alert";
|
||||
import "../../../../../components/ha-dialog-footer";
|
||||
import "../../../../../components/ha-button";
|
||||
import {
|
||||
internationalizationContext,
|
||||
connectionContext,
|
||||
} from "../../../../../data/context";
|
||||
import { subscribeLoRaWANDevices } from "../../../../../data/lorawan";
|
||||
import type { LoRaWANDevice } from "../../../../../data/lorawan";
|
||||
import { DialogMixin } from "../../../../../dialogs/dialog-mixin";
|
||||
import "./lorawan-device-details";
|
||||
|
||||
@customElement("dialog-lorawan-device")
|
||||
class DialogLoRaWANDevice extends DialogMixin<{ device: LoRaWANDevice }>(
|
||||
LitElement
|
||||
) {
|
||||
@consume({ context: connectionContext, subscribe: true })
|
||||
private _connection!: ContextType<typeof connectionContext>;
|
||||
|
||||
private _unsubscribe?: Promise<UnsubscribeFunc>;
|
||||
@state() private _device?: LoRaWANDevice;
|
||||
|
||||
protected firstUpdated() {
|
||||
this._unsubscribe = subscribeLoRaWANDevices(
|
||||
this._connection.connection,
|
||||
({ devices }) => {
|
||||
this._device = devices.find(
|
||||
(device) =>
|
||||
device.dev_eui === this.params!.device.dev_eui &&
|
||||
device.connection.entry_id ===
|
||||
this.params!.device.connection.entry_id
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._unsubscribe?.then((unsubscribe) => unsubscribe());
|
||||
}
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
|
||||
protected render() {
|
||||
if (!this.params) return nothing;
|
||||
const device = this._device ?? this.params.device;
|
||||
return html`<ha-dialog
|
||||
open
|
||||
width="medium"
|
||||
.headerTitle=${device.name || device.dev_eui}
|
||||
.headerSubtitle=${device.model}
|
||||
>
|
||||
${device.support === "supported" ? nothing : html`<ha-alert alert-type="info">${this._i18n.localize(`ui.panel.config.lorawan.support.${device.support}`)}</ha-alert>`}
|
||||
<lorawan-device-details .device=${device}></lorawan-device-details>
|
||||
<ha-dialog-footer slot="footer"
|
||||
><ha-button
|
||||
slot="primaryAction"
|
||||
appearance="plain"
|
||||
@click=${this.closeDialog}
|
||||
>${this._i18n.localize("ui.common.close")}</ha-button
|
||||
></ha-dialog-footer
|
||||
>
|
||||
</ha-dialog>`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
ha-dialog {
|
||||
--dialog-content-padding: 0;
|
||||
}
|
||||
ha-alert {
|
||||
--ha-border-radius-sm: 0;
|
||||
--ha-alert-padding: var(--ha-space-3) var(--ha-space-6);
|
||||
}
|
||||
lorawan-device-details {
|
||||
padding: var(--ha-space-6);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"dialog-lorawan-device": DialogLoRaWANDevice;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { ContextType } from "@lit/context";
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { navigate } from "../../../../../common/navigate";
|
||||
import { isNavigationClick } from "../../../../../common/dom/is-navigation-click";
|
||||
import { consume } from "../../../../../common/decorators/consume";
|
||||
import { uiContext, connectionContext } from "../../../../../data/context";
|
||||
import type { LoRaWANDevice } from "../../../../../data/lorawan";
|
||||
import { brandsUrl } from "../../../../../util/brands-url";
|
||||
|
||||
@customElement("lorawan-connection-link")
|
||||
export class LoRaWANConnectionLink extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public connection!: LoRaWANDevice["connection"];
|
||||
|
||||
@state()
|
||||
@consume({ context: uiContext, subscribe: true })
|
||||
private _ui!: ContextType<typeof uiContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: connectionContext, subscribe: true })
|
||||
private _connection!: ContextType<typeof connectionContext>;
|
||||
|
||||
protected render() {
|
||||
return html` <a
|
||||
href=${`/config/integrations/integration/${this.connection.domain}${__DEMO__ ? "?" : "#"}config_entry=${this.connection.entry_id}`}
|
||||
@click=${this._navigate}
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
src=${brandsUrl({ domain: this.connection.domain, type: "icon", darkOptimized: this._ui?.themes.darkMode }, this._connection?.hassUrl())}
|
||||
/>
|
||||
<span>${this.connection.title}</span>
|
||||
</a>`;
|
||||
}
|
||||
|
||||
private _navigate(ev: MouseEvent) {
|
||||
ev.stopPropagation();
|
||||
const path = isNavigationClick(ev);
|
||||
if (path) navigate(path);
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-2);
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
min-height: 44px;
|
||||
}
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
img {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
object-fit: contain;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"lorawan-connection-link": LoRaWANConnectionLink;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import "../../../../../components/ha-alert";
|
||||
import "../../../../../components/ha-button";
|
||||
import "../../../../../components/ha-card";
|
||||
import "../../../../../components/ha-spinner";
|
||||
import "../../../../../components/ha-icon-next";
|
||||
import "../../../../../components/item/ha-list-item-button";
|
||||
import "../../../../../components/list/ha-list-base";
|
||||
import type { LoRaWANDevices } from "../../../../../data/lorawan";
|
||||
import { subscribeLoRaWANDevices } from "../../../../../data/lorawan";
|
||||
import "../../../../../layouts/hass-subpage";
|
||||
import { panelIsReady } from "../../../../../layouts/panel-ready";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
import { brandsUrl } from "../../../../../util/brands-url";
|
||||
|
||||
@customElement("lorawan-connections")
|
||||
export class LoRaWANConnections extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
@state() private _snapshot?: LoRaWANDevices;
|
||||
@state() private _error = false;
|
||||
private _unsubscribe?: Promise<UnsubscribeFunc>;
|
||||
|
||||
protected firstUpdated() {
|
||||
this._subscribe();
|
||||
}
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
if (this.hasUpdated) this._subscribe();
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._unsubscribe?.then((unsubscribe) => unsubscribe());
|
||||
}
|
||||
|
||||
private _subscribe() {
|
||||
this._error = false;
|
||||
this._unsubscribe = subscribeLoRaWANDevices(
|
||||
this.hass.connection,
|
||||
(snapshot) => {
|
||||
this._snapshot = snapshot;
|
||||
panelIsReady(this);
|
||||
}
|
||||
).catch(() => {
|
||||
this._error = true;
|
||||
panelIsReady(this);
|
||||
return () => undefined;
|
||||
});
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const snapshot = this._snapshot;
|
||||
return html`<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.header=${this.hass.localize("ui.panel.config.lorawan.overview.connections")}
|
||||
back-path="/config/lorawan"
|
||||
>
|
||||
<div class="container">
|
||||
${this._error ? html`<ha-alert alert-type="error">${this.hass.localize("ui.panel.config.lorawan.overview.load_error")}<ha-button slot="action" appearance="plain" @click=${this._subscribe}>${this.hass.localize("ui.common.retry")}</ha-button></ha-alert>` : nothing}
|
||||
${!snapshot && !this._error ? html`<ha-spinner></ha-spinner>` : nothing}
|
||||
${
|
||||
snapshot
|
||||
? snapshot.connections.length
|
||||
? html`<ha-card
|
||||
><ha-list-base>
|
||||
${snapshot.connections.map(
|
||||
(connection) =>
|
||||
html`<ha-list-item-button
|
||||
href=${`/config/lorawan/devices?connection=${connection.entry_id}`}
|
||||
>
|
||||
<img
|
||||
slot="start"
|
||||
alt=""
|
||||
width="24"
|
||||
height="24"
|
||||
src=${brandsUrl({ domain: connection.domain, type: "icon", darkOptimized: this.hass.themes.darkMode }, this.hass.hassUrl())}
|
||||
/>
|
||||
<div slot="headline">${connection.title}</div>
|
||||
<div slot="supporting-text">
|
||||
${this.hass.localize("ui.panel.config.lorawan.overview.devices_count", { count: snapshot.devices.filter((device) => device.connection.entry_id === connection.entry_id).length })}
|
||||
</div>
|
||||
<ha-icon-next slot="end"></ha-icon-next>
|
||||
</ha-list-item-button>`
|
||||
)}
|
||||
</ha-list-base></ha-card
|
||||
>`
|
||||
: html`<ha-alert alert-type="info"
|
||||
>${this.hass.localize("ui.panel.config.lorawan.overview.no_connections")}</ha-alert
|
||||
>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
</hass-subpage>`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
.container {
|
||||
padding: var(--ha-space-2) var(--ha-space-4) var(--ha-space-4);
|
||||
}
|
||||
ha-card,
|
||||
ha-alert {
|
||||
max-width: 600px;
|
||||
margin: var(--ha-space-6) auto var(--ha-space-4);
|
||||
overflow: hidden;
|
||||
}
|
||||
ha-spinner {
|
||||
display: block;
|
||||
margin: var(--ha-space-8) auto;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"lorawan-connections": LoRaWANConnections;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import type { ContextType } from "@lit/context";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { consume } from "../../../../../common/decorators/consume";
|
||||
import "../../../../../components/ha-relative-time";
|
||||
import { internationalizationContext } from "../../../../../data/context";
|
||||
import type { LoRaWANDevice } from "../../../../../data/lorawan";
|
||||
import "./lorawan-connection-link";
|
||||
|
||||
@customElement("lorawan-device-details")
|
||||
export class LoRaWANDeviceDetails extends LitElement {
|
||||
@property({ attribute: false }) public device!: LoRaWANDevice;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
|
||||
private _time(value: string | null) {
|
||||
return value
|
||||
? html`<ha-relative-time
|
||||
.datetime=${value}
|
||||
title=${value}
|
||||
></ha-relative-time>`
|
||||
: this._i18n.localize("ui.panel.config.lorawan.not_observed");
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const device = this.device;
|
||||
return html`
|
||||
<dl>
|
||||
<dt>${this._i18n.localize("ui.panel.config.lorawan.connection")}</dt>
|
||||
<dd>
|
||||
<lorawan-connection-link
|
||||
.connection=${device.connection}
|
||||
></lorawan-connection-link>
|
||||
</dd>
|
||||
<dt>${this._i18n.localize("ui.panel.config.lorawan.dev_eui")}</dt>
|
||||
<dd class="identifier">${device.dev_eui}</dd>
|
||||
<dt>${this._i18n.localize("ui.panel.config.lorawan.application")}</dt>
|
||||
<dd>${device.application_id || "—"}</dd>
|
||||
<dt>${this._i18n.localize("ui.panel.config.lorawan.last_uplink")}</dt>
|
||||
<dd>${this._time(device.last_uplink)}</dd>
|
||||
<dt>${this._i18n.localize("ui.panel.config.lorawan.last_join")}</dt>
|
||||
<dd>${this._time(device.last_join)}</dd>
|
||||
<dt>${this._i18n.localize("ui.panel.config.lorawan.last_status")}</dt>
|
||||
<dd>${this._time(device.last_status)}</dd>
|
||||
${
|
||||
device.downlink_margin === null
|
||||
? nothing
|
||||
: html`<dt>
|
||||
${this._i18n.localize("ui.panel.config.lorawan.downlink_margin")}
|
||||
</dt>
|
||||
<dd>${device.downlink_margin} dB</dd>`
|
||||
}
|
||||
</dl>
|
||||
<h3>${this._i18n.localize("ui.panel.config.lorawan.gateways")}</h3>
|
||||
${
|
||||
device.receptions.length
|
||||
? html` <table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
${this._i18n.localize("ui.panel.config.lorawan.gateway")}
|
||||
</th>
|
||||
<th>
|
||||
${this._i18n.localize("ui.panel.config.lorawan.rssi")}
|
||||
</th>
|
||||
<th>${this._i18n.localize("ui.panel.config.lorawan.snr")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${device.receptions.map(
|
||||
(reception) =>
|
||||
html`<tr>
|
||||
<td class="identifier">${reception.gateway_id}</td>
|
||||
<td>
|
||||
${reception.rssi === null ? "—" : `${reception.rssi} dBm`}
|
||||
</td>
|
||||
<td>
|
||||
${reception.snr === null ? "—" : `${reception.snr} dB`}
|
||||
</td>
|
||||
</tr>`
|
||||
)}
|
||||
</tbody>
|
||||
</table>`
|
||||
: html`<p>
|
||||
${this._i18n.localize("ui.panel.config.lorawan.not_observed")}
|
||||
</p>`
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
dl {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
column-gap: var(--ha-space-6);
|
||||
row-gap: var(--ha-space-3);
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
}
|
||||
dt {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
dd {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.identifier {
|
||||
font-family: var(--ha-font-family-code);
|
||||
}
|
||||
h3 {
|
||||
font-size: var(--ha-font-size-m);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
margin-top: var(--ha-space-6);
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
th {
|
||||
color: var(--secondary-text-color);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
}
|
||||
th,
|
||||
td {
|
||||
text-align: start;
|
||||
padding: var(--ha-space-3) var(--ha-space-2);
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
th:first-child,
|
||||
td:first-child {
|
||||
padding-inline-start: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
th:not(:first-child),
|
||||
td:not(:first-child) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
@media (max-width: 450px) {
|
||||
dl {
|
||||
column-gap: var(--ha-space-3);
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"lorawan-device-details": LoRaWANDeviceDetails;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
import memoizeOne from "memoize-one";
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import { mdiPowerPlug } from "@mdi/js";
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { QueryParamsController } from "../../../../../common/controllers/query-params-controller";
|
||||
import type { HASSDomEvent } from "../../../../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../../../../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../../../../../common/translations/localize";
|
||||
import { navigate } from "../../../../../common/navigate";
|
||||
import type {
|
||||
DataTableColumnContainer,
|
||||
DataTableRowData,
|
||||
RowClickedEvent,
|
||||
} from "../../../../../components/data-table/ha-data-table";
|
||||
import "../../../../../components/ha-select";
|
||||
import type { HaSelectSelectEvent } from "../../../../../components/ha-select";
|
||||
import "../../../../../components/ha-relative-time";
|
||||
import "../../../../../components/ha-svg-icon";
|
||||
import type {
|
||||
LoRaWANDevice,
|
||||
LoRaWANConnection,
|
||||
} from "../../../../../data/lorawan";
|
||||
import {
|
||||
strongestLoRaWANReception,
|
||||
subscribeLoRaWANDevices,
|
||||
} from "../../../../../data/lorawan";
|
||||
import "../../../../../layouts/hass-tabs-subpage-data-table";
|
||||
import { panelIsReady } from "../../../../../layouts/panel-ready";
|
||||
import { loadVirtualizer } from "../../../../../resources/virtualizer";
|
||||
import type { HomeAssistant, Route } from "../../../../../types";
|
||||
import "./lorawan-connection-link";
|
||||
import { connectionLabels } from "./connection-labels";
|
||||
|
||||
interface DeviceRow extends LoRaWANDevice, DataTableRowData {
|
||||
id: string;
|
||||
rssi: number | null;
|
||||
connection_title: string;
|
||||
}
|
||||
|
||||
@customElement("lorawan-devices")
|
||||
export class LoRaWANDevices extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
@property({ attribute: false }) public route!: Route;
|
||||
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
|
||||
@state() private _connectionId = "";
|
||||
|
||||
private _queryParams = new QueryParamsController(
|
||||
this,
|
||||
{ string: ["connection"] as const },
|
||||
{
|
||||
enabled: () => true,
|
||||
navigated: () => this._readConnectionFilter(),
|
||||
}
|
||||
);
|
||||
|
||||
private _tabs = [
|
||||
{
|
||||
translationKey: "ui.panel.config.lorawan.devices",
|
||||
path: "/config/lorawan/devices",
|
||||
},
|
||||
];
|
||||
@state() private _devices?: LoRaWANDevice[];
|
||||
@state() private _error = false;
|
||||
@state() private _connections: LoRaWANConnection[] = [];
|
||||
|
||||
private _unsubscribe?: Promise<UnsubscribeFunc>;
|
||||
|
||||
private _connectionLabels = memoizeOne(connectionLabels);
|
||||
|
||||
private _rows = memoizeOne(
|
||||
(
|
||||
devices: LoRaWANDevice[] | undefined,
|
||||
connection: string | null,
|
||||
labels: Map<string, string>
|
||||
): DeviceRow[] =>
|
||||
(devices ?? [])
|
||||
.filter(
|
||||
(device) => !connection || device.connection.entry_id === connection
|
||||
)
|
||||
.map((device) => ({
|
||||
...device,
|
||||
id: `${device.connection.entry_id}:${device.dev_eui}`,
|
||||
rssi: strongestLoRaWANReception(device.receptions)?.rssi ?? null,
|
||||
connection_title: labels.get(device.connection.entry_id)!,
|
||||
}))
|
||||
);
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
if (this.hasUpdated) this._subscribe();
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._unsubscribe?.then((unsubscribe) => unsubscribe());
|
||||
this._unsubscribe = undefined;
|
||||
}
|
||||
|
||||
protected firstUpdated() {
|
||||
this._readConnectionFilter();
|
||||
this._subscribe();
|
||||
}
|
||||
|
||||
private _subscribe() {
|
||||
this._error = false;
|
||||
this._unsubscribe = loadVirtualizer()
|
||||
.then(() =>
|
||||
subscribeLoRaWANDevices(
|
||||
this.hass.connection,
|
||||
({ devices, connections }) => {
|
||||
this._devices = devices;
|
||||
this._connections = connections;
|
||||
panelIsReady(this);
|
||||
}
|
||||
)
|
||||
)
|
||||
.catch(() => {
|
||||
this._error = true;
|
||||
panelIsReady(this);
|
||||
return () => undefined;
|
||||
});
|
||||
}
|
||||
|
||||
private _columns = memoizeOne(
|
||||
(
|
||||
localize: LocalizeFunc,
|
||||
narrow: boolean
|
||||
): DataTableColumnContainer<DeviceRow> => ({
|
||||
name: {
|
||||
title: localize("ui.panel.config.lorawan.device"),
|
||||
main: true,
|
||||
showNarrow: true,
|
||||
filterable: true,
|
||||
sortable: true,
|
||||
flex: 2,
|
||||
minWidth: "220px",
|
||||
template: (device) =>
|
||||
html`<div>
|
||||
<div class="primary">${device.name || device.dev_eui}</div>
|
||||
<div class="secondary">
|
||||
${[device.manufacturer, device.model].filter(Boolean).join(" · ") || device.dev_eui}
|
||||
</div>
|
||||
</div>`,
|
||||
},
|
||||
dev_eui: {
|
||||
title: localize("ui.panel.config.lorawan.dev_eui"),
|
||||
hidden: true,
|
||||
filterable: true,
|
||||
},
|
||||
connection_title: {
|
||||
groupable: true,
|
||||
urlName: "connection",
|
||||
hidden: narrow,
|
||||
title: localize("ui.panel.config.lorawan.connection"),
|
||||
filterable: true,
|
||||
sortable: true,
|
||||
minWidth: "210px",
|
||||
flex: 1.5,
|
||||
template: (device) =>
|
||||
html`<lorawan-connection-link
|
||||
.connection=${device.connection}
|
||||
></lorawan-connection-link>`,
|
||||
},
|
||||
last_uplink: {
|
||||
hidden: narrow,
|
||||
title: localize("ui.panel.config.lorawan.last_uplink"),
|
||||
sortable: true,
|
||||
minWidth: "150px",
|
||||
template: (device) => this._time(device.last_uplink, localize),
|
||||
},
|
||||
rssi: {
|
||||
hidden: narrow,
|
||||
title: localize("ui.panel.config.lorawan.rssi"),
|
||||
sortable: true,
|
||||
type: "numeric",
|
||||
minWidth: "90px",
|
||||
maxWidth: "110px",
|
||||
template: (device) =>
|
||||
device.rssi === null ? "—" : `${device.rssi} dBm`,
|
||||
},
|
||||
battery_level: {
|
||||
hidden: narrow,
|
||||
title: localize("ui.panel.config.lorawan.battery"),
|
||||
sortable: true,
|
||||
type: "numeric",
|
||||
minWidth: "75px",
|
||||
maxWidth: "100px",
|
||||
template: (device) =>
|
||||
device.external_power_source
|
||||
? html`<ha-svg-icon
|
||||
.path=${mdiPowerPlug}
|
||||
aria-label=${localize("ui.panel.config.lorawan.external_power")}
|
||||
title=${localize("ui.panel.config.lorawan.external_power")}
|
||||
></ha-svg-icon>`
|
||||
: device.battery_level === null
|
||||
? "—"
|
||||
: `${Math.round(device.battery_level)}%`,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
private _time(timestamp: string | null, localize: LocalizeFunc) {
|
||||
return timestamp
|
||||
? html`<ha-relative-time
|
||||
.datetime=${timestamp}
|
||||
title=${timestamp}
|
||||
></ha-relative-time>`
|
||||
: localize("ui.panel.config.lorawan.not_observed");
|
||||
}
|
||||
|
||||
private _readConnectionFilter() {
|
||||
this._connectionId =
|
||||
this._queryParams.searchParams()?.get("connection") ?? "";
|
||||
}
|
||||
|
||||
private _connectionChanged(ev: HaSelectSelectEvent) {
|
||||
this._connectionId = ev.detail.value === "all" ? "" : ev.detail.value;
|
||||
this._queryParams.write({ connection: this._connectionId });
|
||||
}
|
||||
|
||||
private _clearFilter() {
|
||||
this._connectionId = "";
|
||||
this._queryParams.write({ connection: "" });
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const labels = this._connectionLabels(this._connections);
|
||||
return html`<hass-tabs-subpage-data-table
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.isWide=${this.isWide}
|
||||
.route=${this.route}
|
||||
.tabs=${this._tabs}
|
||||
.columns=${this._columns(this.hass.localize, this.narrow)}
|
||||
.data=${this._rows(this._devices, this._connectionId, labels)}
|
||||
.loading=${this._devices === undefined && !this._error}
|
||||
.loadError=${this._error}
|
||||
.noDataText=${this.hass.localize("ui.panel.config.lorawan.no_devices")}
|
||||
.filters=${this._connectionId ? 1 : 0}
|
||||
has-filters
|
||||
sync-url
|
||||
clickable
|
||||
back-path="/config/lorawan"
|
||||
@row-click=${this._rowClicked}
|
||||
@retry-load=${this._subscribe}
|
||||
@clear-filter=${this._clearFilter}
|
||||
>
|
||||
<ha-select
|
||||
slot="filter-pane"
|
||||
.label=${this.hass.localize("ui.panel.config.lorawan.connection")}
|
||||
.value=${this._connectionId || "all"}
|
||||
.options=${[
|
||||
{
|
||||
value: "all",
|
||||
label: this.hass.localize(
|
||||
"ui.panel.config.lorawan.all_connections"
|
||||
),
|
||||
},
|
||||
...this._connections.map((connection) => ({
|
||||
value: connection.entry_id,
|
||||
label: labels.get(connection.entry_id)!,
|
||||
})),
|
||||
]}
|
||||
@selected=${this._connectionChanged}
|
||||
></ha-select>
|
||||
</hass-tabs-subpage-data-table>`;
|
||||
}
|
||||
|
||||
private _rowClicked(ev: HASSDomEvent<RowClickedEvent>) {
|
||||
const device = this._devices!.find(
|
||||
(candidate) =>
|
||||
`${candidate.connection.entry_id}:${candidate.dev_eui}` === ev.detail.id
|
||||
)!;
|
||||
if (device.device_id) {
|
||||
navigate(`/config/devices/device/${device.device_id}`);
|
||||
return;
|
||||
}
|
||||
fireEvent(this, "show-dialog", {
|
||||
dialogTag: "dialog-lorawan-device",
|
||||
dialogImport: () => import("./dialog-lorawan-device"),
|
||||
dialogParams: { device },
|
||||
});
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
hass-tabs-subpage-data-table {
|
||||
display: block;
|
||||
--data-table-row-height: 72px;
|
||||
}
|
||||
ha-select {
|
||||
margin: var(--ha-space-4);
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
hass-tabs-subpage-data-table {
|
||||
--data-table-row-height: 132px;
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"lorawan-devices": LoRaWANDevices;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import {
|
||||
mdiCheck,
|
||||
mdiDevices,
|
||||
mdiPlus,
|
||||
mdiLanConnect,
|
||||
mdiCloseCircleOutline,
|
||||
mdiVectorPolyline,
|
||||
} from "@mdi/js";
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../../../../common/dom/fire_event";
|
||||
import "../../../../../components/ha-alert";
|
||||
import "../../../../../components/ha-button";
|
||||
import "../../../../../components/ha-card";
|
||||
import "../../../../../components/ha-icon-next";
|
||||
import "../../../../../components/ha-spinner";
|
||||
import "../../../../../components/ha-svg-icon";
|
||||
import "../../../../../components/item/ha-list-item-button";
|
||||
import "../../../../../components/list/ha-list-nav";
|
||||
import type { LoRaWANDevices } from "../../../../../data/lorawan";
|
||||
import { subscribeLoRaWANDevices } from "../../../../../data/lorawan";
|
||||
import "../../../../../layouts/hass-subpage";
|
||||
import { panelIsReady } from "../../../../../layouts/panel-ready";
|
||||
import { haLora } from "../../../../../resources/ha-icons";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
|
||||
@customElement("lorawan-overview")
|
||||
export class LoRaWANOverview extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
@state() private _snapshot?: LoRaWANDevices;
|
||||
@state() private _error = false;
|
||||
private _unsubscribe?: Promise<UnsubscribeFunc>;
|
||||
|
||||
protected firstUpdated() {
|
||||
this._subscribe();
|
||||
}
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
if (this.hasUpdated) this._subscribe();
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._unsubscribe?.then((unsubscribe) => unsubscribe());
|
||||
}
|
||||
|
||||
private _subscribe() {
|
||||
this._error = false;
|
||||
this._unsubscribe = subscribeLoRaWANDevices(
|
||||
this.hass.connection,
|
||||
(snapshot) => {
|
||||
this._snapshot = snapshot;
|
||||
panelIsReady(this);
|
||||
}
|
||||
).catch(() => {
|
||||
this._error = true;
|
||||
panelIsReady(this);
|
||||
return () => undefined;
|
||||
});
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const snapshot = this._snapshot;
|
||||
const connected = !!snapshot?.connections.length;
|
||||
return html`<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.header=${this.hass.localize("ui.panel.config.lorawan.title")}
|
||||
back-path="/config/connectivity"
|
||||
has-fab
|
||||
>
|
||||
<ha-button
|
||||
slot="fab"
|
||||
size="l"
|
||||
@click=${this._addDevice}
|
||||
.disabled=${!snapshot?.connections.length}
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
|
||||
${this.hass.localize("ui.panel.config.lorawan.add_device")}
|
||||
</ha-button>
|
||||
<div class="container">
|
||||
${this._error ? html`<ha-alert alert-type="error">${this.hass.localize("ui.panel.config.lorawan.overview.load_error")}<ha-button slot="action" appearance="plain" @click=${this._subscribe}>${this.hass.localize("ui.common.retry")}</ha-button></ha-alert>` : nothing}
|
||||
${!snapshot && !this._error ? html`<ha-spinner></ha-spinner>` : nothing}
|
||||
${
|
||||
snapshot
|
||||
? html`
|
||||
<ha-card class="summary">
|
||||
<div class="summary-content">
|
||||
<div
|
||||
class="status-icon ${connected ? "connected" : "unavailable"}"
|
||||
>
|
||||
<ha-svg-icon
|
||||
.path=${connected ? mdiCheck : mdiCloseCircleOutline}
|
||||
></ha-svg-icon>
|
||||
</div>
|
||||
<div>
|
||||
<h2>
|
||||
${this.hass.localize("ui.panel.config.lorawan.overview.total_connections", { count: snapshot.connections.length })}
|
||||
</h2>
|
||||
<p>
|
||||
${this.hass.localize("ui.panel.config.lorawan.overview.devices_count", { count: snapshot.devices.length })}
|
||||
</p>
|
||||
</div>
|
||||
<ha-svg-icon
|
||||
class="logo"
|
||||
.path=${haLora}
|
||||
.viewBox=${"0 0 250 166"}
|
||||
></ha-svg-icon>
|
||||
</div>
|
||||
</ha-card>
|
||||
<ha-card>
|
||||
<div class="card-heading">
|
||||
<h2>
|
||||
${this.hass.localize("ui.panel.config.lorawan.overview.network")}
|
||||
</h2>
|
||||
<ha-button
|
||||
appearance="filled"
|
||||
href="/config/lorawan/topology"
|
||||
><ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiVectorPolyline}
|
||||
></ha-svg-icon
|
||||
>${this.hass.localize("ui.panel.config.lorawan.overview.show_map")}</ha-button
|
||||
>
|
||||
</div>
|
||||
<ha-list-nav
|
||||
.ariaLabel=${this.hass.localize("ui.panel.config.lorawan.overview.network")}
|
||||
>
|
||||
<ha-list-item-button
|
||||
href="/config/lorawan/devices?group=connection"
|
||||
><ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiDevices}
|
||||
></ha-svg-icon>
|
||||
<div slot="headline">
|
||||
${this.hass.localize("ui.panel.config.lorawan.overview.devices_count", { count: snapshot.devices.length })}
|
||||
</div>
|
||||
<ha-icon-next slot="end"></ha-icon-next
|
||||
></ha-list-item-button>
|
||||
<ha-list-item-button href="/config/lorawan/connections">
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiLanConnect}
|
||||
></ha-svg-icon>
|
||||
<div slot="headline">
|
||||
${this.hass.localize("ui.panel.config.lorawan.overview.total_connections", { count: snapshot.connections.length })}
|
||||
</div>
|
||||
<ha-icon-next slot="end"></ha-icon-next>
|
||||
</ha-list-item-button>
|
||||
</ha-list-nav>
|
||||
</ha-card>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
</hass-subpage>`;
|
||||
}
|
||||
|
||||
private _addDevice() {
|
||||
fireEvent(this, "show-dialog", {
|
||||
dialogTag: "dialog-lorawan-add-device",
|
||||
dialogImport: () => import("./dialog-lorawan-add-device"),
|
||||
dialogParams: { connections: this._snapshot!.connections },
|
||||
});
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
.container {
|
||||
padding: var(--ha-space-2) var(--ha-space-4) var(--ha-space-4);
|
||||
}
|
||||
ha-card {
|
||||
overflow: hidden;
|
||||
margin: 0 auto var(--ha-space-4);
|
||||
max-width: 600px;
|
||||
}
|
||||
.summary {
|
||||
margin-top: var(--ha-space-6);
|
||||
}
|
||||
.summary-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-4);
|
||||
padding: var(--ha-space-4);
|
||||
}
|
||||
.summary h2 {
|
||||
font-size: var(--ha-font-size-xl);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
}
|
||||
.status-icon {
|
||||
position: relative;
|
||||
border-radius: var(--ha-border-radius-2xl);
|
||||
width: var(--ha-space-10);
|
||||
height: var(--ha-space-10);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
--icon-color: var(--success-color);
|
||||
}
|
||||
.status-icon.unavailable {
|
||||
--icon-color: var(--error-color);
|
||||
}
|
||||
.status-icon::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-color: var(--icon-color);
|
||||
opacity: 0.2;
|
||||
}
|
||||
.status-icon ha-svg-icon {
|
||||
color: var(--icon-color);
|
||||
}
|
||||
.logo {
|
||||
--mdc-icon-size: 40px;
|
||||
margin-inline-start: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
h2 {
|
||||
font-size: var(--ha-font-size-l);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
margin: 0;
|
||||
}
|
||||
p {
|
||||
color: var(--secondary-text-color);
|
||||
margin: var(--ha-space-1) 0 0;
|
||||
}
|
||||
.card-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-3);
|
||||
padding: var(--ha-space-4);
|
||||
}
|
||||
ha-spinner {
|
||||
display: block;
|
||||
margin: var(--ha-space-8) auto;
|
||||
}
|
||||
ha-alert {
|
||||
margin-bottom: var(--ha-space-4);
|
||||
}
|
||||
@media (max-width: 450px) {
|
||||
.container {
|
||||
padding: var(--ha-space-2) var(--ha-space-4) var(--ha-space-4);
|
||||
}
|
||||
.card-heading {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"lorawan-overview": LoRaWANOverview;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import type { RouterOptions } from "../../../../../layouts/hass-router-page";
|
||||
import { HassRouterPage } from "../../../../../layouts/hass-router-page";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
|
||||
@customElement("lorawan-router")
|
||||
class LoRaWANRouter extends HassRouterPage {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
|
||||
|
||||
protected routerOptions: RouterOptions = {
|
||||
defaultPage: "overview",
|
||||
showLoading: true,
|
||||
routes: {
|
||||
overview: {
|
||||
tag: "lorawan-overview",
|
||||
load: () => import("./lorawan-overview"),
|
||||
},
|
||||
connections: {
|
||||
tag: "lorawan-connections",
|
||||
load: () => import("./lorawan-connections"),
|
||||
},
|
||||
devices: {
|
||||
tag: "lorawan-devices",
|
||||
load: () => import("./lorawan-devices"),
|
||||
},
|
||||
topology: {
|
||||
tag: "lorawan-topology",
|
||||
load: () => import("./lorawan-topology"),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
protected updatePageEl(el): void {
|
||||
el.hass = this.hass;
|
||||
el.narrow = this.narrow;
|
||||
el.isWide = this.isWide;
|
||||
el.route = this.routeTail;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"lorawan-router": LoRaWANRouter;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
import type {
|
||||
CallbackDataParams,
|
||||
TopLevelFormatterParams,
|
||||
} from "echarts/types/dist/shared";
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import type {
|
||||
HASSDomEvent,
|
||||
HASSDomTargetEvent,
|
||||
} from "../../../../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../../../../common/dom/fire_event";
|
||||
import { navigate } from "../../../../../common/navigate";
|
||||
import type {
|
||||
NetworkData,
|
||||
NetworkNode,
|
||||
} from "../../../../../components/chart/ha-network-graph";
|
||||
import "../../../../../components/chart/ha-network-graph";
|
||||
import "../../../../../components/ha-alert";
|
||||
import "../../../../../components/ha-button";
|
||||
import "../../../../../components/ha-relative-time";
|
||||
import "../../../../../components/ha-spinner";
|
||||
import "../../../../../components/input/ha-input-search";
|
||||
import type { HaInputSearch } from "../../../../../components/input/ha-input-search";
|
||||
import type {
|
||||
LoRaWANDevice,
|
||||
LoRaWANDevices,
|
||||
} from "../../../../../data/lorawan";
|
||||
import { subscribeLoRaWANDevices } from "../../../../../data/lorawan";
|
||||
import type { LoRaWANTopology, LoRaWANTopologyNode } from "./lorawan_topology";
|
||||
import { buildLoRaWANTopology } from "./lorawan_topology";
|
||||
import "../../../../../layouts/hass-subpage";
|
||||
import { panelIsReady } from "../../../../../layouts/panel-ready";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
|
||||
@customElement("lorawan-topology")
|
||||
export class LoRaWANTopologyPanel extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@property({ type: Boolean, reflect: true }) public narrow = false;
|
||||
@state() private _snapshot?: LoRaWANDevices;
|
||||
@state() private _error = false;
|
||||
@state() private _search = "";
|
||||
private _unsubscribe?: Promise<UnsubscribeFunc>;
|
||||
private _topology: LoRaWANTopology = { nodes: [], links: [] };
|
||||
|
||||
protected firstUpdated() {
|
||||
this._subscribe();
|
||||
}
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
if (this.hasUpdated) this._subscribe();
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._unsubscribe?.then((unsubscribe) => unsubscribe());
|
||||
}
|
||||
|
||||
private _subscribe() {
|
||||
this._error = false;
|
||||
this._unsubscribe = subscribeLoRaWANDevices(
|
||||
this.hass.connection,
|
||||
(snapshot) => {
|
||||
this._snapshot = snapshot;
|
||||
panelIsReady(this);
|
||||
}
|
||||
).catch(() => {
|
||||
this._error = true;
|
||||
panelIsReady(this);
|
||||
return () => undefined;
|
||||
});
|
||||
}
|
||||
|
||||
private _nodeName(node: LoRaWANTopologyNode) {
|
||||
return node.kind === "connection"
|
||||
? node.connection.title
|
||||
: node.kind === "gateway"
|
||||
? node.gateway_id
|
||||
: node.device.name || node.device.dev_eui;
|
||||
}
|
||||
|
||||
private _graphData = memoizeOne(
|
||||
(topology: LoRaWANTopology, narrow: boolean): NetworkData => {
|
||||
const style = getComputedStyle(this);
|
||||
const colors = {
|
||||
connection: style.getPropertyValue("--primary-color"),
|
||||
gateway: style.getPropertyValue("--teal-color"),
|
||||
device: style.getPropertyValue("--blue-color"),
|
||||
link: style.getPropertyValue("--divider-color"),
|
||||
};
|
||||
const nodes: NetworkNode[] = [];
|
||||
let offset = 0;
|
||||
for (const root of topology.nodes.filter(
|
||||
(node) => node.kind === "connection"
|
||||
)) {
|
||||
const children = topology.nodes.filter(
|
||||
(node) => node.connection.entry_id === root.connection.entry_id
|
||||
);
|
||||
const gateways = children.filter((node) => node.kind === "gateway");
|
||||
const devices = children.filter((node) => node.kind === "device");
|
||||
const height = Math.max(devices.length, gateways.length, 1) * 100;
|
||||
for (const node of children) {
|
||||
const index =
|
||||
node.kind === "connection"
|
||||
? 0
|
||||
: node.kind === "gateway"
|
||||
? gateways.indexOf(node)
|
||||
: devices.indexOf(node);
|
||||
const count =
|
||||
node.kind === "connection"
|
||||
? 1
|
||||
: node.kind === "gateway"
|
||||
? gateways.length
|
||||
: devices.length;
|
||||
nodes.push({
|
||||
id: node.id,
|
||||
name: this._nodeName(node),
|
||||
context: node.kind === "device" ? node.device.model : undefined,
|
||||
symbol:
|
||||
node.kind === "connection"
|
||||
? "roundRect"
|
||||
: node.kind === "gateway"
|
||||
? "diamond"
|
||||
: "circle",
|
||||
symbolSize: node.kind === "device" ? 20 : 30,
|
||||
itemStyle: {
|
||||
color: colors[node.kind],
|
||||
},
|
||||
label: {
|
||||
position:
|
||||
narrow && node.kind === "device"
|
||||
? "top"
|
||||
: narrow && node.kind === "gateway"
|
||||
? "left"
|
||||
: "right",
|
||||
},
|
||||
fixed: node.kind === "connection",
|
||||
x:
|
||||
node.kind === "connection"
|
||||
? 0
|
||||
: node.kind === "gateway"
|
||||
? 320
|
||||
: 640,
|
||||
y: offset + ((index + 0.5) * height) / count,
|
||||
});
|
||||
}
|
||||
offset += height + 120;
|
||||
}
|
||||
return {
|
||||
nodes,
|
||||
links: topology.links.map((link) => ({
|
||||
source: link.source,
|
||||
target: link.target,
|
||||
symbol: "none",
|
||||
value: link.reception?.rssi ?? undefined,
|
||||
lineStyle: {
|
||||
color:
|
||||
link.kind === "membership"
|
||||
? colors.connection
|
||||
: link.kind === "unobserved"
|
||||
? colors.link
|
||||
: colors.gateway,
|
||||
width: link.kind === "membership" ? 2 : 1.5,
|
||||
type: link.kind === "unobserved" ? "dashed" : "solid",
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
protected willUpdate(changed: PropertyValues) {
|
||||
if (
|
||||
changed.has("hass") &&
|
||||
changed.get("hass")?.themes !== this.hass.themes
|
||||
) {
|
||||
this._graphData.clear();
|
||||
}
|
||||
if (changed.has("_snapshot")) {
|
||||
this._topology = buildLoRaWANTopology(
|
||||
this._snapshot ?? { connections: [], devices: [] }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
return html`<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.header=${this.hass.localize("ui.panel.config.lorawan.topology.title")}
|
||||
.scrollable=${false}
|
||||
back-path="/config/lorawan"
|
||||
>
|
||||
${this.narrow ? html`<div slot="header">${this._renderSearch()}</div>` : nothing}
|
||||
${
|
||||
this._error
|
||||
? html`<ha-alert alert-type="error"
|
||||
>${this.hass.localize("ui.panel.config.lorawan.topology.load_error")}<ha-button
|
||||
slot="action"
|
||||
appearance="plain"
|
||||
@click=${this._subscribe}
|
||||
>${this.hass.localize("ui.common.retry")}</ha-button
|
||||
></ha-alert
|
||||
>`
|
||||
: !this._snapshot
|
||||
? html`<ha-spinner></ha-spinner>`
|
||||
: !this._snapshot.connections.length
|
||||
? html`<ha-alert alert-type="info"
|
||||
>${this.hass.localize("ui.panel.config.lorawan.topology.no_connections")}</ha-alert
|
||||
>`
|
||||
: html`<div class="map">
|
||||
<ha-network-graph
|
||||
.data=${this._graphData(this._topology, this.narrow)}
|
||||
.searchFilter=${this._search}
|
||||
.tooltipFormatter=${this._tooltip}
|
||||
@chart-click=${this._chartClicked}
|
||||
>${!this.narrow ? this._renderSearch("search") : nothing}</ha-network-graph
|
||||
>
|
||||
<div class="legend">
|
||||
<span
|
||||
><i class="connection-marker"></i
|
||||
>${this.hass.localize("ui.panel.config.lorawan.topology.connection")}</span
|
||||
>
|
||||
<span
|
||||
><i class="gateway-marker"></i
|
||||
>${this.hass.localize("ui.panel.config.lorawan.topology.gateway")}</span
|
||||
>
|
||||
<span
|
||||
><i class="device-marker"></i
|
||||
>${this.hass.localize("ui.panel.config.lorawan.topology.device")}</span
|
||||
>
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
</hass-subpage>`;
|
||||
}
|
||||
|
||||
private _renderSearch(slot = "") {
|
||||
return html`<ha-input-search
|
||||
appearance="outlined"
|
||||
slot=${slot}
|
||||
.value=${this._search}
|
||||
.placeholder=${this.hass.localize("ui.panel.config.lorawan.topology.search")}
|
||||
@input=${this._searchChanged}
|
||||
></ha-input-search>`;
|
||||
}
|
||||
|
||||
private _searchChanged(ev: HASSDomTargetEvent<HaInputSearch>) {
|
||||
this._search = ev.target.value ?? "";
|
||||
}
|
||||
|
||||
private _tooltip = (params: TopLevelFormatterParams) => {
|
||||
const item = params as CallbackDataParams;
|
||||
const data = item.data;
|
||||
if (!data || typeof data !== "object") return nothing;
|
||||
if (item.dataType === "edge" && "source" in data && "target" in data) {
|
||||
const link = this._topology.links.find(
|
||||
(candidate) =>
|
||||
candidate.source === data.source && candidate.target === data.target
|
||||
);
|
||||
if (!link) return nothing;
|
||||
if (link.kind === "unobserved") {
|
||||
return html`${this.hass.localize("ui.panel.config.lorawan.topology.unobserved")}`;
|
||||
}
|
||||
if (!link.reception || !link.device) return nothing;
|
||||
return html`<strong
|
||||
>${link.reception.gateway_id} →
|
||||
${link.device.name || link.device.dev_eui}</strong
|
||||
><br />${this.hass.localize("ui.panel.config.lorawan.rssi")}:
|
||||
${link.reception.rssi === null ? "—" : `${link.reception.rssi} dBm`}<br />${this.hass.localize("ui.panel.config.lorawan.snr")}:
|
||||
${link.reception.snr === null ? "—" : `${link.reception.snr} dB`}${link.device.last_uplink ? html`<br />${this.hass.localize("ui.panel.config.lorawan.last_uplink")}: <ha-relative-time .datetime=${link.device.last_uplink}></ha-relative-time>` : nothing}`;
|
||||
}
|
||||
if (!("id" in data)) return nothing;
|
||||
const node = this._topology.nodes.find(
|
||||
(candidate) => candidate.id === data.id
|
||||
);
|
||||
return node
|
||||
? html`<strong>${this._nodeName(node)}</strong
|
||||
><br />${node.connection.title}`
|
||||
: nothing;
|
||||
};
|
||||
|
||||
private _chartClicked(ev: HASSDomEvent<HASSDomEvents["chart-click"]>) {
|
||||
const data = ev.detail.data;
|
||||
if (!data || typeof data !== "object" || !("id" in data)) return;
|
||||
const node = this._topology.nodes.find(
|
||||
(candidate) => candidate.id === data.id
|
||||
);
|
||||
if (node?.kind === "device") this._openDevice(node.device);
|
||||
else if (node?.kind === "connection") {
|
||||
navigate(
|
||||
`/config/integrations/integration/${node.connection.domain}${__DEMO__ ? "?" : "#"}config_entry=${node.connection.entry_id}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private _openDevice(device: LoRaWANDevice) {
|
||||
if (device.device_id) {
|
||||
navigate(`/config/devices/device/${device.device_id}`);
|
||||
return;
|
||||
}
|
||||
fireEvent(this, "show-dialog", {
|
||||
dialogTag: "dialog-lorawan-device",
|
||||
dialogImport: () => import("./dialog-lorawan-device"),
|
||||
dialogParams: { device },
|
||||
});
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
[slot="header"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
ha-input-search {
|
||||
flex: 1;
|
||||
}
|
||||
.map {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
}
|
||||
ha-network-graph {
|
||||
height: 100%;
|
||||
}
|
||||
.legend {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
inset-inline: 0;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
gap: var(--ha-space-4);
|
||||
justify-content: center;
|
||||
padding: 0 var(--ha-space-3) var(--ha-space-4);
|
||||
font-size: var(--ha-font-size-s);
|
||||
}
|
||||
.legend span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
.legend i {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
display: inline-block;
|
||||
}
|
||||
.connection-marker {
|
||||
background: var(--primary-color);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.gateway-marker {
|
||||
background: var(--teal-color);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.device-marker {
|
||||
background: var(--blue-color);
|
||||
border-radius: 50%;
|
||||
}
|
||||
ha-spinner {
|
||||
display: block;
|
||||
margin: var(--ha-space-8) auto;
|
||||
}
|
||||
ha-alert {
|
||||
margin: var(--ha-space-4);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"lorawan-topology": LoRaWANTopologyPanel;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type {
|
||||
LoRaWANConnection,
|
||||
LoRaWANDevice,
|
||||
LoRaWANDevices,
|
||||
LoRaWANReception,
|
||||
} from "../../../../../data/lorawan";
|
||||
|
||||
export type LoRaWANTopologyNode =
|
||||
| { id: string; kind: "connection"; connection: LoRaWANConnection }
|
||||
| {
|
||||
id: string;
|
||||
kind: "gateway";
|
||||
connection: LoRaWANConnection;
|
||||
gateway_id: string;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
kind: "device";
|
||||
connection: LoRaWANConnection;
|
||||
device: LoRaWANDevice;
|
||||
};
|
||||
|
||||
export interface LoRaWANTopologyLink {
|
||||
source: string;
|
||||
target: string;
|
||||
kind: "membership" | "reception" | "unobserved";
|
||||
reception?: LoRaWANReception;
|
||||
device?: LoRaWANDevice;
|
||||
}
|
||||
|
||||
export interface LoRaWANTopology {
|
||||
nodes: LoRaWANTopologyNode[];
|
||||
links: LoRaWANTopologyLink[];
|
||||
}
|
||||
|
||||
export const buildLoRaWANTopology = (
|
||||
snapshot: LoRaWANDevices
|
||||
): LoRaWANTopology => {
|
||||
const nodes: LoRaWANTopologyNode[] = [];
|
||||
const links: LoRaWANTopologyLink[] = [];
|
||||
const gateways = new Set<string>();
|
||||
for (const connection of snapshot.connections) {
|
||||
nodes.push({
|
||||
id: `connection:${connection.entry_id}`,
|
||||
kind: "connection",
|
||||
connection,
|
||||
});
|
||||
}
|
||||
for (const device of snapshot.devices) {
|
||||
const connection = device.connection;
|
||||
const connectionId = `connection:${connection.entry_id}`;
|
||||
const deviceId = `device:${connection.entry_id}:${device.dev_eui}`;
|
||||
nodes.push({ id: deviceId, kind: "device", connection, device });
|
||||
const receptions = device.receptions.filter(
|
||||
(reception) => reception.gateway_id
|
||||
);
|
||||
for (const reception of receptions) {
|
||||
const gatewayId = `gateway:${connection.entry_id}:${reception.gateway_id}`;
|
||||
if (!gateways.has(gatewayId)) {
|
||||
gateways.add(gatewayId);
|
||||
nodes.push({
|
||||
id: gatewayId,
|
||||
kind: "gateway",
|
||||
connection,
|
||||
gateway_id: reception.gateway_id,
|
||||
});
|
||||
links.push({
|
||||
source: connectionId,
|
||||
target: gatewayId,
|
||||
kind: "membership",
|
||||
});
|
||||
}
|
||||
links.push({
|
||||
source: gatewayId,
|
||||
target: deviceId,
|
||||
kind: "reception",
|
||||
reception,
|
||||
device,
|
||||
});
|
||||
}
|
||||
if (!receptions.length) {
|
||||
links.push({
|
||||
source: connectionId,
|
||||
target: deviceId,
|
||||
kind: "unobserved",
|
||||
device,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { nodes, links };
|
||||
};
|
||||
+4
-6
@@ -3,8 +3,8 @@ import { customElement, property } from "lit/decorators";
|
||||
import { fireEvent } from "../../../../../../common/dom/fire_event";
|
||||
import type { HomeAssistant } from "../../../../../../types";
|
||||
|
||||
import "../../../../../../components/ha-alert";
|
||||
import "../../../../../../components/ha-icon-next";
|
||||
import "../../../../../../components/ha-secure-connection-alert";
|
||||
import "../../../../../../components/item/ha-list-item-button";
|
||||
import "../../../../../../components/list/ha-list-base";
|
||||
|
||||
@@ -19,11 +19,9 @@ export class ZWaveJsAddNodeSelectMethod extends LitElement {
|
||||
return html`
|
||||
${
|
||||
!this.hideQrWebcam && !window.isSecureContext
|
||||
? html`<ha-alert alert-type="warning">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.zwave_js.add_node.select_method.webcam_unsupported"
|
||||
)}</ha-alert
|
||||
>`
|
||||
? html`<ha-secure-connection-alert
|
||||
feature="camera"
|
||||
></ha-secure-connection-alert>`
|
||||
: nothing
|
||||
}
|
||||
<ha-list-base>
|
||||
|
||||
@@ -34,7 +34,6 @@ import type {
|
||||
ZwaveJSNodeMetadata,
|
||||
} from "../../../../../data/zwave_js";
|
||||
import {
|
||||
computeSetConfigParamStatus,
|
||||
fetchZwaveNodeCapabilities,
|
||||
fetchZwaveNodeConfigParameters,
|
||||
fetchZwaveNodeMetadata,
|
||||
@@ -62,7 +61,7 @@ const icons = {
|
||||
};
|
||||
|
||||
interface ConfigParamResult {
|
||||
status: ZWaveJSSetConfigParamStatus;
|
||||
status: ZWaveJSSetConfigParamStatus | "error";
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@@ -691,7 +690,7 @@ class ZWaveJSNodeConfig extends LitElement {
|
||||
);
|
||||
this._config![target.key].value = value;
|
||||
|
||||
this._setResult(target.key, computeSetConfigParamStatus(result.status));
|
||||
this._setResult(target.key, result.status);
|
||||
} catch (err: any) {
|
||||
this._setError(target.key, err.message);
|
||||
}
|
||||
|
||||
@@ -16,16 +16,20 @@ import { LitElement, html, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { storage } from "../../../common/decorators/storage";
|
||||
import type { HASSDomEvent } from "../../../common/dom/fire_event";
|
||||
import { navigate } from "../../../common/navigate";
|
||||
import type {
|
||||
FlattenObjectKeys,
|
||||
LocalizeFunc,
|
||||
} from "../../../common/translations/localize";
|
||||
import { hasRejectedItems } from "../../../common/util/promise-all-settled-results";
|
||||
import type {
|
||||
DataTableColumnContainer,
|
||||
RowClickedEvent,
|
||||
SelectionChangedEvent,
|
||||
SortingChangedEvent,
|
||||
} from "../../../components/data-table/ha-data-table";
|
||||
import "../../../components/chips/ha-assist-chip";
|
||||
import "../../../components/ha-dropdown";
|
||||
import type {
|
||||
HaDropdown,
|
||||
@@ -52,6 +56,7 @@ 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 type { HomeAssistant, Route, TranslationDict } from "../../../types";
|
||||
import {
|
||||
getCreatedAtTableColumn,
|
||||
@@ -146,6 +151,11 @@ export class HaConfigLabels extends LitElement {
|
||||
|
||||
private _openingOverflow = false;
|
||||
|
||||
@state() private _selected: string[] = [];
|
||||
|
||||
@query("hass-tabs-subpage-data-table", true)
|
||||
private _dataTable!: HaTabsSubpageDataTable;
|
||||
|
||||
private _columns = memoizeOne((localize: LocalizeFunc, narrow: boolean) => {
|
||||
const columns: DataTableColumnContainer<LabelRegistryEntry> = {
|
||||
icon: {
|
||||
@@ -273,6 +283,9 @@ export class HaConfigLabels extends LitElement {
|
||||
@row-click=${this._editLabel}
|
||||
clickable
|
||||
id="label_id"
|
||||
selectable
|
||||
.selected=${this._selected.length}
|
||||
@selection-changed=${this._handleSelectionChanged}
|
||||
>
|
||||
<ha-icon-button
|
||||
slot="toolbar-icon"
|
||||
@@ -280,6 +293,12 @@ export class HaConfigLabels extends LitElement {
|
||||
.label=${this.hass.localize("ui.common.help")}
|
||||
.path=${mdiHelpCircleOutline}
|
||||
></ha-icon-button>
|
||||
<ha-assist-chip
|
||||
slot="selection-bar"
|
||||
.label=${this.hass.localize("ui.panel.config.labels.delete_selected")}
|
||||
.disabled=${!this._selected.length}
|
||||
@click=${this._deleteSelected}
|
||||
></ha-assist-chip>
|
||||
<ha-button slot="fab" size="l" @click=${this._addLabel}>
|
||||
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
|
||||
${this.hass.localize("ui.panel.config.labels.add_label")}
|
||||
@@ -300,7 +319,7 @@ export class HaConfigLabels extends LitElement {
|
||||
`
|
||||
)}
|
||||
<wa-divider></wa-divider>
|
||||
<ha-dropdown-item variant="danger" value="remove">
|
||||
<ha-dropdown-item variant="danger" value="delete">
|
||||
<ha-svg-icon slot="icon" .path=${mdiDelete}></ha-svg-icon>
|
||||
${this.hass.localize("ui.common.delete")}
|
||||
</ha-dropdown-item>
|
||||
@@ -342,7 +361,7 @@ export class HaConfigLabels extends LitElement {
|
||||
updateEntry: entry
|
||||
? (values) => this._updateLabel(entry, values)
|
||||
: undefined,
|
||||
removeEntry: entry ? () => this._removeLabel(entry) : undefined,
|
||||
removeEntry: entry ? () => this._deleteLabel(entry) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -369,21 +388,21 @@ export class HaConfigLabels extends LitElement {
|
||||
return updated;
|
||||
}
|
||||
|
||||
private _handleRemoveLabelClick = () => {
|
||||
this._removeLabel(this._overflowLabel);
|
||||
private _handleDeleteLabelClick = () => {
|
||||
this._deleteLabel(this._overflowLabel);
|
||||
};
|
||||
|
||||
private async _removeLabel(selectedLabel: LabelRegistryEntry) {
|
||||
private async _deleteLabel(selectedLabel: LabelRegistryEntry) {
|
||||
if (
|
||||
!(await showConfirmationDialog(this, {
|
||||
title: this.hass!.localize(
|
||||
"ui.panel.config.labels.confirm_remove_title"
|
||||
"ui.panel.config.labels.confirm_delete_title"
|
||||
),
|
||||
text: this.hass.localize("ui.panel.config.labels.confirm_remove", {
|
||||
text: this.hass.localize("ui.panel.config.labels.confirm_delete", {
|
||||
label: selectedLabel.name || selectedLabel.label_id,
|
||||
}),
|
||||
dismissText: this.hass!.localize("ui.common.cancel"),
|
||||
confirmText: this.hass!.localize("ui.common.remove"),
|
||||
confirmText: this.hass!.localize("ui.common.delete"),
|
||||
destructive: true,
|
||||
}))
|
||||
) {
|
||||
@@ -400,6 +419,54 @@ export class HaConfigLabels extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _handleSelectionChanged = (
|
||||
ev: HASSDomEvent<SelectionChangedEvent>
|
||||
): void => {
|
||||
this._selected = ev.detail.value;
|
||||
};
|
||||
|
||||
private _deleteSelected = () => {
|
||||
let deleteFailed = false;
|
||||
showConfirmationDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.labels.confirm_delete_selected_title"
|
||||
),
|
||||
text: this.hass.localize(
|
||||
"ui.panel.config.labels.confirm_delete_selected",
|
||||
{ count: this._selected.length }
|
||||
),
|
||||
confirmText: this.hass.localize("ui.common.delete"),
|
||||
dismissText: this.hass.localize("ui.common.cancel"),
|
||||
destructive: true,
|
||||
action: async () => {
|
||||
const results = await Promise.allSettled(
|
||||
this._selected.map((labelId) =>
|
||||
deleteLabelRegistryEntry(this.hass, labelId)
|
||||
)
|
||||
);
|
||||
const deletedIds = new Set(
|
||||
this._selected.filter(
|
||||
(_id, index) => results[index].status === "fulfilled"
|
||||
)
|
||||
);
|
||||
this._labels = this._labels.filter(
|
||||
(label) => !deletedIds.has(label.label_id)
|
||||
);
|
||||
deleteFailed = hasRejectedItems(results);
|
||||
this._dataTable.clearSelection();
|
||||
},
|
||||
}).then((confirmed) => {
|
||||
if (confirmed && deleteFailed) {
|
||||
showAlertDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.labels.delete_failed_title"
|
||||
),
|
||||
text: this.hass.localize("ui.panel.config.labels.delete_failed"),
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
private _handleOverflowAction = (ev: HaDropdownSelectEvent) => {
|
||||
const action = ev.detail.item.value;
|
||||
|
||||
@@ -422,8 +489,8 @@ export class HaConfigLabels extends LitElement {
|
||||
case "navigate-scripts":
|
||||
this._navigateConfig("/config/script/dashboard");
|
||||
break;
|
||||
case "remove":
|
||||
this._handleRemoveLabelClick();
|
||||
case "delete":
|
||||
this._handleDeleteLabelClick();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -9,17 +9,21 @@ import {
|
||||
} from "@mdi/js";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoize from "memoize-one";
|
||||
import { storage } from "../../../../common/decorators/storage";
|
||||
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
|
||||
import { navigate } from "../../../../common/navigate";
|
||||
import { stringCompare } from "../../../../common/string/compare";
|
||||
import type { LocalizeFunc } from "../../../../common/translations/localize";
|
||||
import { hasRejectedItems } from "../../../../common/util/promise-all-settled-results";
|
||||
import type {
|
||||
DataTableColumnContainer,
|
||||
RowClickedEvent,
|
||||
SelectionChangedEvent,
|
||||
SortingChangedEvent,
|
||||
} from "../../../../components/data-table/ha-data-table";
|
||||
import "../../../../components/chips/ha-assist-chip";
|
||||
import "../../../../components/ha-alert";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-dropdown";
|
||||
@@ -60,6 +64,7 @@ import {
|
||||
import type { WindowWithPreloads } from "../../../../data/preloads";
|
||||
import "../../../../layouts/hass-loading-screen";
|
||||
import "../../../../layouts/hass-tabs-subpage-data-table";
|
||||
import type { HaTabsSubpageDataTable } from "../../../../layouts/hass-tabs-subpage-data-table";
|
||||
import type { HomeAssistant, Route } from "../../../../types";
|
||||
import { loadLovelaceResources } from "../../../lovelace/common/load-resources";
|
||||
import { loadDashboardStrategyWithCreateSuggestions } from "../../../lovelace/strategies/get-strategy";
|
||||
@@ -105,6 +110,11 @@ export class HaConfigLovelaceDashboards extends LitElement {
|
||||
|
||||
@state() private _loadFailed = false;
|
||||
|
||||
@state() private _selected: string[] = [];
|
||||
|
||||
@query("hass-tabs-subpage-data-table", true)
|
||||
private _dataTable!: HaTabsSubpageDataTable;
|
||||
|
||||
@state()
|
||||
@storage({
|
||||
storage: "sessionStorage",
|
||||
@@ -431,6 +441,9 @@ export class HaConfigLovelaceDashboards extends LitElement {
|
||||
.filter=${this._filter}
|
||||
@search-changed=${this._handleSearchChange}
|
||||
@row-click=${this._handleRowClicked}
|
||||
selectable
|
||||
.selected=${this._selected.length}
|
||||
@selection-changed=${this._handleSelectionChanged}
|
||||
.loadError=${
|
||||
this._loadFailed
|
||||
? this.hass.localize(
|
||||
@@ -457,6 +470,14 @@ export class HaConfigLovelaceDashboards extends LitElement {
|
||||
</ha-dropdown-item>
|
||||
</a>
|
||||
</ha-dropdown>
|
||||
<ha-assist-chip
|
||||
slot="selection-bar"
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.lovelace.dashboards.delete_selected"
|
||||
)}
|
||||
.disabled=${!this._deletableSelected().length}
|
||||
@click=${this._removeSelected}
|
||||
></ha-assist-chip>
|
||||
<ha-button slot="fab" size="l" @click=${this._addDashboard}>
|
||||
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
@@ -718,6 +739,71 @@ export class HaConfigLovelaceDashboards extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _deletableSelected = () =>
|
||||
this._dashboards.filter(
|
||||
(dashboard) =>
|
||||
this._selected.includes(dashboard.url_path) &&
|
||||
dashboard.mode === "storage"
|
||||
);
|
||||
|
||||
private _handleSelectionChanged = (
|
||||
ev: HASSDomEvent<SelectionChangedEvent>
|
||||
): void => {
|
||||
this._selected = ev.detail.value;
|
||||
};
|
||||
|
||||
private _removeSelected = () => {
|
||||
const deletable = this._deletableSelected();
|
||||
const allDeletable = deletable.length === this._selected.length;
|
||||
let deleteFailed = false;
|
||||
showConfirmationDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.lovelace.dashboards.confirm_delete_selected_title"
|
||||
),
|
||||
text: this.hass.localize(
|
||||
allDeletable
|
||||
? "ui.panel.config.lovelace.dashboards.confirm_delete_selected"
|
||||
: "ui.panel.config.lovelace.dashboards.confirm_delete_selected_partly",
|
||||
allDeletable
|
||||
? { count: deletable.length }
|
||||
: { deletable: deletable.length, selected: this._selected.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((dashboard) =>
|
||||
deleteDashboard(this.hass!, dashboard.id)
|
||||
)
|
||||
);
|
||||
const deletedUrlPaths = new Set(
|
||||
deletable
|
||||
.filter(
|
||||
(_dashboard, index) => results[index].status === "fulfilled"
|
||||
)
|
||||
.map((dashboard) => dashboard.url_path)
|
||||
);
|
||||
this._dashboards = this._dashboards.filter(
|
||||
(dashboard) => !deletedUrlPaths.has(dashboard.url_path)
|
||||
);
|
||||
deleteFailed = hasRejectedItems(results);
|
||||
this._dataTable.clearSelection();
|
||||
},
|
||||
}).then((confirmed) => {
|
||||
if (confirmed && deleteFailed) {
|
||||
showAlertDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.lovelace.dashboards.delete_failed_title"
|
||||
),
|
||||
text: this.hass.localize(
|
||||
"ui.panel.config.lovelace.dashboards.delete_failed"
|
||||
),
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
private _handleSortingChanged(ev: CustomEvent) {
|
||||
this._activeSorting = ev.detail;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
export interface DebugConsoleRun {
|
||||
id: number;
|
||||
code: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The code a console is running. Runs go on side by side, like in the console
|
||||
* of a web browser, so code that waits does not hold up the next.
|
||||
*/
|
||||
export class DebugConsoleRuns {
|
||||
public pending: DebugConsoleRun[] = [];
|
||||
|
||||
private _nextId = 0;
|
||||
|
||||
// Bumped by clearing the log, so runs started before it do not refill it.
|
||||
private _generation = 0;
|
||||
|
||||
constructor(private readonly _changed: () => void) {}
|
||||
|
||||
/**
|
||||
* Run code, and return its result, or nothing when the log got cleared in
|
||||
* the meantime.
|
||||
*/
|
||||
public async run<T>(
|
||||
code: string,
|
||||
execute: (code: string) => Promise<T>
|
||||
): Promise<T | undefined> {
|
||||
const run = { id: this._nextId++, code };
|
||||
const generation = this._generation;
|
||||
this.pending = [...this.pending, run];
|
||||
this._changed();
|
||||
|
||||
try {
|
||||
const result = await execute(code);
|
||||
return generation === this._generation ? result : undefined;
|
||||
} finally {
|
||||
this.pending = this.pending.filter((pending) => pending !== run);
|
||||
this._changed();
|
||||
}
|
||||
}
|
||||
|
||||
/** Forget the results of runs still going, as the log they belong to is gone. */
|
||||
public clear() {
|
||||
this._generation++;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// What the debug console remembers in the browser, per user, so accounts taking
|
||||
// turns in the same browser share nothing. All of it is a convenience: without
|
||||
// storage, the console works and forgets it on reload.
|
||||
|
||||
const STORAGE_KEY_HISTORY = "tools-debug-console-history";
|
||||
const STORAGE_KEY_ALLOW_PASTING = "tools-debug-console-allow-pasting";
|
||||
|
||||
export const MAX_HISTORY = 100;
|
||||
|
||||
/** Return the code the user ran before, oldest first. */
|
||||
export const loadHistory = (userId: string): string[] => {
|
||||
try {
|
||||
const stored = JSON.parse(
|
||||
window.localStorage.getItem(`${STORAGE_KEY_HISTORY}-${userId}`) ?? "[]"
|
||||
);
|
||||
return Array.isArray(stored) ? stored : [];
|
||||
} catch (_err: unknown) {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/** Add code the user ran to the history, and return the new history. */
|
||||
export const addToHistory = (
|
||||
userId: string,
|
||||
history: string[],
|
||||
code: string
|
||||
): string[] => {
|
||||
// Running the same code again in a row adds nothing new to browse back to.
|
||||
const updated =
|
||||
history[history.length - 1] === code
|
||||
? history
|
||||
: [...history, code].slice(-MAX_HISTORY);
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
`${STORAGE_KEY_HISTORY}-${userId}`,
|
||||
JSON.stringify(updated)
|
||||
);
|
||||
} catch (_err: unknown) {
|
||||
// Kept for this page load only.
|
||||
}
|
||||
|
||||
return updated;
|
||||
};
|
||||
|
||||
/** Return if the user unlocked pasting into the console. */
|
||||
export const loadPasteAllowed = (userId: string): boolean => {
|
||||
try {
|
||||
return (
|
||||
window.localStorage.getItem(`${STORAGE_KEY_ALLOW_PASTING}-${userId}`) ===
|
||||
"true"
|
||||
);
|
||||
} catch (_err: unknown) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/** Remember the user unlocked pasting into the console. */
|
||||
export const savePasteAllowed = (userId: string): void => {
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
`${STORAGE_KEY_ALLOW_PASTING}-${userId}`,
|
||||
"true"
|
||||
);
|
||||
} catch (_err: unknown) {
|
||||
// Allowed for this page load only.
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,232 @@
|
||||
import type { ContextType } from "@lit/context";
|
||||
import { mdiClose } from "@mdi/js";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { consume } from "../../../../common/decorators/consume";
|
||||
import type { HASSDomTargetEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/ha-alert";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-checkbox";
|
||||
import type { HaCheckbox } from "../../../../components/ha-checkbox";
|
||||
import "../../../../components/ha-dialog";
|
||||
import "../../../../components/ha-dialog-footer";
|
||||
import "../../../../components/ha-icon-button";
|
||||
import {
|
||||
apiContext,
|
||||
internationalizationContext,
|
||||
} from "../../../../data/context";
|
||||
import { acceptDebugConsoleWarning } from "../../../../data/debug_console";
|
||||
import { DialogMixin } from "../../../../dialogs/dialog-mixin";
|
||||
import type {
|
||||
DebugConsoleWarningDialogParams,
|
||||
DebugConsoleWarningResult,
|
||||
} from "./show-dialog-debug-console-warning";
|
||||
|
||||
const RISKS = ["scams", "access", "safety", "logged"] as const;
|
||||
|
||||
// Long enough to read the risks before they can be accepted.
|
||||
const READ_SECONDS = 10;
|
||||
|
||||
/**
|
||||
* Asks a user to accept the risks before using the debug console, on versions
|
||||
* of Home Assistant that are not for development.
|
||||
*/
|
||||
@customElement("dialog-debug-console-warning")
|
||||
export class DialogDebugConsoleWarning extends DialogMixin<DebugConsoleWarningDialogParams>(
|
||||
LitElement
|
||||
) {
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@consume({ context: apiContext, subscribe: true })
|
||||
private _api!: ContextType<typeof apiContext>;
|
||||
|
||||
@state() private _understood = false;
|
||||
|
||||
@state() private _accepting = false;
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
@state() private _secondsLeft = READ_SECONDS;
|
||||
|
||||
private _result: DebugConsoleWarningResult = "closed";
|
||||
|
||||
private _countdown?: number;
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
const shownAt = Date.now();
|
||||
this._secondsLeft = READ_SECONDS;
|
||||
this._countdown = window.setInterval(() => {
|
||||
const elapsed = Math.floor((Date.now() - shownAt) / 1000);
|
||||
this._secondsLeft = Math.max(READ_SECONDS - elapsed, 0);
|
||||
if (this._secondsLeft === 0) {
|
||||
this._stopCountdown();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
this._stopCountdown();
|
||||
this.params?.closedCallback(this._result);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this.params || !this._i18n) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-dialog
|
||||
open
|
||||
prevent-scrim-close
|
||||
width="medium"
|
||||
@keydown=${this._escapeListener}
|
||||
.headerTitle=${this._i18n.localize(
|
||||
"ui.panel.config.tools.tabs.debug_console.warning.title"
|
||||
)}
|
||||
>
|
||||
<ha-icon-button
|
||||
slot="headerNavigationIcon"
|
||||
.label=${this._i18n.localize("ui.common.close")}
|
||||
.path=${mdiClose}
|
||||
@click=${this._decline}
|
||||
></ha-icon-button>
|
||||
<p>
|
||||
${this._i18n.localize(
|
||||
"ui.panel.config.tools.tabs.debug_console.warning.intro"
|
||||
)}
|
||||
</p>
|
||||
${
|
||||
this._error
|
||||
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
|
||||
: nothing
|
||||
}
|
||||
<ha-alert alert-type="warning">
|
||||
<ul>
|
||||
${RISKS.map(
|
||||
(risk) =>
|
||||
html`<li>
|
||||
${this._i18n.localize(
|
||||
`ui.panel.config.tools.tabs.debug_console.warning.risks.${risk}`
|
||||
)}
|
||||
</li>`
|
||||
)}
|
||||
</ul>
|
||||
</ha-alert>
|
||||
<ha-checkbox
|
||||
autofocus
|
||||
.checked=${this._understood}
|
||||
.disabled=${this._accepting}
|
||||
@change=${this._understoodChanged}
|
||||
>
|
||||
${this._i18n.localize(
|
||||
"ui.panel.config.tools.tabs.debug_console.warning.understand"
|
||||
)}
|
||||
</ha-checkbox>
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button
|
||||
slot="secondaryAction"
|
||||
appearance="plain"
|
||||
@click=${this._decline}
|
||||
>
|
||||
${this._i18n.localize(
|
||||
"ui.panel.config.tools.tabs.debug_console.warning.go_back"
|
||||
)}
|
||||
</ha-button>
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
.disabled=${!this._understood || this._secondsLeft > 0}
|
||||
.loading=${this._accepting}
|
||||
@click=${this._accept}
|
||||
>
|
||||
${
|
||||
this._secondsLeft > 0
|
||||
? this._i18n.localize(
|
||||
"ui.panel.config.tools.tabs.debug_console.warning.continue_in",
|
||||
{ seconds: this._secondsLeft }
|
||||
)
|
||||
: this._i18n.localize(
|
||||
"ui.panel.config.tools.tabs.debug_console.warning.continue"
|
||||
)
|
||||
}
|
||||
</ha-button>
|
||||
</ha-dialog-footer>
|
||||
</ha-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
private _stopCountdown(): void {
|
||||
window.clearInterval(this._countdown);
|
||||
this._countdown = undefined;
|
||||
}
|
||||
|
||||
private _understoodChanged(ev: HASSDomTargetEvent<HaCheckbox>): void {
|
||||
this._understood = ev.target.checked;
|
||||
}
|
||||
|
||||
private async _accept(): Promise<void> {
|
||||
if (!this._understood || this._secondsLeft > 0 || this._accepting) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._accepting = true;
|
||||
this._error = undefined;
|
||||
try {
|
||||
await acceptDebugConsoleWarning(this._api);
|
||||
} catch (err: unknown) {
|
||||
this._error = (err as { message?: string }).message ?? String(err);
|
||||
return;
|
||||
} finally {
|
||||
this._accepting = false;
|
||||
}
|
||||
|
||||
this._result = "accepted";
|
||||
this.closeDialog();
|
||||
}
|
||||
|
||||
private _decline() {
|
||||
this._result = "declined";
|
||||
this.closeDialog();
|
||||
}
|
||||
|
||||
// Escape closes the dialog too, which declines it like the buttons do. In
|
||||
// the capture phase, as the dialog itself stops it from going further.
|
||||
private _escapeListener = {
|
||||
handleEvent: (ev: KeyboardEvent) => {
|
||||
if (ev.key === "Escape") {
|
||||
this._result = "declined";
|
||||
}
|
||||
},
|
||||
capture: true,
|
||||
};
|
||||
|
||||
static styles = css`
|
||||
p {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
ha-alert {
|
||||
display: block;
|
||||
margin-bottom: var(--ha-space-4);
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding-inline-start: var(--ha-space-5);
|
||||
}
|
||||
|
||||
li + li {
|
||||
margin-top: var(--ha-space-2);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"dialog-debug-console-warning": DialogDebugConsoleWarning;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
|
||||
/**
|
||||
* How the warning closed: accepted, declined with Go back, its close button,
|
||||
* or Escape, or closed by something else, like navigating away.
|
||||
*/
|
||||
export type DebugConsoleWarningResult = "accepted" | "declined" | "closed";
|
||||
|
||||
export interface DebugConsoleWarningDialogParams {
|
||||
/** Called once the dialog is gone, with how it closed. */
|
||||
closedCallback: (result: DebugConsoleWarningResult) => void;
|
||||
}
|
||||
|
||||
export const showDebugConsoleWarningDialog = (
|
||||
element: HTMLElement,
|
||||
dialogParams: DebugConsoleWarningDialogParams
|
||||
): void => {
|
||||
fireEvent(element, "show-dialog", {
|
||||
dialogTag: "dialog-debug-console-warning",
|
||||
dialogImport: () => import("./dialog-debug-console-warning"),
|
||||
dialogParams,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,336 @@
|
||||
import type { ContextType } from "@lit/context";
|
||||
import { mdiChevronRight } from "@mdi/js";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import { consume } from "../../../../common/decorators/consume";
|
||||
import { internationalizationContext } from "../../../../data/context";
|
||||
import type {
|
||||
DebugConsoleChild,
|
||||
DebugConsoleGetAttributeResult,
|
||||
DebugConsoleInspectResult,
|
||||
DebugConsoleObjects,
|
||||
DebugConsoleValue,
|
||||
} from "../../../../data/debug_console";
|
||||
|
||||
/**
|
||||
* A value in the console. Objects expand into a tree, loading their children
|
||||
* from the backend on demand, like the object inspector in browser consoles.
|
||||
*/
|
||||
@customElement("tools-debug-console-value")
|
||||
export class ToolsDebugConsoleValue extends LitElement {
|
||||
// Not the hass object: trees can be large, and should not update on every
|
||||
// state change in Home Assistant.
|
||||
@property({ attribute: false }) public objects!: DebugConsoleObjects;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@property({ attribute: false }) public value?: DebugConsoleValue;
|
||||
|
||||
@property() public key?: string;
|
||||
|
||||
/** Set for a property that is only evaluated once selected. */
|
||||
@property({ type: Boolean }) public getter = false;
|
||||
|
||||
/** The object the getter belongs to. */
|
||||
@property({ attribute: false }) public parentId?: string;
|
||||
|
||||
@state() private _expanded = false;
|
||||
|
||||
@state() private _children?: DebugConsoleInspectResult;
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
@state() private _evaluated?: DebugConsoleValue;
|
||||
|
||||
// Properties can change things, so one evaluation at a time.
|
||||
@state() private _evaluating = false;
|
||||
|
||||
// Bumped when the value changes, so answers about an earlier one are dropped.
|
||||
private _request = 0;
|
||||
|
||||
// Inspecting runs code of the object, so reopening waits for the answer
|
||||
// instead of asking again.
|
||||
private _inspecting = false;
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues<this>) {
|
||||
super.willUpdate(changedProps);
|
||||
if (changedProps.has("value") || changedProps.has("parentId")) {
|
||||
this._request++;
|
||||
this._expanded = false;
|
||||
this._inspecting = false;
|
||||
this._children = undefined;
|
||||
this._error = undefined;
|
||||
this._evaluated = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const key = this.key
|
||||
? html`<span class="key">${this.key}</span><span class="colon">: </span>`
|
||||
: nothing;
|
||||
|
||||
if (this._error) {
|
||||
return html`${key}<span class="error">${this._error}</span>`;
|
||||
}
|
||||
|
||||
const value = this._value;
|
||||
|
||||
if (this.getter && !value) {
|
||||
const label = this._i18n.localize(
|
||||
"ui.panel.config.tools.tabs.debug_console.evaluate_property",
|
||||
{ property: this.key ?? "" }
|
||||
);
|
||||
return html`${key}<button
|
||||
class="getter"
|
||||
title=${label}
|
||||
aria-label=${label}
|
||||
.disabled=${this._evaluating}
|
||||
@click=${this._evaluate}
|
||||
>
|
||||
(...)
|
||||
</button>`;
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
if (value.type !== "object") {
|
||||
return html`${key}${this._renderPrimitive(value)}`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<button
|
||||
class="toggle"
|
||||
aria-expanded=${this._expanded ? "true" : "false"}
|
||||
@click=${this._toggle}
|
||||
>
|
||||
<ha-svg-icon
|
||||
class=${this._expanded ? "chevron expanded" : "chevron"}
|
||||
.path=${mdiChevronRight}
|
||||
></ha-svg-icon>
|
||||
${key}<span class="class">${value.class}</span>${
|
||||
value.length !== undefined
|
||||
? html`<span class="length">(${value.length})</span>`
|
||||
: nothing
|
||||
}
|
||||
<span class="preview">${value.preview}</span>
|
||||
</button>
|
||||
${
|
||||
this._expanded && this._children
|
||||
? html`<div class="children">
|
||||
${this._children.children.map((child) => this._renderChild(child))}
|
||||
${
|
||||
this._children.truncated
|
||||
? html`<div class="truncated">
|
||||
${this._i18n.localize(
|
||||
"ui.panel.config.tools.tabs.debug_console.truncated",
|
||||
{ count: this._children.children.length }
|
||||
)}
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
private get _value() {
|
||||
return this._evaluated ?? this.value;
|
||||
}
|
||||
|
||||
private _renderPrimitive(
|
||||
value: Exclude<DebugConsoleValue, { type: "object" }>
|
||||
) {
|
||||
switch (value.type) {
|
||||
case "none":
|
||||
return html`<span class="keyword">None</span>`;
|
||||
case "boolean":
|
||||
return html`<span class="keyword"
|
||||
>${value.value ? "True" : "False"}</span
|
||||
>`;
|
||||
case "number":
|
||||
return html`<span class="number">${value.value}</span>`;
|
||||
case "string":
|
||||
return html`<span class="string"
|
||||
>'${value.value}${value.truncated ? "…" : ""}'</span
|
||||
>`;
|
||||
case "text":
|
||||
return html`<span class="text"
|
||||
>${value.value}${value.truncated ? "…" : ""}</span
|
||||
>`;
|
||||
}
|
||||
|
||||
return nothing;
|
||||
}
|
||||
|
||||
private _renderChild(child: DebugConsoleChild) {
|
||||
return html`<div class="child">
|
||||
<tools-debug-console-value
|
||||
.objects=${this.objects}
|
||||
.key=${child.key}
|
||||
.value=${"value" in child ? child.value : undefined}
|
||||
.getter=${"getter" in child}
|
||||
.parentId=${
|
||||
this._value?.type === "object" ? this._value.object_id : undefined
|
||||
}
|
||||
></tools-debug-console-value>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private async _toggle() {
|
||||
const value = this._value;
|
||||
if (value?.type !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
this._expanded = !this._expanded;
|
||||
if (!this._expanded || this._children || this._inspecting) {
|
||||
return;
|
||||
}
|
||||
|
||||
const request = this._request;
|
||||
this._inspecting = true;
|
||||
try {
|
||||
const children = await this.objects.inspect(value.object_id);
|
||||
if (request === this._request) {
|
||||
this._children = children;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (request === this._request) {
|
||||
this._error = (err as { message?: string }).message ?? String(err);
|
||||
}
|
||||
} finally {
|
||||
if (request === this._request) {
|
||||
this._inspecting = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async _evaluate() {
|
||||
if (this.parentId === undefined || !this.key || this._evaluating) {
|
||||
return;
|
||||
}
|
||||
|
||||
const request = this._request;
|
||||
this._evaluating = true;
|
||||
let result: DebugConsoleGetAttributeResult;
|
||||
try {
|
||||
result = await this.objects.getAttribute(this.parentId, this.key);
|
||||
} catch (err: unknown) {
|
||||
result = { error: (err as { message?: string }).message ?? String(err) };
|
||||
} finally {
|
||||
this._evaluating = false;
|
||||
}
|
||||
|
||||
if (request !== this._request) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ("error" in result) {
|
||||
this._error = result.error;
|
||||
} else {
|
||||
this._evaluated = result.value;
|
||||
}
|
||||
}
|
||||
|
||||
static readonly styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
font-family: var(--ha-font-family-code);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Only on text, as the template whitespace between elements must collapse. */
|
||||
.string,
|
||||
.text,
|
||||
.error {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
button {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:focus-visible {
|
||||
outline: 1px solid var(--primary-color);
|
||||
}
|
||||
|
||||
.toggle {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
--mdc-icon-size: 14px;
|
||||
vertical-align: middle;
|
||||
margin-inline-start: -16px;
|
||||
transition: transform 0.1s;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.chevron.expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
:host-context([dir="rtl"]) .chevron:not(.expanded) {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.children {
|
||||
padding-inline-start: var(--ha-space-4);
|
||||
}
|
||||
|
||||
.child {
|
||||
padding-inline-start: 16px;
|
||||
}
|
||||
|
||||
.key {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.colon,
|
||||
.length,
|
||||
.preview,
|
||||
.truncated,
|
||||
.getter {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.class {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.preview {
|
||||
margin-inline-start: 0.5em;
|
||||
}
|
||||
|
||||
/* The colors of the code editor, so values look like code there. */
|
||||
.keyword {
|
||||
color: var(--codemirror-keyword, #6262ff);
|
||||
}
|
||||
|
||||
.number {
|
||||
color: var(--codemirror-number, #ca7841);
|
||||
}
|
||||
|
||||
.string {
|
||||
color: var(--codemirror-string, #07a);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--error-color);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"tools-debug-console-value": ToolsDebugConsoleValue;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -11,6 +11,7 @@ import "../../../components/ha-icon-button-arrow-prev";
|
||||
import "../../../components/ha-tab-group";
|
||||
import "../../../components/ha-tab-group-tab";
|
||||
import "../../../components/ha-top-app-bar-fixed";
|
||||
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||
import type { HomeAssistant, Route } from "../../../types";
|
||||
import "./tools-router";
|
||||
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
|
||||
@@ -36,6 +37,10 @@ const TOOLS_TABS = [
|
||||
panel: "event",
|
||||
translationKey: "ui.panel.config.tools.tabs.events.title",
|
||||
},
|
||||
{
|
||||
panel: "debug-console",
|
||||
translationKey: "ui.panel.config.tools.tabs.debug_console.title",
|
||||
},
|
||||
{
|
||||
panel: "statistics",
|
||||
translationKey: "ui.panel.config.tools.tabs.statistics.title",
|
||||
@@ -81,7 +86,12 @@ class PanelTools extends LitElement {
|
||||
</ha-dropdown-item>
|
||||
</ha-dropdown>
|
||||
<ha-tab-group @wa-tab-show=${this._handlePageSelected} slot="subRow">
|
||||
${TOOLS_TABS.map(
|
||||
${TOOLS_TABS.filter(
|
||||
(tab) =>
|
||||
tab.panel !== "debug-console" ||
|
||||
// Only loaded when enabled in YAML.
|
||||
isComponentLoaded(this.hass.config, "debug_console")
|
||||
).map(
|
||||
(tab) => html`
|
||||
<ha-tab-group-tab
|
||||
slot="nav"
|
||||
|
||||
@@ -130,7 +130,7 @@ export class DialogStatisticsFix extends LitElement {
|
||||
"ui.panel.config.tools.tabs.statistics.fix_issue.state_class_removed.info_text_4"
|
||||
)}
|
||||
<a
|
||||
href="https://developers.home-assistant.io/docs/core/entity/sensor/#long-term-statistics"
|
||||
href="https://contributors.home-assistant.io/core/entities/sensor/#long-term-statistics"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
|
||||
@@ -38,6 +38,10 @@ class ToolsRouter extends HassRouterPage {
|
||||
tag: "tools-template",
|
||||
load: () => import("./template/tools-template"),
|
||||
},
|
||||
"debug-console": {
|
||||
tag: "tools-debug-console",
|
||||
load: () => import("./debug_console/tools-debug-console"),
|
||||
},
|
||||
statistics: {
|
||||
tag: "tools-statistics",
|
||||
load: () => import("./statistics/tools-statistics"),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import { extractSearchParam } from "../../../../common/url/search-params";
|
||||
@@ -7,6 +7,7 @@ import "../../../../components/ha-assist-pipeline-picker";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-checkbox";
|
||||
import type { HaCheckbox } from "../../../../components/ha-checkbox";
|
||||
import "../../../../components/ha-secure-connection-alert";
|
||||
import "../../../../components/input/ha-input";
|
||||
import type { HaInput } from "../../../../components/input/ha-input";
|
||||
import type {
|
||||
@@ -97,6 +98,13 @@ export class AssistPipelineRunDebug extends LitElement {
|
||||
.value=${this._pipelineId}
|
||||
@value-changed=${this._pipelinePicked}
|
||||
></ha-assist-pipeline-picker>
|
||||
${
|
||||
!window.isSecureContext
|
||||
? html`<ha-secure-connection-alert
|
||||
feature="microphone"
|
||||
></ha-secure-connection-alert>`
|
||||
: nothing
|
||||
}
|
||||
<div class="start-buttons">
|
||||
<ha-button
|
||||
appearance="filled"
|
||||
@@ -253,6 +261,24 @@ export class AssistPipelineRunDebug extends LitElement {
|
||||
);
|
||||
}
|
||||
|
||||
private async _startRecorder(audioRecorder: AudioRecorder) {
|
||||
const microphoneError = await audioRecorder.start();
|
||||
|
||||
if (!microphoneError) {
|
||||
return true;
|
||||
}
|
||||
|
||||
this._audioBuffer = undefined;
|
||||
this._finished = true;
|
||||
showAlertDialog(this, {
|
||||
text: this.hass.localize(
|
||||
`ui.dialogs.voice_command.microphone_${microphoneError}`
|
||||
),
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async _runAudioWakeWordPipeline() {
|
||||
const audioRecorder = new AudioRecorder((data) => {
|
||||
if (this._audioBuffer) {
|
||||
@@ -263,7 +289,10 @@ export class AssistPipelineRunDebug extends LitElement {
|
||||
});
|
||||
|
||||
this._audioBuffer = [];
|
||||
await audioRecorder.start();
|
||||
|
||||
if (!(await this._startRecorder(audioRecorder))) {
|
||||
return;
|
||||
}
|
||||
|
||||
let run: PipelineRun | undefined;
|
||||
|
||||
@@ -306,7 +335,7 @@ export class AssistPipelineRunDebug extends LitElement {
|
||||
|
||||
// Play audio when we're done.
|
||||
if (updatedRun.stage === "done" && !updatedRun.error) {
|
||||
const url = updatedRun.tts!.tts_output!.url;
|
||||
const url = this.hass.hassUrl(updatedRun.tts!.tts_output!.url);
|
||||
const audio = new Audio(url);
|
||||
audio.addEventListener("ended", () => {
|
||||
if (
|
||||
@@ -346,7 +375,10 @@ export class AssistPipelineRunDebug extends LitElement {
|
||||
});
|
||||
|
||||
this._audioBuffer = [];
|
||||
await audioRecorder.start();
|
||||
|
||||
if (!(await this._startRecorder(audioRecorder))) {
|
||||
return;
|
||||
}
|
||||
|
||||
let run: PipelineRun | undefined;
|
||||
|
||||
@@ -386,7 +418,7 @@ export class AssistPipelineRunDebug extends LitElement {
|
||||
|
||||
// Play audio when we're done.
|
||||
if (updatedRun.stage === "done") {
|
||||
const url = updatedRun.tts!.tts_output!.url;
|
||||
const url = this.hass.hassUrl(updatedRun.tts!.tts_output!.url);
|
||||
const audio = new Audio(url);
|
||||
audio.addEventListener("ended", () => {
|
||||
if (
|
||||
@@ -535,6 +567,10 @@ export class AssistPipelineRunDebug extends LitElement {
|
||||
width: 100%;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
ha-secure-connection-alert {
|
||||
width: 100%;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.start-row ha-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -590,7 +590,7 @@ export class AssistPipelineDebug extends LitElement {
|
||||
// Stop any existing audio first
|
||||
this._stopTTS();
|
||||
|
||||
const url = this.pipelineRun!.tts!.tts_output!.url;
|
||||
const url = this.hass.hassUrl(this.pipelineRun!.tts!.tts_output!.url);
|
||||
this._audioElement = new Audio(url);
|
||||
|
||||
this._audioElement.addEventListener("error", () => {
|
||||
|
||||
@@ -17,6 +17,9 @@ import "../../config/energy/components/ha-energy-water-settings";
|
||||
import "../../../components/ha-button";
|
||||
import type { Lovelace, LovelaceCard } from "../../lovelace/types";
|
||||
|
||||
// Grid, solar, battery, gas, water, and individual devices
|
||||
const STEPS = 6;
|
||||
|
||||
@customElement("energy-setup-wizard-card")
|
||||
export class EnergySetupWizard extends LitElement implements LovelaceCard {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -53,7 +56,7 @@ export class EnergySetupWizard extends LitElement implements LovelaceCard {
|
||||
<p>
|
||||
${this.hass.localize("ui.panel.energy.setup.step", {
|
||||
step: this._step + 1,
|
||||
steps: 6,
|
||||
steps: STEPS,
|
||||
})}
|
||||
</p>
|
||||
${
|
||||
@@ -103,7 +106,7 @@ export class EnergySetupWizard extends LitElement implements LovelaceCard {
|
||||
: html`<div></div>`
|
||||
}
|
||||
${
|
||||
this._step < 4
|
||||
this._step < STEPS - 1
|
||||
? html`<ha-button @click=${this._next}
|
||||
>${this.hass.localize("ui.panel.energy.setup.next")}</ha-button
|
||||
>`
|
||||
@@ -131,7 +134,7 @@ export class EnergySetupWizard extends LitElement implements LovelaceCard {
|
||||
}
|
||||
|
||||
private _next() {
|
||||
if (this._step === 5) {
|
||||
if (this._step === STEPS - 1) {
|
||||
return;
|
||||
}
|
||||
this._step++;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ALERT_DOMAINS } from "../../../common/entity/alert_active";
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
import type { AlertCardConfig } from "../cards/types";
|
||||
import type { CardSuggestionProvider } from "./types";
|
||||
|
||||
export const alertCardSuggestions: CardSuggestionProvider<AlertCardConfig> = {
|
||||
getEntitySuggestion(_hass, entityId) {
|
||||
if (!ALERT_DOMAINS.includes(computeDomain(entityId))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { config: { type: "alert", entity: entityId } };
|
||||
},
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { alarmPanelCardSuggestions } from "./hui-alarm-panel-card-suggestions";
|
||||
import { alertCardSuggestions } from "./hui-alert-card-suggestions";
|
||||
import { calendarCardSuggestions } from "./hui-calendar-card-suggestions";
|
||||
import { gaugeCardSuggestions } from "./hui-gauge-card-suggestions";
|
||||
import { historyGraphCardSuggestions } from "./hui-history-graph-card-suggestions";
|
||||
@@ -19,6 +20,7 @@ export const CARD_SUGGESTION_PROVIDERS: Record<string, CardSuggestionProvider> =
|
||||
{
|
||||
tile: tileCardSuggestions,
|
||||
"alarm-panel": alarmPanelCardSuggestions,
|
||||
alert: alertCardSuggestions,
|
||||
calendar: calendarCardSuggestions,
|
||||
gauge: gaugeCardSuggestions,
|
||||
humidifier: humidifierCardSuggestions,
|
||||
|
||||
@@ -8,6 +8,12 @@ import { consume } from "../../../common/decorators/consume";
|
||||
import { computeCssColor } from "../../../common/color/compute-color";
|
||||
import { consumeEntityState } from "../../../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import {
|
||||
ALERT_DOMAINS,
|
||||
DEFAULT_ALERT_COLOR,
|
||||
isAlertActive,
|
||||
} from "../../../common/entity/alert_active";
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
import { isValidEntityId } from "../../../common/entity/valid_entity_id";
|
||||
import "../../../components/ha-card";
|
||||
import "../../../components/ha-relative-time";
|
||||
@@ -17,12 +23,40 @@ import "../../../components/tile/ha-tile-icon";
|
||||
import "../../../components/tile/ha-tile-info";
|
||||
import { formattersContext } from "../../../data/context";
|
||||
import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
|
||||
import type { LovelaceCard, LovelaceGridOptions } from "../types";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { findEntities } from "../common/find-entities";
|
||||
import type {
|
||||
LovelaceCard,
|
||||
LovelaceCardEditor,
|
||||
LovelaceGridOptions,
|
||||
} from "../types";
|
||||
import { tileCardStyle } from "./tile/tile-card-style";
|
||||
import type { AlertCardConfig } from "./types";
|
||||
|
||||
@customElement("hui-alert-card")
|
||||
export class HuiAlertCard extends LitElement implements LovelaceCard {
|
||||
public static async getConfigElement(): Promise<LovelaceCardEditor> {
|
||||
await import("../editor/config-elements/hui-alert-card-editor");
|
||||
|
||||
return document.createElement("hui-alert-card-editor");
|
||||
}
|
||||
|
||||
public static getStubConfig(
|
||||
hass: HomeAssistant,
|
||||
entities: string[],
|
||||
entitiesFallback: string[]
|
||||
): AlertCardConfig {
|
||||
const [entity] = findEntities(
|
||||
hass,
|
||||
1,
|
||||
entities,
|
||||
entitiesFallback,
|
||||
ALERT_DOMAINS
|
||||
);
|
||||
|
||||
return { type: "alert", entity: entity || "" };
|
||||
}
|
||||
|
||||
@state() private _config?: AlertCardConfig;
|
||||
|
||||
@state()
|
||||
@@ -36,11 +70,20 @@ export class HuiAlertCard extends LitElement implements LovelaceCard {
|
||||
public setConfig(config: AlertCardConfig): void {
|
||||
if (
|
||||
!isValidEntityId(config.entity) ||
|
||||
(config.color !== undefined && typeof config.color !== "string") ||
|
||||
(config.alert_color !== undefined &&
|
||||
typeof config.alert_color !== "string") ||
|
||||
(config.clear_color !== undefined &&
|
||||
typeof config.clear_color !== "string") ||
|
||||
(config.pulse !== undefined && typeof config.pulse !== "boolean")
|
||||
) {
|
||||
throw new Error("Invalid configuration");
|
||||
}
|
||||
|
||||
if (!ALERT_DOMAINS.includes(computeDomain(config.entity))) {
|
||||
throw new Error(
|
||||
`Specify an entity from within the ${ALERT_DOMAINS.join(", ")} domains`
|
||||
);
|
||||
}
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
@@ -71,18 +114,22 @@ export class HuiAlertCard extends LitElement implements LovelaceCard {
|
||||
const areaName = this._formatters.formatEntityName(stateObj, {
|
||||
type: "area",
|
||||
});
|
||||
const pulse = this._config.pulse === true;
|
||||
const hasColor =
|
||||
this._config.color !== undefined && this._config.color !== "none";
|
||||
|
||||
const active = isAlertActive(stateObj);
|
||||
|
||||
const color = active
|
||||
? (this._config.alert_color ?? DEFAULT_ALERT_COLOR)
|
||||
: this._config.clear_color;
|
||||
|
||||
const pulse = active && this._config.pulse === true;
|
||||
const hasColor = color !== undefined && color !== "none";
|
||||
|
||||
return html`
|
||||
<ha-card
|
||||
class=${classMap({ pulse, "no-color": !hasColor })}
|
||||
style=${styleMap({
|
||||
"--ha-alert-color":
|
||||
this._config.color && hasColor
|
||||
? computeCssColor(this._config.color)
|
||||
: undefined,
|
||||
color && hasColor ? computeCssColor(color) : undefined,
|
||||
"--ha-alert-static-opacity": pulse
|
||||
? undefined
|
||||
: "var(--ha-alert-pulse-opacity)",
|
||||
@@ -169,7 +216,8 @@ export class HuiAlertCard extends LitElement implements LovelaceCard {
|
||||
position: relative;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
ha-card::before {
|
||||
ha-card::before,
|
||||
ha-card.pulse::before {
|
||||
animation: none;
|
||||
opacity: var(--ha-alert-pulse-opacity);
|
||||
}
|
||||
|
||||
Loaded 100 of 129 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user