Compare commits

..

181 Commits

Author SHA1 Message Date
Paulus Schoutsen
5493fdfcb7 Bumped version to 20210416.0 2021-04-16 12:27:18 -07:00
Paulus Schoutsen
179767e9f8 Align layout of all cards (#8931)
* Align layout of all cards

* Make ignore card have normal button
2021-04-16 12:27:01 -07:00
Paulus Schoutsen
25b3bb1285 Fixes for integration cards (#8930) 2021-04-16 20:22:22 +02:00
Bram Kragten
841c8ab1f1 Update script editor (#8919) 2021-04-16 08:57:07 -07:00
Philip Allgaier
1ce17e2847 Remove non effective CSS for CM6 search panel input (#8921) 2021-04-16 16:29:36 +02:00
Philip Allgaier
a09b206b0e Added missing <ul> to beta join dialog (#8927) 2021-04-16 16:06:52 +02:00
Carlos Garcia Saura
bb4617c53b Correct two swapped supervisor beta join action/confirm texts (#8922) 2021-04-16 14:54:39 +02:00
Philip Allgaier
cfd18bfb74 Corrected "not loaded" state string (#8925) 2021-04-16 14:45:40 +02:00
Philip Allgaier
e225d6f546 Correct wording from "component" to "integration" on new integration page (#8924) 2021-04-16 14:41:38 +02:00
Paulus Schoutsen
60fe48d355 Show config entry state on card (#8911) 2021-04-16 13:16:59 +02:00
GitHub Action
2dcd0d2b0a Translation update 2021-04-16 00:48:38 +00:00
Bram Kragten
8e11aa9130 Fix activate scene button + allow removing icon (#8916) 2021-04-15 13:02:09 +02:00
Philip Allgaier
f6e223c18d Use const everywhere for "group.default_view" (#8918) 2021-04-15 09:54:32 +02:00
Bram Kragten
9d29b55bee Add z-index to add user dialog (#8917) 2021-04-15 09:46:19 +02:00
GitHub Action
92aa8580db Translation update 2021-04-15 00:48:36 +00:00
Donnie
538028a003 Refactor sequence matching to accept item rather than word array (#8866)
* Refactor sequence matching to require an item rather than array of words to filter against

* change 'words' to 'strings'. Add tsdoc description for ScorableTextItem

* Replace type checking with 'as' to clean up code
2021-04-14 15:29:10 -07:00
Carlos Garcia Saura
c53575a74f Set standard name for Cancel button, to align translations (#8914) 2021-04-14 23:09:31 +02:00
Bram Kragten
193016a46a Fix time selector + base am/pm on user language (#8908)
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2021-04-14 21:51:29 +02:00
Bram Kragten
aaa50b4d1d Don't add toast to history (#8915) 2021-04-14 12:01:42 -07:00
Bram Kragten
a43120320e Bump typescript to 4.2.4 (#8876) 2021-04-14 12:00:24 -07:00
Paulus Schoutsen
b8bb0c038d Highlight if log comes from custom component (#8912)
Co-authored-by: Joakim Sørensen <joasoe@gmail.com>
2021-04-14 11:59:00 -07:00
GitHub Action
dc79fc2919 Translation update 2021-04-14 00:48:24 +00:00
Philip Allgaier
30787fef60 Hide new light color mode attributes in more-info (#8895) 2021-04-13 20:23:58 +02:00
J. Nick Koston
445ae156ef Unsubscribe when dismissing during wrap up (#8909) 2021-04-13 20:18:37 +02:00
Jakub Dąbrowski
62a0cfb0f6 Fix computing cards (#8894)
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2021-04-13 16:25:48 +02:00
LJU
96bc3ef99a Improve spelling (#8901) 2021-04-13 15:49:53 +02:00
GitHub Action
1d3b95d24f Translation update 2021-04-13 00:49:02 +00:00
Bram Kragten
56fe4b07f3 Show toast with call service error (#8904) 2021-04-12 17:10:25 -07:00
Jakub Dąbrowski
ea60f7005b Fix saving entities of the device in scene editor (#8884) 2021-04-12 23:04:35 +02:00
Philip Allgaier
9eb59062aa Increase supervisor metric value span width to account for blank (#8885) 2021-04-12 23:02:09 +02:00
Bram Kragten
d00927c31f Update codemirror (#8903) 2021-04-12 22:04:58 +02:00
Charles Garwood
c03017208d Remove link/text about ZHA/Z-Wave config panels moving to integration page (#8867) 2021-04-12 20:17:31 +02:00
GitHub Action
73f945458a Translation update 2021-04-12 00:48:46 +00:00
GitHub Action
db12234611 Translation update 2021-04-11 00:48:30 +00:00
GitHub Action
ed1cd4632f Translation update 2021-04-10 00:48:37 +00:00
Paulus Schoutsen
9833accc79 Fix failed conditions reason (#8870) 2021-04-08 23:01:12 -07:00
GitHub Action
d46123771a Translation update 2021-04-09 00:48:50 +00:00
Charles Garwood
87fe84b1ac Add units to Z-Wave JS Node Config inputs (#8869)
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2021-04-08 16:32:47 -07:00
Bram Kragten
21140f437e Update value of date input (#8865) 2021-04-08 16:31:46 -07:00
Paulus Schoutsen
ba9e410393 Pass narrow (#8864) 2021-04-08 22:59:24 +02:00
Paulus Schoutsen
587fb2a170 Add logbook note (#8843)
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2021-04-08 20:52:37 +02:00
Bram Kragten
7d801ff84c Handle choose being null (#8859)
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2021-04-08 20:48:49 +02:00
Bram Kragten
d69accd9a5 Add dev import buttons for debugging traces (#8860) 2021-04-08 11:32:31 -07:00
J. Nick Koston
1127750c5e Show which integrations are being setup at startup (#8834)
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2021-04-08 07:30:47 -10:00
Bram Kragten
7758bd89c1 Check if logbook component loaded when fetching trace (#8861) 2021-04-08 09:04:08 -07:00
Philip Allgaier
de7264327a Do not use "media_play_pause" but atomic services instead (#8845) 2021-04-08 16:47:04 +02:00
Philip Allgaier
c3f0932794 Use number format setting for attribute rows (#8844) 2021-04-08 10:52:10 +02:00
Philip Allgaier
367907e037 Mention unique ID requirement in trace button tooltip (#8853) 2021-04-08 09:47:25 +02:00
Donnie
2d15bd651e Fix spinner regression and remove unnecessary twoline config (#8847) 2021-04-07 21:18:55 -07:00
GitHub Action
4b1d7863f8 Translation update 2021-04-08 00:48:34 +00:00
Paulus Schoutsen
e425d768dd Remove owner guard from analytics (#8842) 2021-04-07 18:41:39 +02:00
Bram Kragten
9075146b47 Bumped version to 20210407.1 2021-04-07 16:21:07 +02:00
Bram Kragten
26c4591baa Keep root state when replacing, fix subpages for menu button on mobile (#8837) 2021-04-07 12:25:17 +02:00
Bram Kragten
2aac8c55e7 Guard for trace component not loaded (#8838) 2021-04-07 12:21:19 +02:00
Bram Kragten
9d6e07ff96 Bumped version to 20210407.0 2021-04-07 10:07:35 +02:00
Bram Kragten
8f58eee6af Update hui-timer-entity-row.ts 2021-04-07 10:07:17 +02:00
Paulus Schoutsen
8dd3d78f21 Tweak the analytics screens (#8833) 2021-04-07 03:48:10 +02:00
GitHub Action
48161fd02f Translation update 2021-04-07 00:48:35 +00:00
Joakim Sørensen
b61410826d Add AppArmor reason (#8829) 2021-04-07 02:05:27 +02:00
Jaroslav Hanslík
2f0188b280 Fixed generic entity row for climate entities (#8369) 2021-04-06 22:31:47 +02:00
Bram Kragten
3a4fffdb0b Remove dynamic height/width calcs on graph nodes (#8832) 2021-04-06 22:31:22 +02:00
Bram Kragten
109910d18f Add spacer for default of choose (#8827) 2021-04-06 09:32:30 -07:00
Paulus Schoutsen
8874aaabe9 Bumped version to 20210406.0 2021-04-06 16:13:04 +00:00
Bram Kragten
cafbea9c42 Update quick bar (#8823) 2021-04-06 09:05:29 -07:00
Bram Kragten
4843ee80a7 Use checkmark only in chosen choose (#8824) 2021-04-06 09:03:04 -07:00
Bram Kragten
4511c8f30c Don't show back button when no history (#8822)
* Don't show back button when no history

* Update src/translations/en.json

Co-authored-by: Philip Allgaier <mail@spacegaier.de>

Co-authored-by: Philip Allgaier <mail@spacegaier.de>
2021-04-06 08:59:08 -07:00
Thomas Lovén
4cf1e52ac0 Select one branch at a time in choose script graphs (#8812) 2021-04-06 14:38:42 +02:00
Bram Kragten
b501b7f47c Change date picker (#8821) 2021-04-06 05:36:12 -07:00
Philip Allgaier
cc275f9877 Prevent unwanted line breaks in picture-glance tooltips (#8819) 2021-04-06 14:19:24 +02:00
Philip Allgaier
7aae55cde7 Allow manually entering entity IDs in service target entity picker (#8820) 2021-04-06 14:18:55 +02:00
GitHub Action
85eaa219c6 Translation update 2021-04-06 00:48:51 +00:00
Donnie
7d5ecb8ba4 Update fuzzy scorer from VSCode (#8793) 2021-04-05 12:15:09 -07:00
GitHub Action
1fd142d337 Translation update 2021-04-05 00:48:55 +00:00
Josh McCarty
d75c6aecbe Format input number (#8811) 2021-04-04 20:47:08 +02:00
Bram Kragten
dffe0f656d Update styling trace tabs (#8807) 2021-04-03 23:19:06 -07:00
GitHub Action
890639436b Translation update 2021-04-04 00:48:42 +00:00
Charles Garwood
99f66d7c5d Fix zwave_js config panel manual entry inputs (#8806) 2021-04-03 16:08:41 +02:00
GitHub Action
05faa52425 Translation update 2021-04-03 00:48:30 +00:00
Bram Kragten
8f6ec03446 Bumped version to 20210402.1 2021-04-02 20:48:57 +02:00
Bram Kragten
c56b4fade3 Add filtering by related entity + fixes (#8801) 2021-04-02 20:35:28 +02:00
Bram Kragten
61aaaabcb5 Add close button to import blueprint dialog (#8802) 2021-04-02 20:19:35 +02:00
Bram Kragten
d57cf93580 Fix disabled icon button color (#8800)
Fixes #8797
2021-04-02 20:18:56 +02:00
Paulus Schoutsen
82ad5c103d Handle configurations that don't wrap their action sequences in arrays (#8798) 2021-04-02 14:45:34 +02:00
GitHub Action
a0b5bc5456 Translation update 2021-04-02 00:48:47 +00:00
Bram Kragten
05ea3b8187 Make version number based on UTC time (#8796) 2021-04-01 15:33:11 -07:00
Bram Kragten
8301dffb21 Bumped version to 20210402.0 2021-04-02 00:14:02 +02:00
Bram Kragten
01be5243de Option flows dont have result (#8787) 2021-04-01 15:11:15 -07:00
Paulus Schoutsen
334196799a Improve keyboard nav (#8794) 2021-04-02 00:10:17 +02:00
Bram Kragten
c11bbcf442 Add blueprint config to trace (#8751)
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2021-04-01 23:37:46 +02:00
Bram Kragten
8e3a7576ea Align has template functions (#8784)
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2021-04-01 13:49:59 -07:00
Bram Kragten
deca6f03ba Improve ensureArray and use it in tracing (#8785)
* Improve ensureArray and use it in tracing

* Fix typing

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2021-04-01 13:33:47 -07:00
Paulus Schoutsen
401064d3c8 Render script execution state (#8789) 2021-04-01 11:29:08 -07:00
Paulus Schoutsen
b6f59d3c98 Fix the automation picker icons (#8790) 2021-04-01 11:28:56 -07:00
Bram Kragten
1fb3663398 Add sortable last trigger column to automation and script overview (#8783) 2021-04-01 09:02:58 -07:00
Paulus Schoutsen
5c1604e959 Fix showing choose actions if default path chosen and other things (#8779) 2021-04-01 10:28:37 +02:00
GitHub Action
17b1f3e465 Translation update 2021-04-01 00:48:39 +00:00
Bram Kragten
9a68bdeec1 Handle errors in trace (#8775) 2021-03-31 09:35:30 -07:00
Bram Kragten
9b947ef734 Add top level logbook entries tab (#8776) 2021-03-31 09:15:06 -07:00
Bram Kragten
d8153ac8fc Merge branch 'master' into dev 2021-03-31 17:02:45 +02:00
Bram Kragten
27d9f82f7d Bumped version to 20210331.0 2021-03-31 16:59:29 +02:00
Bram Kragten
5b55bcd879 Use logbook for trace logbook items (#8773) 2021-03-31 16:57:48 +02:00
Bram Kragten
5cfd28881b Update cloud-google-pref.ts 2021-03-31 16:53:18 +02:00
Philip Allgaier
bc54a42e01 Fix typo in analytics info text (#8772) 2021-03-31 15:55:29 +02:00
Bram Kragten
03f9964c59 Fix max being undefined in automation (#8771) 2021-03-31 15:36:09 +02:00
Philip Allgaier
f159219d2c Add Lovelace edit mode URL param (#8574)
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2021-03-31 15:10:55 +02:00
Thomas Lovén
e714f32737 Refactoring automation trace graphs (#8763)
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2021-03-31 15:09:00 +02:00
Philip Allgaier
20858db96d Use service name in action confirmation popup (#8493) 2021-03-31 14:12:06 +02:00
dependabot[bot]
89b82bb778 Bump y18n from 3.2.1 to 3.2.2 (#8765)
Bumps [y18n](https://github.com/yargs/y18n) from 3.2.1 to 3.2.2.
- [Release notes](https://github.com/yargs/y18n/releases)
- [Changelog](https://github.com/yargs/y18n/blob/master/CHANGELOG.md)
- [Commits](https://github.com/yargs/y18n/commits)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-03-31 11:16:55 +02:00
David F. Mulcahey
2c886d739f Add feedback dialog for ZHA device reconfiguration (#8742)
* Add feedback dialog for ZHA device reconfiguration

* review comments

* fix scope
2021-03-31 11:16:35 +02:00
GitHub Action
1ccf4e49bc Translation update 2021-03-31 00:48:38 +00:00
Joakim Sørensen
7d63e3e088 Hide system generated integration options (#8764) 2021-03-30 23:09:08 +02:00
Joakim Sørensen
828523f281 Adjust documentation link (#8762) 2021-03-30 21:52:15 +02:00
Philip Allgaier
afe3831f25 Make log levels translatable (#8761) 2021-03-30 20:19:50 +02:00
Philip Allgaier
3888c56f1a Add padding when loading traces or none found (#8760) 2021-03-30 20:16:06 +02:00
Bram Kragten
6f07966ef8 Update en.json 2021-03-30 10:48:04 +02:00
GitHub Action
09eafe8abd Translation update 2021-03-30 00:48:42 +00:00
Paulus Schoutsen
6719a42e27 Bumped version to 20210330.0 2021-03-30 00:15:58 +00:00
Bram Kragten
4b98a70ee8 Fix rendering when selecting all in datatable (#8749)
Fixes #8619
2021-03-29 17:15:08 -07:00
Bram Kragten
db3f5447ca Add filtering by devices/areas to scripts (#8748) 2021-03-29 17:14:02 -07:00
Bram Kragten
fed63f645d Add filtering by devices/areas to scenes (#8747) 2021-03-29 17:09:50 -07:00
Bram Kragten
e7315bb570 Align filtering of integrations with other pages (#8746) 2021-03-29 17:07:11 -07:00
Bram Kragten
cd2404f26a Add link between trace and editor (#8750) 2021-03-29 17:05:51 -07:00
Paulus Schoutsen
b866166425 Adjust for latest trace API (#8755) 2021-03-29 17:01:39 -07:00
Bram Kragten
46580376dd Make trace somewhat useable on mobile (#8752) 2021-03-29 16:59:31 -07:00
Thomas Lovén
b8bfb44aec Rename layout to view_layout (#8714) 2021-03-29 14:28:58 -07:00
Bram Kragten
a153f572d0 Add screenshot for manifest (#8753) 2021-03-29 13:12:55 -07:00
Bram Kragten
66c30a59e7 Check if Google Assistant is linked (#8613)
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2021-03-29 19:27:45 +02:00
Bram Kragten
10b8efc5cb Add filtering to automaton overview (#8736)
* Add filtering to automaton overview

* Update ha-automation-picker.ts

* Update ha-combo-box.ts

* imports

* Rename component

* localize + comments
2021-03-29 14:33:48 +02:00
Bram Kragten
c65d414b7b Show if config entry is not loaded (#8717) 2021-03-29 11:33:41 +02:00
Bram Kragten
7f7d89c745 Hopefully fix some back button issues (#8708) 2021-03-29 11:29:43 +02:00
Joakim Sørensen
742028b691 Add analytics integration (#8695)
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
Co-authored-by: Philip Allgaier <mail@spacegaier.de>
2021-03-29 09:47:04 +02:00
GitHub Action
62f685bac2 Translation update 2021-03-29 00:48:39 +00:00
Paulus Schoutsen
0b3333e88c Bumped version to 20210328.0 2021-03-28 22:28:20 +00:00
Paulus Schoutsen
c341a99b83 Add more trace visualization (#8724)
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2021-03-28 20:31:59 +02:00
Josh McCarty
f43c420d59 Create number formatting options on the profile panel (#7925) 2021-03-28 18:32:48 +02:00
Bram Kragten
0393970a80 Generalize filtering in datatable sub page (#8734) 2021-03-28 18:29:55 +02:00
Paulus Schoutsen
1865e0661f Add child_id to call service result in trace (#8728) 2021-03-27 20:06:02 -07:00
GitHub Action
c07b1194b3 Translation update 2021-03-28 00:48:51 +00:00
Philip Allgaier
bf802628b9 Add margin to text next to chip (#8726)
* Add margin to text next to chip

* Increase margin to 8px
2021-03-27 12:59:30 +01:00
GitHub Action
36020373cd Translation update 2021-03-27 00:48:37 +00:00
Bram Kragten
43e73d69de Handle edit mode for panel mode cards better (#8687) 2021-03-26 15:46:15 +01:00
Bram Kragten
47a3f649d2 Prevent cloud tts update prefs on page load (#8707) 2021-03-26 15:34:19 +01:00
Joakim Sørensen
5c63f8e52a Fix USB sync endpoint (#8725) 2021-03-26 14:14:54 +01:00
GitHub Action
01c553ef13 Translation update 2021-03-26 00:48:26 +00:00
Paulus Schoutsen
f229e4e12a Make iterating ha-timeline efficient (#8721) 2021-03-25 14:59:21 -07:00
Paulus Schoutsen
40cf4c8d32 Add trace details foundation (#8716)
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2021-03-25 11:58:29 -07:00
J. Nick Koston
ee38c419de Ensure platform only integrations are displayed on the config info page (#8698)
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2021-03-25 08:15:40 -10:00
Donnie
10baa34c18 Fix regression where command item text lost capitalization rules (#8719) 2021-03-25 18:20:20 +01:00
Donnie
343b67fa7f Move margin styling from ha-chip to ha-chip-set (#8718) 2021-03-25 09:03:47 -07:00
Donnie
6de8b4e35f Quick Bar: Use command category labels instead of icons (#7692)
* Change commands to use category labels instead of icons. Fixes several translation issues.

* Hydrate with latest dev and resolve conflicts

* Replace custom pill element with material chips

* Add category icons. Fix dark mode text colors

* Update src/components/ha-chip-set.ts

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

* Update src/dialogs/quick-bar/ha-quick-bar.ts

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

* Update src/dialogs/quick-bar/ha-quick-bar.ts

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

* Update src/components/ha-chip.ts

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

* Update src/components/ha-chip.ts

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

Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2021-03-25 08:21:48 -07:00
Bram Kragten
57e535c2c8 Fix changing view type (#8688) 2021-03-25 14:21:38 +01:00
Bram Kragten
af5b22a265 Fix picture glance editor + small opti in hui image (#8692) 2021-03-25 14:17:32 +01:00
Bram Kragten
77972c961b Service control: Don't fire value changed for optional empty input (#8705) 2021-03-25 14:15:57 +01:00
Bram Kragten
a3efa5676b Log less in service worker (#8691) 2021-03-25 14:14:49 +01:00
Bram Kragten
014dbc2a86 Remove paper-material-styles (#8706)
Fix #8702
2021-03-25 13:12:31 +01:00
Bram Kragten
226a2941d6 Service dev tools: Disable UI mode when using templates (#8711) 2021-03-25 13:11:12 +01:00
Paulus Schoutsen
c269c8fd3f Fix ordering of logbook entries inside choose sequence with multiple … (#8715) 2021-03-25 10:54:20 +01:00
GitHub Action
d8fc3c1ebf Translation update 2021-03-25 00:36:40 +00:00
Bram Kragten
9bdda77e89 Fix demo states translation (#8586) 2021-03-08 13:04:58 +01:00
Bram Kragten
fa7bd28c92 Bumped version to 20210302.6 2021-03-07 23:20:37 +01:00
Philip Allgaier
279f78e4a8 Ensure dev-tools state attribute checkbox state gets stored (#8579) 2021-03-07 23:19:13 +01:00
Bram Kragten
8ec3cbdb33 Fix codemirror cursor color (#8571) 2021-03-07 23:18:55 +01:00
Philip Allgaier
7449f7e73f Handle delay templates properly + error handling tweaks (#8578)
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2021-03-07 23:17:05 +01:00
Bram Kragten
d680fde759 Don't allow UI editor for service calls with templates (#8581) 2021-03-07 23:16:49 +01:00
Bram Kragten
ba77a88714 Bumped version to 20210302.5 2021-03-05 17:27:46 +01:00
Bram Kragten
9b39087102 Fix codemirror active line (#8558)
fixes #8556
2021-03-05 17:27:14 +01:00
Bram Kragten
a00961b9ef Bumped version to 20210302.4 2021-03-04 17:03:14 +01:00
Bram Kragten
701c188bab Bump codemirror to 0.18 (#8546) 2021-03-04 17:02:51 +01:00
Bram Kragten
e81002807f Add max height to yaml editor (#8527) 2021-03-04 17:02:33 +01:00
Bram Kragten
e14d652651 Bumped version to 20210302.3 2021-03-03 13:19:12 +01:00
Bram Kragten
98ae5270ef Bumped version to 20210302.2 2021-03-03 12:50:25 +01:00
Bram Kragten
19ccf0ab40 Bump codemirror (#8524) 2021-03-03 12:50:16 +01:00
Bram Kragten
6021bec5ee Bumped version to 20210302.1 2021-03-03 10:42:45 +01:00
Bram Kragten
7fcadc85fa Dont show config changes when user saved it (#8520) 2021-03-03 10:42:13 +01:00
Bram Kragten
5d7f971a82 Merge pull request #8517 from home-assistant/dev 2021-03-02 16:16:22 +01:00
Bram Kragten
c42430ccf9 Merge pull request #8509 from home-assistant/dev 2021-03-01 23:33:33 +01:00
Bram Kragten
16fa6904d9 Merge pull request #8474 from home-assistant/dev 2021-02-26 22:05:19 +01:00
Bram Kragten
12a8a1531d Merge pull request #8459 from home-assistant/dev 2021-02-25 18:54:46 +01:00
Bram Kragten
c85f69c9ee Merge pull request #8448 from home-assistant/dev
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
Co-authored-by: Philip Allgaier <mail@spacegaier.de>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: GitHub Action <github-action@users.noreply.github.com>
Co-authored-by: Joakim Sørensen <joasoe@gmail.com>
Co-authored-by: Álvaro Fernández Rojas <noltari@gmail.com>
Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com>
Co-authored-by: Kendell R <KTibow@users.noreply.github.com>
Co-authored-by: larena1 <60823161+larena1@users.noreply.github.com>
2021-02-24 20:36:51 +01:00
Bram Kragten
216526e391 Merge pull request #8433 from home-assistant/dev 2021-02-22 20:18:48 +01:00
Bram Kragten
311e1cfb00 Merge pull request #8354 from home-assistant/dev 2021-02-08 15:28:40 +01:00
305 changed files with 13872 additions and 5128 deletions

View File

@@ -84,7 +84,8 @@
"@typescript-eslint/no-unused-vars": 0,
"@typescript-eslint/explicit-function-return-type": 0,
"@typescript-eslint/explicit-module-boundary-types": 0,
"@typescript-eslint/no-shadow": ["error"]
"@typescript-eslint/no-shadow": ["error"],
"lit/attribute-value-entities": 0
},
"plugins": ["disable", "import", "lit", "prettier", "@typescript-eslint"],
"processor": "disable/disable"

View File

@@ -39,7 +39,7 @@ class HcLovelace extends LitElement {
urlPath: this.urlPath!,
enableFullEditMode: () => undefined,
mode: "storage",
language: "en",
locale: this.hass.locale,
saveConfig: async () => undefined,
deleteConfig: async () => undefined,
setEditMode: () => undefined,

View File

@@ -2,22 +2,27 @@ import { DemoTrace } from "./types";
export const basicTrace: DemoTrace = {
trace: {
last_action: "action/0/choose/0/sequence/0",
last_condition: "condition/0",
last_step: "action/2",
run_id: "0",
state: "stopped",
timestamp: {
start: "2021-03-22T19:17:09.519178+00:00",
finish: "2021-03-22T19:17:09.556129+00:00",
start: "2021-03-25T04:36:51.223693+00:00",
finish: "2021-03-25T04:36:51.266132+00:00",
},
trigger: "state of input_boolean.toggle_1",
domain: "automation",
item_id: "1615419646544",
action_trace: {
"action/0": [
trace: {
"trigger/0": [
{
path: "action/0",
timestamp: "2021-03-22T19:17:09.526794+00:00",
path: "trigger/0",
timestamp: "2021-03-25T04:36:51.223693+00:00",
},
],
"condition/0": [
{
path: "condition/0",
timestamp: "2021-03-25T04:36:51.228243+00:00",
changed_variables: {
trigger: {
platform: "state",
@@ -29,10 +34,10 @@ export const basicTrace: DemoTrace = {
editable: true,
friendly_name: "Toggle 1",
},
last_changed: "2021-03-22T19:11:24.418709+00:00",
last_updated: "2021-03-22T19:11:24.418709+00:00",
last_changed: "2021-03-24T19:03:59.141440+00:00",
last_updated: "2021-03-24T19:03:59.141440+00:00",
context: {
id: "55daa6c47a7613b0800fe0ec81090a84",
id: "5d0918eb379214d07554bdab6a08bcff",
parent_id: null,
user_id: null,
},
@@ -44,10 +49,58 @@ export const basicTrace: DemoTrace = {
editable: true,
friendly_name: "Toggle 1",
},
last_changed: "2021-03-22T19:17:09.516874+00:00",
last_updated: "2021-03-22T19:17:09.516874+00:00",
last_changed: "2021-03-25T04:36:51.220696+00:00",
last_updated: "2021-03-25T04:36:51.220696+00:00",
context: {
id: "116d7a6562d594b114f7efe728619a3f",
id: "664d6d261450a9ecea6738e97269a149",
parent_id: null,
user_id: "d1b4e89da01445fa8bc98e39fac477ca",
},
},
for: null,
attribute: null,
description: "state of input_boolean.toggle_1",
},
},
result: {
result: true,
},
},
],
"action/0": [
{
path: "action/0",
timestamp: "2021-03-25T04:36:51.243018+00:00",
changed_variables: {
trigger: {
platform: "state",
entity_id: "input_boolean.toggle_1",
from_state: {
entity_id: "input_boolean.toggle_1",
state: "on",
attributes: {
editable: true,
friendly_name: "Toggle 1",
},
last_changed: "2021-03-24T19:03:59.141440+00:00",
last_updated: "2021-03-24T19:03:59.141440+00:00",
context: {
id: "5d0918eb379214d07554bdab6a08bcff",
parent_id: null,
user_id: null,
},
},
to_state: {
entity_id: "input_boolean.toggle_1",
state: "off",
attributes: {
editable: true,
friendly_name: "Toggle 1",
},
last_changed: "2021-03-25T04:36:51.220696+00:00",
last_updated: "2021-03-25T04:36:51.220696+00:00",
context: {
id: "664d6d261450a9ecea6738e97269a149",
parent_id: null,
user_id: "d1b4e89da01445fa8bc98e39fac477ca",
},
@@ -57,38 +110,56 @@ export const basicTrace: DemoTrace = {
description: "state of input_boolean.toggle_1",
},
context: {
id: "54a7371cff31be0f4010c9fde2317322",
parent_id: "116d7a6562d594b114f7efe728619a3f",
id: "6cfcae368e7b3686fad6c59e83ae76c9",
parent_id: "664d6d261450a9ecea6738e97269a149",
user_id: null,
},
},
result: {
params: {
domain: "input_boolean",
service: "toggle",
service_data: {},
target: {
entity_id: ["input_boolean.toggle_4"],
},
},
running_script: false,
limit: 10,
},
},
],
"action/1": [
{
path: "action/1",
timestamp: "2021-03-25T04:36:51.252406+00:00",
result: {
choice: 0,
},
},
],
"action/0/choose/0": [
"action/1/choose/0": [
{
path: "action/0/choose/0",
timestamp: "2021-03-22T19:17:09.530176+00:00",
path: "action/1/choose/0",
timestamp: "2021-03-25T04:36:51.254569+00:00",
result: {
result: true,
},
},
],
"action/0/choose/0/conditions/0": [
"action/1/choose/0/conditions/0": [
{
path: "action/0/choose/0/conditions/0",
timestamp: "2021-03-22T19:17:09.539155+00:00",
path: "action/1/choose/0/conditions/0",
timestamp: "2021-03-25T04:36:51.254697+00:00",
result: {
result: true,
},
},
],
"action/0/choose/0/sequence/0": [
"action/1/choose/0/sequence/0": [
{
path: "action/0/choose/0/sequence/0",
timestamp: "2021-03-22T19:17:09.542769+00:00",
path: "action/1/choose/0/sequence/0",
timestamp: "2021-03-25T04:36:51.257360+00:00",
result: {
params: {
domain: "input_boolean",
@@ -103,57 +174,44 @@ export const basicTrace: DemoTrace = {
},
},
],
},
condition_trace: {
"condition/0": [
"action/1/choose/0/sequence/1": [
{
path: "condition/0",
timestamp: "2021-03-22T19:17:09.520267+00:00",
changed_variables: {
trigger: {
platform: "state",
entity_id: "input_boolean.toggle_1",
from_state: {
entity_id: "input_boolean.toggle_1",
state: "on",
attributes: {
editable: true,
friendly_name: "Toggle 1",
},
last_changed: "2021-03-22T19:11:24.418709+00:00",
last_updated: "2021-03-22T19:11:24.418709+00:00",
context: {
id: "55daa6c47a7613b0800fe0ec81090a84",
parent_id: null,
user_id: null,
},
},
to_state: {
entity_id: "input_boolean.toggle_1",
state: "off",
attributes: {
editable: true,
friendly_name: "Toggle 1",
},
last_changed: "2021-03-22T19:17:09.516874+00:00",
last_updated: "2021-03-22T19:17:09.516874+00:00",
context: {
id: "116d7a6562d594b114f7efe728619a3f",
parent_id: null,
user_id: "d1b4e89da01445fa8bc98e39fac477ca",
},
},
for: null,
attribute: null,
description: "state of input_boolean.toggle_1",
},
},
path: "action/1/choose/0/sequence/1",
timestamp: "2021-03-25T04:36:51.260658+00:00",
result: {
result: true,
params: {
domain: "input_boolean",
service: "toggle",
service_data: {},
target: {
entity_id: ["input_boolean.toggle_3"],
},
},
running_script: false,
limit: 10,
},
},
],
"action/2": [
{
path: "action/2",
timestamp: "2021-03-25T04:36:51.264159+00:00",
result: {
params: {
domain: "input_boolean",
service: "toggle",
service_data: {},
target: {
entity_id: ["input_boolean.toggle_4"],
},
},
running_script: false,
limit: 10,
},
},
],
},
config: {
id: "1615419646544",
alias: "Ensure Party mode",
@@ -172,11 +230,23 @@ export const basicTrace: DemoTrace = {
},
],
action: [
{
service: "input_boolean.toggle",
target: {
entity_id: "input_boolean.toggle_4",
},
},
{
choose: [
{
alias: "If toggle 3 is on",
conditions: "{{ is_state('input_boolean.toggle_3', 'on') }}",
conditions: [
{
condition: "template",
value_template:
"{{ is_state('input_boolean.toggle_3', 'on') }}",
},
],
sequence: [
{
service: "input_boolean.toggle",
@@ -185,6 +255,13 @@ export const basicTrace: DemoTrace = {
entity_id: "input_boolean.toggle_2",
},
},
{
service: "input_boolean.toggle",
alias: "Toggle 3",
target: {
entity_id: "input_boolean.toggle_3",
},
},
],
},
],
@@ -198,53 +275,21 @@ export const basicTrace: DemoTrace = {
},
],
},
{
service: "input_boolean.toggle",
target: {
entity_id: "input_boolean.toggle_4",
},
},
],
mode: "single",
},
context: {
id: "54a7371cff31be0f4010c9fde2317322",
parent_id: "116d7a6562d594b114f7efe728619a3f",
id: "6cfcae368e7b3686fad6c59e83ae76c9",
parent_id: "664d6d261450a9ecea6738e97269a149",
user_id: null,
},
variables: {
trigger: {
platform: "state",
entity_id: "input_boolean.toggle_1",
from_state: {
entity_id: "input_boolean.toggle_1",
state: "on",
attributes: {
editable: true,
friendly_name: "Toggle 1",
},
last_changed: "2021-03-22T19:11:24.418709+00:00",
last_updated: "2021-03-22T19:11:24.418709+00:00",
context: {
id: "55daa6c47a7613b0800fe0ec81090a84",
parent_id: null,
user_id: null,
},
},
to_state: {
entity_id: "input_boolean.toggle_1",
state: "off",
attributes: {
editable: true,
friendly_name: "Toggle 1",
},
last_changed: "2021-03-22T19:17:09.516874+00:00",
last_updated: "2021-03-22T19:17:09.516874+00:00",
context: {
id: "116d7a6562d594b114f7efe728619a3f",
parent_id: null,
user_id: "d1b4e89da01445fa8bc98e39fac477ca",
},
},
for: null,
attribute: null,
description: "state of input_boolean.toggle_1",
},
},
script_execution: "finished",
},
logbookEntries: [
{
@@ -252,12 +297,23 @@ export const basicTrace: DemoTrace = {
message: "has been triggered by state of input_boolean.toggle_1",
source: "state of input_boolean.toggle_1",
entity_id: "automation.toggle_toggles",
context_id: "54a7371cff31be0f4010c9fde2317322",
when: "2021-03-22T19:17:09.523041+00:00",
context_id: "6cfcae368e7b3686fad6c59e83ae76c9",
when: "2021-03-25T04:36:51.240832+00:00",
domain: "automation",
},
{
when: "2021-03-22T19:17:09.549346+00:00",
when: "2021-03-25T04:36:51.249828+00:00",
name: "Toggle 4",
state: "on",
entity_id: "input_boolean.toggle_4",
context_entity_id: "automation.toggle_toggles",
context_entity_id_name: "Ensure Party mode",
context_event_type: "automation_triggered",
context_domain: "automation",
context_name: "Ensure Party mode",
},
{
when: "2021-03-25T04:36:51.258947+00:00",
name: "Toggle 2",
state: "on",
entity_id: "input_boolean.toggle_2",
@@ -267,5 +323,27 @@ export const basicTrace: DemoTrace = {
context_domain: "automation",
context_name: "Ensure Party mode",
},
{
when: "2021-03-25T04:36:51.261806+00:00",
name: "Toggle 3",
state: "off",
entity_id: "input_boolean.toggle_3",
context_entity_id: "automation.toggle_toggles",
context_entity_id_name: "Ensure Party mode",
context_event_type: "automation_triggered",
context_domain: "automation",
context_name: "Ensure Party mode",
},
{
when: "2021-03-25T04:36:51.265246+00:00",
name: "Toggle 4",
state: "off",
entity_id: "input_boolean.toggle_4",
context_entity_id: "automation.toggle_toggles",
context_entity_id_name: "Ensure Party mode",
context_event_type: "automation_triggered",
context_domain: "automation",
context_name: "Ensure Party mode",
},
],
};

View File

@@ -0,0 +1,44 @@
import { LogbookEntry } from "../../../../src/data/logbook";
import { AutomationTraceExtended } from "../../../../src/data/trace";
import { DemoTrace } from "./types";
export const mockDemoTrace = (
tracePartial: Partial<AutomationTraceExtended>,
logbookEntries?: LogbookEntry[]
): DemoTrace => ({
trace: {
last_step: "",
run_id: "0",
state: "stopped",
timestamp: {
start: "2021-03-25T04:36:51.223693+00:00",
finish: "2021-03-25T04:36:51.266132+00:00",
},
trigger: "mocked trigger",
domain: "automation",
item_id: "1615419646544",
trace: {
"trigger/0": [
{
path: "trigger/0",
changed_variables: {
trigger: {
description: "mocked trigger",
},
},
timestamp: "2021-03-25T04:36:51.223693+00:00",
},
],
},
config: {
trigger: [],
action: [],
},
context: {
id: "abcd",
},
script_execution: "finished",
...tracePartial,
},
logbookEntries: logbookEntries || [],
});

View File

@@ -2,8 +2,7 @@ import { DemoTrace } from "./types";
export const motionLightTrace: DemoTrace = {
trace: {
last_action: "action/3",
last_condition: null,
last_step: "action/3",
run_id: "1",
state: "stopped",
timestamp: {
@@ -13,7 +12,13 @@ export const motionLightTrace: DemoTrace = {
trigger: "state of binary_sensor.pauluss_macbook_pro_camera_in_use",
domain: "automation",
item_id: "1614732497392",
action_trace: {
trace: {
"trigger/0": [
{
path: "trigger/0",
timestamp: "2021-03-25T04:36:51.223693+00:00",
},
],
"action/0": [
{
path: "action/0",
@@ -124,7 +129,6 @@ export const motionLightTrace: DemoTrace = {
},
],
},
condition_trace: {},
config: {
mode: "restart",
max_exceeded: "silent",
@@ -172,45 +176,7 @@ export const motionLightTrace: DemoTrace = {
parent_id: "e22ddfd5f11dc4aad9a52fc10dab613b",
user_id: null,
},
variables: {
trigger: {
platform: "state",
entity_id: "binary_sensor.pauluss_macbook_pro_camera_in_use",
from_state: {
entity_id: "binary_sensor.pauluss_macbook_pro_camera_in_use",
state: "off",
attributes: {
friendly_name: "Pauluss MacBook Pro Camera In Use",
icon: "mdi:camera-off",
},
last_changed: "2021-03-14T06:06:29.235325+00:00",
last_updated: "2021-03-14T06:06:29.235325+00:00",
context: {
id: "ad4864c5ce957c38a07b50378eeb245d",
parent_id: null,
user_id: null,
},
},
to_state: {
entity_id: "binary_sensor.pauluss_macbook_pro_camera_in_use",
state: "on",
attributes: {
friendly_name: "Pauluss MacBook Pro Camera In Use",
icon: "mdi:camera",
},
last_changed: "2021-03-14T06:07:01.762009+00:00",
last_updated: "2021-03-14T06:07:01.762009+00:00",
context: {
id: "e22ddfd5f11dc4aad9a52fc10dab613b",
parent_id: null,
user_id: null,
},
},
for: null,
attribute: null,
description: "state of binary_sensor.pauluss_macbook_pro_camera_in_use",
},
},
script_execution: "finished",
},
logbookEntries: [
{

View File

@@ -0,0 +1,102 @@
import { safeDump } from "js-yaml";
import {
customElement,
html,
css,
LitElement,
TemplateResult,
property,
} from "lit-element";
import "../../../src/components/ha-card";
import { describeAction } from "../../../src/data/script_i18n";
import { provideHass } from "../../../src/fake_data/provide_hass";
import { HomeAssistant } from "../../../src/types";
const actions = [
{ wait_template: "{{ true }}", alias: "Something with an alias" },
{ delay: "0:05" },
{ wait_template: "{{ true }}" },
{
condition: "template",
value_template: "{{ true }}",
},
{ event: "happy_event" },
{
device_id: "abcdefgh",
domain: "plex",
entity_id: "media_player.kitchen",
},
{ scene: "scene.kitchen_morning" },
{
wait_for_trigger: [
{
platform: "state",
entity_id: "input_boolean.toggle_1",
},
],
},
{
variables: {
hello: "world",
},
},
{
service: "input_boolean.toggle",
target: {
entity_id: "input_boolean.toggle_4",
},
},
];
@customElement("demo-automation-describe-action")
export class DemoAutomationDescribeAction extends LitElement {
@property({ attribute: false }) hass!: HomeAssistant;
protected render(): TemplateResult {
if (!this.hass) {
return html``;
}
return html`
<ha-card header="Actions">
${actions.map(
(conf) => html`
<div class="action">
<span>${describeAction(this.hass, conf as any)}</span>
<pre>${safeDump(conf)}</pre>
</div>
`
)}
</ha-card>
`;
}
protected firstUpdated(changedProps) {
super.firstUpdated(changedProps);
const hass = provideHass(this);
hass.updateTranslations(null, "en");
}
static get styles() {
return css`
ha-card {
max-width: 600px;
margin: 24px auto;
}
.action {
padding: 16px;
display: flex;
align-items: center;
justify-content: space-between;
}
span {
margin-right: 16px;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"demo-automation-describe-action": DemoAutomationDescribeAction;
}
}

View File

@@ -0,0 +1,65 @@
import { safeDump } from "js-yaml";
import {
customElement,
html,
css,
LitElement,
TemplateResult,
} from "lit-element";
import "../../../src/components/ha-card";
import { describeCondition } from "../../../src/data/automation_i18n";
const conditions = [
{ condition: "and" },
{ condition: "not" },
{ condition: "or" },
{ condition: "state" },
{ condition: "numeric_state" },
{ condition: "sun", after: "sunset" },
{ condition: "sun", after: "sunrise" },
{ condition: "zone" },
{ condition: "time" },
{ condition: "template" },
];
@customElement("demo-automation-describe-condition")
export class DemoAutomationDescribeCondition extends LitElement {
protected render(): TemplateResult {
return html`
<ha-card header="Conditions">
${conditions.map(
(conf) => html`
<div class="condition">
<span>${describeCondition(conf as any)}</span>
<pre>${safeDump(conf)}</pre>
</div>
`
)}
</ha-card>
`;
}
static get styles() {
return css`
ha-card {
max-width: 600px;
margin: 24px auto;
}
.condition {
padding: 16px;
display: flex;
align-items: center;
justify-content: space-between;
}
span {
margin-right: 16px;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"demo-automation-describe-condition": DemoAutomationDescribeCondition;
}
}

View File

@@ -0,0 +1,68 @@
import { safeDump } from "js-yaml";
import {
customElement,
html,
css,
LitElement,
TemplateResult,
} from "lit-element";
import "../../../src/components/ha-card";
import { describeTrigger } from "../../../src/data/automation_i18n";
const triggers = [
{ platform: "state" },
{ platform: "mqtt" },
{ platform: "geo_location" },
{ platform: "homeassistant" },
{ platform: "numeric_state" },
{ platform: "sun" },
{ platform: "time_pattern" },
{ platform: "webhook" },
{ platform: "zone" },
{ platform: "tag" },
{ platform: "time" },
{ platform: "template" },
{ platform: "event" },
];
@customElement("demo-automation-describe-trigger")
export class DemoAutomationDescribeTrigger extends LitElement {
protected render(): TemplateResult {
return html`
<ha-card header="Triggers">
${triggers.map(
(conf) => html`
<div class="trigger">
<span>${describeTrigger(conf as any)}</span>
<pre>${safeDump(conf)}</pre>
</div>
`
)}
</ha-card>
`;
}
static get styles() {
return css`
ha-card {
max-width: 600px;
margin: 24px auto;
}
.trigger {
padding: 16px;
display: flex;
align-items: center;
justify-content: space-between;
}
span {
margin-right: 16px;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"demo-automation-describe-trigger": DemoAutomationDescribeTrigger;
}
}

View File

@@ -0,0 +1,87 @@
import {
customElement,
html,
css,
LitElement,
TemplateResult,
property,
} from "lit-element";
import "../../../src/components/ha-card";
import "../../../src/components/trace/hat-script-graph";
import "../../../src/components/trace/hat-trace-timeline";
import { provideHass } from "../../../src/fake_data/provide_hass";
import { HomeAssistant } from "../../../src/types";
import { mockDemoTrace } from "../data/traces/mock-demo-trace";
import { DemoTrace } from "../data/traces/types";
const traces: DemoTrace[] = [
mockDemoTrace({ state: "running" }),
mockDemoTrace({ state: "debugged" }),
mockDemoTrace({ state: "stopped", script_execution: "failed_conditions" }),
mockDemoTrace({ state: "stopped", script_execution: "failed_single" }),
mockDemoTrace({ state: "stopped", script_execution: "failed_max_runs" }),
mockDemoTrace({ state: "stopped", script_execution: "finished" }),
mockDemoTrace({ state: "stopped", script_execution: "aborted" }),
mockDemoTrace({
state: "stopped",
script_execution: "error",
error: 'Variable "beer" cannot be None',
}),
mockDemoTrace({ state: "stopped", script_execution: "cancelled" }),
];
@customElement("demo-automation-trace-timeline")
export class DemoAutomationTraceTimeline extends LitElement {
@property({ attribute: false }) hass?: HomeAssistant;
protected render(): TemplateResult {
if (!this.hass) {
return html``;
}
return html`
${traces.map(
(trace) => html`
<ha-card .header=${trace.trace.config.alias}>
<div class="card-content">
<hat-trace-timeline
.hass=${this.hass}
.trace=${trace.trace}
.logbookEntries=${trace.logbookEntries}
></hat-trace-timeline>
<button @click=${() => console.log(trace)}>Log trace</button>
</div>
</ha-card>
`
)}
`;
}
protected firstUpdated(changedProps) {
super.firstUpdated(changedProps);
const hass = provideHass(this);
hass.updateTranslations(null, "en");
}
static get styles() {
return css`
ha-card {
max-width: 600px;
margin: 24px;
}
.card-content {
display: flex;
}
button {
position: absolute;
top: 0;
right: 0;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"demo-automation-trace-timeline": DemoAutomationTraceTimeline;
}
}

View File

@@ -4,10 +4,12 @@ import {
css,
LitElement,
TemplateResult,
internalProperty,
property,
} from "lit-element";
import "../../../src/components/ha-card";
import "../../../src/components/trace/hat-trace";
import "../../../src/components/trace/hat-script-graph";
import "../../../src/components/trace/hat-trace-timeline";
import { provideHass } from "../../../src/fake_data/provide_hass";
import { HomeAssistant } from "../../../src/types";
import { DemoTrace } from "../data/traces/types";
@@ -20,20 +22,38 @@ const traces: DemoTrace[] = [basicTrace, motionLightTrace];
export class DemoAutomationTrace extends LitElement {
@property({ attribute: false }) hass?: HomeAssistant;
@internalProperty() private _selected = {};
protected render(): TemplateResult {
if (!this.hass) {
return html``;
}
return html`
${traces.map(
(trace) => html`
<ha-card .heading=${trace.trace.config.alias}>
(trace, idx) => html`
<ha-card .header=${trace.trace.config.alias}>
<div class="card-content">
<hat-trace
<hat-script-graph
.trace=${trace.trace}
.selected=${this._selected[idx]}
@graph-node-selected=${(ev) => {
this._selected = { ...this._selected, [idx]: ev.detail.path };
}}
></hat-script-graph>
<hat-trace-timeline
allowPick
.hass=${this.hass}
.trace=${trace.trace}
.logbookEntries=${trace.logbookEntries}
></hat-trace>
.selectedPath=${this._selected[idx]}
@value-changed=${(ev) => {
this._selected = {
...this._selected,
[idx]: ev.detail.value,
};
}}
></hat-trace-timeline>
<button @click=${() => console.log(trace)}>Log trace</button>
</div>
</ha-card>
`
@@ -53,6 +73,20 @@ export class DemoAutomationTrace extends LitElement {
max-width: 600px;
margin: 24px;
}
.card-content {
display: flex;
}
.card-content > * {
margin-right: 16px;
}
.card-content > *:last-child {
margin-right: 0;
}
button {
position: absolute;
top: 0;
right: 0;
}
`;
}
}

View File

@@ -0,0 +1,331 @@
import {
customElement,
html,
css,
internalProperty,
LitElement,
TemplateResult,
property,
} from "lit-element";
import "../../../src/components/ha-formfield";
import "../../../src/components/ha-switch";
import { IntegrationManifest } from "../../../src/data/integration";
import { provideHass } from "../../../src/fake_data/provide_hass";
import { HomeAssistant } from "../../../src/types";
import "../../../src/panels/config/integrations/ha-integration-card";
import "../../../src/panels/config/integrations/ha-ignored-config-entry-card";
import "../../../src/panels/config/integrations/ha-config-flow-card";
import type {
ConfigEntryExtended,
DataEntryFlowProgressExtended,
} from "../../../src/panels/config/integrations/ha-config-integrations";
import { DeviceRegistryEntry } from "../../../src/data/device_registry";
import { EntityRegistryEntry } from "../../../src/data/entity_registry";
import { classMap } from "lit-html/directives/class-map";
const createConfigEntry = (
title: string,
override: Partial<ConfigEntryExtended> = {}
): ConfigEntryExtended => ({
entry_id: title,
domain: "esphome",
localized_domain_name: "ESPHome",
title,
source: "zeroconf",
state: "loaded",
connection_class: "local_push",
supports_options: false,
supports_unload: true,
disabled_by: null,
...override,
});
const createManifest = (
isCustom: boolean,
isCloud: boolean,
name = "ESPHome"
): IntegrationManifest => ({
name,
domain: "esphome",
is_built_in: !isCustom,
config_flow: false,
documentation: "https://www.home-assistant.io/integrations/esphome/",
iot_class: isCloud ? "cloud_polling" : "local_polling",
});
const loadedEntry = createConfigEntry("Loaded");
const nameAsDomainEntry = createConfigEntry("ESPHome");
const longNameEntry = createConfigEntry(
"Entry with a super long name that is going to the next line"
);
const configPanelEntry = createConfigEntry("Config Panel", {
domain: "mqtt",
localized_domain_name: "MQTT",
});
const optionsFlowEntry = createConfigEntry("Options Flow", {
supports_options: true,
});
const setupErrorEntry = createConfigEntry("Setup Error", {
state: "setup_error",
});
const migrationErrorEntry = createConfigEntry("Migration Error", {
state: "migration_error",
});
const setupRetryEntry = createConfigEntry("Setup Retry", {
state: "setup_retry",
});
const failedUnloadEntry = createConfigEntry("Failed Unload", {
state: "failed_unload",
});
const notLoadedEntry = createConfigEntry("Not Loaded", { state: "not_loaded" });
const disabledEntry = createConfigEntry("Disabled", {
state: "not_loaded",
disabled_by: "user",
});
const disabledFailedUnloadEntry = createConfigEntry(
"Disabled - Failed Unload",
{
state: "failed_unload",
disabled_by: "user",
}
);
const configFlows: DataEntryFlowProgressExtended[] = [
{
flow_id: "adbb401329d8439ebb78ef29837826a8",
handler: "roku",
context: {
source: "ssdp",
unique_id: "YF008D862864",
title_placeholders: {
name: "Living room Roku",
},
},
step_id: "discovery_confirm",
localized_title: "Living room Roku",
},
{
flow_id: "adbb401329d8439ebb78ef29837826a8",
handler: "hue",
context: {
source: "reauth",
unique_id: "YF008D862864",
title_placeholders: {
name: "Living room Roku",
},
},
step_id: "discovery_confirm",
localized_title: "Philips Hue",
},
];
const configEntries: Array<{
items: ConfigEntryExtended[];
is_custom?: boolean;
disabled?: boolean;
highlight?: string;
}> = [
{ items: [loadedEntry] },
{ items: [configPanelEntry] },
{ items: [optionsFlowEntry] },
{ items: [nameAsDomainEntry] },
{ items: [longNameEntry] },
{ items: [setupErrorEntry] },
{ items: [migrationErrorEntry] },
{ items: [setupRetryEntry] },
{ items: [failedUnloadEntry] },
{ items: [notLoadedEntry] },
{
items: [
loadedEntry,
setupErrorEntry,
migrationErrorEntry,
longNameEntry,
setupRetryEntry,
failedUnloadEntry,
notLoadedEntry,
disabledEntry,
nameAsDomainEntry,
configPanelEntry,
optionsFlowEntry,
],
},
{ disabled: true, items: [disabledEntry] },
{ disabled: true, items: [disabledFailedUnloadEntry] },
{
disabled: true,
items: [disabledEntry, disabledFailedUnloadEntry],
},
{
items: [loadedEntry, configPanelEntry],
highlight: "Loaded",
},
];
const createEntityRegistryEntries = (
item: ConfigEntryExtended
): EntityRegistryEntry[] => [
{
config_entry_id: item.entry_id,
device_id: "mock-device-id",
area_id: null,
disabled_by: null,
entity_id: "binary_sensor.updater",
name: null,
icon: null,
platform: "updater",
},
];
const createDeviceRegistryEntries = (
item: ConfigEntryExtended
): DeviceRegistryEntry[] => [
{
entry_type: null,
config_entries: [item.entry_id],
connections: [],
manufacturer: "ESPHome",
model: "Mock Device",
name: "Tag Reader",
sw_version: null,
id: "mock-device-id",
identifiers: [],
via_device_id: null,
area_id: null,
name_by_user: null,
disabled_by: null,
},
];
@customElement("demo-integration-card")
export class DemoIntegrationCard extends LitElement {
@property({ attribute: false }) hass?: HomeAssistant;
@internalProperty() isCustomIntegration = false;
@internalProperty() isCloud = false;
protected render(): TemplateResult {
if (!this.hass) {
return html``;
}
return html`
<div class="container">
<div class="filters">
<ha-formfield label="Custom Integration">
<ha-switch @change=${this._toggleCustomIntegration}></ha-switch>
</ha-formfield>
<ha-formfield label="Relies on cloud">
<ha-switch @change=${this._toggleCloud}></ha-switch>
</ha-formfield>
</div>
<ha-ignored-config-entry-card
.hass=${this.hass}
.entry=${createConfigEntry("Ignored Entry")}
.manifest=${createManifest(this.isCustomIntegration, this.isCloud)}
></ha-ignored-config-entry-card>
${configFlows.map(
(flow) => html`
<ha-config-flow-card
.hass=${this.hass}
.flow=${flow}
.manifest=${createManifest(
this.isCustomIntegration,
this.isCloud,
flow.handler === "roku" ? "Roku" : "Philips Hue"
)}
></ha-config-flow-card>
`
)}
${configEntries.map(
(info) => html`
<ha-integration-card
class=${classMap({
highlight: info.highlight !== undefined,
})}
.hass=${this.hass}
domain="esphome"
.items=${info.items}
.manifest=${createManifest(
this.isCustomIntegration,
this.isCloud
)}
.entityRegistryEntries=${createEntityRegistryEntries(
info.items[0]
)}
.deviceRegistryEntries=${createDeviceRegistryEntries(
info.items[0]
)}
?disabled=${info.disabled}
.selectedConfigEntryId=${info.highlight}
></ha-integration-card>
`
)}
</div>
<div class="container">
<!-- One that is standalone to see how it increases height if height
not defined by other cards. -->
<ha-integration-card
.hass=${this.hass}
domain="esphome"
.items=${[
loadedEntry,
setupErrorEntry,
migrationErrorEntry,
setupRetryEntry,
failedUnloadEntry,
]}
.manifest=${createManifest(this.isCustomIntegration, this.isCloud)}
.entityRegistryEntries=${createEntityRegistryEntries(loadedEntry)}
.deviceRegistryEntries=${createDeviceRegistryEntries(loadedEntry)}
></ha-integration-card>
</div>
`;
}
protected firstUpdated(changedProps) {
super.firstUpdated(changedProps);
const hass = provideHass(this);
hass.updateTranslations(null, "en");
hass.updateTranslations("config", "en");
}
private _toggleCustomIntegration() {
this.isCustomIntegration = !this.isCustomIntegration;
}
private _toggleCloud() {
this.isCloud = !this.isCloud;
}
static get styles() {
return css`
.container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
grid-gap: 16px 16px;
padding: 8px 16px 16px;
margin-bottom: 64px;
}
.container > * {
max-width: 500px;
}
ha-formfield {
margin: 8px 0;
display: block;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"demo-integration-card": DemoIntegrationCard;
}
}

View File

@@ -73,7 +73,7 @@ class SupervisorMetric extends LitElement {
);
}
.value {
width: 42px;
width: 48px;
padding-right: 4px;
}
`;

View File

@@ -18,7 +18,6 @@ import {
} from "lit-element";
import { cache } from "lit-html/directives/cache";
import { fireEvent } from "../../../../src/common/dom/fire_event";
import "../../../../src/components/ha-chips";
import "../../../../src/components/ha-circular-progress";
import "../../../../src/components/ha-dialog";
import "../../../../src/components/ha-expansion-panel";

View File

@@ -8,6 +8,7 @@ import {
property,
TemplateResult,
} from "lit-element";
import { atLeastVersion } from "../../../src/common/config/version";
import { fireEvent } from "../../../src/common/dom/fire_event";
import "../../../src/components/buttons/ha-progress-button";
import "../../../src/components/ha-card";
@@ -38,6 +39,7 @@ import "../components/supervisor-metric";
import { hassioStyle } from "../resources/hassio-style";
const UNSUPPORTED_REASON_URL = {
apparmor: "/more-info/unsupported/apparmor",
container: "/more-info/unsupported/container",
dbus: "/more-info/unsupported/dbus",
docker_configuration: "/more-info/unsupported/docker_configuration",
@@ -150,30 +152,32 @@ class HassioSupervisorInfo extends LitElement {
</ha-settings-row>
${this.supervisor.supervisor.supported
? html` <ha-settings-row three-line>
<span slot="heading">
${this.supervisor.localize(
"system.supervisor.share_diagnostics"
)}
</span>
<div slot="description" class="diagnostics-description">
${this.supervisor.localize(
"system.supervisor.share_diagnostics_description"
)}
<button
class="link"
.title=${this.supervisor.localize("common.show_more")}
@click=${this._diagnosticsInformationDialog}
>
${this.supervisor.localize("common.learn_more")}
</button>
</div>
<ha-switch
haptic
.checked=${this.supervisor.supervisor.diagnostics}
@change=${this._toggleDiagnostics}
></ha-switch>
</ha-settings-row>`
? !atLeastVersion(this.hass.config.version, 2021, 4)
? html` <ha-settings-row three-line>
<span slot="heading">
${this.supervisor.localize(
"system.supervisor.share_diagnostics"
)}
</span>
<div slot="description" class="diagnostics-description">
${this.supervisor.localize(
"system.supervisor.share_diagnostics_description"
)}
<button
class="link"
.title=${this.supervisor.localize("common.show_more")}
@click=${this._diagnosticsInformationDialog}
>
${this.supervisor.localize("common.learn_more")}
</button>
</div>
<ha-switch
haptic
.checked=${this.supervisor.supervisor.diagnostics}
@change=${this._toggleDiagnostics}
></ha-switch>
</ha-settings-row>`
: ""
: html`<div class="error">
${this.supervisor.localize(
"system.supervisor.unsupported_title"
@@ -265,13 +269,15 @@ class HassioSupervisorInfo extends LitElement {
</b>
<br /><br />
${this.supervisor.localize("system.supervisor.beta_release_items")}
<li>Home Assistant Core</li>
<li>Home Assistant Supervisor</li>
<li>Home Assistant Operating System</li>
<ul>
<li>Home Assistant Core</li>
<li>Home Assistant Supervisor</li>
<li>Home Assistant Operating System</li>
</ul>
<br />
${this.supervisor.localize("system.supervisor.join_beta_action")}`,
${this.supervisor.localize("system.supervisor.beta_join_confirm")}`,
confirmText: this.supervisor.localize(
"system.supervisor.beta_join_confirm"
"system.supervisor.join_beta_action"
),
dismissText: this.supervisor.localize("common.cancel"),
});

View File

@@ -100,7 +100,6 @@
"@webcomponents/webcomponentsjs": "^2.2.7",
"chart.js": "~2.8.0",
"chartjs-chart-timeline": "^0.3.0",
"codemirror": "^5.49.0",
"comlink": "^4.3.0",
"core-js": "^3.6.5",
"cropperjs": "^1.5.7",
@@ -228,7 +227,7 @@
"terser-webpack-plugin": "^5.1.1",
"ts-lit-plugin": "^1.2.1",
"ts-mocha": "^7.0.0",
"typescript": "^4.0.3",
"typescript": "^4.2.4",
"vinyl-buffer": "^1.0.1",
"vinyl-source-stream": "^2.0.0",
"webpack": "^5.24.1",

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -10,10 +10,10 @@ function patch(version) {
function today() {
const now = new Date();
return `${now.getFullYear()}${String(now.getMonth() + 1).padStart(
return `${now.getUTCFullYear()}${String(now.getUTCMonth() + 1).padStart(
2,
"0"
)}${String(now.getDate()).padStart(2, "0")}.0`;
)}${String(now.getUTCDate()).padStart(2, "0")}.0`;
}
function auto(version) {

View File

@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
setup(
name="home-assistant-frontend",
version="20210324.0",
version="20210416.0",
description="The Home Assistant frontend",
url="https://github.com/home-assistant/home-assistant-polymer",
author="The Home Assistant Authors",

View File

@@ -62,7 +62,7 @@ export const ensureConnectedCastSession = (cast: CastManager, auth: Auth) => {
return undefined;
}
return new Promise((resolve) => {
return new Promise<void>((resolve) => {
const unsub = cast.addEventListener("connection-changed", () => {
if (cast.castConnectedToOurHass) {
unsub();

View File

@@ -1,9 +1,10 @@
import { format } from "fecha";
import { FrontendTranslationData } from "../../data/translation";
import { toLocaleDateStringSupportsOptions } from "./check_options_support";
export const formatDate = toLocaleDateStringSupportsOptions
? (dateObj: Date, locales: string) =>
dateObj.toLocaleDateString(locales, {
? (dateObj: Date, locales: FrontendTranslationData) =>
dateObj.toLocaleDateString(locales.language, {
year: "numeric",
month: "long",
day: "numeric",
@@ -11,8 +12,8 @@ export const formatDate = toLocaleDateStringSupportsOptions
: (dateObj: Date) => format(dateObj, "longDate");
export const formatDateWeekday = toLocaleDateStringSupportsOptions
? (dateObj: Date, locales: string) =>
dateObj.toLocaleDateString(locales, {
? (dateObj: Date, locales: FrontendTranslationData) =>
dateObj.toLocaleDateString(locales.language, {
weekday: "long",
month: "short",
day: "numeric",

View File

@@ -1,9 +1,10 @@
import { format } from "fecha";
import { FrontendTranslationData } from "../../data/translation";
import { toLocaleStringSupportsOptions } from "./check_options_support";
export const formatDateTime = toLocaleStringSupportsOptions
? (dateObj: Date, locales: string) =>
dateObj.toLocaleString(locales, {
? (dateObj: Date, locales: FrontendTranslationData) =>
dateObj.toLocaleString(locales.language, {
year: "numeric",
month: "long",
day: "numeric",
@@ -13,8 +14,8 @@ export const formatDateTime = toLocaleStringSupportsOptions
: (dateObj: Date) => format(dateObj, "MMMM D, YYYY, HH:mm");
export const formatDateTimeWithSeconds = toLocaleStringSupportsOptions
? (dateObj: Date, locales: string) =>
dateObj.toLocaleString(locales, {
? (dateObj: Date, locales: FrontendTranslationData) =>
dateObj.toLocaleString(locales.language, {
year: "numeric",
month: "long",
day: "numeric",

View File

@@ -1,17 +1,18 @@
import { format } from "fecha";
import { FrontendTranslationData } from "../../data/translation";
import { toLocaleTimeStringSupportsOptions } from "./check_options_support";
export const formatTime = toLocaleTimeStringSupportsOptions
? (dateObj: Date, locales: string) =>
dateObj.toLocaleTimeString(locales, {
? (dateObj: Date, locales: FrontendTranslationData) =>
dateObj.toLocaleTimeString(locales.language, {
hour: "numeric",
minute: "2-digit",
})
: (dateObj: Date) => format(dateObj, "shortTime");
export const formatTimeWithSeconds = toLocaleTimeStringSupportsOptions
? (dateObj: Date, locales: string) =>
dateObj.toLocaleTimeString(locales, {
? (dateObj: Date, locales: FrontendTranslationData) =>
dateObj.toLocaleTimeString(locales.language, {
hour: "numeric",
minute: "2-digit",
second: "2-digit",
@@ -19,8 +20,8 @@ export const formatTimeWithSeconds = toLocaleTimeStringSupportsOptions
: (dateObj: Date) => format(dateObj, "mediumTime");
export const formatTimeWeekday = toLocaleTimeStringSupportsOptions
? (dateObj: Date, locales: string) =>
dateObj.toLocaleTimeString(locales, {
? (dateObj: Date, locales: FrontendTranslationData) =>
dateObj.toLocaleTimeString(locales.language, {
weekday: "long",
hour: "numeric",
minute: "2-digit",

View File

@@ -1,6 +1,10 @@
export const ensureArray = (value?: any) => {
if (!value || Array.isArray(value)) {
type NonUndefined<T> = T extends undefined ? never : T;
export function ensureArray(value: undefined): undefined;
export function ensureArray<T>(value: T | T[]): NonUndefined<T>[];
export function ensureArray(value) {
if (value === undefined || Array.isArray(value)) {
return value;
}
return [value];
};
}

View File

@@ -1,5 +1,6 @@
import { HassEntity } from "home-assistant-js-websocket";
import { UNAVAILABLE, UNKNOWN } from "../../data/entity";
import { FrontendTranslationData } from "../../data/translation";
import { formatDate } from "../datetime/format_date";
import { formatDateTime } from "../datetime/format_date_time";
import { formatTime } from "../datetime/format_time";
@@ -10,7 +11,7 @@ import { computeStateDomain } from "./compute_state_domain";
export const computeStateDisplay = (
localize: LocalizeFunc,
stateObj: HassEntity,
language: string,
locale: FrontendTranslationData,
state?: string
): string => {
const compareState = state !== undefined ? state : stateObj.state;
@@ -20,7 +21,7 @@ export const computeStateDisplay = (
}
if (stateObj.attributes.unit_of_measurement) {
return `${formatNumber(compareState, language)} ${
return `${formatNumber(compareState, locale)} ${
stateObj.attributes.unit_of_measurement
}`;
}
@@ -35,7 +36,7 @@ export const computeStateDisplay = (
stateObj.attributes.month - 1,
stateObj.attributes.day
);
return formatDate(date, language);
return formatDate(date, locale);
}
if (!stateObj.attributes.has_date) {
const now = new Date();
@@ -48,7 +49,7 @@ export const computeStateDisplay = (
stateObj.attributes.hour,
stateObj.attributes.minute
);
return formatTime(date, language);
return formatTime(date, locale);
}
date = new Date(
@@ -58,7 +59,7 @@ export const computeStateDisplay = (
stateObj.attributes.hour,
stateObj.attributes.minute
);
return formatDateTime(date, language);
return formatDateTime(date, locale);
}
if (domain === "humidifier") {
@@ -67,8 +68,13 @@ export const computeStateDisplay = (
}
}
if (domain === "counter") {
return formatNumber(compareState, language);
// `counter` `number` and `input_number` domains do not have a unit of measurement but should still use `formatNumber`
if (
domain === "counter" ||
domain === "number" ||
domain === "input_number"
) {
return formatNumber(compareState, locale);
}
return (

View File

@@ -12,12 +12,16 @@ declare global {
export const navigate = (_node: any, path: string, replace = false) => {
if (__DEMO__) {
if (replace) {
history.replaceState(null, "", `${location.pathname}#${path}`);
history.replaceState(
history.state?.root ? { root: true } : null,
"",
`${location.pathname}#${path}`
);
} else {
window.location.hash = path;
}
} else if (replace) {
history.replaceState(null, "", path);
history.replaceState(history.state?.root ? { root: true } : null, "", path);
} else {
history.pushState(null, "", path);
}

View File

@@ -34,14 +34,12 @@ const _maxLen = 128;
function initTable() {
const table: number[][] = [];
const row: number[] = [0];
for (let i = 1; i <= _maxLen; i++) {
row.push(-i);
const row: number[] = [];
for (let i = 0; i <= _maxLen; i++) {
row[i] = 0;
}
for (let i = 0; i <= _maxLen; i++) {
const thisRow = row.slice(0);
thisRow[0] = -i;
table.push(thisRow);
table.push(row.slice(0));
}
return table;
}
@@ -50,7 +48,7 @@ function isSeparatorAtPos(value: string, index: number): boolean {
if (index < 0 || index >= value.length) {
return false;
}
const code = value.charCodeAt(index);
const code = value.codePointAt(index);
switch (code) {
case CharCode.Underline:
case CharCode.Dash:
@@ -62,8 +60,16 @@ function isSeparatorAtPos(value: string, index: number): boolean {
case CharCode.DoubleQuote:
case CharCode.Colon:
case CharCode.DollarSign:
case CharCode.LessThan:
case CharCode.OpenParen:
case CharCode.OpenSquareBracket:
return true;
case undefined:
return false;
default:
if (isEmojiImprecise(code)) {
return true;
}
return false;
}
}
@@ -92,10 +98,15 @@ function isPatternInWord(
patternLen: number,
wordLow: string,
wordPos: number,
wordLen: number
wordLen: number,
fillMinWordPosArr = false
): boolean {
while (patternPos < patternLen && wordPos < wordLen) {
if (patternLow[patternPos] === wordLow[wordPos]) {
if (fillMinWordPosArr) {
// Remember the min word position for each pattern position
_minWordMatchPos[patternPos] = wordPos;
}
patternPos += 1;
}
wordPos += 1;
@@ -104,42 +115,22 @@ function isPatternInWord(
}
enum Arrow {
Top = 0b1,
Diag = 0b10,
Left = 0b100,
Diag = 1,
Left = 2,
LeftLeft = 3,
}
/**
* A tuple of three values.
* An array representating a fuzzy match.
*
* 0. the score
* 1. the matches encoded as bitmask (2^53)
* 2. the offset at which matching started
* 1. the offset at which matching started
* 2. `<match_pos_N>`
* 3. `<match_pos_1>`
* 4. `<match_pos_0>` etc
*/
export type FuzzyScore = [number, number, number];
interface FilterGlobals {
_matchesCount: number;
_topMatch2: number;
_topScore: number;
_wordStart: number;
_firstMatchCanBeWeak: boolean;
_table: number[][];
_scores: number[][];
_arrows: Arrow[][];
}
function initGlobals(): FilterGlobals {
return {
_matchesCount: 0,
_topMatch2: 0,
_topScore: 0,
_wordStart: 0,
_firstMatchCanBeWeak: false,
_table: initTable(),
_scores: initTable(),
_arrows: <Arrow[][]>initTable(),
};
}
// export type FuzzyScore = [score: number, wordStart: number, ...matches: number[]];// [number, number, number];
export type FuzzyScore = Array<number>;
export function fuzzyScore(
pattern: string,
@@ -150,7 +141,6 @@ export function fuzzyScore(
wordStart: number,
firstMatchCanBeWeak: boolean
): FuzzyScore | undefined {
const globals = initGlobals();
const patternLen = pattern.length > _maxLen ? _maxLen : pattern.length;
const wordLen = word.length > _maxLen ? _maxLen : word.length;
@@ -172,18 +162,30 @@ export function fuzzyScore(
patternLen,
wordLow,
wordStart,
wordLen
wordLen,
true
)
) {
return undefined;
}
// Find the max matching word position for each pattern position
// NOTE: the min matching word position was filled in above, in the `isPatternInWord` call
_fillInMaxWordMatchPos(
patternLen,
wordLen,
patternStart,
wordStart,
patternLow,
wordLow
);
let row = 1;
let column = 1;
let patternPos = patternStart;
let wordPos = wordStart;
let hasStrongFirstMatch = false;
const hasStrongFirstMatch = [false];
// There will be a match, fill in tables
for (
@@ -191,83 +193,146 @@ export function fuzzyScore(
patternPos < patternLen;
row++, patternPos++
) {
// Reduce search space to possible matching word positions and to possible access from next row
const minWordMatchPos = _minWordMatchPos[patternPos];
const maxWordMatchPos = _maxWordMatchPos[patternPos];
const nextMaxWordMatchPos =
patternPos + 1 < patternLen ? _maxWordMatchPos[patternPos + 1] : wordLen;
for (
column = 1, wordPos = wordStart;
wordPos < wordLen;
column = minWordMatchPos - wordStart + 1, wordPos = minWordMatchPos;
wordPos < nextMaxWordMatchPos;
column++, wordPos++
) {
const score = _doScore(
pattern,
patternLow,
patternPos,
patternStart,
word,
wordLow,
wordPos
);
let score = Number.MIN_SAFE_INTEGER;
let canComeDiag = false;
if (patternPos === patternStart && score > 1) {
hasStrongFirstMatch = true;
if (wordPos <= maxWordMatchPos) {
score = _doScore(
pattern,
patternLow,
patternPos,
patternStart,
word,
wordLow,
wordPos,
wordLen,
wordStart,
_diag[row - 1][column - 1] === 0,
hasStrongFirstMatch
);
}
globals._scores[row][column] = score;
let diagScore = 0;
if (score !== Number.MAX_SAFE_INTEGER) {
canComeDiag = true;
diagScore = score + _table[row - 1][column - 1];
}
const diag =
globals._table[row - 1][column - 1] + (score > 1 ? 1 : score);
const top = globals._table[row - 1][column] + -1;
const left = globals._table[row][column - 1] + -1;
const canComeLeft = wordPos > minWordMatchPos;
const leftScore = canComeLeft
? _table[row][column - 1] + (_diag[row][column - 1] > 0 ? -5 : 0)
: 0; // penalty for a gap start
if (left >= top) {
// left or diag
if (left > diag) {
globals._table[row][column] = left;
globals._arrows[row][column] = Arrow.Left;
} else if (left === diag) {
globals._table[row][column] = left;
globals._arrows[row][column] = Arrow.Left || Arrow.Diag;
} else {
globals._table[row][column] = diag;
globals._arrows[row][column] = Arrow.Diag;
}
} else if (top > diag) {
globals._table[row][column] = top;
globals._arrows[row][column] = Arrow.Top;
} else if (top === diag) {
globals._table[row][column] = top;
globals._arrows[row][column] = Arrow.Top || Arrow.Diag;
const canComeLeftLeft =
wordPos > minWordMatchPos + 1 && _diag[row][column - 1] > 0;
const leftLeftScore = canComeLeftLeft
? _table[row][column - 2] + (_diag[row][column - 2] > 0 ? -5 : 0)
: 0; // penalty for a gap start
if (
canComeLeftLeft &&
(!canComeLeft || leftLeftScore >= leftScore) &&
(!canComeDiag || leftLeftScore >= diagScore)
) {
// always prefer choosing left left to jump over a diagonal because that means a match is earlier in the word
_table[row][column] = leftLeftScore;
_arrows[row][column] = Arrow.LeftLeft;
_diag[row][column] = 0;
} else if (canComeLeft && (!canComeDiag || leftScore >= diagScore)) {
// always prefer choosing left since that means a match is earlier in the word
_table[row][column] = leftScore;
_arrows[row][column] = Arrow.Left;
_diag[row][column] = 0;
} else if (canComeDiag) {
_table[row][column] = diagScore;
_arrows[row][column] = Arrow.Diag;
_diag[row][column] = _diag[row - 1][column - 1] + 1;
} else {
globals._table[row][column] = diag;
globals._arrows[row][column] = Arrow.Diag;
throw new Error(`not possible`);
}
}
}
if (_debug) {
printTables(pattern, patternStart, word, wordStart, globals);
printTables(pattern, patternStart, word, wordStart);
}
if (!hasStrongFirstMatch && !firstMatchCanBeWeak) {
if (!hasStrongFirstMatch[0] && !firstMatchCanBeWeak) {
return undefined;
}
globals._matchesCount = 0;
globals._topScore = -100;
globals._wordStart = wordStart;
globals._firstMatchCanBeWeak = firstMatchCanBeWeak;
row--;
column--;
_findAllMatches2(
row - 1,
column - 1,
patternLen === wordLen ? 1 : 0,
0,
false,
globals
);
if (globals._matchesCount === 0) {
return undefined;
const result: FuzzyScore = [_table[row][column], wordStart];
let backwardsDiagLength = 0;
let maxMatchColumn = 0;
while (row >= 1) {
// Find the column where we go diagonally up
let diagColumn = column;
do {
const arrow = _arrows[row][diagColumn];
if (arrow === Arrow.LeftLeft) {
diagColumn -= 2;
} else if (arrow === Arrow.Left) {
diagColumn -= 1;
} else {
// found the diagonal
break;
}
} while (diagColumn >= 1);
// Overturn the "forwards" decision if keeping the "backwards" diagonal would give a better match
if (
backwardsDiagLength > 1 && // only if we would have a contiguous match of 3 characters
patternLow[patternStart + row - 1] === wordLow[wordStart + column - 1] && // only if we can do a contiguous match diagonally
!isUpperCaseAtPos(diagColumn + wordStart - 1, word, wordLow) && // only if the forwards chose diagonal is not an uppercase
backwardsDiagLength + 1 > _diag[row][diagColumn] // only if our contiguous match would be longer than the "forwards" contiguous match
) {
diagColumn = column;
}
if (diagColumn === column) {
// this is a contiguous match
backwardsDiagLength++;
} else {
backwardsDiagLength = 1;
}
if (!maxMatchColumn) {
// remember the last matched column
maxMatchColumn = diagColumn;
}
row--;
column = diagColumn - 1;
result.push(column);
}
return [globals._topScore, globals._topMatch2, wordStart];
if (wordLen === patternLen) {
// the word matches the pattern with all characters!
// giving the score a total match boost (to come up ahead other words)
result[0] += 2;
}
// Add 1 penalty for each skipped character in the word
const skippedCharsCount = maxMatchColumn - patternLen;
result[0] -= skippedCharsCount;
return result;
}
function _doScore(
@@ -277,50 +342,81 @@ function _doScore(
patternStart: number,
word: string,
wordLow: string,
wordPos: number
) {
wordPos: number,
wordLen: number,
wordStart: number,
newMatchStart: boolean,
outFirstMatchStrong: boolean[]
): number {
if (patternLow[patternPos] !== wordLow[wordPos]) {
return -1;
return Number.MIN_SAFE_INTEGER;
}
let score = 1;
let isGapLocation = false;
if (wordPos === patternPos - patternStart) {
// common prefix: `foobar <-> foobaz`
// ^^^^^
if (pattern[patternPos] === word[wordPos]) {
return 7;
}
return 5;
}
if (
score = pattern[patternPos] === word[wordPos] ? 7 : 5;
} else if (
isUpperCaseAtPos(wordPos, word, wordLow) &&
(wordPos === 0 || !isUpperCaseAtPos(wordPos - 1, word, wordLow))
) {
// hitting upper-case: `foo <-> forOthers`
// ^^ ^
if (pattern[patternPos] === word[wordPos]) {
return 7;
}
return 5;
}
if (
score = pattern[patternPos] === word[wordPos] ? 7 : 5;
isGapLocation = true;
} else if (
isSeparatorAtPos(wordLow, wordPos) &&
(wordPos === 0 || !isSeparatorAtPos(wordLow, wordPos - 1))
) {
// hitting a separator: `. <-> foo.bar`
// ^
return 5;
}
if (
score = 5;
} else if (
isSeparatorAtPos(wordLow, wordPos - 1) ||
isWhitespaceAtPos(wordLow, wordPos - 1)
) {
// post separator: `foo <-> bar_foo`
// ^^^
return 5;
score = 5;
isGapLocation = true;
}
return 1;
if (score > 1 && patternPos === patternStart) {
outFirstMatchStrong[0] = true;
}
if (!isGapLocation) {
isGapLocation =
isUpperCaseAtPos(wordPos, word, wordLow) ||
isSeparatorAtPos(wordLow, wordPos - 1) ||
isWhitespaceAtPos(wordLow, wordPos - 1);
}
//
if (patternPos === patternStart) {
// first character in pattern
if (wordPos > wordStart) {
// the first pattern character would match a word character that is not at the word start
// so introduce a penalty to account for the gap preceding this match
score -= isGapLocation ? 3 : 5;
}
} else if (newMatchStart) {
// this would be the beginning of a new match (i.e. there would be a gap before this location)
score += isGapLocation ? 2 : 0;
} else {
// this is part of a contiguous match, so give it a slight bonus, but do so only if it would not be a prefered gap location
score += isGapLocation ? 0 : 1;
}
if (wordPos + 1 === wordLen) {
// we always penalize gaps, but this gives unfair advantages to a match that would match the last character in the word
// so pretend there is a gap after the last character in the word to normalize things
score -= isGapLocation ? 3 : 5;
}
return score;
}
function printTable(
@@ -360,104 +456,96 @@ function printTables(
pattern: string,
patternStart: number,
word: string,
wordStart: number,
globals: FilterGlobals
wordStart: number
): void {
pattern = pattern.substr(patternStart);
word = word.substr(wordStart);
console.log(
printTable(globals._table, pattern, pattern.length, word, word.length)
);
console.log(
printTable(globals._arrows, pattern, pattern.length, word, word.length)
);
console.log(
printTable(globals._scores, pattern, pattern.length, word, word.length)
);
console.log(printTable(_table, pattern, pattern.length, word, word.length));
console.log(printTable(_arrows, pattern, pattern.length, word, word.length));
console.log(printTable(_diag, pattern, pattern.length, word, word.length));
}
function _findAllMatches2(
row: number,
column: number,
total: number,
matches: number,
lastMatched: boolean,
globals: FilterGlobals
): void {
if (globals._matchesCount >= 10 || total < -25) {
// stop when having already 10 results, or
// when a potential alignment as already 5 gaps
return;
const _minWordMatchPos = initArr(2 * _maxLen); // min word position for a certain pattern position
const _maxWordMatchPos = initArr(2 * _maxLen); // max word position for a certain pattern position
const _diag = initTable(); // the length of a contiguous diagonal match
const _table = initTable();
const _arrows = <Arrow[][]>initTable();
function initArr(maxLen: number) {
const row: number[] = [];
for (let i = 0; i <= maxLen; i++) {
row[i] = 0;
}
return row;
}
let simpleMatchCount = 0;
function _fillInMaxWordMatchPos(
patternLen: number,
wordLen: number,
patternStart: number,
wordStart: number,
patternLow: string,
wordLow: string
) {
let patternPos = patternLen - 1;
let wordPos = wordLen - 1;
while (patternPos >= patternStart && wordPos >= wordStart) {
if (patternLow[patternPos] === wordLow[wordPos]) {
_maxWordMatchPos[patternPos] = wordPos;
patternPos--;
}
wordPos--;
}
}
while (row > 0 && column > 0) {
const score = globals._scores[row][column];
const arrow = globals._arrows[row][column];
export interface FuzzyScorer {
(
pattern: string,
lowPattern: string,
patternPos: number,
word: string,
lowWord: string,
wordPos: number,
firstMatchCanBeWeak: boolean
): FuzzyScore | undefined;
}
if (arrow === Arrow.Left) {
// left -> no match, skip a word character
column -= 1;
if (lastMatched) {
total -= 5; // new gap penalty
} else if (matches !== 0) {
total -= 1; // gap penalty after first match
}
lastMatched = false;
simpleMatchCount = 0;
} else if (arrow && Arrow.Diag) {
if (arrow && Arrow.Left) {
// left
_findAllMatches2(
row,
column - 1,
matches !== 0 ? total - 1 : total, // gap penalty after first match
matches,
lastMatched,
globals
);
}
// diag
total += score;
row -= 1;
column -= 1;
lastMatched = true;
// match -> set a 1 at the word pos
matches += 2 ** (column + globals._wordStart);
// count simple matches and boost a row of
// simple matches when they yield in a
// strong match.
if (score === 1) {
simpleMatchCount += 1;
if (row === 0 && !globals._firstMatchCanBeWeak) {
// when the first match is a weak
// match we discard it
return;
}
} else {
// boost
total += 1 + simpleMatchCount * (score - 1);
simpleMatchCount = 0;
}
export function createMatches(score: undefined | FuzzyScore): Match[] {
if (typeof score === "undefined") {
return [];
}
const res: Match[] = [];
const wordPos = score[1];
for (let i = score.length - 1; i > 1; i--) {
const pos = score[i] + wordPos;
const last = res[res.length - 1];
if (last && last.end === pos) {
last.end = pos + 1;
} else {
return;
res.push({ start: pos, end: pos + 1 });
}
}
total -= column >= 3 ? 9 : column * 3; // late start penalty
// dynamically keep track of the current top score
// and insert the current best score at head, the rest at tail
globals._matchesCount += 1;
if (total > globals._topScore) {
globals._topScore = total;
globals._topMatch2 = matches;
}
return res;
}
// #endregion
/**
* A fast function (therefore imprecise) to check if code points are emojis.
* Generated using https://github.com/alexdima/unicode-utils/blob/master/generate-emoji-test.js
*/
export function isEmojiImprecise(x: number): boolean {
return (
(x >= 0x1f1e6 && x <= 0x1f1ff) ||
x === 8986 ||
x === 8987 ||
x === 9200 ||
x === 9203 ||
(x >= 9728 && x <= 10175) ||
x === 11088 ||
x === 11093 ||
(x >= 127744 && x <= 128591) ||
(x >= 128640 && x <= 128764) ||
(x >= 128992 && x <= 129003) ||
(x >= 129280 && x <= 129535) ||
(x >= 129648 && x <= 129750)
);
}

View File

@@ -10,10 +10,13 @@ import { fuzzyScore } from "./filter";
* @return {number} Score representing how well the word matches the filter. Return of 0 means no match.
*/
export const fuzzySequentialMatch = (filter: string, ...words: string[]) => {
let topScore = 0;
export const fuzzySequentialMatch = (
filter: string,
item: ScorableTextItem
) => {
let topScore = Number.NEGATIVE_INFINITY;
for (const word of words) {
for (const word of item.strings) {
const scores = fuzzyScore(
filter,
filter.toLowerCase(),
@@ -28,22 +31,39 @@ export const fuzzySequentialMatch = (filter: string, ...words: string[]) => {
continue;
}
// The VS Code implementation of filter treats a score of "0" as just barely a match
// But we will typically use this matcher in a .filter(), which interprets 0 as a failure.
// By shifting all scores up by 1, we allow "0" matches, while retaining score precedence
const score = scores[0] + 1;
// The VS Code implementation of filter returns a 0 for a weak match.
// But if .filter() sees a "0", it considers that a failed match and will remove it.
// So, we set score to 1 in these cases so the match will be included, and mostly respect correct ordering.
const score = scores[0] === 0 ? 1 : scores[0];
if (score > topScore) {
topScore = score;
}
}
if (topScore === Number.NEGATIVE_INFINITY) {
return undefined;
}
return topScore;
};
/**
* An interface that objects must extend in order to use the fuzzy sequence matcher
*
* @param {number} score - A number representing the existence and strength of a match.
* - `< 0` means a good match that starts in the middle of the string
* - `> 0` means a good match that starts at the beginning of the string
* - `0` means just barely a match
* - `undefined` means not a match
*
* @param {string} strings - Array of strings (aliases) representing the item. The filter string will be compared against each of these for a match.
*
*/
export interface ScorableTextItem {
score?: number;
text: string;
altText?: string;
strings: string[];
}
type FuzzyFilterSort = <T extends ScorableTextItem>(
@@ -54,12 +74,10 @@ type FuzzyFilterSort = <T extends ScorableTextItem>(
export const fuzzyFilterSort: FuzzyFilterSort = (filter, items) => {
return items
.map((item) => {
item.score = item.altText
? fuzzySequentialMatch(filter, item.text, item.altText)
: fuzzySequentialMatch(filter, item.text);
item.score = fuzzySequentialMatch(filter, item);
return item;
})
.filter((item) => item.score !== undefined && item.score > 0)
.filter((item) => item.score !== undefined)
.sort(({ score: scoreA = 0 }, { score: scoreB = 0 }) =>
scoreA > scoreB ? -1 : scoreA < scoreB ? 1 : 0
);

View File

@@ -1,14 +1,36 @@
import { FrontendTranslationData, NumberFormat } from "../../data/translation";
/**
* Formats a number based on the specified language with thousands separator(s) and decimal character for better legibility.
* Formats a number based on the user's preference with thousands separator(s) and decimal character for better legibility.
*
* @param num The number to format
* @param language The language to use when formatting the number
* @param locale The user-selected language and number format, from `hass.locale`
* @param options Intl.NumberFormatOptions to use
*/
export const formatNumber = (
num: string | number,
language: string,
locale?: FrontendTranslationData,
options?: Intl.NumberFormatOptions
): string => {
let format: string | string[] | undefined;
switch (locale?.number_format) {
case NumberFormat.comma_decimal:
format = ["en-US", "en"]; // Use United States with fallback to English formatting 1,234,567.89
break;
case NumberFormat.decimal_comma:
format = ["de", "es", "it"]; // Use German with fallback to Spanish then Italian formatting 1.234.567,89
break;
case NumberFormat.space_comma:
format = ["fr", "sv", "cs"]; // Use French with fallback to Swedish and Czech formatting 1 234 567,89
break;
case NumberFormat.system:
format = undefined;
break;
default:
format = locale?.language;
}
// Polyfill for Number.isNaN, which is more reliable than the global isNaN()
Number.isNaN =
Number.isNaN ||
@@ -16,13 +38,27 @@ export const formatNumber = (
return typeof input === "number" && isNaN(input);
};
if (!Number.isNaN(Number(num)) && Intl) {
return new Intl.NumberFormat(
language,
getDefaultFormatOptions(num, options)
).format(Number(num));
if (
!Number.isNaN(Number(num)) &&
Intl &&
locale?.number_format !== NumberFormat.none
) {
try {
return new Intl.NumberFormat(
format,
getDefaultFormatOptions(num, options)
).format(Number(num));
} catch (error) {
// Don't fail when using "TEST" language
// eslint-disable-next-line no-console
console.error(error);
return new Intl.NumberFormat(
undefined,
getDefaultFormatOptions(num, options)
).format(Number(num));
}
}
return num.toString();
return num ? num.toString() : "";
};
/**

View File

@@ -1,4 +1,5 @@
const isTemplateRegex = new RegExp("{%|{{|{#");
const isTemplateRegex = new RegExp("{%|{{");
export const isTemplate = (value: string): boolean =>
isTemplateRegex.test(value);
@@ -11,7 +12,7 @@ export const hasTemplate = (value: unknown): boolean => {
}
if (typeof value === "object") {
const values = Array.isArray(value) ? value : Object.values(value!);
return values.some((val) => hasTemplate(val));
return values.some((val) => val && hasTemplate(val));
}
return false;
};

View File

@@ -0,0 +1,2 @@
export const strStartsWith = (value: string, search: string) =>
value.substring(0, search.length) === search;

View File

@@ -0,0 +1,5 @@
export const constructUrlCurrentPath = (searchParams: string): string => {
const base = window.location.pathname;
// Prevent trailing "?" if no parameters exist
return searchParams ? base + "?" + searchParams : base;
};

View File

@@ -19,3 +19,17 @@ export const createSearchParam = (params: Record<string, string>): string => {
});
return urlParams.toString();
};
export const addSearchParam = (params: Record<string, string>): string => {
const urlParams = new URLSearchParams(window.location.search);
Object.entries(params).forEach(([key, value]) => {
urlParams.set(key, value);
});
return urlParams.toString();
};
export const removeSearchParam = (param: string): string => {
const urlParams = new URLSearchParams(window.location.search);
urlParams.delete(param);
return urlParams.toString();
};

View File

@@ -1,4 +1,4 @@
export const afterNextRender = (cb: () => void): void => {
export const afterNextRender = (cb: (value: unknown) => void): void => {
requestAnimationFrame(() => setTimeout(cb, 0));
};

View File

@@ -1,65 +0,0 @@
import { html, LitElement } from "lit-element";
import { fireEvent } from "../../common/dom/fire_event";
import "./ha-progress-button";
class HaCallApiButton extends LitElement {
render() {
return html`
<ha-progress-button
.progress="${this.progress}"
@click="${this._buttonTapped}"
?disabled="${this.disabled}"
><slot></slot
></ha-progress-button>
`;
}
constructor() {
super();
this.method = "POST";
this.data = {};
this.disabled = false;
this.progress = false;
}
static get properties() {
return {
hass: {},
progress: Boolean,
path: String,
method: String,
data: {},
disabled: Boolean,
};
}
get progressButton() {
return this.renderRoot.querySelector("ha-progress-button");
}
async _buttonTapped() {
this.progress = true;
const eventData = {
method: this.method,
path: this.path,
data: this.data,
};
try {
const resp = await this.hass.callApi(this.method, this.path, this.data);
this.progress = false;
this.progressButton.actionSuccess();
eventData.success = true;
eventData.response = resp;
} catch (err) {
this.progress = false;
this.progressButton.actionError();
eventData.success = false;
eventData.response = err;
}
fireEvent(this, "hass-api-called", eventData);
}
}
customElements.define("ha-call-api-button", HaCallApiButton);

View File

@@ -0,0 +1,77 @@
import { css, CSSResult, html, LitElement, property, query } from "lit-element";
import { fireEvent } from "../../common/dom/fire_event";
import { HomeAssistant } from "../../types";
import "./ha-progress-button";
class HaCallApiButton extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public method: "POST" | "GET" | "PUT" | "DELETE" = "POST";
@property() public data = {};
@property({ type: Boolean, reflect: true }) public disabled = false;
@property({ type: Boolean }) public progress = false;
@property() public path?: string;
@query("ha-progress-button", true) private _progressButton;
render() {
return html`
<ha-progress-button
.progress=${this.progress}
@click=${this._buttonTapped}
?disabled=${this.disabled}
><slot></slot
></ha-progress-button>
`;
}
async _buttonTapped() {
this.progress = true;
const eventData: {
method: string;
path: string;
data: any;
success?: boolean;
response?: any;
} = {
method: this.method,
path: this.path!,
data: this.data,
};
try {
const resp = await this.hass.callApi(this.method, this.path!, this.data);
this.progress = false;
this._progressButton.actionSuccess();
eventData.success = true;
eventData.response = resp;
} catch (err) {
this.progress = false;
this._progressButton.actionError();
eventData.success = false;
eventData.response = err;
}
fireEvent(this, "hass-api-called", eventData as any);
}
static get styles(): CSSResult {
return css`
:host([disabled]) {
pointer-events: none;
}
`;
}
}
customElements.define("ha-call-api-button", HaCallApiButton);
declare global {
interface HTMLElementTagNameMap {
"ha-call-api-button": HaCallApiButton;
}
}

View File

@@ -63,7 +63,7 @@ export interface DataTableSortColumnData {
}
export interface DataTableColumnData extends DataTableSortColumnData {
title: string;
title: TemplateResult | string;
type?: "numeric" | "icon" | "icon-button";
template?: <T>(data: any, row: T) => TemplateResult | string;
width?: string;
@@ -74,7 +74,7 @@ export interface DataTableColumnData extends DataTableSortColumnData {
}
type ClonedDataTableColumnData = Omit<DataTableColumnData, "title"> & {
title?: string;
title?: TemplateResult | string;
};
export interface DataTableRowData {
@@ -132,7 +132,7 @@ export class HaDataTable extends LitElement {
@query("slot[name='header']") private _header!: HTMLSlotElement;
private _items: DataTableRowData[] = [];
@internalProperty() private _items: DataTableRowData[] = [];
private _checkableRowsCount?: number;
@@ -160,9 +160,9 @@ export class HaDataTable extends LitElement {
public connectedCallback() {
super.connectedCallback();
if (this._filteredData.length) {
if (this._items.length) {
// Force update of location of rows
this._filteredData = [...this._filteredData];
this._items = [...this._items];
}
}
@@ -550,7 +550,9 @@ export class HaDataTable extends LitElement {
private _checkedRowsChanged() {
// force scroller to update, change it's items
this._filteredData = [...this._filteredData];
if (this._items.length) {
this._items = [...this._items];
}
fireEvent(this, "selection-changed", {
value: this._checkedRows,
});

View File

@@ -100,7 +100,7 @@ export class HaDevicePicker extends SubscribeMixin(LitElement) {
public excludeDomains?: string[];
/**
* Show only deviced with entities of these device classes.
* Show only devices with entities of these device classes.
* @type {Array}
* @attr include-device-classes
*/
@@ -113,7 +113,7 @@ export class HaDevicePicker extends SubscribeMixin(LitElement) {
@internalProperty() private _opened?: boolean;
@query("ha-combo-box", true) private _comboBox!: HaComboBox;
@query("ha-combo-box", true) public comboBox!: HaComboBox;
private _init = false;
@@ -242,11 +242,11 @@ export class HaDevicePicker extends SubscribeMixin(LitElement) {
);
public open() {
this._comboBox?.open();
this.comboBox?.open();
}
public focus() {
this._comboBox?.focus();
this.comboBox?.focus();
}
public hassSubscribe(): UnsubscribeFunc[] {
@@ -269,7 +269,7 @@ export class HaDevicePicker extends SubscribeMixin(LitElement) {
(changedProps.has("_opened") && this._opened)
) {
this._init = true;
(this._comboBox as any).items = this._getDevices(
(this.comboBox as any).items = this._getDevices(
this.devices!,
this.areas!,
this.entities!,

View File

@@ -371,7 +371,7 @@ class HaChartBase extends mixinBehaviors(
return value;
}
const date = new Date(values[index].value);
return formatTime(date, this.hass.language);
return formatTime(date, this.hass.locale);
}
drawChart() {

View File

@@ -99,7 +99,7 @@ export class HaEntityPicker extends LitElement {
@property({ type: Boolean }) private _opened = false;
@query("vaadin-combo-box-light", true) private _comboBox!: HTMLElement;
@query("vaadin-combo-box-light", true) private comboBox!: HTMLElement;
public open() {
this.updateComplete.then(() => {
@@ -208,7 +208,7 @@ export class HaEntityPicker extends LitElement {
this.entityFilter,
this.includeDeviceClasses
);
(this._comboBox as any).filteredItems = this._states;
(this.comboBox as any).filteredItems = this._states;
this._initedStates = true;
}
}
@@ -296,7 +296,7 @@ export class HaEntityPicker extends LitElement {
private _filterChanged(ev: CustomEvent): void {
const filterString = ev.detail.value.toLowerCase();
(this._comboBox as any).filteredItems = this._states.filter(
(this.comboBox as any).filteredItems = this._states.filter(
(state) =>
state.entity_id.toLowerCase().includes(filterString) ||
computeStateName(state).toLowerCase().includes(filterString)

View File

@@ -116,12 +116,8 @@ export class HaStateLabelBadge extends LitElement {
: state.state === UNKNOWN
? "-"
: state.attributes.unit_of_measurement
? formatNumber(state.state, this.hass!.language)
: computeStateDisplay(
this.hass!.localize,
state,
this.hass!.language
);
? formatNumber(state.state, this.hass!.locale)
: computeStateDisplay(this.hass!.localize, state, this.hass!.locale);
}
}

View File

@@ -84,7 +84,7 @@ class StateInfo extends LitElement {
}
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (!oldHass || oldHass.language !== this.hass.language) {
if (!oldHass || oldHass.locale !== this.hass.locale) {
this.rtl = computeRTL(this.hass);
}
}

View File

@@ -0,0 +1,10 @@
import { html } from "lit-element";
import { HomeAssistant } from "../types";
import { documentationUrl } from "../util/documentation-url";
export const analyticsLearnMore = (hass: HomeAssistant) => html`<a
.href=${documentationUrl(hass, "/integrations/analytics/")}
target="_blank"
rel="noreferrer"
>${hass.localize("ui.panel.config.core.section.core.analytics.learn_more")}</a
>`;

View File

@@ -0,0 +1,199 @@
import "@polymer/paper-tooltip/paper-tooltip";
import {
css,
CSSResult,
customElement,
html,
LitElement,
property,
TemplateResult,
} from "lit-element";
import { isComponentLoaded } from "../common/config/is_component_loaded";
import { fireEvent } from "../common/dom/fire_event";
import { Analytics, AnalyticsPreferences } from "../data/analytics";
import { haStyle } from "../resources/styles";
import { HomeAssistant } from "../types";
import "./ha-checkbox";
import type { HaCheckbox } from "./ha-checkbox";
import "./ha-settings-row";
const ADDITIONAL_PREFERENCES = ["usage", "statistics"];
declare global {
interface HASSDomEvents {
"analytics-preferences-changed": { preferences: AnalyticsPreferences };
}
}
@customElement("ha-analytics")
export class HaAnalytics extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public analytics?: Analytics;
protected render(): TemplateResult {
const loading = this.analytics === undefined;
const baseEnabled = !loading && this.analytics!.preferences.base;
return html`
<ha-settings-row>
<span slot="prefix">
<ha-checkbox
@change=${this._handleRowCheckboxClick}
.checked=${baseEnabled}
.preference=${"base"}
.disabled=${loading}
name="base"
>
</ha-checkbox>
</span>
<span slot="heading" data-for="base">
${this.hass.localize(
`ui.panel.config.core.section.core.analytics.preference.base.title`
)}
</span>
<span slot="description" data-for="base">
${this.hass.localize(
`ui.panel.config.core.section.core.analytics.preference.base.description`
)}
</span>
</ha-settings-row>
${ADDITIONAL_PREFERENCES.map(
(preference) =>
html`<ha-settings-row>
<span slot="prefix">
<ha-checkbox
@change=${this._handleRowCheckboxClick}
.checked=${this.analytics?.preferences[preference]}
.preference=${preference}
name=${preference}
>
</ha-checkbox>
${!baseEnabled
? html`<paper-tooltip animation-delay="0" position="right"
>${this.hass.localize(
"ui.panel.config.core.section.core.analytics.needs_base"
)}
</paper-tooltip>`
: ""}
</span>
<span slot="heading" data-for=${preference}>
${preference === "usage"
? isComponentLoaded(this.hass, "hassio")
? this.hass.localize(
`ui.panel.config.core.section.core.analytics.preference.usage_supervisor.title`
)
: this.hass.localize(
`ui.panel.config.core.section.core.analytics.preference.usage.title`
)
: this.hass.localize(
`ui.panel.config.core.section.core.analytics.preference.${preference}.title`
)}
</span>
<span slot="description" data-for=${preference}>
${preference !== "usage"
? this.hass.localize(
`ui.panel.config.core.section.core.analytics.preference.${preference}.description`
)
: isComponentLoaded(this.hass, "hassio")
? this.hass.localize(
`ui.panel.config.core.section.core.analytics.preference.usage_supervisor.description`
)
: this.hass.localize(
`ui.panel.config.core.section.core.analytics.preference.usage.description`
)}
</span>
</ha-settings-row>`
)}
<ha-settings-row>
<span slot="prefix">
<ha-checkbox
@change=${this._handleRowCheckboxClick}
.checked=${this.analytics?.preferences.diagnostics}
.preference=${"diagnostics"}
.disabled=${loading}
name="diagnostics"
>
</ha-checkbox>
</span>
<span slot="heading" data-for="diagnostics">
${this.hass.localize(
`ui.panel.config.core.section.core.analytics.preference.diagnostics.title`
)}
</span>
<span slot="description" data-for="diagnostics">
${this.hass.localize(
`ui.panel.config.core.section.core.analytics.preference.diagnostics.description`
)}
</span>
</ha-settings-row>
`;
}
protected updated(changedProps) {
super.updated(changedProps);
this.shadowRoot!.querySelectorAll("*[data-for]").forEach((el) => {
const forEl = (el as HTMLElement).dataset.for;
delete (el as HTMLElement).dataset.for;
el.addEventListener("click", () => {
const toFocus = this.shadowRoot!.querySelector(
`*[name=${forEl}]`
) as HTMLElement | null;
if (toFocus) {
toFocus.focus();
toFocus.click();
}
});
});
}
private _handleRowCheckboxClick(ev: Event) {
const checkbox = ev.currentTarget as HaCheckbox;
const preference = (checkbox as any).preference;
const preferences = this.analytics ? { ...this.analytics.preferences } : {};
if (preferences[preference] === checkbox.checked) {
return;
}
preferences[preference] = checkbox.checked;
if (ADDITIONAL_PREFERENCES.includes(preference) && checkbox.checked) {
preferences.base = true;
} else if (preference === "base" && !checkbox.checked) {
preferences.usage = false;
preferences.statistics = false;
}
fireEvent(this, "analytics-preferences-changed", { preferences });
}
static get styles(): CSSResult[] {
return [
haStyle,
css`
.error {
color: var(--error-color);
}
ha-settings-row {
padding: 0;
}
span[slot="heading"],
span[slot="description"] {
cursor: pointer;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-analytics": HaAnalytics;
}
}

View File

@@ -127,7 +127,7 @@ export class HaAreaPicker extends SubscribeMixin(LitElement) {
@internalProperty() private _opened?: boolean;
@query("vaadin-combo-box-light", true) private _comboBox!: HTMLElement;
@query("vaadin-combo-box-light", true) public comboBox!: HTMLElement;
private _init = false;
@@ -319,7 +319,7 @@ export class HaAreaPicker extends SubscribeMixin(LitElement) {
(changedProps.has("_opened") && this._opened)
) {
this._init = true;
(this._comboBox as any).items = this._getAreas(
(this.comboBox as any).items = this._getAreas(
this._areas!,
this._devices!,
this._entities!,

View File

@@ -1,4 +1,3 @@
import "@material/mwc-button";
import "@material/mwc-menu";
import type { Corner, Menu } from "@material/mwc-menu";
import {
@@ -11,8 +10,6 @@ import {
query,
TemplateResult,
} from "lit-element";
import "./ha-icon-button";
@customElement("ha-button-menu")
export class HaButtonMenu extends LitElement {
@property() public corner: Corner = "TOP_START";

View File

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

View File

@@ -1,6 +1,5 @@
// @ts-ignore
import chipStyles from "@material/chips/dist/mdc.chips.min.css";
import { ripple } from "@material/mwc-ripple/ripple-directive";
import {
css,
CSSResult,
@@ -12,6 +11,7 @@ import {
unsafeCSS,
} from "lit-element";
import { fireEvent } from "../common/dom/fire_event";
import "./ha-chip";
declare global {
// for fire event
@@ -20,8 +20,8 @@ declare global {
}
}
@customElement("ha-chips")
export class HaChips extends LitElement {
@customElement("ha-chip-set")
export class HaChipSet extends LitElement {
@property() public items = [];
protected render(): TemplateResult {
@@ -33,18 +33,9 @@ export class HaChips extends LitElement {
${this.items.map(
(item, idx) =>
html`
<div class="mdc-chip" .index=${idx} @click=${this._handleClick}>
<div class="mdc-chip__ripple" .ripple="${ripple()}"></div>
<span role="gridcell">
<span
role="button"
tabindex="0"
class="mdc-chip__primary-action"
>
<span class="mdc-chip__text">${item}</span>
</span>
</span>
</div>
<ha-chip .index=${idx} @click=${this._handleClick}>
${item}
</ha-chip>
`
)}
</div>
@@ -60,9 +51,9 @@ export class HaChips extends LitElement {
static get styles(): CSSResult {
return css`
${unsafeCSS(chipStyles)}
.mdc-chip {
background-color: rgba(var(--rgb-primary-text-color), 0.15);
color: var(--primary-text-color);
ha-chip {
margin: 4px;
}
`;
}
@@ -70,6 +61,6 @@ export class HaChips extends LitElement {
declare global {
interface HTMLElementTagNameMap {
"ha-chips": HaChips;
"ha-chip-set": HaChipSet;
}
}

74
src/components/ha-chip.ts Normal file
View File

@@ -0,0 +1,74 @@
// @ts-ignore
import chipStyles from "@material/chips/dist/mdc.chips.min.css";
import { ripple } from "@material/mwc-ripple/ripple-directive";
import "./ha-icon";
import {
css,
CSSResult,
customElement,
html,
LitElement,
property,
TemplateResult,
unsafeCSS,
} from "lit-element";
declare global {
// for fire event
interface HASSDomEvents {
"chip-clicked": { index: string };
}
}
@customElement("ha-chip")
export class HaChip extends LitElement {
@property() public index = 0;
@property({ type: Boolean }) public hasIcon = false;
protected render(): TemplateResult {
return html`
<div class="mdc-chip" .index=${this.index}>
${this.hasIcon
? html`<div class="mdc-chip__icon mdc-chip__icon--leading">
<slot name="icon"></slot>
</div>`
: null}
<div class="mdc-chip__ripple" .ripple="${ripple()}"></div>
<span role="gridcell">
<span role="button" tabindex="0" class="mdc-chip__primary-action">
<span class="mdc-chip__text"><slot></slot></span>
</span>
</span>
</div>
`;
}
static get styles(): CSSResult {
return css`
${unsafeCSS(chipStyles)}
.mdc-chip {
background-color: var(
--ha-chip-background-color,
rgba(var(--rgb-primary-text-color), 0.15)
);
color: var(--ha-chip-text-color, var(--primary-text-color));
}
.mdc-chip:hover {
color: var(--ha-chip-text-color, var(--primary-text-color));
}
.mdc-chip__icon--leading {
--mdc-icon-size: 20px;
color: var(--ha-chip-icon-color, var(--ha-chip-text-color));
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-chip": HaChip;
}
}

View File

@@ -53,14 +53,14 @@ class HaClimateState extends LitElement {
if (this.stateObj.attributes.current_temperature != null) {
return `${formatNumber(
this.stateObj.attributes.current_temperature,
this.hass!.language
this.hass.locale
)} ${this.hass.config.unit_system.temperature}`;
}
if (this.stateObj.attributes.current_humidity != null) {
return `${formatNumber(
this.stateObj.attributes.current_humidity,
this.hass!.language
this.hass.locale
)} %`;
}
@@ -78,17 +78,17 @@ class HaClimateState extends LitElement {
) {
return `${formatNumber(
this.stateObj.attributes.target_temp_low,
this.hass!.language
this.hass.locale
)}-${formatNumber(
this.stateObj.attributes.target_temp_high,
this.hass!.language
this.hass.locale
)} ${this.hass.config.unit_system.temperature}`;
}
if (this.stateObj.attributes.temperature != null) {
return `${formatNumber(
this.stateObj.attributes.temperature,
this.hass!.language
this.hass.locale
)} ${this.hass.config.unit_system.temperature}`;
}
if (
@@ -97,17 +97,17 @@ class HaClimateState extends LitElement {
) {
return `${formatNumber(
this.stateObj.attributes.target_humidity_low,
this.hass!.language
this.hass.locale
)}-${formatNumber(
this.stateObj.attributes.target_humidity_high,
this.hass!.language
this.hass.locale
)} %`;
}
if (this.stateObj.attributes.humidity != null) {
return `${formatNumber(
this.stateObj.attributes.humidity,
this.hass!.language
this.hass.locale
)} %`;
}

View File

@@ -86,6 +86,10 @@ export class HaComboBox extends LitElement {
});
}
public get selectedItem() {
return (this._comboBox as any).selectedItem;
}
protected render(): TemplateResult {
return html`
<vaadin-combo-box-light
@@ -149,9 +153,9 @@ export class HaComboBox extends LitElement {
fireEvent(this, ev.type, ev.detail);
}
private _filterChanged(ev: PolymerChangedEvent<boolean>) {
private _filterChanged(ev: PolymerChangedEvent<string>) {
// @ts-ignore
fireEvent(this, ev.type, ev.detail);
fireEvent(this, ev.type, ev.detail, { composed: false });
}
private _valueChanged(ev: PolymerChangedEvent<string>) {

View File

@@ -1,62 +1,61 @@
import "@vaadin/vaadin-date-picker/theme/material/vaadin-date-picker";
import "@vaadin/vaadin-date-picker/theme/material/vaadin-date-picker-light";
import {
css,
CSSResult,
customElement,
html,
LitElement,
property,
PropertyValues,
query,
} from "lit-element";
import "@polymer/paper-input/paper-input";
import { fireEvent } from "../common/dom/fire_event";
import { mdiCalendar } from "@mdi/js";
import "./ha-svg-icon";
const VaadinDatePicker = customElements.get("vaadin-date-picker");
const documentContainer = document.createElement("template");
documentContainer.setAttribute("style", "display: none;");
documentContainer.innerHTML = `
<dom-module id="ha-date-input-styles" theme-for="vaadin-text-field">
<template>
<style>
[part="input-field"] {
top: 2px;
height: 30px;
color: var(--primary-text-color);
}
[part="value"] {
text-align: center;
}
</style>
</template>
</dom-module>
`;
document.head.appendChild(documentContainer.content);
export class HaDateInput extends VaadinDatePicker {
constructor() {
super();
this.i18n.formatDate = this._formatISODate;
this.i18n.parseDate = this._parseISODate;
}
ready() {
super.ready();
const styleEl = document.createElement("style");
styleEl.innerHTML = `
:host {
width: 12ex;
margin-top: -6px;
--material-body-font-size: 16px;
--_material-text-field-input-line-background-color: var(--primary-text-color);
--_material-text-field-input-line-opacity: 1;
--material-primary-color: var(--primary-text-color);
}
`;
this.shadowRoot.appendChild(styleEl);
this._inputElement.querySelector("[part='toggle-button']").style.display =
"none";
}
private _formatISODate(d) {
const i18n = {
monthNames: [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
],
weekdays: [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
],
weekdaysShort: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"],
firstDayOfWeek: 0,
week: "Week",
calendar: "Calendar",
clear: "Clear",
today: "Today",
cancel: "Cancel",
formatTitle: (monthName, fullYear) => {
return monthName + " " + fullYear;
},
formatDate: (d: { day: number; month: number; year: number }) => {
return [
("0000" + String(d.year)).slice(-4),
("0" + String(d.month + 1)).slice(-2),
("0" + String(d.day)).slice(-2),
].join("-");
}
private _parseISODate(text) {
},
parseDate: (text: string) => {
const parts = text.split("-");
const today = new Date();
let date;
@@ -80,11 +79,75 @@ export class HaDateInput extends VaadinDatePicker {
return { day: date, month, year };
}
return undefined;
},
};
@customElement("ha-date-input")
export class HaDateInput extends LitElement {
@property() public value?: string;
@property({ type: Boolean }) public disabled = false;
@property() public label?: string;
@query("vaadin-date-picker-light", true) private _datePicker;
private _inited = false;
updated(changedProps: PropertyValues) {
if (changedProps.has("value")) {
this._datePicker.value = this.value;
this._inited = true;
}
}
render() {
return html`<vaadin-date-picker-light
.disabled=${this.disabled}
@value-changed=${this._valueChanged}
attr-for-value="value"
.i18n=${i18n}
>
<paper-input .label=${this.label} no-label-float>
<ha-svg-icon slot="suffix" .path=${mdiCalendar}></ha-svg-icon>
</paper-input>
</vaadin-date-picker-light>`;
}
private _valueChanged(ev: CustomEvent) {
if (
!this.value ||
(this._inited && !this._compareStringDates(ev.detail.value, this.value))
) {
this.value = ev.detail.value;
fireEvent(this, "change");
fireEvent(this, "value-changed", { value: ev.detail.value });
}
}
private _compareStringDates(a: string, b: string): boolean {
const aParts = a.split("-");
const bParts = b.split("-");
let i = 0;
for (const aPart of aParts) {
if (Number(aPart) !== Number(bParts[i])) {
return false;
}
i++;
}
return true;
}
static get styles(): CSSResult {
return css`
paper-input {
width: 110px;
}
ha-svg-icon {
color: var(--secondary-text-color);
}
`;
}
}
customElements.define("ha-date-input", HaDateInput as any);
declare global {
interface HTMLElementTagNameMap {
"ha-date-input": HaDateInput;

View File

@@ -43,7 +43,7 @@ export class HaDateRangePicker extends LitElement {
protected updated(changedProps: PropertyValues) {
if (changedProps.has("hass")) {
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (!oldHass || oldHass.language !== this.hass.language) {
if (!oldHass || oldHass.locale !== this.hass.locale) {
this._hour24format = this._compute24hourFormat();
this._rtlDirection = computeRTLDirection(this.hass);
}
@@ -62,7 +62,7 @@ export class HaDateRangePicker extends LitElement {
<div slot="input" class="date-range-inputs">
<ha-svg-icon .path=${mdiCalendar}></ha-svg-icon>
<paper-input
.value=${formatDateTime(this.startDate, this.hass.language)}
.value=${formatDateTime(this.startDate, this.hass.locale)}
.label=${this.hass.localize(
"ui.components.date-range-picker.start_date"
)}
@@ -71,7 +71,7 @@ export class HaDateRangePicker extends LitElement {
readonly
></paper-input>
<paper-input
.value=${formatDateTime(this.endDate, this.hass.language)}
.value=${formatDateTime(this.endDate, this.hass.locale)}
label=${this.hass.localize(
"ui.components.date-range-picker.end_date"
)}

View File

@@ -11,6 +11,7 @@ import { ifDefined } from "lit-html/directives/if-defined";
import { styleMap } from "lit-html/directives/style-map";
import { formatNumber } from "../common/string/format_number";
import { afterNextRender } from "../common/util/render-status";
import { FrontendTranslationData } from "../data/translation";
import { getValueInPercentage, normalize } from "../util/calculate";
const getAngle = (value: number, min: number, max: number) => {
@@ -29,7 +30,7 @@ export class Gauge extends LitElement {
@property({ type: Number }) public value = 0;
@property({ type: String }) public language = "";
@property() public locale!: FrontendTranslationData;
@property() public label = "";
@@ -90,7 +91,7 @@ export class Gauge extends LitElement {
</svg>
<svg class="text">
<text class="value-text">
${formatNumber(this.value, this.language)} ${this.label}
${formatNumber(this.value, this.locale)} ${this.label}
</text>
</svg>`;
}

View File

@@ -1,12 +1,9 @@
import { customElement, html, LitElement, property } from "lit-element";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import { TimeSelector } from "../../data/selector";
import { HomeAssistant } from "../../types";
import "../paper-time-input";
const test = new Date().toLocaleString();
const useAMPM = test.includes("AM") || test.includes("PM");
@customElement("ha-selector-time")
export class HaTimeSelector extends LitElement {
@property() public hass!: HomeAssistant;
@@ -19,16 +16,24 @@ export class HaTimeSelector extends LitElement {
@property({ type: Boolean }) public disabled = false;
private _useAmPm = memoizeOne((language: string) => {
const test = new Date().toLocaleString(language);
return test.includes("AM") || test.includes("PM");
});
protected render() {
const useAMPM = this._useAmPm(this.hass.locale.language);
const parts = this.value?.split(":") || [];
const hours = useAMPM ? parts[0] ?? "12" : parts[0] ?? "0";
const hours = parts[0];
return html`
<paper-time-input
.label=${this.label}
.hour=${useAMPM && Number(hours) > 12 ? Number(hours) - 12 : hours}
.min=${parts[1] ?? "00"}
.sec=${parts[2] ?? "00"}
.hour=${hours &&
(useAMPM && Number(hours) > 12 ? Number(hours) - 12 : hours)}
.min=${parts[1]}
.sec=${parts[2]}
.format=${useAMPM ? 12 : 24}
.amPm=${useAMPM && (Number(hours) > 12 ? "PM" : "AM")}
.disabled=${this.disabled}
@@ -42,12 +47,16 @@ export class HaTimeSelector extends LitElement {
private _timeChanged(ev) {
let value = ev.target.value;
if (useAMPM) {
let hours = Number(ev.target.hour);
const useAMPM = this._useAmPm(this.hass.locale.language);
let hours = Number(ev.target.hour || 0);
if (value && useAMPM) {
if (ev.target.amPm === "PM") {
hours += 12;
}
value = `${hours}:${ev.target.min}:${ev.target.sec}`;
value = `${hours}:${ev.target.min || "00"}:${ev.target.sec || "00"}`;
}
if (value === this.value) {
return;
}
fireEvent(this, "value-changed", {
value,

View File

@@ -335,7 +335,10 @@ export class HaServiceControl extends LitElement {
ev.stopPropagation();
const key = (ev.currentTarget as any).key;
const value = ev.detail.value;
if (this.value?.data && this.value.data[key] === value) {
if (
this.value?.data?.[key] === value ||
(!this.value?.data?.[key] && (value === "" || value === undefined))
) {
return;
}

View File

@@ -245,7 +245,7 @@ class HaSidebar extends LitElement {
hass.panelUrl !== oldHass.panelUrl ||
hass.user !== oldHass.user ||
hass.localize !== oldHass.localize ||
hass.language !== oldHass.language ||
hass.locale !== oldHass.locale ||
hass.states !== oldHass.states ||
hass.defaultPanel !== oldHass.defaultPanel
);
@@ -281,7 +281,7 @@ class HaSidebar extends LitElement {
}
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (!oldHass || oldHass.language !== this.hass.language) {
if (!oldHass || oldHass.locale !== this.hass.locale) {
this.rtl = computeRTL(this.hass);
}

View File

@@ -125,35 +125,41 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
return html``;
}
return html`<div class="mdc-chip-set items">
${ensureArray(this.value?.area_id)?.map((area_id) => {
const area = this._areas![area_id];
return this._renderChip(
"area_id",
area_id,
area?.name || area_id,
undefined,
mdiSofa
);
})}
${ensureArray(this.value?.device_id)?.map((device_id) => {
const device = this._devices![device_id];
return this._renderChip(
"device_id",
device_id,
device ? computeDeviceName(device, this.hass) : device_id,
undefined,
mdiDevices
);
})}
${ensureArray(this.value?.entity_id)?.map((entity_id) => {
const entity = this.hass.states[entity_id];
return this._renderChip(
"entity_id",
entity_id,
entity ? computeStateName(entity) : entity_id,
entity ? stateIcon(entity) : undefined
);
})}
${this.value?.area_id
? ensureArray(this.value.area_id).map((area_id) => {
const area = this._areas![area_id];
return this._renderChip(
"area_id",
area_id,
area?.name || area_id,
undefined,
mdiSofa
);
})
: ""}
${this.value?.device_id
? ensureArray(this.value.device_id).map((device_id) => {
const device = this._devices![device_id];
return this._renderChip(
"device_id",
device_id,
device ? computeDeviceName(device, this.hass) : device_id,
undefined,
mdiDevices
);
})
: ""}
${this.value?.entity_id
? ensureArray(this.value.entity_id).map((entity_id) => {
const entity = this.hass.states[entity_id];
return this._renderChip(
"entity_id",
entity_id,
entity ? computeStateName(entity) : entity_id,
entity ? stateIcon(entity) : undefined
);
})
: ""}
</div>
${this._renderPicker()}
<div class="mdc-chip-set">
@@ -344,6 +350,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
.includeDeviceClasses=${this.includeDeviceClasses}
.includeDomains=${this.includeDomains}
@value-changed=${this._targetPicked}
allow-custom-entity
></ha-entity-picker>`;
}
return html``;

View File

@@ -2,6 +2,7 @@ import { html } from "@polymer/polymer/lib/utils/html-tag";
/* eslint-plugin-disable lit */
import { PolymerElement } from "@polymer/polymer/polymer-element";
import { computeStateDisplay } from "../common/entity/compute_state_display";
import { formatNumber } from "../common/string/format_number";
import LocalizeMixin from "../mixins/localize-mixin";
/*
@@ -55,21 +56,31 @@ class HaWaterHeaterState extends LocalizeMixin(PolymerElement) {
computeTarget(hass, stateObj) {
if (!hass || !stateObj) return null;
// We're using "!= null" on purpose so that we match both null and undefined.
if (
stateObj.attributes.target_temp_low != null &&
stateObj.attributes.target_temp_high != null
) {
return `${stateObj.attributes.target_temp_low} - ${stateObj.attributes.target_temp_high} ${hass.config.unit_system.temperature}`;
return `${formatNumber(
stateObj.attributes.target_temp_low,
this.hass.locale
)} - ${formatNumber(
stateObj.attributes.target_temp_high,
this.hass.locale
)} ${hass.config.unit_system.temperature}`;
}
if (stateObj.attributes.temperature != null) {
return `${stateObj.attributes.temperature} ${hass.config.unit_system.temperature}`;
return `${formatNumber(
stateObj.attributes.temperature,
this.hass.locale
)} ${hass.config.unit_system.temperature}`;
}
return "";
}
_localizeState(stateObj) {
return computeStateDisplay(this.hass.localize, stateObj);
return computeStateDisplay(this.hass.localize, stateObj, this.hass.locale);
}
}
customElements.define("ha-water_heater-state", HaWaterHeaterState);

View File

@@ -133,7 +133,7 @@ export class PaperTimeInput extends PolymerElement {
always-float-label$="[[alwaysFloatInputLabels]]"
disabled="[[disabled]]"
>
<span suffix="" slot="suffix">:</span>
<span suffix slot="suffix">:</span>
</paper-input>
<!-- Min Input -->
@@ -303,28 +303,28 @@ export class PaperTimeInput extends PolymerElement {
notify: true,
},
/**
* Suffix for the hour input
* Label for the hour input
*/
hourLabel: {
type: String,
value: "",
},
/**
* Suffix for the min input
* Label for the min input
*/
minLabel: {
type: String,
value: ":",
value: "",
},
/**
* Suffix for the sec input
* Label for the sec input
*/
secLabel: {
type: String,
value: "",
},
/**
* Suffix for the milli sec input
* Label for the milli sec input
*/
millisecLabel: {
type: String,

View File

@@ -361,7 +361,7 @@ class StateHistoryChartLine extends LocalizeMixin(PolymerElement) {
const item = items[0];
const date = data.datasets[item.datasetIndex].data[item.index].x;
return formatDateTimeWithSeconds(date, this.hass.language);
return formatDateTimeWithSeconds(date, this.hass.locale);
};
const chartOptions = {

View File

@@ -201,8 +201,8 @@ class StateHistoryChartTimeline extends LocalizeMixin(PolymerElement) {
const formatTooltipLabel = (item, data) => {
const values = data.datasets[item.datasetIndex].data[item.index];
const start = formatDateTimeWithSeconds(values[0], this.hass.language);
const end = formatDateTimeWithSeconds(values[1], this.hass.language);
const start = formatDateTimeWithSeconds(values[0], this.hass.locale);
const end = formatDateTimeWithSeconds(values[1], this.hass.locale);
const state = values[2];
return [state, start, end];

View File

@@ -12,9 +12,11 @@ import { buttonLinkStyle } from "../../resources/styles";
import "../ha-svg-icon";
@customElement("ha-timeline")
class HaTimeline extends LitElement {
export class HaTimeline extends LitElement {
@property({ type: Boolean, reflect: true }) public label = false;
@property({ type: Boolean, reflect: true }) public raised = false;
@property({ type: Boolean }) public lastItem = false;
@property({ type: String }) public icon?: string;
@@ -86,6 +88,10 @@ class HaTimeline extends LitElement {
--timeline-ball-color,
var(--timeline-color, var(--secondary-text-color))
);
border-radius: 50%;
}
:host([raised]) ha-svg-icon {
transform: scale(1.3);
}
.line {
flex: 1;

View File

@@ -0,0 +1,166 @@
import { css, customElement, LitElement, property, svg } from "lit-element";
import { NODE_SIZE, SPACING } from "./hat-graph";
@customElement("hat-graph-node")
export class HatGraphNode extends LitElement {
@property() iconPath?: string;
@property({ reflect: true, type: Boolean }) disabled?: boolean;
@property({ reflect: true, type: Boolean }) graphstart?: boolean;
@property({ reflect: true, type: Boolean }) nofocus?: boolean;
@property({ reflect: true, type: Number }) badge?: number;
connectedCallback() {
super.connectedCallback();
if (!this.hasAttribute("tabindex") && !this.nofocus)
this.setAttribute("tabindex", "0");
}
render() {
const height = NODE_SIZE + (this.graphstart ? 2 : SPACING + 1);
const width = SPACING + NODE_SIZE;
return svg`
<svg
width="${width}px"
height="${height}px"
viewBox="-${Math.ceil(width / 2)} -${
this.graphstart
? Math.ceil(height / 2)
: Math.ceil((NODE_SIZE + SPACING * 2) / 2)
} ${width} ${height}"
>
${
this.graphstart
? ``
: svg`
<path
class="connector"
d="
M 0 ${-SPACING - NODE_SIZE / 2}
L 0 0
"
line-caps="round"
/>
`
}
<g class="node">
<circle
cx="0"
cy="0"
r="${NODE_SIZE / 2}"
/>
}
${
this.badge
? svg`
<g class="number">
<circle
cx="8"
cy="${-NODE_SIZE / 2}"
r="8"
></circle>
<text
x="8"
y="${-NODE_SIZE / 2}"
text-anchor="middle"
alignment-baseline="middle"
>${this.badge > 9 ? "9+" : this.badge}</text>
</g>
`
: ""
}
<g
style="pointer-events: none"
transform="translate(${-12} ${-12})"
>
${this.iconPath ? svg`<path class="icon" d="${this.iconPath}"/>` : ""}
</g>
</g>
</svg>
`;
}
static get styles() {
return css`
:host {
display: flex;
flex-direction: column;
}
:host(.track) {
--stroke-clr: var(--track-clr);
--icon-clr: var(--default-icon-clr);
}
:host(.active) circle {
--stroke-clr: var(--active-clr);
--icon-clr: var(--default-icon-clr);
}
:host(:focus) {
outline: none;
}
:host(:hover) circle {
--stroke-clr: var(--hover-clr);
--icon-clr: var(--default-icon-clr);
}
:host([disabled]) circle {
stroke: var(--disabled-clr);
}
:host-context([disabled]) {
--stroke-clr: var(--disabled-clr);
}
:host([nofocus]):host-context(.active),
:host([nofocus]):host-context(:focus) {
--circle-clr: var(--active-clr);
--icon-clr: var(--default-icon-clr);
}
circle,
path.connector {
stroke: var(--stroke-clr);
stroke-width: 2;
fill: none;
}
circle {
fill: var(--background-clr);
stroke: var(--circle-clr, var(--stroke-clr));
}
.number circle {
fill: var(--track-clr);
stroke: none;
stroke-width: 0;
}
.number text {
font-size: smaller;
}
path.icon {
fill: var(--icon-clr);
}
:host(.triggered) svg {
overflow: visible;
}
:host(.triggered) circle {
animation: glow 10s;
}
@keyframes glow {
0% {
filter: drop-shadow(0px 0px 5px rgba(var(--rgb-trigger-color), 0));
}
10% {
filter: drop-shadow(0px 0px 10px rgba(var(--rgb-trigger-color), 1));
}
100% {
filter: drop-shadow(0px 0px 5px rgba(var(--rgb-trigger-color), 0));
}
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"hat-graph-node": HatGraphNode;
}
}

View File

@@ -0,0 +1,55 @@
import { css, customElement, LitElement, property, svg } from "lit-element";
import { NODE_SIZE, SPACING } from "./hat-graph";
@customElement("hat-graph-spacer")
export class HatGraphSpacer extends LitElement {
@property({ reflect: true, type: Boolean }) disabled?: boolean;
render() {
return svg`
<svg
width="${SPACING}px"
height="${SPACING + NODE_SIZE + 1}px"
viewBox="-${SPACING / 2} 0 10 ${SPACING + NODE_SIZE + 1}"
>
<path
class="connector"
d="
M 0 ${SPACING + NODE_SIZE + 1}
L 0 0
"
line-caps="round"
/>
}
</svg>
`;
}
static get styles() {
return css`
:host {
display: flex;
flex-direction: column;
}
:host(.track) {
--stroke-clr: var(--track-clr);
--icon-clr: var(--default-icon-clr);
}
:host-context([disabled]) {
--stroke-clr: var(--disabled-clr);
}
path.connector {
stroke: var(--stroke-clr);
stroke-width: 2;
fill: none;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"hat-graph-spacer": HatGraphSpacer;
}
}

View File

@@ -0,0 +1,225 @@
import {
css,
customElement,
html,
LitElement,
property,
svg,
} from "lit-element";
import { classMap } from "lit-html/directives/class-map";
export const BRANCH_HEIGHT = 20;
export const SPACING = 10;
export const NODE_SIZE = 30;
const track_converter = {
fromAttribute: (value) => value.split(",").map((v) => parseInt(v)),
toAttribute: (value) =>
value instanceof Array ? value.join(",") : `${value}`,
};
export interface NodeInfo {
path: string;
config: any;
}
interface BranchConfig {
x: number;
height: number;
start: boolean;
end: boolean;
}
@customElement("hat-graph")
export class HatGraph extends LitElement {
@property({ type: Number }) _num_items = 0;
@property({ reflect: true, type: Boolean }) branching?: boolean;
@property({ reflect: true, converter: track_converter })
track_start?: number[];
@property({ reflect: true, converter: track_converter }) track_end?: number[];
@property({ reflect: true, type: Boolean }) disabled?: boolean;
@property({ reflect: true, type: Boolean }) selected?: boolean;
@property({ reflect: true, type: Boolean }) short = false;
async updateChildren() {
this._num_items = this.children.length;
}
render() {
const branches: BranchConfig[] = [];
let total_width = 0;
let max_height = 0;
let min_height = Number.POSITIVE_INFINITY;
if (this.branching) {
for (const c of Array.from(this.children)) {
if (c.slot === "head") continue;
const rect = c.getBoundingClientRect();
branches.push({
x: rect.width / 2 + total_width,
height: rect.height,
start: c.getAttribute("graphStart") != null,
end: c.getAttribute("graphEnd") != null,
});
total_width += rect.width;
max_height = Math.max(max_height, rect.height);
min_height = Math.min(min_height, rect.height);
}
}
return html`
<slot name="head" @slotchange=${this.updateChildren}> </slot>
${this.branching
? svg`
<svg
id="top"
width="${total_width}"
height="${BRANCH_HEIGHT}"
>
${branches.map((branch, i) => {
if (branch.start) return "";
return svg`
<path
class="${classMap({
line: true,
track: this.track_start?.includes(i) ?? false,
})}"
id="${this.track_start?.includes(i) ? "track-start" : ""}"
index=${i}
d="
M ${total_width / 2} 0
L ${branch.x} ${BRANCH_HEIGHT}
"/>
`;
})}
<use xlink:href="#track-start" />
</svg>
`
: ""}
<div id="branches">
${this.branching
? svg`
<svg
id="lines"
width="${total_width}"
height="${max_height}"
>
${branches.map((branch, i) => {
if (branch.end) return "";
return svg`
<path
class="${classMap({
line: true,
track: this.track_end?.includes(i) ?? false,
})}"
index=${i}
d="
M ${branch.x} ${branch.height}
l 0 ${max_height - branch.height}
"/>
`;
})}
</svg>
`
: ""}
<slot @slotchange=${this.updateChildren}></slot>
</div>
${this.branching && !this.short
? svg`
<svg
id="bottom"
width="${total_width}"
height="${BRANCH_HEIGHT + SPACING}"
>
${branches.map((branch, i) => {
if (branch.end) return "";
return svg`
<path
class="${classMap({
line: true,
track: this.track_end?.includes(i) ?? false,
})}"
id="${this.track_end?.includes(i) ? "track-end" : ""}"
index=${i}
d="
M ${branch.x} 0
L ${branch.x} ${SPACING}
L ${total_width / 2} ${BRANCH_HEIGHT + SPACING}
"/>
`;
})}
<use xlink:href="#track-end" />
</svg>
`
: ""}
`;
}
static get styles() {
return css`
:host {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
--stroke-clr: var(--stroke-color, var(--secondary-text-color));
--active-clr: var(--active-color, var(--primary-color));
--track-clr: var(--track-color, var(--accent-color));
--hover-clr: var(--hover-color, var(--primary-color));
--disabled-clr: var(--disabled-color, var(--disabled-text-color));
--default-trigger-color: 3, 169, 244;
--rgb-trigger-color: var(--trigger-color, var(--default-trigger-color));
--background-clr: var(--background-color, white);
--default-icon-clr: var(--icon-color, black);
--icon-clr: var(--stroke-clr);
}
:host(:focus) {
outline: none;
}
#branches {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
}
:host([branching]) #branches {
flex-direction: row;
align-items: start;
}
:host([branching]) ::slotted(*) {
z-index: 1;
}
:host([branching]) ::slotted([slot="head"]) {
margin-bottom: ${-BRANCH_HEIGHT / 2}px;
}
#lines {
position: absolute;
}
path.line {
stroke: var(--stroke-clr);
stroke-width: 2;
fill: none;
}
path.line.track {
stroke: var(--track-clr);
}
:host([disabled]) path.line {
stroke: var(--disabled-clr);
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"hat-graph": HatGraph;
}
}

View File

@@ -0,0 +1,26 @@
import { LitElement, css, html, customElement } from "lit-element";
@customElement("hat-logbook-note")
class HatLogbookNote extends LitElement {
render() {
return html`
Not all shown logbook entries might be related to this automation.
`;
}
static styles = css`
:host {
display: block;
text-align: center;
font-style: italic;
padding: 16px;
margin-top: 8px;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"hat-logbook-note": HatLogbookNote;
}
}

View File

@@ -0,0 +1,573 @@
import {
html,
LitElement,
property,
customElement,
PropertyValues,
css,
} from "lit-element";
import "@material/mwc-icon-button/mwc-icon-button";
import { fireEvent } from "../../common/dom/fire_event";
import "../ha-svg-icon";
import {
AutomationTraceExtended,
ChooseActionTraceStep,
ConditionTraceStep,
} from "../../data/trace";
import {
mdiAbTesting,
mdiArrowUp,
mdiAsterisk,
mdiCallSplit,
mdiCheckboxBlankOutline,
mdiCheckBoxOutline,
mdiChevronDown,
mdiChevronRight,
mdiChevronUp,
mdiClose,
mdiCodeBrackets,
mdiDevices,
mdiExclamation,
mdiRefresh,
mdiTimerOutline,
mdiTrafficLight,
} from "@mdi/js";
import "./hat-graph-node";
import { classMap } from "lit-html/directives/class-map";
import { NODE_SIZE, SPACING, NodeInfo } from "./hat-graph";
import { Condition, Trigger } from "../../data/automation";
import {
Action,
ChooseAction,
DelayAction,
DeviceAction,
EventAction,
RepeatAction,
SceneAction,
ServiceAction,
WaitAction,
WaitForTriggerAction,
} from "../../data/script";
import { ensureArray } from "../../common/ensure-array";
import "./hat-graph-spacer";
declare global {
interface HASSDomEvents {
"graph-node-selected": NodeInfo;
}
}
@customElement("hat-script-graph")
class HatScriptGraph extends LitElement {
@property({ attribute: false }) public trace!: AutomationTraceExtended;
@property({ attribute: false }) public selected;
@property() trackedNodes: Record<string, any> = {};
private selectNode(config, path) {
return () => {
fireEvent(this, "graph-node-selected", { config, path });
};
}
private render_trigger(config: Trigger, i: number) {
const path = `trigger/${i}`;
const tracked = this.trace && path in this.trace.trace;
if (tracked) {
this.trackedNodes[path] = { config, path };
}
return html`
<hat-graph-node
graphStart
@focus=${this.selectNode(config, path)}
class=${classMap({
track: tracked,
active: this.selected === path,
})}
.iconPath=${mdiAsterisk}
tabindex=${tracked ? "0" : "-1"}
></hat-graph-node>
`;
}
private render_condition(config: Condition, i: number) {
const path = `condition/${i}`;
const trace = this.trace.trace[path] as ConditionTraceStep[] | undefined;
const track_path =
trace?.[0].result === undefined ? 0 : trace[0].result.result ? 1 : 2;
if (trace) {
this.trackedNodes[path] = { config, path };
}
return html`
<hat-graph
branching
@focus=${this.selectNode(config, path)}
class=${classMap({
track: track_path,
active: this.selected === path,
})}
.track_start=${[track_path]}
.track_end=${[track_path]}
tabindex=${trace ? "-1" : "0"}
short
>
<hat-graph-node
slot="head"
class=${classMap({
track: trace !== undefined,
})}
.iconPath=${mdiAbTesting}
nofocus
graphEnd
></hat-graph-node>
<div
style=${`width: ${NODE_SIZE + SPACING}px;`}
graphStart
graphEnd
></div>
<div></div>
<hat-graph-node
.iconPath=${mdiClose}
graphEnd
nofocus
class=${classMap({
track: track_path === 2,
})}
></hat-graph-node>
</hat-graph>
`;
}
private render_choose_node(config: ChooseAction, path: string) {
const trace = this.trace.trace[path] as ChooseActionTraceStep[] | undefined;
const trace_path = trace?.[0].result
? trace[0].result.choice === "default"
? [config.choose?.length || 0]
: [trace[0].result.choice]
: [];
return html`
<hat-graph
tabindex=${trace === undefined ? "-1" : "0"}
branching
.track_start=${trace_path}
.track_end=${trace_path}
@focus=${this.selectNode(config, path)}
class=${classMap({
track: trace !== undefined,
active: this.selected === path,
})}
>
<hat-graph-node
.iconPath=${mdiCallSplit}
class=${classMap({
track: trace !== undefined,
})}
slot="head"
nofocus
></hat-graph-node>
${config.choose?.map((branch, i) => {
const branch_path = `${path}/choose/${i}`;
const track_this =
trace !== undefined && trace[0].result?.choice === i;
if (track_this) {
this.trackedNodes[branch_path] = { config, path: branch_path };
}
return html`
<hat-graph>
<hat-graph-node
.iconPath=${!trace || track_this
? mdiCheckBoxOutline
: mdiCheckboxBlankOutline}
@focus=${this.selectNode(config, branch_path)}
class=${classMap({
active: this.selected === branch_path,
track: track_this,
})}
></hat-graph-node>
${ensureArray(branch.sequence).map((action, j) =>
this.render_node(action, `${branch_path}/sequence/${j}`)
)}
</hat-graph>
`;
})}
<hat-graph>
<hat-graph-spacer
class=${classMap({
track:
trace !== undefined && trace[0].result?.choice === "default",
})}
></hat-graph-spacer>
${ensureArray(config.default)?.map((action, i) =>
this.render_node(action, `${path}/default/${i}`)
)}
</hat-graph>
</hat-graph>
`;
}
private render_condition_node(node: Condition, path: string) {
const trace = (this.trace.trace[path] as ConditionTraceStep[]) || undefined;
const track_path =
trace?.[0].result === undefined ? 0 : trace[0].result.result ? 1 : 2;
return html`
<hat-graph
branching
@focus=${this.selectNode(node, path)}
class=${classMap({
track: track_path,
active: this.selected === path,
})}
.track_start=${[track_path]}
.track_end=${[track_path]}
tabindex=${trace === undefined ? "-1" : "0"}
short
>
<hat-graph-node
slot="head"
class=${classMap({
track: Boolean(trace),
})}
.iconPath=${mdiAbTesting}
nofocus
graphEnd
></hat-graph-node>
<div
style=${`width: ${NODE_SIZE + SPACING}px;`}
graphStart
graphEnd
></div>
<div></div>
<hat-graph-node
.iconPath=${mdiClose}
graphEnd
nofocus
class=${classMap({
track: track_path === 2,
})}
></hat-graph-node>
</hat-graph>
`;
}
private render_delay_node(node: DelayAction, path: string) {
return html`
<hat-graph-node
.iconPath=${mdiTimerOutline}
@focus=${this.selectNode(node, path)}
class=${classMap({
track: path in this.trace.trace,
active: this.selected === path,
})}
tabindex=${this.trace && path in this.trace.trace ? "0" : "-1"}
></hat-graph-node>
`;
}
private render_device_node(node: DeviceAction, path: string) {
return html`
<hat-graph-node
.iconPath=${mdiDevices}
@focus=${this.selectNode(node, path)}
class=${classMap({
track: path in this.trace.trace,
active: this.selected === path,
})}
tabindex=${this.trace && path in this.trace.trace ? "0" : "-1"}
></hat-graph-node>
`;
}
private render_event_node(node: EventAction, path: string) {
return html`
<hat-graph-node
.iconPath=${mdiExclamation}
@focus=${this.selectNode(node, path)}
class=${classMap({
track: path in this.trace.trace,
active: this.selected === path,
})}
tabindex=${this.trace && path in this.trace.trace ? "0" : "-1"}
></hat-graph-node>
`;
}
private render_repeat_node(node: RepeatAction, path: string) {
const trace: any = this.trace.trace[path];
const track_path = trace ? [0, 1] : [];
const repeats = this.trace?.trace[`${path}/repeat/sequence/0`]?.length;
return html`
<hat-graph
.track_start=${track_path}
.track_end=${track_path}
tabindex=${trace === undefined ? "-1" : "0"}
branching
@focus=${this.selectNode(node, path)}
class=${classMap({
track: path in this.trace.trace,
active: this.selected === path,
})}
>
<hat-graph-node
.iconPath=${mdiRefresh}
class=${classMap({
track: trace,
})}
slot="head"
nofocus
></hat-graph-node>
<hat-graph-node
.iconPath=${mdiArrowUp}
nofocus
class=${classMap({
track: track_path.includes(1),
})}
.badge=${repeats}
></hat-graph-node>
<hat-graph>
${ensureArray(node.repeat.sequence).map((action, i) =>
this.render_node(action, `${path}/repeat/sequence/${i}`)
)}
</hat-graph>
</hat-graph>
`;
}
private render_scene_node(node: SceneAction, path: string) {
return html`
<hat-graph-node
.iconPath=${mdiExclamation}
@focus=${this.selectNode(node, path)}
class=${classMap({
track: path in this.trace.trace,
active: this.selected === path,
})}
tabindex=${this.trace && path in this.trace.trace ? "0" : "-1"}
></hat-graph-node>
`;
}
private render_service_node(node: ServiceAction, path: string) {
return html`
<hat-graph-node
.iconPath=${mdiChevronRight}
@focus=${this.selectNode(node, path)}
class=${classMap({
track: path in this.trace.trace,
active: this.selected === path,
})}
tabindex=${this.trace && path in this.trace.trace ? "0" : "-1"}
></hat-graph-node>
`;
}
private render_wait_node(
node: WaitAction | WaitForTriggerAction,
path: string
) {
return html`
<hat-graph-node
.iconPath=${mdiTrafficLight}
@focus=${this.selectNode(node, path)}
class=${classMap({
track: path in this.trace.trace,
active: this.selected === path,
})}
tabindex=${this.trace && path in this.trace.trace ? "0" : "-1"}
></hat-graph-node>
`;
}
private render_other_node(node: Action, path: string) {
return html`
<hat-graph-node
.iconPath=${mdiCodeBrackets}
@focus=${this.selectNode(node, path)}
class=${classMap({
track: path in this.trace.trace,
active: this.selected === path,
})}
></hat-graph-node>
`;
}
private render_node(node: Action, path: string) {
const NODE_TYPES = {
choose: this.render_choose_node,
condition: this.render_condition_node,
delay: this.render_delay_node,
device_id: this.render_device_node,
event: this.render_event_node,
repeat: this.render_repeat_node,
scene: this.render_scene_node,
service: this.render_service_node,
wait_template: this.render_wait_node,
wait_for_trigger: this.render_wait_node,
other: this.render_other_node,
};
const type = Object.keys(NODE_TYPES).find((key) => key in node) || "other";
const nodeEl = NODE_TYPES[type].bind(this)(node, path);
if (this.trace && path in this.trace.trace) {
this.trackedNodes[path] = { config: node, path };
}
return nodeEl;
}
protected render() {
const paths = Object.keys(this.trackedNodes);
const manual_triggered = this.trace && "trigger" in this.trace.trace;
let track_path = manual_triggered ? undefined : [0];
const trigger_nodes = ensureArray(this.trace.config.trigger).map(
(trigger, i) => {
if (this.trace && `trigger/${i}` in this.trace.trace) {
track_path = [i];
}
return this.render_trigger(trigger, i);
}
);
try {
return html`
<hat-graph class="parent">
<div></div>
<hat-graph
branching
id="trigger"
.short=${trigger_nodes.length < 2}
.track_start=${track_path}
.track_end=${track_path}
>
${trigger_nodes}
</hat-graph>
<hat-graph id="condition">
${ensureArray(this.trace.config.condition)?.map((condition, i) =>
this.render_condition(condition!, i)
)}
</hat-graph>
${ensureArray(this.trace.config.action).map((action, i) =>
this.render_node(action, `action/${i}`)
)}
</hat-graph>
<div class="actions">
<mwc-icon-button
.disabled=${paths.length === 0 || paths[0] === this.selected}
@click=${this.previousTrackedNode}
>
<ha-svg-icon .path=${mdiChevronUp}></ha-svg-icon>
</mwc-icon-button>
<mwc-icon-button
.disabled=${paths.length === 0 ||
paths[paths.length - 1] === this.selected}
@click=${this.nextTrackedNode}
>
<ha-svg-icon .path=${mdiChevronDown}></ha-svg-icon>
</mwc-icon-button>
</div>
`;
} catch (err) {
if (__DEV__) {
// eslint-disable-next-line no-console
console.log("Error creating script graph:", err);
}
return html`
<div class="error">
Error rendering graph. Please download trace and share with the
developers.
</div>
`;
}
}
protected update(changedProps: PropertyValues<this>) {
if (changedProps.has("trace")) {
this.trackedNodes = {};
}
super.update(changedProps);
}
protected updated(changedProps: PropertyValues<this>) {
super.updated(changedProps);
// Select first node if new trace loaded but no selection given.
if (changedProps.has("trace")) {
const tracked = this.getTrackedNodes();
const paths = Object.keys(tracked);
// If trace changed and we have no or an invalid selection, select first option.
if (this.selected === "" || !(this.selected in paths)) {
// Find first tracked node with node info
for (const path of paths) {
if (tracked[path]) {
fireEvent(this, "graph-node-selected", tracked[path]);
break;
}
}
}
if (this.trace) {
const sortKeys = Object.keys(this.trace.trace);
const keys = Object.keys(this.trackedNodes).sort(
(a, b) => sortKeys.indexOf(a) - sortKeys.indexOf(b)
);
const sortedTrackedNodes = keys.reduce((obj, key) => {
obj[key] = this.trackedNodes[key];
return obj;
}, {});
this.trackedNodes = sortedTrackedNodes;
}
}
}
public getTrackedNodes() {
return this.trackedNodes;
}
public previousTrackedNode() {
const tracked = this.getTrackedNodes();
const nodes = Object.keys(tracked);
for (let i = nodes.indexOf(this.selected) - 1; i >= 0; i--) {
if (tracked[nodes[i]]) {
fireEvent(this, "graph-node-selected", tracked[nodes[i]]);
break;
}
}
}
public nextTrackedNode() {
const tracked = this.getTrackedNodes();
const nodes = Object.keys(tracked);
for (let i = nodes.indexOf(this.selected) + 1; i < nodes.length; i++) {
if (tracked[nodes[i]]) {
fireEvent(this, "graph-node-selected", tracked[nodes[i]]);
break;
}
}
}
static get styles() {
return css`
:host {
display: flex;
}
.actions {
display: flex;
flex-direction: column;
}
.parent {
margin-left: 8px;
}
.error {
padding: 16px;
max-width: 300px;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"hat-script-graph": HatScriptGraph;
}
}

View File

@@ -0,0 +1,598 @@
import {
css,
CSSResult,
customElement,
html,
LitElement,
property,
PropertyValues,
TemplateResult,
} from "lit-element";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import {
AutomationTraceExtended,
ChooseActionTraceStep,
getDataFromPath,
TriggerTraceStep,
isTriggerPath,
} from "../../data/trace";
import { HomeAssistant } from "../../types";
import "./ha-timeline";
import type { HaTimeline } from "./ha-timeline";
import {
mdiAlertCircle,
mdiCircle,
mdiCircleOutline,
mdiProgressClock,
mdiProgressWrench,
mdiRecordCircleOutline,
} from "@mdi/js";
import { LogbookEntry } from "../../data/logbook";
import {
ChooseAction,
ChooseActionChoice,
getActionType,
} from "../../data/script";
import relativeTime from "../../common/datetime/relative_time";
import { fireEvent } from "../../common/dom/fire_event";
import { describeAction } from "../../data/script_i18n";
import { ifDefined } from "lit-html/directives/if-defined";
const LOGBOOK_ENTRIES_BEFORE_FOLD = 2;
/* eslint max-classes-per-file: "off" */
// Report time entry when more than this time has passed
const SIGNIFICANT_TIME_CHANGE = 1000; // 1 seconds
const isSignificantTimeChange = (a: Date, b: Date) =>
Math.abs(b.getTime() - a.getTime()) > SIGNIFICANT_TIME_CHANGE;
class RenderedTimeTracker {
private lastReportedTime: Date;
constructor(
private hass: HomeAssistant,
private entries: TemplateResult[],
trace: AutomationTraceExtended
) {
this.lastReportedTime = new Date(trace.timestamp.start);
}
setLastReportedTime(date: Date) {
this.lastReportedTime = date;
}
renderTime(from: Date, to: Date): void {
this.entries.push(html`
<ha-timeline label>
${relativeTime(from, this.hass.localize, {
compareTime: to,
includeTense: false,
})}
later
</ha-timeline>
`);
this.lastReportedTime = to;
}
maybeRenderTime(timestamp: Date): boolean {
if (!isSignificantTimeChange(timestamp, this.lastReportedTime)) {
this.lastReportedTime = timestamp;
return false;
}
this.renderTime(this.lastReportedTime, timestamp);
return true;
}
}
class LogbookRenderer {
private curIndex: number;
private pendingItems: Array<[Date, LogbookEntry]> = [];
constructor(
private entries: TemplateResult[],
private timeTracker: RenderedTimeTracker,
private logbookEntries: LogbookEntry[]
) {
// Skip the "automation got triggered item"
this.curIndex =
logbookEntries.length > 0 && logbookEntries[0].domain === "automation"
? 1
: 0;
}
get curItem() {
return this.logbookEntries[this.curIndex];
}
get hasNext() {
return this.curIndex !== this.logbookEntries.length;
}
maybeRenderItem() {
const logbookEntry = this.curItem;
this.curIndex++;
const entryDate = new Date(logbookEntry.when);
if (this.pendingItems.length === 0) {
this.pendingItems.push([entryDate, logbookEntry]);
return;
}
const previousEntryDate = this.pendingItems[
this.pendingItems.length - 1
][0];
// If logbook entry is too long after the last one,
// add a time passed label
if (isSignificantTimeChange(previousEntryDate, entryDate)) {
this._renderLogbookEntries();
this.timeTracker.renderTime(previousEntryDate, entryDate);
}
this.pendingItems.push([entryDate, logbookEntry]);
}
flush() {
if (this.pendingItems.length > 0) {
this._renderLogbookEntries();
}
}
private _renderLogbookEntries() {
this.timeTracker.maybeRenderTime(this.pendingItems[0][0]);
const parts: TemplateResult[] = [];
let i;
for (
i = 0;
i < Math.min(this.pendingItems.length, LOGBOOK_ENTRIES_BEFORE_FOLD);
i++
) {
parts.push(this._renderLogbookEntryHelper(this.pendingItems[i][1]));
}
let moreItems: TemplateResult[] | undefined;
// If we didn't render all items, push rest into `moreItems`
if (i < this.pendingItems.length) {
moreItems = [];
for (; i < this.pendingItems.length; i++) {
moreItems.push(this._renderLogbookEntryHelper(this.pendingItems[i][1]));
}
}
this.entries.push(html`
<ha-timeline .icon=${mdiCircleOutline} .moreItems=${moreItems}>
${parts}
</ha-timeline>
`);
// Clear rendered items.
this.timeTracker.setLastReportedTime(
this.pendingItems[this.pendingItems.length - 1][0]
);
this.pendingItems = [];
}
private _renderLogbookEntryHelper(entry: LogbookEntry) {
return html`${entry.name} (${entry.entity_id})
${entry.message || `turned ${entry.state}`}<br />`;
}
}
class ActionRenderer {
private curIndex = 0;
private keys: string[];
constructor(
private hass: HomeAssistant,
private entries: TemplateResult[],
private trace: AutomationTraceExtended,
private logbookRenderer: LogbookRenderer,
private timeTracker: RenderedTimeTracker
) {
this.keys = Object.keys(trace.trace);
}
get curItem() {
return this._getItem(this.curIndex);
}
get hasNext() {
return this.curIndex !== this.keys.length;
}
renderItem() {
this.curIndex = this._renderItem(this.curIndex);
}
private _getItem(index: number) {
return this.trace.trace[this.keys[index]];
}
private _renderItem(
index: number,
actionType?: ReturnType<typeof getActionType>
): number {
const value = this._getItem(index);
if (isTriggerPath(value[0].path)) {
return this._handleTrigger(index, value[0] as TriggerTraceStep);
}
const timestamp = new Date(value[0].timestamp);
// Render all logbook items that are in front of this item.
while (
this.logbookRenderer.hasNext &&
new Date(this.logbookRenderer.curItem.when) < timestamp
) {
this.logbookRenderer.maybeRenderItem();
}
this.logbookRenderer.flush();
this.timeTracker.maybeRenderTime(timestamp);
const path = value[0].path;
let data;
try {
data = getDataFromPath(this.trace.config, path);
} catch (err) {
this.entries.push(
html`Unable to extract path ${path}. Download trace and report as bug`
);
return index + 1;
}
const isTopLevel = path.split("/").length === 2;
if (!isTopLevel && !actionType) {
this._renderEntry(path, path.replace(/\//g, " "));
return index + 1;
}
if (!actionType) {
actionType = getActionType(data);
}
if (actionType === "choose") {
return this._handleChoose(index);
}
this._renderEntry(path, describeAction(this.hass, data, actionType));
return index + 1;
}
private _handleTrigger(index: number, triggerStep: TriggerTraceStep): number {
this._renderEntry(
triggerStep.path,
`Triggered ${
triggerStep.path === "trigger"
? "manually"
: `by the ${this.trace.trigger}`
} at
${formatDateTimeWithSeconds(
new Date(triggerStep.timestamp),
this.hass.locale
)}`,
mdiCircle
);
return index + 1;
}
private _handleChoose(index: number): number {
// startLevel: choose root config
// +1: 'default
// +2: executed sequence
// +1: 'choose'
// +2: current choice
// +3: 'conditions'
// +4: evaluated condition
// +3: 'sequence'
// +4: executed sequence
const choosePath = this.keys[index];
const startLevel = choosePath.split("/").length - 1;
const chooseTrace = this._getItem(index)[0] as ChooseActionTraceStep;
const defaultExecuted = chooseTrace.result?.choice === "default";
const chooseConfig = this._getDataFromPath(
this.keys[index]
) as ChooseAction;
const name = chooseConfig.alias || "Choose";
if (defaultExecuted) {
this._renderEntry(choosePath, `${name}: Default action executed`);
} else {
const choiceConfig = this._getDataFromPath(
`${this.keys[index]}/choose/${chooseTrace.result?.choice}`
) as ChooseActionChoice | undefined;
const choiceName = choiceConfig
? `${
choiceConfig.alias || `Choice ${chooseTrace.result?.choice}`
} executed`
: `Error: ${chooseTrace.error}`;
this._renderEntry(choosePath, `${name}: ${choiceName}`);
}
let i;
// Skip over conditions
for (i = index + 1; i < this.keys.length; i++) {
const parts = this.keys[i].split("/");
// We're done if no more sequence in current level
if (parts.length <= startLevel) {
return i;
}
// We're going to skip all conditions
if (
(defaultExecuted && parts[startLevel + 1] === "default") ||
(!defaultExecuted && parts[startLevel + 3] === "sequence")
) {
break;
}
}
// Render choice
while (i < this.keys.length) {
const path = this.keys[i];
const parts = path.split("/");
// We're done if no more sequence in current level
if (parts.length <= startLevel) {
return i;
}
// We know it's an action sequence, so force the type like that
// for rendering.
i = this._renderItem(i, getActionType(this._getDataFromPath(path)));
}
return i;
}
private _renderEntry(
path: string,
description: string,
icon = mdiRecordCircleOutline
) {
this.entries.push(html`
<ha-timeline .icon=${icon} data-path=${path}>
${description}
</ha-timeline>
`);
}
private _getDataFromPath(path: string) {
return getDataFromPath(this.trace.config, path);
}
}
@customElement("hat-trace-timeline")
export class HaAutomationTracer extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public trace?: AutomationTraceExtended;
@property({ attribute: false }) public logbookEntries?: LogbookEntry[];
@property({ attribute: false }) public selectedPath?: string;
@property({ type: Boolean }) public allowPick = false;
protected render(): TemplateResult {
if (!this.trace) {
return html``;
}
const entries: TemplateResult[] = [];
const timeTracker = new RenderedTimeTracker(this.hass, entries, this.trace);
const logbookRenderer = new LogbookRenderer(
entries,
timeTracker,
this.logbookEntries || []
);
const actionRenderer = new ActionRenderer(
this.hass,
entries,
this.trace,
logbookRenderer,
timeTracker
);
while (actionRenderer.hasNext) {
actionRenderer.renderItem();
}
while (logbookRenderer.hasNext) {
logbookRenderer.maybeRenderItem();
}
logbookRenderer.flush();
// Render footer
const renderFinishedAt = () =>
formatDateTimeWithSeconds(
new Date(this.trace!.timestamp.finish!),
this.hass.locale
);
const renderRuntime = () => `(runtime:
${(
(new Date(this.trace!.timestamp.finish!).getTime() -
new Date(this.trace!.timestamp.start).getTime()) /
1000
).toFixed(2)}
seconds)`;
let entry: {
description: TemplateResult | string;
icon: string;
className?: string;
};
if (this.trace.state === "running") {
entry = {
description: "Still running",
icon: mdiProgressClock,
};
} else if (this.trace.state === "debugged") {
entry = {
description: "Debugged",
icon: mdiProgressWrench,
};
} else if (this.trace.script_execution === "finished") {
entry = {
description: `Finished at ${renderFinishedAt()} ${renderRuntime()}`,
icon: mdiCircle,
};
} else if (this.trace.script_execution === "aborted") {
entry = {
description: `Aborted at ${renderFinishedAt()} ${renderRuntime()}`,
icon: mdiAlertCircle,
};
} else if (this.trace.script_execution === "cancelled") {
entry = {
description: `Cancelled at ${renderFinishedAt()} ${renderRuntime()}`,
icon: mdiAlertCircle,
};
} else {
let reason: string;
let isError = false;
let extra: TemplateResult | undefined;
switch (this.trace.script_execution) {
case "failed_conditions":
reason = "a condition failed";
break;
case "failed_single":
reason = "only a single execution is allowed";
break;
case "failed_max_runs":
reason = "maximum number of parallel runs reached";
break;
case "error":
reason = "an error was encountered";
isError = true;
extra = html`<br /><br />${this.trace.error!}`;
break;
default:
reason = `of unknown reason "${this.trace.script_execution}"`;
isError = true;
}
entry = {
description: html`Stopped because ${reason} at ${renderFinishedAt()}
${renderRuntime()}${extra || ""}`,
icon: mdiAlertCircle,
className: isError ? "error" : undefined,
};
}
// null means it was stopped by a condition
if (entry) {
entries.push(html`
<ha-timeline
lastItem
.icon=${entry.icon}
class=${ifDefined(entry.className)}
>
${entry.description}
</ha-timeline>
`);
}
return html`${entries}`;
}
protected updated(props: PropertyValues) {
super.updated(props);
// Pick first path when we load a new trace.
if (
this.allowPick &&
props.has("trace") &&
this.trace &&
this.selectedPath &&
!(this.selectedPath in this.trace.trace)
) {
const element = this.shadowRoot!.querySelector<HaTimeline>(
"ha-timeline[data-path]"
);
if (element) {
fireEvent(this, "value-changed", { value: element.dataset.path });
this.selectedPath = element.dataset.path;
}
}
if (props.has("trace") || props.has("selectedPath")) {
this.shadowRoot!.querySelectorAll<HaTimeline>(
"ha-timeline[data-path]"
).forEach((el) => {
el.toggleAttribute("selected", this.selectedPath === el.dataset.path);
if (!this.allowPick || el.tabIndex === 0) {
return;
}
el.tabIndex = 0;
const selectEl = () => {
this.selectedPath = el.dataset.path;
fireEvent(this, "value-changed", { value: el.dataset.path });
};
el.addEventListener("click", selectEl);
el.addEventListener("keydown", (ev: KeyboardEvent) => {
if (ev.key === "Enter" || ev.key === " ") {
selectEl();
}
});
el.addEventListener("mouseover", () => {
el.raised = true;
});
el.addEventListener("mouseout", () => {
el.raised = false;
});
});
}
}
static get styles(): CSSResult[] {
return [
css`
ha-timeline[lastItem].condition {
--timeline-ball-color: var(--error-color);
}
ha-timeline[data-path] {
cursor: pointer;
}
ha-timeline[selected] {
--timeline-ball-color: var(--primary-color);
}
ha-timeline:focus {
outline: none;
--timeline-ball-color: var(--accent-color);
}
.error {
--timeline-ball-color: var(--error-color);
color: var(--error-color);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"hat-trace-timeline": HaAutomationTracer;
}
}

View File

@@ -1,450 +0,0 @@
import {
css,
CSSResult,
customElement,
html,
LitElement,
property,
TemplateResult,
} from "lit-element";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import {
AutomationTraceExtended,
ChooseActionTrace,
getDataFromPath,
} from "../../data/trace";
import { HomeAssistant } from "../../types";
import "./ha-timeline";
import {
mdiCheckCircleOutline,
mdiCircle,
mdiCircleOutline,
mdiPauseCircleOutline,
mdiRecordCircleOutline,
mdiStopCircleOutline,
} from "@mdi/js";
import { LogbookEntry } from "../../data/logbook";
import { getActionType } from "../../data/script";
import relativeTime from "../../common/datetime/relative_time";
const LOGBOOK_ENTRIES_BEFORE_FOLD = 2;
const pathToName = (path: string) => path.split("/").join(" ");
/* eslint max-classes-per-file: "off" */
// Report time entry when more than this time has passed
const SIGNIFICANT_TIME_CHANGE = 5000; // 5 seconds
const isSignificantTimeChange = (a: Date, b: Date) =>
Math.abs(b.getTime() - a.getTime()) > SIGNIFICANT_TIME_CHANGE;
class RenderedTimeTracker {
private lastReportedTime: Date;
constructor(
private hass: HomeAssistant,
private entries: TemplateResult[],
trace: AutomationTraceExtended
) {
this.lastReportedTime = new Date(trace.timestamp.start);
}
setLastReportedTime(date: Date) {
this.lastReportedTime = date;
}
renderTime(from: Date, to: Date): void {
this.entries.push(html`
<ha-timeline label>
${relativeTime(from, this.hass.localize, {
compareTime: to,
includeTense: false,
})}
later
</ha-timeline>
`);
this.lastReportedTime = to;
}
maybeRenderTime(timestamp: Date): boolean {
if (!isSignificantTimeChange(timestamp, this.lastReportedTime)) {
this.lastReportedTime = timestamp;
return false;
}
this.renderTime(this.lastReportedTime, timestamp);
return true;
}
}
class LogbookRenderer {
private curIndex: number;
private pendingItems: Array<[Date, LogbookEntry]> = [];
constructor(
private entries: TemplateResult[],
private timeTracker: RenderedTimeTracker,
private logbookEntries: LogbookEntry[]
) {
// Skip the "automation got triggered item"
this.curIndex =
logbookEntries.length > 0 && logbookEntries[0].domain === "automation"
? 1
: 0;
}
get curItem() {
return this.logbookEntries[this.curIndex];
}
get hasNext() {
return this.curIndex !== this.logbookEntries.length;
}
maybeRenderItem() {
const logbookEntry = this.curItem;
this.curIndex++;
const entryDate = new Date(logbookEntry.when);
if (this.pendingItems.length === 0) {
this.pendingItems.push([entryDate, logbookEntry]);
return;
}
const previousEntryDate = this.pendingItems[
this.pendingItems.length - 1
][0];
// If logbook entry is too long after the last one,
// add a time passed label
if (isSignificantTimeChange(previousEntryDate, entryDate)) {
this._renderLogbookEntries();
this.timeTracker.renderTime(previousEntryDate, entryDate);
}
this.pendingItems.push([entryDate, logbookEntry]);
}
flush() {
if (this.pendingItems.length > 0) {
this._renderLogbookEntries();
}
}
private _renderLogbookEntries() {
this.timeTracker.maybeRenderTime(this.pendingItems[0][0]);
const parts: TemplateResult[] = [];
let i;
for (
i = 0;
i < Math.min(this.pendingItems.length, LOGBOOK_ENTRIES_BEFORE_FOLD);
i++
) {
parts.push(this._renderLogbookEntryHelper(this.pendingItems[i][1]));
}
let moreItems: TemplateResult[] | undefined;
// If we didn't render all items, push rest into `moreItems`
if (i < this.pendingItems.length) {
moreItems = [];
for (; i < this.pendingItems.length; i++) {
moreItems.push(this._renderLogbookEntryHelper(this.pendingItems[i][1]));
}
}
this.entries.push(html`
<ha-timeline .icon=${mdiCircleOutline} .moreItems=${moreItems}>
${parts}
</ha-timeline>
`);
// Clear rendered items.
this.timeTracker.setLastReportedTime(
this.pendingItems[this.pendingItems.length - 1][0]
);
this.pendingItems = [];
}
private _renderLogbookEntryHelper(entry: LogbookEntry) {
return html`${entry.name} (${entry.entity_id}) turned ${entry.state}<br />`;
}
}
class ActionRenderer {
private curIndex = 0;
private keys: string[];
constructor(
private entries: TemplateResult[],
private trace: AutomationTraceExtended,
private timeTracker: RenderedTimeTracker
) {
this.keys = Object.keys(trace.action_trace);
}
get curItem() {
return this._getItem(this.curIndex);
}
get hasNext() {
return this.curIndex !== this.keys.length;
}
renderItem() {
this.curIndex = this._renderItem(this.curIndex);
}
private _getItem(index: number) {
return this.trace.action_trace[this.keys[index]];
}
private _renderItem(
index: number,
actionType?: ReturnType<typeof getActionType>
): number {
const value = this._getItem(index);
const timestamp = new Date(value[0].timestamp);
this.timeTracker.maybeRenderTime(timestamp);
const path = value[0].path;
let data;
try {
data = getDataFromPath(this.trace.config, path);
} catch (err) {
this.entries.push(
html`Unable to extract path ${path}. Download trace and report as bug`
);
return index + 1;
}
const isTopLevel = path.split("/").length === 2;
if (!isTopLevel && !actionType) {
this._renderEntry(path.replace(/\//g, " "));
return index + 1;
}
if (!actionType) {
actionType = getActionType(data);
}
if (actionType === "choose") {
return this._handleChoose(index);
}
this._renderEntry(data.alias || actionType);
return index + 1;
}
private _handleChoose(index: number): number {
// startLevel: choose root config
// +1: 'default
// +2: executed sequence
// +1: 'choose'
// +2: current choice
// +3: 'conditions'
// +4: evaluated condition
// +3: 'sequence'
// +4: executed sequence
const startLevel = this.keys[index].split("/").length - 1;
const chooseTrace = this._getItem(index)[0] as ChooseActionTrace;
const defaultExecuted = chooseTrace.result.choice === "default";
if (defaultExecuted) {
this._renderEntry(`Choose: Default action executed`);
} else {
this._renderEntry(`Choose: Choice ${chooseTrace.result.choice} executed`);
}
let i;
// Skip over conditions
for (i = index + 1; i < this.keys.length; i++) {
const parts = this.keys[i].split("/");
// We're done if no more sequence in current level
if (parts.length <= startLevel) {
return i;
}
// We're going to skip all conditions
if (parts[startLevel + 3] === "sequence") {
break;
}
}
// Render choice
while (i < this.keys.length) {
const path = this.keys[i];
const parts = path.split("/");
// We're done if no more sequence in current level
if (parts.length <= startLevel) {
return i;
}
// We know it's an action sequence, so force the type like that
// for rendering.
i = this._renderItem(i, getActionType(this._getDataFromPath(path)));
}
return i;
}
private _renderEntry(description: string) {
this.entries.push(html`
<ha-timeline .icon=${mdiRecordCircleOutline}>
${description}
</ha-timeline>
`);
}
private _getDataFromPath(path: string) {
return getDataFromPath(this.trace.config, path);
}
}
@customElement("hat-trace")
export class HaAutomationTracer extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) private trace?: AutomationTraceExtended;
@property({ attribute: false }) private logbookEntries?: LogbookEntry[];
protected render(): TemplateResult {
if (!this.trace) {
return html``;
}
const entries = [
html`
<ha-timeline .icon=${mdiCircle}>
Triggered by the ${this.trace.variables.trigger.description} at
${formatDateTimeWithSeconds(
new Date(this.trace.timestamp.start),
this.hass.language
)}
</ha-timeline>
`,
];
if (this.trace.condition_trace) {
for (const [path, value] of Object.entries(this.trace.condition_trace)) {
entries.push(html`
<ha-timeline
?lastItem=${!value[0].result.result}
class="condition"
.icon=${value[0].result.result
? mdiCheckCircleOutline
: mdiStopCircleOutline}
>
${getDataFromPath(this.trace!.config, path).alias ||
pathToName(path)}
${value[0].result.result ? "passed" : "failed"}
</ha-timeline>
`);
}
}
if (this.trace.action_trace && this.logbookEntries) {
const timeTracker = new RenderedTimeTracker(
this.hass,
entries,
this.trace
);
const logbookRenderer = new LogbookRenderer(
entries,
timeTracker,
this.logbookEntries
);
const actionRenderer = new ActionRenderer(
entries,
this.trace,
timeTracker
);
while (logbookRenderer.hasNext && actionRenderer.hasNext) {
// Find next item time-wise.
const logbookItem = logbookRenderer.curItem;
const actionTrace = actionRenderer.curItem;
const actionTimestamp = new Date(actionTrace[0].timestamp);
if (new Date(logbookItem.when) > actionTimestamp) {
logbookRenderer.flush();
actionRenderer.renderItem();
} else {
logbookRenderer.maybeRenderItem();
}
}
while (logbookRenderer.hasNext) {
logbookRenderer.maybeRenderItem();
}
logbookRenderer.flush();
while (actionRenderer.hasNext) {
actionRenderer.renderItem();
}
}
// null means it was stopped by a condition
if (this.trace.last_action !== null) {
entries.push(html`
<ha-timeline
lastItem
.icon=${this.trace.timestamp.finish
? mdiCircle
: mdiPauseCircleOutline}
>
${this.trace.timestamp.finish
? html`Finished at
${formatDateTimeWithSeconds(
new Date(this.trace.timestamp.finish),
this.hass.language
)}
(runtime:
${(
(new Date(this.trace.timestamp.finish!).getTime() -
new Date(this.trace.timestamp.start).getTime()) /
1000
).toFixed(2)}
seconds)`
: "Still running"}
</ha-timeline>
`);
}
return html`${entries}`;
}
static get styles(): CSSResult[] {
return [
css`
ha-timeline[lastItem].condition {
--timeline-ball-color: var(--error-color);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"hat-trace": HaAutomationTracer;
}
}

26
src/data/analytics.ts Normal file
View File

@@ -0,0 +1,26 @@
import { HomeAssistant } from "../types";
export interface AnalyticsPreferences {
base?: boolean;
diagnostics?: boolean;
usage?: boolean;
statistics?: boolean;
}
export interface Analytics {
preferences: AnalyticsPreferences;
}
export const getAnalyticsDetails = (hass: HomeAssistant) =>
hass.callWS<Analytics>({
type: "analytics",
});
export const setAnalyticsPreferences = (
hass: HomeAssistant,
preferences: AnalyticsPreferences
) =>
hass.callWS<AnalyticsPreferences>({
type: "analytics/preferences",
preferences,
});

View File

@@ -23,9 +23,9 @@ export interface ManualAutomationConfig {
id?: string;
alias?: string;
description?: string;
trigger: Trigger[];
condition?: Condition[];
action: Action[];
trigger: Trigger | Trigger[];
condition?: Condition | Condition[];
action: Action | Action[];
mode?: typeof MODES[number];
max?: number;
max_exceeded?:
@@ -161,7 +161,7 @@ export type Trigger =
export interface LogicalCondition {
condition: "and" | "not" | "or";
alias?: string;
conditions: Condition[];
conditions: Condition | Condition[];
}
export interface StateCondition {
@@ -238,6 +238,9 @@ export const deleteAutomation = (hass: HomeAssistant, id: string) =>
let inititialAutomationEditorData: Partial<AutomationConfig> | undefined;
export const getAutomationConfig = (hass: HomeAssistant, id: string) =>
hass.callApi<AutomationConfig>("GET", `config/automation/config/${id}`);
export const showAutomationEditor = (
el: HTMLElement,
data?: Partial<AutomationConfig>

View File

@@ -0,0 +1,15 @@
import { Trigger, Condition } from "./automation";
export const describeTrigger = (trigger: Trigger) => {
return `${trigger.platform} trigger`;
};
export const describeCondition = (condition: Condition) => {
if (condition.alias) {
return condition.alias;
}
if (condition.condition === "template") {
return "Test a template";
}
return `${condition.condition} condition`;
};

View File

@@ -0,0 +1,16 @@
import { HomeAssistant } from "../types";
export type BootstrapIntegrationsTimings = { [key: string]: number };
export const subscribeBootstrapIntegrations = (
hass: HomeAssistant,
callback: (message: BootstrapIntegrationsTimings) => void
) => {
const unsubProm = hass.connection.subscribeMessage<
BootstrapIntegrationsTimings
>((message) => callback(message), {
type: "subscribe_bootstrap_integrations",
});
return unsubProm;
};

View File

@@ -54,7 +54,7 @@ export const getRecent = (
}
const prom = fetchRecent(hass, entityId, startTime, endTime).then(
(stateHistory) => computeHistory(hass, stateHistory, localize, language),
(stateHistory) => computeHistory(hass, stateHistory, localize),
(err) => {
delete RECENT_CACHE[entityId];
throw err;
@@ -140,12 +140,7 @@ export const getRecentWithCache = (
delete stateHistoryCache[cacheKey];
throw err;
}
const stateHistory = computeHistory(
hass,
fetchedHistory,
localize,
language
);
const stateHistory = computeHistory(hass, fetchedHistory, localize);
if (appendingToCache) {
mergeLine(stateHistory.line, cache.data.line);
mergeTimeline(stateHistory.timeline, cache.data.timeline);

View File

@@ -46,6 +46,7 @@ export interface CloudPreferences {
export type CloudStatusLoggedIn = CloudStatusBase & {
email: string;
google_registered: boolean;
google_entities: EntityFilter;
google_domains: string[];
alexa_entities: EntityFilter;

View File

@@ -5,11 +5,17 @@ export interface ConfigEntry {
domain: string;
title: string;
source: string;
state: string;
state:
| "loaded"
| "setup_error"
| "migration_error"
| "setup_retry"
| "not_loaded"
| "failed_unload";
connection_class: string;
supports_options: boolean;
supports_unload: boolean;
disabled_by: string | null;
disabled_by: "user" | null;
}
export interface ConfigEntryMutableParams {

View File

@@ -1,4 +1,5 @@
import { HaFormSchema } from "../components/ha-form/ha-form";
import { ConfigEntry } from "./config_entries";
export interface DataEntryFlowProgressedEvent {
type: "data_entry_flow_progressed";
@@ -44,8 +45,7 @@ export interface DataEntryFlowStepCreateEntry {
flow_id: string;
handler: string;
title: string;
// Config entry ID
result: string;
result?: ConfigEntry;
description: string;
description_placeholders: Record<string, string>;
}

View File

@@ -9,13 +9,13 @@ export interface DeviceRegistryEntry {
config_entries: string[];
connections: Array<[string, string]>;
identifiers: Array<[string, string]>;
manufacturer: string;
model?: string;
name?: string;
sw_version?: string;
via_device_id?: string;
area_id?: string;
name_by_user?: string;
manufacturer: string | null;
model: string | null;
name: string | null;
sw_version: string | null;
via_device_id: string | null;
area_id: string | null;
name_by_user: string | null;
entry_type: "service" | null;
disabled_by: string | null;
}

View File

@@ -5,12 +5,12 @@ import { HomeAssistant } from "../types";
export interface EntityRegistryEntry {
entity_id: string;
name: string;
icon?: string;
name: string | null;
icon: string | null;
platform: string;
config_entry_id?: string;
device_id?: string;
area_id?: string;
config_entry_id: string | null;
device_id: string | null;
area_id: string | null;
disabled_by: string | null;
}

View File

@@ -104,7 +104,7 @@ export const configSyncOS = async (hass: HomeAssistant) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return await hass.callWS({
type: "supervisor/api",
endpoint: "os/config/sync",
endpoint: "/os/config/sync",
method: "post",
timeout: null,
});

View File

@@ -4,6 +4,7 @@ import { computeStateDomain } from "../common/entity/compute_state_domain";
import { computeStateName } from "../common/entity/compute_state_name";
import { LocalizeFunc } from "../common/translations/localize";
import { HomeAssistant } from "../types";
import { FrontendTranslationData } from "./translation";
const DOMAINS_USE_LAST_UPDATED = ["climate", "humidifier", "water_heater"];
const LINE_ATTRIBUTES_TO_KEEP = [
@@ -109,7 +110,7 @@ const equalState = (obj1: LineChartState, obj2: LineChartState) =>
const processTimelineEntity = (
localize: LocalizeFunc,
language: string,
language: FrontendTranslationData,
states: HassEntity[]
): TimelineEntity => {
const data: TimelineState[] = [];
@@ -203,8 +204,7 @@ const processLineChartEntities = (
export const computeHistory = (
hass: HomeAssistant,
stateHistory: HassEntity[][],
localize: LocalizeFunc,
language: string
localize: LocalizeFunc
): HistoryResult => {
const lineChartDevices: { [unit: string]: HassEntity[][] } = {};
const timelineDevices: TimelineEntity[] = [];
@@ -235,7 +235,7 @@ export const computeHistory = (
if (!unit) {
timelineDevices.push(
processTimelineEntity(localize, language, stateInfo)
processTimelineEntity(localize, hass.locale, stateInfo)
);
} else if (unit in lineChartDevices) {
lineChartDevices[unit].push(stateInfo);

View File

@@ -15,7 +15,13 @@ export interface IntegrationManifest {
ssdp?: Array<{ manufacturer?: string; modelName?: string; st?: string }>;
zeroconf?: string[];
homekit?: { models: string[] };
quality_scale?: string;
quality_scale?: "gold" | "internal" | "platinum" | "silver";
iot_class:
| "assumed_state"
| "cloud_polling"
| "cloud_push"
| "local_polling"
| "local_push";
}
export const integrationIssuesUrl = (
@@ -25,8 +31,11 @@ export const integrationIssuesUrl = (
manifest.issue_tracker ||
`https://github.com/home-assistant/home-assistant/issues?q=is%3Aissue+is%3Aopen+label%3A%22integration%3A+${domain}%22`;
export const domainToName = (localize: LocalizeFunc, domain: string) =>
localize(`component.${domain}.title`) || domain;
export const domainToName = (
localize: LocalizeFunc,
domain: string,
manifest?: IntegrationManifest
) => localize(`component.${domain}.title`) || manifest?.name || domain;
export const fetchIntegrationManifests = (hass: HomeAssistant) =>
hass.callWS<IntegrationManifest[]>({ type: "manifest/list" });

View File

@@ -35,14 +35,17 @@ export const getLogbookDataForContext = async (
hass: HomeAssistant,
startDate: string,
contextId?: string
) =>
getLogbookDataFromServer(
): Promise<LogbookEntry[]> =>
addLogbookMessage(
hass,
startDate,
undefined,
undefined,
undefined,
contextId
await getLogbookDataFromServer(
hass,
startDate,
undefined,
undefined,
undefined,
contextId
)
);
export const getLogbookData = async (
@@ -51,15 +54,22 @@ export const getLogbookData = async (
endDate: string,
entityId?: string,
entity_matches_only?: boolean
) => {
const logbookData = await getLogbookDataCache(
): Promise<LogbookEntry[]> =>
addLogbookMessage(
hass,
startDate,
endDate,
entityId,
entity_matches_only
await getLogbookDataCache(
hass,
startDate,
endDate,
entityId,
entity_matches_only
)
);
export const addLogbookMessage = (
hass: HomeAssistant,
logbookData: LogbookEntry[]
): LogbookEntry[] => {
for (const entry of logbookData) {
const stateObj = hass!.states[entry.entity_id!];
if (entry.state && stateObj) {
@@ -71,7 +81,6 @@ export const getLogbookData = async (
);
}
}
return logbookData;
};
@@ -314,7 +323,7 @@ export const getLogbookMessage = (
`${LOGBOOK_LOCALIZE_PATH}.changed_to_state`,
"state",
stateObj
? computeStateDisplay(hass.localize, stateObj, hass.language, state)
? computeStateDisplay(hass.localize, stateObj, hass.locale, state)
: state
);
};

View File

@@ -109,7 +109,7 @@ export interface LovelaceBadgeConfig {
export interface LovelaceCardConfig {
index?: number;
view_index?: number;
layout?: any;
view_layout?: any;
type: string;
[key: string]: any;
}

View File

@@ -292,9 +292,11 @@ export const computeMediaControls = (
? "hass:pause"
: "hass:stop",
action:
state === "playing" && !supportsFeature(stateObj, SUPPORT_PAUSE)
? "media_stop"
: "media_play_pause",
state !== "playing"
? "media_play"
: supportsFeature(stateObj, SUPPORT_PAUSE)
? "media_pause"
: "media_stop",
});
}

View File

@@ -12,10 +12,14 @@ export interface OnboardingIntegrationStepResponse {
auth_code: string;
}
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface OnboardingAnalyticsStepResponse {}
export interface OnboardingResponses {
user: OnboardingUserStepResponse;
core_config: OnboardingCoreConfigStepResponse;
integration: OnboardingIntegrationStepResponse;
analytics: OnboardingAnalyticsStepResponse;
}
export type ValidOnboardingStep = keyof OnboardingResponses;
@@ -49,6 +53,9 @@ export const onboardCoreConfigStep = (hass: HomeAssistant) =>
"onboarding/core_config"
);
export const onboardAnalyticsStep = (hass: HomeAssistant) =>
hass.callApi<OnboardingAnalyticsStepResponse>("POST", "onboarding/analytics");
export const onboardIntegrationStep = (
hass: HomeAssistant,
params: { client_id: string; redirect_uri: string }

View File

@@ -22,7 +22,7 @@ export interface ScriptEntity extends HassEntityBase {
export interface ScriptConfig {
alias: string;
sequence: Action[];
sequence: Action | Action[];
icon?: string;
mode?: typeof MODES[number];
max?: number;
@@ -37,7 +37,8 @@ export interface EventAction {
export interface ServiceAction {
alias?: string;
service: string;
service?: string;
service_template?: string;
entity_id?: string;
target?: HassServiceTarget;
data?: Record<string, any>;
@@ -76,7 +77,7 @@ export interface WaitAction {
export interface WaitForTriggerAction {
alias?: string;
wait_for_trigger: Trigger[];
wait_for_trigger: Trigger | Trigger[];
timeout?: number;
continue_on_timeout?: boolean;
}
@@ -87,7 +88,8 @@ export interface RepeatAction {
}
interface BaseRepeat {
sequence: Action[];
alias?: string;
sequence: Action | Action[];
}
export interface CountRepeat extends BaseRepeat {
@@ -102,15 +104,26 @@ export interface UntilRepeat extends BaseRepeat {
until: Condition[];
}
export interface ChooseActionChoice {
alias?: string;
conditions: string | Condition[];
sequence: Action | Action[];
}
export interface ChooseAction {
choose: [
{
alias?: string;
conditions: string | Condition[];
sequence: Action[];
}
];
default?: Action[];
alias?: string;
choose: ChooseActionChoice[] | null;
default?: Action | Action[];
}
export interface VariablesAction {
alias?: string;
variables: Record<string, unknown>;
}
interface UnknownAction {
alias?: string;
[key: string]: unknown;
}
export type Action =
@@ -123,7 +136,26 @@ export type Action =
| WaitAction
| WaitForTriggerAction
| RepeatAction
| ChooseAction;
| ChooseAction
| VariablesAction
| UnknownAction;
export interface ActionTypes {
delay: DelayAction;
wait_template: WaitAction;
check_condition: Condition;
fire_event: EventAction;
device_action: DeviceAction;
activate_scene: SceneAction;
repeat: RepeatAction;
choose: ChooseAction;
wait_for_trigger: WaitForTriggerAction;
variables: VariablesAction;
service: ServiceAction;
unknown: UnknownAction;
}
export type ActionType = keyof ActionTypes;
export const triggerScript = (
hass: HomeAssistant,
@@ -164,7 +196,7 @@ export const getScriptEditorInitData = () => {
return data;
};
export const getActionType = (action: Action) => {
export const getActionType = (action: Action): ActionType => {
// Check based on config_validation.py#determine_script_action
if ("delay" in action) {
return "delay";

142
src/data/script_i18n.ts Normal file
View File

@@ -0,0 +1,142 @@
import secondsToDuration from "../common/datetime/seconds_to_duration";
import { ensureArray } from "../common/ensure-array";
import { computeStateName } from "../common/entity/compute_state_name";
import { isTemplate } from "../common/string/has-template";
import { HomeAssistant } from "../types";
import { Condition } from "./automation";
import { describeCondition, describeTrigger } from "./automation_i18n";
import {
ActionType,
getActionType,
DelayAction,
SceneAction,
WaitForTriggerAction,
ActionTypes,
VariablesAction,
EventAction,
} from "./script";
export const describeAction = <T extends ActionType>(
hass: HomeAssistant,
action: ActionTypes[T],
actionType?: T
): string => {
if (action.alias) {
return action.alias;
}
if (!actionType) {
actionType = getActionType(action) as T;
}
if (actionType === "service") {
const config = action as ActionTypes["service"];
let base: string | undefined;
if (
config.service_template ||
(config.service && isTemplate(config.service))
) {
base = "Call a service based on a template";
} else if (config.service) {
base = `Call service ${config.service}`;
} else {
return actionType;
}
if (config.target) {
const targets: string[] = [];
for (const [key, label] of Object.entries({
area_id: "areas",
device_id: "devices",
entity_id: "entities",
})) {
if (!(key in config.target)) {
continue;
}
const keyConf: string[] = Array.isArray(config.target[key])
? config.target[key]
: [config.target[key]];
const values: string[] = [];
let renderValues = true;
for (const targetThing of keyConf) {
if (isTemplate(targetThing)) {
targets.push(`templated ${label}`);
renderValues = false;
break;
} else {
values.push(targetThing);
}
}
if (renderValues) {
targets.push(`${label} ${values.join(", ")}`);
}
}
if (targets.length > 0) {
base += ` on ${targets.join(", ")}`;
}
}
return base;
}
if (actionType === "delay") {
const config = action as DelayAction;
let duration: string;
if (typeof config.delay === "number") {
duration = `for ${secondsToDuration(config.delay)!}`;
} else if (typeof config.delay === "string") {
duration = isTemplate(config.delay)
? "based on a template"
: `for ${config.delay}`;
} else {
duration = `for ${JSON.stringify(config.delay)}`;
}
return `Delay ${duration}`;
}
if (actionType === "activate_scene") {
const config = action as SceneAction;
const sceneStateObj = hass.states[config.scene];
return `Activate scene ${
sceneStateObj ? computeStateName(sceneStateObj) : config.scene
}`;
}
if (actionType === "wait_for_trigger") {
const config = action as WaitForTriggerAction;
return `Wait for ${ensureArray(config.wait_for_trigger)
.map((trigger) => describeTrigger(trigger))
.join(", ")}`;
}
if (actionType === "variables") {
const config = action as VariablesAction;
return `Define variables ${Object.keys(config.variables).join(", ")}`;
}
if (actionType === "fire_event") {
const config = action as EventAction;
if (isTemplate(config.event)) {
return "Fire event based on a template";
}
return `Fire event ${config.event}`;
}
if (actionType === "wait_template") {
return "Wait for a template to render true";
}
if (actionType === "check_condition") {
return `Test ${describeCondition(action as Condition)}`;
}
return actionType;
};

View File

@@ -16,9 +16,27 @@ export interface LoggedError {
export const fetchSystemLog = (hass: HomeAssistant) =>
hass.callApi<LoggedError[]>("GET", "error/all");
export const getLoggedErrorIntegration = (item: LoggedError) =>
item.name.startsWith("homeassistant.components.")
? item.name.split(".")[2]
: item.name.startsWith("custom_components.")
? item.name.split(".")[1]
: undefined;
export const getLoggedErrorIntegration = (item: LoggedError) => {
// Try to derive from logger name
if (item.name.startsWith("homeassistant.components.")) {
return item.name.split(".")[2];
}
if (item.name.startsWith("custom_components.")) {
return item.name.split(".")[1];
}
// Try to derive from logged location
if (item.source[0].startsWith("custom_components/")) {
return item.source[0].split("/")[1];
}
if (item.source[0].startsWith("homeassistant/components/")) {
return item.source[0].split("/")[2];
}
return undefined;
};
export const isCustomIntegrationError = (item: LoggedError) =>
item.name.startsWith("custom_components.") ||
item.source[0].startsWith("custom_components/");

View File

@@ -1,65 +1,96 @@
import { strStartsWith } from "../common/string/starts-with";
import { HomeAssistant, Context } from "../types";
import { AutomationConfig } from "./automation";
import {
BlueprintAutomationConfig,
ManualAutomationConfig,
} from "./automation";
interface TraceVariables extends Record<string, unknown> {
trigger: {
description: string;
interface BaseTraceStep {
path: string;
timestamp: string;
error?: string;
changed_variables?: Record<string, unknown>;
}
export interface TriggerTraceStep extends BaseTraceStep {
changed_variables: {
trigger: {
description: string;
[key: string]: unknown;
};
[key: string]: unknown;
};
}
interface BaseTrace {
path: string;
timestamp: string;
changed_variables?: Record<string, unknown>;
export interface ConditionTraceStep extends BaseTraceStep {
result?: { result: boolean };
}
export interface ConditionTrace extends BaseTrace {
result: { result: boolean };
}
export interface CallServiceActionTrace extends BaseTrace {
result: {
export interface CallServiceActionTraceStep extends BaseTraceStep {
result?: {
limit: number;
running_script: boolean;
params: Record<string, unknown>;
};
child_id?: {
domain: string;
item_id: string;
run_id: string;
};
}
export interface ChooseActionTrace extends BaseTrace {
result: { choice: number | "default" };
export interface ChooseActionTraceStep extends BaseTraceStep {
result?: { choice: number | "default" };
}
export interface ChooseChoiceActionTrace extends BaseTrace {
result: { result: boolean };
export interface ChooseChoiceActionTraceStep extends BaseTraceStep {
result?: { result: boolean };
}
export type ActionTrace =
| BaseTrace
| CallServiceActionTrace
| ChooseActionTrace
| ChooseChoiceActionTrace;
export type ActionTraceStep =
| BaseTraceStep
| ConditionTraceStep
| CallServiceActionTraceStep
| ChooseActionTraceStep
| ChooseChoiceActionTraceStep;
export interface AutomationTrace {
domain: string;
item_id: string;
last_action: string | null;
last_condition: string | null;
last_step: string | null;
run_id: string;
state: "running" | "stopped" | "debugged";
timestamp: {
start: string;
finish: string | null;
};
trigger: unknown;
script_execution:
| // The script was not executed because the automation's condition failed
"failed_conditions"
// The script was not executed because the run mode is single
| "failed_single"
// The script was not executed because max parallel runs would be exceeded
| "failed_max_runs"
// All script steps finished:
| "finished"
// Script execution stopped by the script itself because a condition fails, wait_for_trigger timeouts etc:
| "aborted"
// Details about failing condition, timeout etc. is in the last element of the trace
// Script execution stops because of an unexpected exception:
| "error"
// The exception is in the trace itself or in the last element of the trace
// Script execution stopped by async_stop called on the script run because home assistant is shutting down, script mode is SCRIPT_MODE_RESTART etc:
| "cancelled";
// Automation only, should become it's own type when we support script in frontend
trigger: string;
}
export interface AutomationTraceExtended extends AutomationTrace {
condition_trace: Record<string, ConditionTrace[]>;
action_trace: Record<string, ActionTrace[]>;
trace: Record<string, ActionTraceStep[]>;
context: Context;
variables: TraceVariables;
config: AutomationConfig;
config: ManualAutomationConfig;
blueprint_inputs?: BlueprintAutomationConfig;
error?: string;
}
interface TraceTypes {
@@ -110,7 +141,7 @@ export const loadTraceContexts = (
});
export const getDataFromPath = (
config: AutomationConfig,
config: ManualAutomationConfig,
path: string
): any => {
const parts = path.split("/").reverse();
@@ -138,3 +169,11 @@ export const getDataFromPath = (
return result;
};
// It is 'trigger' if manually triggered by the user via UI
export const isTriggerPath = (path: string): boolean =>
path === "trigger" || strStartsWith(path, "trigger/");
export const getTriggerPathFromTrace = (
steps: Record<string, BaseTraceStep[]>
): string | undefined => Object.keys(steps).find((path) => isTriggerPath(path));

View File

@@ -1,8 +1,18 @@
import { HomeAssistant } from "../types";
import { fetchFrontendUserData, saveFrontendUserData } from "./frontend";
export enum NumberFormat {
language = "language",
system = "system",
comma_decimal = "comma_decimal",
decimal_comma = "decimal_comma",
space_comma = "space_comma",
none = "none",
}
export interface FrontendTranslationData {
language: string;
number_format: NumberFormat;
}
declare global {

View File

@@ -133,7 +133,7 @@ export const getWind = (
speed: string,
bearing: string
): string => {
const speedText = `${formatNumber(speed, hass!.language)} ${getWeatherUnit(
const speedText = `${formatNumber(speed, hass.locale)} ${getWeatherUnit(
hass!,
"wind_speed"
)}`;
@@ -206,7 +206,7 @@ export const getSecondaryWeatherAttribute = (
<ha-svg-icon class="attr-icon" .path=${weatherAttrIcon}></ha-svg-icon>
`
: hass!.localize(`ui.card.weather.attributes.${attribute}`)}
${formatNumber(value, hass!.language, { maximumFractionDigits: 1 })}
${formatNumber(value, hass.locale, { maximumFractionDigits: 1 })}
${getWeatherUnit(hass!, attribute)}
`;
};

View File

@@ -82,12 +82,17 @@ export interface ZHAGroupMember {
export const reconfigureNode = (
hass: HomeAssistant,
ieeeAddress: string
): Promise<void> =>
hass.callWS({
type: "zha/devices/reconfigure",
ieee: ieeeAddress,
});
ieeeAddress: string,
callbackFunction: any
) => {
return hass.connection.subscribeMessage(
(message) => callbackFunction(message),
{
type: "zha/devices/reconfigure",
ieee: ieeeAddress,
}
);
};
export const refreshTopology = (hass: HomeAssistant): Promise<void> =>
hass.callWS({

View File

@@ -314,8 +314,8 @@ class DataEntryFlowDialog extends LitElement {
this._step &&
this._step.type === "create_entry"
) {
if (this._params!.flowConfig.loadDevicesAndAreas) {
this._fetchDevices(this._step.result);
if (this._step.result && this._params!.flowConfig.loadDevicesAndAreas) {
this._fetchDevices(this._step.result.entry_id);
this._fetchAreas();
} else {
this._devices = [];

View File

@@ -43,6 +43,13 @@ class StepFlowCreateEntry extends LitElement {
<h2>Success!</h2>
<div class="content">
${this.flowConfig.renderCreateEntryDescription(this.hass, this.step)}
${this.step.result?.state === "not_loaded"
? html`<span class="error"
>${localize(
"ui.panel.config.integrations.config_flow.not_loaded"
)}</span
>`
: ""}
${this.devices.length === 0
? ""
: html`
@@ -136,6 +143,9 @@ class StepFlowCreateEntry extends LitElement {
width: 100%;
}
}
.error {
color: var(--error-color);
}
`,
];
}

View File

@@ -36,11 +36,11 @@ class DialogBox extends LitElement {
public closeDialog(): boolean {
if (this._params?.confirmation || this._params?.prompt) {
this._dismiss();
return true;
return false;
}
if (this._params) {
return false;
this._dismiss();
return true;
}
return true;
}
@@ -140,7 +140,7 @@ class DialogBox extends LitElement {
}
private _dialogClosed(ev) {
if (this._params?.prompt && ev.detail.action === "ignore") {
if (ev.detail.action === "ignore") {
return;
}
this._dismiss();

View File

@@ -45,7 +45,8 @@ export const showDialog = async (
root: ShadowRoot | HTMLElement,
dialogTag: string,
dialogParams: unknown,
dialogImport?: () => Promise<unknown>
dialogImport?: () => Promise<unknown>,
addHistory = true
) => {
if (!(dialogTag in LOADED)) {
if (!dialogImport) {
@@ -59,30 +60,31 @@ export const showDialog = async (
});
}
history.replaceState(
{
dialog: dialogTag,
open: false,
oldState:
history.state?.open && history.state?.dialog !== dialogTag
? history.state
: null,
},
""
);
try {
history.pushState(
{ dialog: dialogTag, dialogParams: dialogParams, open: true },
""
);
} catch (err) {
// dialogParams could not be cloned, probably contains callback
history.pushState(
{ dialog: dialogTag, dialogParams: null, open: true },
if (addHistory) {
history.replaceState(
{
dialog: dialogTag,
open: false,
oldState:
history.state?.open && history.state?.dialog !== dialogTag
? history.state
: null,
},
""
);
try {
history.pushState(
{ dialog: dialogTag, dialogParams: dialogParams, open: true },
""
);
} catch (err) {
// dialogParams could not be cloned, probably contains callback
history.pushState(
{ dialog: dialogTag, dialogParams: null, open: true },
""
);
}
}
const dialogElement = await LOADED[dialogTag];
dialogElement.showDialog(dialogParams);
};

View File

@@ -16,7 +16,6 @@ class DatetimeInput extends PolymerElement {
<div>
<ha-date-input
id="dateInput"
on-value-changed="dateTimeChanged"
label="Date"
value="{{selectedDate}}"
></ha-date-input>

View File

@@ -151,7 +151,7 @@ class MoreInfoLight extends LitElement {
: ""}
<ha-attributes
.stateObj=${this.stateObj}
extra-filters="brightness,color_temp,white_value,effect_list,effect,hs_color,rgb_color,xy_color,min_mireds,max_mireds,entity_id"
extra-filters="brightness,color_temp,white_value,effect_list,effect,hs_color,rgb_color,xy_color,min_mireds,max_mireds,entity_id,supported_color_modes,color_mode"
></ha-attributes>
</div>
`;

View File

@@ -50,7 +50,7 @@ class MoreInfoSun extends LitElement {
<div class="value">
${formatTime(
item === "ris" ? risingDate : settingDate,
this.hass.language
this.hass.locale
)}
</div>
</div>
@@ -61,10 +61,7 @@ class MoreInfoSun extends LitElement {
${this.hass.localize("ui.dialogs.more_info_control.sun.elevation")}
</div>
<div class="value">
${formatNumber(
this.stateObj.attributes.elevation,
this.hass!.language
)}
${formatNumber(this.stateObj.attributes.elevation, this.hass.locale)}
</div>
</div>
`;

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