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 @@
}
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 @@
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 @@
}
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 @@
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 @@
}
}
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 @@
_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 @@
}
}
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 @@
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 @@
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))