Coverage for /home/pi/Software/model-railway-signalling/model_railway_signals/library/mqtt_interface.py: 88%
251 statements
« prev ^ index » next coverage.py v7.2.7, created at 2024-04-10 15:08 +0100
« prev ^ index » next coverage.py v7.2.7, created at 2024-04-10 15:08 +0100
1#-----------------------------------------------------------------------------------------------
2# These functions provides a basic MQTT Client interface for the Model Railway Signalling Package,
3# allowing multiple signalling applications (running on different computers) to share a single
4# Pi-Sprog DCC interface and to share layout state and events across a MQTT broker network.
5#
6# For example, you could run one signalling application on a computer without a Pi-Sprog (e.g.
7# a Windows Laptop), configure that node to "publish" its DCC command feed to the network and
8# then configure another node (this time hosted on a Raspberry Pi) to "subscribe" to the same
9# DCC command feed and then forwarded to its local pi-Sprog DCC interface.
10#
11# You can also use these features to split larger layouts into multiple signalling areas whilst
12# still being able to implement a level of automation between them. Functions are provided to
13# publishing and subscribing to the "state" of signals (for updating signals based on the one
14# ahead), the "state" of track occupancy sections (for "passing" trains between signalling
15# applications) and "signal passed" events (also for track occupancy). MQTT networking is also
16# at the heart of the Block Instruments feature - allowing the different "signalling areas" to
17# communicate prototypically via signalbox bell codes and block section status.
18#
19# To use these networking functions, you can either set up a local MQTT broker on one of the host
20# computers on your local network or alternatively use an 'open source' broker out there on the
21# internet - I've been using a test broker at "mqtt.eclipseprojects.io" (note this has no security
22# or authentication).
23#
24# If you do intend using an internet-based broker then it is important to configure it with an
25# appropriate level of security. This package does support basic username/password authentication
26# for connecting in to the broker but note that these are NOT ENCRYPTED when sending over the
27# internet unless you are also using a SSL connection.
28#-----------------------------------------------------------------------------------------------
29#
30# External API - classes and functions (used by the Schematic Editor):
31#
32# configure_mqtt_client - Configures the local MQTT client and layout network node
33# Mandatory Parameters:
34# network_identifier:str - The name to use for this signalling network (any string)
35# node_identifier:str - The name to use for this node on the network (can be any string)
36# Optional Parameters:
37# mqtt_enhanced_debugging:bool - 'True' to enable additional debug logging (default = False)
38# publish_shutdown:bool - Publish a shutdown message on appication exit (default = False)
39# act_on_shutdown:bool - Make a callback if a shutdown message is received (default = False)
40# shutdown_callback - Callback to make on reciept of a shutdown message (default = None)
41#
42# mqtt_broker_connect - Opens a connection to a local or remote MQTT broker
43# Returns whether the connection was successful or not (True/False)
44# Mandatory Parameters:
45# broker_host:str - The name/IP address of the MQTT broker host to be used
46# Optional Parameters:
47# broker_port:int - The network port for the broker host (default = 1883)
48# broker_username:str - the username to log into the MQTT Broker (default = None)
49# broker_password:str - the password to log into the MQTT Broker (default = None)
50#
51# mqtt_broker_disconnect() - disconnect from the MQTT broker
52#
53# Classes and functions used by the other library modules:
54#
55# split_remote_item_identifier(item_identifier:str) - validates and decodes a remote item identifier
56# Returns [source_node:str, item_id:int] if valid or 'None' if invalid
57#
58# subscribe_to_mqtt_messages - subscribe to a specific message type from a library object on a remote node.
59# Mandatory Parameters:
60# message_type:str - The 'signalling message type' to subscribe to
61# item_node:str - The remote node (on the signalling network) for the Item
62# item_id:str - The Item ID to subscribe to (for the object on the remote node)
63# callback:int - The function to call (with the message) on reciept of messages
64# Optional Parameters:
65# subtopics:bool - Whether to subscribe to all subtopics (for DCC commands) - default False
66#
67# unsubscribe_from_message_type (message_type:str) - unsubscribe from a message type (all items on all nodes)
68#
69# send_mqtt_message - Sends a message out to the MQTT Broker (for consumption by other nodes)
70# Mandatory Parameters:
71# message_type:str - The 'signalling message type' to send
72# item_id:int - The Item ID (to identify the sending item)
73# data:dict - The data to send (a dict of key/value pairs)
74# Optional Parameters:
75# log_message:str - The log message to output when the message is sent - default None
76# retain:bool - Whether the message should be 'retained' by the broker- default False
77# subtopic:str - The optional subtopic to send the message - default None
78#
79# mqtt_shutdown() - Perform an orderly disconnection and shutdown (on application exit)
80#
81#-----------------------------------------------------------------------------------------------
83from . import common
84import json
85import logging
86import time
87import paho.mqtt.client
88import threading
89import socket
91#-----------------------------------------------------------------------------------------------
92# Define an empty dictionary for holding the basic configuration information we need to track
93#-----------------------------------------------------------------------------------------------
95node_config: dict = {}
96node_config["mqtt_client_debug"] = False # Set to True to debug the PAHO MQTT client
97node_config["heartbeat_frequency"] = 4.0 # Constant of 4 seconds
98node_config["network_identifier"] = "" # Set by configure_mqtt_client (user defined)
99node_config["node_identifier"] = "" # Set by configure_mqtt_client (user defined)
100node_config["enhanced_debugging"] = False # Set by configure_mqtt_client (user defined)
101node_config["act_on_shutdown"] = False # Set by configure_mqtt_client (user defined)
102node_config["publish_shutdown"] = False # Set by configure_mqtt_client (user defined)
103node_config["shutdown_callback"] = None # Set by configure_mqtt_client (user defined)
104node_config["local_ip_address"] = "" # Set by the 'on_connect' function
105node_config["connected_to_broker"] = False # Set by the 'on_connect' / 'on_disconnect functions
106node_config["terminate_heartbeat_thread"] = False # Used to coordinate disconnect between threads
107node_config["heartbeat_thread_terminated"] = True # Used to coordinate disconnect between threads
108node_config["list_of_published_topics"] = []
109node_config["list_of_subscribed_topics"] = []
110node_config["callbacks"] = {}
112#-----------------------------------------------------------------------------------------------
113# The MQTT client is held globally:
114#-----------------------------------------------------------------------------------------------
116mqtt_client = None
118#-----------------------------------------------------------------------------------------------
119# Internal dict to hold details of the heartbeats received from other nodes
120# The dict contains entries comprising {["node"]: time_stamp_of_last_heartbeat}
121#-----------------------------------------------------------------------------------------------
123heartbeats = {}
125#-----------------------------------------------------------------------------------------------
126# API function used by the editor to get the list of connected nodes and when they were last seen
127#-----------------------------------------------------------------------------------------------
129def get_node_status():
130 return (heartbeats)
132#-----------------------------------------------------------------------------------------------
133# Common function used by the main thread to wait for responses in other threads. When the
134# specified function returns True within the timeout period, the function exits and returns
135# True. If the end of the timeout period is reached beforehand then the function returns False
136#-----------------------------------------------------------------------------------------------
138def wait_for_response(timeout:float,test_for_response_function):
139 response_received = False
140 timeout_start = time.time()
141 while time.time() < timeout_start + timeout:
142 response_received = test_for_response_function()
143 if response_received: break
144 time.sleep(0.001)
145 return(response_received)
147#-----------------------------------------------------------------------------------------------
148# Find the local IP address (to include in the heartbeat messages):
149# This will return the assigned IP address if we are connected to a network
150#-----------------------------------------------------------------------------------------------
152def find_local_ip_address():
153 test_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
154 try:
155 test_socket.connect(('10.255.255.255', 1))
156 ip_address = test_socket.getsockname()[0]
157 logging.debug("MQTT-Client: Local IP address is "+ip_address)
158 except:
159 logging.error("MQTT-Client: Could not retrieve local IP address")
160 ip_address = "<unknown>"
161 finally:
162 test_socket.close()
163 return(ip_address)
165#-----------------------------------------------------------------------------------------------
166# Internal thread to send out heartbeat messages from the node when connected. The thread is
167# started by the 'mqtt_broker_connect' function and terminated by the 'mqtt_broker_disconnect'
168# function (by setting the 'terminate_heartbeat_thread' flag to True). The flag is then reset
169# to False on the next 'mqtt_broker_connect' (prior to starting a new Thread)
170#-----------------------------------------------------------------------------------------------
172def publish_heartbeat_message():
173 if node_config["connected_to_broker"]:
174 # Topic format for the heartbeat message: "<Message-Type>/<Network-ID>"
175 topic = "heartbeat"+"/"+node_config["network_identifier"]
176 # Payload for the heartbeat message is a dictionary comprising the source node
177 heartbeat_message = {"node":node_config["node_identifier"],"ip":node_config["local_ip_address"]}
178 payload = json.dumps(heartbeat_message)
179 # The PAHO MQTT client may not be thread safe so publish the message from the main Tkinter thread
180 try:
181 mqtt_client.publish(topic,payload,retain=False,qos=1)
182 if node_config["enhanced_debugging"]: logging.debug("MQTT-Client: Publishing: "+str(topic)+str(payload))
183 except Exception as exception:
184 logging.error("MQTT-Client: Error publishing Heartbeat message - "+str(exception))
185 return()
187def thread_to_send_heartbeat_messages():
188 global node_config
189 node_config["heartbeat_thread_terminated"] = False
190 while not node_config["terminate_heartbeat_thread"]:
191 # The PAHO MQTT client may not be thread safe so publish the message from the main Tkinter thread
192 common.execute_function_in_tkinter_thread(publish_heartbeat_message)
193 # Wait before we send out the next heartbeat
194 last_heartbeat_time = time.time()
195 while (time.time() < last_heartbeat_time + node_config["heartbeat_frequency"]
196 and not node_config["terminate_heartbeat_thread"]):
197 time.sleep(0.001)
198 if node_config["enhanced_debugging"]: logging.debug("MQTT-Client: Heartbeat Thread - exiting")
199 node_config["heartbeat_thread_terminated"] = True
200 return()
202# ---------------------------------------------------------------------------------------------
203# Common Function to create a external item identifier from the Item_ID and the remote Node.
204# This identifier can then be used as the "key" to look up the Item in the associated dictionary
205# ---------------------------------------------------------------------------------------------
207def create_remote_item_identifier(item_id:int,node:str = None):
208 return (node+"-"+str(item_id))
210# ---------------------------------------------------------------------------------------------
211# API and Common Function to extract the the item-ID (int) and Node-ID (str) from a compound
212# identifierand return them to the calling programme - Will return None if the conversion
213# fails - hence this function can also be used for validating remote item identifiers
214# ---------------------------------------------------------------------------------------------
216def split_remote_item_identifier(item_identifier:str):
217 return_value = None
218 if isinstance(item_identifier,str) and "-" in item_identifier:
219 node_id = item_identifier.rpartition("-")[0]
220 item_id = item_identifier.rpartition("-")[2]
221 if node_id != "" and item_id.isdigit() and int(item_id) > 0 and int(item_id) < 999:
222 return_value = [node_id,int(item_id)]
223 return (return_value)
225#-----------------------------------------------------------------------------------------------
226# Internal call-back to process mqtt log messages (only called if enhanced_debugging is set)
227#-----------------------------------------------------------------------------------------------
229def on_log(mqtt_client, obj, level, mqtt_log_message):
230 if node_config["mqtt_client_debug"]: logging.debug("MQTT-Client: "+mqtt_log_message)
231 return()
233#-----------------------------------------------------------------------------------------------
234# Internal call-back to process broker disconnection events
235#-----------------------------------------------------------------------------------------------
237def on_disconnect(mqtt_client, userdata, rc):
238 global node_config
239 node_config["connected_to_broker"] = False
240 if rc==0: logging.info("MQTT-Client - Broker connection successfully terminated") 240 ↛ 241line 240 didn't jump to line 241, because the condition on line 240 was never false
241 else: logging.warning("MQTT-Client: Unexpected disconnection from broker")
242 node_config["connected_to_broker"] = False
243 return()
245#-----------------------------------------------------------------------------------------------
246# Internal call-back to process broker connection / re-connection events
247#-----------------------------------------------------------------------------------------------
249def on_connect(mqtt_client, userdata, flags, rc):
250 global node_config
251 if rc == 0: 251 ↛ 277line 251 didn't jump to line 277, because the condition on line 251 was never false
252 node_config["connected_to_broker"] = True
253 logging.info("MQTT-Client - Successfully connected to MQTT Broker")
254 # find the assigned IP address of the machine we are running on (for the heartbeat messages)
255 node_config["local_ip_address"] = find_local_ip_address()
256 # Pause just to ensure that MQTT is all fully up and running before we continue (and allow the client
257 # to set up any subscriptions or publish any messages to the broker). We shouldn't need to do this but
258 # I've experienced problems running on a Windows 10 platform if we don't include a short sleep
259 time.sleep(0.1)
260 # As we set up our broker connection with 'cleansession=true' a disconnection will have removed
261 # all client connection information from the broker (including knowledge of the topics we have
262 # subscribed to) - we therefore need to re-subscribe to all topics with this new connection
263 # Note that this means we will immediately receive all retained messages for those topics
264 if len(node_config["list_of_subscribed_topics"]) > 0:
265 if node_config["enhanced_debugging"]: logging.debug("MQTT-Client: Re-subscribing to all MQTT broker topics")
266 for topic in node_config["list_of_subscribed_topics"]:
267 if node_config["enhanced_debugging"]: logging.debug("MQTT-Client: Subscribing to: "+topic)
268 mqtt_client.subscribe(topic)
269 # Re subscribe to all heartbeat and shutdown messages on the specified network
270 # Topic format for these messages is: "<Message-Type>/<Network-ID>"
271 heartbeat_topic = "heartbeat"+"/"+node_config["network_identifier"]
272 if node_config["enhanced_debugging"]: logging.debug("MQTT-Client: Subscribing to: "+heartbeat_topic)
273 mqtt_client.subscribe(heartbeat_topic)
274 shutdown_topic = "shutdown"+"/"+node_config["network_identifier"]
275 if node_config["enhanced_debugging"]: logging.debug("MQTT-Client: Subscribing to: "+shutdown_topic)
276 mqtt_client.subscribe(shutdown_topic)
277 elif rc == 1: logging.error("MQTT-Client: Connection refused – incorrect protocol version")
278 elif rc == 2: logging.error("MQTT-Client: Connection refused – invalid client identifier")
279 elif rc == 3: logging.error("MQTT-Client: Connection refused – server unavailable")
280 elif rc == 4: logging.error("MQTT-Client: Connection refused – bad username or password")
281 elif rc == 5: logging.error("MQTT-Client: Connection refused – not authorised")
282 return()
284#--------------------------------------------------------------------------------------------------------
285# Internal function to process messages received from the MQTT Broker - unpacking the message and then
286# making the registered callback to pass the message back to the main application. Note that this function
287# is executed in the main tkinter thread (as long as we know the main root window) to make it threadsafe
288# If we don't know the main root window then the function is executed in the current mqtt event thread.
289#--------------------------------------------------------------------------------------------------------
291def process_message(msg):
292 global heartbeats
293 # Unpack the json message so we can extract the contents (with exception handling)
294 try:
295 unpacked_json = json.loads(msg.payload)
296 except Exception as exception:
297 logging.error("MQTT-Client: Exception unpacking json - "+str(exception))
298 else:
299 if node_config["enhanced_debugging"]:
300 logging.debug("MQTT-Client: Received: "+str(msg.topic)+str(unpacked_json))
301 # If it is a heartbeat message then we just update the list of connected nodes
302 if msg.topic.startswith("heartbeat"):
303 heartbeats[unpacked_json["node"]] = [unpacked_json["ip"], int(time.time())]
304 # If it is a shutdown message we only act on it if configured to do so
305 elif msg.topic.startswith("shutdown"):
306 if node_config["act_on_shutdown"] and node_config["shutdown_callback"] is not None: 306 ↛ 309line 306 didn't jump to line 309, because the condition on line 306 was never false
307 logging.info("MQTT-Client - Shutdown message received - Triggering application shutdown")
308 node_config["shutdown_callback"]()
309 elif node_config["enhanced_debugging"]:
310 logging.debug("MQTT-Client: Ignoring Shutdown message (not configured to shutdown)")
311 # Make the callback (that was registered when the calling programme subscribed to the feed)
312 # Note that we also need to test to see if the the topic is a partial match to cover the
313 # case of subscribing to all subtopics for an specified item (with the '+' wildcard)
314 elif msg.topic in node_config["callbacks"]:
315 node_config["callbacks"][msg.topic] (unpacked_json)
316 elif msg.topic.rpartition('/')[0]+"/+" in node_config["callbacks"]: 316 ↛ 319line 316 didn't jump to line 319, because the condition on line 316 was never false
317 node_config["callbacks"][msg.topic.rpartition('/')[0]+"/+"] (unpacked_json)
318 else:
319 logging.warning("MQTT-Client: unhandled message topic: "+str(msg.topic))
320 return()
322#--------------------------------------------------------------------------------------------------------
323# Internal function to handle messages received from the MQTT Broker. Note that we "pass" the
324# execution for processing the function back into the main Tkinter thread (assuming we know the
325# root window (if not, then as a fallback we raise a callback in the current mqtt event thread)
326#--------------------------------------------------------------------------------------------------------
328def on_message(mqtt_client,obj,msg):
329 global node_config
330 # Only process the message if there is a payload - If there is no payload then the message is
331 # a "null message" - sent to purge retained messages from the broker on application exit
332 if msg.payload: common.execute_function_in_tkinter_thread (lambda:process_message(msg))
333 return()
335#-----------------------------------------------------------------------------------------------
336# API Function to configure the MQTT client for this particular network and node
337#-----------------------------------------------------------------------------------------------
339def configure_mqtt_client (network_identifier:str,
340 node_identifier:str,
341 enhanced_debugging:bool = False,
342 publish_shutdown:bool = False,
343 act_on_shutdown:bool = False,
344 shutdown_callback=None):
345 global node_config
346 # Validate the parameters we have been given
347 if not isinstance(network_identifier, str):
348 logging.error("MQTT-Client: configure_mqtt_client - Network Identifier must be specified as a string")
349 elif not isinstance(node_identifier, str):
350 logging.error("MQTT-Client: configure_mqtt_client - Node Identifier must be specified as a string")
351 elif not isinstance(enhanced_debugging, bool):
352 logging.error("MQTT-Client: configure_mqtt_client - Enhanced debugging flag must be specified as a boolean")
353 elif not isinstance(publish_shutdown, bool):
354 logging.error("MQTT-Client: configure_mqtt_client - Publish shutdown flag must be specified as a boolean")
355 elif not isinstance(act_on_shutdown, bool):
356 logging.error("MQTT-Client: configure_mqtt_client - Act on shutdown flag must be specified as a boolean")
357 else:
358 logging.debug("MQTT-Client: Configuring MQTT Client for "+network_identifier+":"+node_identifier)
359 # Configure this module (to enable subscriptions to be configured even if not connected)
360 node_config["enhanced_debugging"] = enhanced_debugging
361 node_config["network_identifier"] = network_identifier
362 node_config["node_identifier"] = node_identifier
363 node_config["publish_shutdown"] = publish_shutdown
364 node_config["act_on_shutdown"] = act_on_shutdown
365 node_config["shutdown_callback"] = shutdown_callback
366 return()
368#-----------------------------------------------------------------------------------------------
369# API Function to connect and/or re-connect to an external MQTT broker instance
370# Returns True if connection was successful (otherwise False)
371#
372# A few notes about disconnecting and then re-connecting from the broker:
373#
374# The main application allows the user to disconnect/reconnect at will (a likely use case when
375# configuring the application for networking for the first time). From reading the PAHO MQTT
376# documentation, disconnect() should perform a clean disconnect from the broker, and my
377# assumption was therefore that you should be able to disconnect and all connect() with
378# updated settings to establish a new connection (note I couldn't use reconnect() as I
379# wanted to allow the broker host/port settings to be updated).
380#
381# However, I found that after a few disconnect/reconnect cycles (in relatively quick succession)
382# the connect() function just timed out (and would time out each subsequent time it was called).
383#
384# My workaround was to completely kill off the currrent mqtt client (stop the loop) following
385# a successful disconnect and then create a new class instance for the new session - hoping the
386# old instance will get garbage collected in due course.
387#
388# I don't like this soultion, but it works. I've seen an occasional disconnect timeout, but in
389# this case the old session remains active and a reconnection (with the new settings) isn't
390# attempted. the next time disconnect/reconnect is initialted it all works.
391#
392# The second intermittent issue I had was either the disconnect() or loop_stop() calls hanging
393# if I didn't clear out the message queues prior to disconnect - although as this code includes
394# a sleep, it could be some sort of timing issue - I've never been able to reliably reproduce
395# so can't be sure - all I know is the code as-is seems to hang together relaibly
396#-----------------------------------------------------------------------------------------------
398def mqtt_broker_connect (broker_host:str,
399 broker_port:int = 1883,
400 broker_username:str = None,
401 broker_password:str = None):
402 global mqtt_client
403 def connect_acknowledgement(): return (node_config["connected_to_broker"])
404 # Validate the parameters we have been given
405 if not isinstance(broker_host, str):
406 logging.error("MQTT-Client: configure_mqtt_client - Broker Host must be specified as a string")
407 elif not isinstance(broker_port, int):
408 logging.error("MQTT-Client: configure_mqtt_client - Broker Port must be specified as an integer")
409 elif broker_username is not None and not isinstance(broker_username, str):
410 logging.error("MQTT-Client: configure_mqtt_client - Broker Username must be specified as None or a string")
411 elif broker_password is not None and not isinstance(broker_password, str):
412 logging.error("MQTT-Client: configure_mqtt_client - Broker Password must be specified as None or a string")
413 else:
414 # Handle the case where we are already connected to the broker
415 if node_config["connected_to_broker"]: mqtt_broker_disconnect()
416 # Do some basic exception handling around opening the broker connection
417 logging.debug("MQTT-Client: Connecting to Broker "+broker_host+":"+str(broker_port))
418 # Create a new mqtt broker instance
419 if mqtt_client is None: mqtt_client = paho.mqtt.client.Client(clean_session=True)
420 mqtt_client.on_message = on_message
421 mqtt_client.on_connect = on_connect
422 mqtt_client.on_disconnect = on_disconnect
423 mqtt_client.reconnect_delay_set(min_delay=1, max_delay=10)
424 mqtt_client.on_log = on_log
425 # Configure the basic username/password authentication (if required)
426 if broker_username is not None: 426 ↛ 428line 426 didn't jump to line 428, because the condition on line 426 was never false
427 mqtt_client.username_pw_set(username=broker_username,password=broker_password)
428 try:
429 mqtt_client.connect_async(broker_host,port=broker_port,keepalive = 10)
430 mqtt_client.loop_start()
431 except Exception as exception:
432 logging.error("MQTT-Client: Error connecting to broker: "+str(exception))
433 else:
434 # Wait for connection acknowledgement (from on-connect callback function)
435 if wait_for_response(1.0, connect_acknowledgement): 435 ↛ 445line 435 didn't jump to line 445, because the condition on line 435 was never false
436 if node_config["heartbeat_thread_terminated"]: 436 ↛ 446line 436 didn't jump to line 446, because the condition on line 436 was never false
437 if node_config["enhanced_debugging"]: logging.debug("MQTT-Client: Starting heartbeat thread")
438 # Reset the broker disconnect initiated flag
439 node_config["terminate_heartbeat_thread"] = False
440 # Start the heartbeat thread
441 heartbeat_thread = threading.Thread (target=thread_to_send_heartbeat_messages)
442 heartbeat_thread.setDaemon(True)
443 heartbeat_thread.start()
444 else:
445 logging.error("MQTT-Client: Timeout connecting to broker")
446 return(node_config["connected_to_broker"])
448#-----------------------------------------------------------------------------------------------
449# API Function to disconnect from an external MQTT broker instance
450# Returns True if disconnection was successful (otherwise False)
451#-----------------------------------------------------------------------------------------------
453def mqtt_broker_disconnect():
454 global node_config
455 global mqtt_client
456 def disconnect_acknowledgement(): return (not node_config["connected_to_broker"])
457 def heartbeat_thread_terminated(): return (node_config["heartbeat_thread_terminated"])
458 if node_config["connected_to_broker"]: 458 ↛ 483line 458 didn't jump to line 483, because the condition on line 458 was never false
459 # Set the flag to tell the heartbeat thread to terminate
460 node_config["terminate_heartbeat_thread"] = True
461 logging.debug("MQTT-Client: Initiating broker disconnect")
462 # Wait until we get confirmation the Heartbeat thread has terminated
463 if node_config["enhanced_debugging"]: logging.debug("MQTT-Client: Shutting down Heartbeat thread")
464 if not wait_for_response(0.5, heartbeat_thread_terminated):
465 logging.error("MQTT-Client: Heartbeat thread failed to terminate")
466 # Clean out the message queues on the broker by publishing null messages (empty strings)
467 # to each of the topics that we have sent messages to during the lifetime of the session
468 logging.debug("MQTT-Client: Clearing message queues before disconnect")
469 for topic in node_config["list_of_published_topics"]:
470 if node_config["enhanced_debugging"]: logging.debug("MQTT-Client: Publishing: "+str(topic)+"-NULL")
471 mqtt_client.publish(topic,payload=None,retain=False,qos=1)
472 # Wait for everything to be published to the broker (with a sleep) and disconnect
473 # I'd rather use a PAHO MQTT check and timeout but there doesn't seem to be one
474 time.sleep(0.25)
475 logging.debug("MQTT-Client: Disconnecting from broker")
476 mqtt_client.disconnect()
477 # Wait for disconnection acknowledgement (from on-disconnect callback function)
478 if wait_for_response(1.0, disconnect_acknowledgement): 478 ↛ 482line 478 didn't jump to line 482, because the condition on line 478 was never false
479 mqtt_client.loop_stop()
480 mqtt_client = None
481 else:
482 logging.error("MQTT-Client: Timeout disconnecting from broker")
483 return(not node_config["connected_to_broker"])
485#-----------------------------------------------------------------------------------------------
486# Externally called function to perform a graceful shutdown of MQTT networking on Application
487# Exit in terms of clearing out the publish topic queues (by sending null messages). If configured
488# The function also sends out a 'shutdown' message to other network nodes for them to act on.
489#-----------------------------------------------------------------------------------------------
491def mqtt_shutdown():
492 global node_config
493 if node_config["connected_to_broker"]:
494 # Publish a shutdown command to other nodes if configured to do so
495 if node_config["publish_shutdown"]: 495 ↛ 504line 495 didn't jump to line 504, because the condition on line 495 was never false
496 # Topic format for the shutdown message: "<Message-Type>/<Network-ID>"
497 topic = "shutdown"+"/"+node_config["network_identifier"]
498 # Payload for the shutdown message is a dictionary comprising the source node
499 shutdown_message = {"node":node_config["node_identifier"]}
500 payload = json.dumps(shutdown_message)
501 if node_config["enhanced_debugging"]: logging.debug("MQTT-Client: Publishing: "+str(topic)+str(payload))
502 mqtt_client.publish(topic,payload,retain=False,qos=1)
503 time.sleep(0.1)
504 mqtt_broker_disconnect()
505 return()
507#-----------------------------------------------------------------------------------------------
508# Externally Called Function to subscribe to topics published by the MQTT broker. This function
509# takes in a string that defines the application-specific message type and converts this into
510# a fully qualified MQTT topic (using the Node and item ID). The registered callback will return
511# the content of the received messages. The optional subtopic flag enables you to subscribe
512# to all sub-topics from a particular item. This is used in the Model Railway Signalling Package
513# for subscribing to all DCC address messages (where each DCC address is a seperate subtopic)
514#-----------------------------------------------------------------------------------------------
516def subscribe_to_mqtt_messages (message_type:str,item_node:str,item_id:int,callback,subtopics:bool=False):
517 global node_config
518 global mqtt_client
519 # The Identifier for a remote object is a string combining the the Node-ID and Object-ID
520 item_identifier = create_remote_item_identifier(item_id,item_node)
521 # Topic format: "<Message-Type>/<Network-ID>/<Item_Identifier>/<optional-subtopic>"
522 topic = message_type+"/"+node_config["network_identifier"]+"/"+item_identifier
523 if subtopics: topic = topic+"/+"
524 logging.debug("MQTT-Client: Subscribing to topic '"+topic+"'")
525 # Only subscribe if connected to the broker(if the client is disconnected
526 # from the broker then all subscriptions will already have been terminated)
527 if node_config["connected_to_broker"]: mqtt_client.subscribe(topic)
528 # Add to the list of subscribed topics (so we can re-subscribe on reconnection)
529 node_config["list_of_subscribed_topics"].append(topic)
530 # Save the callback details for when we receive a message on the topic
531 node_config["callbacks"][topic] = callback
532 return()
534#-----------------------------------------------------------------------------------------------
535# Externally Called Function to Publish a message to the MQTT broker. This function takes
536# in a string that defines the application-specific message type and converts this into
537# a fully qualified MQTT topic. Data items are passed in as a list and converted to json
538# An optional 'info' log message can also be passed in (logged just prior to publishing)
539#-----------------------------------------------------------------------------------------------
541def send_mqtt_message (message_type:str,item_id:int,data:dict,log_message:str=None,retain:bool=False,subtopic=None):
542 # Only publish the broker if we are connected
543 if node_config["connected_to_broker"]:
544 item_identifier = create_remote_item_identifier(item_id,node_config["node_identifier"])
545 # Topic format: "<Message-Type>/<Network-ID>/<Item_Identifier>/<optional-subtopic>"
546 topic = message_type+"/"+node_config["network_identifier"]+"/"+item_identifier
547 if subtopic is not None: topic = topic+"/"+subtopic
548 data["sourceidentifier"] = item_identifier
549 payload = json.dumps(data)
550 if log_message is not None: logging.debug(log_message)
551 if node_config["enhanced_debugging"]:
552 logging.debug("MQTT-Client: Publishing: "+str(topic)+str(payload))
553 # Publish the message to the broker
554 mqtt_client.publish(topic,payload,retain=retain,qos=1)
555 # Add to the list of published topics so we can 'Clean up'
556 # the MQTT broker by publishing empty messages on shutdown
557 if topic not in node_config["list_of_published_topics"]:
558 node_config["list_of_published_topics"].append(topic)
559 return()
561#-----------------------------------------------------------------------------------------------
562# Externally called Function to unsubscribe to a particular message type from the broker. Called by
563# the higher-level 'reset_mqtt_configuration' functions for instruments, signals and sections to
564# clear out the relevant subscrptions in support of the editor - where a configuration change
565# to MQTT networking will trigger a reset of all subscriptions followed by a re-configuration
566#-----------------------------------------------------------------------------------------------
568def unsubscribe_from_message_type(message_type:str):
569 global node_config
570 # Topic format: "<Message-Type>/<Network-ID>/<Item_Identifier>/<optional-subtopic>"
571 # Finally, remove all instances of the message type from the internal subscriptions list
572 # Note we don't iterate through the list to remove items as it will change under us
573 new_list_of_subscribed_topics = []
574 for subscribed_topic in node_config["list_of_subscribed_topics"]:
575 if subscribed_topic.startswith(message_type):
576 if node_config["enhanced_debugging"]:
577 logging.debug("MQTT-Client: Unsubscribing from topic '"+subscribed_topic+"'")
578 # Only unsubscribe if connected to the broker(if the client is disconnected
579 # from the broker then all subscriptions will already have been terminated)
580 if node_config["connected_to_broker"]: mqtt_client.unsubscribe(subscribed_topic)
581 else:
582 new_list_of_subscribed_topics.append(subscribed_topic)
583 node_config["list_of_subscribed_topics"] = new_list_of_subscribed_topics
584 return()
586##################################################################################################################