├── custom_components └── cozylife │ ├── __init__.py │ ├── manifest.json │ ├── const.py │ ├── services.yaml │ ├── utils.py │ ├── switch.py │ ├── tcp_client.py │ └── light.py ├── hacs.json ├── LICENSE ├── getconfig.py ├── .gitignore └── Readme.md /custom_components/cozylife/__init__.py: -------------------------------------------------------------------------------- 1 | """Example Load Platform integration.""" -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Cozylife", 3 | "domains": ["light", "switch"], 4 | "homeassistant": "2021.12.0" 5 | } -------------------------------------------------------------------------------- /custom_components/cozylife/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "cozylife", 3 | "name": "cozylife light", 4 | "documentation": "https://github.com/yangqian/hass-cozylife", 5 | "dependencies": [], 6 | "codeowners": ["yangqian","cozylife"], 7 | "requirements": [], 8 | "iot_class": "local_polling", 9 | "version": "0.4.0" 10 | } 11 | -------------------------------------------------------------------------------- /custom_components/cozylife/const.py: -------------------------------------------------------------------------------- 1 | DOMAIN = "cozylife" 2 | 3 | # http://doc.doit/project-5/doc-8/ 4 | SWITCH_TYPE_CODE = '00' 5 | LIGHT_TYPE_CODE = '01' 6 | SUPPORT_DEVICE_CATEGORY = [SWITCH_TYPE_CODE, LIGHT_TYPE_CODE] 7 | 8 | # http://doc.doit/project-5/doc-8/ 9 | SWITCH = '1' 10 | WORK_MODE = '2' 11 | TEMP = '3' 12 | BRIGHT = '4' 13 | HUE = '5' 14 | SAT = '6' 15 | 16 | LIGHT_DPID = [SWITCH, WORK_MODE, TEMP, BRIGHT, HUE, SAT] 17 | SWITCH_DPID = [SWITCH, ] 18 | -------------------------------------------------------------------------------- /custom_components/cozylife/services.yaml: -------------------------------------------------------------------------------- 1 | set_all_effect: 2 | name: Set all effect 3 | description: Set all the cozylight effect. 4 | fields: 5 | effect: 6 | name: Effect 7 | description: Light effect. 8 | required: true 9 | selector: 10 | select: 11 | options: 12 | - 'manual' 13 | - 'natural' 14 | - 'sleep' 15 | - 'warm' 16 | - 'study' 17 | - 'chrismas' 18 | set_effect: 19 | name: Set effect 20 | description: Set a the light effect. 21 | target: 22 | entity: 23 | integration: cozylife 24 | domain: light 25 | fields: 26 | effect: 27 | name: Effect 28 | description: Light effect. 29 | required: true 30 | selector: 31 | select: 32 | options: 33 | - 'manual' 34 | - 'natural' 35 | - 'sleep' 36 | - 'warm' 37 | - 'study' 38 | - 'chrismas' -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 yangqian 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /custom_components/cozylife/utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | import time 3 | import requests 4 | import logging 5 | 6 | _LOGGER = logging.getLogger(__name__) 7 | 8 | def get_sn() -> str: 9 | """ 10 | message sn 11 | :return: str 12 | """ 13 | return str(int(round(time.time() * 1000))) 14 | 15 | # cache get_pid_list result for many calls 16 | _CACHE_PID = [] 17 | 18 | def get_pid_list(lang='en') -> list: 19 | """ 20 | http://doc.doit/project-12/doc-95/ 21 | :param lang: 22 | :return: 23 | """ 24 | global _CACHE_PID 25 | if len(_CACHE_PID) != 0: 26 | return _CACHE_PID 27 | 28 | domain = 'api-us.doiting.com' 29 | protocol = 'http' 30 | url_prefix = protocol + '://' + domain 31 | try: 32 | res = requests.get(url_prefix + '/api/device_product/model', params={'lang': lang}, timeout=3) 33 | res.raise_for_status() # Raise an HTTPError for bad responses 34 | except requests.exceptions.RequestException as e: 35 | _LOGGER.error(f'Error making API request: {e}') 36 | return [] 37 | 38 | try: 39 | pid_list = res.json() 40 | except json.JSONDecodeError as e: 41 | _LOGGER.error(f'Error decoding JSON response: {e}') 42 | return [] 43 | 44 | if pid_list.get('ret') is None or pid_list['ret'] != '1': 45 | _LOGGER.info('get_pid_list.result is not as expected') 46 | return [] 47 | 48 | info = pid_list.get('info') 49 | if info is None or not isinstance(info, dict) or info.get('list') is None or not isinstance(info['list'], list): 50 | _LOGGER.info('get_pid_list.result structure is not as expected') 51 | return [] 52 | 53 | _CACHE_PID = info['list'] 54 | return _CACHE_PID -------------------------------------------------------------------------------- /getconfig.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from io import StringIO 3 | from custom_components.cozylife.tcp_client import tcp_client 4 | from ipaddress import ip_address 5 | 6 | def ips(start, end): 7 | '''Return IPs in IPv4 range, inclusive. from stackoverflow''' 8 | start_int = int(ip_address(start).packed.hex(), 16) 9 | end_int = int(ip_address(end).packed.hex(), 16) 10 | return [ip_address(ip).exploded for ip in range(start_int, end_int + 1)] 11 | 12 | start = '192.168.1.193' 13 | end = '192.168.1.254' 14 | 15 | if len(sys.argv) == 2: 16 | end = sys.argv[1] 17 | start = sys.argv[1] 18 | 19 | if len(sys.argv) > 2: 20 | end = sys.argv[2] 21 | start = sys.argv[1] 22 | 23 | probelist = ips(start, end) 24 | print("IP scan from {0}, end with {1}".format(probelist[0], probelist[-1])) 25 | 26 | lights_buf = StringIO() 27 | switches_buf = StringIO() 28 | 29 | for ip in probelist: 30 | a = tcp_client(ip, timeout=0.1) 31 | 32 | a._initSocket() 33 | 34 | if a._connect: 35 | device_info = a._device_info() 36 | device_info_str = f' - ip: {ip}\n' 37 | device_info_str += f' did: {a._device_id}\n' 38 | device_info_str += f' pid: {a._pid}\n' 39 | device_info_str += f' dmn: {a._device_model_name}\n' 40 | device_info_str += f' dpid: {a._dpid}\n' 41 | # device_info_str += f' device_type: {a._device_type_code}\n' 42 | 43 | if a._device_type_code == '01': 44 | lights_buf.write(device_info_str) 45 | elif a._device_type_code == '00': 46 | switches_buf.write(device_info_str) 47 | 48 | print(f'light:') 49 | print(f'- platform: cozylife') 50 | print(f' lights:') 51 | print(lights_buf.getvalue()) 52 | 53 | print(f'switch:') 54 | print(f'- platform: cozylife') 55 | print(f' switches:') 56 | print(switches_buf.getvalue()) 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /config 2 | config2/* 3 | 4 | tests/testing_config/deps 5 | tests/testing_config/home-assistant.log* 6 | 7 | # hass-release 8 | data/ 9 | .token 10 | 11 | # Hide sublime text stuff 12 | *.sublime-project 13 | *.sublime-workspace 14 | 15 | # Hide some OS X stuff 16 | .DS_Store 17 | .AppleDouble 18 | .LSOverride 19 | Icon 20 | 21 | # Thumbnails 22 | ._* 23 | 24 | # IntelliJ IDEA 25 | .idea 26 | *.iml 27 | 28 | # pytest 29 | .pytest_cache 30 | .cache 31 | 32 | # GITHUB Proposed Python stuff: 33 | *.py[cod] 34 | 35 | # C extensions 36 | *.so 37 | 38 | # Packages 39 | *.egg 40 | *.egg-info 41 | dist 42 | build 43 | eggs 44 | .eggs 45 | parts 46 | bin 47 | var 48 | sdist 49 | develop-eggs 50 | .installed.cfg 51 | lib 52 | lib64 53 | pip-wheel-metadata 54 | 55 | # Logs 56 | *.log 57 | pip-log.txt 58 | 59 | # Unit test / coverage reports 60 | .coverage 61 | .tox 62 | coverage.xml 63 | nosetests.xml 64 | htmlcov/ 65 | test-reports/ 66 | test-results.xml 67 | test-output.xml 68 | 69 | # Translations 70 | *.mo 71 | 72 | # Mr Developer 73 | .mr.developer.cfg 74 | .project 75 | .pydevproject 76 | 77 | .python-version 78 | 79 | # emacs auto backups 80 | *~ 81 | *# 82 | *.orig 83 | 84 | # venv stuff 85 | pyvenv.cfg 86 | pip-selfcheck.json 87 | venv 88 | .venv 89 | Pipfile* 90 | share/* 91 | /Scripts/ 92 | 93 | # vimmy stuff 94 | *.swp 95 | *.swo 96 | tags 97 | ctags.tmp 98 | 99 | # vagrant stuff 100 | virtualization/vagrant/setup_done 101 | virtualization/vagrant/.vagrant 102 | virtualization/vagrant/config 103 | 104 | # Visual Studio Code 105 | .vscode/* 106 | !.vscode/cSpell.json 107 | !.vscode/extensions.json 108 | !.vscode/tasks.json 109 | .env 110 | 111 | # Built docs 112 | docs/build 113 | 114 | # Windows Explorer 115 | desktop.ini 116 | /home-assistant.pyproj 117 | /home-assistant.sln 118 | /.vs/* 119 | 120 | # mypy 121 | /.mypy_cache/* 122 | /.dmypy.json 123 | 124 | # Secrets 125 | .lokalise_token 126 | 127 | # monkeytype 128 | monkeytype.sqlite3 129 | 130 | # This is left behind by Azure Restore Cache 131 | tmp_cache 132 | 133 | # python-language-server / Rope 134 | .ropeproject 135 | /homeassistant 136 | /.idea 137 | /__pycache__ 138 | -------------------------------------------------------------------------------- /custom_components/cozylife/switch.py: -------------------------------------------------------------------------------- 1 | """Platform for sensor integration.""" 2 | from __future__ import annotations 3 | import logging 4 | from .tcp_client import tcp_client 5 | from datetime import timedelta 6 | import asyncio 7 | 8 | from homeassistant.components.switch import SwitchEntity 9 | from homeassistant.core import HomeAssistant 10 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 11 | from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType 12 | from homeassistant.helpers.event import async_track_time_interval 13 | 14 | from typing import Any, Final, Literal, TypedDict, final 15 | from .const import ( 16 | DOMAIN, 17 | SWITCH_TYPE_CODE, 18 | LIGHT_TYPE_CODE, 19 | LIGHT_DPID, 20 | SWITCH, 21 | WORK_MODE, 22 | TEMP, 23 | BRIGHT, 24 | HUE, 25 | SAT, 26 | ) 27 | 28 | SCAN_INTERVAL = timedelta(seconds=20) 29 | 30 | _LOGGER = logging.getLogger(__name__) 31 | _LOGGER.info(__name__) 32 | 33 | SCAN_INTERVAL = timedelta(seconds=240) 34 | 35 | async def async_setup_platform( 36 | hass: HomeAssistant, 37 | config: ConfigType, 38 | async_add_devices: AddEntitiesCallback, 39 | discovery_info: DiscoveryInfoType | None = None 40 | ) -> None: 41 | """Set up the sensor platform.""" 42 | # We only want this platform to be set up via discovery. 43 | # logging.info('setup_platform', hass, config, add_entities, discovery_info) 44 | _LOGGER.info('setup_platform') 45 | #_LOGGER.info(f'ip={hass.data[DOMAIN]}') 46 | 47 | #if discovery_info is None: 48 | # return 49 | 50 | 51 | switches = [] 52 | for item in config.get('switches') or []: 53 | client = tcp_client(item.get('ip')) 54 | client._device_id = item.get('did') 55 | client._pid = item.get('pid') 56 | client._dpid = item.get('dpid') 57 | client.name = item.get('name') 58 | client._device_model_name = item.get('dmn') 59 | switches.append(CozyLifeSwitch(client, hass)) 60 | 61 | async_add_devices(switches) 62 | for switch in switches: 63 | await hass.async_add_executor_job(switch._tcp_client._initSocket) 64 | await asyncio.sleep(0.01) 65 | 66 | async def async_update(now=None): 67 | for switch in switches: 68 | await hass.async_add_executor_job(switch._refresh_state) 69 | await asyncio.sleep(0.01) 70 | async_track_time_interval(hass, async_update, SCAN_INTERVAL) 71 | 72 | 73 | class CozyLifeSwitch(SwitchEntity): 74 | _tcp_client = None 75 | _attr_is_on = True 76 | 77 | def __init__(self, tcp_client: tcp_client, hass) -> None: 78 | """Initialize the sensor.""" 79 | _LOGGER.info('__init__') 80 | self.hass = hass 81 | self._tcp_client = tcp_client 82 | self._unique_id = tcp_client.device_id 83 | self._name = tcp_client.name or tcp_client.device_id[-4:] 84 | self._refresh_state() 85 | 86 | @property 87 | def unique_id(self) -> str | None: 88 | """Return a unique ID.""" 89 | return self._unique_id 90 | 91 | async def async_update(self): 92 | await self.hass.async_add_executor_job(self._refresh_state) 93 | 94 | def _refresh_state(self): 95 | self._state = self._tcp_client.query() 96 | _LOGGER.info(f'_name={self._name},_state={self._state}') 97 | if self._state: 98 | self._attr_is_on = 0 < self._state['1'] 99 | 100 | @property 101 | def name(self) -> str: 102 | return 'cozylife:' + self._name 103 | 104 | @property 105 | def available(self) -> bool: 106 | """Return if the device is available.""" 107 | if self._tcp_client._connect: 108 | return True 109 | else: 110 | return False 111 | 112 | @property 113 | def is_on(self) -> bool: 114 | """Return True if entity is on.""" 115 | return self._attr_is_on 116 | 117 | async def async_turn_on(self, **kwargs: Any) -> None: 118 | """Turn the entity on.""" 119 | self._attr_is_on = True 120 | 121 | _LOGGER.info(f'turn_on:{kwargs}') 122 | 123 | await self.hass.async_add_executor_job(self._tcp_client.control, { 124 | '1': 1 125 | }) 126 | 127 | return None 128 | 129 | async def async_turn_off(self, **kwargs: Any) -> None: 130 | """Turn the entity off.""" 131 | self._attr_is_on = False 132 | 133 | _LOGGER.info('turn_off') 134 | 135 | await self.hass.async_add_executor_job(self._tcp_client.control, { 136 | '1': 0 137 | }) 138 | 139 | return None 140 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # hass-cozylife 2 | 3 | ## What is it 4 | 5 | This a third-party home assistant custom components works for Cozylife Lights based on [the official repository](https://github.com/cozylife/hass_cozylife_local_pull). The official repo is buggy in too many ways. This one heavily modified. 6 | 7 | * It is a pure local version and does not use UDP discovery. 8 | 9 | * The color temperature is fixed. 10 | 11 | 12 | ## features 13 | 14 | * heartbeat to each bulb in a fix time interval to test the availability. Even if the bulb is not available during the time of setup or later, it can pick it up if the bulb goes online again. 15 | * async 16 | * fixed the color temperature 17 | 18 | 19 | ## Tested Product 20 | 21 | Tested it on [color bulbs (no homekit)](https://detail.1688.com/offer/617699711703.html?spm=a2615.2177701.autotrace-offerGeneral.1.12be5799WNMB96). 22 | It can be initialized though bluetooth. 23 | 24 | It has also been tested on [color bulbs (with homekit)](https://www.aliexpress.com/item/4001365774507.html). It can run both Apple homekit and Home Assistant simultaneously. 25 | 26 | Switch and CW lights are not tested yet. 27 | CW lights should work. 28 | 29 | ## How I Setup the bulb 30 | 31 | The bulb will phone home to dohome.doiting.com. I blocked the dns request (you might also be able to block the internet access entirely, have not tested). This makes the registration process half complete. 32 | But the app could transmit the wifi name and password to the bulb. 33 | In principle, if you complete the full registration with the cloud, the bulb will respond to UDP discovery. 34 | However, my bulbs does not respond to UDP discovery, not sure if is because the code I used was buggy. 35 | 36 | Instead, we run a TCP scan on the operating port 5555 through a specific ip range. 37 | 38 | Note that for we must have persistent IP address otherwise the config will change. Thus can be done on most routers. 39 | 40 | ### Sample config 41 | 42 | Run the following getconfig.py with two parameters ip start and ip end. 43 | ``` 44 | python3 getconfig.py 192.168.1.193 192.168.1.194 45 | ``` 46 | to obtain something like 47 | ``` 48 | light: 49 | - platform: cozylife 50 | lights: 51 | - ip: 192.168.1.193 52 | did: 637929887cb94c4cffff 53 | pid: p93sfg 54 | dmn: Smart Bulb Light 55 | dpid: [1, 2, 3, 4, 5, 7, 8, 9, 13, 14] 56 | - ip: 192.168.1.194 57 | did: 637929887cb94c4ceeee 58 | pid: p93sfg 59 | dmn: Smart Bulb Light 60 | dpid: [1, 2, 3, 4, 5, 7, 8, 9, 13, 14] 61 | ``` 62 | 63 | Copy it to the relevant configuration file (yaml). Here the did is the same as of the official app (unique id). pid, dmn, dpid are also the same as the official app. 64 | 65 | ### Optional requirements 66 | 67 | [Circadian Lighting](https://github.com/claytonjn/hass-circadian_lighting) 68 | 69 | I used a modified v1 version (with Astral v2 dependance). v2 is not tested. 70 | 71 | ## Notes and todo 72 | 73 | * Nothing is encrypted. If it talks to the cloud, I guess it also talks to the cloud unencrypted. In the file model.json, even the ota update file is not encrypted. If someone could crack it. One might be able to flash custom firmware via OTA. 74 | 75 | * Implement effects, the formats are listed below 76 | 77 | * The color is not accurate at all (to be fixed? I am not sensitive to colors). 78 | 79 | ### summary for control parameters: 80 | 81 | * '1': on off switch 82 | * '2': 0 normal change to color / color temperature with a transition period 83 | 84 | * '2': 1 with special effects 85 | * '4': brightness 86 | * '5': hue 87 | * '6': saturation 88 | * '8': speed of change 89 | * '7': consists of two bits of operator and 7 colors 90 | 91 | * colors are in the format of HHHH SSSS TTTT 92 | where HHHH SSSS are hue and saturation. 93 | TTTT is color temperature. 94 | * In color mode, TTTT=FFFF. 95 | * In white mode, HHHH SSSS =FFFF FFFF. 96 | 97 | ### operator code for effects: 98 | 99 | #### List (applicable to color or color temperature, or a mix of the two) 100 | 101 | 102 | 04: 1 on off rotation 103 | 05: 2 on off rotation 104 | 06: 3 on off rotation 105 | 07: 7 on off rotation 106 | 107 | 08: 1 slowly diming 108 | 09: 2 slowly diming 109 | 0a: 3 slowly diming 110 | 0b: 7 slowly diming 111 | 112 | 0c: 1 fast flash 113 | 0d: 2 fast flash 114 | 0e: 3 fast flash 115 | 0f: 7 fast flash 116 | 117 | #### Only for color 118 | 119 | 00: 1 smooth change color rotation 120 | 01: 2 smooth change color rotation 121 | 02: 3 smooth change color rotation 122 | 03: 7 smooth change color rotation (rainbow effects) 123 | 124 | #### Note for color temperature 125 | 126 | 00: brief dim and increase back up '8' does not matter, 01 FFFF FFFF TTTT. 127 | 01: identical to mode '2':0, but it is a sudden change instead of a smooth transition. 128 | 129 | #### Raw status obtained from the app 130 | 131 | ##### gorgeous (rainbow) 132 | 133 | {'1': 1, 134 | '2': 1, 135 | '4': 1000, 136 | '7': '03 0000 03E8 FFFF 0078 03E8 FFFF 00F0 03E8 FFFF 003C 03E8 FFFF 00B4 03E8 FFFF 010E 03E8 FFFF 0026 03E8 FFFF', 137 | '8': 500} 138 | 0078: 120 139 | 00F0: 240 140 | 003C: 60 141 | 00B4: 180 142 | 010E: 270 143 | 0026: 38 144 | 145 | ##### Dazzling (3 color green blue red) 146 | 147 | {'1': 1, 148 | '2': 1, 149 | '4': 1000, 150 | '7': '06 0000 03E8 FFFF 0078 03E8 FFFF 00F0 03E8 FFFF 000000000000000000000000000000000000000000000000', 151 | '8': 800} 152 | 153 | ##### Profusion (7 colors) 154 | 155 | {'1': 1,gg 156 | '2': 1, 157 | '4': 1000, 158 | '7': '07000003E8FFFF007803E8FFFF00F003E8FFFF003C03E8FFFF00B403E8FFFF010E03E8FFFF002603E8FFFF', 159 | '8': 800} 160 | 161 | 162 | ##### Single color 163 | 164 | Soft (0078: 120 03E8: 1000) 165 | {'1': 1, 166 | '2': 1, 167 | '4': 1000, 168 | '7': '08 0078 03E8 FFFF F000 00000000000000000000000000000000000000000000000000000000000000000000', 169 | '8': 500} 170 | 171 | Casual: (brightness 500, color temp 500) 172 | 173 | {'1': 1, 174 | '2': 1, 175 | '4': 500, 176 | '7': '01 FFFF FFFF 01F4 FFFF FFFF 01F4000000000000000000000000000000000000000000000000000000000000', 177 | '8': 1000} 178 | 179 | Work: (brightness 1000, color temp 1000) 180 | 181 | {'1': 1, 182 | '2': 1, 183 | '4': 1000, 184 | '7': '01 FFFF FFFF 03E8 FFFF FFFF 03E8 000000000000000000000000000000000000000000000000000000000000', 185 | '8': 1000} 186 | 187 | Goodnight: (brightness 100, color temp 0) 188 | 189 | {'1': 1, 190 | '2': 1, 191 | '4': 100, 192 | '7': '01 FFFF FFFF 0000 FFFF FFFF 0000 000000000000000000000000000000000000000000000000000000000000', 193 | '8': 1000} 194 | 195 | Reading: (brightness 1000, color temp middle, 01F4: 500) 196 | 197 | {'1': 1, 198 | '2': 1, 199 | '4': 1000, 200 | '7': '01 FFFF FFFF 01F4 FFFF FFFF 01F4 000000000000000000000000000000000000000000000000000000000000', 201 | '8': 1000} 202 | -------------------------------------------------------------------------------- /custom_components/cozylife/tcp_client.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import json 3 | import socket 4 | import time 5 | from typing import Optional, Union, Any 6 | import logging 7 | try: 8 | from .utils import get_pid_list, get_sn 9 | except: 10 | from utils import get_pid_list, get_sn 11 | 12 | CMD_INFO = 0 13 | CMD_QUERY = 2 14 | CMD_SET = 3 15 | CMD_LIST = [CMD_INFO, CMD_QUERY, CMD_SET] 16 | _LOGGER = logging.getLogger(__name__) 17 | 18 | 19 | class tcp_client(object): 20 | """ 21 | Represents a device 22 | send:{"cmd":0,"pv":0,"sn":"1636463553873","msg":{}} 23 | receiver:{"cmd":0,"pv":0,"sn":"1636463553873","msg":{"did":"629168597cb94c4c1d8f","dtp":"02","pid":"e2s64v", 24 | "mac":"7cb94c4c1d8f","ip":"192.168.123.57","rssi":-33,"sv":"1.0.0","hv":"0.0.1"},"res":0} 25 | 26 | send:{"cmd":2,"pv":0,"sn":"1636463611798","msg":{"attr":[0]}} 27 | receiver:{"cmd":2,"pv":0,"sn":"1636463611798","msg":{"attr":[1,2,3,4,5,6],"data":{"1":0,"2":0,"3":1000,"4":1000, 28 | "5":65535,"6":65535}},"res":0} 29 | 30 | send:{"cmd":3,"pv":0,"sn":"1636463662455","msg":{"attr":[1],"data":{"1":0}}} 31 | receiver:{"cmd":3,"pv":0,"sn":"1636463662455","msg":{"attr":[1],"data":{"1":0}},"res":0} 32 | receiver:{"cmd":10,"pv":0,"sn":"1636463664000","res":0,"msg":{"attr":[1,2,3,4,5,6],"data":{"1":0,"2":0,"3":1000, 33 | "4":1000,"5":65535,"6":65535}}} 34 | """ 35 | _ip = str 36 | _port = 5555 37 | _connect = None # socket 38 | 39 | _device_id = str # str 40 | # _device_key = str 41 | _pid = str 42 | _device_type_code = str 43 | _icon = str 44 | _device_model_name = str 45 | _dpid = [] 46 | # last sn 47 | _sn = str 48 | 49 | def __init__(self, ip, timeout=3): 50 | self._ip = ip 51 | self.timeout = timeout 52 | 53 | def disconnect(self): 54 | if self._connect: 55 | try: 56 | #self._connect.shutdown(socket.SHUT_RDWR) 57 | self._connect.close() 58 | except: 59 | pass 60 | self._connect = None 61 | 62 | def __del__(self): 63 | self.disconnect() 64 | 65 | def _initSocket(self): 66 | try: 67 | s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 68 | s.settimeout(self.timeout) 69 | s.connect((self._ip, self._port)) 70 | self._connect = s 71 | except: 72 | _LOGGER.info(f'_initSocketerror,ip={self._ip}') 73 | self.disconnect() 74 | 75 | @property 76 | def check(self) -> bool: 77 | """ 78 | Determine whether the device is filtered 79 | :return: 80 | """ 81 | return True 82 | 83 | @property 84 | def dpid(self): 85 | return self._dpid 86 | 87 | @property 88 | def device_model_name(self): 89 | return self._device_model_name 90 | 91 | @property 92 | def icon(self): 93 | return self._icon 94 | 95 | @property 96 | def device_type_code(self) -> str: 97 | return self._device_type_code 98 | 99 | @property 100 | def device_id(self): 101 | return self._device_id 102 | 103 | def _device_info(self) -> None: 104 | """ 105 | get info for device model 106 | :return: 107 | """ 108 | self._only_send(CMD_INFO, {}) 109 | try: 110 | try: 111 | resp = self._connect.recv(1024) 112 | except: 113 | self.disconnect() 114 | self._initSocket() 115 | return None 116 | resp_json = json.loads(resp.strip()) 117 | except: 118 | _LOGGER.info('_device_info.recv.error') 119 | return None 120 | 121 | if resp_json.get('msg') is None or type(resp_json['msg']) is not dict: 122 | _LOGGER.info('_device_info.recv.error1') 123 | 124 | return None 125 | 126 | if resp_json['msg'].get('did') is None: 127 | _LOGGER.info('_device_info.recv.error2') 128 | 129 | return None 130 | self._device_id = resp_json['msg']['did'] 131 | 132 | if resp_json['msg'].get('pid') is None: 133 | _LOGGER.info('_device_info.recv.error3') 134 | return None 135 | 136 | self._pid = resp_json['msg']['pid'] 137 | 138 | pid_list = get_pid_list() 139 | for item in pid_list: 140 | match = False 141 | for item1 in item['device_model']: 142 | if item1['device_product_id'] == self._pid: 143 | match = True 144 | self._icon = item1['icon'] 145 | self._device_model_name = item1['device_model_name'] 146 | self._dpid = item1['dpid'] 147 | break 148 | 149 | if match: 150 | self._device_type_code = item['device_type_code'] 151 | break 152 | 153 | # _LOGGER.info(pid_list) 154 | _LOGGER.info(self._device_id) 155 | _LOGGER.info(self._device_type_code) 156 | _LOGGER.info(self._pid) 157 | _LOGGER.info(self._device_model_name) 158 | _LOGGER.info(self._icon) 159 | 160 | def _get_package(self, cmd: int, payload: dict) -> bytes: 161 | """ 162 | package message 163 | :param cmd:int: 164 | :param payload: 165 | :return: 166 | """ 167 | self._sn = get_sn() 168 | if CMD_SET == cmd: 169 | message = { 170 | 'pv': 0, 171 | 'cmd': cmd, 172 | 'sn': self._sn, 173 | 'msg': { 174 | 'attr': [int(item) for item in payload.keys()], 175 | 'data': payload, 176 | } 177 | } 178 | elif CMD_QUERY == cmd: 179 | message = { 180 | 'pv': 0, 181 | 'cmd': cmd, 182 | 'sn': self._sn, 183 | 'msg': { 184 | 'attr': [0], 185 | } 186 | } 187 | elif CMD_INFO == cmd: 188 | message = { 189 | 'pv': 0, 190 | 'cmd': cmd, 191 | 'sn': self._sn, 192 | 'msg': {} 193 | } 194 | else: 195 | raise Exception('CMD is not valid') 196 | 197 | payload_str = json.dumps(message, separators=(',', ':',)) 198 | _LOGGER.info(f'_package={payload_str}') 199 | return bytes(payload_str + "\r\n", encoding='utf8') 200 | 201 | def _send_receiver(self, cmd: int, payload: dict) -> Union[dict, Any]: 202 | """ 203 | send & receiver 204 | :param cmd: 205 | :param payload: 206 | :return: 207 | """ 208 | try: 209 | self._connect.send(self._get_package(cmd, payload)) 210 | except: 211 | try: 212 | self.disconnect() 213 | self._initSocket() 214 | self._connect.send(self._get_package(cmd, payload)) 215 | except: 216 | pass 217 | try: 218 | i = 10 219 | while i > 0: 220 | res = self._connect.recv(1024) 221 | # print(f'res={res},sn={self._sn},{self._sn in str(res)}') 222 | i -= 1 223 | # only allow same sn 224 | if self._sn in str(res): 225 | payload = json.loads(res.strip()) 226 | if payload is None or len(payload) == 0: 227 | return None 228 | 229 | if payload.get('msg') is None or type(payload['msg']) is not dict: 230 | return None 231 | 232 | if payload['msg'].get('data') is None or type(payload['msg']['data']) is not dict: 233 | return None 234 | 235 | return payload['msg']['data'] 236 | 237 | return None 238 | 239 | except Exception as e: 240 | # print(f'e={e}') 241 | _LOGGER.info(f'_only_send.recv.error:{e}') 242 | return None 243 | 244 | def _only_send(self, cmd: int, payload: dict) -> None: 245 | """ 246 | send but not receiver 247 | :param cmd: 248 | :param payload: 249 | :return: 250 | """ 251 | try: 252 | self._connect.send(self._get_package(cmd, payload)) 253 | except: 254 | self._connect.send(self._get_package(cmd, payload)) 255 | try: 256 | self.disconnect() 257 | self._initSocket() 258 | self._connect.send(self._get_package(cmd, payload)) 259 | except: 260 | self.disconnect() 261 | 262 | def control(self, payload: dict) -> bool: 263 | """ 264 | control use dpid 265 | :param payload: 266 | :return: 267 | """ 268 | self._only_send(CMD_SET, payload) 269 | return True 270 | 271 | def query(self) -> dict: 272 | """ 273 | query device state 274 | :return: 275 | """ 276 | return self._send_receiver(CMD_QUERY, {}) 277 | -------------------------------------------------------------------------------- /custom_components/cozylife/light.py: -------------------------------------------------------------------------------- 1 | """Platform for sensor integration.""" 2 | from __future__ import annotations 3 | from homeassistant.components import zeroconf 4 | import logging 5 | from .tcp_client import tcp_client 6 | from datetime import timedelta 7 | import time 8 | 9 | from homeassistant.helpers.restore_state import RestoreEntity 10 | from homeassistant.util import color as colorutil 11 | from homeassistant.components.light import ( 12 | PLATFORM_SCHEMA, 13 | ATTR_BRIGHTNESS, 14 | ATTR_COLOR_TEMP, 15 | ATTR_EFFECT, 16 | ATTR_FLASH, 17 | ATTR_HS_COLOR, 18 | ATTR_KELVIN, 19 | ATTR_RGB_COLOR, 20 | ATTR_TRANSITION, 21 | COLOR_MODE_BRIGHTNESS, 22 | COLOR_MODE_COLOR_TEMP, 23 | COLOR_MODE_HS, 24 | COLOR_MODE_ONOFF, 25 | COLOR_MODE_RGB, 26 | COLOR_MODE_UNKNOWN, 27 | FLASH_LONG, 28 | FLASH_SHORT, 29 | SUPPORT_EFFECT, 30 | SUPPORT_FLASH, 31 | SUPPORT_TRANSITION, 32 | LightEntity, 33 | ) 34 | from homeassistant.const import CONF_EFFECT 35 | from homeassistant.core import HomeAssistant, ServiceCall 36 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 37 | from homeassistant.helpers import entity_platform 38 | from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType 39 | from typing import Any, Final, Literal, TypedDict, final 40 | from .const import ( 41 | DOMAIN, 42 | SWITCH_TYPE_CODE, 43 | LIGHT_TYPE_CODE, 44 | LIGHT_DPID, 45 | SWITCH, 46 | WORK_MODE, 47 | TEMP, 48 | BRIGHT, 49 | HUE, 50 | SAT, 51 | ) 52 | 53 | from homeassistant.helpers.event import async_track_time_interval 54 | 55 | import asyncio 56 | 57 | import voluptuous as vol 58 | import homeassistant.helpers.config_validation as cv 59 | 60 | LIGHT_SCHEMA = vol.Schema({ 61 | vol.Required('ip'): cv.string, 62 | vol.Required('did'): cv.string, 63 | vol.Optional('dmn', default='Smart Bulb Light'): cv.string, 64 | vol.Optional('pid', default='p93sfg'): cv.string, 65 | vol.Optional('dpid', default=[1, 2, 3, 4, 5, 7, 8, 9, 13, 14]): 66 | vol.All(cv.ensure_list, [int]) 67 | }) 68 | 69 | PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ 70 | vol.Optional('lights', default=[]): 71 | vol.All(cv.ensure_list, [LIGHT_SCHEMA]) 72 | }) 73 | 74 | 75 | SCAN_INTERVAL = timedelta(seconds=60) 76 | SWITCH_SCAN_INTERVAL = timedelta(seconds=20) 77 | MIN_INTERVAL=0.2 78 | 79 | CIRCADIAN_BRIGHTNESS = True 80 | try: 81 | import custom_components.circadian_lighting as cir 82 | DATA_CIRCADIAN_LIGHTING=cir.DOMAIN #'circadian_lighting' 83 | except: 84 | CIRCADIAN_BRIGHTNESS = False 85 | 86 | _LOGGER = logging.getLogger(__name__) 87 | _LOGGER.info(__name__) 88 | 89 | SERVICE_SET_EFFECT = "set_effect" 90 | SERVICE_SET_ALL_EFFECT = "set_all_effect" 91 | scenes = ['manual','natural','sleep','warm','study','chrismas'] 92 | SERVICE_SCHEMA_SET_ALL_EFFECT = { 93 | vol.Required(CONF_EFFECT): vol.In([mode.lower() for mode in scenes]) 94 | } 95 | SERVICE_SCHEMA_SET_EFFECT = { 96 | vol.Required(CONF_EFFECT): vol.In([mode.lower() for mode in scenes]) 97 | } 98 | 99 | async def async_setup_platform( 100 | hass: HomeAssistant, 101 | config: ConfigType, 102 | async_add_devices: AddEntitiesCallback, 103 | discovery_info: DiscoveryInfoType | None = None 104 | ) -> None: 105 | """Set up the sensor platform.""" 106 | # We only want this platform to be set up via discovery. 107 | _LOGGER.info( 108 | f'setup_platform.hass={hass},config={config},async_add_entities={async_add_devices},discovery_info={discovery_info}') 109 | # zc = await zeroconf.async_get_instance(hass) 110 | # _LOGGER.info(f'zc={zc}') 111 | #_LOGGER.info(f'hass.data={hass.data[DOMAIN]}') 112 | #_LOGGER.info(f'discovery_info={discovery_info}') 113 | #if discovery_info is None: 114 | # return 115 | 116 | lights = [] 117 | # treat switch as light in home assistant 118 | switches = [] 119 | for item in config.get('lights'): 120 | client = tcp_client(item.get('ip')) 121 | client._device_id = item.get('did') 122 | client._pid = item.get('pid') 123 | client._dpid = item.get('dpid') 124 | client._device_model_name = item.get('dmn') 125 | if 'switch' not in client._device_model_name.lower(): 126 | lights.append(CozyLifeLight(client, hass, scenes)) 127 | else: 128 | switches.append(CozyLifeSwitchAsLight(client, hass)) 129 | 130 | async_add_devices(lights) 131 | for light in lights: 132 | await hass.async_add_executor_job(light._tcp_client._initSocket) 133 | await asyncio.sleep(0.01) 134 | 135 | async def async_update(now=None): 136 | for light in lights: 137 | if light._attr_is_on and light._effect == 'natural': 138 | await light.async_turn_on(effect='natural') 139 | else: 140 | await hass.async_add_executor_job(light._refresh_state) 141 | await asyncio.sleep(0.1) 142 | async_track_time_interval(hass, async_update, SCAN_INTERVAL) 143 | 144 | async_add_devices(switches) 145 | for light in switches: 146 | await hass.async_add_executor_job(light._tcp_client._initSocket) 147 | await asyncio.sleep(0.01) 148 | 149 | async def async_update(now=None): 150 | for light in switches: 151 | await hass.async_add_executor_job(light._refresh_state) 152 | await asyncio.sleep(0.1) 153 | async_track_time_interval(hass, async_update, SWITCH_SCAN_INTERVAL) 154 | 155 | platform = entity_platform.async_get_current_platform() 156 | 157 | platform.async_register_entity_service( 158 | SERVICE_SET_EFFECT, SERVICE_SCHEMA_SET_EFFECT, "async_set_effect" 159 | ) 160 | async def async_set_all_effect(call:ServiceCall): 161 | for light in lights: 162 | await light.async_set_effect(call.data.get(ATTR_EFFECT)) 163 | await asyncio.sleep(0.01) 164 | hass.services.async_register(DOMAIN, SERVICE_SET_ALL_EFFECT, async_set_all_effect) 165 | 166 | 167 | 168 | 169 | 170 | class CozyLifeSwitchAsLight(LightEntity): 171 | 172 | _tcp_client = None 173 | _attr_is_on = True 174 | _unrecorded_attributes = frozenset({"brightness","color_temp"}) 175 | def __init__(self, tcp_client: tcp_client, hass) -> None: 176 | """Initialize the sensor.""" 177 | _LOGGER.info('__init__') 178 | self.hass = hass 179 | self._tcp_client = tcp_client 180 | self._unique_id = tcp_client.device_id 181 | self._name = tcp_client.device_id[-4:] 182 | #self._refresh_state() 183 | 184 | @property 185 | def unique_id(self) -> str | None: 186 | """Return a unique ID.""" 187 | return self._unique_id 188 | 189 | async def async_update(self): 190 | await self.hass.async_add_executor_job(self._refresh_state) 191 | 192 | def _refresh_state(self): 193 | self._state = self._tcp_client.query() 194 | _LOGGER.info(f'_name={self._name},_state={self._state}') 195 | if self._state: 196 | self._attr_is_on = 0 < self._state['1'] 197 | 198 | @property 199 | def name(self) -> str: 200 | return 'cozylife:' + self._name 201 | 202 | @property 203 | def available(self) -> bool: 204 | """Return if the device is available.""" 205 | if self._tcp_client._connect: 206 | return True 207 | else: 208 | return False 209 | 210 | @property 211 | def is_on(self) -> bool: 212 | """Return True if entity is on.""" 213 | return self._attr_is_on 214 | 215 | async def async_turn_on(self, **kwargs: Any) -> None: 216 | """Turn the entity on.""" 217 | self._attr_is_on = True 218 | 219 | _LOGGER.info(f'turn_on:{kwargs}') 220 | 221 | await self.hass.async_add_executor_job(self._tcp_client.control, { 222 | '1': 1 223 | }) 224 | 225 | return None 226 | 227 | async def async_turn_off(self, **kwargs: Any) -> None: 228 | """Turn the entity off.""" 229 | self._attr_is_on = False 230 | 231 | _LOGGER.info('turn_off') 232 | 233 | await self.hass.async_add_executor_job(self._tcp_client.control, { 234 | '1': 0 235 | }) 236 | 237 | return None 238 | 239 | 240 | class CozyLifeLight(CozyLifeSwitchAsLight,RestoreEntity): 241 | _attr_brightness: int | None = None 242 | _attr_color_mode: str | None = None 243 | _attr_color_temp: int | None = None 244 | _attr_hs_color = None 245 | _unrecorded_attributes = frozenset({"brightness","color_temp"}) 246 | 247 | _tcp_client = None 248 | 249 | _attr_supported_color_modes = { 250 | COLOR_MODE_ONOFF} 251 | _attr_color_mode = COLOR_MODE_BRIGHTNESS 252 | 253 | def __init__(self, tcp_client: tcp_client, hass, scenes) -> None: 254 | """Initialize the sensor.""" 255 | _LOGGER.info('__init__') 256 | self.hass = hass 257 | self._tcp_client = tcp_client 258 | self._unique_id = tcp_client.device_id 259 | self._scenes = scenes 260 | self._effect = 'manual' 261 | #self._lasteffect = 'manual' 262 | 263 | #circardianlighting initialize 264 | self._cl = None 265 | self._max_brightness = 255 266 | self._min_brightness = 1 267 | #self._name = tcp_client._device_model_name 268 | _LOGGER.info(f'before:{self._unique_id}._attr_color_mode={self._attr_color_mode}._attr_supported_color_modes=' 269 | f'{self._attr_supported_color_modes}.dpid={tcp_client.dpid}') 270 | self._name = tcp_client.device_id[-4:] 271 | self._min_mireds = colorutil.color_temperature_kelvin_to_mired(6500) 272 | self._max_mireds = colorutil.color_temperature_kelvin_to_mired(2700) 273 | self._miredsratio = (self._max_mireds - self._min_mireds)/1000 274 | self._attr_color_temp = 153 275 | self._attr_hs_color = (0, 0) 276 | self._transitioning = 0 277 | self._attr_is_on = False 278 | self._attr_brightness = 0 279 | 280 | # h s 281 | if not 'switch' in self._tcp_client._device_model_name.lower(): 282 | 283 | if 3 in tcp_client.dpid: 284 | self._attr_color_mode = COLOR_MODE_COLOR_TEMP 285 | self._attr_supported_color_modes.add(COLOR_MODE_COLOR_TEMP) 286 | 287 | 288 | if 4 in tcp_client.dpid: 289 | self._attr_supported_color_modes.add(COLOR_MODE_BRIGHTNESS) 290 | 291 | if 5 in tcp_client.dpid or 6 in tcp_client.dpid: 292 | self._attr_color_mode = COLOR_MODE_HS 293 | self._attr_supported_color_modes.add(COLOR_MODE_HS) 294 | 295 | _LOGGER.info(f'after:{self._unique_id}._attr_color_mode={self._attr_color_mode}._attr_supported_color_modes=' 296 | f'{self._attr_supported_color_modes}.dpid={tcp_client.dpid}') 297 | 298 | 299 | #self._refresh_state() 300 | self.SUPPORT_COZYLIGHT = self.get_supported_features() 301 | 302 | async def async_set_effect(self, effect: str): 303 | """Set the effect regardless it is On or Off.""" 304 | _LOGGER.info(f'onoff:{self._attr_is_on} effect:{effect}') 305 | self._effect = effect 306 | if self._attr_is_on: 307 | await self.async_turn_on(effect=effect) 308 | 309 | 310 | @property 311 | def effect(self): 312 | """Return the current effect.""" 313 | return self._effect 314 | 315 | @property 316 | def effect_list(self): 317 | """Return the list of supported effects. 318 | """ 319 | return self._scenes 320 | 321 | def _refresh_state(self): 322 | """ 323 | query device & set attr 324 | :return: 325 | """ 326 | self._state = self._tcp_client.query() 327 | _LOGGER.info(f'_name={self._name},_state={self._state}') 328 | if self._state: 329 | self._attr_is_on = 0 < self._state['1'] 330 | 331 | if '2' in self._state: 332 | if self._state['2'] == 0: 333 | if '3' in self._state: 334 | #self._attr_color_mode = COLOR_MODE_COLOR_TEMP 335 | color_temp = self._state['3'] 336 | if color_temp < 60000: 337 | self._attr_color_mode = COLOR_MODE_COLOR_TEMP 338 | self._attr_color_temp = round( 339 | self._max_mireds-self._state['3'] * self._miredsratio) 340 | 341 | if '4' in self._state: 342 | self._attr_brightness = int(self._state['4'] / 1000 * 255) 343 | 344 | if '5' in self._state: 345 | color = self._state['5'] 346 | if color < 60000: 347 | self._attr_color_mode = COLOR_MODE_HS 348 | r, g, b = colorutil.color_hs_to_RGB( 349 | round(self._state['5']), round(self._state['6'] / 10)) 350 | ## May need to adjust 351 | hs_color = colorutil.color_RGB_to_hs(r, g, b) 352 | self._attr_hs_color = hs_color 353 | 354 | #autobrightness from circadian_lighting if enabled 355 | def calc_color_temp(self): 356 | if self._cl == None: 357 | self._cl = self.hass.data.get(DATA_CIRCADIAN_LIGHTING) 358 | if self._cl == None: 359 | return None 360 | colortemp_in_kelvin = self._cl._colortemp 361 | autocolortemp = colorutil.color_temperature_kelvin_to_mired( 362 | colortemp_in_kelvin) 363 | return autocolortemp 364 | def calc_brightness(self): 365 | if self._cl == None: 366 | self._cl = self.hass.data.get(DATA_CIRCADIAN_LIGHTING) 367 | if self._cl == None: 368 | return None 369 | if self._cl._percent > 0: 370 | return self._max_brightness 371 | else: 372 | return round(((self._max_brightness - self._min_brightness) * ((100+self._cl._percent) / 100)) + self._min_brightness) 373 | 374 | 375 | @property 376 | def color_temp(self) -> int | None: 377 | """Return the CT color value in mireds.""" 378 | return self._attr_color_temp 379 | 380 | async def async_turn_on(self, **kwargs: Any) -> None: 381 | """Turn the entity on.""" 382 | 383 | # 1-255 384 | brightness = kwargs.get(ATTR_BRIGHTNESS) 385 | 386 | # 153 ~ 370 387 | colortemp = kwargs.get(ATTR_COLOR_TEMP) 388 | 389 | # tuple 390 | hs_color = kwargs.get(ATTR_HS_COLOR) 391 | 392 | transition = kwargs.get(ATTR_TRANSITION) 393 | 394 | 395 | 396 | # rgb = kwargs.get(ATTR_RGB_COLOR) 397 | #flash = kwargs.get(ATTR_FLASH) 398 | effect = kwargs.get(ATTR_EFFECT) 399 | 400 | originalcolortemp = self._attr_color_temp 401 | originalhs = self._attr_hs_color 402 | if self._attr_is_on: 403 | originalbrightness = self._attr_brightness 404 | else: 405 | originalbrightness = 0 406 | #if self._attr_color_mode == COLOR_MODE_COLOR_TEMP: 407 | # originalcolortemp = self._attr_color_temp 408 | #else: 409 | # originalhs = self._attr_hs_color 410 | _LOGGER.info( 411 | f'turn_on.kwargs={kwargs},colortemp={colortemp},hs_color={hs_color},originalbrightness={originalbrightness},self._attr_is_on={self._attr_is_on}') 412 | self._attr_is_on = True 413 | self.async_write_ha_state() 414 | payload = {'1': 255, '2': 0} 415 | count = 0 416 | if brightness is not None: 417 | # Color: mininum light brightness 12, max 1000 418 | # White mininum light brightness 4, max 1000 419 | self._effect = 'manual' 420 | payload['4'] = round(brightness / 255 * 1000) 421 | self._attr_brightness = brightness 422 | count += 1 423 | 424 | if colortemp is not None: 425 | # 0-694 426 | #payload['3'] = 1000 - colortemp * 2 427 | self._effect = 'manual' 428 | self._attr_color_mode = COLOR_MODE_COLOR_TEMP 429 | self._attr_color_temp = colortemp 430 | payload['3'] = 1000 - \ 431 | round((colortemp - self._min_mireds) / self._miredsratio) 432 | count += 1 433 | 434 | if hs_color is not None: 435 | # 0-360 436 | # 0-1000 437 | self._effect = 'manual' 438 | self._attr_color_mode = COLOR_MODE_HS 439 | self._attr_hs_color = hs_color 440 | r, g, b = colorutil.color_hs_to_RGB(*hs_color) 441 | # color is not balanced right. needs additional tuning 442 | hs_color = colorutil.color_RGB_to_hs(r, g, b) 443 | payload['5'] = round(hs_color[0]) 444 | payload['6'] = round(hs_color[1] * 10) 445 | count += 1 446 | 447 | if count == 0: 448 | #autocolortemp when brightness color temp and hs_color is not set 449 | if effect is not None: 450 | self._effect = effect 451 | if self._effect == 'natural': 452 | if CIRCADIAN_BRIGHTNESS: 453 | brightness = self.calc_brightness() 454 | payload['4'] = round(brightness / 255 * 1000) 455 | self._attr_brightness = brightness 456 | self._attr_color_mode = COLOR_MODE_COLOR_TEMP 457 | colortemp = self.calc_color_temp() 458 | payload['3'] = 1000 - \ 459 | round((colortemp - self._min_mireds) / self._miredsratio) 460 | _LOGGER.info(f'color={colortemp},payload3={payload["3"]}') 461 | if self._transitioning !=0: 462 | return None 463 | if transition is None: 464 | transition=5 465 | elif self._effect == 'sleep': 466 | payload['4'] = 4 467 | payload['3'] = 0 468 | payload['4'] = 12 469 | #brightness = 5 470 | #self._attr_brightness = brightness 471 | #payload['4'] = round(brightness / 255 * 1000) 472 | self._attr_color_mode = COLOR_MODE_COLOR_TEMP 473 | #self._attr_hs_color = (16,100) 474 | #payload['5'] = round(16) 475 | #payload['6'] = round(1000) 476 | elif self._effect == 'study': 477 | payload['4'] = 1000 478 | payload['3'] = 1000 479 | elif self._effect == 'warm': 480 | payload['4'] = 1000 481 | payload['3'] = 0 482 | elif self._effect == 'chrismas': 483 | payload['2'] = 1 484 | payload['4'] = 1000 485 | payload['8'] = 500 486 | payload['7'] = '03000003E8FFFF007803E8FFFF00F003E8FFFF003C03E8FFFF00B403E8FFFF010E03E8FFFF002603E8FFFF' 487 | 488 | self._transitioning = 0 489 | 490 | if transition: 491 | self._transitioning = time.time() 492 | now = self._transitioning 493 | if self._effect =='chrismas': 494 | await self.hass.async_add_executor_job(self._tcp_client.control, payload) 495 | self._transitioning = 0 496 | return None 497 | if brightness: 498 | payloadtemp = {'1': 255, '2': 0} 499 | p4i = round(originalbrightness / 255 * 1000) 500 | p4f = payload['4'] 501 | p4steps = abs(round((p4i-p4f)/4)) 502 | _LOGGER.info(f'p4i={p4i},p4f={p4f},p4steps={p4steps}') 503 | else: 504 | p4steps = 0 505 | if self._attr_color_mode == COLOR_MODE_COLOR_TEMP: 506 | p3i = 1000 - round((originalcolortemp - self._min_mireds) / self._miredsratio) 507 | p3steps = 0 508 | if '3' in payload: 509 | p3f = payload['3'] 510 | p3steps = abs(round((p3i-p3f)/4)) 511 | _LOGGER.info(f'p3i={p3i},p3f={p3f},p3steps={p3steps}') 512 | steps = p3steps if p3steps > p4steps else p4steps 513 | if steps <= 0: 514 | self._transitioning = 0 515 | return None 516 | stepseconds = transition / steps 517 | if stepseconds < MIN_INTERVAL: 518 | stepseconds = MIN_INTERVAL 519 | steps = round(transition / stepseconds) 520 | stepseconds = transition / steps 521 | _LOGGER.info(f'steps={steps},transition={transition},stepseconds={stepseconds},p3steps={p3steps},p4steps={p4steps}') 522 | for s in range(1,steps+1): 523 | payloadtemp['4']= round(p4i + (p4f - p4i) * s / steps) 524 | if p3steps != 0: 525 | payloadtemp['3']= round(p3i + (p3f - p3i) * s / steps) 526 | if now == self._transitioning: 527 | await self.hass.async_add_executor_job(self._tcp_client.control, payloadtemp) 528 | _LOGGER.info(f'payloadtemp={payloadtemp},stepseconds={stepseconds}') 529 | if s None: 572 | """Turn the entity off.""" 573 | self._transitioning = 0 574 | self._attr_is_on = False 575 | self.async_write_ha_state() 576 | transition = kwargs.get(ATTR_TRANSITION) 577 | originalbrightness=self._attr_brightness 578 | if self._effect == 'natural' and transition is None: 579 | transition = 5 580 | if transition: 581 | self._transitioning = time.time() 582 | now = self._transitioning 583 | payloadtemp = {'1': 255, '2': 0} 584 | p4i = round(originalbrightness / 255 * 1000) 585 | p4f = 0 586 | steps = abs(round((p4i-p4f)/4)) 587 | stepseconds = transition / steps 588 | if stepseconds < MIN_INTERVAL: 589 | stepseconds = MIN_INTERVAL 590 | steps = round(transition / stepseconds) 591 | stepseconds = transition / steps 592 | for s in range(1+steps+1): 593 | payloadtemp['4']= round(p4i + (p4f - p4i) * s / steps) 594 | if now == self._transitioning: 595 | await self.hass.async_add_executor_job(self._tcp_client.control, payloadtemp) 596 | if s tuple[float, float] | None: 610 | """Return the hue and saturation color value [float, float].""" 611 | #_LOGGER.info('hs_color') 612 | # self._refresh_state() 613 | return self._attr_hs_color 614 | 615 | @property 616 | def brightness(self) -> int | None: 617 | """Return the brightness of this light between 0..255.""" 618 | #_LOGGER.info('brightness') 619 | # self._refresh_state() 620 | return self._attr_brightness 621 | 622 | @property 623 | def color_mode(self) -> str | None: 624 | """Return the color mode of the light.""" 625 | #_LOGGER.info('color_mode') 626 | return self._attr_color_mode 627 | 628 | @property 629 | def min_mireds(self): 630 | """Return color temperature min mireds.""" 631 | return self._min_mireds 632 | 633 | @property 634 | def max_mireds(self): 635 | """Return color temperature max mireds.""" 636 | return self._max_mireds 637 | 638 | 639 | @property 640 | def assumed_state(self): 641 | return True 642 | 643 | async def async_added_to_hass(self): 644 | await super().async_added_to_hass() 645 | last_state = await self.async_get_last_state() 646 | if not last_state: 647 | return 648 | if 'last_effect' in last_state.attributes: 649 | self._effect = last_state.attributes['last_effect'] 650 | 651 | @property 652 | def extra_state_attributes(self): 653 | attributes = {} 654 | attributes['last_effect'] = self._effect 655 | attributes['transitioning'] = self._transitioning 656 | 657 | return attributes 658 | 659 | @property 660 | def supported_features(self) -> int: 661 | """Flag supported features.""" 662 | return self.SUPPORT_COZYLIGHT 663 | 664 | def get_supported_features(self) -> int: 665 | """Flag supported features.""" 666 | features = 0 667 | features = features | SUPPORT_EFFECT | SUPPORT_TRANSITION 668 | try: 669 | # Map features for better reading 670 | if COLOR_MODE_BRIGHTNESS in self._attr_supported_color_modes: 671 | features = features | SUPPORT_BRIGHTNESS 672 | if COLOR_MODE_HS in self._attr_supported_color_modes: 673 | features = features | SUPPORT_COLOR 674 | if COLOR_MODE_COLOR_TEMP in self._attr_supported_color_modes: 675 | features = features | SUPPORT_COLOR_TEMP 676 | except: 677 | pass 678 | # fallback 679 | return features 680 | --------------------------------------------------------------------------------