fix!: Color are now aligned with HA > 2022.12

BREAKING CHANGE: this might break some of your color settings
Fix #635
This commit is contained in:
Jérôme Wiedemann 2023-07-23 16:29:12 +00:00
parent 0b3e4d331c
commit 685d55e49c
6 changed files with 306 additions and 36 deletions

View File

@ -58,6 +58,8 @@ import { myComputeStateDisplay } from './compute_state_display';
import copy from 'fast-copy';
import * as pjson from '../package.json';
import { deepEqual } from './deep-equal';
import { stateColorCss } from './state_color';
import { ON } from './const';
let helpers = (window as any).cardHelpers;
const helperPromise = new Promise(async (resolve) => {
@ -337,21 +339,10 @@ class ButtonCard extends LitElement {
}
}
private _getDefaultColorForState(state: HassEntity): string {
switch (state.state) {
case 'on':
return this._config!.color_on;
case 'off':
return this._config!.color_off;
default:
return this._config!.default_color;
}
}
private _getColorForLightEntity(state: HassEntity | undefined, useTemperature: boolean): string {
let color: string = this._config!.default_color;
if (state) {
if (state.state === 'on') {
if (state.state === ON) {
if (state.attributes.rgb_color) {
color = `rgb(${state.attributes.rgb_color.join(',')})`;
if (state.attributes.brightness) {
@ -372,12 +363,15 @@ class ButtonCard extends LitElement {
color = applyBrightnessToColor(color, (state.attributes.brightness + 245) / 5);
}
} else if (state.attributes.brightness) {
color = applyBrightnessToColor(this._getDefaultColorForState(state), (state.attributes.brightness + 245) / 5);
color = applyBrightnessToColor(
stateColorCss(state, state.state) || this._config!.default_color,
(state.attributes.brightness + 245) / 5,
);
} else {
color = this._getDefaultColorForState(state);
color = stateColorCss(state, state.state) || this._config!.default_color;
}
} else {
color = this._getDefaultColorForState(state);
color = stateColorCss(state, state.state) || this._config!.default_color;
}
}
return color;
@ -388,8 +382,6 @@ class ButtonCard extends LitElement {
let color: undefined | string;
if (configState?.color) {
colorValue = configState.color;
} else if (this._config!.color !== 'auto' && state?.state === 'off') {
colorValue = this._config!.color_off;
} else if (this._config!.color) {
colorValue = this._config!.color;
}
@ -398,7 +390,7 @@ class ButtonCard extends LitElement {
} else if (colorValue) {
color = colorValue;
} else if (state) {
color = this._getDefaultColorForState(state);
color = stateColorCss(state, state.state) || this._config!.default_color;
} else {
color = this._config!.default_color;
}
@ -982,8 +974,6 @@ class ButtonCard extends LitElement {
card_size: 3,
...template,
default_color: 'DUMMY',
color_off: 'DUMMY',
color_on: 'DUMMY',
lock: {
enabled: false,
duration: 5,
@ -1003,12 +993,6 @@ class ButtonCard extends LitElement {
};
}
this._config!.default_color = 'var(--primary-text-color)';
if (this._config!.color_type !== 'icon') {
this._config!.color_off = 'var(--card-background-color)';
} else {
this._config!.color_off = 'var(--paper-item-icon-color)';
}
this._config!.color_on = 'var(--paper-item-icon-active-color)';
const jsonConfig = JSON.stringify(this._config);
this._entities = [];

15
src/const.ts Normal file
View File

@ -0,0 +1,15 @@
export const UNAVAILABLE = 'unavailable';
const arrayLiteralIncludes = <T extends readonly unknown[]>(array: T) => (
searchElement: unknown,
fromIndex?: number,
): searchElement is T[number] => array.includes(searchElement as T[number], fromIndex);
export const UNKNOWN = 'unknown';
export const ON = 'on';
export const OFF = 'off';
export const UNAVAILABLE_STATES = [UNAVAILABLE, UNKNOWN] as const;
export const OFF_STATES = [UNAVAILABLE, UNKNOWN, OFF] as const;
export const isUnavailableState = arrayLiteralIncludes(UNAVAILABLE_STATES);
export const isOffState = arrayLiteralIncludes(OFF_STATES);

View File

@ -2,6 +2,8 @@ import { PropertyValues } from 'lit-element';
import tinycolor, { TinyColor } from '@ctrl/tinycolor';
import { HomeAssistant, LovelaceConfig } from 'custom-card-helpers';
import { StateConfig } from './types';
import { HassEntity, HassEntityAttributeBase, HassEntityBase } from 'home-assistant-js-websocket';
import { OFF, UNAVAILABLE, isUnavailableState } from './const';
export function computeDomain(entityId: string): string {
return entityId.substr(0, entityId.indexOf('.'));
@ -12,19 +14,49 @@ export function computeEntity(entityId: string): string {
}
export function getColorFromVariable(color: string): string {
if (color.substring(0, 3) === 'var') {
return window.getComputedStyle(document.documentElement).getPropertyValue(color.substring(4).slice(0, -1)).trim();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const colorArray: string[] = [];
let result = color;
if (color.trim().substring(0, 3) === 'var') {
color.split(',').forEach((singleColor) => {
const matches = singleColor.match(/var\(\s*([a-zA-Z0-9-]*)/);
if (matches) {
colorArray.push(matches[1]);
}
});
colorArray.some((color) => {
const root = window.getComputedStyle(document.documentElement).getPropertyValue(color);
if (root) {
result = root;
return true;
}
const customStyles = document.documentElement.getElementsByTagName('custom-style');
for (const element of customStyles) {
const value = window.getComputedStyle(element).getPropertyValue(color);
if (value) {
result = value;
return true;
}
}
return false;
});
}
return color;
return result;
}
export function getFontColorBasedOnBackgroundColor(backgroundColor: string): string {
const colorObj = new TinyColor(getColorFromVariable(backgroundColor));
if (colorObj.isValid && colorObj.getLuminance() > 0.5) {
return 'rgb(62, 62, 62)'; // bright colors - black font
} else {
return 'rgb(234, 234, 234)'; // dark colors - white font
const bgLuminance = new TinyColor(getColorFromVariable(backgroundColor)).getLuminance();
const light = new TinyColor({ r: 225, g: 225, b: 225 }).getLuminance();
const dark = new TinyColor({ r: 28, g: 28, b: 28 }).getLuminance();
if (bgLuminance === 0) {
return 'rgb(225, 225, 225)';
}
const whiteContrast = (Math.max(bgLuminance, light) + 0.05) / Math.min(bgLuminance, light + 0.05);
const blackContrast = (Math.max(bgLuminance, dark) + 0.05) / Math.min(bgLuminance, dark + 0.05);
return whiteContrast > blackContrast ? 'rgb(225, 225, 225)' : 'rgb(28, 28, 28)';
}
export function getLightColorBasedOnTemperature(current: number, min: number, max: number): string {
@ -172,3 +204,130 @@ export function getLovelace(): LovelaceConfig | null {
}
return null;
}
export function slugify(value: string, delimiter = '_'): string {
const a = 'àáäâãåăæąçćčđďèéěėëêęğǵḧìíïîįłḿǹńňñòóöôœøṕŕřßşśšșťțùúüûǘůűūųẃẍÿýźžż·/_,:;';
const b = `aaaaaaaaacccddeeeeeeegghiiiiilmnnnnooooooprrsssssttuuuuuuuuuwxyyzzz${delimiter}${delimiter}${delimiter}${delimiter}${delimiter}${delimiter}`;
const p = new RegExp(a.split('').join('|'), 'g');
return value
.toString()
.toLowerCase()
.replace(/\s+/g, delimiter) // Replace spaces with delimiter
.replace(p, (c) => b.charAt(a.indexOf(c))) // Replace special characters
.replace(/&/g, `${delimiter}and${delimiter}`) // Replace & with 'and'
.replace(/[^\w-]+/g, '') // Remove all non-word characters
.replace(/-/g, delimiter) // Replace - with delimiter
.replace(new RegExp(`(${delimiter})\\1+`, 'g'), '$1') // Replace multiple delimiters with single delimiter
.replace(new RegExp(`^${delimiter}+`), '') // Trim delimiter from start of text
.replace(new RegExp(`${delimiter}+$`), ''); // Trim delimiter from end of text
}
interface GroupEntityAttributes extends HassEntityAttributeBase {
entity_id: string[];
order: number;
auto?: boolean;
view?: boolean;
control?: 'hidden';
}
export interface GroupEntity extends HassEntityBase {
attributes: GroupEntityAttributes;
}
export const computeGroupDomain = (stateObj: GroupEntity): string | undefined => {
const entityIds = stateObj.attributes.entity_id || [];
const uniqueDomains = [...new Set(entityIds.map((entityId) => computeDomain(entityId)))];
return uniqueDomains.length === 1 ? uniqueDomains[0] : undefined;
};
export function stateActive(stateObj: HassEntity | undefined, state?: string): boolean {
if (stateObj === undefined) {
return false;
}
const domain = computeDomain(stateObj.entity_id);
const compareState = state !== undefined ? state : stateObj?.state;
if (['button', 'event', 'input_button', 'scene'].includes(domain)) {
return compareState !== UNAVAILABLE;
}
if (isUnavailableState(compareState)) {
return false;
}
// The "off" check is relevant for most domains, but there are exceptions
// such as "alert" where "off" is still a somewhat active state and
// therefore gets a custom color and "idle" is instead the state that
// matches what most other domains consider inactive.
if (compareState === OFF && domain !== 'alert') {
return false;
}
// Custom cases
switch (domain) {
case 'alarm_control_panel':
return compareState !== 'disarmed';
case 'alert':
// "on" and "off" are active, as "off" just means alert was acknowledged but is still active
return compareState !== 'idle';
case 'cover':
return compareState !== 'closed';
case 'device_tracker':
case 'person':
return compareState !== 'not_home';
case 'lock':
return compareState !== 'locked';
case 'media_player':
return compareState !== 'standby';
case 'vacuum':
return !['idle', 'docked', 'paused'].includes(compareState);
case 'plant':
return compareState === 'problem';
case 'group':
return ['on', 'home', 'open', 'locked', 'problem'].includes(compareState);
case 'timer':
return compareState === 'active';
case 'camera':
return compareState === 'streaming';
}
return true;
}
export const batteryStateColorProperty = (state: string): string | undefined => {
const value = Number(state);
if (isNaN(value)) {
return undefined;
}
if (value >= 70) {
return '--state-sensor-battery-high-color';
}
if (value >= 30) {
return '--state-sensor-battery-medium-color';
}
return '--state-sensor-battery-low-color';
};
export function computeCssVariable(props: string | string[]): string | undefined {
if (Array.isArray(props)) {
return props
.reverse()
.reduce<string | undefined>((str, variable) => `var(${variable}${str ? `, ${str}` : ''})`, undefined);
}
return `var(${props})`;
}
export function computeCssValue(prop: string | string[], computedStyles: CSSStyleDeclaration): string | undefined {
if (Array.isArray(prop)) {
for (const property of prop) {
const value = computeCssValue(property, computedStyles);
if (value) return value;
}
return undefined;
}
if (!prop.endsWith('-color')) {
return undefined;
}
return computedStyles.getPropertyValue(prop).trim() || undefined;
}

113
src/state_color.ts Normal file
View File

@ -0,0 +1,113 @@
/** Return an color representing a state. */
import { HassEntity } from 'home-assistant-js-websocket';
import { UNAVAILABLE } from './const';
import { computeGroupDomain, GroupEntity } from './helpers';
import { computeCssVariable } from './helpers';
import { computeDomain, slugify } from './helpers';
import { batteryStateColorProperty } from './helpers';
import { stateActive } from './helpers';
const STATE_COLORED_DOMAIN = new Set([
'alarm_control_panel',
'alert',
'automation',
'binary_sensor',
'calendar',
'camera',
'climate',
'cover',
'device_tracker',
'fan',
'group',
'humidifier',
'input_boolean',
'light',
'lock',
'media_player',
'person',
'plant',
'remote',
'schedule',
'script',
'siren',
'sun',
'switch',
'timer',
'update',
'vacuum',
]);
export const stateColorCss = (stateObj: HassEntity, state?: string): undefined | string => {
const compareState = state !== undefined ? state : stateObj?.state;
if (compareState === UNAVAILABLE) {
return `var(--state-unavailable-color)`;
}
const properties = stateColorProperties(stateObj, state);
if (properties) {
return computeCssVariable(properties);
}
return undefined;
};
export const domainStateColorProperties = (domain: string, stateObj: HassEntity, state?: string): string[] => {
const compareState = state !== undefined ? state : stateObj.state;
const active = stateActive(stateObj, state);
const properties: string[] = [];
const stateKey = slugify(compareState, '_');
const activeKey = active ? 'active' : 'inactive';
const dc = stateObj.attributes.device_class;
if (dc) {
properties.push(`--state-${domain}-${dc}-${stateKey}-color`);
}
properties.push(
`--state-${domain}-${stateKey}-color`,
`--state-${domain}-${activeKey}-color`,
`--state-${activeKey}-color`,
);
return properties;
};
export const stateColorProperties = (stateObj: HassEntity, state?: string): string[] | undefined => {
const compareState = state !== undefined ? state : stateObj?.state;
const domain = computeDomain(stateObj.entity_id);
const dc = stateObj.attributes.device_class;
// Special rules for battery coloring
if (domain === 'sensor' && dc === 'battery') {
const property = batteryStateColorProperty(compareState);
if (property) {
return [property];
}
}
// Special rules for group coloring
if (domain === 'group') {
const groupDomain = computeGroupDomain(stateObj as GroupEntity);
if (groupDomain && STATE_COLORED_DOMAIN.has(groupDomain)) {
return domainStateColorProperties(groupDomain, stateObj, state);
}
}
if (STATE_COLORED_DOMAIN.has(domain)) {
return domainStateColorProperties(domain, stateObj, state);
}
return undefined;
};
export const stateColorBrightness = (stateObj: HassEntity): string => {
if (stateObj.attributes.brightness && computeDomain(stateObj.entity_id) !== 'plant') {
// lowest brightness will be around 50% (that's pretty dark)
const brightness = stateObj.attributes.brightness;
return `brightness(${(brightness + 245) / 5}%)`;
}
return '';
};

View File

@ -4,6 +4,7 @@ export const styles = css`
:host {
position: relative;
display: block;
--state-inactive-color: var(--paper-item-icon-color);
}
ha-card {
cursor: pointer;

View File

@ -34,8 +34,6 @@ export interface ButtonCardConfig {
layout: Layout;
entity_picture_style?: CssStyleConfig[];
default_color: string;
color_on: string;
color_off: string;
custom_fields?: CustomFields;
variables?: Variables;
extra_styles?: string;