havenlighting 0.1.0__tar.gz
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.
- havenlighting-0.1.0/PKG-INFO +71 -0
- havenlighting-0.1.0/README.md +59 -0
- havenlighting-0.1.0/havenlighting/__init__.py +14 -0
- havenlighting-0.1.0/havenlighting/client.py +70 -0
- havenlighting-0.1.0/havenlighting/config.py +25 -0
- havenlighting-0.1.0/havenlighting/credentials.py +142 -0
- havenlighting-0.1.0/havenlighting/devices/light.py +73 -0
- havenlighting-0.1.0/havenlighting/devices/location.py +100 -0
- havenlighting-0.1.0/havenlighting/exceptions.py +21 -0
- havenlighting-0.1.0/havenlighting/logging.py +29 -0
- havenlighting-0.1.0/havenlighting/models.py +19 -0
- havenlighting-0.1.0/havenlighting.egg-info/PKG-INFO +71 -0
- havenlighting-0.1.0/havenlighting.egg-info/SOURCES.txt +17 -0
- havenlighting-0.1.0/havenlighting.egg-info/dependency_links.txt +1 -0
- havenlighting-0.1.0/havenlighting.egg-info/requires.txt +5 -0
- havenlighting-0.1.0/havenlighting.egg-info/top_level.txt +1 -0
- havenlighting-0.1.0/pyproject.toml +21 -0
- havenlighting-0.1.0/setup.cfg +4 -0
- havenlighting-0.1.0/tests/test_client.py +76 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: havenlighting
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for Haven Lighting API
|
|
5
|
+
Author-email: Mickey Schwab <mickeyschwab@gmail.com>
|
|
6
|
+
Requires-Python: >=3.7
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: requests>=2.25.0
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: pytest>=6.0; extra == "dev"
|
|
11
|
+
Requires-Dist: pytest-mock>=3.0; extra == "dev"
|
|
12
|
+
|
|
13
|
+
# Haven Lighting API Client
|
|
14
|
+
|
|
15
|
+
A Python client library for interacting with the Haven Lighting API.
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install havenlighting
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from havenlighting import HavenClient
|
|
27
|
+
|
|
28
|
+
# Initialize the client
|
|
29
|
+
client = HavenClient()
|
|
30
|
+
|
|
31
|
+
# Authenticate
|
|
32
|
+
authenticated = client.authenticate(
|
|
33
|
+
email="your-email@example.com",
|
|
34
|
+
password="your-password"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
if authenticated:
|
|
38
|
+
# Discover locations
|
|
39
|
+
locations = client.discover_locations()
|
|
40
|
+
|
|
41
|
+
# Print available locations and lights
|
|
42
|
+
for location_id, location in locations.items():
|
|
43
|
+
print(f"Location: {location.name}")
|
|
44
|
+
|
|
45
|
+
# Get lights for this location
|
|
46
|
+
lights = location.get_lights()
|
|
47
|
+
|
|
48
|
+
# Control lights
|
|
49
|
+
for light_id, light in lights.items():
|
|
50
|
+
print(f"Light: {light.name}")
|
|
51
|
+
light.turn_on() # Turn light on
|
|
52
|
+
light.turn_off() # Turn light off
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Development
|
|
57
|
+
|
|
58
|
+
1. Clone the repository
|
|
59
|
+
2. Install development dependencies:
|
|
60
|
+
```bash
|
|
61
|
+
pip install -e ".[dev]"
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
3. Run tests:
|
|
65
|
+
```bash
|
|
66
|
+
pytest
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## License
|
|
70
|
+
|
|
71
|
+
MIT License - see LICENSE file for details.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Haven Lighting API Client
|
|
2
|
+
|
|
3
|
+
A Python client library for interacting with the Haven Lighting API.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install havenlighting
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from havenlighting import HavenClient
|
|
15
|
+
|
|
16
|
+
# Initialize the client
|
|
17
|
+
client = HavenClient()
|
|
18
|
+
|
|
19
|
+
# Authenticate
|
|
20
|
+
authenticated = client.authenticate(
|
|
21
|
+
email="your-email@example.com",
|
|
22
|
+
password="your-password"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
if authenticated:
|
|
26
|
+
# Discover locations
|
|
27
|
+
locations = client.discover_locations()
|
|
28
|
+
|
|
29
|
+
# Print available locations and lights
|
|
30
|
+
for location_id, location in locations.items():
|
|
31
|
+
print(f"Location: {location.name}")
|
|
32
|
+
|
|
33
|
+
# Get lights for this location
|
|
34
|
+
lights = location.get_lights()
|
|
35
|
+
|
|
36
|
+
# Control lights
|
|
37
|
+
for light_id, light in lights.items():
|
|
38
|
+
print(f"Light: {light.name}")
|
|
39
|
+
light.turn_on() # Turn light on
|
|
40
|
+
light.turn_off() # Turn light off
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Development
|
|
45
|
+
|
|
46
|
+
1. Clone the repository
|
|
47
|
+
2. Install development dependencies:
|
|
48
|
+
```bash
|
|
49
|
+
pip install -e ".[dev]"
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
3. Run tests:
|
|
53
|
+
```bash
|
|
54
|
+
pytest
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## License
|
|
58
|
+
|
|
59
|
+
MIT License - see LICENSE file for details.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from .client import HavenClient
|
|
2
|
+
from .devices.light import Light
|
|
3
|
+
from .devices.location import Location
|
|
4
|
+
from .exceptions import HavenException, AuthenticationError, DeviceError
|
|
5
|
+
|
|
6
|
+
__version__ = "0.1.0"
|
|
7
|
+
__all__ = [
|
|
8
|
+
"HavenClient",
|
|
9
|
+
"Light",
|
|
10
|
+
"Location",
|
|
11
|
+
"HavenException",
|
|
12
|
+
"AuthenticationError",
|
|
13
|
+
"DeviceError",
|
|
14
|
+
]
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Dict, Any, Optional
|
|
3
|
+
from .credentials import Credentials
|
|
4
|
+
from .devices.light import Light
|
|
5
|
+
from .devices.location import Location
|
|
6
|
+
from .exceptions import AuthenticationError, ApiError
|
|
7
|
+
from .logging import setup_logging
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
class HavenClient:
|
|
12
|
+
"""Main client for interacting with Haven Lighting devices."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, log_level: int = logging.INFO, log_file: Optional[str] = None) -> None:
|
|
15
|
+
"""
|
|
16
|
+
Initialize the Haven Lighting client.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
log_level: Logging level (default: INFO)
|
|
20
|
+
log_file: Optional file path for logging output
|
|
21
|
+
"""
|
|
22
|
+
setup_logging(log_level, log_file)
|
|
23
|
+
self._credentials = Credentials()
|
|
24
|
+
self._locations: Dict[int, Location] = {}
|
|
25
|
+
self._lights: Dict[int, Light] = {}
|
|
26
|
+
logger.debug("Initialized HavenClient")
|
|
27
|
+
|
|
28
|
+
def authenticate(self, email: str, password: str) -> bool:
|
|
29
|
+
"""
|
|
30
|
+
Authenticate with the Haven Lighting service.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
email: User's email address
|
|
34
|
+
password: User's password
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
bool: True if authentication successful, False otherwise
|
|
38
|
+
|
|
39
|
+
Raises:
|
|
40
|
+
ApiError: If API request fails
|
|
41
|
+
"""
|
|
42
|
+
try:
|
|
43
|
+
authenticated = self._credentials.authenticate(email, password)
|
|
44
|
+
if authenticated:
|
|
45
|
+
logger.info("Successfully authenticated user: %s", email)
|
|
46
|
+
else:
|
|
47
|
+
logger.warning("Authentication failed for user: %s", email)
|
|
48
|
+
return authenticated
|
|
49
|
+
except ApiError as e:
|
|
50
|
+
logger.error("API error during authentication: %s", str(e))
|
|
51
|
+
raise
|
|
52
|
+
|
|
53
|
+
def get_location(self, location_id: int) -> Location:
|
|
54
|
+
"""Get a location by ID."""
|
|
55
|
+
if not self._credentials:
|
|
56
|
+
raise AuthenticationError("Not authenticated")
|
|
57
|
+
|
|
58
|
+
if location_id not in self._locations:
|
|
59
|
+
self._locations[location_id] = Location(self._credentials, location_id)
|
|
60
|
+
|
|
61
|
+
return self._locations[location_id]
|
|
62
|
+
|
|
63
|
+
def discover_locations(self) -> Dict[int, Location]:
|
|
64
|
+
"""Discover all available locations."""
|
|
65
|
+
if not self._credentials:
|
|
66
|
+
raise AuthenticationError("Not authenticated")
|
|
67
|
+
|
|
68
|
+
locations = Location.discover(self._credentials)
|
|
69
|
+
self._locations.update(locations)
|
|
70
|
+
return self._locations
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Configuration constants for Haven Lighting API."""
|
|
2
|
+
|
|
3
|
+
from typing import Final
|
|
4
|
+
|
|
5
|
+
# API Configuration
|
|
6
|
+
API_TIMEOUT: Final[int] = 30
|
|
7
|
+
MAX_RETRIES: Final[int] = 3
|
|
8
|
+
|
|
9
|
+
# Light States
|
|
10
|
+
LIGHT_STATE: Final[dict] = {
|
|
11
|
+
"OFF": 1,
|
|
12
|
+
"ON": 2
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
# Default Light Parameters
|
|
16
|
+
LIGHT_PARAMS: Final[dict] = {
|
|
17
|
+
"BRIGHTNESS": 63,
|
|
18
|
+
"COLOR": 63,
|
|
19
|
+
"PATTERN_SPEED": 63
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
# API Configuration
|
|
23
|
+
DEVICE_ID: Final[str] = "HavenLightingMobile"
|
|
24
|
+
AUTH_API_BASE: Final[str] = "https://havenwebservices-apiapp-test.azurewebsites.net/api/v2"
|
|
25
|
+
PROD_API_BASE: Final[str] = "https://ase-hvnlght-residential-api-prod.azurewebsites.net/api"
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
from typing import Dict, Any, Optional, Callable
|
|
2
|
+
import requests
|
|
3
|
+
from functools import wraps
|
|
4
|
+
from .exceptions import AuthenticationError, ApiError
|
|
5
|
+
import logging
|
|
6
|
+
from .config import DEVICE_ID, AUTH_API_BASE, PROD_API_BASE, API_TIMEOUT
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
def refresh_on_auth_error(func: Callable) -> Callable:
|
|
11
|
+
"""Decorator to refresh token and retry on authentication errors."""
|
|
12
|
+
@wraps(func)
|
|
13
|
+
def wrapper(self, *args, **kwargs):
|
|
14
|
+
try:
|
|
15
|
+
return func(self, *args, **kwargs)
|
|
16
|
+
except AuthenticationError:
|
|
17
|
+
logger.info("Authentication error, attempting token refresh")
|
|
18
|
+
if self.refresh_token():
|
|
19
|
+
logger.info("Token refresh successful, retrying request")
|
|
20
|
+
return func(self, *args, **kwargs)
|
|
21
|
+
logger.error("Token refresh failed, unable to retry request")
|
|
22
|
+
raise
|
|
23
|
+
return wrapper
|
|
24
|
+
|
|
25
|
+
class Credentials:
|
|
26
|
+
"""Handles authentication and request credentials."""
|
|
27
|
+
|
|
28
|
+
def __init__(self):
|
|
29
|
+
self._token: Optional[str] = None
|
|
30
|
+
self._refresh_token: Optional[str] = None
|
|
31
|
+
self._user_id: Optional[int] = None
|
|
32
|
+
logger.debug("Initialized Credentials")
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def is_authenticated(self) -> bool:
|
|
36
|
+
return bool(self._token and self._user_id)
|
|
37
|
+
|
|
38
|
+
def authenticate(self, email: str, password: str) -> bool:
|
|
39
|
+
"""Authenticate with the Haven Lighting service."""
|
|
40
|
+
logger.debug("Attempting authentication for user: %s", email)
|
|
41
|
+
payload = {
|
|
42
|
+
"email": email,
|
|
43
|
+
"password": password,
|
|
44
|
+
"deviceId": DEVICE_ID,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
response = self._make_request_internal(
|
|
49
|
+
"POST",
|
|
50
|
+
"/User/authenticate",
|
|
51
|
+
json=payload,
|
|
52
|
+
auth_required=False
|
|
53
|
+
)
|
|
54
|
+
if not response["success"] or not response["data"]:
|
|
55
|
+
logger.error("Authentication failed for user %s: %s", email, response["message"])
|
|
56
|
+
return False
|
|
57
|
+
self._update_credentials(response["data"])
|
|
58
|
+
logger.info("Successfully authenticated user: %s", email)
|
|
59
|
+
return True
|
|
60
|
+
|
|
61
|
+
except ApiError as e:
|
|
62
|
+
logger.error("Authentication failed for user %s: %s", email, str(e))
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
def refresh_token(self) -> bool:
|
|
66
|
+
"""Refresh the authentication token."""
|
|
67
|
+
if not self._refresh_token or not self._user_id:
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
response = self._make_request_internal(
|
|
72
|
+
"POST",
|
|
73
|
+
"/User/refresh",
|
|
74
|
+
json={
|
|
75
|
+
"refreshToken": self._refresh_token,
|
|
76
|
+
"userId": self._user_id
|
|
77
|
+
},
|
|
78
|
+
auth_required=False
|
|
79
|
+
)
|
|
80
|
+
self._update_credentials(response["data"])
|
|
81
|
+
return True
|
|
82
|
+
|
|
83
|
+
except ApiError as e:
|
|
84
|
+
logger.error("Token refresh failed: %s", str(e))
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
def _update_credentials(self, data: Dict[str, Any]) -> None:
|
|
88
|
+
"""Update stored credentials from API response."""
|
|
89
|
+
self._token = data["token"]
|
|
90
|
+
self._refresh_token = data["refreshToken"]
|
|
91
|
+
self._user_id = data["id"]
|
|
92
|
+
|
|
93
|
+
@refresh_on_auth_error
|
|
94
|
+
def make_request(
|
|
95
|
+
self,
|
|
96
|
+
method: str,
|
|
97
|
+
path: str,
|
|
98
|
+
auth_required: bool = True,
|
|
99
|
+
use_prod_api: bool = False,
|
|
100
|
+
timeout: int = API_TIMEOUT,
|
|
101
|
+
**kwargs
|
|
102
|
+
) -> Dict[str, Any]:
|
|
103
|
+
"""Make an authenticated API request with automatic token refresh."""
|
|
104
|
+
return self._make_request_internal(
|
|
105
|
+
method, path, auth_required, use_prod_api, timeout, **kwargs
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def _make_request_internal(
|
|
109
|
+
self,
|
|
110
|
+
method: str,
|
|
111
|
+
path: str,
|
|
112
|
+
auth_required: bool = True,
|
|
113
|
+
use_prod_api: bool = False,
|
|
114
|
+
timeout: int = API_TIMEOUT,
|
|
115
|
+
**kwargs
|
|
116
|
+
) -> Dict[str, Any]:
|
|
117
|
+
"""Internal method for making API requests."""
|
|
118
|
+
if auth_required and not self.is_authenticated:
|
|
119
|
+
raise AuthenticationError("Authentication required")
|
|
120
|
+
|
|
121
|
+
base_url = PROD_API_BASE if use_prod_api else AUTH_API_BASE
|
|
122
|
+
url = f"{base_url}{path}"
|
|
123
|
+
|
|
124
|
+
if self._token:
|
|
125
|
+
headers = kwargs.pop("headers", {})
|
|
126
|
+
headers["Authorization"] = f"Bearer {self._token}"
|
|
127
|
+
kwargs["headers"] = headers
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
response = requests.request(method, url, timeout=timeout, **kwargs)
|
|
131
|
+
response.raise_for_status()
|
|
132
|
+
data = response.json()
|
|
133
|
+
|
|
134
|
+
if not data.get("success"):
|
|
135
|
+
if "token" in str(data.get("message", "")).lower():
|
|
136
|
+
raise AuthenticationError(data.get("message", "Token expired"))
|
|
137
|
+
raise ApiError(data.get("message", "Unknown API error"))
|
|
138
|
+
|
|
139
|
+
return data
|
|
140
|
+
except requests.exceptions.RequestException as e:
|
|
141
|
+
logger.error("Request failed: %s", str(e))
|
|
142
|
+
raise ApiError(f"Request failed: {str(e)}")
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from typing import Dict, Any
|
|
2
|
+
import logging
|
|
3
|
+
from ..models import LightData
|
|
4
|
+
from ..config import LIGHT_STATE, LIGHT_PARAMS
|
|
5
|
+
from ..credentials import Credentials
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
class Light:
|
|
10
|
+
"""Represents a Haven light device."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, credentials: Credentials, location_id: int, light_id: int, data: Dict[str, Any]) -> None:
|
|
13
|
+
self._credentials = credentials
|
|
14
|
+
self.location_id = location_id
|
|
15
|
+
self._data = LightData(
|
|
16
|
+
light_id=int(data["lightId"]),
|
|
17
|
+
name=data["name"],
|
|
18
|
+
status=data.get("lightingStatusId", LIGHT_STATE["OFF"]),
|
|
19
|
+
brightness=data.get("brightness", LIGHT_PARAMS["BRIGHTNESS"]),
|
|
20
|
+
color=data.get("color", LIGHT_PARAMS["COLOR"]),
|
|
21
|
+
pattern_speed=data.get("patternSpeed", LIGHT_PARAMS["PATTERN_SPEED"])
|
|
22
|
+
)
|
|
23
|
+
logger.debug("Initialized Light: %s (ID: %d)", self.name, self.id)
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def id(self) -> int:
|
|
27
|
+
return self._data.light_id
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def name(self) -> str:
|
|
31
|
+
return self._data.name
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def is_on(self) -> bool:
|
|
35
|
+
return self._data.status == LIGHT_STATE["ON"]
|
|
36
|
+
|
|
37
|
+
def turn_on(self) -> None:
|
|
38
|
+
"""Turn the light on."""
|
|
39
|
+
logger.debug("Turning on light: %s (ID: %d)", self.name, self.id)
|
|
40
|
+
try:
|
|
41
|
+
self._send_command(LIGHT_STATE["ON"])
|
|
42
|
+
self._data.status = LIGHT_STATE["ON"]
|
|
43
|
+
logger.info("Light turned on successfully: %s", self.name)
|
|
44
|
+
except Exception as e:
|
|
45
|
+
logger.error("Failed to turn on light %s: %s", self.name, str(e))
|
|
46
|
+
raise
|
|
47
|
+
|
|
48
|
+
def turn_off(self) -> None:
|
|
49
|
+
"""Turn the light off."""
|
|
50
|
+
logger.debug("Turning off light: %s (ID: %d)", self.name, self.id)
|
|
51
|
+
try:
|
|
52
|
+
self._send_command(LIGHT_STATE["OFF"])
|
|
53
|
+
self._data.status = LIGHT_STATE["OFF"]
|
|
54
|
+
logger.info("Light turned off successfully: %s", self.name)
|
|
55
|
+
except Exception as e:
|
|
56
|
+
logger.error("Failed to turn off light %s: %s", self.name, str(e))
|
|
57
|
+
raise
|
|
58
|
+
|
|
59
|
+
def _send_command(self, status_id: int) -> None:
|
|
60
|
+
"""Send a command to the light."""
|
|
61
|
+
self._credentials.make_request(
|
|
62
|
+
"POST",
|
|
63
|
+
"/Light/CommandV1",
|
|
64
|
+
params={"locationId": self.location_id},
|
|
65
|
+
json={
|
|
66
|
+
"lightingStatusId": status_id,
|
|
67
|
+
"lightBrightnessId": LIGHT_PARAMS["BRIGHTNESS"],
|
|
68
|
+
"lightColorId": LIGHT_PARAMS["COLOR"],
|
|
69
|
+
"patternSpeedId": LIGHT_PARAMS["PATTERN_SPEED"],
|
|
70
|
+
"selectedLightIds": [self.id],
|
|
71
|
+
"locationId": self.location_id
|
|
72
|
+
}
|
|
73
|
+
)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
from typing import Dict, Any, Optional, ClassVar
|
|
2
|
+
import logging
|
|
3
|
+
from ..models import LocationData
|
|
4
|
+
from .light import Light
|
|
5
|
+
from ..credentials import Credentials
|
|
6
|
+
from ..exceptions import AuthenticationError
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
class Location:
|
|
11
|
+
"""
|
|
12
|
+
Represents a Haven location with its associated lights.
|
|
13
|
+
|
|
14
|
+
Attributes:
|
|
15
|
+
credentials: Credentials object for API requests
|
|
16
|
+
location_id: Unique identifier for the location
|
|
17
|
+
name: Location name
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
MIN_CAPABILITY_LEVEL: ClassVar[int] = 0
|
|
21
|
+
|
|
22
|
+
def __init__(self, credentials: Credentials, location_id: int, data: Optional[Dict[str, Any]] = None) -> None:
|
|
23
|
+
self._credentials = credentials
|
|
24
|
+
self._location_id = location_id
|
|
25
|
+
self._data = LocationData(
|
|
26
|
+
location_id=location_id,
|
|
27
|
+
name=data.get("name", ""),
|
|
28
|
+
owner_name=data.get("ownerName", "")
|
|
29
|
+
) if data else None
|
|
30
|
+
self._lights: Dict[int, Light] = {}
|
|
31
|
+
logger.debug("Initialized Location: %s (ID: %d)", self.name, location_id)
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def name(self) -> str:
|
|
35
|
+
"""Get location name."""
|
|
36
|
+
return self._data.owner_name if self._data else ""
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def discover(cls, credentials: Credentials) -> Dict[int, 'Location']:
|
|
40
|
+
"""
|
|
41
|
+
Discover all locations available to the authenticated user.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
credentials: Authenticated credentials object
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
Dictionary of location_id to Location objects
|
|
48
|
+
|
|
49
|
+
Raises:
|
|
50
|
+
AuthenticationError: If not authenticated
|
|
51
|
+
ApiError: If API request fails
|
|
52
|
+
"""
|
|
53
|
+
response = credentials.make_request(
|
|
54
|
+
"GET",
|
|
55
|
+
"/Location/OrderedLocationV2",
|
|
56
|
+
params={"minimumCapabilityLevel": cls.MIN_CAPABILITY_LEVEL},
|
|
57
|
+
use_prod_api=True
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
locations = {}
|
|
61
|
+
for loc_data in response.get("data", []):
|
|
62
|
+
location_id = int(loc_data["locationId"])
|
|
63
|
+
locations[location_id] = cls(credentials, location_id, loc_data)
|
|
64
|
+
return locations
|
|
65
|
+
|
|
66
|
+
def update(self) -> None:
|
|
67
|
+
"""Update location details."""
|
|
68
|
+
response = self._credentials.make_request(
|
|
69
|
+
"GET",
|
|
70
|
+
f"/Location/InformationSummary/{self._location_id}",
|
|
71
|
+
use_prod_api=True
|
|
72
|
+
)
|
|
73
|
+
self._data = response["data"]
|
|
74
|
+
|
|
75
|
+
def get_lights(self) -> Dict[int, Light]:
|
|
76
|
+
"""Get all lights for this location."""
|
|
77
|
+
logger.debug("Fetching lights for location: %s (ID: %d)", self.name, self._location_id)
|
|
78
|
+
|
|
79
|
+
if not self._lights:
|
|
80
|
+
try:
|
|
81
|
+
response = self._credentials.make_request(
|
|
82
|
+
"GET",
|
|
83
|
+
"/Light/OrderedLightsAndZones",
|
|
84
|
+
params={"locationId": self._location_id}
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
for light_data in response["data"]["lights"]:
|
|
88
|
+
light_id = int(light_data["lightId"])
|
|
89
|
+
self._lights[light_id] = Light(
|
|
90
|
+
self._credentials,
|
|
91
|
+
self._location_id,
|
|
92
|
+
light_id,
|
|
93
|
+
light_data
|
|
94
|
+
)
|
|
95
|
+
logger.info("Found %d lights for location: %s", len(self._lights), self.name)
|
|
96
|
+
except Exception as e:
|
|
97
|
+
logger.error("Failed to fetch lights for location %d: %s", self._location_id, str(e))
|
|
98
|
+
raise
|
|
99
|
+
|
|
100
|
+
return self._lights
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
class HavenException(Exception):
|
|
4
|
+
"""Base exception for Haven Lighting API errors."""
|
|
5
|
+
|
|
6
|
+
def __init__(self, message: str, code: Optional[int] = None) -> None:
|
|
7
|
+
self.message = message
|
|
8
|
+
self.code = code
|
|
9
|
+
super().__init__(self.message)
|
|
10
|
+
|
|
11
|
+
class ApiError(HavenException):
|
|
12
|
+
"""Raised when an API request fails."""
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
class AuthenticationError(HavenException):
|
|
16
|
+
"""Raised when authentication fails or is required but missing."""
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
class DeviceError(HavenException):
|
|
20
|
+
"""Raised when a device operation fails."""
|
|
21
|
+
pass
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Logging configuration for Haven Lighting."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
def setup_logging(level: int = logging.INFO,
|
|
7
|
+
log_file: Optional[str] = None) -> None:
|
|
8
|
+
"""
|
|
9
|
+
Configure logging for the Haven Lighting client.
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
level: Logging level (default: INFO)
|
|
13
|
+
log_file: Optional file path for logging output
|
|
14
|
+
"""
|
|
15
|
+
logger = logging.getLogger("havenlighting")
|
|
16
|
+
logger.setLevel(level)
|
|
17
|
+
|
|
18
|
+
formatter = logging.Formatter(
|
|
19
|
+
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
if log_file:
|
|
23
|
+
file_handler = logging.FileHandler(log_file)
|
|
24
|
+
file_handler.setFormatter(formatter)
|
|
25
|
+
logger.addHandler(file_handler)
|
|
26
|
+
|
|
27
|
+
console_handler = logging.StreamHandler()
|
|
28
|
+
console_handler.setFormatter(formatter)
|
|
29
|
+
logger.addHandler(console_handler)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
@dataclass
|
|
5
|
+
class LightData:
|
|
6
|
+
"""Data model for light attributes."""
|
|
7
|
+
light_id: int
|
|
8
|
+
name: str
|
|
9
|
+
status: int
|
|
10
|
+
brightness: int = 63
|
|
11
|
+
color: int = 63
|
|
12
|
+
pattern_speed: int = 63
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class LocationData:
|
|
16
|
+
"""Data model for location attributes."""
|
|
17
|
+
location_id: int
|
|
18
|
+
name: str
|
|
19
|
+
owner_name: Optional[str] = None
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: havenlighting
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for Haven Lighting API
|
|
5
|
+
Author-email: Mickey Schwab <mickeyschwab@gmail.com>
|
|
6
|
+
Requires-Python: >=3.7
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: requests>=2.25.0
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: pytest>=6.0; extra == "dev"
|
|
11
|
+
Requires-Dist: pytest-mock>=3.0; extra == "dev"
|
|
12
|
+
|
|
13
|
+
# Haven Lighting API Client
|
|
14
|
+
|
|
15
|
+
A Python client library for interacting with the Haven Lighting API.
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install havenlighting
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from havenlighting import HavenClient
|
|
27
|
+
|
|
28
|
+
# Initialize the client
|
|
29
|
+
client = HavenClient()
|
|
30
|
+
|
|
31
|
+
# Authenticate
|
|
32
|
+
authenticated = client.authenticate(
|
|
33
|
+
email="your-email@example.com",
|
|
34
|
+
password="your-password"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
if authenticated:
|
|
38
|
+
# Discover locations
|
|
39
|
+
locations = client.discover_locations()
|
|
40
|
+
|
|
41
|
+
# Print available locations and lights
|
|
42
|
+
for location_id, location in locations.items():
|
|
43
|
+
print(f"Location: {location.name}")
|
|
44
|
+
|
|
45
|
+
# Get lights for this location
|
|
46
|
+
lights = location.get_lights()
|
|
47
|
+
|
|
48
|
+
# Control lights
|
|
49
|
+
for light_id, light in lights.items():
|
|
50
|
+
print(f"Light: {light.name}")
|
|
51
|
+
light.turn_on() # Turn light on
|
|
52
|
+
light.turn_off() # Turn light off
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Development
|
|
57
|
+
|
|
58
|
+
1. Clone the repository
|
|
59
|
+
2. Install development dependencies:
|
|
60
|
+
```bash
|
|
61
|
+
pip install -e ".[dev]"
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
3. Run tests:
|
|
65
|
+
```bash
|
|
66
|
+
pytest
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## License
|
|
70
|
+
|
|
71
|
+
MIT License - see LICENSE file for details.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
havenlighting/__init__.py
|
|
4
|
+
havenlighting/client.py
|
|
5
|
+
havenlighting/config.py
|
|
6
|
+
havenlighting/credentials.py
|
|
7
|
+
havenlighting/exceptions.py
|
|
8
|
+
havenlighting/logging.py
|
|
9
|
+
havenlighting/models.py
|
|
10
|
+
havenlighting.egg-info/PKG-INFO
|
|
11
|
+
havenlighting.egg-info/SOURCES.txt
|
|
12
|
+
havenlighting.egg-info/dependency_links.txt
|
|
13
|
+
havenlighting.egg-info/requires.txt
|
|
14
|
+
havenlighting.egg-info/top_level.txt
|
|
15
|
+
havenlighting/devices/light.py
|
|
16
|
+
havenlighting/devices/location.py
|
|
17
|
+
tests/test_client.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
havenlighting
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=45", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "havenlighting"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python client for Haven Lighting API"
|
|
9
|
+
authors = [{name = "Mickey Schwab", email = "mickeyschwab@gmail.com"}]
|
|
10
|
+
license = {file = "LICENSE"}
|
|
11
|
+
readme = "README.md"
|
|
12
|
+
requires-python = ">=3.7"
|
|
13
|
+
dependencies = [
|
|
14
|
+
"requests>=2.25.0",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.optional-dependencies]
|
|
18
|
+
dev = [
|
|
19
|
+
"pytest>=6.0",
|
|
20
|
+
"pytest-mock>=3.0"
|
|
21
|
+
]
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from havenlighting import HavenClient
|
|
4
|
+
from havenlighting.config import DEVICE_ID
|
|
5
|
+
from havenlighting.credentials import Credentials
|
|
6
|
+
from havenlighting.exceptions import AuthenticationError, DeviceError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_client_initialization():
|
|
10
|
+
client = HavenClient()
|
|
11
|
+
assert isinstance(client._credentials, Credentials)
|
|
12
|
+
assert not client._credentials.is_authenticated
|
|
13
|
+
assert client._locations == {}
|
|
14
|
+
assert client._lights == {}
|
|
15
|
+
|
|
16
|
+
def test_authentication_success(client, mocker):
|
|
17
|
+
# Create credentials first
|
|
18
|
+
credentials = Credentials()
|
|
19
|
+
|
|
20
|
+
# Mock the internal request method
|
|
21
|
+
mock_request = mocker.patch.object(credentials, '_make_request_internal')
|
|
22
|
+
mock_request.return_value = {
|
|
23
|
+
"success": True,
|
|
24
|
+
"data": {
|
|
25
|
+
"token": "test_token",
|
|
26
|
+
"refreshToken": "test_refresh_token",
|
|
27
|
+
"id": 123
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
client._credentials = credentials
|
|
32
|
+
assert client.authenticate("test@example.com", "password")
|
|
33
|
+
assert client._credentials.is_authenticated
|
|
34
|
+
mock_request.assert_called_once_with(
|
|
35
|
+
"POST",
|
|
36
|
+
"/User/authenticate",
|
|
37
|
+
json={
|
|
38
|
+
"email": "test@example.com",
|
|
39
|
+
"password": "password",
|
|
40
|
+
"deviceId": DEVICE_ID
|
|
41
|
+
},
|
|
42
|
+
auth_required=False
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def test_authentication_failure(client, mocker):
|
|
46
|
+
# Create credentials first
|
|
47
|
+
credentials = Credentials()
|
|
48
|
+
|
|
49
|
+
# Mock failed authentication
|
|
50
|
+
mock_request = mocker.patch.object(credentials, '_make_request_internal')
|
|
51
|
+
mock_request.return_value = {
|
|
52
|
+
"success": False,
|
|
53
|
+
"returnCode": 600,
|
|
54
|
+
"severityLevel": 1,
|
|
55
|
+
"message": "Password is incorrect",
|
|
56
|
+
"data": None
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
client._credentials = credentials
|
|
60
|
+
|
|
61
|
+
assert not client.authenticate("test@example.com", "wrong_password")
|
|
62
|
+
assert not client._credentials.is_authenticated
|
|
63
|
+
|
|
64
|
+
def test_discover_locations(authenticated_client, mock_location_response, mocker):
|
|
65
|
+
# Mock the location discovery request
|
|
66
|
+
mock_request = mocker.patch.object(authenticated_client._credentials, 'make_request')
|
|
67
|
+
mock_request.return_value = mock_location_response
|
|
68
|
+
|
|
69
|
+
locations = authenticated_client.discover_locations()
|
|
70
|
+
assert len(locations) == 1
|
|
71
|
+
location = next(iter(locations.values()))
|
|
72
|
+
assert location.name == "Test Location"
|
|
73
|
+
|
|
74
|
+
def test_discover_locations_unauthenticated(client):
|
|
75
|
+
with pytest.raises(AuthenticationError):
|
|
76
|
+
client.discover_locations()
|