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.
@@ -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,5 @@
1
+ requests>=2.25.0
2
+
3
+ [dev]
4
+ pytest>=6.0
5
+ pytest-mock>=3.0
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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()