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.
- systemair_api/__init__.py +10 -0
- systemair_api/__main__.py +118 -0
- systemair_api/__version__.py +3 -0
- systemair_api/api/__init__.py +4 -0
- systemair_api/api/systemair_api.py +281 -0
- systemair_api/api/websocket_client.py +100 -0
- systemair_api/auth/__init__.py +3 -0
- systemair_api/auth/authenticator.py +306 -0
- systemair_api/models/__init__.py +4 -0
- systemair_api/models/ventilation_data.py +65 -0
- systemair_api/models/ventilation_unit.py +412 -0
- systemair_api/utils/__init__.py +4 -0
- systemair_api/utils/constants.py +28 -0
- systemair_api/utils/exceptions.py +135 -0
- systemair_api/utils/register_constants.py +324 -0
- systemair_api_promises-0.1.0.dist-info/METADATA +458 -0
- systemair_api_promises-0.1.0.dist-info/RECORD +21 -0
- systemair_api_promises-0.1.0.dist-info/WHEEL +5 -0
- systemair_api_promises-0.1.0.dist-info/entry_points.txt +2 -0
- systemair_api_promises-0.1.0.dist-info/licenses/LICENSE +21 -0
- systemair_api_promises-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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,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()
|