├── .eslintrc.js ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── build.yml │ ├── release.yml │ └── validate.yaml ├── .gitignore ├── .prettierrc.js ├── CONTRIBUTE.md ├── LICENSE ├── README.md ├── assets ├── card-editor.png ├── examples │ ├── action-custom-icon.png │ ├── action-custom.png │ ├── action-minimal.png │ ├── climate.gif │ ├── cover.gif │ ├── fan.gif │ ├── general-compact.png │ ├── general-minimal.png │ ├── icon-icon-override.png │ ├── icon-minimal.png │ ├── lock.gif │ ├── media.gif │ ├── slider-force-square.png │ ├── slider-minimal.png │ ├── slider-show-track.png │ ├── slider-state-color.png │ └── switch.gif ├── grid-full-width.png ├── grid-not-square.png ├── preview-2.gif └── preview.gif ├── dist └── slider-button-card.js ├── hacs.json ├── package-lock.json ├── package.json ├── rollup.config.dev.js ├── rollup.config.js ├── src ├── action-handler-directive.ts ├── const.ts ├── controllers │ ├── climate-controller.ts │ ├── controller.ts │ ├── cover-controller.ts │ ├── fan-controller.ts │ ├── get-controller.ts │ ├── input-boolean-controller.ts │ ├── light-controller.ts │ ├── lock-controller.ts │ ├── media-controller.ts │ └── switch-controller.ts ├── editor.ts ├── localize │ ├── languages │ │ ├── de.json │ │ ├── en.json │ │ ├── fr.json │ │ ├── he.json │ │ ├── ko.json │ │ ├── nl.json │ │ ├── pl.json │ │ ├── pt.json │ │ └── ru.json │ └── localize.ts ├── slider-button-card.ts ├── types.ts └── utils.ts └── tsconfig.json /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: "@typescript-eslint/parser", // Specifies the ESLint parser 3 | parserOptions: { 4 | ecmaVersion: 2020, // Allows for the parsing of modern ECMAScript features 5 | sourceType: "module" // Allows for the use of imports 6 | }, 7 | extends: [ 8 | "plugin:@typescript-eslint/recommended" // Uses the recommended rules from the @typescript-eslint/eslint-plugin 9 | ], 10 | rules: { 11 | // Place to specify ESLint rules. Can be used to overwrite rules specified from the extended configs 12 | // e.g. "@typescript-eslint/explicit-function-return-type": "off", 13 | } 14 | }; -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [mattieha] 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | --- 8 | 9 | 14 | 15 | **Checklist:** 16 | 17 | - [ ] I updated to the latest version available 18 | - [ ] I cleared the cache of my browser 19 | 20 | **Release with the issue:** 21 | 22 | **Last working release (if known):** 23 | 24 | **Browser and Operating System:** 25 | 26 | 29 | 30 | **Description of problem:** 31 | 32 | 35 | 36 | **Javascript errors shown in the web inspector (if applicable):** 37 | 38 | ``` 39 | 40 | ``` 41 | 42 | **Additional information:** 43 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: feature request 6 | assignees: '' 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: 'Build' 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | build: 13 | name: Test build 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v1 17 | - name: Build 18 | run: | 19 | npm install 20 | npm run build 21 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | release: 9 | name: Prepare release 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v1 13 | 14 | # Build 15 | - name: Build the file 16 | run: | 17 | cd /home/runner/work/slider-button-card/slider-button-card 18 | npm install 19 | npm run build 20 | 21 | # Upload build file to the releas as an asset. 22 | - name: Upload zip to release 23 | uses: svenstaro/upload-release-action@v1-release 24 | 25 | with: 26 | repo_token: ${{ secrets.GITHUB_TOKEN }} 27 | file: /home/runner/work/slider-button-card/slider-button-card/dist/slider-button-card.js 28 | asset_name: slider-button-card.js 29 | tag: ${{ github.ref }} 30 | overwrite: true 31 | -------------------------------------------------------------------------------- /.github/workflows/validate.yaml: -------------------------------------------------------------------------------- 1 | name: Validate 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | 9 | jobs: 10 | validate: 11 | runs-on: "ubuntu-latest" 12 | steps: 13 | - uses: "actions/checkout@v2" 14 | - name: HACS validation 15 | uses: "hacs/action@main" 16 | with: 17 | CATEGORY: "plugin" 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /.rpt2_cache/ 3 | /.idea/ 4 | /.vscode/ 5 | /.devcontainer/ 6 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: true, 3 | trailingComma: 'all', 4 | singleQuote: true, 5 | printWidth: 120, 6 | tabWidth: 2, 7 | }; -------------------------------------------------------------------------------- /CONTRIBUTE.md: -------------------------------------------------------------------------------- 1 | # Contributing to the Slider button card by 2 | 3 | Thanks for taking the time to contribute to this card. 4 | 5 | ## Adding a new translation 6 | Steps for adding a new translation: 7 | ### Json translation file 8 | Translation files are located under `src/localize/languages` 9 | 1. Make a copy of `en.json` 10 | 2. Rename it to your two characters language `xx.json` 11 | 3. Translate only the values 12 | ### Make it available 13 | For the card to be able to use the translation it needs to be added to `src/localize/localize.ts` 14 | 1. Add an import statement: `import * as xx from './languages/xx.json';` 15 | 2. Add it to the language variable 16 | ```` typescript 17 | const languages: any = { 18 | en: en, 19 | he: he, 20 | nl: nl, 21 | pl: pl, 22 | ru: ru, 23 | xx: xx, // Add alphabeticly (without this comment) 24 | }; 25 | ```` 26 | 27 | ### Update Readme 28 | Add your translation to the `README.md` under the languages section: 29 | ```markdown 30 | - English 31 | - Hebrew 32 | - Nederlands (Dutch) 33 | - Polish (polski) 34 | - Russian 35 | - Your new language 36 | ``` 37 | 38 | ### Create a PR 39 | Commit your changes and create a [Pull Request](https://github.com/mattieha/slider-button-card/pulls) 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Custom cards for Home Assistant 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Slider button card by [@mattieha](https://www.github.com/mattieha) 2 | [![GitHub Release][releases-shield]][releases] 3 | [![hacs_badge](https://img.shields.io/badge/HACS-default-orange.svg?style=for-the-badge)](https://github.com/custom-components/hacs) 4 | 5 | A button card with integrated slider for `light, switch, fan, cover, input_boolean, media_player, climate, lock` entities. 6 | 7 | ![Preview][preview] 8 | ![Preview 2][preview-2] 9 | 10 | #### Please ⭐️ this repo if you find it useful 11 | 12 | ## TOC 13 | - [Installation](#installation) 14 | - [HACS](#hacs) 15 | - [Manual](#manual) 16 | - [Configuration](#configuration) 17 | - [Visual Editor](#visual-editor) 18 | - [Options](#options) 19 | - [Icon options](#icon-options) 20 | - [Slider options](#slider-options) 21 | - [Action button options](#action-button-options) 22 | - [Tap action](#action-options) 23 | - [Styles](#styles) 24 | - [Examples](#examples) 25 | - [Minimal working config](#minimal-working-config) 26 | - [Per feature](#per-feature) 27 | - [General](#general) 28 | - [Icon](#icon) 29 | - [Action button](#action-button) 30 | - [Slider](#slider) 31 | - [Full examples](#full-examples) 32 | - [Fan](#fan) 33 | - [Switch](#switch) 34 | - [Cover](#cover) 35 | - [Media player](#media-player) 36 | - [Climate](#climate) 37 | - [Lock](#lock) 38 | - [In a grid](#grid) 39 | - [Group support](#groups) 40 | - [Known issues](#known-issues) 41 | - [Languages](#languages) 42 | - [Credits](#credits) 43 | 44 | ## Installation 45 | 46 | ### HACS 47 | This card is available in [HACS][hacs] (Home Assistant Community Store). 48 | Just search for `Slider Button Card` in Frontend tab. 49 | 50 | ### Manual 51 | 52 | 1. Download `slider-button-card.js` file from the [latest-release]. 53 | 2. Put `slider-button-card.js` file into your `config/www` folder. 54 | 3. Go to _Configuration_ → _Lovelace Dashboards_ → _Resources_ → Click Plus button → Set _Url_ as `/local/slider-button-card.js` → Set _Resource type_ as `JavaScript Module`. 55 | 4. Add `custom:slider-button-card` to Lovelace UI as any other card (using either editor or YAML configuration). 56 | 57 | ## Configuration 58 | 59 | ### Visual Editor 60 | 61 | Slider Button Card supports Lovelace's Visual Editor. 62 |
63 | Show screenshot 64 | 65 | ![Visual Editor][visual-editor] 66 |
67 | 68 | 69 | ### Options 70 | 71 | | Name | Type | Requirement | Description | Default | 72 | | ----------------- | ------- | ------------ | ------------------------------------------- | ------------------- | 73 | | type | string | **Required** | `custom:slider-button-card` | 74 | | entity | string | **Required** | HA entity ID from domain `light, switch, fan, cover, input_boolean, media_player, climate, lock` | | 75 | | name | string | **Optional** | Name | `entity.friendly_name` | 76 | | show_name | boolean | **Optional** | Show name | `true` | 77 | | show_state | boolean | **Optional** | Show state | `true` | 78 | | compact | boolean | **Optional** | Compact mode, display name and state inline with icon. Useful for full width cards. | `false` | 79 | | icon | object | **Optional** | [Icon options](#icon-options) | | 80 | | slider | object | **Optional** | [Slider options](#slider-options) | | 81 | | action_button | object | **Optional** | [Action button options](#action-button-options) | | 82 | 83 | ### Icon Options 84 | 85 | | Name | Type | Requirement | Description | Default | 86 | | ----------------- | ------- | ------------ | ------------------------------------------- | ------------------- | 87 | | icon | string | **Optional** | Icon | `default entity icon` | 88 | | show | boolean | **Optional** | Show icon | `true` | 89 | | use_state_color | boolean | **Optional** | Use state color | `true` | 90 | | tap_action | object | **Optional** | [Action](#action-options) to take on tap | `action: more-info` | 91 | 92 | ### Slider Options 93 | 94 | | Name | Type | Requirement | Description | Default | 95 | | ----------------- | ------- | ------------ | ------------------------------------------- | ------------------- | 96 | | direction | string | **Optional** | Direction `left-right, top-bottom, bottom-top` | `left-right` | 97 | | background | string | **Optional** | Background `solid, gradient, triangle, striped, custom` | `gradient` | 98 | | use_state_color | boolean | **Optional** | Use state color | `true` | 99 | | use_percentage_bg_opacity | boolean | **Optional** | Apply opacity to background based on percentage | `true` | 100 | | show_track | boolean | **Optional** | Show track when state is on | `false` | 101 | | force_square | boolean | **Optional** | Force the button as a square | `false` | 102 | | toggle_on_click | boolean | **Optional** | Force the slider to act as a toggle, if `true` sliding is disabled | `false` | 103 | | attribute | string | **Optional** | Control an [attribute](#attributes) for `light` or `cover` entities | | 104 | | invert | boolean | **Optional** | Invert calculation of state and percentage, useful for `cover` entities | `false`
`true` for `cover` | 105 | 106 | ### Attributes 107 | Light: 108 | - `brightness_pct` **default** 109 | - `brightness` 110 | - `color_temp` 111 | - `hue` 112 | - `saturation` 113 | 114 | _Warning options other than `brightness_pct` and `brightness` may give strange results_ 115 | 116 | For example when `color_temp` is selected as attribute and the current `color_mode` of the light is **not** `color_temp` there is no value available for the slider, so the min value will be displayed. Same for `hue` and `saturation`, slider will only show correct value when the `color_mode` is `hs`. 117 | 118 | Cover: 119 | - `position` **default** 120 | - `tilt` 121 | ### Action button Options 122 | 123 | | Name | Type | Requirement | Description | Default | 124 | | ----------------- | ------- | ------------ | ------------------------------------------- | ------------------- | 125 | | mode | string | **Optional** | Mode `toggle, custom` | `toggle` | 126 | | show | boolean | **Optional** | Show the action button | `true` | 127 | | icon | string | **Optional** | Icon when mode is `custom` | `mdi:power` | 128 | | show_spinner | boolean | **Optional** | Show spinner when mode is `custom` | `true` | 129 | | tap_action | object | **Optional** | [Action](#action-options) to take on tap | `action: toggle` | 130 | 131 | ### Action Options 132 | 133 | | Name | Type | Requirement | Description | Default | 134 | | --------------- | ------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------- | ----------- | 135 | | action | string | **Required** | Action to perform (more-info, toggle, call-service, navigate url, none) | `more-info` | 136 | | navigation_path | string | **Optional** | Path to navigate to (e.g. /lovelace/0/) when action defined as navigate | `none` | 137 | | url | string | **Optional** | URL to open on click when action is url. The URL will open in a new tab | `none` | 138 | | service | string | **Optional** | Service to call (e.g. media_player.media_play_pause) when action defined as call-service | `none` | 139 | | service_data | object | **Optional** | Service data to include (e.g. entity_id: media_player.bedroom) when action defined as call-service | `none` | 140 | | haptic | string | **Optional** | Haptic feedback for the [Beta IOS App](http://home-assistant.io/ios/beta) _success, warning, failure, light, medium, heavy, selection_ | `none` | 141 | | repeat | number | **Optional** | How often to repeat the `hold_action` in milliseconds. | `non` | 142 | ### Styles 143 | Custom styles can be set by using [Card mod](https://github.com/thomasloven/lovelace-card-mod) 144 | ```yaml 145 | style: | 146 | :host { 147 | --VARIABLE: VALUE; 148 | } 149 | ``` 150 | 151 | | Variable | Description | Default | 152 | | ----------------------- | ------------------------------------------- | ------------------- | 153 | | `--icon-color` | Color of the icon when `icon.use_state_color === false` | `var(--paper-item-icon-color)` | 154 | | `--label-color-on` | Color of the label when state is on | `var(--primary-text-color, white)` | 155 | | `--label-color-off` | Color of the label when state is off | `var(--primary-text-color, white)` | 156 | | `--state-color-on` | Color of the state value when state is on | `var(--label-badge-text-color, white)` | 157 | | `--state-color-off` | Color of the state value when state is off | `var(--disabled-text-color)` | 158 | | `--action-icon-color-on` | Color of the action button icon when state is on | `var(--paper-item-icon-color, black)` | 159 | | `--action-icon-color-off` | Color of the action button icon when state is off | `var(--paper-item-icon-color, black)` | 160 | | `--action-spinner-color` | Color of the spinner action button | `var(--label-badge-text-color, white)` | 161 | 162 | ## Examples 163 | 164 | ### Minimal working config 165 | 166 | 167 | 168 | 170 | 171 | 172 | 174 | 189 | 190 |
Minimal working config 169 |
173 | 175 | 176 | ```yaml 177 | type: custom:slider-button-card 178 | entity: light.couch 179 | slider: 180 | direction: left-right 181 | background: gradient 182 | icon: 183 | tap_action: 184 | action: more-info 185 | action_button: 186 | mode: toggle 187 | ``` 188 |
191 | 192 | ### Per feature 193 | 194 | #### General 195 | 196 | 197 | 198 | 199 | 201 | 202 | 203 | 205 | 211 | 212 |
Compact, best used in full width (not in grid) 200 |
204 | 206 | 207 | ```yaml 208 | compact: true 209 | ``` 210 |
213 | 214 | #### Icon 215 | 216 | 217 | 218 | 219 | 221 | 222 | 223 | 225 | 233 | 234 |
Minimal config 220 |
224 | 226 | 227 | ```yaml 228 | icon: 229 | tap_action: 230 | action: more-info 231 | ``` 232 |
235 | 236 | 237 | 238 | 239 | 241 | 242 | 243 | 245 | 254 | 255 |
Icon override 240 |
244 | 246 | 247 | ```yaml 248 | icon: 249 | icon: mdi:lightbulb 250 | tap_action: 251 | action: more-info 252 | ``` 253 |
256 | 257 | 258 | #### Action button 259 | 260 | 261 | 262 | 263 | 265 | 266 | 267 | 269 | 277 | 278 |
Minimal config 264 |
268 | 270 | 271 | ```yaml 272 | action_button: 273 | mode: toggle 274 | show: true 275 | ``` 276 |
279 | 280 | 281 | 282 | 283 | 285 | 286 | 287 | 289 | 299 | 300 |
Custom 284 |
288 | 290 | 291 | ```yaml 292 | action_button: 293 | mode: custom 294 | show: true 295 | tap_action: 296 | action: toggle 297 | ``` 298 |
301 | 302 | 303 | 304 | 305 | 307 | 308 | 309 | 311 | 325 | 326 |
Custom icon and tap action 306 |
310 | 312 | 313 | ```yaml 314 | action_button: 315 | mode: custom 316 | show: true 317 | icon: mdi:palette 318 | tap_action: 319 | action: call-service 320 | service: scene.turn_on 321 | service_data: 322 | entity_id: scene.test 323 | ``` 324 |
327 | 328 | #### Slider 329 | 330 | 331 | 332 | 333 | 335 | 336 | 337 | 339 | 347 | 348 |
Minimal config 334 |
338 | 340 | 341 | ```yaml 342 | slider: 343 | direction: left-right 344 | background: gradient 345 | ``` 346 |
349 | 350 | 351 | 352 | 353 | 355 | 356 | 357 | 359 | 368 | 369 |
Background uses color or color_temp if available 354 |
358 | 360 | 361 | ```yaml 362 | slider: 363 | direction: left-right 364 | background: gradient 365 | use_state_color: true 366 | ``` 367 |
370 | 371 | 372 | 373 | 374 | 376 | 377 | 378 | 380 | 390 | 391 |
Show track, best used in full width or triangle 375 |
379 | 381 | 382 | ```yaml 383 | slider: 384 | direction: left-right 385 | background: triangle 386 | use_state_color: true 387 | show_track: true 388 | ``` 389 |
392 | 393 | 394 | 395 | 396 | 398 | 399 | 400 | 402 | 413 | 414 |
Force square 397 |
401 | 403 | 404 | ```yaml 405 | slider: 406 | direction: left-right 407 | background: triangle 408 | use_state_color: true 409 | show_track: true 410 | force_square: true 411 | ``` 412 |
415 | 416 | 417 | 418 | ### Full examples 419 | #### Fan 420 | For fan entities the icon auto rotates based on the speed of the fan. 421 | 422 | 423 | 424 | 426 | 427 | 428 | 430 | 449 | 450 |
Icon rotate animation 425 |
429 | 431 | 432 | ```yaml 433 | type: custom:slider-button-card 434 | entity: fan.living_fan 435 | slider: 436 | direction: left-right 437 | background: triangle 438 | show_track: true 439 | icon: 440 | tap_action: 441 | action: more-info 442 | action_button: 443 | tap_action: 444 | action: toggle 445 | mode: custom 446 | name: Fan 447 | ``` 448 |
451 | 452 | #### Switch 453 | Use `slider.toggle_on_click: true` so the slider acts as a toggle (sliding is disabled). 454 | 455 | 456 | 457 | 459 | 460 | 461 | 463 | 483 | 484 |
Toggle on click 458 |
462 | 464 | 465 | ```yaml 466 | type: custom:slider-button-card 467 | entity: switch.socket 468 | slider: 469 | direction: left-right 470 | background: custom 471 | toggle_on_click: true 472 | icon: 473 | use_state_color: true 474 | tap_action: 475 | action: more-info 476 | action_button: 477 | tap_action: 478 | action: toggle 479 | mode: custom 480 | name: Switch 481 | ``` 482 |
485 | 486 | #### Cover 487 | For most use cases: set `slider.direction: top-bottom` and `slider.background: striped`; 488 | 489 | 490 | 491 | 493 | 494 | 495 | 497 | 517 | 518 |
Direction top to bottom, custom action icon 492 |
496 | 498 | 499 | ```yaml 500 | type: custom:slider-button-card 501 | entity: cover.living_cover 502 | slider: 503 | direction: top-bottom 504 | background: striped 505 | icon: 506 | show: true 507 | tap_action: 508 | action: more-info 509 | action_button: 510 | tap_action: 511 | action: toggle 512 | mode: custom 513 | icon: mdi:swap-vertical 514 | name: Cover 515 | ``` 516 |
519 | 520 | #### Media player 521 | Default behavior: slider is used for volume control, when there is an entity picture it will be used instead of the icon. 522 | In this example the action button is used to toggle play/pause. 523 | 524 | 525 | 526 | 528 | 529 | 530 | 532 | 556 | 557 |
Action button to toggle play/pause 527 |
531 | 533 | 534 | ```yaml 535 | type: custom:slider-button-card 536 | entity: media_player.spotify_mha 537 | slider: 538 | direction: left-right 539 | background: triangle 540 | show_track: true 541 | icon: 542 | tap_action: 543 | action: more-info 544 | action_button: 545 | mode: custom 546 | icon: mdi:play-pause 547 | tap_action: 548 | action: call-service 549 | service: media_player.media_play_pause 550 | service_data: 551 | entity_id: media_player.spotify_mha 552 | name: Media 553 | 554 | ``` 555 |
558 | 559 | #### Climate 560 | Default behavior: slider is used to set target temperature, it doesn't alter state. 561 | 562 | 563 | 564 | 566 | 567 | 568 | 570 | 590 | 591 |
Target temperature and state disabled in card 565 |
569 | 571 | 572 | ```yaml 573 | type: custom:slider-button-card 574 | entity: climate.harmony_climate_controller 575 | slider: 576 | direction: left-right 577 | background: triangle 578 | show_track: true 579 | icon: 580 | tap_action: 581 | action: more-info 582 | action_button: 583 | mode: custom 584 | tap_action: 585 | action: toggle 586 | name: Airco 587 | 588 | ``` 589 |
592 | 593 | #### Lock 594 | Default behavior: `slider.toggle_on_click: true` 595 | 596 | 597 | 598 | 600 | 601 | 602 | 604 | 622 | 623 |
Action button hidden 599 |
603 | 605 | 606 | ```yaml 607 | type: custom:slider-button-card 608 | entity: lock.virtual_lock 609 | slider: 610 | direction: left-right 611 | background: solid 612 | toggle_on_click: true 613 | icon: 614 | use_state_color: true 615 | tap_action: 616 | action: more-info 617 | action_button: 618 | show: false 619 | name: Lock 620 | ``` 621 |
624 | 625 | #### Grid 626 | 627 | 628 | 629 | 630 | 632 | 633 | 634 | 636 | 702 | 703 |
4 columns, square: false 631 |
635 | 637 | 638 | ```yaml 639 | type: grid 640 | cards: 641 | - type: custom:slider-button-card 642 | entity: light.couch 643 | slider: 644 | direction: left-right 645 | background: gradient 646 | use_state_color: true 647 | icon: 648 | tap_action: 649 | action: more-info 650 | use_state_color: true 651 | action_button: 652 | mode: toggle 653 | - type: custom:slider-button-card 654 | entity: switch.socket 655 | slider: 656 | direction: left-right 657 | background: custom 658 | toggle_on_click: true 659 | icon: 660 | use_state_color: true 661 | tap_action: 662 | action: more-info 663 | action_button: 664 | tap_action: 665 | action: toggle 666 | mode: toggle 667 | name: Switch 668 | - type: custom:slider-button-card 669 | entity: fan.living_fan 670 | slider: 671 | direction: left-right 672 | background: triangle 673 | show_track: true 674 | icon: 675 | tap_action: 676 | action: more-info 677 | action_button: 678 | tap_action: 679 | action: toggle 680 | mode: custom 681 | name: Fan 682 | - type: custom:slider-button-card 683 | entity: cover.living_cover 684 | slider: 685 | direction: top-bottom 686 | background: striped 687 | icon: 688 | show: true 689 | tap_action: 690 | action: more-info 691 | action_button: 692 | tap_action: 693 | action: toggle 694 | mode: custom 695 | icon: mdi:swap-vertical 696 | name: Cover 697 | square: false 698 | columns: 4 699 | 700 | ``` 701 |
704 | 705 | ## Groups 706 | Mixed `group` entities are not supported, if you want to control multiple 707 | - lights use [Light group](https://www.home-assistant.io/integrations/light.group/) 708 | - covers use [Cover group](https://www.home-assistant.io/integrations/cover.group/) 709 | - media players use [Media player group](https://www.home-assistant.io/integrations/media_player.group/) 710 | 711 | ## Known issues 712 | When you discover any bugs please open an [issue](https://github.com/mattieha/slider-button-card/issues). 713 | 714 | ## Languages 715 | 716 | This card supports translations. Please, help to add more translations and improve existing ones. Here's a list of supported languages: 717 | 718 | - English 719 | - French 720 | - German 721 | - Hebrew 722 | - Nederlands (Dutch) 723 | - Polish (polski) 724 | - Portuguese 725 | - Russian 726 | - Korean 727 | - [_Your language?_][add-translation] 728 | 729 | ## Credits 730 | - Inspired by [Slider entity row](https://github.com/thomasloven/lovelace-slider-entity-row) 731 | 732 | --- 733 | [![beer](https://www.buymeacoffee.com/assets/img/custom_images/black_img.png)](https://www.buymeacoffee.com/mattijsha) 734 | 735 | 736 | [hacs]: https://hacs.xyz 737 | [add-translation]: https://github.com/mattieha/slider-button-card/blob/main/CONTRIBUTE.md#adding-a-new-translation 738 | [visual-editor]: https://raw.githubusercontent.com/mattieha/slider-button-card/main/assets/card-editor.png 739 | [preview]: https://raw.githubusercontent.com/mattieha/slider-button-card/main/assets/preview.gif 740 | [preview-2]: https://raw.githubusercontent.com/mattieha/slider-button-card/main/assets/preview-2.gif 741 | [grid]: https://raw.githubusercontent.com/mattieha/slider-button-card/main/assets/grid-not-square.png 742 | [full-width]: https://raw.githubusercontent.com/mattieha/slider-button-card/main/assets/grid-full-width.png 743 | [latest-release]: https://github.com/mattieha/slider-button-card/releases/latest 744 | [releases-shield]: https://img.shields.io/github/release/mattieha/slider-button-card.svg?style=for-the-badge 745 | [releases]: https://github.com/mattieha/slider-button-card/releases 746 | [icon-minimal]: https://raw.githubusercontent.com/mattieha/slider-button-card/main/assets/grid-full-width.png 747 | -------------------------------------------------------------------------------- /assets/card-editor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattieha/slider-button-card/877bc94e55b14c1dea0e1e419fb60255a4b1e9a3/assets/card-editor.png -------------------------------------------------------------------------------- /assets/examples/action-custom-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattieha/slider-button-card/877bc94e55b14c1dea0e1e419fb60255a4b1e9a3/assets/examples/action-custom-icon.png -------------------------------------------------------------------------------- /assets/examples/action-custom.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattieha/slider-button-card/877bc94e55b14c1dea0e1e419fb60255a4b1e9a3/assets/examples/action-custom.png -------------------------------------------------------------------------------- /assets/examples/action-minimal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattieha/slider-button-card/877bc94e55b14c1dea0e1e419fb60255a4b1e9a3/assets/examples/action-minimal.png -------------------------------------------------------------------------------- /assets/examples/climate.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattieha/slider-button-card/877bc94e55b14c1dea0e1e419fb60255a4b1e9a3/assets/examples/climate.gif -------------------------------------------------------------------------------- /assets/examples/cover.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattieha/slider-button-card/877bc94e55b14c1dea0e1e419fb60255a4b1e9a3/assets/examples/cover.gif -------------------------------------------------------------------------------- /assets/examples/fan.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattieha/slider-button-card/877bc94e55b14c1dea0e1e419fb60255a4b1e9a3/assets/examples/fan.gif -------------------------------------------------------------------------------- /assets/examples/general-compact.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattieha/slider-button-card/877bc94e55b14c1dea0e1e419fb60255a4b1e9a3/assets/examples/general-compact.png -------------------------------------------------------------------------------- /assets/examples/general-minimal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattieha/slider-button-card/877bc94e55b14c1dea0e1e419fb60255a4b1e9a3/assets/examples/general-minimal.png -------------------------------------------------------------------------------- /assets/examples/icon-icon-override.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattieha/slider-button-card/877bc94e55b14c1dea0e1e419fb60255a4b1e9a3/assets/examples/icon-icon-override.png -------------------------------------------------------------------------------- /assets/examples/icon-minimal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattieha/slider-button-card/877bc94e55b14c1dea0e1e419fb60255a4b1e9a3/assets/examples/icon-minimal.png -------------------------------------------------------------------------------- /assets/examples/lock.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattieha/slider-button-card/877bc94e55b14c1dea0e1e419fb60255a4b1e9a3/assets/examples/lock.gif -------------------------------------------------------------------------------- /assets/examples/media.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattieha/slider-button-card/877bc94e55b14c1dea0e1e419fb60255a4b1e9a3/assets/examples/media.gif -------------------------------------------------------------------------------- /assets/examples/slider-force-square.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattieha/slider-button-card/877bc94e55b14c1dea0e1e419fb60255a4b1e9a3/assets/examples/slider-force-square.png -------------------------------------------------------------------------------- /assets/examples/slider-minimal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattieha/slider-button-card/877bc94e55b14c1dea0e1e419fb60255a4b1e9a3/assets/examples/slider-minimal.png -------------------------------------------------------------------------------- /assets/examples/slider-show-track.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattieha/slider-button-card/877bc94e55b14c1dea0e1e419fb60255a4b1e9a3/assets/examples/slider-show-track.png -------------------------------------------------------------------------------- /assets/examples/slider-state-color.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattieha/slider-button-card/877bc94e55b14c1dea0e1e419fb60255a4b1e9a3/assets/examples/slider-state-color.png -------------------------------------------------------------------------------- /assets/examples/switch.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattieha/slider-button-card/877bc94e55b14c1dea0e1e419fb60255a4b1e9a3/assets/examples/switch.gif -------------------------------------------------------------------------------- /assets/grid-full-width.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattieha/slider-button-card/877bc94e55b14c1dea0e1e419fb60255a4b1e9a3/assets/grid-full-width.png -------------------------------------------------------------------------------- /assets/grid-not-square.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattieha/slider-button-card/877bc94e55b14c1dea0e1e419fb60255a4b1e9a3/assets/grid-not-square.png -------------------------------------------------------------------------------- /assets/preview-2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattieha/slider-button-card/877bc94e55b14c1dea0e1e419fb60255a4b1e9a3/assets/preview-2.gif -------------------------------------------------------------------------------- /assets/preview.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattieha/slider-button-card/877bc94e55b14c1dea0e1e419fb60255a4b1e9a3/assets/preview.gif -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Slider Button Card", 3 | "render_readme": true, 4 | "filename": "slider-button-card.js", 5 | "domains": ["light", "fan", "cover", "switch", "input_boolean", "media_player"] 6 | } 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "slider-button-card", 3 | "version": "1.10.3", 4 | "description": "Lovelace slider-button-card", 5 | "keywords": [ 6 | "home-assistant", 7 | "homeassistant", 8 | "hass", 9 | "automation", 10 | "lovelace", 11 | "custom-cards" 12 | ], 13 | "module": "slider-button-card.js", 14 | "repository": { 15 | "url": "https://github.com/mattieha/slider-button-card" 16 | }, 17 | "author": "M Hoog Antink", 18 | "license": "MIT", 19 | "dependencies": { 20 | "@ctrl/tinycolor": "^3.4.0", 21 | "custom-card-helpers": "^1.6.6", 22 | "fast-copy": "^2.1.1", 23 | "home-assistant-js-websocket": "^4.5.0", 24 | "lit-element": "^2.4.0", 25 | "lit-html": "^1.3.0" 26 | }, 27 | "devDependencies": { 28 | "@babel/core": "^7.12.3", 29 | "@babel/plugin-proposal-class-properties": "^7.12.1", 30 | "@babel/plugin-proposal-decorators": "^7.12.1", 31 | "@rollup/plugin-json": "^4.1.0", 32 | "@typescript-eslint/eslint-plugin": "^2.34.0", 33 | "@typescript-eslint/parser": "^2.34.0", 34 | "eslint": "^6.8.0", 35 | "eslint-config-airbnb-base": "^14.2.1", 36 | "eslint-config-prettier": "^6.15.0", 37 | "eslint-plugin-import": "^2.22.1", 38 | "eslint-plugin-prettier": "^3.1.4", 39 | "prettier": "^1.19.1", 40 | "rollup": "^1.32.1", 41 | "rollup-plugin-babel": "^4.4.0", 42 | "rollup-plugin-commonjs": "^10.1.0", 43 | "rollup-plugin-node-resolve": "^5.2.0", 44 | "rollup-plugin-serve": "^1.1.0", 45 | "rollup-plugin-terser": "^5.3.1", 46 | "rollup-plugin-typescript2": "^0.24.3", 47 | "rollup-plugin-uglify": "^6.0.4", 48 | "typescript": "^3.9.7" 49 | }, 50 | "scripts": { 51 | "start": "rollup -c rollup.config.dev.js --watch", 52 | "build": "npm run lint && npm run rollup", 53 | "lint": "eslint src/*.ts", 54 | "rollup": "rollup -c" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /rollup.config.dev.js: -------------------------------------------------------------------------------- 1 | import resolve from "rollup-plugin-node-resolve"; 2 | import typescript from "rollup-plugin-typescript2"; 3 | import babel from "rollup-plugin-babel"; 4 | import serve from "rollup-plugin-serve"; 5 | import { terser } from "rollup-plugin-terser"; 6 | import json from '@rollup/plugin-json'; 7 | 8 | export default { 9 | input: ["src/slider-button-card.ts"], 10 | output: { 11 | dir: "./dist", 12 | format: "es", 13 | }, 14 | plugins: [ 15 | resolve(), 16 | typescript(), 17 | json(), 18 | babel({ 19 | exclude: "node_modules/**", 20 | }), 21 | terser(), 22 | serve({ 23 | contentBase: "./dist", 24 | host: "0.0.0.0", 25 | port: 5000, 26 | allowCrossOrigin: true, 27 | headers: { 28 | "Access-Control-Allow-Origin": "*", 29 | }, 30 | }), 31 | ], 32 | }; 33 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from 'rollup-plugin-typescript2'; 2 | import commonjs from 'rollup-plugin-commonjs'; 3 | import nodeResolve from 'rollup-plugin-node-resolve'; 4 | import babel from 'rollup-plugin-babel'; 5 | import { terser } from 'rollup-plugin-terser'; 6 | import serve from 'rollup-plugin-serve'; 7 | import json from '@rollup/plugin-json'; 8 | 9 | const dev = process.env.ROLLUP_WATCH; 10 | 11 | const serveopts = { 12 | contentBase: ['./dist'], 13 | host: '0.0.0.0', 14 | port: 5000, 15 | allowCrossOrigin: true, 16 | headers: { 17 | 'Access-Control-Allow-Origin': '*', 18 | }, 19 | }; 20 | 21 | const plugins = [ 22 | nodeResolve({}), 23 | commonjs(), 24 | typescript(), 25 | json(), 26 | babel({ 27 | exclude: 'node_modules/**', 28 | }), 29 | dev && serve(serveopts), 30 | !dev && terser(), 31 | ]; 32 | 33 | export default [ 34 | { 35 | input: 'src/slider-button-card.ts', 36 | output: { 37 | dir: 'dist', 38 | format: 'es', 39 | }, 40 | plugins: [...plugins], 41 | }, 42 | ]; 43 | -------------------------------------------------------------------------------- /src/action-handler-directive.ts: -------------------------------------------------------------------------------- 1 | import { directive, PropertyPart } from 'lit-html'; 2 | 3 | import { ActionHandlerDetail, ActionHandlerOptions } from 'custom-card-helpers/dist/types'; 4 | import { fireEvent } from 'custom-card-helpers'; 5 | 6 | const isTouch = 'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0; 7 | 8 | interface ActionHandler extends HTMLElement { 9 | holdTime: number; 10 | bind(element: Element, options): void; 11 | } 12 | interface ActionHandlerElement extends HTMLElement { 13 | actionHandler?: boolean; 14 | } 15 | 16 | declare global { 17 | interface HASSDomEvents { 18 | action: ActionHandlerDetail; 19 | } 20 | } 21 | 22 | class ActionHandler extends HTMLElement implements ActionHandler { 23 | public holdTime = 500; 24 | 25 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 26 | public ripple: any; 27 | 28 | protected timer?: number; 29 | 30 | protected held = false; 31 | 32 | private dblClickTimeout?: number; 33 | 34 | constructor() { 35 | super(); 36 | this.ripple = document.createElement('mwc-ripple'); 37 | } 38 | 39 | public connectedCallback(): void { 40 | Object.assign(this.style, { 41 | position: 'absolute', 42 | width: isTouch ? '100px' : '50px', 43 | height: isTouch ? '100px' : '50px', 44 | transform: 'translate(-50%, -50%)', 45 | pointerEvents: 'none', 46 | zIndex: '999', 47 | }); 48 | 49 | this.appendChild(this.ripple); 50 | this.ripple.primary = true; 51 | 52 | ['touchcancel', 'mouseout', 'mouseup', 'touchmove', 'mousewheel', 'wheel', 'scroll'].forEach(ev => { 53 | document.addEventListener( 54 | ev, 55 | () => { 56 | clearTimeout(this.timer); 57 | this.stopAnimation(); 58 | this.timer = undefined; 59 | }, 60 | { passive: true }, 61 | ); 62 | }); 63 | } 64 | 65 | public bind(element: ActionHandlerElement, options): void { 66 | if (element.actionHandler) { 67 | return; 68 | } 69 | element.actionHandler = true; 70 | 71 | element.addEventListener('contextmenu', (ev: Event) => { 72 | const e = ev || window.event; 73 | if (e.preventDefault) { 74 | e.preventDefault(); 75 | } 76 | if (e.stopPropagation) { 77 | e.stopPropagation(); 78 | } 79 | e.cancelBubble = true; 80 | e.returnValue = false; 81 | return false; 82 | }); 83 | 84 | const start = (ev: Event): void => { 85 | this.held = false; 86 | let x; 87 | let y; 88 | if ((ev as TouchEvent).touches) { 89 | x = (ev as TouchEvent).touches[0].pageX; 90 | y = (ev as TouchEvent).touches[0].pageY; 91 | } else { 92 | x = (ev as MouseEvent).pageX; 93 | y = (ev as MouseEvent).pageY; 94 | } 95 | 96 | this.timer = window.setTimeout(() => { 97 | this.startAnimation(x, y); 98 | this.held = true; 99 | }, this.holdTime); 100 | }; 101 | 102 | const end = (ev: Event): void => { 103 | // Prevent mouse event if touch event 104 | ev.preventDefault(); 105 | if (['touchend', 'touchcancel'].includes(ev.type) && this.timer === undefined) { 106 | return; 107 | } 108 | clearTimeout(this.timer); 109 | this.stopAnimation(); 110 | this.timer = undefined; 111 | if (this.held) { 112 | fireEvent(element, 'action', { action: 'hold' }); 113 | } else if (options.hasDoubleClick) { 114 | if ((ev.type === 'click' && (ev as MouseEvent).detail < 2) || !this.dblClickTimeout) { 115 | this.dblClickTimeout = window.setTimeout(() => { 116 | this.dblClickTimeout = undefined; 117 | fireEvent(element, 'action', { action: 'tap' }); 118 | }, 250); 119 | } else { 120 | clearTimeout(this.dblClickTimeout); 121 | this.dblClickTimeout = undefined; 122 | fireEvent(element, 'action', { action: 'double_tap' }); 123 | } 124 | } else { 125 | fireEvent(element, 'action', { action: 'tap' }); 126 | } 127 | }; 128 | 129 | const handleEnter = (ev: KeyboardEvent): void => { 130 | if (ev.keyCode !== 13) { 131 | return; 132 | } 133 | end(ev); 134 | }; 135 | 136 | element.addEventListener('touchstart', start, { passive: true }); 137 | element.addEventListener('touchend', end); 138 | element.addEventListener('touchcancel', end); 139 | 140 | element.addEventListener('mousedown', start, { passive: true }); 141 | element.addEventListener('click', end); 142 | 143 | element.addEventListener('keyup', handleEnter); 144 | } 145 | 146 | private startAnimation(x: number, y: number): void { 147 | Object.assign(this.style, { 148 | left: `${x}px`, 149 | top: `${y}px`, 150 | display: null, 151 | }); 152 | this.ripple.disabled = false; 153 | this.ripple.active = true; 154 | this.ripple.unbounded = true; 155 | } 156 | 157 | private stopAnimation(): void { 158 | this.ripple.active = false; 159 | this.ripple.disabled = true; 160 | this.style.display = 'none'; 161 | } 162 | } 163 | 164 | // TODO You need to replace all instances of "action-handler-boilerplate" with "action-handler-" 165 | customElements.define('action-handler-slider-button', ActionHandler); 166 | 167 | const getActionHandler = (): ActionHandler => { 168 | const body = document.body; 169 | if (body.querySelector('action-handler-slider-button')) { 170 | return body.querySelector('action-handler-slider-button') as ActionHandler; 171 | } 172 | 173 | const actionhandler = document.createElement('action-handler-slider-button'); 174 | body.appendChild(actionhandler); 175 | 176 | return actionhandler as ActionHandler; 177 | }; 178 | 179 | export const actionHandlerBind = (element: ActionHandlerElement, options: ActionHandlerOptions): void => { 180 | const actionhandler: ActionHandler = getActionHandler(); 181 | if (!actionhandler) { 182 | return; 183 | } 184 | actionhandler.bind(element, options); 185 | }; 186 | 187 | export const actionHandler = directive((options: ActionHandlerOptions = {}) => (part: PropertyPart): void => { 188 | actionHandlerBind(part.committer.element as ActionHandlerElement, options); 189 | }); 190 | -------------------------------------------------------------------------------- /src/const.ts: -------------------------------------------------------------------------------- 1 | import * as pack from '../package.json'; 2 | export const CARD_VERSION = pack.version; 3 | -------------------------------------------------------------------------------- /src/controllers/climate-controller.ts: -------------------------------------------------------------------------------- 1 | import { STATES_OFF } from 'custom-card-helpers'; 2 | import { capitalizeFirst } from '../utils'; 3 | import { Controller } from './controller'; 4 | 5 | export class ClimateController extends Controller { 6 | _targetValue; 7 | _invert = false; 8 | 9 | get _value(): number { 10 | return this.stateObj.attributes.temperature; 11 | } 12 | 13 | set _value(value) { 14 | this._hass.callService('climate', 'set_temperature', { 15 | // eslint-disable-next-line @typescript-eslint/camelcase 16 | entity_id: this.stateObj.entity_id, 17 | temperature: value, 18 | }); 19 | } 20 | 21 | get isOff(): boolean { 22 | return STATES_OFF.includes(this.state); 23 | } 24 | 25 | get _step(): number { 26 | return this.stateObj.attributes?.target_temp_step || 1; 27 | } 28 | 29 | get _min(): number { 30 | return this.stateObj.attributes?.min_temp || 7; 31 | } 32 | 33 | get _max(): number { 34 | return this.stateObj.attributes?.max_temp || 35; 35 | } 36 | 37 | get isValuePercentage(): boolean { 38 | return false; 39 | } 40 | 41 | get label(): string { 42 | const unit = this._hass.config.unit_system.temperature; 43 | const mode = capitalizeFirst(this.state); 44 | // const current = this.stateObj.attributes?.current_temperature ? ` | ${this.stateObj.attributes.current_temperature}${unit}` : ''; 45 | return `${this.targetValue}${unit} | ${mode}`; 46 | } 47 | 48 | } 49 | -------------------------------------------------------------------------------- /src/controllers/controller.ts: -------------------------------------------------------------------------------- 1 | import { computeStateDomain, domainIcon, HomeAssistant } from 'custom-card-helpers'; 2 | import { HassEntity } from 'home-assistant-js-websocket'; 3 | import { SliderBackground, SliderButtonCardConfig, SliderDirections } from '../types'; 4 | import { getLightColorBasedOnTemperature, normalize, percentageToValue, toPercentage } from '../utils'; 5 | 6 | export interface Style { 7 | icon: ObjectStyle; 8 | slider: ObjectStyle; 9 | } 10 | 11 | export interface ObjectStyle { 12 | filter: string; 13 | color: string; 14 | rotateSpeed?: string; 15 | } 16 | 17 | export abstract class Controller { 18 | _config: SliderButtonCardConfig; 19 | _hass: any; 20 | _sliderPrevColor = ''; 21 | 22 | abstract _value?: number; 23 | abstract _targetValue?: number; 24 | abstract _min?: number; 25 | abstract _max?: number; 26 | abstract _step?: number; 27 | abstract _invert?: boolean; 28 | 29 | protected constructor(config: SliderButtonCardConfig) { 30 | this._config = config; 31 | } 32 | 33 | set hass(hass: HomeAssistant) { 34 | this._hass = hass; 35 | } 36 | 37 | get stateObj(): any { 38 | return this._hass.states[this._config.entity] as HassEntity; 39 | } 40 | 41 | get domain(): string { 42 | return computeStateDomain(this.stateObj); 43 | } 44 | 45 | get name(): string { 46 | return this._config.name ? this._config.name : this.stateObj?.attributes?.friendly_name ? this.stateObj.attributes.friendly_name : ''; 47 | } 48 | 49 | get icon(): string { 50 | if (typeof this._config.icon?.icon === 'string' && this._config.icon?.icon.length) { 51 | return this._config.icon.icon; 52 | } 53 | return this.stateObj.attributes?.icon ? this.stateObj.attributes.icon : domainIcon(this.domain, this.stateObj.state); 54 | } 55 | 56 | get value(): number { 57 | if (this._value) { 58 | return Math.round(this._value / this.step) * this.step; 59 | } 60 | return this.min; 61 | } 62 | 63 | set value(value: number) { 64 | if (value !== this.value) { 65 | this._value = value; 66 | // this._value = Math.round(value / this.step) * this.step; 67 | } 68 | } 69 | 70 | get targetValue(): number { 71 | if (this._targetValue === 0) { 72 | return 0; 73 | } 74 | if (this._targetValue) { 75 | return Math.round(this._targetValue / this.step) * this.step; 76 | } 77 | if (this.value) { 78 | return this.value; 79 | } 80 | return 0; 81 | } 82 | 83 | set targetValue(value: number) { 84 | if (value !== this.targetValue) { 85 | this._targetValue = value; 86 | // this._targetValue = Math.round(value / this.step) * this.step; 87 | } 88 | } 89 | 90 | get label(): string { 91 | return `${this.targetValue}`; 92 | } 93 | 94 | get hidden(): boolean { 95 | return false; 96 | } 97 | 98 | get hasSlider(): boolean { 99 | return true; 100 | } 101 | 102 | get hasToggle(): boolean { 103 | return this._config.slider?.toggle_on_click ?? false; 104 | } 105 | 106 | get toggleValue(): number { 107 | return this.value === this.min ? this.max : this.min; 108 | } 109 | 110 | get state(): string { 111 | return this.stateObj?.state; 112 | } 113 | 114 | get isOff(): boolean { 115 | return this.percentage === 0; 116 | } 117 | 118 | get isUnavailable(): boolean { 119 | return this.state ? this.state === 'unavailable' : true; 120 | } 121 | 122 | get isSliderDisabled(): boolean { 123 | return this.isUnavailable ? this.isUnavailable : this.hasToggle; 124 | } 125 | 126 | get min(): number { 127 | return this._config.slider?.min ?? this._min ?? 0; 128 | } 129 | 130 | get max(): number { 131 | return this._config.slider?.max ?? this._max ?? 100; 132 | } 133 | 134 | get step(): number { 135 | return this._config.slider?.step ?? this._step ?? 5; 136 | } 137 | 138 | get invert(): boolean { 139 | return this._config.slider?.invert ?? this._invert ?? false; 140 | } 141 | 142 | get isValuePercentage(): boolean { 143 | return true; 144 | } 145 | 146 | get percentage(): number { 147 | return Math.round( 148 | ((this.targetValue - (this.invert ? this.max : this.min)) * 100) / (this.max - this.min) * (this.invert ? -1 : 1) 149 | ); 150 | } 151 | 152 | get valueFromPercentage(): number { 153 | return percentageToValue(this.percentage, this.min, this.max); 154 | } 155 | 156 | get allowedAttributes(): string[] { 157 | return []; 158 | } 159 | 160 | get style(): Style { 161 | return { 162 | icon: { 163 | filter: this.iconFilter, 164 | color: this.iconColor, 165 | rotateSpeed: this.iconRotateSpeed, 166 | }, 167 | slider: { 168 | filter: this.sliderFilter, 169 | color: this.sliderColor, 170 | }, 171 | }; 172 | } 173 | 174 | get iconFilter(): string { 175 | if (!this._config.icon?.use_state_color || this.percentage === 0) { 176 | return 'brightness(100%)'; 177 | } 178 | return `brightness(${(this.percentage + 100) / 2}%)`; 179 | } 180 | 181 | get iconColor(): string { 182 | if (this._config.icon?.use_state_color) { 183 | if (this.stateObj.attributes.hs_color) { 184 | const [hue, sat] = this.stateObj.attributes.hs_color; 185 | if (sat > 10) { 186 | return `hsl(${hue}, 100%, ${100 - sat / 2}%)`; 187 | } 188 | } else if (this.percentage > 0) { 189 | return 'var(--paper-item-icon-active-color, #fdd835)' 190 | } else { 191 | return 'var(--paper-item-icon-color, #44739e)' 192 | } 193 | } 194 | return ''; 195 | } 196 | 197 | get iconRotateSpeed(): string { 198 | return '0s'; 199 | } 200 | 201 | get sliderFilter(): string { 202 | if (!this._config.slider?.use_percentage_bg_opacity || this.percentage === 0 || this._config.slider.background === SliderBackground.GRADIENT) { 203 | return 'brightness(100%)'; 204 | } 205 | return `brightness(${(this.percentage + 100) / 2}%)`; 206 | } 207 | 208 | get sliderColor(): string { 209 | if (this._config.slider?.use_state_color) { 210 | if (this.stateObj.attributes.hs_color) { 211 | const [hue, sat] = this.stateObj.attributes.hs_color; 212 | if (sat > 10) { 213 | const color = `hsl(${hue}, 100%, ${100 - sat / 2}%)`; 214 | this._sliderPrevColor = color; 215 | return color; 216 | } 217 | } else if ( 218 | this.stateObj.attributes.color_temp && 219 | this.stateObj.attributes.min_mireds && 220 | this.stateObj.attributes.max_mireds 221 | ) { 222 | const color = getLightColorBasedOnTemperature( 223 | this.stateObj.attributes.color_temp, 224 | this.stateObj.attributes.min_mireds, 225 | this.stateObj.attributes.max_mireds, 226 | ); 227 | this._sliderPrevColor = color; 228 | return color; 229 | } else if (this._sliderPrevColor.startsWith('hsl') || this._sliderPrevColor.startsWith('rgb')) { 230 | return this._sliderPrevColor; 231 | } 232 | } 233 | return 'inherit'; 234 | } 235 | 236 | moveSlider(event: any, {left, top, width, height}): number { 237 | let percentage = this.calcMovementPercentage(event, {left, top, width, height}); 238 | percentage = this.applyStep(percentage); 239 | percentage = normalize(percentage, 0, 100); 240 | if (!this.isValuePercentage) { 241 | percentage = percentageToValue(percentage, this.min, this.max); 242 | } 243 | return percentage; 244 | } 245 | 246 | calcMovementPercentage(event: any, {left, top, width, height}): number { 247 | let percentage; 248 | switch(this._config.slider?.direction) { 249 | case SliderDirections.LEFT_RIGHT: 250 | percentage = toPercentage( 251 | event.clientX, 252 | left, 253 | width 254 | ); 255 | if (this.invert) { 256 | percentage = 100 - percentage; 257 | } 258 | break 259 | case SliderDirections.TOP_BOTTOM: 260 | percentage = toPercentage( 261 | event.clientY, 262 | top, 263 | height 264 | ); 265 | if (this.invert) { 266 | percentage = 100 - percentage; 267 | } 268 | break 269 | case SliderDirections.BOTTOM_TOP: 270 | percentage = toPercentage( 271 | event.clientY, 272 | top, 273 | height 274 | ); 275 | if (!this.invert) { 276 | percentage = 100 - percentage; 277 | } 278 | break 279 | 280 | } 281 | return percentage; 282 | } 283 | 284 | applyStep(value: number): number { 285 | return Math.round(value / this.step) * this.step; 286 | } 287 | 288 | log(name = '', value: string | number | object = ''): void { 289 | if (this._config.debug) { 290 | console.log(`${this._config.entity}: ${name}`, value) 291 | } 292 | } 293 | } 294 | -------------------------------------------------------------------------------- /src/controllers/cover-controller.ts: -------------------------------------------------------------------------------- 1 | import { stateIcon } from 'custom-card-helpers'; 2 | import { CoverAttributes } from '../types'; 3 | import { getEnumValues } from '../utils'; 4 | import { Controller } from './controller'; 5 | 6 | export class CoverController extends Controller { 7 | _min = 0; 8 | _targetValue; 9 | _invert = true; 10 | 11 | get attribute(): string { 12 | if (this._config.slider?.attribute?.length && this.allowedAttributes.includes(this._config.slider?.attribute)) { 13 | return this._config.slider?.attribute; 14 | } 15 | return CoverAttributes.POSITION; 16 | } 17 | 18 | get icon(): string { 19 | if (typeof this._config.icon?.icon === 'string' && this._config.icon?.icon.length) { 20 | return this._config.icon.icon; 21 | } 22 | return stateIcon(this.stateObj); 23 | } 24 | 25 | get allowedAttributes(): string[] { 26 | return getEnumValues(CoverAttributes); 27 | } 28 | get _value(): number { 29 | switch(this.attribute) { 30 | case CoverAttributes.POSITION: 31 | return this.stateObj?.state === 'closed' 32 | ? 0 33 | : this.stateObj.attributes.current_position; 34 | case CoverAttributes.TILT: 35 | return this.stateObj.attributes.current_tilt_position; 36 | default: 37 | return 0; 38 | } 39 | } 40 | 41 | set _value(value) { 42 | if (!this.hasSlider) { 43 | const service = value > 0 ? 'open_cover' : 'close_cover'; 44 | this._hass.callService('cover', service, { 45 | // eslint-disable-next-line @typescript-eslint/camelcase 46 | entity_id: this.stateObj.entity_id 47 | }); 48 | } else { 49 | switch(this.attribute) { 50 | case CoverAttributes.POSITION: 51 | this._hass.callService('cover', 'set_cover_position', { 52 | // eslint-disable-next-line @typescript-eslint/camelcase 53 | entity_id: this.stateObj.entity_id, 54 | position: value 55 | }); 56 | break; 57 | case CoverAttributes.TILT: 58 | this._hass.callService('cover', 'set_cover_tilt_position', { 59 | // eslint-disable-next-line @typescript-eslint/camelcase 60 | entity_id: this.stateObj.entity_id, 61 | // eslint-disable-next-line @typescript-eslint/camelcase 62 | tilt_position: value 63 | }); 64 | break; 65 | default: 66 | } 67 | } 68 | } 69 | 70 | get _step(): number { 71 | return 1; 72 | } 73 | 74 | get label(): string { 75 | const defaultLabel = this._hass.localize(`component.cover.state._.${this.state}`); 76 | const closedLabel = this._hass.localize('component.cover.state._.closed'); 77 | const openLabel = this._hass.localize('component.cover.state._.open'); 78 | if (!this.hasSlider) { 79 | return defaultLabel; 80 | } 81 | switch(this.attribute) { 82 | case CoverAttributes.POSITION: 83 | if (this.percentage === 0) { 84 | return this.invert ? openLabel : closedLabel; 85 | } 86 | if (this.percentage === 100) { 87 | return this.invert ? closedLabel : openLabel; 88 | } 89 | return `${this.percentage}%`; 90 | case CoverAttributes.TILT: 91 | return `${this.percentage}`; 92 | } 93 | return defaultLabel; 94 | } 95 | 96 | get hasSlider(): boolean { 97 | switch(this.attribute) { 98 | case CoverAttributes.POSITION: 99 | if ('current_position' in this.stateObj.attributes) { 100 | return true; 101 | } 102 | if ( 103 | 'supported_features' in this.stateObj.attributes && 104 | this.stateObj.attributes.supported_features & 4 105 | ) { 106 | return true; 107 | } 108 | break; 109 | case CoverAttributes.TILT: 110 | if ('current_tilt_position' in this.stateObj.attributes) { 111 | return true; 112 | } 113 | if ( 114 | 'supported_features' in this.stateObj.attributes && 115 | this.stateObj.attributes.supported_features & 128 116 | ) { 117 | return true; 118 | } 119 | break; 120 | default: 121 | return false; 122 | } 123 | return false; 124 | } 125 | 126 | get _max(): number { 127 | return this.hasSlider ? 100 : 1; 128 | } 129 | 130 | } 131 | -------------------------------------------------------------------------------- /src/controllers/fan-controller.ts: -------------------------------------------------------------------------------- 1 | import { STATES_OFF } from 'custom-card-helpers'; 2 | import { Controller } from './controller'; 3 | 4 | export class FanController extends Controller { 5 | _min = 0; 6 | _targetValue; 7 | _invert = false; 8 | 9 | get _value(): number { 10 | return this.isUnavailable || STATES_OFF.includes(this.state) 11 | ? 0 12 | : this.hasSlider ? this.stateObj.attributes.percentage : 1; 13 | } 14 | 15 | set _value(value) { 16 | const service = value > 0 ? 'turn_on' : 'turn_off'; 17 | if (value > 0 && this.hasSlider) { 18 | this._hass.callService('fan', 'set_percentage', { 19 | // eslint-disable-next-line @typescript-eslint/camelcase 20 | entity_id: this.stateObj.entity_id, 21 | percentage: value 22 | }); 23 | } else { 24 | this._hass.callService('fan', service, { 25 | // eslint-disable-next-line @typescript-eslint/camelcase 26 | entity_id: this.stateObj.entity_id 27 | }); 28 | } 29 | } 30 | 31 | get _step(): number { 32 | return this.stateObj.attributes.percentage_step; 33 | } 34 | 35 | get label(): string { 36 | if (this.percentage > 0) { 37 | if (this.hasSlider) { 38 | return `${this.percentage}%` 39 | } else { 40 | return this._hass.localize('component.fan.state._.on'); 41 | } 42 | } 43 | return this._hass.localize('component.fan.state._.off'); 44 | } 45 | 46 | get hasSlider(): boolean { 47 | return 'speed' in this.stateObj.attributes; 48 | } 49 | 50 | get _max(): number { 51 | return this.hasSlider ? 100 : 1; 52 | } 53 | 54 | get iconRotateSpeed(): string { 55 | let speed = 0; 56 | if (this.percentage > 0) { 57 | speed = 3 - ((this.percentage / 100) * 2); 58 | } 59 | return `${speed}s` 60 | } 61 | 62 | } 63 | -------------------------------------------------------------------------------- /src/controllers/get-controller.ts: -------------------------------------------------------------------------------- 1 | import { computeDomain } from 'custom-card-helpers'; 2 | import { Domain, SliderButtonCardConfig } from '../types'; 3 | import { ClimateController } from './climate-controller'; 4 | import { Controller } from './controller'; 5 | import { CoverController } from './cover-controller'; 6 | import { FanController } from './fan-controller'; 7 | import { InputBooleanController } from './input-boolean-controller'; 8 | import { LightController } from './light-controller'; 9 | import { LockController } from './lock-controller'; 10 | import { MediaController } from './media-controller'; 11 | import { SwitchController } from './switch-controller'; 12 | 13 | export class ControllerFactory { 14 | static getInstance(config: SliderButtonCardConfig): Controller { 15 | const domain = computeDomain(config.entity); 16 | const mapping = { 17 | [Domain.LIGHT]: LightController, 18 | [Domain.FAN]: FanController, 19 | [Domain.SWITCH]: SwitchController, 20 | [Domain.COVER]: CoverController, 21 | [Domain.INPUT_BOOLEAN]: InputBooleanController, 22 | [Domain.MEDIA_PLAYER]: MediaController, 23 | [Domain.CLIMATE]: ClimateController, 24 | [Domain.LOCK]: LockController, 25 | }; 26 | if (typeof mapping[domain] === 'undefined') { 27 | throw new Error(`Unsupported entity type: ${domain}`) 28 | } 29 | return new mapping[domain](config); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/controllers/input-boolean-controller.ts: -------------------------------------------------------------------------------- 1 | import { STATES_OFF } from 'custom-card-helpers'; 2 | import { Controller } from './controller'; 3 | 4 | export class InputBooleanController extends Controller { 5 | _min = 0; 6 | _max = 1; 7 | _targetValue; 8 | _invert = false; 9 | 10 | get _value(): number { 11 | return !STATES_OFF.includes(this.stateObj.state) 12 | ? 1 13 | : 0; 14 | } 15 | 16 | set _value(value) { 17 | const service = value > 0 ? 'turn_on' : 'turn_off'; 18 | this._hass.callService('input_boolean', service, { 19 | // eslint-disable-next-line @typescript-eslint/camelcase 20 | entity_id: this.stateObj.entity_id 21 | }); 22 | } 23 | 24 | get _step(): number { 25 | return 1; 26 | } 27 | 28 | get label(): string { 29 | if (this.percentage > 0) { 30 | return this._hass.localize('component.input_boolean.state._.on'); 31 | } 32 | return this._hass.localize('component.input_boolean.state._.off'); 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /src/controllers/light-controller.ts: -------------------------------------------------------------------------------- 1 | import { STATES_OFF } from 'custom-card-helpers'; 2 | import { LightAttributes, LightColorModes } from '../types'; 3 | import { getEnumValues, getLightColorBasedOnTemperature } from '../utils'; 4 | import { Controller } from './controller'; 5 | 6 | const HS_INDEX = { 7 | hue: 0, 8 | saturation: 1 9 | }; 10 | 11 | export class LightController extends Controller { 12 | 13 | _step = 1; 14 | _targetValue; 15 | _invert = false; 16 | 17 | get attribute(): string { 18 | const attr = this._config.slider?.attribute as LightAttributes; 19 | let useAttr = LightAttributes.BRIGHTNESS_PCT; 20 | let supported: string[] = []; 21 | if (Array.isArray(this.stateObj?.attributes?.supported_color_modes)) { 22 | supported = this.stateObj?.attributes?.supported_color_modes; 23 | } 24 | if (supported.length === 1 && supported[0] === LightAttributes.ON_OFF) { 25 | useAttr = LightAttributes.ON_OFF; 26 | } 27 | if (attr?.length && this.allowedAttributes.includes(attr)) { 28 | 29 | useAttr = attr; 30 | switch(attr) { 31 | case LightAttributes.COLOR_TEMP: 32 | if (!supported.includes('color_temp')) { 33 | useAttr = LightAttributes.BRIGHTNESS_PCT; 34 | } 35 | break; 36 | case LightAttributes.HUE: 37 | case LightAttributes.SATURATION: 38 | if (!supported.includes('hs')) { 39 | useAttr = LightAttributes.BRIGHTNESS_PCT; 40 | } 41 | break; 42 | } 43 | } 44 | return useAttr; 45 | } 46 | 47 | get allowedAttributes(): string[] { 48 | return getEnumValues(LightAttributes); 49 | } 50 | 51 | get colorMode(): LightColorModes | undefined { 52 | return this.stateObj?.attributes?.color_mode; 53 | } 54 | 55 | get _value(): number { 56 | if (!this.stateObj || STATES_OFF.includes(this.state)) { 57 | return this.isValuePercentage ? 0 : this.min; 58 | } 59 | const attr = this.stateObj.attributes; 60 | switch(this.attribute) { 61 | case LightAttributes.COLOR_TEMP: 62 | return attr.color_temp ? Math.round(attr.color_temp) : this.min; 63 | case LightAttributes.BRIGHTNESS: 64 | return Math.round(attr.brightness); 65 | case LightAttributes.BRIGHTNESS_PCT: 66 | return Math.round((attr.brightness * 100.0) / 255); 67 | case LightAttributes.ON_OFF: 68 | return 1; 69 | case LightAttributes.HUE: 70 | case LightAttributes.SATURATION: 71 | return attr.hs_color 72 | ? Math.round(attr.hs_color[HS_INDEX[this.attribute]]) 73 | : 0; 74 | default: 75 | return 0; 76 | } 77 | } 78 | 79 | set _value(value) { 80 | if (!this.stateObj) { 81 | return; 82 | } 83 | let attr = this.attribute; 84 | let _value; 85 | let service = value > 0 ? 'turn_on' : 'turn_off'; 86 | let data = { 87 | // eslint-disable-next-line @typescript-eslint/camelcase 88 | entity_id: this.stateObj.entity_id, 89 | } 90 | switch(attr) { 91 | case LightAttributes.BRIGHTNESS: 92 | case LightAttributes.BRIGHTNESS_PCT: 93 | value = 94 | attr === LightAttributes.BRIGHTNESS 95 | ? Math.round(value) 96 | : Math.round((value / 100.0) * 255); 97 | if (!value) { 98 | service = 'turn_off'; 99 | } else { 100 | attr = 'brightness'; 101 | data = { 102 | ...data, 103 | [attr]: value 104 | } 105 | } 106 | break; 107 | case LightAttributes.HUE: 108 | case LightAttributes.SATURATION: 109 | _value = this.stateObj.attributes.hs_color || [0, 0]; 110 | _value[HS_INDEX[attr]] = value; 111 | value = _value; 112 | attr = 'hs_color'; 113 | service = 'turn_on'; 114 | data = { 115 | ...data, 116 | [attr]: value 117 | } 118 | break; 119 | case LightAttributes.COLOR_TEMP: 120 | attr = 'color_temp'; 121 | service = 'turn_on'; 122 | data = { 123 | ...data, 124 | [attr]: value 125 | } 126 | break; 127 | } 128 | 129 | this._hass.callService('light', service, { 130 | ...data 131 | }); 132 | } 133 | 134 | get _min(): number { 135 | switch(this.attribute) { 136 | case LightAttributes.COLOR_TEMP: 137 | return this.stateObj ? this.stateObj.attributes?.min_mireds ? this.stateObj.attributes.min_mireds : 153 : 153; 138 | default: 139 | return 0; 140 | } 141 | } 142 | 143 | get _max(): number { 144 | switch(this.attribute) { 145 | case LightAttributes.COLOR_TEMP: 146 | return this.stateObj ? this.stateObj.attributes?.max_mireds ? this.stateObj.attributes.max_mireds : 500 : 500; 147 | case LightAttributes.BRIGHTNESS: 148 | return 255; 149 | case LightAttributes.HUE: 150 | return 360; 151 | case LightAttributes.ON_OFF: 152 | return 1; 153 | default: 154 | return 100; 155 | } 156 | } 157 | 158 | get isValuePercentage(): boolean { 159 | switch(this.attribute) { 160 | case LightAttributes.COLOR_TEMP: 161 | case LightAttributes.HUE: 162 | case LightAttributes.BRIGHTNESS: 163 | return false; 164 | default: 165 | return true; 166 | } 167 | } 168 | 169 | get isOff(): boolean { 170 | switch(this.attribute) { 171 | case LightAttributes.COLOR_TEMP: 172 | case LightAttributes.HUE: 173 | case LightAttributes.SATURATION: 174 | case LightAttributes.BRIGHTNESS: 175 | case LightAttributes.ON_OFF: 176 | return STATES_OFF.includes(this.state); 177 | default: 178 | return this.colorMode === LightColorModes.ON_OFF ? STATES_OFF.includes(this.state) : this.percentage === 0; 179 | } 180 | } 181 | 182 | get label(): string { 183 | if (this.isOff) { 184 | return this._hass.localize('component.light.state._.off'); 185 | } 186 | if (this.colorMode === LightColorModes.ON_OFF) { 187 | return this._hass.localize('component.light.state._.on'); 188 | } 189 | switch(this.attribute) { 190 | case LightAttributes.ON_OFF: 191 | return this._hass.localize('component.light.state._.on'); 192 | case LightAttributes.COLOR_TEMP: 193 | case LightAttributes.BRIGHTNESS: 194 | return `${this.targetValue}`; 195 | case LightAttributes.BRIGHTNESS_PCT: 196 | case LightAttributes.SATURATION: 197 | return `${this.targetValue}%`; 198 | case LightAttributes.HUE: 199 | return `${this.targetValue}°`; 200 | default: 201 | return `${this.targetValue}`; 202 | } 203 | } 204 | 205 | get hasToggle(): boolean { 206 | let supported: string[] = []; 207 | if (Array.isArray(this.stateObj?.attributes?.supported_color_modes)) { 208 | supported = this.stateObj?.attributes?.supported_color_modes; 209 | } 210 | if (supported.length === 1 && supported[0] === LightAttributes.ON_OFF) { 211 | return true; 212 | } 213 | return this._config.slider?.toggle_on_click ?? false; 214 | } 215 | 216 | get hasSlider(): boolean { 217 | if (!this.stateObj) { 218 | return false; 219 | } 220 | switch(this.attribute) { 221 | case LightAttributes.ON_OFF: 222 | return false; 223 | case LightAttributes.BRIGHTNESS: 224 | case LightAttributes.BRIGHTNESS_PCT: 225 | if ('brightness' in this.stateObj.attributes) { 226 | return true; 227 | } 228 | return !!('supported_features' in this.stateObj.attributes && 229 | this.stateObj?.attributes?.supported_features & 1); 230 | 231 | case LightAttributes.COLOR_TEMP: 232 | if ('color_temp' in this.stateObj.attributes) { 233 | return true; 234 | } 235 | return !!('supported_features' in this.stateObj.attributes && 236 | this.stateObj.attributes.supported_features & 2); 237 | 238 | case LightAttributes.HUE: 239 | case LightAttributes.SATURATION: 240 | if ('hs_color' in this.stateObj.attributes) { 241 | return true; 242 | } 243 | return !!('supported_features' in this.stateObj.attributes && 244 | this.stateObj.attributes.supported_features & 16); 245 | 246 | default: 247 | return false; 248 | } 249 | } 250 | 251 | get sliderColor(): string { 252 | let returnColor = 'inherit'; 253 | if (this._config.slider?.use_state_color) { 254 | if (this.stateObj.attributes.hs_color && this.attribute !== LightAttributes.COLOR_TEMP) { 255 | const [hue, sat] = this.stateObj.attributes.hs_color; 256 | let useHue = hue; 257 | let useSat = sat; 258 | switch(this.attribute) { 259 | case LightAttributes.HUE: 260 | useHue = this.valueFromPercentage; 261 | break; 262 | case LightAttributes.SATURATION: 263 | useSat = this.percentage; 264 | break; 265 | } 266 | if (useSat > 10) { 267 | returnColor = `hsl(${useHue}, 100%, ${100 - useSat / 2}%)`; 268 | this._sliderPrevColor = returnColor; 269 | } 270 | } else if ( 271 | this.attribute === LightAttributes.HUE || this.attribute === LightAttributes.SATURATION 272 | ) { 273 | let useHue = 0; 274 | let useSat = 20; 275 | switch(this.attribute) { 276 | case LightAttributes.HUE: 277 | useHue = this.valueFromPercentage; 278 | break; 279 | case LightAttributes.SATURATION: 280 | useSat = this.percentage; 281 | break; 282 | } 283 | if (useSat > 10) { 284 | returnColor = `hsl(${useHue}, 100%, ${100 - useSat / 2}%)`; 285 | this._sliderPrevColor = returnColor; 286 | } 287 | } else if ( 288 | this.stateObj.attributes.color_temp && 289 | this.stateObj.attributes.min_mireds && 290 | this.stateObj.attributes.max_mireds 291 | ) { 292 | returnColor = getLightColorBasedOnTemperature( 293 | this.attribute === LightAttributes.COLOR_TEMP ? this.valueFromPercentage : this.stateObj.attributes.color_temp, 294 | this.stateObj.attributes.min_mireds, 295 | this.stateObj.attributes.max_mireds 296 | ); 297 | this._sliderPrevColor = returnColor; 298 | } else if (this.attribute === LightAttributes.COLOR_TEMP) { 299 | returnColor = getLightColorBasedOnTemperature( 300 | this.valueFromPercentage, 301 | 153, 302 | 500 303 | ); 304 | this._sliderPrevColor = returnColor; 305 | } else if (this._sliderPrevColor.startsWith('hsl') || this._sliderPrevColor.startsWith('rgb')) { 306 | returnColor = this._sliderPrevColor; 307 | } 308 | } 309 | return returnColor; 310 | } 311 | } 312 | -------------------------------------------------------------------------------- /src/controllers/lock-controller.ts: -------------------------------------------------------------------------------- 1 | import { STATES_OFF } from 'custom-card-helpers'; 2 | import { Controller } from './controller'; 3 | 4 | export class LockController extends Controller { 5 | _min = 0; 6 | _max = 1; 7 | _targetValue; 8 | _invert = false; 9 | 10 | get _value(): number { 11 | return !STATES_OFF.includes(this.stateObj.state) 12 | ? 1 13 | : 0; 14 | } 15 | 16 | set _value(value) { 17 | const service = value > 0 ? 'lock' : 'unlock'; 18 | this._hass.callService('lock', service, { 19 | // eslint-disable-next-line @typescript-eslint/camelcase 20 | entity_id: this.stateObj.entity_id 21 | }); 22 | } 23 | 24 | get _step(): number { 25 | return 1; 26 | } 27 | 28 | get label(): string { 29 | if (this.percentage > 0) { 30 | return this._hass.localize('component.lock.state._.unlocked'); 31 | } 32 | return this._hass.localize('component.lock.state._.locked'); 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /src/controllers/media-controller.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/camelcase */ 2 | import { Controller } from './controller'; 3 | 4 | export class MediaController extends Controller { 5 | _min = 0; 6 | _max = 100; 7 | _step = 1; 8 | _targetValue; 9 | _invert = false; 10 | 11 | get _value(): number { 12 | return this.isUnavailable || this.stateObj?.attributes?.is_volume_muted 13 | ? 0 14 | : Math.floor(parseFloat(Number.parseFloat(this.stateObj.attributes.volume_level).toPrecision(2)) * 100.0); 15 | } 16 | 17 | set _value(value) { 18 | value = value / 100.0; 19 | this._hass.callService('media_player', 'volume_set', { 20 | entity_id: this.stateObj.entity_id, 21 | volume_level: value, 22 | }); 23 | if (value) 24 | this._hass.callService('media_player', 'volume_mute', { 25 | entity_id: this.stateObj.entity_id, 26 | is_volume_muted: false, 27 | }); 28 | } 29 | 30 | get isOff(): boolean { 31 | return this.stateObj.state === 'off'; 32 | } 33 | 34 | get label(): string { 35 | if (this.stateObj.attributes.is_volume_muted) return '-'; 36 | return !!this.stateObj.attributes.volume_level 37 | ? `${this.percentage}%` 38 | : this._hass.localize(`component.media_player.state._.${this.state}`); 39 | } 40 | 41 | } 42 | -------------------------------------------------------------------------------- /src/controllers/switch-controller.ts: -------------------------------------------------------------------------------- 1 | import { STATES_OFF } from 'custom-card-helpers'; 2 | import { Controller } from './controller'; 3 | 4 | export class SwitchController extends Controller { 5 | _min = 0; 6 | _max = 1; 7 | _targetValue; 8 | _invert = false; 9 | 10 | get _value(): number { 11 | return !STATES_OFF.includes(this.stateObj.state) 12 | ? 1 13 | : 0; 14 | } 15 | 16 | set _value(value) { 17 | const service = value > 0 ? 'turn_on' : 'turn_off'; 18 | this._hass.callService('switch', service, { 19 | // eslint-disable-next-line @typescript-eslint/camelcase 20 | entity_id: this.stateObj.entity_id 21 | }); 22 | } 23 | 24 | get _step(): number { 25 | return 1; 26 | } 27 | 28 | get label(): string { 29 | if (this.percentage > 0) { 30 | return this._hass.localize('component.switch.state._.on'); 31 | } 32 | return this._hass.localize('component.switch.state._.off'); 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /src/editor.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | /* eslint-disable @typescript-eslint/camelcase */ 3 | import copy from 'fast-copy'; 4 | import { 5 | LitElement, 6 | html, 7 | customElement, 8 | property, 9 | TemplateResult, 10 | CSSResult, 11 | css, 12 | state 13 | } from 'lit-element'; 14 | import { HomeAssistant, fireEvent, LovelaceCardEditor, stateIcon, computeDomain } from 'custom-card-helpers'; 15 | import { localize } from './localize/localize'; 16 | import { ActionButtonConfig, ActionButtonConfigDefault, ActionButtonMode, Domain, IconConfig, IconConfigDefault, SliderBackground, SliderButtonCardConfig, SliderConfig, SliderConfigDefault, SliderDirections } from './types'; 17 | import { applyPatch, getEnumValues, getSliderDefaultForEntity } from './utils'; 18 | 19 | @customElement('slider-button-card-editor') 20 | export class SliderButtonCardEditor extends LitElement implements LovelaceCardEditor { 21 | @property({ attribute: false }) public hass?: HomeAssistant; 22 | @state() private _config?: SliderButtonCardConfig; 23 | @state() private _helpers?: any; 24 | private _initialized = false; 25 | private directions = getEnumValues(SliderDirections); 26 | private backgrounds = getEnumValues(SliderBackground); 27 | private actionModes = getEnumValues(ActionButtonMode); 28 | private actions = [ 29 | "more-info", 30 | "toggle", 31 | "navigate", 32 | "url", 33 | "call-service", 34 | "none", 35 | ]; 36 | 37 | 38 | public async setConfig(config: SliderButtonCardConfig): Promise { 39 | this._config = config; 40 | if (this._helpers === undefined) { 41 | await this.loadCardHelpers(); 42 | } 43 | } 44 | 45 | protected shouldUpdate(): boolean { 46 | if (!this._initialized) { 47 | this._initialize(); 48 | } 49 | 50 | return true; 51 | } 52 | 53 | get _name(): string { 54 | return this._config?.name || ''; 55 | } 56 | 57 | get _show_name(): boolean { 58 | return typeof this._config?.show_name === 'undefined' ? true : this._config?.show_name; 59 | } 60 | 61 | get _show_state(): boolean { 62 | return typeof this._config?.show_state === 'undefined' ? true : this._config?.show_state; 63 | } 64 | 65 | get _compact(): boolean { 66 | return typeof this._config?.compact !== 'boolean' ? false : this._config?.compact; 67 | } 68 | 69 | get _entity(): string { 70 | return this._config?.entity || ''; 71 | } 72 | 73 | get _icon(): IconConfig { 74 | return this._config?.icon || IconConfigDefault; 75 | } 76 | 77 | get _slider(): SliderConfig { 78 | return this._config?.slider || SliderConfigDefault; 79 | } 80 | 81 | get _action_button(): ActionButtonConfig { 82 | return this._config?.action_button || ActionButtonConfigDefault; 83 | } 84 | 85 | protected render(): TemplateResult | void { 86 | if (!this.hass || !this._helpers) { 87 | return html``; 88 | } 89 | // The climate more-info has ha-switch and paper-dropdown-menu elements that are lazy loaded unless explicitly done here 90 | this._helpers.importMoreInfoControl('climate'); 91 | 92 | return html` 93 |
94 |
95 |
96 | 97 | 98 |
99 | 108 | 115 |
116 | 117 | 122 | 123 | 124 | 129 | 130 | 131 | 136 | 137 |
138 |
139 |
140 | 141 |
142 | 143 | 144 |
145 | 152 | 153 |
154 | 155 | 160 | 161 | ${this.renderStateColor('icon')} 162 |
163 | 171 |
172 |
173 | 174 |
175 | 176 | 177 |
178 |
179 | 182 | 189 | ${this.directions.map(direction => { 190 | return html` 191 | ${localize(`direction.${direction}`)} 192 | `; 193 | })} 194 | 195 | 196 | 199 | 206 | ${this.backgrounds.map(background => { 207 | return html` 208 | ${localize(`background.${background}`)} 209 | `; 210 | })} 211 | 212 | 213 | 214 |
215 |
216 | ${this.renderBrightness('slider')} 217 | ${this.renderStateColor('slider')} 218 | 219 | 224 | 225 | 226 | 231 | 232 | 233 | 238 | 239 |
240 |
241 |
242 | 243 |
244 | 245 | 246 |
247 | 250 | 257 | ${this.actionModes.map(mode => { 258 | return html` 259 | ${localize(`mode.${mode}`)} 260 | `; 261 | })} 262 | 263 | 264 | ${this._action_button.mode === ActionButtonMode.CUSTOM 265 | ? html` 266 | 273 | 274 | ` 275 | : 276 | ''} 277 |
278 | 279 | 284 | 285 | ${this._action_button.mode === ActionButtonMode.CUSTOM 286 | ? html` 287 | 288 | 293 | 294 | ` 295 | : 296 | ''} 297 |
298 | ${this._action_button.mode === ActionButtonMode.CUSTOM 299 | ? html` 300 | 308 | ` 309 | : 310 | ''} 311 |
312 |
313 |
314 |
315 | `; 316 | } 317 | 318 | protected renderBrightness(path: string): TemplateResult | void { 319 | const item = this[`_${path}`]; 320 | return html` 321 | 322 | 327 | 328 | `; 329 | } 330 | 331 | protected renderStateColor(path: string): TemplateResult | void { 332 | const item = this[`_${path}`]; 333 | return html` 334 | 335 | 340 | 341 | `; 342 | } 343 | 344 | private _initialize(): void { 345 | if (this.hass === undefined) return; 346 | if (this._config === undefined) return; 347 | if (this._helpers === undefined) return; 348 | this._initialized = true; 349 | } 350 | 351 | private async loadCardHelpers(): Promise { 352 | this._helpers = await (window as any).loadCardHelpers(); 353 | } 354 | 355 | private _valueChangedSelect(ev): void { 356 | const value = ev.detail.value; 357 | if (!value) { 358 | return; 359 | } 360 | this._changeValue(value.parentElement?.configValue, value.itemValue); 361 | } 362 | 363 | private _valueChangedEntity(ev): void { 364 | const target = ev.target; 365 | const value = ev.detail?.value; 366 | const updateDefaults = computeDomain(value) !== computeDomain(this._config?.entity || 'light.dummy'); 367 | this._changeValue('name', ''); 368 | this._changeValue('icon.icon', ''); 369 | this._changeValue(target.configValue, value); 370 | if (updateDefaults) { 371 | const cfg = copy(this._config); 372 | applyPatch(cfg, ['slider'], getSliderDefaultForEntity(value)); 373 | this._config = cfg; 374 | fireEvent(this, 'config-changed', { config: this._config }); 375 | } 376 | } 377 | 378 | private _valueChanged(ev): void { 379 | const target = ev.target; 380 | const value = ev.detail?.value; 381 | this._changeValue(target.configValue, target.checked !== undefined ? target.checked : value); 382 | } 383 | 384 | private _changeValue(configValue: string, value: string | boolean | number): void { 385 | if (!this._config || !this.hass) { 386 | return; 387 | } 388 | if (this[`_${configValue}`] !== undefined && this[`_${configValue}`] === value) { 389 | return; 390 | } 391 | if (configValue) { 392 | const cfg = copy(this._config); 393 | applyPatch(cfg, [...configValue.split('.')], value); 394 | this._config = cfg; 395 | if (value === '') { 396 | delete this._config[configValue]; 397 | } 398 | } 399 | fireEvent(this, 'config-changed', { config: this._config }); 400 | } 401 | 402 | static get styles(): CSSResult { 403 | return css` 404 | ha-switch { 405 | padding: 16px 6px; 406 | } 407 | .side-by-side { 408 | display: flex; 409 | flex-flow: row wrap; 410 | } 411 | .side-by-side > * { 412 | padding-right: 8px; 413 | width: 50%; 414 | flex-flow: column wrap; 415 | box-sizing: border-box; 416 | } 417 | .side-by-side > *:last-child { 418 | flex: 1; 419 | padding-right: 0; 420 | } 421 | .suffix { 422 | margin: 0 8px; 423 | } 424 | .group { 425 | padding: 15px; 426 | border: 1px solid var(--primary-text-color) 427 | } 428 | .tabs { 429 | overflow: hidden; 430 | } 431 | .tab { 432 | width: 100%; 433 | color: var(--primary-text-color); 434 | overflow: hidden; 435 | } 436 | .tab-label { 437 | display: flex; 438 | justify-content: space-between; 439 | padding: 1em 1em 1em 0em; 440 | border-bottom: 1px solid var(--secondary-text-color); 441 | font-weight: bold; 442 | cursor: pointer; 443 | } 444 | .tab-label:hover { 445 | /*background: #1a252f;*/ 446 | } 447 | .tab-label::after { 448 | content: "❯"; 449 | width: 1em; 450 | height: 1em; 451 | text-align: center; 452 | transition: all 0.35s; 453 | } 454 | .tab-content { 455 | max-height: 0; 456 | padding: 0 1em; 457 | background: var(--secondary-background-color); 458 | transition: all 0.35s; 459 | } 460 | input.tab-checkbox { 461 | position: absolute; 462 | opacity: 0; 463 | z-index: -1; 464 | } 465 | input.tab-checkbox:checked + .tab-label { 466 | border-color: var(--accent-color); 467 | } 468 | input.tab-checkbox:checked + .tab-label::after { 469 | transform: rotate(90deg); 470 | } 471 | input.tab-checkbox:checked ~ .tab-content { 472 | max-height: 100vh; 473 | padding: 1em; 474 | } 475 | `; 476 | } 477 | } 478 | -------------------------------------------------------------------------------- /src/localize/languages/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "common": { 3 | "version": "v", 4 | "invalid_configuration": "Ungültige Konfiguration", 5 | "show_warning": "Zeige Warnung", 6 | "show_error": "Zeige Fehler" 7 | }, 8 | "tabs": { 9 | "general": { 10 | "title": "Allgemein", 11 | "entity": "Entiät (vorgeschrieben)", 12 | "name": "Name (optional)", 13 | "show_name": "Namen zeigen?", 14 | "show_state": "Zustand zeigen?", 15 | "compact": "Kompakt?" 16 | }, 17 | "icon": { 18 | "title": "Icon", 19 | "icon": "Icon (optional)", 20 | "show_icon": "Icon zeigen?", 21 | "use_state_color": "Zustandsfarbe verwenden?", 22 | "tap_action": "Tap action" 23 | }, 24 | "slider": { 25 | "title": "Schieberegler", 26 | "direction": "Richtung", 27 | "background": "Hintergrund", 28 | "use_brightness": "Helligkeit benutzen?", 29 | "show_track": "Spur anzeigen?", 30 | "toggle_on_click": "Als Schalter benutzen (schieben deaktivieren)", 31 | "force_square": "Quadrat erzwingen?" 32 | }, 33 | "action_button": { 34 | "title": "Action-Knopf", 35 | "mode": "Modus", 36 | "icon": "Icon", 37 | "show_button": "Knopf zeigen?", 38 | "show_spinner": "Spinner anzeigen?", 39 | "tap_action": "Tap action" 40 | } 41 | }, 42 | "state": { 43 | "off": "Aus", 44 | "on": "An" 45 | }, 46 | "direction": { 47 | "left-right": "Links nach Rechts", 48 | "top-bottom": "Oben nach Unten", 49 | "bottom-top": "Unten nach Oben" 50 | }, 51 | "background": { 52 | "striped": "gestreift", 53 | "gradient": "Farbverlauf", 54 | "solid": "Einfarbig", 55 | "triangle": "Dreieck", 56 | "custom": "benuzerdefiniert" 57 | }, 58 | "mode": { 59 | "toggle": "Umschalter", 60 | "custom": "benuzerdefiniert" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/localize/languages/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "common": { 3 | "version": "v", 4 | "invalid_configuration": "Invalid configuration", 5 | "show_warning": "Show Warning", 6 | "show_error": "Show Error" 7 | }, 8 | "tabs": { 9 | "general": { 10 | "title": "General", 11 | "entity": "Entity (Required)", 12 | "name": "Name (Optional)", 13 | "show_name": "Show name?", 14 | "show_state": "Show state?", 15 | "compact": "Compact?" 16 | }, 17 | "icon": { 18 | "title": "Icon", 19 | "icon": "Icon (Optional)", 20 | "show_icon": "Show icon?", 21 | "use_state_color": "Use state color?", 22 | "tap_action": "Tap action" 23 | }, 24 | "slider": { 25 | "title": "Slider", 26 | "direction": "Direction", 27 | "background": "Background", 28 | "use_brightness": "Use brightness?", 29 | "show_track": "Show track?", 30 | "toggle_on_click": "Act as a toggle (disable sliding)", 31 | "force_square": "Force square?" 32 | }, 33 | "action_button": { 34 | "title": "Action button", 35 | "mode": "Mode", 36 | "icon": "Icon", 37 | "show_button": "Show button?", 38 | "show_spinner": "Show spinner?", 39 | "tap_action": "Tap action" 40 | } 41 | }, 42 | "state": { 43 | "off": "Off", 44 | "on": "On" 45 | }, 46 | "direction": { 47 | "left-right": "Left to right", 48 | "top-bottom": "Top to bottom", 49 | "bottom-top": "Bottom to top" 50 | }, 51 | "background": { 52 | "striped": "Striped", 53 | "gradient": "Gradient", 54 | "solid": "Solid", 55 | "triangle": "Triangle", 56 | "custom": "Custom" 57 | }, 58 | "mode": { 59 | "toggle": "Toggle", 60 | "custom": "Custom" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/localize/languages/fr.json: -------------------------------------------------------------------------------- 1 | { 2 | "common": { 3 | "version": "v", 4 | "invalid_configuration": "Configuration incorrecte", 5 | "show_warning": "Afficher les avertissement", 6 | "show_error": "Afficher les erreurs" 7 | }, 8 | "tabs": { 9 | "general": { 10 | "title": "Général", 11 | "entity": "Entité (Obligatoire)", 12 | "name": "Nom (Optionnel)", 13 | "show_name": "Afficher le nom ?", 14 | "show_state": "Afficher l'état ?", 15 | "compact": "Compact ?" 16 | }, 17 | "icon": { 18 | "title": "Icône", 19 | "icon": "Icône (Optionnel)", 20 | "show_icon": "Afficher l'icône ?", 21 | "use_state_color": "Afficher la couleur d'état?", 22 | "tap_action": "Action" 23 | }, 24 | "slider": { 25 | "title": "Curseur", 26 | "direction": "Direction", 27 | "background": "Fond", 28 | "use_brightness": "Utiliser la luminosité ?", 29 | "show_track": "Afficher le chemin ?", 30 | "toggle_on_click": "Agir comme un bouton (désactive le curseur)", 31 | "force_square": "Forcer carré ?" 32 | }, 33 | "action_button": { 34 | "title": "Bouton d'action", 35 | "mode": "Mode", 36 | "icon": "Icône", 37 | "show_button": "Afficher le bouton ?", 38 | "show_spinner": "Afficher spinner ?", 39 | "tap_action": "Action" 40 | } 41 | }, 42 | "state": { 43 | "off": "Inactif", 44 | "on": "Actif" 45 | }, 46 | "direction": { 47 | "left-right": "gauche à droite", 48 | "top-bottom": "haut à bas", 49 | "bottom-top": "Bas à haut" 50 | }, 51 | "background": { 52 | "striped": "Rayures", 53 | "gradient": "Dégradé", 54 | "solid": "Uni", 55 | "triangle": "Triangle", 56 | "custom": "Personnalisé" 57 | }, 58 | "mode": { 59 | "toggle": "Bascule", 60 | "custom": "Personnalisé" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/localize/languages/he.json: -------------------------------------------------------------------------------- 1 | { 2 | "common": { 3 | "version": "v", 4 | "invalid_configuration": "תצורה לא חוקית", 5 | "show_warning": "הצג אזהרה", 6 | "show_error": "הצג שגיאה" 7 | }, 8 | "tabs": { 9 | "general": { 10 | "title": "כללי", 11 | "entity": "ישיות (נדרש)", 12 | "name": "שם (אופציונלי)", 13 | "show_name": "להציג שם?", 14 | "show_state": "להציג מצב?", 15 | "compact": "קוֹמפָּקטִי?" 16 | }, 17 | "icon": { 18 | "title": "סמליל", 19 | "icon": "סמליל (אופציונלי)", 20 | "show_icon": "להציג סמליל?", 21 | "use_state_color": "להשתמש בצבע מצב?", 22 | "tap_action": "פעולה בהקשה" 23 | }, 24 | "slider": { 25 | "title": "גלילה", 26 | "direction": "כיוון", 27 | "background": "רקע", 28 | "use_brightness": "להשתמש בבהירות?", 29 | "show_track": "להציג מסלול?", 30 | "toggle_on_click": "פעל כמתג (השבת החלקה)", 31 | "force_square": "כוח מרובע?" 32 | }, 33 | "action_button": { 34 | "title": "כפתור פעולה", 35 | "mode": "מצב", 36 | "icon": "סמליל", 37 | "show_button": "להציג כפתור?", 38 | "show_spinner": "להציג ספינר?", 39 | "tap_action": "פעולה בהקשה" 40 | } 41 | }, 42 | "state": { 43 | "off": "כבוי", 44 | "on": "פועל" 45 | }, 46 | "direction": { 47 | "left-right": "שמאל לימין", 48 | "top-bottom": "מלמעלה למטה", 49 | "bottom-top": "מלמטה למעלה" 50 | }, 51 | "background": { 52 | "striped": "מפוספס", 53 | "gradient": "שיפוע", 54 | "solid": "מוצק", 55 | "triangle": "משולש", 56 | "custom": "מותאם אישית" 57 | }, 58 | "mode": { 59 | "toggle": "החלפה", 60 | "custom": "מותאם אישית" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/localize/languages/ko.json: -------------------------------------------------------------------------------- 1 | { 2 | "common": { 3 | "version": "v", 4 | "invalid_configuration": "유효하지 않은 설정입니다", 5 | "show_warning": "경고 표시", 6 | "show_error": "에러 표시" 7 | }, 8 | "tabs": { 9 | "general": { 10 | "title": "일반", 11 | "entity": "구성 요소 (필수)", 12 | "name": "이름 (옵션)", 13 | "show_name": "이름 표시", 14 | "show_state": "상태 표시", 15 | "compact": "슬림 모드" 16 | }, 17 | "icon": { 18 | "title": "아이콘", 19 | "icon": "아이콘 (옵션)", 20 | "show_icon": "아이콘 표시", 21 | "use_state_color": "상태 색상 사용", 22 | "tap_action": "탭 액션" 23 | }, 24 | "slider": { 25 | "title": "슬라이더", 26 | "direction": "방향 지정", 27 | "background": "배경", 28 | "use_brightness": "밝기 사용", 29 | "show_track": "범위 표시", 30 | "toggle_on_click": "토글 버튼으로 동작(슬라이더 비활성화)", 31 | "force_square": "정사각형 모양으로 고정" 32 | }, 33 | "action_button": { 34 | "title": "액션 버튼", 35 | "mode": "모드", 36 | "icon": "아이콘", 37 | "show_button": "버튼 표시", 38 | "show_spinner": "로딩 스피너 표시", 39 | "tap_action": "탭 액셥" 40 | } 41 | }, 42 | "state": { 43 | "off": "꺼짐", 44 | "on": "켜짐" 45 | }, 46 | "direction": { 47 | "left-right": "왼쪽에서 오른쪽", 48 | "top-bottom": "위에서 아래", 49 | "bottom-top": "아래에서 위" 50 | }, 51 | "background": { 52 | "striped": "줄무늬", 53 | "gradient": "그레디언트", 54 | "solid": "단색", 55 | "triangle": "삼각형", 56 | "custom": "커스텀" 57 | }, 58 | "mode": { 59 | "toggle": "토글 모드", 60 | "custom": "커스텀 모드" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/localize/languages/nl.json: -------------------------------------------------------------------------------- 1 | { 2 | "common": { 3 | "version": "v", 4 | "invalid_configuration": "Ongeldige configuratie", 5 | "show_warning": "Toon waarschuwing", 6 | "show_error": "Toon fout" 7 | }, 8 | "tabs": { 9 | "general": { 10 | "title": "Algemeen", 11 | "entity": "Entiteit (Verplicht)", 12 | "name": "Naam (Optioneel)", 13 | "show_name": "Toon naam?", 14 | "show_state": "Toon status?", 15 | "compact": "Compact?" 16 | }, 17 | "icon": { 18 | "title": "Icoon", 19 | "icon": "Icoon (Optioneel)", 20 | "show_icon": "Toon icoon?", 21 | "use_state_color": "Gebruik status kleur?", 22 | "tap_action": "Tap actie" 23 | }, 24 | "slider": { 25 | "title": "Schuifregelaar", 26 | "direction": "Richting", 27 | "background": "Actergrond", 28 | "use_brightness": "Gebruik helderheid?", 29 | "show_track": "Toon spoor?", 30 | "toggle_on_click": "Fungeren als een schakelaar (schuiven uitschakelen)", 31 | "force_square": "Forceer vierkant?" 32 | }, 33 | "action_button": { 34 | "title": "Actie button", 35 | "mode": "Modus", 36 | "icon": "Icoon", 37 | "show_button": "Toon button?", 38 | "show_spinner": "Toon spinner?", 39 | "tap_action": "Tap actie" 40 | } 41 | }, 42 | "state": { 43 | "off": "Uit", 44 | "on": "Aan" 45 | }, 46 | "direction": { 47 | "left-right": "Links naar rechts", 48 | "top-bottom": "Boven naar onder", 49 | "bottom-top": "Onder naar boven" 50 | }, 51 | "background": { 52 | "striped": "Gestreept", 53 | "gradient": "Verloop", 54 | "solid": "Vast", 55 | "triangle": "Driehoek", 56 | "custom": "Aangepast" 57 | }, 58 | "mode": { 59 | "toggle": "Schakelaar", 60 | "custom": "Aangepast" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/localize/languages/pl.json: -------------------------------------------------------------------------------- 1 | { 2 | "common": { 3 | "version": "v", 4 | "invalid_configuration": "Nieprawidłowa konfiguracja", 5 | "show_warning": "Pokaż ostrzeżenia", 6 | "show_error": "Pokaż błędy" 7 | }, 8 | "tabs": { 9 | "general": { 10 | "title": "Ogólne", 11 | "entity": "Encja (Wymagana)", 12 | "name": "Nazwa (Opcjonalna)", 13 | "show_name": "Pokazać nazwę?", 14 | "show_state": "Pokazać stan?", 15 | "compact": "Kompaktowy?" 16 | }, 17 | "icon": { 18 | "title": "Ikona", 19 | "icon": "Ikona (Opcjonalna)", 20 | "show_icon": "Pokazać ikonę?", 21 | "use_state_color": "Uzyć kolor stanu?", 22 | "tap_action": "Akcja kliknięcia" 23 | }, 24 | "slider": { 25 | "title": "Suwak", 26 | "direction": "Kierunek", 27 | "background": "Tło", 28 | "use_brightness": "Użyć jasności?", 29 | "show_track": "Pokazać ślad?", 30 | "toggle_on_click": "Działaj jako przełącznik (wyłącz przesuwanie)", 31 | "force_square": "Wymusić kwadrat?" 32 | }, 33 | "action_button": { 34 | "title": "Przycisk akcji", 35 | "mode": "Tryb", 36 | "icon": "Ikona", 37 | "show_button": "Pokazać przycisk?", 38 | "show_spinner": "Pokazać spinner?", 39 | "tap_action": "Akcja kliknięcia" 40 | } 41 | }, 42 | "state": { 43 | "off": "Wyłączony", 44 | "on": "Włączony" 45 | }, 46 | "direction": { 47 | "left-right": "Z lewej do prawej", 48 | "top-bottom": "Z góry na dół", 49 | "bottom-top": "Z dołu do góry" 50 | }, 51 | "background": { 52 | "striped": "W paski", 53 | "gradient": "Gradient", 54 | "solid": "Pełne tło", 55 | "triangle": "Trójkąt", 56 | "custom": "Ustawienia własne" 57 | }, 58 | "mode": { 59 | "toggle": "Przełącznik", 60 | "custom": "Ustawienia własne" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/localize/languages/pt.json: -------------------------------------------------------------------------------- 1 | { 2 | "common": { 3 | "version": "v", 4 | "invalid_configuration": "Configuração Inválida", 5 | "show_warning": "Mostrar Aviso", 6 | "show_error": "Mostrar Erro" 7 | }, 8 | "tabs": { 9 | "general": { 10 | "title": "Geral", 11 | "entity": "Entidade (Obrigatório)", 12 | "name": "Nome (Opcional)", 13 | "show_name": "Mostrar Nome?", 14 | "show_state": "Mostrar Estado?", 15 | "compact": "Compactar?" 16 | }, 17 | "icon": { 18 | "title": "Ícone", 19 | "icon": "Ícone (Opcional)", 20 | "show_icon": "Mostrar Ícone?", 21 | "use_state_color": "Usar Cor de Estado?", 22 | "tap_action": "Ação de Toque" 23 | }, 24 | "slider": { 25 | "title": "Slider", 26 | "direction": "Direção", 27 | "background": "Fundo", 28 | "use_brightness": "Usar Brilho?", 29 | "show_track": "Mostrar Acompanhamento?", 30 | "toggle_on_click": "Atua como um alternador (desative o deslizamento)", 31 | "force_square": "Forçar Quadrado?" 32 | }, 33 | "action_button": { 34 | "title": "Botão de Ação", 35 | "mode": "Modo", 36 | "icon": "Ícone", 37 | "show_button": "Mostrar Botão?", 38 | "show_spinner": "Mostrar Spinner?", 39 | "tap_action": "Ação de Toque" 40 | } 41 | }, 42 | "state": { 43 | "off": "Desligar", 44 | "on": "Ligar" 45 | }, 46 | "direction": { 47 | "left-right": "Esquerda para a Direita", 48 | "top-bottom": "De Cima para Baixo", 49 | "bottom-top": "De Baixo para Cima" 50 | }, 51 | "background": { 52 | "striped": "Listrado", 53 | "gradient": "Gradiente", 54 | "solid": "Sólido", 55 | "triangle": "Triângulo", 56 | "custom": "Personalizado" 57 | }, 58 | "mode": { 59 | "toggle": "Alternancia", 60 | "custom": "Personalizado" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/localize/languages/ru.json: -------------------------------------------------------------------------------- 1 | { 2 | "common": { 3 | "version": "v", 4 | "invalid_configuration": "Неверная конфигурация", 5 | "show_warning": "Показать предупреждения", 6 | "show_error": "Показать ошибки" 7 | }, 8 | "tabs": { 9 | "general": { 10 | "title": "Общие", 11 | "entity": "Объект (обязательно)", 12 | "name": "Имя (Опционально)", 13 | "show_name": "Отображать имя?", 14 | "show_state": "Отображать статус?", 15 | "compact": "Компактный?" 16 | }, 17 | "icon": { 18 | "title": "Иконка", 19 | "icon": "Иконка (Опционально)", 20 | "show_icon": "Показать иконку?", 21 | "use_state_color": "Использовать цвет статуса?", 22 | "tap_action": "Действие по нажатию" 23 | }, 24 | "slider": { 25 | "title": "Слайдер", 26 | "direction": "Направление", 27 | "background": "Фон", 28 | "use_brightness": "Использовать яркость?", 29 | "show_track": "Показать трек?", 30 | "toggle_on_click": "Действовать как переключатель (отключить скольжение)", 31 | "force_square": "Отображать квадратным?" 32 | }, 33 | "action_button": { 34 | "title": "Кнопка действия", 35 | "mode": "Режим", 36 | "icon": "Иконка", 37 | "show_button": "Отобразить кнопку?", 38 | "show_spinner": "Отобразить спиннер?", 39 | "tap_action": "Действие по нажатию" 40 | } 41 | }, 42 | "state": { 43 | "off": "Выкл", 44 | "on": "Вкл" 45 | }, 46 | "direction": { 47 | "left-right": "Слева направо", 48 | "top-bottom": "Сверху вниз", 49 | "bottom-top": "Снизу вверх" 50 | }, 51 | "background": { 52 | "striped": "Полосатый", 53 | "gradient": "Градиент", 54 | "solid": "Сплошной цвет", 55 | "triangle": "Треугольник", 56 | "custom": "Свои настройки" 57 | }, 58 | "mode": { 59 | "toggle": "Переключатель", 60 | "custom": "Свои настройки" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/localize/localize.ts: -------------------------------------------------------------------------------- 1 | import * as en from './languages/en.json'; 2 | import * as de from './languages/de.json'; 3 | import * as fr from './languages/fr.json'; 4 | import * as he from './languages/he.json'; 5 | import * as nl from './languages/nl.json'; 6 | import * as pl from './languages/pl.json'; 7 | import * as pt from './languages/pt.json'; 8 | import * as ru from './languages/ru.json'; 9 | import * as ko from './languages/ko.json'; 10 | 11 | 12 | 13 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 14 | const languages: any = { 15 | en: en, 16 | de: de, 17 | fr: fr, 18 | he: he, 19 | nl: nl, 20 | pl: pl, 21 | pt: pt, 22 | ru: ru, 23 | ko: ko, 24 | }; 25 | 26 | export function localize(string: string, search = '', replace = ''): string { 27 | const lang = (localStorage.getItem('selectedLanguage') || 'en').replace(/['"]+/g, '').replace('-', '_'); 28 | 29 | let translated: string; 30 | 31 | try { 32 | translated = string.split('.').reduce((o, i) => o[i], languages[lang]); 33 | } catch (e) { 34 | translated = string.split('.').reduce((o, i) => o[i], languages['en']); 35 | } 36 | 37 | if (translated === undefined) translated = string.split('.').reduce((o, i) => o[i], languages['en']); 38 | 39 | if (search !== '' && replace !== '') { 40 | translated = translated.replace(search, replace); 41 | } 42 | return translated; 43 | } 44 | -------------------------------------------------------------------------------- /src/slider-button-card.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import { ActionHandlerEvent, applyThemesOnElement, computeStateDomain, handleAction, hasConfigOrEntityChanged, HomeAssistant, LovelaceCard, LovelaceCardEditor, STATES_OFF, toggleEntity } from 'custom-card-helpers'; 3 | import copy from 'fast-copy'; 4 | import { css, CSSResult, customElement, html, LitElement, property, PropertyValues, query, state, TemplateResult } from 'lit-element'; 5 | import { classMap } from 'lit-html/directives/class-map'; 6 | import { ifDefined } from 'lit-html/directives/if-defined'; 7 | import { styleMap } from 'lit-html/directives/style-map'; 8 | import { actionHandler } from './action-handler-directive'; 9 | import { CARD_VERSION } from './const'; 10 | import { Controller } from './controllers/controller'; 11 | import { ControllerFactory } from './controllers/get-controller'; 12 | import './editor'; 13 | import { localize } from './localize/localize'; 14 | 15 | import type { SliderButtonCardConfig } from './types'; 16 | import { ActionButtonConfigDefault, ActionButtonMode, IconConfigDefault } from './types'; 17 | import { getSliderDefaultForEntity } from './utils'; 18 | 19 | /* eslint no-console: 0 */ 20 | console.info( 21 | `%c SLIDER-BUTTON-CARD %c ${localize('common.version')}${CARD_VERSION} %c`, 22 | 'background-color: #555;color: #fff;padding: 3px 2px 3px 3px;border: 1px solid #555;border-radius: 3px 0 0 3px;font-family: Roboto,Verdana,Geneva,sans-serif;text-shadow: 0 1px 0 rgba(1, 1, 1, 0.3)', 23 | 'background-color: transparent;color: #555;padding: 3px 3px 3px 2px;border: 1px solid #555; border-radius: 0 3px 3px 0;font-family: Roboto,Verdana,Geneva,sans-serif', 24 | 'background-color: transparent' 25 | 26 | ); 27 | 28 | // This puts your card into the UI card picker dialog 29 | (window as any).customCards = (window as any).customCards || []; 30 | (window as any).customCards.push({ 31 | type: 'slider-button-card', 32 | name: 'Slider button Card', 33 | description: 'A button card with slider', 34 | preview: true, 35 | }); 36 | 37 | @customElement('slider-button-card') 38 | export class SliderButtonCard extends LitElement implements LovelaceCard { 39 | @property({attribute: false}) public hass!: HomeAssistant; 40 | @state() private config!: SliderButtonCardConfig; 41 | @query('.state') stateText; 42 | @query('.button') button; 43 | @query('.action') action; 44 | @query('.slider') slider; 45 | private changing = false; 46 | private changed = false; 47 | private ctrl!: Controller; 48 | private actionTimeout; 49 | 50 | public static async getConfigElement(): Promise { 51 | return document.createElement('slider-button-card-editor'); 52 | } 53 | 54 | public static getStubConfig(hass: HomeAssistant, entities: string[]): object { 55 | const entity = entities.find(item => item.startsWith('light')) || ''; 56 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 57 | const dummy = hass; 58 | return { 59 | entity: entity, 60 | slider: getSliderDefaultForEntity(entity), 61 | // eslint-disable-next-line @typescript-eslint/camelcase 62 | show_name: true, 63 | // eslint-disable-next-line @typescript-eslint/camelcase 64 | show_state: true, 65 | compact: false, 66 | icon: copy(IconConfigDefault), 67 | // eslint-disable-next-line @typescript-eslint/camelcase 68 | action_button: copy(ActionButtonConfigDefault), 69 | }; 70 | } 71 | public getCardSize(): number { 72 | return 0; 73 | } 74 | 75 | public setConfig(config: SliderButtonCardConfig): void { 76 | if (!config) { 77 | throw new Error(localize('common.invalid_configuration')); 78 | } 79 | 80 | if (!config.entity) { 81 | throw new Error(localize('common.invalid_configuration')); 82 | } 83 | 84 | this.config = { 85 | slider: getSliderDefaultForEntity(config.entity), 86 | icon: copy(IconConfigDefault), 87 | // eslint-disable-next-line @typescript-eslint/camelcase 88 | show_name: true, 89 | // eslint-disable-next-line @typescript-eslint/camelcase 90 | show_state: true, 91 | compact: false, 92 | // eslint-disable-next-line @typescript-eslint/camelcase 93 | action_button: copy(ActionButtonConfigDefault), 94 | debug: false, 95 | ...config 96 | }; 97 | this.ctrl = ControllerFactory.getInstance(this.config); 98 | } 99 | 100 | protected shouldUpdate(changedProps: PropertyValues): boolean { 101 | if (!this.config) { 102 | return false; 103 | } 104 | const oldHass = changedProps.get('hass') as HomeAssistant | undefined; 105 | if ( 106 | !oldHass || 107 | oldHass.themes !== this.hass.themes || 108 | oldHass.language !== this.hass.language 109 | ) { 110 | this.ctrl.log('shouldUpdate', 'forced true'); 111 | return true; 112 | } 113 | return hasConfigOrEntityChanged(this, changedProps, false); 114 | } 115 | 116 | protected updated(changedProps: PropertyValues): void { 117 | this.updateValue(this.ctrl.value, false); 118 | this.animateActionEnd(); 119 | const oldHass = changedProps.get('hass') as HomeAssistant | undefined; 120 | const oldConfig = changedProps.get('config') as 121 | | SliderButtonCardConfig 122 | | undefined; 123 | if ( 124 | oldHass?.themes !== this.hass.themes || 125 | oldConfig?.theme !== this.config.theme 126 | ) { 127 | this.ctrl.log('Theme','updated'); 128 | applyThemesOnElement(this, this.hass.themes, this.config.theme); 129 | } 130 | this.ctrl.log('Updated', this.ctrl.value); 131 | } 132 | 133 | protected firstUpdated(_changedProperties: PropertyValues): void { 134 | super.firstUpdated(_changedProperties); 135 | } 136 | 137 | protected render(): TemplateResult | void { 138 | this.ctrl.hass = this.hass; 139 | if (!this.ctrl.stateObj) { 140 | return this._showError(localize('common.show_error')); 141 | } 142 | return html` 143 | 148 |
157 |
166 | ${this.ctrl.hasToggle 167 | ? html` 168 |
169 | ` 170 | : ''} 171 |
172 |
173 |
174 | ${this.renderText()} 175 | ${this.renderAction()} 176 | ${this.renderIcon()} 177 |
178 |
179 | `; 180 | } 181 | 182 | private renderText(): TemplateResult { 183 | if (!this.config.show_name && !this.config.show_state) { 184 | return html``; 185 | } 186 | return html` 187 |
188 | ${this.config.show_name 189 | ? html` 190 |
${this.ctrl.name}
191 | ` 192 | : ''} 193 | ${this.config.show_state 194 | ? html` 195 |
196 | ${this.ctrl.isUnavailable 197 | ? html` 198 | ${this.hass.localize('state.default.unavailable')} 199 | ` : html` 200 | ${this.ctrl.label} 201 | `} 202 |
203 | ` 204 | : ''} 205 |
206 | `; 207 | } 208 | 209 | private renderIcon(): TemplateResult { 210 | if (this.config.icon?.show === false) { 211 | return html``; 212 | } 213 | let hasPicture = false; 214 | let backgroundImage = ''; 215 | if (this.ctrl.stateObj.attributes.entity_picture) { 216 | backgroundImage = `url(${this.ctrl.stateObj.attributes.entity_picture})`; 217 | hasPicture = true; 218 | } 219 | return html` 220 |
this._handleAction(e, this.config.icon)} 222 | .actionHandler=${actionHandler({ 223 | hasHold: false, 224 | hasDoubleClick: false, 225 | })} 226 | style=${styleMap({ 227 | 'background-image': `${backgroundImage}`, 228 | })} 229 | > 230 | 238 |
239 | `; 240 | } 241 | 242 | private renderAction(): TemplateResult { 243 | if (this.config.action_button?.show === false) { 244 | return html``; 245 | } 246 | if (this.config.action_button?.mode === ActionButtonMode.TOGGLE) { 247 | return html` 248 |
249 | 254 |
255 | `; 256 | } 257 | return html` 258 |
this._handleAction(e, this.config.action_button)} 260 | .actionHandler=${actionHandler({ 261 | hasHold: false, 262 | hasDoubleClick: false, 263 | })} 264 | > 265 | 269 | ${typeof this.config.action_button?.show_spinner === 'undefined' || this.config.action_button?.show_spinner 270 | ? html` 271 | 272 | 273 | 274 | ` 275 | : ''} 276 |
277 | `; 278 | } 279 | 280 | private _handleAction(ev: ActionHandlerEvent, config): void { 281 | if (this.hass && this.config && ev.detail.action) { 282 | if (config.tap_action?.action === 'toggle' && !this.ctrl.isUnavailable) { 283 | this.animateActionStart(); 284 | } 285 | handleAction(this, this.hass, {...config, entity: this.config.entity}, ev.detail.action); 286 | } 287 | } 288 | 289 | private async handleClick(ev: Event): Promise { 290 | if (this.ctrl.hasToggle && !this.ctrl.isUnavailable) { 291 | ev.preventDefault(); 292 | this.animateActionStart(); 293 | this.ctrl.log('Toggle'); 294 | await toggleEntity(this.hass, this.config.entity); 295 | // this.setStateValue(this.ctrl.toggleValue); 296 | } 297 | } 298 | 299 | private _toggle(): void { 300 | if (this.hass && this.config) { 301 | // eslint-disable-next-line @typescript-eslint/camelcase 302 | handleAction(this, this.hass, {tap_action: {action: 'toggle'}, entity: this.config.entity}, 'tap'); 303 | } 304 | } 305 | 306 | private setStateValue(value: number): void { 307 | this.ctrl.log('setStateValue', value); 308 | this.updateValue(value, false); 309 | this.ctrl.value = value; 310 | this.animateActionStart(); 311 | } 312 | 313 | private animateActionStart(): void { 314 | this.animateActionEnd(); 315 | if (this.action) { 316 | this.action.classList.add('loading'); 317 | } 318 | } 319 | 320 | private animateActionEnd(): void { 321 | if (this.action) { 322 | clearTimeout(this.actionTimeout); 323 | this.actionTimeout = setTimeout(()=> { 324 | this.action.classList.remove('loading'); 325 | }, 750) 326 | } 327 | } 328 | 329 | private updateValue(value: number, changing = true): void { 330 | this.changing = changing; 331 | this.changed = !changing; 332 | this.ctrl.log('updateValue', value); 333 | this.ctrl.targetValue = value; 334 | if (!this.button) { 335 | return 336 | } 337 | this.button.classList.remove('off'); 338 | if (changing) { 339 | this.button.classList.add('changing'); 340 | } else { 341 | this.button.classList.remove('changing'); 342 | if (this.ctrl.isOff) { 343 | this.button.classList.add('off'); 344 | } 345 | } 346 | if (this.stateText) { 347 | this.stateText.innerHTML = this.ctrl.isUnavailable ? `${this.hass.localize('state.default.unavailable')}` : this.ctrl.label; 348 | } 349 | this.button.style.setProperty('--slider-value', `${this.ctrl.percentage}%`); 350 | this.button.style.setProperty('--slider-bg-filter', this.ctrl.style.slider.filter); 351 | this.button.style.setProperty('--slider-color', this.ctrl.style.slider.color); 352 | this.button.style.setProperty('--icon-filter', this.ctrl.style.icon.filter); 353 | this.button.style.setProperty('--icon-color', this.ctrl.style.icon.color); 354 | this.button.style.setProperty('--icon-rotate-speed', this.ctrl.style.icon.rotateSpeed || '0s'); 355 | } 356 | 357 | private _showError(error: string): TemplateResult { 358 | const errorCard = document.createElement('hui-error-card'); 359 | errorCard.setConfig({ 360 | type: 'error', 361 | error, 362 | origConfig: this.config 363 | }); 364 | 365 | return html` 366 | ${errorCard} 367 | `; 368 | } 369 | 370 | private getColorFromVariable(color: string): string { 371 | if (typeof color !== 'undefined' && color.substring(0, 3) === 'var') { 372 | let varColor = window.getComputedStyle(this).getPropertyValue(color.substring(4).slice(0, -1)).trim(); 373 | if (!varColor.length) { 374 | varColor = window.getComputedStyle(document.documentElement).getPropertyValue(color.substring(4).slice(0, -1)).trim(); 375 | } 376 | return varColor 377 | } 378 | return color; 379 | } 380 | 381 | private onPointerDown(event: PointerEvent): void { 382 | event.preventDefault(); 383 | event.stopPropagation(); 384 | if (this.ctrl.isSliderDisabled) { 385 | return; 386 | } 387 | this.slider.setPointerCapture(event.pointerId); 388 | } 389 | 390 | private onPointerUp(event: PointerEvent): void { 391 | if (this.ctrl.isSliderDisabled) { 392 | return; 393 | } 394 | this.setStateValue(this.ctrl.targetValue); 395 | this.slider.releasePointerCapture(event.pointerId); 396 | } 397 | 398 | private onPointerMove(event: any): void { 399 | if (this.ctrl.isSliderDisabled) { 400 | return; 401 | } 402 | if (!this.slider.hasPointerCapture(event.pointerId)) return; 403 | const {left, top, width, height} = this.slider.getBoundingClientRect(); 404 | const percentage = this.ctrl.moveSlider(event, {left, top, width, height}); 405 | this.ctrl.log('onPointerMove', percentage); 406 | this.updateValue(percentage); 407 | } 408 | 409 | connectedCallback(): void { 410 | super.connectedCallback(); 411 | } 412 | 413 | disconnectedCallback(): void { 414 | super.disconnectedCallback(); 415 | } 416 | 417 | static get styles(): CSSResult { 418 | return css` 419 | ha-card { 420 | box-sizing: border-box; 421 | height: 100%; 422 | width: 100%; 423 | min-height: 7rem; 424 | display: flex; 425 | flex-direction: column; 426 | justify-content: space-between; 427 | touch-action: none; 428 | overflow: hidden; 429 | --mdc-icon-size: 2.2em; 430 | } 431 | ha-card.square { 432 | aspect-ratio: 1 / 1; 433 | } 434 | ha-card.compact { 435 | min-height: 3rem !important; 436 | } 437 | :host { 438 | --slider-bg-default-color: var(--primary-color, rgb(95, 124, 171)); 439 | --slider-bg: var(--slider-color); 440 | --slider-bg-filter: brightness(100%); 441 | --slider-bg-direction: to right; 442 | --slider-track-color: #2b374e; 443 | --slider-tracker-color: transparent; 444 | --slider-value: 0%; 445 | --slider-transition-duration: 0.2s; 446 | /*--label-text-shadow: rgb(255 255 255 / 10%) -1px -1px 1px, rgb(0 0 0 / 50%) 1px 1px 1px;*/ 447 | /*--label-color-on: var(--primary-text-color, white);*/ 448 | /*--label-color-off: var(--primary-text-color, white);*/ 449 | --icon-filter: brightness(100%); 450 | --icon-color: var(--paper-item-icon-color); 451 | --icon-rotate-speed: 0s; 452 | /*--state-color-on: #BAC0C6; */ 453 | /*--state-color-off: var(--disabled-text-color);*/ 454 | /*--state-text-shadow: rgb(255 255 255 / 10%) -1px -1px 1px, rgb(0 0 0 / 50%) 1px 1px 1px;*/ 455 | --btn-bg-color-off: rgba(43,55,78,1); 456 | --btn-bg-color-on: #20293c; 457 | /*--action-icon-color-on: var(--paper-item-icon-color, black);*/ 458 | /*--action-icon-color-off: var(--paper-item-icon-color, black);*/ 459 | /*--action-spinner-color: var(--label-badge-text-color, white);*/ 460 | } 461 | /* --- BUTTON --- */ 462 | 463 | .button { 464 | position: relative; 465 | padding: 0.8rem; 466 | box-sizing: border-box; 467 | height: 100%; 468 | min-height: 7rem; 469 | width: 100%; 470 | display: block; 471 | overflow: hidden; 472 | transition: all 0.2s ease-in-out; 473 | touch-action: none; 474 | } 475 | ha-card.compact .button { 476 | min-height: 3rem !important; 477 | } 478 | .button.off { 479 | background-color: var(--btn-bg-color-off); 480 | } 481 | 482 | /* --- ICON --- */ 483 | 484 | .icon { 485 | position: relative; 486 | cursor: pointer; 487 | width: var(--mdc-icon-size, 24px); 488 | height: var(--mdc-icon-size, 24px); 489 | box-sizing: border-box; 490 | padding: 0; 491 | outline: none; 492 | animation: var(--icon-rotate-speed, 0s) linear 0s infinite normal both running rotate; 493 | -webkit-tap-highlight-color: transparent; 494 | } 495 | .icon ha-icon { 496 | filter: var(--icon-filter, brightness(100%)); 497 | color: var(--icon-color); 498 | transition: color 0.4s ease-in-out 0s, filter 0.2s linear 0s; 499 | } 500 | .icon.has-picture { 501 | background-size: cover; 502 | border-radius: 50%; 503 | } 504 | .icon.has-picture ha-icon{ 505 | display: none; 506 | } 507 | .unavailable .icon ha-icon { 508 | color: var(--disabled-text-color); 509 | } 510 | .compact .icon { 511 | float: left; 512 | } 513 | 514 | /* --- TEXT --- */ 515 | 516 | .text { 517 | position: absolute; 518 | bottom: 0; 519 | left: 0; 520 | padding: 0.8rem; 521 | pointer-events: none; 522 | user-select: none; 523 | font-size: 1.1rem; 524 | line-height: 1.3rem; 525 | max-width: calc(100% - 2em); 526 | /*text-shadow: rgb(255 255 255 / 10%) -1px -1px 1px, rgb(0 0 0 / 50%) 1px 1px 1px;*/ 527 | } 528 | .compact .text { 529 | position: relative; 530 | top: 0.5rem; 531 | left: 0.5rem; 532 | display: inline-block; 533 | padding: 0; 534 | height: 1.3rem; 535 | width: 100%; 536 | overflow: hidden; 537 | max-width: calc(100% - 4em); 538 | } 539 | .compact.hide-action .text { 540 | max-width: calc(100% - 2em); 541 | } 542 | 543 | /* --- LABEL --- */ 544 | 545 | .name { 546 | color: var(--label-color-on, var(--primary-text-color, white)); 547 | text-overflow: ellipsis; 548 | overflow: hidden; 549 | white-space: nowrap; 550 | text-shadow: var(--label-text-shadow, none); 551 | } 552 | .off .name { 553 | color: var(--label-color-off, var(--primary-text-color, white)); 554 | } 555 | .unavailable.off .name, 556 | .unavailable .name { 557 | color: var(--disabled-text-color); 558 | } 559 | .compact .name { 560 | display: inline-block; 561 | max-width: calc(100% - 3.5em); 562 | } 563 | 564 | /* --- STATE --- */ 565 | 566 | .state { 567 | color: var(--state-color-on, var(--label-badge-text-color, white)); 568 | text-overflow: ellipsis; 569 | white-space: nowrap; 570 | text-shadow: var(--state-text-shadow); 571 | transition: font-size 0.1s ease-in-out; 572 | } 573 | .changing .state { 574 | font-size: 150%; 575 | } 576 | .off .state { 577 | color: var(--state-color-off, var(--disabled-text-color)); 578 | } 579 | .unavailable .state { 580 | color: var(--disabled-text-color); 581 | } 582 | .compact .state { 583 | display: inline-block; 584 | max-width: calc(100% - 0em); 585 | overflow: hidden; 586 | } 587 | 588 | 589 | /* --- SLIDER --- */ 590 | 591 | .slider { 592 | position: absolute; 593 | top: 0px; 594 | left: 0px; 595 | height: 100%; 596 | width: 100%; 597 | background-color: var( --ha-card-background, var(--card-background-color, var(--btn-bg-color-on, black)) ); 598 | cursor: ew-resize; 599 | z-index: 0; 600 | } 601 | .slider[data-mode="bottom-top"] { 602 | cursor: ns-resize; 603 | } 604 | .slider[data-mode="top-bottom"] { 605 | cursor: ns-resize; 606 | } 607 | .slider:active { 608 | cursor: grabbing; 609 | } 610 | 611 | /* --- SLIDER OVERLAY --- */ 612 | 613 | .slider .toggle-overlay { 614 | position: absolute; 615 | top: 0px; 616 | left: 0px; 617 | height: 100%; 618 | width: 100%; 619 | cursor: pointer; 620 | opacity: 0; 621 | z-index: 999; 622 | } 623 | 624 | /* --- SLIDER BACKGROUND --- */ 625 | 626 | .slider-bg { 627 | position: absolute; 628 | top: 0; 629 | left: 0px; 630 | height: 100%; 631 | width: 100%; 632 | background: var(--slider-bg); 633 | background-size: var(--slider-bg-size, 100% 100%); 634 | background-color: var(--slider-bg-color, transparent); 635 | background-position: var(--slider-bg-position, 0 0); 636 | filter: var(--slider-bg-filter, brightness(100%)); 637 | } 638 | .off .slider .slider-bg { 639 | background-color: var( --ha-card-background, var(--card-background-color, var(--btn-bg-color-off, black)) ); 640 | } 641 | .slider[data-background="solid"] .slider-bg { 642 | --slider-bg-color: var(--slider-color); 643 | } 644 | .slider[data-background="triangle"] .slider-bg { 645 | --slider-bg-direction: to bottom right; 646 | --slider-bg: linear-gradient(var(--slider-bg-direction), transparent 0%, transparent 50%, var(--slider-color) 50%, var(--slider-color) 100%); 647 | border-right: 0px solid; 648 | } 649 | .slider[data-background="triangle"][data-mode="bottom-top"] .slider-bg { 650 | --slider-bg-direction: to top left; 651 | } 652 | .slider[data-background="triangle"][data-mode="top-bottom"] .slider-bg { 653 | --slider-bg-direction: to bottom left; 654 | } 655 | .slider[data-background="custom"] .slider-bg { 656 | --slider-bg: repeating-linear-gradient(-45deg, var(--slider-color) 0, var(--slider-color) 1px, var(--slider-color) 0, transparent 10%); 657 | --slider-bg-size: 30px 30px; 658 | } 659 | .slider[data-background="gradient"] .slider-bg { 660 | --slider-bg: linear-gradient(var(--slider-bg-direction), rgba(0, 0, 0, 0) -10%, var(--slider-color) 100%); 661 | } 662 | .slider[data-background="striped"] .slider-bg { 663 | --slider-bg: linear-gradient(var(--slider-bg-direction), var(--slider-color), var(--slider-color) 50%, transparent 50%, transparent); 664 | --slider-bg-size: 4px 100%; 665 | } 666 | .slider[data-background="striped"][data-mode="bottom-top"] .slider-bg, 667 | .slider[data-background="striped"][data-mode="top-bottom"] .slider-bg { 668 | --slider-bg-size: 100% 4px; 669 | } 670 | .slider[data-mode="bottom-top"] .slider-bg { 671 | --slider-bg-direction: to top; 672 | } 673 | .slider[data-mode="top-bottom"] .slider-bg { 674 | --slider-bg-direction: to bottom; 675 | } 676 | 677 | /* --- SLIDER THUMB --- */ 678 | 679 | .slider-thumb { 680 | position: relative; 681 | width: 100%; 682 | height: 100%; 683 | transform: translateX(var(--slider-value)); 684 | background: transparent; 685 | transition: transform var(--slider-transition-duration) ease-in; 686 | } 687 | .changing .slider .slider-thumb { 688 | transition: none; 689 | } 690 | .slider[data-mode="top-bottom"] .slider-thumb { 691 | transform: translateY(var(--slider-value)) !important; 692 | } 693 | .slider[data-mode="bottom-top"] .slider-thumb { 694 | transform: translateY(calc(var(--slider-value) * -1)) !important; 695 | } 696 | 697 | .slider-thumb:before { 698 | content: ''; 699 | position: absolute; 700 | top: 0; 701 | left: -2px; 702 | height: 100%; 703 | width: 2px; 704 | background: var(--slider-color); 705 | opacity: 0; 706 | transition: opacity 0.2s ease-in-out 0s; 707 | box-shadow: var(--slider-color) 0px 1px 5px 1px; 708 | z-index: 999; 709 | } 710 | .slider[data-mode="top-bottom"] .slider-thumb:before { 711 | top: -2px; 712 | left: 0px; 713 | height: 2px; 714 | width: 100%; 715 | } 716 | .changing .slider-thumb:before { 717 | opacity: 0.5; 718 | } 719 | .off.changing .slider-thumb:before { 720 | opacity: 0; 721 | } 722 | 723 | .slider-thumb:after { 724 | content: ''; 725 | position: absolute; 726 | top: 0; 727 | left: 0px; 728 | height: 100%; 729 | width: 100%; 730 | background: var( --ha-card-background, var(--card-background-color, var(--btn-bg-color-on, black)) ); 731 | opacity: 1; 732 | } 733 | .slider[data-show-track="true"] .slider-thumb:after { 734 | opacity: 0.9; 735 | } 736 | .off .slider[data-show-track="true"] .slider-thumb:after { 737 | opacity: 1; 738 | } 739 | 740 | /* --- ACTION BUTTON --- */ 741 | 742 | .action { 743 | position: relative; 744 | float: right; 745 | width: var(--mdc-icon-size, 24px); 746 | height: var(--mdc-icon-size, 24px); 747 | color: var(--action-icon-color-on, var(--paper-item-icon-color, black)); 748 | cursor: pointer; 749 | outline: none; 750 | -webkit-tap-highlight-color: transparent; 751 | } 752 | .action ha-switch { 753 | position: absolute; 754 | right: 0; 755 | top: 5px; 756 | } 757 | .off .action { 758 | color: var(--action-icon-color-off, var(--paper-item-icon-color, black)); 759 | } 760 | .unavailable .action { 761 | color: var(--disabled-text-color); 762 | } 763 | 764 | 765 | .circular-loader { 766 | position: absolute; 767 | left: -8px; 768 | top: -8px; 769 | width: calc(var(--mdc-icon-size, 24px) + 16px); 770 | height: calc(var(--mdc-icon-size, 24px) + 16px); 771 | opacity: 0; 772 | transition: opacity 0.2s ease-in-out; 773 | animation: rotate 2s linear infinite; 774 | } 775 | .action.loading .circular-loader { 776 | opacity: 1; 777 | } 778 | 779 | .loader-path { 780 | fill: none; 781 | stroke-width: 2px; 782 | stroke: var(--action-spinner-color, var(--label-badge-text-color, white)); 783 | animation: animate-stroke 1.5s ease-in-out infinite both; 784 | stroke-linecap: round; 785 | } 786 | 787 | /* --- MISC --- */ 788 | 789 | .unavailable .slider .toggle-overlay, 790 | .unavailable .action, 791 | .unavailable .action ha-switch, 792 | .unavailable .slider { 793 | cursor: not-allowed !important; 794 | } 795 | 796 | 797 | @keyframes rotate { 798 | 100% { 799 | transform: rotate(360deg); 800 | } 801 | } 802 | 803 | @keyframes animate-stroke { 804 | 0% { 805 | stroke-dasharray: 1, 200; 806 | stroke-dashoffset: 0; 807 | } 808 | 50% { 809 | stroke-dasharray: 89, 200; 810 | stroke-dashoffset: -35; 811 | } 812 | 100% { 813 | stroke-dasharray: 89, 200; 814 | stroke-dashoffset: -124; 815 | } 816 | } 817 | `; 818 | } 819 | } 820 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/camelcase */ 2 | import { ActionConfig, LovelaceCard, LovelaceCardConfig, LovelaceCardEditor } from 'custom-card-helpers'; 3 | 4 | declare global { 5 | interface HTMLElementTagNameMap { 6 | 'slider-button-card-editor': LovelaceCardEditor; 7 | 'hui-error-card': LovelaceCard; 8 | } 9 | } 10 | 11 | export interface SliderButtonCardConfig extends LovelaceCardConfig { 12 | type: string; 13 | entity: string; 14 | name?: string; 15 | show_name?: boolean; 16 | show_state?: boolean; 17 | icon?: IconConfig; 18 | action_button?: ActionButtonConfig; 19 | slider?: SliderConfig; 20 | theme?: string; 21 | debug?: boolean; 22 | compact?: boolean; 23 | } 24 | 25 | export interface ActionButtonConfig { 26 | mode?: ActionButtonMode; 27 | icon?: string; 28 | show?: boolean; 29 | show_spinner?: boolean; 30 | tap_action?: ActionConfig; 31 | } 32 | 33 | export interface IconConfig { 34 | icon?: string; 35 | show?: boolean; 36 | tap_action?: ActionConfig; 37 | use_state_color?: boolean; 38 | } 39 | 40 | export interface SliderConfig { 41 | min?: number; 42 | max?: number; 43 | step?: number; 44 | attribute?: string; 45 | direction?: SliderDirections; 46 | background: SliderBackground; 47 | use_percentage_bg_opacity?: boolean; 48 | use_state_color?: boolean; 49 | show_track?: boolean; 50 | toggle_on_click?: boolean; 51 | invert?: boolean; 52 | force_square: boolean; 53 | } 54 | 55 | export enum ActionButtonMode { 56 | TOGGLE = 'toggle', 57 | CUSTOM = 'custom', 58 | } 59 | 60 | export enum SliderDirections { 61 | LEFT_RIGHT = 'left-right', 62 | TOP_BOTTOM = 'top-bottom', 63 | BOTTOM_TOP = 'bottom-top', 64 | } 65 | 66 | export enum SliderBackground { 67 | SOLID = 'solid', 68 | GRADIENT = 'gradient', 69 | TRIANGLE = 'triangle', 70 | STRIPED = 'striped', 71 | CUSTOM = 'custom', 72 | } 73 | 74 | export enum Domain { 75 | LIGHT = 'light', 76 | SWITCH = 'switch', 77 | FAN = 'fan', 78 | COVER = 'cover', 79 | INPUT_BOOLEAN = 'input_boolean', 80 | MEDIA_PLAYER = 'media_player', 81 | CLIMATE = 'climate', 82 | LOCK = 'lock', 83 | } 84 | 85 | export const ActionButtonConfigDefault: ActionButtonConfig = { 86 | mode: ActionButtonMode.TOGGLE, 87 | icon: 'mdi:power', 88 | show: true, 89 | show_spinner: true, 90 | tap_action: { 91 | action: 'toggle' 92 | }, 93 | }; 94 | 95 | export const IconConfigDefault: IconConfig = { 96 | show: true, 97 | use_state_color: true, 98 | tap_action: { 99 | action: 'more-info' 100 | }, 101 | }; 102 | 103 | export const SliderConfigDefault: SliderConfig = { 104 | direction: SliderDirections.LEFT_RIGHT, 105 | background: SliderBackground.SOLID, 106 | use_percentage_bg_opacity: false, 107 | use_state_color: false, 108 | show_track: false, 109 | toggle_on_click: false, 110 | force_square: false, 111 | }; 112 | 113 | export const SliderConfigDefaultDomain: Map = new Map([ 114 | [Domain.LIGHT, { 115 | direction: SliderDirections.LEFT_RIGHT, 116 | background: SliderBackground.GRADIENT, 117 | use_state_color: true, 118 | use_percentage_bg_opacity: false, 119 | show_track: false, 120 | toggle_on_click: false, 121 | force_square: false, 122 | }], 123 | [Domain.FAN, { 124 | direction: SliderDirections.LEFT_RIGHT, 125 | background: SliderBackground.SOLID, 126 | use_state_color: false, 127 | use_percentage_bg_opacity: false, 128 | show_track: false, 129 | toggle_on_click: false, 130 | force_square: false, 131 | }], 132 | [Domain.SWITCH, { 133 | direction: SliderDirections.LEFT_RIGHT, 134 | background: SliderBackground.SOLID, 135 | use_state_color: false, 136 | use_percentage_bg_opacity: false, 137 | show_track: false, 138 | toggle_on_click: true, 139 | force_square: false, 140 | }], 141 | [Domain.COVER, { 142 | direction: SliderDirections.TOP_BOTTOM, 143 | background: SliderBackground.STRIPED, 144 | use_state_color: false, 145 | use_percentage_bg_opacity: false, 146 | toggle_on_click: false, 147 | show_track: false, 148 | force_square: false, 149 | invert: true, 150 | }], 151 | [Domain.INPUT_BOOLEAN, { 152 | direction: SliderDirections.LEFT_RIGHT, 153 | background: SliderBackground.SOLID, 154 | use_state_color: false, 155 | use_percentage_bg_opacity: false, 156 | show_track: false, 157 | toggle_on_click: true, 158 | force_square: false, 159 | }], 160 | [Domain.MEDIA_PLAYER, { 161 | direction: SliderDirections.LEFT_RIGHT, 162 | background: SliderBackground.TRIANGLE, 163 | use_state_color: false, 164 | use_percentage_bg_opacity: false, 165 | show_track: true, 166 | toggle_on_click: false, 167 | force_square: false, 168 | }], 169 | [Domain.LOCK, { 170 | direction: SliderDirections.LEFT_RIGHT, 171 | background: SliderBackground.SOLID, 172 | use_state_color: false, 173 | use_percentage_bg_opacity: false, 174 | show_track: false, 175 | toggle_on_click: true, 176 | force_square: false, 177 | }], 178 | [Domain.CLIMATE, { 179 | direction: SliderDirections.LEFT_RIGHT, 180 | background: SliderBackground.TRIANGLE, 181 | use_state_color: false, 182 | use_percentage_bg_opacity: false, 183 | show_track: true, 184 | toggle_on_click: false, 185 | force_square: false, 186 | }], 187 | ]); 188 | 189 | export enum LightAttributes { 190 | COLOR_TEMP = 'color_temp', 191 | BRIGHTNESS = 'brightness', 192 | BRIGHTNESS_PCT = 'brightness_pct', 193 | HUE = 'hue', 194 | SATURATION = 'saturation', 195 | ON_OFF = 'onoff', 196 | } 197 | 198 | export enum LightColorModes { 199 | COLOR_TEMP = 'color_temp', 200 | BRIGHTNESS = 'brightness', 201 | HS = 'hs', 202 | ON_OFF = 'onoff', 203 | } 204 | 205 | export enum CoverAttributes { 206 | POSITION = 'position', 207 | TILT = 'tilt', 208 | } 209 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { tinycolor, TinyColor } from '@ctrl/tinycolor'; 2 | import { computeDomain } from 'custom-card-helpers'; 3 | import copy from 'fast-copy'; 4 | import { Domain, SliderConfig, SliderConfigDefault, SliderConfigDefaultDomain } from './types'; 5 | 6 | export function getEnumValues(enumeration): string[] { 7 | return Object.keys(enumeration).map(key => enumeration[key]).filter(value => typeof value === 'string'); 8 | } 9 | 10 | export const applyPatch = (data, path, value): void => { 11 | if (path.length === 1) { 12 | data[path[0]] = value; 13 | return; 14 | } 15 | if (!data[path[0]]) { 16 | data[path[0]] = {}; 17 | } 18 | // eslint-disable-next-line consistent-return 19 | return applyPatch(data[path[0]], path.slice(1), value); 20 | }; 21 | 22 | export function getSliderDefaultForEntity(entity: string): SliderConfig { 23 | const domain = computeDomain(entity) || Domain.LIGHT; 24 | const cfg = SliderConfigDefaultDomain.get(domain) || SliderConfigDefault; 25 | return copy(cfg); 26 | } 27 | 28 | export function getLightColorBasedOnTemperature(current: number, min: number, max: number): string { 29 | const high = new TinyColor('rgb(255, 160, 0)'); // orange-ish 30 | const low = new TinyColor('rgb(166, 209, 255)'); // blue-ish 31 | const middle = new TinyColor('white'); 32 | const mixAmount = ((current - min) / (max - min)) * 100; 33 | if (mixAmount < 50) { 34 | return tinycolor(low) 35 | .mix(middle, mixAmount * 2) 36 | .toRgbString(); 37 | } else { 38 | return tinycolor(middle) 39 | .mix(high, (mixAmount - 50) * 2) 40 | .toRgbString(); 41 | } 42 | } 43 | export function toPercentage(value: number, min: number, max: number): number { 44 | return (((value - min) / max) * 100); //.toFixed(2); 45 | } 46 | 47 | export function percentageToValue(percent: number, min: number, max: number): number { 48 | return Math.floor( 49 | (percent * (max - min) / 100 + min) 50 | ) 51 | } 52 | 53 | export const normalize = (value: number, min: number, max: number): number => { 54 | if (isNaN(value) || isNaN(min) || isNaN(max)) { 55 | // Not a number, return 0 56 | return 0; 57 | } 58 | if (value > max) return max; 59 | if (value < min) return min; 60 | return value; 61 | }; 62 | 63 | export const capitalizeFirst = (s): string => (s && s[0].toUpperCase() + s.slice(1)) || ""; 64 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "module": "esnext", 5 | "moduleResolution": "node", 6 | "lib": [ 7 | "es2017", 8 | "dom", 9 | "dom.iterable" 10 | ], 11 | "noEmit": true, 12 | "noUnusedParameters": true, 13 | "noImplicitReturns": true, 14 | "noFallthroughCasesInSwitch": true, 15 | "strict": true, 16 | "noImplicitAny": false, 17 | "skipLibCheck": true, 18 | "resolveJsonModule": true, 19 | "experimentalDecorators": true 20 | } 21 | } --------------------------------------------------------------------------------