20220526.0 (#12807)

* Fix setting _externalAccess (#12584)

* Fix enter key support for generic dialog box (#12600)

* Revert #10991 (#12618)

* Replace host-context with css properties

* Remove "Lovelace" from Github issue templates (#12614)

* Remove "Lovelace" from Github issue templates

* Changes from review

* Get full core logs from core (#12639)

* Move YAML to first tab of Developer Tools (#12589)

* Add configuration panel for Application Credentials (#12344)

Co-authored-by: Zack Barett <zackbarett@hey.com>
Co-authored-by: Zack <zackbarett@hey.com>

* Add label for Fix issue column header in statistics developer tools (#12597)

Signed-off-by: Patrick ZAJDA <patrick@zajda.fr>

* RTL settings clickable list item fix (#12595)

* Update src/state/translations-mixin.ts

Co-authored-by: Bram Kragten <mail@bramkragten.nl>

* Update following review

* Update Translations to create helper (#12656)

* Hide Cloud URL - Add Copy Icon (#12655)

Co-authored-by: Bram Kragten <mail@bramkragten.nl>

* Show script traces in logbook (#12643)

* Rtl menu fix (#12561)

Co-authored-by: Bram Kragten <mail@bramkragten.nl>

* Use  /

* Update var name

* Use FabBase

* Update ha-fab.ts

* Switch logbook calls to use the new websocket (#12665)

* Add calendar trigger offsets in automation editor (#12486)

* Add calendar trigger offsets in automation editor

* Use duration selector for offset

* Fix typing for offsets/duratons

* Fix strict error handling in Markdown card templates (#12661)

* Add missing label to search icon (#12671)

* Update Lokalise URL (#12684)

* Teach logbook about additional context data (#12667)

Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>

* Guard for missing backup integration (#12696)

* Rtl changes (#12693)

* Return focus after dialogs close (#11999)

* Bumped version to 20220516.0

* Fix float-end for LTR (#12707)

* Add my support for Application Credentials (#12709)

* Show manage cloud link to config (#12673)

* Add guard logic from PR home-assistant#12181 to input select row (#12703)

* Refactor logbook data fetch logic into reusable class (#12701)

* Add logbook to device info page (#12714)

* Add logbook to area info page (#12715)

* Add missing labels in energy dashboard settings (#12722)

Signed-off-by: Patrick ZAJDA <patrick@zajda.fr>

* Delete focus targets for replaced dialogs (#12724)

* Convert history calls to use new websocket endpoint (#12662)

* Add support for OAuth2 callback via My (#12718)

* Support requesting translations for multiple integrations in one request (#12704)

* Support requesting translations for multiple integrations in one request

- Requires https://github.com/home-assistant/core/pull/71979

* onboarding as well

* integrations -> integration

* fix cache

* short return if they are all loaded

* reduce

* reduce

* reduce

* Show the integration brand icon when there is no entity in logbook (#12713)

* Support requesting multiple integration manifests in a single request (#12706)

* Support requesting multiple integration manifests in a single request

* only fetch if there are some to actually fetch

* handle empty

* not truthy, wrong language

* Do not copy params

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>

* Import all date-fns from modules (#12717)

* Fix 'loading_log' string (#12712)

* Bumped version to 20220518.0

* Fix python to js timestamp conversions in logbook traces (#12677)

- The websocket version needs the time converted from
  where python stores the decimal

* Update Material Design Icons to v6.7.96 (#12111)

* Various RTL fixes (#12721)

* Select + target picker Rtl fixes (#12711)

* Add error handling for application credentials removal (#12686)

* Update zwave_js data collection URL (#12666)

* Use device_id instead of config entry id and node id for zwave_js (#12658)

* Use device_id instead of config entry id and node id for zwave_js

* Add additional cleanup from #12642

* Revert removal of multiple config entries check

* Update src/panels/config/devices/device-detail/integration-elements/zwave_js/ha-device-actions-zwave_js.ts

Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>

* Update src/panels/config/devices/device-detail/integration-elements/zwave_js/ha-device-info-zwave_js.ts

Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>

Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>

* Get attributes from first state when using minimal responses (#12732)

* Pass device ID to logbook if available (#12728)

* Compute the icon based on the logbook state and not the current state (#12725)

Co-authored-by: Bram Kragten <mail@bramkragten.nl>

* Add option to compare energy graphs with previous period (#12723)

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>

* Add an application credentials display name (#12720)

Co-authored-by: Bram Kragten <mail@bramkragten.nl>
Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>

* Fixes logbook (#12740)

* Bumped version to 20220521.0

* Stop closed event when selecting datadisk (#12749)

* Use logbook livestream when requesting a time window that includes the future (#12744)

* Fetch supervisor info directly (#12751)

* Remove kernel and agent versions from about page (#12750)

* Move `preload_stream` setting to entity settings (#12730)

Co-authored-by: Zack <zackbarett@hey.com>

* Use new localized context state and source in logbook (#12742)

* Use new logbook streaming websocket api for cases where we need end_time (#12753)

* Open Application Credentials from integration configuration flow (#12708)

* Make entities and devices independent in the scene editor (#11046)

Co-authored-by: Zack Barett <zackbarett@hey.com>
Co-authored-by: Erik <erik@montnemery.com>
Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>

* Allow setting `device_class` "outlet" again through entity settings (#12669)

* Allow setting `device_class` "outlet" again through UI

* Fixes

* Null check deviceClass and adjust used translation

* Bumped version to 20220523.0 (#12756)

* Adjust logbook stream consumer to handle new metadata (#12755)

* Adjust path to version info in issue template (#12760)

* Add My HA link to about page to Github issue template (#12761)

* RTL updates (#12745)

* Add compare to energy sources table (#12762)

* Fix (#12764)

* Change service_data to just data (#12628)

* Simplify OAuth2 authorize callback URL (#12765)

* Hide hidden media player entities in media panel (#12766)

* Add integration filter to Device Selector (#12680)

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>

* Integration filter for Area Selector (#12682)

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>

* Bump Version to 20220524.0 (#12769)

* Move zwave_js node comments from device config to info page (#12625)

Co-authored-by: Zack Barett <zackbarett@hey.com>
Co-authored-by: Bram Kragten <mail@bramkragten.nl>

* Various RTL fixes

* Add controller statistics to zwave_js config dashboard (#12668)

* Move Logbook and make device page better (#12763)

Co-authored-by: Bram Kragten <mail@bramkragten.nl>

* Move metadata to pyproject.toml (#12770)

* Add aria-haspopup to button menus (#12758)

Co-authored-by: Zack Barett <zackbarett@hey.com>

* Hardware MVP (#12773)

* Fix "unavailable" handling for climate state rendering (#12778)

* Ensure state is vertically centered in more-info (#12780)

* Update zwave_js/network_status WS API (#12735)

* Bumped version to 20220525.0 (#12779)

* Use dynamic weather domain icon + icon alignment fix weather more-info (#12781)

* Fix typo in credentials removal dialog (#12784)

* Handle history api being passed entity ids as CSV (#12787)

* Fix history cache when there is cacheConfig (#12788)

* Use Hardware Integration for System Menu (#12789)

* Fix Media Player More info cramped controls (#12790)

* Fix Switch as X unable to change to a new type (#12797)

* Fix Zwave Alerts on device page (#12785)

Co-authored-by: Bram Kragten <mail@bramkragten.nl>

* Set Error if entity is unavailable (#12791)

* Dynamically determine the correct action config struct (#12798)

* Remove import

* Hide Cloud information a bit more (#12802)

* Add dynamic header/footer config determination and update struct (#12795)

* Bump HAWS to 7.1.0 (#12804)

* Update Narrow Order on Device Page (#12801)

Co-authored-by: Bram Kragten <mail@bramkragten.nl>

* Fix combo box inside dialog (#12805)

* Bumped version to 20220526.0

Co-authored-by: Joakim Sørensen <ludeeus@ludeeus.dev>
Co-authored-by: Steve Repsher <steverep@users.noreply.github.com>
Co-authored-by: Zack Barett <zackbarett@hey.com>
Co-authored-by: Yosi Levy <yosilevy@gmail.com>
Co-authored-by: Philip Allgaier <mail@spacegaier.de>
Co-authored-by: Allen Porter <allen@thebends.org>
Co-authored-by: Patrick ZAJDA <patrick@zajda.fr>
Co-authored-by: Yosi Levy <37745463+yosilevy@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: Franck Nijhof <git@frenck.dev>
Co-authored-by: Sven <85389871+wrt54g@users.noreply.github.com>
Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
Co-authored-by: breakthestatic <breakthestatic@gmail.com>
Co-authored-by: RoboMagus <68224306+RoboMagus@users.noreply.github.com>
Co-authored-by: Michael Irigoyen <michael@irigoyen.dev>
Co-authored-by: Raman Gupta <7243222+raman325@users.noreply.github.com>
Co-authored-by: David F. Mulcahey <david.mulcahey@me.com>
Co-authored-by: Erik <erik@montnemery.com>
Co-authored-by: Thomas Lovén <thomasloven@gmail.com>
Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com>
This commit is contained in:
Bram Kragten 2022-05-26 23:41:27 +02:00 committed by GitHub
commit d810cae194
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
194 changed files with 6487 additions and 3451 deletions

View File

@ -51,7 +51,7 @@ DO NOT DELETE ANY TEXT from this template! Otherwise, your issue may be closed w
<!-- <!--
Provide details about the versions you are using, which helps us reproducing Provide details about the versions you are using, which helps us reproducing
and finding the issue quicker. Version information is found in the and finding the issue quicker. Version information is found in the
Home Assistant frontend: Configuration -> Info. Home Assistant frontend: Settings -> About.
Browser version and operating system is important! Please try to replicate Browser version and operating system is important! Please try to replicate
your issue in a different browser and be sure to include your findings. your issue in a different browser and be sure to include your findings.

View File

@ -1,4 +1,4 @@
name: Report a bug with the UI, Frontend or Lovelace name: Report a bug with the UI / Dashboards
description: Report an issue related to the Home Assistant frontend. description: Report an issue related to the Home Assistant frontend.
labels: bug labels: bug
body: body:
@ -9,7 +9,7 @@ body:
If you have a feature or enhancement request for the frontend, please [start an discussion][fr] instead of creating an issue. If you have a feature or enhancement request for the frontend, please [start an discussion][fr] instead of creating an issue.
**Please not not report issues for custom Lovelace cards.** **Please not not report issues for custom cards.**
[fr]: https://github.com/home-assistant/frontend/discussions [fr]: https://github.com/home-assistant/frontend/discussions
[releases]: https://github.com/home-assistant/home-assistant/releases [releases]: https://github.com/home-assistant/home-assistant/releases
@ -64,7 +64,7 @@ body:
label: What version of Home Assistant Core has the issue? label: What version of Home Assistant Core has the issue?
placeholder: core- placeholder: core-
description: > description: >
Can be found in the Configuration panel -> Info. Can be found in: [Settings -> About](https://my.home-assistant.io/redirect/info/).
- type: input - type: input
attributes: attributes:
label: What was the last working version of Home Assistant Core? label: What was the last working version of Home Assistant Core?

View File

@ -1,17 +1,17 @@
blank_issues_enabled: false blank_issues_enabled: false
contact_links: contact_links:
- name: Request a feature for the UI, Frontend or Lovelace - name: Request a feature for the UI / Dashboards
url: https://github.com/home-assistant/frontend/discussions/category_choices url: https://github.com/home-assistant/frontend/discussions/category_choices
about: Request an new feature for the Home Assistant frontend. about: Request an new feature for the Home Assistant frontend.
- name: Report a bug that is NOT related to the UI, Frontend or Lovelace - name: Report a bug that is NOT related to the UI / Dashboards
url: https://github.com/home-assistant/core/issues url: https://github.com/home-assistant/core/issues
about: This is the issue tracker for our frontend. Please report other issues with the backend repository. about: This is the issue tracker for our frontend. Please report other issues in the backend ("core") repository.
- name: Report incorrect or missing information on our website - name: Report incorrect or missing information on our website
url: https://github.com/home-assistant/home-assistant.io/issues url: https://github.com/home-assistant/home-assistant.io/issues
about: Our documentation has its own issue tracker. Please report issues with the website there. about: Our documentation has its own issue tracker. Please report issues with the website there.
- name: I have a question or need support - name: I have a question or need support
url: https://www.home-assistant.io/help url: https://www.home-assistant.io/help
about: We use GitHub for tracking bugs, check our website for resources on getting help. about: We use GitHub for tracking bugs. Check our website for resources on getting help.
- name: I'm unsure where to go - name: I'm unsure where to go
url: https://www.home-assistant.io/join-chat url: https://www.home-assistant.io/join-chat
about: If you are unsure where to go, then joining our chat is recommended; Just ask! about: If you are unsure where to go, then joining our chat is recommended; Just ask!

View File

@ -26,8 +26,8 @@ module.exports = {
}, },
version() { version() {
const version = fs const version = fs
.readFileSync(path.resolve(paths.polymer_dir, "setup.cfg"), "utf8") .readFileSync(path.resolve(paths.polymer_dir, "pyproject.toml"), "utf8")
.match(/version\W+=\W(\d{8}\.\d)/); .match(/version\W+=\W"(\d{8}\.\d)"/);
if (!version) { if (!version) {
throw Error("Version not found"); throw Error("Version not found");
} }

View File

@ -194,7 +194,7 @@ export const demoLovelaceJimpower: DemoConfig["lovelace"] = () => ({
type: "state-icon", type: "state-icon",
tap_action: { tap_action: {
action: "call-service", action: "call-service",
service_data: { data: {
entity_id: "group.downstairs_lights", entity_id: "group.downstairs_lights",
}, },
service: "homeassistant.toggle", service: "homeassistant.toggle",

View File

@ -377,7 +377,7 @@ export const demoLovelaceTeachingbirds: DemoConfig["lovelace"] = () => ({
name: "AC bed", name: "AC bed",
tap_action: { tap_action: {
action: "call-service", action: "call-service",
service_data: { data: {
entity_id: "script.air_cleaner_quiet", entity_id: "script.air_cleaner_quiet",
}, },
service: "script.turn_on", service: "script.turn_on",
@ -390,7 +390,7 @@ export const demoLovelaceTeachingbirds: DemoConfig["lovelace"] = () => ({
name: "AC bed", name: "AC bed",
tap_action: { tap_action: {
action: "call-service", action: "call-service",
service_data: { data: {
entity_id: "script.air_cleaner_auto", entity_id: "script.air_cleaner_auto",
}, },
service: "script.turn_on", service: "script.turn_on",
@ -403,7 +403,7 @@ export const demoLovelaceTeachingbirds: DemoConfig["lovelace"] = () => ({
name: "AC bed", name: "AC bed",
tap_action: { tap_action: {
action: "call-service", action: "call-service",
service_data: { data: {
entity_id: "script.air_cleaner_turbo", entity_id: "script.air_cleaner_turbo",
}, },
service: "script.turn_on", service: "script.turn_on",
@ -416,7 +416,7 @@ export const demoLovelaceTeachingbirds: DemoConfig["lovelace"] = () => ({
name: "AC", name: "AC",
tap_action: { tap_action: {
action: "call-service", action: "call-service",
service_data: { data: {
entity_id: "script.ac_off", entity_id: "script.ac_off",
}, },
service: "script.turn_on", service: "script.turn_on",
@ -429,7 +429,7 @@ export const demoLovelaceTeachingbirds: DemoConfig["lovelace"] = () => ({
name: "AC", name: "AC",
tap_action: { tap_action: {
action: "call-service", action: "call-service",
service_data: { data: {
entity_id: "script.ac_on", entity_id: "script.ac_on",
}, },
service: "script.turn_on", service: "script.turn_on",
@ -629,7 +629,7 @@ export const demoLovelaceTeachingbirds: DemoConfig["lovelace"] = () => ({
entity: "scene.morning_lights", entity: "scene.morning_lights",
tap_action: { tap_action: {
action: "call-service", action: "call-service",
service_data: { data: {
entity_id: "scene.morning_lights", entity_id: "scene.morning_lights",
}, },
service: "scene.turn_on", service: "scene.turn_on",
@ -641,7 +641,7 @@ export const demoLovelaceTeachingbirds: DemoConfig["lovelace"] = () => ({
entity: "scene.movie_time", entity: "scene.movie_time",
tap_action: { tap_action: {
action: "call-service", action: "call-service",
service_data: { data: {
entity_id: "scene.movie_time", entity_id: "scene.movie_time",
}, },
service: "scene.turn_on", service: "scene.turn_on",
@ -702,7 +702,7 @@ export const demoLovelaceTeachingbirds: DemoConfig["lovelace"] = () => ({
entity: "light.downstairs_lights", entity: "light.downstairs_lights",
tap_action: { tap_action: {
action: "call-service", action: "call-service",
service_data: { data: {
entity_id: "light.downstairs_lights", entity_id: "light.downstairs_lights",
}, },
service: "light.toggle", service: "light.toggle",
@ -714,7 +714,7 @@ export const demoLovelaceTeachingbirds: DemoConfig["lovelace"] = () => ({
entity: "light.upstairs_lights", entity: "light.upstairs_lights",
tap_action: { tap_action: {
action: "call-service", action: "call-service",
service_data: { data: {
entity_id: "light.upstairs_lights", entity_id: "light.upstairs_lights",
}, },
service: "light.toggle", service: "light.toggle",

View File

@ -1,4 +1,4 @@
import { format, startOfToday, startOfTomorrow } from "date-fns"; import { format, startOfToday, startOfTomorrow } from "date-fns/esm";
import { EnergySolarForecasts } from "../../../src/data/energy"; import { EnergySolarForecasts } from "../../../src/data/energy";
import { MockHomeAssistant } from "../../../src/fake_data/provide_hass"; import { MockHomeAssistant } from "../../../src/fake_data/provide_hass";

View File

@ -4,7 +4,7 @@ import {
addMonths, addMonths,
differenceInHours, differenceInHours,
endOfDay, endOfDay,
} from "date-fns"; } from "date-fns/esm";
import { HassEntity } from "home-assistant-js-websocket"; import { HassEntity } from "home-assistant-js-websocket";
import { StatisticValue } from "../../../src/data/history"; import { StatisticValue } from "../../../src/data/history";
import { MockHomeAssistant } from "../../../src/fake_data/provide_hass"; import { MockHomeAssistant } from "../../../src/fake_data/provide_hass";

View File

@ -119,7 +119,7 @@ export const basicTrace: DemoTrace = {
params: { params: {
domain: "input_boolean", domain: "input_boolean",
service: "toggle", service: "toggle",
service_data: {}, data: {},
target: { target: {
entity_id: ["input_boolean.toggle_4"], entity_id: ["input_boolean.toggle_4"],
}, },
@ -164,7 +164,7 @@ export const basicTrace: DemoTrace = {
params: { params: {
domain: "input_boolean", domain: "input_boolean",
service: "toggle", service: "toggle",
service_data: {}, data: {},
target: { target: {
entity_id: ["input_boolean.toggle_2"], entity_id: ["input_boolean.toggle_2"],
}, },
@ -182,7 +182,7 @@ export const basicTrace: DemoTrace = {
params: { params: {
domain: "input_boolean", domain: "input_boolean",
service: "toggle", service: "toggle",
service_data: {}, data: {},
target: { target: {
entity_id: ["input_boolean.toggle_3"], entity_id: ["input_boolean.toggle_3"],
}, },
@ -200,7 +200,7 @@ export const basicTrace: DemoTrace = {
params: { params: {
domain: "input_boolean", domain: "input_boolean",
service: "toggle", service: "toggle",
service_data: {}, data: {},
target: { target: {
entity_id: ["input_boolean.toggle_4"], entity_id: ["input_boolean.toggle_4"],
}, },
@ -298,11 +298,11 @@ export const basicTrace: DemoTrace = {
source: "state of input_boolean.toggle_1", source: "state of input_boolean.toggle_1",
entity_id: "automation.toggle_toggles", entity_id: "automation.toggle_toggles",
context_id: "6cfcae368e7b3686fad6c59e83ae76c9", context_id: "6cfcae368e7b3686fad6c59e83ae76c9",
when: "2021-03-25T04:36:51.240832+00:00", when: 1616647011.240832,
domain: "automation", domain: "automation",
}, },
{ {
when: "2021-03-25T04:36:51.249828+00:00", when: 1616647011.249828,
name: "Toggle 4", name: "Toggle 4",
state: "on", state: "on",
entity_id: "input_boolean.toggle_4", entity_id: "input_boolean.toggle_4",
@ -313,7 +313,7 @@ export const basicTrace: DemoTrace = {
context_name: "Ensure Party mode", context_name: "Ensure Party mode",
}, },
{ {
when: "2021-03-25T04:36:51.258947+00:00", when: 1616647011.258947,
name: "Toggle 2", name: "Toggle 2",
state: "on", state: "on",
entity_id: "input_boolean.toggle_2", entity_id: "input_boolean.toggle_2",
@ -324,7 +324,7 @@ export const basicTrace: DemoTrace = {
context_name: "Ensure Party mode", context_name: "Ensure Party mode",
}, },
{ {
when: "2021-03-25T04:36:51.261806+00:00", when: 1616647011.261806,
name: "Toggle 3", name: "Toggle 3",
state: "off", state: "off",
entity_id: "input_boolean.toggle_3", entity_id: "input_boolean.toggle_3",
@ -335,7 +335,7 @@ export const basicTrace: DemoTrace = {
context_name: "Ensure Party mode", context_name: "Ensure Party mode",
}, },
{ {
when: "2021-03-25T04:36:51.265246+00:00", when: 1616647011.265246,
name: "Toggle 4", name: "Toggle 4",
state: "off", state: "off",
entity_id: "input_boolean.toggle_4", entity_id: "input_boolean.toggle_4",

View File

@ -185,11 +185,11 @@ export const motionLightTrace: DemoTrace = {
"has been triggered by state of binary_sensor.pauluss_macbook_pro_camera_in_use", "has been triggered by state of binary_sensor.pauluss_macbook_pro_camera_in_use",
source: "state of binary_sensor.pauluss_macbook_pro_camera_in_use", source: "state of binary_sensor.pauluss_macbook_pro_camera_in_use",
entity_id: "automation.auto_elgato", entity_id: "automation.auto_elgato",
when: "2021-03-14T06:07:01.768492+00:00", when: 1615702021.768492,
domain: "automation", domain: "automation",
}, },
{ {
when: "2021-03-14T06:07:01.872187+00:00", when: 1615702021.872187,
name: "Elgato Key Light Air", name: "Elgato Key Light Air",
state: "on", state: "on",
entity_id: "light.elgato_key_light_air", entity_id: "light.elgato_key_light_air",
@ -200,7 +200,7 @@ export const motionLightTrace: DemoTrace = {
context_name: "Auto Elgato", context_name: "Auto Elgato",
}, },
{ {
when: "2021-03-14T06:07:53.284505+00:00", when: 1615702073.284505,
name: "Elgato Key Light Air", name: "Elgato Key Light Air",
state: "off", state: "off",
entity_id: "light.elgato_key_light_air", entity_id: "light.elgato_key_light_air",

View File

@ -249,7 +249,7 @@ const CONFIGS = [
name: Bed light name: Bed light
action_name: Toggle light action_name: Toggle light
service: light.toggle service: light.toggle
service_data: data:
entity_id: light.bed_light entity_id: light.bed_light
- type: section - type: section
label: Links label: Links

View File

@ -199,7 +199,7 @@ const CONFIGS = [
tap_action: tap_action:
action: call-service action: call-service
service: light.turn_on service: light.turn_on
service_data: data:
entity_id: light.ceiling_lights entity_id: light.ceiling_lights
- entity: sun.sun - entity: sun.sun
name: Regular name: Regular

View File

@ -40,7 +40,7 @@ const CONFIGS = [
left: 90% left: 90%
padding: 0px padding: 0px
service: light.turn_off service: light.turn_off
service_data: data:
entity_id: group.all_lights entity_id: group.all_lights
- type: icon - type: icon
icon: mdi:cctv icon: mdi:cctv
@ -88,7 +88,7 @@ const CONFIGS = [
left: 90% left: 90%
padding: 0px padding: 0px
service: light.turn_off service: light.turn_off
service_data: data:
entity_id: group.all_lights entity_id: group.all_lights
- type: icon - type: icon
icon: mdi:cctv icon: mdi:cctv

View File

@ -17,7 +17,10 @@ import {
HassioAddonDetails, HassioAddonDetails,
} from "../../../src/data/hassio/addon"; } from "../../../src/data/hassio/addon";
import { extractApiErrorMessage } from "../../../src/data/hassio/common"; import { extractApiErrorMessage } from "../../../src/data/hassio/common";
import { setSupervisorOption } from "../../../src/data/hassio/supervisor"; import {
fetchHassioSupervisorInfo,
setSupervisorOption,
} from "../../../src/data/hassio/supervisor";
import { Supervisor } from "../../../src/data/supervisor/supervisor"; import { Supervisor } from "../../../src/data/supervisor/supervisor";
import { showConfirmationDialog } from "../../../src/dialogs/generic/show-dialog-box"; import { showConfirmationDialog } from "../../../src/dialogs/generic/show-dialog-box";
import "../../../src/layouts/hass-error-screen"; import "../../../src/layouts/hass-error-screen";
@ -169,38 +172,40 @@ class HassioAddonDashboard extends LitElement {
if (this.route.path === "") { if (this.route.path === "") {
const requestedAddon = extractSearchParam("addon"); const requestedAddon = extractSearchParam("addon");
const requestedAddonRepository = extractSearchParam("repository_url"); const requestedAddonRepository = extractSearchParam("repository_url");
if ( if (requestedAddonRepository) {
requestedAddonRepository && const supervisorInfo = await fetchHassioSupervisorInfo(this.hass);
!this.supervisor.supervisor.addons_repositories.find(
(repo) => repo === requestedAddonRepository
)
) {
if ( if (
!(await showConfirmationDialog(this, { !supervisorInfo.addons_repositories.find(
title: this.supervisor.localize("my.add_addon_repository_title"), (repo) => repo === requestedAddonRepository
text: this.supervisor.localize( )
"my.add_addon_repository_description",
{ addon: requestedAddon, repository: requestedAddonRepository }
),
confirmText: this.supervisor.localize("common.add"),
dismissText: this.supervisor.localize("common.cancel"),
}))
) { ) {
this._error = this.supervisor.localize( if (
"my.error_repository_not_found" !(await showConfirmationDialog(this, {
); title: this.supervisor.localize("my.add_addon_repository_title"),
return; text: this.supervisor.localize(
} "my.add_addon_repository_description",
{ addon: requestedAddon, repository: requestedAddonRepository }
),
confirmText: this.supervisor.localize("common.add"),
dismissText: this.supervisor.localize("common.cancel"),
}))
) {
this._error = this.supervisor.localize(
"my.error_repository_not_found"
);
return;
}
try { try {
await setSupervisorOption(this.hass, { await setSupervisorOption(this.hass, {
addons_repositories: [ addons_repositories: [
...this.supervisor.supervisor.addons_repositories, ...supervisorInfo.addons_repositories,
requestedAddonRepository, requestedAddonRepository,
], ],
}); });
} catch (err: any) { } catch (err: any) {
this._error = extractApiErrorMessage(err); this._error = extractApiErrorMessage(err);
}
} }
} }

View File

@ -72,8 +72,8 @@
"@material/mwc-textfield": "0.25.3", "@material/mwc-textfield": "0.25.3",
"@material/mwc-top-app-bar-fixed": "^0.25.3", "@material/mwc-top-app-bar-fixed": "^0.25.3",
"@material/top-app-bar": "14.0.0-canary.261f2db59.0", "@material/top-app-bar": "14.0.0-canary.261f2db59.0",
"@mdi/js": "6.6.95", "@mdi/js": "6.7.96",
"@mdi/svg": "6.6.95", "@mdi/svg": "6.7.96",
"@polymer/app-layout": "^3.1.0", "@polymer/app-layout": "^3.1.0",
"@polymer/iron-flex-layout": "^3.0.1", "@polymer/iron-flex-layout": "^3.0.1",
"@polymer/iron-icon": "^3.0.1", "@polymer/iron-icon": "^3.0.1",
@ -89,8 +89,8 @@
"@polymer/paper-tooltip": "^3.0.1", "@polymer/paper-tooltip": "^3.0.1",
"@polymer/polymer": "3.4.1", "@polymer/polymer": "3.4.1",
"@thomasloven/round-slider": "0.5.4", "@thomasloven/round-slider": "0.5.4",
"@vaadin/combo-box": "^22.0.4", "@vaadin/combo-box": "^23.0.10",
"@vaadin/vaadin-themable-mixin": "^22.0.4", "@vaadin/vaadin-themable-mixin": "^23.0.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",
@ -108,7 +108,7 @@
"fuse.js": "^6.0.0", "fuse.js": "^6.0.0",
"google-timezones-json": "^1.0.2", "google-timezones-json": "^1.0.2",
"hls.js": "^1.1.5", "hls.js": "^1.1.5",
"home-assistant-js-websocket": "^7.0.3", "home-assistant-js-websocket": "^7.1.0",
"idb-keyval": "^5.1.3", "idb-keyval": "^5.1.3",
"intl-messageformat": "^9.9.1", "intl-messageformat": "^9.9.1",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",

View File

@ -1,3 +1,30 @@
[build-system] [build-system]
requires = ["setuptools~=60.5", "wheel~=0.37.1"] requires = ["setuptools~=62.3", "wheel~=0.37.1"]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20220526.0"
license = {text = "Apache-2.0"}
description = "The Home Assistant frontend"
readme = "README.md"
authors = [
{name = "The Home Assistant Authors", email = "hello@home-assistant.io"}
]
requires-python = ">=3.4.0"
[project.urls]
"Homepage" = "https://github.com/home-assistant/frontend"
[tool.setuptools]
platforms = ["any"]
zip-safe = false
include-package-data = true
[tool.setuptools.packages.find]
include = ["hass_frontend*"]
[tool.mypy]
python_version = 3.4
show_error_codes = true
strict = true

View File

@ -50,14 +50,14 @@ async function main(args) {
return; return;
} }
const setup = fs.readFileSync("setup.cfg", "utf8"); const setup = fs.readFileSync("pyproject.toml", "utf8");
const version = setup.match(/\d{8}\.\d+/)[0]; const version = setup.match(/version\W+=\W"(\d{8}\.\d)"/)[1];
const newVersion = method(version); const newVersion = method(version);
console.log("Current version:", version); console.log("Current version:", version);
console.log("New version:", newVersion); console.log("New version:", newVersion);
fs.writeFileSync("setup.cfg", setup.replace(version, newVersion), "utf-8"); fs.writeFileSync("pyproject.toml", setup.replace(version, newVersion), "utf-8");
if (!commit) { if (!commit) {
return; return;

View File

@ -1,26 +0,0 @@
[metadata]
name = home-assistant-frontend
version = 20220504.1
author = The Home Assistant Authors
author_email = hello@home-assistant.io
license = Apache-2.0
platforms = any
description = The Home Assistant frontend
long_description = file: README.md
long_description_content_type = text/markdown
url = https://github.com/home-assistant/frontend
[options]
packages = find:
zip_safe = False
include_package_data = True
python_requires = >= 3.4.0
[options.packages.find]
include =
hass_frontend*
[mypy]
python_version = 3.4
show_error_codes = True
strict = True

View File

@ -0,0 +1,41 @@
const DEFAULT_OWN = true;
// Finds the closest ancestor of an element that has a specific optionally owned property,
// traversing slot and shadow root boundaries until the body element is reached
export const closestWithProperty = (
element: Element | null,
property: string | symbol,
own = DEFAULT_OWN
) => {
if (!element || element === document.body) return null;
element = element.assignedSlot ?? element;
if (element.parentElement) {
element = element.parentElement;
} else {
const root = element.getRootNode();
element = root instanceof ShadowRoot ? root.host : null;
}
if (
own
? Object.prototype.hasOwnProperty.call(element, property)
: element && property in element
)
return element;
return closestWithProperty(element, property, own);
};
// Finds the set of all such ancestors and includes starting element as first in the set
export const ancestorsWithProperty = (
element: Element | null,
property: string | symbol,
own = DEFAULT_OWN
) => {
const ancestors: Set<Element> = new Set();
while (element) {
ancestors.add(element);
element = closestWithProperty(element, property, own);
}
return ancestors;
};

View File

@ -1,6 +1,11 @@
import { HassEntity } from "home-assistant-js-websocket"; import { HassEntity } from "home-assistant-js-websocket";
import { UNAVAILABLE_STATES } from "../../data/entity";
export const computeActiveState = (stateObj: HassEntity): string => { export const computeActiveState = (stateObj: HassEntity): string => {
if (UNAVAILABLE_STATES.includes(stateObj.state)) {
return stateObj.state;
}
const domain = stateObj.entity_id.split(".")[0]; const domain = stateObj.entity_id.split(".")[0];
let state = stateObj.state; let state = stateObj.state;

View File

@ -2,67 +2,74 @@ import { HassEntity } from "home-assistant-js-websocket";
import { UNAVAILABLE, UNKNOWN } from "../../data/entity"; import { UNAVAILABLE, UNKNOWN } from "../../data/entity";
import { FrontendLocaleData } from "../../data/translation"; import { FrontendLocaleData } from "../../data/translation";
import { import {
updateIsInstalling,
UpdateEntity,
UPDATE_SUPPORT_PROGRESS, UPDATE_SUPPORT_PROGRESS,
updateIsInstallingFromAttributes,
} from "../../data/update"; } from "../../data/update";
import { formatDate } from "../datetime/format_date"; import { formatDate } from "../datetime/format_date";
import { formatDateTime } from "../datetime/format_date_time"; import { formatDateTime } from "../datetime/format_date_time";
import { formatTime } from "../datetime/format_time"; import { formatTime } from "../datetime/format_time";
import { formatNumber, isNumericState } from "../number/format_number"; import { formatNumber, isNumericFromAttributes } from "../number/format_number";
import { LocalizeFunc } from "../translations/localize"; import { LocalizeFunc } from "../translations/localize";
import { computeStateDomain } from "./compute_state_domain"; import { supportsFeatureFromAttributes } from "./supports-feature";
import { supportsFeature } from "./supports-feature";
import { formatDuration, UNIT_TO_SECOND_CONVERT } from "../datetime/duration"; import { formatDuration, UNIT_TO_SECOND_CONVERT } from "../datetime/duration";
import { computeDomain } from "./compute_domain";
export const computeStateDisplay = ( export const computeStateDisplay = (
localize: LocalizeFunc, localize: LocalizeFunc,
stateObj: HassEntity, stateObj: HassEntity,
locale: FrontendLocaleData, locale: FrontendLocaleData,
state?: string state?: string
): string => { ): string =>
const compareState = state !== undefined ? state : stateObj.state; computeStateDisplayFromEntityAttributes(
localize,
locale,
stateObj.entity_id,
stateObj.attributes,
state !== undefined ? state : stateObj.state
);
if (compareState === UNKNOWN || compareState === UNAVAILABLE) { export const computeStateDisplayFromEntityAttributes = (
return localize(`state.default.${compareState}`); localize: LocalizeFunc,
locale: FrontendLocaleData,
entityId: string,
attributes: any,
state: string
): string => {
if (state === UNKNOWN || state === UNAVAILABLE) {
return localize(`state.default.${state}`);
} }
// Entities with a `unit_of_measurement` or `state_class` are numeric values and should use `formatNumber` // Entities with a `unit_of_measurement` or `state_class` are numeric values and should use `formatNumber`
if (isNumericState(stateObj)) { if (isNumericFromAttributes(attributes)) {
// state is duration // state is duration
if ( if (
stateObj.attributes.device_class === "duration" && attributes.device_class === "duration" &&
stateObj.attributes.unit_of_measurement && attributes.unit_of_measurement &&
UNIT_TO_SECOND_CONVERT[stateObj.attributes.unit_of_measurement] UNIT_TO_SECOND_CONVERT[attributes.unit_of_measurement]
) { ) {
try { try {
return formatDuration( return formatDuration(state, attributes.unit_of_measurement);
compareState,
stateObj.attributes.unit_of_measurement
);
} catch (_err) { } catch (_err) {
// fallback to default // fallback to default
} }
} }
if (stateObj.attributes.device_class === "monetary") { if (attributes.device_class === "monetary") {
try { try {
return formatNumber(compareState, locale, { return formatNumber(state, locale, {
style: "currency", style: "currency",
currency: stateObj.attributes.unit_of_measurement, currency: attributes.unit_of_measurement,
minimumFractionDigits: 2, minimumFractionDigits: 2,
}); });
} catch (_err) { } catch (_err) {
// fallback to default // fallback to default
} }
} }
return `${formatNumber(compareState, locale)}${ return `${formatNumber(state, locale)}${
stateObj.attributes.unit_of_measurement attributes.unit_of_measurement ? " " + attributes.unit_of_measurement : ""
? " " + stateObj.attributes.unit_of_measurement
: ""
}`; }`;
} }
const domain = computeStateDomain(stateObj); const domain = computeDomain(entityId);
if (domain === "input_datetime") { if (domain === "input_datetime") {
if (state !== undefined) { if (state !== undefined) {
@ -97,36 +104,32 @@ export const computeStateDisplay = (
} else { } else {
// If not trying to display an explicit state, create `Date` object from `stateObj`'s attributes then format. // If not trying to display an explicit state, create `Date` object from `stateObj`'s attributes then format.
let date: Date; let date: Date;
if (stateObj.attributes.has_date && stateObj.attributes.has_time) { if (attributes.has_date && attributes.has_time) {
date = new Date( date = new Date(
stateObj.attributes.year, attributes.year,
stateObj.attributes.month - 1, attributes.month - 1,
stateObj.attributes.day, attributes.day,
stateObj.attributes.hour, attributes.hour,
stateObj.attributes.minute attributes.minute
); );
return formatDateTime(date, locale); return formatDateTime(date, locale);
} }
if (stateObj.attributes.has_date) { if (attributes.has_date) {
date = new Date( date = new Date(attributes.year, attributes.month - 1, attributes.day);
stateObj.attributes.year,
stateObj.attributes.month - 1,
stateObj.attributes.day
);
return formatDate(date, locale); return formatDate(date, locale);
} }
if (stateObj.attributes.has_time) { if (attributes.has_time) {
date = new Date(); date = new Date();
date.setHours(stateObj.attributes.hour, stateObj.attributes.minute); date.setHours(attributes.hour, attributes.minute);
return formatTime(date, locale); return formatTime(date, locale);
} }
return stateObj.state; return state;
} }
} }
if (domain === "humidifier") { if (domain === "humidifier") {
if (compareState === "on" && stateObj.attributes.humidity) { if (state === "on" && attributes.humidity) {
return `${stateObj.attributes.humidity} %`; return `${attributes.humidity} %`;
} }
} }
@ -136,7 +139,7 @@ export const computeStateDisplay = (
domain === "number" || domain === "number" ||
domain === "input_number" domain === "input_number"
) { ) {
return formatNumber(compareState, locale); return formatNumber(state, locale);
} }
// state of button is a timestamp // state of button is a timestamp
@ -144,12 +147,12 @@ export const computeStateDisplay = (
domain === "button" || domain === "button" ||
domain === "input_button" || domain === "input_button" ||
domain === "scene" || domain === "scene" ||
(domain === "sensor" && stateObj.attributes.device_class === "timestamp") (domain === "sensor" && attributes.device_class === "timestamp")
) { ) {
try { try {
return formatDateTime(new Date(compareState), locale); return formatDateTime(new Date(state), locale);
} catch (_err) { } catch (_err) {
return compareState; return state;
} }
} }
@ -160,30 +163,28 @@ export const computeStateDisplay = (
// When the latest version is skipped, show the latest version // When the latest version is skipped, show the latest version
// When update is not available, show "Up-to-date" // When update is not available, show "Up-to-date"
// When update is not available and there is no latest_version show "Unavailable" // When update is not available and there is no latest_version show "Unavailable"
return compareState === "on" return state === "on"
? updateIsInstalling(stateObj as UpdateEntity) ? updateIsInstallingFromAttributes(attributes)
? supportsFeature(stateObj, UPDATE_SUPPORT_PROGRESS) ? supportsFeatureFromAttributes(attributes, UPDATE_SUPPORT_PROGRESS)
? localize("ui.card.update.installing_with_progress", { ? localize("ui.card.update.installing_with_progress", {
progress: stateObj.attributes.in_progress, progress: attributes.in_progress,
}) })
: localize("ui.card.update.installing") : localize("ui.card.update.installing")
: stateObj.attributes.latest_version : attributes.latest_version
: stateObj.attributes.skipped_version === : attributes.skipped_version === attributes.latest_version
stateObj.attributes.latest_version ? attributes.latest_version ?? localize("state.default.unavailable")
? stateObj.attributes.latest_version ??
localize("state.default.unavailable")
: localize("ui.card.update.up_to_date"); : localize("ui.card.update.up_to_date");
} }
return ( return (
// Return device class translation // Return device class translation
(stateObj.attributes.device_class && (attributes.device_class &&
localize( localize(
`component.${domain}.state.${stateObj.attributes.device_class}.${compareState}` `component.${domain}.state.${attributes.device_class}.${state}`
)) || )) ||
// Return default translation // Return default translation
localize(`component.${domain}.state._.${compareState}`) || localize(`component.${domain}.state._.${state}`) ||
// We don't know! Return the raw state. // We don't know! Return the raw state.
compareState state
); );
}; };

View File

@ -1,7 +1,13 @@
import { HassEntity } from "home-assistant-js-websocket"; import { HassEntity } from "home-assistant-js-websocket";
import { computeObjectId } from "./compute_object_id"; import { computeObjectId } from "./compute_object_id";
export const computeStateNameFromEntityAttributes = (
entityId: string,
attributes: { [key: string]: any }
): string =>
attributes.friendly_name === undefined
? computeObjectId(entityId).replace(/_/g, " ")
: attributes.friendly_name || "";
export const computeStateName = (stateObj: HassEntity): string => export const computeStateName = (stateObj: HassEntity): string =>
stateObj.attributes.friendly_name === undefined computeStateNameFromEntityAttributes(stateObj.entity_id, stateObj.attributes);
? computeObjectId(stateObj.entity_id).replace(/_/g, " ")
: stateObj.attributes.friendly_name || "";

View File

@ -29,7 +29,8 @@ import {
mdiWeatherNight, mdiWeatherNight,
} from "@mdi/js"; } from "@mdi/js";
import { HassEntity } from "home-assistant-js-websocket"; import { HassEntity } from "home-assistant-js-websocket";
import { updateIsInstalling, UpdateEntity } from "../../data/update"; import { UpdateEntity, updateIsInstalling } from "../../data/update";
import { weatherIcon } from "../../data/weather";
/** /**
* Return the icon to be used for a domain. * Return the icon to be used for a domain.
* *
@ -46,6 +47,20 @@ export const domainIcon = (
stateObj?: HassEntity, stateObj?: HassEntity,
state?: string state?: string
): string => { ): string => {
const icon = domainIconWithoutDefault(domain, stateObj, state);
if (icon) {
return icon;
}
// eslint-disable-next-line
console.warn(`Unable to find icon for domain ${domain}`);
return DEFAULT_DOMAIN_ICON;
};
export const domainIconWithoutDefault = (
domain: string,
stateObj?: HassEntity,
state?: string
): string | undefined => {
const compareState = state !== undefined ? state : stateObj?.state; const compareState = state !== undefined ? state : stateObj?.state;
switch (domain) { switch (domain) {
@ -87,6 +102,15 @@ export const domainIcon = (
? mdiCheckCircleOutline ? mdiCheckCircleOutline
: mdiCloseCircleOutline; : mdiCloseCircleOutline;
case "input_datetime":
if (!stateObj?.attributes.has_date) {
return mdiClock;
}
if (!stateObj.attributes.has_time) {
return mdiCalendar;
}
break;
case "lock": case "lock":
switch (compareState) { switch (compareState) {
case "unlocked": case "unlocked":
@ -124,15 +148,6 @@ export const domainIcon = (
break; break;
} }
case "input_datetime":
if (!stateObj?.attributes.has_date) {
return mdiClock;
}
if (!stateObj.attributes.has_time) {
return mdiCalendar;
}
break;
case "sun": case "sun":
return stateObj?.state === "above_horizon" return stateObj?.state === "above_horizon"
? FIXED_DOMAIN_ICONS[domain] ? FIXED_DOMAIN_ICONS[domain]
@ -144,13 +159,14 @@ export const domainIcon = (
? mdiPackageDown ? mdiPackageDown
: mdiPackageUp : mdiPackageUp
: mdiPackage; : mdiPackage;
case "weather":
return weatherIcon(stateObj?.state);
} }
if (domain in FIXED_DOMAIN_ICONS) { if (domain in FIXED_DOMAIN_ICONS) {
return FIXED_DOMAIN_ICONS[domain]; return FIXED_DOMAIN_ICONS[domain];
} }
// eslint-disable-next-line return undefined;
console.warn(`Unable to find icon for domain ${domain}`);
return DEFAULT_DOMAIN_ICON;
}; };

View File

@ -3,6 +3,13 @@ import { HassEntity } from "home-assistant-js-websocket";
export const supportsFeature = ( export const supportsFeature = (
stateObj: HassEntity, stateObj: HassEntity,
feature: number feature: number
): boolean => supportsFeatureFromAttributes(stateObj.attributes, feature);
export const supportsFeatureFromAttributes = (
attributes: {
[key: string]: any;
},
feature: number
): boolean => ): boolean =>
// eslint-disable-next-line no-bitwise // eslint-disable-next-line no-bitwise
(stateObj.attributes.supported_features! & feature) !== 0; (attributes.supported_features! & feature) !== 0;

View File

@ -7,8 +7,11 @@ import { round } from "./round";
* @param stateObj The entity state object * @param stateObj The entity state object
*/ */
export const isNumericState = (stateObj: HassEntity): boolean => export const isNumericState = (stateObj: HassEntity): boolean =>
!!stateObj.attributes.unit_of_measurement || isNumericFromAttributes(stateObj.attributes);
!!stateObj.attributes.state_class;
export const isNumericFromAttributes = (attributes: {
[key: string]: any;
}): boolean => !!attributes.unit_of_measurement || !!attributes.state_class;
export const numberFormatToLocale = ( export const numberFormatToLocale = (
localeOptions: FrontendLocaleData localeOptions: FrontendLocaleData

View File

@ -13,7 +13,7 @@ export const throttle = <T extends any[]>(
) => { ) => {
let timeout: number | undefined; let timeout: number | undefined;
let previous = 0; let previous = 0;
return (...args: T): void => { const throttledFunc = (...args: T): void => {
const later = () => { const later = () => {
previous = leading === false ? 0 : Date.now(); previous = leading === false ? 0 : Date.now();
timeout = undefined; timeout = undefined;
@ -35,4 +35,10 @@ export const throttle = <T extends any[]>(
timeout = window.setTimeout(later, remaining); timeout = window.setTimeout(later, remaining);
} }
}; };
throttledFunc.cancel = () => {
clearTimeout(timeout);
timeout = undefined;
previous = 0;
};
return throttledFunc;
}; };

View File

@ -34,7 +34,7 @@ import {
endOfMonth, endOfMonth,
endOfQuarter, endOfQuarter,
endOfYear, endOfYear,
} from "date-fns"; } from "date-fns/esm";
import { import {
formatDate, formatDate,
formatDateMonth, formatDateMonth,

View File

@ -269,8 +269,8 @@ export class HaDataTable extends LitElement {
@change=${this._handleHeaderRowCheckboxClick} @change=${this._handleHeaderRowCheckboxClick}
.indeterminate=${this._checkedRows.length && .indeterminate=${this._checkedRows.length &&
this._checkedRows.length !== this._checkableRowsCount} this._checkedRows.length !== this._checkableRowsCount}
.checked=${this._checkedRows.length === .checked=${this._checkedRows.length &&
this._checkableRowsCount} this._checkedRows.length === this._checkableRowsCount}
> >
</ha-checkbox> </ha-checkbox>
</div> </div>

View File

@ -20,7 +20,7 @@ interface HassEntityWithCachedName extends HassEntity {
friendly_name: string; friendly_name: string;
} }
export type HaEntityPickerEntityFilterFunc = (entityId: HassEntity) => boolean; export type HaEntityPickerEntityFilterFunc = (entity: HassEntity) => boolean;
// eslint-disable-next-line lit/prefer-static-styles // eslint-disable-next-line lit/prefer-static-styles
const rowRenderer: ComboBoxLitRenderer<HassEntityWithCachedName> = (item) => const rowRenderer: ComboBoxLitRenderer<HassEntityWithCachedName> = (item) =>
@ -31,6 +31,7 @@ const rowRenderer: ComboBoxLitRenderer<HassEntityWithCachedName> = (item) =>
<span>${item.friendly_name}</span> <span>${item.friendly_name}</span>
<span slot="secondary">${item.entity_id}</span> <span slot="secondary">${item.entity_id}</span>
</mwc-list-item>`; </mwc-list-item>`;
@customElement("ha-entity-picker") @customElement("ha-entity-picker")
export class HaEntityPicker extends LitElement { export class HaEntityPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;

View File

@ -1,17 +1,22 @@
import type { Button } from "@material/mwc-button";
import "@material/mwc-menu"; import "@material/mwc-menu";
import type { Corner, Menu, MenuCorner } from "@material/mwc-menu"; import type { Corner, Menu, MenuCorner } from "@material/mwc-menu";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, query } from "lit/decorators"; import { customElement, property, query } from "lit/decorators";
import { FOCUS_TARGET } from "../dialogs/make-dialog-manager";
import type { HaIconButton } from "./ha-icon-button";
@customElement("ha-button-menu") @customElement("ha-button-menu")
export class HaButtonMenu extends LitElement { export class HaButtonMenu extends LitElement {
protected readonly [FOCUS_TARGET];
@property() public corner: Corner = "TOP_START"; @property() public corner: Corner = "TOP_START";
@property() public menuCorner: MenuCorner = "START"; @property() public menuCorner: MenuCorner = "START";
@property({ type: Number }) public x?: number; @property({ type: Number }) public x: number | null = null;
@property({ type: Number }) public y?: number; @property({ type: Number }) public y: number | null = null;
@property({ type: Boolean }) public multi = false; @property({ type: Boolean }) public multi = false;
@ -31,10 +36,18 @@ export class HaButtonMenu extends LitElement {
return this._menu?.selected; return this._menu?.selected;
} }
public override focus() {
if (this._menu?.open) {
this._menu.focusItemAtIndex(0);
} else {
this._triggerButton?.focus();
}
}
protected render(): TemplateResult { protected render(): TemplateResult {
return html` return html`
<div @click=${this._handleClick}> <div @click=${this._handleClick}>
<slot name="trigger"></slot> <slot name="trigger" @slotchange=${this._setTriggerAria}></slot>
</div> </div>
<mwc-menu <mwc-menu
.corner=${this.corner} .corner=${this.corner}
@ -50,6 +63,21 @@ export class HaButtonMenu extends LitElement {
`; `;
} }
protected firstUpdated(changedProps): void {
super.firstUpdated(changedProps);
if (document.dir === "rtl") {
this.updateComplete.then(() => {
this.querySelectorAll("mwc-list-item").forEach((item) => {
const style = document.createElement("style");
style.innerHTML =
"span.material-icons:first-of-type { margin-left: var(--mdc-list-item-graphic-margin, 32px) !important; margin-right: 0px !important;}";
item!.shadowRoot!.appendChild(style);
});
});
}
}
private _handleClick(): void { private _handleClick(): void {
if (this.disabled) { if (this.disabled) {
return; return;
@ -58,6 +86,18 @@ export class HaButtonMenu extends LitElement {
this._menu!.show(); this._menu!.show();
} }
private get _triggerButton() {
return this.querySelector(
'ha-icon-button[slot="trigger"], mwc-button[slot="trigger"]'
) as HaIconButton | Button | null;
}
private _setTriggerAria() {
if (this._triggerButton) {
this._triggerButton.ariaHasPopup = "menu";
}
}
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return css` return css`
:host { :host {

View File

@ -66,9 +66,13 @@ export class HaChip extends LitElement {
line-height: 14px; line-height: 14px;
color: var(--ha-chip-icon-color, var(--ha-chip-text-color)); color: var(--ha-chip-icon-color, var(--ha-chip-text-color));
} }
.mdc-chip.mdc-chip--selected .mdc-chip__checkmark,
.mdc-chip.no-text .mdc-chip.no-text
.mdc-chip__icon--leading:not(.mdc-chip__icon--leading-hidden) { .mdc-chip__icon--leading:not(.mdc-chip__icon--leading-hidden) {
margin-right: -4px; margin-right: -4px;
margin-inline-start: -4px;
margin-inline-end: 4px;
direction: var(--direction);
} }
span[role="gridcell"] { span[role="gridcell"] {

View File

@ -47,10 +47,6 @@ export class HaClickableListItem extends ListItemBase {
padding-left: 0px; padding-left: 0px;
padding-right: 0px; padding-right: 0px;
} }
:host([rtl]) span {
margin-left: var(--mdc-list-item-graphic-margin, 20px) !important;
margin-right: 0px !important;
}
:host([graphic="avatar"]:not([twoLine])), :host([graphic="avatar"]:not([twoLine])),
:host([graphic="icon"]:not([twoLine])) { :host([graphic="icon"]:not([twoLine])) {
height: 48px; height: 48px;
@ -64,6 +60,19 @@ export class HaClickableListItem extends ListItemBase {
padding-right: var(--mdc-list-side-padding, 20px); padding-right: var(--mdc-list-side-padding, 20px);
overflow: hidden; overflow: hidden;
} }
span.material-icons:first-of-type {
margin-inline-start: 0px !important;
margin-inline-end: var(
--mdc-list-item-graphic-margin,
16px
) !important;
direction: var(--direction);
}
span.material-icons:last-of-type {
margin-inline-start: auto !important;
margin-inline-end: 0px !important;
direction: var(--direction);
}
`, `,
]; ];
} }

View File

@ -3,6 +3,7 @@ import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property } from "lit/decorators";
import { formatNumber } from "../common/number/format_number"; import { formatNumber } from "../common/number/format_number";
import { CLIMATE_PRESET_NONE } from "../data/climate"; import { CLIMATE_PRESET_NONE } from "../data/climate";
import { UNAVAILABLE_STATES } from "../data/entity";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
@customElement("ha-climate-state") @customElement("ha-climate-state")
@ -15,22 +16,22 @@ class HaClimateState extends LitElement {
const currentStatus = this._computeCurrentStatus(); const currentStatus = this._computeCurrentStatus();
return html`<div class="target"> return html`<div class="target">
${this.stateObj.state !== "unknown" ${!UNAVAILABLE_STATES.includes(this.stateObj.state)
? html`<span class="state-label"> ? html`<span class="state-label">
${this._localizeState()} ${this._localizeState()}
${this.stateObj.attributes.preset_mode && ${this.stateObj.attributes.preset_mode &&
this.stateObj.attributes.preset_mode !== CLIMATE_PRESET_NONE this.stateObj.attributes.preset_mode !== CLIMATE_PRESET_NONE
? html`- ? html`-
${this.hass.localize( ${this.hass.localize(
`state_attributes.climate.preset_mode.${this.stateObj.attributes.preset_mode}` `state_attributes.climate.preset_mode.${this.stateObj.attributes.preset_mode}`
) || this.stateObj.attributes.preset_mode}` ) || this.stateObj.attributes.preset_mode}`
: ""} : ""}
</span>` </span>
: ""} <div class="unit">${this._computeTarget()}</div>`
<div class="unit">${this._computeTarget()}</div> : this._localizeState()}
</div> </div>
${currentStatus ${currentStatus && !UNAVAILABLE_STATES.includes(this.stateObj.state)
? html`<div class="current"> ? html`<div class="current">
${this.hass.localize("ui.card.climate.currently")}: ${this.hass.localize("ui.card.climate.currently")}:
<div class="unit">${currentStatus}</div> <div class="unit">${currentStatus}</div>
@ -108,6 +109,10 @@ class HaClimateState extends LitElement {
} }
private _localizeState(): string { private _localizeState(): string {
if (UNAVAILABLE_STATES.includes(this.stateObj.state)) {
return this.hass.localize(`state.default.${this.stateObj.state}`);
}
const stateString = this.hass.localize( const stateString = this.hass.localize(
`component.climate.state._.${this.stateObj.state}` `component.climate.state._.${this.stateObj.state}`
); );

View File

@ -96,6 +96,8 @@ export class HaComboBox extends LitElement {
@query("vaadin-combo-box-light", true) private _comboBox!: ComboBoxLight; @query("vaadin-combo-box-light", true) private _comboBox!: ComboBoxLight;
private _overlayMutationObserver?: MutationObserver;
public open() { public open() {
this.updateComplete.then(() => { this.updateComplete.then(() => {
this._comboBox?.open(); this._comboBox?.open();
@ -108,6 +110,14 @@ export class HaComboBox extends LitElement {
}); });
} }
public disconnectedCallback() {
super.disconnectedCallback();
if (this._overlayMutationObserver) {
this._overlayMutationObserver.disconnect();
this._overlayMutationObserver = undefined;
}
}
public get selectedItem() { public get selectedItem() {
return this._comboBox.selectedItem; return this._comboBox.selectedItem;
} }
@ -194,12 +204,57 @@ export class HaComboBox extends LitElement {
} }
private _openedChanged(ev: PolymerChangedEvent<boolean>) { private _openedChanged(ev: PolymerChangedEvent<boolean>) {
const opened = ev.detail.value;
// delay this so we can handle click event before setting _opened // delay this so we can handle click event before setting _opened
setTimeout(() => { setTimeout(() => {
this._opened = ev.detail.value; this._opened = opened;
}, 0); }, 0);
// @ts-ignore // @ts-ignore
fireEvent(this, ev.type, ev.detail); fireEvent(this, ev.type, ev.detail);
if (
opened &&
"MutationObserver" in window &&
!this._overlayMutationObserver
) {
const overlay = document.querySelector<HTMLElement>(
"vaadin-combo-box-overlay"
);
if (!overlay) {
return;
}
this._overlayMutationObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (
mutation.type === "attributes" &&
mutation.attributeName === "inert" &&
// @ts-expect-error
overlay.inert === true
) {
// @ts-expect-error
overlay.inert = false;
this._overlayMutationObserver?.disconnect();
this._overlayMutationObserver = undefined;
} else if (mutation.type === "childList") {
mutation.removedNodes.forEach((node) => {
if (node.nodeName === "VAADIN-COMBO-BOX-OVERLAY") {
this._overlayMutationObserver?.disconnect();
this._overlayMutationObserver = undefined;
}
});
}
});
});
this._overlayMutationObserver.observe(overlay, {
attributes: true,
});
this._overlayMutationObserver.observe(document.body, {
childList: true,
});
}
} }
private _filterChanged(ev: PolymerChangedEvent<string>) { private _filterChanged(ev: PolymerChangedEvent<string>) {
@ -241,6 +296,9 @@ export class HaComboBox extends LitElement {
.toggle-button { .toggle-button {
right: 12px; right: 12px;
top: -10px; top: -10px;
inset-inline-start: initial;
inset-inline-end: 12px;
direction: var(--direction);
} }
:host([opened]) .toggle-button { :host([opened]) .toggle-button {
color: var(--primary-color); color: var(--primary-color);
@ -249,18 +307,9 @@ export class HaComboBox extends LitElement {
--mdc-icon-size: 20px; --mdc-icon-size: 20px;
top: -7px; top: -7px;
right: 36px; right: 36px;
} inset-inline-start: initial;
inset-inline-end: 36px;
:host-context([style*="direction: rtl;"]) .toggle-button { direction: var(--direction);
left: 12px;
right: auto;
top: -10px;
}
:host-context([style*="direction: rtl;"]) .clear-button {
--mdc-icon-size: 20px;
top: -7px;
left: 36px;
right: auto;
} }
`; `;
} }

View File

@ -140,6 +140,9 @@ export class HaDateRangePicker extends LitElement {
return css` return css`
ha-svg-icon { ha-svg-icon {
margin-right: 8px; margin-right: 8px;
margin-inline-end: 8px;
margin-inline-start: initial;
direction: var(--direction);
} }
.date-range-inputs { .date-range-inputs {
@ -166,6 +169,9 @@ export class HaDateRangePicker extends LitElement {
ha-textfield:last-child { ha-textfield:last-child {
margin-left: 8px; margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: initial;
direction: var(--direction);
} }
@media only screen and (max-width: 800px) { @media only screen and (max-width: 800px) {

View File

@ -3,8 +3,8 @@ import { styles } from "@material/mwc-dialog/mwc-dialog.css";
import { mdiClose } from "@mdi/js"; import { mdiClose } from "@mdi/js";
import { css, html, TemplateResult } from "lit"; import { css, html, TemplateResult } from "lit";
import { customElement } from "lit/decorators"; import { customElement } from "lit/decorators";
import { computeRTLDirection } from "../common/util/compute_rtl";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
import { FOCUS_TARGET } from "../dialogs/make-dialog-manager";
import "./ha-icon-button"; import "./ha-icon-button";
export const createCloseHeading = ( export const createCloseHeading = (
@ -17,12 +17,13 @@ export const createCloseHeading = (
.path=${mdiClose} .path=${mdiClose}
dialogAction="close" dialogAction="close"
class="header_button" class="header_button"
dir=${computeRTLDirection(hass)}
></ha-icon-button> ></ha-icon-button>
`; `;
@customElement("ha-dialog") @customElement("ha-dialog")
export class HaDialog extends DialogBase { export class HaDialog extends DialogBase {
protected readonly [FOCUS_TARGET];
public scrollToPos(x: number, y: number) { public scrollToPos(x: number, y: number) {
this.contentElement?.scrollTo(x, y); this.contentElement?.scrollTo(x, y);
} }
@ -89,18 +90,18 @@ export class HaDialog extends DialogBase {
} }
.header_title { .header_title {
margin-right: 40px; margin-right: 40px;
margin-inline-end: 40px;
direction: var(--direction);
} }
[dir="rtl"].header_button { .header_button {
right: auto; inset-inline-start: initial;
left: 16px; inset-inline-end: 16px;
direction: var(--direction);
} }
[dir="rtl"].header_title { .dialog-actions {
margin-left: 40px; inset-inline-start: initial !important;
margin-right: 0px; inset-inline-end: 0px !important;
} direction: var(--direction);
:host-context([style*="direction: rtl;"]) .dialog-actions {
left: 0px !important;
right: auto !important;
} }
`, `,
]; ];

View File

@ -133,6 +133,9 @@ class HaExpansionPanel extends LitElement {
.summary-icon { .summary-icon {
transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1); transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
margin-left: auto; margin-left: auto;
margin-inline-start: auto;
margin-inline-end: initial;
direction: var(--direction);
} }
.summary-icon.expanded { .summary-icon.expanded {

View File

@ -1,24 +1,25 @@
import { Fab } from "@material/mwc-fab"; import { FabBase } from "@material/mwc-fab/mwc-fab-base";
import { styles } from "@material/mwc-fab/mwc-fab.css";
import { customElement } from "lit/decorators"; import { customElement } from "lit/decorators";
import { css } from "lit"; import { css } from "lit";
@customElement("ha-fab") @customElement("ha-fab")
export class HaFab extends Fab { export class HaFab extends FabBase {
protected firstUpdated(changedProperties) { protected firstUpdated(changedProperties) {
super.firstUpdated(changedProperties); super.firstUpdated(changedProperties);
this.style.setProperty("--mdc-theme-secondary", "var(--primary-color)"); this.style.setProperty("--mdc-theme-secondary", "var(--primary-color)");
} }
static override styles = Fab.styles.concat([ static override styles = [
styles,
css` css`
:host-context([style*="direction: rtl;"]) :host .mdc-fab--extended .mdc-fab__icon {
.mdc-fab--extended margin-inline-start: -8px;
.mdc-fab__icon { margin-inline-end: 12px;
margin-left: 12px !important; direction: var(--direction);
margin-right: calc(12px - 20px) !important;
} }
`, `,
]); ];
} }
declare global { declare global {

View File

@ -175,24 +175,23 @@ export class HaFileUpload extends LitElement {
} }
.mdc-text-field__icon--leading { .mdc-text-field__icon--leading {
margin-bottom: 12px; margin-bottom: 12px;
} inset-inline-start: initial;
:host-context([style*="direction: rtl;"]) inset-inline-end: 0px;
.mdc-text-field__icon--leading { direction: var(--direction);
margin-right: 0px;
} }
.mdc-text-field--filled .mdc-floating-label--float-above { .mdc-text-field--filled .mdc-floating-label--float-above {
transform: scale(0.75); transform: scale(0.75);
top: 8px; top: 8px;
} }
:host-context([style*="direction: rtl;"]) .mdc-floating-label { .mdc-floating-label {
left: initial; inset-inline-start: 16px !important;
right: 16px; inset-inline-end: initial !important;
direction: var(--direction);
} }
:host-context([style*="direction: rtl;"]) .mdc-text-field--filled .mdc-floating-label {
.mdc-text-field--filled inset-inline-start: 48px !important;
.mdc-floating-label { inset-inline-end: initial !important;
left: initial; direction: var(--direction);
right: 48px;
} }
.dragged:before { .dragged:before {
position: var(--layout-fit_-_position); position: var(--layout-fit_-_position);

View File

@ -133,9 +133,10 @@ export class HaFormString extends LitElement implements HaFormElement {
color: var(--secondary-text-color); color: var(--secondary-text-color);
} }
:host-context([style*="direction: rtl;"]) ha-icon-button { ha-icon-button {
right: auto; inset-inline-start: initial;
left: 12px; inset-inline-end: 12px;
direction: var(--direction);
} }
`; `;
} }

View File

@ -28,10 +28,15 @@ export class HaFormfield extends FormfieldBase {
css` css`
:host(:not([alignEnd])) ::slotted(ha-switch) { :host(:not([alignEnd])) ::slotted(ha-switch) {
margin-right: 10px; margin-right: 10px;
margin-inline-end: 10px;
margin-inline-start: inline;
} }
:host([dir="rtl"]:not([alignEnd])) ::slotted(ha-switch) { .mdc-form-field > label {
margin-left: 10px; direction: var(--direction);
margin-right: auto; margin-inline-start: 0;
margin-inline-end: auto;
padding-inline-start: 4px;
padding-inline-end: 0;
} }
`, `,
]; ];

View File

@ -1,6 +1,8 @@
import "@material/mwc-icon-button"; import "@material/mwc-icon-button";
import type { IconButton } from "@material/mwc-icon-button";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property, query } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import "./ha-svg-icon"; import "./ha-svg-icon";
@customElement("ha-icon-button") @customElement("ha-icon-button")
@ -11,21 +13,32 @@ export class HaIconButton extends LitElement {
@property({ type: String }) path?: string; @property({ type: String }) path?: string;
// Label that is used for ARIA support and as tooltip // Label that is used for ARIA support and as tooltip
@property({ type: String }) label = ""; @property({ type: String }) label?: string;
// These should always be set as properties, not attributes,
// so that only the <button> element gets the attribute
@property({ type: String, attribute: "aria-haspopup" })
override ariaHasPopup!: IconButton["ariaHasPopup"];
@property({ type: Boolean }) hideTitle = false; @property({ type: Boolean }) hideTitle = false;
@query("mwc-icon-button", true) private _button?: IconButton;
public override focus() {
this._button?.focus();
}
static shadowRootOptions: ShadowRootInit = { static shadowRootOptions: ShadowRootInit = {
mode: "open", mode: "open",
delegatesFocus: true, delegatesFocus: true,
}; };
protected render(): TemplateResult { protected render(): TemplateResult {
// Note: `ariaLabel` required despite the `mwc-icon-button` docs saying `label` should be enough
return html` return html`
<mwc-icon-button <mwc-icon-button
.ariaLabel=${this.label} aria-label=${ifDefined(this.label)}
.title=${this.hideTitle ? "" : this.label} title=${ifDefined(this.hideTitle ? undefined : this.label)}
aria-haspopup=${ifDefined(this.ariaHasPopup)}
.disabled=${this.disabled} .disabled=${this.disabled}
> >
${this.path ${this.path

View File

@ -47,9 +47,18 @@ export class HaSelect extends SelectBase {
.mdc-select__anchor { .mdc-select__anchor {
width: var(--ha-select-min-width, 200px); width: var(--ha-select-min-width, 200px);
} }
:host-context([style*="direction: rtl;"]) .mdc-floating-label { .mdc-select--filled .mdc-floating-label {
right: 16px !important; inset-inline-start: 12px;
left: initial !important; inset-inline-end: initial;
direction: var(--direction);
}
.mdc-select .mdc-select__anchor {
padding-inline-start: 12px;
padding-inline-end: 0px;
direction: var(--direction);
}
.mdc-select__anchor .mdc-floating-label--float-above {
transform-origin: var(--float-start);
} }
`, `,
]; ];

View File

@ -1,15 +1,24 @@
import { UnsubscribeFunc } from "home-assistant-js-websocket";
import { html, LitElement } from "lit"; import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { ConfigEntry, getConfigEntries } from "../../data/config_entries"; import memoizeOne from "memoize-one";
import { DeviceRegistryEntry } from "../../data/device_registry"; import { DeviceRegistryEntry } from "../../data/device_registry";
import { EntityRegistryEntry } from "../../data/entity_registry"; import {
EntityRegistryEntry,
subscribeEntityRegistry,
} from "../../data/entity_registry";
import {
EntitySources,
fetchEntitySourcesWithCache,
} from "../../data/entity_sources";
import { AreaSelector } from "../../data/selector"; import { AreaSelector } from "../../data/selector";
import { SubscribeMixin } from "../../mixins/subscribe-mixin";
import { HomeAssistant } from "../../types"; import { HomeAssistant } from "../../types";
import "../ha-area-picker"; import "../ha-area-picker";
import "../ha-areas-picker"; import "../ha-areas-picker";
@customElement("ha-selector-area") @customElement("ha-selector-area")
export class HaAreaSelector extends LitElement { export class HaAreaSelector extends SubscribeMixin(LitElement) {
@property() public hass!: HomeAssistant; @property() public hass!: HomeAssistant;
@property() public selector!: AreaSelector; @property() public selector!: AreaSelector;
@ -20,29 +29,44 @@ export class HaAreaSelector extends LitElement {
@property() public helper?: string; @property() public helper?: string;
@state() public _configEntries?: ConfigEntry[]; @state() private _entitySources?: EntitySources;
@state() private _entities?: EntityRegistryEntry[];
@property({ type: Boolean }) public disabled = false; @property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = true; @property({ type: Boolean }) public required = true;
public hassSubscribe(): UnsubscribeFunc[] {
return [
subscribeEntityRegistry(this.hass.connection!, (entities) => {
this._entities = entities.filter((entity) => entity.device_id !== null);
}),
];
}
protected updated(changedProperties) { protected updated(changedProperties) {
if (changedProperties.has("selector")) { if (
const oldSelector = changedProperties.get("selector"); changedProperties.has("selector") &&
if ( (this.selector.area.device?.integration ||
oldSelector !== this.selector && this.selector.area.entity?.integration) &&
this.selector.area.device?.integration !this._entitySources
) { ) {
getConfigEntries(this.hass, { fetchEntitySourcesWithCache(this.hass).then((sources) => {
domain: this.selector.area.device.integration, this._entitySources = sources;
}).then((entries) => { });
this._configEntries = entries;
});
}
} }
} }
protected render() { protected render() {
if (
(this.selector.area.device?.integration ||
this.selector.area.entity?.integration) &&
!this._entitySources
) {
return html``;
}
if (!this.selector.area.multiple) { if (!this.selector.area.multiple) {
return html` return html`
<ha-area-picker <ha-area-picker
@ -87,39 +111,62 @@ export class HaAreaSelector extends LitElement {
} }
private _filterEntities = (entity: EntityRegistryEntry): boolean => { private _filterEntities = (entity: EntityRegistryEntry): boolean => {
if (this.selector.area.entity?.integration) { const filterIntegration = this.selector.area.entity?.integration;
if (entity.platform !== this.selector.area.entity.integration) { if (
filterIntegration &&
this._entitySources?.[entity.entity_id]?.domain !== filterIntegration
) {
return false;
}
return true;
};
private _filterDevices = (device: DeviceRegistryEntry): boolean => {
if (!this.selector.area.device) {
return true;
}
const {
manufacturer: filterManufacturer,
model: filterModel,
integration: filterIntegration,
} = this.selector.area.device;
if (filterManufacturer && device.manufacturer !== filterManufacturer) {
return false;
}
if (filterModel && device.model !== filterModel) {
return false;
}
if (filterIntegration && this._entitySources && this._entities) {
const deviceIntegrations = this._deviceIntegrations(
this._entitySources,
this._entities
);
if (!deviceIntegrations?.[device.id]?.includes(filterIntegration)) {
return false; return false;
} }
} }
return true; return true;
}; };
private _filterDevices = (device: DeviceRegistryEntry): boolean => { private _deviceIntegrations = memoizeOne(
if ( (entitySources: EntitySources, entities: EntityRegistryEntry[]) => {
this.selector.area.device?.manufacturer && const deviceIntegrations: Record<string, string[]> = {};
device.manufacturer !== this.selector.area.device.manufacturer
) { for (const entity of entities) {
return false; const source = entitySources[entity.entity_id];
} if (!source?.domain) {
if ( continue;
this.selector.area.device?.model && }
device.model !== this.selector.area.device.model if (!deviceIntegrations[entity.device_id!]) {
) { deviceIntegrations[entity.device_id!] = [];
return false; }
} deviceIntegrations[entity.device_id!].push(source.domain);
if (this.selector.area.device?.integration) {
if (
this._configEntries &&
!this._configEntries.some((entry) =>
device.config_entries.includes(entry.entry_id)
)
) {
return false;
} }
return deviceIntegrations;
} }
return true; );
};
} }
declare global { declare global {

View File

@ -1,18 +1,33 @@
import { UnsubscribeFunc } from "home-assistant-js-websocket";
import { html, LitElement } from "lit"; import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { ConfigEntry, getConfigEntries } from "../../data/config_entries"; import memoizeOne from "memoize-one";
import { ConfigEntry } from "../../data/config_entries";
import type { DeviceRegistryEntry } from "../../data/device_registry"; import type { DeviceRegistryEntry } from "../../data/device_registry";
import {
EntityRegistryEntry,
subscribeEntityRegistry,
} from "../../data/entity_registry";
import {
EntitySources,
fetchEntitySourcesWithCache,
} from "../../data/entity_sources";
import type { DeviceSelector } from "../../data/selector"; import type { DeviceSelector } from "../../data/selector";
import { SubscribeMixin } from "../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant } from "../../types";
import "../device/ha-device-picker"; import "../device/ha-device-picker";
import "../device/ha-devices-picker"; import "../device/ha-devices-picker";
@customElement("ha-selector-device") @customElement("ha-selector-device")
export class HaDeviceSelector extends LitElement { export class HaDeviceSelector extends SubscribeMixin(LitElement) {
@property() public hass!: HomeAssistant; @property() public hass!: HomeAssistant;
@property() public selector!: DeviceSelector; @property() public selector!: DeviceSelector;
@state() private _entitySources?: EntitySources;
@state() private _entities?: EntityRegistryEntry[];
@property() public value?: any; @property() public value?: any;
@property() public label?: string; @property() public label?: string;
@ -25,20 +40,32 @@ export class HaDeviceSelector extends LitElement {
@property({ type: Boolean }) public required = true; @property({ type: Boolean }) public required = true;
protected updated(changedProperties) { public hassSubscribe(): UnsubscribeFunc[] {
if (changedProperties.has("selector")) { return [
const oldSelector = changedProperties.get("selector"); subscribeEntityRegistry(this.hass.connection!, (entities) => {
if (oldSelector !== this.selector && this.selector.device?.integration) { this._entities = entities.filter((entity) => entity.device_id !== null);
getConfigEntries(this.hass, { }),
domain: this.selector.device.integration, ];
}).then((entries) => { }
this._configEntries = entries;
}); protected updated(changedProperties): void {
} super.updated(changedProperties);
if (
changedProperties.has("selector") &&
this.selector.device.integration &&
!this._entitySources
) {
fetchEntitySourcesWithCache(this.hass).then((sources) => {
this._entitySources = sources;
});
} }
} }
protected render() { protected render() {
if (this.selector.device.integration && !this._entitySources) {
return html``;
}
if (!this.selector.device.multiple) { if (!this.selector.device.multiple) {
return html` return html`
<ha-device-picker <ha-device-picker
@ -80,30 +107,48 @@ export class HaDeviceSelector extends LitElement {
} }
private _filterDevices = (device: DeviceRegistryEntry): boolean => { private _filterDevices = (device: DeviceRegistryEntry): boolean => {
if ( const {
this.selector.device?.manufacturer && manufacturer: filterManufacturer,
device.manufacturer !== this.selector.device.manufacturer model: filterModel,
) { integration: filterIntegration,
} = this.selector.device;
if (filterManufacturer && device.manufacturer !== filterManufacturer) {
return false; return false;
} }
if ( if (filterModel && device.model !== filterModel) {
this.selector.device?.model &&
device.model !== this.selector.device.model
) {
return false; return false;
} }
if (this.selector.device?.integration) { if (filterIntegration && this._entitySources && this._entities) {
if ( const deviceIntegrations = this._deviceIntegrations(
this._configEntries && this._entitySources,
!this._configEntries.some((entry) => this._entities
device.config_entries.includes(entry.entry_id) );
) if (!deviceIntegrations?.[device.id]?.includes(filterIntegration)) {
) {
return false; return false;
} }
} }
return true; return true;
}; };
private _deviceIntegrations = memoizeOne(
(entitySources: EntitySources, entities: EntityRegistryEntry[]) => {
const deviceIntegrations: Record<string, string[]> = {};
for (const entity of entities) {
const source = entitySources[entity.entity_id];
if (!source?.domain) {
continue;
}
if (!deviceIntegrations[entity.device_id!]) {
deviceIntegrations[entity.device_id!] = [];
}
deviceIntegrations[entity.device_id!].push(source.domain);
}
return deviceIntegrations;
}
);
} }
declare global { declare global {

View File

@ -1,4 +1,3 @@
import "@material/mwc-formfield/mwc-formfield";
import "@material/mwc-list/mwc-list-item"; import "@material/mwc-list/mwc-list-item";
import { mdiClose } from "@mdi/js"; import { mdiClose } from "@mdi/js";
import { css, html, LitElement } from "lit"; import { css, html, LitElement } from "lit";
@ -47,14 +46,14 @@ export class HaSelectSelector extends LitElement {
${this.label} ${this.label}
${options.map( ${options.map(
(item: SelectOption) => html` (item: SelectOption) => html`
<mwc-formfield .label=${item.label}> <ha-formfield .label=${item.label}>
<ha-radio <ha-radio
.checked=${item.value === this.value} .checked=${item.value === this.value}
.value=${item.value} .value=${item.value}
.disabled=${this.disabled} .disabled=${this.disabled}
@change=${this._valueChanged} @change=${this._valueChanged}
></ha-radio> ></ha-radio>
</mwc-formfield> </ha-formfield>
` `
)} )}
</div> </div>

View File

@ -287,9 +287,7 @@ export class HaServiceControl extends LitElement {
${shouldRenderServiceDataYaml ${shouldRenderServiceDataYaml
? html`<ha-yaml-editor ? html`<ha-yaml-editor
.hass=${this.hass} .hass=${this.hass}
.label=${this.hass.localize( .label=${this.hass.localize("ui.components.service-control.data")}
"ui.components.service-control.service_data"
)}
.name=${"data"} .name=${"data"}
.defaultValue=${this._value?.data} .defaultValue=${this._value?.data}
@value-changed=${this._dataChanged} @value-changed=${this._dataChanged}

View File

@ -569,6 +569,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
height: 16px; height: 16px;
--mdc-icon-size: 14px; --mdc-icon-size: 14px;
color: var(--secondary-text-color); color: var(--secondary-text-color);
margin-inline-start: 4px !important;
margin-inline-end: -4px !important;
direction: var(--direction);
} }
.mdc-chip__icon--leading { .mdc-chip__icon--leading {
display: flex; display: flex;
@ -578,6 +581,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
border-radius: 50%; border-radius: 50%;
padding: 6px; padding: 6px;
margin-left: -14px !important; margin-left: -14px !important;
margin-inline-start: -14px !important;
margin-inline-end: 4px !important;
direction: var(--direction);
} }
.expand-btn { .expand-btn {
margin-right: 0; margin-right: 0;
@ -616,10 +622,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
opacity: var(--light-disabled-opacity); opacity: var(--light-disabled-opacity);
pointer-events: none; pointer-events: none;
} }
:host-context([style*="direction: rtl;"]) .mdc-chip__icon {
margin-right: -14px !important;
margin-left: 4px !important;
}
`; `;
} }
} }

View File

@ -57,6 +57,9 @@ export class HaTextField extends TextFieldBase {
.mdc-text-field__affix--suffix { .mdc-text-field__affix--suffix {
padding-left: var(--text-field-suffix-padding-left, 12px); padding-left: var(--text-field-suffix-padding-left, 12px);
padding-right: var(--text-field-suffix-padding-right, 0px); padding-right: var(--text-field-suffix-padding-right, 0px);
padding-inline-start: var(--text-field-suffix-padding-left, 12px);
padding-inline-end: var(--text-field-suffix-padding-right, 0px);
direction: var(--direction);
} }
.mdc-text-field:not(.mdc-text-field--disabled) .mdc-text-field:not(.mdc-text-field--disabled)
@ -92,17 +95,19 @@ export class HaTextField extends TextFieldBase {
overflow: var(--text-field-overflow); overflow: var(--text-field-overflow);
} }
:host-context([style*="direction: rtl;"]) .mdc-floating-label { .mdc-floating-label {
right: 10px !important; inset-inline-start: 16px !important;
left: initial !important; inset-inline-end: initial !important;
transform-origin: var(--float-start);
direction: var(--direction);
} }
:host-context([style*="direction: rtl;"]) .mdc-text-field--with-leading-icon.mdc-text-field--filled
.mdc-text-field--with-leading-icon.mdc-text-field--filled
.mdc-floating-label { .mdc-floating-label {
max-width: calc(100% - 48px); max-width: calc(100% - 48px);
right: 48px !important; inset-inline-start: 48px !important;
left: initial !important; inset-inline-end: initial !important;
direction: var(--direction);
} }
`, `,
]; ];

View File

@ -314,9 +314,10 @@ class DialogMediaManage extends LitElement {
vertical-align: middle; vertical-align: middle;
} }
:host-context([style*="direction: rtl;"]) ha-svg-icon[slot="icon"] { ha-svg-icon[slot="icon"] {
margin-left: 8px !important; margin-inline-start: 0px !important;
margin-right: 0px !important; margin-inline-end: 8px !important;
direction: var(--direction);
} }
.refresh { .refresh {

View File

@ -60,9 +60,10 @@ class MediaManageButton extends LitElement {
vertical-align: middle; vertical-align: middle;
} }
:host-context([style*="direction: rtl;"]) ha-svg-icon[slot="icon"] { ha-svg-icon[slot="icon"] {
margin-left: 8px; margin-inline-start: 0px;
margin-right: 0px; margin-inline-end: 8px;
direction: var(--direction);
} }
`; `;
} }

View File

@ -28,6 +28,7 @@ import { fireEvent } from "../../common/dom/fire_event";
import { computeRTLDirection } from "../../common/util/compute_rtl"; import { computeRTLDirection } from "../../common/util/compute_rtl";
import { debounce } from "../../common/util/debounce"; import { debounce } from "../../common/util/debounce";
import { getSignedPath } from "../../data/auth"; import { getSignedPath } from "../../data/auth";
import { UNAVAILABLE_STATES } from "../../data/entity";
import type { MediaPlayerItem } from "../../data/media-player"; import type { MediaPlayerItem } from "../../data/media-player";
import { import {
browseMediaPlayer, browseMediaPlayer,
@ -45,6 +46,7 @@ import type { HomeAssistant } from "../../types";
import { brandsUrl, extractDomainFromBrandUrl } from "../../util/brands-url"; import { brandsUrl, extractDomainFromBrandUrl } from "../../util/brands-url";
import { documentationUrl } from "../../util/documentation-url"; import { documentationUrl } from "../../util/documentation-url";
import "../entity/ha-entity-picker"; import "../entity/ha-entity-picker";
import "../ha-alert";
import "../ha-button-menu"; import "../ha-button-menu";
import "../ha-card"; import "../ha-card";
import "../ha-circular-progress"; import "../ha-circular-progress";
@ -246,6 +248,16 @@ export class HaMediaPlayerBrowse extends LitElement {
], ],
replace: true, replace: true,
}); });
} else if (
err.code === "entity_not_found" &&
UNAVAILABLE_STATES.includes(this.hass.states[this.entityId]?.state)
) {
this._setError({
message: this.hass.localize(
`ui.components.media-browser.media_player_unavailable`
),
code: "entity_not_found",
});
} else { } else {
this._setError(err); this._setError(err);
} }
@ -305,7 +317,11 @@ export class HaMediaPlayerBrowse extends LitElement {
protected render(): TemplateResult { protected render(): TemplateResult {
if (this._error) { if (this._error) {
return html` return html`
<div class="container">${this._renderError(this._error)}</div> <div class="container">
<ha-alert alert-type="error">
${this._renderError(this._error)}
</ha-alert>
</div>
`; `;
} }
@ -420,7 +436,9 @@ export class HaMediaPlayerBrowse extends LitElement {
this._error this._error
? html` ? html`
<div class="container"> <div class="container">
${this._renderError(this._error)} <ha-alert alert-type="error">
${this._renderError(this._error)}
</ha-alert>
</div> </div>
` `
: isTTSMediaSource(currentItem.media_content_id) : isTTSMediaSource(currentItem.media_content_id)

View File

@ -120,9 +120,10 @@ class MediaUploadButton extends LitElement {
vertical-align: middle; vertical-align: middle;
} }
:host-context([style*="direction: rtl;"]) ha-svg-icon[slot="icon"] { ha-svg-icon[slot="icon"] {
margin-left: 8px; margin-inline-start: 0px;
margin-right: 0px; margin-inline-end: 8px;
direction: var(--direction);
} }
`; `;
} }

View File

@ -3,7 +3,7 @@ import { customElement, property } from "lit/decorators";
import { LogbookEntry } from "../../data/logbook"; import { LogbookEntry } from "../../data/logbook";
import { HomeAssistant } from "../../types"; import { HomeAssistant } from "../../types";
import "./hat-logbook-note"; import "./hat-logbook-note";
import "../../panels/logbook/ha-logbook"; import "../../panels/logbook/ha-logbook-renderer";
import { TraceExtended } from "../../data/trace"; import { TraceExtended } from "../../data/trace";
@customElement("ha-trace-logbook") @customElement("ha-trace-logbook")
@ -19,12 +19,12 @@ export class HaTraceLogbook extends LitElement {
protected render(): TemplateResult { protected render(): TemplateResult {
return this.logbookEntries.length return this.logbookEntries.length
? html` ? html`
<ha-logbook <ha-logbook-renderer
relative-time relative-time
.hass=${this.hass} .hass=${this.hass}
.entries=${this.logbookEntries} .entries=${this.logbookEntries}
.narrow=${this.narrow} .narrow=${this.narrow}
></ha-logbook> ></ha-logbook-renderer>
<hat-logbook-note .domain=${this.trace.domain}></hat-logbook-note> <hat-logbook-note .domain=${this.trace.domain}></hat-logbook-note>
` `
: html`<div class="padded-box"> : html`<div class="padded-box">

View File

@ -13,7 +13,7 @@ import {
getDataFromPath, getDataFromPath,
TraceExtended, TraceExtended,
} from "../../data/trace"; } from "../../data/trace";
import "../../panels/logbook/ha-logbook"; import "../../panels/logbook/ha-logbook-renderer";
import { traceTabStyles } from "./trace-tab-styles"; import { traceTabStyles } from "./trace-tab-styles";
import { HomeAssistant } from "../../types"; import { HomeAssistant } from "../../types";
import type { NodeInfo } from "./hat-script-graph"; import type { NodeInfo } from "./hat-script-graph";
@ -194,7 +194,7 @@ export class HaTracePathDetails extends LitElement {
// it's the last entry. Find all logbook entries after start. // it's the last entry. Find all logbook entries after start.
const startTime = new Date(startTrace[0].timestamp); const startTime = new Date(startTrace[0].timestamp);
const idx = this.logbookEntries.findIndex( const idx = this.logbookEntries.findIndex(
(entry) => new Date(entry.when) >= startTime (entry) => new Date(entry.when * 1000) >= startTime
); );
if (idx === -1) { if (idx === -1) {
entries = []; entries = [];
@ -210,7 +210,7 @@ export class HaTracePathDetails extends LitElement {
entries = []; entries = [];
for (const entry of this.logbookEntries || []) { for (const entry of this.logbookEntries || []) {
const entryDate = new Date(entry.when); const entryDate = new Date(entry.when * 1000);
if (entryDate >= startTime) { if (entryDate >= startTime) {
if (entryDate < endTime) { if (entryDate < endTime) {
entries.push(entry); entries.push(entry);
@ -224,12 +224,12 @@ export class HaTracePathDetails extends LitElement {
return entries.length return entries.length
? html` ? html`
<ha-logbook <ha-logbook-renderer
relative-time relative-time
.hass=${this.hass} .hass=${this.hass}
.entries=${entries} .entries=${entries}
.narrow=${this.narrow} .narrow=${this.narrow}
></ha-logbook> ></ha-logbook-renderer>
<hat-logbook-note .domain=${this.trace.domain}></hat-logbook-note> <hat-logbook-note .domain=${this.trace.domain}></hat-logbook-note>
` `
: html`<div class="padded-box"> : html`<div class="padded-box">

View File

@ -116,7 +116,7 @@ class LogbookRenderer {
maybeRenderItem() { maybeRenderItem() {
const logbookEntry = this.curItem; const logbookEntry = this.curItem;
this.curIndex++; this.curIndex++;
const entryDate = new Date(logbookEntry.when); const entryDate = new Date(logbookEntry.when * 1000);
if (this.pendingItems.length === 0) { if (this.pendingItems.length === 0) {
this.pendingItems.push([entryDate, logbookEntry]); this.pendingItems.push([entryDate, logbookEntry]);
@ -248,7 +248,7 @@ class ActionRenderer {
// Render all logbook items that are in front of this item. // Render all logbook items that are in front of this item.
while ( while (
this.logbookRenderer.hasNext && this.logbookRenderer.hasNext &&
new Date(this.logbookRenderer.curItem.when) < timestamp new Date(this.logbookRenderer.curItem.when * 1000) < timestamp
) { ) {
this.logbookRenderer.maybeRenderItem(); this.logbookRenderer.maybeRenderItem();
} }

View File

@ -0,0 +1,47 @@
import { HomeAssistant } from "../types";
export interface ApplicationCredentialsConfig {
domains: string[];
}
export interface ApplicationCredential {
id: string;
domain: string;
client_id: string;
client_secret: string;
name: string;
}
export const fetchApplicationCredentialsConfig = async (hass: HomeAssistant) =>
hass.callWS<ApplicationCredentialsConfig>({
type: "application_credentials/config",
});
export const fetchApplicationCredentials = async (hass: HomeAssistant) =>
hass.callWS<ApplicationCredential[]>({
type: "application_credentials/list",
});
export const createApplicationCredential = async (
hass: HomeAssistant,
domain: string,
clientId: string,
clientSecret: string,
name?: string
) =>
hass.callWS<ApplicationCredential>({
type: "application_credentials/create",
domain,
client_id: clientId,
client_secret: clientSecret,
name,
});
export const deleteApplicationCredential = async (
hass: HomeAssistant,
applicationCredentialsId: string
) =>
hass.callWS<void>({
type: "application_credentials/delete",
application_credentials_id: applicationCredentialsId,
});

View File

@ -157,6 +157,7 @@ export interface CalendarTrigger extends BaseTrigger {
platform: "calendar"; platform: "calendar";
event: "start" | "end"; event: "start" | "end";
entity_id: string; entity_id: string;
offset: string;
} }
export type Trigger = export type Trigger =

View File

@ -1,13 +1,13 @@
import { HassEntity } from "home-assistant-js-websocket";
import { LocalizeFunc } from "../common/translations/localize"; import { LocalizeFunc } from "../common/translations/localize";
import { HomeAssistant } from "../types"; import { HomeAssistant } from "../types";
import { import {
computeHistory, computeHistory,
fetchRecent, HistoryStates,
HistoryResult, HistoryResult,
LineChartUnit, LineChartUnit,
TimelineEntity, TimelineEntity,
entityIdHistoryNeedsAttributes, entityIdHistoryNeedsAttributes,
fetchRecentWS,
} from "./history"; } from "./history";
export interface CacheConfig { export interface CacheConfig {
@ -34,7 +34,7 @@ const RECENT_THRESHOLD = 60000; // 1 minute
const RECENT_CACHE: { [cacheKey: string]: RecentCacheResults } = {}; const RECENT_CACHE: { [cacheKey: string]: RecentCacheResults } = {};
const stateHistoryCache: { [cacheKey: string]: CachedResults } = {}; const stateHistoryCache: { [cacheKey: string]: CachedResults } = {};
// Cached type 1 unction. Without cache config. // Cached type 1 function. Without cache config.
export const getRecent = ( export const getRecent = (
hass: HomeAssistant, hass: HomeAssistant,
entityId: string, entityId: string,
@ -55,7 +55,7 @@ export const getRecent = (
} }
const noAttributes = !entityIdHistoryNeedsAttributes(hass, entityId); const noAttributes = !entityIdHistoryNeedsAttributes(hass, entityId);
const prom = fetchRecent( const prom = fetchRecentWS(
hass, hass,
entityId, entityId,
startTime, startTime,
@ -103,13 +103,14 @@ export const getRecentWithCache = (
language: string language: string
) => { ) => {
const cacheKey = cacheConfig.cacheKey; const cacheKey = cacheConfig.cacheKey;
const fullCacheKey = cacheKey + `_${cacheConfig.hoursToShow}`;
const endTime = new Date(); const endTime = new Date();
const startTime = new Date(endTime); const startTime = new Date(endTime);
startTime.setHours(startTime.getHours() - cacheConfig.hoursToShow); startTime.setHours(startTime.getHours() - cacheConfig.hoursToShow);
let toFetchStartTime = startTime; let toFetchStartTime = startTime;
let appendingToCache = false; let appendingToCache = false;
let cache = stateHistoryCache[cacheKey + `_${cacheConfig.hoursToShow}`]; let cache = stateHistoryCache[fullCacheKey];
if ( if (
cache && cache &&
toFetchStartTime >= cache.startTime && toFetchStartTime >= cache.startTime &&
@ -123,7 +124,7 @@ export const getRecentWithCache = (
return cache.prom; return cache.prom;
} }
} else { } else {
cache = stateHistoryCache[cacheKey] = getEmptyCache( cache = stateHistoryCache[fullCacheKey] = getEmptyCache(
language, language,
startTime, startTime,
endTime endTime
@ -134,12 +135,12 @@ export const getRecentWithCache = (
const noAttributes = !entityIdHistoryNeedsAttributes(hass, entityId); const noAttributes = !entityIdHistoryNeedsAttributes(hass, entityId);
const genProm = async () => { const genProm = async () => {
let fetchedHistory: HassEntity[][]; let fetchedHistory: HistoryStates;
try { try {
const results = await Promise.all([ const results = await Promise.all([
curCacheProm, curCacheProm,
fetchRecent( fetchRecentWS(
hass, hass,
entityId, entityId,
toFetchStartTime, toFetchStartTime,
@ -152,7 +153,7 @@ export const getRecentWithCache = (
]); ]);
fetchedHistory = results[1]; fetchedHistory = results[1];
} catch (err: any) { } catch (err: any) {
delete stateHistoryCache[cacheKey]; delete stateHistoryCache[fullCacheKey];
throw err; throw err;
} }
const stateHistory = computeHistory(hass, fetchedHistory, localize); const stateHistory = computeHistory(hass, fetchedHistory, localize);

View File

@ -1,11 +1,14 @@
import { import {
addDays,
addHours, addHours,
addMilliseconds,
addMonths,
differenceInDays, differenceInDays,
endOfToday, endOfToday,
endOfYesterday, endOfYesterday,
startOfToday, startOfToday,
startOfYesterday, startOfYesterday,
} from "date-fns"; } from "date-fns/esm";
import { Collection, getCollection } from "home-assistant-js-websocket"; import { Collection, getCollection } from "home-assistant-js-websocket";
import { groupBy } from "../common/util/group-by"; import { groupBy } from "../common/util/group-by";
import { subscribeOne } from "../common/util/subscribe-one"; import { subscribeOne } from "../common/util/subscribe-one";
@ -14,9 +17,9 @@ import { ConfigEntry, getConfigEntries } from "./config_entries";
import { subscribeEntityRegistry } from "./entity_registry"; import { subscribeEntityRegistry } from "./entity_registry";
import { import {
fetchStatistics, fetchStatistics,
getStatisticMetadata,
Statistics, Statistics,
StatisticsMetaData, StatisticsMetaData,
getStatisticMetadata,
} from "./history"; } from "./history";
const energyCollectionKeys: (string | undefined)[] = []; const energyCollectionKeys: (string | undefined)[] = [];
@ -232,19 +235,24 @@ export const energySourcesByType = (prefs: EnergyPreferences) =>
export interface EnergyData { export interface EnergyData {
start: Date; start: Date;
end?: Date; end?: Date;
startCompare?: Date;
endCompare?: Date;
prefs: EnergyPreferences; prefs: EnergyPreferences;
info: EnergyInfo; info: EnergyInfo;
stats: Statistics; stats: Statistics;
statsCompare: Statistics;
co2SignalConfigEntry?: ConfigEntry; co2SignalConfigEntry?: ConfigEntry;
co2SignalEntity?: string; co2SignalEntity?: string;
fossilEnergyConsumption?: FossilEnergyConsumption; fossilEnergyConsumption?: FossilEnergyConsumption;
fossilEnergyConsumptionCompare?: FossilEnergyConsumption;
} }
const getEnergyData = async ( const getEnergyData = async (
hass: HomeAssistant, hass: HomeAssistant,
prefs: EnergyPreferences, prefs: EnergyPreferences,
start: Date, start: Date,
end?: Date end?: Date,
compare?: boolean
): Promise<EnergyData> => { ): Promise<EnergyData> => {
const [configEntries, entityRegistryEntries, info] = await Promise.all([ const [configEntries, entityRegistryEntries, info] = await Promise.all([
getConfigEntries(hass, { domain: "co2signal" }), getConfigEntries(hass, { domain: "co2signal" }),
@ -350,6 +358,8 @@ const getEnergyData = async (
} }
const dayDifference = differenceInDays(end || new Date(), start); const dayDifference = differenceInDays(end || new Date(), start);
const period =
dayDifference > 35 ? "month" : dayDifference > 2 ? "day" : "hour";
// Subtract 1 hour from start to get starting point data // Subtract 1 hour from start to get starting point data
const startMinHour = addHours(start, -1); const startMinHour = addHours(start, -1);
@ -359,10 +369,34 @@ const getEnergyData = async (
startMinHour, startMinHour,
end, end,
statIDs, statIDs,
dayDifference > 35 ? "month" : dayDifference > 2 ? "day" : "hour" period
); );
let statsCompare;
let startCompare;
let endCompare;
if (compare) {
if (dayDifference > 27 && dayDifference < 32) {
// When comparing a month, we want to start at the begining of the month
startCompare = addMonths(start, -1);
} else {
startCompare = addDays(start, (dayDifference + 1) * -1);
}
const compareStartMinHour = addHours(startCompare, -1);
endCompare = addMilliseconds(start, -1);
statsCompare = await fetchStatistics(
hass!,
compareStartMinHour,
endCompare,
statIDs,
period
);
}
let fossilEnergyConsumption: FossilEnergyConsumption | undefined; let fossilEnergyConsumption: FossilEnergyConsumption | undefined;
let fossilEnergyConsumptionCompare: FossilEnergyConsumption | undefined;
if (co2SignalEntity !== undefined) { if (co2SignalEntity !== undefined) {
fossilEnergyConsumption = await getFossilEnergyConsumption( fossilEnergyConsumption = await getFossilEnergyConsumption(
@ -373,6 +407,16 @@ const getEnergyData = async (
end, end,
dayDifference > 35 ? "month" : dayDifference > 2 ? "day" : "hour" dayDifference > 35 ? "month" : dayDifference > 2 ? "day" : "hour"
); );
if (compare) {
fossilEnergyConsumptionCompare = await getFossilEnergyConsumption(
hass!,
startCompare,
consumptionStatIDs,
co2SignalEntity,
endCompare,
dayDifference > 35 ? "month" : dayDifference > 2 ? "day" : "hour"
);
}
} }
Object.values(stats).forEach((stat) => { Object.values(stats).forEach((stat) => {
@ -388,15 +432,19 @@ const getEnergyData = async (
} }
}); });
const data = { const data: EnergyData = {
start, start,
end, end,
startCompare,
endCompare,
info, info,
prefs, prefs,
stats, stats,
statsCompare,
co2SignalConfigEntry, co2SignalConfigEntry,
co2SignalEntity, co2SignalEntity,
fossilEnergyConsumption, fossilEnergyConsumption,
fossilEnergyConsumptionCompare,
}; };
return data; return data;
@ -405,9 +453,11 @@ const getEnergyData = async (
export interface EnergyCollection extends Collection<EnergyData> { export interface EnergyCollection extends Collection<EnergyData> {
start: Date; start: Date;
end?: Date; end?: Date;
compare?: boolean;
prefs?: EnergyPreferences; prefs?: EnergyPreferences;
clearPrefs(): void; clearPrefs(): void;
setPeriod(newStart: Date, newEnd?: Date): void; setPeriod(newStart: Date, newEnd?: Date): void;
setCompare(compare: boolean): void;
_refreshTimeout?: number; _refreshTimeout?: number;
_updatePeriodTimeout?: number; _updatePeriodTimeout?: number;
_active: number; _active: number;
@ -478,7 +528,8 @@ export const getEnergyDataCollection = (
hass, hass,
collection.prefs, collection.prefs,
collection.start, collection.start,
collection.end collection.end,
collection.compare
); );
} }
) as EnergyCollection; ) as EnergyCollection;
@ -534,6 +585,9 @@ export const getEnergyDataCollection = (
collection._updatePeriodTimeout = undefined; collection._updatePeriodTimeout = undefined;
} }
}; };
collection.setCompare = (compare: boolean) => {
collection.compare = compare;
};
return collection; return collection;
}; };

View File

@ -20,3 +20,20 @@ export const BOARD_NAMES: Record<string, string> = {
"intel-nuc": "Intel NUC", "intel-nuc": "Intel NUC",
yellow: "Home Assistant Yellow", yellow: "Home Assistant Yellow",
}; };
export interface HardwareInfo {
hardware: HardwareInfoEntry[];
}
export interface HardwareInfoEntry {
board: HardwareInfoBoardInfo;
name: string;
url?: string;
}
export interface HardwareInfoBoardInfo {
manufacturer: string;
model?: string;
revision?: string;
hassio_board_id?: string;
}

View File

@ -1,8 +1,7 @@
import { HassEntity } from "home-assistant-js-websocket"; import { HassEntity } from "home-assistant-js-websocket";
import { computeDomain } from "../common/entity/compute_domain"; import { computeDomain } from "../common/entity/compute_domain";
import { computeStateDisplay } from "../common/entity/compute_state_display"; import { computeStateDisplayFromEntityAttributes } from "../common/entity/compute_state_display";
import { computeStateDomain } from "../common/entity/compute_state_domain"; import { computeStateNameFromEntityAttributes } from "../common/entity/compute_state_name";
import { computeStateName } from "../common/entity/compute_state_name";
import { LocalizeFunc } from "../common/translations/localize"; import { LocalizeFunc } from "../common/translations/localize";
import { HomeAssistant } from "../types"; import { HomeAssistant } from "../types";
import { FrontendLocaleData } from "./translation"; import { FrontendLocaleData } from "./translation";
@ -27,7 +26,7 @@ const LINE_ATTRIBUTES_TO_KEEP = [
export interface LineChartState { export interface LineChartState {
state: string; state: string;
last_changed: string; last_changed: number;
attributes?: Record<string, any>; attributes?: Record<string, any>;
} }
@ -47,7 +46,7 @@ export interface LineChartUnit {
export interface TimelineState { export interface TimelineState {
state_localize: string; state_localize: string;
state: string; state: string;
last_changed: string; last_changed: number;
} }
export interface TimelineEntity { export interface TimelineEntity {
@ -141,6 +140,21 @@ export interface StatisticsValidationResults {
[statisticId: string]: StatisticsValidationResult[]; [statisticId: string]: StatisticsValidationResult[];
} }
export interface HistoryStates {
[entityId: string]: EntityHistoryState[];
}
interface EntityHistoryState {
/** state */
s: string;
/** attributes */
a: { [key: string]: any };
/** last_changed; if set, also applies to lu */
lc: number;
/** last_updated */
lu: number;
}
export const entityIdHistoryNeedsAttributes = ( export const entityIdHistoryNeedsAttributes = (
hass: HomeAssistant, hass: HomeAssistant,
entityId: string entityId: string
@ -181,6 +195,27 @@ export const fetchRecent = (
return hass.callApi("GET", url); return hass.callApi("GET", url);
}; };
export const fetchRecentWS = (
hass: HomeAssistant,
entityId: string, // This may be CSV
startTime: Date,
endTime: Date,
skipInitialState = false,
significantChangesOnly?: boolean,
minimalResponse = true,
noAttributes?: boolean
) =>
hass.callWS<HistoryStates>({
type: "history/history_during_period",
start_time: startTime.toISOString(),
end_time: endTime.toISOString(),
significant_changes_only: significantChangesOnly || false,
include_start_time_state: !skipInitialState,
minimal_response: minimalResponse,
no_attributes: noAttributes || false,
entity_ids: entityId.split(","),
});
export const fetchDate = ( export const fetchDate = (
hass: HomeAssistant, hass: HomeAssistant,
startTime: Date, startTime: Date,
@ -198,6 +233,27 @@ export const fetchDate = (
}` }`
); );
export const fetchDateWS = (
hass: HomeAssistant,
startTime: Date,
endTime: Date,
entityId?: string
) => {
const params = {
type: "history/history_during_period",
start_time: startTime.toISOString(),
end_time: endTime.toISOString(),
minimal_response: true,
no_attributes: !!(
entityId && !entityIdHistoryNeedsAttributes(hass, entityId)
),
};
if (entityId) {
return hass.callWS<HistoryStates>({ ...params, entity_ids: [entityId] });
}
return hass.callWS<HistoryStates>(params);
};
const equalState = (obj1: LineChartState, obj2: LineChartState) => const equalState = (obj1: LineChartState, obj2: LineChartState) =>
obj1.state === obj2.state && obj1.state === obj2.state &&
// Only compare attributes if both states have an attributes object. // Only compare attributes if both states have an attributes object.
@ -212,46 +268,47 @@ const equalState = (obj1: LineChartState, obj2: LineChartState) =>
const processTimelineEntity = ( const processTimelineEntity = (
localize: LocalizeFunc, localize: LocalizeFunc,
language: FrontendLocaleData, language: FrontendLocaleData,
states: HassEntity[] entityId: string,
states: EntityHistoryState[]
): TimelineEntity => { ): TimelineEntity => {
const data: TimelineState[] = []; const data: TimelineState[] = [];
const last_element = states.length - 1; const first: EntityHistoryState = states[0];
for (const state of states) { for (const state of states) {
if (data.length > 0 && state.state === data[data.length - 1].state) { if (data.length > 0 && state.s === data[data.length - 1].state) {
continue; continue;
} }
// Copy the data from the last element as its the newest
// and is only needed to localize the data
if (!state.entity_id) {
state.attributes = states[last_element].attributes;
state.entity_id = states[last_element].entity_id;
}
data.push({ data.push({
state_localize: computeStateDisplay(localize, state, language), state_localize: computeStateDisplayFromEntityAttributes(
state: state.state, localize,
last_changed: state.last_changed, language,
entityId,
state.a || first.a,
state.s
),
state: state.s,
// lc (last_changed) may be omitted if its the same
// as lu (last_updated).
last_changed: (state.lc ? state.lc : state.lu) * 1000,
}); });
} }
return { return {
name: computeStateName(states[0]), name: computeStateNameFromEntityAttributes(entityId, states[0].a),
entity_id: states[0].entity_id, entity_id: entityId,
data, data,
}; };
}; };
const processLineChartEntities = ( const processLineChartEntities = (
unit, unit,
entities: HassEntity[][] entities: HistoryStates
): LineChartUnit => { ): LineChartUnit => {
const data: LineChartEntity[] = []; const data: LineChartEntity[] = [];
for (const states of entities) { Object.keys(entities).forEach((entityId) => {
const last: HassEntity = states[states.length - 1]; const states = entities[entityId];
const domain = computeStateDomain(last); const first: EntityHistoryState = states[0];
const domain = computeDomain(entityId);
const processedStates: LineChartState[] = []; const processedStates: LineChartState[] = [];
for (const state of states) { for (const state of states) {
@ -259,18 +316,24 @@ const processLineChartEntities = (
if (DOMAINS_USE_LAST_UPDATED.includes(domain)) { if (DOMAINS_USE_LAST_UPDATED.includes(domain)) {
processedState = { processedState = {
state: state.state, state: state.s,
last_changed: state.last_updated, last_changed: state.lu * 1000,
attributes: {}, attributes: {},
}; };
for (const attr of LINE_ATTRIBUTES_TO_KEEP) { for (const attr of LINE_ATTRIBUTES_TO_KEEP) {
if (attr in state.attributes) { if (attr in state.a) {
processedState.attributes![attr] = state.attributes[attr]; processedState.attributes![attr] = state.a[attr];
} }
} }
} else { } else {
processedState = state; processedState = {
state: state.s,
// lc (last_changed) may be omitted if its the same
// as lu (last_updated).
last_changed: (state.lc ? state.lc : state.lu) * 1000,
attributes: {},
};
} }
if ( if (
@ -289,52 +352,53 @@ const processLineChartEntities = (
data.push({ data.push({
domain, domain,
name: computeStateName(last), name: computeStateNameFromEntityAttributes(entityId, first.a),
entity_id: last.entity_id, entity_id: entityId,
states: processedStates, states: processedStates,
}); });
} });
return { return {
unit, unit,
identifier: entities.map((states) => states[0].entity_id).join(""), identifier: Object.keys(entities).join(""),
data, data,
}; };
}; };
const stateUsesUnits = (state: HassEntity) => const stateUsesUnits = (state: HassEntity) =>
"unit_of_measurement" in state.attributes || attributesHaveUnits(state.attributes);
"state_class" in state.attributes;
const attributesHaveUnits = (attributes: { [key: string]: any }) =>
"unit_of_measurement" in attributes || "state_class" in attributes;
export const computeHistory = ( export const computeHistory = (
hass: HomeAssistant, hass: HomeAssistant,
stateHistory: HassEntity[][], stateHistory: HistoryStates,
localize: LocalizeFunc localize: LocalizeFunc
): HistoryResult => { ): HistoryResult => {
const lineChartDevices: { [unit: string]: HassEntity[][] } = {}; const lineChartDevices: { [unit: string]: HistoryStates } = {};
const timelineDevices: TimelineEntity[] = []; const timelineDevices: TimelineEntity[] = [];
if (!stateHistory) { if (!stateHistory) {
return { line: [], timeline: [] }; return { line: [], timeline: [] };
} }
Object.keys(stateHistory).forEach((entityId) => {
stateHistory.forEach((stateInfo) => { const stateInfo = stateHistory[entityId];
if (stateInfo.length === 0) { if (stateInfo.length === 0) {
return; return;
} }
const entityId = stateInfo[0].entity_id;
const currentState = const currentState =
entityId in hass.states ? hass.states[entityId] : undefined; entityId in hass.states ? hass.states[entityId] : undefined;
const stateWithUnitorStateClass = const stateWithUnitorStateClass =
!currentState && !currentState &&
stateInfo.find((state) => state.attributes && stateUsesUnits(state)); stateInfo.find((state) => state.a && attributesHaveUnits(state.a));
let unit: string | undefined; let unit: string | undefined;
if (currentState && stateUsesUnits(currentState)) { if (currentState && stateUsesUnits(currentState)) {
unit = currentState.attributes.unit_of_measurement || " "; unit = currentState.attributes.unit_of_measurement || " ";
} else if (stateWithUnitorStateClass) { } else if (stateWithUnitorStateClass) {
unit = stateWithUnitorStateClass.attributes.unit_of_measurement || " "; unit = stateWithUnitorStateClass.a.unit_of_measurement || " ";
} else { } else {
unit = { unit = {
climate: hass.config.unit_system.temperature, climate: hass.config.unit_system.temperature,
@ -348,12 +412,15 @@ export const computeHistory = (
if (!unit) { if (!unit) {
timelineDevices.push( timelineDevices.push(
processTimelineEntity(localize, hass.locale, stateInfo) processTimelineEntity(localize, hass.locale, entityId, stateInfo)
); );
} else if (unit in lineChartDevices) { } else if (unit in lineChartDevices && entityId in lineChartDevices[unit]) {
lineChartDevices[unit].push(stateInfo); lineChartDevices[unit][entityId].push(...stateInfo);
} else { } else {
lineChartDevices[unit] = [stateInfo]; if (!(unit in lineChartDevices)) {
lineChartDevices[unit] = {};
}
lineChartDevices[unit][entityId] = stateInfo;
} }
}); });

View File

@ -42,8 +42,18 @@ export const domainToName = (
manifest?: IntegrationManifest manifest?: IntegrationManifest
) => localize(`component.${domain}.title`) || manifest?.name || domain; ) => localize(`component.${domain}.title`) || manifest?.name || domain;
export const fetchIntegrationManifests = (hass: HomeAssistant) => export const fetchIntegrationManifests = (
hass.callWS<IntegrationManifest[]>({ type: "manifest/list" }); hass: HomeAssistant,
integrations?: string[]
) => {
const params: any = {
type: "manifest/list",
};
if (integrations) {
params.integrations = integrations;
}
return hass.callWS<IntegrationManifest[]>(params);
};
export const fetchIntegrationManifest = ( export const fetchIntegrationManifest = (
hass: HomeAssistant, hass: HomeAssistant,

View File

@ -1,5 +1,9 @@
import { HassEntity } from "home-assistant-js-websocket"; import { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket";
import { BINARY_STATE_OFF, BINARY_STATE_ON } from "../common/const"; import {
BINARY_STATE_OFF,
BINARY_STATE_ON,
DOMAINS_WITH_DYNAMIC_PICTURE,
} from "../common/const";
import { computeDomain } from "../common/entity/compute_domain"; import { computeDomain } from "../common/entity/compute_domain";
import { computeStateDisplay } from "../common/entity/compute_state_display"; import { computeStateDisplay } from "../common/entity/compute_state_display";
import { LocalizeFunc } from "../common/translations/localize"; import { LocalizeFunc } from "../common/translations/localize";
@ -9,25 +13,51 @@ import { UNAVAILABLE_STATES } from "./entity";
const LOGBOOK_LOCALIZE_PATH = "ui.components.logbook.messages"; const LOGBOOK_LOCALIZE_PATH = "ui.components.logbook.messages";
export const CONTINUOUS_DOMAINS = ["proximity", "sensor"]; export const CONTINUOUS_DOMAINS = ["proximity", "sensor"];
export interface LogbookStreamMessage {
events: LogbookEntry[];
start_time?: number; // Start time of this historical chunk
end_time?: number; // End time of this historical chunk
partial?: boolean; // Indiciates more historical chunks are coming
}
export interface LogbookEntry { export interface LogbookEntry {
when: string; // Base data
when: number; // Python timestamp. Do *1000 to get JS timestamp.
name: string; name: string;
message?: string; message?: string;
entity_id?: string; entity_id?: string;
icon?: string; icon?: string;
source?: string; source?: string; // The trigger source
domain?: string; domain?: string;
state?: string; // The state of the entity
// Context data
context_id?: string; context_id?: string;
context_user_id?: string; context_user_id?: string;
context_event_type?: string; context_event_type?: string;
context_domain?: string; context_domain?: string;
context_service?: string; context_service?: string; // Service calls only
context_entity_id?: string; context_entity_id?: string;
context_entity_id_name?: string; context_entity_id_name?: string; // Legacy, not longer sent
context_name?: string; context_name?: string;
state?: string; context_state?: string; // The state of the entity
context_source?: string; // The trigger source
context_message?: string;
} }
//
// Localization mapping for all the triggers in core
// in homeassistant.components.homeassistant.triggers
//
const triggerPhrases = {
"numeric state of": "triggered_by_numeric_state_of", // number state trigger
"state of": "triggered_by_state_of", // state trigger
event: "triggered_by_event", // event trigger
time: "triggered_by_time", // time trigger
"time pattern": "triggered_by_time_pattern", // time trigger
"Home Assistant stopping": "triggered_by_homeassistant_stopping", // stop event
"Home Assistant starting": "triggered_by_homeassistant_starting", // start event
};
const DATA_CACHE: { const DATA_CACHE: {
[cacheKey: string]: { [entityId: string]: Promise<LogbookEntry[]> }; [cacheKey: string]: { [entityId: string]: Promise<LogbookEntry[]> };
} = {}; } = {};
@ -37,18 +67,13 @@ export const getLogbookDataForContext = async (
startDate: string, startDate: string,
contextId?: string contextId?: string
): Promise<LogbookEntry[]> => { ): Promise<LogbookEntry[]> => {
const localize = await hass.loadBackendTranslation("device_class"); await hass.loadBackendTranslation("device_class");
return addLogbookMessage( return getLogbookDataFromServer(
hass, hass,
localize, startDate,
await getLogbookDataFromServer( undefined,
hass, undefined,
startDate, contextId
undefined,
undefined,
undefined,
contextId
)
); );
}; };
@ -56,107 +81,123 @@ export const getLogbookData = async (
hass: HomeAssistant, hass: HomeAssistant,
startDate: string, startDate: string,
endDate: string, endDate: string,
entityId?: string, entityIds?: string[],
entity_matches_only?: boolean deviceIds?: string[]
): Promise<LogbookEntry[]> => { ): Promise<LogbookEntry[]> => {
const localize = await hass.loadBackendTranslation("device_class"); await hass.loadBackendTranslation("device_class");
return addLogbookMessage( return deviceIds?.length
hass, ? getLogbookDataFromServer(
localize,
await getLogbookDataCache(
hass,
startDate,
endDate,
entityId,
entity_matches_only
)
);
};
export const addLogbookMessage = (
hass: HomeAssistant,
localize: LocalizeFunc,
logbookData: LogbookEntry[]
): LogbookEntry[] => {
for (const entry of logbookData) {
const stateObj = hass!.states[entry.entity_id!];
if (entry.state && stateObj) {
entry.message = getLogbookMessage(
hass, hass,
localize, startDate,
entry.state, endDate,
stateObj, entityIds,
computeDomain(entry.entity_id!) undefined,
); deviceIds
} )
} : getLogbookDataCache(hass, startDate, endDate, entityIds);
return logbookData;
}; };
export const getLogbookDataCache = async ( const getLogbookDataCache = async (
hass: HomeAssistant, hass: HomeAssistant,
startDate: string, startDate: string,
endDate: string, endDate: string,
entityId?: string, entityId?: string[]
entity_matches_only?: boolean
) => { ) => {
const ALL_ENTITIES = "*"; const ALL_ENTITIES = "*";
if (!entityId) { const entityIdKey = entityId ? entityId.toString() : ALL_ENTITIES;
entityId = ALL_ENTITIES;
}
const cacheKey = `${startDate}${endDate}`; const cacheKey = `${startDate}${endDate}`;
if (!DATA_CACHE[cacheKey]) { if (!DATA_CACHE[cacheKey]) {
DATA_CACHE[cacheKey] = {}; DATA_CACHE[cacheKey] = {};
} }
if (entityId in DATA_CACHE[cacheKey]) { if (entityIdKey in DATA_CACHE[cacheKey]) {
return DATA_CACHE[cacheKey][entityId]; return DATA_CACHE[cacheKey][entityIdKey];
} }
if (entityId !== ALL_ENTITIES && DATA_CACHE[cacheKey][ALL_ENTITIES]) { if (entityId && DATA_CACHE[cacheKey][ALL_ENTITIES]) {
const entities = await DATA_CACHE[cacheKey][ALL_ENTITIES]; const entities = await DATA_CACHE[cacheKey][ALL_ENTITIES];
return entities.filter((entity) => entity.entity_id === entityId); return entities.filter(
(entity) => entity.entity_id && entityId.includes(entity.entity_id)
);
} }
DATA_CACHE[cacheKey][entityId] = getLogbookDataFromServer( DATA_CACHE[cacheKey][entityIdKey] = getLogbookDataFromServer(
hass, hass,
startDate, startDate,
endDate, endDate,
entityId !== ALL_ENTITIES ? entityId : undefined, entityId
entity_matches_only );
).then((entries) => entries.reverse()); return DATA_CACHE[cacheKey][entityIdKey];
return DATA_CACHE[cacheKey][entityId];
}; };
const getLogbookDataFromServer = async ( const getLogbookDataFromServer = (
hass: HomeAssistant, hass: HomeAssistant,
startDate: string, startDate: string,
endDate?: string, endDate?: string,
entityId?: string, entityIds?: string[],
entitymatchesOnly?: boolean, contextId?: string,
contextId?: string deviceIds?: string[]
) => { ): Promise<LogbookEntry[]> => {
const params = new URLSearchParams(); // If all specified filters are empty lists, we can return an empty list.
if (
(entityIds || deviceIds) &&
(!entityIds || entityIds.length === 0) &&
(!deviceIds || deviceIds.length === 0)
) {
return Promise.resolve([]);
}
const params: any = {
type: "logbook/get_events",
start_time: startDate,
};
if (endDate) { if (endDate) {
params.append("end_time", endDate); params.end_time = endDate;
} }
if (entityId) { if (entityIds?.length) {
params.append("entity", entityId); params.entity_ids = entityIds;
} }
if (entitymatchesOnly) { if (deviceIds?.length) {
params.append("entity_matches_only", ""); params.device_ids = deviceIds;
} }
if (contextId) { if (contextId) {
params.append("context_id", contextId); params.context_id = contextId;
} }
return hass.callWS<LogbookEntry[]>(params);
};
return hass.callApi<LogbookEntry[]>( export const subscribeLogbook = (
"GET", hass: HomeAssistant,
`logbook/${startDate}?${params.toString()}` callbackFunction: (message: LogbookStreamMessage) => void,
startDate: string,
endDate: string,
entityIds?: string[],
deviceIds?: string[]
): Promise<UnsubscribeFunc> => {
// If all specified filters are empty lists, we can return an empty list.
if (
(entityIds || deviceIds) &&
(!entityIds || entityIds.length === 0) &&
(!deviceIds || deviceIds.length === 0)
) {
return Promise.reject("No entities or devices");
}
const params: any = {
type: "logbook/event_stream",
start_time: startDate,
end_time: endDate,
};
if (entityIds?.length) {
params.entity_ids = entityIds;
}
if (deviceIds?.length) {
params.device_ids = deviceIds;
}
return hass.connection.subscribeMessage<LogbookStreamMessage>(
(message) => callbackFunction(message),
params
); );
}; };
@ -164,7 +205,49 @@ export const clearLogbookCache = (startDate: string, endDate: string) => {
DATA_CACHE[`${startDate}${endDate}`] = {}; DATA_CACHE[`${startDate}${endDate}`] = {};
}; };
export const getLogbookMessage = ( export const createHistoricState = (
currentStateObj: HassEntity,
state?: string
): HassEntity => <HassEntity>(<unknown>{
entity_id: currentStateObj.entity_id,
state: state,
attributes: {
// Rebuild the historical state by copying static attributes only
device_class: currentStateObj?.attributes.device_class,
source_type: currentStateObj?.attributes.source_type,
has_date: currentStateObj?.attributes.has_date,
has_time: currentStateObj?.attributes.has_time,
// We do not want to use dynamic entity pictures (e.g., from media player) for the log book rendering,
// as they would present a false state in the log (played media right now vs actual historic data).
entity_picture_local: DOMAINS_WITH_DYNAMIC_PICTURE.has(
computeDomain(currentStateObj.entity_id)
)
? undefined
: currentStateObj?.attributes.entity_picture_local,
entity_picture: DOMAINS_WITH_DYNAMIC_PICTURE.has(
computeDomain(currentStateObj.entity_id)
)
? undefined
: currentStateObj?.attributes.entity_picture,
},
});
export const localizeTriggerSource = (
localize: LocalizeFunc,
source: string
) => {
for (const triggerPhrase in triggerPhrases) {
if (source.startsWith(triggerPhrase)) {
return source.replace(
triggerPhrase,
`${localize(`ui.components.logbook.${triggerPhrases[triggerPhrase]}`)}`
);
}
}
return source;
};
export const localizeStateMessage = (
hass: HomeAssistant, hass: HomeAssistant,
localize: LocalizeFunc, localize: LocalizeFunc,
state: string, state: string,

View File

@ -131,9 +131,9 @@ export interface CallServiceActionConfig extends BaseActionConfig {
action: "call-service"; action: "call-service";
service: string; service: string;
target?: HassServiceTarget; target?: HassServiceTarget;
service_data?: { // "service_data" is kept for backwards compatibility. Replaced by "data".
[key: string]: any; service_data?: Record<string, unknown>;
}; data?: Record<string, unknown>;
} }
export interface NavigateActionConfig extends BaseActionConfig { export interface NavigateActionConfig extends BaseActionConfig {
@ -159,6 +159,7 @@ export interface CustomActionConfig extends BaseActionConfig {
} }
export interface BaseActionConfig { export interface BaseActionConfig {
action: string;
confirmation?: ConfirmationRestrictionConfig; confirmation?: ConfirmationRestrictionConfig;
} }

View File

@ -47,12 +47,17 @@ export interface SceneConfig {
name: string; name: string;
icon?: string; icon?: string;
entities: SceneEntities; entities: SceneEntities;
metadata?: SceneMetaData;
} }
export interface SceneEntities { export interface SceneEntities {
[entityId: string]: string | { state: string; [key: string]: any }; [entityId: string]: string | { state: string; [key: string]: any };
} }
export interface SceneMetaData {
[entityId: string]: { entity_only?: boolean | undefined };
}
export const activateScene = ( export const activateScene = (
hass: HomeAssistant, hass: HomeAssistant,
entityId: string entityId: string

View File

@ -52,7 +52,7 @@ export const getHassTranslations = async (
hass: HomeAssistant, hass: HomeAssistant,
language: string, language: string,
category: TranslationCategory, category: TranslationCategory,
integration?: string, integration?: string | string[],
config_flow?: boolean config_flow?: boolean
): Promise<Record<string, unknown>> => { ): Promise<Record<string, unknown>> => {
const result = await hass.callWS<{ resources: Record<string, unknown> }>({ const result = await hass.callWS<{ resources: Record<string, unknown> }>({

View File

@ -7,7 +7,10 @@ import type {
import { BINARY_STATE_ON } from "../common/const"; import { BINARY_STATE_ON } from "../common/const";
import { computeDomain } from "../common/entity/compute_domain"; import { computeDomain } from "../common/entity/compute_domain";
import { computeStateDomain } from "../common/entity/compute_state_domain"; import { computeStateDomain } from "../common/entity/compute_state_domain";
import { supportsFeature } from "../common/entity/supports-feature"; import {
supportsFeature,
supportsFeatureFromAttributes,
} from "../common/entity/supports-feature";
import { caseInsensitiveStringCompare } from "../common/string/compare"; import { caseInsensitiveStringCompare } from "../common/string/compare";
import { showAlertDialog } from "../dialogs/generic/show-dialog-box"; import { showAlertDialog } from "../dialogs/generic/show-dialog-box";
import { HomeAssistant } from "../types"; import { HomeAssistant } from "../types";
@ -35,8 +38,13 @@ export interface UpdateEntity extends HassEntityBase {
} }
export const updateUsesProgress = (entity: UpdateEntity): boolean => export const updateUsesProgress = (entity: UpdateEntity): boolean =>
supportsFeature(entity, UPDATE_SUPPORT_PROGRESS) && updateUsesProgressFromAttributes(entity.attributes);
typeof entity.attributes.in_progress === "number";
export const updateUsesProgressFromAttributes = (attributes: {
[key: string]: any;
}): boolean =>
supportsFeatureFromAttributes(attributes, UPDATE_SUPPORT_PROGRESS) &&
typeof attributes.in_progress === "number";
export const updateCanInstall = ( export const updateCanInstall = (
entity: UpdateEntity, entity: UpdateEntity,
@ -49,6 +57,11 @@ export const updateCanInstall = (
export const updateIsInstalling = (entity: UpdateEntity): boolean => export const updateIsInstalling = (entity: UpdateEntity): boolean =>
updateUsesProgress(entity) || !!entity.attributes.in_progress; updateUsesProgress(entity) || !!entity.attributes.in_progress;
export const updateIsInstallingFromAttributes = (attributes: {
[key: string]: any;
}): boolean =>
updateUsesProgressFromAttributes(attributes) || !!attributes.in_progress;
export const updateReleaseNotes = (hass: HomeAssistant, entityId: string) => export const updateReleaseNotes = (hass: HomeAssistant, entityId: string) =>
hass.callWS<string | null>({ hass.callWS<string | null>({
type: "update/release_notes", type: "update/release_notes",

View File

@ -2,9 +2,21 @@ import {
mdiAlertCircleOutline, mdiAlertCircleOutline,
mdiGauge, mdiGauge,
mdiWaterPercent, mdiWaterPercent,
mdiWeatherCloudy,
mdiWeatherFog, mdiWeatherFog,
mdiWeatherHail,
mdiWeatherLightning,
mdiWeatherLightningRainy,
mdiWeatherNight,
mdiWeatherNightPartlyCloudy,
mdiWeatherPartlyCloudy,
mdiWeatherPouring,
mdiWeatherRainy, mdiWeatherRainy,
mdiWeatherSnowy,
mdiWeatherSnowyRainy,
mdiWeatherSunny,
mdiWeatherWindy, mdiWeatherWindy,
mdiWeatherWindyVariant,
} from "@mdi/js"; } from "@mdi/js";
import { import {
HassEntityAttributeBase, HassEntityAttributeBase,
@ -57,7 +69,21 @@ export const weatherSVGs = new Set<string>([
]); ]);
export const weatherIcons = { export const weatherIcons = {
"clear-night": mdiWeatherNight,
cloudy: mdiWeatherCloudy,
exceptional: mdiAlertCircleOutline, exceptional: mdiAlertCircleOutline,
fog: mdiWeatherFog,
hail: mdiWeatherHail,
lightning: mdiWeatherLightning,
"lightning-rainy": mdiWeatherLightningRainy,
partlycloudy: mdiWeatherPartlyCloudy,
pouring: mdiWeatherPouring,
rainy: mdiWeatherRainy,
snowy: mdiWeatherSnowy,
"snowy-rainy": mdiWeatherSnowyRainy,
sunny: mdiWeatherSunny,
windy: mdiWeatherWindy,
"windy-variant": mdiWeatherWindyVariant,
}; };
export const weatherAttrIcons = { export const weatherAttrIcons = {
@ -437,6 +463,13 @@ export const getWeatherStateIcon = (
return undefined; return undefined;
}; };
export const weatherIcon = (state?: string, nightTime?: boolean): string =>
!state
? undefined
: nightTime && state === "partlycloudy"
? mdiWeatherNightPartlyCloudy
: weatherIcons[state];
const DAY_IN_MILLISECONDS = 86400000; const DAY_IN_MILLISECONDS = 86400000;
export const isForecastHourly = ( export const isForecastHourly = (

View File

@ -145,7 +145,7 @@ export interface ZWaveJSController {
supports_timers: boolean; supports_timers: boolean;
is_heal_network_active: boolean; is_heal_network_active: boolean;
inclusion_state: InclusionState; inclusion_state: InclusionState;
nodes: number[]; nodes: ZWaveJSNodeStatus[];
} }
export interface ZWaveJSNodeStatus { export interface ZWaveJSNodeStatus {
@ -167,6 +167,9 @@ export interface ZwaveJSNodeMetadata {
wakeup: string; wakeup: string;
reset: string; reset: string;
device_database_url: string; device_database_url: string;
}
export interface ZwaveJSNodeComments {
comments: ZWaveJSNodeComment[]; comments: ZWaveJSNodeComment[];
} }
@ -200,8 +203,7 @@ export interface ZWaveJSNodeConfigParamMetadata {
export interface ZWaveJSSetConfigParamData { export interface ZWaveJSSetConfigParamData {
type: string; type: string;
entry_id: string; device_id: string;
node_id: number;
property: number; property: number;
property_key?: number; property_key?: number;
value: string | number; value: string | number;
@ -228,6 +230,20 @@ export interface ZWaveJSHealNetworkStatusMessage {
heal_node_status: { [key: number]: string }; heal_node_status: { [key: number]: string };
} }
export interface ZWaveJSControllerStatisticsUpdatedMessage {
event: "statistics updated";
source: "controller";
messages_tx: number;
messages_rx: number;
messages_dropped_tx: number;
messages_dropped_rx: number;
nak: number;
can: number;
timeout_ack: number;
timeout_response: number;
timeout_callback: number;
}
export interface ZWaveJSRemovedNode { export interface ZWaveJSRemovedNode {
node_id: number; node_id: number;
manufacturer: string; manufacturer: string;
@ -285,12 +301,23 @@ export const migrateZwave = (
export const fetchZwaveNetworkStatus = ( export const fetchZwaveNetworkStatus = (
hass: HomeAssistant, hass: HomeAssistant,
entry_id: string device_or_entry_id: {
): Promise<ZWaveJSNetwork> => device_id?: string;
hass.callWS({ entry_id?: string;
}
): Promise<ZWaveJSNetwork> => {
if (device_or_entry_id.device_id && device_or_entry_id.entry_id) {
throw new Error("Only one of device or entry ID should be supplied.");
}
if (!device_or_entry_id.device_id && !device_or_entry_id.entry_id) {
throw new Error("Either device or entry ID should be supplied.");
}
return hass.callWS({
type: "zwave_js/network_status", type: "zwave_js/network_status",
entry_id, device_id: device_or_entry_id.device_id,
entry_id: device_or_entry_id.entry_id,
}); });
};
export const fetchZwaveDataCollectionStatus = ( export const fetchZwaveDataCollectionStatus = (
hass: HomeAssistant, hass: HomeAssistant,
@ -427,49 +454,50 @@ export const unprovisionZwaveSmartStartNode = (
export const fetchZwaveNodeStatus = ( export const fetchZwaveNodeStatus = (
hass: HomeAssistant, hass: HomeAssistant,
entry_id: string, device_id: string
node_id: number
): Promise<ZWaveJSNodeStatus> => ): Promise<ZWaveJSNodeStatus> =>
hass.callWS({ hass.callWS({
type: "zwave_js/node_status", type: "zwave_js/node_status",
entry_id, device_id,
node_id,
}); });
export const fetchZwaveNodeMetadata = ( export const fetchZwaveNodeMetadata = (
hass: HomeAssistant, hass: HomeAssistant,
entry_id: string, device_id: string
node_id: number
): Promise<ZwaveJSNodeMetadata> => ): Promise<ZwaveJSNodeMetadata> =>
hass.callWS({ hass.callWS({
type: "zwave_js/node_metadata", type: "zwave_js/node_metadata",
entry_id, device_id,
node_id, });
export const fetchZwaveNodeComments = (
hass: HomeAssistant,
device_id: string
): Promise<ZwaveJSNodeComments> =>
hass.callWS({
type: "zwave_js/node_comments",
device_id,
}); });
export const fetchZwaveNodeConfigParameters = ( export const fetchZwaveNodeConfigParameters = (
hass: HomeAssistant, hass: HomeAssistant,
entry_id: string, device_id: string
node_id: number
): Promise<ZWaveJSNodeConfigParams> => ): Promise<ZWaveJSNodeConfigParams> =>
hass.callWS({ hass.callWS({
type: "zwave_js/get_config_parameters", type: "zwave_js/get_config_parameters",
entry_id, device_id,
node_id,
}); });
export const setZwaveNodeConfigParameter = ( export const setZwaveNodeConfigParameter = (
hass: HomeAssistant, hass: HomeAssistant,
entry_id: string, device_id: string,
node_id: number,
property: number, property: number,
value: number, value: number,
property_key?: number property_key?: number
): Promise<ZWaveJSSetConfigParamResult> => { ): Promise<ZWaveJSSetConfigParamResult> => {
const data: ZWaveJSSetConfigParamData = { const data: ZWaveJSSetConfigParamData = {
type: "zwave_js/set_config_parameter", type: "zwave_js/set_config_parameter",
entry_id, device_id,
node_id,
property, property,
value, value,
property_key, property_key,
@ -479,42 +507,36 @@ export const setZwaveNodeConfigParameter = (
export const reinterviewZwaveNode = ( export const reinterviewZwaveNode = (
hass: HomeAssistant, hass: HomeAssistant,
entry_id: string, device_id: string,
node_id: number,
callbackFunction: (message: ZWaveJSRefreshNodeStatusMessage) => void callbackFunction: (message: ZWaveJSRefreshNodeStatusMessage) => void
): Promise<UnsubscribeFunc> => ): Promise<UnsubscribeFunc> =>
hass.connection.subscribeMessage( hass.connection.subscribeMessage(
(message: any) => callbackFunction(message), (message: any) => callbackFunction(message),
{ {
type: "zwave_js/refresh_node_info", type: "zwave_js/refresh_node_info",
entry_id, device_id,
node_id,
} }
); );
export const healZwaveNode = ( export const healZwaveNode = (
hass: HomeAssistant, hass: HomeAssistant,
entry_id: string, device_id: string
node_id: number
): Promise<boolean> => ): Promise<boolean> =>
hass.callWS({ hass.callWS({
type: "zwave_js/heal_node", type: "zwave_js/heal_node",
entry_id, device_id,
node_id,
}); });
export const removeFailedZwaveNode = ( export const removeFailedZwaveNode = (
hass: HomeAssistant, hass: HomeAssistant,
entry_id: string, device_id: string,
node_id: number,
callbackFunction: (message: any) => void callbackFunction: (message: any) => void
): Promise<UnsubscribeFunc> => ): Promise<UnsubscribeFunc> =>
hass.connection.subscribeMessage( hass.connection.subscribeMessage(
(message: any) => callbackFunction(message), (message: any) => callbackFunction(message),
{ {
type: "zwave_js/remove_failed_node", type: "zwave_js/remove_failed_node",
entry_id, device_id,
node_id,
} }
); );
@ -538,16 +560,14 @@ export const stopHealZwaveNetwork = (
export const subscribeZwaveNodeReady = ( export const subscribeZwaveNodeReady = (
hass: HomeAssistant, hass: HomeAssistant,
entry_id: string, device_id: string,
node_id: number,
callbackFunction: (message) => void callbackFunction: (message) => void
): Promise<UnsubscribeFunc> => ): Promise<UnsubscribeFunc> =>
hass.connection.subscribeMessage( hass.connection.subscribeMessage(
(message: any) => callbackFunction(message), (message: any) => callbackFunction(message),
{ {
type: "zwave_js/node_ready", type: "zwave_js/node_ready",
entry_id, device_id,
node_id,
} }
); );
@ -564,6 +584,19 @@ export const subscribeHealZwaveNetworkProgress = (
} }
); );
export const subscribeZwaveControllerStatistics = (
hass: HomeAssistant,
entry_id: string,
callbackFunction: (message: ZWaveJSControllerStatisticsUpdatedMessage) => void
): Promise<UnsubscribeFunc> =>
hass.connection.subscribeMessage(
(message: any) => callbackFunction(message),
{
type: "zwave_js/subscribe_controller_statistics",
entry_id,
}
);
export const getZwaveJsIdentifiersFromDevice = ( export const getZwaveJsIdentifiersFromDevice = (
device: DeviceRegistryEntry device: DeviceRegistryEntry
): ZWaveJSNodeIdentifiers | undefined => { ): ZWaveJSNodeIdentifiers | undefined => {

View File

@ -309,7 +309,7 @@ class DataEntryFlowDialog extends LitElement {
: this._step.type === "abort" : this._step.type === "abort"
? html` ? html`
<step-flow-abort <step-flow-abort
.flowConfig=${this._params.flowConfig} .params=${this._params}
.step=${this._step} .step=${this._step}
.hass=${this.hass} .hass=${this.hass}
.domain=${this._step.handler} .domain=${this._step.handler}
@ -518,10 +518,9 @@ class DataEntryFlowDialog extends LitElement {
position: absolute; position: absolute;
top: 0; top: 0;
right: 0; right: 0;
} inset-inline-start: initial;
:host-context([style*="direction: rtl;"]) .dialog-actions { inset-inline-end: 0px;
right: auto; direction: var(--direction);
left: 0;
} }
.dialog-actions > * { .dialog-actions > * {
color: var(--secondary-text-color); color: var(--secondary-text-color);

View File

@ -131,6 +131,7 @@ export interface DataEntryFlowDialogParams {
}) => void; }) => void;
flowConfig: FlowConfig; flowConfig: FlowConfig;
showAdvanced?: boolean; showAdvanced?: boolean;
dialogParentElement?: HTMLElement;
} }
export const loadDataEntryFlowDialog = () => import("./dialog-data-entry-flow"); export const loadDataEntryFlowDialog = () => import("./dialog-data-entry-flow");
@ -146,6 +147,7 @@ export const showFlowDialog = (
dialogParams: { dialogParams: {
...dialogParams, ...dialogParams,
flowConfig, flowConfig,
dialogParentElement: element,
}, },
}); });
}; };

View File

@ -1,15 +1,25 @@
import "@material/mwc-button"; import "@material/mwc-button";
import { CSSResultGroup, html, LitElement, TemplateResult } from "lit"; import {
CSSResultGroup,
html,
LitElement,
TemplateResult,
PropertyValues,
} 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 { DataEntryFlowStepAbort } from "../../data/data_entry_flow"; import { DataEntryFlowStepAbort } from "../../data/data_entry_flow";
import { HomeAssistant } from "../../types"; import { HomeAssistant } from "../../types";
import { FlowConfig } from "./show-dialog-data-entry-flow"; import { showAddApplicationCredentialDialog } from "../../panels/config/application_credentials/show-dialog-add-application-credential";
import { configFlowContentStyles } from "./styles"; import { configFlowContentStyles } from "./styles";
import { showConfirmationDialog } from "../generic/show-dialog-box";
import { domainToName } from "../../data/integration";
import { DataEntryFlowDialogParams } from "./show-dialog-data-entry-flow";
import { showConfigFlowDialog } from "./show-dialog-config-flow";
@customElement("step-flow-abort") @customElement("step-flow-abort")
class StepFlowAbort extends LitElement { class StepFlowAbort extends LitElement {
@property({ attribute: false }) public flowConfig!: FlowConfig; @property({ attribute: false }) public params!: DataEntryFlowDialogParams;
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@ -17,11 +27,21 @@ class StepFlowAbort extends LitElement {
@property({ attribute: false }) public domain!: string; @property({ attribute: false }) public domain!: string;
protected firstUpdated(changed: PropertyValues) {
super.firstUpdated(changed);
if (this.step.reason === "missing_credentials") {
this._handleMissingCreds();
}
}
protected render(): TemplateResult { protected render(): TemplateResult {
if (this.step.reason === "missing_credentials") {
return html``;
}
return html` return html`
<h2>${this.hass.localize(`component.${this.domain}.title`)}</h2> <h2>${this.hass.localize(`component.${this.domain}.title`)}</h2>
<div class="content"> <div class="content">
${this.flowConfig.renderAbortDescription(this.hass, this.step)} ${this.params.flowConfig.renderAbortDescription(this.hass, this.step)}
</div> </div>
<div class="buttons"> <div class="buttons">
<mwc-button @click=${this._flowDone} <mwc-button @click=${this._flowDone}
@ -33,6 +53,32 @@ class StepFlowAbort extends LitElement {
`; `;
} }
private async _handleMissingCreds() {
const confirm = await showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.integrations.config_flow.missing_credentials",
{
integration: domainToName(this.hass.localize, this.domain),
}
),
});
this._flowDone();
if (!confirm) {
return;
}
// Prompt to enter credentials and restart integration setup
showAddApplicationCredentialDialog(this.params.dialogParentElement!, {
selectedDomain: this.domain,
applicationCredentialAddedCallback: () => {
showConfigFlowDialog(this.params.dialogParentElement!, {
dialogClosedCallback: this.params.dialogClosedCallback,
startFlowHandler: this.domain,
showAdvanced: this.hass.userData?.showAdvanced,
});
},
});
}
private _flowDone(): void { private _flowDone(): void {
fireEvent(this, "flow-update", { step: undefined }); fireEvent(this, "flow-update", { step: undefined });
} }

View File

@ -192,11 +192,8 @@ class StepFlowForm extends LitElement {
} }
h2 { h2 {
word-break: break-word; word-break: break-word;
padding-right: 72px; padding-inline-end: 72px;
} direction: var(--direction);
:host-context([style*="direction: rtl;"]) h2 {
padding-right: auto !important;
padding-left: 72px !important;
} }
`, `,
]; ];

View File

@ -104,11 +104,8 @@ class StepFlowPickFlow extends LitElement {
margin: 16px 0; margin: 16px 0;
} }
h2 { h2 {
padding-right: 66px; padding-inline-end: 66px;
} direction: var(--direction);
:host-context([style*="direction: rtl;"]) h2 {
padding-right: auto !important;
padding-left: 66px !important;
} }
@media all and (max-height: 900px) { @media all and (max-height: 900px) {
div { div {

View File

@ -311,11 +311,8 @@ class StepFlowPickHandler extends LitElement {
border-bottom-color: var(--divider-color); border-bottom-color: var(--divider-color);
} }
h2 { h2 {
padding-right: 66px; padding-inline-end: 66px;
} direction: var(--direction);
:host-context([style*="direction: rtl;"]) h2 {
padding-right: auto !important;
padding-left: 66px !important;
} }
@media all and (max-height: 900px) { @media all and (max-height: 900px) {
mwc-list { mwc-list {

View File

@ -3,7 +3,11 @@ import { css } from "lit";
export const configFlowContentStyles = css` export const configFlowContentStyles = css`
h2 { h2 {
margin: 24px 38px 0 0; margin: 24px 38px 0 0;
margin-inline-start: 0px;
margin-inline-end: 38px;
padding: 0 24px; padding: 0 24px;
padding-inline-start: 24px;
padding-inline-end: 24px;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
font-family: var( font-family: var(

View File

@ -1,12 +1,13 @@
import "@material/mwc-button/mwc-button"; import "@material/mwc-button/mwc-button";
import { mdiAlertOutline } from "@mdi/js"; import { mdiAlertOutline } from "@mdi/js";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, query, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-dialog"; import "../../components/ha-dialog";
import "../../components/ha-svg-icon"; import "../../components/ha-svg-icon";
import "../../components/ha-switch"; import "../../components/ha-switch";
import "../../components/ha-textfield"; import { HaTextField } from "../../components/ha-textfield";
import { haStyleDialog } from "../../resources/styles"; import { haStyleDialog } from "../../resources/styles";
import { HomeAssistant } from "../../types"; import { HomeAssistant } from "../../types";
import { DialogBoxParams } from "./show-dialog-box"; import { DialogBoxParams } from "./show-dialog-box";
@ -17,13 +18,10 @@ class DialogBox extends LitElement {
@state() private _params?: DialogBoxParams; @state() private _params?: DialogBoxParams;
@state() private _value?: string; @query("ha-textfield") private _textField?: HaTextField;
public async showDialog(params: DialogBoxParams): Promise<void> { public async showDialog(params: DialogBoxParams): Promise<void> {
this._params = params; this._params = params;
if (params.prompt) {
this._value = params.defaultValue;
}
} }
public closeDialog(): boolean { public closeDialog(): boolean {
@ -75,9 +73,7 @@ class DialogBox extends LitElement {
? html` ? html`
<ha-textfield <ha-textfield
dialogInitialFocus dialogInitialFocus
.value=${this._value || ""} value=${ifDefined(this._params.defaultValue)}
@keyup=${this._handleKeyUp}
@input=${this._valueChanged}
.label=${this._params.inputLabel .label=${this._params.inputLabel
? this._params.inputLabel ? this._params.inputLabel
: ""} : ""}
@ -109,10 +105,6 @@ class DialogBox extends LitElement {
`; `;
} }
private _valueChanged(ev) {
this._value = ev.target.value;
}
private _dismiss(): void { private _dismiss(): void {
if (this._params?.cancel) { if (this._params?.cancel) {
this._params.cancel(); this._params.cancel();
@ -120,15 +112,9 @@ class DialogBox extends LitElement {
this._close(); this._close();
} }
private _handleKeyUp(ev: KeyboardEvent) {
if (ev.keyCode === 13) {
this._confirm();
}
}
private _confirm(): void { private _confirm(): void {
if (this._params!.confirm) { if (this._params!.confirm) {
this._params!.confirm(this._value); this._params!.confirm(this._textField?.value);
} }
this._close(); this._close();
} }

View File

@ -1,6 +1,9 @@
import { HASSDomEvent, ValidHassDomEvent } from "../common/dom/fire_event"; import { HASSDomEvent, ValidHassDomEvent } from "../common/dom/fire_event";
import { mainWindow } from "../common/dom/get_main_window"; import { mainWindow } from "../common/dom/get_main_window";
import { ProvideHassElement } from "../mixins/provide-hass-lit-mixin"; import { ProvideHassElement } from "../mixins/provide-hass-lit-mixin";
import { ancestorsWithProperty } from "../common/dom/ancestors-with-property";
import { deepActiveElement } from "../common/dom/deep-active-element";
import { nextRender } from "../common/util/render-status";
declare global { declare global {
// for fire event // for fire event
@ -40,7 +43,17 @@ export interface DialogState {
dialogParams?: unknown; dialogParams?: unknown;
} }
const LOADED = {}; interface LoadedDialogInfo {
element: Promise<HassDialog>;
closedFocusTargets?: Set<Element>;
}
interface LoadedDialogsDict {
[tag: string]: LoadedDialogInfo;
}
const LOADED: LoadedDialogsDict = {};
export const FOCUS_TARGET = Symbol.for("HA focus target");
export const showDialog = async ( export const showDialog = async (
element: HTMLElement & ProvideHassElement, element: HTMLElement & ProvideHassElement,
@ -60,11 +73,25 @@ export const showDialog = async (
} }
return; return;
} }
LOADED[dialogTag] = dialogImport().then(() => { LOADED[dialogTag] = {
const dialogEl = document.createElement(dialogTag) as HassDialog; element: dialogImport().then(() => {
element.provideHass(dialogEl); const dialogEl = document.createElement(dialogTag) as HassDialog;
return dialogEl; element.provideHass(dialogEl);
}); return dialogEl;
}),
};
}
// Get the focus targets after the dialog closes, but keep the original if dialog is being replaced
if (mainWindow.history.state?.replaced) {
LOADED[dialogTag].closedFocusTargets =
LOADED[mainWindow.history.state.dialog].closedFocusTargets;
delete LOADED[mainWindow.history.state.dialog].closedFocusTargets;
} else {
LOADED[dialogTag].closedFocusTargets = ancestorsWithProperty(
deepActiveElement(),
FOCUS_TARGET
);
} }
if (addHistory) { if (addHistory) {
@ -93,25 +120,29 @@ export const showDialog = async (
); );
} }
} }
const dialogElement = await LOADED[dialogTag];
const dialogElement = await LOADED[dialogTag].element;
dialogElement.addEventListener("dialog-closed", _handleClosedFocus);
// Append it again so it's the last element in the root, // Append it again so it's the last element in the root,
// so it's guaranteed to be on top of the other elements // so it's guaranteed to be on top of the other elements
root.appendChild(dialogElement); root.appendChild(dialogElement);
dialogElement.showDialog(dialogParams); dialogElement.showDialog(dialogParams);
}; };
export const replaceDialog = () => { export const replaceDialog = (dialogElement: HassDialog) => {
mainWindow.history.replaceState( mainWindow.history.replaceState(
{ ...mainWindow.history.state, replaced: true }, { ...mainWindow.history.state, replaced: true },
"" ""
); );
dialogElement.removeEventListener("dialog-closed", _handleClosedFocus);
}; };
export const closeDialog = async (dialogTag: string): Promise<boolean> => { export const closeDialog = async (dialogTag: string): Promise<boolean> => {
if (!(dialogTag in LOADED)) { if (!(dialogTag in LOADED)) {
return true; return true;
} }
const dialogElement: HassDialog = await LOADED[dialogTag]; const dialogElement = await LOADED[dialogTag].element;
if (dialogElement.closeDialog) { if (dialogElement.closeDialog) {
return dialogElement.closeDialog() !== false; return dialogElement.closeDialog() !== false;
} }
@ -137,3 +168,33 @@ export const makeDialogManager = (
} }
); );
}; };
const _handleClosedFocus = async (ev: HASSDomEvent<DialogClosedParams>) => {
const closedFocusTargets = LOADED[ev.detail.dialog].closedFocusTargets;
delete LOADED[ev.detail.dialog].closedFocusTargets;
if (!closedFocusTargets) return;
// Undo whatever the browser focused to provide easy checking
let focusedElement = deepActiveElement();
if (focusedElement instanceof HTMLElement) focusedElement.blur();
// Make sure backdrop is fully updated before trying (especially needed for underlay dialogs)
await nextRender();
// Try all targets in order and stop when one works
for (const focusTarget of closedFocusTargets) {
if (focusTarget instanceof HTMLElement) {
focusTarget.focus();
focusedElement = deepActiveElement();
if (focusedElement && focusedElement !== document.body) return;
}
}
if (__DEV__) {
// eslint-disable-next-line no-console
console.warn(
"Failed to focus any targets after closing dialog: %o",
closedFocusTargets
);
}
};

View File

@ -1,35 +1,14 @@
import { import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
css,
CSSResultGroup,
html,
LitElement,
PropertyValues,
TemplateResult,
} from "lit";
import { property, state } from "lit/decorators"; import { property, state } from "lit/decorators";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { supportsFeature } from "../../../common/entity/supports-feature";
import "../../../components/ha-camera-stream"; import "../../../components/ha-camera-stream";
import type { HaCheckbox } from "../../../components/ha-checkbox"; import { CameraEntity } from "../../../data/camera";
import "../../../components/ha-checkbox";
import {
CameraEntity,
CameraPreferences,
CAMERA_SUPPORT_STREAM,
fetchCameraPrefs,
STREAM_TYPE_HLS,
updateCameraPrefs,
} from "../../../data/camera";
import type { HomeAssistant } from "../../../types"; import type { HomeAssistant } from "../../../types";
import "../../../components/ha-formfield";
class MoreInfoCamera extends LitElement { class MoreInfoCamera extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant; @property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public stateObj?: CameraEntity; @property({ attribute: false }) public stateObj?: CameraEntity;
@state() private _cameraPrefs?: CameraPreferences;
@state() private _attached = false; @state() private _attached = false;
public connectedCallback() { public connectedCallback() {
@ -54,83 +33,13 @@ class MoreInfoCamera extends LitElement {
allow-exoplayer allow-exoplayer
controls controls
></ha-camera-stream> ></ha-camera-stream>
${this._cameraPrefs
? html`
<ha-formfield label="Preload stream">
<ha-checkbox
.checked=${this._cameraPrefs.preload_stream}
@change=${this._handleCheckboxChanged}
>
</ha-checkbox>
</ha-formfield>
`
: undefined}
`; `;
} }
protected updated(changedProps: PropertyValues) {
if (!changedProps.has("stateObj")) {
return;
}
const oldState = changedProps.get("stateObj") as this["stateObj"];
const oldEntityId = oldState ? oldState.entity_id : undefined;
const curEntityId = this.stateObj ? this.stateObj.entity_id : undefined;
// Same entity, ignore.
if (curEntityId === oldEntityId) {
return;
}
if (
curEntityId &&
isComponentLoaded(this.hass!, "stream") &&
supportsFeature(this.stateObj!, CAMERA_SUPPORT_STREAM) &&
// The stream component for HLS streams supports a server-side pre-load
// option that client initiated WebRTC streams do not
this.stateObj!.attributes.frontend_stream_type === STREAM_TYPE_HLS
) {
// Fetch in background while we set up the video.
this._fetchCameraPrefs();
}
}
private async _fetchCameraPrefs() {
this._cameraPrefs = await fetchCameraPrefs(
this.hass!,
this.stateObj!.entity_id
);
}
private async _handleCheckboxChanged(ev) {
const checkbox = ev.currentTarget as HaCheckbox;
try {
this._cameraPrefs = await updateCameraPrefs(
this.hass!,
this.stateObj!.entity_id,
{
preload_stream: checkbox.checked!,
}
);
} catch (err: any) {
alert(err.message);
checkbox.checked = !checkbox.checked;
}
}
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return css` return css`
:host { :host {
display: block; display: block;
position: relative;
}
ha-formfield {
position: absolute;
top: 0;
right: 0;
background-color: var(--secondary-background-color);
padding-right: 16px;
border-bottom-left-radius: 4px;
} }
`; `;
} }

View File

@ -211,6 +211,7 @@ class MoreInfoMediaPlayer extends LitElement {
.controls { .controls {
display: flex; display: flex;
flex-wrap: wrap;
align-items: center; align-items: center;
--mdc-theme-primary: currentColor; --mdc-theme-primary: currentColor;
} }

View File

@ -1,23 +1,9 @@
import { import {
mdiAlertCircleOutline,
mdiEye, mdiEye,
mdiGauge, mdiGauge,
mdiThermometer, mdiThermometer,
mdiWaterPercent, mdiWaterPercent,
mdiWeatherCloudy,
mdiWeatherFog,
mdiWeatherHail,
mdiWeatherLightning,
mdiWeatherLightningRainy,
mdiWeatherNight,
mdiWeatherPartlyCloudy,
mdiWeatherPouring,
mdiWeatherRainy,
mdiWeatherSnowy,
mdiWeatherSnowyRainy,
mdiWeatherSunny,
mdiWeatherWindy, mdiWeatherWindy,
mdiWeatherWindyVariant,
} from "@mdi/js"; } from "@mdi/js";
import { HassEntity } from "home-assistant-js-websocket"; import { HassEntity } from "home-assistant-js-websocket";
import { import {
@ -37,27 +23,10 @@ import {
getWeatherUnit, getWeatherUnit,
getWind, getWind,
isForecastHourly, isForecastHourly,
weatherIcons,
} from "../../../data/weather"; } from "../../../data/weather";
import { HomeAssistant } from "../../../types"; import { HomeAssistant } from "../../../types";
const weatherIcons = {
"clear-night": mdiWeatherNight,
cloudy: mdiWeatherCloudy,
exceptional: mdiAlertCircleOutline,
fog: mdiWeatherFog,
hail: mdiWeatherHail,
lightning: mdiWeatherLightning,
"lightning-rainy": mdiWeatherLightningRainy,
partlycloudy: mdiWeatherPartlyCloudy,
pouring: mdiWeatherPouring,
rainy: mdiWeatherRainy,
snowy: mdiWeatherSnowy,
"snowy-rainy": mdiWeatherSnowyRainy,
sunny: mdiWeatherSunny,
windy: mdiWeatherWindy,
"windy-variant": mdiWeatherWindyVariant,
};
@customElement("more-info-weather") @customElement("more-info-weather")
class MoreInfoWeather extends LitElement { class MoreInfoWeather extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@ -235,6 +204,7 @@ class MoreInfoWeather extends LitElement {
return css` return css`
ha-svg-icon { ha-svg-icon {
color: var(--paper-item-icon-color); color: var(--paper-item-icon-color);
margin-left: 8px;
} }
.section { .section {
margin: 16px 0 8px 0; margin: 16px 0 8px 0;

View File

@ -295,7 +295,7 @@ export class MoreInfoDialog extends LitElement {
} }
private _gotoSettings() { private _gotoSettings() {
replaceDialog(); replaceDialog(this);
showEntityEditorDialog(this, { showEntityEditorDialog(this, {
entity_id: this._entityId!, entity_id: this._entityId!,
}); });

View File

@ -1,4 +1,4 @@
import { startOfYesterday } from "date-fns"; import { startOfYesterday } from "date-fns/esm";
import { css, html, LitElement, PropertyValues, TemplateResult } from "lit"; import { css, html, LitElement, PropertyValues, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../common/config/is_component_loaded"; import { isComponentLoaded } from "../../common/config/is_component_loaded";

View File

@ -1,17 +1,11 @@
import { startOfYesterday } from "date-fns"; import { startOfYesterday } from "date-fns/esm";
import { css, html, LitElement, PropertyValues, TemplateResult } from "lit"; import { css, html, LitElement, PropertyValues, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../common/config/is_component_loaded"; import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { throttle } from "../../common/util/throttle";
import "../../components/ha-circular-progress";
import { getLogbookData, LogbookEntry } from "../../data/logbook";
import { loadTraceContexts, TraceContexts } from "../../data/trace";
import { fetchUsers } from "../../data/user";
import "../../panels/logbook/ha-logbook"; import "../../panels/logbook/ha-logbook";
import { haStyle } from "../../resources/styles"; import type { HomeAssistant } from "../../types";
import { HomeAssistant } from "../../types";
@customElement("ha-more-info-logbook") @customElement("ha-more-info-logbook")
export class MoreInfoLogbook extends LitElement { export class MoreInfoLogbook extends LitElement {
@ -19,26 +13,14 @@ export class MoreInfoLogbook extends LitElement {
@property() public entityId!: string; @property() public entityId!: string;
@state() private _logbookEntries?: LogbookEntry[];
@state() private _traceContexts?: TraceContexts;
@state() private _userIdToName = {};
private _lastLogbookDate?: Date;
private _fetchUserPromise?: Promise<void>;
private _error?: string;
private _showMoreHref = ""; private _showMoreHref = "";
private _throttleGetLogbookEntries = throttle(() => { private _time = { recent: 86400 };
this._getLogBookData();
}, 10000); private _entityIdAsList = memoizeOne((entityId: string) => [entityId]);
protected render(): TemplateResult { protected render(): TemplateResult {
if (!this.entityId) { if (!isComponentLoaded(this.hass, "logbook") || !this.entityId) {
return html``; return html``;
} }
const stateObj = this.hass.states[this.entityId]; const stateObj = this.hass.states[this.entityId];
@ -48,150 +30,34 @@ export class MoreInfoLogbook extends LitElement {
} }
return html` return html`
${isComponentLoaded(this.hass, "logbook") <div class="header">
? this._error <div class="title">
? html`<div class="no-entries"> ${this.hass.localize("ui.dialogs.more_info_control.logbook")}
${`${this.hass.localize( </div>
"ui.components.logbook.retrieval_error" <a href=${this._showMoreHref} @click=${this._close}
)}: ${this._error}`} >${this.hass.localize("ui.dialogs.more_info_control.show_more")}</a
</div>` >
: !this._logbookEntries </div>
? html` <ha-logbook
<ha-circular-progress .hass=${this.hass}
active .time=${this._time}
alt=${this.hass.localize("ui.common.loading")} .entityIds=${this._entityIdAsList(this.entityId)}
></ha-circular-progress> narrow
` no-icon
: this._logbookEntries.length no-name
? html` relative-time
<div class="header"> ></ha-logbook>
<div class="title">
${this.hass.localize("ui.dialogs.more_info_control.logbook")}
</div>
<a href=${this._showMoreHref} @click=${this._close}
>${this.hass.localize(
"ui.dialogs.more_info_control.show_more"
)}</a
>
</div>
<ha-logbook
narrow
no-icon
no-name
relative-time
.hass=${this.hass}
.entries=${this._logbookEntries}
.traceContexts=${this._traceContexts}
.userIdToName=${this._userIdToName}
></ha-logbook>
`
: html`<div class="no-entries">
${this.hass.localize("ui.components.logbook.entries_not_found")}
</div>`
: ""}
`; `;
} }
protected firstUpdated(): void { protected willUpdate(changedProps: PropertyValues): void {
this._fetchUserPromise = this._fetchUserNames(); super.willUpdate(changedProps);
}
protected updated(changedProps: PropertyValues): void {
super.updated(changedProps);
if (changedProps.has("entityId")) {
this._lastLogbookDate = undefined;
this._logbookEntries = undefined;
if (!this.entityId) {
return;
}
if (changedProps.has("entityId") && this.entityId) {
this._showMoreHref = `/logbook?entity_id=${ this._showMoreHref = `/logbook?entity_id=${
this.entityId this.entityId
}&start_date=${startOfYesterday().toISOString()}`; }&start_date=${startOfYesterday().toISOString()}`;
this._throttleGetLogbookEntries();
return;
} }
if (!this.entityId || !changedProps.has("hass")) {
return;
}
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (
oldHass &&
this.hass.states[this.entityId] !== oldHass?.states[this.entityId]
) {
// wait for commit of data (we only account for the default setting of 1 sec)
setTimeout(this._throttleGetLogbookEntries, 1000);
}
}
private async _getLogBookData() {
if (!isComponentLoaded(this.hass, "logbook")) {
return;
}
const lastDate =
this._lastLogbookDate ||
new Date(new Date().getTime() - 24 * 60 * 60 * 1000);
const now = new Date();
let newEntries;
let traceContexts;
try {
[newEntries, traceContexts] = await Promise.all([
getLogbookData(
this.hass,
lastDate.toISOString(),
now.toISOString(),
this.entityId,
true
),
this.hass.user?.is_admin ? loadTraceContexts(this.hass) : {},
this._fetchUserPromise,
]);
} catch (err: any) {
this._error = err.message;
}
this._logbookEntries = this._logbookEntries
? [...newEntries, ...this._logbookEntries]
: newEntries;
this._lastLogbookDate = now;
this._traceContexts = traceContexts;
}
private async _fetchUserNames() {
const userIdToName = {};
// Start loading users
const userProm = this.hass.user?.is_admin && fetchUsers(this.hass);
// Process persons
Object.values(this.hass.states).forEach((entity) => {
if (
entity.attributes.user_id &&
computeStateDomain(entity) === "person"
) {
this._userIdToName[entity.attributes.user_id] =
entity.attributes.friendly_name;
}
});
// Process users
if (userProm) {
const users = await userProm;
for (const user of users) {
if (!(user.id in userIdToName)) {
userIdToName[user.id] = user.name;
}
}
}
this._userIdToName = userIdToName;
} }
private _close(): void { private _close(): void {
@ -200,13 +66,7 @@ export class MoreInfoLogbook extends LitElement {
static get styles() { static get styles() {
return [ return [
haStyle,
css` css`
.no-entries {
text-align: center;
padding: 16px;
color: var(--secondary-text-color);
}
ha-logbook { ha-logbook {
--logbook-max-height: 250px; --logbook-max-height: 250px;
} }
@ -215,10 +75,6 @@ export class MoreInfoLogbook extends LitElement {
--logbook-max-height: unset; --logbook-max-height: unset;
} }
} }
ha-circular-progress {
display: flex;
justify-content: center;
}
.header { .header {
display: flex; display: flex;
flex-direction: row; flex-direction: row;

View File

@ -49,12 +49,14 @@ class OnboardingIntegrations extends LitElement {
this.hass.loadBackendTranslation("title", undefined, true); this.hass.loadBackendTranslation("title", undefined, true);
this._unsubEvents = subscribeConfigFlowInProgress(this.hass, (flows) => { this._unsubEvents = subscribeConfigFlowInProgress(this.hass, (flows) => {
this._discovered = flows; this._discovered = flows;
const integrations: Set<string> = new Set();
for (const flow of flows) { for (const flow of flows) {
// To render title placeholders // To render title placeholders
if (flow.context.title_placeholders) { if (flow.context.title_placeholders) {
this.hass.loadBackendTranslation("config", flow.handler); integrations.add(flow.handler);
} }
} }
this.hass.loadBackendTranslation("config", Array.from(integrations));
}); });
} }

View File

@ -336,6 +336,9 @@ export class HAFullCalendar extends LitElement {
.today { .today {
margin-right: 20px; margin-right: 20px;
margin-inline-end: 20px;
margin-inline-start: initial;
direction: var(--direction);
} }
.prev, .prev,

View File

@ -194,10 +194,13 @@ class PanelCalendar extends LitElement {
.calendar-list { .calendar-list {
padding-right: 16px; padding-right: 16px;
padding-inline-end: 16px;
padding-inline-start: initial;
min-width: 170px; min-width: 170px;
flex: 0 0 15%; flex: 0 0 15%;
overflow: hidden; overflow: hidden;
--mdc-theme-text-primary-on-background: var(--primary-text-color); --mdc-theme-text-primary-on-background: var(--primary-text-color);
direction: var(--direction);
} }
.calendar-list > div { .calendar-list > div {

View File

@ -0,0 +1,246 @@
import "@material/mwc-button";
import "@material/mwc-list/mwc-list-item";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
import { ComboBoxLitRenderer } from "lit-vaadin-helpers";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-circular-progress";
import "../../../components/ha-combo-box";
import { createCloseHeading } from "../../../components/ha-dialog";
import "../../../components/ha-textfield";
import {
fetchApplicationCredentialsConfig,
createApplicationCredential,
ApplicationCredential,
} from "../../../data/application_credential";
import { domainToName } from "../../../data/integration";
import { haStyleDialog } from "../../../resources/styles";
import { HomeAssistant } from "../../../types";
import { AddApplicationCredentialDialogParams } from "./show-dialog-add-application-credential";
interface Domain {
id: string;
name: string;
}
const rowRenderer: ComboBoxLitRenderer<Domain> = (item) => html`<mwc-list-item>
<span>${item.name}</span>
</mwc-list-item>`;
@customElement("dialog-add-application-credential")
export class DialogAddApplicationCredential extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _loading = false;
// Error message when can't talk to server etc
@state() private _error?: string;
@state() private _params?: AddApplicationCredentialDialogParams;
@state() private _domain?: string;
@state() private _name?: string;
@state() private _clientId?: string;
@state() private _clientSecret?: string;
@state() private _domains?: Domain[];
public showDialog(params: AddApplicationCredentialDialogParams) {
this._params = params;
this._domain =
params.selectedDomain !== undefined ? params.selectedDomain : "";
this._name = "";
this._clientId = "";
this._clientSecret = "";
this._error = undefined;
this._loading = false;
this._fetchConfig();
}
private async _fetchConfig() {
const config = await fetchApplicationCredentialsConfig(this.hass);
this._domains = config.domains.map((domain) => ({
id: domain,
name: domainToName(this.hass.localize, domain),
}));
}
protected render(): TemplateResult {
if (!this._params || !this._domains) {
return html``;
}
return html`
<ha-dialog
open
@closed=${this._abortDialog}
scrimClickAction
escapeKeyAction
.heading=${createCloseHeading(
this.hass,
this.hass.localize(
"ui.panel.config.application_credentials.editor.caption"
)
)}
>
<div>
${this._error ? html` <div class="error">${this._error}</div> ` : ""}
<ha-combo-box
name="domain"
.hass=${this.hass}
.disabled=${!!this._params.selectedDomain}
.label=${this.hass.localize(
"ui.panel.config.application_credentials.editor.domain"
)}
.value=${this._domain}
.renderer=${rowRenderer}
.items=${this._domains}
item-id-path="id"
item-value-path="id"
item-label-path="name"
required
@value-changed=${this._handleDomainPicked}
></ha-combo-box>
<ha-textfield
class="name"
name="name"
.label=${this.hass.localize(
"ui.panel.config.application_credentials.editor.name"
)}
.value=${this._name}
required
@input=${this._handleValueChanged}
error-message=${this.hass.localize("ui.common.error_required")}
dialogInitialFocus
></ha-textfield>
<ha-textfield
class="clientId"
name="clientId"
.label=${this.hass.localize(
"ui.panel.config.application_credentials.editor.client_id"
)}
.value=${this._clientId}
required
@input=${this._handleValueChanged}
error-message=${this.hass.localize("ui.common.error_required")}
dialogInitialFocus
></ha-textfield>
<ha-textfield
.label=${this.hass.localize(
"ui.panel.config.application_credentials.editor.client_secret"
)}
type="password"
name="clientSecret"
.value=${this._clientSecret}
required
@input=${this._handleValueChanged}
error-message=${this.hass.localize("ui.common.error_required")}
></ha-textfield>
</div>
${this._loading
? html`
<div slot="primaryAction" class="submit-spinner">
<ha-circular-progress active></ha-circular-progress>
</div>
`
: html`
<mwc-button
slot="primaryAction"
.disabled=${!this._domain ||
!this._clientId ||
!this._clientSecret}
@click=${this._createApplicationCredential}
>
${this.hass.localize(
"ui.panel.config.application_credentials.editor.create"
)}
</mwc-button>
`}
</ha-dialog>
`;
}
public closeDialog() {
this._params = undefined;
this._domains = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
private async _handleDomainPicked(ev: CustomEvent) {
ev.stopPropagation();
this._domain = ev.detail.value;
}
private _handleValueChanged(ev: CustomEvent) {
this._error = undefined;
const name = (ev.target as any).name;
const value = (ev.target as any).value;
this[`_${name}`] = value;
}
private _abortDialog() {
if (this._params && this._params.dialogAbortedCallback) {
this._params.dialogAbortedCallback();
}
this.closeDialog();
}
private async _createApplicationCredential(ev) {
ev.preventDefault();
if (!this._domain || !this._clientId || !this._clientSecret) {
return;
}
this._loading = true;
this._error = "";
let applicationCredential: ApplicationCredential;
try {
applicationCredential = await createApplicationCredential(
this.hass,
this._domain,
this._clientId,
this._clientSecret,
this._name
);
} catch (err: any) {
this._loading = false;
this._error = err.message;
return;
}
this._params!.applicationCredentialAddedCallback(applicationCredential);
this.closeDialog();
}
static get styles(): CSSResultGroup {
return [
haStyleDialog,
css`
ha-dialog {
--mdc-dialog-max-width: 500px;
--dialog-z-index: 10;
}
.row {
display: flex;
padding: 8px 0;
}
ha-combo-box {
display: block;
margin-bottom: 24px;
}
ha-textfield {
display: block;
margin-bottom: 24px;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-add-application-credential": DialogAddApplicationCredential;
}
}

View File

@ -0,0 +1,285 @@
import { mdiDelete, mdiPlus } from "@mdi/js";
import { css, CSSResultGroup, html, LitElement, PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import { LocalizeFunc } from "../../../common/translations/localize";
import {
DataTableColumnContainer,
SelectionChangedEvent,
} from "../../../components/data-table/ha-data-table";
import "../../../components/data-table/ha-data-table-icon";
import "../../../components/ha-fab";
import "../../../components/ha-help-tooltip";
import "../../../components/ha-svg-icon";
import {
ApplicationCredential,
deleteApplicationCredential,
fetchApplicationCredentials,
} from "../../../data/application_credential";
import { domainToName } from "../../../data/integration";
import {
showAlertDialog,
showConfirmationDialog,
} from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-tabs-subpage-data-table";
import type { HaTabsSubpageDataTable } from "../../../layouts/hass-tabs-subpage-data-table";
import { HomeAssistant, Route } from "../../../types";
import { configSections } from "../ha-panel-config";
import { showAddApplicationCredentialDialog } from "./show-dialog-add-application-credential";
@customElement("ha-config-application-credentials")
export class HaConfigApplicationCredentials extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() public _applicationCredentials: ApplicationCredential[] = [];
@property() public isWide!: boolean;
@property() public narrow!: boolean;
@property() public route!: Route;
@state() private _selected: string[] = [];
@query("hass-tabs-subpage-data-table", true)
private _dataTable!: HaTabsSubpageDataTable;
private _columns = memoizeOne(
(narrow: boolean, localize: LocalizeFunc): DataTableColumnContainer => {
const columns: DataTableColumnContainer<ApplicationCredential> = {
name: {
title: localize(
"ui.panel.config.application_credentials.picker.headers.name"
),
width: "40%",
direction: "asc",
grows: true,
template: (_, entry: ApplicationCredential) => html`${entry.name}`,
},
clientId: {
title: localize(
"ui.panel.config.application_credentials.picker.headers.client_id"
),
width: "30%",
direction: "asc",
hidden: narrow,
template: (_, entry: ApplicationCredential) =>
html`${entry.client_id}`,
},
application: {
title: localize(
"ui.panel.config.application_credentials.picker.headers.application"
),
sortable: true,
width: "30%",
direction: "asc",
template: (_, entry) => html`${domainToName(localize, entry.domain)}`,
},
};
return columns;
}
);
protected firstUpdated(changedProperties: PropertyValues) {
super.firstUpdated(changedProperties);
this._loadTranslations();
this._fetchApplicationCredentials();
}
protected render() {
return html`
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
.route=${this.route}
backPath="/config"
.tabs=${configSections.devices}
.columns=${this._columns(this.narrow, this.hass.localize)}
.data=${this._applicationCredentials}
hasFab
selectable
@selection-changed=${this._handleSelectionChanged}
>
${this._selected.length
? html`
<div
class=${classMap({
"header-toolbar": this.narrow,
"table-header": !this.narrow,
})}
slot="header"
>
<p class="selected-txt">
${this.hass.localize(
"ui.panel.config.application_credentials.picker.selected",
"number",
this._selected.length
)}
</p>
<div class="header-btns">
${!this.narrow
? html`
<mwc-button
@click=${this._removeSelected}
class="warning"
>${this.hass.localize(
"ui.panel.config.application_credentials.picker.remove_selected.button"
)}</mwc-button
>
`
: html`
<ha-icon-button
class="warning"
id="remove-btn"
@click=${this._removeSelected}
.path=${mdiDelete}
.label=${this.hass.localize("ui.common.remove")}
></ha-icon-button>
<ha-help-tooltip
.label=${this.hass.localize(
"ui.panel.config.application_credentials.picker.remove_selected.button"
)}
>
</ha-help-tooltip>
`}
</div>
</div>
`
: html``}
<ha-fab
slot="fab"
.label=${this.hass.localize(
"ui.panel.config.application_credentials.picker.add_application_credential"
)}
extended
@click=${this._addApplicationCredential}
>
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
</ha-fab>
</hass-tabs-subpage-data-table>
`;
}
private _handleSelectionChanged(
ev: HASSDomEvent<SelectionChangedEvent>
): void {
this._selected = ev.detail.value;
}
private _removeSelected() {
showConfirmationDialog(this, {
title: this.hass.localize(
`ui.panel.config.application_credentials.picker.remove_selected.confirm_title`,
"number",
this._selected.length
),
text: this.hass.localize(
"ui.panel.config.application_credentials.picker.remove_selected.confirm_text"
),
confirmText: this.hass.localize("ui.common.remove"),
dismissText: this.hass.localize("ui.common.cancel"),
confirm: async () => {
try {
await Promise.all(
this._selected.map(async (applicationCredential) => {
await deleteApplicationCredential(
this.hass,
applicationCredential
);
})
);
} catch (err: any) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.application_credentials.picker.remove_selected.error_title"
),
text: err.message,
});
return;
}
this._dataTable.clearSelection();
this._fetchApplicationCredentials();
},
});
}
private async _loadTranslations() {
await this.hass.loadBackendTranslation("title", undefined, true);
}
private async _fetchApplicationCredentials() {
this._applicationCredentials = await fetchApplicationCredentials(this.hass);
}
private _addApplicationCredential() {
showAddApplicationCredentialDialog(this, {
applicationCredentialAddedCallback: async (
applicationCredential: ApplicationCredential
) => {
if (applicationCredential) {
this._applicationCredentials = [
...this._applicationCredentials,
applicationCredential,
];
}
},
});
}
static get styles(): CSSResultGroup {
return css`
.table-header {
display: flex;
justify-content: space-between;
align-items: center;
height: 56px;
background-color: var(--mdc-text-field-fill-color, whitesmoke);
border-bottom: 1px solid
var(--mdc-text-field-idle-line-color, rgba(0, 0, 0, 0.42));
box-sizing: border-box;
}
.header-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
color: var(--secondary-text-color);
position: relative;
top: -4px;
}
.selected-txt {
font-weight: bold;
padding-left: 16px;
padding-inline-start: 16px;
direction: var(--direction);
}
.table-header .selected-txt {
margin-top: 20px;
}
.header-toolbar .selected-txt {
font-size: 16px;
}
.header-toolbar .header-btns {
margin-right: -12px;
}
.header-btns {
display: flex;
}
.header-btns > mwc-button,
.header-btns > ha-icon-button {
margin: 8px;
}
ha-button-menu {
margin-left: 8px;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-application-credentials": HaConfigApplicationCredentials;
}
}

View File

@ -0,0 +1,24 @@
import { fireEvent } from "../../../common/dom/fire_event";
import { ApplicationCredential } from "../../../data/application_credential";
export interface AddApplicationCredentialDialogParams {
applicationCredentialAddedCallback: (
applicationCredential: ApplicationCredential
) => void;
dialogAbortedCallback?: () => void;
selectedDomain?: string;
}
export const loadAddApplicationCredentialDialog = () =>
import("./dialog-add-application-credential");
export const showAddApplicationCredentialDialog = (
element: HTMLElement,
dialogParams: AddApplicationCredentialDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-add-application-credential",
dialogImport: loadAddApplicationCredentialDialog,
dialogParams,
});
};

View File

@ -2,7 +2,10 @@ import "@material/mwc-button";
import { mdiImagePlus, mdiPencil } from "@mdi/js"; import { mdiImagePlus, mdiPencil } from "@mdi/js";
import "@polymer/paper-item/paper-item"; import "@polymer/paper-item/paper-item";
import "@polymer/paper-item/paper-item-body"; import "@polymer/paper-item/paper-item-body";
import { HassEntity } from "home-assistant-js-websocket/dist/types"; import {
HassEntity,
UnsubscribeFunc,
} from "home-assistant-js-websocket/dist/types";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined"; import { ifDefined } from "lit/directives/if-defined";
@ -19,6 +22,7 @@ import "../../../components/ha-icon-next";
import { import {
AreaRegistryEntry, AreaRegistryEntry,
deleteAreaRegistryEntry, deleteAreaRegistryEntry,
subscribeAreaRegistry,
updateAreaRegistryEntry, updateAreaRegistryEntry,
} from "../../../data/area_registry"; } from "../../../data/area_registry";
import { AutomationEntity } from "../../../data/automation"; import { AutomationEntity } from "../../../data/automation";
@ -26,18 +30,22 @@ import {
computeDeviceName, computeDeviceName,
DeviceRegistryEntry, DeviceRegistryEntry,
sortDeviceRegistryByName, sortDeviceRegistryByName,
subscribeDeviceRegistry,
} from "../../../data/device_registry"; } from "../../../data/device_registry";
import { import {
computeEntityRegistryName, computeEntityRegistryName,
EntityRegistryEntry, EntityRegistryEntry,
sortEntityRegistryByName, sortEntityRegistryByName,
subscribeEntityRegistry,
} from "../../../data/entity_registry"; } from "../../../data/entity_registry";
import { SceneEntity } from "../../../data/scene"; import { SceneEntity } from "../../../data/scene";
import { ScriptEntity } from "../../../data/script"; import { ScriptEntity } from "../../../data/script";
import { findRelated, RelatedResult } from "../../../data/search"; import { findRelated, RelatedResult } from "../../../data/search";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box"; import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { haStyle } from "../../../resources/styles"; import { haStyle } from "../../../resources/styles";
import { HomeAssistant, Route } from "../../../types"; import { HomeAssistant, Route } from "../../../types";
import "../../logbook/ha-logbook";
import { showEntityEditorDialog } from "../entities/show-dialog-entity-editor"; import { showEntityEditorDialog } from "../entities/show-dialog-entity-editor";
import { configSections } from "../ha-panel-config"; import { configSections } from "../ha-panel-config";
import { import {
@ -51,17 +59,11 @@ declare type NameAndEntity<EntityType extends HassEntity> = {
}; };
@customElement("ha-config-area-page") @customElement("ha-config-area-page")
class HaConfigAreaPage extends LitElement { class HaConfigAreaPage extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@property() public areaId!: string; @property() public areaId!: string;
@property() public areas!: AreaRegistryEntry[];
@property() public devices!: DeviceRegistryEntry[];
@property() public entities!: EntityRegistryEntry[];
@property({ type: Boolean, reflect: true }) public narrow!: boolean; @property({ type: Boolean, reflect: true }) public narrow!: boolean;
@property() public isWide!: boolean; @property() public isWide!: boolean;
@ -70,8 +72,16 @@ class HaConfigAreaPage extends LitElement {
@property() public route!: Route; @property() public route!: Route;
@state() public _areas!: AreaRegistryEntry[];
@state() public _devices!: DeviceRegistryEntry[];
@state() public _entities!: EntityRegistryEntry[];
@state() private _related?: RelatedResult; @state() private _related?: RelatedResult;
private _logbookTime = { recent: 86400 };
private _area = memoizeOne( private _area = memoizeOne(
( (
areaId: string, areaId: string,
@ -86,7 +96,7 @@ class HaConfigAreaPage extends LitElement {
registryDevices: DeviceRegistryEntry[], registryDevices: DeviceRegistryEntry[],
registryEntities: EntityRegistryEntry[] registryEntities: EntityRegistryEntry[]
) => { ) => {
const devices = new Map(); const devices = new Map<string, DeviceRegistryEntry>();
for (const device of registryDevices) { for (const device of registryDevices) {
if (device.area_id === areaId) { if (device.area_id === areaId) {
@ -102,7 +112,7 @@ class HaConfigAreaPage extends LitElement {
if (entity.area_id === areaId) { if (entity.area_id === areaId) {
entities.push(entity); entities.push(entity);
} }
} else if (devices.has(entity.device_id)) { } else if (entity.device_id && devices.has(entity.device_id)) {
indirectEntities.push(entity); indirectEntities.push(entity);
} }
} }
@ -115,6 +125,20 @@ class HaConfigAreaPage extends LitElement {
} }
); );
private _allDeviceIds = memoizeOne((devices: DeviceRegistryEntry[]) =>
devices.map((device) => device.id)
);
private _allEntities = memoizeOne(
(memberships: {
entities: EntityRegistryEntry[];
indirectEntities: EntityRegistryEntry[];
}) =>
memberships.entities
.map((entry) => entry.entity_id)
.concat(memberships.indirectEntities.map((entry) => entry.entity_id))
);
protected firstUpdated(changedProps) { protected firstUpdated(changedProps) {
super.firstUpdated(changedProps); super.firstUpdated(changedProps);
loadAreaRegistryDetailDialog(); loadAreaRegistryDetailDialog();
@ -127,8 +151,26 @@ class HaConfigAreaPage extends LitElement {
} }
} }
protected hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] {
return [
subscribeAreaRegistry(this.hass.connection, (areas) => {
this._areas = areas;
}),
subscribeDeviceRegistry(this.hass.connection, (entries) => {
this._devices = entries;
}),
subscribeEntityRegistry(this.hass.connection, (entries) => {
this._entities = entries;
}),
];
}
protected render(): TemplateResult { protected render(): TemplateResult {
const area = this._area(this.areaId, this.areas); if (!this._areas || !this._devices || !this._entities) {
return html``;
}
const area = this._area(this.areaId, this._areas);
if (!area) { if (!area) {
return html` return html`
@ -139,11 +181,12 @@ class HaConfigAreaPage extends LitElement {
`; `;
} }
const { devices, entities } = this._memberships( const memberships = this._memberships(
this.areaId, this.areaId,
this.devices, this._devices,
this.entities this._entities
); );
const { devices, entities } = memberships;
// Pre-compute the entity and device names, so we can sort by them // Pre-compute the entity and device names, so we can sort by them
if (devices) { if (devices) {
@ -359,8 +402,6 @@ class HaConfigAreaPage extends LitElement {
</ha-card> </ha-card>
` `
: ""} : ""}
</div>
<div class="column">
${isComponentLoaded(this.hass, "scene") ${isComponentLoaded(this.hass, "scene")
? html` ? html`
<ha-card <ha-card
@ -442,6 +483,26 @@ class HaConfigAreaPage extends LitElement {
` `
: ""} : ""}
</div> </div>
<div class="column">
${isComponentLoaded(this.hass, "logbook")
? html`
<ha-card
outlined
.header=${this.hass.localize("panel.logbook")}
>
<ha-logbook
.hass=${this.hass}
.time=${this._logbookTime}
.entityIds=${this._allEntities(memberships)}
.deviceIds=${this._allDeviceIds(memberships.devices)}
virtualize
narrow
no-icon
></ha-logbook>
</ha-card>
`
: ""}
</div>
</div> </div>
</hass-tabs-subpage> </hass-tabs-subpage>
`; `;
@ -699,6 +760,13 @@ class HaConfigAreaPage extends LitElement {
opacity: 0.5; opacity: 0.5;
border-radius: 50%; border-radius: 50%;
} }
ha-logbook {
height: 400px;
}
:host([narrow]) ha-logbook {
height: 235px;
overflow: auto;
}
`, `,
]; ];
} }

View File

@ -1,6 +1,7 @@
import { mdiHelpCircle, mdiPlus } from "@mdi/js"; import { mdiHelpCircle, mdiPlus } from "@mdi/js";
import { UnsubscribeFunc } from "home-assistant-js-websocket";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map"; import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import "../../../components/ha-fab"; import "../../../components/ha-fab";
@ -9,12 +10,20 @@ import "../../../components/ha-svg-icon";
import { import {
AreaRegistryEntry, AreaRegistryEntry,
createAreaRegistryEntry, createAreaRegistryEntry,
subscribeAreaRegistry,
} from "../../../data/area_registry"; } from "../../../data/area_registry";
import type { DeviceRegistryEntry } from "../../../data/device_registry"; import {
import type { EntityRegistryEntry } from "../../../data/entity_registry"; DeviceRegistryEntry,
subscribeDeviceRegistry,
} from "../../../data/device_registry";
import {
EntityRegistryEntry,
subscribeEntityRegistry,
} from "../../../data/entity_registry";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box"; import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-loading-screen"; import "../../../layouts/hass-loading-screen";
import "../../../layouts/hass-tabs-subpage"; import "../../../layouts/hass-tabs-subpage";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { HomeAssistant, Route } from "../../../types"; import { HomeAssistant, Route } from "../../../types";
import "../ha-config-section"; import "../ha-config-section";
import { configSections } from "../ha-panel-config"; import { configSections } from "../ha-panel-config";
@ -24,7 +33,7 @@ import {
} from "./show-dialog-area-registry-detail"; } from "./show-dialog-area-registry-detail";
@customElement("ha-config-areas-dashboard") @customElement("ha-config-areas-dashboard")
export class HaConfigAreasDashboard extends LitElement { export class HaConfigAreasDashboard extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@property() public isWide?: boolean; @property() public isWide?: boolean;
@ -33,13 +42,13 @@ export class HaConfigAreasDashboard extends LitElement {
@property() public route!: Route; @property() public route!: Route;
@property() public areas!: AreaRegistryEntry[]; @state() private _areas!: AreaRegistryEntry[];
@property() public devices!: DeviceRegistryEntry[]; @state() private _devices!: DeviceRegistryEntry[];
@property() public entities!: EntityRegistryEntry[]; @state() private _entities!: EntityRegistryEntry[];
private _areas = memoizeOne( private _processAreas = memoizeOne(
( (
areas: AreaRegistryEntry[], areas: AreaRegistryEntry[],
devices: DeviceRegistryEntry[], devices: DeviceRegistryEntry[],
@ -75,6 +84,20 @@ export class HaConfigAreasDashboard extends LitElement {
}) })
); );
protected hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] {
return [
subscribeAreaRegistry(this.hass.connection, (areas) => {
this._areas = areas;
}),
subscribeDeviceRegistry(this.hass.connection, (entries) => {
this._devices = entries;
}),
subscribeEntityRegistry(this.hass.connection, (entries) => {
this._entities = entries;
}),
];
}
protected render(): TemplateResult { protected render(): TemplateResult {
return html` return html`
<hass-tabs-subpage <hass-tabs-subpage
@ -92,56 +115,62 @@ export class HaConfigAreasDashboard extends LitElement {
@click=${this._showHelp} @click=${this._showHelp}
></ha-icon-button> ></ha-icon-button>
<div class="container"> <div class="container">
${this._areas(this.areas, this.devices, this.entities).map( ${!this._areas || !this._devices || !this._entities
(area) => ? ""
html`<a href=${`/config/areas/area/${area.area_id}`} : this._processAreas(
><ha-card outlined> this._areas,
<div this._devices,
style=${styleMap({ this._entities
backgroundImage: area.picture ).map(
? `url(${area.picture})` (area) =>
: undefined, html`<a href=${`/config/areas/area/${area.area_id}`}
})} ><ha-card outlined>
class="picture ${!area.picture ? "placeholder" : ""}" <div
></div> style=${styleMap({
<h1 class="card-header">${area.name}</h1> backgroundImage: area.picture
<div class="card-content"> ? `url(${area.picture})`
<div> : undefined,
${area.devices })}
? html` class="picture ${!area.picture ? "placeholder" : ""}"
${this.hass.localize( ></div>
"ui.panel.config.integrations.config_entry.devices", <h1 class="card-header">${area.name}</h1>
"count", <div class="card-content">
area.devices <div>
)}${area.services ? "," : ""} ${area.devices
` ? html`
: ""} ${this.hass.localize(
${area.services "ui.panel.config.integrations.config_entry.devices",
? html` "count",
${this.hass.localize( area.devices
"ui.panel.config.integrations.config_entry.services", )}${area.services ? "," : ""}
"count", `
area.services : ""}
)} ${area.services
` ? html`
: ""} ${this.hass.localize(
${(area.devices || area.services) && area.entities "ui.panel.config.integrations.config_entry.services",
? this.hass.localize("ui.common.and") "count",
: ""} area.services
${area.entities )}
? html` `
${this.hass.localize( : ""}
"ui.panel.config.integrations.config_entry.entities", ${(area.devices || area.services) && area.entities
"count", ? this.hass.localize("ui.common.and")
area.entities : ""}
)} ${area.entities
` ? html`
: ""} ${this.hass.localize(
</div> "ui.panel.config.integrations.config_entry.entities",
</div> "count",
</ha-card></a area.entities
>` )}
)} `
: ""}
</div>
</div>
</ha-card></a
>`
)}
</div> </div>
<ha-fab <ha-fab
slot="fab" slot="fab"

View File

@ -1,20 +1,4 @@
import { UnsubscribeFunc } from "home-assistant-js-websocket"; import { customElement, property } from "lit/decorators";
import { PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import { stringCompare } from "../../../common/string/compare";
import {
AreaRegistryEntry,
subscribeAreaRegistry,
} from "../../../data/area_registry";
import { ConfigEntry, getConfigEntries } from "../../../data/config_entries";
import {
DeviceRegistryEntry,
subscribeDeviceRegistry,
} from "../../../data/device_registry";
import {
EntityRegistryEntry,
subscribeEntityRegistry,
} from "../../../data/entity_registry";
import { import {
HassRouterPage, HassRouterPage,
RouterOptions, RouterOptions,
@ -46,44 +30,6 @@ class HaConfigAreas extends HassRouterPage {
}, },
}; };
@state() private _configEntries: ConfigEntry[] = [];
@state()
private _deviceRegistryEntries: DeviceRegistryEntry[] = [];
@state()
private _entityRegistryEntries: EntityRegistryEntry[] = [];
@state() private _areas: AreaRegistryEntry[] = [];
private _unsubs?: UnsubscribeFunc[];
public connectedCallback() {
super.connectedCallback();
if (!this.hass) {
return;
}
this._loadData();
}
public disconnectedCallback() {
super.disconnectedCallback();
if (this._unsubs) {
while (this._unsubs.length) {
this._unsubs.pop()!();
}
this._unsubs = undefined;
}
}
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (!this._unsubs && changedProps.has("hass")) {
this._loadData();
}
}
protected updatePageEl(pageEl) { protected updatePageEl(pageEl) {
pageEl.hass = this.hass; pageEl.hass = this.hass;
@ -91,37 +37,11 @@ class HaConfigAreas extends HassRouterPage {
pageEl.areaId = this.routeTail.path.substr(1); pageEl.areaId = this.routeTail.path.substr(1);
} }
pageEl.entries = this._configEntries;
pageEl.devices = this._deviceRegistryEntries;
pageEl.entities = this._entityRegistryEntries;
pageEl.areas = this._areas;
pageEl.narrow = this.narrow; pageEl.narrow = this.narrow;
pageEl.isWide = this.isWide; pageEl.isWide = this.isWide;
pageEl.showAdvanced = this.showAdvanced; pageEl.showAdvanced = this.showAdvanced;
pageEl.route = this.routeTail; pageEl.route = this.routeTail;
} }
private _loadData() {
getConfigEntries(this.hass).then((configEntries) => {
this._configEntries = configEntries.sort((conf1, conf2) =>
stringCompare(conf1.title, conf2.title)
);
});
if (this._unsubs) {
return;
}
this._unsubs = [
subscribeAreaRegistry(this.hass.connection, (areas) => {
this._areas = areas;
}),
subscribeDeviceRegistry(this.hass.connection, (entries) => {
this._deviceRegistryEntries = entries;
}),
subscribeEntityRegistry(this.hass.connection, (entries) => {
this._entityRegistryEntries = entries;
}),
];
}
} }
declare global { declare global {

View File

@ -460,17 +460,13 @@ export default class HaAutomationActionRow extends LitElement {
border-top-left-radius: var(--ha-card-border-radius); border-top-left-radius: var(--ha-card-border-radius);
} }
.card-menu { .card-menu {
float: right; float: var(--float-end, right);
z-index: 3; z-index: 3;
margin: 4px; margin: 4px;
--mdc-theme-text-primary-on-background: var(--primary-text-color); --mdc-theme-text-primary-on-background: var(--primary-text-color);
display: flex; display: flex;
align-items: center; align-items: center;
} }
:host-context([style*="direction: rtl;"]) .card-menu {
right: initial;
left: 16px;
}
mwc-list-item[disabled] { mwc-list-item[disabled] {
--mdc-theme-text-primary-on-background: var(--disabled-text-color); --mdc-theme-text-primary-on-background: var(--disabled-text-color);
} }

View File

@ -288,16 +288,13 @@ export default class HaAutomationConditionRow extends LitElement {
border-top-left-radius: var(--ha-card-border-radius); border-top-left-radius: var(--ha-card-border-radius);
} }
.card-menu { .card-menu {
float: right; float: var(--float-end, right);
z-index: 3; z-index: 3;
margin: 4px; margin: 4px;
--mdc-theme-text-primary-on-background: var(--primary-text-color); --mdc-theme-text-primary-on-background: var(--primary-text-color);
display: flex; display: flex;
align-items: center; align-items: center;
} }
:host-context([style*="direction: rtl;"]) .card-menu {
float: left;
}
mwc-list-item[disabled] { mwc-list-item[disabled] {
--mdc-theme-text-primary-on-background: var(--disabled-text-color); --mdc-theme-text-primary-on-background: var(--disabled-text-color);
} }

View File

@ -487,16 +487,13 @@ export default class HaAutomationTriggerRow extends LitElement {
border-top-left-radius: var(--ha-card-border-radius); border-top-left-radius: var(--ha-card-border-radius);
} }
.card-menu { .card-menu {
float: right; float: var(--float-end, right);
z-index: 3; z-index: 3;
margin: 4px; margin: 4px;
--mdc-theme-text-primary-on-background: var(--primary-text-color); --mdc-theme-text-primary-on-background: var(--primary-text-color);
display: flex; display: flex;
align-items: center; align-items: center;
} }
:host-context([style*="direction: rtl;"]) .card-menu {
float: left;
}
.triggered { .triggered {
cursor: pointer; cursor: pointer;
position: absolute; position: absolute;

View File

@ -5,7 +5,9 @@ import { fireEvent } from "../../../../../common/dom/fire_event";
import type { CalendarTrigger } from "../../../../../data/automation"; import type { CalendarTrigger } from "../../../../../data/automation";
import type { HomeAssistant } from "../../../../../types"; import type { HomeAssistant } from "../../../../../types";
import type { TriggerElement } from "../ha-automation-trigger-row"; import type { TriggerElement } from "../ha-automation-trigger-row";
import type { HaDurationData } from "../../../../../components/ha-duration-input";
import type { HaFormSchema } from "../../../../../components/ha-form/types"; import type { HaFormSchema } from "../../../../../components/ha-form/types";
import { createDurationData } from "../../../../../common/datetime/create_duration_data";
import type { LocalizeFunc } from "../../../../../common/translations/localize"; import type { LocalizeFunc } from "../../../../../common/translations/localize";
@customElement("ha-automation-trigger-calendar") @customElement("ha-automation-trigger-calendar")
@ -39,20 +41,57 @@ export class HaCalendarTrigger extends LitElement implements TriggerElement {
], ],
], ],
}, },
{ name: "offset", selector: { duration: {} } },
{
name: "offset_type",
type: "select",
required: true,
options: [
[
"before",
localize(
"ui.panel.config.automation.editor.triggers.type.calendar.before"
),
],
[
"after",
localize(
"ui.panel.config.automation.editor.triggers.type.calendar.after"
),
],
],
},
]); ]);
public static get defaultConfig() { public static get defaultConfig() {
return { return {
event: "start" as CalendarTrigger["event"], event: "start" as CalendarTrigger["event"],
offset: 0,
}; };
} }
protected render() { protected render() {
const schema = this._schema(this.hass.localize); const schema = this._schema(this.hass.localize);
// Convert from string representation to ha form duration representation
const trigger_offset = this.trigger.offset;
const duration: HaDurationData = createDurationData(trigger_offset)!;
let offset_type = "after";
if (
(typeof trigger_offset === "object" && duration!.hours! < 0) ||
(typeof trigger_offset === "string" && trigger_offset.startsWith("-"))
) {
duration.hours = Math.abs(duration.hours!);
offset_type = "before";
}
const data = {
...this.trigger,
offset: duration,
offset_type: offset_type,
};
return html` return html`
<ha-form <ha-form
.schema=${schema} .schema=${schema}
.data=${this.trigger} .data=${data}
.hass=${this.hass} .hass=${this.hass}
.computeLabel=${this._computeLabelCallback} .computeLabel=${this._computeLabelCallback}
@value-changed=${this._valueChanged} @value-changed=${this._valueChanged}
@ -62,7 +101,14 @@ export class HaCalendarTrigger extends LitElement implements TriggerElement {
private _valueChanged(ev: CustomEvent): void { private _valueChanged(ev: CustomEvent): void {
ev.stopPropagation(); ev.stopPropagation();
const newTrigger = ev.detail.value; // Convert back to duration string representation
const duration = ev.detail.value.offset;
const offsetType = ev.detail.value.offset_type === "before" ? "-" : "";
const newTrigger = {
...ev.detail.value,
offset: `${offsetType}${duration.hours}:${duration.minutes}:${duration.seconds}`,
};
delete newTrigger.offset_type;
fireEvent(this, "value-changed", { value: newTrigger }); fireEvent(this, "value-changed", { value: newTrigger });
} }

View File

@ -88,7 +88,10 @@ export class CloudAccount extends SubscribeMixin(LitElement) {
> >
<div class="account-row"> <div class="account-row">
<paper-item-body two-line> <paper-item-body two-line>
${this.cloudStatus.email} ${this.cloudStatus.email.replace(
/(\w{3})[\w.-]+@([\w.]+\w)/,
"$1***@$2"
)}
<div secondary class="wrap"> <div secondary class="wrap">
${this._subscription ${this._subscription
? this._subscription.human_description.replace( ? this._subscription.human_description.replace(

View File

@ -1,9 +1,11 @@
import "@material/mwc-button"; import "@material/mwc-button";
import { mdiContentCopy } from "@mdi/js";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; import { css, CSSResultGroup, html, LitElement, TemplateResult } 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 "../../../../components/ha-card"; import { copyToClipboard } from "../../../../common/util/copy-clipboard";
import "../../../../components/ha-alert"; import "../../../../components/ha-alert";
import "../../../../components/ha-card";
import "../../../../components/ha-switch"; import "../../../../components/ha-switch";
// eslint-disable-next-line // eslint-disable-next-line
import type { HaSwitch } from "../../../../components/ha-switch"; import type { HaSwitch } from "../../../../components/ha-switch";
@ -13,6 +15,7 @@ import {
disconnectCloudRemote, disconnectCloudRemote,
} from "../../../../data/cloud"; } from "../../../../data/cloud";
import type { HomeAssistant } from "../../../../types"; import type { HomeAssistant } from "../../../../types";
import { showToast } from "../../../../util/toast";
import { showCloudCertificateDialog } from "../dialog-cloud-certificate/show-dialog-cloud-certificate"; import { showCloudCertificateDialog } from "../dialog-cloud-certificate/show-dialog-cloud-certificate";
@customElement("cloud-remote-pref") @customElement("cloud-remote-pref")
@ -84,9 +87,15 @@ export class CloudRemotePref extends LitElement {
target="_blank" target="_blank"
class="break-word" class="break-word"
rel="noreferrer" rel="noreferrer"
> >${this.hass.localize(
https://${remote_domain}</a "ui.panel.config.cloud.account.remote.nabu_casa_url"
)}</a
>. >.
<ha-svg-icon
.url=${`https://${remote_domain}`}
.path=${mdiContentCopy}
@click=${this._copyURL}
></ha-svg-icon>
</div> </div>
<div class="card-actions"> <div class="card-actions">
<a <a
@ -133,6 +142,14 @@ export class CloudRemotePref extends LitElement {
} }
} }
private async _copyURL(ev): Promise<void> {
const url = ev.currentTarget.url;
await copyToClipboard(url);
showToast(this, {
message: this.hass.localize("ui.common.copied_clipboard"),
});
}
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return css` return css`
.preparing { .preparing {
@ -154,9 +171,6 @@ export class CloudRemotePref extends LitElement {
font-weight: bold; font-weight: bold;
margin-bottom: 1em; margin-bottom: 1em;
} }
.warning ha-svg-icon {
color: var(--warning-color);
}
.break-word { .break-word {
overflow-wrap: break-word; overflow-wrap: break-word;
} }
@ -178,6 +192,11 @@ export class CloudRemotePref extends LitElement {
.spacer { .spacer {
flex-grow: 1; flex-grow: 1;
} }
ha-svg-icon {
--mdc-icon-size: 18px;
color: var(--secondary-text-color);
cursor: pointer;
}
`; `;
} }
} }

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