Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
name: Tests

on:
push:
branches: [main]
pull_request:

jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- run: pip install -e . -r requirements-test.txt
- run: pytest --cov=utec_py --cov-report=term-missing
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
## Additions
pypi.py
test.py
.venv/

# User-specific files
*.rsuser
Expand Down
7 changes: 5 additions & 2 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
"-p",
"*test.py"
],
"python.testing.pytestEnabled": false,
"python.testing.unittestEnabled": true
"python.testing.pytestEnabled": true,
"python.testing.unittestEnabled": false,
"python.testing.pytestArgs": [
"tests"
],
}
28 changes: 13 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@

Python API for U-Tec Devices. Primarily for Home Assistant, but should be able to be used anywhere.

## **This project is still under development. Expect rapid and functionality breaking changes.**

# Classes
## Oauth2.0 Authenticator
Handles most of the Oauth2 authentication process. Generates auth request URL for webauth, and token exchange. Includes token management methods, expiry validation, token refresh. Contains an Abstract method for retrieving initial token to allow for varyation of initial auth request handling.
### UtecOauth2 Constructor

## UtecAPI
## UhomeApi
U-Tec's API is a single endpoint (https://api.utec.com/action) that uses variable header information in the JSON payload to reach different API interfaces. So this class just handles packaging the variables responsible for different interfaces/actions correctly within the payload of the request.

## Device module
Expand All @@ -25,6 +27,10 @@ Provides an easy way to organise/ingest device info without much significant cha
attributes=data.get('attributes'),
state=data.get('state')
```
## Device Abstraction
Devices are abstracted via the device type files, API requests are pre-formatted and pre-filled for simple integration with downstream integrations.
Currently Utec only fully supports all functions of locks, but there is limited functionality with switches and lights.

### Methods
#### Token Management
**exchange_code**
Expand All @@ -37,13 +43,12 @@ Verifies current token validity before returning either a new token obtained via
Updates current token parameters, stores expires_in time and calculates expires_at with a 30s grace period.

#### API Requests
**make_auth_request**
Handles configuring auth header, via validation method, and performing API webession request. Returns a async context manager ClientResponse variable.
**make_request**
Handles API requests, takes a clientsession websession to perform request function.

## API Manager
**package_and_perform_request**
Is the main function for performing API requests, takes name and namespace for the header object, which corresponds to the Device/User/Config interface, and their various sub functionalities. All device manipulation/command data is passed as a single dict variable via the different specific request functions.
Also includes the API request with error and response handling, returning the response as a dict.
**create_request**
Packages request body parameters into a Utec/Uhome compliant standard.

## Install
```
Expand All @@ -55,7 +60,6 @@ pip install utec_py
```
from utec_py import AbstractAuth
from utec_py import UtecAPI
from utec_py import DeviceList

API = api()

Expand All @@ -68,7 +72,7 @@ class customAuthImplementation(AbstractAuth):
"""Return authentication for a custom auth implementation""
## API requests can be run with or without custom implementation as API class uses Abstract Auth as a parent to define request processess.

async def async_make_auth_request():
async def async_make_request():
"""Perform API Request"""
```
**In built Auth Handling**
Expand All @@ -86,12 +90,6 @@ Authenticator.exchange_access_code("access_code")
# Once Authenication has been completed devices can be called via API
API._discover() # Perform device discovery
API._query_device(device_id) # Query a specific devcie
API._send_command(device_id, capability, command) # Send a device command without arguments
API._send_command_with_arg(device_id, capability, command, arguments: dict) # For commands with arguments ie light brightness or colourtemperature

# Devices can then be managed from raw api responses or translated into more readable formats via device module.
Discover_devices = DeviceList.From_dict(api_data) # Parses device info which can then be called via print or other functions
for device in device_list.devices:
print(f"ID: {device.id}, Name: {device.name}")
API._send_command(device_id, capability, command, args)
```

5 changes: 5 additions & 0 deletions requirements-test.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
pytest>=8.0
pytest-asyncio>=0.24
aioresponses>=0.7.6
coverage[toml]>=7.4
pytest-cov>=5.0
9 changes: 9 additions & 0 deletions src/utec_py/devices/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,15 @@ def has_capability(self, capability: str) -> bool:
"""Check if the device supports a specific capability."""
return capability in self._supported_capabilities

def _state_has_capability(self, *capabilities: str) -> bool:
"""Return whether current state data contains one of the capabilities."""
if not self._state_data:
return False
return any(
state.get("capability") in capabilities
for state in self._state_data.get("states", [])
)

def _validate_capabilities(self) -> None:
"""Validate that the device has all required capabilities.

Expand Down
2 changes: 1 addition & 1 deletion src/utec_py/devices/device_const.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ class DeviceCapability(str, Enum):
LOCK = "st.lock"
BATTERY_LEVEL = "st.batteryLevel"
LOCK_USER = "st.lockUser"
DOOR_SENSOR = "st.doorSensor"
DOOR_SENSOR = "st.DoorSensor"
BRIGHTNESS = "st.brightness"
SWITCH_LEVEL = "st.switchLevel"
COLOR = "st.color"
Expand Down
9 changes: 6 additions & 3 deletions src/utec_py/devices/lock.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,18 @@ def lock_state(self) -> str:
@property
def has_door_sensor(self) -> bool:
"""Check if the lock has a door sensor capability."""
return self.has_capability(DeviceCapability.DOOR_SENSOR)
return self.has_capability(DeviceCapability.DOOR_SENSOR) or self.has_capability(
"st.doorSensor"
) or self._state_has_capability(DeviceCapability.DOOR_SENSOR, "st.doorSensor")

@property
def door_state(self) -> str | None:
"""Get the door state if door sensor is present."""
if not self.has_door_sensor:
return None
# API attribute name is "sensorState" (lowercase s)
return self._get_state_value(DeviceCapability.DOOR_SENSOR, "sensorState")
return self._get_state_value(DeviceCapability.DOOR_SENSOR, "sensorState") or self._get_state_value(
"st.doorSensor", "sensorState"
)

@property
def lock_mode(self) -> str | None:
Expand Down
12 changes: 12 additions & 0 deletions src/utec_py/devices/sensor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"""Abstraction layer for sensor entities"""

from .device import BaseDevice
from .device_const import DeviceCapability, DeviceCategory, DeviceCommand, LockState

class Sensor(BaseDevice):
"""Represents a Sensor device in the U-Home API.

Maps to Home Assistant's Sensor platform.
"""


94 changes: 93 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1 +1,93 @@
pytest_plugins = ['pytest_asyncio']
"""Shared pytest fixtures for utec-py tests."""

from __future__ import annotations

from typing import Any
from unittest.mock import AsyncMock, MagicMock

import aiohttp
import pytest
import pytest_asyncio

from utec_py.api import UHomeApi
from utec_py.auth import AbstractAuth


class _FakeAuth(AbstractAuth):
"""Concrete AbstractAuth returning a fixed access token."""

def __init__(self, websession: aiohttp.ClientSession, token: str = "test-token") -> None:
super().__init__(websession)
self._token = token

async def async_get_access_token(self) -> str:
return self._token


@pytest_asyncio.fixture
async def session():
async with aiohttp.ClientSession() as s:
yield s


@pytest_asyncio.fixture
async def fake_auth(session):
return _FakeAuth(session)


@pytest.fixture
def mock_api() -> MagicMock:
"""AsyncMock-capable UHomeApi stub for device-level tests."""
api = MagicMock(spec=UHomeApi)
api.send_command = AsyncMock(return_value={"payload": {"devices": []}})
api.query_device = AsyncMock(return_value={"payload": {"devices": []}})
api.get_device_state = AsyncMock(return_value={"payload": {"devices": []}})
api.discover_devices = AsyncMock(return_value={"payload": {"devices": []}})
api.set_push_status = AsyncMock(return_value={})
api.validate_auth = AsyncMock(return_value=True)
return api


@pytest.fixture
def discovery_dict():
"""Factory for discovery-shape device dicts."""

def _make(
handle_type: str = "utec-switch",
device_id: str = "dev-1",
name: str = "Test Device",
category: str = "switch",
**overrides: Any,
) -> dict:
data = {
"id": device_id,
"name": name,
"handleType": handle_type,
"category": category,
"deviceInfo": {
"manufacturer": "U-Tec",
"model": "M1",
"hwVersion": "1.0",
"serialNumber": "SN-1",
},
"attributes": {},
}
data.update(overrides)
return data

return _make


@pytest.fixture
def state_payload():
"""Factory for state-shape dicts consumed by BaseDevice.update_state_data."""

def _make(device_id: str = "dev-1", states: list[dict] | None = None) -> dict:
return {
"id": device_id,
"states": states or [
{"capability": "st.healthCheck", "name": "status", "value": "Online"}
],
}

return _make
Loading
Loading