emerald-hws 0.0.10__py3-none-any.whl → 0.0.12__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.
emerald_hws/__init__.py CHANGED
@@ -1 +1,3 @@
1
+ from .emeraldhws import EmeraldHWS
1
2
 
3
+ __all__ = ["EmeraldHWS"]
emerald_hws/emeraldhws.py CHANGED
@@ -1,12 +1,14 @@
1
1
  import json
2
- import requests
3
- import os
4
2
  import logging
5
- import boto3
3
+ import os
6
4
  import random
7
5
  import threading
8
- from awsiot import mqtt5_client_builder, mqtt_connection_builder
9
- from awscrt import mqtt5, http, auth, io
6
+ import time
7
+
8
+ import boto3
9
+ import requests
10
+ from awscrt import mqtt5, auth, io
11
+ from awsiot import mqtt5_client_builder
10
12
 
11
13
 
12
14
  class EmeraldHWS():
@@ -22,11 +24,13 @@ class EmeraldHWS():
22
24
  MQTT_HOST = "a13v32g67itvz9-ats.iot.ap-southeast-2.amazonaws.com"
23
25
  COGNITO_IDENTITY_POOL_ID = "ap-southeast-2:f5bbb02c-c00e-4f10-acb3-e7d1b05268e8"
24
26
 
25
- def __init__(self, email, password, update_callback=None):
27
+ def __init__(self, email, password, update_callback=None, connection_timeout_minutes=720, health_check_minutes=60):
26
28
  """ Initialise the API client
27
29
  :param email: The email address for logging into the Emerald app
28
30
  :param password: The password for the supplied user account
29
31
  :param update_callback: Optional callback function to be called when an update is available
32
+ :param connection_timeout_minutes: Optional timeout in minutes before reconnecting MQTT (default: 720 minutes/12 hours)
33
+ :param health_check_minutes: Optional interval in minutes to check for message activity (default: 60 minutes/1 hour)
30
34
  """
31
35
 
32
36
  self.email = email
@@ -35,6 +39,27 @@ class EmeraldHWS():
35
39
  self.properties = {}
36
40
  self.logger = logging.getLogger()
37
41
  self.update_callback = update_callback
42
+
43
+ # Convert minutes to seconds for internal use
44
+ self.connection_timeout = connection_timeout_minutes * 60.0
45
+ self.health_check_interval = health_check_minutes * 60.0 if health_check_minutes > 0 else 0
46
+ self.last_message_time = None
47
+ self.health_check_timer = None
48
+
49
+ # Connection state tracking
50
+ self.connection_state = "initial" # possible states: initial, connected, failed
51
+ self.consecutive_failures = 0
52
+ self.max_backoff_seconds = 60 # Maximum backoff of 1 minute
53
+
54
+ # Ensure reasonable minimum values (e.g., at least 5 minutes for connection timeout)
55
+ if connection_timeout_minutes < 5 and connection_timeout_minutes != 0:
56
+ self.logger.warning("emeraldhws: Connection timeout too short, setting to minimum of 5 minutes")
57
+ self.connection_timeout = 5 * 60.0
58
+
59
+ # Ensure reasonable minimum values for health check (e.g., at least 5 minutes)
60
+ if 0 < health_check_minutes < 5:
61
+ self.logger.warning("emeraldhws: Health check interval too short, setting to minimum of 5 minutes")
62
+ self.health_check_interval = 5 * 60.0
38
63
 
39
64
  def getLoginToken(self):
40
65
  """ Performs an API request to get a token from the API
@@ -87,20 +112,48 @@ class EmeraldHWS():
87
112
 
88
113
  self.update_callback = update_callback
89
114
 
90
- def reconnectMQTT(self):
115
+ def reconnectMQTT(self, reason="scheduled"):
91
116
  """ Stops an existing MQTT connection and creates a new one
117
+ :param reason: Reason for reconnection (scheduled, health_check, etc.)
92
118
  """
93
-
94
- self.logger.debug("emeraldhws: awsiot: Tearing down and reconnecting to prevent stale connection")
119
+ self.logger.info(f"emeraldhws: awsiot: Reconnecting MQTT connection (reason: {reason})")
120
+
121
+ # Store current temperature values for comparison after reconnect
122
+ temp_values = {}
123
+ for properties in self.properties:
124
+ heat_pumps = properties.get('heat_pump', [])
125
+ for heat_pump in heat_pumps:
126
+ hws_id = heat_pump['id']
127
+ if 'last_state' in heat_pump and 'temp_current' in heat_pump['last_state']:
128
+ temp_values[hws_id] = heat_pump['last_state']['temp_current']
129
+
95
130
  self.mqttClient.stop()
96
131
  self.connectMQTT()
97
132
  self.subscribeAllHWS()
133
+
134
+ # After reconnection, check if temperatures have changed
135
+ def check_temp_changes():
136
+ for properties in self.properties:
137
+ heat_pumps = properties.get('heat_pump', [])
138
+ for heat_pump in heat_pumps:
139
+ hws_id = heat_pump['id']
140
+ if (hws_id in temp_values and
141
+ 'last_state' in heat_pump and
142
+ 'temp_current' in heat_pump['last_state']):
143
+ old_temp = temp_values[hws_id]
144
+ new_temp = heat_pump['last_state']['temp_current']
145
+ if old_temp != new_temp:
146
+ self.logger.info(f"emeraldhws: Temperature changed after reconnect for {hws_id}: {old_temp} → {new_temp}")
147
+
148
+ # Check for temperature changes after a short delay to allow for updates
149
+ threading.Timer(10.0, check_temp_changes).start()
98
150
 
99
151
  def connectMQTT(self):
100
152
  """ Establishes a connection to Amazon IOT core's MQTT service
101
153
  """
102
154
 
103
- cert_path = os.path.join(os.path.dirname(__file__), '__assets__', 'SFSRootCAG2.pem')
155
+ # Certificate path is available but not currently used in the connection
156
+ # os.path.join(os.path.dirname(__file__), '__assets__', 'SFSRootCAG2.pem')
104
157
  identityPoolID = self.COGNITO_IDENTITY_POOL_ID
105
158
  region = self.MQTT_HOST.split('.')[2]
106
159
  cognito_endpoint = "cognito-identity." + region + ".amazonaws.com"
@@ -131,7 +184,16 @@ class EmeraldHWS():
131
184
 
132
185
  client.start()
133
186
  self.mqttClient = client
134
- threading.Timer(43200.0, self.reconnectMQTT).start() # 12 hours
187
+
188
+ # Schedule periodic reconnection using configurable timeout
189
+ if self.connection_timeout > 0:
190
+ threading.Timer(self.connection_timeout, self.reconnectMQTT).start()
191
+
192
+ # Start health check timer if enabled
193
+ if self.health_check_interval > 0:
194
+ self.health_check_timer = threading.Timer(self.health_check_interval, self.check_connection_health)
195
+ self.health_check_timer.daemon = True
196
+ self.health_check_timer.start()
135
197
 
136
198
  def mqttDecodeUpdate(self, topic, payload):
137
199
  """ Attempt to decode a received MQTT message and direct appropriately
@@ -153,12 +215,15 @@ class EmeraldHWS():
153
215
  publish_packet = publish_packet_data.publish_packet
154
216
  assert isinstance(publish_packet, mqtt5.PublishPacket)
155
217
  self.logger.debug("emeraldhws: awsiot: Received message from MQTT topic {}: {}".format(publish_packet.topic, publish_packet.payload))
218
+ self.last_message_time = time.time() # Update the last message time
156
219
  self.mqttDecodeUpdate(publish_packet.topic, publish_packet.payload)
157
220
 
158
221
  def on_connection_interrupted(self, connection, error, **kwargs):
159
222
  """ Log error when MQTT is interrupted
160
223
  """
161
- self.logger.debug("emeraldhws: awsiot: Connection interrupted. error: {}".format(error))
224
+ error_code = getattr(error, 'code', 'unknown')
225
+ error_name = getattr(error, 'name', 'unknown')
226
+ self.logger.info(f"emeraldhws: awsiot: Connection interrupted. Error: {error_name} (code: {error_code}), Message: {error}")
162
227
 
163
228
  def on_connection_resumed(self, connection, return_code, session_present, **kwargs):
164
229
  """ Log message when MQTT is resumed
@@ -169,12 +234,35 @@ class EmeraldHWS():
169
234
  """ Log message when connection succeeded
170
235
  """
171
236
  self.logger.debug("emeraldhws: awsiot: connection succeeded")
237
+ # Reset failure counter and update connection state
238
+ self.consecutive_failures = 0
239
+ self.connection_state = "connected"
172
240
  return
173
241
 
174
242
  def on_lifecycle_connection_failure(self, lifecycle_connection_failure: mqtt5.LifecycleConnectFailureData):
175
243
  """ Log message when connection failed
176
244
  """
177
- self.logger.debug("emeraldhws: awsiot: connection failed")
245
+ error = lifecycle_connection_failure.error
246
+ error_code = getattr(error, 'code', 'unknown')
247
+ error_name = getattr(error, 'name', 'unknown')
248
+ error_message = str(error)
249
+
250
+ # Update connection state and increment failure counter
251
+ self.connection_state = "failed"
252
+ self.consecutive_failures += 1
253
+
254
+ # Log at INFO level since this is important for troubleshooting
255
+ self.logger.info(f"emeraldhws: awsiot: connection failed - Error: {error_name} (code: {error_code}), Message: {error_message}")
256
+
257
+ # If there's a CONNACK packet available, log its details too
258
+ if hasattr(lifecycle_connection_failure, 'connack_packet') and lifecycle_connection_failure.connack_packet:
259
+ connack = lifecycle_connection_failure.connack_packet
260
+ reason_code = getattr(connack, 'reason_code', 'unknown')
261
+ reason_string = getattr(connack, 'reason_string', '')
262
+ if reason_string:
263
+ self.logger.info(f"emeraldhws: awsiot: MQTT CONNACK reason: {reason_code} - {reason_string}")
264
+ else:
265
+ self.logger.info(f"emeraldhws: awsiot: MQTT CONNACK reason code: {reason_code}")
178
266
  return
179
267
 
180
268
  def on_lifecycle_stopped(self, lifecycle_stopped_data: mqtt5.LifecycleStoppedData):
@@ -186,14 +274,56 @@ class EmeraldHWS():
186
274
  def on_lifecycle_disconnection(self, lifecycle_disconnect_data: mqtt5.LifecycleDisconnectData):
187
275
  """ Log message when disconnected
188
276
  """
189
- self.logger.debug("emeraldhws: awsiot: disconnected")
277
+ # Extract disconnect reason if available
278
+ reason = "unknown reason"
279
+ if hasattr(lifecycle_disconnect_data, 'disconnect_packet') and lifecycle_disconnect_data.disconnect_packet:
280
+ reason_code = getattr(lifecycle_disconnect_data.disconnect_packet, 'reason_code', 'unknown')
281
+ reason_string = getattr(lifecycle_disconnect_data.disconnect_packet, 'reason_string', '')
282
+ reason = f"reason code: {reason_code}" + (f" - {reason_string}" if reason_string else "")
283
+
284
+ self.logger.info(f"emeraldhws: awsiot: disconnected - {reason}")
190
285
  return
191
286
 
192
287
  def on_lifecycle_attempting_connect(self, lifecycle_attempting_connect_data: mqtt5.LifecycleAttemptingConnectData):
193
288
  """ Log message when attempting connect
194
289
  """
195
- self.logger.debug("emeraldhws: awsiot: attempting to connect")
290
+ # Include endpoint information if available
291
+ endpoint = getattr(lifecycle_attempting_connect_data, 'endpoint', 'unknown')
292
+ self.logger.debug(f"emeraldhws: awsiot: attempting to connect to {endpoint}")
196
293
  return
294
+
295
+ def check_connection_health(self):
296
+ """ Check if we've received any messages recently, reconnect if not
297
+ """
298
+ if self.last_message_time is None:
299
+ # No messages received yet, don't reconnect
300
+ self.logger.debug("emeraldhws: awsiot: Health check - No messages received yet")
301
+ else:
302
+ current_time = time.time()
303
+ time_since_last_message = current_time - self.last_message_time
304
+ minutes_since_last = time_since_last_message / 60.0
305
+
306
+ if time_since_last_message > self.health_check_interval:
307
+ # This is an INFO level log because it's an important event
308
+ self.logger.info(f"emeraldhws: awsiot: No messages received for {minutes_since_last:.1f} minutes, reconnecting")
309
+
310
+ # If we're in a failed state, apply exponential backoff
311
+ if self.connection_state == "failed" and self.consecutive_failures > 0:
312
+ # Calculate backoff time with exponential increase, capped at max_backoff_seconds
313
+ backoff_seconds = min(2 ** (self.consecutive_failures - 1), self.max_backoff_seconds)
314
+ self.logger.info(f"emeraldhws: awsiot: Connection in failed state, applying backoff of {backoff_seconds} seconds before retry (attempt {self.consecutive_failures})")
315
+ time.sleep(backoff_seconds)
316
+
317
+ self.reconnectMQTT(reason="health_check")
318
+ else:
319
+ # This is a DEBUG level log to avoid cluttering logs
320
+ self.logger.debug(f"emeraldhws: awsiot: Health check - Last message received {minutes_since_last:.1f} minutes ago")
321
+
322
+ # Schedule next health check
323
+ if self.health_check_interval > 0:
324
+ self.health_check_timer = threading.Timer(self.health_check_interval, self.check_connection_health)
325
+ self.health_check_timer.daemon = True
326
+ self.health_check_timer.start()
197
327
 
198
328
  def updateHWSState(self, id, key, value):
199
329
  """ Updates the specified value for the supplied key in the HWS id specified
@@ -207,7 +337,7 @@ class EmeraldHWS():
207
337
  for heat_pump in heat_pumps:
208
338
  if heat_pump['id'] == id:
209
339
  heat_pump['last_state'][key] = value
210
- if self.update_callback != None:
340
+ if self.update_callback is not None:
211
341
  self.update_callback()
212
342
 
213
343
  def subscribeForUpdates(self, id):
@@ -224,7 +354,8 @@ class EmeraldHWS():
224
354
  topic_filter=mqtt_topic,
225
355
  qos=mqtt5.QoS.AT_LEAST_ONCE)]))
226
356
 
227
- suback = subscribe_future.result(20)
357
+ # Wait for subscription to complete
358
+ subscribe_future.result(20)
228
359
 
229
360
  def getFullStatus(self, id):
230
361
  """ Returns a dict with the full status of the specified HWS
@@ -0,0 +1,80 @@
1
+ Metadata-Version: 2.4
2
+ Name: emerald_hws
3
+ Version: 0.0.12
4
+ Summary: A package to manipulate and monitor Emerald Heat Pump Hot Water Systems
5
+ Author-email: Ross Williamson <ross@inertia.net.nz>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/ross-w/emerald_hws_py
8
+ Project-URL: Bug Tracker, https://github.com/ross-w/emerald_hws_py/issues
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.7
12
+ Description-Content-Type: text/markdown
13
+ Requires-Dist: boto3<2.0.0,>=1.40.0
14
+ Requires-Dist: awsiotsdk<2.0.0,>=1.24.0
15
+ Requires-Dist: requests>=2.25.0
16
+ Provides-Extra: dev
17
+ Requires-Dist: ruff<1.0.0,>=0.12.0; extra == "dev"
18
+
19
+ # emerald_hws_py
20
+ Python package for controlling Emerald Heat Pump Hot Water Systems
21
+
22
+ ## Overview
23
+ This package provides an interface to control and monitor Emerald Heat Pump Hot Water Systems through their API and MQTT service.
24
+
25
+ ## Installation
26
+ ```bash
27
+ pip install emerald_hws
28
+ ```
29
+
30
+ ## Usage
31
+ ```python
32
+ from emerald_hws.emeraldhws import EmeraldHWS
33
+
34
+ # Basic usage with default connection settings
35
+ client = EmeraldHWS("your_email@example.com", "your_password")
36
+ client.connect()
37
+
38
+ # List all hot water systems
39
+ hws_list = client.listHWS()
40
+ print(f"Found {len(hws_list)} hot water systems")
41
+
42
+ # Get status of first HWS
43
+ hws_id = hws_list[0]
44
+ status = client.getFullStatus(hws_id)
45
+ print(f"Current temperature: {status['last_state'].get('temp_current')}")
46
+
47
+ # Turn on the hot water system
48
+ client.turnOn(hws_id)
49
+ ```
50
+
51
+ ## Configuration Options
52
+
53
+ ### Connection Timeout
54
+ The module will automatically reconnect to the MQTT service periodically to prevent stale connections. You can configure this timeout:
55
+
56
+ ```python
57
+ # Set connection timeout to 6 hours (360 minutes)
58
+ client = EmeraldHWS("your_email@example.com", "your_password", connection_timeout_minutes=360)
59
+ ```
60
+
61
+ ### Health Check
62
+ The module can proactively check for message activity and reconnect if no messages have been received for a specified period:
63
+
64
+ ```python
65
+ # Set health check to check every 30 minutes
66
+ client = EmeraldHWS("your_email@example.com", "your_password", health_check_minutes=30)
67
+
68
+ # Disable health check
69
+ client = EmeraldHWS("your_email@example.com", "your_password", health_check_minutes=0)
70
+ ```
71
+
72
+ ## Callback for Updates
73
+ You can register a callback function to be notified when the state of any hot water system changes:
74
+
75
+ ```python
76
+ def my_callback():
77
+ print("Hot water system state updated!")
78
+
79
+ client = EmeraldHWS("your_email@example.com", "your_password", update_callback=my_callback)
80
+ ```
@@ -0,0 +1,7 @@
1
+ emerald_hws/__init__.py,sha256=uukjQ-kiPYKWvGT3jLL6kJA1DCNAxtw4HlLKqPSypXs,61
2
+ emerald_hws/emeraldhws.py,sha256=wfsZXOR_MlL3FEPCrTz3eU2fhY1xFCW9ztODI50hL04,22092
3
+ emerald_hws/__assets__/SFSRootCAG2.pem,sha256=hw9W0AnYrrlbcWsOewAgIl1ULEsoO57Ylu35dCjWcS4,1424
4
+ emerald_hws-0.0.12.dist-info/METADATA,sha256=cKfX2Ye1-KtmUzZFm8o91gazAZ9NNO3HIO61QD9VJv0,2534
5
+ emerald_hws-0.0.12.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
6
+ emerald_hws-0.0.12.dist-info/top_level.txt,sha256=ZCiUmnBkDr2n4QVkTet1s_AKiGJjuz3heuCR5w5ZqLY,12
7
+ emerald_hws-0.0.12.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (75.8.0)
2
+ Generator: setuptools (80.9.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,21 +0,0 @@
1
- MIT License
2
-
3
- Copyright (c) 2024 Ross Williamson
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
@@ -1,18 +0,0 @@
1
- Metadata-Version: 2.2
2
- Name: emerald_hws
3
- Version: 0.0.10
4
- Summary: A package to manipulate and monitor Emerald Heat Pump Hot Water Systems
5
- Author-email: Ross Williamson <ross@inertia.net.nz>
6
- Project-URL: Homepage, https://github.com/ross-w/emerald_hws_py
7
- Project-URL: Bug Tracker, https://github.com/ross-w/emerald_hws_py/issues
8
- Classifier: Programming Language :: Python :: 3
9
- Classifier: License :: OSI Approved :: MIT License
10
- Classifier: Operating System :: OS Independent
11
- Requires-Python: >=3.7
12
- Description-Content-Type: text/markdown
13
- License-File: LICENSE
14
- Requires-Dist: boto3
15
- Requires-Dist: awsiotsdk
16
-
17
- # emerald_hws_py
18
- Python package for controlling Emerald Heat Pump Hot Water Systems
@@ -1,8 +0,0 @@
1
- emerald_hws/__init__.py,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
2
- emerald_hws/emeraldhws.py,sha256=aAH2x5f9c5sw2NBg0BSbPJ0iuzNdLaXWvWMK7h2hYeY,14361
3
- emerald_hws/__assets__/SFSRootCAG2.pem,sha256=hw9W0AnYrrlbcWsOewAgIl1ULEsoO57Ylu35dCjWcS4,1424
4
- emerald_hws-0.0.10.dist-info/LICENSE,sha256=zzMi56JX7OO-epbXNfe_oFW6sfZHSlO7Yxm0Oh0V014,1072
5
- emerald_hws-0.0.10.dist-info/METADATA,sha256=8TYWMzh7CgGm7WBoOAdUwNyKzv0k2--LGRti97PM7DU,689
6
- emerald_hws-0.0.10.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
7
- emerald_hws-0.0.10.dist-info/top_level.txt,sha256=ZCiUmnBkDr2n4QVkTet1s_AKiGJjuz3heuCR5w5ZqLY,12
8
- emerald_hws-0.0.10.dist-info/RECORD,,