├── MANIFEST.in ├── .gitignore ├── tests ├── conftest.py └── test_commands.py ├── magichue ├── __init__.py ├── bulb_types.py ├── exceptions.py ├── utils.py ├── discover.py ├── commands.py ├── http_api.py ├── modes.py ├── light.py └── magichue.py ├── .github └── ISSUE_TEMPLATE │ └── bug_report.md ├── setup.py ├── LICENSE └── README.md /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md LICENSE 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | *.py[cod] 3 | dist/ 4 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | import sys 3 | 4 | _here = here = pathlib.Path(__file__).resolve().parent 5 | sys.path.append(str(_here.parent)) 6 | import pytest 7 | -------------------------------------------------------------------------------- /magichue/__init__.py: -------------------------------------------------------------------------------- 1 | from .magichue import Light 2 | from .modes import * 3 | from .discover import discover_bulbs 4 | from .light import RemoteLight, LocalLight 5 | from .http_api import RemoteAPI 6 | 7 | 8 | __author__ = "namacha" 9 | __version__ = "0.3.1" 10 | __license__ = "MIT" 11 | -------------------------------------------------------------------------------- /magichue/bulb_types.py: -------------------------------------------------------------------------------- 1 | BULB_RGBWW = 0x44 2 | BULB_TAPE = 0x33 3 | BULB_RGBWWCW = 0x35 4 | 5 | 6 | def str_bulb_type(bulb_type): 7 | if bulb_type == BULB_RGBWW: 8 | return "rgbww" 9 | if bulb_type == BULB_TAPE: 10 | return "tape" 11 | if bulb_type == BULB_RGBWWCW: 12 | return "rgbwwcw" 13 | else: 14 | return "UNKNOWN" 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | 5 | --- 6 | 7 | **Describe the bug** 8 | A description of what the bug is. 9 | 10 | **To Reproduce** 11 | Code to reproduce the behavior: 12 | ```python 13 | # Insert code 14 | ``` 15 | 16 | **Device** 17 | - Device Information: bulb name or/and online store link. 18 | 19 | **Additional context** 20 | Add any other context about the problem here. 21 | -------------------------------------------------------------------------------- /magichue/exceptions.py: -------------------------------------------------------------------------------- 1 | class HTTPError(Exception): 2 | """An error on HTTP connection""" 3 | 4 | pass 5 | 6 | 7 | class MagicHueAPIError(Exception): 8 | """Something gone wrong on MagicHue API""" 9 | 10 | pass 11 | 12 | 13 | class InvalidData(Exception): 14 | """Received data is invalid""" 15 | 16 | pass 17 | 18 | 19 | class DeviceOffline(Exception): 20 | """Device is offline""" 21 | 22 | pass 23 | 24 | 25 | class DeviceDisconnected(Exception): 26 | """Local device is disconnected""" 27 | 28 | pass 29 | -------------------------------------------------------------------------------- /magichue/utils.py: -------------------------------------------------------------------------------- 1 | __all__ = [ 2 | "speed2slowness", 3 | "slowness2speed", 4 | ] 5 | 6 | 7 | def speed2slowness(value): 8 | """speed: float value 0 to 1.0 9 | slowness: integer value 1 to 31""" 10 | slowness = int(-30 * value + 31) 11 | return slowness 12 | 13 | 14 | def slowness2speed(value): 15 | """invert function of speed2slowness""" 16 | speed = (31 - value) / 30 17 | return speed 18 | 19 | 20 | def round_value(value, _min, _max): 21 | if not isinstance(value, (int, float)): 22 | raise ValueError("Invalid value: value must be a int or float.") 23 | if value < _min: 24 | return _min 25 | if value > _max: 26 | return _max 27 | return value 28 | -------------------------------------------------------------------------------- /magichue/discover.py: -------------------------------------------------------------------------------- 1 | import socket 2 | 3 | 4 | def make_socket(timeout): 5 | sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 6 | sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 7 | sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) 8 | sock.settimeout(timeout) 9 | return sock 10 | 11 | 12 | def discover_bulbs(timeout=1, broadcast_ip="255.255.255.255"): 13 | DISCOVERY_PORT = 48899 14 | DISCOVERY_MSG = b"HF-A11ASSISTHREAD" 15 | addrs = [] 16 | 17 | sock = make_socket(timeout) 18 | sock.sendto(DISCOVERY_MSG, (broadcast_ip, DISCOVERY_PORT)) 19 | 20 | try: 21 | while True: 22 | response, addr = sock.recvfrom(64) 23 | if response != DISCOVERY_MSG: 24 | addr = response.decode().split(",")[0] 25 | addrs.append(addr) 26 | except socket.timeout: 27 | pass 28 | 29 | sock.close() 30 | return addrs 31 | -------------------------------------------------------------------------------- /tests/test_commands.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Test: magichue/commands.py 3 | ''' 4 | 5 | import pytest 6 | 7 | from magichue import commands 8 | 9 | 10 | class TestCommand(commands.Command): 11 | array = [0x1, 0x2, 0x3, 0xf, 0xa] 12 | response_length = 4 13 | 14 | 15 | def test_cmd_hex_array(): 16 | assert TestCommand.hex_array() == [0x1, 0x2, 0x3, 0xf, 0xa, 0x0f, 0x2e] 17 | 18 | 19 | def test_cmd_byte_string(): 20 | assert TestCommand.byte_string() == b'\x01\x02\x03\x0f\n\x0f\x2e' 21 | 22 | 23 | def test_cmd_hex_string(): 24 | assert TestCommand.hex_string() == "0102030f0a0f2e" 25 | 26 | 27 | def test_turn_on_1(): 28 | assert commands.TurnON.hex_array() == [0x71, 0x23, 0x0f, 0xa3] 29 | 30 | 31 | def test_turn_on_remote(): 32 | assert commands.TurnON.hex_array(is_remote=True) == [0x71, 0x23, 0xf0, 0x84] 33 | 34 | 35 | def test_from_array(): 36 | arr = [0x31, 0xa1, 0xf0, 0x12] 37 | cmd = commands.Command.from_array(arr) 38 | assert cmd.hex_string() == "31a1f0120fe3" 39 | assert cmd.hex_array() == arr + [0x0f, 0xe3] 40 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | import magichue 4 | 5 | 6 | setup( 7 | name='python-magichue', 8 | version=magichue.__version__, 9 | url='https://github.com/namacha/python-magichue', 10 | author=magichue.__author__, 11 | author_email='mac.ayu15@gmail.com', 12 | long_description_content_type='text/markdown', 13 | description='A library to interface with Magichue(or Magichome)', 14 | long_description=open('README.md').read(), 15 | license=magichue.__license__, 16 | classifiers=[ 17 | 'Development Status :: 2 - Pre-Alpha', 18 | 'Environment :: Other Environment', 19 | 'Intended Audience :: Developers', 20 | 'Intended Audience :: Education', 21 | 'License :: OSI Approved :: MIT License', 22 | 'Operating System :: Microsoft :: Windows', 23 | 'Operating System :: MacOS', 24 | 'Operating System :: POSIX :: Linux', 25 | 'Programming Language :: Python :: 2', 26 | 'Programming Language :: Python :: 3', 27 | 'Topic :: Home Automation', 28 | ], 29 | packages=find_packages(), 30 | ) 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 namacha 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 | -------------------------------------------------------------------------------- /magichue/commands.py: -------------------------------------------------------------------------------- 1 | import struct 2 | from typing import List 3 | 4 | 5 | class _Meta(type): 6 | required_attributes = ["array", "response_len"] 7 | 8 | def __new__(cls, classname, bases, _dict): 9 | newclass = type.__new__(cls, classname, bases, _dict) 10 | for attr_name in cls.required_attributes: 11 | if not hasattr(newclass, attr_name): 12 | raise NotImplementedError(f"{newclass.__name__}.{attr_name} is not set") 13 | return newclass 14 | 15 | 16 | class Command: 17 | 18 | needs_terminator = True 19 | array: List[int] 20 | response_len: int 21 | 22 | @classmethod 23 | def append_terminator(cls, arr, is_remote): 24 | if cls.needs_terminator: 25 | return arr + [0xF0 if is_remote else 0x0F] 26 | else: 27 | return arr 28 | 29 | @staticmethod 30 | def calc_checksum(arr): 31 | hex_checksum = hex(sum(arr))[-2:] 32 | checksum = int(hex_checksum, 16) 33 | return checksum 34 | 35 | @classmethod 36 | def from_array(cls, arr, response_len: int = 0): 37 | cmd = Command 38 | cmd.array = arr 39 | cmd.response_len = response_len 40 | return cmd 41 | 42 | @classmethod 43 | def attach_checksum(cls, arr): 44 | return arr + [cls.calc_checksum(arr)] 45 | 46 | @classmethod 47 | def hex_array(cls, is_remote: bool = False) -> list: 48 | return cls.attach_checksum(cls.append_terminator(cls.array, is_remote)) 49 | 50 | @classmethod 51 | def byte_string(cls, is_remote: bool = False) -> bytes: 52 | _arr = cls.attach_checksum(cls.append_terminator(cls.array, is_remote)) 53 | return struct.pack("!%dB" % len(_arr), *_arr) 54 | 55 | @classmethod 56 | def hex_string(cls, is_remote: bool = False) -> str: 57 | _arr = cls.attach_checksum(cls.append_terminator(cls.array, is_remote)) 58 | return "".join([hex(v)[2:].zfill(2) for v in _arr]) 59 | 60 | 61 | class TurnON(Command, metaclass=_Meta): 62 | """Command: Turn on light bulb. 63 | Response: 64 | (240, 113, 35, 133) 65 | | | | | 66 | | | | Checksum 67 | | | Status: 0x23(TurnON) 68 | | Header 69 | Header: 0xf0(240): Remote, 0x0f(15): Local 70 | """ 71 | 72 | array = [0x71, 0x23] 73 | response_len = 4 74 | 75 | 76 | class TurnOFF(Command, metaclass=_Meta): 77 | """Command: Turn off light bulb. 78 | Response: 79 | (240, 113, 36, 133) 80 | | | | | 81 | | | | Checksum 82 | | | Status: 0x24(TurnOFF) 83 | | Header 84 | Header: 0xf0(240): Remote, 0x0f(15): Local 85 | """ 86 | 87 | array = [0x71, 0x24] 88 | response_len = 4 89 | 90 | 91 | class QueryStatus(Command, metaclass=_Meta): 92 | """Command: Query status of light bulb. 93 | Response: 94 | (129, 53, 36, 97, 0, 1, 0, 0, 0, 255, 7, 0, 15, 81) 95 | | | | | | | | | | | | | | | 96 | | | | | | | | | | | | | | CheckSum 97 | | | | | | | | | | | | | Color Status: 0x0f(15) RGB, 0xf0(240) White 98 | | | | | | | | | | | | CoolWhite(0-255) 99 | | | | | | | | | | | Version 100 | | | | | | | | | | WarmWhite(0-255) 101 | | | | | | | | | B(0-255) 102 | | | | | | | | G(0-255) 103 | | | | | | | R(0-255) 104 | | | | | | Speed(0x1-0x1f): 0x1 is fastest 105 | | | | | Run/Pause 106 | | | | Mode 107 | | | ON/OFF: 0x23(35) ON, 0x24(36) OFF 108 | | Device Type 109 | Header 110 | """ 111 | 112 | array = [0x81, 0x8A, 0x8B] 113 | response_len = 14 114 | needs_terminator = False 115 | 116 | 117 | class QueryCurrentTime(Command, metaclass=_Meta): 118 | """Command: Query time of bulb clock 119 | Response: 120 | (240, 17, 20, 21, 12, 21, 17, 38, 7, 2, 0, 139) 121 | | | | | | | | | | | | | 122 | | | | | | | | | | | | Checksum 123 | | | | | | | | | | | Reserved 124 | | | | | | | | | | Day of week 125 | | | | | | | | | Second 126 | | | | | | | | Minute 127 | | | | | | | Hour 128 | | | | | | Date 129 | | | | | Month 130 | | | | Year 131 | | | Header 132 | | Header 133 | Header: 0xf0(240): Remote, 0x0f(15): Local 134 | """ 135 | 136 | array = [0x11, 0x1A, 0x1B] 137 | response_len = 12 138 | 139 | 140 | class QueryTimers(Command, metaclass=_Meta): 141 | """Command: Query scheduled timers""" 142 | 143 | array = [0x22, 0x2A, 0x2B] 144 | response_len = 94 145 | 146 | 147 | class QueryCustomMode(Command, metaclass=_Meta): 148 | """Query custom mode content""" 149 | 150 | array = [0x52, 0x5A, 0x5B] 151 | response_len = 70 152 | 153 | 154 | QUERY_STATUS_1 = 0x81 155 | QUERY_STATUS_2 = 0x8A 156 | QUERY_STATUS_3 = 0x8B 157 | RESPONSE_LEN_QUERY_STATUS = 14 158 | 159 | SET_COLOR = 0x31 160 | RESPONSE_LEN_SET_COLOR = 1 161 | 162 | CHANGE_MODE = 0x61 163 | RESPONSE_LEN_CHANGE_MODE = 1 164 | 165 | CUSTOM_MODE = 0x51 166 | RESPONSE_LEN_CUSTOM_MODE = 0 167 | 168 | CUSTOM_MODE_TERMINATOR_1 = 0xFF 169 | CUSTOM_MODE_TERMINATOR_2 = 0xF0 170 | 171 | TURN_ON_1 = 0x71 172 | TURN_ON_2 = 0x23 173 | TURN_ON_3 = 0x0F 174 | TURN_OFF_1 = 0x71 175 | TURN_OFF_2 = 0x24 176 | TURN_OFF_3 = 0x0F 177 | RESPONSE_LEN_POWER = 4 178 | 179 | 180 | TRUE = 0x0F 181 | FALSE = 0xF0 182 | ON = 0x23 183 | OFF = 0x24 184 | -------------------------------------------------------------------------------- /magichue/http_api.py: -------------------------------------------------------------------------------- 1 | import random 2 | import logging 3 | import hashlib 4 | import json 5 | from dataclasses import dataclass 6 | from string import ascii_uppercase, digits 7 | from typing import List 8 | 9 | import requests 10 | 11 | from .light import RemoteLight 12 | from .commands import Command 13 | from .exceptions import HTTPError, MagicHueAPIError 14 | 15 | 16 | API_BASE = "https://wifij01us.magichue.net/app" 17 | UA = "Magic Hue/1.2.2 (IOS,13.400000,ja_JP)" 18 | 19 | _LOGGER = logging.getLogger(__name__) 20 | 21 | 22 | @dataclass 23 | class RemoteDevice: 24 | 25 | device_type: int 26 | version: int 27 | macaddr: str 28 | local_ip: str 29 | state_str: str 30 | 31 | 32 | class RemoteAPI: 33 | def __init__(self, token): 34 | self.token = token 35 | 36 | @staticmethod 37 | def sanitize_json_text(text: str) -> str: 38 | """Sometimes MagicHue api returns broken json text which ends with `.`""" 39 | if text.endswith("."): 40 | _LOGGER.debug("A junk end of the line. Sanitized") 41 | return text[:-1] 42 | return text 43 | 44 | @staticmethod 45 | def handle_api_response(res: requests.Response): 46 | clean_text = RemoteAPI.sanitize_json_text(res.text) 47 | try: 48 | _decoded = json.loads(clean_text) 49 | except json.decoder.JSONDecodeError: 50 | raise MagicHueAPIError("Invalid JSON String: {}".format(clean_text)) 51 | if _decoded.get("code") != 0: 52 | if _decoded.get("msg"): 53 | raise MagicHueAPIError(f"{_decoded.get('msg')}") 54 | raise MagicHueAPIError("Unknown Eror: {}".format(clean_text)) 55 | return _decoded 56 | 57 | @classmethod 58 | def auth(cls, user: str, password: str, client_id: str = ""): 59 | if not client_id: 60 | client_id = "".join( 61 | [random.choice(ascii_uppercase + digits) for _ in range(32)] 62 | ) 63 | payload = { 64 | "userID": user, 65 | "password": hashlib.md5(password.encode("utf8")).hexdigest(), 66 | "clientID": client_id, 67 | } 68 | _LOGGER.debug("Logging in with email {}".format(user)) 69 | res = requests.post( 70 | API_BASE + "/login/MagicHue", json=payload, headers={"User-Agent": UA} 71 | ) 72 | 73 | res_dict = cls.handle_api_response(res) 74 | _LOGGER.debug("Login successful") 75 | return res_dict.get("token") 76 | 77 | @classmethod 78 | def login_with_user_password(cls, user: str, password: str, client_id: str = ""): 79 | token = cls.auth(user, password, client_id) 80 | return RemoteAPI(token=token) 81 | 82 | @classmethod 83 | def login_with_token(cls, token: str): 84 | return RemoteAPI(token) 85 | 86 | def _post_with_token(self, endpoint, payload): 87 | _LOGGER.debug( 88 | "Sending POST request to {}, payload={}".format( 89 | endpoint, 90 | payload, 91 | ) 92 | ) 93 | res = requests.post( 94 | API_BASE + endpoint, 95 | json=payload, 96 | headers={"User-Agent": UA, "token": self.token}, 97 | ) 98 | _LOGGER.debug( 99 | "Got response({}): {}".format( 100 | res.status_code, 101 | res.text, 102 | ) 103 | ) 104 | res_dict = RemoteAPI.handle_api_response(res) 105 | return res_dict 106 | 107 | def _get_with_token(self, endpoint): 108 | _LOGGER.debug("Sending GET request to {}".format(endpoint)) 109 | res = requests.get( 110 | API_BASE + endpoint, headers={"User-Agent": UA, "token": self.token} 111 | ) 112 | _LOGGER.debug( 113 | "Got response({}): {}".format( 114 | res.status_code, 115 | res.text, 116 | ) 117 | ) 118 | res_dict = RemoteAPI.handle_api_response(res) 119 | return res_dict 120 | 121 | def _send_request(self, cmd: Command, macaddr: str): 122 | payload = { 123 | "hexData": cmd.hex_string(is_remote=True), 124 | "macAddress": macaddr, 125 | "responseCount": cmd.response_len, 126 | } 127 | result = self._post_with_token("/sendRequestCommand/MagicHue", payload) 128 | return result["data"] 129 | 130 | def _send_command(self, cmd: Command, macaddr: str): 131 | payload = { 132 | "dataCommandItems": [{"hexData": cmd.hex_string(), "macAddress": macaddr}] 133 | } 134 | result = self._post_with_token("/sendCommandBatch/MagicHue", payload) 135 | return result 136 | 137 | def get_online_bulbs(self, online_only=True) -> List[RemoteLight]: 138 | devices = self.get_online_devices(online_only=online_only) 139 | bulbs = [ 140 | RemoteLight(api=self, macaddr=dev.macaddr) 141 | for dev in devices 142 | ] 143 | return bulbs 144 | 145 | def get_online_devices(self, online_only=True) -> List[RemoteDevice]: 146 | result = self._get_with_token("/getMyBindDevicesAndState/MagicHue") 147 | arr = result.get("data") 148 | _LOGGER.debug("Found {} devices".format(len(arr))) 149 | devices = [] 150 | for dev_dict in arr: 151 | if online_only and not dev_dict.get("isOnline"): 152 | continue 153 | dev = RemoteDevice( 154 | device_type=dev_dict.get("deviceType"), 155 | version=dev_dict.get("ledVersionNum"), 156 | macaddr=dev_dict.get("macAddress"), 157 | local_ip=dev_dict.get("localIP"), 158 | state_str=dev_dict.get("state"), 159 | ) 160 | devices.append(dev) 161 | return devices 162 | 163 | def get_all_devices(self): 164 | return self.get_online_devices(online_only=False) 165 | -------------------------------------------------------------------------------- /magichue/modes.py: -------------------------------------------------------------------------------- 1 | from .commands import ( 2 | CHANGE_MODE, 3 | CUSTOM_MODE, 4 | CUSTOM_MODE_TERMINATOR_1, 5 | CUSTOM_MODE_TERMINATOR_2, 6 | RESPONSE_LEN_CHANGE_MODE, 7 | RESPONSE_LEN_CUSTOM_MODE, 8 | ) 9 | 10 | from .utils import speed2slowness 11 | 12 | 13 | __all__ = [ 14 | "RAINBOW_CROSSFADE", 15 | "RED_GRADUALLY", 16 | "GREEN_GRADUALLY", 17 | "BLUE_GRADUALLY", 18 | "YELLOW_GRADUALLY", 19 | "BLUE_GREEN_GRADUALLY", 20 | "PURPLE_GRADUALLY", 21 | "WHITE_GRADUALLY", 22 | "RED_GREEN_CROSSFADE", 23 | "RED_BLUE_CROSSFADE", 24 | "GREEN_BLUE_CROSSFADE", 25 | "RAINBOW_STROBE", 26 | "RED_STROBE", 27 | "GREEN_STROBE", 28 | "BLUE_STROBE", 29 | "YELLOW_STROBE", 30 | "BLUE_GREEN_STROBE", 31 | "PURPLE_STROBE", 32 | "WHITE_STROBE", 33 | "RAINBOW_FLASH", 34 | "NORMAL", 35 | "MODE_GRADUALLY", 36 | "MODE_JUMP", 37 | "MODE_STROBE", 38 | "CUSTOM_MODE_BLANK", 39 | "Mode", 40 | "CustomMode", 41 | "SETUP_MODE", 42 | ] 43 | 44 | 45 | class Mode: 46 | 47 | RESPONSE_LEN = RESPONSE_LEN_CHANGE_MODE 48 | 49 | def __repr__(self): 50 | return "".format(self._status_text()) 51 | 52 | def _status_text(self): 53 | return self.name 54 | 55 | def __init__(self, value, speed, name): 56 | self.value = value 57 | self.speed = speed 58 | self.name = name 59 | 60 | def _make_data(self): # slowness is a integer value 1 to 49 61 | slowness = speed2slowness(self.speed) 62 | d = [CHANGE_MODE, self.value, slowness] 63 | return d 64 | 65 | 66 | class CustomMode(Mode): 67 | 68 | RESPONSE_LEN = RESPONSE_LEN_CUSTOM_MODE 69 | 70 | def __repr__(self): 71 | return "".format( 72 | len(self.colors), 73 | self.speed, 74 | self.name, 75 | ) 76 | 77 | def __init__(self, mode, speed, colors): 78 | super().__init__(CUSTOM, speed, "CUSTOM") 79 | self.colors = colors 80 | self.speed = speed 81 | self.mode = mode 82 | self._color_list = self._make_colors_list() 83 | 84 | def _trim_colors_list(self): 85 | blank_colors = [CUSTOM_MODE_BLANK] 86 | if len(self.colors) < 16: 87 | diff = 16 - len(self.colors) 88 | return self.colors + blank_colors * diff 89 | if len(self.colors) >= 16: 90 | return self.colors[:16] 91 | 92 | def _make_colors_list(self): 93 | ls = [] 94 | colors = self._trim_colors_list() 95 | for r, g, b in colors: 96 | ls.append(r) 97 | ls.append(g) 98 | ls.append(b) 99 | ls.append(0) 100 | return ls 101 | 102 | def _make_data(self): 103 | data = ( 104 | [CUSTOM_MODE] 105 | + self._color_list 106 | + [speed2slowness(self.speed)] 107 | + [self.mode] 108 | + [CUSTOM_MODE_TERMINATOR_1, CUSTOM_MODE_TERMINATOR_2] 109 | ) 110 | return data 111 | 112 | 113 | _RAINBOW_CROSSFADE = 0x25 114 | _RED_GRADUALLY = 0x26 115 | _GREEN_GRADUALLY = 0x27 116 | _BLUE_GRADUALLY = 0x28 117 | _YELLOW_GRADUALLY = 0x29 118 | _BLUE_GREEN_GRADUALLY = 0x2A 119 | _PURPLE_GRADUALLY = 0x2B 120 | _WHITE_GRADUALLY = 0x2C 121 | _RED_GREEN_CROSSFADE = 0x2D 122 | _RED_BLUE_CROSSFADE = 0x2E 123 | _GREEN_BLUE_CROSSFADE = 0x2F 124 | _RAINBOW_STROBE = 0x30 125 | _RED_STROBE = 0x31 126 | _GREEN_STROBE = 0x32 127 | _BLUE_STROBE = 0x33 128 | _YELLOW_STROBE = 0x34 129 | _BLUE_GREEN_STROBE = 0x35 130 | _PURPLE_STROBE = 0x36 131 | _WHITE_STROBE = 0x37 132 | _RAINBOW_FLASH = 0x38 133 | _NORMAL = 0x61 134 | _CUSTOM = 0x60 135 | _SETUP = 0x63 136 | 137 | MODE_GRADUALLY = 0x3A 138 | MODE_JUMP = 0x3B 139 | MODE_STROBE = 0x3C 140 | CUSTOM_MODE_BLANK = (0x1, 0x2, 0x3) 141 | 142 | _VALUE_TO_NAME = { 143 | MODE_GRADUALLY: "GRADUALLY", 144 | MODE_JUMP: "JUMP", 145 | MODE_STROBE: "STROBE", 146 | } 147 | 148 | 149 | RAINBOW_CROSSFADE = Mode(_RAINBOW_CROSSFADE, 1, "RAINBOW_CROSSFADE") 150 | RED_GRADUALLY = Mode(_RED_GRADUALLY, 1, "RED_GRADUALLY") 151 | GREEN_GRADUALLY = Mode(_GREEN_GRADUALLY, 1, "GREEN_GRADUALLY") 152 | BLUE_GRADUALLY = Mode(_BLUE_GRADUALLY, 1, "BLUE_GRADUALLY") 153 | YELLOW_GRADUALLY = Mode(_YELLOW_GRADUALLY, 1, "YELLOW_GRADUALLY") 154 | BLUE_GREEN_GRADUALLY = Mode(_BLUE_GREEN_GRADUALLY, 1, "BLUE_GREEN_GRADUALLY") 155 | PURPLE_GRADUALLY = Mode(_PURPLE_GRADUALLY, 1, "PURPLE_GRADUALLY") 156 | WHITE_GRADUALLY = Mode(_WHITE_GRADUALLY, 1, "WHITE_GRADUALLY") 157 | RED_GREEN_CROSSFADE = Mode(_RED_GREEN_CROSSFADE, 1, "_RED_GREEN_CROSSFADE") 158 | RED_BLUE_CROSSFADE = Mode(_RED_BLUE_CROSSFADE, 1, "RED_BLUE_CROSSFADE") 159 | GREEN_BLUE_CROSSFADE = Mode(_GREEN_BLUE_CROSSFADE, 1, "GREEN_BLUE_CROSSFADE") 160 | RAINBOW_STROBE = Mode(_RAINBOW_STROBE, 1, "RAINBOW_STROBE") 161 | RED_STROBE = Mode(_RED_STROBE, 1, "RED_STROBE") 162 | GREEN_STROBE = Mode(_GREEN_STROBE, 1, "GREEN_STROBE") 163 | BLUE_STROBE = Mode(_BLUE_STROBE, 1, "BLUE_STROBE") 164 | YELLOW_STROBE = Mode(_YELLOW_STROBE, 1, "YELLOW_STROBE") 165 | BLUE_GREEN_STROBE = Mode(_BLUE_GREEN_STROBE, 1, "BLUE_GREEN_STROBE") 166 | PURPLE_STROBE = Mode(_PURPLE_STROBE, 1, "PURPLE_STROBE") 167 | WHITE_STROBE = Mode(_WHITE_STROBE, 1, "WHITE_STROBE") 168 | RAINBOW_FLASH = Mode(_RAINBOW_FLASH, 1, "RAINBOW_FLASH") 169 | NORMAL = Mode(_NORMAL, 1, "NORMAL") 170 | CUSTOM = Mode(_CUSTOM, 1, "CUSTOM") 171 | SETUP_MODE = Mode(_SETUP, 1, "SETUP") 172 | 173 | _VALUE_TO_MODE = { 174 | _RAINBOW_CROSSFADE: RAINBOW_CROSSFADE, 175 | _RED_GRADUALLY: RED_GRADUALLY, 176 | _GREEN_GRADUALLY: GREEN_GRADUALLY, 177 | _BLUE_GRADUALLY: BLUE_GRADUALLY, 178 | _YELLOW_GRADUALLY: YELLOW_GRADUALLY, 179 | _BLUE_GREEN_GRADUALLY: BLUE_GREEN_GRADUALLY, 180 | _PURPLE_GRADUALLY: PURPLE_GRADUALLY, 181 | _WHITE_GRADUALLY: WHITE_GRADUALLY, 182 | _RED_GREEN_CROSSFADE: RED_GREEN_CROSSFADE, 183 | _RED_BLUE_CROSSFADE: RED_BLUE_CROSSFADE, 184 | _GREEN_BLUE_CROSSFADE: GREEN_BLUE_CROSSFADE, 185 | _RAINBOW_STROBE: RAINBOW_STROBE, 186 | _GREEN_STROBE: GREEN_STROBE, 187 | _BLUE_STROBE: BLUE_STROBE, 188 | _YELLOW_STROBE: YELLOW_STROBE, 189 | _BLUE_GREEN_STROBE: BLUE_GREEN_STROBE, 190 | _PURPLE_STROBE: PURPLE_STROBE, 191 | _WHITE_STROBE: WHITE_STROBE, 192 | _RAINBOW_FLASH: RAINBOW_FLASH, 193 | _NORMAL: NORMAL, 194 | _CUSTOM: CUSTOM, 195 | _SETUP: SETUP_MODE, 196 | } 197 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # python-magichue 2 | 3 | ![demo](https://github.com/namacha/python-magichue/raw/image/hue.gif) 4 | 5 | Magichue(as known as Magichome, FluxLED, etc.) is a cheap smart led bulb that you can controll hue/saturation/brightnes and power over WiFi. They are available at Amazon or other online web shop. 6 | 7 | I tested this library with RGBWWCW(v7), RGB(v8), RGBWW(v8) bulbs. 8 | 9 | 10 | **Now it is possible to use Remote API !** 11 | 12 | # Example 13 | Rainbow cross-fade. 14 | ```python 15 | import time 16 | import magichue 17 | 18 | user = 'username@example.com' 19 | password = 'password' 20 | api = magichue.RemoteAPI.login_with_user_password(user=user, password=password) 21 | light = api.get_online_bulbs()[0] 22 | 23 | # local_device_ips = magichue.discover_bulbs() 24 | # light = magichue.LocalLight(local_device_ips[0]) 25 | 26 | 27 | if not light.on: 28 | light.on = True 29 | 30 | if light.is_white: 31 | light.is_white = False 32 | 33 | light.rgb = (0, 0, 0) 34 | light.brightness = 255 35 | light.saturation = 1 36 | 37 | for hue in range(1000): 38 | light.hue = hue / 1000 39 | time.sleep(0.05) 40 | 41 | ``` 42 | 43 | 44 | # Installation 45 | ``` 46 | $ pip install python-magichue 47 | ``` 48 | 49 | # Usage 50 | 51 | ## Remote API 52 | You have to login and register your bulb with MagicHome account in advance. 53 | 54 | ### Login with Username/Password 55 | ```python 56 | api = magichue.RemoteAPI.login_with_user_password(user='xxx', password='xxx') 57 | print(api.token) # you can show TOKEN and save it. 58 | ``` 59 | 60 | ### Login with Token 61 | It is recommended to use token string. 62 | ```python 63 | TOKEN = 'xxx' 64 | api = magichue.RemoteAPI.login_with_token(TOKEN) 65 | ``` 66 | ### Make bulb instance 67 | ```python 68 | TOKEN = 'xxx' 69 | api = magichue.RemoteAPI.login_with_token(TOKEN) 70 | light = RemoteLight(api=api, macaddr='xxx') 71 | ``` 72 | 73 | ## Discover bulbs 74 | 75 | ### Local bulbs 76 | ```python 77 | from magichue import discover_bulbs, LocalLight 78 | addrs = discover_bulbs() # returns list of bulb address 79 | light = magichue.LocalLight(addrs[0]) 80 | ``` 81 | 82 | ### Remote bulbs 83 | ```python 84 | from magichue import RemoteAPI 85 | 86 | TOKEN = 'xxx' 87 | api = magichue.RemoteAPI.login_with_token(TOKEN) 88 | online_bulbs = api.get_online_bulbs() 89 | light = online_bulbs[0] 90 | 91 | # Getting online device information. 92 | online_devices = api.get_online_devices() 93 | # It is also possible to retrieve all device info binded with your account. 94 | all_devices = api.get_all_devices() 95 | ``` 96 | 97 | ## Power State 98 | 99 | ### Getting power status. 100 | ```python 101 | print(light.on) # => True if light is on else False 102 | ``` 103 | 104 | ### Setting light on/off. 105 | ```python 106 | light.on = True 107 | light.on = False 108 | # or 109 | light.turn_on() 110 | light.turn_off() 111 | ``` 112 | 113 | ## Getting color 114 | This shows a tuple of current RGB. 115 | ```python 116 | print(light.rgb) 117 | ``` 118 | or access individually. 119 | ```python 120 | print(light.r) 121 | print(light.g) 122 | print(light.b) 123 | ``` 124 | 125 | ## White LEDs 126 | If your bulbs support white leds, you can change brightness(0-255) of white leds. 127 | 128 | To use white led, 129 | ```python 130 | light.is_white = True 131 | # light.is_white = False # This disables white led. 132 | ``` 133 | 134 | **If white led is enabled, you can't change color of bulb!** 135 | So, you need to execute ``` light.is_white = False ``` before changing color. 136 | 137 | ### Warm White(ww) 138 | ```python 139 | light.cw = 0 140 | light.w = 255 141 | ``` 142 | 143 | ### Cold White (cw) 144 | ```python 145 | light.w = 0 146 | light.cw = 255 147 | ``` 148 | 149 | ## Setting color 150 | ### By rgb 151 | ```python 152 | light.rgb = (128, 0, 32) 153 | ``` 154 | or 155 | ```python 156 | light.r = 200 157 | light.g = 0 158 | light.b = 32 159 | ``` 160 | 161 | ### By hsb 162 | ```python 163 | light.hue = 0.3 164 | light.saturation = 0.6 165 | light.brightness = 255 166 | ``` 167 | hue, saturation are float value from 0 to 1. brightness is a integer value from 0 to 255. 168 | These variables are also readable. 169 | 170 | ### Note about stripe bulb 171 | Stripe bulb doesn't seem to allow jump to another color when you change color. 172 | To disable fading effect, 173 | ```python 174 | light.rgb = (128, 0, 20) # It fades 175 | light.allow_fading = False # True by default 176 | light.rgb = (20, 0, 128) # Jumps 177 | ``` 178 | 179 | ## Bulb clock 180 | ```python 181 | print(light.get_current_time()) 182 | ``` 183 | 184 | 185 | ## Changing mode 186 | Magichue blub has a built-in flash patterns. 187 | 188 | To check current mode, just 189 | ```python 190 | print(light.mode.name) # string name of mode 191 | print(light.mode.value) # integer value 192 | ``` 193 | 194 | and changing modes, 195 | ```python 196 | light.mode = magichue.RAINBOW_CROSSFADE 197 | ``` 198 | 199 | 200 | These are built-in modes. 201 | ``` 202 | RAINBOW_CROSSFADE 203 | RED_GRADUALLY 204 | GREEN_GRADUALLY 205 | BLUE_GRADUALLY 206 | YELLOW_GRADUALLY 207 | BLUE_GREEN_GRADUALLY 208 | PURPLE_GRADUALLY 209 | WHITE_GRADUALLY 210 | RED_GREEN_CROSSFADE 211 | RED_BLUE_CROSSFADE 212 | GREEN_BLUE_CROSSFADE 213 | RAINBOW_STROBE 214 | GREEN_STROBE 215 | BLUE_STROBE 216 | YELLOW_STROBE 217 | BLUE_GREEN_STROBE 218 | PURPLE_STROBE 219 | WHITE_STROBE 220 | RAINBOW_FLASH 221 | NORMAL 222 | ``` 223 | 224 | 225 | ### Changing the speed of mode 226 | 227 | speed is a float value from 0 to 1. 228 | 229 | ```python 230 | print(light.speed) 231 | 232 | light.speed = 0.5 # set speed to 50% 233 | ``` 234 | 235 | 236 | 237 | 238 | ### Custom Modes 239 | You can create custom light flash patterns. 240 | 241 | **mode** 242 | - MODE_JUMP 243 | - MODE_GRADUALLY 244 | - MODE_STROBE 245 | 246 | **speed** 247 | A float value 0 to 1 248 | 249 | **colors** 250 | A list of rgb(tuple or list) which has less than 17 length. 251 | 252 | ```python 253 | from magichue import ( 254 | CustomMode, 255 | MODE_JUMP, 256 | ) 257 | 258 | 259 | # Creating Mode 260 | mypattern1 = CustomMode( 261 | mode=MODE_JUMP, 262 | speed=0.5, 263 | colors=[ 264 | (128, 0, 32), 265 | (100, 20, 0), 266 | (30, 30, 100), 267 | (0, 0, 50) 268 | ] 269 | ) 270 | 271 | # Apply Mode 272 | light.mode = mypattern1 273 | ``` 274 | 275 | --- 276 | Other features are in development. 277 | 278 | ## Debugging 279 | Putting this snippet to begging of your code, this library outputs debug log. 280 | ```python 281 | import loggging 282 | logging.basicConfig(level=logging.DEBUG) 283 | ``` 284 | -------------------------------------------------------------------------------- /magichue/light.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta, abstractmethod 2 | from datetime import datetime 3 | import struct 4 | import socket 5 | import select 6 | import colorsys 7 | import logging 8 | 9 | from .commands import Command, TurnON, TurnOFF, QueryStatus, QueryCurrentTime 10 | from .exceptions import ( 11 | InvalidData, 12 | DeviceOffline, 13 | DeviceDisconnected, 14 | ) 15 | 16 | from .magichue import Status 17 | from . import modes 18 | from . import bulb_types 19 | from . import utils 20 | 21 | 22 | _LOGGER = logging.getLogger(__name__) 23 | 24 | 25 | class AbstractLight(metaclass=ABCMeta): 26 | """An abstract class of MagicHue Light.""" 27 | 28 | _LOGGER = logging.getLogger(__name__ + ".AbstractLight") 29 | 30 | status: Status 31 | allow_fading: bool = True 32 | 33 | def __repr__(self): 34 | on = "on" if self.status.on else "off" 35 | class_name = self.__class__.__name__ 36 | if self.status.mode.value != modes._NORMAL: 37 | return "<%s: %s (%s)>" % (class_name, on, self.status.mode.name) 38 | else: 39 | if self.status.bulb_type == bulb_types.BULB_RGBWW: 40 | return "<{}: {} (r:{} g:{} b:{} w:{})>".format( 41 | class_name, 42 | on, 43 | *(self.status.rgb()), 44 | self.status.w, 45 | ) 46 | if self.status.bulb_type == bulb_types.BULB_RGBWWCW: 47 | return "<{}: {} (r:{} g:{} b:{} w:{} cw:{})>".format( 48 | class_name, 49 | on, 50 | *(self.status.rgb()), 51 | self.status.w, 52 | self.status.cw, 53 | ) 54 | if self.status.bulb_type == bulb_types.BULB_TAPE: 55 | return "<{}: {} (r:{} g:{} b:{})>".format( 56 | class_name, 57 | on, 58 | *(self.status.rgb()), 59 | ) 60 | 61 | @property 62 | def on(self): 63 | return self.status.on 64 | 65 | @on.setter 66 | def on(self, value): 67 | if not isinstance(value, bool): 68 | raise ValueError("Invalid value: Should be True or False") 69 | if value: 70 | self.status.on = True 71 | return self.turn_on() 72 | else: 73 | self.status.on = False 74 | return self.turn_off() 75 | 76 | @property 77 | def rgb(self): 78 | return self.status.rgb() 79 | 80 | @rgb.setter 81 | def rgb(self, rgb): 82 | self.status.update_rgb(rgb) 83 | self._apply_status() 84 | 85 | @property 86 | def r(self): 87 | return self.status.r 88 | 89 | @r.setter 90 | def r(self, v): 91 | self.status.update_r(v) 92 | self._apply_status() 93 | 94 | @property 95 | def g(self): 96 | return self.status.g 97 | 98 | @g.setter 99 | def g(self, v): 100 | self.status.update_g(v) 101 | self._apply_status() 102 | 103 | @property 104 | def b(self): 105 | return self.status.b 106 | 107 | @b.setter 108 | def b(self, v): 109 | self.status.update_b(v) 110 | self._apply_status() 111 | 112 | @property 113 | def w(self): 114 | return self.status.w 115 | 116 | @w.setter 117 | def w(self, v): 118 | self.status.update_w(v) 119 | self._apply_status() 120 | 121 | @property 122 | def cw(self): 123 | return self.status.cw 124 | 125 | @cw.setter 126 | def cw(self, v): 127 | self.status.update_cw(v) 128 | self._apply_status() 129 | 130 | @property 131 | def cww(self): 132 | return (self.status.cw, self.status.w) 133 | 134 | @cww.setter 135 | def cww(self, cww): 136 | cw, w = cww 137 | self.status.update_cw(cw) 138 | self.status.update_w(w) 139 | self._apply_status() 140 | 141 | @property 142 | def is_white(self): 143 | return self.status.is_white 144 | 145 | @is_white.setter 146 | def is_white(self, v): 147 | if not isinstance(v, bool): 148 | raise ValueError("Invalid value: value must be a bool.") 149 | self.status.is_white = v 150 | self._apply_status() 151 | 152 | @property 153 | def hue(self): 154 | h = colorsys.rgb_to_hsv(*self.status.rgb())[0] 155 | return h 156 | 157 | @hue.setter 158 | def hue(self, h): 159 | if not h <= 1: 160 | raise ValueError("arg must not be more than 1") 161 | sb = colorsys.rgb_to_hsv(*self.status.rgb())[1:] 162 | rgb = map(int, colorsys.hsv_to_rgb(h, *sb)) 163 | self.status.update_rgb(rgb) 164 | self._apply_status() 165 | 166 | @property 167 | def saturation(self): 168 | s = colorsys.rgb_to_hsv(*self.status.rgb())[1] 169 | return s 170 | 171 | @saturation.setter 172 | def saturation(self, s): 173 | if not s <= 1: 174 | raise ValueError("arg must not be more than 1") 175 | h, v = colorsys.rgb_to_hsv(*self.status.rgb())[::2] 176 | rgb = map(int, colorsys.hsv_to_rgb(h, s, v)) 177 | self.status.update_rgb(rgb) 178 | self._apply_status() 179 | 180 | @property 181 | def brightness(self): 182 | if self.is_white: 183 | b = self.w 184 | else: 185 | b = colorsys.rgb_to_hsv(*self.status.rgb())[2] 186 | return b 187 | 188 | @brightness.setter 189 | def brightness(self, v): 190 | if self.is_white: 191 | self.status.update_w(v) 192 | else: 193 | hs = colorsys.rgb_to_hsv(*self.status.rgb())[:2] 194 | rgb = map(int, colorsys.hsv_to_rgb(hs[0], hs[1], v)) 195 | self.status.update_rgb(rgb) 196 | self._apply_status() 197 | 198 | @property 199 | def speed(self): 200 | return self.status.speed 201 | 202 | @speed.setter 203 | def speed(self, value): 204 | value = utils.round_value(value, 0, 1) 205 | self.status.speed = value 206 | self.mode.speed = value 207 | self._set_mode(self.mode) 208 | 209 | @property 210 | def mode(self): 211 | return self.status.mode 212 | 213 | @mode.setter 214 | def mode(self, v): 215 | if not isinstance(v, modes.Mode): 216 | raise ValueError("Invalid value: value must be a instance of Mode") 217 | if isinstance(v, modes.CustomMode): 218 | self.status.speed = v.speed 219 | self.status.mode = v 220 | self._set_mode(v) 221 | 222 | @abstractmethod 223 | def _send_command(self, cmd: Command, send_only: bool = True): 224 | pass 225 | 226 | def _set_mode(self, _mode): 227 | self._LOGGER.debug("_set_mode") 228 | cmd = Command.from_array(_mode._make_data()) 229 | self._send_command(cmd) 230 | 231 | def _get_status_data(self): 232 | self._LOGGER.debug("_get_status_data") 233 | data = self._send_command(QueryStatus, send_only=False) 234 | return data 235 | 236 | def get_current_time(self) -> datetime: 237 | """Get bulb clock time.""" 238 | self._LOGGER.debug("get_current_time") 239 | 240 | data = self._send_command(QueryCurrentTime, send_only=False) 241 | bulb_date = datetime( 242 | data[3] + 2000, # Year 243 | data[4], # Month 244 | data[5], # Date 245 | data[6], # Hour 246 | data[7], # Minute 247 | data[8], # Second 248 | ) 249 | return bulb_date 250 | 251 | def turn_on(self): 252 | """Trun bulb power on""" 253 | self._LOGGER.debug("turn_on") 254 | self._send_command(TurnON) 255 | self.status.on = True 256 | 257 | def turn_off(self): 258 | """Trun bulb power off""" 259 | self._LOGGER.debug("turn_off") 260 | self._send_command(TurnOFF) 261 | self.status.on = False 262 | 263 | def update_status(self): 264 | """Sync local status with bulb""" 265 | self._update_status() 266 | 267 | def _update_status(self): 268 | data = self._get_status_data() 269 | self.status.parse(data) 270 | 271 | def _apply_status(self): 272 | self._LOGGER.debug("_apply_status") 273 | data = self.status.make_data() 274 | if not self.allow_fading: 275 | self._LOGGER.debug("allow_fading is False") 276 | c = modes.CustomMode(mode=modes.MODE_JUMP, speed=0.1, colors=[self.rgb]) 277 | self._set_mode(c) 278 | cmd = Command.from_array(data) 279 | self._send_command(cmd) 280 | 281 | 282 | class RemoteLight(AbstractLight): 283 | 284 | _LOGGER = logging.getLogger(__name__ + ".RemoteLight") 285 | 286 | def __init__(self, api, macaddr: str, allow_fading: bool = True): 287 | self.api = api 288 | self.macaddr = macaddr 289 | self.status = Status() 290 | self.allow_fading = allow_fading 291 | self._update_status() 292 | 293 | def _send_command(self, cmd: Command, send_only: bool = True): 294 | self._LOGGER.debug( 295 | "Sending command({}) to: {}".format( 296 | cmd.__name__, 297 | self.macaddr, 298 | ) 299 | ) 300 | if send_only: 301 | return self.api._send_command(cmd, self.macaddr) 302 | else: 303 | data = self.str2hexarray(self._send_request(cmd)) 304 | if len(data) != cmd.response_len: 305 | raise InvalidData( 306 | "Expect length: %d, got %d\n%s" 307 | % (cmd.response_len, len(data), str(data)) 308 | ) 309 | return data 310 | 311 | def _send_request(self, cmd: Command): 312 | return self.api._send_request(cmd, self.macaddr) 313 | 314 | @staticmethod 315 | def str2hexarray(hexstr: str) -> tuple: 316 | ls = [int(hexstr[i : i + 2], 16) for i in range(0, len(hexstr), 2)] 317 | return tuple(ls) 318 | 319 | 320 | class LocalLight(AbstractLight): 321 | 322 | _LOGGER = logging.getLogger(__name__ + ".LocalLight") 323 | 324 | port = 5577 325 | timeout = 1 326 | 327 | def __init__(self, ipaddr: str, allow_fading: bool = True): 328 | self.ipaddr = ipaddr 329 | self._connect() 330 | self.status = Status() 331 | self.allow_fading = allow_fading 332 | self._update_status() 333 | 334 | def _connect(self): 335 | self._LOGGER.debug("Trying to make a connection with bulb(%s)" % self.ipaddr) 336 | self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 337 | self._sock.settimeout(self.timeout) 338 | self._sock.connect((self.ipaddr, self.port)) 339 | self._LOGGER.debug("Connection has been established with %s" % self.ipaddr) 340 | 341 | def _send(self, data): 342 | self._LOGGER.debug("Trying to send data(%s) to %s" % (str(data), self.ipaddr)) 343 | if self._sock._closed: 344 | raise DeviceDisconnected 345 | self._sock.send(data) 346 | 347 | def _receive(self, length): 348 | self._LOGGER.debug( 349 | "Trying to receive %d bytes data from %s" % (length, self.ipaddr) 350 | ) 351 | if self._sock._closed: 352 | raise DeviceDisconnected 353 | 354 | data = self._sock.recv(length) 355 | self._LOGGER.debug("Got %d bytes data from %s" % (len(data), self.ipaddr)) 356 | self._LOGGER.debug("Received data: %s" % str(data)) 357 | return data 358 | 359 | def _flush_receive_buffer(self): 360 | self._LOGGER.debug("Flushing receive buffer") 361 | if self._sock._closed: 362 | raise DeviceDisconnected 363 | while True: 364 | read_sock, _, _ = select.select([self._sock], [], [], self.timeout) 365 | if not read_sock: 366 | self._LOGGER.debug("Nothing received. buffer has been flushed") 367 | break 368 | self._LOGGER.debug("There is stil something in the buffer") 369 | _ = self._receive(255) 370 | if not _: 371 | raise DeviceDisconnected 372 | 373 | def _send_command(self, cmd: Command, send_only: bool = True): 374 | self._LOGGER.debug( 375 | "Sending command({}) to {}: {}".format( 376 | cmd.__name__, 377 | self.ipaddr, 378 | cmd.byte_string(), 379 | ) 380 | ) 381 | if send_only: 382 | self._send(cmd.byte_string()) 383 | else: 384 | self._flush_receive_buffer() 385 | self._send(cmd.byte_string()) 386 | data = self._receive(cmd.response_len) 387 | decoded_data = struct.unpack("!%dB" % len(data), data) 388 | if len(data) == cmd.response_len: 389 | return decoded_data 390 | else: 391 | raise InvalidData( 392 | "Expect length: %d, got %d\n%s" 393 | % (cmd.response_len, len(decoded_data), str(decoded_data)) 394 | ) 395 | 396 | def _connect(self, timeout=3): 397 | self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 398 | self._sock.settimeout(timeout) 399 | self._sock.connect((self.ipaddr, self.port)) 400 | -------------------------------------------------------------------------------- /magichue/magichue.py: -------------------------------------------------------------------------------- 1 | import socket 2 | import select 3 | import struct 4 | import colorsys 5 | 6 | from . import modes 7 | from . import bulb_types 8 | from . import commands 9 | from . import utils 10 | 11 | PORT = 5577 12 | 13 | 14 | class Status(object): 15 | 16 | ON = 0x23 17 | OFF = 0x24 18 | 19 | def __init__(self, r=0, g=0, b=0, w=0, cw=0, is_white=True, on=True): 20 | self.r = r 21 | self.g = g 22 | self.b = b 23 | self.w = w # brightness of warm white light 24 | self.cw = cw # brightness of cold white light 25 | self.is_white = is_white # use warm white light 26 | self.on = on 27 | self.speed = 1.0 # maximum by default 28 | self.mode = modes.NORMAL 29 | self.bulb_type = bulb_types.BULB_RGBWW 30 | self.version = 0 31 | 32 | def update_r(self, v): 33 | self.r = utils.round_value(v, 0, 255) 34 | 35 | def update_g(self, v): 36 | self.g = utils.round_value(v, 0, 255) 37 | 38 | def update_b(self, v): 39 | self.b = utils.round_value(v, 0, 255) 40 | 41 | def update_rgb(self, v): 42 | try: 43 | r, g, b = v 44 | except ValueError: 45 | raise ValueError( 46 | "Invalid value: rgb must be a list or tuple which has 3 items" 47 | ) 48 | self.update_r(r) 49 | self.update_g(g) 50 | self.update_b(b) 51 | 52 | def update_w(self, v): 53 | self.w = utils.round_value(v, 0, 255) 54 | 55 | def update_cw(self, v): 56 | self.cw = utils.round_value(v, 0, 255) 57 | 58 | def rgb(self): 59 | return (self.r, self.g, self.b) 60 | 61 | def parse(self, data): 62 | if data[0] != 0x81: 63 | return 64 | self.bulb_type = data[1] 65 | self.on = data[2] == commands.ON 66 | mode_value = data[3] 67 | self.r, self.g, self.b, self.w = data[6:10] 68 | self.version = data[10] 69 | self.cw = data[11] 70 | self.is_white = data[12] == commands.TRUE 71 | self.mode = modes._VALUE_TO_MODE.get( 72 | mode_value, modes.Mode(mode_value, 1, "UNKOWN") 73 | ) 74 | slowness = data[5] 75 | self.speed = utils.slowness2speed(slowness) 76 | 77 | def make_data(self): 78 | is_white = 0x0F if self.is_white else 0xF0 79 | if self.bulb_type == bulb_types.BULB_RGBWWCW: 80 | data = [ 81 | commands.SET_COLOR, 82 | self.r, 83 | self.g, 84 | self.b, 85 | self.w if self.w else 0, 86 | self.cw, 87 | is_white, 88 | 0x0F, # 0x0f is a terminator 89 | ] 90 | else: 91 | data = [ 92 | commands.SET_COLOR, 93 | self.r, 94 | self.g, 95 | self.b, 96 | self.w if self.w else 0, 97 | is_white, 98 | 0x0F, # 0x0f is a terminator 99 | ] 100 | return data 101 | 102 | 103 | class Light(object): 104 | 105 | PORT = 5577 106 | 107 | def __repr__(self): 108 | on = "on" if self.on else "off" 109 | if self._status.mode.value != modes._NORMAL: 110 | return "" % (on, self._status.mode.name) 111 | else: 112 | if self._status.bulb_type == bulb_types.BULB_RGBWW: 113 | return "".format( 114 | on, 115 | *(self._status.rgb()), 116 | self._status.w, 117 | ) 118 | if self._status.bulb_type == bulb_types.BULB_RGBWWCW: 119 | return "".format( 120 | on, 121 | *(self._status.rgb()), 122 | self._status.w, 123 | self._status.cw, 124 | ) 125 | if self._status.bulb_type == bulb_types.BULB_TAPE: 126 | return "".format( 127 | on, 128 | *(self._status.rgb()), 129 | ) 130 | 131 | def __init__( 132 | self, 133 | addr, 134 | port=PORT, 135 | name="None", 136 | confirm_receive_on_send=False, 137 | allow_fading=True, 138 | ): 139 | 140 | import warnings 141 | 142 | message = "`Light` is deprecated and will be removed in the future. Use `LocalLight` or `RemoteLight` instead" 143 | warnings.warn(message, UserWarning) 144 | 145 | self.addr = addr 146 | self.port = port 147 | self.name = name 148 | 149 | self.confirm_receive_on_send = confirm_receive_on_send 150 | self.allow_fading = allow_fading 151 | 152 | self._status = Status() 153 | self._connect() 154 | self._update_status() 155 | 156 | def _connect(self, timeout=1): 157 | self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 158 | self._sock.settimeout(timeout) 159 | self._sock.connect((self.addr, self.port)) 160 | 161 | def _send(self, data): 162 | return self._sock.send(data) 163 | 164 | def _receive(self, length): 165 | return self._sock.recv(length) 166 | 167 | def _send_with_checksum(self, data, response_len, receive=True): 168 | data_with_checksum = self._attach_checksum(data) 169 | format_str = "!%dB" % len(data_with_checksum) 170 | data = struct.pack(format_str, *data_with_checksum) 171 | self._send(data) 172 | if receive: 173 | response = self._receive(response_len) 174 | return response 175 | 176 | def _turn_on(self): 177 | on_data = [commands.TURN_ON_1, commands.TURN_ON_2, commands.TURN_ON_3] 178 | return self._send_with_checksum( 179 | on_data, commands.RESPONSE_LEN_POWER, receive=self.confirm_receive_on_send 180 | ) 181 | 182 | def _turn_off(self): 183 | off_data = [commands.TURN_OFF_1, commands.TURN_OFF_2, commands.TURN_OFF_3] 184 | return self._send_with_checksum( 185 | off_data, commands.RESPONSE_LEN_POWER, receive=self.confirm_receive_on_send 186 | ) 187 | 188 | def _flush_receive_buffer(self, timeout=0.2): 189 | while True: 190 | read_sock, _, _ = select.select([self._sock], [], [], timeout) 191 | if not read_sock: 192 | break 193 | _ = self._sock.recv(255) 194 | 195 | def _confirm_checksum(self, received): 196 | data_without_checksum = received[:-1] 197 | calculated_checksum = self._calc_checksum(data_without_checksum) 198 | received_checksum = received[-1] 199 | return calculated_checksum == received_checksum 200 | 201 | def _calc_checksum(self, data): 202 | hex_checksum = hex(sum(data)) 203 | checksum = int(hex_checksum[-2:], 16) 204 | return checksum 205 | 206 | def _attach_checksum(self, data): 207 | checksum = self._calc_checksum(data) 208 | return data + [checksum] 209 | 210 | def _get_status_data(self): 211 | self._flush_receive_buffer() 212 | cmd = [ 213 | commands.QUERY_STATUS_1, 214 | commands.QUERY_STATUS_2, 215 | commands.QUERY_STATUS_3, 216 | ] 217 | 218 | raw_data = self._send_with_checksum( 219 | cmd, 220 | commands.RESPONSE_LEN_QUERY_STATUS, 221 | ) 222 | data = struct.unpack("!%dB" % commands.RESPONSE_LEN_QUERY_STATUS, raw_data) 223 | return data 224 | 225 | def _update_status(self): 226 | data = self._get_status_data() 227 | self._status.parse(data) 228 | 229 | def update_status(self): 230 | self._update_status() 231 | 232 | def _apply_status(self): 233 | data = self._status.make_data() 234 | if not self.allow_fading: 235 | c = modes.CustomMode( 236 | mode=modes.MODE_JUMP, 237 | speed=0.1, 238 | colors=[(self._status.r, self._status.g, self._status.b)], 239 | ) 240 | self._set_mode(c) 241 | self._send_with_checksum( 242 | data, commands.RESPONSE_LEN_SET_COLOR, receive=self.confirm_receive_on_send 243 | ) 244 | 245 | @property 246 | def rgb(self): 247 | return self._status.rgb() 248 | 249 | @rgb.setter 250 | def rgb(self, rgb): 251 | self._status.update_rgb(rgb) 252 | self._apply_status() 253 | 254 | @property 255 | def r(self): 256 | return self._status.r 257 | 258 | @r.setter 259 | def r(self, v): 260 | self._status.update_r(v) 261 | self._apply_status() 262 | 263 | @property 264 | def g(self): 265 | return self._status.g 266 | 267 | @g.setter 268 | def g(self, v): 269 | self._status.update_g(v) 270 | self._apply_status() 271 | 272 | @property 273 | def b(self): 274 | return self._status.b 275 | 276 | @b.setter 277 | def b(self, v): 278 | self._status.update_b(v) 279 | self._apply_status() 280 | 281 | @property 282 | def w(self): 283 | return self._status.w 284 | 285 | @w.setter 286 | def w(self, v): 287 | self._status.update_w(v) 288 | self._apply_status() 289 | 290 | @property 291 | def cw(self): 292 | return self._status.cw 293 | 294 | @cw.setter 295 | def cw(self, v): 296 | self._status.update_cw(v) 297 | self._apply_status() 298 | 299 | @property 300 | def cww(self): 301 | return (self._status.cw, self._status.w) 302 | 303 | @cww.setter 304 | def cww(self, cww): 305 | self._status.update_cw(cww[0]) 306 | self._status.update_w(cww[1]) 307 | self._apply_status() 308 | 309 | @property 310 | def is_white(self): 311 | return self._status.is_white 312 | 313 | @is_white.setter 314 | def is_white(self, v): 315 | if not isinstance(v, bool): 316 | raise ValueError("Invalid value: value must be a bool.") 317 | self._status.is_white = v 318 | self._apply_status() 319 | 320 | @property 321 | def hue(self): 322 | h = colorsys.rgb_to_hsv(*self._status.rgb())[0] 323 | return h 324 | 325 | @hue.setter 326 | def hue(self, h): 327 | if not h <= 1: 328 | raise ValueError("arg must not be more than 1") 329 | sb = colorsys.rgb_to_hsv(*self._status.rgb())[1:] 330 | rgb = map(int, colorsys.hsv_to_rgb(h, *sb)) 331 | self._status.update_rgb(rgb) 332 | self._apply_status() 333 | 334 | @property 335 | def saturation(self): 336 | s = colorsys.rgb_to_hsv(*self._status.rgb())[1] 337 | return s 338 | 339 | @saturation.setter 340 | def saturation(self, s): 341 | if not s <= 1: 342 | raise ValueError("arg must not be more than 1") 343 | h, v = colorsys.rgb_to_hsv(*self._status.rgb())[::2] 344 | rgb = map(int, colorsys.hsv_to_rgb(h, s, v)) 345 | self._status.update_rgb(rgb) 346 | self._apply_status() 347 | 348 | @property 349 | def brightness(self): 350 | if self.is_white: 351 | b = self.w 352 | else: 353 | b = colorsys.rgb_to_hsv(*self._status.rgb())[2] 354 | return b 355 | 356 | @brightness.setter 357 | def brightness(self, v): 358 | if self.is_white: 359 | self._status.update_w(v) 360 | else: 361 | hs = colorsys.rgb_to_hsv(*self._status.rgb())[:2] 362 | rgb = map(int, colorsys.hsv_to_rgb(hs[0], hs[1], v)) 363 | self._status.update_rgb(rgb) 364 | self._apply_status() 365 | 366 | @property 367 | def speed(self): 368 | return self._status.speed 369 | 370 | @speed.setter 371 | def speed(self, value): 372 | if value >= 1: 373 | value = 1 374 | elif value < 0: 375 | value = 0 376 | value = utils.round_value(value, 0, 1) 377 | self._status.speed = value 378 | self._set_mode(self.mode) 379 | 380 | @property 381 | def on(self): 382 | return self._status.on 383 | 384 | @on.setter 385 | def on(self, value): 386 | if not isinstance(value, bool): 387 | raise ValueError("Invalid value: Should be True or False") 388 | if value: 389 | self._status.on = True 390 | return self._turn_on() 391 | else: 392 | self._status.on = False 393 | return self._turn_off() 394 | 395 | @on.deleter 396 | def on(self): 397 | pass 398 | 399 | @property 400 | def mode_str(self): 401 | import warnings 402 | 403 | message = "`.mode_str` is deprecated and will be removed in the future." 404 | warnings.warn(message, UserWarning) 405 | return "" 406 | 407 | @mode_str.setter 408 | def mode_str(self, value): 409 | pass 410 | 411 | @property 412 | def mode(self): 413 | return self._status.mode 414 | 415 | @mode.setter 416 | def mode(self, mode): 417 | if isinstance(mode, modes.Mode): 418 | mode.speed = self.speed 419 | self._set_mode(mode) 420 | 421 | @mode.deleter 422 | def mode(self): 423 | pass 424 | 425 | def _set_mode(self, mode): 426 | mode.speed = self.speed 427 | self._status.mode = mode 428 | self._send_with_checksum( 429 | mode._make_data(), mode.RESPONSE_LEN, receive=self.confirm_receive_on_send 430 | ) 431 | --------------------------------------------------------------------------------