diff --git a/docs/account.html b/docs/account.html index 33b80ce..5002271 100644 --- a/docs/account.html +++ b/docs/account.html @@ -102,14 +102,14 @@

Classes

} if self.session is None: async with aiohttp.ClientSession() as session: - with async_timeout.timeout(10): + async with async_timeout.timeout(10): async with session.post( AUTHENTICATION_ENDPOINT, json=dataDictionary ) as resp: await checkResponseForError(await resp.text()) return await resp.text() else: - with async_timeout.timeout(10): + async with async_timeout.timeout(10): async with self.session.post( AUTHENTICATION_ENDPOINT, json=dataDictionary ) as resp: @@ -132,12 +132,12 @@

Classes

raise if self.session is None: async with aiohttp.ClientSession() as session: - with async_timeout.timeout(10): + async with async_timeout.timeout(10): async with session.get(uri, headers=headers) as resp: await checkResponseForError(await resp.text()) return await resp.text() else: - with async_timeout.timeout(10): + async with async_timeout.timeout(10): async with self.session.get(uri, headers=headers) as resp: await checkResponseForError(await resp.text()) return await resp.text() @@ -163,7 +163,7 @@

Classes

} if self.session is None: async with aiohttp.ClientSession() as session: - with async_timeout.timeout(10): + async with async_timeout.timeout(10): async with session.post( CONTROLLER_AUTHORIZATION_ENDPOINT, headers=headers, @@ -172,7 +172,7 @@

Classes

await checkResponseForError(await resp.text()) return await resp.text() else: - with async_timeout.timeout(10): + async with async_timeout.timeout(10): async with self.session.post( CONTROLLER_AUTHORIZATION_ENDPOINT, headers=headers, diff --git a/docs/auth.html b/docs/auth.html index a603399..b354f55 100644 --- a/docs/auth.html +++ b/docs/auth.html @@ -75,7 +75,7 @@

Module pyControl4.auth

} } async with aiohttp.ClientSession() as session: - with async_timeout.timeout(10): + async with async_timeout.timeout(10): async with session.post( AUTHENTICATION_ENDPOINT, json=dataDictionary ) as resp: @@ -96,7 +96,7 @@

Module pyControl4.auth

_LOGGER.error(msg) raise async with aiohttp.ClientSession() as session: - with async_timeout.timeout(10): + async with async_timeout.timeout(10): async with session.get(uri, headers=headers) as resp: return await resp.text() @@ -121,7 +121,7 @@

Module pyControl4.auth

} } async with aiohttp.ClientSession() as session: - with async_timeout.timeout(10): + async with async_timeout.timeout(10): async with session.post( CONTROLLER_AUTHORIZATION_ENDPOINT, headers=headers, @@ -276,7 +276,7 @@

Classes

} } async with aiohttp.ClientSession() as session: - with async_timeout.timeout(10): + async with async_timeout.timeout(10): async with session.post( AUTHENTICATION_ENDPOINT, json=dataDictionary ) as resp: @@ -297,7 +297,7 @@

Classes

_LOGGER.error(msg) raise async with aiohttp.ClientSession() as session: - with async_timeout.timeout(10): + async with async_timeout.timeout(10): async with session.get(uri, headers=headers) as resp: return await resp.text() @@ -322,7 +322,7 @@

Classes

} } async with aiohttp.ClientSession() as session: - with async_timeout.timeout(10): + async with async_timeout.timeout(10): async with session.post( CONTROLLER_AUTHORIZATION_ENDPOINT, headers=headers, diff --git a/docs/director.html b/docs/director.html index 587ae3c..b96bfe9 100644 --- a/docs/director.html +++ b/docs/director.html @@ -99,14 +99,14 @@

Classes

async with aiohttp.ClientSession( connector=aiohttp.TCPConnector(verify_ssl=False) ) as session: - with async_timeout.timeout(10): + async with async_timeout.timeout(10): async with session.get( self.base_url + uri, headers=self.headers ) as resp: await checkResponseForError(await resp.text()) return await resp.text() else: - with async_timeout.timeout(10): + async with async_timeout.timeout(10): async with self.session.get( self.base_url + uri, headers=self.headers ) as resp: @@ -135,14 +135,14 @@

Classes

async with aiohttp.ClientSession( connector=aiohttp.TCPConnector(verify_ssl=False) ) as session: - with async_timeout.timeout(10): + async with async_timeout.timeout(10): async with session.post( self.base_url + uri, headers=self.headers, json=dataDictionary ) as resp: await checkResponseForError(await resp.text()) return await resp.text() else: - with async_timeout.timeout(10): + async with async_timeout.timeout(10): async with self.session.post( self.base_url + uri, headers=self.headers, json=dataDictionary ) as resp: @@ -799,14 +799,14 @@

Returns

async with aiohttp.ClientSession( connector=aiohttp.TCPConnector(verify_ssl=False) ) as session: - with async_timeout.timeout(10): + async with async_timeout.timeout(10): async with session.get( self.base_url + uri, headers=self.headers ) as resp: await checkResponseForError(await resp.text()) return await resp.text() else: - with async_timeout.timeout(10): + async with async_timeout.timeout(10): async with self.session.get( self.base_url + uri, headers=self.headers ) as resp: @@ -849,14 +849,14 @@

Parameters

async with aiohttp.ClientSession( connector=aiohttp.TCPConnector(verify_ssl=False) ) as session: - with async_timeout.timeout(10): + async with async_timeout.timeout(10): async with session.post( self.base_url + uri, headers=self.headers, json=dataDictionary ) as resp: await checkResponseForError(await resp.text()) return await resp.text() else: - with async_timeout.timeout(10): + async with async_timeout.timeout(10): async with self.session.post( self.base_url + uri, headers=self.headers, json=dataDictionary ) as resp: diff --git a/pyControl4/account.py b/pyControl4/account.py index 658f1b3..d0b4425 100644 --- a/pyControl4/account.py +++ b/pyControl4/account.py @@ -64,14 +64,14 @@ async def __sendAccountAuthRequest(self): } if self.session is None: async with aiohttp.ClientSession() as session: - with async_timeout.timeout(10): + async with async_timeout.timeout(10): async with session.post( AUTHENTICATION_ENDPOINT, json=dataDictionary ) as resp: await checkResponseForError(await resp.text()) return await resp.text() else: - with async_timeout.timeout(10): + async with async_timeout.timeout(10): async with self.session.post( AUTHENTICATION_ENDPOINT, json=dataDictionary ) as resp: @@ -94,12 +94,12 @@ async def __sendAccountGetRequest(self, uri): raise if self.session is None: async with aiohttp.ClientSession() as session: - with async_timeout.timeout(10): + async with async_timeout.timeout(10): async with session.get(uri, headers=headers) as resp: await checkResponseForError(await resp.text()) return await resp.text() else: - with async_timeout.timeout(10): + async with async_timeout.timeout(10): async with self.session.get(uri, headers=headers) as resp: await checkResponseForError(await resp.text()) return await resp.text() @@ -125,7 +125,7 @@ async def __sendControllerAuthRequest(self, controller_common_name): } if self.session is None: async with aiohttp.ClientSession() as session: - with async_timeout.timeout(10): + async with async_timeout.timeout(10): async with session.post( CONTROLLER_AUTHORIZATION_ENDPOINT, headers=headers, @@ -134,7 +134,7 @@ async def __sendControllerAuthRequest(self, controller_common_name): await checkResponseForError(await resp.text()) return await resp.text() else: - with async_timeout.timeout(10): + async with async_timeout.timeout(10): async with self.session.post( CONTROLLER_AUTHORIZATION_ENDPOINT, headers=headers, diff --git a/pyControl4/director.py b/pyControl4/director.py index d2bf551..8d5a972 100644 --- a/pyControl4/director.py +++ b/pyControl4/director.py @@ -50,14 +50,14 @@ async def sendGetRequest(self, uri): async with aiohttp.ClientSession( connector=aiohttp.TCPConnector(verify_ssl=False) ) as session: - with async_timeout.timeout(10): + async with async_timeout.timeout(10): async with session.get( self.base_url + uri, headers=self.headers ) as resp: await checkResponseForError(await resp.text()) return await resp.text() else: - with async_timeout.timeout(10): + async with async_timeout.timeout(10): async with self.session.get( self.base_url + uri, headers=self.headers ) as resp: @@ -86,14 +86,14 @@ async def sendPostRequest(self, uri, command, params, async_variable=True): async with aiohttp.ClientSession( connector=aiohttp.TCPConnector(verify_ssl=False) ) as session: - with async_timeout.timeout(10): + async with async_timeout.timeout(10): async with session.post( self.base_url + uri, headers=self.headers, json=dataDictionary ) as resp: await checkResponseForError(await resp.text()) return await resp.text() else: - with async_timeout.timeout(10): + async with async_timeout.timeout(10): async with self.session.post( self.base_url + uri, headers=self.headers, json=dataDictionary ) as resp: diff --git a/pyControl4/light.py b/pyControl4/light.py index 93d27ef..001d1b4 100644 --- a/pyControl4/light.py +++ b/pyControl4/light.py @@ -43,3 +43,118 @@ async def rampToLevel(self, level, time): "RAMP_TO_LEVEL", {"LEVEL": level, "TIME": time}, ) + + async def setColorXY( + self, x: float, y: float, *, rate: int | None = None, mode: int = 0 + ): + """ + Sends SET_COLOR_TARGET with xy and mode. + - x, y: CIE 1931 coordinates (0..1 ~ typically) + - rate: ramp duration in milliseconds (Optional) + - mode: 0 = full color, 1 = CCT + """ + params = { + "LIGHT_COLOR_TARGET_X": float(x), + "LIGHT_COLOR_TARGET_Y": float(y), + "LIGHT_COLOR_TARGET_MODE": int(mode), + } + if rate is not None: + params["RATE"] = int(rate) + + await self.director.sendPostRequest( + f"/api/v1/items/{self.item_id}/commands", + "SET_COLOR_TARGET", + params, + ) + + async def setColorRGB(self, r: int, g: int, b: int, *, rate: int | None = None): + """RGB 0..255 -> xy, mode=0 (full color).""" + x, y = self._rgb_to_xy(r, g, b) + await self.setColorXY(x, y, rate=rate, mode=0) + + async def setColorHex(self, hex_color: str, *, rate: int | None = None): + """HEX (#RRGGBB/#RGB/RRGGBB/RGB) -> xy, mode=0 (full color).""" + r, g, b = self._hex_to_rgb(hex_color) + await self.setColorRGB(r, g, b, rate=rate) + + async def setColorTemperature(self, kelvin: int, *, rate: int | None = None): + """Kelvin -> xy, mode=1 (CCT).""" + x, y = self._cct_to_xy(kelvin) + await self.setColorXY(x, y, rate=rate, mode=1) + + # ---------- Color Utilities ---------- + @staticmethod + def _hex_to_rgb(color: str) -> tuple[int, int, int]: + s = color.strip() + if s.startswith("#"): + s = s[1:] + if len(s) == 3: + s = "".join(c * 2 for c in s) + if len(s) != 6: + raise ValueError("HEX color must be RRGGBB, #RRGGBB, #RGB or RGB") + r = int(s[0:2], 16) + g = int(s[2:4], 16) + b = int(s[4:6], 16) + return r, g, b + + @staticmethod + def _srgb_to_linear(c: float) -> float: + # c in [0..1] + return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 + + @classmethod + def _rgb_to_xy(cls, r: int, g: int, b: int) -> tuple[float, float]: + # Normalize 0..255 -> 0..1 sRGB + rs = r / 255.0 + gs = g / 255.0 + bs = b / 255.0 + # Correction gamma sRGB -> lin + rlin = cls._srgb_to_linear(rs) + glin = cls._srgb_to_linear(gs) + blin = cls._srgb_to_linear(bs) + # lin RGB -> XYZ (D65) + X = rlin * 0.4124 + glin * 0.3576 + blin * 0.1805 + Y = rlin * 0.2126 + glin * 0.7152 + blin * 0.0722 + Z = rlin * 0.0193 + glin * 0.1192 + blin * 0.9505 + denom = X + Y + Z + if denom <= 1e-9: + return 0.3127, 0.3290 # fallback D65 if complete black + x = X / denom + y = Y / denom + # Optionally round to 4 decimal places (many drivers prefer this) + return round(x, 4), round(y, 4) + + @staticmethod + def _cct_to_xy(kelvin: int) -> tuple[float, float]: + """Approximation CIE 1931 xy for 1667K..25000K (classic formulas).""" + K = float(kelvin) + if K < 1667: + K = 1667.0 + if K > 25000: + K = 25000.0 + + # x as a function of K + if 1667 <= K <= 4000: + x = ( + (-0.2661239 * 1e9) / (K**3) + - (0.2343580 * 1e6) / (K**2) + + (0.8776956 * 1e3) / K + + 0.179910 + ) + else: # 4000..25000 + x = ( + (-3.0258469 * 1e9) / (K**3) + + (2.1070379 * 1e6) / (K**2) + + (0.2226347 * 1e3) / K + + 0.240390 + ) + + # y as a function of x and K + if 1667 <= K <= 2222: + y = -1.1063814 * x**3 - 1.34811020 * x**2 + 2.18555832 * x - 0.20219683 + elif 2222 < K <= 4000: + y = -0.9549476 * x**3 - 1.37418593 * x**2 + 2.09137015 * x - 0.16748867 + else: # 4000..25000 + y = 3.0817580 * x**3 - 5.87338670 * x**2 + 3.75112997 * x - 0.37001483 + + return round(x, 4), round(y, 4) diff --git a/pyControl4/websocket.py b/pyControl4/websocket.py index 1ee67f2..91a425e 100644 --- a/pyControl4/websocket.py +++ b/pyControl4/websocket.py @@ -60,7 +60,7 @@ async def on_clientId(self, clientId): async with aiohttp.ClientSession( connector=aiohttp.TCPConnector(verify_ssl=False) ) as session: - with async_timeout.timeout(10): + async with async_timeout.timeout(10): async with session.get( self.url + self.uri, params={"JWT": self.token, "SubscriptionClient": clientId}, @@ -71,7 +71,7 @@ async def on_clientId(self, clientId): self.subscriptionId = data["subscriptionId"] await self.emit("startSubscription", self.subscriptionId) else: - with async_timeout.timeout(10): + async with async_timeout.timeout(10): async with self.session.get( self.url + self.uri, params={"JWT": self.token, "SubscriptionClient": clientId}, @@ -116,7 +116,6 @@ def __init__( self.connect_callback = connect_callback self.disconnect_callback = disconnect_callback - # Keep track of the callbacks registered for each item id self._item_callbacks = dict() # Initialize self._sio to None self._sio = None @@ -125,31 +124,53 @@ def __init__( def item_callbacks(self): """Returns a dictionary of registered item ids (key) and their callbacks (value). - item_callbacks cannot be modified directly. Use add_item_callback() and remove_item_callback() instead. + MODIFIED: Returns flattened dict for compatibility with existing code. """ - return self._item_callbacks + return { + item_id: callbacks[0] if callbacks else None + for item_id, callbacks in self._item_callbacks.items() + } def add_item_callback(self, item_id, callback): """Register a callback to receive updates about an item. - If a callback is already registered for the item, it will be overwritten with the provided callback. + MODIFIED: Now supports multiple callbacks per item_id. Parameters: `item_id` - The Control4 item ID. - `callback` - The callback to be called when an update is received for the provided item id. """ - _LOGGER.debug("Subscribing to updates for item id: %s", item_id) - self._item_callbacks[item_id] = callback + if item_id not in self._item_callbacks: + self._item_callbacks[item_id] = [] - def remove_item_callback(self, item_id): - """Unregister callback for an item. + # Avoid duplicates + if callback not in self._item_callbacks[item_id]: + self._item_callbacks[item_id].append(callback) + + def remove_item_callback(self, item_id, callback=None): + """Unregister callback(s) for an item. + MODIFIED: Supports selective or complete removal. Parameters: `item_id` - The Control4 item ID. + `callback` - (Optional) Specific callback to remove. If None, removes all callbacks for this item_id. """ - self._item_callbacks.pop(item_id) + if item_id not in self._item_callbacks: + return + + if callback is None: + # Remove all callbacks for this item_id + del self._item_callbacks[item_id] + else: + # Remove a specific callback + try: + self._item_callbacks[item_id].remove(callback) + # If no more callbacks, remove the entry + if not self._item_callbacks[item_id]: + del self._item_callbacks[item_id] + except ValueError: + pass async def sio_connect(self, director_bearer_token): """Start WebSockets connection and listen, using the provided director_bearer_token to authenticate with the Control4 Director. @@ -199,19 +220,25 @@ async def _process_message(self, message): """Process an incoming event message.""" _LOGGER.debug(message) try: - c = self._item_callbacks[message["iddevice"]] + callbacks = self._item_callbacks[message["iddevice"]] except KeyError: _LOGGER.debug("No Callback for device id {}".format(message["iddevice"])) return True - if isinstance(message, list): - for m in message: - await c(message["iddevice"], m) - else: - await c(message["iddevice"], message) + for callback in callbacks[:]: + try: + if isinstance(message, list): + for m in message: + await callback(message["iddevice"], m) + else: + await callback(message["iddevice"], message) + except Exception as exc: + _LOGGER.warning( + "Captured exception during callback: {}".format(str(exc)) + ) async def _execute_callback(self, callback, *args, **kwargs): - """Callback with some data capturing any excpetions.""" + """Callback with some data capturing any exceptions.""" try: self.sio.emit("ping") await callback(*args, **kwargs) diff --git a/setup.py b/setup.py index ec62b3d..da2b5e8 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setuptools.setup( name="pyControl4", # Replace with your own username - version="1.5.0", + version="1.5.1", author="lawtancool", author_email="contact@lawrencetan.ca", description="Python 3 asyncio package for interacting with Control4 systems", diff --git a/test.py b/test.py index 67feb5c..60efe0c 100644 --- a/test.py +++ b/test.py @@ -9,7 +9,7 @@ import json import aiohttp -ip = "192.168.1.25" +ip = "192.168.2.40" # asyncio.run( # checkResponseForError( @@ -39,10 +39,10 @@ async def returnClientSession(): # print(director_bearer_token) director = C4Director(ip, director_bearer_token["token"]) -alarm = C4SecurityPanel(director, 460) -print(asyncio.run(alarm.getEmergencyTypes())) +# alarm = C4SecurityPanel(director, 460) +# print(asyncio.run(alarm.getEmergencyTypes())) -print(asyncio.run(director.getItemSetup(471))) +print(asyncio.run(director.getItemSetup(293))) # sensor = C4ContactSensor(director, 471) # print(asyncio.run(sensor.getContactState())) @@ -54,6 +54,8 @@ async def returnClientSession(): # print(asyncio.run(director.getAllItemVariableValue("LIGHT_LEVEL"))) -# light = C4Light(director, 253) +# light = C4Light(director, 789) # asyncio.run(light.rampToLevel(10, 10000)) # print(asyncio.run(light.getState())) +# asyncio.run(light.setColorTemperature(4000, rate=1000)) +# asyncio.run(light.setColorRGB(255, 0, 0, rate=1000))