Skip to content

Commit 449dec3

Browse files
lawtancoolCopilotdavidrecordonclaude
authored
Refactor Control4 API interaction methods for consistency and clarity (#52)
* Refactor Control4 API interaction methods for consistency and clarity - Updated method names in C4Director, C4Fan, C4Light, C4Relay, C4Room, and C4Websocket classes to follow Python naming conventions (snake_case). - Replaced direct session management with an async context manager in C4Director for better session handling. - Improved error handling in error_handling.py by extracting error information into a dedicated function. * Update README example with new method names * Bump version to 2.0.0 * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Refactor session management in C4Account and C4Director for improved consistency; update item_callbacks property in C4Websocket to return a read-only view. * Fix typos * Refactor C4ContactSensor constructor parameter for clarity; update documentation in C4Room for consistency * Refactor HVAC and fan modes methods to return lists * Remove unused message handling in on_subscribe method of C4DirectorNamespace * Improve get_arm_state method in C4SecurityPanel for better error handling and clarity * Add type hints for constructor parameters in C4Director and update variable name types in get_item_variable_value methods; correct volume adjustment comment in C4Room * Add future annotations import to multiple files for improved type hinting * Make error_handling sync instead of async * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Fix method references in docstrings * Fix line too long in alarm.py * Fix black * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Fix websocket init docstring * Add tests for error_handling and director modules (#55) Tests were written first against the 1.x API to verify correctness on the existing codebase, then adjusted for the 2.0 snake_case renames and sync error_handling refactor so that this commit applies cleanly on top of the 2.x-refactor branch. test_error_handling.py — covers all branches of check_response_for_error: - JSON and XML happy paths (no error keys) - C4ErrorResponse format: BadCredentials, Unauthorized, NotFound, unknown code fallback to C4Exception - Flat JSON code format: NotFound, BadCredentials (details priority) - Director error format: BadToken, Unauthorized, InvalidCategory, unknown error fallback to C4Exception - XML C4ErrorResponse parsing - Exception hierarchy and message preservation test_director.py — covers get_item_variable_value edge cases: - Return types: int, bool, string, zero (not confused with None) - Undefined normalization to None - JSON null passthrough as None - Empty response and invalid format raise ValueError - List/tuple var_name joining - get_all_item_variable_value mixed Undefined normalization - get_all_item_info returns parsed JSON (2.0 behavior) Also adds tests/conftest.py with shared director fixture and removes the duplicate fixture from test_undefined_handling.py. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: David Recordon <recordond@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ddb143e commit 449dec3

18 files changed

Lines changed: 1082 additions & 551 deletions

README.md

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ from pyControl4.account import C4Account
1616
from pyControl4.director import C4Director
1717
from pyControl4.light import C4Light
1818
import asyncio
19-
import json
2019

2120
username = ""
2221
password = ""
@@ -25,31 +24,31 @@ ip = "192.168.1.25"
2524

2625
"""Authenticate with Control4 account"""
2726
account = C4Account(username, password)
28-
asyncio.run(account.getAccountBearerToken())
27+
asyncio.run(account.get_account_bearer_token())
2928

3029
"""Get and print controller name"""
31-
accountControllers = asyncio.run(account.getAccountControllers())
32-
print(accountControllers["controllerCommonName"])
30+
account_controllers = asyncio.run(account.get_account_controllers())
31+
print(account_controllers["controllerCommonName"])
3332

3433
"""Get bearer token to communicate with controller locally"""
3534
director_bearer_token = asyncio.run(
36-
account.getDirectorBearerToken(accountControllers["controllerCommonName"])
35+
account.get_director_bearer_token(account_controllers["controllerCommonName"])
3736
)["token"]
3837

3938
"""Create new C4Director instance"""
4039
director = C4Director(ip, director_bearer_token)
4140

4241
"""Print all devices on the controller"""
43-
print(asyncio.run(director.getAllItemInfo()))
42+
print(asyncio.run(director.get_all_item_info()))
4443

4544
"""Create new C4Light instance"""
4645
light = C4Light(director, 253)
4746

4847
"""Ramp light level to 10% over 10000ms"""
49-
asyncio.run(light.rampToLevel(10, 10000))
48+
asyncio.run(light.ramp_to_level(10, 10000))
5049

5150
"""Print state of light"""
52-
print(asyncio.run(light.getState()))
51+
print(asyncio.run(light.get_state()))
5352
```
5453

5554
## Contributing

pyControl4/__init__.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
1+
from __future__ import annotations
2+
3+
from pyControl4.director import C4Director
4+
5+
16
class C4Entity:
2-
def __init__(self, C4Director, item_id):
7+
def __init__(self, director: C4Director, item_id: int):
38
"""Creates a Control4 object.
49
510
Parameters:
6-
`C4Director` - A `pyControl4.director.C4Director` object that corresponds
11+
`director` - A `pyControl4.director.C4Director` object that corresponds
712
to the Control4 Director that the device is connected to.
813
914
`item_id` - The Control4 item ID.
1015
"""
11-
self.director = C4Director
16+
self.director = director
1217
self.item_id = int(item_id)

pyControl4/account.py

Lines changed: 91 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,17 @@
22
controller info, and retrieves a bearer token for connecting to a Control4 Director.
33
"""
44

5+
from __future__ import annotations
6+
7+
from contextlib import asynccontextmanager
8+
from typing import AsyncGenerator
9+
510
import aiohttp
611
import asyncio
712
import json
813
import logging
914

10-
from .error_handling import checkResponseForError
15+
from .error_handling import check_response_for_error
1116

1217
AUTHENTICATION_ENDPOINT = "https://apis.control4.com/authentication/v1/rest"
1318
CONTROLLER_AUTHORIZATION_ENDPOINT = (
@@ -22,9 +27,9 @@
2227
class C4Account:
2328
def __init__(
2429
self,
25-
username,
26-
password,
27-
session: aiohttp.ClientSession = None,
30+
username: str,
31+
password: str,
32+
session: aiohttp.ClientSession | None = None,
2833
):
2934
"""Creates a Control4 account object.
3035
@@ -42,11 +47,24 @@ def __init__(
4247
self.password = password
4348
self.session = session
4449

45-
async def __sendAccountAuthRequest(self):
50+
@asynccontextmanager
51+
async def _get_session(self) -> AsyncGenerator[aiohttp.ClientSession, None]:
52+
"""Returns the configured session or creates a temporary one.
53+
54+
If self.session is set, yields it without closing.
55+
Otherwise, creates and closes a temporary session.
56+
"""
57+
if self.session is not None:
58+
yield self.session
59+
else:
60+
async with aiohttp.ClientSession() as session:
61+
yield session
62+
63+
async def _send_account_auth_request(self) -> str:
4664
"""Used internally to retrieve an account bearer token. Returns the entire
4765
JSON response from the Control4 auth API.
4866
"""
49-
dataDictionary = {
67+
data_dict = {
5068
"clientInfo": {
5169
"device": {
5270
"deviceName": "pyControl4",
@@ -63,23 +81,16 @@ async def __sendAccountAuthRequest(self):
6381
},
6482
}
6583
}
66-
if self.session is None:
67-
async with aiohttp.ClientSession() as session:
68-
async with asyncio.timeout(10):
69-
async with session.post(
70-
AUTHENTICATION_ENDPOINT, json=dataDictionary
71-
) as resp:
72-
await checkResponseForError(await resp.text())
73-
return await resp.text()
74-
else:
84+
async with self._get_session() as session:
7585
async with asyncio.timeout(10):
76-
async with self.session.post(
77-
AUTHENTICATION_ENDPOINT, json=dataDictionary
86+
async with session.post(
87+
AUTHENTICATION_ENDPOINT, json=data_dict
7888
) as resp:
79-
await checkResponseForError(await resp.text())
80-
return await resp.text()
89+
text = await resp.text()
90+
check_response_for_error(text)
91+
return text
8192

82-
async def __sendAccountGetRequest(self, uri):
93+
async def _send_account_get_request(self, uri: str) -> str:
8394
"""Used internally to send GET requests to the Control4 API,
8495
authenticated with the account bearer token. Returns the entire JSON
8596
response from the Control4 auth API.
@@ -88,85 +99,71 @@ async def __sendAccountGetRequest(self, uri):
8899
`uri` - Full URI to send GET request to.
89100
"""
90101
try:
91-
headers = {"Authorization": "Bearer {}".format(self.account_bearer_token)}
102+
headers = {"Authorization": f"Bearer {self.account_bearer_token}"}
92103
except AttributeError:
93104
msg = (
94105
"The account bearer token is missing. "
95106
"Is your username/password correct?"
96107
)
97108
_LOGGER.error(msg)
98109
raise
99-
if self.session is None:
100-
async with aiohttp.ClientSession() as session:
101-
async with asyncio.timeout(10):
102-
async with session.get(uri, headers=headers) as resp:
103-
await checkResponseForError(await resp.text())
104-
return await resp.text()
105-
else:
110+
async with self._get_session() as session:
106111
async with asyncio.timeout(10):
107-
async with self.session.get(uri, headers=headers) as resp:
108-
await checkResponseForError(await resp.text())
109-
return await resp.text()
112+
async with session.get(uri, headers=headers) as resp:
113+
text = await resp.text()
114+
check_response_for_error(text)
115+
return text
110116

111-
async def __sendControllerAuthRequest(self, controller_common_name):
117+
async def _send_controller_auth_request(self, controller_common_name: str) -> str:
112118
"""Used internally to retrieve an director bearer token. Returns the
113119
entire JSON response from the Control4 auth API.
114120
115121
Parameters:
116122
`controller_common_name`: Common name of the controller.
117-
See `getAccountControllers()` for details.
123+
See `get_account_controllers()` for details.
118124
"""
119125
try:
120-
headers = {"Authorization": "Bearer {}".format(self.account_bearer_token)}
126+
headers = {"Authorization": f"Bearer {self.account_bearer_token}"}
121127
except AttributeError:
122128
msg = (
123129
"The account bearer token is missing. "
124130
"Is your username/password correct?"
125131
)
126132
_LOGGER.error(msg)
127133
raise
128-
dataDictionary = {
134+
data_dict = {
129135
"serviceInfo": {
130136
"commonName": controller_common_name,
131137
"services": "director",
132138
}
133139
}
134-
if self.session is None:
135-
async with aiohttp.ClientSession() as session:
136-
async with asyncio.timeout(10):
137-
async with session.post(
138-
CONTROLLER_AUTHORIZATION_ENDPOINT,
139-
headers=headers,
140-
json=dataDictionary,
141-
) as resp:
142-
await checkResponseForError(await resp.text())
143-
return await resp.text()
144-
else:
140+
async with self._get_session() as session:
145141
async with asyncio.timeout(10):
146-
async with self.session.post(
142+
async with session.post(
147143
CONTROLLER_AUTHORIZATION_ENDPOINT,
148144
headers=headers,
149-
json=dataDictionary,
145+
json=data_dict,
150146
) as resp:
151-
await checkResponseForError(await resp.text())
152-
return await resp.text()
147+
text = await resp.text()
148+
check_response_for_error(text)
149+
return text
153150

154-
async def getAccountBearerToken(self):
151+
async def get_account_bearer_token(self) -> str:
155152
"""Gets an account bearer token for making Control4 online API requests."""
156-
data = await self.__sendAccountAuthRequest()
157-
jsonDictionary = json.loads(data)
153+
data = await self._send_account_auth_request()
154+
json_dict = json.loads(data)
158155
try:
159-
self.account_bearer_token = jsonDictionary["authToken"]["token"]
156+
self.account_bearer_token = json_dict["authToken"]["token"]
160157
return self.account_bearer_token
161158
except KeyError:
162159
msg = (
163-
"Did not recieve an account bearer token. "
160+
"Did not receive an account bearer token. "
164161
"Is your username/password correct?"
165162
)
166163
_LOGGER.error(msg + data)
167164
raise
168165

169-
async def getAccountControllers(self):
166+
async def get_account_controllers(self) -> dict:
170167
"""Returns a dictionary of the information for all controllers registered
171168
to an account.
172169
@@ -179,16 +176,21 @@ async def getAccountControllers(self):
179176
}
180177
```
181178
"""
182-
data = await self.__sendAccountGetRequest(GET_CONTROLLERS_ENDPOINT)
183-
jsonDictionary = json.loads(data)
184-
return jsonDictionary["account"]
179+
data = await self._send_account_get_request(GET_CONTROLLERS_ENDPOINT)
180+
json_dict = json.loads(data)
181+
try:
182+
return json_dict["account"]
183+
except KeyError:
184+
msg = "Did not receive account information from the Control4 API."
185+
_LOGGER.error(msg + " Response: " + data)
186+
raise
185187

186-
async def getControllerInfo(self, controller_href):
188+
async def get_controller_info(self, controller_href: str) -> dict:
187189
"""Returns a dictionary of the information of a specific controller.
188190
189191
Parameters:
190192
`controller_href` - The API `href` of the controller (get this from
191-
the output of `getAccountControllers()`)
193+
the output of `get_account_controllers()`)
192194
193195
Returns:
194196
```
@@ -225,32 +227,44 @@ async def getControllerInfo(self, controller_href):
225227
}
226228
```
227229
"""
228-
data = await self.__sendAccountGetRequest(controller_href)
229-
jsonDictionary = json.loads(data)
230-
return jsonDictionary
230+
data = await self._send_account_get_request(controller_href)
231+
json_dict = json.loads(data)
232+
return json_dict
231233

232-
async def getControllerOSVersion(self, controller_href):
234+
async def get_controller_os_version(self, controller_href: str) -> str:
233235
"""Returns the OS version of a controller as a string.
234236
235237
Parameters:
236238
`controller_href` - The API `href` of the controller (get this from
237-
the output of `getAccountControllers()`)
239+
the output of `get_account_controllers()`)
238240
"""
239-
data = await self.__sendAccountGetRequest(controller_href + "/controller")
240-
jsonDictionary = json.loads(data)
241-
return jsonDictionary["osVersion"]
241+
data = await self._send_account_get_request(controller_href + "/controller")
242+
json_dict = json.loads(data)
243+
try:
244+
return json_dict["osVersion"]
245+
except KeyError:
246+
msg = "Did not receive OS version from the Control4 API."
247+
_LOGGER.error(msg + " Response: " + data)
248+
raise
242249

243-
async def getDirectorBearerToken(self, controller_common_name):
250+
async def get_director_bearer_token(self, controller_common_name: str) -> dict:
244251
"""Returns a dictionary with a director bearer token for making Control4
245252
Director API requests, and its time valid in seconds (usually 86400 seconds)
246253
247254
Parameters:
248255
`controller_common_name`: Common name of the controller.
249-
See `getAccountControllers()` for details.
256+
See `get_account_controllers()` for details.
250257
"""
251-
data = await self.__sendControllerAuthRequest(controller_common_name)
252-
jsonDictionary = json.loads(data)
253-
return {
254-
"token": jsonDictionary["authToken"]["token"],
255-
"validSeconds": jsonDictionary["authToken"]["validSeconds"],
256-
}
258+
data = await self._send_controller_auth_request(controller_common_name)
259+
json_dict = json.loads(data)
260+
try:
261+
auth_token = json_dict.get("authToken", {})
262+
token = auth_token.get("token")
263+
valid_seconds = auth_token.get("validSeconds")
264+
if token is None or valid_seconds is None:
265+
raise KeyError("Missing token or validSeconds in authToken")
266+
return {"token": token, "validSeconds": valid_seconds}
267+
except KeyError:
268+
msg = "Did not receive a director bearer token from the Control4 API."
269+
_LOGGER.error(msg + " Response: " + data)
270+
raise

0 commit comments

Comments
 (0)