havenlighting 0.1.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.
@@ -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,13 @@
1
+ havenlighting/__init__.py,sha256=A6p2d4D0dFy9TplO4MZIp_9buu9fuAFitTh95nPGkeQ,330
2
+ havenlighting/client.py,sha256=OJsu8EXZ3Gpu_DnXRdagMI1ZI65jLYChmOSjqloENLE,2491
3
+ havenlighting/config.py,sha256=DT2lGusoIzystkepjyvq_SJ5huzeIW7j_gCzkORzR3Y,606
4
+ havenlighting/credentials.py,sha256=OJkIrJUxUZiihV7YVF9K0th2I4BeQGvwh0xKQHZnutw,5178
5
+ havenlighting/exceptions.py,sha256=9lPoeFusbq8Fi9xVrT9Vqgel3AzhWxeaL2BZFUgGMbA,594
6
+ havenlighting/logging.py,sha256=AKeGd_snrL7ogL3zGGojqmjfho9Ql53IePxQgPhaF60,864
7
+ havenlighting/models.py,sha256=jTcN3L1GWrYsfLTRRLfCge02gG2JXGZLh3GASrwA7jQ,405
8
+ havenlighting/devices/light.py,sha256=2cbY8Z8ri9mAqpbFwKtFJx2Vx44on4VgnqqboUybINk,2696
9
+ havenlighting/devices/location.py,sha256=D9GHp_1yD_Y41z8VbuzK1tCrRJdWZEPvTGtRFwLzLLY,3567
10
+ havenlighting-0.1.0.dist-info/METADATA,sha256=8s73K7TYb2C6W2Xedbh9BtAHJTofGc8ZeFo-dFojFag,1489
11
+ havenlighting-0.1.0.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
12
+ havenlighting-0.1.0.dist-info/top_level.txt,sha256=NBaRZI7uZAszFW1P0jaBchYQviyjoCyqgBvB_VLl86Y,14
13
+ havenlighting-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (75.6.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ havenlighting