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 +102 -0
- nwp500/api_client.py +373 -0
- nwp500/auth.py +502 -0
- nwp500/config.py +9 -0
- nwp500/constants.py +12 -0
- nwp500/events.py +361 -0
- nwp500/models.py +650 -0
- nwp500/mqtt_client.py +1654 -0
- nwp500/skeleton.py +152 -0
- nwp500_python-1.0.0.dist-info/METADATA +249 -0
- nwp500_python-1.0.0.dist-info/RECORD +14 -0
- nwp500_python-1.0.0.dist-info/WHEEL +5 -0
- nwp500_python-1.0.0.dist-info/licenses/LICENSE.txt +21 -0
- nwp500_python-1.0.0.dist-info/top_level.txt +1 -0
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
|