Compare commits

..

100 Commits

Author SHA1 Message Date
Paulus Schoutsen
355440c0fa Hide default image if unavailable 2020-03-08 10:10:00 -07:00
Paulus Schoutsen
e7d89d0f9a Style unavailable/unknown cards 2020-03-08 10:02:40 -07:00
Paulus Schoutsen
814fcf63a8 Fix cardHeight (#5102)
* Fix cardHeight

* Opacity instead of changing DOM

* Better fix

* Remove guard we don't need
2020-03-08 09:42:59 -07:00
Zack Arnett
b72d8cf7d7 Media Card: Fix Progress update (#5096) 2020-03-07 22:00:52 -08:00
Zack Arnett
8e7ef58715 Card Picker: Firefox Fix (#5098) 2020-03-07 21:59:25 -08:00
Paulus Schoutsen
56bfa01c56 Fix demo name 2020-03-07 21:53:40 -08:00
Paulus Schoutsen
4a0fc3e087 Rename demo 2020-03-07 21:51:48 -08:00
Paulus Schoutsen
f3c371996f Add media player card gallery (#5101) 2020-03-07 21:47:53 -08:00
Paulus Schoutsen
e5467181cb Fix vibrant type (#5100) 2020-03-08 00:46:51 -05:00
HomeAssistant Azure
0b3d2ea4ad [ci skip] Translation update 2020-03-08 00:32:36 +00:00
HomeAssistant Azure
9648aa3588 [ci skip] Translation update 2020-03-07 00:32:40 +00:00
HomeAssistant Azure
1b92cbbf74 [ci skip] Translation update 2020-03-06 13:41:27 +00:00
Bram Kragten
9979c046b3 Update azure-pipelines-translation.yml for Azure Pipelines 2020-03-06 14:38:19 +01:00
Bram Kragten
a0900afba3 Bumped version to 20200306.0 2020-03-06 14:00:36 +01:00
Bram Kragten
1cb614c8a8 Forgot border height (#5088) 2020-03-06 13:46:11 +01:00
David F. Mulcahey
e63723f39e fix click handlers on data tables (#5087) 2020-03-06 13:45:12 +01:00
Bram Kragten
720bd03173 Add image to demo media player (#5086) 2020-03-06 13:44:59 +01:00
Bram Kragten
9e07cf67a5 Fix navigation after deleting a view and allow deleting view with cards (#5085)
* Fix navigation after deleting a view

* Allow deleting views with cards
2020-03-06 13:14:43 +01:00
Thomas Lovén
503dec7345 GUI editor for conditional card (#5051)
* GUI editor for conditional card

* Typing

* Fix typos. Remove quotes

* Add lovelace to card picker

* Address review comments
2020-03-06 12:58:41 +01:00
Bram Kragten
5a2649a65b Virtualize data tabel (#5066)
* WIP

* Fixes and implement further

* Give more space to table on mobile

* Remove unused deps

* Update ha-config-lovelace-dashboards.ts

* Add auto-height

* Console.bye

hihi

* lint
2020-03-06 12:58:13 +01:00
Tomasz
1599dc9e16 Set view visibility form UI (#4978)
* wip

* move logic to hui-view-visibility-editor.ts

* users will be always set

* extra filter to remove deleted users

* added better UI.
I had to add   to ha-switch to avoid scrollbar

* fix for comments

* setting default visibility in a better way

* simplified logic, addressed comments
2020-03-06 12:36:34 +01:00
Zack Arnett
84dc8188c4 New Media Player - Cloned from Android Spotify Notification (#5044)
* Media Player Spotify Clone

* Ellipsis fix

* Style for theme update

* Height now determined by player height

* comments - Also includes Testing with images

* Pushing to resolve balloobs comments ;)

* Add Node Vibrant type

* Update Styles

* Comments

* Reviews

* Lint

* undo readme

* Reviews && Clean up

* Clear Interval

* Animation updates + comments

* small style tweak and comment

* Last little fix

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2020-03-05 21:33:38 -05:00
Paulus Schoutsen
74657ae815 Make cast backwards compatible (#5081)
* Make cast backwards compatible

* Update hc-main.ts

* Update home-assistant-js-websocket
2020-03-05 13:36:13 -08:00
Paulus Schoutsen
1a3b747d17 Style fixes to lovelace config dashboard (#5055)
* Style fixes to lovelace config dashboard

* Add standard panel and show which is default

* Update dialog-lovelace-dashboard-detail.ts

* Updated lovelace icon

* Comments and adapt to backend changes
2020-03-05 11:52:16 -08:00
Bram Kragten
802db71400 Fix secondary info in generic entity row (#5072) 2020-03-05 20:42:15 +01:00
Paulus Schoutsen
4f98524258 Optimise config entities panel + clarify not being able to dele… (#5079) 2020-03-05 09:09:29 -08:00
Bram Kragten
b17ea09b8b Improve chips (#5070)
Follow mdc specs
2020-03-05 12:59:32 +01:00
Bram Kragten
8abbc71e91 Only rebuild when current panel changed (#5071)
* Only rebuild when current panel changed

* Oops

* Update yarn.lock

* copy dep to repo

* Update deep-equal.ts

* Comments
2020-03-05 12:59:18 +01:00
Paulus Schoutsen
1db31fb0f7 Show entity reg editor for helpers when not editable (#5056) 2020-03-04 17:20:16 -08:00
Bram Kragten
e9b5725d7b Update default icon Lovelace sidebar. (#5069) 2020-03-05 00:19:41 +01:00
Bram Kragten
d3105b6846 Add rel=noopener (#5046) 2020-03-04 22:34:48 +01:00
Bram Kragten
196540afc7 Allow card editors to use card picker again (#5067) 2020-03-04 22:04:04 +01:00
Paulus Schoutsen
2b8b9f8311 Remove unused camera command (#5068) 2020-03-04 12:39:56 -08:00
redbar0n
b4f0fce600 Changed login text to be less misleading (#5054)
* Changed login text to be less misleading

See https://github.com/home-assistant/frontend/issues/5053

* Update according to suggestion for improvement

The new text should be more user-friendly.
2020-03-04 15:31:40 +01:00
Zack Arnett
c6f101a487 Add Descriptions for cards - Card Picker (#5058)
* Add Descriptions for cards - this is for card pick

* oops

* Comments from Frenck

* few card capitalizations found

* Comments

* Comments on glance
2020-03-03 17:05:24 -05:00
Paulus Schoutsen
54739c7ccd Support grouped system log messages (#5030)
* Support grouped system log messages

* Format source
2020-03-03 13:55:26 -08:00
Zack Arnett
aa2e632df3 Card Picker with Previews of cards (#4975)
* Card Picker with Previews of cards

* Getting Preview Async - Using dialogs entities

* Create generic getElement - filter entities before

* lint

* Add entities back to Picker. Set Qualifier

* Style Updates

* Move setup of filtered cards to connected

* style updates

* Dont pull entities if noEntity config

* Move all config logic to getConfig

* Style Update - Remove Manual process

* lint

* Accounting for ll-rebuild for async cards

* Style Updates - Use GetStubConfig for most

* Lint

* Filter entities with function - style - no preview

* Iframe rename and description

* Move getstubconfig to helper - update spinner

* Style for themes

* Move entities to be calc once

* Should update

* oops

* TSC

* Comments
2020-03-03 14:53:55 -05:00
Paulus Schoutsen
f3445d99cf Sort ungrouped entities (#5047)
* Sort ungrouped entities

* Console.bye
2020-03-03 17:36:39 +01:00
Bram Kragten
7e48b21767 Remove states-ui and allow setting (local) default lovelace panel (#5043)
* Remove states-ui and allow setting (local) default lovelace panel

* Remove from demo

* Delete ha-cards.js

* Add default for yaml defined dashboards

* Update ha-config-lovelace-dashboards.ts
2020-03-03 16:27:35 +01:00
Bram Kragten
1d1688093a Update config.yml 2020-03-03 11:10:57 +01:00
Bram Kragten
d392695ab7 Recreate translations when changed on dev (#5042) 2020-03-02 07:55:16 -08:00
HomeAssistant Azure
5066560411 [ci skip] Translation update 2020-03-02 12:54:26 +00:00
Bram Kragten
7fa6686e8c Update README.md 2020-03-02 13:40:45 +01:00
Paulus Schoutsen
d74fe6ed52 Update translation hashing (#5025)
* Update translation hashing

* Move gulp-rename
2020-03-02 11:36:00 +01:00
HomeAssistant Azure
319a3b4943 [ci skip] Translation update 2020-03-02 00:36:43 +00:00
HomeAssistant Azure
226e6e9f59 [ci skip] Translation update 2020-03-01 00:32:29 +00:00
Erik Montnemery
42f311a457 Update whitelist (#5026) 2020-02-29 14:09:14 +01:00
HomeAssistant Azure
e50ec2e2e2 [ci skip] Translation update 2020-02-29 00:32:31 +00:00
Bram Kragten
b72a3361c0 Fixes for brightness automation action (#5003)
* Fixes for brightness

* Add checbox before optional range integer

* Console

* Comments
2020-02-28 23:01:45 +01:00
Paulus Schoutsen
d7aaed05b7 Clean up generic row (#5022) 2020-02-28 13:35:42 -08:00
Bram Kragten
c5fe5565bb Merge branch 'master' into dev 2020-02-28 22:21:45 +01:00
Bram Kragten
a1a1763897 Bumped version to 20200228.0 2020-02-28 22:20:44 +01:00
Bram Kragten
724357683c Add take control for yaml mode (#4992) 2020-02-28 22:00:01 +01:00
Bram Kragten
0d6de9fe73 Fix for unavailable input-select (#4991)
* Fix for unavailable select

* Update hui-thermostat-card.ts
2020-02-28 21:59:14 +01:00
Bram Kragten
5646045e9e Add UI to create and manage Lovelace dashboards and resources (#5012)
* Add UI to create and manage Lovelace dashboards and resources

* update, comments, fixes

* Align icons with seach icon and checkboxes

* Fix

* Remove js and html resource types

* Allow it for existing ones
2020-02-28 21:58:50 +01:00
Bram Kragten
17c7a3bbac Bumped version to 20200220.5 2020-02-28 15:35:08 +01:00
Ian Richardson
8d65eb1fdf Fix state_color for button (#4995) 2020-02-28 15:35:04 +01:00
Ian Richardson
2298a55b16 Fix action handling for buttons row (#5007) 2020-02-28 15:34:45 +01:00
HomeAssistant Azure
33d65bcefc [ci skip] Translation update 2020-02-28 00:32:34 +00:00
Thomas Lovén
3cc7deda04 GUI editors for stacks (#4999)
* GUI editors for stacks

* fix type checking

* lint

* Address review comments

* Cleanup. Removing inline functions, combining others

* Give the class a more fitting name

* Final tweak

* Update stack cards

Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2020-02-27 14:07:55 +01:00
Bram Kragten
e2de660bec Only cache default lovelace and handle config updates (#5000)
* Only cache default lovelace and handle config updates

* Update partial-panel-resolver.ts
2020-02-27 14:01:37 +01:00
Bram Kragten
6b1e5a525f Fix update if no lastversion and change audio pickers to default (#5010) 2020-02-27 11:44:09 +01:00
Ian Richardson
93565f0ed9 Fix action handling for buttons row (#5007) 2020-02-27 09:20:20 +01:00
HomeAssistant Azure
143d1162b6 [ci skip] Translation update 2020-02-27 00:32:30 +00:00
Ian Richardson
788d616fa2 Fix state_color for button (#4995) 2020-02-26 23:03:47 +01:00
Bram Kragten
0de9471a5d Revert "Static import all the LL cards etc" (#4989)
This reverts commit 52ded635ff.
2020-02-26 08:03:05 -08:00
Bram Kragten
b229071248 Add helper UI (#4940)
* Add helper UI

* Oops

* Update

* Update

* Update

* Lint

* Add all input forms

* Return extended entity registry entry from update

* Comments

Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
2020-02-26 12:53:03 +01:00
Bram Kragten
6d145730a5 Bumped version to 20200220.4 2020-02-26 09:42:10 +01:00
Paulus Schoutsen
f02bb67485 Static import all the LL cards etc (#4987) 2020-02-26 09:42:03 +01:00
Paulus Schoutsen
52ded635ff Static import all the LL cards etc (#4987) 2020-02-26 09:40:54 +01:00
HomeAssistant Azure
a6d73828b8 [ci skip] Translation update 2020-02-26 00:32:31 +00:00
Paulus Schoutsen
1d052fa5bb Add support for multiple Lovelace dashboards (#4967)
* Add support for multiple Lovelace dashboards

* Fix navigation, add to cast, revert resource loading

* Change resource logic

* Lint + cast fix

* Comments

* Fixes

* Console.bye

* Lint"

Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2020-02-25 13:06:25 -08:00
Bram Kragten
38d758b52f Bumped version to 20200220.3 2020-02-25 21:56:52 +01:00
Bram Kragten
9162e9c318 Bumped version to 20200220.2 2020-02-25 21:55:36 +01:00
Bram Kragten
189ea00768 Fix for when the preview element was an error element (#4969)
* Fix for when the preview element was an error element

* Comments

* Update hui-dialog-edit-card.ts
2020-02-25 21:51:52 +01:00
Bram Kragten
25d6427aed Fix for when the preview element was an error element (#4969)
* Fix for when the preview element was an error element

* Comments

* Update hui-dialog-edit-card.ts
2020-02-25 21:51:07 +01:00
Franck Nijhof
8a61442cf2 Add dev demo builds GitHub Actions workflow (#4980) 2020-02-25 10:15:48 -08:00
Franck Nijhof
106d405699 Only trigger on PRs or pushes against the master and dev branch… (#4982) 2020-02-25 10:06:14 -08:00
Chris Talkington
1f23e9062f Fix typo in supervisor popup (#4966) 2020-02-24 20:45:08 -08:00
Franck Nijhof
231b498ea5 Remove Travis-CI (#4972) 2020-02-24 20:43:47 -08:00
HomeAssistant Azure
a256e5abfa [ci skip] Translation update 2020-02-25 00:33:05 +00:00
Ruslan Sayfutdinov
028b370ead [logbook] fix scrolling on iOS (#4950)
* [logbook] fix scrolling on iOS

* Update styling

* Update ha-logbook.ts

Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2020-02-24 13:50:22 +01:00
Sergey Avdeev
18abc6adf7 Update icon_color_css.ts (#4962) 2020-02-24 11:43:58 +01:00
Ruslan Sayfutdinov
95aa29d6ca Fix layout of history and logbook filters (#4963) 2020-02-24 11:38:05 +01:00
Paulus Schoutsen
5d2242dd16 Add a smarter default value for entity card show header toggle (#4964) 2020-02-24 11:29:40 +01:00
HomeAssistant Azure
de8bca6967 [ci skip] Translation update 2020-02-24 00:32:30 +00:00
HomeAssistant Azure
12234de20e [ci skip] Translation update 2020-02-23 00:32:34 +00:00
Tomasz
b41369a2ad Localize tabs names in view editor (#4954)
* Localize tabs

* remove Polish localization
2020-02-22 15:00:01 +01:00
HomeAssistant Azure
6e35c79c14 [ci skip] Translation update 2020-02-22 00:32:31 +00:00
Franck Nijhof
22e4c0512e Add GitHub Actions (#4952) 2020-02-21 13:59:09 +01:00
HomeAssistant Azure
3606b8077f [ci skip] Translation update 2020-02-21 00:34:40 +00:00
Paulus Schoutsen
3a90a65ba8 Merge pull request #4948 from home-assistant/dev
20200220.1
2020-02-20 14:49:22 -08:00
Paulus Schoutsen
e59987a8ed Bumped version to 20200220.1 2020-02-20 14:47:40 -08:00
Paulus Schoutsen
22d8ce0fd9 Fix creating card (#4947) 2020-02-20 14:47:28 -08:00
Zep Fietje
01eae3876b Make config list items appearance consistent for automations, scenes and scripts (#4945) 2020-02-20 21:10:40 +01:00
Paulus Schoutsen
2e43f390a4 Merge pull request #4942 from home-assistant/dev
20200220.0
2020-02-20 10:29:20 -08:00
Paulus Schoutsen
65421fa551 Bumped version to 20200220.0 2020-02-20 10:14:57 -08:00
Zack Arnett
fc88922ce3 Fix CPU and Browser Usuage (#4935) 2020-02-20 09:57:14 +01:00
Paulus Schoutsen
52609dded9 Add rebuild support to editor preview (#4932)
* Add rebuild support to editor preview

* getLovelaceCardClass function added

* Use error class

* Tiny cleanup

* Misplaced comment
2020-02-20 09:55:42 +01:00
HomeAssistant Azure
6d54496187 [ci skip] Translation update 2020-02-20 00:32:31 +00:00
314 changed files with 14005 additions and 5225 deletions

1
.gitattributes vendored
View File

@@ -11,3 +11,4 @@
*.mp3 binary
demo/public/api/camera_proxy_stream/* binary
demo/public/api/media_player_proxy/* binary

View File

@@ -3,6 +3,7 @@ name: Report a bug with the UI, Frontend or Lovelace
about: Report an issue related to the Home Assistant frontend.
labels: bug
---
<!-- READ THIS FIRST:
- If you need additional help with this template please refer to https://www.home-assistant.io/help/reporting_issues/
- Make sure you are running the latest version of Home Assistant before reporting an issue: https://github.com/home-assistant/home-assistant/releases
@@ -10,6 +11,7 @@ labels: bug
- Provide as many details as possible. Paste logs, configuration samples and code into the backticks.
DO NOT DELETE ANY TEXT from this template! Otherwise, your issue may be closed without comment.
-->
## Checklist
- [ ] I have updated to the latest available Home Assistant version.
@@ -17,21 +19,22 @@ DO NOT DELETE ANY TEXT from this template! Otherwise, your issue may be closed w
- [ ] I have tried a different browser to see if it is related to my browser.
## The problem
<!--
Describe the issue you are experiencing here to communicate to the
maintainers. Tell us about the current behavior.
If possible provide a screenshot with a description.
-->
## Expected behavior
<!--
<!--
Describe what you expected to happen or it should look/behave.
If possible provide a screenshot with a description.
-->
## Steps to reproduce
<!--
Provide steps for us, that helps reproducing your issue.
For example:
@@ -43,8 +46,8 @@ DO NOT DELETE ANY TEXT from this template! Otherwise, your issue may be closed w
6. Set the HVAC action to cool
-->
## Environment
<!--
Provide details about the versions you are using, which helps us reproducing
and finding the issue quicker. Version information is found in the
@@ -54,13 +57,13 @@ DO NOT DELETE ANY TEXT from this template! Otherwise, your issue may be closed w
your issue in a different browser and be sure to include your findings.
-->
- Home Assistant release with the issue:
- Last working Home Assistant release (if known):
- UI Type (States or Lovelace):
- Browser and browser version:
- Operating system:
- Home Assistant release with the issue:
- Last working Home Assistant release (if known):
- Browser and browser version:
- Operating system:
## Problem-relevant configuration
<!--
An example configuration that caused the problem for you. Fill this out even
if it seems unimportant to you. Please be sure to remove personal information
@@ -72,6 +75,7 @@ DO NOT DELETE ANY TEXT from this template! Otherwise, your issue may be closed w
```
## Javascript errors shown in your browser console/inspector
<!--
If you come across any javascript or other error logs, e.g., in your browser
console/inspector please provide them.
@@ -82,4 +86,3 @@ DO NOT DELETE ANY TEXT from this template! Otherwise, your issue may be closed w
```
## Additional information

View File

@@ -1,7 +1,7 @@
blank_issues_enabled: false
contact_links:
- name: Report a bug that is NOT related to the UI, Frontend or Lovelace
url: https://github.com/home-assistant/home-assistant/issues
url: https://github.com/home-assistant/core/issues
about: This is the issue tracker for our frontend. Please report other issues with the backend repository.
- name: Report incorrect or missing information on our website
url: https://github.com/home-assistant/home-assistant.io/issues

127
.github/workflows/ci.yaml vendored Normal file
View File

@@ -0,0 +1,127 @@
name: CI
on:
push:
branches:
- dev
- master
pull_request:
branches:
- dev
- master
jobs:
lint:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@v2
- name: Setting up Node.js
uses: actions/setup-node@v1
with:
node-version: 12.x
- name: Get yarn cache path
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
- name: Fetching Yarn cache
uses: actions/cache@v1
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Install dependencies
run: yarn install
env:
CI: true
- name: Build icons
run: ./node_modules/.bin/gulp gen-icons-hassio gen-icons-mdi gen-icons-app
- name: Build translations
run: ./node_modules/.bin/gulp build-translations
- name: Run eslint
run: ./node_modules/.bin/eslint src hassio/src gallery/src
- name: Run tslint
run: ./node_modules/.bin/tslint 'src/**/*.ts' 'hassio/src/**/*.ts' 'gallery/src/**/*.ts' 'cast/src/**/*.ts' 'test-mocha/**/*.ts'
- name: Run tsc
run: ./node_modules/.bin/tsc
test:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@v2
- name: Setting up Node.js
uses: actions/setup-node@v1
with:
node-version: 12.x
- name: Get yarn cache path
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
- name: Fetching Yarn cache
uses: actions/cache@v1
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Install dependencies
run: yarn install
env:
CI: true
- name: Run Mocha
run: npm run mocha
build:
runs-on: ubuntu-latest
needs: [lint, test]
steps:
- name: Check out files from GitHub
uses: actions/checkout@v2
- name: Setting up Node.js
uses: actions/setup-node@v1
with:
node-version: 12.x
- name: Get yarn cache path
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
- name: Fetching Yarn cache
uses: actions/cache@v1
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Install dependencies
run: yarn install
env:
CI: true
- name: Build Application
run: ./node_modules/.bin/gulp build-app
env:
TRAVIS: "true"
supervisor:
runs-on: ubuntu-latest
needs: [lint, test]
steps:
- name: Check out files from GitHub
uses: actions/checkout@v2
- name: Setting up Node.js
uses: actions/setup-node@v1
with:
node-version: 12.x
- name: Get yarn cache path
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
- name: Fetching Yarn cache
uses: actions/cache@v1
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Install dependencies
run: yarn install
env:
CI: true
- name: Build Application
run: ./node_modules/.bin/gulp build-hassio
env:
TRAVIS: "true"

39
.github/workflows/demo.yaml vendored Normal file
View File

@@ -0,0 +1,39 @@
name: Demo
on:
push:
branches:
- dev
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@v2
- name: Setting up Node.js
uses: actions/setup-node@v1
with:
node-version: 12.x
- name: Get yarn cache path
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
- name: Fetching Yarn cache
uses: actions/cache@v1
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Install dependencies
run: yarn install
env:
CI: true
- name: Build Demo
run: ./node_modules/.bin/gulp build-demo
- name: Deploy to Netlify
uses: netlify/actions/cli@master
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_DEMO_DEV_SITE_ID }}
with:
args: deploy --dir=demo/dist --prod

View File

@@ -1,18 +0,0 @@
sudo: false
language: node_js
cache:
yarn: true
directories:
- bower_components
install: yarn install
script:
- npm run build
- hassio/script/build_hassio
# Because else eslint fails because hassio has cleaned that build
- ./node_modules/.bin/gulp gen-icons-app
- npm run test
# - xvfb-run wct --module-resolution=node --npm
# - 'if [ "${TRAVIS_PULL_REQUEST}" = "false" ]; then wct --module-resolution=node --npm --plugin sauce; fi'
dist: trusty
addons:
sauce_connect: true

View File

@@ -1,4 +1,4 @@
# Home Assistant Polymer [![Build Status](https://travis-ci.org/home-assistant/home-assistant-polymer.svg?branch=master)](https://travis-ci.org/home-assistant/home-assistant-polymer)
# Home Assistant Frontend
This is the repository for the official [Home Assistant](https://home-assistant.io) frontend.
@@ -19,12 +19,15 @@ This is the repository for the official [Home Assistant](https://home-assistant.
## Frontend development
### Classic environment
A complete guide can be found at the following [link](https://www.home-assistant.io/developers/frontend/). It describes a short guide for the build of project.
### Docker environment
It is possible to compile the project and/or run commands in the development environment having only the [Docker](https://www.docker.com) pre-installed in the system. On the root of project you can do:
* `sh ./script/docker_run.sh build` Build all the project with one command
* `sh ./script/docker_run.sh bash` Open an interactive shell (the same environment generated by the *classic environment*) where you can run commands. This bash work on your project directory and any change on your file is automatically present within your build bash.
- `sh ./script/docker_run.sh build` Build all the project with one command
- `sh ./script/docker_run.sh bash` Open an interactive shell (the same environment generated by the _classic environment_) where you can run commands. This bash work on your project directory and any change on your file is automatically present within your build bash.
**Note**: if you have installed `npm` in addition to the `docker`, you can use the commands `npm run docker_build` and `npm run bash` to get a full build or bash as explained above

View File

@@ -11,7 +11,7 @@ trigger:
pr: none
schedules:
- cron: "30 0 * * *"
displayName: "translation update"
displayName: "frontend translation update"
branches:
include:
- dev

View File

@@ -24,7 +24,7 @@ gulp.task(
gulp.parallel("gen-icons-app", "gen-icons-mdi"),
"gen-pages-dev",
"gen-index-app-dev",
gulp.series("create-test-translation", "build-translations")
"build-translations"
),
"copy-static",
"webpack-watch-app"

View File

@@ -16,7 +16,7 @@ gulp.task(
process.env.NODE_ENV = "development";
},
"clean-gallery",
gulp.parallel("gen-icons-app", "gen-icons-app", "build-translations"),
gulp.parallel("gen-icons-app", "gen-icons-mdi", "build-translations"),
"copy-static-gallery",
"gen-index-gallery-dev",
"webpack-dev-server-gallery"

View File

@@ -65,6 +65,12 @@ function copyMapPanel(staticDir) {
);
}
gulp.task("copy-translations", (done) => {
const staticDir = paths.static;
copyTranslations(staticDir);
done();
});
gulp.task("copy-static", (done) => {
const staticDir = paths.static;
const staticPath = genStaticPath(paths.static);

View File

@@ -2,6 +2,7 @@ const gulp = require("gulp");
const path = require("path");
const fs = require("fs");
const paths = require("../paths");
const { mapFiles } = require("../util");
const ICON_PACKAGE_PATH = path.resolve(
__dirname,
@@ -57,20 +58,6 @@ function generateIconset(iconsetName, iconNames) {
return `<ha-iconset-svg name="${iconsetName}" size="24"><svg><defs>${iconDefs}</defs></svg></ha-iconset-svg>`;
}
// Helper function to map recursively over files in a folder and it's subfolders
function mapFiles(startPath, filter, mapFunc) {
const files = fs.readdirSync(startPath);
for (let i = 0; i < files.length; i++) {
const filename = path.join(startPath, files[i]);
const stat = fs.lstatSync(filename);
if (stat.isDirectory()) {
mapFiles(filename, filter, mapFunc);
} else if (filename.indexOf(filter) >= 0) {
mapFunc(filename);
}
}
}
// Find all icons used by the project.
function findIcons(searchPath, iconsetName) {
const iconRegex = new RegExp(`${iconsetName}:[\\w-]+`, "g");

View File

@@ -1,14 +1,18 @@
const crypto = require("crypto");
const del = require("del");
const path = require("path");
const source = require("vinyl-source-stream");
const vinylBuffer = require("vinyl-buffer");
const gulp = require("gulp");
const fs = require("fs");
const foreach = require("gulp-foreach");
const hash = require("gulp-hash");
const hashFilename = require("gulp-hash-filename");
const merge = require("gulp-merge-json");
const minify = require("gulp-jsonminify");
const rename = require("gulp-rename");
const transform = require("gulp-json-transform");
const { mapFiles } = require("../util");
const env = require("../env");
const paths = require("../paths");
const inDir = "translations";
const workDir = "build-translations";
@@ -39,8 +43,6 @@ const TRANSLATION_FRAGMENTS = [
"developer-tools",
];
const tasks = [];
function recursiveFlatten(prefix, data) {
let output = {};
Object.keys(data).forEach(function(key) {
@@ -116,11 +118,9 @@ function lokaliseTransform(data, original, file) {
return output;
}
let taskName = "clean-translations";
gulp.task(taskName, function() {
return del([`${outDir}/**/*.json`]);
gulp.task("clean-translations", function() {
return del([workDir]);
});
tasks.push(taskName);
gulp.task("ensure-translations-build-dir", (done) => {
if (!fs.existsSync(workDir)) {
@@ -129,29 +129,23 @@ gulp.task("ensure-translations-build-dir", (done) => {
done();
});
taskName = "create-test-metadata";
gulp.task(
taskName,
gulp.series("ensure-translations-build-dir", function writeTestMetaData(cb) {
fs.writeFile(
workDir + "/testMetadata.json",
JSON.stringify({
test: {
nativeName: "Test",
},
}),
cb
);
})
);
tasks.push(taskName);
gulp.task("create-test-metadata", function(cb) {
fs.writeFile(
workDir + "/testMetadata.json",
JSON.stringify({
test: {
nativeName: "Test",
},
}),
cb
);
});
taskName = "create-test-translation";
gulp.task(
taskName,
gulp.series("create-test-metadata", function() {
"create-test-translation",
gulp.series("create-test-metadata", function createTestTranslation() {
return gulp
.src("src/translations/en.json")
.src(path.join(paths.translations_src, "en.json"))
.pipe(
transform(function(data, file) {
return recursiveEmpty(data);
@@ -161,7 +155,6 @@ gulp.task(
.pipe(gulp.dest(workDir));
})
);
tasks.push(taskName);
/**
* This task will build a master translation file, to be used as the base for
@@ -172,235 +165,215 @@ tasks.push(taskName);
* project is buildable immediately after merging new translation keys, since
* the Lokalise update to translations/en.json will not happen immediately.
*/
taskName = "build-master-translation";
gulp.task(
taskName,
gulp.series("clean-translations", function() {
return gulp
.src("src/translations/en.json")
.pipe(
transform(function(data, file) {
return lokaliseTransform(data, data, file);
})
)
.pipe(rename("translationMaster.json"))
.pipe(gulp.dest(workDir));
})
);
tasks.push(taskName);
gulp.task("build-master-translation", function() {
return gulp
.src(path.join(paths.translations_src, "en.json"))
.pipe(
transform(function(data, file) {
return lokaliseTransform(data, data, file);
})
)
.pipe(rename("translationMaster.json"))
.pipe(gulp.dest(workDir));
});
taskName = "build-merged-translations";
gulp.task(
taskName,
gulp.series("build-master-translation", function() {
return gulp
.src([inDir + "/*.json", workDir + "/test.json"], { allowEmpty: true })
.pipe(
transform(function(data, file) {
return lokaliseTransform(data, data, file);
})
)
.pipe(
foreach(function(stream, file) {
// For each language generate a merged json file. It begins with the master
// translation as a failsafe for untranslated strings, and merges all parent
// tags into one file for each specific subtag
//
// TODO: This is a naive interpretation of BCP47 that should be improved.
// Will be OK for now as long as we don't have anything more complicated
// than a base translation + region.
const tr = path.basename(file.history[0], ".json");
const subtags = tr.split("-");
const src = [workDir + "/translationMaster.json"];
for (let i = 1; i <= subtags.length; i++) {
const lang = subtags.slice(0, i).join("-");
if (lang === "test") {
src.push(workDir + "/test.json");
} else if (lang !== "en") {
src.push(inDir + "/" + lang + ".json");
}
gulp.task("build-merged-translations", function() {
return gulp
.src([inDir + "/*.json", workDir + "/test.json"], { allowEmpty: true })
.pipe(
transform(function(data, file) {
return lokaliseTransform(data, data, file);
})
)
.pipe(
foreach(function(stream, file) {
// For each language generate a merged json file. It begins with the master
// translation as a failsafe for untranslated strings, and merges all parent
// tags into one file for each specific subtag
//
// TODO: This is a naive interpretation of BCP47 that should be improved.
// Will be OK for now as long as we don't have anything more complicated
// than a base translation + region.
const tr = path.basename(file.history[0], ".json");
const subtags = tr.split("-");
const src = [workDir + "/translationMaster.json"];
for (let i = 1; i <= subtags.length; i++) {
const lang = subtags.slice(0, i).join("-");
if (lang === "test") {
src.push(workDir + "/test.json");
} else if (lang !== "en") {
src.push(inDir + "/" + lang + ".json");
}
return gulp
.src(src, { allowEmpty: true })
.pipe(transform((data) => emptyFilter(data)))
.pipe(
merge({
fileName: tr + ".json",
})
)
.pipe(gulp.dest(fullDir));
})
);
})
);
tasks.push(taskName);
}
return gulp
.src(src, { allowEmpty: true })
.pipe(transform((data) => emptyFilter(data)))
.pipe(
merge({
fileName: tr + ".json",
})
)
.pipe(gulp.dest(fullDir));
})
);
});
var taskName;
const splitTasks = [];
TRANSLATION_FRAGMENTS.forEach((fragment) => {
taskName = "build-translation-fragment-" + fragment;
gulp.task(
taskName,
gulp.series("build-merged-translations", function() {
// Return only the translations for this fragment.
return gulp
.src(fullDir + "/*.json")
.pipe(
transform((data) => ({
ui: {
panel: {
[fragment]: data.ui.panel[fragment],
},
gulp.task(taskName, function() {
// Return only the translations for this fragment.
return gulp
.src(fullDir + "/*.json")
.pipe(
transform((data) => ({
ui: {
panel: {
[fragment]: data.ui.panel[fragment],
},
}))
)
.pipe(gulp.dest(workDir + "/" + fragment));
})
);
tasks.push(taskName);
},
}))
)
.pipe(gulp.dest(workDir + "/" + fragment));
});
splitTasks.push(taskName);
});
taskName = "build-translation-core";
gulp.task(
taskName,
gulp.series("build-merged-translations", function() {
// Remove the fragment translations from the core translation.
return gulp
.src(fullDir + "/*.json")
.pipe(
transform((data) => {
TRANSLATION_FRAGMENTS.forEach((fragment) => {
delete data.ui.panel[fragment];
});
return data;
})
)
.pipe(gulp.dest(coreDir));
})
);
tasks.push(taskName);
gulp.task(taskName, function() {
// Remove the fragment translations from the core translation.
return gulp
.src(fullDir + "/*.json")
.pipe(
transform((data) => {
TRANSLATION_FRAGMENTS.forEach((fragment) => {
delete data.ui.panel[fragment];
});
return data;
})
)
.pipe(gulp.dest(coreDir));
});
splitTasks.push(taskName);
taskName = "build-flattened-translations";
gulp.task(
taskName,
gulp.series(...splitTasks, function() {
// Flatten the split versions of our translations, and move them into outDir
return gulp
.src(
TRANSLATION_FRAGMENTS.map(
(fragment) => workDir + "/" + fragment + "/*.json"
).concat(coreDir + "/*.json"),
{ base: workDir }
)
.pipe(
transform(function(data) {
// Polymer.AppLocalizeBehavior requires flattened json
return flatten(data);
})
)
.pipe(minify())
.pipe(hashFilename())
.pipe(
rename((filePath) => {
if (filePath.dirname === "core") {
filePath.dirname = "";
}
})
)
.pipe(gulp.dest(outDir));
})
);
tasks.push(taskName);
gulp.task("build-flattened-translations", function() {
// Flatten the split versions of our translations, and move them into outDir
return gulp
.src(
TRANSLATION_FRAGMENTS.map(
(fragment) => workDir + "/" + fragment + "/*.json"
).concat(coreDir + "/*.json"),
{ base: workDir }
)
.pipe(
transform(function(data) {
// Polymer.AppLocalizeBehavior requires flattened json
return flatten(data);
})
)
.pipe(minify())
.pipe(
rename((filePath) => {
if (filePath.dirname === "core") {
filePath.dirname = "";
}
})
)
.pipe(gulp.dest(outDir));
});
taskName = "build-translation-fingerprints";
gulp.task(
taskName,
gulp.series("build-flattened-translations", function() {
return gulp
.src(outDir + "/**/*.json")
.pipe(
rename({
extname: "",
})
)
.pipe(
hash({
algorithm: "md5",
hashLength: 32,
template: "<%= name %>.json",
})
)
.pipe(hash.manifest("translationFingerprints.json"))
.pipe(
transform(function(data) {
// After generating fingerprints of our translation files, consolidate
// all translation fragment fingerprints under the translation name key
const newData = {};
Object.entries(data).forEach(([key, value]) => {
const [path, _md5] = key.rsplit("-", 1);
// let translation = key;
let translation = path;
const parts = translation.split("/");
if (parts.length === 2) {
translation = parts[1];
}
if (!(translation in newData)) {
newData[translation] = {
fingerprints: {},
};
}
newData[translation].fingerprints[path] = value;
});
return newData;
})
)
.pipe(gulp.dest(workDir));
})
);
tasks.push(taskName);
const fingerprints = {};
taskName = "build-translations";
gulp.task(
taskName,
gulp.series("build-translation-fingerprints", function() {
return gulp
.src(
[
"src/translations/translationMetadata.json",
workDir + "/testMetadata.json",
workDir + "/translationFingerprints.json",
],
{ allowEmpty: true }
)
.pipe(merge({}))
.pipe(
transform(function(data) {
const newData = {};
Object.entries(data).forEach(([key, value]) => {
// Filter out translations without native name.
if (data[key].nativeName) {
newData[key] = data[key];
} else {
console.warn(
`Skipping language ${key}. Native name was not translated.`
);
}
if (data[key]) newData[key] = value;
});
return newData;
})
)
.pipe(
transform((data) => ({
fragments: TRANSLATION_FRAGMENTS,
translations: data,
}))
)
.pipe(rename("translationMetadata.json"))
.pipe(gulp.dest(workDir));
})
);
tasks.push(taskName);
"build-translation-fingerprints",
function fingerprintTranslationFiles() {
// Fingerprint full file of each language
const files = fs.readdirSync(fullDir);
for (let i = 0; i < files.length; i++) {
fingerprints[files[i].split(".")[0]] = {
// In dev we create fake hashes
hash: env.isProdBuild
? crypto
.createHash("md5")
.update(fs.readFileSync(path.join(fullDir, files[i]), "utf-8"))
.digest("hex")
: "dev",
};
}
module.exports = tasks;
mapFiles(outDir, ".json", (filename) => {
const parsed = path.parse(filename);
// nl.json -> nl-<hash>.json
if (!(parsed.name in fingerprints)) {
throw new Error(`Unable to find hash for ${filename}`);
}
fs.renameSync(
filename,
`${parsed.dir}/${parsed.name}-${fingerprints[parsed.name].hash}${
parsed.ext
}`
);
});
const stream = source("translationFingerprints.json");
stream.write(JSON.stringify(fingerprints));
process.nextTick(() => stream.end());
return stream.pipe(vinylBuffer()).pipe(gulp.dest(workDir));
}
);
gulp.task(
"build-translations",
gulp.series(
"clean-translations",
"ensure-translations-build-dir",
env.isProdBuild ? (done) => done() : "create-test-translation",
"build-master-translation",
"build-merged-translations",
gulp.parallel(...splitTasks),
"build-flattened-translations",
"build-translation-fingerprints",
function writeMetadata() {
return gulp
.src(
[
path.join(paths.translations_src, "translationMetadata.json"),
workDir + "/testMetadata.json",
workDir + "/translationFingerprints.json",
],
{ allowEmpty: true }
)
.pipe(merge({}))
.pipe(
transform(function(data) {
const newData = {};
Object.entries(data).forEach(([key, value]) => {
// Filter out translations without native name.
if (data[key].nativeName) {
newData[key] = data[key];
} else {
console.warn(
`Skipping language ${key}. Native name was not translated.`
);
}
if (data[key]) newData[key] = value;
});
return newData;
})
)
.pipe(
transform((data) => ({
fragments: TRANSLATION_FRAGMENTS,
translations: data,
}))
)
.pipe(rename("translationMetadata.json"))
.pipe(gulp.dest(workDir));
}
)
);

View File

@@ -3,6 +3,7 @@ const gulp = require("gulp");
const webpack = require("webpack");
const WebpackDevServer = require("webpack-dev-server");
const log = require("fancy-log");
const path = require("path");
const paths = require("../paths");
const {
createAppConfig,
@@ -58,9 +59,13 @@ const handler = (done) => (err, stats) => {
gulp.task("webpack-watch-app", () => {
// we are not calling done, so this command will run forever
webpack(createAppConfig({ isProdBuild: false, latestBuild: true })).watch(
{},
{ ignored: /build-translations/ },
handler()
);
gulp.watch(
path.join(paths.translations_src, "en.json"),
gulp.series("build-translations", "copy-translations")
);
});
gulp.task(

View File

@@ -29,4 +29,6 @@ module.exports = {
hassio_dir: path.resolve(__dirname, "../hassio"),
hassio_root: path.resolve(__dirname, "../hassio/build"),
hassio_publicPath: "/api/hassio/app/",
translations_src: path.resolve(__dirname, "../src/translations"),
};

16
build-scripts/util.js Normal file
View File

@@ -0,0 +1,16 @@
const path = require("path");
const fs = require("fs");
// Helper function to map recursively over files in a folder and it's subfolders
module.exports.mapFiles = function mapFiles(startPath, filter, mapFunc) {
const files = fs.readdirSync(startPath);
for (let i = 0; i < files.length; i++) {
const filename = path.join(startPath, files[i]);
const stat = fs.lstatSync(filename);
if (stat.isDirectory()) {
mapFiles(filename, filter, mapFunc);
} else if (filename.indexOf(filter) >= 0) {
mapFunc(filename);
}
}
};

View File

@@ -148,11 +148,17 @@ const createAppConfig = ({ isProdBuild, latestBuild, isStatsBuild }) => {
// Create an object mapping browser urls to their paths during build
const translationMetadata = require("../build-translations/translationMetadata.json");
const workBoxTranslationsTemplatedURLs = {};
const englishFP = translationMetadata.translations.en.fingerprints;
Object.keys(englishFP).forEach((key) => {
const englishFilename = `en-${translationMetadata.translations.en.hash}.json`;
// core
workBoxTranslationsTemplatedURLs[
`/static/translations/${englishFilename}`
] = `build-translations/output/${englishFilename}`;
Object.keys(translationMetadata.fragments).forEach((fragment) => {
workBoxTranslationsTemplatedURLs[
`/static/translations/${englishFP[key]}`
] = `build-translations/output/${key}.json`;
`/static/translations/${fragment}/${englishFilename}`
] = `build-translations/output/${fragment}/${englishFilename}`;
});
config.plugins.push(

View File

@@ -26,10 +26,12 @@ import { CastManager } from "../../../../src/cast/cast_manager";
import {
LovelaceConfig,
getLovelaceCollection,
getLegacyLovelaceCollection,
} from "../../../../src/data/lovelace";
import "./hc-layout";
import { generateDefaultViewConfig } from "../../../../src/panels/lovelace/common/generate-lovelace-config";
import { toggleAttribute } from "../../../../src/common/dom/toggle_attribute";
import { atLeastVersion } from "../../../../src/common/config/version";
@customElement("hc-cast")
class HcCast extends LitElement {
@@ -133,7 +135,9 @@ class HcCast extends LitElement {
protected firstUpdated(changedProps) {
super.firstUpdated(changedProps);
const llColl = getLovelaceCollection(this.connection);
const llColl = atLeastVersion(this.connection.haVersion, 0, 107)
? getLovelaceCollection(this.connection)
: getLegacyLovelaceCollection(this.connection);
// We first do a single refresh because we need to check if there is LL
// configuration.
llColl.refresh().then(

View File

@@ -15,6 +15,9 @@ import {
import {
LovelaceConfig,
getLovelaceCollection,
fetchResources,
LegacyLovelaceConfig,
getLegacyLovelaceCollection,
} from "../../../../src/data/lovelace";
import "./hc-launch-screen";
import { castContext } from "../cast_context";
@@ -22,6 +25,9 @@ import { CAST_NS } from "../../../../src/cast/const";
import { ReceiverStatusMessage } from "../../../../src/cast/sender_messages";
import { loadLovelaceResources } from "../../../../src/panels/lovelace/common/load-resources";
import { isNavigationClick } from "../../../../src/common/dom/is-navigation-click";
import { atLeastVersion } from "../../../../src/common/config/version";
let resourcesLoaded = false;
@customElement("hc-main")
export class HcMain extends HassElement {
@@ -34,6 +40,7 @@ export class HcMain extends HassElement {
@property() private _error?: string;
private _unsubLovelace?: UnsubscribeFunc;
private _urlPath?: string | null;
public processIncomingMessage(msg: HassMessage) {
if (msg.type === "connect") {
@@ -108,6 +115,7 @@ export class HcMain extends HassElement {
if (this.hass) {
status.hassUrl = this.hass.auth.data.hassUrl;
status.lovelacePath = this._lovelacePath!;
status.urlPath = this._urlPath;
}
if (senderId) {
@@ -163,8 +171,14 @@ export class HcMain extends HassElement {
this._error = "Cannot show Lovelace because we're not connected.";
return;
}
if (!this._unsubLovelace) {
const llColl = getLovelaceCollection(this.hass!.connection);
if (!this._unsubLovelace || this._urlPath !== msg.urlPath) {
this._urlPath = msg.urlPath;
if (this._unsubLovelace) {
this._unsubLovelace();
}
const llColl = atLeastVersion(this.hass.connection.haVersion, 0, 107)
? getLovelaceCollection(this.hass!.connection, msg.urlPath)
: getLegacyLovelaceCollection(this.hass!.connection);
// We first do a single refresh because we need to check if there is LL
// configuration.
try {
@@ -183,6 +197,15 @@ export class HcMain extends HassElement {
);
}
}
if (!resourcesLoaded) {
resourcesLoaded = true;
const resources = atLeastVersion(this.hass.connection.haVersion, 0, 107)
? await fetchResources(this.hass!.connection)
: (this._lovelaceConfig as LegacyLovelaceConfig).resources;
if (resources) {
loadLovelaceResources(resources, this.hass!.auth.data.hassUrl);
}
}
this._showDemo = false;
this._lovelacePath = msg.viewPath;
if (castContext.getDeviceCapabilities().touch_input_supported) {
@@ -194,12 +217,6 @@ export class HcMain extends HassElement {
private _handleNewLovelaceConfig(lovelaceConfig: LovelaceConfig) {
castContext.setApplicationState(lovelaceConfig.title!);
this._lovelaceConfig = lovelaceConfig;
if (lovelaceConfig.resources) {
loadLovelaceResources(
lovelaceConfig.resources,
this.hass!.auth.data.hassUrl
);
}
}
private _handleShowDemo(_msg: ShowDemoMessage) {

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -0,0 +1,62 @@
import { getEntity } from "../../../src/fake_data/entity";
export const createMediaPlayerEntities = () => [
getEntity("media_player", "bedroom", "playing", {
media_content_type: "movie",
media_title: "Epic sax guy 10 hours",
app_name: "YouTube",
friendly_name: "Skip, no pause",
supported_features: 32,
}),
getEntity("media_player", "family_room", "paused", {
friendly_name: "Paused, music",
media_content_type: "music",
media_title: "I Wanna Be A Hippy (Flamman & Abraxas Radio Mix)",
media_artist: "Technohead",
supported_features: 16417,
entity_picture: "/images/album_cover.jpg",
}),
getEntity("media_player", "family_room_no_play", "paused", {
friendly_name: "Paused, no play",
media_content_type: "movie",
media_title: "Epic sax guy 10 hours",
app_name: "YouTube",
supported_features: 33,
}),
getEntity("media_player", "living_room", "playing", {
friendly_name: "Pause, No skip, tvshow",
media_content_type: "tvshow",
media_title: "Chapter 1",
media_series_title: "House of Cards",
app_name: "Netflix",
supported_features: 1,
}),
getEntity("media_player", "lounge_room", "idle", {
friendly_name: "Screen casting",
media_content_type: "music",
media_title: "I Wanna Be A Hippy (Flamman & Abraxas Radio Mix)",
media_artist: "Technohead",
supported_features: 1,
}),
getEntity("media_player", "theater", "off", {
friendly_name: "Chromcast Idle",
media_content_type: "movie",
media_title: "Epic sax guy 10 hours",
app_name: "YouTube",
supported_features: 33,
}),
getEntity("media_player", "android_cast", "playing", {
friendly_name: "Player Off",
media_title: "Android Screen Casting",
app_name: "Screen Mirroring",
supported_features: 21437,
}),
getEntity("media_player", "unavailable", "unavailable", {
friendly_name: "Player Unavailable",
supported_features: 21437,
}),
getEntity("media_player", "unknown", "unknown", {
friendly_name: "Player Unknown",
supported_features: 21437,
}),
];

View File

@@ -0,0 +1,102 @@
import { html } from "@polymer/polymer/lib/utils/html-tag";
import { PolymerElement } from "@polymer/polymer/polymer-element";
import { provideHass } from "../../../src/fake_data/provide_hass";
import "../components/demo-cards";
import { createMediaPlayerEntities } from "../data/media_players";
const CONFIGS = [
{
heading: "Skip, no pause",
config: `
- type: media-control
entity: media_player.bedroom
`,
},
{
heading: "Paused, music",
config: `
- type: media-control
entity: media_player.family_room
`,
},
{
heading: "Paused, no play",
config: `
- type: media-control
entity: media_player.family_room_no_play
`,
},
{
heading: "Pause, No skip, tvshow",
config: `
- type: media-control
entity: media_player.living_room
`,
},
{
heading: "Screen casting",
config: `
- type: media-control
entity: media_player.android_cast
`,
},
{
heading: "Chromcast Idle",
config: `
- type: media-control
entity: media_player.lounge_room
`,
},
{
heading: "Player Off",
config: `
- type: media-control
entity: media_player.theater
`,
},
{
heading: "Player Unavailable",
config: `
- type: media-control
entity: media_player.unavailable
`,
},
{
heading: "Player Unknown",
config: `
- type: media-control
entity: media_player.unknown
`,
},
];
class DemoHuiMediControlCard extends PolymerElement {
static get template() {
return html`
<demo-cards
id="demos"
hass="[[hass]]"
configs="[[_configs]]"
></demo-cards>
`;
}
static get properties() {
return {
_configs: {
type: Object,
value: CONFIGS,
},
hass: Object,
};
}
public ready() {
super.ready();
const hass = provideHass(this.$.demos);
hass.addEntities(createMediaPlayerEntities());
}
}
customElements.define("demo-hui-media-control-card", DemoHuiMediControlCard);

View File

@@ -1,54 +1,9 @@
import { html } from "@polymer/polymer/lib/utils/html-tag";
import { PolymerElement } from "@polymer/polymer/polymer-element";
import { getEntity } from "../../../src/fake_data/entity";
import { provideHass } from "../../../src/fake_data/provide_hass";
import "../components/demo-cards";
const ENTITIES = [
getEntity("media_player", "bedroom", "playing", {
media_content_type: "movie",
media_title: "Epic sax guy 10 hours",
app_name: "YouTube",
supported_features: 32,
}),
getEntity("media_player", "family_room", "paused", {
media_content_type: "music",
media_title: "I Wanna Be A Hippy (Flamman & Abraxas Radio Mix)",
media_artist: "Technohead",
supported_features: 16417,
}),
getEntity("media_player", "family_room_no_play", "paused", {
media_content_type: "movie",
media_title: "Epic sax guy 10 hours",
app_name: "YouTube",
supported_features: 33,
}),
getEntity("media_player", "living_room", "playing", {
media_content_type: "tvshow",
media_title: "Chapter 1",
media_series_title: "House of Cards",
app_name: "Netflix",
supported_features: 1,
}),
getEntity("media_player", "lounge_room", "idle", {
media_content_type: "music",
media_title: "I Wanna Be A Hippy (Flamman & Abraxas Radio Mix)",
media_artist: "Technohead",
supported_features: 1,
}),
getEntity("media_player", "theater", "off", {
media_content_type: "movie",
media_title: "Epic sax guy 10 hours",
app_name: "YouTube",
supported_features: 33,
}),
getEntity("media_player", "android_cast", "playing", {
media_title: "Android Screen Casting",
app_name: "Screen Mirroring",
supported_features: 21437,
}),
];
import { createMediaPlayerEntities } from "../data/media_players";
const CONFIGS = [
{
@@ -69,7 +24,11 @@ const CONFIGS = [
- entity: media_player.lounge_room
name: Chromcast Idle
- entity: media_player.theater
name: 'Player Off'
name: Player Off
- entity: media_player.unavailable
name: Player Unavailable
- entity: media_player.unknown
name: Player Unknown
`,
},
];
@@ -98,7 +57,7 @@ class DemoHuiMediaPlayerRows extends PolymerElement {
public ready() {
super.ready();
const hass = provideHass(this.$.demos);
hass.addEntities(ENTITIES);
hass.addEntities(createMediaPlayerEntities());
}
}

View File

@@ -18,6 +18,7 @@ import {
} from "../../../src/data/hassio/addon";
import { navigate } from "../../../src/common/navigate";
import { filterAndSort } from "../components/hassio-filter-addons";
import { atLeastVersion } from "../../../src/common/config/version";
class HassioAddonRepositoryEl extends LitElement {
@property() public hass!: HomeAssistant;
@@ -39,7 +40,6 @@ class HassioAddonRepositoryEl extends LitElement {
protected render(): TemplateResult {
const repo = this.repo;
const addons = this._getAddons(this.addons, this.filter);
const ha105pluss = this._computeHA105plus;
if (this.filter && addons.length < 1) {
return html`
@@ -57,7 +57,9 @@ class HassioAddonRepositoryEl extends LitElement {
</h1>
<p class="description">
Maintained by ${repo.maintainer}<br />
<a class="repo" href=${repo.url} target="_blank">${repo.url}</a>
<a class="repo" href=${repo.url} target="_blank" rel="noreferrer">
${repo.url}
</a>
</p>
<div class="card-group">
${addons.map(
@@ -90,7 +92,11 @@ class HassioAddonRepositoryEl extends LitElement {
: !addon.available
? "not_available"
: ""}
.iconImage=${ha105pluss && addon.icon
.iconImage=${atLeastVersion(
this.hass.connection.haVersion,
0,
105
) && addon.icon
? `/api/hassio/addons/${addon.slug}/icon`
: undefined}
.showTopbar=${addon.installed || !addon.available}
@@ -115,11 +121,6 @@ class HassioAddonRepositoryEl extends LitElement {
navigate(this, `/hassio/addon/${ev.currentTarget.addon.slug}`);
}
private get _computeHA105plus(): boolean {
const [major, minor] = this.hass.config.version.split(".", 2);
return Number(major) > 0 || (major === "0" && Number(minor) >= 105);
}
static get styles(): CSSResultArray {
return [
hassioStyle,

View File

@@ -128,22 +128,27 @@ class HassioAddonAudio extends LitElement {
private _setInputDevice(ev): void {
const device = ev.detail.item.getAttribute("device");
this._selectedInput = device || null;
this._selectedInput = device;
}
private _setOutputDevice(ev): void {
const device = ev.detail.item.getAttribute("device");
this._selectedOutput = device || null;
this._selectedOutput = device;
}
private async _addonChanged(): Promise<void> {
this._selectedInput = this.addon.audio_input;
this._selectedOutput = this.addon.audio_output;
this._selectedInput =
this.addon.audio_input === null ? "default" : this.addon.audio_input;
this._selectedOutput =
this.addon.audio_output === null ? "default" : this.addon.audio_output;
if (this._outputDevices) {
return;
}
const noDevice: HassioHardwareAudioDevice = { device: null, name: "-" };
const noDevice: HassioHardwareAudioDevice = {
device: "default",
name: "Default",
};
try {
const { audio } = await fetchHassioHardwareAudio(this.hass);
@@ -168,8 +173,10 @@ class HassioAddonAudio extends LitElement {
private async _saveSettings(): Promise<void> {
this._error = undefined;
const data: HassioAddonSetOptionParams = {
audio_input: this._selectedInput || null,
audio_output: this._selectedOutput || null,
audio_input:
this._selectedInput === "default" ? null : this._selectedInput,
audio_output:
this._selectedOutput === "default" ? null : this._selectedOutput,
};
try {
await setHassioAddonOption(this.hass, this.addon.slug, data);

View File

@@ -36,6 +36,7 @@ import { haStyle } from "../../../src/resources/styles";
import { HomeAssistant } from "../../../src/types";
import { navigate } from "../../../src/common/navigate";
import { showHassioMarkdownDialog } from "../dialogs/markdown/show-dialog-hassio-markdown";
import { atLeastVersion } from "../../../src/common/config/version";
const PERMIS_DESC = {
rating: {
@@ -185,14 +186,19 @@ class HassioAddonInfo extends LitElement {
<div class="description light-color">
${this.addon.description}.<br />
Visit
<a href="${this.addon.url}" target="_blank">
<a href="${this.addon.url}" target="_blank" rel="noreferrer">
${this.addon.name} page</a
>
for details.
</div>
${this.addon.logo
? html`
<a href="${this.addon.url}" target="_blank" class="logo">
<a
href="${this.addon.url}"
target="_blank"
class="logo"
rel="noreferrer"
>
<img src="/api/hassio/addons/${this.addon.slug}/logo" />
</a>
`
@@ -428,6 +434,7 @@ class HassioAddonInfo extends LitElement {
tabindex="-1"
target="_blank"
class="right"
rel="noopener"
>
<mwc-button>
Open web UI
@@ -452,7 +459,7 @@ class HassioAddonInfo extends LitElement {
`
: ""}
<ha-progress-button
.disabled=${!this.addon.available}
.disabled=${!this.addon.available || this._installing}
.progress=${this._installing}
@click=${this._installClicked}
>
@@ -653,7 +660,10 @@ class HassioAddonInfo extends LitElement {
}
private get _computeCannotIngressSidebar(): boolean {
return !this.addon.ingress || !this._computeHA92plus;
return (
!this.addon.ingress ||
!atLeastVersion(this.hass.connection.haVersion, 0, 92)
);
}
private get _computeUsesProtectedOptions(): boolean {
@@ -662,11 +672,6 @@ class HassioAddonInfo extends LitElement {
);
}
private get _computeHA92plus(): boolean {
const [major, minor] = this.hass.config.version.split(".", 2);
return Number(major) > 0 || (major === "0" && Number(minor) >= 92);
}
private async _startOnBootToggled(): Promise<void> {
this._error = undefined;
const data: HassioAddonSetOptionParams = {

View File

@@ -15,6 +15,7 @@ import { navigate } from "../../../src/common/navigate";
import { hassioStyle } from "../resources/hassio-style";
import { haStyle } from "../../../src/resources/styles";
import "../components/hassio-card-content";
import { atLeastVersion } from "../../../src/common/config/version";
@customElement("hassio-addons")
class HassioAddons extends LitElement {
@@ -22,9 +23,6 @@ class HassioAddons extends LitElement {
@property() public addons?: HassioAddonInfo[];
protected render(): TemplateResult {
const [major, minor] = this.hass.config.version.split(".", 2);
const ha105pluss =
Number(major) > 0 || (major === "0" && Number(minor) >= 105);
return html`
<div class="content">
<h1>Add-ons</h1>
@@ -68,7 +66,11 @@ class HassioAddons extends LitElement {
: addon.installed && addon.state === "started"
? "running"
: "stopped"}
.iconImage=${ha105pluss && addon.icon
.iconImage=${atLeastVersion(
this.hass.connection.haVersion,
0,
105
) && addon.icon
? `/api/hassio/addons/${addon.slug}/icon`
: undefined}
></hassio-card-content>

View File

@@ -42,7 +42,9 @@ export class HassioUpdate extends LitElement {
!!value &&
(value.last_version
? value.version !== value.last_version
: value.version !== value.version_latest)
: value.version_latest
? value.version !== value.version_latest
: false)
);
}).length;
@@ -102,7 +104,7 @@ export class HassioUpdate extends LitElement {
releaseNotesUrl: string,
icon?: string
): TemplateResult {
if (lastVersion === curVersion) {
if (!lastVersion || lastVersion === curVersion) {
return html``;
}
return html`
@@ -121,7 +123,7 @@ export class HassioUpdate extends LitElement {
</div>
</div>
<div class="card-actions">
<a href="${releaseNotesUrl}" target="_blank">
<a href="${releaseNotesUrl}" target="_blank" rel="noreferrer">
<mwc-button>Release notes</mwc-button>
</a>
<ha-call-api-button

View File

@@ -148,7 +148,7 @@ class HassioSupervisorInfo extends LitElement {
!confirm(`WARNING:
Beta releases are for testers and early adopters and can contain unstable code changes. Make sure you have backups of your data before you activate this feature.
This inludes beta releases for:
This includes beta releases for:
- Home Assistant (Release Candidates)
- Hass.io supervisor
- Host system`)

View File

@@ -19,8 +19,6 @@
"license": "Apache-2.0",
"dependencies": {
"@material/chips": "^5.0.0",
"@material/data-table": "^5.0.0",
"@material/mwc-base": "^0.13.0",
"@material/mwc-button": "^0.13.0",
"@material/mwc-checkbox": "^0.13.0",
"@material/mwc-dialog": "^0.13.0",
@@ -70,6 +68,7 @@
"@polymer/paper-tooltip": "^3.0.1",
"@polymer/polymer": "3.1.0",
"@thomasloven/round-slider": "0.3.7",
"@types/resize-observer-browser": "^0.1.3",
"@vaadin/vaadin-combo-box": "^5.0.10",
"@vaadin/vaadin-date-picker": "^4.0.7",
"@webcomponents/shadycss": "^1.9.0",
@@ -85,7 +84,7 @@
"fuse.js": "^3.4.4",
"google-timezones-json": "^1.0.2",
"hls.js": "^0.12.4",
"home-assistant-js-websocket": "4.4.1",
"home-assistant-js-websocket": "4.5.0",
"intl-messageformat": "^2.2.0",
"js-yaml": "^3.13.1",
"leaflet": "^1.4.0",
@@ -97,10 +96,12 @@
"mdn-polyfills": "^5.16.0",
"memoize-one": "^5.0.2",
"moment": "^2.24.0",
"node-vibrant": "^3.1.5",
"preact": "^8.4.2",
"preact-compat": "^3.18.4",
"react-big-calendar": "^0.20.4",
"regenerator-runtime": "^0.13.2",
"resize-observer": "^1.0.0",
"roboto-fontface": "^0.10.0",
"superstruct": "^0.6.1",
"tslib": "^1.10.0",
@@ -145,13 +146,11 @@
"fs-extra": "^7.0.1",
"gulp": "^4.0.0",
"gulp-foreach": "^0.1.0",
"gulp-hash": "^4.2.2",
"gulp-hash-filename": "^2.0.1",
"gulp-insert": "^0.5.0",
"gulp-json-transform": "^0.4.6",
"gulp-jsonminify": "^1.1.0",
"gulp-merge-json": "^1.3.1",
"gulp-rename": "^1.4.0",
"gulp-rename": "^2.0.0",
"gulp-zopfli-green": "^3.0.1",
"html-loader": "^0.5.5",
"html-webpack-plugin": "^3.2.0",
@@ -174,6 +173,8 @@
"tslint-eslint-rules": "^5.4.0",
"tslint-plugin-prettier": "^2.0.1",
"typescript": "^3.7.2",
"vinyl-buffer": "^1.0.1",
"vinyl-source-stream": "^2.0.0",
"web-component-tester": "^6.9.2",
"webpack": "^4.40.2",
"webpack-cli": "^3.3.9",

View File

@@ -11,17 +11,13 @@
"src/panels/dev-template/ha-panel-dev-template.js",
"src/panels/history/ha-panel-history.js",
"src/panels/iframe/ha-panel-iframe.js",
"src/panels/kiosk/ha-panel-kiosk.js",
"src/panels/logbook/ha-panel-logbook.js",
"src/panels/map/ha-panel-map.js",
"src/panels/shopping-list/ha-panel-shopping-list.js",
"src/panels/mailbox/ha-panel-mailbox.js",
"hassio/src/entrypoint.js"
],
"sources": [
"src/**/*",
"!src/translations/*"
],
"sources": ["src/**/*", "!src/translations/*"],
"lint": {
"rules": ["polymer-3"],
"ignoreWarnings": ["could-not-resolve-reference", "could-not-load"],

View File

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

View File

@@ -1,45 +0,0 @@
import { TemplateResult, html } from "lit-html";
import { customElement, LitElement, property } from "lit-element";
import { HassEntity } from "home-assistant-js-websocket";
import "../components/entity/ha-state-label-badge";
import { HomeAssistant } from "../types";
import { fireEvent } from "../common/dom/fire_event";
@customElement("ha-badges-card")
class HaBadgesCard extends LitElement {
@property() public hass?: HomeAssistant;
@property() public states?: HassEntity[];
protected render(): TemplateResult {
if (!this.hass || !this.states) {
return html``;
}
return html`
${this.states.map(
(state) => html`
<ha-state-label-badge
.hass=${this.hass}
.state=${state}
@click=${this._handleClick}
></ha-state-label-badge>
`
)}
`;
}
private _handleClick(ev: Event) {
const entityId = ((ev.target as any).state as HassEntity).entity_id;
fireEvent(this, "hass-more-info", {
entityId,
});
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-badges-card": HaBadgesCard;
}
}

View File

@@ -1,127 +0,0 @@
import "@polymer/paper-styles/element-styles/paper-material-styles";
import { html } from "@polymer/polymer/lib/utils/html-tag";
import { PolymerElement } from "@polymer/polymer/polymer-element";
import { computeStateName } from "../common/entity/compute_state_name";
import { EventsMixin } from "../mixins/events-mixin";
import LocalizeMixin from "../mixins/localize-mixin";
import { fetchThumbnailUrlWithCache } from "../data/camera";
const UPDATE_INTERVAL = 10000; // ms
/*
* @appliesMixin LocalizeMixin
* @appliesMixin EventsMixin
*/
class HaCameraCard extends LocalizeMixin(EventsMixin(PolymerElement)) {
static get template() {
return html`
<style include="paper-material-styles">
:host {
@apply --paper-material-elevation-1;
display: block;
position: relative;
font-size: 0px;
border-radius: 2px;
cursor: pointer;
min-height: 48px;
line-height: 0;
}
.camera-feed {
width: 100%;
height: auto;
border-radius: 2px;
}
.caption {
@apply --paper-font-common-nowrap;
position: absolute;
left: 0px;
right: 0px;
bottom: 0px;
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
background-color: rgba(0, 0, 0, 0.3);
padding: 16px;
font-size: 16px;
font-weight: 500;
line-height: 16px;
color: white;
}
</style>
<template is="dom-if" if="[[cameraFeedSrc]]">
<img
src="[[cameraFeedSrc]]"
class="camera-feed"
alt="[[_computeStateName(stateObj)]]"
on-load="_imageLoaded"
on-error="_imageError"
/>
</template>
<div class="caption">
[[_computeStateName(stateObj)]]
<template is="dom-if" if="[[!imageLoaded]]">
([[localize('ui.card.camera.not_available')]])
</template>
</div>
`;
}
static get properties() {
return {
hass: Object,
stateObj: {
type: Object,
observer: "updateCameraFeedSrc",
},
cameraFeedSrc: {
type: String,
value: "",
},
imageLoaded: {
type: Boolean,
value: true,
},
};
}
ready() {
super.ready();
this.addEventListener("click", () => this.cardTapped());
}
connectedCallback() {
super.connectedCallback();
this.timer = setInterval(() => this.updateCameraFeedSrc(), UPDATE_INTERVAL);
}
disconnectedCallback() {
super.disconnectedCallback();
clearInterval(this.timer);
}
_imageLoaded() {
this.imageLoaded = true;
}
_imageError() {
this.imageLoaded = false;
}
cardTapped() {
this.fire("hass-more-info", { entityId: this.stateObj.entity_id });
}
async updateCameraFeedSrc() {
this.cameraFeedSrc = await fetchThumbnailUrlWithCache(
this.hass,
this.stateObj.entity_id
);
}
_computeStateName(stateObj) {
return computeStateName(stateObj);
}
}
customElements.define("ha-camera-card", HaCameraCard);

View File

@@ -1,81 +0,0 @@
import { PolymerElement } from "@polymer/polymer/polymer-element";
import "./ha-camera-card";
import "./ha-entities-card";
import "./ha-history_graph-card";
import "./ha-media_player-card";
import "./ha-persistent_notification-card";
import "./ha-plant-card";
import "./ha-weather-card";
import dynamicContentUpdater from "../common/dom/dynamic_content_updater";
class HaCardChooser extends PolymerElement {
static get properties() {
return {
cardData: {
type: Object,
observer: "cardDataChanged",
},
};
}
_updateCard(newData) {
dynamicContentUpdater(
this,
"HA-" + newData.cardType.toUpperCase() + "-CARD",
newData
);
}
createObserver() {
this._updatesAllowed = false;
this.observer = new IntersectionObserver((entries) => {
if (!entries.length) return;
if (entries[0].isIntersecting) {
this.style.height = "";
if (this._detachedChild) {
this.appendChild(this._detachedChild);
this._detachedChild = null;
}
this._updateCard(this.cardData); // Don't use 'newData' as it might have changed.
this._updatesAllowed = true;
} else {
// Set the card to be 48px high. Otherwise if the card is kept as 0px height then all
// following cards would trigger the observer at once.
const offsetHeight = this.offsetHeight;
this.style.height = `${offsetHeight || 48}px`;
if (this.lastChild) {
this._detachedChild = this.lastChild;
this.removeChild(this.lastChild);
}
this._updatesAllowed = false;
}
});
this.observer.observe(this);
}
cardDataChanged(newData) {
if (!newData) return;
// ha-entities-card is exempt from observer as it doesn't load heavy resources.
// and usually doesn't load external resources (except for entity_picture).
const eligibleToObserver =
window.IntersectionObserver && newData.cardType !== "entities";
if (!eligibleToObserver) {
if (this.observer) {
this.observer.unobserve(this);
this.observer = null;
}
this.style.height = "";
this._updateCard(newData);
return;
}
if (!this.observer) {
this.createObserver();
}
if (this._updatesAllowed) {
this._updateCard(newData);
}
}
}
customElements.define("ha-card-chooser", HaCardChooser);

View File

@@ -1,182 +0,0 @@
import "@polymer/iron-flex-layout/iron-flex-layout-classes";
import { html } from "@polymer/polymer/lib/utils/html-tag";
import { PolymerElement } from "@polymer/polymer/polymer-element";
import "../components/entity/ha-entity-toggle";
import "../components/ha-card";
import "../state-summary/state-card-content";
import { computeStateDomain } from "../common/entity/compute_state_domain";
import { computeStateName } from "../common/entity/compute_state_name";
import { stateMoreInfoType } from "../common/entity/state_more_info_type";
import { canToggleState } from "../common/entity/can_toggle_state";
import { EventsMixin } from "../mixins/events-mixin";
import LocalizeMixin from "../mixins/localize-mixin";
class HaEntitiesCard extends LocalizeMixin(EventsMixin(PolymerElement)) {
static get template() {
return html`
<style include="iron-flex"></style>
<style>
ha-card {
padding: 16px;
}
.states {
margin: -4px 0;
}
.state {
padding: 4px 0;
}
.header {
@apply --paper-font-headline;
/* overwriting line-height +8 because entity-toggle can be 40px height,
compensating this with reduced padding */
line-height: 40px;
color: var(--primary-text-color);
padding: 4px 0 12px;
}
.header .name {
@apply --paper-font-common-nowrap;
}
ha-entity-toggle {
margin-left: 16px;
}
.more-info {
cursor: pointer;
}
</style>
<ha-card>
<template is="dom-if" if="[[title]]">
<div
class$="[[computeTitleClass(groupEntity)]]"
on-click="entityTapped"
>
<div class="flex name">[[title]]</div>
<template is="dom-if" if="[[showGroupToggle(groupEntity, states)]]">
<ha-entity-toggle
hass="[[hass]]"
state-obj="[[groupEntity]]"
></ha-entity-toggle>
</template>
</div>
</template>
<div class="states">
<template
is="dom-repeat"
items="[[states]]"
on-dom-change="addTapEvents"
>
<div class$="[[computeStateClass(item)]]">
<state-card-content
hass="[[hass]]"
class="state-card"
state-obj="[[item]]"
></state-card-content>
</div>
</template>
</div>
</ha-card>
`;
}
static get properties() {
return {
hass: Object,
states: Array,
groupEntity: Object,
title: {
type: String,
computed: "computeTitle(states, groupEntity, localize)",
},
};
}
constructor() {
super();
// We need to save a single bound function reference to ensure the event listener
// can identify it properly.
this.entityTapped = this.entityTapped.bind(this);
}
computeTitle(states, groupEntity, localize) {
if (groupEntity) {
return computeStateName(groupEntity).trim();
}
const domain = computeStateDomain(states[0]);
return (
(localize && localize(`domain.${domain}`)) || domain.replace(/_/g, " ")
);
}
computeTitleClass(groupEntity) {
let classes = "header horizontal layout center ";
if (groupEntity) {
classes += "more-info";
}
return classes;
}
computeStateClass(stateObj) {
return stateMoreInfoType(stateObj) !== "hidden"
? "state more-info"
: "state";
}
addTapEvents() {
const cards = this.root.querySelectorAll(".state");
cards.forEach((card) => {
if (card.classList.contains("more-info")) {
card.addEventListener("click", this.entityTapped);
} else {
card.removeEventListener("click", this.entityTapped);
}
});
}
entityTapped(ev) {
const item = this.root
.querySelector("dom-repeat")
.itemForElement(ev.target);
let entityId;
if (!item && !this.groupEntity) {
return;
}
ev.stopPropagation();
if (item) {
entityId = item.entity_id;
} else {
entityId = this.groupEntity.entity_id;
}
this.fire("hass-more-info", { entityId: entityId });
}
showGroupToggle(groupEntity, states) {
if (
!groupEntity ||
!states ||
groupEntity.attributes.control === "hidden" ||
(groupEntity.state !== "on" && groupEntity.state !== "off")
) {
return false;
}
// Only show if we can toggle 2+ entities in group
let canToggleCount = 0;
for (let i = 0; i < states.length; i++) {
if (!canToggleState(this.hass, states[i])) {
continue;
}
canToggleCount++;
if (canToggleCount > 1) {
break;
}
}
return canToggleCount > 1;
}
}
customElements.define("ha-entities-card", HaEntitiesCard);

View File

@@ -1,409 +0,0 @@
import "@polymer/iron-flex-layout/iron-flex-layout-classes";
import "@polymer/paper-icon-button/paper-icon-button";
import "@polymer/paper-progress/paper-progress";
import "@polymer/paper-styles/element-styles/paper-material-styles";
import { html } from "@polymer/polymer/lib/utils/html-tag";
import { PolymerElement } from "@polymer/polymer/polymer-element";
import HassMediaPlayerEntity from "../util/hass-media-player-model";
import { fetchMediaPlayerThumbnailWithCache } from "../data/media-player";
import { computeStateName } from "../common/entity/compute_state_name";
import { EventsMixin } from "../mixins/events-mixin";
import LocalizeMixin from "../mixins/localize-mixin";
/*
* @appliesMixin LocalizeMixin
* @appliesMixin EventsMixin
*/
class HaMediaPlayerCard extends LocalizeMixin(EventsMixin(PolymerElement)) {
static get template() {
return html`
<style
include="paper-material-styles iron-flex iron-flex-alignment iron-positioning"
>
:host {
@apply --paper-material-elevation-1;
display: block;
position: relative;
font-size: 0px;
border-radius: 2px;
}
.banner {
position: relative;
background-color: white;
border-top-left-radius: 2px;
border-top-right-radius: 2px;
}
.banner:before {
display: block;
content: "";
width: 100%;
/* removed .25% from 16:9 ratio to fix YT black bars */
padding-top: 56%;
transition: padding-top 0.8s;
}
.banner.no-cover {
background-position: center center;
background-image: url(/static/images/card_media_player_bg.png);
background-repeat: no-repeat;
background-color: var(--primary-color);
}
.banner.content-type-music:before {
padding-top: 100%;
}
.banner.content-type-game:before {
padding-top: 100%;
}
.banner.no-cover:before {
padding-top: 88px;
}
.banner > .cover {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-top-left-radius: 2px;
border-top-right-radius: 2px;
background-position: center center;
background-size: cover;
transition: opacity 0.8s;
opacity: 1;
}
.banner.is-off > .cover {
opacity: 0;
}
.banner > .caption {
@apply --paper-font-caption;
position: absolute;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, var(--dark-secondary-opacity));
padding: 8px 16px;
font-size: 14px;
font-weight: 500;
color: white;
transition: background-color 0.5s;
}
.banner.is-off > .caption {
background-color: initial;
}
.banner > .caption .title {
@apply --paper-font-common-nowrap;
font-size: 1.2em;
margin: 8px 0 4px;
}
.progress {
width: 100%;
height: var(--paper-progress-height, 4px);
margin-top: calc(-1 * var(--paper-progress-height, 4px));
--paper-progress-active-color: var(--accent-color);
--paper-progress-container-color: rgba(200, 200, 200, 0.5);
}
.controls {
position: relative;
@apply --paper-font-body1;
padding: 8px;
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
background-color: var(--paper-card-background-color, white);
}
.controls paper-icon-button {
width: 44px;
height: 44px;
}
.playback-controls {
direction: ltr;
}
paper-icon-button {
opacity: var(--dark-primary-opacity);
}
paper-icon-button[disabled] {
opacity: var(--dark-disabled-opacity);
}
paper-icon-button.primary {
width: 56px !important;
height: 56px !important;
background-color: var(--primary-color);
color: white;
border-radius: 50%;
padding: 8px;
transition: background-color 0.5s;
}
paper-icon-button.primary[disabled] {
background-color: rgba(0, 0, 0, var(--dark-disabled-opacity));
}
[invisible] {
visibility: hidden !important;
}
</style>
<div
class$="[[computeBannerClasses(playerObj, _coverShowing, _coverLoadError)]]"
>
<div class="cover" id="cover"></div>
<div class="caption">
[[_computeStateName(stateObj)]]
<div class="title">[[computePrimaryText(localize, playerObj)]]</div>
[[playerObj.secondaryTitle]]<br />
</div>
</div>
<paper-progress
max="[[stateObj.attributes.media_duration]]"
value="[[playbackPosition]]"
hidden$="[[computeHideProgress(playerObj)]]"
class="progress"
></paper-progress>
<div class="controls layout horizontal justified">
<paper-icon-button
aria-label="Turn off"
icon="hass:power"
on-click="handleTogglePower"
invisible$="[[computeHidePowerButton(playerObj)]]"
class="self-center secondary"
></paper-icon-button>
<div class="playback-controls">
<paper-icon-button
aria-label="Previous track"
icon="hass:skip-previous"
invisible$="[[!playerObj.supportsPreviousTrack]]"
disabled="[[playerObj.isOff]]"
on-click="handlePrevious"
></paper-icon-button>
<paper-icon-button
aria-label="Play or Pause"
class="primary"
icon="[[computePlaybackControlIcon(playerObj)]]"
invisible$="[[!computePlaybackControlIcon(playerObj)]]"
disabled="[[playerObj.isOff]]"
on-click="handlePlaybackControl"
></paper-icon-button>
<paper-icon-button
aria-label="Next track"
icon="hass:skip-next"
invisible$="[[!playerObj.supportsNextTrack]]"
disabled="[[playerObj.isOff]]"
on-click="handleNext"
></paper-icon-button>
</div>
<paper-icon-button
aria-label="More information."
icon="hass:dots-vertical"
on-click="handleOpenMoreInfo"
class="self-center secondary"
></paper-icon-button>
</div>
`;
}
static get properties() {
return {
hass: Object,
stateObj: Object,
playerObj: {
type: Object,
computed: "computePlayerObj(hass, stateObj)",
observer: "playerObjChanged",
},
playbackControlIcon: {
type: String,
computed: "computePlaybackControlIcon(playerObj)",
},
playbackPosition: Number,
_coverShowing: {
type: Boolean,
value: false,
},
_coverLoadError: {
type: Boolean,
value: false,
},
};
}
async playerObjChanged(playerObj, oldPlayerObj) {
if (playerObj.isPlaying && playerObj.showProgress) {
if (!this._positionTracking) {
this._positionTracking = setInterval(
() => this.updatePlaybackPosition(),
1000
);
}
} else if (this._positionTracking) {
clearInterval(this._positionTracking);
this._positionTracking = null;
}
if (playerObj.showProgress) {
this.updatePlaybackPosition();
}
const picture = playerObj.stateObj.attributes.entity_picture;
const oldPicture =
oldPlayerObj && oldPlayerObj.stateObj.attributes.entity_picture;
if (picture !== oldPicture && !picture) {
this.$.cover.style.backgroundImage = "";
return;
}
if (picture === oldPicture) {
return;
}
// We have a new picture url
// If entity picture is non-relative, we use that url directly.
if (picture.substr(0, 1) !== "/") {
this._coverShowing = true;
this._coverLoadError = false;
this.$.cover.style.backgroundImage = `url(${picture})`;
return;
}
try {
const {
content_type: contentType,
content,
} = await fetchMediaPlayerThumbnailWithCache(
this.hass,
playerObj.stateObj.entity_id
);
this._coverShowing = true;
this._coverLoadError = false;
this.$.cover.style.backgroundImage = `url(data:${contentType};base64,${content})`;
} catch (err) {
this._coverShowing = false;
this._coverLoadError = true;
this.$.cover.style.backgroundImage = "";
}
}
updatePlaybackPosition() {
this.playbackPosition = this.playerObj.currentProgress;
}
computeBannerClasses(playerObj, coverShowing, coverLoadError) {
var cls = "banner";
if (!playerObj) {
return cls;
}
if (playerObj.isOff || playerObj.isIdle) {
cls += " is-off no-cover";
} else if (
!playerObj.stateObj.attributes.entity_picture ||
coverLoadError ||
!coverShowing
) {
cls += " no-cover";
} else if (playerObj.stateObj.attributes.media_content_type === "music") {
cls += " content-type-music";
} else if (playerObj.stateObj.attributes.media_content_type === "game") {
cls += " content-type-game";
}
return cls;
}
computeHideProgress(playerObj) {
return !playerObj.showProgress;
}
computeHidePowerButton(playerObj) {
return playerObj.isOff
? !playerObj.supportsTurnOn
: !playerObj.supportsTurnOff;
}
computePlayerObj(hass, stateObj) {
return new HassMediaPlayerEntity(hass, stateObj);
}
computePrimaryText(localize, playerObj) {
return (
playerObj.primaryTitle ||
localize(`state.media_player.${playerObj.stateObj.state}`) ||
localize(`state.default.${playerObj.stateObj.state}`) ||
playerObj.stateObj.state
);
}
computePlaybackControlIcon(playerObj) {
if (playerObj.isPlaying) {
return playerObj.supportsPause ? "hass:pause" : "hass:stop";
}
if (playerObj.hasMediaControl || playerObj.isOff || playerObj.isIdle) {
if (
playerObj.hasMediaControl &&
playerObj.supportsPause &&
!playerObj.isPaused
) {
return "hass:play-pause";
}
return playerObj.supportsPlay ? "hass:play" : null;
}
return "";
}
_computeStateName(stateObj) {
return computeStateName(stateObj);
}
handleNext(ev) {
ev.stopPropagation();
this.playerObj.nextTrack();
}
handleOpenMoreInfo(ev) {
ev.stopPropagation();
this.fire("hass-more-info", { entityId: this.stateObj.entity_id });
}
handlePlaybackControl(ev) {
ev.stopPropagation();
this.playerObj.mediaPlayPause();
}
handlePrevious(ev) {
ev.stopPropagation();
this.playerObj.previousTrack();
}
handleTogglePower(ev) {
ev.stopPropagation();
this.playerObj.togglePower();
}
}
customElements.define("ha-media_player-card", HaMediaPlayerCard);

View File

@@ -1,76 +0,0 @@
import "@material/mwc-button";
import { html } from "@polymer/polymer/lib/utils/html-tag";
import { PolymerElement } from "@polymer/polymer/polymer-element";
import "../components/ha-card";
import "../components/ha-markdown";
import { computeStateName } from "../common/entity/compute_state_name";
import LocalizeMixin from "../mixins/localize-mixin";
import { computeObjectId } from "../common/entity/compute_object_id";
/*
* @appliesMixin LocalizeMixin
*/
class HaPersistentNotificationCard extends LocalizeMixin(PolymerElement) {
static get template() {
return html`
<style>
:host {
@apply --paper-font-body1;
}
ha-markdown {
display: block;
padding: 0 16px;
-ms-user-select: initial;
-webkit-user-select: initial;
-moz-user-select: initial;
}
ha-markdown p:first-child {
margin-top: 0;
}
ha-markdown p:last-child {
margin-bottom: 0;
}
ha-markdown a {
color: var(--primary-color);
}
ha-markdown img {
max-width: 100%;
}
mwc-button {
margin: 8px;
}
</style>
<ha-card header="[[computeTitle(stateObj)]]">
<ha-markdown content="[[stateObj.attributes.message]]"></ha-markdown>
<mwc-button on-click="dismissTap"
>[[localize('ui.card.persistent_notification.dismiss')]]</mwc-button
>
</ha-card>
`;
}
static get properties() {
return {
hass: Object,
stateObj: Object,
};
}
computeTitle(stateObj) {
return stateObj.attributes.title || computeStateName(stateObj);
}
dismissTap(ev) {
ev.preventDefault();
this.hass.callService("persistent_notification", "dismiss", {
notification_id: computeObjectId(this.stateObj.entity_id),
});
}
}
customElements.define(
"ha-persistent_notification-card",
HaPersistentNotificationCard
);

View File

@@ -1,165 +0,0 @@
import { html } from "@polymer/polymer/lib/utils/html-tag";
import { PolymerElement } from "@polymer/polymer/polymer-element";
import "../components/ha-card";
import "../components/ha-icon";
import { computeStateName } from "../common/entity/compute_state_name";
import { EventsMixin } from "../mixins/events-mixin";
class HaPlantCard extends EventsMixin(PolymerElement) {
static get template() {
return html`
<style>
.banner {
display: flex;
align-items: flex-end;
background-repeat: no-repeat;
background-size: cover;
background-position: center;
padding-top: 12px;
}
.has-plant-image .banner {
padding-top: 30%;
}
.header {
@apply --paper-font-headline;
line-height: 40px;
padding: 8px 16px;
}
.has-plant-image .header {
font-size: 16px;
font-weight: 500;
line-height: 16px;
padding: 16px;
color: white;
width: 100%;
background: rgba(0, 0, 0, var(--dark-secondary-opacity));
}
.content {
display: flex;
justify-content: space-between;
padding: 16px 32px 24px 32px;
}
.has-plant-image .content {
padding-bottom: 16px;
}
ha-icon {
color: var(--paper-item-icon-color);
margin-bottom: 8px;
}
.attributes {
cursor: pointer;
}
.attributes div {
text-align: center;
}
.problem {
color: var(--google-red-500);
font-weight: bold;
}
.uom {
color: var(--secondary-text-color);
}
</style>
<ha-card
class$="[[computeImageClass(stateObj.attributes.entity_picture)]]"
>
<div
class="banner"
style="background-image:url([[stateObj.attributes.entity_picture]])"
>
<div class="header">[[computeTitle(stateObj)]]</div>
</div>
<div class="content">
<template
is="dom-repeat"
items="[[computeAttributes(stateObj.attributes)]]"
>
<div class="attributes" on-click="attributeClicked">
<div>
<ha-icon
icon="[[computeIcon(item, stateObj.attributes.battery)]]"
></ha-icon>
</div>
<div
class$="[[computeAttributeClass(stateObj.attributes.problem, item)]]"
>
[[computeValue(stateObj.attributes, item)]]
</div>
<div class="uom">
[[computeUom(stateObj.attributes.unit_of_measurement_dict,
item)]]
</div>
</div>
</template>
</div>
</ha-card>
`;
}
static get properties() {
return {
hass: Object,
stateObj: Object,
config: Object,
};
}
constructor() {
super();
this.sensors = {
moisture: "hass:water",
temperature: "hass:thermometer",
brightness: "hass:white-balance-sunny",
conductivity: "hass:emoticon-poop",
battery: "hass:battery",
};
}
computeTitle(stateObj) {
return (this.config && this.config.name) || computeStateName(stateObj);
}
computeAttributes(data) {
return Object.keys(this.sensors).filter((key) => key in data);
}
computeIcon(attr, batLvl) {
const icon = this.sensors[attr];
if (attr === "battery") {
if (batLvl <= 5) {
return `${icon}-alert`;
}
if (batLvl < 95) {
return `${icon}-${Math.round(batLvl / 10 - 0.01) * 10}`;
}
}
return icon;
}
computeValue(attributes, attr) {
return attributes[attr];
}
computeUom(dict, attr) {
return dict[attr] || "";
}
computeAttributeClass(problem, attr) {
return problem.indexOf(attr) === -1 ? "" : "problem";
}
computeImageClass(entityPicture) {
return entityPicture ? "has-plant-image" : "";
}
attributeClicked(ev) {
this.fire("hass-more-info", {
entityId: this.stateObj.attributes.sensors[ev.model.item],
});
}
}
customElements.define("ha-plant-card", HaPlantCard);

View File

@@ -1,383 +0,0 @@
import { html } from "@polymer/polymer/lib/utils/html-tag";
import { PolymerElement } from "@polymer/polymer/polymer-element";
import { computeStateName } from "../common/entity/compute_state_name";
import "../components/ha-card";
import "../components/ha-icon";
import { EventsMixin } from "../mixins/events-mixin";
import LocalizeMixin from "../mixins/localize-mixin";
import { computeRTL } from "../common/util/compute_rtl";
/*
* @appliesMixin LocalizeMixin
*/
class HaWeatherCard extends LocalizeMixin(EventsMixin(PolymerElement)) {
static get template() {
return html`
<style>
:host {
cursor: pointer;
}
.content {
padding: 0 20px 20px;
}
ha-icon {
color: var(--paper-item-icon-color);
}
.header {
font-family: var(--paper-font-headline_-_font-family);
-webkit-font-smoothing: var(
--paper-font-headline_-_-webkit-font-smoothing
);
font-size: var(--paper-font-headline_-_font-size);
font-weight: var(--paper-font-headline_-_font-weight);
letter-spacing: var(--paper-font-headline_-_letter-spacing);
line-height: var(--paper-font-headline_-_line-height);
text-rendering: var(
--paper-font-common-expensive-kerning_-_text-rendering
);
opacity: var(--dark-primary-opacity);
padding: 24px 16px 16px;
display: flex;
align-items: baseline;
}
.name {
margin-left: 16px;
font-size: 16px;
color: var(--secondary-text-color);
}
:host([rtl]) .name {
margin-left: 0px;
margin-right: 16px;
}
.now {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
}
.main {
display: flex;
align-items: center;
margin-right: 32px;
}
:host([rtl]) .main {
margin-right: 0px;
}
.main ha-icon {
--iron-icon-height: 72px;
--iron-icon-width: 72px;
margin-right: 8px;
}
:host([rtl]) .main ha-icon {
margin-right: 0px;
}
.main .temp {
font-size: 52px;
line-height: 1em;
position: relative;
}
:host([rtl]) .main .temp {
direction: ltr;
margin-right: 28px;
}
.main .temp span {
font-size: 24px;
line-height: 1em;
position: absolute;
top: 4px;
}
.measurand {
display: inline-block;
}
:host([rtl]) .measurand {
direction: ltr;
}
.forecast {
margin-top: 16px;
display: flex;
justify-content: space-between;
}
.forecast div {
flex: 0 0 auto;
text-align: center;
}
.forecast .icon {
margin: 4px 0;
text-align: center;
}
:host([rtl]) .forecast .temp {
direction: ltr;
}
.weekday {
font-weight: bold;
}
.attributes,
.templow,
.precipitation {
color: var(--secondary-text-color);
}
:host([rtl]) .precipitation {
direction: ltr;
}
</style>
<ha-card>
<div class="header">
[[computeState(stateObj.state, localize)]]
<div class="name">[[computeName(stateObj)]]</div>
</div>
<div class="content">
<div class="now">
<div class="main">
<template is="dom-if" if="[[showWeatherIcon(stateObj.state)]]">
<ha-icon icon="[[getWeatherIcon(stateObj.state)]]"></ha-icon>
</template>
<div class="temp">
[[stateObj.attributes.temperature]]<span
>[[getUnit('temperature')]]</span
>
</div>
</div>
<div class="attributes">
<template
is="dom-if"
if="[[_showValue(stateObj.attributes.pressure)]]"
>
<div>
[[localize('ui.card.weather.attributes.air_pressure')]]:
<span class="measurand">
[[stateObj.attributes.pressure]] [[getUnit('air_pressure')]]
</span>
</div>
</template>
<template
is="dom-if"
if="[[_showValue(stateObj.attributes.humidity)]]"
>
<div>
[[localize('ui.card.weather.attributes.humidity')]]:
<span class="measurand"
>[[stateObj.attributes.humidity]] %</span
>
</div>
</template>
<template
is="dom-if"
if="[[_showValue(stateObj.attributes.wind_speed)]]"
>
<div>
[[localize('ui.card.weather.attributes.wind_speed')]]:
<span class="measurand">
[[getWindSpeed(stateObj.attributes.wind_speed)]]
</span>
[[getWindBearing(stateObj.attributes.wind_bearing, localize)]]
</div>
</template>
</div>
</div>
<template is="dom-if" if="[[forecast]]">
<div class="forecast">
<template is="dom-repeat" items="[[forecast]]">
<div>
<div class="weekday">
[[computeDate(item.datetime)]]<br />
<template is="dom-if" if="[[!_showValue(item.templow)]]">
[[computeTime(item.datetime)]]
</template>
</div>
<template is="dom-if" if="[[_showValue(item.condition)]]">
<div class="icon">
<ha-icon
icon="[[getWeatherIcon(item.condition)]]"
></ha-icon>
</div>
</template>
<template is="dom-if" if="[[_showValue(item.temperature)]]">
<div class="temp">
[[item.temperature]] [[getUnit('temperature')]]
</div>
</template>
<template is="dom-if" if="[[_showValue(item.templow)]]">
<div class="templow">
[[item.templow]] [[getUnit('temperature')]]
</div>
</template>
<template is="dom-if" if="[[_showValue(item.precipitation)]]">
<div class="precipitation">
[[item.precipitation]] [[getUnit('precipitation')]]
</div>
</template>
</div>
</template>
</div>
</template>
</div>
</ha-card>
`;
}
static get properties() {
return {
hass: Object,
config: Object,
stateObj: Object,
forecast: {
type: Array,
computed: "computeForecast(stateObj.attributes.forecast)",
},
rtl: {
type: Boolean,
reflectToAttribute: true,
computed: "_computeRTL(hass)",
},
};
}
constructor() {
super();
this.cardinalDirections = [
"N",
"NNE",
"NE",
"ENE",
"E",
"ESE",
"SE",
"SSE",
"S",
"SSW",
"SW",
"WSW",
"W",
"WNW",
"NW",
"NNW",
"N",
];
this.weatherIcons = {
"clear-night": "hass:weather-night",
cloudy: "hass:weather-cloudy",
exceptional: "hass:alert-circle-outline",
fog: "hass:weather-fog",
hail: "hass:weather-hail",
lightning: "hass:weather-lightning",
"lightning-rainy": "hass:weather-lightning-rainy",
partlycloudy: "hass:weather-partly-cloudy",
pouring: "hass:weather-pouring",
rainy: "hass:weather-rainy",
snowy: "hass:weather-snowy",
"snowy-rainy": "hass:weather-snowy-rainy",
sunny: "hass:weather-sunny",
windy: "hass:weather-windy",
"windy-variant": "hass:weather-windy-variant",
};
}
ready() {
this.addEventListener("click", this._onClick);
super.ready();
}
_onClick() {
this.fire("hass-more-info", { entityId: this.stateObj.entity_id });
}
computeForecast(forecast) {
return forecast && forecast.slice(0, 5);
}
getUnit(measure) {
const lengthUnit = this.hass.config.unit_system.length || "";
switch (measure) {
case "air_pressure":
return lengthUnit === "km" ? "hPa" : "inHg";
case "length":
return lengthUnit;
case "precipitation":
return lengthUnit === "km" ? "mm" : "in";
default:
return this.hass.config.unit_system[measure] || "";
}
}
computeState(state, localize) {
return localize(`state.weather.${state}`) || state;
}
computeName(stateObj) {
return (this.config && this.config.name) || computeStateName(stateObj);
}
showWeatherIcon(condition) {
return condition in this.weatherIcons;
}
getWeatherIcon(condition) {
return this.weatherIcons[condition];
}
windBearingToText(degree) {
const degreenum = parseInt(degree);
if (isFinite(degreenum)) {
return this.cardinalDirections[(((degreenum + 11.25) / 22.5) | 0) % 16];
}
return degree;
}
getWindSpeed(speed) {
return `${speed} ${this.getUnit("length")}/h`;
}
getWindBearing(bearing, localize) {
if (bearing != null) {
const cardinalDirection = this.windBearingToText(bearing);
return `(${localize(
`ui.card.weather.cardinal_direction.${cardinalDirection.toLowerCase()}`
) || cardinalDirection})`;
}
return ``;
}
_showValue(item) {
return typeof item !== "undefined" && item !== null;
}
computeDate(data) {
const date = new Date(data);
return date.toLocaleDateString(this.hass.language, { weekday: "short" });
}
computeTime(data) {
const date = new Date(data);
return date.toLocaleTimeString(this.hass.language, { hour: "numeric" });
}
_computeRTL(hass) {
return computeRTL(hass);
}
}
customElements.define("ha-weather-card", HaWeatherCard);

View File

@@ -21,6 +21,7 @@ export interface ConnectMessage extends BaseCastMessage {
export interface ShowLovelaceViewMessage extends BaseCastMessage {
type: "show_lovelace_view";
viewPath: string | number | null;
urlPath: string | null;
}
export interface ShowDemoMessage extends BaseCastMessage {
@@ -43,11 +44,13 @@ export const castSendAuth = (cast: CastManager, auth: Auth) =>
export const castSendShowLovelaceView = (
cast: CastManager,
viewPath: ShowLovelaceViewMessage["viewPath"]
viewPath: ShowLovelaceViewMessage["viewPath"],
urlPath?: string | null
) =>
cast.sendMessage({
type: "show_lovelace_view",
viewPath,
urlPath: urlPath || null,
});
export const castSendShowDemo = (cast: CastManager) =>

View File

@@ -8,6 +8,7 @@ export interface ReceiverStatusMessage extends BaseCastMessage {
showDemo: boolean;
hassUrl?: string;
lovelacePath?: string | number | null;
urlPath?: string | null;
}
export type SenderMessage = ReceiverStatusMessage;

View File

@@ -0,0 +1,11 @@
export const atLeastVersion = (
version: string,
major: number,
minor: number
): boolean => {
const [haMajor, haMinor] = version.split(".", 2);
return (
Number(haMajor) > major ||
(Number(haMajor) === major && Number(haMinor) >= minor)
);
};

View File

@@ -4,7 +4,7 @@ export const dynamicElement = directive(
(tag: string, properties?: { [key: string]: any }) => (part: Part): void => {
if (!(part instanceof NodePart)) {
throw new Error(
"dynamicContentDirective can only be used in content bindings"
"dynamicElementDirective can only be used in content bindings"
);
}

View File

@@ -23,7 +23,7 @@ const fixedIcons = {
homeassistant: "hass:home-assistant",
homekit: "hass:home-automation",
image_processing: "hass:image-filter-frames",
input_boolean: "hass:drawing",
input_boolean: "hass:toggle-switch-outline",
input_datetime: "hass:calendar-clock",
input_number: "hass:ray-vertex",
input_select: "hass:format-list-bulleted",

View File

@@ -29,6 +29,10 @@ export const iconColorCSS = css`
color: var(--heat-color, #ff8100);
}
ha-icon[data-domain="climate"][data-state="drying"] {
color: var(--dry-color, #efbd07);
}
ha-icon[data-domain="alarm_control_panel"] {
color: var(--alarm-color-armed, var(--label-badge-red));
}

View File

@@ -0,0 +1,107 @@
// From https://github.com/epoberezkin/fast-deep-equal
// MIT License - Copyright (c) 2017 Evgeny Poberezkin
export const deepEqual = (a: any, b: any): boolean => {
if (a === b) {
return true;
}
if (a && b && typeof a === "object" && typeof b === "object") {
if (a.constructor !== b.constructor) {
return false;
}
let i: number | [any, any];
let length: number;
if (Array.isArray(a)) {
length = a.length;
if (length !== b.length) {
return false;
}
for (i = length; i-- !== 0; ) {
if (!deepEqual(a[i], b[i])) {
return false;
}
}
return true;
}
if (a instanceof Map && b instanceof Map) {
if (a.size !== b.size) {
return false;
}
for (i of a.entries()) {
if (!b.has(i[0])) {
return false;
}
}
for (i of a.entries()) {
if (!deepEqual(i[1], b.get(i[0]))) {
return false;
}
}
return true;
}
if (a instanceof Set && b instanceof Set) {
if (a.size !== b.size) {
return false;
}
for (i of a.entries()) {
if (!b.has(i[0])) {
return false;
}
}
return true;
}
if (ArrayBuffer.isView(a) && ArrayBuffer.isView(b)) {
// @ts-ignore
length = a.length;
// @ts-ignore
if (length !== b.length) {
return false;
}
for (i = length; i-- !== 0; ) {
if (a[i] !== b[i]) {
return false;
}
}
return true;
}
if (a.constructor === RegExp) {
return a.source === b.source && a.flags === b.flags;
}
if (a.valueOf !== Object.prototype.valueOf) {
return a.valueOf() === b.valueOf();
}
if (a.toString !== Object.prototype.toString) {
return a.toString() === b.toString();
}
let keys: string[];
keys = Object.keys(a);
length = keys.length;
if (length !== Object.keys(b).length) {
return false;
}
for (i = length; i-- !== 0; ) {
if (!Object.prototype.hasOwnProperty.call(b, keys[i])) {
return false;
}
}
for (i = length; i-- !== 0; ) {
const key = keys[i];
if (!deepEqual(a[key], b[key])) {
return false;
}
}
return true;
}
// true if both NaN, false otherwise
return a !== a && b !== b;
};

View File

@@ -1,27 +1,21 @@
import { repeat } from "lit-html/directives/repeat";
import deepClone from "deep-clone-simple";
import {
MDCDataTableAdapter,
MDCDataTableFoundation,
} from "@material/data-table";
import { classMap } from "lit-html/directives/class-map";
import { scroll } from "lit-virtualizer";
import {
html,
query,
queryAll,
CSSResult,
css,
customElement,
property,
TemplateResult,
PropertyValues,
LitElement,
} from "lit-element";
import { BaseElement } from "@material/mwc-base/base-element";
// eslint-disable-next-line import/no-webpack-loader-syntax
// @ts-ignore
// tslint:disable-next-line: no-implicit-dependencies
@@ -35,6 +29,8 @@ import { HaCheckbox } from "../ha-checkbox";
import { fireEvent } from "../../common/dom/fire_event";
import { nextRender } from "../../common/util/render-status";
import { debounce } from "../../common/util/debounce";
import { styleMap } from "lit-html/directives/style-map";
import { ifDefined } from "lit-html/directives/if-defined";
declare global {
// for fire event
@@ -50,8 +46,7 @@ export interface RowClickedEvent {
}
export interface SelectionChangedEvent {
id: string;
selected: boolean;
value: string[];
}
export interface SortingChangedEvent {
@@ -76,6 +71,8 @@ export interface DataTableColumnData extends DataTableSortColumnData {
title: string;
type?: "numeric" | "icon";
template?: <T>(data: any, row: T) => TemplateResult | string;
width?: string;
grows?: boolean;
}
export interface DataTableRowData {
@@ -84,26 +81,23 @@ export interface DataTableRowData {
}
@customElement("ha-data-table")
export class HaDataTable extends BaseElement {
export class HaDataTable extends LitElement {
@property({ type: Object }) public columns: DataTableColumnContainer = {};
@property({ type: Array }) public data: DataTableRowData[] = [];
@property({ type: Boolean }) public selectable = false;
@property({ type: Boolean, attribute: "auto-height" })
public autoHeight = false;
@property({ type: String }) public id = "id";
@property({ type: String }) public filter = "";
protected mdcFoundation!: MDCDataTableFoundation;
protected readonly mdcFoundationClass = MDCDataTableFoundation;
@query(".mdc-data-table") protected mdcRoot!: HTMLElement;
@queryAll(".mdc-data-table__row") protected rowElements!: HTMLElement[];
@property({ type: Boolean }) private _filterable = false;
@property({ type: Boolean }) private _headerChecked = false;
@property({ type: Boolean }) private _headerIndeterminate = false;
@property({ type: Array }) private _checkedRows: string[] = [];
@property({ type: String }) private _filter = "";
@property({ type: String }) private _sortColumn?: string;
@property({ type: String }) private _sortDirection: SortingDirection = null;
@property({ type: Array }) private _filteredData: DataTableRowData[] = [];
@query("slot[name='header']") private _header!: HTMLSlotElement;
@query(".scroller") private _scroller!: HTMLDivElement;
@query(".mdc-data-table__table") private _table!: HTMLDivElement;
private _checkableRowsCount?: number;
private _checkedRows: string[] = [];
private _sortColumns: {
[key: string]: DataTableSortColumnData;
} = {};
@@ -114,18 +108,17 @@ export class HaDataTable extends BaseElement {
(value: string) => {
this._filter = value;
},
200,
100,
false
);
public clearSelection(): void {
this._headerChecked = false;
this._headerIndeterminate = false;
this.mdcFoundation.handleHeaderRowCheckboxChange();
this._checkedRows = [];
this._checkedRowsChanged();
}
protected firstUpdated() {
super.firstUpdated();
protected firstUpdated(properties: PropertyValues) {
super.firstUpdated(properties);
this._worker = sortFilterWorker();
}
@@ -159,6 +152,12 @@ export class HaDataTable extends BaseElement {
this._debounceSearch(this.filter);
}
if (properties.has("data")) {
this._checkableRowsCount = this.data.filter(
(row) => row.selectable !== false
).length;
}
if (
properties.has("data") ||
properties.has("columns") ||
@@ -173,7 +172,7 @@ export class HaDataTable extends BaseElement {
protected render() {
return html`
<div class="mdc-data-table">
<slot name="header" @slotchange=${this._calcScrollHeight}>
<slot name="header" @slotchange=${this._calcTableHeight}>
${this._filterable
? html`
<div class="table-header">
@@ -184,168 +183,151 @@ export class HaDataTable extends BaseElement {
`
: ""}
</slot>
<div class="scroller">
<table class="mdc-data-table__table">
<thead>
<tr class="mdc-data-table__header-row">
${this.selectable
? html`
<th
class="mdc-data-table__header-cell mdc-data-table__header-cell--checkbox"
role="columnheader"
scope="col"
>
<ha-checkbox
class="mdc-data-table__row-checkbox"
@change=${this._handleHeaderRowCheckboxChange}
.indeterminate=${this._headerIndeterminate}
.checked=${this._headerChecked}
>
</ha-checkbox>
</th>
`
: ""}
${Object.entries(this.columns).map((columnEntry) => {
const [key, column] = columnEntry;
const sorted = key === this._sortColumn;
const classes = {
"mdc-data-table__header-cell--numeric": Boolean(
column.type && column.type === "numeric"
),
"mdc-data-table__header-cell--icon": Boolean(
column.type && column.type === "icon"
),
sortable: Boolean(column.sortable),
"not-sorted": Boolean(column.sortable && !sorted),
};
return html`
<th
class="mdc-data-table__header-cell ${classMap(classes)}"
role="columnheader"
scope="col"
@click=${this._handleHeaderClick}
data-column-id="${key}"
>
${column.sortable
? html`
<ha-icon
.icon=${sorted && this._sortDirection === "desc"
? "hass:arrow-down"
: "hass:arrow-up"}
></ha-icon>
`
: ""}
<span>${column.title}</span>
</th>
`;
})}
</tr>
</thead>
<tbody class="mdc-data-table__content">
${repeat(
this._filteredData!,
(row: DataTableRowData) => row[this.id],
(row: DataTableRowData) => html`
<tr
data-row-id="${row[this.id]}"
@click=${this._handleRowClick}
class="mdc-data-table__row"
.selectable=${row.selectable !== false}
<div
class="mdc-data-table__table ${classMap({
"auto-height": this.autoHeight,
})}"
style=${styleMap({
height: this.autoHeight
? `${this._filteredData.length * 53 + 57}px`
: `calc(100% - ${this._header?.clientHeight}px)`,
})}
>
<div class="mdc-data-table__header-row">
${this.selectable
? html`
<div
class="mdc-data-table__header-cell mdc-data-table__header-cell--checkbox"
role="columnheader"
scope="col"
>
${this.selectable
? html`
<td
class="mdc-data-table__cell mdc-data-table__cell--checkbox"
>
<ha-checkbox
class="mdc-data-table__row-checkbox"
@change=${this._handleRowCheckboxChange}
.disabled=${row.selectable === false}
.checked=${this._checkedRows.includes(
String(row[this.id])
)}
>
</ha-checkbox>
</td>
`
: ""}
${Object.entries(this.columns).map((columnEntry) => {
const [key, column] = columnEntry;
return html`
<td
class="mdc-data-table__cell ${classMap({
"mdc-data-table__cell--numeric": Boolean(
column.type && column.type === "numeric"
),
"mdc-data-table__cell--icon": Boolean(
column.type && column.type === "icon"
),
})}"
>
${column.template
? column.template(row[key], row)
: row[key]}
</td>
`;
})}
</tr>
<ha-checkbox
class="mdc-data-table__row-checkbox"
@change=${this._handleHeaderRowCheckboxClick}
.indeterminate=${this._checkedRows.length &&
this._checkedRows.length !== this._checkableRowsCount}
.checked=${this._checkedRows.length ===
this._checkableRowsCount}
>
</ha-checkbox>
</div>
`
)}
</tbody>
</table>
: ""}
${Object.entries(this.columns).map((columnEntry) => {
const [key, column] = columnEntry;
const sorted = key === this._sortColumn;
const classes = {
"mdc-data-table__header-cell--numeric": Boolean(
column.type && column.type === "numeric"
),
"mdc-data-table__header-cell--icon": Boolean(
column.type && column.type === "icon"
),
sortable: Boolean(column.sortable),
"not-sorted": Boolean(column.sortable && !sorted),
grows: Boolean(column.grows),
};
return html`
<div
class="mdc-data-table__header-cell ${classMap(classes)}"
style=${column.width
? styleMap({
[column.grows ? "minWidth" : "width"]: String(
column.width
),
})
: ""}
role="columnheader"
scope="col"
@click=${this._handleHeaderClick}
.columnId=${key}
>
${column.sortable
? html`
<ha-icon
.icon=${sorted && this._sortDirection === "desc"
? "hass:arrow-down"
: "hass:arrow-up"}
></ha-icon>
`
: ""}
<span>${column.title}</span>
</div>
`;
})}
</div>
<div class="mdc-data-table__content scroller">
${scroll({
items: this._filteredData,
renderItem: (row: DataTableRowData) => html`
<div
.rowId="${row[this.id]}"
@click=${this._handleRowClick}
class="mdc-data-table__row ${classMap({
"mdc-data-table__row--selected": this._checkedRows.includes(
String(row[this.id])
),
})}"
aria-selected=${ifDefined(
this._checkedRows.includes(String(row[this.id]))
? true
: undefined
)}
.selectable=${row.selectable !== false}
>
${this.selectable
? html`
<div
class="mdc-data-table__cell mdc-data-table__cell--checkbox"
>
<ha-checkbox
class="mdc-data-table__row-checkbox"
@change=${this._handleRowCheckboxClick}
.disabled=${row.selectable === false}
.checked=${this._checkedRows.includes(
String(row[this.id])
)}
>
</ha-checkbox>
</div>
`
: ""}
${Object.entries(this.columns).map((columnEntry) => {
const [key, column] = columnEntry;
return html`
<div
class="mdc-data-table__cell ${classMap({
"mdc-data-table__cell--numeric": Boolean(
column.type && column.type === "numeric"
),
"mdc-data-table__cell--icon": Boolean(
column.type && column.type === "icon"
),
grows: Boolean(column.grows),
})}"
style=${column.width
? styleMap({
[column.grows ? "minWidth" : "width"]: String(
column.width
),
})
: ""}
>
${column.template
? column.template(row[key], row)
: row[key]}
</div>
`;
})}
</div>
`,
})}
</div>
</div>
</div>
`;
}
protected createAdapter(): MDCDataTableAdapter {
return {
addClassAtRowIndex: (rowIndex: number, cssClasses: string) => {
if (!(this.rowElements[rowIndex] as any).selectable) {
return;
}
this.rowElements[rowIndex].classList.add(cssClasses);
},
getRowCount: () => this.rowElements.length,
getRowElements: () => this.rowElements,
getRowIdAtIndex: (rowIndex: number) => this._getRowIdAtIndex(rowIndex),
getRowIndexByChildElement: (el: Element) =>
Array.prototype.indexOf.call(this.rowElements, el.closest("tr")),
getSelectedRowCount: () => this._checkedRows.length,
isCheckboxAtRowIndexChecked: (rowIndex: number) =>
this._checkedRows.includes(this._getRowIdAtIndex(rowIndex)),
isHeaderRowCheckboxChecked: () => this._headerChecked,
isRowsSelectable: () => this.selectable,
notifyRowSelectionChanged: () => undefined,
notifySelectedAll: () => undefined,
notifyUnselectedAll: () => undefined,
registerHeaderRowCheckbox: () => undefined,
registerRowCheckboxes: () => undefined,
removeClassAtRowIndex: (rowIndex: number, cssClasses: string) => {
this.rowElements[rowIndex].classList.remove(cssClasses);
},
setAttributeAtRowIndex: (
rowIndex: number,
attr: string,
value: string
) => {
this.rowElements[rowIndex].setAttribute(attr, value);
},
setHeaderRowCheckboxChecked: (checked: boolean) => {
this._headerChecked = checked;
},
setHeaderRowCheckboxIndeterminate: (indeterminate: boolean) => {
this._headerIndeterminate = indeterminate;
},
setRowCheckboxCheckedAtIndex: (rowIndex: number, checked: boolean) => {
if (!(this.rowElements[rowIndex] as any).selectable) {
return;
}
this._setRowChecked(this._getRowIdAtIndex(rowIndex), checked);
},
};
}
private async _filterData() {
const startTime = new Date().getTime();
this.curRequest++;
@@ -373,14 +355,10 @@ export class HaDataTable extends BaseElement {
this._filteredData = data;
}
private _getRowIdAtIndex(rowIndex: number): string {
return this.rowElements[rowIndex].getAttribute("data-row-id")!;
}
private _handleHeaderClick(ev: Event) {
const columnId = (ev.target as HTMLElement)
.closest("th")!
.getAttribute("data-column-id")!;
const columnId = ((ev.target as HTMLElement).closest(
".mdc-data-table__header-cell"
) as any).columnId;
if (!this.columns[columnId].sortable) {
return;
}
@@ -400,19 +378,32 @@ export class HaDataTable extends BaseElement {
});
}
private _handleHeaderRowCheckboxChange(ev: Event) {
private _handleHeaderRowCheckboxClick(ev: Event) {
const checkbox = ev.target as HaCheckbox;
this._headerChecked = checkbox.checked;
this._headerIndeterminate = checkbox.indeterminate;
this.mdcFoundation.handleHeaderRowCheckboxChange();
if (checkbox.checked) {
this._checkedRows = this._filteredData
.filter((data) => data.selectable !== false)
.map((data) => data[this.id]);
this._checkedRowsChanged();
} else {
this._checkedRows = [];
this._checkedRowsChanged();
}
}
private _handleRowCheckboxChange(ev: Event) {
private _handleRowCheckboxClick(ev: Event) {
const checkbox = ev.target as HaCheckbox;
const rowId = checkbox.closest("tr")!.getAttribute("data-row-id");
const rowId = (checkbox.closest(".mdc-data-table__row") as any).rowId;
this._setRowChecked(rowId!, checkbox.checked);
this.mdcFoundation.handleRowCheckboxChange(ev);
if (checkbox.checked) {
if (this._checkedRows.includes(rowId)) {
return;
}
this._checkedRows = [...this._checkedRows, rowId];
} else {
this._checkedRows = this._checkedRows.filter((row) => row !== rowId);
}
this._checkedRowsChanged();
}
private _handleRowClick(ev: Event) {
@@ -420,26 +411,15 @@ export class HaDataTable extends BaseElement {
if (target.tagName === "HA-CHECKBOX") {
return;
}
const rowId = target.closest("tr")!.getAttribute("data-row-id")!;
const rowId = (target.closest(".mdc-data-table__row") as any).rowId;
fireEvent(this, "row-click", { id: rowId }, { bubbles: false });
}
private _setRowChecked(rowId: string, checked: boolean) {
if (checked) {
if (this._checkedRows.includes(rowId)) {
return;
}
this._checkedRows = [...this._checkedRows, rowId];
} else {
const index = this._checkedRows.indexOf(rowId);
if (index === -1) {
return;
}
this._checkedRows.splice(index, 1);
}
private _checkedRowsChanged() {
// force scroller to update, change it's items
this._filteredData = [...this._filteredData];
fireEvent(this, "selection-changed", {
id: rowId,
selected: checked,
value: this._checkedRows,
});
}
@@ -447,15 +427,20 @@ export class HaDataTable extends BaseElement {
this._debounceSearch(ev.detail.value);
}
private async _calcScrollHeight() {
private async _calcTableHeight() {
if (this.autoHeight) {
return;
}
await this.updateComplete;
this._scroller.style.maxHeight = `calc(100% - ${this._header.clientHeight}px)`;
this._table.style.height = `calc(100% - ${this._header.clientHeight}px)`;
}
static get styles(): CSSResult {
return css`
/* default mdc styles, colors changed, without checkbox styles */
:host {
height: 100%;
}
.mdc-data-table__content {
font-family: Roboto, sans-serif;
-moz-osx-font-smoothing: grayscale;
@@ -477,7 +462,7 @@ export class HaDataTable extends BaseElement {
display: inline-flex;
flex-direction: column;
box-sizing: border-box;
overflow-x: auto;
overflow: hidden;
}
.mdc-data-table__row--selected {
@@ -485,12 +470,13 @@ export class HaDataTable extends BaseElement {
}
.mdc-data-table__row {
border-top-color: rgba(var(--rgb-primary-text-color), 0.12);
display: flex;
width: 100%;
height: 52px;
}
.mdc-data-table__row {
border-top-width: 1px;
border-top-style: solid;
.mdc-data-table__row ~ .mdc-data-table__row {
border-top: 1px solid rgba(var(--rgb-primary-text-color), 0.12);
}
.mdc-data-table__row:not(.mdc-data-table__row--selected):hover {
@@ -507,16 +493,24 @@ export class HaDataTable extends BaseElement {
.mdc-data-table__header-row {
height: 56px;
display: flex;
width: 100%;
border-bottom: 1px solid rgba(var(--rgb-primary-text-color), 0.12);
overflow-x: auto;
}
.mdc-data-table__row {
height: 52px;
.mdc-data-table__header-row::-webkit-scrollbar {
display: none;
}
.mdc-data-table__cell,
.mdc-data-table__header-cell {
padding-right: 16px;
padding-left: 16px;
align-self: center;
overflow: hidden;
text-overflow: ellipsis;
flex-shrink: 0;
}
.mdc-data-table__header-cell--checkbox,
@@ -538,10 +532,10 @@ export class HaDataTable extends BaseElement {
}
.mdc-data-table__table {
height: 100%;
width: 100%;
border: 0;
white-space: nowrap;
border-collapse: collapse;
}
.mdc-data-table__cell {
@@ -568,9 +562,29 @@ export class HaDataTable extends BaseElement {
.mdc-data-table__cell--icon {
color: var(--secondary-text-color);
text-align: center;
}
.mdc-data-table__header-cell--icon,
.mdc-data-table__cell--icon {
width: 24px;
}
.mdc-data-table__header-cell.mdc-data-table__header-cell--icon {
text-align: center;
}
.mdc-data-table__header-cell.sortable.mdc-data-table__header-cell--icon:hover,
.mdc-data-table__header-cell.sortable.mdc-data-table__header-cell--icon:not(.not-sorted) {
text-align: left;
}
.mdc-data-table__cell--icon:first-child ha-icon {
margin-left: 8px;
}
.mdc-data-table__cell--icon:first-child state-badge {
margin-right: -8px;
}
.mdc-data-table__header-cell {
font-family: Roboto, sans-serif;
-moz-osx-font-smoothing: grayscale;
@@ -592,16 +606,16 @@ export class HaDataTable extends BaseElement {
.mdc-data-table__header-cell--numeric {
text-align: right;
}
.mdc-data-table__header-cell--numeric.sortable:hover,
.mdc-data-table__header-cell--numeric.sortable:not(.not-sorted) {
text-align: left;
}
[dir="rtl"] .mdc-data-table__header-cell--numeric,
.mdc-data-table__header-cell--numeric[dir="rtl"] {
/* @noflip */
text-align: left;
}
.mdc-data-table__header-cell--icon {
text-align: center;
}
/* custom from here */
:host {
@@ -615,27 +629,33 @@ export class HaDataTable extends BaseElement {
}
.mdc-data-table__header-cell {
overflow: hidden;
position: relative;
}
.mdc-data-table__header-cell span {
position: relative;
left: 0px;
}
.mdc-data-table__header-cell.sortable {
cursor: pointer;
}
.mdc-data-table__header-cell.not-sorted:not(.mdc-data-table__header-cell--numeric):not(.mdc-data-table__header-cell--icon)
span {
position: relative;
left: -24px;
.mdc-data-table__header-cell > * {
transition: left 0.2s ease;
}
.mdc-data-table__header-cell.not-sorted > * {
transition: left 0.2s ease 0s;
.mdc-data-table__header-cell ha-icon {
top: -3px;
position: absolute;
}
.mdc-data-table__header-cell.not-sorted ha-icon {
left: -36px;
left: -20px;
}
.mdc-data-table__header-cell.not-sorted:not(.mdc-data-table__header-cell--numeric):not(.mdc-data-table__header-cell--icon):hover
span {
left: 0px;
.mdc-data-table__header-cell.sortable:not(.not-sorted) span,
.mdc-data-table__header-cell.sortable.not-sorted:hover span {
left: 24px;
}
.mdc-data-table__header-cell:hover.not-sorted ha-icon {
left: 0px;
.mdc-data-table__header-cell.sortable:not(.not-sorted) ha-icon,
.mdc-data-table__header-cell.sortable:hover.not-sorted ha-icon {
left: 12px;
}
.table-header {
border-bottom: 1px solid rgba(var(--rgb-primary-text-color), 0.12);
@@ -644,12 +664,25 @@ export class HaDataTable extends BaseElement {
position: relative;
top: 2px;
}
.scroller {
overflow: auto;
}
slot[name="header"] {
display: block;
}
.center {
text-align: center;
}
.scroller {
display: flex;
position: relative;
contain: strict;
height: calc(100% - 57px);
}
.mdc-data-table__table:not(.auto-height) .scroller {
overflow: auto;
}
.grows {
flex-grow: 1;
flex-shrink: 1;
}
`;
}
}

View File

@@ -5,8 +5,10 @@ import {
LitElement,
property,
TemplateResult,
customElement,
} from "lit-element";
@customElement("ha-card")
class HaCard extends LitElement {
@property() public header?: string;
@@ -70,4 +72,8 @@ class HaCard extends LitElement {
}
}
customElements.define("ha-card", HaCard);
declare global {
interface HTMLElementTagNameMap {
"ha-card": HaCard;
}
}

View File

@@ -1,374 +0,0 @@
import "@polymer/iron-flex-layout/iron-flex-layout-classes";
import { timeOut } from "@polymer/polymer/lib/utils/async";
import { Debouncer } from "@polymer/polymer/lib/utils/debounce";
import { html } from "@polymer/polymer/lib/utils/html-tag";
import { PolymerElement } from "@polymer/polymer/polymer-element";
import "../cards/ha-badges-card";
import "../cards/ha-card-chooser";
import "./ha-demo-badge";
import { computeStateDomain } from "../common/entity/compute_state_domain";
import { splitByGroups } from "../common/entity/split_by_groups";
import { getGroupEntities } from "../common/entity/get_group_entities";
// mapping domain to size of the card.
const DOMAINS_WITH_CARD = {
camera: 4,
history_graph: 4,
media_player: 3,
persistent_notification: 0,
plant: 3,
weather: 4,
};
// 4 types:
// badges: 0 .. 10
// before groups < 0
// groups: X
// rest: 100
const PRIORITY = {
// before groups < 0
configurator: -20,
persistent_notification: -15,
// badges have priority >= 0
updater: 0,
sun: 1,
person: 2,
device_tracker: 3,
alarm_control_panel: 4,
timer: 5,
sensor: 6,
binary_sensor: 7,
mailbox: 8,
};
const getPriority = (domain) => (domain in PRIORITY ? PRIORITY[domain] : 100);
const sortPriority = (domainA, domainB) => domainA.priority - domainB.priority;
const entitySortBy = (entityA, entityB) => {
const nameA = (
entityA.attributes.friendly_name || entityA.entity_id
).toLowerCase();
const nameB = (
entityB.attributes.friendly_name || entityB.entity_id
).toLowerCase();
if (nameA < nameB) {
return -1;
}
if (nameA > nameB) {
return 1;
}
return 0;
};
const iterateDomainSorted = (collection, func) => {
Object.keys(collection)
.map((key) => collection[key])
.sort(sortPriority)
.forEach((domain) => {
domain.states.sort(entitySortBy);
func(domain);
});
};
class HaCards extends PolymerElement {
static get template() {
return html`
<style include="iron-flex iron-flex-factors"></style>
<style>
:host {
display: block;
padding: 4px 4px 0;
transform: translateZ(0);
position: relative;
}
.badges {
font-size: 85%;
text-align: center;
padding-top: 16px;
}
.column {
max-width: 500px;
overflow-x: hidden;
}
ha-card-chooser {
display: block;
margin: 4px 4px 8px;
}
@media (max-width: 500px) {
:host {
padding-left: 0;
padding-right: 0;
}
ha-card-chooser {
margin-left: 0;
margin-right: 0;
}
}
@media (max-width: 599px) {
.column {
max-width: 600px;
}
}
</style>
<div id="main">
<template is="dom-if" if="[[cards.badges.length]]">
<div class="badges">
<template is="dom-if" if="[[cards.demo]]">
<ha-demo-badge></ha-demo-badge>
</template>
<ha-badges-card
states="[[cards.badges]]"
hass="[[hass]]"
></ha-badges-card>
</div>
</template>
<div class="horizontal layout center-justified">
<template is="dom-repeat" items="[[cards.columns]]" as="column">
<div class="column flex-1">
<template is="dom-repeat" items="[[column]]" as="card">
<ha-card-chooser card-data="[[card]]"></ha-card-chooser>
</template>
</div>
</template>
</div>
</div>
`;
}
static get properties() {
return {
hass: Object,
columns: {
type: Number,
value: 2,
},
states: Object,
viewVisible: {
type: Boolean,
value: false,
},
orderedGroupEntities: Array,
cards: Object,
};
}
static get observers() {
return ["updateCards(columns, states, viewVisible, orderedGroupEntities)"];
}
updateCards(columns, states, viewVisible, orderedGroupEntities) {
if (!viewVisible) {
if (this.$.main.parentNode) {
this.$.main._parentNode = this.$.main.parentNode;
this.$.main.parentNode.removeChild(this.$.main);
}
return;
}
if (!this.$.main.parentNode && this.$.main._parentNode) {
this.$.main._parentNode.appendChild(this.$.main);
}
this._debouncer = Debouncer.debounce(
this._debouncer,
timeOut.after(10),
() => {
// Things might have changed since it got scheduled.
if (this.viewVisible) {
this.cards = this.computeCards(columns, states, orderedGroupEntities);
}
}
);
}
emptyCards() {
return {
demo: false,
badges: [],
columns: [],
};
}
computeCards(columns, states, orderedGroupEntities) {
const hass = this.hass;
const cards = this.emptyCards();
const entityCount = [];
for (let i = 0; i < columns; i++) {
cards.columns.push([]);
entityCount.push(0);
}
// Find column with < 5 entities, else column with lowest count
function getIndex(size) {
let minIndex = 0;
for (let i = 0; i < entityCount.length; i++) {
if (entityCount[i] < 5) {
minIndex = i;
break;
}
if (entityCount[i] < entityCount[minIndex]) {
minIndex = i;
}
}
entityCount[minIndex] += size;
return minIndex;
}
function addEntitiesCard(name, entities, groupEntity) {
if (entities.length === 0) return;
const owncard = [];
const other = [];
let size = 0;
entities.forEach((entity) => {
const domain = computeStateDomain(entity);
if (
domain in DOMAINS_WITH_CARD &&
!entity.attributes.custom_ui_state_card
) {
owncard.push(entity);
size += DOMAINS_WITH_CARD[domain];
} else {
other.push(entity);
size++;
}
});
// Add 1 to the size if we're rendering entities card
size += other.length > 0;
const curIndex = getIndex(size);
if (other.length > 0) {
cards.columns[curIndex].push({
hass: hass,
cardType: "entities",
states: other,
groupEntity: groupEntity || false,
});
}
owncard.forEach((entity) => {
cards.columns[curIndex].push({
hass: hass,
cardType: computeStateDomain(entity),
stateObj: entity,
});
});
}
const splitted = splitByGroups(states);
if (orderedGroupEntities) {
splitted.groups.sort(
(gr1, gr2) =>
orderedGroupEntities[gr1.entity_id] -
orderedGroupEntities[gr2.entity_id]
);
} else {
splitted.groups.sort(
(gr1, gr2) => gr1.attributes.order - gr2.attributes.order
);
}
const badgesColl = {};
const beforeGroupColl = {};
const afterGroupedColl = {};
Object.keys(splitted.ungrouped).forEach((key) => {
const state = splitted.ungrouped[key];
const domain = computeStateDomain(state);
if (domain === "a") {
cards.demo = true;
return;
}
const priority = getPriority(domain);
let coll;
if (priority < 0) {
coll = beforeGroupColl;
} else if (priority < 10) {
coll = badgesColl;
} else {
coll = afterGroupedColl;
}
if (!(domain in coll)) {
coll[domain] = {
domain: domain,
priority: priority,
states: [],
};
}
coll[domain].states.push(state);
});
if (orderedGroupEntities) {
Object.keys(badgesColl)
.map((key) => badgesColl[key])
.forEach((domain) => {
cards.badges.push.apply(cards.badges, domain.states);
});
cards.badges.sort(
(e1, e2) =>
orderedGroupEntities[e1.entity_id] -
orderedGroupEntities[e2.entity_id]
);
} else {
iterateDomainSorted(badgesColl, (domain) => {
cards.badges.push.apply(cards.badges, domain.states);
});
}
iterateDomainSorted(beforeGroupColl, (domain) => {
addEntitiesCard(domain.domain, domain.states);
});
splitted.groups.forEach((groupState) => {
const entities = getGroupEntities(states, groupState);
addEntitiesCard(
groupState.entity_id,
Object.keys(entities).map((key) => entities[key]),
groupState
);
});
iterateDomainSorted(afterGroupedColl, (domain) => {
addEntitiesCard(domain.domain, domain.states);
});
// Remove empty columns
cards.columns = cards.columns.filter((val) => val.length > 0);
return cards;
}
}
customElements.define("ha-cards", HaCards);

View File

@@ -8,7 +8,7 @@ import {
customElement,
unsafeCSS,
} from "lit-element";
import { ripple } from "@material/mwc-ripple/ripple-directive";
// @ts-ignore
import chipStyles from "@material/chips/dist/mdc.chips.min.css";
import { fireEvent } from "../common/dom/fire_event";
@@ -33,22 +33,27 @@ export class HaChips extends LitElement {
${this.items.map(
(item, idx) =>
html`
<button
class="mdc-chip"
.index=${idx}
@click=${this._handleClick}
>
<span class="mdc-chip__text">${item}</span>
</button>
<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>
`
)}
</div>
`;
}
private _handleClick(ev) {
private _handleClick(ev): void {
fireEvent(this, "chip-clicked", {
index: ev.target.closest("button").index,
index: ev.currentTarget.index,
});
}

View File

@@ -1,12 +1,23 @@
import { customElement, CSSResult, css } from "lit-element";
import { customElement, CSSResult, css, html } from "lit-element";
import "@polymer/paper-icon-button/paper-icon-button";
import "@material/mwc-dialog";
import { style } from "@material/mwc-dialog/mwc-dialog-css";
// tslint:disable-next-line
import { Dialog } from "@material/mwc-dialog";
import { Constructor } from "../types";
import { Constructor, HomeAssistant } from "../types";
// tslint:disable-next-line
const MwcDialog = customElements.get("mwc-dialog") as Constructor<Dialog>;
export const createCloseHeading = (hass: HomeAssistant, title: string) => html`
${title}
<paper-icon-button
aria-label=${hass.localize("ui.dialogs.generic.close")}
icon="hass:close"
dialogAction="close"
class="close_button"
></paper-icon-button>
`;
@customElement("ha-dialog")
export class HaDialog extends MwcDialog {
protected static get styles(): CSSResult[] {
@@ -19,6 +30,15 @@ export class HaDialog extends MwcDialog {
.mdc-dialog__container {
align-items: var(--vertial-align-dialog, center);
}
.mdc-dialog__title::before {
display: block;
height: 20px;
}
.close_button {
position: absolute;
right: 16px;
top: 12px;
}
`,
];
}

View File

@@ -1,6 +1,6 @@
import { classMap } from "lit-html/directives/class-map";
import { html, customElement } from "lit-element";
import { ripple } from "@material/mwc-ripple/ripple-directive.js";
import { ripple } from "@material/mwc-ripple/ripple-directive";
import "@material/mwc-fab";
import { Constructor } from "../types";

View File

@@ -5,6 +5,8 @@ import {
property,
TemplateResult,
query,
CSSResult,
css,
} from "lit-element";
import {
HaFormElement,
@@ -19,13 +21,14 @@ import "@polymer/paper-input/paper-input";
// tslint:disable-next-line
import { PaperInputElement } from "@polymer/paper-input/paper-input";
import { PaperSliderElement } from "@polymer/paper-slider/paper-slider";
import { HaCheckbox } from "../ha-checkbox";
@customElement("ha-form-integer")
export class HaFormInteger extends LitElement implements HaFormElement {
@property() public schema!: HaFormIntegerSchema;
@property() public data!: HaFormIntegerData;
@property() public label!: string;
@property() public suffix!: string;
@property() public data?: HaFormIntegerData;
@property() public label?: string;
@property() public suffix?: string;
@query("paper-input ha-paper-slider") private _input?: HTMLElement;
public focus() {
@@ -39,20 +42,31 @@ export class HaFormInteger extends LitElement implements HaFormElement {
? html`
<div>
${this.label}
<ha-paper-slider
pin=""
.value=${this._value}
.min=${this.schema.valueMin}
.max=${this.schema.valueMax}
@value-changed=${this._valueChanged}
></ha-paper-slider>
<div class="flex">
${this.schema.optional && this.schema.default === undefined
? html`
<ha-checkbox
@change=${this._handleCheckboxChange}
.checked=${this.data !== undefined}
></ha-checkbox>
`
: ""}
<ha-paper-slider
pin=""
.value=${this._value}
.min=${this.schema.valueMin}
.max=${this.schema.valueMax}
.disabled=${this.data === undefined}
@value-changed=${this._valueChanged}
></ha-paper-slider>
</div>
</div>
`
: html`
<paper-input
type="number"
.label=${this.label}
.value=${this.data}
.value=${this._value}
.required=${this.schema.required}
.autoValidate=${this.schema.required}
@value-changed=${this._valueChanged}
@@ -61,7 +75,14 @@ export class HaFormInteger extends LitElement implements HaFormElement {
}
private get _value() {
return this.data || 0;
return this.data || this.schema.default || 0;
}
private _handleCheckboxChange(ev: Event) {
const checked = (ev.target as HaCheckbox).checked;
fireEvent(this, "value-changed", {
value: checked ? this._value : undefined,
});
}
private _valueChanged(ev: Event) {
@@ -75,6 +96,14 @@ export class HaFormInteger extends LitElement implements HaFormElement {
value,
});
}
static get styles(): CSSResult {
return css`
.flex {
display: flex;
}
`;
}
}
declare global {

View File

@@ -95,7 +95,7 @@ export interface HaFormTimeData {
export interface HaFormElement extends LitElement {
schema: HaFormSchema;
data: HaFormDataContainer | HaFormData;
data?: HaFormDataContainer | HaFormData;
label?: string;
suffix?: string;
}

View File

@@ -0,0 +1,65 @@
import {
html,
css,
LitElement,
TemplateResult,
property,
customElement,
} from "lit-element";
import "@polymer/paper-input/paper-input";
import "./ha-icon";
import { fireEvent } from "../common/dom/fire_event";
@customElement("ha-icon-input")
export class HaIconInput extends LitElement {
@property() public value?: string;
@property() public label?: string;
@property() public placeholder?: string;
@property({ attribute: "error-message" }) public errorMessage?: string;
@property({ type: Boolean }) public disabled = false;
protected render(): TemplateResult {
return html`
<paper-input
.value=${this.value}
.label=${this.label}
.placeholder=${this.placeholder}
@value-changed=${this._valueChanged}
.disabled=${this.disabled}
auto-validate
.errorMessage=${this.errorMessage}
pattern="^\\S+:\\S+$"
>
${this.value || this.placeholder
? html`
<ha-icon .icon=${this.value || this.placeholder} slot="suffix">
</ha-icon>
`
: ""}
</paper-input>
`;
}
private _valueChanged(ev: CustomEvent) {
this.value = ev.detail.value;
fireEvent(
this,
"value-changed",
{ value: ev.detail.value },
{
bubbles: false,
composed: false,
}
);
}
static get styles() {
return css`
ha-icon {
position: relative;
bottom: 4px;
}
`;
}
}

View File

@@ -53,6 +53,7 @@ class HaMarkdown extends UpdatingElement {
node.host !== document.location.host
) {
node.target = "_blank";
node.rel = "noreferrer";
// protect referrer on external links and deny window.opener access for security reasons
// (see https://mathiasbynens.github.io/rel-noopener/)

View File

@@ -18,6 +18,11 @@ class HaPaperSlider extends PaperSliderClass {
line-height: normal;
}
.disabled.ring > .slider-knob > .slider-knob-inner {
background-color: var(--paper-slider-disabled-knob-color, var(--paper-grey-400));
border: 2px solid var(--paper-slider-disabled-knob-color, var(--paper-grey-400));
}
.pin > .slider-knob > .slider-knob-inner::before {
top: unset;
margin-left: unset;

View File

@@ -70,9 +70,7 @@ export class HaRelatedItems extends SubscribeMixin(LitElement) {
}
if (Object.keys(this._related).length === 0) {
return html`
<p>
${this.hass.localize("ui.components.related-items.no_related_found")}
</p>
${this.hass.localize("ui.components.related-items.no_related_found")}
`;
}
return html`

View File

@@ -32,6 +32,7 @@ import { classMap } from "lit-html/directives/class-map";
// tslint:disable-next-line: no-duplicate-imports
import { PaperIconItemElement } from "@polymer/paper-item/paper-icon-item";
import { computeRTL } from "../common/util/compute_rtl";
import { compare } from "../common/string/compare";
const SHOW_AFTER_SPACER = ["config", "developer-tools", "hassio"];
@@ -46,7 +47,21 @@ const SORT_VALUE_URL_PATHS = {
config: 11,
};
const panelSorter = (a, b) => {
const panelSorter = (a: PanelInfo, b: PanelInfo) => {
// Put all the Lovelace at the top.
const aLovelace = a.component_name === "lovelace";
const bLovelace = b.component_name === "lovelace";
if (aLovelace && bLovelace) {
return compare(a.title!, b.title!);
}
if (aLovelace && !bLovelace) {
return -1;
}
if (bLovelace) {
return 1;
}
const aBuiltIn = a.url_path in SORT_VALUE_URL_PATHS;
const bBuiltIn = b.url_path in SORT_VALUE_URL_PATHS;
@@ -60,14 +75,9 @@ const panelSorter = (a, b) => {
return 1;
}
// both not built in, sort by title
if (a.title! < b.title!) {
return -1;
}
if (a.title! > b.title!) {
return 1;
}
return 0;
return compare(a.title!, b.title!);
};
const DEFAULT_PAGE = localStorage.defaultPage || DEFAULT_PANEL;
const computePanels = (hass: HomeAssistant): [PanelInfo[], PanelInfo[]] => {
const panels = hass.panels;
@@ -79,7 +89,7 @@ const computePanels = (hass: HomeAssistant): [PanelInfo[], PanelInfo[]] => {
const afterSpacer: PanelInfo[] = [];
Object.values(panels).forEach((panel) => {
if (!panel.title) {
if (!panel.title || panel.url_path === DEFAULT_PAGE) {
return;
}
(SHOW_AFTER_SPACER.includes(panel.url_path)
@@ -103,8 +113,7 @@ class HaSidebar extends LitElement {
@property({ type: Boolean }) public alwaysExpand = false;
@property({ type: Boolean, reflect: true }) public expanded = false;
@property() public _defaultPage?: string =
localStorage.defaultPage || DEFAULT_PANEL;
@property() private _externalConfig?: ExternalConfig;
@property() private _notifications?: PersistentNotification[];
// property used only in css
@@ -133,6 +142,9 @@ class HaSidebar extends LitElement {
}
}
const defaultPanel =
this.hass.panels[DEFAULT_PAGE] || this.hass.panels[DEFAULT_PANEL];
return html`
<div class="menu">
${!this.narrow
@@ -157,9 +169,9 @@ class HaSidebar extends LitElement {
@keydown=${this._listboxKeydown}
>
${this._renderPanel(
this._defaultPage,
"hass:apps",
hass.localize("panel.states")
defaultPanel.url_path,
defaultPanel.icon || "hass:view-dashboard",
defaultPanel.title || hass.localize("panel.states")
)}
${beforeSpacer.map((panel) =>
this._renderPanel(

View File

@@ -6,6 +6,13 @@ import { afterNextRender } from "../common/util/render-status";
// tslint:disable-next-line
import { HaCodeEditor } from "./ha-code-editor";
declare global {
// for fire event
interface HASSDomEvents {
"editor-refreshed": undefined;
}
}
const isEmpty = (obj: object) => {
if (typeof obj !== "object") {
return false;
@@ -37,6 +44,7 @@ export class HaYamlEditor extends LitElement {
if (this._editor?.codemirror) {
this._editor.codemirror.refresh();
}
afterNextRender(() => fireEvent(this, "editor-refreshed"));
});
}

View File

@@ -41,15 +41,6 @@ export const fetchThumbnailUrl = async (
return hass.hassUrl(path.path);
};
export const fetchThumbnail = (hass: HomeAssistant, entityId: string) => {
// tslint:disable-next-line: no-console
console.warn("This method has been deprecated.");
return hass.callWS<CameraThumbnail>({
type: "camera_thumbnail",
entity_id: entityId,
});
};
export const fetchStreamUrl = async (
hass: HomeAssistant,
entityId: string,

View File

@@ -66,7 +66,15 @@ export const fetchDeviceTriggerCapabilities = (
trigger,
});
const whitelist = ["above", "below", "code", "for"];
const whitelist = [
"above",
"below",
"brightness",
"code",
"for",
"position",
"set_brightness",
];
export const deviceAutomationsEqual = (
a: DeviceAutomation,

View File

@@ -6,14 +6,23 @@ import { debounce } from "../common/util/debounce";
export interface EntityRegistryEntry {
entity_id: string;
name: string;
icon?: string;
platform: string;
config_entry_id?: string;
device_id?: string;
disabled_by: string | null;
}
export interface ExtEntityRegistryEntry extends EntityRegistryEntry {
unique_id: string;
capabilities: object;
original_name?: string;
original_icon?: string;
}
export interface EntityRegistryEntryUpdateParams {
name?: string | null;
icon?: string | null;
disabled_by?: string | null;
new_entity_id?: string;
}
@@ -29,12 +38,21 @@ export const computeEntityRegistryName = (
return state ? computeStateName(state) : null;
};
export const getExtendedEntityRegistryEntry = (
hass: HomeAssistant,
entityId: string
): Promise<ExtEntityRegistryEntry> =>
hass.callWS({
type: "config/entity_registry/get",
entity_id: entityId,
});
export const updateEntityRegistryEntry = (
hass: HomeAssistant,
entityId: string,
updates: Partial<EntityRegistryEntryUpdateParams>
): Promise<EntityRegistryEntry> =>
hass.callWS<EntityRegistryEntry>({
): Promise<ExtEntityRegistryEntry> =>
hass.callWS({
type: "config/entity_registry/update",
entity_id: entityId,
...updates,

View File

@@ -59,3 +59,12 @@ export const getOptimisticFrontendUserDataCollection = <
`_frontendUserData-${userDataKey}`,
() => fetchFrontendUserData(conn, userDataKey)
);
export const subscribeFrontendUserData = <UserDataKey extends ValidUserDataKey>(
conn: Connection,
userDataKey: UserDataKey,
onChange: (state: FrontendUserData[UserDataKey] | null) => void
) =>
getOptimisticFrontendUserDataCollection(conn, userDataKey).subscribe(
onChange
);

View File

@@ -1,11 +0,0 @@
import { HomeAssistant } from "../types";
export const setInputSelectOption = (
hass: HomeAssistant,
entity: string,
option: string
) =>
hass.callService("input_select", "select_option", {
option,
entity_id: entity,
});

43
src/data/input_boolean.ts Normal file
View File

@@ -0,0 +1,43 @@
import { HomeAssistant } from "../types";
export interface InputBoolean {
id: string;
name: string;
icon?: string;
initial?: boolean;
}
export interface InputBooleanMutableParams {
name: string;
icon: string;
initial: boolean;
}
export const fetchInputBoolean = (hass: HomeAssistant) =>
hass.callWS<InputBoolean[]>({ type: "input_boolean/list" });
export const createInputBoolean = (
hass: HomeAssistant,
values: InputBooleanMutableParams
) =>
hass.callWS<InputBoolean>({
type: "input_boolean/create",
...values,
});
export const updateInputBoolean = (
hass: HomeAssistant,
id: string,
updates: Partial<InputBooleanMutableParams>
) =>
hass.callWS<InputBoolean>({
type: "input_boolean/update",
input_boolean_id: id,
...updates,
});
export const deleteInputBoolean = (hass: HomeAssistant, id: string) =>
hass.callWS({
type: "input_boolean/delete",
input_boolean_id: id,
});

View File

@@ -1,5 +1,22 @@
import { HomeAssistant } from "../types";
export interface InputDateTime {
id: string;
name: string;
icon?: string;
initial?: string;
has_time: boolean;
has_date: boolean;
}
export interface InputDateTimeMutableParams {
name: string;
icon: string;
initial: string;
has_time: boolean;
has_date: boolean;
}
export const setInputDateTimeValue = (
hass: HomeAssistant,
entityId: string,
@@ -9,3 +26,32 @@ export const setInputDateTimeValue = (
const param = { entity_id: entityId, time, date };
hass.callService(entityId.split(".", 1)[0], "set_datetime", param);
};
export const fetchInputDateTime = (hass: HomeAssistant) =>
hass.callWS<InputDateTime[]>({ type: "input_datetime/list" });
export const createInputDateTime = (
hass: HomeAssistant,
values: InputDateTimeMutableParams
) =>
hass.callWS<InputDateTime>({
type: "input_datetime/create",
...values,
});
export const updateInputDateTime = (
hass: HomeAssistant,
id: string,
updates: Partial<InputDateTimeMutableParams>
) =>
hass.callWS<InputDateTime>({
type: "input_datetime/update",
input_datetime_id: id,
...updates,
});
export const deleteInputDateTime = (hass: HomeAssistant, id: string) =>
hass.callWS({
type: "input_datetime/delete",
input_datetime_id: id,
});

53
src/data/input_number.ts Normal file
View File

@@ -0,0 +1,53 @@
import { HomeAssistant } from "../types";
export interface InputNumber {
id: string;
name: string;
min: number;
max: number;
icon?: string;
initial?: number;
step?: number;
mode?: "box" | "slider";
unit_of_measurement?: string;
}
export interface InputNumberMutableParams {
name: string;
icon: string;
initial: number;
min: number;
max: number;
step: number;
mode: "box" | "slider";
unit_of_measurement?: string;
}
export const fetchInputNumber = (hass: HomeAssistant) =>
hass.callWS<InputNumber[]>({ type: "input_number/list" });
export const createInputNumber = (
hass: HomeAssistant,
values: InputNumberMutableParams
) =>
hass.callWS<InputNumber>({
type: "input_number/create",
...values,
});
export const updateInputNumber = (
hass: HomeAssistant,
id: string,
updates: Partial<InputNumberMutableParams>
) =>
hass.callWS<InputNumber>({
type: "input_number/update",
input_number_id: id,
...updates,
});
export const deleteInputNumber = (hass: HomeAssistant, id: string) =>
hass.callWS({
type: "input_number/delete",
input_number_id: id,
});

55
src/data/input_select.ts Normal file
View File

@@ -0,0 +1,55 @@
import { HomeAssistant } from "../types";
export interface InputSelect {
id: string;
name: string;
options: string[];
icon?: string;
initial?: string;
}
export interface InputSelectMutableParams {
name: string;
icon: string;
initial: string;
options: string[];
}
export const setInputSelectOption = (
hass: HomeAssistant,
entity: string,
option: string
) =>
hass.callService("input_select", "select_option", {
option,
entity_id: entity,
});
export const fetchInputSelect = (hass: HomeAssistant) =>
hass.callWS<InputSelect[]>({ type: "input_select/list" });
export const createInputSelect = (
hass: HomeAssistant,
values: InputSelectMutableParams
) =>
hass.callWS<InputSelect>({
type: "input_select/create",
...values,
});
export const updateInputSelect = (
hass: HomeAssistant,
id: string,
updates: Partial<InputSelectMutableParams>
) =>
hass.callWS<InputSelect>({
type: "input_select/update",
input_select_id: id,
...updates,
});
export const deleteInputSelect = (hass: HomeAssistant, id: string) =>
hass.callWS({
type: "input_select/delete",
input_select_id: id,
});

View File

@@ -1,7 +1,57 @@
import { HomeAssistant } from "../types";
export interface InputText {
id: string;
name: string;
icon?: string;
initial?: string;
min?: number;
max?: number;
pattern?: string;
mode?: "text" | "password";
}
export interface InputTextMutableParams {
name: string;
icon: string;
initial: string;
min: number;
max: number;
pattern: string;
mode: "text" | "password";
}
export const setValue = (hass: HomeAssistant, entity: string, value: string) =>
hass.callService(entity.split(".", 1)[0], "set_value", {
value,
entity_id: entity,
});
export const fetchInputText = (hass: HomeAssistant) =>
hass.callWS<InputText[]>({ type: "input_text/list" });
export const createInputText = (
hass: HomeAssistant,
values: InputTextMutableParams
) =>
hass.callWS<InputText>({
type: "input_text/create",
...values,
});
export const updateInputText = (
hass: HomeAssistant,
id: string,
updates: Partial<InputTextMutableParams>
) =>
hass.callWS<InputText>({
type: "input_text/update",
input_text_id: id,
...updates,
});
export const deleteInputText = (hass: HomeAssistant, id: string) =>
hass.callWS({
type: "input_text/delete",
input_text_id: id,
});

View File

@@ -1,12 +1,69 @@
import { HomeAssistant } from "../types";
import { Connection, getCollection } from "home-assistant-js-websocket";
import {
Connection,
getCollection,
HassEventBase,
} from "home-assistant-js-websocket";
import { HASSDomEvent } from "../common/dom/fire_event";
export interface LovelacePanelConfig {
mode: "yaml" | "storage";
}
export interface LovelaceConfig {
title?: string;
views: LovelaceViewConfig[];
background?: string;
resources?: Array<{ type: "css" | "js" | "module" | "html"; url: string }>;
}
export interface LegacyLovelaceConfig extends LovelaceConfig {
resources?: LovelaceResource[];
}
export interface LovelaceResource {
id: string;
type: "css" | "js" | "module" | "html";
url: string;
}
export interface LovelaceResourcesMutableParams {
res_type: "css" | "js" | "module" | "html";
url: string;
}
export type LovelaceDashboard =
| LovelaceYamlDashboard
| LovelaceStorageDashboard;
interface LovelaceGenericDashboard {
id: string;
url_path: string;
require_admin: boolean;
show_in_sidebar: boolean;
icon?: string;
title: string;
}
export interface LovelaceYamlDashboard extends LovelaceGenericDashboard {
mode: "yaml";
filename: string;
}
export interface LovelaceStorageDashboard extends LovelaceGenericDashboard {
mode: "storage";
}
export interface LovelaceDashboardMutableParams {
require_admin: boolean;
show_in_sidebar: boolean;
icon?: string;
title: string;
}
export interface LovelaceDashboardCreateParams
extends LovelaceDashboardMutableParams {
url_path: string;
mode: "storage";
}
export interface LovelaceViewConfig {
@@ -95,7 +152,138 @@ export type ActionConfig =
| NoActionConfig
| CustomActionConfig;
type LovelaceUpdatedEvent = HassEventBase & {
event_type: "lovelace_updated";
data: {
url_path: string | null;
mode: "yaml" | "storage";
};
};
export const fetchResources = (conn: Connection): Promise<LovelaceResource[]> =>
conn.sendMessagePromise({
type: "lovelace/resources",
});
export const createResource = (
hass: HomeAssistant,
values: LovelaceResourcesMutableParams
) =>
hass.callWS<LovelaceResource>({
type: "lovelace/resources/create",
...values,
});
export const updateResource = (
hass: HomeAssistant,
id: string,
updates: Partial<LovelaceResourcesMutableParams>
) =>
hass.callWS<LovelaceResource>({
type: "lovelace/resources/update",
resource_id: id,
...updates,
});
export const deleteResource = (hass: HomeAssistant, id: string) =>
hass.callWS({
type: "lovelace/resources/delete",
resource_id: id,
});
export const fetchDashboards = (
hass: HomeAssistant
): Promise<LovelaceDashboard[]> =>
hass.callWS({
type: "lovelace/dashboards/list",
});
export const createDashboard = (
hass: HomeAssistant,
values: LovelaceDashboardCreateParams
) =>
hass.callWS<LovelaceDashboard>({
type: "lovelace/dashboards/create",
...values,
});
export const updateDashboard = (
hass: HomeAssistant,
id: string,
updates: Partial<LovelaceDashboardMutableParams>
) =>
hass.callWS<LovelaceDashboard>({
type: "lovelace/dashboards/update",
dashboard_id: id,
...updates,
});
export const deleteDashboard = (hass: HomeAssistant, id: string) =>
hass.callWS({
type: "lovelace/dashboards/delete",
dashboard_id: id,
});
export const fetchConfig = (
conn: Connection,
urlPath: string | null,
force: boolean
): Promise<LovelaceConfig> =>
conn.sendMessagePromise({
type: "lovelace/config",
url_path: urlPath,
force,
});
export const saveConfig = (
hass: HomeAssistant,
urlPath: string | null,
config: LovelaceConfig
): Promise<void> =>
hass.callWS({
type: "lovelace/config/save",
url_path: urlPath,
config,
});
export const deleteConfig = (
hass: HomeAssistant,
urlPath: string | null
): Promise<void> =>
hass.callWS({
type: "lovelace/config/delete",
url_path: urlPath,
});
export const subscribeLovelaceUpdates = (
conn: Connection,
urlPath: string | null,
onChange: () => void
) =>
conn.subscribeEvents<LovelaceUpdatedEvent>((ev) => {
if (ev.data.url_path === urlPath) {
onChange();
}
}, "lovelace_updated");
export const getLovelaceCollection = (
conn: Connection,
urlPath: string | null = null
) =>
getCollection(
conn,
`_lovelace_${urlPath ?? ""}`,
(conn2) => fetchConfig(conn2, urlPath, false),
(_conn, store) =>
subscribeLovelaceUpdates(conn, urlPath, () =>
fetchConfig(conn, urlPath, false).then((config) =>
store.setState(config, true)
)
)
);
// Legacy functions to support cast for Home Assistion < 0.107
const fetchLegacyConfig = (
conn: Connection,
force: boolean
): Promise<LovelaceConfig> =>
@@ -104,38 +292,27 @@ export const fetchConfig = (
force,
});
export const saveConfig = (
hass: HomeAssistant,
config: LovelaceConfig
): Promise<void> =>
hass.callWS({
type: "lovelace/config/save",
config,
});
export const deleteConfig = (hass: HomeAssistant): Promise<void> =>
hass.callWS({
type: "lovelace/config/delete",
});
export const subscribeLovelaceUpdates = (
const subscribeLegacyLovelaceUpdates = (
conn: Connection,
onChange: () => void
) => conn.subscribeEvents(onChange, "lovelace_updated");
export const getLovelaceCollection = (conn: Connection) =>
export const getLegacyLovelaceCollection = (conn: Connection) =>
getCollection(
conn,
"_lovelace",
(conn2) => fetchConfig(conn2, false),
(conn2) => fetchLegacyConfig(conn2, false),
(_conn, store) =>
subscribeLovelaceUpdates(conn, () =>
fetchConfig(conn, false).then((config) => store.setState(config, true))
subscribeLegacyLovelaceUpdates(conn, () =>
fetchLegacyConfig(conn, false).then((config) =>
store.setState(config, true)
)
)
);
export interface WindowWithLovelaceProm extends Window {
llConfProm?: Promise<LovelaceConfig>;
llResProm?: Promise<LovelaceResource[]>;
}
export interface ActionHandlerOptions {

View File

@@ -1,6 +1,4 @@
import { HomeAssistant } from "../types";
import { timeCachePromiseFunc } from "../common/util/time-cache-function-promise";
import { HassEntity } from "home-assistant-js-websocket";
export const SUPPORT_PAUSE = 1;
export const SUPPORT_SEEK = 2;
@@ -17,30 +15,51 @@ export const SUPPORT_STOP = 4096;
export const SUPPORTS_PLAY = 16384;
export const SUPPORT_SELECT_SOUND_MODE = 65536;
export const OFF_STATES = ["off", "idle"];
export const CONTRAST_RATIO = 3.5;
export interface MediaPlayerThumbnail {
content_type: string;
content: string;
}
export const fetchMediaPlayerThumbnailWithCache = (
hass: HomeAssistant,
entityId: string
) =>
timeCachePromiseFunc(
"_media_playerTmb",
9000,
fetchMediaPlayerThumbnail,
hass,
entityId
);
export const getCurrentProgress = (stateObj: HassEntity): number => {
let progress = stateObj.attributes.media_position;
export const fetchMediaPlayerThumbnail = (
hass: HomeAssistant,
entityId: string
) => {
return hass.callWS<MediaPlayerThumbnail>({
type: "media_player_thumbnail",
entity_id: entityId,
});
if (stateObj.state !== "playing") {
return progress;
}
progress +=
(Date.now() -
new Date(stateObj.attributes.media_position_updated_at).getTime()) /
1000.0;
return progress;
};
export const computeMediaDescription = (stateObj: HassEntity): string => {
let secondaryTitle: string;
switch (stateObj.attributes.media_content_type) {
case "music":
secondaryTitle = stateObj.attributes.media_artist;
break;
case "playlist":
secondaryTitle = stateObj.attributes.media_playlist;
break;
case "tvshow":
secondaryTitle = stateObj.attributes.media_series_title;
if (stateObj.attributes.media_season) {
secondaryTitle += " S" + stateObj.attributes.media_season;
if (stateObj.attributes.media_episode) {
secondaryTitle += "E" + stateObj.attributes.media_episode;
}
}
break;
default:
secondaryTitle = stateObj.attributes.app_name
? stateObj.attributes.app_name
: "";
}
return secondaryTitle;
};

View File

@@ -1 +1,2 @@
export const SENSOR_DEVICE_CLASS_BATTERY = "battery";
export const SENSOR_DEVICE_CLASS_TIMESTAMP = "timestamp";

View File

@@ -2,9 +2,9 @@ import { HomeAssistant } from "../types";
export interface LoggedError {
name: string;
message: string;
message: [string];
level: string;
source: string;
source: [string, number];
// unix timestamp in seconds
timestamp: number;
exception: string;

View File

@@ -38,7 +38,7 @@ class StepFlowExternal extends LitElement {
<div class="content">
${this.flowConfig.renderExternalStepDescription(this.hass, this.step)}
<div class="open-button">
<a href=${this.step.url} target="_blank">
<a href=${this.step.url} target="_blank" rel="noreferrer">
<mwc-button raised>
${localize(
"ui.panel.config.integrations.config_flow.external_step.open_site"

View File

@@ -97,6 +97,7 @@ class StepFlowPickHandler extends LitElement {
)}<a
href="https://www.home-assistant.io/integrations/"
target="_blank"
rel="noreferrer"
>${this.hass.localize(
"ui.panel.config.integrations.home_assistant_website"
)}</a

View File

@@ -129,6 +129,10 @@ class DialogBox extends LitElement {
return [
haStyleDialog,
css`
:host([inert]) {
pointer-events: initial !important;
cursor: initial !important;
}
ha-paper-dialog {
min-width: 400px;
max-width: 500px;

View File

@@ -8,7 +8,6 @@ import "../resources/ha-style";
import "./more-info/more-info-controls";
import { computeStateDomain } from "../common/entity/compute_state_domain";
import { isComponentLoaded } from "../common/config/is_component_loaded";
import DialogMixin from "../mixins/dialog-mixin";
@@ -81,7 +80,6 @@ class HaMoreInfoDialog extends DialogMixin(PolymerElement) {
hass="[[hass]]"
state-obj="[[stateObj]]"
dialog-element="[[_dialogElement()]]"
registry-entry="[[_registryInfo]]"
large="{{large}}"
></more-info-controls>
`;
@@ -102,8 +100,6 @@ class HaMoreInfoDialog extends DialogMixin(PolymerElement) {
observer: "_largeChanged",
},
_registryInfo: Object,
dataDomain: {
computed: "_computeDomain(stateObj)",
reflectToAttribute: true,
@@ -127,11 +123,10 @@ class HaMoreInfoDialog extends DialogMixin(PolymerElement) {
return hass.states[hass.moreInfoEntityId] || null;
}
async _stateObjChanged(newVal, oldVal) {
async _stateObjChanged(newVal) {
if (!newVal) {
this.setProperties({
opened: false,
_registryInfo: null,
large: false,
});
return;
@@ -144,25 +139,6 @@ class HaMoreInfoDialog extends DialogMixin(PolymerElement) {
this.opened = true;
})
);
if (
!isComponentLoaded(this.hass, "config") ||
(oldVal && oldVal.entity_id === newVal.entity_id)
) {
return;
}
if (this.hass.user.is_admin) {
try {
const info = await this.hass.callWS({
type: "config/entity_registry/get",
entity_id: newVal.entity_id,
});
this._registryInfo = info;
} catch (err) {
this._registryInfo = null;
}
}
}
_dialogOpenChanged(newVal) {

View File

@@ -45,7 +45,7 @@ class MoreInfoCamera extends LitElement {
return html`
<ha-camera-stream
.hass="${this.hass}"
.hass=${this.hass}
.stateObj="${this.stateObj}"
showcontrols
></ha-camera-stream>

View File

@@ -39,7 +39,8 @@ class MoreInfoPerson extends LitElement {
></ha-map>
`
: ""}
${this.hass.user?.is_admin &&
${!__DEMO__ &&
this.hass.user?.is_admin &&
this.stateObj.state === "not_home" &&
this.stateObj.attributes.latitude &&
this.stateObj.attributes.longitude

View File

@@ -22,7 +22,7 @@ import LocalizeMixin from "../../mixins/localize-mixin";
import { computeRTL } from "../../common/util/compute_rtl";
import { removeEntityRegistryEntry } from "../../data/entity_registry";
import { showConfirmationDialog } from "../generic/show-dialog-box";
import { showEntityRegistryDetailDialog } from "../../panels/config/entities/show-dialog-entity-registry-detail";
import { showEntityEditorDialog } from "../../panels/config/entities/show-dialog-entity-editor";
const DOMAINS_NO_INFO = ["camera", "configurator", "history_graph"];
const EDITABLE_DOMAINS_WITH_ID = ["scene", "automation"];
@@ -88,7 +88,7 @@ class MoreInfoControls extends LocalizeMixin(EventsMixin(PolymerElement)) {
<div class="main-title" main-title="" on-click="enlarge">
[[_computeStateName(stateObj)]]
</div>
<template is="dom-if" if="[[registryEntry]]">
<template is="dom-if" if="[[_computeConfig(hass)]]">
<paper-icon-button
aria-label$="[[localize('ui.dialogs.more_info_control.settings')]]"
icon="hass:settings"
@@ -221,6 +221,10 @@ class MoreInfoControls extends LocalizeMixin(EventsMixin(PolymerElement)) {
return stateObj ? computeStateName(stateObj) : "";
}
_computeConfig(hass) {
return hass.user.is_admin && isComponentLoaded(hass, "config");
}
_computeEdit(hass, stateObj) {
const domain = this._computeDomain(stateObj);
return (
@@ -260,7 +264,9 @@ class MoreInfoControls extends LocalizeMixin(EventsMixin(PolymerElement)) {
}
_gotoSettings() {
showEntityRegistryDetailDialog(this, { entry: this.registryEntry });
showEntityEditorDialog(this, {
entity_id: this.stateObj.entity_id,
});
this.fire("hass-more-info", { entityId: null });
}

View File

@@ -36,13 +36,13 @@ export class HuiNotificationItem extends LitElement {
return "entity_id" in this.notification
? html`
<configurator-notification-item
.hass="${this.hass}"
.hass=${this.hass}
.notification="${this.notification}"
></configurator-notification-item>
`
: html`
<persistent-notification-item
.hass="${this.hass}"
.hass=${this.hass}
.notification="${this.notification}"
></persistent-notification-item>
`;

View File

@@ -39,7 +39,7 @@ export class HuiPersistentNotificationItem extends LitElement {
<div class="time">
<span>
<ha-relative-time
.hass="${this.hass}"
.hass=${this.hass}
.datetime="${this.notification.created_at}"
></ha-relative-time>
<paper-tooltip

View File

@@ -113,6 +113,7 @@ export class HaVoiceCommandDialog extends LitElement {
class="button"
href="${this._agentInfo.onboarding.url}"
target="_blank"
rel="noreferrer"
><mwc-button unelevated>Yes!</mwc-button></a
>
<mwc-button outlined>No</mwc-button>
@@ -185,6 +186,7 @@ export class HaVoiceCommandDialog extends LitElement {
href=${this._agentInfo.attribution.url}
class="attribution"
target="_blank"
rel="noreferrer"
>${this._agentInfo.attribution.name}</a
>
`

View File

@@ -16,7 +16,12 @@ import { subscribeThemes } from "../data/ws-themes";
import { subscribeUser } from "../data/ws-user";
import { HomeAssistant } from "../types";
import { hassUrl } from "../data/auth";
import { fetchConfig, WindowWithLovelaceProm } from "../data/lovelace";
import { subscribeFrontendUserData } from "../data/frontend";
import {
fetchConfig,
fetchResources,
WindowWithLovelaceProm,
} from "../data/lovelace";
declare global {
interface Window {
@@ -84,9 +89,15 @@ window.hassConnection.then(({ conn }) => {
subscribePanels(conn, noop);
subscribeThemes(conn, noop);
subscribeUser(conn, noop);
subscribeFrontendUserData(conn, "core", noop);
if (location.pathname === "/" || location.pathname.startsWith("/lovelace/")) {
(window as WindowWithLovelaceProm).llConfProm = fetchConfig(conn, false);
(window as WindowWithLovelaceProm).llConfProm = fetchConfig(
conn,
null,
false
);
(window as WindowWithLovelaceProm).llResProm = fetchResources(conn);
}
});

View File

@@ -15,13 +15,6 @@ export const demoPanels: Panels = {
config: null,
url_path: "dev-state",
},
states: {
component_name: "states",
icon: null,
title: null,
config: null,
url_path: "states",
},
"dev-event": {
component_name: "dev-event",
icon: null,
@@ -43,13 +36,6 @@ export const demoPanels: Panels = {
config: null,
url_path: "profile",
},
kiosk: {
component_name: "kiosk",
icon: null,
title: null,
config: null,
url_path: "kiosk",
},
"dev-info": {
component_name: "dev-info",
icon: null,

View File

@@ -135,6 +135,7 @@ export class HaTabsSubpageDataTable extends LitElement {
return css`
ha-data-table {
width: 100%;
height: 100%;
--data-table-border-width: 0;
}
:host(:not([narrow])) ha-data-table {

View File

@@ -15,6 +15,7 @@ import { Route, HomeAssistant } from "../types";
import { navigate } from "../common/navigate";
import "@material/mwc-ripple";
import { isComponentLoaded } from "../common/config/is_component_loaded";
import memoizeOne from "memoize-one";
export interface PageNavigation {
path: string;
@@ -22,7 +23,7 @@ export interface PageNavigation {
component?: string;
name?: string;
core?: boolean;
exportOnly?: boolean;
advancedOnly?: boolean;
icon?: string;
info?: any;
}
@@ -33,12 +34,57 @@ class HassTabsSubpage extends LitElement {
@property({ type: String, attribute: "back-path" }) public backPath?: string;
@property() public backCallback?: () => void;
@property({ type: Boolean }) public hassio = false;
@property({ type: Boolean }) public showAdvanced = false;
@property() public route!: Route;
@property() public tabs!: PageNavigation[];
@property({ type: Boolean, reflect: true }) public narrow = false;
@property() private _activeTab: number = -1;
private _getTabs = memoizeOne(
(
tabs: PageNavigation[],
activeTab: number,
showAdvanced: boolean | undefined,
_components,
_language
) => {
const shownTabs = tabs.filter(
(page) =>
(!page.component ||
page.core ||
isComponentLoaded(this.hass, page.component)) &&
(!page.advancedOnly || showAdvanced)
);
return shownTabs.map(
(page, index) => html`
<div
class="tab ${classMap({
active: index === activeTab,
})}"
@click=${this._tabTapped}
.path=${page.path}
>
${this.narrow
? html`
<ha-icon .icon=${page.icon}></ha-icon>
`
: ""}
${!this.narrow || index === activeTab
? html`
<span class="name"
>${page.translationKey
? this.hass.localize(page.translationKey)
: name}</span
>
`
: ""}
<mwc-ripple></mwc-ripple>
</div>
`
);
}
);
protected updated(changedProperties: PropertyValues) {
super.updated(changedProperties);
if (changedProperties.has("route")) {
@@ -49,6 +95,14 @@ class HassTabsSubpage extends LitElement {
}
protected render(): TemplateResult {
const tabs = this._getTabs(
this.tabs,
this._activeTab,
this.hass.userData?.showAdvanced,
this.hass.config.components,
this.hass.language
);
return html`
<div class="toolbar">
<ha-paper-icon-button-arrow-prev
@@ -61,41 +115,13 @@ class HassTabsSubpage extends LitElement {
<div main-title><slot name="header"></slot></div>
`
: ""}
<div id="tabbar" class=${classMap({ "bottom-bar": this.narrow })}>
${this.tabs.map((page, index) =>
(!page.component ||
page.core ||
isComponentLoaded(this.hass, page.component)) &&
(!page.exportOnly || this.showAdvanced)
? html`
<div
class="tab ${classMap({
active: index === this._activeTab,
})}"
@click=${this._tabTapped}
.path=${page.path}
>
${this.narrow
? html`
<ha-icon .icon=${page.icon}></ha-icon>
`
: ""}
${!this.narrow || index === this._activeTab
? html`
<span class="name"
>${page.translationKey
? this.hass.localize(page.translationKey)
: name}</span
>
`
: ""}
<mwc-ripple></mwc-ripple>
</div>
`
: ""
)}
</div>
${tabs.length > 1 || !this.narrow
? html`
<div id="tabbar" class=${classMap({ "bottom-bar": this.narrow })}>
${tabs}
</div>
`
: ""}
<div id="toolbar-icon">
<slot name="toolbar-icon"></slot>
</div>

View File

@@ -23,7 +23,7 @@ import { AppDrawerLayoutElement } from "@polymer/app-layout/app-drawer-layout/ap
import { showNotificationDrawer } from "../dialogs/notifications/show-notification-drawer";
import { toggleAttribute } from "../common/dom/toggle_attribute";
const NON_SWIPABLE_PANELS = ["kiosk", "map"];
const NON_SWIPABLE_PANELS = ["map"];
declare global {
// for fire event

View File

@@ -8,8 +8,9 @@ import {
RouteOptions,
} from "./hass-router-page";
import { removeInitSkeleton } from "../util/init-skeleton";
import { deepEqual } from "../common/util/deep-equal";
const CACHE_COMPONENTS = ["lovelace", "states", "developer-tools"];
const CACHE_URL_PATHS = ["lovelace", "developer-tools"];
const COMPONENTS = {
calendar: () =>
import(
@@ -31,10 +32,6 @@ const COMPONENTS = {
import(
/* webpackChunkName: "panel-lovelace" */ "../panels/lovelace/ha-panel-lovelace"
),
states: () =>
import(
/* webpackChunkName: "panel-states" */ "../panels/states/ha-panel-states"
),
history: () =>
import(
/* webpackChunkName: "panel-history" */ "../panels/history/ha-panel-history"
@@ -43,10 +40,6 @@ const COMPONENTS = {
import(
/* webpackChunkName: "panel-iframe" */ "../panels/iframe/ha-panel-iframe"
),
kiosk: () =>
import(
/* webpackChunkName: "panel-kiosk" */ "../panels/kiosk/ha-panel-kiosk"
),
logbook: () =>
import(
/* webpackChunkName: "panel-logbook" */ "../panels/logbook/ha-panel-logbook"
@@ -69,11 +62,10 @@ const COMPONENTS = {
const getRoutes = (panels: Panels): RouterOptions => {
const routes: RouterOptions["routes"] = {};
Object.values(panels).forEach((panel) => {
const data: RouteOptions = {
tag: `ha-panel-${panel.component_name}`,
cache: CACHE_COMPONENTS.includes(panel.component_name),
cache: CACHE_URL_PATHS.includes(panel.url_path),
};
if (panel.component_name in COMPONENTS) {
data.load = COMPONENTS[panel.component_name];
@@ -89,7 +81,7 @@ const getRoutes = (panels: Panels): RouterOptions => {
@customElement("partial-panel-resolver")
class PartialPanelResolver extends HassRouterPage {
@property() public hass?: HomeAssistant;
@property() public hass!: HomeAssistant;
@property() public narrow?: boolean;
protected updated(changedProps: PropertyValues) {
@@ -101,11 +93,8 @@ class PartialPanelResolver extends HassRouterPage {
const oldHass = changedProps.get("hass") as this["hass"];
if (
this.hass!.panels &&
(!oldHass || oldHass.panels !== this.hass!.panels)
) {
this._updateRoutes();
if (this.hass.panels && (!oldHass || oldHass.panels !== this.hass.panels)) {
this._updateRoutes(oldHass?.panels);
}
}
@@ -118,7 +107,7 @@ class PartialPanelResolver extends HassRouterPage {
}
protected updatePageEl(el) {
const hass = this.hass!;
const hass = this.hass;
if ("setProperties" in el) {
// As long as we have Polymer panels
@@ -136,11 +125,20 @@ class PartialPanelResolver extends HassRouterPage {
}
}
private async _updateRoutes() {
this.routerOptions = getRoutes(this.hass!.panels);
await this.rebuild();
await this.pageRendered;
removeInitSkeleton();
private async _updateRoutes(oldPanels?: HomeAssistant["panels"]) {
this.routerOptions = getRoutes(this.hass.panels);
if (
!oldPanels ||
!deepEqual(
oldPanels[this._currentPage],
this.hass.panels[this._currentPage]
)
) {
await this.rebuild();
await this.pageRendered;
removeInitSkeleton();
}
}
}

View File

@@ -82,6 +82,10 @@ class NotificationManager extends LitElement {
static get styles(): CSSResult {
return css`
:host {
display: flex;
align-items: center;
}
mwc-button {
color: var(--primary-color);
font-weight: bold;

View File

@@ -10,6 +10,7 @@ import {
import { LitElement, customElement, property, html } from "lit-element";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { HomeAssistant } from "../../../../../types";
import memoizeOne from "memoize-one";
@customElement("ha-automation-action-device_id")
export class HaDeviceAction extends LitElement {
@@ -27,14 +28,20 @@ export class HaDeviceAction extends LitElement {
};
}
private _extraFieldsData = memoizeOne((capabilities, action: DeviceAction) =>
capabilities && capabilities.extra_fields
? capabilities.extra_fields.map((item) => {
return { [item.name]: action[item.name] };
})
: undefined
);
protected render() {
const deviceId = this._deviceId || this.action.device_id;
const extraFieldsData =
this._capabilities && this._capabilities.extra_fields
? this._capabilities.extra_fields.map((item) => {
return { [item.name]: this.action[item.name] };
})
: undefined;
const extraFieldsData = this._extraFieldsData(
this._capabilities,
this.action
);
return html`
<ha-device-picker
@@ -82,10 +89,8 @@ export class HaDeviceAction extends LitElement {
}
private async _getCapabilities() {
const action = this.action;
this._capabilities = action.domain
? await fetchDeviceActionCapabilities(this.hass, action)
this._capabilities = this.action.domain
? await fetchDeviceActionCapabilities(this.hass, this.action)
: null;
}

View File

@@ -26,7 +26,7 @@ export default class HaNumericStateCondition extends LitElement {
<ha-entity-picker
.value="${entity_id}"
@value-changed="${this._entityPicked}"
.hass="${this.hass}"
.hass=${this.hass}
allow-custom-entity
></ha-entity-picker>
<paper-input

View File

@@ -156,6 +156,7 @@ export class HaAutomationEditor extends LitElement {
<a
href="https://home-assistant.io/docs/automation/trigger/"
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.learn_more"
@@ -184,6 +185,7 @@ export class HaAutomationEditor extends LitElement {
<a
href="https://home-assistant.io/docs/scripts/conditions/"
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.automation.editor.conditions.learn_more"
@@ -212,6 +214,7 @@ export class HaAutomationEditor extends LitElement {
<a
href="https://home-assistant.io/docs/automation/action/"
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.automation.editor.actions.learn_more"

View File

@@ -63,6 +63,7 @@ class HaAutomationPicker extends LitElement {
<a
href="https://home-assistant.io/docs/automation/editor/"
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.automation.picker.learn_more"

View File

@@ -123,6 +123,7 @@ class DialogThingtalk extends LitElement {
<a
href="https://almond.stanford.edu/"
target="_blank"
rel="noreferrer"
class="attribution"
>Powered by Almond</a
>

View File

@@ -42,7 +42,7 @@ export default class HaNumericStateTrigger extends LitElement {
<ha-entity-picker
.value="${entity_id}"
@value-changed="${this._entityPicked}"
.hass="${this.hass}"
.hass=${this.hass}
allow-custom-entity
></ha-entity-picker>
<paper-input

View File

@@ -95,11 +95,15 @@ class CloudAccount extends EventsMixin(LocalizeMixin(PolymerElement)) {
</div>
<div class="card-actions">
<a href="https://account.nabucasa.com" target="_blank"
><mwc-button
>[[localize('ui.panel.config.cloud.account.manage_account')]]</mwc-button
></a
<a
href="https://account.nabucasa.com"
target="_blank"
rel="noreferrer"
>
<mwc-button
>[[localize('ui.panel.config.cloud.account.manage_account')]]</mwc-button
>
</a>
<mwc-button style="float: right" on-click="handleLogout"
>[[localize('ui.panel.config.cloud.account.sign_out')]]</mwc-button
>
@@ -117,8 +121,12 @@ class CloudAccount extends EventsMixin(LocalizeMixin(PolymerElement)) {
</p>
<p>
[[localize('ui.panel.config.cloud.account.integrations_introduction2')]]
<a href="https://www.nabucasa.com" target="_blank"
>[[localize('ui.panel.config.cloud.account.integrations_link_all_features')]]</a
<a
href="https://www.nabucasa.com"
target="_blank"
rel="noreferrer"
>
[[localize('ui.panel.config.cloud.account.integrations_link_all_features')]] </a
>.
</p>
</div>

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