20240327.0 (#20210)

This commit is contained in:
Bram Kragten 2024-03-27 17:52:08 +01:00 committed by GitHub
commit 795c16a941
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
236 changed files with 14392 additions and 11027 deletions

View File

@ -1,5 +1,5 @@
# See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.148.1/containers/python-3/.devcontainer/base.Dockerfile # See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.148.1/containers/python-3/.devcontainer/base.Dockerfile
FROM mcr.microsoft.com/vscode/devcontainers/python:0-3.11 FROM mcr.microsoft.com/devcontainers/python:3.12
ENV \ ENV \
DEBIAN_FRONTEND=noninteractive \ DEBIAN_FRONTEND=noninteractive \

View File

@ -21,7 +21,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }} url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps: steps:
- name: Check out files from GitHub - name: Check out files from GitHub
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4.1.2
with: with:
ref: dev ref: dev
@ -57,7 +57,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }} url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps: steps:
- name: Check out files from GitHub - name: Check out files from GitHub
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4.1.2
with: with:
ref: master ref: master

View File

@ -24,7 +24,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out files from GitHub - name: Check out files from GitHub
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4.1.2
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4.0.2 uses: actions/setup-node@v4.0.2
with: with:
@ -37,7 +37,7 @@ jobs:
- name: Build resources - name: Build resources
run: ./node_modules/.bin/gulp gen-icons-json build-translations build-locale-data gather-gallery-pages run: ./node_modules/.bin/gulp gen-icons-json build-translations build-locale-data gather-gallery-pages
- name: Setup lint cache - name: Setup lint cache
uses: actions/cache@v4.0.0 uses: actions/cache@v4.0.2
with: with:
path: | path: |
node_modules/.cache/prettier node_modules/.cache/prettier
@ -58,7 +58,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out files from GitHub - name: Check out files from GitHub
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4.1.2
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4.0.2 uses: actions/setup-node@v4.0.2
with: with:
@ -76,7 +76,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out files from GitHub - name: Check out files from GitHub
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4.1.2
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4.0.2 uses: actions/setup-node@v4.0.2
with: with:
@ -100,7 +100,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out files from GitHub - name: Check out files from GitHub
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4.1.2
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4.0.2 uses: actions/setup-node@v4.0.2
with: with:

View File

@ -23,7 +23,7 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4.1.2
with: with:
# We must fetch at least the immediate parents so that if this is # We must fetch at least the immediate parents so that if this is
# a pull request then we can checkout the head. # a pull request then we can checkout the head.

View File

@ -22,7 +22,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }} url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps: steps:
- name: Check out files from GitHub - name: Check out files from GitHub
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4.1.2
with: with:
ref: dev ref: dev
@ -58,7 +58,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }} url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps: steps:
- name: Check out files from GitHub - name: Check out files from GitHub
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4.1.2
with: with:
ref: master ref: master

View File

@ -16,7 +16,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }} url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps: steps:
- name: Check out files from GitHub - name: Check out files from GitHub
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4.1.2
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4.0.2 uses: actions/setup-node@v4.0.2

View File

@ -21,7 +21,7 @@ jobs:
if: github.repository == 'home-assistant/frontend' && contains(github.event.pull_request.labels.*.name, 'needs design preview') if: github.repository == 'home-assistant/frontend' && contains(github.event.pull_request.labels.*.name, 'needs design preview')
steps: steps:
- name: Check out files from GitHub - name: Check out files from GitHub
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4.1.2
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4.0.2 uses: actions/setup-node@v4.0.2

View File

@ -6,7 +6,7 @@ on:
- cron: "0 1 * * *" - cron: "0 1 * * *"
env: env:
PYTHON_VERSION: "3.11" PYTHON_VERSION: "3.12"
NODE_OPTIONS: --max_old_space_size=6144 NODE_OPTIONS: --max_old_space_size=6144
permissions: permissions:
@ -20,7 +20,7 @@ jobs:
contents: write contents: write
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4.1.2
- name: Set up Python ${{ env.PYTHON_VERSION }} - name: Set up Python ${{ env.PYTHON_VERSION }}
uses: actions/setup-python@v5 uses: actions/setup-python@v5

View File

@ -6,7 +6,7 @@ on:
- published - published
env: env:
PYTHON_VERSION: "3.11" PYTHON_VERSION: "3.12"
NODE_OPTIONS: --max_old_space_size=6144 NODE_OPTIONS: --max_old_space_size=6144
# Set default workflow permissions # Set default workflow permissions
@ -23,7 +23,7 @@ jobs:
contents: write # Required to upload release assets contents: write # Required to upload release assets
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4.1.2
- name: Verify version - name: Verify version
uses: home-assistant/actions/helpers/verify-version@master uses: home-assistant/actions/helpers/verify-version@master
@ -55,7 +55,7 @@ jobs:
script/release script/release
- name: Upload release assets - name: Upload release assets
uses: softprops/action-gh-release@v0.1.15 uses: softprops/action-gh-release@v2.0.4
with: with:
files: | files: |
dist/*.whl dist/*.whl

View File

@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4.1.2
- name: Upload Translations - name: Upload Translations
run: | run: |

View File

@ -1,13 +0,0 @@
diff --git a/simple-tooltip.js b/simple-tooltip.js
index 78a87f6a223925f0e29fbedb268c85a142ec6985..3d686dd6a3d5a93342b4b01408089fc316b408ca 100644
--- a/simple-tooltip.js
+++ b/simple-tooltip.js
@@ -195,6 +195,8 @@ class SimpleTooltip extends LitElement {
.hidden {
position: absolute;
left: -10000px;
+ inset-inline-start: -10000px;
+ inset-inline-end: initial;
top: auto;
width: 1px;
height: 1px;

File diff suppressed because one or more lines are too long

View File

@ -6,4 +6,4 @@ enableGlobalCache: false
nodeLinker: node-modules nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.1.0.cjs yarnPath: .yarn/releases/yarn-4.1.1.cjs

View File

@ -72,6 +72,8 @@ export class HaDemo extends HomeAssistantAppEl {
id: "sensor.co2_intensity", id: "sensor.co2_intensity",
name: null, name: null,
icon: null, icon: null,
labels: [],
categories: {},
platform: "co2signal", platform: "co2signal",
hidden_by: null, hidden_by: null,
entity_category: null, entity_category: null,
@ -88,6 +90,8 @@ export class HaDemo extends HomeAssistantAppEl {
id: "sensor.co2_intensity", id: "sensor.co2_intensity",
name: null, name: null,
icon: null, icon: null,
labels: [],
categories: {},
platform: "co2signal", platform: "co2signal",
hidden_by: null, hidden_by: null,
entity_category: null, entity_category: null,

View File

@ -10,6 +10,7 @@ export const mockConfigEntries = (hass: MockHomeAssistant) => {
supports_options: false, supports_options: false,
supports_remove_device: false, supports_remove_device: false,
supports_unload: true, supports_unload: true,
supports_reconfigure: true,
pref_disable_new_entities: false, pref_disable_new_entities: false,
pref_disable_polling: false, pref_disable_polling: false,
disabled_by: null, disabled_by: null,

View File

@ -17,6 +17,7 @@ export const basicTrace: DemoTrace = {
{ {
path: "trigger/0", path: "trigger/0",
timestamp: "2021-03-25T04:36:51.223693+00:00", timestamp: "2021-03-25T04:36:51.223693+00:00",
changed_variables: {},
}, },
], ],
"condition/0": [ "condition/0": [

View File

@ -17,6 +17,7 @@ export const motionLightTrace: DemoTrace = {
{ {
path: "trigger/0", path: "trigger/0",
timestamp: "2021-03-25T04:36:51.223693+00:00", timestamp: "2021-03-25T04:36:51.223693+00:00",
changed_variables: {},
}, },
], ],
"action/0": [ "action/0": [

View File

@ -21,10 +21,10 @@ const ENTITIES = [
}), }),
]; ];
const conditions = [ const conditions: Condition[] = [
{ condition: "and" }, { condition: "and", conditions: [] },
{ condition: "not" }, { condition: "not", conditions: [] },
{ condition: "or" }, { condition: "or", conditions: [] },
{ condition: "state", entity_id: "light.kitchen", state: "on" }, { condition: "state", entity_id: "light.kitchen", state: "on" },
{ {
condition: "numeric_state", condition: "numeric_state",
@ -34,11 +34,11 @@ const conditions = [
above: 20, above: 20,
}, },
{ condition: "sun", after: "sunset" }, { condition: "sun", after: "sunset" },
{ condition: "sun", after: "sunrise", offset: "-01:00" }, { condition: "sun", after: "sunrise", before_offset: 3600 },
{ condition: "zone", entity_id: "device_tracker.person", zone: "zone.home" }, { condition: "zone", entity_id: "device_tracker.person", zone: "zone.home" },
{ condition: "trigger", id: "motion" }, { condition: "trigger", id: "motion" },
{ condition: "time" }, { condition: "time" },
{ condition: "template" }, { condition: "template", value_template: "" },
]; ];
const initialCondition: Condition = { const initialCondition: Condition = {

View File

@ -55,6 +55,7 @@ export class DemoAutomationTraceTimeline extends LitElement {
super.firstUpdated(changedProps); super.firstUpdated(changedProps);
const hass = provideHass(this); const hass = provideHass(this);
hass.updateTranslations(null, "en"); hass.updateTranslations(null, "en");
hass.updateTranslations("config", "en");
} }
static get styles() { static get styles() {

View File

@ -60,6 +60,7 @@ export class DemoAutomationTrace extends LitElement {
super.firstUpdated(changedProps); super.firstUpdated(changedProps);
const hass = provideHass(this); const hass = provideHass(this);
hass.updateTranslations(null, "en"); hass.updateTranslations(null, "en");
hass.updateTranslations("config", "en");
} }
static get styles() { static get styles() {

View File

@ -162,7 +162,7 @@ export class DemoHaBarButton extends LitElement {
} }
.custom-group { .custom-group {
--control-button-group-thickness: 100px; --control-button-group-thickness: 100px;
--control-button-group-border-radius: 18px; --control-button-group-border-radius: 36px;
--control-button-group-spacing: 20px; --control-button-group-spacing: 20px;
} }
.custom-group ha-control-button { .custom-group ha-control-button {

View File

@ -94,7 +94,7 @@ export class DemoHarControlNumberButtons extends LitElement {
--control-number-buttons-background-color: #2196f3; --control-number-buttons-background-color: #2196f3;
--control-number-buttons-background-opacity: 0.1; --control-number-buttons-background-opacity: 0.1;
--control-number-buttons-thickness: 100px; --control-number-buttons-thickness: 100px;
--control-number-buttons-border-radius: 24px; --control-number-buttons-border-radius: 36px;
} }
`; `;
} }

View File

@ -186,8 +186,8 @@ export class DemoHaControlSelect extends LitElement {
.custom { .custom {
--mdc-icon-size: 24px; --mdc-icon-size: 24px;
--control-select-color: var(--state-fan-active-color); --control-select-color: var(--state-fan-active-color);
--control-select-thickness: 100px; --control-select-thickness: 130px;
--control-select-border-radius: 24px; --control-select-border-radius: 48px;
} }
.vertical-selects { .vertical-selects {
height: 300px; height: 300px;

View File

@ -150,8 +150,8 @@ export class DemoHaBarSlider extends LitElement {
--control-slider-color: #ffcf4c; --control-slider-color: #ffcf4c;
--control-slider-background: #ffcf4c; --control-slider-background: #ffcf4c;
--control-slider-background-opacity: 0.2; --control-slider-background-opacity: 0.2;
--control-slider-thickness: 100px; --control-slider-thickness: 130px;
--control-slider-border-radius: 24px; --control-slider-border-radius: 48px;
} }
.vertical-sliders { .vertical-sliders {
height: 300px; height: 300px;

View File

@ -117,8 +117,8 @@ export class DemoHaControlSwitch extends LitElement {
.custom { .custom {
--control-switch-on-color: var(--green-color); --control-switch-on-color: var(--green-color);
--control-switch-off-color: var(--red-color); --control-switch-off-color: var(--red-color);
--control-switch-thickness: 100px; --control-switch-thickness: 130px;
--control-switch-border-radius: 24px; --control-switch-border-radius: 48px;
--control-switch-padding: 6px; --control-switch-padding: 6px;
--mdc-icon-size: 24px; --mdc-icon-size: 24px;
} }

View File

@ -59,6 +59,7 @@ const DEVICES = [
hw_version: null, hw_version: null,
via_device_id: null, via_device_id: null,
serial_number: null, serial_number: null,
labels: [],
}, },
{ {
area_id: "backyard", area_id: "backyard",
@ -77,6 +78,7 @@ const DEVICES = [
hw_version: null, hw_version: null,
via_device_id: null, via_device_id: null,
serial_number: null, serial_number: null,
labels: [],
}, },
{ {
area_id: null, area_id: null,
@ -95,30 +97,37 @@ const DEVICES = [
hw_version: null, hw_version: null,
via_device_id: null, via_device_id: null,
serial_number: null, serial_number: null,
labels: [],
}, },
]; ];
const AREAS: AreaRegistryEntry[] = [ const AREAS: AreaRegistryEntry[] = [
{ {
area_id: "backyard", area_id: "backyard",
floor_id: null,
name: "Backyard", name: "Backyard",
icon: null, icon: null,
picture: null, picture: null,
aliases: [], aliases: [],
labels: [],
}, },
{ {
area_id: "bedroom", area_id: "bedroom",
floor_id: null,
name: "Bedroom", name: "Bedroom",
icon: "mdi:bed", icon: "mdi:bed",
picture: null, picture: null,
aliases: [], aliases: [],
labels: [],
}, },
{ {
area_id: "livingroom", area_id: "livingroom",
floor_id: null,
name: "Livingroom", name: "Livingroom",
icon: "mdi:sofa", icon: "mdi:sofa",
picture: null, picture: null,
aliases: [], aliases: [],
labels: [],
}, },
]; ];

View File

@ -55,6 +55,7 @@ const DEVICES = [
hw_version: null, hw_version: null,
via_device_id: null, via_device_id: null,
serial_number: null, serial_number: null,
labels: [],
}, },
{ {
area_id: "backyard", area_id: "backyard",
@ -73,6 +74,7 @@ const DEVICES = [
hw_version: null, hw_version: null,
via_device_id: null, via_device_id: null,
serial_number: null, serial_number: null,
labels: [],
}, },
{ {
area_id: null, area_id: null,
@ -91,30 +93,37 @@ const DEVICES = [
hw_version: null, hw_version: null,
via_device_id: null, via_device_id: null,
serial_number: null, serial_number: null,
labels: [],
}, },
]; ];
const AREAS: AreaRegistryEntry[] = [ const AREAS: AreaRegistryEntry[] = [
{ {
area_id: "backyard", area_id: "backyard",
floor_id: null,
name: "Backyard", name: "Backyard",
icon: null, icon: null,
picture: null, picture: null,
aliases: [], aliases: [],
labels: [],
}, },
{ {
area_id: "bedroom", area_id: "bedroom",
floor_id: null,
name: "Bedroom", name: "Bedroom",
icon: "mdi:bed", icon: "mdi:bed",
picture: null, picture: null,
aliases: [], aliases: [],
labels: [],
}, },
{ {
area_id: "livingroom", area_id: "livingroom",
floor_id: null,
name: "Livingroom", name: "Livingroom",
icon: "mdi:sofa", icon: "mdi:sofa",
picture: null, picture: null,
aliases: [], aliases: [],
labels: [],
}, },
]; ];

View File

@ -11,7 +11,7 @@ const ENTITIES = [
latitude: 32.877105, latitude: 32.877105,
longitude: 117.232185, longitude: 117.232185,
gps_accuracy: 91, gps_accuracy: 91,
battery: 71, battery: 25,
friendly_name: "Paulus", friendly_name: "Paulus",
}), }),
getEntity("device_tracker", "demo_anne_therese", "school", { getEntity("device_tracker", "demo_anne_therese", "school", {
@ -19,7 +19,7 @@ const ENTITIES = [
latitude: 32.877105, latitude: 32.877105,
longitude: 117.232185, longitude: 117.232185,
gps_accuracy: 91, gps_accuracy: 91,
battery: 71, battery: 50,
friendly_name: "Anne Therese", friendly_name: "Anne Therese",
}), }),
getEntity("device_tracker", "demo_home_boy", "home", { getEntity("device_tracker", "demo_home_boy", "home", {
@ -27,7 +27,7 @@ const ENTITIES = [
latitude: 32.877105, latitude: 32.877105,
longitude: 117.232185, longitude: 117.232185,
gps_accuracy: 91, gps_accuracy: 91,
battery: 71, battery: 75,
friendly_name: "Home Boy", friendly_name: "Home Boy",
}), }),
getEntity("light", "bed_light", "on", { getEntity("light", "bed_light", "on", {
@ -39,21 +39,53 @@ const ENTITIES = [
getEntity("light", "ceiling_lights", "off", { getEntity("light", "ceiling_lights", "off", {
friendly_name: "Ceiling Lights", friendly_name: "Ceiling Lights",
}), }),
getEntity("sensor", "battery_1", 20, {
device_class: "battery",
friendly_name: "Battery 1",
unit_of_measurement: "%",
}),
getEntity("sensor", "battery_2", 35, {
device_class: "battery",
friendly_name: "Battery 2",
unit_of_measurement: "%",
}),
getEntity("sensor", "battery_3", 40, {
device_class: "battery",
friendly_name: "Battery 3",
unit_of_measurement: "%",
}),
getEntity("sensor", "battery_4", 80, {
device_class: "battery",
friendly_name: "Battery 4",
unit_of_measurement: "%",
}),
getEntity("input_number", "min_battery_level", 30, {
mode: "slider",
step: 10,
min: 0,
max: 100,
icon: "mdi:battery-alert-variant",
friendly_name: "Minimum Battery Level",
unit_of_measurement: "%",
}),
]; ];
const CONFIGS = [ const CONFIGS = [
{ {
heading: "Unfiltered controller", heading: "Unfiltered entities",
config: ` config: `
- type: entities - type: entities
entities: entities:
- device_tracker.demo_anne_therese
- device_tracker.demo_home_boy
- device_tracker.demo_paulus
- light.bed_light - light.bed_light
- light.ceiling_lights - light.ceiling_lights
- light.kitchen_lights - light.kitchen_lights
`, `,
}, },
{ {
heading: "Filtered entities card", heading: "On and home entities",
config: ` config: `
- type: entity-filter - type: entity-filter
entities: entities:
@ -63,11 +95,30 @@ const CONFIGS = [
- light.bed_light - light.bed_light
- light.ceiling_lights - light.ceiling_lights
- light.kitchen_lights - light.kitchen_lights
state_filter: conditions:
- condition: state
state:
- "on" - "on"
- home - home
`, `,
}, },
{
heading: "Same state as Bed Light",
config: `
- type: entity-filter
entities:
- device_tracker.demo_anne_therese
- device_tracker.demo_home_boy
- device_tracker.demo_paulus
- light.bed_light
- light.ceiling_lights
- light.kitchen_lights
conditions:
- condition: state
state:
- light.bed_light
`,
},
{ {
heading: 'With "entities" card config', heading: 'With "entities" card config',
config: ` config: `
@ -79,9 +130,11 @@ const CONFIGS = [
- light.bed_light - light.bed_light
- light.ceiling_lights - light.ceiling_lights
- light.kitchen_lights - light.kitchen_lights
state_filter: conditions:
- condition: state
state:
- "on" - "on"
- not_home - home
card: card:
type: entities type: entities
title: Custom Title title: Custom Title
@ -99,15 +152,101 @@ const CONFIGS = [
- light.bed_light - light.bed_light
- light.ceiling_lights - light.ceiling_lights
- light.kitchen_lights - light.kitchen_lights
state_filter: conditions:
- condition: state
state:
- "on" - "on"
- not_home - home
card: card:
type: glance type: glance
show_state: true show_state: true
title: Custom Title title: Custom Title
`, `,
}, },
{
heading:
"Filtered entities by battery attribute (< '30') using state filter",
config: `
- type: entity-filter
entities:
- device_tracker.demo_anne_therese
- device_tracker.demo_home_boy
- device_tracker.demo_paulus
state_filter:
- operator: <
attribute: battery
value: "30"
`,
},
{
heading: "Unfiltered number entities",
config: `
- type: entities
entities:
- input_number.min_battery_level
- sensor.battery_1
- sensor.battery_3
- sensor.battery_2
- sensor.battery_4
`,
},
{
heading: "Battery lower than 50%",
config: `
- type: entity-filter
entities:
- sensor.battery_1
- sensor.battery_3
- sensor.battery_2
- sensor.battery_4
conditions:
- condition: numeric_state
below: 50
`,
},
{
heading: "Battery lower than min battery level",
config: `
- type: entity-filter
entities:
- sensor.battery_1
- sensor.battery_3
- sensor.battery_2
- sensor.battery_4
conditions:
- condition: numeric_state
below: input_number.min_battery_level
`,
},
{
heading: "Battery between min battery level and 70%",
config: `
- type: entity-filter
entities:
- sensor.battery_1
- sensor.battery_3
- sensor.battery_2
- sensor.battery_4
conditions:
- condition: numeric_state
above: input_number.min_battery_level
below: 70
`,
},
{
heading: "Error: Entities must be specified",
config: `
- type: entity-filter
`,
},
{
heading: "Error: Incorrect filter config",
config: `
- type: entity-filter
entities:
- sensor.gas_station_lowest_price
`,
},
]; ];
@customElement("demo-lovelace-entity-filter-card") @customElement("demo-lovelace-entity-filter-card")

View File

@ -36,6 +36,45 @@ const ENTITIES = [
friendly_name: "Nest", friendly_name: "Nest",
supported_features: 43, supported_features: 43,
}), }),
getEntity("climate", "overkiz_radiator", "heat", {
current_temperature: 18,
min_temp: 7,
max_temp: 35,
temperature: 20,
hvac_modes: ["heat", "auto", "off"],
friendly_name: "Overkiz radiator",
supported_features: 17,
preset_mode: "comfort",
preset_modes: [
"none",
"frost_protection",
"eco",
"comfort",
"comfort-1",
"comfort-2",
"auto",
"boost",
"external",
"prog",
],
}),
getEntity("climate", "overkiz_towel_dryer", "heat", {
current_temperature: null,
min_temp: 7,
max_temp: 35,
hvac_modes: ["heat", "off"],
friendly_name: "Overkiz towel dryer",
supported_features: 16,
preset_mode: "eco",
preset_modes: [
"none",
"frost_protection",
"eco",
"comfort",
"comfort-1",
"comfort-2",
],
}),
getEntity("climate", "sensibo", "fan_only", { getEntity("climate", "sensibo", "fan_only", {
current_temperature: null, current_temperature: null,
temperature: null, temperature: null,
@ -46,7 +85,9 @@ const ENTITIES = [
friendly_name: "Sensibo purifier", friendly_name: "Sensibo purifier",
fan_modes: ["low", "high"], fan_modes: ["low", "high"],
fan_mode: "low", fan_mode: "low",
supported_features: 9, swing_modes: ["on", "off", "both", "vertical", "horizontal"],
swing_mode: "vertical",
supported_features: 41,
}), }),
getEntity("climate", "unavailable", "unavailable", { getEntity("climate", "unavailable", "unavailable", {
supported_features: 43, supported_features: 43,
@ -59,8 +100,6 @@ const CONFIGS = [
config: ` config: `
- type: thermostat - type: thermostat
entity: climate.ecobee entity: climate.ecobee
- type: thermostat
entity: climate.nest
`, `,
}, },
{ {
@ -70,6 +109,66 @@ const CONFIGS = [
entity: climate.nest entity: climate.nest
`, `,
}, },
{
heading: "Feature example",
config: `
- type: thermostat
entity: climate.overkiz_radiator
features:
- type: climate-hvac-modes
hvac_modes:
- heat
- 'off'
- auto
- type: climate-preset-modes
style: icons
preset_modes:
- none
- frost_protection
- eco
- comfort
- comfort-1
- comfort-2
- auto
- boost
- external
- prog
- type: climate-preset-modes
style: dropdown
preset_modes:
- none
- frost_protection
- eco
- comfort
- comfort-1
- comfort-2
- auto
- boost
- external
- prog
`,
},
{
heading: "Preset only example",
config: `
- type: thermostat
entity: climate.overkiz_towel_dryer
features:
- type: climate-hvac-modes
hvac_modes:
- heat
- 'off'
- type: climate-preset-modes
style: icons
preset_modes:
- none
- frost_protection
- eco
- comfort
- comfort-1
- comfort-2
`,
},
{ {
heading: "Fan only example", heading: "Fan only example",
config: ` config: `
@ -85,6 +184,14 @@ const CONFIGS = [
fan_modes: fan_modes:
- low - low
- high - high
- type: climate-swing-modes
style: icons
swing_modes:
- 'on'
- 'off'
- 'both'
- 'vertical'
- 'horizontal'
`, `,
}, },
{ {

View File

@ -406,6 +406,7 @@ export class DemoEntityState extends LitElement {
entity_id: "select.speed", entity_id: "select.speed",
translation_key: "speed", translation_key: "speed",
platform: "demo", platform: "demo",
labels: [],
}, },
}, },
}); });

View File

@ -31,6 +31,7 @@ const createConfigEntry = (
supports_options: false, supports_options: false,
supports_remove_device: false, supports_remove_device: false,
supports_unload: true, supports_unload: true,
supports_reconfigure: true,
disabled_by: null, disabled_by: null,
pref_disable_new_entities: false, pref_disable_new_entities: false,
pref_disable_polling: false, pref_disable_polling: false,
@ -198,6 +199,8 @@ const createEntityRegistryEntries = (
has_entity_name: false, has_entity_name: false,
unique_id: "updater", unique_id: "updater",
options: null, options: null,
labels: [],
categories: {},
}, },
]; ];
@ -221,6 +224,7 @@ const createDeviceRegistryEntries = (
name_by_user: null, name_by_user: null,
disabled_by: null, disabled_by: null,
configuration_url: null, configuration_url: null,
labels: [],
}, },
]; ];

View File

@ -11,7 +11,7 @@ import "../../components/demo-more-infos";
import { ClimateEntityFeature } from "../../../../src/data/climate"; import { ClimateEntityFeature } from "../../../../src/data/climate";
const ENTITIES = [ const ENTITIES = [
getEntity("climate", "thermostat", "heat", { getEntity("climate", "radiator", "heat", {
friendly_name: "Basic heater", friendly_name: "Basic heater",
hvac_modes: ["heat", "off"], hvac_modes: ["heat", "off"],
hvac_mode: "heat", hvac_mode: "heat",
@ -80,6 +80,24 @@ const ENTITIES = [
max_humidity: 100, max_humidity: 100,
humidity: 50, humidity: 50,
}), }),
getEntity("climate", "towel_dryer", "heat", {
friendly_name: "Preset only heater",
hvac_modes: ["heat", "off"],
hvac_mode: "heat",
preset_modes: [
"none",
"frost_protection",
"eco",
"comfort",
"comfort-1",
"comfort-2",
],
preset_mode: "eco",
current_temperature: null,
min_temp: 7,
max_temp: 35,
supported_features: ClimateEntityFeature.PRESET_MODE,
}),
getEntity("climate", "unavailable", "unavailable", { getEntity("climate", "unavailable", "unavailable", {
friendly_name: "Unavailable heater", friendly_name: "Unavailable heater",
hvac_modes: ["heat", "off"], hvac_modes: ["heat", "off"],

View File

@ -25,22 +25,22 @@
"license": "Apache-2.0", "license": "Apache-2.0",
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@babel/runtime": "7.23.9", "@babel/runtime": "7.24.1",
"@braintree/sanitize-url": "7.0.0", "@braintree/sanitize-url": "7.0.1",
"@codemirror/autocomplete": "6.12.0", "@codemirror/autocomplete": "6.15.0",
"@codemirror/commands": "6.3.3", "@codemirror/commands": "6.3.3",
"@codemirror/language": "6.10.1", "@codemirror/language": "6.10.1",
"@codemirror/legacy-modes": "6.3.3", "@codemirror/legacy-modes": "6.3.3",
"@codemirror/search": "6.5.6", "@codemirror/search": "6.5.6",
"@codemirror/state": "6.4.1", "@codemirror/state": "6.4.1",
"@codemirror/view": "6.24.1", "@codemirror/view": "6.26.0",
"@egjs/hammerjs": "2.0.17", "@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "6.12.2", "@formatjs/intl-datetimeformat": "6.12.3",
"@formatjs/intl-displaynames": "6.6.6", "@formatjs/intl-displaynames": "6.6.6",
"@formatjs/intl-getcanonicallocales": "2.3.0", "@formatjs/intl-getcanonicallocales": "2.3.0",
"@formatjs/intl-listformat": "7.5.5", "@formatjs/intl-listformat": "7.5.5",
"@formatjs/intl-locale": "3.4.5", "@formatjs/intl-locale": "3.4.5",
"@formatjs/intl-numberformat": "8.10.0", "@formatjs/intl-numberformat": "8.10.1",
"@formatjs/intl-pluralrules": "5.2.12", "@formatjs/intl-pluralrules": "5.2.12",
"@formatjs/intl-relativetimeformat": "11.2.12", "@formatjs/intl-relativetimeformat": "11.2.12",
"@fullcalendar/core": "6.1.11", "@fullcalendar/core": "6.1.11",
@ -54,7 +54,7 @@
"@lit-labs/motion": "1.0.7", "@lit-labs/motion": "1.0.7",
"@lit-labs/observers": "2.0.2", "@lit-labs/observers": "2.0.2",
"@lit-labs/virtualizer": "2.0.12", "@lit-labs/virtualizer": "2.0.12",
"@lrnwebcomponents/simple-tooltip": "patch:@lrnwebcomponents/simple-tooltip@npm%3A8.0.0#~/.yarn/patches/@lrnwebcomponents-simple-tooltip-npm-8.0.0-77591f2e0c.patch", "@lrnwebcomponents/simple-tooltip": "8.0.2",
"@material/chips": "=14.0.0-canary.53b3cad2f.0", "@material/chips": "=14.0.0-canary.53b3cad2f.0",
"@material/data-table": "=14.0.0-canary.53b3cad2f.0", "@material/data-table": "=14.0.0-canary.53b3cad2f.0",
"@material/mwc-base": "0.27.0", "@material/mwc-base": "0.27.0",
@ -72,6 +72,7 @@
"@material/mwc-radio": "0.27.0", "@material/mwc-radio": "0.27.0",
"@material/mwc-ripple": "0.27.0", "@material/mwc-ripple": "0.27.0",
"@material/mwc-select": "0.27.0", "@material/mwc-select": "0.27.0",
"@material/mwc-snackbar": "0.27.0",
"@material/mwc-switch": "0.27.0", "@material/mwc-switch": "0.27.0",
"@material/mwc-tab": "0.27.0", "@material/mwc-tab": "0.27.0",
"@material/mwc-tab-bar": "0.27.0", "@material/mwc-tab-bar": "0.27.0",
@ -86,11 +87,10 @@
"@polymer/paper-item": "3.0.1", "@polymer/paper-item": "3.0.1",
"@polymer/paper-listbox": "3.0.1", "@polymer/paper-listbox": "3.0.1",
"@polymer/paper-tabs": "3.1.0", "@polymer/paper-tabs": "3.1.0",
"@polymer/paper-toast": "3.0.1",
"@polymer/polymer": "3.5.1", "@polymer/polymer": "3.5.1",
"@thomasloven/round-slider": "0.6.0", "@thomasloven/round-slider": "0.6.0",
"@vaadin/combo-box": "24.3.6", "@vaadin/combo-box": "24.3.10",
"@vaadin/vaadin-themable-mixin": "24.3.6", "@vaadin/vaadin-themable-mixin": "24.3.10",
"@vibrant/color": "3.2.1-alpha.1", "@vibrant/color": "3.2.1-alpha.1",
"@vibrant/core": "3.2.1-alpha.1", "@vibrant/core": "3.2.1-alpha.1",
"@vibrant/quantizer-mmcq": "3.2.1-alpha.1", "@vibrant/quantizer-mmcq": "3.2.1-alpha.1",
@ -98,20 +98,20 @@
"@webcomponents/scoped-custom-element-registry": "0.0.9", "@webcomponents/scoped-custom-element-registry": "0.0.9",
"@webcomponents/webcomponentsjs": "2.8.0", "@webcomponents/webcomponentsjs": "2.8.0",
"app-datepicker": "5.1.1", "app-datepicker": "5.1.1",
"chart.js": "4.4.1", "chart.js": "4.4.2",
"color-name": "2.0.0", "color-name": "2.0.0",
"comlink": "4.4.1", "comlink": "4.4.1",
"core-js": "3.36.0", "core-js": "3.36.1",
"cropperjs": "1.6.1", "cropperjs": "1.6.1",
"date-fns": "2.30.0", "date-fns": "2.30.0",
"date-fns-tz": "2.0.0", "date-fns-tz": "2.0.1",
"deep-clone-simple": "1.1.1", "deep-clone-simple": "1.1.1",
"deep-freeze": "0.0.1", "deep-freeze": "0.0.1",
"element-internals-polyfill": "1.3.10", "element-internals-polyfill": "1.3.10",
"fuse.js": "7.0.0", "fuse.js": "7.0.0",
"google-timezones-json": "1.2.0", "google-timezones-json": "1.2.0",
"hls.js": "patch:hls.js@npm%3A1.5.7#~/.yarn/patches/hls.js-npm-1.5.7-f5bbd3d060.patch", "hls.js": "patch:hls.js@npm%3A1.5.7#~/.yarn/patches/hls.js-npm-1.5.7-f5bbd3d060.patch",
"home-assistant-js-websocket": "9.1.0", "home-assistant-js-websocket": "9.2.1",
"idb-keyval": "6.2.1", "idb-keyval": "6.2.1",
"intl-messageformat": "10.5.11", "intl-messageformat": "10.5.11",
"js-yaml": "4.1.0", "js-yaml": "4.1.0",
@ -119,7 +119,7 @@
"leaflet-draw": "1.0.4", "leaflet-draw": "1.0.4",
"lit": "2.8.0", "lit": "2.8.0",
"luxon": "3.4.4", "luxon": "3.4.4",
"marked": "12.0.0", "marked": "12.0.1",
"memoize-one": "6.0.0", "memoize-one": "6.0.0",
"node-vibrant": "3.2.1-alpha.1", "node-vibrant": "3.2.1-alpha.1",
"proxy-polyfill": "0.3.2", "proxy-polyfill": "0.3.2",
@ -130,7 +130,7 @@
"rrule": "2.8.1", "rrule": "2.8.1",
"sortablejs": "1.15.2", "sortablejs": "1.15.2",
"stacktrace-js": "2.0.2", "stacktrace-js": "2.0.2",
"superstruct": "1.0.3", "superstruct": "1.0.4",
"tinykeys": "2.1.0", "tinykeys": "2.1.0",
"tsparticles-engine": "2.12.0", "tsparticles-engine": "2.12.0",
"tsparticles-preset-links": "2.12.0", "tsparticles-preset-links": "2.12.0",
@ -147,20 +147,20 @@
"workbox-precaching": "7.0.0", "workbox-precaching": "7.0.0",
"workbox-routing": "7.0.0", "workbox-routing": "7.0.0",
"workbox-strategies": "7.0.0", "workbox-strategies": "7.0.0",
"xss": "1.0.14" "xss": "1.0.15"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "7.23.9", "@babel/core": "7.24.3",
"@babel/helper-define-polyfill-provider": "0.5.0", "@babel/helper-define-polyfill-provider": "0.6.1",
"@babel/plugin-proposal-decorators": "7.23.9", "@babel/plugin-proposal-decorators": "7.24.1",
"@babel/plugin-transform-runtime": "7.23.9", "@babel/plugin-transform-runtime": "7.24.3",
"@babel/preset-env": "7.23.9", "@babel/preset-env": "7.24.3",
"@babel/preset-typescript": "7.23.3", "@babel/preset-typescript": "7.24.1",
"@bundle-stats/plugin-webpack-filter": "4.10.1", "@bundle-stats/plugin-webpack-filter": "4.12.2",
"@koa/cors": "5.0.0", "@koa/cors": "5.0.0",
"@lokalise/node-api": "12.1.0", "@lokalise/node-api": "12.3.0",
"@octokit/auth-oauth-device": "6.0.1", "@octokit/auth-oauth-device": "7.0.1",
"@octokit/plugin-retry": "6.0.1", "@octokit/plugin-retry": "7.0.3",
"@octokit/rest": "20.0.2", "@octokit/rest": "20.0.2",
"@open-wc/dev-server-hmr": "0.1.4", "@open-wc/dev-server-hmr": "0.1.4",
"@rollup/plugin-babel": "6.0.4", "@rollup/plugin-babel": "6.0.4",
@ -170,7 +170,7 @@
"@rollup/plugin-replace": "5.0.5", "@rollup/plugin-replace": "5.0.5",
"@types/babel__plugin-transform-runtime": "7.9.5", "@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.13", "@types/chromecast-caf-receiver": "6.0.13",
"@types/chromecast-caf-sender": "1.0.8", "@types/chromecast-caf-sender": "1.0.9",
"@types/color-name": "1.1.3", "@types/color-name": "1.1.3",
"@types/glob": "8.1.0", "@types/glob": "8.1.0",
"@types/html-minifier-terser": "7.0.2", "@types/html-minifier-terser": "7.0.2",
@ -185,8 +185,8 @@
"@types/tar": "6.1.11", "@types/tar": "6.1.11",
"@types/ua-parser-js": "0.7.39", "@types/ua-parser-js": "0.7.39",
"@types/webspeechapi": "0.0.29", "@types/webspeechapi": "0.0.29",
"@typescript-eslint/eslint-plugin": "7.0.2", "@typescript-eslint/eslint-plugin": "7.3.1",
"@typescript-eslint/parser": "7.0.2", "@typescript-eslint/parser": "7.3.1",
"@web/dev-server": "0.1.38", "@web/dev-server": "0.1.38",
"@web/dev-server-rollup": "0.4.1", "@web/dev-server-rollup": "0.4.1",
"babel-loader": "9.1.3", "babel-loader": "9.1.3",
@ -195,7 +195,7 @@
"del": "7.1.0", "del": "7.1.0",
"eslint": "8.57.0", "eslint": "8.57.0",
"eslint-config-airbnb-base": "15.0.0", "eslint-config-airbnb-base": "15.0.0",
"eslint-config-airbnb-typescript": "17.1.0", "eslint-config-airbnb-typescript": "18.0.0",
"eslint-config-prettier": "9.1.0", "eslint-config-prettier": "9.1.0",
"eslint-import-resolver-webpack": "0.13.8", "eslint-import-resolver-webpack": "0.13.8",
"eslint-plugin-disable": "2.0.3", "eslint-plugin-disable": "2.0.3",
@ -210,7 +210,7 @@
"gulp": "4.0.2", "gulp": "4.0.2",
"gulp-flatmap": "1.0.2", "gulp-flatmap": "1.0.2",
"gulp-json-transform": "0.5.0", "gulp-json-transform": "0.5.0",
"gulp-merge-json": "2.1.2", "gulp-merge-json": "2.2.1",
"gulp-rename": "2.0.0", "gulp-rename": "2.0.0",
"gulp-zopfli-green": "6.0.1", "gulp-zopfli-green": "6.0.1",
"html-minifier-terser": "7.2.0", "html-minifier-terser": "7.2.0",
@ -220,11 +220,11 @@
"lint-staged": "15.2.2", "lint-staged": "15.2.2",
"lit-analyzer": "2.0.3", "lit-analyzer": "2.0.3",
"lodash.template": "4.5.0", "lodash.template": "4.5.0",
"magic-string": "0.30.7", "magic-string": "0.30.8",
"map-stream": "0.0.7", "map-stream": "0.0.7",
"mocha": "10.3.0", "mocha": "10.3.0",
"object-hash": "3.0.0", "object-hash": "3.0.0",
"open": "10.0.3", "open": "10.1.0",
"pinst": "3.0.0", "pinst": "3.0.0",
"prettier": "3.2.5", "prettier": "3.2.5",
"rollup": "2.79.1", "rollup": "2.79.1",
@ -235,16 +235,16 @@
"sinon": "17.0.1", "sinon": "17.0.1",
"source-map-url": "0.4.1", "source-map-url": "0.4.1",
"systemjs": "6.14.3", "systemjs": "6.14.3",
"tar": "6.2.0", "tar": "6.2.1",
"terser-webpack-plugin": "5.3.10", "terser-webpack-plugin": "5.3.10",
"transform-async-modules-webpack-plugin": "1.0.2", "transform-async-modules-webpack-plugin": "1.0.4",
"ts-lit-plugin": "2.0.2", "ts-lit-plugin": "2.0.2",
"typescript": "5.3.3", "typescript": "5.4.3",
"vinyl-buffer": "1.0.1", "vinyl-buffer": "1.0.1",
"vinyl-source-stream": "2.0.0", "vinyl-source-stream": "2.0.0",
"webpack": "5.90.3", "webpack": "5.91.0",
"webpack-cli": "5.1.4", "webpack-cli": "5.1.4",
"webpack-dev-server": "5.0.2", "webpack-dev-server": "5.0.4",
"webpack-manifest-plugin": "5.0.0", "webpack-manifest-plugin": "5.0.0",
"webpack-stats-plugin": "1.1.3", "webpack-stats-plugin": "1.1.3",
"webpackbar": "6.0.1", "webpackbar": "6.0.1",
@ -260,5 +260,5 @@
"sortablejs@1.15.2": "patch:sortablejs@npm%3A1.15.2#~/.yarn/patches/sortablejs-npm-1.15.2-73347ae85a.patch", "sortablejs@1.15.2": "patch:sortablejs@npm%3A1.15.2#~/.yarn/patches/sortablejs-npm-1.15.2-73347ae85a.patch",
"leaflet-draw@1.0.4": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch" "leaflet-draw@1.0.4": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch"
}, },
"packageManager": "yarn@4.1.0" "packageManager": "yarn@4.1.1"
} }

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.2 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 52 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 69 KiB

View File

@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "home-assistant-frontend" name = "home-assistant-frontend"
version = "20240307.0" version = "20240327.0"
license = {text = "Apache-2.0"} license = {text = "Apache-2.0"}
description = "The Home Assistant frontend" description = "The Home Assistant frontend"
readme = "README.md" readme = "README.md"
authors = [ authors = [
{name = "The Home Assistant Authors", email = "hello@home-assistant.io"} {name = "The Home Assistant Authors", email = "hello@home-assistant.io"}
] ]
requires-python = ">=3.10.0" requires-python = ">=3.11.0"
[project.urls] [project.urls]
"Homepage" = "https://github.com/home-assistant/frontend" "Homepage" = "https://github.com/home-assistant/frontend"

View File

@ -1,3 +1,5 @@
import { theme2hex } from "./convert-color";
export const COLORS = [ export const COLORS = [
"#44739e", "#44739e",
"#984ea3", "#984ea3",
@ -65,10 +67,10 @@ export function getColorByIndex(index: number) {
export function getGraphColorByIndex( export function getGraphColorByIndex(
index: number, index: number,
style: CSSStyleDeclaration style: CSSStyleDeclaration
) { ): string {
// The CSS vars for the colors use range 1..n, so we need to adjust the index from the internal 0..n color index range. // The CSS vars for the colors use range 1..n, so we need to adjust the index from the internal 0..n color index range.
return ( const themeColor =
style.getPropertyValue(`--graph-color-${index + 1}`) || style.getPropertyValue(`--graph-color-${index + 1}`) ||
getColorByIndex(index) getColorByIndex(index);
); return theme2hex(themeColor);
} }

View File

@ -1,3 +1,4 @@
import colors from "color-name";
import { expandHex } from "./hex"; import { expandHex } from "./hex";
const rgb_hex = (component: number): string => { const rgb_hex = (component: number): string => {
@ -126,3 +127,18 @@ export const rgb2hs = (rgb: [number, number, number]): [number, number] =>
export const hs2rgb = (hs: [number, number]): [number, number, number] => export const hs2rgb = (hs: [number, number]): [number, number, number] =>
hsv2rgb([hs[0], hs[1], 255]); hsv2rgb([hs[0], hs[1], 255]);
export function theme2hex(themeColor: string): string {
if (themeColor.startsWith("#")) {
return themeColor;
}
const rgbFromColorName = colors[themeColor];
if (!rgbFromColorName) {
// We have a named color, and there's nothing in the table,
// so nothing further we can do with it.
// Compare/border/background color will all be the same.
return themeColor;
}
return rgb2hex(rgbFromColorName);
}

View File

@ -231,6 +231,7 @@ export const SENSOR_ENTITIES = [
"calendar", "calendar",
"camera", "camera",
"device_tracker", "device_tracker",
"image",
"weather", "weather",
]; ];

View File

@ -37,3 +37,20 @@ export const calcDateProperty = (
locale.time_zone === TimeZone.server locale.time_zone === TimeZone.server
? (calcZonedDate(date, config.time_zone, fn, options) as number | boolean) ? (calcZonedDate(date, config.time_zone, fn, options) as number | boolean)
: fn(date, options); : fn(date, options);
export const calcDateDifferenceProperty = (
endDate: Date,
startDate: Date,
fn: (date: Date, options?: any) => boolean | number,
locale: FrontendLocaleData,
config: HassConfig
) =>
calcDateProperty(
endDate,
fn,
locale,
config,
locale.time_zone === TimeZone.server
? utcToZonedTime(startDate, config.time_zone)
: startDate
);

View File

@ -16,6 +16,7 @@ import { customElement, property, state, query } from "lit/decorators";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { getGraphColorByIndex } from "../../common/color/colors"; import { getGraphColorByIndex } from "../../common/color/colors";
import { isComponentLoaded } from "../../common/config/is_component_loaded"; import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { fireEvent } from "../../common/dom/fire_event";
import { import {
formatNumber, formatNumber,
numberFormatToLocale, numberFormatToLocale,
@ -25,6 +26,7 @@ import {
getDisplayUnit, getDisplayUnit,
getStatisticLabel, getStatisticLabel,
getStatisticMetadata, getStatisticMetadata,
isExternalStatistic,
Statistics, Statistics,
statisticsHaveType, statisticsHaveType,
StatisticsMetaData, StatisticsMetaData,
@ -79,6 +81,8 @@ export class StatisticsChart extends LitElement {
@property({ type: Boolean }) public isLoadingData = false; @property({ type: Boolean }) public isLoadingData = false;
@property({ type: Boolean }) public clickForMoreInfo = true;
@property() public period?: string; @property() public period?: string;
@state() private _chartData: ChartData = { datasets: [] }; @state() private _chartData: ChartData = { datasets: [] };
@ -273,6 +277,33 @@ export class StatisticsChart extends LitElement {
}, },
// @ts-expect-error // @ts-expect-error
locale: numberFormatToLocale(this.hass.locale), locale: numberFormatToLocale(this.hass.locale),
onClick: (e: any) => {
if (
!this.clickForMoreInfo ||
!(e.native instanceof MouseEvent) ||
(e.native instanceof PointerEvent && e.native.pointerType !== "mouse")
) {
return;
}
const chart = e.chart;
const points = chart.getElementsAtEventForMode(
e,
"nearest",
{ intersect: true },
true
);
if (points.length) {
const firstPoint = points[0];
const statisticId = this._statisticIds[firstPoint.datasetIndex];
if (!isExternalStatistic(statisticId)) {
fireEvent(this, "hass-more-info", { entityId: statisticId });
chart.canvas.dispatchEvent(new Event("mouseout")); // to hide tooltip
}
}
},
}; };
} }

View File

@ -4,22 +4,32 @@ import { css, html } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property } from "lit/decorators";
@customElement("ha-assist-chip") @customElement("ha-assist-chip")
// @ts-ignore
export class HaAssistChip extends MdAssistChip { export class HaAssistChip extends MdAssistChip {
@property({ type: Boolean, reflect: true }) filled = false; @property({ type: Boolean, reflect: true }) filled = false;
@property({ type: Boolean }) active = false;
static override styles = [ static override styles = [
...super.styles, ...super.styles,
css` css`
:host { :host {
--md-sys-color-primary: var(--primary-text-color); --md-sys-color-primary: var(--primary-text-color);
--md-sys-color-on-surface: var(--primary-text-color); --md-sys-color-on-surface: var(--primary-text-color);
--md-assist-chip-container-shape: 16px; --md-assist-chip-container-shape: var(
--ha-assist-chip-container-shape,
16px
);
--md-assist-chip-outline-color: var(--outline-color); --md-assist-chip-outline-color: var(--outline-color);
--md-assist-chip-label-text-weight: 400; --md-assist-chip-label-text-weight: 400;
--ha-assist-chip-filled-container-color: rgba( --ha-assist-chip-filled-container-color: rgba(
var(--rgb-primary-text-color), var(--rgb-primary-text-color),
0.15 0.15
); );
--ha-assist-chip-active-container-color: rgba(
var(--rgb-primary-color),
0.15
);
} }
/** Material 3 doesn't have a filled chip, so we have to make our own **/ /** Material 3 doesn't have a filled chip, so we have to make our own **/
.filled { .filled {
@ -31,10 +41,21 @@ export class HaAssistChip extends MdAssistChip {
background-color: var(--ha-assist-chip-filled-container-color); background-color: var(--ha-assist-chip-filled-container-color);
} }
/** Set the size of mdc icons **/ /** Set the size of mdc icons **/
::slotted([slot="icon"]) { ::slotted([slot="icon"]),
::slotted([slot="trailingIcon"]) {
display: flex; display: flex;
--mdc-icon-size: var(--md-input-chip-icon-size, 18px); --mdc-icon-size: var(--md-input-chip-icon-size, 18px);
} }
.trailing.icon ::slotted(*),
.trailing.icon svg {
margin-inline-end: unset;
margin-inline-start: var(--_icon-label-space);
}
:where(.active)::before {
background: var(--ha-assist-chip-active-container-color);
opacity: var(--ha-assist-chip-active-container-opacity);
}
`, `,
]; ];
@ -45,6 +66,30 @@ export class HaAssistChip extends MdAssistChip {
return super.renderOutline(); return super.renderOutline();
} }
protected override getContainerClasses() {
return {
...super.getContainerClasses(),
active: this.active,
};
}
protected override renderPrimaryContent() {
return html`
<span class="leading icon" aria-hidden="true">
${this.renderLeadingIcon()}
</span>
<span class="label">${this.label}</span>
<span class="touch"></span>
<span class="trailing leading icon" aria-hidden="true">
${this.renderTrailingIcon()}
</span>
`;
}
protected renderTrailingIcon() {
return html`<slot name="trailing-icon"></slot>`;
}
} }
declare global { declare global {

View File

@ -19,12 +19,16 @@ export class HaInputChip extends MdInputChip {
var(--rgb-primary-text-color), var(--rgb-primary-text-color),
0.15 0.15
); );
--ha-input-chip-selected-container-opacity: 1;
} }
/** Set the size of mdc icons **/ /** Set the size of mdc icons **/
::slotted([slot="icon"]) { ::slotted([slot="icon"]) {
display: flex; display: flex;
--mdc-icon-size: var(--md-input-chip-icon-size, 18px); --mdc-icon-size: var(--md-input-chip-icon-size, 18px);
} }
.selected::before {
opacity: var(--ha-input-chip-selected-container-opacity);
}
`, `,
]; ];
} }

View File

@ -0,0 +1,127 @@
import { css, html, LitElement, nothing, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { LabelRegistryEntry } from "../../data/label_registry";
import { computeCssColor } from "../../common/color/compute-color";
import { fireEvent } from "../../common/dom/fire_event";
import "../ha-label";
@customElement("ha-data-table-labels")
class HaDataTableLabels extends LitElement {
@property({ attribute: false }) public labels!: LabelRegistryEntry[];
protected render(): TemplateResult {
return html`
<ha-chip-set>
${repeat(
this.labels.slice(0, 2),
(label) => label.label_id,
(label) => this._renderLabel(label, true)
)}
${this.labels.length > 2
? html`<ha-button-menu
absolute
role="button"
tabindex="0"
@click=${this._handleIconOverflowMenuOpened}
@closed=${this._handleIconOverflowMenuClosed}
>
<ha-label slot="trigger" class="plus" dense>
+${this.labels.length - 2}
</ha-label>
${repeat(
this.labels.slice(2),
(label) => label.label_id,
(label) => html`
<ha-list-item @click=${this._labelClicked} .item=${label}>
${this._renderLabel(label, false)}
</ha-list-item>
`
)}
</ha-button-menu>`
: nothing}
</ha-chip-set>
`;
}
private _renderLabel(label: LabelRegistryEntry, clickAction: boolean) {
const color = label?.color ? computeCssColor(label.color) : undefined;
return html`
<ha-label
dense
role="button"
tabindex="0"
.item=${label}
@click=${clickAction ? this._labelClicked : undefined}
@keydown=${clickAction ? this._labelClicked : undefined}
style=${color ? `--color: ${color}` : ""}
>
${label?.icon
? html`<ha-icon slot="icon" .icon=${label.icon}></ha-icon>`
: nothing}
${label.name}
</ha-label>
`;
}
private _labelClicked(ev) {
ev.stopPropagation();
if (ev.type === "keydown" && ev.key !== "Enter" && ev.key !== " ") {
return;
}
const label = (ev.currentTarget as any).item as LabelRegistryEntry;
fireEvent(this, "label-clicked", { label });
}
protected _handleIconOverflowMenuOpened(e) {
e.stopPropagation();
// If this component is used inside a data table, the z-index of the row
// needs to be increased. Otherwise the ha-button-menu would be displayed
// underneath the next row in the table.
const row = this.closest(".mdc-data-table__row") as HTMLDivElement | null;
if (row) {
row.style.zIndex = "1";
}
}
protected _handleIconOverflowMenuClosed() {
const row = this.closest(".mdc-data-table__row") as HTMLDivElement | null;
if (row) {
row.style.zIndex = "";
}
}
static get styles() {
return css`
:host {
display: block;
flex-grow: 1;
margin-top: 4px;
height: 22px;
}
ha-chip-set {
position: fixed;
flex-wrap: nowrap;
}
ha-label {
--ha-label-background-color: var(--color);
--ha-label-background-opacity: 0.5;
}
ha-button-menu {
border-radius: 10px;
}
.plus {
border: 1px solid var(--divider-color);
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-data-table-labels": HaDataTableLabels;
}
interface HASSDomEvents {
"label-clicked": { label: LabelRegistryEntry };
}
}

View File

@ -32,6 +32,7 @@ import type { HaCheckbox } from "../ha-checkbox";
import "../ha-svg-icon"; import "../ha-svg-icon";
import "../search-input"; import "../search-input";
import { filterData, sortData } from "./sort-filter"; import { filterData, sortData } from "./sort-filter";
import { groupBy } from "../../common/util/group-by";
declare global { declare global {
// for fire event // for fire event
@ -67,13 +68,20 @@ export interface DataTableSortColumnData {
filterKey?: string; filterKey?: string;
valueColumn?: string; valueColumn?: string;
direction?: SortingDirection; direction?: SortingDirection;
groupable?: boolean;
} }
export interface DataTableColumnData<T = any> extends DataTableSortColumnData { export interface DataTableColumnData<T = any> extends DataTableSortColumnData {
main?: boolean; main?: boolean;
title: TemplateResult | string; title: TemplateResult | string;
label?: TemplateResult | string; label?: TemplateResult | string;
type?: "numeric" | "icon" | "icon-button" | "overflow-menu" | "flex"; type?:
| "numeric"
| "icon"
| "icon-button"
| "overflow"
| "overflow-menu"
| "flex";
template?: (row: T) => TemplateResult | string | typeof nothing; template?: (row: T) => TemplateResult | string | typeof nothing;
width?: string; width?: string;
maxWidth?: string; maxWidth?: string;
@ -95,6 +103,8 @@ export interface SortableColumnContainer {
[key: string]: ClonedDataTableColumnData; [key: string]: ClonedDataTableColumnData;
} }
const UNDEFINED_GROUP_KEY = "zzzzz_undefined";
@customElement("ha-data-table") @customElement("ha-data-table")
export class HaDataTable extends LitElement { export class HaDataTable extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@ -129,14 +139,16 @@ export class HaDataTable extends LitElement {
@property({ type: String }) public filter = ""; @property({ type: String }) public filter = "";
@property() public groupColumn?: string;
@property() public sortColumn?: string;
@property() public sortDirection: SortingDirection = null;
@state() private _filterable = false; @state() private _filterable = false;
@state() private _filter = ""; @state() private _filter = "";
@state() private _sortColumn?: string;
@state() private _sortDirection: SortingDirection = null;
@state() private _filteredData: DataTableRowData[] = []; @state() private _filteredData: DataTableRowData[] = [];
@state() private _headerHeight = 0; @state() private _headerHeight = 0;
@ -195,8 +207,14 @@ export class HaDataTable extends LitElement {
for (const columnId in this.columns) { for (const columnId in this.columns) {
if (this.columns[columnId].direction) { if (this.columns[columnId].direction) {
this._sortDirection = this.columns[columnId].direction!; this.sortDirection = this.columns[columnId].direction!;
this._sortColumn = columnId; this.sortColumn = columnId;
fireEvent(this, "sorting-changed", {
column: columnId,
direction: this.sortDirection,
});
break; break;
} }
} }
@ -226,11 +244,16 @@ export class HaDataTable extends LitElement {
properties.has("data") || properties.has("data") ||
properties.has("columns") || properties.has("columns") ||
properties.has("_filter") || properties.has("_filter") ||
properties.has("_sortColumn") || properties.has("sortColumn") ||
properties.has("_sortDirection") properties.has("sortDirection") ||
properties.has("groupColumn")
) { ) {
this._sortFilterData(); this._sortFilterData();
} }
if (properties.has("selectable")) {
this._items = [...this._items];
}
} }
protected render() { protected render() {
@ -263,6 +286,7 @@ export class HaDataTable extends LitElement {
})} })}
> >
<div class="mdc-data-table__header-row" role="row" aria-rowindex="1"> <div class="mdc-data-table__header-row" role="row" aria-rowindex="1">
<slot name="header-row">
${this.selectable ${this.selectable
? html` ? html`
<div <div
@ -285,7 +309,7 @@ export class HaDataTable extends LitElement {
if (column.hidden) { if (column.hidden) {
return ""; return "";
} }
const sorted = key === this._sortColumn; const sorted = key === this.sortColumn;
const classes = { const classes = {
"mdc-data-table__header-cell--numeric": "mdc-data-table__header-cell--numeric":
column.type === "numeric", column.type === "numeric",
@ -294,6 +318,8 @@ export class HaDataTable extends LitElement {
column.type === "icon-button", column.type === "icon-button",
"mdc-data-table__header-cell--overflow-menu": "mdc-data-table__header-cell--overflow-menu":
column.type === "overflow-menu", column.type === "overflow-menu",
"mdc-data-table__header-cell--overflow":
column.type === "overflow",
sortable: Boolean(column.sortable), sortable: Boolean(column.sortable),
"not-sorted": Boolean(column.sortable && !sorted), "not-sorted": Boolean(column.sortable && !sorted),
grows: Boolean(column.grows), grows: Boolean(column.grows),
@ -311,7 +337,7 @@ export class HaDataTable extends LitElement {
role="columnheader" role="columnheader"
aria-sort=${ifDefined( aria-sort=${ifDefined(
sorted sorted
? this._sortDirection === "desc" ? this.sortDirection === "desc"
? "descending" ? "descending"
: "ascending" : "ascending"
: undefined : undefined
@ -322,7 +348,7 @@ export class HaDataTable extends LitElement {
${column.sortable ${column.sortable
? html` ? html`
<ha-svg-icon <ha-svg-icon
.path=${sorted && this._sortDirection === "desc" .path=${sorted && this.sortDirection === "desc"
? mdiArrowDown ? mdiArrowDown
: mdiArrowUp} : mdiArrowUp}
></ha-svg-icon> ></ha-svg-icon>
@ -332,6 +358,7 @@ export class HaDataTable extends LitElement {
</div> </div>
`; `;
})} })}
</slot>
</div> </div>
${!this._filteredData.length ${!this._filteredData.length
? html` ? html`
@ -408,7 +435,7 @@ export class HaDataTable extends LitElement {
: ""} : ""}
${Object.entries(this.columns).map(([key, column]) => { ${Object.entries(this.columns).map(([key, column]) => {
if (column.hidden) { if (column.hidden) {
return ""; return nothing;
} }
return html` return html`
<div <div
@ -421,6 +448,7 @@ export class HaDataTable extends LitElement {
column.type === "icon-button", column.type === "icon-button",
"mdc-data-table__cell--overflow-menu": "mdc-data-table__cell--overflow-menu":
column.type === "overflow-menu", column.type === "overflow-menu",
"mdc-data-table__cell--overflow": column.type === "overflow",
grows: Boolean(column.grows), grows: Boolean(column.grows),
forceLTR: Boolean(column.forceLTR), forceLTR: Boolean(column.forceLTR),
})}" })}"
@ -453,12 +481,12 @@ export class HaDataTable extends LitElement {
); );
} }
const prom = this._sortColumn const prom = this.sortColumn
? sortData( ? sortData(
filteredData, filteredData,
this._sortColumns[this._sortColumn], this._sortColumns[this.sortColumn],
this._sortDirection, this.sortDirection,
this._sortColumn, this.sortColumn,
this.hass.locale.language this.hass.locale.language
) )
: filteredData; : filteredData;
@ -477,7 +505,7 @@ export class HaDataTable extends LitElement {
return; return;
} }
if (this.appendRow || this.hasFab) { if (this.appendRow || this.hasFab || this.groupColumn) {
const items = [...data]; const items = [...data];
if (this.appendRow) { if (this.appendRow) {
@ -487,7 +515,46 @@ export class HaDataTable extends LitElement {
if (this.hasFab) { if (this.hasFab) {
items.push({ empty: true }); items.push({ empty: true });
} }
if (this.groupColumn) {
const grouped = groupBy(items, (item) => item[this.groupColumn!]);
if (grouped.undefined) {
// make sure ungrouped items are at the bottom
grouped[UNDEFINED_GROUP_KEY] = grouped.undefined;
delete grouped.undefined;
}
const sorted: {
[key: string]: DataTableRowData[];
} = Object.keys(grouped)
.sort()
.reduce((obj, key) => {
obj[key] = grouped[key];
return obj;
}, {});
const groupedItems: DataTableRowData[] = [];
Object.entries(sorted).forEach(([groupName, rows]) => {
if (
groupName !== UNDEFINED_GROUP_KEY ||
Object.keys(sorted).length > 1
) {
groupedItems.push({
append: true,
content: html`<div
class="mdc-data-table__cell group-header"
role="cell"
>
${groupName === UNDEFINED_GROUP_KEY ? "" : groupName || ""}
</div>`,
});
}
groupedItems.push(...rows);
});
this._items = groupedItems;
} else {
this._items = items; this._items = items;
}
} else { } else {
this._items = data; this._items = data;
} }
@ -507,19 +574,19 @@ export class HaDataTable extends LitElement {
if (!this.columns[columnId].sortable) { if (!this.columns[columnId].sortable) {
return; return;
} }
if (!this._sortDirection || this._sortColumn !== columnId) { if (!this.sortDirection || this.sortColumn !== columnId) {
this._sortDirection = "asc"; this.sortDirection = "asc";
} else if (this._sortDirection === "asc") { } else if (this.sortDirection === "asc") {
this._sortDirection = "desc"; this.sortDirection = "desc";
} else { } else {
this._sortDirection = null; this.sortDirection = null;
} }
this._sortColumn = this._sortDirection === null ? undefined : columnId; this.sortColumn = this.sortDirection === null ? undefined : columnId;
fireEvent(this, "sorting-changed", { fireEvent(this, "sorting-changed", {
column: columnId, column: columnId,
direction: this._sortDirection, direction: this.sortDirection,
}); });
} }
@ -552,8 +619,15 @@ export class HaDataTable extends LitElement {
}; };
private _handleRowClick = (ev: Event) => { private _handleRowClick = (ev: Event) => {
const target = ev.target as HTMLElement; if (
if (["HA-CHECKBOX", "MWC-BUTTON"].includes(target.tagName)) { ev
.composedPath()
.find((el) =>
["ha-checkbox", "mwc-button", "ha-button", "ha-assist-chip"].includes(
(el as HTMLElement).localName
)
)
) {
return; return;
} }
const rowId = (ev.currentTarget as any).rowId; const rowId = (ev.currentTarget as any).rowId;
@ -629,7 +703,7 @@ export class HaDataTable extends LitElement {
.mdc-data-table__row { .mdc-data-table__row {
display: flex; display: flex;
width: 100%; width: 100%;
height: 52px; height: var(--data-table-row-height, 52px);
} }
.mdc-data-table__row ~ .mdc-data-table__row { .mdc-data-table__row ~ .mdc-data-table__row {
@ -655,7 +729,6 @@ export class HaDataTable extends LitElement {
display: flex; display: flex;
width: 100%; width: 100%;
border-bottom: 1px solid var(--divider-color); border-bottom: 1px solid var(--divider-color);
overflow-x: auto;
} }
.mdc-data-table__header-row::-webkit-scrollbar { .mdc-data-table__header-row::-webkit-scrollbar {
@ -809,7 +882,9 @@ export class HaDataTable extends LitElement {
padding-inline-start: initial; padding-inline-start: initial;
} }
.mdc-data-table__cell--overflow-menu, .mdc-data-table__cell--overflow-menu,
.mdc-data-table__header-cell--overflow-menu { .mdc-data-table__cell--overflow,
.mdc-data-table__header-cell--overflow-menu,
.mdc-data-table__header-cell--overflow {
overflow: initial; overflow: initial;
} }
.mdc-data-table__cell--icon-button a { .mdc-data-table__cell--icon-button a {
@ -839,6 +914,12 @@ export class HaDataTable extends LitElement {
/* custom from here */ /* custom from here */
.group-header {
padding-top: 12px;
width: 100%;
font-weight: 500;
}
:host { :host {
display: block; display: block;
} }

View File

@ -0,0 +1,490 @@
import { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket";
import { LitElement, PropertyValues, TemplateResult, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { computeDomain } from "../common/entity/compute_domain";
import {
ScorableTextItem,
fuzzyFilterSort,
} from "../common/string/filter/sequence-matching";
import { AreaRegistryEntry } from "../data/area_registry";
import {
DeviceEntityDisplayLookup,
DeviceRegistryEntry,
getDeviceEntityDisplayLookup,
} from "../data/device_registry";
import { EntityRegistryDisplayEntry } from "../data/entity_registry";
import {
FloorRegistryEntry,
getFloorAreaLookup,
subscribeFloorRegistry,
} from "../data/floor_registry";
import { SubscribeMixin } from "../mixins/subscribe-mixin";
import { HomeAssistant, ValueChangedEvent } from "../types";
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
import "./ha-combo-box";
import type { HaComboBox } from "./ha-combo-box";
import "./ha-icon-button";
import "./ha-list-item";
import "./ha-svg-icon";
import { stringCompare } from "../common/string/compare";
type ScorableAreaFloorEntry = ScorableTextItem & FloorAreaEntry;
interface FloorAreaEntry {
id: string | null;
name: string;
icon: string | null;
strings: string[];
type: "floor" | "area";
hasFloor?: boolean;
}
const rowRenderer: ComboBoxLitRenderer<FloorAreaEntry> = (item) =>
html`<ha-list-item
graphic="icon"
style=${item.type === "area" && item.hasFloor
? "--mdc-list-side-padding-left: 48px;"
: ""}
>
${item.icon
? html`<ha-icon slot="graphic" .icon=${item.icon}></ha-icon>`
: nothing}
${item.name}
</ha-list-item>`;
@customElement("ha-area-floor-picker")
export class HaAreaFloorPicker extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public label?: string;
@property() public value?: string;
@property() public helper?: string;
@property() public placeholder?: string;
/**
* Show only areas with entities from specific domains.
* @type {Array}
* @attr include-domains
*/
@property({ type: Array, attribute: "include-domains" })
public includeDomains?: string[];
/**
* Show no areas with entities of these domains.
* @type {Array}
* @attr exclude-domains
*/
@property({ type: Array, attribute: "exclude-domains" })
public excludeDomains?: string[];
/**
* Show only areas with entities of these device classes.
* @type {Array}
* @attr include-device-classes
*/
@property({ type: Array, attribute: "include-device-classes" })
public includeDeviceClasses?: string[];
/**
* List of areas to be excluded.
* @type {Array}
* @attr exclude-areas
*/
@property({ type: Array, attribute: "exclude-areas" })
public excludeAreas?: string[];
/**
* List of floors to be excluded.
* @type {Array}
* @attr exclude-floors
*/
@property({ type: Array, attribute: "exclude-floors" })
public excludeFloors?: string[];
@property({ attribute: false })
public deviceFilter?: HaDevicePickerDeviceFilterFunc;
@property({ attribute: false })
public entityFilter?: (entity: HassEntity) => boolean;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = false;
@state() private _floors?: FloorRegistryEntry[];
@state() private _opened?: boolean;
@query("ha-combo-box", true) public comboBox!: HaComboBox;
private _init = false;
protected hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] {
return [
subscribeFloorRegistry(this.hass.connection, (floors) => {
this._floors = floors;
}),
];
}
public async open() {
await this.updateComplete;
await this.comboBox?.open();
}
public async focus() {
await this.updateComplete;
await this.comboBox?.focus();
}
private _getAreas = memoizeOne(
(
floors: FloorRegistryEntry[],
areas: AreaRegistryEntry[],
devices: DeviceRegistryEntry[],
entities: EntityRegistryDisplayEntry[],
includeDomains: this["includeDomains"],
excludeDomains: this["excludeDomains"],
includeDeviceClasses: this["includeDeviceClasses"],
deviceFilter: this["deviceFilter"],
entityFilter: this["entityFilter"],
excludeAreas: this["excludeAreas"],
excludeFloors: this["excludeFloors"]
): FloorAreaEntry[] => {
if (!areas.length && !floors.length) {
return [
{
id: "no_areas",
type: "area",
name: this.hass.localize("ui.components.area-picker.no_areas"),
icon: null,
strings: [],
},
];
}
let deviceEntityLookup: DeviceEntityDisplayLookup = {};
let inputDevices: DeviceRegistryEntry[] | undefined;
let inputEntities: EntityRegistryDisplayEntry[] | undefined;
if (
includeDomains ||
excludeDomains ||
includeDeviceClasses ||
deviceFilter ||
entityFilter
) {
deviceEntityLookup = getDeviceEntityDisplayLookup(entities);
inputDevices = devices;
inputEntities = entities.filter((entity) => entity.area_id);
if (includeDomains) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) =>
includeDomains.includes(computeDomain(entity.entity_id))
);
});
inputEntities = inputEntities!.filter((entity) =>
includeDomains.includes(computeDomain(entity.entity_id))
);
}
if (excludeDomains) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return true;
}
return entities.every(
(entity) =>
!excludeDomains.includes(computeDomain(entity.entity_id))
);
});
inputEntities = inputEntities!.filter(
(entity) =>
!excludeDomains.includes(computeDomain(entity.entity_id))
);
}
if (includeDeviceClasses) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) => {
const stateObj = this.hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return (
stateObj.attributes.device_class &&
includeDeviceClasses.includes(stateObj.attributes.device_class)
);
});
});
inputEntities = inputEntities!.filter((entity) => {
const stateObj = this.hass.states[entity.entity_id];
return (
stateObj.attributes.device_class &&
includeDeviceClasses.includes(stateObj.attributes.device_class)
);
});
}
if (deviceFilter) {
inputDevices = inputDevices!.filter((device) =>
deviceFilter!(device)
);
}
if (entityFilter) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) => {
const stateObj = this.hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return entityFilter(stateObj);
});
});
inputEntities = inputEntities!.filter((entity) => {
const stateObj = this.hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return entityFilter!(stateObj);
});
}
}
let outputAreas = areas;
let areaIds: string[] | undefined;
if (inputDevices) {
areaIds = inputDevices
.filter((device) => device.area_id)
.map((device) => device.area_id!);
}
if (inputEntities) {
areaIds = (areaIds ?? []).concat(
inputEntities
.filter((entity) => entity.area_id)
.map((entity) => entity.area_id!)
);
}
if (areaIds) {
outputAreas = outputAreas.filter((area) =>
areaIds!.includes(area.area_id)
);
}
if (excludeAreas) {
outputAreas = outputAreas.filter(
(area) => !excludeAreas!.includes(area.area_id)
);
}
if (excludeFloors) {
outputAreas = outputAreas.filter(
(area) => !area.floor_id || !excludeFloors!.includes(area.floor_id)
);
}
if (!outputAreas.length) {
return [
{
id: "no_areas",
type: "area",
name: this.hass.localize("ui.components.area-picker.no_match"),
icon: null,
strings: [],
},
];
}
const floorAreaLookup = getFloorAreaLookup(outputAreas);
const unassisgnedAreas = Object.values(outputAreas).filter(
(area) => !area.floor_id || !floorAreaLookup[area.floor_id]
);
// @ts-ignore
const floorAreaEntries: Array<
[FloorRegistryEntry | undefined, AreaRegistryEntry[]]
> = Object.entries(floorAreaLookup)
.map(([floorId, floorAreas]) => {
const floor = floors.find((fl) => fl.floor_id === floorId)!;
return [floor, floorAreas] as const;
})
.sort(([floorA], [floorB]) => {
if (floorA.level !== floorB.level) {
return (floorA.level ?? 0) - (floorB.level ?? 0);
}
return stringCompare(floorA.name, floorB.name);
});
const output: FloorAreaEntry[] = [];
floorAreaEntries.forEach(([floor, floorAreas]) => {
if (floor) {
output.push({
id: floor.floor_id,
type: "floor",
name: floor.name,
icon: floor.icon,
strings: [floor.floor_id, ...floor.aliases, floor.name],
});
}
output.push(
...floorAreas.map((area) => ({
id: area.area_id,
type: "area" as const,
name: area.name,
icon: area.icon,
strings: [area.area_id, ...area.aliases, area.name],
hasFloor: true,
}))
);
});
if (!output.length && !unassisgnedAreas.length) {
output.push({
id: "no_areas",
type: "area",
name: this.hass.localize(
"ui.components.area-picker.unassigned_areas"
),
icon: null,
strings: [],
});
}
output.push(
...unassisgnedAreas.map((area) => ({
id: area.area_id,
type: "area" as const,
name: area.name,
icon: area.icon,
strings: [area.area_id, ...area.aliases, area.name],
}))
);
return output;
}
);
protected updated(changedProps: PropertyValues) {
if (
(!this._init && this.hass && this._floors) ||
(this._init && changedProps.has("_opened") && this._opened)
) {
this._init = true;
const areas = this._getAreas(
this._floors!,
Object.values(this.hass.areas),
Object.values(this.hass.devices),
Object.values(this.hass.entities),
this.includeDomains,
this.excludeDomains,
this.includeDeviceClasses,
this.deviceFilter,
this.entityFilter,
this.excludeAreas,
this.excludeFloors
);
this.comboBox.items = areas;
this.comboBox.filteredItems = areas;
}
}
protected render(): TemplateResult {
return html`
<ha-combo-box
.hass=${this.hass}
.helper=${this.helper}
item-value-path="id"
item-id-path="id"
item-label-path="name"
.value=${this._value}
.disabled=${this.disabled}
.required=${this.required}
.label=${this.label === undefined && this.hass
? this.hass.localize("ui.components.area-picker.area")
: this.label}
.placeholder=${this.placeholder
? this.hass.areas[this.placeholder]?.name
: undefined}
.renderer=${rowRenderer}
@filter-changed=${this._filterChanged}
@opened-changed=${this._openedChanged}
@value-changed=${this._areaChanged}
>
</ha-combo-box>
`;
}
private _filterChanged(ev: CustomEvent): void {
const target = ev.target as HaComboBox;
const filterString = ev.detail.value;
if (!filterString) {
this.comboBox.filteredItems = this.comboBox.items;
return;
}
const filteredItems = fuzzyFilterSort<ScorableAreaFloorEntry>(
filterString,
target.items || []
);
this.comboBox.filteredItems = filteredItems;
}
private get _value() {
return this.value || "";
}
private _openedChanged(ev: ValueChangedEvent<boolean>) {
this._opened = ev.detail.value;
}
private async _areaChanged(ev: ValueChangedEvent<string>) {
ev.stopPropagation();
const newValue = ev.detail.value;
if (newValue === "no_areas") {
return;
}
const selected = this.comboBox.selectedItem;
fireEvent(this, "value-changed", {
value: {
id: selected.id,
type: selected.type,
},
});
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-area-floor-picker": HaAreaFloorPicker;
}
}

View File

@ -137,10 +137,12 @@ export class HaAreaPicker extends LitElement {
return [ return [
{ {
area_id: "no_areas", area_id: "no_areas",
floor_id: null,
name: this.hass.localize("ui.components.area-picker.no_areas"), name: this.hass.localize("ui.components.area-picker.no_areas"),
picture: null, picture: null,
icon: null, icon: null,
aliases: [], aliases: [],
labels: [],
}, },
]; ];
} }
@ -282,10 +284,12 @@ export class HaAreaPicker extends LitElement {
outputAreas = [ outputAreas = [
{ {
area_id: "no_areas", area_id: "no_areas",
floor_id: null,
name: this.hass.localize("ui.components.area-picker.no_match"), name: this.hass.localize("ui.components.area-picker.no_match"),
picture: null, picture: null,
icon: null, icon: null,
aliases: [], aliases: [],
labels: [],
}, },
]; ];
} }
@ -296,10 +300,12 @@ export class HaAreaPicker extends LitElement {
...outputAreas, ...outputAreas,
{ {
area_id: "add_new", area_id: "add_new",
floor_id: null,
name: this.hass.localize("ui.components.area-picker.add_new"), name: this.hass.localize("ui.components.area-picker.add_new"),
picture: null, picture: null,
icon: "mdi:plus", icon: "mdi:plus",
aliases: [], aliases: [],
labels: [],
}, },
]; ];
} }

View File

@ -1,221 +0,0 @@
import type { Corner } from "@material/mwc-menu";
import "@material/mwc-menu/mwc-menu-surface";
import { mdiFilterVariant } from "@mdi/js";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { stopPropagation } from "../common/dom/stop_propagation";
import { computeStateName } from "../common/entity/compute_state_name";
import { computeDeviceName } from "../data/device_registry";
import { findRelated, RelatedResult } from "../data/search";
import type { HomeAssistant } from "../types";
import "./device/ha-device-picker";
import "./entity/ha-entity-picker";
import "./ha-area-picker";
import "./ha-icon-button";
declare global {
// for fire event
interface HASSDomEvents {
"related-changed": {
value?: FilterValue;
items?: RelatedResult;
filter?: string;
};
}
}
interface FilterValue {
area?: string;
device?: string;
entity?: string;
}
@customElement("ha-button-related-filter-menu")
export class HaRelatedFilterButtonMenu extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public corner: Corner = "BOTTOM_START";
@property({ type: Boolean, reflect: true }) public narrow = false;
@property({ type: Boolean }) public disabled = false;
@property({ attribute: false }) public value?: FilterValue;
/**
* Show no entities of these domains.
* @type {Array}
* @attr exclude-domains
*/
@property({ type: Array, attribute: "exclude-domains" })
public excludeDomains?: string[];
@state() private _open = false;
protected render(): TemplateResult {
return html`
<ha-icon-button
@click=${this._handleClick}
.label=${this.hass.localize("ui.components.related-filter-menu.filter")}
.path=${mdiFilterVariant}
></ha-icon-button>
<mwc-menu-surface
.open=${this._open}
.anchor=${this}
.fullwidth=${this.narrow}
.corner=${this.corner}
@closed=${this._onClosed}
@input=${stopPropagation}
>
<ha-area-picker
.label=${this.hass.localize(
"ui.components.related-filter-menu.filter_by_area"
)}
.hass=${this.hass}
.value=${this.value?.area}
no-add
@value-changed=${this._areaPicked}
@click=${this._preventDefault}
></ha-area-picker>
<ha-device-picker
.label=${this.hass.localize(
"ui.components.related-filter-menu.filter_by_device"
)}
.hass=${this.hass}
.value=${this.value?.device}
@value-changed=${this._devicePicked}
@click=${this._preventDefault}
></ha-device-picker>
<ha-entity-picker
.label=${this.hass.localize(
"ui.components.related-filter-menu.filter_by_entity"
)}
.hass=${this.hass}
.value=${this.value?.entity}
.excludeDomains=${this.excludeDomains}
@value-changed=${this._entityPicked}
@click=${this._preventDefault}
></ha-entity-picker>
</mwc-menu-surface>
`;
}
private _handleClick(): void {
if (this.disabled) {
return;
}
this._open = true;
}
private _onClosed(ev): void {
ev.stopPropagation();
this._open = false;
}
private _preventDefault(ev) {
ev.preventDefault();
}
private async _entityPicked(ev: CustomEvent) {
ev.stopPropagation();
const entityId = ev.detail.value;
if (!entityId) {
fireEvent(this, "related-changed", { value: undefined });
return;
}
const filter = this.hass.localize(
"ui.components.related-filter-menu.filtered_by_entity",
{
entity_name: computeStateName(
(ev.currentTarget as any).comboBox.selectedItem
),
}
);
const items = await findRelated(this.hass, "entity", entityId);
fireEvent(this, "related-changed", {
value: { entity: entityId },
filter,
items,
});
}
private async _devicePicked(ev: CustomEvent) {
ev.stopPropagation();
const deviceId = ev.detail.value;
if (!deviceId) {
fireEvent(this, "related-changed", { value: undefined });
return;
}
const filter = this.hass.localize(
"ui.components.related-filter-menu.filtered_by_device",
{
device_name: computeDeviceName(
(ev.currentTarget as any).comboBox.selectedItem,
this.hass
),
}
);
const items = await findRelated(this.hass, "device", deviceId);
fireEvent(this, "related-changed", {
value: { device: deviceId },
filter,
items,
});
}
private async _areaPicked(ev: CustomEvent) {
ev.stopPropagation();
const areaId = ev.detail.value;
if (!areaId) {
fireEvent(this, "related-changed", { value: undefined });
return;
}
const filter = this.hass.localize(
"ui.components.related-filter-menu.filtered_by_area",
{ area_name: (ev.currentTarget as any).comboBox.selectedItem.name }
);
const items = await findRelated(this.hass, "area", areaId);
fireEvent(this, "related-changed", {
value: { area: areaId },
filter,
items,
});
}
static get styles(): CSSResultGroup {
return css`
:host {
display: inline-block;
position: relative;
--mdc-menu-min-width: 250px;
}
ha-area-picker,
ha-device-picker,
ha-entity-picker {
display: block;
width: 300px;
padding: 4px 16px;
box-sizing: border-box;
}
ha-area-picker {
padding-top: 16px;
}
ha-entity-picker {
padding-bottom: 16px;
}
:host([narrow]) ha-area-picker,
:host([narrow]) ha-device-picker,
:host([narrow]) ha-entity-picker {
width: 100%;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-button-related-filter-menu": HaRelatedFilterButtonMenu;
}
}

View File

@ -2,17 +2,15 @@ import "@material/mwc-list/mwc-list-item";
import { css, html, LitElement, nothing } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property } from "lit/decorators";
import { styleMap } from "lit/directives/style-map"; import { styleMap } from "lit/directives/style-map";
import { import { computeCssColor, THEME_COLORS } from "../common/color/compute-color";
computeCssColor, import { fireEvent } from "../common/dom/fire_event";
THEME_COLORS, import { stopPropagation } from "../common/dom/stop_propagation";
} from "../../../common/color/compute-color"; import "./ha-select";
import { fireEvent } from "../../../common/dom/fire_event"; import { HomeAssistant } from "../types";
import { stopPropagation } from "../../../common/dom/stop_propagation"; import { LocalizeKeys } from "../common/translations/localize";
import "../../../components/ha-select";
import { HomeAssistant } from "../../../types";
@customElement("hui-color-picker") @customElement("ha-color-picker")
export class HuiColorPicker extends LitElement { export class HaColorPicker extends LitElement {
@property() public label?: string; @property() public label?: string;
@property() public helper?: string; @property() public helper?: string;
@ -21,6 +19,8 @@ export class HuiColorPicker extends LitElement {
@property() public value?: string; @property() public value?: string;
@property({ type: Boolean }) public defaultColor = false;
@property({ type: Boolean }) public disabled = false; @property({ type: Boolean }) public disabled = false;
_valueSelected(ev) { _valueSelected(ev) {
@ -52,16 +52,16 @@ export class HuiColorPicker extends LitElement {
</span> </span>
` `
: nothing} : nothing}
<mwc-list-item value="default"> ${this.defaultColor
${this.hass.localize( ? html` <mwc-list-item value="default">
`ui.panel.lovelace.editor.color-picker.default_color` ${this.hass.localize(`ui.components.color-picker.default_color`)}
)} </mwc-list-item>`
</mwc-list-item> : nothing}
${Array.from(THEME_COLORS).map( ${Array.from(THEME_COLORS).map(
(color) => html` (color) => html`
<mwc-list-item .value=${color} graphic="icon"> <mwc-list-item .value=${color} graphic="icon">
${this.hass.localize( ${this.hass.localize(
`ui.panel.lovelace.editor.color-picker.colors.${color}` `ui.components.color-picker.colors.${color}` as LocalizeKeys
) || color} ) || color}
<span slot="graphic">${this.renderColorCircle(color)}</span> <span slot="graphic">${this.renderColorCircle(color)}</span>
</mwc-list-item> </mwc-list-item>
@ -100,6 +100,6 @@ export class HuiColorPicker extends LitElement {
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
"hui-color-picker": HuiColorPicker; "ha-color-picker": HaColorPicker;
} }
} }

View File

@ -84,6 +84,7 @@ export class HaControlButton extends LitElement {
--control-button-background-color: var(--disabled-color); --control-button-background-color: var(--disabled-color);
--control-button-background-opacity: 0.2; --control-button-background-opacity: 0.2;
--control-button-border-radius: 10px; --control-button-border-radius: 10px;
--control-button-padding: 8px;
--mdc-icon-size: 20px; --mdc-icon-size: 20px;
color: var(--primary-text-color); color: var(--primary-text-color);
width: 40px; width: 40px;
@ -95,16 +96,20 @@ export class HaControlButton extends LitElement {
position: relative; position: relative;
cursor: pointer; cursor: pointer;
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
text-align: center;
width: 100%; width: 100%;
height: 100%; height: 100%;
border-radius: var(--control-button-border-radius); border-radius: var(--control-button-border-radius);
border: none; border: none;
margin: 0; margin: 0;
padding: 0; padding: var(--control-button-padding);
box-sizing: border-box; box-sizing: border-box;
line-height: 0; line-height: inherit;
font-family: Roboto;
font-weight: 500;
outline: none; outline: none;
overflow: hidden; overflow: hidden;
background: none; background: none;
@ -126,6 +131,8 @@ export class HaControlButton extends LitElement {
background-color 180ms ease-in-out, background-color 180ms ease-in-out,
opacity 180ms ease-in-out; opacity 180ms ease-in-out;
opacity: var(--control-button-background-opacity); opacity: var(--control-button-background-opacity);
pointer-events: none;
white-space: normal;
} }
.button { .button {
transition: color 180ms ease-in-out; transition: color 180ms ease-in-out;
@ -133,6 +140,7 @@ export class HaControlButton extends LitElement {
} }
.button ::slotted(*) { .button ::slotted(*) {
pointer-events: none; pointer-events: none;
opacity: 0.95;
} }
.button:disabled { .button:disabled {
cursor: not-allowed; cursor: not-allowed;

View File

@ -529,7 +529,7 @@ export class HaControlSlider extends LitElement {
0, 0,
0 0
); );
border-radius: 0 var(--border-radius) var(--border-radius) 0; border-radius: 0 8px 8px 0;
} }
.slider .slider-track-bar:after { .slider .slider-track-bar:after {
top: 0; top: 0;
@ -546,7 +546,7 @@ export class HaControlSlider extends LitElement {
0, 0,
0 0
); );
border-radius: var(--border-radius) 0 0 var(--border-radius); border-radius: 8px 0 0 8px;
} }
.slider .slider-track-bar.end::after { .slider .slider-track-bar.end::after {
right: initial; right: initial;
@ -561,7 +561,7 @@ export class HaControlSlider extends LitElement {
calc((1 - var(--value, 0)) * var(--slider-size)), calc((1 - var(--value, 0)) * var(--slider-size)),
0 0
); );
border-radius: var(--border-radius) var(--border-radius) 0 0; border-radius: 8px 8px 0 0;
} }
:host([vertical]) .slider .slider-track-bar:after { :host([vertical]) .slider .slider-track-bar:after {
top: var(--handle-margin); top: var(--handle-margin);
@ -579,7 +579,7 @@ export class HaControlSlider extends LitElement {
calc((0 - var(--value, 0)) * var(--slider-size)), calc((0 - var(--value, 0)) * var(--slider-size)),
0 0
); );
border-radius: 0 0 var(--border-radius) var(--border-radius); border-radius: 0 0 8px 8px;
} }
:host([vertical]) .slider .slider-track-bar.end::after { :host([vertical]) .slider .slider-track-bar.end::after {
top: initial; top: initial;

View File

@ -139,12 +139,12 @@ export class HaDialog extends DialogBase {
} }
.header_button { .header_button {
position: absolute; position: absolute;
right: -8px; right: -12px;
top: -8px; top: -12px;
text-decoration: none; text-decoration: none;
color: inherit; color: inherit;
inset-inline-start: initial; inset-inline-start: initial;
inset-inline-end: -8px; inset-inline-end: -12px;
direction: var(--direction); direction: var(--direction);
} }
.dialog-actions { .dialog-actions {

View File

@ -83,13 +83,11 @@ export class HaExpansionPanel extends LitElement {
protected willUpdate(changedProps: PropertyValues) { protected willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps); super.willUpdate(changedProps);
if (changedProps.has("expanded") && this.expanded) { if (changedProps.has("expanded")) {
this._showContent = this.expanded; this._showContent = this.expanded;
setTimeout(() => { setTimeout(() => {
// Verify we're still expanded // Verify we're still expanded
if (this.expanded) { this._container.style.overflow = this.expanded ? "initial" : "hidden";
this._container.style.overflow = "initial";
}
}, 300); }, 300);
} }
} }

View File

@ -0,0 +1,175 @@
import { SelectedDetail } from "@material/mwc-list";
import "@material/mwc-menu/mwc-menu-surface";
import { css, CSSResultGroup, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { findRelated, RelatedResult } from "../data/search";
import type { HomeAssistant } from "../types";
import { haStyleScrollbar } from "../resources/styles";
import { Blueprints, fetchBlueprints } from "../data/blueprint";
@customElement("ha-filter-blueprints")
export class HaFilterBlueprints extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public value?: string[];
@property() public type?: "automation" | "script";
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, reflect: true }) public expanded = false;
@state() private _shouldRender = false;
@state() private _blueprints?: Blueprints;
protected render() {
return html`
<ha-expansion-panel
leftChevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this.hass.localize("ui.panel.config.blueprint.caption")}
${this.value?.length
? html`<div class="badge">${this.value?.length}</div>`
: nothing}
</div>
${this._blueprints && this._shouldRender
? html`
<mwc-list
@selected=${this._blueprintsSelected}
multi
class="ha-scrollbar"
>
${Object.entries(this._blueprints).map(([id, blueprint]) =>
"error" in blueprint
? nothing
: html`<ha-check-list-item
.value=${id}
.selected=${this.value?.includes(id)}
>
${blueprint.metadata.name || id}
</ha-check-list-item>`
)}
</mwc-list>
`
: nothing}
</ha-expansion-panel>
`;
}
protected async firstUpdated() {
if (!this.type) {
return;
}
this._blueprints = await fetchBlueprints(this.hass, this.type);
}
protected updated(changed) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (this.narrow || !this.expanded) return;
this.renderRoot.querySelector("mwc-list")!.style.height =
`${this.clientHeight - 49}px`;
}, 300);
}
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
private async _blueprintsSelected(
ev: CustomEvent<SelectedDetail<Set<number>>>
) {
const blueprints = this._blueprints!;
const relatedPromises: Promise<RelatedResult>[] = [];
if (!ev.detail.index.size) {
fireEvent(this, "data-table-filter-changed", {
value: [],
items: undefined,
});
this.value = [];
return;
}
const value: string[] = [];
for (const index of ev.detail.index) {
const blueprintId = Object.keys(blueprints)[index];
value.push(blueprintId);
if (this.type) {
relatedPromises.push(
findRelated(this.hass, `${this.type}_blueprint`, blueprintId)
);
}
}
this.value = value;
const results = await Promise.all(relatedPromises);
const items: Set<string> = new Set();
for (const result of results) {
if (result[this.type!]) {
result[this.type!]!.forEach((item) => items.add(item));
}
}
fireEvent(this, "data-table-filter-changed", {
value,
items: this.type ? items : undefined,
});
}
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: 0;
--expansion-panel-content-padding: 0;
}
.header {
display: flex;
align-items: center;
}
.badge {
display: inline-block;
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: 0;
min-width: 16px;
box-sizing: border-box;
border-radius: 50%;
font-weight: 400;
font-size: 11px;
background-color: var(--accent-color);
line-height: 16px;
text-align: center;
padding: 0px 2px;
color: var(--text-accent-color, var(--text-primary-color));
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-filter-blueprints": HaFilterBlueprints;
}
}

View File

@ -0,0 +1,284 @@
import { ActionDetail, SelectedDetail } from "@material/mwc-list";
import { mdiDelete, mdiDotsVertical, mdiPencil, mdiPlus } from "@mdi/js";
import { UnsubscribeFunc } from "home-assistant-js-websocket";
import { CSSResultGroup, LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import {
CategoryRegistryEntry,
deleteCategoryRegistryEntry,
subscribeCategoryRegistry,
} from "../data/category_registry";
import { showConfirmationDialog } from "../dialogs/generic/show-dialog-box";
import { SubscribeMixin } from "../mixins/subscribe-mixin";
import { showCategoryRegistryDetailDialog } from "../panels/config/category/show-dialog-category-registry-detail";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
import "./ha-expansion-panel";
import "./ha-icon";
import "./ha-list-item";
@customElement("ha-filter-categories")
export class HaFilterCategories extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public value?: string[];
@property() public scope?: string;
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, reflect: true }) public expanded = false;
@state() private _categories: CategoryRegistryEntry[] = [];
@state() private _shouldRender = false;
protected hassSubscribeRequiredHostProps = ["scope"];
protected hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] {
return [
subscribeCategoryRegistry(
this.hass.connection,
this.scope!,
(categories) => {
this._categories = categories;
}
),
];
}
protected render() {
return html`
<ha-expansion-panel
leftChevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this.hass.localize("ui.panel.config.category.caption")}
${this.value?.length
? html`<div class="badge">${this.value?.length}</div>`
: nothing}
</div>
${this._shouldRender
? html`
<mwc-list
@selected=${this._categorySelected}
class="ha-scrollbar"
activatable
>
<ha-list-item
.selected=${!this.value?.length}
.activated=${!this.value?.length}
>${this.hass.localize(
"ui.panel.config.category.filter.show_all"
)}</ha-list-item
>
${this._categories.map(
(category) =>
html`<ha-list-item
.value=${category.category_id}
.selected=${this.value?.includes(category.category_id)}
.activated=${this.value?.includes(category.category_id)}
graphic="icon"
hasMeta
>
${category.icon
? html`<ha-icon
slot="graphic"
.icon=${category.icon}
></ha-icon>`
: nothing}
${category.name}
<ha-button-menu
@action=${this._handleAction}
slot="meta"
fixed
.categoryId=${category.category_id}
>
<ha-icon-button
.path=${mdiDotsVertical}
slot="trigger"
></ha-icon-button>
<mwc-list-item graphic="icon"
><ha-svg-icon
.path=${mdiPencil}
slot="graphic"
></ha-svg-icon
>${this.hass.localize(
"ui.panel.config.category.editor.edit"
)}</mwc-list-item
>
<mwc-list-item graphic="icon" class="warning"
><ha-svg-icon
class="warning"
.path=${mdiDelete}
slot="graphic"
></ha-svg-icon
>${this.hass.localize(
"ui.panel.config.category.editor.delete"
)}</mwc-list-item
>
</ha-button-menu>
</ha-list-item>`
)}
</mwc-list>
`
: nothing}
</ha-expansion-panel>
${this.expanded
? html`<ha-list-item graphic="icon" @click=${this._addCategory}>
<ha-svg-icon slot="graphic" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize("ui.panel.config.category.editor.add")}
</ha-list-item>`
: nothing}
`;
}
protected updated(changed) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this.renderRoot.querySelector("mwc-list")!.style.height =
`${this.clientHeight - (49 + 48)}px`;
}, 300);
}
}
private _handleAction(ev: CustomEvent<ActionDetail>) {
const categoryId = (ev.currentTarget as any).categoryId;
switch (ev.detail.index) {
case 0:
this._editCategory(categoryId);
break;
case 1:
this._deleteCategory(categoryId);
break;
}
}
private _editCategory(id: string) {
showCategoryRegistryDetailDialog(this, {
scope: this.scope!,
entry: this._categories.find((cat) => cat.category_id === id),
});
}
private async _deleteCategory(id: string) {
const confirm = await showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.category.editor.confirm_delete"
),
text: this.hass.localize(
"ui.panel.config.category.editor.confirm_delete_text"
),
confirmText: this.hass.localize("ui.common.delete"),
destructive: true,
});
if (!confirm) {
return;
}
try {
await deleteCategoryRegistryEntry(this.hass, this.scope!, id);
fireEvent(this, "data-table-filter-changed", {
value: [],
items: undefined,
});
} catch (err: any) {
alert(`Failed to delete: ${err.message}`);
}
}
private _addCategory() {
if (!this.scope) {
return;
}
showCategoryRegistryDetailDialog(this, { scope: this.scope });
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
private async _categorySelected(ev: CustomEvent<SelectedDetail<number>>) {
if (!ev.detail.index) {
fireEvent(this, "data-table-filter-changed", {
value: [],
items: undefined,
});
this.value = [];
return;
}
const index = ev.detail.index - 1;
const val = this._categories![index]?.category_id;
if (!val) {
return;
}
this.value = [val];
fireEvent(this, "data-table-filter-changed", {
value: this.value,
items: undefined,
});
}
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: 0;
--expansion-panel-content-padding: 0;
}
.header {
display: flex;
align-items: center;
}
.badge {
display: inline-block;
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: 0;
min-width: 16px;
box-sizing: border-box;
border-radius: 50%;
font-weight: 400;
font-size: 11px;
background-color: var(--accent-color);
line-height: 16px;
text-align: center;
padding: 0px 2px;
color: var(--text-accent-color, var(--text-primary-color));
}
mwc-list {
--mdc-list-item-meta-size: auto;
--mdc-list-side-padding-right: 4px;
--mdc-icon-button-size: 36px;
}
.warning {
color: var(--error-color);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-filter-categories": HaFilterCategories;
}
}

View File

@ -0,0 +1,206 @@
import {
css,
CSSResultGroup,
html,
LitElement,
nothing,
PropertyValues,
} from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { stringCompare } from "../common/string/compare";
import { computeDeviceName } from "../data/device_registry";
import { findRelated, RelatedResult } from "../data/search";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
import "./ha-expansion-panel";
import "./ha-check-list-item";
import { loadVirtualizer } from "../resources/virtualizer";
@customElement("ha-filter-devices")
export class HaFilterDevices extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public value?: string[];
@property() public type?: keyof RelatedResult;
@property({ type: Boolean, reflect: true }) public expanded = false;
@property({ type: Boolean }) public narrow = false;
@state() private _shouldRender = false;
public willUpdate(properties: PropertyValues) {
super.willUpdate(properties);
if (!this.hasUpdated) {
loadVirtualizer();
}
}
protected render() {
return html`
<ha-expansion-panel
leftChevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this.hass.localize("ui.panel.config.devices.caption")}
${this.value?.length
? html`<div class="badge">${this.value?.length}</div>`
: nothing}
</div>
${this._shouldRender
? html`<mwc-list class="ha-scrollbar">
<lit-virtualizer
.items=${this._devices(this.hass.devices)}
.renderItem=${this._renderItem}
@click=${this._handleItemClick}
>
</lit-virtualizer>
</mwc-list>`
: nothing}
</ha-expansion-panel>
`;
}
private _renderItem = (device) =>
html`<ha-check-list-item
.value=${device.id}
.selected=${this.value?.includes(device.id)}
>
${computeDeviceName(device, this.hass)}
</ha-check-list-item>`;
private _handleItemClick(ev) {
const listItem = ev.target.closest("ha-check-list-item");
const value = listItem?.value;
if (!value) {
return;
}
if (this.value?.includes(value)) {
this.value = this.value?.filter((val) => val !== value);
} else {
this.value = [...(this.value || []), value];
}
listItem.selected = this.value?.includes(value);
this._findRelated();
}
protected updated(changed) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this.renderRoot.querySelector("mwc-list")!.style.height =
`${this.clientHeight - 49}px`;
}, 300);
}
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
private _devices = memoizeOne((devices: HomeAssistant["devices"]) => {
const values = Object.values(devices);
return values.sort((a, b) =>
stringCompare(
a.name_by_user || a.name || "",
b.name_by_user || b.name || "",
this.hass.locale.language
)
);
});
private async _findRelated() {
const relatedPromises: Promise<RelatedResult>[] = [];
if (!this.value?.length) {
fireEvent(this, "data-table-filter-changed", {
value: [],
items: undefined,
});
this.value = [];
return;
}
const value: string[] = [];
for (const deviceId of this.value) {
value.push(deviceId);
if (this.type) {
relatedPromises.push(findRelated(this.hass, "device", deviceId));
}
}
this.value = value;
const results = await Promise.all(relatedPromises);
const items: Set<string> = new Set();
for (const result of results) {
if (result[this.type!]) {
result[this.type!]!.forEach((item) => items.add(item));
}
}
fireEvent(this, "data-table-filter-changed", {
value,
items: this.type ? items : undefined,
});
}
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: 0;
--expansion-panel-content-padding: 0;
}
.header {
display: flex;
align-items: center;
}
.badge {
display: inline-block;
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: 0;
min-width: 16px;
box-sizing: border-box;
border-radius: 50%;
font-weight: 400;
font-size: 11px;
background-color: var(--accent-color);
line-height: 16px;
text-align: center;
padding: 0px 2px;
color: var(--text-accent-color, var(--text-primary-color));
}
ha-check-list-item {
width: 100%;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-filter-devices": HaFilterDevices;
}
}

View File

@ -0,0 +1,220 @@
import {
css,
CSSResultGroup,
html,
LitElement,
nothing,
PropertyValues,
} from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { computeStateDomain } from "../common/entity/compute_state_domain";
import { computeStateName } from "../common/entity/compute_state_name";
import { stringCompare } from "../common/string/compare";
import { findRelated, RelatedResult } from "../data/search";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
import "./ha-state-icon";
import "./ha-check-list-item";
import { loadVirtualizer } from "../resources/virtualizer";
@customElement("ha-filter-entities")
export class HaFilterEntities extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public value?: string[];
@property() public type?: keyof RelatedResult;
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, reflect: true }) public expanded = false;
@state() private _shouldRender = false;
public willUpdate(properties: PropertyValues) {
super.willUpdate(properties);
if (!this.hasUpdated) {
loadVirtualizer();
}
}
protected render() {
return html`
<ha-expansion-panel
leftChevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this.hass.localize("ui.panel.config.entities.caption")}
${this.value?.length
? html`<div class="badge">${this.value?.length}</div>`
: nothing}
</div>
${this._shouldRender
? html`
<mwc-list class="ha-scrollbar">
<lit-virtualizer
.items=${this._entities(this.hass.states, this.type)}
.renderItem=${this._renderItem}
@click=${this._handleItemClick}
>
</lit-virtualizer>
</mwc-list>
`
: nothing}
</ha-expansion-panel>
`;
}
protected updated(changed) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this.renderRoot.querySelector("mwc-list")!.style.height =
`${this.clientHeight - 49}px`;
}, 300);
}
}
private _renderItem = (entity) =>
html`<ha-check-list-item
.value=${entity.entity_id}
.selected=${this.value?.includes(entity.entity_id)}
graphic="icon"
>
<ha-state-icon
slot="graphic"
.hass=${this.hass}
.stateObj=${entity}
></ha-state-icon>
${computeStateName(entity)}
</ha-check-list-item>`;
private _handleItemClick(ev) {
const listItem = ev.target.closest("ha-check-list-item");
const value = listItem?.value;
if (!value) {
return;
}
if (this.value?.includes(value)) {
this.value = this.value?.filter((val) => val !== value);
} else {
this.value = [...(this.value || []), value];
}
listItem.selected = this.value?.includes(value);
this._findRelated();
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
private _entities = memoizeOne(
(states: HomeAssistant["states"], type: this["type"]) => {
const values = Object.values(states);
return values
.filter(
(entityState) => !type || computeStateDomain(entityState) !== type
)
.sort((a, b) =>
stringCompare(
computeStateName(a),
computeStateName(b),
this.hass.locale.language
)
);
}
);
private async _findRelated() {
const relatedPromises: Promise<RelatedResult>[] = [];
if (!this.value?.length) {
fireEvent(this, "data-table-filter-changed", {
value: [],
items: undefined,
});
this.value = [];
return;
}
const value: string[] = [];
for (const entityId of this.value) {
value.push(entityId);
if (this.type) {
relatedPromises.push(findRelated(this.hass, "entity", entityId));
}
}
this.value = value;
const results = await Promise.all(relatedPromises);
const items: Set<string> = new Set();
for (const result of results) {
if (result[this.type!]) {
result[this.type!]!.forEach((item) => items.add(item));
}
}
fireEvent(this, "data-table-filter-changed", {
value,
items: this.type ? items : undefined,
});
}
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: 0;
--expansion-panel-content-padding: 0;
}
.header {
display: flex;
align-items: center;
}
.badge {
display: inline-block;
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: 0;
min-width: 16px;
box-sizing: border-box;
border-radius: 50%;
font-weight: 400;
font-size: 11px;
background-color: var(--accent-color);
line-height: 16px;
text-align: center;
padding: 0px 2px;
color: var(--text-accent-color, var(--text-primary-color));
}
ha-check-list-item {
width: 100%;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-filter-entities": HaFilterEntities;
}
}

View File

@ -0,0 +1,287 @@
import "@material/mwc-menu/mwc-menu-surface";
import { UnsubscribeFunc } from "home-assistant-js-websocket";
import { css, CSSResultGroup, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import {
FloorRegistryEntry,
getFloorAreaLookup,
subscribeFloorRegistry,
} from "../data/floor_registry";
import { findRelated, RelatedResult } from "../data/search";
import { SubscribeMixin } from "../mixins/subscribe-mixin";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
import "./ha-check-list-item";
@customElement("ha-filter-floor-areas")
export class HaFilterFloorAreas extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public value?: {
floors?: string[];
areas?: string[];
};
@property() public type?: keyof RelatedResult;
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, reflect: true }) public expanded = false;
@state() private _shouldRender = false;
@state() private _floors?: FloorRegistryEntry[];
protected render() {
const areas = this._areas(this.hass.areas, this._floors);
return html`
<ha-expansion-panel
leftChevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this.hass.localize("ui.panel.config.areas.caption")}
${this.value?.areas?.length || this.value?.floors?.length
? html`<div class="badge">
${(this.value?.areas?.length || 0) +
(this.value?.floors?.length || 0)}
</div>`
: nothing}
</div>
${this._shouldRender
? html`
<mwc-list class="ha-scrollbar">
${repeat(
areas?.floors || [],
(floor) => floor.floor_id,
(floor) => html`
<ha-check-list-item
.value=${floor.floor_id}
.type=${"floors"}
.selected=${this.value?.floors?.includes(
floor.floor_id
) || false}
graphic="icon"
@request-selected=${this._handleItemClick}
>
${floor.icon
? html`<ha-icon
slot="graphic"
.icon=${floor.icon}
></ha-icon>`
: nothing}
${floor.name}
</ha-check-list-item>
${repeat(
floor.areas,
(area) => area.area_id,
(area) => this._renderArea(area)
)}
`
)}
${repeat(
areas?.unassisgnedAreas,
(area) => area.area_id,
(area) => this._renderArea(area)
)}
</mwc-list>
`
: nothing}
</ha-expansion-panel>
`;
}
private _renderArea(area) {
return html`<ha-check-list-item
.value=${area.area_id}
.selected=${this.value?.areas?.includes(area.area_id) || false}
.type=${"areas"}
graphic="icon"
class=${area.floor_id ? "floor" : ""}
@request-selected=${this._handleItemClick}
>
${area.icon
? html`<ha-icon slot="graphic" .icon=${area.icon}></ha-icon>`
: nothing}
${area.name}
</ha-check-list-item>`;
}
private _handleItemClick(ev) {
ev.stopPropagation();
const listItem = ev.currentTarget;
const type = listItem?.type;
const value = listItem?.value;
if (ev.detail.selected === listItem.selected || !value) {
return;
}
if (this.value?.[type]?.includes(value)) {
this.value = {
...this.value,
[type]: this.value[type].filter((val) => val !== value),
};
} else {
if (!this.value) {
this.value = {};
}
this.value = {
...this.value,
[type]: [...(this.value[type] || []), value],
};
}
listItem.selected = this.value[type]?.includes(value);
this._findRelated();
}
protected hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] {
return [
subscribeFloorRegistry(this.hass.connection, (floors) => {
this._floors = floors;
}),
];
}
protected updated(changed) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this.renderRoot.querySelector("mwc-list")!.style.height =
`${this.clientHeight - 49}px`;
}, 300);
}
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
private _areas = memoizeOne(
(areaReg: HomeAssistant["areas"], floors?: FloorRegistryEntry[]) => {
const areas = Object.values(areaReg);
const floorAreaLookup = getFloorAreaLookup(areas);
const unassisgnedAreas = areas.filter(
(area) => !area.floor_id || !floorAreaLookup[area.floor_id]
);
return {
floors: floors?.map((floor) => ({
...floor,
areas: floorAreaLookup[floor.floor_id] || [],
})),
unassisgnedAreas: unassisgnedAreas,
};
}
);
private async _findRelated() {
const relatedPromises: Promise<RelatedResult>[] = [];
if (
!this.value ||
(!this.value.areas?.length && !this.value.floors?.length)
) {
fireEvent(this, "data-table-filter-changed", {
value: {},
items: undefined,
});
return;
}
if (this.value.areas) {
for (const areaId of this.value.areas) {
if (this.type) {
relatedPromises.push(findRelated(this.hass, "area", areaId));
}
}
}
if (this.value.floors) {
for (const floorId of this.value.floors) {
if (this.type) {
relatedPromises.push(findRelated(this.hass, "floor", floorId));
}
}
}
const results = await Promise.all(relatedPromises);
const items: Set<string> = new Set();
for (const result of results) {
if (result[this.type!]) {
result[this.type!]!.forEach((item) => items.add(item));
}
}
fireEvent(this, "data-table-filter-changed", {
value: this.value,
items: this.type ? items : undefined,
});
}
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: 0;
--expansion-panel-content-padding: 0;
}
.header {
display: flex;
align-items: center;
}
.badge {
display: inline-block;
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: 0;
min-width: 16px;
box-sizing: border-box;
border-radius: 50%;
font-weight: 400;
font-size: 11px;
background-color: var(--accent-color);
line-height: 16px;
text-align: center;
padding: 0px 2px;
color: var(--text-accent-color, var(--text-primary-color));
}
.floor {
padding-left: 32px;
padding-inline-start: 32px;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-filter-floor-areas": HaFilterFloorAreas;
}
interface HASSDomEvents {
"data-table-filter-changed": { value: any; items: Set<string> | undefined };
}
}

View File

@ -0,0 +1,183 @@
import { SelectedDetail } from "@material/mwc-list";
import { css, CSSResultGroup, 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 { stringCompare } from "../common/string/compare";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
import {
fetchIntegrationManifests,
IntegrationManifest,
} from "../data/integration";
import "./ha-domain-icon";
@customElement("ha-filter-integrations")
export class HaFilterIntegrations extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public value?: string[];
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, reflect: true }) public expanded = false;
@state() private _manifests?: IntegrationManifest[];
@state() private _shouldRender = false;
protected render() {
return html`
<ha-expansion-panel
leftChevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this.hass.localize("ui.panel.config.integrations.caption")}
${this.value?.length
? html`<div class="badge">${this.value?.length}</div>`
: nothing}
</div>
${this._manifests && this._shouldRender
? html`
<mwc-list
@selected=${this._integrationsSelected}
multi
class="ha-scrollbar"
>
${this._integrations(this._manifests).map(
(integration) =>
html`<ha-check-list-item
.value=${integration.domain}
.selected=${this.value?.includes(integration.domain)}
graphic="icon"
>
<ha-domain-icon
slot="graphic"
.hass=${this.hass}
.domain=${integration.domain}
brandFallback
></ha-domain-icon>
${integration.name || integration.domain}
</ha-check-list-item>`
)}
</mwc-list>
`
: nothing}
</ha-expansion-panel>
`;
}
protected updated(changed) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this.renderRoot.querySelector("mwc-list")!.style.height =
`${this.clientHeight - 49}px`;
}, 300);
}
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
protected async firstUpdated() {
this._manifests = await fetchIntegrationManifests(this.hass);
}
private _integrations = memoizeOne((manifest: IntegrationManifest[]) =>
manifest
.filter(
(mnfst) =>
!mnfst.integration_type ||
!["entity", "system", "hardware"].includes(mnfst.integration_type)
)
.sort((a, b) =>
stringCompare(
a.name || a.domain,
b.name || b.domain,
this.hass.locale.language
)
)
);
private async _integrationsSelected(
ev: CustomEvent<SelectedDetail<Set<number>>>
) {
const integrations = this._integrations(this._manifests!);
if (!ev.detail.index.size) {
fireEvent(this, "data-table-filter-changed", {
value: [],
items: undefined,
});
this.value = [];
return;
}
const value: string[] = [];
for (const index of ev.detail.index) {
const domain = integrations[index].domain;
value.push(domain);
}
this.value = value;
fireEvent(this, "data-table-filter-changed", {
value,
items: undefined,
});
}
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: 0;
--expansion-panel-content-padding: 0;
}
.header {
display: flex;
align-items: center;
}
.badge {
display: inline-block;
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: 0;
min-width: 16px;
box-sizing: border-box;
border-radius: 50%;
font-weight: 400;
font-size: 11px;
background-color: var(--accent-color);
line-height: 16px;
text-align: center;
padding: 0px 2px;
color: var(--text-accent-color, var(--text-primary-color));
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-filter-integrations": HaFilterIntegrations;
}
}

View File

@ -0,0 +1,183 @@
import { SelectedDetail } from "@material/mwc-list";
import "@material/mwc-menu/mwc-menu-surface";
import { UnsubscribeFunc } from "home-assistant-js-websocket";
import { CSSResultGroup, LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { computeCssColor } from "../common/color/compute-color";
import { fireEvent } from "../common/dom/fire_event";
import {
LabelRegistryEntry,
subscribeLabelRegistry,
} from "../data/label_registry";
import { SubscribeMixin } from "../mixins/subscribe-mixin";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
import "./ha-check-list-item";
import "./ha-expansion-panel";
import "./ha-icon";
import "./ha-label";
@customElement("ha-filter-labels")
export class HaFilterLabels extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public value?: string[];
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, reflect: true }) public expanded = false;
@state() private _labels: LabelRegistryEntry[] = [];
@state() private _shouldRender = false;
protected hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] {
return [
subscribeLabelRegistry(this.hass.connection, (labels) => {
this._labels = labels;
}),
];
}
protected render() {
return html`
<ha-expansion-panel
leftChevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this.hass.localize("ui.panel.config.labels.caption")}
${this.value?.length
? html`<div class="badge">${this.value?.length}</div>`
: nothing}
</div>
${this._shouldRender
? html`
<mwc-list
@selected=${this._labelSelected}
class="ha-scrollbar"
multi
>
${this._labels.map((label) => {
const color = label.color
? computeCssColor(label.color)
: undefined;
return html`<ha-check-list-item
.value=${label.label_id}
.selected=${this.value?.includes(label.label_id)}
hasMeta
>
<ha-label style=${color ? `--color: ${color}` : ""}>
${label.icon
? html`<ha-icon
slot="icon"
.icon=${label.icon}
></ha-icon>`
: nothing}
${label.name}
</ha-label>
</ha-check-list-item>`;
})}
</mwc-list>
`
: nothing}
</ha-expansion-panel>
`;
}
protected updated(changed) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this.renderRoot.querySelector("mwc-list")!.style.height =
`${this.clientHeight - 49}px`;
}, 300);
}
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
private async _labelSelected(ev: CustomEvent<SelectedDetail<Set<number>>>) {
if (!ev.detail.index.size) {
fireEvent(this, "data-table-filter-changed", {
value: [],
items: undefined,
});
this.value = [];
return;
}
const value: string[] = [];
for (const index of ev.detail.index) {
const labelId = this._labels[index].label_id;
value.push(labelId);
}
this.value = value;
fireEvent(this, "data-table-filter-changed", {
value,
items: undefined,
});
}
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: 0;
--expansion-panel-content-padding: 0;
}
.header {
display: flex;
align-items: center;
}
.badge {
display: inline-block;
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: 0;
min-width: 16px;
box-sizing: border-box;
border-radius: 50%;
font-weight: 400;
font-size: 11px;
background-color: var(--accent-color);
line-height: 16px;
text-align: center;
padding: 0px 2px;
color: var(--text-accent-color, var(--text-primary-color));
}
.warning {
color: var(--error-color);
}
ha-label {
--ha-label-background-color: var(--color);
--ha-label-background-opacity: 0.5;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-filter-labels": HaFilterLabels;
}
}

View File

@ -0,0 +1,165 @@
import { SelectedDetail } from "@material/mwc-list";
import { css, CSSResultGroup, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
import "./ha-expansion-panel";
import "./ha-check-list-item";
import "./ha-icon";
@customElement("ha-filter-states")
export class HaFilterStates extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public label?: string;
@property({ attribute: false }) public value?: string[];
@property({ attribute: false }) public states?: {
value: any;
label?: string;
icon?: string;
}[];
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, reflect: true }) public expanded = false;
@state() private _shouldRender = false;
protected render() {
if (!this.states) {
return nothing;
}
const hasIcon = this.states.find((item) => item.icon);
return html`
<ha-expansion-panel
leftChevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this.label}
${this.value?.length
? html`<div class="badge">${this.value?.length}</div>`
: nothing}
</div>
${this._shouldRender
? html`
<mwc-list
@selected=${this._statesSelected}
multi
class="ha-scrollbar"
>
${this.states.map(
(item) =>
html`<ha-check-list-item
.value=${item.value}
.selected=${this.value?.includes(item.value)}
.graphic=${hasIcon ? "icon" : undefined}
>
${item.icon
? html`<ha-icon
slot="graphic"
.icon=${item.icon}
></ha-icon>`
: nothing}
${item.label}
</ha-check-list-item>`
)}
</mwc-list>
`
: nothing}
</ha-expansion-panel>
`;
}
protected updated(changed) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this.renderRoot.querySelector("mwc-list")!.style.height =
`${this.clientHeight - 49}px`;
}, 300);
}
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
private async _statesSelected(ev: CustomEvent<SelectedDetail<Set<number>>>) {
if (!ev.detail.index.size) {
fireEvent(this, "data-table-filter-changed", {
value: [],
items: undefined,
});
this.value = [];
return;
}
const value: string[] = [];
for (const index of ev.detail.index) {
const val = this.states![index].value;
value.push(val);
}
this.value = value;
fireEvent(this, "data-table-filter-changed", {
value,
items: undefined,
});
}
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: 0;
--expansion-panel-content-padding: 0;
}
.header {
display: flex;
align-items: center;
}
.badge {
display: inline-block;
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: 0;
min-width: 16px;
box-sizing: border-box;
border-radius: 50%;
font-weight: 400;
font-size: 11px;
background-color: var(--accent-color);
line-height: 16px;
text-align: center;
padding: 0px 2px;
color: var(--text-accent-color, var(--text-primary-color));
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-filter-states": HaFilterStates;
}
}

View File

@ -0,0 +1,499 @@
import { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket";
import { html, LitElement, nothing, PropertyValues, TemplateResult } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { computeDomain } from "../common/entity/compute_domain";
import {
fuzzyFilterSort,
ScorableTextItem,
} from "../common/string/filter/sequence-matching";
import { AreaRegistryEntry } from "../data/area_registry";
import {
DeviceEntityDisplayLookup,
DeviceRegistryEntry,
getDeviceEntityDisplayLookup,
} from "../data/device_registry";
import { EntityRegistryDisplayEntry } from "../data/entity_registry";
import {
showAlertDialog,
showPromptDialog,
} from "../dialogs/generic/show-dialog-box";
import { HomeAssistant, ValueChangedEvent } from "../types";
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
import "./ha-combo-box";
import type { HaComboBox } from "./ha-combo-box";
import "./ha-icon-button";
import "./ha-list-item";
import "./ha-svg-icon";
import { SubscribeMixin } from "../mixins/subscribe-mixin";
import {
createFloorRegistryEntry,
FloorRegistryEntry,
getFloorAreaLookup,
subscribeFloorRegistry,
} from "../data/floor_registry";
type ScorableFloorRegistryEntry = ScorableTextItem & FloorRegistryEntry;
const rowRenderer: ComboBoxLitRenderer<FloorRegistryEntry> = (item) =>
html`<ha-list-item
graphic="icon"
class=${classMap({ "add-new": item.floor_id === "add_new" })}
>
${item.icon
? html`<ha-icon slot="graphic" .icon=${item.icon}></ha-icon>`
: nothing}
${item.name}
</ha-list-item>`;
@customElement("ha-floor-picker")
export class HaFloorPicker extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public label?: string;
@property() public value?: string;
@property() public helper?: string;
@property() public placeholder?: string;
@property({ type: Boolean, attribute: "no-add" })
public noAdd = false;
/**
* Show only floors with entities from specific domains.
* @type {Array}
* @attr include-domains
*/
@property({ type: Array, attribute: "include-domains" })
public includeDomains?: string[];
/**
* Show no floors with entities of these domains.
* @type {Array}
* @attr exclude-domains
*/
@property({ type: Array, attribute: "exclude-domains" })
public excludeDomains?: string[];
/**
* Show only floors with entities of these device classes.
* @type {Array}
* @attr include-device-classes
*/
@property({ type: Array, attribute: "include-device-classes" })
public includeDeviceClasses?: string[];
/**
* List of floors to be excluded.
* @type {Array}
* @attr exclude-floors
*/
@property({ type: Array, attribute: "exclude-floor" })
public excludeFloors?: string[];
@property({ attribute: false })
public deviceFilter?: HaDevicePickerDeviceFilterFunc;
@property({ attribute: false })
public entityFilter?: (entity: HassEntity) => boolean;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = false;
@state() private _opened?: boolean;
@state() private _floors?: FloorRegistryEntry[];
@query("ha-combo-box", true) public comboBox!: HaComboBox;
private _suggestion?: string;
private _init = false;
public async open() {
await this.updateComplete;
await this.comboBox?.open();
}
public async focus() {
await this.updateComplete;
await this.comboBox?.focus();
}
protected hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] {
return [
subscribeFloorRegistry(this.hass.connection, (floors) => {
this._floors = floors;
}),
];
}
private _getFloors = memoizeOne(
(
floors: FloorRegistryEntry[],
areas: AreaRegistryEntry[],
devices: DeviceRegistryEntry[],
entities: EntityRegistryDisplayEntry[],
includeDomains: this["includeDomains"],
excludeDomains: this["excludeDomains"],
includeDeviceClasses: this["includeDeviceClasses"],
deviceFilter: this["deviceFilter"],
entityFilter: this["entityFilter"],
noAdd: this["noAdd"],
excludeFloors: this["excludeFloors"]
): FloorRegistryEntry[] => {
if (!floors.length) {
return [
{
floor_id: "no_floors",
name: this.hass.localize("ui.components.floor-picker.no_floors"),
icon: null,
level: 0,
aliases: [],
},
];
}
let deviceEntityLookup: DeviceEntityDisplayLookup = {};
let inputDevices: DeviceRegistryEntry[] | undefined;
let inputEntities: EntityRegistryDisplayEntry[] | undefined;
if (
includeDomains ||
excludeDomains ||
includeDeviceClasses ||
deviceFilter ||
entityFilter
) {
deviceEntityLookup = getDeviceEntityDisplayLookup(entities);
inputDevices = devices;
inputEntities = entities.filter((entity) => entity.area_id);
if (includeDomains) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) =>
includeDomains.includes(computeDomain(entity.entity_id))
);
});
inputEntities = inputEntities!.filter((entity) =>
includeDomains.includes(computeDomain(entity.entity_id))
);
}
if (excludeDomains) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return true;
}
return entities.every(
(entity) =>
!excludeDomains.includes(computeDomain(entity.entity_id))
);
});
inputEntities = inputEntities!.filter(
(entity) =>
!excludeDomains.includes(computeDomain(entity.entity_id))
);
}
if (includeDeviceClasses) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) => {
const stateObj = this.hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return (
stateObj.attributes.device_class &&
includeDeviceClasses.includes(stateObj.attributes.device_class)
);
});
});
inputEntities = inputEntities!.filter((entity) => {
const stateObj = this.hass.states[entity.entity_id];
return (
stateObj.attributes.device_class &&
includeDeviceClasses.includes(stateObj.attributes.device_class)
);
});
}
if (deviceFilter) {
inputDevices = inputDevices!.filter((device) =>
deviceFilter!(device)
);
}
if (entityFilter) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) => {
const stateObj = this.hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return entityFilter(stateObj);
});
});
inputEntities = inputEntities!.filter((entity) => {
const stateObj = this.hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return entityFilter!(stateObj);
});
}
}
let outputFloors = floors;
let areaIds: string[] | undefined;
if (inputDevices) {
areaIds = inputDevices
.filter((device) => device.area_id)
.map((device) => device.area_id!);
}
if (inputEntities) {
areaIds = (areaIds ?? []).concat(
inputEntities
.filter((entity) => entity.area_id)
.map((entity) => entity.area_id!)
);
}
if (areaIds) {
const floorAreaLookup = getFloorAreaLookup(areas);
outputFloors = outputFloors.filter((floor) =>
floorAreaLookup[floor.floor_id].some((area) =>
areaIds!.includes(area.area_id)
)
);
}
if (excludeFloors) {
outputFloors = outputFloors.filter(
(floor) => !excludeFloors!.includes(floor.floor_id)
);
}
if (!outputFloors.length) {
outputFloors = [
{
floor_id: "no_floors",
name: this.hass.localize("ui.components.floor-picker.no_match"),
icon: null,
level: 0,
aliases: [],
},
];
}
return noAdd
? outputFloors
: [
...outputFloors,
{
floor_id: "add_new",
name: this.hass.localize("ui.components.floor-picker.add_new"),
icon: "mdi:plus",
level: 0,
aliases: [],
},
];
}
);
protected updated(changedProps: PropertyValues) {
if (
(!this._init && this.hass && this._floors) ||
(this._init && changedProps.has("_opened") && this._opened)
) {
this._init = true;
const floors = this._getFloors(
this._floors!,
Object.values(this.hass.areas),
Object.values(this.hass.devices),
Object.values(this.hass.entities),
this.includeDomains,
this.excludeDomains,
this.includeDeviceClasses,
this.deviceFilter,
this.entityFilter,
this.noAdd,
this.excludeFloors
).map((floor) => ({
...floor,
strings: [floor.floor_id, floor.name], // ...floor.aliases
}));
this.comboBox.items = floors;
this.comboBox.filteredItems = floors;
}
}
protected render(): TemplateResult {
return html`
<ha-combo-box
.hass=${this.hass}
.helper=${this.helper}
item-value-path="floor_id"
item-id-path="floor_id"
item-label-path="name"
.value=${this._value}
.disabled=${this.disabled}
.required=${this.required}
.label=${this.label === undefined && this.hass
? this.hass.localize("ui.components.floor-picker.floor")
: this.label}
.placeholder=${this.placeholder
? this._floors?.find((floor) => floor.floor_id === this.placeholder)
?.name
: undefined}
.renderer=${rowRenderer}
@filter-changed=${this._filterChanged}
@opened-changed=${this._openedChanged}
@value-changed=${this._floorChanged}
>
</ha-combo-box>
`;
}
private _filterChanged(ev: CustomEvent): void {
const target = ev.target as HaComboBox;
const filterString = ev.detail.value;
if (!filterString) {
this.comboBox.filteredItems = this.comboBox.items;
return;
}
const filteredItems = fuzzyFilterSort<ScorableFloorRegistryEntry>(
filterString,
target.items || []
);
if (!this.noAdd && filteredItems?.length === 0) {
this._suggestion = filterString;
this.comboBox.filteredItems = [
{
floor_id: "add_new_suggestion",
name: this.hass.localize(
"ui.components.floor-picker.add_new_sugestion",
{ name: this._suggestion }
),
picture: null,
},
];
} else {
this.comboBox.filteredItems = filteredItems;
}
}
private get _value() {
return this.value || "";
}
private _openedChanged(ev: ValueChangedEvent<boolean>) {
this._opened = ev.detail.value;
}
private _floorChanged(ev: ValueChangedEvent<string>) {
ev.stopPropagation();
let newValue = ev.detail.value;
if (newValue === "no_floors") {
newValue = "";
}
if (!["add_new_suggestion", "add_new"].includes(newValue)) {
if (newValue !== this._value) {
this._setValue(newValue);
}
return;
}
(ev.target as any).value = this._value;
showPromptDialog(this, {
title: this.hass.localize("ui.components.floor-picker.add_dialog.title"),
text: this.hass.localize("ui.components.floor-picker.add_dialog.text"),
confirmText: this.hass.localize(
"ui.components.floor-picker.add_dialog.add"
),
inputLabel: this.hass.localize(
"ui.components.floor-picker.add_dialog.name"
),
defaultValue:
newValue === "add_new_suggestion" ? this._suggestion : undefined,
confirm: async (name) => {
if (!name) {
return;
}
try {
const floor = await createFloorRegistryEntry(this.hass, {
name,
});
const floors = [...this._floors!, floor];
this.comboBox.filteredItems = this._getFloors(
floors,
Object.values(this.hass.areas)!,
Object.values(this.hass.devices)!,
Object.values(this.hass.entities)!,
this.includeDomains,
this.excludeDomains,
this.includeDeviceClasses,
this.deviceFilter,
this.entityFilter,
this.noAdd,
this.excludeFloors
);
await this.updateComplete;
await this.comboBox.updateComplete;
this._setValue(floor.floor_id);
} catch (err: any) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.components.floor-picker.add_dialog.failed_create_floor"
),
text: err.message,
});
}
},
cancel: () => {
this._setValue(undefined);
this._suggestion = undefined;
this.comboBox.setInputValue("");
},
});
}
private _setValue(value?: string) {
this.value = value;
setTimeout(() => {
fireEvent(this, "value-changed", { value });
fireEvent(this, "change");
}, 0);
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-floor-picker": HaFloorPicker;
}
}

View File

@ -0,0 +1,484 @@
import { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket";
import { LitElement, PropertyValues, TemplateResult, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { computeDomain } from "../common/entity/compute_domain";
import {
ScorableTextItem,
fuzzyFilterSort,
} from "../common/string/filter/sequence-matching";
import {
DeviceEntityDisplayLookup,
DeviceRegistryEntry,
getDeviceEntityDisplayLookup,
} from "../data/device_registry";
import { EntityRegistryDisplayEntry } from "../data/entity_registry";
import {
LabelRegistryEntry,
createLabelRegistryEntry,
subscribeLabelRegistry,
} from "../data/label_registry";
import { SubscribeMixin } from "../mixins/subscribe-mixin";
import { showLabelDetailDialog } from "../panels/config/labels/show-dialog-label-detail";
import { HomeAssistant, ValueChangedEvent } from "../types";
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
import "./ha-combo-box";
import type { HaComboBox } from "./ha-combo-box";
import "./ha-icon-button";
import "./ha-list-item";
import "./ha-svg-icon";
type ScorableLabelRegistryEntry = ScorableTextItem & LabelRegistryEntry;
const rowRenderer: ComboBoxLitRenderer<LabelRegistryEntry> = (item) =>
html`<ha-list-item
graphic="icon"
class=${classMap({ "add-new": item.label_id === "add_new" })}
>
${item.icon
? html`<ha-icon slot="graphic" .icon=${item.icon}></ha-icon>`
: nothing}
${item.name}
</ha-list-item>`;
@customElement("ha-label-picker")
export class HaLabelPicker extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public label?: string;
@property() public value?: string;
@property() public helper?: string;
@property() public placeholder?: string;
@property({ type: Boolean, attribute: "no-add" })
public noAdd = false;
/**
* Show only labels with entities from specific domains.
* @type {Array}
* @attr include-domains
*/
@property({ type: Array, attribute: "include-domains" })
public includeDomains?: string[];
/**
* Show no labels with entities of these domains.
* @type {Array}
* @attr exclude-domains
*/
@property({ type: Array, attribute: "exclude-domains" })
public excludeDomains?: string[];
/**
* Show only labels with entities of these device classes.
* @type {Array}
* @attr include-device-classes
*/
@property({ type: Array, attribute: "include-device-classes" })
public includeDeviceClasses?: string[];
/**
* List of labels to be excluded.
* @type {Array}
* @attr exclude-labels
*/
@property({ type: Array, attribute: "exclude-label" })
public excludeLabels?: string[];
@property({ attribute: false })
public deviceFilter?: HaDevicePickerDeviceFilterFunc;
@property({ attribute: false })
public entityFilter?: (entity: HassEntity) => boolean;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = false;
@state() private _opened?: boolean;
@state() private _labels?: LabelRegistryEntry[];
@query("ha-combo-box", true) public comboBox!: HaComboBox;
private _suggestion?: string;
private _init = false;
public async open() {
await this.updateComplete;
await this.comboBox?.open();
}
public async focus() {
await this.updateComplete;
await this.comboBox?.focus();
}
protected hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] {
return [
subscribeLabelRegistry(this.hass.connection, (labels) => {
this._labels = labels;
}),
];
}
private _getLabels = memoizeOne(
(
labels: LabelRegistryEntry[],
areas: HomeAssistant["areas"],
devices: DeviceRegistryEntry[],
entities: EntityRegistryDisplayEntry[],
includeDomains: this["includeDomains"],
excludeDomains: this["excludeDomains"],
includeDeviceClasses: this["includeDeviceClasses"],
deviceFilter: this["deviceFilter"],
entityFilter: this["entityFilter"],
noAdd: this["noAdd"],
excludeLabels: this["excludeLabels"]
): LabelRegistryEntry[] => {
if (!labels.length) {
return [
{
label_id: "no_labels",
name: this.hass.localize("ui.components.label-picker.no_labels"),
icon: null,
color: null,
},
];
}
let deviceEntityLookup: DeviceEntityDisplayLookup = {};
let inputDevices: DeviceRegistryEntry[] | undefined;
let inputEntities: EntityRegistryDisplayEntry[] | undefined;
if (
includeDomains ||
excludeDomains ||
includeDeviceClasses ||
deviceFilter ||
entityFilter
) {
deviceEntityLookup = getDeviceEntityDisplayLookup(entities);
inputDevices = devices;
inputEntities = entities.filter((entity) => entity.labels.length > 0);
if (includeDomains) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) =>
includeDomains.includes(computeDomain(entity.entity_id))
);
});
inputEntities = inputEntities!.filter((entity) =>
includeDomains.includes(computeDomain(entity.entity_id))
);
}
if (excludeDomains) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return true;
}
return entities.every(
(entity) =>
!excludeDomains.includes(computeDomain(entity.entity_id))
);
});
inputEntities = inputEntities!.filter(
(entity) =>
!excludeDomains.includes(computeDomain(entity.entity_id))
);
}
if (includeDeviceClasses) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) => {
const stateObj = this.hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return (
stateObj.attributes.device_class &&
includeDeviceClasses.includes(stateObj.attributes.device_class)
);
});
});
inputEntities = inputEntities!.filter((entity) => {
const stateObj = this.hass.states[entity.entity_id];
return (
stateObj.attributes.device_class &&
includeDeviceClasses.includes(stateObj.attributes.device_class)
);
});
}
if (deviceFilter) {
inputDevices = inputDevices!.filter((device) =>
deviceFilter!(device)
);
}
if (entityFilter) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) => {
const stateObj = this.hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return entityFilter(stateObj);
});
});
inputEntities = inputEntities!.filter((entity) => {
const stateObj = this.hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return entityFilter!(stateObj);
});
}
}
let outputLabels = labels;
const usedLabels = new Set<string>();
let areaIds: string[] | undefined;
if (inputDevices) {
areaIds = inputDevices
.filter((device) => device.area_id)
.map((device) => device.area_id!);
inputDevices.forEach((device) => {
device.labels.forEach((label) => usedLabels.add(label));
});
}
if (inputEntities) {
areaIds = (areaIds ?? []).concat(
inputEntities
.filter((entity) => entity.area_id)
.map((entity) => entity.area_id!)
);
inputEntities.forEach((entity) => {
entity.labels.forEach((label) => usedLabels.add(label));
});
}
if (areaIds) {
areaIds.forEach((areaId) => {
const area = areas[areaId];
area.labels.forEach((label) => usedLabels.add(label));
});
}
if (excludeLabels) {
outputLabels = outputLabels.filter(
(label) => !excludeLabels!.includes(label.label_id)
);
}
if (inputDevices || inputEntities) {
outputLabels = outputLabels.filter((label) =>
usedLabels.has(label.label_id)
);
}
if (!outputLabels.length) {
outputLabels = [
{
label_id: "no_labels",
name: this.hass.localize("ui.components.label-picker.no_match"),
icon: null,
color: null,
},
];
}
return noAdd
? outputLabels
: [
...outputLabels,
{
label_id: "add_new",
name: this.hass.localize("ui.components.label-picker.add_new"),
icon: "mdi:plus",
color: null,
},
];
}
);
protected updated(changedProps: PropertyValues) {
if (
(!this._init && this.hass && this._labels) ||
(this._init && changedProps.has("_opened") && this._opened)
) {
this._init = true;
const labels = this._getLabels(
this._labels!,
this.hass.areas,
Object.values(this.hass.devices),
Object.values(this.hass.entities),
this.includeDomains,
this.excludeDomains,
this.includeDeviceClasses,
this.deviceFilter,
this.entityFilter,
this.noAdd,
this.excludeLabels
).map((label) => ({
...label,
strings: [label.label_id, label.name],
}));
this.comboBox.items = labels;
this.comboBox.filteredItems = labels;
}
}
protected render(): TemplateResult {
return html`
<ha-combo-box
.hass=${this.hass}
.helper=${this.helper}
item-value-path="label_id"
item-id-path="label_id"
item-label-path="name"
.value=${this._value}
.disabled=${this.disabled}
.required=${this.required}
.label=${this.label === undefined && this.hass
? this.hass.localize("ui.components.label-picker.label")
: this.label}
.placeholder=${this.placeholder
? this._labels?.find((label) => label.label_id === this.placeholder)
?.name
: undefined}
.renderer=${rowRenderer}
@filter-changed=${this._filterChanged}
@opened-changed=${this._openedChanged}
@value-changed=${this._labelChanged}
>
</ha-combo-box>
`;
}
private _filterChanged(ev: CustomEvent): void {
const target = ev.target as HaComboBox;
const filterString = ev.detail.value;
if (!filterString) {
this.comboBox.filteredItems = this.comboBox.items;
return;
}
const filteredItems = fuzzyFilterSort<ScorableLabelRegistryEntry>(
filterString,
target.items || []
);
if (!this.noAdd && filteredItems?.length === 0) {
this._suggestion = filterString;
this.comboBox.filteredItems = [
{
label_id: "add_new_suggestion",
name: this.hass.localize(
"ui.components.label-picker.add_new_sugestion",
{ name: this._suggestion }
),
picture: null,
},
];
} else {
this.comboBox.filteredItems = filteredItems;
}
}
private get _value() {
return this.value || "";
}
private _openedChanged(ev: ValueChangedEvent<boolean>) {
this._opened = ev.detail.value;
}
private _labelChanged(ev: ValueChangedEvent<string>) {
ev.stopPropagation();
let newValue = ev.detail.value;
if (newValue === "no_labels") {
newValue = "";
this.comboBox.setInputValue("");
return;
}
if (!["add_new_suggestion", "add_new"].includes(newValue)) {
if (newValue !== this._value) {
this._setValue(newValue);
}
return;
}
(ev.target as any).value = this._value;
showLabelDetailDialog(this, {
entry: undefined,
suggestedName: newValue === "add_new_suggestion" ? this._suggestion : "",
createEntry: async (values) => {
const label = await createLabelRegistryEntry(this.hass, values);
const labels = [...this._labels!, label];
this.comboBox.filteredItems = this._getLabels(
labels,
this.hass.areas!,
Object.values(this.hass.devices)!,
Object.values(this.hass.entities)!,
this.includeDomains,
this.excludeDomains,
this.includeDeviceClasses,
this.deviceFilter,
this.entityFilter,
this.noAdd,
this.excludeLabels
);
await this.updateComplete;
await this.comboBox.updateComplete;
this._setValue(label.label_id);
return label;
},
});
this._suggestion = undefined;
this.comboBox.setInputValue("");
}
private _setValue(value?: string) {
this.value = value;
setTimeout(() => {
fireEvent(this, "value-changed", { value });
fireEvent(this, "change");
}, 0);
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-label-picker": HaLabelPicker;
}
}

View File

@ -1,13 +1,17 @@
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement } from "lit/decorators"; import { customElement, property } from "lit/decorators";
import "@material/web/ripple/ripple";
@customElement("ha-label") @customElement("ha-label")
class HaLabel extends LitElement { class HaLabel extends LitElement {
@property({ type: Boolean, reflect: true }) dense = false;
protected render(): TemplateResult { protected render(): TemplateResult {
return html` return html`
<span class="label"> <span class="content">
<slot name="icon"></slot> <slot name="icon"></slot>
<slot></slot> <slot></slot>
<md-ripple></md-ripple>
</span> </span>
`; `;
} }
@ -22,8 +26,10 @@ class HaLabel extends LitElement {
var(--rgb-primary-text-color), var(--rgb-primary-text-color),
0.15 0.15
); );
} --ha-label-background-opacity: 1;
.label {
position: relative;
box-sizing: border-box;
display: inline-flex; display: inline-flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
@ -35,9 +41,22 @@ class HaLabel extends LitElement {
height: 32px; height: 32px;
padding: 0 16px; padding: 0 16px;
border-radius: 18px; border-radius: 18px;
background-color: var(--ha-label-background-color);
color: var(--ha-label-text-color); color: var(--ha-label-text-color);
--mdc-icon-size: 18px; --mdc-icon-size: 12px;
}
.content > * {
position: relative;
display: inline-flex;
flex-direction: row;
align-items: center;
}
:host:before {
position: absolute;
content: "";
inset: 0;
border-radius: inherit;
background-color: var(--ha-label-background-color);
opacity: var(--ha-label-background-opacity);
} }
::slotted([slot="icon"]) { ::slotted([slot="icon"]) {
margin-right: 8px; margin-right: 8px;
@ -45,11 +64,23 @@ class HaLabel extends LitElement {
margin-inline-start: -8px; margin-inline-start: -8px;
margin-inline-end: 8px; margin-inline-end: 8px;
display: flex; display: flex;
color: var(--ha-label-icon-color);
} }
span { span {
display: inline-flex; display: inline-flex;
} }
:host([dense]) {
height: 20px;
padding: 0 12px;
border-radius: 10px;
}
:host([dense]) ::slotted([slot="icon"]) {
margin-right: 4px;
margin-left: -4px;
margin-inline-start: -4px;
margin-inline-end: 4px;
}
`, `,
]; ];
} }

View File

@ -0,0 +1,212 @@
import { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket";
import { LitElement, TemplateResult, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { computeCssColor } from "../common/color/compute-color";
import { fireEvent } from "../common/dom/fire_event";
import {
LabelRegistryEntry,
subscribeLabelRegistry,
updateLabelRegistryEntry,
} from "../data/label_registry";
import { SubscribeMixin } from "../mixins/subscribe-mixin";
import { showLabelDetailDialog } from "../panels/config/labels/show-dialog-label-detail";
import { HomeAssistant, ValueChangedEvent } from "../types";
import "./chips/ha-chip-set";
import "./chips/ha-input-chip";
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
import "./ha-label-picker";
import type { HaLabelPicker } from "./ha-label-picker";
@customElement("ha-labels-picker")
export class HaLabelsPicker extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public label?: string;
@property({ attribute: false }) public value?: string[];
@property() public helper?: string;
@property() public placeholder?: string;
@property({ type: Boolean, attribute: "no-add" })
public noAdd = false;
/**
* Show only labels with entities from specific domains.
* @type {Array}
* @attr include-domains
*/
@property({ type: Array, attribute: "include-domains" })
public includeDomains?: string[];
/**
* Show no labels with entities of these domains.
* @type {Array}
* @attr exclude-domains
*/
@property({ type: Array, attribute: "exclude-domains" })
public excludeDomains?: string[];
/**
* Show only labels with entities of these device classes.
* @type {Array}
* @attr include-device-classes
*/
@property({ type: Array, attribute: "include-device-classes" })
public includeDeviceClasses?: string[];
/**
* List of labels to be excluded.
* @type {Array}
* @attr exclude-labels
*/
@property({ type: Array, attribute: "exclude-label" })
public excludeLabels?: string[];
@property({ attribute: false })
public deviceFilter?: HaDevicePickerDeviceFilterFunc;
@property({ attribute: false })
public entityFilter?: (entity: HassEntity) => boolean;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = false;
@state() private _labels?: LabelRegistryEntry[];
@query("ha-label-picker", true) public labelPicker!: HaLabelPicker;
public async open() {
await this.updateComplete;
await this.labelPicker?.open();
}
public async focus() {
await this.updateComplete;
await this.labelPicker?.focus();
}
protected hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] {
return [
subscribeLabelRegistry(this.hass.connection, (labels) => {
this._labels = labels;
}),
];
}
protected render(): TemplateResult {
return html`
${this.value?.length
? html`<ha-chip-set>
${repeat(
this.value,
(item) => item,
(item, idx) => {
const label = this._labels?.find(
(lbl) => lbl.label_id === item
);
const color = label?.color
? computeCssColor(label.color)
: undefined;
return html`
<ha-input-chip
.idx=${idx}
.item=${label}
@remove=${this._removeItem}
@click=${this._openDetail}
.label=${label?.name}
selected
style=${color ? `--color: ${color}` : ""}
>
${label?.icon
? html`<ha-icon
slot="icon"
.icon=${label.icon}
></ha-icon>`
: nothing}
</ha-input-chip>
`;
}
)}
</ha-chip-set>`
: nothing}
<ha-label-picker
.hass=${this.hass}
.helper=${this.helper}
.disabled=${this.disabled}
.required=${this.required}
.label=${this.label === undefined && this.hass
? this.hass.localize("ui.components.label-picker.add_label")
: this.label}
.placeholder=${this.placeholder}
.excludeLabels=${this.value}
@value-changed=${this._labelChanged}
>
</ha-label-picker>
`;
}
private get _value() {
return this.value || [];
}
private _removeItem(ev) {
this._value.splice(ev.target.idx, 1);
this._setValue([...this._value]);
}
private _openDetail(ev) {
const label = ev.target.item;
showLabelDetailDialog(this, {
entry: label,
updateEntry: async (values) => {
const updated = await updateLabelRegistryEntry(
this.hass,
label.label_id,
values
);
this._labels = this._labels!.map((lbl) =>
lbl.label_id === updated.label_id ? updated : lbl
);
return updated;
},
});
}
private _labelChanged(ev: ValueChangedEvent<string>) {
ev.stopPropagation();
const newValue = ev.detail.value;
if (!newValue || this._value.includes(newValue)) {
return;
}
this._setValue([...this._value, newValue]);
this.labelPicker.value = "";
}
private _setValue(value?: string[]) {
this.value = value;
setTimeout(() => {
fireEvent(this, "value-changed", { value });
fireEvent(this, "change");
}, 0);
}
static styles = css`
ha-chip-set {
margin-bottom: 8px;
}
ha-input-chip {
--md-input-chip-selected-container-color: var(--color);
--ha-input-chip-selected-container-opacity: 0.5;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-labels-picker": HaLabelsPicker;
}
}

View File

@ -17,7 +17,7 @@ export const pushSupported =
class HaPushNotificationsToggle extends LitElement { class HaPushNotificationsToggle extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@state() private _disabled: boolean = false; @property({ type: Boolean }) public disabled!: boolean;
@state() private _pushChecked: boolean = @state() private _pushChecked: boolean =
"Notification" in window && Notification.permission === "granted"; "Notification" in window && Notification.permission === "granted";
@ -27,7 +27,7 @@ class HaPushNotificationsToggle extends LitElement {
protected render(): TemplateResult { protected render(): TemplateResult {
return html` return html`
<ha-switch <ha-switch
.disabled=${this._disabled || this._loading} .disabled=${this.disabled || this._loading}
.checked=${this._pushChecked} .checked=${this._pushChecked}
@change=${this._handlePushChange} @change=${this._handlePushChange}
></ha-switch> ></ha-switch>

View File

@ -0,0 +1,83 @@
import { CSSResultGroup, LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators";
import { ensureArray } from "../../common/array/ensure-array";
import { fireEvent } from "../../common/dom/fire_event";
import { LabelSelector } from "../../data/selector";
import { HomeAssistant } from "../../types";
import "../ha-labels-picker";
@customElement("ha-selector-label")
export class HaLabelSelector extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property() public value?: string | string[];
@property() public name?: string;
@property() public label?: string;
@property() public placeholder?: string;
@property() public helper?: string;
@property({ attribute: false }) public selector!: LabelSelector;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = true;
protected render() {
if (this.selector.label.multiple) {
return html`
<ha-labels-picker
.hass=${this.hass}
.value=${ensureArray(this.value ?? [])}
.disabled=${this.disabled}
.label=${this.label}
@value-changed=${this._handleChange}
>
</ha-labels-picker>
`;
}
return html`
<ha-label-picker
.hass=${this.hass}
.value=${this.value}
.disabled=${this.disabled}
.label=${this.label}
@value-changed=${this._handleChange}
>
</ha-label-picker>
`;
}
private _handleChange(ev) {
let value = ev.detail.value;
if (this.value === value) {
return;
}
if (
(value === "" || (Array.isArray(value) && value.length === 0)) &&
!this.required
) {
value = undefined;
}
fireEvent(this, "value-changed", { value });
}
static get styles(): CSSResultGroup {
return css`
ha-labels-picker {
display: block;
width: 100%;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-selector-label": HaLabelSelector;
}
}

View File

@ -2,7 +2,7 @@ import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import { UiColorSelector } from "../../data/selector"; import { UiColorSelector } from "../../data/selector";
import "../../panels/lovelace/components/hui-color-picker"; import "../ha-color-picker";
import { HomeAssistant } from "../../types"; import { HomeAssistant } from "../../types";
@customElement("ha-selector-ui_color") @customElement("ha-selector-ui_color")
@ -19,13 +19,14 @@ export class HaSelectorUiColor extends LitElement {
protected render() { protected render() {
return html` return html`
<hui-color-picker <ha-color-picker
.label=${this.label} .label=${this.label}
.hass=${this.hass} .hass=${this.hass}
.value=${this.value} .value=${this.value}
.helper=${this.helper} .helper=${this.helper}
.defaultColor=${this.selector.ui_color?.default_color}
@value-changed=${this._valueChanged} @value-changed=${this._valueChanged}
></hui-color-picker> ></ha-color-picker>
`; `;
} }

View File

@ -30,6 +30,7 @@ const LOAD_ELEMENTS = {
entity: () => import("./ha-selector-entity"), entity: () => import("./ha-selector-entity"),
statistic: () => import("./ha-selector-statistic"), statistic: () => import("./ha-selector-statistic"),
file: () => import("./ha-selector-file"), file: () => import("./ha-selector-file"),
label: () => import("./ha-selector-label"),
language: () => import("./ha-selector-language"), language: () => import("./ha-selector-language"),
navigation: () => import("./ha-selector-navigation"), navigation: () => import("./ha-selector-navigation"),
number: () => import("./ha-selector-number"), number: () => import("./ha-selector-number"),

View File

@ -30,6 +30,8 @@ import {
entityMeetsTargetSelector, entityMeetsTargetSelector,
expandAreaTarget, expandAreaTarget,
expandDeviceTarget, expandDeviceTarget,
expandFloorTarget,
expandLabelTarget,
Selector, Selector,
} from "../data/selector"; } from "../data/selector";
import { HomeAssistant, ValueChangedEvent } from "../types"; import { HomeAssistant, ValueChangedEvent } from "../types";
@ -58,20 +60,12 @@ const showOptionalToggle = (field) =>
!("boolean" in field.selector && field.default); !("boolean" in field.selector && field.default);
interface ExtHassService extends Omit<HassService, "fields"> { interface ExtHassService extends Omit<HassService, "fields"> {
fields: { fields: Array<
Omit<HassService["fields"][string], "selector"> & {
key: string; key: string;
name?: string;
description: string;
required?: boolean;
advanced?: boolean;
default?: any;
example?: any;
filter?: {
supported_features?: number[];
attribute?: Record<string, any[]>;
};
selector?: Selector; selector?: Selector;
}[]; }
>;
hasSelector: string[]; hasSelector: string[];
} }
@ -275,10 +269,42 @@ export class HaServiceControl extends LitElement {
ensureArray( ensureArray(
value?.target?.device_id || value?.data?.device_id value?.target?.device_id || value?.data?.device_id
)?.slice() || []; )?.slice() || [];
const targetAreas = ensureArray( const targetAreas =
value?.target?.area_id || value?.data?.area_id ensureArray(value?.target?.area_id || value?.data?.area_id)?.slice() ||
[];
const targetFloors = ensureArray(
value?.target?.floor_id || value?.data?.floor_id
)?.slice(); )?.slice();
if (targetAreas) { const targetLabels = ensureArray(
value?.target?.label_id || value?.data?.label_id
)?.slice();
if (targetLabels) {
targetLabels.forEach((labelId) => {
const expanded = expandLabelTarget(
this.hass,
labelId,
this.hass.areas,
this.hass.devices,
this.hass.entities,
targetSelector
);
targetDevices.push(...expanded.devices);
targetEntities.push(...expanded.entities);
targetAreas.push(...expanded.areas);
});
}
if (targetFloors) {
targetFloors.forEach((floorId) => {
const expanded = expandFloorTarget(
this.hass,
floorId,
this.hass.areas,
targetSelector
);
targetAreas.push(...expanded.areas);
});
}
if (targetAreas.length) {
targetAreas.forEach((areaId) => { targetAreas.forEach((areaId) => {
const expanded = expandAreaTarget( const expanded = expandAreaTarget(
this.hass, this.hass,

View File

@ -83,7 +83,7 @@ export class HaSortable extends LitElement {
super.connectedCallback(); super.connectedCallback();
this._shouldBeDestroy = false; this._shouldBeDestroy = false;
if (this.hasUpdated) { if (this.hasUpdated) {
this.requestUpdate(); this._createSortable();
} }
} }

View File

@ -6,12 +6,18 @@ import "@material/mwc-menu/mwc-menu-surface";
import { import {
mdiClose, mdiClose,
mdiDevices, mdiDevices,
mdiFloorPlan,
mdiLabel,
mdiPlus, mdiPlus,
mdiSofa, mdiSofa,
mdiUnfoldMoreVertical, mdiUnfoldMoreVertical,
} from "@mdi/js"; } from "@mdi/js";
import { ComboBoxLightOpenedChangedEvent } from "@vaadin/combo-box/vaadin-combo-box-light"; import { ComboBoxLightOpenedChangedEvent } from "@vaadin/combo-box/vaadin-combo-box-light";
import { HassEntity, HassServiceTarget } from "home-assistant-js-websocket"; import {
HassEntity,
HassServiceTarget,
UnsubscribeFunc,
} from "home-assistant-js-websocket";
import { css, CSSResultGroup, html, LitElement, nothing, unsafeCSS } from "lit"; import { css, CSSResultGroup, html, LitElement, nothing, unsafeCSS } from "lit";
import { customElement, property, query, state } from "lit/decorators"; import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map"; import { classMap } from "lit/directives/class-map";
@ -31,13 +37,25 @@ import "./device/ha-device-picker";
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker"; import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
import "./entity/ha-entity-picker"; import "./entity/ha-entity-picker";
import type { HaEntityPickerEntityFilterFunc } from "./entity/ha-entity-picker"; import type { HaEntityPickerEntityFilterFunc } from "./entity/ha-entity-picker";
import "./ha-area-picker"; import "./ha-area-floor-picker";
import "./ha-icon-button"; import "./ha-icon-button";
import "./ha-input-helper-text"; import "./ha-input-helper-text";
import "./ha-svg-icon"; import "./ha-svg-icon";
import { SubscribeMixin } from "../mixins/subscribe-mixin";
import {
FloorRegistryEntry,
subscribeFloorRegistry,
} from "../data/floor_registry";
import {
LabelRegistryEntry,
subscribeLabelRegistry,
} from "../data/label_registry";
import { computeCssColor } from "../common/color/compute-color";
import { AreaRegistryEntry } from "../data/area_registry";
import { hex2rgb } from "../common/color/convert-color";
@customElement("ha-target-picker") @customElement("ha-target-picker")
export class HaTargetPicker extends LitElement { export class HaTargetPicker extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public value?: HassServiceTarget; @property({ attribute: false }) public value?: HassServiceTarget;
@ -72,14 +90,33 @@ export class HaTargetPicker extends LitElement {
@property({ type: Boolean }) public addOnTop = false; @property({ type: Boolean }) public addOnTop = false;
@state() private _addMode?: "area_id" | "entity_id" | "device_id"; @state() private _addMode?:
| "area_id"
| "entity_id"
| "device_id"
| "label_id";
@query("#input") private _inputElement?; @query("#input") private _inputElement?;
@query(".add-container", true) private _addContainer?: HTMLDivElement; @query(".add-container", true) private _addContainer?: HTMLDivElement;
@state() private _floors?: FloorRegistryEntry[];
@state() private _labels?: LabelRegistryEntry[];
private _opened = false; private _opened = false;
protected hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] {
return [
subscribeFloorRegistry(this.hass.connection, (floors) => {
this._floors = floors;
}),
subscribeLabelRegistry(this.hass.connection, (labels) => {
this._labels = labels;
}),
];
}
protected render() { protected render() {
if (this.addOnTop) { if (this.addOnTop) {
return html` ${this._renderChips()} ${this._renderItems()} `; return html` ${this._renderChips()} ${this._renderItems()} `;
@ -90,6 +127,21 @@ export class HaTargetPicker extends LitElement {
private _renderItems() { private _renderItems() {
return html` return html`
<div class="mdc-chip-set items"> <div class="mdc-chip-set items">
${this.value?.floor_id
? ensureArray(this.value.floor_id).map((floor_id) => {
const floor = this._floors?.find(
(flr) => flr.floor_id === floor_id
);
return this._renderChip(
"floor_id",
floor_id,
floor?.name || floor_id,
undefined,
floor?.icon,
mdiFloorPlan
);
})
: ""}
${this.value?.area_id ${this.value?.area_id
? ensureArray(this.value.area_id).map((area_id) => { ? ensureArray(this.value.area_id).map((area_id) => {
const area = this.hass.areas![area_id]; const area = this.hass.areas![area_id];
@ -102,7 +154,7 @@ export class HaTargetPicker extends LitElement {
mdiSofa mdiSofa
); );
}) })
: ""} : nothing}
${this.value?.device_id ${this.value?.device_id
? ensureArray(this.value.device_id).map((device_id) => { ? ensureArray(this.value.device_id).map((device_id) => {
const device = this.hass.devices![device_id]; const device = this.hass.devices![device_id];
@ -115,7 +167,7 @@ export class HaTargetPicker extends LitElement {
mdiDevices mdiDevices
); );
}) })
: ""} : nothing}
${this.value?.entity_id ${this.value?.entity_id
? ensureArray(this.value.entity_id).map((entity_id) => { ? ensureArray(this.value.entity_id).map((entity_id) => {
const entity = this.hass.states[entity_id]; const entity = this.hass.states[entity_id];
@ -126,7 +178,35 @@ export class HaTargetPicker extends LitElement {
entity entity
); );
}) })
: ""} : nothing}
${this.value?.label_id
? ensureArray(this.value.label_id).map((label_id) => {
const label = this._labels?.find(
(lbl) => lbl.label_id === label_id
);
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 this._renderChip(
"label_id",
label_id,
label ? label.name : label_id,
undefined,
label?.icon,
mdiLabel,
color
);
})
: nothing}
</div> </div>
`; `;
} }
@ -194,6 +274,26 @@ export class HaTargetPicker extends LitElement {
</span> </span>
</span> </span>
</div> </div>
<div
class="mdc-chip label_id add"
.type=${"label_id"}
@click=${this._showPicker}
>
<div class="mdc-chip__ripple"></div>
<ha-svg-icon
class="mdc-chip__icon mdc-chip__icon--leading"
.path=${mdiPlus}
></ha-svg-icon>
<span role="gridcell">
<span role="button" tabindex="0" class="mdc-chip__primary-action">
<span class="mdc-chip__text"
>${this.hass.localize(
"ui.components.target-picker.add_label_id"
)}</span
>
</span>
</span>
</div>
${this._renderPicker()} ${this._renderPicker()}
</div> </div>
${this.helper ${this.helper
@ -207,18 +307,22 @@ export class HaTargetPicker extends LitElement {
} }
private _renderChip( private _renderChip(
type: "area_id" | "device_id" | "entity_id", type: "floor_id" | "area_id" | "device_id" | "entity_id" | "label_id",
id: string, id: string,
name: string, name: string,
entityState?: HassEntity, entityState?: HassEntity,
icon?: string | null, icon?: string | null,
fallbackIconPath?: string fallbackIconPath?: string,
color?: string
) { ) {
return html` return html`
<div <div
class="mdc-chip ${classMap({ class="mdc-chip ${classMap({
[type]: true, [type]: true,
})}" })}"
style=${color
? `--color: rgb(${color}); --background-color: rgba(${color}, .5)`
: ""}
> >
${icon ${icon
? html`<ha-icon ? html`<ha-icon
@ -296,7 +400,7 @@ export class HaTargetPicker extends LitElement {
@input=${stopPropagation} @input=${stopPropagation}
>${this._addMode === "area_id" >${this._addMode === "area_id"
? html` ? html`
<ha-area-picker <ha-area-floor-picker
.hass=${this.hass} .hass=${this.hass}
id="input" id="input"
.type=${"area_id"} .type=${"area_id"}
@ -309,9 +413,10 @@ export class HaTargetPicker extends LitElement {
.includeDeviceClasses=${this.includeDeviceClasses} .includeDeviceClasses=${this.includeDeviceClasses}
.includeDomains=${this.includeDomains} .includeDomains=${this.includeDomains}
.excludeAreas=${ensureArray(this.value?.area_id)} .excludeAreas=${ensureArray(this.value?.area_id)}
.excludeFloors=${ensureArray(this.value?.floor_id)}
@value-changed=${this._targetPicked} @value-changed=${this._targetPicked}
@click=${this._preventDefault} @click=${this._preventDefault}
></ha-area-picker> ></ha-area-floor-picker>
` `
: this._addMode === "device_id" : this._addMode === "device_id"
? html` ? html`
@ -331,6 +436,25 @@ export class HaTargetPicker extends LitElement {
@click=${this._preventDefault} @click=${this._preventDefault}
></ha-device-picker> ></ha-device-picker>
` `
: this._addMode === "label_id"
? html`
<ha-label-picker
.hass=${this.hass}
id="input"
.type=${"label_id"}
.label=${this.hass.localize(
"ui.components.target-picker.add_label_id"
)}
no-add
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.includeDeviceClasses=${this.includeDeviceClasses}
.includeDomains=${this.includeDomains}
.excludeLabels=${ensureArray(this.value?.label_id)}
@value-changed=${this._targetPicked}
@click=${this._preventDefault}
></ha-label-picker>
`
: html` : html`
<ha-entity-picker <ha-entity-picker
.hass=${this.hass} .hass=${this.hass}
@ -356,18 +480,24 @@ export class HaTargetPicker extends LitElement {
if (!ev.detail.value) { if (!ev.detail.value) {
return; return;
} }
const value = ev.detail.value; let value = ev.detail.value;
const target = ev.currentTarget; const target = ev.currentTarget;
let type = target.type;
if (target.type === "entity_id" && !isValidEntityId(value)) { if (type === "entity_id" && !isValidEntityId(value)) {
return; return;
} }
if (type === "area_id") {
value = ev.detail.value.id;
type = `${ev.detail.value.type}_id`;
}
target.value = ""; target.value = "";
if ( if (
this.value && this.value &&
this.value[target.type] && this.value[type] &&
ensureArray(this.value[target.type]).includes(value) ensureArray(this.value[type]).includes(value)
) { ) {
return; return;
} }
@ -375,19 +505,31 @@ export class HaTargetPicker extends LitElement {
value: this.value value: this.value
? { ? {
...this.value, ...this.value,
[target.type]: this.value[target.type] [type]: this.value[type]
? [...ensureArray(this.value[target.type]), value] ? [...ensureArray(this.value[type]), value]
: value, : value,
} }
: { [target.type]: value }, : { [type]: value },
}); });
} }
private _handleExpand(ev) { private _handleExpand(ev) {
const target = ev.currentTarget as any; const target = ev.currentTarget as any;
const newAreas: string[] = [];
const newDevices: string[] = []; const newDevices: string[] = [];
const newEntities: string[] = []; const newEntities: string[] = [];
if (target.type === "area_id") {
if (target.type === "floor_id") {
Object.values(this.hass.areas).forEach((area) => {
if (
area.floor_id === target.id &&
!this.value!.area_id?.includes(area.area_id) &&
this._areaMeetsFilter(area)
) {
newAreas.push(area.area_id);
}
});
} else if (target.type === "area_id") {
Object.values(this.hass.devices).forEach((device) => { Object.values(this.hass.devices).forEach((device) => {
if ( if (
device.area_id === target.id && device.area_id === target.id &&
@ -416,6 +558,34 @@ export class HaTargetPicker extends LitElement {
newEntities.push(entity.entity_id); newEntities.push(entity.entity_id);
} }
}); });
} else if (target.type === "label_id") {
Object.values(this.hass.areas).forEach((area) => {
if (
area.labels.includes(target.id) &&
!this.value!.area_id?.includes(area.area_id) &&
this._areaMeetsFilter(area)
) {
newAreas.push(area.area_id);
}
});
Object.values(this.hass.devices).forEach((device) => {
if (
device.labels.includes(target.id) &&
!this.value!.device_id?.includes(device.id) &&
this._deviceMeetsFilter(device)
) {
newDevices.push(device.id);
}
});
Object.values(this.hass.entities).forEach((entity) => {
if (
entity.labels.includes(target.id) &&
!this.value!.entity_id?.includes(entity.entity_id) &&
this._entityRegMeetsFilter(entity)
) {
newEntities.push(entity.entity_id);
}
});
} else { } else {
return; return;
} }
@ -426,6 +596,9 @@ export class HaTargetPicker extends LitElement {
if (newDevices.length) { if (newDevices.length) {
value = this._addItems(value, "device_id", newDevices); value = this._addItems(value, "device_id", newDevices);
} }
if (newAreas.length) {
value = this._addItems(value, "area_id", newAreas);
}
value = this._removeItem(value, target.type, target.id); value = this._removeItem(value, target.type, target.id);
fireEvent(this, "value-changed", { value }); fireEvent(this, "value-changed", { value });
} }
@ -495,45 +668,34 @@ export class HaTargetPicker extends LitElement {
ev.preventDefault(); ev.preventDefault();
} }
private _areaMeetsFilter(area: AreaRegistryEntry): boolean {
const areaDevices = Object.values(this.hass.devices).filter(
(device) => device.area_id === area.area_id
);
if (areaDevices.some((device) => this._deviceMeetsFilter(device))) {
return true;
}
const areaEntities = Object.values(this.hass.entities).filter(
(entity) => entity.area_id === area.area_id
);
if (areaEntities.some((entity) => this._entityRegMeetsFilter(entity))) {
return true;
}
return false;
}
private _deviceMeetsFilter(device: DeviceRegistryEntry): boolean { private _deviceMeetsFilter(device: DeviceRegistryEntry): boolean {
const devEntities = Object.values(this.hass.entities).filter( const devEntities = Object.values(this.hass.entities).filter(
(entity) => entity.device_id === device.id (entity) => entity.device_id === device.id
); );
if (this.includeDomains) { if (!devEntities.some((entity) => this._entityRegMeetsFilter(entity))) {
if (!devEntities || !devEntities.length) {
return false; return false;
} }
if (
!devEntities.some((entity) =>
this.includeDomains!.includes(computeDomain(entity.entity_id))
)
) {
return false;
}
}
if (this.includeDeviceClasses) {
if (!devEntities || !devEntities.length) {
return false;
}
if (
!devEntities.some((entity) => {
const stateObj = this.hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return (
stateObj.attributes.device_class &&
this.includeDeviceClasses!.includes(
stateObj.attributes.device_class
)
);
})
) {
return false;
}
}
if (this.deviceFilter) { if (this.deviceFilter) {
if (!this.deviceFilter(device)) { if (!this.deviceFilter(device)) {
@ -541,19 +703,6 @@ export class HaTargetPicker extends LitElement {
} }
} }
if (this.entityFilter) {
if (
!devEntities.some((entity) => {
const stateObj = this.hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return this.entityFilter!(stateObj);
})
) {
return false;
}
}
return true; return true;
} }
@ -641,8 +790,8 @@ export class HaTargetPicker extends LitElement {
--mdc-icon-size: 20px; --mdc-icon-size: 20px;
border-radius: 50%; border-radius: 50%;
padding: 6px; padding: 6px;
margin-left: -14px !important; margin-left: -13px !important;
margin-inline-start: -14px !important; margin-inline-start: -13px !important;
margin-inline-end: 4px !important; margin-inline-end: 4px !important;
direction: var(--direction); direction: var(--direction);
} }
@ -651,16 +800,19 @@ export class HaTargetPicker extends LitElement {
margin-inline-end: 0; margin-inline-end: 0;
margin-inline-start: initial; margin-inline-start: initial;
} }
.mdc-chip.area_id:not(.add) { .mdc-chip.area_id:not(.add),
border: 2px solid #fed6a4; .mdc-chip.floor_id:not(.add) {
border: 1px solid #fed6a4;
background: var(--card-background-color); background: var(--card-background-color);
} }
.mdc-chip.area_id:not(.add) .mdc-chip__icon--leading, .mdc-chip.area_id:not(.add) .mdc-chip__icon--leading,
.mdc-chip.area_id.add { .mdc-chip.area_id.add,
.mdc-chip.floor_id:not(.add) .mdc-chip__icon--leading,
.mdc-chip.floor_id.add {
background: #fed6a4; background: #fed6a4;
} }
.mdc-chip.device_id:not(.add) { .mdc-chip.device_id:not(.add) {
border: 2px solid #a8e1fb; border: 1px solid #a8e1fb;
background: var(--card-background-color); background: var(--card-background-color);
} }
.mdc-chip.device_id:not(.add) .mdc-chip__icon--leading, .mdc-chip.device_id:not(.add) .mdc-chip__icon--leading,
@ -668,13 +820,21 @@ export class HaTargetPicker extends LitElement {
background: #a8e1fb; background: #a8e1fb;
} }
.mdc-chip.entity_id:not(.add) { .mdc-chip.entity_id:not(.add) {
border: 2px solid #d2e7b9; border: 1px solid #d2e7b9;
background: var(--card-background-color); background: var(--card-background-color);
} }
.mdc-chip.entity_id:not(.add) .mdc-chip__icon--leading, .mdc-chip.entity_id:not(.add) .mdc-chip__icon--leading,
.mdc-chip.entity_id.add { .mdc-chip.entity_id.add {
background: #d2e7b9; background: #d2e7b9;
} }
.mdc-chip.label_id:not(.add) {
border: 1px solid var(--color, #e0e0e0);
background: var(--card-background-color);
}
.mdc-chip.label_id:not(.add) .mdc-chip__icon--leading,
.mdc-chip.label_id.add {
background: var(--background-color, #e0e0e0);
}
.mdc-chip:hover { .mdc-chip:hover {
z-index: 5; z-index: 5;
} }
@ -690,7 +850,7 @@ export class HaTargetPicker extends LitElement {
} }
ha-entity-picker, ha-entity-picker,
ha-device-picker, ha-device-picker,
ha-area-picker { ha-area-floor-picker {
display: block; display: block;
width: 100%; width: 100%;
} }

View File

@ -1,35 +1,8 @@
import "@polymer/paper-toast/paper-toast";
import type { PaperToastElement } from "@polymer/paper-toast/paper-toast";
import { customElement } from "lit/decorators"; import { customElement } from "lit/decorators";
import type { Constructor } from "../types"; import { Snackbar } from "@material/mwc-snackbar/mwc-snackbar";
const PaperToast = customElements.get(
"paper-toast"
) as Constructor<PaperToastElement>;
@customElement("ha-toast") @customElement("ha-toast")
export class HaToast extends PaperToast { export class HaToast extends Snackbar {}
private _resizeListener?: (obj: { matches: boolean }) => unknown;
private _mediaq?: MediaQueryList;
public connectedCallback() {
super.connectedCallback();
if (!this._resizeListener) {
this._resizeListener = (ev) =>
this.classList.toggle("fit-bottom", ev.matches);
this._mediaq = window.matchMedia("(max-width: 599px");
}
this._mediaq!.addListener(this._resizeListener);
this._resizeListener(this._mediaq!);
}
public disconnectedCallback() {
super.disconnectedCallback();
this._mediaq!.removeListener(this._resizeListener!);
}
}
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {

View File

@ -0,0 +1,112 @@
import "@material/web/textfield/outlined-text-field";
import type { MdOutlinedTextField } from "@material/web/textfield/outlined-text-field";
import { mdiMagnify } from "@mdi/js";
import { CSSResultGroup, LitElement, TemplateResult, css, html } from "lit";
import { customElement, property, query } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { HomeAssistant } from "../types";
import "./ha-icon-button";
import "./ha-svg-icon";
@customElement("search-input-outlined")
class SearchInputOutlined extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public filter?: string;
@property({ type: Boolean })
public suffix = false;
@property({ type: Boolean })
public autofocus = false;
@property({ type: String })
public label?: string;
@property({ type: String })
public placeholder?: string;
public focus() {
this._input?.focus();
}
@query("md-outlined-text-field", true) private _input!: MdOutlinedTextField;
protected render(): TemplateResult {
return html`
<md-outlined-text-field
.autofocus=${this.autofocus}
.aria-label=${this.label || this.hass.localize("ui.common.search")}
.placeholder=${this.placeholder ||
this.hass.localize("ui.common.search")}
.value=${this.filter || ""}
icon
.iconTrailing=${this.filter || this.suffix}
@input=${this._filterInputChanged}
>
<slot name="prefix" slot="leading-icon">
<ha-svg-icon
tabindex="-1"
class="prefix"
.path=${mdiMagnify}
></ha-svg-icon>
</slot>
</md-outlined-text-field>
`;
}
private async _filterChanged(value: string) {
fireEvent(this, "value-changed", { value: String(value) });
}
private async _filterInputChanged(e) {
this._filterChanged(e.target.value);
}
static get styles(): CSSResultGroup {
return css`
:host {
display: inline-flex;
}
md-outlined-text-field {
display: block;
width: 100%;
--md-sys-color-on-surface: var(--primary-text-color);
--md-sys-color-primary: var(--primary-text-color);
--md-outlined-text-field-input-text-color: var(--primary-text-color);
--md-sys-color-on-surface-variant: var(--secondary-text-color);
--md-outlined-field-top-space: 5.5px;
--md-outlined-field-bottom-space: 5.5px;
--md-outlined-field-outline-color: var(--outline-color);
--md-outlined-field-container-shape-start-start: 10px;
--md-outlined-field-container-shape-start-end: 10px;
--md-outlined-field-container-shape-end-end: 10px;
--md-outlined-field-container-shape-end-start: 10px;
--md-outlined-field-focus-outline-width: 1px;
--md-outlined-field-focus-outline-color: var(--primary-color);
}
ha-svg-icon,
ha-icon-button {
display: flex;
--mdc-icon-size: var(--md-input-chip-icon-size, 18px);
color: var(--primary-text-color);
}
ha-svg-icon {
outline: none;
}
.clear-button {
--mdc-icon-size: 20px;
}
.trailing {
display: flex;
align-items: center;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"search-input-outlined": SearchInputOutlined;
}
}

View File

@ -163,12 +163,7 @@ export class HaTracePathDetails extends LitElement {
} }
)} )}
<br /> <br />
${result ${error
? html`${this.hass!.localize(
"ui.panel.config.automation.trace.path.result"
)}
<pre>${dump(result)}</pre>`
: error
? html`<div class="error"> ? html`<div class="error">
${this.hass!.localize( ${this.hass!.localize(
"ui.panel.config.automation.trace.path.error", "ui.panel.config.automation.trace.path.error",
@ -178,6 +173,12 @@ export class HaTracePathDetails extends LitElement {
)} )}
</div>` </div>`
: nothing} : nothing}
${result
? html`${this.hass!.localize(
"ui.panel.config.automation.trace.path.result"
)}
<pre>${dump(result)}</pre>`
: nothing}
${Object.keys(rest).length === 0 ${Object.keys(rest).length === 0
? nothing ? nothing
: html`<pre>${dump(rest)}</pre>`} : html`<pre>${dump(rest)}</pre>`}

View File

@ -1,15 +1,16 @@
import { mdiExclamationThick } from "@mdi/js";
import { import {
css,
LitElement, LitElement,
PropertyValues, PropertyValues,
html,
TemplateResult, TemplateResult,
svg, css,
html,
nothing, nothing,
svg,
} from "lit"; } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property } from "lit/decorators";
import { NODE_SIZE, SPACING } from "./hat-graph-const";
import { isSafari } from "../../util/is_safari"; import { isSafari } from "../../util/is_safari";
import { NODE_SIZE, SPACING } from "./hat-graph-const";
/** /**
* @attribute active * @attribute active
@ -21,6 +22,8 @@ export class HatGraphNode extends LitElement {
@property({ type: Boolean, reflect: true }) public disabled = false; @property({ type: Boolean, reflect: true }) public disabled = false;
@property({ type: Boolean }) public error = false;
@property({ reflect: true, type: Boolean }) notEnabled = false; @property({ reflect: true, type: Boolean }) notEnabled = false;
@property({ reflect: true, type: Boolean }) graphStart = false; @property({ reflect: true, type: Boolean }) graphStart = false;
@ -65,16 +68,28 @@ export class HatGraphNode extends LitElement {
`} `}
<g class="node"> <g class="node">
<circle cx="0" cy="0" r=${NODE_SIZE / 2} /> <circle cx="0" cy="0" r=${NODE_SIZE / 2} />
${this.error
? svg`
<g class="error">
<circle
cx="-12"
cy=${-NODE_SIZE / 2}
r="8"
></circle>
<path transform="translate(-18 -21) scale(.5)" class="exclamation" d=${mdiExclamationThick}/>
</g>
`
: nothing}
${this.badge ${this.badge
? svg` ? svg`
<g class="number"> <g class="number">
<circle <circle
cx="8" cx="12"
cy=${-NODE_SIZE / 2} cy=${-NODE_SIZE / 2}
r="8" r="8"
></circle> ></circle>
<text <text
x="8" x="12"
y=${-NODE_SIZE / 2} y=${-NODE_SIZE / 2}
text-anchor="middle" text-anchor="middle"
alignment-baseline="middle" alignment-baseline="middle"
@ -82,7 +97,7 @@ export class HatGraphNode extends LitElement {
</g> </g>
` `
: nothing} : nothing}
<g style="pointer-events: none" transform="translate(${-12} ${-12})"> <g style="pointer-events: none" transform="translate(-12 -12)">
${this.iconPath ${this.iconPath
? svg`<path class="icon" d=${this.iconPath}/>` ? svg`<path class="icon" d=${this.iconPath}/>`
: svg`<foreignObject><span class="icon"><slot name="icon"></slot></span></foreignObject>`} : svg`<foreignObject><span class="icon"><slot name="icon"></slot></span></foreignObject>`}
@ -143,13 +158,22 @@ export class HatGraphNode extends LitElement {
fill: var(--background-clr); fill: var(--background-clr);
stroke: var(--circle-clr, var(--stroke-clr)); stroke: var(--circle-clr, var(--stroke-clr));
} }
.error circle {
fill: var(--error-color);
stroke: none;
stroke-width: 0;
}
.error .exclamation {
fill: var(--text-primary-color);
}
.number circle { .number circle {
fill: var(--track-clr); fill: var(--track-clr);
stroke: none; stroke: none;
stroke-width: 0; stroke-width: 0;
} }
.number text { .number text {
font-size: smaller; font-size: 10px;
fill: var(--text-primary-color);
} }
path.icon { path.icon {
fill: var(--icon-clr); fill: var(--icon-clr);

View File

@ -93,6 +93,7 @@ export class HatScriptGraph extends LitElement {
?active=${this.selected === path} ?active=${this.selected === path}
.iconPath=${mdiAsterisk} .iconPath=${mdiAsterisk}
.notEnabled=${config.enabled === false} .notEnabled=${config.enabled === false}
.error=${this.trace.trace[path]?.some((tr) => tr.error)}
tabindex=${track ? "0" : "-1"} tabindex=${track ? "0" : "-1"}
></hat-graph-node> ></hat-graph-node>
`; `;
@ -171,6 +172,7 @@ export class HatScriptGraph extends LitElement {
?track=${trace !== undefined} ?track=${trace !== undefined}
?active=${this.selected === path} ?active=${this.selected === path}
.notEnabled=${disabled || config.enabled === false} .notEnabled=${disabled || config.enabled === false}
.error=${this.trace.trace[path]?.some((tr) => tr.error)}
slot="head" slot="head"
nofocus nofocus
></hat-graph-node> ></hat-graph-node>
@ -424,6 +426,7 @@ export class HatScriptGraph extends LitElement {
?track=${path in this.trace.trace} ?track=${path in this.trace.trace}
?active=${this.selected === path} ?active=${this.selected === path}
.notEnabled=${disabled || node.enabled === false} .notEnabled=${disabled || node.enabled === false}
.error=${this.trace.trace[path]?.some((tr) => tr.error)}
tabindex=${this.trace && path in this.trace.trace ? "0" : "-1"} tabindex=${this.trace && path in this.trace.trace ? "0" : "-1"}
> >
${node.service ${node.service
@ -451,6 +454,7 @@ export class HatScriptGraph extends LitElement {
?track=${path in this.trace.trace} ?track=${path in this.trace.trace}
?active=${this.selected === path} ?active=${this.selected === path}
.notEnabled=${disabled || node.enabled === false} .notEnabled=${disabled || node.enabled === false}
.error=${this.trace.trace[path]?.some((tr) => tr.error)}
tabindex=${this.trace && path in this.trace.trace ? "0" : "-1"} tabindex=${this.trace && path in this.trace.trace ? "0" : "-1"}
></hat-graph-node> ></hat-graph-node>
`; `;
@ -517,6 +521,7 @@ export class HatScriptGraph extends LitElement {
@focus=${this.selectNode(node, path)} @focus=${this.selectNode(node, path)}
?track=${path in this.trace.trace} ?track=${path in this.trace.trace}
?active=${this.selected === path} ?active=${this.selected === path}
.error=${this.trace.trace[path]?.some((tr) => tr.error)}
.notEnabled=${disabled || node.enabled === false} .notEnabled=${disabled || node.enabled === false}
></hat-graph-node> ></hat-graph-node>
`; `;

View File

@ -153,7 +153,7 @@ class LogbookRenderer {
const parts: TemplateResult[] = []; const parts: TemplateResult[] = [];
let i; let i: number;
for ( for (
i = 0; i = 0;
@ -232,7 +232,7 @@ class ActionRenderer {
const value = this._getItem(index); const value = this._getItem(index);
if (renderAllIterations) { if (renderAllIterations) {
let i; let i: number = 0;
value.forEach((item) => { value.forEach((item) => {
i = this._renderIteration(index, item, actionType); i = this._renderIteration(index, item, actionType);
}); });
@ -270,7 +270,12 @@ class ActionRenderer {
} catch (err: any) { } catch (err: any) {
this._renderEntry( this._renderEntry(
path, path,
`Unable to extract path ${path}. Download trace and report as bug` this.hass.localize(
"ui.panel.config.automation.trace.messages.path_error",
{
path: path,
}
)
); );
return index + 1; return index + 1;
} }
@ -324,20 +329,22 @@ class ActionRenderer {
private _handleTrigger(index: number, triggerStep: TriggerTraceStep): number { private _handleTrigger(index: number, triggerStep: TriggerTraceStep): number {
this._renderEntry( this._renderEntry(
triggerStep.path, triggerStep.path,
`${ this.hass.localize(
triggerStep.changed_variables.trigger.alias "ui.panel.config.automation.trace.messages.triggered_by",
? `${triggerStep.changed_variables.trigger.alias} triggered` {
: "Triggered" triggeredBy: triggerStep.changed_variables.trigger?.alias
} ${ ? "alias"
triggerStep.path === "trigger" : "other",
? "manually" alias: triggerStep.changed_variables.trigger?.alias,
: `by the ${this.trace.trigger}` triggeredPath: triggerStep.path === "trigger" ? "manual" : "trigger",
} at trigger: this.trace.trigger,
${formatDateTimeWithSeconds( time: formatDateTimeWithSeconds(
new Date(triggerStep.timestamp), new Date(triggerStep.timestamp),
this.hass.locale, this.hass.locale,
this.hass.config this.hass.config
)}`, ),
}
),
mdiCircle mdiCircle
); );
return index + 1; return index + 1;
@ -367,12 +374,17 @@ class ActionRenderer {
this.keys[index] this.keys[index]
) as ChooseAction; ) as ChooseAction;
const disabled = chooseConfig.enabled === false; const disabled = chooseConfig.enabled === false;
const name = chooseConfig.alias || "Choose"; const name =
chooseConfig.alias ||
this.hass.localize("ui.panel.config.automation.trace.messages.choose");
if (defaultExecuted) { if (defaultExecuted) {
this._renderEntry( this._renderEntry(
choosePath, choosePath,
`${name}: Default action executed`, this.hass.localize(
"ui.panel.config.automation.trace.messages.default_action_executed",
{ name: name }
),
undefined, undefined,
disabled disabled
); );
@ -385,8 +397,17 @@ class ActionRenderer {
`${this.keys[index]}/choose/${chooseTrace.result.choice}` `${this.keys[index]}/choose/${chooseTrace.result.choice}`
) as ChooseActionChoice | undefined; ) as ChooseActionChoice | undefined;
const choiceName = choiceConfig const choiceName = choiceConfig
? `${choiceConfig.alias || `Option ${choiceNumeric}`} executed` ? `${
: `Error: ${chooseTrace.error}`; choiceConfig.alias ||
this.hass.localize(
"ui.panel.config.automation.trace.messages.option_executed",
{ option: choiceNumeric }
)
}`
: this.hass.localize(
"ui.panel.config.automation.trace.messages.error",
{ error: chooseTrace.error }
);
this._renderEntry( this._renderEntry(
choosePath, choosePath,
`${name}: ${choiceName}`, `${name}: ${choiceName}`,
@ -396,13 +417,16 @@ class ActionRenderer {
} else { } else {
this._renderEntry( this._renderEntry(
choosePath, choosePath,
`${name}: No action taken`, this.hass.localize(
"ui.panel.config.automation.trace.messages.no_action_executed",
{ name: name }
),
undefined, undefined,
disabled disabled
); );
} }
let i; let i: number;
// Skip over conditions // Skip over conditions
for (i = index + 1; i < this.keys.length; i++) { for (i = index + 1; i < this.keys.length; i++) {
@ -479,26 +503,38 @@ class ActionRenderer {
const ifTrace = this._getItem(index)[0] as IfActionTraceStep; const ifTrace = this._getItem(index)[0] as IfActionTraceStep;
const ifConfig = this._getDataFromPath(this.keys[index]) as IfAction; const ifConfig = this._getDataFromPath(this.keys[index]) as IfAction;
const disabled = ifConfig.enabled === false; const disabled = ifConfig.enabled === false;
const name = ifConfig.alias || "If"; const name =
ifConfig.alias ||
this.hass.localize("ui.panel.config.automation.trace.messages.if");
if (ifTrace.result?.choice) { if (ifTrace.result?.choice) {
const choiceConfig = this._getDataFromPath( const choiceConfig = this._getDataFromPath(
`${this.keys[index]}/${ifTrace.result.choice}/` `${this.keys[index]}/${ifTrace.result.choice}/`
) as any; ) as any;
const choiceName = choiceConfig const choiceName = choiceConfig
? `${choiceConfig.alias || `${ifTrace.result.choice} action executed`}` ? choiceConfig.alias ||
: `Error: ${ifTrace.error}`; this.hass.localize(
"ui.panel.config.automation.trace.messages.action_executed",
{ action: ifTrace.result.choice }
)
: this.hass.localize(
"ui.panel.config.automation.trace.messages.error",
{ error: ifTrace.error }
);
this._renderEntry(ifPath, `${name}: ${choiceName}`, undefined, disabled); this._renderEntry(ifPath, `${name}: ${choiceName}`, undefined, disabled);
} else { } else {
this._renderEntry( this._renderEntry(
ifPath, ifPath,
`${name}: No action taken`, this.hass.localize(
"ui.panel.config.automation.trace.messages.no_action_executed",
{ name: name }
),
undefined, undefined,
disabled disabled
); );
} }
let i; let i: number;
// Skip over conditions // Skip over conditions
for (i = index + 1; i < this.keys.length; i++) { for (i = index + 1; i < this.keys.length; i++) {
@ -534,7 +570,11 @@ class ActionRenderer {
const disabled = parallelConfig.enabled === false; const disabled = parallelConfig.enabled === false;
const name = parallelConfig.alias || "Execute in parallel"; const name =
parallelConfig.alias ||
this.hass.localize(
"ui.panel.config.automation.trace.messages.execute_in_parallel"
);
this._renderEntry(parallelPath, name, undefined, disabled); this._renderEntry(parallelPath, name, undefined, disabled);
@ -564,7 +604,11 @@ class ActionRenderer {
this.entries.push(html` this.entries.push(html`
<ha-timeline .icon=${icon} data-path=${path} .notEnabled=${disabled}> <ha-timeline .icon=${icon} data-path=${path} .notEnabled=${disabled}>
${description}${disabled ${description}${disabled
? html`<span class="disabled"> (disabled)</span>` ? html`<span class="disabled">
${this.hass.localize(
"ui.panel.config.automation.trace.messages.disabled"
)}</span
>`
: ""} : ""}
</ha-timeline> </ha-timeline>
`); `);
@ -636,13 +680,12 @@ export class HaAutomationTracer extends LitElement {
this.hass.locale, this.hass.locale,
this.hass.config this.hass.config
); );
const renderRuntime = () => `(runtime: const renderRuntime = () =>
${( (
(new Date(this.trace!.timestamp.finish!).getTime() - (new Date(this.trace!.timestamp.finish!).getTime() -
new Date(this.trace!.timestamp.start).getTime()) / new Date(this.trace!.timestamp.start).getTime()) /
1000 1000
).toFixed(2)} ).toFixed(2);
seconds)`;
let entry: { let entry: {
description: TemplateResult | string; description: TemplateResult | string;
@ -652,57 +695,90 @@ export class HaAutomationTracer extends LitElement {
if (this.trace.state === "running") { if (this.trace.state === "running") {
entry = { entry = {
description: "Still running", description: this.hass.localize(
"ui.panel.config.automation.trace.messages.still_running"
),
icon: mdiProgressClock, icon: mdiProgressClock,
}; };
} else if (this.trace.state === "debugged") { } else if (this.trace.state === "debugged") {
entry = { entry = {
description: "Debugged", description: this.hass.localize(
"ui.panel.config.automation.trace.messages.debugged"
),
icon: mdiProgressWrench, icon: mdiProgressWrench,
}; };
} else if (this.trace.script_execution === "finished") { } else if (this.trace.script_execution === "finished") {
entry = { entry = {
description: `Finished at ${renderFinishedAt()} ${renderRuntime()}`, description: this.hass.localize(
"ui.panel.config.automation.trace.messages.finished",
{
time: renderFinishedAt(),
executiontime: renderRuntime(),
}
),
icon: mdiCircle, icon: mdiCircle,
}; };
} else if (this.trace.script_execution === "aborted") { } else if (this.trace.script_execution === "aborted") {
entry = { entry = {
description: `Aborted at ${renderFinishedAt()} ${renderRuntime()}`, description: this.hass.localize(
"ui.panel.config.automation.trace.messages.aborted",
{
time: renderFinishedAt(),
executiontime: renderRuntime(),
}
),
icon: mdiAlertCircle, icon: mdiAlertCircle,
}; };
} else if (this.trace.script_execution === "cancelled") { } else if (this.trace.script_execution === "cancelled") {
entry = { entry = {
description: `Cancelled at ${renderFinishedAt()} ${renderRuntime()}`, description: this.hass.localize(
"ui.panel.config.automation.trace.messages.cancelled",
{
time: renderFinishedAt(),
executiontime: renderRuntime(),
}
),
icon: mdiAlertCircle, icon: mdiAlertCircle,
}; };
} else { } else {
let reason: string; let message:
| "stopped_failed_conditions"
| "stopped_failed_single"
| "stopped_failed_max_runs"
| "stopped_error"
| "stopped_unknown_reason";
let isError = false; let isError = false;
let extra: TemplateResult | undefined; let extra: TemplateResult | undefined;
switch (this.trace.script_execution) { switch (this.trace.script_execution) {
case "failed_conditions": case "failed_conditions":
reason = "a condition failed"; message = "stopped_failed_conditions";
break; break;
case "failed_single": case "failed_single":
reason = "only a single execution is allowed"; message = "stopped_failed_single";
break; break;
case "failed_max_runs": case "failed_max_runs":
reason = "maximum number of parallel runs reached"; message = "stopped_failed_max_runs";
break; break;
case "error": case "error":
reason = "an error was encountered";
isError = true; isError = true;
message = "stopped_error";
extra = html`<br /><br />${this.trace.error!}`; extra = html`<br /><br />${this.trace.error!}`;
break; break;
default: default:
reason = `of unknown reason "${this.trace.script_execution}"`;
isError = true; isError = true;
message = "stopped_unknown_reason";
} }
entry = { entry = {
description: html`Stopped because ${reason} at ${renderFinishedAt()} description: html`${this.hass.localize(
${renderRuntime()}${extra || ""}`, `ui.panel.config.automation.trace.messages.${message}`,
{
time: renderFinishedAt(),
executiontime: renderRuntime(),
}
)}
${extra || ""}`,
icon: mdiAlertCircle, icon: mdiAlertCircle,
className: isError ? "error" : undefined, className: isError ? "error" : undefined,
}; };

View File

@ -7,9 +7,11 @@ export { subscribeAreaRegistry } from "./ws-area_registry";
export interface AreaRegistryEntry { export interface AreaRegistryEntry {
area_id: string; area_id: string;
floor_id: string | null;
name: string; name: string;
picture: string | null; picture: string | null;
icon: string | null; icon: string | null;
labels: string[];
aliases: string[]; aliases: string[];
} }
@ -23,9 +25,11 @@ export interface AreaDeviceLookup {
export interface AreaRegistryEntryMutableParams { export interface AreaRegistryEntryMutableParams {
name: string; name: string;
floor_id?: string | null;
picture?: string | null; picture?: string | null;
icon?: string | null; icon?: string | null;
aliases?: string[]; aliases?: string[];
labels?: string[];
} }
export const createAreaRegistryEntry = ( export const createAreaRegistryEntry = (

View File

@ -219,8 +219,8 @@ export interface NumericStateCondition extends BaseCondition {
condition: "numeric_state"; condition: "numeric_state";
entity_id: string; entity_id: string;
attribute?: string; attribute?: string;
above?: number; above?: string | number;
below?: number; below?: string | number;
value_template?: string; value_template?: string;
} }

View File

@ -0,0 +1,86 @@
import { Connection, createCollection } from "home-assistant-js-websocket";
import { Store } from "home-assistant-js-websocket/dist/store";
import { stringCompare } from "../common/string/compare";
import { HomeAssistant } from "../types";
import { debounce } from "../common/util/debounce";
export interface CategoryRegistryEntry {
category_id: string;
name: string;
icon: string | null;
}
export interface CategoryRegistryEntryMutableParams {
name: string;
icon?: string | null;
}
export const fetchCategoryRegistry = (conn: Connection, scope: string) =>
conn
.sendMessagePromise<CategoryRegistryEntry[]>({
type: "config/category_registry/list",
scope,
})
.then((categories) =>
categories.sort((ent1, ent2) => stringCompare(ent1.name, ent2.name))
);
export const subscribeCategoryRegistry = (
conn: Connection,
scope: string,
onChange: (floors: CategoryRegistryEntry[]) => void
) =>
createCollection<CategoryRegistryEntry[]>(
`_categoryRegistry_${scope}`,
(conn2: Connection) => fetchCategoryRegistry(conn2, scope),
(conn2: Connection, store: Store<CategoryRegistryEntry[]>) =>
conn2.subscribeEvents(
debounce(
() =>
fetchCategoryRegistry(conn2, scope).then(
(categories: CategoryRegistryEntry[]) =>
store.setState(categories, true)
),
500,
true
),
"category_registry_updated"
),
conn,
onChange
);
export const createCategoryRegistryEntry = (
hass: HomeAssistant,
scope: string,
values: CategoryRegistryEntryMutableParams
) =>
hass.callWS<CategoryRegistryEntry>({
type: "config/category_registry/create",
scope,
...values,
});
export const updateCategoryRegistryEntry = (
hass: HomeAssistant,
scope: string,
category_id: string,
updates: Partial<CategoryRegistryEntryMutableParams>
) =>
hass.callWS<CategoryRegistryEntry>({
type: "config/category_registry/update",
scope,
category_id,
...updates,
});
export const deleteCategoryRegistryEntry = (
hass: HomeAssistant,
scope: string,
category_id: string
) =>
hass.callWS({
type: "config/category_registry/delete",
scope,
category_id,
});

View File

@ -148,6 +148,11 @@ export const updateCloudPref = (
...prefs, ...prefs,
}); });
export const removeCloudData = (hass: HomeAssistant) =>
hass.callWS({
type: "cloud/remove_data",
});
export const updateCloudGoogleEntityConfig = ( export const updateCloudGoogleEntityConfig = (
hass: HomeAssistant, hass: HomeAssistant,
entity_id: string, entity_id: string,

View File

@ -1,5 +1,4 @@
import { caseInsensitiveStringCompare } from "../../common/string/compare"; import { caseInsensitiveStringCompare } from "../../common/string/compare";
import { LocalizeFunc } from "../../common/translations/localize";
import { HomeAssistant } from "../../types"; import { HomeAssistant } from "../../types";
export interface CloudTTSInfo { export interface CloudTTSInfo {
@ -27,27 +26,21 @@ export const getCloudTtsLanguages = (info?: CloudTTSInfo) => {
return languages; return languages;
}; };
export const getCloudTtsSupportedGenders = ( export const getCloudTtsSupportedVoices = (
language: string, language: string,
info: CloudTTSInfo | undefined, info: CloudTTSInfo | undefined
localize: LocalizeFunc
) => { ) => {
const genders: Array<[string, string]> = []; const voices: Array<string> = [];
if (!info) { if (!info) {
return genders; return voices;
} }
for (const [curLang, gender] of info.languages) { for (const [curLang, voice] of info.languages) {
if (curLang === language) { if (curLang === language) {
genders.push([ voices.push(voice);
gender,
gender === "male" || gender === "female"
? localize(`ui.components.media-browser.tts.gender_${gender}`)
: gender,
]);
} }
} }
return genders.sort((a, b) => caseInsensitiveStringCompare(a[1], b[1])); return voices.sort((a, b) => caseInsensitiveStringCompare(a, b));
}; };

View File

@ -18,6 +18,7 @@ export interface ConfigEntry {
supports_options: boolean; supports_options: boolean;
supports_remove_device: boolean; supports_remove_device: boolean;
supports_unload: boolean; supports_unload: boolean;
supports_reconfigure: boolean;
pref_disable_new_entities: boolean; pref_disable_new_entities: boolean;
pref_disable_polling: boolean; pref_disable_polling: boolean;
disabled_by: "user" | null; disabled_by: "user" | null;

View File

@ -26,13 +26,18 @@ const HEADERS = {
"HA-Frontend-Base": `${location.protocol}//${location.host}`, "HA-Frontend-Base": `${location.protocol}//${location.host}`,
}; };
export const createConfigFlow = (hass: HomeAssistant, handler: string) => export const createConfigFlow = (
hass: HomeAssistant,
handler: string,
entry_id?: string
) =>
hass.callApi<DataEntryFlowStep>( hass.callApi<DataEntryFlowStep>(
"POST", "POST",
"config/config_entries/flow", "config/config_entries/flow",
{ {
handler, handler,
show_advanced_options: Boolean(hass.userData?.showAdvanced), show_advanced_options: Boolean(hass.userData?.showAdvanced),
entry_id,
}, },
HEADERS HEADERS
); );

View File

@ -20,6 +20,7 @@ export interface DeviceRegistryEntry {
manufacturer: string | null; manufacturer: string | null;
model: string | null; model: string | null;
name: string | null; name: string | null;
labels: string[];
sw_version: string | null; sw_version: string | null;
hw_version: string | null; hw_version: string | null;
serial_number: string | null; serial_number: string | null;
@ -43,6 +44,7 @@ export interface DeviceRegistryEntryMutableParams {
area_id?: string | null; area_id?: string | null;
name_by_user?: string | null; name_by_user?: string | null;
disabled_by?: string | null; disabled_by?: string | null;
labels?: string[];
} }
export const fallbackDeviceName = ( export const fallbackDeviceName = (
@ -140,7 +142,7 @@ export const getDeviceEntityDisplayLookup = (
export const getDeviceIntegrationLookup = ( export const getDeviceIntegrationLookup = (
entitySources: EntitySources, entitySources: EntitySources,
entities: EntityRegistryDisplayEntry[] entities: EntityRegistryDisplayEntry[] | EntityRegistryEntry[]
): Record<string, string[]> => { ): Record<string, string[]> => {
const deviceIntegrations: Record<string, string[]> = {}; const deviceIntegrations: Record<string, string[]> = {};

View File

@ -11,7 +11,11 @@ import {
isLastDayOfMonth, isLastDayOfMonth,
} from "date-fns/esm"; } from "date-fns/esm";
import { Collection, getCollection } from "home-assistant-js-websocket"; import { Collection, getCollection } from "home-assistant-js-websocket";
import { calcDate, calcDateProperty } from "../common/datetime/calc_date"; import {
calcDate,
calcDateProperty,
calcDateDifferenceProperty,
} from "../common/datetime/calc_date";
import { formatTime24h } from "../common/datetime/format_time"; import { formatTime24h } from "../common/datetime/format_time";
import { groupBy } from "../common/util/group-by"; import { groupBy } from "../common/util/group-by";
import { HomeAssistant } from "../types"; import { HomeAssistant } from "../types";
@ -443,12 +447,12 @@ const getEnergyData = async (
addMonths, addMonths,
hass.locale, hass.locale,
hass.config, hass.config,
-(calcDateProperty( -(calcDateDifferenceProperty(
end || new Date(), end || new Date(),
start,
differenceInMonths, differenceInMonths,
hass.locale, hass.locale,
hass.config, hass.config
start
) as number) - 1 ) as number) - 1
); );
} else { } else {

View File

@ -70,6 +70,7 @@ export const DOMAIN_ATTRIBUTES_UNITS = {
brightness: "%", brightness: "%",
}, },
sun: { sun: {
azimuth: "°",
elevation: "°", elevation: "°",
}, },
vacuum: { vacuum: {

View File

@ -18,6 +18,7 @@ export interface EntityRegistryDisplayEntry {
icon?: string; icon?: string;
device_id?: string; device_id?: string;
area_id?: string; area_id?: string;
labels: string[];
hidden?: boolean; hidden?: boolean;
entity_category?: entityCategory; entity_category?: entityCategory;
translation_key?: string; translation_key?: string;
@ -30,6 +31,7 @@ export interface EntityRegistryDisplayEntryResponse {
ei: string; ei: string;
di?: string; di?: string;
ai?: string; ai?: string;
lb: string[];
ec?: number; ec?: number;
en?: string; en?: string;
ic?: string; ic?: string;
@ -50,6 +52,7 @@ export interface EntityRegistryEntry {
config_entry_id: string | null; config_entry_id: string | null;
device_id: string | null; device_id: string | null;
area_id: string | null; area_id: string | null;
labels: string[];
disabled_by: "user" | "device" | "integration" | "config_entry" | null; disabled_by: "user" | "device" | "integration" | "config_entry" | null;
hidden_by: Exclude<EntityRegistryEntry["disabled_by"], "config_entry">; hidden_by: Exclude<EntityRegistryEntry["disabled_by"], "config_entry">;
entity_category: entityCategory | null; entity_category: entityCategory | null;
@ -58,6 +61,7 @@ export interface EntityRegistryEntry {
unique_id: string; unique_id: string;
translation_key?: string; translation_key?: string;
options: EntityRegistryOptions | null; options: EntityRegistryOptions | null;
categories: { [scope: string]: string };
} }
export interface ExtEntityRegistryEntry extends EntityRegistryEntry { export interface ExtEntityRegistryEntry extends EntityRegistryEntry {
@ -133,6 +137,8 @@ export interface EntityRegistryEntryUpdateParams {
| WeatherEntityOptions | WeatherEntityOptions
| LightEntityOptions; | LightEntityOptions;
aliases?: string[]; aliases?: string[];
labels?: string[];
categories?: { [scope: string]: string | null };
} }
const batteryPriorities = ["sensor", "binary_sensor"]; const batteryPriorities = ["sensor", "binary_sensor"];

133
src/data/floor_registry.ts Normal file
View File

@ -0,0 +1,133 @@
import { Connection, createCollection } from "home-assistant-js-websocket";
import { Store } from "home-assistant-js-websocket/dist/store";
import { stringCompare } from "../common/string/compare";
import { HomeAssistant } from "../types";
import { AreaRegistryEntry } from "./area_registry";
import { debounce } from "../common/util/debounce";
export { subscribeAreaRegistry } from "./ws-area_registry";
export interface FloorRegistryEntry {
floor_id: string;
name: string;
level: number;
icon: string | null;
aliases: string[];
}
export interface FloorAreaLookup {
[floorId: string]: AreaRegistryEntry[];
}
export interface FloorRegistryEntryMutableParams {
name: string;
level?: number | null;
icon?: string | null;
aliases?: string[];
}
const fetchFloorRegistry = (conn: Connection) =>
conn
.sendMessagePromise({
type: "config/floor_registry/list",
})
.then((floors) =>
(floors as FloorRegistryEntry[]).sort((ent1, ent2) => {
if (ent1.level !== ent2.level) {
return (ent1.level ?? 9999) - (ent2.level ?? 9999);
}
return stringCompare(ent1.name, ent2.name);
})
);
const subscribeFloorRegistryUpdates = (
conn: Connection,
store: Store<FloorRegistryEntry[]>
) =>
conn.subscribeEvents(
debounce(
() =>
fetchFloorRegistry(conn).then((areas: FloorRegistryEntry[]) =>
store.setState(areas, true)
),
500,
true
),
"floor_registry_updated"
);
export const subscribeFloorRegistry = (
conn: Connection,
onChange: (floors: FloorRegistryEntry[]) => void
) =>
createCollection<FloorRegistryEntry[]>(
"_floorRegistry",
fetchFloorRegistry,
subscribeFloorRegistryUpdates,
conn,
onChange
);
export const createFloorRegistryEntry = (
hass: HomeAssistant,
values: FloorRegistryEntryMutableParams
) =>
hass.callWS<FloorRegistryEntry>({
type: "config/floor_registry/create",
...values,
});
export const updateFloorRegistryEntry = (
hass: HomeAssistant,
floorId: string,
updates: Partial<FloorRegistryEntryMutableParams>
) =>
hass.callWS<AreaRegistryEntry>({
type: "config/floor_registry/update",
floor_id: floorId,
...updates,
});
export const deleteFloorRegistryEntry = (
hass: HomeAssistant,
floorId: string
) =>
hass.callWS({
type: "config/floor_registry/delete",
floor_id: floorId,
});
export const getFloorAreaLookup = (
areas: AreaRegistryEntry[]
): FloorAreaLookup => {
const floorAreaLookup: FloorAreaLookup = {};
for (const area of areas) {
if (!area.floor_id) {
continue;
}
if (!(area.floor_id in floorAreaLookup)) {
floorAreaLookup[area.floor_id] = [];
}
floorAreaLookup[area.floor_id].push(area);
}
return floorAreaLookup;
};
export const floorCompare =
(entries?: FloorRegistryEntry[], order?: string[]) =>
(a: string, b: string) => {
const indexA = order ? order.indexOf(a) : -1;
const indexB = order ? order.indexOf(b) : -1;
if (indexA === -1 && indexB === -1) {
const nameA = entries?.[a]?.name ?? a;
const nameB = entries?.[b]?.name ?? b;
return stringCompare(nameA, nameB);
}
if (indexA === -1) {
return 1;
}
if (indexB === -1) {
return -1;
}
return indexA - indexB;
};

View File

@ -43,6 +43,7 @@ export interface IntegrationManifest {
| "cloud_push" | "cloud_push"
| "local_polling" | "local_polling"
| "local_push"; | "local_push";
single_config_entry?: boolean;
} }
export interface IntegrationSetup { export interface IntegrationSetup {
domain: string; domain: string;

View File

@ -11,6 +11,7 @@ export interface Integration {
iot_class?: string; iot_class?: string;
supported_by?: string; supported_by?: string;
is_built_in?: boolean; is_built_in?: boolean;
single_config_entry?: boolean;
} }
export interface Integrations { export interface Integrations {

View File

@ -0,0 +1,86 @@
import { Connection, createCollection } from "home-assistant-js-websocket";
import { Store } from "home-assistant-js-websocket/dist/store";
import { stringCompare } from "../common/string/compare";
import { HomeAssistant } from "../types";
import { debounce } from "../common/util/debounce";
export interface LabelRegistryEntry {
label_id: string;
name: string;
icon: string | null;
color: string | null;
}
export interface LabelRegistryEntryMutableParams {
name: string;
icon?: string | null;
color?: string | null;
}
export const fetchLabelRegistry = (conn: Connection) =>
conn
.sendMessagePromise({
type: "config/label_registry/list",
})
.then((labels) =>
(labels as LabelRegistryEntry[]).sort((ent1, ent2) =>
stringCompare(ent1.name, ent2.name)
)
);
export const subscribeLabelRegistryUpdates = (
conn: Connection,
store: Store<LabelRegistryEntry[]>
) =>
conn.subscribeEvents(
debounce(
() =>
fetchLabelRegistry(conn).then((labels: LabelRegistryEntry[]) =>
store.setState(labels, true)
),
500,
true
),
"label_registry_updated"
);
export const subscribeLabelRegistry = (
conn: Connection,
onChange: (labels: LabelRegistryEntry[]) => void
) =>
createCollection<LabelRegistryEntry[]>(
"_labelRegistry",
fetchLabelRegistry,
subscribeLabelRegistryUpdates,
conn,
onChange
);
export const createLabelRegistryEntry = (
hass: HomeAssistant,
values: LabelRegistryEntryMutableParams
) =>
hass.callWS<LabelRegistryEntry>({
type: "config/label_registry/create",
...values,
});
export const updateLabelRegistryEntry = (
hass: HomeAssistant,
labelId: string,
updates: Partial<LabelRegistryEntryMutableParams>
) =>
hass.callWS<LabelRegistryEntry>({
type: "config/label_registry/update",
label_id: labelId,
...updates,
});
export const deleteLabelRegistryEntry = (
hass: HomeAssistant,
labelId: string
) =>
hass.callWS({
type: "config/label_registry/delete",
label_id: labelId,
});

View File

@ -18,6 +18,7 @@ export interface LovelaceBaseViewConfig {
visible?: boolean | ShowViewConfig[]; visible?: boolean | ShowViewConfig[];
subview?: boolean; subview?: boolean;
back_path?: string; back_path?: string;
max_columns?: number; // Only used for section view, it should move to a section view config type when the views will have dedicated editor.
} }
export interface LovelaceViewConfig extends LovelaceBaseViewConfig { export interface LovelaceViewConfig extends LovelaceBaseViewConfig {

View File

@ -26,6 +26,7 @@ export type ItemType =
| "config_entry" | "config_entry"
| "device" | "device"
| "entity" | "entity"
| "floor"
| "group" | "group"
| "scene" | "scene"
| "script" | "script"

View File

@ -8,7 +8,10 @@ import {
DeviceRegistryEntry, DeviceRegistryEntry,
getDeviceIntegrationLookup, getDeviceIntegrationLookup,
} from "./device_registry"; } from "./device_registry";
import { EntityRegistryDisplayEntry } from "./entity_registry"; import {
EntityRegistryDisplayEntry,
EntityRegistryEntry,
} from "./entity_registry";
import { EntitySources } from "./entity_sources"; import { EntitySources } from "./entity_sources";
export type Selector = export type Selector =
@ -34,6 +37,7 @@ export type Selector =
| LegacyEntitySelector | LegacyEntitySelector
| FileSelector | FileSelector
| IconSelector | IconSelector
| LabelSelector
| LanguageSelector | LanguageSelector
| LocationSelector | LocationSelector
| MediaSelector | MediaSelector
@ -242,6 +246,12 @@ export interface IconSelector {
} | null; } | null;
} }
export interface LabelSelector {
label: {
multiple?: boolean;
};
}
export interface LanguageSelector { export interface LanguageSelector {
language: { language: {
languages?: string[]; languages?: string[];
@ -421,9 +431,95 @@ export interface UiActionSelector {
export interface UiColorSelector { export interface UiColorSelector {
// eslint-disable-next-line @typescript-eslint/ban-types // eslint-disable-next-line @typescript-eslint/ban-types
ui_color: {} | null; ui_color: { default_color?: boolean } | null;
} }
export const expandLabelTarget = (
hass: HomeAssistant,
labelId: string,
areas: HomeAssistant["areas"],
devices: HomeAssistant["devices"],
entities: HomeAssistant["entities"],
targetSelector: TargetSelector,
entitySources?: EntitySources
) => {
const newEntities: string[] = [];
const newDevices: string[] = [];
const newAreas: string[] = [];
Object.values(areas).forEach((area) => {
if (
area.labels.includes(labelId) &&
areaMeetsTargetSelector(
hass,
entities,
devices,
area.area_id,
targetSelector,
entitySources
)
) {
newAreas.push(area.area_id);
}
});
Object.values(devices).forEach((device) => {
if (
device.labels.includes(labelId) &&
deviceMeetsTargetSelector(
hass,
Object.values(entities),
device,
targetSelector,
entitySources
)
) {
newDevices.push(device.id);
}
});
Object.values(entities).forEach((entity) => {
if (
entity.labels.includes(labelId) &&
entityMeetsTargetSelector(
hass.states[entity.entity_id],
targetSelector,
entitySources
)
) {
newEntities.push(entity.entity_id);
}
});
return { areas: newAreas, devices: newDevices, entities: newEntities };
};
export const expandFloorTarget = (
hass: HomeAssistant,
floorId: string,
areas: HomeAssistant["areas"],
targetSelector: TargetSelector,
entitySources?: EntitySources
) => {
const newAreas: string[] = [];
Object.values(areas).forEach((area) => {
if (
area.floor_id === floorId &&
areaMeetsTargetSelector(
hass,
hass.entities,
hass.devices,
area.area_id,
targetSelector,
entitySources
)
) {
newAreas.push(area.area_id);
}
});
return { areas: newAreas };
};
export const expandAreaTarget = ( export const expandAreaTarget = (
hass: HomeAssistant, hass: HomeAssistant,
areaId: string, areaId: string,
@ -529,7 +625,7 @@ export const areaMeetsTargetSelector = (
export const deviceMeetsTargetSelector = ( export const deviceMeetsTargetSelector = (
hass: HomeAssistant, hass: HomeAssistant,
entityRegistry: EntityRegistryDisplayEntry[], entityRegistry: EntityRegistryDisplayEntry[] | EntityRegistryEntry[],
device: DeviceRegistryEntry, device: DeviceRegistryEntry,
targetSelector: TargetSelector, targetSelector: TargetSelector,
entitySources?: EntitySources entitySources?: EntitySources

Some files were not shown because too many files have changed in this diff Show More