Coverage for /home/pi/Software/model-railway-signalling/model_railway_signals/library/track_sections.py: 71%
190 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# This module is used for creating and managing Track Occupancy objects (sections)
3# --------------------------------------------------------------------------------
4#
5# Public types and functions:
6#
7# section_callback_type (tells the calling program what has triggered the callback):
8# section_callback_type.section_updated - The section has been updated by the user
9#
10# create_section - Creates a Track Occupancy section object
11# Mandatory Parameters:
12# Canvas - The Tkinter Drawing canvas on which the section is to be displayed
13# section_id:int - The ID to be used for the section
14# x:int, y:int - Position of the section on the canvas (in pixels)
15# Optional Parameters:
16# section_callback - The function to call if the section is updated - default = None
17# Note that the callback function returns (item_id, callback type)
18# editable:bool - If the section can be manually toggled and/or edited (default = True)
19# label:str - The label to display on the section when occupied - default: "OCCUPIED"
20#
21# section_occupied (section_id:int/str)- Returns the section state (True=Occupied, False=Clear)
22# The Section ID can either be specified as an integer representing the ID of a
23# section created on our schematic, or a string representing the compound
24# identifier of a section on an remote MQTT network node.
25#
26# section_label (section_id:int/str)- Returns the 'label' of the section (as a string)
27# The Section ID can either be specified as an integer representing the ID of a
28# section created on our schematic, or a string representing the compound
29# identifier of a section on an remote MQTT network node.
30#
31# set_section_occupied - Sets the section to "OCCUPIED" (and updates the 'label' if required)
32# Mandatory Parameters:
33# section_id:int - The ID to be used for the section
34# Optional Parameters:
35# label:str - An updated label to display when occupied (Default = No Change)
36# publish:bool - Publish updates via MQTT Broker (Default = True)
37#
38# clear_section_occupied - Sets the section to "CLEAR" (and updates the 'label' if required)
39# Returns the current value of the Section Lable (as a string) to allow this
40# to be 'passed' to the next section (via the set_section_occupied function)
41# Mandatory Parameters:
42# section_id:int - The ID to be used for the section
43# Optional Parameters:
44# label:str - An updated label to display when occupied (Default = No Change)
45# publish:bool - Publish updates via MQTT Broker (Default = True)
46#
47# ------------------------------------------------------------------------------------------
48#
49# The following functions are associated with the MQTT networking Feature:
50##
51# subscribe_to_remote_section - Subscribes to a remote track section object
52# Mandatory Parameters:
53# remote_identifier:str - the remote identifier for the track section in the form 'node-id'
54# Optional Parameters:
55# section_callback - Function to call when a track section update is received - default = None
56#
57# set_sections_to_publish_state - Enable the publication of state updates for track sections.
58# All subsequent changes will be automatically published to remote subscribers
59# Mandatory Parameters:
60# *sec_ids:int - The track sections to publish (multiple Section_IDs can be specified)
61#
62# --------------------------------------------------------------------------------
64from . import common
65from . import mqtt_interface
66from . import file_interface
67from typing import Union
68import tkinter as Tk
69import enum
70import logging
72# -------------------------------------------------------------------------
73# Classes used by external functions when using track sections
74# -------------------------------------------------------------------------
76class section_callback_type(enum.Enum):
77 section_updated = 21 # The section has been updated by the user
79# -------------------------------------------------------------------------
80# sections are to be added to a global dictionary when created
81# -------------------------------------------------------------------------
83sections: dict = {}
85# -------------------------------------------------------------------------
86# Global variables used by the Track Sections Module
87# -------------------------------------------------------------------------
89# Global references to the Tkinter Entry box and the associated window
90text_entry_box = None
91entry_box_window = None
92# Global list of track sections to publish to the MQTT Broker
93list_of_sections_to_publish=[]
95# -------------------------------------------------------------------------
96# The default "External" callback for the section buttons
97# Used if this is not specified when the section is created
98# -------------------------------------------------------------------------
100def null_callback(section_id:int, callback_type):
101 return(section_id, callback_type)
103# -------------------------------------------------------------------------
104# Internal Function to check if a section exists in the list of section
105# Used in Most externally-called functions to validate the section ID
106# -------------------------------------------------------------------------
108def section_exists(section_id:int):
109 return (str(section_id) in sections.keys() )
111# -------------------------------------------------------------------------
112# Callback for processing Button presses (manual toggling of Track Sections)
113# -------------------------------------------------------------------------
115def section_button_event (section_id:int):
116 logging.info ("Section "+str(section_id)+": Track Section Toggled *****************************************************")
117 toggle_section(section_id)
118 # Publish the state changes to the broker (for other nodes to consume). Note that changes will only
119 # be published if the MQTT interface has been configured for publishing updates for this track section
120 send_mqtt_section_updated_event(section_id)
121 # Make the external callback (if one has been defined)
122 sections[str(section_id)]["extcallback"] (section_id,section_callback_type.section_updated)
123 return ()
125# -------------------------------------------------------------------------
126# Internal function to flip the state of the section
127# -------------------------------------------------------------------------
129def toggle_section (section_id:int):
130 global sections
131 if sections[str(section_id)]["occupied"]:
132 # section is on
133 logging.info ("Section "+str(section_id)+": Changing to CLEAR - Label \'"
134 +sections[str(section_id)]["labeltext"]+"\'")
135 sections[str(section_id)]["occupied"] = False
136 sections[str(section_id)]["button1"].config(relief="raised", bg="grey", fg="grey40",
137 activebackground="grey", activeforeground="grey40")
138 else:
139 # section is off
140 logging.info ("Section "+str(section_id)+": Changing to OCCUPIED - Label \'"
141 +sections[str(section_id)]["labeltext"]+"\'")
142 sections[str(section_id)]["occupied"] = True
143 sections[str(section_id)]["button1"].config(relief="sunken", bg="black",fg="white",
144 activebackground="black", activeforeground="white")
145 return()
147# -------------------------------------------------------------------------
148# Internal function to get the new label text from the entry widget (on RETURN)
149# -------------------------------------------------------------------------
151def update_identifier(section_id):
152 global sections
153 global text_entry_box
154 global entry_box_window
155 logging.info ("Section "+str(section_id)+": Track Section Label Updated **************************************")
156 # Set the new label for the section button and set the width to the width it was created with
157 # If we get back an empty string then set the label back to the default (OCCUPIED)
158 new_section_label =text_entry_box.get()
159 if new_section_label=="": new_section_label="XXXX"
160 sections[str(section_id)]["labeltext"] = new_section_label
161 sections[str(section_id)]["button1"]["text"] = new_section_label
162 sections[str(section_id)]["button1"].config(width=sections[str(section_id)]["labellength"])
163 # Assume that by entering a value the user wants to set the section to OCCUPIED. Note that the
164 # toggle_section function will also publish the section state & label changes to the MQTT Broker
165 if not sections[str(section_id)]["occupied"]:
166 toggle_section(section_id)
167 # Publish the label changes to the broker (for other nodes to consume). Note that changes will only
168 # be published if the MQTT interface has been configured for publishing updates for this track section
169 send_mqtt_section_updated_event(section_id)
170 # Make an external callback to indicate the section has been switched
171 sections[str(section_id)]["extcallback"] (section_id,section_callback_type.section_updated)
172 # Clean up by destroying the entry box and the window we created it in
173 text_entry_box.destroy()
174 sections[str(section_id)]["canvas"].delete(entry_box_window)
175 return()
177# -------------------------------------------------------------------------
178# Internal function to close the entry widget (on ESCAPE)
179# -------------------------------------------------------------------------
181def cancel_update(section_id):
182 global text_entry_box
183 global entry_box_window
184 # Clean up by destroying the entry box and the window we created it in
185 text_entry_box.destroy()
186 sections[str(section_id)]["canvas"].delete(entry_box_window)
187 return()
189# -------------------------------------------------------------------------
190# Internal function to create an entry widget (on right mouse button click)
191# -------------------------------------------------------------------------
193def open_entry_box(section_id):
194 global text_entry_box
195 global entry_box_window
196 canvas = sections[str(section_id)]["canvas"]
197 # If another text entry box is already open then close that first
198 if entry_box_window is not None:
199 text_entry_box.destroy()
200 canvas.delete(entry_box_window)
201 # Set the font size and length for the text entry box
202 font_size = common.fontsize
203 label_length = sections[str(section_id)]["labellength"]
204 # Create the entry box and bind the RETURN, ESCAPE and FOCUSOUT events to it
205 text_entry_box = Tk.Entry(canvas,width=label_length,font=('Ariel',font_size,"normal"))
206 text_entry_box.bind('<Return>', lambda event:update_identifier(section_id))
207 text_entry_box.bind('<Escape>', lambda event:cancel_update(section_id))
208 text_entry_box.bind('<FocusOut>', lambda event:update_identifier(section_id))
209 # if the section button is already showing occupied then we EDIT the value
210 if sections[str(section_id)]["occupied"]:
211 text_entry_box.insert(0,sections[str(section_id)]["labeltext"])
212 # Create a window on the canvas for the Entry box (overlaying the section button)
213 bbox = sections[str(section_id)]["canvas"].bbox("section"+str(section_id))
214 x = bbox[0] + (bbox[2]-bbox[0]) / 2
215 y = bbox[1] + (bbox[3]-bbox[1]) / 2
216 entry_box_window = canvas.create_window (x,y,window=text_entry_box)
217 # Force focus on the entry box so it will accept the keyboard entry immediately
218 text_entry_box.focus()
219 return()
221# -------------------------------------------------------------------------
222# Public API function to create a section (drawing objects + state)
223# -------------------------------------------------------------------------
225def create_section (canvas, section_id:int, x:int, y:int,
226 section_callback = null_callback,
227 label:str = "OCCUPIED",
228 editable:bool = True):
229 global sections
230 logging.info ("Section "+str(section_id)+": Creating Track Occupancy Section")
231 # Verify that a section with the same ID does not already exist
232 if section_exists(section_id): 232 ↛ 233line 232 didn't jump to line 233, because the condition on line 232 was never true
233 logging.error ("Section "+str(section_id)+": Section already exists")
234 elif section_id < 1: 234 ↛ 235line 234 didn't jump to line 235, because the condition on line 234 was never true
235 logging.error ("Section "+str(section_id)+": Section ID must be greater than zero")
236 else:
237 # Create the button objects and their callbacks
238 font_size = common.fontsize
239 section_button = Tk.Button (canvas, text=label, state="normal", relief="raised", 239 ↛ exitline 239 didn't jump to the function exit
240 padx=common.xpadding, pady=common.ypadding, font=('Ariel',font_size,"normal"),
241 bg="grey", fg="grey40", activebackground="grey", activeforeground="grey40",
242 command = lambda:section_button_event(section_id), width = len(label))
243 # Note the "Tag" for the drawing objects for this track section (i.e. this window)
244 canvas.create_window (x,y,window=section_button,tags="section"+str(section_id))
245 # Compile a dictionary of everything we need to track
246 sections[str(section_id)] = {"canvas" : canvas, # canvas object
247 "button1" : section_button, # drawing object
248 "extcallback" : section_callback, # External callback to make
249 "labeltext" : label, # The Text to display (when OCCUPIED)
250 "labellength" : len(label), # The fixed length for the button
251 "positionx" : x, # Position of the button on the canvas
252 "positiony" : y, # Position of the button on the canvas
253 "occupied" : False } # Current state
254 # Bind the Middle and Right Mouse buttons to the section_button if the
255 # Section is editable so that a "right click" will open the entry box
256 # Disable the button(so the section cannot be toggled) if not editable
257 if editable:
258 section_button.bind('<Button-2>', lambda event:open_entry_box(section_id)) 258 ↛ exitline 258 didn't run the lambda on line 258
259 section_button.bind('<Button-3>', lambda event:open_entry_box(section_id)) 259 ↛ exitline 259 didn't run the lambda on line 259
260 else:
261 section_button.config(state="disabled")
262 # Get the initial state for the section (if layout state has been successfully loaded)
263 loaded_state = file_interface.get_initial_item_state("sections",section_id)
264 # Set the label to the loaded_label (loaded_label will be 'None' if no data was loaded)
265 if loaded_state["labeltext"]:
266 sections[str(section_id)]["labeltext"] = loaded_state["labeltext"]
267 sections[str(section_id)]["button1"]["text"] = loaded_state["labeltext"]
268 # Toggle the section if OCCUPIED (loaded_state_occupied will be 'None' if no data was loaded)
269 if loaded_state["occupied"]: toggle_section(section_id)
270 # Publish the initial state to the broker (for other nodes to consume). Note that changes will only
271 # only be published if the MQTT interface has been configured for publishing updates for this track
272 # section. This allows publish/subscribe to be configured prior to track section creation
273 send_mqtt_section_updated_event(section_id)
274 return()
276# -------------------------------------------------------------------------
277# Public API function to Return the current state of the section
278# -------------------------------------------------------------------------
280def section_occupied (section_id:Union[int,str]):
281 # Validate the section exists
282 if not section_exists(section_id): 282 ↛ 283line 282 didn't jump to line 283, because the condition on line 282 was never true
283 logging.error ("Section "+str(section_id)+": section_occupied - Section does not exist")
284 occupied = False
285 elif not sections[str(section_id)]["occupied"]:
286 occupied = False
287 else:
288 occupied = True
289 return(occupied)
291# -------------------------------------------------------------------------
292# Public API function to Return the current label of the section (train identifier)
293# -------------------------------------------------------------------------
295def section_label (section_id:Union[int,str]):
296 # Validate the section exists
297 if not section_exists(section_id): 297 ↛ 298line 297 didn't jump to line 298, because the condition on line 297 was never true
298 logging.error ("Section "+str(section_id)+": section_label - Section does not exist")
299 section_label=None
300 else:
301 section_label = sections[str(section_id)]["labeltext"]
302 return(section_label)
304# -------------------------------------------------------------------------
305# Public API function to Set a section to OCCUPIED (and optionally update the label)
306# -------------------------------------------------------------------------
308def set_section_occupied (section_id:int,label:str=None, publish:bool=True):
309 # Validate the section exists
310 if not section_exists(section_id): 310 ↛ 311line 310 didn't jump to line 311, because the condition on line 310 was never true
311 logging.error ("Section "+str(section_id)+": set_section_occupied - Section does not exist")
312 else:
313 if not section_occupied(section_id):
314 # Need to toggle the section - ALSO update the label if that has been changed
315 if label is not None and sections[str(section_id)]["labeltext"] != label:
316 sections[str(section_id)]["button1"]["text"] = label
317 sections[str(section_id)]["labeltext"]= label
318 toggle_section(section_id)
319 # Publish the state changes to the broker (for other nodes to consume). Note that changes will only
320 # be published if the MQTT interface has been configured for publishing updates for this track section
321 if publish: send_mqtt_section_updated_event(section_id)
322 elif label is not None and sections[str(section_id)]["labeltext"] != label:
323 # Section state remains unchanged but we need to update the Label
324 sections[str(section_id)]["button1"]["text"] = label
325 sections[str(section_id)]["labeltext"]= label
326 # Publish the label changes to the broker (for other nodes to consume). Note that changes will only
327 # be published if the MQTT interface has been configured for publishing updates for this track section
328 if publish: send_mqtt_section_updated_event(section_id)
329 return()
331# -------------------------------------------------------------------------
332# Public API function to Set a section to CLEAR (returns the label)
333# -------------------------------------------------------------------------
335def clear_section_occupied (section_id:int, label:str=None, publish:bool=True):
336 # Validate the section exists
337 if not section_exists(section_id): 337 ↛ 338line 337 didn't jump to line 338, because the condition on line 337 was never true
338 logging.error ("Section "+str(section_id)+": clear_section_occupied - Section does not exist")
339 section_label = ""
340 elif section_occupied(section_id):
341 # Need to toggle the section - ALSO update the label if that has been changed
342 if label is not None and sections[str(section_id)]["labeltext"] != label:
343 sections[str(section_id)]["button1"]["text"] = label
344 sections[str(section_id)]["labeltext"]= label
345 toggle_section(section_id)
346 # Publish the state changes to the broker (for other nodes to consume). Note that changes will only
347 # be published if the MQTT interface has been configured for publishing updates for this track section
348 if publish: send_mqtt_section_updated_event(section_id)
349 section_label = sections[str(section_id)]["labeltext"]
350 else:
351 # Section state remains unchanged but we need to update the Label
352 if label is not None and sections[str(section_id)]["labeltext"] != label:
353 sections[str(section_id)]["button1"]["text"] = label
354 sections[str(section_id)]["labeltext"]= label
355 section_label = sections[str(section_id)]["labeltext"]
356 return(section_label)
358#-----------------------------------------------------------------------------------------------
359# Public API Function to "subscribe" to section updates published by remote MQTT "Node"
360#-----------------------------------------------------------------------------------------------
362def subscribe_to_remote_section (remote_identifier:str,section_callback):
363 global sections
364 # Validate the remote identifier (must be 'node-id' where id is an int between 1 and 99)
365 if mqtt_interface.split_remote_item_identifier(remote_identifier) is None: 365 ↛ 366line 365 didn't jump to line 366, because the condition on line 365 was never true
366 logging.error ("MQTT-Client: Section "+remote_identifier+": The remote identifier must be in the form of 'Node-ID'")
367 logging.error ("with the 'Node' element a non-zero length string and the 'ID' element an integer between 1 and 99")
368 else:
369 if section_exists(remote_identifier): 369 ↛ 370line 369 didn't jump to line 370, because the condition on line 369 was never true
370 logging.warning("MQTT-Client: Section "+remote_identifier+" - has already been subscribed to via MQTT networking")
371 sections[remote_identifier] = {}
372 sections[remote_identifier]["occupied"] = False
373 sections[remote_identifier]["labeltext"] = "XXXX"
374 sections[remote_identifier]["extcallback"] = section_callback
375 # Subscribe to updates from the remote section
376 [node_id,item_id] = mqtt_interface.split_remote_item_identifier(remote_identifier)
377 mqtt_interface.subscribe_to_mqtt_messages("section_updated_event",node_id,item_id,
378 handle_mqtt_section_updated_event)
379 return()
381#-----------------------------------------------------------------------------------------------
382# Public API Function to set configure a section to publish state changes to remote MQTT nodes
383#-----------------------------------------------------------------------------------------------
385def set_sections_to_publish_state(*sec_ids:int):
386 global list_of_sections_to_publish
387 for sec_id in sec_ids:
388 logging.debug("MQTT-Client: Configuring section "+str(sec_id)+" to publish state changes via MQTT broker")
389 if sec_id in list_of_sections_to_publish: 389 ↛ 390line 389 didn't jump to line 390, because the condition on line 389 was never true
390 logging.warning("MQTT-Client: Section "+str(sec_id)+" - is already configured to publish state changes")
391 else:
392 list_of_sections_to_publish.append(sec_id)
393 # Publish the initial state now this has been added to the list of sections to publish
394 # This allows the publish/subscribe functions to be configured after section creation
395 if str(sec_id) in sections.keys(): send_mqtt_section_updated_event(sec_id)
396 return()
398# --------------------------------------------------------------------------------
399# Callback for handling received MQTT messages from a remote track section
400# --------------------------------------------------------------------------------
402def handle_mqtt_section_updated_event(message):
403 global sections
404 if "sourceidentifier" in message.keys() and "occupied" in message.keys() and "labeltext" in message.keys(): 404 ↛ 411line 404 didn't jump to line 411, because the condition on line 404 was never false
405 section_identifier = message["sourceidentifier"]
406 sections[section_identifier]["occupied"] = message["occupied"]
407 sections[section_identifier]["labeltext"] = message["labeltext"]
408 logging.info("Section "+section_identifier+": State update from remote section ***************************")
409 # Make the external callback (if one has been defined)
410 sections[section_identifier]["extcallback"] (section_identifier,section_callback_type.section_updated)
411 return()
413# --------------------------------------------------------------------------------
414# Internal function for building and sending MQTT messages - but only if the
415# section has been configured to publish updates via the mqtt broker
416# --------------------------------------------------------------------------------
418def send_mqtt_section_updated_event(section_id:int):
419 if section_id in list_of_sections_to_publish:
420 data = {}
421 data["occupied"] = sections[str(section_id)]["occupied"]
422 data["labeltext"] = sections[str(section_id)]["labeltext"]
423 log_message = "Section "+str(section_id)+": Publishing section state to MQTT Broker"
424 # Publish as "retained" messages so remote items that subscribe later will always pick up the latest state
425 mqtt_interface.send_mqtt_message("section_updated_event",section_id,data=data,log_message=log_message,retain=True)
426 return()
428# ------------------------------------------------------------------------------------------
429# Non public API function for deleting a section object (including all the drawing objects)
430# This is used by the schematic editor for changing section types where we delete the existing
431# section with all its data and then recreate it (with the same ID) in its new configuration.
432# Note that we don't delete the section from the list_of_sections_to_publish (via MQTT) as
433# the MQTT configuration can be set completely asynchronously from create/delete sections
434# ------------------------------------------------------------------------------------------
436def delete_section(section_id:int):
437 global sections
438 global entry_box_window
439 global list_of_sections_to_publish
440 if section_exists(section_id):
441 # If a text entry box is open then we need to destroy it
442 if entry_box_window is not None: 442 ↛ 443line 442 didn't jump to line 443, because the condition on line 442 was never true
443 text_entry_box.destroy()
444 sections[str(section_id)]["canvas"].delete(entry_box_window)
445 # Delete all the tkinter canvas drawing objects associated with the section
446 sections[str(section_id)]["canvas"].delete("section"+str(section_id))
447 # Delete all the tkinter button objects created for the section
448 sections[str(section_id)]["button1"].destroy()
449 # Finally, delete the entry from the dictionary of sections
450 del sections[str(section_id)]
451 return()
453# ------------------------------------------------------------------------------------------
454# Non public API function to return the tkinter canvas 'tags' for the section
455# ------------------------------------------------------------------------------------------
457def get_tags(section_id:int):
458 return("section"+str(section_id))
460# ------------------------------------------------------------------------------------------
461# Non public API function to reset the list of published/subscribed sections. Used by
462# the schematic editor for re-setting the MQTT configuration prior to re-configuring
463# via the set_sections_to_publish_state and subscribe_to_section_updates functions
464# ------------------------------------------------------------------------------------------
466def reset_mqtt_configuration():
467 global sections
468 global list_of_sections_to_publish
469 # We only need to clear the list to stop any further section events being published
470 list_of_sections_to_publish.clear()
471 # For subscriptions we unsubscribe from all topics associated with the message_type
472 mqtt_interface.unsubscribe_from_message_type("section_updated_event")
473 # Finally remove all "remote" sections from the dictionary of sections - these
474 # will be re-created if they are subsequently re-subscribed to. Note we don't iterate
475 # through the dictionary of sections to remove items as it will change under us
476 new_sections = {}
477 for key in sections:
478 if key.isdigit(): new_sections[key] = sections[key]
479 sections = new_sections
480 return()
482###############################################################################