Repository: JonahKr/power-distribution-card Branch: master Commit: a0c826b085c1 Files: 53 Total size: 151.4 KB Directory structure: gitextract_bav71kz2/ ├── .github/ │ └── workflows/ │ ├── build.yaml │ └── release.yaml ├── .gitignore ├── .prettierrc.js ├── .vscode/ │ └── settings.json ├── LICENSE ├── README.md ├── eslint.config.mjs ├── hacs.json ├── package.json ├── rollup.config.mjs ├── src/ │ ├── action-handler.ts │ ├── card-tags.ts │ ├── deep-equal.ts │ ├── editor/ │ │ ├── bar-editor.ts │ │ ├── editor.ts │ │ ├── ha-form.ts │ │ ├── item-editor.ts │ │ └── items-editor.ts │ ├── localize/ │ │ ├── languages/ │ │ │ ├── de.json │ │ │ ├── en.json │ │ │ └── sk.json │ │ └── localize.ts │ ├── power-distribution-card.ts │ ├── presets.ts │ ├── styles.ts │ ├── types.ts │ └── utils/ │ ├── compute-color.ts │ ├── create-thing.ts │ ├── custom-cards.ts │ ├── debounce.ts │ ├── get-lovelace.ts │ ├── ha-component-loader.ts │ ├── hass-types/ │ │ ├── action.ts │ │ ├── action_handler.ts │ │ ├── event.ts │ │ ├── fire_event.ts │ │ ├── format-number.ts │ │ ├── get_main_window.ts │ │ ├── handle-action.ts │ │ ├── haptics.ts │ │ ├── has-action.ts │ │ ├── homeassistant.ts │ │ ├── integration.ts │ │ ├── localize.ts │ │ ├── lovelace.ts │ │ ├── navigate.ts │ │ ├── show-dialog-box.ts │ │ ├── show-ha-voice-command-dialog.ts │ │ ├── toast.ts │ │ └── toggle-entity.ts │ └── index.ts └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/build.yaml ================================================ name: "Build" on: push: branches: - master pull_request: branches: - master jobs: build: name: Test build runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: pnpm/action-setup@v4 with: version: latest - name: Build run: | pnpm install pnpm run build ================================================ FILE: .github/workflows/release.yaml ================================================ name: Release on: release: types: [published] jobs: release: name: Prepare release runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: pnpm/action-setup@v4 with: version: latest #Building the Js-File - name: Build the file run: | pnpm install pnpm run build # Upload build file to the releas as an asset. - name: Upload zip to release uses: svenstaro/upload-release-action@v1-release with: repo_token: ${{ secrets.GITHUB_TOKEN }} file: /home/runner/work/power-distribution-card/power-distribution-card/dist/power-distribution-card.js asset_name: power-distribution-card.js tag: ${{ github.ref }} overwrite: true ================================================ FILE: .gitignore ================================================ # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* lerna-debug.log* # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json # Runtime data pids *.pid *.seed *.pid.lock # Directory for instrumented libs generated by jscoverage/JSCover lib-cov # Coverage directory used by tools like istanbul coverage *.lcov # nyc test coverage .nyc_output # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) .grunt # Bower dependency directory (https://bower.io/) bower_components # node-waf configuration .lock-wscript # Compiled binary addons (https://nodejs.org/api/addons.html) build/Release # Dependency directories node_modules/ jspm_packages/ # Snowpack dependency directory (https://snowpack.dev/) web_modules/ # TypeScript cache *.tsbuildinfo # Optional npm cache directory .npm # Optional eslint cache .eslintcache # Microbundle cache .rpt2_cache/ .rts2_cache_cjs/ .rts2_cache_es/ .rts2_cache_umd/ # Optional REPL history .node_repl_history # Output of 'npm pack' *.tgz # Yarn Integrity file .yarn-integrity # dotenv environment variables file .env .env.test # parcel-bundler cache (https://parceljs.org/) .cache .parcel-cache # Next.js build output .next out # Nuxt.js build / generate output .nuxt dist # Gatsby files .cache/ # Comment in the public line in if your project uses Gatsby and not Next.js # https://nextjs.org/blog/next-9-1#public-directory-support # public # vuepress build output .vuepress/dist # Serverless directories .serverless/ # FuseBox cache .fusebox/ # DynamoDB Local files .dynamodb/ # TernJS port file .tern-port # Stores VSCode versions used for testing VSCode extensions .vscode-test # yarn v2 .yarn/cache .yarn/unplugged .yarn/build-state.yml .yarn/install-state.gz .pnp.* ================================================ FILE: .prettierrc.js ================================================ module.exports = { semi: true, trailingComma: 'all', singleQuote: true, printWidth: 120, tabWidth: 2, endOfLine: 'auto', }; ================================================ FILE: .vscode/settings.json ================================================ { "typescript.tsdk": "node_modules/typescript/lib" } ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2025 JonahKr Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # power-distribution-card [![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) [![Actions Status](https://github.com/JonahKr/power-distribution-card/workflows/Build/badge.svg)](https://github.com/JonahKr/power-distribution-card/actions) [![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) [![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg)](https://github.com/custom-components/hacs) Buy Me A Coffee
Inspired by e3dc-logo

The Lovelace Card for visualizing power distributions.


Table of Contents




Installation

Installation via HACS

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

Manual installation

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) 2. Place the file in your `config/www` folder 3. Include the card code in your `ui-lovelace-card.yaml` ```yaml resources: - url: /local/power-distribution-card.js type: module ``` Or alternatively set it up via the UI: `Configuration -> Lovelace Dashboards -> Resources (TAB)` For more guidance check out the [docs](https://developers.home-assistant.io/docs/frontend/custom-ui/registering-resources/).
***

Configuration

Presets

Every Sensor you want to add has to use one of the Presets. You can add as many of these as you want.
mdi-battery-outline mdi-electirc-car mdi-lightbulb mdi-transmission-tower mdi-home-assistant mdi-hydro-power mdi-pool mdi-lightning-bolt-outline mdi-solar-power mdi-wind-turbine mdi-radiator
battery car_charger consumer grid home hydro pool producer solar wind heating
Any Home Battery e.g. E3dc, Powerwall Any Electric Car Charger A custom home power consumer The interface to the power grid Your Home's power consumption Hydropower setup like Turbulent pool heater or pump custom home power producer Power coming from Solar Power coming from Wind Radiators
The presets *consumer* and *producer* enable to add any custom device into your Card with just a bit of tweaking.

## Simple Configuration 🛠️ With Version 2.0 a Visual Editor got introduced. You can find the Card in your Card Selector probably at the bottom. From there on you can configure your way to your custom Card. The easiest way to get your Card up and running, is by defining the entities for the presets directly.


```diff ! Please Check for every Sensor: positive sensor values = production, negative values = consumption ! If this is the other way around in your Case, check the `invert_value` setting (Advanced Configuration)! ```

### Placeholder 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. Alternatively you can use the provided `placeholder` preset.



## YAML Only If you are a real hardcore YAML connoisseur here is a basic example to get things started: ```yaml type: 'custom:power-distribution-card' title: Title animation: flash entities: - entity: sensor.e3dc_home preset: home - entity: sensor.e3dc_solar preset: solar - entity: sensor.e3dc_battery preset: battery center: type: bars bars: - preset: autarky name: autarky - preset: ratio name: ratio ``` You can find all options for every entity here. If you want to further modify the center panel youz can find the documentation here.


## Animation For the animation you have 3 options: `flash`, `slide`, `none` ```yaml type: 'custom:power-distribution-card' animation: 'slide' ```

## Center Panel For customizing the Center Panel you basically have 3 Options: ### None 🕳️ the *void*
### Bars 📊 Bars have the following Settings: | Setting | type | example | description | | --------------------- |:-------------:|:-----------------:| :------------| | `bar_color` | string | red, #C1C1C1 | You can pass any string that CSS will accept as a color. | | `bar_bg_color` | string | red, #C1C1C1 | The Background Color of the Bar. You can pass any string that CSS will accept as a color. | | `entity` | string | sensor.ln_autarky | You can specify the entity_id here as well. Required when `preset` is not `autarky` or `ratio`. | | `invert_value` | bool | false | This will invert the value received from HASS. | | `lower_bound` | number | 0 | Lower bound for bar fill scaling (default: 0). Values at or below this show an empty bar. | | `name` | string | Eigenstrom | Feel free to change the displayed name of the element. | | `preset` | `'autarky'` \| `'ratio'` \| `''` | `autarky` | `autarky`/`ratio` auto-calculate from entity totals. Use `''` (empty string) with an `entity` for a custom bar. | | `tap_action` | Action Config | [Configuration](https://www.home-assistant.io/lovelace/actions/#configuration-variables) | Single tap action for item. | | `double_tap_action` | Action Config | [Configuration](https://www.home-assistant.io/lovelace/actions/#configuration-variables) | Double tap action for item. | | `unit_of_measurement` | string | *W* , *kW* | Default: %; The Unit of the sensor value. **Should be detected automatically!** | | `upper_bound` | number | 100 | Upper bound for bar fill scaling (default: 100). Values at or above this show a full bar. |


### Cards 🃏

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. 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. For example you could insert a glance card: ```yaml center: type: card card: type: glance entities: - sensor.any_Sensor ```


## Entity Configuration ⚙️ There are alot of settings you can customize your sensors with: | Setting | type | example | description | | -------------------------- |:-------------:|:----------------------------:| :------------| | `attribute` | string | deferredWatts | A Sensor can have multiple attributes. If one of them is your desired value to display, add it here. | | `arrow_color` | object | {smaller:'red'} | You can Change the Color of the arrow dependant on the value. (Bigger, Equal and Smaller) | | `calc_excluded` | boolean | true | If the Item should be excluded from ratio/autarky calculations. | | `color_threshold` | number | 0, -100, 420.69 | The value at which the coloring logic switches on. (default: 0) | | `consumer` | boolean | true | Marks this entity as a power consumer. Negative values contribute to the consumption total used by autarky/ratio bars. | | `decimals` | number | 0, 2 | The Number of Decimals shown. (default: 2) | | `display_abs` | boolean | true | Display values as absolute (non-negative) numbers. Defaults to `true` when a preset is used. | | `double_tap_action` | Action Config | [Configuration](https://www.home-assistant.io/lovelace/actions/#configuration-variables) | Double tap action for item. | | `entity` | string | sensor.e3dc_grid | You can specify the entity_id here as well. | | `hide_arrows` | bool | true | Toggles the visibility of the *arrows*. | | `icon` | string | mdi:dishwasher | Why not change the displayed Icon to any [MDI](https://pictogrammers.com/library/mdi/) one? | | `icon_color` | object | {smaller:'red'} | You can Change the Color of the icon dependant on the value. (Bigger, Equal and Smaller) | | `invert_arrow` | bool | true | This will change the *arrows* direction to the opposite one. | | `invert_value` | bool | false | This will invert the value received from HASS. This affects calculations as well! | | `name` | string | dishwasher | Feel free to change the displayed name of the element. | | `producer` | boolean | true | Marks this entity as a power producer. Positive values contribute to the production total used by autarky/ratio bars. | | `secondary_info_attribute` | string | min_temp | Requires `secondary_info_entity`. Displays the attribute value instead of the sensor state. | | `secondary_info_decimals` | number | 1 | Number of decimals for the secondary info value. | | `secondary_info_entity` | string | sensor.e3dc_grid | entity_id of the secondary info sensor. | | `secondary_info_replace_name` | bool | true | This will replace the name of the item with the secondary info. | | `tap_action` | Action Config | [Configuration](https://www.home-assistant.io/lovelace/actions/#configuration-variables) | Single tap action for item. | | `threshold` | number | 2 | Ignoring all absolute values smaller than threshold. | | `unit_of_display` | string | *W* , *kW* , *adaptive* | The Unit the value is displayed in (default: W). Adaptive will show kW for values >= 1kW. | | `unit_of_measurement` | string | *W* , *kW* | The Unit of the sensor value. **Should be detected automatically!** |

This could look something like: ```yaml entities: - decimals: 2 display_abs: true name: battery unit_of_display: W consumer: true icon: 'mdi:battery-outline' producer: true entity: sensor.e3dc_battery preset: battery icon_color: bigger: 'green' equal: '' smaller: 'red' ```



## Preset features The Presets `battery` and `grid` have some additional features which allow some further customization. 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. If one of those presets is selected there will be additional options in the visual editor. If you prefer yaml, here are all extra options which can be set per item: | Setting | type | example | description | | --------------------------- |:-------------:|:----------------------------:| :------------| | `battery_percentage_entity` | string | sensor.xyz | Sensor containing the battery charge percentage from 0 to 100 | | `grid_buy_entity` | string | sensor.xyz | Sensor containing the imported power from the grid | | `grid_sell_entity` | string | sensor.xyz | Sensor containing the sold power towards the grid |

FAQs ❓

### What the heck are these autarky and ratio calculating? So basically these bar-graphs are nice indicators to show you: 1. the autarky of your home (Home Production like Solar / Home Consumption) 2. the ratio / share of produced electricity used by the home (The Germans call it `Eigenverbrauchsanteil` 😉) ### kW and kWh is not the Same! I know... In this case usability is more important and the user has to decide if he is ok with that.

**If you find a Bug or have some suggestions, let me know here!** **If you like the card, consider starring it.** ================================================ FILE: eslint.config.mjs ================================================ import { defineConfig } from "eslint/config"; import tsParser from "@typescript-eslint/parser"; import path from "node:path"; import { fileURLToPath } from "node:url"; import js from "@eslint/js"; import { FlatCompat } from "@eslint/eslintrc"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const compat = new FlatCompat({ baseDirectory: __dirname, recommendedConfig: js.configs.recommended, allConfig: js.configs.all }); export default defineConfig([{ extends: compat.extends( "plugin:@typescript-eslint/recommended", "prettier", "plugin:prettier/recommended", ), languageOptions: { parser: tsParser, ecmaVersion: 2018, sourceType: "module", parserOptions: { experimentalDecorators: true, }, }, rules: { "@typescript-eslint/camelcase": 0, }, }]); ================================================ FILE: hacs.json ================================================ { "name": "Power Distribution Card", "filename": "power-distribution-card.js", "render_readme": true, "homeassistant": "2026.3" } ================================================ FILE: package.json ================================================ { "name": "power-distribution-card", "version": "3.0.0", "license": "MIT", "author": "JonahKr", "description": "A Lovelace Card for visualizing power distributions.", "keywords": [ "power", "distribution", "lovelace", "hacs", "home assistant", "e3dc" ], "module": "power-distribution-card.js", "repository": { "type": "git", "url": "git+https://github.com/JonahKr/power-distribution-card.git" }, "bugs": { "url": "https://github.com/JonahKr/power-distribution-card/issues" }, "homepage": "https://github.com/JonahKr/power-distribution-card#readme", "scripts": { "start": "BUILD_DEV=true rollup -c --watch", "serve": "http-server dist/ --cors -p 5000", "build": "pnpm run rollup", "lint": "eslint src/*.ts", "rollup": "rollup -c" }, "dependencies": { "@formatjs/intl-numberformat": "^9.3.1", "@mdi/js": "^7.4.47", "lit": "3.3.2" }, "devDependencies": { "@babel/core": "7.29.0", "@babel/plugin-proposal-class-properties": "7.18.6", "@babel/plugin-proposal-decorators": "7.29.0", "@rollup/plugin-babel": "7.0.0", "@rollup/plugin-commonjs": "29.0.2", "@rollup/plugin-json": "6.1.0", "@rollup/plugin-node-resolve": "16.0.3", "@rollup/plugin-replace": "^6.0.3", "@rollup/plugin-terser": "^1.0.0", "@rollup/plugin-typescript": "^12.3.0", "@typescript-eslint/eslint-plugin": "8.57.2", "@typescript-eslint/parser": "8.57.2", "eslint": "10.1.0", "eslint-config-airbnb-base": "15.0.0", "eslint-config-prettier": "10.1.8", "eslint-plugin-import": "2.32.0", "eslint-plugin-prettier": "5.5.5", "http-server": "^14.1.1", "prettier": "3.8.1", "rollup": "4.60.0", "typescript": "6.0.2" } } ================================================ FILE: rollup.config.mjs ================================================ import typescript from '@rollup/plugin-typescript'; import nodeResolve from '@rollup/plugin-node-resolve'; import babel from '@rollup/plugin-babel'; import terser from '@rollup/plugin-terser'; import json from '@rollup/plugin-json'; import replace from '@rollup/plugin-replace'; const dev = process.env.BUILD_DEV === 'true'; const devSuffix = dev ? '-dev' : ''; const plugins = [ replace({ __DEV_SUFFIX__: JSON.stringify(devSuffix), preventAssignment: true, }), nodeResolve({}), typescript(), json(), babel({ exclude: 'node_modules/**', babelHelpers: 'bundled', }), terser(), ]; export default [ { input: 'src/power-distribution-card.ts', output: { file: `dist/power-distribution-card${devSuffix}.js`, format: 'es', }, plugins: [...plugins], }, ]; ================================================ FILE: src/action-handler.ts ================================================ import { noChange } from 'lit'; import { AttributePart, directive, Directive, DirectiveParameters } from 'lit/directive.js'; import { deepEqual } from './deep-equal'; import { ACTION_HANDLER_TAG } from './card-tags'; import { fireEvent } from './utils'; export const actions = ['more-info', 'toggle', 'navigate', 'url', 'call-service', 'none'] as const; interface ActionHandlerMock extends HTMLElement { holdTime: number; bind(element: Element, options?: ActionHandlerOptions): void; } interface ActionHandlerElement extends HTMLElement { actionHandler?: { options: ActionHandlerOptions; start?: (ev: Event) => void; end?: (ev: Event) => void; handleEnter?: (ev: KeyboardEvent) => void; }; } export interface ActionHandlerOptions { hasHold?: boolean; hasDoubleClick?: boolean; disabled?: boolean; } class ActionHandler extends HTMLElement implements ActionHandlerMock { public holdTime = 500; protected timer?: number; private dblClickTimeout?: number; public bind(element: ActionHandlerElement, options: ActionHandlerOptions = {}) { if (element.actionHandler && deepEqual(options, element.actionHandler.options)) { return; } if (element.actionHandler) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion element.removeEventListener('click', element.actionHandler.end!); } element.actionHandler = { options }; if (options.disabled) { return; } element.actionHandler.end = (ev: Event): void => { const target = element; //ev.target as HTMLElement; // Prevent mouse event if touch event if (ev.cancelable) { ev.preventDefault(); } clearTimeout(this.timer); this.timer = undefined; if (options.hasDoubleClick) { if ((ev.type === 'click' && (ev as MouseEvent).detail < 2) || !this.dblClickTimeout) { this.dblClickTimeout = window.setTimeout(() => { this.dblClickTimeout = undefined; fireEvent(target, 'action', { action: 'tap' }); }, 250); } else { clearTimeout(this.dblClickTimeout); this.dblClickTimeout = undefined; fireEvent(target, 'action', { action: 'double_tap' }); } } else { fireEvent(target, 'action', { action: 'tap' }); } }; element.addEventListener('click', element.actionHandler.end); } } customElements.define(ACTION_HANDLER_TAG, ActionHandler); const getActionHandler = (): ActionHandler => { const body = document.body; if (body.querySelector(ACTION_HANDLER_TAG)) { return body.querySelector(ACTION_HANDLER_TAG) as ActionHandler; } const actionhandler = document.createElement(ACTION_HANDLER_TAG); body.appendChild(actionhandler); return actionhandler as ActionHandler; }; export const actionHandlerBind = (element: ActionHandlerElement, options?: ActionHandlerOptions): void => { const actionhandler: ActionHandler = getActionHandler(); if (!actionhandler) { return; } actionhandler.bind(element, options); }; export const actionHandler = directive( class extends Directive { update(part: AttributePart, [options]: DirectiveParameters) { actionHandlerBind(part.element as ActionHandlerElement, options); return noChange; } // eslint-disable-next-line @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars render(_options?: ActionHandlerOptions) {} }, ); ================================================ FILE: src/card-tags.ts ================================================ // Build-time constant injected by rollup: '-dev' in watch mode, '' in production. // Never edit this value manually — change the rollup config or npm scripts instead. declare const __DEV_SUFFIX__: string; export const CARD_TAG = `power-distribution-card${__DEV_SUFFIX__}`; export const EDITOR_TAG = `${CARD_TAG}-editor`; export const ITEM_EDITOR_TAG = `${CARD_TAG}-item-editor`; export const BAR_EDITOR_TAG = `${CARD_TAG}-bar-editor`; export const ITEMS_EDITOR_TAG = `${CARD_TAG}-items-editor`; export const ACTION_HANDLER_TAG = `action-handler${__DEV_SUFFIX__}-power-distribution-card`; ================================================ FILE: src/deep-equal.ts ================================================ // From https://github.com/epoberezkin/fast-deep-equal // MIT License - Copyright (c) 2017 Evgeny Poberezkin // eslint-disable-next-line @typescript-eslint/no-explicit-any export const deepEqual = (a: any, b: any): boolean => { if (a === b) { return true; } if (a && b && typeof a === 'object' && typeof b === 'object') { if (a.constructor !== b.constructor) { return false; } // eslint-disable-next-line @typescript-eslint/no-explicit-any let i: number | [any, any]; let length: number; if (Array.isArray(a)) { length = a.length; if (length !== b.length) { return false; } for (i = length; i-- !== 0; ) { if (!deepEqual(a[i], b[i])) { return false; } } return true; } if (a instanceof Map && b instanceof Map) { if (a.size !== b.size) { return false; } for (i of a.entries()) { if (!b.has(i[0])) { return false; } } for (i of a.entries()) { if (!deepEqual(i[1], b.get(i[0]))) { return false; } } return true; } if (a instanceof Set && b instanceof Set) { if (a.size !== b.size) { return false; } for (i of a.entries()) { if (!b.has(i[0])) { return false; } } return true; } if (ArrayBuffer.isView(a) && ArrayBuffer.isView(b)) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore length = a.length; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore if (length !== b.length) { return false; } for (i = length; i-- !== 0; ) { if (a[i] !== b[i]) { return false; } } return true; } if (a.constructor === RegExp) { return a.source === b.source && a.flags === b.flags; } if (a.valueOf !== Object.prototype.valueOf) { return a.valueOf() === b.valueOf(); } if (a.toString !== Object.prototype.toString) { return a.toString() === b.toString(); } const keys = Object.keys(a); length = keys.length; if (length !== Object.keys(b).length) { return false; } for (i = length; i-- !== 0; ) { if (!Object.prototype.hasOwnProperty.call(b, keys[i])) { return false; } } for (i = length; i-- !== 0; ) { const key = keys[i]; if (!deepEqual(a[key], b[key])) { return false; } } return true; } // true if both NaN, false otherwise // eslint-disable-next-line no-self-compare return a !== a && b !== b; }; ================================================ FILE: src/editor/bar-editor.ts ================================================ import { LitElement, html, css, nothing, CSSResultGroup } from 'lit'; import { property, state } from 'lit/decorators.js'; import { BAR_EDITOR_TAG } from '../card-tags'; import { BarSettings } from '../types'; import { localize } from '../localize/localize'; import { HaFormSchema } from './ha-form'; import { mdiDelete, mdiPlus } from '@mdi/js'; import { deepEqual } from '../deep-equal'; import { fireCustomEvent, HomeAssistant } from '../utils'; const BAR_PRESETS = ['autarky', 'ratio', '']; const SCHEMA: HaFormSchema[] = [ { name: "entity", selector: { entity: {} } }, { type: "grid", name: "", schema: [ { name: "name", selector: { text: {} } }, { name: "preset", selector: { select: { options: BAR_PRESETS, mode: 'dropdown' } } }, ] }, { type: "grid", name: "", schema: [ { name: "lower_bound", selector: { number: {} } }, { name: "upper_bound", selector: { number: {} } }, ] }, { type: "grid", name: "", schema: [ { name: "bar_color", selector: { ui_color: {} } }, { name: "bar_bg_color", selector: { ui_color: {} } }, ] }, { name: "tap_action", selector: { ui_action: {} }, }, { name: "double_tap_action", selector: { ui_action: {} }, } ]; export class ItemEditor extends LitElement { @property({ attribute: false }) hass?: HomeAssistant; @property({ attribute: false }) config?: BarSettings[]; @state() protected _selectedCard = 0; private _computeLabel = (schema: HaFormSchema) => { const nameMap: Record = { bar_color: 'color', bar_bg_color: 'background_color', }; const name = nameMap[schema.name] ?? schema.name; return localize('editor.settings.' + name); }; protected render() { if (!this.hass) { return nothing; } const config = this.config ?? []; const selected = this._selectedCard; const numBars = config.length; return html`
${config.map( (_card, i) => html` ${i + 1}` )}
${numBars > 0 ? html`
` : nothing} `; } protected valueChanged(ev: CustomEvent<{ value: BarSettings }>) { ev.stopPropagation(); if (!this.config || !this.hass) { return; } // Check if value has changed if (deepEqual(this.config[this._selectedCard], ev.detail.value)) return; // Replace value for current index in readonly config this.config = this.config!.map((item, index) => index === this._selectedCard ? ev.detail.value : item); fireCustomEvent(this, "config-changed", this.config) } protected _addBar() { if (!this.config) { this.config = [{}]; } else { this.config = [...this.config, {}]; } this._selectedCard = this.config.length - 1; fireCustomEvent(this, "config-changed", this.config); } protected _selectBar(ev: CustomEvent<{ name: string }>) { this._selectedCard = parseInt(ev.detail.name, 10); } protected _moveRight() { if (!this.config || this._selectedCard >= this.config.length - 1) return; const newConfig = this.config.slice(); const movedElement = newConfig.splice(this._selectedCard, 1)[0]; newConfig.splice(this._selectedCard + 1, 0, movedElement); this.config = newConfig; this._selectedCard++; fireCustomEvent(this, "config-changed", this.config); } protected _moveLeft() { if (!this.config || this._selectedCard === 0) return; const newConfig = this.config.slice(); const movedElement = newConfig.splice(this._selectedCard, 1)[0]; newConfig.splice(this._selectedCard - 1, 0, movedElement); this.config = newConfig; this._selectedCard--; fireCustomEvent(this, "config-changed", this.config); } protected _delete() { if (!this.config) return; const newConfig = this.config.slice(); newConfig.splice(this._selectedCard, 1); this.config = newConfig; if (this._selectedCard >= newConfig.length && newConfig.length > 0) { this._selectedCard = newConfig.length - 1; } fireCustomEvent(this, "config-changed", this.config); } static get styles(): CSSResultGroup { return [ css` .toolbar { display: flex; justify-content: space-between; align-items: center; } ha-tab-group { flex-grow: 1; min-width: 0; --ha-tab-track-color: var(--card-background-color); } #bar-options { display: flex; justify-content: flex-end; width: 100%; } #editor { border: 1px solid var(--divider-color); padding: 12px; } @media (max-width: 450px) { #editor { margin: 0 -12px; } } `, ]; } } customElements.define(BAR_EDITOR_TAG, ItemEditor); ================================================ FILE: src/editor/editor.ts ================================================ import { LitElement, TemplateResult, html, css, CSSResultGroup, nothing } from 'lit'; import { html as staticHtml, unsafeStatic } from 'lit/static-html.js'; import { property, state } from 'lit/decorators.js'; import { EDITOR_TAG, ITEM_EDITOR_TAG, BAR_EDITOR_TAG, ITEMS_EDITOR_TAG } from '../card-tags'; import { mdiPencil } from '@mdi/js'; import { getLovelace } from '../utils'; import { PDCConfig, EntitySettings, CustomValueEvent, } from '../types'; import { computeLabel, localize } from '../localize/localize'; import './item-editor'; import './items-editor'; import './bar-editor'; import { loadHaComponents } from '../utils/ha-component-loader'; import { HaFormSchema } from './ha-form'; import { fireEvent, HomeAssistant } from '../utils'; /** * Editor Settings */ const animation = ['none', 'flash', 'slide']; const center = ['none', 'card', 'bars']; type EditorType = 'main' | 'item' | 'bars' | 'card'; type Editor = { type: EditorType; index?: number; } const SCHEMA: HaFormSchema[] = [ { name: 'title', selector: { text: {} } }, { name: 'animation', selector: { select: { options: animation, mode: 'dropdown' } }, required: true }, ]; export class PowerDistributionCardEditor extends LitElement { @property({ attribute: false }) public hass?: HomeAssistant; @state() private _config!: PDCConfig; @state() private _activeEditor: Editor = { type: 'main' }; public setConfig(config: PDCConfig) { // Migrate old format: center.content -> center.bars or center.card if (config.center && 'content' in config.center) { const oldContent = (config.center as any).content; const { content: _removed, ..._centerWithoutContent } = config.center as any; let newCenter: PDCConfig['center']; if (config.center.type === 'bars') { newCenter = { ..._centerWithoutContent, bars: oldContent }; } else if (config.center.type === 'card') { newCenter = { ..._centerWithoutContent, card: oldContent }; } else { newCenter = _centerWithoutContent; } this._config = { ...config, center: newCenter }; fireEvent(this, 'config-changed', { config: this._config }); } else { this._config = config; } } protected firstUpdated() { loadHaComponents(); } protected render() { if (!this.hass || !this._config) return nothing; if (this._activeEditor.type === 'main') { return this._renderMainEditor(); } // All Subpages get an additional header const content: (TemplateResult | typeof nothing)[] = [ html`
`, ]; switch (this._activeEditor.type) { case 'item': content.push(this._renderItemEditor()); break; case 'bars': content.push(this._renderBarEditor()); break; case 'card': content.push(this._renderCardEditor()); break; } return html`${content}`; } protected _enableCenterEditor(ev: any): void { ev.stopPropagation(); this._activeEditor = { type: ev.currentTarget.value }; } protected _enableItemEditor(ev: any): void { ev.stopPropagation(); this._activeEditor = { type: 'item', index: ev.detail, }; } protected _goBack(): void { this._activeEditor = { type: 'main' }; } protected _valueChanged(ev: CustomValueEvent) { ev.stopPropagation(); if (!this._config || !this.hass) { return; } const target = ev.target; const detail = ev.detail; if (target && detail) { if (target.configValue) { let value: any = detail; // Specific Case if (target.configValue == 'center.type') { value = detail.value; } // We split the target configValue by '.' to allow for nested config values of depth 1 const configValues = target.configValue.split('.'); this._config = { ...this._config, [configValues[0]]: configValues.length > 1 ? { ...this._config[configValues[0]], [configValues[1]]: value, } : value, }; } else { // Assuming a return from ha-form this._config = detail.value as PDCConfig; } fireEvent(this, 'config-changed', { config: this._config }); } } protected _renderMainEditor(): TemplateResult { return html`
({ value: val, label: val }))} > ${this._config?.center?.type != 'none' ? html`` : ''}

${staticHtml`<${unsafeStatic(ITEMS_EDITOR_TAG)} .hass=${this.hass} .entities=${this._config.entities} .configValue=${'entities'} @edit-item=${this._enableItemEditor} @config-changed=${this._valueChanged} >`} `; } protected _renderItemEditor() { const index = this._activeEditor.index; if (index == undefined) { return nothing; } return staticHtml`<${unsafeStatic(ITEM_EDITOR_TAG)} .hass=${this.hass} .config=${this._config.entities[index]} @config-changed=${this._itemChanged} >`; } /** * Bar Editor * ------------------- * This Bar Editor allows the user to easily add and remove new bars. */ protected _renderBarEditor() { return staticHtml`<${unsafeStatic(BAR_EDITOR_TAG)} .hass=${this.hass} .config=${this._config.center.bars} .configValue=${'center.bars'} @config-changed=${this._valueChanged} >`; } private _itemChanged(ev: CustomEvent) { ev.stopPropagation(); if (!this._config || !this.hass) { return; } const index = this._activeEditor.index; if (index != undefined) { const entities = [...this._config.entities]; entities[index] = ev.detail; fireEvent(this, 'config-changed', { config: { ...this._config, entities: entities } }); } } private _renderCardEditor(): TemplateResult { return html`

Card configuration is only editable via yaml.

Check out the Readme to check out the latest and best way to add it. `; } private _cardChanged(ev: CustomEvent): void { ev.stopPropagation(); if (!this._config || !this.hass) return; this._config = { ...this._config, center: { ...this._config.center, card: ev.detail.config }, }; fireEvent(this, 'config-changed', { config: this._config }); } /** * The Second Part comes from here: https://github.com/home-assistant/frontend/blob/dev/src/resources/ha-sortable-style.ts * @returns Editor CSS */ static get styles(): CSSResultGroup[] { return [ css` .checkbox { display: flex; align-items: center; padding: 8px 0; } .checkbox input { height: 20px; width: 20px; margin-left: 0; margin-right: 8px; } `, css` h3 { margin-bottom: 0.5em; } .row { margin-bottom: 12px; margin-top: 12px; display: block; } .side-by-side { display: flex; } .side-by-side > * { flex: 1 1 0%; padding-right: 4px; } .entity, .add-item { display: flex; align-items: center; } .entity .handle { padding-right: 8px; cursor: move; } .entity ha-entity-picker, .add-item ha-entity-picker { flex-grow: 1; } .add-preset { padding-right: 8px; max-width: 130px; } .remove-icon, .edit-icon, .add-icon { --mdc-icon-button-size: 36px; color: var(--secondary-text-color); } .secondary { font-size: 12px; color: var(--secondary-text-color); }`, ]; } } customElements.define(EDITOR_TAG, PowerDistributionCardEditor); ================================================ FILE: src/editor/ha-form.ts ================================================ import type { LitElement } from "lit"; interface HaDurationData { hours?: number; minutes?: number; seconds?: number; milliseconds?: number; } export type HaFormSchema = | HaFormConstantSchema | HaFormStringSchema | HaFormIntegerSchema | HaFormFloatSchema | HaFormBooleanSchema | HaFormSelectSchema | HaFormMultiSelectSchema | HaFormTimeSchema | HaFormSelector | HaFormGridSchema | HaFormExpandableSchema | HaFormOptionalActionsSchema; export interface HaFormBaseSchema { name: string; // This value is applied if no data is submitted for this field default?: HaFormData; required?: boolean; disabled?: boolean; description?: { suffix?: string; // This value will be set initially when form is loaded suggested_value?: HaFormData; }; context?: Record; } export interface HaFormGridSchema extends HaFormBaseSchema { type: "grid"; flatten?: boolean; column_min_width?: string; schema: readonly HaFormSchema[]; } export interface HaFormExpandableSchema extends HaFormBaseSchema { type: "expandable"; flatten?: boolean; title?: string; icon?: string; iconPath?: string; expanded?: boolean; headingLevel?: 1 | 2 | 3 | 4 | 5 | 6; schema: readonly HaFormSchema[]; } export interface HaFormOptionalActionsSchema extends HaFormBaseSchema { type: "optional_actions"; flatten?: boolean; schema: readonly HaFormSchema[]; } export interface HaFormSelector extends HaFormBaseSchema { type?: never; selector: Selector; } export interface HaFormConstantSchema extends HaFormBaseSchema { type: "constant"; value?: string; } export interface HaFormIntegerSchema extends HaFormBaseSchema { type: "integer"; default?: HaFormIntegerData; valueMin?: number; valueMax?: number; } export interface HaFormSelectSchema extends HaFormBaseSchema { type: "select"; options: readonly (readonly [string, string])[]; } export interface HaFormMultiSelectSchema extends HaFormBaseSchema { type: "multi_select"; options: | Record | readonly string[] | readonly (readonly [string, string])[]; } export interface HaFormFloatSchema extends HaFormBaseSchema { type: "float"; } export interface HaFormStringSchema extends HaFormBaseSchema { type: "string"; format?: string; autocomplete?: string; autofocus?: boolean; } export interface HaFormBooleanSchema extends HaFormBaseSchema { type: "boolean"; } export interface HaFormTimeSchema extends HaFormBaseSchema { type: "positive_time_period_dict"; } // Type utility to unionize a schema array by flattening any grid schemas export type SchemaUnion< SchemaArray extends readonly HaFormSchema[], Schema = SchemaArray[number], > = Schema extends HaFormGridSchema | HaFormExpandableSchema ? SchemaUnion | Schema : Schema; export type HaFormDataContainer = Record; export type HaFormData = | HaFormStringData | HaFormIntegerData | HaFormFloatData | HaFormBooleanData | HaFormSelectData | HaFormMultiSelectData | HaFormTimeData; export type HaFormStringData = string; export type HaFormIntegerData = number; export type HaFormFloatData = number; export type HaFormBooleanData = boolean; export type HaFormSelectData = string; export type HaFormMultiSelectData = string[]; export type HaFormTimeData = HaDurationData; export interface HaFormElement extends LitElement { schema: HaFormSchema | readonly HaFormSchema[]; data?: HaFormDataContainer | HaFormData; label?: string; } export type Selector = | ActionSelector | AddonSelector | AreaSelector | AttributeSelector | BooleanSelector | ColorRGBSelector | ColorTempSelector | UiColorSelector | DateSelector | DateTimeSelector | DeviceSelector | DurationSelector | EntitySelector | IconSelector | LocationSelector | MediaSelector | NumberSelector | ObjectSelector | SelectSelector | StringSelector | TargetSelector | TemplateSelector | ThemeSelector | TimeSelector | UiActionSelector; export interface ActionSelector { // eslint-disable-next-line @typescript-eslint/ban-types action: {}; } export interface AddonSelector { addon: { name?: string; slug?: string; }; } export interface AreaSelector { area: { entity?: { integration?: EntitySelector["entity"]["integration"]; domain?: EntitySelector["entity"]["domain"]; device_class?: EntitySelector["entity"]["device_class"]; }; device?: { integration?: DeviceSelector["device"]["integration"]; manufacturer?: DeviceSelector["device"]["manufacturer"]; model?: DeviceSelector["device"]["model"]; }; multiple?: boolean; }; } export interface AttributeSelector { attribute: { entity_id?: string; }; } export interface BooleanSelector { // eslint-disable-next-line @typescript-eslint/ban-types boolean: {}; } export interface ColorRGBSelector { // eslint-disable-next-line @typescript-eslint/ban-types color_rgb: {}; } export interface UiColorSelector { // eslint-disable-next-line @typescript-eslint/ban-types ui_color: {}; } export interface ColorTempSelector { color_temp: { min_mireds?: number; max_mireds?: number; }; } export interface DateSelector { // eslint-disable-next-line @typescript-eslint/ban-types date: {}; } export interface DateTimeSelector { // eslint-disable-next-line @typescript-eslint/ban-types datetime: {}; } export interface DeviceSelector { device: { integration?: string; manufacturer?: string; model?: string; entity?: { domain?: EntitySelector["entity"]["domain"]; device_class?: EntitySelector["entity"]["device_class"]; }; multiple?: boolean; }; } export interface DurationSelector { duration: { enable_day?: boolean; }; } export interface EntitySelector { entity: { integration?: string; domain?: string | string[]; device_class?: string; multiple?: boolean; include_entities?: string[]; exclude_entities?: string[]; }; } export interface IconSelector { icon: { placeholder?: string; fallbackPath?: string; }; } export interface LocationSelector { location: { radius?: boolean; icon?: string }; } export interface LocationSelectorValue { latitude: number; longitude: number; radius?: number; } export interface MediaSelector { // eslint-disable-next-line @typescript-eslint/ban-types media: {}; } export interface MediaSelectorValue { entity_id?: string; media_content_id?: string; media_content_type?: string; metadata?: { title?: string; thumbnail?: string | null; media_class?: string; children_media_class?: string | null; navigateIds?: { media_content_type: string; media_content_id: string }[]; }; } export interface NumberSelector { number: { min?: number; max?: number; step?: number; mode?: "box" | "slider"; unit_of_measurement?: string; }; } export interface ObjectSelector { // eslint-disable-next-line @typescript-eslint/ban-types object: {}; } export interface SelectOption { value: string; label: string; } export interface SelectSelector { select: { multiple?: boolean; custom_value?: boolean; mode?: "list" | "dropdown"; options: string[] | SelectOption[]; }; } export interface StringSelector { text: { multiline?: boolean; type?: | "number" | "text" | "search" | "tel" | "url" | "email" | "password" | "date" | "month" | "week" | "time" | "datetime-local" | "color"; suffix?: string; }; } export interface TargetSelector { target: { entity?: { integration?: EntitySelector["entity"]["integration"]; domain?: EntitySelector["entity"]["domain"]; device_class?: EntitySelector["entity"]["device_class"]; }; device?: { integration?: DeviceSelector["device"]["integration"]; manufacturer?: DeviceSelector["device"]["manufacturer"]; model?: DeviceSelector["device"]["model"]; }; }; } export interface TemplateSelector { // eslint-disable-next-line @typescript-eslint/ban-types template: {}; } export interface ThemeSelector { // eslint-disable-next-line @typescript-eslint/ban-types theme: {}; } export interface TimeSelector { // eslint-disable-next-line @typescript-eslint/ban-types time: {}; } export type UiAction = Exclude; export interface UiActionSelector { ui_action: { actions?: UiAction[]; } | null; } export interface ToggleActionConfig extends BaseActionConfig { action: "toggle"; } export interface CallServiceActionConfig extends BaseActionConfig { action: "call-service" | "perform-action"; /** @deprecated "service" is kept for backwards compatibility. Replaced by "perform_action". */ service?: string; perform_action: string; target?: any; /** @deprecated "service_data" is kept for backwards compatibility. Replaced by "data". */ service_data?: Record; data?: Record; } export interface NavigateActionConfig extends BaseActionConfig { action: "navigate"; navigation_path: string; } export interface UrlActionConfig extends BaseActionConfig { action: "url"; url_path: string; } export interface MoreInfoActionConfig extends BaseActionConfig { action: "more-info"; } export interface NoActionConfig extends BaseActionConfig { action: "none"; } export interface CustomActionConfig extends BaseActionConfig { action: "fire-dom-event"; } export interface AssistActionConfig extends BaseActionConfig { action: "assist"; pipeline_id?: string; start_listening?: boolean; } export interface BaseActionConfig { action: string; confirmation?: ConfirmationRestrictionConfig; } export interface ConfirmationRestrictionConfig { text?: string; exemptions?: RestrictionConfig[]; } export interface RestrictionConfig { user: string; } export type ActionConfig = | ToggleActionConfig | CallServiceActionConfig | NavigateActionConfig | UrlActionConfig | MoreInfoActionConfig | AssistActionConfig | NoActionConfig | CustomActionConfig; ================================================ FILE: src/editor/item-editor.ts ================================================ import { LitElement, html, css, CSSResult, nothing } from 'lit'; import { property } from 'lit/decorators.js'; import { ITEM_EDITOR_TAG } from '../card-tags'; import { EntitySettings } from '../types'; import { localize } from '../localize/localize'; import { PresetList } from '../presets'; import { HaFormSchema } from './ha-form'; import { fireEvent, HomeAssistant } from '../utils'; const BASE_SCHEMA: HaFormSchema[] = [ { name: "general", type: "expandable", flatten: true, expanded: true, title: localize('editor.settings.general_settings', true), icon: "mdi:text", schema: [ { name: "entity", selector: { entity: { domain: "sensor"} } }, { type: "grid", name: "", schema: [ { name: "name", selector: { text: {} } }, { name: "icon", selector: { icon: {} } }, { name: "attribute", selector: { attribute: {}}, context: { filter_entity: "entity" } }, { name: "preset", selector: { select: { options: PresetList as any as string[], mode: 'dropdown' } } }, ] }, ] }, { name: "Value Settings", type: "expandable", flatten: true, title: localize('editor.settings.value', true) + " " +localize('editor.settings.settings', true), icon: "mdi:numeric", schema: [ { type: "grid", name: "", schema: [ { name: "unit_of_display", selector: { text: {} } }, { name: "decimals", selector: { number: { step: 1} } }, { name: "invert_value", type: "boolean"}, { name: "display_abs", type: "boolean"}, { name: "hide_arrows", type: "boolean"}, { name: "calc_excluded", type: "boolean"}, { name: "threshold", selector: { number: { } } }, ] } ] }, { name: "Secondary Info", type: "expandable", flatten: true, title: localize('editor.settings.secondary_info', true), icon: "mdi:attachment-plus", schema: [ { name: "secondary_info_entity", selector: { entity: { domain: "sensor"} } }, { name: "secondary_info_attribute", selector: { attribute: {}}, context: { filter_entity: "secondary_info_entity" }}, { name: "secondary_info_decimals", selector: { number: { step: 1 } } }, { name: "secondary_info_replace_name", type: "boolean"}, ] }, { name: "Action Settings", type: "expandable", flatten: true, title: localize('editor.settings.action_settings', true), icon: "mdi:gesture-tap", schema: [ { type: "grid", name: "", schema: [ { name: "tap_action", selector: { ui_action: {} }, }, { name: "double_tap_action", selector: { ui_action: {} }, } ] } ] }, { name: "Color Settings", type: "expandable", flatten: true, title: localize('editor.settings.color_settings', true), icon: "mdi:palette", schema: [ { name: "color_threshold", selector: { number: { } } }, { type: "grid", name: "", schema: [ { name: "icon_color_bigger", selector: { ui_color: {} } }, { name: "arrow_color_bigger", selector: { ui_color: {} } }, { name: "icon_color_equal", selector: { ui_color: {} } }, { name: "arrow_color_equal", selector: { ui_color: {} } }, { name: "icon_color_smaller", selector: { ui_color: {} } }, { name: "arrow_color_smaller", selector: { ui_color: {} } }, ] } ] } ]; const PRESET_LABEL_MAP: Record = { battery_percentage_entity: 'battery_percentage', grid_buy_entity: 'grid_buy', grid_sell_entity: 'grid_sell', secondary_info_decimals: 'decimals', }; export class ItemEditor extends LitElement { @property({ attribute: false }) config?: EntitySettings; @property({ attribute: false }) hass?: HomeAssistant; private get _flatConfig() { const c = this.config!; return { ...c, icon_color_bigger: c.icon_color?.bigger, icon_color_equal: c.icon_color?.equal, icon_color_smaller: c.icon_color?.smaller, arrow_color_bigger: c.arrow_color?.bigger, arrow_color_equal: c.arrow_color?.equal, arrow_color_smaller: c.arrow_color?.smaller, }; } private get _schema(): HaFormSchema[] { const preset = this.config?.preset; const presetFields: HaFormSchema[] = preset === 'battery' ? [{ name: 'battery_percentage_entity', selector: { entity: {} } }] : preset === 'grid' ? [ { name: 'grid_buy_entity', selector: { entity: {} } }, { name: 'grid_sell_entity', selector: { entity: {} } }, ] : []; if (presetFields.length === 0) return BASE_SCHEMA; const presetSection: HaFormSchema = { name: 'preset_section', type: 'expandable', flatten: true, title: localize('editor.settings.preset_settings', true), icon: "mdi:shape", schema: presetFields, }; return [BASE_SCHEMA[0], presetSection, ...BASE_SCHEMA.slice(1)]; } private _computeLabel = (schema: HaFormSchema) => { const key = PRESET_LABEL_MAP[schema.name] ?? schema.name; return `${localize('editor.settings.' + key)} ${!schema.required ? `(${localize('editor.optional')})` : ''}`; }; protected render() { // If its a placeholder, don't render anything if (!this.hass || !this.config || this.config.preset == 'placeholder') { return nothing; } return html` `; } private _formValueChanged(ev: CustomEvent): void { ev.stopPropagation(); if (!this.config || !this.hass) return; const { icon_color_bigger, icon_color_equal, icon_color_smaller, arrow_color_bigger, arrow_color_equal, arrow_color_smaller, ...rest } = ev.detail.value; const icon_color = (icon_color_bigger || icon_color_equal || icon_color_smaller) ? { bigger: icon_color_bigger || undefined, equal: icon_color_equal || undefined, smaller: icon_color_smaller || undefined } : undefined; const arrow_color = (arrow_color_bigger || arrow_color_equal || arrow_color_smaller) ? { bigger: arrow_color_bigger || undefined, equal: arrow_color_equal || undefined, smaller: arrow_color_smaller || undefined } : undefined; fireEvent(this, 'config-changed', { ...rest, icon_color, arrow_color }); } static get styles(): CSSResult { return css` .checkbox { display: flex; align-items: center; padding: 8px 0; } .checkbox input { height: 20px; width: 20px; margin-left: 0; margin-right: 8px; } h3 { margin-bottom: 0.5em; } .row { margin-bottom: 12px; margin-top: 12px; display: block; } .side-by-side { display: flex; } .side-by-side > * { flex: 1 1 0%; padding-right: 4px; } `; } } customElements.define(ITEM_EDITOR_TAG, ItemEditor); ================================================ FILE: src/editor/items-editor.ts ================================================ import { LitElement, html } from 'lit'; import { EditorTarget, EntitySettings, HTMLElementValue } from '../types'; import { localize } from '../localize/localize'; import { property, state } from 'lit/decorators.js'; import { ITEMS_EDITOR_TAG } from '../card-tags'; import { repeat } from 'lit/directives/repeat.js'; import { css, CSSResult, nothing } from 'lit'; import { mdiClose, mdiPencil, mdiPlusCircleOutline } from '@mdi/js'; import { DefaultItem, PresetList, PresetObject } from '../presets'; import { fireCustomEvent, HomeAssistant } from '../utils'; export class ItemsEditor extends LitElement { @property({ attribute: false }) entities?: EntitySettings[]; @property({ attribute: false }) hass?: HomeAssistant; @state() private _selectedPreset: string = PresetList[0]; private _entityKeys = new WeakMap(); private _getKey(action: EntitySettings) { if (!this._entityKeys.has(action)) { this._entityKeys.set(action, Math.random().toString()); } return this._entityKeys.get(action)!; } public disconnectedCallback() { super.disconnectedCallback(); } protected render() { if (!this.entities || !this.hass) { return nothing; } return html`

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

${repeat( this.entities, (entityConf) => this._getKey(entityConf), (entityConf, index) => html`
`, )}
({ value: val, label: val }))} @selected=${(ev: CustomEvent<{ value: string }>) => { this._selectedPreset = ev.detail.value; }} >
`; } private _valueChanged(ev: CustomEvent): void { if (!this.entities || !this.hass) { return; } const value = ev.detail.value; const index = (ev.target as any).index; const newConfigEntities = this.entities!.concat(); newConfigEntities[index] = { ...newConfigEntities[index], entity: value || '', }; fireCustomEvent(this, 'config-changed', newConfigEntities); } private _removeRow(ev: Event): void { ev.stopPropagation(); const index = (ev.currentTarget as EditorTarget).index; if (index != undefined) { const entities = this.entities!.concat(); entities.splice(index, 1); fireCustomEvent(this, 'config-changed', entities); } } private _editRow(ev: Event): void { ev.stopPropagation(); const index = (ev.target as EditorTarget).index; if (index != undefined) { fireCustomEvent(this, 'edit-item', index); } } private _addRow(ev: Event): void { ev.stopPropagation(); if (!this.entities || !this.hass) { return; } const preset = this._selectedPreset || 'placeholder'; const entity_id = (this.shadowRoot!.querySelector('.add-entity') as HTMLElementValue).value; const item = Object.assign({}, DefaultItem, PresetObject[preset], { entity: entity_id, preset: entity_id == '' ? 'placeholder' : preset, }); fireCustomEvent(this, 'config-changed', [...this.entities, item]); } private _rowMoved(ev: CustomEvent<{ oldIndex: number; newIndex: number }>): void { ev.stopPropagation(); const { oldIndex, newIndex } = ev.detail; if (oldIndex === newIndex || !this.entities) return; const newEntities = this.entities.concat(); newEntities.splice(newIndex, 0, newEntities.splice(oldIndex, 1)[0]); fireCustomEvent(this, 'config-changed', newEntities); } static get styles(): CSSResult { return css` .entity, .add-item { display: flex; align-items: center; } .entity { display: flex; align-items: center; } .entity .handle { padding-right: 8px; cursor: move; padding-inline-end: 8px; padding-inline-start: initial; direction: var(--direction); } .entity .handle > * { pointer-events: none; } .entity ha-entity-picker, .add-item ha-entity-picker { flex-grow: 1; } .entities { margin-bottom: 8px; } .add-preset { padding-right: 8px; max-width: 130px; } .remove-icon, .edit-icon, .add-icon { --mdc-icon-button-size: 36px; color: var(--secondary-text-color); } `; } } customElements.define(ITEMS_EDITOR_TAG, ItemsEditor); ================================================ FILE: src/localize/languages/de.json ================================================ { "common": { "description": "Eine Karte zur Visualizierung von Stromverteilungen" }, "editor": { "actions": { "add": "Hinzufügen", "edit": "Bearbeiten", "remove": "Entfernen" }, "optional": "Optional", "settings": { "action_settings": "Interaktions Einstellungen", "animation": "Animation", "autarky": "Autarkie", "attribute": "Attribut", "background_color": "Hintergrundfarbe", "battery_percentage": "Batterie Ladung %", "arrow_color_bigger": "Pfeil - Größer", "arrow_color_equal": "Pfeil - Gleich", "arrow_color_smaller": "Pfeil - Kleiner", "icon_color_bigger": "Symbol - Größer", "icon_color_equal": "Symbol - Gleich", "icon_color_smaller": "Symbol - Kleiner", "calc_excluded": "Von Rechnungen ausschließen", "center": "Mittelbereich", "color": "Farbe", "color_settings": "Farb Einstellungen", "color_threshold": "Farb-Schwellenwert", "decimals": "Dezimalstellen", "display_abs": "Absolute Wertanzeige", "double_tap_action": "Doppel Tipp Aktion", "entities": "Entities", "entity": "Element", "general_settings": "Allgemeine Einstellungen", "grid_buy": "Netz Ankauf", "grid_sell": "Netz Verkauf", "hide_arrows": "Pfeile Verstecken", "lower_bound": "Untere Grenze", "upper_bound": "Obere Grenze", "preset_settings": "Vorlagen Einstellungen", "icon": "Symbol", "invert_value": "Wert Invertieren", "name": "Name", "preset": "Vorlagen", "ratio": "Anteil", "secondary_info": "Zusatzinformationen", "secondary_info_entity": "Element", "secondary_info_attribute": "Attribut", "secondary_info_replace_name": "Namen Ersetzen", "settings": "Einstellungen", "tap_action": "Tipp Aktion", "threshold": "Schwellenwert", "title": "Titel", "unit_of_display": "Angezeigte Einheit", "value": "Wert" } } } ================================================ FILE: src/localize/languages/en.json ================================================ { "common": { "description": "A Lovelace Card for visualizing power distributions." }, "editor": { "actions": { "add": "Add", "edit": "Edit", "remove": "Remove" }, "optional": "Optional", "settings": { "action_settings": "Action Settings", "animation": "Animation", "autarky": "autarky", "attribute": "Attribute", "background_color": "Background Color", "battery_percentage": "Battery Charge %", "arrow_color_bigger": "Arrow - Bigger", "arrow_color_equal": "Arrow - Equal", "arrow_color_smaller": "Arrow - Smaller", "icon_color_bigger": "Icon - Bigger", "icon_color_equal": "Icon - Equal", "icon_color_smaller": "Icon - Smaller", "calc_excluded": "Excluded from Calculations", "center": "Center", "color": "Color", "color_settings": "Color Settings", "color_threshold": "Color Threshold", "decimals": "Decimals", "display_abs": "Display Absolute Value", "double_tap_action": "Double Tap Action", "entities": "Entities", "entity": "Entity", "general_settings": "General Settings", "grid_buy": "Grid Buy", "grid_sell": "Grid Sell", "hide_arrows": "Hide Arrows", "lower_bound": "Lower Bound", "upper_bound": "Upper Bound", "preset_settings": "Preset Settings", "icon": "Icon", "invert_value": "Invert Value", "name": "Name", "preset": "Preset", "ratio": "ratio", "secondary_info": "Secondary Info", "secondary_info_entity": "Entity", "secondary_info_attribute": "Attribute", "secondary_info_replace_name": "Replace Name", "settings": "Settings", "tap_action": "Tap Action", "threshold": "Threshold", "title": "Title", "unit_of_display": "Unit of Display", "value": "value" } } } ================================================ FILE: src/localize/languages/sk.json ================================================ { "common": { "description": "A Lovelace Card for visualizing power distributions." }, "editor": { "actions": { "add": "Pridať", "edit": "Editovať", "remove": "Odobrať" }, "optional": "Voliteľné", "settings": { "action_settings": "Nastavenia akcie", "animation": "Animácia", "autarky": "sebestačnosť", "attribute": "Atribút", "background_color": "Farba pozadia", "battery_percentage": "Nabitie batérie %", "arrow_color_bigger": "Šípka - Väčšie", "arrow_color_equal": "Šípka - Rovné", "arrow_color_smaller": "Šípka - Menšie", "icon_color_bigger": "Ikona - Väčšie", "icon_color_equal": "Ikona - Rovné", "icon_color_smaller": "Ikona - Menšie", "calc_excluded": "Vylúčené z výpočtov", "center": "Centrum", "color": "Farba", "color_settings": "Nastavenia farby", "color_threshold": "Prah farby", "decimals": "Desatinné čísla", "display_abs": "Zobraziť absolútnu hodnotu", "double_tap_action": "Akcia dvojitého klepnutia", "entities": "Entity", "entity": "Entita", "general_settings": "Všeobecné nastavenia", "grid_buy": "Sieť nákup", "grid_sell": "Sieť predaj", "hide_arrows": "Skryť šípky", "lower_bound": "Dolná hranica", "upper_bound": "Horná hranica", "preset_settings": "Nastavenia predvoľby", "icon": "Ikona", "invert_value": "Invertovať hodnotu", "name": "Názov", "preset": "Predvoľba", "ratio": "pomer", "secondary_info": "Sekundárne informácie", "secondary_info_entity": "Entita", "secondary_info_attribute": "Atribút", "secondary_info_replace_name": "Nahradiť názov", "settings": "nastavenia", "tap_action": "Akcia klepnutia", "threshold": "Prah", "title": "Titul", "unit_of_display": "Jednotka zobrazenia", "value": "hodnota" } } } ================================================ FILE: src/localize/localize.ts ================================================ import * as en from './languages/en.json'; import * as de from './languages/de.json'; import { HaFormSchema } from '../editor/ha-form'; import * as sk from './languages/sk.json'; const languages = { en: en, de: de, sk: sk, }; /** * Translating Strings to different languages. * Thanks to custom-cards/spotify-card * @param string The Section-Key Pair * @param search String which should be replaced * @param replace String to replace with */ export function localize(string: string, capitalized = false, search = '', replace = ''): string { const lang = (localStorage.getItem('selectedLanguage') || navigator.language.split('-')[0] || 'en') .replace(/['"]+/g, '') .replace('-', '_'); let translated: string; try { translated = string.split('.').reduce((o, i) => o[i], languages[lang]); } catch (e) { translated = string.split('.').reduce((o, i) => o[i], languages['en']) as unknown as string; } if (translated === undefined) translated = string.split('.').reduce((o, i) => o[i], languages['en']) as unknown as string; if (search !== '' && replace !== '') { translated = translated.replace(search, replace); } return capitalized ? capitalizeFirstLetter(translated) : translated; } function capitalizeFirstLetter(string: string) { if (!string) return ""; return string.charAt(0).toUpperCase() + string.slice(1); } export function computeLabel(schema: HaFormSchema) { return `${localize('editor.settings.' + schema.name)} ${!schema.required ? `(${localize('editor.optional')})` : ''}`; } ================================================ FILE: src/power-distribution-card.ts ================================================ import { LitElement, html, TemplateResult, PropertyValues, CSSResultGroup } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { createThing } from './utils'; import { version } from '../package.json'; import { PDCConfig, EntitySettings, ArrowStates, BarSettings } from './types'; import { DefaultItem, DefaultConfig, PresetList, PresetObject } from './presets'; import { styles, narrow_styles } from './styles'; import { localize } from './localize/localize'; import { actionHandler } from './action-handler'; import './editor/editor'; import { ActionHandlerEvent, handleAction, hasAction, HomeAssistant, LovelaceCard, LovelaceCardConfig, registerCustomCard, formatNumber } from './utils'; import { CARD_TAG, EDITOR_TAG } from './card-tags'; import { computeCssColor } from './utils/compute-color'; import { debounce } from './utils/debounce'; console.info( `%c POWER-DISTRIBUTION-CARD %c ${version} `, `font-weight: 500; color: black; background:#f6aa1c;`, `font-weight: 500; color: #f6aa1c; background: #220901;`, ); registerCustomCard(CARD_TAG, 'Power Distribution Card', localize('common.description')); @customElement(CARD_TAG) export class PowerDistributionCard extends LitElement { /** * Function for creating the editor for the power-distribution-card */ public static async getConfigElement(): Promise { await import('./editor/editor'); return document.createElement(EDITOR_TAG) as LitElement; } /** * Returns a mock config for preview in the card picker */ public static getStubConfig(): Record { return { title: 'Title', entities: [], center: { type: 'bars', bars: [ { preset: 'autarky', name: localize('editor.settings.autarky') }, { preset: 'ratio', name: localize('editor.settings.ratio') }, ], }, }; } @property({ attribute: false }) public hass!: HomeAssistant; @state() private _config!: PDCConfig; @property() private _card!: LovelaceCard; private _resizeObserver?: ResizeObserver; @state() private _narrow = false; /** * Configuring all the passed Settings and Changing it to a more usefull Internal one. * @param config The Config Object configured via YAML */ public async setConfig(config: PDCConfig): Promise { //The Addition of the last object is needed to override the entities array for the preset settings const _config = Object.assign({}, DefaultConfig, config); // Migrate old format: center.content -> center.bars or center.card if (_config.center && 'content' in _config.center) { const oldContent = (_config.center as any).content; const { content: _removed, ..._centerWithoutContent } = _config.center as any; if (_config.center.type === 'bars') { _config.center = { ..._centerWithoutContent, bars: oldContent as BarSettings[] }; } else if (_config.center.type === 'card') { _config.center = { ..._centerWithoutContent, card: oldContent as import('./utils').LovelaceCardConfig }; } else { _config.center = _centerWithoutContent; } } // Applying Defaults depending on preset _config.entities = config.entities.map((item) => { if (item.preset && PresetList.includes(item.preset)) { return Object.assign({}, DefaultItem, PresetObject[item.preset], item); } else { return item; } }); this._config = _config; } public firstUpdated(): void { const _config = this._config; //unit-of-measurement Auto Configuration from hass element _config.entities.forEach((item, index) => { if (item.entity && !item.unit_of_measurement) { const hass_uom = this._state({ entity: item.entity, attribute: 'unit_of_measurement' }) as string; this._config.entities[index].unit_of_measurement = hass_uom || 'W'; } }); // Applying the same to bars if (_config.center.type == 'bars' && _config.center.bars) { const bars = _config.center.bars.map((item) => { if (item.unit_of_measurement) return item; let hass_uom = '%'; if (item.entity) { hass_uom = this._state({ entity: item.entity, attribute: 'unit_of_measurement' }) as string; } return Object.assign({}, item, { unit_of_measurement: item.unit_of_measurement || hass_uom }); }); this._config.center = { ...this._config.center, bars: bars, }; } else if (this._config.center.type == 'card' && this._config.center.card) { this._card = this._createCardElement(this._config.center.card); } //Resize Observer this._adjustWidth(); this._attachObserver(); } protected updated(changedProps: PropertyValues): void { super.updated(changedProps); if (!this._card || (!changedProps.has('hass') && !changedProps.has('editMode'))) { return; } if (this.hass) { this._card.hass = this.hass; } } public static get styles(): CSSResultGroup { return styles; } public connectedCallback(): void { super.connectedCallback(); this.updateComplete.then(() => this._attachObserver()); } public disconnectedCallback(): void { if (this._resizeObserver) { this._resizeObserver.disconnect(); } } private async _attachObserver(): Promise { if (!this._resizeObserver) { this._resizeObserver = new ResizeObserver(debounce(() => this._adjustWidth(), 250, false)); } const card = this.shadowRoot?.querySelector('ha-card'); // If we show an error or warning there is no ha-card if (!card) return; this._resizeObserver.observe(card); } private _adjustWidth(): void { const card = this.shadowRoot?.querySelector('ha-card'); if (!card) return; this._narrow = card.offsetWidth < 400; } private _formatValue(rawValue: number, entity?: string, decimals?: number): [string, number] { const precision = decimals != null ? decimals : (entity ? (this.hass.entities[entity]?.display_precision ?? 2) : 2); const factor = 10 ** precision; const rounded = Math.round(rawValue * factor) / factor; return [formatNumber(rounded, this.hass.locale), rounded]; } /** * Retrieving the sensor value of hass for a Item as a number * @param item a Settings object * @returns The current value from Homeassistant in Watts */ private _val(item: EntitySettings | BarSettings): number { let modifier = item.invert_value ? -1 : 1; //Proper K Scaling e.g. 1kW = 1000W if (item.unit_of_measurement?.charAt(0) == 'k') modifier *= 1000; // If an entity exists, check if the attribute setting is entered -> value from attribute else value from entity let num = this._state(item as EntitySettings) as number; //Applying Threshold const threshold = (item as EntitySettings).threshold || null; num = threshold ? (Math.abs(num) < threshold ? 0 : num) : num; return num * modifier; } /** * Retrieving the raw state of an sensor/attribute * @param item A Settings object * @returns entitys/attributes state */ private _state(item: EntitySettings): unknown { return item.entity && this.hass.states[item.entity] ? item.attribute ? this.hass.states[item.entity].attributes[item.attribute] : this.hass.states[item.entity].state : null; } /** * This is the main rendering function for this card * @returns html for the power-distribution-card */ protected render(): TemplateResult { const left_panel: TemplateResult[] = []; const center_panel: (TemplateResult | LovelaceCard)[] = []; const right_panel: TemplateResult[] = []; let consumption = 0; let production = 0; this._config.entities.forEach((item, index) => { const value = this._val(item); if (!item.calc_excluded) { if (item.producer && value > 0) { production += value; } if (item.consumer && value < 0) { consumption -= value; } } const _item = this._render_item(value, item, index); //Sorting the Items to either side if (index % 2 == 0) left_panel.push(_item); else right_panel.push(_item); }); //Populating the Center Panel const center = this._config.center; switch (center.type) { case 'none': break; case 'card': if (this._card) { center_panel.push(this._card); } else { console.warn('NO CARD'); } break; case 'bars': center_panel.push(this._render_bars(consumption, production)); break; } return html` ${this._narrow ? narrow_styles : undefined}
${left_panel}
${center_panel}
${right_panel}
`; } private _handleAction(ev: ActionHandlerEvent): void { if (this.hass && this._config && ev.detail.action) { handleAction( this, this.hass, { // eslint-disable-next-line @typescript-eslint/no-explicit-any entity: (ev.currentTarget as any).entity, // eslint-disable-next-line @typescript-eslint/no-explicit-any tap_action: (ev.currentTarget as any).tap_action, // eslint-disable-next-line @typescript-eslint/no-explicit-any double_tap_action: (ev.currentTarget as any).double_tap_action, }, ev.detail.action, ); } } /** * Creating a Item Element * @param value The Value of the Sensor * @param item The EntitySettings Object of the Item * @param index The index of the Item. This is needed for the Arrow Directions. * @returns Html for a single Item */ private _render_item(value: number, item: EntitySettings, index: number): TemplateResult { //Placeholder item if (!item.entity) { return html``; } let math_value = value; //Unit-Of-Display and Unit_of_measurement let unit_of_display = item.unit_of_display || 'W'; const uod_split = unit_of_display.charAt(0); if (uod_split[0] == 'k') { math_value /= 1000; } else if (item.unit_of_display == 'adaptive') { //Using the uom suffix enables to adapt the initial unit to the automatic scaling naming let uom_suffix = 'W'; if (item.unit_of_measurement) { uom_suffix = item.unit_of_measurement[0] == 'k' ? item.unit_of_measurement.substring(1) : item.unit_of_measurement; } if (Math.abs(math_value) > 999) { math_value /= 1000; unit_of_display = 'k' + uom_suffix; } else { unit_of_display = uom_suffix; } } // Arrow directions const state = item.invert_arrow ? math_value * -1 : math_value; // Toggle Absolute Values math_value = item.display_abs ? Math.abs(math_value) : math_value; // Decimal Precision let [formatValue, formatted_math_value] = this._formatValue(math_value, item.entity, item.decimals); //NaNFlag for Offline Sensors for example const NanFlag = isNaN(formatted_math_value); // Secondary info let secondary_info: string | undefined; if (item.secondary_info_entity) { if (item.secondary_info_attribute) { secondary_info = this._state({ entity: item.secondary_info_entity, attribute: item.secondary_info_attribute }) + ''; } else { const siRaw = this._state({ entity: item.secondary_info_entity }) as string; if (isNaN(parseFloat(siRaw))) { secondary_info = String(siRaw); } else { secondary_info = `${this._formatValue(parseFloat(siRaw), item.secondary_info_entity, item.secondary_info_decimals)[0]}${this._state({ entity: item.secondary_info_entity, attribute: 'unit_of_measurement' }) || ''}`; } } } // Secondary info replace name let displayName = item.name; if (item.secondary_info_replace_name) { displayName = secondary_info; secondary_info = undefined; } //Preset Features // 1. Battery Icon let icon = item.icon; if (item.preset === 'battery' && item.battery_percentage_entity) { const bat_val = this._val({ entity: item.battery_percentage_entity }); if (!isNaN(bat_val)) { icon = 'mdi:battery'; // mdi:battery-100 and -0 don't exist thats why we have to handle it seperately if (bat_val < 5) { icon = 'mdi:battery-outline'; } else if (bat_val < 95) { icon = 'mdi:battery-' + (bat_val / 10).toFixed(0) + '0'; } } } // 2. Grid Buy-Sell let nameReplaceFlag = false; let grid_buy_sell = html``; if (item.preset === 'grid' && (item.grid_buy_entity || item.grid_sell_entity)) { nameReplaceFlag = true; const gridBuyValue = item.grid_buy_entity ? this._formatValue(this._val({ entity: item.grid_buy_entity }), item.grid_buy_entity, item.decimals)[0] : undefined; const gridSellValue = item.grid_sell_entity ? this._formatValue(this._val({ entity: item.grid_sell_entity }), item.grid_sell_entity, item.decimals)[0] : undefined; grid_buy_sell = html`
${item.grid_buy_entity ? html`
B: ${gridBuyValue}${this._state({ entity: item.grid_buy_entity, attribute: 'unit_of_measurement', }) || undefined}
` : undefined} ${item.grid_sell_entity ? html`
S: ${gridSellValue}${this._state({ entity: item.grid_sell_entity, attribute: 'unit_of_measurement', }) || undefined}
` : undefined}
`; } // COLOR CHANGE const ct = item.color_threshold || 0; // Icon color dependant on state let icon_color: string | undefined; if (item.icon_color) { if (state > ct) icon_color = item.icon_color.bigger; if (state < ct) icon_color = item.icon_color.smaller; if (state == ct) icon_color = item.icon_color.equal; if (icon_color) icon_color = computeCssColor(icon_color); } // Arrow color let arrow_color: string | undefined; if (item.arrow_color) { if (state > ct) arrow_color = item.arrow_color.bigger; if (state < ct) arrow_color = item.arrow_color.smaller; if (state == ct) arrow_color = item.arrow_color.equal; if (arrow_color) arrow_color = computeCssColor(arrow_color); } return html` ${secondary_info ? html`

${secondary_info}

` : null}
${nameReplaceFlag ? grid_buy_sell : html`

${displayName}

`}

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

${!item.hide_arrows ? this._render_arrow( //This takes the side the item is on (index even = left) into account for the arrows value == 0 || NanFlag ? 'none' : index % 2 == 0 ? state > 0 ? 'right' : 'left' : state > 0 ? 'left' : 'right', arrow_color, ) : html`` }
`; } /** * Render function for Generating Arrows (CSS Only) * @param direction One of three Options: none, right, left * @param index To detect which side the item is on and adapt the direction accordingly */ private _render_arrow(direction: ArrowStates, color?: string): TemplateResult { const a = this._config.animation; if (direction == 'none') { return html`
`; } else { return html`
`; } } /** * Render Support Function Calculating and Generating the Autarky and Ratio Bars * @param consumption the total home consumption * @param production the total home production * @returns html containing the bars as Template Results */ private _render_bars(consumption: number, production: number): TemplateResult { const bars: TemplateResult[] = []; if (!this._config.center.bars || this._config.center.bars.length == 0) return html``; this._config.center.bars.forEach((element) => { let value = -1; switch (element.preset) { case 'autarky': //Autarky in Percent = Home Production(Solar, Battery)*100 / Home Consumption if (!element.entity) value = consumption != 0 ? Math.min(Math.round((production * 100) / Math.abs(consumption)), 100) : 0; break; case 'ratio': //Ratio in Percent = Home Consumption / Home Production(Solar, Battery)*100 if (!element.entity) value = production != 0 ? Math.min(Math.round((Math.abs(consumption) * 100) / production), 100) : 0; break; } const rawValue = value < 0 ? parseInt(this._val(element).toFixed(0), 10) : value; const lb = element.lower_bound ?? 0; const ub = element.upper_bound ?? 100; const barHeight = Math.min(Math.max(((rawValue - lb) / (ub - lb)) * 100, 0), 100); bars.push(html`

${Math.round(barHeight)}${element.unit_of_measurement || '%'}

${element.name || ''}

`); }); return html`${bars}`; } private _createCardElement(cardConfig: LovelaceCardConfig) { const element = createThing(cardConfig) as LovelaceCard; if (this.hass) { element.hass = this.hass; } element.addEventListener( 'll-rebuild', (ev) => { ev.stopPropagation(); this._rebuildCard(element, cardConfig); }, { once: true }, ); return element; } private _rebuildCard(cardElToReplace: LovelaceCard, config: LovelaceCardConfig): void { const newCardEl = this._createCardElement(config); if (cardElToReplace.parentElement) { cardElToReplace.parentElement.replaceChild(newCardEl, cardElToReplace); } if (this._card === cardElToReplace) { this._card = newCardEl; } } } ================================================ FILE: src/presets.ts ================================================ import { EntitySettings, PDCConfig } from './types'; export type PresetType = (typeof PresetList)[number]; export const PresetList = [ 'battery', 'car_charger', 'consumer', 'grid', 'home', 'hydro', 'pool', 'producer', 'solar', 'wind', 'heating', 'placeholder', ] as const; export const PresetObject: { [key: string]: EntitySettings } = { battery: { consumer: true, icon: 'mdi:battery-outline', name: 'battery', producer: true, }, car_charger: { consumer: true, icon: 'mdi:car-electric', name: 'car', }, consumer: { consumer: true, icon: 'mdi:lightbulb', name: 'consumer', }, grid: { icon: 'mdi:transmission-tower', name: 'grid', }, home: { consumer: true, icon: 'mdi:home-assistant', name: 'home', }, hydro: { icon: 'mdi:hydro-power', name: 'hydro', producer: true, }, pool: { consumer: true, icon: 'mdi:pool', name: 'pool', }, producer: { icon: 'mdi:lightning-bolt-outline', name: 'producer', producer: true, }, solar: { icon: 'mdi:solar-power', name: 'solar', producer: true, }, wind: { icon: 'mdi:wind-turbine', name: 'wind', producer: true, }, heating: { icon: 'mdi:radiator', name: 'heating', consumer: true, }, placeholder: { name: 'placeholder', }, }; export const DefaultItem: EntitySettings = { decimals: 2, display_abs: true, name: '', unit_of_display: 'W', }; export const DefaultConfig: PDCConfig = { type: '', title: undefined, animation: 'flash', entities: [], center: { type: 'none', }, }; ================================================ FILE: src/styles.ts ================================================ import { css, html } from 'lit'; export const styles = css` * { box-sizing: border-box; } p { margin: 4px 0 4px 0; text-align: center; } .card-content { display: grid; grid-template-columns: 1.5fr 1fr 1.5fr; column-gap: 10px; } #center-panel { display: flex; align-items: center; justify-content: center; grid-column: 2; flex-wrap: wrap; min-width: 100px; } #center-panel > div { display: flex; width: 100%; min-height: 150px; max-height: 200px; flex-basis: 50%; flex-flow: column; } #center-panel > div > p { flex: 0 1 auto; } .bar-wrapper { position: relative; width: 50%; height: 80%; margin: auto; flex: 1 1 auto; background-color: rgba(114, 114, 114, 0.2); } bar { position: absolute; right: 0; bottom: 0; left: 0; background-color: var(--secondary-text-color); } item { display: block; overflow: hidden; margin-bottom: 10px; cursor: pointer; } .buy-sell { height: 28px; display: flex; flex-direction: column; font-size: 11px; line-height: 14px; text-align: center; } .grid-buy { color: red; } .grid-sell { color: green; } .placeholder { height: 62px; } #right-panel > item > value { float: left; } #right-panel > item > badge { float: right; } badge { float: left; width: 50%; padding: 4px; border: 1px solid; border-color: var(--disabled-text-color); border-radius: 1em; position: relative; } icon > ha-icon { display: block; width: 24px; margin: 0 auto; color: var(--state-icon-color); } .secondary { position: absolute; top: 4px; right: 8%; font-size: 80%; } value { float: right; width: 50%; min-width: 54px; } value > p { height: 1em; } /************** ARROW ANIMATION **************/ .blank { width: 55px; height: 4px; margin: 8px auto 8px auto; opacity: 0.2; background-color: var(--secondary-text-color); } .arrow-container { display: flex; width: 55px; height: 16px; overflow: hidden; margin: auto; } .left { transform: rotate(180deg); } .arrow { width: 0; border-top: 8px solid transparent; border-bottom: 8px solid transparent; border-left: 16px solid var(--secondary-text-color); margin: 0 1.5px; } .flash { animation: flash 3s infinite steps(1); opacity: 0.2; } @keyframes flash { 0%, 66% { opacity: 0.2; } 33% { opacity: 0.8; } } .delay-1 { animation-delay: 1s; } .delay-2 { animation-delay: 2s; } .slide { animation: slide 1.5s linear infinite both; position: relative; left: -19px; } @keyframes slide { 0% { -webkit-transform: translateX(0); transform: translateX(0); } 100% { -webkit-transform: translateX(19px); transform: translateX(19px); } } `; export const narrow_styles = html` `; ================================================ FILE: src/types.ts ================================================ import { PresetType } from './presets'; import { ActionConfig, LovelaceCardConfig } from './utils'; import { NavigateOptions } from './utils/hass-types/navigate'; declare global { interface HASSDomEvents { "action": { action: string }; "config-changed": { config: any }; "hass-more-info": { entityId: string }; "ll-custom": ActionConfig; "ll-rebuild": Record; "ll-upgrade": Record; "show-dialog": { dialogTag: string; dialogParams: unknown; dialogImport?: () => Promise; addHistory?: boolean }; "location-changed": NavigateOptions; } } export interface PDCConfig extends LovelaceCardConfig { title?: string; animation?: 'none' | 'flash' | 'slide'; entities: EntitySettings[]; center: center; } export interface EntitySettings extends presetFeatures { attribute?: string; arrow_color?: { bigger?: string; equal?: string; smaller?: string }; calc_excluded?: boolean; consumer?: boolean; color_threshold?: number; decimals?: number; display_abs?: boolean; double_tap_action?: ActionConfig; entity?: string; hide_arrows?: boolean; icon?: string; icon_color?: { bigger?: string; equal?: string; smaller?: string }; invert_value?: boolean; invert_arrow?: boolean; name?: string | undefined; preset?: PresetType; producer?: boolean; secondary_info_attribute?: string; secondary_info_decimals?: number; secondary_info_entity?: string; secondary_info_replace_name?: boolean; tap_action?: ActionConfig; threshold?: number; unit_of_display?: string; unit_of_measurement?: string; } export interface center { type: 'none' | 'card' | 'bars'; bars?: BarSettings[]; card?: LovelaceCardConfig; } export interface presetFeatures { battery_percentage_entity?: string; grid_sell_entity?: string; grid_buy_entity?: string; } export interface BarSettings { bar_color?: string; bar_bg_color?: string; entity?: string; invert_value?: boolean; lower_bound?: number; name?: string | undefined; preset?: 'autarky' | 'ratio' | ''; tap_action?: ActionConfig; unit_of_measurement?: string; upper_bound?: number; double_tap_action?: ActionConfig; } export type ArrowStates = 'right' | 'left' | 'none'; export interface Target extends EventTarget { checked?: boolean; configValue?: string; i?: number; value?: string | EntitySettings[] | BarSettings[] | { bigger: string; equal: string; smaller: string }; } export interface CustomValueEvent extends Event { target: Target; // currentTarget?: { // i?: number; // value?: string; // }; detail?: { value?: T; }; } export interface EditorTarget extends EventTarget { value?: string; index?: number; checked?: boolean; configValue?: string; type?: HTMLInputElement['type']; config?: ActionConfig; } export interface HTMLElementValue extends HTMLElement { value: string; } declare global { interface Window { loadCardHelpers: () => Promise; customCards: { type?: string; name?: string; description?: string; preview?: boolean }[]; ResizeObserver: { new (callback: ResizeObserverCallback): ResizeObserver; prototype: ResizeObserver }; } interface Element { offsetWidth: number; } } ================================================ FILE: src/utils/compute-color.ts ================================================ export const THEME_COLORS = new Set([ "primary", "accent", "red", "pink", "purple", "deep-purple", "indigo", "blue", "light-blue", "cyan", "teal", "green", "light-green", "lime", "yellow", "amber", "orange", "deep-orange", "brown", "light-grey", "grey", "dark-grey", "blue-grey", "black", "white", ]); const YAML_ONLY_THEMES_COLORS = new Set([ "primary-text", "secondary-text", "disabled", ]); export function computeCssVariableName(color: string): string { if (THEME_COLORS.has(color) || YAML_ONLY_THEMES_COLORS.has(color)) { return `--${color}-color`; } return color; } export function computeCssColor(color: string): string { const cssVarName = computeCssVariableName(color); if (cssVarName !== color) { return `var(${cssVarName})`; } return color; } /** * Validates if a string is a valid color. * Accepts: hex colors (#xxx, #xxxxxx), theme colors, and valid CSS color names. */ export function isValidColorString(color: string | undefined): boolean { if (!color || typeof color !== "string") { return false; } // Check if it's a theme color if (THEME_COLORS.has(color)) { return true; } // Check if it's a hex color if (/^#([0-9A-Fa-f]{3}){1,2}$/.test(color)) { return true; } // Check if it's a valid CSS color name by trying to parse it // Use CSS.supports() for a more efficient test without DOM manipulation // This checks if the browser recognizes the color value try { const style = new Option().style; style.color = color; return style.color !== ""; } catch { return false; } } ================================================ FILE: src/utils/create-thing.ts ================================================ import { fireEvent } from "./hass-types/fire_event"; import type { LovelaceCardConfig } from "./hass-types/lovelace"; const TIMEOUT = 2000; const _createErrorCardElement = (error: string, config: LovelaceCardConfig) => { const el = document.createElement("hui-error-card") as any; try { el.setConfig({ type: "error", error, config }); } catch (_err) { // ignore } return el; }; const _createElement = (tag: string, config: LovelaceCardConfig) => { const element = document.createElement(tag) as any; try { element.setConfig(config); } catch (err) { console.error(tag, err); return _createErrorCardElement((err as Error).message, config); } return element; }; export const createThing = (cardConfig: LovelaceCardConfig) => { if (!cardConfig || typeof cardConfig !== "object" || !cardConfig.type) { return _createErrorCardElement("No type defined", cardConfig); } const { type } = cardConfig; if (type.startsWith("custom:")) { const tag = type.slice("custom:".length); if (customElements.get(tag)) { return _createElement(tag, cardConfig); } const element = _createErrorCardElement( `Custom element doesn't exist: ${tag}.`, cardConfig ); element.style.display = "None"; const timer = window.setTimeout(() => { element.style.display = ""; }, TIMEOUT); customElements.whenDefined(tag).then(() => { clearTimeout(timer); fireEvent(element, "ll-rebuild"); }); return element; } const tag = `hui-${type}-card`; if (customElements.get(tag)) { return _createElement(tag, cardConfig); } const element = _createErrorCardElement( `Unknown card type: ${type}.`, cardConfig ); element.style.display = "None"; const timer = window.setTimeout(() => { element.style.display = ""; }, TIMEOUT); customElements.whenDefined(tag).then(() => { clearTimeout(timer); fireEvent(element, "ll-rebuild"); }); return element; }; ================================================ FILE: src/utils/custom-cards.ts ================================================ import { repository } from "../../package.json"; export function registerCustomCard(type: string, name: string, description: string): void { const windowWithCards = window as unknown as Window & { customCards: unknown[]; }; windowWithCards.customCards = windowWithCards.customCards || []; windowWithCards.customCards.push({ type, name, description, preview: true, documentationURL: `${repository.url}/readme.md`, }); } ================================================ FILE: src/utils/debounce.ts ================================================ // From: src/common/util/debounce.ts https://raw.githubusercontent.com/home-assistant/frontend/446661915bbfd74b119176076d6d5f6ae7e392fa/src/common/util/debounce.ts // Returns a function, that, as long as it continues to be invoked, will not // be triggered. The function will be called after it stops being called for // N milliseconds. If `immediate` is passed, trigger the function on the // leading edge. The trailing edge only fires if there were additional calls // during the wait period. export const debounce = ( func: (...args: T) => void, wait: number, immediate = false ) => { let timeout: number | undefined; let trailingArgs: T | undefined; const debouncedFunc = (...args: T): void => { const isLeading = immediate && !timeout; if (timeout) { trailingArgs = args; } clearTimeout(timeout); timeout = window.setTimeout(() => { timeout = undefined; if (trailingArgs) { func(...trailingArgs); trailingArgs = undefined; } else if (!immediate) { func(...args); } }, wait); if (isLeading) { func(...args); } }; debouncedFunc.cancel = () => { clearTimeout(timeout); trailingArgs = undefined; }; return debouncedFunc; }; ================================================ FILE: src/utils/get-lovelace.ts ================================================ export const getLovelace = () => { const root = document .querySelector("home-assistant") ?.shadowRoot?.querySelector("home-assistant-main")?.shadowRoot; const resolver = root?.querySelector("ha-drawer partial-panel-resolver") || root?.querySelector("app-drawer-layout partial-panel-resolver"); const huiRoot = (((resolver as any)?.shadowRoot || resolver) as any) ?.querySelector("ha-panel-lovelace") ?.shadowRoot?.querySelector("hui-root"); if (huiRoot) { const ll = huiRoot.lovelace; ll.current_view = huiRoot.___curView; return ll; } return null; }; ================================================ FILE: src/utils/ha-component-loader.ts ================================================ export const loadHaComponents = () => { if (!customElements.get("ha-entity-picker")) { loadCustomElement("hui-entities-card").then((el: any) => el?.getConfigElement()); } }; export const loadCustomElement = async (name: string) => { let Component = customElements.get(name) as T; if (Component) { return Component; } await customElements.whenDefined(name); return customElements.get(name) as T; }; ================================================ FILE: src/utils/hass-types/action.ts ================================================ /** * Types are from the original Homeassistant Repository: * https://github.com/home-assistant/frontend/blob/dev/src/data/lovelace/config/action.ts */ import type { HassServiceTarget } from "home-assistant-js-websocket"; export interface ToggleActionConfig extends BaseActionConfig { action: "toggle"; } export interface CallServiceActionConfig extends BaseActionConfig { action: "call-service" | "perform-action"; /** @deprecated "service" is kept for backwards compatibility. Replaced by "perform_action". */ service?: string; perform_action: string; target?: HassServiceTarget; /** @deprecated "service_data" is kept for backwards compatibility. Replaced by "data". */ service_data?: Record; data?: Record; } export interface NavigateActionConfig extends BaseActionConfig { action: "navigate"; navigation_path: string; navigation_replace?: boolean; } export interface UrlActionConfig extends BaseActionConfig { action: "url"; url_path: string; } export interface MoreInfoActionConfig extends BaseActionConfig { action: "more-info"; entity?: string; } export interface AssistActionConfig extends BaseActionConfig { action: "assist"; pipeline_id?: string; start_listening?: boolean; } export interface NoActionConfig extends BaseActionConfig { action: "none"; } export interface CustomActionConfig extends BaseActionConfig { action: "fire-dom-event"; } export interface BaseActionConfig { action: string; confirmation?: ConfirmationRestrictionConfig; } export interface ConfirmationRestrictionConfig { text?: string; exemptions?: RestrictionConfig[]; } export interface RestrictionConfig { user: string; } export type ActionConfig = | ToggleActionConfig | CallServiceActionConfig | NavigateActionConfig | UrlActionConfig | MoreInfoActionConfig | AssistActionConfig | NoActionConfig | CustomActionConfig; export interface ActionConfigParams { entity?: string; camera_image?: string; image_entity?: string; hold_action?: ActionConfig; tap_action?: ActionConfig; double_tap_action?: ActionConfig; } export type IntegrationType = | "device" | "helper" | "hub" | "service" | "hardware" | "entity" | "system"; ================================================ FILE: src/utils/hass-types/action_handler.ts ================================================ /** * Types are from the original Homeassistant Repository: * https://github.com/home-assistant/frontend/blob/dev/src/data/lovelace/action_handler.ts */ import { HASSDomEvent } from "./event"; export interface ActionHandlerOptions { hasTap?: boolean; hasHold?: boolean; hasDoubleClick?: boolean; disabled?: boolean; } export interface ActionHandlerDetail { action: "hold" | "tap" | "double_tap"; } export type ActionHandlerEvent = HASSDomEvent; ================================================ FILE: src/utils/hass-types/event.ts ================================================ /** * Types are from the original Homeassistant Repository: * https://github.com/home-assistant/frontend/blob/dev/src/common/dom/fire_event.ts */ declare global { interface HASSDomEvents {} } export type ValidHassDomEvent = keyof HASSDomEvents; export interface HASSDomEvent extends Event { detail: T; } ================================================ FILE: src/utils/hass-types/fire_event.ts ================================================ // Polymer legacy event helpers used courtesy of the Polymer project. // // Copyright (c) 2017 The Polymer Authors. All rights reserved. // // Redistribution and use in source and binary forms, with or without // modification, are permitted provided that the following conditions are // met: // // * Redistributions of source code must retain the above copyright // notice, this list of conditions and the following disclaimer. // * Redistributions in binary form must reproduce the above // copyright notice, this list of conditions and the following disclaimer // in the documentation and/or other materials provided with the // distribution. // * Neither the name of Google Inc. nor the names of its // contributors may be used to endorse or promote products derived from // this software without specific prior written permission. // // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS // "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT // LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR // A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT // OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, // SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT // LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, // DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY // THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT // (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE // OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. declare global { interface HASSDomEvents {} } export type ValidHassDomEvent = keyof HASSDomEvents; export interface HASSDomEvent extends Event { detail: T; } /** * Dispatches a custom event with an optional detail value. * * @param {string} type Name of event type. * @param {*=} detail Detail value containing event-specific * payload. * @param {{ bubbles: (boolean|undefined), * cancelable: (boolean|undefined), * composed: (boolean|undefined) }=} * options Object specifying options. These may include: * `bubbles` (boolean, defaults to `true`), * `cancelable` (boolean, defaults to false), and * `node` on which to fire the event (HTMLElement, defaults to `this`). * @return {Event} The new event that was fired. */ export const fireEvent = ( node: HTMLElement | Window, type: HassEvent, detail?: HASSDomEvents[HassEvent], options?: { bubbles?: boolean; cancelable?: boolean; composed?: boolean; } ) => { options = options || {}; // @ts-ignore detail = detail === null || detail === undefined ? {} : detail; const event = new Event(type, { bubbles: options.bubbles === undefined ? true : options.bubbles, cancelable: Boolean(options.cancelable), composed: options.composed === undefined ? true : options.composed, }); (event as any).detail = detail; node.dispatchEvent(event); return event; }; ================================================ FILE: src/utils/hass-types/format-number.ts ================================================ import { shouldPolyfill } from "@formatjs/intl-numberformat/should-polyfill.js"; import { FrontendLocaleData, NumberFormat } from "./homeassistant"; export async function applyPolyfills(): Promise { if (shouldPolyfill()) { await import("@formatjs/intl-numberformat/polyfill-force.js"); } } export const numberFormatToLocale = ( localeOptions: FrontendLocaleData ): string | string[] | undefined => { switch (localeOptions.number_format) { case NumberFormat.comma_decimal: return ["en-US", "en"]; // Use United States with fallback to English formatting 1,234,567.89 case NumberFormat.decimal_comma: return ["de", "es", "it"]; // Use German with fallback to Spanish then Italian formatting 1.234.567,89 case NumberFormat.space_comma: return ["fr", "sv", "cs"]; // Use French with fallback to Swedish and Czech formatting 1 234 567,89 case NumberFormat.quote_decimal: return ["de-CH"]; // Use German (Switzerland) formatting 1'234'567.89 case NumberFormat.system: return undefined; default: return localeOptions.language; } }; /** * Formats a number based on the user's preference with thousands separator(s) and decimal character for better legibility. * * @param num The number to format * @param localeOptions The user-selected language and formatting, from `hass.locale` * @param options Intl.NumberFormatOptions to use */ export const formatNumber = ( num: string | number, localeOptions?: FrontendLocaleData, options?: Intl.NumberFormatOptions ): string => formatNumberToParts(num, localeOptions, options) .map((part) => part.value) .join(""); /** * Returns an array of objects containing the formatted number in parts * Similar to Intl.NumberFormat.prototype.formatToParts() * * Input params - same as for formatNumber() */ export const formatNumberToParts = ( num: string | number, localeOptions?: FrontendLocaleData, options?: Intl.NumberFormatOptions ): any[] => { const locale = localeOptions ? numberFormatToLocale(localeOptions) : undefined; if ( localeOptions?.number_format !== NumberFormat.none && !Number.isNaN(Number(num)) ) { return new Intl.NumberFormat( locale, getDefaultFormatOptions(num, options) ).formatToParts(Number(num)); } if ( !Number.isNaN(Number(num)) && num !== "" && localeOptions?.number_format === NumberFormat.none ) { // If NumberFormat is none, use en-US format without grouping. return new Intl.NumberFormat( "en-US", getDefaultFormatOptions(num, { ...options, useGrouping: false, }) ).formatToParts(Number(num)); } return [{ type: "literal", value: num }]; }; /** * Generates default options for Intl.NumberFormat * @param num The number to be formatted * @param options The Intl.NumberFormatOptions that should be included in the returned options */ export const getDefaultFormatOptions = ( num: string | number, options?: Intl.NumberFormatOptions ): Intl.NumberFormatOptions => { const defaultOptions: Intl.NumberFormatOptions = { maximumFractionDigits: 2, ...options, }; if (typeof num !== "string") { return defaultOptions; } // Keep decimal trailing zeros if they are present in a string numeric value if ( !options || (options.minimumFractionDigits === undefined && options.maximumFractionDigits === undefined) ) { const digits = num.indexOf(".") > -1 ? num.split(".")[1].length : 0; defaultOptions.minimumFractionDigits = digits; defaultOptions.maximumFractionDigits = digits; } return defaultOptions; }; ================================================ FILE: src/utils/hass-types/get_main_window.ts ================================================ // From: https://github.com/home-assistant/frontend/blob/dev/src/data/main_window.ts export const MAIN_WINDOW_NAME = "ha-main-window"; // From: https://github.com/home-assistant/frontend/blob/dev/src/common/dom/get_main_window.ts export const mainWindow = (() => { try { return window.name === MAIN_WINDOW_NAME ? window : parent.name === MAIN_WINDOW_NAME ? parent : top!; } catch { return window; } })(); ================================================ FILE: src/utils/hass-types/handle-action.ts ================================================ import { ActionConfig } from "./action"; import { fireEvent } from "./fire_event"; import { forwardHaptic } from "./haptics"; import { HomeAssistant } from "./homeassistant"; import { domainToName } from "./integration"; import { navigate } from "./navigate"; import { showConfirmationDialog } from "./show-dialog-box"; import { showVoiceCommandDialog } from "./show-ha-voice-command-dialog"; import { showToast } from "./toast"; import { toggleEntity } from "./toggle-entity"; export interface ActionConfigParams { entity?: string; camera_image?: string; image_entity?: string; hold_action?: ActionConfig; tap_action?: ActionConfig; double_tap_action?: ActionConfig; } export const handleAction = async ( node: HTMLElement, hass: HomeAssistant, config: ActionConfigParams, action: string ): Promise => { let actionConfig: ActionConfig | undefined; if (action === "double_tap" && config.double_tap_action) { actionConfig = config.double_tap_action; } else if (action === "hold" && config.hold_action) { actionConfig = config.hold_action; } else if (action === "tap" && config.tap_action) { actionConfig = config.tap_action; } if (!actionConfig) { actionConfig = { action: "more-info", }; } if ( actionConfig.confirmation && (!actionConfig.confirmation.exemptions || !actionConfig.confirmation.exemptions.some( (e) => e.user === hass!.user?.id )) ) { forwardHaptic(node, "warning"); let serviceName; if ( actionConfig.action === "call-service" || actionConfig.action === "perform-action" ) { const [domain, service] = (actionConfig.perform_action || actionConfig.service)!.split(".", 2); const serviceDomains = hass.services; if (domain in serviceDomains && service in serviceDomains[domain]) { await hass.loadBackendTranslation("title"); const localize = await hass.loadBackendTranslation("services"); serviceName = `${domainToName(localize, domain)}: ${ localize(`component.${domain}.services.${serviceName}.name`) || serviceDomains[domain][service].name || service }`; } } if ( !(await showConfirmationDialog(node, { text: actionConfig.confirmation.text || hass.localize("ui.panel.lovelace.cards.actions.action_confirmation", { action: serviceName || hass.localize( `ui.panel.lovelace.editor.action-editor.actions.${actionConfig.action}` ) || actionConfig.action, }), })) ) { return; } } switch (actionConfig.action) { case "more-info": { const entityId = actionConfig.entity || config.entity || config.camera_image || config.image_entity; if (entityId) { fireEvent(node, "hass-more-info", { entityId }); } else { showToast(node, { message: hass.localize( "ui.panel.lovelace.cards.actions.no_entity_more_info" ), }); forwardHaptic(node, "failure"); } break; } case "navigate": if (actionConfig.navigation_path) { navigate(actionConfig.navigation_path, { replace: actionConfig.navigation_replace, }); } else { showToast(node, { message: hass.localize( "ui.panel.lovelace.cards.actions.no_navigation_path" ), }); forwardHaptic(node, "failure"); } break; case "url": { if (actionConfig.url_path) { window.open(actionConfig.url_path); } else { showToast(node, { message: hass.localize("ui.panel.lovelace.cards.actions.no_url"), }); forwardHaptic(node, "failure"); } break; } case "toggle": { if (config.entity) { toggleEntity(hass, config.entity!); forwardHaptic(node, "light"); } else { showToast(node, { message: hass.localize( "ui.panel.lovelace.cards.actions.no_entity_toggle" ), }); forwardHaptic(node, "failure"); } break; } case "perform-action": case "call-service": { if (!actionConfig.perform_action && !actionConfig.service) { showToast(node, { message: hass.localize("ui.panel.lovelace.cards.actions.no_action"), }); forwardHaptic(node, "failure"); return; } const [domain, service] = (actionConfig.perform_action || actionConfig.service)!.split(".", 2); hass.callService( domain, service, actionConfig.data ?? actionConfig.service_data, actionConfig.target ); forwardHaptic(node, "light"); break; } case "assist": { showVoiceCommandDialog(node, hass, { start_listening: actionConfig.start_listening ?? false, pipeline_id: actionConfig.pipeline_id ?? "last_used", }); break; } case "fire-dom-event": { fireEvent(node, "ll-custom", actionConfig); } }}; ================================================ FILE: src/utils/hass-types/haptics.ts ================================================ /** * Broadcast haptic feedback requests */ import { HASSDomEvent } from "./event"; import { fireEvent } from "./fire_event"; // Allowed types are from iOS HIG. // https://developer.apple.com/design/human-interface-guidelines/ios/user-interaction/feedback/#haptics // Implementors on platforms other than iOS should attempt to match the patterns (shown in HIG) as closely as possible. export type HapticType = | "success" | "warning" | "failure" | "light" | "medium" | "heavy" | "selection"; declare global { // for fire event interface HASSDomEvents { haptic: HapticType; } interface GlobalEventHandlersEventMap { haptic: HASSDomEvent; } } export const forwardHaptic = (node: HTMLElement, hapticType: HapticType) => { fireEvent(node, "haptic", hapticType); }; ================================================ FILE: src/utils/hass-types/has-action.ts ================================================ // From: https://github.com/home-assistant/frontend/blob/dev/src/panels/lovelace/common/has-action.ts import { ActionConfig } from "./action"; export function hasAction(config?: ActionConfig): boolean { return config !== undefined && config.action !== "none"; } ================================================ FILE: src/utils/hass-types/homeassistant.ts ================================================ /** * This contains the typings for the hass Homeassistant object from various sources. * The main file is: * https://github.com/home-assistant/frontend/blob/dev/src/types.ts */ import type { Auth, Connection, HassConfig, HassEntities, HassEntity, HassServices, HassServiceTarget, MessageBase, } from "home-assistant-js-websocket"; import { HTMLTemplateResult } from "lit"; type EntityCategory = "config" | "diagnostic"; export interface EntityRegistryDisplayEntry { entity_id: string; name?: string; icon?: string; device_id?: string; area_id?: string; labels: string[]; hidden?: boolean; entity_category?: EntityCategory; translation_key?: string; platform?: string; display_precision?: number; has_entity_name?: boolean; } export interface RegistryEntry { created_at: number; modified_at: number; } export interface DeviceRegistryEntry extends RegistryEntry { id: string; config_entries: string[]; config_entries_subentries: Record; connections: [string, string][]; identifiers: [string, string][]; manufacturer: string | null; model: string | null; model_id: string | null; name: string | null; labels: string[]; sw_version: string | null; hw_version: string | null; serial_number: string | null; via_device_id: string | null; area_id: string | null; name_by_user: string | null; entry_type: "service" | null; disabled_by: "user" | "integration" | "config_entry" | null; configuration_url: string | null; primary_config_entry: string | null; } export interface AreaRegistryEntry extends RegistryEntry { aliases: string[]; area_id: string; floor_id: string | null; humidity_entity_id: string | null; icon: string | null; labels: string[]; name: string; picture: string | null; temperature_entity_id: string | null; } export interface FloorRegistryEntry extends RegistryEntry { floor_id: string; name: string; level: number | null; icon: string | null; aliases: string[]; } export interface ThemeVars { // Incomplete "primary-color": string; "text-primary-color": string; "accent-color": string; [key: string]: string; } export type Theme = ThemeVars & { modes?: { light?: ThemeVars; dark?: ThemeVars; }; }; export interface Themes { default_theme: string; default_dark_theme: string | null; themes: Record; // Currently effective dark mode. Will never be undefined. If user selected "auto" // in theme picker, this property will still contain either true or false based on // what has been determined via system preferences and support from the selected theme. darkMode: boolean; // Currently globally active theme name theme: string; } export enum NumberFormat { language = "language", system = "system", comma_decimal = "comma_decimal", decimal_comma = "decimal_comma", quote_decimal = "quote_decimal", space_comma = "space_comma", none = "none", } export enum TimeFormat { language = "language", system = "system", am_pm = "12", twenty_four = "24", } export enum TimeZone { local = "local", server = "server", } export enum DateFormat { language = "language", system = "system", DMY = "DMY", MDY = "MDY", YMD = "YMD", } export enum FirstWeekday { language = "language", monday = "monday", tuesday = "tuesday", wednesday = "wednesday", thursday = "thursday", friday = "friday", saturday = "saturday", sunday = "sunday", } export interface FrontendLocaleData { language: string; number_format: NumberFormat; time_format: TimeFormat; date_format: DateFormat; first_weekday: FirstWeekday; time_zone: TimeZone; } export type LocalizeFunc = ( key: string, values?: Record< string, string | number | HTMLTemplateResult | null | undefined > ) => string; export interface ValueChangedEvent extends CustomEvent { detail: { value: T; }; } export type Constructor = new (...args: any[]) => T; export interface ClassElement { kind: "field" | "method"; key: PropertyKey; placement: "static" | "prototype" | "own"; initializer?: (...args) => unknown; extras?: ClassElement[]; finisher?: (cls: Constructor) => undefined | Constructor; descriptor?: PropertyDescriptor; } export interface Credential { auth_provider_type: string; auth_provider_id: string; } export interface MFAModule { id: string; name: string; enabled: boolean; } export interface CurrentUser { id: string; is_owner: boolean; is_admin: boolean; name: string; credentials: Credential[]; mfa_modules: MFAModule[]; } // Currently selected theme and its settings. These are the values stored in local storage. // Note: These values are not meant to be used at runtime to check whether dark mode is active // or which theme name to use, as this interface represents the config data for the theme picker. // The actually active dark mode and theme name can be read from hass.themes. export interface ThemeSettings { theme: string; // Radio box selection for theme picker. Do not use in Lovelace rendering as // it can be undefined == auto. // Property hass.themes.darkMode carries effective current mode. dark?: boolean; primaryColor?: string; accentColor?: string; } export interface PanelInfo | null> { component_name: string; config: T; icon: string | null; title: string | null; url_path: string; config_panel_domain?: string; } export type Panels = Record; export interface CalendarViewChanged { end: Date; start: Date; view: string; } export type FullCalendarView = | "dayGridMonth" | "dayGridWeek" | "dayGridDay" | "listWeek"; export type ThemeMode = "auto" | "light" | "dark"; export interface ToggleButton { label: string; iconPath?: string; value: string; } export interface Translation { nativeName: string; isRTL: boolean; hash: string; } export interface TranslationMetadata { fragments: string[]; translations: Record; } export interface IconMetaFile { version: string; parts: IconMeta[]; } export interface IconMeta { start: string; file: string; } export interface Notification { notification_id: string; message: string; title: string; status: "read" | "unread"; created_at: string; } export type Resources = Record>; export interface Context { id: string; parent_id?: string; user_id?: string | null; } export interface ServiceCallResponse { context: Context; response?: T; } export interface ServiceCallRequest { domain: string; service: string; serviceData?: Record; target?: HassServiceTarget; } export interface CoreFrontendUserData { showAdvanced?: boolean; showEntityIdPicker?: boolean; } export type TranslationCategory = | "title" | "state" | "entity" | "entity_component" | "exceptions" | "config" | "config_subentries" | "config_panel" | "options" | "device_automation" | "mfa_setup" | "system_health" | "application_credentials" | "issues" | "selector" | "services"; export const getHassTranslations = async ( hass: HomeAssistant, language: string, category: TranslationCategory, integration?: string | string[], config_flow?: boolean ): Promise> => { const result = await hass.callWS<{ resources: Record }>({ type: "frontend/get_translations", language, category, integration, config_flow, }); return result.resources; }; export interface HomeAssistant { auth: Auth & { external?: any }; connection: Connection; connected: boolean; states: HassEntities; entities: Record; devices: Record; areas: Record; floors: Record; services: HassServices; config: HassConfig; themes: Themes; selectedTheme: ThemeSettings | null; panels: Panels; panelUrl: string; // i18n // current effective language in that order: // - backend saved user selected language // - language in local app storage // - browser language // - english (en) language: string; // local stored language, keep that name for backward compatibility selectedLanguage: string | null; locale: FrontendLocaleData; resources: Resources; localize: LocalizeFunc; translationMetadata: TranslationMetadata; suspendWhenHidden: boolean; enableShortcuts: boolean; vibrate: boolean; debugConnection: boolean; dockedSidebar: "docked" | "always_hidden" | "auto"; defaultPanel: string; moreInfoEntityId: string | null; user?: CurrentUser; userData?: CoreFrontendUserData | null; hassUrl(path?): string; callService( domain: ServiceCallRequest["domain"], service: ServiceCallRequest["service"], serviceData?: ServiceCallRequest["serviceData"], target?: ServiceCallRequest["target"], notifyOnError?: boolean, returnResponse?: boolean ): Promise>; callApi( method: "GET" | "POST" | "PUT" | "DELETE", path: string, parameters?: Record, headers?: Record ): Promise; callApiRaw( // introduced in 2024.11 method: "GET" | "POST" | "PUT" | "DELETE", path: string, parameters?: Record, headers?: Record, signal?: AbortSignal ): Promise; fetchWithAuth(path: string, init?: Record): Promise; sendWS(msg: MessageBase): void; callWS(msg: MessageBase): Promise; loadBackendTranslation( category: Parameters[2], integrations?: Parameters[3], configFlow?: Parameters[4] ): Promise; loadFragmentTranslation(fragment: string): Promise; formatEntityState(stateObj: HassEntity, state?: string): string; formatEntityAttributeValue( stateObj: HassEntity, attribute: string, value?: any ): string; formatEntityAttributeName(stateObj: HassEntity, attribute: string): string; } ================================================ FILE: src/utils/hass-types/integration.ts ================================================ // From: https://github.com/home-assistant/frontend/blob/dev/src/data/integration.ts import { LocalizeFunc } from "./homeassistant"; export type IntegrationType = | "device" | "helper" | "hub" | "service" | "hardware" | "entity" | "system"; export interface IntegrationManifest { is_built_in: boolean; overwrites_built_in?: boolean; domain: string; name: string; config_flow: boolean; documentation: string; issue_tracker?: string; dependencies?: string[]; after_dependencies?: string[]; codeowners?: string[]; requirements?: string[]; ssdp?: { manufacturer?: string; modelName?: string; st?: string }[]; zeroconf?: string[]; homekit?: { models: string[] }; integration_type?: IntegrationType; loggers?: string[]; quality_scale?: | "bronze" | "silver" | "gold" | "platinum" | "no_score" | "internal" | "legacy" | "custom"; iot_class: | "assumed_state" | "cloud_polling" | "cloud_push" | "local_polling" | "local_push"; single_config_entry?: boolean; version?: string; } export const domainToName = ( localize: LocalizeFunc, domain: string, manifest?: IntegrationManifest ) => localize(`component.${domain}.title`) || manifest?.name || domain; ================================================ FILE: src/utils/hass-types/localize.ts ================================================ export type LocalizeKeys = | FlattenObjectKeys> | `panel.${string}` | `ui.card.alarm_control_panel.${string}` | `ui.card.weather.attributes.${string}` | `ui.card.weather.cardinal_direction.${string}` | `ui.card.lawn_mower.actions.${string}` | `ui.common.${string}` | `ui.components.calendar.event.rrule.${string}` | `ui.components.selectors.file.${string}` | `ui.components.logbook.messages.detected_device_classes.${string}` | `ui.components.logbook.messages.cleared_device_classes.${string}` | `ui.dialogs.entity_registry.editor.${string}` | `ui.dialogs.more_info_control.lawn_mower.${string}` | `ui.dialogs.more_info_control.vacuum.${string}` | `ui.dialogs.quick-bar.commands.${string}` | `ui.dialogs.unhealthy.reasons.${string}` | `ui.dialogs.unsupported.reasons.${string}` | `ui.panel.config.${string}.${"caption" | "description"}` | `ui.panel.config.dashboard.${string}` | `ui.panel.config.storage.segments.${string}` | `ui.panel.config.zha.${string}` | `ui.panel.config.zwave_js.${string}` | `ui.panel.lovelace.card.${string}` | `ui.panel.lovelace.editor.${string}` | `ui.panel.page-authorize.form.${string}` | `component.${string}`; // Tweaked from https://www.raygesualdo.com/posts/flattening-object-keys-with-typescript-types export type FlattenObjectKeys< T extends Record, Key extends keyof T = keyof T, > = Key extends string ? T[Key] extends Record ? `${Key}.${FlattenObjectKeys}` : `${Key}` : never; export type TranslationDict = typeof import('../../localize/languages/en.json'); ================================================ FILE: src/utils/hass-types/lovelace.ts ================================================ import { HomeAssistant } from "./homeassistant"; export type Condition = | LocationCondition | NumericStateCondition | StateCondition | ScreenCondition | UserCondition | OrCondition | AndCondition | NotCondition; // Legacy conditional card condition export interface LegacyCondition { entity?: string; state?: string | string[]; state_not?: string | string[]; } interface BaseCondition { condition: string; } export interface LocationCondition extends BaseCondition { condition: "location"; locations?: string[]; } export interface NumericStateCondition extends BaseCondition { condition: "numeric_state"; entity?: string; below?: string | number; above?: string | number; } export interface StateCondition extends BaseCondition { condition: "state"; entity?: string; state?: string | string[]; state_not?: string | string[]; } export interface ScreenCondition extends BaseCondition { condition: "screen"; media_query?: string; } export interface UserCondition extends BaseCondition { condition: "user"; users?: string[]; } export interface OrCondition extends BaseCondition { condition: "or"; conditions?: Condition[]; } export interface AndCondition extends BaseCondition { condition: "and"; conditions?: Condition[]; } export interface NotCondition extends BaseCondition { condition: "not"; conditions?: Condition[]; } export interface LovelaceCardConfig { index?: number; view_index?: number; view_layout?: any; /** @deprecated Use `grid_options` instead */ layout_options?: LovelaceLayoutOptions; grid_options?: LovelaceGridOptions; type: string; [key: string]: any; visibility?: Condition[]; } export interface LovelaceLayoutOptions { grid_columns?: number | "full"; grid_rows?: number | "auto"; grid_max_columns?: number; grid_min_columns?: number; grid_min_rows?: number; grid_max_rows?: number; } export interface LovelaceGridOptions { columns?: number | "full"; rows?: number | "auto"; max_columns?: number; min_columns?: number; min_rows?: number; max_rows?: number; fixed_rows?: boolean; fixed_columns?: boolean; } export interface LovelaceCard extends HTMLElement { hass?: HomeAssistant; preview?: boolean; layout?: string; connectedWhileHidden?: boolean; getCardSize(): number | Promise; /** @deprecated Use `getGridOptions` instead */ getLayoutOptions?(): LovelaceLayoutOptions; getGridOptions?(): LovelaceGridOptions; setConfig(config: LovelaceCardConfig): void; } ================================================ FILE: src/utils/hass-types/navigate.ts ================================================ // Partially from https://github.com/home-assistant/frontend/blob/dev/src/common/navigate.ts import { fireEvent } from "./fire_event"; import { mainWindow } from "./get_main_window"; export interface NavigateOptions { replace?: boolean; data?: any; } export const navigate = ( path: string, options?: NavigateOptions, ) => { const replace = options?.replace || false; if (replace) { history.replaceState( history.state?.root ? { root: true } : (options?.data ?? null), "", `${mainWindow.location.pathname}#${path}` ); } else { history.pushState(null, "", path); } fireEvent(window, "location-changed", { replace }); }; ================================================ FILE: src/utils/hass-types/show-dialog-box.ts ================================================ // Derived From: https://github.com/home-assistant/frontend/blob/dev/src/dialogs/generic/show-dialog-box.ts import { TemplateResult } from "lit"; import { fireEvent } from "./fire_event"; interface BaseDialogBoxParams { confirmText?: string; text?: string | TemplateResult; title?: string; warning?: boolean; } export interface AlertDialogParams extends BaseDialogBoxParams { confirm?: () => void; } export interface ConfirmationDialogParams extends BaseDialogBoxParams { dismissText?: string; confirm?: () => void; cancel?: () => void; destructive?: boolean; } export interface PromptDialogParams extends BaseDialogBoxParams { inputLabel?: string; dismissText?: string; inputType?: string; defaultValue?: string; placeholder?: string; confirm?: (out?: string) => void; cancel?: () => void; inputMin?: number | string; inputMax?: number | string; } export interface DialogBoxParams extends ConfirmationDialogParams, PromptDialogParams { confirm?: (out?: string) => void; confirmation?: boolean; prompt?: boolean; } const showDialogHelper = ( element: HTMLElement, dialogParams: DialogBoxParams, extra?: { confirmation?: DialogBoxParams["confirmation"]; prompt?: DialogBoxParams["prompt"]; } ) => new Promise((resolve) => { const origCancel = dialogParams.cancel; const origConfirm = dialogParams.confirm; fireEvent(element, "show-dialog", { dialogTag: "dialog-box", dialogParams: { ...dialogParams, ...extra, cancel: () => { resolve(extra?.prompt ? null : false); if (origCancel) { origCancel(); } }, confirm: (out) => { resolve(extra?.prompt ? out : true); if (origConfirm) { origConfirm(out); } }, }, }); }); export const showConfirmationDialog = ( element: HTMLElement, dialogParams: ConfirmationDialogParams ) => showDialogHelper(element, dialogParams, { confirmation: true, }) as Promise; ================================================ FILE: src/utils/hass-types/show-ha-voice-command-dialog.ts ================================================ import { fireEvent } from "./fire_event"; import type { HomeAssistant } from "./homeassistant"; export interface VoiceCommandDialogParams { pipeline_id: "last_used" | "preferred" | string; start_listening?: boolean; } export const showVoiceCommandDialog = ( element: HTMLElement, hass: HomeAssistant, dialogParams: VoiceCommandDialogParams ): void => { if (hass.auth.external?.config.hasAssist) { hass.auth.external!.fireMessage({ type: "assist/show", payload: { pipeline_id: dialogParams.pipeline_id, // Start listening by default for app start_listening: dialogParams.start_listening ?? true, }, }); return; } fireEvent(element, "show-dialog", { dialogTag: "ha-voice-command-dialog", dialogParams: { pipeline_id: dialogParams.pipeline_id, // Don't start listening by default for web start_listening: dialogParams.start_listening ?? false, }, }); }; ================================================ FILE: src/utils/hass-types/toast.ts ================================================ import { fireEvent } from "./fire_event"; declare global { // for fire event interface HASSDomEvents { "hass-notification": ShowToastParams; } } export interface ShowToastParams { // Unique ID for the toast. If a new toast is shown with the same ID as the previous toast, it will be replaced to avoid flickering. id?: string; message: | string | { translationKey: string; args?: Record }; action?: ToastActionParams; duration?: number; dismissable?: boolean; } export interface ToastActionParams { action: () => void; text: | string | { translationKey: string; args?: Record }; } export const showToast = (el: HTMLElement, params: ShowToastParams) => fireEvent(el, "hass-notification", params); ================================================ FILE: src/utils/hass-types/toggle-entity.ts ================================================ import { HomeAssistant, ServiceCallResponse } from "./homeassistant"; // From: https://github.com/home-assistant/frontend/blob/dev/src/panels/lovelace/common/entity/toggle-entity.ts /** States that we consider "off". */ export const STATES_OFF = ["closed", "locked", "off"]; export const toggleEntity = ( hass: HomeAssistant, entityId: string ): Promise => { const turnOn = STATES_OFF.includes(hass.states[entityId].state); return turnOnOffEntity(hass, entityId, turnOn); }; // From: https://github.com/home-assistant/frontend/blob/dev/src/panels/lovelace/common/entity/turn-on-off-entity.ts export const turnOnOffEntity = ( hass: HomeAssistant, entityId: string, turnOn = true ): Promise => { const stateDomain = computeDomain(entityId); const serviceDomain = stateDomain === "group" ? "homeassistant" : stateDomain; let service; switch (stateDomain) { case "lock": service = turnOn ? "unlock" : "lock"; break; case "cover": service = turnOn ? "open_cover" : "close_cover"; break; case "button": case "input_button": service = "press"; break; case "scene": service = "turn_on"; break; case "valve": service = turnOn ? "open_valve" : "close_valve"; break; default: service = turnOn ? "turn_on" : "turn_off"; } return hass.callService(serviceDomain, service, { entity_id: entityId }); }; // From: https://github.com/home-assistant/frontend/blob/dev/src/common/entity/compute_domain.ts export const computeDomain = (entityId: string): string => entityId.substring(0, entityId.indexOf(".")); ================================================ FILE: src/utils/index.ts ================================================ export * from './custom-cards'; export { createThing } from './create-thing'; export { getLovelace } from './get-lovelace'; export * from './hass-types/handle-action'; export * from './hass-types/has-action'; export * from './hass-types/action_handler'; export { ActionConfig } from './hass-types/action'; export * from './hass-types/event'; export * from './hass-types/homeassistant'; export * from './hass-types/lovelace'; export { fireEvent } from './hass-types/fire_event'; export { formatNumber, applyPolyfills } from './hass-types/format-number'; export function fireCustomEvent(node: HTMLElement | Window, type: string, detail: T): void { const event = new CustomEvent(type, { bubbles: false, composed: false, detail: detail }); node.dispatchEvent(event); } ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "target": "es2017", "module": "esnext", "moduleResolution": "Bundler", "lib": ["ES2021", "dom", "dom.iterable"], "noEmit": true, "noUnusedParameters": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, "strict": true, "noImplicitAny": false, "skipLibCheck": true, "resolveJsonModule": true, "esModuleInterop": true, "experimentalDecorators": true } }