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

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#----------------------------------------------------------------------------------------------- 

82 

83from . import common 

84import json 

85import logging 

86import time 

87import paho.mqtt.client 

88import threading 

89import socket 

90 

91#----------------------------------------------------------------------------------------------- 

92# Define an empty dictionary for holding the basic configuration information we need to track 

93#----------------------------------------------------------------------------------------------- 

94 

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"] = {} 

111 

112#----------------------------------------------------------------------------------------------- 

113# The MQTT client is held globally: 

114#----------------------------------------------------------------------------------------------- 

115 

116mqtt_client = None 

117 

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#----------------------------------------------------------------------------------------------- 

122 

123heartbeats = {} 

124 

125#----------------------------------------------------------------------------------------------- 

126# API function used by the editor to get the list of connected nodes and when they were last seen 

127#----------------------------------------------------------------------------------------------- 

128 

129def get_node_status(): 

130 return (heartbeats) 

131 

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#----------------------------------------------------------------------------------------------- 

137 

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) 

146 

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#----------------------------------------------------------------------------------------------- 

151 

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) 

164 

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#----------------------------------------------------------------------------------------------- 

171 

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() 

186 

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() 

201 

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# --------------------------------------------------------------------------------------------- 

206 

207def create_remote_item_identifier(item_id:int,node:str = None): 

208 return (node+"-"+str(item_id)) 

209 

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# --------------------------------------------------------------------------------------------- 

215 

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) 

224 

225#----------------------------------------------------------------------------------------------- 

226# Internal call-back to process mqtt log messages (only called if enhanced_debugging is set) 

227#----------------------------------------------------------------------------------------------- 

228 

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() 

232 

233#----------------------------------------------------------------------------------------------- 

234# Internal call-back to process broker disconnection events 

235#----------------------------------------------------------------------------------------------- 

236 

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() 

244 

245#----------------------------------------------------------------------------------------------- 

246# Internal call-back to process broker connection / re-connection events 

247#----------------------------------------------------------------------------------------------- 

248 

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() 

283 

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#-------------------------------------------------------------------------------------------------------- 

290 

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() 

321 

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#-------------------------------------------------------------------------------------------------------- 

327 

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() 

334 

335#----------------------------------------------------------------------------------------------- 

336# API Function to configure the MQTT client for this particular network and node 

337#----------------------------------------------------------------------------------------------- 

338 

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() 

367 

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#----------------------------------------------------------------------------------------------- 

397 

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"]) 

447 

448#----------------------------------------------------------------------------------------------- 

449# API Function to disconnect from an external MQTT broker instance 

450# Returns True if disconnection was successful (otherwise False) 

451#----------------------------------------------------------------------------------------------- 

452 

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"]) 

484 

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#----------------------------------------------------------------------------------------------- 

490 

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() 

506 

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#----------------------------------------------------------------------------------------------- 

515 

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() 

533 

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#----------------------------------------------------------------------------------------------- 

540 

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() 

560 

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#----------------------------------------------------------------------------------------------- 

567 

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() 

585 

586##################################################################################################################