systemair-api-promises 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,10 @@
1
+ """Systemair API - Python library for communicating with and controlling Systemair ventilation units."""
2
+
3
+ from systemair_api.__version__ import __version__
4
+
5
+ from systemair_api.models.ventilation_unit import VentilationUnit
6
+ from systemair_api.api.systemair_api import SystemairAPI
7
+ from systemair_api.auth.authenticator import SystemairAuthenticator
8
+ from systemair_api.api.websocket_client import SystemairWebSocket
9
+ from systemair_api.utils.constants import UserModes
10
+ from systemair_api.utils.register_constants import RegisterConstants
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env python
2
+ """Main entry point for the systemair_api package."""
3
+
4
+ import os
5
+ import time
6
+ import threading
7
+ from dotenv import load_dotenv
8
+
9
+ from systemair_api.auth.authenticator import SystemairAuthenticator
10
+ from systemair_api.api.systemair_api import SystemairAPI
11
+ from systemair_api.api.websocket_client import SystemairWebSocket
12
+ from systemair_api.models.ventilation_unit import VentilationUnit
13
+ from systemair_api.utils.constants import UserModes
14
+
15
+ def on_message(message: dict) -> None:
16
+ """Handle incoming WebSocket messages.
17
+
18
+ Args:
19
+ message: The message received from the WebSocket
20
+ """
21
+ print("Received WebSocket message:", message)
22
+
23
+ def main() -> None:
24
+ """Run the example application demonstrating the SystemAIR API."""
25
+ # Load environment variables from .env file if it exists
26
+ load_dotenv()
27
+
28
+ # Get credentials from environment variables
29
+ email = os.getenv("EMAIL")
30
+ password = os.getenv("PASSWORD")
31
+
32
+ if not email or not password:
33
+ print("Please set EMAIL and PASSWORD environment variables")
34
+ print("You can create a .env file with these variables")
35
+ return
36
+
37
+ # Authenticate
38
+ authenticator = SystemairAuthenticator(email, password)
39
+ access_token = authenticator.authenticate()
40
+
41
+ if not access_token:
42
+ print("Authentication failed")
43
+ return
44
+
45
+ print("Authentication successful, access token obtained")
46
+
47
+ # Create API client
48
+ api = SystemairAPI(access_token)
49
+
50
+ # Get account devices
51
+ devices_response = api.get_account_devices()
52
+ if not devices_response or "data" not in devices_response:
53
+ print("Failed to get devices")
54
+ return
55
+
56
+ devices = devices_response["data"]["GetAccountDevices"]
57
+ if not devices:
58
+ print("No devices found")
59
+ return
60
+
61
+ print(f"Found {len(devices)} devices:")
62
+ ventilation_units = []
63
+
64
+ for device in devices:
65
+ print(f" - {device['name']} (ID: {device['identifier']})")
66
+ ventilation_unit = VentilationUnit(device['identifier'], device['name'])
67
+ ventilation_units.append(ventilation_unit)
68
+
69
+ # Set up WebSocket client
70
+ ws_client = SystemairWebSocket(access_token, on_message)
71
+ ws_client.connect()
72
+
73
+ try:
74
+ # Get status for the first device
75
+ if ventilation_units:
76
+ unit = ventilation_units[0]
77
+
78
+ # Get device status
79
+ status_response = api.fetch_device_status(unit.identifier)
80
+ if status_response:
81
+ unit.update_from_api(status_response)
82
+ print(f"\nCurrent Status for {unit.name}:")
83
+ unit.print_status()
84
+
85
+ # Broadcast to get updates via WebSocket
86
+ api.broadcast_device_statuses([unit.identifier])
87
+
88
+ # Example: Change user mode to Away
89
+ print("\nChanging user mode to Away...")
90
+ unit.set_user_mode(api, UserModes.AWAY)
91
+
92
+ # Wait for a moment to receive WebSocket updates
93
+ time.sleep(5)
94
+
95
+ # Change user mode back to Auto
96
+ print("\nChanging user mode back to Auto...")
97
+ unit.set_user_mode(api, UserModes.AUTO)
98
+
99
+ # Get updated status
100
+ status_response = api.fetch_device_status(unit.identifier)
101
+ if status_response:
102
+ unit.update_from_api(status_response)
103
+ print("\nUpdated Status:")
104
+ unit.print_status()
105
+
106
+ # Keep the main thread alive for WebSocket messages
107
+ print("\nListening for WebSocket messages. Press Ctrl+C to exit.")
108
+ while True:
109
+ time.sleep(1)
110
+
111
+ except KeyboardInterrupt:
112
+ print("\nExiting...")
113
+ finally:
114
+ # Disconnect WebSocket
115
+ ws_client.disconnect()
116
+
117
+ if __name__ == "__main__":
118
+ main()
@@ -0,0 +1,3 @@
1
+ """Version information."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ """API communication modules for Systemair ventilation units."""
2
+
3
+ from systemair_api.api.systemair_api import SystemairAPI
4
+ from systemair_api.api.websocket_client import SystemairWebSocket
@@ -0,0 +1,281 @@
1
+ """SystemairAPI - Core API communication module for Systemair ventilation units."""
2
+
3
+ from typing import Dict, List, Optional, Any, Union, cast
4
+ import requests
5
+ from systemair_api.utils.constants import APIEndpoints
6
+ from systemair_api.utils.exceptions import APIError, DeviceNotFoundError, RateLimitError
7
+
8
+ class SystemairAPI:
9
+ """Core API interface for communicating with Systemair Home Solutions API.
10
+
11
+ Provides methods for discovering devices, fetching device status,
12
+ and sending control commands to ventilation units.
13
+ """
14
+
15
+ def __init__(self, access_token: str) -> None:
16
+ """Initialize the SystemairAPI with an access token.
17
+
18
+ Args:
19
+ access_token: A valid JWT access token from authentication
20
+ """
21
+ self.access_token: str = access_token
22
+ self.headers: Dict[str, str] = {
23
+ 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:128.0) Gecko/20100101 Firefox/128.0',
24
+ 'Accept': '*/*',
25
+ 'Accept-Language': 'en-US,en;q=0.5',
26
+ 'Accept-Encoding': 'gzip, deflate, br, zstd',
27
+ 'Referer': 'https://homesolutions.systemair.com/device/home',
28
+ 'content-type': 'application/json',
29
+ 'x-access-token': self.access_token,
30
+ 'Origin': 'https://homesolutions.systemair.com',
31
+ 'Connection': 'keep-alive',
32
+ 'Sec-Fetch-Dest': 'empty',
33
+ 'Sec-Fetch-Mode': 'cors',
34
+ 'Sec-Fetch-Site': 'same-origin',
35
+ }
36
+
37
+ def update_token(self, access_token: str) -> None:
38
+ """Update the access token used for API requests.
39
+
40
+ This method should be called when the token is refreshed.
41
+
42
+ Args:
43
+ access_token: The new access token
44
+ """
45
+ self.access_token = access_token
46
+ self.headers['x-access-token'] = access_token
47
+
48
+ def broadcast_device_statuses(self, device_ids: List[str]) -> Dict[str, Any]:
49
+ """Broadcast requests for device statuses to trigger WebSocket updates.
50
+
51
+ Args:
52
+ device_ids: List of device identifiers to request updates for
53
+
54
+ Returns:
55
+ dict: API response
56
+
57
+ Raises:
58
+ APIError: If the API request fails
59
+ DeviceNotFoundError: If one or more devices are not found
60
+ RateLimitError: If rate limit is exceeded
61
+ """
62
+ data = {
63
+ "variables": {"deviceIds": device_ids},
64
+ "query": """
65
+ query ($deviceIds: [String]!) {
66
+ BroadcastDeviceStatuses(deviceIds: $deviceIds)
67
+ }
68
+ """
69
+ }
70
+
71
+ try:
72
+ response = requests.post(APIEndpoints.GATEWAY, headers=self.headers, json=data)
73
+
74
+ if response.status_code == 429:
75
+ retry_after = response.headers.get('Retry-After')
76
+ retry_seconds = int(retry_after) if retry_after and retry_after.isdigit() else None
77
+ raise RateLimitError(retry_after=retry_seconds)
78
+
79
+ response.raise_for_status()
80
+ result = cast(Dict[str, Any], response.json())
81
+
82
+ # Check for error in response data
83
+ if 'errors' in result:
84
+ errors = result['errors']
85
+ for error in errors:
86
+ if 'message' in error and any(device_id in error.get('message', '') for device_id in device_ids):
87
+ raise DeviceNotFoundError(device_ids[0], error.get('message'))
88
+ raise APIError(message=errors[0].get('message', 'Unknown API error'),
89
+ response_data=result)
90
+
91
+ return result
92
+ except requests.exceptions.RequestException as e:
93
+ raise APIError(f"Failed to broadcast device statuses: {str(e)}",
94
+ getattr(e, 'response', None) and getattr(e.response, 'status_code', None))
95
+
96
+ def fetch_device_status(self, device_id: str) -> Dict[str, Any]:
97
+ """Fetch detailed status for a specific device.
98
+
99
+ Args:
100
+ device_id: The unique identifier of the device
101
+
102
+ Returns:
103
+ dict: API response with detailed device status
104
+
105
+ Raises:
106
+ APIError: If the API request fails
107
+ DeviceNotFoundError: If the device is not found
108
+ RateLimitError: If rate limit is exceeded
109
+ """
110
+ headers = self.headers.copy()
111
+ headers['device-id'] = device_id
112
+ headers['device-type'] = 'LEGACY'
113
+
114
+ data = {
115
+ "variables": {"input": {"route": "/home", "viewId": ""}},
116
+ "query": """
117
+ query ($input: GetViewInputType!) {
118
+ GetView(input: $input) {
119
+ children {
120
+ type
121
+ properties
122
+ }
123
+ }
124
+ }
125
+ """
126
+ }
127
+
128
+ try:
129
+ response = requests.post(APIEndpoints.REMOTE, headers=headers, json=data)
130
+
131
+ if response.status_code == 404:
132
+ raise DeviceNotFoundError(device_id)
133
+
134
+ if response.status_code == 429:
135
+ retry_after = response.headers.get('Retry-After')
136
+ retry_seconds = int(retry_after) if retry_after and retry_after.isdigit() else None
137
+ raise RateLimitError(retry_after=retry_seconds)
138
+
139
+ response.raise_for_status()
140
+ result = cast(Dict[str, Any], response.json())
141
+
142
+ # Check for error in response data
143
+ if 'errors' in result:
144
+ errors = result['errors']
145
+ for error in errors:
146
+ if 'message' in error and device_id in error.get('message', ''):
147
+ raise DeviceNotFoundError(device_id, error.get('message'))
148
+ raise APIError(message=errors[0].get('message', 'Unknown API error'),
149
+ response_data=result)
150
+
151
+ return result
152
+ except requests.exceptions.RequestException as e:
153
+ if getattr(e, 'response', None) and getattr(e.response, 'status_code', None) == 404:
154
+ raise DeviceNotFoundError(device_id, str(e))
155
+ raise APIError(f"Failed to fetch device status: {str(e)}",
156
+ getattr(e, 'response', None) and getattr(e.response, 'status_code', None))
157
+
158
+ def get_account_devices(self) -> Dict[str, Any]:
159
+ """Get all devices associated with the current account.
160
+
161
+ Returns:
162
+ dict: API response with devices list
163
+
164
+ Raises:
165
+ APIError: If the API request fails
166
+ RateLimitError: If rate limit is exceeded
167
+ """
168
+ data = {
169
+ "operationName": "GetLoggedInAccount",
170
+ "variables": {},
171
+ "query": """
172
+ query GetLoggedInAccount {
173
+ GetAccountDevices {
174
+ identifier
175
+ name
176
+ street
177
+ zipcode
178
+ city
179
+ country
180
+ deviceType {
181
+ entry
182
+ module
183
+ scope
184
+ type
185
+ }
186
+ }
187
+ }
188
+ """
189
+ }
190
+
191
+ try:
192
+ response = requests.post(APIEndpoints.GATEWAY, headers=self.headers, json=data)
193
+
194
+ if response.status_code == 429:
195
+ retry_after = response.headers.get('Retry-After')
196
+ retry_seconds = int(retry_after) if retry_after and retry_after.isdigit() else None
197
+ raise RateLimitError(retry_after=retry_seconds)
198
+
199
+ response.raise_for_status()
200
+ result = cast(Dict[str, Any], response.json())
201
+
202
+ # Check for error in response data
203
+ if 'errors' in result:
204
+ errors = result['errors']
205
+ raise APIError(message=errors[0].get('message', 'Unknown API error'),
206
+ response_data=result,
207
+ status_code=response.status_code)
208
+
209
+ return result
210
+ except requests.exceptions.RequestException as e:
211
+ raise APIError(f"Failed to fetch account devices: {str(e)}",
212
+ getattr(e, 'response', None) and getattr(e.response, 'status_code', None))
213
+
214
+
215
+ def write_data_item(self, device_id: str, register_id: int, value: Union[int, float, str]) -> Dict[str, Any]:
216
+ """Write a value to a specific register on a device.
217
+
218
+ Args:
219
+ device_id: The unique identifier of the device
220
+ register_id: The register ID to write to
221
+ value: The value to write
222
+
223
+ Returns:
224
+ dict: API response indicating success
225
+
226
+ Raises:
227
+ APIError: If the API request fails
228
+ DeviceNotFoundError: If the device is not found
229
+ RateLimitError: If rate limit is exceeded
230
+ ValidationError: If the provided value is invalid
231
+ """
232
+ headers = self.headers.copy()
233
+ headers['device-id'] = device_id
234
+ headers['device-type'] = 'LEGACY'
235
+
236
+ data = {
237
+ "variables": {
238
+ "input": {
239
+ "dataPoints": [{"id": register_id, "value": str(value)}]
240
+ }
241
+ },
242
+ "query": """
243
+ mutation ($input: WriteDataItemsInput!) {
244
+ WriteDataItems(input: $input)
245
+ }
246
+ """
247
+ }
248
+
249
+ try:
250
+ response = requests.post(APIEndpoints.REMOTE, headers=headers, json=data)
251
+
252
+ if response.status_code == 404:
253
+ raise DeviceNotFoundError(device_id)
254
+
255
+ if response.status_code == 429:
256
+ retry_after = response.headers.get('Retry-After')
257
+ retry_seconds = int(retry_after) if retry_after and retry_after.isdigit() else None
258
+ raise RateLimitError(retry_after=retry_seconds)
259
+
260
+ response.raise_for_status()
261
+ result = cast(Dict[str, Any], response.json())
262
+
263
+ # Check for error in response data
264
+ if 'errors' in result:
265
+ errors = result['errors']
266
+ for error in errors:
267
+ msg = error.get('message', '')
268
+ if 'device' in msg.lower() and 'not found' in msg.lower():
269
+ raise DeviceNotFoundError(device_id, msg)
270
+ if 'invalid' in msg.lower() or 'validation' in msg.lower():
271
+ from systemair_api.utils.exceptions import ValidationError
272
+ raise ValidationError(message=msg, field=f"register_{register_id}", value=value)
273
+ raise APIError(message=errors[0].get('message', 'Unknown API error'),
274
+ response_data=result)
275
+
276
+ return result
277
+ except requests.exceptions.RequestException as e:
278
+ if getattr(e, 'response', None) and getattr(e.response, 'status_code', None) == 404:
279
+ raise DeviceNotFoundError(device_id, str(e))
280
+ raise APIError(f"Failed to write data to device: {str(e)}",
281
+ getattr(e, 'response', None) and getattr(e.response, 'status_code', None))
@@ -0,0 +1,100 @@
1
+ """SystemairWebSocket - WebSocket client for real-time updates from Systemair ventilation units."""
2
+
3
+ import websocket
4
+ import json
5
+ import threading
6
+ import ssl
7
+ from typing import Any, Callable, Dict, Optional, Union, cast
8
+ from websocket import WebSocket, WebSocketApp
9
+
10
+ class SystemairWebSocket:
11
+ """WebSocket client for real-time updates from Systemair Home Solutions API.
12
+
13
+ Establishes a persistent connection to the Systemair WebSocket server
14
+ and processes incoming messages through a callback function.
15
+ """
16
+
17
+ def __init__(self, access_token: str, on_message_callback: Callable[[Dict[str, Any]], None]) -> None:
18
+ """Initialize the WebSocket client.
19
+
20
+ Args:
21
+ access_token: A valid JWT access token from authentication
22
+ on_message_callback: Callback function that will be called with message data
23
+ """
24
+ self.access_token: str = access_token
25
+ self.on_message_callback: Callable[[Dict[str, Any]], None] = on_message_callback
26
+ self.ws: Optional[WebSocketApp] = None
27
+ self.thread: Optional[threading.Thread] = None
28
+
29
+ def on_message(self, ws: WebSocket, message: Any) -> None:
30
+ """Handle incoming WebSocket messages.
31
+
32
+ Args:
33
+ ws: WebSocket connection
34
+ message: Raw message data
35
+ """
36
+ data = json.loads(message)
37
+ self.on_message_callback(data)
38
+
39
+ def on_error(self, ws: WebSocket, error: Any) -> None:
40
+ """Handle WebSocket errors.
41
+
42
+ Args:
43
+ ws: WebSocket connection
44
+ error: Error information
45
+ """
46
+ # Log to stdout for diagnostic purposes, but keep it minimal
47
+ print(f"WebSocket error: {error}")
48
+
49
+ def on_close(self, ws: WebSocket, close_status_code: Any, close_msg: Any) -> None:
50
+ """Handle WebSocket connection closure.
51
+
52
+ Args:
53
+ ws: WebSocket connection
54
+ close_status_code: Status code for the closure
55
+ close_msg: Closure message
56
+ """
57
+ # Important status messages are kept to help diagnose issues
58
+ if close_status_code:
59
+ print(f"WebSocket connection closed with code: {close_status_code}")
60
+ else:
61
+ print("WebSocket connection closed")
62
+
63
+ def on_open(self, ws: WebSocket) -> None:
64
+ """Handle WebSocket connection opening.
65
+
66
+ Args:
67
+ ws: WebSocket connection
68
+ """
69
+ # Connection established notification is useful for debugging
70
+ print("WebSocket connection opened")
71
+
72
+ def connect(self) -> None:
73
+ """Establish a WebSocket connection in a separate thread.
74
+
75
+ The connection is opened in a daemon thread to allow the main program
76
+ to continue execution.
77
+ """
78
+ # Disable WebSocket trace output to keep logs cleaner
79
+ websocket.enableTrace(False)
80
+ self.ws = websocket.WebSocketApp(
81
+ f"wss://homesolutions.systemair.com/streaming/",
82
+ header=[
83
+ f"Sec-WebSocket-Protocol: accessToken, {self.access_token}",
84
+ "Origin: https://homesolutions.systemair.com",
85
+ ],
86
+ on_open=self.on_open,
87
+ on_message=self.on_message,
88
+ on_error=self.on_error,
89
+ on_close=self.on_close,
90
+ )
91
+ self.thread = threading.Thread(target=self.ws.run_forever, kwargs={"sslopt": {"cert_reqs": ssl.CERT_NONE}})
92
+ self.thread.daemon = True
93
+ self.thread.start()
94
+
95
+ def disconnect(self) -> None:
96
+ """Close the WebSocket connection and clean up resources."""
97
+ if self.ws:
98
+ self.ws.close()
99
+ if self.thread:
100
+ self.thread.join()
@@ -0,0 +1,3 @@
1
+ """Authentication modules for Systemair Home Solutions cloud."""
2
+
3
+ from systemair_api.auth.authenticator import SystemairAuthenticator