├── .eslintrc.js ├── .github ├── ISSUE_TEMPLATE │ ├── bug-report.yml │ ├── config.yml │ └── feature-request.yml ├── issue_label_bot.yaml └── workflows │ ├── build.yaml │ ├── check.py │ ├── stale.yaml │ └── validate.yaml ├── .gitignore ├── .prettierrc ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── dist └── scheduler-card.js ├── hacs.json ├── package.json ├── rollup.config.js ├── screenshots ├── Demonstration.gif ├── Screenshot_Editor_1.png ├── Screenshot_Editor_2.png ├── Screenshot_Overview.png ├── action_variable_example.png ├── action_variable_list_example.png ├── entities_example.png ├── groups_example.png ├── instructions_select_entity.png ├── instructions_timepicker.png ├── tags_example.png ├── timescheme_example.png └── version_badge.png ├── src ├── components │ ├── button-group.ts │ ├── generic-dialog.ts │ ├── my-relative-time.ts │ ├── scheduler-select.ts │ ├── scheduler-selector.ts │ ├── subscribe-mixin.ts │ ├── time-picker.ts │ ├── timeslot-editor.ts │ ├── variable-picker.ts │ └── variable-slider.ts ├── config-validation.ts ├── const.ts ├── data │ ├── actions │ │ ├── assign_action.ts │ │ ├── compare_actions.ts │ │ ├── compute_action_display.ts │ │ ├── compute_actions.ts │ │ ├── compute_common_actions.ts │ │ ├── import_action.ts │ │ └── migrate_action_config.ts │ ├── compute_states.ts │ ├── custom_dialog.ts │ ├── date-time │ │ ├── day_object.ts │ │ ├── format_date.ts │ │ ├── format_time.ts │ │ ├── format_weekday.ts │ │ ├── relative_time.ts │ │ ├── start_of_week.ts │ │ ├── string_to_date.ts │ │ ├── time.ts │ │ ├── weekday_to_list.ts │ │ └── weekday_type.ts │ ├── entities │ │ ├── compute_entities.ts │ │ ├── compute_supported_features.ts │ │ ├── entity_filter.ts │ │ └── parse_entity.ts │ ├── entity_group.ts │ ├── item_display │ │ ├── compute_days_display.ts │ │ ├── compute_schedule_display.ts │ │ └── compute_time_display.ts │ ├── match_pattern.ts │ ├── variables │ │ ├── compute_merged_variable.ts │ │ ├── compute_variables.ts │ │ ├── level_variable.ts │ │ ├── list_variable.ts │ │ └── text_variable.ts │ └── websockets.ts ├── editor │ ├── scheduler-editor-entity.ts │ ├── scheduler-editor-options.ts │ ├── scheduler-editor-time.ts │ └── scheduler-editor.ts ├── helpers.ts ├── load-ha-form.js ├── localize │ ├── languages │ │ ├── cs.json │ │ ├── de.json │ │ ├── en.json │ │ ├── es.json │ │ ├── et.json │ │ ├── fi.json │ │ ├── fr.json │ │ ├── he.json │ │ ├── hu.json │ │ ├── it.json │ │ ├── lv.json │ │ ├── nl.json │ │ ├── no.json │ │ ├── pl.json │ │ ├── pt-BR.json │ │ ├── pt.json │ │ ├── ro.json │ │ ├── ru.json │ │ ├── sk.json │ │ ├── sl.json │ │ ├── uk.json │ │ └── zh-Hans.json │ └── localize.ts ├── scheduler-card-editor.ts ├── scheduler-card.ts ├── standard-configuration │ ├── action_icons.ts │ ├── action_name.ts │ ├── actions.ts │ ├── attribute.ts │ ├── group.ts │ ├── group_name.ts │ ├── standardActions.ts │ ├── standardIcon.ts │ ├── standardStates.ts │ ├── state_icons.ts │ ├── states.ts │ ├── variable_icons.ts │ ├── variable_name.ts │ ├── variable_options.ts │ └── variables.ts ├── styles.ts └── types.ts └── tsconfig.json /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', // Specifies the ESLint parser 3 | extends: [ 4 | 'plugin:@typescript-eslint/recommended', // Uses the recommended rules from the @typescript-eslint/eslint-plugin 5 | 'prettier/@typescript-eslint', // Uses eslint-config-prettier to disable ESLint rules from @typescript-eslint/eslint-plugin that would conflict with prettier 6 | 'plugin:prettier/recommended', // Enables eslint-plugin-prettier and displays prettier errors as ESLint errors. Make sure this is always the last configuration in the extends array. 7 | ], 8 | parserOptions: { 9 | ecmaVersion: 2018, // Allows for the parsing of modern ECMAScript features 10 | sourceType: 'module', // Allows for the use of imports 11 | experimentalDecorators: true, 12 | }, 13 | rules: { 14 | "@typescript-eslint/camelcase": 0, 15 | "@typescript-eslint/no-explicit-any": "off", 16 | "@typescript-eslint/explicit-function-return-type": "off", 17 | "@typescript-eslint/no-empty-function": "off", 18 | "@typescript-eslint/no-non-null-assertion": "off", 19 | "@typescript-eslint/no-unused-vars": "off", 20 | "@typescript-eslint/no-use-before-define": "off", 21 | "@typescript-eslint/ban-ts-ignore": "off" 22 | } 23 | }; -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.yml: -------------------------------------------------------------------------------- 1 | name: Bug report 2 | description: Create a report to help improve Scheduler-card 3 | labels: bug 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | Note that this place is only for reporting issues with the card, not the [component](https://github.com/nielsfaber/scheduler-component). 9 | 10 | --- 11 | 12 | Thank you for taking the effort to fill in all relevant details. 13 | - type: checkboxes 14 | id: check_existing 15 | attributes: 16 | label: Checklist 17 | description: Please check the following items before submitting your request. 18 | options: 19 | - label: I checked for similar existing issues (both open and closed) before posting. 20 | required: true 21 | - label: I will participate in further discussion about this issue and can help by testing (if requested). 22 | required: true 23 | - type: input 24 | id: card-version 25 | attributes: 26 | label: Card Version 27 | description: "Which version of Scheduler-card are you running? If you're not sure, you can check it via the [browser logs](https://zapier.com/help/troubleshoot/behavior/view-and-save-your-browser-console-logs)." 28 | placeholder: "v1.0.0" 29 | validations: 30 | required: true 31 | - type: input 32 | id: component-version 33 | attributes: 34 | label: Component Version 35 | description: "Which version of Scheduler-com ponent are you running (if relevant)? See [here](https://github.com/nielsfaber/scheduler-component#updating) for instructions how to check it." 36 | - type: textarea 37 | id: bug-description 38 | attributes: 39 | label: Bug description 40 | description: What happened and what did you expect instead? 41 | validations: 42 | required: true 43 | - type: textarea 44 | id: steps 45 | attributes: 46 | label: Steps to reproduce 47 | description: Which steps did you take to see this bug? 48 | validations: 49 | required: true 50 | - type: textarea 51 | id: extra 52 | attributes: 53 | label: Additional info 54 | description: Add additional info which you see relevant here (optional). 55 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Questions/discussion 4 | url: https://community.home-assistant.io/t/scheduler-card-custom-component/217458/1381 5 | about: Ask questions, discuss configuration, share your setup 6 | - name: Bugs/features for scheduler-component 7 | url: https://github.com/nielsfaber/scheduler-component/issues 8 | about: Report problems or propose new features for the scheduler-component -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.yml: -------------------------------------------------------------------------------- 1 | name: Feature request 2 | description: Suggest an idea for this project 3 | labels: feature_request 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | Your feature requests should be kept small, else it will probably remain open for a long time or not be implemented at all. 9 | Note that this place is only for proposing improvements for the card, not the [component](https://github.com/nielsfaber/scheduler-component). 10 | 11 | --- 12 | 13 | Thank you for taking the effort to fill in all relevant details. 14 | - type: checkboxes 15 | id: check_existing 16 | attributes: 17 | label: Checklist 18 | description: Please check the following items before submitting your request. 19 | options: 20 | - label: I checked for similar existing requests (both open and closed) before posting. 21 | required: true 22 | - label: My request is generic, other users may benefit from it too. 23 | required: true 24 | - label: I will participate in further discussion about this feature and can test it (if requested) once it's done. 25 | required: true 26 | - type: textarea 27 | id: proposal 28 | attributes: 29 | label: Proposal 30 | description: A clear description of what you want to see changed or added. 31 | validations: 32 | required: true 33 | - type: textarea 34 | id: extra 35 | attributes: 36 | label: Additional info 37 | description: Some example of how the new functionality should look like. 38 | validations: 39 | required: true -------------------------------------------------------------------------------- /.github/issue_label_bot.yaml: -------------------------------------------------------------------------------- 1 | label-alias: 2 | bug: 'bug' 3 | feature_request: 'enhancement' 4 | question: 'question' 5 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v4 13 | 14 | - uses: JasonEtco/upload-to-release@master 15 | env: 16 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 17 | with: 18 | args: ./dist/scheduler-card.js text/javascript 19 | -------------------------------------------------------------------------------- /.github/workflows/check.py: -------------------------------------------------------------------------------- 1 | # Setup 2 | import glob 3 | import json 4 | from colorama import init, Fore, Style 5 | 6 | init() 7 | from icu import Locale 8 | 9 | # Crossvalidator 10 | try: 11 | english_file = open("./src/localize/languages/en.json") 12 | except FileNotFoundError: 13 | print( 14 | "❗ The original", 15 | Style.BRIGHT + "English file", 16 | Style.DIM + Fore.RED + "was not found.", 17 | Style.RESET_ALL + "It needs to be fixed before this PR can be merged.", 18 | ) 19 | raise Exception("English file not found") 20 | 21 | try: 22 | english_file = json.load(english_file) 23 | except json.decoder.JSONDecodeError as e: 24 | print( 25 | "❗ The original", 26 | Style.BRIGHT + "English file", 27 | Style.DIM + Fore.RED + "contains invalid JSON.", 28 | Style.RESET_ALL + "It needs to be fixed before this PR can be merged.", 29 | ) 30 | print( 31 | "You may need to add a", 32 | Fore.GREEN + "comma at the end of a line" + Style.RESET_ALL, 33 | "before adding another line.", 34 | ) 35 | print( 36 | "The error is", 37 | Style.BRIGHT + Fore.RED + str(e).replace("Expecting", "expecting"), 38 | ) 39 | raise Exception("English file invalid JSON") 40 | english_lang = Locale("en_US") 41 | 42 | 43 | def cross_validate(english_value, other_language_value, other_language, key_name=None): 44 | this_lang = other_language.split("/")[-1].split(".js")[0].replace("-", "_") 45 | this_lang = Locale(this_lang).getDisplayName(english_lang) 46 | if other_language_value is None: 47 | print( 48 | "🟡 In" + Style.BRIGHT + Fore.YELLOW, 49 | f"{this_lang}" + Style.RESET_ALL, 50 | f"there is no value for {Fore.YELLOW + key_name + Fore.WHITE}.", 51 | ) 52 | elif type(english_value) != type(other_language_value): 53 | raise Exception( 54 | f"The type of the English value ({english_value}) and the type of" 55 | + f"{this_lang}'s value ({other_language_value}) are different for key {key_name}." 56 | ) 57 | elif isinstance(english_value, dict): 58 | for name, item in english_value.items(): 59 | cross_validate(item, other_language_value.get(name), other_language, name) 60 | 61 | 62 | # The thing 63 | for filename in glob.glob("./src/localize/languages/*.json"): 64 | try: 65 | file = json.load(open(filename, encoding="utf-8")) 66 | cross_validate(english_file, file, filename) 67 | except json.decoder.JSONDecodeError as e: 68 | print( 69 | "❗ The file", 70 | Style.BRIGHT + filename, 71 | Style.DIM + Fore.RED + "contains invalid JSON.", 72 | Style.RESET_ALL + "It needs to be fixed before this PR can be merged.", 73 | ) 74 | print( 75 | "You may need to add a", 76 | Fore.GREEN + "comma at the end of a line" + Fore.WHITE, 77 | "before adding another line.", 78 | ) 79 | print( 80 | "The error is", 81 | Style.BRIGHT + Fore.RED + str(e).replace("Expecting", "expecting"), 82 | ) 83 | raise Exception(f"{filename} contains invalid JSON") 84 | print( 85 | "🎉 All JSON files in", 86 | Style.BRIGHT + Fore.BLUE + "src/localize/languages" + Style.RESET_ALL, 87 | "were validated", 88 | Fore.GREEN + "successfully.", 89 | ) 90 | -------------------------------------------------------------------------------- /.github/workflows/stale.yaml: -------------------------------------------------------------------------------- 1 | name: "Close stale issues/pull requests" 2 | on: 3 | schedule: 4 | - cron: "0 0 * * *" 5 | workflow_dispatch: 6 | 7 | jobs: 8 | stale: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/stale@v3 12 | with: 13 | repo-token: ${{ secrets.GITHUB_TOKEN }} 14 | stale-issue-message: 'This issue is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 14 days' 15 | stale-pr-message: 'This pull request is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 14 days' 16 | days-before-stale: 60 17 | days-before-close: 14 18 | operations-per-run: 500 19 | exempt-issue-labels: 'will pick this up at some point' 20 | -------------------------------------------------------------------------------- /.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 | hacs_validate: 11 | runs-on: "ubuntu-latest" 12 | steps: 13 | - uses: "actions/checkout@v4" 14 | - name: HACS validation 15 | uses: "hacs/action@main" 16 | with: 17 | CATEGORY: "plugin" 18 | language_validate: 19 | runs-on: "ubuntu-latest" 20 | steps: 21 | - uses: "actions/checkout@v4" 22 | - name: Setup Python 23 | uses: actions/setup-python@v2 24 | with: 25 | python-version: '3.8.x' 26 | - uses: actions/cache@v4 27 | name: Load pip cache 28 | with: 29 | path: ~/.cache/pip 30 | key: language-validate-pip-cache 31 | - name: Install dependencies 32 | run: | 33 | python3 -m pip install colorama setuptools 34 | python3 -m pip install pyicu 35 | - name: Language validation 36 | run: | 37 | script -e -c "python3 .github/workflows/check.py" 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package-lock.json 3 | README.html 4 | *.DS_Store 5 | .vscode 6 | ._* 7 | stats.html 8 | dist/ 9 | !dist/scheduler-card.js 10 | .history -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "es5", 4 | "singleQuote": true, 5 | "printWidth": 120, 6 | "tabWidth": 2 7 | } 8 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thank you for your interest in contributing to this project! 4 | You can contribute to this project by submitting a PR with your changes. 5 | Note that it may take between 1-4 weeks before your work can be reviewed and approved. 6 | 7 | ## Building 8 | 9 | * Install Node.js (latest LTS release). 10 | * On Linux, macOS, or Windows WSL, consider using [nvm.sh](https://github.com/nvm-sh/nvm/blob/master/README.md) 11 | * On Windows native, see [Nodejs.org](https://nodejs.org/) 12 | * `git clone https://github.com/nielsfaber/scheduler-card.git` 13 | * `cd scheduler-card` 14 | * `npm install --no-package-lock` 15 | * `npm start` # To develop interactively 16 | * `npm run build` # Run lint, prettier, rollup (update 'dist/scheduler-card.js') 17 | 18 | ## Submitting new translations 19 | 20 | If you would like to add a translation of the card in your native language, you can take the [english translation file](https://github.com/nielsfaber/scheduler-card/blob/main/src/localize/languages/en.json) as starting point and replace the texts on the right side of each line with local translation. Words within {curly brackets} should not be translated. 21 | Note that a translation file should be complete before it can be accepted as PR. 22 | 23 | ## Submitting new features 24 | 25 | If you wish to implement a new feature for this project, please first create a feature request to explain your idea and for further discussion/alignment. 26 | This avoids the risk of spending effort on code which may not be adopted by the project. 27 | 28 | ## Warning! 29 | 30 | Please note that a major rewrite of the codebase is ongoing as of March 2024. If you are 31 | planning on submitting a significant contribution, get in touch to request a branch for 32 | the new version to be published so that you can rebase your work on it. 33 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Scheduler Card", 3 | "render_readme": true, 4 | "filename": "scheduler-card.js", 5 | "homeassistant": "2025.5.0" 6 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "scheduler-card", 3 | "version": "1.0.0", 4 | "description": "Scheduler card for Lovelace", 5 | "main": "dist/scheduler-card.js", 6 | "scripts": { 7 | "build": "npm run lint && npm run format && npm run rollup", 8 | "rollup": "rollup -c", 9 | "lint": "eslint src/**/*.ts --fix", 10 | "format": "prettier --write '**/*.ts'", 11 | "start": "rollup -c --watch" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/nielsfaber/scheduler-card.git" 16 | }, 17 | "author": "nielsfaber", 18 | "license": "ISC", 19 | "bugs": { 20 | "url": "https://github.com/nielsfaber/scheduler-card/issues" 21 | }, 22 | "homepage": "https://github.com/nielsfaber/scheduler-card#readme", 23 | "dependencies": { 24 | "@formatjs/intl-utils": "^3.8.4", 25 | "@mdi/js": "^6.4.95", 26 | "@typescript-eslint/eslint-plugin": "^2.6.0", 27 | "@typescript-eslint/parser": "^2.6.0", 28 | "custom-card-helpers": "1.8.0", 29 | "eslint-config-airbnb-base": "^14.0.0", 30 | "eslint-config-prettier": "^6.5.0", 31 | "eslint-plugin-import": "^2.18.2", 32 | "eslint-plugin-prettier": "^3.1.1", 33 | "fecha": "^4.2.1", 34 | "home-assistant-js-websocket": "^5.7.0", 35 | "lit": "^2.0.0", 36 | "rollup": "^1.32.1", 37 | "rollup-plugin-commonjs": "^10.1.0", 38 | "rollup-plugin-json": "^4.0.0", 39 | "rollup-plugin-node-resolve": "^5.2.0", 40 | "rollup-plugin-serve": "^1.0.1", 41 | "rollup-plugin-terser": "^5.1.2", 42 | "rollup-plugin-typescript2": "^0.31.1", 43 | "rollup-plugin-uglify": "^6.0.3", 44 | "typescript": "^3.6.4" 45 | }, 46 | "devDependencies": { 47 | "eslint": "^6.8.0", 48 | "prettier": "1.19.1", 49 | "rollup-plugin-visualizer": "^4.2.0" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import nodeResolve from 'rollup-plugin-node-resolve'; 2 | import typescript from 'rollup-plugin-typescript2'; 3 | import json from 'rollup-plugin-json'; 4 | import { terser } from 'rollup-plugin-terser'; 5 | import commonjs from 'rollup-plugin-commonjs'; 6 | import visualizer from 'rollup-plugin-visualizer'; 7 | 8 | 9 | const plugins = [ 10 | nodeResolve(), 11 | commonjs({ 12 | include: 'node_modules/**', 13 | }), 14 | typescript(), 15 | json(), 16 | visualizer(), 17 | terser(), 18 | ]; 19 | 20 | export default [ 21 | { 22 | input: 'src/scheduler-card.ts', 23 | output: { 24 | dir: 'dist', 25 | format: 'iife', 26 | sourcemap: false, 27 | }, 28 | plugins: [...plugins], 29 | context: 'window', 30 | }, 31 | ]; 32 | -------------------------------------------------------------------------------- /screenshots/Demonstration.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nielsfaber/scheduler-card/04be0e41aedaad224fadace3c8b53cf6706554c6/screenshots/Demonstration.gif -------------------------------------------------------------------------------- /screenshots/Screenshot_Editor_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nielsfaber/scheduler-card/04be0e41aedaad224fadace3c8b53cf6706554c6/screenshots/Screenshot_Editor_1.png -------------------------------------------------------------------------------- /screenshots/Screenshot_Editor_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nielsfaber/scheduler-card/04be0e41aedaad224fadace3c8b53cf6706554c6/screenshots/Screenshot_Editor_2.png -------------------------------------------------------------------------------- /screenshots/Screenshot_Overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nielsfaber/scheduler-card/04be0e41aedaad224fadace3c8b53cf6706554c6/screenshots/Screenshot_Overview.png -------------------------------------------------------------------------------- /screenshots/action_variable_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nielsfaber/scheduler-card/04be0e41aedaad224fadace3c8b53cf6706554c6/screenshots/action_variable_example.png -------------------------------------------------------------------------------- /screenshots/action_variable_list_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nielsfaber/scheduler-card/04be0e41aedaad224fadace3c8b53cf6706554c6/screenshots/action_variable_list_example.png -------------------------------------------------------------------------------- /screenshots/entities_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nielsfaber/scheduler-card/04be0e41aedaad224fadace3c8b53cf6706554c6/screenshots/entities_example.png -------------------------------------------------------------------------------- /screenshots/groups_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nielsfaber/scheduler-card/04be0e41aedaad224fadace3c8b53cf6706554c6/screenshots/groups_example.png -------------------------------------------------------------------------------- /screenshots/instructions_select_entity.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nielsfaber/scheduler-card/04be0e41aedaad224fadace3c8b53cf6706554c6/screenshots/instructions_select_entity.png -------------------------------------------------------------------------------- /screenshots/instructions_timepicker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nielsfaber/scheduler-card/04be0e41aedaad224fadace3c8b53cf6706554c6/screenshots/instructions_timepicker.png -------------------------------------------------------------------------------- /screenshots/tags_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nielsfaber/scheduler-card/04be0e41aedaad224fadace3c8b53cf6706554c6/screenshots/tags_example.png -------------------------------------------------------------------------------- /screenshots/timescheme_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nielsfaber/scheduler-card/04be0e41aedaad224fadace3c8b53cf6706554c6/screenshots/timescheme_example.png -------------------------------------------------------------------------------- /screenshots/version_badge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nielsfaber/scheduler-card/04be0e41aedaad224fadace3c8b53cf6706554c6/screenshots/version_badge.png -------------------------------------------------------------------------------- /src/components/button-group.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, html } from 'lit'; 2 | import { property, customElement } from 'lit/decorators.js'; 3 | import { PrettyPrintIcon, PrettyPrintName } from '../helpers'; 4 | import { commonStyle } from '../styles'; 5 | 6 | export type ButtonItem = { 7 | name?: string; 8 | icon?: string; 9 | value?: string; 10 | id?: string; 11 | disabled?: boolean; 12 | }; 13 | 14 | function name(item: ButtonItem) { 15 | return item.name?.trim() || item.value || item.id || ''; 16 | } 17 | 18 | function value(item: ButtonItem, index: number) { 19 | return item.id || item.value || index; 20 | } 21 | 22 | @customElement('button-group') 23 | export class ButtonGroup extends LitElement { 24 | @property({ type: Array }) items: ButtonItem[] = []; 25 | @property() value: string | null | number | (string | number)[] = null; 26 | @property({ type: Number }) min?: number; 27 | @property({ type: Boolean }) optional?: boolean; 28 | @property({ type: Boolean }) multiple?: boolean; 29 | 30 | render() { 31 | if (!this.items.length) { 32 | return html` 33 |
34 | 35 |
36 | `; 37 | } 38 | return this.items.map((val, key) => this.renderButton(val, key)); 39 | } 40 | 41 | renderButton(item: ButtonItem, index: number) { 42 | const selection = Array.isArray(this.value) ? this.value : [this.value]; 43 | const id = value(item, index); 44 | 45 | return html` 46 | this.selectItem(id)} 50 | > 51 | ${item.icon 52 | ? html` 53 | 54 | ` 55 | : ''} 56 | ${PrettyPrintName(name(item))} 57 | 58 | `; 59 | } 60 | 61 | selectItem(val: string | number) { 62 | if (!Array.isArray(this.value)) { 63 | if (val == this.value) { 64 | if (this.optional) this.value = null; 65 | else return; 66 | } else this.value = val; 67 | } else if (!this.multiple) { 68 | this.value = this.value.includes(val) ? [] : Array(val); 69 | } else { 70 | let value = Array.isArray(this.value) ? [...this.value] : []; 71 | if (value.includes(val)) { 72 | if (this.min !== undefined && value.length <= this.min) return; 73 | value = value.filter(e => e != val); 74 | } else value.push(val); 75 | this.value = value; 76 | } 77 | 78 | const selection = Array.isArray(this.value) 79 | ? this.value.map(e => this.items.find((v, k) => value(v, k) == e)) 80 | : this.value !== null 81 | ? this.items.find((v, k) => value(v, k) == this.value) 82 | : null; 83 | const myEvent = new CustomEvent('change', { detail: selection }); 84 | this.dispatchEvent(myEvent); 85 | } 86 | 87 | static styles = commonStyle; 88 | } 89 | -------------------------------------------------------------------------------- /src/components/generic-dialog.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, html, css, CSSResultGroup, TemplateResult } from 'lit'; 2 | import { property, customElement, state } from 'lit/decorators.js'; 3 | import { HomeAssistant } from 'custom-card-helpers'; 4 | import { mdiClose } from '@mdi/js'; 5 | 6 | export type DialogParams = { 7 | title: string; 8 | description: string | TemplateResult; 9 | primaryButtonLabel: string; 10 | secondaryButtonLabel?: string; 11 | primaryButtonCritical?: boolean; 12 | cancel: () => void; 13 | confirm: () => void; 14 | }; 15 | 16 | @customElement('generic-dialog') 17 | export class GenericDialog extends LitElement { 18 | @property({ attribute: false }) public hass!: HomeAssistant; 19 | 20 | @state() private _params?: DialogParams; 21 | 22 | public async showDialog(params: DialogParams): Promise { 23 | this._params = params; 24 | await this.updateComplete; 25 | } 26 | 27 | public async closeDialog() { 28 | if (this._params) this._params.cancel(); 29 | this._params = undefined; 30 | } 31 | 32 | render() { 33 | if (!this._params) return html``; 34 | return html` 35 | 36 | 37 | 43 | 44 | ${this._params.title} 45 | 46 | 47 |
48 | ${this._params.description} 49 |
50 | 51 | ${this._params.secondaryButtonLabel 52 | ? html` 53 | 54 | ${this._params.secondaryButtonLabel} 55 | 56 | ` 57 | : ''} 58 | 64 | ${this._params.primaryButtonLabel} 65 | 66 |
67 | `; 68 | } 69 | 70 | confirmClick() { 71 | this._params!.confirm(); 72 | } 73 | 74 | cancelClick() { 75 | this._params!.cancel(); 76 | } 77 | 78 | static get styles(): CSSResultGroup { 79 | return css` 80 | div.wrapper { 81 | color: var(--primary-text-color); 82 | } 83 | `; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/components/my-relative-time.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, html } from 'lit'; 2 | import { property, customElement } from 'lit/decorators.js'; 3 | import { HomeAssistant } from 'custom-card-helpers'; 4 | import { capitalize, getLocale } from '../helpers'; 5 | import { formatWeekday } from '../data/date-time/format_weekday'; 6 | import { localize } from '../localize/localize'; 7 | import { formatTime } from '../data/date-time/format_time'; 8 | import { formatDate } from '../data/date-time/format_date'; 9 | import { selectUnit } from '@formatjs/intl-utils'; 10 | 11 | const secondsPerMinute = 60; 12 | const secondsPerHour = 3600; 13 | const hoursPerDay = 24; 14 | 15 | @customElement('my-relative-time') 16 | export class MyRelativeTime extends LitElement { 17 | @property() _hass?: HomeAssistant; 18 | @property() datetime?: Date; 19 | 20 | updateInterval = 60; 21 | timer = 0; 22 | 23 | startRefreshTimer(updateInterval: number) { 24 | clearInterval(this.timer); 25 | this.timer = window.setInterval(() => { 26 | this.requestUpdate(); 27 | }, updateInterval * 1000); 28 | this.updateInterval = updateInterval; 29 | } 30 | 31 | set hass(hass: HomeAssistant) { 32 | this._hass = hass; 33 | this.startRefreshTimer(this.updateInterval); //restart 34 | } 35 | 36 | relativeTime(dateObj: Date): string { 37 | if (!this._hass) return ''; 38 | const now = new Date(); 39 | let delta = (now.getTime() - dateObj.getTime()) / 1000; 40 | const tense = delta >= 0 ? 'past' : 'future'; 41 | delta = Math.abs(delta); 42 | const roundedDelta = Math.round(delta); 43 | 44 | if (tense == 'future' && roundedDelta > 0) { 45 | if (delta / secondsPerHour >= 6) { 46 | const startOfToday = now.setHours(0, 0, 0, 0); 47 | const daysFromNow = Math.floor( 48 | (dateObj.valueOf() - startOfToday.valueOf()) / (hoursPerDay * secondsPerHour * 1000) 49 | ); 50 | let day = ''; 51 | if (daysFromNow > 14) { 52 | //October 12 53 | day = formatDate(dateObj, getLocale(this._hass)); 54 | } else if (daysFromNow > 7) { 55 | //Next Friday 56 | day = localize( 57 | 'ui.components.date.next_week_day', 58 | getLocale(this._hass), 59 | '{weekday}', 60 | formatWeekday(dateObj, getLocale(this._hass)) 61 | ); 62 | } else if (daysFromNow == 1) { 63 | //Tomorrow 64 | day = localize('ui.components.date.tomorrow', getLocale(this._hass)); 65 | } else if (daysFromNow > 0) { 66 | //Friday 67 | day = formatWeekday(dateObj, getLocale(this._hass)); 68 | } 69 | 70 | let time = localize( 71 | 'ui.components.time.absolute', 72 | getLocale(this._hass), 73 | '{time}', 74 | formatTime(dateObj, getLocale(this._hass)) 75 | ); 76 | 77 | if (dateObj.getHours() == 12 && dateObj.getMinutes() == 0) { 78 | time = localize('ui.components.time.at_noon', getLocale(this._hass)); 79 | } else if (dateObj.getHours() == 0 && dateObj.getMinutes() == 0) { 80 | time = localize('ui.components.time.at_midnight', getLocale(this._hass)); 81 | } 82 | return String(day + ' ' + time).trim(); 83 | } else if (Math.round(delta / secondsPerMinute) > 60 && Math.round(delta / secondsPerMinute) < 120) { 84 | // in 1 hour and 52 minutes 85 | const mins = Math.round(delta / secondsPerMinute - 60); 86 | const join = this._hass.localize('ui.common.and'); 87 | 88 | // @ts-expect-error 89 | const text1 = new Intl.RelativeTimeFormat(getLocale(this._hass).language, { numeric: 'auto' }).format( 90 | 1, 91 | 'hour' 92 | ); 93 | const text2 = Intl.NumberFormat(getLocale(this._hass).language, { 94 | style: 'unit', 95 | // @ts-expect-error 96 | unit: 'minute', 97 | unitDisplay: 'long', 98 | }).format(mins); 99 | 100 | return `${text1} ${join} ${text2}`; 101 | } else if (Math.round(delta) > 60 && Math.round(delta) < 120) { 102 | // in 1 minute and 52 seconds 103 | const seconds = Math.round(delta - 60); 104 | const join = this._hass.localize('ui.common.and'); 105 | 106 | // @ts-expect-error 107 | const text1 = new Intl.RelativeTimeFormat(getLocale(this._hass).language, { numeric: 'auto' }).format( 108 | 1, 109 | 'minute' 110 | ); 111 | const text2 = Intl.NumberFormat(getLocale(this._hass).language, { 112 | style: 'unit', 113 | // @ts-expect-error 114 | unit: 'second', 115 | unitDisplay: 'long', 116 | }).format(seconds); 117 | 118 | return `${text1} ${join} ${text2}`; 119 | } 120 | } 121 | 122 | // in 5 minutes/hours/seconds (or now) 123 | const diff = selectUnit(dateObj); 124 | // @ts-expect-error 125 | return new Intl.RelativeTimeFormat(getLocale(this._hass).language, { numeric: 'auto' }).format( 126 | diff.value, 127 | diff.unit 128 | ); 129 | } 130 | 131 | render() { 132 | if (!this._hass || !this.datetime) return html``; 133 | 134 | const now = new Date(); 135 | const secondsRemaining = Math.round((this.datetime.valueOf() - now.valueOf()) / 1000); 136 | let updateInterval = 60; 137 | if (Math.abs(secondsRemaining) <= 150) updateInterval = Math.max(Math.ceil(Math.abs(secondsRemaining)) / 10, 2); 138 | if (this.updateInterval != updateInterval) this.startRefreshTimer(updateInterval); 139 | 140 | return html` 141 | ${capitalize(this.relativeTime(this.datetime))} 142 | `; 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/components/scheduler-selector.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, html, TemplateResult, css, PropertyValues, CSSResultGroup } from 'lit'; 2 | import { property, customElement } from 'lit/decorators.js'; 3 | import { fireEvent } from 'custom-card-helpers'; 4 | import { isDefined, isEqual } from '../helpers'; 5 | 6 | import './scheduler-select'; 7 | 8 | export type Option = { 9 | value: string; 10 | name: string; 11 | icon?: string; 12 | }; 13 | 14 | @customElement('scheduler-selector') 15 | export class SchedulerSelector extends LitElement { 16 | @property() 17 | items: Option[] = []; 18 | 19 | @property({ type: Array }) 20 | value: string[] = []; 21 | 22 | @property() 23 | label = ''; 24 | 25 | @property({ type: Boolean }) 26 | invalid = false; 27 | 28 | shouldUpdate(changedProps: PropertyValues) { 29 | if (changedProps.get('items')) { 30 | if (!isEqual(this.items, changedProps.get('items') as Option[])) this.firstUpdated(); 31 | } 32 | return true; 33 | } 34 | 35 | protected firstUpdated() { 36 | //remove items from selection which are not in the list (anymore) 37 | if (this.value.some(e => !this.items.map(v => v.value).includes(e))) { 38 | this.value = this.value.filter(e => this.items.map(v => v.value).includes(e)); 39 | fireEvent(this, 'value-changed', { value: this.value }); 40 | } 41 | } 42 | 43 | protected render(): TemplateResult { 44 | return html` 45 |
46 | ${this.value.length 47 | ? this.value 48 | .map(val => this.items.find(e => e.value == val)) 49 | .filter(isDefined) 50 | .map( 51 | e => 52 | html` 53 |
54 | 55 | ${e.name} 56 | 57 | this._removeClick(e.value)}> 58 | 59 | 60 |
61 | ` 62 | ) 63 | : ''} 64 |
65 | 66 | !this.value.includes(e.value))} 68 | label=${this.label} 69 | .icons=${false} 70 | .allowCustomValue=${true} 71 | @value-changed=${this._addClick} 72 | ?invalid=${this.invalid && this.value.length != this.items.length} 73 | > 74 | 75 | `; 76 | } 77 | 78 | private _removeClick(value: string) { 79 | this.value = this.value.filter(e => e !== value); 80 | fireEvent(this, 'value-changed', { value: this.value }); 81 | } 82 | 83 | private _addClick(ev: Event) { 84 | ev.stopPropagation(); 85 | const target = ev.target as HTMLInputElement; 86 | const value = target.value; 87 | if (!this.value.includes(value)) this.value = [...this.value, value]; 88 | target.value = ''; 89 | fireEvent(this, 'value-changed', { value: [...this.value] }); 90 | } 91 | 92 | static get styles(): CSSResultGroup { 93 | return css` 94 | div.chip-set { 95 | margin: 0px -4px; 96 | } 97 | div.chip { 98 | height: 32px; 99 | border-radius: 16px; 100 | border: 2px solid rgba(var(--rgb-primary-color), 0.54); 101 | line-height: 1.25rem; 102 | font-size: 0.875rem; 103 | font-weight: 400; 104 | padding: 0px 12px; 105 | display: inline-flex; 106 | align-items: center; 107 | box-sizing: border-box; 108 | margin: 4px; 109 | } 110 | .icon { 111 | vertical-align: middle; 112 | outline: none; 113 | display: flex; 114 | align-items: center; 115 | border-radius: 50%; 116 | padding: 6px; 117 | color: rgba(0, 0, 0, 0.54); 118 | background: rgb(168, 232, 251); 119 | --mdc-icon-size: 20px; 120 | margin-left: -14px !important; 121 | } 122 | .label { 123 | margin: 0px 4px; 124 | } 125 | .button { 126 | cursor: pointer; 127 | background: var(--secondary-text-color); 128 | border-radius: 50%; 129 | --mdc-icon-size: 14px; 130 | color: var(--card-background-color); 131 | width: 16px; 132 | height: 16px; 133 | padding: 1px; 134 | box-sizing: border-box; 135 | display: inline-flex; 136 | align-items: center; 137 | margin-right: -6px !important; 138 | } 139 | `; 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/components/subscribe-mixin.ts: -------------------------------------------------------------------------------- 1 | import { PropertyValues, ReactiveElement } from 'lit'; 2 | import { property } from 'lit/decorators.js'; 3 | import { UnsubscribeFunc } from 'home-assistant-js-websocket'; 4 | import { HomeAssistant } from 'custom-card-helpers'; 5 | 6 | export interface HassSubscribeElement { 7 | hassSubscribe(): UnsubscribeFunc[]; 8 | } 9 | export type Constructor = new (...args: any[]) => T; 10 | 11 | export const SubscribeMixin = >(superClass: T) => { 12 | class SubscribeClass extends superClass { 13 | @property({ attribute: false }) public hass?: HomeAssistant; 14 | 15 | private __unsubs?: Array>; 16 | 17 | public connectedCallback() { 18 | super.connectedCallback(); 19 | this.__checkSubscribed(); 20 | } 21 | 22 | public disconnectedCallback() { 23 | super.disconnectedCallback(); 24 | if (this.__unsubs) { 25 | while (this.__unsubs.length) { 26 | const unsub = this.__unsubs.pop()!; 27 | if (unsub instanceof Promise) { 28 | unsub.then(unsubFunc => unsubFunc()); 29 | } else { 30 | unsub(); 31 | } 32 | } 33 | this.__unsubs = undefined; 34 | } 35 | } 36 | 37 | protected updated(changedProps: PropertyValues) { 38 | super.updated(changedProps); 39 | if (changedProps.has('hass')) { 40 | this.__checkSubscribed(); 41 | } 42 | } 43 | 44 | protected hassSubscribe(): Array> { 45 | return []; 46 | } 47 | 48 | private __checkSubscribed(): void { 49 | if (this.__unsubs !== undefined || !((this as unknown) as Element).isConnected || this.hass === undefined) { 50 | return; 51 | } 52 | this.__unsubs = this.hassSubscribe(); 53 | } 54 | } 55 | return SubscribeClass; 56 | }; 57 | -------------------------------------------------------------------------------- /src/components/variable-picker.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, html, css } from 'lit'; 2 | import { property, customElement } from 'lit/decorators.js'; 3 | import { Variable, LevelVariable, EVariableType, ListVariable, TextVariable } from '../types'; 4 | 5 | import './variable-slider'; 6 | import './button-group'; 7 | import { fireEvent } from 'custom-card-helpers'; 8 | 9 | @customElement('scheduler-variable-picker') 10 | export class SchedulerVariablePicker extends LitElement { 11 | @property() 12 | variable?: Variable | null; 13 | 14 | @property() 15 | value?: string | number | null; 16 | 17 | firstUpdated() { 18 | if ( 19 | (this.value === null || this.value === undefined) && 20 | this.variable?.type == EVariableType.Level && 21 | !(this.variable as LevelVariable).optional 22 | ) 23 | this.levelVariableUpdated((this.variable as LevelVariable).min); 24 | } 25 | 26 | render() { 27 | if (!this.variable) return html``; 28 | else if (this.variable.type == EVariableType.Level) return this.renderLevelVariable(); 29 | else if (this.variable.type == EVariableType.List) return this.renderListVariable(); 30 | else if (this.variable.type == EVariableType.Text) return this.renderTextVariable(); 31 | else return html``; 32 | } 33 | 34 | private levelVariableUpdated(ev: CustomEvent | number) { 35 | const value = typeof ev == 'number' ? ev : Number(ev.detail.value); 36 | this.value = value; 37 | fireEvent(this, 'value-changed', { value: value }); 38 | } 39 | 40 | renderLevelVariable() { 41 | const variable = this.variable as LevelVariable; 42 | const value = Number(this.value); 43 | 44 | return html` 45 | 56 | 57 | `; 58 | } 59 | 60 | private listVariableUpdated(ev: Event | string) { 61 | const value = typeof ev == 'string' ? ev : String((ev.target as HTMLInputElement).value); 62 | this.value = value; 63 | fireEvent(this, 'value-changed', { value: value }); 64 | } 65 | 66 | renderListVariable() { 67 | const variable = this.variable as ListVariable; 68 | const options = variable.options; 69 | const value = String(this.value) || null; 70 | if (options.length == 1 && value != options[0].value) this.listVariableUpdated(options[0].value); 71 | 72 | return html` 73 | 74 | `; 75 | } 76 | 77 | renderTextVariable() { 78 | const variable = this.variable as TextVariable; 79 | const value = this.value; 80 | 81 | return html` 82 | 83 | `; 84 | } 85 | 86 | static styles = css` 87 | ha-textfield { 88 | width: 100%; 89 | } 90 | `; 91 | } 92 | -------------------------------------------------------------------------------- /src/components/variable-slider.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, html, css } from 'lit'; 2 | import { property, customElement } from 'lit/decorators.js'; 3 | import { loadHaForm } from '../load-ha-form'; 4 | import { commonStyle } from '../styles'; 5 | import { fireEvent } from 'custom-card-helpers'; 6 | 7 | @customElement('variable-slider') 8 | export class VariableSlider extends LitElement { 9 | @property({ type: Number }) 10 | min = 0; 11 | 12 | @property({ type: Number }) 13 | max = 255; 14 | 15 | @property({ type: Number }) 16 | step = 1; 17 | 18 | //raw value 19 | @property({ type: Number }) 20 | set value(value: number) { 21 | value = isNaN(value) ? this.min : this._roundedValue(value / this.scaleFactor); 22 | this._displayedValue = value; 23 | } 24 | 25 | @property({ type: Number }) 26 | scaleFactor = 1; 27 | 28 | @property({ type: String }) 29 | unit = ''; 30 | 31 | @property({ type: Boolean }) 32 | optional = false; 33 | 34 | @property({ type: Boolean }) 35 | disabled = false; 36 | 37 | @property({ type: Number }) 38 | _displayedValue = 0; 39 | 40 | firstUpdated() { 41 | (async () => await loadHaForm())(); 42 | 43 | if (this.disabled && !this.optional) { 44 | this.disabled = false; 45 | this.requestUpdate(); 46 | } 47 | } 48 | 49 | render() { 50 | return html` 51 |
52 |
53 | ${this.getCheckbox()} 54 |
55 |
56 | ${this.getSlider()} 57 |
58 |
59 | ${this._displayedValue}${this.unit} 60 |
61 |
62 | `; 63 | } 64 | 65 | getSlider() { 66 | if (!this.disabled) { 67 | return html` 68 | 76 | `; 77 | } else { 78 | return html` 79 | 87 | `; 88 | } 89 | } 90 | 91 | getCheckbox() { 92 | if (!this.optional) return html``; 93 | return html` 94 | 95 | `; 96 | } 97 | 98 | toggleChecked(e: Event) { 99 | const checked = (e.target as HTMLInputElement).checked; 100 | this.disabled = !checked; 101 | const value = this.disabled ? null : this._scaledValue(this._displayedValue); 102 | fireEvent(this, 'value-changed', { value: value }); 103 | } 104 | 105 | private _updateValue(e: Event) { 106 | let value = Number((e.target as HTMLInputElement).value); 107 | this._displayedValue = value; 108 | value = this._scaledValue(this._displayedValue); 109 | fireEvent(this, 'value-changed', { value: value }); 110 | } 111 | 112 | private _roundedValue(value: number) { 113 | value = Math.round(value / this.step) * this.step; 114 | value = parseFloat(value.toPrecision(12)); 115 | if (value > this.max) value = this.max; 116 | else if (value < this.min) value = this.min; 117 | return value; 118 | } 119 | 120 | private _scaledValue(value: number) { 121 | value = this._roundedValue(value); 122 | value = value * this.scaleFactor; 123 | value = parseFloat(value.toFixed(2)); 124 | return value; 125 | } 126 | 127 | static styles = css` 128 | ${commonStyle} :host { 129 | width: 100%; 130 | } 131 | ha-slider { 132 | width: 100%; 133 | } 134 | `; 135 | } 136 | -------------------------------------------------------------------------------- /src/const.ts: -------------------------------------------------------------------------------- 1 | import { CardConfig } from './types'; 2 | 3 | export const CARD_VERSION = 'v3.2.15'; 4 | 5 | export const DefaultTimeStep = 10; 6 | 7 | export const DefaultGroupIcon = 'folder-outline'; 8 | export const DefaultEntityIcon = 'folder-outline'; 9 | export const DefaultActionIcon = 'flash'; 10 | export const DeadEntityName = '(unknown entity)'; 11 | export const DeadEntityIcon = 'help-circle-outline'; 12 | 13 | export const FieldTemperature = 'temperature'; 14 | export const WorkdaySensor = 'binary_sensor.workday_sensor'; 15 | 16 | export const NotifyDomain = 'notify'; 17 | 18 | export enum ETabOptions { 19 | Entity = 'entity', 20 | Time = 'time', 21 | Options = 'options', 22 | } 23 | 24 | export const DefaultCardConfig: CardConfig = { 25 | type: 'scheduler-card', 26 | discover_existing: true, 27 | standard_configuration: true, 28 | include: [], 29 | exclude: [], 30 | groups: [], 31 | customize: {}, 32 | title: true, 33 | time_step: 10, 34 | show_header_toggle: false, 35 | display_options: { 36 | primary_info: 'default', 37 | secondary_info: ['relative-time', 'additional-tasks'], 38 | icon: 'action', 39 | }, 40 | tags: [], 41 | sort_by: ['relative-time', 'state'], 42 | }; 43 | 44 | export const WebsocketEvent = 'scheduler_updated'; 45 | -------------------------------------------------------------------------------- /src/data/actions/assign_action.ts: -------------------------------------------------------------------------------- 1 | import { Action, EVariableType, LevelVariable, ListVariable, ServiceCall, TextVariable } from '../../types'; 2 | import { omit } from '../../helpers'; 3 | 4 | export const assignAction = (entity_id: string, action: Action) => { 5 | let output: ServiceCall = { 6 | entity_id: entity_id, 7 | service: action.service, 8 | service_data: { ...action.service_data }, 9 | }; 10 | 11 | Object.entries(action.variables || {}).forEach(([key, config]) => { 12 | const serviceArgs = Object.keys(output.service_data || {}); 13 | if (serviceArgs.includes(key)) return; 14 | 15 | if (config.type == EVariableType.Level) { 16 | config = config as LevelVariable; 17 | output = { 18 | ...output, 19 | service_data: config.optional 20 | ? omit(output.service_data || {}, key) 21 | : { 22 | ...output.service_data, 23 | [key]: parseFloat((config.min * config.scale_factor).toPrecision(12)) || 0, 24 | }, 25 | }; 26 | } else if (config.type == EVariableType.List) { 27 | config = config as ListVariable; 28 | output = { 29 | ...output, 30 | service_data: { 31 | ...output.service_data, 32 | [key]: config.options.length ? config.options[0].value : undefined, 33 | }, 34 | }; 35 | } else if (config.type == EVariableType.Text) { 36 | config = config as TextVariable; 37 | output = { 38 | ...output, 39 | service_data: { 40 | ...output.service_data, 41 | [key]: '', 42 | }, 43 | }; 44 | } 45 | }); 46 | 47 | return output; 48 | }; 49 | -------------------------------------------------------------------------------- /src/data/actions/compare_actions.ts: -------------------------------------------------------------------------------- 1 | import { isEqual } from '../../helpers'; 2 | import { Action, EVariableType, LevelVariable, ListVariable } from '../../types'; 3 | 4 | export function compareActions(actionA: Action, actionB: Action, allowVars = false) { 5 | const isOptional = (variable: string, action: Action) => { 6 | return ( 7 | Object.keys(action.variables || {}).includes(variable) && 8 | action.variables![variable].type == EVariableType.Level && 9 | (action.variables![variable] as LevelVariable).optional 10 | ); 11 | }; 12 | 13 | //actions should have the same service 14 | if (actionA.service !== actionB.service) return false; 15 | 16 | const serviceDataA = Object.keys(actionA.service_data || {}); 17 | const variablesA = Object.keys(actionA.variables || {}); 18 | 19 | const serviceDataB = Object.keys(actionB.service_data || {}); 20 | const variablesB = Object.keys(actionB.variables || {}); 21 | 22 | const argsA = [...new Set([...serviceDataA, ...variablesA])]; 23 | const argsB = [...new Set([...serviceDataB, ...variablesB])]; 24 | const allArgs = [...new Set([...argsA, ...argsB])]; 25 | 26 | return allArgs.every(arg => { 27 | // both actions must have the parameter in common 28 | if (!argsA.includes(arg)) return isOptional(arg, actionB); 29 | if (!argsB.includes(arg)) return isOptional(arg, actionA); 30 | 31 | // if its a fixed parameter it must be equal 32 | if ( 33 | serviceDataA.filter(e => !variablesA.includes(e)).includes(arg) && 34 | serviceDataB.filter(e => !variablesB.includes(e)).includes(arg) 35 | ) 36 | return isEqual(actionA.service_data![arg], actionB.service_data![arg]); 37 | 38 | // if both are variables they are assumed to be equal 39 | if (variablesA.includes(arg) && variablesB.includes(arg)) return true; 40 | 41 | if (!allowVars) return false; 42 | 43 | // compare a fixed value with variable 44 | const value = serviceDataA.includes(arg) ? actionA.service_data![arg] : actionB.service_data![arg]; 45 | 46 | const variable = variablesA.includes(arg) ? actionA.variables![arg] : actionB.variables![arg]; 47 | 48 | if (variable.type === EVariableType.List) { 49 | return (variable as ListVariable).options.some(e => e.value === value); 50 | } else if (variable.type === EVariableType.Level) return !isNaN(value); 51 | else if (variable.type == EVariableType.Text) return true; 52 | 53 | return false; 54 | }); 55 | } 56 | -------------------------------------------------------------------------------- /src/data/actions/compute_action_display.ts: -------------------------------------------------------------------------------- 1 | import { computeEntity } from 'custom-card-helpers'; 2 | import { Action, EVariableType, LevelVariable, ListVariable, TextVariable } from '../../types'; 3 | import { levelVariableDisplay } from '../variables/level_variable'; 4 | import { PrettyPrintName } from '../../helpers'; 5 | import { listVariableDisplay } from '../variables/list_variable'; 6 | import { textVariableDisplay } from '../variables/text_variable'; 7 | 8 | const wildcardPattern = /\{([^\}]+)\}/; 9 | const parameterPattern = /\[([^\]]+)\]/; 10 | const MAX_RECURSION_DEPTH = 100; 11 | 12 | export function computeActionDisplay(action: Action) { 13 | let name = action.name; 14 | if (!name) name = PrettyPrintName(computeEntity(action.service)); 15 | 16 | const replaceWildcards = (string: string, recursionDepth = 0): string => { 17 | const res = wildcardPattern.exec(string); 18 | if (!res) return string; 19 | const field = res[1]; 20 | 21 | if (!Object.keys(action.service_data || {}).includes(field)) return string.replace(res[0], ''); 22 | 23 | let replacement: string; 24 | if (Object.keys(action.variables || {}).includes(field)) { 25 | if (action.variables![field].type == EVariableType.Level) 26 | replacement = levelVariableDisplay(action.service_data![field], action.variables![field] as LevelVariable); 27 | else if (action.variables![field].type == EVariableType.List) 28 | replacement = listVariableDisplay(action.service_data![field], action.variables![field] as ListVariable); 29 | else replacement = textVariableDisplay(action.service_data![field], action.variables![field] as TextVariable); 30 | } else { 31 | replacement = action.service_data![field]; 32 | } 33 | string = string.replace(res[0], replacement); 34 | if (recursionDepth >= MAX_RECURSION_DEPTH) return string; 35 | return replaceWildcards(string); 36 | }; 37 | 38 | const replaceSubstrings = (string: string, recursionDepth = 0): string => { 39 | const res = parameterPattern.exec(string); 40 | if (!res) return string; 41 | 42 | const field = res[1].match(wildcardPattern)![1]; 43 | const hasWildcard = Object.keys(action.service_data || {}).includes(field); 44 | if (hasWildcard) string = string.replace(res[0], replaceWildcards(res[1])); 45 | else string = string.replace(res[0], ''); 46 | if (recursionDepth >= MAX_RECURSION_DEPTH) return string; 47 | return replaceSubstrings(string); 48 | }; 49 | 50 | name = replaceSubstrings(name); 51 | name = replaceWildcards(name); 52 | return name || ''; 53 | } 54 | -------------------------------------------------------------------------------- /src/data/actions/compute_actions.ts: -------------------------------------------------------------------------------- 1 | import { HomeAssistant, computeDomain, computeEntity } from 'custom-card-helpers'; 2 | import { CardConfig, Action, ListVariable, EVariableType } from '../../types'; 3 | import { standardActions } from '../../standard-configuration/standardActions'; 4 | import { matchPattern } from '../match_pattern'; 5 | import { isDefined, flatten, omit, pick } from '../../helpers'; 6 | import { compareActions } from './compare_actions'; 7 | import { computeCommonActions } from './compute_common_actions'; 8 | import { computeVariables } from '../variables/compute_variables'; 9 | import { HassEntity } from 'home-assistant-js-websocket'; 10 | import { computeSupportedFeatures } from '../entities/compute_supported_features'; 11 | import { computeActionDisplay } from './compute_action_display'; 12 | 13 | function parseString(str: string) { 14 | return str 15 | .replace(/_/g, ' ') 16 | .trim() 17 | .toLowerCase(); 18 | } 19 | 20 | export function computeActions(entity_id: string | string[], hass: HomeAssistant, config: CardConfig): Action[] { 21 | if (Array.isArray(entity_id)) { 22 | let actions = entity_id.map(e => computeActions(e, hass, config)); 23 | return computeCommonActions(actions); 24 | } 25 | 26 | const stateObj = hass.states[entity_id] as HassEntity | undefined; 27 | 28 | //fetch standard actions for entity 29 | let actions = config.standard_configuration ? standardActions(entity_id, hass) : []; 30 | 31 | //get excluded actions for entity 32 | const excludedActions: string[] = flatten( 33 | Object.entries(config.customize) 34 | .filter(([a]) => matchPattern(a, entity_id)) 35 | .sort((a, b) => b[0].length - a[0].length) 36 | .map(([, b]) => b.exclude_actions) 37 | .filter(isDefined) 38 | ); 39 | if (excludedActions.length) { 40 | actions = actions.filter(e => !excludedActions.some(a => parseString(computeActionDisplay(e)).includes(a))); 41 | } 42 | 43 | //get customizations for entity 44 | const customizedActions: Action[] = flatten( 45 | Object.entries(config.customize) 46 | .filter(([a]) => matchPattern(a, entity_id)) 47 | .sort((a, b) => b[0].length - a[0].length) 48 | .map(([, b]) => b.actions) 49 | .filter(isDefined) 50 | ); 51 | if (customizedActions.length) { 52 | customizedActions.forEach(action => { 53 | //ensure services have domain prefixed 54 | if (!computeDomain(action.service).length) 55 | action = { ...action, service: computeDomain(entity_id) + '.' + computeEntity(action.service) }; 56 | 57 | //ensure service call has no entity_id 58 | if (action.service_data) action = { ...action, service_data: omit(action.service_data, 'entity_id') }; 59 | let res = actions.findIndex(e => compareActions(e, action)); 60 | if (res < 0) { 61 | //try to find it in unfiltered list of built-in actions 62 | let allActions = config.standard_configuration ? standardActions(entity_id, hass, false) : []; 63 | const match = allActions.find(e => compareActions(e, action)); 64 | if (match) { 65 | actions = [...actions, match]; 66 | res = actions.length - 1; 67 | } 68 | } 69 | 70 | if (res >= 0) { 71 | //standard action should be overwritten 72 | Object.assign(actions, { 73 | [res]: { ...actions[res], ...omit(action, 'variables') }, 74 | }); 75 | 76 | if (Object.keys(action.variables || {}).length) { 77 | let variableConfig = actions[res].variables || {}; 78 | 79 | //merge variable config 80 | variableConfig = Object.entries(variableConfig) 81 | .map(([field, variable]) => { 82 | return Object.keys(action.variables!).includes(field) 83 | ? [field, { ...variable, ...action.variables![field] }] 84 | : [field, action.variables![field]]; 85 | }) 86 | .reduce((obj, [key, val]) => (val ? Object.assign(obj, { [key as string]: val }) : obj), {}); 87 | 88 | //add new variables 89 | const newVariables = Object.keys(action.variables!).filter(e => !Object.keys(variableConfig).includes(e)); 90 | 91 | variableConfig = { 92 | ...variableConfig, 93 | ...computeVariables(pick(action.variables, newVariables)), 94 | }; 95 | 96 | Object.assign(actions, { 97 | [res]: { ...actions[res], variables: variableConfig }, 98 | }); 99 | } 100 | } else { 101 | //add a new action 102 | action = { 103 | ...action, 104 | variables: computeVariables(action.variables), 105 | }; 106 | actions.push(action); 107 | } 108 | }); 109 | } 110 | 111 | //filter by supported_features 112 | const supportedFeatures = computeSupportedFeatures(stateObj); 113 | actions = actions.filter(e => !e.supported_feature || e.supported_feature & supportedFeatures); 114 | 115 | //list variable with 1 option is not really a variable 116 | actions = actions.map(action => { 117 | if (Object.keys(action.variables || {}).length) { 118 | Object.keys(action.variables!).forEach(field => { 119 | if ( 120 | action.variables![field].type == EVariableType.List && 121 | (action.variables![field] as ListVariable).options.length == 1 122 | ) { 123 | action = { 124 | ...action, 125 | service_data: { 126 | ...action.service_data, 127 | [field]: (action.variables![field] as ListVariable).options[0].value, 128 | }, 129 | variables: omit(action.variables!, field), 130 | }; 131 | } 132 | }); 133 | } 134 | return action; 135 | }); 136 | 137 | return actions; 138 | } 139 | -------------------------------------------------------------------------------- /src/data/actions/compute_common_actions.ts: -------------------------------------------------------------------------------- 1 | import { isDefined } from '../../helpers'; 2 | import { Action, VariableDictionary, EVariableType, ListVariable } from '../../types'; 3 | import { compareActions } from './compare_actions'; 4 | import { computeMergedVariable } from '../variables/compute_merged_variable'; 5 | 6 | export function computeCommonActions(actionLists: Action[][]) { 7 | //calculate actions that are in common for all 8 | 9 | if (actionLists.length == 1) return actionLists[0]; 10 | 11 | let commonActions = actionLists[0].filter(action => 12 | actionLists.slice(1).every(list => list.some(e => compareActions(action, e))) 13 | ); 14 | 15 | commonActions = commonActions 16 | .map(action => { 17 | const mergedVariables: VariableDictionary = Object.entries(action.variables || {}) 18 | .map(([field, variable]) => { 19 | //compute action per entity 20 | const actions = actionLists.map(e => e.find(k => compareActions(k, action))); 21 | 22 | //remove the variable if it is not in common 23 | if (!actions.every(e => e && e.variables && field in e.variables)) { 24 | return [field, undefined]; 25 | } 26 | 27 | const variables = actions.map(e => e!.variables![field]); 28 | 29 | if (!variables.every(e => variable.type == e.type)) return [field, undefined]; 30 | 31 | //compute intersection of variables 32 | return [field, computeMergedVariable(...variables)]; 33 | }) 34 | .reduce((obj, [key, val]) => (val ? Object.assign(obj, { [key as string]: val }) : obj), {}); 35 | 36 | if (Object.values(mergedVariables).find(e => e.type == EVariableType.List && !(e as ListVariable).options.length)) 37 | return undefined; 38 | 39 | action = { 40 | ...action, 41 | variables: mergedVariables, 42 | }; 43 | 44 | return action; 45 | }) 46 | .filter(isDefined); 47 | 48 | return commonActions; 49 | } 50 | -------------------------------------------------------------------------------- /src/data/actions/import_action.ts: -------------------------------------------------------------------------------- 1 | import { ServiceCall, Action, VariableDictionary, EVariableType, ListVariable } from '../../types'; 2 | import { standardActions } from '../../standard-configuration/standardActions'; 3 | import { unique } from '../../helpers'; 4 | import { HomeAssistant } from 'custom-card-helpers'; 5 | import { compareActions } from './compare_actions'; 6 | import { listVariable } from '../variables/list_variable'; 7 | 8 | export function importAction(action: ServiceCall, hass: HomeAssistant): Action { 9 | const id = action.entity_id || action.service; 10 | const service = action.service; 11 | const serviceData = action.service_data || {}; 12 | const serviceArgs = Object.keys(serviceData); 13 | 14 | let actions = standardActions(id, hass, false); 15 | let matches = actions.filter(e => compareActions(action, e, true)); 16 | 17 | if (matches.length == 1) actions = matches; 18 | else { 19 | //only find actions with matching service name 20 | actions = actions.filter(e => e.service == service); 21 | 22 | // if action has a fixed argument, it should be provided to be a match 23 | actions = actions.filter(e => Object.keys(e.service_data || {}).every(k => serviceArgs.includes(k))); 24 | } 25 | 26 | if (actions.length > 1) { 27 | //the match is ambiguous, check service_data to find the action with best overlap 28 | 29 | actions.sort((a, b) => { 30 | const fixedArgsOverlapA = Object.entries(a.service_data || {}) 31 | .map(([k, v]): number => (k in serviceData ? (serviceData[k] == v ? 1 : -1) : 0)) 32 | .reduce((sum, e) => sum + e, 0); 33 | const fixedArgsOverlapB = Object.entries(b.service_data || {}) 34 | .map(([k, v]): number => (k in serviceData ? (serviceData[k] == v ? 1 : -1) : 0)) 35 | .reduce((sum, e) => sum + e, 0); 36 | 37 | //if one of the services has more fixed serviceArgs in common, it is preferred 38 | if (fixedArgsOverlapA > fixedArgsOverlapB) return -1; 39 | if (fixedArgsOverlapA < fixedArgsOverlapB) return 1; 40 | 41 | const serviceDataA = unique([...Object.keys(a.service_data || {}), ...Object.keys(a.variables || {})]); 42 | const serviceDataB = unique([...Object.keys(b.service_data || {}), ...Object.keys(b.variables || {})]); 43 | 44 | const overlapA = serviceArgs.filter(e => serviceDataA.includes(e)).length; 45 | const overlapB = serviceArgs.filter(e => serviceDataB.includes(e)).length; 46 | 47 | //if one of the services has more variable serviceArgs in common, it is preferred 48 | if (overlapA > overlapB) return -1; 49 | if (overlapA < overlapB) return 1; 50 | 51 | const extraKeysA = serviceDataA.filter(e => !serviceArgs.includes(e)).length; 52 | const extraKeysB = serviceDataB.filter(e => !serviceArgs.includes(e)).length; 53 | 54 | //if one of the services has less extra serviceArgs, it is preferred 55 | if (extraKeysA > extraKeysB) return 1; 56 | if (extraKeysA < extraKeysB) return -1; 57 | return 0; 58 | }); 59 | } 60 | if (actions.length) { 61 | // add option to the list if it is not existing 62 | let variables: VariableDictionary = { ...(actions[0].variables || {}) }; 63 | Object.entries(serviceData).forEach(([key, val]) => { 64 | if (!Object.keys(variables || {}).includes(key)) return; 65 | if (variables[key].type != EVariableType.List) return; 66 | variables = { 67 | ...variables, 68 | [key]: (variables[key] as ListVariable).options.some(e => e.value == val) 69 | ? variables[key] 70 | : { ...variables[key], options: [...(variables[key] as ListVariable).options, { value: val }] }, 71 | }; 72 | }); 73 | 74 | return { 75 | ...actions[0], 76 | service_data: { ...(actions[0].service_data || {}), ...serviceData }, 77 | variables: variables, 78 | }; 79 | } else { 80 | //unknown action, add from config 81 | return { 82 | service: service, 83 | service_data: action.service_data, 84 | }; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/data/actions/migrate_action_config.ts: -------------------------------------------------------------------------------- 1 | import { ServiceCall, Action, EVariableType, ListVariable, LevelVariable } from '../../types'; 2 | import { HomeAssistant } from 'custom-card-helpers'; 3 | import { importAction } from './import_action'; 4 | import { compareActions } from './compare_actions'; 5 | import { assignAction } from './assign_action'; 6 | 7 | export const migrateActionConfig = ( 8 | config: ServiceCall, 9 | entities: string[], 10 | actions: Action[], 11 | hass: HomeAssistant 12 | ): ServiceCall[] | null => { 13 | if (!config) return null; 14 | 15 | const action = importAction(config, hass); 16 | let match = actions.find(e => compareActions(e, action, true)); 17 | 18 | if (!match) return null; 19 | 20 | let output: ServiceCall[] | null = entities.map(e => assignAction(e, match!)); 21 | 22 | output = Object.keys(match.variables || {}) 23 | .filter(k => Object.keys(config.service_data || {}).includes(k)) 24 | .reduce((output: ServiceCall[] | null, variable) => { 25 | if (!output) return output; 26 | switch (match!.variables![variable].type) { 27 | case EVariableType.Level: 28 | //keep the selected level variable while maintaining min/max/step size/scale factor 29 | const levelVariable = match!.variables![variable] as LevelVariable; 30 | let val = Number(config.service_data![variable]); 31 | val = val / levelVariable.scale_factor; 32 | val = Math.round(val / levelVariable.step) * levelVariable.step; 33 | val = parseFloat(val.toPrecision(12)); 34 | if (val > levelVariable.max) val = levelVariable.max; 35 | else if (val < levelVariable.min) val = levelVariable.min; 36 | val = val * levelVariable.scale_factor; 37 | val = parseFloat(val.toFixed(2)); 38 | return output.map(e => Object.assign(e, { service_data: { ...e.service_data, [variable]: val } })); 39 | 40 | case EVariableType.List: 41 | const listVariable = match!.variables![variable] as ListVariable; 42 | if (listVariable.options.some(e => e.value == config.service_data![variable])) 43 | //keep the selected list variable if it is in common 44 | return output.map(e => 45 | Object.assign(e, { service_data: { ...e.service_data, [variable]: config.service_data![variable] } }) 46 | ); 47 | else return null; 48 | 49 | case EVariableType.Text: 50 | //keep the selected text variable 51 | return output.map(e => 52 | Object.assign(e, { service_data: { ...e.service_data, [variable]: config.service_data![variable] } }) 53 | ); 54 | default: 55 | return output; 56 | } 57 | }, output); 58 | 59 | return output; 60 | }; 61 | -------------------------------------------------------------------------------- /src/data/compute_states.ts: -------------------------------------------------------------------------------- 1 | import { HomeAssistant } from 'custom-card-helpers'; 2 | import { CardConfig, Variable } from '../types'; 3 | import { standardStates } from '../standard-configuration/standardStates'; 4 | import { isDefined } from '../helpers'; 5 | import { matchPattern } from './match_pattern'; 6 | import { listVariable } from './variables/list_variable'; 7 | import { levelVariable } from './variables/level_variable'; 8 | 9 | export function computeStates(entity_id: string, hass: HomeAssistant, config: CardConfig): Variable | null { 10 | //fetch standard states for entity 11 | let stateConfig = config.standard_configuration ? standardStates(entity_id, hass) : null; 12 | 13 | //get customizations for entity 14 | const customizedStates = Object.entries(config.customize) 15 | .filter(([a]) => matchPattern(a, entity_id)) 16 | .sort((a, b) => b[0].length - a[0].length) 17 | .map(([, b]) => b.states) 18 | .filter(isDefined); 19 | if (customizedStates.length) { 20 | customizedStates.forEach(userConfig => { 21 | if (Array.isArray(userConfig)) { 22 | stateConfig = listVariable({ options: userConfig.map(e => Object({ value: e })) }); 23 | } else { 24 | stateConfig = levelVariable(userConfig); 25 | } 26 | }); 27 | } 28 | return stateConfig; 29 | } 30 | -------------------------------------------------------------------------------- /src/data/custom_dialog.ts: -------------------------------------------------------------------------------- 1 | import { fireEvent } from 'custom-card-helpers'; 2 | 3 | type ValidHassDomEvent = keyof HASSDomEvents; 4 | 5 | export interface ProvideHassElement { 6 | provideHass(element: HTMLElement); 7 | } 8 | 9 | interface HassDialog extends HTMLElement { 10 | showDialog(params: T); 11 | closeDialog?: () => boolean | void; 12 | } 13 | 14 | interface ShowDialogParams { 15 | dialogTag: string; 16 | dialogImport: () => Promise; 17 | dialogParams: T; 18 | addHistory?: boolean; 19 | } 20 | 21 | interface LoadedDialogInfo { 22 | element: Promise; 23 | closedFocusTargets?: Set; 24 | } 25 | 26 | interface LoadedDialogsDict { 27 | [tag: string]: LoadedDialogInfo; 28 | } 29 | 30 | const LOADED: LoadedDialogsDict = {}; 31 | 32 | export const isEmbeddedInPopup = (element: HTMLElement) => { 33 | let root = element as any; 34 | while (root && root.parentNode) { 35 | if (root.parentNode === document) { 36 | break; 37 | } else if (root.parentNode instanceof DocumentFragment) { 38 | root = root.parentNode.host; 39 | } else { 40 | root = root.parentNode; 41 | } 42 | if (root.tagName.toUpperCase() == 'BODY') return false; 43 | else if (root.tagName.toUpperCase() == 'BROWSER-MOD-POPUP') return true; 44 | } 45 | return false; 46 | }; 47 | 48 | export const getPopupRootElement = (element: HTMLElement) => { 49 | let root = element as any; 50 | while (root && root.parentNode) { 51 | if (root.parentNode === document) { 52 | break; 53 | } else if (root.parentNode instanceof DocumentFragment) { 54 | root = root.parentNode.host; 55 | } else { 56 | root = root.parentNode; 57 | } 58 | if (root.tagName.toUpperCase() == 'BODY') break; 59 | } 60 | return root; 61 | }; 62 | 63 | export const showDialog = async ( 64 | element: HTMLElement & ProvideHassElement, 65 | config: ShowDialogParams, 66 | useAlternativeDialog?: boolean 67 | ) => { 68 | const popupRoot = useAlternativeDialog 69 | ? getPopupRootElement(element) 70 | : useAlternativeDialog === undefined 71 | ? isEmbeddedInPopup(element) 72 | ? getPopupRootElement(element) 73 | : null 74 | : null; 75 | 76 | if (popupRoot === null) { 77 | fireEvent(element, 'show-dialog', config); 78 | } else { 79 | if (!(config.dialogTag in LOADED)) { 80 | if (!config.dialogImport) { 81 | return; 82 | } 83 | LOADED[config.dialogTag] = { 84 | element: config.dialogImport().then(() => { 85 | const dialogEl = document.createElement(config.dialogTag) as HassDialog; 86 | element.provideHass(dialogEl); 87 | return dialogEl; 88 | }), 89 | }; 90 | } 91 | const dialogElement = await LOADED[config.dialogTag].element; 92 | popupRoot.appendChild(dialogElement); 93 | dialogElement.showDialog(config.dialogParams); 94 | } 95 | }; 96 | -------------------------------------------------------------------------------- /src/data/date-time/day_object.ts: -------------------------------------------------------------------------------- 1 | export function weekday(ts: Date) { 2 | let day = ts.getDay(); 3 | if (day == 0) day = 7; 4 | return day; 5 | } 6 | -------------------------------------------------------------------------------- /src/data/date-time/format_date.ts: -------------------------------------------------------------------------------- 1 | import { format } from 'fecha'; 2 | import { FrontendLocaleData } from './format_time'; 3 | 4 | export function formatDate(dateObj: Date, locale: FrontendLocaleData, isoFormat?: boolean) { 5 | const isCurrentYear = dateObj.getFullYear() == new Date().getFullYear(); 6 | 7 | const supportLocaleDateString = () => { 8 | try { 9 | new Date().toLocaleDateString('i'); 10 | } catch (e) { 11 | return e.name === 'RangeError'; 12 | } 13 | return false; 14 | }; 15 | 16 | if (isoFormat) return format(dateObj, 'isoDate'); 17 | 18 | if (isCurrentYear) { 19 | if (supportLocaleDateString()) { 20 | return new Intl.DateTimeFormat(locale.language, { 21 | month: 'long', 22 | day: 'numeric', 23 | }).format(dateObj); 24 | } else return format(dateObj, 'MMMM D'); 25 | } else { 26 | if (supportLocaleDateString()) { 27 | return new Intl.DateTimeFormat(locale.language, { 28 | year: 'numeric', 29 | month: 'long', 30 | day: 'numeric', 31 | }).format(dateObj); 32 | } else return format(dateObj, 'MMMM D, YYYY'); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/data/date-time/format_time.ts: -------------------------------------------------------------------------------- 1 | import { FrontendTranslationData } from 'custom-card-helpers'; 2 | import { format } from 'fecha'; 3 | 4 | export enum TimeFormat { 5 | language = 'language', 6 | system = 'system', 7 | am_pm = '12', 8 | twenty_four = '24', 9 | } 10 | 11 | export interface FrontendLocaleData extends FrontendTranslationData { 12 | time_format?: TimeFormat; 13 | } 14 | 15 | export const formatAmPm = (locale: FrontendLocaleData): boolean => { 16 | if (locale.time_format === TimeFormat.language || locale.time_format === TimeFormat.system) { 17 | const testLanguage = locale.time_format === TimeFormat.language ? locale.language : undefined; 18 | const test = new Date().toLocaleString(testLanguage); 19 | return test.includes('AM') || test.includes('PM'); 20 | } 21 | return locale.time_format === TimeFormat.am_pm; 22 | }; 23 | 24 | export function formatTime( 25 | dateObj: Date, 26 | locale: FrontendLocaleData, 27 | formatOption?: TimeFormat.am_pm | TimeFormat.twenty_four 28 | ) { 29 | const supportLocaleString = () => { 30 | try { 31 | new Date().toLocaleTimeString('i'); 32 | } catch (e) { 33 | return (e as any).name === 'RangeError'; 34 | } 35 | return false; 36 | }; 37 | 38 | if (formatOption === TimeFormat.am_pm || (!formatOption && locale.time_format === TimeFormat.am_pm)) { 39 | return format(dateObj, 'h:mm A'); // '5:30 AM' 40 | } 41 | if (formatOption === TimeFormat.twenty_four || (!formatOption && locale.time_format === TimeFormat.twenty_four)) { 42 | return format(dateObj, 'shortTime'); // '05:30' 43 | } 44 | 45 | if (supportLocaleString()) { 46 | return dateObj.toLocaleTimeString(locale.language, { 47 | hour: 'numeric', 48 | minute: '2-digit', 49 | hour12: formatAmPm(locale), 50 | }); 51 | } 52 | return formatAmPm(locale) 53 | ? formatTime(dateObj, locale, TimeFormat.am_pm) 54 | : formatTime(dateObj, locale, TimeFormat.twenty_four); 55 | } 56 | -------------------------------------------------------------------------------- /src/data/date-time/format_weekday.ts: -------------------------------------------------------------------------------- 1 | import { FrontendTranslationData } from 'custom-card-helpers'; 2 | 3 | export const weekdayArray = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday']; 4 | 5 | export const formatWeekday = (date: Date | number, locale: FrontendTranslationData, short?: boolean): string => { 6 | const supportLocaleString = () => { 7 | try { 8 | new Date().toLocaleDateString('i'); 9 | } catch (e) { 10 | return e.name === 'RangeError'; 11 | } 12 | return false; 13 | }; 14 | 15 | if (typeof date !== 'object') { 16 | let _date = new Date(2017, 1, 26); 17 | _date.setDate(_date.getDate() + date); 18 | date = _date; 19 | } 20 | 21 | if (supportLocaleString()) { 22 | return date.toLocaleDateString(locale.language, { 23 | weekday: short ? 'short' : 'long', 24 | }); 25 | } else { 26 | const weekday = date.getDay(); 27 | return short ? weekdayArray[weekday].substr(0, 3) : weekdayArray[weekday]; 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /src/data/date-time/relative_time.ts: -------------------------------------------------------------------------------- 1 | import { HomeAssistant } from 'custom-card-helpers'; 2 | import { ETimeEvent } from '../../types'; 3 | import { parseRelativeTime, roundTime, stringToTime, timeToString } from './time'; 4 | 5 | export const relToAbsTime = (timeStr: string, hass: HomeAssistant, options: { stepSize?: number } = {}) => { 6 | const res = parseRelativeTime(timeStr); 7 | if (!res) return timeStr; 8 | 9 | const sunEntity = hass.states['sun.sun']; 10 | 11 | const ts_ref = 12 | res.event == 'sunrise' 13 | ? stringToTime(sunEntity.attributes.next_rising, hass) 14 | : stringToTime(sunEntity.attributes.next_setting, hass); 15 | 16 | let ts = res.sign == '+' ? ts_ref + stringToTime(res.offset, hass) : ts_ref - stringToTime(res.offset, hass); 17 | 18 | if (options.stepSize) ts = roundTime(ts, options.stepSize); 19 | 20 | return timeToString(ts); 21 | }; 22 | 23 | export const absToRelTime = ( 24 | timeStr: string, 25 | event: ETimeEvent | undefined, 26 | hass: HomeAssistant, 27 | options: { stepSize?: number } = {} 28 | ) => { 29 | const res = parseRelativeTime(timeStr); 30 | if (res) timeStr = relToAbsTime(timeStr, hass); 31 | 32 | const ts = stringToTime(timeStr, hass); 33 | 34 | const sunEntity = hass.states['sun.sun']; 35 | 36 | const ts_sunrise = stringToTime(sunEntity.attributes.next_rising, hass); 37 | const ts_sunset = stringToTime(sunEntity.attributes.next_setting, hass); 38 | 39 | if (!event) event = Math.abs(ts - ts_sunrise) < Math.abs(ts - ts_sunset) ? ETimeEvent.Sunrise : ETimeEvent.Sunset; 40 | 41 | const ts_ref = 42 | event == ETimeEvent.Sunrise 43 | ? stringToTime(sunEntity.attributes.next_rising, hass) 44 | : stringToTime(sunEntity.attributes.next_setting, hass); 45 | 46 | let offset = ts - ts_ref; 47 | if (options.stepSize) offset = roundTime(offset, options.stepSize, { maxHours: 6 }); 48 | return `${event}${offset > 0 ? '+' : '-'}${timeToString(Math.abs(offset))}`; 49 | }; 50 | -------------------------------------------------------------------------------- /src/data/date-time/start_of_week.ts: -------------------------------------------------------------------------------- 1 | export function startOfWeek(locale) { 2 | const parts = locale.match( 3 | /^([a-z]{2,3})(?:-([a-z]{3})(?=$|-))?(?:-([a-z]{4})(?=$|-))?(?:-([a-z]{2}|\d{3})(?=$|-))?/i 4 | ); 5 | 6 | const language = parts[1]; 7 | const region = parts[4]; 8 | 9 | const regionSat = 'AEAFBHDJDZEGIQIRJOKWLYOMQASDSY'.match(/../g)!; 10 | const regionSun = 'AGARASAUBDBRBSBTBWBZCACNCODMDOETGTGUHKHNIDILINJMJPKEKHKRLAMHMMMOMTMXMZNINPPAPEPHPKPRPTPYSASGSVTHTTTWUMUSVEVIWSYEZAZW'.match( 11 | /../g 12 | )!; 13 | const languageSat = ['ar', 'arq', 'arz', 'fa']; 14 | const languageSun = 'amasbndzengnguhehiidjajvkmknkolomhmlmrmtmyneomorpapssdsmsnsutatethtnurzhzu'.match(/../g)!; 15 | 16 | if (region) return regionSun.includes(region) ? 'sun' : regionSat.includes(region) ? 'sat' : 'mon'; 17 | else return languageSun.includes(language) ? 'sun' : languageSat.includes(language) ? 'sat' : 'mon'; 18 | } 19 | -------------------------------------------------------------------------------- /src/data/date-time/string_to_date.ts: -------------------------------------------------------------------------------- 1 | export function stringToDate(dateTimeString?: string) { 2 | let date = new Date(); 3 | 4 | const dateMatch = (dateTimeString || '').match(/^([0-9]{4})-([0-9]{2})-([0-9]{2})/); 5 | if (dateMatch !== null) date.setFullYear(Number(dateMatch[1]), Number(dateMatch[2]) - 1, Number(dateMatch[3])); 6 | 7 | const timeMatch = (dateTimeString || '').match(/([0-9]{2}):([0-9]{2})(:([0-9]{2}))?$/); 8 | if (timeMatch !== null) 9 | date.setHours( 10 | Number(timeMatch[1]), 11 | Number(timeMatch[2]), 12 | timeMatch.length > 4 ? Number(timeMatch[4]) : date.getSeconds() 13 | ); 14 | 15 | return date; 16 | } 17 | -------------------------------------------------------------------------------- /src/data/date-time/time.ts: -------------------------------------------------------------------------------- 1 | import { HomeAssistant } from 'custom-card-helpers'; 2 | import { ETimeEvent } from '../../types'; 3 | 4 | export function stringToTime(string: string, hass: HomeAssistant) { 5 | if (string.match(/^([0-9:]+)$/)) { 6 | const parts = string.split(':').map(Number); 7 | return parts[0] * 3600 + parts[1] * 60 + (parts[2] || 0); 8 | } 9 | const res = parseRelativeTime(string); 10 | if (res) { 11 | const sunEntity = hass.states['sun.sun']; 12 | const ts_sunrise = stringToTime(sunEntity.attributes.next_rising, hass); 13 | const ts_sunset = stringToTime(sunEntity.attributes.next_setting, hass); 14 | 15 | let ts = res.event == 'sunrise' ? ts_sunrise : ts_sunset; 16 | ts = res.sign == '+' ? ts + stringToTime(res.offset, hass) : ts - stringToTime(res.offset, hass); 17 | return ts; 18 | } 19 | const ts = new Date(string); 20 | return ts.getHours() * 3600 + ts.getMinutes() * 60 + ts.getSeconds(); 21 | } 22 | 23 | export function timeToString(time: number) { 24 | const hours = Math.floor(time / 3600); 25 | time -= hours * 3600; 26 | const minutes = Math.floor(time / 60); 27 | time -= minutes * 60; 28 | const seconds = Math.round(time); 29 | return ( 30 | String(hours % 24).padStart(2, '0') + 31 | ':' + 32 | String(minutes).padStart(2, '0') + 33 | ':' + 34 | String(seconds).padStart(2, '0') 35 | ); 36 | } 37 | 38 | export function roundTime( 39 | value: number, 40 | stepSize: number, 41 | options: { wrapAround?: boolean; maxHours?: number } = { wrapAround: true } 42 | ) { 43 | let hours = value >= 0 ? Math.floor(value / 3600) : Math.ceil(value / 3600); 44 | let minutes = Math.floor((value - hours * 3600) / 60); 45 | 46 | if (minutes % stepSize != 0) minutes = Math.round(minutes / stepSize) * stepSize; 47 | 48 | if (minutes >= 60) { 49 | hours++; 50 | minutes -= 60; 51 | } else if (minutes < 0) { 52 | hours--; 53 | minutes += 60; 54 | } 55 | if (options.wrapAround) { 56 | if (hours >= 24) hours -= 24; 57 | else if (hours < 0) hours += 24; 58 | } 59 | const time = hours * 3600 + minutes * 60; 60 | if (options.maxHours) { 61 | if (time > options.maxHours * 3600) return options.maxHours * 3600; 62 | if (time < -options.maxHours * 3600) return -options.maxHours * 3600; 63 | } 64 | return time; 65 | } 66 | 67 | export function parseRelativeTime(time: string) { 68 | const match = time.match(/^([a-z]+)([\+|-]{1})([0-9:]+)$/); 69 | if (!match) return false; 70 | return { 71 | event: match[1] as ETimeEvent, 72 | sign: match[2], 73 | offset: match[3], 74 | }; 75 | } 76 | -------------------------------------------------------------------------------- /src/data/date-time/weekday_to_list.ts: -------------------------------------------------------------------------------- 1 | import { WeekdayType } from '../../types'; 2 | 3 | export function weekdayToList(weekday: WeekdayType) { 4 | let list = weekday 5 | .map(e => { 6 | switch (e) { 7 | case 'mon': 8 | return 1; 9 | case 'tue': 10 | return 2; 11 | case 'wed': 12 | return 3; 13 | case 'thu': 14 | return 4; 15 | case 'fri': 16 | return 5; 17 | case 'sat': 18 | return 6; 19 | case 'sun': 20 | return 7; 21 | default: 22 | return; 23 | } 24 | }) 25 | .filter(e => e) as number[]; 26 | return list; 27 | } 28 | -------------------------------------------------------------------------------- /src/data/date-time/weekday_type.ts: -------------------------------------------------------------------------------- 1 | import { WeekdayType, EDayType } from '../../types'; 2 | 3 | export function weekdayType(weekday: WeekdayType) { 4 | if (weekday.includes('daily')) return EDayType.Daily; 5 | if (weekday.includes('workday')) return EDayType.Workday; 6 | if (weekday.includes('weekend')) return EDayType.Weekend; 7 | return EDayType.Custom; 8 | } 9 | -------------------------------------------------------------------------------- /src/data/entities/compute_entities.ts: -------------------------------------------------------------------------------- 1 | import { HomeAssistant } from 'custom-card-helpers'; 2 | import { CardConfig } from '../../types'; 3 | import { entityFilter } from './entity_filter'; 4 | import { computeActions } from '../actions/compute_actions'; 5 | import { computeStates } from '../compute_states'; 6 | 7 | export function computeEntities( 8 | hass: HomeAssistant, 9 | config: CardConfig, 10 | options: { filterActions: boolean; filterStates: boolean } = { filterActions: true, filterStates: false } 11 | ) { 12 | let entities = Object.keys(hass.states).filter(e => entityFilter(e, config)); 13 | 14 | if ('notify' in hass.services) { 15 | entities = [ 16 | ...entities, 17 | ...Object.keys(hass.services['notify']) 18 | .map(e => `notify.${e}`) 19 | .filter(e => entityFilter(e, config)), 20 | ]; 21 | } 22 | 23 | if (options.filterActions && options.filterStates) 24 | entities = entities.filter(e => computeActions(e, hass, config).length || computeStates(e, hass, config)); 25 | else if (options.filterActions) entities = entities.filter(e => computeActions(e, hass, config).length); 26 | else if (options.filterStates) entities = entities.filter(e => computeStates(e, hass, config)); 27 | 28 | return entities; 29 | } 30 | -------------------------------------------------------------------------------- /src/data/entities/compute_supported_features.ts: -------------------------------------------------------------------------------- 1 | import { HassEntity } from 'home-assistant-js-websocket'; 2 | import { computeDomain } from 'custom-card-helpers'; 3 | import { unique } from '../../helpers'; 4 | 5 | export const colorModesToSupportedFeatures = (colorModes: any) => { 6 | if (!colorModes || !Array.isArray(colorModes)) return 0; 7 | let features: number[] = colorModes.map(mode => { 8 | switch (mode) { 9 | case 'brightness': 10 | case 'color_temp': 11 | case 'hs': 12 | case 'xy': 13 | case 'rgb': 14 | case 'rgbw': 15 | case 'rgbww': 16 | return 1; 17 | case 'unknown': 18 | case 'onoff': 19 | case 'white': 20 | default: 21 | return 0; 22 | } 23 | }); 24 | features = unique(features); 25 | return features.reduce((acc, val) => acc | val, 0); 26 | }; 27 | 28 | export const computeSupportedFeatures = (stateObj: HassEntity | undefined) => { 29 | if (!stateObj) return 0; 30 | const domain = computeDomain(stateObj.entity_id); 31 | 32 | switch (domain) { 33 | case 'light': 34 | return colorModesToSupportedFeatures(stateObj.attributes.supported_color_modes); 35 | default: 36 | return stateObj.attributes.supported_features || 0; 37 | } 38 | }; 39 | -------------------------------------------------------------------------------- /src/data/entities/entity_filter.ts: -------------------------------------------------------------------------------- 1 | import { Dictionary } from '../../types'; 2 | import { matchPattern } from '../match_pattern'; 3 | 4 | type FilterType = { include?: string[]; exclude?: string[]; customize?: Dictionary; groups?: FilterType[] }; 5 | 6 | export function entityFilter(entity_id: string, config: FilterType) { 7 | const applyFilter = (value: string, filter: FilterType) => { 8 | return ( 9 | ((filter.include || []).some(e => matchPattern(e, value)) || 10 | Object.keys(filter.customize || {}).some(e => matchPattern(e, value))) && 11 | !(filter.exclude || []).some(e => matchPattern(e, value)) 12 | ); 13 | }; 14 | return config.groups?.some(group => applyFilter(entity_id, group)) || applyFilter(entity_id, config); 15 | } 16 | -------------------------------------------------------------------------------- /src/data/entities/parse_entity.ts: -------------------------------------------------------------------------------- 1 | import { CardConfig, EntityElement } from '../../types'; 2 | import { standardIcon } from '../../standard-configuration/standardIcon'; 3 | import { matchPattern } from '../match_pattern'; 4 | import { pick } from '../../helpers'; 5 | import { HomeAssistant, computeEntity, computeDomain } from 'custom-card-helpers'; 6 | import { DeadEntityIcon, DeadEntityName, DefaultEntityIcon, NotifyDomain } from '../../const'; 7 | 8 | export function parseEntity(entity_id: string, hass: HomeAssistant, config: Partial) { 9 | const stateObj = entity_id in hass.states ? hass.states[entity_id] : undefined; 10 | 11 | let entity: EntityElement = { 12 | id: entity_id, 13 | name: stateObj ? stateObj.attributes.friendly_name || computeEntity(entity_id) : DeadEntityName, 14 | icon: stateObj ? stateObj.attributes.icon : DeadEntityIcon, 15 | }; 16 | 17 | if (!stateObj && computeDomain(entity_id) == NotifyDomain) { 18 | let name = computeEntity(entity_id); 19 | let icon = standardIcon(entity_id, hass); 20 | if (name.includes('mobile_app_')) { 21 | name = name.split('mobile_app_').pop()!; 22 | if (`device_tracker.${name}` in hass.states) { 23 | const deviceTracker = hass.states[`device_tracker.${name}`]; 24 | name = deviceTracker.attributes.friendly_name || name; 25 | icon = 'hass:cellphone-text'; 26 | } 27 | } 28 | entity = { 29 | ...entity, 30 | name: name, 31 | icon: icon, 32 | }; 33 | } 34 | 35 | if ((config.standard_configuration === undefined || config.standard_configuration) && !entity.icon) { 36 | entity = { ...entity, icon: standardIcon(entity_id, hass) }; 37 | } else if (!entity.icon) { 38 | entity = { ...entity, icon: DefaultEntityIcon }; 39 | } 40 | 41 | if (config.customize) { 42 | const customize = Object.entries(config.customize) 43 | .filter(([pattern]) => matchPattern(pattern, entity.id)) 44 | .sort((a, b) => b[0].length - a[0].length) 45 | .map(([, v]) => v) 46 | .forEach(el => { 47 | entity = { ...entity, ...pick(el, ['name', 'icon']) }; 48 | }); 49 | } 50 | 51 | return entity; 52 | } 53 | -------------------------------------------------------------------------------- /src/data/entity_group.ts: -------------------------------------------------------------------------------- 1 | import { Group, CardConfig } from '../types'; 2 | import { DefaultGroupIcon } from '../const'; 3 | import { computeDomain, HomeAssistant } from 'custom-card-helpers'; 4 | import { domainIcons } from '../standard-configuration/standardIcon'; 5 | import { entityFilter } from './entities/entity_filter'; 6 | import { standardGroupNames } from '../standard-configuration/group_name'; 7 | 8 | export function entityGroups(entities: string[], config: Partial, hass: HomeAssistant) { 9 | let groups: Group[] = []; 10 | 11 | //create groups from user config 12 | if (config.groups) { 13 | config.groups.forEach(el => { 14 | if (!entities.find(e => entityFilter(e, el))) return; 15 | groups = [ 16 | ...groups, 17 | { 18 | name: el.name, 19 | icon: el.icon || DefaultGroupIcon, 20 | entities: entities.filter(e => entityFilter(e, el)), 21 | }, 22 | ]; 23 | }); 24 | } 25 | 26 | const ungroupedEntities = entities.filter(e => !groups.some(g => g.entities.includes(e))); 27 | const domains = ungroupedEntities.map(computeDomain).filter((v, k, arr) => arr.indexOf(v) === k); 28 | 29 | //automatically create groups for ungrouped entities 30 | domains.forEach(domain => { 31 | groups = [ 32 | ...groups, 33 | { 34 | name: standardGroupNames(domain, hass), 35 | icon: 36 | (config.standard_configuration === undefined || config.standard_configuration) && domain in domainIcons 37 | ? domainIcons[domain] 38 | : DefaultGroupIcon, 39 | entities: ungroupedEntities.filter(e => entityFilter(e, { include: [domain], exclude: [] })), 40 | }, 41 | ]; 42 | }); 43 | return groups; 44 | } 45 | -------------------------------------------------------------------------------- /src/data/item_display/compute_days_display.ts: -------------------------------------------------------------------------------- 1 | import { HomeAssistant } from 'custom-card-helpers'; 2 | import { getLocale } from '../../helpers'; 3 | import { localize } from '../../localize/localize'; 4 | import { WeekdayType, EDayType } from '../../types'; 5 | import { formatWeekday } from '../date-time/format_weekday'; 6 | import { weekdayToList } from '../date-time/weekday_to_list'; 7 | import { weekdayType } from '../date-time/weekday_type'; 8 | 9 | export const computeDaysDisplay = (weekdays: WeekdayType, hass: HomeAssistant) => { 10 | function findSequence(list: number[]): number[] { 11 | const len: number[] = []; 12 | for (let i = 0; i < list.length - 1; i++) { 13 | let j = i + 1; 14 | while (list[j] - list[j - 1] == 1) j++; 15 | len.push(j - i); 16 | } 17 | return len; 18 | } 19 | 20 | if (!hass) return ''; 21 | switch (weekdayType(weekdays)) { 22 | case EDayType.Daily: 23 | return localize('ui.components.date.day_types_long.daily', getLocale(hass)); 24 | case EDayType.Workday: 25 | return localize('ui.components.date.day_types_long.workdays', getLocale(hass)); 26 | case EDayType.Weekend: 27 | return localize('ui.components.date.day_types_long.weekend', getLocale(hass)); 28 | case EDayType.Custom: 29 | const list = weekdayToList(weekdays); 30 | const seq = findSequence(list); 31 | const len = Math.max(...seq); 32 | if (list.length == 6) { 33 | const missing = [1, 2, 3, 4, 5, 6, 7].filter(e => !list.includes(e)); 34 | return localize( 35 | 'ui.components.date.repeated_days_except', 36 | getLocale(hass), 37 | '{excludedDays}', 38 | formatWeekday(missing.pop()!, getLocale(hass)) 39 | ); 40 | } 41 | const dayNames = list.map(e => formatWeekday(e, getLocale(hass))); 42 | if (list.length >= 3 && len >= 3) { 43 | const start = seq.reduce((obj, e, i) => (e == len ? i : obj), 0); 44 | dayNames.splice( 45 | start, 46 | len, 47 | localize( 48 | 'ui.components.date.days_range', 49 | getLocale(hass), 50 | ['{startDay}', '{endDay}'], 51 | [dayNames[start], dayNames[start + len - 1]] 52 | ) 53 | ); 54 | } 55 | const daysString = 56 | dayNames.length > 1 57 | ? `${dayNames.slice(0, -1).join(', ')} ${hass.localize('ui.common.and')} ${dayNames.pop()}` 58 | : `${dayNames.pop()}`; 59 | return localize('ui.components.date.repeated_days', getLocale(hass), '{days}', daysString); 60 | default: 61 | return ''; 62 | } 63 | }; 64 | -------------------------------------------------------------------------------- /src/data/item_display/compute_schedule_display.ts: -------------------------------------------------------------------------------- 1 | import { computeDomain, HomeAssistant } from 'custom-card-helpers'; 2 | import { AsArray, capitalize, getLocale, PrettyPrintName, unique } from '../../helpers'; 3 | import { localize } from '../../localize/localize'; 4 | import { standardIcon } from '../../standard-configuration/standardIcon'; 5 | import { Action, CardConfig, Schedule } from '../../types'; 6 | import { compareActions } from '../actions/compare_actions'; 7 | import { computeActions } from '../actions/compute_actions'; 8 | import { computeActionDisplay } from '../actions/compute_action_display'; 9 | import { importAction } from '../actions/import_action'; 10 | import { parseEntity } from '../entities/parse_entity'; 11 | import { computeDaysDisplay } from './compute_days_display'; 12 | import { computeTimeDisplay } from './compute_time_display'; 13 | 14 | export const computeScheduleHeader = (schedule: Schedule, config: CardConfig, hass: HomeAssistant) => { 15 | const primaryInfo = AsArray( 16 | !config.display_options || !config.display_options.primary_info 17 | ? '{entity}: {action}' 18 | : config.display_options.primary_info 19 | ); 20 | return primaryInfo.map(e => computeItemDisplay(e, schedule, config, hass)); 21 | }; 22 | 23 | export const computeScheduleInfo = (schedule: Schedule, config: CardConfig, hass: HomeAssistant) => { 24 | const primaryInfo = AsArray( 25 | !config.display_options || !config.display_options.secondary_info 26 | ? ['relative-time', 'additional-tasks'] 27 | : config.display_options.secondary_info 28 | ); 29 | return primaryInfo.map(e => computeItemDisplay(e, schedule, config, hass)); 30 | }; 31 | 32 | export const computeScheduleIcon = (schedule: Schedule, config: CardConfig, hass: HomeAssistant) => { 33 | if (config.display_options && config.display_options.icon && config.display_options.icon == 'entity') { 34 | const entities = computeEntities(schedule, config, hass); 35 | return unique(entities.map(e => e.icon)).length == 1 ? entities[0].icon : standardIcon(entities[0].id, hass); 36 | } else { 37 | const action = computeAction(schedule, config, hass); 38 | return action.icon; 39 | } 40 | }; 41 | 42 | const computeItemDisplay = ( 43 | displayItem: string, 44 | schedule: Schedule, 45 | config: CardConfig, 46 | hass: HomeAssistant 47 | ): string => { 48 | switch (displayItem) { 49 | case 'default': 50 | return ( 51 | computeItemDisplay('name', schedule, config, hass) || 52 | `${computeItemDisplay('entity', schedule, config, hass)}: ${computeItemDisplay( 53 | 'action', 54 | schedule, 55 | config, 56 | hass 57 | )}` 58 | ); 59 | case 'name': 60 | return schedule.name || ''; 61 | case 'relative-time': 62 | return ``; 63 | case 'additional-tasks': 64 | return schedule.timeslots.length > 1 65 | ? '+' + 66 | localize( 67 | 'ui.panel.overview.additional_tasks', 68 | getLocale(hass), 69 | '{number}', 70 | String(schedule.timeslots.length - 1) 71 | ) 72 | : ''; 73 | case 'time': 74 | return capitalize(computeTimeDisplay(schedule.timeslots[schedule.next_entries[0]], hass)); 75 | case 'days': 76 | return capitalize(computeDaysDisplay(schedule.weekdays, hass)); 77 | case 'entity': 78 | const entities = computeEntities(schedule, config, hass); 79 | const entityDomains = unique(entities.map(e => computeDomain(e.id))); 80 | 81 | return entities.length == 1 82 | ? capitalize(PrettyPrintName(entities[0].name || '')) 83 | : entityDomains.length == 1 84 | ? `${entities.length}x ${localize(`domains.${entityDomains[0]}`, getLocale(hass)) || entityDomains[0]}` 85 | : `${entities.length}x entities`; 86 | case 'action': 87 | const action = computeAction(schedule, config, hass); 88 | return capitalize(computeActionDisplay(action)); 89 | case 'tags': 90 | return (schedule.tags || []).map(e => `${e}`).join(''); 91 | default: 92 | const regex = /\{([^\}]+)\}/; 93 | let res; 94 | while ((res = regex.exec(displayItem))) { 95 | displayItem = displayItem.replace(res[0], String(computeItemDisplay(String(res[1]), schedule, config, hass))); 96 | } 97 | return displayItem; 98 | } 99 | }; 100 | 101 | export const computeAction = (schedule: Schedule, config: CardConfig, hass: HomeAssistant) => { 102 | const nextEntry = schedule.timeslots[schedule.next_entries[0]]; 103 | 104 | const match = computeActions(nextEntry.actions[0].entity_id || nextEntry.actions[0].service, hass, config) 105 | .filter(e => compareActions(e, nextEntry.actions[0], true)) 106 | .reduce((_acc: Action | undefined, e) => e, undefined); 107 | 108 | return match 109 | ? { 110 | ...match, 111 | service_data: { 112 | ...match.service_data, 113 | ...Object.entries(nextEntry.actions[0].service_data || {}) 114 | .filter(([k]) => Object.keys(match.variables || {}).includes(k)) 115 | .reduce((obj, [key, val]) => Object.assign(obj, { [key]: val }), {}), 116 | }, 117 | } 118 | : importAction(nextEntry.actions[0], hass); 119 | }; 120 | 121 | export const computeEntities = (schedule: Schedule, config: CardConfig, hass: HomeAssistant) => { 122 | const nextEntry = schedule.timeslots[schedule.next_entries[0]]; 123 | const entities = unique(nextEntry.actions.map(e => e.entity_id || e.service)).map(e => parseEntity(e, hass, config)); 124 | return entities; 125 | }; 126 | -------------------------------------------------------------------------------- /src/data/item_display/compute_time_display.ts: -------------------------------------------------------------------------------- 1 | import { HomeAssistant, TimeFormat } from 'custom-card-helpers'; 2 | import { getLocale } from '../../helpers'; 3 | import { localize } from '../../localize/localize'; 4 | import { ETimeEvent, Timeslot } from '../../types'; 5 | import { formatTime } from '../date-time/format_time'; 6 | import { stringToDate } from '../date-time/string_to_date'; 7 | import { parseRelativeTime, stringToTime } from '../date-time/time'; 8 | 9 | export const computeTimeDisplay = (entry: Timeslot, hass: HomeAssistant) => { 10 | const computeRelativeTimeString = (timeString: string) => { 11 | const res = parseRelativeTime(timeString); 12 | if (!res) return timeString; 13 | 14 | const eventString = 15 | res.event == ETimeEvent.Sunrise 16 | ? getLocale(hass).language == 'de' 17 | ? hass.localize('ui.panel.config.automation.editor.conditions.type.sun.sunrise') 18 | : hass.localize('ui.panel.config.automation.editor.conditions.type.sun.sunrise').toLowerCase() 19 | : getLocale(hass).language == 'de' 20 | ? hass.localize('ui.panel.config.automation.editor.conditions.type.sun.sunset') 21 | : hass.localize('ui.panel.config.automation.editor.conditions.type.sun.sunset').toLowerCase(); 22 | if (Math.abs(stringToTime(res.offset, hass)) < 5 * 60) 23 | return localize('ui.components.time.at_sun_event', getLocale(hass), '{sunEvent}', eventString); 24 | 25 | const signString = 26 | res.sign == '-' 27 | ? hass 28 | .localize('ui.panel.config.automation.editor.conditions.type.sun.before') 29 | .replace(/[^a-z]/gi, '') 30 | .toLowerCase() 31 | : hass 32 | .localize('ui.panel.config.automation.editor.conditions.type.sun.after') 33 | .replace(/[^a-z]/gi, '') 34 | .toLowerCase(); 35 | 36 | const timeStr = formatTime(stringToDate(res.offset), getLocale(hass), TimeFormat.twenty_four); 37 | 38 | return `${timeStr} ${signString} ${eventString}`; 39 | }; 40 | 41 | if (!entry.stop) { 42 | const timeString = entry.start; 43 | if (parseRelativeTime(timeString)) return computeRelativeTimeString(timeString); 44 | else { 45 | const time = stringToDate(timeString); 46 | return localize('ui.components.time.absolute', getLocale(hass), '{time}', formatTime(time, getLocale(hass))); 47 | } 48 | } else { 49 | const start = parseRelativeTime(entry.start) 50 | ? computeRelativeTimeString(entry.start) 51 | : formatTime(stringToDate(entry.start), getLocale(hass)); 52 | const end = parseRelativeTime(entry.stop) 53 | ? computeRelativeTimeString(entry.stop) 54 | : formatTime(stringToDate(entry.stop), getLocale(hass)); 55 | return localize('ui.components.time.interval', getLocale(hass), ['{startTime}', '{endTime}'], [start, end]); 56 | } 57 | }; 58 | -------------------------------------------------------------------------------- /src/data/match_pattern.ts: -------------------------------------------------------------------------------- 1 | import { computeDomain } from 'custom-card-helpers'; 2 | 3 | export function matchPattern(pattern: string, value: string) { 4 | let res = false; 5 | if (pattern.match(/^[a-z0-9_\.]+$/)) { 6 | res = !pattern.includes('.') && value.includes('.') ? pattern == computeDomain(value) : pattern == value; 7 | } else { 8 | try { 9 | if ((pattern.startsWith('/') && pattern.endsWith('/')) || pattern.indexOf('*') !== -1) { 10 | if (!pattern.startsWith('/')) { 11 | pattern = pattern.replace(/\./g, '.').replace(/\*/g, '.*'); 12 | pattern = `/^${pattern}$/`; 13 | } 14 | const regex = new RegExp(pattern.slice(1, -1)); 15 | res = regex.test(value); 16 | } 17 | } catch (e) {} 18 | } 19 | return res; 20 | } 21 | -------------------------------------------------------------------------------- /src/data/variables/compute_merged_variable.ts: -------------------------------------------------------------------------------- 1 | import { LevelVariable, ListVariable, TextVariable, EVariableType } from '../../types'; 2 | import { levelVariable } from './level_variable'; 3 | import { listVariable } from './list_variable'; 4 | import { textVariable } from './text_variable'; 5 | import { isDefined, unique } from '../../helpers'; 6 | import { computeVariables } from './compute_variables'; 7 | 8 | export function computeMergedVariable( 9 | ...variables: Partial[] 10 | ): LevelVariable | ListVariable | TextVariable | undefined { 11 | const types = unique(variables.map(e => e.type).filter(isDefined)); 12 | if (!types.length) { 13 | variables = Object.values(computeVariables(Object.assign({}, ...variables))!); 14 | return computeMergedVariable(...variables); 15 | } 16 | 17 | if (types.length > 1) return undefined; 18 | 19 | if (types[0] == EVariableType.Level) { 20 | return levelVariable(...(variables as LevelVariable[])); 21 | } else if (types[0] == EVariableType.List) { 22 | return listVariable(...(variables as ListVariable[])); 23 | } else return textVariable(...(variables as TextVariable[])); 24 | } 25 | -------------------------------------------------------------------------------- /src/data/variables/compute_variables.ts: -------------------------------------------------------------------------------- 1 | import { LevelVariable, ListVariable, TextVariable, Dictionary, VariableDictionary } from '../../types'; 2 | import { listVariable } from './list_variable'; 3 | import { levelVariable } from './level_variable'; 4 | import { textVariable } from './text_variable'; 5 | 6 | export function computeVariables( 7 | variables?: Dictionary> 8 | ): VariableDictionary | undefined { 9 | if (!Object.keys(variables || {}).length) return undefined; 10 | 11 | return Object.entries(variables!) 12 | .map(([field, variable]) => { 13 | if ('options' in variable) { 14 | return [field, listVariable(variable as ListVariable)]; 15 | } else if ('min' in variable || 'max' in variable) { 16 | return [field, levelVariable(variable as LevelVariable)]; 17 | } else { 18 | return [field, textVariable(variable as TextVariable)]; 19 | } 20 | }) 21 | .reduce((obj, [key, val]) => (val ? Object.assign(obj, { [key as string]: val }) : obj), {}); 22 | } 23 | -------------------------------------------------------------------------------- /src/data/variables/level_variable.ts: -------------------------------------------------------------------------------- 1 | import { isDefined, unique } from '../../helpers'; 2 | import { LevelVariable, EVariableType } from '../../types'; 3 | 4 | export function levelVariable(...config: Partial[]) { 5 | //factory function to create LevelVariable from configuration 6 | 7 | const min = config.map(e => e.min).filter(isDefined); 8 | const max = config.map(e => e.max).filter(isDefined); 9 | const step = config.map(e => e.step).filter(isDefined); 10 | const scale_factor = unique(config.map(e => e.scale_factor).filter(isDefined)); 11 | const optional = config.map(e => e.optional).filter(isDefined); 12 | const unit = config.map(e => e.unit).filter(isDefined); 13 | const name = config.map(e => e.name).filter(isDefined); 14 | 15 | const stepSize = step.length ? Math.max(...step) : 1; 16 | const round = (val: number) => { 17 | val = Math.round(val / stepSize) * stepSize; 18 | return parseFloat(val.toPrecision(12)); 19 | }; 20 | 21 | const variable: LevelVariable = { 22 | type: EVariableType.Level, 23 | min: round(min.length ? Math.min(...min) : 0), 24 | max: round(max.length ? Math.max(...max) : 255), 25 | step: stepSize, 26 | scale_factor: scale_factor.length == 1 ? scale_factor[0] : 1, 27 | optional: (optional.length && optional.every(e => e)) || false, 28 | unit: unit.length ? unit.reduce((_acc, val) => val) : '', 29 | name: name.length ? name.reduce((_acc, val) => val) : undefined, 30 | }; 31 | return variable; 32 | } 33 | 34 | export function levelVariableDisplay(value: any, variable: LevelVariable): string { 35 | let val = Number(value); 36 | if (isNaN(val)) return ''; 37 | 38 | if (variable.scale_factor != 1) { 39 | val = val / variable.scale_factor; 40 | val = Math.round(val / variable.step) * variable.step; 41 | val = parseFloat(val.toPrecision(12)); 42 | if (val > variable.max) val = variable.max; 43 | else if (val < variable.min) val = variable.min; 44 | } 45 | return `${val}${variable.unit}`; 46 | } 47 | -------------------------------------------------------------------------------- /src/data/variables/list_variable.ts: -------------------------------------------------------------------------------- 1 | import { isDefined, flatten } from '../../helpers'; 2 | import { ListVariableOption, ListVariable, EVariableType, AtLeast } from '../../types'; 3 | 4 | export function listVariable(...config: AtLeast[]) { 5 | //factory function to create ListVariable from configuration 6 | 7 | const commonOptions = config[0].options 8 | .map(e => e.value) 9 | .filter(option => config.map(e => e.options).every(list => list.map(e => e.value).includes(option))); 10 | 11 | const options: ListVariableOption[] = commonOptions.map(val => { 12 | const name = config 13 | .map(e => e.options.find(o => o.value == val)) 14 | .filter(isDefined) 15 | .map(e => e.name) 16 | .filter(isDefined); 17 | const icon = config 18 | .map(e => e.options.find(o => o.value == val)) 19 | .filter(isDefined) 20 | .map(e => e.icon) 21 | .filter(isDefined); 22 | 23 | let item: ListVariableOption = { 24 | value: val, 25 | name: name.length ? name.reduce((_acc, val) => val) : undefined, 26 | icon: icon.length ? icon.reduce((_acc, val) => val) : undefined, 27 | }; 28 | return item; 29 | }); 30 | 31 | const name = config.map(e => e.name).filter(isDefined); 32 | 33 | const variable: ListVariable = { 34 | type: EVariableType.List, 35 | name: name.length ? name.reduce((_acc, val) => val) : undefined, 36 | options: options, 37 | }; 38 | return variable; 39 | } 40 | 41 | export function listVariableDisplay(value: any, variable: ListVariable): string { 42 | const option = variable.options.find(e => e.value == value); 43 | return option ? option.name || option.value : ''; 44 | } 45 | -------------------------------------------------------------------------------- /src/data/variables/text_variable.ts: -------------------------------------------------------------------------------- 1 | import { isDefined } from '../../helpers'; 2 | import { EVariableType, TextVariable } from '../../types'; 3 | 4 | export function textVariable(...config: Partial[]) { 5 | //factory function to create ListVariable from configuration 6 | 7 | const name = config.map(e => e.name).filter(isDefined); 8 | 9 | const variable: TextVariable = { 10 | type: EVariableType.Text, 11 | name: name.length ? name.reduce((_acc, val) => val) : undefined, 12 | multiline: config.some(e => e.multiline), 13 | }; 14 | return variable; 15 | } 16 | 17 | export function textVariableDisplay(value: any, _variable: TextVariable): string { 18 | return String(value); 19 | } 20 | -------------------------------------------------------------------------------- /src/data/websockets.ts: -------------------------------------------------------------------------------- 1 | import { HomeAssistant, fireEvent } from 'custom-card-helpers'; 2 | import { Schedule, ScheduleConfig, TagEntry } from '../types'; 3 | import { html, TemplateResult } from 'lit'; 4 | import { DialogParams } from '../components/generic-dialog'; 5 | import { ProvideHassElement, showDialog } from './custom_dialog'; 6 | 7 | export const fetchSchedules = (hass: HomeAssistant): Promise => 8 | hass.callWS({ 9 | type: 'scheduler', 10 | }); 11 | 12 | export const fetchScheduleItem = (hass: HomeAssistant, schedule_id: string): Promise => 13 | hass.callWS({ 14 | type: 'scheduler/item', 15 | schedule_id: schedule_id, 16 | }); 17 | 18 | export const saveSchedule = (hass: HomeAssistant, config: ScheduleConfig): Promise => { 19 | return hass.callApi('POST', 'scheduler/add', config); 20 | }; 21 | 22 | export const editSchedule = ( 23 | hass: HomeAssistant, 24 | config: ScheduleConfig & { schedule_id: string } 25 | ): Promise => { 26 | return hass.callApi('POST', 'scheduler/edit', config); 27 | }; 28 | 29 | export const deleteSchedule = (hass: HomeAssistant, schedule_id: string): Promise => { 30 | return hass.callApi('POST', 'scheduler/remove', { schedule_id: schedule_id }); 31 | }; 32 | 33 | export const fetchTags = (hass: HomeAssistant): Promise => 34 | hass.callWS({ 35 | type: 'scheduler/tags', 36 | }); 37 | 38 | export function showErrorDialog( 39 | target: HTMLElement & ProvideHassElement, 40 | error: string | TemplateResult, 41 | hass: HomeAssistant, 42 | useAlternativeDialog?: boolean 43 | ) { 44 | const params: DialogParams = { 45 | title: hass.localize('state_badge.default.error'), 46 | description: error, 47 | primaryButtonLabel: hass.localize('ui.common.ok'), 48 | confirm: () => { }, 49 | cancel: () => { }, 50 | }; 51 | showDialog( 52 | target, 53 | { 54 | dialogTag: 'generic-dialog', 55 | dialogImport: () => import('../components/generic-dialog'), 56 | dialogParams: params, 57 | }, 58 | useAlternativeDialog 59 | ); 60 | } 61 | 62 | export function handleError( 63 | err: { body: { message: string }; error: string }, 64 | el: HTMLElement & ProvideHassElement, 65 | hass: HomeAssistant, 66 | useAlternativeDialog?: boolean 67 | ) { 68 | const errorMessage = html` 69 | Something went wrong!
70 | ${err.body.message}

71 | ${err.error}

72 | Please report the bug. 73 | `; 74 | showErrorDialog(el, errorMessage, hass, useAlternativeDialog); 75 | } 76 | -------------------------------------------------------------------------------- /src/load-ha-form.js: -------------------------------------------------------------------------------- 1 | 2 | export const loadHaForm = async () => { 3 | if (customElements.get("ha-checkbox") && customElements.get("ha-slider") && customElements.get("ha-combo-box")) return; 4 | 5 | await customElements.whenDefined("partial-panel-resolver"); 6 | const ppr = document.createElement('partial-panel-resolver'); 7 | ppr.hass = { 8 | panels: [{ 9 | url_path: "tmp", 10 | component_name: "config", 11 | }] 12 | }; 13 | ppr._updateRoutes(); 14 | await ppr.routerOptions.routes.tmp.load(); 15 | 16 | await customElements.whenDefined("ha-panel-config"); 17 | const cpr = document.createElement("ha-panel-config"); 18 | await cpr.routerOptions.routes.automation.load(); 19 | } -------------------------------------------------------------------------------- /src/localize/languages/cs.json: -------------------------------------------------------------------------------- 1 | { 2 | "services": { 3 | "generic": { 4 | "parameter_to_value": "{parameter} na {value}", 5 | "action_with_parameter": "{action} s {parameter}" 6 | }, 7 | "climate": { 8 | "set_temperature": "nastavit teplotu[ na {temperature}]", 9 | "set_temperature_hvac_mode_heat": "topení[ na {temperature}]", 10 | "set_temperature_hvac_mode_cool": "chlazení[ na {temperature}]", 11 | "set_temperature_hvac_mode_heat_cool": "topení/chlazení[ na {temperature}]", 12 | "set_temperature_hvac_mode_heat_cool_range": "topení/chlazení[ na {target_temp_low} - {target_temp_high}]", 13 | "set_temperature_hvac_mode_auto": "automatika[ na {temperature}]", 14 | "set_hvac_mode": "nastavit režim[ na {hvac_mode}]", 15 | "set_preset_mode": "nastavit předvolbu[ {preset_mode}]", 16 | "set_fan_mode": "set fan mode[ to {fan_mode}]" 17 | }, 18 | "cover": { 19 | "close_cover": "zavřít", 20 | "open_cover": "otevřít", 21 | "set_cover_position": "nastavit polohu[ na {position}]", 22 | "set_cover_tilt_position": "set tilt position[ to {tilt_position}]" 23 | }, 24 | "fan": { 25 | "set_speed": "nastavit rychlost[ na {speed}]", 26 | "set_direction": "nastavit směr[ na {direction}]", 27 | "oscillate": "nastavit oscilaci[ na {oscillate}]" 28 | }, 29 | "humidifier": { 30 | "set_humidity": "nastavit vlhkost[ na {humidity}]", 31 | "set_mode": "nastavit režim[ na {mode}]" 32 | }, 33 | "input_number": { 34 | "set_value": "nastavit hodnotu[ na {value}]" 35 | }, 36 | "input_select": { 37 | "select_option": "vybrat možnost[ {option}]" 38 | }, 39 | "select": { 40 | "select_option": "vybrat možnost[ {option}]" 41 | }, 42 | "light": { 43 | "turn_on": "zapnout[ na {brightness} jas]" 44 | }, 45 | "media_player": { 46 | "select_source": "vybrat zdroj[ {source}]" 47 | }, 48 | "notify": { 49 | "notify": "send notification" 50 | }, 51 | "script": { 52 | "script": "spustit" 53 | }, 54 | "vacuum": { 55 | "start_pause": "start / pauza" 56 | }, 57 | "water_heater": { 58 | "set_operation_mode": "nastavit režim[ na {operation_mode}]", 59 | "set_away_mode": "vypnout režim" 60 | } 61 | }, 62 | "domains": { 63 | "alarm_control_panel": "poplašný ovládací panel", 64 | "binary_sensor": "binary sensors", 65 | "climate": "klima", 66 | "cover": "rolety", 67 | "fan": "ventilátory", 68 | "group": "skupiny", 69 | "humidifier": "zvlhčovače", 70 | "input_boolean": "input boolean", 71 | "input_number": "input number", 72 | "input_select": "input select", 73 | "lawn_mower": "lawn mower", 74 | "light": "světla", 75 | "lock": "zámky", 76 | "media_player": "média přehrávače", 77 | "notify": "notification", 78 | "switch": "spínače", 79 | "vacuum": "vysavače", 80 | "water_heater": "ohřívače vody" 81 | }, 82 | "ui": { 83 | "components": { 84 | "date": { 85 | "day_types_short": { 86 | "daily": "denně", 87 | "workdays": "pracovní dny", 88 | "weekend": "víkendy" 89 | }, 90 | "day_types_long": { 91 | "daily": "každý den", 92 | "workdays": "v pracovní dny", 93 | "weekend": "o víkendu" 94 | }, 95 | "days": "dnů", 96 | "tomorrow": "zítra", 97 | "repeated_days": "každý {days}", 98 | "repeated_days_except": "každý den kromě {excludedDays}", 99 | "days_range": "od {startDay} do {endDay}", 100 | "next_week_day": "příští {weekday}" 101 | }, 102 | "time": { 103 | "absolute": "od {time}", 104 | "interval": "od {startTime} do {endTime}", 105 | "at_midnight": "od půlnoc", 106 | "at_noon": "od poledne", 107 | "at_sun_event": "na {sunEvent}" 108 | } 109 | }, 110 | "dialog": { 111 | "enable_schedule": { 112 | "title": "Dokončete úpravy", 113 | "description": "Plán, který byl změněn, je aktuálně zakázán, měl by být povolen?" 114 | }, 115 | "confirm_delete": { 116 | "title": "Odebrat entitu?", 117 | "description": "Opravdu chcete tuto entitu odebrat?" 118 | }, 119 | "confirm_migrate": { 120 | "title": "Aktualizovat plán", 121 | "description": "Některá nastavení budou touto změnou ztracena. Chceš pokračovat?" 122 | } 123 | }, 124 | "panel": { 125 | "common": { 126 | "title": "Plánovač", 127 | "new_schedule": "Nový plán", 128 | "default_name": "Plán #{id}" 129 | }, 130 | "overview": { 131 | "no_entries": "Nejsou žádné položky k zobrazení", 132 | "backend_error": "Could not connect with the scheduler component. It needs to be installed as integration before this card can be used.", 133 | "excluded_items": "{number} vyloučeno {if number is 1} položka {else} položek", 134 | "hide_excluded": "skrýt vyloučené položky", 135 | "additional_tasks": "{number} a více {if number is 1} úkol {else} úkolů" 136 | }, 137 | "entity_picker": { 138 | "no_groups_defined": "Nejsou definovány žádné skupiny", 139 | "no_group_selected": "Nejprve vyberte skupinu", 140 | "no_entities_for_group": "V této skupině nejsou žádné entity", 141 | "no_entity_selected": "Nejprve vyberte entitu", 142 | "no_actions_for_entity": "Pro tuto entitu neexistují žádné akce", 143 | "make_scheme": "vytvořit schéma", 144 | "multiple": "Multiple" 145 | }, 146 | "time_picker": { 147 | "no_timeslot_selected": "Nejprve vyberte časový úsek", 148 | "time_scheme": "Schéma", 149 | "time_input_mode": "Time control mode" 150 | }, 151 | "conditions": { 152 | "equal_to": "je", 153 | "unequal_to": "není", 154 | "all": "Vše", 155 | "any": "žádný", 156 | "no_conditions_defined": "Nejsou definovány žádné podmínky", 157 | "track_conditions": "Re-evaluate when conditions change" 158 | }, 159 | "options": { 160 | "repeat_type": "chování po spuštění", 161 | "period": "období" 162 | } 163 | } 164 | } 165 | } -------------------------------------------------------------------------------- /src/localize/languages/et.json: -------------------------------------------------------------------------------- 1 | { 2 | "services": { 3 | "generic": { 4 | "parameter_to_value": "{parameter} {value} jaoks", 5 | "action_with_parameter": "{action} väärtusega {parameter}" 6 | }, 7 | "climate": { 8 | "set_temperature": "vali temperatuur [{temperature}]", 9 | "set_temperature_hvac_mode_heat": "küte[ @ {temperature}]", 10 | "set_temperature_hvac_mode_cool": "jahutus [ @ {temperature}]", 11 | "set_temperature_hvac_mode_heat_cool": "küte/jahutus[ @ {temperature}]", 12 | "set_temperature_hvac_mode_heat_cool_range": "küte/jahutus[ @ {target_temp_low} - {target_temp_high}]", 13 | "set_temperature_hvac_mode_auto": "automaatne[ @ {temperature}]", 14 | "set_hvac_mode": "vali režiim [{hvac_mode}]", 15 | "set_preset_mode": "eelseade[ {preset_mode}]", 16 | "set_fan_mode": "set fan mode[ to {fan_mode}]" 17 | }, 18 | "cover": { 19 | "close_cover": "sulge", 20 | "open_cover": "ava", 21 | "set_cover_position": "sea asendisse[{position}]", 22 | "set_cover_tilt_position": "sea ribide kalle [ asendisse {tilt_position}]" 23 | }, 24 | "fan": { 25 | "set_speed": "vali kiirus[ @ {speed}]", 26 | "set_direction": "vali suund[ @ {direction}]", 27 | "oscillate": "vali hajutus[ @ {oscillate}]" 28 | }, 29 | "humidifier": { 30 | "set_humidity": "sea niiskus[ {humidity}]", 31 | "set_mode": "vali režiim [{mode}]" 32 | }, 33 | "input_number": { 34 | "set_value": "vali väärtus[ {value}]" 35 | }, 36 | "input_select": { 37 | "select_option": "valik[ {option}]" 38 | }, 39 | "select": { 40 | "select_option": "valik[ {option}]" 41 | }, 42 | "light": { 43 | "turn_on": "lülita sisse[ heledusega {brightness}]" 44 | }, 45 | "media_player": { 46 | "select_source": "vali sisend[ {source}]" 47 | }, 48 | "notify": { 49 | "notify": "send notification" 50 | }, 51 | "script": { 52 | "script": "käivita" 53 | }, 54 | "vacuum": { 55 | "start_pause": "alusta/ootele" 56 | }, 57 | "water_heater": { 58 | "set_operation_mode": "vali režiim [{operation_mode}]", 59 | "set_away_mode": "kodust ära" 60 | } 61 | }, 62 | "domains": { 63 | "alarm_control_panel": "valvepaneel", 64 | "binary_sensor": "binary sensors", 65 | "climate": "kliimaseade", 66 | "cover": "aknakatted", 67 | "fan": "ventilaatorid", 68 | "group": "grupid", 69 | "humidifier": "niisutajad", 70 | "input_boolean": "tõeväärtus", 71 | "input_number": "numbriline valik", 72 | "input_select": "valikmenüü", 73 | "lawn_mower": "lawn mower", 74 | "light": "valgustid", 75 | "lock": "lukud", 76 | "media_player": "meediamängijad", 77 | "notify": "notification", 78 | "switch": "lülitid", 79 | "vacuum": "tolmuimejad", 80 | "water_heater": "veeboilerid" 81 | }, 82 | "ui": { 83 | "components": { 84 | "date": { 85 | "day_types_short": { 86 | "daily": "iga päev", 87 | "workdays": "tööpäevadel", 88 | "weekend": "nädalavahetusel" 89 | }, 90 | "day_types_long": { 91 | "daily": "iga päev", 92 | "workdays": "tööpäevadel", 93 | "weekend": "nädalavahetusel" 94 | }, 95 | "days": "päeva", 96 | "tomorrow": "homme", 97 | "repeated_days": "iga {days} järel", 98 | "repeated_days_except": "iga päev aga mitte {excludedDays}", 99 | "days_range": "{startDay} kuni {endDay}", 100 | "next_week_day": "järgmisel {weekday}" 101 | }, 102 | "time": { 103 | "absolute": "{time}", 104 | "interval": "{startTime} kuni {endTime}", 105 | "at_midnight": "keskööl", 106 | "at_noon": "keskpäeval", 107 | "at_sun_event": "{sunEvent}" 108 | } 109 | }, 110 | "dialog": { 111 | "enable_schedule": { 112 | "title": "Viige muudatused lõpule", 113 | "description": "Muudetud ajakava on praegu keelatud, kas see peaks olema lubatud?" 114 | }, 115 | "confirm_delete": { 116 | "title": "Kas eemaldan olemi?", 117 | "description": "Oled kindel, et soovid selle olemi eemaldada?" 118 | }, 119 | "confirm_migrate": { 120 | "title": "Muutke ajakava", 121 | "description": "Selle muudatusega lähevad mõned seaded kaotsi. Kas soovite jätkata?" 122 | } 123 | }, 124 | "panel": { 125 | "common": { 126 | "title": "Ajastaja", 127 | "new_schedule": "Uus ajakava", 128 | "default_name": "Ajakava #{id}" 129 | }, 130 | "overview": { 131 | "no_entries": "Ajastused puuduvad", 132 | "backend_error": "Ajastaja sidumine puudub. Sidumine tuleb luua enne selle kaardi kasutamist.", 133 | "excluded_items": "välja on jäetud {number} {if number is 1} ajastus {else} ajastust", 134 | "hide_excluded": "peida välja jäetud ajastused", 135 | "additional_tasks": "veel {number} {if number is 1} ajastus {else} ajastust" 136 | }, 137 | "entity_picker": { 138 | "no_groups_defined": "Gruppe pole valitud", 139 | "no_group_selected": "Vali alustuseks grupid", 140 | "no_entities_for_group": "Selles grupis puuduvad olemid", 141 | "no_entity_selected": "Vali alustuseks olem", 142 | "no_actions_for_entity": "Selle olemi jaoks pole tegevusi", 143 | "make_scheme": "loo skeem", 144 | "multiple": "Mitu" 145 | }, 146 | "time_picker": { 147 | "no_timeslot_selected": "Alustuseks vali ajavahemik", 148 | "time_scheme": "Kkeem", 149 | "time_input_mode": "Time control mode" 150 | }, 151 | "conditions": { 152 | "equal_to": "võrdub", 153 | "unequal_to": "ei võrdu", 154 | "all": "kõik", 155 | "any": "iga", 156 | "no_conditions_defined": "Tingimusi pole määratud", 157 | "track_conditions": "Re-evaluate when conditions change" 158 | }, 159 | "options": { 160 | "repeat_type": "toiming peale käivitumist", 161 | "period": "periood" 162 | } 163 | } 164 | } 165 | } -------------------------------------------------------------------------------- /src/localize/languages/he.json: -------------------------------------------------------------------------------- 1 | { 2 | "services": { 3 | "generic": { 4 | "parameter_to_value": "{parameter} ל {value}", 5 | "action_with_parameter": "{action} עם {parameter}" 6 | }, 7 | "climate": { 8 | "set_temperature": "קבע טמפרטורה[ ל {temperature}]", 9 | "set_temperature_hvac_mode_heat": "חימום[ ל {temperature}]", 10 | "set_temperature_hvac_mode_cool": "קירור[ ל {temperature}]", 11 | "set_temperature_hvac_mode_heat_cool": "חימום/קירור[ ל {temperature}]", 12 | "set_temperature_hvac_mode_heat_cool_range": "חימום/קירור[ ל {target_temp_low} - {target_temp_high}]", 13 | "set_temperature_hvac_mode_auto": "אוטומטי[ ל {temperature}]", 14 | "set_hvac_mode": "קבע מצב עבודה[ ל {hvac_mode}]", 15 | "set_preset_mode": "קבע הגדרה[ ל {preset_mode}]", 16 | "set_fan_mode": "set fan mode[ to {fan_mode}]" 17 | }, 18 | "cover": { 19 | "close_cover": "סגירה", 20 | "open_cover": "פתיחה", 21 | "set_cover_position": "קבע מיקום[ ל {position}]", 22 | "set_cover_tilt_position": "קבע הטיה[ ל {tilt_position}]" 23 | }, 24 | "fan": { 25 | "set_speed": "קבע מהירות[ ל {speed}]", 26 | "set_direction": "קבע כיוון[ ל {direction}]", 27 | "oscillate": "קבע תנודה[ ל {oscillate}]" 28 | }, 29 | "humidifier": { 30 | "set_humidity": "קבע לחות[ ל {humidity}]", 31 | "set_mode": "קבע מצב עבודה[ ל {mode}]" 32 | }, 33 | "input_number": { 34 | "set_value": "קבע ערך[ ל {value}]" 35 | }, 36 | "input_select": { 37 | "select_option": "בחר אפשרות[ {option}]" 38 | }, 39 | "select": { 40 | "select_option": "בחר אפשרות[ {option}]" 41 | }, 42 | "light": { 43 | "turn_on": "הדלקה[ בעוצמה של {brightness}]" 44 | }, 45 | "media_player": { 46 | "select_source": "select source[ {source}]" 47 | }, 48 | "notify": { 49 | "notify": "send notification" 50 | }, 51 | "script": { 52 | "script": "בצע" 53 | }, 54 | "vacuum": { 55 | "start_pause": "התחל / הפסק" 56 | }, 57 | "water_heater": { 58 | "set_operation_mode": "קבע מצב עבודה[ ל {operation_mode}]", 59 | "set_away_mode": "קבע מצב מוץ לבית" 60 | } 61 | }, 62 | "domains": { 63 | "alarm_control_panel": "בקרת אזעקה", 64 | "binary_sensor": "binary sensors", 65 | "climate": "מזג אוויר", 66 | "cover": "תריסים", 67 | "fan": "מאווררים", 68 | "group": "קבוצות יישויות", 69 | "humidifier": "מכשירי אדים", 70 | "input_boolean": "כניסה בוליאנית", 71 | "input_number": "כניסה מספרית", 72 | "input_select": "בחירת כניסה", 73 | "lawn_mower": "lawn mower", 74 | "light": "תאורה", 75 | "lock": "מנעולים", 76 | "media_player": "נגני מדיה", 77 | "notify": "notification", 78 | "switch": "מפסקים", 79 | "vacuum": "שואבי אבק", 80 | "water_heater": "מחממי מים" 81 | }, 82 | "ui": { 83 | "components": { 84 | "date": { 85 | "day_types_short": { 86 | "daily": "כל יום", 87 | "workdays": "ימי חול", 88 | "weekend": "סוף שבוע" 89 | }, 90 | "day_types_long": { 91 | "daily": "כל יום", 92 | "workdays": "בימי חול", 93 | "weekend": "בסוף השבוע" 94 | }, 95 | "days": "ימים", 96 | "tomorrow": "מחר", 97 | "repeated_days": "בכל {days}", 98 | "repeated_days_except": "בכל יום פרט ל {excludedDays}", 99 | "days_range": "מ- {startDay} ועד- {endDay}", 100 | "next_week_day": "הבא {weekday}" 101 | }, 102 | "time": { 103 | "absolute": "בשעה {time}", 104 | "interval": "משעה {startTime} עד שעה {endTime}", 105 | "at_midnight": "בחצות הלילה", 106 | "at_noon": "בחצות היום", 107 | "at_sun_event": "ב {sunEvent}" 108 | } 109 | }, 110 | "dialog": { 111 | "enable_schedule": { 112 | "title": "השלם את השינויים", 113 | "description": "לוח הזמנים ששונה מושבת כעת, האם צריך להפעיל אותו?" 114 | }, 115 | "confirm_delete": { 116 | "title": "להסיר את הישות?", 117 | "description": "האם בוודאות ברצונך להסיר ישות זו?" 118 | }, 119 | "confirm_migrate": { 120 | "title": "שנה את לוח הזמנים", 121 | "description": "חלק מההגדרות יאבדו על ידי פעולה זו. האם אתה רוצה להמשיך?" 122 | } 123 | }, 124 | "panel": { 125 | "common": { 126 | "title": "לוח זמנים", 127 | "new_schedule": "לוח זמנים חדש", 128 | "default_name": "לוח זמנים #{id}" 129 | }, 130 | "overview": { 131 | "no_entries": "אין פריטים להצגה", 132 | "backend_error": "אין אפשרות להתחבר לרכיב התזמונים. נדרש להתקין את הרכיב באינטגרציה לפני השימוש בכרטיס.", 133 | "excluded_items": "{number} לא נכלל {if number is 1} פריט {else} פריטים", 134 | "hide_excluded": "הסתר פריטים לא כלולים", 135 | "additional_tasks": "{number} נוסף {if number is 1} משימה {else} משימות" 136 | }, 137 | "entity_picker": { 138 | "no_groups_defined": "לא הוגדרו קבוצות", 139 | "no_group_selected": "בחר קבוצה תחילה", 140 | "no_entities_for_group": "אין יישויות בקבוצה זו", 141 | "no_entity_selected": "תחילה בחר יישות", 142 | "no_actions_for_entity": "אין פעולות עבור יישות זאת", 143 | "make_scheme": "בנה סכימה", 144 | "multiple": "מספר יישויות" 145 | }, 146 | "time_picker": { 147 | "no_timeslot_selected": "בחר משבצת זמן קודם", 148 | "time_scheme": "סכימה", 149 | "time_input_mode": "Time control mode" 150 | }, 151 | "conditions": { 152 | "equal_to": "שווה ל", 153 | "unequal_to": "שונה מ", 154 | "all": "כל התנאים", 155 | "any": "אחד מהתנאים", 156 | "no_conditions_defined": "לא הוגדרו תנאים", 157 | "track_conditions": "Re-evaluate when conditions change" 158 | }, 159 | "options": { 160 | "repeat_type": "התנהגות לאחר הפעלה", 161 | "period": "פרק זמן" 162 | } 163 | } 164 | } 165 | } -------------------------------------------------------------------------------- /src/localize/languages/hu.json: -------------------------------------------------------------------------------- 1 | { 2 | "services": { 3 | "generic": { 4 | "parameter_to_value": "{parameter} to {value}", 5 | "action_with_parameter": "{action} with {parameter}" 6 | }, 7 | "climate": { 8 | "set_temperature": "hőmérséklet[ to {temperature}]", 9 | "set_temperature_hvac_mode_heat": "melegíteni[ to {temperature}]", 10 | "set_temperature_hvac_mode_cool": "hűtés[ to {temperature}]", 11 | "set_temperature_hvac_mode_heat_cool": "melegíteni/hűtés[ to {temperature}]", 12 | "set_temperature_hvac_mode_heat_cool_range": "melegíteni/hűtés[ to {target_temp_low} - {target_temp_high}]", 13 | "set_temperature_hvac_mode_auto": "automatikus[ to {temperature}]", 14 | "set_hvac_mode": "mód beállítása[ to {hvac_mode}]", 15 | "set_preset_mode": "preset beállítása[ {preset_mode}]", 16 | "set_fan_mode": "set fan mode[ to {fan_mode}]" 17 | }, 18 | "cover": { 19 | "close_cover": "zárás", 20 | "open_cover": "nyitás", 21 | "set_cover_position": "változtass pozíciót[ to {position}]", 22 | "set_cover_tilt_position": "set tilt position[ to {tilt_position}]" 23 | }, 24 | "fan": { 25 | "set_speed": "set speed[ to {speed}]", 26 | "set_direction": "set direction[ to {direction}]", 27 | "oscillate": "set oscillation[ to {oscillate}]" 28 | }, 29 | "humidifier": { 30 | "set_humidity": "set humidity[ to {humidity}]", 31 | "set_mode": "mód beállítása[ to {mode}]" 32 | }, 33 | "input_number": { 34 | "set_value": "érték beállítása[ to {value}]" 35 | }, 36 | "input_select": { 37 | "select_option": "opció kiválasztása[ {option}]" 38 | }, 39 | "select": { 40 | "select_option": "opció kiválasztása[ {option}]" 41 | }, 42 | "light": { 43 | "turn_on": "bekapcsolás[ with {brightness} brightness]" 44 | }, 45 | "media_player": { 46 | "select_source": "forrás kiválasztása[ {source}]" 47 | }, 48 | "notify": { 49 | "notify": "send notification" 50 | }, 51 | "script": { 52 | "script": "kezdés" 53 | }, 54 | "vacuum": { 55 | "start_pause": "start / pause" 56 | }, 57 | "water_heater": { 58 | "set_operation_mode": "mód beállítása[ to {operation_mode}]", 59 | "set_away_mode": "set away mode" 60 | } 61 | }, 62 | "domains": { 63 | "alarm_control_panel": "alarm control panel", 64 | "binary_sensor": "binary sensors", 65 | "climate": "termosztát", 66 | "cover": "redőny", 67 | "fan": "ventilátor", 68 | "group": "csoportok", 69 | "humidifier": "humifiers", 70 | "input_boolean": "logikai bemenet", 71 | "input_number": "szám bemenet", 72 | "input_select": "legördülő bemenet", 73 | "lawn_mower": "lawn mower", 74 | "light": "lámpa", 75 | "lock": "locks", 76 | "media_player": "lejátszó", 77 | "notify": "notification", 78 | "switch": "kapcsoló", 79 | "vacuum": "pórszívó", 80 | "water_heater": "water heaters" 81 | }, 82 | "ui": { 83 | "components": { 84 | "date": { 85 | "day_types_short": { 86 | "daily": "minden nap", 87 | "workdays": "munkanapokon", 88 | "weekend": "hétvégén" 89 | }, 90 | "day_types_long": { 91 | "daily": "minden nap", 92 | "workdays": "munkanapokon", 93 | "weekend": "hétvégén" 94 | }, 95 | "days": "Napokon", 96 | "tomorrow": "tomorrow", 97 | "repeated_days": "every {days}", 98 | "repeated_days_except": "every day except {excludedDays}", 99 | "days_range": "from {startDay} to {endDay}", 100 | "next_week_day": "következő {weekday}" 101 | }, 102 | "time": { 103 | "absolute": "{time}-kor", 104 | "interval": "{startTime} - {endTime}", 105 | "at_midnight": "éjfélkor", 106 | "at_noon": "délben", 107 | "at_sun_event": "{sunEvent}kor" 108 | } 109 | }, 110 | "dialog": { 111 | "enable_schedule": { 112 | "title": "Végezze el a módosításokat", 113 | "description": "A módosított ütemezés jelenleg le van tiltva, engedélyezni kell?" 114 | }, 115 | "confirm_delete": { 116 | "title": "Biztos benne, hogy eltávolítja az entitást?", 117 | "description": "Biztos benne, hogy el szeretné távolítani ezt az entitást?" 118 | }, 119 | "confirm_migrate": { 120 | "title": "Ütemezés módosítása", 121 | "description": "Ezzel a művelettel bizonyos beállítások elvesznek. Akarod folytatni?" 122 | } 123 | }, 124 | "panel": { 125 | "common": { 126 | "title": "Időzítések", 127 | "new_schedule": "Új ütemezés", 128 | "default_name": "Ütemterv #{id}" 129 | }, 130 | "overview": { 131 | "no_entries": "Nincs megjeleníthető elem", 132 | "backend_error": "Could not connect with the scheduler component. It needs to be installed as integration before this card can be used.", 133 | "excluded_items": "{number} excluded {if number is 1} item {else} items", 134 | "hide_excluded": "hide excluded items", 135 | "additional_tasks": "még {number} feladat" 136 | }, 137 | "entity_picker": { 138 | "no_groups_defined": "Nincsenek deffiniált csoportok", 139 | "no_group_selected": "Előbb egy csoportot szükséges választani", 140 | "no_entities_for_group": "Ebben a csoportban nem találhatók entitások", 141 | "no_entity_selected": "Előbb egy entitást szükséges választani", 142 | "no_actions_for_entity": "Ehhez az entitáshoz nem tartoznak műveletek", 143 | "make_scheme": "make scheme", 144 | "multiple": "Multiple" 145 | }, 146 | "time_picker": { 147 | "no_timeslot_selected": "Select a timeslot first", 148 | "time_scheme": "Scheme", 149 | "time_input_mode": "Time control mode" 150 | }, 151 | "conditions": { 152 | "equal_to": "is", 153 | "unequal_to": "is not", 154 | "all": "all", 155 | "any": "any", 156 | "no_conditions_defined": "There are no conditions defined", 157 | "track_conditions": "Re-evaluate when conditions change" 158 | }, 159 | "options": { 160 | "repeat_type": "behaviour after triggering", 161 | "period": "időszak" 162 | } 163 | } 164 | } 165 | } -------------------------------------------------------------------------------- /src/localize/languages/no.json: -------------------------------------------------------------------------------- 1 | { 2 | "services": { 3 | "generic": { 4 | "parameter_to_value": "{parameter} til {value}", 5 | "action_with_parameter": "{action} med {parameter}" 6 | }, 7 | "climate": { 8 | "set_temperature": "sett temperatur[ til {temperature}]", 9 | "set_temperature_hvac_mode_heat": "oppvarming[ til {temperature}]", 10 | "set_temperature_hvac_mode_cool": "kjøling[ til {temperature}]", 11 | "set_temperature_hvac_mode_heat_cool": "oppvarming/kjøling[ til {temperature}]", 12 | "set_temperature_hvac_mode_heat_cool_range": "oppvarming/kjøling[ til {target_temp_low} - {target_temp_high}]", 13 | "set_temperature_hvac_mode_auto": "auto[ til {temperature}]", 14 | "set_hvac_mode": "sett modus[ til {hvac_mode}]", 15 | "set_preset_mode": "sett forhåndsvalg[ {preset_mode}]", 16 | "set_fan_mode": "set fan mode[ to {fan_mode}]" 17 | }, 18 | "cover": { 19 | "close_cover": "lukk", 20 | "open_cover": "åpne", 21 | "set_cover_position": "sett posisjon[ til {position}]", 22 | "set_cover_tilt_position": "sett vippestilling[ til {tilt_position}]" 23 | }, 24 | "fan": { 25 | "set_speed": "sett hastighet[ til {speed}]", 26 | "set_direction": "sett retning[ til {direction}]", 27 | "oscillate": "sett svingning[ til {oscillate}]" 28 | }, 29 | "humidifier": { 30 | "set_humidity": "sett luftfuktighet[ til {humidity}]", 31 | "set_mode": "sett modus[ til {mode}]" 32 | }, 33 | "input_number": { 34 | "set_value": "sett verdi[ til {value}]" 35 | }, 36 | "input_select": { 37 | "select_option": "velg[ {option}]" 38 | }, 39 | "select": { 40 | "select_option": "velg[ {option}]" 41 | }, 42 | "light": { 43 | "turn_on": "slå på[ med {brightness} lysstyrke]" 44 | }, 45 | "media_player": { 46 | "select_source": "velg kilde[ {source}]" 47 | }, 48 | "notify": { 49 | "notify": "send notifikasjon" 50 | }, 51 | "script": { 52 | "script": "utfør" 53 | }, 54 | "vacuum": { 55 | "start_pause": "start / pause" 56 | }, 57 | "water_heater": { 58 | "set_operation_mode": "sett modus[ til {operation_mode}]", 59 | "set_away_mode": "sett bortemodus" 60 | } 61 | }, 62 | "domains": { 63 | "alarm_control_panel": "alarmpanel", 64 | "binary_sensor": "binary sensors", 65 | "climate": "klima", 66 | "cover": "solskjerming", 67 | "fan": "vifter", 68 | "group": "grupper", 69 | "humidifier": "luftfuktere", 70 | "input_boolean": "input boolsk", 71 | "input_number": "input nummer", 72 | "input_select": "input valg", 73 | "lawn_mower": "lawn mower", 74 | "light": "lys", 75 | "lock": "låser", 76 | "media_player": "mediaspillere", 77 | "notify": "notification", 78 | "switch": "brytere", 79 | "vacuum": "støvsugere", 80 | "water_heater": "varmtvannsberedere" 81 | }, 82 | "ui": { 83 | "components": { 84 | "date": { 85 | "day_types_short": { 86 | "daily": "hver dag", 87 | "workdays": "ukedager", 88 | "weekend": "helg" 89 | }, 90 | "day_types_long": { 91 | "daily": "hver dag", 92 | "workdays": "ukedager", 93 | "weekend": "helg" 94 | }, 95 | "days": "Dager", 96 | "tomorrow": "imorgen", 97 | "repeated_days": "hver {days}", 98 | "repeated_days_except": "hver dag unntatt {excludedDays}", 99 | "days_range": "fra {startDay} til {endDay}", 100 | "next_week_day": "neste {weekday}" 101 | }, 102 | "time": { 103 | "absolute": "kl. {time}", 104 | "interval": "fra {startTime} til {endTime}", 105 | "at_midnight": "ved midnatt", 106 | "at_noon": "kl. 12.00", 107 | "at_sun_event": "ved {sunEvent}" 108 | } 109 | }, 110 | "dialog": { 111 | "enable_schedule": { 112 | "title": "Fullfør endringene", 113 | "description": "Tidsplanen som er endret er for øyeblikket deaktivert, bør den være aktivert?" 114 | }, 115 | "confirm_delete": { 116 | "title": "Vil du fjerne entiteten?", 117 | "description": "Er du sikker på at du vil fjerne denne entiteten?" 118 | }, 119 | "confirm_migrate": { 120 | "title": "Endre tidsplanen", 121 | "description": "Noen innstillinger vil gå tapt ved denne handlingen. Vil du fortsette?" 122 | } 123 | }, 124 | "panel": { 125 | "common": { 126 | "title": "Tidsplan", 127 | "new_schedule": "Ny tidsplan", 128 | "default_name": "Tidsplan #{id}" 129 | }, 130 | "overview": { 131 | "no_entries": "Det er ingen definerte tidsplaner å vise", 132 | "backend_error": "Kunne ikke koble til tidsplankomponenten. Den må installeres som en integrasjon før dette kortet kan benyttes.", 133 | "excluded_items": "{number} ekskludert {if number is 1} element {else} elementer", 134 | "hide_excluded": "skjul ekskluderte elementer", 135 | "additional_tasks": "{number} flere {if number is 1} oppgaver {else} oppgaver" 136 | }, 137 | "entity_picker": { 138 | "no_groups_defined": "Ingen grupper definert", 139 | "no_group_selected": "Velg en gruppe først", 140 | "no_entities_for_group": "Det finnes ingen entiteter i denne gruppen", 141 | "no_entity_selected": "Velg en entitet først", 142 | "no_actions_for_entity": "Det finnes ingen handlinger for denne entiteten", 143 | "make_scheme": "lag tidsplan", 144 | "multiple": "Flere" 145 | }, 146 | "time_picker": { 147 | "no_timeslot_selected": "Velg tidsluke først", 148 | "time_scheme": "Tidsplan", 149 | "time_input_mode": "Time control mode" 150 | }, 151 | "conditions": { 152 | "equal_to": "er", 153 | "unequal_to": "er ikke", 154 | "all": "alle", 155 | "any": "any", 156 | "no_conditions_defined": "Ingen vilkår definert", 157 | "track_conditions": "Re-evaluate when conditions change" 158 | }, 159 | "options": { 160 | "repeat_type": "oppførsel etter fullføring", 161 | "period": "periode" 162 | } 163 | } 164 | } 165 | } -------------------------------------------------------------------------------- /src/localize/languages/pl.json: -------------------------------------------------------------------------------- 1 | { 2 | "services": { 3 | "generic": { 4 | "parameter_to_value": "{parameter} na {value}", 5 | "action_with_parameter": "{action} z {parameter}" 6 | }, 7 | "climate": { 8 | "set_temperature": "ustaw temperaturę[ na {temperature}]", 9 | "set_temperature_hvac_mode_heat": "grzej[ do {temperature}]", 10 | "set_temperature_hvac_mode_cool": "chłodź[ do {temperature}]", 11 | "set_temperature_hvac_mode_heat_cool": "grzej/chłodź[ do {temperature}]", 12 | "set_temperature_hvac_mode_heat_cool_range": "grzej/chłodź[ do {target_temp_low} - {target_temp_high}]", 13 | "set_temperature_hvac_mode_auto": "automatyczny[ do {temperature}]", 14 | "set_hvac_mode": "ustaw tryb[ na {hvac_mode}]", 15 | "set_preset_mode": "ustaw preset[ {preset_mode}]", 16 | "set_fan_mode": "set fan mode[ to {fan_mode}]" 17 | }, 18 | "cover": { 19 | "close_cover": "zamknij", 20 | "open_cover": "otwórz", 21 | "set_cover_position": "ustaw pozycję[ na {position}]", 22 | "set_cover_tilt_position": "ustaw pozycję lameli[ na {tilt_position}]" 23 | }, 24 | "fan": { 25 | "set_speed": "ustaw prędkość[ na {speed}]", 26 | "set_direction": "ustaw kierunek[ na {direction}]", 27 | "oscillate": "ustaw oscylacje[ na {oscillate}]" 28 | }, 29 | "humidifier": { 30 | "set_humidity": "ustaw wilgotność[ na {humidity}]", 31 | "set_mode": "ustaw tryb[ na {mode}]" 32 | }, 33 | "input_number": { 34 | "set_value": "ustaw wartość[ na {value}]" 35 | }, 36 | "input_select": { 37 | "select_option": "wybierz opcję[ {option}]" 38 | }, 39 | "select": { 40 | "select_option": "wybierz opcję[ {option}]" 41 | }, 42 | "light": { 43 | "turn_on": "zapal[ z jasnością {brightness}]" 44 | }, 45 | "media_player": { 46 | "select_source": "wybierz źródło[ {source}]" 47 | }, 48 | "notify": { 49 | "notify": "send notification" 50 | }, 51 | "script": { 52 | "script": "wykonaj" 53 | }, 54 | "vacuum": { 55 | "start_pause": "start / pauza" 56 | }, 57 | "water_heater": { 58 | "set_operation_mode": "ustaw tryb[ na {operation_mode}]", 59 | "set_away_mode": "ustaw tryb nieobecności" 60 | } 61 | }, 62 | "domains": { 63 | "alarm_control_panel": "panel kontrolny alarmu", 64 | "binary_sensor": "binary sensors", 65 | "climate": "klimatyzacja", 66 | "cover": "rolety", 67 | "fan": "wentylatory", 68 | "group": "grupy", 69 | "humidifier": "nawilżacze", 70 | "input_boolean": "wejście logiczne", 71 | "input_number": "wejście liczbowe", 72 | "input_select": "wybór wejścia", 73 | "lawn_mower": "lawn mower", 74 | "light": "światła", 75 | "lock": "zamki", 76 | "media_player": "odtwarzacze", 77 | "notify": "notification", 78 | "switch": "przełączniki", 79 | "vacuum": "odkurzacze", 80 | "water_heater": "podgrzewacze wody" 81 | }, 82 | "ui": { 83 | "components": { 84 | "date": { 85 | "day_types_short": { 86 | "daily": "codziennie", 87 | "workdays": "robocze", 88 | "weekend": "weekendy" 89 | }, 90 | "day_types_long": { 91 | "daily": "codziennie", 92 | "workdays": "w dni robocze", 93 | "weekend": "podczas weekendu" 94 | }, 95 | "days": "dni", 96 | "tomorrow": "jutro", 97 | "repeated_days": "co {days} dni", 98 | "repeated_days_except": "coddziennie z wyjątkiem {excludedDays}", 99 | "days_range": "od {startDay} do {endDay}", 100 | "next_week_day": "następna {weekday}" 101 | }, 102 | "time": { 103 | "absolute": "o {time}", 104 | "interval": "od {startTime} do {endTime}", 105 | "at_midnight": "o północ", 106 | "at_noon": "o południe", 107 | "at_sun_event": "o {sunEvent}" 108 | } 109 | }, 110 | "dialog": { 111 | "enable_schedule": { 112 | "title": "Zakończ modyfikacje", 113 | "description": "Zmieniony harmonogram jest obecnie wyłączony, czy powinien być włączony?" 114 | }, 115 | "confirm_delete": { 116 | "title": "Usunąć encję?", 117 | "description": "Czy na pewno chcesz usunąć tę encję?" 118 | }, 119 | "confirm_migrate": { 120 | "title": "Zmodyfikuj harmonogram", 121 | "description": "Ta czynność spowoduje utratę niektórych ustawień. Czy chcesz kontynuować?" 122 | } 123 | }, 124 | "panel": { 125 | "common": { 126 | "title": "Harmonogram", 127 | "new_schedule": "Nowy harmonogram", 128 | "default_name": "Harmonogram #{id}" 129 | }, 130 | "overview": { 131 | "no_entries": "Nie ma elementów do pokazania", 132 | "backend_error": "Could not connect with the scheduler component. It needs to be installed as integration before this card can be used.", 133 | "excluded_items": "{number} wykluczona {if number is 1} pozycja {else} pozycje", 134 | "hide_excluded": "ukryj wykluczone pozycje", 135 | "additional_tasks": "{number} dodatkowe {if number is 1} zadanie {else} zadań(nia)" 136 | }, 137 | "entity_picker": { 138 | "no_groups_defined": "Nie ma zdefiniowanych grup", 139 | "no_group_selected": "Najpierw wybierz grupę", 140 | "no_entities_for_group": "Nie ma encji w tej grupie", 141 | "no_entity_selected": "Najpierw wybierz encję", 142 | "no_actions_for_entity": "Nie ma akcji dla tej encji", 143 | "make_scheme": "stwórz schemat", 144 | "multiple": "Multiple" 145 | }, 146 | "time_picker": { 147 | "no_timeslot_selected": "Najpierw wybierz przedział czasowy", 148 | "time_scheme": "Schemat", 149 | "time_input_mode": "Time control mode" 150 | }, 151 | "conditions": { 152 | "equal_to": "jest równe ", 153 | "unequal_to": "nie jest równe", 154 | "all": "wszystkie", 155 | "any": "dowolny", 156 | "no_conditions_defined": "Nie ma zdefiniowanych warunków", 157 | "track_conditions": "Re-evaluate when conditions change" 158 | }, 159 | "options": { 160 | "repeat_type": "zachowanie po zakończeniu", 161 | "period": "okres" 162 | } 163 | } 164 | } 165 | } -------------------------------------------------------------------------------- /src/localize/languages/ru.json: -------------------------------------------------------------------------------- 1 | { 2 | "services": { 3 | "generic": { 4 | "parameter_to_value": "{parameter} к {value}", 5 | "action_with_parameter": "{action} с {parameter}" 6 | }, 7 | "climate": { 8 | "set_temperature": "установить температуру[ {temperature}]", 9 | "set_temperature_hvac_mode_heat": "обогрев[ {temperature}]", 10 | "set_temperature_hvac_mode_cool": "охлаждение[ {temperature}]", 11 | "set_temperature_hvac_mode_heat_cool": "обогрев/охлаждение[ {temperature}]", 12 | "set_temperature_hvac_mode_heat_cool_range": "обогрев/охлаждение[ {target_temp_low} - {target_temp_high}]", 13 | "set_temperature_hvac_mode_auto": "aвто[ {temperature}]", 14 | "set_hvac_mode": "установить режим[ {hvac_mode}]", 15 | "set_preset_mode": "выбрать набор настроек[ {preset_mode}]", 16 | "set_fan_mode": "set fan mode[ to {fan_mode}]" 17 | }, 18 | "cover": { 19 | "close_cover": "закрыть", 20 | "open_cover": "открыть", 21 | "set_cover_position": "установить позицию[ {position}]", 22 | "set_cover_tilt_position": "установить наклон[ {tilt_position}]" 23 | }, 24 | "fan": { 25 | "set_speed": "установить скорость[ {speed}]", 26 | "set_direction": "установить направление[ {direction}]", 27 | "oscillate": "установить колебание[ {oscillate}]" 28 | }, 29 | "humidifier": { 30 | "set_humidity": "установить влажность[ {humidity}]", 31 | "set_mode": "установить режим[ {mode}]" 32 | }, 33 | "input_number": { 34 | "set_value": "установить значение[ в {value}]" 35 | }, 36 | "input_select": { 37 | "select_option": "установить опцию[ {option}]" 38 | }, 39 | "select": { 40 | "select_option": "установить опцию[ {option}]" 41 | }, 42 | "light": { 43 | "turn_on": "включить[ с {brightness} яркостью]" 44 | }, 45 | "media_player": { 46 | "select_source": "выбрать источник[ {source}]" 47 | }, 48 | "notify": { 49 | "notify": "послать сообщение" 50 | }, 51 | "script": { 52 | "script": "запустить" 53 | }, 54 | "vacuum": { 55 | "start_pause": "старт / пауза" 56 | }, 57 | "water_heater": { 58 | "set_operation_mode": "установить режим[ {operation_mode}]", 59 | "set_away_mode": "установить режим вне дома" 60 | } 61 | }, 62 | "domains": { 63 | "alarm_control_panel": "панель управления сигнализацией", 64 | "binary_sensor": "binary sensors", 65 | "climate": "климат", 66 | "cover": "жалюзи", 67 | "fan": "вентиляторы", 68 | "group": "группы", 69 | "humidifier": "увлажнители", 70 | "input_boolean": "логические", 71 | "input_number": "числовые", 72 | "input_select": "списки", 73 | "lawn_mower": "lawn mower", 74 | "light": "освещение", 75 | "lock": "замки", 76 | "media_player": "медиа-плееры", 77 | "notify": "notification", 78 | "switch": "розетки", 79 | "vacuum": "пылесосы", 80 | "water_heater": "подогреватели воды" 81 | }, 82 | "ui": { 83 | "components": { 84 | "date": { 85 | "day_types_short": { 86 | "daily": "ежедневно", 87 | "workdays": "рабочие дни", 88 | "weekend": "выходные" 89 | }, 90 | "day_types_long": { 91 | "daily": "каждый день", 92 | "workdays": "по рабочим дням", 93 | "weekend": "в выходные" 94 | }, 95 | "days": "дни", 96 | "tomorrow": "завтра", 97 | "repeated_days": "каждый {days}", 98 | "repeated_days_except": "каждый день кроме {excludedDays}", 99 | "days_range": "с {startDay} до {endDay}", 100 | "next_week_day": "в следующую {weekday}" 101 | }, 102 | "time": { 103 | "absolute": "в {time}", 104 | "interval": "с {startTime} до {endTime}", 105 | "at_midnight": "в полночь", 106 | "at_noon": "в полдень", 107 | "at_sun_event": "в {sunEvent}" 108 | } 109 | }, 110 | "dialog": { 111 | "enable_schedule": { 112 | "title": "Завершите модификации", 113 | "description": "Расписание, которое было изменено, в настоящее время отключено, следует ли его включить?" 114 | }, 115 | "confirm_delete": { 116 | "title": "Удалить объект?", 117 | "description": "Вы уверены, что хотите удалить этот объект?" 118 | }, 119 | "confirm_migrate": { 120 | "title": "Изменить расписание", 121 | "description": "При этом некоторые настройки будут потеряны. Вы хотите продолжить?" 122 | } 123 | }, 124 | "panel": { 125 | "common": { 126 | "title": "Планировщик", 127 | "new_schedule": "Новое расписание", 128 | "default_name": "Расписание #{id}" 129 | }, 130 | "overview": { 131 | "no_entries": "Отсутствуют элементы", 132 | "backend_error": "Нет соединенияс scheduler component. Scheduler component должен быть установлен до применения этой карты.", 133 | "excluded_items": "{number} исключено {if number is 1} элемент {else} элементов", 134 | "hide_excluded": "скрыть исключенные элементы", 135 | "additional_tasks": "{number} больше {if number is 1} задача {else} задач" 136 | }, 137 | "entity_picker": { 138 | "no_groups_defined": "Не определены группы", 139 | "no_group_selected": "Выберите группу", 140 | "no_entities_for_group": "Отсутствуют элементы в группе", 141 | "no_entity_selected": "Выберите элемент", 142 | "no_actions_for_entity": "Нет действий для этого элемента", 143 | "make_scheme": "создать схему", 144 | "multiple": "Множественный" 145 | }, 146 | "time_picker": { 147 | "no_timeslot_selected": "Выберите временной слот", 148 | "time_scheme": "Cхему", 149 | "time_input_mode": "Time control mode" 150 | }, 151 | "conditions": { 152 | "equal_to": "равно", 153 | "unequal_to": "не равно", 154 | "all": "все", 155 | "any": "любое", 156 | "no_conditions_defined": "Не определены условия", 157 | "track_conditions": "Re-evaluate when conditions change" 158 | }, 159 | "options": { 160 | "repeat_type": "поведение после срабатывания", 161 | "period": "период" 162 | } 163 | } 164 | } 165 | } -------------------------------------------------------------------------------- /src/localize/languages/uk.json: -------------------------------------------------------------------------------- 1 | { 2 | "services": { 3 | "generic": { 4 | "parameter_to_value": "{parameter} до {value}", 5 | "action_with_parameter": "{action} з {parameter}" 6 | }, 7 | "climate": { 8 | "set_temperature": "встановити температуру[ to {temperature}]", 9 | "set_temperature_hvac_mode_heat": "нагрів[ to {temperature}]", 10 | "set_temperature_hvac_mode_cool": "охолодження[ to {temperature}]", 11 | "set_temperature_hvac_mode_heat_cool": "нагрів/охолодження[ to {temperature}]", 12 | "set_temperature_hvac_mode_heat_cool_range": "нагрів/охолодження[ to {target_temp_low} - {target_temp_high}]", 13 | "set_temperature_hvac_mode_auto": "aвтоматичний[ to {temperature}]", 14 | "set_hvac_mode": "встановити режим[ to {hvac_mode}]", 15 | "set_preset_mode": "вибрати пресет[ to {preset_mode}]", 16 | "set_fan_mode": "set fan mode[ to {fan_mode}]" 17 | }, 18 | "cover": { 19 | "close_cover": "закрити", 20 | "open_cover": "відкрити", 21 | "set_cover_position": "встановити позицію[ to {position}]", 22 | "set_cover_tilt_position": "set tilt position[ to {tilt_position}]" 23 | }, 24 | "fan": { 25 | "set_speed": "встановити швидкість[ to {speed}]", 26 | "set_direction": "встановити напрямок[ to {direction}]", 27 | "oscillate": "встановити коливання[ to {oscillate}]" 28 | }, 29 | "humidifier": { 30 | "set_humidity": "встановити вологість[ to {humidity}]", 31 | "set_mode": "встановити режим[ to {mode}]" 32 | }, 33 | "input_number": { 34 | "set_value": "встановити значення[ to {value}]" 35 | }, 36 | "input_select": { 37 | "select_option": "встановити опцію[ {option}]" 38 | }, 39 | "select": { 40 | "select_option": "встановити опцію[ {option}]" 41 | }, 42 | "light": { 43 | "turn_on": "увімкнути[ з {brightness} якскравістю]" 44 | }, 45 | "media_player": { 46 | "select_source": "вибрати джерело[ {source}]" 47 | }, 48 | "notify": { 49 | "notify": "send notification" 50 | }, 51 | "script": { 52 | "script": "виконати" 53 | }, 54 | "vacuum": { 55 | "start_pause": "старт / пауза" 56 | }, 57 | "water_heater": { 58 | "set_operation_mode": "встановити режим[ to {operation_mode}]", 59 | "set_away_mode": "встановити режим Не вдома" 60 | } 61 | }, 62 | "domains": { 63 | "alarm_control_panel": "панель керування сигналізацією", 64 | "binary_sensor": "binary sensors", 65 | "climate": "клімат", 66 | "cover": "жалюзі", 67 | "fan": "вентилятори", 68 | "group": "групи", 69 | "humidifier": "зволожувачі", 70 | "input_boolean": "логічні", 71 | "input_number": "числові", 72 | "input_select": "списки", 73 | "lawn_mower": "lawn mower", 74 | "light": "освітлення", 75 | "lock": "замки", 76 | "media_player": "медіаплеєри", 77 | "notify": "notification", 78 | "switch": "вимикачі", 79 | "vacuum": "пилососи", 80 | "water_heater": "водонагрівачі" 81 | }, 82 | "ui": { 83 | "components": { 84 | "date": { 85 | "day_types_short": { 86 | "daily": "щоденно", 87 | "workdays": "робочі дні", 88 | "weekend": "вихідні" 89 | }, 90 | "day_types_long": { 91 | "daily": "кожного дня", 92 | "workdays": "в робочі дні", 93 | "weekend": "по вихідних" 94 | }, 95 | "days": "дні", 96 | "tomorrow": "завтра", 97 | "repeated_days": "кожні {days}", 98 | "repeated_days_except": "кожного дня окрім {excludedDays}", 99 | "days_range": "з {startDay} до {endDay}", 100 | "next_week_day": "наступної {weekday}" 101 | }, 102 | "time": { 103 | "absolute": "о {time}", 104 | "interval": "з {startTime} до {endTime}", 105 | "at_midnight": "опівночі", 106 | "at_noon": "опівдні", 107 | "at_sun_event": "о {sunEvent}" 108 | } 109 | }, 110 | "dialog": { 111 | "enable_schedule": { 112 | "title": "Завершіть зміни", 113 | "description": "Розклад, який було змінено, наразі вимкнено, чи потрібно його ввімкнути?" 114 | }, 115 | "confirm_delete": { 116 | "title": "Видалити сутність?", 117 | "description": "Ви дійсно бажаєте видалити цю сутність?" 118 | }, 119 | "confirm_migrate": { 120 | "title": "Змінити розклад", 121 | "description": "У результаті цієї дії деякі налаштування буде втрачено. Ви бажаєте продовжити?" 122 | } 123 | }, 124 | "panel": { 125 | "common": { 126 | "title": "Планувальник", 127 | "new_schedule": "Новий розклад", 128 | "default_name": "Розклад #{id}" 129 | }, 130 | "overview": { 131 | "no_entries": "Елементи відсутні", 132 | "backend_error": "Не вдалося підключитися до компонента планувальника. Перш ніж використовувати цю карту, її потрібно встановити як інтеграцію.", 133 | "excluded_items": "{number} виключено {if number is 1} елемент {else} елементів", 134 | "hide_excluded": "сховати виключені елементи", 135 | "additional_tasks": "{number} більше {if number is 1} завдання {else} завдань" 136 | }, 137 | "entity_picker": { 138 | "no_groups_defined": "Немає визначених груп", 139 | "no_group_selected": "Спершу виберіть групу", 140 | "no_entities_for_group": "В даній групі відсутні елементи", 141 | "no_entity_selected": "Спершу виберіть елемент", 142 | "no_actions_for_entity": "Немає дій для цього елемента", 143 | "make_scheme": "створити схему", 144 | "multiple": "Декілька" 145 | }, 146 | "time_picker": { 147 | "no_timeslot_selected": "Спершу виберіть часовий проміжок", 148 | "time_scheme": "Cхему", 149 | "time_input_mode": "Time control mode" 150 | }, 151 | "conditions": { 152 | "equal_to": "дорівнює", 153 | "unequal_to": "не рівне", 154 | "all": "всі", 155 | "any": "будь-яке", 156 | "no_conditions_defined": "Не визначені умови", 157 | "track_conditions": "Re-evaluate when conditions change" 158 | }, 159 | "options": { 160 | "repeat_type": "поведінка після спрацювання", 161 | "period": "період" 162 | } 163 | } 164 | } 165 | } -------------------------------------------------------------------------------- /src/localize/languages/zh-Hans.json: -------------------------------------------------------------------------------- 1 | { 2 | "services": { 3 | "generic": { 4 | "parameter_to_value": "{parameter} 至 {value}", 5 | "action_with_parameter": "{action} 使用 {parameter}" 6 | }, 7 | "climate": { 8 | "set_temperature": "设定温度[ 至 {temperature}]", 9 | "set_temperature_hvac_mode_heat": "制热模式[ 至 {temperature}]", 10 | "set_temperature_hvac_mode_cool": "制冷模式[ 至 {temperature}]", 11 | "set_temperature_hvac_mode_heat_cool": "制热模式/制冷模式[ 至 {temperature}]", 12 | "set_temperature_hvac_mode_heat_cool_range": "制热模式/制冷模式[ 至 {target_temp_low} - {target_temp_high}]", 13 | "set_temperature_hvac_mode_auto": "自动[ 至 {temperature}]", 14 | "set_hvac_mode": "设定模式[ 为 {hvac_mode}]", 15 | "set_preset_mode": "设定预设模式[ 为 {preset_mode}]", 16 | "set_fan_mode": "set fan mode[ to {fan_mode}]" 17 | }, 18 | "cover": { 19 | "close_cover": "关闭", 20 | "open_cover": "打开", 21 | "set_cover_position": "设定位置[ 至 {position}]", 22 | "set_cover_tilt_position": "设定倾斜位置[ 至 {tilt_position}]" 23 | }, 24 | "fan": { 25 | "set_speed": "设定风速[ 至 {speed}]", 26 | "set_direction": "设定方向[ 至 {direction}]", 27 | "oscillate": "设置摇摆[ 至 {oscillate}]" 28 | }, 29 | "humidifier": { 30 | "set_humidity": "设定湿度[ 至 {humidity}]", 31 | "set_mode": "设定模式[ 为 {mode}]" 32 | }, 33 | "input_number": { 34 | "set_value": "设定数值[ 至 {value}]" 35 | }, 36 | "input_select": { 37 | "select_option": "选择选项[ {option}]" 38 | }, 39 | "select": { 40 | "select_option": "选择选项[ {option}]" 41 | }, 42 | "light": { 43 | "turn_on": "打开[ 并设定亮度为 {brightness}]" 44 | }, 45 | "media_player": { 46 | "select_source": "选择播放源[ {source}]" 47 | }, 48 | "notify": { 49 | "notify": "发送通知" 50 | }, 51 | "script": { 52 | "script": "执行" 53 | }, 54 | "vacuum": { 55 | "start_pause": "开始 / 暂停" 56 | }, 57 | "water_heater": { 58 | "set_operation_mode": "设定模式[ 为 {operation_mode}]", 59 | "set_away_mode": "设定离开模式" 60 | } 61 | }, 62 | "domains": { 63 | "alarm_control_panel": "警戒控制面板", 64 | "binary_sensor": "binary sensors", 65 | "climate": "空调/地暖", 66 | "cover": "窗帘", 67 | "fan": "风扇/空气净化器", 68 | "group": "实体组", 69 | "humidifier": "空气加湿器", 70 | "input_boolean": "输入二元选择器", 71 | "input_number": "输入数值", 72 | "input_select": "输入选择", 73 | "lawn_mower": "lawn mower", 74 | "light": "灯具", 75 | "lock": "门锁", 76 | "media_player": "媒体播放器", 77 | "notify": "notification", 78 | "switch": "开关", 79 | "vacuum": "扫地机/吸尘器", 80 | "water_heater": "热水器" 81 | }, 82 | "ui": { 83 | "components": { 84 | "date": { 85 | "day_types_short": { 86 | "daily": "每日", 87 | "workdays": "工作日", 88 | "weekend": "周末" 89 | }, 90 | "day_types_long": { 91 | "daily": "每一天", 92 | "workdays": "在工作日", 93 | "weekend": "在周末" 94 | }, 95 | "days": "天", 96 | "tomorrow": "明天", 97 | "repeated_days": "每 {days}", 98 | "repeated_days_except": "每天,除了 {excludedDays}", 99 | "days_range": "从 {startDay} 至 {endDay}", 100 | "next_week_day": "下{weekday}" 101 | }, 102 | "time": { 103 | "absolute": "在 {time}", 104 | "interval": "从 {startTime} 至 {endTime}", 105 | "at_midnight": "在午夜", 106 | "at_noon": "在正午", 107 | "at_sun_event": "在 {sunEvent}" 108 | } 109 | }, 110 | "dialog": { 111 | "enable_schedule": { 112 | "title": "完成修改", 113 | "description": "已更改的计划当前已禁用,是否应该启用?" 114 | }, 115 | "confirm_delete": { 116 | "title": "是否删除实体?", 117 | "description": "您确定要删除此实体吗?" 118 | }, 119 | "confirm_migrate": { 120 | "title": "修改时间表", 121 | "description": "此操作将丢失某些设置。 你想继续吗?" 122 | } 123 | }, 124 | "panel": { 125 | "common": { 126 | "title": "计划任务", 127 | "new_schedule": "新时间表", 128 | "default_name": "日程 #{id}" 129 | }, 130 | "overview": { 131 | "no_entries": "无事项", 132 | "backend_error": "计划任务组件关联失败。本卡片使用前,需先安装计划任务组件和集成.", 133 | "excluded_items": "{number} 除外 {if number is 1} 事项 {else} 事项", 134 | "hide_excluded": "隐藏除外事项", 135 | "additional_tasks": "{number} 更多 {if number is 1} 任务 {else} 任务" 136 | }, 137 | "entity_picker": { 138 | "no_groups_defined": "未添加需执行计划任务的群组", 139 | "no_group_selected": "请选择群组", 140 | "no_entities_for_group": "群组不含实体", 141 | "no_entity_selected": "请选择实体", 142 | "no_actions_for_entity": "该实体不含可执行的动作", 143 | "make_scheme": "制定计划", 144 | "multiple": "多选" 145 | }, 146 | "time_picker": { 147 | "no_timeslot_selected": "请选择时间段", 148 | "time_scheme": "议程", 149 | "time_input_mode": "Time control mode" 150 | }, 151 | "conditions": { 152 | "equal_to": "是", 153 | "unequal_to": "非", 154 | "all": "全部", 155 | "any": "任一", 156 | "no_conditions_defined": "未定义条件", 157 | "track_conditions": "Re-evaluate when conditions change" 158 | }, 159 | "options": { 160 | "repeat_type": "触发后的行为", 161 | "period": "时期" 162 | } 163 | } 164 | } 165 | } -------------------------------------------------------------------------------- /src/localize/localize.ts: -------------------------------------------------------------------------------- 1 | import * as cs from './languages/cs.json'; 2 | import * as de from './languages/de.json'; 3 | import * as en from './languages/en.json'; 4 | import * as es from './languages/es.json'; 5 | import * as et from './languages/et.json'; 6 | import * as fi from './languages/fi.json'; 7 | import * as fr from './languages/fr.json'; 8 | import * as he from './languages/he.json'; 9 | import * as hu from './languages/hu.json'; 10 | import * as it from './languages/it.json'; 11 | import * as lv from './languages/lv.json'; 12 | import * as nl from './languages/nl.json'; 13 | import * as no from './languages/no.json'; 14 | import * as pl from './languages/pl.json'; 15 | import * as pt from './languages/pt.json'; 16 | import * as pt_br from './languages/pt-BR.json'; 17 | import * as ro from './languages/ro.json'; 18 | import * as ru from './languages/ru.json'; 19 | import * as sk from './languages/sk.json'; 20 | import * as sl from './languages/sl.json'; 21 | import * as uk from './languages/uk.json'; 22 | import * as zh_Hans from './languages/zh-Hans.json'; 23 | import { FrontendTranslationData } from 'custom-card-helpers'; 24 | 25 | const languages: any = { 26 | cs: cs, 27 | de: de, 28 | en: en, 29 | es: es, 30 | et: et, 31 | es_419: es, 32 | fi: fi, 33 | fr: fr, 34 | he: he, 35 | hu: hu, 36 | it: it, 37 | lv: lv, 38 | nb: no, 39 | nl: nl, 40 | nn: no, 41 | no: no, 42 | pl: pl, 43 | pt: pt, 44 | 'pt-BR': pt_br, 45 | ro: ro, 46 | sk: sk, 47 | sl: sl, 48 | ru: ru, 49 | uk: uk, 50 | 'zh-Hans': zh_Hans, 51 | }; 52 | 53 | export function localize( 54 | string: string, 55 | locale: FrontendTranslationData, 56 | search: string | (string | number)[] | number = '', 57 | replace: string | (string | number)[] | number = '' 58 | ) { 59 | let translated: string; 60 | try { 61 | if (locale.language == 'test') return 'TRANSLATED'; 62 | translated = string.split('.').reduce((o, i) => o[i], languages[locale.language]); 63 | if (!translated) translated = string.split('.').reduce((o, i) => o[i], languages['en']); 64 | } catch (e) { 65 | try { 66 | translated = string.split('.').reduce((o, i) => o[i], languages['en']); 67 | } catch (e) { 68 | translated = ''; 69 | } 70 | } 71 | 72 | if (search !== '' && replace !== '' && translated) { 73 | if (!Array.isArray(search)) search = [search]; 74 | if (!Array.isArray(replace)) replace = [replace]; 75 | for (let i = 0; i < (search as string[]).length; i++) { 76 | translated = translated.replace(String(search[i]), String(replace[i])); 77 | const res = translated.match(/\{if ([a-z]+) is ([^\}]+)\}\ ?([^\{]+)\ ?\{else\}\ ?([^\{]+)/i); 78 | if (res && String(search[i]).replace(/[\{\}']+/g, '') == res[1]) { 79 | const is_match = String(replace[i]) == res[2]; 80 | if (is_match) translated = translated.replace(res[0], res[3]); 81 | else translated = translated.replace(res[0], res[4]); 82 | } 83 | } 84 | } 85 | 86 | // if (!translated) { 87 | // console.log(`missing translation for ${string}`); 88 | // } 89 | return translated; 90 | } 91 | -------------------------------------------------------------------------------- /src/standard-configuration/action_icons.ts: -------------------------------------------------------------------------------- 1 | import { computeDomain, computeEntity } from 'custom-card-helpers'; 2 | import { HassEntity } from 'home-assistant-js-websocket'; 3 | import { DefaultActionIcon } from '../const'; 4 | 5 | type IconItem = string | ((action: string, stateObj?: HassEntity) => string); 6 | 7 | type IconList = Record>; 8 | 9 | const coverIcon = (action: string, stateObj?: HassEntity) => { 10 | const closedState = action == 'close'; 11 | switch (stateObj?.attributes.device_class) { 12 | case 'garage': 13 | return closedState ? 'mdi:garage' : 'mdi:garage-open'; 14 | case 'door': 15 | return closedState ? 'mdi:door-closed' : 'mdi:door-open'; 16 | case 'blind': 17 | return closedState ? 'mdi:blinds' : 'mdi:blinds-open'; 18 | case 'window': 19 | return closedState ? 'mdi:window-closed' : 'mdi:window-open'; 20 | default: 21 | return closedState ? 'mdi:window-shutter' : 'mdi:window-shutter-open'; 22 | } 23 | }; 24 | 25 | const actionIcons: IconList = { 26 | alarm_control_panel: { 27 | alarm_disarm: 'mdi:lock-open-variant-outline', 28 | alarm_arm_home: 'mdi:home-outline', 29 | alarm_arm_away: 'mdi:exit-run', 30 | alarm_arm_night: 'mdi:power-sleep', 31 | alarm_arm_custom_bypass: 'mdi:shield-lock-outline', 32 | arm_vacation: 'mdi:shield-airplane', 33 | }, 34 | automation: { 35 | turn_on: 'mdi:power', 36 | turn_off: 'mdi:power-off', 37 | trigger: 'mdi:play', 38 | }, 39 | button: { 40 | press: 'mdi:gesture-tap-button', 41 | }, 42 | climate: { 43 | turn_off: 'mdi:power-off', 44 | heat: 'mdi:fire', 45 | cool: 'mdi:snowflake', 46 | heat_cool: 'mdi:thermometer', 47 | heat_cool_range: 'mdi:thermometer', 48 | set_temperature: 'mdi:thermometer', 49 | auto: 'mdi:autorenew', 50 | set_mode: 'mdi:cog-transfer-outline', 51 | set_preset: 'mdi:cloud-download-outline', 52 | set_fan_mode: 'mdi:fan', 53 | }, 54 | cover: { 55 | close: coverIcon, 56 | open: coverIcon, 57 | set_position: 'mdi:ray-vertex', 58 | set_tilt_position: 'mdi:valve', 59 | }, 60 | fan: { 61 | turn_on: 'mdi:power', 62 | turn_off: 'mdi:power-off', 63 | set_percentage: 'mdi:weather-windy', 64 | set_oscillation: 'mdi:arrow-left-right', 65 | set_direction: 'mdi:cog-clockwise', 66 | set_preset_mode: 'mdi:cloud-download-outline', 67 | }, 68 | humidifier: { 69 | turn_on: 'mdi:power', 70 | turn_off: 'mdi:power-off', 71 | set_humidity: 'mdi:water-percent', 72 | set_mode: 'mdi:cog-transfer-outline', 73 | }, 74 | input_boolean: { 75 | turn_on: 'mdi:flash', 76 | turn_off: 'mdi:flash-off', 77 | }, 78 | input_button: { 79 | press: 'mdi:gesture-tap-button', 80 | }, 81 | input_number: { 82 | set_value: 'mdi:counter', 83 | }, 84 | input_select: { 85 | select_option: 'mdi:counter', 86 | }, 87 | lawn_mower: { 88 | start_mowing: 'mdi:play', 89 | pause: 'mdi:pause', 90 | dock: 'mdi:home-import-outline' 91 | }, 92 | light: { 93 | turn_on: 'mdi:lightbulb', 94 | turn_off: 'mdi:lightbulb-off', 95 | }, 96 | lock: { 97 | lock: 'mdi:lock-outline', 98 | unlock: 'mdi:lock-open-variant-outline', 99 | }, 100 | media_player: { 101 | turn_on: 'mdi:power', 102 | turn_off: 'mdi:power-off', 103 | select_source: 'mdi:music-box-multiple-outline', 104 | }, 105 | notify: { 106 | '{entity_id}': 'mdi:message-alert', 107 | }, 108 | number: { 109 | set_value: 'mdi:counter', 110 | }, 111 | scene: { 112 | turn_on: 'mdi:play', 113 | }, 114 | script: { 115 | turn_on: 'mdi:flash', 116 | turn_off: 'mdi:flash-off', 117 | '{entity_id}': 'mdi:play', 118 | }, 119 | select: { 120 | select_option: 'mdi:counter', 121 | }, 122 | switch: { 123 | turn_on: 'mdi:flash', 124 | turn_off: 'mdi:flash-off', 125 | }, 126 | vacuum: { 127 | turn_on: 'mdi:power', 128 | start: 'mdi:play-circle-outline', 129 | play_pause: 'mdi:play-circule-outline', 130 | }, 131 | water_heater: { 132 | set_temperature: 'mdi:thermometer', 133 | set_mode: 'mdi:cog-transfer-outline', 134 | set_away_mode: 'mdi:car-traction-control', 135 | }, 136 | }; 137 | 138 | export const actionIcon = (domain: string, action: string, stateObj: HassEntity | undefined): string | undefined => { 139 | if (domain in actionIcons && action in actionIcons[domain]) { 140 | let item = actionIcons[domain][action]; 141 | if (item instanceof Function) { 142 | item = item(action, stateObj); 143 | } 144 | return item; 145 | } 146 | return DefaultActionIcon; 147 | }; 148 | -------------------------------------------------------------------------------- /src/standard-configuration/action_name.ts: -------------------------------------------------------------------------------- 1 | import { computeDomain, computeEntity, HomeAssistant } from 'custom-card-helpers'; 2 | import { getLocale } from '../helpers'; 3 | import { localize } from '../localize/localize'; 4 | 5 | type actionNameTemplate = (action: string) => string; 6 | 7 | const actionNamesList: Record> = { 8 | alarm_control_panel: { 9 | alarm_disarm: 'ui.card.alarm_control_panel.disarm', 10 | alarm_arm_home: 'ui.card.alarm_control_panel.arm_home', 11 | alarm_arm_away: 'ui.card.alarm_control_panel.arm_away', 12 | alarm_arm_night: 'ui.card.alarm_control_panel.arm_night', 13 | alarm_arm_custom_bypass: 'ui.card.alarm_control_panel.arm_custom_bypass', 14 | alarm_arm_vacation: 'ui.card.alarm_control_panel.arm_vacation', 15 | }, 16 | automation: { 17 | turn_on: 'ui.card.vacuum.actions.turn_on', 18 | turn_off: 'ui.card.vacuum.actions.turn_off', 19 | trigger: 'ui.card.script.run', 20 | }, 21 | button: { 22 | press: 'ui.card.button.press', 23 | }, 24 | climate: { 25 | turn_off: 'ui.card.vacuum.actions.turn_off', 26 | heat: 'services.climate.set_temperature_hvac_mode_heat', 27 | cool: 'services.climate.set_temperature_hvac_mode_cool', 28 | heat_cool: 'services.climate.set_temperature_hvac_mode_heat_cool', 29 | heat_cool_range: 'services.climate.set_temperature_hvac_mode_heat_cool_range', 30 | auto: 'services.climate.set_temperature_hvac_mode_auto', 31 | set_temperature: 'services.climate.set_temperature', 32 | set_mode: 'services.climate.set_hvac_mode', 33 | set_preset: 'services.climate.set_preset_mode', 34 | set_fan_mode: 'services.climate.set_fan_mode', 35 | }, 36 | cover: { 37 | close: 'services.cover.close_cover', 38 | open: 'services.cover.open_cover', 39 | set_position: 'services.cover.set_cover_position', 40 | set_tilt: 'services.cover.set_cover_tilt_position', 41 | }, 42 | fan: { 43 | turn_on: 'ui.card.vacuum.actions.turn_on', 44 | turn_off: 'ui.card.vacuum.actions.turn_off', 45 | set_speed: 'services.fan.set_speed', 46 | set_oscillation: 'services.fan.oscillate', 47 | set_direction: 'services.fan.set_direction', 48 | set_preset: 'services.climate.set_preset_mode', 49 | }, 50 | humidifier: { 51 | turn_on: 'ui.card.vacuum.actions.turn_on', 52 | turn_off: 'ui.card.vacuum.actions.turn_off', 53 | set_humidity: 'services.humidifier.set_humidity', 54 | set_mode: 'services.humidifier.set_mode', 55 | }, 56 | input_boolean: { 57 | turn_on: 'ui.card.vacuum.actions.turn_on', 58 | turn_off: 'ui.card.vacuum.actions.turn_off', 59 | }, 60 | input_button: { 61 | press: 'ui.card.button.press', 62 | }, 63 | input_number: { 64 | set_value: 'services.input_number.set_value', 65 | }, 66 | input_select: { 67 | select_option: 'services.input_select.select_option', 68 | }, 69 | lawn_mower: { 70 | start_mowing: 'ui.card.lawn_mower.actions.start_mowing', 71 | pause: 'ui.card.timer.actions.pause', 72 | dock: 'ui.card.lawn_mower.actions.dock' 73 | }, 74 | light: { 75 | turn_on: 'ui.card.vacuum.actions.turn_on', 76 | turn_off: 'ui.card.vacuum.actions.turn_off', 77 | }, 78 | lock: { 79 | lock: 'ui.card.lock.lock', 80 | unlock: 'ui.card.lock.unlock', 81 | }, 82 | media_player: { 83 | turn_on: 'ui.card.vacuum.actions.turn_on', 84 | turn_off: 'ui.card.vacuum.actions.turn_off', 85 | select_source: 'services.media_player.select_source', 86 | }, 87 | notify: { 88 | '{entity_id}': 'services.notify.notify', 89 | }, 90 | number: { 91 | set_value: 'services.input_number.set_value', 92 | }, 93 | scene: { 94 | turn_on: 'ui.card.vacuum.actions.turn_on', 95 | }, 96 | script: { 97 | turn_on: 'ui.card.vacuum.actions.turn_on', 98 | turn_off: 'ui.card.vacuum.actions.turn_off', 99 | '{entity_id}': 'services.script.script', 100 | }, 101 | select: { 102 | select_option: 'services.input_select.select_option', 103 | }, 104 | switch: { 105 | turn_on: 'ui.card.vacuum.actions.turn_on', 106 | turn_off: 'ui.card.vacuum.actions.turn_off', 107 | }, 108 | vacuum: { 109 | turn_on: 'ui.card.vacuum.actions.turn_on', 110 | start: 'ui.card.vacuum.start_cleaning', 111 | play_pause: 'services.vacuum.start_pause', 112 | }, 113 | water_heater: { 114 | set_temperature: 'services.climate.set_temperature', 115 | set_mode: 'services.water_heater.set_operation_mode', 116 | set_away_mode: 'services.water_heater.set_away_mode', 117 | }, 118 | }; 119 | 120 | export const actionName = (domain: string, action: string, hass: HomeAssistant) => { 121 | if (domain in actionNamesList && action in actionNamesList[domain]) { 122 | let item = actionNamesList[domain][action]; 123 | if (item instanceof Function) { 124 | item = item(action); 125 | } 126 | return item.startsWith('services') ? localize(item, getLocale(hass)) : hass.localize(item); 127 | } 128 | return action; 129 | }; 130 | -------------------------------------------------------------------------------- /src/standard-configuration/attribute.ts: -------------------------------------------------------------------------------- 1 | import { HassEntity } from 'home-assistant-js-websocket'; 2 | import { isDefined } from '../helpers'; 3 | 4 | export const numericAttribute = (stateObj: HassEntity | undefined, attribute: string | number, fallback?: number) => { 5 | if (typeof attribute == 'number') return attribute; 6 | if (!isDefined(stateObj) || !isDefined(stateObj.attributes[attribute])) return fallback; 7 | 8 | const val = stateObj.attributes[attribute]; 9 | if (typeof val == 'number') return val; 10 | return fallback; 11 | }; 12 | 13 | export const listAttribute = (stateObj: HassEntity | undefined, attribute: string, initializer: string[] = []) => { 14 | if (!isDefined(stateObj) || !isDefined(stateObj.attributes[attribute])) return initializer; 15 | 16 | const val = stateObj.attributes[attribute]; 17 | if (Array.isArray(val)) return val.map(e => String(e)); 18 | return initializer; 19 | }; 20 | 21 | export const stringAttribute = (stateObj: HassEntity | undefined, attribute: string, initializer = '') => { 22 | if (!isDefined(stateObj) || !isDefined(stateObj.attributes[attribute])) return initializer; 23 | 24 | const val = stateObj.attributes[attribute]; 25 | if (typeof val == 'string') return val; 26 | return initializer; 27 | }; 28 | -------------------------------------------------------------------------------- /src/standard-configuration/group.ts: -------------------------------------------------------------------------------- 1 | import { Action, Variable, EVariableType, ListVariable, LevelVariable } from '../types'; 2 | import { HassEntity } from 'home-assistant-js-websocket'; 3 | import { computeDomain, computeEntity, HomeAssistant } from 'custom-card-helpers'; 4 | import { listVariable } from '../data/variables/list_variable'; 5 | import { levelVariable } from '../data/variables/level_variable'; 6 | import { computeCommonActions } from '../data/actions/compute_common_actions'; 7 | import { omit } from '../helpers'; 8 | import { computeSupportedFeatures } from '../data/entities/compute_supported_features'; 9 | 10 | export function groupActions(hass: HomeAssistant, entity: HassEntity, entityActions: Action[][]) { 11 | const entities: string[] = 12 | entity && entity.attributes.entity_id && Array.isArray(entity.attributes.entity_id) 13 | ? entity.attributes.entity_id 14 | : []; 15 | 16 | entityActions = entityActions.map((actions, i) => { 17 | //filter by supported_features 18 | const stateObj: HassEntity | undefined = hass.states[entities[i]]; 19 | const supportedFeatures = computeSupportedFeatures(stateObj); 20 | actions = actions 21 | .filter(e => !e.supported_feature || e.supported_feature & supportedFeatures) 22 | .map(action => omit(action, 'supported_feature')); 23 | return actions; 24 | }); 25 | 26 | //find matches 27 | const mixedDomains = [...new Set(entities.map(e => computeDomain(e)))].length > 1; 28 | if (mixedDomains) { 29 | entityActions = entityActions.map(actionList => { 30 | return actionList.map(action => { 31 | if (computeEntity(action.service) == 'turn_on' || computeEntity(action.service) == 'turn_off') { 32 | return { 33 | ...action, 34 | service: 'homeassistant' + '.' + computeEntity(action.service), 35 | icon: computeEntity(action.service) == 'turn_on' ? 'flash' : 'flash-off', 36 | }; 37 | } 38 | return action; 39 | }); 40 | }); 41 | } 42 | if (!entityActions.length) return []; 43 | const commonActions = computeCommonActions(entityActions); 44 | return commonActions; 45 | } 46 | 47 | export const groupStates = (_hass: HomeAssistant, _stateObj: HassEntity, entityStates: Variable[]): Variable | null => { 48 | if (!entityStates.length) return null; 49 | if (!entityStates.every(e => e.type == entityStates[0].type)) return null; 50 | if (entityStates[0].type == EVariableType.List) return listVariable(...(entityStates as ListVariable[])); 51 | else if (entityStates[0].type == EVariableType.Level) return levelVariable(...(entityStates as LevelVariable[])); 52 | else return null; 53 | }; 54 | -------------------------------------------------------------------------------- /src/standard-configuration/group_name.ts: -------------------------------------------------------------------------------- 1 | import { HomeAssistant } from 'custom-card-helpers'; 2 | import { getLocale } from '../helpers'; 3 | import { localize } from '../localize/localize'; 4 | 5 | const domainNames: Record = { 6 | alarm_control_panel: 'domains.alarm_control_panel', 7 | automation: 'ui.dialogs.quick-bar.commands.navigation.automation', 8 | binary_sensor: 'domains.binary_sensor', 9 | calendar: 'panel.calendar', 10 | climate: 'domains.climate', 11 | cover: 'domains.cover', 12 | fan: 'domains.fan', 13 | group: 'domains.group', 14 | humidifier: 'domains.humidifier', 15 | input_boolean: 'domains.input_boolean', 16 | input_number: 'domains.input_number', 17 | input_select: 'domains.input_select', 18 | lawn_mower: 'domains.lawn_mower', 19 | light: 'domains.light', 20 | lock: 'domains.lock', 21 | media_player: 'domains.media_player', 22 | notify: 'domains.notify', 23 | person: 'ui.dialogs.quick-bar.commands.navigation.person', 24 | scene: 'ui.dialogs.quick-bar.commands.navigation.scene', 25 | script: 'ui.dialogs.quick-bar.commands.navigation.script', 26 | sensor: 'ui.panel.config.devices.entities.sensor', 27 | sun: 'ui.panel.config.automation.editor.conditions.type.sun.label', 28 | switch: 'domains.switch', 29 | vacuum: 'domains.vacuum', 30 | water_heater: 'domains.water_heater', 31 | }; 32 | 33 | export const standardGroupNames = (domain: string, hass: HomeAssistant) => { 34 | if (domain in domainNames) { 35 | const translationKey = domainNames[domain]; 36 | const domainTranslation = translationKey.startsWith('domains') 37 | ? localize(translationKey, getLocale(hass)) 38 | : hass.localize(translationKey); 39 | 40 | if (domainTranslation) return domainTranslation; 41 | } 42 | return domain; 43 | }; 44 | -------------------------------------------------------------------------------- /src/standard-configuration/standardActions.ts: -------------------------------------------------------------------------------- 1 | import { computeDomain, computeEntity, HomeAssistant } from 'custom-card-helpers'; 2 | import { DefaultActionIcon } from '../const'; 3 | import { isDefined, pick } from '../helpers'; 4 | import { Action, EVariableType, ListVariable } from '../types'; 5 | import { parseVariable, VariableConfig } from './variables'; 6 | import { ActionItem, actionList } from './actions'; 7 | import { HassEntity } from 'home-assistant-js-websocket'; 8 | import { levelVariable } from '../data/variables/level_variable'; 9 | import { listVariable } from '../data/variables/list_variable'; 10 | import { textVariable } from '../data/variables/text_variable'; 11 | import { actionName } from './action_name'; 12 | import { actionIcon } from './action_icons'; 13 | import { getVariableName } from './variable_name'; 14 | import { getVariableOptionName, getVariableOptions } from './variable_options'; 15 | import { getVariableOptionIcon } from './variable_icons'; 16 | import { listAttribute } from './attribute'; 17 | import { groupActions } from './group'; 18 | 19 | export const standardActions = (entity_id: string, hass: HomeAssistant, filterCapabilities = true): Action[] => { 20 | const domain = computeDomain(entity_id); 21 | 22 | if (domain == 'group') { 23 | const stateObj = hass.states[entity_id]; 24 | const subEntities = listAttribute(stateObj, 'entity_id'); 25 | if (!subEntities.length) return []; 26 | 27 | const subActions = subEntities.map(e => standardActions(e, hass, filterCapabilities)); 28 | return groupActions(hass, stateObj, subActions); 29 | } 30 | 31 | //not supported by standard configuration 32 | if (!Object.keys(actionList).includes(domain)) return []; 33 | 34 | return Object.entries(actionList[domain]) 35 | .map(([id, config]) => parseAction(id, config, entity_id, hass, filterCapabilities)) 36 | .filter(isDefined); 37 | }; 38 | 39 | const parseAction = ( 40 | id: string, 41 | config: ActionItem, 42 | entity_id: string, 43 | hass: HomeAssistant, 44 | filterCapabilities: boolean 45 | ): Action | undefined => { 46 | const domain = computeDomain(entity_id); 47 | const stateObj = hass.states[entity_id]; 48 | if (config.condition && !config.condition(stateObj)) return; 49 | 50 | if (id.startsWith('_')) id = id = id.substring(1); 51 | 52 | let action: Action = { 53 | name: '', 54 | icon: DefaultActionIcon, 55 | service: config.service ? `${domain}.${config.service}` : `${domain}.${id}`, 56 | service_data: config.service_data, 57 | }; 58 | 59 | if (config.supported_feature) { 60 | const supportedFeature = 61 | config.supported_feature instanceof Function ? config.supported_feature(stateObj) : config.supported_feature; 62 | action = { 63 | ...action, 64 | supported_feature: supportedFeature, 65 | }; 66 | } 67 | 68 | action = { 69 | ...action, 70 | name: actionName(domain, id, hass), 71 | icon: actionIcon(domain, id, stateObj), 72 | }; 73 | 74 | Object.keys(config.variables || {}).forEach(key => { 75 | action = { 76 | ...action, 77 | variables: { 78 | ...action.variables, 79 | [key]: parseActionVariable(domain, key, config.variables![key], stateObj, hass, filterCapabilities), 80 | }, 81 | }; 82 | }); 83 | 84 | //strip actions having no selectable options 85 | if ( 86 | Object.values(action.variables || {}).some(e => e.type == EVariableType.List && !(e as ListVariable).options.length) 87 | ) 88 | return; 89 | 90 | //insert entity ID for services notify / script 91 | const match = action.service.match(/^[a-z_]+\.(\{entity_id\})$/); 92 | if (match) 93 | action = { 94 | ...action, 95 | service: action.service.replace(match[1], computeEntity(entity_id)), 96 | }; 97 | 98 | return action; 99 | }; 100 | 101 | const parseActionVariable = ( 102 | domain: string, 103 | variable: string, 104 | variableConfig: VariableConfig, 105 | stateObj: HassEntity | undefined, 106 | hass: HomeAssistant, 107 | filterCapabilities: boolean 108 | ) => { 109 | let config = parseVariable(variableConfig, stateObj, hass); 110 | config = { ...config, name: getVariableName(domain, variable, hass) }; 111 | if ('options' in config && isDefined(config.options)) { 112 | let options = [...config.options]; 113 | if (!filterCapabilities) { 114 | const extraOptions = getVariableOptions(domain, variable).filter(k => !options.map(e => e.value).includes(k)); 115 | options = [...options, ...extraOptions.map(e => Object({ value: e }))]; 116 | } 117 | options = options.map(e => 118 | Object.assign(e, { 119 | name: e.name ? e.name : getVariableOptionName(domain, variable, e.value, hass), 120 | icon: e.icon ? e.icon : getVariableOptionIcon(domain, variable, e.value), 121 | }) 122 | ); 123 | config = { ...config, options: options }; 124 | return listVariable(config); 125 | } else if ('min' in config && isDefined(config.min) && 'max' in config && isDefined(config.max)) { 126 | return levelVariable(config); 127 | } else { 128 | return textVariable(config); 129 | } 130 | }; 131 | -------------------------------------------------------------------------------- /src/standard-configuration/standardIcon.ts: -------------------------------------------------------------------------------- 1 | import { computeDomain, HomeAssistant, stateIcon } from 'custom-card-helpers'; 2 | import { HassEntity } from 'home-assistant-js-websocket'; 3 | 4 | export const binarySensorIcon = (stateObj: HassEntity) => { 5 | if (stateObj) return stateIcon({ ...stateObj, state: 'off' }) || 'mdi:radiobox-blank'; 6 | else return 'mdi:radiobox-blank'; 7 | }; 8 | 9 | export const sensorIcon = (stateObj: HassEntity) => { 10 | const deviceClass = stateObj.attributes.device_class || ''; 11 | switch (deviceClass) { 12 | case 'humidity': 13 | return 'mdi:water-percent'; 14 | case 'illuminance': 15 | return 'mdi:brightness-5'; 16 | case 'temperature': 17 | return 'mdi:thermometer'; 18 | case 'power': 19 | return 'mdi:flash'; 20 | case 'pressure': 21 | return 'mdi:gauge'; 22 | case 'signal_strength': 23 | return 'mdi:wifi'; 24 | default: 25 | return stateObj.attributes.unit_of_measurement?.includes('°') ? 'mdi:thermometer' : 'mdi:eye'; 26 | } 27 | }; 28 | 29 | const coverIcon = (stateObj: HassEntity, state?: string) => { 30 | const closedState = state == 'closed'; 31 | switch (stateObj.attributes.device_class) { 32 | case 'garage': 33 | return closedState ? 'mdi:garage' : 'mdi:garage-open'; 34 | case 'door': 35 | return closedState ? 'mdi:door-closed' : 'mdi:door-open'; 36 | case 'blind': 37 | return closedState ? 'mdi:blinds' : 'mdi:blinds-open'; 38 | case 'window': 39 | return closedState ? 'mdi:window-closed' : 'mdi:window-open'; 40 | default: 41 | return closedState ? 'mdi:window-shutter' : 'mdi:window-shutter-open'; 42 | } 43 | }; 44 | 45 | export const domainIcons: Record = { 46 | alarm_control_panel: 'mdi:alarm-light-outline', 47 | automation: 'mdi:playlist-play', 48 | binary_sensor: 'mdi:radiobox-blank', 49 | button: 'mdi:gesture-tap-button', 50 | calendar: 'mdi:calendar', 51 | camera: 'mdi:camera', 52 | climate: 'mdi:home-thermometer-outline', 53 | cover: 'mdi:window-shutter', 54 | device_tracker: 'mdi:account', 55 | fan: 'mdi:fan', 56 | group: 'mdi:google-circles-communities', 57 | humidifier: 'mdi:air-humidifier', 58 | input_boolean: 'mdi:drawing', 59 | input_button: 'mdi:gesture-tap-button', 60 | input_number: 'mdi:ray-vertex', 61 | input_select: 'mdi:format-list-bulleted', 62 | select: 'mdi:format-list-bulleted', 63 | input_text: 'mdi:textbox', 64 | lawn_mower: 'mdi:robot-mower', 65 | light: 'mdi:lightbulb-outline', 66 | lock: 'mdi:lock-open-outline', 67 | media_player: 'mdi:cast-connected', 68 | number: 'mdi:ray-vertex', 69 | notify: 'mdi:message-text-outline', 70 | person: 'mdi:account-outline', 71 | proximity: 'mdi:map-marker-distance', 72 | remote: 'mdi:remote', 73 | scene: 'mdi:palette-outline', 74 | script: 'mdi:file-document', 75 | sensor: 'mdi:eye', 76 | sun: 'mdi:white-balance-sunny', 77 | switch: 'mdi:flash', 78 | timer: 'mdi:timer', 79 | vacuum: 'mdi:robot-vacuum', 80 | water_heater: 'mdi:water-boiler', 81 | }; 82 | 83 | export const standardIcon = (entity_id: string, hass: HomeAssistant) => { 84 | const domain = computeDomain(entity_id); 85 | const stateObj = hass.states[entity_id]; 86 | 87 | switch (domain) { 88 | case 'binary_sensor': 89 | return binarySensorIcon(stateObj); 90 | case 'cover': 91 | return coverIcon(stateObj); 92 | case 'sensor': 93 | return sensorIcon(stateObj); 94 | default: 95 | if (domain in domainIcons) return domainIcons[domain]; 96 | return 'mdi:folder-outline'; 97 | } 98 | }; 99 | -------------------------------------------------------------------------------- /src/standard-configuration/standardStates.ts: -------------------------------------------------------------------------------- 1 | import { computeDomain, computeStateDisplay, HomeAssistant } from 'custom-card-helpers'; 2 | import { HassEntity } from 'home-assistant-js-websocket'; 3 | import { DefaultActionIcon } from '../const'; 4 | import { levelVariable } from '../data/variables/level_variable'; 5 | import { listVariable } from '../data/variables/list_variable'; 6 | import { textVariable } from '../data/variables/text_variable'; 7 | import { getLocale, isDefined } from '../helpers'; 8 | import { localize } from '../localize/localize'; 9 | import { Variable } from '../types'; 10 | import { listAttribute } from './attribute'; 11 | import { groupStates } from './group'; 12 | import { statesList } from './states'; 13 | import { stateIcon } from './state_icons'; 14 | import { parseVariable } from './variables'; 15 | 16 | export function standardStates(entity_id: string, hass: HomeAssistant): Variable | null { 17 | const domain = computeDomain(entity_id); 18 | const stateObj: HassEntity | undefined = hass.states[entity_id]; 19 | if (!stateObj) return null; 20 | 21 | if (domain == 'group') { 22 | const stateObj = hass.states[entity_id]; 23 | const subEntities = listAttribute(stateObj, 'entity_id'); 24 | if (!subEntities.length) return null; 25 | const subStates = subEntities.map(e => standardStates(e, hass)); 26 | return subStates.every(isDefined) ? groupStates(hass, stateObj, subStates as Variable[]) : null; 27 | } 28 | 29 | if (!Object.keys(statesList).includes(domain)) return null; 30 | 31 | let stateConfig = parseVariable(statesList[domain], stateObj, hass); 32 | 33 | if ('options' in stateConfig && isDefined(stateConfig.options)) { 34 | let options = [...stateConfig.options]; 35 | options = options.map(e => 36 | Object.assign(e, { 37 | icon: e.icon ? e.icon : stateIcon(stateObj, e.value, hass, DefaultActionIcon), 38 | name: e.name ? e.name : getStateName(stateObj, e.value, hass), 39 | }) 40 | ); 41 | stateConfig = { ...stateConfig, options: options }; 42 | if (!options.length) return null; 43 | return listVariable(stateConfig); 44 | } else if ('min' in stateConfig && isDefined(stateConfig.min) && 'max' in stateConfig && isDefined(stateConfig.max)) { 45 | return levelVariable(stateConfig); 46 | } else { 47 | return textVariable(stateConfig); 48 | } 49 | } 50 | 51 | const getStateName = (stateObj: HassEntity, state: string, hass: HomeAssistant) => { 52 | const domain = computeDomain(stateObj.entity_id); 53 | return ( 54 | (stateObj.attributes.device_class && 55 | hass.localize(`component.${domain}.entity_component._.${stateObj.attributes.device_class}.state.${state}`)) || 56 | hass.localize(`component.${domain}.entity_component._.state.${state}`) || 57 | state 58 | ); 59 | }; 60 | -------------------------------------------------------------------------------- /src/standard-configuration/state_icons.ts: -------------------------------------------------------------------------------- 1 | import { computeDomain, computeEntity, HomeAssistant, stateIcon as HaStateIcon } from 'custom-card-helpers'; 2 | import { HassEntity } from 'home-assistant-js-websocket'; 3 | import { DefaultEntityIcon } from '../const'; 4 | 5 | type Template = (stateObj: HassEntity, state: string, hass: HomeAssistant) => string; 6 | type IconItem = string | Template; 7 | type IconList = Record | Template>; 8 | 9 | const binarySensorIcon = (stateObj: HassEntity, state: string) => { 10 | return HaStateIcon({ ...stateObj, state: state }); 11 | }; 12 | 13 | const coverIcon = (stateObj: HassEntity, state: string) => { 14 | const closedState = state == 'closed'; 15 | switch (stateObj.attributes.device_class) { 16 | case 'garage': 17 | return closedState ? 'mdi:garage' : 'mdi:garage-open'; 18 | case 'door': 19 | return closedState ? 'mdi:door-closed' : 'mdi:door-open'; 20 | case 'blind': 21 | return closedState ? 'mdi:blinds' : 'mdi:blinds-open'; 22 | case 'window': 23 | return closedState ? 'mdi:window-closed' : 'mdi:window-open'; 24 | default: 25 | return closedState ? 'mdi:window-shutter' : 'mdi:window-shutter-open'; 26 | } 27 | }; 28 | 29 | const personIcon = (_stateObj: HassEntity, state: string, hass: HomeAssistant) => { 30 | const stateIcons: Record = { 31 | home: 'mdi:home-outline', 32 | not_home: 'mdi:exit-run', 33 | }; 34 | 35 | Object.keys(hass.states) 36 | .filter(e => computeDomain(e) == 'zone') 37 | .forEach(e => { 38 | const name = computeEntity(e); 39 | const icon = hass.states[e].attributes.icon; 40 | if (!icon) return; 41 | stateIcons[name] = icon; 42 | }); 43 | 44 | return state in stateIcons ? stateIcons[state] : 'mdi:flash'; 45 | }; 46 | 47 | export const stateIcons: IconList = { 48 | alarm_control_panel: { 49 | disarmed: 'mdi:lock-open-variant-outline', 50 | armed_away: 'mdi:exit-run', 51 | armed_home: 'mdi:home-outline', 52 | armed_night: 'mdi:power-sleep', 53 | triggered: 'mdi:alarm-light-outline', 54 | }, 55 | binary_sensor: { 56 | on: binarySensorIcon, 57 | off: binarySensorIcon, 58 | }, 59 | calendar: { 60 | on: 'mdi:flash', 61 | off: 'mdi:flash-off', 62 | }, 63 | climate: { 64 | off: 'mdi:power-off', 65 | heat: 'mdi:fire', 66 | cool: 'mdi:snowflake', 67 | heat_cool: 'mdi:thermometer', 68 | auto: 'mdi:autorenew', 69 | dry: 'mdi:water-percent', 70 | fan_only: 'mdi:fan', 71 | }, 72 | cover: { 73 | closed: coverIcon, 74 | open: coverIcon, 75 | }, 76 | device_tracker: { 77 | home: 'mdi:home-outline', 78 | not_home: 'mdi:exit-run', 79 | }, 80 | fan: { 81 | on: 'mdi:power', 82 | off: 'mdi:power-off', 83 | }, 84 | humidifier: { 85 | on: 'mdi:power', 86 | off: 'mdi:power-off', 87 | }, 88 | input_boolean: { 89 | on: 'mdi:flash', 90 | off: 'mdi:flash-off', 91 | }, 92 | light: { 93 | on: 'mdi:lightbulb', 94 | off: 'mdi:lightbulb-off', 95 | }, 96 | lock: { 97 | unlocked: 'mdi:lock-open-variant-outline', 98 | locked: 'mdi:lock-outline', 99 | }, 100 | person: personIcon, 101 | sensor: { 102 | unit: 'attributes.unit_of_measurement', 103 | }, 104 | sun: { 105 | below_horizon: 'mdi:weather-sunny-off', 106 | above_horizon: 'mdi:weather-sunny', 107 | }, 108 | switch: { 109 | on: 'mdi:flash', 110 | off: 'mdi:flash-off', 111 | }, 112 | timer: { 113 | active: 'mdi:play', 114 | paused: 'mdi:pause', 115 | idle: 'mdi:sleep', 116 | }, 117 | }; 118 | 119 | export const stateIcon = (stateObj: HassEntity, state: string | undefined, hass: HomeAssistant, fallback?: string) => { 120 | const domain = computeDomain(stateObj.entity_id); 121 | if (!state) state = stateObj.state; 122 | 123 | if (domain in stateIcons) { 124 | if (state in stateIcons[domain]) { 125 | const entry = stateIcons[domain][state]; 126 | return typeof entry == 'string' ? entry : entry(stateObj, state, hass); 127 | } else if (typeof stateIcons[domain] == 'function') { 128 | return (stateIcons[domain] as Template)(stateObj, state, hass); 129 | } 130 | } 131 | return fallback || DefaultEntityIcon; 132 | }; 133 | -------------------------------------------------------------------------------- /src/standard-configuration/states.ts: -------------------------------------------------------------------------------- 1 | import { computeDomain, computeEntity } from 'custom-card-helpers'; 2 | import { numericAttribute, stringAttribute } from './attribute'; 3 | import { VariableConfig } from './variables'; 4 | 5 | const onOffType = { options: ['on', 'off'] }; 6 | 7 | export const statesList: Record = { 8 | alarm_control_panel: { 9 | template: stateObj => { 10 | let modes = ['disarmed', 'triggered']; 11 | const supported = numericAttribute(stateObj, 'supported_features') || 0; 12 | if (supported & 2) modes = [...modes, 'armed_away']; 13 | if (supported & 1) modes = [...modes, 'armed_home']; 14 | if (supported & 4) modes = [...modes, 'armed_night']; 15 | if (supported & 16) modes = [...modes, 'armed_custom_bypass']; 16 | if (supported & 32) modes = [...modes, 'armed_vacation']; 17 | return { options: modes }; 18 | }, 19 | }, 20 | binary_sensor: onOffType, 21 | climate: { 22 | options: 'hvac_modes', 23 | }, 24 | calendar: onOffType, 25 | cover: { options: ['open', 'closed'] }, 26 | device_tracker: { options: ['home', 'not_home'] }, 27 | fan: onOffType, 28 | humidifier: onOffType, 29 | input_boolean: onOffType, 30 | input_number: { 31 | min: 'min', 32 | max: 'max', 33 | unit: 'unit_of_measurement', 34 | step: 'step', 35 | }, 36 | input_select: { 37 | options: 'options', 38 | }, 39 | light: onOffType, 40 | lock: { options: ['locked', 'unlocked'] }, 41 | number: { 42 | min: 'min', 43 | max: 'max', 44 | step: 'step', 45 | }, 46 | person: { 47 | template: (_stateObj, hass) => { 48 | const modes = ['home', 'not_home']; 49 | const zones = Object.keys(hass.states) 50 | .filter(e => computeDomain(e) == 'zone') 51 | .map(computeEntity); 52 | return { options: [...new Set([...modes, ...zones])] }; 53 | }, 54 | }, 55 | proximity: { 56 | unit: 'unit_of_measurement', 57 | }, 58 | select: { 59 | options: 'options', 60 | }, 61 | sensor: { 62 | template: stateObj => 63 | stateObj && !isNaN(Number(stateObj.state)) 64 | ? stringAttribute(stateObj, 'unit_of_measurement') == '%' 65 | ? { 66 | min: 0, 67 | max: 100, 68 | unit: '%', 69 | step: 1, 70 | } 71 | : { unit: 'unit_of_measurement' } 72 | : {}, 73 | }, 74 | sun: { options: ['below_horizon', 'above_horizon'] }, 75 | switch: onOffType, 76 | timer: { 77 | options: ['active', 'paused', 'idle'], 78 | }, 79 | water_heater: { 80 | options: 'operation_list', 81 | }, 82 | }; 83 | -------------------------------------------------------------------------------- /src/standard-configuration/variable_icons.ts: -------------------------------------------------------------------------------- 1 | type IconList = Record>>; 2 | 3 | const actionIcons: IconList = { 4 | climate: { 5 | hvac_mode: { 6 | off: 'mdi:power-off', 7 | heat: 'mdi:fire', 8 | cool: 'mdi:snowflake', 9 | heat_cool: 'mdi:thermometer', 10 | auto: 'mdi:autorenew', 11 | dry: 'mdi:water-percent', 12 | fan_only: 'mdi:fan', 13 | }, 14 | preset_mode: { 15 | activity: 'mdi:account-alert-outline', 16 | away: 'mdi:car-traction-control', 17 | boost: 'mdi:rocket-launch-outline', 18 | comfort: 'mdi:car-seat-cooler', 19 | eco: 'mdi:leaf', 20 | home: 'mdi:home-outline', 21 | none: 'mdi:cancel', 22 | sleep: 'mdi:sleep', 23 | }, 24 | }, 25 | fan: { 26 | direction: { 27 | forward: 'mdi:autorenew', 28 | reverse: 'mdi:sync', 29 | }, 30 | oscillating: { 31 | True: 'mdi:toggle-switch-outline', 32 | False: 'mdi:toggle-switch-off-outline', 33 | }, 34 | }, 35 | humidifier: { 36 | mode: { 37 | auto: 'mdi:autorenew', 38 | away: 'mdi:car-traction-control', 39 | baby: 'mdi:baby-bottle-outline', 40 | boost: 'mdi:rocket-launch-outline', 41 | comfort: 'mdi:car-seat-cooler', 42 | eco: 'mdi:leaf', 43 | home: 'mdi:home-outline', 44 | normal: 'mdi:account-outline', 45 | sleep: 'mdi:sleep', 46 | }, 47 | }, 48 | water_heater: { 49 | operation_mode: { 50 | off: 'mdi:power-off', 51 | eco: 'mdi:leaf', 52 | electric: 'mdi:lightning-bolt', 53 | gas: 'mdi:fire', 54 | heat_pump: 'mdi:hvac', 55 | high_demand: 'mdi:water-plus-outline', 56 | performance: 'mdi:rocket-launch-outline', 57 | }, 58 | away_mode: { 59 | on: 'mdi:toggle-switch-outline', 60 | off: 'mdi:toggle-switch-off-outline', 61 | }, 62 | }, 63 | }; 64 | 65 | export const getVariableOptionIcon = (domain: string, variable: string, option: string) => { 66 | if (domain in actionIcons && variable in actionIcons[domain] && option in actionIcons[domain][variable]) 67 | return actionIcons[domain][variable][option]; 68 | return; 69 | }; 70 | -------------------------------------------------------------------------------- /src/standard-configuration/variable_name.ts: -------------------------------------------------------------------------------- 1 | import { HomeAssistant } from 'custom-card-helpers'; 2 | 3 | const variableList: Record> = { 4 | climate: { 5 | temperature: 'ui.card.weather.attributes.temperature', 6 | target_temp_low: 'ui.panel.lovelace.editor.card.generic.minimum', 7 | target_temp_high: 'ui.panel.lovelace.editor.card.generic.maximum', 8 | hvac_mode: 'ui.card.climate.operation', 9 | preset_mode: 'ui.card.climate.preset_mode', 10 | fan_mode: 'ui.card.climate.fan_mode', 11 | }, 12 | cover: { 13 | position: 'ui.card.cover.position', 14 | tilt_position: 'ui.card.cover.tilt_position', 15 | }, 16 | fan: { 17 | percentage: 'ui.card.fan.speed', 18 | oscillating: 'ui.card.fan.oscillate', 19 | direction: 'ui.card.fan.direction', 20 | preset_mode: 'ui.card.fan.preset_mode', 21 | }, 22 | humidifier: { 23 | humidity: 'ui.card.humidifier.humidity', 24 | mode: 'ui.card.humidifier.mode', 25 | }, 26 | input_number: { 27 | value: 'ui.panel.config.helpers.types.input_number', 28 | }, 29 | input_select: { 30 | option: 'ui.components.dialogs.input_select.options', 31 | }, 32 | light: { 33 | brightness: 'ui.card.light.brightness', 34 | }, 35 | media_player: { 36 | source: 'ui.card.media_player.source', 37 | }, 38 | notify: { 39 | title: 'ui.panel.config.automation.editor.actions.type.device_id.extra_fields.title', 40 | message: 'ui.panel.config.automation.editor.actions.type.device_id.extra_fields.message', 41 | }, 42 | number: { 43 | value: 'ui.panel.config.helpers.types.input_number', 44 | }, 45 | select: { 46 | option: 'ui.components.dialogs.input_select.options', 47 | }, 48 | water_heater: { 49 | temperature: 'ui.card.weather.attributes.temperature', 50 | operation_mode: 'ui.card.water_heater.operation', 51 | away_mode: 'ui.card.water_heater.away_mode', 52 | }, 53 | }; 54 | 55 | export const getVariableName = (domain: string, variable: string, hass: HomeAssistant) => { 56 | if (domain in variableList && variable in variableList[domain]) { 57 | return hass.localize(variableList[domain][variable]); 58 | } 59 | return variable; 60 | }; 61 | -------------------------------------------------------------------------------- /src/standard-configuration/variable_options.ts: -------------------------------------------------------------------------------- 1 | import { HomeAssistant } from 'custom-card-helpers'; 2 | 3 | type IconList = Record>>; 4 | 5 | export const variableOptions: IconList = { 6 | climate: { 7 | hvac_mode: { 8 | off: 'component.climate.entity_component._.state.off', 9 | heat: 'component.climate.entity_component._.state.heat', 10 | cool: 'component.climate.entity_component._.state.cool', 11 | heat_cool: 'component.climate.entity_component._.state.heat_cool', 12 | dry: 'component.climate.entity_component._.state.dry', 13 | fan_only: 'component.climate.entity_component._.state.fan_only', 14 | }, 15 | preset_mode: { 16 | activity: 'state_attributes.climate.preset_mode.activity', 17 | away: 'state_attributes.climate.preset_mode.away', 18 | boost: 'state_attributes.climate.preset_mode.boost', 19 | comfort: 'state_attributes.climate.preset_mode.comfort', 20 | eco: 'state_attributes.climate.preset_mode.eco', 21 | home: 'state_attributes.climate.preset_mode.home', 22 | none: 'state_attributes.climate.preset_mode.none', 23 | sleep: 'state_attributes.climate.preset_mode.sleep', 24 | }, 25 | }, 26 | fan: { 27 | direction: { 28 | forward: 'ui.card.fan.forward', 29 | reverse: 'ui.card.fan.reverse', 30 | }, 31 | oscillating: { 32 | True: 'state.default.on', 33 | False: 'state.default.off', 34 | }, 35 | }, 36 | humidifier: { 37 | mode: { 38 | auto: 'state_attributes.humidifier.mode.auto', 39 | away: 'state_attributes.humidifier.mode.away', 40 | baby: 'state_attributes.humidifier.mode.baby', 41 | boost: 'state_attributes.humidifier.mode.boost', 42 | comfort: 'state_attributes.humidifier.mode.comfort', 43 | eco: 'state_attributes.humidifier.mode.eco', 44 | home: 'state_attributes.humidifier.mode.home', 45 | normal: 'state_attributes.humidifier.mode.normal', 46 | sleep: 'state_attributes.humidifier.mode.sleep', 47 | }, 48 | }, 49 | water_heater: { 50 | operation_mode: { 51 | off: 'component.water_heater.entity_component._.state.off', 52 | eco: 'component.water_heater.entity_component._.state.eco', 53 | electric: 'component.water_heater.entity_component._.state.electric', 54 | gas: 'component.water_heater.entity_component._.state.gas', 55 | heat_pump: 'component.water_heater.entity_component._.state.heat_pump', 56 | high_demand: 'component.water_heater.entity_component._.state.high_demand', 57 | performance: 'component.water_heater.entity_component._.state.performance', 58 | }, 59 | away_mode: { 60 | on: 'state.default.on', 61 | off: 'state.default.off', 62 | }, 63 | }, 64 | }; 65 | 66 | export const getVariableOptions = (domain: string, variable: string) => { 67 | if (domain in variableOptions && variable in variableOptions[domain]) 68 | return Object.keys(variableOptions[domain][variable]); 69 | return []; 70 | }; 71 | 72 | export const getVariableOptionName = (domain: string, variable: string, option: string, hass: HomeAssistant) => { 73 | if (domain in variableOptions && variable in variableOptions[domain] && option in variableOptions[domain][variable]) 74 | return hass.localize(variableOptions[domain][variable][option]); 75 | return option; 76 | }; 77 | -------------------------------------------------------------------------------- /src/standard-configuration/variables.ts: -------------------------------------------------------------------------------- 1 | import { HomeAssistant } from 'custom-card-helpers'; 2 | import { HassEntity } from 'home-assistant-js-websocket'; 3 | import { isDefined, omit, pick } from '../helpers'; 4 | import { AtLeast, LevelVariable, ListVariable, TextVariable } from '../types'; 5 | import { listAttribute, numericAttribute, stringAttribute } from './attribute'; 6 | 7 | type ListOption = { name: string; icon: string }; 8 | 9 | type ListVariableConfig = { 10 | options: string | string[] | Record>; 11 | }; 12 | 13 | type ListVariableTemplate = (stateObj: HassEntity | undefined, hass: HomeAssistant) => ListVariableConfig; 14 | 15 | export type ListVariableType = ListVariableConfig | { template: ListVariableTemplate }; 16 | 17 | type LevelVariableConfig = { 18 | min?: string | number; 19 | max?: string | number; 20 | step?: string | number; 21 | unit?: string; 22 | optional?: boolean; 23 | scale_factor?: number; 24 | }; 25 | 26 | type LevelVariableTemplate = (stateObj: HassEntity | undefined, hass: HomeAssistant) => LevelVariableConfig; 27 | 28 | export type LevelVariableType = LevelVariableConfig | { template: LevelVariableTemplate }; 29 | 30 | type TextVariableConfig = { 31 | multiline?: boolean; 32 | }; 33 | 34 | export type VariableConfig = ListVariableType | LevelVariableType | TextVariableConfig; 35 | 36 | export const parseVariable = ( 37 | config: VariableConfig, 38 | stateObj: HassEntity | undefined, 39 | hass: HomeAssistant 40 | ): Partial | AtLeast | Partial => { 41 | const res = 42 | 'template' in config && isDefined(config.template) 43 | ? { ...omit(config, 'template'), ...config.template(stateObj, hass) } 44 | : ({ ...config } as ListVariableConfig | LevelVariableConfig | TextVariableConfig); 45 | 46 | if ('options' in res) { 47 | return parseListVariable(res, stateObj); 48 | } else if ('min' in res && 'max' in res) { 49 | return parseLevelVariable(res, stateObj); 50 | } else { 51 | return res as TextVariableConfig; 52 | } 53 | }; 54 | 55 | export const parseListVariable = ( 56 | config: ListVariableConfig, 57 | stateObj: HassEntity | undefined 58 | ): AtLeast => { 59 | if (typeof config.options == 'string') { 60 | const res = listAttribute(stateObj, config.options); 61 | return { 62 | options: res.map(e => Object({ value: e })), 63 | }; 64 | } else if (Array.isArray(config.options)) { 65 | return { 66 | options: config.options.map(e => Object({ value: e })), 67 | }; 68 | } else { 69 | return { 70 | options: Object.entries(config.options).map(([k, v]) => Object({ value: k, ...v })), 71 | }; 72 | } 73 | }; 74 | 75 | export const parseLevelVariable = (config: LevelVariableConfig, stateObj: HassEntity | undefined) => { 76 | let result: Partial = pick(config, ['unit', 'optional', 'scale_factor']); 77 | if (isDefined(config.min)) result = { ...result, min: numericAttribute(stateObj, config.min) }; 78 | if (isDefined(config.max)) result = { ...result, max: numericAttribute(stateObj, config.max) }; 79 | if (isDefined(config.step)) result = { ...result, step: numericAttribute(stateObj, config.step) }; 80 | if (isDefined(config.unit) && config.unit == 'unit_of_measurement') 81 | result = { ...result, unit: stringAttribute(stateObj, config.unit, '') }; 82 | return result; 83 | }; 84 | -------------------------------------------------------------------------------- /src/styles.ts: -------------------------------------------------------------------------------- 1 | import { css } from 'lit'; 2 | 3 | export const commonStyle = css` 4 | .card-header { 5 | display: flex; 6 | justify-content: space-between; 7 | } 8 | .card-header .name { 9 | white-space: nowrap; 10 | overflow: hidden; 11 | text-overflow: ellipsis; 12 | display: flex; 13 | } 14 | .card-header ha-switch { 15 | padding: 5px; 16 | } 17 | .card-header ha-icon-button { 18 | position: absolute; 19 | right: 6px; 20 | top: 6px; 21 | } 22 | .card-content { 23 | flex: 1; 24 | } 25 | .card-content > *:first-child { 26 | margin-top: 0; 27 | } 28 | .card-content > *:last-child { 29 | margin-bottom: 0; 30 | } 31 | div.text-field, div.secondary { 32 | color: var(--secondary-text-color); 33 | } 34 | .disabled { 35 | color: var(--disabled-text-color); 36 | } 37 | div.header { 38 | color: var(--secondary-text-color); 39 | text-transform: uppercase; 40 | font-weight: 500; 41 | font-size: 12px; 42 | margin: 20px 0px 0px 0px; 43 | display: flex; 44 | flex-direction: row; 45 | } 46 | div.header .switch { 47 | text-transform: none; 48 | font-weight: normal; 49 | font-size: 14px; 50 | display: flex; 51 | flex-grow: 1; 52 | justify-content: flex-end; 53 | } 54 | div.header ha-switch { 55 | display: flex; 56 | align-self: center; 57 | margin: 0px 8px; 58 | line-height: 24px; 59 | } 60 | mwc-button.active { 61 | background: var(--primary-color); 62 | --mdc-theme-primary: var(--text-primary-color); 63 | border-radius: 4px; 64 | } 65 | mwc-button ha-icon { 66 | margin-right: 11px; 67 | } 68 | mwc-button.warning { 69 | --mdc-theme-primary: var(--error-color); 70 | } 71 | div.checkbox-container { 72 | display: grid; 73 | grid-template-columns: max-content 1fr max-content; 74 | grid-template-rows: min-content; 75 | grid-template-areas: "checkbox slider value"; 76 | grid-gap: 0px 10px; 77 | } 78 | div.checkbox-container div.checkbox { 79 | grid-area: checkbox; 80 | display: flex; 81 | align-items: center;x 82 | } 83 | div.checkbox-container div.slider { 84 | grid-area: slider; 85 | display: flex; 86 | align-items: center; 87 | } 88 | div.checkbox-container div.value { 89 | grid-area: value; 90 | min-width: 40px; 91 | display: flex; 92 | align-items: center; 93 | } 94 | a { 95 | color: var(--primary-color); 96 | } 97 | a:visited { 98 | color: var(--accent-color); 99 | } 100 | 101 | 102 | .content { 103 | padding: 0px 24px 16px 24px; 104 | } 105 | .buttons { 106 | box-sizing: border-box; 107 | display: flex; 108 | padding: 24px; 109 | padding-top: 16px; 110 | justify-content: space-between; 111 | padding-bottom: max(env(safe-area-inset-bottom), 24px); 112 | background-color: var(--mdc-theme-surface, #fff); 113 | border-top: 1px solid var(--divider-color); 114 | position: sticky; 115 | bottom: 0px; 116 | } 117 | .buttons.centered { 118 | flex-wrap: wrap; 119 | justify-content: center; 120 | } 121 | `; 122 | 123 | export const dialogStyle = css` 124 | ha-dialog { 125 | --mdc-dialog-min-width: 400px; 126 | --mdc-dialog-max-width: 600px; 127 | --mdc-dialog-heading-ink-color: var(--primary-text-color); 128 | --mdc-dialog-content-ink-color: var(--primary-text-color); 129 | --justify-action-buttons: space-between; 130 | --dialog-content-padding: 0px; 131 | } 132 | ha-dialog .form { 133 | color: var(--primary-text-color); 134 | } 135 | a { 136 | color: var(--primary-color); 137 | } 138 | /* make dialog fullscreen on small screens */ 139 | @media all and (max-width: 450px), all and (max-height: 500px) { 140 | ha-dialog { 141 | --mdc-dialog-min-width: calc(100vw - env(safe-area-inset-right) - env(safe-area-inset-left)); 142 | --mdc-dialog-max-width: calc(100vw - env(safe-area-inset-right) - env(safe-area-inset-left)); 143 | --mdc-dialog-min-height: 100%; 144 | --mdc-dialog-max-height: 100%; 145 | --vertical-align-dialog: flex-end; 146 | --ha-dialog-border-radius: 0px; 147 | } 148 | } 149 | mwc-button.warning { 150 | --mdc-theme-primary: var(--error-color); 151 | } 152 | .error { 153 | color: var(--error-color); 154 | } 155 | ha-dialog { 156 | --dialog-surface-position: static; 157 | --dialog-content-position: static; 158 | --vertical-align-dialog: flex-start; 159 | } 160 | .content { 161 | outline: none; 162 | } 163 | .heading { 164 | border-bottom: 1px solid var(--mdc-dialog-scroll-divider-color, rgba(0, 0, 0, 0.12)); 165 | } 166 | :host([tab='time']) ha-dialog { 167 | --dialog-content-padding: 0px; 168 | } 169 | @media all and (min-width: 600px) and (min-height: 501px) { 170 | ha-dialog { 171 | --mdc-dialog-min-width: 560px; 172 | --mdc-dialog-max-width: 580px; 173 | --dialog-surface-margin-top: 40px; 174 | --mdc-dialog-max-height: calc(100% - 72px); 175 | } 176 | :host([large]) ha-dialog { 177 | --mdc-dialog-min-width: 90vw; 178 | --mdc-dialog-max-width: 90vw; 179 | } 180 | } 181 | mwc-tab[disabled] { 182 | --mdc-tab-text-label-color-default: var(--material-disabled-text-color); 183 | pointer-events: none; 184 | } 185 | `; 186 | -------------------------------------------------------------------------------- /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 | "allowSyntheticDefaultImports": true 21 | } 22 | } --------------------------------------------------------------------------------