├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── question.md ├── dependabot.yml ├── release-config.yml └── workflows │ ├── continous-integration.yml │ └── release-drafter.yml ├── .gitignore ├── .vscode └── launch.json ├── LICENSE ├── README.md ├── hacs.json ├── package-lock.json ├── package.json ├── rollup.config.js ├── src ├── action.ts ├── battery-provider.ts ├── colors.ts ├── custom-elements │ ├── battery-state-card.css │ ├── battery-state-card.ts │ ├── battery-state-card.views.ts │ ├── battery-state-entity.css │ ├── battery-state-entity.ts │ ├── battery-state-entity.views.ts │ ├── lovelace-card.ts │ └── shared.css ├── default-config.ts ├── entity-fields │ ├── battery-level.ts │ ├── charging-state.ts │ ├── get-icon.ts │ ├── get-name.ts │ └── get-secondary-info.ts ├── filter.ts ├── grouping.ts ├── index.ts ├── rich-string-processor.ts ├── sorting.ts ├── type-extensions.ts ├── typings.d.ts └── utils.ts ├── test ├── card │ ├── entity-list.test.ts │ ├── filters.test.ts │ ├── grouping.test.ts │ └── sorting.test.ts ├── entity │ ├── icon.test.ts │ ├── name.test.ts │ ├── secondary-info.test.ts │ └── state.test.ts ├── helpers.ts └── other │ ├── colors.test.ts │ ├── entity-fields │ ├── battery-level.test.ts │ ├── charging-state.test.ts │ ├── get-icon.test.ts │ ├── get-name.test.ts │ └── get-secondary-info.test.ts │ ├── filter.test.ts │ ├── rich-string-processor.test.ts │ └── sorting.test.ts └── tsconfig.json /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help improve the card 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | 12 | 13 | **How to reproduce** 14 | 15 | 16 | **Expected behavior** 17 | 18 | 19 | **YAML configuration** 20 | ```yaml 21 | 22 | # please paste here your card config (before backticks below; do not remove them) 23 | 24 | ``` 25 | 26 | ***Entity debug data*** 27 | 28 | 29 | ```json 30 | 31 | ``` 32 | 33 | **Dev console errors** 34 | 35 | 36 | **Screenshots** 37 | 38 | 39 | **Version** 40 | 41 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this card 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | 12 | 13 | **Describe the solution you'd like** 14 | 15 | 16 | **Additional context** 17 | 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question 3 | about: Ask for help if your card doesn't work as expected 4 | title: '' 5 | labels: question 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe what is wrong** 11 | 12 | **YAML configuration** 13 | ```yaml 14 | 15 | # paste your card configuration here 16 | 17 | ``` 18 | 19 | **Screenshot** 20 | 21 | 22 | **Version** 23 | 24 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | ignore: 9 | - dependency-name: rollup 10 | versions: 11 | - 2.38.3 12 | - 2.38.4 13 | - 2.38.5 14 | - 2.39.1 15 | - 2.40.0 16 | - 2.41.0 17 | - 2.41.1 18 | - 2.41.2 19 | - 2.41.3 20 | - 2.41.4 21 | - 2.41.5 22 | - 2.42.0 23 | - 2.42.2 24 | - 2.42.3 25 | - 2.43.1 26 | - 2.45.0 27 | - 2.45.1 28 | - dependency-name: adm-zip 29 | versions: 30 | - 0.5.4 31 | - dependency-name: typescript 32 | versions: 33 | - 4.1.4 34 | - 4.1.5 35 | - 4.2.2 36 | - dependency-name: "@rollup/plugin-typescript" 37 | versions: 38 | - 8.1.1 39 | - 8.2.0 40 | - dependency-name: "@rollup/plugin-node-resolve" 41 | versions: 42 | - 11.2.0 43 | -------------------------------------------------------------------------------- /.github/release-config.yml: -------------------------------------------------------------------------------- 1 | name-template: 'v$NEXT_MINOR_VERSION' 2 | tag-template: 'v$NEXT_MINOR_VERSION' 3 | categories: 4 | - title: '🚀 Features' 5 | labels: 6 | - 'feature' 7 | - 'enhancement' 8 | - title: '🐛 Bug Fixes' 9 | labels: 10 | - 'fix' 11 | - 'bugfix' 12 | - 'bug' 13 | - title: '🧰 Maintenance' 14 | labels: 15 | - 'chore' 16 | - 'documentation' 17 | - 'miscellaneous' 18 | exclude-labels: 19 | - 'dependencies' 20 | change-template: '- $TITLE #$NUMBER (@$AUTHOR)' 21 | change-title-escapes: '\<*_&' # You can add # and @ to disable mentions, and add ` to disable code blocks. 22 | version-resolver: 23 | major: 24 | labels: 25 | - 'major' 26 | minor: 27 | labels: 28 | - 'minor' 29 | patch: 30 | labels: 31 | - 'patch' 32 | default: patch 33 | template: | 34 | $CHANGES 35 | 36 | ![GitHub Releases (by Release)](https://img.shields.io/github/downloads/maxwroc/battery-state-card/v$NEXT_MINOR_VERSION/total) 37 | -------------------------------------------------------------------------------- /.github/workflows/continous-integration.yml: -------------------------------------------------------------------------------- 1 | name: Continous integration 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | # only PR for master 7 | branches: 8 | - master 9 | - next 10 | - vNext 11 | 12 | jobs: 13 | continous_integration: 14 | runs-on: macOS-latest 15 | steps: 16 | - name: Checkout files 17 | uses: actions/checkout@v2 18 | - name: NodeJS setup 19 | uses: actions/setup-node@v2 20 | with: 21 | node-version: 14 22 | registry-url: https://registry.npmjs.org/ 23 | - name: Installing dependencies 24 | run: npm ci 25 | - name: Build 26 | run: npm run build 27 | - name: Test 28 | run: npm run test+coverage 29 | - name: Coveralls 30 | uses: coverallsapp/github-action@v1.1.2 31 | with: 32 | github-token: ${{ secrets.GITHUB_TOKEN }} 33 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | # branches to consider in the event; optional, defaults to all 7 | branches: 8 | - master 9 | # pull_request event is required only for autolabeler 10 | # pull_request: 11 | # # Only following types are handled by the action, but one can default to all as well 12 | # types: [opened, reopened, synchronize] 13 | 14 | jobs: 15 | validate_release: 16 | runs-on: macOS-latest 17 | steps: 18 | # build files 19 | - name: Checkout files 20 | uses: actions/checkout@v2 21 | 22 | - name: NodeJS setup 23 | uses: actions/setup-node@v2 24 | with: 25 | node-version: 14 26 | registry-url: https://registry.npmjs.org/ 27 | 28 | - name: Installing dependencies 29 | run: npm ci 30 | 31 | - name: Build release 32 | run: npm run release 33 | 34 | - name: Test 35 | run: npm run test+coverage 36 | 37 | - name: Coveralls 38 | uses: coverallsapp/github-action@v1.1.2 39 | with: 40 | github-token: ${{ secrets.GITHUB_TOKEN }} 41 | 42 | update_release_draft: 43 | needs: validate_release 44 | runs-on: ubuntu-latest 45 | steps: 46 | - name: Checkout files 47 | uses: actions/checkout@v2 48 | 49 | - name: NodeJS setup 50 | uses: actions/setup-node@v2 51 | with: 52 | node-version: 14 53 | registry-url: https://registry.npmjs.org/ 54 | 55 | - name: Installing dependencies 56 | run: npm ci 57 | 58 | - name: Build release 59 | run: npm run release 60 | 61 | - name: Create release draft 62 | id: create_release 63 | uses: release-drafter/release-drafter@v5 64 | # (Optional) specify config name to use, relative to .github/. Default: release-drafter.yml 65 | with: 66 | config-name: release-config.yml 67 | disable-autolabeler: true 68 | env: 69 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 70 | 71 | - name: Upload release assets 72 | run: | 73 | find ./dist -type f -exec gh release upload --clobber ${{ env.VERSION }} {} + 74 | env: 75 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 76 | VERSION: ${{ steps.create_release.outputs.tag_name }} 77 | FILES: ./dist/* 78 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /coverage/ 2 | /node_modules/ 3 | /dist/ 4 | /src/styles.ts -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Run all tests in the file", 9 | "type": "node", 10 | "request": "launch", 11 | "runtimeExecutable": "npm", 12 | "runtimeArgs": [ 13 | "run-script", 14 | "test" 15 | ], 16 | "args": [ 17 | "--", 18 | "--runTestsByPath", 19 | "${relativeFile}" 20 | ], 21 | "console": "integratedTerminal", 22 | }, 23 | { 24 | "name": "Run all tests in the file (debug in electron)", 25 | "type": "node", 26 | "request": "launch", 27 | "runtimeExecutable": "npm", 28 | "runtimeArgs": [ 29 | "run-script", 30 | "test+debug" 31 | ], 32 | "args": [ 33 | "--", 34 | "--runTestsByPath", 35 | "${relativeFile}" 36 | ], 37 | "console": "integratedTerminal", 38 | }, 39 | { 40 | "type": "node", 41 | "request": "launch", 42 | "name": "Run selected test", 43 | "runtimeExecutable": "npm", 44 | "runtimeArgs": [ 45 | "run-script", 46 | "test" 47 | ], 48 | "args": [ 49 | "--", 50 | "--runTestsByPath", 51 | "${relativeFile}", 52 | "-t", 53 | "${selectedText}" 54 | ], 55 | "console": "integratedTerminal", 56 | }, 57 | { 58 | "type": "node", 59 | "request": "launch", 60 | "name": "Run selected test (debug in electron)", 61 | "runtimeExecutable": "npm", 62 | "runtimeArgs": [ 63 | "run-script", 64 | "test+debug" 65 | ], 66 | "args": [ 67 | "--", 68 | "--runTestsByPath", 69 | "${relativeFile}", 70 | "-t", 71 | "${selectedText}" 72 | ], 73 | "console": "integratedTerminal", 74 | } 75 | ] 76 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Max Chodorowski 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Battery State Card / Entity Row", 3 | "content_in_root": false, 4 | "filename": "battery-state-card.js", 5 | "render_readme": true 6 | } 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "battery-state-card", 3 | "version": "3.2.1", 4 | "description": "Battery State card for Home Assistant", 5 | "main": "dist/battery-state-card.js", 6 | "author": "Max Chodorowski", 7 | "license": "MIT", 8 | "keywords": [ 9 | "battery", 10 | "state", 11 | "home", 12 | "assistant", 13 | "lovelace" 14 | ], 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/maxwroc/battery-state-card.git" 18 | }, 19 | "scripts": { 20 | "build": "rollup -c", 21 | "release": "rollup --environment RELEASE -c", 22 | "watch": "rollup -c --watch", 23 | "test": "jest", 24 | "test+integration": "jest --testPathPattern=\"test/(entity|card)\"", 25 | "test+coverage": "jest --coverage", 26 | "test+coverage+unit": "jest --coverage --testPathPattern=test/other", 27 | "test+debug": "SET DEBUG_MODE=1&&jest" 28 | }, 29 | "jest": { 30 | "projects": [ 31 | { 32 | "preset": "ts-jest", 33 | "runner": "jest-electron/runner", 34 | "testEnvironment": "jest-electron/environment", 35 | "testMatch": [ 36 | "/test/card/**/*.test.ts", 37 | "/test/entity/**/*.test.ts" 38 | ], 39 | "verbose": false, 40 | "setupFilesAfterEnv": [ 41 | "/dist/battery-state-card.js" 42 | ], 43 | "globals": { 44 | "ts-jest": { 45 | "isolatedModules": true 46 | } 47 | }, 48 | "coveragePathIgnorePatterns": [ 49 | "/test" 50 | ] 51 | }, 52 | { 53 | "preset": "ts-jest", 54 | "testMatch": [ 55 | "/test/other/**/*.test.ts" 56 | ], 57 | "verbose": false, 58 | "globals": { 59 | "ts-jest": { 60 | "isolatedModules": true 61 | } 62 | }, 63 | "coveragePathIgnorePatterns": [ 64 | "/test" 65 | ] 66 | } 67 | ] 68 | }, 69 | "devDependencies": { 70 | "@rollup/plugin-node-resolve": "^15.2.3", 71 | "@rollup/plugin-typescript": "^11.1.5", 72 | "@types/jest": "^27.4.0", 73 | "jest": "^24.9.0", 74 | "jest-electron": "^0.1.12", 75 | "rollup": "^2.79.1", 76 | "rollup-plugin-import-css": "^3.3.5", 77 | "rollup-plugin-minify-html-literals": "^1.2.6", 78 | "rollup-plugin-serve": "^2.0.2", 79 | "rollup-plugin-terser": "^7.0.2", 80 | "rollup-plugin-version-injector": "^1.3.3", 81 | "ts-jest": "^24.3.0", 82 | "tslib": "^2.5.3", 83 | "typescript": "^5.2.2" 84 | }, 85 | "dependencies": { 86 | "custom-card-helpers": "^1.9.0", 87 | "lit": "^2.8.0" 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from '@rollup/plugin-typescript'; 2 | import resolve from '@rollup/plugin-node-resolve'; 3 | import cssImports from 'rollup-plugin-import-css'; 4 | import minifyHTML from 'rollup-plugin-minify-html-literals'; 5 | import serve from 'rollup-plugin-serve'; 6 | import { terser } from 'rollup-plugin-terser'; 7 | import versionInjector from 'rollup-plugin-version-injector'; 8 | import pkg from './package.json'; 9 | 10 | 11 | let targetFileName = pkg.main; 12 | 13 | const plugins = [ 14 | resolve(), 15 | minifyHTML(), 16 | cssImports(), 17 | versionInjector({ 18 | injectInComments: false, 19 | logLevel: 'warn', 20 | }) 21 | ]; 22 | 23 | if (process.env.ROLLUP_WATCH) { 24 | plugins.push(serve({ 25 | contentBase: ['./'], 26 | host: '0.0.0.0', 27 | port: 5501, 28 | allowCrossOrigin: true, 29 | headers: { 30 | 'Access-Control-Allow-Origin': '*', 31 | }, 32 | })); 33 | } 34 | 35 | plugins.push(typescript()); 36 | 37 | let sourcemapPathTransform = undefined; 38 | 39 | if (process.env.RELEASE) { 40 | plugins.push( 41 | terser({ 42 | compress: {} 43 | }) 44 | ); 45 | 46 | let repoRoot = pkg.repository.url 47 | .replace("https://github.com", "https://raw.githubusercontent.com") 48 | .replace(/.git$/, ""); 49 | repoRoot += "/"; 50 | 51 | sourcemapPathTransform = file => repoRoot + "v" + pkg.version + file.substr(2); 52 | } 53 | 54 | export default { 55 | external: [], 56 | input: 'src/index.ts', 57 | output: { 58 | globals: {}, 59 | file: targetFileName, 60 | format: 'iife', 61 | sourcemap: true, 62 | sourcemapExcludeSources: true, 63 | sourcemapPathTransform: sourcemapPathTransform 64 | }, 65 | plugins: plugins, 66 | } -------------------------------------------------------------------------------- /src/action.ts: -------------------------------------------------------------------------------- 1 | import { HomeAssistant } from "custom-card-helpers"; 2 | import { log } from "./utils"; 3 | 4 | const nameToFuncMap: { [key in SupportedActions]: (data: IActionData, hass: HomeAssistant) => void } = { 5 | 6 | "more-info": (data) => { 7 | const evt = new Event('hass-more-info', { composed: true }); 8 | evt.detail = { entityId: data.entityId }; 9 | data.card.dispatchEvent(evt); 10 | }, 11 | 12 | "navigate": (data) => { 13 | if (!data.config.navigation_path) { 14 | log("Missing 'navigation_path' for 'navigate' tap action"); 15 | return; 16 | } 17 | 18 | window.history.pushState(null, "", data.config.navigation_path); 19 | const evt = new Event("location-changed", { composed: true }); 20 | evt.detail = { replace: false }; 21 | window.dispatchEvent(evt); 22 | }, 23 | 24 | "call-service": (data, hass) => { 25 | if (!data.config.service) { 26 | log("Missing 'service' for 'call-service' tap action"); 27 | return; 28 | } 29 | 30 | const [domain, service] = data.config.service.split(".", 2); 31 | const serviceData = { ...data.config.service_data }; 32 | hass.callService(domain, service, serviceData); 33 | }, 34 | 35 | "url": data => { 36 | if (!data.config.url_path) { 37 | log("Missing 'url_path' for 'url' tap action"); 38 | return; 39 | } 40 | 41 | window.location.href = data.config.url_path; 42 | } 43 | } 44 | 45 | export const handleAction = (data: IActionData, hass: HomeAssistant): void => { 46 | if (!data.config || data.config.action == "none") { 47 | return; 48 | } 49 | 50 | if (!(data.config.action in nameToFuncMap)) { 51 | log("Unknown tap action type: " + data.config.action); 52 | return; 53 | } 54 | 55 | nameToFuncMap[data.config.action](data, hass); 56 | } -------------------------------------------------------------------------------- /src/battery-provider.ts: -------------------------------------------------------------------------------- 1 | import { log, safeGetConfigArrayOfObjects } from "./utils"; 2 | import { HomeAssistant } from "custom-card-helpers"; 3 | import { BatteryStateEntity } from "./custom-elements/battery-state-entity"; 4 | import { Filter } from "./filter"; 5 | import { EntityRegistryDisplayEntry, HomeAssistantExt } from "./type-extensions"; 6 | 7 | /** 8 | * Properties which should be copied over to individual entities from the card 9 | */ 10 | const entititesGlobalProps: (keyof IBatteryEntityConfig)[] = [ 11 | "bulk_rename", 12 | "charging_state", 13 | "colors", 14 | "debug", 15 | "default_state_formatting", 16 | "extend_entity_data", 17 | "icon", 18 | "non_battery_entity", 19 | "round", 20 | "secondary_info", 21 | "state_map", 22 | "tap_action", 23 | "value_override", 24 | "unit", 25 | ]; 26 | 27 | /** 28 | * Class responsible for intializing Battery view models based on given configuration. 29 | */ 30 | export class BatteryProvider { 31 | 32 | /** 33 | * Filters for automatic adding entities. 34 | */ 35 | private include: Filter[] | undefined; 36 | 37 | /** 38 | * Filters to remove entitites from collection. 39 | */ 40 | private exclude: Filter[] | undefined; 41 | 42 | /** 43 | * Collection of battery HTML elements. 44 | */ 45 | private batteries: IBatteryCollection = {}; 46 | 47 | /** 48 | * Groups to be resolved on HA state update. 49 | */ 50 | private groupsToResolve: string[] = []; 51 | 52 | /** 53 | * Collection of groups and their properties taken from HA 54 | */ 55 | public groupsData: IGroupDataMap = {}; 56 | 57 | /** 58 | * Whether include filters were processed already. 59 | */ 60 | private initialized: boolean = false; 61 | 62 | constructor(private config: IBatteryCardConfig) { 63 | this.include = config.filter?.include?.map(f => new Filter(f)); 64 | this.exclude = config.filter?.exclude?.map(f => new Filter(f)); 65 | 66 | if (!this.include) { 67 | this.initialized = false; 68 | } 69 | 70 | this.processExplicitEntities(); 71 | } 72 | 73 | async update(hass: HomeAssistantExt): Promise { 74 | if (!this.initialized) { 75 | // groups and includes should be processed just once 76 | this.initialized = true; 77 | this.processGroupEntities(hass); 78 | this.processIncludes(hass); 79 | } 80 | 81 | const updateComplete = Object.keys(this.batteries).map(id => { 82 | const battery = this.batteries[id]; 83 | battery.hass = hass; 84 | return battery.cardUpdated; 85 | }); 86 | 87 | await Promise.all(updateComplete); 88 | 89 | this.processExcludes(); 90 | } 91 | 92 | /** 93 | * Return batteries 94 | * @param hass Home Assistant instance 95 | */ 96 | getBatteries(): IBatteryCollection { 97 | return this.batteries; 98 | } 99 | 100 | /** 101 | * Creates and returns new Battery View Model 102 | */ 103 | private createBattery(entityConfig: IBatteryEntityConfig): IBatteryCollectionItem { 104 | // assing card-level values if they were not defined on entity-level 105 | entititesGlobalProps 106 | .filter(p => (entityConfig)[p] == undefined) 107 | .forEach(p => (entityConfig)[p] = (this.config)[p]); 108 | 109 | const battery = new BatteryStateEntity(); 110 | battery.entityId = entityConfig.entity 111 | battery.setConfig(entityConfig); 112 | 113 | return battery; 114 | } 115 | 116 | /** 117 | * Adds batteries based on entities from config. 118 | */ 119 | private processExplicitEntities() { 120 | let entities = safeGetConfigArrayOfObjects(this.config.entities, "entity"); 121 | 122 | // remove groups to add them later 123 | entities = entities.filter(e => { 124 | if (!e.entity) { 125 | throw new Error("Invalid configuration - missing property 'entity' on:\n" + JSON.stringify(e)); 126 | } 127 | 128 | if (e.entity.startsWith("group.")) { 129 | this.groupsToResolve.push(e.entity); 130 | return false; 131 | } 132 | 133 | return true; 134 | }); 135 | 136 | // processing groups and entities from collapse property 137 | // this way user doesn't need to put same IDs twice in the configuration 138 | if (this.config.collapse && Array.isArray(this.config.collapse)) { 139 | this.config.collapse.forEach(group => { 140 | if (group.group_id) { 141 | // check if it's not there already 142 | if (this.groupsToResolve.indexOf(group.group_id) == -1) { 143 | this.groupsToResolve.push(group.group_id); 144 | } 145 | } 146 | else if (group.entities) { 147 | group.entities.forEach(entity_id => { 148 | // check if it's not there already 149 | if (!entities.some(e => e.entity == entity_id)) { 150 | entities.push({ entity: entity_id }); 151 | } 152 | }); 153 | } 154 | }); 155 | } 156 | 157 | entities.forEach(entityConf => { 158 | this.batteries[entityConf.entity] = this.createBattery(entityConf); 159 | }); 160 | } 161 | 162 | /** 163 | * Adds batteries based on filter.include config. 164 | * @param hass Home Assistant instance 165 | */ 166 | private processIncludes(hass: HomeAssistant): void { 167 | if (!this.include) { 168 | return; 169 | } 170 | 171 | Object.keys(hass.states).forEach(entityId => { 172 | // check if entity matches filter conditions 173 | if (this.include?.some(filter => filter.isValid(hass.states[entityId])) && 174 | // check if battery is not added already (via explicit entities) 175 | !this.batteries[entityId]) { 176 | 177 | this.batteries[entityId] = this.createBattery({ entity: entityId }); 178 | } 179 | }); 180 | } 181 | 182 | /** 183 | * Adds batteries from group entities (if they were on the list) 184 | * @param hass Home Assistant instance 185 | */ 186 | private processGroupEntities(hass: HomeAssistant): void { 187 | this.groupsToResolve.forEach(group_id => { 188 | const groupEntity = hass.states[group_id]; 189 | if (!groupEntity) { 190 | log(`Group "${group_id}" not found`); 191 | return; 192 | } 193 | 194 | const groupData = groupEntity.attributes as IHomeAssistantGroupProps; 195 | if (!Array.isArray(groupData.entity_id)) { 196 | log(`Entities not found in "${group_id}"`); 197 | return; 198 | } 199 | 200 | groupData.entity_id.forEach(entity_id => { 201 | // check if battery is on the list already 202 | if (this.batteries[entity_id]) { 203 | return; 204 | } 205 | 206 | this.batteries[entity_id] = this.createBattery({ entity: entity_id }); 207 | }); 208 | 209 | this.groupsData[group_id] = groupData; 210 | }); 211 | 212 | this.groupsToResolve = []; 213 | } 214 | 215 | /** 216 | * Removes or hides batteries based on filter.exclude config. 217 | */ 218 | private processExcludes() { 219 | if (this.exclude == undefined) { 220 | Object.keys(this.batteries).forEach((entityId) => { 221 | const battery = this.batteries[entityId]; 222 | battery.isHidden = (battery.entityData?.display)?.hidden; 223 | }); 224 | 225 | return; 226 | } 227 | 228 | const filters = this.exclude; 229 | const toBeRemoved: string[] = []; 230 | 231 | Object.keys(this.batteries).forEach((entityId) => { 232 | const battery = this.batteries[entityId]; 233 | let isHidden = false; 234 | for (let filter of filters) { 235 | // we want to show batteries for which entities are missing in HA 236 | if (filter.isValid(battery.entityData, battery.state)) { 237 | if (filter.is_permanent) { 238 | // permanent filters have conditions based on static values so we can safely 239 | // remove such battery to avoid updating them unnecessarily 240 | toBeRemoved.push(entityId); 241 | // no need to process further 242 | break; 243 | } 244 | else { 245 | isHidden = true; 246 | } 247 | } 248 | } 249 | 250 | // we keep the view model to keep updating it 251 | // it might be shown/not-hidden next time 252 | battery.isHidden = isHidden || (battery.entityData?.display)?.hidden; 253 | }); 254 | 255 | toBeRemoved.forEach(entityId => delete this.batteries[entityId]); 256 | } 257 | } 258 | 259 | export interface IBatteryCollection { 260 | [key: string]: IBatteryCollectionItem 261 | } 262 | 263 | export interface IBatteryCollectionItem extends BatteryStateEntity { 264 | entityId?: string; 265 | isHidden?: boolean; 266 | } -------------------------------------------------------------------------------- /src/colors.ts: -------------------------------------------------------------------------------- 1 | import { log, safeGetConfigArrayOfObjects } from "./utils"; 2 | 3 | /** 4 | * Gets icon color 5 | * @param config Entity config 6 | * @param batteryLevel Battery level/state 7 | * @param isCharging Whether battery is in chargin mode 8 | * @returns Icon color 9 | */ 10 | export const getColorForBatteryLevel = (config: IBatteryEntityConfig, batteryLevel: number | undefined, isCharging: boolean): string => { 11 | 12 | if (isCharging && config.charging_state?.color) { 13 | return config.charging_state.color; 14 | } 15 | 16 | if (batteryLevel === undefined || isNaN(batteryLevel) || batteryLevel > 100 || batteryLevel < 0) { 17 | return defaultColor; 18 | } 19 | 20 | const colorSteps = safeGetConfigArrayOfObjects(config.colors?.steps, "color"); 21 | 22 | if (config.colors?.gradient) { 23 | return getGradientColors(colorSteps, batteryLevel); 24 | } 25 | 26 | let thresholds: IColorSteps[] = defaultColorSteps; 27 | if (config.colors?.steps) { 28 | // making sure the value is always set 29 | thresholds = colorSteps.map(s => { 30 | s.value = s.value === undefined || s.value > 100 ? 100 : s.value; 31 | return s; 32 | }); 33 | } 34 | 35 | return thresholds.find(th => batteryLevel <= th.value!)?.color || defaultColor; 36 | } 37 | 38 | /** 39 | * Gets color for given battery level (smooth transition between step colors) 40 | * @param config Color steps 41 | * @param level Battery level 42 | * @returns Hex HTML color 43 | */ 44 | const getGradientColors = (config: IColorSteps[], level: number): string => { 45 | 46 | let simpleList = config.map(s => s.color); 47 | if (!isColorGradientValid(simpleList)) { 48 | log("For gradient colors you need to use hex HTML colors. E.g. '#FF00FF'", "error"); 49 | return defaultColor; 50 | } 51 | 52 | if (simpleList.length < 2) { 53 | log("For gradient colors you need to specify at least two steps/colors", "error"); 54 | return defaultColor; 55 | } 56 | 57 | // if values were used we should respect them and calculate gradient between them 58 | if (config.every(s => s.value != undefined)) { 59 | 60 | const first = config[0]; 61 | if (level <= first.value!) { 62 | return first.color; 63 | } 64 | 65 | const last = config[config.length - 1]; 66 | if (level >= last.value!) { 67 | return last.color; 68 | } 69 | 70 | const index = config.findIndex(s => level <= s.value!); 71 | if (index != -1) { 72 | simpleList = [ config[index - 1].color, config[index].color ]; 73 | // calculate percentage 74 | level = (level - config[index - 1].value!) * 100 / (config[index].value! - config[index - 1].value!); 75 | } 76 | } 77 | 78 | return getColorInterpolationForPercentage(simpleList, level); 79 | } 80 | 81 | /** 82 | * Default color (inherited color) 83 | */ 84 | const defaultColor = "inherit"; 85 | 86 | /** 87 | * Default step values 88 | */ 89 | const defaultColorSteps: IColorSteps[] = [{ value: 20, color: "var(--label-badge-red)" }, { value: 55, color: "var(--label-badge-yellow)" }, { value: 100, color: "var(--label-badge-green)" }]; 90 | 91 | /** 92 | * HTML color pattern 93 | */ 94 | const htmlColorPattern = /^#[A-Fa-f0-9]{6}$/; 95 | 96 | /** 97 | * Converts HTML hex color to RGB values 98 | * 99 | * @param color Color to convert 100 | */ 101 | const convertHexColorToRGB = (color: string) => { 102 | color = color.replace("#", ""); 103 | return { 104 | r: parseInt(color.substr(0, 2), 16), 105 | g: parseInt(color.substr(2, 2), 16), 106 | b: parseInt(color.substr(4, 2), 16), 107 | } 108 | }; 109 | 110 | /** 111 | * Gets color interpolation for given color range and percentage 112 | * 113 | * @param colors HTML hex color values 114 | * @param pct Percent 115 | */ 116 | const getColorInterpolationForPercentage = function (colors: string[], pct: number): string { 117 | // convert from 0-100 to 0-1 range 118 | pct = pct / 100; 119 | 120 | const percentColors = colors.map((color, index) => { 121 | return { 122 | pct: (1 / (colors.length - 1)) * index, 123 | color: convertHexColorToRGB(color) 124 | } 125 | }); 126 | 127 | let colorBucket = 1 128 | for (colorBucket = 1; colorBucket < percentColors.length - 1; colorBucket++) { 129 | if (pct < percentColors[colorBucket].pct) { 130 | break; 131 | } 132 | } 133 | 134 | const lower = percentColors[colorBucket - 1]; 135 | const upper = percentColors[colorBucket]; 136 | const range = upper.pct - lower.pct; 137 | const rangePct = (pct - lower.pct) / range; 138 | const pctLower = 1 - rangePct; 139 | const pctUpper = rangePct; 140 | const color = { 141 | r: Math.floor(lower.color.r * pctLower + upper.color.r * pctUpper), 142 | g: Math.floor(lower.color.g * pctLower + upper.color.g * pctUpper), 143 | b: Math.floor(lower.color.b * pctLower + upper.color.b * pctUpper) 144 | }; 145 | return "#" + [color.r, color.g, color.b].map(i => i.toString(16).padStart(2, "0")).join("") ; 146 | }; 147 | 148 | /** 149 | * Tests whether given color gradient elements are valid 150 | * @param gradientColors Gradient color steps 151 | * @returns Whether the given collection is valid 152 | */ 153 | const isColorGradientValid = (gradientColors: string[]) => { 154 | if (gradientColors.length < 2) { 155 | log("Value for 'color_gradient' should be an array with at least 2 colors."); 156 | return; 157 | } 158 | 159 | for (const color of gradientColors) { 160 | if (!htmlColorPattern.test(color)) { 161 | log("Color '${color}' is not valid. Please provide valid HTML hex color in #XXXXXX format."); 162 | return false; 163 | } 164 | } 165 | 166 | return true; 167 | } -------------------------------------------------------------------------------- /src/custom-elements/battery-state-card.css: -------------------------------------------------------------------------------- 1 | .entity-spacing { 2 | margin: 8px 0; 3 | } 4 | 5 | .entity-spacing:first-child { 6 | margin-top: 0; 7 | } 8 | 9 | .entity-spacing:last-child { 10 | margin-bottom: 0; 11 | } 12 | 13 | .expandWrapper > .toggler { 14 | display: flex; 15 | align-items: center; 16 | cursor: pointer; 17 | } 18 | .expandWrapper > .toggler > .name { 19 | flex: 1; 20 | } 21 | .expandWrapper > .toggler div.chevron { 22 | transform: rotate(-90deg); 23 | font-size: 26px; 24 | height: 40px; 25 | width: 40px; 26 | display: flex; 27 | justify-content: center; 28 | align-items: center; 29 | } 30 | .expandWrapper > .toggler .chevron, 31 | .expandWrapper > .toggler + div { 32 | transition: all 0.5s ease; 33 | } 34 | .expandWrapper > .toggler.expanded .chevron { 35 | transform: rotate(-90deg) scaleX(-1); 36 | } 37 | .expandWrapper > .toggler + div { 38 | overflow: hidden; 39 | } 40 | .expandWrapper > .toggler:not(.expanded) + div { 41 | max-height: 0 !important; 42 | } -------------------------------------------------------------------------------- /src/custom-elements/battery-state-card.ts: -------------------------------------------------------------------------------- 1 | import { css, CSSResult, html, TemplateResult } from "lit" 2 | import { LovelaceCard } from "./lovelace-card"; 3 | import { property } from "lit/decorators.js"; 4 | import { cardHtml } from "./battery-state-card.views"; 5 | import { BatteryProvider, IBatteryCollection } from "../battery-provider"; 6 | import { getBatteryGroups, IBatteryGroup } from "../grouping"; 7 | import sharedStyles from "./shared.css" 8 | import cardStyles from "./battery-state-card.css" 9 | import { getIdsOfSortedBatteries } from "../sorting"; 10 | import { safeGetConfigArrayOfObjects } from "../utils"; 11 | import defaultConfig from "../default-config"; 12 | 13 | 14 | /** 15 | * Battery Card element 16 | */ 17 | export class BatteryStateCard extends LovelaceCard { 18 | 19 | /** 20 | * Card title 21 | */ 22 | @property({attribute: false}) 23 | public header: string | undefined; 24 | 25 | /** 26 | * List of entity IDs to render (without group) 27 | */ 28 | @property({attribute:false}) 29 | public list: string[] = []; 30 | 31 | /** 32 | * List of groups (containing list of entity IDs to render) 33 | */ 34 | @property({attribute: false}) 35 | public groups: IBatteryGroup[] = []; 36 | 37 | /** 38 | * Battery elements generator class 39 | */ 40 | private batteryProvider: BatteryProvider; 41 | 42 | /** 43 | * Battery elements (all known, not sorted nor grouped) 44 | */ 45 | public batteries: IBatteryCollection = {}; 46 | 47 | /** 48 | * Card CSS styles 49 | */ 50 | static get styles(): CSSResult { 51 | return css([sharedStyles + cardStyles]); 52 | } 53 | 54 | async internalUpdate(configUpdated: boolean, hassUpdated: boolean) { 55 | if (this.batteryProvider == undefined || configUpdated) { 56 | // checking whether we should apply default config 57 | if (Object.keys(this.config).length == 1) { 58 | // cloning default config 59 | this.config = { ... defaultConfig }; 60 | } 61 | 62 | this.batteryProvider = new BatteryProvider(this.config); 63 | } 64 | 65 | if (hassUpdated) { 66 | await this.batteryProvider.update(this.hass!); 67 | } 68 | 69 | this.header = this.config.title; 70 | 71 | this.batteries = this.batteryProvider.getBatteries(); 72 | 73 | const indexes = getIdsOfSortedBatteries(this.config, this.batteries) 74 | // we don't want to have any batteries which are hidden 75 | .filter(id => !this.batteries[id].isHidden); 76 | 77 | const groupingResult = getBatteryGroups(this.batteries, indexes, this.config.collapse, this.batteryProvider.groupsData); 78 | 79 | // we want to avoid render triggering 80 | if (JSON.stringify(groupingResult.list) != JSON.stringify(this.list)) { 81 | this.list = groupingResult.list; 82 | } 83 | 84 | // we want to avoid render triggering 85 | if (JSON.stringify(groupingResult.groups) != JSON.stringify(this.groups)) { 86 | this.groups = groupingResult.groups; 87 | } 88 | } 89 | 90 | internalRender(): TemplateResult<1> { 91 | if (this.list.length == 0 && this.groups.length == 0) { 92 | // if there are no entities to show we don't want to render anything 93 | this.style.display = "none"; 94 | return html``; 95 | } 96 | 97 | this.style.removeProperty("display"); 98 | 99 | return cardHtml(this); 100 | } 101 | 102 | onError(): void { 103 | this.style.removeProperty("display"); 104 | } 105 | 106 | /** 107 | * Gets the height of your card. 108 | * 109 | * Home Assistant uses this to automatically distribute all cards over 110 | * the available columns. One is equal 50px. 111 | * 112 | * Unfortunatelly this func is called only once when layout is being 113 | * rendered thus in case of dynamic number of entities (based on filters) 114 | * we cannot provide any reasonable estimation. 115 | */ 116 | getCardSize() { 117 | let size = safeGetConfigArrayOfObjects(this.config.entities, "entity").length || 1; 118 | 119 | if (this.config.collapse) { 120 | if (typeof this.config.collapse == "number") { 121 | // +1 to account the expand button 122 | return this.config.collapse + 1; 123 | } 124 | else { 125 | return this.config.collapse.length + 1; 126 | } 127 | } 128 | 129 | // +1 to account header 130 | return size + 1; 131 | } 132 | } -------------------------------------------------------------------------------- /src/custom-elements/battery-state-card.views.ts: -------------------------------------------------------------------------------- 1 | import { html } from "lit"; 2 | import { IBatteryCollection } from "../battery-provider"; 3 | import { IBatteryGroup } from "../grouping"; 4 | import { BatteryStateCard } from "./battery-state-card"; 5 | import { BatteryStateEntity } from "./battery-state-entity"; 6 | import { icon } from "./battery-state-entity.views"; 7 | 8 | const header = (text: string | undefined) => text && html` 9 |
10 |
11 | ${text} 12 |
13 |
14 | `; 15 | 16 | 17 | export const collapsableWrapper = (model: IBatteryGroup, batteries: IBatteryCollection) => { 18 | const elemId = "expander" + Math.random().toString().substr(2); 19 | return html` 20 |
21 |
(e.currentTarget).classList.toggle("expanded")}> 22 | ${icon(model.icon, model.iconColor)} 23 |
24 | ${model.title} 25 | ${model.secondaryInfo ? html`
${model.secondaryInfo}
` : null} 26 |
27 |
28 |
29 |
30 | ${model.batteryIds.map(id => batteryWrapper(batteries[id]))} 31 |
32 |
33 | ` 34 | }; 35 | 36 | 37 | export const cardHtml = (model: BatteryStateCard) => html` 38 | 39 | ${header(model.header)} 40 |
41 | ${model.list.map(id => batteryWrapper(model.batteries[id]))} 42 | ${model.groups.map(g => collapsableWrapper(g, model.batteries))} 43 |
44 |
45 | `; 46 | 47 | const batteryWrapper = (battery: BatteryStateEntity) => html` 48 |
49 | ${battery} 50 |
51 | `; -------------------------------------------------------------------------------- /src/custom-elements/battery-state-entity.css: -------------------------------------------------------------------------------- 1 | :host { 2 | display: flex; 3 | align-items: center; 4 | } 5 | :host > ha-alert { 6 | flex: 1 0 auto; 7 | } 8 | -------------------------------------------------------------------------------- /src/custom-elements/battery-state-entity.ts: -------------------------------------------------------------------------------- 1 | import { css } from "lit"; 2 | import { property } from "lit/decorators.js" 3 | import { safeGetConfigObject } from "../utils"; 4 | import { batteryHtml, debugOutput } from "./battery-state-entity.views"; 5 | import { LovelaceCard } from "./lovelace-card"; 6 | import sharedStyles from "./shared.css" 7 | import entityStyles from "./battery-state-entity.css"; 8 | import { handleAction } from "../action"; 9 | import { getColorForBatteryLevel } from "../colors"; 10 | import { getSecondaryInfo } from "../entity-fields/get-secondary-info"; 11 | import { getChargingState } from "../entity-fields/charging-state"; 12 | import { getBatteryLevel } from "../entity-fields/battery-level"; 13 | import { getName } from "../entity-fields/get-name"; 14 | import { getIcon } from "../entity-fields/get-icon"; 15 | import { DeviceRegistryEntry } from "../type-extensions"; 16 | 17 | /** 18 | * Battery entity element 19 | */ 20 | export class BatteryStateEntity extends LovelaceCard { 21 | 22 | /** 23 | * Name 24 | */ 25 | @property({ attribute: false }) 26 | public name: string; 27 | 28 | /** 29 | * Secondary information displayed undreneath the name 30 | */ 31 | @property({ attribute: false }) 32 | public secondaryInfo: string; 33 | 34 | /** 35 | * Entity state / battery level 36 | */ 37 | @property({ attribute: false }) 38 | public state: string; 39 | 40 | /** 41 | * Unit 42 | */ 43 | @property({ attribute: false }) 44 | public unit: string | undefined; 45 | 46 | /** 47 | * Entity icon 48 | */ 49 | @property({ attribute: false }) 50 | public icon: string; 51 | 52 | /** 53 | * Entity icon color 54 | */ 55 | @property({ attribute: false }) 56 | public iconColor: string; 57 | 58 | /** 59 | * Tap action 60 | */ 61 | @property({ attribute: false }) 62 | public action: IAction | undefined; 63 | 64 | /** 65 | * Raw entity data 66 | */ 67 | public entityData: IMap = {}; 68 | 69 | /** 70 | * Numeric representation of the state 71 | */ 72 | public stateNumeric: number | undefined; 73 | 74 | /** 75 | * Entity CSS styles 76 | */ 77 | public static get styles() { 78 | return css([sharedStyles + entityStyles]); 79 | } 80 | 81 | async internalUpdate() { 82 | this.entityData = { 83 | ...this.hass?.states[this.config.entity] 84 | }; 85 | 86 | if (this.config.extend_entity_data !== false) { 87 | this.extendEntityData(); 88 | } 89 | 90 | if (this.config.debug === true || this.config.debug === this.config.entity) { 91 | this.alert = { 92 | title: `Debug: ${this.config.entity}`, 93 | content: debugOutput(JSON.stringify(this.entityData, null, 2)), 94 | } 95 | } 96 | 97 | var { state, level, unit} = getBatteryLevel(this.config, this.hass, this.entityData); 98 | this.state = state; 99 | this.unit = unit; 100 | this.stateNumeric = level; 101 | 102 | const isCharging = getChargingState(this.config, this.state, this.hass); 103 | this.entityData["charging"] = isCharging ? (this.config.charging_state?.secondary_info_text || "Charging") : "" // todo: think about i18n 104 | 105 | this.name = getName(this.config, this.hass, this.entityData); 106 | this.secondaryInfo = getSecondaryInfo(this.config, this.hass, this.entityData); 107 | this.icon = getIcon(this.config, level, isCharging, this.hass); 108 | this.iconColor = getColorForBatteryLevel(this.config, level, isCharging); 109 | } 110 | 111 | connectedCallback() { 112 | super.connectedCallback(); 113 | // enable action if configured 114 | this.setupAction(true); 115 | } 116 | 117 | disconnectedCallback() { 118 | super.disconnectedCallback(); 119 | // disabling action if exists 120 | this.setupAction(false); 121 | } 122 | 123 | internalRender() { 124 | return batteryHtml(this); 125 | } 126 | 127 | onError(): void { 128 | } 129 | 130 | /** 131 | * Adding or removing action 132 | * @param enable Whether to enable/add the tap action 133 | */ 134 | private setupAction(enable: boolean = true) { 135 | if (enable && !this.error && !this.alert) { 136 | let tapAction = this.config.tap_action || "more-info"; 137 | if (tapAction != "none" && !this.action) { 138 | this.action = evt => { 139 | evt.stopPropagation(); 140 | handleAction({ 141 | card: this, 142 | config: safeGetConfigObject(tapAction, "action"), 143 | entityId: this.config.entity, 144 | }, this.hass!); 145 | } 146 | 147 | this.addEventListener("click", this.action); 148 | this.classList.add("clickable"); 149 | } 150 | } 151 | else { 152 | if (this.action) { 153 | this.classList.remove("clickable"); 154 | this.removeEventListener("click", this.action); 155 | this.action = undefined; 156 | } 157 | } 158 | } 159 | 160 | /** 161 | * Adds display, device and area objects to entityData 162 | */ 163 | private extendEntityData(): void { 164 | 165 | if (!this.hass) { 166 | return; 167 | } 168 | 169 | const entityDisplayEntry = this.hass.entities && this.hass.entities[this.config.entity]; 170 | 171 | if (entityDisplayEntry) { 172 | this.entityData["display"] = entityDisplayEntry; 173 | this.entityData["device"] = entityDisplayEntry.device_id 174 | ? this.hass.devices && this.hass.devices[entityDisplayEntry.device_id] 175 | : undefined; 176 | 177 | const area_id = entityDisplayEntry.area_id || (this.entityData["device"])?.area_id; 178 | if (area_id) { 179 | this.entityData["area"] = this.hass.areas && this.hass.areas[area_id]; 180 | } 181 | } 182 | } 183 | } -------------------------------------------------------------------------------- /src/custom-elements/battery-state-entity.views.ts: -------------------------------------------------------------------------------- 1 | import { HomeAssistant } from "custom-card-helpers"; 2 | import { TemplateResult, html } from "lit"; 3 | import { BatteryStateEntity } from "./battery-state-entity"; 4 | 5 | const relativeTimeTag = new RegExp("([^<]+)", "g"); 6 | 7 | /** 8 | * Replaces temporary RT tages with proper HA "relative-time" ones 9 | * 10 | * @param text Text to be processed 11 | * @param hass HomeAssistant instance 12 | * @returns Rendered templates 13 | */ 14 | const replaceTags = (text: string, hass?: HomeAssistant): TemplateResult[] => { 15 | 16 | const result: TemplateResult[] = [] 17 | 18 | let matches: string[] | null = []; 19 | let currentPos = 0; 20 | while(matches = relativeTimeTag.exec(text)) { 21 | const matchPos = text.indexOf(matches[0], currentPos); 22 | 23 | if (matchPos != 0) { 24 | result.push(html`${text.substring(currentPos, matchPos)}`); 25 | } 26 | 27 | result.push(html``); 28 | 29 | currentPos += matchPos + matches[0].length; 30 | } 31 | 32 | if (currentPos < text.length) { 33 | result.push(html`${text.substring(currentPos, text.length)}`); 34 | } 35 | 36 | return result; 37 | } 38 | 39 | export const secondaryInfo = (text?: string, hass?: HomeAssistant) => text && html` 40 |
${replaceTags(text, hass)}
41 | `; 42 | 43 | export const icon = (icon?: string, color?: string) => icon && html` 44 |
45 | 49 |
50 | `; 51 | 52 | export const batteryHtml = (model: BatteryStateEntity) => html` 53 | ${icon(model.icon, model.iconColor)} 54 |
55 | ${model.name} 56 | ${secondaryInfo(model.secondaryInfo, model.hass)} 57 |
58 |
59 | ${model.state}${unit(model.unit)} 60 |
61 | `; 62 | 63 | const unit = (unit: string | undefined) => unit && html` ${unit}`; 64 | 65 | export const debugOutput = (content: string) => { 66 | 67 | const actions = [{ 68 | text: "Show / hide", 69 | action: (e: MouseEvent) => { 70 | const debugContent = (e.currentTarget)?.parentElement?.parentElement?.querySelector(".debug_expand"); 71 | if (debugContent) { 72 | debugContent.style.display = debugContent.style.display === "none" ? "block" : "none"; 73 | } 74 | } 75 | }]; 76 | 77 | if (navigator.clipboard) { 78 | actions.push({ 79 | text: "Copy to clipboard", 80 | action: () => navigator.clipboard.writeText(content), 81 | }); 82 | } 83 | 84 | return html`
${actions.length && html`${ actions.map(a => html`[${a.text}] `) }`}
85 | ` 89 | }; -------------------------------------------------------------------------------- /src/custom-elements/lovelace-card.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, TemplateResult, html } from "lit"; 2 | import { HomeAssistantExt } from "../type-extensions"; 3 | import { throttledCall } from "../utils"; 4 | import { property } from "lit/decorators.js" 5 | 6 | /** 7 | * Lovelace UI component/card base 8 | */ 9 | export abstract class LovelaceCard extends LitElement { 10 | 11 | /** 12 | * Error 13 | */ 14 | @property({ attribute: false }) 15 | public error: Error | undefined; 16 | 17 | /** 18 | * Warning 19 | */ 20 | @property({ attribute: false }) 21 | public alert: { type?: "error" | "warning", title?: string, content?: TemplateResult | string } | undefined; 22 | 23 | /** 24 | * HomeAssistant object 25 | */ 26 | private _hass: HomeAssistantExt | undefined; 27 | 28 | /** 29 | * Component/card config 30 | */ 31 | protected config: TConfig; 32 | 33 | /** 34 | * Queue of the collbacks to call after next update 35 | */ 36 | private updateNotifyQueue: { (): void }[] = []; 37 | 38 | /** 39 | * Whether config has been updated since the last internalUpdate call 40 | */ 41 | private configUpdated = false; 42 | 43 | /** 44 | * Whether hass has been updated since the last internalUpdate call 45 | */ 46 | private hassUpdated = false; 47 | 48 | /** 49 | * Safe update triggering function 50 | * 51 | * It happens quite often that setConfig or hassio setter are called few times 52 | * in the same execution path. We want to throttle such updates and handle just 53 | * the last one. 54 | */ 55 | private triggerUpdate = throttledCall(async () => { 56 | 57 | this.alert = undefined; 58 | 59 | try { 60 | await this.internalUpdate(this.configUpdated, this.hassUpdated); 61 | this.error = undefined; 62 | } 63 | catch (e: unknown) { 64 | if (typeof e === "string") { 65 | this.error = { message: e, name: "" }; 66 | } 67 | else if (e instanceof Error) { 68 | this.error = e; 69 | } 70 | } 71 | 72 | if (this.configUpdated) { 73 | // always rerender when config has changed 74 | this.requestUpdate(); 75 | } 76 | 77 | this.configUpdated = false; 78 | this.hassUpdated = false; 79 | this.updateNotifyQueue.forEach(n => n()); 80 | this.updateNotifyQueue = []; 81 | }, 100); 82 | 83 | /** 84 | * HomeAssistant object setter 85 | */ 86 | set hass(hass: HomeAssistantExt | undefined) { 87 | this._hass = hass; 88 | this.hassUpdated = true; 89 | this.triggerUpdate(); 90 | } 91 | 92 | /** 93 | * HomeAssistant object getter 94 | */ 95 | get hass(): HomeAssistantExt | undefined { 96 | return this._hass; 97 | } 98 | 99 | /** 100 | * Helper getter to wait for an update to finish 101 | */ 102 | get cardUpdated() { 103 | return new Promise((resolve) => this.updateNotifyQueue.push(resolve)); 104 | } 105 | 106 | /** 107 | * Func for setting card configuration 108 | * @param config Card config 109 | */ 110 | setConfig(config: TConfig): void { 111 | // the original config is immutable 112 | this.config = JSON.parse(JSON.stringify(config)); 113 | this.configUpdated = true; 114 | this.triggerUpdate(); 115 | }; 116 | 117 | /** 118 | * Handler called when updated happened 119 | * @param config Whether config has been updated since the last call 120 | * @param hass Whetther hass has been updated since the last call 121 | */ 122 | abstract internalUpdate(config: boolean, hass:boolean): Promise; 123 | 124 | /** 125 | * Handler called when render was triggered and updated HTML template is required 126 | */ 127 | abstract internalRender(): TemplateResult<1>; 128 | 129 | /** 130 | * Handler called when exception was caught to let know the card 131 | */ 132 | abstract onError(): void; 133 | 134 | render(): TemplateResult<1> { 135 | if (this.error) { 136 | this.onError(); 137 | return errorHtml(this.tagName, "Exception: " + this.error.message, this.error.stack); 138 | } 139 | 140 | if (this.alert) { 141 | return html`${this.alert.content}`; 142 | } 143 | 144 | return this.internalRender(); 145 | } 146 | } 147 | 148 | const errorHtml = (cardName: string, message: string, content: string | undefined) => html` 149 | 150 |

151 | ${message} 152 |

153 |

    154 |
  1. 155 | Please check if the problem was reported already
    156 | Click here to search 157 |
  2. 158 |
  3. 159 | If it wasn't please consider creating one
    160 | Click here to create
    161 | Please copy-paste the below stack trace. 162 |
  4. 163 |
164 |
${content}
165 |
166 | `; -------------------------------------------------------------------------------- /src/custom-elements/shared.css: -------------------------------------------------------------------------------- 1 | 2 | :host(.clickable), 3 | .clickable { 4 | cursor: pointer; 5 | } 6 | 7 | 8 | 9 | .truncate { 10 | white-space: nowrap; 11 | text-overflow: ellipsis; 12 | overflow: hidden; 13 | } 14 | 15 | .name { 16 | flex: 1; 17 | margin: 0 6px; 18 | } 19 | 20 | .secondary { 21 | color: var(--secondary-text-color); 22 | } 23 | 24 | .icon { 25 | flex: 0 0 40px; 26 | border-radius: 50%; 27 | text-align: center; 28 | line-height: 40px; 29 | margin-right: 10px; 30 | color: var(--paper-item-icon-color) 31 | } -------------------------------------------------------------------------------- /src/default-config.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | sort: { 3 | by: "state" 4 | }, 5 | collapse: 8, 6 | filter: { 7 | include: [{ 8 | name: "attributes.device_class", 9 | value: "battery" 10 | }], 11 | exclude: [{ 12 | name: "entity_id", 13 | value: "binary_sensor.*" 14 | }] 15 | }, 16 | secondary_info: "{last_changed}", 17 | bulk_rename: [ 18 | { from: " Battery" }, 19 | { from: " level" }, 20 | ], 21 | colors: { 22 | steps: [ "#ff0000", "#ffff00", "#00ff00" ], 23 | gradient: true, 24 | } 25 | } -------------------------------------------------------------------------------- /src/entity-fields/battery-level.ts: -------------------------------------------------------------------------------- 1 | import { RichStringProcessor } from "../rich-string-processor"; 2 | import { HomeAssistantExt } from "../type-extensions"; 3 | import { isNumber, log, toNumber } from "../utils"; 4 | 5 | /** 6 | * Some sensor may produce string value like "45%". This regex is meant to parse such values. 7 | */ 8 | const stringValuePattern = /\b([0-9]{1,3})\s?%/; 9 | 10 | 11 | /** 12 | * HA formatted state pattern 13 | */ 14 | const formattedStatePattern = /(-?[0-9,.]+)\s?(.*)/; 15 | 16 | /** 17 | * Getts battery level/state 18 | * @param config Entity config 19 | * @param hass HomeAssistant state object 20 | * @returns Battery level 21 | */ 22 | export const getBatteryLevel = (config: IBatteryEntityConfig, hass: HomeAssistantExt | undefined, entityData: IMap | undefined): IBatteryState => { 23 | const UnknownLevel = hass?.localize("state.default.unknown") || "Unknown"; 24 | let state: string; 25 | let unit: string | undefined; 26 | 27 | const stringProcessor = new RichStringProcessor(hass, entityData); 28 | 29 | if (config.value_override !== undefined) { 30 | const processedValue = stringProcessor.process(config.value_override.toString()); 31 | return { 32 | state: processedValue, 33 | level: isNumber(processedValue) ? toNumber(processedValue) : undefined, 34 | unit: getUnit(processedValue, undefined, undefined, config, hass), 35 | } 36 | } 37 | 38 | if (!entityData) { 39 | return { 40 | state: UnknownLevel 41 | }; 42 | } 43 | 44 | if (config.attribute) { 45 | state = entityData.attributes[config.attribute]?.toString(); 46 | if (state == undefined) { 47 | log(`Attribute "${config.attribute}" doesn't exist on "${config.entity}" entity`); 48 | state = UnknownLevel; 49 | } 50 | } 51 | else { 52 | const candidates: (string | number | undefined)[] = [ 53 | config.non_battery_entity ? null: entityData.attributes?.battery_level, 54 | config.non_battery_entity ? null: entityData.attributes?.battery, 55 | entityData.state 56 | ]; 57 | 58 | state = candidates.find(val => isNumber(val))?.toString() || 59 | candidates.find(val => val !== null && val !== undefined)?.toString() || 60 | UnknownLevel; 61 | } 62 | 63 | let displayValue: string | undefined; 64 | 65 | // check if we should convert value eg. for binary sensors 66 | if (config.state_map) { 67 | const convertedVal = config.state_map.find(s => s.from === state); 68 | if (convertedVal === undefined) { 69 | if (!isNumber(state)) { 70 | log(`Missing option for '${state}' in 'state_map.'`); 71 | } 72 | } 73 | else { 74 | state = convertedVal.to.toString(); 75 | if (convertedVal.display !== undefined) { 76 | displayValue = stringProcessor.process(convertedVal.display); 77 | } 78 | } 79 | } 80 | 81 | // trying to extract value from string e.g. "34 %" 82 | if (!isNumber(state)) { 83 | const match = stringValuePattern.exec(state); 84 | if (match != null) { 85 | state = match[1]; 86 | } 87 | } 88 | 89 | if (isNumber(state)) { 90 | if (config.multiplier) { 91 | state = (config.multiplier * toNumber(state)).toString(); 92 | } 93 | 94 | if (typeof config.round === "number") { 95 | state = parseFloat(state).toFixed(config.round).toString(); 96 | } 97 | } 98 | else { 99 | // capitalize first letter 100 | state = state.charAt(0).toUpperCase() + state.slice(1); 101 | } 102 | 103 | // check if HA should format the value 104 | if (config.default_state_formatting !== false && !displayValue && state === entityData.state && hass) { 105 | const formattedState = hass.formatEntityState(entityData); 106 | 107 | const matches = formattedState.match(formattedStatePattern); 108 | if (matches != null) { 109 | state = matches[1]; 110 | unit = matches[2] || unit; 111 | } 112 | } 113 | 114 | return { 115 | state: displayValue || state, 116 | level: isNumber(state) ? toNumber(state) : undefined, 117 | unit: getUnit(state, displayValue, unit, config, hass), 118 | }; 119 | } 120 | 121 | const getUnit = (state: string, displayValue: string | undefined, unit: string | undefined, config: IBatteryEntityConfig, hass?: HomeAssistantExt): string | undefined => { 122 | if (config.unit) { 123 | // config unit override 124 | unit = config.unit 125 | } 126 | else { 127 | // default unit 128 | unit = unit || hass?.states[config.entity]?.attributes["unit_of_measurement"] || "%" 129 | } 130 | 131 | if (!isNumber(state) || (displayValue && !isNumber(displayValue))) { 132 | // for non numeric states unit should not be rendered 133 | unit = undefined; 134 | } 135 | 136 | return unit; 137 | } 138 | 139 | interface IBatteryState { 140 | /** 141 | * Battery level 142 | */ 143 | level?: number; 144 | 145 | /** 146 | * Battery state to display 147 | */ 148 | state: string; 149 | 150 | /** 151 | * Unit override 152 | */ 153 | unit?: string 154 | } -------------------------------------------------------------------------------- /src/entity-fields/charging-state.ts: -------------------------------------------------------------------------------- 1 | import { HomeAssistant } from "custom-card-helpers/dist/types"; 2 | import { log, safeGetArray } from "../utils"; 3 | 4 | /** 5 | * Gets flag indicating charging mode 6 | * @param config Entity config 7 | * @param state Battery level/state 8 | * @param hass HomeAssistant state object 9 | * @returns Whether battery is in chargin mode 10 | */ 11 | export const getChargingState = (config: IBatteryEntityConfig, state: string, hass?: HomeAssistant): boolean => { 12 | 13 | if (!hass) { 14 | return false; 15 | } 16 | 17 | const chargingConfig = config.charging_state; 18 | if (!chargingConfig) { 19 | return getDefaultChargingState(config, hass); 20 | } 21 | 22 | let entityWithChargingState = hass.states[config.entity]; 23 | 24 | // check whether we should use different entity to get charging state 25 | if (chargingConfig.entity_id) { 26 | entityWithChargingState = hass.states[chargingConfig.entity_id] 27 | if (!entityWithChargingState) { 28 | log(`'charging_state' entity id (${chargingConfig.entity_id}) not found.`); 29 | return false; 30 | } 31 | 32 | state = entityWithChargingState.state; 33 | } 34 | 35 | const attributesLookup = safeGetArray(chargingConfig.attribute); 36 | // check if we should take the state from attribute 37 | if (attributesLookup.length != 0) { 38 | // take first attribute name which exists on entity 39 | const exisitngAttrib = attributesLookup.find(attr => getValueFromJsonPath(entityWithChargingState.attributes, attr.name) !== undefined); 40 | if (exisitngAttrib) { 41 | return exisitngAttrib.value !== undefined ? 42 | getValueFromJsonPath(entityWithChargingState.attributes, exisitngAttrib.name) == exisitngAttrib.value : 43 | true; 44 | } 45 | else { 46 | // if there is no attribute indicating charging it means the charging state is false 47 | return false; 48 | } 49 | } 50 | 51 | const statesIndicatingCharging = safeGetArray(chargingConfig.state); 52 | 53 | return statesIndicatingCharging.length == 0 ? !!state : statesIndicatingCharging.some(s => s == state); 54 | } 55 | 56 | const standardBatteryLevelEntitySuffix = "_battery_level"; 57 | const standardBatteryStateEntitySuffix = "_battery_state"; 58 | const getDefaultChargingState = (config: IBatteryEntityConfig, hass?: HomeAssistant): boolean => { 59 | if (!config.entity.endsWith(standardBatteryLevelEntitySuffix)) { 60 | return false; 61 | } 62 | 63 | const batteryStateEntity = hass?.states[config.entity.replace(standardBatteryLevelEntitySuffix, standardBatteryStateEntitySuffix)]; 64 | if (!batteryStateEntity) { 65 | return false; 66 | } 67 | 68 | return ["charging", "full"].includes(batteryStateEntity.state); 69 | } 70 | 71 | /** 72 | * Returns value from given object and the path 73 | * @param data Data 74 | * @param path JSON path 75 | * @returns Value from the path 76 | */ 77 | const getValueFromJsonPath = (data: any, path: string) => { 78 | if (data === undefined) { 79 | return data; 80 | } 81 | 82 | path.split(".").forEach(chunk => { 83 | data = data ? data[chunk] : undefined; 84 | }); 85 | 86 | return data; 87 | } -------------------------------------------------------------------------------- /src/entity-fields/get-icon.ts: -------------------------------------------------------------------------------- 1 | import { HomeAssistant } from "custom-card-helpers"; 2 | import { log } from "../utils"; 3 | import { RichStringProcessor } from "../rich-string-processor"; 4 | 5 | /** 6 | * Gets MDI icon class 7 | * @param config Entity config 8 | * @param level Battery level/state 9 | * @param isCharging Whether battery is in chargin mode 10 | * @param hass HomeAssistant state object 11 | * @returns Mdi icon string 12 | */ 13 | export const getIcon = (config: IBatteryEntityConfig, level: number | undefined, isCharging: boolean, hass: HomeAssistant | undefined): string => { 14 | if (isCharging && config.charging_state?.icon) { 15 | return config.charging_state.icon; 16 | } 17 | 18 | if (config.icon) { 19 | const attribPrefix = "attribute."; 20 | // check if we should return the icon/string from the attribute value 21 | if (hass && config.icon.startsWith(attribPrefix)) { 22 | const attribName = config.icon.substr(attribPrefix.length); 23 | const val = hass.states[config.entity].attributes[attribName] as string | undefined; 24 | if (!val) { 25 | log(`Icon attribute missing in '${config.entity}' entity`, "error"); 26 | return config.icon; 27 | } 28 | 29 | return val; 30 | } 31 | 32 | const processor = new RichStringProcessor(hass, { ...hass?.states[config.entity] }); 33 | return processor.process(config.icon); 34 | } 35 | 36 | if (level === undefined || isNaN(level) || level > 100 || level < 0) { 37 | return "mdi:battery-unknown"; 38 | } 39 | 40 | const roundedLevel = Math.round(level / 10) * 10; 41 | switch (roundedLevel) { 42 | case 100: 43 | return isCharging ? 'mdi:battery-charging-100' : "mdi:battery"; 44 | case 0: 45 | return isCharging ? "mdi:battery-charging-outline" : "mdi:battery-outline"; 46 | default: 47 | return (isCharging ? "mdi:battery-charging-" : "mdi:battery-") + roundedLevel; 48 | } 49 | } -------------------------------------------------------------------------------- /src/entity-fields/get-name.ts: -------------------------------------------------------------------------------- 1 | import { HomeAssistant } from "custom-card-helpers"; 2 | import { getRegexFromString, safeGetArray } from "../utils"; 3 | import { RichStringProcessor } from "../rich-string-processor"; 4 | 5 | 6 | /** 7 | * Battery name getter 8 | * @param config Entity config 9 | * @param hass HomeAssistant state object 10 | * @returns Battery name 11 | */ 12 | export const getName = (config: IBatteryEntityConfig, hass: HomeAssistant | undefined, entityData: IMap | undefined): string => { 13 | if (config.name) { 14 | const proc = new RichStringProcessor(hass, entityData); 15 | return proc.process(config.name); 16 | } 17 | 18 | let name = entityData?.attributes?.friendly_name; 19 | 20 | // when we have failed to get the name we just return entity id 21 | if (!name) { 22 | return config.entity; 23 | } 24 | 25 | // assuming it is not IBulkRename 26 | let renameRules = config.bulk_rename; 27 | 28 | let capitalizeFirstLetter = true; 29 | 30 | // testing if it's IBulkRename 31 | if (config.bulk_rename && !Array.isArray(config.bulk_rename) && (config.bulk_rename)?.from === undefined) { 32 | // we are assuming it is a IBulkRename config 33 | const bulkRename = config.bulk_rename; 34 | 35 | renameRules = bulkRename.rules; 36 | capitalizeFirstLetter = bulkRename.capitalize_first !== false; 37 | } 38 | 39 | name = applyRenames(name, renameRules); 40 | 41 | if (capitalizeFirstLetter && name !== "") { 42 | name = name[0].toLocaleUpperCase() + name.substring(1); 43 | } 44 | 45 | return name; 46 | } 47 | 48 | const applyRenames = (name: string, renameRules: IConvert | IConvert[] | undefined) => safeGetArray(renameRules).reduce((result, rule) => { 49 | const regex = getRegexFromString(rule.from); 50 | if (regex) { 51 | // create regexp after removing slashes 52 | result = result.replace(regex, rule.to || ""); 53 | } 54 | else { 55 | result = result.replace(rule.from, rule.to || ""); 56 | } 57 | 58 | return result; 59 | }, name) -------------------------------------------------------------------------------- /src/entity-fields/get-secondary-info.ts: -------------------------------------------------------------------------------- 1 | import { HomeAssistant } from "custom-card-helpers/dist/types"; 2 | import { RichStringProcessor } from "../rich-string-processor"; 3 | import { isNumber } from "../utils"; 4 | 5 | /** 6 | * Gets secondary info text 7 | * @param config Entity config 8 | * @param hass HomeAssistant state object 9 | * @param entidyData Entity data 10 | * @returns Secondary info text 11 | */ 12 | export const getSecondaryInfo = (config: IBatteryEntityConfig, hass: HomeAssistant | undefined, entityData: IMap | undefined): string => { 13 | if (config.secondary_info) { 14 | const processor = new RichStringProcessor(hass, entityData); 15 | 16 | let result = processor.process(config.secondary_info); 17 | 18 | // we convert to Date in the next step where number conversion to date is valid too 19 | // although in such cases we want to return the number - not a date 20 | if (isNumber(result)) { 21 | return result; 22 | } 23 | 24 | const dateVal = Date.parse(result); 25 | // The RT tags will be converted to proper HA tags at the views layer 26 | return isNaN(dateVal) ? result : `${result}`; 27 | } 28 | 29 | return null; 30 | } -------------------------------------------------------------------------------- /src/filter.ts: -------------------------------------------------------------------------------- 1 | import { getRegexFromString, getValueFromObject, isNumber, log, toNumber } from "./utils"; 2 | 3 | /** 4 | * Functions to check if filter condition is met 5 | */ 6 | const operatorHandlers: { [key in FilterOperator]: (val: FilterValueType, expectedVal: FilterValueType) => boolean } = { 7 | "exists": val => val !== undefined, 8 | "not_exists": val => val === undefined, 9 | "contains": (val, searchString) => val !== undefined && val !== null && val.toString().indexOf(searchString!.toString()) != -1, 10 | "=": (val, expectedVal) => isNumber(val) || isNumber(expectedVal) ? toNumber(val) == toNumber(expectedVal) : val == expectedVal, 11 | ">": (val, expectedVal) => toNumber(val) > toNumber(expectedVal), 12 | "<": (val, expectedVal) => toNumber(val) < toNumber(expectedVal), 13 | ">=": (val, expectedVal) => toNumber(val) >= toNumber(expectedVal), 14 | "<=": (val, expectedVal) => toNumber(val) <= toNumber(expectedVal), 15 | "matches": (val, pattern) => { 16 | if (val === undefined || val === null) { 17 | return false; 18 | } 19 | 20 | pattern = pattern!.toString() 21 | 22 | let exp = getRegexFromString(pattern); 23 | if (!exp && pattern.includes("*")) { 24 | exp = new RegExp("^" + pattern.replace(/\*/g, ".*") + "$"); 25 | } 26 | 27 | return exp ? exp.test(val.toString()) : val === pattern; 28 | } 29 | } 30 | 31 | /** 32 | * Filter class 33 | */ 34 | export class Filter { 35 | 36 | /** 37 | * Whether filter is permanent. 38 | * 39 | * Permanent filters removes entities/batteries from collections permanently 40 | * instead of making them hidden. 41 | */ 42 | get is_permanent(): boolean { 43 | return this.config.name != "state"; 44 | } 45 | 46 | constructor(private config: IFilter) { 47 | 48 | } 49 | 50 | /** 51 | * Checks whether entity meets the filter conditions. 52 | * @param entityData Hass entity data 53 | * @param state State override - battery state/level 54 | */ 55 | isValid(entityData: any, state?: string): boolean { 56 | const val = this.getValue(entityData, state); 57 | return this.meetsExpectations(val); 58 | } 59 | 60 | /** 61 | * Gets the value to validate. 62 | * @param entityData Hass entity data 63 | * @param state State override - battery state/level 64 | */ 65 | private getValue(entityData: any, state?: string): FilterValueType { 66 | if (!this.config.name) { 67 | log("Missing filter 'name' property"); 68 | return; 69 | } 70 | 71 | if (this.config.name == "state" && state !== undefined) { 72 | return state; 73 | } 74 | 75 | return getValueFromObject(entityData, this.config.name); 76 | } 77 | 78 | /** 79 | * Checks whether value meets the filter conditions. 80 | * @param val Value to validate 81 | */ 82 | private meetsExpectations(val: FilterValueType): boolean { 83 | 84 | let operator = this.config.operator; 85 | if (!operator) { 86 | if (this.config.value === undefined) { 87 | operator = "exists"; 88 | } 89 | else if (this.config.value === null) { 90 | operator = "="; 91 | } 92 | else { 93 | const expectedVal = this.config.value.toString(); 94 | const regex = getRegexFromString(expectedVal); 95 | operator = expectedVal.indexOf("*") != -1 || regex ? 96 | "matches" : 97 | "="; 98 | } 99 | } 100 | 101 | const func = operatorHandlers[operator]; 102 | if (!func) { 103 | log(`Operator '${this.config.operator}' not supported. Supported operators: ${Object.keys(operatorHandlers).join(", ")}`); 104 | return false; 105 | } 106 | 107 | return func(val, this.config.value); 108 | } 109 | } -------------------------------------------------------------------------------- /src/grouping.ts: -------------------------------------------------------------------------------- 1 | import { log, toNumber } from "./utils"; 2 | import { IBatteryCollection, IBatteryCollectionItem } from "./battery-provider"; 3 | 4 | export interface IBatteryGroup { 5 | title?: string; 6 | secondaryInfo?: string; 7 | icon?: string; 8 | iconColor?: string; 9 | batteryIds: string[]; 10 | } 11 | 12 | export interface IBatteryGroupResult { 13 | list: string[]; 14 | groups: IBatteryGroup[]; 15 | } 16 | 17 | /** 18 | * Returns battery collections to render 19 | */ 20 | export const getBatteryGroups = (batteries: IBatteryCollection, sortedIds: string[], config: number | IGroupConfig[] | undefined, haGroupData: IGroupDataMap): IBatteryGroupResult => { 21 | const result: IBatteryGroupResult = { 22 | list: [], 23 | groups: [] 24 | }; 25 | 26 | if (!config) { 27 | result.list = sortedIds; 28 | return result; 29 | } 30 | 31 | if (typeof config == "number") { 32 | result.list = sortedIds.slice(0, config); 33 | const remainingBatteries = sortedIds.slice(config); 34 | if (remainingBatteries.length > 0) { 35 | result.groups.push(createGroup(haGroupData, remainingBatteries)); 36 | } 37 | } 38 | else {// make sure that max property is set for every group 39 | populateMinMaxFields(config); 40 | 41 | sortedIds.forEach(id => { 42 | const foundIndex = getGroupIndex(config, batteries[id], haGroupData); 43 | if (foundIndex == -1) { 44 | // batteries without group 45 | result.list.push(id); 46 | } 47 | else { 48 | // bumping group index as the first group is for the orphans 49 | result.groups[foundIndex] = result.groups[foundIndex] || createGroup(haGroupData, [], config[foundIndex]); 50 | result.groups[foundIndex].batteryIds.push(id); 51 | } 52 | }); 53 | } 54 | 55 | // do the post processing for dynamic values which depend on the group items 56 | result.groups.forEach(g => { 57 | if (g.title) { 58 | g.title = getEnrichedText(g.title, g, batteries); 59 | } 60 | 61 | if (g.secondaryInfo) { 62 | g.secondaryInfo = getEnrichedText(g.secondaryInfo, g, batteries); 63 | } 64 | 65 | g.icon = getIcon(g.icon, g.batteryIds, batteries); 66 | g.iconColor = getIconColor(g.iconColor, g.batteryIds, batteries); 67 | }); 68 | 69 | return result; 70 | } 71 | 72 | /** 73 | * Returns group index to which battery should be assigned. 74 | * @param config Collapsing groups config 75 | * @param battery Batterry view model 76 | * @param haGroupData Home assistant group data 77 | */ 78 | const getGroupIndex = (config: IGroupConfig[], battery: IBatteryCollectionItem, haGroupData: IGroupDataMap): number => { 79 | return config.findIndex(group => { 80 | 81 | if (group.group_id && !haGroupData[group.group_id]?.entity_id?.some(id => battery.entityId == id)) { 82 | return false; 83 | } 84 | 85 | if (group.entities && !group.entities.some(id => battery.entityId == id)) { 86 | return false 87 | } 88 | 89 | const level = isNaN(toNumber(battery.state)) ? 0 : toNumber(battery.state); 90 | 91 | return level >= group.min! && level <= group.max!; 92 | }); 93 | } 94 | 95 | /** 96 | * Sets missing max/min fields. 97 | * @param config Collapsing groups config 98 | */ 99 | var populateMinMaxFields = (config: IGroupConfig[]): void => config.forEach(groupConfig => { 100 | if (groupConfig.min == undefined) { 101 | groupConfig.min = 0; 102 | } 103 | 104 | if (groupConfig.max != undefined && groupConfig.max < groupConfig.min) { 105 | log("Collapse group min value should be lower than max.\n" + JSON.stringify(groupConfig, null, 2)); 106 | return; 107 | } 108 | 109 | if (groupConfig.max == undefined) { 110 | groupConfig.max = 100; 111 | } 112 | }); 113 | 114 | /** 115 | * Creates and returns group view data object. 116 | * @param haGroupData Home assistant group data 117 | * @param batteries Batterry view model 118 | * @param config Collapsing group config 119 | */ 120 | const createGroup = (haGroupData: IGroupDataMap, batteryIds: string[], config?: IGroupConfig): IBatteryGroup => { 121 | 122 | if (config?.group_id && !haGroupData[config.group_id]) { 123 | throw new Error("Group not found: " + config.group_id); 124 | } 125 | 126 | let name = config?.name; 127 | if (!name && config?.group_id) { 128 | name = haGroupData[config.group_id].friendly_name; 129 | } 130 | 131 | let icon = config?.icon; 132 | if (icon === undefined && config?.group_id) { 133 | icon = haGroupData[config.group_id].icon; 134 | } 135 | 136 | return { 137 | title: name, 138 | icon: icon, 139 | iconColor: config?.icon_color, 140 | batteryIds: batteryIds, 141 | secondaryInfo: config?.secondary_info 142 | } 143 | } 144 | 145 | /** 146 | * Replaces all keywords, used in the text, with values 147 | * @param text Text to process 148 | * @param group Battery group view data 149 | */ 150 | const getEnrichedText = (text: string, group: IBatteryGroup, batteries: IBatteryCollection): string => { 151 | text = text.replace(/\{[a-z]+\}/g, keyword => { 152 | switch (keyword) { 153 | case "{min}": 154 | return group.batteryIds.reduce((agg, id) => agg > toNumber(batteries[id].state) ? toNumber(batteries[id].state) : agg, 100).toString(); 155 | case "{max}": 156 | return group.batteryIds.reduce((agg, id) => agg < toNumber(batteries[id].state) ? toNumber(batteries[id].state) : agg, 0).toString(); 157 | case "{count}": 158 | return group.batteryIds.length.toString(); 159 | case "{range}": 160 | const min = group.batteryIds.reduce((agg, id) => agg > toNumber(batteries[id].state) ? toNumber(batteries[id].state) : agg, 100).toString(); 161 | const max = group.batteryIds.reduce((agg, id) => agg < toNumber(batteries[id].state) ? toNumber(batteries[id].state) : agg, 0).toString(); 162 | return min == max ? min : min + "-" + max; 163 | default: 164 | return keyword; 165 | } 166 | }); 167 | 168 | return text; 169 | } 170 | 171 | const getIcon = (icon: string | undefined, batteryIdsInGroup: string[], batteries: IBatteryCollection): string | undefined => { 172 | switch (icon) { 173 | case "first": 174 | if (batteryIdsInGroup.length > 0) { 175 | icon = batteries[batteryIdsInGroup[0]].icon; 176 | } 177 | else { 178 | icon = undefined; 179 | } 180 | break; 181 | case "last": 182 | if (batteryIdsInGroup.length > 0) { 183 | const lastIndex = batteryIdsInGroup.length - 1; 184 | icon = batteries[batteryIdsInGroup[lastIndex]].icon; 185 | } 186 | else { 187 | icon = undefined; 188 | } 189 | break; 190 | } 191 | 192 | return icon; 193 | } 194 | 195 | const getIconColor = (iconColor: string | undefined, batteryIdsInGroup: string[], batteries: IBatteryCollection): string | undefined => { 196 | switch (iconColor) { 197 | case "first": 198 | if (batteryIdsInGroup.length > 0) { 199 | iconColor = batteries[batteryIdsInGroup[0]].iconColor; 200 | } 201 | else { 202 | iconColor = undefined; 203 | } 204 | break; 205 | case "last": 206 | if (batteryIdsInGroup.length > 0) { 207 | const lastIndex = batteryIdsInGroup.length - 1; 208 | iconColor = batteries[batteryIdsInGroup[lastIndex]].iconColor; 209 | } 210 | else { 211 | iconColor = undefined; 212 | } 213 | break; 214 | } 215 | 216 | return iconColor; 217 | } -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { BatteryStateEntity } from "./custom-elements/battery-state-entity"; 2 | import { BatteryStateCard } from "./custom-elements/battery-state-card"; 3 | import { log, printVersion } from "./utils"; 4 | 5 | declare let window: HomeAssistantWindow; 6 | 7 | if (customElements.get("battery-state-entity") === undefined) { 8 | printVersion(); 9 | customElements.define("battery-state-entity", BatteryStateEntity); 10 | customElements.define("battery-state-card", BatteryStateCard); 11 | } 12 | else { 13 | log("Element seems to be defined already", "warn"); 14 | } 15 | 16 | window.customCards = window.customCards || []; 17 | window.customCards.push({ 18 | type: "battery-state-card", 19 | name: "Battery state card", 20 | preview: true, 21 | description: "Customizable card for listing battery states/levels" 22 | }); -------------------------------------------------------------------------------- /src/rich-string-processor.ts: -------------------------------------------------------------------------------- 1 | import { HomeAssistant } from "custom-card-helpers"; 2 | import { log } from "./utils"; 3 | 4 | const validEntityDomains = [ 5 | "automation", 6 | "binary_sensor", 7 | "button", 8 | "calendar", 9 | "camera", 10 | "climate", 11 | "device_tracker", 12 | "group", 13 | "input_boolean", 14 | "input_datetime", 15 | "input_number", 16 | "input_select", 17 | "input_text", 18 | "light", 19 | "media_player", 20 | "number", 21 | "person", 22 | "remote", 23 | "scene", 24 | "script", 25 | "select", 26 | "sensor", 27 | "switch", 28 | "update", 29 | "weather", 30 | "zone", 31 | ]; 32 | 33 | /** 34 | * Class for processing keyword strings 35 | */ 36 | export class RichStringProcessor { 37 | 38 | constructor(private hass: HomeAssistant | undefined, private entityData: IMap | undefined) { 39 | } 40 | 41 | /** 42 | * Replaces keywords in given string with the data 43 | */ 44 | process(text: string): string { 45 | if (!text) { 46 | return ""; 47 | } 48 | 49 | return text.replace(/\{([^\}]+)\}/g, (matchWithBraces, keyword) => this.replaceKeyword(keyword, "")); 50 | } 51 | 52 | /** 53 | * Converts keyword in the final value 54 | */ 55 | private replaceKeyword(keyword: string, defaultValue: string): string { 56 | const processingDetails = keyword.split("|"); 57 | const dataSource = processingDetails.shift(); 58 | 59 | const value = this.getValue(dataSource); 60 | 61 | if (value === undefined) { 62 | return defaultValue; 63 | } 64 | 65 | const processors = processingDetails.map(command => { 66 | const match = commandPattern.exec(command); 67 | if (!match || !match.groups || !availableProcessors[match.groups.func]) { 68 | return undefined; 69 | } 70 | 71 | return availableProcessors[match.groups.func](match.groups.params); 72 | }); 73 | 74 | const result = processors.filter(p => p !== undefined).reduce((res, proc) => proc!(res), value); 75 | 76 | return result === undefined ? defaultValue : result; 77 | } 78 | 79 | private getValue(dataSource: string | undefined): string | undefined { 80 | 81 | if (dataSource === undefined) { 82 | return dataSource; 83 | } 84 | 85 | const chunks = dataSource.split("."); 86 | let data = { ...this.entityData }; 87 | 88 | if (validEntityDomains.includes(chunks[0])) { 89 | data = { 90 | ...this.hass?.states[chunks.splice(0, 2).join(".")] 91 | }; 92 | } 93 | 94 | for (let i = 0; i < chunks.length; i++) { 95 | data = data[chunks[i]]; 96 | if (data === undefined) { 97 | break; 98 | } 99 | } 100 | 101 | if (typeof data == "object") { 102 | data = JSON.stringify(data); 103 | } 104 | 105 | return data === undefined ? undefined : data.toString(); 106 | } 107 | } 108 | 109 | const commandPattern = /(?[a-z]+)\((?[^\)]*)\)/; 110 | 111 | const availableProcessors: IMap = { 112 | "replace": (params) => { 113 | const replaceDataChunks = params.split(","); 114 | if (replaceDataChunks.length != 2) { 115 | log("'replace' function requires two params"); 116 | return undefined; 117 | } 118 | 119 | return val => { 120 | return val.replace(replaceDataChunks[0], replaceDataChunks[1]) 121 | }; 122 | }, 123 | "round": (params) => { 124 | let decimalPlaces = parseInt(params); 125 | if (isNaN(decimalPlaces)) { 126 | decimalPlaces = 0; 127 | } 128 | 129 | return val => parseFloat(val).toFixed(decimalPlaces); 130 | }, 131 | "multiply": (params) => { 132 | if (params === "") { 133 | log("[KString]multiply function is missing parameter"); 134 | return val => val; 135 | } 136 | 137 | const multiplier = Number(params); 138 | 139 | return val => isNaN(multiplier) ? val : (Number(val) * multiplier).toString(); 140 | }, 141 | "greaterthan": (params) => { 142 | const chunks = params.split(","); 143 | if (chunks.length != 2) { 144 | log("[KString]greaterthan function requires two parameters"); 145 | return val => val; 146 | } 147 | 148 | const compareTo = Number(chunks[0]); 149 | return val => Number(val) > compareTo ? chunks[1] : val; 150 | }, 151 | "lessthan": (params) => { 152 | const chunks = params.split(","); 153 | if (chunks.length != 2) { 154 | log("[KString]lessthan function requires two parameters"); 155 | return val => val; 156 | } 157 | 158 | const compareTo = Number(chunks[0]); 159 | return val => Number(val) < compareTo ? chunks[1] : val; 160 | }, 161 | "between": (params) => { 162 | const chunks = params.split(","); 163 | if (chunks.length != 3) { 164 | log("[KString]between function requires three parameters"); 165 | return val => val; 166 | } 167 | 168 | const compareLower = Number(chunks[0]); 169 | const compareGreater = Number(chunks[1]); 170 | return val => { 171 | const numericVal = Number(val); 172 | return compareLower < numericVal && compareGreater > numericVal ? chunks[2] : val; 173 | } 174 | }, 175 | "thresholds": (params) => { 176 | const thresholds = params.split(",").map(v => Number(v)); 177 | 178 | return val => { 179 | const numericVal = Number(val); 180 | const result = thresholds.findIndex(v => numericVal < v); 181 | 182 | if (result == -1) { 183 | // looks like the value is higher than the last threshold 184 | return "100"; 185 | } 186 | 187 | return Math.round(100 / thresholds.length * result).toString(); 188 | } 189 | }, 190 | "abs": () => 191 | val => Math.abs(Number(val)).toString(), 192 | "equals": (params) => { 193 | const chunks = params.split(","); 194 | if (chunks.length != 2) { 195 | log("[KString]equals function requires two parameters"); 196 | return val => val; 197 | } 198 | 199 | return val => val == chunks[0] ? chunks[1] : val; 200 | }, 201 | "add": (params) => { 202 | if (params === "") { 203 | log("[KString]add function is missing parameter"); 204 | return val => val; 205 | } 206 | 207 | const addend = Number(params); 208 | 209 | return val => isNaN(addend) ? val : (Number(val) + addend).toString(); 210 | }, 211 | "reltime": () => { 212 | return val => { 213 | const unixTime = Date.parse(val); 214 | if (isNaN(unixTime)) { 215 | log("[KString]value isn't a valid date: " + val); 216 | return val; 217 | } 218 | 219 | // The RT tags will be converted to proper HA tags at the views layer 220 | return `${val}` 221 | }; 222 | } 223 | } 224 | 225 | interface IProcessor { 226 | (val: string): string; 227 | } 228 | 229 | interface IProcessorCtor { 230 | (params: string): IProcessor | undefined 231 | } 232 | 233 | -------------------------------------------------------------------------------- /src/sorting.ts: -------------------------------------------------------------------------------- 1 | import { IBatteryCollection } from "./battery-provider"; 2 | import { isNumber, log, safeGetConfigArrayOfObjects, toNumber } from "./utils"; 3 | 4 | /** 5 | * Sorts batteries by given criterias and returns their IDs 6 | * @param config Card configuration 7 | * @param batteries List of all known battery elements 8 | * @returns List of battery IDs (batteries sorted by given criterias) 9 | */ 10 | export const getIdsOfSortedBatteries = (config: IBatteryCardConfig, batteries: IBatteryCollection): string[] => { 11 | let batteriesToSort = Object.keys(batteries); 12 | 13 | const sortOptions = safeGetConfigArrayOfObjects(config.sort, "by"); 14 | 15 | return batteriesToSort.sort((idA, idB) => { 16 | let result = 0; 17 | sortOptions.find(o => { 18 | 19 | let valA: any; 20 | let valB: any; 21 | 22 | switch(o.by) { 23 | case "name": 24 | valA = batteries[idA].name; 25 | valB = batteries[idB].name; 26 | break; 27 | case "state": 28 | // always prefer numeric state for sorting 29 | valA = batteries[idA].stateNumeric == undefined ? batteries[idA].state : batteries[idA].stateNumeric; 30 | valB = batteries[idB].stateNumeric == undefined ? batteries[idB].state : batteries[idB].stateNumeric; 31 | break; 32 | default: 33 | if ((o.by).startsWith("entity.")) { 34 | const pathChunks = (o.by).split("."); 35 | pathChunks.shift(); 36 | valA = pathChunks.reduce((acc, val, i) => acc === undefined ? undefined : acc[val], batteries[idA].entityData); 37 | valB = pathChunks.reduce((acc, val, i) => acc === undefined ? undefined : acc[val], batteries[idB].entityData); 38 | } 39 | else { 40 | log("Unknown sort field: " + o.by, "warn"); 41 | } 42 | } 43 | 44 | if (isNumber(valA) || isNumber(valB)) { 45 | result = compareNumbers(valA, valB); 46 | } 47 | else if (valA === undefined) { 48 | if (valB === undefined) { 49 | result = 0; 50 | } 51 | else { 52 | result = -1; 53 | } 54 | } 55 | else { 56 | result = compareStrings(valA, valB); 57 | } 58 | 59 | if (o.desc) { 60 | // opposite result 61 | result *= -1; 62 | } 63 | 64 | return result != 0; 65 | }); 66 | 67 | return result; 68 | }); 69 | } 70 | 71 | /** 72 | * Number comparer 73 | * @param a Value A 74 | * @param b Value B 75 | * @returns Comparison result 76 | */ 77 | const compareNumbers = (a: string, b: string): number => { 78 | let aNum = toNumber(a); 79 | let bNum = toNumber(b); 80 | aNum = isNaN(aNum) ? -1 : aNum; 81 | bNum = isNaN(bNum) ? -1 : bNum; 82 | return aNum - bNum; 83 | } 84 | 85 | 86 | /** 87 | * String comparer 88 | * @param a Value A 89 | * @param b Value B 90 | * @returns Comparison result 91 | */ 92 | const compareStrings = (a: string, b: string): number => a.localeCompare(b); -------------------------------------------------------------------------------- /src/type-extensions.ts: -------------------------------------------------------------------------------- 1 | import { HomeAssistant } from "custom-card-helpers"; 2 | 3 | /** 4 | * https://github.com/home-assistant/frontend/blob/dev/src/types.ts 5 | */ 6 | export interface HomeAssistantExt extends HomeAssistant { 7 | entities: { [id: string]: EntityRegistryDisplayEntry }; 8 | devices: { [id: string]: DeviceRegistryEntry }; 9 | areas: { [id: string]: AreaRegistryEntry }; 10 | 11 | formatEntityState(stateObj: any, state?: string): string; 12 | formatEntityAttributeValue( 13 | stateObj: any, 14 | attribute: string, 15 | value?: any 16 | ): string; 17 | formatEntityAttributeName(stateObj: any, attribute: string): string; 18 | } 19 | 20 | type entityCategory = "config" | "diagnostic"; 21 | 22 | export interface EntityRegistryDisplayEntry { 23 | entity_id: string; 24 | name?: string; 25 | device_id?: string; 26 | area_id?: string; 27 | hidden?: boolean; 28 | entity_category?: entityCategory; 29 | translation_key?: string; 30 | platform?: string; 31 | display_precision?: number; 32 | } 33 | 34 | export interface DeviceRegistryEntry { 35 | id: string; 36 | config_entries: string[]; 37 | connections: Array<[string, string]>; 38 | identifiers: Array<[string, string]>; 39 | manufacturer: string | null; 40 | model: string | null; 41 | name: string | null; 42 | sw_version: string | null; 43 | hw_version: string | null; 44 | serial_number: string | null; 45 | via_device_id: string | null; 46 | area_id: string | null; 47 | name_by_user: string | null; 48 | entry_type: "service" | null; 49 | disabled_by: "user" | "integration" | "config_entry" | null; 50 | configuration_url: string | null; 51 | } 52 | 53 | export interface AreaRegistryEntry { 54 | area_id: string; 55 | name: string; 56 | picture: string | null; 57 | aliases: string[]; 58 | } -------------------------------------------------------------------------------- /src/typings.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.css"; 2 | 3 | /** 4 | * Color threshold 5 | */ 6 | interface IColorSteps { 7 | /** 8 | * Value/threshold below which color should be applied 9 | */ 10 | value?: number; 11 | 12 | /** 13 | * Color to be applied when value is below the threshold 14 | */ 15 | color: string; 16 | } 17 | 18 | /** 19 | * Color settings 20 | */ 21 | interface IColorSettings { 22 | /** 23 | * Color steps 24 | */ 25 | steps: ISimplifiedArray; 26 | /** 27 | * Whether to enable smooth color transition between steps 28 | */ 29 | gradient?: boolean; 30 | } 31 | 32 | /** 33 | * Supported action names 34 | */ 35 | type SupportedActions = "more-info" | "call-service" | "navigate" | "url"; 36 | 37 | /** 38 | * Action configuration (tapping/clicking) 39 | */ 40 | interface IActionConfig { 41 | /** 42 | * Action to be performed 43 | */ 44 | action: SupportedActions; 45 | 46 | /** 47 | * Navigation path (home assistant page url path) 48 | */ 49 | navigation_path: string; 50 | 51 | /** 52 | * Url to navigate (external) 53 | */ 54 | url_path: string; 55 | 56 | /** 57 | * Name of the service to call 58 | */ 59 | service: string; 60 | 61 | /** 62 | * Data for the service call 63 | */ 64 | service_data: any; 65 | } 66 | 67 | /** 68 | * Convert one value to another 69 | */ 70 | interface IConvert { 71 | /** 72 | * Value to look for 73 | */ 74 | from: string; 75 | 76 | /** 77 | * Value replacement 78 | */ 79 | to: string; 80 | 81 | /** 82 | * Entity state to display 83 | */ 84 | display?: string; 85 | } 86 | 87 | interface IBulkRename { 88 | /** 89 | * Rules for replacing one value by other 90 | */ 91 | rules?: IConvert | IConvert[]; 92 | 93 | /** 94 | * Whether to capitalize first letter 95 | */ 96 | capitalize_first?: boolean 97 | } 98 | 99 | /** 100 | * Attribute 101 | */ 102 | interface IAttribute { 103 | /** 104 | * Name 105 | */ 106 | name: string; 107 | 108 | /** 109 | * Value 110 | */ 111 | value: any; 112 | } 113 | 114 | interface IChargingState { 115 | /** 116 | * Entity ID to extract the charging state info (in case it is different than the base entity) 117 | */ 118 | entity_id?: string; 119 | 120 | /** 121 | * Collection of states indicating that battery is charging 122 | */ 123 | state?: string | string[]; 124 | 125 | /** 126 | * Attribute to extract charging state info from 127 | */ 128 | attribute?: IAttribute | IAttribute[]; 129 | 130 | /** 131 | * Icon override for charging indication 132 | */ 133 | icon?: string; 134 | 135 | /** 136 | * Color override for charging indication 137 | */ 138 | color?: string; 139 | 140 | /** 141 | * Override for the text shown in secondary_info (when battery is charging) 142 | */ 143 | secondary_info_text?: string; 144 | } 145 | 146 | /** 147 | * Filter group types 148 | */ 149 | type FilterGroups = "exclude" | "include"; 150 | 151 | /** 152 | * Supprted filter operators 153 | */ 154 | type FilterOperator = "exists" | "not_exists" | "=" | ">" | "<" | ">=" | "<=" | "contains" | "matches"; 155 | 156 | /** 157 | * Allowed filter value types 158 | */ 159 | type FilterValueType = string | number | boolean | null | undefined; 160 | 161 | /** 162 | * Filter object 163 | */ 164 | interface IFilter { 165 | /** 166 | * Name of the entity property or attribute (attributes has to be prefixed with "attributes.") 167 | */ 168 | name: string; 169 | 170 | /** 171 | * Operator used to compare the values 172 | */ 173 | operator?: FilterOperator; 174 | 175 | /** 176 | * Value to compare with the extracted one 177 | */ 178 | value?: FilterValueType; 179 | } 180 | 181 | interface IBatteryEntityConfig { 182 | 183 | /** 184 | * Entity ID 185 | */ 186 | entity: string; 187 | 188 | /** 189 | * Override for entity name / friendly_name 190 | */ 191 | name?: string; 192 | 193 | /** 194 | * Icon override 195 | */ 196 | icon?: string; 197 | 198 | /** 199 | * Attribute name to extract batterly level from 200 | */ 201 | attribute?: string; 202 | 203 | /** 204 | * Multiplier for battery level (when not in 0-100 range) 205 | */ 206 | multiplier?: number; 207 | 208 | /** 209 | * When specified it rounds the value to number of fractional digits 210 | */ 211 | round?: number; 212 | 213 | /** 214 | * Action to be performed when entity is tapped/clicked 215 | */ 216 | tap_action?: IActionConfig; 217 | 218 | /** 219 | * Collection of mappings for values (useful when state/level is not numeric) 220 | */ 221 | state_map?: IConvert[]; 222 | 223 | /** 224 | * Configuration for charging state indication 225 | */ 226 | charging_state?: IChargingState; 227 | 228 | /** 229 | * (Testing purposes) Override for battery level value 230 | */ 231 | value_override?: string | number; 232 | 233 | /** 234 | * Colors settings 235 | */ 236 | colors?: IColorSettings; 237 | 238 | /** 239 | * What to display as secondary info 240 | */ 241 | secondary_info?: string; 242 | 243 | /** 244 | * Rules for renaming entities/batteries 245 | */ 246 | bulk_rename?: IConvert | IConvert[] | IBulkRename; 247 | 248 | /** 249 | * Override for unit shown next to the value 250 | */ 251 | unit?: string; 252 | 253 | /** 254 | * Whether the entity is not a battery entity 255 | */ 256 | non_battery_entity?: boolean; 257 | 258 | /** 259 | * Whether to allow HA to format the state value 260 | */ 261 | default_state_formatting?: boolean; 262 | 263 | /** 264 | * Whether to add display/device/area data 265 | */ 266 | extend_entity_data?: boolean, 267 | 268 | /** 269 | * Whether to print the debug output 270 | */ 271 | debug?: string | boolean, 272 | } 273 | 274 | interface IBatteryCardConfig { 275 | /** 276 | * List of entities to show in the card 277 | */ 278 | entities: ISimplifiedArray; 279 | 280 | /** 281 | * Title of the card (header text) 282 | */ 283 | title?: string; 284 | 285 | /** 286 | * Sort options 287 | */ 288 | sort?: ISimplifiedArray; 289 | 290 | /** 291 | * Collapse after given number of entities 292 | */ 293 | collapse?: number | IGroupConfig[]; 294 | 295 | /** 296 | * Filters for auto adding or removing entities 297 | */ 298 | filter?: { [key in FilterGroups]: IFilter[] }; 299 | } 300 | 301 | /** 302 | * Battery card root config 303 | */ 304 | interface IBatteryStateCardConfig extends IBatteryCardConfig, IBatteryEntityConfig { 305 | 306 | } 307 | 308 | type SortByOption = "state" | "name"; 309 | 310 | interface ISortOption { 311 | by: SortByOption; 312 | desc?: boolean; 313 | } 314 | 315 | interface IHomeAssistantGroupProps { 316 | entity_id: string[]; 317 | friendly_name?: string; 318 | icon?: string; 319 | } 320 | 321 | interface IGroupDataMap { 322 | [group_id: string]: IHomeAssistantGroupProps 323 | } 324 | 325 | interface IGroupConfig { 326 | name?: string; 327 | secondary_info?: string; 328 | group_id?: string; 329 | entities?: string[]; 330 | icon?: string; 331 | icon_color?: string; 332 | min?: number; 333 | max?: number; 334 | } 335 | 336 | interface IAction { 337 | (evt: Event): void 338 | } 339 | 340 | interface IActionData { 341 | config: IActionConfig 342 | card: Node; 343 | entityId: string 344 | } 345 | 346 | interface IMap { 347 | [key: string]: T; 348 | } 349 | 350 | type IObjectOrString = T | string; 351 | type ISimplifiedArray = IObjectOrString | IObjectOrString[] | undefined; 352 | 353 | interface HomeAssistantWindow extends Window { 354 | customCards: ICardInfo[] | undefined; 355 | } 356 | 357 | interface ICardInfo { 358 | type: string; 359 | name: string; 360 | description: string; 361 | preview?: boolean; 362 | documentationURL?: string; 363 | } -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export const printVersion = () => console.info( 2 | "%c BATTERY-STATE-CARD %c [VI]{version}[/VI]", 3 | "color: white; background: forestgreen; font-weight: 700;", 4 | "color: forestgreen; background: white; font-weight: 700;", 5 | ); 6 | 7 | /** 8 | * Logs message in developer console 9 | * @param message Message to log 10 | * @param level Message level/importance 11 | */ 12 | export const log = (message: string, level: "warn" | "error" = "warn") => { 13 | console[level]("[battery-state-card] " + message); 14 | } 15 | 16 | /** 17 | * Checks whether given value is a number 18 | * @param val String value to check 19 | */ 20 | export const isNumber = (value: string | number | boolean | null | undefined): boolean => { 21 | if (value === undefined || value === null || typeof value === "boolean") { 22 | return false; 23 | } 24 | 25 | if (typeof(value) == "string") { 26 | // trying to solve decimal number formatting in some langs 27 | value = value.replace(",", "."); 28 | } 29 | 30 | return value !== '' && !isNaN(Number(value)); 31 | } 32 | 33 | /** 34 | * Converts string representation of the number to the actual JS number 35 | * @param value String value to convert 36 | * @returns Result number 37 | */ 38 | export const toNumber = (value: string | number | boolean | null | undefined): number => { 39 | if (typeof(value) == "string") { 40 | // trying to solve decimal number formatting in some langs 41 | value = value.replace(",", "."); 42 | } 43 | 44 | return Number(value); 45 | } 46 | 47 | /** 48 | * Returns array of values regardles if given value is string array or null 49 | * @param val Value to process 50 | */ 51 | export const safeGetArray = (val: T | T[] | undefined): T[] => { 52 | if (Array.isArray(val)) { 53 | return val; 54 | } 55 | 56 | return val !== undefined ? [val] : []; 57 | }; 58 | 59 | /** 60 | * Converts config value to array of specified objects. 61 | * 62 | * ISimplifiedArray config object supports simple list of strings or even an individual item. This function 63 | * ensures we're getting an array in all situations. 64 | * 65 | * E.g. all of the below are valid entries and can be converted to objects 66 | * 1. Single string 67 | * my_setting: "name" 68 | * 2. Single object 69 | * my_setting: 70 | * by: "name" 71 | * desc: true 72 | * 3. Array of strings 73 | * my_setting: 74 | * - "name" 75 | * - "state" 76 | * 4. Array of objects 77 | * my_setting: 78 | * - by: "name" 79 | * - by: "sort" 80 | * desc: true 81 | * 82 | * @param value Config array 83 | * @param defaultKey Key of the object to populate 84 | * @returns Array of objects 85 | */ 86 | export const safeGetConfigArrayOfObjects = (value: ISimplifiedArray, defaultKey: keyof T): T[] => { 87 | return safeGetArray(value).map(v => safeGetConfigObject(v, defaultKey)); 88 | } 89 | 90 | /** 91 | * Converts string to object with given property or returns the object if it is not a string 92 | * @param value Value from the config 93 | * @param propertyName Property name of the expected config object to which value will be assigned 94 | */ 95 | export const safeGetConfigObject = (value: IObjectOrString, propertyName: keyof T): T => { 96 | 97 | switch (typeof value) { 98 | case "string": 99 | const result = {}; 100 | result[propertyName] = value; 101 | return result; 102 | case "object": 103 | // make a copy as the original one is immutable 104 | return { ...value }; 105 | } 106 | 107 | return value; 108 | } 109 | 110 | /** 111 | * Throttles given function calls. In given throttle time always the last arriving call is executed. 112 | * @param func Function to call 113 | * @param throttleMs Number of ms to wait before calling 114 | */ 115 | export const throttledCall = function (func: T, throttleMs: number): T { 116 | let timeoutHook: any; 117 | return (((...args: []) => { 118 | if (timeoutHook) { 119 | // cancel previous call 120 | clearTimeout(timeoutHook); 121 | timeoutHook = null; 122 | } 123 | 124 | // schedule new call 125 | timeoutHook = setTimeout(() => func.apply(null, args), 100); 126 | })) as T 127 | } 128 | 129 | 130 | const regexPattern = /\/(.*?)\/([igm]{1,3})/ 131 | /** 132 | * Extracts regex from the given string 133 | * @param ruleVal Value to process 134 | * @returns Parsed regex 135 | */ 136 | export const getRegexFromString = (ruleVal: string): RegExp | null => { 137 | if (ruleVal[0] == "/" && ruleVal[ruleVal.length - 1] == "/") { 138 | return new RegExp(ruleVal.substr(1, ruleVal.length - 2)); 139 | } 140 | else { 141 | let matches = ruleVal.match(regexPattern) 142 | if (matches && matches.length == 3) { 143 | return new RegExp(matches[1], matches[2]); 144 | } 145 | } 146 | 147 | return null; 148 | } 149 | 150 | /** 151 | * Extracts value for given path from the object 152 | * @param dataObject Object to extract data from 153 | * @param path Path to the value 154 | * @returns Value from the path 155 | */ 156 | export const getValueFromObject = (dataObject: any, path: string): string | number | boolean | null | undefined => { 157 | const chunks = path.split("."); 158 | 159 | for (let i = 0; i < chunks.length; i++) { 160 | dataObject = dataObject[chunks[i]]; 161 | if (dataObject === undefined) { 162 | break; 163 | } 164 | } 165 | 166 | if (dataObject !== null && typeof dataObject == "object") { 167 | dataObject = JSON.stringify(dataObject); 168 | } 169 | 170 | return dataObject; 171 | } -------------------------------------------------------------------------------- /test/card/entity-list.test.ts: -------------------------------------------------------------------------------- 1 | import { BatteryStateCard } from "../../src/custom-elements/battery-state-card"; 2 | import { CardElements, HomeAssistantMock } from "../helpers"; 3 | 4 | test("Entities as strings/ids", async () => { 5 | 6 | const hass = new HomeAssistantMock(); 7 | const motionSensor = hass.addEntity("Bedroom motion battery level", "90"); 8 | const tempSensor = hass.addEntity("Temp sensor battery level", "50"); 9 | 10 | const cardElem = hass.addCard("battery-state-card", { 11 | title: "Header", 12 | entities: [ // array of entity IDs 13 | motionSensor.entity_id, 14 | tempSensor.entity_id, 15 | ] 16 | }); 17 | 18 | // waiting for card to be updated/rendered 19 | await cardElem.cardUpdated; 20 | 21 | const card = new CardElements(cardElem); 22 | 23 | expect(card.header).toBe("Header"); 24 | expect(card.itemsCount).toBe(2); 25 | }); 26 | 27 | test("Entities as objects with custom settings", async () => { 28 | const hass = new HomeAssistantMock(); 29 | const motionSensor = hass.addEntity("Bedroom motion battery level", "90"); 30 | const tempSensor = hass.addEntity("Temp sensor battery level", "50"); 31 | 32 | const cardElem = hass.addCard("battery-state-card", { 33 | title: "Header", 34 | entities: [ // array of entity IDs 35 | { 36 | entity: motionSensor.entity_id, 37 | name: "Entity 1" 38 | }, 39 | { 40 | entity: tempSensor.entity_id, 41 | name: "Entity 2" 42 | } 43 | ] 44 | }); 45 | 46 | // waiting for card to be updated/rendered 47 | await cardElem.cardUpdated; 48 | 49 | const card = new CardElements(cardElem); 50 | 51 | expect(card.itemsCount).toBe(2); 52 | expect(card.item(0).nameText).toBe("Entity 1"); 53 | expect(card.item(1).nameText).toBe("Entity 2"); 54 | }); -------------------------------------------------------------------------------- /test/card/filters.test.ts: -------------------------------------------------------------------------------- 1 | import { BatteryStateCard } from "../../src/custom-elements/battery-state-card"; 2 | import { CardElements, HomeAssistantMock } from "../helpers"; 3 | 4 | test("Include filter via entity_id", async () => { 5 | 6 | const hass = new HomeAssistantMock(); 7 | hass.addEntity("Bedroom motion battery level", "90"); 8 | hass.addEntity("Temp sensor battery level", "50"); 9 | 10 | const cardElem = hass.addCard("battery-state-card", { 11 | title: "Header", 12 | filter: { 13 | include: [ 14 | { 15 | name: "entity_id", 16 | value: "*_battery_level" 17 | } 18 | ], 19 | exclude: [] 20 | }, 21 | entities: [] 22 | }); 23 | 24 | // waiting for card to be updated/rendered 25 | await cardElem.cardUpdated; 26 | 27 | const card = new CardElements(cardElem); 28 | 29 | expect(card.itemsCount).toBe(2); 30 | }); 31 | 32 | test("Include via entity_id and exclude via state - empty result", async () => { 33 | 34 | const hass = new HomeAssistantMock(); 35 | hass.addEntity("Bedroom motion battery level", "90"); 36 | hass.addEntity("Temp sensor battery level", "50"); 37 | 38 | const cardElem = hass.addCard("battery-state-card", { 39 | title: "Header", 40 | filter: { 41 | include: [ 42 | { 43 | name: "entity_id", 44 | value: "*_battery_level" 45 | } 46 | ], 47 | exclude: [ 48 | { 49 | name: "state", 50 | value: 40, 51 | operator: ">" 52 | } 53 | ] 54 | }, 55 | entities: [] 56 | }); 57 | 58 | // waiting for card to be updated/rendered 59 | await cardElem.cardUpdated; 60 | 61 | const card = new CardElements(cardElem); 62 | 63 | expect(card.itemsCount).toBe(0); 64 | // we expect to not have any content 65 | expect(cardElem.shadowRoot!.childElementCount).toBe(0); 66 | }); 67 | 68 | 69 | test.each([ 70 | [false, 1], 71 | [true, 0], 72 | ])("Entity filtered based on hidden state", async (isHidden: boolean, numOfRenderedEntities: number) => { 73 | 74 | const hass = new HomeAssistantMock(); 75 | const entity = hass.addEntity("Bedroom motion battery level", "90"); 76 | entity.setProperty("display", { entity_id: "", hidden: isHidden }) 77 | 78 | const cardElem = hass.addCard("battery-state-card", { 79 | title: "Header", 80 | filter: { 81 | include: [ 82 | { 83 | name: "entity_id", 84 | value: "*_battery_level" 85 | } 86 | ], 87 | exclude: [], 88 | }, 89 | entities: [] 90 | }); 91 | 92 | // waiting for card to be updated/rendered 93 | await cardElem.cardUpdated; 94 | 95 | const card = new CardElements(cardElem); 96 | 97 | expect(card.itemsCount).toBe(numOfRenderedEntities); 98 | }); -------------------------------------------------------------------------------- /test/card/grouping.test.ts: -------------------------------------------------------------------------------- 1 | import { BatteryStateCard } from "../../src/custom-elements/battery-state-card"; 2 | import { CardElements, HomeAssistantMock } from "../helpers"; 3 | 4 | describe("Grouping", () => { 5 | test.each([ 6 | [["10", "24", "25", "26", "50"], 25, "10 %|24 %", "25 %|26 %|50 %"], 7 | [["10.1", "24.2", "25.3", "26.4", "50.5"], 25, "10,1 %|24,2 %", "25,3 %|26,4 %|50,5 %", ","], 8 | [["10.1", "24.2", "25.3", "26.4", "50.5"], 25, "10.1 %|24.2 %", "25.3 %|26.4 %|50.5 %", "."], 9 | ])("works with 'min' setting", async (entityStates: string[], min: number, ungrouped: string, inGroup: string, decimalPoint = ".") => { 10 | 11 | const hass = new HomeAssistantMock(); 12 | const entities = entityStates.map((state, i) => { 13 | const batt = hass.addEntity(`Batt ${i + 1}`, state); 14 | return batt.entity_id; 15 | }); 16 | const groupEntity = hass.addEntity("My group", "30", { entity_id: entities }, "group"); 17 | 18 | hass.mockFunc("formatEntityState", (entityData: any) => `${entityData.state.replace(".", decimalPoint)} %`); 19 | 20 | const cardElem = hass.addCard("battery-state-card", { 21 | title: "Header", 22 | entities: [], 23 | //sort: "state", 24 | collapse: [ 25 | { 26 | group_id: groupEntity.entity_id, 27 | min 28 | } 29 | ] 30 | }); 31 | 32 | // waiting for card to be updated/rendered 33 | await cardElem.cardUpdated; 34 | 35 | const card = new CardElements(cardElem); 36 | 37 | const ungroupedStates = card.items.map(e => e.stateText).join("|"); 38 | expect(ungroupedStates).toBe(ungrouped); 39 | 40 | expect(card.groupsCount).toBe(1); 41 | 42 | const groupStates = card.group(0).items.map(e => e.stateText).join("|"); 43 | expect(groupStates).toBe(inGroup); 44 | }); 45 | 46 | test.each([ 47 | [["10", "24", "25", "26", "50"], "Min {min}, Max {max}, Range {range}, Count {count}", "Min 25, Max 50, Range 25-50, Count 3"], 48 | ])("secondary info keywords", async (entityStates: string[], secondaryInfo: string, expectedSecondaryInfo: string) => { 49 | 50 | const hass = new HomeAssistantMock(); 51 | const entities = entityStates.map((state, i) => { 52 | const batt = hass.addEntity(`Batt ${i + 1}`, state); 53 | return batt.entity_id; 54 | }); 55 | const groupEntity = hass.addEntity("My group", "30", { entity_id: entities }, "group"); 56 | 57 | const cardElem = hass.addCard("battery-state-card", { 58 | title: "Header", 59 | entities: [], 60 | //sort: "state", 61 | collapse: [ 62 | { 63 | group_id: groupEntity.entity_id, 64 | min: 25, 65 | secondary_info: secondaryInfo 66 | } 67 | ] 68 | }); 69 | 70 | // waiting for card to be updated/rendered 71 | await cardElem.cardUpdated; 72 | 73 | const card = new CardElements(cardElem); 74 | 75 | expect(card.groupsCount).toBe(1); 76 | expect(card.group(0).secondaryInfoText).toBe(expectedSecondaryInfo); 77 | }); 78 | }); -------------------------------------------------------------------------------- /test/card/sorting.test.ts: -------------------------------------------------------------------------------- 1 | import { BatteryStateCard } from "../../src/custom-elements/battery-state-card"; 2 | import { CardElements, HomeAssistantMock } from "../helpers"; 3 | 4 | 5 | describe("Entities correctly sorted", () => { 6 | test.each([ 7 | ["state", ["50", "90", "40"], "40 %, 50 %, 90 %"], 8 | ["state", ["40.5", "40.9", "40", "40.4"], "40 %, 40,4 %, 40,5 %, 40,9 %"], 9 | ])("when various state values appear", async (sort: ISimplifiedArray, entityStates: string[], expectedOrder: string) => { 10 | 11 | const hass = new HomeAssistantMock(); 12 | const entities = entityStates.map((state, i) => { 13 | const batt = hass.addEntity(`Batt ${i + 1}`, state); 14 | return batt.entity_id; 15 | }); 16 | 17 | hass.mockFunc("formatEntityState", (entityData: any) => `${entityData.state.replace(".", ",")} %`); 18 | 19 | const cardElem = hass.addCard("battery-state-card", { 20 | title: "Header", 21 | entities, 22 | sort 23 | }); 24 | 25 | // waiting for card to be updated/rendered 26 | await cardElem.cardUpdated; 27 | 28 | const card = new CardElements(cardElem); 29 | 30 | const result = card.items.map(e => e.stateText).join(", "); 31 | expect(result).toBe(expectedOrder); 32 | }); 33 | 34 | test.each([ 35 | ["state", ["50%", "90%", "40%"], "40 %, 50 %, 90 %"], 36 | ])("when HA state formatting returns various result formats", async (sort: ISimplifiedArray, formattedStates: string[], expectedOrder: string) => { 37 | 38 | const hass = new HomeAssistantMock(); 39 | const entities = formattedStates.map((state, i) => { 40 | const batt = hass.addEntity(`Batt ${i + 1}`, "10"); 41 | return batt.entity_id; 42 | }); 43 | 44 | let i = 0; 45 | hass.mockFunc("formatEntityState", (entityData: any) => formattedStates[i++]); 46 | 47 | const cardElem = hass.addCard("battery-state-card", { 48 | title: "Header", 49 | entities, 50 | sort 51 | }); 52 | 53 | // waiting for card to be updated/rendered 54 | await cardElem.cardUpdated; 55 | 56 | const card = new CardElements(cardElem); 57 | 58 | const result = card.items.map(e => e.stateText).join(", "); 59 | expect(result).toBe(expectedOrder); 60 | }); 61 | 62 | test.each([ 63 | ["state", ["good", "low", "empty", "full"], "Empty, Low, Good, Full"], 64 | ["state", ["good", "low", "unknown", "empty", "full"], "Unknown, Empty, Low, Good, Full"], 65 | ])("when state_map is used with display value", async (sort: ISimplifiedArray, formattedStates: string[], expectedOrder: string) => { 66 | 67 | const hass = new HomeAssistantMock(); 68 | const entities = formattedStates.map((state, i) => { 69 | const batt = hass.addEntity(`Batt ${i + 1}`, state); 70 | return batt.entity_id; 71 | }); 72 | 73 | const cardElem = hass.addCard("battery-state-card", { 74 | title: "Header", 75 | entities, 76 | sort, 77 | state_map: [ 78 | { 79 | from: "empty", 80 | to: "0", 81 | display: "Empty" 82 | }, 83 | { 84 | from: "low", 85 | to: "1", 86 | display: "Low" 87 | }, 88 | { 89 | from: "good", 90 | to: "2", 91 | display: "Good" 92 | }, 93 | { 94 | from: "full", 95 | to: "3", 96 | display: "Full" 97 | }, 98 | ] 99 | }); 100 | 101 | // waiting for card to be updated/rendered 102 | await cardElem.cardUpdated; 103 | 104 | const card = new CardElements(cardElem); 105 | 106 | const result = card.items.map(e => e.stateText).join(", "); 107 | expect(result).toBe(expectedOrder); 108 | }); 109 | }); -------------------------------------------------------------------------------- /test/entity/icon.test.ts: -------------------------------------------------------------------------------- 1 | import { BatteryStateEntity } from "../../src/custom-elements/battery-state-entity"; 2 | import { EntityElements, HomeAssistantMock } from "../helpers"; 3 | 4 | test.each([ 5 | [95, "mdi:battery"], 6 | [94, "mdi:battery-90"], 7 | [49, "mdi:battery-50"], 8 | [10, "mdi:battery-10"], 9 | [5, "mdi:battery-10"], 10 | [4, "mdi:battery-outline"], 11 | [0, "mdi:battery-outline"], 12 | ])("Dynamic battery icon", async (state: number, expectedIcon: string) => { 13 | 14 | const hass = new HomeAssistantMock(); 15 | const sensor = hass.addEntity("Motion sensor battery level", state.toString()); 16 | const cardElem = hass.addCard("battery-state-entity", { 17 | entity: sensor.entity_id 18 | }); 19 | 20 | await cardElem.cardUpdated; 21 | 22 | const entity = new EntityElements(cardElem); 23 | 24 | expect(entity.iconName).toBe(expectedIcon); 25 | }); 26 | 27 | test("Static icon", async () => { 28 | const hass = new HomeAssistantMock(); 29 | const sensor = hass.addEntity("Motion sensor battery level", "80"); 30 | const cardElem = hass.addCard("battery-state-entity", { 31 | entity: sensor.entity_id, 32 | icon: "mdi:static" 33 | }); 34 | 35 | await cardElem.cardUpdated; 36 | 37 | const entity = new EntityElements(cardElem); 38 | 39 | expect(entity.iconName).toBe("mdi:static"); 40 | }); 41 | 42 | // ################# Charging state ################# 43 | 44 | test.each([ 45 | [95, "mdi:battery-charging-100"], 46 | [94, "mdi:battery-charging-90"], 47 | [49, "mdi:battery-charging-50"], 48 | [10, "mdi:battery-charging-10"], 49 | [5, "mdi:battery-charging-10"], 50 | [4, "mdi:battery-charging-outline"], 51 | [0, "mdi:battery-charging-outline"], 52 | ])("Dynamic charging icon", async (state: number, expectedIcon: string) => { 53 | 54 | const hass = new HomeAssistantMock(); 55 | const sensor = hass.addEntity("Motion sensor battery level", state.toString(), { is_charging: "true" }); 56 | const cardElem = hass.addCard("battery-state-entity", { 57 | entity: sensor.entity_id, 58 | charging_state: { 59 | attribute: { 60 | name: "is_charging", 61 | value: "true" 62 | } 63 | } 64 | }); 65 | 66 | await cardElem.cardUpdated; 67 | 68 | const entity = new EntityElements(cardElem); 69 | 70 | expect(entity.iconName).toBe(expectedIcon); 71 | }); 72 | 73 | test("Static charging icon", async () => { 74 | const hass = new HomeAssistantMock(); 75 | const sensor = hass.addEntity("Motion sensor battery level", "80", { is_charging: "true" }); 76 | const cardElem = hass.addCard("battery-state-entity", { 77 | entity: sensor.entity_id, 78 | charging_state: { 79 | icon: "mdi:static-charging-icon", 80 | attribute: { 81 | name: "is_charging", 82 | value: "true" 83 | } 84 | } 85 | }); 86 | 87 | await cardElem.cardUpdated; 88 | 89 | const entity = new EntityElements(cardElem); 90 | 91 | expect(entity.iconName).toBe("mdi:static-charging-icon"); 92 | }); 93 | 94 | test("Charging state taken from object set as attribute", async () => { 95 | const hass = new HomeAssistantMock(); 96 | const sensor = hass.addEntity("Motion sensor battery level", "80", { valetudo_state: { "id": 8, "name": "Charging" } }); 97 | const cardElem = hass.addCard("battery-state-entity", { 98 | entity: sensor.entity_id, 99 | charging_state: { 100 | icon: "mdi:static-charging-icon", 101 | attribute: { 102 | name: "valetudo_state.name", 103 | value: "Charging" 104 | } 105 | } 106 | }); 107 | 108 | await cardElem.cardUpdated; 109 | 110 | const entity = new EntityElements(cardElem); 111 | 112 | expect(entity.iconName).toBe("mdi:static-charging-icon"); 113 | }); -------------------------------------------------------------------------------- /test/entity/name.test.ts: -------------------------------------------------------------------------------- 1 | import { BatteryStateEntity } from "../../src/custom-elements/battery-state-entity"; 2 | import { EntityElements, HomeAssistantMock } from "../helpers"; 3 | 4 | test("Name taken from friendly_name attribute", async () => { 5 | const hass = new HomeAssistantMock(); 6 | const sensor = hass.addEntity("Motion sensor battery level", "80"); 7 | const cardElem = hass.addCard("battery-state-entity", { 8 | entity: sensor.entity_id, 9 | }); 10 | 11 | await cardElem.cardUpdated; 12 | 13 | const entity = new EntityElements(cardElem); 14 | 15 | expect(entity.nameText).toBe("Motion sensor battery level"); 16 | }); 17 | 18 | test("Name taken from config override", async () => { 19 | const hass = new HomeAssistantMock(); 20 | const sensor = hass.addEntity("Motion sensor battery level", "80"); 21 | const cardElem = hass.addCard("battery-state-entity", { 22 | entity: sensor.entity_id, 23 | name: "Static name" 24 | }); 25 | 26 | await cardElem.cardUpdated; 27 | 28 | const entity = new EntityElements(cardElem); 29 | 30 | expect(entity.nameText).toBe("Static name"); 31 | }); -------------------------------------------------------------------------------- /test/entity/secondary-info.test.ts: -------------------------------------------------------------------------------- 1 | import { BatteryStateEntity } from "../../src/custom-elements/battery-state-entity"; 2 | import { EntityElements, HomeAssistantMock } from "../helpers"; 3 | 4 | test("Secondary info custom text", async () => { 5 | 6 | const hass = new HomeAssistantMock(); 7 | const sensor = hass.addEntity("Motion sensor battery level", "80"); 8 | const cardElem = hass.addCard("battery-state-entity", { 9 | entity: sensor.entity_id, 10 | secondary_info: "my info text" 11 | }); 12 | 13 | await cardElem.cardUpdated; 14 | 15 | const entity = new EntityElements(cardElem); 16 | expect(entity.secondaryInfoText).toBe("my info text"); 17 | }); 18 | 19 | test("Secondary info charging text", async () => { 20 | const hass = new HomeAssistantMock(); 21 | const sensor = hass.addEntity("Motion sensor battery level", "80", { is_charging: "true" }); 22 | const cardElem = hass.addCard("battery-state-entity", { 23 | entity: sensor.entity_id, 24 | secondary_info: "{charging}", 25 | charging_state: { 26 | secondary_info_text: "Charging now", 27 | attribute: { 28 | name: "is_charging", 29 | value: "true" 30 | } 31 | } 32 | }); 33 | 34 | await cardElem.cardUpdated; 35 | 36 | const entity = new EntityElements(cardElem); 37 | expect(entity.secondaryInfoText).toBe("Charging now"); 38 | }); 39 | 40 | test("Secondary info other entity attribute value", async () => { 41 | const hass = new HomeAssistantMock(); 42 | const flowerBattery = hass.addEntity("Flower sensor battery level", "80", {}); 43 | const flowerEntity = hass.addEntity("Flower needs", "needs water", { sun_level: "good" }, "sensor"); 44 | const cardElem = hass.addCard("battery-state-entity", { 45 | entity: flowerBattery.entity_id, 46 | secondary_info: "Sun level is {sensor.flower_needs.attributes.sun_level}", 47 | }); 48 | 49 | await cardElem.cardUpdated; 50 | 51 | const entity = new EntityElements(cardElem); 52 | expect(entity.secondaryInfoText).toBe("Sun level is good"); 53 | }); 54 | 55 | test("Secondary info date value - renders relative time element", async () => { 56 | const hass = new HomeAssistantMock(); 57 | const flowerBattery = hass.addEntity("Flower sensor battery level", "80", {}); 58 | 59 | let dateStringSerialized = JSON.stringify(new Date(2022, 1, 24, 23, 45, 55)); 60 | const dateString = dateStringSerialized.substring(1, dateStringSerialized.length - 1); // removing quotes 61 | flowerBattery.setLastUpdated(dateString); 62 | 63 | const cardElem = hass.addCard("battery-state-entity", { 64 | entity: flowerBattery.entity_id, 65 | secondary_info: "{last_updated}", 66 | }); 67 | 68 | await cardElem.cardUpdated; 69 | 70 | const entity = new EntityElements(cardElem); 71 | const relTimeElem = entity.secondaryInfo?.firstElementChild; 72 | expect(relTimeElem.tagName).toBe("HA-RELATIVE-TIME"); 73 | expect(JSON.stringify((relTimeElem).datetime)).toBe(dateStringSerialized); 74 | }); 75 | 76 | // test("Secondary info date value - renders relative time element with text", async () => { 77 | // const hass = new HomeAssistantMock(); 78 | // const flowerBattery = hass.addEntity("Flower sensor battery level", "80", {}); 79 | 80 | // const date = new Date(2022, 1, 24, 23, 45, 55); 81 | // let dateString = JSON.stringify(date); 82 | // dateString = dateString.substring(1, dateString.length - 1); // removing quotes 83 | // flowerBattery.setLastUpdated(dateString); 84 | 85 | // const cardElem = hass.addCard("battery-state-entity", { 86 | // entity: flowerBattery.entity_id, 87 | // secondary_info: "Last updated: {last_updated}", 88 | // }); 89 | 90 | // await cardElem.cardUpdated; 91 | 92 | // const entity = new EntityElements(cardElem); 93 | // const relTimeElem = entity.secondaryInfo?.firstElementChild; 94 | // expect(relTimeElem.tagName).toBe("HA-RELATIVE-TIME"); 95 | // expect((relTimeElem).datetime).toBe(date); 96 | // }); -------------------------------------------------------------------------------- /test/entity/state.test.ts: -------------------------------------------------------------------------------- 1 | import { BatteryStateEntity } from "../../src/custom-elements/battery-state-entity"; 2 | import { EntityElements, HomeAssistantMock } from "../helpers"; 3 | 4 | 5 | test("State updates", async () => { 6 | const hass = new HomeAssistantMock(); 7 | const sensor = hass.addEntity("Motion sensor battery level", "80"); 8 | const cardElem = hass.addCard("battery-state-entity", { 9 | entity: sensor.entity_id, 10 | }); 11 | 12 | await cardElem.cardUpdated; 13 | 14 | const entity = new EntityElements(cardElem); 15 | 16 | expect(entity.stateText).toBe("80 %"); 17 | 18 | sensor.setState("50"); 19 | 20 | await cardElem.cardUpdated; 21 | 22 | expect(entity.stateText).toBe("50 %"); 23 | }); 24 | 25 | test.each([ 26 | [80.451, 2, "80.45 %"], 27 | [80.456, 2, "80.46 %"], 28 | [80.456, 0, "80 %"], 29 | [80.456, undefined, "80.456 %"] 30 | ])("State rounding", async (state: number, round: number | undefined, expectedState: string) => { 31 | const hass = new HomeAssistantMock(); 32 | const sensor = hass.addEntity("Motion sensor battery level", state.toString()); 33 | const cardElem = hass.addCard("battery-state-entity", { 34 | entity: sensor.entity_id, 35 | round: round 36 | }); 37 | 38 | await cardElem.cardUpdated; 39 | 40 | const entity = new EntityElements(cardElem); 41 | 42 | expect(entity.stateText).toBe(expectedState); 43 | }); 44 | 45 | test("State with custom unit", async () => { 46 | const hass = new HomeAssistantMock(); 47 | const sensor = hass.addEntity("Motion sensor battery level", "80"); 48 | const cardElem = hass.addCard("battery-state-entity", { 49 | entity: sensor.entity_id, 50 | unit: "lqi" 51 | }); 52 | 53 | await cardElem.cardUpdated; 54 | 55 | const entity = new EntityElements(cardElem); 56 | 57 | expect(entity.stateText).toBe("80 lqi"); 58 | }); 59 | 60 | test("State with string value", async () => { 61 | const hass = new HomeAssistantMock(); 62 | const sensor = hass.addEntity("Motion sensor battery level", "Charging"); 63 | const cardElem = hass.addCard("battery-state-entity", { 64 | entity: sensor.entity_id, 65 | }); 66 | 67 | await cardElem.cardUpdated; 68 | 69 | const entity = new EntityElements(cardElem); 70 | 71 | expect(entity.stateText).toBe("Charging"); 72 | }); 73 | 74 | test.each([ 75 | ["High", "Good"], 76 | ["Low", "Low"] 77 | ])("State map with string values as target", async (state: string, expectedState: string) => { 78 | const hass = new HomeAssistantMock(); 79 | const sensor = hass.addEntity("Motion sensor battery level", state); 80 | const cardElem = hass.addCard("battery-state-entity", { 81 | entity: sensor.entity_id, 82 | state_map: [ 83 | { from: "High", to: "Good" }, 84 | { from: "Low", to: "Low" } 85 | ] 86 | }); 87 | 88 | await cardElem.cardUpdated; 89 | 90 | const entity = new EntityElements(cardElem); 91 | 92 | expect(entity.stateText).toBe(expectedState); 93 | }); 94 | 95 | test.each([ 96 | ["Charging", "80", undefined, "80 %"], // value taken from battery_level attribute 97 | ["Charging", undefined, "55", "55 %"], // value taken from battery attribute 98 | ["44", "OneThird", undefined, "44 %"], // value taken from the entity state 99 | ])("State value priority", async (entityState: string, batteryLevelAttrib?: string, batteryAttrib?: string, expectedState?: string) => { 100 | const hass = new HomeAssistantMock(); 101 | const sensor = hass.addEntity("Motion sensor battery level", entityState); 102 | sensor.setAttributes({ battery_level: batteryLevelAttrib, battery: batteryAttrib }) 103 | const cardElem = hass.addCard("battery-state-entity", { 104 | entity: sensor.entity_id, 105 | }); 106 | 107 | await cardElem.cardUpdated; 108 | 109 | const entity = new EntityElements(cardElem); 110 | 111 | expect(entity.stateText).toBe(expectedState); 112 | }); -------------------------------------------------------------------------------- /test/helpers.ts: -------------------------------------------------------------------------------- 1 | import { BatteryStateCard } from "../src/custom-elements/battery-state-card"; 2 | import { BatteryStateEntity } from "../src/custom-elements/battery-state-entity"; 3 | import { LovelaceCard } from "../src/custom-elements/lovelace-card"; 4 | import { DeviceRegistryEntry, EntityRegistryDisplayEntry, HomeAssistantExt, AreaRegistryEntry } from "../src/type-extensions"; 5 | import { throttledCall } from "../src/utils"; 6 | 7 | /** 8 | * Removing all custome elements 9 | */ 10 | afterEach(() => { 11 | ["battery-state-card", "battery-state-entity"].forEach(cardTagName => Array 12 | .from(document.body.getElementsByTagName(cardTagName)) 13 | .forEach(elem => elem.remove())); 14 | }); 15 | 16 | export class CardElements { 17 | constructor(private card: BatteryStateCard) { 18 | 19 | } 20 | 21 | get header() { 22 | return this.card.shadowRoot?.querySelector(".card-header .truncate")?.textContent?.trim(); 23 | } 24 | 25 | get itemsCount() { 26 | return this.card.shadowRoot!.querySelectorAll(".card-content > * > battery-state-entity").length; 27 | } 28 | 29 | get groupsCount() { 30 | return this.card.shadowRoot!.querySelectorAll(".card-content > .expandWrapper").length; 31 | } 32 | 33 | get items(): EntityElements[] { 34 | const result: EntityElements[] = []; 35 | for (let index = 0; index < this.itemsCount; index++) { 36 | result.push(this.item(index)); 37 | } 38 | 39 | return result; 40 | } 41 | 42 | get groups(): GroupElement[] { 43 | const result: GroupElement[] = []; 44 | for (let index = 0; index < this.groupsCount; index++) { 45 | result.push(this.group(index)); 46 | } 47 | 48 | return result; 49 | } 50 | 51 | item(index: number) { 52 | const entity = this.card.shadowRoot!.querySelectorAll(".card-content > * > battery-state-entity")[index]; 53 | if (!entity) { 54 | throw new Error("Card element not found: " + index); 55 | } 56 | 57 | return new EntityElements(entity); 58 | } 59 | 60 | group(index: number) { 61 | const group = this.card.shadowRoot!.querySelectorAll(".card-content > .expandWrapper")[index]; 62 | if (!group) { 63 | throw new Error("Group element not found: " + index); 64 | } 65 | 66 | return new GroupElement(group); 67 | } 68 | } 69 | 70 | export class EntityElements { 71 | 72 | private root: HTMLElement; 73 | 74 | constructor(private card: BatteryStateEntity, isShadowRoot: boolean = true) { 75 | this.root = isShadowRoot ? card.shadowRoot! : card; 76 | } 77 | 78 | get iconName() { 79 | return this.root.querySelector("ha-icon")?.getAttribute("icon"); 80 | } 81 | 82 | get nameText() { 83 | return this.root.querySelector(".name")?.textContent?.trim(); 84 | } 85 | 86 | get secondaryInfo() { 87 | return this.root.querySelector(".secondary"); 88 | } 89 | 90 | get secondaryInfoText() { 91 | return this.secondaryInfo?.textContent?.trim(); 92 | } 93 | 94 | get stateText() { 95 | return this.root.querySelector(".state") 96 | ?.textContent 97 | ?.trim() 98 | .replace(String.fromCharCode(160), " "); // replace non breakable space 99 | } 100 | } 101 | 102 | export class GroupElement extends EntityElements { 103 | constructor(private elem: HTMLElement) { 104 | super(elem.querySelector(".toggler"), false); 105 | } 106 | 107 | private get batteryNodes(): NodeListOf { 108 | return this.elem.querySelectorAll(".groupItems > * > battery-state-entity"); 109 | } 110 | 111 | get itemsCount() { 112 | return this.batteryNodes.length; 113 | } 114 | 115 | get items(): EntityElements[] { 116 | const result: EntityElements[] = []; 117 | for (let index = 0; index < this.itemsCount; index++) { 118 | result.push(this.item(index)); 119 | } 120 | 121 | return result; 122 | } 123 | 124 | item(index: number): EntityElements { 125 | const entity = this.batteryNodes[index]; 126 | if (!entity) { 127 | throw new Error("Card element not found: " + index); 128 | } 129 | 130 | return new EntityElements(entity); 131 | } 132 | } 133 | 134 | 135 | export class HomeAssistantMock> { 136 | 137 | private cards: LovelaceCard[] = []; 138 | 139 | public hass: HomeAssistantExt = { 140 | states: {}, 141 | localize: jest.fn((key: string) => `[${key}]`), 142 | formatEntityState: jest.fn((entityData: any) => `${entityData.state} %`), 143 | }; 144 | 145 | private throttledUpdate = throttledCall(() => { 146 | this.cards.forEach(c => c.hass = this.hass); 147 | }, 0); 148 | 149 | constructor(disableCardUpdates?: boolean) { 150 | if (disableCardUpdates) { 151 | this.throttledUpdate = () => {}; 152 | } 153 | } 154 | 155 | mockFunc(funcName: keyof HomeAssistantExt, mockedFunc: Function) { 156 | (this.hass)[funcName] = jest.fn(mockedFunc) 157 | } 158 | 159 | addCard>(type: string, config: extractGeneric): T { 160 | const elementName = type.replace("custom:", ""); 161 | 162 | if (customElements.get(elementName) === undefined) { 163 | throw new Error("Card definition not found: " + elementName); 164 | } 165 | 166 | const card = document.createElement(elementName); 167 | card.setConfig(config as T); 168 | card.hass = this.hass; 169 | 170 | document.body.appendChild(card); 171 | this.cards.push(card); 172 | 173 | return card; 174 | } 175 | 176 | addEntity(name: string, state?: string, attribs?: IEntityAttributes, domain?: string): IEntityMock { 177 | const entity = { 178 | entity_id: convertoToEntityId(name, domain), 179 | state: state || "", 180 | attributes: { 181 | friendly_name: name, 182 | ...attribs 183 | }, 184 | last_changed: "", 185 | last_updated: "", 186 | context: { 187 | id: "", 188 | user_id: null, 189 | parent_id: null, 190 | }, 191 | setState: (state: string) => { 192 | this.hass.states[entity.entity_id].state = state; 193 | 194 | this.throttledUpdate(); 195 | return entity; 196 | }, 197 | setAttributes: (attribs: IEntityAttributes) => { 198 | 199 | if (attribs === null) { 200 | this.hass.states[entity.entity_id].attributes = undefined; 201 | return entity; 202 | } 203 | 204 | this.hass.states[entity.entity_id].attributes = { 205 | ...this.hass.states[entity.entity_id].attributes, 206 | ...attribs 207 | }; 208 | 209 | this.throttledUpdate(); 210 | return entity; 211 | }, 212 | setLastUpdated: (val: string) => { 213 | entity.last_updated = val; 214 | this.throttledUpdate(); 215 | }, 216 | setLastChanged: (val: string) => { 217 | entity.last_changed = val; 218 | this.throttledUpdate(); 219 | }, 220 | setProperty: (name: K, val: HaEntityPropertyToTypeMap[K]) => { 221 | (entity)[name] = val; 222 | } 223 | }; 224 | 225 | this.hass.states[entity.entity_id] = entity; 226 | 227 | return entity 228 | } 229 | } 230 | 231 | 232 | export const convertoToEntityId = (input: string, domain?: string) => { 233 | return (domain ? domain + "." : "") + input.toLocaleLowerCase().replace(/[-\s]/g, "_"); 234 | } 235 | 236 | type extractGeneric = Type extends LovelaceCard ? X : never 237 | 238 | 239 | interface IEntityAttributes { 240 | [key: string]: any; 241 | friendly_name?: string; 242 | battery_level?: string; 243 | battery?: string; 244 | device_class?: string; 245 | } 246 | 247 | interface IEntityMock { 248 | readonly entity_id: string; 249 | readonly state: string; 250 | setState(state: string): IEntityMock; 251 | setAttributes(attribs: IEntityAttributes | null): IEntityMock; 252 | setLastUpdated(val: string): void; 253 | setLastChanged(val: string): void; 254 | setProperty(name: K, val: HaEntityPropertyToTypeMap[K]): void; 255 | } 256 | 257 | interface HaEntityPropertyToTypeMap { 258 | "display": EntityRegistryDisplayEntry, 259 | "device": DeviceRegistryEntry, 260 | "area": AreaRegistryEntry, 261 | } -------------------------------------------------------------------------------- /test/other/colors.test.ts: -------------------------------------------------------------------------------- 1 | import { getColorForBatteryLevel } from "../../src/colors" 2 | 3 | describe("Colors", () => { 4 | 5 | test.each([ 6 | [0, "var(--label-badge-red)"], 7 | [20, "var(--label-badge-red)"], 8 | [21, "var(--label-badge-yellow)"], 9 | [55, "var(--label-badge-yellow)"], 10 | [56, "var(--label-badge-green)"], 11 | [100, "var(--label-badge-green)"], 12 | ])("default steps", (batteryLevel: number, expectedColor: string) => { 13 | const result = getColorForBatteryLevel({ entity: "", colors: undefined }, batteryLevel, false); 14 | 15 | expect(result).toBe(expectedColor); 16 | }) 17 | 18 | test.each([ 19 | [0, "red"], 20 | [10, "red"], 21 | [11, "yellow"], 22 | [40, "yellow"], 23 | [41, "green"], 24 | [60, "green"], 25 | [100, "green"], 26 | ])("custom steps config", (batteryLevel: number, expectedColor: string) => { 27 | 28 | const colorsConfig: IColorSettings = { 29 | steps: [ 30 | { value: 10, color: "red" }, 31 | { value: 40, color: "yellow" }, 32 | { value: 100, color: "green" } 33 | ] 34 | } 35 | const result = getColorForBatteryLevel({ entity: "", colors: colorsConfig }, batteryLevel, false); 36 | 37 | expect(result).toBe(expectedColor); 38 | }) 39 | 40 | test.each([ 41 | [0, "#ff0000"], 42 | [25, "#ff7f00"], 43 | [50, "#ffff00"], 44 | [75, "#7fff00"], 45 | [100, "#00ff00"], 46 | ])("gradient simple color list", (batteryLevel: number, expectedColor: string) => { 47 | const result = getColorForBatteryLevel({ entity: "", colors: { steps: ["#ff0000", "#ffff00", "#00ff00"], gradient: true } }, batteryLevel, false); 48 | 49 | expect(result).toBe(expectedColor); 50 | }) 51 | 52 | test.each([ 53 | [0, "#ff0000"], 54 | [10, "#ff0000"], 55 | [20, "#ff0000"], // color shouldn't change up to this point 56 | [35, "#ff7f00"], // middle point 57 | [50, "#ffff00"], 58 | [60, "#bfff00"], // middle point 59 | [90, "#00ff00"], // color shouldn't change from this point 60 | [95, "#00ff00"], 61 | [100, "#00ff00"], 62 | ])("gradient with step values", (batteryLevel: number, expectedColor: string) => { 63 | const config = { 64 | entity: "", 65 | colors: { 66 | steps: [ 67 | { value: 20, color: "#ff0000" }, 68 | { value: 50, color: "#ffff00" }, 69 | { value: 90, color: "#00ff00"}, 70 | ], 71 | gradient: true 72 | } 73 | } 74 | 75 | const result = getColorForBatteryLevel(config, batteryLevel, false); 76 | 77 | expect(result).toBe(expectedColor); 78 | }) 79 | 80 | test("diabling colors", () => { 81 | const config = { 82 | entity: "", 83 | colors: { 84 | steps: [] 85 | } 86 | } 87 | 88 | const result = getColorForBatteryLevel(config, 80, false); 89 | 90 | expect(result).toBe("inherit"); 91 | }) 92 | }) -------------------------------------------------------------------------------- /test/other/entity-fields/battery-level.test.ts: -------------------------------------------------------------------------------- 1 | import { getBatteryLevel } from "../../../src/entity-fields/battery-level"; 2 | import { HomeAssistantMock } from "../../helpers"; 3 | 4 | describe("Battery level", () => { 5 | 6 | test("is equal value_override setting when it is provided", () => { 7 | const hassMock = new HomeAssistantMock(true); 8 | const { state, level, unit } = getBatteryLevel({ entity: "any", value_override: "45" }, hassMock.hass, {}); 9 | 10 | expect(level).toBe(45); 11 | expect(state).toBe("45"); 12 | expect(unit).toBe("%"); 13 | }); 14 | 15 | test("doen't throw exception when attributes are not set on entity", () => { 16 | const hassMock = new HomeAssistantMock(true); 17 | const entity = hassMock.addEntity("Mocked entity", "45", { battery_state: "45" }); 18 | entity.setAttributes(null); 19 | 20 | const { state, level, unit } = getBatteryLevel({ entity: "mocked_entity" }, hassMock.hass, hassMock.hass.states["mocked_entity"]); 21 | 22 | expect(level).toBe(45); 23 | expect(state).toBe("45"); 24 | expect(unit).toBe("%") 25 | }); 26 | 27 | test("is 'Unknown' when entity not found and no localized string", () => { 28 | const hassMock = new HomeAssistantMock(true); 29 | hassMock.hass.localize = () => null; 30 | const { state, level, unit } = getBatteryLevel({ entity: "any" }, hassMock.hass, undefined); 31 | 32 | expect(level).toBeUndefined(); 33 | expect(state).toBe("Unknown"); 34 | expect(unit).toBeUndefined(); 35 | }); 36 | 37 | test("is 'Unknown' localized string when entity not found", () => { 38 | const hassMock = new HomeAssistantMock(true); 39 | const { state, level, unit } = getBatteryLevel({ entity: "any" }, hassMock.hass, undefined); 40 | 41 | expect(level).toBeUndefined(); 42 | expect(state).toBe("[state.default.unknown]"); 43 | expect(unit).toBeUndefined(); 44 | }); 45 | 46 | test("is taken from attribute but attribute is missing", () => { 47 | 48 | const hassMock = new HomeAssistantMock(true); 49 | hassMock.addEntity("Mocked entity", "OK", { battery_state: "45" }); 50 | 51 | const { state, level, unit } = getBatteryLevel({ entity: "mocked_entity", attribute: "battery_state_missing" }, hassMock.hass, hassMock.hass.states["mocked_entity"]); 52 | 53 | expect(level).toBeUndefined(); 54 | expect(state).toBe("[state.default.unknown]"); 55 | expect(unit).toBeUndefined(); 56 | }); 57 | 58 | test("is taken from attribute", () => { 59 | 60 | const hassMock = new HomeAssistantMock(true); 61 | hassMock.addEntity("Mocked entity", "OK", { battery_state: "45" }); 62 | 63 | const { state, level, unit } = getBatteryLevel({ entity: "mocked_entity", attribute: "battery_state" }, hassMock.hass, hassMock.hass.states["mocked_entity"]); 64 | 65 | expect(level).toBe(45); 66 | expect(state).toBe("45"); 67 | expect(unit).toBe("%"); 68 | }); 69 | 70 | test("is taken from attribute - value includes percentage", () => { 71 | 72 | const hassMock = new HomeAssistantMock(true); 73 | hassMock.addEntity("Mocked entity", "OK", { battery_state: "45%" }); 74 | 75 | const { state, level } = getBatteryLevel({ entity: "mocked_entity", attribute: "battery_state" }, hassMock.hass, hassMock.hass.states["mocked_entity"]); 76 | 77 | expect(level).toBe(45); 78 | expect(state).toBe("45"); 79 | }); 80 | 81 | test("is taken from state - value includes percentage", () => { 82 | 83 | const hassMock = new HomeAssistantMock(true); 84 | hassMock.addEntity("Mocked entity", "45%"); 85 | 86 | const { state, level } = getBatteryLevel({ entity: "mocked_entity" }, hassMock.hass, hassMock.hass.states["mocked_entity"]); 87 | 88 | expect(level).toBe(45); 89 | expect(state).toBe("45"); 90 | }); 91 | 92 | test("is taken from dafault locations - attribute: battery_level", () => { 93 | 94 | const hassMock = new HomeAssistantMock(true); 95 | hassMock.addEntity("Mocked entity", "OK", { battery_level: "45%" }); 96 | 97 | const { state, level } = getBatteryLevel({ entity: "mocked_entity" }, hassMock.hass, hassMock.hass.states["mocked_entity"]); 98 | 99 | expect(level).toBe(45); 100 | expect(state).toBe("45"); 101 | }); 102 | 103 | test("is taken from dafault locations - attribute: battery", () => { 104 | 105 | const hassMock = new HomeAssistantMock(true); 106 | hassMock.addEntity("Mocked entity", "OK", { battery: "45%" }); 107 | 108 | const { state, level } = getBatteryLevel({ entity: "mocked_entity" }, hassMock.hass, hassMock.hass.states["mocked_entity"]); 109 | 110 | expect(level).toBe(45); 111 | expect(state).toBe("45"); 112 | }); 113 | 114 | test("is taken from dafault locations - non battery entity", () => { 115 | 116 | const hassMock = new HomeAssistantMock(true); 117 | hassMock.addEntity("Mocked entity", "OK", { battery_level: "45%" }); 118 | 119 | const { state, level } = getBatteryLevel({ entity: "mocked_entity", non_battery_entity: true }, hassMock.hass, hassMock.hass.states["mocked_entity"]); 120 | 121 | expect(level).toBeUndefined(); 122 | expect(state).toBe("OK"); 123 | }); 124 | 125 | test("is taken from dafault locations - state", () => { 126 | 127 | const hassMock = new HomeAssistantMock(true); 128 | hassMock.addEntity("Mocked entity", "45"); 129 | 130 | const { state, level } = getBatteryLevel({ entity: "mocked_entity" }, hassMock.hass, hassMock.hass.states["mocked_entity"]); 131 | 132 | expect(level).toBe(45); 133 | expect(state).toBe("45"); 134 | }); 135 | 136 | test("is taken from dafault locations - numeric value cannot be found", () => { 137 | 138 | const hassMock = new HomeAssistantMock(true); 139 | hassMock.addEntity("Mocked entity", "OK"); 140 | 141 | const { state, level } = getBatteryLevel({ entity: "mocked_entity" }, hassMock.hass, hassMock.hass.states["mocked_entity"]); 142 | 143 | expect(level).toBeUndefined(); 144 | expect(state).toBe("OK"); 145 | }); 146 | 147 | test("multiplier applied", () => { 148 | 149 | const hassMock = new HomeAssistantMock(true); 150 | hassMock.addEntity("Mocked entity", "0.9"); 151 | 152 | const { state, level } = getBatteryLevel({ entity: "mocked_entity", multiplier: 100 }, hassMock.hass, hassMock.hass.states["mocked_entity"]); 153 | 154 | expect(level).toBe(90); 155 | expect(state).toBe("90"); 156 | }); 157 | 158 | test.each([ 159 | ["20.458", 2, "20.46"], 160 | ["20.458", 0, "20"], 161 | ]) 162 | ("round applied", (entityState: string, round: number, expectedResult: string) => { 163 | 164 | const hassMock = new HomeAssistantMock(true); 165 | hassMock.addEntity("Mocked entity", entityState); 166 | 167 | const { state, level } = getBatteryLevel({ entity: "mocked_entity", round }, hassMock.hass, hassMock.hass.states["mocked_entity"]); 168 | 169 | expect(state).toBe(expectedResult); 170 | }); 171 | 172 | test("first letter is capitalized", () => { 173 | 174 | const hassMock = new HomeAssistantMock(true); 175 | hassMock.addEntity("Mocked entity", "ok"); 176 | 177 | const { state, level } = getBatteryLevel({ entity: "mocked_entity" }, hassMock.hass, hassMock.hass.states["mocked_entity"]); 178 | 179 | expect(level).toBeUndefined(); 180 | expect(state).toBe("Ok"); 181 | }); 182 | 183 | test.each([ 184 | ["ok", "100", 100, "%", undefined], 185 | ["empty", "0", 0, "%", undefined], 186 | ["20", "20", 20, "%", undefined], 187 | ["charge", "Empty", 0, undefined, "Empty"], 188 | ["charge", "StateFromOtherEntity", 0, undefined, "{sensor.other_entity.state}"], 189 | ]) 190 | ("state map applied", (entityState: string, expectedState: string, expectedLevel: number | undefined, expectedUnit: string | undefined, display?: string) => { 191 | 192 | const hassMock = new HomeAssistantMock(true); 193 | hassMock.addEntity("Mocked entity", entityState); 194 | hassMock.addEntity("Other entity", "StateFromOtherEntity", undefined, "sensor"); 195 | 196 | const { state, level, unit } = getBatteryLevel({ entity: "mocked_entity", state_map: [ { from: "ok", to: "100" }, { from: "empty", to: "0" }, { from: "charge", to: "0", display } ] }, hassMock.hass, hassMock.hass.states["mocked_entity"]); 197 | 198 | expect(level).toBe(expectedLevel); 199 | expect(state).toBe(expectedState); 200 | expect(unit).toBe(expectedUnit); 201 | }); 202 | 203 | test.each([ 204 | [undefined, "45", "dbm", { state: "45", level: 45, unit: "[dbm]" }], // test default when the setting is not set in the config 205 | [true, "45", "dbm", { state: "45", level: 45, unit: "[dbm]" }], // test when the setting is explicitly true 206 | [false, "45", "dbm", { state: "45", level: 45, unit: "%" }], // test when the setting is turned off 207 | [true, "45", "dbm", { state: "56", level: 56, unit: "%" }, [ { from: "45", to: "56" } ]], // test when the state was changed by state_map 208 | [true, "45", "dbm", { state: "Low", level: 45, unit: undefined }, [ { from: "45", to: "45", display: "Low" } ]], // test when the display value was changed by state_map 209 | [true, "45.4", "dbm", { state: "45,4", level: 45.4, unit: "[dbm]" }, undefined, ","], // test when default HA formatting returns state with comma as decimal point 210 | ]) 211 | ("default HA formatting", (defaultStateFormatting: boolean | undefined, entityState: string, unitOfMeasurement: string, expected: { state: string, level: number, unit?: string }, stateMap: IConvert[] | undefined = undefined, decimalPoint: string = ".") => { 212 | 213 | const hassMock = new HomeAssistantMock(true); 214 | hassMock.addEntity("Mocked entity", entityState); 215 | hassMock.mockFunc("formatEntityState", (entityData: any) => `${entityData.state.replace(".", decimalPoint)} [${unitOfMeasurement}]`); 216 | 217 | const { state, level, unit } = getBatteryLevel({ entity: "mocked_entity", default_state_formatting: defaultStateFormatting, state_map: stateMap }, hassMock.hass, hassMock.hass.states["mocked_entity"]); 218 | 219 | expect(level).toBe(expected.level); 220 | expect(state).toBe(expected.state); 221 | expect(unit).toBe(expected.unit); 222 | }); 223 | 224 | test.each([ 225 | ["OK", undefined, undefined, undefined], 226 | ["45", undefined, undefined, "%"], 227 | ["45", "dBm", undefined, "dBm"], 228 | ["45", "dBm", "rpm", "rpm"], 229 | ])("unit is correct", (entityState: string, entityUnitOfMeasurement: string | undefined, configOverride: string | undefined, expectedUnit: string | undefined) => { 230 | const hassMock = new HomeAssistantMock(true); 231 | const entity = hassMock.addEntity("Mocked entity", entityState, { unit_of_measurement: entityUnitOfMeasurement }); 232 | 233 | const { unit } = getBatteryLevel({ entity: entity.entity_id, default_state_formatting: false, unit: configOverride }, hassMock.hass, hassMock.hass.states[entity.entity_id]); 234 | 235 | expect(unit).toBe(expectedUnit); 236 | }) 237 | 238 | test.each([ 239 | ["OK", undefined, undefined, undefined], 240 | ["45", undefined, undefined, "%"], 241 | ["45", "dBm", undefined, "dBm"], 242 | ["45", "dBm", "rpm", "rpm"], 243 | ])("unit is correct when value_override is used", (entityState: string, entityUnitOfMeasurement: string | undefined, configOverride: string | undefined, expectedUnit: string | undefined) => { 244 | const hassMock = new HomeAssistantMock(true); 245 | const entity = hassMock.addEntity("Mocked entity", entityState, { unit_of_measurement: entityUnitOfMeasurement }); 246 | 247 | const { unit } = getBatteryLevel({ entity: entity.entity_id, default_state_formatting: false, unit: configOverride, value_override: "{state}" }, hassMock.hass, hassMock.hass.states[entity.entity_id]); 248 | 249 | expect(unit).toBe(expectedUnit); 250 | }) 251 | 252 | test.each([ 253 | ["45", {}, "45"], 254 | [46, {}, "46"], 255 | ["ok", { battery_level: "47" }, "47"], 256 | ["ok", { battery_level: 48 }, "48"], 257 | ["ok", { battery: "49" }, "49"], 258 | ["ok", { battery: 50 }, "50"], 259 | ])("state value coming from default places", (entityState: string | number, entityAttributes: IMap, expectedState: string) => { 260 | const hassMock = new HomeAssistantMock(true); 261 | const entity = hassMock.addEntity("Mocked entity", entityState, entityAttributes); 262 | 263 | const { state } = getBatteryLevel({ entity: entity.entity_id, default_state_formatting: false, unit: undefined}, hassMock.hass, hassMock.hass.states[entity.entity_id]); 264 | 265 | expect(state).toBe(expectedState); 266 | }) 267 | 268 | test("number in the attribute value", () => { 269 | const hassMock = new HomeAssistantMock(true); 270 | const entity = hassMock.addEntity("Mocked entity", "OK", { custom_attribute: 2 }); 271 | 272 | const { state } = getBatteryLevel({ entity: entity.entity_id, attribute: "custom_attribute"}, hassMock.hass, hassMock.hass.states[entity.entity_id]); 273 | 274 | expect(state).toBe("2"); 275 | }) 276 | 277 | test.each([ 278 | ["55 %", { state: "55", level: 55, unit: "%" }], 279 | ["66%", { state: "66", level: 66, unit: "%" }], 280 | ["-77lqi", { state: "-77", level: -77, unit: "lqi" }], 281 | ["-88.8lqi", { state: "-88.8", level: -88.8, unit: "lqi" }], 282 | ["99", { state: "99", level: 99, unit: "%" }], 283 | ]) 284 | ("default HA formatting - various formatted states", (formattedResult: string, expected: { state: string, level: number, unit?: string }) => { 285 | 286 | const hassMock = new HomeAssistantMock(true); 287 | const entity = hassMock.addEntity("Mocked entity", "45"); 288 | hassMock.mockFunc("formatEntityState", () => formattedResult); 289 | 290 | const { state, level, unit } = getBatteryLevel({ entity: "mocked_entity" }, hassMock.hass, hassMock.hass.states[entity.entity_id]); 291 | 292 | expect(level).toBe(expected.level); 293 | expect(state).toBe(expected.state); 294 | expect(unit).toBe(expected.unit); 295 | }); 296 | }); -------------------------------------------------------------------------------- /test/other/entity-fields/charging-state.test.ts: -------------------------------------------------------------------------------- 1 | import { getChargingState } from "../../../src/entity-fields/charging-state"; 2 | import { HomeAssistantMock } from "../../helpers"; 3 | 4 | 5 | describe("Charging state", () => { 6 | 7 | test("is false when there is no charging configuration", () => { 8 | const hassMock = new HomeAssistantMock(true); 9 | const isCharging = getChargingState({ entity: "any" }, "90", hassMock.hass); 10 | 11 | expect(isCharging).toBe(false); 12 | }) 13 | 14 | test("is false when there is no hass", () => { 15 | const isCharging = getChargingState( 16 | { entity: "sensor.my_entity", charging_state: { attribute: [ { name: "is_charging", value: "true" } ] } }, 17 | "45", 18 | undefined); 19 | 20 | expect(isCharging).toBe(false); 21 | }) 22 | 23 | test("is true when charging state is in attribute", () => { 24 | const hassMock = new HomeAssistantMock(true); 25 | const entity = hassMock.addEntity("Sensor", "80", { is_charging: "true" }) 26 | const isCharging = getChargingState( 27 | { entity: entity.entity_id, charging_state: { attribute: [ { name: "is_charging", value: "true" } ] } }, 28 | entity.state, 29 | hassMock.hass); 30 | 31 | expect(isCharging).toBe(true); 32 | }) 33 | 34 | test("is false when charging state is in attribute but attrib value is false", () => { 35 | const hassMock = new HomeAssistantMock(true); 36 | const entity = hassMock.addEntity("Sensor", "80", { is_charging: "true" }) 37 | const isCharging = getChargingState( 38 | { entity: entity.entity_id, charging_state: { attribute: [ { name: "is_charging", value: "false" } ] } }, 39 | entity.state, 40 | hassMock.hass); 41 | 42 | expect(isCharging).toBe(false); 43 | }) 44 | 45 | test("is true when charging state is in attribute (more than one attribute in configuration)", () => { 46 | const hassMock = new HomeAssistantMock(true); 47 | const entity = hassMock.addEntity("Sensor", "80", { is_charging: "true" }) 48 | const isCharging = getChargingState( 49 | { entity: entity.entity_id, charging_state: { attribute: [ { name: "status", value: "charging" }, { name: "is_charging", value: "true" } ] } }, 50 | entity.state, 51 | hassMock.hass); 52 | 53 | expect(isCharging).toBe(true); 54 | }) 55 | 56 | test("is false when charging state is in attribute (and attribute is missing)", () => { 57 | const hassMock = new HomeAssistantMock(true); 58 | const entity = hassMock.addEntity("Sensor", "80") 59 | const isCharging = getChargingState( 60 | { entity: entity.entity_id, charging_state: { attribute: [ { name: "status", value: "charging" }, { name: "is_charging", value: "true" } ] } }, 61 | entity.state, 62 | hassMock.hass); 63 | 64 | expect(isCharging).toBe(false); 65 | }) 66 | 67 | test.each([ 68 | ["charging", true], 69 | ["charging", false, "MissingEntity"], 70 | ["discharging", false] 71 | ])("charging state is in the external entity state", (chargingEntityState: string, expected: boolean, missingEntitySuffix = "") => { 72 | const hassMock = new HomeAssistantMock(true); 73 | const entity = hassMock.addEntity("Sensor", "80") 74 | const entityChargingState = hassMock.addEntity("Charging sensor", chargingEntityState) 75 | const isCharging = getChargingState( 76 | { entity: entity.entity_id, charging_state: { entity_id: entityChargingState.entity_id + missingEntitySuffix, state: "charging" } }, 77 | entity.state, 78 | hassMock.hass); 79 | 80 | expect(isCharging).toBe(expected); 81 | }) 82 | 83 | test.each([ 84 | ["charging", true], 85 | ["full", true], 86 | ["full", false, " missing"], 87 | ])("default charging state", (chargingEntityState: string, expected: boolean, missingEntitySuffix = "") => { 88 | const hassMock = new HomeAssistantMock(true); 89 | const entity = hassMock.addEntity("Sensor battery level", "80", { is_charging: "true" }) 90 | const entityChargingState = hassMock.addEntity("Sensor battery state" + missingEntitySuffix, chargingEntityState) 91 | const isCharging = getChargingState( 92 | { entity: entity.entity_id }, 93 | entity.state, 94 | hassMock.hass); 95 | 96 | expect(isCharging).toBe(expected); 97 | }) 98 | 99 | }); -------------------------------------------------------------------------------- /test/other/entity-fields/get-icon.test.ts: -------------------------------------------------------------------------------- 1 | import { getIcon } from "../../../src/entity-fields/get-icon"; 2 | import { HomeAssistantMock } from "../../helpers"; 3 | 4 | describe("Get icon", () => { 5 | test("charging and charging icon set in config", () => { 6 | let icon = getIcon({ entity: "", charging_state: { icon: "mdi:custom" } }, 20, true, undefined); 7 | expect(icon).toBe("mdi:custom"); 8 | }); 9 | 10 | test.each([ 11 | [-2], 12 | [200], 13 | [NaN], 14 | ])("returns unknown state icon when invalid state passed", (invalidEntityState: number) => { 15 | let icon = getIcon({ entity: "" }, invalidEntityState, false, undefined); 16 | expect(icon).toBe("mdi:battery-unknown"); 17 | }); 18 | 19 | test.each([ 20 | [0, false, "mdi:battery-outline"], 21 | [5, false, "mdi:battery-10"], 22 | [10, false, "mdi:battery-10"], 23 | [15, false, "mdi:battery-20"], 24 | [20, false, "mdi:battery-20"], 25 | [25, false, "mdi:battery-30"], 26 | [30, false, "mdi:battery-30"], 27 | [90, false, "mdi:battery-90"], 28 | [95, false, "mdi:battery"], 29 | [100, false, "mdi:battery"], 30 | [0, true, "mdi:battery-charging-outline"], 31 | [5, true, "mdi:battery-charging-10"], 32 | [10, true, "mdi:battery-charging-10"], 33 | [15, true, "mdi:battery-charging-20"], 34 | [20, true, "mdi:battery-charging-20"], 35 | [25, true, "mdi:battery-charging-30"], 36 | [30, true, "mdi:battery-charging-30"], 37 | [90, true, "mdi:battery-charging-90"], 38 | [95, true, "mdi:battery-charging-100"], 39 | [100, true, "mdi:battery-charging-100"], 40 | ])("returns correct state icon", (batteryLevel: number, isCharging: boolean, expectedIcon: string) => { 41 | let icon = getIcon({ entity: "" }, batteryLevel, isCharging, undefined); 42 | expect(icon).toBe(expectedIcon); 43 | }); 44 | 45 | test("returns custom icon from config", () => { 46 | let icon = getIcon({ entity: "", icon: "mdi:custom" }, 20, false, undefined); 47 | expect(icon).toBe("mdi:custom"); 48 | }); 49 | 50 | test.each([ 51 | ["signal-cellular-{state}", "20", "signal-cellular-20"], 52 | ["signal-cellular-{state|abs()|greaterthan(69,outline)|greaterthan(59,1)|greaterthan(49,2)|greaterthan(2,3)}", "40", "signal-cellular-3"], 53 | ["signal-cellular-{state|abs()|greaterthan(69,outline)|greaterthan(59,1)|greaterthan(49,2)|greaterthan(2,3)}", "55", "signal-cellular-2"], 54 | ["signal-cellular-{state|abs()|greaterthan(69,outline)|greaterthan(59,1)|greaterthan(49,2)|greaterthan(2,3)}", "65", "signal-cellular-1"], 55 | ["signal-cellular-{state|abs()|greaterthan(69,outline)|greaterthan(59,1)|greaterthan(49,2)|greaterthan(2,3)}", "75", "signal-cellular-outline"], 56 | ])("returns dynamic icon", (configuredIcon: string, state: string, expectedResult: string) => { 57 | 58 | const hassMock = new HomeAssistantMock(); 59 | hassMock.addEntity("Battery state", state); 60 | 61 | let icon = getIcon({ entity: "battery_state", icon: configuredIcon }, Number(state), false, hassMock.hass); 62 | expect(icon).toBe(expectedResult); 63 | }) 64 | 65 | test("icon from attribute", () => { 66 | const hassMock = new HomeAssistantMock(); 67 | hassMock.addEntity("Battery state", "45", { icon: "mdi:attribute-icon" }); 68 | 69 | let icon = getIcon({ entity: "battery_state", icon: "attribute.icon" }, 45, false, hassMock.hass); 70 | expect(icon).toBe("mdi:attribute-icon"); 71 | }) 72 | 73 | test("icon from attribute - attribute missing", () => { 74 | const hassMock = new HomeAssistantMock(); 75 | hassMock.addEntity("Battery state", "45", { icon: "mdi:attribute-icon" }); 76 | 77 | let icon = getIcon({ entity: "battery_state", icon: "attribute.icon_non_existing" }, 45, false, hassMock.hass); 78 | expect(icon).toBe("attribute.icon_non_existing"); 79 | }) 80 | }); -------------------------------------------------------------------------------- /test/other/entity-fields/get-name.test.ts: -------------------------------------------------------------------------------- 1 | import { getName } from "../../../src/entity-fields/get-name"; 2 | import { HomeAssistantMock } from "../../helpers"; 3 | 4 | describe("Get name", () => { 5 | test("returns name from the config", () => { 6 | const hassMock = new HomeAssistantMock(true); 7 | let name = getName({ entity: "test", name: "Entity name" }, hassMock.hass, {}) 8 | 9 | expect(name).toBe("Entity name"); 10 | }); 11 | 12 | test("returns entity id when name and hass is missing", () => { 13 | let name = getName({ entity: "sensor.my_entity_id" }, undefined, {}) 14 | 15 | expect(name).toBe("sensor.my_entity_id"); 16 | }); 17 | 18 | test("doesn't throw exception when attributes property is missing", () => { 19 | const hassMock = new HomeAssistantMock(true); 20 | const entity = hassMock.addEntity("My entity", "45", { friendly_name: "My entity name" }); 21 | entity.setAttributes(null); 22 | 23 | let name = getName({ entity: "my_entity" }, hassMock.hass, {}); 24 | 25 | expect(name).toBe("my_entity"); 26 | }); 27 | 28 | test("returns name from friendly_name attribute of the entity", () => { 29 | const hassMock = new HomeAssistantMock(true); 30 | const entity = hassMock.addEntity("My entity", "45", { friendly_name: "My entity name" }); 31 | 32 | let name = getName({ entity: "my_entity" }, hassMock.hass, hassMock.hass.states[entity.entity_id]); 33 | 34 | expect(name).toBe("My entity name"); 35 | }); 36 | 37 | test("returns entity id when entity not found in hass", () => { 38 | const hassMock = new HomeAssistantMock(true); 39 | let name = getName({ entity: "my_entity_missing" }, hassMock.hass, {}); 40 | 41 | expect(name).toBe("my_entity_missing"); 42 | }); 43 | 44 | test("returns entity id when entity doesn't have a friendly_name attribute", () => { 45 | const hassMock = new HomeAssistantMock(true); 46 | const entity = hassMock.addEntity("My entity", "45", { friendly_name: undefined }); 47 | 48 | let name = getName({ entity: "my_entity" }, hassMock.hass, hassMock.hass.states[entity.entity_id]); 49 | 50 | expect(name).toBe("my_entity"); 51 | }); 52 | 53 | test("doesn't throw when name is empty", () => { 54 | const hassMock = new HomeAssistantMock(true); 55 | const entity = hassMock.addEntity("My entity", "45", { friendly_name: "Battery" }); 56 | 57 | let name = getName({ entity: "my_entity", bulk_rename: [{ from: "Battery", to: "" }] }, hassMock.hass, hassMock.hass.states[entity.entity_id]); 58 | 59 | expect(name).toBe(""); 60 | }); 61 | 62 | test.each( 63 | [ 64 | ["Kitchen temperature battery", { from: "battery", to: "bat" }, "Kitchen temperature bat"], 65 | ["Kitchen temperature battery", { from: "/\\s[^\\s]+ery/", to: "" }, "Kitchen temperature"], 66 | ["Kitchen temperature battery", [{ from: "battery", to: "bat." }, {from: "temperature", to: "temp."}], "Kitchen temp. bat."], 67 | ] 68 | )("returns renamed entity name", (entityName: string, renameRules: IConvert | IConvert[], expectedResult: string) => { 69 | const hassMock = new HomeAssistantMock(true); 70 | const entity = hassMock.addEntity("My entity", "45", { friendly_name: entityName }); 71 | 72 | let name = getName({ entity: "my_entity", bulk_rename: renameRules }, hassMock.hass, hassMock.hass.states[entity.entity_id]); 73 | 74 | expect(name).toBe(expectedResult); 75 | }); 76 | 77 | test.each( 78 | [ 79 | ["Kitchen Battery", { from: "/ Battery/", to: "" }, "Kitchen"], 80 | ["Kitchen Battery", { from: "/ battery/i", to: "" }, "Kitchen"], 81 | ["Kitchen battery temperature battery", [{ from: "/\\sbattery/ig", to: "" }], "Kitchen temperature"], 82 | ] 83 | )("regex", (entityName: string, renameRules: IConvert | IConvert[], expectedResult: string) => { 84 | const hassMock = new HomeAssistantMock(true); 85 | const entity = hassMock.addEntity("My entity", "45", { friendly_name: entityName }); 86 | 87 | let name = getName({ entity: "my_entity", bulk_rename: renameRules }, hassMock.hass, hassMock.hass.states[entity.entity_id]); 88 | 89 | expect(name).toBe(expectedResult); 90 | }); 91 | 92 | test.each([ 93 | ["State in the name {state}%", "State in the name 45%"], 94 | ["KString func {state|multiply(2)}%", "KString func 90%"], 95 | ["KString other entity {sensor.other_entity.state}", "KString other entity CR2032"], 96 | ])("KString in the name", (name: string, expectedResult: string) => { 97 | const hassMock = new HomeAssistantMock(true); 98 | const mockEntity = hassMock.addEntity("My entity", "45"); 99 | 100 | hassMock.addEntity("Other entity", "CR2032", undefined, "sensor"); 101 | 102 | let result = getName({entity: mockEntity.entity_id, name}, hassMock.hass, hassMock.hass.states[mockEntity.entity_id]); 103 | expect(result).toBe(expectedResult); 104 | }) 105 | 106 | test.each([ 107 | ["Battery kitchen1", { from: "/Battery\\s?/", to: "" }, "Kitchen1"], 108 | ["Battery kitchen2", { rules: { from: "/Battery\\s?/", to: "" } }, "Kitchen2"], 109 | ["Battery kitchen3", { rules: { from: "/Battery\\s?/", to: "" }, capitalize_first: true }, "Kitchen3"], 110 | ["Battery kitchen4", { rules: { from: "/Battery\\s?/", to: "" }, capitalize_first: false }, "kitchen4"], 111 | ["battery kitchen5", { capitalize_first: false }, "battery kitchen5"], 112 | ["battery kitchen6", undefined, "Battery kitchen6"], 113 | ])("KString in the name", (entityName: string, renameRules: IBulkRename | IConvert | IConvert[] | undefined, expectedResult: string) => { 114 | const hassMock = new HomeAssistantMock(true); 115 | const entity = hassMock.addEntity("My entity", "45", { friendly_name: entityName }); 116 | 117 | let result = getName({entity: "my_entity", bulk_rename: renameRules}, hassMock.hass, hassMock.hass.states[entity.entity_id]); 118 | expect(result).toBe(expectedResult); 119 | }) 120 | }); -------------------------------------------------------------------------------- /test/other/entity-fields/get-secondary-info.test.ts: -------------------------------------------------------------------------------- 1 | import { getSecondaryInfo } from "../../../src/entity-fields/get-secondary-info" 2 | import { HomeAssistantMock } from "../../helpers" 3 | 4 | describe("Secondary info", () => { 5 | 6 | test("Unsupported entity domain", () => { 7 | const hassMock = new HomeAssistantMock(true); 8 | const entity = hassMock.addEntity("Motion sensor kitchen", "50", {}, "water"); 9 | const secondaryInfoConfig = "{" + entity.entity_id + "}"; 10 | const result = getSecondaryInfo({ entity: "any", secondary_info: secondaryInfoConfig }, hassMock.hass, {}); 11 | 12 | expect(result).toBe(""); 13 | }) 14 | 15 | test("Other entity state (number)", () => { 16 | const hassMock = new HomeAssistantMock(true); 17 | const entity = hassMock.addEntity("Motion sensor kitchen", "50", {}, "sensor"); 18 | const secondaryInfoConfig = "{" + entity.entity_id + ".state}"; 19 | const result = getSecondaryInfo({ entity: "any", secondary_info: secondaryInfoConfig }, hassMock.hass, {}); 20 | 21 | expect(result).toBe("50"); 22 | }) 23 | 24 | test("Attribute 'last_changed'", () => { 25 | const hassMock = new HomeAssistantMock(true); 26 | const entity = hassMock.addEntity("Motion sensor kitchen", "50", {}, "sensor"); 27 | entity.setLastChanged("2022-02-07"); 28 | const secondaryInfoConfig = "{last_changed}"; 29 | const result = getSecondaryInfo({ entity: entity.entity_id, secondary_info: secondaryInfoConfig }, hassMock.hass, hassMock.hass.states[entity.entity_id]); 30 | 31 | expect(result).toBe("2022-02-07"); 32 | }) 33 | 34 | test("Secondary info config not set", () => { 35 | const hassMock = new HomeAssistantMock(true); 36 | const entity = hassMock.addEntity("Motion sensor kitchen", "50", {}, "sensor"); 37 | entity.setLastChanged("2022-02-07"); 38 | const result = getSecondaryInfo({ entity: entity.entity_id }, hassMock.hass, {}); 39 | 40 | expect(result).toBeNull(); 41 | }) 42 | 43 | test("Secondary info charging text", () => { 44 | const hassMock = new HomeAssistantMock(true); 45 | const entity = hassMock.addEntity("Motion sensor kitchen", "50", {}, "sensor"); 46 | 47 | const entityData = { 48 | ...hassMock.hass.states[entity.entity_id], 49 | "charging": "Charging" 50 | } 51 | const result = getSecondaryInfo({ entity: entity.entity_id, secondary_info: "{charging}" }, hassMock.hass, entityData); 52 | 53 | expect(result).toBe("Charging"); 54 | }) 55 | }) -------------------------------------------------------------------------------- /test/other/filter.test.ts: -------------------------------------------------------------------------------- 1 | import { Filter } from "../../src/filter"; 2 | import { HomeAssistantMock } from "../helpers"; 3 | 4 | describe("Filter", () => { 5 | 6 | test("unsupported operator", () => { 7 | const hassMock = new HomeAssistantMock(); 8 | 9 | const entity = hassMock.addEntity("Entity name", "90", { battery_level: "45" }); 10 | 11 | const filter = new Filter({ name: "attributes.battery_level", operator: "unsupported" }); 12 | const isValid = filter.isValid(entity); 13 | 14 | expect(isValid).toBe(false); 15 | }) 16 | 17 | test.each([ 18 | [""], 19 | [undefined], 20 | ])("filter name missing", (filterName: string | undefined) => { 21 | const hassMock = new HomeAssistantMock(); 22 | 23 | const entity = hassMock.addEntity("Entity name", "90", { battery_level: "45" }); 24 | 25 | const filter = new Filter({ name: filterName }); 26 | const isValid = filter.isValid(entity); 27 | 28 | expect(isValid).toBe(false); 29 | }) 30 | 31 | test.each([ 32 | ["45", true], 33 | ["90", false], 34 | ])("filter based on state - state coming from custom source", (filterValue: string, expectedIsValid: boolean) => { 35 | const hassMock = new HomeAssistantMock(); 36 | 37 | const entity = hassMock.addEntity("Entity name", "90"); 38 | 39 | const filter = new Filter({ name: "state", value: filterValue }); 40 | const isValid = filter.isValid(entity, "45"); 41 | 42 | expect(isValid).toBe(expectedIsValid); 43 | }) 44 | 45 | test.each([ 46 | ["Bedroom motion battery level", "*_battery_level", true], 47 | ["Bedroom motion battery level", "/_battery_level$/", true], 48 | ["Bedroom motion battery level", "*_battery_*", true], 49 | ["Bedroom motion battery level", "*_battery_", false], 50 | ["Bedroom motion", "*_battery_level", false], 51 | ["Bedroom motion", "/BEDroom_motion/", false], 52 | ["Bedroom motion", "/BEDroom_motion/i", true], 53 | ["sensor.bot_outside_power_battery", "sensor.*bot_*battery", true], 54 | ])("matches func returns correct results", (entityName: string, filterValue: string, expectedIsVlid: boolean) => { 55 | const hassMock = new HomeAssistantMock(); 56 | 57 | const entity = hassMock.addEntity(entityName, "90"); 58 | 59 | const filter = new Filter({ name: "entity_id", value: filterValue }); 60 | const isValid = filter.isValid(entity); 61 | 62 | expect(filter.is_permanent).toBeTruthy(); 63 | expect(isValid).toBe(expectedIsVlid); 64 | }) 65 | 66 | test.each([ 67 | ["attributes.battery_level", { battery_level: "45" }, true, "exists"], 68 | ["attributes.battery_level", { battery_level: "45" }, true, undefined], 69 | ["attributes.battery_state", { battery_level: "45" }, false, "exists"], 70 | ["attributes.battery_level", { battery_level: "45" }, false, "not_exists"], 71 | ["attributes.battery_state", { battery_level: "45" }, true, "not_exists"], 72 | ])("exists/not_exists func returns correct results", (fileterName: string, attribs: IMap, expectedIsVlid: boolean, operator: FilterOperator | undefined) => { 73 | const hassMock = new HomeAssistantMock(); 74 | 75 | const entity = hassMock.addEntity("Entity name", "90", attribs); 76 | 77 | const filter = new Filter({ name: fileterName, operator }); 78 | const isValid = filter.isValid(entity); 79 | 80 | expect(filter.is_permanent).toBeTruthy(); 81 | expect(isValid).toBe(expectedIsVlid); 82 | }) 83 | 84 | test.each([ 85 | ["45", "matches", "45", true], 86 | ["45", "matches", "55", false], 87 | [undefined, "matches", "55", false], 88 | ["45", "=", "45", true], 89 | ["45", "=", "45", true], 90 | ["string test", "=", "string", false], 91 | ["string test", "=", "string test", true], 92 | ["45", ">", "44", true], 93 | ["45", ">", "45", false], 94 | ["45", ">=", "45", true], 95 | ["45", ">=", "44", true], 96 | ["45", ">=", "46", false], 97 | ["45", "<", "45", false], 98 | ["45", "<", "46", true], 99 | ["45", "<=", "45", true], 100 | ["45", "<=", "44", false], 101 | ["45", "<=", "46", true], 102 | ["some longer text", "contains", "longer", true], 103 | ["some longer text", "contains", "loonger", false], 104 | // decimals 105 | ["45.0", "=", "45", true], 106 | ["45,0", "=", "45", true], 107 | ["44.1", ">", "44", true], 108 | ["44,1", ">", "44", true], 109 | ["44", "<", "44.1", true], 110 | ["44", "<", "44,1", true], 111 | ])("matching functions return correct results", (state: string | undefined, operator: FilterOperator | undefined, value: string | number, expectedIsVlid: boolean) => { 112 | const hassMock = new HomeAssistantMock(); 113 | 114 | const entity = hassMock.addEntity("Entity name", "ok", { battery_level: state }); 115 | 116 | const filter = new Filter({ name: "attributes.battery_level", operator, value }); 117 | const isValid = filter.isValid(entity); 118 | 119 | expect(isValid).toBe(expectedIsVlid); 120 | }) 121 | test.each([ 122 | [44, "<", "44,1", true], 123 | [44, ">", "44.1", false], 124 | [true, "=", "false", false], 125 | [true, "=", "true", false], 126 | [true, "=", true, true], 127 | [true, undefined, true, true], 128 | [false, undefined, true, false], 129 | [true, undefined, false, false], 130 | [true, undefined, null, false], 131 | [null, undefined, null, true], 132 | ])("non mixed types of values", (attributeValue: FilterValueType, operator: FilterOperator | undefined, value: FilterValueType, expectedIsVlid: boolean) => { 133 | const hassMock = new HomeAssistantMock(); 134 | 135 | const entity = hassMock.addEntity("Entity name", "ok", { entity_attrib: attributeValue }); 136 | 137 | const filter = new Filter({ name: "attributes.entity_attrib", operator, value }); 138 | const isValid = filter.isValid(entity); 139 | 140 | expect(isValid).toBe(expectedIsVlid); 141 | }) 142 | 143 | test.each([ 144 | [{ state: "45", device: { name: "Device name" } }, "path.missing", "Device name", false], 145 | [{ state: "45", device: { name: "Device name" } }, "device.name", "Device name", true], 146 | [{ state: "45", device: { name: "Device name" } }, "device.name", "Device other name", false], 147 | [{ state: "45", device: { name: "Device name", manufacturer: { name: "Contoso" } } }, "device.manufacturer", "Contoso", false], 148 | [{ state: "45", device: { name: "Device name", manufacturer: { name: "Contoso" } } }, "device.manufacturer.name", "Contoso", true], 149 | ])("filter based on nested entity data", (entityData: any, filterName: string, filterValue: string, expectedIsValid: boolean) => { 150 | const hassMock = new HomeAssistantMock(); 151 | 152 | const filter = new Filter({ name: filterName, value: filterValue }); 153 | const isValid = filter.isValid(entityData, "45"); 154 | 155 | expect(isValid).toBe(expectedIsValid); 156 | }) 157 | }); -------------------------------------------------------------------------------- /test/other/rich-string-processor.test.ts: -------------------------------------------------------------------------------- 1 | import { BatteryStateEntity } from "../../src/custom-elements/battery-state-entity"; 2 | import { RichStringProcessor } from "../../src/rich-string-processor" 3 | import { HomeAssistantMock } from "../helpers" 4 | 5 | describe("RichStringProcessor", () => { 6 | 7 | test.each([ 8 | [null, ""], 9 | [undefined, ""], 10 | ["", ""], 11 | ["false", "false"], 12 | ["0", "0"], 13 | ])("missing text", (input: any, expected: string) => { 14 | const hassMock = new HomeAssistantMock(true); 15 | const motionEntity = hassMock.addEntity("Bedroom motion", "50", {}, "sensor"); 16 | const proc = new RichStringProcessor(hassMock.hass, {}); 17 | 18 | const result = proc.process(input); 19 | expect(result).toBe(expected); 20 | }) 21 | 22 | test.each([ 23 | ["Value {state}, {last_updated}", "Value 20.56, 2021-04-05 15:11:35"], // few placeholders 24 | ["Value {state}, {attributes.charging_state}", "Value 20.56, Charging"], // attribute value 25 | ["Value {state}, {sensor.kitchen_switch.state}", "Value 20.56, 55"], // external entity state 26 | ["Value {state}, {sensor.kitchen_switch.attributes.charging_state}", "Value 20.56, Fully charged"], // external entity attribute value 27 | ["Value {state}, {device_tracker.kitchen_switch.state}", "Value 20.56, 55", "device_tracker"], // external entity state 28 | ])("replaces placeholders", (text: string, expectedResult: string, otherEntityDomain = "sensor") => { 29 | const hassMock = new HomeAssistantMock(true); 30 | const motionEntity = hassMock.addEntity("Bedroom motion", "20.56", { charging_state: "Charging" }, "sensor"); 31 | const switchEntity = hassMock.addEntity("Kitchen switch", "55", { charging_state: "Fully charged" }, otherEntityDomain); 32 | 33 | motionEntity.setLastUpdated("2021-04-05 15:11:35"); 34 | const proc = new RichStringProcessor(hassMock.hass, hassMock.hass.states[motionEntity.entity_id]); 35 | 36 | const result = proc.process(text); 37 | expect(result).toBe(expectedResult); 38 | }); 39 | 40 | test.each([ 41 | ["Rounded value {state|round()}", "Rounded value 21"], // rounding func - no param 42 | ["Rounded value {state|round(1)}", "Rounded value 20.6"], // rounding func - with param 43 | ])("round function", (text: string, expectedResult: string) => { 44 | const hassMock = new HomeAssistantMock(true); 45 | const motionEntity = hassMock.addEntity("Bedroom motion", "20.56", {}, "sensor"); 46 | const proc = new RichStringProcessor(hassMock.hass, hassMock.hass.states[motionEntity.entity_id]); 47 | 48 | const result = proc.process(text); 49 | expect(result).toBe(expectedResult); 50 | }); 51 | 52 | test.each([ 53 | ["{attributes.friendly_name|replace(motion,motion sensor)}", "Bedroom motion sensor"], // replacing part of the attribute value 54 | ])("replace function", (text: string, expectedResult: string) => { 55 | const hassMock = new HomeAssistantMock(true); 56 | const motionEntity = hassMock.addEntity("Bedroom motion", "20.56", {}, "sensor"); 57 | const proc = new RichStringProcessor(hassMock.hass, hassMock.hass.states[motionEntity.entity_id]); 58 | 59 | const result = proc.process(text); 60 | expect(result).toBe(expectedResult); 61 | }); 62 | 63 | test("couple functions for the same placeholder", () => { 64 | const hassMock = new HomeAssistantMock(true); 65 | const motionEntity = hassMock.addEntity("Bedroom motion", "Value 20.56%", {}, "sensor"); 66 | const proc = new RichStringProcessor(hassMock.hass, hassMock.hass.states[motionEntity.entity_id]); 67 | 68 | const result = proc.process("{state|replace(Value ,)|replace(%,)|round()}"); 69 | expect(result).toBe("21"); 70 | }); 71 | 72 | test("using custom data", () => { 73 | const hassMock = new HomeAssistantMock(true); 74 | const motionEntity = hassMock.addEntity("Bedroom motion", "Value 20.56%", {}, "sensor"); 75 | 76 | const entityData = { 77 | ...hassMock.hass.states[motionEntity.entity_id], 78 | "is_charging": "Charging", 79 | } 80 | 81 | const proc = new RichStringProcessor(hassMock.hass, entityData); 82 | 83 | const result = proc.process("{is_charging}"); 84 | expect(result).toBe("Charging"); 85 | }); 86 | 87 | test.each([ 88 | ["Value {state|multiply(2)}", "20.56", "Value 41.12"], 89 | ["Value {state|multiply(0.5)}", "20.56", "Value 10.28"], 90 | ["Value {state|multiply()}", "20.56", "Value 20.56"], // param missing 91 | ])("multiply function", (text: string, state:string, expectedResult: string) => { 92 | const hassMock = new HomeAssistantMock(true); 93 | const motionEntity = hassMock.addEntity("Bedroom motion", state, {}, "sensor"); 94 | const proc = new RichStringProcessor(hassMock.hass, hassMock.hass.states[motionEntity.entity_id]); 95 | 96 | const result = proc.process(text); 97 | expect(result).toBe(expectedResult); 98 | }); 99 | 100 | test.each([ 101 | ["Value {state|add(2)}", "20.56", "Value 22.56"], 102 | ["Value {state|add(-21.5)|round(2)}", "20.56", "Value -0.94"], 103 | ["Value {state|add(0)}", "20.56", "Value 20.56"], 104 | ["Value {state|add()}", "20.56", "Value 20.56"], // param missing 105 | ])("add function", (text: string, state:string, expectedResult: string) => { 106 | const hassMock = new HomeAssistantMock(true); 107 | const motionEntity = hassMock.addEntity("Bedroom motion", state, {}, "sensor"); 108 | const proc = new RichStringProcessor(hassMock.hass, hassMock.hass.states[motionEntity.entity_id]); 109 | 110 | const result = proc.process(text); 111 | expect(result).toBe(expectedResult); 112 | }); 113 | 114 | test.each([ 115 | ["{state|lessthan(2,0)|greaterthan(7,100)|between(1,8,50)}", "1", "0"], 116 | ["{state|lessthan(2,0)|greaterthan(7,100)|between(1,8,50)}", "2", "50"], 117 | ["{state|lessthan(2,0)|greaterthan(7,100)|between(1,8,50)}", "5", "50"], 118 | ["{state|lessthan(2,0)|greaterthan(7,100)|between(1,8,50)}", "7", "50"], 119 | ["{state|lessthan(2,0)|greaterthan(7,100)|between(1,8,50)}", "8", "100"], 120 | ["{state|lessthan(2,0)|greaterthan(7,100)|between(1,8,50)}", "70", "100"], 121 | // missing params 122 | ["{state|lessthan()|greaterthan(7,100)|between(1,8,50)}", "1", "1"], 123 | ["{state|lessthan(2,0)|greaterthan(7,100)|between()}", "5", "5"], 124 | ["{state|lessthan(2,0)|greaterthan()|between(1,8,50)}", "70", "70"], 125 | ])("greater, lessthan, between functions", (text: string, state:string, expectedResult: string) => { 126 | const hassMock = new HomeAssistantMock(true); 127 | const motionEntity = hassMock.addEntity("Bedroom motion", state, {}, "sensor"); 128 | const proc = new RichStringProcessor(hassMock.hass, hassMock.hass.states[motionEntity.entity_id]); 129 | 130 | const result = proc.process(text); 131 | expect(result).toBe(expectedResult); 132 | }); 133 | 134 | test.each([ 135 | ["{state|thresholds(22,88,200,450)}", "1", "0"], 136 | ["{state|thresholds(22,88,200,450)}", "22", "25"], 137 | ["{state|thresholds(22,88,200,450)}", "60", "25"], 138 | ["{state|thresholds(22,88,200,450)}", "90", "50"], 139 | ["{state|thresholds(22,88,200,450)}", "205", "75"], 140 | ["{state|thresholds(22,88,200,450)}", "449", "75"], 141 | ["{state|thresholds(22,88,200,450)}", "500", "100"], 142 | ["{state|thresholds(22,88,200)}", "90", "67"], 143 | ["{state|thresholds(22,88,200)}", "200", "100"], 144 | ])("threshold function", (text: string, state:string, expectedResult: string) => { 145 | const hassMock = new HomeAssistantMock(true); 146 | const motionEntity = hassMock.addEntity("Bedroom motion", state, {}, "sensor"); 147 | const proc = new RichStringProcessor(hassMock.hass, hassMock.hass.states[motionEntity.entity_id]); 148 | 149 | const result = proc.process(text); 150 | expect(result).toBe(expectedResult); 151 | }); 152 | 153 | test.each([ 154 | ["{state|abs()}", "-64", "64"], 155 | ["{state|abs()}", "64", "64"], 156 | ])("abs function", (text: string, state:string, expectedResult: string) => { 157 | const hassMock = new HomeAssistantMock(true); 158 | const motionEntity = hassMock.addEntity("Bedroom motion", state, {}, "sensor"); 159 | const proc = new RichStringProcessor(hassMock.hass, hassMock.hass.states[motionEntity.entity_id]); 160 | 161 | const result = proc.process(text); 162 | expect(result).toBe(expectedResult); 163 | }); 164 | 165 | test.each([ 166 | ["{state|equals(64,ok)}", "64", "ok"], 167 | ["{state|equals(65,err)}", "64", "64"], 168 | ["Not enough params {state|equals(64)}", "64", "Not enough params 64"], 169 | ])("equals function", (text: string, state:string, expectedResult: string) => { 170 | const hassMock = new HomeAssistantMock(true); 171 | const motionEntity = hassMock.addEntity("Bedroom motion", state, {}, "sensor"); 172 | const proc = new RichStringProcessor(hassMock.hass, hassMock.hass.states[motionEntity.entity_id]); 173 | 174 | const result = proc.process(text); 175 | expect(result).toBe(expectedResult); 176 | }); 177 | 178 | test.each([ 179 | ["{state|reltime()}", "2021-08-25T00:00:00.000Z", "2021-08-25T00:00:00.000Z"], 180 | ["Rel time: {state|reltime()}", "2021-08-25T00:00:00.000Z", "Rel time: 2021-08-25T00:00:00.000Z"], 181 | ["Not date: {state|reltime()}", "this is not date", "Not date: this is not date"], 182 | ])("reltime function", (text: string, state:string, expectedResult: string) => { 183 | const hassMock = new HomeAssistantMock(true); 184 | const motionEntity = hassMock.addEntity("Bedroom motion", state, {}, "sensor"); 185 | const proc = new RichStringProcessor(hassMock.hass, hassMock.hass.states[motionEntity.entity_id]); 186 | 187 | const result = proc.process(text); 188 | expect(result).toBe(expectedResult); 189 | }); 190 | }) -------------------------------------------------------------------------------- /test/other/sorting.test.ts: -------------------------------------------------------------------------------- 1 | import { IBatteryCollection, IBatteryCollectionItem } from "../../src/battery-provider"; 2 | import { getIdsOfSortedBatteries } from "../../src/sorting"; 3 | import { convertoToEntityId } from "../helpers"; 4 | 5 | describe("Entity sorting", () => { 6 | 7 | test.each([ 8 | ["name", false, ["a_sensor", "b_sensor", "g_sensor", "m_sensor", "z_sensor"]], 9 | ["name", true, ["z_sensor", "m_sensor", "g_sensor", "b_sensor", "a_sensor"]], 10 | ["state", false, ["m_sensor", "g_sensor", "b_sensor", "a_sensor", "z_sensor"]], 11 | ["state", true, ["z_sensor", "b_sensor", "a_sensor", "g_sensor", "m_sensor"]], 12 | ])("Sorting with single option", (sortyBy: SortByOption, desc: boolean, expectedOrder: string[]) => { 13 | 14 | const sortedIds = getIdsOfSortedBatteries({ entities: [], sort: [{ by: sortyBy, desc: desc }]}, convertToCollection(batteries)); 15 | 16 | expect(sortedIds).toStrictEqual(expectedOrder); 17 | }) 18 | 19 | test.each([ 20 | [{ stateDesc: false, nameDesc: false }, ["m_sensor", "g_sensor", "a_sensor", "b_sensor", "z_sensor"]], 21 | [{ stateDesc: false, nameDesc: true }, ["m_sensor", "g_sensor", "b_sensor", "a_sensor", "z_sensor"]], 22 | [{ stateDesc: true, nameDesc: false }, ["z_sensor", "a_sensor", "b_sensor", "g_sensor", "m_sensor"]], 23 | [{ stateDesc: true, nameDesc: true }, ["z_sensor", "b_sensor", "a_sensor", "g_sensor", "m_sensor"]], 24 | [{ stateDesc: false, nameDesc: false, reverse: true }, ["a_sensor", "b_sensor", "g_sensor", "m_sensor", "z_sensor"]], 25 | [{ stateDesc: false, nameDesc: true, reverse: true }, ["z_sensor", "m_sensor", "g_sensor", "b_sensor", "a_sensor"]], 26 | [{ stateDesc: true, nameDesc: false, reverse: true }, ["a_sensor", "b_sensor", "g_sensor", "m_sensor", "z_sensor"]], 27 | [{ stateDesc: true, nameDesc: true, reverse: true }, ["z_sensor", "m_sensor", "g_sensor", "b_sensor", "a_sensor"]], 28 | ])("Sorting with multiple options", (opt: { nameDesc: boolean, stateDesc: boolean, reverse?: boolean }, expectedOrder: string[]) => { 29 | 30 | const sortOptions = [ 31 | { 32 | by: "state", 33 | desc: opt.stateDesc, 34 | }, 35 | { 36 | by: "name", 37 | desc: opt.nameDesc, 38 | } 39 | ]; 40 | 41 | if (opt.reverse) { 42 | sortOptions.reverse(); 43 | } 44 | 45 | const sortedIds = getIdsOfSortedBatteries({ entities: [], sort: sortOptions}, convertToCollection(batteries)); 46 | 47 | expect(sortedIds).toStrictEqual(expectedOrder); 48 | }); 49 | 50 | test.each([ 51 | ["name", ["a_sensor", "b_sensor", "g_sensor", "m_sensor", "z_sensor"]], 52 | [["name"], ["a_sensor", "b_sensor", "g_sensor", "m_sensor", "z_sensor"]], 53 | [["state"], ["m_sensor", "g_sensor", "b_sensor", "a_sensor", "z_sensor"]], 54 | [["state", "name"], ["m_sensor", "g_sensor", "a_sensor", "b_sensor", "z_sensor"]], 55 | [["entity.last_changed"], ["b_sensor", "m_sensor", "g_sensor", "a_sensor", "z_sensor"]], 56 | [["entity.last_changed", "name"], ["b_sensor", "g_sensor", "m_sensor", "a_sensor", "z_sensor"]], 57 | ])("Sorting options as strings", (sort: ISimplifiedArray, expectedOrder: string[]) => { 58 | 59 | const sortedIds = getIdsOfSortedBatteries({ entities: [], sort: sort }, convertToCollection(batteries)); 60 | 61 | expect(sortedIds).toStrictEqual(expectedOrder); 62 | }); 63 | 64 | test.each([ 65 | ["state", "20", undefined, "5", ["b_sensor", "c_sensor", "a_sensor"]], 66 | ["state", undefined, "20", "5", ["a_sensor", "c_sensor", "b_sensor"]], 67 | ["state", "test", undefined, undefined, ["b_sensor", "c_sensor", "a_sensor"]], 68 | ["wrong_sort_string", "50", "20", "5", ["a_sensor", "b_sensor", "c_sensor"]], 69 | ])("Missing properties or wrong sort type", (sort: string, stateA: string | undefined, stateB: string | undefined, stateC: string | undefined, expectedOrder: string[]) => { 70 | 71 | let testBatteries = [ 72 | createBattery("a Sensor", stateA), 73 | createBattery("b Sensor", stateB), 74 | createBattery("c Sensor", stateC), 75 | ]; 76 | 77 | const sortedIds = getIdsOfSortedBatteries({ entities: [], sort }, convertToCollection(testBatteries)); 78 | 79 | expect(sortedIds).toStrictEqual(expectedOrder); 80 | }); 81 | 82 | test.each([ 83 | ["state", "38", "38,5", "38,4", ["a_sensor", "c_sensor", "b_sensor"]], 84 | ["state", "38", "99,5", "99,4", ["a_sensor", "c_sensor", "b_sensor"]], 85 | ["state", "38", "99,4", "99,44", ["a_sensor", "b_sensor", "c_sensor"]], 86 | ["state", "38", "99.4", "99.44", ["a_sensor", "b_sensor", "c_sensor"]], 87 | ])("Decimals separated by comma", (sort: string, stateA: string | undefined, stateB: string | undefined, stateC: string | undefined, expectedOrder: string[]) => { 88 | 89 | let testBatteries = [ 90 | createBattery("a Sensor", stateA), 91 | createBattery("b Sensor", stateB), 92 | createBattery("c Sensor", stateC), 93 | ]; 94 | 95 | const sortedIds = getIdsOfSortedBatteries({ entities: [], sort }, convertToCollection(testBatteries)); 96 | 97 | expect(sortedIds).toStrictEqual(expectedOrder); 98 | }); 99 | }); 100 | 101 | const createBattery = (name: string, state: string | undefined, last_changed?: string | undefined) => { 102 | const b = { 103 | entityId: convertoToEntityId(name), 104 | name: name, 105 | state: state, 106 | entityData: { 107 | "last_changed": last_changed?.substring(1,last_changed.length - 2), 108 | } 109 | } 110 | 111 | return b; 112 | } 113 | 114 | const batteries = [ 115 | createBattery("Z Sensor", "80", JSON.stringify(new Date(2023, 10, 27))), 116 | createBattery("B Sensor", "30", JSON.stringify(new Date(2023, 9, 25))), 117 | createBattery("M Sensor", "10", JSON.stringify(new Date(2023, 10, 7))), 118 | createBattery("A Sensor", "30", JSON.stringify(new Date(2023, 10, 14))), 119 | createBattery("G Sensor", "20", JSON.stringify(new Date(2023, 10, 7))), 120 | ]; 121 | 122 | const convertToCollection = (batteries: IBatteryCollectionItem[]) => batteries.reduce((r, b) => { 123 | r[b.entityId!] = b; 124 | return r; 125 | }, {}); -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES6", 4 | "alwaysStrict": true, 5 | "noImplicitAny": true, 6 | "strictNullChecks": true, 7 | "noImplicitThis": true, 8 | "strict": true, 9 | "strictPropertyInitialization": false, 10 | "moduleResolution": "node", 11 | "experimentalDecorators": true 12 | } 13 | } --------------------------------------------------------------------------------