├── .vscode └── settings.json ├── examples └── Example_1.6.gif ├── hacs.json ├── .prettierrc.js ├── .github └── workflows │ ├── build.yaml │ └── release.yaml ├── tsconfig.json ├── src ├── util.ts ├── localize │ ├── localize.ts │ └── languages │ │ ├── en.json │ │ ├── sk.json │ │ └── de.json ├── presets.ts ├── deep-equal.ts ├── types.ts ├── action-handler.ts ├── styles.ts ├── editor │ ├── items-editor.ts │ ├── item-editor.ts │ └── editor.ts └── power-distribution-card.ts ├── rollup.config.mjs ├── eslint.config.mjs ├── LICENSE ├── package.json ├── .gitignore └── README.md /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } -------------------------------------------------------------------------------- /examples/Example_1.6.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JonahKr/power-distribution-card/HEAD/examples/Example_1.6.gif -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "power-distribution-card", 3 | "render_readme": true, 4 | "content_in_root": false, 5 | "filename": "power-distribution-card.js" 6 | } 7 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: true, 3 | trailingComma: 'all', 4 | singleQuote: true, 5 | printWidth: 120, 6 | tabWidth: 2, 7 | endOfLine: 'auto', 8 | }; 9 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: "Build" 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | build: 13 | name: Test build 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Build 18 | run: | 19 | yarn install 20 | npm run build -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "module": "esnext", 5 | "moduleResolution": "node", 6 | "lib": ["es2017", "dom", "dom.iterable"], 7 | "noEmit": true, 8 | "noUnusedParameters": true, 9 | "noImplicitReturns": true, 10 | "noFallthroughCasesInSwitch": true, 11 | "strict": true, 12 | "noImplicitAny": false, 13 | "skipLibCheck": true, 14 | "resolveJsonModule": true, 15 | "esModuleInterop": true, 16 | "experimentalDecorators": true 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import ResizeObserver from 'resize-observer-polyfill'; 2 | 3 | /** 4 | * Installing the ResizeObserver Polyfill 5 | */ 6 | export const installResizeObserver = async (): Promise => { 7 | if (typeof ResizeObserver !== 'function') { 8 | window.ResizeObserver = (await import('resize-observer-polyfill')).default; 9 | } 10 | }; 11 | 12 | export function fireEvent(node: HTMLElement | Window, type: string, detail: T): void { 13 | const event = new CustomEvent(type, { bubbles: false, composed: false, detail: detail }); 14 | node.dispatchEvent(event); 15 | } 16 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import typescript from '@rollup/plugin-typescript'; 2 | import commonjs from '@rollup/plugin-commonjs'; 3 | import nodeResolve from '@rollup/plugin-node-resolve'; 4 | import babel from '@rollup/plugin-babel'; 5 | import terser from '@rollup/plugin-terser'; 6 | import json from '@rollup/plugin-json'; 7 | 8 | const plugins = [ 9 | nodeResolve({}), 10 | commonjs(), 11 | typescript(), 12 | json(), 13 | babel({ 14 | exclude: 'node_modules/**', 15 | babelHelpers: 'bundled', 16 | }), 17 | terser(), 18 | ]; 19 | 20 | export default [ 21 | { 22 | input: 'src/power-distribution-card.ts', 23 | output: { 24 | dir: 'dist', 25 | format: 'es', 26 | }, 27 | plugins: [...plugins], 28 | }, 29 | ]; 30 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | release: 9 | name: Prepare release 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | #Building the Js-File 14 | - name: Build the file 15 | run: | 16 | cd /home/runner/work/power-distribution-card/power-distribution-card 17 | yarn install 18 | npm run build 19 | # Upload build file to the releas as an asset. 20 | - name: Upload zip to release 21 | uses: svenstaro/upload-release-action@v1-release 22 | 23 | with: 24 | repo_token: ${{ secrets.GITHUB_TOKEN }} 25 | file: /home/runner/work/power-distribution-card/power-distribution-card/dist/power-distribution-card.js 26 | asset_name: power-distribution-card.js 27 | tag: ${{ github.ref }} 28 | overwrite: true -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import tsParser from "@typescript-eslint/parser"; 2 | import path from "node:path"; 3 | import { fileURLToPath } from "node:url"; 4 | import js from "@eslint/js"; 5 | import { FlatCompat } from "@eslint/eslintrc"; 6 | 7 | const __filename = fileURLToPath(import.meta.url); 8 | const __dirname = path.dirname(__filename); 9 | const compat = new FlatCompat({ 10 | baseDirectory: __dirname, 11 | recommendedConfig: js.configs.recommended, 12 | allConfig: js.configs.all 13 | }); 14 | 15 | export default [...compat.extends( 16 | "plugin:@typescript-eslint/recommended", 17 | "prettier", 18 | "plugin:prettier/recommended", 19 | ), { 20 | languageOptions: { 21 | parser: tsParser, 22 | ecmaVersion: 2018, 23 | sourceType: "module", 24 | 25 | parserOptions: { 26 | experimentalDecorators: true, 27 | }, 28 | }, 29 | 30 | rules: { 31 | "@typescript-eslint/camelcase": 0, 32 | }, 33 | }]; -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 JonahKr 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 | -------------------------------------------------------------------------------- /src/localize/localize.ts: -------------------------------------------------------------------------------- 1 | import * as en from './languages/en.json'; 2 | import * as de from './languages/de.json'; 3 | import * as sk from './languages/sk.json'; 4 | 5 | const languages = { 6 | en: en, 7 | de: de, 8 | sk: sk, 9 | }; 10 | 11 | /** 12 | * Translating Strings to different languages. 13 | * Thanks to custom-cards/spotify-card 14 | * @param string The Section-Key Pair 15 | * @param search String which should be replaced 16 | * @param replace String to replace with 17 | */ 18 | export function localize(string: string, capitalized = false, search = '', replace = ''): string { 19 | const lang = (localStorage.getItem('selectedLanguage') || navigator.language.split('-')[0] || 'en') 20 | .replace(/['"]+/g, '') 21 | .replace('-', '_'); 22 | 23 | let translated: string; 24 | try { 25 | translated = string.split('.').reduce((o, i) => o[i], languages[lang]); 26 | } catch (e) { 27 | translated = string.split('.').reduce((o, i) => o[i], languages['en']) as unknown as string; 28 | } 29 | 30 | if (translated === undefined) 31 | translated = string.split('.').reduce((o, i) => o[i], languages['en']) as unknown as string; 32 | 33 | if (search !== '' && replace !== '') { 34 | translated = translated.replace(search, replace); 35 | } 36 | return capitalized ? capitalizeFirstLetter(translated) : translated; 37 | } 38 | 39 | function capitalizeFirstLetter(string) { 40 | return string.charAt(0).toUpperCase() + string.slice(1); 41 | } 42 | -------------------------------------------------------------------------------- /src/localize/languages/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "common": { 3 | "version": "Version", 4 | "description": "A Lovelace Card for visualizing power distributions.", 5 | "invalid_configuration": "Invalid configuration", 6 | "show_warning": "Show Warning" 7 | }, 8 | "editor": { 9 | "actions": { 10 | "add": "Add", 11 | "edit": "Edit", 12 | "remove": "Remove", 13 | "save": "Save" 14 | }, 15 | "optional": "Optional", 16 | "required": "Required", 17 | "settings": { 18 | "action_settings": "Action Settings", 19 | "animation": "Animation", 20 | "autarky": "autarky", 21 | "attribute": "Attribute", 22 | "background_color": "Background Color", 23 | "battery_percentage": "Battery Charge %", 24 | "bigger": "Bigger", 25 | "calc_excluded": "Excluded from Calculations", 26 | "center": "Center", 27 | "color": "Color", 28 | "color-settings": "Color Settings", 29 | "color_threshold": "Color Threshold", 30 | "decimals": "Decimals", 31 | "display-abs": "Display Absolute Value", 32 | "double_tap_action": "Double Tap Action", 33 | "entities": "Entities", 34 | "entity": "Entity", 35 | "equal": "Equal", 36 | "grid-buy": "Grid Buy", 37 | "grid-sell": "Grid Sell", 38 | "hide-arrows": "Hide Arrows", 39 | "icon": "Icon", 40 | "invert-value": "Invert Value", 41 | "name": "Name", 42 | "preset": "Preset", 43 | "ratio": "ratio", 44 | "replace_name": "Replace Name", 45 | "secondary-info": "Secondary Info", 46 | "settings": "settings", 47 | "smaller": "Smaller", 48 | "tap_action": "Tap Action", 49 | "threshold": "Threshold", 50 | "title": "Title", 51 | "unit_of_display": "Unit of Display", 52 | "value": "value" 53 | } 54 | } 55 | } -------------------------------------------------------------------------------- /src/localize/languages/sk.json: -------------------------------------------------------------------------------- 1 | { 2 | "common": { 3 | "version": "Verzia", 4 | "description": "A Lovelace Card for visualizing power distributions.", 5 | "invalid_configuration": "Chybná konfigurácia", 6 | "show_warning": "Zobraziť upozornenia" 7 | }, 8 | "editor": { 9 | "actions": { 10 | "add": "Pridať", 11 | "edit": "Editovať", 12 | "remove": "Odobrať", 13 | "save": "Uložiť" 14 | }, 15 | "optional": "Voliteľné", 16 | "required": "Požadované", 17 | "settings": { 18 | "action_settings": "Nastavenia akcie", 19 | "animation": "Animácia", 20 | "autarky": "sebestačnosť", 21 | "attribute": "Atribút", 22 | "background_color": "Farba pozadia", 23 | "battery_percentage": "Nabitie batérie %", 24 | "bigger": "Väčšie", 25 | "calc_excluded": "Vylúčené z výpočtov", 26 | "center": "Centrum", 27 | "color": "Farba", 28 | "color-settings": "Nastavenia farby", 29 | "color_threshold": "Prah farby", 30 | "decimals": "Desatinné čísla", 31 | "display-abs": "Zobraziť absolútnu hodnotu", 32 | "double_tap_action": "Akcia dvojitého klepnutia", 33 | "entities": "Entity", 34 | "entity": "Entita", 35 | "equal": "Rovné", 36 | "grid-buy": "Sieť nákup", 37 | "grid-sell": "Sieť predaj", 38 | "hide-arrows": "Skryť šípky", 39 | "icon": "Ikona", 40 | "invert-value": "Invertovať hodnotu", 41 | "name": "Názov", 42 | "preset": "Predvoľba", 43 | "ratio": "pomer", 44 | "replace_name": "Nahradiť názov", 45 | "secondary-info": "Sekundárne informácie", 46 | "settings": "nastavenia", 47 | "smaller": "Menšie", 48 | "tap_action": "Akcia klepnutia", 49 | "threshold": "Prah", 50 | "title": "Titul", 51 | "unit_of_display": "Jednotka zobrazenia", 52 | "value": "hodnota" 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/localize/languages/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "common": { 3 | "version": "Version", 4 | "description": "Eine Karte zur Visualizierung von Stromverteilungen", 5 | "invalid_configuration": "Ungültige Konfiguration", 6 | "show_warning": "Warnung" 7 | }, 8 | "editor": { 9 | "actions": { 10 | "add": "Hinzufügen", 11 | "edit": "Bearbeiten", 12 | "remove": "Entfernen", 13 | "save": "Speichern" 14 | }, 15 | "optional": "Optional", 16 | "required": "Erforderlich", 17 | "settings": { 18 | "action_settings": "Interaktions Einstellungen", 19 | "animation": "Animation", 20 | "autarky": "Autarkie", 21 | "attribute": "Attribut", 22 | "background_color": "Hintergrundfarbe", 23 | "battery_percentage": "Batterie Ladung %", 24 | "bigger": "Größer ", 25 | "calc_excluded": "Von Rechnungen ausschließen", 26 | "center": "Mittelbereich", 27 | "color": "Farbe", 28 | "color-settings": "Farb Einstellungen", 29 | "color_threshold": "Farb-Schwellenwert", 30 | "decimals": "Dezimalstellen", 31 | "display-abs": "Absolute Wertanzeige", 32 | "double_tap_action": "Doppel Tipp Aktion", 33 | "entities": "Entities", 34 | "entity": "Element", 35 | "equal": "Gleich", 36 | "grid-buy": "Netz Ankauf", 37 | "grid-sell": "Netz Verkauf", 38 | "hide-arrows": "Pfeile Verstecken", 39 | "icon": "Symbol", 40 | "invert-value": "Wert Invertieren", 41 | "name": "Name", 42 | "preset": "Vorlagen", 43 | "ratio": "Anteil", 44 | "replace_name": "Namen Ersetzen", 45 | "secondary-info": "Zusatzinformationen", 46 | "settings": "Einstellungen", 47 | "smaller": "Kleiner", 48 | "tap_action": "Tipp Aktion", 49 | "threshold": "Schwellenwert", 50 | "title": "Titel", 51 | "unit_of_display": "Angezeigte Einheit", 52 | "value": "Wert" 53 | } 54 | } 55 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "power-distribution-card", 3 | "version": "2.5.12", 4 | "license": "MIT", 5 | "author": "JonahKr", 6 | "description": "A Lovelace Card for visualizing power distributions.", 7 | "keywords": [ 8 | "power", 9 | "distribution", 10 | "lovelace", 11 | "hacs", 12 | "home assistant", 13 | "e3dc" 14 | ], 15 | "module": "power-distribution-card.js", 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/JonahKr/power-distribution-card.git" 19 | }, 20 | "bugs": { 21 | "url": "https://github.com/JonahKr/power-distribution-card/issues" 22 | }, 23 | "homepage": "https://github.com/JonahKr/power-distribution-card#readme", 24 | "scripts": { 25 | "start": "rollup -c --watch", 26 | "build": "npm run lint && npm run rollup", 27 | "lint": "eslint src/*.ts", 28 | "rollup": "rollup -c" 29 | }, 30 | "dependencies": { 31 | "@mdi/js": "^7.4.47", 32 | "custom-card-helpers": "1.9.0", 33 | "lit": "2.8.0", 34 | "resize-observer-polyfill": "1.5.1", 35 | "sortablejs": "1.15.2" 36 | }, 37 | "devDependencies": { 38 | "@babel/core": "7.24.7", 39 | "@babel/plugin-transform-class-properties": "7.25.9", 40 | "@babel/plugin-proposal-decorators": "7.24.7", 41 | "@eslint/eslintrc": "^3.1.0", 42 | "@eslint/js": "^9.16.0", 43 | "@rollup/plugin-babel": "6.0.4", 44 | "@rollup/plugin-commonjs": "26.0.1", 45 | "@rollup/plugin-json": "6.1.0", 46 | "@rollup/plugin-node-resolve": "15.2.3", 47 | "@rollup/plugin-terser": "^0.4.4", 48 | "@rollup/plugin-typescript": "^11.1.6", 49 | "@types/sortablejs": "^1.15.8", 50 | "@typescript-eslint/eslint-plugin": "8.0.0", 51 | "@typescript-eslint/parser": "8.0.0", 52 | "eslint": "9.6.0", 53 | "eslint-config-prettier": "9.1.0", 54 | "eslint-plugin-import": "2.31.0", 55 | "eslint-plugin-prettier": "5.1.3", 56 | "prettier": "3.3.2", 57 | "rollup": "4.28.1", 58 | "tslib": "^2.6.3", 59 | "typescript": "5.5.3" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/presets.ts: -------------------------------------------------------------------------------- 1 | import { EntitySettings, PDCConfig } from './types'; 2 | 3 | export type PresetType = (typeof PresetList)[number]; 4 | 5 | export const PresetList = [ 6 | 'battery', 7 | 'car_charger', 8 | 'consumer', 9 | 'grid', 10 | 'home', 11 | 'hydro', 12 | 'pool', 13 | 'producer', 14 | 'solar', 15 | 'wind', 16 | 'heating', 17 | 'placeholder', 18 | ] as const; 19 | 20 | export const PresetObject: { [key: string]: EntitySettings } = { 21 | battery: { 22 | consumer: true, 23 | icon: 'mdi:battery-outline', 24 | name: 'battery', 25 | producer: true, 26 | }, 27 | car_charger: { 28 | consumer: true, 29 | icon: 'mdi:car-electric', 30 | name: 'car', 31 | }, 32 | consumer: { 33 | consumer: true, 34 | icon: 'mdi:lightbulb', 35 | name: 'consumer', 36 | }, 37 | grid: { 38 | icon: 'mdi:transmission-tower', 39 | name: 'grid', 40 | }, 41 | home: { 42 | consumer: true, 43 | icon: 'mdi:home-assistant', 44 | name: 'home', 45 | }, 46 | hydro: { 47 | icon: 'mdi:hydro-power', 48 | name: 'hydro', 49 | producer: true, 50 | }, 51 | pool: { 52 | consumer: true, 53 | icon: 'mdi:pool', 54 | name: 'pool', 55 | }, 56 | producer: { 57 | icon: 'mdi:lightning-bolt-outline', 58 | name: 'producer', 59 | producer: true, 60 | }, 61 | solar: { 62 | icon: 'mdi:solar-power', 63 | name: 'solar', 64 | producer: true, 65 | }, 66 | wind: { 67 | icon: 'mdi:wind-turbine', 68 | name: 'wind', 69 | producer: true, 70 | }, 71 | heating: { 72 | icon: 'mdi:radiator', 73 | name: 'heating', 74 | consumer: true, 75 | }, 76 | placeholder: { 77 | name: 'placeholder', 78 | }, 79 | }; 80 | 81 | export const DefaultItem: EntitySettings = { 82 | decimals: 2, 83 | display_abs: true, 84 | name: '', 85 | unit_of_display: 'W', 86 | }; 87 | 88 | export const DefaultConfig: PDCConfig = { 89 | type: '', 90 | title: undefined, 91 | animation: 'flash', 92 | entities: [], 93 | center: { 94 | type: 'none', 95 | }, 96 | }; 97 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # Snowpack dependency directory (https://snowpack.dev/) 45 | web_modules/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | .parcel-cache 78 | 79 | # Next.js build output 80 | .next 81 | out 82 | 83 | # Nuxt.js build / generate output 84 | .nuxt 85 | dist 86 | 87 | # Gatsby files 88 | .cache/ 89 | # Comment in the public line in if your project uses Gatsby and not Next.js 90 | # https://nextjs.org/blog/next-9-1#public-directory-support 91 | # public 92 | 93 | # vuepress build output 94 | .vuepress/dist 95 | 96 | # Serverless directories 97 | .serverless/ 98 | 99 | # FuseBox cache 100 | .fusebox/ 101 | 102 | # DynamoDB Local files 103 | .dynamodb/ 104 | 105 | # TernJS port file 106 | .tern-port 107 | 108 | # Stores VSCode versions used for testing VSCode extensions 109 | .vscode-test 110 | 111 | # yarn v2 112 | .yarn/cache 113 | .yarn/unplugged 114 | .yarn/build-state.yml 115 | .yarn/install-state.gz 116 | .pnp.* -------------------------------------------------------------------------------- /src/deep-equal.ts: -------------------------------------------------------------------------------- 1 | // From https://github.com/epoberezkin/fast-deep-equal 2 | // MIT License - Copyright (c) 2017 Evgeny Poberezkin 3 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 4 | export const deepEqual = (a: any, b: any): boolean => { 5 | if (a === b) { 6 | return true; 7 | } 8 | 9 | if (a && b && typeof a === 'object' && typeof b === 'object') { 10 | if (a.constructor !== b.constructor) { 11 | return false; 12 | } 13 | 14 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 15 | let i: number | [any, any]; 16 | let length: number; 17 | if (Array.isArray(a)) { 18 | length = a.length; 19 | if (length !== b.length) { 20 | return false; 21 | } 22 | for (i = length; i-- !== 0; ) { 23 | if (!deepEqual(a[i], b[i])) { 24 | return false; 25 | } 26 | } 27 | return true; 28 | } 29 | 30 | if (a instanceof Map && b instanceof Map) { 31 | if (a.size !== b.size) { 32 | return false; 33 | } 34 | for (i of a.entries()) { 35 | if (!b.has(i[0])) { 36 | return false; 37 | } 38 | } 39 | for (i of a.entries()) { 40 | if (!deepEqual(i[1], b.get(i[0]))) { 41 | return false; 42 | } 43 | } 44 | return true; 45 | } 46 | 47 | if (a instanceof Set && b instanceof Set) { 48 | if (a.size !== b.size) { 49 | return false; 50 | } 51 | for (i of a.entries()) { 52 | if (!b.has(i[0])) { 53 | return false; 54 | } 55 | } 56 | return true; 57 | } 58 | 59 | if (ArrayBuffer.isView(a) && ArrayBuffer.isView(b)) { 60 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 61 | // @ts-ignore 62 | length = a.length; 63 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 64 | // @ts-ignore 65 | if (length !== b.length) { 66 | return false; 67 | } 68 | for (i = length; i-- !== 0; ) { 69 | if (a[i] !== b[i]) { 70 | return false; 71 | } 72 | } 73 | return true; 74 | } 75 | 76 | if (a.constructor === RegExp) { 77 | return a.source === b.source && a.flags === b.flags; 78 | } 79 | if (a.valueOf !== Object.prototype.valueOf) { 80 | return a.valueOf() === b.valueOf(); 81 | } 82 | if (a.toString !== Object.prototype.toString) { 83 | return a.toString() === b.toString(); 84 | } 85 | 86 | const keys = Object.keys(a); 87 | length = keys.length; 88 | if (length !== Object.keys(b).length) { 89 | return false; 90 | } 91 | for (i = length; i-- !== 0; ) { 92 | if (!Object.prototype.hasOwnProperty.call(b, keys[i])) { 93 | return false; 94 | } 95 | } 96 | 97 | for (i = length; i-- !== 0; ) { 98 | const key = keys[i]; 99 | 100 | if (!deepEqual(a[key], b[key])) { 101 | return false; 102 | } 103 | } 104 | 105 | return true; 106 | } 107 | 108 | // true if both NaN, false otherwise 109 | // eslint-disable-next-line no-self-compare 110 | return a !== a && b !== b; 111 | }; 112 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { ActionConfig, LovelaceCardConfig } from 'custom-card-helpers'; 2 | import { PresetType } from './presets'; 3 | 4 | export interface PDCConfig extends LovelaceCardConfig { 5 | title?: string; 6 | animation?: 'none' | 'flash' | 'slide'; 7 | entities: EntitySettings[]; 8 | center: center; 9 | } 10 | 11 | export interface EntitySettings extends presetFeatures { 12 | attribute?: string; 13 | arrow_color?: { bigger?: string; equal?: string; smaller?: string }; 14 | calc_excluded?: boolean; 15 | consumer?: boolean; 16 | color_threshold?: number; 17 | decimals?: number; 18 | display_abs?: boolean; 19 | double_tap_action?: ActionConfig; 20 | entity?: string; 21 | hide_arrows?: boolean; 22 | icon?: string; 23 | icon_color?: { bigger?: string; equal?: string; smaller?: string }; 24 | invert_value?: boolean; 25 | invert_arrow?: boolean; 26 | name?: string | undefined; 27 | preset?: PresetType; 28 | producer?: boolean; 29 | secondary_info_attribute?: string; 30 | secondary_info_entity?: string; 31 | secondary_info_replace_name?: boolean; 32 | tap_action?: ActionConfig; 33 | threshold?: number; 34 | unit_of_display?: string; 35 | unit_of_measurement?: string; 36 | } 37 | 38 | export interface center { 39 | type: 'none' | 'card' | 'bars'; 40 | content?: LovelaceCardConfig | BarSettings[]; 41 | } 42 | 43 | export interface presetFeatures { 44 | battery_percentage_entity?: string; 45 | grid_sell_entity?: string; 46 | grid_buy_entity?: string; 47 | } 48 | export interface BarSettings { 49 | bar_color?: string; 50 | bar_bg_color?: string; 51 | entity?: string; 52 | invert_value?: boolean; 53 | name?: string | undefined; 54 | preset?: 'autarky' | 'ratio' | ''; 55 | tap_action?: ActionConfig; 56 | unit_of_measurement?: string; 57 | double_tap_action?: ActionConfig; 58 | } 59 | 60 | export type ArrowStates = 'right' | 'left' | 'none'; 61 | 62 | export interface Target extends EventTarget { 63 | checked?: boolean; 64 | configValue?: string; 65 | i?: number; 66 | value?: string | EntitySettings[] | BarSettings[] | { bigger: string; equal: string; smaller: string }; 67 | } 68 | 69 | export interface CustomValueEvent extends Event { 70 | target: Target; 71 | // currentTarget?: { 72 | // i?: number; 73 | // value?: string; 74 | // }; 75 | detail?: { 76 | value?: T; 77 | }; 78 | } 79 | 80 | export interface EditorTarget extends EventTarget { 81 | value?: string; 82 | index?: number; 83 | checked?: boolean; 84 | configValue?: string; 85 | type?: HTMLInputElement['type']; 86 | config: ActionConfig; 87 | } 88 | 89 | export interface SubElementConfig { 90 | type: 'entity' | 'bars' | 'card'; 91 | index?: number; 92 | } 93 | 94 | export interface HTMLElementValue extends HTMLElement { 95 | value: string; 96 | } 97 | declare global { 98 | interface Window { 99 | loadCardHelpers: () => Promise; 100 | customCards: { type?: string; name?: string; description?: string; preview?: boolean }[]; 101 | ResizeObserver: { new (callback: ResizeObserverCallback): ResizeObserver; prototype: ResizeObserver }; 102 | } 103 | 104 | interface Element { 105 | offsetWidth: number; 106 | } 107 | } 108 | 109 | export interface HassCustomElement extends CustomElementConstructor { 110 | getConfigElement(): Promise; 111 | } 112 | -------------------------------------------------------------------------------- /src/action-handler.ts: -------------------------------------------------------------------------------- 1 | import { fireEvent } from 'custom-card-helpers'; 2 | import { noChange } from 'lit'; 3 | import { AttributePart, directive, Directive, DirectiveParameters } from 'lit/directive.js'; 4 | 5 | import { deepEqual } from './deep-equal'; 6 | 7 | export const actions = ['more-info', 'toggle', 'navigate', 'url', 'call-service', 'none'] as const; 8 | 9 | interface ActionHandlerMock extends HTMLElement { 10 | holdTime: number; 11 | bind(element: Element, options?: ActionHandlerOptions): void; 12 | } 13 | interface ActionHandlerElement extends HTMLElement { 14 | actionHandler?: { 15 | options: ActionHandlerOptions; 16 | start?: (ev: Event) => void; 17 | end?: (ev: Event) => void; 18 | handleEnter?: (ev: KeyboardEvent) => void; 19 | }; 20 | } 21 | 22 | export interface ActionHandlerOptions { 23 | hasHold?: boolean; 24 | hasDoubleClick?: boolean; 25 | disabled?: boolean; 26 | } 27 | 28 | class ActionHandler extends HTMLElement implements ActionHandlerMock { 29 | public holdTime = 500; 30 | protected timer?: number; 31 | private dblClickTimeout?: number; 32 | 33 | public bind(element: ActionHandlerElement, options: ActionHandlerOptions = {}) { 34 | if (element.actionHandler && deepEqual(options, element.actionHandler.options)) { 35 | return; 36 | } 37 | 38 | if (element.actionHandler) { 39 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 40 | element.removeEventListener('click', element.actionHandler.end!); 41 | } 42 | element.actionHandler = { options }; 43 | 44 | if (options.disabled) { 45 | return; 46 | } 47 | 48 | element.actionHandler.end = (ev: Event): void => { 49 | const target = element; //ev.target as HTMLElement; 50 | // Prevent mouse event if touch event 51 | if (ev.cancelable) { 52 | ev.preventDefault(); 53 | } 54 | clearTimeout(this.timer); 55 | this.timer = undefined; 56 | if (options.hasDoubleClick) { 57 | if ((ev.type === 'click' && (ev as MouseEvent).detail < 2) || !this.dblClickTimeout) { 58 | this.dblClickTimeout = window.setTimeout(() => { 59 | this.dblClickTimeout = undefined; 60 | fireEvent(target, 'action', { action: 'tap' }); 61 | }, 250); 62 | } else { 63 | clearTimeout(this.dblClickTimeout); 64 | this.dblClickTimeout = undefined; 65 | fireEvent(target, 'action', { action: 'double_tap' }); 66 | } 67 | } else { 68 | fireEvent(target, 'action', { action: 'tap' }); 69 | } 70 | }; 71 | element.addEventListener('click', element.actionHandler.end); 72 | } 73 | } 74 | 75 | customElements.define('action-handler-power-distribution-card', ActionHandler); 76 | 77 | const getActionHandler = (): ActionHandler => { 78 | const body = document.body; 79 | if (body.querySelector('action-handler-power-distribution-card')) { 80 | return body.querySelector('action-handler-power-distribution-card') as ActionHandler; 81 | } 82 | 83 | const actionhandler = document.createElement('action-handler-power-distribution-card'); 84 | body.appendChild(actionhandler); 85 | 86 | return actionhandler as ActionHandler; 87 | }; 88 | 89 | export const actionHandlerBind = (element: ActionHandlerElement, options?: ActionHandlerOptions): void => { 90 | const actionhandler: ActionHandler = getActionHandler(); 91 | if (!actionhandler) { 92 | return; 93 | } 94 | actionhandler.bind(element, options); 95 | }; 96 | 97 | export const actionHandler = directive( 98 | class extends Directive { 99 | update(part: AttributePart, [options]: DirectiveParameters) { 100 | actionHandlerBind(part.element as ActionHandlerElement, options); 101 | return noChange; 102 | } 103 | // eslint-disable-next-line @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars 104 | render(_options?: ActionHandlerOptions) {} 105 | }, 106 | ); 107 | -------------------------------------------------------------------------------- /src/styles.ts: -------------------------------------------------------------------------------- 1 | import { css, html } from 'lit'; 2 | 3 | export const styles = css` 4 | * { 5 | box-sizing: border-box; 6 | } 7 | 8 | p { 9 | margin: 4px 0 4px 0; 10 | text-align: center; 11 | } 12 | 13 | .card-content { 14 | display: grid; 15 | grid-template-columns: 1.5fr 1fr 1.5fr; 16 | column-gap: 10px; 17 | } 18 | 19 | #center-panel { 20 | display: flex; 21 | align-items: center; 22 | justify-content: center; 23 | grid-column: 2; 24 | flex-wrap: wrap; 25 | min-width: 100px; 26 | } 27 | 28 | #center-panel > div { 29 | display: flex; 30 | width: 100%; 31 | min-height: 150px; 32 | max-height: 200px; 33 | flex-basis: 50%; 34 | flex-flow: column; 35 | } 36 | 37 | #center-panel > div > p { 38 | flex: 0 1 auto; 39 | } 40 | 41 | .bar-wrapper { 42 | position: relative; 43 | 44 | width: 50%; 45 | height: 80%; 46 | margin: auto; 47 | 48 | flex: 1 1 auto; 49 | 50 | background-color: rgba(114, 114, 114, 0.2); 51 | } 52 | 53 | bar { 54 | position: absolute; 55 | right: 0; 56 | bottom: 0; 57 | left: 0; 58 | background-color: var(--secondary-text-color); 59 | } 60 | 61 | item { 62 | display: block; 63 | overflow: hidden; 64 | margin-bottom: 10px; 65 | cursor: pointer; 66 | } 67 | 68 | .buy-sell { 69 | height: 28px; 70 | display: flex; 71 | flex-direction: column; 72 | font-size: 11px; 73 | line-height: 14px; 74 | text-align: center; 75 | } 76 | 77 | .grid-buy { 78 | color: red; 79 | } 80 | 81 | .grid-sell { 82 | color: green; 83 | } 84 | 85 | .placeholder { 86 | height: 62px; 87 | } 88 | 89 | #right-panel > item > value { 90 | float: left; 91 | } 92 | 93 | #right-panel > item > badge { 94 | float: right; 95 | } 96 | 97 | badge { 98 | float: left; 99 | 100 | width: 50%; 101 | padding: 4px; 102 | 103 | border: 1px solid; 104 | border-color: var(--disabled-text-color); 105 | border-radius: 1em; 106 | 107 | position: relative; 108 | } 109 | 110 | icon > ha-icon { 111 | display: block; 112 | 113 | width: 24px; 114 | margin: 0 auto; 115 | 116 | color: var(--state-icon-color); 117 | } 118 | 119 | .secondary { 120 | position: absolute; 121 | top: 4px; 122 | right: 8%; 123 | font-size: 80%; 124 | } 125 | 126 | value { 127 | float: right; 128 | width: 50%; 129 | min-width: 54px; 130 | } 131 | 132 | value > p { 133 | height: 1em; 134 | } 135 | 136 | table { 137 | width: 100%; 138 | } 139 | 140 | /************** 141 | ARROW ANIMATION 142 | **************/ 143 | 144 | .blank { 145 | width: 55px; 146 | height: 4px; 147 | margin: 8px auto 8px auto; 148 | opacity: 0.2; 149 | background-color: var(--secondary-text-color); 150 | } 151 | 152 | .arrow-container { 153 | display: flex; 154 | width: 55px; 155 | height: 16px; 156 | overflow: hidden; 157 | margin: auto; 158 | } 159 | 160 | .left { 161 | transform: rotate(180deg); 162 | } 163 | 164 | .arrow { 165 | width: 0; 166 | border-top: 8px solid transparent; 167 | border-bottom: 8px solid transparent; 168 | border-left: 16px solid var(--secondary-text-color); 169 | margin: 0 1.5px; 170 | } 171 | 172 | .flash { 173 | animation: flash 3s infinite steps(1); 174 | opacity: 0.2; 175 | } 176 | 177 | @keyframes flash { 178 | 0%, 179 | 66% { 180 | opacity: 0.2; 181 | } 182 | 33% { 183 | opacity: 0.8; 184 | } 185 | } 186 | 187 | .delay-1 { 188 | animation-delay: 1s; 189 | } 190 | .delay-2 { 191 | animation-delay: 2s; 192 | } 193 | 194 | .slide { 195 | animation: slide 1.5s linear infinite both; 196 | position: relative; 197 | left: -19px; 198 | } 199 | 200 | @keyframes slide { 201 | 0% { 202 | -webkit-transform: translateX(0); 203 | transform: translateX(0); 204 | } 205 | 100% { 206 | -webkit-transform: translateX(19px); 207 | transform: translateX(19px); 208 | } 209 | } 210 | `; 211 | 212 | export const narrow_styles = html` 213 | 236 | `; 237 | -------------------------------------------------------------------------------- /src/editor/items-editor.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, html } from 'lit'; 2 | 3 | import { HomeAssistant } from 'custom-card-helpers'; 4 | import { EditorTarget, EntitySettings, HTMLElementValue } from '../types'; 5 | import { localize } from '../localize/localize'; 6 | import { customElement, property } from 'lit/decorators.js'; 7 | import { repeat } from 'lit/directives/repeat.js'; 8 | import { css, CSSResult, nothing } from 'lit'; 9 | import { mdiClose, mdiPencil, mdiPlusCircleOutline } from '@mdi/js'; 10 | import { DefaultItem, PresetList, PresetObject } from '../presets'; 11 | import { fireEvent } from '../util'; 12 | 13 | import Sortable from 'sortablejs'; 14 | import SortableCore, { OnSpill, AutoScroll, SortableEvent } from 'sortablejs/modular/sortable.core.esm'; 15 | 16 | SortableCore.mount(OnSpill, new AutoScroll()); 17 | 18 | @customElement('power-distribution-card-items-editor') 19 | export class ItemsEditor extends LitElement { 20 | @property({ attribute: false }) entities?: EntitySettings[]; 21 | 22 | @property({ attribute: false }) hass?: HomeAssistant; 23 | 24 | private _sortable?: Sortable; 25 | 26 | private _entityKeys = new WeakMap(); 27 | 28 | private _getKey(action: EntitySettings) { 29 | if (!this._entityKeys.has(action)) { 30 | this._entityKeys.set(action, Math.random().toString()); 31 | } 32 | 33 | return this._entityKeys.get(action)!; 34 | } 35 | 36 | public disconnectedCallback() { 37 | this._destroySortable(); 38 | } 39 | 40 | private _destroySortable() { 41 | this._sortable?.destroy(); 42 | this._sortable = undefined; 43 | } 44 | 45 | protected async firstUpdated(): Promise { 46 | this._createSortable(); 47 | } 48 | 49 | /** 50 | * Creating the Sortable Element (https://github.com/SortableJS/sortablejs) used as a foundation 51 | */ 52 | private _createSortable(): void { 53 | this._sortable = new Sortable(this.shadowRoot!.querySelector('.entities')!, { 54 | animation: 150, 55 | fallbackClass: 'sortable-fallback', 56 | handle: '.handle', 57 | onChoose: (evt: SortableEvent) => { 58 | (evt.item as any).placeholder = document.createComment('sort-placeholder'); 59 | evt.item.after((evt.item as any).placeholder); 60 | }, 61 | onEnd: (evt: SortableEvent) => { 62 | // put back in original location 63 | if ((evt.item as any).placeholder) { 64 | (evt.item as any).placeholder.replaceWith(evt.item); 65 | delete (evt.item as any).placeholder; 66 | } 67 | this._rowMoved(evt); 68 | }, 69 | }); 70 | } 71 | 72 | protected render() { 73 | if (!this.entities || !this.hass) { 74 | return nothing; 75 | } 76 | 77 | return html` 78 |

${localize('editor.settings.entities')}

79 |
80 | ${repeat( 81 | this.entities, 82 | (entityConf) => this._getKey(entityConf), 83 | (entityConf, index) => html` 84 |
85 |
86 | 87 |
88 | 98 | 99 | 106 | 107 | 114 |
115 | `, 116 | )} 117 |
118 |
119 | ev.stopPropagation()} 126 | > 127 | ${PresetList.map((val) => html`${val}`)} 128 | 129 | 130 | 131 | 132 | 138 |
139 | `; 140 | } 141 | 142 | private _valueChanged(ev: CustomEvent): void { 143 | if (!this.entities || !this.hass) { 144 | return; 145 | } 146 | const value = ev.detail.value; 147 | const index = (ev.target as any).index; 148 | const newConfigEntities = this.entities!.concat(); 149 | 150 | newConfigEntities[index] = { 151 | ...newConfigEntities[index], 152 | entity: value || '', 153 | }; 154 | 155 | fireEvent(this, 'config-changed', newConfigEntities); 156 | } 157 | 158 | private _removeRow(ev: Event): void { 159 | ev.stopPropagation(); 160 | const index = (ev.currentTarget as EditorTarget).index; 161 | if (index != undefined) { 162 | const entities = this.entities!.concat(); 163 | entities.splice(index, 1); 164 | fireEvent(this, 'config-changed', entities); 165 | } 166 | } 167 | 168 | private _editRow(ev: Event): void { 169 | ev.stopPropagation(); 170 | 171 | const index = (ev.target as EditorTarget).index; 172 | if (index != undefined) { 173 | fireEvent(this, 'edit-item', index); 174 | } 175 | } 176 | 177 | private _addRow(ev: Event): void { 178 | ev.stopPropagation(); 179 | if (!this.entities || !this.hass) { 180 | return; 181 | } 182 | 183 | const preset = (this.shadowRoot!.querySelector('.add-preset') as HTMLElementValue).value || 'placeholder'; 184 | const entity_id = (this.shadowRoot!.querySelector('.add-entity') as HTMLElementValue).value; 185 | 186 | const item = Object.assign({}, DefaultItem, PresetObject[preset], { 187 | entity: entity_id, 188 | preset: entity_id == '' ? 'placeholder' : preset, 189 | }); 190 | 191 | fireEvent(this, 'config-changed', [...this.entities, item]); 192 | } 193 | 194 | private _rowMoved(ev: SortableEvent): void { 195 | ev.stopPropagation(); 196 | if (ev.oldIndex === ev.newIndex || !this.entities) return; 197 | 198 | const newEntities = this.entities.concat(); 199 | newEntities.splice(ev.newIndex!, 0, newEntities.splice(ev.oldIndex!, 1)[0]); 200 | 201 | fireEvent(this, 'config-changed', newEntities); 202 | } 203 | 204 | static get styles(): CSSResult { 205 | return css` 206 | #sortable a:nth-of-type(2n) paper-icon-item { 207 | animation-name: keyframes1; 208 | animation-iteration-count: infinite; 209 | transform-origin: 50% 10%; 210 | animation-delay: -0.75s; 211 | animation-duration: 0.25s; 212 | } 213 | #sortable a:nth-of-type(2n-1) paper-icon-item { 214 | animation-name: keyframes2; 215 | animation-iteration-count: infinite; 216 | animation-direction: alternate; 217 | transform-origin: 30% 5%; 218 | animation-delay: -0.5s; 219 | animation-duration: 0.33s; 220 | } 221 | #sortable a { 222 | height: 48px; 223 | display: flex; 224 | } 225 | #sortable { 226 | outline: none; 227 | display: block !important; 228 | } 229 | .hidden-panel { 230 | display: flex !important; 231 | } 232 | .sortable-fallback { 233 | display: none; 234 | } 235 | .sortable-ghost { 236 | opacity: 0.4; 237 | } 238 | .sortable-fallback { 239 | opacity: 0; 240 | } 241 | @keyframes keyframes1 { 242 | 0% { 243 | transform: rotate(-1deg); 244 | animation-timing-function: ease-in; 245 | } 246 | 50% { 247 | transform: rotate(1.5deg); 248 | animation-timing-function: ease-out; 249 | } 250 | } 251 | @keyframes keyframes2 { 252 | 0% { 253 | transform: rotate(1deg); 254 | animation-timing-function: ease-in; 255 | } 256 | 50% { 257 | transform: rotate(-1.5deg); 258 | animation-timing-function: ease-out; 259 | } 260 | } 261 | .show-panel, 262 | .hide-panel { 263 | display: none; 264 | position: absolute; 265 | top: 0; 266 | right: 4px; 267 | --mdc-icon-button-size: 40px; 268 | } 269 | :host([rtl]) .show-panel { 270 | right: initial; 271 | left: 4px; 272 | } 273 | .hide-panel { 274 | top: 4px; 275 | right: 8px; 276 | } 277 | :host([rtl]) .hide-panel { 278 | right: initial; 279 | left: 8px; 280 | } 281 | :host([expanded]) .hide-panel { 282 | display: block; 283 | } 284 | :host([expanded]) .show-panel { 285 | display: inline-flex; 286 | } 287 | paper-icon-item.hidden-panel, 288 | paper-icon-item.hidden-panel span, 289 | paper-icon-item.hidden-panel ha-icon[slot='item-icon'] { 290 | color: var(--secondary-text-color); 291 | cursor: pointer; 292 | } 293 | .entity, 294 | .add-item { 295 | display: flex; 296 | align-items: center; 297 | } 298 | .entity { 299 | display: flex; 300 | align-items: center; 301 | } 302 | .entity .handle { 303 | padding-right: 8px; 304 | cursor: move; 305 | padding-inline-end: 8px; 306 | padding-inline-start: initial; 307 | direction: var(--direction); 308 | } 309 | .entity .handle > * { 310 | pointer-events: none; 311 | } 312 | .entity ha-entity-picker, 313 | .add-item ha-entity-picker { 314 | flex-grow: 1; 315 | } 316 | .entities { 317 | margin-bottom: 8px; 318 | } 319 | .add-preset { 320 | padding-right: 8px; 321 | max-width: 130px; 322 | } 323 | .remove-icon, 324 | .edit-icon, 325 | .add-icon { 326 | --mdc-icon-button-size: 36px; 327 | color: var(--secondary-text-color); 328 | } 329 | `; 330 | } 331 | } 332 | -------------------------------------------------------------------------------- /src/editor/item-editor.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, TemplateResult, html } from 'lit'; 2 | import { customElement, property } from 'lit/decorators.js'; 3 | 4 | import { HomeAssistant } from 'custom-card-helpers'; 5 | 6 | import { fireEvent } from '../util'; 7 | import { EditorTarget, EntitySettings } from '../types'; 8 | import { localize } from '../localize/localize'; 9 | import { PresetList } from '../presets'; 10 | import { actions } from '../action-handler'; 11 | import { css, CSSResult } from 'lit'; 12 | 13 | @customElement('power-distribution-card-item-editor') 14 | export class ItemEditor extends LitElement { 15 | @property({ attribute: false }) config?: EntitySettings; 16 | 17 | @property({ attribute: false }) hass?: HomeAssistant; 18 | 19 | protected render(): TemplateResult { 20 | // If its a placeholder, don't render anything 21 | if (!this.hass || !this.config || this.config.preset == 'placeholder') { 22 | return html``; 23 | } 24 | const item = this.config; 25 | 26 | // Attributes for the selection drop down panel 27 | // TODO!: Why destructure the object 28 | let attributes: string[] = []; 29 | if (item.entity) { 30 | attributes = Object.keys({ ...this.hass?.states[item.entity || 0].attributes }) || []; 31 | } 32 | 33 | let secondary_info_attributes: string[] = []; 34 | if (item.secondary_info_entity) { 35 | secondary_info_attributes = Object.keys({ ...this.hass?.states[item.secondary_info_entity].attributes }) || []; 36 | } 37 | 38 | return html` 39 |
40 | 46 | 52 |
53 |
54 | 63 | ev.stopPropagation()} 69 | > 70 | ${attributes.length > 0 ? html`` : ''} 71 | ${attributes.map((val) => html`${val}`)} 72 | 73 |
74 |
75 | ev.stopPropagation()} 81 | > 82 | ${PresetList.map((val) => html`${val}`)} 83 | 84 |
85 | 92 | 93 |
94 |
95 |
${this._renderPresetFeatures()}
96 |

97 |

${localize('editor.settings.value', true)} ${localize('editor.settings.settings', true)}

98 |
99 | 105 | 113 |
114 |
115 |
116 | 123 | 124 |
125 |
126 | 133 | 134 |
135 |
136 |
137 |
138 | 145 | 146 |
147 | 153 |
154 |
155 |

${localize('editor.settings.secondary-info', true)}

156 |
157 | 166 | ev.stopPropagation()} 173 | > 174 | ${secondary_info_attributes.length > 0 ? html`` : undefined} 175 | ${secondary_info_attributes.map((val) => html`${val}`)} 176 | 177 |
178 |
179 | 186 | 187 |
188 |
189 |

${localize('editor.settings.color-settings', true)}

190 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 213 | 221 | 229 | 230 | 231 | 232 | 240 | 248 | 256 | 257 |
Element> ${item.color_threshold || 0}= ${item.color_threshold || 0}< ${item.color_threshold || 0}
icon 206 | 212 | 214 | 220 | 222 | 228 |
arrows 233 | 239 | 241 | 247 | 249 | 255 |
258 |
259 |

${localize('editor.settings.action_settings')}

260 |
261 | 269 | 270 | 278 | 279 |
280 | `; 281 | } 282 | 283 | private _renderPresetFeatures(): TemplateResult { 284 | if (!this.config || !this.hass) return html``; 285 | 286 | const preset = this.config.preset; 287 | switch (preset) { 288 | case 'battery': 289 | return html` 290 | 299 | `; 300 | case 'grid': 301 | return html` 302 | 311 | 320 | `; 321 | default: 322 | return html``; 323 | } 324 | } 325 | 326 | private _valueChanged(ev: CustomEvent): void { 327 | ev.stopPropagation(); 328 | if (!this.config || !this.hass) { 329 | return; 330 | } 331 | 332 | const target = ev.target! as EditorTarget; 333 | 334 | const value = target.checked !== undefined ? target.checked : ev.detail.value || target.value || ev.detail.config; 335 | const configValue = target.configValue; 336 | // Skip if no configValue or value is the same 337 | if (!configValue || this.config[configValue] === value) { 338 | return; 339 | } 340 | 341 | fireEvent(this, 'config-changed', { ...this.config, [configValue]: value }); 342 | } 343 | 344 | private _colorChanged(ev: CustomEvent): void { 345 | ev.stopPropagation(); 346 | if (!this.config || !this.hass) { 347 | return; 348 | } 349 | 350 | const target = ev.target! as EditorTarget; 351 | 352 | const value = target.value; 353 | const configValue = target.configValue; 354 | if (!configValue) return; 355 | // Split configvalue 356 | const [thing, step] = configValue.split('.'); 357 | 358 | const color_set = { ...this.config[thing] } || {}; 359 | color_set[step] = value; 360 | 361 | // Skip if no configValue or value is the same 362 | if (!configValue || this.config[thing] === color_set) return; 363 | 364 | fireEvent(this, 'config-changed', { ...this.config, [thing]: color_set }); 365 | } 366 | 367 | static get styles(): CSSResult { 368 | return css` 369 | .checkbox { 370 | display: flex; 371 | align-items: center; 372 | padding: 8px 0; 373 | } 374 | .checkbox input { 375 | height: 20px; 376 | width: 20px; 377 | margin-left: 0; 378 | margin-right: 8px; 379 | } 380 | h3 { 381 | margin-bottom: 0.5em; 382 | } 383 | .row { 384 | margin-bottom: 12px; 385 | margin-top: 12px; 386 | display: block; 387 | } 388 | .side-by-side { 389 | display: flex; 390 | } 391 | .side-by-side > * { 392 | flex: 1 1 0%; 393 | padding-right: 4px; 394 | } 395 | `; 396 | } 397 | } 398 | -------------------------------------------------------------------------------- /src/editor/editor.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, TemplateResult, html, css, CSSResultGroup } from 'lit'; 2 | import { customElement, property, state } from 'lit/decorators.js'; 3 | 4 | 5 | import { mdiClose, mdiPencil } from '@mdi/js'; 6 | 7 | import { fireEvent, HomeAssistant, LovelaceCardEditor } from 'custom-card-helpers'; 8 | import { 9 | PDCConfig, 10 | SubElementConfig, 11 | BarSettings, 12 | HassCustomElement, 13 | EntitySettings, 14 | } from '../types'; 15 | import { localize } from '../localize/localize'; 16 | 17 | import './item-editor'; 18 | import './items-editor'; 19 | 20 | /** 21 | * Editor Settings 22 | */ 23 | const animation = ['none', 'flash', 'slide']; 24 | const center = ['none', 'card', 'bars']; 25 | const bar_presets = ['autarky', 'ratio', '']; 26 | const actions = ['more-info', 'toggle', 'navigate', 'url', 'call-service', 'none']; 27 | 28 | @customElement('power-distribution-card-editor') 29 | export class PowerDistributionCardEditor extends LitElement implements LovelaceCardEditor { 30 | @property({ attribute: false }) public hass?: HomeAssistant; 31 | @state() private _config!: PDCConfig; 32 | 33 | public async setConfig(config: PDCConfig): Promise { 34 | this._config = config; 35 | } 36 | 37 | /** 38 | * This Preloads all standard hass components which are not natively avaiable 39 | * https://discord.com/channels/330944238910963714/351047592588869643/783477690036125747 for more info 40 | * Update 2022-11-22 : Visual editors in homeassistant have primarily changed to use the ha-form component! 41 | */ 42 | protected async firstUpdated(): Promise { 43 | if (!customElements.get('ha-form') || !customElements.get('hui-action-editor')) { 44 | (customElements.get('hui-button-card') as HassCustomElement)?.getConfigElement(); 45 | } 46 | 47 | if (!customElements.get('ha-entity-picker')) { 48 | (customElements.get('hui-entities-card') as HassCustomElement)?.getConfigElement(); 49 | } 50 | 51 | console.log(this.hass); 52 | } 53 | 54 | protected render(): TemplateResult | void { 55 | if (!this.hass) return html``; 56 | if (this._subElementEditor) return this._renderSubElementEditor(); 57 | return html` 58 |
59 | 65 | ev.stopPropagation()} 73 | > 74 | ${animation.map((val) => html`${val}`)} 75 | 76 |
77 |
78 | ev.stopPropagation()} 83 | .value=${this._config?.center?.type || 'none'} 84 | > 85 | ${center.map((val) => html`${val}`)} 86 | 87 | ${this._config?.center?.type == 'bars' || this._config?.center?.type == 'card' 88 | ? html`` 94 | : ''} 95 |
96 |
97 | 103 | 104 |
105 | `; 106 | } 107 | 108 | private _entitiesChanged(ev: CustomEvent): void { 109 | ev.stopPropagation(); 110 | if (!this._config || !this.hass) { 111 | return; 112 | } 113 | fireEvent(this, 'config-changed', { config: { ...this._config, entities: ev.detail } as PDCConfig }); 114 | } 115 | 116 | private _edit_item(ev: CustomEvent): void { 117 | ev.stopPropagation(); 118 | if (!this._config || !this.hass) { 119 | return; 120 | } 121 | const index = ev.detail; 122 | 123 | this._subElementEditor = { 124 | type: 'entity', 125 | index: index, 126 | }; 127 | } 128 | 129 | /** 130 | * SubElementEditor 131 | */ 132 | 133 | @state() private _subElementEditor: SubElementConfig | undefined = undefined; 134 | 135 | private _renderSubElementEditor(): TemplateResult { 136 | const subel: TemplateResult[] = [ 137 | html` 138 |
139 |
140 | 141 | 142 | 143 |
144 |
`, 145 | ]; 146 | const index = this._subElementEditor?.index; 147 | switch (this._subElementEditor?.type) { 148 | case 'entity': 149 | subel.push(html` 150 | 155 | 156 | `); 157 | break; 158 | case 'bars': 159 | subel.push(this._barEditor()); 160 | break; 161 | case 'card': 162 | subel.push(this._cardEditor()); 163 | break; 164 | } 165 | return html`${subel}`; 166 | } 167 | 168 | private _goBack(): void { 169 | this._subElementEditor = undefined; 170 | // Resetting the entities sortable list 171 | // this._sortable?.destroy(); 172 | // this._sortable = undefined; 173 | // this._sortable = this._createSortable(); 174 | } 175 | 176 | private _itemChanged(ev: CustomEvent) { 177 | ev.stopPropagation(); 178 | if (!this._config || !this.hass) { 179 | return; 180 | } 181 | const index = this._subElementEditor?.index; 182 | if (index != undefined) { 183 | const entities = [...this._config.entities]; 184 | entities[index] = ev.detail; 185 | fireEvent(this, 'config-changed', { config : { ...this._config, entities: entities }}); 186 | } 187 | } 188 | 189 | /** 190 | * TODO: Get rid of duplicated Updating functions 191 | * Custom handeling for Center panel 192 | */ 193 | private _centerChanged(ev: any): void { 194 | if (!this._config || !this.hass) { 195 | return; 196 | } 197 | if (ev.target) { 198 | const target = ev.target; 199 | if (target.configValue) { 200 | this._config = { 201 | ...this._config, 202 | center: { 203 | ...this._config.center, 204 | [target.configValue]: target.checked !== undefined ? target.checked : target.value, 205 | }, 206 | }; 207 | } 208 | } 209 | fireEvent(this, 'config-changed', { config: this._config }); 210 | } 211 | 212 | private _editCenter(ev: any): void { 213 | if (ev.currentTarget) { 214 | this._subElementEditor = { 215 | type: <'card' | 'bars'>ev.currentTarget.value, 216 | }; 217 | } 218 | } 219 | 220 | 221 | /** 222 | * Bar Editor 223 | * ------------------- 224 | * This Bar Editor allows the user to easily add and remove new bars. 225 | */ 226 | 227 | private _barChanged(ev: any): void { 228 | if (!ev.target) return; 229 | const target = ev.target; 230 | if (!target.configValue) return; 231 | let content: BarSettings[]; 232 | if (target.configValue == 'content') { 233 | content = target.value as BarSettings[]; 234 | } else { 235 | content = [...(this._config.center.content as BarSettings[])]; 236 | const index = target.i || this._subElementEditor?.index || 0; 237 | content[index] = { 238 | ...content[index], 239 | [target.configValue]: target.checked != undefined ? target.checked : target.value, 240 | }; 241 | } 242 | 243 | this._config = { ...this._config, center: { type: 'bars', content: content } }; 244 | fireEvent(this, 'config-changed', { config: this._config }); 245 | } 246 | 247 | private _removeBar(ev: any): void { 248 | const index = ev.currentTarget?.i || 0; 249 | const newBars = [...(this._config.center.content as BarSettings[])]; 250 | newBars.splice(index, 1); 251 | 252 | this._barChanged({ target: { configValue: 'content', value: newBars } }); 253 | } 254 | 255 | private async _addBar(): Promise { 256 | const item = Object.assign({}, { name: 'Name', preset: 'custom' }); 257 | const newBars = [...((this._config.center.content as BarSettings[]) || []), item]; 258 | //This basically fakes a event object 259 | this._barChanged({ target: { configValue: 'content', value: newBars } }); 260 | } 261 | 262 | private _barEditor(): TemplateResult { 263 | const editor: TemplateResult[] = []; 264 | if (this._config.center.content) { 265 | (this._config.center.content as BarSettings[]).forEach((e, i) => 266 | editor.push(html` 267 |
268 |

Bar ${i + 1} 269 | 276 | 277 |

278 |
279 | 286 | 296 |
297 |
298 |
299 | 307 | 308 |
309 |
310 | ev.stopPropagation()} 316 | .i=${i} 317 | > 318 | ${bar_presets.map((val) => html`${val}`)} 319 | 320 |
321 |
322 |
323 | 330 | 337 |
338 |

${localize('editor.settings.action_settings')}

339 |
340 | 348 | 349 | 357 | 358 |
359 |
360 |
361 | `), 362 | ); 363 | } 364 | editor.push(html` 365 | 366 | 367 | 368 | `); 369 | return html`${editor.map((e) => html`${e}`)}`; 370 | } 371 | 372 | /** 373 | * Card Editor 374 | * ----------- 375 | * The Following is needed to implement the Card editor inside of the editor 376 | * 382 | */ 383 | 384 | //@query('hui-card-element-editor') 385 | //private _cardEditorEl?; 386 | 387 | private _cardEditor(): TemplateResult { 388 | //const card = this._subElementEditor?.element; 389 | return html` 390 | Sadly you cannot edit cards from the visual editor yet. 391 |

392 | Check out the 393 | Readme 396 | to check out the latest and best way to add it. 397 | `; 398 | } 399 | 400 | 401 | private _valueChanged(ev: any): void { 402 | if (!this._config || !this.hass) { 403 | return; 404 | } 405 | if (ev.target) { 406 | const target = ev.target; 407 | if (target.configValue) { 408 | this._config = { 409 | ...this._config, 410 | [target.configValue]: target.checked !== undefined ? target.checked : target.value, 411 | }; 412 | } 413 | } 414 | fireEvent(this, 'config-changed', { config: this._config }); 415 | } 416 | 417 | /** 418 | * The Second Part comes from here: https://github.com/home-assistant/frontend/blob/dev/src/resources/ha-sortable-style.ts 419 | * @returns Editor CSS 420 | */ 421 | static get styles(): CSSResultGroup[] { 422 | return [ 423 | css` 424 | .checkbox { 425 | display: flex; 426 | align-items: center; 427 | padding: 8px 0; 428 | } 429 | .checkbox input { 430 | height: 20px; 431 | width: 20px; 432 | margin-left: 0; 433 | margin-right: 8px; 434 | } 435 | `, 436 | css` 437 | h3 { 438 | margin-bottom: 0.5em; 439 | } 440 | .row { 441 | margin-bottom: 12px; 442 | margin-top: 12px; 443 | display: block; 444 | } 445 | .side-by-side { 446 | display: flex; 447 | } 448 | .side-by-side > * { 449 | flex: 1 1 0%; 450 | padding-right: 4px; 451 | } 452 | .entity, 453 | .add-item { 454 | display: flex; 455 | align-items: center; 456 | } 457 | .entity .handle { 458 | padding-right: 8px; 459 | cursor: move; 460 | } 461 | .entity ha-entity-picker, 462 | .add-item ha-entity-picker { 463 | flex-grow: 1; 464 | } 465 | .add-preset { 466 | padding-right: 8px; 467 | max-width: 130px; 468 | } 469 | .remove-icon, 470 | .edit-icon, 471 | .add-icon { 472 | --mdc-icon-button-size: 36px; 473 | color: var(--secondary-text-color); 474 | } 475 | .secondary { 476 | font-size: 12px; 477 | color: var(--secondary-text-color); 478 | }`, 479 | ]; 480 | } 481 | } 482 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # power-distribution-card 2 | [![GitHub package.json version](https://img.shields.io/github/package-json/v/JonahKr/power-distribution-card)](https://github.com/JonahKr/power-distribution-card/blob/master/package.json) 3 | [![Actions Status](https://github.com/JonahKr/power-distribution-card/workflows/Build/badge.svg)](https://github.com/JonahKr/power-distribution-card/actions) 4 | [![GitHub license](https://img.shields.io/github/license/JonahKr/power-distribution-card)](https://img.shields.io/github/license/JonahKr/power-distribution-card/blob/master/LICENSE) 5 | [![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg)](https://github.com/custom-components/hacs) 6 | Buy Me A Coffee 7 |
8 | 9 |

10 | Inspired by 11 | e3dc-logo 12 |
13 |
14 |

The Lovelace Card for visualizing power distributions.

15 |

16 | 17 |

18 | 19 |
20 | 21 | 22 | 23 |
24 |

Table of Contents

25 | 42 |
43 |
44 | 45 |
46 | 47 |
48 | 49 | 50 |
51 |

Installation

52 | 53 |

Installation via HACS

54 | 55 | 1. Make sure the [HACS](https://github.com/custom-components/hacs) custom component is installed and working. 56 | 2. Search for `power-distribution-card` and add it through HACS 57 | 3. Refresh home-assistant. 58 | 59 |

Manual installation

60 | 61 | 1. Download the latest release of the [power-distribution-card](http://www.github.com/JonahKr/power-distribution-card/releases/latest/download/power-distribution-card.js) 62 | 2. Place the file in your `config/www` folder 63 | 3. Include the card code in your `ui-lovelace-card.yaml` 64 | ```yaml 65 | resources: 66 | - url: /local/power-distribution-card.js 67 | type: module 68 | ``` 69 | Or alternatively set it up via the UI: 70 | `Configuration -> Lovelace Dashboards -> Resources (TAB)` 71 | For more guidance check out the [docs](https://developers.home-assistant.io/docs/frontend/custom-ui/registering-resources/). 72 | 73 |
74 |
75 | 76 | *** 77 | 78 |
79 | 80 |
81 |

Configuration

82 | 83 |
84 |

Presets

85 | 86 | Every Sensor you want to add has to use one of the Presets. You can add as many of these as you want. 87 | 88 | 89 | 90 | 95 | 98 | 101 | 104 | 107 | 110 | 113 | 116 | 119 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 140 | 143 | 146 | 149 | 152 | 155 | 158 | 161 | 164 | 167 | 170 | 171 |
91 | mdi-battery-outline 92 | 93 | mdi-electirc-car 94 | 96 | mdi-lightbulb 97 | 99 | mdi-transmission-tower 100 | 102 | mdi-home-assistant 103 | 105 | mdi-hydro-power 106 | 108 | mdi-pool 109 | 111 | mdi-lightning-bolt-outline 112 | 114 | mdi-solar-power 115 | 117 | mdi-wind-turbine 118 | 120 | mdi-radiator 121 |
batterycar_chargerconsumergridhomehydropoolproducersolarwindheating
138 | Any Home Battery e.g. E3dc, Powerwall 139 | 141 | Any Electric Car Charger 142 | 144 | A custom home power consumer 145 | 147 | The interface to the power grid 148 | 150 | Your Home's power consumption 151 | 153 | Hydropower setup like Turbulent 154 | 156 | pool heater or pump 157 | 159 | custom home power producer 160 | 162 | Power coming from Solar 163 | 165 | Power coming from Wind 166 | 168 | Radiators 169 |
172 | 173 | The presets *consumer* and *producer* enable to add any custom device into your Card with just a bit of tweaking. 174 |
175 |
176 |
177 | 178 | ## Simple Configuration 🛠️ 179 | 180 | With Version 2.0 a Visual Editor got introduced. 181 | You can find the Card in your Card Selector probably at the bottom. 182 | From there on you can configure your way to your custom Card. 183 | The easiest way to get your Card up and running, is by defining the entities for the presets directly. 184 |
185 | 186 |

187 | 188 |

189 |
190 | 191 | ```diff 192 | ! Please Check for every Sensor: positive sensor values = production, negative values = consumption 193 | ! If this is the other way around in your Case, check the `invert_value` setting (Advanced Configuration)! 194 | ``` 195 | 196 |

197 | 198 |

199 | 200 | 201 | ### Placeholder 202 | By submitting an empty entity_id and preset, you will generate a plain transparent placeholder item which can be used to further customize your layout. 203 | Alternatively you can use the provided `placeholder` preset. 204 |

205 | 206 |

207 |
208 |

209 | 210 | 211 |
212 | 213 | ## YAML Only 214 | 215 | If you are a real hardcore YAML connoisseur here is a basic example to get things started: 216 | ```yaml 217 | type: 'custom:power-distribution-card' 218 | title: Title 219 | animation: flash 220 | entities: 221 | - entity: sensor.e3dc_home 222 | preset: home 223 | - entity: sensor.e3dc_solar 224 | preset: solar 225 | - entity: sensor.e3dc_battery 226 | preset: battery 227 | center: 228 | type: bars 229 | content: 230 | - preset: autarky 231 | name: autarky 232 | - preset: ratio 233 | name: ratio 234 | ``` 235 | You can find all options for every entity here. 236 | If you want to further modify the center panel youz can find the documentation here. 237 |
238 |

239 | 240 | 241 |
242 | 243 | ## Animation 244 | 245 | For the animation you have 3 options: `flash`, `slide`, `none` 246 | ```yaml 247 | type: 'custom:power-distribution-card' 248 | animation: 'slide' 249 | ``` 250 |
251 |
252 | 253 |
254 | 255 | ## Center Panel 256 | 257 | For customizing the Center Panel you basically have 3 Options: 258 | 259 | ### None 🕳️ 260 | 261 | the *void* 262 | 263 |
264 | 265 | ### Bars 📊 266 | 267 | Bars have the following Settings: 268 | | Setting | type | example | description | 269 | | --------------------- |:-------------:|:-----------------:| :------------| 270 | | `bar_color` | string | red, #C1C1C1 |You can pass any string that CSS will accept as a color. | 271 | | `bar_bg_color` | string | red, #C1C1C1 |The Background Color of the Bar. You can pass any string that CSS will accept as a color. | 272 | | `entity` | string | sensor.ln_autarky | You can specify the entity_id here aswell. | 273 | | `invert_value` | bool | false | This will invert the value recieved from HASS. This affects calculations aswell! | 274 | | `name` | string | Eigenstrom | Feel free to change the displayed name of the element. | 275 | | `preset` | 'ratio' 'autarky' 'custom' | all in type | Option to autocalc ratio/autarky. | 276 | | `tap_action` | Action Config | [Configuration](https://www.home-assistant.io/lovelace/actions/#configuration-variables) | Single tap action for item. | 277 | | `double_tap_action` | Action Config | [Configuration](https://www.home-assistant.io/lovelace/actions/#configuration-variables) | Double tap action for item. | 278 | | `unit_of_measurement` | string | *W* , *kW* | Default: %; The Unit of the sensor value. **Should be detected automatically!** | 279 | 280 |
281 | 282 | ### Cards 🃏 283 | 284 | 285 |

286 | 287 |

288 | 289 | Cards couldn't yet be included in the Visual editor in a nice way. I am working on it though. Feel free to open a Issue with suggestions. 290 | To add a card you can simply replace the `center` part in the Code Editor. Be aware though: While you can switch between `none` and `card` without any issues, switching to Bars will override your settings. 291 | 292 | For example you could insert a glance card: 293 | ```yaml 294 | center: 295 | type: card 296 | content: 297 | type: glance 298 | entities: 299 | - sensor.any_Sensor 300 | ``` 301 |
302 |

303 | 304 |
305 | 306 | ## Entity Configuration ⚙️ 307 | 308 | There are alot of settings you can customize your sensors with: 309 | 310 | | Setting | type | example | description | 311 | | -------------------------- |:-------------:|:----------------------------:| :------------| 312 | | `attribute` | string | deferredWatts | A Sensor can have multiple attributes. If one of them is your desired value to display, add it here. | 313 | | `arrow_color` | object | {smaller:'red'} | You can Change the Color of the arrow dependant on the value. (Bigger, Equal and Smaller) | 314 | | `calc_excluded` | boolean | true | If the Item should be excluded from ratio/autarky calculations | 315 | | `color_threshold` | number | 0, -100, 420.69 | The value at which the coloring logic your switch on. (default: 0) | 316 | | `decimals` | number | 0, 2 | The Number of Decimals shown. (default: 2) | 317 | | `display_abs` | boolean | false | Values are displayed absolute per default. | 318 | | `double_tap_action` | Action Config | [Configuration](https://www.home-assistant.io/lovelace/actions/#configuration-variables) | Double tap action for item. | 319 | | `entity` | string | sensor.e3dc_grid | You can specify the entity_id here aswell. | 320 | | `hide_arrows` | bool | true | Toggeling the visibility od the *arrows*. | 321 | | `icon` | string | mdi:dishwasher | Why not change the displayed Icon to any [MDI](https://pictogrammers.com/library/mdi/) one? | 322 | | `icon_color` | object | {smaller:'red'} | You can Change the Color of the icon dependant on the value. (Bigger, Equal and Smaller) | 323 | | `invert_arrow` | bool | true | This will change the *arrows* direction to the oposite one. | 324 | | `invert_value` | bool | false | This will invert the value recieved from HASS. This affects calculations aswell! | 325 | | `name` | string | dishwasher | Feel free to change the displayed name of the element. | 326 | | `secondary_info_attribute` | string | min_temp | Requires Entity. Instead of Sensor, the Attribute Value gets displayed. | 327 | | `secondary_info_entity` | string | sensor.e3dc_grid | entity_id of the secondary info sensor | 328 | | `secondary_info_replace_name` | bool | true | This will replace the name of the item with the secondary info. | 329 | | `tap_action` | Action Config | [Configuration](https://www.home-assistant.io/lovelace/actions/#configuration-variables) | Single tap action for item. | 330 | | `threshold` | number | 2 | Ignoring all abolute values smaller than threshold. | 331 | | `unit_of_display` | string | *W* , *kW* , *adaptive* | The Unit the value is displayed in (default: W). Adaptive will show kW for values >= 1kW | 332 | | `unit_of_measurement` | string | *W* , *kW* | The Unit of the sensor value. **Should be detected automatically!** | 333 |

334 | 335 | This could look something like: 336 | 337 | ```yaml 338 | entities: 339 | - decimals: 2 340 | display_abs: true 341 | name: battery 342 | unit_of_display: W 343 | consumer: true 344 | icon: 'mdi:battery-outline' 345 | producer: true 346 | entity: sensor.e3dc_battery 347 | preset: battery 348 | icon_color: 349 | bigger: 'green' 350 | equal: '' 351 | smaller: 'red' 352 | ``` 353 | 354 |

355 |
356 |
357 | 358 |
359 | 360 | ## Preset features 361 | 362 | The Presets `battery` and `grid` have some additional features which allow some further customization. 363 | For the Battery the icon can display the state of charge and the grid preset can have a small display with power sold and bought from the grid. 364 | 365 | 366 | 367 | If one of those presets is selected there will be additional options in the visual editor. 368 | If you prefer yaml, here are all extra options which can be set per item: 369 | 370 | | Setting | type | example | description | 371 | | --------------------------- |:-------------:|:----------------------------:| :------------| 372 | | `battery_percentage_entity` | string | sensor.xyz | Sensor containing the battery charge percentage from 0 to 100 | 373 | | `grid_buy_entity` | string | sensor.xyz | Sensor containing the imported power from the grid | 374 | | `grid_sell_entity` | string | sensor.xyz | Sensor containing the sold power towards the grid | 375 | 376 |
377 |
378 | 379 |
380 | 381 |
382 |

FAQs ❓

383 | 384 | ### What the heck are these autarky and ratio calculating? 385 | So basically these bar-graphs are nice indicators to show you: 386 | 1. the autarky of your home (Home Production like Solar / Home Consumption) 387 | 2. the ratio / share of produced electricity used by the home (The Germans call it `Eigenverbrauchsanteil` 😉) 388 | 389 | ### kW and kWh is not the Same! 390 | I know... In this case usability is more important and the user has to decide if he is ok with that. 391 | 392 |
393 |
394 | 395 | 396 |
397 | 398 | **If you find a Bug or have some suggestions, let me know here!** 399 | 400 | **If you like the card, consider starring it.** 401 | -------------------------------------------------------------------------------- /src/power-distribution-card.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, html, TemplateResult, PropertyValues, CSSResultGroup } from 'lit'; 2 | 3 | import { customElement, property, state } from 'lit/decorators.js'; 4 | 5 | import { 6 | debounce, 7 | HomeAssistant, 8 | formatNumber, 9 | LovelaceCardEditor, 10 | LovelaceCard, 11 | LovelaceCardConfig, 12 | createThing, 13 | hasAction, 14 | ActionHandlerEvent, 15 | handleAction, 16 | } from 'custom-card-helpers'; 17 | 18 | import { version } from '../package.json'; 19 | 20 | import { PDCConfig, EntitySettings, ArrowStates, BarSettings } from './types'; 21 | import { DefaultItem, DefaultConfig, PresetList, PresetObject, PresetType } from './presets'; 22 | import { styles, narrow_styles } from './styles'; 23 | import { localize } from './localize/localize'; 24 | import ResizeObserver from 'resize-observer-polyfill'; 25 | import { installResizeObserver } from './util'; 26 | import { actionHandler } from './action-handler'; 27 | 28 | import './editor/editor'; 29 | 30 | console.info( 31 | `%c POWER-DISTRIBUTION-CARD %c ${version} `, 32 | `font-weight: 500; color: white; background: #03a9f4;`, 33 | `font-weight: 500; color: #03a9f4; background: white;`, 34 | ); 35 | 36 | window.customCards.push({ 37 | type: 'power-distribution-card', 38 | name: 'Power Distribution Card', 39 | description: localize('common.description'), 40 | }); 41 | 42 | @customElement('power-distribution-card') 43 | export class PowerDistributionCard extends LitElement { 44 | /** 45 | * Linking to the visual Editor Element 46 | * @returns Editor DOM Element 47 | */ 48 | public static async getConfigElement(): Promise { 49 | await import('./editor/editor'); 50 | return document.createElement('power-distribution-card-editor') as LovelaceCardEditor; 51 | } 52 | 53 | /** 54 | * Function for creating the standard power-distribution-card 55 | * @returns Example Config for this Card 56 | */ 57 | public static getStubConfig(): Record { 58 | return { 59 | title: 'Title', 60 | entities: [], 61 | center: { 62 | type: 'bars', 63 | content: [ 64 | { preset: 'autarky', name: localize('editor.settings.autarky') }, 65 | { preset: 'ratio', name: localize('editor.settings.ratio') }, 66 | ], 67 | }, 68 | }; 69 | } 70 | 71 | @property() public hass!: HomeAssistant; 72 | 73 | @state() private _config!: PDCConfig; 74 | 75 | @property() private _card!: LovelaceCard; 76 | 77 | private _resizeObserver?: ResizeObserver; 78 | @state() private _narrow = false; 79 | 80 | /** 81 | * Configuring all the passed Settings and Changing it to a more usefull Internal one. 82 | * @param config The Config Object configured via YAML 83 | */ 84 | public async setConfig(config: PDCConfig): Promise { 85 | //The Addition of the last object is needed to override the entities array for the preset settings 86 | const _config = Object.assign({}, DefaultConfig, config, { entities: [] }); 87 | 88 | //Entities Preset Object Stacking 89 | if (!config.entities) throw new Error('You need to define Entities!'); 90 | config.entities.forEach((item) => { 91 | if (item.preset && PresetList.includes(item.preset)) { 92 | const _item: EntitySettings = Object.assign({}, DefaultItem, PresetObject[item.preset], item); 93 | _config.entities.push(_item); 94 | } else { 95 | throw new Error('The preset `' + item.preset + '` is not a valid entry. Please choose a Preset from the List.'); 96 | } 97 | }); 98 | this._config = _config; 99 | 100 | //Setting up card if needed 101 | if (this._config.center.type == 'card') { 102 | this._card = this._createCardElement(this._config.center.content as LovelaceCardConfig); 103 | } 104 | } 105 | 106 | public firstUpdated(): void { 107 | const _config = this._config; 108 | //unit-of-measurement Auto Configuration from hass element 109 | _config.entities.forEach((item, index) => { 110 | if (!item.entity) return; 111 | const hass_uom = this._state({ entity: item.entity, attribute: 'unit_of_measurement' }) as string; 112 | if (!item.unit_of_measurement) this._config.entities[index].unit_of_measurement = hass_uom || 'W'; 113 | }); 114 | // Applying the same to bars 115 | if (_config.center.type == 'bars') { 116 | const content = (_config.center.content as BarSettings[]).map((item) => { 117 | let hass_uom = '%'; 118 | if (item.entity) { 119 | hass_uom = this._state({ entity: item.entity, attribute: 'unit_of_measurement' }) as string; 120 | } 121 | return { 122 | ...item, 123 | unit_of_measurement: item.unit_of_measurement || hass_uom, 124 | }; 125 | }); 126 | this._config = { 127 | ...this._config, 128 | center: { 129 | ...this._config.center, 130 | content: content, 131 | }, 132 | }; 133 | } 134 | 135 | //Resize Observer 136 | this._adjustWidth(); 137 | this._attachObserver(); 138 | //This is needed to prevent Rendering without the unit_of_measurements 139 | this.requestUpdate(); 140 | } 141 | 142 | protected updated(changedProps: PropertyValues): void { 143 | super.updated(changedProps); 144 | if (!this._card || (!changedProps.has('hass') && !changedProps.has('editMode'))) { 145 | return; 146 | } 147 | if (this.hass) { 148 | this._card.hass = this.hass; 149 | } 150 | } 151 | 152 | public static get styles(): CSSResultGroup { 153 | return styles; 154 | } 155 | 156 | public connectedCallback(): void { 157 | super.connectedCallback(); 158 | this.updateComplete.then(() => this._attachObserver()); 159 | } 160 | 161 | public disconnectedCallback(): void { 162 | if (this._resizeObserver) { 163 | this._resizeObserver.disconnect(); 164 | } 165 | } 166 | 167 | private async _attachObserver(): Promise { 168 | if (!this._resizeObserver) { 169 | await installResizeObserver(); 170 | this._resizeObserver = new ResizeObserver(debounce(() => this._adjustWidth(), 250, false)); 171 | } 172 | const card = this.shadowRoot?.querySelector('ha-card'); 173 | // If we show an error or warning there is no ha-card 174 | if (!card) return; 175 | this._resizeObserver.observe(card); 176 | } 177 | 178 | private _adjustWidth(): void { 179 | const card = this.shadowRoot?.querySelector('ha-card'); 180 | if (!card) return; 181 | this._narrow = card.offsetWidth < 400; 182 | } 183 | 184 | /** 185 | * Retrieving the sensor value of hass for a Item as a number 186 | * @param item a Settings object 187 | * @returns The current value from Homeassistant in Watts 188 | */ 189 | private _val(item: EntitySettings | BarSettings): number { 190 | let modifier = item.invert_value ? -1 : 1; 191 | //Proper K Scaling e.g. 1kW = 1000W 192 | if (item.unit_of_measurement?.charAt(0) == 'k') modifier *= 1000; 193 | // If an entity exists, check if the attribute setting is entered -> value from attribute else value from entity 194 | let num = this._state(item as EntitySettings) as number; 195 | //Applying Threshold 196 | const threshold = (item as EntitySettings).threshold || null; 197 | num = threshold ? (Math.abs(num) < threshold ? 0 : num) : num; 198 | return num * modifier; 199 | } 200 | 201 | /** 202 | * Retrieving the raw state of an sensor/attribute 203 | * @param item A Settings object 204 | * @returns entitys/attributes state 205 | */ 206 | private _state(item: EntitySettings): unknown { 207 | return item.entity && this.hass.states[item.entity] 208 | ? item.attribute 209 | ? this.hass.states[item.entity].attributes[item.attribute] 210 | : this.hass.states[item.entity].state 211 | : null; 212 | } 213 | 214 | /** 215 | * This is the main rendering function for this card 216 | * @returns html for the power-distribution-card 217 | */ 218 | protected render(): TemplateResult { 219 | const left_panel: TemplateResult[] = []; 220 | const center_panel: (TemplateResult | LovelaceCard)[] = []; 221 | const right_panel: TemplateResult[] = []; 222 | 223 | let consumption = 0; 224 | let production = 0; 225 | 226 | this._config.entities.forEach((item, index) => { 227 | const value = this._val(item); 228 | 229 | if (!item.calc_excluded) { 230 | if (item.producer && value > 0) { 231 | production += value; 232 | } 233 | if (item.consumer && value < 0) { 234 | consumption -= value; 235 | } 236 | } 237 | 238 | const _item = this._render_item(value, item, index); 239 | //Sorting the Items to either side 240 | if (index % 2 == 0) left_panel.push(_item); 241 | else right_panel.push(_item); 242 | }); 243 | 244 | //Populating the Center Panel 245 | const center = this._config.center; 246 | switch (center.type) { 247 | case 'none': 248 | break; 249 | case 'card': 250 | if (this._card) center_panel.push(this._card); 251 | else console.warn('NO CARD'); 252 | break; 253 | case 'bars': 254 | center_panel.push(this._render_bars(consumption, production)); 255 | break; 256 | } 257 | 258 | return html` ${this._narrow ? narrow_styles : undefined} 259 | 260 |
261 |
${left_panel}
262 |
${center_panel}
263 |
${right_panel}
264 |
265 |
`; 266 | } 267 | 268 | private _handleAction(ev: ActionHandlerEvent): void { 269 | if (this.hass && this._config && ev.detail.action) { 270 | handleAction( 271 | this, 272 | this.hass, 273 | { 274 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 275 | entity: (ev.currentTarget as any).entity, 276 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 277 | tap_action: (ev.currentTarget as any).tap_action, 278 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 279 | double_tap_action: (ev.currentTarget as any).double_tap_action, 280 | }, 281 | ev.detail.action, 282 | ); 283 | } 284 | } 285 | 286 | /** 287 | * Creating a Item Element 288 | * @param value The Value of the Sensor 289 | * @param item The EntitySettings Object of the Item 290 | * @param index The index of the Item. This is needed for the Arrow Directions. 291 | * @returns Html for a single Item 292 | */ 293 | private _render_item(value: number, item: EntitySettings, index: number): TemplateResult { 294 | //Placeholder item 295 | if (!item.entity) { 296 | return html``; 297 | } 298 | let math_value = value; 299 | //Unit-Of-Display and Unit_of_measurement 300 | let unit_of_display = item.unit_of_display || 'W'; 301 | const uod_split = unit_of_display.charAt(0); 302 | if (uod_split[0] == 'k') { 303 | math_value /= 1000; 304 | } else if (item.unit_of_display == 'adaptive') { 305 | //Using the uom suffix enables to adapt the initial unit to the automatic scaling naming 306 | let uom_suffix = 'W'; 307 | if (item.unit_of_measurement) { 308 | uom_suffix = 309 | item.unit_of_measurement[0] == 'k' ? item.unit_of_measurement.substring(1) : item.unit_of_measurement; 310 | } 311 | if (Math.abs(math_value) > 999) { 312 | math_value /= 1000; 313 | unit_of_display = 'k' + uom_suffix; 314 | } else { 315 | unit_of_display = uom_suffix; 316 | } 317 | } 318 | 319 | //Decimal Precision 320 | const decFakTen = 10 ** (item.decimals || item.decimals == 0 ? item.decimals : 2); 321 | math_value = Math.round(math_value * decFakTen) / decFakTen; 322 | // Arrow directions 323 | const state = item.invert_arrow ? math_value * -1 : math_value; 324 | //Toggle Absolute Values 325 | math_value = item.display_abs ? Math.abs(math_value) : math_value; 326 | //Format Number 327 | const formatValue = formatNumber(math_value, this.hass.locale); 328 | 329 | // Secondary info 330 | let secondary_info: string | undefined; 331 | if (item.secondary_info_entity) { 332 | if (item.secondary_info_attribute) { 333 | secondary_info = 334 | this._state({ entity: item.secondary_info_entity, attribute: item.secondary_info_attribute }) + ''; 335 | } else { 336 | secondary_info = `${this._state({ entity: item.secondary_info_entity })}${ 337 | this._state({ entity: item.secondary_info_entity, attribute: 'unit_of_measurement' }) || '' 338 | }`; 339 | } 340 | } 341 | // Secondary info replace name 342 | if (item.secondary_info_replace_name) { 343 | item.name = secondary_info; 344 | secondary_info = undefined; 345 | } 346 | 347 | //Preset Features 348 | // 1. Battery Icon 349 | let icon = item.icon; 350 | if (item.preset === 'battery' && item.battery_percentage_entity) { 351 | const bat_val = this._val({ entity: item.battery_percentage_entity }); 352 | if (!isNaN(bat_val)) { 353 | icon = 'mdi:battery'; 354 | // mdi:battery-100 and -0 don't exist thats why we have to handle it seperately 355 | if (bat_val < 5) { 356 | icon = 'mdi:battery-outline'; 357 | } else if (bat_val < 95) { 358 | icon = 'mdi:battery-' + (bat_val / 10).toFixed(0) + '0'; 359 | } 360 | } 361 | } 362 | // 2. Grid Buy-Sell 363 | let nameReplaceFlag = false; 364 | let grid_buy_sell = html``; 365 | if (item.preset === 'grid' && (item.grid_buy_entity || item.grid_sell_entity)) { 366 | nameReplaceFlag = true; 367 | grid_buy_sell = html` 368 |
369 | ${item.grid_buy_entity 370 | ? html`
371 | B: 372 | ${this._val({ entity: item.grid_buy_entity })}${this._state({ 373 | entity: item.grid_buy_entity, 374 | attribute: 'unit_of_measurement', 375 | }) || undefined} 376 |
` 377 | : undefined} 378 | ${item.grid_sell_entity 379 | ? html`
380 | S: 381 | ${this._val({ entity: item.grid_sell_entity })}${this._state({ 382 | entity: item.grid_sell_entity, 383 | attribute: 'unit_of_measurement', 384 | }) || undefined} 385 |
` 386 | : undefined} 387 |
388 | `; 389 | } 390 | 391 | // COLOR CHANGE 392 | const ct = item.color_threshold || 0; 393 | // Icon color dependant on state 394 | let icon_color: string | undefined; 395 | if (item.icon_color) { 396 | if (state > ct) icon_color = item.icon_color.bigger; 397 | if (state < ct) icon_color = item.icon_color.smaller; 398 | if (state == ct) icon_color = item.icon_color.equal; 399 | } 400 | // Arrow color 401 | let arrow_color: string | undefined; 402 | if (item.arrow_color) { 403 | if (state > ct) arrow_color = item.arrow_color.bigger; 404 | if (state < ct) arrow_color = item.arrow_color.smaller; 405 | if (state == ct) arrow_color = item.arrow_color.equal; 406 | } 407 | 408 | //NaNFlag for Offline Sensors for example 409 | const NanFlag = isNaN(math_value); 410 | 411 | return html` 412 | 421 | 422 | 423 | 424 | ${secondary_info ? html`

${secondary_info}

` : null} 425 |
426 | ${nameReplaceFlag ? grid_buy_sell : html`

${item.name}

`} 427 |
428 | 429 |

${NanFlag ? `` : formatValue} ${NanFlag ? `` : unit_of_display}

430 | ${ 431 | !item.hide_arrows 432 | ? this._render_arrow( 433 | //This takes the side the item is on (index even = left) into account for the arrows 434 | value == 0 || NanFlag 435 | ? 'none' 436 | : index % 2 == 0 437 | ? state > 0 438 | ? 'right' 439 | : 'left' 440 | : state > 0 441 | ? 'left' 442 | : 'right', 443 | arrow_color, 444 | ) 445 | : html`` 446 | } 447 | 449 | `; 450 | } 451 | 452 | /** 453 | * Render function for Generating Arrows (CSS Only) 454 | * @param direction One of three Options: none, right, left 455 | * @param index To detect which side the item is on and adapt the direction accordingly 456 | */ 457 | private _render_arrow(direction: ArrowStates, color?: string): TemplateResult { 458 | const a = this._config.animation; 459 | if (direction == 'none') { 460 | return html`
`; 461 | } else { 462 | return html` 463 |
464 |
465 |
466 |
467 |
468 |
469 | `; 470 | } 471 | } 472 | 473 | /** 474 | * Render Support Function Calculating and Generating the Autarky and Ratio Bars 475 | * @param consumption the total home consumption 476 | * @param production the total home production 477 | * @returns html containing the bars as Template Results 478 | */ 479 | private _render_bars(consumption: number, production: number): TemplateResult { 480 | const bars: TemplateResult[] = []; 481 | if (!this._config.center.content || (this._config.center.content as BarSettings[]).length == 0) return html``; 482 | (this._config.center.content as BarSettings[]).forEach((element) => { 483 | let value = -1; 484 | 485 | switch (element.preset) { 486 | case 'autarky': //Autarky in Percent = Home Production(Solar, Battery)*100 / Home Consumption 487 | if (!element.entity) 488 | value = consumption != 0 ? Math.min(Math.round((production * 100) / Math.abs(consumption)), 100) : 0; 489 | break; 490 | case 'ratio': //Ratio in Percent = Home Consumption / Home Production(Solar, Battery)*100 491 | if (!element.entity) 492 | value = production != 0 ? Math.min(Math.round((Math.abs(consumption) * 100) / production), 100) : 0; 493 | break; 494 | } 495 | if (value < 0) value = Math.min(parseInt(this._val(element).toFixed(0), 10), 100); 496 | bars.push(html` 497 |
508 |

${value}${element.unit_of_measurement || '%'}

509 |
510 | 511 |
512 |

${element.name || ''}

513 |
514 | `); 515 | }); 516 | return html`${bars.map((e) => html`${e}`)}`; 517 | } 518 | 519 | private _createCardElement(cardConfig: LovelaceCardConfig) { 520 | const element = createThing(cardConfig) as LovelaceCard; 521 | if (this.hass) { 522 | element.hass = this.hass; 523 | } 524 | element.addEventListener( 525 | 'll-rebuild', 526 | (ev) => { 527 | ev.stopPropagation(); 528 | this._rebuildCard(element, cardConfig); 529 | }, 530 | { once: true }, 531 | ); 532 | return element; 533 | } 534 | 535 | private _rebuildCard(cardElToReplace: LovelaceCard, config: LovelaceCardConfig): void { 536 | const newCardEl = this._createCardElement(config); 537 | if (cardElToReplace.parentElement) { 538 | cardElToReplace.parentElement.replaceChild(newCardEl, cardElToReplace); 539 | } 540 | if (this._card === cardElToReplace) this._card = newCardEl; 541 | } 542 | } 543 | --------------------------------------------------------------------------------