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 +132 -122
- {emerald_hws-0.0.17.dist-info → emerald_hws-0.0.18.dist-info}/METADATA +1 -1
- emerald_hws-0.0.18.dist-info/RECORD +7 -0
- emerald_hws-0.0.17.dist-info/RECORD +0 -7
- {emerald_hws-0.0.17.dist-info → emerald_hws-0.0.18.dist-info}/WHEEL +0 -0
- {emerald_hws-0.0.17.dist-info → emerald_hws-0.0.18.dist-info}/top_level.txt +0 -0
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.
|
126
|
+
with self._mqtt_lock:
|
127
|
+
self.logger.info(f"emeraldhws: awsiot: Reconnecting MQTT connection (reason: {reason})")
|
123
128
|
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
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
|
-
|
162
|
-
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
|
178
|
-
|
179
|
-
|
180
|
-
|
181
|
-
|
182
|
-
|
183
|
-
|
184
|
-
|
185
|
-
|
186
|
-
|
187
|
-
|
188
|
-
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
|
195
|
-
|
196
|
-
|
197
|
-
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
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
|
-
#
|
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
|
-
|
394
|
-
self.
|
376
|
+
with self._mqtt_lock:
|
377
|
+
if not self.mqttClient:
|
378
|
+
self.connectMQTT()
|
395
379
|
|
396
|
-
|
397
|
-
|
398
|
-
|
399
|
-
|
400
|
-
|
401
|
-
|
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
|
-
|
404
|
-
|
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.
|
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.
|
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
|
-
|
447
|
-
|
448
|
-
|
449
|
-
|
450
|
-
|
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.
|
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
|
-
|
593
|
-
self.
|
594
|
-
|
595
|
-
|
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()
|
@@ -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,,
|
File without changes
|
File without changes
|