mirror of
https://github.com/home-assistant/frontend.git
synced 2026-08-24 15:50:05 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f4e5279a5 | ||
|
|
848496b09e | ||
|
|
2c2eef7942 | ||
|
|
4e3cc4c705 | ||
|
|
8f96d2c6e4 | ||
|
|
2b9347fd72 | ||
|
|
12f54835f0 |
@@ -32,12 +32,12 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
uses: github/codeql-action/init@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
build-mode: none
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
uses: github/codeql-action/analyze@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
|
||||
with:
|
||||
category: "/language:javascript-typescript"
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"_comment": "Initial JS budget (raw/uncompressed bytes) for the cold-load critical entrypoints. Enforced by build-scripts/check-bundle-size.cjs in CI. Re-seed after an intentional change with `--update --headroom=<percent>`.",
|
||||
"frontend-modern": {
|
||||
"app": 585518,
|
||||
"core": 57048,
|
||||
"authorize": 552423,
|
||||
"onboarding": 666818
|
||||
"app": 595204,
|
||||
"core": 57741,
|
||||
"authorize": 576928,
|
||||
"onboarding": 685964
|
||||
},
|
||||
"frontend-legacy": {
|
||||
"app": 887384,
|
||||
"core": 244482,
|
||||
"authorize": 844995,
|
||||
"onboarding": 1008816
|
||||
"app": 861452,
|
||||
"core": 258557,
|
||||
"authorize": 834356,
|
||||
"onboarding": 1001360
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,39 +51,4 @@ export const demoDevices: DeviceRegistryEntry[] = [
|
||||
primary_config_entry: "mock-sonos",
|
||||
entry_type: null,
|
||||
},
|
||||
{
|
||||
...baseDevice,
|
||||
id: "power-strip",
|
||||
name: "Power strip",
|
||||
manufacturer: "Acme",
|
||||
model: "Smart Power Strip",
|
||||
config_entries: ["mock-hue"],
|
||||
primary_config_entry: "mock-hue",
|
||||
entry_type: null,
|
||||
},
|
||||
// Child devices (logical parts of the power strip). They carry the parent's
|
||||
// inherited hardware fields, mirroring how resolveChildDevices fills them in
|
||||
// from the WebSocket, and reference the parent via parent_device_id.
|
||||
{
|
||||
...baseDevice,
|
||||
id: "power-strip-outlet-1",
|
||||
name: "Outlet 1",
|
||||
manufacturer: "Acme",
|
||||
model: "Smart Power Strip",
|
||||
config_entries: ["mock-hue"],
|
||||
primary_config_entry: "mock-hue",
|
||||
entry_type: null,
|
||||
parent_device_id: "power-strip",
|
||||
},
|
||||
{
|
||||
...baseDevice,
|
||||
id: "power-strip-outlet-2",
|
||||
name: "Outlet 2",
|
||||
manufacturer: "Acme",
|
||||
model: "Smart Power Strip",
|
||||
config_entries: ["mock-hue"],
|
||||
primary_config_entry: "mock-hue",
|
||||
entry_type: null,
|
||||
parent_device_id: "power-strip",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -102,11 +102,6 @@ export const mockEnergy = (hass: MockHomeAssistant) => {
|
||||
cost_sensors: {},
|
||||
solar_forecast_domains: [],
|
||||
}));
|
||||
hass.mockWS("energy/validate", () => ({
|
||||
energy_sources: Array.from({ length: 6 }, () => []),
|
||||
device_consumption: Array.from({ length: 6 }, () => []),
|
||||
device_consumption_water: Array.from({ length: 2 }, () => []),
|
||||
}));
|
||||
hass.mockWS(
|
||||
"energy/fossil_energy_consumption",
|
||||
({ period }): FossilEnergyConsumption => ({
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockSensor = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS(
|
||||
"sensor/device_class_convertible_units",
|
||||
({ device_class }: { device_class: string }) => ({
|
||||
units: device_class === "energy" ? ["kWh"] : ["W"],
|
||||
})
|
||||
);
|
||||
hass.mockWS("sensor/numeric_device_classes", () => ({
|
||||
numeric_device_classes: [
|
||||
"volume_storage",
|
||||
|
||||
@@ -57,7 +57,6 @@ export default [
|
||||
"ha-control-select",
|
||||
"ha-control-select-menu",
|
||||
"ha-hs-color-picker",
|
||||
"ha-color-temp-brightness-picker",
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
---
|
||||
title: Color temperature and brightness picker
|
||||
subtitle: Dual-axis pad controlling color temperature and brightness together
|
||||
---
|
||||
@@ -1,102 +0,0 @@
|
||||
import "../../../../src/components/ha-color-temp-brightness-picker";
|
||||
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
|
||||
import "../../../../src/components/ha-card";
|
||||
import "../../../../src/components/ha-slider";
|
||||
|
||||
const MIN_KELVIN = 2000;
|
||||
const MAX_KELVIN = 6500;
|
||||
|
||||
@customElement("demo-components-ha-color-temp-brightness-picker")
|
||||
export class DemoHaColorTempBrightnessPicker extends LitElement {
|
||||
@state()
|
||||
value: [number, number] = [3000, 50];
|
||||
|
||||
@state()
|
||||
liveValue?: [number, number];
|
||||
|
||||
private _valueCursor(ev) {
|
||||
this.liveValue = ev.detail.value;
|
||||
}
|
||||
|
||||
private _valueChanged(ev) {
|
||||
this.value = ev.detail.value;
|
||||
}
|
||||
|
||||
private _kelvinChanged(ev) {
|
||||
this.value = [Number(ev.target.value), this.value[1]];
|
||||
}
|
||||
|
||||
private _brightnessChanged(ev) {
|
||||
this.value = [this.value[0], Number(ev.target.value)];
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const [kelvin, brightness] = this.liveValue ?? this.value;
|
||||
|
||||
return html`
|
||||
<ha-card>
|
||||
<div class="card-content">
|
||||
<p class="value">${kelvin} K - ${brightness}%</p>
|
||||
<ha-color-temp-brightness-picker
|
||||
.value=${this.value}
|
||||
.minKelvin=${MIN_KELVIN}
|
||||
.maxKelvin=${MAX_KELVIN}
|
||||
@value-changed=${this._valueChanged}
|
||||
@cursor-moved=${this._valueCursor}
|
||||
></ha-color-temp-brightness-picker>
|
||||
<p>Color temperature: ${this.value[0]} K</p>
|
||||
<ha-slider
|
||||
labeled
|
||||
step="1"
|
||||
.min=${MIN_KELVIN}
|
||||
.max=${MAX_KELVIN}
|
||||
.value=${this.value[0]}
|
||||
@change=${this._kelvinChanged}
|
||||
>
|
||||
</ha-slider>
|
||||
<p>Brightness: ${this.value[1]}%</p>
|
||||
<ha-slider
|
||||
labeled
|
||||
step="1"
|
||||
min="1"
|
||||
max="100"
|
||||
.value=${this.value[1]}
|
||||
@change=${this._brightnessChanged}
|
||||
>
|
||||
</ha-slider>
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
ha-card {
|
||||
max-width: 600px;
|
||||
margin: 24px auto;
|
||||
}
|
||||
.card-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
ha-color-temp-brightness-picker {
|
||||
width: 320px;
|
||||
height: 320px;
|
||||
}
|
||||
.value {
|
||||
font-size: var(--ha-font-size-xl);
|
||||
font-weight: var(--ha-font-weight-bold);
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"demo-components-ha-color-temp-brightness-picker": DemoHaColorTempBrightnessPicker;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
@@ -8,6 +9,7 @@ import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervis
|
||||
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
|
||||
import "../../../../src/components/ha-selector/ha-selector";
|
||||
import "../../../../src/components/ha-settings-row";
|
||||
import "../../../../src/components/ha-target-picker";
|
||||
import type { AreaRegistryEntry } from "../../../../src/data/area/area_registry";
|
||||
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
|
||||
import type { EntityRegistryDisplayEntry } from "../../../../src/data/entity/entity_registry";
|
||||
@@ -153,6 +155,9 @@ interface Sample {
|
||||
description: string;
|
||||
selector: Selector;
|
||||
value: unknown;
|
||||
// Render ha-target-picker directly in compact (chip) mode instead of the
|
||||
// ha-selector, which does not expose the compact option.
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const SAMPLES: Sample[] = [
|
||||
@@ -163,6 +168,14 @@ const SAMPLES: Sample[] = [
|
||||
selector: { target: {} },
|
||||
value: { device_id: ["old_composite"] },
|
||||
},
|
||||
{
|
||||
name: "Target (compact)",
|
||||
description:
|
||||
"In compact mode the replaced reference is shown as a warning chip.",
|
||||
selector: { target: {} },
|
||||
value: { device_id: ["old_composite"] },
|
||||
compact: true,
|
||||
},
|
||||
{
|
||||
name: "Device (unfiltered, multiple matches)",
|
||||
description:
|
||||
@@ -261,13 +274,23 @@ class DemoHaSelectorReplacedDevice
|
||||
<ha-settings-row narrow slot=${slot}>
|
||||
<span slot="heading">${sample.name}</span>
|
||||
<span slot="description">${sample.description}</span>
|
||||
<ha-selector
|
||||
.hass=${this.hass}
|
||||
.selector=${sample.selector}
|
||||
.value=${this._values[idx]}
|
||||
.sampleIdx=${idx}
|
||||
@value-changed=${this._handleValueChanged}
|
||||
></ha-selector>
|
||||
${
|
||||
sample.compact
|
||||
? html`<ha-target-picker
|
||||
compact
|
||||
.hass=${this.hass}
|
||||
.value=${this._values[idx] as HassServiceTarget}
|
||||
.sampleIdx=${idx}
|
||||
@value-changed=${this._handleValueChanged}
|
||||
></ha-target-picker>`
|
||||
: html`<ha-selector
|
||||
.hass=${this.hass}
|
||||
.selector=${sample.selector}
|
||||
.value=${this._values[idx]}
|
||||
.sampleIdx=${idx}
|
||||
@value-changed=${this._handleValueChanged}
|
||||
></ha-selector>`
|
||||
}
|
||||
</ha-settings-row>
|
||||
`
|
||||
)}
|
||||
|
||||
@@ -152,84 +152,6 @@ const DEVICES: DeviceRegistryEntry[] = [
|
||||
primary_config_entry: null,
|
||||
parent_device_id: null,
|
||||
},
|
||||
{
|
||||
area_id: "livingroom",
|
||||
configuration_url: null,
|
||||
config_entries: ["config_entry_1"],
|
||||
config_entries_subentries: {},
|
||||
connections: [],
|
||||
disabled_by: null,
|
||||
entry_type: null,
|
||||
id: "device_power_strip",
|
||||
identifiers: [["demo", "strip1"] as [string, string]],
|
||||
manufacturer: "Acme",
|
||||
model: "Smart Power Strip",
|
||||
model_id: null,
|
||||
name_by_user: null,
|
||||
name: "Power strip",
|
||||
sw_version: null,
|
||||
hw_version: null,
|
||||
via_device_id: null,
|
||||
serial_number: null,
|
||||
labels: [],
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
primary_config_entry: null,
|
||||
parent_device_id: null,
|
||||
},
|
||||
// Child devices of the power strip. They have no area of their own and
|
||||
// inherit the parent's area ("Livingroom"); the picker renders them indented
|
||||
// under the parent with a tree connector.
|
||||
{
|
||||
area_id: null,
|
||||
configuration_url: null,
|
||||
config_entries: ["config_entry_1"],
|
||||
config_entries_subentries: {},
|
||||
connections: [],
|
||||
disabled_by: null,
|
||||
entry_type: null,
|
||||
id: "device_outlet_1",
|
||||
identifiers: [["demo", "outlet1"] as [string, string]],
|
||||
manufacturer: "Acme",
|
||||
model: "Smart Power Strip",
|
||||
model_id: null,
|
||||
name_by_user: null,
|
||||
name: "Outlet 1",
|
||||
sw_version: null,
|
||||
hw_version: null,
|
||||
via_device_id: null,
|
||||
serial_number: null,
|
||||
labels: [],
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
primary_config_entry: null,
|
||||
parent_device_id: "device_power_strip",
|
||||
},
|
||||
{
|
||||
area_id: null,
|
||||
configuration_url: null,
|
||||
config_entries: ["config_entry_1"],
|
||||
config_entries_subentries: {},
|
||||
connections: [],
|
||||
disabled_by: null,
|
||||
entry_type: null,
|
||||
id: "device_outlet_2",
|
||||
identifiers: [["demo", "outlet2"] as [string, string]],
|
||||
manufacturer: "Acme",
|
||||
model: "Smart Power Strip",
|
||||
model_id: null,
|
||||
name_by_user: null,
|
||||
name: "Outlet 2",
|
||||
sw_version: null,
|
||||
hw_version: null,
|
||||
via_device_id: null,
|
||||
serial_number: null,
|
||||
labels: [],
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
primary_config_entry: null,
|
||||
parent_device_id: "device_power_strip",
|
||||
},
|
||||
];
|
||||
|
||||
const AREAS: DemoArea[] = [
|
||||
|
||||
@@ -132,12 +132,12 @@ const ENTITIES = [
|
||||
fan_modes: ["on_low", "on_high", "auto_low", "auto_high", "off"],
|
||||
preset_modes: ["home", "eco", "away"],
|
||||
swing_modes: ["auto", "1", "2", "3", "off"],
|
||||
switch_horizontal_modes: ["auto", "4", "5", "6", "off"],
|
||||
current_temperature: 23,
|
||||
target_temp_high: 24,
|
||||
target_temp_low: 21,
|
||||
fan_mode: "auto_low",
|
||||
preset_mode: "home",
|
||||
swing_horizontal_modes: ["auto", "4", "5", "6", "off"],
|
||||
swing_mode: "auto",
|
||||
swing_horizontal_mode: "off",
|
||||
supported_features:
|
||||
@@ -340,84 +340,6 @@ const CONFIGS = [
|
||||
features: [{ type: "fan-oscillate" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Inline features: one feature",
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "climate.dual_thermostat",
|
||||
features_position: "inline",
|
||||
features: [{ type: "climate-hvac-modes", style: "dropdown" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Inline features: two features",
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "climate.dual_thermostat",
|
||||
features_position: "inline",
|
||||
features: [
|
||||
{ type: "climate-hvac-modes", style: "dropdown" },
|
||||
{ type: "climate-preset-modes", style: "dropdown" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Inline features: three features",
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "climate.dual_thermostat",
|
||||
features_position: "inline",
|
||||
features: [
|
||||
{ type: "climate-hvac-modes", style: "dropdown" },
|
||||
{ type: "climate-preset-modes", style: "dropdown" },
|
||||
{ type: "climate-fan-modes", style: "dropdown" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Inline features: four features",
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "climate.dual_thermostat",
|
||||
features_position: "inline",
|
||||
features: [
|
||||
{ type: "climate-hvac-modes", style: "dropdown" },
|
||||
{ type: "climate-preset-modes", style: "dropdown" },
|
||||
{ type: "climate-fan-modes", style: "dropdown" },
|
||||
{ type: "climate-swing-modes", style: "dropdown" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Inline features: five features",
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "climate.dual_thermostat",
|
||||
features_position: "inline",
|
||||
features: [
|
||||
{ type: "climate-hvac-modes", style: "dropdown" },
|
||||
{ type: "climate-preset-modes", style: "dropdown" },
|
||||
{ type: "climate-fan-modes", style: "dropdown" },
|
||||
{ type: "climate-swing-modes", style: "dropdown" },
|
||||
{ type: "climate-swing-horizontal-modes", style: "dropdown" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Bottom features: five features",
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "climate.dual_thermostat",
|
||||
features_position: "bottom",
|
||||
features: [
|
||||
{ type: "climate-hvac-modes", style: "dropdown" },
|
||||
{ type: "climate-preset-modes", style: "dropdown" },
|
||||
{ type: "climate-fan-modes", style: "dropdown" },
|
||||
{ type: "climate-swing-modes", style: "dropdown" },
|
||||
{ type: "climate-swing-horizontal-modes", style: "dropdown" },
|
||||
],
|
||||
},
|
||||
},
|
||||
] satisfies DemoCardConfig<TileCardConfig>[];
|
||||
|
||||
@customElement("demo-lovelace-tile-card")
|
||||
|
||||
@@ -15,6 +15,7 @@ const ALL_FEATURES =
|
||||
VacuumEntityFeature.STOP +
|
||||
VacuumEntityFeature.RETURN_HOME +
|
||||
VacuumEntityFeature.FAN_SPEED +
|
||||
VacuumEntityFeature.BATTERY +
|
||||
VacuumEntityFeature.STATUS +
|
||||
VacuumEntityFeature.LOCATE +
|
||||
VacuumEntityFeature.CLEAN_SPOT +
|
||||
@@ -27,6 +28,8 @@ const ENTITIES = [
|
||||
attributes: {
|
||||
friendly_name: "Full featured vacuum",
|
||||
supported_features: ALL_FEATURES,
|
||||
battery_level: 85,
|
||||
battery_icon: "mdi:battery-80",
|
||||
fan_speed: "balanced",
|
||||
fan_speed_list: ["silent", "standard", "balanced", "turbo", "max"],
|
||||
status: "Charged",
|
||||
@@ -38,6 +41,8 @@ const ENTITIES = [
|
||||
attributes: {
|
||||
friendly_name: "Cleaning vacuum",
|
||||
supported_features: ALL_FEATURES,
|
||||
battery_level: 62,
|
||||
battery_icon: "mdi:battery-60",
|
||||
fan_speed: "turbo",
|
||||
fan_speed_list: ["silent", "standard", "balanced", "turbo", "max"],
|
||||
status: "Cleaning bedroom",
|
||||
@@ -53,7 +58,10 @@ const ENTITIES = [
|
||||
VacuumEntityFeature.START +
|
||||
VacuumEntityFeature.PAUSE +
|
||||
VacuumEntityFeature.STOP +
|
||||
VacuumEntityFeature.RETURN_HOME,
|
||||
VacuumEntityFeature.RETURN_HOME +
|
||||
VacuumEntityFeature.BATTERY,
|
||||
battery_level: 23,
|
||||
battery_icon: "mdi:battery-20",
|
||||
status: "Returning to dock",
|
||||
},
|
||||
},
|
||||
@@ -88,6 +96,8 @@ const ENTITIES = [
|
||||
attributes: {
|
||||
friendly_name: "Paused vacuum",
|
||||
supported_features: ALL_FEATURES,
|
||||
battery_level: 45,
|
||||
battery_icon: "mdi:battery-40",
|
||||
fan_speed: "standard",
|
||||
fan_speed_list: ["silent", "standard", "balanced", "turbo", "max"],
|
||||
status: "Paused",
|
||||
|
||||
+8
-7
@@ -89,7 +89,7 @@
|
||||
"@vvo/tzdb": "6.198.0",
|
||||
"@webcomponents/scoped-custom-element-registry": "0.0.10",
|
||||
"@webcomponents/webcomponentsjs": "2.8.0",
|
||||
"barcode-detector": "3.2.2",
|
||||
"barcode-detector": "3.2.1",
|
||||
"cally": "0.9.2",
|
||||
"color-name": "2.1.1",
|
||||
"comlink": "4.4.2",
|
||||
@@ -103,11 +103,11 @@
|
||||
"echarts": "6.1.0",
|
||||
"element-internals-polyfill": "3.0.2",
|
||||
"fuse.js": "7.5.0",
|
||||
"hls.js": "1.7.0",
|
||||
"hls.js": "1.6.17",
|
||||
"home-assistant-js-websocket": "9.6.0",
|
||||
"idb-keyval": "6.3.0",
|
||||
"intl-messageformat": "11.2.13",
|
||||
"js-yaml": "5.3.0",
|
||||
"js-yaml": "5.2.3",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
|
||||
"leaflet.markercluster": "1.5.3",
|
||||
@@ -151,8 +151,8 @@
|
||||
"@octokit/plugin-retry": "8.1.1",
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@playwright/test": "1.62.1",
|
||||
"@rsdoctor/rspack-plugin": "1.6.2",
|
||||
"@rspack/core": "2.1.10",
|
||||
"@rsdoctor/rspack-plugin": "1.6.1",
|
||||
"@rspack/core": "2.1.8",
|
||||
"@rspack/dev-server": "2.2.0",
|
||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||
"@types/chromecast-caf-receiver": "6.0.26",
|
||||
@@ -186,7 +186,7 @@
|
||||
"fs-extra": "11.4.0",
|
||||
"generate-license-file": "4.2.1",
|
||||
"glob": "13.0.6",
|
||||
"globals": "17.11.0",
|
||||
"globals": "17.9.0",
|
||||
"gulp": "5.0.1",
|
||||
"gulp-json-transform": "0.5.0",
|
||||
"gulp-rename": "2.1.0",
|
||||
@@ -210,7 +210,7 @@
|
||||
"terser-webpack-plugin": "5.6.1",
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.67.0",
|
||||
"typescript-eslint": "8.66.0",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "4.1.10",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
@@ -223,6 +223,7 @@
|
||||
"clean-css": "5.3.3",
|
||||
"@lit/reactive-element": "2.1.2",
|
||||
"@fullcalendar/daygrid": "6.1.21",
|
||||
"globals": "17.9.0",
|
||||
"tslib": "2.8.1",
|
||||
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch"
|
||||
},
|
||||
|
||||
@@ -39,25 +39,6 @@ const formatTimeWithSecondsMem = memoizeOne(
|
||||
})
|
||||
);
|
||||
|
||||
// 9:15:24.123 PM || 21:15:24,123
|
||||
export const formatTimeWithMilliseconds = (
|
||||
dateObj: Date,
|
||||
locale: FrontendLocaleData,
|
||||
config: HassConfig
|
||||
) => formatTimeWithMillisecondsMem(locale, config.time_zone).format(dateObj);
|
||||
|
||||
const formatTimeWithMillisecondsMem = memoizeOne(
|
||||
(locale: FrontendLocaleData, serverTimeZone: string) =>
|
||||
new Intl.DateTimeFormat(locale.language, {
|
||||
hour: useAmPm(locale) ? "numeric" : "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
fractionalSecondDigits: 3,
|
||||
hourCycle: useAmPm(locale) ? "h12" : "h23",
|
||||
timeZone: resolveTimeZone(locale.time_zone, serverTimeZone),
|
||||
})
|
||||
);
|
||||
|
||||
// Tuesday 7:00 PM || Tuesday 19:00
|
||||
export const formatTimeWeekday = (
|
||||
dateObj: Date,
|
||||
|
||||
@@ -25,7 +25,6 @@ export type LocalizeKeys =
|
||||
| `ui.dialogs.unsupported.reasons.${string}`
|
||||
| `ui.panel.config.${string}.${"caption" | "description"}`
|
||||
| `ui.panel.config.dashboard.${string}`
|
||||
| `ui.panel.config.mqtt.${string}`
|
||||
| `ui.panel.config.storage.segments.${string}`
|
||||
| `ui.panel.config.zha.${string}`
|
||||
| `ui.panel.config.zwave_js.${string}`
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import { deepEqual } from "../util/deep-equal";
|
||||
import {
|
||||
createQueryString,
|
||||
decodeQueryParams,
|
||||
@@ -36,15 +35,6 @@ export const historyLogbookTargetFromQueryParams = (
|
||||
): HassServiceTarget | undefined =>
|
||||
serviceTargetFromQueryParams(params, historyLogbookTargetParamKeys);
|
||||
|
||||
export const historyLogbookTargetsEqual = (
|
||||
a: HassServiceTarget,
|
||||
b: HassServiceTarget
|
||||
): boolean =>
|
||||
deepEqual(
|
||||
queryParamsFromServiceTarget(a, historyLogbookTargetParamKeys),
|
||||
queryParamsFromServiceTarget(b, historyLogbookTargetParamKeys)
|
||||
);
|
||||
|
||||
export const createHistoryLogbookUrl = (
|
||||
path: string,
|
||||
target: HassServiceTarget,
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import {
|
||||
isMoreInfoView,
|
||||
type MoreInfoView,
|
||||
} from "../../dialogs/more-info/more-info-view";
|
||||
import type { SearchParamsSource } from "./query-params";
|
||||
|
||||
const ENTITY_ID_PARAM = "more-info-entity-id";
|
||||
const VIEW_PARAM = "more-info-view";
|
||||
|
||||
export interface MoreInfoUrlData {
|
||||
entityId?: string;
|
||||
view?: MoreInfoView;
|
||||
hash: URLSearchParams;
|
||||
}
|
||||
|
||||
export interface CreateMoreInfoUrlData {
|
||||
entityId: string;
|
||||
view: MoreInfoView;
|
||||
hash?: URLSearchParams;
|
||||
}
|
||||
|
||||
export const decodeMoreInfoUrl = (
|
||||
search: SearchParamsSource,
|
||||
hash = ""
|
||||
): MoreInfoUrlData => {
|
||||
const params =
|
||||
typeof search === "string"
|
||||
? new URLSearchParams(search)
|
||||
: search instanceof URLSearchParams
|
||||
? search
|
||||
: new URLSearchParams(search);
|
||||
const entityId = params.get(ENTITY_ID_PARAM) || undefined;
|
||||
const view = params.get(VIEW_PARAM) || undefined;
|
||||
|
||||
return {
|
||||
entityId,
|
||||
view: isMoreInfoView(view) ? view : undefined,
|
||||
hash: new URLSearchParams(
|
||||
__DEMO__ ? "" : hash.startsWith("#") ? hash.substring(1) : hash
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
export const createMoreInfoUrl = (
|
||||
base: string,
|
||||
data: CreateMoreInfoUrlData
|
||||
): string => {
|
||||
const url = new URL(base, window.location.origin);
|
||||
url.searchParams.set(ENTITY_ID_PARAM, data.entityId);
|
||||
url.searchParams.set(VIEW_PARAM, data.view);
|
||||
if (!__DEMO__ && data.hash !== undefined) {
|
||||
url.hash = data.hash.toString();
|
||||
}
|
||||
|
||||
return `${url.pathname}${url.search}${url.hash}`;
|
||||
};
|
||||
|
||||
export const removeMoreInfoUrl = (base: string): string => {
|
||||
const url = new URL(base, window.location.origin);
|
||||
url.searchParams.delete(ENTITY_ID_PARAM);
|
||||
url.searchParams.delete(VIEW_PARAM);
|
||||
|
||||
return `${url.pathname}${url.search}${url.hash}`;
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
import { ResizeController } from "@lit-labs/observers/resize-controller";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
@@ -23,10 +22,6 @@ import { hex2rgb } from "../../common/color/convert-color";
|
||||
import { measureTextWidth } from "../../util/text";
|
||||
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
|
||||
|
||||
const ROW_HEIGHT = 30;
|
||||
const ROW_HEIGHT_INSIDE_LABELS = 64;
|
||||
const GRID_BOTTOM = 30;
|
||||
|
||||
@customElement("state-history-chart-timeline")
|
||||
export class StateHistoryChartTimeline extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -43,10 +38,6 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
|
||||
@property({ attribute: "show-names", type: Boolean }) public showNames = true;
|
||||
|
||||
/** Draw each row's name above its bar instead of in a label column. */
|
||||
@property({ attribute: "inside-labels", type: Boolean })
|
||||
public insideLabels = false;
|
||||
|
||||
@property({ attribute: "click-for-more-info", type: Boolean })
|
||||
public clickForMoreInfo = true;
|
||||
|
||||
@@ -69,13 +60,6 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
|
||||
@state() private _yWidth = 0;
|
||||
|
||||
private _width = 0;
|
||||
|
||||
private _resize = new ResizeController(this, {
|
||||
skipInitial: true,
|
||||
callback: (entries) => entries[0]?.contentRect.width,
|
||||
});
|
||||
|
||||
private _chartTime: Date = new Date();
|
||||
|
||||
protected render() {
|
||||
@@ -83,7 +67,7 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
<ha-chart-base
|
||||
.hass=${this.hass}
|
||||
.options=${this._chartOptions}
|
||||
.height=${`${this.data.length * (this.insideLabels ? ROW_HEIGHT_INSIDE_LABELS : ROW_HEIGHT) + GRID_BOTTOM}px`}
|
||||
.height=${`${this.data.length * 30 + 30}px`}
|
||||
.data=${this._chartData as HaECSeries}
|
||||
small-controls
|
||||
@chart-click=${this._handleChartClick}
|
||||
@@ -193,19 +177,13 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
this._generateData();
|
||||
}
|
||||
|
||||
const width = this.insideLabels ? Math.round(this._resize.value ?? 0) : 0;
|
||||
const widthChanged = width !== this._width;
|
||||
this._width = width;
|
||||
|
||||
if (
|
||||
!this.hasUpdated ||
|
||||
changedProps.has("startTime") ||
|
||||
changedProps.has("endTime") ||
|
||||
changedProps.has("showNames") ||
|
||||
changedProps.has("insideLabels") ||
|
||||
changedProps.has("paddingYAxis") ||
|
||||
changedProps.has("_yWidth") ||
|
||||
widthChanged
|
||||
changedProps.has("_yWidth")
|
||||
) {
|
||||
this._createOptions();
|
||||
}
|
||||
@@ -215,22 +193,14 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
const narrow = this.narrow;
|
||||
const showNames = this.chunked || this.showNames;
|
||||
const maxInternalLabelWidth = narrow ? 105 : 185;
|
||||
const insideLabels = this.insideLabels;
|
||||
const labelWidth =
|
||||
showNames && !insideLabels
|
||||
? Math.max(this.paddingYAxis, this._yWidth)
|
||||
: 0;
|
||||
const labelWidth = showNames
|
||||
? Math.max(this.paddingYAxis, this._yWidth)
|
||||
: 0;
|
||||
const labelMargin = 5;
|
||||
const rtl = computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
);
|
||||
// Keeps the plot aligned with the line charts sharing the y-axis padding.
|
||||
const plotPadding = insideLabels ? this.paddingYAxis : labelWidth;
|
||||
// A zero width hides the labels instead of truncating them.
|
||||
const insideLabelWidth = this._width
|
||||
? Math.max(0, this._width - plotPadding - labelMargin)
|
||||
: undefined;
|
||||
this._chartOptions = {
|
||||
xAxis: {
|
||||
type: "time",
|
||||
@@ -254,52 +224,37 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
axisLine: {
|
||||
show: false,
|
||||
},
|
||||
axisLabel: insideLabels
|
||||
? {
|
||||
show: showNames,
|
||||
inside: true,
|
||||
margin: 0,
|
||||
padding: [0, rtl ? 2 : 0, 14, rtl ? 0 : 2],
|
||||
align: rtl ? "right" : "left",
|
||||
verticalAlign: "bottom",
|
||||
width: insideLabelWidth,
|
||||
overflow: "truncate",
|
||||
formatter: (id: string) =>
|
||||
(this._chartData.find((d) => d.id === id)?.name as string) ??
|
||||
"",
|
||||
hideOverlap: true,
|
||||
axisLabel: {
|
||||
show: showNames,
|
||||
width: labelWidth,
|
||||
overflow: "truncate",
|
||||
margin: labelMargin,
|
||||
formatter: (id: string) => {
|
||||
const label = this._chartData.find((d) => d.id === id)
|
||||
?.name as string;
|
||||
const width = label
|
||||
? Math.min(
|
||||
measureTextWidth(label, 12) + labelMargin,
|
||||
maxInternalLabelWidth
|
||||
)
|
||||
: 0;
|
||||
if (width > this._yWidth) {
|
||||
this._yWidth = width;
|
||||
fireEvent(this, "y-width-changed", {
|
||||
value: this._yWidth,
|
||||
chartIndex: this.chartIndex,
|
||||
});
|
||||
}
|
||||
: {
|
||||
show: showNames,
|
||||
width: labelWidth,
|
||||
overflow: "truncate",
|
||||
margin: labelMargin,
|
||||
formatter: (id: string) => {
|
||||
const label = this._chartData.find((d) => d.id === id)
|
||||
?.name as string;
|
||||
const width = label
|
||||
? Math.min(
|
||||
measureTextWidth(label, 12) + labelMargin,
|
||||
maxInternalLabelWidth
|
||||
)
|
||||
: 0;
|
||||
if (width > this._yWidth) {
|
||||
this._yWidth = width;
|
||||
fireEvent(this, "y-width-changed", {
|
||||
value: this._yWidth,
|
||||
chartIndex: this.chartIndex,
|
||||
});
|
||||
}
|
||||
return label;
|
||||
},
|
||||
hideOverlap: true,
|
||||
},
|
||||
return label;
|
||||
},
|
||||
hideOverlap: true,
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
top: insideLabels ? 20 : 10,
|
||||
bottom: GRID_BOTTOM,
|
||||
left: rtl ? 1 : plotPadding,
|
||||
right: rtl ? plotPadding : 1,
|
||||
top: 10,
|
||||
bottom: 30,
|
||||
left: rtl ? 1 : labelWidth,
|
||||
right: rtl ? labelWidth : 1,
|
||||
},
|
||||
tooltip: {
|
||||
renderMode: "html",
|
||||
@@ -443,10 +398,6 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
ha-chart-base {
|
||||
--chart-max-height: none;
|
||||
}
|
||||
|
||||
@@ -79,10 +79,6 @@ export class StateHistoryCharts extends LitElement {
|
||||
|
||||
@property({ attribute: "show-names", type: Boolean }) public showNames = true;
|
||||
|
||||
/** Draw timeline row names above their bar instead of in a label column. */
|
||||
@property({ attribute: "inside-labels", type: Boolean, reflect: true })
|
||||
public insideLabels = false;
|
||||
|
||||
@property({ attribute: "click-for-more-info", type: Boolean })
|
||||
public clickForMoreInfo = true;
|
||||
|
||||
@@ -231,7 +227,6 @@ export class StateHistoryCharts extends LitElement {
|
||||
.startTime=${this._computedStartTime}
|
||||
.endTime=${this._computedEndTime}
|
||||
.showNames=${this.showNames}
|
||||
.insideLabels=${this.insideLabels}
|
||||
.names=${this.names}
|
||||
.narrow=${this.narrow}
|
||||
.chunked=${this.virtualize}
|
||||
@@ -429,12 +424,6 @@ export class StateHistoryCharts extends LitElement {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
/* Names inside the plot sit close to the chart above them, so the groups
|
||||
need more room between them to stay apart. */
|
||||
:host([inside-labels]) .entry-container.timeline:not(:first-child) {
|
||||
margin-top: var(--ha-space-8);
|
||||
}
|
||||
|
||||
.entry-container:hover {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ export class HaDataTable extends LitElement {
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n?: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public narrow = false;
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
@property({ type: Object }) public columns: DataTableColumnContainer = {};
|
||||
|
||||
@@ -1158,11 +1158,6 @@ export class HaDataTable extends LitElement {
|
||||
/* default mdc styles, colors changed, without checkbox styles */
|
||||
:host {
|
||||
height: 100%;
|
||||
--_cell-padding-inline: 16px;
|
||||
}
|
||||
|
||||
:host([narrow]) {
|
||||
--_cell-padding-inline: 8px;
|
||||
}
|
||||
.mdc-data-table__content {
|
||||
font-family: var(--ha-font-family-body);
|
||||
@@ -1243,7 +1238,8 @@ export class HaDataTable extends LitElement {
|
||||
|
||||
.mdc-data-table__cell,
|
||||
.mdc-data-table__header-cell {
|
||||
padding-inline: var(--_cell-padding-inline);
|
||||
padding-right: 16px;
|
||||
padding-left: 16px;
|
||||
min-width: 150px;
|
||||
align-self: center;
|
||||
overflow: hidden;
|
||||
@@ -1263,8 +1259,14 @@ export class HaDataTable extends LitElement {
|
||||
|
||||
.mdc-data-table__header-cell--checkbox,
|
||||
.mdc-data-table__cell--checkbox {
|
||||
padding-inline-start: var(--_cell-padding-inline);
|
||||
padding-inline-end: 0;
|
||||
/* @noflip */
|
||||
padding-left: 16px;
|
||||
/* @noflip */
|
||||
padding-right: 0;
|
||||
/* @noflip */
|
||||
padding-inline-start: 16px;
|
||||
/* @noflip */
|
||||
padding-inline-end: initial;
|
||||
width: 60px;
|
||||
min-width: 60px;
|
||||
}
|
||||
@@ -1377,7 +1379,8 @@ export class HaDataTable extends LitElement {
|
||||
.mdc-data-table__header-cell--overflow-menu:first-child,
|
||||
.mdc-data-table__header-cell--icon-button:first-child,
|
||||
.mdc-data-table__cell--icon-button:first-child {
|
||||
padding-inline-start: var(--_cell-padding-inline);
|
||||
padding-left: 16px;
|
||||
padding-inline-start: 16px;
|
||||
padding-inline-end: initial;
|
||||
}
|
||||
|
||||
@@ -1385,7 +1388,8 @@ export class HaDataTable extends LitElement {
|
||||
.mdc-data-table__header-cell--overflow-menu:last-child,
|
||||
.mdc-data-table__header-cell--icon-button:last-child,
|
||||
.mdc-data-table__cell--icon-button:last-child {
|
||||
padding-inline-end: var(--_cell-padding-inline);
|
||||
padding-right: 16px;
|
||||
padding-inline-end: 16px;
|
||||
padding-inline-start: initial;
|
||||
}
|
||||
.mdc-data-table__cell--overflow-menu,
|
||||
@@ -1512,17 +1516,11 @@ export class HaDataTable extends LitElement {
|
||||
.center {
|
||||
text-align: center;
|
||||
}
|
||||
.primary {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.secondary {
|
||||
color: var(--secondary-text-color);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.scroller {
|
||||
height: calc(100% - 57px);
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import { mdiCalendar } from "@mdi/js";
|
||||
import { css, html } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import "../chips/ha-assist-chip";
|
||||
import "../ha-icon-button-next";
|
||||
import "../ha-icon-button-prev";
|
||||
import "../ha-svg-icon";
|
||||
import {
|
||||
haDateRangePickerStyles,
|
||||
HaDateRangePicker,
|
||||
} from "./ha-date-range-picker";
|
||||
|
||||
/**
|
||||
* Date range picker as a single pill that also steps through ranges: a
|
||||
* previous button, the selected range and a next button. Meant for a toolbar,
|
||||
* next to other chips.
|
||||
*/
|
||||
@customElement("ha-date-range-nav")
|
||||
export class HaDateRangeNav extends HaDateRangePicker {
|
||||
protected override _renderField() {
|
||||
return html`
|
||||
<ha-icon-button-prev
|
||||
class="step"
|
||||
.label=${this._i18n.localize("ui.common.previous")}
|
||||
.disabled=${this.disabled}
|
||||
@click=${this._handlePrev}
|
||||
></ha-icon-button-prev>
|
||||
<ha-assist-chip
|
||||
id="field"
|
||||
class="range"
|
||||
.label=${this._formatRange(" – ")}
|
||||
.disabled=${this.disabled}
|
||||
@click=${this._openPicker}
|
||||
>
|
||||
<ha-svg-icon slot="icon" .path=${mdiCalendar}></ha-svg-icon>
|
||||
</ha-assist-chip>
|
||||
<ha-icon-button-next
|
||||
class="step"
|
||||
.label=${this._i18n.localize("ui.common.next")}
|
||||
.disabled=${this.disabled}
|
||||
@click=${this._handleNext}
|
||||
></ha-icon-button-next>
|
||||
`;
|
||||
}
|
||||
|
||||
static override styles = [
|
||||
haDateRangePickerStyles,
|
||||
css`
|
||||
/* The three controls read as one pill, with the range chip's borders as
|
||||
the dividers between them. */
|
||||
.date-range-inputs {
|
||||
gap: 0;
|
||||
border: 1px solid var(--outline-color);
|
||||
border-radius: var(--ha-assist-chip-container-shape, 10px);
|
||||
background: var(--ha-assist-chip-container-color, transparent);
|
||||
overflow: hidden;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.step {
|
||||
--ha-icon-button-size: 32px;
|
||||
--mdc-icon-size: 20px;
|
||||
}
|
||||
|
||||
.range {
|
||||
--md-assist-chip-outline-color: transparent;
|
||||
--ha-assist-chip-container-shape: 0;
|
||||
--ha-assist-chip-container-color: transparent;
|
||||
border-inline: 1px solid var(--divider-color);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-date-range-nav": HaDateRangeNav;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import "@home-assistant/webawesome/dist/components/popover/popover";
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { mdiCalendar } from "@mdi/js";
|
||||
import "cally";
|
||||
import { isThisYear } from "date-fns";
|
||||
import type { HassConfig } from "home-assistant-js-websocket/dist/types";
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
@@ -10,7 +11,10 @@ import { tinykeys } from "tinykeys";
|
||||
import { shiftDateRange } from "../../common/datetime/calc_date";
|
||||
import type { DateRange } from "../../common/datetime/calc_date_range";
|
||||
import { calcDateRange } from "../../common/datetime/calc_date_range";
|
||||
import { formatShortDateTimeWithConditionalYear } from "../../common/datetime/format_date_time";
|
||||
import {
|
||||
formatShortDateTime,
|
||||
formatShortDateTimeWithYear,
|
||||
} from "../../common/datetime/format_date_time";
|
||||
import { transform } from "../../common/decorators/transform";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { configContext, internationalizationContext } from "../../data/context";
|
||||
@@ -38,67 +42,18 @@ const EXTENDED_RANGE_KEYS: DateRange[] = [
|
||||
"now-30d",
|
||||
];
|
||||
|
||||
export const haDateRangePickerStyles = css`
|
||||
ha-icon-button {
|
||||
direction: var(--direction);
|
||||
}
|
||||
|
||||
.date-range-inputs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
|
||||
ha-textarea {
|
||||
display: inline-block;
|
||||
width: 340px;
|
||||
}
|
||||
@media only screen and (max-width: 460px) {
|
||||
ha-textarea {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
wa-popover {
|
||||
--wa-space-l: 0;
|
||||
}
|
||||
|
||||
wa-popover::part(dialog)::backdrop {
|
||||
opacity: 0;
|
||||
transition: opacity var(--ha-animation-duration-normal) ease-out;
|
||||
}
|
||||
|
||||
wa-popover.open::part(dialog)::backdrop {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
:host(:not([backdrop])) wa-popover::part(dialog)::backdrop {
|
||||
background: none;
|
||||
}
|
||||
|
||||
wa-popover::part(body) {
|
||||
min-width: max(var(--body-width), 250px);
|
||||
max-width: calc(
|
||||
100vw - var(--safe-area-inset-left) - var(--safe-area-inset-right) - var(
|
||||
--ha-space-8
|
||||
)
|
||||
);
|
||||
overflow: hidden;
|
||||
}
|
||||
`;
|
||||
|
||||
@customElement("ha-date-range-picker")
|
||||
export class HaDateRangePicker extends LitElement {
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
protected _i18n!: ContextType<typeof internationalizationContext>;
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: configContext, subscribe: true })
|
||||
@transform<HomeAssistantConfig, HassConfig>({
|
||||
transformer: ({ config }) => config,
|
||||
})
|
||||
protected _hassConfig!: HassConfig;
|
||||
private _hassConfig!: HassConfig;
|
||||
|
||||
@property({ attribute: false }) public startDate!: Date;
|
||||
|
||||
@@ -188,7 +143,73 @@ export class HaDateRangePicker extends LitElement {
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<div class="container">
|
||||
<div class="date-range-inputs">${this._renderField()}</div>
|
||||
<div class="date-range-inputs">
|
||||
${
|
||||
!this.minimal
|
||||
? html`<ha-textarea
|
||||
id="field"
|
||||
rows="1"
|
||||
resize="auto"
|
||||
@click=${this._openPicker}
|
||||
@keydown=${this._handleKeydown}
|
||||
.value=${
|
||||
(isThisYear(this.startDate)
|
||||
? formatShortDateTime(
|
||||
this.startDate,
|
||||
this._i18n.locale,
|
||||
this._hassConfig
|
||||
)
|
||||
: formatShortDateTimeWithYear(
|
||||
this.startDate,
|
||||
this._i18n.locale,
|
||||
this._hassConfig
|
||||
)) +
|
||||
(window.innerWidth >= 459 ? " - " : " - \n") +
|
||||
(isThisYear(this.endDate)
|
||||
? formatShortDateTime(
|
||||
this.endDate,
|
||||
this._i18n.locale,
|
||||
this._hassConfig
|
||||
)
|
||||
: formatShortDateTimeWithYear(
|
||||
this.endDate,
|
||||
this._i18n.locale,
|
||||
this._hassConfig
|
||||
))
|
||||
}
|
||||
.label=${
|
||||
this._i18n.localize(
|
||||
"ui.components.date-range-picker.start_date"
|
||||
) +
|
||||
" - " +
|
||||
this._i18n.localize(
|
||||
"ui.components.date-range-picker.end_date"
|
||||
)
|
||||
}
|
||||
.disabled=${this.disabled}
|
||||
readonly
|
||||
></ha-textarea>
|
||||
<ha-icon-button-prev
|
||||
.label=${this._i18n.localize("ui.common.previous")}
|
||||
@click=${this._handlePrev}
|
||||
>
|
||||
</ha-icon-button-prev>
|
||||
<ha-icon-button-next
|
||||
.label=${this._i18n.localize("ui.common.next")}
|
||||
@click=${this._handleNext}
|
||||
>
|
||||
</ha-icon-button-next>`
|
||||
: html`<ha-icon-button
|
||||
@click=${this._openPicker}
|
||||
.disabled=${this.disabled}
|
||||
id="field"
|
||||
.label=${this._i18n.localize(
|
||||
"ui.components.date-range-picker.select_date_range"
|
||||
)}
|
||||
.path=${mdiCalendar}
|
||||
></ha-icon-button>`
|
||||
}
|
||||
</div>
|
||||
${
|
||||
this._pickerWrapperOpen || this._opened
|
||||
? this._openedNarrow
|
||||
@@ -227,60 +248,6 @@ export class HaDateRangePicker extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The control that opens the picker. It has to carry `id="field"`, which the
|
||||
* popover anchors to.
|
||||
*/
|
||||
protected _renderField() {
|
||||
if (this.minimal) {
|
||||
return html`<ha-icon-button
|
||||
@click=${this._openPicker}
|
||||
.disabled=${this.disabled}
|
||||
id="field"
|
||||
.label=${this._i18n.localize(
|
||||
"ui.components.date-range-picker.select_date_range"
|
||||
)}
|
||||
.path=${mdiCalendar}
|
||||
></ha-icon-button>`;
|
||||
}
|
||||
|
||||
return html`<ha-textarea
|
||||
id="field"
|
||||
rows="1"
|
||||
resize="auto"
|
||||
@click=${this._openPicker}
|
||||
@keydown=${this._handleKeydown}
|
||||
.value=${this._formatRange(window.innerWidth >= 459 ? " - " : " - \n")}
|
||||
.label=${
|
||||
this._i18n.localize("ui.components.date-range-picker.start_date") +
|
||||
" - " +
|
||||
this._i18n.localize("ui.components.date-range-picker.end_date")
|
||||
}
|
||||
.disabled=${this.disabled}
|
||||
readonly
|
||||
></ha-textarea>
|
||||
<ha-icon-button-prev
|
||||
.label=${this._i18n.localize("ui.common.previous")}
|
||||
@click=${this._handlePrev}
|
||||
>
|
||||
</ha-icon-button-prev>
|
||||
<ha-icon-button-next
|
||||
.label=${this._i18n.localize("ui.common.next")}
|
||||
@click=${this._handleNext}
|
||||
>
|
||||
</ha-icon-button-next>`;
|
||||
}
|
||||
|
||||
protected _formatRange(separator: string): string {
|
||||
const format = (date: Date) =>
|
||||
formatShortDateTimeWithConditionalYear(
|
||||
date,
|
||||
this._i18n.locale,
|
||||
this._hassConfig
|
||||
);
|
||||
return format(this.startDate) + separator + format(this.endDate);
|
||||
}
|
||||
|
||||
private _renderPicker() {
|
||||
if (!this._opened) {
|
||||
return nothing;
|
||||
@@ -336,12 +303,12 @@ export class HaDateRangePicker extends LitElement {
|
||||
this._opened = false;
|
||||
};
|
||||
|
||||
protected _handleNext(ev: MouseEvent): void {
|
||||
private _handleNext(ev: MouseEvent): void {
|
||||
if (ev && ev.stopPropagation) ev.stopPropagation();
|
||||
this._shift(true);
|
||||
}
|
||||
|
||||
protected _handlePrev(ev: MouseEvent): void {
|
||||
private _handlePrev(ev: MouseEvent): void {
|
||||
if (ev && ev.stopPropagation) ev.stopPropagation();
|
||||
this._shift(false);
|
||||
}
|
||||
@@ -369,7 +336,7 @@ export class HaDateRangePicker extends LitElement {
|
||||
this._pickerWrapperOpen = false;
|
||||
}
|
||||
|
||||
protected _openPicker(ev?: Event) {
|
||||
private _openPicker(ev?: Event) {
|
||||
if (this.disabled) {
|
||||
return;
|
||||
}
|
||||
@@ -385,7 +352,7 @@ export class HaDateRangePicker extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
protected _handleKeydown(ev: KeyboardEvent) {
|
||||
private _handleKeydown(ev: KeyboardEvent) {
|
||||
if (ev.key === "Enter" || ev.key === " ") {
|
||||
ev.stopPropagation();
|
||||
this._openPicker(ev);
|
||||
@@ -402,7 +369,56 @@ export class HaDateRangePicker extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
static styles = [haDateRangePickerStyles];
|
||||
static styles = [
|
||||
css`
|
||||
ha-icon-button {
|
||||
direction: var(--direction);
|
||||
}
|
||||
|
||||
.date-range-inputs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
|
||||
ha-textarea {
|
||||
display: inline-block;
|
||||
width: 340px;
|
||||
}
|
||||
@media only screen and (max-width: 460px) {
|
||||
ha-textarea {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
wa-popover {
|
||||
--wa-space-l: 0;
|
||||
}
|
||||
|
||||
wa-popover::part(dialog)::backdrop {
|
||||
opacity: 0;
|
||||
transition: opacity var(--ha-animation-duration-normal) ease-out;
|
||||
}
|
||||
|
||||
wa-popover.open::part(dialog)::backdrop {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
:host(:not([backdrop])) wa-popover::part(dialog)::backdrop {
|
||||
background: none;
|
||||
}
|
||||
|
||||
wa-popover::part(body) {
|
||||
min-width: max(var(--body-width), 250px);
|
||||
max-width: calc(
|
||||
100vw - var(--safe-area-inset-left) - var(
|
||||
--safe-area-inset-right
|
||||
) - var(--ha-space-8)
|
||||
);
|
||||
overflow: hidden;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -8,7 +8,6 @@ export const datePickerStyles = css`
|
||||
}
|
||||
calendar-date::part(button),
|
||||
calendar-range::part(button) {
|
||||
color: var(--primary-text-color);
|
||||
border: none;
|
||||
background-color: unset;
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
|
||||
@@ -3,13 +3,11 @@ import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { computeAreaName } from "../../common/entity/compute_area_name";
|
||||
import { computeDeviceName } from "../../common/entity/compute_device_name";
|
||||
import { getDeviceArea } from "../../common/entity/context/get_device_context";
|
||||
import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import { getConfigEntries, type ConfigEntry } from "../../data/config_entries";
|
||||
import {
|
||||
deviceComboBoxKeys,
|
||||
@@ -28,9 +26,7 @@ import "../ha-alert";
|
||||
import "../ha-button";
|
||||
import "../ha-generic-picker";
|
||||
import type { HaGenericPicker } from "../ha-generic-picker";
|
||||
import type { PickerComboBoxSearchFn } from "../ha-picker-combo-box";
|
||||
import "../ha-svg-icon";
|
||||
import "../ha-tree-indicator";
|
||||
import { showDeviceReplacedDialog } from "./show-dialog-device-replaced";
|
||||
|
||||
export type HaDevicePickerDeviceFilterFunc = (
|
||||
@@ -132,7 +128,6 @@ export class HaDevicePicker extends LitElement {
|
||||
entityFilter,
|
||||
excludeDevices,
|
||||
value,
|
||||
nested: true,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -221,34 +216,6 @@ export class HaDevicePicker extends LitElement {
|
||||
this.value
|
||||
);
|
||||
|
||||
// The fuzzy search ranks matches by relevance, which would pull a child device
|
||||
// above its parent (the parent often only matches through the lower-weighted
|
||||
// child names). Restore the nested order from the full item list and recompute
|
||||
// which child is last, so the tree connectors stay correct while searching.
|
||||
private _searchFn: PickerComboBoxSearchFn<DevicePickerItem> = (
|
||||
_search,
|
||||
filteredItems,
|
||||
allItems
|
||||
) => {
|
||||
const matchedIds = new Set(filteredItems.map((item) => item.id));
|
||||
const ordered = allItems.filter((item) => matchedIds.has(item.id));
|
||||
// Keep any items the search added that are not part of the nested list
|
||||
// (for example the "no items available" placeholder or additional items).
|
||||
const orderedIds = new Set(ordered.map((item) => item.id));
|
||||
const extras = filteredItems.filter((item) => !orderedIds.has(item.id));
|
||||
|
||||
return [
|
||||
...ordered.map((item, index) => {
|
||||
if (!item.is_child) {
|
||||
return item;
|
||||
}
|
||||
const nextItem = ordered[index + 1];
|
||||
return { ...item, last: !nextItem || !nextItem.is_child };
|
||||
}),
|
||||
...extras,
|
||||
];
|
||||
};
|
||||
|
||||
private _valueRenderer = memoizeOne(
|
||||
(
|
||||
configEntriesLookup: Record<string, ConfigEntry>,
|
||||
@@ -312,76 +279,46 @@ export class HaDevicePicker extends LitElement {
|
||||
}
|
||||
);
|
||||
|
||||
private _rowRenderer: RenderItemFunction<DevicePickerItem> = (item) => {
|
||||
const rtl = computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
);
|
||||
return html`
|
||||
<ha-combo-box-item
|
||||
type="button"
|
||||
style=${
|
||||
item.is_child
|
||||
? "--md-list-item-leading-space: var(--ha-space-12);"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
${
|
||||
item.is_child
|
||||
? html`<ha-tree-indicator
|
||||
style=${styleMap({
|
||||
width: "var(--ha-space-12)",
|
||||
position: "absolute",
|
||||
top: "0",
|
||||
height: "100%",
|
||||
left: rtl ? undefined : "var(--ha-space-1)",
|
||||
right: rtl ? "var(--ha-space-1)" : undefined,
|
||||
transform: rtl ? "scaleX(-1)" : "",
|
||||
})}
|
||||
.end=${item.last}
|
||||
private _rowRenderer: RenderItemFunction<DevicePickerItem> = (item) => html`
|
||||
<ha-combo-box-item type="button">
|
||||
${
|
||||
item.domain
|
||||
? html`
|
||||
<img
|
||||
slot="start"
|
||||
></ha-tree-indicator>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
item.domain
|
||||
? html`
|
||||
<img
|
||||
slot="start"
|
||||
alt=""
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
src=${brandsUrl(
|
||||
{
|
||||
domain: item.domain,
|
||||
type: "icon",
|
||||
darkOptimized: this.hass.themes.darkMode,
|
||||
},
|
||||
this.hass.auth.data.hassUrl
|
||||
)}
|
||||
/>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
alt=""
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
src=${brandsUrl(
|
||||
{
|
||||
domain: item.domain,
|
||||
type: "icon",
|
||||
darkOptimized: this.hass.themes.darkMode,
|
||||
},
|
||||
this.hass.auth.data.hassUrl
|
||||
)}
|
||||
/>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
|
||||
<span slot="headline">${item.primary}</span>
|
||||
${
|
||||
item.secondary
|
||||
? html`<span slot="supporting-text">${item.secondary}</span>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
item.domain_name
|
||||
? html`
|
||||
<div slot="trailing-supporting-text" class="domain">
|
||||
${item.domain_name}
|
||||
</div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</ha-combo-box-item>
|
||||
`;
|
||||
};
|
||||
<span slot="headline">${item.primary}</span>
|
||||
${
|
||||
item.secondary
|
||||
? html`<span slot="supporting-text">${item.secondary}</span>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
item.domain_name
|
||||
? html`
|
||||
<div slot="trailing-supporting-text" class="domain">
|
||||
${item.domain_name}
|
||||
</div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</ha-combo-box-item>
|
||||
`;
|
||||
|
||||
protected render() {
|
||||
const placeholder =
|
||||
@@ -438,8 +375,6 @@ export class HaDevicePicker extends LitElement {
|
||||
.value=${this.value}
|
||||
.rowRenderer=${this._rowRenderer}
|
||||
.getItems=${this._getItems}
|
||||
.searchFn=${this._searchFn}
|
||||
no-sort
|
||||
.hideClearIcon=${this.hideClearIcon}
|
||||
.valueRenderer=${valueRenderer}
|
||||
.searchKeys=${deviceComboBoxKeys}
|
||||
|
||||
@@ -34,8 +34,6 @@ export class HaButtonToggleGroup extends LitElement {
|
||||
@property({ type: Boolean, reflect: true, attribute: "full-width" })
|
||||
public fullWidth = false;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@property() public variant:
|
||||
"brand" | "neutral" | "success" | "warning" | "danger" = "brand";
|
||||
|
||||
@@ -59,7 +57,6 @@ export class HaButtonToggleGroup extends LitElement {
|
||||
.value=${button.value}
|
||||
@click=${this._handleClick}
|
||||
.title=${button.label}
|
||||
.disabled=${this.disabled}
|
||||
.appearance=${this.active === button.value ? "accent" : "filled"}
|
||||
>
|
||||
${
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import type {
|
||||
LocalizeFunc,
|
||||
LocalizeKeys,
|
||||
} from "../common/translations/localize";
|
||||
import type { YamlFieldSchema } from "../resources/yaml_field_schema";
|
||||
|
||||
/**
|
||||
* Tooltip element rendered inside a CodeMirror hoverTooltip for YAML field
|
||||
* keys in the automation / script / card YAML editors.
|
||||
*
|
||||
* Shows:
|
||||
* - Field name (monospace)
|
||||
* - "required" badge when applicable
|
||||
* - Description paragraph
|
||||
* - Selector type hint
|
||||
* - Example value
|
||||
* - Default value
|
||||
*/
|
||||
@customElement("ha-code-editor-yaml-hover")
|
||||
export class HaCodeEditorYamlHover extends LitElement {
|
||||
@property({ attribute: false }) public fieldName = "";
|
||||
|
||||
@property({ attribute: false }) public fieldSchema!: YamlFieldSchema;
|
||||
|
||||
/**
|
||||
* Optional localize callback forwarded from the editor so translated
|
||||
* descriptions can be rendered. When absent, strings are shown verbatim.
|
||||
*/
|
||||
@property({ attribute: false }) public localize?: LocalizeFunc;
|
||||
|
||||
render() {
|
||||
const schema = this.fieldSchema;
|
||||
if (!schema) return nothing;
|
||||
|
||||
// Built-in field descriptions are translation keys; descriptions coming
|
||||
// from the backend are already translated and fall through unchanged.
|
||||
const description = schema.description
|
||||
? this.localize?.(schema.description as LocalizeKeys) ||
|
||||
schema.description
|
||||
: undefined;
|
||||
|
||||
const selectorType = schema.selector
|
||||
? Object.keys(schema.selector)[0]
|
||||
: undefined;
|
||||
|
||||
return html`
|
||||
<div class="header">
|
||||
<code class="key">${this.fieldName}</code>
|
||||
${
|
||||
schema.required
|
||||
? html`<span class="badge required"
|
||||
>${this._label("required", "required")}</span
|
||||
>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
selectorType
|
||||
? html`<span class="badge type">${selectorType}</span>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
${description ? html`<div class="desc">${description}</div>` : nothing}
|
||||
${
|
||||
schema.example != null
|
||||
? html`<div class="meta">
|
||||
<span class="meta-label"
|
||||
>${this._label("example", "Example:")}</span
|
||||
>
|
||||
<code>${String(schema.example)}</code>
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
schema.default != null
|
||||
? html`<div class="meta">
|
||||
<span class="meta-label"
|
||||
>${this._label("default", "Default:")}</span
|
||||
>
|
||||
<code>${String(schema.default)}</code>
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
private _label(
|
||||
key: "required" | "example" | "default",
|
||||
fallback: string
|
||||
): string {
|
||||
return (
|
||||
this.localize?.(`ui.components.yaml-editor.schema.${key}`) || fallback
|
||||
);
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
padding: 6px 10px;
|
||||
max-width: 320px;
|
||||
line-height: 1.5;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
code.key {
|
||||
font-family: var(--ha-font-family-code);
|
||||
font-size: 1em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
border-radius: 4px;
|
||||
padding: 0 5px;
|
||||
font-size: 0.78em;
|
||||
line-height: 1.6;
|
||||
font-family: var(--ha-font-family-body);
|
||||
}
|
||||
|
||||
.badge.required {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--error-color, #db4437) 15%,
|
||||
transparent
|
||||
);
|
||||
color: var(--error-color, #db4437);
|
||||
}
|
||||
|
||||
.badge.type {
|
||||
background: color-mix(in srgb, var(--primary-color) 12%, transparent);
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
.desc {
|
||||
color: var(--secondary-text-color);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: baseline;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
font-size: 0.85em;
|
||||
opacity: 0.75;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.meta code {
|
||||
font-family: var(--ha-font-family-code);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-code-editor-yaml-hover": HaCodeEditorYamlHover;
|
||||
}
|
||||
}
|
||||
+147
-123
@@ -32,16 +32,21 @@ import { consume } from "@lit/context";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { stopPropagation } from "../common/dom/stop_propagation";
|
||||
import { getEntityContext } from "../common/entity/context/get_entity_context";
|
||||
import { computeDeviceName } from "../common/entity/compute_device_name";
|
||||
import { computeEntityName } from "../common/entity/compute_entity_name";
|
||||
import { computeAreaName } from "../common/entity/compute_area_name";
|
||||
import { computeFloorName } from "../common/entity/compute_floor_name";
|
||||
import { copyToClipboard } from "../common/util/copy-clipboard";
|
||||
import { haStyleScrollbar } from "../resources/styles";
|
||||
import {
|
||||
buildEntityCompletions,
|
||||
buildDeviceCompletions,
|
||||
buildAreaCompletions,
|
||||
buildFloorCompletions,
|
||||
buildLabelCompletions,
|
||||
} from "../resources/ha_completion_items";
|
||||
import type {
|
||||
JinjaArgType,
|
||||
HassArgHoverContext,
|
||||
} from "../resources/jinja_ha_completions";
|
||||
import type { YamlFieldSchemaMap } from "../resources/yaml_field_schema";
|
||||
import "./ha-code-editor-yaml-hover";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { showToast } from "../util/toast";
|
||||
import { documentationUrl } from "../util/documentation-url";
|
||||
@@ -90,6 +95,14 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
|
||||
@property() public mode = "yaml";
|
||||
|
||||
/**
|
||||
* Optional field schema for YAML mode. When set, the editor will provide
|
||||
* field-aware key/value completions, hover tooltips, and linting for the
|
||||
* known fields described by this map.
|
||||
*/
|
||||
@property({ attribute: false })
|
||||
public yamlFieldSchema?: YamlFieldSchemaMap;
|
||||
|
||||
// eslint-disable-next-line lit/no-native-attributes
|
||||
@property({ type: Boolean }) public autofocus = false;
|
||||
|
||||
@@ -169,6 +182,12 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
|
||||
private _completionInfoDestroy?: () => void;
|
||||
|
||||
// Stored YAML syntax error set by setYamlError(); consumed by _yamlSyntaxLinter.
|
||||
private _yamlSyntaxError: {
|
||||
mark?: { position: number; line: number; column: number };
|
||||
reason?: string;
|
||||
} | null = null;
|
||||
|
||||
private _completionInfoRequest = 0;
|
||||
|
||||
private _completionInfoKey?: string;
|
||||
@@ -203,6 +222,10 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
* Push a YAML parse error (or null to clear) into the lint gutter as a
|
||||
* diagnostic. Avoids re-parsing the document — the caller (ha-yaml-editor)
|
||||
* already has the error from its own js-yaml load() call.
|
||||
*
|
||||
* Stores the error and triggers forceLinting() so the yamlLintCompartment
|
||||
* linter re-runs and returns it as a diagnostic — rather than calling
|
||||
* setDiagnostics() which would wipe diagnostics from other linters.
|
||||
*/
|
||||
public setYamlError(
|
||||
err: {
|
||||
@@ -210,27 +233,10 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
reason?: string;
|
||||
} | null
|
||||
): void {
|
||||
if (!this.codemirror || !this._loadedCodeMirror) return;
|
||||
let diagnostics: {
|
||||
from: number;
|
||||
to: number;
|
||||
severity: "error";
|
||||
message: string;
|
||||
}[] = [];
|
||||
if (err) {
|
||||
const doc = this.codemirror.state.doc;
|
||||
const pos = err.mark ? Math.min(err.mark.position, doc.length) : 0;
|
||||
const line = doc.lineAt(pos);
|
||||
const message = `${
|
||||
err.reason ||
|
||||
this._i18n?.localize("ui.components.yaml-editor.error") ||
|
||||
"YAML syntax error"
|
||||
}${err.mark ? ` (${this._i18n?.localize("ui.components.yaml-editor.error_location", { line: err.mark.line + 1, column: err.mark.column + 1 })})` : ""}`;
|
||||
diagnostics = [{ from: pos, to: line.to, severity: "error", message }];
|
||||
this._yamlSyntaxError = err;
|
||||
if (this.codemirror && this._loadedCodeMirror) {
|
||||
this._loadedCodeMirror.forceLinting(this.codemirror);
|
||||
}
|
||||
this.codemirror.dispatch(
|
||||
this._loadedCodeMirror.setDiagnostics(this.codemirror.state, diagnostics)
|
||||
);
|
||||
}
|
||||
|
||||
public connectedCallback() {
|
||||
@@ -291,9 +297,7 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
effects: [
|
||||
this._loadedCodeMirror!.langCompartment!.reconfigure(this._mode),
|
||||
this._loadedCodeMirror!.yamlLintCompartment!.reconfigure(
|
||||
this.lint && !this.readOnly
|
||||
? [this._loadedCodeMirror!.lintGutter()]
|
||||
: []
|
||||
this._buildYamlSyntaxLinter()
|
||||
),
|
||||
],
|
||||
});
|
||||
@@ -305,20 +309,23 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
this._loadedCodeMirror!.EditorView!.editable.of(!this.readOnly)
|
||||
),
|
||||
this._loadedCodeMirror!.yamlLintCompartment!.reconfigure(
|
||||
this.lint && !this.readOnly
|
||||
? [this._loadedCodeMirror!.lintGutter()]
|
||||
: []
|
||||
this._buildYamlSyntaxLinter()
|
||||
),
|
||||
],
|
||||
});
|
||||
this._updateToolbarButtons();
|
||||
}
|
||||
if (changedProps.has("lint")) {
|
||||
if (changedProps.has("lint") || changedProps.has("yamlFieldSchema")) {
|
||||
transactions.push({
|
||||
effects: this._loadedCodeMirror!.yamlLintCompartment!.reconfigure(
|
||||
this.lint && !this.readOnly
|
||||
? [this._loadedCodeMirror!.lintGutter()]
|
||||
: []
|
||||
this._buildYamlSyntaxLinter()
|
||||
),
|
||||
});
|
||||
}
|
||||
if (changedProps.has("yamlFieldSchema") || changedProps.has("readOnly")) {
|
||||
transactions.push({
|
||||
effects: this._loadedCodeMirror!.yamlSchemaCompartment!.reconfigure(
|
||||
this._buildSchemaLinter()
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -372,6 +379,65 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
return this._loadedCodeMirror!.langs[this.mode];
|
||||
}
|
||||
|
||||
private _buildSchemaLinter() {
|
||||
if (!this._loadedCodeMirror || !this.yamlFieldSchema || this.readOnly) {
|
||||
return [];
|
||||
}
|
||||
const schema = this.yamlFieldSchema;
|
||||
return [
|
||||
this._loadedCodeMirror.linter(
|
||||
(view) =>
|
||||
this._loadedCodeMirror!.haYamlLintSource(
|
||||
view,
|
||||
schema,
|
||||
this._i18n?.localize
|
||||
),
|
||||
{ delay: 500 }
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the yamlLintCompartment extensions: a linter that surfaces the
|
||||
* stored _yamlSyntaxError (set by setYamlError), plus the lint gutter when
|
||||
* either syntax linting or schema linting is active.
|
||||
*
|
||||
* Using a linter() instead of setDiagnostics() means this linter's
|
||||
* diagnostics are managed independently of the schema linter's diagnostics —
|
||||
* they don't overwrite each other.
|
||||
*/
|
||||
private _buildYamlSyntaxLinter() {
|
||||
if (this.readOnly) return [];
|
||||
const showGutter = this.lint || !!this.yamlFieldSchema;
|
||||
const extensions: Extension[] = [];
|
||||
if (showGutter) {
|
||||
extensions.push(this._loadedCodeMirror!.lintGutter());
|
||||
}
|
||||
if (this.lint) {
|
||||
extensions.push(
|
||||
this._loadedCodeMirror!.linter(
|
||||
(view) => {
|
||||
const err = this._yamlSyntaxError;
|
||||
if (!err) return [];
|
||||
const doc = view.state.doc;
|
||||
const pos = err.mark ? Math.min(err.mark.position, doc.length) : 0;
|
||||
const line = doc.lineAt(pos);
|
||||
const message = `${
|
||||
err.reason ||
|
||||
this._i18n?.localize("ui.components.yaml-editor.error") ||
|
||||
"YAML syntax error"
|
||||
}${err.mark ? ` (${this._i18n?.localize("ui.components.yaml-editor.error_location", { line: err.mark.line + 1, column: err.mark.column + 1 })})` : ""}`;
|
||||
return [
|
||||
{ from: pos, to: line.to, severity: "error" as const, message },
|
||||
];
|
||||
},
|
||||
{ delay: 0 }
|
||||
)
|
||||
);
|
||||
}
|
||||
return extensions;
|
||||
}
|
||||
|
||||
private _createCodeMirror() {
|
||||
if (!this._loadedCodeMirror) {
|
||||
throw new Error("Cannot create editor before CodeMirror is loaded");
|
||||
@@ -420,7 +486,10 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
this.linewrap ? this._loadedCodeMirror.EditorView.lineWrapping : []
|
||||
),
|
||||
this._loadedCodeMirror.yamlLintCompartment.of(
|
||||
this.lint && !this.readOnly ? [this._loadedCodeMirror.lintGutter()] : []
|
||||
this._buildYamlSyntaxLinter()
|
||||
),
|
||||
this._loadedCodeMirror.yamlSchemaCompartment.of(
|
||||
this._buildSchemaLinter()
|
||||
),
|
||||
this._loadedCodeMirror.EditorView.updateListener.of(this._onUpdate),
|
||||
this._loadedCodeMirror.tooltips({
|
||||
@@ -436,6 +505,21 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
),
|
||||
{ hoverTime: 300 }
|
||||
),
|
||||
...(this.mode === "yaml"
|
||||
? [
|
||||
this._loadedCodeMirror.hoverTooltip(
|
||||
(view, pos) =>
|
||||
this.yamlFieldSchema
|
||||
? this._loadedCodeMirror!.haYamlHoverSource(view, pos, {
|
||||
schema: this.yamlFieldSchema,
|
||||
localize: this._i18n?.localize,
|
||||
hassContext: this._hassArgHoverContext(),
|
||||
})
|
||||
: null,
|
||||
{ hoverTime: 300 }
|
||||
),
|
||||
]
|
||||
: []),
|
||||
...(this.placeholder ? [placeholder(this.placeholder)] : []),
|
||||
];
|
||||
|
||||
@@ -443,6 +527,19 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
const completionSources: CompletionSource[] = [
|
||||
this._loadedCodeMirror.haJinjaCompletionSource,
|
||||
];
|
||||
if (this.mode === "yaml") {
|
||||
completionSources.push(
|
||||
this._loadedCodeMirror.haYamlCompletionSource(() => ({
|
||||
schema: this.yamlFieldSchema,
|
||||
localize: this._i18n?.localize,
|
||||
states: this._states,
|
||||
devices: this._registries?.devices,
|
||||
areas: this._registries?.areas,
|
||||
floors: this._registries?.floors,
|
||||
labels: this._labels,
|
||||
}))
|
||||
);
|
||||
}
|
||||
if (this.autocompleteEntities) {
|
||||
completionSources.push(this._entityCompletions.bind(this));
|
||||
}
|
||||
@@ -453,6 +550,7 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
this._loadedCodeMirror.autocompletion({
|
||||
override: completionSources,
|
||||
maxRenderedOptions: 10,
|
||||
activateOnCompletion: (completion) => completion.type === "yaml-key",
|
||||
}),
|
||||
this._loadedCodeMirror.closeBrackets(),
|
||||
this._loadedCodeMirror.closeBracketsOverride,
|
||||
@@ -753,19 +851,6 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
this._states![key]
|
||||
);
|
||||
|
||||
const entityName = computeEntityName(
|
||||
this._states![key],
|
||||
this._registries!.entities,
|
||||
this._registries!.devices
|
||||
);
|
||||
const deviceName = context.device
|
||||
? computeDeviceName(context.device)
|
||||
: undefined;
|
||||
const areaName = context.area ? computeAreaName(context.area) : undefined;
|
||||
const floorName = context.floor
|
||||
? computeFloorName(context.floor)
|
||||
: undefined;
|
||||
|
||||
const completionItems: CompletionItem[] = [
|
||||
{
|
||||
label: this._i18n!.localize(
|
||||
@@ -773,39 +858,31 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
),
|
||||
value: formattedState,
|
||||
subValue:
|
||||
// If the state exactly matches the formatted state, don't show the raw state
|
||||
this._states![key].state === formattedState
|
||||
? undefined
|
||||
: this._states![key].state,
|
||||
},
|
||||
];
|
||||
|
||||
if (entityName) {
|
||||
completionItems.push({
|
||||
label: this._i18n!.localize(
|
||||
"ui.components.entity.entity-picker.entity"
|
||||
),
|
||||
value: entityName,
|
||||
});
|
||||
}
|
||||
|
||||
if (deviceName) {
|
||||
if (context.device && context.device.name) {
|
||||
completionItems.push({
|
||||
label: this._i18n!.localize("ui.components.device-picker.device"),
|
||||
value: deviceName,
|
||||
value: context.device.name,
|
||||
});
|
||||
}
|
||||
|
||||
if (areaName) {
|
||||
if (context.area && context.area.name) {
|
||||
completionItems.push({
|
||||
label: this._i18n!.localize("ui.components.area-picker.area"),
|
||||
value: areaName,
|
||||
value: context.area.name,
|
||||
});
|
||||
}
|
||||
|
||||
if (floorName) {
|
||||
if (context.floor && context.floor.name) {
|
||||
completionItems.push({
|
||||
label: this._i18n!.localize("ui.components.floor-picker.floor"),
|
||||
value: floorName,
|
||||
value: context.floor.name,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1029,23 +1106,9 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
});
|
||||
};
|
||||
|
||||
private _getStates = memoizeOne((states: HassEntities): Completion[] => {
|
||||
if (!states) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const options = Object.keys(states).map((key) => ({
|
||||
type: "variable",
|
||||
label: states[key].attributes.friendly_name
|
||||
? `${states[key].attributes.friendly_name} ${key}` // label is used for searching, so include both name and entity_id here
|
||||
: key,
|
||||
displayLabel: key,
|
||||
detail: states[key].attributes.friendly_name,
|
||||
apply: key,
|
||||
}));
|
||||
|
||||
return options;
|
||||
});
|
||||
private _getStates = memoizeOne((states: HassEntities): Completion[] =>
|
||||
buildEntityCompletions(states)
|
||||
);
|
||||
|
||||
// Map of HA Jinja function name → (arg index → JinjaArgType).
|
||||
// Derived from the snippet definitions in jinja_ha_completions.ts.
|
||||
@@ -1440,18 +1503,7 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
|
||||
private _getDevices = memoizeOne(
|
||||
(devices: HomeAssistant["devices"]): Completion[] =>
|
||||
Object.values(devices)
|
||||
.filter((device) => !device.disabled_by)
|
||||
.map((device) => {
|
||||
const name = computeDeviceName(device);
|
||||
return {
|
||||
type: "variable",
|
||||
label: `${name} ${device.id}`,
|
||||
displayLabel: name ?? device.id,
|
||||
detail: device.id,
|
||||
apply: device.id,
|
||||
};
|
||||
})
|
||||
buildDeviceCompletions(devices)
|
||||
);
|
||||
|
||||
/** Build a CompletionResult for device IDs, with `from` set inside the quotes. */
|
||||
@@ -1470,17 +1522,7 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
}
|
||||
|
||||
private _getAreas = memoizeOne(
|
||||
(areas: HomeAssistant["areas"]): Completion[] =>
|
||||
Object.values(areas).map((area) => {
|
||||
const name = computeAreaName(area) ?? area.area_id;
|
||||
return {
|
||||
type: "variable",
|
||||
label: `${name} ${area.area_id}`, // label is used for searching, so include both name and ID here
|
||||
displayLabel: name,
|
||||
detail: area.area_id,
|
||||
apply: area.area_id,
|
||||
};
|
||||
})
|
||||
(areas: HomeAssistant["areas"]): Completion[] => buildAreaCompletions(areas)
|
||||
);
|
||||
|
||||
/** Build a CompletionResult for area IDs, with `from` set inside the quotes. */
|
||||
@@ -1500,16 +1542,7 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
|
||||
private _getFloors = memoizeOne(
|
||||
(floors: HomeAssistant["floors"]): Completion[] =>
|
||||
Object.values(floors).map((floor) => {
|
||||
const name = computeFloorName(floor) ?? floor.floor_id;
|
||||
return {
|
||||
type: "variable",
|
||||
label: `${name} ${floor.floor_id}`, // label is used for searching, so include both name and ID here
|
||||
displayLabel: name,
|
||||
detail: floor.floor_id,
|
||||
apply: floor.floor_id,
|
||||
};
|
||||
})
|
||||
buildFloorCompletions(floors)
|
||||
);
|
||||
|
||||
/** Build a CompletionResult for floor IDs, with `from` set inside the quotes. */
|
||||
@@ -1529,16 +1562,7 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
|
||||
private _getLabels = memoizeOne(
|
||||
(labels: LabelRegistryEntry[]): Completion[] =>
|
||||
labels.map((label) => {
|
||||
const name = label.name.trim() || label.label_id;
|
||||
return {
|
||||
type: "variable",
|
||||
label: `${name} ${label.label_id}`, // label is used for searching, so include both name and ID here
|
||||
displayLabel: name,
|
||||
detail: label.label_id,
|
||||
apply: label.label_id,
|
||||
};
|
||||
})
|
||||
buildLabelCompletions(labels)
|
||||
);
|
||||
|
||||
/** Build a CompletionResult for label IDs, with `from` set inside the quotes. */
|
||||
|
||||
@@ -1,315 +0,0 @@
|
||||
import { DIRECTION_ALL, Manager, Pan, Tap } from "@egjs/hammerjs";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { rgb2hex } from "../common/color/convert-color";
|
||||
import {
|
||||
DEFAULT_MAX_KELVIN,
|
||||
DEFAULT_MIN_KELVIN,
|
||||
temperature2rgb,
|
||||
} from "../common/color/convert-light-color";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { mainWindow } from "../common/dom/get_main_window";
|
||||
import { generateColorTemperatureGradient } from "../dialogs/more-info/components/lights/light-color-temp-picker";
|
||||
|
||||
const clamp = (value: number, min: number, max: number) =>
|
||||
Math.min(max, Math.max(min, value));
|
||||
|
||||
// Dark end of the vertical brightness gradient; not fully black so the
|
||||
// temperature hue stays visible at the bottom of the pad
|
||||
const PAD_DARK_COLOR = "rgb(26, 26, 26)";
|
||||
|
||||
// The marker color's luminance scales with brightness down to this floor,
|
||||
// so the marker never renders fully black at minimum brightness
|
||||
const MARKER_LUMINANCE_FLOOR = 0.2;
|
||||
|
||||
@customElement("ha-color-temp-brightness-picker")
|
||||
export class HaColorTempBrightnessPicker extends LitElement {
|
||||
@property({ type: Boolean, reflect: true })
|
||||
public disabled = false;
|
||||
|
||||
/** [color temperature in kelvin, brightness percentage] */
|
||||
@property({ attribute: false })
|
||||
public value?: [number, number];
|
||||
|
||||
@property({ attribute: false })
|
||||
public minKelvin?: number;
|
||||
|
||||
@property({ attribute: false })
|
||||
public maxKelvin?: number;
|
||||
|
||||
@query("#pad") private _pad?: HTMLElement;
|
||||
|
||||
private _mc?: HammerManager;
|
||||
|
||||
@state()
|
||||
private _pressed?: string;
|
||||
|
||||
// Normalized physical position on the pad, [0, 1] left-to-right/top-to-bottom
|
||||
@state()
|
||||
private _cursorPosition?: [number, number];
|
||||
|
||||
@state()
|
||||
private _localValue?: [number, number];
|
||||
|
||||
private get _minKelvin() {
|
||||
return this.minKelvin ?? DEFAULT_MIN_KELVIN;
|
||||
}
|
||||
|
||||
private get _maxKelvin() {
|
||||
return this.maxKelvin ?? DEFAULT_MAX_KELVIN;
|
||||
}
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues<this>): void {
|
||||
super.firstUpdated(changedProps);
|
||||
this._setupListeners();
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this._setupListeners();
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this._destroyListeners();
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues<this>): void {
|
||||
super.willUpdate(changedProps);
|
||||
const rangeChanged =
|
||||
changedProps.has("minKelvin") || changedProps.has("maxKelvin");
|
||||
if (
|
||||
rangeChanged ||
|
||||
(changedProps.has("value") &&
|
||||
(this._localValue?.[0] !== this.value?.[0] ||
|
||||
this._localValue?.[1] !== this.value?.[1]))
|
||||
) {
|
||||
this._resetPosition();
|
||||
}
|
||||
}
|
||||
|
||||
private _setupListeners() {
|
||||
if (this._pad && !this._mc) {
|
||||
this._mc = new Manager(this._pad);
|
||||
this._mc.add(
|
||||
new Pan({
|
||||
direction: DIRECTION_ALL,
|
||||
enable: true,
|
||||
})
|
||||
);
|
||||
|
||||
this._mc.add(new Tap({ event: "singletap" }));
|
||||
|
||||
let savedPosition;
|
||||
this._mc.on("panstart", (e) => {
|
||||
if (this.disabled) return;
|
||||
this._pressed = e.pointerType;
|
||||
savedPosition = this._cursorPosition;
|
||||
});
|
||||
this._mc.on("pancancel", () => {
|
||||
if (this.disabled) return;
|
||||
this._pressed = undefined;
|
||||
this._cursorPosition = savedPosition;
|
||||
});
|
||||
this._mc.on("panmove", (e) => {
|
||||
if (this.disabled) return;
|
||||
this._cursorPosition = this._getPositionFromEvent(e);
|
||||
this._localValue = this._getValueFromCoord(...this._cursorPosition);
|
||||
fireEvent(this, "cursor-moved", { value: this._localValue });
|
||||
});
|
||||
this._mc.on("panend", (e) => {
|
||||
if (this.disabled) return;
|
||||
this._pressed = undefined;
|
||||
this._cursorPosition = this._getPositionFromEvent(e);
|
||||
this._localValue = this._getValueFromCoord(...this._cursorPosition);
|
||||
fireEvent(this, "cursor-moved", { value: undefined });
|
||||
fireEvent(this, "value-changed", { value: this._localValue });
|
||||
});
|
||||
|
||||
this._mc.on("singletap", (e) => {
|
||||
if (this.disabled) return;
|
||||
this._cursorPosition = this._getPositionFromEvent(e);
|
||||
this._localValue = this._getValueFromCoord(...this._cursorPosition);
|
||||
fireEvent(this, "value-changed", { value: this._localValue });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private _destroyListeners() {
|
||||
if (this._mc) {
|
||||
this._mc.destroy();
|
||||
this._mc = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _resetPosition() {
|
||||
if (this.value === undefined) {
|
||||
this._cursorPosition = undefined;
|
||||
this._localValue = undefined;
|
||||
return;
|
||||
}
|
||||
this._cursorPosition = this._getCoordsFromValue(this.value);
|
||||
this._localValue = this.value;
|
||||
}
|
||||
|
||||
private get _rtl() {
|
||||
return mainWindow.document.dir === "rtl";
|
||||
}
|
||||
|
||||
private _getCoordsFromValue = (value: [number, number]): [number, number] => {
|
||||
const xValue =
|
||||
(clamp(value[0], this._minKelvin, this._maxKelvin) - this._minKelvin) /
|
||||
(this._maxKelvin - this._minKelvin);
|
||||
const x = this._rtl ? 1 - xValue : xValue;
|
||||
const y = 1 - clamp(value[1], 1, 100) / 100;
|
||||
return [x, y];
|
||||
};
|
||||
|
||||
private _getValueFromCoord = (x: number, y: number): [number, number] => {
|
||||
const xValue = this._rtl ? 1 - x : x;
|
||||
const kelvin = Math.round(
|
||||
this._minKelvin + xValue * (this._maxKelvin - this._minKelvin)
|
||||
);
|
||||
const brightness = clamp(Math.round((1 - y) * 100), 1, 100);
|
||||
return [kelvin, brightness];
|
||||
};
|
||||
|
||||
private _getPositionFromEvent = (e: HammerInput): [number, number] => {
|
||||
const boundingRect = this._pad!.getBoundingClientRect();
|
||||
const x = clamp(
|
||||
(e.center.x - boundingRect.left) / boundingRect.width,
|
||||
0,
|
||||
1
|
||||
);
|
||||
const y = clamp(
|
||||
(e.center.y - boundingRect.top) / boundingRect.height,
|
||||
0,
|
||||
1
|
||||
);
|
||||
return [x, y];
|
||||
};
|
||||
|
||||
private _generateTemperatureGradient = memoizeOne(
|
||||
(min: number, max: number) => generateColorTemperatureGradient(min, max)
|
||||
);
|
||||
|
||||
render() {
|
||||
const gradient = this._generateTemperatureGradient(
|
||||
this._minKelvin,
|
||||
this._maxKelvin
|
||||
);
|
||||
|
||||
const [x, y] = this._cursorPosition ?? [0, 0];
|
||||
|
||||
const markerScale = this._pressed
|
||||
? this._pressed === "touch"
|
||||
? "2.5"
|
||||
: "1.5"
|
||||
: "1";
|
||||
const markerOffset = this._pressed === "touch" ? "0px, -24px" : "0px, 0px";
|
||||
|
||||
let markerColor = "#ffffff";
|
||||
if (this._localValue) {
|
||||
const rgb = temperature2rgb(this._localValue[0]);
|
||||
const factor =
|
||||
MARKER_LUMINANCE_FLOOR +
|
||||
(1 - MARKER_LUMINANCE_FLOOR) * (this._localValue[1] / 100);
|
||||
markerColor = rgb2hex([
|
||||
Math.round(rgb[0] * factor),
|
||||
Math.round(rgb[1] * factor),
|
||||
Math.round(rgb[2] * factor),
|
||||
]);
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="container ${classMap({ pressed: Boolean(this._pressed) })}">
|
||||
<div
|
||||
id="pad"
|
||||
style=${styleMap({
|
||||
"background-image": `linear-gradient(to top, ${PAD_DARK_COLOR}, white), linear-gradient(to ${this._rtl ? "left" : "right"}, ${gradient})`,
|
||||
})}
|
||||
></div>
|
||||
<div
|
||||
class="cursor"
|
||||
style=${styleMap({
|
||||
left: `${x * 100}%`,
|
||||
top: `${y * 100}%`,
|
||||
visibility: this._cursorPosition ? undefined : "hidden",
|
||||
})}
|
||||
>
|
||||
<div
|
||||
class="marker"
|
||||
style=${styleMap({
|
||||
"background-color": markerColor,
|
||||
transform: `translate(${markerOffset}) scale(${markerScale})`,
|
||||
})}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
outline: none;
|
||||
}
|
||||
.container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
}
|
||||
#pad {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
clip-path: inset(0 round var(--ha-border-radius-6xl));
|
||||
background-blend-mode: multiply;
|
||||
background-repeat: no-repeat;
|
||||
cursor: pointer;
|
||||
touch-action: none;
|
||||
}
|
||||
:host([disabled]) #pad {
|
||||
cursor: initial;
|
||||
filter: grayscale(1) opacity(0.5);
|
||||
}
|
||||
.cursor {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.marker {
|
||||
position: absolute;
|
||||
top: -16px;
|
||||
left: -16px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
border: 2px solid white;
|
||||
box-shadow:
|
||||
0 1px 4px rgba(0, 0, 0, 0.3),
|
||||
0 1px 6px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.container:not(.pressed) .marker {
|
||||
transition:
|
||||
transform var(--ha-animation-duration-instant) ease-in-out,
|
||||
background-color var(--ha-animation-duration-instant) ease-in-out;
|
||||
}
|
||||
.container:not(.pressed) .cursor {
|
||||
transition:
|
||||
left var(--ha-animation-duration-fast) ease-in-out,
|
||||
top var(--ha-animation-duration-fast) ease-in-out;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-color-temp-brightness-picker": HaColorTempBrightnessPicker;
|
||||
}
|
||||
}
|
||||
@@ -143,8 +143,7 @@ export class HaControlSelect extends LitElement {
|
||||
? repeat(
|
||||
this.options,
|
||||
(option) => option.value,
|
||||
(option, index) =>
|
||||
this._renderOption(option, index === this._tabbableIndex)
|
||||
(option) => this._renderOption(option)
|
||||
)
|
||||
: nothing
|
||||
}
|
||||
@@ -152,14 +151,7 @@ export class HaControlSelect extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
/* a radio group with no selection puts its first option in the tab sequence */
|
||||
private get _tabbableIndex() {
|
||||
const selectedIndex =
|
||||
this.options?.findIndex((option) => option.value === this.value) ?? -1;
|
||||
return selectedIndex === -1 ? 0 : selectedIndex;
|
||||
}
|
||||
|
||||
private _renderOption(option: ControlSelectOption, tabbable: boolean) {
|
||||
private _renderOption(option: ControlSelectOption) {
|
||||
const isSelected = this.value === option.value;
|
||||
|
||||
return html`
|
||||
@@ -170,7 +162,7 @@ export class HaControlSelect extends LitElement {
|
||||
selected: isSelected,
|
||||
})}
|
||||
role="radio"
|
||||
tabindex=${tabbable ? "0" : "-1"}
|
||||
tabindex=${isSelected ? "0" : "-1"}
|
||||
.value=${option.value}
|
||||
aria-checked=${isSelected ? "true" : "false"}
|
||||
aria-label=${ifDefined(option.ariaLabel ?? option.label)}
|
||||
|
||||
@@ -68,7 +68,6 @@ export class HaDurationInput extends LitElement {
|
||||
{ label: "-", iconPath: mdiMinusThick, value: "-" },
|
||||
]}
|
||||
.active=${this._negative ? "-" : "+"}
|
||||
.disabled=${this.disabled}
|
||||
@value-changed=${this._negativeChanged}
|
||||
></ha-button-toggle-group>
|
||||
`
|
||||
@@ -236,8 +235,8 @@ export class HaDurationInput extends LitElement {
|
||||
ev.stopPropagation();
|
||||
const negative = (ev.detail?.value || ev.target.value) === "-";
|
||||
this._toggleNegative = negative;
|
||||
if (this.data) {
|
||||
const value = { ...this.data };
|
||||
const value = this.data;
|
||||
if (value) {
|
||||
FIELDS.forEach((t) => {
|
||||
if (value[t]) {
|
||||
value[t] = negative ? -Math.abs(value[t]) : Math.abs(value[t]);
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import "./ha-svg-icon";
|
||||
|
||||
/**
|
||||
* Centered placeholder for a surface that has nothing to show, with an icon, a
|
||||
* heading, an optional description and optional actions.
|
||||
*
|
||||
* @slot - Actions that help the user fill the surface, e.g. a button.
|
||||
*/
|
||||
@customElement("ha-empty-state")
|
||||
export class HaEmptyState extends LitElement {
|
||||
/** SVG path of the icon shown above the heading. */
|
||||
@property() public icon?: string;
|
||||
|
||||
@property() public heading?: string;
|
||||
|
||||
@property() public description?: string;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<div class="content">
|
||||
${
|
||||
this.icon
|
||||
? html`<ha-svg-icon .path=${this.icon}></ha-svg-icon>`
|
||||
: nothing
|
||||
}
|
||||
${this.heading ? html`<h2>${this.heading}</h2>` : nothing}
|
||||
${this.description ? html`<p>${this.description}</p>` : nothing}
|
||||
<slot></slot>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-4);
|
||||
box-sizing: border-box;
|
||||
max-width: 500px;
|
||||
padding: var(--ha-space-8) var(--ha-space-4);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
ha-svg-icon {
|
||||
--mdc-icon-size: var(--ha-empty-state-icon-size, 64px);
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: var(--ha-font-size-xl);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-empty-state": HaEmptyState;
|
||||
}
|
||||
}
|
||||
@@ -1,287 +0,0 @@
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import type { SelectedDetail } from "@material/mwc-list";
|
||||
import { mdiFilterVariantRemove } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import { fireEvent, type HASSDomEvent } from "../common/dom/fire_event";
|
||||
import { computeStateDomain } from "../common/entity/compute_state_domain";
|
||||
import { stringCompare } from "../common/string/compare";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import { internationalizationContext, statesContext } from "../data/context";
|
||||
import { haStyleScrollbar } from "../resources/styles";
|
||||
import "./ha-check-list-item";
|
||||
import "./ha-domain-icon";
|
||||
import "./ha-expansion-panel";
|
||||
import "./ha-icon-button";
|
||||
import "./ha-list";
|
||||
import "./input/ha-input-search";
|
||||
import type { HaInputSearch } from "./input/ha-input-search";
|
||||
|
||||
interface DeviceClassItem {
|
||||
deviceClass: string;
|
||||
domain: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
@customElement("ha-filter-device-classes")
|
||||
export class HaFilterDeviceClasses extends LitElement {
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
|
||||
@consume({ context: statesContext, subscribe: true })
|
||||
@state()
|
||||
private _states!: ContextType<typeof statesContext>;
|
||||
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
@state()
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@property({ attribute: false }) public value?: string[];
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public expanded = false;
|
||||
|
||||
@state() private _shouldRender = false;
|
||||
|
||||
@state() private _filter?: string;
|
||||
|
||||
@query("ha-list") private _list?: HTMLElement;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-expansion-panel
|
||||
left-chevron
|
||||
.expanded=${this.expanded}
|
||||
@expanded-will-change=${this._expandedWillChange}
|
||||
@expanded-changed=${this._expandedChanged}
|
||||
>
|
||||
<div slot="header" class="header">
|
||||
${this._localize("ui.components.filter-device-classes.caption")}
|
||||
${
|
||||
this.value?.length
|
||||
? html`<div class="badge">${this.value?.length}</div>
|
||||
<ha-icon-button
|
||||
.path=${mdiFilterVariantRemove}
|
||||
@click=${this._clearFilter}
|
||||
></ha-icon-button>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
${
|
||||
this._shouldRender
|
||||
? html`<ha-input-search
|
||||
appearance="outlined"
|
||||
.value=${this._filter}
|
||||
@input=${this._handleSearchChange}
|
||||
>
|
||||
</ha-input-search>
|
||||
<ha-list
|
||||
class="ha-scrollbar"
|
||||
@selected=${this._handleItemSelected}
|
||||
multi
|
||||
>
|
||||
${repeat(
|
||||
this._deviceClasses(
|
||||
this._states,
|
||||
this._localize,
|
||||
this._i18n.locale.language,
|
||||
this._filter
|
||||
),
|
||||
(item) => item.deviceClass,
|
||||
(item) =>
|
||||
html`<ha-check-list-item
|
||||
.value=${item.deviceClass}
|
||||
.selected=${(this.value || []).includes(item.deviceClass)}
|
||||
graphic="icon"
|
||||
>
|
||||
<ha-domain-icon
|
||||
slot="graphic"
|
||||
.domain=${item.domain}
|
||||
.deviceClass=${item.deviceClass}
|
||||
.state=${item.domain === "binary_sensor" ? "on" : undefined}
|
||||
></ha-domain-icon>
|
||||
${item.name}
|
||||
</ha-check-list-item>`
|
||||
)}
|
||||
</ha-list> `
|
||||
: nothing
|
||||
}
|
||||
</ha-expansion-panel>
|
||||
`;
|
||||
}
|
||||
|
||||
private _deviceClasses = memoizeOne(
|
||||
(
|
||||
states: ContextType<typeof statesContext>,
|
||||
localize: LocalizeFunc,
|
||||
language: string | undefined,
|
||||
filter: string | undefined
|
||||
): DeviceClassItem[] =>
|
||||
this._deviceClassItems(this._deviceClassDomains(states), localize)
|
||||
.filter(
|
||||
(item) =>
|
||||
!filter ||
|
||||
item.deviceClass.toLowerCase().includes(filter) ||
|
||||
item.name.toLowerCase().includes(filter)
|
||||
)
|
||||
.sort((a, b) => stringCompare(a.name, b.name, language))
|
||||
);
|
||||
|
||||
private _deviceClassDomains = memoizeOne(
|
||||
(states: ContextType<typeof statesContext>): Map<string, string[]> => {
|
||||
const domains = new Map<string, string[]>();
|
||||
Object.values(states).forEach((stateObj) => {
|
||||
const deviceClass = stateObj.attributes.device_class;
|
||||
if (!deviceClass) {
|
||||
return;
|
||||
}
|
||||
const domain = computeStateDomain(stateObj);
|
||||
const known = domains.get(deviceClass);
|
||||
if (!known) {
|
||||
domains.set(deviceClass, [domain]);
|
||||
} else if (!known.includes(domain)) {
|
||||
known.push(domain);
|
||||
}
|
||||
});
|
||||
return domains;
|
||||
}
|
||||
);
|
||||
|
||||
private _deviceClassItems = memoizeOne(
|
||||
(
|
||||
deviceClassDomains: Map<string, string[]>,
|
||||
localize: LocalizeFunc
|
||||
): DeviceClassItem[] =>
|
||||
[...deviceClassDomains].map(([deviceClass, domains]) => {
|
||||
for (const domain of domains) {
|
||||
const name = localize(
|
||||
`component.${domain}.entity_component.${deviceClass}.name`
|
||||
);
|
||||
if (name) {
|
||||
return { deviceClass, domain, name };
|
||||
}
|
||||
}
|
||||
return { deviceClass, domain: domains[0], name: deviceClass };
|
||||
}),
|
||||
([domainsA, localizeA], [domainsB, localizeB]) =>
|
||||
localizeA === localizeB &&
|
||||
domainsA.size === domainsB.size &&
|
||||
[...domainsA].every(
|
||||
([deviceClass, domains]) =>
|
||||
domainsB.get(deviceClass)?.join() === domains.join()
|
||||
)
|
||||
);
|
||||
|
||||
protected updated(changed: PropertyValues<this>) {
|
||||
if (changed.has("expanded") && this.expanded) {
|
||||
setTimeout(() => {
|
||||
if (!this.expanded) return;
|
||||
this._list!.style.height = `${this.clientHeight - 49 - 4 - 32}px`;
|
||||
// 49px - height of a header + 1px
|
||||
// 4px - padding-top of the search-input
|
||||
// 32px - height of the search input
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
private _expandedWillChange(ev: HASSDomEvent<{ expanded: boolean }>) {
|
||||
this._shouldRender = ev.detail.expanded;
|
||||
}
|
||||
|
||||
private _expandedChanged(ev: HASSDomEvent<{ expanded: boolean }>) {
|
||||
this.expanded = ev.detail.expanded;
|
||||
}
|
||||
|
||||
private _handleItemSelected(ev: CustomEvent<SelectedDetail<Set<number>>>) {
|
||||
const deviceClasses = this._deviceClasses(
|
||||
this._states,
|
||||
this._localize,
|
||||
this._i18n.locale.language,
|
||||
this._filter
|
||||
);
|
||||
|
||||
const visible = new Set(deviceClasses.map((item) => item.deviceClass));
|
||||
const preserved = (this.value || []).filter((d) => !visible.has(d));
|
||||
const selected = [...ev.detail.index]
|
||||
.map((i) => deviceClasses[i]?.deviceClass)
|
||||
.filter((d): d is string => !!d);
|
||||
|
||||
this.value = [...preserved, ...selected];
|
||||
|
||||
fireEvent(this, "data-table-filter-changed", {
|
||||
value: this.value.length ? this.value : undefined,
|
||||
items: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
private _clearFilter(ev: Event) {
|
||||
ev.preventDefault();
|
||||
this.value = undefined;
|
||||
fireEvent(this, "data-table-filter-changed", {
|
||||
value: undefined,
|
||||
items: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
private _handleSearchChange(ev: InputEvent) {
|
||||
const target = ev.target as HaInputSearch;
|
||||
this._filter = (target.value ?? "").toLowerCase();
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleScrollbar,
|
||||
css`
|
||||
:host {
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
:host([expanded]) {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
ha-expansion-panel {
|
||||
--ha-card-border-radius: var(--ha-border-radius-square);
|
||||
--expansion-panel-content-padding: 0;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.header ha-icon-button {
|
||||
margin-inline-start: auto;
|
||||
margin-inline-end: 8px;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
margin-inline-start: 8px;
|
||||
margin-inline-end: initial;
|
||||
min-width: 16px;
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
font-size: var(--ha-font-size-xs);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
background-color: var(--primary-color);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
text-align: center;
|
||||
padding: 0px 2px;
|
||||
color: var(--text-primary-color);
|
||||
}
|
||||
ha-input-search {
|
||||
display: block;
|
||||
padding: var(--ha-space-1) var(--ha-space-2) 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-filter-device-classes": HaFilterDeviceClasses;
|
||||
}
|
||||
}
|
||||
@@ -216,7 +216,7 @@ export class HaFilterDomains extends LitElement {
|
||||
align-items: center;
|
||||
}
|
||||
.header ha-icon-button {
|
||||
margin-inline-start: auto;
|
||||
margin-inline-start: initial;
|
||||
margin-inline-end: 8px;
|
||||
}
|
||||
ha-check-list-item {
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import "./chips/ha-assist-chip";
|
||||
import "./ha-svg-icon";
|
||||
|
||||
/**
|
||||
* Chip that opens a filter pane, with a badge showing how many filters are
|
||||
* active.
|
||||
*/
|
||||
@customElement("ha-filter-pane-chip")
|
||||
export class HaFilterPaneChip extends LitElement {
|
||||
@property() public label = "";
|
||||
|
||||
/** SVG path of the leading icon. */
|
||||
@property() public path?: string;
|
||||
|
||||
/** Number of active filters, shown as a badge when there is at least one. */
|
||||
@property({ type: Number }) public count = 0;
|
||||
|
||||
@property({ type: Boolean }) public active = false;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-assist-chip
|
||||
.label=${this.label}
|
||||
.active=${this.active}
|
||||
.disabled=${this.disabled}
|
||||
>
|
||||
${
|
||||
this.path
|
||||
? html`<ha-svg-icon slot="icon" .path=${this.path}></ha-svg-icon>`
|
||||
: nothing
|
||||
}
|
||||
</ha-assist-chip>
|
||||
${this.count ? html`<div class="badge">${this.count}</div>` : nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
--ha-assist-chip-container-shape: 10px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
inset-inline-end: -4px;
|
||||
inset-inline-start: initial;
|
||||
min-width: 16px;
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
font-size: var(--ha-font-size-xs);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
background-color: var(--primary-color);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
text-align: center;
|
||||
padding: 0 2px;
|
||||
color: var(--text-primary-color);
|
||||
pointer-events: none;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-filter-pane-chip": HaFilterPaneChip;
|
||||
}
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
import { mdiFilterVariant, mdiFilterVariantRemove } from "@mdi/js";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import { haStyleScrollbar } from "../resources/styles";
|
||||
import "./ha-adaptive-dialog";
|
||||
import "./ha-button";
|
||||
import "./ha-dialog-footer";
|
||||
import "./ha-filter-pane-chip";
|
||||
import "./ha-icon-button";
|
||||
|
||||
/**
|
||||
* Filter pane for a filtered page: a column next to the content on wide
|
||||
* screens, a bottom sheet on narrow ones. Mirrors the filter pane of
|
||||
* `hass-tabs-subpage-data-table` for pages that are not a data table.
|
||||
*
|
||||
* The page keeps ownership of whether the pane is shown, so that it can also
|
||||
* open it from elsewhere (e.g. an empty state) and hide its own toolbar chip
|
||||
* while it is open.
|
||||
*
|
||||
* @slot - Filter panels, e.g. `ha-filter-domains`.
|
||||
*/
|
||||
@customElement("ha-filter-pane")
|
||||
export class HaFilterPane extends LitElement {
|
||||
@property({ type: Boolean, reflect: true }) public narrow = false;
|
||||
|
||||
/** Header label, defaults to "Filters". */
|
||||
@property() public label?: string;
|
||||
|
||||
/** SVG path of the header chip icon. */
|
||||
@property() public path = mdiFilterVariant;
|
||||
|
||||
/** Number of active filters, shows the clear button when above zero. */
|
||||
@property({ type: Number }) public count = 0;
|
||||
|
||||
/**
|
||||
* Number of results the current filters resolve to, shown on the narrow
|
||||
* confirm button. Leave undefined when the page shows everything.
|
||||
*/
|
||||
@property({ attribute: false }) public resultCount?: number;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
|
||||
protected render() {
|
||||
const label =
|
||||
this.label ?? this._localize("ui.components.subpage-data-table.filters");
|
||||
|
||||
if (this.narrow) {
|
||||
return html`
|
||||
<ha-adaptive-dialog
|
||||
open
|
||||
flexcontent
|
||||
.headerTitle=${label}
|
||||
@closed=${this._close}
|
||||
>
|
||||
${this._renderClearButton("headerActionItems")}
|
||||
<div class="sheet-content">
|
||||
<slot></slot>
|
||||
</div>
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button slot="primaryAction" data-dialog="close">
|
||||
${
|
||||
this.resultCount === undefined
|
||||
? this._localize("ui.common.close")
|
||||
: this._localize(
|
||||
"ui.components.subpage-data-table.show_results",
|
||||
{ number: this.resultCount }
|
||||
)
|
||||
}
|
||||
</ha-button>
|
||||
</ha-dialog-footer>
|
||||
</ha-adaptive-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="header">
|
||||
<ha-filter-pane-chip
|
||||
active
|
||||
.label=${label}
|
||||
.path=${this.path}
|
||||
.disabled=${this.disabled}
|
||||
@click=${this._close}
|
||||
></ha-filter-pane-chip>
|
||||
${this._renderClearButton()}
|
||||
</div>
|
||||
<div class="content ha-scrollbar">
|
||||
<slot></slot>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderClearButton(slot?: string) {
|
||||
if (!this.count) {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<ha-icon-button
|
||||
slot=${ifDefined(slot)}
|
||||
.path=${mdiFilterVariantRemove}
|
||||
.disabled=${this.disabled}
|
||||
.label=${this._localize("ui.components.subpage-data-table.clear_filter")}
|
||||
@click=${this._clear}
|
||||
></ha-icon-button>
|
||||
`;
|
||||
}
|
||||
|
||||
private _close() {
|
||||
fireEvent(this, "close-filter-pane");
|
||||
}
|
||||
|
||||
private _clear() {
|
||||
fireEvent(this, "clear-filter");
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleScrollbar,
|
||||
css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 0 0 var(--ha-filter-pane-width, 320px);
|
||||
width: var(--ha-filter-pane-width, 320px);
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
border-inline-end: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
/* The bottom sheet positions itself, so the pane takes no space. */
|
||||
:host([narrow]) {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--ha-space-4);
|
||||
box-sizing: border-box;
|
||||
height: 56px;
|
||||
flex-shrink: 0;
|
||||
padding: 0 16px;
|
||||
background: var(--primary-background-color);
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
.content,
|
||||
.sheet-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
ha-adaptive-dialog {
|
||||
--dialog-content-padding: 0;
|
||||
/* Fixed height so the sheet does not resize while filtering. */
|
||||
--ha-bottom-sheet-height: calc(100dvh - var(--ha-space-12));
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-filter-pane": HaFilterPane;
|
||||
}
|
||||
interface HASSDomEvents {
|
||||
"close-filter-pane": undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { DEFAULT_MIN_KELVIN } from "../../common/color/convert-light-color";
|
||||
import type { Selector } from "../../data/selector";
|
||||
|
||||
/**
|
||||
* Value a selector already displays when no field value is set.
|
||||
* Used when enabling an optional service/trigger/condition field.
|
||||
*/
|
||||
export const getSelectorFallbackValue = (selector: Selector): unknown => {
|
||||
if ("constant" in selector) {
|
||||
return selector.constant?.value;
|
||||
}
|
||||
if ("boolean" in selector) {
|
||||
return false;
|
||||
}
|
||||
if ("number" in selector) {
|
||||
return selector.number?.min ?? 0;
|
||||
}
|
||||
if ("color_temp" in selector) {
|
||||
if (selector.color_temp?.unit === "kelvin") {
|
||||
return selector.color_temp.min ?? DEFAULT_MIN_KELVIN;
|
||||
}
|
||||
return selector.color_temp?.min ?? selector.color_temp?.min_mireds ?? 153;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
@@ -43,8 +43,6 @@ const CUSTOM_ICONS: Record<string, () => Promise<string>> = {
|
||||
import("../resources/esphome-logo-svg").then((mod) => mod.mdiEsphomeLogo),
|
||||
matter: () =>
|
||||
import("../resources/matter-logo-svg").then((mod) => mod.mdiMatterLogo),
|
||||
mqtt: () =>
|
||||
import("../resources/mqtt-logo-svg").then((mod) => mod.mdiMqttLogo),
|
||||
};
|
||||
|
||||
@customElement("ha-icon")
|
||||
|
||||
@@ -16,13 +16,16 @@ import "../ha-input-helper-text";
|
||||
import "../ha-select-box";
|
||||
import type { SelectBoxOption } from "../ha-select-box";
|
||||
|
||||
const TRIGGER_BEHAVIORS: AutomationBehaviorTriggerMode[] = [
|
||||
export const TRIGGER_BEHAVIORS: AutomationBehaviorTriggerMode[] = [
|
||||
"each",
|
||||
"first",
|
||||
"all",
|
||||
];
|
||||
|
||||
const CONDITION_BEHAVIORS: AutomationBehaviorConditionMode[] = ["any", "all"];
|
||||
export const CONDITION_BEHAVIORS: AutomationBehaviorConditionMode[] = [
|
||||
"any",
|
||||
"all",
|
||||
];
|
||||
|
||||
@customElement("ha-selector-automation_behavior")
|
||||
export class HaSelectorAutomationBehavior extends LitElement {
|
||||
|
||||
@@ -81,7 +81,6 @@ export class HaChooseSelector extends LitElement {
|
||||
.required=${this.required}
|
||||
@value-changed=${this._handleValueChanged}
|
||||
.helper=${this.helper}
|
||||
.localizeValue=${this.localizeValue}
|
||||
></ha-selector>`;
|
||||
}
|
||||
|
||||
@@ -108,12 +107,7 @@ export class HaChooseSelector extends LitElement {
|
||||
: {
|
||||
[this._activeChoice!]: this.value,
|
||||
};
|
||||
const choice = ev.detail?.value || ev.target.value;
|
||||
this._activeChoice = choice;
|
||||
if (choice && "constant" in this.selector.choose.choices[choice].selector) {
|
||||
value[choice] =
|
||||
this.selector.choose.choices[choice].selector.constant?.value;
|
||||
}
|
||||
this._activeChoice = ev.detail?.value || ev.target.value;
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
...value,
|
||||
|
||||
@@ -35,7 +35,6 @@ import {
|
||||
} from "../data/selector";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../types";
|
||||
import { documentationUrl } from "../util/documentation-url";
|
||||
import { getSelectorFallbackValue } from "./ha-form/get-selector-fallback-value";
|
||||
import "./ha-checkbox";
|
||||
import type { HaCheckbox } from "./ha-checkbox";
|
||||
import "./ha-icon-button";
|
||||
@@ -800,8 +799,20 @@ export class HaServiceControl extends LitElement {
|
||||
|
||||
let defaultValue = field?.default;
|
||||
|
||||
if (defaultValue == null && field?.selector) {
|
||||
defaultValue = getSelectorFallbackValue(field.selector);
|
||||
if (
|
||||
defaultValue == null &&
|
||||
field?.selector &&
|
||||
"constant" in field.selector
|
||||
) {
|
||||
defaultValue = field.selector.constant?.value;
|
||||
}
|
||||
|
||||
if (
|
||||
defaultValue == null &&
|
||||
field?.selector &&
|
||||
"boolean" in field.selector
|
||||
) {
|
||||
defaultValue = false;
|
||||
}
|
||||
|
||||
if (defaultValue != null) {
|
||||
|
||||
@@ -664,7 +664,6 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
overscroll-behavior: contain;
|
||||
-ms-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
|
||||
@@ -1,268 +0,0 @@
|
||||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { ensureArray } from "../common/array/ensure-array";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { computeDomain } from "../common/entity/compute_domain";
|
||||
import type { DataTableFiltersValue } from "../data/data_table_filters";
|
||||
import type { HaEntityPickerEntityFilterFunc } from "../data/entity/entity";
|
||||
import type { EntitySources } from "../data/entity/entity_sources";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import "./ha-filter-device-classes";
|
||||
import "./ha-filter-domains";
|
||||
import "./ha-filter-integrations";
|
||||
import "./ha-target-picker";
|
||||
|
||||
/**
|
||||
* Ways to narrow down the entities a target selection resolves to. Not to be
|
||||
* confused with `EntitySources`, which maps an entity to its integration.
|
||||
*/
|
||||
export interface SourceFilters {
|
||||
domains?: string[];
|
||||
deviceClasses?: string[];
|
||||
integrations?: string[];
|
||||
}
|
||||
|
||||
const TARGET_KEYS = [
|
||||
"floor_id",
|
||||
"area_id",
|
||||
"device_id",
|
||||
"entity_id",
|
||||
"label_id",
|
||||
] as const;
|
||||
|
||||
/** Number of picked targets, no matter which type they are. */
|
||||
export const countTargets = (target: HassServiceTarget): number =>
|
||||
TARGET_KEYS.reduce(
|
||||
(count, key) => count + (target[key] ? ensureArray(target[key]).length : 0),
|
||||
0
|
||||
);
|
||||
|
||||
/** Number of filters that have at least one option selected. */
|
||||
export const countSourceFilters = (filters: SourceFilters): number =>
|
||||
Object.values(filters).filter((value) => value?.length).length;
|
||||
|
||||
/**
|
||||
* Narrows entity IDs down by the selected filters: an entity is kept when it
|
||||
* matches every filter that has a selection.
|
||||
*/
|
||||
export const applySourceFilters = (
|
||||
entityIds: string[],
|
||||
filters: SourceFilters,
|
||||
states: HomeAssistant["states"],
|
||||
entities: HomeAssistant["entities"],
|
||||
entitySources?: EntitySources
|
||||
): string[] => {
|
||||
const domains = filters.domains?.length ? filters.domains : undefined;
|
||||
const deviceClasses = filters.deviceClasses?.length
|
||||
? filters.deviceClasses
|
||||
: undefined;
|
||||
const integrations = filters.integrations?.length
|
||||
? filters.integrations
|
||||
: undefined;
|
||||
|
||||
if (!domains && !deviceClasses && !integrations) {
|
||||
return entityIds;
|
||||
}
|
||||
|
||||
return entityIds.filter((entityId) => {
|
||||
if (domains && !domains.includes(computeDomain(entityId))) {
|
||||
return false;
|
||||
}
|
||||
if (deviceClasses) {
|
||||
const deviceClass = states[entityId]?.attributes.device_class;
|
||||
if (!deviceClass || !deviceClasses.includes(deviceClass)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (integrations) {
|
||||
const integration =
|
||||
entities[entityId]?.platform ?? entitySources?.[entityId]?.domain;
|
||||
if (!integration || !integrations.includes(integration)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Picker for what a page shows: the targets to include, narrowed down by
|
||||
* domain, device class and integration. Meant to be placed in an
|
||||
* `ha-filter-pane`.
|
||||
*
|
||||
* The pages resolve every entity of a target, secondary ones included, so the
|
||||
* target picker counts them too.
|
||||
*/
|
||||
@customElement("ha-sources-picker")
|
||||
export class HaSourcesPicker extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public value: HassServiceTarget = {};
|
||||
|
||||
@property({ attribute: false }) public filters: SourceFilters = {};
|
||||
|
||||
@property({ attribute: false })
|
||||
public entityFilter?: HaEntityPickerEntityFilterFunc;
|
||||
|
||||
/** Explains what the page shows while no target is picked. */
|
||||
@property() public description?: string;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
// Only one filter panel is expanded at a time, so that the expanded one can
|
||||
// use the height that is left in the pane.
|
||||
@state() private _expandedFilter?: keyof SourceFilters;
|
||||
|
||||
protected render() {
|
||||
const noTargets = countTargets(this.value) === 0;
|
||||
|
||||
return html`
|
||||
${
|
||||
this.description && noTargets
|
||||
? html`<div class="description">${this.description}</div>`
|
||||
: nothing
|
||||
}
|
||||
<ha-target-picker
|
||||
class=${classMap({ "no-padding-top": noTargets })}
|
||||
.hass=${this.hass}
|
||||
.value=${this.value}
|
||||
.entityFilter=${this.entityFilter}
|
||||
.primaryEntitiesOnly=${false}
|
||||
.disabled=${this.disabled}
|
||||
@value-changed=${this._targetsChanged}
|
||||
></ha-target-picker>
|
||||
<div
|
||||
class=${classMap({ filters: true, expanded: !!this._expandedFilter })}
|
||||
>
|
||||
<ha-filter-domains
|
||||
.value=${this.filters.domains}
|
||||
.expanded=${this._expandedFilter === "domains"}
|
||||
@data-table-filter-changed=${this._domainsChanged}
|
||||
@expanded-changed=${this._domainsExpanded}
|
||||
></ha-filter-domains>
|
||||
<ha-filter-device-classes
|
||||
.value=${this.filters.deviceClasses}
|
||||
.expanded=${this._expandedFilter === "deviceClasses"}
|
||||
@data-table-filter-changed=${this._deviceClassesChanged}
|
||||
@expanded-changed=${this._deviceClassesExpanded}
|
||||
></ha-filter-device-classes>
|
||||
<ha-filter-integrations
|
||||
.value=${this.filters.integrations}
|
||||
.expanded=${this._expandedFilter === "integrations"}
|
||||
@data-table-filter-changed=${this._integrationsChanged}
|
||||
@expanded-changed=${this._integrationsExpanded}
|
||||
></ha-filter-integrations>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
protected firstUpdated() {
|
||||
// The filter panels label themselves with keys from the config panel.
|
||||
this.hass.loadFragmentTranslation("config");
|
||||
}
|
||||
|
||||
private _targetsChanged(ev: CustomEvent) {
|
||||
ev.stopPropagation();
|
||||
fireEvent(this, "value-changed", { value: ev.detail.value || {} });
|
||||
}
|
||||
|
||||
private _domainsChanged(ev: CustomEvent) {
|
||||
this._filterChanged("domains", ev);
|
||||
}
|
||||
|
||||
private _deviceClassesChanged(ev: CustomEvent) {
|
||||
this._filterChanged("deviceClasses", ev);
|
||||
}
|
||||
|
||||
private _integrationsChanged(ev: CustomEvent) {
|
||||
this._filterChanged("integrations", ev);
|
||||
}
|
||||
|
||||
private _filterChanged(key: keyof SourceFilters, ev: CustomEvent) {
|
||||
ev.stopPropagation();
|
||||
const value = ev.detail.value as DataTableFiltersValue;
|
||||
fireEvent(this, "source-filters-changed", {
|
||||
value: {
|
||||
...this.filters,
|
||||
[key]: Array.isArray(value) && value.length ? value : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _domainsExpanded(ev: CustomEvent) {
|
||||
this._filterExpanded("domains", ev);
|
||||
}
|
||||
|
||||
private _deviceClassesExpanded(ev: CustomEvent) {
|
||||
this._filterExpanded("deviceClasses", ev);
|
||||
}
|
||||
|
||||
private _integrationsExpanded(ev: CustomEvent) {
|
||||
this._filterExpanded("integrations", ev);
|
||||
}
|
||||
|
||||
private _filterExpanded(key: keyof SourceFilters, ev: CustomEvent) {
|
||||
if (ev.detail.expanded) {
|
||||
this._expandedFilter = key;
|
||||
} else if (this._expandedFilter === key) {
|
||||
this._expandedFilter = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
/* The sections are laid out by the pane, so that an expanded filter
|
||||
panel can use the height that is left. */
|
||||
:host {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.description {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 92px;
|
||||
margin: var(--ha-space-4) var(--ha-space-4) 0;
|
||||
padding: 0 var(--ha-space-6);
|
||||
border-radius: var(--ha-border-radius-lg);
|
||||
background-color: var(--ha-color-fill-neutral-quiet-resting);
|
||||
text-align: center;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
ha-target-picker {
|
||||
display: block;
|
||||
flex: none;
|
||||
padding: var(--ha-space-4);
|
||||
}
|
||||
|
||||
/* The description already spaces the picker from the pane header. */
|
||||
ha-target-picker.no-padding-top {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 0 auto;
|
||||
border-top: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
/* An expanded panel sizes itself to the space that is left over. */
|
||||
.filters.expanded {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-sources-picker": HaSourcesPicker;
|
||||
}
|
||||
interface HASSDomEvents {
|
||||
"source-filters-changed": { value: SourceFilters };
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,9 @@ import { domainToName } from "../data/integration";
|
||||
import { getLabels, labelComboBoxKeys } from "../data/label/label_picker";
|
||||
import type { LabelRegistryEntry } from "../data/label/label_registry";
|
||||
import {
|
||||
areaMeetsFilter,
|
||||
deviceMeetsFilter,
|
||||
entityRegMeetsFilter,
|
||||
getTargetComboBoxItemType,
|
||||
type TargetItem,
|
||||
type TargetType,
|
||||
@@ -63,6 +66,7 @@ import type { PickerComboBoxItem } from "./ha-picker-combo-box";
|
||||
import "./ha-svg-icon";
|
||||
import "./ha-tree-indicator";
|
||||
import "./target-picker/ha-target-picker-item-group";
|
||||
import "./target-picker/ha-target-picker-value-chip";
|
||||
|
||||
const SEPARATOR = "________";
|
||||
const CREATE_ID = "___create-new-entity___";
|
||||
@@ -81,6 +85,8 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public compact = false;
|
||||
|
||||
@property({ attribute: false }) public createDomains?: string[];
|
||||
|
||||
@property({ type: Boolean, attribute: "primary-entities-only" })
|
||||
@@ -110,6 +116,8 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public disabled = false;
|
||||
|
||||
@property({ attribute: "add-on-top", type: Boolean }) public addOnTop = false;
|
||||
|
||||
@state() private _selectedSection?: TargetTypeFloorless;
|
||||
|
||||
@state() private _replaceTarget?: TargetItem;
|
||||
@@ -150,7 +158,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
excludeDevices,
|
||||
value,
|
||||
idPrefix,
|
||||
nested: true,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -245,9 +252,119 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
Fuse.createIndex(keys, states);
|
||||
|
||||
protected render() {
|
||||
if (this.addOnTop) {
|
||||
return html` ${this._renderPicker()} ${this._renderItems()} `;
|
||||
}
|
||||
return html` ${this._renderItems()} ${this._renderPicker()} `;
|
||||
}
|
||||
|
||||
private _renderValueChips() {
|
||||
const entityIds = this.value?.entity_id
|
||||
? ensureArray(this.value.entity_id)
|
||||
: [];
|
||||
const deviceIds = this.value?.device_id
|
||||
? ensureArray(this.value.device_id)
|
||||
: [];
|
||||
const areaIds = this.value?.area_id ? ensureArray(this.value.area_id) : [];
|
||||
const floorIds = this.value?.floor_id
|
||||
? ensureArray(this.value.floor_id)
|
||||
: [];
|
||||
const labelIds = this.value?.label_id
|
||||
? ensureArray(this.value.label_id)
|
||||
: [];
|
||||
|
||||
if (
|
||||
!entityIds.length &&
|
||||
!deviceIds.length &&
|
||||
!areaIds.length &&
|
||||
!floorIds.length &&
|
||||
!labelIds.length
|
||||
) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="items">
|
||||
${
|
||||
floorIds.length
|
||||
? floorIds.map(
|
||||
(floor_id) => html`
|
||||
<ha-target-picker-value-chip
|
||||
.hass=${this.hass}
|
||||
type="floor"
|
||||
.itemId=${floor_id}
|
||||
@remove-target-item=${this._handleRemove}
|
||||
@expand-target-item=${this._handleExpand}
|
||||
></ha-target-picker-value-chip>
|
||||
`
|
||||
)
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
areaIds.length
|
||||
? areaIds.map(
|
||||
(area_id) => html`
|
||||
<ha-target-picker-value-chip
|
||||
.hass=${this.hass}
|
||||
type="area"
|
||||
.itemId=${area_id}
|
||||
@remove-target-item=${this._handleRemove}
|
||||
@expand-target-item=${this._handleExpand}
|
||||
></ha-target-picker-value-chip>
|
||||
`
|
||||
)
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
deviceIds.length
|
||||
? deviceIds.map(
|
||||
(device_id) => html`
|
||||
<ha-target-picker-value-chip
|
||||
.hass=${this.hass}
|
||||
type="device"
|
||||
.itemId=${device_id}
|
||||
.compositeSplits=${this._compositeSplits}
|
||||
@remove-target-item=${this._handleRemove}
|
||||
@expand-target-item=${this._handleExpand}
|
||||
></ha-target-picker-value-chip>
|
||||
`
|
||||
)
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
entityIds.length
|
||||
? entityIds.map(
|
||||
(entity_id) => html`
|
||||
<ha-target-picker-value-chip
|
||||
.hass=${this.hass}
|
||||
type="entity"
|
||||
.itemId=${entity_id}
|
||||
@remove-target-item=${this._handleRemove}
|
||||
@expand-target-item=${this._handleExpand}
|
||||
></ha-target-picker-value-chip>
|
||||
`
|
||||
)
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
labelIds.length
|
||||
? labelIds.map(
|
||||
(label_id) => html`
|
||||
<ha-target-picker-value-chip
|
||||
.hass=${this.hass}
|
||||
type="label"
|
||||
.itemId=${label_id}
|
||||
@remove-target-item=${this._handleRemove}
|
||||
@expand-target-item=${this._handleExpand}
|
||||
></ha-target-picker-value-chip>
|
||||
`
|
||||
)
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderValueGroups() {
|
||||
const entityIds = this.value?.entity_id
|
||||
? ensureArray(this.value.entity_id)
|
||||
@@ -361,7 +478,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
|
||||
private _renderItems() {
|
||||
return html` ${this._renderValueGroups()} `;
|
||||
return html`
|
||||
${this.compact ? this._renderValueChips() : this._renderValueGroups()}
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderPicker() {
|
||||
@@ -536,6 +655,152 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
});
|
||||
}
|
||||
|
||||
private _handleExpand(ev: HASSDomEvent<HASSDomEvents["expand-target-item"]>) {
|
||||
const type = ev.detail.type;
|
||||
const itemId = ev.detail.id;
|
||||
const newAreas: string[] = [];
|
||||
const newDevices: string[] = [];
|
||||
const newEntities: string[] = [];
|
||||
|
||||
if (type === "floor") {
|
||||
Object.values(this.hass.areas).forEach((area) => {
|
||||
if (
|
||||
area.floor_id === itemId &&
|
||||
!this.value!.area_id?.includes(area.area_id) &&
|
||||
areaMeetsFilter(
|
||||
area,
|
||||
this.hass.devices,
|
||||
this.hass.entities,
|
||||
this.deviceFilter,
|
||||
this.includeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.hass.states,
|
||||
this.entityFilter
|
||||
)
|
||||
) {
|
||||
newAreas.push(area.area_id);
|
||||
}
|
||||
});
|
||||
} else if (type === "area") {
|
||||
Object.values(this.hass.devices).forEach((device) => {
|
||||
if (
|
||||
device.area_id === itemId &&
|
||||
!this.value!.device_id?.includes(device.id) &&
|
||||
deviceMeetsFilter(
|
||||
device,
|
||||
this.hass.entities,
|
||||
this.deviceFilter,
|
||||
this.includeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.hass.states,
|
||||
this.entityFilter
|
||||
)
|
||||
) {
|
||||
newDevices.push(device.id);
|
||||
}
|
||||
});
|
||||
Object.values(this.hass.entities).forEach((entity) => {
|
||||
if (
|
||||
entity.area_id === itemId &&
|
||||
!this.value!.entity_id?.includes(entity.entity_id) &&
|
||||
entityRegMeetsFilter(
|
||||
entity,
|
||||
false,
|
||||
this.includeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.hass.states,
|
||||
this.entityFilter
|
||||
)
|
||||
) {
|
||||
newEntities.push(entity.entity_id);
|
||||
}
|
||||
});
|
||||
} else if (type === "device") {
|
||||
Object.values(this.hass.entities).forEach((entity) => {
|
||||
if (
|
||||
entity.device_id === itemId &&
|
||||
!this.value!.entity_id?.includes(entity.entity_id) &&
|
||||
entityRegMeetsFilter(
|
||||
entity,
|
||||
false,
|
||||
this.includeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.hass.states,
|
||||
this.entityFilter
|
||||
)
|
||||
) {
|
||||
newEntities.push(entity.entity_id);
|
||||
}
|
||||
});
|
||||
} else if (type === "label") {
|
||||
Object.values(this.hass.areas).forEach((area) => {
|
||||
if (
|
||||
area.labels.includes(itemId) &&
|
||||
!this.value!.area_id?.includes(area.area_id) &&
|
||||
areaMeetsFilter(
|
||||
area,
|
||||
this.hass.devices,
|
||||
this.hass.entities,
|
||||
this.deviceFilter,
|
||||
this.includeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.hass.states,
|
||||
this.entityFilter
|
||||
)
|
||||
) {
|
||||
newAreas.push(area.area_id);
|
||||
}
|
||||
});
|
||||
Object.values(this.hass.devices).forEach((device) => {
|
||||
if (
|
||||
device.labels.includes(itemId) &&
|
||||
!this.value!.device_id?.includes(device.id) &&
|
||||
deviceMeetsFilter(
|
||||
device,
|
||||
this.hass.entities,
|
||||
this.deviceFilter,
|
||||
this.includeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.hass.states,
|
||||
this.entityFilter
|
||||
)
|
||||
) {
|
||||
newDevices.push(device.id);
|
||||
}
|
||||
});
|
||||
Object.values(this.hass.entities).forEach((entity) => {
|
||||
if (
|
||||
entity.labels.includes(itemId) &&
|
||||
!this.value!.entity_id?.includes(entity.entity_id) &&
|
||||
entityRegMeetsFilter(
|
||||
entity,
|
||||
true,
|
||||
this.includeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.hass.states,
|
||||
this.entityFilter
|
||||
)
|
||||
) {
|
||||
newEntities.push(entity.entity_id);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
let value = this.value;
|
||||
if (newEntities.length) {
|
||||
value = this._addItems(value, "entity_id", newEntities);
|
||||
}
|
||||
if (newDevices.length) {
|
||||
value = this._addItems(value, "device_id", newDevices);
|
||||
}
|
||||
if (newAreas.length) {
|
||||
value = this._addItems(value, "area_id", newAreas);
|
||||
}
|
||||
value = this._removeItem(value, type, itemId);
|
||||
fireEvent(this, "value-changed", { value });
|
||||
}
|
||||
|
||||
private _handleReplace(
|
||||
ev: HASSDomEvent<HASSDomEvents["replace-target-item"]>
|
||||
) {
|
||||
@@ -575,6 +840,17 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
this._replaceTargetAnchor = undefined;
|
||||
}
|
||||
|
||||
private _addItems(
|
||||
value: this["value"],
|
||||
type: string,
|
||||
ids: string[]
|
||||
): this["value"] {
|
||||
return {
|
||||
...value,
|
||||
[type]: value![type] ? ensureArray(value![type])!.concat(ids) : ids,
|
||||
};
|
||||
}
|
||||
|
||||
private _removeItem(
|
||||
value: this["value"],
|
||||
type: TargetType,
|
||||
@@ -725,28 +1001,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
|
||||
if (!filterType || filterType === "device") {
|
||||
const selectedDeviceIds = targetValue?.device_id
|
||||
? replacingDeviceId
|
||||
? ensureArray(targetValue.device_id).filter(
|
||||
(deviceId) => deviceId !== replacingDeviceId
|
||||
)
|
||||
: ensureArray(targetValue.device_id)
|
||||
: undefined;
|
||||
// A selected parent device already targets its children, so exclude
|
||||
// those children from the picker too (mirrors selecting a floor
|
||||
// removing its areas from the list).
|
||||
const excludeDeviceIds = selectedDeviceIds
|
||||
? [
|
||||
...selectedDeviceIds,
|
||||
...Object.values(this.hass.devices)
|
||||
.filter(
|
||||
(device) =>
|
||||
device.parent_device_id !== null &&
|
||||
selectedDeviceIds.includes(device.parent_device_id)
|
||||
)
|
||||
.map((device) => device.id),
|
||||
]
|
||||
: undefined;
|
||||
let deviceItems = this._getDevicesMemoized(
|
||||
this.hass,
|
||||
configEntryLookup,
|
||||
@@ -754,41 +1008,26 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
includeDeviceClasses,
|
||||
deviceFilter,
|
||||
entityFilter,
|
||||
excludeDeviceIds,
|
||||
targetValue?.device_id
|
||||
? replacingDeviceId
|
||||
? ensureArray(targetValue.device_id).filter(
|
||||
(deviceId) => deviceId !== replacingDeviceId
|
||||
)
|
||||
: ensureArray(targetValue.device_id)
|
||||
: undefined,
|
||||
replacingDeviceId,
|
||||
`device${SEPARATOR}`
|
||||
);
|
||||
// getDevices already returns child devices nested under their parent
|
||||
// with the top-level devices sorted; keep that order rather than
|
||||
// re-sorting by label, which would separate children from their parent.
|
||||
).sort(this._sortBySortingLabel);
|
||||
|
||||
if (searchTerm) {
|
||||
// Keep the nested parent-then-children order (sort=false), matching
|
||||
// the areas group; the default sorted search would reorder matches by
|
||||
// relevance and pull children above their parent.
|
||||
deviceItems = this._filterGroup(
|
||||
"device",
|
||||
deviceItems,
|
||||
searchTerm,
|
||||
deviceComboBoxKeys,
|
||||
false
|
||||
deviceComboBoxKeys
|
||||
);
|
||||
}
|
||||
|
||||
// Recompute the tree "last child" flag over the (possibly filtered)
|
||||
// list so the last visible child of each parent draws its end connector.
|
||||
deviceItems = deviceItems.map((item, index) => {
|
||||
if (!(item as DevicePickerItem).is_child) {
|
||||
return item;
|
||||
}
|
||||
const nextItem = deviceItems[index + 1] as
|
||||
DevicePickerItem | undefined;
|
||||
return {
|
||||
...item,
|
||||
last: !nextItem || !nextItem.is_child,
|
||||
};
|
||||
});
|
||||
|
||||
if (!filterType && deviceItems.length) {
|
||||
// show group title
|
||||
items.push(localize("ui.components.target-picker.type.devices"));
|
||||
@@ -1006,9 +1245,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
let hasFloor = false;
|
||||
let rtl = false;
|
||||
let showEntityId = false;
|
||||
const isChildDeviceRow =
|
||||
type === "device" && !!(item as DevicePickerItem).is_child;
|
||||
if (type === "area" || type === "floor" || isChildDeviceRow) {
|
||||
if (type === "area" || type === "floor") {
|
||||
rtl = computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
@@ -1028,27 +1265,27 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
.type=${type === "empty" ? "text" : "button"}
|
||||
class=${type === "empty" ? "empty" : ""}
|
||||
style=${
|
||||
((item as FloorComboBoxItem).type === "area" && hasFloor) ||
|
||||
isChildDeviceRow
|
||||
(item as FloorComboBoxItem).type === "area" && hasFloor
|
||||
? "--md-list-item-leading-space: var(--ha-space-12);"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
${
|
||||
((item as FloorComboBoxItem).type === "area" && hasFloor) ||
|
||||
isChildDeviceRow
|
||||
(item as FloorComboBoxItem).type === "area" && hasFloor
|
||||
? html`
|
||||
<ha-tree-indicator
|
||||
style=${styleMap({
|
||||
width: "var(--ha-space-12)",
|
||||
position: "absolute",
|
||||
top: "0",
|
||||
height: "100%",
|
||||
left: rtl ? undefined : "var(--ha-space-1)",
|
||||
right: rtl ? "var(--ha-space-1)" : undefined,
|
||||
transform: rtl ? "scaleX(-1)" : "",
|
||||
})}
|
||||
.end=${(item as { last?: boolean }).last}
|
||||
.end=${
|
||||
(item as FloorComboBoxItem & { last?: boolean | undefined })
|
||||
.last
|
||||
}
|
||||
slot="start"
|
||||
></ha-tree-indicator>
|
||||
`
|
||||
@@ -1150,6 +1387,13 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.items {
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
padding: var(--ha-space-2) 0;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
.item-groups {
|
||||
overflow: hidden;
|
||||
border: var(--ha-border-width-sm) solid var(--divider-color);
|
||||
@@ -1165,6 +1409,7 @@ declare global {
|
||||
|
||||
interface HASSDomEvents {
|
||||
"remove-target-item": TargetItem;
|
||||
"expand-target-item": TargetItem;
|
||||
"replace-target-item": TargetItem;
|
||||
"migrate-target-item": { id: string; replacements: string[] };
|
||||
"remove-target-group": string;
|
||||
|
||||
@@ -8,31 +8,10 @@ export class HaTreeIndicator extends LitElement {
|
||||
public end?: boolean = false;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
// preserveAspectRatio="none" lets the connector stretch to the host box, so
|
||||
// it can span the full height of a taller row instead of being letterboxed
|
||||
// to a square in the middle. non-scaling-stroke keeps the line width and
|
||||
// dash pattern identical no matter how far it is stretched.
|
||||
return html`
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
viewBox="0 0 48 48"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<line
|
||||
x1="24"
|
||||
y1="0"
|
||||
x2="24"
|
||||
y2=${this.end ? "24" : "48"}
|
||||
vector-effect="non-scaling-stroke"
|
||||
></line>
|
||||
<line
|
||||
x1="24"
|
||||
y1="24"
|
||||
x2="36"
|
||||
y2="24"
|
||||
vector-effect="non-scaling-stroke"
|
||||
></line>
|
||||
<svg width="100%" height="100%" viewBox="0 0 48 48">
|
||||
<line x1="24" y1="0" x2="24" y2=${this.end ? "24" : "48"}></line>
|
||||
<line x1="24" y1="24" x2="36" y2="24"></line>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { consume } from "@lit/context";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { copyToClipboard } from "../common/util/copy-clipboard";
|
||||
import { haStyle } from "../resources/styles";
|
||||
import type { YamlFieldSchemaMap } from "../resources/yaml_field_schema";
|
||||
import { showToast } from "../util/toast";
|
||||
import "./ha-button";
|
||||
import "./ha-code-editor";
|
||||
@@ -32,6 +33,14 @@ export class HaYamlEditor extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public yamlSchema: Schema = YAML11_SCHEMA;
|
||||
|
||||
/**
|
||||
* Optional field schema for YAML mode. When provided, the code editor will
|
||||
* offer field-aware key/value completions, hover tooltips, and linting.
|
||||
* This is forwarded directly to ha-code-editor.
|
||||
*/
|
||||
@property({ attribute: false })
|
||||
public yamlFieldSchema?: YamlFieldSchemaMap;
|
||||
|
||||
@property({ attribute: false }) public defaultValue?: any;
|
||||
|
||||
@property({ attribute: "is-valid", type: Boolean }) public isValid = true;
|
||||
@@ -123,8 +132,9 @@ export class HaYamlEditor extends LitElement {
|
||||
.inDialog=${this.inDialog}
|
||||
mode="yaml"
|
||||
lint
|
||||
autocomplete-entities
|
||||
.autocompleteEntities=${!this.yamlFieldSchema}
|
||||
autocomplete-icons
|
||||
.yamlFieldSchema=${this.yamlFieldSchema}
|
||||
.error=${this.isValid === false}
|
||||
@value-changed=${this._onChange}
|
||||
@editor-save=${this._onEditorSave}
|
||||
|
||||
@@ -393,9 +393,6 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
nextEntries.referenced_entities =
|
||||
entries?.referenced_entities.filter((entity_id) => {
|
||||
const entity = this.hass.entities[entity_id];
|
||||
if (!entity) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
entity.area_id === rowItem ||
|
||||
!entity.device_id ||
|
||||
@@ -419,9 +416,6 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
this.type === "label" && entries
|
||||
? entries.referenced_entities.filter((entity_id) => {
|
||||
const entity = this.hass.entities[entity_id];
|
||||
if (!entity) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
entity.labels.includes(this.itemId) &&
|
||||
!entries.referenced_devices.includes(entity.device_id || "")
|
||||
@@ -430,7 +424,7 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
: nextType === "device" && entries
|
||||
? entries.referenced_entities.filter(
|
||||
(entity_id) =>
|
||||
this.hass.entities[entity_id]?.area_id === this.itemId
|
||||
this.hass.entities[entity_id].area_id === this.itemId
|
||||
)
|
||||
: [];
|
||||
|
||||
@@ -439,7 +433,7 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
? entries.referenced_devices.filter(
|
||||
(device_id) =>
|
||||
!devicesInAreas.includes(device_id) &&
|
||||
this.hass.devices[device_id]?.labels.includes(this.itemId)
|
||||
this.hass.devices[device_id].labels.includes(this.itemId)
|
||||
)
|
||||
: [];
|
||||
|
||||
@@ -534,12 +528,6 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
entries.referenced_areas = entries.referenced_areas.filter(
|
||||
(area_id) => {
|
||||
const area = this.hass.areas[area_id];
|
||||
// Absent from the registry is not a filter decision: drop the id
|
||||
// without marking it hidden, so entities targeted through their
|
||||
// own area or label are not dropped along with it.
|
||||
if (!area) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
(this.type === "floor" || area.labels.includes(this.itemId)) &&
|
||||
areaMeetsFilter(
|
||||
@@ -572,9 +560,6 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
entries.referenced_devices = entries.referenced_devices.filter(
|
||||
(device_id) => {
|
||||
const device = this.hass.devices[device_id];
|
||||
if (!device) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
!hiddenAreaIds.includes(device.area_id || "") &&
|
||||
deviceMeetsFilter(
|
||||
@@ -600,11 +585,6 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
entries.referenced_entities = entries.referenced_entities.filter(
|
||||
(entity_id) => {
|
||||
const entity = this.hass.entities[entity_id];
|
||||
// Core can reference entities that are absent from the display
|
||||
// registry (e.g. disabled ones expanded from an area).
|
||||
if (!entity) {
|
||||
return false;
|
||||
}
|
||||
if (hiddenDeviceIds.includes(entity.device_id || "")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
import "@home-assistant/webawesome/dist/components/tag/tag";
|
||||
import { consume } from "@lit/context";
|
||||
import {
|
||||
mdiAlertOutline,
|
||||
mdiDevices,
|
||||
mdiHome,
|
||||
mdiLabel,
|
||||
mdiTextureBox,
|
||||
mdiUnfoldMoreVertical,
|
||||
} from "@mdi/js";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { computeCssColor } from "../../common/color/compute-color";
|
||||
import { hex2rgb } from "../../common/color/convert-color";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { computeDeviceNameDisplay } from "../../common/entity/compute_device_name";
|
||||
import { computeDomain } from "../../common/entity/compute_domain";
|
||||
import { computeStateName } from "../../common/entity/compute_state_name";
|
||||
import { slugify } from "../../common/string/slugify";
|
||||
import { getConfigEntry } from "../../data/config_entries";
|
||||
import { labelsContext } from "../../data/context";
|
||||
import type { DeviceCompositeSplits } from "../../data/device/device_registry";
|
||||
import { domainToName } from "../../data/integration";
|
||||
import type { LabelRegistryEntry } from "../../data/label/label_registry";
|
||||
import type { TargetType } from "../../data/target";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { brandsUrl } from "../../util/brands-url";
|
||||
import "../ha-domain-icon";
|
||||
import { floorDefaultIconPath } from "../ha-floor-icon";
|
||||
import "../ha-icon";
|
||||
import "../ha-icon-button";
|
||||
import "../ha-state-icon";
|
||||
import "../ha-tooltip";
|
||||
|
||||
@customElement("ha-target-picker-value-chip")
|
||||
export class HaTargetPickerValueChip extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property() public type!: TargetType;
|
||||
|
||||
@property({ attribute: "item-id" }) public itemId!: string;
|
||||
|
||||
@property({ attribute: false })
|
||||
public compositeSplits?: DeviceCompositeSplits;
|
||||
|
||||
@state() private _domainName?: string;
|
||||
|
||||
@state() private _iconImg?: string;
|
||||
|
||||
@state()
|
||||
@consume({ context: labelsContext, subscribe: true })
|
||||
_labelRegistry!: LabelRegistryEntry[];
|
||||
|
||||
protected render() {
|
||||
const { name, iconPath, fallbackIconPath, stateObject, color } =
|
||||
this._itemData(this.type, this.itemId);
|
||||
|
||||
const split =
|
||||
this.type === "device" && !this.hass.devices?.[this.itemId]
|
||||
? this.compositeSplits?.[this.itemId]
|
||||
: undefined;
|
||||
// Show the replaced reference using the primary replacement device's name,
|
||||
// falling back to the first still-existing split device if the primary
|
||||
// device itself was deleted. If no replacement device exists at all, fall
|
||||
// back to the normal "not found" display.
|
||||
const replacementDevice = split
|
||||
? (split.primary_id && this.hass.devices?.[split.primary_id]) ||
|
||||
split.split_ids
|
||||
.map((id) => this.hass.devices?.[id])
|
||||
.find((device) => device)
|
||||
: undefined;
|
||||
const replaced = !!replacementDevice;
|
||||
const replacedName = replacementDevice
|
||||
? computeDeviceNameDisplay(
|
||||
replacementDevice,
|
||||
this.hass.localize,
|
||||
this.hass.states
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return html`
|
||||
<wa-tag
|
||||
pill
|
||||
with-remove
|
||||
class=${classMap({ [this.type]: true, replaced })}
|
||||
style=${color ? `--color: rgb(${color});` : ""}
|
||||
@wa-remove=${this._removeItem}
|
||||
>
|
||||
<div class="icon">
|
||||
${
|
||||
replaced
|
||||
? html`<ha-svg-icon .path=${mdiAlertOutline}></ha-svg-icon>`
|
||||
: iconPath
|
||||
? html`<ha-icon .icon=${iconPath}></ha-icon>`
|
||||
: this._iconImg
|
||||
? html`<img
|
||||
alt=${this._domainName || ""}
|
||||
width="24"
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
src=${this._iconImg}
|
||||
/>`
|
||||
: fallbackIconPath
|
||||
? html`<ha-svg-icon
|
||||
.path=${fallbackIconPath}
|
||||
></ha-svg-icon>`
|
||||
: stateObject
|
||||
? html`<ha-state-icon
|
||||
.stateObj=${stateObject}
|
||||
></ha-state-icon>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
<span class="name">
|
||||
${
|
||||
replaced
|
||||
? replacedName ||
|
||||
this.hass.localize(
|
||||
"ui.components.target-picker.replaced_device"
|
||||
)
|
||||
: name
|
||||
}
|
||||
</span>
|
||||
${
|
||||
this.type === "entity" || replaced
|
||||
? nothing
|
||||
: html`<ha-tooltip .for="expand-${slugify(this.itemId)}"
|
||||
>${this.hass.localize(
|
||||
`ui.components.target-picker.expand_${this.type}_id`
|
||||
)}
|
||||
</ha-tooltip>
|
||||
<ha-icon-button
|
||||
class="expand-btn mdc-chip__icon mdc-chip__icon--trailing"
|
||||
.label=${this.hass.localize(
|
||||
"ui.components.target-picker.expand"
|
||||
)}
|
||||
.path=${mdiUnfoldMoreVertical}
|
||||
hide-title
|
||||
.id="expand-${slugify(this.itemId)}"
|
||||
.type=${this.type}
|
||||
@click=${this._handleExpand}
|
||||
></ha-icon-button>`
|
||||
}
|
||||
</wa-tag>
|
||||
`;
|
||||
}
|
||||
|
||||
private _itemData = memoizeOne((type: TargetType, itemId: string) => {
|
||||
if (type === "floor") {
|
||||
const floor = this.hass.floors?.[itemId];
|
||||
return {
|
||||
name: floor?.name || itemId,
|
||||
iconPath: floor?.icon,
|
||||
fallbackIconPath: floor ? floorDefaultIconPath(floor) : mdiHome,
|
||||
};
|
||||
}
|
||||
if (type === "area") {
|
||||
const area = this.hass.areas?.[itemId];
|
||||
return {
|
||||
name: area?.name || itemId,
|
||||
iconPath: area?.icon,
|
||||
fallbackIconPath: mdiTextureBox,
|
||||
};
|
||||
}
|
||||
if (type === "device") {
|
||||
const device = this.hass.devices?.[itemId];
|
||||
|
||||
if (device?.primary_config_entry) {
|
||||
this._getDeviceDomain(device.primary_config_entry);
|
||||
}
|
||||
|
||||
return {
|
||||
name: device
|
||||
? computeDeviceNameDisplay(
|
||||
device,
|
||||
this.hass.localize,
|
||||
this.hass.states
|
||||
)
|
||||
: itemId,
|
||||
fallbackIconPath: mdiDevices,
|
||||
};
|
||||
}
|
||||
if (type === "entity") {
|
||||
this._setDomainName(computeDomain(itemId));
|
||||
|
||||
const stateObj = this.hass.states[itemId];
|
||||
return {
|
||||
name: computeStateName(stateObj) || itemId,
|
||||
stateObject: stateObj,
|
||||
};
|
||||
}
|
||||
|
||||
// type label
|
||||
const label = this._labelRegistry.find((lab) => lab.label_id === itemId);
|
||||
let color = label?.color ? computeCssColor(label.color) : undefined;
|
||||
if (color?.startsWith("var(")) {
|
||||
const computedStyles = getComputedStyle(this);
|
||||
color = computedStyles.getPropertyValue(
|
||||
color.substring(4, color.length - 1)
|
||||
);
|
||||
}
|
||||
if (color?.startsWith("#")) {
|
||||
color = hex2rgb(color).join(",");
|
||||
}
|
||||
return {
|
||||
name: label?.name || itemId,
|
||||
iconPath: label?.icon,
|
||||
fallbackIconPath: mdiLabel,
|
||||
color,
|
||||
};
|
||||
});
|
||||
|
||||
private _setDomainName(domain: string) {
|
||||
this._domainName = domainToName(this.hass.localize, domain);
|
||||
}
|
||||
|
||||
private async _getDeviceDomain(configEntryId: string) {
|
||||
try {
|
||||
const data = await getConfigEntry(this.hass, configEntryId);
|
||||
const domain = data.config_entry.domain;
|
||||
this._iconImg = brandsUrl(
|
||||
{
|
||||
domain: domain,
|
||||
type: "icon",
|
||||
darkOptimized: this.hass.themes?.darkMode,
|
||||
},
|
||||
this.hass.auth.data.hassUrl
|
||||
);
|
||||
|
||||
this._setDomainName(domain);
|
||||
} catch {
|
||||
// failed to load config entry -> ignore
|
||||
}
|
||||
}
|
||||
|
||||
private _removeItem(ev: MouseEvent) {
|
||||
ev.stopPropagation();
|
||||
fireEvent(this, "remove-target-item", {
|
||||
type: this.type,
|
||||
id: this.itemId,
|
||||
});
|
||||
}
|
||||
|
||||
private _handleExpand(ev: MouseEvent) {
|
||||
ev.stopPropagation();
|
||||
fireEvent(this, "expand-target-item", {
|
||||
type: this.type,
|
||||
id: this.itemId,
|
||||
});
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
}
|
||||
wa-tag {
|
||||
background-color: var(--card-background-color);
|
||||
border-width: var(--ha-border-width-md);
|
||||
padding-inline-start: 0;
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
wa-tag.entity {
|
||||
border-color: var(--ha-color-green-80);
|
||||
--background-color: var(--ha-color-green-80);
|
||||
}
|
||||
wa-tag.device {
|
||||
border-color: var(--ha-color-primary-80);
|
||||
--background-color: var(--ha-color-primary-80);
|
||||
}
|
||||
wa-tag.area {
|
||||
border-color: var(--ha-color-orange-80);
|
||||
--background-color: var(--ha-color-orange-80);
|
||||
}
|
||||
wa-tag.label {
|
||||
border-color: var(--color);
|
||||
--background-color: var(--color);
|
||||
--icon-primary-color: var(--primary-text-color);
|
||||
}
|
||||
wa-tag.replaced {
|
||||
border-color: var(--ha-color-border-warning-normal, var(--warning-color));
|
||||
--background-color: var(--warning-color);
|
||||
color: var(--ha-color-on-warning-normal, var(--warning-color));
|
||||
}
|
||||
|
||||
.name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.icon {
|
||||
background-color: var(--background-color);
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
padding: var(--ha-space-2) var(--ha-space-1);
|
||||
display: flex;
|
||||
}
|
||||
.expand-btn {
|
||||
--ha-icon-button-size: 16px;
|
||||
--mdc-icon-size: 14px;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-target-picker-value-chip": HaTargetPickerValueChip;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { stopPropagation } from "../../common/dom/stop_propagation";
|
||||
@@ -25,13 +25,6 @@ export class HaTileContainer extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public actionHandlerOptions?: ActionHandlerOptions;
|
||||
|
||||
@state() private _hasFeatures = false;
|
||||
|
||||
private _handleFeaturesSlotChange(ev: Event) {
|
||||
this._hasFeatures =
|
||||
(ev.target as HTMLSlotElement).assignedElements().length > 0;
|
||||
}
|
||||
|
||||
private _handleFocus(ev: FocusEvent) {
|
||||
if ((ev.target as HTMLElement).matches(":focus-visible")) {
|
||||
this.setAttribute("focused", "");
|
||||
@@ -43,12 +36,8 @@ export class HaTileContainer extends LitElement {
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const isInline = this.featurePosition === "inline";
|
||||
const containerClasses = {
|
||||
inline: isInline,
|
||||
"has-features-below": isInline && this._hasFeatures,
|
||||
"fixed-height": this.fixedInfoHeight,
|
||||
};
|
||||
const containerOrientationClass =
|
||||
this.featurePosition === "inline" ? "horizontal" : "";
|
||||
const contentClasses = {
|
||||
vertical: this.vertical,
|
||||
"fixed-info-height": this.fixedInfoHeight,
|
||||
@@ -67,21 +56,15 @@ export class HaTileContainer extends LitElement {
|
||||
<ha-ripple .disabled=${!this.interactive}></ha-ripple>
|
||||
</div>
|
||||
<div
|
||||
class="container ${classMap(containerClasses)}"
|
||||
class="container ${containerOrientationClass}"
|
||||
@action=${stopPropagation}
|
||||
@click=${stopPropagation}
|
||||
>
|
||||
<div class="row">
|
||||
<div class="content ${classMap(contentClasses)}">
|
||||
<slot name="icon"></slot>
|
||||
<slot name="info" id="info"></slot>
|
||||
</div>
|
||||
<slot name="features-inline"></slot>
|
||||
<div class="content ${classMap(contentClasses)}">
|
||||
<slot name="icon"></slot>
|
||||
<slot name="info" id="info"></slot>
|
||||
</div>
|
||||
<slot
|
||||
name="features"
|
||||
@slotchange=${this._handleFeaturesSlotChange}
|
||||
></slot>
|
||||
<slot name="features"></slot>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -114,13 +97,7 @@ export class HaTileContainer extends LitElement {
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.container.inline .row {
|
||||
.container.horizontal {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
@@ -176,30 +153,13 @@ export class HaTileContainer extends LitElement {
|
||||
padding: 0 var(--ha-space-3) var(--ha-space-3) var(--ha-space-3);
|
||||
}
|
||||
|
||||
.container.inline ::slotted([slot="features-inline"]) {
|
||||
/* size the feature on the 6 column grid track, so it lines up with neighbouring tiles */
|
||||
.container.horizontal ::slotted([slot="features"]) {
|
||||
width: calc(50% - var(--column-gap, 0px) / 2 - var(--ha-space-3));
|
||||
flex: none;
|
||||
--feature-height: var(--ha-space-9);
|
||||
padding: 0 var(--ha-space-3);
|
||||
padding-inline-start: 0;
|
||||
}
|
||||
/* the inline feature keeps the icon height, unless the card reserves a row it can fill */
|
||||
.container.inline:not(.has-features-below)
|
||||
::slotted([slot="features-inline"]),
|
||||
.container.inline:not(.fixed-height) ::slotted([slot="features-inline"]) {
|
||||
--feature-height: var(--ha-space-9);
|
||||
}
|
||||
|
||||
.container.inline.has-features-below ::slotted([slot="features"]) {
|
||||
/* keep both columns under the inline feature, which sits on the grid track */
|
||||
--ha-card-feature-column-gap: calc(
|
||||
var(--column-gap, 0px) + var(--ha-space-3) * 2
|
||||
);
|
||||
--ha-card-feature-divider: 1px solid var(--ha-color-border-neutral-quiet);
|
||||
--ha-card-feature-divider-inset: calc(
|
||||
var(--ha-space-3) + var(--column-gap, 0px) / 2
|
||||
);
|
||||
}
|
||||
[role="button"] {
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
|
||||
@@ -29,7 +29,6 @@ export class HaTraceLogbook extends LitElement {
|
||||
.hass=${this.hass}
|
||||
.entries=${this.logbookEntries}
|
||||
.narrow=${this.narrow}
|
||||
no-detail
|
||||
></ha-logbook-renderer>
|
||||
<hat-logbook-note .domain=${this.trace.domain}></hat-logbook-note>
|
||||
`
|
||||
|
||||
@@ -437,7 +437,6 @@ export class HaTracePathDetails extends LitElement {
|
||||
.hass=${this.hass}
|
||||
.entries=${entries}
|
||||
.narrow=${this.narrow}
|
||||
no-detail
|
||||
></ha-logbook-renderer>
|
||||
<hat-logbook-note .domain=${this.trace.domain}></hat-logbook-note>
|
||||
`
|
||||
|
||||
@@ -3,7 +3,6 @@ import { computeDeviceNameDisplay } from "../../common/entity/compute_device_nam
|
||||
import { computeDomain } from "../../common/entity/compute_domain";
|
||||
import { getDeviceArea } from "../../common/entity/context/get_device_context";
|
||||
import type { LocalizeFunc } from "../../common/translations/localize";
|
||||
import { caseInsensitiveStringCompare } from "../../common/string/compare";
|
||||
import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import type { HaDevicePickerDeviceFilterFunc } from "../../components/device/ha-device-picker";
|
||||
import type { PickerComboBoxItem } from "../../components/ha-picker-combo-box";
|
||||
@@ -25,17 +24,12 @@ import {
|
||||
export interface DevicePickerItem extends PickerComboBoxItem {
|
||||
domain?: string;
|
||||
domain_name?: string;
|
||||
// Set when this device is a child rendered indented under its parent.
|
||||
is_child?: boolean;
|
||||
// Set on the last child of a parent so the tree connector draws its end.
|
||||
last?: boolean;
|
||||
}
|
||||
|
||||
export interface DeviceAreaLabel {
|
||||
areaName?: string;
|
||||
viaDeviceName?: string;
|
||||
viaDeviceAreaName?: string;
|
||||
parentDeviceName?: string;
|
||||
}
|
||||
|
||||
export interface GetDevicesOptions {
|
||||
@@ -47,10 +41,6 @@ export interface GetDevicesOptions {
|
||||
excludeDevices?: string[];
|
||||
value?: string;
|
||||
idPrefix?: string;
|
||||
// When set, order the result so children directly follow their parent and
|
||||
// flag them for indented rendering. Requires the picker to disable its own
|
||||
// sorting (no-sort) so this order is preserved.
|
||||
nested?: boolean;
|
||||
}
|
||||
|
||||
export const computeDeviceAreaLabel = (
|
||||
@@ -79,16 +69,6 @@ export const computeDeviceAreaLabel = (
|
||||
? computeAreaName(viaDeviceArea)
|
||||
: undefined;
|
||||
|
||||
// A child device is a logical part of its parent. We surface the parent name
|
||||
// only as a search term (below) — not in the area label, which stays the pure
|
||||
// (inherited) area. The nested tree rendering communicates the relationship.
|
||||
const parentDevice = device.parent_device_id
|
||||
? devices[device.parent_device_id]
|
||||
: undefined;
|
||||
const parentDeviceName = parentDevice
|
||||
? computeDeviceNameDisplay(parentDevice, localize, states)
|
||||
: undefined;
|
||||
|
||||
const isRTL = computeRTL(language, translationMetadata.translations);
|
||||
|
||||
const areaName = area
|
||||
@@ -97,7 +77,7 @@ export const computeDeviceAreaLabel = (
|
||||
? `${viaDeviceAreaName}${isRTL ? " ◂ " : " ▸ "}${viaDeviceName}`
|
||||
: viaDeviceName || undefined;
|
||||
|
||||
return { areaName, viaDeviceName, viaDeviceAreaName, parentDeviceName };
|
||||
return { areaName, viaDeviceName, viaDeviceAreaName };
|
||||
};
|
||||
|
||||
export const deviceComboBoxKeys: FuseWeightedKey[] = [
|
||||
@@ -125,14 +105,6 @@ export const deviceComboBoxKeys: FuseWeightedKey[] = [
|
||||
name: "search_labels.viaDeviceArea",
|
||||
weight: 3,
|
||||
},
|
||||
{
|
||||
name: "search_labels.parentDeviceName",
|
||||
weight: 3,
|
||||
},
|
||||
{
|
||||
name: "search_labels.childDeviceNames",
|
||||
weight: 3,
|
||||
},
|
||||
];
|
||||
|
||||
export const getDevices = (
|
||||
@@ -149,7 +121,6 @@ export const getDevices = (
|
||||
excludeDevices,
|
||||
value,
|
||||
idPrefix = "",
|
||||
nested,
|
||||
} = options ?? {};
|
||||
|
||||
const devices = Object.values(hass.devices);
|
||||
@@ -157,60 +128,26 @@ export const getDevices = (
|
||||
|
||||
let deviceEntityLookup: DeviceEntityDisplayLookup = {};
|
||||
|
||||
const filtersEntities =
|
||||
includeDomains || excludeDomains || includeDeviceClasses || entityFilter;
|
||||
|
||||
if (filtersEntities) {
|
||||
if (
|
||||
includeDomains ||
|
||||
excludeDomains ||
|
||||
includeDeviceClasses ||
|
||||
entityFilter
|
||||
) {
|
||||
deviceEntityLookup = getDeviceEntityDisplayLookup(entities);
|
||||
}
|
||||
|
||||
// Targeting a device also targets its child devices (a parent inherits its
|
||||
// children's entities), so a device should match an entity-based filter when
|
||||
// it OR any of its children has a matching entity. Build a parent -> children
|
||||
// map and resolve each device's effective entity set accordingly. Nesting is
|
||||
// single-level, so one hop covers it.
|
||||
const filterChildrenByParent = new Map<string, DeviceRegistryEntry[]>();
|
||||
if (filtersEntities) {
|
||||
for (const device of devices) {
|
||||
if (device.parent_device_id) {
|
||||
const siblings = filterChildrenByParent.get(device.parent_device_id);
|
||||
if (siblings) {
|
||||
siblings.push(device);
|
||||
} else {
|
||||
filterChildrenByParent.set(device.parent_device_id, [device]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const effectiveEntities = (
|
||||
deviceId: string
|
||||
): EntityRegistryDisplayEntry[] => {
|
||||
const own = deviceEntityLookup[deviceId] ?? [];
|
||||
const children = filterChildrenByParent.get(deviceId);
|
||||
if (!children) {
|
||||
return own;
|
||||
}
|
||||
const combined = [...own];
|
||||
for (const child of children) {
|
||||
const childEntities = deviceEntityLookup[child.id];
|
||||
if (childEntities) {
|
||||
combined.push(...childEntities);
|
||||
}
|
||||
}
|
||||
return combined;
|
||||
};
|
||||
|
||||
let inputDevices = devices.filter(
|
||||
(device) => device.id === value || !device.disabled_by
|
||||
);
|
||||
|
||||
if (includeDomains) {
|
||||
inputDevices = inputDevices.filter((device) => {
|
||||
const devEntities = effectiveEntities(device.id);
|
||||
if (!devEntities.length) {
|
||||
const devEntities = deviceEntityLookup[device.id];
|
||||
if (!devEntities || !devEntities.length) {
|
||||
return false;
|
||||
}
|
||||
return devEntities.some((entity) =>
|
||||
return deviceEntityLookup[device.id].some((entity) =>
|
||||
includeDomains.includes(computeDomain(entity.entity_id))
|
||||
);
|
||||
});
|
||||
@@ -218,11 +155,11 @@ export const getDevices = (
|
||||
|
||||
if (excludeDomains) {
|
||||
inputDevices = inputDevices.filter((device) => {
|
||||
const devEntities = effectiveEntities(device.id);
|
||||
if (!devEntities.length) {
|
||||
const devEntities = deviceEntityLookup[device.id];
|
||||
if (!devEntities || !devEntities.length) {
|
||||
return true;
|
||||
}
|
||||
return devEntities.every(
|
||||
return entities.every(
|
||||
(entity) => !excludeDomains.includes(computeDomain(entity.entity_id))
|
||||
);
|
||||
});
|
||||
@@ -236,11 +173,11 @@ export const getDevices = (
|
||||
|
||||
if (includeDeviceClasses) {
|
||||
inputDevices = inputDevices.filter((device) => {
|
||||
const devEntities = effectiveEntities(device.id);
|
||||
if (!devEntities.length) {
|
||||
const devEntities = deviceEntityLookup[device.id];
|
||||
if (!devEntities || !devEntities.length) {
|
||||
return false;
|
||||
}
|
||||
return devEntities.some((entity) => {
|
||||
return deviceEntityLookup[device.id].some((entity) => {
|
||||
const stateObj = hass.states[entity.entity_id];
|
||||
if (!stateObj) {
|
||||
return false;
|
||||
@@ -255,8 +192,8 @@ export const getDevices = (
|
||||
|
||||
if (entityFilter) {
|
||||
inputDevices = inputDevices.filter((device) => {
|
||||
const devEntities = effectiveEntities(device.id);
|
||||
if (!devEntities.length) {
|
||||
const devEntities = deviceEntityLookup[device.id];
|
||||
if (!devEntities || !devEntities.length) {
|
||||
return false;
|
||||
}
|
||||
return devEntities.some((entity) => {
|
||||
@@ -285,7 +222,7 @@ export const getDevices = (
|
||||
deviceEntityLookup[device.id]
|
||||
);
|
||||
|
||||
const { areaName, viaDeviceName, viaDeviceAreaName, parentDeviceName } =
|
||||
const { areaName, viaDeviceName, viaDeviceAreaName } =
|
||||
computeDeviceAreaLabel(
|
||||
device,
|
||||
hass.areas,
|
||||
@@ -322,79 +259,10 @@ export const getDevices = (
|
||||
domainName: domainName || null,
|
||||
viaDeviceName: viaDeviceName || null,
|
||||
viaDeviceArea: viaDeviceAreaName || null,
|
||||
parentDeviceName: parentDeviceName || null,
|
||||
},
|
||||
sorting_label: [primary, areaName, domainName].filter(Boolean).join("_"),
|
||||
};
|
||||
});
|
||||
|
||||
if (!nested) {
|
||||
return outputDevices;
|
||||
}
|
||||
|
||||
// Order children directly after their parent and flag them for indented
|
||||
// rendering. outputDevices is 1:1 with inputDevices, so we can pair them up.
|
||||
const itemByDeviceId = new Map<string, DevicePickerItem>();
|
||||
inputDevices.forEach((device, index) => {
|
||||
itemByDeviceId.set(device.id, outputDevices[index]);
|
||||
});
|
||||
const presentIds = new Set(inputDevices.map((device) => device.id));
|
||||
|
||||
const childrenByParent = new Map<string, DeviceRegistryEntry[]>();
|
||||
const topLevel: DeviceRegistryEntry[] = [];
|
||||
for (const device of inputDevices) {
|
||||
const parentId = device.parent_device_id;
|
||||
// A child whose parent was filtered out is shown as a top-level row.
|
||||
if (parentId && presentIds.has(parentId)) {
|
||||
const siblings = childrenByParent.get(parentId);
|
||||
if (siblings) {
|
||||
siblings.push(device);
|
||||
} else {
|
||||
childrenByParent.set(parentId, [device]);
|
||||
}
|
||||
} else {
|
||||
topLevel.push(device);
|
||||
}
|
||||
}
|
||||
|
||||
const compareByName = (a: DeviceRegistryEntry, b: DeviceRegistryEntry) =>
|
||||
caseInsensitiveStringCompare(
|
||||
itemByDeviceId.get(a.id)!.primary,
|
||||
itemByDeviceId.get(b.id)!.primary,
|
||||
hass.locale.language
|
||||
);
|
||||
|
||||
topLevel.sort(compareByName);
|
||||
|
||||
const ordered: DevicePickerItem[] = [];
|
||||
for (const device of topLevel) {
|
||||
const parentItem = itemByDeviceId.get(device.id)!;
|
||||
const children = childrenByParent.get(device.id);
|
||||
if (children) {
|
||||
children.sort(compareByName);
|
||||
// Add the children's names to the parent's search terms so a search that
|
||||
// matches a child keeps the parent visible (mirrors how a floor stays
|
||||
// visible when one of its areas matches).
|
||||
ordered.push({
|
||||
...parentItem,
|
||||
search_labels: {
|
||||
...parentItem.search_labels,
|
||||
childDeviceNames: children
|
||||
.map((child) => itemByDeviceId.get(child.id)!.primary)
|
||||
.join(" "),
|
||||
},
|
||||
});
|
||||
children.forEach((child, index) => {
|
||||
ordered.push({
|
||||
...itemByDeviceId.get(child.id)!,
|
||||
is_child: true,
|
||||
last: index === children.length - 1,
|
||||
});
|
||||
});
|
||||
} else {
|
||||
ordered.push(parentItem);
|
||||
}
|
||||
}
|
||||
|
||||
return ordered;
|
||||
return outputDevices;
|
||||
};
|
||||
|
||||
@@ -81,75 +81,6 @@ export type DeviceRegistryListEntry =
|
||||
export const isChildDevice = (device: DeviceRegistryEntry): boolean =>
|
||||
device.parent_device_id !== null;
|
||||
|
||||
/**
|
||||
* Devices whose effective area is the given area: devices with that area, and
|
||||
* child devices that inherit it because they have no area of their own. Mirrors
|
||||
* core's dr.async_entries_for_area, so a child device with a different explicit
|
||||
* area is not part of its parent's area.
|
||||
*/
|
||||
export const devicesInEffectiveArea = (
|
||||
devices: Record<string, DeviceRegistryEntry>,
|
||||
areaId: string
|
||||
): DeviceRegistryEntry[] =>
|
||||
Object.values(devices).filter((device) => {
|
||||
if (device.area_id) {
|
||||
return device.area_id === areaId;
|
||||
}
|
||||
if (device.parent_device_id) {
|
||||
return devices[device.parent_device_id]?.area_id === areaId;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
export interface DeviceRowItem {
|
||||
device: DeviceRegistryEntry;
|
||||
isChild: boolean;
|
||||
// True for the last child of a parent, so the tree connector draws its end.
|
||||
isLastChild: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Order a flat device list so each child directly follows its parent, flagging
|
||||
* children for indented rendering. The incoming order of the top-level devices
|
||||
* (and of the children within each parent) is preserved. A child whose parent
|
||||
* is not in the list is treated as a top-level device.
|
||||
*/
|
||||
export const groupDevicesByParent = (
|
||||
devices: DeviceRegistryEntry[]
|
||||
): DeviceRowItem[] => {
|
||||
const presentIds = new Set(devices.map((device) => device.id));
|
||||
const childrenByParent = new Map<string, DeviceRegistryEntry[]>();
|
||||
const topLevel: DeviceRegistryEntry[] = [];
|
||||
|
||||
for (const device of devices) {
|
||||
const parentId = device.parent_device_id;
|
||||
if (parentId && presentIds.has(parentId)) {
|
||||
const siblings = childrenByParent.get(parentId);
|
||||
if (siblings) {
|
||||
siblings.push(device);
|
||||
} else {
|
||||
childrenByParent.set(parentId, [device]);
|
||||
}
|
||||
} else {
|
||||
topLevel.push(device);
|
||||
}
|
||||
}
|
||||
|
||||
const result: DeviceRowItem[] = [];
|
||||
for (const device of topLevel) {
|
||||
result.push({ device, isChild: false, isLastChild: false });
|
||||
const children = childrenByParent.get(device.id) ?? [];
|
||||
children.forEach((child, index) => {
|
||||
result.push({
|
||||
device: child,
|
||||
isChild: true,
|
||||
isLastChild: index === children.length - 1,
|
||||
});
|
||||
});
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export type DeviceEntityDisplayLookup = Record<
|
||||
string,
|
||||
EntityRegistryDisplayEntry[]
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
import type { DateRange } from "../common/datetime/calc_date_range";
|
||||
import { calcDateRange } from "../common/datetime/calc_date_range";
|
||||
import { formatTime24h } from "../common/datetime/format_time";
|
||||
import { DEFAULT_ENTITY_NAME } from "../common/entity/compute_entity_name_display";
|
||||
import { formatNumber } from "../common/number/format_number";
|
||||
import { normalizeValueBySIPrefix } from "../common/number/normalize-by-si-prefix";
|
||||
import { groupBy } from "../common/util/group-by";
|
||||
@@ -37,7 +36,6 @@ import type {
|
||||
import {
|
||||
fetchStatistics,
|
||||
getDisplayUnit,
|
||||
getStatisticLabel,
|
||||
getStatisticMetadata,
|
||||
VOLUME_UNITS,
|
||||
} from "./recorder";
|
||||
@@ -313,59 +311,6 @@ export interface EnergySourceByType {
|
||||
export const energySourcesByType = (prefs: EnergyPreferences) =>
|
||||
groupBy(prefs.energy_sources, (item) => item.type) as EnergySourceByType;
|
||||
|
||||
/**
|
||||
* Display name of a configured statistic. A name set by the user always wins;
|
||||
* otherwise the entity is named the same way the rest of the UI names
|
||||
* entities, so devices sharing an entity name stay distinguishable.
|
||||
* Statistics without an entity (external or removed) keep the statistic label.
|
||||
*/
|
||||
export const computeEnergyLabel = (
|
||||
hass: HomeAssistant,
|
||||
statisticId: string,
|
||||
statisticsMetaData?: StatisticsMetaData,
|
||||
customName?: string
|
||||
): string => {
|
||||
if (customName) {
|
||||
return customName;
|
||||
}
|
||||
|
||||
const stateObj = hass.states[statisticId];
|
||||
|
||||
if (stateObj) {
|
||||
return hass.formatEntityName(stateObj, DEFAULT_ENTITY_NAME);
|
||||
}
|
||||
|
||||
return getStatisticLabel(hass, statisticId, statisticsMetaData);
|
||||
};
|
||||
|
||||
/**
|
||||
* Device labels keyed by statistic id. Cards that show live power or flow
|
||||
* key their nodes by `stat_rate` instead of `stat_consumption`; devices
|
||||
* without the requested statistic are left out.
|
||||
*/
|
||||
export const computeEnergyDeviceLabels = (
|
||||
hass: HomeAssistant,
|
||||
devices: DeviceConsumptionEnergyPreference[],
|
||||
statsMetadata?: Record<string, StatisticsMetaData>,
|
||||
statisticKey: "stat_consumption" | "stat_rate" = "stat_consumption"
|
||||
): Record<string, string> => {
|
||||
const labels: Record<string, string> = {};
|
||||
|
||||
for (const device of devices) {
|
||||
const statisticId = device[statisticKey];
|
||||
if (statisticId) {
|
||||
labels[statisticId] = computeEnergyLabel(
|
||||
hass,
|
||||
statisticId,
|
||||
statsMetadata?.[statisticId],
|
||||
device.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return labels;
|
||||
};
|
||||
|
||||
export interface EnergyData {
|
||||
start: Date;
|
||||
end?: Date;
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@ export const getLogbookDataForContext = async (
|
||||
): Promise<LogbookEntry[]> =>
|
||||
getLogbookDataFromServer(hass, startDate, undefined, undefined, contextId);
|
||||
|
||||
export const getLogbookDataFromServer = (
|
||||
const getLogbookDataFromServer = (
|
||||
hass: HomeAssistant,
|
||||
startDate: string,
|
||||
endDate?: string,
|
||||
|
||||
@@ -2,15 +2,12 @@ import type { Connection } from "home-assistant-js-websocket";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import type { LovelaceResource } from "../resource";
|
||||
import type { LovelaceStrategyConfig } from "./strategy";
|
||||
import type {
|
||||
LovelaceDashboardBackgroundConfig,
|
||||
LovelaceViewRawConfig,
|
||||
} from "./view";
|
||||
import type { LovelaceViewRawConfig } from "./view";
|
||||
|
||||
export interface LovelaceDashboardBaseConfig {}
|
||||
|
||||
export interface LovelaceConfig extends LovelaceDashboardBaseConfig {
|
||||
background?: LovelaceDashboardBackgroundConfig;
|
||||
background?: string;
|
||||
views: LovelaceViewRawConfig[];
|
||||
}
|
||||
|
||||
|
||||
@@ -31,9 +31,6 @@ export interface LovelaceViewBackgroundConfig {
|
||||
attachment?: "scroll" | "fixed";
|
||||
}
|
||||
|
||||
export type LovelaceDashboardBackgroundConfig =
|
||||
string | LovelaceViewBackgroundConfig;
|
||||
|
||||
export interface LovelaceViewHeaderConfig {
|
||||
card?: LovelaceCardConfig;
|
||||
layout?: "start" | "center" | "responsive";
|
||||
@@ -63,7 +60,7 @@ export interface LovelaceBaseViewConfig {
|
||||
show_icon_and_title?: boolean;
|
||||
theme?: string;
|
||||
panel?: boolean;
|
||||
background?: LovelaceDashboardBackgroundConfig;
|
||||
background?: string | LovelaceViewBackgroundConfig;
|
||||
visible?: boolean | ShowViewConfig[];
|
||||
subview?: boolean;
|
||||
back_path?: string;
|
||||
|
||||
@@ -45,73 +45,6 @@ export interface MatterNodeDiagnostics {
|
||||
|
||||
export type MatterPingResult = Record<string, boolean>;
|
||||
|
||||
export type MatterTopologyNodeKind =
|
||||
"matter" | "border_router" | "thread_unknown" | "wifi_ap";
|
||||
|
||||
export type MatterTopologyStrength =
|
||||
"strong" | "medium" | "weak" | "none" | "unknown";
|
||||
|
||||
export interface MatterTopologyDirectionInfo {
|
||||
strength: MatterTopologyStrength;
|
||||
lqi?: number | null;
|
||||
rssi?: number | null;
|
||||
}
|
||||
|
||||
export interface MatterNetworkTopologyNode {
|
||||
id: string;
|
||||
kind: MatterTopologyNodeKind;
|
||||
network_type: string;
|
||||
node_id?: number | null;
|
||||
ha_device_id?: string | null;
|
||||
role?: string | null;
|
||||
available?: boolean | null;
|
||||
is_bridge?: boolean | null;
|
||||
ext_address?: string | null;
|
||||
rloc16?: number | null;
|
||||
ext_pan_id?: string | null;
|
||||
network_name?: string | null;
|
||||
ssid?: string | null;
|
||||
bssid?: string | null;
|
||||
host_name?: string | null;
|
||||
vendor_name?: string | null;
|
||||
model_name?: string | null;
|
||||
last_seen?: number | null;
|
||||
}
|
||||
|
||||
export interface MatterNetworkTopologyConnection {
|
||||
source: string;
|
||||
target: string;
|
||||
network: string;
|
||||
strength: MatterTopologyStrength;
|
||||
source_to_target?: MatterTopologyDirectionInfo | null;
|
||||
target_to_source?: MatterTopologyDirectionInfo | null;
|
||||
via_route_table?: boolean | null;
|
||||
path_cost?: number | null;
|
||||
}
|
||||
|
||||
export interface MatterNetworkTopology {
|
||||
collected_at: number;
|
||||
nodes: MatterNetworkTopologyNode[];
|
||||
connections: MatterNetworkTopologyConnection[];
|
||||
}
|
||||
|
||||
export const fetchMatterNetworkTopology = (
|
||||
hass: HomeAssistant,
|
||||
refresh = false
|
||||
): Promise<MatterNetworkTopology> =>
|
||||
hass.callWS({
|
||||
type: "matter/network_topology",
|
||||
refresh,
|
||||
});
|
||||
|
||||
export const subscribeMatterNetworkTopology = (
|
||||
hass: HomeAssistant,
|
||||
callback: (topology: MatterNetworkTopology) => void
|
||||
): Promise<UnsubscribeFunc> =>
|
||||
hass.connection.subscribeMessage<MatterNetworkTopology>(callback, {
|
||||
type: "matter/subscribe_network_topology",
|
||||
});
|
||||
|
||||
export interface MatterCommissioningParameters {
|
||||
setup_pin_code: number;
|
||||
setup_manual_code: string;
|
||||
|
||||
+11
-33
@@ -14,7 +14,6 @@ import type {
|
||||
import type { HomeAssistant } from "../types";
|
||||
import {
|
||||
type DeviceRegistryEntry,
|
||||
devicesInEffectiveArea,
|
||||
getDeviceIntegrationLookup,
|
||||
} from "./device/device_registry";
|
||||
import type {
|
||||
@@ -727,10 +726,9 @@ export const expandAreaTarget = (
|
||||
) => {
|
||||
const newEntities: string[] = [];
|
||||
const newDevices: string[] = [];
|
||||
// Devices of an area are its effective-area members: a child device inheriting
|
||||
// this area counts, a child with a different explicit area does not.
|
||||
devicesInEffectiveArea(devices, areaId).forEach((device) => {
|
||||
Object.values(devices).forEach((device) => {
|
||||
if (
|
||||
device.area_id === areaId &&
|
||||
deviceMeetsTargetSelector(
|
||||
hass.states,
|
||||
Object.values(entities),
|
||||
@@ -792,8 +790,9 @@ export const areaMeetsTargetSelector = (
|
||||
targetSelector: TargetSelector,
|
||||
entitySources?: EntitySources
|
||||
): boolean => {
|
||||
const hasMatchingdevice = devicesInEffectiveArea(devices, areaId).some(
|
||||
(device) =>
|
||||
const hasMatchingdevice = Object.values(devices).some((device) => {
|
||||
if (
|
||||
device.area_id === areaId &&
|
||||
deviceMeetsTargetSelector(
|
||||
hass.states,
|
||||
Object.values(entities),
|
||||
@@ -801,7 +800,11 @@ export const areaMeetsTargetSelector = (
|
||||
targetSelector,
|
||||
entitySources
|
||||
)
|
||||
);
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (hasMatchingdevice) {
|
||||
return true;
|
||||
}
|
||||
@@ -843,8 +846,6 @@ export const deviceMeetsTargetSelector = (
|
||||
}
|
||||
}
|
||||
if (targetSelector.target?.entity) {
|
||||
// Only the device's own entities: a child device is reached through the
|
||||
// device target itself, so a parent must not match on a child's behalf.
|
||||
const entities = entityRegistry.filter(
|
||||
(reg) => reg.device_id === device.id
|
||||
);
|
||||
@@ -1111,11 +1112,6 @@ export const resolveEntityIDs = (
|
||||
const targetFloors = new Set(ensureArray(targetPickerValue.floor_id));
|
||||
const targetLabels = new Set(ensureArray(targetPickerValue.label_id));
|
||||
|
||||
// Only a directly targeted device pulls in its child devices. Devices that are
|
||||
// only reached through a label or an area must not, because core does not
|
||||
// inherit labels to children and resolves areas by effective area membership.
|
||||
const directDevices = new Set(targetDevices);
|
||||
|
||||
targetLabels.forEach((labelId) => {
|
||||
const expanded = expandLabelTarget(
|
||||
hass,
|
||||
@@ -1135,10 +1131,6 @@ export const resolveEntityIDs = (
|
||||
expanded.areas.forEach((id) => targetAreas.add(id));
|
||||
});
|
||||
|
||||
// Devices only reached through an area do not pull in entities that are
|
||||
// explicitly assigned to another area, matching core.
|
||||
const devicesNotViaArea = new Set(targetDevices);
|
||||
|
||||
targetAreas.forEach((areaId) => {
|
||||
const expanded = expandAreaTarget(
|
||||
hass,
|
||||
@@ -1151,16 +1143,6 @@ export const resolveEntityIDs = (
|
||||
expanded.entities.forEach((id) => targetEntities.add(id));
|
||||
});
|
||||
|
||||
// Targeting a device also targets its child devices, matching core's
|
||||
// server-side target resolution. Only direct device targets expand this way;
|
||||
// nesting is single-level, so one pass is enough.
|
||||
Object.values(devices).forEach((device) => {
|
||||
if (device.parent_device_id && directDevices.has(device.parent_device_id)) {
|
||||
targetDevices.add(device.id);
|
||||
devicesNotViaArea.add(device.id);
|
||||
}
|
||||
});
|
||||
|
||||
targetDevices.forEach((deviceId) => {
|
||||
const expanded = expandDeviceTarget(
|
||||
hass,
|
||||
@@ -1168,11 +1150,7 @@ export const resolveEntityIDs = (
|
||||
entities,
|
||||
targetSelector
|
||||
);
|
||||
expanded.entities.forEach((id) => {
|
||||
if (devicesNotViaArea.has(deviceId) || !entities[id]?.area_id) {
|
||||
targetEntities.add(id);
|
||||
}
|
||||
});
|
||||
expanded.entities.forEach((id) => targetEntities.add(id));
|
||||
});
|
||||
|
||||
return Array.from(targetEntities);
|
||||
|
||||
+4
-8
@@ -7,10 +7,7 @@ import type { CallWS, HomeAssistant } from "../types";
|
||||
import type { AreaRegistryEntry } from "./area/area_registry";
|
||||
import type { FloorComboBoxItem } from "./area_floor_picker";
|
||||
import type { DevicePickerItem } from "./device/device_picker";
|
||||
import {
|
||||
devicesInEffectiveArea,
|
||||
type DeviceRegistryEntry,
|
||||
} from "./device/device_registry";
|
||||
import type { DeviceRegistryEntry } from "./device/device_registry";
|
||||
import type { HaEntityPickerEntityFilterFunc } from "./entity/entity";
|
||||
import type { EntityComboBoxItem } from "./entity/entity_picker";
|
||||
import type { EntityRegistryDisplayEntry } from "./entity/entity_registry";
|
||||
@@ -128,7 +125,9 @@ export const areaMeetsFilter = (
|
||||
entityFilter?: HaEntityPickerEntityFilterFunc,
|
||||
includeSecondary = false
|
||||
): boolean => {
|
||||
const areaDevices = devicesInEffectiveArea(devices, area.area_id);
|
||||
const areaDevices = Object.values(devices).filter(
|
||||
(device) => device.area_id === area.area_id
|
||||
);
|
||||
|
||||
if (
|
||||
areaDevices.some((device) =>
|
||||
@@ -179,9 +178,6 @@ export const deviceMeetsFilter = (
|
||||
entityFilter?: HaEntityPickerEntityFilterFunc,
|
||||
includeSecondary = false
|
||||
): boolean => {
|
||||
// Only the device's own entities: child devices are targeted through the
|
||||
// device itself (see core's target resolution), not by making a parent match
|
||||
// on behalf of a child.
|
||||
const devEntities = Object.values(entities).filter(
|
||||
(entity) => entity.device_id === device.id
|
||||
);
|
||||
|
||||
@@ -184,7 +184,6 @@ export const checkForEntityUpdates = async (
|
||||
}
|
||||
|
||||
showToast(element, {
|
||||
id: "check-updates",
|
||||
message: hass.localize("ui.panel.config.updates.checking_updates"),
|
||||
});
|
||||
|
||||
@@ -195,7 +194,6 @@ export const checkForEntityUpdates = async (
|
||||
if (computeDomain(event.data.entity_id) === "update") {
|
||||
updated++;
|
||||
showToast(element, {
|
||||
id: "check-updates",
|
||||
message: hass.localize("ui.panel.config.updates.updates_refreshed", {
|
||||
count: updated,
|
||||
}),
|
||||
@@ -218,7 +216,6 @@ export const checkForEntityUpdates = async (
|
||||
|
||||
if (updated === 0) {
|
||||
showToast(element, {
|
||||
id: "check-updates",
|
||||
message: hass.localize("ui.panel.config.updates.no_new_updates"),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ export enum VacuumEntityFeature {
|
||||
STOP = 8,
|
||||
RETURN_HOME = 16,
|
||||
FAN_SPEED = 32,
|
||||
BATTERY = 64,
|
||||
STATUS = 128,
|
||||
SEND_COMMAND = 256,
|
||||
LOCATE = 512,
|
||||
@@ -33,6 +34,7 @@ export enum VacuumEntityFeature {
|
||||
}
|
||||
|
||||
interface VacuumEntityAttributes extends HassEntityAttributeBase {
|
||||
battery_level?: number;
|
||||
fan_speed?: string;
|
||||
fan_speed_list?: string[];
|
||||
[key: string]: any;
|
||||
|
||||
+2
-3
@@ -566,10 +566,9 @@ export const getWeatherStateIcon = (
|
||||
if (userDefinedIcon) {
|
||||
return html`
|
||||
<div
|
||||
style=${styleMap({
|
||||
"background-size": "cover",
|
||||
style="background-size: cover;${styleMap({
|
||||
"background-image": userDefinedIcon,
|
||||
})}
|
||||
})}"
|
||||
></div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Connection, UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { callWS } from "../util/websocket";
|
||||
import type { DeviceRegistryEntry } from "./device/device_registry";
|
||||
|
||||
export enum InclusionState {
|
||||
/** The controller isn't doing anything regarding inclusion. */
|
||||
@@ -466,27 +465,6 @@ export interface RequestedGrant {
|
||||
clientSideAuth: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Z-Wave node ID of a device from its registry identifiers, which have
|
||||
* the form `<home id>-<node id>[-<manufacturer>:<product type>:<product id>]`.
|
||||
* Returns undefined for devices without a node, e.g. provisioning entries.
|
||||
*/
|
||||
export const getNodeIdFromDevice = (
|
||||
device: DeviceRegistryEntry
|
||||
): number | undefined => {
|
||||
for (const [domain, identifier] of device.identifiers) {
|
||||
// a provisioning entry is identified by its DSK, whose blocks parse as numbers
|
||||
if (domain !== "zwave_js" || identifier.startsWith("provision_")) {
|
||||
continue;
|
||||
}
|
||||
const nodeId = parseInt(identifier.split("-")[1]);
|
||||
if (!isNaN(nodeId)) {
|
||||
return nodeId;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const invokeZWaveCCApi = <T = unknown>(
|
||||
hass: HomeAssistant,
|
||||
device_id: string,
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { throttle } from "../../../../common/util/throttle";
|
||||
import "../../../../components/ha-color-temp-brightness-picker";
|
||||
import { apiContext } from "../../../../data/context";
|
||||
import { UNAVAILABLE } from "../../../../data/entity/entity";
|
||||
import type { LightEntity } from "../../../../data/light";
|
||||
import { LightColorMode } from "../../../../data/light";
|
||||
|
||||
@customElement("light-color-temp-brightness-picker")
|
||||
class LightColorTempBrightnessPicker extends LitElement {
|
||||
@state()
|
||||
@consume({ context: apiContext, subscribe: true })
|
||||
private _api!: ContextType<typeof apiContext>;
|
||||
|
||||
@property({ attribute: false }) public stateObj!: LightEntity;
|
||||
|
||||
@state() private _value?: [number, number];
|
||||
|
||||
@state() private _isInteracting?: boolean;
|
||||
|
||||
protected render() {
|
||||
if (!this.stateObj) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-color-temp-brightness-picker
|
||||
.value=${this._value}
|
||||
.minKelvin=${this.stateObj.attributes.min_color_temp_kelvin}
|
||||
.maxKelvin=${this.stateObj.attributes.max_color_temp_kelvin}
|
||||
.disabled=${this.stateObj.state === UNAVAILABLE}
|
||||
@cursor-moved=${this._cursorMoved}
|
||||
@value-changed=${this._valueChanged}
|
||||
>
|
||||
</ha-color-temp-brightness-picker>
|
||||
`;
|
||||
}
|
||||
|
||||
private _updateValues() {
|
||||
const stateObj = this.stateObj;
|
||||
|
||||
if (stateObj.state !== "on") {
|
||||
this._value = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const kelvin =
|
||||
stateObj.attributes.color_mode === LightColorMode.COLOR_TEMP
|
||||
? stateObj.attributes.color_temp_kelvin
|
||||
: undefined;
|
||||
const brightness = stateObj.attributes.brightness;
|
||||
|
||||
this._value =
|
||||
kelvin != null && brightness != null
|
||||
? [kelvin, Math.max(Math.round((brightness * 100) / 255), 1)]
|
||||
: undefined;
|
||||
}
|
||||
|
||||
public willUpdate(changedProps: PropertyValues<this>) {
|
||||
super.willUpdate(changedProps);
|
||||
|
||||
if (this._isInteracting || !changedProps.has("stateObj")) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._updateValues();
|
||||
}
|
||||
|
||||
private _cursorMoved(ev: CustomEvent) {
|
||||
const value = ev.detail.value;
|
||||
|
||||
this._isInteracting = value !== undefined;
|
||||
|
||||
if (value === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._value = value;
|
||||
|
||||
this._throttleUpdateLight();
|
||||
}
|
||||
|
||||
private _throttleUpdateLight = throttle(() => {
|
||||
this._updateLight();
|
||||
}, 500);
|
||||
|
||||
private _valueChanged(ev: CustomEvent) {
|
||||
const value = ev.detail.value;
|
||||
|
||||
if (value === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._value = value;
|
||||
|
||||
this._throttleUpdateLight.cancel();
|
||||
this._updateLight();
|
||||
}
|
||||
|
||||
private _updateLight() {
|
||||
const [color_temp_kelvin, brightness_pct] = this._value!;
|
||||
|
||||
this._api.callService("light", "turn_on", {
|
||||
entity_id: this.stateObj!.entity_id,
|
||||
color_temp_kelvin,
|
||||
brightness_pct,
|
||||
});
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
ha-color-temp-brightness-picker {
|
||||
height: 45vh;
|
||||
max-height: 320px;
|
||||
min-height: 200px;
|
||||
aspect-ratio: 1;
|
||||
max-width: 100%;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"light-color-temp-brightness-picker": LightColorTempBrightnessPicker;
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,21 @@ import { isNumericEntity } from "../../data/history";
|
||||
import { CONTINUOUS_DOMAINS } from "../../data/logbook";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
|
||||
export {
|
||||
isMoreInfoView,
|
||||
MORE_INFO_VIEWS,
|
||||
type MoreInfoView,
|
||||
} from "./more-info-view";
|
||||
export const MORE_INFO_VIEWS = [
|
||||
"info",
|
||||
"history",
|
||||
"settings",
|
||||
"related",
|
||||
"add_to",
|
||||
"details",
|
||||
] as const;
|
||||
|
||||
export type MoreInfoView = (typeof MORE_INFO_VIEWS)[number];
|
||||
|
||||
export const isMoreInfoView = (
|
||||
value: string | undefined
|
||||
): value is MoreInfoView =>
|
||||
value !== undefined && (MORE_INFO_VIEWS as readonly string[]).includes(value);
|
||||
|
||||
export const DOMAINS_NO_INFO = ["camera", "configurator"];
|
||||
/**
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { createContext } from "@lit/context";
|
||||
|
||||
export interface MoreInfoContext {
|
||||
hash: URLSearchParams;
|
||||
setHashParam: (key: string, value?: string) => void;
|
||||
}
|
||||
|
||||
export const moreInfoContext =
|
||||
createContext<MoreInfoContext>("more-info-context");
|
||||
@@ -41,13 +41,11 @@ import "../components/ha-more-info-state-header";
|
||||
import "../components/lights/ha-favorite-color-button";
|
||||
import "../components/lights/ha-more-info-light-favorite-colors";
|
||||
import "../components/lights/light-color-rgb-picker";
|
||||
import "../components/lights/light-color-temp-brightness-picker";
|
||||
import "../components/lights/light-color-temp-picker";
|
||||
import { moreInfoControlStyle } from "../components/more-info-control-style";
|
||||
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
|
||||
|
||||
type MainControl =
|
||||
"brightness" | "color_temp" | "color" | "color_temp_brightness";
|
||||
type MainControl = "brightness" | "color_temp" | "color";
|
||||
|
||||
@customElement("more-info-light")
|
||||
class MoreInfoLight extends LitElement {
|
||||
@@ -80,31 +78,12 @@ class MoreInfoLight extends LitElement {
|
||||
.attributeValue=${value}
|
||||
></ha-attribute-icon>`;
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues<this>): void {
|
||||
super.willUpdate(changedProps);
|
||||
if (
|
||||
changedProps.has("stateObj") &&
|
||||
this.stateObj &&
|
||||
this._mainControl === "color_temp_brightness" &&
|
||||
!this._supportsDualAxis(this.stateObj)
|
||||
) {
|
||||
this._mainControl = "brightness";
|
||||
}
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues<this>): void {
|
||||
if (changedProps.has("stateObj")) {
|
||||
this._effect = this.stateObj?.attributes.effect;
|
||||
}
|
||||
}
|
||||
|
||||
private _supportsDualAxis(stateObj: LightEntity): boolean {
|
||||
return (
|
||||
lightSupportsBrightness(stateObj) &&
|
||||
lightSupportsColorMode(stateObj, LightColorMode.COLOR_TEMP)
|
||||
);
|
||||
}
|
||||
|
||||
private _setMainControl(ev: any) {
|
||||
ev.stopPropagation();
|
||||
this._mainControl = ev.currentTarget.control;
|
||||
@@ -149,8 +128,6 @@ class MoreInfoLight extends LitElement {
|
||||
LightEntityFeature.EFFECT
|
||||
);
|
||||
|
||||
const supportsDualAxis = this._supportsDualAxis(this.stateObj);
|
||||
|
||||
const showFavoriteColors = Boolean(
|
||||
this.entry &&
|
||||
(this.editMode ||
|
||||
@@ -206,17 +183,6 @@ class MoreInfoLight extends LitElement {
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
supportsDualAxis &&
|
||||
this._mainControl === "color_temp_brightness"
|
||||
? html`
|
||||
<light-color-temp-brightness-picker
|
||||
.stateObj=${this.stateObj}
|
||||
>
|
||||
</light-color-temp-brightness-picker>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<ha-icon-button-group>
|
||||
${
|
||||
supportsBrightness
|
||||
@@ -288,26 +254,6 @@ class MoreInfoLight extends LitElement {
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
supportsDualAxis
|
||||
? html`
|
||||
<ha-icon-button-toggle
|
||||
border-only
|
||||
.selected=${
|
||||
this._mainControl === "color_temp_brightness"
|
||||
}
|
||||
.disabled=${this.stateObj!.state === UNAVAILABLE}
|
||||
.label=${this._localize(
|
||||
"ui.dialogs.more_info_control.light.color_temp_brightness"
|
||||
)}
|
||||
.control=${"color_temp_brightness"}
|
||||
@click=${this._setMainControl}
|
||||
>
|
||||
<span class="wheel color-temp-brightness"></span>
|
||||
</ha-icon-button-toggle>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
supportsWhite
|
||||
? html`
|
||||
@@ -438,15 +384,6 @@ class MoreInfoLight extends LitElement {
|
||||
rgb(255, 160, 0) 100%
|
||||
);
|
||||
}
|
||||
.wheel.color-temp-brightness {
|
||||
background-image:
|
||||
linear-gradient(to top, rgb(102, 102, 102), white),
|
||||
linear-gradient(90deg, rgb(255, 160, 0), white, rgb(166, 209, 255));
|
||||
background-blend-mode: multiply;
|
||||
/* Mask the blended result as one layer; radius clipping bleeds
|
||||
per background layer at the edge in Gecko */
|
||||
clip-path: inset(0 round var(--ha-border-radius-circle));
|
||||
}
|
||||
*[disabled] .wheel {
|
||||
filter: grayscale(1) opacity(0.5);
|
||||
}
|
||||
|
||||
@@ -159,6 +159,23 @@ class MoreInfoVacuum extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
// Use deprecated battery_level and battery_icon attributes
|
||||
if (
|
||||
supportsFeature(this.stateObj, VacuumEntityFeature.BATTERY) &&
|
||||
this.stateObj.attributes.battery_level
|
||||
) {
|
||||
return html`
|
||||
<span class="battery" slot="after-time">
|
||||
<span
|
||||
>${Math.round(
|
||||
this.stateObj.attributes.battery_level
|
||||
)}${blankBeforePercent(this._i18n.locale)}%</span
|
||||
>
|
||||
<ha-icon .icon=${this.stateObj.attributes.battery_icon}></ha-icon>
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
return nothing;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import { formatDateWeekdayShort } from "../../../common/datetime/format_date";
|
||||
import { formatTime } from "../../../common/datetime/format_time";
|
||||
import { transform } from "../../../common/decorators/transform";
|
||||
import { formatNumber } from "../../../common/number/format_number";
|
||||
import type { HASSDomEvent } from "../../../common/dom/fire_event";
|
||||
import "../../../components/ha-alert";
|
||||
import "../../../components/ha-relative-time";
|
||||
import "../../../components/ha-spinner";
|
||||
@@ -49,16 +48,11 @@ import type {
|
||||
HomeAssistantFormatters,
|
||||
HomeAssistantInternationalization,
|
||||
} from "../../../types";
|
||||
import { moreInfoContext, type MoreInfoContext } from "../context";
|
||||
|
||||
@customElement("more-info-weather")
|
||||
class MoreInfoWeather extends LitElement {
|
||||
@property({ attribute: false }) public stateObj?: WeatherEntity;
|
||||
|
||||
@state()
|
||||
@consume({ context: moreInfoContext, subscribe: true })
|
||||
private _moreInfoContext?: MoreInfoContext;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n!: HomeAssistantInternationalization;
|
||||
@@ -133,33 +127,15 @@ class MoreInfoWeather extends LitElement {
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
super.willUpdate(changedProps);
|
||||
|
||||
if (
|
||||
(changedProps.has("stateObj") ||
|
||||
changedProps.has("_moreInfoContext") ||
|
||||
!this._subscribed) &&
|
||||
this.stateObj
|
||||
) {
|
||||
if ((changedProps.has("stateObj") || !this._subscribed) && this.stateObj) {
|
||||
const oldState = changedProps.get("stateObj") as
|
||||
WeatherEntity | undefined;
|
||||
if (
|
||||
oldState?.entity_id !== this.stateObj?.entity_id ||
|
||||
changedProps.has("_moreInfoContext") ||
|
||||
!this._subscribed
|
||||
) {
|
||||
const supportedForecastTypes = getSupportedForecastTypes(this.stateObj);
|
||||
const requestedForecastType =
|
||||
this._moreInfoContext?.hash.get("forecast");
|
||||
const selectedForecastType =
|
||||
supportedForecastTypes.find(
|
||||
(forecastType) => forecastType === requestedForecastType
|
||||
) ?? getDefaultForecastType(this.stateObj);
|
||||
if (selectedForecastType !== requestedForecastType) {
|
||||
this._moreInfoContext?.setHashParam("forecast", selectedForecastType);
|
||||
}
|
||||
if (this._forecastType !== selectedForecastType || !this._subscribed) {
|
||||
this._forecastType = selectedForecastType;
|
||||
this._subscribeForecastEvents();
|
||||
}
|
||||
this._forecastType = getDefaultForecastType(this.stateObj);
|
||||
this._subscribeForecastEvents();
|
||||
}
|
||||
} else if (changedProps.has("_forecastType")) {
|
||||
this._subscribeForecastEvents();
|
||||
@@ -533,17 +509,8 @@ class MoreInfoWeather extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _handleForecastTypeChanged(
|
||||
ev: HASSDomEvent<{ name: ModernForecastType }>
|
||||
): void {
|
||||
if (
|
||||
!this.stateObj ||
|
||||
!getSupportedForecastTypes(this.stateObj).includes(ev.detail.name)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
private _handleForecastTypeChanged(ev: CustomEvent): void {
|
||||
this._forecastType = ev.detail.name;
|
||||
this._moreInfoContext?.setHashParam("forecast", this._forecastType);
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
mdiTransitConnectionVariant,
|
||||
} from "@mdi/js";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { provide } from "@lit/context";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
@@ -44,10 +43,8 @@ import { shouldHandleRequestSelectedEvent } from "../../common/mwc/handle-reques
|
||||
import {
|
||||
getHistoryState,
|
||||
navigate,
|
||||
replaceCurrentUrl,
|
||||
updateHistoryState,
|
||||
} from "../../common/navigate";
|
||||
import { createMoreInfoUrl } from "../../common/url/more-info-query-params";
|
||||
import type { LocalizeKeys } from "../../common/translations/localize";
|
||||
import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import { withViewTransition } from "../../common/util/view-transition";
|
||||
@@ -88,7 +85,6 @@ import {
|
||||
EDITABLE_DOMAINS_WITH_UNIQUE_ID,
|
||||
type MoreInfoView,
|
||||
} from "./const";
|
||||
import { moreInfoContext, type MoreInfoContext } from "./context";
|
||||
import "./controls/more-info-default";
|
||||
import type { FavoritesDialogContext } from "./favorites";
|
||||
import { getFavoritesDialogHandler } from "./favorites";
|
||||
@@ -106,9 +102,6 @@ export interface MoreInfoDialogParams {
|
||||
tab?: MoreInfoView;
|
||||
large?: boolean;
|
||||
data?: Record<string, any>;
|
||||
hash?: URLSearchParams;
|
||||
fromUrl?: boolean;
|
||||
returnUrl?: string;
|
||||
parentElement?: LitElement;
|
||||
}
|
||||
|
||||
@@ -154,12 +147,6 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
|
||||
|
||||
@state() private _data?: Record<string, any>;
|
||||
|
||||
@provide({ context: moreInfoContext })
|
||||
@state()
|
||||
private _moreInfoContext: MoreInfoContext = this._createMoreInfoContext();
|
||||
|
||||
private _returnUrl?: string;
|
||||
|
||||
@state() private _currView: MoreInfoView = DEFAULT_VIEW;
|
||||
|
||||
@state() private _initialView: MoreInfoView = DEFAULT_VIEW;
|
||||
@@ -194,8 +181,6 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
|
||||
const view = params.view || params.tab || DEFAULT_VIEW;
|
||||
|
||||
this._data = params.data;
|
||||
this._moreInfoContext = this._createMoreInfoContext(params.hash);
|
||||
this._returnUrl = params.returnUrl;
|
||||
this._currView = view;
|
||||
this._initialView = view;
|
||||
this._childViewStack = [];
|
||||
@@ -231,9 +216,6 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
|
||||
}
|
||||
|
||||
private _dialogClosed() {
|
||||
if (this._returnUrl) {
|
||||
replaceCurrentUrl(this._returnUrl);
|
||||
}
|
||||
this._entityId = undefined;
|
||||
this._parentEntityIds = [];
|
||||
this._entry = undefined;
|
||||
@@ -242,8 +224,6 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
|
||||
this._initialView = DEFAULT_VIEW;
|
||||
this._currView = DEFAULT_VIEW;
|
||||
this._childViewStack = [];
|
||||
this._moreInfoContext = this._createMoreInfoContext();
|
||||
this._returnUrl = undefined;
|
||||
this._isEscapeEnabled = true;
|
||||
window.removeEventListener("dialog-closed", this._enableEscapeKeyClose);
|
||||
window.removeEventListener("show-dialog", this._disableEscapeKeyClose);
|
||||
@@ -291,10 +271,7 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
|
||||
return entity?.device_id ?? null;
|
||||
}
|
||||
|
||||
private _setView(view: MoreInfoView, preserveHash = false) {
|
||||
if (view !== this._currView && !preserveHash) {
|
||||
this._moreInfoContext = this._createMoreInfoContext();
|
||||
}
|
||||
private _setView(view: MoreInfoView) {
|
||||
updateHistoryState({
|
||||
dialogParams: {
|
||||
...getHistoryState()?.dialogParams,
|
||||
@@ -302,38 +279,6 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
|
||||
},
|
||||
});
|
||||
this._currView = view;
|
||||
this._syncUrl();
|
||||
}
|
||||
|
||||
private _syncUrl() {
|
||||
if (!this._returnUrl || !this._entityId) {
|
||||
return;
|
||||
}
|
||||
replaceCurrentUrl(
|
||||
createMoreInfoUrl(this._returnUrl, {
|
||||
entityId: this._entityId,
|
||||
view: this._currView,
|
||||
hash: this._moreInfoContext.hash,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private _createMoreInfoContext(hash?: URLSearchParams): MoreInfoContext {
|
||||
return {
|
||||
hash: new URLSearchParams(hash),
|
||||
setHashParam: (key, value) => this._setHashParam(key, value),
|
||||
};
|
||||
}
|
||||
|
||||
private _setHashParam(key: string, value?: string) {
|
||||
const hash = new URLSearchParams(this._moreInfoContext.hash);
|
||||
if (value) {
|
||||
hash.set(key, value);
|
||||
} else {
|
||||
hash.delete(key);
|
||||
}
|
||||
this._moreInfoContext = this._createMoreInfoContext(hash);
|
||||
this._syncUrl();
|
||||
}
|
||||
|
||||
private _goBack() {
|
||||
@@ -361,9 +306,7 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
|
||||
if (this._parentEntityIds.length > 0) {
|
||||
this._entityId = this._parentEntityIds.pop();
|
||||
this._currView = DEFAULT_VIEW;
|
||||
this._moreInfoContext = this._createMoreInfoContext();
|
||||
this._loadEntityRegistryEntry();
|
||||
this._syncUrl();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1064,22 +1007,19 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
|
||||
}
|
||||
const view = ev.detail.view || ev.detail.tab || DEFAULT_VIEW;
|
||||
if (entityId === this._entityId) {
|
||||
this._moreInfoContext = this._createMoreInfoContext(ev.detail.hash);
|
||||
this._infoEditMode = false;
|
||||
this._detailsYamlMode = false;
|
||||
this._setView(view, true);
|
||||
this._setView(view);
|
||||
return;
|
||||
}
|
||||
this._parentEntityIds = [...this._parentEntityIds, this._entityId!];
|
||||
this._entityId = entityId;
|
||||
this._moreInfoContext = this._createMoreInfoContext(ev.detail.hash);
|
||||
this._currView = view === "details" ? view : DEFAULT_VIEW;
|
||||
this._initialView = view;
|
||||
this._infoEditMode = false;
|
||||
this._detailsYamlMode = false;
|
||||
this._childViewStack = [];
|
||||
this._loadEntityRegistryEntry();
|
||||
this._syncUrl();
|
||||
}
|
||||
|
||||
private _enableEscapeKeyClose = () => {
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
export const MORE_INFO_VIEWS = [
|
||||
"info",
|
||||
"history",
|
||||
"settings",
|
||||
"related",
|
||||
"add_to",
|
||||
"details",
|
||||
] as const;
|
||||
|
||||
export type MoreInfoView = (typeof MORE_INFO_VIEWS)[number];
|
||||
|
||||
export const isMoreInfoView = (
|
||||
value: string | undefined
|
||||
): value is MoreInfoView =>
|
||||
value !== undefined && (MORE_INFO_VIEWS as readonly string[]).includes(value);
|
||||
@@ -74,6 +74,11 @@ export class MockVacuumEntity extends MockBaseEntity {
|
||||
stateAttrs.fan_speed = attrs.fan_speed ?? null;
|
||||
}
|
||||
|
||||
if (supportsFeatureFromAttributes(attrs, VacuumEntityFeature.BATTERY)) {
|
||||
stateAttrs.battery_level = attrs.battery_level ?? null;
|
||||
stateAttrs.battery_icon = attrs.battery_icon ?? null;
|
||||
}
|
||||
|
||||
if (supportsFeatureFromAttributes(attrs, VacuumEntityFeature.STATUS)) {
|
||||
stateAttrs.status = attrs.status ?? null;
|
||||
}
|
||||
|
||||
@@ -34,7 +34,6 @@ import "../components/ha-dialog-footer";
|
||||
import "../components/ha-dropdown";
|
||||
import type { HaDropdownSelectEvent } from "../components/ha-dropdown";
|
||||
import "../components/ha-dropdown-item";
|
||||
import "../components/ha-filter-pane-chip";
|
||||
import "../components/ha-icon-button";
|
||||
import "../components/ha-svg-icon";
|
||||
import "../components/input/ha-input-search";
|
||||
@@ -238,13 +237,20 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
const localize = this.localizeFunc || this.hass.localize;
|
||||
const showPane = this._showPaneController.value ?? !this.narrow;
|
||||
const filterButton = this.hasFilters
|
||||
? html`<ha-filter-pane-chip
|
||||
.label=${localize("ui.components.subpage-data-table.filters")}
|
||||
.path=${mdiFilterVariant}
|
||||
.count=${this.filters}
|
||||
.active=${!!this.filters}
|
||||
@click=${this._toggleFilters}
|
||||
></ha-filter-pane-chip>`
|
||||
? html`<div class="relative">
|
||||
<ha-assist-chip
|
||||
.label=${localize("ui.components.subpage-data-table.filters")}
|
||||
.active=${this.filters}
|
||||
@click=${this._toggleFilters}
|
||||
>
|
||||
<ha-svg-icon slot="icon" .path=${mdiFilterVariant}></ha-svg-icon>
|
||||
</ha-assist-chip>
|
||||
${
|
||||
this.filters
|
||||
? html`<div class="badge">${this.filters}</div>`
|
||||
: nothing
|
||||
}
|
||||
</div>`
|
||||
: nothing;
|
||||
|
||||
const selectModeBtn =
|
||||
@@ -465,14 +471,18 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
? nothing
|
||||
: html`<div class="pane" slot="pane">
|
||||
<div class="table-header">
|
||||
<ha-filter-pane-chip
|
||||
<ha-assist-chip
|
||||
.label=${localize(
|
||||
"ui.components.subpage-data-table.filters"
|
||||
)}
|
||||
.path=${mdiFilterVariant}
|
||||
active
|
||||
@click=${this._toggleFilters}
|
||||
></ha-filter-pane-chip>
|
||||
>
|
||||
<ha-svg-icon
|
||||
slot="icon"
|
||||
.path=${mdiFilterVariant}
|
||||
></ha-svg-icon>
|
||||
</ha-assist-chip>
|
||||
${
|
||||
this.filters
|
||||
? html`<ha-icon-button
|
||||
@@ -875,6 +885,24 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
inset-inline-end: -4px;
|
||||
inset-inline-start: initial;
|
||||
min-width: 16px;
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
font-size: var(--ha-font-size-xs);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
background-color: var(--primary-color);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
text-align: center;
|
||||
padding: 0px 2px;
|
||||
color: var(--text-primary-color);
|
||||
}
|
||||
|
||||
.narrow-header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -923,8 +951,11 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
|
||||
ha-assist-chip,
|
||||
ha-filter-pane-chip {
|
||||
.relative {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
ha-assist-chip {
|
||||
--ha-assist-chip-container-shape: 10px;
|
||||
--ha-assist-chip-container-color: var(--card-background-color);
|
||||
}
|
||||
|
||||
@@ -6,16 +6,12 @@ import { storage } from "../common/decorators/storage";
|
||||
import { isNavigationClick } from "../common/dom/is-navigation-click";
|
||||
import { navigate } from "../common/navigate";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import { decodeMoreInfoUrl } from "../common/url/more-info-query-params";
|
||||
import { extractSearchParamsObject } from "../common/url/search-params";
|
||||
import { afterNextRender } from "../common/util/render-status";
|
||||
import { fetchHttpConfig } from "../data/http";
|
||||
import type { HttpConfigState } from "../data/http";
|
||||
import type { WindowWithPreloads } from "../data/preloads";
|
||||
import type { RecorderInfo } from "../data/recorder";
|
||||
import { getRecorderInfo } from "../data/recorder";
|
||||
import { showHttpPendingConfigDialog } from "../dialogs/http-pending-config/show-dialog-http-pending-config";
|
||||
import { showMoreInfoDialog } from "../dialogs/more-info/show-ha-more-info-dialog";
|
||||
import "../resources/custom-card-support";
|
||||
import { HassElement } from "../state/hass-element";
|
||||
import QuickBarMixin from "../state/quick-bar-mixin";
|
||||
@@ -148,7 +144,6 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
|
||||
// the shadow root. Appending the dialog before that render would let it
|
||||
// tear the freshly-added dialog straight back out of the DOM.
|
||||
this.checkHttpPendingConfig();
|
||||
this._restoreMoreInfoFromUrl();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,12 +178,6 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
|
||||
// Handle history changes
|
||||
window.addEventListener("popstate", () => updateRoute());
|
||||
|
||||
// Restore a more-info dialog deep-linked in the current URL, if any.
|
||||
window.addEventListener("location-changed", () =>
|
||||
this._restoreMoreInfoFromUrl()
|
||||
);
|
||||
window.addEventListener("popstate", () => this._restoreMoreInfoFromUrl());
|
||||
|
||||
// Handle clicking on links
|
||||
window.addEventListener("click", (ev) => {
|
||||
const href = isNavigationClick(ev);
|
||||
@@ -308,35 +297,6 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
|
||||
});
|
||||
}
|
||||
|
||||
private _restoreMoreInfoFromUrl() {
|
||||
// Only restore once the main UI is rendered so the dialog has access to
|
||||
// the loaded entities.
|
||||
if (this.render !== this.renderHass) {
|
||||
return;
|
||||
}
|
||||
const searchParams = extractSearchParamsObject();
|
||||
if (!searchParams["more-info-entity-id"]) {
|
||||
return;
|
||||
}
|
||||
const { entityId, view, hash } = decodeMoreInfoUrl(
|
||||
window.location.search,
|
||||
window.location.hash
|
||||
);
|
||||
if (!entityId) {
|
||||
return;
|
||||
}
|
||||
// Wait for the next render to ensure the view is fully loaded
|
||||
// because the more info dialog is closed when the url changes
|
||||
afterNextRender(() => {
|
||||
showMoreInfoDialog(this, {
|
||||
entityId,
|
||||
view,
|
||||
hash,
|
||||
fromUrl: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
protected async checkDataBaseMigration() {
|
||||
if (__DEMO__) {
|
||||
this._databaseMigration = false;
|
||||
|
||||
@@ -49,7 +49,6 @@ const SUPPORTED_UI_TYPES = [
|
||||
|
||||
const secretTag = defineScalarTag("!secret", {
|
||||
resolve: (data) => `!secret ${data}`,
|
||||
identify: () => false,
|
||||
});
|
||||
|
||||
const ADDON_YAML_SCHEMA = YAML11_SCHEMA.withTags(secretTag);
|
||||
|
||||
@@ -545,7 +545,7 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
|
||||
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
|
||||
: nothing
|
||||
}
|
||||
<div class="description-text">${this._currentAddon.description}</div>
|
||||
${this._currentAddon.description}.<br />
|
||||
${this.i18n.localize(
|
||||
"ui.panel.config.apps.dashboard.visit_app_page",
|
||||
{
|
||||
@@ -1658,15 +1658,6 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.description:dir(rtl) > .description-text {
|
||||
text-align: right;
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
.long-description {
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
img.logo {
|
||||
max-width: 100%;
|
||||
max-height: 40px;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { dynamicElement } from "../../../../common/dom/dynamic-element-directive";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/ha-yaml-editor";
|
||||
@@ -8,6 +9,7 @@ import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
|
||||
import { COLLAPSIBLE_ACTION_ELEMENTS } from "../../../../data/action";
|
||||
import { migrateAutomationAction, type Action } from "../../../../data/script";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { actionSchemaKey, actionToYamlSchema } from "../yaml_schema_helpers";
|
||||
import "../ha-automation-editor-warning";
|
||||
import { editorStyles, indentStyle } from "../styles";
|
||||
import {
|
||||
@@ -41,6 +43,14 @@ export default class HaAutomationActionEditor extends LitElement {
|
||||
@query(COLLAPSIBLE_ACTION_ELEMENTS.join(", "))
|
||||
private _collapsibleElement?: ActionElement;
|
||||
|
||||
private _actionYamlSchema = memoizeOne(
|
||||
(
|
||||
actionKey: string | undefined,
|
||||
services: HomeAssistant["services"],
|
||||
localize: HomeAssistant["localize"]
|
||||
) => actionToYamlSchema(actionKey, services, localize)
|
||||
);
|
||||
|
||||
protected render() {
|
||||
const yamlMode = this.yamlMode || !this.uiSupported;
|
||||
const type = getAutomationActionType(this.action);
|
||||
@@ -74,6 +84,11 @@ export default class HaAutomationActionEditor extends LitElement {
|
||||
.defaultValue=${this.action}
|
||||
@value-changed=${this._onYamlChange}
|
||||
.readOnly=${this.disabled}
|
||||
.yamlFieldSchema=${this._actionYamlSchema(
|
||||
actionSchemaKey(this.action),
|
||||
this.hass.services,
|
||||
this.hass.localize
|
||||
)}
|
||||
></ha-yaml-editor>
|
||||
`
|
||||
: html`
|
||||
|
||||
@@ -370,7 +370,6 @@ export class HaAutomationAddSearch extends LitElement {
|
||||
width: "var(--ha-space-12)",
|
||||
position: "absolute",
|
||||
top: "0",
|
||||
height: "100%",
|
||||
left: rtl ? undefined : "var(--ha-space-1)",
|
||||
right: rtl ? "var(--ha-space-1)" : undefined,
|
||||
transform: rtl ? "scaleX(-1)" : "",
|
||||
|
||||
@@ -11,6 +11,10 @@ import { expandConditionWithShorthand } from "../../../../data/automation";
|
||||
import type { ConditionDescription } from "../../../../data/condition";
|
||||
import { COLLAPSIBLE_CONDITION_ELEMENTS } from "../../../../data/condition";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import {
|
||||
builtInConditionSchema,
|
||||
conditionDescriptionToSchema,
|
||||
} from "../yaml_schema_helpers";
|
||||
import "../ha-automation-editor-warning";
|
||||
import { editorStyles, indentStyle } from "../styles";
|
||||
import type { ConditionElement } from "./ha-automation-condition-row";
|
||||
@@ -44,6 +48,22 @@ export default class HaAutomationConditionEditor extends LitElement {
|
||||
@query(COLLAPSIBLE_CONDITION_ELEMENTS.join(", "))
|
||||
private _collapsibleElement?: ConditionElement;
|
||||
|
||||
// Memoized on the condition type rather than the condition itself: the
|
||||
// condition object gets a new identity on every keystroke, and the schema
|
||||
// only depends on its type.
|
||||
private _conditionYamlSchema = memoizeOne(
|
||||
(
|
||||
conditionType: string,
|
||||
description: ConditionDescription | undefined,
|
||||
localize: HomeAssistant["localize"]
|
||||
) => {
|
||||
if (!description) {
|
||||
return builtInConditionSchema(conditionType, localize);
|
||||
}
|
||||
return conditionDescriptionToSchema(conditionType, description, localize);
|
||||
}
|
||||
);
|
||||
|
||||
private _processedCondition = memoizeOne((condition) =>
|
||||
expandConditionWithShorthand(condition)
|
||||
);
|
||||
@@ -82,6 +102,11 @@ export default class HaAutomationConditionEditor extends LitElement {
|
||||
.defaultValue=${this.condition}
|
||||
@value-changed=${this._onYamlChange}
|
||||
.readOnly=${this.disabled}
|
||||
.yamlFieldSchema=${this._conditionYamlSchema(
|
||||
condition.condition,
|
||||
this.description,
|
||||
this.hass.localize
|
||||
)}
|
||||
></ha-yaml-editor>
|
||||
`
|
||||
: html`
|
||||
|
||||
+49
-14
@@ -32,6 +32,54 @@ const numericStateConditionStruct = object({
|
||||
enabled: optional(boolean()),
|
||||
});
|
||||
|
||||
export const YAML_SCHEMA = [
|
||||
{ name: "entity_id", required: true, selector: { entity: {} } },
|
||||
{
|
||||
name: "attribute",
|
||||
selector: { attribute: { hide_attributes: NON_NUMERIC_ATTRIBUTES } },
|
||||
context: { filter_entity: "entity_id" },
|
||||
},
|
||||
{
|
||||
name: "above",
|
||||
selector: {
|
||||
number: {
|
||||
mode: "box",
|
||||
min: Number.MIN_SAFE_INTEGER,
|
||||
max: Number.MAX_SAFE_INTEGER,
|
||||
step: 0.1,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "below",
|
||||
selector: {
|
||||
number: {
|
||||
mode: "box",
|
||||
min: Number.MIN_SAFE_INTEGER,
|
||||
max: Number.MAX_SAFE_INTEGER,
|
||||
step: 0.1,
|
||||
},
|
||||
},
|
||||
},
|
||||
{ name: "value_template", selector: { template: {} } },
|
||||
] as const;
|
||||
|
||||
export const computeLabel = (
|
||||
fieldName: string,
|
||||
localize: LocalizeFunc
|
||||
): string => {
|
||||
switch (fieldName) {
|
||||
case "entity_id":
|
||||
return localize("ui.components.entity.entity-picker.entity");
|
||||
case "attribute":
|
||||
return localize("ui.components.entity.entity-attribute-picker.attribute");
|
||||
default:
|
||||
return localize(
|
||||
`ui.panel.config.automation.editor.triggers.type.numeric_state.${fieldName}` as any
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@customElement("ha-automation-condition-numeric_state")
|
||||
export default class HaNumericStateCondition extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -242,20 +290,7 @@ export default class HaNumericStateCondition extends LitElement {
|
||||
|
||||
private _computeLabelCallback = (
|
||||
schema: SchemaUnion<ReturnType<typeof this._schema>>
|
||||
): string => {
|
||||
switch (schema.name) {
|
||||
case "entity_id":
|
||||
return this.hass.localize("ui.components.entity.entity-picker.entity");
|
||||
case "attribute":
|
||||
return this.hass.localize(
|
||||
"ui.components.entity.entity-attribute-picker.attribute"
|
||||
);
|
||||
default:
|
||||
return this.hass.localize(
|
||||
`ui.panel.config.automation.editor.triggers.type.numeric_state.${schema.name}`
|
||||
);
|
||||
}
|
||||
};
|
||||
): string => computeLabel(schema.name, this.hass.localize);
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
|
||||
@@ -7,7 +7,6 @@ import { createDurationData } from "../../../../../common/datetime/create_durati
|
||||
import { durationDataToSeconds } from "../../../../../common/datetime/duration_to_seconds";
|
||||
import { fireEvent } from "../../../../../common/dom/fire_event";
|
||||
import { stopPropagation } from "../../../../../common/dom/stop_propagation";
|
||||
import { getSelectorFallbackValue } from "../../../../../components/ha-form/get-selector-fallback-value";
|
||||
import "../../../../../components/ha-checkbox";
|
||||
import "../../../../../components/ha-selector/ha-selector";
|
||||
import "../../../../../components/ha-settings-row";
|
||||
@@ -430,8 +429,20 @@ export class HaPlatformCondition extends LitElement {
|
||||
Object.entries(this.description).find(([k, _value]) => k === key)?.[1];
|
||||
let defaultValue = field?.default;
|
||||
|
||||
if (defaultValue == null && field?.selector) {
|
||||
defaultValue = getSelectorFallbackValue(field.selector);
|
||||
if (
|
||||
defaultValue == null &&
|
||||
field?.selector &&
|
||||
"constant" in field.selector
|
||||
) {
|
||||
defaultValue = field.selector.constant?.value;
|
||||
}
|
||||
|
||||
if (
|
||||
defaultValue == null &&
|
||||
field?.selector &&
|
||||
"boolean" in field.selector
|
||||
) {
|
||||
defaultValue = false;
|
||||
}
|
||||
|
||||
if (defaultValue != null) {
|
||||
|
||||
@@ -19,6 +19,7 @@ import "../../../../../components/ha-form/ha-form";
|
||||
import type { SchemaUnion } from "../../../../../components/ha-form/types";
|
||||
import type { StateCondition } from "../../../../../data/automation";
|
||||
import { STATE_CONDITION_HIDDEN_ATTRIBUTES } from "../../../../../data/entity/entity_attributes";
|
||||
import type { LocalizeFunc } from "../../../../../common/translations/localize";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
import { forDictStruct } from "../../structs";
|
||||
import type { ConditionElement } from "../ha-automation-condition-row";
|
||||
@@ -34,7 +35,7 @@ const stateConditionStruct = object({
|
||||
enabled: optional(boolean()),
|
||||
});
|
||||
|
||||
const SCHEMA = [
|
||||
export const SCHEMA = [
|
||||
{ name: "entity_id", required: true, selector: { entity: {} } },
|
||||
{
|
||||
name: "attribute",
|
||||
@@ -61,6 +62,26 @@ const SCHEMA = [
|
||||
{ name: "for", selector: { duration: {} } },
|
||||
] as const;
|
||||
|
||||
export const computeLabel = (
|
||||
fieldName: string,
|
||||
localize: LocalizeFunc
|
||||
): string => {
|
||||
switch (fieldName) {
|
||||
case "entity_id":
|
||||
return localize("ui.components.entity.entity-picker.entity");
|
||||
case "attribute":
|
||||
return localize("ui.components.entity.entity-attribute-picker.attribute");
|
||||
case "for":
|
||||
return localize(
|
||||
"ui.panel.config.automation.editor.triggers.type.state.for"
|
||||
);
|
||||
default:
|
||||
return localize(
|
||||
`ui.panel.config.automation.editor.conditions.type.state.${fieldName}` as any
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@customElement("ha-automation-condition-state")
|
||||
export class HaStateCondition extends LitElement implements ConditionElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -125,24 +146,7 @@ export class HaStateCondition extends LitElement implements ConditionElement {
|
||||
|
||||
private _computeLabelCallback = (
|
||||
schema: SchemaUnion<typeof SCHEMA>
|
||||
): string => {
|
||||
switch (schema.name) {
|
||||
case "entity_id":
|
||||
return this.hass.localize("ui.components.entity.entity-picker.entity");
|
||||
case "attribute":
|
||||
return this.hass.localize(
|
||||
"ui.components.entity.entity-attribute-picker.attribute"
|
||||
);
|
||||
case "for":
|
||||
return this.hass.localize(
|
||||
`ui.panel.config.automation.editor.triggers.type.state.for`
|
||||
);
|
||||
default:
|
||||
return this.hass.localize(
|
||||
`ui.panel.config.automation.editor.conditions.type.state.${schema.name}`
|
||||
);
|
||||
}
|
||||
};
|
||||
): string => computeLabel(schema.name, this.hass.localize);
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
|
||||
@@ -14,6 +14,29 @@ type FormType = "before" | "after" | "between";
|
||||
const BEFORE_DEFAULT = "sunrise";
|
||||
const AFTER_DEFAULT = "sunset";
|
||||
|
||||
export const YAML_SCHEMA = [
|
||||
{
|
||||
name: "before",
|
||||
type: "select" as const,
|
||||
options: [["sunrise", "sunrise"] as const, ["sunset", "sunset"] as const],
|
||||
},
|
||||
{ name: "before_offset", selector: { duration: { allow_negative: true } } },
|
||||
{
|
||||
name: "after",
|
||||
type: "select" as const,
|
||||
options: [["sunrise", "sunrise"] as const, ["sunset", "sunset"] as const],
|
||||
},
|
||||
{ name: "after_offset", selector: { duration: { allow_negative: true } } },
|
||||
] as const;
|
||||
|
||||
export const computeLabel = (
|
||||
fieldName: string,
|
||||
localize: LocalizeFunc
|
||||
): string =>
|
||||
localize(
|
||||
`ui.panel.config.automation.editor.conditions.type.sun.${fieldName}` as any
|
||||
);
|
||||
|
||||
@customElement("ha-automation-condition-sun")
|
||||
export class HaSunCondition extends LitElement implements ConditionElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -154,10 +177,7 @@ export class HaSunCondition extends LitElement implements ConditionElement {
|
||||
|
||||
private _computeLabelCallback = (schema: {
|
||||
name: "before" | "after";
|
||||
}): string =>
|
||||
this.hass.localize(
|
||||
`ui.panel.config.automation.editor.conditions.type.sun.${schema.name}`
|
||||
);
|
||||
}): string => computeLabel(schema.name, this.hass.localize);
|
||||
|
||||
private _typeSelected(ev: HaSelectSelectEvent): void {
|
||||
const value = ev.detail.value as FormType;
|
||||
|
||||
@@ -5,11 +5,20 @@ import type { HomeAssistant } from "../../../../../types";
|
||||
import type { SchemaUnion } from "../../../../../components/ha-form/types";
|
||||
import "../../../../../components/ha-form/ha-form";
|
||||
import { fireEvent } from "../../../../../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../../../../../common/translations/localize";
|
||||
|
||||
const SCHEMA = [
|
||||
export const SCHEMA = [
|
||||
{ name: "value_template", required: true, selector: { template: {} } },
|
||||
] as const;
|
||||
|
||||
export const computeLabel = (
|
||||
fieldName: string,
|
||||
localize: LocalizeFunc
|
||||
): string =>
|
||||
localize(
|
||||
`ui.panel.config.automation.editor.conditions.type.template.${fieldName}` as any
|
||||
);
|
||||
|
||||
@customElement("ha-automation-condition-template")
|
||||
export class HaTemplateCondition extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -43,10 +52,7 @@ export class HaTemplateCondition extends LitElement {
|
||||
|
||||
private _computeLabelCallback = (
|
||||
schema: SchemaUnion<typeof SCHEMA>
|
||||
): string =>
|
||||
this.hass.localize(
|
||||
`ui.panel.config.automation.editor.conditions.type.template.${schema.name}`
|
||||
);
|
||||
): string => computeLabel(schema.name, this.hass.localize);
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -13,6 +13,24 @@ import type { ConditionElement } from "../ha-automation-condition-row";
|
||||
|
||||
const DAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const;
|
||||
|
||||
export const YAML_SCHEMA = [
|
||||
{ name: "after", selector: { time: {} } },
|
||||
{ name: "before", selector: { time: {} } },
|
||||
{
|
||||
name: "weekday",
|
||||
type: "multi_select" as const,
|
||||
options: DAYS.map((d) => [d, d] as const),
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const computeLabel = (
|
||||
fieldName: string,
|
||||
localize: LocalizeFunc
|
||||
): string =>
|
||||
localize(
|
||||
`ui.panel.config.automation.editor.conditions.type.time.${fieldName}` as any
|
||||
);
|
||||
|
||||
@customElement("ha-automation-condition-time")
|
||||
export class HaTimeCondition extends LitElement implements ConditionElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -184,10 +202,7 @@ export class HaTimeCondition extends LitElement implements ConditionElement {
|
||||
|
||||
private _computeLabelCallback = (
|
||||
schema: SchemaUnion<ReturnType<typeof this._schema>>
|
||||
): string =>
|
||||
this.hass.localize(
|
||||
`ui.panel.config.automation.editor.conditions.type.time.${schema.name}`
|
||||
);
|
||||
): string => computeLabel(schema.name, this.hass.localize);
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type Trigger,
|
||||
type TriggerCondition,
|
||||
} from "../../../../../data/automation";
|
||||
import type { LocalizeFunc } from "../../../../../common/translations/localize";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
|
||||
const getTriggersIds = (triggers: Trigger[]): string[] => {
|
||||
@@ -22,6 +23,20 @@ const getTriggersIds = (triggers: Trigger[]): string[] => {
|
||||
return Array.from(new Set(triggerIds));
|
||||
};
|
||||
|
||||
// Static YAML schema — trigger IDs are dynamic at runtime, so we use a
|
||||
// plain text selector here to at least provide key completion.
|
||||
export const YAML_SCHEMA = [
|
||||
{ name: "id", required: true, selector: { text: { multiple: true } } },
|
||||
] as const;
|
||||
|
||||
export const computeLabel = (
|
||||
fieldName: string,
|
||||
localize: LocalizeFunc
|
||||
): string =>
|
||||
localize(
|
||||
`ui.panel.config.automation.editor.conditions.type.trigger.${fieldName}` as any
|
||||
);
|
||||
|
||||
@customElement("ha-automation-condition-trigger")
|
||||
export class HaTriggerCondition extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -94,10 +109,7 @@ export class HaTriggerCondition extends LitElement {
|
||||
|
||||
private _computeLabelCallback = (
|
||||
schema: SchemaUnion<ReturnType<typeof this._schema>>
|
||||
): string =>
|
||||
this.hass.localize(
|
||||
`ui.panel.config.automation.editor.conditions.type.trigger.${schema.name}`
|
||||
);
|
||||
): string => computeLabel(schema.name, this.hass.localize);
|
||||
|
||||
private _automationUpdated(config?: AutomationConfig) {
|
||||
this._triggerIds = config?.triggers
|
||||
|
||||
@@ -2,12 +2,10 @@ import "@home-assistant/webawesome/dist/components/divider/divider";
|
||||
import { ResizeController } from "@lit-labs/observers/resize-controller";
|
||||
import { consume } from "@lit/context";
|
||||
import {
|
||||
mdiCloseThick,
|
||||
mdiCog,
|
||||
mdiContentDuplicate,
|
||||
mdiDelete,
|
||||
mdiDotsVertical,
|
||||
mdiExclamationThick,
|
||||
mdiHelpCircleOutline,
|
||||
mdiInformationOutline,
|
||||
mdiMenuDown,
|
||||
@@ -131,28 +129,6 @@ import {
|
||||
import { getAvailableAssistants } from "../voice-assistants/expose/available-assistants";
|
||||
import { showNewAutomationDialog } from "./show-dialog-new-automation";
|
||||
|
||||
const renderIconBadge = (path: string, color: string) => html`
|
||||
<div
|
||||
style=${styleMap({
|
||||
position: "absolute",
|
||||
top: "-5px",
|
||||
insetInlineEnd: "-7px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "18px",
|
||||
height: "18px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: color,
|
||||
boxShadow: "0 0 0 2px var(--data-table-background-color)",
|
||||
color: "var(--data-table-background-color)",
|
||||
"--mdc-icon-size": "12px",
|
||||
})}
|
||||
>
|
||||
<ha-svg-icon style="margin: 0;" .path=${path}></ha-svg-icon>
|
||||
</div>
|
||||
`;
|
||||
|
||||
type AutomationItem = AutomationEntity & {
|
||||
name: string;
|
||||
area: string | undefined;
|
||||
@@ -327,11 +303,6 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
|
||||
localize: LocalizeFunc,
|
||||
entitiesToCheck?: any[]
|
||||
): DataTableColumnContainer<AutomationItem> => {
|
||||
const triggeredAtColumn = getTriggeredAtTableColumn<AutomationItem>(
|
||||
localize,
|
||||
this.hass
|
||||
);
|
||||
|
||||
const columns: DataTableColumnContainer<AutomationItem> = {
|
||||
icon: {
|
||||
title: "",
|
||||
@@ -339,34 +310,18 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
|
||||
type: "icon",
|
||||
moveable: false,
|
||||
showNarrow: true,
|
||||
template: (automation) => {
|
||||
const unavailable = automation.state === UNAVAILABLE;
|
||||
const disabled = automation.state === "off";
|
||||
return html`<div
|
||||
style="position: relative; display: inline-flex; width: 24px; height: 24px;"
|
||||
>
|
||||
<ha-state-icon
|
||||
.stateObj=${automation}
|
||||
.stateValue=${unavailable || disabled ? "on" : undefined}
|
||||
style=${styleMap({
|
||||
display: "flex",
|
||||
margin: "0",
|
||||
color: unavailable
|
||||
template: (automation) =>
|
||||
html`<ha-state-icon
|
||||
.stateObj=${automation}
|
||||
style=${styleMap({
|
||||
color:
|
||||
automation.state === UNAVAILABLE
|
||||
? "var(--error-color)"
|
||||
: disabled
|
||||
? "var(--disabled-color)"
|
||||
: automation.state === "on"
|
||||
? "var(--state-active-color)"
|
||||
: "unset",
|
||||
})}
|
||||
></ha-state-icon>
|
||||
${
|
||||
unavailable
|
||||
? renderIconBadge(mdiExclamationThick, "var(--error-color)")
|
||||
: disabled
|
||||
? renderIconBadge(mdiCloseThick, "var(--disabled-color)")
|
||||
: nothing
|
||||
}
|
||||
</div>`;
|
||||
},
|
||||
})}
|
||||
></ha-state-icon>`,
|
||||
},
|
||||
entity_id: getEntityIdHiddenTableColumn(),
|
||||
name: {
|
||||
@@ -387,33 +342,23 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
|
||||
area: getAreaTableColumn(localize),
|
||||
category: getCategoryTableColumn(localize),
|
||||
labels: getLabelsTableColumn(),
|
||||
last_triggered: {
|
||||
...triggeredAtColumn,
|
||||
template: (automation) =>
|
||||
narrow && automation.state === "off"
|
||||
? nothing
|
||||
: triggeredAtColumn.template!(automation),
|
||||
},
|
||||
last_triggered: getTriggeredAtTableColumn(localize, this.hass),
|
||||
formatted_state: {
|
||||
minWidth: "82px",
|
||||
maxWidth: "82px",
|
||||
sortable: true,
|
||||
groupable: true,
|
||||
hidden: narrow,
|
||||
type: "overflow",
|
||||
title: this.hass.localize("ui.panel.config.automation.picker.state"),
|
||||
template: (automation) =>
|
||||
narrow
|
||||
? automation.state === "off"
|
||||
? localize("ui.panel.config.automation.picker.disabled")
|
||||
: nothing
|
||||
: html`
|
||||
<ha-switch
|
||||
@click=${stopPropagation}
|
||||
@change=${this._handleSwitchToggle}
|
||||
.automation=${automation}
|
||||
.checked=${automation.state === "on"}
|
||||
></ha-switch>
|
||||
`,
|
||||
template: (automation) => html`
|
||||
<ha-switch
|
||||
@click=${stopPropagation}
|
||||
@change=${this._handleSwitchToggle}
|
||||
.automation=${automation}
|
||||
.checked=${automation.state === "on"}
|
||||
></ha-switch>
|
||||
`,
|
||||
},
|
||||
actions: {
|
||||
lastFixed: true,
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { CSSResultGroup } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { dynamicElement } from "../../../../common/dom/dynamic-element-directive";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/ha-yaml-editor";
|
||||
@@ -13,6 +14,10 @@ import type { TriggerDescription } from "../../../../data/trigger";
|
||||
import { isTriggerList } from "../../../../data/trigger";
|
||||
import { haStyle } from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import {
|
||||
builtInTriggerSchema,
|
||||
triggerDescriptionToSchema,
|
||||
} from "../yaml_schema_helpers";
|
||||
import "../ha-automation-editor-warning";
|
||||
import "./types/ha-automation-trigger-platform";
|
||||
|
||||
@@ -37,6 +42,23 @@ export default class HaAutomationTriggerEditor extends LitElement {
|
||||
|
||||
@query("ha-yaml-editor") public yamlEditor?: HaYamlEditor;
|
||||
|
||||
// Memoized on the trigger type rather than the trigger itself: the trigger
|
||||
// object gets a new identity on every keystroke, and the schema only depends
|
||||
// on its type.
|
||||
private _triggerYamlSchema = memoizeOne(
|
||||
(
|
||||
triggerType: string | undefined,
|
||||
description: TriggerDescription | undefined,
|
||||
localize: HomeAssistant["localize"]
|
||||
) => {
|
||||
if (triggerType === undefined) return undefined;
|
||||
if (!description) {
|
||||
return builtInTriggerSchema(triggerType, localize);
|
||||
}
|
||||
return triggerDescriptionToSchema(triggerType, description, localize);
|
||||
}
|
||||
);
|
||||
|
||||
protected render() {
|
||||
const type = isTriggerList(this.trigger) ? "list" : this.trigger.trigger;
|
||||
|
||||
@@ -72,6 +94,13 @@ export default class HaAutomationTriggerEditor extends LitElement {
|
||||
<ha-yaml-editor
|
||||
.defaultValue=${this.trigger}
|
||||
.readOnly=${this.disabled}
|
||||
.yamlFieldSchema=${this._triggerYamlSchema(
|
||||
isTriggerList(this.trigger)
|
||||
? undefined
|
||||
: this.trigger.trigger,
|
||||
this.description,
|
||||
this.hass.localize
|
||||
)}
|
||||
@value-changed=${this._onYamlChange}
|
||||
></ha-yaml-editor>
|
||||
`
|
||||
|
||||
@@ -11,6 +11,37 @@ import { createDurationData } from "../../../../../common/datetime/create_durati
|
||||
import type { LocalizeFunc } from "../../../../../common/translations/localize";
|
||||
import type { SchemaUnion } from "../../../../../components/ha-form/types";
|
||||
|
||||
export const computeLabel = (
|
||||
fieldName: string,
|
||||
localize: LocalizeFunc
|
||||
): string => {
|
||||
switch (fieldName) {
|
||||
case "entity_id":
|
||||
return localize("ui.components.entity.entity-picker.entity");
|
||||
case "event":
|
||||
return localize(
|
||||
"ui.panel.config.automation.editor.triggers.type.calendar.event"
|
||||
);
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
export const YAML_SCHEMA = [
|
||||
{
|
||||
name: "entity_id",
|
||||
required: true,
|
||||
selector: { entity: { domain: "calendar" } },
|
||||
},
|
||||
{
|
||||
name: "event",
|
||||
type: "select" as const,
|
||||
required: true,
|
||||
options: [["start", "start"] as const, ["end", "end"] as const],
|
||||
},
|
||||
{ name: "offset", selector: { text: {} } },
|
||||
] as const;
|
||||
|
||||
@customElement("ha-automation-trigger-calendar")
|
||||
export class HaCalendarTrigger extends LitElement implements TriggerElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -125,17 +156,7 @@ export class HaCalendarTrigger extends LitElement implements TriggerElement {
|
||||
|
||||
private _computeLabelCallback = (
|
||||
schema: SchemaUnion<ReturnType<typeof this._schema>>
|
||||
): string => {
|
||||
switch (schema.name) {
|
||||
case "entity_id":
|
||||
return this.hass.localize("ui.components.entity.entity-picker.entity");
|
||||
case "event":
|
||||
return this.hass.localize(
|
||||
"ui.panel.config.automation.editor.triggers.type.calendar.event"
|
||||
);
|
||||
}
|
||||
return "";
|
||||
};
|
||||
): string => computeLabel(schema.name, this.hass.localize);
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -8,6 +8,25 @@ import type { HomeAssistant } from "../../../../../types";
|
||||
import type { LocalizeFunc } from "../../../../../common/translations/localize";
|
||||
import type { SchemaUnion } from "../../../../../components/ha-form/types";
|
||||
|
||||
export const computeLabel = (
|
||||
fieldName: string,
|
||||
localize: LocalizeFunc
|
||||
): string =>
|
||||
localize(
|
||||
`ui.panel.config.automation.editor.triggers.type.geo_location.${fieldName}` as any
|
||||
);
|
||||
|
||||
export const YAML_SCHEMA = [
|
||||
{ name: "source", selector: { text: {} } },
|
||||
{ name: "zone", selector: { entity: { domain: "zone" } } },
|
||||
{
|
||||
name: "event",
|
||||
type: "select" as const,
|
||||
required: true,
|
||||
options: [["enter", "enter"] as const, ["leave", "leave"] as const],
|
||||
},
|
||||
] as const;
|
||||
|
||||
@customElement("ha-automation-trigger-geo_location")
|
||||
export class HaGeolocationTrigger extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -73,10 +92,7 @@ export class HaGeolocationTrigger extends LitElement {
|
||||
|
||||
private _computeLabelCallback = (
|
||||
schema: SchemaUnion<ReturnType<typeof this._schema>>
|
||||
): string =>
|
||||
this.hass.localize(
|
||||
`ui.panel.config.automation.editor.triggers.type.geo_location.${schema.name}`
|
||||
);
|
||||
): string => computeLabel(schema.name, this.hass.localize);
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -8,6 +8,23 @@ import type { HomeAssistant } from "../../../../../types";
|
||||
import type { LocalizeFunc } from "../../../../../common/translations/localize";
|
||||
import type { SchemaUnion } from "../../../../../components/ha-form/types";
|
||||
|
||||
export const computeLabel = (
|
||||
fieldName: string,
|
||||
localize: LocalizeFunc
|
||||
): string =>
|
||||
localize(
|
||||
`ui.panel.config.automation.editor.triggers.type.homeassistant.${fieldName}` as any
|
||||
);
|
||||
|
||||
export const YAML_SCHEMA = [
|
||||
{
|
||||
name: "event",
|
||||
type: "select" as const,
|
||||
required: true,
|
||||
options: [["start", "start"] as const, ["shutdown", "shutdown"] as const],
|
||||
},
|
||||
] as const;
|
||||
|
||||
@customElement("ha-automation-trigger-homeassistant")
|
||||
export class HaHassTrigger extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -69,10 +86,7 @@ export class HaHassTrigger extends LitElement {
|
||||
|
||||
private _computeLabelCallback = (
|
||||
schema: SchemaUnion<ReturnType<typeof this._schema>>
|
||||
): string =>
|
||||
this.hass.localize(
|
||||
`ui.panel.config.automation.editor.triggers.type.homeassistant.${schema.name}`
|
||||
);
|
||||
): string => computeLabel(schema.name, this.hass.localize);
|
||||
|
||||
static styles = css`
|
||||
label {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { hasTemplate } from "../../../../../common/string/has-template";
|
||||
import "../../../../../components/ha-form/ha-form";
|
||||
import type { SchemaUnion } from "../../../../../components/ha-form/types";
|
||||
import type { NumericStateTrigger } from "../../../../../data/automation";
|
||||
import type { LocalizeFunc } from "../../../../../common/translations/localize";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
|
||||
const SCHEMA = [
|
||||
@@ -183,6 +184,63 @@ const SCHEMA = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const computeLabel = (
|
||||
fieldName: string,
|
||||
localize: LocalizeFunc
|
||||
): string => {
|
||||
switch (fieldName) {
|
||||
case "entity_id":
|
||||
return localize("ui.components.entity.entity-picker.entity");
|
||||
case "attribute":
|
||||
return localize("ui.components.entity.entity-attribute-picker.attribute");
|
||||
case "for":
|
||||
return localize(
|
||||
"ui.panel.config.automation.editor.triggers.type.state.for"
|
||||
);
|
||||
default:
|
||||
return localize(
|
||||
`ui.panel.config.automation.editor.triggers.type.numeric_state.${fieldName}` as any
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const YAML_SCHEMA = [
|
||||
{
|
||||
name: "entity_id",
|
||||
required: true,
|
||||
selector: { entity: { multiple: true } },
|
||||
},
|
||||
{
|
||||
name: "attribute",
|
||||
selector: { attribute: {} },
|
||||
context: { filter_entity: "entity_id" },
|
||||
},
|
||||
{
|
||||
name: "above",
|
||||
selector: {
|
||||
number: {
|
||||
mode: "box",
|
||||
min: Number.MIN_SAFE_INTEGER,
|
||||
max: Number.MAX_SAFE_INTEGER,
|
||||
step: 0.1,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "below",
|
||||
selector: {
|
||||
number: {
|
||||
mode: "box",
|
||||
min: Number.MIN_SAFE_INTEGER,
|
||||
max: Number.MAX_SAFE_INTEGER,
|
||||
step: 0.1,
|
||||
},
|
||||
},
|
||||
},
|
||||
{ name: "value_template", selector: { template: {} } },
|
||||
{ name: "for", selector: { duration: {} } },
|
||||
] as const;
|
||||
|
||||
@customElement("ha-automation-trigger-numeric_state")
|
||||
export class HaNumericStateTrigger extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -276,24 +334,7 @@ export class HaNumericStateTrigger extends LitElement {
|
||||
|
||||
private _computeLabelCallback = (
|
||||
schema: SchemaUnion<typeof SCHEMA>
|
||||
): string => {
|
||||
switch (schema.name) {
|
||||
case "entity_id":
|
||||
return this.hass.localize("ui.components.entity.entity-picker.entity");
|
||||
case "attribute":
|
||||
return this.hass.localize(
|
||||
"ui.components.entity.entity-attribute-picker.attribute"
|
||||
);
|
||||
case "for":
|
||||
return this.hass.localize(
|
||||
`ui.panel.config.automation.editor.triggers.type.state.for`
|
||||
);
|
||||
default:
|
||||
return this.hass.localize(
|
||||
`ui.panel.config.automation.editor.triggers.type.numeric_state.${schema.name}`
|
||||
);
|
||||
}
|
||||
};
|
||||
): string => computeLabel(schema.name, this.hass.localize);
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
|
||||
+23
-4
@@ -12,9 +12,31 @@ import type { PersistentNotificationTrigger } from "../../../../../data/automati
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
import type { TriggerElement } from "../ha-automation-trigger-row";
|
||||
|
||||
export const computeLabel = (
|
||||
fieldName: string,
|
||||
localize: LocalizeFunc
|
||||
): string =>
|
||||
localize(
|
||||
`ui.panel.config.automation.editor.triggers.type.persistent_notification.${fieldName}` as any
|
||||
);
|
||||
|
||||
const DEFAULT_UPDATE_TYPES = ["added", "removed"];
|
||||
const DEFAULT_NOTIFICATION_ID = "";
|
||||
|
||||
export const YAML_SCHEMA = [
|
||||
{ name: "notification_id", selector: { text: {} } },
|
||||
{
|
||||
name: "update_type",
|
||||
type: "multi_select" as const,
|
||||
options: [
|
||||
["added", "added"] as const,
|
||||
["removed", "removed"] as const,
|
||||
["current", "current"] as const,
|
||||
["updated", "updated"] as const,
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
|
||||
@customElement("ha-automation-trigger-persistent_notification")
|
||||
export class HaPersistentNotificationTrigger
|
||||
extends LitElement
|
||||
@@ -99,10 +121,7 @@ export class HaPersistentNotificationTrigger
|
||||
|
||||
private _computeLabelCallback = (
|
||||
schema: SchemaUnion<ReturnType<typeof this._schema>>
|
||||
): string =>
|
||||
this.hass.localize(
|
||||
`ui.panel.config.automation.editor.triggers.type.persistent_notification.${schema.name}`
|
||||
);
|
||||
): string => computeLabel(schema.name, this.hass.localize);
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../../../../common/dom/fire_event";
|
||||
import { getSelectorFallbackValue } from "../../../../../components/ha-form/get-selector-fallback-value";
|
||||
import "../../../../../components/ha-checkbox";
|
||||
import "../../../../../components/ha-selector/ha-selector";
|
||||
import "../../../../../components/ha-settings-row";
|
||||
@@ -423,8 +422,20 @@ export class HaPlatformTrigger extends LitElement {
|
||||
Object.entries(this.description).find(([k, _value]) => k === key)?.[1];
|
||||
let defaultValue = field?.default;
|
||||
|
||||
if (defaultValue == null && field?.selector) {
|
||||
defaultValue = getSelectorFallbackValue(field.selector);
|
||||
if (
|
||||
defaultValue == null &&
|
||||
field?.selector &&
|
||||
"constant" in field.selector
|
||||
) {
|
||||
defaultValue = field.selector.constant?.value;
|
||||
}
|
||||
|
||||
if (
|
||||
defaultValue == null &&
|
||||
field?.selector &&
|
||||
"boolean" in field.selector
|
||||
) {
|
||||
defaultValue = false;
|
||||
}
|
||||
|
||||
if (defaultValue != null) {
|
||||
|
||||
@@ -43,6 +43,40 @@ const stateTriggerStruct = assign(
|
||||
})
|
||||
);
|
||||
|
||||
export const YAML_SCHEMA = [
|
||||
{
|
||||
name: "entity_id",
|
||||
required: true,
|
||||
selector: { entity: { multiple: true } },
|
||||
},
|
||||
{
|
||||
name: "attribute",
|
||||
selector: { attribute: {} },
|
||||
context: { filter_entity: "entity_id" },
|
||||
},
|
||||
{
|
||||
name: "from",
|
||||
selector: { state: { multiple: true } },
|
||||
context: { filter_entity: "entity_id" },
|
||||
},
|
||||
{
|
||||
name: "to",
|
||||
selector: { state: { multiple: true } },
|
||||
context: { filter_entity: "entity_id" },
|
||||
},
|
||||
{ name: "for", selector: { duration: {} } },
|
||||
] as const;
|
||||
|
||||
export const computeLabel = (
|
||||
fieldName: string,
|
||||
localize: LocalizeFunc
|
||||
): string =>
|
||||
localize(
|
||||
fieldName === "entity_id"
|
||||
? "ui.components.entity.entity-picker.entity"
|
||||
: (`ui.panel.config.automation.editor.triggers.type.state.${fieldName}` as any)
|
||||
);
|
||||
|
||||
@customElement("ha-automation-trigger-state")
|
||||
export class HaStateTrigger extends LitElement implements TriggerElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -338,12 +372,7 @@ export class HaStateTrigger extends LitElement implements TriggerElement {
|
||||
|
||||
private _computeLabelCallback = (
|
||||
schema: SchemaUnion<ReturnType<typeof this._schema>>
|
||||
): string =>
|
||||
this.hass.localize(
|
||||
schema.name === "entity_id"
|
||||
? "ui.components.entity.entity-picker.entity"
|
||||
: `ui.panel.config.automation.editor.triggers.type.state.${schema.name}`
|
||||
);
|
||||
): string => computeLabel(schema.name, this.hass.localize);
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
|
||||
@@ -9,6 +9,24 @@ import "../../../../../components/ha-form/ha-form";
|
||||
import type { LocalizeFunc } from "../../../../../common/translations/localize";
|
||||
import type { SchemaUnion } from "../../../../../components/ha-form/types";
|
||||
|
||||
export const computeLabel = (
|
||||
fieldName: string,
|
||||
localize: LocalizeFunc
|
||||
): string =>
|
||||
localize(
|
||||
`ui.panel.config.automation.editor.triggers.type.sun.${fieldName}` as any
|
||||
);
|
||||
|
||||
export const YAML_SCHEMA = [
|
||||
{
|
||||
name: "event",
|
||||
type: "select" as const,
|
||||
required: true,
|
||||
options: [["sunrise", "sunrise"] as const, ["sunset", "sunset"] as const],
|
||||
},
|
||||
{ name: "offset", selector: { text: {} } },
|
||||
] as const;
|
||||
|
||||
@customElement("ha-automation-trigger-sun")
|
||||
export class HaSunTrigger extends LitElement implements TriggerElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -73,10 +91,7 @@ export class HaSunTrigger extends LitElement implements TriggerElement {
|
||||
|
||||
private _computeLabelCallback = (
|
||||
schema: SchemaUnion<ReturnType<typeof this._schema>>
|
||||
): string =>
|
||||
this.hass.localize(
|
||||
`ui.panel.config.automation.editor.triggers.type.sun.${schema.name}`
|
||||
);
|
||||
): string => computeLabel(schema.name, this.hass.localize);
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -7,8 +7,9 @@ import { createDurationData } from "../../../../../common/datetime/create_durati
|
||||
import { fireEvent } from "../../../../../common/dom/fire_event";
|
||||
import { hasTemplate } from "../../../../../common/string/has-template";
|
||||
import type { SchemaUnion } from "../../../../../components/ha-form/types";
|
||||
import type { LocalizeFunc } from "../../../../../common/translations/localize";
|
||||
|
||||
const SCHEMA = [
|
||||
export const SCHEMA = [
|
||||
{ name: "value_template", required: true, selector: { template: {} } },
|
||||
{
|
||||
name: "for",
|
||||
@@ -25,6 +26,14 @@ const SCHEMA = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const computeLabel = (
|
||||
fieldName: string,
|
||||
localize: LocalizeFunc
|
||||
): string =>
|
||||
localize(
|
||||
`ui.panel.config.automation.editor.triggers.type.template.${fieldName}` as any
|
||||
);
|
||||
|
||||
@customElement("ha-automation-trigger-template")
|
||||
export class HaTemplateTrigger extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -102,10 +111,7 @@ export class HaTemplateTrigger extends LitElement {
|
||||
|
||||
private _computeLabelCallback = (
|
||||
schema: SchemaUnion<typeof SCHEMA>
|
||||
): string =>
|
||||
this.hass.localize(
|
||||
`ui.panel.config.automation.editor.triggers.type.template.${schema.name}`
|
||||
);
|
||||
): string => computeLabel(schema.name, this.hass.localize);
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -18,6 +18,37 @@ const MODE_ENTITY = "entity";
|
||||
const VALID_DOMAINS = ["sensor", "input_datetime"];
|
||||
const DAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const;
|
||||
|
||||
// Real YAML keys for the time trigger (the UI uses time/entity/mode/offset
|
||||
// as an abstraction, but the actual YAML uses `at` and `weekday`).
|
||||
export const YAML_SCHEMA = [
|
||||
{ name: "at", required: true, selector: { text: {} } },
|
||||
{
|
||||
name: "weekday",
|
||||
type: "multi_select" as const,
|
||||
options: DAYS.map((d) => [d, d] as const),
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const computeLabel = (
|
||||
fieldName: string,
|
||||
localize: LocalizeFunc
|
||||
): string => {
|
||||
switch (fieldName) {
|
||||
case "time":
|
||||
return localize(
|
||||
"ui.panel.config.automation.editor.triggers.type.time.at"
|
||||
);
|
||||
case "weekday":
|
||||
return localize(
|
||||
"ui.panel.config.automation.editor.triggers.type.time.weekday"
|
||||
);
|
||||
default:
|
||||
return localize(
|
||||
`ui.panel.config.automation.editor.triggers.type.time.${fieldName}` as any
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@customElement("ha-automation-trigger-time")
|
||||
export class HaTimeTrigger extends LitElement implements TriggerElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -207,21 +238,7 @@ export class HaTimeTrigger extends LitElement implements TriggerElement {
|
||||
|
||||
private _computeLabelCallback = (
|
||||
schema: SchemaUnion<ReturnType<typeof this._schema>>
|
||||
): string => {
|
||||
switch (schema.name) {
|
||||
case "time":
|
||||
return this.hass.localize(
|
||||
`ui.panel.config.automation.editor.triggers.type.time.at`
|
||||
);
|
||||
case "weekday":
|
||||
return this.hass.localize(
|
||||
`ui.panel.config.automation.editor.triggers.type.time.weekday`
|
||||
);
|
||||
}
|
||||
return this.hass.localize(
|
||||
`ui.panel.config.automation.editor.triggers.type.time.${schema.name}`
|
||||
);
|
||||
};
|
||||
): string => computeLabel(schema.name, this.hass.localize);
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
|
||||
@@ -6,13 +6,28 @@ import type { SchemaUnion } from "../../../../../components/ha-form/types";
|
||||
import type { TimePatternTrigger } from "../../../../../data/automation";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
import type { TriggerElement } from "../ha-automation-trigger-row";
|
||||
import type { LocalizeFunc } from "../../../../../common/translations/localize";
|
||||
|
||||
const SCHEMA = [
|
||||
export const SCHEMA = [
|
||||
{ name: "hours", selector: { text: {} } },
|
||||
{ name: "minutes", selector: { text: {} } },
|
||||
{ name: "seconds", selector: { text: {} } },
|
||||
] as const;
|
||||
|
||||
export const computeLabel = (
|
||||
fieldName: string,
|
||||
localize: LocalizeFunc
|
||||
): string =>
|
||||
localize(
|
||||
`ui.panel.config.automation.editor.triggers.type.time_pattern.${fieldName}` as any
|
||||
);
|
||||
|
||||
export const computeHelper = (
|
||||
_fieldName: string,
|
||||
localize: LocalizeFunc
|
||||
): string =>
|
||||
localize("ui.panel.config.automation.editor.triggers.type.time_pattern.help");
|
||||
|
||||
@customElement("ha-automation-trigger-time_pattern")
|
||||
export class HaTimePatternTrigger extends LitElement implements TriggerElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -47,17 +62,11 @@ export class HaTimePatternTrigger extends LitElement implements TriggerElement {
|
||||
|
||||
private _computeLabelCallback = (
|
||||
schema: SchemaUnion<typeof SCHEMA>
|
||||
): string =>
|
||||
this.hass.localize(
|
||||
`ui.panel.config.automation.editor.triggers.type.time_pattern.${schema.name}`
|
||||
);
|
||||
): string => computeLabel(schema.name, this.hass.localize);
|
||||
|
||||
private _computeHelperCallback = (
|
||||
_schema: SchemaUnion<typeof SCHEMA>
|
||||
): string =>
|
||||
this.hass.localize(
|
||||
`ui.panel.config.automation.editor.triggers.type.time_pattern.help`
|
||||
);
|
||||
schema: SchemaUnion<typeof SCHEMA>
|
||||
): string => computeHelper(schema.name, this.hass.localize);
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user