├── .gitignore ├── .github ├── img │ ├── cover.png │ ├── domain.png │ ├── image.png │ ├── mirror.png │ ├── popup.png │ ├── sensor.png │ ├── popup-view.png │ ├── alert-classes.png │ ├── appearance_area.png │ ├── area-card-header.png │ ├── area-multi-dark.png │ ├── area-multi-light.png │ ├── area-single-dark.png │ ├── custom-buttons.png │ ├── area-multi-dark-v2.png │ ├── area-single-light.png │ ├── area-vertical-dark.png │ ├── customization-area.png │ ├── area-multi-light-v2.png │ └── area-vertical-light.png ├── FUNDING.YML ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.yml │ └── bug_report.yml └── workflows │ ├── action.yml │ ├── stale.yml │ └── update_issue_template.yml ├── src ├── ha │ ├── common │ │ ├── translations │ │ │ ├── localize.ts │ │ │ ├── blank_before_unit.ts │ │ │ └── blank_before_percent.ts │ │ ├── entity │ │ │ └── compute_domain.ts │ │ ├── number │ │ │ ├── round.ts │ │ │ └── format_number.ts │ │ ├── const.ts │ │ ├── util │ │ │ ├── debounce.ts │ │ │ └── deep-equal.ts │ │ ├── string │ │ │ └── compare.ts │ │ └── dom │ │ │ ├── fire_event.ts │ │ │ └── apply_themes_on_element.ts │ ├── panels │ │ └── lovelace │ │ │ ├── common │ │ │ ├── has-action.ts │ │ │ ├── handle-actions.ts │ │ │ └── directives │ │ │ │ └── action-handler-directive.ts │ │ │ ├── editor │ │ │ └── types.ts │ │ │ └── types.ts │ ├── data │ │ ├── selector.ts │ │ ├── ws-themes.ts │ │ ├── sensor.ts │ │ ├── translation.ts │ │ ├── entity_registry.ts │ │ └── lovelace.ts │ ├── index.ts │ └── types.ts ├── index.ts ├── card-items.ts ├── translations.ts ├── card-actions.ts ├── editor-schema.ts ├── helpers.ts ├── const.ts ├── card-styles.ts ├── items-editor.ts ├── item-editor.ts └── popup-dialog.ts ├── hacs.json ├── package.json ├── tsconfig.json ├── vite.config.js └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.github/img/cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xBourner/area-card-plus/HEAD/.github/img/cover.png -------------------------------------------------------------------------------- /.github/img/domain.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xBourner/area-card-plus/HEAD/.github/img/domain.png -------------------------------------------------------------------------------- /.github/img/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xBourner/area-card-plus/HEAD/.github/img/image.png -------------------------------------------------------------------------------- /.github/img/mirror.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xBourner/area-card-plus/HEAD/.github/img/mirror.png -------------------------------------------------------------------------------- /.github/img/popup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xBourner/area-card-plus/HEAD/.github/img/popup.png -------------------------------------------------------------------------------- /.github/img/sensor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xBourner/area-card-plus/HEAD/.github/img/sensor.png -------------------------------------------------------------------------------- /.github/img/popup-view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xBourner/area-card-plus/HEAD/.github/img/popup-view.png -------------------------------------------------------------------------------- /src/ha/common/translations/localize.ts: -------------------------------------------------------------------------------- 1 | export type LocalizeFunc = (key: string, ...args: any[]) => string; 2 | -------------------------------------------------------------------------------- /.github/img/alert-classes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xBourner/area-card-plus/HEAD/.github/img/alert-classes.png -------------------------------------------------------------------------------- /.github/img/appearance_area.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xBourner/area-card-plus/HEAD/.github/img/appearance_area.png -------------------------------------------------------------------------------- /.github/img/area-card-header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xBourner/area-card-plus/HEAD/.github/img/area-card-header.png -------------------------------------------------------------------------------- /.github/img/area-multi-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xBourner/area-card-plus/HEAD/.github/img/area-multi-dark.png -------------------------------------------------------------------------------- /.github/img/area-multi-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xBourner/area-card-plus/HEAD/.github/img/area-multi-light.png -------------------------------------------------------------------------------- /.github/img/area-single-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xBourner/area-card-plus/HEAD/.github/img/area-single-dark.png -------------------------------------------------------------------------------- /.github/img/custom-buttons.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xBourner/area-card-plus/HEAD/.github/img/custom-buttons.png -------------------------------------------------------------------------------- /.github/img/area-multi-dark-v2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xBourner/area-card-plus/HEAD/.github/img/area-multi-dark-v2.png -------------------------------------------------------------------------------- /.github/img/area-single-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xBourner/area-card-plus/HEAD/.github/img/area-single-light.png -------------------------------------------------------------------------------- /.github/img/area-vertical-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xBourner/area-card-plus/HEAD/.github/img/area-vertical-dark.png -------------------------------------------------------------------------------- /.github/img/customization-area.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xBourner/area-card-plus/HEAD/.github/img/customization-area.png -------------------------------------------------------------------------------- /.github/img/area-multi-light-v2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xBourner/area-card-plus/HEAD/.github/img/area-multi-light-v2.png -------------------------------------------------------------------------------- /.github/img/area-vertical-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xBourner/area-card-plus/HEAD/.github/img/area-vertical-light.png -------------------------------------------------------------------------------- /src/ha/common/entity/compute_domain.ts: -------------------------------------------------------------------------------- 1 | export const computeDomain = (entityId: string): string => 2 | entityId.substr(0, entityId.indexOf(".")); 3 | -------------------------------------------------------------------------------- /src/ha/common/number/round.ts: -------------------------------------------------------------------------------- 1 | export const round = (value: number, precision = 2): number => 2 | Math.round(value * 10 ** precision) / 10 ** precision; 3 | -------------------------------------------------------------------------------- /.github/FUNDING.YML: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | 4 | github: 5 | - xBourner 6 | buy_me_a_coffee: bourner 7 | custom: 8 | - https://paypal.me/gibgas123 9 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "area-card-plus", 3 | "content_in_root": false, 4 | "render_readme": true, 5 | "filename": "area-card-plus.js", 6 | "homeassistant": "2024.12.0" 7 | } 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Discord 4 | url: https://discord.gg/RfVx7hmZD3 5 | about: Join my Discord for better communication. 6 | -------------------------------------------------------------------------------- /src/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 | 7 | export type UiAction = Exclude; 8 | -------------------------------------------------------------------------------- /src/ha/data/selector.ts: -------------------------------------------------------------------------------- 1 | export interface SelectOption { 2 | value: any; 3 | label: string; 4 | description?: string; 5 | image?: string | SelectBoxOptionImage; 6 | disabled?: boolean; 7 | } 8 | 9 | interface SelectBoxOptionImage { 10 | src: string; 11 | src_dark?: string; 12 | flip_rtl?: boolean; 13 | } 14 | -------------------------------------------------------------------------------- /.github/workflows/action.yml: -------------------------------------------------------------------------------- 1 | name: HACS Action 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | 9 | jobs: 10 | hacs: 11 | name: HACS Action 12 | runs-on: "ubuntu-latest" 13 | steps: 14 | - name: HACS Action 15 | uses: "hacs/action@main" 16 | with: 17 | category: "plugin" 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "area-card-plus", 3 | "version": "v1.2", 4 | "type": "module", 5 | "devDependencies": { 6 | "typescript": "^4.0.0", 7 | "vite": "^6.3.5" 8 | }, 9 | "dependencies": { 10 | "@mdi/js": "^7.4.47", 11 | "home-assistant-js-websocket": "^9.4.0", 12 | "lit": "^3.2.1", 13 | "memoize-one": "^6.0.0" 14 | }, 15 | "scripts": { 16 | "dev": "vite", 17 | "build": "vite build" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/ha/common/translations/blank_before_unit.ts: -------------------------------------------------------------------------------- 1 | import type { FrontendLocaleData } from "../../data/translation"; 2 | import { blankBeforePercent } from "./blank_before_percent"; 3 | 4 | export const blankBeforeUnit = ( 5 | unit: string, 6 | localeOptions: FrontendLocaleData | undefined 7 | ): string => { 8 | if (unit === "°") { 9 | return ""; 10 | } 11 | if (localeOptions && unit === "%") { 12 | return blankBeforePercent(localeOptions); 13 | } 14 | return " "; 15 | }; 16 | -------------------------------------------------------------------------------- /src/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 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2020", 4 | "module": "ESNext", 5 | "moduleResolution": "node", 6 | "resolveJsonModule": true, 7 | "strict": true, 8 | "esModuleInterop": true, 9 | "skipLibCheck": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "experimentalDecorators": true, 12 | "outDir": "./dist", 13 | "rootDir": ".", 14 | "noImplicitAny": false 15 | }, 16 | "include": ["src/**/*.ts", "src/ha/", "package.json"], 17 | "exclude": ["node_modules"] 18 | } 19 | -------------------------------------------------------------------------------- /src/ha/panels/lovelace/editor/types.ts: -------------------------------------------------------------------------------- 1 | import type { ActionConfig } from "../../../data/lovelace"; 2 | 3 | export interface EditorTarget extends EventTarget { 4 | value?: string; 5 | index?: number; 6 | checked?: boolean; 7 | configValue?: string; 8 | type?: HTMLInputElement["type"]; 9 | config: ActionConfig; 10 | } 11 | 12 | export interface SubElementEditorConfig { 13 | index?: number; 14 | saveElementConfig?: (elementConfig: any) => void; 15 | context?: any; 16 | type: "header" | "footer" | "row" | "feature" | "element" | "heading-badge"; 17 | } 18 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import packageJson from "../package.json"; 2 | import "./card"; 3 | import "./editor"; 4 | 5 | console.info( 6 | `%c AREA-CARD %c ${packageJson.version} `, 7 | "color: steelblue; background: black; font-weight: bold;", 8 | "color: white ; background: dimgray; font-weight: bold;" 9 | ); 10 | 11 | (window as any).customCards = (window as any).customCards || []; 12 | (window as any).customCards.push({ 13 | type: "area-card-plus", 14 | name: "Area Card Plus", 15 | preview: true, 16 | description: "A custom card to display area information.", 17 | }); 18 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import path from "path"; 3 | 4 | export default defineConfig({ 5 | build: { 6 | lib: { 7 | // Entspricht deinem Webpack-Entry 8 | entry: path.resolve(__dirname, "src/index.ts"), 9 | fileName: () => "area-card-plus.js", // Output-Dateiname 10 | formats: ["es"], // Home Assistant braucht ES-Module 11 | }, 12 | outDir: "dist", // Entspricht Webpack output.path 13 | emptyOutDir: true, 14 | rollupOptions: { 15 | external: [], 16 | }, 17 | }, 18 | resolve: { 19 | extensions: [".ts", ".js"], // Wie in deiner Webpack config 20 | }, 21 | }); 22 | -------------------------------------------------------------------------------- /src/ha/panels/lovelace/types.ts: -------------------------------------------------------------------------------- 1 | import { LovelaceCardConfig, LovelaceConfig } from "../../data/lovelace"; 2 | import { HomeAssistant } from "../../types"; 3 | 4 | export interface LovelaceCardEditor extends LovelaceGenericElementEditor { 5 | setConfig(config: LovelaceCardConfig): void; 6 | } 7 | 8 | export interface LovelaceGenericElementEditor extends HTMLElement { 9 | hass?: HomeAssistant; 10 | lovelace?: LovelaceConfig; 11 | setConfig(config: any): void; 12 | focusYamlEditor?: () => void; 13 | } 14 | 15 | export interface LovelaceCard extends HTMLElement { 16 | hass?: HomeAssistant; 17 | isPanel?: boolean; 18 | editMode?: boolean; 19 | getCardSize(): number | Promise; 20 | setConfig(config: LovelaceCardConfig): void; 21 | } 22 | -------------------------------------------------------------------------------- /src/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/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/ha/common/const.ts: -------------------------------------------------------------------------------- 1 | /** States that we consider "off". */ 2 | export const STATES_OFF = [ 3 | "closed", 4 | "locked", 5 | "off", 6 | "docked", 7 | "idle", 8 | "standby", 9 | "paused", 10 | "auto", 11 | "not_home", 12 | "disarmed", 13 | ]; 14 | 15 | /** Binary States */ 16 | export const BINARY_STATE_ON = "on"; 17 | export const BINARY_STATE_OFF = "off"; 18 | 19 | /** Temperature units. */ 20 | export const UNIT_C = "°C"; 21 | export const UNIT_F = "°F"; 22 | 23 | /** Domains where we allow toggle in Lovelace. */ 24 | export const DOMAINS_TOGGLE = new Set([ 25 | "fan", 26 | "input_boolean", 27 | "light", 28 | "switch", 29 | "group", 30 | "automation", 31 | "humidifier", 32 | "valve", 33 | ]); 34 | 35 | const UNAVAILABLE = "unavailable"; 36 | const UNKNOWN = "unknown"; 37 | 38 | export const UNAVAILABLE_STATES = [UNAVAILABLE, UNKNOWN]; 39 | -------------------------------------------------------------------------------- /src/ha/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./common/const"; 2 | export * from "./common/dom/apply_themes_on_element"; 3 | export * from "./common/dom/fire_event"; 4 | export * from "./common/entity/compute_domain"; 5 | export * from "./common/number/format_number"; 6 | export * from "./common/string/compare"; 7 | export * from "./common/translations/blank_before_unit"; 8 | export * from "./common/translations/localize"; 9 | export * from "./data/lovelace"; 10 | export * from "./data/selector"; 11 | export * from "./data/sensor"; 12 | export * from "./data/translation"; 13 | export * from "./data/ws-themes"; 14 | export * from "./panels/lovelace/common/directives/action-handler-directive"; 15 | export * from "./panels/lovelace/common/handle-actions"; 16 | export * from "./panels/lovelace/common/has-action"; 17 | export * from "./panels/lovelace/editor/types"; 18 | export * from "./panels/lovelace/types"; 19 | export * from "./types"; 20 | -------------------------------------------------------------------------------- /src/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/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/ha/data/sensor.ts: -------------------------------------------------------------------------------- 1 | import type { HomeAssistant } from "../types"; 2 | 3 | export const SENSOR_DEVICE_CLASS_BATTERY = "battery"; 4 | export const SENSOR_DEVICE_CLASS_TIMESTAMP = "timestamp"; 5 | export const SENSOR_DEVICE_CLASS_TEMPERATURE = "temperature"; 6 | export const SENSOR_DEVICE_CLASS_HUMIDITY = "humidity"; 7 | 8 | export interface SensorDeviceClassUnits { 9 | units: string[]; 10 | } 11 | 12 | export const getSensorDeviceClassConvertibleUnits = ( 13 | hass: HomeAssistant, 14 | deviceClass: string 15 | ): Promise => 16 | hass.callWS({ 17 | type: "sensor/device_class_convertible_units", 18 | device_class: deviceClass, 19 | }); 20 | 21 | export interface SensorNumericDeviceClasses { 22 | numeric_device_classes: string[]; 23 | } 24 | 25 | let sensorNumericDeviceClassesCache: 26 | | Promise 27 | | undefined; 28 | 29 | export const getSensorNumericDeviceClasses = async ( 30 | hass: HomeAssistant 31 | ): Promise => { 32 | if (sensorNumericDeviceClassesCache) { 33 | return sensorNumericDeviceClassesCache; 34 | } 35 | sensorNumericDeviceClassesCache = hass.callWS({ 36 | type: "sensor/numeric_device_classes", 37 | }); 38 | return sensorNumericDeviceClassesCache!; 39 | }; 40 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Suggest an idea for this project. 3 | title: '[FR]: ' 4 | labels: 5 | - FR 6 | assignees: 7 | - xBourner 8 | body: 9 | - type: markdown 10 | attributes: 11 | value: 'Thanks for taking the time to fill out this feature request!' 12 | - type: textarea 13 | id: problem 14 | attributes: 15 | label: Is the FR related to a problem? 16 | description: Please tell me exactly what happened while using the card. The more detailed, the better. 17 | placeholder: Tell us what you see! 18 | - type: textarea 19 | id: solution 20 | attributes: 21 | label: Describe the solution you'd like 22 | description: A clear and concise description of what you want to happen. 23 | placeholder: Your solution 24 | validations: 25 | required: true 26 | - type: textarea 27 | id: screenshots 28 | attributes: 29 | label: Screenshots 30 | placeholder: If applicable, add screenshots to help explain your problem. 31 | - type: checkboxes 32 | id: terms 33 | attributes: 34 | label: Agreement 35 | options: 36 | - label: I agree that I updated HA and the card to the latest version and deleted 37 | my cache or reinstalled the HA app before submitting this bug. 38 | required: true 39 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: Close stale issues 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 * * *' 6 | workflow_dispatch: 7 | 8 | permissions: 9 | issues: write 10 | pull-requests: write 11 | 12 | jobs: 13 | stale: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/stale@v9 17 | with: 18 | repo-token: ${{ secrets.GITHUB_TOKEN }} 19 | 20 | # Mark issues as stale after 21 days of inactivity 21 | days-before-stale: 21 22 | 23 | # Close issues 7 days after being marked as stale 24 | days-before-close: 7 25 | 26 | # Message posted when marking as stale 27 | stale-issue-message: > 28 | This issue has been automatically marked as stale because it has been inactive for 3 weeks. 29 | If this issue is still relevant, please comment or update it to keep it open. 30 | 31 | # Message posted when closing the issue 32 | close-issue-message: > 33 | This issue has been automatically closed due to 4 weeks of inactivity. 34 | Please open a new issue if you still experience this problem. 35 | 36 | # Label applied when an issue becomes stale 37 | stale-issue-label: "stale" 38 | 39 | # Labels that prevent an issue from being marked as stale 40 | exempt-issue-labels: "pinned,security,keep-open" 41 | 42 | # Do not mark or close pull requests 43 | days-before-pr-stale: -1 44 | -------------------------------------------------------------------------------- /src/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/card-items.ts: -------------------------------------------------------------------------------- 1 | import { HassEntity } from "home-assistant-js-websocket"; 2 | import memoizeOne from "memoize-one"; 3 | import { ALERT_DOMAINS, COVER_DOMAINS, SENSOR_DOMAINS } from "./const"; 4 | 5 | export const computeCovers = memoizeOne( 6 | (entitiesByDomain: { [k: string]: HassEntity[] }, deviceClasses: any) => 7 | COVER_DOMAINS.flatMap((domain) => { 8 | if (!(domain in entitiesByDomain)) return [] as any[]; 9 | return deviceClasses[domain].map((deviceClass: string) => ({ 10 | domain, 11 | deviceClass, 12 | })); 13 | }) 14 | ); 15 | 16 | export const computeAlerts = memoizeOne( 17 | (entitiesByDomain: { [k: string]: HassEntity[] }, deviceClasses: any) => 18 | ALERT_DOMAINS.flatMap((domain) => { 19 | if (!(domain in entitiesByDomain)) return [] as any[]; 20 | return deviceClasses[domain].map((deviceClass: string) => ({ 21 | domain, 22 | deviceClass, 23 | })); 24 | }) 25 | ); 26 | 27 | export const computeSensors = memoizeOne( 28 | (entitiesByDomain: { [k: string]: HassEntity[] }, deviceClasses: any) => 29 | SENSOR_DOMAINS.flatMap((domain) => { 30 | if (!(domain in entitiesByDomain)) return [] as any[]; 31 | return deviceClasses[domain].map( 32 | (deviceClass: string, index: number) => ({ 33 | domain, 34 | deviceClass, 35 | index, 36 | }) 37 | ); 38 | }) 39 | ); 40 | 41 | export const computeButtons = memoizeOne( 42 | ( 43 | toggle_domains: string[] | undefined, 44 | entitiesByDomain: { [k: string]: HassEntity[] } 45 | ) => 46 | (toggle_domains || []).filter( 47 | (domain: string) => domain in entitiesByDomain 48 | ) 49 | ); 50 | 51 | export const computeCameraEntity = memoizeOne( 52 | ( 53 | show_camera: boolean | undefined, 54 | entitiesByDomain: { [k: string]: HassEntity[] } 55 | ) => { 56 | if (show_camera && "camera" in entitiesByDomain) 57 | return entitiesByDomain.camera[0]?.entity_id; 58 | return undefined; 59 | } 60 | ); 61 | -------------------------------------------------------------------------------- /src/ha/data/entity_registry.ts: -------------------------------------------------------------------------------- 1 | type entityCategory = "config" | "diagnostic"; 2 | 3 | export interface EntityRegistryDisplayEntry { 4 | entity_id: string; 5 | name?: string; 6 | device_id?: string; 7 | area_id?: string; 8 | hidden?: boolean; 9 | entity_category?: entityCategory; 10 | translation_key?: string; 11 | platform?: string; 12 | display_precision?: number; 13 | } 14 | 15 | export type LightColor = 16 | | { color_temp_kelvin: number } 17 | | { hs_color: [number, number] } 18 | | { rgb_color: [number, number, number] } 19 | | { rgbw_color: [number, number, number, number] } 20 | | { rgbww_color: [number, number, number, number, number] }; 21 | 22 | export interface EntityRegistryEntry { 23 | id: string; 24 | entity_id: string; 25 | name: string | null; 26 | icon: string | null; 27 | platform: string; 28 | config_entry_id: string | null; 29 | device_id: string | null; 30 | area_id: string | null; 31 | disabled_by: "user" | "device" | "integration" | "config_entry" | null; 32 | hidden_by: Exclude; 33 | entity_category: entityCategory | null; 34 | has_entity_name: boolean; 35 | original_name?: string; 36 | unique_id: string; 37 | translation_key?: string; 38 | options: EntityRegistryOptions | null; 39 | labels: string[]; 40 | } 41 | 42 | export interface SensorEntityOptions { 43 | display_precision?: number | null; 44 | suggested_display_precision?: number | null; 45 | unit_of_measurement?: string | null; 46 | } 47 | 48 | export interface LightEntityOptions { 49 | favorite_colors?: LightColor[]; 50 | } 51 | 52 | export interface NumberEntityOptions { 53 | unit_of_measurement?: string | null; 54 | } 55 | 56 | export interface LockEntityOptions { 57 | default_code?: string | null; 58 | } 59 | 60 | export interface WeatherEntityOptions { 61 | precipitation_unit?: string | null; 62 | pressure_unit?: string | null; 63 | temperature_unit?: string | null; 64 | visibility_unit?: string | null; 65 | wind_speed_unit?: string | null; 66 | } 67 | 68 | export interface SwitchAsXEntityOptions { 69 | entity_id: string; 70 | } 71 | 72 | export interface AlarmControlPanelEntityOptions { 73 | default_code?: string | null; 74 | } 75 | 76 | export interface EntityRegistryOptions { 77 | number?: NumberEntityOptions; 78 | sensor?: SensorEntityOptions; 79 | alarm_control_panel?: AlarmControlPanelEntityOptions; 80 | lock?: LockEntityOptions; 81 | weather?: WeatherEntityOptions; 82 | light?: LightEntityOptions; 83 | switch_as_x?: SwitchAsXEntityOptions; 84 | conversation?: Record; 85 | "cloud.alexa"?: Record; 86 | "cloud.google_assistant"?: Record; 87 | } 88 | -------------------------------------------------------------------------------- /src/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/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/ha/data/lovelace.ts: -------------------------------------------------------------------------------- 1 | import { HassServiceTarget } from "home-assistant-js-websocket"; 2 | import { HASSDomEvent } from "../common/dom/fire_event"; 3 | 4 | export interface LovelaceCardConfig { 5 | index?: number; 6 | view_index?: number; 7 | view_layout?: any; 8 | type: string; 9 | [key: string]: any; 10 | } 11 | 12 | export interface LovelaceLayoutOptions { 13 | grid_columns?: number; 14 | grid_rows?: number; 15 | } 16 | 17 | export interface LovelaceGridOptions { 18 | columns?: number; 19 | rows?: number; 20 | min_columns?: number; 21 | max_columns?: number; 22 | min_rows?: number; 23 | max_rows?: number; 24 | } 25 | 26 | export interface ToggleActionConfig extends BaseActionConfig { 27 | action: "toggle"; 28 | } 29 | 30 | export interface CallServiceActionConfig extends BaseActionConfig { 31 | action: "call-service" | "perform-action"; 32 | /** @deprecated "service" is kept for backwards compatibility. Replaced by "perform_action". */ 33 | service?: string; 34 | perform_action: string; 35 | target?: HassServiceTarget; 36 | /** @deprecated "service_data" is kept for backwards compatibility. Replaced by "data". */ 37 | service_data?: Record; 38 | data?: Record; 39 | } 40 | 41 | export interface NavigateActionConfig extends BaseActionConfig { 42 | action: "navigate"; 43 | navigation_path: string; 44 | } 45 | 46 | export interface UrlActionConfig extends BaseActionConfig { 47 | action: "url"; 48 | url_path: string; 49 | } 50 | 51 | export interface MoreInfoActionConfig extends BaseActionConfig { 52 | action: "more-info"; 53 | } 54 | 55 | export interface NoActionConfig extends BaseActionConfig { 56 | action: "none"; 57 | } 58 | 59 | export interface CustomActionConfig extends BaseActionConfig { 60 | action: "fire-dom-event"; 61 | } 62 | 63 | export interface AssistActionConfig extends BaseActionConfig { 64 | action: "assist"; 65 | pipeline_id?: string; 66 | start_listening?: boolean; 67 | } 68 | 69 | export interface BaseActionConfig { 70 | action: string; 71 | confirmation?: ConfirmationRestrictionConfig; 72 | } 73 | 74 | export interface ConfirmationRestrictionConfig { 75 | text?: string; 76 | exemptions?: RestrictionConfig[]; 77 | } 78 | 79 | export interface RestrictionConfig { 80 | user: string; 81 | } 82 | 83 | export type ActionConfig = 84 | | ToggleActionConfig 85 | | CallServiceActionConfig 86 | | NavigateActionConfig 87 | | UrlActionConfig 88 | | MoreInfoActionConfig 89 | | AssistActionConfig 90 | | NoActionConfig 91 | | CustomActionConfig; 92 | 93 | export interface ActionHandlerOptions { 94 | hasTap?: boolean; 95 | hasHold?: boolean; 96 | hasDoubleClick?: boolean; 97 | disabled?: boolean; 98 | } 99 | 100 | export interface ActionHandlerDetail { 101 | action: "hold" | "tap" | "double_tap"; 102 | } 103 | 104 | export type ActionHandlerEvent = HASSDomEvent; 105 | 106 | export interface LovelaceConfig { 107 | title?: string; 108 | strategy?: { 109 | type: string; 110 | options?: Record; 111 | }; 112 | views: LovelaceViewConfig[]; 113 | background?: string; 114 | } 115 | 116 | export interface LovelaceViewConfig { 117 | index?: number; 118 | title?: string; 119 | type?: string; 120 | strategy?: { 121 | type: string; 122 | options?: Record; 123 | }; 124 | cards?: LovelaceCardConfig[]; 125 | path?: string; 126 | icon?: string; 127 | theme?: string; 128 | panel?: boolean; 129 | background?: string; 130 | visible?: boolean | ShowViewConfig[]; 131 | } 132 | 133 | export interface ShowViewConfig { 134 | user?: string; 135 | } 136 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: File a bug report. 3 | title: '[Bug]: ' 4 | labels: 5 | - bug 6 | assignees: 7 | - xBourner 8 | body: 9 | - type: markdown 10 | attributes: 11 | value: Thanks for taking the time to fill out this bug report! 12 | - type: textarea 13 | id: what-happened 14 | attributes: 15 | label: What happened? 16 | description: Please tell me exactly what happened while using the card. The more 17 | detailed, the better. 18 | placeholder: Tell us what you see! 19 | validations: 20 | required: true 21 | - type: textarea 22 | id: to-reproduce 23 | attributes: 24 | label: 'To Reproduce:' 25 | placeholder: Steps to reproduce the behavior 26 | validations: 27 | required: true 28 | - type: textarea 29 | id: screenshots 30 | attributes: 31 | label: Screenshots 32 | placeholder: If applicable, add screenshots to help explain your problem. 33 | - type: dropdown 34 | id: ha_version 35 | attributes: 36 | label: HA Version 37 | description: What version of HA are you running? Please update version if you 38 | don't see your running verision in this list. 39 | options: 40 | - 2025.12.3 41 | - 2025.12.2 42 | - 2025.12.1 43 | - 2025.12.0b9 44 | - 2025.12.0b8 45 | - 2025.12.0b7 46 | - 2025.12.0b6 47 | - 2025.12.0b5 48 | - 2025.12.0b4 49 | - 2025.12.0b3 50 | - 2025.12.0b2 51 | - 2025.12.0b1 52 | - 2025.12.0b0 53 | - 2025.12.0 54 | - 2025.11.3 55 | - 2025.11.2 56 | - 2025.11.1 57 | - 2025.11.0b6 58 | - 2025.11.0b5 59 | - 2025.11.0b4 60 | - 2025.11.0b3 61 | - 2025.11.0b2 62 | - 2025.11.0b1 63 | - 2025.11.0b0 64 | - 2025.11.0 65 | - 2025.10.4 66 | - 2025.10.3 67 | - 2025.10.2 68 | - 2025.10.1 69 | - 2025.10.0b7 70 | - 2025.10.0b6 71 | - 2025.10.0b5 72 | - 2025.10.0b4 73 | - 2025.10.0b3 74 | - 2025.10.0b2 75 | - 2025.10.0b1 76 | - 2025.10.0b0 77 | - 2025.10.0 78 | default: 0 79 | validations: 80 | required: true 81 | - type: dropdown 82 | id: card_version 83 | attributes: 84 | label: Card Version 85 | description: What version of the card are you running? 86 | options: 87 | - v1.2-beta 88 | - v1.1.1-beta 89 | - v1.1-beta 90 | - v1.1 91 | - v1.0.5 92 | - v1.0.4 93 | - v1.0.3 94 | - v1.0.2 95 | - v1.0.1 96 | - v1.0 97 | - v0.6 98 | - v0.5.4 99 | - v0.5.3 100 | - v0.5.2 101 | - v0.5.1 102 | - v0.5.0 103 | - v0.4.3 104 | - v0.4.2 105 | - v0.4.1 106 | - v0.4.0 107 | - v0.3.1 108 | - v0.3.0 109 | - v0.2.0 110 | - v0.1.9 111 | - v0.1.8 112 | - v0.1.7 113 | - v0.1.6 114 | - v0.1.5 115 | - v0.1.4 116 | - v0.1.3 117 | - v0.1.2 118 | - v0.1.1 119 | - v0.1.0 120 | - v0.0.11 121 | - v0.0.10 122 | - v0.0.9 123 | - v0.0.8 124 | - v0.0.7 125 | - v0.0.6 126 | - v0.0.5 127 | - v0.0.4 128 | - v0.0.3 129 | - v0.0.2 130 | - v0.0.1 131 | default: 0 132 | validations: 133 | required: true 134 | - type: dropdown 135 | id: browsers 136 | attributes: 137 | label: What browsers are you seeing the problem on? 138 | description: Browser/App 139 | multiple: true 140 | options: 141 | - Firefox 142 | - Chrome 143 | - Safari 144 | - Microsoft Edge 145 | - HA App on Android 146 | - HA App on iOS 147 | validations: 148 | required: true 149 | - type: checkboxes 150 | id: terms 151 | attributes: 152 | label: Agreement 153 | options: 154 | - label: I agree that I updated HA and the card to the latest version and deleted 155 | my cache or reinstalled the HA app before submitting this bug. 156 | required: true 157 | -------------------------------------------------------------------------------- /src/ha/common/dom/apply_themes_on_element.ts: -------------------------------------------------------------------------------- 1 | export const applyThemesOnElement = ( 2 | element: any, 3 | themes: any, 4 | selectedTheme?: string, 5 | themeSettings?: Partial<{ 6 | dark: boolean; 7 | primaryColor: string; 8 | accentColor: string; 9 | }>, 10 | main?: boolean 11 | ) => { 12 | const themeToApply = selectedTheme || (main ? themes?.theme : undefined); 13 | const darkMode = 14 | themeSettings?.dark !== undefined 15 | ? themeSettings.dark 16 | : themes?.darkMode || false; 17 | 18 | if (!element.__themes) { 19 | element.__themes = { cacheKey: null, keys: new Set() }; 20 | } 21 | 22 | let cacheKey = themeToApply || ""; 23 | let themeRules: Record = {}; 24 | 25 | // Default theme: only use provided primary/accent colors, do not wipe inline styles 26 | if (themeToApply === "default") { 27 | const primaryColor = themeSettings?.primaryColor; 28 | const accentColor = themeSettings?.accentColor; 29 | 30 | if (primaryColor) { 31 | cacheKey = `${cacheKey}__primary_${primaryColor}`; 32 | themeRules["primary-color"] = String(primaryColor); 33 | } 34 | if (accentColor) { 35 | cacheKey = `${cacheKey}__accent_${accentColor}`; 36 | themeRules["accent-color"] = String(accentColor); 37 | } 38 | 39 | // If nothing changes and we already applied the same config, skip 40 | if ( 41 | !primaryColor && 42 | !accentColor && 43 | element.__themes?.cacheKey === "default" 44 | ) { 45 | return; 46 | } 47 | } 48 | 49 | // Custom theme: merge base rules with dark/light mode specific overrides if present 50 | if ( 51 | themeToApply && 52 | themeToApply !== "default" && 53 | themes?.themes?.[themeToApply] 54 | ) { 55 | const { modes, ...base } = themes.themes[themeToApply] || {}; 56 | themeRules = { ...themeRules, ...base }; 57 | if (modes) { 58 | if (darkMode && modes.dark) { 59 | themeRules = { ...themeRules, ...modes.dark }; 60 | } else if (!darkMode && modes.light) { 61 | themeRules = { ...themeRules, ...modes.light }; 62 | } 63 | } 64 | } else if ( 65 | !themeToApply && 66 | (!element.__themes?.keys || 67 | (element.__themes.keys as Set).size === 0) 68 | ) { 69 | // No theme to apply and nothing set previously 70 | return; 71 | } 72 | 73 | const prevKeys: Set = element.__themes?.keys || new Set(); 74 | const newKeys = new Set(Object.keys(themeRules)); 75 | 76 | // If default theme with no explicit colors provided, clear previously set vars 77 | if (themeToApply === "default" && newKeys.size === 0) { 78 | for (const key of prevKeys) { 79 | try { 80 | element.style.removeProperty(`--${key}`); 81 | } catch {} 82 | } 83 | element.__themes = { cacheKey: "default", keys: new Set() }; 84 | return; 85 | } 86 | 87 | // If cacheKey unchanged and keys are identical, skip reapplying 88 | if (element.__themes?.cacheKey === cacheKey) { 89 | let same = true; 90 | if (prevKeys.size !== newKeys.size) { 91 | same = false; 92 | } else { 93 | for (const k of prevKeys) { 94 | if (!newKeys.has(k)) { 95 | same = false; 96 | break; 97 | } 98 | } 99 | } 100 | if (same) return; 101 | } 102 | 103 | // Remove variables that are no longer present 104 | for (const key of prevKeys) { 105 | if (!newKeys.has(key)) { 106 | try { 107 | element.style.removeProperty(`--${key}`); 108 | } catch {} 109 | } 110 | } 111 | 112 | // Apply new variables 113 | for (const [key, value] of Object.entries(themeRules)) { 114 | element.style.setProperty(`--${key}`, String(value)); 115 | } 116 | 117 | element.__themes.cacheKey = cacheKey || null; 118 | element.__themes.keys = newKeys; 119 | }; 120 | -------------------------------------------------------------------------------- /.github/workflows/update_issue_template.yml: -------------------------------------------------------------------------------- 1 | name: Update Issue Template 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | tags: 7 | - 'v*.*.*' 8 | schedule: 9 | - cron: '0 0 * * *' # optional: täglicher Cron-Lauf 10 | 11 | jobs: 12 | update-template: 13 | runs-on: ubuntu-latest 14 | 15 | permissions: 16 | contents: write 17 | 18 | steps: 19 | # 1️⃣ Checkout Repository 20 | - uses: actions/checkout@v4 21 | with: 22 | fetch-depth: 0 23 | 24 | # 2️⃣ Sammle alle Tags vom eigenen Repo (für card_version) 25 | - name: Get card version tags 26 | id: get_card_tags 27 | run: | 28 | # Alle Tags sortiert, inkl. Beta/Pre-Releases 29 | TAGS=$(git tag --sort=-v:refname | jq -R -s -c 'split("\n")[:-1]') 30 | echo "CARD_TAGS=$TAGS" >> $GITHUB_ENV 31 | echo "Found card tags: $TAGS" 32 | 33 | 34 | # 3️⃣ Sammle letzte 3 Major.Minor-Versionen inkl. Betas von HA 35 | - name: Get HA tags 36 | id: get_ha_tags 37 | run: | 38 | HA_TAGS=$(git ls-remote --tags https://github.com/home-assistant/core.git \ 39 | | awk '{print $2}' \ 40 | | sed 's|refs/tags/||' \ 41 | | grep -E '^[0-9]+\.[0-9]+\.[0-9]+(b[0-9]+)?$' \ 42 | | sort -Vr) 43 | 44 | # Filter letzte 3 Major.Minor-Versionen 45 | HA_FINAL=$(echo "$HA_TAGS" | awk -F. '{print $1"."$2}' | uniq | head -n 3 | while read v; do grep "^$v" <<<"$HA_TAGS"; done) 46 | 47 | # JSON-Liste erstellen 48 | HA_JSON=$(echo "$HA_FINAL" | jq -R -s -c 'split("\n")[:-1]') 49 | echo "HA_TAGS=$HA_JSON" >> $GITHUB_ENV 50 | echo "Found HA tags: $HA_JSON" 51 | 52 | # 4️⃣ Python-Skript: Issue Template aktualisieren 53 | - name: Update Issue Template with Python 54 | env: 55 | CARD_TAGS: ${{ env.CARD_TAGS }} 56 | HA_TAGS: ${{ env.HA_TAGS }} 57 | run: | 58 | python - <<'PY' 59 | import os, sys, json 60 | try: 61 | import yaml 62 | except ImportError: 63 | import subprocess 64 | subprocess.check_call([sys.executable, "-m", "pip", "install", "--quiet", "pyyaml"]) 65 | import yaml 66 | 67 | CARD_TAGS_JSON = os.environ.get("CARD_TAGS", "[]") 68 | HA_TAGS_JSON = os.environ.get("HA_TAGS", "[]") 69 | 70 | try: 71 | card_tags = json.loads(CARD_TAGS_JSON) 72 | except Exception as e: 73 | print("Error parsing CARD_TAGS JSON:", e) 74 | card_tags = [] 75 | 76 | try: 77 | ha_tags = json.loads(HA_TAGS_JSON) 78 | except Exception as e: 79 | print("Error parsing HA_TAGS JSON:", e) 80 | ha_tags = [] 81 | 82 | FILE = ".github/ISSUE_TEMPLATE/bug_report.yml" 83 | if not os.path.exists(FILE): 84 | print(f"Error: {FILE} not found") 85 | sys.exit(1) 86 | 87 | with open(FILE, "r", encoding="utf-8") as f: 88 | data = yaml.safe_load(f) 89 | 90 | if data is None: 91 | print("Error: YAML file empty or invalid") 92 | sys.exit(1) 93 | 94 | updated_card = updated_ha = False 95 | for item in data.get("body", []): 96 | if isinstance(item, dict): 97 | if item.get("id") == "card_version": 98 | attrs = item.get("attributes") or {} 99 | attrs["options"] = card_tags 100 | item["attributes"] = attrs 101 | updated_card = True 102 | elif item.get("id") == "ha_version": 103 | attrs = item.get("attributes") or {} 104 | attrs["options"] = ha_tags 105 | item["attributes"] = attrs 106 | updated_ha = True 107 | 108 | if not updated_card: 109 | print("Warning: Kein Eintrag mit id 'card_version' gefunden.") 110 | if not updated_ha: 111 | print("Warning: Kein Eintrag mit id 'ha_version' gefunden.") 112 | 113 | with open(FILE, "w", encoding="utf-8") as f: 114 | yaml.safe_dump(data, f, allow_unicode=True, sort_keys=False) 115 | 116 | print(f"Successfully updated {FILE}") 117 | PY 118 | 119 | # 5️⃣ Commit und Push 120 | - name: Commit and Push changes 121 | uses: stefanzweifel/git-auto-commit-action@v7 122 | with: 123 | commit_message: Update issue template with latest tags 124 | file_pattern: .github/ISSUE_TEMPLATE/bug_report.yml 125 | branch: main 126 | -------------------------------------------------------------------------------- /src/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 "../../data/entity_registry"; 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Area Card Plus

2 | 3 | [![(https://hacs.xyz)](https://img.shields.io/badge/hacs-default-orange.svg?style=for-the-badge)](https://github.com/hacs/integration) 4 | ![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/xBourner/area-card-plus/total?style=for-the-badge) 5 | [![GitHub release](https://img.shields.io/github/release/xBourner/area-card?style=for-the-badge)](https://github.com/xBourner/area-card/releases/) 6 | [![stars - status-card](https://img.shields.io/github/stars/xBourner/area-card?style=for-the-badge)](https://github.com/xBourner/area-card) 7 | [![GitHub issues](https://img.shields.io/github/issues/xBourner/area-card?style=for-the-badge)](https://github.com/xBourner/area-card/issues) 8 | 9 | Area Card Plus Header 10 | 11 | # Support my work 12 | 13 | If you like my work it would be nice if you support it. You don't have to but this will keep me motivated and i will appreciate it much!
14 | You can also join my Discord Server to leave a feedback, get help or contribute with ideas :) 15 | 16 | [![Discord](https://img.shields.io/discord/1341456711835455609?style=for-the-badge&logo=discord&logoColor=%237289da&label=Discord&color=%237289da)](https://discord.gg/RfVx7hmZD3) 17 | [![Buy Me A Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-ffdd00?&logo=buy-me-a-coffee&logoColor=black&style=for-the-badge)](https://www.buymeacoffee.com/bourner) 18 | [![GitHub Sponsors](https://img.shields.io/badge/Sponsor%20on%20GitHub-30363d?style=for-the-badge&logo=github&logoColor=white)](https://github.com/sponsors/xBourner) 19 | [![PayPal](https://img.shields.io/badge/PayPal-003087?logo=paypal&logoColor=fff&style=for-the-badge)](https://www.paypal.me/gibgas123) 20 | 21 | # Overview 22 | 23 | An **Area Card** for your Home Assistant Dashboard 24 | 25 | I always thought the area card has so much more potential so i made my own one.
26 | The card will show all entities/devices grouped into domains or device classes that are linked to your area.
27 | To make sure this card will work like it should please check if your relevant entities are assigned to the correct domain. 28 | 29 | This card i highly influenced by [Dwains Dashboard](https://github.com/dwainscheeren/dwains-lovelace-dashboard). So now you can use this great idea as a single card in all of your Dashboards 30 | 31 |

32 | Light 33 |   34 | Dark 35 |

36 | 37 |

38 | Light 39 |   40 | Dark 41 |

42 | 43 | **Vertical Mode** 44 |

45 | Light 46 |   47 | Dark 48 |

49 | 50 | **V2 Theme** 51 |

52 | Light 53 |   54 | Dark 55 |

56 | 57 | 58 | ### How it works 59 | - 🤖 **Auto generating card** - Works when entities/devices are assigned to areas 60 | - ✅ **Based on entity states** - Shows entities that are in a on/active state 61 | - 📚 **Automatic Grouping** - Entities grouped by domain/device_class 62 | - 🎨 Available in **Two Designs** 63 | - 📑 **Popup View** - Entities will render as Tile Cards in a new view 64 | - 🧠 **GUI Editor** - No code or scripts needed 65 | - 🔧 **Highly customizable** - almost everything customizable 66 | - 📱 **Optimized for desktop and phones** 67 | - 🌍 **Available in all HA languages** 68 | 69 |
70 | 71 | ## Installation 72 | 73 | ### HACS Installation (Recommended) 74 | 75 | [![Open in HACS](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=xBourner&repository=area-card-plus&category=plugin) 76 | 77 | #### Steps: 78 | 79 | 1. Make sure **[HACS](https://hacs.xyz)** is installed. 80 | 3. Go to **HACS → Custom Repositories**. 81 | 4. Add this repository: `https://github.com/xBourner/area-card-plus` as type `Dashboard` 82 | 5. Install **Status Card**. 83 | 6. **Clear your browser cache** and reload (F5) Home Assistant. 84 | 85 | For more info look at [How to add Custom Repositories](https://hacs.xyz/docs/faq/custom_repositories/) 86 | 87 | ### Usage: 88 | 89 | After adding the repository to your HA instance you need to add the card to one of your dashboards.
90 | Just choose an area an the card will do the rest.
91 | The card needs to work with your areas so you need to assign your relevant devices/entities to your areas. 92 | 93 | ### Configuration: 94 | 95 | See more in [Wiki](https://github.com/xBourner/area-card-plus/wiki) 96 | 97 | # Feedback 98 | 99 | To see the latest changes please look at: [Releases](https://github.com/xBourner/area-card-plus/releases) 100 | 101 | 102 | Thank you for using my custom cards. Please leave some feedback or a star. 103 | If you have any problems, suggestions for improvements or want to connect with me you can joing my discord: https://discord.gg/RfVx7hmZD3 104 | 105 |

106 | 107 | [🔝 Back to top](#top) 108 | 109 | 110 | 111 | -------------------------------------------------------------------------------- /src/translations.ts: -------------------------------------------------------------------------------- 1 | import { HomeAssistant, Schema } from "./ha"; 2 | 3 | export function translateEntityState( 4 | hass: HomeAssistant, 5 | state: string, 6 | domain: string 7 | ): string { 8 | const localized = hass.localize( 9 | `component.${domain}.entity_component._.state.${state}` 10 | ); 11 | return localized || state; 12 | } 13 | 14 | export function computeLabelCallback( 15 | hass: HomeAssistant, 16 | schema: Schema 17 | ): string { 18 | switch (schema.name) { 19 | case "theme": 20 | return `${hass!.localize( 21 | "ui.panel.lovelace.editor.card.generic.theme" 22 | )} (${hass!.localize("ui.panel.lovelace.editor.card.config.optional")})`; 23 | case "area_name": 24 | return ( 25 | hass!.localize("ui.panel.lovelace.editor.card.area.name") + 26 | " " + 27 | hass!.localize(`ui.panel.lovelace.editor.card.generic.name`) 28 | ); 29 | case "area_icon": 30 | return ( 31 | hass!.localize("ui.panel.lovelace.editor.card.area.name") + 32 | " " + 33 | hass!.localize(`ui.panel.lovelace.editor.card.generic.icon`) 34 | ); 35 | case "area_name_color": 36 | return ( 37 | hass!.localize("ui.panel.lovelace.editor.card.area.name") + 38 | " " + 39 | hass!.localize(`ui.panel.lovelace.editor.card.generic.name`) + 40 | " " + 41 | hass!.localize(`ui.panel.lovelace.editor.card.tile.color`) 42 | ); 43 | case "area_icon_color": 44 | return ( 45 | hass!.localize("ui.panel.lovelace.editor.card.area.name") + 46 | " " + 47 | hass!.localize(`ui.panel.lovelace.editor.card.generic.icon`) + 48 | " " + 49 | hass!.localize(`ui.panel.lovelace.editor.card.tile.color`) 50 | ); 51 | case "v2_color": 52 | return hass!.localize(`ui.panel.lovelace.editor.card.tile.color`); 53 | case "css": 54 | return "CSS"; 55 | case "domain_css": 56 | return "Domain CSS"; 57 | case "cover_css": 58 | return "Cover CSS"; 59 | case "alert_css": 60 | return "Alert CSS"; 61 | case "icon_css": 62 | return "Icon CSS"; 63 | case "name_css": 64 | return "Name CSS"; 65 | case "mirrored": 66 | return "Mirror Card Layout"; 67 | case "alert_color": 68 | case "sensor_color": 69 | case "domain_color": 70 | return hass!.localize(`ui.panel.lovelace.editor.card.tile.color`); 71 | case "columns": 72 | return hass!.localize(`ui.components.grid-size-picker.columns`); 73 | case "appearance": 74 | return ( 75 | hass!.localize(`ui.panel.lovelace.editor.card.tile.appearance`) || 76 | "Appearance" 77 | ); 78 | case "toggle_domains": 79 | return hass!.localize(`ui.panel.lovelace.editor.cardpicker.domain`); 80 | case "popup": 81 | return "Popup"; 82 | case "popup_domains": 83 | return ( 84 | "Popup" + 85 | " " + 86 | hass!.localize(`ui.panel.lovelace.editor.cardpicker.domain`) 87 | ); 88 | case "extra_entities": 89 | return ( 90 | hass!.localize(`ui.common.add`) + 91 | " " + 92 | hass!.localize(`ui.panel.lovelace.editor.card.generic.entities`) + 93 | ":" 94 | ); 95 | case "hidden_entities": 96 | return ( 97 | hass!.localize(`ui.common.hide`) + 98 | " " + 99 | hass!.localize(`ui.panel.lovelace.editor.card.generic.entities`) + 100 | ":" 101 | ); 102 | case "hide_unavailable": 103 | return ( 104 | hass!.localize(`ui.common.hide`) + 105 | " " + 106 | hass!.localize(`state.default.unavailable`) 107 | ); 108 | case "show_active": 109 | return ( 110 | hass!.localize(`ui.common.hide`) + 111 | " " + 112 | hass!.localize(`ui.components.entity.entity-state-picker.state`) + 113 | " " + 114 | hass!.localize(`component.binary_sensor.entity_component._.state.off`) 115 | ); 116 | case "edit_filters": 117 | return ( 118 | hass!.localize(`ui.panel.lovelace.editor.common.edit`) + 119 | " " + 120 | hass!.localize(`ui.components.subpage-data-table.filters`) 121 | ); 122 | case "label_filter": 123 | return ( 124 | hass!.localize("ui.components.label-picker.label") + 125 | " " + 126 | hass!.localize("ui.components.related-filter-menu.filter") 127 | ); 128 | case "cover_classes": 129 | return hass!.localize(`component.cover.entity_component._.name`); 130 | case "label": 131 | return hass!.localize("ui.components.label-picker.label"); 132 | case "show_sensor_icons": 133 | return hass!.localize("ui.panel.lovelace.editor.card.generic.show_icon"); 134 | case "wrap_sensor_icons": 135 | return ( 136 | hass!.localize( 137 | "ui.panel.lovelace.editor.edit_view_header.settings.badges_wrap_options.wrap" 138 | ) + 139 | " " + 140 | hass!.localize("ui.panel.lovelace.editor.card.sensor.name") 141 | ); 142 | case "category_filter": 143 | return ( 144 | hass!.localize("ui.components.category-picker.category") + 145 | " " + 146 | hass!.localize("ui.components.related-filter-menu.filter") 147 | ); 148 | case "name": 149 | return hass!.localize("ui.common.name"); 150 | case "state": 151 | return hass!.localize("ui.components.entity.entity-state-picker.state"); 152 | case "ungroup_areas": 153 | return ( 154 | hass!.localize("ui.common.disable") + 155 | " " + 156 | hass!.localize("ui.panel.lovelace.editor.card.area.name") + 157 | " " + 158 | hass!.localize("component.group.entity_component._.name") 159 | ); 160 | case "popup_sort": 161 | return "Popup Sort"; 162 | case "show_icon": 163 | case "tap_action": 164 | case "hold_action": 165 | case "double_tap_action": 166 | case "camera_view": 167 | return hass!.localize( 168 | `ui.panel.lovelace.editor.card.generic.${schema.name}` 169 | ); 170 | 171 | 172 | default: 173 | return hass!.localize( 174 | `ui.panel.lovelace.editor.card.area.${schema.name}` 175 | ); 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /src/card-actions.ts: -------------------------------------------------------------------------------- 1 | import { handleAction, hasAction } from "./ha"; 2 | import { actionHandler } from "./ha"; 3 | 4 | export const handleDomainAction = ( 5 | card: any, 6 | domain: string 7 | ): ((ev: CustomEvent) => void) => { 8 | const key = `domain|${domain}`; 9 | if (!card._actionHandlerCache.has(key)) { 10 | card._actionHandlerCache.set( 11 | key, 12 | makeActionHandler(card, "domain", domain) 13 | ); 14 | } 15 | return card._actionHandlerCache.get(key)!; 16 | }; 17 | 18 | export const handleAlertAction = ( 19 | card: any, 20 | domain: string, 21 | deviceClass: string 22 | ): ((ev: CustomEvent) => void) => { 23 | const key = `alert|${domain}|${deviceClass}`; 24 | if (!card._actionHandlerCache.has(key)) { 25 | card._actionHandlerCache.set( 26 | key, 27 | makeActionHandler(card, "alert", domain, deviceClass) 28 | ); 29 | } 30 | return card._actionHandlerCache.get(key)!; 31 | }; 32 | 33 | export const handleCoverAction = ( 34 | card: any, 35 | domain: string, 36 | deviceClass: string 37 | ): ((ev: CustomEvent) => void) => { 38 | const key = `cover|${domain}|${deviceClass}`; 39 | if (!card._actionHandlerCache.has(key)) { 40 | card._actionHandlerCache.set( 41 | key, 42 | makeActionHandler(card, "cover", domain, deviceClass) 43 | ); 44 | } 45 | return card._actionHandlerCache.get(key)!; 46 | }; 47 | 48 | export const handleSensorAction = ( 49 | card: any, 50 | domain: string, 51 | deviceClass: string 52 | ): ((ev: CustomEvent) => void) => { 53 | const key = `sensor|${domain}|${deviceClass}`; 54 | if (!card._actionHandlerCache.has(key)) { 55 | card._actionHandlerCache.set( 56 | key, 57 | makeActionHandler(card, "sensor", domain, deviceClass) 58 | ); 59 | } 60 | return card._actionHandlerCache.get(key)!; 61 | }; 62 | 63 | export const makeActionHandler = ( 64 | card: any, 65 | kind: "domain" | "alert" | "cover" | "sensor" | "custom_button", 66 | domain: string, 67 | deviceClass?: string, 68 | customButton?: any 69 | ): ((ev: CustomEvent) => void) => { 70 | return (ev: CustomEvent) => { 71 | ev.stopPropagation(); 72 | 73 | let customization: any; 74 | if (kind === "domain") { 75 | customization = card._customizationDomainMap.get(domain); 76 | } else if (kind === "alert") { 77 | customization = card._customizationAlertMap.get(deviceClass || ""); 78 | } else if (kind === "cover") { 79 | customization = card._customizationCoverMap.get(deviceClass || ""); 80 | } else if (kind === "sensor") { 81 | customization = card._customizationSensorMap.get(deviceClass || ""); 82 | } else if (kind === "custom_button") { 83 | customization = customButton; 84 | } 85 | 86 | const actionConfig = 87 | ev.detail.action === "tap" 88 | ? customization?.tap_action 89 | : ev.detail.action === "hold" 90 | ? customization?.hold_action 91 | : ev.detail.action === "double_tap" 92 | ? customization?.double_tap_action 93 | : null; 94 | 95 | if (kind === "domain") { 96 | const isToggle = 97 | actionConfig === "toggle" || actionConfig?.action === "toggle"; 98 | const isMoreInfo = 99 | actionConfig === "more-info" || actionConfig?.action === "more-info"; 100 | 101 | if (isToggle) { 102 | if (domain === "media_player") { 103 | card.hass.callService( 104 | domain, 105 | card._isOn(domain) ? "media_pause" : "media_play", 106 | undefined, 107 | { area_id: card._config!.area } 108 | ); 109 | } else if (domain === "lock") { 110 | card.hass.callService( 111 | domain, 112 | card._isOn(domain) ? "lock" : "unlock", 113 | undefined, 114 | { area_id: card._config!.area } 115 | ); 116 | } else if (domain === "vacuum") { 117 | card.hass.callService( 118 | domain, 119 | card._isOn(domain) ? "stop" : "start", 120 | undefined, 121 | { area_id: card._config!.area } 122 | ); 123 | } else { 124 | card.hass.callService( 125 | domain, 126 | card._isOn(domain) ? "turn_off" : "turn_on", 127 | undefined, 128 | { area_id: card._config!.area } 129 | ); 130 | } 131 | return; 132 | } else if (isMoreInfo || actionConfig === undefined) { 133 | if (domain !== "binary_sensor" && domain !== "sensor") { 134 | if (domain === "climate") { 135 | const climateCustomization = card._config?.customization_domain?.find( 136 | (item: { type: string }) => item.type === "climate" 137 | ); 138 | const displayMode = (climateCustomization as any)?.display_mode; 139 | if (displayMode === "icon" || displayMode === "text_icon") { 140 | card._showPopupForDomain(domain); 141 | } 142 | } else { 143 | card._showPopupForDomain(domain); 144 | } 145 | } 146 | return; 147 | } 148 | 149 | const config = { 150 | tap_action: customization?.tap_action, 151 | hold_action: customization?.hold_action, 152 | double_tap_action: customization?.double_tap_action, 153 | }; 154 | 155 | handleAction(card, card.hass!, config, ev.detail.action!); 156 | return; 157 | } 158 | 159 | const isMoreInfo = 160 | actionConfig === "more-info" || actionConfig?.action === "more-info"; 161 | 162 | if (kind === "alert") { 163 | if (isMoreInfo || actionConfig === undefined) { 164 | if (domain === "binary_sensor") { 165 | card._showPopupForDomain(domain, deviceClass); 166 | } 167 | return; 168 | } 169 | } else if (kind === "cover") { 170 | if (isMoreInfo || actionConfig === undefined) { 171 | if (domain === "cover") { 172 | card._showPopupForDomain(domain, deviceClass); 173 | } 174 | return; 175 | } 176 | } else if (kind === "sensor") { 177 | if (isMoreInfo) { 178 | if (domain === "sensor") { 179 | card._showPopupForDomain(domain, deviceClass); 180 | } 181 | return; 182 | } 183 | if (ev.detail.action === "tap" && !customization?.tap_action) { 184 | return; 185 | } 186 | } 187 | 188 | const config = { 189 | tap_action: customization?.tap_action, 190 | hold_action: customization?.hold_action, 191 | double_tap_action: customization?.double_tap_action, 192 | }; 193 | 194 | handleAction(card, card.hass!, config, ev.detail.action!); 195 | }; 196 | }; 197 | 198 | export const renderActionHandler = ( 199 | customization: any, 200 | defaultConfig?: any 201 | ) => { 202 | return actionHandler({ 203 | hasHold: hasAction( 204 | customization?.hold_action || defaultConfig?.hold_action 205 | ), 206 | hasDoubleClick: hasAction( 207 | customization?.double_tap_action || defaultConfig?.double_tap_action 208 | ), 209 | }); 210 | }; 211 | -------------------------------------------------------------------------------- /src/ha/panels/lovelace/common/directives/action-handler-directive.ts: -------------------------------------------------------------------------------- 1 | import { noChange } from "lit"; 2 | import type { AttributePart, DirectiveParameters } from "lit/directive.js"; 3 | import { directive, Directive } from "lit/directive.js"; 4 | import { fireEvent } from "../../../../common/dom/fire_event"; 5 | import { deepEqual } from "../../../../common/util/deep-equal"; 6 | import type { 7 | ActionHandlerDetail, 8 | ActionHandlerOptions, 9 | } from "../../../../data/lovelace"; 10 | 11 | interface ActionHandlerType extends HTMLElement { 12 | holdTime: number; 13 | bind(element: Element, options?: ActionHandlerOptions): void; 14 | } 15 | interface ActionHandlerElement extends HTMLElement { 16 | actionHandler?: { 17 | options: ActionHandlerOptions; 18 | start?: (ev: Event) => void; 19 | end?: (ev: Event) => void; 20 | handleKeyDown?: (ev: KeyboardEvent) => void; 21 | }; 22 | } 23 | 24 | declare global { 25 | interface HTMLElementTagNameMap { 26 | "action-handler-area-card": ActionHandler; 27 | } 28 | interface HASSDomEvents { 29 | action: ActionHandlerDetail; 30 | } 31 | } 32 | 33 | class ActionHandler extends HTMLElement implements ActionHandlerType { 34 | public holdTime = 500; 35 | 36 | protected timer?: number; 37 | 38 | protected held = false; 39 | 40 | private cancelled = false; 41 | 42 | private dblClickTimeout?: number; 43 | 44 | public connectedCallback() { 45 | [ 46 | "touchcancel", 47 | "mouseout", 48 | "mouseup", 49 | "touchmove", 50 | "mousewheel", 51 | "wheel", 52 | "scroll", 53 | ].forEach((ev) => { 54 | document.addEventListener( 55 | ev, 56 | () => { 57 | this.cancelled = true; 58 | if (this.timer) { 59 | clearTimeout(this.timer); 60 | this.timer = undefined; 61 | } 62 | }, 63 | { passive: true } 64 | ); 65 | }); 66 | } 67 | 68 | public bind( 69 | element: ActionHandlerElement, 70 | options: ActionHandlerOptions = {} 71 | ) { 72 | if ( 73 | element.actionHandler && 74 | deepEqual(options, element.actionHandler.options) 75 | ) { 76 | return; 77 | } 78 | 79 | if (element.actionHandler) { 80 | element.removeEventListener("touchstart", element.actionHandler.start!); 81 | element.removeEventListener("touchend", element.actionHandler.end!); 82 | element.removeEventListener("touchcancel", element.actionHandler.end!); 83 | 84 | element.removeEventListener("mousedown", element.actionHandler.start!); 85 | element.removeEventListener("click", element.actionHandler.end!); 86 | 87 | element.removeEventListener( 88 | "keydown", 89 | element.actionHandler.handleKeyDown! 90 | ); 91 | } 92 | element.actionHandler = { options }; 93 | 94 | if (options.disabled) { 95 | return; 96 | } 97 | 98 | element.actionHandler.start = (ev: Event) => { 99 | this.cancelled = false; 100 | let x: number; 101 | let y: number; 102 | if ((ev as TouchEvent).touches) { 103 | x = (ev as TouchEvent).touches[0].clientX; 104 | y = (ev as TouchEvent).touches[0].clientY; 105 | } else { 106 | x = (ev as MouseEvent).clientX; 107 | y = (ev as MouseEvent).clientY; 108 | } 109 | 110 | if (options.hasHold) { 111 | this.held = false; 112 | this.timer = window.setTimeout(() => { 113 | this.held = true; 114 | }, this.holdTime); 115 | } 116 | }; 117 | 118 | element.actionHandler.end = (ev: Event) => { 119 | // Don't respond when moved or scrolled while touch 120 | if (ev.currentTarget !== ev.target) { 121 | return; 122 | } 123 | if ( 124 | ev.type === "touchcancel" || 125 | (ev.type === "touchend" && this.cancelled) 126 | ) { 127 | return; 128 | } 129 | const target = ev.target as HTMLElement; 130 | // Prevent mouse event if touch event 131 | if (ev.cancelable) { 132 | ev.preventDefault(); 133 | } 134 | if (options.hasHold) { 135 | clearTimeout(this.timer); 136 | this.timer = undefined; 137 | } 138 | if (options.hasHold && this.held) { 139 | fireEvent(target, "action", { action: "hold" }); 140 | } else if (options.hasDoubleClick) { 141 | if ( 142 | (ev.type === "click" && (ev as MouseEvent).detail < 2) || 143 | !this.dblClickTimeout 144 | ) { 145 | this.dblClickTimeout = window.setTimeout(() => { 146 | this.dblClickTimeout = undefined; 147 | fireEvent(target, "action", { action: "tap" }); 148 | }, 250); 149 | } else { 150 | clearTimeout(this.dblClickTimeout); 151 | this.dblClickTimeout = undefined; 152 | fireEvent(target, "action", { action: "double_tap" }); 153 | } 154 | } else { 155 | fireEvent(target, "action", { action: "tap" }); 156 | } 157 | }; 158 | 159 | element.actionHandler.handleKeyDown = (ev: KeyboardEvent) => { 160 | if (!["Enter", " "].includes(ev.key)) { 161 | return; 162 | } 163 | (ev.currentTarget as ActionHandlerElement).actionHandler!.end!(ev); 164 | }; 165 | 166 | element.addEventListener("touchstart", element.actionHandler.start, { 167 | passive: true, 168 | }); 169 | element.addEventListener("touchend", element.actionHandler.end); 170 | element.addEventListener("touchcancel", element.actionHandler.end); 171 | 172 | element.addEventListener("mousedown", element.actionHandler.start, { 173 | passive: true, 174 | }); 175 | element.addEventListener("click", element.actionHandler.end); 176 | 177 | element.addEventListener("keydown", element.actionHandler.handleKeyDown); 178 | } 179 | } 180 | 181 | customElements.define("action-handler-area-card", ActionHandler); 182 | 183 | const getActionHandler = (): ActionHandlerType => { 184 | const body = document.body; 185 | if (body.querySelector("action-handler-area-card")) { 186 | return body.querySelector("action-handler-area-card") as ActionHandlerType; 187 | } 188 | 189 | const actionhandler = document.createElement("action-handler-area-card"); 190 | body.appendChild(actionhandler); 191 | 192 | return actionhandler as ActionHandlerType; 193 | }; 194 | 195 | export const actionHandlerBind = ( 196 | element: ActionHandlerElement, 197 | options?: ActionHandlerOptions 198 | ) => { 199 | const actionhandler: ActionHandlerType = getActionHandler(); 200 | if (!actionhandler) { 201 | return; 202 | } 203 | actionhandler.bind(element, options); 204 | }; 205 | 206 | export const actionHandler = directive( 207 | class extends Directive { 208 | update(part: AttributePart, [options]: DirectiveParameters) { 209 | actionHandlerBind(part.element as ActionHandlerElement, options); 210 | return noChange; 211 | } 212 | 213 | // eslint-disable-next-line @typescript-eslint/no-empty-function 214 | render(_options?: ActionHandlerOptions) {} 215 | } 216 | ); 217 | -------------------------------------------------------------------------------- /src/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 | import type { LovelaceCardConfig } from "../ha"; 18 | 19 | export interface ConfigChangedEvent { 20 | config: LovelaceCardConfig; 21 | _config?: LovelaceCardConfig; 22 | error?: string; 23 | guiModeAvailable?: boolean; 24 | } 25 | 26 | declare global { 27 | /* eslint-disable no-var, no-redeclare */ 28 | var __DEV__: boolean; 29 | var __DEMO__: boolean; 30 | var __BUILD__: "latest" | "es5"; 31 | var __VERSION__: string; 32 | var __STATIC_PATH__: string; 33 | var __BACKWARDS_COMPAT__: boolean; 34 | var __SUPERVISOR__: boolean; 35 | /* eslint-enable no-var, no-redeclare */ 36 | 37 | interface Window { 38 | // Custom panel entry point url 39 | customPanelJS: string; 40 | ShadyCSS: { 41 | nativeCss: boolean; 42 | nativeShadow: boolean; 43 | prepareTemplate(templateElement, elementName, elementExtension); 44 | styleElement(element); 45 | styleSubtree(element, overrideProperties); 46 | styleDocument(overrideProperties); 47 | getComputedStyleValue(element, propertyName); 48 | }; 49 | } 50 | // for fire event 51 | interface HASSDomEvents { 52 | "value-changed": { 53 | value: unknown; 54 | }; 55 | "config-changed": ConfigChangedEvent; 56 | change: undefined; 57 | "edit-item": number; 58 | } 59 | 60 | // For loading workers in webpack 61 | interface ImportMeta { 62 | url: string; 63 | } 64 | } 65 | 66 | export interface Schema { 67 | name: string; 68 | selector?: any; 69 | required?: boolean; 70 | default?: any; 71 | type?: string; 72 | } 73 | 74 | interface EntityRegistryDisplayEntry { 75 | entity_id: string; 76 | name?: string; 77 | device_id?: string; 78 | area_id?: string; 79 | hidden?: boolean; 80 | entity_category?: "config" | "diagnostic"; 81 | translation_key?: string; 82 | platform?: string; 83 | display_precision?: number; 84 | } 85 | 86 | export interface DeviceRegistryEntry { 87 | id: string; 88 | config_entries: string[]; 89 | connections: Array<[string, string]>; 90 | identifiers: Array<[string, string]>; 91 | manufacturer: string | null; 92 | model: string | null; 93 | name: string | null; 94 | sw_version: string | null; 95 | hw_version: string | null; 96 | via_device_id: string | null; 97 | area_id: string | null; 98 | name_by_user: string | null; 99 | entry_type: "service" | null; 100 | disabled_by: "user" | "integration" | "config_entry" | null; 101 | configuration_url: string | null; 102 | } 103 | 104 | export interface AreaRegistryEntry { 105 | aliases: string[]; 106 | area_id: string; 107 | floor_id: string | null; 108 | humidity_entity_id: string | null; 109 | icon: string | null; 110 | labels: string[]; 111 | name: string; 112 | picture: string | null; 113 | temperature_entity_id: string | null; 114 | } 115 | 116 | export interface ThemeSettings { 117 | theme: string; 118 | // Radio box selection for theme picker. Do not use in Lovelace rendering as 119 | // it can be undefined == auto. 120 | // Property hass.themes.darkMode carries effective current mode. 121 | dark?: boolean; 122 | primaryColor?: string; 123 | accentColor?: string; 124 | } 125 | 126 | export interface PanelInfo | null> { 127 | component_name: string; 128 | config: T; 129 | icon: string | null; 130 | title: string | null; 131 | url_path: string; 132 | } 133 | 134 | export interface Panels { 135 | [name: string]: PanelInfo; 136 | } 137 | 138 | export interface Resources { 139 | [language: string]: Record; 140 | } 141 | 142 | export interface Translation { 143 | nativeName: string; 144 | isRTL: boolean; 145 | hash: string; 146 | } 147 | 148 | export interface TranslationMetadata { 149 | fragments: string[]; 150 | translations: { 151 | [lang: string]: Translation; 152 | }; 153 | } 154 | 155 | export interface Credential { 156 | auth_provider_type: string; 157 | auth_provider_id: string; 158 | } 159 | 160 | export interface MFAModule { 161 | id: string; 162 | name: string; 163 | enabled: boolean; 164 | } 165 | 166 | export interface CurrentUser { 167 | id: string; 168 | is_owner: boolean; 169 | is_admin: boolean; 170 | name: string; 171 | credentials: Credential[]; 172 | mfa_modules: MFAModule[]; 173 | } 174 | 175 | export interface ServiceCallRequest { 176 | domain: string; 177 | service: string; 178 | serviceData?: Record; 179 | target?: HassServiceTarget; 180 | } 181 | 182 | export interface Context { 183 | id: string; 184 | parent_id?: string; 185 | user_id?: string | null; 186 | } 187 | 188 | export interface ServiceCallResponse { 189 | context: Context; 190 | } 191 | 192 | export interface HomeAssistant { 193 | auth: Auth; 194 | connection: Connection; 195 | connected: boolean; 196 | states: HassEntities; 197 | entities: { [id: string]: EntityRegistryDisplayEntry }; 198 | devices: { [id: string]: DeviceRegistryEntry }; 199 | areas: Record; 200 | services: HassServices; 201 | config: HassConfig; 202 | themes: Themes; 203 | selectedTheme: ThemeSettings | null; 204 | panels: Panels; 205 | panelUrl: string; 206 | // i18n 207 | // current effective language in that order: 208 | // - backend saved user selected language 209 | // - language in local app storage 210 | // - browser language 211 | // - english (en) 212 | language: string; 213 | // local stored language, keep that name for backward compatibility 214 | selectedLanguage: string | null; 215 | locale: FrontendLocaleData; 216 | resources: Resources; 217 | localize: LocalizeFunc; 218 | translationMetadata: TranslationMetadata; 219 | suspendWhenHidden: boolean; 220 | enableShortcuts: boolean; 221 | vibrate: boolean; 222 | dockedSidebar: "docked" | "always_hidden" | "auto"; 223 | defaultPanel: string; 224 | moreInfoEntityId: string | null; 225 | user?: CurrentUser; 226 | hassUrl(path?): string; 227 | callService( 228 | domain: ServiceCallRequest["domain"], 229 | service: ServiceCallRequest["service"], 230 | serviceData?: ServiceCallRequest["serviceData"], 231 | target?: ServiceCallRequest["target"] 232 | ): Promise; 233 | callApi( 234 | method: "GET" | "POST" | "PUT" | "DELETE", 235 | path: string, 236 | parameters?: Record, 237 | headers?: Record 238 | ): Promise; 239 | fetchWithAuth(path: string, init?: Record): Promise; 240 | sendWS(msg: MessageBase): void; 241 | callWS(msg: MessageBase): Promise; 242 | loadBackendTranslation( 243 | category: TranslationCategory, 244 | integration?: string | string[], 245 | configFlow?: boolean 246 | ): Promise; 247 | formatEntityState(stateObj: HassEntity, state?: string): string; 248 | formatEntityAttributeValue( 249 | stateObj: HassEntity, 250 | attribute: string, 251 | value?: any 252 | ): string; 253 | formatEntityAttributeName(stateObj: HassEntity, attribute: string): string; 254 | } 255 | -------------------------------------------------------------------------------- /src/editor-schema.ts: -------------------------------------------------------------------------------- 1 | import memoizeOne from "memoize-one"; 2 | import { AreaCardDisplayType } from "./editor"; 3 | import { Schema, SelectOption, UiAction, HomeAssistant } from "./ha"; 4 | 5 | export const getConfigSchema = memoizeOne(() => { 6 | return [{ name: "area", selector: { area: {} } }]; 7 | }); 8 | 9 | export const getAppearanceSchema = memoizeOne( 10 | ( 11 | designVersion: string, 12 | displayType: AreaCardDisplayType, 13 | hass: HomeAssistant 14 | ) => { 15 | const localize = (key: string) => hass.localize(key) || key; 16 | 17 | return [ 18 | { 19 | name: "", 20 | type: "grid", 21 | schema: [ 22 | { name: "area_name", selector: { text: {} } }, 23 | { 24 | name: "area_name_color", 25 | selector: { 26 | ui_color: { default_color: "state", include_state: true }, 27 | }, 28 | }, 29 | { name: "area_icon", selector: { icon: {} } }, 30 | { 31 | name: "area_icon_color", 32 | selector: { 33 | ui_color: { default_color: "state", include_state: true }, 34 | }, 35 | }, 36 | { 37 | name: "display_type", 38 | selector: { 39 | select: { 40 | options: [ 41 | "icon", 42 | "picture", 43 | "icon & picture", 44 | "camera", 45 | "camera & icon", 46 | ].map((value) => { 47 | const keyForPart = (part: string) => { 48 | const p = part.trim().toLowerCase(); 49 | if (p === "icon") 50 | return "ui.panel.lovelace.editor.card.generic.icon"; 51 | if (p === "picture" || p === "image") 52 | return "ui.components.selectors.image.image"; 53 | if (p === "camera") 54 | return `ui.panel.lovelace.editor.card.area.display_type_options.camera`; 55 | return `ui.panel.lovelace.editor.card.area.display_type_options.${part}`; 56 | }; 57 | 58 | const parts = value.split(" & ").map((p) => p.trim()); 59 | const label = parts 60 | .map((p) => localize(keyForPart(p)) || p) 61 | .join(" & "); 62 | 63 | return { value, label }; 64 | }), 65 | mode: "dropdown", 66 | }, 67 | }, 68 | }, 69 | ...(displayType === "camera" || displayType === "camera & icon" 70 | ? ([ 71 | { 72 | name: "camera_view", 73 | selector: { 74 | select: { 75 | options: ["auto", "live"].map((value) => ({ 76 | value, 77 | label: localize( 78 | `ui.panel.lovelace.editor.card.generic.camera_view_options.${value}` 79 | ), 80 | })), 81 | mode: "dropdown", 82 | }, 83 | }, 84 | }, 85 | ] as const satisfies readonly Schema[]) 86 | : []), 87 | ], 88 | }, 89 | { name: "mirrored", selector: { boolean: {} } }, 90 | { 91 | name: "layout", 92 | required: true, 93 | selector: { 94 | select: { 95 | mode: "box", 96 | options: ["vertical", "horizontal"].map((value) => ({ 97 | label: hass.localize( 98 | `ui.panel.lovelace.editor.card.tile.content_layout_options.${value}` 99 | ), 100 | value, 101 | image: { 102 | src: `/static/images/form/tile_content_layout_${value}.svg`, 103 | src_dark: `/static/images/form/tile_content_layout_${value}_dark.svg`, 104 | flip_rtl: true, 105 | }, 106 | })), 107 | }, 108 | }, 109 | }, 110 | { 111 | name: "design", 112 | selector: { 113 | select: { mode: "box", options: ["V1", "V2"] }, 114 | }, 115 | }, 116 | ...(designVersion === "V2" 117 | ? ([ 118 | { 119 | name: "v2_color", 120 | selector: { 121 | color_rgb: { 122 | default_color: "state", 123 | include_state: true, 124 | }, 125 | }, 126 | }, 127 | ] as const) 128 | : []), 129 | { name: "theme", required: false, selector: { theme: {} } }, 130 | ]; 131 | } 132 | ); 133 | 134 | export const getActionsSchema = memoizeOne(() => { 135 | const actions: UiAction[] = [ 136 | "more-info", 137 | "navigate", 138 | "url", 139 | "perform-action", 140 | "none", 141 | ]; 142 | 143 | return [ 144 | { name: "tap_action", selector: { ui_action: { actions } } }, 145 | { name: "double_tap_action", selector: { ui_action: { actions } } }, 146 | { name: "hold_action", selector: { ui_action: { actions } } }, 147 | ]; 148 | }); 149 | 150 | export const getBinarySchema = memoizeOne((binaryClasses: SelectOption[]) => [ 151 | { 152 | name: "alert_classes", 153 | selector: { 154 | select: { 155 | reorder: true, 156 | multiple: true, 157 | custom_value: true, 158 | options: binaryClasses, 159 | }, 160 | }, 161 | }, 162 | { 163 | name: "alert_color", 164 | selector: { ui_color: { default_color: "state", include_state: true } }, 165 | }, 166 | ]); 167 | 168 | export const getCoverSchema = memoizeOne((CoverClasses: SelectOption[]) => [ 169 | { 170 | name: "cover_classes", 171 | selector: { 172 | select: { 173 | reorder: true, 174 | multiple: true, 175 | custom_value: true, 176 | options: CoverClasses, 177 | }, 178 | }, 179 | }, 180 | { 181 | name: "cover_color", 182 | selector: { ui_color: { default_color: "state", include_state: true } }, 183 | }, 184 | ]); 185 | 186 | export const getSensorSchema = memoizeOne((sensorClasses: SelectOption[]) => [ 187 | { 188 | name: "", 189 | type: "grid", 190 | schema: [ 191 | { name: "show_sensor_icons", selector: { boolean: {} } }, 192 | { name: "wrap_sensor_icons", selector: { boolean: {} } }, 193 | ], 194 | }, 195 | { 196 | name: "sensor_classes", 197 | selector: { 198 | select: { 199 | reorder: true, 200 | multiple: true, 201 | custom_value: true, 202 | options: sensorClasses, 203 | }, 204 | }, 205 | }, 206 | { 207 | name: "sensor_color", 208 | selector: { ui_color: { default_color: "state", include_state: true } }, 209 | }, 210 | ]); 211 | 212 | export const getToggleSchema = memoizeOne((toggleDomains: SelectOption[]) => [ 213 | { 214 | name: "toggle_domains", 215 | selector: { 216 | select: { 217 | reorder: true, 218 | multiple: true, 219 | custom_value: true, 220 | options: toggleDomains, 221 | }, 222 | }, 223 | }, 224 | { 225 | name: "domain_color", 226 | selector: { ui_color: { default_color: "state", include_state: true } }, 227 | }, 228 | ]); 229 | 230 | export const getStyleSchema = memoizeOne(() => [ 231 | { 232 | name: "styles", 233 | selector: { 234 | object: {}, 235 | }, 236 | }, 237 | ]); 238 | -------------------------------------------------------------------------------- /src/helpers.ts: -------------------------------------------------------------------------------- 1 | import memoizeOne from "memoize-one"; 2 | import { HomeAssistant } from "./ha"; 3 | import { HassEntity } from "home-assistant-js-websocket"; 4 | import { 5 | ALERT_DOMAINS, 6 | CLIMATE_DOMAINS, 7 | COVER_DOMAINS, 8 | DOMAIN_ICONS, 9 | DomainType, 10 | OTHER_DOMAINS, 11 | SENSOR_DOMAINS, 12 | TOGGLE_DOMAINS, 13 | } from "./const"; 14 | import { 15 | caseInsensitiveStringCompare, 16 | computeDomain, 17 | formatNumber, 18 | isNumericState, 19 | blankBeforeUnit, 20 | } from "./ha"; 21 | 22 | export const getFriendlyName = ( 23 | states: { [entity_id: string]: HassEntity }, 24 | entityId: string 25 | ): string => { 26 | return (states?.[entityId]?.attributes?.friendly_name as string) || entityId; 27 | }; 28 | 29 | export const compareByFriendlyName = ( 30 | states: { [entity_id: string]: HassEntity }, 31 | language?: string 32 | ): ((a: string, b: string) => number) => { 33 | return (a: string, b: string) => 34 | caseInsensitiveStringCompare( 35 | getFriendlyName(states, a), 36 | getFriendlyName(states, b), 37 | language 38 | ); 39 | }; 40 | 41 | export const getEntitiesIndex = memoizeOne( 42 | ( 43 | entities: HomeAssistant["entities"], 44 | devices: HomeAssistant["devices"] 45 | ): Map> => { 46 | const index = new Map>(); 47 | 48 | const add = (areaId: string, entityId: string) => { 49 | if (!index.has(areaId)) index.set(areaId, new Set()); 50 | index.get(areaId)!.add(entityId); 51 | }; 52 | 53 | for (const entity of Object.values(entities)) { 54 | if (entity.area_id) { 55 | add(entity.area_id, entity.entity_id); 56 | } else if (entity.device_id) { 57 | const device = devices[entity.device_id]; 58 | if (device && device.area_id) { 59 | add(device.area_id, entity.entity_id); 60 | } 61 | } 62 | } 63 | return index; 64 | } 65 | ); 66 | 67 | export const getAreaEntityIds = memoizeOne( 68 | ( 69 | areaId: string, 70 | devicesInArea: Set, 71 | entities: HomeAssistant["entities"], 72 | hiddenEntitiesSet: Set, 73 | labelConfig: string[] | undefined, 74 | index?: Map> 75 | ): string[] => { 76 | let candidates: string[] = []; 77 | 78 | if (index && index.has(areaId)) { 79 | candidates = Array.from(index.get(areaId)!); 80 | } else { 81 | candidates = Object.values(entities) 82 | .filter((e: any) => { 83 | if (!e.area_id && !e.device_id) return false; 84 | if (e.area_id) { 85 | if (e.area_id !== areaId) return false; 86 | } else { 87 | if (!devicesInArea.has(e.device_id)) return false; 88 | } 89 | return true; 90 | }) 91 | .map((e: any) => e.entity_id); 92 | } 93 | 94 | return candidates.filter((id) => { 95 | const e: any = entities[id]; 96 | if (!e) return false; 97 | 98 | if (e.hidden || hiddenEntitiesSet.has(id)) return false; 99 | 100 | if (Array.isArray(labelConfig) && labelConfig.length > 0) { 101 | return ( 102 | e.labels && e.labels.some((l: string) => labelConfig.includes(l)) 103 | ); 104 | } 105 | return true; 106 | }); 107 | } 108 | ); 109 | 110 | export const getEntitiesByDomain = memoizeOne( 111 | ( 112 | entityIds: string[], 113 | states: HomeAssistant["states"], 114 | deviceClasses: { [key: string]: string[] } 115 | ) => { 116 | const entitiesByDomain: { [domain: string]: HassEntity[] } = {}; 117 | 118 | for (const entity of entityIds) { 119 | const domain = computeDomain(entity); 120 | 121 | if ( 122 | !TOGGLE_DOMAINS.includes(domain) && 123 | !SENSOR_DOMAINS.includes(domain) && 124 | !ALERT_DOMAINS.includes(domain) && 125 | !COVER_DOMAINS.includes(domain) && 126 | !OTHER_DOMAINS.includes(domain) && 127 | !CLIMATE_DOMAINS.includes(domain) 128 | ) { 129 | continue; 130 | } 131 | 132 | const stateObj: HassEntity | undefined = states[entity]; 133 | if (!stateObj) { 134 | continue; 135 | } 136 | 137 | if ( 138 | (ALERT_DOMAINS.includes(domain) || 139 | SENSOR_DOMAINS.includes(domain) || 140 | COVER_DOMAINS.includes(domain)) && 141 | !deviceClasses[domain].includes(stateObj.attributes.device_class || "") 142 | ) { 143 | continue; 144 | } 145 | 146 | if (!(domain in entitiesByDomain)) { 147 | entitiesByDomain[domain] = []; 148 | } 149 | entitiesByDomain[domain].push(stateObj); 150 | } 151 | 152 | return entitiesByDomain; 153 | } 154 | ); 155 | 156 | export const getDevicesInArea = memoizeOne( 157 | (areaId: string | undefined, devices: Record | undefined) => 158 | new Set( 159 | areaId && devices 160 | ? Object.values(devices).reduce((acc, device) => { 161 | if (device.area_id === areaId) acc.push(device.id); 162 | return acc; 163 | }, []) 164 | : [] 165 | ) 166 | ); 167 | 168 | export const findArea = memoizeOne( 169 | ( 170 | areaId: string | undefined, 171 | areas: any[] | Record | undefined 172 | ) => { 173 | const areaList: any[] = Array.isArray(areas) 174 | ? areas 175 | : areas 176 | ? Object.values(areas) 177 | : []; 178 | return areaList.find((area) => area.area_id === areaId) || null; 179 | } 180 | ); 181 | 182 | export const filterByCategory = ( 183 | entityId: string, 184 | hassEntities: HomeAssistant["entities"], 185 | categoryFilter?: string 186 | ): boolean => { 187 | if (!categoryFilter) return true; 188 | 189 | const entry: any = (hassEntities as any)?.[entityId]; 190 | if (!entry) return true; 191 | 192 | const cat: string | null = 193 | typeof entry.entity_category === "string" ? entry.entity_category : null; 194 | 195 | if (!cat) return true; 196 | 197 | switch (categoryFilter) { 198 | case "config": 199 | return cat !== "config"; 200 | case "diagnostic": 201 | return cat !== "diagnostic"; 202 | case "config+diagnostic": 203 | return cat !== "config" && cat !== "diagnostic"; 204 | default: 205 | return true; 206 | } 207 | }; 208 | 209 | export const calculateAverage = ( 210 | domain: string, 211 | deviceClass: string | undefined, 212 | entities: HassEntity[], 213 | locale: any 214 | ): string | undefined => { 215 | if (!entities || entities.length === 0) { 216 | return undefined; 217 | } 218 | 219 | let uom: any; 220 | const values = entities.filter((entity) => { 221 | if (!isNumericState(entity) || isNaN(Number(entity.state))) { 222 | return false; 223 | } 224 | if (!uom) { 225 | uom = entity.attributes.unit_of_measurement; 226 | return true; 227 | } 228 | return entity.attributes.unit_of_measurement === uom; 229 | }); 230 | 231 | if (!values.length) { 232 | return undefined; 233 | } 234 | 235 | const sum = values.reduce((total, entity) => total + Number(entity.state), 0); 236 | 237 | if (deviceClass === "power") { 238 | return `${formatNumber(sum, locale, { 239 | maximumFractionDigits: 1, 240 | })}${uom ? blankBeforeUnit(uom, locale) : ""}${uom || ""}`; 241 | } else { 242 | return `${formatNumber(sum / values.length, locale, { 243 | maximumFractionDigits: 1, 244 | })}${uom ? blankBeforeUnit(uom, locale) : ""}${uom || ""}`; 245 | } 246 | }; 247 | 248 | export const getIcon = ( 249 | domain: DomainType, 250 | on: boolean, 251 | deviceClass?: string 252 | ): string => { 253 | if (domain in DOMAIN_ICONS) { 254 | const icons = DOMAIN_ICONS[domain] as any; 255 | 256 | if (deviceClass && typeof icons === "object") { 257 | const dc = (icons as Record)[deviceClass]; 258 | if (dc) { 259 | if (typeof dc === "string") return dc; 260 | if (typeof dc === "object" && "on" in dc && "off" in dc) 261 | return on ? dc.on : dc.off; 262 | } 263 | } 264 | 265 | if (typeof icons === "object" && "on" in icons && "off" in icons) { 266 | return on ? icons.on : icons.off; 267 | } 268 | 269 | if (typeof icons === "string") return icons; 270 | } 271 | 272 | return ""; 273 | }; 274 | -------------------------------------------------------------------------------- /src/const.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_ASPECT_RATIO = "16:9"; 2 | 3 | export type DomainType = 4 | | "light" 5 | | "switch" 6 | | "fan" 7 | | "climate" 8 | | "media_player" 9 | | "lock" 10 | | "vacuum" 11 | | "cover" 12 | | "binary_sensor"; 13 | 14 | export const SENSOR_DOMAINS = ["sensor"]; 15 | export const ALERT_DOMAINS = ["binary_sensor"]; 16 | export const COVER_DOMAINS = ["cover"]; 17 | export const CLIMATE_DOMAINS = ["climate"]; 18 | export const OTHER_DOMAINS = ["camera"]; 19 | export const TOGGLE_DOMAINS = [ 20 | "light", 21 | "switch", 22 | "fan", 23 | "media_player", 24 | "lock", 25 | "vacuum", 26 | "cover", 27 | "script", 28 | "scene", 29 | ]; 30 | 31 | export const DEVICE_CLASSES = { 32 | sensor: ["temperature", "humidity"], 33 | binary_sensor: ["motion", "window"], 34 | cover: ["garage"], 35 | }; 36 | 37 | import { 38 | mdiAlarmLight, 39 | mdiAlarmLightOff, 40 | mdiBellRing, 41 | mdiBellOff, 42 | mdiLockOpen, 43 | mdiLock, 44 | mdiLightbulb, 45 | mdiLightbulbOff, 46 | mdiCast, 47 | mdiCastOff, 48 | mdiThermostat, 49 | mdiThermostatCog, 50 | mdiAirHumidifier, 51 | mdiAirHumidifierOff, 52 | mdiToggleSwitch, 53 | mdiToggleSwitchOff, 54 | mdiPowerPlug, 55 | mdiPowerPlugOff, 56 | mdiRobotVacuum, 57 | mdiRobotVacuumOff, 58 | mdiRobotMower, 59 | mdiFan, 60 | mdiFanOff, 61 | mdiGarageOpen, 62 | mdiGarage, 63 | mdiDoorOpen, 64 | mdiDoorClosed, 65 | mdiGateOpen, 66 | mdiGate, 67 | mdiBlindsOpen, 68 | mdiBlinds, 69 | mdiCurtains, 70 | mdiCurtainsClosed, 71 | mdiValveOpen, 72 | mdiValveClosed, 73 | mdiAwningOutline, 74 | mdiWindowShutterOpen, 75 | mdiWindowShutter, 76 | mdiRollerShade, 77 | mdiRollerShadeClosed, 78 | mdiWindowOpen, 79 | mdiWindowClosed, 80 | mdiPowerOff, 81 | mdiMotionSensor, 82 | mdiMotionSensorOff, 83 | mdiWaterAlert, 84 | mdiWaterOff, 85 | mdiHomeOutline, 86 | mdiHomeExportOutline, 87 | mdiSeat, 88 | mdiSeatOutline, 89 | mdiVibrate, 90 | mdiVibrateOff, 91 | mdiShieldLockOpen, 92 | mdiShieldLock, 93 | mdiAlertCircleOutline, 94 | mdiAlertCircleCheckOutline, 95 | mdiSmokeDetectorOutline, 96 | mdiSmokeDetectorOffOutline, 97 | mdiPlay, 98 | mdiPause, 99 | mdiPower, 100 | mdiBatteryAlert, 101 | mdiBattery, 102 | mdiBatteryCharging, 103 | mdiBatteryCheck, 104 | mdiGasStationOutline, 105 | mdiGasStationOffOutline, 106 | mdiMoleculeCo, 107 | mdiSnowflake, 108 | mdiSnowflakeOff, 109 | mdiWeatherSunny, 110 | mdiWeatherSunnyOff, 111 | mdiConnection, 112 | mdiShieldAlertOutline, 113 | mdiShieldCheckOutline, 114 | mdiVolumeHigh, 115 | mdiVolumeOff, 116 | mdiAutorenew, 117 | mdiAutorenewOff, 118 | mdiShieldHome, 119 | mdiLightbulbOutline, 120 | mdiLightbulbOffOutline, 121 | mdiCar, 122 | mdiCarOff, 123 | mdiAccount, 124 | mdiAccountOff, 125 | mdiValve, 126 | mdiWaterBoiler, 127 | mdiWaterPumpOff, 128 | mdiRemote, 129 | mdiRemoteOff, 130 | mdiAirFilter, 131 | mdiCamera, 132 | mdiCameraOff, 133 | mdiCalendar, 134 | mdiCalendarRemove, 135 | mdiMovie, 136 | mdiMovieOff, 137 | mdiBell, 138 | mdiGauge, 139 | mdiScriptText, 140 | mdiTagMultiple, 141 | mdiFormatListBulleted, 142 | mdiRobot, 143 | mdiRobotOff, 144 | mdiGestureTapButton, 145 | mdiNumeric, 146 | mdiCommentMultiple, 147 | mdiSatelliteVariant, 148 | mdiCounter, 149 | mdiCalendarStar, 150 | mdiGoogleCirclesCommunities, 151 | mdiImage, 152 | mdiImageOff, 153 | mdiImageFilterCenterFocus, 154 | mdiCalendarClock, 155 | mdiTextBox, 156 | mdiRecordRec, 157 | mdiRecord, 158 | mdiWeatherNight, 159 | mdiClockOutline, 160 | mdiClockRemove, 161 | mdiTimerOutline, 162 | mdiTimerOff, 163 | mdiCheckCircleOutline, 164 | mdiCheckboxBlankCircleOutline, 165 | mdiMicrophone, 166 | mdiMicrophoneOff, 167 | mdiWeatherPartlyCloudy, 168 | mdiMapMarker, 169 | mdiMapMarkerOff, 170 | } from "@mdi/js"; 171 | 172 | export const DOMAIN_ICONS = { 173 | alarm_control_panel: { on: mdiAlarmLight, off: mdiAlarmLightOff }, 174 | siren: { on: mdiBellRing, off: mdiBellOff }, 175 | lock: { on: mdiLockOpen, off: mdiLock }, 176 | light: { on: mdiLightbulb, off: mdiLightbulbOff }, 177 | media_player: { on: mdiCast, off: mdiCastOff }, 178 | climate: { on: mdiThermostat, off: mdiThermostatCog }, 179 | humidifier: { on: mdiAirHumidifier, off: mdiAirHumidifierOff }, 180 | switch: { 181 | on: mdiToggleSwitch, 182 | off: mdiToggleSwitchOff, 183 | switch: { on: mdiToggleSwitch, off: mdiToggleSwitchOff }, 184 | outlet: { on: mdiPowerPlug, off: mdiPowerPlugOff }, 185 | }, 186 | vacuum: { on: mdiRobotVacuum, off: mdiRobotVacuumOff }, 187 | lawn_mower: { on: mdiRobotMower, off: mdiRobotMower }, 188 | fan: { on: mdiFan, off: mdiFanOff }, 189 | 190 | cover: { 191 | on: mdiGarageOpen, 192 | off: mdiGarage, 193 | garage: { on: mdiGarageOpen, off: mdiGarage }, 194 | door: { on: mdiDoorOpen, off: mdiDoorClosed }, 195 | gate: { on: mdiGateOpen, off: mdiGate }, 196 | blind: { on: mdiBlindsOpen, off: mdiBlinds }, 197 | curtain: { on: mdiCurtains, off: mdiCurtainsClosed }, 198 | damper: { on: mdiValveOpen, off: mdiValveClosed }, 199 | awning: { on: mdiAwningOutline, off: mdiAwningOutline }, 200 | shutter: { on: mdiWindowShutterOpen, off: mdiWindowShutter }, 201 | shade: { on: mdiRollerShade, off: mdiRollerShadeClosed }, 202 | window: { on: mdiWindowOpen, off: mdiWindowClosed }, 203 | }, 204 | binary_sensor: { 205 | on: mdiPowerOff, 206 | off: mdiPowerOff, 207 | motion: { on: mdiMotionSensor, off: mdiMotionSensorOff }, 208 | moisture: { on: mdiWaterAlert, off: mdiWaterOff }, 209 | window: { on: mdiWindowOpen, off: mdiWindowClosed }, 210 | door: { on: mdiDoorOpen, off: mdiDoorClosed }, 211 | lock: { on: mdiLockOpen, off: mdiLock }, 212 | presence: { on: mdiHomeOutline, off: mdiHomeExportOutline }, 213 | occupancy: { on: mdiSeat, off: mdiSeatOutline }, 214 | vibration: { on: mdiVibrate, off: mdiVibrateOff }, 215 | opening: { on: mdiShieldLockOpen, off: mdiShieldLock }, 216 | garage_door: { on: mdiGarageOpen, off: mdiGarage }, 217 | problem: { 218 | on: mdiAlertCircleOutline, 219 | off: mdiAlertCircleCheckOutline, 220 | }, 221 | smoke: { 222 | on: mdiSmokeDetectorOutline, 223 | off: mdiSmokeDetectorOffOutline, 224 | }, 225 | running: { on: mdiPlay, off: mdiPause }, 226 | plug: { on: mdiPowerPlug, off: mdiPowerPlugOff }, 227 | power: { on: mdiPower, off: mdiPowerOff }, 228 | battery: { on: mdiBatteryAlert, off: mdiBattery }, 229 | battery_charging: { on: mdiBatteryCharging, off: mdiBatteryCheck }, 230 | gas: { on: mdiGasStationOutline, off: mdiGasStationOffOutline }, 231 | carbon_monoxide: { on: mdiMoleculeCo, off: mdiMoleculeCo }, 232 | cold: { on: mdiSnowflake, off: mdiSnowflakeOff }, 233 | heat: { on: mdiWeatherSunny, off: mdiWeatherSunnyOff }, 234 | connectivity: { on: mdiConnection, off: mdiConnection }, 235 | safety: { on: mdiShieldAlertOutline, off: mdiShieldCheckOutline }, 236 | sound: { on: mdiVolumeHigh, off: mdiVolumeOff }, 237 | update: { on: mdiAutorenew, off: mdiAutorenewOff }, 238 | tamper: { on: mdiShieldHome, off: mdiShieldHome }, 239 | light: { on: mdiLightbulbOutline, off: mdiLightbulbOffOutline }, 240 | moving: { on: mdiCar, off: mdiCarOff }, 241 | }, 242 | person: { on: mdiAccount, off: mdiAccountOff }, 243 | device_tracker: { on: mdiAccount, off: mdiAccountOff }, 244 | valve: { on: mdiValve, off: mdiValveClosed }, 245 | water_heater: { on: mdiWaterBoiler, off: mdiWaterPumpOff }, 246 | remote: { on: mdiRemote, off: mdiRemoteOff }, 247 | update: { on: mdiAutorenew, off: mdiAutorenewOff }, 248 | air_quality: { on: mdiAirFilter, off: mdiAirFilter }, 249 | camera: { on: mdiCamera, off: mdiCameraOff }, 250 | calendar: { on: mdiCalendar, off: mdiCalendarRemove }, 251 | scene: { on: mdiMovie, off: mdiMovieOff }, 252 | notifications: { on: mdiBell, off: mdiBellOff }, 253 | sensor: { on: mdiGauge, off: mdiGauge }, 254 | script: { on: mdiScriptText, off: mdiScriptText }, 255 | tags: { on: mdiTagMultiple, off: mdiTagMultiple }, 256 | select: { on: mdiFormatListBulleted, off: mdiFormatListBulleted }, 257 | automation: { on: mdiRobot, off: mdiRobotOff }, 258 | button: { on: mdiGestureTapButton, off: mdiGestureTapButton }, 259 | number: { on: mdiNumeric, off: mdiNumeric }, 260 | conversation: { on: mdiCommentMultiple, off: mdiCommentMultiple }, 261 | assist_satellite: { 262 | on: mdiSatelliteVariant, 263 | off: mdiSatelliteVariant, 264 | }, 265 | counter: { on: mdiCounter, off: mdiCounter }, 266 | event: { on: mdiCalendarStar, off: mdiCalendarStar }, 267 | group: { 268 | on: mdiGoogleCirclesCommunities, 269 | off: mdiGoogleCirclesCommunities, 270 | }, 271 | image: { on: mdiImage, off: mdiImageOff }, 272 | image_processing: { 273 | on: mdiImageFilterCenterFocus, 274 | off: mdiImageFilterCenterFocus, 275 | }, 276 | input_boolean: { on: mdiToggleSwitch, off: mdiToggleSwitchOff }, 277 | input_datetime: { on: mdiCalendarClock, off: mdiCalendarClock }, 278 | input_number: { on: mdiNumeric, off: mdiNumeric }, 279 | input_select: { 280 | on: mdiFormatListBulleted, 281 | off: mdiFormatListBulleted, 282 | }, 283 | input_text: { on: mdiTextBox, off: mdiTextBox }, 284 | stt: { on: mdiRecordRec, off: mdiRecord }, 285 | sun: { on: mdiWeatherSunny, off: mdiWeatherNight }, 286 | text: { on: mdiTextBox, off: mdiTextBox }, 287 | date: { on: mdiCalendar, off: mdiCalendarRemove }, 288 | datetime: { on: mdiCalendarClock, off: mdiCalendarClock }, 289 | time: { on: mdiClockOutline, off: mdiClockRemove }, 290 | timer: { on: mdiTimerOutline, off: mdiTimerOff }, 291 | todo: { 292 | on: mdiCheckCircleOutline, 293 | off: mdiCheckboxBlankCircleOutline, 294 | }, 295 | tts: { on: mdiVolumeHigh, off: mdiVolumeOff }, 296 | wake_word: { on: mdiMicrophone, off: mdiMicrophoneOff }, 297 | weather: { on: mdiWeatherPartlyCloudy, off: mdiWeatherNight }, 298 | zone: { on: mdiMapMarker, off: mdiMapMarkerOff }, 299 | geo_location: { on: mdiMapMarker, off: mdiMapMarkerOff }, 300 | }; 301 | 302 | export interface CustomizationConfig { 303 | type: string; 304 | css?: string | Record; 305 | icon_css?: string | Record; 306 | _parsedCss?: Record; 307 | _parsedIconCss?: Record; 308 | styles?: Record; 309 | } 310 | -------------------------------------------------------------------------------- /src/card-styles.ts: -------------------------------------------------------------------------------- 1 | import memoizeOne from "memoize-one"; 2 | import { css } from "lit"; 3 | 4 | export const parseCss = ( 5 | css?: string | Record, 6 | styleCache?: Map> 7 | ): Record => { 8 | if (!css) return {}; 9 | 10 | if (typeof css === "object") { 11 | return Object.entries(css).reduce((acc, [key, value]) => { 12 | const finalKey = key.startsWith("--") 13 | ? key 14 | : key.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()); 15 | acc[finalKey] = String(value); 16 | return acc; 17 | }, {} as Record); 18 | } 19 | 20 | const key = css.trim(); 21 | if (styleCache && styleCache.has(key)) return styleCache.get(key)!; 22 | 23 | const normalized = css.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\n/g, " "); 24 | 25 | const obj = normalized 26 | .split(";") 27 | .map((s) => s.trim()) 28 | .filter((s) => s && s.includes(":")) 29 | .reduce((acc: Record, rule: string) => { 30 | const parts = rule.split(":"); 31 | const keyPart = parts[0]; 32 | const valuePart = parts.slice(1).join(":"); 33 | 34 | if (keyPart && valuePart !== undefined) { 35 | const trimmed = keyPart.trim(); 36 | const finalKey = trimmed.startsWith("--") 37 | ? trimmed 38 | : trimmed.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()); 39 | acc[finalKey] = valuePart.trim(); 40 | } 41 | return acc; 42 | }, {} as Record); 43 | 44 | if (styleCache) { 45 | styleCache.set(key, obj); 46 | } 47 | return obj; 48 | }; 49 | 50 | export const getParsedCss = ( 51 | source?: string | Record, 52 | customization?: any, 53 | styleCache?: Map> 54 | ): Record => { 55 | if (customization && customization._parsedCss) 56 | return customization._parsedCss; 57 | if (!source) return {}; 58 | return parseCss(source, styleCache); 59 | }; 60 | 61 | export const computeIconStyles = memoizeOne( 62 | ( 63 | isV2Design: boolean, 64 | rowSize: number, 65 | icon_css: string | Record | undefined, 66 | area_icon_color: string | undefined, 67 | styleCache?: Map> 68 | ) => { 69 | const base = { 70 | ...(isV2Design && rowSize === 1 ? { "--mdc-icon-size": "20px" } : {}), 71 | ...(area_icon_color ? { color: `var(--${area_icon_color}-color)` } : {}), 72 | }; 73 | 74 | if (!icon_css) return base; 75 | 76 | const cssRules = getParsedCss(icon_css, undefined, styleCache); 77 | 78 | return { ...base, ...cssRules }; 79 | } 80 | ); 81 | 82 | export const cardStyles = css` 83 | ha-card { 84 | overflow: hidden; 85 | position: relative; 86 | height: 100%; 87 | } 88 | .header { 89 | position: relative; 90 | height: 100%; 91 | width: 100%; 92 | } 93 | .picture { 94 | height: 100%; 95 | width: 100%; 96 | background-size: cover; 97 | background-position: center; 98 | position: relative; 99 | } 100 | hui-image { 101 | height: 100%; 102 | width: 100%; 103 | } 104 | .sensors { 105 | --mdc-icon-size: 20px; 106 | } 107 | .sensor-value { 108 | vertical-align: middle; 109 | } 110 | .sensor-row { 111 | display: flex; 112 | align-items: center; 113 | gap: 0.5em; 114 | } 115 | .icon-container { 116 | position: absolute; 117 | top: 16px; 118 | left: 16px; 119 | color: var(--primary-color); 120 | z-index: 1; 121 | pointer-events: none; 122 | } 123 | .icon-container.row { 124 | top: 25%; 125 | } 126 | .icon-container.v2 { 127 | top: 8px; 128 | left: 8px; 129 | border-radius: 50%; 130 | } 131 | .mirrored .icon-container { 132 | left: unset; 133 | right: 16px; 134 | } 135 | .content { 136 | display: flex; 137 | flex-direction: row; 138 | justify-content: space-between; 139 | position: absolute; 140 | top: 0; 141 | left: 0; 142 | right: 0; 143 | bottom: 0; 144 | cursor: pointer; 145 | } 146 | .content.row { 147 | flex-direction: column; 148 | justify-content: center; 149 | } 150 | .right { 151 | display: flex; 152 | flex-direction: row; 153 | justify-content: flex-end; 154 | align-items: flex-start; 155 | position: absolute; 156 | top: 8px; 157 | right: 8px; 158 | gap: 7px; 159 | } 160 | .right.row { 161 | top: unset; 162 | } 163 | .mirrored .right { 164 | right: unset; 165 | left: 8px; 166 | flex-direction: row-reverse; 167 | } 168 | .alerts, 169 | .covers, 170 | .custom_buttons { 171 | display: flex; 172 | flex-direction: column; 173 | align-items: center; 174 | justify-content: center; 175 | margin-right: -3px; 176 | gap: 2px; 177 | } 178 | .alerts.row, 179 | .covers.row, 180 | .custom_buttons.row { 181 | flex-direction: row-reverse; 182 | } 183 | .buttons { 184 | display: flex; 185 | flex-direction: column; 186 | gap: 2px; 187 | margin-right: -3px; 188 | } 189 | .buttons.row { 190 | flex-direction: row-reverse; 191 | } 192 | .bottom { 193 | display: flex; 194 | flex-direction: column; 195 | position: absolute; 196 | bottom: 8px; 197 | left: 16px; 198 | } 199 | .bottom.row { 200 | flex-direction: row; 201 | left: calc(var(--row-size, 3) * 20px + 25px); 202 | bottom: unset; 203 | align-items: baseline; 204 | gap: 5px; 205 | } 206 | .mirrored .bottom.row { 207 | flex-direction: row-reverse; 208 | right: calc(var(--row-size, 3) * 20px + 25px) !important; 209 | } 210 | .mirrored .bottom { 211 | left: unset; 212 | right: 16px; 213 | text-align: end; 214 | } 215 | .name { 216 | font-weight: bold; 217 | margin-bottom: 8px; 218 | z-index: 1; 219 | } 220 | .name.row { 221 | margin-bottom: 0; 222 | } 223 | .icon-with-count { 224 | display: flex; 225 | align-items: center; 226 | gap: 5px; 227 | background: none; 228 | border: solid 0.025rem rgba(var(--rgb-primary-text-color), 0.15); 229 | padding: 1px; 230 | border-radius: 5px; 231 | --mdc-icon-size: 20px; 232 | } 233 | .icon-with-count > * { 234 | pointer-events: none; 235 | } 236 | 237 | .toggle-on { 238 | color: var(--primary-text-color); 239 | } 240 | .toggle-off { 241 | color: var(--secondary-text-color) !important; 242 | } 243 | .off { 244 | color: var(--secondary-text-color); 245 | } 246 | .navigate { 247 | cursor: pointer; 248 | } 249 | .hover:hover { 250 | background-color: rgba(var(--rgb-primary-text-color), 0.15); 251 | } 252 | .text-small { 253 | font-size: 0.9em; 254 | } 255 | .text-medium { 256 | font-size: 1em; 257 | } 258 | .text-large { 259 | font-size: 1.3em; 260 | } 261 | .v2 .covers { 262 | flex-direction: row-reverse; 263 | } 264 | .mirrored .v2 .covers { 265 | flex-direction: row; 266 | } 267 | .v2 .custom_buttons { 268 | flex-direction: row-reverse; 269 | } 270 | .mirrored .v2 .custom_buttons { 271 | flex-direction: row; 272 | } 273 | .v2 .alerts { 274 | flex-direction: row-reverse; 275 | } 276 | .mirrored .v2 .areas { 277 | flex-direction: row; 278 | } 279 | .v2 .buttons { 280 | flex-direction: row-reverse; 281 | } 282 | .mirrored .v2 .buttons { 283 | flex-direction: row; 284 | } 285 | .mirrored .v2 .bottom { 286 | right: 105px !important; 287 | left: unset; 288 | } 289 | .v2 .right { 290 | bottom: 0px; 291 | left: 0px; 292 | right: 0px; 293 | padding: calc(var(--row-size, 3) * 3px) 8px; 294 | top: unset; 295 | min-height: 24px; 296 | pointer-events: none; 297 | } 298 | .v2 .bottom { 299 | left: calc(var(--row-size, 3) * 15px + 55px); 300 | top: calc(var(--row-size, 3) * 5px + 4px); 301 | bottom: unset; 302 | } 303 | .v2 .bottom.row { 304 | top: calc(var(--row-size, 3) * 8px + 12px); 305 | left: calc(var(--row-size, 3) * 15px + 55px); 306 | } 307 | 308 | .v2 .name { 309 | margin-bottom: calc(var(--row-size, 3) * 1.5px + 1px); 310 | } 311 | .v2 .name.row { 312 | margin-bottom: 0px; 313 | } 314 | 315 | @supports (--row-size: 1) { 316 | .icon-container ha-icon { 317 | --mdc-icon-size: calc(var(--row-size, 3) * 20px); 318 | } 319 | .icon-container.v2 ha-icon { 320 | --mdc-icon-size: calc(var(--row-size, 3) * 15px); 321 | border-radius: 50%; 322 | display: flex; 323 | padding: 16px; 324 | color: var(--card-background-color); 325 | } 326 | } 327 | 328 | @media (max-width: 768px) { 329 | .name { 330 | font-weight: bold; 331 | margin-bottom: 5px; 332 | } 333 | } 334 | @keyframes spin { 335 | from { 336 | transform: rotate(0deg); 337 | } 338 | to { 339 | transform: rotate(360deg); 340 | } 341 | } 342 | @keyframes pulse { 343 | 0% { 344 | transform: scale(1); 345 | } 346 | 50% { 347 | transform: scale(1.1); 348 | } 349 | 100% { 350 | transform: scale(1); 351 | } 352 | } 353 | @keyframes shake { 354 | 0% { 355 | transform: translate(1px, 1px) rotate(0deg); 356 | } 357 | 10% { 358 | transform: translate(-1px, -2px) rotate(-1deg); 359 | } 360 | 20% { 361 | transform: translate(-3px, 0px) rotate(1deg); 362 | } 363 | 30% { 364 | transform: translate(3px, 2px) rotate(0deg); 365 | } 366 | 40% { 367 | transform: translate(1px, -1px) rotate(1deg); 368 | } 369 | 50% { 370 | transform: translate(-1px, 2px) rotate(-1deg); 371 | } 372 | 60% { 373 | transform: translate(-3px, 1px) rotate(0deg); 374 | } 375 | 70% { 376 | transform: translate(3px, 1px) rotate(-1deg); 377 | } 378 | 80% { 379 | transform: translate(-1px, -1px) rotate(1deg); 380 | } 381 | 90% { 382 | transform: translate(1px, 2px) rotate(0deg); 383 | } 384 | 100% { 385 | transform: translate(1px, -2px) rotate(-1deg); 386 | } 387 | } 388 | @keyframes blink { 389 | 50% { 390 | opacity: 0; 391 | } 392 | } 393 | @keyframes bounce { 394 | 0%, 395 | 20%, 396 | 50%, 397 | 80%, 398 | 100% { 399 | transform: translateY(0); 400 | } 401 | 40% { 402 | transform: translateY(-6px); 403 | } 404 | 60% { 405 | transform: translateY(-3px); 406 | } 407 | } 408 | `; 409 | -------------------------------------------------------------------------------- /src/items-editor.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, html } from "lit"; 2 | import { 3 | HomeAssistant, 4 | fireEvent, 5 | SelectOption, 6 | LovelaceCardConfig, 7 | EditorTarget, 8 | } from "./ha"; 9 | import { customElement, property } from "lit/decorators.js"; 10 | import { repeat } from "lit/directives/repeat.js"; 11 | import { css, CSSResult, nothing } from "lit"; 12 | import { mdiClose, mdiPencil, mdiGestureTapButton } from "@mdi/js"; 13 | 14 | interface HTMLElementValue extends HTMLElement { 15 | value: string; 16 | } 17 | 18 | abstract class BaseItemsEditor extends LitElement { 19 | @property({ attribute: false }) hass?: HomeAssistant; 20 | @property({ type: Array }) SelectOptions: SelectOption[] = []; 21 | 22 | protected abstract customization: LovelaceCardConfig[] | undefined; 23 | 24 | private _entityKeys = new WeakMap(); 25 | 26 | private _getKey(action: LovelaceCardConfig) { 27 | if (!this._entityKeys.has(action)) { 28 | this._entityKeys.set(action, Math.random().toString()); 29 | } 30 | return this._entityKeys.get(action)!; 31 | } 32 | 33 | protected render() { 34 | if (!this.hass) { 35 | return nothing; 36 | } 37 | 38 | const selectedTypes = new Set( 39 | (this.customization || []).map((conf) => conf.type) 40 | ); 41 | const availableOptions = this.SelectOptions.filter( 42 | (option) => !selectedTypes.has(option.value) 43 | ); 44 | 45 | return html` 46 |
47 | ${this.customization && 48 | repeat( 49 | this.customization, 50 | (conf) => this._getKey(conf), 51 | (conf, index) => html` 52 |
53 | ev.stopPropagation()} 63 | @value-changed=${this._valueChanged} 64 | > 65 | 66 | ${this.SelectOptions.find((o) => o.value === conf.type) 67 | ?.label || conf.type} 68 | 69 | 70 | 71 | 78 | 79 | 86 |
87 | ` 88 | )} 89 | 90 |
91 | ev.stopPropagation()} 104 | @click=${this._addRow} 105 | > 106 | ${availableOptions.map( 107 | (option) => 108 | html`${option.label}` 111 | )} 112 | 113 |
114 |
115 | `; 116 | } 117 | 118 | private _valueChanged(ev: CustomEvent): void { 119 | if (!this.customization || !this.hass) { 120 | return; 121 | } 122 | const value = ev.detail.value; 123 | const index = (ev.target as any).index; 124 | const newCustomization = this.customization.concat(); 125 | newCustomization[index] = { ...newCustomization[index], type: value || "" }; 126 | fireEvent(this, "config-changed", newCustomization as any); 127 | } 128 | 129 | private _removeRow(ev: Event): void { 130 | ev.stopPropagation(); 131 | const index = (ev.currentTarget as EditorTarget).index; 132 | if (index != undefined) { 133 | const customization = this.customization!.concat(); 134 | customization.splice(index, 1); 135 | fireEvent(this, "config-changed", customization as any); 136 | } 137 | } 138 | 139 | private _editRow(ev: Event): void { 140 | ev.stopPropagation(); 141 | const index = (ev.target as EditorTarget).index; 142 | if (index != undefined) { 143 | fireEvent(this, "edit-item", index); 144 | } 145 | } 146 | 147 | private _addRow(ev: Event): void { 148 | ev.stopPropagation(); 149 | if (!this.customization || !this.hass) { 150 | return; 151 | } 152 | const selectElement = this.shadowRoot!.querySelector( 153 | ".add-customization" 154 | ) as HTMLElementValue; 155 | if (!selectElement || !selectElement.value) { 156 | return; 157 | } 158 | const preset = selectElement.value; 159 | const newItem: LovelaceCardConfig = { type: preset }; 160 | fireEvent(this, "config-changed", [...this.customization, newItem] as any); 161 | selectElement.value = ""; 162 | } 163 | 164 | static get styles(): CSSResult { 165 | return css` 166 | .customization { 167 | margin-top: 16px; 168 | } 169 | .customize-item, 170 | .add-item { 171 | display: flex; 172 | align-items: center; 173 | } 174 | .add-customization, 175 | .select-customization { 176 | width: 100%; 177 | margin-top: 8px; 178 | } 179 | .remove-icon, 180 | .edit-icon { 181 | --mdc-icon-button-size: 36px; 182 | color: var(--secondary-text-color); 183 | padding-left: 4px; 184 | } 185 | `; 186 | } 187 | } 188 | 189 | @customElement("domain-items-editor") 190 | export class DomainItemsEditor extends BaseItemsEditor { 191 | @property({ attribute: false }) customization_domain?: LovelaceCardConfig[]; 192 | protected get customization() { 193 | return this.customization_domain; 194 | } 195 | } 196 | 197 | @customElement("alert-items-editor") 198 | export class AlertItemsEditor extends BaseItemsEditor { 199 | @property({ attribute: false }) customization_alert?: LovelaceCardConfig[]; 200 | protected get customization() { 201 | return this.customization_alert; 202 | } 203 | } 204 | 205 | @customElement("cover-items-editor") 206 | export class CoverItemsEditor extends BaseItemsEditor { 207 | @property({ attribute: false }) customization_cover?: LovelaceCardConfig[]; 208 | protected get customization() { 209 | return this.customization_cover; 210 | } 211 | } 212 | 213 | @customElement("sensor-items-editor") 214 | export class SensorItemsEditor extends BaseItemsEditor { 215 | @property({ attribute: false }) customization_sensor?: LovelaceCardConfig[]; 216 | protected get customization() { 217 | return this.customization_sensor; 218 | } 219 | } 220 | 221 | @customElement("popup-items-editor") 222 | export class PopupItemsEditor extends BaseItemsEditor { 223 | @property({ attribute: false }) customization_popup?: LovelaceCardConfig[]; 224 | protected get customization() { 225 | return this.customization_popup; 226 | } 227 | } 228 | 229 | @customElement("custom-buttons-editor") 230 | export class CustomButtonsEditor extends LitElement { 231 | @property({ attribute: false }) hass?: HomeAssistant; 232 | @property({ attribute: false }) custom_buttons?: any[]; 233 | 234 | private _editRow(ev: Event): void { 235 | ev.stopPropagation(); 236 | const index = (ev.currentTarget as any).index; 237 | fireEvent(this, "edit-item", index); 238 | } 239 | 240 | private _removeRow(ev: Event): void { 241 | ev.stopPropagation(); 242 | if (!this.custom_buttons) return; 243 | const index = (ev.currentTarget as any).index; 244 | const newButtons = [...this.custom_buttons]; 245 | newButtons.splice(index, 1); 246 | 247 | fireEvent(this, "config-changed", newButtons as any); 248 | } 249 | 250 | private _addRow(): void { 251 | const newButton = { 252 | name: "", 253 | icon: "", 254 | tap_action: { action: "none" }, 255 | }; 256 | const newButtons = [...(this.custom_buttons || []), newButton]; 257 | fireEvent(this, "config-changed", newButtons as any); 258 | } 259 | 260 | protected render() { 261 | if (!this.hass) { 262 | return nothing; 263 | } 264 | 265 | return html` 266 |
267 | ${this.custom_buttons?.map( 268 | (button, index) => html` 269 |
270 |
271 | ${(() => { 272 | const icon = button.icon; 273 | if (icon?.startsWith("M")) { 274 | return html``; 275 | } 276 | if (icon) { 277 | return html``; 278 | } 279 | return html``; 282 | })()} 283 | ${button.name || `Button ${index + 1}`} 286 |
287 | 293 | 299 |
300 | ` 301 | )} 302 |
303 | 304 | Add Custom Button 305 | 306 |
307 |
308 | `; 309 | } 310 | 311 | static styles = css` 312 | .row { 313 | display: flex; 314 | align-items: center; 315 | padding: 4px 0; 316 | } 317 | .item { 318 | flex-grow: 1; 319 | display: flex; 320 | align-items: center; 321 | gap: 8px; 322 | } 323 | .name { 324 | text-overflow: ellipsis; 325 | overflow: hidden; 326 | white-space: nowrap; 327 | font-size: 16px; 328 | } 329 | .add-btn { 330 | padding: 8px 16px; 331 | border: none; 332 | border-radius: 4px; 333 | cursor: pointer; 334 | background-color: var(--primary-color); 335 | color: white; 336 | font-weight: 500; 337 | -webkit-align-self: flex-start; 338 | -ms-flex-item-align: flex-start; 339 | align-self: flex-start; 340 | } 341 | ha-icon { 342 | color: var(--secondary-text-color); 343 | } 344 | .add-button-container { 345 | padding: 8px 0; 346 | text-align: right; 347 | } 348 | `; 349 | } 350 | -------------------------------------------------------------------------------- /src/item-editor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | LitElement, 3 | TemplateResult, 4 | html, 5 | css, 6 | CSSResult, 7 | PropertyValues, 8 | nothing, 9 | } from "lit"; 10 | import { customElement, property, state } from "lit/decorators.js"; 11 | import { HomeAssistant, LovelaceCardConfig, UiAction, Schema } from "./ha"; 12 | 13 | import memoizeOne from "memoize-one"; 14 | 15 | interface ItemConfig extends LovelaceCardConfig {} 16 | 17 | const ACTIONS: UiAction[] = [ 18 | "more-info", 19 | "toggle", 20 | "navigate", 21 | "url", 22 | "perform-action", 23 | "none", 24 | ]; 25 | 26 | const ACTIONS_ALERT: UiAction[] = [ 27 | "more-info", 28 | "navigate", 29 | "url", 30 | "perform-action", 31 | "none", 32 | ]; 33 | 34 | const ACTIONS_SENSOR: UiAction[] = [ 35 | "more-info", 36 | "navigate", 37 | "url", 38 | "perform-action", 39 | "none", 40 | ]; 41 | 42 | @customElement("item-editor") 43 | export class ItemEditor extends LitElement { 44 | @property({ attribute: false }) config?: LovelaceCardConfig; 45 | @property({ attribute: false }) hass?: HomeAssistant; 46 | @property({ type: Boolean }) useSensorSchema: boolean = false; 47 | @property({ attribute: false }) lovelace?: unknown; 48 | @state() private getSchema?: string; 49 | @state() private _config?: ItemConfig; 50 | @state() private _activeTab = "config"; 51 | 52 | updated(changedProperties: PropertyValues) { 53 | if (changedProperties.has("config") && this.config) { 54 | this._config = { ...this.config }; 55 | } 56 | } 57 | 58 | private _schemadomainConfig = memoizeOne(() => { 59 | const base: Schema[] = [ 60 | { name: "icon", selector: { icon: {} } }, 61 | { 62 | name: "color", 63 | selector: { ui_color: { default_color: "state", include_state: true } }, 64 | }, 65 | ]; 66 | 67 | if (this._config?.type === "climate") { 68 | base.unshift( 69 | { 70 | name: "display_mode", 71 | selector: { 72 | select: { 73 | mode: "dropdown", 74 | options: [ 75 | { value: "text", label: "Text" }, 76 | { value: "icon", label: "Icon" }, 77 | { value: "text_icon", label: "Text + Icon" }, 78 | ], 79 | }, 80 | }, 81 | }, 82 | { 83 | name: "show_set_temperature", 84 | selector: { 85 | boolean: {}, 86 | }, 87 | } 88 | ); 89 | } 90 | 91 | return base; 92 | }); 93 | 94 | private _schemadomainActions = memoizeOne(() => { 95 | const actions = ACTIONS; 96 | return [ 97 | { 98 | name: "tap_action", 99 | selector: { ui_action: { actions } }, 100 | }, 101 | { 102 | name: "double_tap_action", 103 | selector: { ui_action: { actions } }, 104 | }, 105 | { 106 | name: "hold_action", 107 | selector: { ui_action: { actions } }, 108 | }, 109 | ]; 110 | }); 111 | 112 | private _schemaalertConfig = memoizeOne(() => { 113 | return [ 114 | { name: "invert", selector: { boolean: {} } }, 115 | { name: "icon", selector: { icon: {} } }, 116 | { 117 | name: "color", 118 | selector: { ui_color: { default_color: "state", include_state: true } }, 119 | }, 120 | ]; 121 | }); 122 | 123 | private _schemaalertActions = memoizeOne(() => { 124 | const actions = ACTIONS_ALERT; 125 | return [ 126 | { 127 | name: "tap_action", 128 | selector: { ui_action: { actions } }, 129 | }, 130 | { 131 | name: "double_tap_action", 132 | selector: { ui_action: { actions } }, 133 | }, 134 | { 135 | name: "hold_action", 136 | selector: { ui_action: { actions } }, 137 | }, 138 | ]; 139 | }); 140 | 141 | private _schemasensorConfig = memoizeOne(() => { 142 | return [ 143 | { name: "invert", selector: { boolean: {} } }, 144 | { 145 | name: "color", 146 | selector: { ui_color: { default_color: "state", include_state: true } }, 147 | }, 148 | ]; 149 | }); 150 | 151 | private _schemasensorActions = memoizeOne(() => { 152 | const actions = ACTIONS_SENSOR; 153 | return [ 154 | { 155 | name: "tap_action", 156 | selector: { ui_action: { actions } }, 157 | }, 158 | { 159 | name: "double_tap_action", 160 | selector: { ui_action: { actions } }, 161 | }, 162 | { 163 | name: "hold_action", 164 | selector: { ui_action: { actions } }, 165 | }, 166 | ]; 167 | }); 168 | 169 | private _schemacustombuttonConfig = memoizeOne(() => { 170 | return [ 171 | { name: "name", selector: { text: {} } }, 172 | { name: "icon", selector: { icon: {} } }, 173 | { 174 | name: "color", 175 | selector: { ui_color: { default_color: "state", include_state: true } }, 176 | }, 177 | ]; 178 | }); 179 | 180 | private _schemacustombuttonActions = memoizeOne(() => { 181 | const actions = ACTIONS; 182 | return [ 183 | { 184 | name: "tap_action", 185 | selector: { ui_action: { actions } }, 186 | }, 187 | { 188 | name: "double_tap_action", 189 | selector: { ui_action: { actions } }, 190 | }, 191 | { 192 | name: "hold_action", 193 | selector: { ui_action: { actions } }, 194 | }, 195 | ]; 196 | }); 197 | 198 | private _schemaStyle = memoizeOne(() => { 199 | return [ 200 | { 201 | name: "styles", 202 | selector: { 203 | object: {}, 204 | }, 205 | }, 206 | ]; 207 | }); 208 | 209 | protected render(): TemplateResult { 210 | if (!this.hass || !this.config) { 211 | return html``; 212 | } 213 | 214 | const hass = this.hass; 215 | 216 | if (!this._config) { 217 | this._config = { ...this.config, area: this.config.area || "" }; 218 | } 219 | 220 | let schema: Schema[] | undefined; 221 | if (this._activeTab === "config") { 222 | switch (this.getSchema) { 223 | case "sensor": 224 | schema = this._schemasensorConfig(); 225 | break; 226 | case "domain": 227 | schema = this._schemadomainConfig(); 228 | break; 229 | case "alert": 230 | case "cover": 231 | schema = this._schemaalertConfig(); 232 | break; 233 | case "custom_button": 234 | schema = this._schemacustombuttonConfig(); 235 | break; 236 | } 237 | } else if (this._activeTab === "actions") { 238 | switch (this.getSchema) { 239 | case "sensor": 240 | schema = this._schemasensorActions(); 241 | break; 242 | case "domain": 243 | schema = this._schemadomainActions(); 244 | break; 245 | case "alert": 246 | case "cover": 247 | schema = this._schemaalertActions(); 248 | break; 249 | case "custom_button": 250 | schema = this._schemacustombuttonActions(); 251 | break; 252 | } 253 | } else if (this._activeTab === "style") { 254 | schema = this._schemaStyle(); 255 | } 256 | 257 | const data = { ...this._config }; 258 | 259 | return html` 260 | 261 | (this._activeTab = "config")} 264 | > 265 | ${hass.localize("ui.panel.lovelace.editor.edit_card.tab_config") ?? 266 | "Configuration"} 267 | 268 | (this._activeTab = "actions")} 271 | > 272 | ${hass.localize("ui.panel.lovelace.editor.card.generic.actions")} 273 | 274 | (this._activeTab = "style")} 277 | > 278 | Style 279 | 280 | ${this.getSchema !== "custom_button" 281 | ? html` 282 | (this._activeTab = "popup")} 285 | > 286 | Popup Card 287 | 288 | ` 289 | : ""} 290 | 291 | 292 | ${this._activeTab === "style" 293 | ? html` 294 | 295 |

296 | You can use standard CSS per identifier.
297 | Identifiers: 298 |

299 |
    300 |
  • button: Item Container (Background, Border)
  • 301 |
  • icon: Item Icon
  • 302 | ${this.getSchema === "custom_button" 303 | ? html`
  • name: Item Name (Label)
  • ` 304 | : nothing} 305 |
306 |

307 | Animations:
308 | spin, pulse, shake, blink, bounce 309 |

310 |

Example:

311 |
312 | button:
313 |   --mdc-icon-size: 24px;
314 |   color: green;          
315 | icon:
316 |   animation: spin 2s linear infinite;
317 |   --mdc-icon-size: 40px;
318 |   color: var(--primary-color);
319 | 
321 |
322 | ` 323 | : nothing} 324 | ${this._activeTab === "popup" 325 | ? this._renderPopupTab() 326 | : html` 327 | 334 | `} 335 | `; 336 | } 337 | 338 | private _renderPopupTab(): TemplateResult { 339 | const popupCard = this._config?.popup_card; 340 | 341 | if (!popupCard) { 342 | return html` 343 |
344 | 349 |
350 | `; 351 | } 352 | 353 | return html` 354 |
355 |
356 |

357 | Popup 358 | ${this.hass!.localize( 359 | "ui.panel.lovelace.editor.edit_card.tab_config" 360 | )} 361 |

362 | 367 | ${this.hass!.localize("ui.common.delete")} 368 | 369 |
370 | 376 |
377 | `; 378 | } 379 | private _cardPicked(ev: CustomEvent): void { 380 | ev.stopPropagation(); 381 | const config = ev.detail.config; 382 | this._updatePopupCard(config); 383 | } 384 | 385 | private _popupCardChanged(ev: CustomEvent): void { 386 | ev.stopPropagation(); 387 | const config = ev.detail.config; 388 | this._updatePopupCard(config); 389 | } 390 | 391 | private _updatePopupCard(popup_card: LovelaceCardConfig): void { 392 | if (!this._config) return; 393 | const updatedConfig = { 394 | ...this._config, 395 | popup_card, 396 | }; 397 | this._config = updatedConfig; 398 | this.dispatchEvent( 399 | new CustomEvent("config-changed", { 400 | detail: updatedConfig, 401 | }) 402 | ); 403 | } 404 | 405 | private _removePopupCard(): void { 406 | if (!this._config) return; 407 | const { popup_card, ...rest } = this._config; 408 | this._config = rest as LovelaceCardConfig; 409 | 410 | this.dispatchEvent( 411 | new CustomEvent("config-changed", { 412 | detail: this._config, 413 | }) 414 | ); 415 | } 416 | 417 | private _computeLabelCallback = (schema: Schema): string => { 418 | switch (schema.name) { 419 | case "color": 420 | return this.hass!.localize(`ui.panel.lovelace.editor.card.tile.color`); 421 | case "enable_popup_view": 422 | return ( 423 | this.hass!.localize("ui.common.enable") + 424 | " " + 425 | this.hass!.localize( 426 | "ui.panel.lovelace.editor.action-editor.actions.more-info" 427 | ) 428 | ); 429 | case "disable_toggle_action": 430 | return ( 431 | this.hass!.localize("ui.common.disable") + 432 | " " + 433 | this.hass!.localize( 434 | "ui.panel.lovelace.editor.card.generic.tap_action" 435 | ) 436 | ); 437 | case "styles": 438 | return "Styles"; 439 | case "display_mode": 440 | return "Display Mode"; 441 | case "popup_card": 442 | return "Change Popup Card Type"; 443 | case "icon": 444 | case "tap_action": 445 | case "hold_action": 446 | case "double_tap_action": 447 | return this.hass!.localize( 448 | `ui.panel.lovelace.editor.card.generic.${schema.name}` 449 | ); 450 | case "invert": 451 | case "invert_state": 452 | return this.hass!.localize( 453 | "ui.dialogs.entity_registry.editor.invert.label" 454 | ); 455 | case "name": 456 | return this.hass!.localize(`ui.common.name`); 457 | case "show_set_temperature": 458 | return "Show Set Temperature"; 459 | default: 460 | return this.hass!.localize( 461 | `ui.panel.lovelace.editor.card.area.${schema.name}` 462 | ); 463 | } 464 | }; 465 | 466 | private _valueChangedSchema(event: CustomEvent): void { 467 | if (!this.config) { 468 | return; 469 | } 470 | 471 | const updatedConfig = { 472 | ...this.config, 473 | ...event.detail.value, 474 | }; 475 | 476 | this._config = updatedConfig; 477 | 478 | this.dispatchEvent( 479 | new CustomEvent("config-changed", { 480 | detail: updatedConfig, 481 | }) 482 | ); 483 | } 484 | 485 | static get styles(): CSSResult { 486 | return css` 487 | .checkbox { 488 | display: flex; 489 | align-items: center; 490 | padding: 8px 0; 491 | } 492 | .checkbox input { 493 | height: 20px; 494 | width: 20px; 495 | margin-left: 0; 496 | margin-right: 8px; 497 | } 498 | h3 { 499 | margin-bottom: 0.5em; 500 | } 501 | .row { 502 | margin-bottom: 12px; 503 | margin-top: 12px; 504 | display: block; 505 | } 506 | .side-by-side { 507 | display: flex; 508 | } 509 | .side-by-side > * { 510 | flex: 1 1 0%; 511 | padding-right: 4px; 512 | } 513 | ha-tab-group { 514 | display: block; 515 | margin-bottom: 16px; 516 | padding: 0 1em; 517 | } 518 | ha-tab-group-tab { 519 | flex: 1; 520 | } 521 | ha-tab-group-tab::part(base) { 522 | width: 100%; 523 | justify-content: center; 524 | } 525 | `; 526 | } 527 | } 528 | -------------------------------------------------------------------------------- /src/popup-dialog.ts: -------------------------------------------------------------------------------- 1 | import { repeat } from "lit/directives/repeat.js"; 2 | import { LitElement, html, css, PropertyValues } from "lit"; 3 | import { property, state } from "lit/decorators.js"; 4 | import { 5 | LovelaceCard, 6 | HomeAssistant, 7 | computeDomain, 8 | UNAVAILABLE_STATES, 9 | STATES_OFF, 10 | Schema, 11 | } from "./ha"; 12 | import { HassEntity } from "home-assistant-js-websocket"; 13 | import { 14 | SENSOR_DOMAINS, 15 | ALERT_DOMAINS, 16 | COVER_DOMAINS, 17 | DOMAIN_ICONS, 18 | } from "./const"; 19 | import { mdiClose } from "@mdi/js"; 20 | import { computeLabelCallback, translateEntityState } from "./translations"; 21 | import memoizeOne from "memoize-one"; 22 | import { 23 | getEntitiesIndex, 24 | compareByFriendlyName, 25 | filterByCategory, 26 | getAreaEntityIds, 27 | getDevicesInArea, 28 | } from "./helpers"; 29 | 30 | const EMPTY_SET = new Set(); 31 | 32 | const OFF_STATES = [UNAVAILABLE_STATES, STATES_OFF]; 33 | 34 | export class AreaCardPlusPopup extends LitElement { 35 | connectedCallback(): void { 36 | super.connectedCallback(); 37 | window.addEventListener("popstate", this._onPopState); 38 | } 39 | 40 | disconnectedCallback(): void { 41 | super.disconnectedCallback(); 42 | window.removeEventListener("popstate", this._onPopState); 43 | this._cardEls.clear(); 44 | } 45 | 46 | private _onPopState = (ev: PopStateEvent) => { 47 | if (this.open) { 48 | this._onClosed(ev); 49 | } 50 | }; 51 | 52 | @property({ type: Boolean }) public open = false; 53 | @property({ type: String }) public selectedDomain?: string; 54 | @property({ type: String }) public selectedDeviceClass?: string; 55 | @property({ type: String }) public content = ""; 56 | @property({ type: Array }) public entities: HassEntity[] = []; 57 | @property({ attribute: false }) public hass?: HomeAssistant; 58 | @property({ attribute: false }) public card!: LovelaceCard & { 59 | areas?: any[]; 60 | entities?: any[]; 61 | devices?: any[]; 62 | _config?: any; 63 | selectedGroup?: number | null; 64 | selectedDomain?: string | null; 65 | getCustomizationForType?: (type: string) => any; 66 | _totalEntities?: (...args: any[]) => HassEntity[]; 67 | _isOn?: (...args: any[]) => HassEntity[]; 68 | _shouldShowTotalEntities?: (...args: any[]) => boolean; 69 | list_mode?: boolean; 70 | }; 71 | 72 | @state() public selectedGroup?: number; 73 | private _cardEls: Map = new Map(); 74 | 75 | public async showDialog(params: { 76 | title?: string; 77 | hass: HomeAssistant; 78 | entities?: HassEntity[]; 79 | content?: string; 80 | selectedDomain?: string; 81 | selectedDeviceClass?: string; 82 | selectedGroup?: number; 83 | card?: unknown; 84 | }): Promise { 85 | this.title = params.title ?? this.title; 86 | this.hass = params.hass; 87 | this.entities = params.entities ?? []; 88 | if (params.content !== undefined) this.content = params.content; 89 | this.selectedDomain = params.selectedDomain; 90 | this.selectedDeviceClass = params.selectedDeviceClass; 91 | this.selectedGroup = params.selectedGroup; 92 | this.card = params.card as LovelaceCard & { areas?: any[] }; 93 | this._cardEls.clear(); 94 | this.open = true; 95 | this.requestUpdate(); 96 | try { 97 | await this.updateComplete; 98 | } catch (_) {} 99 | this._applyDialogStyleAfterRender(); 100 | } 101 | 102 | private _applyDialogStyleAfterRender() { 103 | try { 104 | requestAnimationFrame(() => { 105 | try { 106 | this._applyDialogStyle(); 107 | } catch (_) {} 108 | }); 109 | } catch (_) { 110 | try { 111 | this._applyDialogStyle(); 112 | } catch (_) {} 113 | } 114 | } 115 | 116 | private _applyDialogStyle() { 117 | const surface = document 118 | .querySelector("body > home-assistant") 119 | ?.shadowRoot?.querySelector("area-card-plus-popup") 120 | ?.shadowRoot?.querySelector("ha-dialog") 121 | ?.shadowRoot?.querySelector( 122 | "div > div.mdc-dialog__container > div.mdc-dialog__surface" 123 | ) as HTMLElement | null; 124 | 125 | if (surface) { 126 | surface.style.minHeight = "unset"; 127 | return true; 128 | } 129 | return false; 130 | } 131 | 132 | protected firstUpdated(_changedProperties: PropertyValues): void { 133 | super.firstUpdated(_changedProperties); 134 | } 135 | 136 | private _onClosed = (_ev: Event) => { 137 | this.open = false; 138 | this._cardEls.clear(); 139 | this.dispatchEvent( 140 | new CustomEvent("dialog-closed", { 141 | bubbles: true, 142 | composed: true, 143 | detail: { dialog: this }, 144 | }) 145 | ); 146 | this.dispatchEvent( 147 | new CustomEvent("popup-closed", { 148 | bubbles: true, 149 | composed: true, 150 | detail: { dialog: this }, 151 | }) 152 | ); 153 | }; 154 | 155 | private _toTileConfig(cardConfig: { 156 | type: string; 157 | entity?: string; 158 | [k: string]: any; 159 | }) { 160 | return { 161 | type: "tile", 162 | entity: cardConfig.entity, 163 | }; 164 | } 165 | 166 | private async _createCardElement( 167 | hass: HomeAssistant, 168 | cardConfig: { type: string; entity?: string; [key: string]: any }, 169 | isFallback = false 170 | ): Promise { 171 | try { 172 | const helpers = await (window as any)?.loadCardHelpers?.(); 173 | if (helpers?.createCardElement) { 174 | const el = helpers.createCardElement(cardConfig) as LovelaceCard; 175 | (el as any).hass = hass; 176 | (el as any).setAttribute?.("data-hui-card", ""); 177 | return el; 178 | } 179 | } catch {} 180 | 181 | try { 182 | const type = cardConfig.type || "tile"; 183 | const isCustom = typeof type === "string" && type.startsWith("custom:"); 184 | const tag = isCustom ? type.slice(7) : `hui-${type}-card`; 185 | 186 | if (isCustom && !(customElements as any).get(tag)) { 187 | await customElements.whenDefined(tag).catch(() => {}); 188 | } 189 | 190 | const el = document.createElement(tag) as LovelaceCard; 191 | 192 | if (typeof el.setConfig === "function") { 193 | el.setConfig(cardConfig); 194 | } 195 | 196 | (el as any).hass = hass; 197 | (el as any).setAttribute?.("data-hui-card", ""); 198 | return el; 199 | } catch { 200 | if (!isFallback) { 201 | return this._createCardElement( 202 | hass, 203 | this._toTileConfig(cardConfig), 204 | true 205 | ); 206 | } 207 | const empty = document.createElement("div"); 208 | empty.setAttribute("data-hui-card", ""); 209 | return empty; 210 | } 211 | } 212 | 213 | private _getPopupCardConfig(entity: HassEntity) { 214 | const card: any = this.card; 215 | const domainFromEntity = computeDomain(entity.entity_id); 216 | 217 | const domain = this.selectedDomain || domainFromEntity; 218 | const deviceClass = this.selectedDomain 219 | ? this.selectedDeviceClass 220 | : (this.hass?.states?.[entity.entity_id]?.attributes as any) 221 | ?.device_class; 222 | 223 | const cfg = card?._config || {}; 224 | let customization: any | undefined; 225 | 226 | if (ALERT_DOMAINS.includes(domain)) { 227 | customization = cfg.customization_alert?.find( 228 | (c: any) => c.type === deviceClass 229 | ); 230 | if (!customization) 231 | customization = cfg.customization_domain?.find( 232 | (c: any) => c.type === domain 233 | ); 234 | } else if (SENSOR_DOMAINS.includes(domain)) { 235 | customization = cfg.customization_sensor?.find( 236 | (c: any) => c.type === deviceClass 237 | ); 238 | if (!customization) 239 | customization = cfg.customization_domain?.find( 240 | (c: any) => c.type === domain 241 | ); 242 | } else if (COVER_DOMAINS.includes(domain)) { 243 | customization = cfg.customization_cover?.find( 244 | (c: any) => c.type === deviceClass 245 | ); 246 | if (!customization) 247 | customization = cfg.customization_domain?.find( 248 | (c: any) => c.type === domain 249 | ); 250 | } else { 251 | customization = cfg.customization_domain?.find( 252 | (c: any) => c.type === domain 253 | ); 254 | } 255 | 256 | const popupCard = customization?.popup_card as any | undefined; 257 | 258 | const resolvedType: string = 259 | (popupCard && typeof popupCard.type === "string" && popupCard.type) || 260 | customization?.popup_card_type || 261 | "tile"; 262 | 263 | const baseOptions = 264 | resolvedType === "tile" 265 | ? (this.DOMAIN_FEATURES as any)[domainFromEntity] ?? {} 266 | : {}; 267 | 268 | let overrideOptions: any = {}; 269 | if (popupCard && typeof popupCard === "object") { 270 | const { type: _omitType, entity: _omitEntity, ...rest } = popupCard; 271 | overrideOptions = rest; 272 | } else { 273 | overrideOptions = customization?.popup_card_options ?? {}; 274 | } 275 | 276 | const finalConfig = { 277 | type: resolvedType, 278 | entity: entity.entity_id, 279 | ...baseOptions, 280 | ...overrideOptions, 281 | } as any; 282 | 283 | return finalConfig; 284 | } 285 | 286 | private DOMAIN_FEATURES: Record = { 287 | alarm_control_panel: { 288 | state_content: ["state", "last_changed"], 289 | features: [ 290 | { 291 | type: "alarm-modes", 292 | modes: [ 293 | "armed_home", 294 | "armed_away", 295 | "armed_night", 296 | "armed_vacation", 297 | "armed_custom_bypass", 298 | "disarmed", 299 | ], 300 | }, 301 | ], 302 | }, 303 | light: { 304 | state_content: ["state", "brightness", "last_changed"], 305 | features: [{ type: "light-brightness" }], 306 | }, 307 | cover: { 308 | state_content: ["state", "position", "last_changed"], 309 | features: [{ type: "cover-open-close" }, { type: "cover-position" }], 310 | }, 311 | vacuum: { 312 | state_content: ["state", "last_changed"], 313 | features: [ 314 | { 315 | type: "vacuum-commands", 316 | commands: [ 317 | "start_pause", 318 | "stop", 319 | "clean_spot", 320 | "locate", 321 | "return_home", 322 | ], 323 | }, 324 | ], 325 | }, 326 | climate: { 327 | state_content: ["state", "current_temperature", "last_changed"], 328 | features: [ 329 | { 330 | type: "climate-hvac-modes", 331 | hvac_modes: [ 332 | "auto", 333 | "heat_cool", 334 | "heat", 335 | "cool", 336 | "dry", 337 | "fan_only", 338 | "off", 339 | ], 340 | }, 341 | ], 342 | }, 343 | water_heater: { 344 | state_content: ["state", "last_changed"], 345 | features: [ 346 | { 347 | type: "water-heater-operation-modes", 348 | operation_modes: [ 349 | "electric", 350 | "gas", 351 | "heat_pump", 352 | "eco", 353 | "performance", 354 | "high_demand", 355 | "off", 356 | ], 357 | }, 358 | ], 359 | }, 360 | humidifier: { 361 | state_content: ["state", "current_humidity", "last_changed"], 362 | features: [{ type: "target-humidity" }], 363 | }, 364 | media_player: { 365 | show_entity_picture: true, 366 | state_content: ["state", "volume_level", "last_changed"], 367 | features: [{ type: "media-player-playback" }], 368 | }, 369 | lock: { 370 | state_content: ["state", "last_changed"], 371 | features: [{ type: "lock-commands" }], 372 | }, 373 | fan: { 374 | state_content: ["state", "percentage", "last_changed"], 375 | features: [{ type: "fan-speed" }], 376 | }, 377 | counter: { 378 | state_content: ["state", "last_changed"], 379 | features: [ 380 | { 381 | type: "counter-actions", 382 | actions: ["increment", "decrement", "reset"], 383 | }, 384 | ], 385 | }, 386 | lawn_mower: { 387 | state_content: ["state", "last_changed"], 388 | features: [ 389 | { 390 | type: "lawn-mower-commands", 391 | commands: ["start_pause", "dock"], 392 | }, 393 | ], 394 | }, 395 | update: { 396 | state_content: ["state", "latest_version", "last_changed"], 397 | features: [{ type: "update-actions", backup: "ask" }], 398 | }, 399 | switch: { 400 | state_content: ["state", "last_changed"], 401 | features: [{ type: "toggle" }], 402 | }, 403 | scene: { 404 | state_content: ["state", "last_changed"], 405 | features: [{ type: "button" }], 406 | }, 407 | script: { 408 | state_content: ["state", "last_changed"], 409 | features: [{ type: "button" }], 410 | }, 411 | input_boolean: { 412 | state_content: ["state", "last_changed"], 413 | features: [{ type: "toggle" }], 414 | }, 415 | calendar: { 416 | state_content: "message", 417 | }, 418 | timer: { 419 | state_content: ["state", "remaining_time"], 420 | }, 421 | binary_sensor: { 422 | state_content: ["state", "last_changed"], 423 | }, 424 | device_tracker: { 425 | state_content: ["state", "last_changed"], 426 | }, 427 | remote: { 428 | state_content: ["state", "last_changed"], 429 | }, 430 | valve: { 431 | state_content: ["state", "last_changed"], 432 | features: [{ type: "valve-open-close" }], 433 | }, 434 | }; 435 | 436 | private _getCandidateEntityIds = memoizeOne( 437 | ( 438 | registryEntitiesOrMap: any, 439 | cardConfig: any, 440 | areaId: string, 441 | devicesInArea: Set, 442 | popupDomains: string[], 443 | selectedDomain?: string | null, 444 | index?: Map> 445 | ): string[] => { 446 | const hiddenEntitiesSet = new Set( 447 | cardConfig?.hidden_entities || [] 448 | ); 449 | 450 | const candidates = getAreaEntityIds( 451 | areaId, 452 | devicesInArea as any, 453 | registryEntitiesOrMap, 454 | hiddenEntitiesSet, 455 | cardConfig?.label, 456 | index 457 | ); 458 | 459 | const categoryFilter = cardConfig?.category_filter; 460 | const filterByCategoryFn = (entityId: string) => 461 | filterByCategory(entityId, registryEntitiesOrMap, categoryFilter); 462 | 463 | return candidates.filter((entityId) => { 464 | if (!filterByCategoryFn(entityId)) return false; 465 | const domain = computeDomain(entityId); 466 | if (popupDomains.length > 0 && !popupDomains.includes(domain)) 467 | return false; 468 | if (selectedDomain && domain !== selectedDomain) return false; 469 | return true; 470 | }); 471 | } 472 | ); 473 | 474 | protected shouldUpdate(changedProps: PropertyValues): boolean { 475 | if (changedProps.has("open") || changedProps.has("card")) { 476 | return true; 477 | } 478 | 479 | if (!this.open) { 480 | return false; 481 | } 482 | 483 | if ( 484 | changedProps.has("selectedDomain") || 485 | changedProps.has("selectedDeviceClass") || 486 | changedProps.has("selectedGroup") || 487 | changedProps.has("entities") || 488 | changedProps.has("content") 489 | ) { 490 | return true; 491 | } 492 | 493 | if (!changedProps.has("hass")) { 494 | return false; 495 | } 496 | 497 | const oldHass = changedProps.get("hass") as HomeAssistant | undefined; 498 | if ( 499 | !oldHass || 500 | oldHass.themes !== this.hass!.themes || 501 | oldHass.locale !== this.hass!.locale 502 | ) { 503 | return true; 504 | } 505 | 506 | const card: any = this.card; 507 | if (!card) return false; 508 | 509 | const areaId: string = card._config?.area; 510 | const devicesArr: any = 511 | card._devices && Array.isArray(card._devices) 512 | ? card._devices 513 | : card.hass && card.hass.devices 514 | ? card.hass.devices 515 | : {}; 516 | 517 | const entitiesIndex = getEntitiesIndex( 518 | this.hass!.entities, 519 | this.hass!.devices 520 | ); 521 | 522 | const devicesInArea: Set = entitiesIndex 523 | ? EMPTY_SET 524 | : getDevicesInArea(areaId, devicesArr); 525 | 526 | const candidates = this._getCandidateEntityIds( 527 | this.hass!.entities, 528 | card._config, 529 | areaId, 530 | devicesInArea, 531 | card._config?.popup_domains || [], 532 | this.selectedDomain || null, 533 | entitiesIndex 534 | ); 535 | 536 | const extraEntities: string[] = card._config?.extra_entities || []; 537 | 538 | for (const id of candidates) { 539 | if (!oldHass.states[id] || oldHass.states[id] !== this.hass!.states[id]) { 540 | return true; 541 | } 542 | } 543 | 544 | for (const id of extraEntities) { 545 | if (!oldHass.states[id] || oldHass.states[id] !== this.hass!.states[id]) { 546 | return true; 547 | } 548 | } 549 | 550 | return false; 551 | } 552 | 553 | private _getOrCreateCard(entity: HassEntity): HTMLElement { 554 | const id = entity.entity_id; 555 | const existing = this._cardEls.get(id); 556 | if (existing) { 557 | try { 558 | (existing as any).hass = this.hass; 559 | } catch (_) {} 560 | return existing; 561 | } 562 | const placeholder = document.createElement("div"); 563 | placeholder.classList.add("card-placeholder"); 564 | placeholder.setAttribute("data-hui-card", ""); 565 | this._cardEls.set(id, placeholder); 566 | 567 | const cfg = this._getPopupCardConfig(entity); 568 | this._createCardElement(this.hass!, cfg).then((el) => { 569 | try { 570 | const current = this._cardEls.get(id); 571 | if (current === placeholder) { 572 | placeholder.replaceWith(el as any); 573 | this._cardEls.set(id, el as any); 574 | } 575 | (el as any).hass = this.hass; 576 | } catch (_) {} 577 | }); 578 | return placeholder; 579 | } 580 | 581 | public computeLabel = memoizeOne( 582 | (schema: Schema, domain?: string, deviceClass?: string): string => { 583 | return computeLabelCallback(this.hass!, schema); 584 | } 585 | ); 586 | 587 | private _isActive(e: HassEntity): boolean { 588 | return !OFF_STATES.flat().includes(e.state); 589 | } 590 | 591 | private sortEntitiesForPopup(entities: HassEntity[]): HassEntity[] { 592 | const mode = (this.card as any)?._config?.popup_sort || "name"; 593 | const arr = entities.slice(); 594 | if (mode === "state") { 595 | const cmp = compareByFriendlyName( 596 | this.hass!.states, 597 | this.hass!.locale.language 598 | ); 599 | return arr.sort((a, b) => { 600 | const aActive = this._isActive(a) ? 0 : 1; 601 | const bActive = this._isActive(b) ? 0 : 1; 602 | if (aActive !== bActive) return aActive - bActive; 603 | const aDom = computeDomain(a.entity_id); 604 | const bDom = computeDomain(b.entity_id); 605 | const aState = this.hass 606 | ? translateEntityState(this.hass, a.state, aDom) 607 | : a.state; 608 | const bState = this.hass 609 | ? translateEntityState(this.hass, b.state, bDom) 610 | : b.state; 611 | const s = (aState || "").localeCompare(bState || ""); 612 | if (s !== 0) return s; 613 | return cmp(a.entity_id, b.entity_id); 614 | }); 615 | } 616 | const cmp = compareByFriendlyName( 617 | this.hass!.states, 618 | this.hass!.locale.language 619 | ); 620 | return arr.sort((a, b) => cmp(a.entity_id, b.entity_id)); 621 | } 622 | 623 | protected render() { 624 | if (!this.open || !this.hass || !this.card) return html``; 625 | 626 | const card: any = this.card; 627 | const areaId: string = card._config?.area; 628 | const devicesArr: any = 629 | card._devices && Array.isArray(card._devices) 630 | ? card._devices 631 | : card.hass && card.hass.devices 632 | ? card.hass.devices 633 | : {}; 634 | 635 | const entitiesIndex = 636 | card.hass && card.hass.devices && card.hass.entities 637 | ? getEntitiesIndex(card.hass.entities, card.hass.devices) 638 | : undefined; 639 | const devicesInArea: Set = entitiesIndex 640 | ? EMPTY_SET 641 | : getDevicesInArea(areaId, devicesArr); 642 | 643 | const registryEntities: any[] = []; 644 | const states = this.hass.states; 645 | const popupDomains: string[] = card._config?.popup_domains || []; 646 | const hiddenEntities: string[] = card._config?.hidden_entities || []; 647 | const extraEntities: string[] = card._config?.extra_entities || []; 648 | const labelFilter: string[] | undefined = card._config?.label; 649 | const hideUnavailable: boolean | undefined = card._config?.hide_unavailable; 650 | const categoryFilter: string | undefined = card._config?.category_filter; 651 | const selectedDomain = this.selectedDomain || null; 652 | const selectedDeviceClass = this.selectedDeviceClass || null; 653 | const filterByCategoryFn = (entityId: string) => 654 | filterByCategory(entityId, this.hass!.entities, categoryFilter); 655 | 656 | let candidates: string[] = []; 657 | 658 | if (this.entities && this.entities.length > 0) { 659 | candidates = this.entities.reduce((acc, entity) => { 660 | const entityId = entity.entity_id; 661 | if (!filterByCategoryFn(entityId)) return acc; 662 | 663 | const domain = computeDomain(entityId); 664 | if (popupDomains.length > 0 && !popupDomains.includes(domain)) 665 | return acc; 666 | if (selectedDomain && domain !== selectedDomain) return acc; 667 | 668 | acc.push(entityId); 669 | return acc; 670 | }, []); 671 | } else { 672 | candidates = this._getCandidateEntityIds( 673 | this.hass.entities, 674 | card._config, 675 | areaId, 676 | devicesInArea, 677 | popupDomains, 678 | selectedDomain, 679 | entitiesIndex 680 | ); 681 | } 682 | 683 | let ents: HassEntity[] = []; 684 | for (const entityId of candidates) { 685 | const stateObj = states[entityId]; 686 | if (!stateObj) continue; 687 | 688 | if ( 689 | (hideUnavailable || false) && 690 | UNAVAILABLE_STATES.includes(stateObj.state) 691 | ) { 692 | continue; 693 | } 694 | 695 | if ( 696 | selectedDeviceClass && 697 | (stateObj.attributes as any).device_class !== selectedDeviceClass 698 | ) 699 | continue; 700 | ents.push(stateObj); 701 | } 702 | 703 | for (const extra of extraEntities) { 704 | const domain = computeDomain(extra); 705 | const st = states[extra]; 706 | if (!st) continue; 707 | if (popupDomains.length > 0 && !popupDomains.includes(domain)) continue; 708 | if (selectedDomain && domain !== selectedDomain) continue; 709 | if ( 710 | selectedDeviceClass && 711 | (st.attributes as any).device_class !== selectedDeviceClass 712 | ) 713 | continue; 714 | if ( 715 | filterByCategoryFn(extra) && 716 | !ents.some((e) => e.entity_id === extra) 717 | ) { 718 | ents.push(st); 719 | } 720 | } 721 | 722 | const ungroupAreas = card?._config?.ungroup_areas === true; 723 | let displayColumns = card._config?.columns ? card._config.columns : 4; 724 | 725 | let finalDomainEntries: Array<[string, HassEntity[]]> = []; 726 | let sorted: HassEntity[] = []; 727 | 728 | if (ungroupAreas) { 729 | sorted = this.sortEntitiesForPopup(ents); 730 | displayColumns = Math.min(displayColumns, Math.max(1, sorted.length)); 731 | } else { 732 | const byDomain: Record = {}; 733 | for (const e of ents) { 734 | const d = computeDomain(e.entity_id); 735 | if (!(d in byDomain)) byDomain[d] = []; 736 | byDomain[d].push(e); 737 | } 738 | 739 | const _iconOrder = Object.keys(DOMAIN_ICONS || {}); 740 | const sortOrder = popupDomains.length > 0 ? popupDomains : _iconOrder; 741 | finalDomainEntries = Object.entries(byDomain) 742 | .filter(([d]) => !selectedDomain || d === selectedDomain) 743 | .sort(([a], [b]) => { 744 | const ia = sortOrder.indexOf(a); 745 | const ib = sortOrder.indexOf(b); 746 | return ( 747 | (ia === -1 ? sortOrder.length : ia) - 748 | (ib === -1 ? sortOrder.length : ib) 749 | ); 750 | }) 751 | .map( 752 | ([d, list]) => 753 | [d, this.sortEntitiesForPopup(list)] as [string, HassEntity[]] 754 | ); 755 | 756 | const maxEntityCount = finalDomainEntries.length 757 | ? Math.max(...finalDomainEntries.map(([, list]) => list.length)) 758 | : 0; 759 | displayColumns = Math.min(displayColumns, Math.max(1, maxEntityCount)); 760 | } 761 | 762 | const hasEntities = finalDomainEntries.length > 0 || sorted.length > 0; 763 | 764 | if (!hasEntities) { 765 | return html` 766 | 771 |
772 | ${this.content || 773 | this.hass.localize("ui.panel.lovelace.cards.entity.no_entities") || 774 | "No entities"} 775 |
776 |
777 | `; 778 | } 779 | 780 | const area = card._area?.(card._config?.area, card.hass?.areas) ?? null; 781 | 782 | return html` 783 | 790 |
791 | 797 |
798 |

${card._config?.area_name || (area && (area as any).name)}

799 |
800 |
801 |
802 | ${ 803 | !ungroupAreas 804 | ? html`${repeat( 805 | finalDomainEntries, 806 | ([dom]) => dom, 807 | ([dom, list]) => html` 808 |
809 |

810 | ${dom === "binary_sensor" || 811 | dom === "sensor" || 812 | dom === "cover" 813 | ? this._getDomainName( 814 | dom, 815 | selectedDeviceClass || undefined 816 | ) 817 | : this._getDomainName(dom)} 818 |

819 |
820 | ${repeat( 821 | list, 822 | (entity: HassEntity) => entity.entity_id, 823 | (entity: HassEntity) => html` 824 |
825 | ${this._getOrCreateCard(entity)} 826 |
827 | ` 828 | )} 829 |
830 |
831 | ` 832 | )}` 833 | : html` 834 |
835 |
836 | ${sorted.map( 837 | (entity: HassEntity) => html` 838 |
839 | ${this._getOrCreateCard(entity)} 840 |
841 | ` 842 | )} 843 |
844 |
845 | ` 846 | } 847 |
848 | 849 |
850 | `; 851 | } 852 | 853 | private _getDomainName(domain: string, deviceClass?: string): string { 854 | if (!this.hass) return domain; 855 | if (domain === "scene") return "Scene"; 856 | if ( 857 | domain === "binary_sensor" || 858 | domain === "sensor" || 859 | domain === "cover" 860 | ) { 861 | return deviceClass 862 | ? this.hass.localize( 863 | `component.${domain}.entity_component.${deviceClass}.name` 864 | ) 865 | : this.hass.localize(`component.${domain}.entity_component._.name`); 866 | } 867 | return this.hass.localize(`component.${domain}.entity_component._.name`); 868 | } 869 | 870 | static styles = css` 871 | :host { 872 | display: block; 873 | } 874 | :host([hidden]) { 875 | display: none; 876 | } 877 | ha-dialog { 878 | --dialog-content-padding: 12px; 879 | --mdc-dialog-min-width: calc((var(--columns, 4) * 22.5vw) + 3vw); 880 | --mdc-dialog-max-width: calc((var(--columns, 4) * 22.5vw) + 5vw); 881 | box-sizing: border-box; 882 | overflow-x: auto; 883 | } 884 | .dialog-header { 885 | display: flex; 886 | justify-content: flex-start; 887 | align-items: center; 888 | gap: 8px; 889 | min-width: 15vw; 890 | position: sticky; 891 | top: 0; 892 | z-index: 10; 893 | border-bottom: 1px solid rgba(0, 0, 0, 0.07); 894 | background: transparent; 895 | } 896 | .dialog-header .menu-button { 897 | margin-left: auto; 898 | } 899 | .dialog-content.scrollable { 900 | margin-bottom: 16px; 901 | max-height: 80vh; 902 | overflow-y: auto; 903 | scrollbar-width: none; 904 | -ms-overflow-style: none; 905 | } 906 | .dialog-content.scrollable::-webkit-scrollbar { 907 | display: none; 908 | } 909 | .cards-wrapper { 910 | display: flex; 911 | flex-direction: column; 912 | align-items: center; 913 | justify-content: center; 914 | box-sizing: border-box; 915 | width: 100%; 916 | overflow-x: auto; 917 | } 918 | h4 { 919 | width: 100%; 920 | padding-left: 1.5em; 921 | box-sizing: border-box; 922 | font-size: 1.2em; 923 | margin: 0.6em 0; 924 | } 925 | .entity-cards { 926 | display: grid; 927 | grid-template-columns: repeat(var(--columns, 4), 22.5vw); 928 | gap: 4px; 929 | width: 100%; 930 | box-sizing: border-box; 931 | overflow-x: hidden; 932 | justify-content: center; 933 | } 934 | .entity-card { 935 | width: 22.5vw; 936 | box-sizing: border-box; 937 | } 938 | 939 | @media (max-width: 1200px) { 940 | ha-dialog { 941 | --mdc-dialog-min-width: 96vw; 942 | --mdc-dialog-max-width: 96vw; 943 | } 944 | .entity-card { 945 | width: 30vw; 946 | } 947 | .entity-cards { 948 | grid-template-columns: repeat(3, 30vw); 949 | } 950 | h4 { 951 | width: 100%; 952 | font-size: 1.2em; 953 | margin: 0.6em 0; 954 | padding: 0 1em; 955 | box-sizing: border-box; 956 | } 957 | } 958 | 959 | @media (max-width: 900px) { 960 | ha-dialog { 961 | --mdc-dialog-min-width: 96vw; 962 | --mdc-dialog-max-width: 96vw; 963 | } 964 | .entity-card { 965 | width: 45vw; 966 | } 967 | .entity-cards { 968 | grid-template-columns: repeat(2, 45vw); 969 | } 970 | h4 { 971 | width: 100%; 972 | font-size: 1.2em; 973 | margin: 0.6em 0; 974 | padding: 0 1em; 975 | box-sizing: border-box; 976 | } 977 | } 978 | 979 | @media (max-width: 700px) { 980 | ha-dialog { 981 | --dialog-content-padding: 8px; 982 | --mdc-dialog-min-width: 96vw; 983 | --mdc-dialog-max-width: 96vw; 984 | } 985 | .cards-wrapper { 986 | align-items: stretch; 987 | width: 100%; 988 | overflow-x: hidden; 989 | } 990 | .entity-card { 991 | width: 92vw; 992 | } 993 | .entity-cards { 994 | grid-template-columns: 1fr; 995 | } 996 | h4 { 997 | width: 100%; 998 | font-size: 1.2em; 999 | margin: 0.6em 0; 1000 | padding: 0 0.3em; 1001 | box-sizing: border-box; 1002 | } 1003 | } 1004 | `; 1005 | } 1006 | 1007 | customElements.define("area-card-plus-popup", AreaCardPlusPopup); 1008 | --------------------------------------------------------------------------------