emerald-hws 0.0.17__py3-none-any.whl → 0.0.18__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/emeraldhws.py CHANGED
@@ -41,6 +41,9 @@ class EmeraldHWS():
41
41
  self.update_callback = update_callback
42
42
  self._state_lock = threading.RLock() # Thread-safe lock for state operations
43
43
  self._connection_event = threading.Event() # Event to signal when MQTT connection is established
44
+ self._connect_lock = threading.Lock() # Lock to prevent concurrent connect() calls
45
+ self._mqtt_lock = threading.RLock() # Lock to protect MQTT client lifecycle operations
46
+ self._is_connected = False # Flag to track connection state
44
47
  self.mqttClient = None # Initialize to None
45
48
 
46
49
  # Convert minutes to seconds for internal use
@@ -48,17 +51,18 @@ class EmeraldHWS():
48
51
  self.health_check_interval = health_check_minutes * 60.0 if health_check_minutes > 0 else 0
49
52
  self.last_message_time = None
50
53
  self.health_check_timer = None
54
+ self.reconnect_timer = None
51
55
 
52
56
  # Connection state tracking
53
57
  self.connection_state = "initial" # possible states: initial, connected, failed
54
58
  self.consecutive_failures = 0
55
59
  self.max_backoff_seconds = 60 # Maximum backoff of 1 minute
56
-
60
+
57
61
  # Ensure reasonable minimum values (e.g., at least 5 minutes for connection timeout)
58
62
  if connection_timeout_minutes < 5 and connection_timeout_minutes != 0:
59
63
  self.logger.warning("emeraldhws: Connection timeout too short, setting to minimum of 5 minutes")
60
64
  self.connection_timeout = 5 * 60.0
61
-
65
+
62
66
  # Ensure reasonable minimum values for health check (e.g., at least 5 minutes)
63
67
  if 0 < health_check_minutes < 5:
64
68
  self.logger.warning("emeraldhws: Health check interval too short, setting to minimum of 5 minutes")
@@ -119,100 +123,67 @@ class EmeraldHWS():
119
123
  """ Stops an existing MQTT connection and creates a new one
120
124
  :param reason: Reason for reconnection (scheduled, health_check, etc.)
121
125
  """
122
- self.logger.info(f"emeraldhws: awsiot: Reconnecting MQTT connection (reason: {reason})")
126
+ with self._mqtt_lock:
127
+ self.logger.info(f"emeraldhws: awsiot: Reconnecting MQTT connection (reason: {reason})")
123
128
 
124
- # Store current temperature values for comparison after reconnect
125
- temp_values = {}
126
- for properties in self.properties:
127
- heat_pumps = properties.get('heat_pump', [])
128
- for heat_pump in heat_pumps:
129
- hws_id = heat_pump['id']
130
- if 'last_state' in heat_pump and 'temp_current' in heat_pump['last_state']:
131
- temp_values[hws_id] = heat_pump['last_state']['temp_current']
132
-
133
- if self.mqttClient is not None:
134
- self.mqttClient.stop()
135
- self.mqttClient = None # Clear the client so a new one can be created
136
-
137
- self.connectMQTT()
138
- self.subscribeAllHWS()
139
-
140
- # After reconnection, check if temperatures have changed
141
- def check_temp_changes():
142
- for properties in self.properties:
143
- heat_pumps = properties.get('heat_pump', [])
144
- for heat_pump in heat_pumps:
145
- hws_id = heat_pump['id']
146
- if (hws_id in temp_values and
147
- 'last_state' in heat_pump and
148
- 'temp_current' in heat_pump['last_state']):
149
- old_temp = temp_values[hws_id]
150
- new_temp = heat_pump['last_state']['temp_current']
151
- if old_temp != new_temp:
152
- self.logger.info(f"emeraldhws: Temperature changed after reconnect for {hws_id}: {old_temp} → {new_temp}")
153
-
154
- # Check for temperature changes after a short delay to allow for updates
155
- threading.Timer(10.0, check_temp_changes).start()
129
+ if self.mqttClient is not None:
130
+ self.mqttClient.stop()
131
+ self.mqttClient = None # Clear the client so a new one can be created
132
+
133
+ self.connectMQTT()
134
+ self.subscribeAllHWS()
135
+
136
+ self.logger.info(f"emeraldhws: awsiot: MQTT reconnection completed (reason: {reason})")
156
137
 
157
138
  def connectMQTT(self):
158
139
  """ Establishes a connection to Amazon IOT core's MQTT service
159
140
  """
160
-
161
- # If already connected, skip
162
- if self.mqttClient is not None:
163
- self.logger.debug("emeraldhws: awsiot: MQTT client already exists, skipping connection")
164
- return
165
-
166
- # Clear the connection event before starting new connection
167
- self._connection_event.clear()
168
-
169
- # Certificate path is available but not currently used in the connection
170
- # os.path.join(os.path.dirname(__file__), '__assets__', 'SFSRootCAG2.pem')
171
- identityPoolID = self.COGNITO_IDENTITY_POOL_ID
172
- region = self.MQTT_HOST.split('.')[2]
173
- cognito_endpoint = "cognito-identity." + region + ".amazonaws.com"
174
- cognitoIdentityClient = boto3.client('cognito-identity', region_name=region)
175
-
176
- temporaryIdentityId = cognitoIdentityClient.get_id(IdentityPoolId=identityPoolID)
177
- identityID = temporaryIdentityId["IdentityId"]
178
- self.logger.debug("emeraldhws: awsiot: AWS IoT IdentityID: {}".format(identityID))
179
-
180
- credentials_provider = auth.AwsCredentialsProvider.new_cognito(
181
- endpoint=cognito_endpoint,
182
- identity=identityID,
183
- tls_ctx=io.ClientTlsContext(io.TlsContextOptions()))
184
-
185
- client = mqtt5_client_builder.websockets_with_default_aws_signing(
186
- endpoint = self.MQTT_HOST,
187
- region = region,
188
- credentials_provider = credentials_provider,
189
- on_connection_interrupted = self.on_connection_interrupted,
190
- on_connection_resumed = self.on_connection_resumed,
191
- on_lifecycle_connection_success = self.on_lifecycle_connection_success,
192
- on_lifecycle_stopped = self.on_lifecycle_stopped,
193
- on_lifecycle_attempting_connect = self.on_lifecycle_attempting_connect,
194
- on_lifecycle_disconnection = self.on_lifecycle_disconnection,
195
- on_lifecycle_connection_failure = self.on_lifecycle_connection_failure,
196
- on_publish_received = self.mqttCallback
197
- )
198
-
199
- client.start()
200
- self.mqttClient = client
201
-
202
- # Block until connection is established or timeout (30 seconds)
203
- if not self._connection_event.wait(timeout=30):
204
- self.logger.warning("emeraldhws: awsiot: Connection establishment timed out after 30 seconds")
205
- # Continue anyway - the connection may still succeed asynchronously
206
-
207
- # Schedule periodic reconnection using configurable timeout
208
- if self.connection_timeout > 0:
209
- threading.Timer(self.connection_timeout, self.reconnectMQTT).start()
210
-
211
- # Start health check timer if enabled
212
- if self.health_check_interval > 0:
213
- self.health_check_timer = threading.Timer(self.health_check_interval, self.check_connection_health)
214
- self.health_check_timer.daemon = True
215
- self.health_check_timer.start()
141
+ with self._mqtt_lock:
142
+ # If already connected, skip
143
+ if self.mqttClient is not None:
144
+ self.logger.debug("emeraldhws: awsiot: MQTT client already exists, skipping connection")
145
+ return
146
+
147
+ # Clear the connection event before starting new connection
148
+ self._connection_event.clear()
149
+
150
+ # Certificate path is available but not currently used in the connection
151
+ # os.path.join(os.path.dirname(__file__), '__assets__', 'SFSRootCAG2.pem')
152
+ identityPoolID = self.COGNITO_IDENTITY_POOL_ID
153
+ region = self.MQTT_HOST.split('.')[2]
154
+ cognito_endpoint = "cognito-identity." + region + ".amazonaws.com"
155
+ cognitoIdentityClient = boto3.client('cognito-identity', region_name=region)
156
+
157
+ temporaryIdentityId = cognitoIdentityClient.get_id(IdentityPoolId=identityPoolID)
158
+ identityID = temporaryIdentityId["IdentityId"]
159
+ self.logger.debug("emeraldhws: awsiot: AWS IoT IdentityID: {}".format(identityID))
160
+
161
+ credentials_provider = auth.AwsCredentialsProvider.new_cognito(
162
+ endpoint=cognito_endpoint,
163
+ identity=identityID,
164
+ tls_ctx=io.ClientTlsContext(io.TlsContextOptions()))
165
+
166
+ client = mqtt5_client_builder.websockets_with_default_aws_signing(
167
+ endpoint = self.MQTT_HOST,
168
+ region = region,
169
+ credentials_provider = credentials_provider,
170
+ on_connection_interrupted = self.on_connection_interrupted,
171
+ on_connection_resumed = self.on_connection_resumed,
172
+ on_lifecycle_connection_success = self.on_lifecycle_connection_success,
173
+ on_lifecycle_stopped = self.on_lifecycle_stopped,
174
+ on_lifecycle_attempting_connect = self.on_lifecycle_attempting_connect,
175
+ on_lifecycle_disconnection = self.on_lifecycle_disconnection,
176
+ on_lifecycle_connection_failure = self.on_lifecycle_connection_failure,
177
+ on_publish_received = self.mqttCallback
178
+ )
179
+
180
+ client.start()
181
+ self.mqttClient = client
182
+
183
+ # Block until connection is established or timeout (30 seconds)
184
+ if not self._connection_event.wait(timeout=30):
185
+ self.logger.warning("emeraldhws: awsiot: Connection establishment timed out after 30 seconds")
186
+ # Continue anyway - the connection may still succeed asynchronously
216
187
 
217
188
  def mqttDecodeUpdate(self, topic, payload):
218
189
  """ Attempt to decode a received MQTT message and direct appropriately
@@ -334,9 +305,21 @@ class EmeraldHWS():
334
305
  """
335
306
  self.logger.debug("emeraldhws: awsiot: attempting to connect")
336
307
  return
337
-
308
+
309
+ def scheduled_reconnect(self):
310
+ """ Periodic MQTT reconnect - called by timer and reschedules itself
311
+ """
312
+ self.reconnectMQTT(reason="scheduled")
313
+
314
+ # Reschedule for next time
315
+ if self.connection_timeout > 0:
316
+ self.reconnect_timer = threading.Timer(self.connection_timeout, self.scheduled_reconnect)
317
+ self.reconnect_timer.daemon = True
318
+ self.reconnect_timer.start()
319
+
338
320
  def check_connection_health(self):
339
321
  """ Check if we've received any messages recently, reconnect if not
322
+ Called by timer and reschedules itself
340
323
  """
341
324
  if self.last_message_time is None:
342
325
  # No messages received yet, don't reconnect
@@ -345,24 +328,24 @@ class EmeraldHWS():
345
328
  current_time = time.time()
346
329
  time_since_last_message = current_time - self.last_message_time
347
330
  minutes_since_last = time_since_last_message / 60.0
348
-
331
+
349
332
  if time_since_last_message > self.health_check_interval:
350
333
  # This is an INFO level log because it's an important event
351
334
  self.logger.info(f"emeraldhws: awsiot: No messages received for {minutes_since_last:.1f} minutes, reconnecting")
352
-
335
+
353
336
  # If we're in a failed state, apply exponential backoff
354
337
  if self.connection_state == "failed" and self.consecutive_failures > 0:
355
338
  # Calculate backoff time with exponential increase, capped at max_backoff_seconds
356
339
  backoff_seconds = min(2 ** (self.consecutive_failures - 1), self.max_backoff_seconds)
357
340
  self.logger.info(f"emeraldhws: awsiot: Connection in failed state, applying backoff of {backoff_seconds} seconds before retry (attempt {self.consecutive_failures})")
358
341
  time.sleep(backoff_seconds)
359
-
342
+
360
343
  self.reconnectMQTT(reason="health_check")
361
344
  else:
362
345
  # This is a DEBUG level log to avoid cluttering logs
363
346
  self.logger.debug(f"emeraldhws: awsiot: Health check - Last message received {minutes_since_last:.1f} minutes ago")
364
-
365
- # Schedule next health check
347
+
348
+ # Always reschedule next health check
366
349
  if self.health_check_interval > 0:
367
350
  self.health_check_timer = threading.Timer(self.health_check_interval, self.check_connection_health)
368
351
  self.health_check_timer.daemon = True
@@ -381,7 +364,7 @@ class EmeraldHWS():
381
364
  for heat_pump in heat_pumps:
382
365
  if heat_pump['id'] == id:
383
366
  heat_pump['last_state'][key] = value
384
-
367
+
385
368
  # Call callback AFTER releasing lock to avoid potential deadlocks
386
369
  if self.update_callback is not None:
387
370
  self.update_callback()
@@ -390,25 +373,26 @@ class EmeraldHWS():
390
373
  """ Subscribes to the MQTT topics for the supplied HWS
391
374
  :param id: The UUID of the requested HWS
392
375
  """
393
- if not self.mqttClient:
394
- self.connectMQTT()
376
+ with self._mqtt_lock:
377
+ if not self.mqttClient:
378
+ self.connectMQTT()
395
379
 
396
- mqtt_topic = "ep/heat_pump/from_gw/{}".format(id)
397
- subscribe_future = self.mqttClient.subscribe(
398
- subscribe_packet=mqtt5.SubscribePacket(
399
- subscriptions=[mqtt5.Subscription(
400
- topic_filter=mqtt_topic,
401
- qos=mqtt5.QoS.AT_LEAST_ONCE)]))
380
+ mqtt_topic = "ep/heat_pump/from_gw/{}".format(id)
381
+ subscribe_future = self.mqttClient.subscribe(
382
+ subscribe_packet=mqtt5.SubscribePacket(
383
+ subscriptions=[mqtt5.Subscription(
384
+ topic_filter=mqtt_topic,
385
+ qos=mqtt5.QoS.AT_LEAST_ONCE)]))
402
386
 
403
- # Wait for subscription to complete
404
- subscribe_future.result(20)
387
+ # Wait for subscription to complete
388
+ subscribe_future.result(20)
405
389
 
406
390
  def getFullStatus(self, id):
407
391
  """ Returns a dict with the full status of the specified HWS
408
392
  :param id: UUID of the HWS to get the status for
409
393
  """
410
394
 
411
- if not self.properties:
395
+ if not self._is_connected:
412
396
  self.connect()
413
397
 
414
398
  with self._state_lock:
@@ -425,7 +409,7 @@ class EmeraldHWS():
425
409
  :param payload: JSON payload to send eg {"switch":1}
426
410
  """
427
411
 
428
- if not self.properties:
412
+ if not self._is_connected:
429
413
  self.connect()
430
414
 
431
415
  hwsdetail = self.getFullStatus(id)
@@ -443,11 +427,17 @@ class EmeraldHWS():
443
427
  payload
444
428
  ]
445
429
  mqtt_topic = "ep/heat_pump/to_gw/{}".format(id)
446
- publish_future = self.mqttClient.publish(
447
- mqtt5.PublishPacket(
448
- topic=mqtt_topic,
449
- payload=json.dumps(msg),
450
- qos=mqtt5.QoS.AT_LEAST_ONCE))
430
+
431
+ with self._mqtt_lock:
432
+ if not self.mqttClient:
433
+ raise Exception("MQTT client not connected")
434
+ publish_future = self.mqttClient.publish(
435
+ mqtt5.PublishPacket(
436
+ topic=mqtt_topic,
437
+ payload=json.dumps(msg),
438
+ qos=mqtt5.QoS.AT_LEAST_ONCE))
439
+
440
+ # Wait for publish to complete outside the lock
451
441
  publish_future.result(20) # 20 seconds
452
442
 
453
443
  def turnOn(self, id):
@@ -506,12 +496,12 @@ class EmeraldHWS():
506
496
  work_state = full_status.get("last_state").get("work_state")
507
497
  # work_state: 0=off/idle, 1=actively heating, 2=on but not heating
508
498
  return (work_state == 1)
509
-
499
+
510
500
  # Fallback to device_operation_status if work_state not available yet
511
501
  # (e.g., before first MQTT update after initialization)
512
502
  heating_status = full_status.get("device_operation_status")
513
503
  return (heating_status == 1)
514
-
504
+
515
505
  return False
516
506
 
517
507
  def getHourlyEnergyUsage(self, id):
@@ -562,7 +552,7 @@ class EmeraldHWS():
562
552
  def listHWS(self):
563
553
  """ Returns a list of UUIDs of all discovered HWS
564
554
  """
565
- if not self.properties:
555
+ if not self._is_connected:
566
556
  self.connect()
567
557
 
568
558
  hws = []
@@ -589,7 +579,27 @@ class EmeraldHWS():
589
579
  """ Connect to the API with the supplied credentials, retrieve HWS details
590
580
  :returns: True if successful
591
581
  """
592
- self.getLoginToken()
593
- self.getAllHWS()
594
- self.connectMQTT()
595
- self.subscribeAllHWS()
582
+ # Use lock to ensure only one thread can connect at a time
583
+ with self._connect_lock:
584
+ # Double-check pattern: check again inside the lock
585
+ if self._is_connected:
586
+ self.logger.debug("emeraldhws: Already connected, skipping")
587
+ return
588
+
589
+ self.logger.debug("emeraldhws: Connecting...")
590
+ self.getLoginToken()
591
+ self.getAllHWS()
592
+ self.connectMQTT()
593
+ self.subscribeAllHWS()
594
+ self._is_connected = True
595
+
596
+ # Start timers ONCE on initial connection
597
+ if self.connection_timeout > 0:
598
+ self.reconnect_timer = threading.Timer(self.connection_timeout, self.scheduled_reconnect)
599
+ self.reconnect_timer.daemon = True
600
+ self.reconnect_timer.start()
601
+
602
+ if self.health_check_interval > 0:
603
+ self.health_check_timer = threading.Timer(self.health_check_interval, self.check_connection_health)
604
+ self.health_check_timer.daemon = True
605
+ self.health_check_timer.start()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: emerald_hws
3
- Version: 0.0.17
3
+ Version: 0.0.18
4
4
  Summary: A package to manipulate and monitor Emerald Heat Pump Hot Water Systems
5
5
  Author-email: Ross Williamson <ross@inertia.net.nz>
6
6
  License-Expression: MIT
@@ -0,0 +1,7 @@
1
+ emerald_hws/__init__.py,sha256=uukjQ-kiPYKWvGT3jLL6kJA1DCNAxtw4HlLKqPSypXs,61
2
+ emerald_hws/emeraldhws.py,sha256=_kW1CtCrhfUW7AXhzTdqO7TSSKQe_Oxw9q5v6qiPx_Q,26598
3
+ emerald_hws/__assets__/SFSRootCAG2.pem,sha256=hw9W0AnYrrlbcWsOewAgIl1ULEsoO57Ylu35dCjWcS4,1424
4
+ emerald_hws-0.0.18.dist-info/METADATA,sha256=gmRQmS3lp6IcJbb6jPCAYrn4sY7gY5GWln4a9x8VToY,2534
5
+ emerald_hws-0.0.18.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
6
+ emerald_hws-0.0.18.dist-info/top_level.txt,sha256=ZCiUmnBkDr2n4QVkTet1s_AKiGJjuz3heuCR5w5ZqLY,12
7
+ emerald_hws-0.0.18.dist-info/RECORD,,
@@ -1,7 +0,0 @@
1
- emerald_hws/__init__.py,sha256=uukjQ-kiPYKWvGT3jLL6kJA1DCNAxtw4HlLKqPSypXs,61
2
- emerald_hws/emeraldhws.py,sha256=ixNkwFXVEHLXtQhMmGi_ncfKhNDZWJdmlyuAW92tyyo,26201
3
- emerald_hws/__assets__/SFSRootCAG2.pem,sha256=hw9W0AnYrrlbcWsOewAgIl1ULEsoO57Ylu35dCjWcS4,1424
4
- emerald_hws-0.0.17.dist-info/METADATA,sha256=ytcqaOt_6fMzL1gGvoSybD3cQHiqLzPr5KTrPvO3rCc,2534
5
- emerald_hws-0.0.17.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
6
- emerald_hws-0.0.17.dist-info/top_level.txt,sha256=ZCiUmnBkDr2n4QVkTet1s_AKiGJjuz3heuCR5w5ZqLY,12
7
- emerald_hws-0.0.17.dist-info/RECORD,,