diff --git a/src/controls/dialog.ts b/src/controls/dialog.ts index 888df7d..0f58bcd 100644 --- a/src/controls/dialog.ts +++ b/src/controls/dialog.ts @@ -4,7 +4,7 @@ import { customElement, state } from 'lit/decorators.js'; import { classMap } from 'lit-html/directives/class-map.js'; import { Background } from '../core/colors/background'; import { Color } from '../core/colors/color'; -import { LightController } from '../core/light-controller'; +import { AreaLightController } from '../core/area-light-controller'; import { ViewUtils } from '../core/view-utils'; import { HueLikeLightCardConfig } from '../types/config'; import { Consts } from '../types/consts'; @@ -16,7 +16,7 @@ import { HueDialogLightTile, ILightSelectedEventDetail } from './dialog-light-ti import { ILightContainer } from '../types/types-interface'; import { ITileEventDetail } from './dialog-tile'; import { HueLightDetail } from './light-detail'; -import { LightContainer } from '../core/light-container'; +import { LightController } from '../core/light-controller'; import { HueHistoryStateManager, HueHistoryStep } from './history-state-manager'; import { localize } from '../localize/localize'; import { ActionHandler } from '../core/action-handler'; @@ -36,13 +36,13 @@ export class HueDialog extends IdLitElement { private _isRendered = false; private _config: HueLikeLightCardConfig; - private _ctrl: LightController; + private _ctrl: AreaLightController; private _actionHandler: ActionHandler; @state() private _selectedLight: ILightContainer | null; - public constructor(config: HueLikeLightCardConfig, lightController: LightController, actionHandler: ActionHandler) { + public constructor(config: HueLikeLightCardConfig, lightController: AreaLightController, actionHandler: ActionHandler) { super('HueDialog'); this._config = config; @@ -52,7 +52,7 @@ export class HueDialog extends IdLitElement { //#region Hass changes - private onLightControllerChanged(propertyName: keyof LightController) { + private onLightControllerChanged(propertyName: keyof AreaLightController) { // when LightController changed - update this if (propertyName == 'hass') { this.requestUpdate(); @@ -77,7 +77,7 @@ export class HueDialog extends IdLitElement { // set light into detail if (this._lightDetailElement) { - this._lightDetailElement.lightContainer = this._selectedLight; + this._lightDetailElement.lightContainer = this._selectedLight; this._lightDetailElement.show(); } }; diff --git a/src/controls/light-detail.ts b/src/controls/light-detail.ts index dab7480..e5d766a 100644 --- a/src/controls/light-detail.ts +++ b/src/controls/light-detail.ts @@ -7,7 +7,7 @@ import { Consts } from '../types/consts'; import { PropertyValues, css, unsafeCSS } from 'lit'; import { HueBrightnessRollup, IRollupValueChangeEventDetail } from './brightness-rollup'; import { HueColorTempPicker, HueColorTempPickerMarker, IHueColorTempPickerEventDetail } from './color-temp-picker'; -import { LightContainer } from '../core/light-container'; +import { LightController } from '../core/light-controller'; import { HueColorTempModeSelector } from './color-temp-mode-selector'; import { HaControlSwitch } from '../types/types-hass'; import { HueBigSwitch } from './big-switch'; @@ -32,7 +32,7 @@ export class HueLightDetail extends IdLitElement { } @property() - public lightContainer: LightContainer | null = null; + public lightContainer: LightController | null = null; /** * Called after new lightContainer is set. @@ -180,7 +180,7 @@ export class HueLightDetail extends IdLitElement { protected override updated(changedProps: PropertyValues): void { // register for changes on light if (changedProps.has('lightContainer')) { - const oldValue = changedProps.get('lightContainer') as LightContainer | null; + const oldValue = changedProps.get('lightContainer') as LightController | null; if (oldValue) { oldValue.unregisterOnPropertyChanged(this._id); } @@ -245,7 +245,7 @@ export class HueLightDetail extends IdLitElement { } `; - private _lastRenderedContainer: LightContainer | null; + private _lastRenderedContainer: LightController | null; protected override render() { this._lastRenderedContainer = this.lightContainer || this._lastRenderedContainer; const onlySwitch = this._lastRenderedContainer?.features.isEmpty() == true; @@ -257,7 +257,7 @@ export class HueLightDetail extends IdLitElement { `; } - private onSwitch(ctrl: LightContainer, ev: Event) { + private onSwitch(ctrl: LightController, ev: Event) { const target = ev.target; if (!target) return; diff --git a/src/core/action-handler.ts b/src/core/action-handler.ts index 05753e7..ec4fc4a 100644 --- a/src/core/action-handler.ts +++ b/src/core/action-handler.ts @@ -2,15 +2,15 @@ import { fireEvent } from 'custom-card-helpers'; import { HueDialog } from '../controls/dialog'; import { HueLikeLightCardConfig } from '../types/config'; import { ClickAction, ClickActionData, SceneData } from '../types/types-config'; -import { LightController } from './light-controller'; +import { AreaLightController } from './area-light-controller'; import { HueLikeLightCard } from '../hue-like-light-card'; export class ActionHandler { private _config: HueLikeLightCardConfig; - private _ctrl: LightController; + private _ctrl: AreaLightController; private _owner: HueLikeLightCard; - public constructor(config: HueLikeLightCardConfig, ctrl: LightController, element: HueLikeLightCard) { + public constructor(config: HueLikeLightCardConfig, ctrl: AreaLightController, element: HueLikeLightCard) { this._config = config; this._ctrl = ctrl; this._owner = element; diff --git a/src/core/area-light-controller.ts b/src/core/area-light-controller.ts new file mode 100644 index 0000000..232aa2e --- /dev/null +++ b/src/core/area-light-controller.ts @@ -0,0 +1,243 @@ +import { HomeAssistant } from 'custom-card-helpers'; +import { IHassTextTemplate, ILightContainer, ILightFeatures } from '../types/types-interface'; +import { Background } from './colors/background'; +import { Color } from './colors/color'; +import { GlobalLights } from './global-lights'; +import { HassTextTemplate, StaticTextTemplate } from './hass-text-template'; +import { LightController } from './light-controller'; +import { LightFeaturesCombined } from './light-features'; +import { NotifyBase } from './notify-base'; +import { IconHelper } from './icon-helper'; +import { localize } from '../localize/localize'; + +/** + * Serves as a controller for lights in single area. + * This can contain multiple lights even some interactions can be different. + * (Instead of turnOn, activate scene). + */ +export class AreaLightController extends NotifyBase implements ILightContainer { + private _hass: HomeAssistant; + private _lightGroup?: LightController; + private _lights: LightController[]; + private _lightsFeatures: LightFeaturesCombined; + private _defaultColor: Color; + + public constructor(entity_ids: string[], defaultColor: Color, lightGroupEntityId?: string) { + super(); + + // we need at least one + if (!entity_ids.length) + throw new Error('No entity specified.'); + + this._defaultColor = defaultColor; + this._lights = entity_ids.map(e => GlobalLights.getLightContainer(e)); + this._lightsFeatures = new LightFeaturesCombined(() => this._lights.map(l => l.features)); + if (lightGroupEntityId) { + this._lightGroup = GlobalLights.getLightContainer(lightGroupEntityId); + } + } + + /** + * Returns count of registered lights. + */ + public get count() { + return this._lights.length; + } + + /** + * @returns all lit lights. + */ + public getLitLights(): ILightContainer[] { + return this._lights.filter(l => l.isOn()); + } + + /** + * @returns all lights in this controller. + */ + public getLights(): ILightContainer[] { + return this._lights.map(l => l); // map will cause creation of new array + } + + public set hass(hass: HomeAssistant) { + this._hass = hass; + this._lights.forEach(l => l.hass = hass); + if (this._lightGroup) { + this._lightGroup.hass = hass; + } + this.raisePropertyChanged('hass'); + } + public get hass() { + return this._hass; + } + + public isOn(): boolean { + if (this._lightGroup) { + return this._lightGroup.isOn(); + } + return this._lights.some(l => l.isOn()); + } + public isOff(): boolean { + if (this._lightGroup) { + return this._lightGroup.isOff(); + } + return this._lights.every(l => l.isOff()); + } + public isUnavailable(): boolean { + if (this._lightGroup) { + return this._lightGroup.isUnavailable(); + } + return this._lights.every(l => l.isUnavailable()); + } + public turnOn(): void { + if (this._lightGroup) { + return this._lightGroup.turnOn(); + } + this._lights.filter(l => l.isOff()).forEach(l => l.turnOn()); + } + public turnOff(): void { + if (this._lightGroup) { + return this._lightGroup.turnOff(); + } + this._lights.filter(l => l.isOn()).forEach(l => l.turnOff()); + } + + public get brightnessValue() { + return this.valueGetFactory(); + } + public set brightnessValue(value: number) { + const litLights = this._lights.filter(l => l.isOn()); + // when only one light is on, set the value to that light + if (litLights.length == 1) { + litLights[0].brightnessValue = value; + return; + } + else if (litLights.length == 0) { // when no light is on, set value to all lights + this._lights.forEach(l => l.brightnessValue = value); + return; + } + + // get percentage change of remaining value + const oldValue = this.brightnessValue; + const valueChange = value - oldValue; + const remainingValue = valueChange > 0 ? (100 - this.brightnessValue) : this.brightnessValue; + const percentualChange = valueChange / remainingValue; // percentual of remaining + + // calculate the value for each light + this._lights.filter(l => l.isOn()).forEach(l => { + const lightOldValue = l.brightnessValue; + // of value of this light is the same asi value of controller, set it exactly to value + if (lightOldValue == oldValue) { + l.brightnessValue = value; + return; + } + + // get remaining part of this one light + const remainingLightValue = valueChange > 0 ? (100 - l.brightnessValue) : l.brightnessValue; + // compute value increment + const lightValueChange = Math.round(remainingLightValue * percentualChange); + // get new value + let newValue = l.brightnessValue + lightValueChange; + + // don't let the value drop to zero, if the target value isn't exactly zero + if (newValue < 1 && value > 0) { + newValue = 1; + } + l.brightnessValue = newValue; + }); + } + + private valueGetFactory() { + // get average from every light that is on + let total = 0; + let count = 0; + this._lights.forEach(e => { + if (e.isOn()) { + count++; + total += e.brightnessValue; + } + }); + if (count == 0) + return 0; + + const value = total / count * 1.0; + return value; + } + + public getIcon(): string { + if (this._lights.length == 1) { + return this._lights[0].getIcon() || IconHelper.getIcon(1); + } + + return IconHelper.getIcon(this._lights.length); + } + + public getTitle() { + if (this._lightGroup) { + return this._lightGroup.getTitle(); + } + + let title = ''; + for (let i = 0; i < this._lights.length && i < 3; i++) { + if (i > 0) { + title += ', '; + } + title += this._lights[i].getTitle(); + } + if (this._lights.length > 3) + title += ', ...'; + + return new StaticTextTemplate(title); + } + + /** + * @returns localized description of how many lights are on. + */ + public getDescription(description: string | undefined): IHassTextTemplate { + const total = this._lights.length; + let lit = 0; + this._lights.forEach(l => { + if (l.isOn()) { + lit++; + } + }); + + let result:string; + + if (description != null) { + if (description) { + result = description.replace('%s', lit.toString()); + return new HassTextTemplate(result); + } + result = ''; + } + else if (lit == 0) { + result = localize(this.hass, 'card.description.noLightsOn'); + } + else if (lit == total) { + result = localize(this.hass, 'card.description.allLightsOn'); + } + else if (lit == 1) { + result = localize(this.hass, 'card.description.oneLightOn'); + } + else { + result = localize(this.hass, 'card.description.someLightsAreOn', '%s', lit.toString()); + } + + return new StaticTextTemplate(result); + } + + public getBackground(): Background | null { + const backgrounds = this._lights.filter(l => l.isOn()).map(l => l.getBackground() || this._defaultColor); + if (backgrounds.length == 0) + return null; + return new Background(backgrounds); + } + + public getEntityId(): string { + throw Error('Cannot get entity id from LightController'); + } + + public get features(): ILightFeatures { + return this._lightsFeatures; + } +} \ No newline at end of file diff --git a/src/core/global-lights.ts b/src/core/global-lights.ts index 3fe0ea7..2a65031 100644 --- a/src/core/global-lights.ts +++ b/src/core/global-lights.ts @@ -1,16 +1,16 @@ -import { LightContainer } from './light-container'; +import { LightController } from './light-controller'; /** * Static class making LightContainer instances global. */ export class GlobalLights { - private static _containers:Record = {}; + private static _containers:Record = {}; - public static getLightContainer(entity_id: string): LightContainer { + public static getLightContainer(entity_id: string): LightController { let instance = this._containers[entity_id]; if (!instance) { //console.log(`[GlobalLights] Creating instance for '${entity_id}'`); - instance = new LightContainer(entity_id); + instance = new LightController(entity_id); this._containers[entity_id] = instance; } else { diff --git a/src/core/light-container.ts b/src/core/light-container.ts deleted file mode 100644 index de76e15..0000000 --- a/src/core/light-container.ts +++ /dev/null @@ -1,353 +0,0 @@ -import { HomeAssistant } from 'custom-card-helpers'; -import { HassLightColorMode, HassLightEntity } from '../types/types-hass'; -import { Consts } from '../types/consts'; -import { ensureEntityDomain } from '../types/extensions'; -import { ISingleLightContainer, ILightFeatures } from '../types/types-interface'; -import { Background } from './colors/background'; -import { Color } from './colors/color'; -import { StaticTextTemplate } from './hass-text-template'; -import { LightFeatures } from './light-features'; -import { TimeCache, TimeCacheValue } from './time-cache'; -import { NotifyBase } from './notify-base'; - -type CacheKeys = 'state' | 'brightnessValue' | 'colorMode' | 'colorTemp' | 'color'; - -export class LightContainer extends NotifyBase implements ISingleLightContainer { - private _entity_id: string; - private _hass: HomeAssistant; - private _entity: HassLightEntity; - private _entityFeatures: LightFeatures; - - public constructor(entity_id: string) { - super(); - - ensureEntityDomain(entity_id, 'light'); - - this._entity_id = entity_id; - - this.initTimeCache(); - } - - public set hass(value: HomeAssistant) { - this._hass = value; - if (!this._hass.states) { - throw new Error('No \'states\' available on passed hass instance.'); - } - - this._entity = this._hass.states[this._entity_id]; - if (!this._entity) { - throw new Error(`Entity '${this._entity_id}' not found in states.`); - } - - this._entityFeatures = new LightFeatures(this._entity); - this.raisePropertyChanged('hass'); - } - - //#region Info - - public getIcon() { - return this._entity && this._entity.attributes.icon; - } - - public getTitle() { - return new StaticTextTemplate(this._entity.attributes.friendly_name ?? this._entity_id); - } - - public getEntityId(): string { - return this._entity_id; - } - - public get features(): ILightFeatures { - return this._entityFeatures; - } - - //#endregion - - //#region TimeCache - - /* - * This TimeCache is here, so the UI control can react instantly on changes. - * When user do some change, it might take up to about 2 seconds for HA to register these changes on devices. - * So the cache is here to tell the UI that the expected change has happened instantly. - * After the specified interval, cached values are invalidated and in the moment of getting these values, live values are read from HA. - */ - - // TODO: also implement some change notify mechanizm - - private _cache: TimeCache; - private _lastOnBrightnessValue: number; - private _lastColorTemp: number | null; - - private initTimeCache(): void { - this._cache = new TimeCache(Consts.TimeCacheInterval);// ms - this._cache.registerProperty('state', () => new TimeCacheValue(this._entity?.state, this.getDontCacheState())); - this._cache.registerProperty('brightnessValue', () => new TimeCacheValue(this.brightnessValueGetFactory(), this.getDontCacheBrightnessValue())); - this._cache.registerProperty('colorMode', () => this.colorModeGetFactory()); - this._cache.registerProperty('colorTemp', () => this.colorTempGetFactory()); - this._cache.registerProperty('color', () => this.colorGetFactory()); - } - - private getDontCacheState(): boolean { - return !this._entity || this._entity.state == 'unavailable'; - } - private getDontCacheBrightnessValue(): boolean { - return this.getDontCacheState() || this._entity.attributes?.brightness == null; - } - - private notifyTurnOn(): void { - this._cache.setValue('state', 'on'); - if (this._lastOnBrightnessValue) { - this._cache.setValue('brightnessValue', this._lastOnBrightnessValue); - } - } - - private notifyTurnOff(): void { - this._cache.setValue('state', 'off'); - this._cache.setValue('brightnessValue', 0); - } - - private notifyBrightnessValueChanged(value: number): void { - if (value > 0) { - this._lastOnBrightnessValue = value; - } - this._cache.setValue('brightnessValue', value); - this._cache.setValue('state', value > 0 ? 'on' : 'off'); - } - - private notifyColorTempChanged(value: number): void { - this._lastColorTemp = value; - this._cache.setValue('colorTemp', value); - this._cache.setValue('colorMode', HassLightColorMode.color_temp); - } - - private notifyColorChanged(value: Color, mode: HassLightColorMode): void { - this._cache.setValue('colorTemp', null); - this._cache.setValue('colorMode', mode); - this._cache.setValue('color', value); - } - - //#endregion - - //#region State ON/OFF - - public isUnavailable(): boolean { - return this._cache.getValue('state') == 'unavailable'; - } - public isOn(): boolean { - return this._cache.getValue('state') == 'on'; - } - public isOff(): boolean { - return !this.isOn(); - } - public turnOn(): void { - this.toggle(true); - } - public turnOff(): void { - this.toggle(false); - } - public toggle(on: boolean) { - if (this.isUnavailable()) - return; - - if (on) { - this.notifyTurnOn(); - } - else { - this.notifyTurnOff(); - } - this._hass.callService('light', on ? 'turn_on' : 'turn_off', { entity_id: this._entity_id }); - } - - //#endregion - - //#region Brightness Value - - private brightnessValueGetFactory() { - if (this.isOff()) - return 0; - - const attr = this._entity.attributes; - const brightness = attr?.brightness ?? 255; - this._lastOnBrightnessValue = Math.round((brightness / 255.0) * 100); // brightness is 0-255 - return this._lastOnBrightnessValue; - } - public get brightnessValue() { - return this._cache.getValue('brightnessValue'); - } - public set brightnessValue(value: number) { - // just to be sure - if (value < 0) { - value = 0; - } - else if (value > 100) { - value = 100; - } - - this.notifyBrightnessValueChanged(value); - const brightness = Math.round((value / 100.0) * 255); // value is 0-100 - this._hass.callService('light', 'turn_on', { - entity_id: this._entity_id, - ['brightness']: brightness - }); - } - - //#endregion - - //#region Color mode - - private colorModeGetFactory(): TimeCacheValue { - let result = HassLightColorMode.unknown; - let dontCache = true; - if (this.isOn()) { - const entityMode = this._entity.attributes.color_mode; - if (entityMode) { - result = entityMode; - dontCache = false; - - // There is bug with unoriginal hue lights - // - color_temp is set only for a while, then the mode is switched back to xy (0,0) and temperature is not known - - // So, when we have last saved colortemp, and mode is xy = 00, then return color_temp - if (this._lastColorTemp && result == HassLightColorMode.xy && this._entity.attributes.xy_color) { - const [x, y] = this._entity.attributes.xy_color; - if (x === 0 && y === 0) { - result = HassLightColorMode.color_temp; - } - } - } - } - - return new TimeCacheValue(result, dontCache); - } - public get colorMode(): HassLightColorMode { - return this._cache.getValue('colorMode'); - } - - public isColorModeColor() { - const colorModes = [ - HassLightColorMode.hs, - HassLightColorMode.xy, - HassLightColorMode.rgb, - HassLightColorMode.rgbw, - HassLightColorMode.rgbww - ]; - - return colorModes.includes(this.colorMode); - } - - public isColorModeTemp(): boolean { - return this.colorMode == HassLightColorMode.color_temp; - } - - //#endregion - - //#region Color Temp - - private colorTempGetFactory(): TimeCacheValue { - // when is off or not in temp mode, return default - if (this.isOff() || !this.isColorModeTemp()) - return new TimeCacheValue(null, true); - - const attr = this._entity.attributes; - if (attr?.color_temp_kelvin) { - this._lastColorTemp = attr?.color_temp_kelvin; - } - return new TimeCacheValue(this._lastColorTemp, !this._lastColorTemp); - } - public get colorTemp(): number | null { - return this._cache.getValue('colorTemp'); - } - public set colorTemp(newTemp: number | null) { - if (!newTemp) - return; - - const minTemp = this._entity?.attributes?.min_color_temp_kelvin ?? 2000; - const maxTemp = this._entity?.attributes?.max_color_temp_kelvin ?? 6500; - - // just to be sure - if (newTemp < minTemp) { - newTemp = minTemp; - } - else if (newTemp > maxTemp) { - newTemp = maxTemp; - } - - this.notifyColorTempChanged(newTemp); - this._hass.callService('light', 'turn_on', { - entity_id: this._entity_id, - ['kelvin']: newTemp - }); - } - - //#endregion - - //#region Color - - private colorGetFactory() { - // when is off or not in color mode, return default - if (this.isOff() || !this.isColorModeColor()) - return new TimeCacheValue(null, true); - - const attr = this._entity?.attributes; - let result: Color | null = null; - if (attr.hs_color) { - const [h, s] = attr.hs_color; - result = new Color(h, s / 100, 1, 1, 'hsv'); - } - else if (attr.rgb_color) { - const [r, g, b] = attr.rgb_color; - result = new Color(r, g, b); - } - - return new TimeCacheValue(result, !result); - } - public get color(): Color | null { - return this._cache.getValue('color'); - } - public set color(newColor: Color | null) { - if (!newColor) - return; - - let mode: HassLightColorMode; - const serviceData: Record = { entity_id: this._entity_id }; - if (newColor.getOriginalMode() == 'hsv') { - mode = HassLightColorMode.hs; - serviceData.hs_color = [newColor.getHue(), newColor.getSaturation() * 100]; - } - else { - mode = HassLightColorMode.rgb; - serviceData.rgb_color = [newColor.getRed(), newColor.getGreen(), newColor.getBlue()]; - } - - this.notifyColorChanged(newColor, mode); - this._hass.callService('light', 'turn_on', serviceData); - } - - //#endregion - - private _lastBackground: Background; - public getBackground(): Background | null { - let temp: number | null; - let color: Color | null; - - let bgColor: Color | null = null; - if (this.isColorModeTemp() && (temp = this.colorTemp)) { - const [r, g, b] = Color.hueTempToRgb(temp); - bgColor = new Color(r, g, b); - } - else if (this.isColorModeColor() && (color = this.color)) { - bgColor = color; - } - - if (!bgColor) { - if (this._lastBackground) - return this._lastBackground; - - return null; - } - - this._lastBackground = new Background([bgColor]); - return this._lastBackground; - } -} - diff --git a/src/core/light-controller.ts b/src/core/light-controller.ts index b316cb5..c6438b2 100644 --- a/src/core/light-controller.ts +++ b/src/core/light-controller.ts @@ -1,238 +1,356 @@ import { HomeAssistant } from 'custom-card-helpers'; -import { IHassTextTemplate, ILightContainer, ILightFeatures } from '../types/types-interface'; +import { HassLightColorMode, HassLightEntity } from '../types/types-hass'; +import { Consts } from '../types/consts'; +import { ensureEntityDomain } from '../types/extensions'; +import { ISingleLightContainer, ILightFeatures } from '../types/types-interface'; import { Background } from './colors/background'; import { Color } from './colors/color'; -import { GlobalLights } from './global-lights'; -import { HassTextTemplate, StaticTextTemplate } from './hass-text-template'; -import { LightContainer } from './light-container'; -import { LightFeaturesCombined } from './light-features'; +import { StaticTextTemplate } from './hass-text-template'; +import { LightFeatures } from './light-features'; +import { TimeCache, TimeCacheValue } from './time-cache'; import { NotifyBase } from './notify-base'; -import { IconHelper } from './icon-helper'; -import { localize } from '../localize/localize'; -export class LightController extends NotifyBase implements ILightContainer { +type CacheKeys = 'state' | 'brightnessValue' | 'colorMode' | 'colorTemp' | 'color'; + +/** + * Serves as controller for single light. + */ +export class LightController extends NotifyBase implements ISingleLightContainer { + private _entity_id: string; private _hass: HomeAssistant; - private _lightGroup?: LightContainer; - private _lights: LightContainer[]; - private _lightsFeatures: LightFeaturesCombined; - private _defaultColor: Color; + private _entity: HassLightEntity; + private _entityFeatures: LightFeatures; - public constructor(entity_ids: string[], defaultColor: Color, lightGroupEntityId?: string) { + public constructor(entity_id: string) { super(); - // we need at least one - if (!entity_ids.length) - throw new Error('No entity specified.'); + ensureEntityDomain(entity_id, 'light'); + + this._entity_id = entity_id; + + this.initTimeCache(); + } - this._defaultColor = defaultColor; - this._lights = entity_ids.map(e => GlobalLights.getLightContainer(e)); - this._lightsFeatures = new LightFeaturesCombined(() => this._lights.map(l => l.features)); - if (lightGroupEntityId) { - this._lightGroup = GlobalLights.getLightContainer(lightGroupEntityId); + public set hass(value: HomeAssistant) { + this._hass = value; + if (!this._hass.states) { + throw new Error('No \'states\' available on passed hass instance.'); } + + this._entity = this._hass.states[this._entity_id]; + if (!this._entity) { + throw new Error(`Entity '${this._entity_id}' not found in states.`); + } + + this._entityFeatures = new LightFeatures(this._entity); + this.raisePropertyChanged('hass'); } - /** - * Returns count of registered lights. - */ - public get count() { - return this._lights.length; + //#region Info + + public getIcon() { + return this._entity && this._entity.attributes.icon; } - /** - * @returns all lit lights. - */ - public getLitLights(): ILightContainer[] { - return this._lights.filter(l => l.isOn()); + public getTitle() { + return new StaticTextTemplate(this._entity.attributes.friendly_name ?? this._entity_id); + } + + public getEntityId(): string { + return this._entity_id; } - /** - * @returns all lights in this controller. + public get features(): ILightFeatures { + return this._entityFeatures; + } + + //#endregion + + //#region TimeCache + + /* + * This TimeCache is here, so the UI control can react instantly on changes. + * When user do some change, it might take up to about 2 seconds for HA to register these changes on devices. + * So the cache is here to tell the UI that the expected change has happened instantly. + * After the specified interval, cached values are invalidated and in the moment of getting these values, live values are read from HA. */ - public getLights(): ILightContainer[] { - return this._lights.map(l => l); // map will cause creation of new array + + // TODO: also implement some change notify mechanizm + + private _cache: TimeCache; + private _lastOnBrightnessValue: number; + private _lastColorTemp: number | null; + + private initTimeCache(): void { + this._cache = new TimeCache(Consts.TimeCacheInterval);// ms + this._cache.registerProperty('state', () => new TimeCacheValue(this._entity?.state, this.getDontCacheState())); + this._cache.registerProperty('brightnessValue', () => new TimeCacheValue(this.brightnessValueGetFactory(), this.getDontCacheBrightnessValue())); + this._cache.registerProperty('colorMode', () => this.colorModeGetFactory()); + this._cache.registerProperty('colorTemp', () => this.colorTempGetFactory()); + this._cache.registerProperty('color', () => this.colorGetFactory()); } - public set hass(hass: HomeAssistant) { - this._hass = hass; - this._lights.forEach(l => l.hass = hass); - if (this._lightGroup) { - this._lightGroup.hass = hass; - } - this.raisePropertyChanged('hass'); + private getDontCacheState(): boolean { + return !this._entity || this._entity.state == 'unavailable'; } - public get hass() { - return this._hass; + private getDontCacheBrightnessValue(): boolean { + return this.getDontCacheState() || this._entity.attributes?.brightness == null; } - public isOn(): boolean { - if (this._lightGroup) { - return this._lightGroup.isOn(); + private notifyTurnOn(): void { + this._cache.setValue('state', 'on'); + if (this._lastOnBrightnessValue) { + this._cache.setValue('brightnessValue', this._lastOnBrightnessValue); } - return this._lights.some(l => l.isOn()); } - public isOff(): boolean { - if (this._lightGroup) { - return this._lightGroup.isOff(); + + private notifyTurnOff(): void { + this._cache.setValue('state', 'off'); + this._cache.setValue('brightnessValue', 0); + } + + private notifyBrightnessValueChanged(value: number): void { + if (value > 0) { + this._lastOnBrightnessValue = value; } - return this._lights.every(l => l.isOff()); + this._cache.setValue('brightnessValue', value); + this._cache.setValue('state', value > 0 ? 'on' : 'off'); } + + private notifyColorTempChanged(value: number): void { + this._lastColorTemp = value; + this._cache.setValue('colorTemp', value); + this._cache.setValue('colorMode', HassLightColorMode.color_temp); + } + + private notifyColorChanged(value: Color, mode: HassLightColorMode): void { + this._cache.setValue('colorTemp', null); + this._cache.setValue('colorMode', mode); + this._cache.setValue('color', value); + } + + //#endregion + + //#region State ON/OFF + public isUnavailable(): boolean { - if (this._lightGroup) { - return this._lightGroup.isUnavailable(); - } - return this._lights.every(l => l.isUnavailable()); + return this._cache.getValue('state') == 'unavailable'; + } + public isOn(): boolean { + return this._cache.getValue('state') == 'on'; + } + public isOff(): boolean { + return !this.isOn(); } public turnOn(): void { - if (this._lightGroup) { - return this._lightGroup.turnOn(); - } - this._lights.filter(l => l.isOff()).forEach(l => l.turnOn()); + this.toggle(true); } public turnOff(): void { - if (this._lightGroup) { - return this._lightGroup.turnOff(); + this.toggle(false); + } + public toggle(on: boolean) { + if (this.isUnavailable()) + return; + + if (on) { + this.notifyTurnOn(); + } + else { + this.notifyTurnOff(); } - this._lights.filter(l => l.isOn()).forEach(l => l.turnOff()); + this._hass.callService('light', on ? 'turn_on' : 'turn_off', { entity_id: this._entity_id }); } + //#endregion + + //#region Brightness Value + + private brightnessValueGetFactory() { + if (this.isOff()) + return 0; + + const attr = this._entity.attributes; + const brightness = attr?.brightness ?? 255; + this._lastOnBrightnessValue = Math.round((brightness / 255.0) * 100); // brightness is 0-255 + return this._lastOnBrightnessValue; + } public get brightnessValue() { - return this.valueGetFactory(); + return this._cache.getValue('brightnessValue'); } public set brightnessValue(value: number) { - const litLights = this._lights.filter(l => l.isOn()); - // when only one light is on, set the value to that light - if (litLights.length == 1) { - litLights[0].brightnessValue = value; - return; + // just to be sure + if (value < 0) { + value = 0; } - else if (litLights.length == 0) { // when no light is on, set value to all lights - this._lights.forEach(l => l.brightnessValue = value); - return; + else if (value > 100) { + value = 100; } - // get percentage change of remaining value - const oldValue = this.brightnessValue; - const valueChange = value - oldValue; - const remainingValue = valueChange > 0 ? (100 - this.brightnessValue) : this.brightnessValue; - const percentualChange = valueChange / remainingValue; // percentual of remaining + this.notifyBrightnessValueChanged(value); + const brightness = Math.round((value / 100.0) * 255); // value is 0-100 + this._hass.callService('light', 'turn_on', { + entity_id: this._entity_id, + ['brightness']: brightness + }); + } - // calculate the value for each light - this._lights.filter(l => l.isOn()).forEach(l => { - const lightOldValue = l.brightnessValue; - // of value of this light is the same asi value of controller, set it exactly to value - if (lightOldValue == oldValue) { - l.brightnessValue = value; - return; - } + //#endregion - // get remaining part of this one light - const remainingLightValue = valueChange > 0 ? (100 - l.brightnessValue) : l.brightnessValue; - // compute value increment - const lightValueChange = Math.round(remainingLightValue * percentualChange); - // get new value - let newValue = l.brightnessValue + lightValueChange; + //#region Color mode - // don't let the value drop to zero, if the target value isn't exactly zero - if (newValue < 1 && value > 0) { - newValue = 1; - } - l.brightnessValue = newValue; - }); - } + private colorModeGetFactory(): TimeCacheValue { + let result = HassLightColorMode.unknown; + let dontCache = true; + if (this.isOn()) { + const entityMode = this._entity.attributes.color_mode; + if (entityMode) { + result = entityMode; + dontCache = false; - private valueGetFactory() { - // get average from every light that is on - let total = 0; - let count = 0; - this._lights.forEach(e => { - if (e.isOn()) { - count++; - total += e.brightnessValue; + // There is bug with unoriginal hue lights + // - color_temp is set only for a while, then the mode is switched back to xy (0,0) and temperature is not known + + // So, when we have last saved colortemp, and mode is xy = 00, then return color_temp + if (this._lastColorTemp && result == HassLightColorMode.xy && this._entity.attributes.xy_color) { + const [x, y] = this._entity.attributes.xy_color; + if (x === 0 && y === 0) { + result = HassLightColorMode.color_temp; + } + } } - }); - if (count == 0) - return 0; + } - const value = total / count * 1.0; - return value; + return new TimeCacheValue(result, dontCache); + } + public get colorMode(): HassLightColorMode { + return this._cache.getValue('colorMode'); } - public getIcon(): string { - if (this._lights.length == 1) { - return this._lights[0].getIcon() || IconHelper.getIcon(1); - } + public isColorModeColor() { + const colorModes = [ + HassLightColorMode.hs, + HassLightColorMode.xy, + HassLightColorMode.rgb, + HassLightColorMode.rgbw, + HassLightColorMode.rgbww + ]; - return IconHelper.getIcon(this._lights.length); + return colorModes.includes(this.colorMode); } - public getTitle() { - if (this._lightGroup) { - return this._lightGroup.getTitle(); + public isColorModeTemp(): boolean { + return this.colorMode == HassLightColorMode.color_temp; + } + + //#endregion + + //#region Color Temp + + private colorTempGetFactory(): TimeCacheValue { + // when is off or not in temp mode, return default + if (this.isOff() || !this.isColorModeTemp()) + return new TimeCacheValue(null, true); + + const attr = this._entity.attributes; + if (attr?.color_temp_kelvin) { + this._lastColorTemp = attr?.color_temp_kelvin; } + return new TimeCacheValue(this._lastColorTemp, !this._lastColorTemp); + } + public get colorTemp(): number | null { + return this._cache.getValue('colorTemp'); + } + public set colorTemp(newTemp: number | null) { + if (!newTemp) + return; - let title = ''; - for (let i = 0; i < this._lights.length && i < 3; i++) { - if (i > 0) { - title += ', '; - } - title += this._lights[i].getTitle(); + const minTemp = this._entity?.attributes?.min_color_temp_kelvin ?? 2000; + const maxTemp = this._entity?.attributes?.max_color_temp_kelvin ?? 6500; + + // just to be sure + if (newTemp < minTemp) { + newTemp = minTemp; + } + else if (newTemp > maxTemp) { + newTemp = maxTemp; } - if (this._lights.length > 3) - title += ', ...'; - return new StaticTextTemplate(title); + this.notifyColorTempChanged(newTemp); + this._hass.callService('light', 'turn_on', { + entity_id: this._entity_id, + ['kelvin']: newTemp + }); } - /** - * @returns localized description of how many lights are on. - */ - public getDescription(description: string | undefined): IHassTextTemplate { - const total = this._lights.length; - let lit = 0; - this._lights.forEach(l => { - if (l.isOn()) { - lit++; - } - }); + //#endregion - let result:string; + //#region Color - if (description != null) { - if (description) { - result = description.replace('%s', lit.toString()); - return new HassTextTemplate(result); - } - result = ''; - } - else if (lit == 0) { - result = localize(this.hass, 'card.description.noLightsOn'); + private colorGetFactory() { + // when is off or not in color mode, return default + if (this.isOff() || !this.isColorModeColor()) + return new TimeCacheValue(null, true); + + const attr = this._entity?.attributes; + let result: Color | null = null; + if (attr.hs_color) { + const [h, s] = attr.hs_color; + result = new Color(h, s / 100, 1, 1, 'hsv'); } - else if (lit == total) { - result = localize(this.hass, 'card.description.allLightsOn'); + else if (attr.rgb_color) { + const [r, g, b] = attr.rgb_color; + result = new Color(r, g, b); } - else if (lit == 1) { - result = localize(this.hass, 'card.description.oneLightOn'); + + return new TimeCacheValue(result, !result); + } + public get color(): Color | null { + return this._cache.getValue('color'); + } + public set color(newColor: Color | null) { + if (!newColor) + return; + + let mode: HassLightColorMode; + const serviceData: Record = { entity_id: this._entity_id }; + if (newColor.getOriginalMode() == 'hsv') { + mode = HassLightColorMode.hs; + serviceData.hs_color = [newColor.getHue(), newColor.getSaturation() * 100]; } else { - result = localize(this.hass, 'card.description.someLightsAreOn', '%s', lit.toString()); + mode = HassLightColorMode.rgb; + serviceData.rgb_color = [newColor.getRed(), newColor.getGreen(), newColor.getBlue()]; } - return new StaticTextTemplate(result); + this.notifyColorChanged(newColor, mode); + this._hass.callService('light', 'turn_on', serviceData); } + //#endregion + + private _lastBackground: Background; public getBackground(): Background | null { - const backgrounds = this._lights.filter(l => l.isOn()).map(l => l.getBackground() || this._defaultColor); - if (backgrounds.length == 0) + let temp: number | null; + let color: Color | null; + + let bgColor: Color | null = null; + if (this.isColorModeTemp() && (temp = this.colorTemp)) { + const [r, g, b] = Color.hueTempToRgb(temp); + bgColor = new Color(r, g, b); + } + else if (this.isColorModeColor() && (color = this.color)) { + bgColor = color; + } + + if (!bgColor) { + if (this._lastBackground) + return this._lastBackground; + return null; - return new Background(backgrounds); - } + } - public getEntityId(): string { - throw Error('Cannot get entity id from LightController'); + this._lastBackground = new Background([bgColor]); + return this._lastBackground; } +} - public get features(): ILightFeatures { - return this._lightsFeatures; - } -} \ No newline at end of file diff --git a/src/hue-like-light-card.ts b/src/hue-like-light-card.ts index ab1de88..e1ed5f1 100644 --- a/src/hue-like-light-card.ts +++ b/src/hue-like-light-card.ts @@ -4,7 +4,7 @@ import { classMap } from 'lit-html/directives/class-map.js'; import { customElement } from 'lit/decorators.js'; import { ActionHandler } from './core/action-handler'; import { Background } from './core/colors/background'; -import { LightController } from './core/light-controller'; +import { AreaLightController } from './core/area-light-controller'; import { ViewUtils } from './core/view-utils'; import { HueLikeLightCardConfig } from './types/config'; import { Consts } from './types/consts'; @@ -32,7 +32,7 @@ VersionNotifier.toConsole(); export class HueLikeLightCard extends LitElement implements LovelaceCard { private _config?: HueLikeLightCardConfig; private _hass?: HomeAssistant; - private _ctrl?: LightController; + private _ctrl?: AreaLightController; private _actionHandler?: ActionHandler; private _error?: ErrorInfo; private _mc?: HammerManager; @@ -127,7 +127,7 @@ export class HueLikeLightCard extends LitElement implements LovelaceCard { if (this._config?.isInitialized != true) throw new Error('Config is not initialized.'); - this._ctrl = new LightController(this._config.getEntities(), this._config.getDefaultColor(), this._config.groupEntity); + this._ctrl = new AreaLightController(this._config.getEntities(), this._config.getDefaultColor(), this._config.groupEntity); this._actionHandler = new ActionHandler(this._config, this._ctrl, this); // For theme color set background to null