utec-client 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
File without changes
utec_client/api.py ADDED
@@ -0,0 +1,146 @@
1
+ """Api class for Uhome/Utec API."""
2
+
3
+ import json
4
+ import logging
5
+ from enum import Enum
6
+ from typing import Any, TypedDict
7
+ from uuid import uuid4
8
+
9
+ from attr import dataclass
10
+
11
+ from .auth import AbstractAuth
12
+ from .const import API_BASE_URL
13
+ from .exceptions import ApiError
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class ApiNamespace(str, Enum):
19
+ DEVICE = "Uhome.Device"
20
+ USER = "Uhome.User"
21
+ CONFIGURE = "Uhome.Configure"
22
+
23
+
24
+ class ApiOperation(str, Enum):
25
+ DISCOVERY = "Discovery"
26
+ QUERY = "Query"
27
+ COMMAND = "Command"
28
+ SET = "Set"
29
+
30
+
31
+ @dataclass
32
+ class ApiHeader(TypedDict):
33
+ namespace: str
34
+ name: str
35
+ messageId: str
36
+ payloadVersion: str
37
+
38
+
39
+ @dataclass
40
+ class ApiRequest(TypedDict):
41
+ header: ApiHeader
42
+ payload: dict | None
43
+
44
+
45
+ class UHomeApi:
46
+ """U-Home API client implementation."""
47
+
48
+ def __init__(self, Auth: AbstractAuth) -> None:
49
+ """Initialise the API."""
50
+ self.auth = Auth
51
+
52
+ async def async_create_request(
53
+ self, namespace: ApiNamespace, operation: ApiOperation, parameters: dict | None
54
+ ) -> ApiRequest:
55
+ """Create a standardised API request."""
56
+ header: ApiHeader = {
57
+ "namespace": namespace,
58
+ "name": operation,
59
+ "messageId": str(uuid4()),
60
+ "payloadVersion": "1",
61
+ }
62
+ return {"header": header, "payload": parameters}
63
+
64
+ async def _async_make_request(self, **kwargs):
65
+ """Make an authenticated API request."""
66
+ response = await self.auth.async_make_auth_request("POST", API_BASE_URL, **kwargs)
67
+ try:
68
+ if response.status == 204:
69
+ return {}
70
+ elif response.status in (200, 201, 202):
71
+ return await response.json()
72
+ else:
73
+ error_text = await response.text()
74
+ logger.error(f"API error: {response.status} - {error_text}")
75
+ raise ApiError(response.status, error_text)
76
+ finally:
77
+ await response.release()
78
+
79
+ async def validate_auth(self) -> bool:
80
+ """Validate authentication by making a test request."""
81
+ try:
82
+ await self.discover_devices()
83
+ return True
84
+ except ApiError:
85
+ return False
86
+
87
+ async def discover_devices(self) -> dict[str, Any]:
88
+ """Discover available devices."""
89
+ logger.debug("Discovering devices")
90
+ payload = await self.async_create_request(ApiNamespace.DEVICE, ApiOperation.DISCOVERY, {})
91
+ return await self._async_make_request(json=payload)
92
+
93
+ async def get_device_state(self, device_ids: list, custom_data: dict | None) -> dict[str, Any]:
94
+ """Get device status - supports multiple devices at once and custom data."""
95
+ devices = []
96
+ for device_id in device_ids:
97
+ device = {"id": device_id}
98
+ if custom_data:
99
+ device["custom_data"] = custom_data
100
+ devices.append(device)
101
+ params = {"devices": devices}
102
+ payload = await self.async_create_request(ApiNamespace.DEVICE, ApiOperation.QUERY, params)
103
+ return await self._async_make_request(json=payload)
104
+
105
+ async def query_device(self, device_id: str):
106
+ """Query single device."""
107
+ device = [{"id": device_id}]
108
+ params = {"devices": device}
109
+ payload = await self.async_create_request(ApiNamespace.DEVICE, ApiOperation.QUERY, params)
110
+ logger.debug(
111
+ "Querying device with device ID %s and payload %s",
112
+ device_id,
113
+ json.dumps(payload, default=str),
114
+ )
115
+ return await self._async_make_request(json=payload)
116
+
117
+ async def send_command(
118
+ self, device_id: str, capability: str, command: str, arguments: dict | None
119
+ ) -> dict[str, Any]:
120
+ """Send command to device."""
121
+ command_data: dict[str, Any] = {"capability": capability, "name": command}
122
+ if arguments:
123
+ command_data["arguments"] = arguments
124
+
125
+ params = {"devices": [{"id": device_id, "command": command_data}]}
126
+
127
+ payload = await self.async_create_request(ApiNamespace.DEVICE, ApiOperation.COMMAND, params)
128
+ logger.debug(
129
+ "Sending Command %s to device %s, with payload %s",
130
+ command,
131
+ device_id,
132
+ json.dumps(payload, default=str),
133
+ )
134
+ return await self._async_make_request(json=payload)
135
+
136
+ async def set_push_status(self, uri: str, access_token: str):
137
+ """Register URI for push device updates.
138
+
139
+ Args:
140
+ uri: URL to receive push updates - must be HTTP/HTTPS with valid CA cert
141
+ access_token: OAuth2 access token sent by U-Tec with each push notification
142
+ """
143
+ params = {"configure": {"notification": {"access_token": access_token, "url": uri}}}
144
+ payload = await self.async_create_request(ApiNamespace.CONFIGURE, ApiOperation.SET, params)
145
+ logger.debug("Setting push update url. URL: %s", uri)
146
+ return await self._async_make_request(json=payload)
utec_client/auth.py ADDED
@@ -0,0 +1,45 @@
1
+ """Abstract Auth class for Uhome/Utec API."""
2
+
3
+ # auth.py - OAuth2 implementation
4
+
5
+ from abc import ABC, abstractmethod
6
+
7
+ from aiohttp import ClientResponse, ClientSession
8
+
9
+
10
+ class AbstractAuth(ABC):
11
+ """Abstract Auth base for extension in integrations.
12
+
13
+ Takes an aiohttp clientsession for extending token management.
14
+
15
+ """
16
+
17
+ def __init__(self, websession: ClientSession) -> None:
18
+ """Initialise auth class."""
19
+ self.websession = websession
20
+
21
+ @abstractmethod
22
+ async def async_get_access_token(self) -> str:
23
+ """Return a valid access token (refresh if needed)."""
24
+
25
+ async def async_make_auth_request(self, method, host: str, **kwargs) -> ClientResponse:
26
+ """Performs authenticated request using the clientsession passed to the class init."""
27
+ if headers := kwargs.pop("headers", {}):
28
+ headers = dict(headers)
29
+
30
+ headers.update(
31
+ {
32
+ "Content-Type": "application/json",
33
+ "Accept": "application/json",
34
+ }
35
+ )
36
+
37
+ access_token = await self.async_get_access_token()
38
+ headers["authorization"] = f"Bearer {access_token}"
39
+
40
+ return await self.websession.request(
41
+ method,
42
+ host,
43
+ **kwargs,
44
+ headers=headers,
45
+ )
utec_client/const.py ADDED
@@ -0,0 +1,38 @@
1
+ """U-Home API constants."""
2
+
3
+ from enum import Enum
4
+ from typing import Any, TypedDict
5
+
6
+ AUTH_BASE_URL = "https://oauth.u-tec.com/authorize?"
7
+ TOKEN_BASE_URL = "https://oauth.u-tec.com/token?"
8
+ API_BASE_URL = "https://api.u-tec.com/action"
9
+
10
+ ATTR_HANDLE_TYPE = "handleType"
11
+ ATTR_DEVICE_ID = "id"
12
+ ATTR_NAME = "name"
13
+ ATTR_CATEGORY = "category"
14
+ ATTR_DEVICE_INFO = "deviceInfo"
15
+ ATTR_ATTRIBUTES = "attributes"
16
+
17
+
18
+ class ApiNamespace(str, Enum):
19
+ DEVICE = "Uhome.Device"
20
+ USER = "Uhome.User"
21
+
22
+
23
+ class ApiOperation(str, Enum):
24
+ DISCOVERY = "Discovery"
25
+ QUERY = "Query"
26
+ COMMAND = "Command"
27
+
28
+
29
+ class ApiHeader(TypedDict):
30
+ namespace: ApiNamespace
31
+ name: ApiOperation
32
+ messageID: str
33
+ payloadVersion: str
34
+
35
+
36
+ class ApiRequest(TypedDict):
37
+ header: ApiHeader
38
+ payload: dict[str, Any] | None
File without changes
@@ -0,0 +1,274 @@
1
+ """Base device implementation for U-Home API devices."""
2
+
3
+ import logging
4
+ from dataclasses import dataclass
5
+ from datetime import datetime
6
+ from typing import Any
7
+
8
+ from ..api import UHomeApi
9
+ from ..exceptions import DeviceError
10
+ from .device_const import HANDLE_TYPE_CAPABILITIES, DeviceCategory, DeviceCommand
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ @dataclass
16
+ class DeviceInfo:
17
+ """Class that represents device information in the U-Home API."""
18
+
19
+ manufacturer: str
20
+ model: str
21
+ hw_version: str
22
+ serial_number: str | None = None
23
+
24
+ @classmethod
25
+ def from_dict(cls, data: dict[str, Any]) -> "DeviceInfo":
26
+ """Create DeviceInfo instance from API response dictionary."""
27
+ return cls(
28
+ manufacturer=data.get("manufacturer", ""),
29
+ model=data.get("model", ""),
30
+ hw_version=data.get("hwVersion", ""),
31
+ serial_number=data.get("serialNumber"),
32
+ )
33
+
34
+
35
+ class BaseDevice:
36
+ """Base class for all U-Home devices."""
37
+
38
+ def __init__(self, discovery_data: dict, api: UHomeApi) -> None:
39
+ """Initialize the device with discovery data.
40
+
41
+ Args:
42
+ discovery_data: Dictionary containing device discovery information
43
+ api: UHomeApi instance for device communication
44
+
45
+ Raises:
46
+ DeviceError: If required device information is missing
47
+
48
+ """
49
+ self._api = api
50
+ self._discovery_data = discovery_data
51
+ self._state_data: dict | None = None
52
+ self._last_update: datetime | None = None
53
+
54
+ # Extract required fields
55
+ try:
56
+ self._id = discovery_data["id"]
57
+ self._name = discovery_data["name"]
58
+ self._handle_type = discovery_data["handleType"]
59
+ self._category = discovery_data.get("category", "unknown")
60
+
61
+ # Parse device info
62
+ device_info_data = discovery_data.get("deviceInfo", {})
63
+ self._device_info = DeviceInfo.from_dict(device_info_data)
64
+
65
+ # Get attributes and capabilities
66
+ self._attributes = discovery_data.get("attributes", {})
67
+ self._supported_capabilities = HANDLE_TYPE_CAPABILITIES.get(self._handle_type, set())
68
+
69
+ self._validate_capabilities()
70
+
71
+ except KeyError as err:
72
+ raise DeviceError(f"Missing required field in discovery data: {err}") from err
73
+
74
+ @property
75
+ def device_id(self) -> str:
76
+ """Get the unique device ID."""
77
+ return self._id
78
+
79
+ @property
80
+ def name(self) -> str:
81
+ """Get the device name."""
82
+ return self._name
83
+
84
+ @property
85
+ def handle_type(self) -> str:
86
+ """Get the device handle type."""
87
+ return self._handle_type
88
+
89
+ @property
90
+ def category(self) -> DeviceCategory:
91
+ """Get the device category."""
92
+ return DeviceCategory(self._category)
93
+
94
+ @property
95
+ def manufacturer(self) -> str:
96
+ """Get the device manufacturer."""
97
+ return self._device_info.manufacturer
98
+
99
+ @property
100
+ def model(self) -> str:
101
+ """Get the device model."""
102
+ return self._device_info.model
103
+
104
+ @property
105
+ def hw_version(self) -> str:
106
+ """Get the device hardware version."""
107
+ return self._device_info.hw_version
108
+
109
+ @property
110
+ def serial_number(self) -> str | None:
111
+ """Get the device serial number."""
112
+ return self._device_info.serial_number
113
+
114
+ @property
115
+ def supported_capabilities(self) -> set[str]:
116
+ """Get the set of supported capabilities."""
117
+ return self._supported_capabilities
118
+
119
+ @property
120
+ def available(self) -> bool:
121
+ """Indicate if the device is available."""
122
+ if not self._state_data:
123
+ return False
124
+ return self._get_state_value("st.healthCheck", "status") == "Online"
125
+
126
+ @property
127
+ def attributes(self) -> dict[str, Any]:
128
+ """Get device attributes."""
129
+ return self._attributes
130
+
131
+ def has_capability(self, capability: str) -> bool:
132
+ """Check if the device supports a specific capability."""
133
+ return capability in self._supported_capabilities
134
+
135
+ def _state_has_capability(self, *capabilities: str) -> bool:
136
+ """Return whether current state data contains one of the capabilities."""
137
+ if not self._state_data:
138
+ return False
139
+ return any(
140
+ state.get("capability") in capabilities for state in self._state_data.get("states", [])
141
+ )
142
+
143
+ def _validate_capabilities(self) -> None:
144
+ """Validate that the device has all required capabilities.
145
+
146
+ Raises:
147
+ DeviceError: If device is missing required capabilities
148
+
149
+ """
150
+ required_capabilities = HANDLE_TYPE_CAPABILITIES.get(self._handle_type, set())
151
+ if not required_capabilities.issubset(self._supported_capabilities):
152
+ missing = required_capabilities - self._supported_capabilities
153
+ raise DeviceError(f"Device {self._id} missing required capabilities: {missing}")
154
+
155
+ def _get_state_value(self, capability: str, attribute: str) -> Any:
156
+ """Get a specific state value from device state data.
157
+
158
+ Args:
159
+ capability: The capability namespace
160
+ attribute: The attribute name
161
+
162
+ Returns:
163
+ The state value if found, None otherwise
164
+
165
+ """
166
+ if not self._state_data:
167
+ logger.debug("No state data available for device %s", self.device_id)
168
+ return None
169
+
170
+ states = self._state_data.get("states", [])
171
+
172
+ if not states:
173
+ logger.debug("Empty states array for device %s", self.device_id)
174
+ return None
175
+
176
+ for state in states:
177
+ if state.get("capability") == capability and state.get("name") == attribute:
178
+ logger.debug("Found %s.%s = %s", capability, attribute, state.get("value"))
179
+ return state.get("value")
180
+ logger.debug("State %s.%s not found for device %s", capability, attribute, self.device_id)
181
+ return None
182
+
183
+ def get_state_data(self) -> dict:
184
+ """Get the current device states in a standardized format."""
185
+ states = {}
186
+ if self._state_data and "states" in self._state_data:
187
+ for state in self._state_data["states"]:
188
+ capability = state.get("capability")
189
+ name = state.get("name")
190
+ value = state.get("value")
191
+ if capability and name:
192
+ if capability not in states:
193
+ states[capability] = {}
194
+ states[capability][name] = value
195
+ return states
196
+
197
+ async def send_command(self, command: DeviceCommand) -> None:
198
+ """Send a command to the device.
199
+
200
+ Args:
201
+ command: DeviceCommand instance containing command details
202
+
203
+ Raises:
204
+ DeviceError: If command sending fails
205
+
206
+ """
207
+ logger.debug("Sending command %s for device ID %s", command.name, self.device_id)
208
+ try:
209
+ await self._api.send_command(
210
+ self.device_id, command.capability, command.name, command.arguments
211
+ )
212
+
213
+ self._last_update = datetime.now()
214
+
215
+ except Exception as err:
216
+ raise DeviceError(f"Failed to send command to device: {err}") from err
217
+
218
+ async def update(self) -> None:
219
+ """Update device state data.
220
+
221
+ Raises:
222
+ DeviceError: If update fails
223
+
224
+ """
225
+ logger.debug("updating device %s", self.device_id)
226
+ try:
227
+ response = await self._api.query_device(self.device_id)
228
+ if response and "payload" in response:
229
+ devices = response["payload"].get("devices", [])
230
+ if devices:
231
+ self._state_data = devices[0]
232
+ logger.debug(
233
+ "Updated device %s with data: %s",
234
+ self.device_id,
235
+ self._state_data,
236
+ )
237
+ self._last_update = datetime.now()
238
+
239
+ except Exception as err:
240
+ raise DeviceError(f"Failed to update device state: {err}") from err
241
+
242
+ async def update_state_data(self, push_data: dict) -> dict[str, Any] | None:
243
+ """Update device data from push data"""
244
+ if "states" in push_data:
245
+ self._state_data = push_data
246
+ logger.debug("Updated device %s with push data: %s", self.device_id, push_data)
247
+ self._last_update = datetime.now()
248
+ else:
249
+ logger.warning("Invalid push data format for device %s: %s", self.device_id, push_data)
250
+
251
+ @property
252
+ def device_info(self) -> dict[str, Any]:
253
+ """Get device information for Home Assistant."""
254
+ return {
255
+ "identifiers": {("uhome", self.device_id)},
256
+ "name": self.name,
257
+ "manufacturer": self.manufacturer,
258
+ "model": self.model,
259
+ "hw_version": self.hw_version,
260
+ }
261
+
262
+ def __str__(self) -> str:
263
+ """Return string representation of the device."""
264
+ return f"{self.__class__.__name__}(id={self.device_id}, name={self.name})"
265
+
266
+ def __repr__(self) -> str:
267
+ """Return detailed string representation of the device."""
268
+ return (
269
+ f"{self.__class__.__name__}("
270
+ f"id={self.device_id}, "
271
+ f"name={self.name}, "
272
+ f"type={self.handle_type}, "
273
+ f"category={self.category})"
274
+ )
@@ -0,0 +1,222 @@
1
+ """Device types and constants for U-Home API devices."""
2
+
3
+ from dataclasses import dataclass
4
+ from enum import Enum, IntEnum
5
+ from typing import Any
6
+
7
+
8
+ class HandleType(str, Enum):
9
+ """Device handle types supported by the API."""
10
+
11
+ UTEC_LOCK = "utec-lock"
12
+ UTEC_LOCK_SENSOR = "utec-lock-sensor"
13
+ UTEC_DIMMER = "utec-dimmer"
14
+ UTEC_LIGHT_RGBAW = "utec-light-rgbaw-br"
15
+ UTEC_SWITCH = "utec-switch"
16
+
17
+
18
+ class DeviceCapability(str, Enum):
19
+ """Device capabilities supported by the API."""
20
+
21
+ SWITCH = "st.switch"
22
+ LOCK = "st.lock"
23
+ BATTERY_LEVEL = "st.batteryLevel"
24
+ LOCK_USER = "st.lockUser"
25
+ DOOR_SENSOR = "st.DoorSensor"
26
+ BRIGHTNESS = "st.brightness"
27
+ SWITCH_LEVEL = "st.switchLevel"
28
+ COLOR = "st.color"
29
+ COLOR_TEMPERATURE = "st.colorTemperature"
30
+ HEALTH_CHECK = "st.healthCheck"
31
+
32
+
33
+ class DeviceCategory(str, Enum):
34
+ """Device categories as returned by the API."""
35
+
36
+ LOCK = "SmartLock"
37
+ PLUG = "SmartPlug"
38
+ SWITCH = "SmartSwitch"
39
+ LIGHT = "LIGHT"
40
+ UNKNOWN = "Unknown"
41
+
42
+
43
+ class LockState(str, Enum):
44
+ """Lock state values from API."""
45
+
46
+ LOCKED = "Locked"
47
+ UNLOCKED = "Unlocked"
48
+ JAMMED = "Jammed"
49
+ UNKNOWN = "Unknown"
50
+
51
+
52
+ class LockMode(IntEnum):
53
+ """Lock mode values from API (st.lock / lockMode attribute).
54
+
55
+ Per API spec: 0 = Normal, 1 = Passage, 2 = Locked.
56
+ """
57
+
58
+ NORMAL = 0
59
+ PASSAGE = 1
60
+ LOCKED = 2
61
+
62
+
63
+ class DoorState(str, Enum):
64
+ """Door state values from API (st.doorSensor / sensorState attribute)."""
65
+
66
+ CLOSED = "Closed"
67
+ OPEN = "Open"
68
+ UNKNOWN = "Unknown"
69
+
70
+
71
+ # SwitchState is kept for reading state values returned by the API.
72
+ # Commands use the command name directly ("on"/"off") with no arguments,
73
+ # per the st.switch capability spec.
74
+ class SwitchState(str, Enum):
75
+ """Switch state values returned by the API."""
76
+
77
+ ON = "on"
78
+ OFF = "off"
79
+ UNKNOWN = "Unknown"
80
+
81
+
82
+ @dataclass
83
+ class DeviceCommand:
84
+ """Represents a command to be sent to a U-Home device."""
85
+
86
+ capability: str
87
+ name: str
88
+ arguments: dict[str, Any] | None = None
89
+
90
+ def to_dict(self) -> dict[str, Any]:
91
+ """Convert command to API-compatible dictionary format."""
92
+ command_dict: dict[str, Any] = {
93
+ "capability": self.capability,
94
+ "name": self.name,
95
+ }
96
+ if self.arguments:
97
+ command_dict["arguments"] = self.arguments
98
+ return command_dict
99
+
100
+
101
+ @dataclass
102
+ class ColorState:
103
+ """Represents color state of a light."""
104
+
105
+ r: int # 0-255
106
+ g: int # 0-255
107
+ b: int # 0-255
108
+
109
+ @classmethod
110
+ def from_dict(cls, data: dict[str, int]) -> "ColorState":
111
+ """Create ColorState instance from dictionary."""
112
+ return cls(r=data.get("r", 0), g=data.get("g", 0), b=data.get("b", 0))
113
+
114
+ def to_dict(self) -> dict[str, int]:
115
+ """Convert color state to dictionary format."""
116
+ return {"r": self.r, "g": self.g, "b": self.b}
117
+
118
+
119
+ @dataclass
120
+ class ColorTemperatureRange(int):
121
+ """Represents color temperature range for lights."""
122
+
123
+ min: int # Minimum temperature in Kelvin
124
+ max: int # Maximum temperature in Kelvin
125
+ step: int = 1 # Step size for temperature adjustment
126
+
127
+
128
+ class BrightnessRange(int):
129
+ """Constants for brightness range."""
130
+
131
+ MIN = 0
132
+ MAX = 100
133
+ STEP = 1
134
+
135
+
136
+ class ColorTempRange(int):
137
+ """Constants for color temperature range in Kelvin."""
138
+
139
+ MIN = 2000
140
+ MAX = 9000
141
+ STEP = 1
142
+
143
+
144
+ # Mapping of handle types to their required capabilities
145
+ HANDLE_TYPE_CAPABILITIES: dict[str, set[str]] = {
146
+ HandleType.UTEC_LOCK: {
147
+ DeviceCapability.LOCK,
148
+ DeviceCapability.BATTERY_LEVEL,
149
+ DeviceCapability.LOCK_USER,
150
+ DeviceCapability.HEALTH_CHECK,
151
+ },
152
+ HandleType.UTEC_LOCK_SENSOR: {
153
+ DeviceCapability.LOCK,
154
+ DeviceCapability.BATTERY_LEVEL,
155
+ DeviceCapability.DOOR_SENSOR,
156
+ DeviceCapability.HEALTH_CHECK,
157
+ },
158
+ HandleType.UTEC_DIMMER: {
159
+ DeviceCapability.SWITCH,
160
+ DeviceCapability.BRIGHTNESS,
161
+ DeviceCapability.HEALTH_CHECK,
162
+ },
163
+ HandleType.UTEC_LIGHT_RGBAW: {
164
+ DeviceCapability.SWITCH,
165
+ DeviceCapability.BRIGHTNESS,
166
+ DeviceCapability.COLOR,
167
+ DeviceCapability.COLOR_TEMPERATURE,
168
+ DeviceCapability.HEALTH_CHECK,
169
+ },
170
+ HandleType.UTEC_SWITCH: {DeviceCapability.SWITCH, DeviceCapability.HEALTH_CHECK},
171
+ }
172
+
173
+
174
+ @dataclass
175
+ class DeviceState:
176
+ """Represents a device state in the API."""
177
+
178
+ capability: str
179
+ name: str
180
+ value: Any
181
+
182
+ @classmethod
183
+ def from_dict(cls, data: dict[str, Any]) -> "DeviceState":
184
+ """Create DeviceState instance from API response dictionary."""
185
+ return cls(capability=data["capability"], name=data["name"], value=data["value"])
186
+
187
+
188
+ @dataclass
189
+ class DeviceAttributes:
190
+ """Device attributes from discovery data."""
191
+
192
+ color_model: str | None = None
193
+ color_temp_range: ColorTemperatureRange | None = None
194
+ switch_type: str | None = None
195
+
196
+ @classmethod
197
+ def from_dict(cls, data: dict[str, Any]) -> "DeviceAttributes":
198
+ """Create DeviceAttributes instance from discovery data dictionary."""
199
+ temp_range = None
200
+ if "colorTemperatureRange" in data:
201
+ temp_range = ColorTemperatureRange(
202
+ min=data["colorTemperatureRange"]["min"],
203
+ max=data["colorTemperatureRange"]["max"],
204
+ step=data["colorTemperatureRange"].get("step", 1),
205
+ )
206
+
207
+ return cls(
208
+ color_model=data.get("colorModel"),
209
+ color_temp_range=temp_range,
210
+ switch_type=data.get("switchType"),
211
+ )
212
+
213
+
214
+ # State value mapping for Home Assistant integration
215
+ STATE_MAP = {
216
+ DoorState.CLOSED: "closed",
217
+ DoorState.OPEN: "open",
218
+ DoorState.UNKNOWN: "unknown",
219
+ SwitchState.ON: "on",
220
+ SwitchState.OFF: "off",
221
+ SwitchState.UNKNOWN: "unknown",
222
+ }
@@ -0,0 +1,128 @@
1
+ """Abstraction layer for device interaction - Light."""
2
+ # src/utec-client/devices/light.py
3
+
4
+ from .device import BaseDevice
5
+ from .device_const import (
6
+ BrightnessRange,
7
+ ColorState,
8
+ ColorTempRange,
9
+ DeviceCapability,
10
+ DeviceCommand,
11
+ SwitchState,
12
+ )
13
+
14
+
15
+ class Light(BaseDevice):
16
+ """Represents a Light device in the U-Home API.
17
+
18
+ Maps to Home Assistant's light platform.
19
+ """
20
+
21
+ @property
22
+ def is_on(self) -> bool:
23
+ """Get light state."""
24
+ state = self._get_state_value(DeviceCapability.SWITCH, "switch")
25
+ return state == SwitchState.ON if state is not None else False
26
+
27
+ @property
28
+ def brightness(self) -> int | None:
29
+ """Get brightness level (0-100).
30
+
31
+ The API reports brightness under st.switchLevel / level, not st.brightness.
32
+ """
33
+ return self._get_state_value(DeviceCapability.SWITCH_LEVEL, "level")
34
+
35
+ @property
36
+ def color_temp(self) -> int | None:
37
+ """Get color temperature in Kelvin."""
38
+ return self._get_state_value(DeviceCapability.COLOR_TEMPERATURE, "temperature")
39
+
40
+ @property
41
+ def rgb_color(self) -> tuple[int, int, int] | None:
42
+ """Get RGB color."""
43
+ color_data = self._get_state_value(DeviceCapability.COLOR, "color")
44
+ if color_data:
45
+ color = ColorState.from_dict(color_data)
46
+ return (color.r, color.g, color.b)
47
+ return None
48
+
49
+ @property
50
+ def supported_features(self) -> set:
51
+ """Get supported features for Home Assistant."""
52
+ features = set()
53
+ if self.has_capability(DeviceCapability.BRIGHTNESS):
54
+ features.add("brightness")
55
+ if self.has_capability(DeviceCapability.COLOR):
56
+ features.add("color")
57
+ if self.has_capability(DeviceCapability.COLOR_TEMPERATURE):
58
+ features.add("color_temp")
59
+ return features
60
+
61
+ async def turn_on(self, **kwargs) -> None:
62
+ """Turn on the light with optional attributes.
63
+
64
+ If an attribute command (brightness, color_temp, rgb_color) is provided,
65
+ the device turns on implicitly — no separate "on" command is needed.
66
+ Only send the explicit "on" command when no attributes are specified.
67
+ """
68
+ if "brightness" in kwargs:
69
+ await self.set_brightness(kwargs["brightness"])
70
+ elif "color_temp" in kwargs:
71
+ await self.set_color_temp(kwargs["color_temp"])
72
+ elif "rgb_color" in kwargs:
73
+ await self.set_rgb_color(*kwargs["rgb_color"])
74
+ else:
75
+ command = DeviceCommand(
76
+ capability=DeviceCapability.SWITCH,
77
+ name="on",
78
+ )
79
+ await self.send_command(command)
80
+
81
+ async def turn_off(self) -> None:
82
+ """Turn off the light.
83
+
84
+ Per st.switch capability spec, the command name is "off" with no arguments.
85
+ """
86
+ command = DeviceCommand(
87
+ capability=DeviceCapability.SWITCH,
88
+ name="off",
89
+ )
90
+ await self.send_command(command)
91
+
92
+ async def set_brightness(self, brightness: int) -> None:
93
+ """Set brightness level (1-100).
94
+
95
+ Per st.switchLevel capability spec, command is setLevel with a
96
+ "level" argument (integer 0-100). We clamp to 1 as the minimum
97
+ since 0 means off per the spec — use turn_off() for that.
98
+ """
99
+ brightness = max(1, min(BrightnessRange.MAX, brightness))
100
+ command = DeviceCommand(
101
+ capability=DeviceCapability.SWITCH_LEVEL,
102
+ name="setLevel",
103
+ arguments={"level": brightness},
104
+ )
105
+ await self.send_command(command)
106
+
107
+ async def set_color_temp(self, temp: int) -> None:
108
+ """Set color temperature in Kelvin."""
109
+ if not ColorTempRange.MIN <= temp <= ColorTempRange.MAX:
110
+ raise ValueError(
111
+ f"Color temperature must be between {ColorTempRange.MIN}K and {ColorTempRange.MAX}K"
112
+ )
113
+ command = DeviceCommand(
114
+ capability=DeviceCapability.COLOR_TEMPERATURE,
115
+ name="temperature",
116
+ arguments={"value": temp},
117
+ )
118
+ await self.send_command(command)
119
+
120
+ async def set_rgb_color(self, red: int, green: int, blue: int) -> None:
121
+ """Set RGB color."""
122
+ color = ColorState(r=red, g=green, b=blue)
123
+ command = DeviceCommand(
124
+ capability=DeviceCapability.COLOR,
125
+ name="color",
126
+ arguments={"value": color.to_dict()},
127
+ )
128
+ await self.send_command(command)
@@ -0,0 +1,108 @@
1
+ """Abstraction layer for device interaction - Lock."""
2
+
3
+ from .device import BaseDevice
4
+ from .device_const import (
5
+ DeviceCapability,
6
+ DeviceCategory,
7
+ DeviceCommand,
8
+ DoorState,
9
+ LockMode,
10
+ LockState,
11
+ )
12
+
13
+
14
+ class Lock(BaseDevice):
15
+ """Represents a Lock device in the U-Home API.
16
+
17
+ Maps to Home Assistant's lock platform.
18
+ """
19
+
20
+ @property
21
+ def category(self) -> DeviceCategory:
22
+ """Get the device category."""
23
+ return DeviceCategory.LOCK
24
+
25
+ @property
26
+ def lock_state(self) -> str:
27
+ """Get the current lock state."""
28
+ state = self._get_state_value(DeviceCapability.LOCK, "lockState")
29
+ return state if state else "Unknown"
30
+
31
+ @property
32
+ def has_door_sensor(self) -> bool:
33
+ """Check if the lock has a door sensor capability."""
34
+ return (
35
+ self.has_capability(DeviceCapability.DOOR_SENSOR)
36
+ or self.has_capability("st.doorSensor")
37
+ or self._state_has_capability(DeviceCapability.DOOR_SENSOR, "st.doorSensor")
38
+ )
39
+
40
+ @property
41
+ def door_state(self) -> str | None:
42
+ """Get the door state if door sensor is present."""
43
+ if not self.has_door_sensor:
44
+ return None
45
+ door_state = self._get_state_value(DeviceCapability.DOOR_SENSOR, "sensorState")
46
+ return door_state or self._get_state_value("st.doorSensor", "sensorState")
47
+
48
+ @property
49
+ def lock_mode(self) -> str | None:
50
+ """Get the current lock mode."""
51
+ state = self._get_state_value(DeviceCapability.LOCK, "lockMode")
52
+ lock_mode_map = {
53
+ LockMode.NORMAL: "Normal",
54
+ LockMode.PASSAGE: "Passage",
55
+ LockMode.LOCKED: "Locked",
56
+ }
57
+ return lock_mode_map.get(state)
58
+
59
+ @property
60
+ def is_locked(self) -> bool:
61
+ """Check if the lock is in locked state."""
62
+ state = self._get_state_value(DeviceCapability.LOCK, "lockState")
63
+ return state == LockState.LOCKED if state is not None else False
64
+
65
+ @property
66
+ def is_jammed(self) -> bool:
67
+ """Check if the lock is jammed."""
68
+ state = self._get_state_value(DeviceCapability.LOCK, "lockState")
69
+ return state == LockState.JAMMED if state is not None else False
70
+
71
+ @property
72
+ def is_door_open(self) -> bool | None:
73
+ """Check if the door is open."""
74
+ if not self.has_door_sensor:
75
+ return None
76
+ return self.door_state == DoorState.OPEN
77
+
78
+ @property
79
+ def battery_status(self) -> str | None:
80
+ """Get the current battery level as a string."""
81
+ batt_level = self._get_state_value(DeviceCapability.BATTERY_LEVEL, "level")
82
+ battery_states = {
83
+ 1: "Critically Low",
84
+ 2: "Low",
85
+ 3: "Medium",
86
+ 4: "High",
87
+ 5: "Full",
88
+ }
89
+ return battery_states.get(batt_level)
90
+
91
+ @property
92
+ def battery_level(self) -> int | None:
93
+ """Get the current battery level as a percentage."""
94
+ level = self._get_state_value(DeviceCapability.BATTERY_LEVEL, "level")
95
+ if level is None:
96
+ return None
97
+ batt_map = {1: 10, 2: 30, 3: 50, 4: 70, 5: 100}
98
+ return batt_map.get(level, 0)
99
+
100
+ async def lock(self) -> None:
101
+ """Lock the device."""
102
+ command = DeviceCommand(capability=DeviceCapability.LOCK, name="lock")
103
+ await self.send_command(command)
104
+
105
+ async def unlock(self) -> None:
106
+ """Unlock the device."""
107
+ command = DeviceCommand(capability=DeviceCapability.LOCK, name="unlock")
108
+ await self.send_command(command)
@@ -0,0 +1,10 @@
1
+ """Abstraction layer for sensor entities"""
2
+
3
+ from .device import BaseDevice
4
+
5
+
6
+ class Sensor(BaseDevice):
7
+ """Represents a Sensor device in the U-Home API.
8
+
9
+ Maps to Home Assistant's Sensor platform.
10
+ """
@@ -0,0 +1,46 @@
1
+ """Abstraction layer for device interaction - Switch."""
2
+
3
+ # src/utec-client/devices/switch.py
4
+
5
+ from .device import BaseDevice
6
+ from .device_const import DeviceCapability, DeviceCommand, SwitchState
7
+
8
+
9
+ class Switch(BaseDevice):
10
+ """Represents a Switch device in the U-Home API.
11
+
12
+ Maps to Home Assistant's switch platform.
13
+ """
14
+
15
+ @property
16
+ def is_on(self) -> bool:
17
+ """Get switch state."""
18
+ state = self._get_state_value(DeviceCapability.SWITCH, "switch")
19
+ return state == SwitchState.ON if state is not None else False
20
+
21
+ @property
22
+ def available(self) -> bool:
23
+ """Device availability for Home Assistant."""
24
+ return self._state_data is not None
25
+
26
+ async def turn_on(self) -> None:
27
+ """Turn on the switch.
28
+
29
+ Per st.switch capability spec, the command name is "on" with no arguments.
30
+ """
31
+ command = DeviceCommand(
32
+ capability=DeviceCapability.SWITCH,
33
+ name="on",
34
+ )
35
+ await self.send_command(command)
36
+
37
+ async def turn_off(self) -> None:
38
+ """Turn off the switch.
39
+
40
+ Per st.switch capability spec, the command name is "off" with no arguments.
41
+ """
42
+ command = DeviceCommand(
43
+ capability=DeviceCapability.SWITCH,
44
+ name="off",
45
+ )
46
+ await self.send_command(command)
@@ -0,0 +1,40 @@
1
+ """U-Home API exceptions."""
2
+
3
+
4
+ class UHomeError(Exception):
5
+ """Base exception for U-Home API."""
6
+
7
+ pass
8
+
9
+
10
+ class AuthenticationError(UHomeError):
11
+ """Authentication failed."""
12
+
13
+ pass
14
+
15
+
16
+ class ApiError(UHomeError):
17
+ """API call failed."""
18
+
19
+ def __init__(self, status_code, message) -> None:
20
+ super().__init__(f"API call failed: {status_code} - {message}")
21
+ self.status_code = status_code
22
+ self.message = message
23
+
24
+
25
+ class ValidationError(UHomeError):
26
+ """Validation failed."""
27
+
28
+ pass
29
+
30
+
31
+ class DeviceError(Exception):
32
+ """Base exception for device-related errors."""
33
+
34
+ pass
35
+
36
+
37
+ class UnsupportedFeatureError(DeviceError):
38
+ """Exception raised when a feature is not supported by the device."""
39
+
40
+ pass
@@ -0,0 +1,99 @@
1
+ Metadata-Version: 2.4
2
+ Name: utec-client
3
+ Version: 0.5.0
4
+ Summary: An independently maintained U-Tec/U-Home API client library.
5
+ Author-email: Luke Wallis <wallis.luke4@gmail.com>
6
+ Maintainer: Geoff Franks
7
+ License-Expression: MIT
8
+ Project-URL: Homepage, https://github.com/geofffranks/utec-client
9
+ Project-URL: Issues, https://github.com/geofffranks/utec-client/issues
10
+ Keywords: utec,uhome,home automation,api client
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Requires-Python: >=3.11
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: aiohttp<4.0.0,>=3.7.4
23
+ Provides-Extra: dev
24
+ Requires-Dist: aioresponses>=0.7.6; extra == "dev"
25
+ Requires-Dist: aiohttp<3.13,>=3.7.4; extra == "dev" and extra == "dev"
26
+ Requires-Dist: build>=1.2; extra == "dev"
27
+ Requires-Dist: coverage[toml]>=7.4; extra == "dev"
28
+ Requires-Dist: pytest>=8.0; extra == "dev"
29
+ Requires-Dist: pytest-asyncio>=0.24; extra == "dev"
30
+ Requires-Dist: pytest-cov>=5.0; extra == "dev"
31
+ Requires-Dist: ruff>=0.8; extra == "dev"
32
+ Dynamic: license-file
33
+
34
+ # utec-client
35
+
36
+ > **Fork notice:** This is an independently maintained fork of [LF2b2w/utec-py](https://github.com/LF2b2w/utec-py), the original repository by [@LF2b2w](https://github.com/LF2b2w). The upstream project currently appears to be unmaintained, so this fork exists to keep the library working alongside the companion [Uhome-HA](https://github.com/LF2b2w/Uhome-HA) integration. It is not vendor-official. Any changes made here will be offered back to the upstream repository should it become responsive again. See [MIGRATION.md](MIGRATION.md) if you are coming from `utec_py` — the import name changed.
37
+
38
+ Python API client for U-Tec/U-Home devices. Primarily for Home Assistant, but usable anywhere.
39
+
40
+ ## Install
41
+
42
+ ```sh
43
+ pip install utec-client
44
+ ```
45
+
46
+ Imports use the `utec_client` namespace:
47
+
48
+ ```python
49
+ from utec_client.api import UHomeApi
50
+ from utec_client.auth import AbstractAuth
51
+ from utec_client.devices.lock import Lock
52
+ from utec_client.devices.light import Light
53
+ from utec_client.devices.sensor import Sensor
54
+ from utec_client.devices.switch import Switch
55
+ from utec_client.exceptions import ApiError
56
+ ```
57
+
58
+ ## API overview
59
+
60
+ ### `UHomeApi` (`utec_client.api`)
61
+
62
+ U-Tec's API is a single endpoint (`https://api.utec.com/action`) that uses
63
+ variable header information in the JSON payload to reach different API
64
+ interfaces. `UHomeApi` handles packaging the variables responsible for the
65
+ different interfaces/actions correctly within the request payload.
66
+
67
+ ### `AbstractAuth` (`utec_client.auth`)
68
+
69
+ Abstract base class handling most of the OAuth2 process: generating the auth
70
+ request URL, token exchange, token management, expiry validation, and token
71
+ refresh. Subclass it and implement the abstract token-retrieval method to
72
+ control how the initial auth request is handled. `UHomeApi` uses an
73
+ `AbstractAuth` implementation to perform authenticated requests.
74
+
75
+ ### Device modules (`utec_client.devices`)
76
+
77
+ Device info is organized with minimal change from the raw API response
78
+ (`id`, `name`, `category`, `handleType`, `deviceInfo`, `capabilities`,
79
+ `customData`, `attributes`, `state`). Device type classes (`Light`, `Lock`,
80
+ `Sensor`, `Switch`) pre-format and pre-fill API requests for simple
81
+ integration downstream. Locks are the most fully supported device type;
82
+ switches and lights have limited functionality.
83
+
84
+ ## Development
85
+
86
+ Requires Python 3.11–3.14.
87
+
88
+ ```sh
89
+ pip install -e .[dev]
90
+ ruff check . && ruff format --check .
91
+ pytest # includes the 90% branch-coverage floor
92
+ ```
93
+
94
+ See [docs/RELEASES.md](docs/RELEASES.md) for the release process and
95
+ [MIGRATION.md](MIGRATION.md) for the `utec_py` → `utec_client` rename guide.
96
+
97
+ ## License
98
+
99
+ MIT. Upstream authorship (Luke Wallis) is preserved; see [LICENSE](LICENSE).
@@ -0,0 +1,17 @@
1
+ utec_client/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ utec_client/api.py,sha256=nplR2DBAKcMDLfWlWkoAGyomDojGFxra75pFf47bym8,4991
3
+ utec_client/auth.py,sha256=Z2Jjbdq84SkCmM6zsrkbqLOOIhebFMO_BTSEDzCg2fI,1299
4
+ utec_client/const.py,sha256=24ltBHK8zC4NHMA8uVv4rXQ1SSCqfDHNhf21LjMZlVg,801
5
+ utec_client/exceptions.py,sha256=R23pyFu5qjZ94wYuXitJFYQRNbd1ErO4TvB6WgRs1D8,749
6
+ utec_client/devices/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ utec_client/devices/device.py,sha256=zx58agvQaxv-_K_tBjIPyKmIjtItvU9Y-EJ1Qi1gDrg,9397
8
+ utec_client/devices/device_const.py,sha256=Oh6stzy5v3wUrwmksDT00aF_kHtwnoi7dYemj-3MEFY,5838
9
+ utec_client/devices/light.py,sha256=YiS1gtPGvYODDhDGIgWSPY1NBcYbZnNt4XncrjwOJBI,4506
10
+ utec_client/devices/lock.py,sha256=m1ExHQs_R-HzXoLA-aNVEgjUN_35mj-gJymvu5P7Vgg,3571
11
+ utec_client/devices/sensor.py,sha256=IeTC28gZdE16kDpcrv-h8uJ4PtX79rT-IxDFjOnOKSY,212
12
+ utec_client/devices/switch.py,sha256=9lZt3Gi8c2mn9DNQ4MZjCcqrz98f7qyIHGtHx-jioYE,1343
13
+ utec_client-0.5.0.dist-info/licenses/LICENSE,sha256=h7k2EO0ogHiMCoUXZ7U8xj5vYWe67WQRyZnVvlVDvgw,1063
14
+ utec_client-0.5.0.dist-info/METADATA,sha256=s2mxOKOflFUCWUiexaaMxlOfJn2d6StaOyMf9gWNg34,4089
15
+ utec_client-0.5.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
16
+ utec_client-0.5.0.dist-info/top_level.txt,sha256=ReH0PiV5wVRG0Rn088iunoAcchcPXuVSjDTq0WRXn8Q,12
17
+ utec_client-0.5.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 LF2b2w
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.
@@ -0,0 +1 @@
1
+ utec_client