nwp500-python 1.0.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.
nwp500/__init__.py ADDED
@@ -0,0 +1,102 @@
1
+ from importlib.metadata import (
2
+ PackageNotFoundError,
3
+ version,
4
+ ) # pragma: no cover
5
+
6
+ try:
7
+ # Change here if project is renamed and does not equal the package name
8
+ dist_name = "nwp500-python"
9
+ __version__ = version(dist_name)
10
+ except PackageNotFoundError: # pragma: no cover
11
+ __version__ = "unknown"
12
+ finally:
13
+ del version, PackageNotFoundError
14
+
15
+ # Export main components
16
+ from nwp500.api_client import (
17
+ APIError,
18
+ NavienAPIClient,
19
+ )
20
+ from nwp500.auth import (
21
+ AuthenticationError,
22
+ AuthenticationResponse,
23
+ AuthTokens,
24
+ InvalidCredentialsError,
25
+ NavienAuthClient,
26
+ TokenExpiredError,
27
+ TokenRefreshError,
28
+ UserInfo,
29
+ authenticate,
30
+ refresh_access_token,
31
+ )
32
+ from nwp500.events import (
33
+ EventEmitter,
34
+ EventListener,
35
+ )
36
+ from nwp500.models import (
37
+ Device,
38
+ DeviceFeature,
39
+ DeviceInfo,
40
+ DeviceStatus,
41
+ EnergyUsageData,
42
+ EnergyUsageResponse,
43
+ EnergyUsageTotal,
44
+ FirmwareInfo,
45
+ Location,
46
+ MonthlyEnergyData,
47
+ MqttCommand,
48
+ MqttRequest,
49
+ OperationMode,
50
+ TemperatureUnit,
51
+ TOUInfo,
52
+ TOUSchedule,
53
+ )
54
+ from nwp500.mqtt_client import (
55
+ MqttConnectionConfig,
56
+ NavienMqttClient,
57
+ PeriodicRequestType,
58
+ )
59
+
60
+ __all__ = [
61
+ "__version__",
62
+ # Models
63
+ "DeviceStatus",
64
+ "DeviceFeature",
65
+ "DeviceInfo",
66
+ "Location",
67
+ "Device",
68
+ "FirmwareInfo",
69
+ "TOUSchedule",
70
+ "TOUInfo",
71
+ "OperationMode",
72
+ "TemperatureUnit",
73
+ "MqttRequest",
74
+ "MqttCommand",
75
+ "EnergyUsageData",
76
+ "MonthlyEnergyData",
77
+ "EnergyUsageTotal",
78
+ "EnergyUsageResponse",
79
+ # Authentication
80
+ "NavienAuthClient",
81
+ "AuthenticationResponse",
82
+ "AuthTokens",
83
+ "UserInfo",
84
+ "AuthenticationError",
85
+ "InvalidCredentialsError",
86
+ "TokenExpiredError",
87
+ "TokenRefreshError",
88
+ "authenticate",
89
+ "refresh_access_token",
90
+ # Constants
91
+ "constants",
92
+ # API Client
93
+ "NavienAPIClient",
94
+ "APIError",
95
+ # MQTT Client
96
+ "NavienMqttClient",
97
+ "MqttConnectionConfig",
98
+ "PeriodicRequestType",
99
+ # Event Emitter
100
+ "EventEmitter",
101
+ "EventListener",
102
+ ]
nwp500/api_client.py ADDED
@@ -0,0 +1,373 @@
1
+ """
2
+ API Client for Navien Smart Control REST API.
3
+
4
+ This module provides a high-level client for interacting with the Navien Smart Control
5
+ API, implementing all endpoints from the OpenAPI specification.
6
+ """
7
+
8
+ import logging
9
+ from typing import Any, Optional
10
+
11
+ import aiohttp
12
+
13
+ from .auth import AuthenticationError, NavienAuthClient
14
+ from .config import API_BASE_URL
15
+ from .models import (
16
+ Device,
17
+ FirmwareInfo,
18
+ TOUInfo,
19
+ )
20
+
21
+ __author__ = "Emmanuel Levijarvi"
22
+ __copyright__ = "Emmanuel Levijarvi"
23
+ __license__ = "MIT"
24
+
25
+ _logger = logging.getLogger(__name__)
26
+
27
+
28
+ class APIError(Exception):
29
+ """Raised when API returns an error response."""
30
+
31
+ def __init__(
32
+ self,
33
+ message: str,
34
+ code: Optional[int] = None,
35
+ response: Optional[dict] = None,
36
+ ):
37
+ self.message = message
38
+ self.code = code
39
+ self.response = response
40
+ super().__init__(self.message)
41
+
42
+
43
+ class NavienAPIClient:
44
+ """
45
+ High-level client for Navien Smart Control REST API.
46
+
47
+ This client implements all endpoints from the OpenAPI specification and
48
+ automatically handles authentication, token refresh, and error handling.
49
+
50
+ The client requires an authenticated NavienAuthClient to be provided.
51
+
52
+ Example:
53
+ >>> async with NavienAuthClient() as auth_client:
54
+ ... await auth_client.sign_in("user@example.com", "password")
55
+ ... api_client = NavienAPIClient(auth_client=auth_client)
56
+ ... devices = await api_client.list_devices()
57
+ """
58
+
59
+ def __init__(
60
+ self,
61
+ auth_client: NavienAuthClient,
62
+ base_url: str = API_BASE_URL,
63
+ session: Optional[aiohttp.ClientSession] = None,
64
+ ):
65
+ """
66
+ Initialize Navien API client.
67
+
68
+ Args:
69
+ auth_client: Authenticated NavienAuthClient instance. Must already be
70
+ authenticated via sign_in().
71
+ base_url: Base URL for the API
72
+ session: Optional aiohttp session (uses auth_client's session if not provided)
73
+
74
+ Raises:
75
+ ValueError: If auth_client is not authenticated
76
+ """
77
+ if not auth_client.is_authenticated:
78
+ raise ValueError(
79
+ "auth_client must be authenticated before creating API client. "
80
+ "Call auth_client.sign_in() first."
81
+ )
82
+
83
+ self.base_url = base_url.rstrip("/")
84
+ self._auth_client = auth_client
85
+ self._session = session or auth_client._session
86
+ self._owned_session = False # Never own session when auth_client is provided
87
+ self._owned_auth = False # Never own auth_client
88
+
89
+ async def __aenter__(self):
90
+ """Async context manager entry - not required but supported for convenience."""
91
+ return self
92
+
93
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
94
+ """Async context manager exit - cleanup is handled by auth_client."""
95
+ pass
96
+
97
+ async def _make_request(
98
+ self,
99
+ method: str,
100
+ endpoint: str,
101
+ json_data: Optional[dict] = None,
102
+ params: Optional[dict] = None,
103
+ ) -> dict[str, Any]:
104
+ """
105
+ Make an authenticated API request.
106
+
107
+ Args:
108
+ method: HTTP method (GET, POST, etc.)
109
+ endpoint: API endpoint path
110
+ json_data: JSON body data
111
+ params: Query parameters
112
+
113
+ Returns:
114
+ Response data dictionary
115
+
116
+ Raises:
117
+ APIError: If API returns an error
118
+ AuthenticationError: If not authenticated
119
+ """
120
+ if not self._auth_client or not self._auth_client.is_authenticated:
121
+ raise AuthenticationError("Must authenticate before making API calls")
122
+
123
+ # Ensure token is valid
124
+ await self._auth_client.ensure_valid_token()
125
+
126
+ # Get authentication headers
127
+ headers = self._auth_client.get_auth_headers()
128
+
129
+ # Make request
130
+ url = f"{self.base_url}{endpoint}"
131
+
132
+ _logger.debug(f"{method} {url}")
133
+
134
+ try:
135
+ async with self._session.request(
136
+ method, url, headers=headers, json=json_data, params=params
137
+ ) as response:
138
+ response_data = await response.json()
139
+
140
+ # Check for API errors
141
+ code = response_data.get("code", response.status)
142
+ msg = response_data.get("msg", "")
143
+
144
+ if code != 200 or not response.ok:
145
+ _logger.error(f"API error: {code} - {msg}")
146
+ raise APIError(
147
+ f"API request failed: {msg}",
148
+ code=code,
149
+ response=response_data,
150
+ )
151
+
152
+ return response_data
153
+
154
+ except aiohttp.ClientError as e:
155
+ _logger.error(f"Network error: {e}")
156
+ raise APIError(f"Network error: {str(e)}")
157
+
158
+ # Device Management Endpoints
159
+
160
+ async def list_devices(self, offset: int = 0, count: int = 20) -> list[Device]:
161
+ """
162
+ List all devices associated with the user.
163
+
164
+ Args:
165
+ offset: Pagination offset (default: 0)
166
+ count: Number of devices to return (default: 20)
167
+
168
+ Returns:
169
+ List of Device objects
170
+
171
+ Raises:
172
+ APIError: If API request fails
173
+ AuthenticationError: If not authenticated
174
+ """
175
+ if not self._auth_client.user_email:
176
+ raise AuthenticationError("Must authenticate first")
177
+
178
+ response = await self._make_request(
179
+ "POST",
180
+ "/device/list",
181
+ json_data={
182
+ "offset": offset,
183
+ "count": count,
184
+ "userId": self._auth_client.user_email,
185
+ },
186
+ )
187
+
188
+ devices_data = response.get("data", [])
189
+ devices = [Device.from_dict(d) for d in devices_data]
190
+
191
+ _logger.info(f"Retrieved {len(devices)} device(s)")
192
+ return devices
193
+
194
+ async def get_device_info(self, mac_address: str, additional_value: str = "") -> Device:
195
+ """
196
+ Get detailed information about a specific device.
197
+
198
+ Args:
199
+ mac_address: Device MAC address
200
+ additional_value: Additional device identifier (optional)
201
+
202
+ Returns:
203
+ Device object with detailed information
204
+
205
+ Raises:
206
+ APIError: If API request fails
207
+ AuthenticationError: If not authenticated
208
+ """
209
+ if not self._auth_client.user_email:
210
+ raise AuthenticationError("Must authenticate first")
211
+
212
+ response = await self._make_request(
213
+ "POST",
214
+ "/device/info",
215
+ json_data={
216
+ "macAddress": mac_address,
217
+ "additionalValue": additional_value,
218
+ "userId": self._auth_client.user_email,
219
+ },
220
+ )
221
+
222
+ data = response.get("data", {})
223
+ device = Device.from_dict(data)
224
+
225
+ _logger.info(f"Retrieved info for device: {device.device_info.device_name}")
226
+ return device
227
+
228
+ async def get_firmware_info(
229
+ self, mac_address: str, additional_value: str = ""
230
+ ) -> list[FirmwareInfo]:
231
+ """
232
+ Get firmware information for a specific device.
233
+
234
+ Args:
235
+ mac_address: Device MAC address
236
+ additional_value: Additional device identifier (optional)
237
+
238
+ Returns:
239
+ List of FirmwareInfo objects
240
+
241
+ Raises:
242
+ APIError: If API request fails
243
+ AuthenticationError: If not authenticated
244
+ """
245
+ if not self._auth_client.user_email:
246
+ raise AuthenticationError("Must authenticate first")
247
+
248
+ response = await self._make_request(
249
+ "POST",
250
+ "/device/firmware/info",
251
+ json_data={
252
+ "macAddress": mac_address,
253
+ "additionalValue": additional_value,
254
+ "userId": self._auth_client.user_email,
255
+ },
256
+ )
257
+
258
+ data = response.get("data", {})
259
+ firmwares_data = data.get("firmwares", [])
260
+ firmwares = [FirmwareInfo.from_dict(f) for f in firmwares_data]
261
+
262
+ _logger.info(f"Retrieved firmware info: {len(firmwares)} firmware(s)")
263
+ return firmwares
264
+
265
+ async def get_tou_info(
266
+ self,
267
+ mac_address: str,
268
+ additional_value: str,
269
+ controller_id: str,
270
+ user_type: str = "O",
271
+ ) -> TOUInfo:
272
+ """
273
+ Get Time of Use (TOU) information for a device.
274
+
275
+ Args:
276
+ mac_address: Device MAC address
277
+ additional_value: Additional device identifier
278
+ controller_id: Controller ID
279
+ user_type: User type (default: "O")
280
+
281
+ Returns:
282
+ TOUInfo object
283
+
284
+ Raises:
285
+ APIError: If API request fails
286
+ AuthenticationError: If not authenticated
287
+ """
288
+ if not self._auth_client.user_email:
289
+ raise AuthenticationError("Must authenticate first")
290
+
291
+ response = await self._make_request(
292
+ "GET",
293
+ "/device/tou",
294
+ params={
295
+ "additionalValue": additional_value,
296
+ "controllerId": controller_id,
297
+ "macAddress": mac_address,
298
+ "userId": self._auth_client.user_email,
299
+ "userType": user_type,
300
+ },
301
+ )
302
+
303
+ data = response.get("data", {})
304
+ tou_info = TOUInfo.from_dict(data)
305
+
306
+ _logger.info(f"Retrieved TOU info for {mac_address}")
307
+ return tou_info
308
+
309
+ async def update_push_token(
310
+ self,
311
+ push_token: str,
312
+ model_name: str = "Python Client",
313
+ app_version: str = "1.0.0",
314
+ os: str = "Python",
315
+ os_version: str = "3.8+",
316
+ ) -> bool:
317
+ """
318
+ Update push notification token.
319
+
320
+ Args:
321
+ push_token: Push notification token
322
+ model_name: Device model name (default: "Python Client")
323
+ app_version: Application version (default: "1.0.0")
324
+ os: Operating system (default: "Python")
325
+ os_version: OS version (default: "3.8+")
326
+
327
+ Returns:
328
+ True if successful
329
+
330
+ Raises:
331
+ APIError: If API request fails
332
+ AuthenticationError: If not authenticated
333
+ """
334
+ if not self._auth_client.user_email:
335
+ raise AuthenticationError("Must authenticate first")
336
+
337
+ await self._make_request(
338
+ "POST",
339
+ "/app/update-push-token",
340
+ json_data={
341
+ "modelName": model_name,
342
+ "appVersion": app_version,
343
+ "os": os,
344
+ "osVersion": os_version,
345
+ "userId": self._auth_client.user_email,
346
+ "pushToken": push_token,
347
+ },
348
+ )
349
+
350
+ _logger.info("Push token updated successfully")
351
+ return True
352
+
353
+ # Convenience methods
354
+
355
+ async def get_first_device(self) -> Optional[Device]:
356
+ """
357
+ Get the first device associated with the user.
358
+
359
+ Returns:
360
+ First Device object or None if no devices
361
+ """
362
+ devices = await self.list_devices(count=1)
363
+ return devices[0] if devices else None
364
+
365
+ @property
366
+ def is_authenticated(self) -> bool:
367
+ """Check if client is authenticated."""
368
+ return self._auth_client.is_authenticated
369
+
370
+ @property
371
+ def user_email(self) -> Optional[str]:
372
+ """Get current user email."""
373
+ return self._auth_client.user_email