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.
- utec_client/__init__.py +0 -0
- utec_client/api.py +146 -0
- utec_client/auth.py +45 -0
- utec_client/const.py +38 -0
- utec_client/devices/__init__.py +0 -0
- utec_client/devices/device.py +274 -0
- utec_client/devices/device_const.py +222 -0
- utec_client/devices/light.py +128 -0
- utec_client/devices/lock.py +108 -0
- utec_client/devices/sensor.py +10 -0
- utec_client/devices/switch.py +46 -0
- utec_client/exceptions.py +40 -0
- utec_client-0.5.0.dist-info/METADATA +99 -0
- utec_client-0.5.0.dist-info/RECORD +17 -0
- utec_client-0.5.0.dist-info/WHEEL +5 -0
- utec_client-0.5.0.dist-info/licenses/LICENSE +21 -0
- utec_client-0.5.0.dist-info/top_level.txt +1 -0
utec_client/__init__.py
ADDED
|
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,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,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
|