emerald-hws 0.0.17__tar.gz → 0.0.19__tar.gz
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-0.0.17 → emerald_hws-0.0.19}/PKG-INFO +1 -1
- {emerald_hws-0.0.17 → emerald_hws-0.0.19}/src/emerald_hws/emeraldhws.py +156 -128
- {emerald_hws-0.0.17 → emerald_hws-0.0.19}/src/emerald_hws.egg-info/PKG-INFO +1 -1
- {emerald_hws-0.0.17 → emerald_hws-0.0.19}/.github/dependabot.yml +0 -0
- {emerald_hws-0.0.17 → emerald_hws-0.0.19}/.github/workflows/lint.yml +0 -0
- {emerald_hws-0.0.17 → emerald_hws-0.0.19}/.github/workflows/publish.yml +0 -0
- {emerald_hws-0.0.17 → emerald_hws-0.0.19}/.github/workflows/smoke-test.yml +0 -0
- {emerald_hws-0.0.17 → emerald_hws-0.0.19}/.gitignore +0 -0
- {emerald_hws-0.0.17 → emerald_hws-0.0.19}/LICENSE +0 -0
- {emerald_hws-0.0.17 → emerald_hws-0.0.19}/README.md +0 -0
- {emerald_hws-0.0.17 → emerald_hws-0.0.19}/pyproject.toml +0 -0
- {emerald_hws-0.0.17 → emerald_hws-0.0.19}/requirements.txt +0 -0
- {emerald_hws-0.0.17 → emerald_hws-0.0.19}/setup.cfg +0 -0
- {emerald_hws-0.0.17 → emerald_hws-0.0.19}/src/emerald_hws/__assets__/SFSRootCAG2.pem +0 -0
- {emerald_hws-0.0.17 → emerald_hws-0.0.19}/src/emerald_hws/__init__.py +0 -0
- {emerald_hws-0.0.17 → emerald_hws-0.0.19}/src/emerald_hws.egg-info/SOURCES.txt +0 -0
- {emerald_hws-0.0.17 → emerald_hws-0.0.19}/src/emerald_hws.egg-info/dependency_links.txt +0 -0
- {emerald_hws-0.0.17 → emerald_hws-0.0.19}/src/emerald_hws.egg-info/requires.txt +0 -0
- {emerald_hws-0.0.17 → emerald_hws-0.0.19}/src/emerald_hws.egg-info/top_level.txt +0 -0
@@ -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.
|
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.
|
145
|
+
with self._mqtt_lock:
|
146
|
+
self.logger.info(f"emeraldhws: awsiot: Reconnecting MQTT connection (reason: {reason})")
|
123
147
|
|
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()
|
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
|
-
|
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()
|
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
|
-
#
|
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
|
-
|
394
|
-
self.
|
395
|
+
with self._mqtt_lock:
|
396
|
+
if not self.mqttClient:
|
397
|
+
self.connectMQTT()
|
395
398
|
|
396
|
-
|
397
|
-
|
398
|
-
|
399
|
-
|
400
|
-
|
401
|
-
|
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
|
-
|
404
|
-
|
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.
|
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.
|
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
|
-
|
447
|
-
|
448
|
-
|
449
|
-
|
450
|
-
|
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.
|
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
|
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
|
-
|
582
|
-
|
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
|
-
|
593
|
-
self.
|
594
|
-
|
595
|
-
|
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()
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|