emerald-hws 0.0.17__py3-none-any.whl → 0.0.19__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")
@@ -105,10 +109,29 @@ class EmeraldHWS():
105
109
 
106
110
  if post_response_json.get("code") == 200:
107
111
  self.logger.debug("emeraldhws: Successfully logged into Emerald API")
108
- self.properties = post_response_json.get("info").get("property")
112
+ with self._state_lock:
113
+ self.properties = post_response_json.get("info").get("property")
109
114
  else:
110
115
  raise Exception("Unable to fetch properties from Emerald API")
111
116
 
117
+ def _wait_for_properties(self, timeout=30):
118
+ """
119
+ Wait for properties to be populated and return a thread-safe copy.
120
+ Blocks until properties is a non-empty list or timeout occurs.
121
+
122
+ :param timeout: Maximum seconds to wait
123
+ :returns: List of properties
124
+ :raises: Exception if timeout or properties not available
125
+ """
126
+ start_time = time.time()
127
+ while time.time() - start_time < timeout:
128
+ with self._state_lock:
129
+ if isinstance(self.properties, list) and len(self.properties) > 0:
130
+ return list(self.properties) # Return a copy
131
+ time.sleep(0.1) # Small delay before retry
132
+
133
+ raise Exception("Timeout waiting for properties to be populated")
134
+
112
135
  def replaceCallback(self, update_callback):
113
136
  """ Replaces the current registered update callback (if any) with the supplied
114
137
  """
@@ -119,100 +142,67 @@ class EmeraldHWS():
119
142
  """ Stops an existing MQTT connection and creates a new one
120
143
  :param reason: Reason for reconnection (scheduled, health_check, etc.)
121
144
  """
122
- self.logger.info(f"emeraldhws: awsiot: Reconnecting MQTT connection (reason: {reason})")
145
+ with self._mqtt_lock:
146
+ self.logger.info(f"emeraldhws: awsiot: Reconnecting MQTT connection (reason: {reason})")
123
147
 
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()
148
+ if self.mqttClient is not None:
149
+ self.mqttClient.stop()
150
+ self.mqttClient = None # Clear the client so a new one can be created
151
+
152
+ self.connectMQTT()
153
+ self.subscribeAllHWS()
154
+
155
+ self.logger.info(f"emeraldhws: awsiot: MQTT reconnection completed (reason: {reason})")
156
156
 
157
157
  def connectMQTT(self):
158
158
  """ Establishes a connection to Amazon IOT core's MQTT service
159
159
  """
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()
160
+ with self._mqtt_lock:
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
216
206
 
217
207
  def mqttDecodeUpdate(self, topic, payload):
218
208
  """ Attempt to decode a received MQTT message and direct appropriately
@@ -334,9 +324,21 @@ class EmeraldHWS():
334
324
  """
335
325
  self.logger.debug("emeraldhws: awsiot: attempting to connect")
336
326
  return
337
-
327
+
328
+ def scheduled_reconnect(self):
329
+ """ Periodic MQTT reconnect - called by timer and reschedules itself
330
+ """
331
+ self.reconnectMQTT(reason="scheduled")
332
+
333
+ # Reschedule for next time
334
+ if self.connection_timeout > 0:
335
+ self.reconnect_timer = threading.Timer(self.connection_timeout, self.scheduled_reconnect)
336
+ self.reconnect_timer.daemon = True
337
+ self.reconnect_timer.start()
338
+
338
339
  def check_connection_health(self):
339
340
  """ Check if we've received any messages recently, reconnect if not
341
+ Called by timer and reschedules itself
340
342
  """
341
343
  if self.last_message_time is None:
342
344
  # No messages received yet, don't reconnect
@@ -345,24 +347,24 @@ class EmeraldHWS():
345
347
  current_time = time.time()
346
348
  time_since_last_message = current_time - self.last_message_time
347
349
  minutes_since_last = time_since_last_message / 60.0
348
-
350
+
349
351
  if time_since_last_message > self.health_check_interval:
350
352
  # This is an INFO level log because it's an important event
351
353
  self.logger.info(f"emeraldhws: awsiot: No messages received for {minutes_since_last:.1f} minutes, reconnecting")
352
-
354
+
353
355
  # If we're in a failed state, apply exponential backoff
354
356
  if self.connection_state == "failed" and self.consecutive_failures > 0:
355
357
  # Calculate backoff time with exponential increase, capped at max_backoff_seconds
356
358
  backoff_seconds = min(2 ** (self.consecutive_failures - 1), self.max_backoff_seconds)
357
359
  self.logger.info(f"emeraldhws: awsiot: Connection in failed state, applying backoff of {backoff_seconds} seconds before retry (attempt {self.consecutive_failures})")
358
360
  time.sleep(backoff_seconds)
359
-
361
+
360
362
  self.reconnectMQTT(reason="health_check")
361
363
  else:
362
364
  # This is a DEBUG level log to avoid cluttering logs
363
365
  self.logger.debug(f"emeraldhws: awsiot: Health check - Last message received {minutes_since_last:.1f} minutes ago")
364
-
365
- # Schedule next health check
366
+
367
+ # Always reschedule next health check
366
368
  if self.health_check_interval > 0:
367
369
  self.health_check_timer = threading.Timer(self.health_check_interval, self.check_connection_health)
368
370
  self.health_check_timer.daemon = True
@@ -381,7 +383,7 @@ class EmeraldHWS():
381
383
  for heat_pump in heat_pumps:
382
384
  if heat_pump['id'] == id:
383
385
  heat_pump['last_state'][key] = value
384
-
386
+
385
387
  # Call callback AFTER releasing lock to avoid potential deadlocks
386
388
  if self.update_callback is not None:
387
389
  self.update_callback()
@@ -390,25 +392,26 @@ class EmeraldHWS():
390
392
  """ Subscribes to the MQTT topics for the supplied HWS
391
393
  :param id: The UUID of the requested HWS
392
394
  """
393
- if not self.mqttClient:
394
- self.connectMQTT()
395
+ with self._mqtt_lock:
396
+ if not self.mqttClient:
397
+ self.connectMQTT()
395
398
 
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)]))
399
+ mqtt_topic = "ep/heat_pump/from_gw/{}".format(id)
400
+ subscribe_future = self.mqttClient.subscribe(
401
+ subscribe_packet=mqtt5.SubscribePacket(
402
+ subscriptions=[mqtt5.Subscription(
403
+ topic_filter=mqtt_topic,
404
+ qos=mqtt5.QoS.AT_LEAST_ONCE)]))
402
405
 
403
- # Wait for subscription to complete
404
- subscribe_future.result(20)
406
+ # Wait for subscription to complete
407
+ subscribe_future.result(20)
405
408
 
406
409
  def getFullStatus(self, id):
407
410
  """ Returns a dict with the full status of the specified HWS
408
411
  :param id: UUID of the HWS to get the status for
409
412
  """
410
413
 
411
- if not self.properties:
414
+ if not self._is_connected:
412
415
  self.connect()
413
416
 
414
417
  with self._state_lock:
@@ -425,7 +428,7 @@ class EmeraldHWS():
425
428
  :param payload: JSON payload to send eg {"switch":1}
426
429
  """
427
430
 
428
- if not self.properties:
431
+ if not self._is_connected:
429
432
  self.connect()
430
433
 
431
434
  hwsdetail = self.getFullStatus(id)
@@ -443,11 +446,17 @@ class EmeraldHWS():
443
446
  payload
444
447
  ]
445
448
  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))
449
+
450
+ with self._mqtt_lock:
451
+ if not self.mqttClient:
452
+ raise Exception("MQTT client not connected")
453
+ publish_future = self.mqttClient.publish(
454
+ mqtt5.PublishPacket(
455
+ topic=mqtt_topic,
456
+ payload=json.dumps(msg),
457
+ qos=mqtt5.QoS.AT_LEAST_ONCE))
458
+
459
+ # Wait for publish to complete outside the lock
451
460
  publish_future.result(20) # 20 seconds
452
461
 
453
462
  def turnOn(self, id):
@@ -506,12 +515,12 @@ class EmeraldHWS():
506
515
  work_state = full_status.get("last_state").get("work_state")
507
516
  # work_state: 0=off/idle, 1=actively heating, 2=on but not heating
508
517
  return (work_state == 1)
509
-
518
+
510
519
  # Fallback to device_operation_status if work_state not available yet
511
520
  # (e.g., before first MQTT update after initialization)
512
521
  heating_status = full_status.get("device_operation_status")
513
522
  return (heating_status == 1)
514
-
523
+
515
524
  return False
516
525
 
517
526
  def getHourlyEnergyUsage(self, id):
@@ -562,12 +571,13 @@ class EmeraldHWS():
562
571
  def listHWS(self):
563
572
  """ Returns a list of UUIDs of all discovered HWS
564
573
  """
565
- if not self.properties:
574
+ if not self._is_connected:
566
575
  self.connect()
567
576
 
577
+ properties_list = self._wait_for_properties()
568
578
  hws = []
569
579
 
570
- for properties in self.properties:
580
+ for properties in properties_list:
571
581
  heat_pumps = properties.get('heat_pump', [])
572
582
  for heat_pump in heat_pumps:
573
583
  hws.append(heat_pump["id"])
@@ -578,10 +588,8 @@ class EmeraldHWS():
578
588
  """ Subscribes to updates from all detected HWS
579
589
  """
580
590
 
581
- if not self.properties:
582
- self.getAllHWS()
583
-
584
- for property in self.properties:
591
+ properties_list = self._wait_for_properties()
592
+ for property in properties_list:
585
593
  for hws in property.get("heat_pump"):
586
594
  self.subscribeForUpdates(hws.get("id"))
587
595
 
@@ -589,7 +597,27 @@ class EmeraldHWS():
589
597
  """ Connect to the API with the supplied credentials, retrieve HWS details
590
598
  :returns: True if successful
591
599
  """
592
- self.getLoginToken()
593
- self.getAllHWS()
594
- self.connectMQTT()
595
- self.subscribeAllHWS()
600
+ # Use lock to ensure only one thread can connect at a time
601
+ with self._connect_lock:
602
+ # Double-check pattern: check again inside the lock
603
+ if self._is_connected:
604
+ self.logger.debug("emeraldhws: Already connected, skipping")
605
+ return
606
+
607
+ self.logger.debug("emeraldhws: Connecting...")
608
+ self.getLoginToken()
609
+ self.getAllHWS()
610
+ self.connectMQTT()
611
+ self.subscribeAllHWS()
612
+ self._is_connected = True
613
+
614
+ # Start timers ONCE on initial connection
615
+ if self.connection_timeout > 0:
616
+ self.reconnect_timer = threading.Timer(self.connection_timeout, self.scheduled_reconnect)
617
+ self.reconnect_timer.daemon = True
618
+ self.reconnect_timer.start()
619
+
620
+ if self.health_check_interval > 0:
621
+ self.health_check_timer = threading.Timer(self.health_check_interval, self.check_connection_health)
622
+ self.health_check_timer.daemon = True
623
+ 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.19
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=Zew-LUriunMceTCRm28ysQ6-wL-wkS8WEWKCn_x9dXQ,27452
3
+ emerald_hws/__assets__/SFSRootCAG2.pem,sha256=hw9W0AnYrrlbcWsOewAgIl1ULEsoO57Ylu35dCjWcS4,1424
4
+ emerald_hws-0.0.19.dist-info/METADATA,sha256=I5ed2dPU2OO9eO9UMZmSKgscxm_uyqp2g9i3VGcKgYQ,2534
5
+ emerald_hws-0.0.19.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
6
+ emerald_hws-0.0.19.dist-info/top_level.txt,sha256=ZCiUmnBkDr2n4QVkTet1s_AKiGJjuz3heuCR5w5ZqLY,12
7
+ emerald_hws-0.0.19.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,,