├── .gitattributes ├── src ├── .vscode │ └── settings.json ├── dependencies │ ├── ha │ │ ├── common │ │ │ ├── entity │ │ │ │ ├── compute_state_display.ts │ │ │ │ ├── compute_domain.ts │ │ │ │ ├── compute_object_id.ts │ │ │ │ ├── compute_state_domain.ts │ │ │ │ ├── color │ │ │ │ │ └── battery_color.ts │ │ │ │ ├── supports-feature.ts │ │ │ │ ├── compute_state_name.ts │ │ │ │ └── battery_icon.ts │ │ │ ├── translations │ │ │ │ ├── localize.ts │ │ │ │ └── blank_before_percent.ts │ │ │ ├── number │ │ │ │ ├── round.ts │ │ │ │ ├── clamp.ts │ │ │ │ └── format_number.ts │ │ │ ├── util │ │ │ │ ├── render-status.ts │ │ │ │ ├── debounce.ts │ │ │ │ ├── compute_rtl.ts │ │ │ │ └── deep-equal.ts │ │ │ ├── const.ts │ │ │ ├── string │ │ │ │ ├── has-template.ts │ │ │ │ └── compare.ts │ │ │ ├── structs │ │ │ │ └── handle-errors.ts │ │ │ └── dom │ │ │ │ └── fire_event.ts │ │ ├── data │ │ │ ├── main_window.ts │ │ │ ├── ws-templates.ts │ │ │ ├── ws-themes.ts │ │ │ ├── translation.ts │ │ │ ├── entity.ts │ │ │ ├── entity_registry.ts │ │ │ └── lovelace.ts │ │ ├── util │ │ │ ├── is_touch.ts │ │ │ └── calculate.ts │ │ ├── panels │ │ │ └── lovelace │ │ │ │ ├── common │ │ │ │ ├── has-action.ts │ │ │ │ ├── validate-condition.ts │ │ │ │ ├── handle-actions.ts │ │ │ │ ├── entity │ │ │ │ │ ├── turn-on-off-entity.ts │ │ │ │ │ └── turn-on-off-entities.ts │ │ │ │ └── directives │ │ │ │ │ └── action-handler-directive.ts │ │ │ │ ├── editor │ │ │ │ └── structs │ │ │ │ │ ├── base-card-struct.ts │ │ │ │ │ └── action-struct.ts │ │ │ │ └── types.ts │ │ ├── index.ts │ │ ├── resources │ │ │ └── ha-sortable-styles.ts │ │ └── types.ts │ ├── calendar-card-pro │ │ ├── index.ts │ │ ├── README.md │ │ └── logger.ts │ ├── mushroom │ │ ├── utils │ │ │ ├── base-element.ts │ │ │ ├── custom-cards.ts │ │ │ ├── cache-manager.ts │ │ │ ├── loader.ts │ │ │ └── form │ │ │ │ ├── ha-form.ts │ │ │ │ └── ha-selector.ts │ │ ├── index.ts │ │ ├── README.md │ │ └── shared │ │ │ └── config │ │ │ └── actions-config.ts │ └── is-svg-path │ │ └── valid-svg-path.ts ├── utils │ ├── css │ │ └── valid-font-size.ts │ ├── color │ │ ├── computed-color.ts │ │ └── get-interpolated-color.ts │ ├── object │ │ ├── get-value.ts │ │ ├── delete-key.ts │ │ ├── move-key.ts │ │ └── set-value.ts │ ├── number │ │ ├── get-angle.ts │ │ ├── format-to-locale.ts │ │ └── numberUtils.ts │ ├── string │ │ └── icon.ts │ └── migrate-parameters.ts ├── card │ ├── css │ │ ├── card.ts │ │ └── gauge.ts │ ├── const.ts │ ├── _gradient-renderer.ts │ ├── _segments.ts │ └── config.ts ├── translations │ ├── en-GB.json │ └── en.json ├── localize.ts └── tests │ ├── utils │ ├── migrate.test.ts │ ├── icon.test.ts │ ├── numbers.test.ts │ ├── objects.test.ts │ └── colors.test.ts │ └── card │ ├── getLightDarkModeColor.test.ts │ └── segments │ ├── getGradientSegmentsFrom.test.ts │ ├── getGradientSegmentsPos.test.ts │ └── computeSeverity.test.ts ├── .prettierrc.js ├── .github ├── FUNDING.yml ├── workflows │ ├── validate.yaml │ ├── release.yaml │ └── stale.yaml └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.yml ├── .browserslistrc ├── .prettierignore ├── hacs.json ├── renovate.json ├── examples ├── inverted-percentage.md ├── inner-as-min-max.md ├── temperature-humidity.md └── energy-grid-neutrality-gauge.md ├── .gitignore ├── preflight.sh ├── tsconfig.json ├── FAQ.md ├── package.json ├── rollup.config.mjs └── SHAPES.md /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf -------------------------------------------------------------------------------- /src/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /src/dependencies/ha/common/entity/compute_state_display.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | trailingComma: "es5", 3 | }; 4 | -------------------------------------------------------------------------------- /src/dependencies/calendar-card-pro/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./logger"; 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [benjamin-dcs] 2 | buy_me_a_coffee: benjamindcs 3 | -------------------------------------------------------------------------------- /.browserslistrc: -------------------------------------------------------------------------------- 1 | unreleased versions 2 | last 7 years 3 | >= 0.05% and supports websockets -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .hass_dev/* 4 | package-lock.json 5 | package.json -------------------------------------------------------------------------------- /src/dependencies/ha/data/main_window.ts: -------------------------------------------------------------------------------- 1 | export const MAIN_WINDOW_NAME = "ha-main-window"; 2 | -------------------------------------------------------------------------------- /src/dependencies/ha/common/translations/localize.ts: -------------------------------------------------------------------------------- 1 | export type LocalizeFunc = (key: string, ...args: any[]) => string; 2 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Gauge Card Pro", 3 | "filename": "gauge-card-pro.js", 4 | "homeassistant": "2024.8", 5 | "render_readme": true 6 | } 7 | -------------------------------------------------------------------------------- /src/dependencies/ha/common/entity/compute_domain.ts: -------------------------------------------------------------------------------- 1 | export const computeDomain = (entityId: string): string => 2 | entityId.substr(0, entityId.indexOf(".")); 3 | -------------------------------------------------------------------------------- /src/dependencies/ha/common/number/round.ts: -------------------------------------------------------------------------------- 1 | export const round = (value: number, precision = 2): number => 2 | Math.round(value * 10 ** precision) / 10 ** precision; 3 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["config:recommended"], 4 | "rebaseWhen": "behind-base-branch" 5 | } 6 | -------------------------------------------------------------------------------- /src/dependencies/ha/util/is_touch.ts: -------------------------------------------------------------------------------- 1 | export const isTouch = 2 | "ontouchstart" in window || 3 | navigator.maxTouchPoints > 0 || 4 | // @ts-ignore 5 | navigator.msMaxTouchPoints > 0; 6 | -------------------------------------------------------------------------------- /src/dependencies/ha/common/entity/compute_object_id.ts: -------------------------------------------------------------------------------- 1 | /** Compute the object ID of a state. */ 2 | export const computeObjectId = (entityId: string): string => 3 | entityId.substr(entityId.indexOf(".") + 1); 4 | -------------------------------------------------------------------------------- /examples/inverted-percentage.md: -------------------------------------------------------------------------------- 1 | # Inverted percentage 2 | 3 | ```yaml 4 | type: custom:gauge-card-pro 5 | entity: sensor.percentage 6 | value: "{{ 100 - states(entity) | float }}" 7 | value_text: "{{ 100 - states(entity) | float }}" 8 | ``` 9 | -------------------------------------------------------------------------------- /src/dependencies/ha/panels/lovelace/common/has-action.ts: -------------------------------------------------------------------------------- 1 | import { ActionConfig } from "../../../data/lovelace"; 2 | 3 | export function hasAction(config?: ActionConfig): boolean { 4 | return config !== undefined && config.action !== "none"; 5 | } 6 | -------------------------------------------------------------------------------- /src/dependencies/mushroom/utils/base-element.ts: -------------------------------------------------------------------------------- 1 | import { HomeAssistant } from "../../ha"; 2 | 3 | export function computeDarkMode(hass?: HomeAssistant): boolean { 4 | if (!hass) return false; 5 | return (hass.themes as any).darkMode as boolean; 6 | } 7 | -------------------------------------------------------------------------------- /src/dependencies/ha/common/entity/compute_state_domain.ts: -------------------------------------------------------------------------------- 1 | import type { HassEntity } from "home-assistant-js-websocket"; 2 | import { computeDomain } from "./compute_domain"; 3 | 4 | export const computeStateDomain = (stateObj: HassEntity) => 5 | computeDomain(stateObj.entity_id); 6 | -------------------------------------------------------------------------------- /src/dependencies/ha/common/util/render-status.ts: -------------------------------------------------------------------------------- 1 | export const afterNextRender = (cb: (value: unknown) => void): void => { 2 | requestAnimationFrame(() => setTimeout(cb, 0)); 3 | }; 4 | 5 | export const nextRender = () => 6 | new Promise((resolve) => { 7 | afterNextRender(resolve); 8 | }); 9 | -------------------------------------------------------------------------------- /src/dependencies/ha/panels/lovelace/editor/structs/base-card-struct.ts: -------------------------------------------------------------------------------- 1 | import { object, string, any } from "superstruct"; 2 | 3 | export const baseLovelaceCardConfig = object({ 4 | type: string(), 5 | view_layout: any(), 6 | layout_options: any(), 7 | grid_options: any(), 8 | visibility: any(), 9 | }); 10 | -------------------------------------------------------------------------------- /src/dependencies/mushroom/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./shared/config/actions-config"; 2 | export * from "./utils/form/ha-form"; 3 | export * from "./utils/form/ha-selector"; 4 | export * from "./utils/base-element"; 5 | export * from "./utils/cache-manager"; 6 | export * from "./utils/custom-cards"; 7 | export * from "./utils/loader"; 8 | -------------------------------------------------------------------------------- /src/dependencies/mushroom/README.md: -------------------------------------------------------------------------------- 1 | This folder contains all the files used for this card by [🍄 Mushroom](https://github.com/piitaya/lovelace-mushroom/) `v4.4.0`. Files are modified by renaming of the methods for clarity purposes and/or stripping of unnecessary code. 2 | 3 | Thanks Paul Bottein and the other maintainers/contributors! 4 | -------------------------------------------------------------------------------- /src/dependencies/calendar-card-pro/README.md: -------------------------------------------------------------------------------- 1 | This folder contains all the files used for this card by [Calendar Card Pro](https://github.com/alexpfau/calendar-card-pro) `v3.0.3`. Files are modified by renaming of the methods for clarity purposes and/or stripping of unnecessary code. 2 | 3 | Thanks Alex Pfau and the other maintainers/contributors! 4 | -------------------------------------------------------------------------------- /src/dependencies/ha/common/const.ts: -------------------------------------------------------------------------------- 1 | /** States that we consider "off". */ 2 | export const STATES_OFF = ["closed", "locked", "off"]; 3 | 4 | /** Binary States */ 5 | export const BINARY_STATE_ON = "on"; 6 | export const BINARY_STATE_OFF = "off"; 7 | 8 | /** Temperature units. */ 9 | export const UNIT_C = "°C"; 10 | export const UNIT_F = "°F"; 11 | -------------------------------------------------------------------------------- /.github/workflows/validate.yaml: -------------------------------------------------------------------------------- 1 | name: HACS validation 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: 6 | - main 7 | jobs: 8 | validate: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v6 13 | - name: HACS validation 14 | uses: "hacs/action@main" 15 | with: 16 | category: "plugin" 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .hass_dev/* 4 | !.hass_dev/configuration.yaml 5 | !.hass_dev/lovelace.yaml 6 | !.hass_dev/lovelace-mushroom-showcase.yaml 7 | !.hass_dev/lovelace.yaml 8 | !.hass_dev/automations.yaml 9 | !.hass_dev/scripts.yaml 10 | !.hass_dev/scenes.yaml 11 | !.hass_dev/views 12 | !.hass_dev/www 13 | !.hass_dev/packages 14 | .hass_dev/www/community/* 15 | 16 | # Mac OS 17 | .DS_Store 18 | .vscode/settings.json 19 | .idea 20 | /.hass_dev 21 | *.xcf 22 | -------------------------------------------------------------------------------- /src/dependencies/ha/common/number/clamp.ts: -------------------------------------------------------------------------------- 1 | export const clamp = (value: number, min: number, max: number) => 2 | Math.min(Math.max(value, min), max); 3 | 4 | // Variant that only applies the clamping to a border if the border is defined 5 | export const conditionalClamp = (value: number, min?: number, max?: number) => { 6 | let result: number; 7 | result = min ? Math.max(value, min) : value; 8 | result = max ? Math.min(result, max) : result; 9 | return result; 10 | }; 11 | -------------------------------------------------------------------------------- /src/dependencies/ha/common/entity/color/battery_color.ts: -------------------------------------------------------------------------------- 1 | export const batteryStateColorProperty = ( 2 | state: string 3 | ): string | undefined => { 4 | const value = Number(state); 5 | if (Number.isNaN(value)) { 6 | return undefined; 7 | } 8 | if (value >= 70) { 9 | return "--state-sensor-battery-high-color"; 10 | } 11 | if (value >= 30) { 12 | return "--state-sensor-battery-medium-color"; 13 | } 14 | return "--state-sensor-battery-low-color"; 15 | }; 16 | -------------------------------------------------------------------------------- /examples/inner-as-min-max.md: -------------------------------------------------------------------------------- 1 | ![image](https://github.com/user-attachments/assets/1b4a0078-7d0c-417c-a51a-f63ee8f0339d) 2 | 3 | ```yaml 4 | inner: 5 | mode: static 6 | segments: | 7 | {% set min = states('sensor.min') | float %} 8 | {% set max = states('sensor.max') | float %} 9 | {{ 10 | [ 11 | { "from": 0, "color": "#eeeeee" }, 12 | { "from": min, "color": "var(--info-color)" }, 13 | { "from": max, "color":"#eeeeee" } 14 | ] 15 | }} 16 | ``` 17 | -------------------------------------------------------------------------------- /src/dependencies/is-svg-path/valid-svg-path.ts: -------------------------------------------------------------------------------- 1 | // Couldn't get the package to work, so internalized from https://github.com/dy/is-svg-path 2 | export function isValidSvgPath(path: any) { 3 | if (typeof path !== "string") return false; 4 | 5 | path = path.trim(); 6 | 7 | // https://www.w3.org/TR/SVG/paths.html#PathDataBNF 8 | if ( 9 | /^[mzlhvcsqta]\s*[-+.0-9][^mlhvzcsqta]+/i.test(path) && 10 | /[\dz]$/i.test(path) && 11 | path.length > 4 12 | ) 13 | return true; 14 | 15 | return false; 16 | } 17 | -------------------------------------------------------------------------------- /src/dependencies/ha/common/entity/supports-feature.ts: -------------------------------------------------------------------------------- 1 | import { HassEntity } from "home-assistant-js-websocket"; 2 | 3 | export const supportsFeature = ( 4 | stateObj: HassEntity, 5 | feature: number 6 | ): boolean => supportsFeatureFromAttributes(stateObj.attributes, feature); 7 | 8 | export const supportsFeatureFromAttributes = ( 9 | attributes: { 10 | [key: string]: any; 11 | }, 12 | feature: number 13 | ): boolean => 14 | // eslint-disable-next-line no-bitwise 15 | (attributes.supported_features! & feature) !== 0; 16 | -------------------------------------------------------------------------------- /src/dependencies/ha/common/translations/blank_before_percent.ts: -------------------------------------------------------------------------------- 1 | import type { FrontendLocaleData } from "../../data/translation"; 2 | 3 | // Logic based on https://en.wikipedia.org/wiki/Percent_sign#Form_and_spacing 4 | export const blankBeforePercent = ( 5 | localeOptions: FrontendLocaleData 6 | ): string => { 7 | switch (localeOptions.language) { 8 | case "cs": 9 | case "de": 10 | case "fi": 11 | case "fr": 12 | case "sk": 13 | case "sv": 14 | return " "; 15 | default: 16 | return ""; 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /src/dependencies/ha/common/string/has-template.ts: -------------------------------------------------------------------------------- 1 | const isTemplateRegex = /{%|{{/; 2 | 3 | export const isTemplate = (value: string): boolean => 4 | isTemplateRegex.test(value); 5 | 6 | export const hasTemplate = (value: unknown): boolean => { 7 | if (!value) { 8 | return false; 9 | } 10 | if (typeof value === "string") { 11 | return isTemplate(value); 12 | } 13 | if (typeof value === "object") { 14 | const values = Array.isArray(value) ? value : Object.values(value!); 15 | return values.some((val) => val && hasTemplate(val)); 16 | } 17 | return false; 18 | }; 19 | -------------------------------------------------------------------------------- /src/dependencies/ha/common/entity/compute_state_name.ts: -------------------------------------------------------------------------------- 1 | import { HassEntity } from "home-assistant-js-websocket"; 2 | import { computeObjectId } from "./compute_object_id"; 3 | 4 | export const computeStateNameFromEntityAttributes = ( 5 | entityId: string, 6 | attributes: { [key: string]: any } 7 | ): string => 8 | attributes.friendly_name === undefined 9 | ? computeObjectId(entityId).replace(/_/g, " ") 10 | : attributes.friendly_name || ""; 11 | 12 | export const computeStateName = (stateObj: HassEntity): string => 13 | computeStateNameFromEntityAttributes(stateObj.entity_id, stateObj.attributes); 14 | -------------------------------------------------------------------------------- /src/dependencies/mushroom/utils/custom-cards.ts: -------------------------------------------------------------------------------- 1 | import { repository } from "../../../../package.json"; 2 | 3 | interface RegisterCardParams { 4 | type: string; 5 | name: string; 6 | description: string; 7 | } 8 | 9 | export function registerCustomCard(params: RegisterCardParams) { 10 | const windowWithCards = window as unknown as Window & { 11 | customCards: unknown[]; 12 | }; 13 | windowWithCards.customCards = windowWithCards.customCards || []; 14 | 15 | windowWithCards.customCards.push({ 16 | ...params, 17 | preview: true, 18 | documentationURL: `${repository.url}/blob/main/README.md`, 19 | }); 20 | } 21 | -------------------------------------------------------------------------------- /preflight.sh: -------------------------------------------------------------------------------- 1 | DARKGRAY='\033[1;30m' 2 | RED='\033[0;31m' 3 | NC='\033[0m' # No Color 4 | 5 | ON_WHITE='\033[47m' 6 | 7 | clear 8 | 9 | echo "${RED}${ON_WHITE}###${DARKGRAY} FORMATTING ${RED}${ON_WHITE}###${NC}" 10 | npm run format 11 | echo "" 12 | 13 | if [ "$1" != "--noinstall" ]; then 14 | echo "${RED}${ON_WHITE}###${DARKGRAY} INSTALLING ${RED}${ON_WHITE}###${NC}" 15 | npm install 16 | echo "" 17 | echo "${RED}${ON_WHITE}###${DARKGRAY} CHECKING UNUSED DEPENDENCIES ${RED}${ON_WHITE}###${NC}" 18 | npm run depcheck 19 | echo "" 20 | fi 21 | 22 | echo "${RED}${ON_WHITE}###${DARKGRAY} TESTING ${RED}${ON_WHITE}###${NC}" 23 | npm run test -------------------------------------------------------------------------------- /src/dependencies/ha/util/calculate.ts: -------------------------------------------------------------------------------- 1 | export const normalize = (value: number, min: number, max: number): number => { 2 | if (isNaN(value) || isNaN(min) || isNaN(max)) { 3 | // Not a number, return 0 4 | return 0; 5 | } 6 | if (value > max) return max; 7 | if (value < min) return min; 8 | return value; 9 | }; 10 | 11 | export const getValueInPercentage = ( 12 | value: number, 13 | min: number, 14 | max: number 15 | ): number => { 16 | const newMax = max - min; 17 | const newVal = value - min; 18 | return (100 * newVal) / newMax; 19 | }; 20 | 21 | export const roundWithOneDecimal = (value: number): number => 22 | Math.round(value * 10) / 10; 23 | -------------------------------------------------------------------------------- /src/dependencies/mushroom/utils/cache-manager.ts: -------------------------------------------------------------------------------- 1 | export class CacheManager { 2 | constructor(expiration?: number) { 3 | this._expiration = expiration; 4 | } 5 | 6 | private _expiration?: number; 7 | 8 | private _cache = new Map(); 9 | 10 | public get(key: string): T | undefined { 11 | return this._cache.get(key); 12 | } 13 | 14 | public set(key: string, value: T): void { 15 | this._cache.set(key, value); 16 | if (this._expiration) { 17 | window.setTimeout(() => this._cache.delete(key), this._expiration); 18 | } 19 | } 20 | 21 | public has(key: string): boolean { 22 | return this._cache.has(key); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "target": "es2017", 5 | "module": "esnext", 6 | "moduleResolution": "node", 7 | "lib": ["es2017", "dom", "dom.iterable"], 8 | "noEmit": true, 9 | "noUnusedParameters": true, 10 | "noImplicitReturns": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "strict": true, 13 | "noImplicitAny": false, 14 | "skipLibCheck": true, 15 | "resolveJsonModule": true, 16 | "experimentalDecorators": true, 17 | "outDir": "dist", 18 | "rootDir": ".", 19 | "types": ["vitest"] 20 | }, 21 | "include": ["src/**/*"], 22 | "exclude": ["node_modules"] 23 | } 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "" 5 | labels: "" 6 | assignees: "" 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /src/utils/css/valid-font-size.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Checks whether a given value is a valid CSS font-size. 3 | * 4 | * This function first ensures the input is a string, then uses the 5 | * browser’s native `CSS.supports()` API to verify that the string 6 | * is recognized as a valid value for the `font-size` property. 7 | * 8 | * @param font_size - The font-size value to validate (e.g. "16px", "1.2em", "small"). 9 | * @returns `true` if `font_size` is a string and is supported by the browser’s CSS parser; otherwise `false`. 10 | */ 11 | export function isValidFontSize(font_size: string): boolean { 12 | if (typeof font_size !== "string") return false; 13 | return CSS.supports("font-size", font_size); 14 | } 15 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | env: 4 | DEPLOY: "prod" 5 | 6 | on: 7 | push: 8 | tags: 9 | - "v*.*.*" 10 | 11 | jobs: 12 | release: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v6 17 | 18 | - name: Install 19 | run: npm ci 20 | 21 | - name: Run unit test 22 | run: npm test 23 | 24 | - name: Build 25 | run: npm run build 26 | 27 | - name: Release 28 | uses: softprops/action-gh-release@v2 29 | if: startsWith(github.ref, 'refs/tags/') 30 | with: 31 | draft: true 32 | generate_release_notes: true 33 | files: dist/*.js 34 | -------------------------------------------------------------------------------- /src/card/css/card.ts: -------------------------------------------------------------------------------- 1 | import { css } from "lit"; 2 | 3 | export const cardCSS = css` 4 | ha-card { 5 | height: 100%; 6 | overflow: hidden; 7 | padding: 16px; 8 | display: flex; 9 | align-items: center; 10 | justify-content: center; 11 | flex-direction: column; 12 | box-sizing: border-box; 13 | } 14 | 15 | ha-card.action { 16 | cursor: pointer; 17 | } 18 | 19 | ha-card:focus { 20 | outline: none; 21 | } 22 | 23 | gauge-card-pro-gauge { 24 | width: 100%; 25 | max-width: 250px; 26 | } 27 | 28 | .title { 29 | text-align: center; 30 | line-height: initial; 31 | width: 100%; 32 | } 33 | 34 | .primary-title { 35 | margin-top: 8px; 36 | } 37 | `; 38 | -------------------------------------------------------------------------------- /src/dependencies/ha/panels/lovelace/common/validate-condition.ts: -------------------------------------------------------------------------------- 1 | import { UNAVAILABLE } from "../../../data/entity"; 2 | import { HomeAssistant } from "../../../types"; 3 | 4 | export interface Condition { 5 | entity: string; 6 | state?: string; 7 | state_not?: string; 8 | } 9 | 10 | export function checkConditionsMet( 11 | conditions: Condition[], 12 | hass: HomeAssistant 13 | ): boolean { 14 | return conditions.every((c) => { 15 | const state = hass.states[c.entity] 16 | ? hass!.states[c.entity]?.state 17 | : UNAVAILABLE; 18 | 19 | return c.state ? state === c.state : state !== c.state_not; 20 | }); 21 | } 22 | 23 | export function validateConditionalConfig(conditions: Condition[]): boolean { 24 | return conditions.every( 25 | (c) => (c.entity && (c.state || c.state_not)) as unknown as boolean 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /src/dependencies/ha/data/ws-templates.ts: -------------------------------------------------------------------------------- 1 | import { Connection, UnsubscribeFunc } from "home-assistant-js-websocket"; 2 | 3 | export interface RenderTemplateResult { 4 | result: string; 5 | listeners: TemplateListeners; 6 | } 7 | 8 | interface TemplateListeners { 9 | all: boolean; 10 | domains: string[]; 11 | entities: string[]; 12 | time: boolean; 13 | } 14 | 15 | export const subscribeRenderTemplate = ( 16 | conn: Connection, 17 | onChange: (result: RenderTemplateResult) => void, 18 | params: { 19 | template: string; 20 | entity_ids?: string | string[]; 21 | variables?: Record; 22 | timeout?: number; 23 | strict?: boolean; 24 | } 25 | ): Promise => 26 | conn.subscribeMessage((msg: RenderTemplateResult) => onChange(msg), { 27 | type: "render_template", 28 | ...params, 29 | }); 30 | -------------------------------------------------------------------------------- /src/dependencies/ha/data/ws-themes.ts: -------------------------------------------------------------------------------- 1 | export interface ThemeVars { 2 | // Incomplete 3 | "primary-color": string; 4 | "text-primary-color": string; 5 | "accent-color": string; 6 | [key: string]: string; 7 | } 8 | 9 | export type Theme = ThemeVars & { 10 | modes?: { 11 | light?: ThemeVars; 12 | dark?: ThemeVars; 13 | }; 14 | }; 15 | 16 | export interface Themes { 17 | default_theme: string; 18 | default_dark_theme: string | null; 19 | themes: Record; 20 | // Currently effective dark mode. Will never be undefined. If user selected "auto" 21 | // in theme picker, this property will still contain either true or false based on 22 | // what has been determined via system preferences and support from the selected theme. 23 | darkMode: boolean; 24 | // Currently globally active theme name 25 | theme: string; 26 | } 27 | -------------------------------------------------------------------------------- /src/dependencies/ha/panels/lovelace/common/handle-actions.ts: -------------------------------------------------------------------------------- 1 | import { fireEvent } from "../../../common/dom/fire_event"; 2 | import { ActionConfig } from "../../../data/lovelace"; 3 | import { HomeAssistant } from "../../../types"; 4 | 5 | export type ActionConfigParams = { 6 | entity?: string; 7 | camera_image?: string; 8 | hold_action?: ActionConfig; 9 | tap_action?: ActionConfig; 10 | double_tap_action?: ActionConfig; 11 | }; 12 | 13 | export const handleAction = async ( 14 | node: HTMLElement, 15 | _hass: HomeAssistant, 16 | config: ActionConfigParams, 17 | action: string 18 | ): Promise => { 19 | fireEvent(node, "hass-action", { config, action }); 20 | }; 21 | 22 | type ActionParams = { config: ActionConfigParams; action: string }; 23 | 24 | declare global { 25 | interface HASSDomEvents { 26 | "hass-action": ActionParams; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/translations/en-GB.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor": { 3 | "card": { 4 | "icon_double_tap_action": "Icon double tap behaviour", 5 | "icon_hold_action": "Icon hold behaviour", 6 | "icon_tap_action": "Icon tap behaviour", 7 | "primary_value_text_double_tap_action": "Primary value-text double tap behaviour", 8 | "primary_value_text_hold_action": "Primary value-text hold behaviour", 9 | "primary_value_text_tap_action": "Primary value-text tap behaviour", 10 | "secondary_value_text_double_tap_action": "Secondary value-text double tap behaviour", 11 | "secondary_value_text_hold_action": "Secondary value-text hold behaviour", 12 | "secondary_value_text_tap_action": "Secondary value-text tap behaviour" 13 | } 14 | }, 15 | "migration": { 16 | "description-pos": "the color peaks at the exact value (old behaviour)." 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/utils/color/computed-color.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Resolves a CSS custom-property reference to its computed value on , 3 | * or returns the original input if it’s not a valid CSS variable. 4 | * 5 | * @param color - Either: 6 | * • A CSS custom-property reference in the form `"var(--some-color)"`, 7 | * • Any other string (e.g. `"#ff0000"`, `"red"`, `"rgb(255,0,0)"`), 8 | * • Or a non-string value (which is returned unchanged). 9 | * @returns The resolved CSS value of the custom property (e.g. `"#ff0000"`), 10 | * or the original input if it wasn’t a valid `var(...)` reference. 11 | */ 12 | export function getComputedColor(color: string) { 13 | if (typeof color !== "string") return color; 14 | if (!(color.startsWith("var(") && color.endsWith(")"))) return color; 15 | return window 16 | .getComputedStyle(document.body) 17 | .getPropertyValue(color.slice(4, -1)); 18 | } 19 | -------------------------------------------------------------------------------- /src/dependencies/mushroom/utils/loader.ts: -------------------------------------------------------------------------------- 1 | // Hack to load ha-components needed for editor 2 | export const loadHaComponents = () => { 3 | if ( 4 | !customElements.get("ha-form") || 5 | !customElements.get("hui-card-features-editor") 6 | ) { 7 | (customElements.get("hui-tile-card") as any)?.getConfigElement(); 8 | } 9 | if (!customElements.get("ha-entity-picker")) { 10 | (customElements.get("hui-entities-card") as any)?.getConfigElement(); 11 | } 12 | if (!customElements.get("ha-card-conditions-editor")) { 13 | (customElements.get("hui-conditional-card") as any)?.getConfigElement(); 14 | } 15 | }; 16 | 17 | export const loadCustomElement = async (name: string) => { 18 | let Component = customElements.get(name) as T; 19 | if (Component) { 20 | return Component; 21 | } 22 | await customElements.whenDefined(name); 23 | return customElements.get(name) as T; 24 | }; 25 | -------------------------------------------------------------------------------- /src/utils/object/get-value.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Safely retrieves the value at a given dot-delimited path within an object. 3 | * 4 | * @template ObjectType 5 | * @param {ObjectType} object 6 | * The object from which to retrieve the value. 7 | * @param {string} path 8 | * A dot-notation string describing the nested property path 9 | * (e.g. `"user.address.street"`). 10 | * @returns {*} 11 | * The value found at the specified path, or `undefined` if: 12 | * - the object is `null`/`undefined` 13 | * - the path is an empty string 14 | * - any intermediate property along the path does not exist. 15 | */ 16 | export function getValueFromPath( 17 | object: ObjectType, 18 | path: string 19 | ): any { 20 | if (!object || !path) { 21 | return; 22 | } 23 | const keys = path.split("."); 24 | let result = object; 25 | for (const key of keys) { 26 | result = result?.[key]; 27 | } 28 | return result; 29 | } 30 | -------------------------------------------------------------------------------- /src/localize.ts: -------------------------------------------------------------------------------- 1 | // Internalized external dependencies 2 | import { HomeAssistant } from "./dependencies/ha"; 3 | 4 | import * as en from "./translations/en.json"; 5 | import * as en_GB from "./translations/en-GB.json"; 6 | 7 | const languages: Record = { 8 | en, 9 | "en-GB": en_GB, 10 | }; 11 | 12 | const DEFAULT_LANG = "en"; 13 | 14 | function getTranslatedString(key: string, lang: string): string | undefined { 15 | try { 16 | return key 17 | .split(".") 18 | .reduce( 19 | (o, i) => (o as Record)[i], 20 | languages[lang] 21 | ) as string; 22 | } catch (_) { 23 | return undefined; 24 | } 25 | } 26 | 27 | export default function setupCustomlocalize(hass?: HomeAssistant) { 28 | return function (key: string) { 29 | const lang = hass?.locale.language ?? DEFAULT_LANG; 30 | 31 | let translated = getTranslatedString(key, lang); 32 | if (!translated) translated = getTranslatedString(key, DEFAULT_LANG); 33 | return translated ?? key; 34 | }; 35 | } 36 | -------------------------------------------------------------------------------- /src/utils/number/get-angle.ts: -------------------------------------------------------------------------------- 1 | // Internalized external dependencies 2 | import { 3 | getValueInPercentage, 4 | normalize, 5 | } from "../../dependencies/ha/util/calculate"; 6 | 7 | /** 8 | * Converts a numeric value within a specified range into an angle between 0° and 180°. 9 | * 10 | * This function first normalizes the input `value` relative to the provided `min` and `max` bounds, 11 | * then computes its percentage position within that range, and finally maps that percentage to 12 | * an angle on a semicircle (0–180 degrees). 13 | * 14 | * @param value - The current value to convert into an angle. 15 | * @param min - The minimum possible value (corresponds to 0°). 16 | * @param max - The maximum possible value (corresponds to 180°). 17 | * @returns A number in degrees (0–180) representing where `value` falls within the `[min, max]` range. 18 | */ 19 | export const getAngle = (value: number, min: number, max: number) => { 20 | const percentage = getValueInPercentage(normalize(value, min, max), min, max); 21 | return (percentage * 180) / 100; 22 | }; 23 | -------------------------------------------------------------------------------- /src/dependencies/ha/common/util/debounce.ts: -------------------------------------------------------------------------------- 1 | // From: https://davidwalsh.name/javascript-debounce-function 2 | 3 | // Returns a function, that, as long as it continues to be invoked, will not 4 | // be triggered. The function will be called after it stops being called for 5 | // N milliseconds. If `immediate` is passed, trigger the function on the 6 | // leading edge, instead of the trailing. 7 | 8 | export const debounce = ( 9 | func: (...args: T) => void, 10 | wait: number, 11 | immediate = false 12 | ) => { 13 | let timeout: number | undefined; 14 | const debouncedFunc = (...args: T): void => { 15 | const later = () => { 16 | timeout = undefined; 17 | if (!immediate) { 18 | func(...args); 19 | } 20 | }; 21 | const callNow = immediate && !timeout; 22 | clearTimeout(timeout); 23 | timeout = window.setTimeout(later, wait); 24 | if (callNow) { 25 | func(...args); 26 | } 27 | }; 28 | debouncedFunc.cancel = () => { 29 | clearTimeout(timeout); 30 | }; 31 | return debouncedFunc; 32 | }; 33 | -------------------------------------------------------------------------------- /src/dependencies/ha/panels/lovelace/common/entity/turn-on-off-entity.ts: -------------------------------------------------------------------------------- 1 | import { computeDomain } from "../../../../common/entity/compute_domain"; 2 | import { HomeAssistant, ServiceCallResponse } from "../../../../types"; 3 | 4 | export const turnOnOffEntity = ( 5 | hass: HomeAssistant, 6 | entityId: string, 7 | turnOn = true 8 | ): Promise => { 9 | const stateDomain = computeDomain(entityId); 10 | const serviceDomain = stateDomain === "group" ? "homeassistant" : stateDomain; 11 | 12 | let service; 13 | switch (stateDomain) { 14 | case "lock": 15 | service = turnOn ? "unlock" : "lock"; 16 | break; 17 | case "cover": 18 | service = turnOn ? "open_cover" : "close_cover"; 19 | break; 20 | case "button": 21 | case "input_button": 22 | service = "press"; 23 | break; 24 | case "scene": 25 | service = "turn_on"; 26 | break; 27 | default: 28 | service = turnOn ? "turn_on" : "turn_off"; 29 | } 30 | 31 | return hass.callService(serviceDomain, service, { entity_id: entityId }); 32 | }; 33 | -------------------------------------------------------------------------------- /src/dependencies/mushroom/shared/config/actions-config.ts: -------------------------------------------------------------------------------- 1 | import { object, optional } from "superstruct"; 2 | import { ActionConfig, actionConfigStruct } from "../../../ha"; 3 | import { HaFormSchema } from "../../utils/form/ha-form"; 4 | import { UiAction } from "../../utils/form/ha-selector"; 5 | 6 | export const actionsSharedConfigStruct = object({ 7 | tap_action: optional(actionConfigStruct), 8 | hold_action: optional(actionConfigStruct), 9 | double_tap_action: optional(actionConfigStruct), 10 | }); 11 | 12 | export type ActionsSharedConfig = { 13 | tap_action?: ActionConfig; 14 | hold_action?: ActionConfig; 15 | double_tap_action?: ActionConfig; 16 | }; 17 | 18 | export const computeActionsFormSchema = ( 19 | actions?: UiAction[] 20 | ): HaFormSchema[] => { 21 | return [ 22 | { 23 | name: "tap_action", 24 | selector: { ui_action: { actions } }, 25 | }, 26 | { 27 | name: "hold_action", 28 | selector: { ui_action: { actions } }, 29 | }, 30 | { 31 | name: "double_tap_action", 32 | selector: { ui_action: { actions } }, 33 | }, 34 | ]; 35 | }; 36 | -------------------------------------------------------------------------------- /.github/workflows/stale.yaml: -------------------------------------------------------------------------------- 1 | name: Stale 2 | 3 | permissions: 4 | issues: write 5 | 6 | # yamllint disable-line rule:truthy 7 | on: 8 | schedule: 9 | - cron: "0 0 * * *" 10 | workflow_dispatch: 11 | 12 | jobs: 13 | stale: 14 | runs-on: ubuntu-latest 15 | steps: 16 | # The 60 day stale policy for issues 17 | # Used for: 18 | # - Issues 19 | # - No issues marked as no-stale or help-wanted 20 | # - No PRs (-1) 21 | - name: 60 days stale issues 22 | uses: actions/stale@v10.1.0 23 | with: 24 | days-before-stale: 60 25 | days-before-close: 7 26 | days-before-pr-stale: -1 27 | days-before-pr-close: -1 28 | operations-per-run: 250 29 | remove-stale-when-updated: true 30 | stale-issue-label: "stale" 31 | exempt-issue-labels: "no-stale,help-wanted,needs-more-information" 32 | stale-issue-message: > 33 | This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions. 34 | -------------------------------------------------------------------------------- /src/dependencies/ha/common/string/compare.ts: -------------------------------------------------------------------------------- 1 | import memoizeOne from "memoize-one"; 2 | 3 | const collator = memoizeOne( 4 | (language: string | undefined) => new Intl.Collator(language) 5 | ); 6 | 7 | const caseInsensitiveCollator = memoizeOne( 8 | (language: string | undefined) => 9 | new Intl.Collator(language, { sensitivity: "accent" }) 10 | ); 11 | 12 | const fallbackStringCompare = (a: string, b: string) => { 13 | if (a < b) { 14 | return -1; 15 | } 16 | if (a > b) { 17 | return 1; 18 | } 19 | 20 | return 0; 21 | }; 22 | 23 | export const stringCompare = ( 24 | a: string, 25 | b: string, 26 | language: string | undefined = undefined 27 | ) => { 28 | // @ts-ignore 29 | if (Intl?.Collator) { 30 | return collator(language).compare(a, b); 31 | } 32 | 33 | return fallbackStringCompare(a, b); 34 | }; 35 | 36 | export const caseInsensitiveStringCompare = ( 37 | a: string, 38 | b: string, 39 | language: string | undefined = undefined 40 | ) => { 41 | // @ts-ignore 42 | if (Intl?.Collator) { 43 | return caseInsensitiveCollator(language).compare(a, b); 44 | } 45 | 46 | return fallbackStringCompare(a.toLowerCase(), b.toLowerCase()); 47 | }; 48 | -------------------------------------------------------------------------------- /src/utils/number/format-to-locale.ts: -------------------------------------------------------------------------------- 1 | import { 2 | formatNumber, 3 | getNumberFormatOptions, 4 | HomeAssistant, 5 | } from "../../dependencies/ha"; 6 | 7 | import { NumberUtils } from "./numberUtils"; 8 | 9 | export const formatEntityToLocal = ( 10 | hass: HomeAssistant, 11 | entity: string | any 12 | ) => { 13 | if (!hass || !entity) return undefined; 14 | 15 | const stateObj = hass.states[entity]; 16 | if ( 17 | !stateObj || 18 | stateObj.state === "unavailable" || 19 | !NumberUtils.isNumeric(stateObj.state) 20 | ) 21 | return ""; 22 | 23 | const locale = hass.locale; 24 | const formatOptions = getNumberFormatOptions( 25 | stateObj, 26 | hass.entities[stateObj.entity_id] 27 | ); 28 | return formatNumber(stateObj.state, locale, formatOptions); 29 | }; 30 | 31 | export const formatNumberToLocal = ( 32 | hass: HomeAssistant, 33 | value: number | any 34 | ) => { 35 | if (!hass) return undefined; 36 | const numValue = NumberUtils.tryToNumber(value); 37 | if (numValue === undefined) return undefined; 38 | 39 | const locale = hass!.locale; 40 | const formatOptions = undefined; 41 | 42 | return formatNumber(numValue, locale, formatOptions); 43 | }; 44 | -------------------------------------------------------------------------------- /src/dependencies/ha/common/util/compute_rtl.ts: -------------------------------------------------------------------------------- 1 | import { LitElement } from "lit"; 2 | import { HomeAssistant } from "../../types"; 3 | 4 | export function computeRTL(hass: HomeAssistant) { 5 | const lang = hass.language || "en"; 6 | if (hass.translationMetadata.translations[lang]) { 7 | return hass.translationMetadata.translations[lang].isRTL || false; 8 | } 9 | return false; 10 | } 11 | 12 | export function computeRTLDirection(hass: HomeAssistant) { 13 | return emitRTLDirection(computeRTL(hass)); 14 | } 15 | 16 | export function emitRTLDirection(rtl: boolean) { 17 | return rtl ? "rtl" : "ltr"; 18 | } 19 | 20 | export function computeDirectionStyles(isRTL: boolean, element: LitElement) { 21 | const direction: string = emitRTLDirection(isRTL); 22 | setDirectionStyles(direction, element); 23 | } 24 | 25 | export function setDirectionStyles(direction: string, element: LitElement) { 26 | element.style.direction = direction; 27 | element.style.setProperty("--direction", direction); 28 | element.style.setProperty( 29 | "--float-start", 30 | direction === "ltr" ? "left" : "right" 31 | ); 32 | element.style.setProperty( 33 | "--float-end", 34 | direction === "ltr" ? "right" : "left" 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /examples/temperature-humidity.md: -------------------------------------------------------------------------------- 1 | ![image](https://github.com/user-attachments/assets/80d3f704-8ba6-4530-a07e-79cff5248e2e) 2 | 3 | ```yaml 4 | type: custom:gauge-card-pro 5 | entity: sensor.temperature 6 | entity2: sensor.humidity 7 | needle: true 8 | min: "18" 9 | max: "21" 10 | segments: 11 | - from: 18 12 | color: var(--blue-color) 13 | - from: 18.5 14 | color: var(--light-blue-color) 15 | - from: 19 16 | color: var(--light-green-color) 17 | - from: 20 18 | color: var(--light-green-color) 19 | - from: 20.5 20 | color: var(--orange-color) 21 | - from: 21 22 | color: var(--red-color) 23 | inner: 24 | min: 0 25 | max: 100 26 | mode: needle 27 | gradient: true 28 | gradient_resolution: high 29 | segments: 30 | - from: 0 31 | color: var(--red-color) 32 | - from: 40 33 | color: var(--light-blue-color) 34 | - from: 60 35 | color: var(--light-blue-color) 36 | - from: 100 37 | color: var(--black-color) 38 | gradient: true 39 | gradient_resolution: high 40 | value_texts: 41 | primary: "{{ states(entity) | float | round(1) }}°C" 42 | secondary: "{{ states(entity2) | float | round(0) }}%" 43 | secondary_color: "#aaa" 44 | titles: 45 | primary: Living room 46 | ``` 47 | -------------------------------------------------------------------------------- /src/tests/utils/migrate.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest"; 2 | import { migrate_parameters } from "../../utils/migrate-parameters"; 3 | 4 | describe("migrate_parameters", () => { 5 | it("icon.battery", () => { 6 | const config: object = { 7 | type: "custom:gauge-card-pro", 8 | entity: "sensor.test", 9 | icon: { 10 | battery: "sensor.battery", 11 | }, 12 | }; 13 | const migrated_config: object = { 14 | type: "custom:gauge-card-pro", 15 | entity: "sensor.test", 16 | icon: { 17 | type: "battery", 18 | value: "sensor.battery", 19 | }, 20 | }; 21 | const result = migrate_parameters(config); 22 | expect(result).toEqual(migrated_config); 23 | }); 24 | 25 | it("icon.template", () => { 26 | const config: object = { 27 | type: "custom:gauge-card-pro", 28 | entity: "sensor.test", 29 | icon: { 30 | template: "{{ template }}", 31 | }, 32 | }; 33 | const migrated_config: object = { 34 | type: "custom:gauge-card-pro", 35 | entity: "sensor.test", 36 | icon: { 37 | type: "template", 38 | value: "{{ template }}", 39 | }, 40 | }; 41 | const result = migrate_parameters(config); 42 | expect(result).toEqual(migrated_config); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /FAQ.md: -------------------------------------------------------------------------------- 1 | # Frequently asked questions 2 | 3 | ## Is it possible to template segments? 4 | 5 | Yes, as explained in the [readme](https://github.com/benjamin-dcs/gauge-card-pro?tab=readme-ov-file) [here](https://github.com/benjamin-dcs/gauge-card-pro?tab=readme-ov-file#template-list) 6 | 7 | ```yaml 8 | segments: |- 9 | {% set max = states('sensor.max_sensor') | float %} 10 | {{ 11 | [ 12 | { "from": 0, "color": "#4caf50" }, 13 | { "from": 25, "color": "#8bc34a" }, 14 | { "from": 50, "color": "#ffeb3b" }, 15 | { "from": 75, "color": "#ff9800" }, 16 | { "from": 100, "color": "#f44336" }, 17 | { "from": 125, "color": "#926bc7" }, 18 | { "from": max, "color":"#795548" } 19 | ] 20 | }} 21 | ``` 22 | 23 | ## Is it possible to change the icon? 24 | 25 | Yes, as explained in the [readme](https://github.com/benjamin-dcs/gauge-card-pro?tab=readme-ov-file) [here](https://github.com/benjamin-dcs/gauge-card-pro?tab=readme-ov-file#icon-configuration-variables) 26 | 27 | You need to use `type: template` and return an 'object' as described [here](<[readme](https://github.com/benjamin-dcs/gauge-card-pro?tab=readme-ov-file)>), for example: 28 | 29 | ```yaml 30 | icon: 31 | type: template 32 | value: |- 33 | {% if is_state('binary_sensor.pump', 'on') %} 34 | {{ { "icon": 'mdi:your_icon', "color": "#00ff00" } }} 35 | {% endif %} 36 | ``` 37 | -------------------------------------------------------------------------------- /src/dependencies/ha/panels/lovelace/common/entity/turn-on-off-entities.ts: -------------------------------------------------------------------------------- 1 | import { STATES_OFF } from "../../../../common/const"; 2 | import { computeDomain } from "../../../../common/entity/compute_domain"; 3 | import { HomeAssistant } from "../../../../types"; 4 | 5 | export const turnOnOffEntities = ( 6 | hass: HomeAssistant, 7 | entityIds: string[], 8 | turnOn = true 9 | ): void => { 10 | const domainsToCall = {}; 11 | entityIds.forEach((entityId) => { 12 | const stateObj = hass.states[entityId]; 13 | if (stateObj && STATES_OFF.includes(stateObj.state) === turnOn) { 14 | const stateDomain = computeDomain(entityId); 15 | const serviceDomain = ["cover", "lock"].includes(stateDomain) 16 | ? stateDomain 17 | : "homeassistant"; 18 | 19 | if (!(serviceDomain in domainsToCall)) { 20 | domainsToCall[serviceDomain] = []; 21 | } 22 | domainsToCall[serviceDomain].push(entityId); 23 | } 24 | }); 25 | 26 | Object.keys(domainsToCall).forEach((domain) => { 27 | let service; 28 | switch (domain) { 29 | case "lock": 30 | service = turnOn ? "unlock" : "lock"; 31 | break; 32 | case "cover": 33 | service = turnOn ? "open_cover" : "close_cover"; 34 | break; 35 | default: 36 | service = turnOn ? "turn_on" : "turn_off"; 37 | } 38 | 39 | const entities = domainsToCall[domain]; 40 | hass.callService(domain, service, { entity_id: entities }); 41 | }); 42 | }; 43 | -------------------------------------------------------------------------------- /src/utils/string/icon.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Determines whether a given value represents an icon function call in string form. 3 | * 4 | * This function checks if the provided value is a string that starts with 5 | * `"icon("` and ends with `")"`. It returns `true` if both conditions are met, 6 | * indicating that the string is formatted as an icon invocation, and `false` 7 | * otherwise. 8 | * 9 | * @param {*} value_text - The value to test. If it is not a string, the function 10 | * will immediately return `false`. 11 | * @returns {boolean} `true` if `value_text` is a string starting with `"icon("` 12 | * and ending with `")"`, otherwise `false`. 13 | */ 14 | export const isIcon = (value_text: any): boolean => { 15 | if (typeof value_text !== "string") return false; 16 | return value_text.startsWith("icon(") && value_text.endsWith(")"); 17 | }; 18 | 19 | /** 20 | * Extracts the inner icon name from a wrapped icon string, or returns the original value if it's not an icon. 21 | * 22 | * @param {*} value_text - The value to check. Expected to be a string in the form `"icon(name)"` or any other type. 23 | * @returns {string|*} If `value_text` is an icon (as determined by `isIcon`), returns the inner name (everything between `"icon("` and `")"`). Otherwise, returns `value_text` unchanged. 24 | */ 25 | export const getIcon = (value_text: any): string | any => { 26 | if (!isIcon(value_text)) return value_text; 27 | return value_text!.slice(5, -1); 28 | }; 29 | -------------------------------------------------------------------------------- /src/utils/object/delete-key.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Deletes a property from a nested object by following a dot-separated path. 3 | * 4 | * Given an object and a string path like `"a.b.c"`, this function will traverse 5 | * `obj.a.b` and attempt to delete the `c` property. If any segment of the path 6 | * doesn’t exist or isn’t an object, or if the final key is missing, it does nothing. 7 | * 8 | * @param {Object} source - The object from which to delete the key. 9 | * @param {string} path - The dot-separated path to the key to delete (e.g. `"foo.bar.baz"`). 10 | * @returns {{ result: T, success: boolean }} Returns `true` with updated source if the key was found and deleted; otherwise `false` with the source. 11 | */ 12 | export function deleteKey( 13 | source: any, 14 | path: string 15 | ): { result: any; success: boolean } { 16 | const clone = JSON.parse(JSON.stringify(source)); // deep clone so we don't mutate 17 | const keys = path.split("."); 18 | const lastKey = keys.pop(); 19 | 20 | if (!lastKey) return { result: source, success: false }; 21 | 22 | let current = clone; 23 | for (const key of keys) { 24 | if (typeof current[key] !== "object" || current[key] === null) { 25 | return { result: clone, success: false }; // Path does not exist or is not an object 26 | } 27 | current = current[key]; 28 | } 29 | 30 | if (current && lastKey in current) { 31 | delete current[lastKey]; 32 | return { result: clone, success: true }; 33 | } 34 | return { result: clone, success: false }; 35 | } 36 | -------------------------------------------------------------------------------- /src/dependencies/ha/data/translation.ts: -------------------------------------------------------------------------------- 1 | export enum NumberFormat { 2 | language = "language", 3 | system = "system", 4 | comma_decimal = "comma_decimal", 5 | decimal_comma = "decimal_comma", 6 | space_comma = "space_comma", 7 | none = "none", 8 | } 9 | 10 | export enum TimeFormat { 11 | language = "language", 12 | system = "system", 13 | am_pm = "12", 14 | twenty_four = "24", 15 | } 16 | 17 | export enum TimeZone { 18 | local = "local", 19 | server = "server", 20 | } 21 | 22 | export enum DateFormat { 23 | language = "language", 24 | system = "system", 25 | DMY = "DMY", 26 | MDY = "MDY", 27 | YMD = "YMD", 28 | } 29 | 30 | export enum FirstWeekday { 31 | language = "language", 32 | monday = "monday", 33 | tuesday = "tuesday", 34 | wednesday = "wednesday", 35 | thursday = "thursday", 36 | friday = "friday", 37 | saturday = "saturday", 38 | sunday = "sunday", 39 | } 40 | 41 | export interface FrontendLocaleData { 42 | language: string; 43 | number_format: NumberFormat; 44 | time_format: TimeFormat; 45 | date_format: DateFormat; 46 | first_weekday: FirstWeekday; 47 | time_zone: TimeZone; 48 | } 49 | 50 | declare global { 51 | interface FrontendUserData { 52 | language: FrontendLocaleData; 53 | } 54 | } 55 | 56 | export type TranslationCategory = 57 | | "title" 58 | | "state" 59 | | "entity" 60 | | "entity_component" 61 | | "config" 62 | | "config_panel" 63 | | "options" 64 | | "device_automation" 65 | | "mfa_setup" 66 | | "system_health" 67 | | "device_class" 68 | | "application_credentials" 69 | | "issues" 70 | | "selector"; 71 | -------------------------------------------------------------------------------- /src/utils/number/numberUtils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Utility class for number-related operations and checks. 3 | */ 4 | export class NumberUtils { 5 | /** 6 | * Checks whether a value is numeric (either a finite number or a string representing a finite number). 7 | * 8 | * This function acts as a type guard, narrowing the type of `val` to `number` if it returns true. 9 | * 10 | * @param value - The value to test. 11 | * @returns `true` if `val` is a finite number; otherwise `false`. 12 | */ 13 | static isNumeric(value: unknown): value is number { 14 | return Number.isFinite(Number(value)); 15 | } 16 | 17 | /** 18 | * Attempts to convert the given value to a number. 19 | * If the value is not numeric (per NumberUtils.isNumeric), returns undefined. 20 | * 21 | * @param {*} value - The value to convert. 22 | * @returns {(number|undefined)} The numeric value if conversion succeeded; otherwise undefined. 23 | */ 24 | static tryToNumber(value: any): number | undefined { 25 | if (!NumberUtils.isNumeric(value)) return undefined; 26 | return typeof value === "number" ? value : Number(value); 27 | } 28 | 29 | /** 30 | * Converts a value to a number if possible, otherwise returns a provided default. 31 | * 32 | * @param value - The value to convert. 33 | * @param defaultValue - The number to return if `value` is not numeric. 34 | * @returns The numeric representation of `value` if it is numeric; otherwise `defaultValue`. 35 | */ 36 | static toNumberOrDefault(value: any, defaultValue: number): number { 37 | return NumberUtils.tryToNumber(value) ?? defaultValue; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/dependencies/ha/data/entity.ts: -------------------------------------------------------------------------------- 1 | import { HassEntity } from "home-assistant-js-websocket"; 2 | import { computeDomain } from "../common/entity/compute_domain"; 3 | 4 | export const UNAVAILABLE = "unavailable"; 5 | export const UNKNOWN = "unknown"; 6 | 7 | export const ON = "on"; 8 | export const OFF = "off"; 9 | 10 | const OFF_STATES = [UNAVAILABLE, UNKNOWN, OFF]; 11 | 12 | export function isActive(stateObj: HassEntity) { 13 | const domain = computeDomain(stateObj.entity_id); 14 | const state = stateObj.state; 15 | 16 | if (["button", "input_button", "scene"].includes(domain)) { 17 | return state !== UNAVAILABLE; 18 | } 19 | 20 | if (OFF_STATES.includes(state)) { 21 | return false; 22 | } 23 | 24 | // Custom cases 25 | switch (domain) { 26 | case "cover": 27 | case "valve": 28 | return !["closed", "closing"].includes(state); 29 | case "device_tracker": 30 | case "person": 31 | return state !== "not_home"; 32 | case "media_player": 33 | return state !== "standby"; 34 | case "vacuum": 35 | return !["idle", "docked", "paused"].includes(state); 36 | case "plant": 37 | return state === "problem"; 38 | default: 39 | return true; 40 | } 41 | } 42 | 43 | export function isAvailable(stateObj: HassEntity) { 44 | return stateObj.state !== UNAVAILABLE; 45 | } 46 | 47 | export function isOff(stateObj: HassEntity) { 48 | return stateObj.state === OFF; 49 | } 50 | 51 | export function isUnknown(stateObj: HassEntity) { 52 | return stateObj.state === UNKNOWN; 53 | } 54 | 55 | export function getEntityPicture(stateObj: HassEntity) { 56 | return ( 57 | (stateObj.attributes.entity_picture_local as string | undefined) || 58 | stateObj.attributes.entity_picture 59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /src/dependencies/ha/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./common/const"; 2 | export * from "./common/dom/fire_event"; 3 | export * from "./common/entity/color/battery_color"; 4 | export * from "./common/entity/battery_icon"; 5 | export * from "./common/entity/compute_domain"; 6 | export * from "./common/entity/compute_state_domain"; 7 | export * from "./common/entity/supports-feature"; 8 | export * from "./common/number/clamp"; 9 | export * from "./common/number/format_number"; 10 | export * from "./common/number/round"; 11 | export * from "./common/structs/handle-errors"; 12 | export * from "./common/translations/blank_before_percent"; 13 | export * from "./common/translations/localize"; 14 | export * from "./common/util/compute_rtl"; 15 | export * from "./common/util/debounce"; 16 | export * from "./common/util/deep-equal"; 17 | export * from "./common/util/render-status"; 18 | export * from "./data/entity"; 19 | export * from "./data/lovelace"; 20 | export * from "./data/main_window"; 21 | export * from "./data/translation"; 22 | export * from "./data/ws-templates"; 23 | export * from "./data/ws-themes"; 24 | export * from "./panels/lovelace/common/directives/action-handler-directive"; 25 | export * from "./panels/lovelace/common/entity/turn-on-off-entities"; 26 | export * from "./panels/lovelace/common/entity/turn-on-off-entity"; 27 | export * from "./panels/lovelace/common/handle-actions"; 28 | export * from "./panels/lovelace/common/has-action"; 29 | export * from "./panels/lovelace/common/validate-condition"; 30 | export * from "./panels/lovelace/editor/structs/action-struct"; 31 | export * from "./panels/lovelace/editor/structs/base-card-struct"; 32 | export * from "./panels/lovelace/types"; 33 | export * from "./resources/ha-sortable-styles"; 34 | export * from "./util/calculate"; 35 | export * from "./types"; 36 | -------------------------------------------------------------------------------- /src/dependencies/ha/common/entity/battery_icon.ts: -------------------------------------------------------------------------------- 1 | import type { HassEntity } from "home-assistant-js-websocket"; 2 | 3 | const BATTERY_ICONS = { 4 | 10: "mdi:battery-10", 5 | 20: "mdi:battery-20", 6 | 30: "mdi:battery-30", 7 | 40: "mdi:battery-40", 8 | 50: "mdi:battery-50", 9 | 60: "mdi:battery-60", 10 | 70: "mdi:battery-70", 11 | 80: "mdi:battery-80", 12 | 90: "mdi:battery-90", 13 | 100: "mdi:battery", 14 | }; 15 | const BATTERY_CHARGING_ICONS = { 16 | 10: "mdi:battery-charging-10", 17 | 20: "mdi:battery-charging-20", 18 | 30: "mdi:battery-charging-30", 19 | 40: "mdi:battery-charging-40", 20 | 50: "mdi:battery-charging-50", 21 | 60: "mdi:battery-charging-60", 22 | 70: "mdi:battery-charging-70", 23 | 80: "mdi:battery-charging-80", 24 | 90: "mdi:battery-charging-90", 25 | 100: "mdi:battery-charging", 26 | }; 27 | 28 | export const batteryIcon = (stateObj: HassEntity, state?: string) => { 29 | const level = state ?? stateObj.state; 30 | return batteryLevelIcon(level); 31 | }; 32 | 33 | export const batteryLevelIcon = ( 34 | batteryLevel: number | string, 35 | isBatteryCharging?: boolean 36 | ): string => { 37 | const batteryValue = Number(batteryLevel); 38 | if (Number.isNaN(batteryValue)) { 39 | if (batteryLevel === "off") { 40 | return "mdi:battery"; 41 | } 42 | if (batteryLevel === "on") { 43 | return "mdi:battery-alert"; 44 | } 45 | return "mdi:battery-unknown"; 46 | } 47 | 48 | const batteryRound = Math.round(batteryValue / 10) * 10; 49 | if (isBatteryCharging && batteryValue >= 10) { 50 | return BATTERY_CHARGING_ICONS[batteryRound]; 51 | } 52 | if (isBatteryCharging) { 53 | return "mdi:battery-charging-outline"; 54 | } 55 | if (batteryValue <= 5) { 56 | return "mdi:battery-alert-variant-outline"; 57 | } 58 | return BATTERY_ICONS[batteryRound]; 59 | }; 60 | -------------------------------------------------------------------------------- /src/dependencies/ha/common/structs/handle-errors.ts: -------------------------------------------------------------------------------- 1 | import { StructError } from "superstruct"; 2 | import type { HomeAssistant } from "../../types"; 3 | 4 | export const handleStructError = ( 5 | hass: HomeAssistant, 6 | err: Error 7 | ): { warnings: string[]; errors?: string[] } => { 8 | if (!(err instanceof StructError)) { 9 | return { warnings: [err.message], errors: undefined }; 10 | } 11 | const errors: string[] = []; 12 | const warnings: string[] = []; 13 | for (const failure of err.failures()) { 14 | if (failure.value === undefined) { 15 | errors.push( 16 | hass.localize( 17 | "ui.errors.config.key_missing", 18 | "key", 19 | failure.path.join(".") 20 | ) 21 | ); 22 | } else if (failure.type === "never") { 23 | warnings.push( 24 | hass.localize( 25 | "ui.errors.config.key_not_expected", 26 | "key", 27 | failure.path.join(".") 28 | ) 29 | ); 30 | } else if (failure.type === "union") { 31 | continue; 32 | } else if (failure.type === "enums") { 33 | warnings.push( 34 | hass.localize( 35 | "ui.errors.config.key_wrong_type", 36 | "key", 37 | failure.path.join("."), 38 | "type_correct", 39 | failure.message.replace("Expected ", "").split(", ")[0], 40 | "type_wrong", 41 | JSON.stringify(failure.value) 42 | ) 43 | ); 44 | } else { 45 | warnings.push( 46 | hass.localize( 47 | "ui.errors.config.key_wrong_type", 48 | "key", 49 | failure.path.join("."), 50 | "type_correct", 51 | failure.refinement || failure.type, 52 | "type_wrong", 53 | JSON.stringify(failure.value) 54 | ) 55 | ); 56 | } 57 | } 58 | return { warnings, errors }; 59 | }; 60 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gauge-card-pro", 3 | "version": "1.8.1", 4 | "description": "Gauge Card Pro", 5 | "main": "index.js", 6 | "scripts": { 7 | "start:watch": "rollup -c --watch --bundleConfigAsCjs", 8 | "build": "rollup -c --bundleConfigAsCjs", 9 | "depcheck": "depcheck --ignores='eslint,@babel/preset-env'", 10 | "format": "prettier --write --list-different .", 11 | "start:hass-stable": "docker run --rm -p8123:8123 -v ./.hass_dev:/config homeassistant/home-assistant:stable", 12 | "start:hass": "docker run --rm -p8123:8123 -v ./.hass_dev:/config homeassistant/home-assistant:beta", 13 | "start:hass-dev": "docker run --rm -p8123:8123 -v ./.hass_dev:/config homeassistant/home-assistant:dev", 14 | "test": "vitest run --silent" 15 | }, 16 | "author": "Benjamin DCS", 17 | "repository": { 18 | "type": "git", 19 | "url": "https://github.com/benjamin-dcs/gauge-card-pro" 20 | }, 21 | "license": "ISC", 22 | "dependencies": { 23 | "@mdi/js": "7.4.47", 24 | "home-assistant-js-websocket": "9.5.0", 25 | "lit": "3.3.1", 26 | "memoize-one": "6.0.0", 27 | "object-hash": "3.0.0", 28 | "superstruct": "2.0.2", 29 | "tinygradient": "2.0.1", 30 | "zod": "4.1.12" 31 | }, 32 | "devDependencies": { 33 | "@babel/core": "7.28.5", 34 | "@babel/preset-env": "7.28.5", 35 | "@rollup/plugin-babel": "6.1.0", 36 | "@rollup/plugin-commonjs": "29.0.0", 37 | "@rollup/plugin-json": "6.1.0", 38 | "@rollup/plugin-node-resolve": "16.0.3", 39 | "@rollup/plugin-replace": "6.0.3", 40 | "@rollup/plugin-terser": "0.4.4", 41 | "@rollup/plugin-typescript": "12.3.0", 42 | "depcheck": "1.4.7", 43 | "eslint": "9.39.1", 44 | "prettier": "3.6.2", 45 | "rollup": "4.53.3", 46 | "rollup-plugin-serve": "1.1.1", 47 | "tslib": "2.8.1", 48 | "typescript": "5.9.3", 49 | "vitest": "4.0.13" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/tests/utils/icon.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest"; 2 | import { isIcon, getIcon } from "../../utils/string/icon"; 3 | 4 | describe("isIcon", () => { 5 | it("true", () => { 6 | const result = isIcon("icon(mdi:gauge)"); 7 | expect(result).toBe(true); 8 | }); 9 | 10 | it("more than just icon", () => { 11 | const result = isIcon("icon(mdi:gauge) kW"); 12 | expect(result).toBe(false); 13 | }); 14 | 15 | it("no wrapping", () => { 16 | const result = isIcon("mdi:gauge"); 17 | expect(result).toBe(false); 18 | }); 19 | 20 | it("just text", () => { 21 | const result = isIcon("3.14 kW"); 22 | expect(result).toBe(false); 23 | }); 24 | 25 | it("number", () => { 26 | const result = isIcon(123); 27 | expect(result).toBe(false); 28 | }); 29 | 30 | it("array", () => { 31 | const result = isIcon(["icon(mdi:gauge)"]); 32 | expect(result).toBe(false); 33 | }); 34 | 35 | it("undefined", () => { 36 | const result = isIcon(undefined); 37 | expect(result).toBe(false); 38 | }); 39 | }); 40 | 41 | describe("getIcon", () => { 42 | it("mdi:gauge", () => { 43 | const result = getIcon("icon(mdi:gauge)"); 44 | expect(result).toEqual("mdi:gauge"); 45 | }); 46 | 47 | it("more than just icon", () => { 48 | const result = getIcon("icon(mdi:gauge) kW"); 49 | expect(result).toEqual("icon(mdi:gauge) kW"); 50 | }); 51 | 52 | it("no wrapping", () => { 53 | const result = getIcon("mdi:gauge"); 54 | expect(result).toEqual("mdi:gauge"); 55 | }); 56 | 57 | it("just text", () => { 58 | const result = getIcon("3.14 kW"); 59 | expect(result).toEqual("3.14 kW"); 60 | }); 61 | 62 | it("number", () => { 63 | const result = getIcon(123); 64 | expect(result).toEqual(123); 65 | }); 66 | 67 | it("array", () => { 68 | const result = getIcon(["icon(mdi:gauge)"]); 69 | expect(result).toEqual(["icon(mdi:gauge)"]); 70 | }); 71 | 72 | it("undefined", () => { 73 | const result = getIcon(undefined); 74 | expect(result).toEqual(undefined); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /src/tests/utils/numbers.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest"; 2 | import { getAngle } from "../../utils/number/get-angle"; 3 | import { NumberUtils } from "../../utils/number/numberUtils"; 4 | 5 | describe("getAngle", () => { 6 | it("0-180 90", () => { 7 | const result = getAngle(90, 0, 180); 8 | expect(result).toEqual(90); 9 | }); 10 | 11 | it("0-360 180", () => { 12 | const result = getAngle(180, 0, 360); 13 | expect(result).toEqual(90); 14 | }); 15 | }); 16 | 17 | describe("isNumeric", () => { 18 | it("numeric 123", () => { 19 | const result = NumberUtils.isNumeric(123); 20 | expect(result).toEqual(true); 21 | }); 22 | 23 | it("string 123", () => { 24 | const result = NumberUtils.isNumeric("123"); 25 | expect(result).toEqual(true); 26 | }); 27 | 28 | it("hex", () => { 29 | const result = NumberUtils.isNumeric("0x10"); 30 | expect(result).toEqual(true); 31 | }); 32 | 33 | it("exp.", () => { 34 | const result = NumberUtils.isNumeric("1e3"); 35 | expect(result).toEqual(true); 36 | }); 37 | 38 | // Every object in JavaScript (and thus TypeScript) inherits a default valueOf() method from Object.prototype, 39 | // which normally returns the object itself. By providing your own valueOf method, you override that default. 40 | it("object", () => { 41 | const result = NumberUtils.isNumeric({ valueOf: () => 5 }); 42 | expect(result).toEqual(true); 43 | }); 44 | 45 | it("string", () => { 46 | const result = NumberUtils.isNumeric("abc"); 47 | expect(result).toEqual(false); 48 | }); 49 | }); 50 | 51 | describe("toNumberOrDefault", () => { 52 | it("return number if number", () => { 53 | const result = NumberUtils.toNumberOrDefault(123, 0); 54 | expect(result).toEqual(123); 55 | }); 56 | 57 | it("convert string to number", () => { 58 | const result = NumberUtils.toNumberOrDefault("123", 0); 59 | expect(result).toEqual(123); 60 | }); 61 | 62 | it("return default for NaN", () => { 63 | const result = NumberUtils.toNumberOrDefault("abc", 0); 64 | expect(result).toEqual(0); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /src/dependencies/ha/panels/lovelace/types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | LovelaceBadgeConfig, 3 | LovelaceCardConfig, 4 | LovelaceConfig, 5 | } from "../../data/lovelace"; 6 | import { FrontendLocaleData } from "../../data/translation"; 7 | import { Constructor, HomeAssistant } from "../../types"; 8 | 9 | declare global { 10 | // eslint-disable-next-line 11 | interface HASSDomEvents { 12 | "ll-rebuild": Record; 13 | "ll-badge-rebuild": Record; 14 | } 15 | } 16 | 17 | export interface Lovelace { 18 | config: LovelaceConfig; 19 | // If not set, a strategy was used to generate everything 20 | rawConfig: LovelaceConfig | undefined; 21 | editMode: boolean; 22 | urlPath: string | null; 23 | mode: "generated" | "yaml" | "storage"; 24 | locale: FrontendLocaleData; 25 | enableFullEditMode: () => void; 26 | setEditMode: (editMode: boolean) => void; 27 | saveConfig: (newConfig: LovelaceConfig) => Promise; 28 | deleteConfig: () => Promise; 29 | } 30 | 31 | export interface LovelaceBadge extends HTMLElement { 32 | hass?: HomeAssistant; 33 | setConfig(config: LovelaceBadgeConfig): void; 34 | } 35 | 36 | export interface LovelaceCard extends HTMLElement { 37 | hass?: HomeAssistant; 38 | isPanel?: boolean; 39 | editMode?: boolean; 40 | getCardSize(): number | Promise; 41 | setConfig(config: LovelaceCardConfig): void; 42 | } 43 | 44 | export interface LovelaceCardConstructor extends Constructor { 45 | getStubConfig?: ( 46 | hass: HomeAssistant, 47 | entities: string[], 48 | entitiesFallback: string[] 49 | ) => LovelaceCardConfig; 50 | getConfigElement?: () => LovelaceCardEditor; 51 | } 52 | 53 | export interface LovelaceCardEditor extends LovelaceGenericElementEditor { 54 | setConfig(config: LovelaceCardConfig): void; 55 | } 56 | 57 | export interface LovelaceBadgeEditor extends LovelaceGenericElementEditor { 58 | setConfig(config: LovelaceBadgeConfig): void; 59 | } 60 | 61 | export interface LovelaceGenericElementEditor extends HTMLElement { 62 | hass?: HomeAssistant; 63 | lovelace?: LovelaceConfig; 64 | setConfig(config: any): void; 65 | focusYamlEditor?: () => void; 66 | } 67 | -------------------------------------------------------------------------------- /examples/energy-grid-neutrality-gauge.md: -------------------------------------------------------------------------------- 1 | # Energy Grid Neutrality Gauge 2 | 3 | ![image](https://github.com/user-attachments/assets/a2ec069d-fd1f-4437-b755-21e87e43ffc8) 4 | 5 | ```yaml 6 | type: custom:gauge-card-pro 7 | value: |- 8 | {% set consumedFromGrid = 9 | states('sensor.p1_meter_energy_import_tariff_1_daily') | float 10 | + 11 | states('sensor.p1_meter_energy_import_tariff_2_daily') | float 12 | %} 13 | 14 | {% set returnedToGrid = 15 | states('sensor.p1_meter_energy_export_tariff_1_daily') | float 16 | + 17 | states('sensor.p1_meter_energy_export_tariff_2_daily') | float 18 | %} 19 | 20 | {% if returnedToGrid > consumedFromGrid %} 21 | {{ 1 - consumedFromGrid / returnedToGrid }} 22 | {% else %} 23 | {{ (1 - returnedToGrid / consumedFromGrid) * -1 }} 24 | {% endif %} 25 | value_texts: 26 | primary: |- 27 | {% set consumedFromGrid = 28 | states('sensor.p1_meter_energy_import_tariff_1_daily') | float 29 | + 30 | states('sensor.p1_meter_energy_import_tariff_2_daily') | float 31 | %} 32 | 33 | {% set returnedToGrid = 34 | states('sensor.p1_meter_energy_export_tariff_1_daily') | float 35 | + 36 | states('sensor.p1_meter_energy_export_tariff_2_daily') | float 37 | %} 38 | {{ (returnedToGrid - consumedFromGrid) | abs | round(1) | replace('.', ',') }} kWh 39 | titles: 40 | name: |- 41 | {% set consumedFromGrid = 42 | states('sensor.p1_meter_energy_import_tariff_1_daily') | float 43 | + 44 | states('sensor.p1_meter_energy_import_tariff_2_daily') | float 45 | %} 46 | 47 | {% set returnedToGrid = 48 | states('sensor.p1_meter_energy_export_tariff_1_daily') | float 49 | + 50 | states('sensor.p1_meter_energy_export_tariff_2_daily') | float 51 | %} 52 | 53 | {% if returnedToGrid > consumedFromGrid %} 54 | Returned 55 | {% else %} 56 | Consumed 57 | {% endif %} 58 | min: "-1" 59 | max: "1" 60 | needle: true 61 | segments: 62 | - from: -1 63 | color: var(--error-color) 64 | - from: 0 65 | color: var(--warning-color) 66 | - from: 1 67 | color: var(--success-color) 68 | gradient: true 69 | gradient_resolution: medium 70 | ``` 71 | -------------------------------------------------------------------------------- /src/utils/object/move-key.ts: -------------------------------------------------------------------------------- 1 | // General utilities 2 | import { deleteKey } from "./delete-key"; 3 | import { trySetValue } from "./set-value"; 4 | 5 | /** 6 | * Creates a deep clone of the given object and moves a property from one path to another. 7 | * 8 | * The function: 9 | * 1. Clones the `source` object using `structuredClone`, so the original remains unmodified. 10 | * 2. Reads the value at the nested path specified by `from` (dot-separated). 11 | * 3. If the value exists, attempts to set it at the `to` path (dot-separated), 12 | * optionally allowing overwriting of existing values. 13 | * 4. If setting succeeds, deletes the original key at the `from` path in the clone. 14 | * 15 | * @param {any} source - The object (or value) to operate on. Can be any JSON-serializable value. 16 | * @param {string} from - Dot-separated path to the key in the source object to move (e.g. `"a.b.c"`). 17 | * @param {string} to - Dot-separated path where the key should be moved (e.g. `"x.y.z"`). 18 | * @param {boolean} [overwrite=false] - If `true`, existing values at the `to` path will be overwritten. 19 | * If `false`, the move will not override existing values. 20 | * @returns {any} A new object (clone of `source`) with the key moved if possible; otherwise, the unchanged clone. 21 | */ 22 | export function moveKey( 23 | source: any, 24 | from: string, 25 | to: string, 26 | overwrite: boolean = false 27 | ): any { 28 | const clone = JSON.parse(JSON.stringify(source)); 29 | const fromParts = from.split("."); 30 | 31 | let fromObj = clone; 32 | for (let i = 0; i < fromParts.length - 1; i++) { 33 | fromObj = fromObj?.[fromParts[i]]; 34 | if (typeof fromObj !== "object" || fromObj === null) { 35 | return clone; 36 | } 37 | } 38 | 39 | const keyFrom = fromParts[fromParts.length - 1]; 40 | const value = fromObj?.[keyFrom]; 41 | 42 | if (value === undefined) { 43 | return clone; 44 | } 45 | 46 | let { result: newClone, success } = trySetValue( 47 | clone, 48 | to, 49 | value, 50 | true, 51 | overwrite 52 | ); 53 | 54 | if (success) newClone = deleteKey(newClone, from).result; 55 | 56 | return newClone; 57 | } 58 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import { 2 | getBabelInputPlugin, 3 | getBabelOutputPlugin, 4 | } from "@rollup/plugin-babel"; 5 | import commonjs from "@rollup/plugin-commonjs"; 6 | import json from "@rollup/plugin-json"; 7 | import nodeResolve from "@rollup/plugin-node-resolve"; 8 | import replace from "@rollup/plugin-replace"; 9 | import terser from "@rollup/plugin-terser"; 10 | import typescript from "@rollup/plugin-typescript"; 11 | import serve from "rollup-plugin-serve"; 12 | 13 | // Use the existing NODE_ENV variable for both purposes 14 | const dev = process.env.ROLLUP_WATCH; 15 | 16 | const serveOptions = { 17 | contentBase: ["./dist"], 18 | host: "0.0.0.0", 19 | port: 4000, 20 | allowCrossOrigin: true, 21 | headers: { 22 | "Access-Control-Allow-Origin": "*", 23 | }, 24 | }; 25 | 26 | const plugins = [ 27 | replace({ 28 | preventAssignment: true, 29 | delimiters: ["", ""], 30 | // Change log level in constants.ts to 0 in production 31 | "CURRENT_LOG_LEVEL: 1": `CURRENT_LOG_LEVEL: ${dev ? 0 : 1}`, 32 | "CURRENT_LOG_LEVEL: 2": `CURRENT_LOG_LEVEL: ${dev ? 0 : 2}`, 33 | "CURRENT_LOG_LEVEL: 3": `CURRENT_LOG_LEVEL: ${dev ? 0 : 3}`, 34 | }), 35 | typescript({ 36 | declaration: false, 37 | }), 38 | nodeResolve(), 39 | json(), 40 | commonjs(), 41 | getBabelInputPlugin({ 42 | babelHelpers: "bundled", 43 | }), 44 | getBabelOutputPlugin({ 45 | presets: [ 46 | [ 47 | "@babel/preset-env", 48 | { 49 | modules: false, 50 | }, 51 | ], 52 | ], 53 | compact: !dev, 54 | }), 55 | ...(dev ? [serve(serveOptions)] : [terser()]), 56 | ]; 57 | 58 | export default [ 59 | { 60 | input: "src/card/card.ts", 61 | output: { 62 | file: "dist/gauge-card-pro.js", 63 | format: "es", 64 | inlineDynamicImports: true, 65 | }, 66 | plugins, 67 | moduleContext: (id) => { 68 | const thisAsWindowForModules = [ 69 | "node_modules/@formatjs/intl-utils/lib/src/diff.js", 70 | "node_modules/@formatjs/intl-utils/lib/src/resolve-locale.js", 71 | ]; 72 | if (thisAsWindowForModules.some((id_) => id.trimRight().endsWith(id_))) { 73 | return "window"; 74 | } 75 | }, 76 | }, 77 | ]; 78 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Report an issue with Gauge Card Pro 2 | description: Report an issue with Gauge Card Pro 3 | body: 4 | - type: markdown 5 | attributes: 6 | value: | 7 | ## FAQ - Read this first 8 | Please checkout the [Frequently Asked Question](https://github.com/benjamin-dcs/gauge-card-pro/blob/main/FAQ.md) first and make sure your question is not already answered there. 9 | 10 | Also, please search through the earlier [closed issues](https://github.com/benjamin-dcs/gauge-card-pro/issues?q=is%3Aissue%20state%3Aclosed) if you bug has been reported before 11 | - type: markdown 12 | attributes: 13 | value: | 14 | ## Report the bug 15 | - type: textarea 16 | validations: 17 | required: true 18 | attributes: 19 | label: The problem 20 | description: >- 21 | Describe the issue you are experiencing here, to communicate to the 22 | maintainers. Tell us what you were trying to do and what happened. 23 | 24 | Provide a clear and concise description of what the problem is. 25 | - type: textarea 26 | validations: 27 | required: true 28 | attributes: 29 | label: Expectation 30 | description: >- 31 | Please describe as detailed as possible what you expected to happen 32 | - type: textarea 33 | attributes: 34 | label: YAML configuration 35 | description: | 36 | If applicable, please provide an example piece of YAML that can help reproduce this problem. 37 | This can be from an automation, script, scene or configuration. 38 | render: yaml 39 | - type: textarea 40 | attributes: 41 | label: Screenshots 42 | description: | 43 | If applicable, please provide (a) screenshot(s) of your card illustrating the problem 44 | - type: textarea 45 | attributes: 46 | label: Anything in the browser console (Ctrl + Shift + J or Cmd + Option + J) that might be useful for us? 47 | description: For example, error message, or stack traces. 48 | render: txt 49 | - type: textarea 50 | attributes: 51 | label: Additional information 52 | description: > 53 | If you have any additional information for us, use the field below. 54 | -------------------------------------------------------------------------------- /src/utils/object/set-value.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Deep-clones the given source object and attempts to set a value at the specified 3 | * dot-delimited path. Returns the cloned object and whether the assignment succeeded. 4 | * 5 | * @param {T} source 6 | * The original object or value to clone. This function will not mutate the input. 7 | * @param {string} key 8 | * A dot-delimited path specifying where to set the value (e.g. `"user.profile.name"`). 9 | * @param {*} value 10 | * The value to assign at the target path. 11 | * @param {boolean} [createMissingObjects=false] 12 | * If `true`, any missing nested objects along the path will be created as empty objects. 13 | * If `false`, the function will abort and return `success: false` if any part of the 14 | * path is missing or not an object. 15 | * @param {boolean} [overwrite=false] 16 | * If `true`, will overwrite an existing value at the target path. If `false`, 17 | * and the property already exists (not `undefined`), the function will not modify it 18 | * and will return `success: false`. 19 | * 20 | * @returns {{ result: T, success: boolean }} 21 | * - `result`: A deep clone of `source` with the attempted assignment applied (if successful). 22 | * - `success`: `true` if the value was set, `false` otherwise. 23 | */ 24 | export function trySetValue( 25 | source: any, 26 | key: string, 27 | value: any, 28 | createMissingObjects: boolean = false, 29 | overwrite: boolean = false 30 | ): { result: any; success: boolean } { 31 | const clone = JSON.parse(JSON.stringify(source)); // deep clone so we don't mutate 32 | const keyParts = key.split("."); 33 | 34 | let newObj = clone; 35 | for (let i = 0; i < keyParts.length - 1; i++) { 36 | if ( 37 | typeof newObj[keyParts[i]] !== "object" || 38 | newObj[keyParts[i]] === null || 39 | newObj[keyParts[i]] === undefined 40 | ) { 41 | if (createMissingObjects) { 42 | newObj[keyParts[i]] = {}; 43 | } else { 44 | return { result: clone, success: false }; 45 | } 46 | } 47 | newObj = newObj[keyParts[i]]; 48 | } 49 | 50 | const keyTo = keyParts[keyParts.length - 1]; 51 | 52 | if (overwrite || newObj[keyTo] === undefined) { 53 | newObj[keyTo] = value; 54 | return { result: clone, success: true }; 55 | } 56 | 57 | return { result: clone, success: false }; 58 | } 59 | -------------------------------------------------------------------------------- /src/utils/color/get-interpolated-color.ts: -------------------------------------------------------------------------------- 1 | // External dependencies 2 | import { tinygradient } from "tinygradient"; 3 | 4 | // Local constants & types 5 | import { GradientSegment } from "../../card/config"; 6 | 7 | interface SingleSegment { 8 | min: number; 9 | colorMin: string; 10 | max: number; 11 | colorMax: string; 12 | value: number; 13 | } 14 | 15 | interface SegmentsArray { 16 | gradientSegments: GradientSegment[]; 17 | min: number; 18 | max: number; 19 | value: number; 20 | } 21 | 22 | /** 23 | * Computes a hex color by interpolating across a gradient based on a numeric value. 24 | * 25 | * This function accepts either: 26 | * 1. A simple two-color segment (`SingleSegment`), defined by 27 | * - `colorMin` (color at `min`) and `colorMax` (color at `max`), 28 | * - `min` and `max` bounds, 29 | * - `value` to interpolate. 30 | * 2. An arbitrary gradient (`SegmentsArray`), defined by 31 | * - `gradientSegments`: an array of `{ pos: number; color: string }` entries, 32 | * - `min` and `max` bounds, 33 | * - `value` to interpolate. 34 | * 35 | * It normalizes `value` into the [0,1] range, rounds to two decimal places, 36 | * then uses `tinygradient` to pick the corresponding RGB color and return it as a hex string. 37 | * If `value` is outside the `[min, max]` range, it returns `undefined`. 38 | * 39 | * @param opts - Either a `SingleSegment` or `SegmentsArray` describing the gradient and value. 40 | * @returns A hex color string (e.g. `"#3fa9f5"`) corresponding to the interpolated point, 41 | * or `undefined` if `value < min` or `value > max`. 42 | */ 43 | export function getInterpolatedColor(opts: SingleSegment): string | undefined; 44 | export function getInterpolatedColor(opts: SegmentsArray): string | undefined; 45 | export function getInterpolatedColor( 46 | opts: SingleSegment | SegmentsArray 47 | ): string | undefined { 48 | const gradientSegments = 49 | "gradientSegments" in opts 50 | ? opts.gradientSegments 51 | : [ 52 | { pos: 0, color: opts.colorMin }, 53 | { pos: 1, color: opts.colorMax }, 54 | ]; 55 | 56 | const min = opts.min; 57 | const max = opts.max; 58 | const value = opts.value; 59 | 60 | if (value < min || value > max) return; 61 | 62 | const _tinygradient = tinygradient(gradientSegments); 63 | let pos: number; 64 | pos = (value - min) / (max - min); 65 | pos = Math.round(pos * 100) / 100; 66 | 67 | return _tinygradient.rgbAt(pos).toHexString(); 68 | } 69 | -------------------------------------------------------------------------------- /src/dependencies/ha/resources/ha-sortable-styles.ts: -------------------------------------------------------------------------------- 1 | import { css } from "lit"; 2 | 3 | export const sortableStyles = css` 4 | #sortable a:nth-of-type(2n) paper-icon-item { 5 | animation-name: keyframes1; 6 | animation-iteration-count: infinite; 7 | transform-origin: 50% 10%; 8 | animation-delay: -0.75s; 9 | animation-duration: 0.25s; 10 | } 11 | 12 | #sortable a:nth-of-type(2n-1) paper-icon-item { 13 | animation-name: keyframes2; 14 | animation-iteration-count: infinite; 15 | animation-direction: alternate; 16 | transform-origin: 30% 5%; 17 | animation-delay: -0.5s; 18 | animation-duration: 0.33s; 19 | } 20 | 21 | #sortable a { 22 | height: 48px; 23 | display: flex; 24 | } 25 | 26 | #sortable { 27 | outline: none; 28 | display: block !important; 29 | } 30 | 31 | .hidden-panel { 32 | display: flex !important; 33 | } 34 | 35 | .sortable-fallback { 36 | display: none; 37 | } 38 | 39 | .sortable-ghost { 40 | opacity: 0.4; 41 | } 42 | 43 | .sortable-fallback { 44 | opacity: 0; 45 | } 46 | 47 | @keyframes keyframes1 { 48 | 0% { 49 | transform: rotate(-1deg); 50 | animation-timing-function: ease-in; 51 | } 52 | 53 | 50% { 54 | transform: rotate(1.5deg); 55 | animation-timing-function: ease-out; 56 | } 57 | } 58 | 59 | @keyframes keyframes2 { 60 | 0% { 61 | transform: rotate(1deg); 62 | animation-timing-function: ease-in; 63 | } 64 | 65 | 50% { 66 | transform: rotate(-1.5deg); 67 | animation-timing-function: ease-out; 68 | } 69 | } 70 | 71 | .show-panel, 72 | .hide-panel { 73 | display: none; 74 | position: absolute; 75 | top: 0; 76 | right: 4px; 77 | --mdc-icon-button-size: 40px; 78 | } 79 | 80 | :host([rtl]) .show-panel { 81 | right: initial; 82 | left: 4px; 83 | } 84 | 85 | .hide-panel { 86 | top: 4px; 87 | right: 8px; 88 | } 89 | 90 | :host([rtl]) .hide-panel { 91 | right: initial; 92 | left: 8px; 93 | } 94 | 95 | :host([expanded]) .hide-panel { 96 | display: block; 97 | } 98 | 99 | :host([expanded]) .show-panel { 100 | display: inline-flex; 101 | } 102 | 103 | paper-icon-item.hidden-panel, 104 | paper-icon-item.hidden-panel span, 105 | paper-icon-item.hidden-panel ha-icon[slot="item-icon"] { 106 | color: var(--secondary-text-color); 107 | cursor: pointer; 108 | } 109 | `; 110 | -------------------------------------------------------------------------------- /src/utils/migrate-parameters.ts: -------------------------------------------------------------------------------- 1 | // External dependencies 2 | import { z } from "zod"; 3 | 4 | // General utilities 5 | import { moveKey } from "./object/move-key"; 6 | import { trySetValue } from "./object/set-value"; 7 | 8 | import { GaugeCardProCardConfig } from "../card/config"; 9 | 10 | export function migrate_parameters(config: GaugeCardProCardConfig | any) { 11 | if (!config) return; 12 | 13 | // 1.2.0 - May 27 '25 14 | if (config.setpoint !== null && config.setpoint?.type === undefined) { 15 | if (typeof config.setpoint?.value === "number") { 16 | config = trySetValue( 17 | config, 18 | "setpoint.type", 19 | "number", 20 | true, 21 | false 22 | ).result; 23 | } else if (typeof config.setpoint?.value === "string") { 24 | config = trySetValue( 25 | config, 26 | "setpoint.type", 27 | "template", 28 | true, 29 | false 30 | ).result; 31 | } 32 | } 33 | 34 | // 1.5.1 - Jul 10 '25 35 | if (config.icon?.battery !== undefined) { 36 | config = moveKey(config, "icon.battery", "icon.value"); 37 | config = trySetValue(config, "icon.type", "battery", true, false).result; 38 | } 39 | 40 | if (config.icon?.template !== undefined) { 41 | config = moveKey(config, "icon.template", "icon.value"); 42 | config = trySetValue(config, "icon.type", "template", true, false).result; 43 | } 44 | 45 | // 1.8.0 46 | if (config.shapes?.main_needle_with_inner !== undefined) { 47 | config = moveKey( 48 | config, 49 | "shapes.main_needle_with_inner", 50 | "shapes.main_needle" 51 | ); 52 | } 53 | 54 | if (config.shapes?.main_min_indicator_with_inner !== undefined) { 55 | config = moveKey( 56 | config, 57 | "shapes.main_min_indicator_with_inner", 58 | "shapes.main_min_indicator" 59 | ); 60 | } 61 | 62 | if (config.shapes?.main_min_indicator_with_inner !== undefined) { 63 | config = moveKey( 64 | config, 65 | "shapes.main_max_indicator_with_inner", 66 | "shapes.main_max_indicator" 67 | ); 68 | } 69 | 70 | if (config.shapes?.inner_needle_on_main !== undefined) { 71 | config = moveKey( 72 | config, 73 | "shapes.inner_needle_on_main", 74 | "shapes.inner_needle" 75 | ); 76 | } 77 | 78 | if (config.shapes?.inner_setpoint_needle_on_main) { 79 | config = moveKey( 80 | config, 81 | "shapes.inner_setpoint_needle_on_main", 82 | "shapes.inner_setpoint_needle" 83 | ); 84 | } 85 | 86 | return config; 87 | } 88 | -------------------------------------------------------------------------------- /src/tests/utils/objects.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest"; 2 | import { moveKey } from "../../utils/object/move-key"; 3 | import { deleteKey } from "../../utils/object/delete-key"; 4 | import { trySetValue } from "../../utils/object/set-value"; 5 | 6 | describe("trySetValue", () => { 7 | it("delete 1", () => { 8 | const { result, success } = deleteKey({ a: { b: { c: 42 } } }, "a.b.c"); 9 | expect(success).toBe(true); 10 | expect(result).toEqual({ a: { b: {} } }); 11 | }); 12 | }); 13 | 14 | describe("trySetValue", () => { 15 | it("sets a new value in an empty object", () => { 16 | const { result, success } = trySetValue({}, "a.b.c", 42, true); 17 | expect(success).toBe(true); 18 | expect(result).toEqual({ a: { b: { c: 42 } } }); 19 | }); 20 | 21 | it("does not overwrite an existing value by default", () => { 22 | const { result, success } = trySetValue( 23 | { a: { b: { c: 1 } } }, 24 | "a.b.c", 25 | 42 26 | ); 27 | expect(success).toBe(false); 28 | expect(result.a.b.c).toBe(1); 29 | }); 30 | 31 | it("overwrites a value when overwrite is true", () => { 32 | const { result, success } = trySetValue( 33 | { a: { b: { c: 1 } } }, 34 | "a.b.c", 35 | 42, 36 | true, 37 | true 38 | ); 39 | expect(success).toBe(true); 40 | expect(result.a.b.c).toBe(42); 41 | }); 42 | 43 | it("returns unchanged clone if path is missing and create_missing_objects is false", () => { 44 | const original = { a: {} }; 45 | const { result, success } = trySetValue(original, "a.b.c", 42, false); 46 | expect(success).toBe(false); 47 | expect(result).toEqual(original); 48 | }); 49 | }); 50 | 51 | describe("moveKey", () => { 52 | it("moves a shallow key", () => { 53 | const input = { x: 1 }; 54 | const output = moveKey(input, "x", "y"); 55 | expect(output).toEqual({ y: 1 }); 56 | }); 57 | 58 | it("moves a deep key into a new path", () => { 59 | const input = { a: { b: { c: 99 } } }; 60 | const output = moveKey(input, "a.b.c", "x.y.z"); 61 | expect(output).toEqual({ a: { b: {} }, x: { y: { z: 99 } } }); 62 | }); 63 | 64 | it("does not overwrite an existing key unless overwrite=true", () => { 65 | const input = { a: { b: { c: 123 } }, x: { y: { z: 999 } } }; 66 | const output = moveKey(input, "a.b.c", "x.y.z", false); 67 | expect(output.a.b).toEqual({ c: 123 }); // not deleted 68 | expect(output.x.y.z).toBe(999); // not overwritten 69 | }); 70 | 71 | it("overwrites and deletes source when overwrite=true", () => { 72 | const input = { a: { b: { c: 10 } }, x: { y: { z: 999 } } }; 73 | const output = moveKey(input, "a.b.c", "x.y.z", true); 74 | expect(output.a.b).toEqual({}); // key deleted 75 | expect(output.x.y.z).toBe(10); // overwritten 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /src/card/css/gauge.ts: -------------------------------------------------------------------------------- 1 | import { css } from "lit"; 2 | 3 | export const gaugeCSS = css` 4 | .elements-group { 5 | display: block; 6 | } 7 | .main-background { 8 | fill: none; 9 | stroke-width: 15; 10 | } 11 | .gradient-background { 12 | opacity: 0.25; 13 | } 14 | .min-max-indicator { 15 | transition: all 1s ease 0s; 16 | stroke: none; 17 | } 18 | .inner-gauge { 19 | position: absolute; 20 | top: 0; 21 | } 22 | .value { 23 | fill: none; 24 | stroke-width: 15; 25 | transition: all 1s ease 0s; 26 | } 27 | .inner-gradient-bg-bg { 28 | fill: none; 29 | stroke-width: 5; 30 | stroke: #ffffff; 31 | } 32 | .inner-value { 33 | fill: none; 34 | stroke-width: 5; 35 | transition: all 1.5s ease 0s; 36 | } 37 | .inner-value-stroke { 38 | fill: none; 39 | stroke-width: 6; 40 | stroke: var(--card-background-color); 41 | transition: all 1.5s ease 0s; 42 | } 43 | .needles { 44 | position: absolute; 45 | top: 0; 46 | } 47 | .needle { 48 | transition: all 1s ease 0s; 49 | } 50 | .segment { 51 | fill: none; 52 | stroke-width: 15; 53 | } 54 | .inner-segment { 55 | fill: none; 56 | stroke-width: 5; 57 | } 58 | .primary-value-text { 59 | position: absolute; 60 | 61 | max-width: 55%; 62 | left: 50%; 63 | bottom: -6%; 64 | transform: translate(-50%, 0%); 65 | } 66 | .primary-value-icon { 67 | position: absolute; 68 | height: 40%; 69 | width: 100%; 70 | bottom: -3%; 71 | } 72 | .primary-value-state-icon { 73 | --mdc-icon-size: 19%; 74 | } 75 | .secondary-value-text { 76 | position: absolute; 77 | max-height: 22%; 78 | max-width: 30%; 79 | left: 50%; 80 | bottom: 28%; 81 | transform: translate(-50%, 0%); 82 | } 83 | .secondary-value-icon { 84 | position: absolute; 85 | height: 22%; 86 | width: 100%; 87 | bottom: 32%; 88 | } 89 | .secondary-value-state-icon { 90 | --mdc-icon-size: 10%; 91 | } 92 | .value-text { 93 | font-size: 50px; 94 | text-anchor: middle; 95 | direction: ltr; 96 | } 97 | 98 | .icon { 99 | position: absolute; 100 | bottom: 0%; 101 | text-align: center; 102 | line-height: 0; 103 | } 104 | 105 | .icon-container { 106 | position: absolute; 107 | height: 17%; 108 | width: 100%; 109 | top: 0%; 110 | } 111 | 112 | .icon-inner-container { 113 | display: flex; 114 | height: 100%; 115 | width: 10%; 116 | justify-content: center; 117 | --mdc-icon-size: 100%; 118 | } 119 | 120 | .icon-label-text { 121 | position: absolute; 122 | max-height: 65%; 123 | width: 200%; 124 | top: 100%; 125 | min-height: 10px; 126 | } 127 | `; 128 | -------------------------------------------------------------------------------- /src/tests/card/getLightDarkModeColor.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, afterEach } from "vitest"; 2 | import { computeDarkMode } from "../../dependencies/mushroom/utils/base-element"; 3 | import { GaugeCardProCard } from "../../card/card"; 4 | 5 | vi.mock("../../utils/color/computed-color", () => ({ 6 | getComputedColor: (color: string) => { 7 | switch (color) { 8 | case "var(--info-color)": 9 | return "#039be5"; 10 | default: 11 | return color; 12 | } 13 | }, 14 | })); 15 | 16 | vi.mock( 17 | "../../dependencies/ha/panels/lovelace/common/directives/action-handler-directive.ts", 18 | () => ({ isTouch: () => false }) 19 | ); 20 | 21 | vi.mock("../../dependencies/mushroom/utils/custom-cards.ts", () => ({ 22 | registerCustomCard: () => "", 23 | })); 24 | 25 | vi.mock("../../dependencies/mushroom/utils/base-element"); 26 | 27 | describe("getLightDarkModeColor", () => { 28 | afterEach(() => { 29 | vi.clearAllMocks(); 30 | }); 31 | 32 | type TestCase = { 33 | name: string; 34 | configColor: any; 35 | mode: "light" | "dark"; 36 | expected: string | undefined; 37 | }; 38 | 39 | const cases: TestCase[] = [ 40 | { 41 | name: "single color", 42 | configColor: "#aaaaaa", 43 | mode: "light", 44 | expected: "#aaaaaa", 45 | }, 46 | { 47 | name: "light_mode", 48 | configColor: { light_mode: "#ffffff", dark_mode: "#000000" }, 49 | mode: "light", 50 | expected: "#ffffff", 51 | }, 52 | { 53 | name: "dark_mode", 54 | configColor: { light_mode: "#ffffff", dark_mode: "#000000" }, 55 | mode: "dark", 56 | expected: "#000000", 57 | }, 58 | { 59 | name: "missing dark_mode", 60 | configColor: { light_mode: "#ffffff" }, 61 | mode: "dark", 62 | expected: "#123456", 63 | }, 64 | { 65 | name: "missing light_mode", 66 | configColor: { dark_mode: "#000000" }, 67 | mode: "dark", 68 | expected: "#123456", 69 | }, 70 | { 71 | name: "undefined", 72 | configColor: undefined, 73 | mode: "dark", 74 | expected: "#123456", 75 | }, 76 | ]; 77 | 78 | it.each(cases)("$name", ({ configColor, mode, expected }) => { 79 | // mock computeDarkMode 80 | vi.mocked(computeDarkMode).mockReturnValue(mode === "dark"); 81 | 82 | // mock card.getValue() 83 | const card = new GaugeCardProCard(); 84 | vi.spyOn(card, "getValue").mockImplementation((key: string) => { 85 | switch (key) { 86 | case "needle_color": 87 | return configColor; 88 | default: 89 | return undefined; 90 | } 91 | }); 92 | 93 | const result = card["getLightDarkModeColor"]("needle_color", "#123456"); 94 | expect(card.getValue).toHaveBeenCalledOnce; 95 | expect(result).toBe(expected); 96 | }); 97 | }); 98 | -------------------------------------------------------------------------------- /src/dependencies/ha/common/util/deep-equal.ts: -------------------------------------------------------------------------------- 1 | // From https://github.com/epoberezkin/fast-deep-equal 2 | // MIT License - Copyright (c) 2017 Evgeny Poberezkin 3 | export const deepEqual = (a: any, b: any): boolean => { 4 | if (a === b) { 5 | return true; 6 | } 7 | 8 | if (a && b && typeof a === "object" && typeof b === "object") { 9 | if (a.constructor !== b.constructor) { 10 | return false; 11 | } 12 | 13 | let i: number | [any, any]; 14 | let length: number; 15 | if (Array.isArray(a)) { 16 | length = a.length; 17 | if (length !== b.length) { 18 | return false; 19 | } 20 | for (i = length; i-- !== 0; ) { 21 | if (!deepEqual(a[i], b[i])) { 22 | return false; 23 | } 24 | } 25 | return true; 26 | } 27 | 28 | if (a instanceof Map && b instanceof Map) { 29 | if (a.size !== b.size) { 30 | return false; 31 | } 32 | for (i of a.entries()) { 33 | if (!b.has(i[0])) { 34 | return false; 35 | } 36 | } 37 | for (i of a.entries()) { 38 | if (!deepEqual(i[1], b.get(i[0]))) { 39 | return false; 40 | } 41 | } 42 | return true; 43 | } 44 | 45 | if (a instanceof Set && b instanceof Set) { 46 | if (a.size !== b.size) { 47 | return false; 48 | } 49 | for (i of a.entries()) { 50 | if (!b.has(i[0])) { 51 | return false; 52 | } 53 | } 54 | return true; 55 | } 56 | 57 | if (ArrayBuffer.isView(a) && ArrayBuffer.isView(b)) { 58 | // @ts-ignore 59 | length = a.length; 60 | // @ts-ignore 61 | if (length !== b.length) { 62 | return false; 63 | } 64 | for (i = length; i-- !== 0; ) { 65 | if (a[i] !== b[i]) { 66 | return false; 67 | } 68 | } 69 | return true; 70 | } 71 | 72 | if (a.constructor === RegExp) { 73 | return a.source === b.source && a.flags === b.flags; 74 | } 75 | if (a.valueOf !== Object.prototype.valueOf) { 76 | return a.valueOf() === b.valueOf(); 77 | } 78 | if (a.toString !== Object.prototype.toString) { 79 | return a.toString() === b.toString(); 80 | } 81 | 82 | const keys = Object.keys(a); 83 | length = keys.length; 84 | if (length !== Object.keys(b).length) { 85 | return false; 86 | } 87 | for (i = length; i-- !== 0; ) { 88 | if (!Object.prototype.hasOwnProperty.call(b, keys[i])) { 89 | return false; 90 | } 91 | } 92 | 93 | for (i = length; i-- !== 0; ) { 94 | const key = keys[i]; 95 | 96 | if (!deepEqual(a[key], b[key])) { 97 | return false; 98 | } 99 | } 100 | 101 | return true; 102 | } 103 | 104 | // true if both NaN, false otherwise 105 | // eslint-disable-next-line no-self-compare 106 | return a !== a && b !== b; 107 | }; 108 | -------------------------------------------------------------------------------- /src/card/const.ts: -------------------------------------------------------------------------------- 1 | import { getComputedColor } from "../utils/color/computed-color"; 2 | import { version } from "../../package.json"; 3 | 4 | export const VERSION = version; 5 | 6 | export const LOGGING = { 7 | /** 8 | * Current log level 9 | * 0 = ERROR, 1 = WARN, 2 = INFO, 3 = DEBUG 10 | */ 11 | CURRENT_LOG_LEVEL: 3, 12 | 13 | /** Standard prefix for log messages */ 14 | PREFIX: "🌈 Gauge Card Pro", 15 | }; 16 | 17 | export const ERROR_COLOR = getComputedColor("var(--error-color)") || "#db4437"; 18 | export const SUCCESS_COLOR = 19 | getComputedColor("var(--success-color") || "#43a047"; 20 | export const WARNING_COLOR = 21 | getComputedColor("var(--warning-color") || "#ffa600"; 22 | export const INFO_COLOR = getComputedColor("var(--info-color)") || "#039be5"; 23 | 24 | // config defaults 25 | export const DEFAULT_GRADIENT_RESOLUTION = "medium"; 26 | export const DEFUALT_ICON_COLOR = "var(--primary-text-color)"; 27 | export const DEFAULT_INNER_MODE = "severity"; 28 | export const DEFAULT_INNER_VALUE = "{{ states(entity2) | float(0) }}"; 29 | export const DEFAULT_MIN = 0; 30 | export const DEFAULT_MIN_INDICATOR_COLOR = "rgb(255, 255, 255)"; 31 | export const DEFAULT_MIN_MAX_INDICATOR_OPACITY = 0.8; 32 | export const DEFAULT_MAX = 100; 33 | export const DEFAULT_MAX_INDICATOR_COLOR = "rgb(255, 255, 255)"; 34 | export const DEFAULT_NEEDLE_COLOR = "var(--primary-text-color)"; 35 | export const DEFAULT_SETPOINT_NEELDLE_COLOR = "var(--error-color)"; 36 | export const DEFAULT_SEVERITY_COLOR = INFO_COLOR; 37 | export const DEFAULT_TITLE_COLOR = "var(--primary-text-color)"; 38 | export const DEFAULT_TITLE_FONT_SIZE_PRIMARY = "15px"; 39 | export const DEFAULT_TITLE_FONT_SIZE_SECONDARY = "14px"; 40 | export const DEFUALT_VALUE = "{{ states(entity) | float(0) }}"; 41 | export const DEFAULT_VALUE_TEXT_COLOR = "var(--primary-text-color)"; 42 | export const DEFAULT_VALUE_TEXT_PRIMARY = 43 | "{{ states(entity) | float(0) | round(1) }}"; 44 | 45 | export const GRADIENT_RESOLUTION_MAP = { 46 | very_low: { 47 | segments: 25, 48 | samples: 1, 49 | }, 50 | low: { 51 | segments: 50, 52 | samples: 1, 53 | }, 54 | medium: { 55 | segments: 100, 56 | samples: 1, 57 | }, 58 | high: { 59 | segments: 200, 60 | samples: 1, 61 | }, 62 | }; 63 | 64 | export const MAIN_GAUGE_NEEDLE = "M -28 0 L -27.5 -2 L -47.5 0 L -27.5 2.25 z"; 65 | export const MAIN_GAUGE_NEEDLE_WITH_INNER = "M -49 -2 L -40 0 L -49 2 z"; 66 | export const MAIN_GAUGE_MIN_MAX_INDICATOR = 67 | "M-32.5 0A32.5 32.5 0 0 0 32.5 0L47.5 0A-47.5-47.5 0 01-47.5 0L-47.5 0 z"; 68 | export const MAIN_GAUGE_SETPOINT_NEEDLE = "M -49 -2 L -40 0 L -49 0 z"; 69 | 70 | export const INNER_GAUGE_NEEDLE = "M -27.5 -1.5 L -32 0 L -27.5 1.5 z"; 71 | export const INNER_GAUGE_MIN_MAX_INDICATOR = 72 | "M-29.5 0A29.5 29.5 0 0 0 29.5 0L34.5 0A-34.5-34.5 0 01-34.5 0L-34.5 0 z"; 73 | export const INNER_GAUGE_SETPOINT_NEEDLE = "M -27.5 -1.5 L -32 0 L -27.5 0 z"; 74 | export const INNER_GAUGE_ON_MAIN_NEEDLE = "M -30 -1.5 L -34.5 0 L -30 1.5 z"; 75 | export const INNER_GAUGE_SETPOINT_ON_MAIN_NEEDLE = 76 | "M -30 -1.5 L -34.5 0 L -30 0 z"; 77 | -------------------------------------------------------------------------------- /src/tests/utils/colors.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest"; 2 | import { getInterpolatedColor } from "../../utils/color/get-interpolated-color"; 3 | 4 | describe("getInterpolatedColor - single", () => { 5 | it("min", () => { 6 | const result = getInterpolatedColor({ 7 | min: 100, 8 | colorMin: "#ff0000", 9 | max: 200, 10 | colorMax: "00ff00", 11 | value: 100, 12 | }); 13 | expect(result).toEqual("#ff0000"); 14 | }); 15 | 16 | it("max", () => { 17 | const result = getInterpolatedColor({ 18 | min: 100, 19 | colorMin: "#ff0000", 20 | max: 200, 21 | colorMax: "00ff00", 22 | value: 200, 23 | }); 24 | expect(result).toEqual("#00ff00"); 25 | }); 26 | 27 | it("mid", () => { 28 | const result = getInterpolatedColor({ 29 | min: 100, 30 | colorMin: "#ff0000", 31 | max: 200, 32 | colorMax: "00ff00", 33 | value: 150, 34 | }); 35 | expect(result).toEqual("#807f00"); 36 | }); 37 | 38 | it("too small", () => { 39 | const result = getInterpolatedColor({ 40 | min: 100, 41 | colorMin: "#ff0000", 42 | max: 200, 43 | colorMax: "00ff00", 44 | value: 0, 45 | }); 46 | expect(result).toEqual(undefined); 47 | }); 48 | 49 | it("too big", () => { 50 | const result = getInterpolatedColor({ 51 | min: 100, 52 | colorMin: "#ff0000", 53 | max: 200, 54 | colorMax: "00ff00", 55 | value: 300, 56 | }); 57 | expect(result).toEqual(undefined); 58 | }); 59 | }); 60 | 61 | describe("getInterpolatedColor - array", () => { 62 | const gradientSegments = [ 63 | { pos: 0, color: "#ff0000" }, 64 | { pos: 1, color: "#00ff00" }, 65 | ]; 66 | it("min", () => { 67 | const result = getInterpolatedColor({ 68 | gradientSegments: gradientSegments, 69 | min: 100, 70 | max: 200, 71 | value: 100, 72 | }); 73 | expect(result).toEqual("#ff0000"); 74 | }); 75 | 76 | it("max", () => { 77 | const result = getInterpolatedColor({ 78 | gradientSegments: gradientSegments, 79 | min: 100, 80 | max: 200, 81 | value: 200, 82 | }); 83 | expect(result).toEqual("#00ff00"); 84 | }); 85 | 86 | it("mid", () => { 87 | const result = getInterpolatedColor({ 88 | gradientSegments: gradientSegments, 89 | min: 100, 90 | max: 200, 91 | value: 150, 92 | }); 93 | expect(result).toEqual("#807f00"); 94 | }); 95 | 96 | it("too small", () => { 97 | const result = getInterpolatedColor({ 98 | gradientSegments: gradientSegments, 99 | min: 100, 100 | max: 200, 101 | value: 0, 102 | }); 103 | expect(result).toEqual(undefined); 104 | }); 105 | 106 | it("too big", () => { 107 | const result = getInterpolatedColor({ 108 | gradientSegments: gradientSegments, 109 | min: 100, 110 | max: 200, 111 | value: 300, 112 | }); 113 | expect(result).toEqual(undefined); 114 | }); 115 | }); 116 | -------------------------------------------------------------------------------- /src/card/_gradient-renderer.ts: -------------------------------------------------------------------------------- 1 | // External dependencies 2 | import { GradientPath } from "../dependencies/gradient-path/gradient-path"; 3 | 4 | // Internalized external dependencies 5 | import * as Logger from "../dependencies/calendar-card-pro"; 6 | 7 | // Local utilities 8 | import { NumberUtils } from "../utils/number/numberUtils"; 9 | 10 | // Local constants & types 11 | import { DEFAULT_GRADIENT_RESOLUTION, GRADIENT_RESOLUTION_MAP } from "./const"; 12 | import { Gauge, GradientSegment } from "./config"; 13 | 14 | export class GradientRenderer { 15 | public gauge: Gauge; 16 | 17 | private gp: GradientPath; 18 | 19 | private _prevMin?: number; 20 | private _prevMax?: number; 21 | private _prevSegments?: GradientSegment[]; 22 | 23 | constructor(gauge: Gauge) { 24 | this.gauge = gauge; 25 | } 26 | 27 | private setPrevs( 28 | min: number | undefined = undefined, 29 | max: number | undefined = undefined, 30 | segments: GradientSegment[] | undefined = undefined 31 | ) { 32 | this._prevMin = min; 33 | this._prevMax = max; 34 | this._prevSegments = segments; 35 | } 36 | 37 | public initialize(path, resolution) { 38 | if (NumberUtils.isNumeric(resolution)) { 39 | // min 2, max 500 40 | const _resolution = Math.max(Math.min(Number(resolution), 500), 2); 41 | 42 | // More samples for lower resolution so the gauge is still circular 43 | this.gp = new GradientPath({ 44 | path: path, 45 | segments: _resolution, 46 | samples: 47 | _resolution < 25 ? Math.max(Math.round(25 / _resolution) + 1, 4) : 1, 48 | }); 49 | } else { 50 | if (!resolution) resolution = DEFAULT_GRADIENT_RESOLUTION; 51 | this.gp = new GradientPath({ 52 | path: path, 53 | segments: GRADIENT_RESOLUTION_MAP[resolution].segments, 54 | samples: GRADIENT_RESOLUTION_MAP[resolution].samples, 55 | }); 56 | } 57 | } 58 | 59 | private segmentsEqual(a?: GradientSegment[], b?: GradientSegment[]) { 60 | if (a === b) return true; 61 | if (!a || !b) return false; 62 | if (a.length !== b.length) return false; 63 | for (let i = 0; i < a.length; i++) { 64 | const s1 = a[i]; 65 | const s2 = b[i]; 66 | if (s1.pos !== s2.pos || s1.color !== s2.color) return false; 67 | } 68 | return true; 69 | } 70 | 71 | public render(min: number, max: number, gradientSegments: GradientSegment[]) { 72 | if ( 73 | min === this._prevMin && 74 | max === this._prevMax && 75 | // Using this instead of 'JSON.stringify(gradientSegments) === JSON.stringify(this._prevSegments)' 76 | // for better performance 77 | this.segmentsEqual(gradientSegments, this._prevSegments) 78 | ) { 79 | return; 80 | } 81 | 82 | const width = this.gauge === "main" ? 14 : 4; 83 | 84 | try { 85 | this.gp.render({ 86 | type: "path", 87 | fill: gradientSegments, 88 | width: width, 89 | stroke: gradientSegments, 90 | strokeWidth: 1, 91 | }); 92 | } catch (e) { 93 | Logger.error("Error gradient-path:", e); 94 | } 95 | this.setPrevs(min, max, gradientSegments); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/dependencies/ha/panels/lovelace/editor/structs/action-struct.ts: -------------------------------------------------------------------------------- 1 | import { 2 | array, 3 | boolean, 4 | dynamic, 5 | enums, 6 | literal, 7 | object, 8 | optional, 9 | string, 10 | type, 11 | union, 12 | } from "superstruct"; 13 | import { BaseActionConfig } from "../../../../data/lovelace"; 14 | 15 | const actionConfigStructUser = object({ 16 | user: string(), 17 | }); 18 | 19 | const actionConfigStructConfirmation = union([ 20 | boolean(), 21 | object({ 22 | text: optional(string()), 23 | excemptions: optional(array(actionConfigStructUser)), 24 | }), 25 | ]); 26 | 27 | const actionConfigStructUrl = object({ 28 | action: literal("url"), 29 | url_path: string(), 30 | confirmation: optional(actionConfigStructConfirmation), 31 | }); 32 | 33 | const actionConfigStructService = object({ 34 | action: enums(["call-service", "perform-action"]), 35 | service: optional(string()), 36 | perform_action: optional(string()), 37 | service_data: optional(object()), 38 | data: optional(object()), 39 | target: optional( 40 | object({ 41 | entity_id: optional(union([string(), array(string())])), 42 | device_id: optional(union([string(), array(string())])), 43 | area_id: optional(union([string(), array(string())])), 44 | floor_id: optional(union([string(), array(string())])), 45 | label_id: optional(union([string(), array(string())])), 46 | }) 47 | ), 48 | confirmation: optional(actionConfigStructConfirmation), 49 | }); 50 | 51 | const actionConfigStructNavigate = object({ 52 | action: literal("navigate"), 53 | navigation_path: string(), 54 | navigation_replace: optional(boolean()), 55 | confirmation: optional(actionConfigStructConfirmation), 56 | }); 57 | 58 | const actionConfigStructAssist = type({ 59 | action: literal("assist"), 60 | pipeline_id: optional(string()), 61 | start_listening: optional(boolean()), 62 | }); 63 | 64 | const actionConfigStructMoreInfo = type({ 65 | action: literal("more-info"), 66 | entity: optional(string()), 67 | }); 68 | 69 | export const actionConfigStructType = object({ 70 | action: enums([ 71 | "none", 72 | "toggle", 73 | "more-info", 74 | "call-service", 75 | "perform-action", 76 | "url", 77 | "navigate", 78 | "assist", 79 | ]), 80 | confirmation: optional(actionConfigStructConfirmation), 81 | }); 82 | 83 | export const actionConfigStruct = dynamic((value) => { 84 | if (value && typeof value === "object" && "action" in value) { 85 | switch ((value as BaseActionConfig).action!) { 86 | case "call-service": { 87 | return actionConfigStructService; 88 | } 89 | case "perform-action": { 90 | return actionConfigStructService; 91 | } 92 | case "navigate": { 93 | return actionConfigStructNavigate; 94 | } 95 | case "url": { 96 | return actionConfigStructUrl; 97 | } 98 | case "assist": { 99 | return actionConfigStructAssist; 100 | } 101 | case "more-info": { 102 | return actionConfigStructMoreInfo; 103 | } 104 | } 105 | } 106 | 107 | return actionConfigStructType; 108 | }); 109 | -------------------------------------------------------------------------------- /src/dependencies/ha/common/dom/fire_event.ts: -------------------------------------------------------------------------------- 1 | // Polymer legacy event helpers used courtesy of the Polymer project. 2 | // 3 | // Copyright (c) 2017 The Polymer Authors. All rights reserved. 4 | // 5 | // Redistribution and use in source and binary forms, with or without 6 | // modification, are permitted provided that the following conditions are 7 | // met: 8 | // 9 | // * Redistributions of source code must retain the above copyright 10 | // notice, this list of conditions and the following disclaimer. 11 | // * Redistributions in binary form must reproduce the above 12 | // copyright notice, this list of conditions and the following disclaimer 13 | // in the documentation and/or other materials provided with the 14 | // distribution. 15 | // * Neither the name of Google Inc. nor the names of its 16 | // contributors may be used to endorse or promote products derived from 17 | // this software without specific prior written permission. 18 | // 19 | // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 20 | // "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 21 | // LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 22 | // A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 23 | // OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 24 | // SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 25 | // LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 26 | // DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 27 | // THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 28 | // (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | // OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | 31 | declare global { 32 | // eslint-disable-next-line 33 | interface HASSDomEvents {} 34 | } 35 | 36 | export type ValidHassDomEvent = keyof HASSDomEvents; 37 | 38 | export interface HASSDomEvent extends Event { 39 | detail: T; 40 | } 41 | 42 | /** 43 | * Dispatches a custom event with an optional detail value. 44 | * 45 | * @param {string} type Name of event type. 46 | * @param {*=} detail Detail value containing event-specific 47 | * payload. 48 | * @param {{ bubbles: (boolean|undefined), 49 | * cancelable: (boolean|undefined), 50 | * composed: (boolean|undefined) }=} 51 | * options Object specifying options. These may include: 52 | * `bubbles` (boolean, defaults to `true`), 53 | * `cancelable` (boolean, defaults to false), and 54 | * `node` on which to fire the event (HTMLElement, defaults to `this`). 55 | * @return {Event} The new event that was fired. 56 | */ 57 | export const fireEvent = ( 58 | node: HTMLElement | Window, 59 | type: HassEvent, 60 | detail?: HASSDomEvents[HassEvent], 61 | options?: { 62 | bubbles?: boolean; 63 | cancelable?: boolean; 64 | composed?: boolean; 65 | } 66 | ) => { 67 | options = options || {}; 68 | // @ts-ignore 69 | detail = detail === null || detail === undefined ? {} : detail; 70 | const event = new Event(type, { 71 | bubbles: options.bubbles === undefined ? true : options.bubbles, 72 | cancelable: Boolean(options.cancelable), 73 | composed: options.composed === undefined ? true : options.composed, 74 | }); 75 | (event as any).detail = detail; 76 | node.dispatchEvent(event); 77 | return event; 78 | }; 79 | -------------------------------------------------------------------------------- /src/tests/card/segments/getGradientSegmentsFrom.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, afterEach } from "vitest"; 2 | 3 | // import type { GaugeCardProCard } from "../../../card/card"; 4 | import { GaugeCardProCard } from "../../../card/card"; 5 | import { getGradientSegments } from "../../../card/_segments"; 6 | 7 | vi.mock( 8 | "../../../dependencies/ha/panels/lovelace/common/directives/action-handler-directive.ts", 9 | () => ({ isTouch: () => false }) 10 | ); 11 | 12 | vi.mock("../../../dependencies/mushroom/utils/custom-cards.ts", () => ({ 13 | registerCustomCard: () => "", 14 | })); 15 | 16 | vi.mock("../../../utils/color/computed-color", () => ({ 17 | getComputedColor: (color: string) => { 18 | switch (color) { 19 | case "var(--info-color)": 20 | return "#039be5"; 21 | default: 22 | return color; 23 | } 24 | }, 25 | })); 26 | 27 | describe("getGradientSegments", () => { 28 | afterEach(() => { 29 | vi.clearAllMocks(); 30 | }); 31 | 32 | type TestCase = { 33 | name: string; 34 | min: number; 35 | max: number; 36 | segments?: {}[]; 37 | expected: {}[] | undefined; 38 | use_new_from_segments_style?: boolean; 39 | }; 40 | 41 | const cases: TestCase[] = [ 42 | { 43 | name: "midpoints - old style", 44 | min: 0, 45 | max: 100, 46 | segments: [ 47 | { from: 0, color: "#00ff00" }, 48 | { from: 40, color: "#ffff00" }, 49 | { from: 80, color: "#ff0000" }, 50 | ], 51 | expected: [ 52 | { pos: 0, color: "#00ff00" }, 53 | { pos: 0.4, color: "#ffff00" }, 54 | { pos: 0.8, color: "#ff0000" }, 55 | ], 56 | use_new_from_segments_style: false, 57 | }, 58 | { 59 | name: "midpoints - new style", 60 | min: 0, 61 | max: 100, 62 | segments: [ 63 | { from: 0, color: "#00ff00" }, 64 | { from: 40, color: "#ffff00" }, 65 | { from: 80, color: "#ff0000" }, 66 | ], 67 | expected: [ 68 | { pos: 0, color: "#00ff00" }, 69 | { pos: 0.2, color: "#00ff00" }, 70 | { pos: 0.6, color: "#ffff00" }, 71 | { pos: 0.9, color: "#ff0000" }, 72 | ], 73 | }, 74 | { 75 | name: "midpoints - 1st from below min", 76 | min: -100, 77 | max: 100, 78 | segments: [ 79 | { from: 0, color: "#00ff00" }, 80 | { from: 40, color: "#ffff00" }, 81 | { from: 80, color: "#ff0000" }, 82 | ], 83 | expected: [ 84 | { pos: 0, color: "#039be5" }, 85 | { pos: 0.25, color: "#039be5" }, 86 | { pos: 0.5, color: "#00ff00" }, 87 | { pos: 0.6, color: "#00ff00" }, 88 | { pos: 0.8, color: "#ffff00" }, 89 | { pos: 0.95, color: "#ff0000" }, 90 | ], 91 | }, 92 | ]; 93 | 94 | const card = new GaugeCardProCard(); 95 | it.each(cases)( 96 | "$name", 97 | ({ min, max, segments, expected, use_new_from_segments_style = true }) => { 98 | vi.spyOn(card, "_config", "get").mockReturnValue({ 99 | type: "custom:gauge-card-pro", 100 | use_new_from_segments_style: use_new_from_segments_style, 101 | }); 102 | 103 | vi.spyOn(card, "getValue").mockImplementation((key: string) => { 104 | switch (key) { 105 | case "segments": 106 | return segments; 107 | default: 108 | return undefined; 109 | } 110 | }); 111 | 112 | const result = getGradientSegments(card, "main", min, max, true); 113 | 114 | expect(card.getValue).toHaveBeenNthCalledWith(1, "segments"); 115 | expect(result).toEqual(expected); 116 | } 117 | ); 118 | }); 119 | -------------------------------------------------------------------------------- /src/dependencies/mushroom/utils/form/ha-form.ts: -------------------------------------------------------------------------------- 1 | import type { LitElement } from "lit"; 2 | import { Selector } from "./ha-selector"; 3 | 4 | interface HaDurationData { 5 | hours?: number; 6 | minutes?: number; 7 | seconds?: number; 8 | milliseconds?: number; 9 | } 10 | 11 | export type HaFormSchema = 12 | | HaFormConstantSchema 13 | | HaFormStringSchema 14 | | HaFormIntegerSchema 15 | | HaFormFloatSchema 16 | | HaFormBooleanSchema 17 | | HaFormSelectSchema 18 | | HaFormMultiSelectSchema 19 | | HaFormTimeSchema 20 | | HaFormSelector 21 | | HaFormGridSchema 22 | | HaFormExpandableSchema 23 | | HaFormOptionalActionsSchema; 24 | 25 | export interface HaFormBaseSchema { 26 | name: string; 27 | // This value is applied if no data is submitted for this field 28 | default?: HaFormData; 29 | required?: boolean; 30 | disabled?: boolean; 31 | description?: { 32 | suffix?: string; 33 | // This value will be set initially when form is loaded 34 | suggested_value?: HaFormData; 35 | }; 36 | context?: Record; 37 | } 38 | 39 | export interface HaFormGridSchema extends HaFormBaseSchema { 40 | type: "grid"; 41 | flatten?: boolean; 42 | column_min_width?: string; 43 | schema: readonly HaFormSchema[]; 44 | } 45 | 46 | export interface HaFormExpandableSchema extends HaFormBaseSchema { 47 | type: "expandable"; 48 | flatten?: boolean; 49 | title?: string; 50 | icon?: string; 51 | iconPath?: string; 52 | expanded?: boolean; 53 | headingLevel?: 1 | 2 | 3 | 4 | 5 | 6; 54 | schema: readonly HaFormSchema[]; 55 | } 56 | 57 | export interface HaFormOptionalActionsSchema extends HaFormBaseSchema { 58 | type: "optional_actions"; 59 | flatten?: boolean; 60 | schema: readonly HaFormSchema[]; 61 | } 62 | 63 | export interface HaFormSelector extends HaFormBaseSchema { 64 | type?: never; 65 | selector: Selector; 66 | } 67 | 68 | export interface HaFormConstantSchema extends HaFormBaseSchema { 69 | type: "constant"; 70 | value?: string; 71 | } 72 | 73 | export interface HaFormIntegerSchema extends HaFormBaseSchema { 74 | type: "integer"; 75 | default?: HaFormIntegerData; 76 | valueMin?: number; 77 | valueMax?: number; 78 | } 79 | 80 | export interface HaFormSelectSchema extends HaFormBaseSchema { 81 | type: "select"; 82 | options: readonly (readonly [string, string])[]; 83 | } 84 | 85 | export interface HaFormMultiSelectSchema extends HaFormBaseSchema { 86 | type: "multi_select"; 87 | options: 88 | | Record 89 | | readonly string[] 90 | | readonly (readonly [string, string])[]; 91 | } 92 | 93 | export interface HaFormFloatSchema extends HaFormBaseSchema { 94 | type: "float"; 95 | } 96 | 97 | export interface HaFormStringSchema extends HaFormBaseSchema { 98 | type: "string"; 99 | format?: string; 100 | autocomplete?: string; 101 | autofocus?: boolean; 102 | } 103 | 104 | export interface HaFormBooleanSchema extends HaFormBaseSchema { 105 | type: "boolean"; 106 | } 107 | 108 | export interface HaFormTimeSchema extends HaFormBaseSchema { 109 | type: "positive_time_period_dict"; 110 | } 111 | 112 | // Type utility to unionize a schema array by flattening any grid schemas 113 | export type SchemaUnion< 114 | SchemaArray extends readonly HaFormSchema[], 115 | Schema = SchemaArray[number], 116 | > = Schema extends 117 | | HaFormGridSchema 118 | | HaFormExpandableSchema 119 | | HaFormOptionalActionsSchema 120 | ? SchemaUnion | Schema 121 | : Schema; 122 | 123 | export type HaFormDataContainer = Record; 124 | 125 | export type HaFormData = 126 | | HaFormStringData 127 | | HaFormIntegerData 128 | | HaFormFloatData 129 | | HaFormBooleanData 130 | | HaFormSelectData 131 | | HaFormMultiSelectData 132 | | HaFormTimeData; 133 | 134 | export type HaFormStringData = string; 135 | export type HaFormIntegerData = number; 136 | export type HaFormFloatData = number; 137 | export type HaFormBooleanData = boolean; 138 | export type HaFormSelectData = string; 139 | export type HaFormMultiSelectData = string[]; 140 | export type HaFormTimeData = HaDurationData; 141 | 142 | export interface HaFormElement extends LitElement { 143 | schema: HaFormSchema | readonly HaFormSchema[]; 144 | data?: HaFormDataContainer | HaFormData; 145 | label?: string; 146 | } 147 | -------------------------------------------------------------------------------- /src/tests/card/segments/getGradientSegmentsPos.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, afterEach } from "vitest"; 2 | 3 | import type { GaugeCardProCard } from "../../../card/card"; 4 | import { getGradientSegments } from "../../../card/_segments"; 5 | 6 | vi.mock("../../../utils/color/computed-color", () => ({ 7 | getComputedColor: (color: string) => { 8 | switch (color) { 9 | case "var(--info-color)": 10 | return "#039be5"; 11 | default: 12 | return color; 13 | } 14 | }, 15 | })); 16 | 17 | vi.mock( 18 | "../../../dependencies/ha/panels/lovelace/common/directives/action-handler-directive.ts", 19 | () => ({ 20 | isTouch: () => false, 21 | }) 22 | ); 23 | 24 | describe("getGradientSegments", () => { 25 | afterEach(() => { 26 | vi.clearAllMocks(); 27 | }); 28 | 29 | type TestCase = { 30 | name: string; 31 | min: number; 32 | max: number; 33 | segmentsOverride?: {}[]; 34 | expected: {}[] | undefined; 35 | }; 36 | 37 | const cases: TestCase[] = [ 38 | { 39 | name: "single segment", 40 | min: 0, 41 | max: 100, 42 | segmentsOverride: [{ from: 0, color: "#ff0000" }], 43 | expected: [ 44 | { pos: 0, color: "#ff0000" }, 45 | { pos: 1, color: "#ff0000" }, 46 | ], 47 | }, 48 | { 49 | name: "multiple, matching min/max, no interpolation", 50 | min: 100, 51 | max: 200, 52 | expected: [ 53 | { pos: 0, color: "#ff0000" }, 54 | { pos: 0.1, color: "#00ff00" }, 55 | { pos: 0.2, color: "#0000ff" }, 56 | { pos: 0.6, color: "#00ff00" }, 57 | { pos: 1, color: "#ff0000" }, 58 | ], 59 | }, 60 | { 61 | name: "multiple, sub, matching min/max, no interpolation", 62 | min: 110, 63 | max: 160, 64 | expected: [ 65 | { pos: 0, color: "#00ff00" }, 66 | { pos: 0.2, color: "#0000ff" }, 67 | { pos: 1, color: "#00ff00" }, 68 | ], 69 | }, 70 | { 71 | name: "range fully below lowest segment", 72 | min: 0, 73 | max: 10, 74 | expected: [ 75 | { pos: 0, color: "#039be5" }, 76 | { pos: 1, color: "#039be5" }, 77 | ], 78 | }, 79 | { 80 | name: "range fully above highest segment", 81 | min: 1000, 82 | max: 2000, 83 | expected: [ 84 | { pos: 0, color: "#ff0000" }, 85 | { pos: 1, color: "#ff0000" }, 86 | ], 87 | }, 88 | { 89 | name: "max @ first from", 90 | min: 0, 91 | max: 100, 92 | expected: [ 93 | { pos: 0, color: "#039be5" }, 94 | { pos: 1, color: "#039be5" }, 95 | ], 96 | }, 97 | { 98 | name: "min @ last from", 99 | min: 200, 100 | max: 300, 101 | expected: [ 102 | { pos: 0, color: "#ff0000" }, 103 | { pos: 1, color: "#ff0000" }, 104 | ], 105 | }, 106 | { 107 | name: "interpolation to min", 108 | min: 250, 109 | max: 300, 110 | segmentsOverride: [ 111 | { from: 0, color: "#000000" }, 112 | { from: 100, color: "#ffffff" }, 113 | { from: 200, color: "#00ff00" }, 114 | { from: 300, color: "#ff0000" }, 115 | ], 116 | expected: [ 117 | { pos: 0, color: "#7f8000" }, 118 | { pos: 1, color: "#ff0000" }, 119 | ], 120 | }, 121 | { 122 | name: "interpolation to max", 123 | min: 100, 124 | max: 150, 125 | segmentsOverride: [ 126 | { from: 0, color: "#ffffff" }, 127 | { from: 100, color: "#ff0000" }, 128 | { from: 200, color: "#00ff00" }, 129 | { from: 300, color: "#ffffff" }, 130 | ], 131 | expected: [ 132 | { pos: 0, color: "#ff0000" }, 133 | { pos: 1, color: "#807f00" }, 134 | ], 135 | }, 136 | ]; 137 | 138 | // mock card.getValue() 139 | const card = { getValue: vi.fn() } as unknown as GaugeCardProCard; 140 | it.each(cases)("$name", ({ min, max, segmentsOverride, expected }) => { 141 | vi.spyOn(card, "getValue").mockImplementation((key: string) => { 142 | switch (key) { 143 | case "segments": 144 | return segmentsOverride 145 | ? segmentsOverride 146 | : [ 147 | { from: 100, color: "#ff0000" }, 148 | { from: 110, color: "#00ff00" }, 149 | { from: 120, color: "#0000ff" }, 150 | { from: 160, color: "#00ff00" }, 151 | { from: 200, color: "#ff0000" }, 152 | ]; 153 | default: 154 | return undefined; 155 | } 156 | }); 157 | 158 | const result = getGradientSegments(card, "main", min, max); 159 | 160 | expect(card.getValue).toHaveBeenNthCalledWith(1, "segments"); 161 | expect(result).toEqual(expected); 162 | }); 163 | }); 164 | -------------------------------------------------------------------------------- /src/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor": { 3 | "card": { 4 | "actions": "Actions", 5 | "color_interpolation_note_off": "Colors (segments) are not interpolated. Enable 'Gradient' to enable color-interpolation between your configured segments.", 6 | "color_interpolation_note_on": "Colors (segments) are interpolated. Disable 'Gradient' to disable color-interpolation between your configured segments.", 7 | "configure_segments": "Add segments using the Code Editor to enable Gradient options", 8 | "configure_inner_segments": "Add inner segments using the Code Editor to enable Gradient options", 9 | "double_tap_action": "Double tap behavior", 10 | "enable_inner": "Enable inner gauge", 11 | "entities": "Entities", 12 | "entity": "Entity (used in templates in action)", 13 | "entity2": "Entity2 (used in templates)", 14 | "gradient": "Gradient", 15 | "gradient_background": "Gradient background", 16 | "gradient_resolution": "Gradient resolution", 17 | "gradient_resolution_options": { 18 | "very_low": "Very low", 19 | "low": "Low", 20 | "medium": "Medium", 21 | "high": "High" 22 | }, 23 | "header": "Header", 24 | "hide_background": "Hide background", 25 | "hide_label": "Hide label", 26 | "hold_action": "Hold behavior", 27 | "icon_double_tap_action": "Icon double tap behavior", 28 | "icon_hold_action": "Icon hold behavior", 29 | "icon_tap_action": "Icon tap behavior", 30 | "icon_type": "Icon type", 31 | "inner_gauge": "Inner gauge", 32 | "inner_mode_options": { 33 | "severity": "Severity", 34 | "static": "Static", 35 | "needle": "Needle", 36 | "on_main": "On main" 37 | }, 38 | "interactions": "Interactions", 39 | "left": "Align left", 40 | "main_gauge": "Main gauge", 41 | "min_indicator": "Minimum indicator", 42 | "max_indicator": "Maximum indicator", 43 | "mode": "Mode", 44 | "needle": "Needle", 45 | "opacity": "Opacity", 46 | "number": "Fixed number", 47 | "primary": "Value", 48 | "primary_font_size": "Font-size", 49 | "primary_font_size_reduction": "Font-size reduction", 50 | "primary_header": "Primary", 51 | "primary_unit_before_value": "Unit before value", 52 | "primary_value_text_double_tap_action": "Primary value-text double tap behavior", 53 | "primary_value_text_hold_action": "Primary value-text hold behavior", 54 | "primary_value_text_tap_action": "Primary value-text tap behavior", 55 | "secondary": "Value", 56 | "secondary_font_size": "Font-size", 57 | "secondary_header": "Secondary", 58 | "secondary_unit_before_value": "Unit before value", 59 | "secondary_value_text_double_tap_action": "Secondary value-text double tap behavior", 60 | "secondary_value_text_hold_action": "Secondary value-text hold behavior", 61 | "secondary_value_text_tap_action": "Secondary value-text tap behavior", 62 | "segments_alert": { 63 | "title": "Segments calculations", 64 | "description": { 65 | "from": "You are using 'from'-segments. The colors peak in between two segments. Convert to 'pos'-segments to peak colors at the exact value.", 66 | "pos": "You are using 'pos'-segments. The colors peak at the exact value. Convert to 'from'-segments to peak in between two segments." 67 | }, 68 | "convert_to_from": "Convert to from", 69 | "convert_to_pos": "Convert to pos" 70 | }, 71 | "setpoint": "Setpoint needle", 72 | "setpoint_entity": "Entity", 73 | "state": "State", 74 | "threshold": "Threshold", 75 | "titles": "Titles", 76 | "severity": "Severity", 77 | "value": "Value", 78 | "value_texts": "Value texts", 79 | "value_texts_note1": "ℹ️ 'Value' and 'Unit of measurement' can be made blank/empty by setting the parameter to \"\" in the Code Editor (yaml).", 80 | "value_texts_note2": "For example", 81 | "value_texts_note3": "⚠️ Deleting configuration of any of these fields in this Visual Editor also causes this to happen. Please delete this manually in the Code Editor to return to using default values." 82 | } 83 | }, 84 | "card": { 85 | "not_found": "Entity not found" 86 | }, 87 | "migration": { 88 | "title": "Segments calculations updated!", 89 | "description": "A new calculation method for 'from'-segments (segments defined using 'from:') has been introduced. There is now a difference between 'from'-segments and 'pos'-segments (segments defined using 'pos:').", 90 | "description-from": "the color now peaks in between two segments.", 91 | "description-pos": "the color peaks at the exact value (old behavior).", 92 | "more-info": "For more information, checkout my ", 93 | "keep": "Keep current configuration", 94 | "convert": "Convert to pos" 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/dependencies/ha/common/number/format_number.ts: -------------------------------------------------------------------------------- 1 | import { 2 | HassEntity, 3 | HassEntityAttributeBase, 4 | } from "home-assistant-js-websocket"; 5 | import { FrontendLocaleData, NumberFormat } from "../../data/translation"; 6 | import { EntityRegistryDisplayEntry } from "../../types"; 7 | import { round } from "./round"; 8 | 9 | /** 10 | * Returns true if the entity is considered numeric based on the attributes it has 11 | * @param stateObj The entity state object 12 | */ 13 | export const isNumericState = (stateObj: HassEntity): boolean => 14 | isNumericFromAttributes(stateObj.attributes); 15 | 16 | export const isNumericFromAttributes = ( 17 | attributes: HassEntityAttributeBase 18 | ): boolean => !!attributes.unit_of_measurement || !!attributes.state_class; 19 | 20 | export const numberFormatToLocale = ( 21 | localeOptions: FrontendLocaleData 22 | ): string | string[] | undefined => { 23 | switch (localeOptions.number_format) { 24 | case NumberFormat.comma_decimal: 25 | return ["en-US", "en"]; // Use United States with fallback to English formatting 1,234,567.89 26 | case NumberFormat.decimal_comma: 27 | return ["de", "es", "it"]; // Use German with fallback to Spanish then Italian formatting 1.234.567,89 28 | case NumberFormat.space_comma: 29 | return ["fr", "sv", "cs"]; // Use French with fallback to Swedish and Czech formatting 1 234 567,89 30 | case NumberFormat.system: 31 | return undefined; 32 | default: 33 | return localeOptions.language; 34 | } 35 | }; 36 | 37 | /** 38 | * Formats a number based on the user's preference with thousands separator(s) and decimal character for better legibility. 39 | * 40 | * @param num The number to format 41 | * @param localeOptions The user-selected language and formatting, from `hass.locale` 42 | * @param options Intl.NumberFormatOptions to use 43 | */ 44 | export const formatNumber = ( 45 | num: string | number, 46 | localeOptions?: FrontendLocaleData, 47 | options?: Intl.NumberFormatOptions 48 | ): string => { 49 | const locale = localeOptions 50 | ? numberFormatToLocale(localeOptions) 51 | : undefined; 52 | 53 | // Polyfill for Number.isNaN, which is more reliable than the global isNaN() 54 | Number.isNaN = 55 | Number.isNaN || 56 | function isNaN(input) { 57 | return typeof input === "number" && isNaN(input); 58 | }; 59 | 60 | if ( 61 | localeOptions?.number_format !== NumberFormat.none && 62 | !Number.isNaN(Number(num)) && 63 | Intl 64 | ) { 65 | try { 66 | return new Intl.NumberFormat( 67 | locale, 68 | getDefaultFormatOptions(num, options) 69 | ).format(Number(num)); 70 | } catch (err: any) { 71 | // Don't fail when using "TEST" language 72 | // eslint-disable-next-line no-console 73 | console.error(err); 74 | return new Intl.NumberFormat( 75 | undefined, 76 | getDefaultFormatOptions(num, options) 77 | ).format(Number(num)); 78 | } 79 | } 80 | if (typeof num === "string") { 81 | return num; 82 | } 83 | return `${round(num, options?.maximumFractionDigits).toString()}${ 84 | options?.style === "currency" ? ` ${options.currency}` : "" 85 | }`; 86 | }; 87 | 88 | /** 89 | * Checks if the current entity state should be formatted as an integer based on the `state` and `step` attribute and returns the appropriate `Intl.NumberFormatOptions` object with `maximumFractionDigits` set 90 | * @param entityState The state object of the entity 91 | * @returns An `Intl.NumberFormatOptions` object with `maximumFractionDigits` set to 0, or `undefined` 92 | */ 93 | export const getNumberFormatOptions = ( 94 | entityState: HassEntity, 95 | entity?: EntityRegistryDisplayEntry 96 | ): Intl.NumberFormatOptions | undefined => { 97 | const precision = entity?.display_precision; 98 | if (precision != null) { 99 | return { 100 | maximumFractionDigits: precision, 101 | minimumFractionDigits: precision, 102 | }; 103 | } 104 | if ( 105 | Number.isInteger(Number(entityState.attributes?.step)) && 106 | Number.isInteger(Number(entityState.state)) 107 | ) { 108 | return { maximumFractionDigits: 0 }; 109 | } 110 | if (entityState.attributes.step != null) { 111 | return { 112 | maximumFractionDigits: Math.ceil( 113 | Math.log10(1 / entityState.attributes.step) 114 | ), 115 | }; 116 | } 117 | return undefined; 118 | }; 119 | 120 | /** 121 | * Generates default options for Intl.NumberFormat 122 | * @param num The number to be formatted 123 | * @param options The Intl.NumberFormatOptions that should be included in the returned options 124 | */ 125 | export const getDefaultFormatOptions = ( 126 | num: string | number, 127 | options?: Intl.NumberFormatOptions 128 | ): Intl.NumberFormatOptions => { 129 | const defaultOptions: Intl.NumberFormatOptions = { 130 | maximumFractionDigits: 2, 131 | ...options, 132 | }; 133 | 134 | if (typeof num !== "string") { 135 | return defaultOptions; 136 | } 137 | 138 | // Keep decimal trailing zeros if they are present in a string numeric value 139 | if ( 140 | !options || 141 | (options.minimumFractionDigits === undefined && 142 | options.maximumFractionDigits === undefined) 143 | ) { 144 | const digits = num.indexOf(".") > -1 ? num.split(".")[1].length : 0; 145 | defaultOptions.minimumFractionDigits = digits; 146 | defaultOptions.maximumFractionDigits = digits; 147 | } 148 | 149 | return defaultOptions; 150 | }; 151 | -------------------------------------------------------------------------------- /src/dependencies/mushroom/utils/form/ha-selector.ts: -------------------------------------------------------------------------------- 1 | import { ActionConfig } from "../../../ha"; 2 | 3 | export type Selector = 4 | | ActionSelector 5 | | AddonSelector 6 | | AreaSelector 7 | | AttributeSelector 8 | | BooleanSelector 9 | | ColorRGBSelector 10 | | ColorTempSelector 11 | | DateSelector 12 | | DateTimeSelector 13 | | DeviceSelector 14 | | DurationSelector 15 | | EntitySelector 16 | | IconSelector 17 | | LocationSelector 18 | | MediaSelector 19 | | NumberSelector 20 | | ObjectSelector 21 | | SelectSelector 22 | | StringSelector 23 | | TargetSelector 24 | | TemplateSelector 25 | | ThemeSelector 26 | | TimeSelector 27 | | UiActionSelector; 28 | 29 | export interface ActionSelector { 30 | // eslint-disable-next-line @typescript-eslint/ban-types 31 | action: {}; 32 | } 33 | 34 | export interface AddonSelector { 35 | addon: { 36 | name?: string; 37 | slug?: string; 38 | }; 39 | } 40 | 41 | export interface AreaSelector { 42 | area: { 43 | entity?: { 44 | integration?: EntitySelector["entity"]["integration"]; 45 | domain?: EntitySelector["entity"]["domain"]; 46 | device_class?: EntitySelector["entity"]["device_class"]; 47 | }; 48 | device?: { 49 | integration?: DeviceSelector["device"]["integration"]; 50 | manufacturer?: DeviceSelector["device"]["manufacturer"]; 51 | model?: DeviceSelector["device"]["model"]; 52 | }; 53 | multiple?: boolean; 54 | }; 55 | } 56 | 57 | export interface AttributeSelector { 58 | attribute: { 59 | entity_id?: string; 60 | }; 61 | } 62 | 63 | export interface BooleanSelector { 64 | // eslint-disable-next-line @typescript-eslint/ban-types 65 | boolean: {}; 66 | } 67 | 68 | export interface ColorRGBSelector { 69 | // eslint-disable-next-line @typescript-eslint/ban-types 70 | color_rgb: {}; 71 | } 72 | 73 | export interface ColorTempSelector { 74 | color_temp: { 75 | min_mireds?: number; 76 | max_mireds?: number; 77 | }; 78 | } 79 | 80 | export interface DateSelector { 81 | // eslint-disable-next-line @typescript-eslint/ban-types 82 | date: {}; 83 | } 84 | 85 | export interface DateTimeSelector { 86 | // eslint-disable-next-line @typescript-eslint/ban-types 87 | datetime: {}; 88 | } 89 | 90 | export interface DeviceSelector { 91 | device: { 92 | integration?: string; 93 | manufacturer?: string; 94 | model?: string; 95 | entity?: { 96 | domain?: EntitySelector["entity"]["domain"]; 97 | device_class?: EntitySelector["entity"]["device_class"]; 98 | }; 99 | multiple?: boolean; 100 | }; 101 | } 102 | 103 | export interface DurationSelector { 104 | duration: { 105 | enable_day?: boolean; 106 | }; 107 | } 108 | 109 | export interface EntitySelector { 110 | entity: { 111 | integration?: string; 112 | domain?: string | string[]; 113 | device_class?: string; 114 | multiple?: boolean; 115 | include_entities?: string[]; 116 | exclude_entities?: string[]; 117 | }; 118 | } 119 | 120 | export interface IconSelector { 121 | icon: { 122 | placeholder?: string; 123 | fallbackPath?: string; 124 | }; 125 | } 126 | 127 | export interface LocationSelector { 128 | location: { radius?: boolean; icon?: string }; 129 | } 130 | 131 | export interface LocationSelectorValue { 132 | latitude: number; 133 | longitude: number; 134 | radius?: number; 135 | } 136 | 137 | export interface MediaSelector { 138 | // eslint-disable-next-line @typescript-eslint/ban-types 139 | media: {}; 140 | } 141 | 142 | export interface MediaSelectorValue { 143 | entity_id?: string; 144 | media_content_id?: string; 145 | media_content_type?: string; 146 | metadata?: { 147 | title?: string; 148 | thumbnail?: string | null; 149 | media_class?: string; 150 | children_media_class?: string | null; 151 | navigateIds?: { media_content_type: string; media_content_id: string }[]; 152 | }; 153 | } 154 | 155 | export interface NumberSelector { 156 | number: { 157 | min?: number; 158 | max?: number; 159 | step?: number; 160 | mode?: "box" | "slider"; 161 | unit_of_measurement?: string; 162 | }; 163 | } 164 | 165 | export interface ObjectSelector { 166 | // eslint-disable-next-line @typescript-eslint/ban-types 167 | object: {}; 168 | } 169 | 170 | export interface SelectOption { 171 | value: string; 172 | label: string; 173 | } 174 | 175 | export interface SelectSelector { 176 | select: { 177 | multiple?: boolean; 178 | custom_value?: boolean; 179 | mode?: "list" | "dropdown"; 180 | options: string[] | SelectOption[]; 181 | }; 182 | } 183 | 184 | export interface StringSelector { 185 | text: { 186 | multiline?: boolean; 187 | type?: 188 | | "number" 189 | | "text" 190 | | "search" 191 | | "tel" 192 | | "url" 193 | | "email" 194 | | "password" 195 | | "date" 196 | | "month" 197 | | "week" 198 | | "time" 199 | | "datetime-local" 200 | | "color"; 201 | suffix?: string; 202 | }; 203 | } 204 | 205 | export interface TargetSelector { 206 | target: { 207 | entity?: { 208 | integration?: EntitySelector["entity"]["integration"]; 209 | domain?: EntitySelector["entity"]["domain"]; 210 | device_class?: EntitySelector["entity"]["device_class"]; 211 | }; 212 | device?: { 213 | integration?: DeviceSelector["device"]["integration"]; 214 | manufacturer?: DeviceSelector["device"]["manufacturer"]; 215 | model?: DeviceSelector["device"]["model"]; 216 | }; 217 | }; 218 | } 219 | 220 | export interface TemplateSelector { 221 | // eslint-disable-next-line @typescript-eslint/ban-types 222 | template: {}; 223 | } 224 | 225 | export interface ThemeSelector { 226 | // eslint-disable-next-line @typescript-eslint/ban-types 227 | theme: {}; 228 | } 229 | export interface TimeSelector { 230 | // eslint-disable-next-line @typescript-eslint/ban-types 231 | time: {}; 232 | } 233 | 234 | export type UiAction = Exclude; 235 | 236 | export interface UiActionSelector { 237 | ui_action: { 238 | actions?: UiAction[]; 239 | default_action?: UiAction; 240 | } | null; 241 | } 242 | -------------------------------------------------------------------------------- /SHAPES.md: -------------------------------------------------------------------------------- 1 | ## Main gauge 2 | 3 | ### Value needle 4 | 5 | | Image | Author | Shape | 6 | | :-------------------------------------------------------------------------------------------------------------------- | :------------- | :-------------------------------------------- | 7 | | image | Gauge Card Pro | `M -28 0 L -27.5 -2 L -47.5 0 L -27.5 2.25 z` | 8 | 9 | ### Value needle with inner gauge 10 | 11 | | Image | Author | Shape | 12 | | :-------------------------------------------------------------------------------------------------------------------- | :------------- | :--------------------------- | 13 | | image | Gauge Card Pro | `M -49 -2 L -40 0 L -49 2 z` | 14 | 15 | ### Min indicator 16 | 17 | | Image | Author | Shape | 18 | | :-------------------------------------------------------------------------------------------------------------------- | :------------- | :------------------------------------------------------------------------ | 19 | | image | Gauge Card Pro | `M-32.5 0A32.5 32.5 0 0 0 32.5 0L47.5 0A-47.5-47.5 0 01-47.5 0L-47.5 0 z` | 20 | 21 | ### Max indicator 22 | 23 | | Image | Author | Shape | 24 | | :-------------------------------------------------------------------------------------------------------------------- | :------------- | :------------------------------------------------------------------------ | 25 | | image | Gauge Card Pro | `M-32.5 0A32.5 32.5 0 0 0 32.5 0L47.5 0A-47.5-47.5 0 01-47.5 0L-47.5 0 z` | 26 | 27 | ### Setpoint needle 28 | 29 | | Image | Author | Shape | 30 | | :-------------------------------------------------------------------------------------------------------------------- | :------------- | :--------------------------- | 31 | | image | Gauge Card Pro | `M -49 -2 L -40 0 L -49 0 z` | 32 | 33 | ## Inner gauge 34 | 35 | ### Value needle 36 | 37 | | Image | Author | Shape | 38 | | :-------------------------------------------------------------------------------------------------------------------- | :------------- | :----------------------------------- | 39 | | image | Gauge Card Pro | `M -27.5 -1.5 L -32 0 L -27.5 1.5 z` | 40 | 41 | ### Min indicator 42 | 43 | | Image | Author | Shape | 44 | | :-------------------------------------------------------------------------------------------------------------------- | :------------- | :------------------------------------------------------------------------ | 45 | | image | Gauge Card Pro | `M-29.5 0A29.5 29.5 0 0 0 29.5 0L34.5 0A-34.5-34.5 0 01-34.5 0L-34.5 0 z` | 46 | 47 | ### Max indicator 48 | 49 | | Image | Author | Shape | 50 | | :-------------------------------------------------------------------------------------------------------------------- | :------------- | :------------------------------------------------------------------------ | 51 | | image | Gauge Card Pro | `M-29.5 0A29.5 29.5 0 0 0 29.5 0L34.5 0A-34.5-34.5 0 01-34.5 0L-34.5 0 z` | 52 | 53 | ### Setpoint needle 54 | 55 | | Image | Author | Shape | 56 | | :-------------------------------------------------------------------------------------------------------------------- | :------------- | :--------------------------------- | 57 | | image | Gauge Card Pro | `M -27.5 -1.5 L -32 0 L -27.5 0 z` | 58 | 59 | ### Value needle in `on_main`-mode 60 | 61 | | Image | Author | Shape | 62 | | :-------------------------------------------------------------------------------------------------------------------- | :------------- | :--------------------------------- | 63 | | image | Gauge Card Pro | `M -30 -1.5 L -34.5 0 L -30 1.5 z` | 64 | 65 | ### Setpoint needle in `on_main`-mode 66 | 67 | | Image | Author | Shape | 68 | | :-------------------------------------------------------------------------------------------------------------------- | :------------- | :------------------------------- | 69 | | image | Gauge Card Pro | `M -30 -1.5 L -34.5 0 L -30 0 z` | 70 | -------------------------------------------------------------------------------- /src/dependencies/ha/types.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Auth, 3 | Connection, 4 | HassConfig, 5 | HassEntities, 6 | HassEntity, 7 | HassServices, 8 | HassServiceTarget, 9 | MessageBase, 10 | } from "home-assistant-js-websocket"; 11 | import type { LocalizeFunc } from "./common/translations/localize"; 12 | import type { 13 | FrontendLocaleData, 14 | TranslationCategory, 15 | } from "./data/translation"; 16 | import type { Themes } from "./data/ws-themes"; 17 | 18 | declare global { 19 | /* eslint-disable no-var, no-redeclare */ 20 | var __DEV__: boolean; 21 | var __DEMO__: boolean; 22 | var __BUILD__: "latest" | "es5"; 23 | var __VERSION__: string; 24 | var __STATIC_PATH__: string; 25 | var __BACKWARDS_COMPAT__: boolean; 26 | var __SUPERVISOR__: boolean; 27 | /* eslint-enable no-var, no-redeclare */ 28 | 29 | interface Window { 30 | // Custom panel entry point url 31 | customPanelJS: string; 32 | ShadyCSS: { 33 | nativeCss: boolean; 34 | nativeShadow: boolean; 35 | prepareTemplate(templateElement, elementName, elementExtension); 36 | styleElement(element); 37 | styleSubtree(element, overrideProperties); 38 | styleDocument(overrideProperties); 39 | getComputedStyleValue(element, propertyName); 40 | }; 41 | } 42 | // for fire event 43 | interface HASSDomEvents { 44 | "value-changed": { 45 | value: unknown; 46 | }; 47 | change: undefined; 48 | } 49 | 50 | // For loading workers in webpack 51 | interface ImportMeta { 52 | url: string; 53 | } 54 | } 55 | 56 | export interface EntityRegistryDisplayEntry { 57 | entity_id: string; 58 | name?: string; 59 | device_id?: string; 60 | area_id?: string; 61 | hidden?: boolean; 62 | entity_category?: "config" | "diagnostic"; 63 | translation_key?: string; 64 | platform?: string; 65 | display_precision?: number; 66 | } 67 | 68 | export interface DeviceRegistryEntry { 69 | id: string; 70 | config_entries: string[]; 71 | connections: Array<[string, string]>; 72 | identifiers: Array<[string, string]>; 73 | manufacturer: string | null; 74 | model: string | null; 75 | name: string | null; 76 | sw_version: string | null; 77 | hw_version: string | null; 78 | via_device_id: string | null; 79 | area_id: string | null; 80 | name_by_user: string | null; 81 | entry_type: "service" | null; 82 | disabled_by: "user" | "integration" | "config_entry" | null; 83 | configuration_url: string | null; 84 | } 85 | 86 | export interface AreaRegistryEntry { 87 | area_id: string; 88 | name: string; 89 | picture: string | null; 90 | } 91 | 92 | export interface ThemeSettings { 93 | theme: string; 94 | // Radio box selection for theme picker. Do not use in Lovelace rendering as 95 | // it can be undefined == auto. 96 | // Property hass.themes.darkMode carries effective current mode. 97 | dark?: boolean; 98 | primaryColor?: string; 99 | accentColor?: string; 100 | } 101 | 102 | export interface PanelInfo | null> { 103 | component_name: string; 104 | config: T; 105 | icon: string | null; 106 | title: string | null; 107 | url_path: string; 108 | } 109 | 110 | export interface Panels { 111 | [name: string]: PanelInfo; 112 | } 113 | 114 | export interface Resources { 115 | [language: string]: Record; 116 | } 117 | 118 | export interface Translation { 119 | nativeName: string; 120 | isRTL: boolean; 121 | hash: string; 122 | } 123 | 124 | export interface TranslationMetadata { 125 | fragments: string[]; 126 | translations: { 127 | [lang: string]: Translation; 128 | }; 129 | } 130 | 131 | export interface Credential { 132 | auth_provider_type: string; 133 | auth_provider_id: string; 134 | } 135 | 136 | export interface MFAModule { 137 | id: string; 138 | name: string; 139 | enabled: boolean; 140 | } 141 | 142 | export interface CurrentUser { 143 | id: string; 144 | is_owner: boolean; 145 | is_admin: boolean; 146 | name: string; 147 | credentials: Credential[]; 148 | mfa_modules: MFAModule[]; 149 | } 150 | 151 | export interface ServiceCallRequest { 152 | domain: string; 153 | service: string; 154 | serviceData?: Record; 155 | target?: HassServiceTarget; 156 | } 157 | 158 | export interface Context { 159 | id: string; 160 | parent_id?: string; 161 | user_id?: string | null; 162 | } 163 | 164 | export interface ServiceCallResponse { 165 | context: Context; 166 | } 167 | 168 | export interface HomeAssistant { 169 | auth: Auth; 170 | connection: Connection; 171 | connected: boolean; 172 | states: HassEntities; 173 | entities: { [id: string]: EntityRegistryDisplayEntry }; 174 | devices: { [id: string]: DeviceRegistryEntry }; 175 | areas: { [id: string]: AreaRegistryEntry }; 176 | services: HassServices; 177 | config: HassConfig; 178 | themes: Themes; 179 | selectedTheme: ThemeSettings | null; 180 | panels: Panels; 181 | panelUrl: string; 182 | // i18n 183 | // current effective language in that order: 184 | // - backend saved user selected language 185 | // - language in local app storage 186 | // - browser language 187 | // - english (en) 188 | language: string; 189 | // local stored language, keep that name for backward compatibility 190 | selectedLanguage: string | null; 191 | locale: FrontendLocaleData; 192 | resources: Resources; 193 | localize: LocalizeFunc; 194 | translationMetadata: TranslationMetadata; 195 | suspendWhenHidden: boolean; 196 | enableShortcuts: boolean; 197 | vibrate: boolean; 198 | dockedSidebar: "docked" | "always_hidden" | "auto"; 199 | defaultPanel: string; 200 | moreInfoEntityId: string | null; 201 | user?: CurrentUser; 202 | hassUrl(path?): string; 203 | callService( 204 | domain: ServiceCallRequest["domain"], 205 | service: ServiceCallRequest["service"], 206 | serviceData?: ServiceCallRequest["serviceData"], 207 | target?: ServiceCallRequest["target"] 208 | ): Promise; 209 | callApi( 210 | method: "GET" | "POST" | "PUT" | "DELETE", 211 | path: string, 212 | parameters?: Record, 213 | headers?: Record 214 | ): Promise; 215 | fetchWithAuth(path: string, init?: Record): Promise; 216 | sendWS(msg: MessageBase): void; 217 | callWS(msg: MessageBase): Promise; 218 | loadBackendTranslation( 219 | category: TranslationCategory, 220 | integration?: string | string[], 221 | configFlow?: boolean 222 | ): Promise; 223 | formatEntityState(stateObj: HassEntity, state?: string): string; 224 | formatEntityAttributeValue( 225 | stateObj: HassEntity, 226 | attribute: string, 227 | value?: any 228 | ): string; 229 | formatEntityAttributeName(stateObj: HassEntity, attribute: string): string; 230 | } 231 | 232 | export type Constructor = new (...args: any[]) => T; 233 | -------------------------------------------------------------------------------- /src/tests/card/segments/computeSeverity.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, afterEach } from "vitest"; 2 | import type { Gauge } from "../../../card/config"; 3 | import type { GaugeCardProCard } from "../../../card/card"; 4 | import { computeSeverity } from "../../../card/_segments"; 5 | 6 | vi.mock("../../../utils/color/computed-color", () => ({ 7 | getComputedColor: (color: string) => { 8 | switch (color) { 9 | case "var(--info-color)": 10 | return "#039be5"; 11 | default: 12 | return color; 13 | } 14 | }, 15 | })); 16 | 17 | vi.mock( 18 | "../../../dependencies/ha/panels/lovelace/common/directives/action-handler-directive.ts", 19 | () => ({ isTouch: () => false }) 20 | ); 21 | 22 | describe("computeSeverity", () => { 23 | afterEach(() => { 24 | vi.clearAllMocks(); 25 | }); 26 | 27 | type TestCase = { 28 | name: string; 29 | config: { 30 | type: string; 31 | gradient?: boolean; 32 | needle?: boolean; 33 | inner?: {}; 34 | }; 35 | gauge?: Gauge; 36 | segments?: Array<{ from: number; color: string }>; 37 | min: number; 38 | max: number; 39 | value: number; 40 | shouldCallSegments: boolean; 41 | shouldCallInnerSegments: boolean; 42 | expected: string | undefined; 43 | }; 44 | 45 | const defaultSegments = [ 46 | { from: 0, color: "#ff0000" }, 47 | { from: 100, color: "#00ff00" }, 48 | { from: 200, color: "#0000ff" }, 49 | ]; 50 | 51 | const cases: TestCase[] = [ 52 | { 53 | name: "0, no interpolation", 54 | config: { 55 | type: "custom:gauge-card-pro", 56 | gradient: false, 57 | needle: false, 58 | }, 59 | segments: defaultSegments, 60 | min: 0, 61 | max: 200, 62 | value: 0, 63 | shouldCallSegments: true, 64 | shouldCallInnerSegments: false, 65 | expected: "#ff0000", 66 | }, 67 | { 68 | name: "50, no interpolation", 69 | config: { 70 | type: "custom:gauge-card-pro", 71 | gradient: false, 72 | needle: false, 73 | }, 74 | segments: defaultSegments, 75 | min: 0, 76 | max: 200, 77 | value: 50, 78 | shouldCallSegments: true, 79 | shouldCallInnerSegments: false, 80 | expected: "#ff0000", 81 | }, 82 | { 83 | name: "100, no interpolation", 84 | config: { 85 | type: "custom:gauge-card-pro", 86 | gradient: false, 87 | needle: false, 88 | }, 89 | segments: defaultSegments, 90 | min: 0, 91 | max: 200, 92 | value: 100, 93 | shouldCallSegments: true, 94 | shouldCallInnerSegments: false, 95 | expected: "#00ff00", 96 | }, 97 | { 98 | name: "150, no interpolation", 99 | config: { 100 | type: "custom:gauge-card-pro", 101 | gradient: false, 102 | needle: false, 103 | }, 104 | segments: defaultSegments, 105 | min: 0, 106 | max: 200, 107 | value: 150, 108 | shouldCallSegments: true, 109 | shouldCallInnerSegments: false, 110 | expected: "#00ff00", 111 | }, 112 | { 113 | name: "200, no interpolation", 114 | config: { 115 | type: "custom:gauge-card-pro", 116 | gradient: false, 117 | needle: false, 118 | }, 119 | segments: defaultSegments, 120 | min: 0, 121 | max: 200, 122 | value: 200, 123 | shouldCallSegments: true, 124 | shouldCallInnerSegments: false, 125 | expected: "#0000ff", 126 | }, 127 | { 128 | name: "250, no interpolation", 129 | config: { 130 | type: "custom:gauge-card-pro", 131 | gradient: false, 132 | needle: false, 133 | }, 134 | segments: defaultSegments, 135 | min: 0, 136 | max: 200, 137 | value: 200, 138 | shouldCallSegments: true, 139 | shouldCallInnerSegments: false, 140 | expected: "#0000ff", 141 | }, 142 | { 143 | name: "50, with interpolation", 144 | config: { 145 | type: "custom:gauge-card-pro", 146 | gradient: true, 147 | needle: false, 148 | }, 149 | segments: defaultSegments, 150 | min: 0, 151 | max: 200, 152 | value: 50, 153 | shouldCallSegments: true, 154 | shouldCallInnerSegments: false, 155 | expected: "#807f00", 156 | }, 157 | { 158 | name: "-1", 159 | config: { 160 | type: "custom:gauge-card-pro", 161 | gradient: false, 162 | needle: false, 163 | }, 164 | segments: defaultSegments, 165 | min: 0, 166 | max: 200, 167 | value: -1, 168 | shouldCallSegments: true, 169 | shouldCallInnerSegments: false, 170 | expected: "#039be5", 171 | }, 172 | { 173 | name: "needle", 174 | config: { 175 | type: "custom:gauge-card-pro", 176 | gradient: false, 177 | needle: true, 178 | }, 179 | segments: defaultSegments, 180 | min: 0, 181 | max: 200, 182 | value: 100, 183 | shouldCallSegments: false, 184 | shouldCallInnerSegments: false, 185 | expected: undefined, 186 | }, 187 | { 188 | name: "no segments config", 189 | config: { 190 | type: "custom:gauge-card-pro", 191 | gradient: false, 192 | needle: false, 193 | }, 194 | min: 0, 195 | max: 200, 196 | value: 100, 197 | shouldCallSegments: true, 198 | shouldCallInnerSegments: false, 199 | expected: "#039be5", 200 | }, 201 | { 202 | name: "inner 50, no interpolation", 203 | config: { 204 | type: "custom:gauge-card-pro", 205 | inner: { 206 | mode: "severity", 207 | gradient: false, 208 | }, 209 | }, 210 | segments: defaultSegments, 211 | gauge: "inner", 212 | min: 0, 213 | max: 200, 214 | value: 50, 215 | shouldCallSegments: false, 216 | shouldCallInnerSegments: true, 217 | expected: "#ff0000", 218 | }, 219 | { 220 | name: "inner 50, with interpolation", 221 | config: { 222 | type: "custom:gauge-card-pro", 223 | inner: { 224 | mode: "severity", 225 | gradient: true, 226 | }, 227 | }, 228 | segments: defaultSegments, 229 | gauge: "inner", 230 | min: 0, 231 | max: 200, 232 | value: 50, 233 | shouldCallSegments: false, 234 | shouldCallInnerSegments: true, 235 | expected: "#807f00", 236 | }, 237 | ]; 238 | 239 | const card = { 240 | _config: vi.fn(), 241 | getValue: vi.fn(), 242 | } as unknown as GaugeCardProCard; 243 | 244 | it.each(cases)( 245 | "$name", 246 | ({ 247 | config, 248 | gauge, 249 | segments, 250 | min, 251 | max, 252 | value, 253 | expected, 254 | shouldCallSegments, 255 | shouldCallInnerSegments, 256 | }) => { 257 | // mock _config 258 | vi.spyOn(card, "_config", "get").mockReturnValue({ 259 | type: config.type, 260 | gradient: config.gradient, 261 | needle: config.needle, 262 | inner: config.inner, 263 | }); 264 | 265 | // mock card.getValue() 266 | vi.spyOn(card, "getValue").mockImplementation((key: string) => { 267 | switch (key) { 268 | case "segments": 269 | return segments; 270 | case "inner.segments": 271 | return segments; 272 | default: 273 | return undefined; 274 | } 275 | }); 276 | 277 | const _gauge = gauge === undefined ? "main" : "inner"; 278 | const result = computeSeverity(card, _gauge, min, max, value); 279 | 280 | if (shouldCallSegments) { 281 | expect(card.getValue).toHaveBeenNthCalledWith(1, "segments"); 282 | } else { 283 | expect(card.getValue).not.toHaveBeenCalledWith("segments"); 284 | } 285 | 286 | if (shouldCallInnerSegments) { 287 | expect(card.getValue).toHaveBeenNthCalledWith(1, "inner.segments"); 288 | } else { 289 | expect(card.getValue).not.toHaveBeenCalledWith("inner.segments"); 290 | } 291 | 292 | expect(result).toBe(expected); 293 | } 294 | ); 295 | }); 296 | -------------------------------------------------------------------------------- /src/dependencies/ha/panels/lovelace/common/directives/action-handler-directive.ts: -------------------------------------------------------------------------------- 1 | import { noChange } from "lit"; 2 | import { 3 | AttributePart, 4 | directive, 5 | Directive, 6 | DirectiveParameters, 7 | } from "lit/directive.js"; 8 | import { fireEvent } from "../../../../common/dom/fire_event"; 9 | import { deepEqual } from "../../../../common/util/deep-equal"; 10 | import { 11 | ActionHandlerDetail, 12 | ActionHandlerOptions, 13 | } from "../../../../data/lovelace"; 14 | 15 | const isTouch = 16 | "ontouchstart" in window || 17 | navigator.maxTouchPoints > 0 || 18 | // @ts-ignore 19 | navigator.msMaxTouchPoints > 0; 20 | 21 | interface ActionHandlerType extends HTMLElement { 22 | holdTime: number; 23 | bind(element: Element, options?: ActionHandlerOptions): void; 24 | } 25 | interface ActionHandlerElement extends HTMLElement { 26 | actionHandler?: { 27 | options: ActionHandlerOptions; 28 | start?: (ev: Event) => void; 29 | end?: (ev: Event) => void; 30 | handleKeyDown?: (ev: KeyboardEvent) => void; 31 | }; 32 | } 33 | 34 | declare global { 35 | interface HTMLElementTagNameMap { 36 | "action-handler": ActionHandler; 37 | } 38 | interface HASSDomEvents { 39 | action: ActionHandlerDetail; 40 | } 41 | } 42 | 43 | class ActionHandler extends HTMLElement implements ActionHandlerType { 44 | public holdTime = 500; 45 | 46 | protected timer?: number; 47 | 48 | protected held = false; 49 | 50 | private cancelled = false; 51 | 52 | private dblClickTimeout?: number; 53 | 54 | public connectedCallback() { 55 | Object.assign(this.style, { 56 | position: "fixed", 57 | width: isTouch ? "100px" : "50px", 58 | height: isTouch ? "100px" : "50px", 59 | transform: "translate(-50%, -50%) scale(0)", 60 | pointerEvents: "none", 61 | zIndex: "999", 62 | background: "var(--primary-color)", 63 | display: null, 64 | opacity: "0.2", 65 | borderRadius: "50%", 66 | transition: "transform 180ms ease-in-out", 67 | }); 68 | 69 | [ 70 | "touchcancel", 71 | "mouseout", 72 | "mouseup", 73 | "touchmove", 74 | "mousewheel", 75 | "wheel", 76 | "scroll", 77 | ].forEach((ev) => { 78 | document.addEventListener( 79 | ev, 80 | () => { 81 | this.cancelled = true; 82 | if (this.timer) { 83 | this._stopAnimation(); 84 | clearTimeout(this.timer); 85 | this.timer = undefined; 86 | } 87 | }, 88 | { passive: true } 89 | ); 90 | }); 91 | } 92 | 93 | public bind( 94 | element: ActionHandlerElement, 95 | options: ActionHandlerOptions = {} 96 | ) { 97 | if ( 98 | element.actionHandler && 99 | deepEqual(options, element.actionHandler.options) 100 | ) { 101 | return; 102 | } 103 | 104 | if (element.actionHandler) { 105 | element.removeEventListener("touchstart", element.actionHandler.start!); 106 | element.removeEventListener("touchend", element.actionHandler.end!); 107 | element.removeEventListener("touchcancel", element.actionHandler.end!); 108 | 109 | element.removeEventListener("mousedown", element.actionHandler.start!); 110 | element.removeEventListener("click", element.actionHandler.end!); 111 | 112 | element.removeEventListener( 113 | "keydown", 114 | element.actionHandler.handleKeyDown! 115 | ); 116 | } else { 117 | element.addEventListener("contextmenu", (ev: Event) => { 118 | const e = ev || window.event; 119 | if (e.preventDefault) { 120 | e.preventDefault(); 121 | } 122 | if (e.stopPropagation) { 123 | e.stopPropagation(); 124 | } 125 | e.cancelBubble = true; 126 | e.returnValue = false; 127 | return false; 128 | }); 129 | } 130 | 131 | element.actionHandler = { options }; 132 | 133 | if (options.disabled) { 134 | return; 135 | } 136 | 137 | element.actionHandler.start = (ev: Event) => { 138 | this.cancelled = false; 139 | let x; 140 | let y; 141 | if ((ev as TouchEvent).touches) { 142 | x = (ev as TouchEvent).touches[0].clientX; 143 | y = (ev as TouchEvent).touches[0].clientY; 144 | } else { 145 | x = (ev as MouseEvent).clientX; 146 | y = (ev as MouseEvent).clientY; 147 | } 148 | 149 | if (options.hasHold) { 150 | this.held = false; 151 | this.timer = window.setTimeout(() => { 152 | this._startAnimation(x, y); 153 | this.held = true; 154 | }, this.holdTime); 155 | } 156 | }; 157 | 158 | element.actionHandler.end = (ev: Event) => { 159 | // Don't respond when moved or scrolled while touch 160 | if ( 161 | ev.type === "touchcancel" || 162 | (ev.type === "touchend" && this.cancelled) 163 | ) { 164 | return; 165 | } 166 | const target = ev.target as HTMLElement; 167 | // Prevent mouse event if touch event 168 | if (ev.cancelable) { 169 | ev.preventDefault(); 170 | } 171 | if (options.hasHold) { 172 | clearTimeout(this.timer); 173 | this._stopAnimation(); 174 | this.timer = undefined; 175 | } 176 | if (options.hasHold && this.held) { 177 | fireEvent(target, "action", { action: "hold" }); 178 | } else if (options.hasDoubleClick) { 179 | if ( 180 | (ev.type === "click" && (ev as MouseEvent).detail < 2) || 181 | !this.dblClickTimeout 182 | ) { 183 | this.dblClickTimeout = window.setTimeout(() => { 184 | this.dblClickTimeout = undefined; 185 | fireEvent(target, "action", { action: "tap" }); 186 | }, 250); 187 | } else { 188 | clearTimeout(this.dblClickTimeout); 189 | this.dblClickTimeout = undefined; 190 | fireEvent(target, "action", { action: "double_tap" }); 191 | } 192 | } else { 193 | fireEvent(target, "action", { action: "tap" }); 194 | } 195 | }; 196 | 197 | element.actionHandler.handleKeyDown = (ev: KeyboardEvent) => { 198 | if (!["Enter", " "].includes(ev.key)) { 199 | return; 200 | } 201 | (ev.currentTarget as ActionHandlerElement).actionHandler!.end!(ev); 202 | }; 203 | 204 | element.addEventListener("touchstart", element.actionHandler.start, { 205 | passive: true, 206 | }); 207 | element.addEventListener("touchend", element.actionHandler.end); 208 | element.addEventListener("touchcancel", element.actionHandler.end); 209 | 210 | element.addEventListener("mousedown", element.actionHandler.start, { 211 | passive: true, 212 | }); 213 | element.addEventListener("click", element.actionHandler.end); 214 | 215 | element.addEventListener("keydown", element.actionHandler.handleKeyDown); 216 | } 217 | 218 | private _startAnimation(x: number, y: number) { 219 | Object.assign(this.style, { 220 | left: `${x}px`, 221 | top: `${y}px`, 222 | transform: "translate(-50%, -50%) scale(1)", 223 | }); 224 | } 225 | 226 | private _stopAnimation() { 227 | Object.assign(this.style, { 228 | left: null, 229 | top: null, 230 | transform: "translate(-50%, -50%) scale(0)", 231 | }); 232 | } 233 | } 234 | 235 | const getActionHandler = (): ActionHandlerType => { 236 | const body = document.body; 237 | if (body.querySelector("action-handler")) { 238 | return body.querySelector("action-handler") as ActionHandlerType; 239 | } 240 | 241 | const actionhandler = document.createElement("action-handler"); 242 | body.appendChild(actionhandler); 243 | 244 | return actionhandler as ActionHandlerType; 245 | }; 246 | 247 | export const actionHandlerBind = ( 248 | element: ActionHandlerElement, 249 | options?: ActionHandlerOptions 250 | ) => { 251 | const actionhandler: ActionHandlerType = getActionHandler(); 252 | if (!actionhandler) { 253 | return; 254 | } 255 | actionhandler.bind(element, options); 256 | }; 257 | 258 | export const actionHandler = directive( 259 | class extends Directive { 260 | update(part: AttributePart, [options]: DirectiveParameters) { 261 | actionHandlerBind(part.element as ActionHandlerElement, options); 262 | return noChange; 263 | } 264 | 265 | // eslint-disable-next-line @typescript-eslint/no-empty-function 266 | render(_options?: ActionHandlerOptions) {} 267 | } 268 | ); 269 | -------------------------------------------------------------------------------- /src/dependencies/ha/data/entity_registry.ts: -------------------------------------------------------------------------------- 1 | import { Connection, createCollection } from "home-assistant-js-websocket"; 2 | import { Store } from "home-assistant-js-websocket/dist/store"; 3 | import memoizeOne from "memoize-one"; 4 | import { computeStateName } from "../common/entity/compute_state_name"; 5 | import { caseInsensitiveStringCompare } from "../common/string/compare"; 6 | import { debounce } from "../common/util/debounce"; 7 | import { HomeAssistant } from "../types"; 8 | // import { LightColor } from "./light"; 9 | 10 | type entityCategory = "config" | "diagnostic"; 11 | 12 | export interface EntityRegistryDisplayEntry { 13 | entity_id: string; 14 | name?: string; 15 | device_id?: string; 16 | area_id?: string; 17 | hidden?: boolean; 18 | entity_category?: entityCategory; 19 | translation_key?: string; 20 | platform?: string; 21 | display_precision?: number; 22 | } 23 | 24 | interface EntityRegistryDisplayEntryResponse { 25 | entities: { 26 | ei: string; 27 | di?: string; 28 | ai?: string; 29 | ec?: number; 30 | en?: string; 31 | pl?: string; 32 | tk?: string; 33 | hb?: boolean; 34 | dp?: number; 35 | }[]; 36 | entity_categories: Record; 37 | } 38 | 39 | export interface EntityRegistryEntry { 40 | id: string; 41 | entity_id: string; 42 | name: string | null; 43 | icon: string | null; 44 | platform: string; 45 | config_entry_id: string | null; 46 | device_id: string | null; 47 | area_id: string | null; 48 | disabled_by: "user" | "device" | "integration" | "config_entry" | null; 49 | hidden_by: Exclude; 50 | entity_category: entityCategory | null; 51 | has_entity_name: boolean; 52 | original_name?: string; 53 | unique_id: string; 54 | translation_key?: string; 55 | options: EntityRegistryOptions | null; 56 | } 57 | 58 | export interface ExtEntityRegistryEntry extends EntityRegistryEntry { 59 | capabilities: Record; 60 | original_icon?: string; 61 | device_class?: string; 62 | original_device_class?: string; 63 | aliases: string[]; 64 | } 65 | 66 | export interface UpdateEntityRegistryEntryResult { 67 | entity_entry: ExtEntityRegistryEntry; 68 | reload_delay?: number; 69 | require_restart?: boolean; 70 | } 71 | 72 | export interface SensorEntityOptions { 73 | display_precision?: number | null; 74 | suggested_display_precision?: number | null; 75 | unit_of_measurement?: string | null; 76 | } 77 | 78 | // export interface LightEntityOptions { 79 | // favorite_colors?: LightColor[]; 80 | // } 81 | 82 | export interface NumberEntityOptions { 83 | unit_of_measurement?: string | null; 84 | } 85 | 86 | export interface LockEntityOptions { 87 | default_code?: string | null; 88 | } 89 | 90 | export interface WeatherEntityOptions { 91 | precipitation_unit?: string | null; 92 | pressure_unit?: string | null; 93 | temperature_unit?: string | null; 94 | visibility_unit?: string | null; 95 | wind_speed_unit?: string | null; 96 | } 97 | 98 | export interface SwitchAsXEntityOptions { 99 | entity_id: string; 100 | } 101 | 102 | export interface AlarmControlPanelEntityOptions { 103 | default_code?: string | null; 104 | } 105 | 106 | export interface EntityRegistryOptions { 107 | number?: NumberEntityOptions; 108 | sensor?: SensorEntityOptions; 109 | alarm_control_panel?: AlarmControlPanelEntityOptions; 110 | lock?: LockEntityOptions; 111 | weather?: WeatherEntityOptions; 112 | // light?: LightEntityOptions; 113 | switch_as_x?: SwitchAsXEntityOptions; 114 | conversation?: Record; 115 | "cloud.alexa"?: Record; 116 | "cloud.google_assistant"?: Record; 117 | } 118 | 119 | export interface EntityRegistryEntryUpdateParams { 120 | name?: string | null; 121 | icon?: string | null; 122 | device_class?: string | null; 123 | area_id?: string | null; 124 | disabled_by?: string | null; 125 | hidden_by: string | null; 126 | new_entity_id?: string; 127 | options_domain?: string; 128 | options?: 129 | | SensorEntityOptions 130 | | NumberEntityOptions 131 | | LockEntityOptions 132 | | WeatherEntityOptions; 133 | // | LightEntityOptions; 134 | aliases?: string[]; 135 | } 136 | 137 | export const findBatteryEntity = ( 138 | hass: HomeAssistant, 139 | entities: EntityRegistryEntry[] 140 | ): EntityRegistryEntry | undefined => 141 | entities.find( 142 | (entity) => 143 | hass.states[entity.entity_id] && 144 | hass.states[entity.entity_id].attributes.device_class === "battery" 145 | ); 146 | 147 | export const findBatteryChargingEntity = ( 148 | hass: HomeAssistant, 149 | entities: EntityRegistryEntry[] 150 | ): EntityRegistryEntry | undefined => 151 | entities.find( 152 | (entity) => 153 | hass.states[entity.entity_id] && 154 | hass.states[entity.entity_id].attributes.device_class === 155 | "battery_charging" 156 | ); 157 | 158 | export const computeEntityRegistryName = ( 159 | hass: HomeAssistant, 160 | entry: EntityRegistryEntry 161 | ): string | null => { 162 | if (entry.name) { 163 | return entry.name; 164 | } 165 | const state = hass.states[entry.entity_id]; 166 | if (state) { 167 | return computeStateName(state); 168 | } 169 | return entry.original_name ? entry.original_name : entry.entity_id; 170 | }; 171 | 172 | export const getExtendedEntityRegistryEntry = ( 173 | hass: HomeAssistant, 174 | entityId: string 175 | ): Promise => 176 | hass.callWS({ 177 | type: "config/entity_registry/get", 178 | entity_id: entityId, 179 | }); 180 | 181 | export const getExtendedEntityRegistryEntries = ( 182 | hass: HomeAssistant, 183 | entityIds: string[] 184 | ): Promise> => 185 | hass.callWS({ 186 | type: "config/entity_registry/get_entries", 187 | entity_ids: entityIds, 188 | }); 189 | 190 | export const updateEntityRegistryEntry = ( 191 | hass: HomeAssistant, 192 | entityId: string, 193 | updates: Partial 194 | ): Promise => 195 | hass.callWS({ 196 | type: "config/entity_registry/update", 197 | entity_id: entityId, 198 | ...updates, 199 | }); 200 | 201 | export const removeEntityRegistryEntry = ( 202 | hass: HomeAssistant, 203 | entityId: string 204 | ): Promise => 205 | hass.callWS({ 206 | type: "config/entity_registry/remove", 207 | entity_id: entityId, 208 | }); 209 | 210 | export const fetchEntityRegistry = (conn: Connection) => 211 | conn.sendMessagePromise({ 212 | type: "config/entity_registry/list", 213 | }); 214 | 215 | export const fetchEntityRegistryDisplay = (conn: Connection) => 216 | conn.sendMessagePromise({ 217 | type: "config/entity_registry/list_for_display", 218 | }); 219 | 220 | const subscribeEntityRegistryUpdates = ( 221 | conn: Connection, 222 | store: Store 223 | ) => 224 | conn.subscribeEvents( 225 | debounce( 226 | () => 227 | fetchEntityRegistry(conn).then((entities) => 228 | store.setState(entities, true) 229 | ), 230 | 500, 231 | true 232 | ), 233 | "entity_registry_updated" 234 | ); 235 | 236 | export const subscribeEntityRegistry = ( 237 | conn: Connection, 238 | onChange: (entities: EntityRegistryEntry[]) => void 239 | ) => 240 | createCollection( 241 | "_entityRegistry", 242 | fetchEntityRegistry, 243 | subscribeEntityRegistryUpdates, 244 | conn, 245 | onChange 246 | ); 247 | 248 | const subscribeEntityRegistryDisplayUpdates = ( 249 | conn: Connection, 250 | store: Store 251 | ) => 252 | conn.subscribeEvents( 253 | debounce( 254 | () => 255 | fetchEntityRegistryDisplay(conn).then((entities) => 256 | store.setState(entities, true) 257 | ), 258 | 500, 259 | true 260 | ), 261 | "entity_registry_updated" 262 | ); 263 | 264 | export const subscribeEntityRegistryDisplay = ( 265 | conn: Connection, 266 | onChange: (entities: EntityRegistryDisplayEntryResponse) => void 267 | ) => 268 | createCollection( 269 | "_entityRegistryDisplay", 270 | fetchEntityRegistryDisplay, 271 | subscribeEntityRegistryDisplayUpdates, 272 | conn, 273 | onChange 274 | ); 275 | 276 | export const sortEntityRegistryByName = ( 277 | entries: EntityRegistryEntry[], 278 | language: string 279 | ) => 280 | entries.sort((entry1, entry2) => 281 | caseInsensitiveStringCompare(entry1.name || "", entry2.name || "", language) 282 | ); 283 | 284 | export const entityRegistryByEntityId = memoizeOne( 285 | (entries: EntityRegistryEntry[]) => { 286 | const entities: Record = {}; 287 | for (const entity of entries) { 288 | entities[entity.entity_id] = entity; 289 | } 290 | return entities; 291 | } 292 | ); 293 | 294 | export const entityRegistryById = memoizeOne( 295 | (entries: EntityRegistryEntry[]) => { 296 | const entities: Record = {}; 297 | for (const entity of entries) { 298 | entities[entity.id] = entity; 299 | } 300 | return entities; 301 | } 302 | ); 303 | 304 | export const getEntityPlatformLookup = ( 305 | entities: EntityRegistryEntry[] 306 | ): Record => { 307 | const entityLookup = {}; 308 | for (const confEnt of entities) { 309 | if (!confEnt.platform) { 310 | continue; 311 | } 312 | entityLookup[confEnt.entity_id] = confEnt.platform; 313 | } 314 | return entityLookup; 315 | }; 316 | -------------------------------------------------------------------------------- /src/dependencies/calendar-card-pro/logger.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/order */ 2 | /** 3 | * Logging utilities for Calendar Card Pro 4 | * Provides consistent log formatting, level-based filtering, and error handling 5 | */ 6 | 7 | import { LOGGING } from "../../card/const"; 8 | 9 | // Add a flag to ensure the banner only shows once per session 10 | let BANNER_SHOWN = false; 11 | 12 | // Different log levels - keeping enum in logger-utils.ts 13 | export enum LogLevel { 14 | ERROR = 0, 15 | WARN = 1, 16 | INFO = 2, 17 | DEBUG = 3, 18 | } 19 | 20 | // Use the constant from constants.ts as the default value 21 | let currentLogLevel = LOGGING.CURRENT_LOG_LEVEL; 22 | 23 | // Styling for log messages - keeping in logger-utils.ts 24 | const LOG_STYLES = { 25 | // Title pill (left side - dark grey with emoji) 26 | title: [ 27 | "background: #424242", 28 | "color: white", 29 | "display: inline-block", 30 | "line-height: 20px", 31 | "text-align: center", 32 | "border-radius: 5px 0 0 5px", 33 | "font-size: 12px", 34 | "font-weight: bold", 35 | "padding: 4px 8px 4px 12px", 36 | "margin: 5px 0", 37 | ].join(";"), 38 | 39 | // Version pill (right side - pale blue) 40 | version: [ 41 | "background: #4fc3f7", 42 | "color: white", 43 | "display: inline-block", 44 | "line-height: 20px", 45 | "text-align: center", 46 | "border-radius: 0 5px 5px 0", 47 | "font-size: 12px", 48 | "font-weight: bold", 49 | "padding: 4px 12px 4px 8px", 50 | "margin: 5px 0", 51 | ].join(";"), 52 | 53 | // Standard prefix (non-pill version for regular logs) 54 | prefix: ["color: #4fc3f7", "font-weight: bold"].join(";"), 55 | 56 | // Error styling 57 | error: ["color: #f44336", "font-weight: bold"].join(";"), 58 | 59 | // Warning styling 60 | warn: ["color: #ff9800", "font-weight: bold"].join(";"), 61 | }; 62 | 63 | //----------------------------------------------------------------------------- 64 | // INITIALIZATION FUNCTIONS 65 | //----------------------------------------------------------------------------- 66 | 67 | /** 68 | * Initialize the logger with the component version 69 | * @param version Current component version 70 | */ 71 | export function initializeLogger(version: string): void { 72 | // Show version banner (always show this regardless of log level) 73 | printVersionBanner(version); 74 | } 75 | 76 | /** 77 | * Print the welcome banner with version info 78 | * @param version Component version 79 | */ 80 | export function printVersionBanner(version: string): void { 81 | // Only show banner once per browser session 82 | if (BANNER_SHOWN) return; 83 | 84 | console.groupCollapsed( 85 | `%c${LOGGING.PREFIX}%cv${version}`, 86 | LOG_STYLES.title, 87 | LOG_STYLES.version 88 | ); 89 | console.log( 90 | "%c Description: %c Build beautiful Gauge cards using 🌈 gradients and 🛠️ templates. ", 91 | "font-weight: bold", 92 | "font-weight: normal" 93 | ); 94 | console.log( 95 | "%c GitHub: %c https://github.com/benjamin-dcs/gauge-card-pro ", 96 | "font-weight: bold", 97 | "font-weight: normal" 98 | ); 99 | console.groupEnd(); 100 | 101 | // Mark banner as shown 102 | BANNER_SHOWN = true; 103 | } 104 | 105 | //----------------------------------------------------------------------------- 106 | // PRIMARY PUBLIC API FUNCTIONS 107 | //----------------------------------------------------------------------------- 108 | 109 | /** 110 | * Enhanced error logging that handles different error types and contexts 111 | * Consolidates error, logError and handleApiError into a single flexible function 112 | * 113 | * @param messageOrError - Error object, message string, or other value 114 | * @param context - Optional context (string, object, or unknown) 115 | * @param data - Additional data to include in the log 116 | */ 117 | export function error( 118 | messageOrError: string | Error | unknown, 119 | context?: string | Record | unknown, 120 | ...data: unknown[] 121 | ): void { 122 | if (currentLogLevel < LogLevel.ERROR) return; 123 | 124 | // Convert unknown context to a safe format 125 | const safeContext = formatUnknownContext(context); 126 | 127 | // Process based on error type and context type 128 | if (messageOrError instanceof Error) { 129 | // Case 1: Error object 130 | const errorMessage = messageOrError.message || "Unknown error"; 131 | const contextInfo = 132 | typeof safeContext === "string" ? ` during ${safeContext}` : ""; 133 | const [formattedMsg, style] = formatLogMessage( 134 | `Error${contextInfo}: ${errorMessage}`, 135 | LOG_STYLES.error 136 | ); 137 | 138 | console.error(formattedMsg, style); 139 | 140 | // Always log stack trace for Error objects 141 | if (messageOrError.stack) { 142 | console.error(messageOrError.stack); 143 | } 144 | 145 | // Add context object if provided 146 | if (safeContext && typeof safeContext === "object") { 147 | console.error("Context:", { 148 | ...safeContext, 149 | timestamp: new Date().toISOString(), 150 | }); 151 | } 152 | 153 | // Include any additional data 154 | if (data.length > 0) { 155 | console.error("Additional data:", ...data); 156 | } 157 | } else if (typeof messageOrError === "string") { 158 | // Case 2: String message 159 | const contextInfo = 160 | typeof safeContext === "string" ? ` during ${safeContext}` : ""; 161 | const [formattedMsg, style] = formatLogMessage( 162 | `${messageOrError}${contextInfo}`, 163 | LOG_STYLES.error 164 | ); 165 | 166 | if (safeContext && typeof safeContext === "object") { 167 | // If context is an object, include it in the log 168 | console.error(formattedMsg, style, { 169 | context: { 170 | ...safeContext, 171 | timestamp: new Date().toISOString(), 172 | }, 173 | ...(data.length > 0 ? { additionalData: data } : {}), 174 | }); 175 | } else if (data.length > 0) { 176 | // Just include additional data 177 | console.error(formattedMsg, style, ...data); 178 | } else { 179 | // Simple error message 180 | console.error(formattedMsg, style); 181 | } 182 | } else { 183 | // Case 3: Unknown error type 184 | const contextInfo = 185 | typeof safeContext === "string" ? ` during ${safeContext}` : ""; 186 | const [formattedMsg, style] = formatLogMessage( 187 | `Unknown error${contextInfo}:`, 188 | LOG_STYLES.error 189 | ); 190 | 191 | console.error(formattedMsg, style, messageOrError); 192 | 193 | // Add context object if provided 194 | if (safeContext && typeof safeContext === "object") { 195 | console.error("Context:", { 196 | ...safeContext, 197 | timestamp: new Date().toISOString(), 198 | }); 199 | } 200 | 201 | // Include any additional data 202 | if (data.length > 0) { 203 | console.error("Additional data:", ...data); 204 | } 205 | } 206 | } 207 | 208 | /** 209 | * Log a warning message 210 | */ 211 | export function warn(message: string, ...data: unknown[]): void { 212 | simpleLog(LogLevel.WARN, message, LOG_STYLES.warn, console.warn, ...data); 213 | } 214 | 215 | /** 216 | * Log an info message 217 | */ 218 | export function info(message: string, ...data: unknown[]): void { 219 | simpleLog(LogLevel.INFO, message, LOG_STYLES.prefix, console.log, ...data); 220 | } 221 | 222 | /** 223 | * Log a debug message 224 | */ 225 | export function debug(message: string, ...data: unknown[]): void { 226 | simpleLog(LogLevel.DEBUG, message, LOG_STYLES.prefix, console.log, ...data); 227 | } 228 | 229 | //----------------------------------------------------------------------------- 230 | // INTERNAL HELPER FUNCTIONS 231 | //----------------------------------------------------------------------------- 232 | 233 | /** 234 | * Internal helper for basic log levels (warn, info, debug) 235 | * @param level - Log level for filtering 236 | * @param message - Message to log 237 | * @param style - Style to apply to the message 238 | * @param consoleMethod - Console method to use 239 | * @param data - Additional data to log 240 | */ 241 | function simpleLog( 242 | level: LogLevel, 243 | message: string, 244 | style: string, 245 | consoleMethod: (...args: unknown[]) => void, 246 | ...data: unknown[] 247 | ): void { 248 | if (currentLogLevel < level) return; 249 | 250 | const [formattedMsg, styleArg] = formatLogMessage(message, style); 251 | if (data.length > 0) { 252 | consoleMethod(formattedMsg, styleArg, ...data); 253 | } else { 254 | consoleMethod(formattedMsg, styleArg); 255 | } 256 | } 257 | 258 | /** 259 | * Format a log message with consistent prefix and styling 260 | * @param message The message to format 261 | * @param style The style to apply 262 | * @returns Tuple of [formattedMessage, style] for console methods 263 | */ 264 | function formatLogMessage(message: string, style: string): [string, string] { 265 | return [`%c[${LOGGING.PREFIX}] ${message}`, style]; 266 | } 267 | 268 | /** 269 | * Process unknown context into a usable format for logging 270 | * @param context - Any context value that might be provided 271 | * @returns A string, object, or undefined that can be safely used in logs 272 | */ 273 | function formatUnknownContext( 274 | context: unknown 275 | ): string | Record | undefined { 276 | if (context === undefined || context === null) { 277 | return undefined; 278 | } 279 | 280 | if (typeof context === "string") { 281 | return context; 282 | } 283 | 284 | if (typeof context === "object") { 285 | try { 286 | // Try to safely convert to Record 287 | return { ...(context as Record) }; 288 | } catch { 289 | // If conversion fails, stringify it 290 | try { 291 | return { value: JSON.stringify(context) }; 292 | } catch { 293 | return { value: String(context) }; 294 | } 295 | } 296 | } 297 | 298 | // For primitive values, just convert to string 299 | return String(context); 300 | } 301 | -------------------------------------------------------------------------------- /src/dependencies/ha/data/lovelace.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Connection, 3 | getCollection, 4 | HassEventBase, 5 | HassServiceTarget, 6 | } from "home-assistant-js-websocket"; 7 | import { HASSDomEvent } from "../common/dom/fire_event"; 8 | import { Lovelace, LovelaceCard } from "../panels/lovelace/types"; 9 | import { HomeAssistant } from "../types"; 10 | 11 | export interface LovelacePanelConfig { 12 | mode: "yaml" | "storage"; 13 | } 14 | 15 | export interface LovelaceConfig { 16 | title?: string; 17 | strategy?: { 18 | type: string; 19 | options?: Record; 20 | }; 21 | views: LovelaceViewConfig[]; 22 | background?: string; 23 | } 24 | 25 | export interface LegacyLovelaceConfig extends LovelaceConfig { 26 | resources?: LovelaceResource[]; 27 | } 28 | 29 | export interface LovelaceResource { 30 | id: string; 31 | type: "css" | "js" | "module" | "html"; 32 | url: string; 33 | } 34 | 35 | export interface LovelaceResourcesMutableParams { 36 | res_type: LovelaceResource["type"]; 37 | url: string; 38 | } 39 | 40 | export type LovelaceDashboard = 41 | | LovelaceYamlDashboard 42 | | LovelaceStorageDashboard; 43 | 44 | interface LovelaceGenericDashboard { 45 | id: string; 46 | url_path: string; 47 | require_admin: boolean; 48 | show_in_sidebar: boolean; 49 | icon?: string; 50 | title: string; 51 | } 52 | 53 | export interface LovelaceYamlDashboard extends LovelaceGenericDashboard { 54 | mode: "yaml"; 55 | filename: string; 56 | } 57 | 58 | export interface LovelaceStorageDashboard extends LovelaceGenericDashboard { 59 | mode: "storage"; 60 | } 61 | 62 | export interface LovelaceDashboardMutableParams { 63 | require_admin: boolean; 64 | show_in_sidebar: boolean; 65 | icon?: string; 66 | title: string; 67 | } 68 | 69 | export interface LovelaceDashboardCreateParams 70 | extends LovelaceDashboardMutableParams { 71 | url_path: string; 72 | mode: "storage"; 73 | } 74 | 75 | export interface LovelaceViewConfig { 76 | index?: number; 77 | title?: string; 78 | type?: string; 79 | strategy?: { 80 | type: string; 81 | options?: Record; 82 | }; 83 | cards?: LovelaceCardConfig[]; 84 | path?: string; 85 | icon?: string; 86 | theme?: string; 87 | panel?: boolean; 88 | background?: string; 89 | visible?: boolean | ShowViewConfig[]; 90 | } 91 | 92 | export interface LovelaceViewElement extends HTMLElement { 93 | hass?: HomeAssistant; 94 | lovelace?: Lovelace; 95 | narrow?: boolean; 96 | index?: number; 97 | cards?: Array; 98 | isStrategy: boolean; 99 | setConfig(config: LovelaceViewConfig): void; 100 | } 101 | 102 | export interface ShowViewConfig { 103 | user?: string; 104 | } 105 | 106 | export interface LovelaceBadgeConfig { 107 | type?: string; 108 | [key: string]: any; 109 | } 110 | 111 | export interface LovelaceCardConfig { 112 | index?: number; 113 | view_index?: number; 114 | view_layout?: any; 115 | type: string; 116 | [key: string]: any; 117 | } 118 | 119 | export interface LovelaceLayoutOptions { 120 | grid_columns?: number; 121 | grid_rows?: number; 122 | } 123 | 124 | export interface LovelaceGridOptions { 125 | columns?: number; 126 | rows?: number; 127 | min_columns?: number; 128 | max_columns?: number; 129 | min_rows?: number; 130 | max_rows?: number; 131 | } 132 | 133 | export interface ToggleActionConfig extends BaseActionConfig { 134 | action: "toggle"; 135 | } 136 | 137 | export interface CallServiceActionConfig extends BaseActionConfig { 138 | action: "call-service" | "perform-action"; 139 | /** @deprecated "service" is kept for backwards compatibility. Replaced by "perform_action". */ 140 | service?: string; 141 | perform_action: string; 142 | target?: HassServiceTarget; 143 | /** @deprecated "service_data" is kept for backwards compatibility. Replaced by "data". */ 144 | service_data?: Record; 145 | data?: Record; 146 | } 147 | 148 | export interface NavigateActionConfig extends BaseActionConfig { 149 | action: "navigate"; 150 | navigation_path: string; 151 | } 152 | 153 | export interface UrlActionConfig extends BaseActionConfig { 154 | action: "url"; 155 | url_path: string; 156 | } 157 | 158 | export interface MoreInfoActionConfig extends BaseActionConfig { 159 | action: "more-info"; 160 | } 161 | 162 | export interface NoActionConfig extends BaseActionConfig { 163 | action: "none"; 164 | } 165 | 166 | export interface CustomActionConfig extends BaseActionConfig { 167 | action: "fire-dom-event"; 168 | } 169 | 170 | export interface AssistActionConfig extends BaseActionConfig { 171 | action: "assist"; 172 | pipeline_id?: string; 173 | start_listening?: boolean; 174 | } 175 | 176 | export interface BaseActionConfig { 177 | action: string; 178 | confirmation?: ConfirmationRestrictionConfig; 179 | } 180 | 181 | export interface ConfirmationRestrictionConfig { 182 | text?: string; 183 | exemptions?: RestrictionConfig[]; 184 | } 185 | 186 | export interface RestrictionConfig { 187 | user: string; 188 | } 189 | 190 | export type ActionConfig = 191 | | ToggleActionConfig 192 | | CallServiceActionConfig 193 | | NavigateActionConfig 194 | | UrlActionConfig 195 | | MoreInfoActionConfig 196 | | AssistActionConfig 197 | | NoActionConfig 198 | | CustomActionConfig; 199 | 200 | type LovelaceUpdatedEvent = HassEventBase & { 201 | event_type: "lovelace_updated"; 202 | data: { 203 | url_path: string | null; 204 | mode: "yaml" | "storage"; 205 | }; 206 | }; 207 | 208 | export const fetchResources = (conn: Connection): Promise => 209 | conn.sendMessagePromise({ 210 | type: "lovelace/resources", 211 | }); 212 | 213 | export const createResource = ( 214 | hass: HomeAssistant, 215 | values: LovelaceResourcesMutableParams 216 | ) => 217 | hass.callWS({ 218 | type: "lovelace/resources/create", 219 | ...values, 220 | }); 221 | 222 | export const updateResource = ( 223 | hass: HomeAssistant, 224 | id: string, 225 | updates: Partial 226 | ) => 227 | hass.callWS({ 228 | type: "lovelace/resources/update", 229 | resource_id: id, 230 | ...updates, 231 | }); 232 | 233 | export const deleteResource = (hass: HomeAssistant, id: string) => 234 | hass.callWS({ 235 | type: "lovelace/resources/delete", 236 | resource_id: id, 237 | }); 238 | 239 | export const fetchDashboards = ( 240 | hass: HomeAssistant 241 | ): Promise => 242 | hass.callWS({ 243 | type: "lovelace/dashboards/list", 244 | }); 245 | 246 | export const createDashboard = ( 247 | hass: HomeAssistant, 248 | values: LovelaceDashboardCreateParams 249 | ) => 250 | hass.callWS({ 251 | type: "lovelace/dashboards/create", 252 | ...values, 253 | }); 254 | 255 | export const updateDashboard = ( 256 | hass: HomeAssistant, 257 | id: string, 258 | updates: Partial 259 | ) => 260 | hass.callWS({ 261 | type: "lovelace/dashboards/update", 262 | dashboard_id: id, 263 | ...updates, 264 | }); 265 | 266 | export const deleteDashboard = (hass: HomeAssistant, id: string) => 267 | hass.callWS({ 268 | type: "lovelace/dashboards/delete", 269 | dashboard_id: id, 270 | }); 271 | 272 | export const fetchConfig = ( 273 | conn: Connection, 274 | urlPath: string | null, 275 | force: boolean 276 | ): Promise => 277 | conn.sendMessagePromise({ 278 | type: "lovelace/config", 279 | url_path: urlPath, 280 | force, 281 | }); 282 | 283 | export const saveConfig = ( 284 | hass: HomeAssistant, 285 | urlPath: string | null, 286 | config: LovelaceConfig 287 | ): Promise => 288 | hass.callWS({ 289 | type: "lovelace/config/save", 290 | url_path: urlPath, 291 | config, 292 | }); 293 | 294 | export const deleteConfig = ( 295 | hass: HomeAssistant, 296 | urlPath: string | null 297 | ): Promise => 298 | hass.callWS({ 299 | type: "lovelace/config/delete", 300 | url_path: urlPath, 301 | }); 302 | 303 | export const subscribeLovelaceUpdates = ( 304 | conn: Connection, 305 | urlPath: string | null, 306 | onChange: () => void 307 | ) => 308 | conn.subscribeEvents((ev) => { 309 | if (ev.data.url_path === urlPath) { 310 | onChange(); 311 | } 312 | }, "lovelace_updated"); 313 | 314 | export const getLovelaceCollection = ( 315 | conn: Connection, 316 | urlPath: string | null = null 317 | ) => 318 | getCollection( 319 | conn, 320 | `_lovelace_${urlPath ?? ""}`, 321 | (conn2) => fetchConfig(conn2, urlPath, false), 322 | (_conn, store) => 323 | subscribeLovelaceUpdates(conn, urlPath, () => 324 | fetchConfig(conn, urlPath, false).then((config) => 325 | store.setState(config, true) 326 | ) 327 | ) 328 | ); 329 | 330 | // Legacy functions to support cast for Home Assistion < 0.107 331 | const fetchLegacyConfig = ( 332 | conn: Connection, 333 | force: boolean 334 | ): Promise => 335 | conn.sendMessagePromise({ 336 | type: "lovelace/config", 337 | force, 338 | }); 339 | 340 | const subscribeLegacyLovelaceUpdates = ( 341 | conn: Connection, 342 | onChange: () => void 343 | ) => conn.subscribeEvents(onChange, "lovelace_updated"); 344 | 345 | export const getLegacyLovelaceCollection = (conn: Connection) => 346 | getCollection( 347 | conn, 348 | "_lovelace", 349 | (conn2) => fetchLegacyConfig(conn2, false), 350 | (_conn, store) => 351 | subscribeLegacyLovelaceUpdates(conn, () => 352 | fetchLegacyConfig(conn, false).then((config) => 353 | store.setState(config, true) 354 | ) 355 | ) 356 | ); 357 | 358 | export interface WindowWithLovelaceProm extends Window { 359 | llConfProm?: Promise; 360 | llResProm?: Promise; 361 | } 362 | 363 | export interface ActionHandlerOptions { 364 | hasHold?: boolean; 365 | hasDoubleClick?: boolean; 366 | disabled?: boolean; 367 | } 368 | 369 | export interface ActionHandlerDetail { 370 | action: "hold" | "tap" | "double_tap"; 371 | } 372 | 373 | export type ActionHandlerEvent = HASSDomEvent; 374 | -------------------------------------------------------------------------------- /src/card/_segments.ts: -------------------------------------------------------------------------------- 1 | // External dependencies 2 | import { z } from "zod"; 3 | 4 | // Internalized external dependencies 5 | import * as Logger from "../dependencies/calendar-card-pro"; 6 | 7 | // Local utilities 8 | import { getComputedColor } from "../utils/color/computed-color"; 9 | import { getInterpolatedColor } from "../utils/color/get-interpolated-color"; 10 | import { 11 | Gauge, 12 | GradientSegment, 13 | GaugeSegment, 14 | GaugeSegmentSchemaFrom, 15 | GaugeSegmentSchemaPos, 16 | } from "./config"; 17 | 18 | // Local constants & types 19 | import { DEFAULT_SEVERITY_COLOR, INFO_COLOR } from "./const"; 20 | import { GaugeCardProCard, TemplateKey } from "./card"; 21 | 22 | /** 23 | * Get the configured segments array (pos & color). 24 | * Adds an extra first segment in case the first 'pos' is larger than the 'min' of the gauge. 25 | * Each segment is validated. On error returns full red. 26 | */ 27 | export function getSegments( 28 | card: GaugeCardProCard, 29 | gauge: Gauge, 30 | min: number, 31 | max: number, 32 | from_midpoints = false 33 | ): GaugeSegment[] { 34 | const _gauge = gauge === "main" ? "" : "inner."; 35 | let from_segments = false; 36 | 37 | const config_segments = card.getValue(`${_gauge}segments`); 38 | if (!config_segments) { 39 | return [{ pos: min, color: DEFAULT_SEVERITY_COLOR }]; 40 | } 41 | 42 | const validateSegments = (): 43 | | { pos: string | number; color: string }[] 44 | | undefined => { 45 | const resultFrom = z 46 | .array(GaugeSegmentSchemaFrom) 47 | .safeParse(config_segments); 48 | if (resultFrom.success) { 49 | from_segments = true; 50 | return resultFrom.data.map(({ from, color }) => ({ 51 | pos: from, 52 | color, 53 | })); 54 | } 55 | 56 | const resultPos = z.array(GaugeSegmentSchemaPos).safeParse(config_segments); 57 | if (resultPos.success) { 58 | return resultPos.data; 59 | } 60 | 61 | return undefined; 62 | }; 63 | 64 | const validatedSegments = validateSegments(); 65 | if (!validatedSegments) { 66 | Logger.error("Invalid segments definition:", config_segments); 67 | return [{ pos: min, color: "#ff0000" }]; 68 | } 69 | 70 | let validatedNumericSegments: GaugeSegment[] = []; 71 | validatedSegments.forEach((segment) => { 72 | if (String(segment.pos).slice(-1) === "%") { 73 | const pos = 74 | (Number(String(segment.pos).slice(0, -1)) / 100) * (max - min) + min; 75 | validatedNumericSegments.push({ 76 | pos: pos, 77 | color: segment.color, 78 | }); 79 | } else { 80 | validatedNumericSegments.push({ 81 | pos: Number(segment.pos), 82 | color: segment.color, 83 | }); 84 | } 85 | }); 86 | 87 | validatedNumericSegments.sort( 88 | (a: GaugeSegment, b: GaugeSegment) => a.pos - b.pos 89 | ); 90 | 91 | let segments: GaugeSegment[] = []; 92 | const firstSegment = validatedNumericSegments[0]; 93 | 94 | // In case the first 'pos' is larger than the 'min' of the gauge, add INFO_COLOR from min 95 | if (min < firstSegment.pos) { 96 | segments.push({ 97 | pos: min, 98 | color: INFO_COLOR, 99 | }); 100 | } 101 | 102 | if (max <= firstSegment.pos) { 103 | segments.push({ 104 | pos: max, 105 | color: INFO_COLOR, 106 | }); 107 | return segments; 108 | } 109 | 110 | // Convert from_segments to midpoints 111 | const use_new_from_segments_style = card._config?.use_new_from_segments_style; 112 | const numSegments = validatedNumericSegments.length; 113 | if ( 114 | from_segments && 115 | use_new_from_segments_style && 116 | from_midpoints && 117 | numSegments > 1 118 | ) { 119 | if (min < firstSegment.pos) { 120 | segments.push({ 121 | pos: (min + firstSegment.pos) / 2, 122 | color: INFO_COLOR, 123 | }); 124 | } 125 | 126 | segments.push({ 127 | pos: firstSegment.pos, 128 | color: firstSegment.color, 129 | }); 130 | 131 | for (let i = 0; i < numSegments - 1; i++) { 132 | const currentSegment = validatedNumericSegments[i]; 133 | const nextSegment = validatedNumericSegments[i + 1]; 134 | const midpointPos = (currentSegment.pos + nextSegment.pos) / 2; 135 | segments.push({ 136 | pos: midpointPos, 137 | color: currentSegment.color, 138 | }); 139 | } 140 | 141 | const lastSegment = validatedNumericSegments[numSegments - 1]; 142 | if (max > lastSegment.pos) { 143 | const midpointPos = (lastSegment.pos + max) / 2; 144 | segments.push({ 145 | pos: midpointPos, 146 | color: lastSegment.color, 147 | }); 148 | } else { 149 | segments.push({ 150 | pos: validatedNumericSegments[numSegments - 1].pos, 151 | color: validatedNumericSegments[numSegments - 1].color, 152 | }); 153 | } 154 | } else { 155 | segments = [...segments, ...validatedNumericSegments]; 156 | } 157 | 158 | return segments; 159 | } 160 | 161 | /** 162 | * Get the configured segments array formatted as a tinygradient array (pos & color; from 0 to 1). 163 | * Adds an extra first solid segment in case the first 'pos' is larger than the 'min' of the gauge. 164 | * Interpolates in case the first and/or last segment are beyond min/max. 165 | * Each segment is validated. On error returns full red. 166 | */ 167 | export function getGradientSegments( 168 | card: GaugeCardProCard, 169 | gauge: Gauge, 170 | min: number, 171 | max: number, 172 | from_midpoints = false 173 | ): GradientSegment[] { 174 | const segments = getSegments(card, gauge, min, max, from_midpoints); 175 | const numSegments = segments.length; 176 | 177 | // gradient-path expects at least 2 segments 178 | if (numSegments < 2) { 179 | return [ 180 | { pos: 0, color: getComputedColor(segments[0].color) }, 181 | { pos: 1, color: getComputedColor(segments[0].color) }, 182 | ]; 183 | } 184 | 185 | let gradientSegments: GradientSegment[] = []; 186 | const diff = max - min; 187 | 188 | for (let i = 0; i < numSegments; i++) { 189 | const level = segments[i].pos; 190 | let color = getComputedColor(segments[i].color); 191 | let pos: number; 192 | 193 | if (level < min) { 194 | let nextLevel: number; 195 | let nextColor: string; 196 | if (i + 1 < numSegments) { 197 | nextLevel = segments[i + 1].pos; 198 | nextColor = getComputedColor(segments[i + 1].color); 199 | if (nextLevel <= min) { 200 | // both current level and next level are invisible -> skip 201 | continue; 202 | } 203 | } else { 204 | // only current level is below minimum. The next iteration will determine what to do with this segment 205 | continue; 206 | } 207 | // segment is partly invisible, so we interpolate the minimum color to pos 0 208 | color = getInterpolatedColor({ 209 | min: level, 210 | colorMin: color, 211 | max: nextLevel, 212 | colorMax: nextColor, 213 | value: min, 214 | })!; 215 | pos = 0; 216 | } else if (level > max) { 217 | let prevLevel: number; 218 | let prevColor: string; 219 | if (i > 0) { 220 | prevLevel = segments[i - 1].pos; 221 | prevColor = getComputedColor(segments[i - 1].color); 222 | if (prevLevel >= max) { 223 | // both current level and previous level are invisible -> skip 224 | continue; 225 | } 226 | } else { 227 | // only current level is above maximum. The next iteration will determine what to do with this segment 228 | continue; 229 | } 230 | // segment is partly invisible, so we interpolate the maximum color to pos 1 231 | color = getInterpolatedColor({ 232 | min: prevLevel, 233 | colorMin: prevColor, 234 | max: level, 235 | colorMax: color, 236 | value: max, 237 | })!; 238 | pos = 1; 239 | } else { 240 | pos = (level - min) / diff; 241 | } 242 | 243 | gradientSegments.push({ pos: pos, color: color }); 244 | } 245 | 246 | if (gradientSegments.length < 2) { 247 | if (max <= segments[0].pos) { 248 | // current range below lowest segment 249 | let color = getComputedColor(segments[0].color); 250 | return [ 251 | { pos: 0, color: color }, 252 | { pos: 1, color: color }, 253 | ]; 254 | } else { 255 | // current range above highest segment 256 | let color = getComputedColor(segments[numSegments - 1].color); 257 | return [ 258 | { pos: 0, color: color }, 259 | { pos: 1, color: color }, 260 | ]; 261 | } 262 | } 263 | 264 | return gradientSegments; 265 | } 266 | 267 | /** 268 | * Compute the segment color at a specific value 269 | */ 270 | export function computeSeverity( 271 | card: GaugeCardProCard, 272 | gauge: Gauge, 273 | min: number, 274 | max: number, 275 | value: number 276 | ): string | undefined { 277 | if (gauge === "main" && card._config!.needle) return undefined; 278 | if ( 279 | gauge === "inner" && 280 | ["static", "needle"].includes(card._config!.inner!.mode!) 281 | ) 282 | return undefined; 283 | 284 | const interpolation = 285 | gauge === "main" ? card._config!.gradient : card._config!.inner!.gradient; // here we're sure to have an inner 286 | if (interpolation) { 287 | const gradienSegments = getGradientSegments(card, gauge, min, max, true); 288 | return getInterpolatedColor({ 289 | gradientSegments: gradienSegments, 290 | min: min, 291 | max: max, 292 | value: Math.min(value, max), // beyond max, the gauge shows max. Also needed for getInterpolatedColor 293 | })!; 294 | } else { 295 | return getSegmentColor(card, gauge, min, max, value)!; 296 | } 297 | } 298 | 299 | /** 300 | * Get the configured segment color at a specific value 301 | */ 302 | function getSegmentColor( 303 | card: GaugeCardProCard, 304 | gauge: Gauge, 305 | min: number, 306 | max: number, 307 | value: number 308 | ): string { 309 | const segments = getSegments(card, gauge, min, max); 310 | for (let i = 0; i < segments.length; i++) { 311 | const segment = segments[i]; 312 | if ( 313 | segment && 314 | value >= segment.pos && 315 | (i + 1 === segments.length || value < segments[i + 1]?.pos) 316 | ) { 317 | return segment.color; 318 | } 319 | } 320 | return INFO_COLOR; // should never happen, but just in case 321 | } 322 | -------------------------------------------------------------------------------- /src/card/config.ts: -------------------------------------------------------------------------------- 1 | // External dependencies 2 | import { 3 | any, 4 | array, 5 | assign, 6 | boolean, 7 | enums, 8 | number, 9 | object, 10 | optional, 11 | string, 12 | union, 13 | } from "superstruct"; 14 | import { z } from "zod"; 15 | 16 | // Core HA helpers 17 | import { 18 | ActionConfig, 19 | actionConfigStruct, 20 | baseLovelaceCardConfig, 21 | LovelaceCardConfig, 22 | } from "../dependencies/ha"; 23 | import { mdiOpacity } from "@mdi/js"; 24 | 25 | const gradientResolutionStruct = enums(["very_low", "low", "medium", "high"]); 26 | const innerGaugeModes = enums(["severity", "static", "needle", "on_main"]); 27 | const iconTypes = enums(["battery", "template"]); 28 | const setpointTypes = enums(["entity", "number", "template"]); 29 | 30 | export type Gauge = "main" | "inner"; 31 | 32 | export type GradientSegment = { 33 | pos: number; 34 | color?: string; 35 | }; 36 | 37 | // Pos is considered the standard in the code. From is only used to transform to pos 38 | export type GaugeSegment = { 39 | pos: number; 40 | color: string; 41 | }; 42 | export type GaugeSegmentFrom = { 43 | from: number; 44 | color: string; 45 | }; 46 | 47 | // Used to validate config `segments` 48 | const percentage_regex = new RegExp(String.raw`^-?\d+(?:\.\d+)?%$`, "g"); 49 | export const GaugeSegmentSchemaFrom = z.object({ 50 | from: z.union([z.coerce.number(), z.string().regex(percentage_regex)]), 51 | color: z.coerce.string(), 52 | }); 53 | export const GaugeSegmentSchemaPos = z.object({ 54 | pos: z.union([z.coerce.number(), z.string().regex(percentage_regex)]), 55 | color: z.coerce.string(), 56 | }); 57 | 58 | //----------------------------------------------------------------------------- 59 | // CONFIGS 60 | //----------------------------------------------------------------------------- 61 | 62 | type LightDarkModeColor = { 63 | light_mode: string; 64 | dark_mode: string; 65 | }; 66 | 67 | type MinMaxIndicatorConfig = { 68 | type: string; 69 | color?: string | LightDarkModeColor; 70 | value: number | string; 71 | opacity?: number; 72 | }; 73 | 74 | type IconConfig = { 75 | type: string; 76 | value: string; 77 | state?: string; 78 | threshold?: string; 79 | left?: boolean; 80 | hide_label?: boolean; 81 | }; 82 | 83 | type SetpointConfig = { 84 | type: string; 85 | color?: string | LightDarkModeColor; 86 | value: number | string; 87 | }; 88 | 89 | type TitlesConfig = { 90 | primary?: string; 91 | primary_color?: string; 92 | primary_font_size?: string; 93 | secondary?: string; 94 | secondary_color?: string; 95 | secondary_font_size?: string; 96 | }; 97 | 98 | type ValueTextsConfig = { 99 | primary?: string; 100 | primary_color?: string; 101 | primary_unit?: string; 102 | primary_unit_before_value?: boolean; 103 | primary_font_size_reduction?: number | string; 104 | secondary?: string; 105 | secondary_color?: string; 106 | secondary_unit?: string; 107 | secondary_unit_before_value?: boolean; 108 | }; 109 | 110 | type ShapesConfig = { 111 | main_needle?: string; 112 | main_min_indicator?: string; 113 | main_max_indicator?: string; 114 | main_setpoint_needle?: string; 115 | inner_needle?: string; 116 | inner_min_indicator?: string; 117 | inner_max_indicator?: string; 118 | inner_setpoint_needle?: string; 119 | }; 120 | 121 | type InnerGaugeConfig = { 122 | gradient?: boolean; 123 | gradient_background?: boolean; 124 | gradient_resolution?: string | number; 125 | min?: number | string; 126 | max?: number | string; 127 | min_indicator?: MinMaxIndicatorConfig; 128 | max_indicator?: MinMaxIndicatorConfig; 129 | mode?: string; 130 | needle_color?: string | LightDarkModeColor; 131 | segments?: string | GaugeSegmentFrom[] | GaugeSegment[]; 132 | setpoint?: SetpointConfig; 133 | value?: string; 134 | }; 135 | 136 | export type GaugeCardProCardConfig = LovelaceCardConfig & { 137 | header?: string; 138 | entity?: string; 139 | entity2?: string; 140 | use_new_from_segments_style?: boolean; 141 | gradient?: boolean; 142 | gradient_background?: boolean; 143 | gradient_resolution?: string | number; 144 | hide_background?: boolean; 145 | inner?: InnerGaugeConfig; 146 | min?: number | string; 147 | max?: number | string; 148 | min_indicator?: MinMaxIndicatorConfig; 149 | max_indicator?: MinMaxIndicatorConfig; 150 | needle?: boolean; 151 | needle_color?: string | LightDarkModeColor; 152 | segments?: string | GaugeSegmentFrom[] | GaugeSegment[]; 153 | setpoint?: SetpointConfig; 154 | titles?: TitlesConfig; 155 | icon?: IconConfig; 156 | value?: string; 157 | value_texts?: ValueTextsConfig; 158 | shapes?: ShapesConfig; 159 | 160 | entity_id?: string | string[]; 161 | 162 | // actions 163 | tap_action?: ActionConfig; 164 | hold_action?: ActionConfig; 165 | double_tap_action?: ActionConfig; 166 | 167 | primary_value_text_tap_action?: ActionConfig; 168 | primary_value_text_hold_action?: ActionConfig; 169 | primary_value_text_double_tap_action?: ActionConfig; 170 | 171 | secondary_value_text_tap_action?: ActionConfig; 172 | secondary_value_text_hold_action?: ActionConfig; 173 | secondary_value_text_double_tap_action?: ActionConfig; 174 | 175 | icon_tap_action?: ActionConfig; 176 | icon_hold_action?: ActionConfig; 177 | icon_double_tap_action?: ActionConfig; 178 | }; 179 | 180 | //----------------------------------------------------------------------------- 181 | // STRUCTS 182 | //----------------------------------------------------------------------------- 183 | 184 | const lightDarkModeColorStruct = object({ 185 | light_mode: string(), 186 | dark_mode: string(), 187 | }); 188 | 189 | const gaugeSegmentFromStruct = object({ 190 | from: union([number(), string()]), 191 | color: string(), 192 | }); 193 | 194 | const gaugeSegmentPosStruct = object({ 195 | pos: union([number(), string()]), 196 | color: string(), 197 | }); 198 | 199 | const minMaxIndicatorStruct = object({ 200 | color: optional(union([string(), lightDarkModeColorStruct])), 201 | type: setpointTypes, 202 | value: optional(union([number(), string()])), 203 | opacity: optional(number()), 204 | }); 205 | 206 | const iconStruct = object({ 207 | type: iconTypes, 208 | value: optional(string()), 209 | state: optional(string()), 210 | threshold: optional(number()), 211 | left: optional(boolean()), 212 | hide_label: optional(boolean()), 213 | }); 214 | 215 | const setpointStruct = object({ 216 | color: optional(union([string(), lightDarkModeColorStruct])), 217 | type: setpointTypes, 218 | value: optional(union([number(), string()])), 219 | }); 220 | 221 | const titlesStruct = object({ 222 | primary: optional(string()), 223 | primary_color: optional(string()), 224 | primary_font_size: optional(string()), 225 | secondary: optional(string()), 226 | secondary_color: optional(string()), 227 | secondary_font_size: optional(string()), 228 | }); 229 | 230 | const valueTextsStruct = object({ 231 | primary: optional(string()), 232 | primary_color: optional(string()), 233 | primary_unit: optional(string()), 234 | primary_unit_before_value: optional(boolean()), 235 | primary_font_size_reduction: optional(union([number(), string()])), 236 | secondary: optional(string()), 237 | secondary_color: optional(string()), 238 | secondary_unit: optional(string()), 239 | secondary_unit_before_value: optional(boolean()), 240 | }); 241 | 242 | const shapesStruct = object({ 243 | main_needle: optional(string()), 244 | main_min_indicator: optional(string()), 245 | main_max_indicator: optional(string()), 246 | main_setpoint_needle: optional(string()), 247 | inner_needle: optional(string()), 248 | inner_min_indicator: optional(string()), 249 | inner_max_indicator: optional(string()), 250 | inner_setpoint_needle: optional(string()), 251 | }); 252 | 253 | const innerGaugeStruct = object({ 254 | gradient: optional(boolean()), 255 | gradient_background: optional(boolean()), 256 | gradient_resolution: optional(union([gradientResolutionStruct, number()])), 257 | min: optional(union([number(), string()])), 258 | max: optional(union([number(), string()])), 259 | min_indicator: optional(minMaxIndicatorStruct), 260 | max_indicator: optional(minMaxIndicatorStruct), 261 | mode: optional(innerGaugeModes), 262 | needle_color: optional(union([string(), lightDarkModeColorStruct])), 263 | segments: optional( 264 | union([ 265 | string(), 266 | array(gaugeSegmentFromStruct), 267 | array(gaugeSegmentPosStruct), 268 | ]) 269 | ), 270 | setpoint: optional(setpointStruct), 271 | value: optional(string()), 272 | }); 273 | 274 | export const gaugeCardProConfigStruct = assign( 275 | baseLovelaceCardConfig, 276 | object({ 277 | header: optional(string()), 278 | entity: optional(string()), 279 | entity2: optional(string()), 280 | use_new_from_segments_style: optional(boolean()), 281 | gradient: optional(boolean()), 282 | gradient_background: optional(boolean()), 283 | gradient_resolution: optional(union([gradientResolutionStruct, number()])), 284 | hide_background: optional(boolean()), 285 | inner: optional(innerGaugeStruct), 286 | min: optional(union([number(), string()])), 287 | max: optional(union([number(), string()])), 288 | min_indicator: optional(minMaxIndicatorStruct), 289 | max_indicator: optional(minMaxIndicatorStruct), 290 | needle: optional(boolean()), 291 | needle_color: optional(union([string(), lightDarkModeColorStruct])), 292 | segments: optional( 293 | union([ 294 | string(), 295 | array(gaugeSegmentFromStruct), 296 | array(gaugeSegmentPosStruct), 297 | ]) 298 | ), 299 | setpoint: optional(setpointStruct), 300 | titles: optional(titlesStruct), 301 | icon: optional(iconStruct), 302 | value: optional(string()), 303 | value_texts: optional(valueTextsStruct), 304 | shapes: optional(shapesStruct), 305 | 306 | entity_id: optional(union([string(), array(string())])), 307 | 308 | // actions 309 | tap_action: optional(actionConfigStruct), 310 | hold_action: optional(actionConfigStruct), 311 | double_tap_action: optional(actionConfigStruct), 312 | 313 | primary_value_text_tap_action: optional(actionConfigStruct), 314 | primary_value_text_hold_action: optional(actionConfigStruct), 315 | primary_value_text_double_tap_action: optional(actionConfigStruct), 316 | 317 | secondary_value_text_tap_action: optional(actionConfigStruct), 318 | secondary_value_text_hold_action: optional(actionConfigStruct), 319 | secondary_value_text_double_tap_action: optional(actionConfigStruct), 320 | 321 | icon_tap_action: optional(actionConfigStruct), 322 | icon_hold_action: optional(actionConfigStruct), 323 | icon_double_tap_action: optional(actionConfigStruct), 324 | 325 | card_mod: optional(any()), 326 | }) 327 | ); 328 | --------------------------------------------------------------------------------