Coverage for /home/pi/Software/model-railway-signalling/model_railway_signals/editor/editor.py: 63%
379 statements
« prev ^ index » next coverage.py v7.2.7, created at 2024-04-05 17:29 +0100
« prev ^ index » next coverage.py v7.2.7, created at 2024-04-05 17:29 +0100
1#------------------------------------------------------------------------------------
2# This python module will launch the schematic editor (creating the top level window)
3# The run_editor() function is called from '__main__.py' if the package is run as
4# a module (-m) - or can be called externally (useful for running in a pyhon IDE)
5# This module contains all the functions to process the main menubar selections
6#
7# External API functions intended for use by other editor modules:
8# run_editor() - Start the application
9#
10# Makes the following external API calls to other editor modules:
11# objects.save_schematic_state() - Save the state following save or load
12# objects.set_all(new_objects) - Set the dict of objects following a load
13# objects.get_all() - Retrieve the dict of objects for saving to file
14# objects.reset_objects() - Reset the schematic back to its default state
15# objects.mqtt_update_signals(pub_list, sub_list) - configure MQTT networking
16# objects.mqtt_update_sections(pub_list, sub_list) - configure MQTT networking
17# objects.mqtt_update_instruments(pub_list, sub_list) - configure MQTT networking
18# schematic.initialise(root, callback, width, height, grid, snap) - Create the canvas
19# schematic.delete_all_objects() - For deleting all objects (on new/load)
20# schematic.update_canvas(width,height,grid,snap) - Update the canvas following reload/resizing
21# schematic.enable_editing() - On mode toggle or load (if loaded file is in edit mode and not already in edit mode)
22# schematic.disable_editing() - On mode toggle or load (if loaded file is in run mode and not already in run mode)
23# run_layout.configure_automation() - On automation toggle or load
24# run_layout.configure_spad_popups() - On settings update or load
25# settings.get_all() - Get all settings (for save)
26# settings.set_all() - Set all settings (following load)
27# settings.get_canvas() - Get default/loaded canvas settings (for resizing)
28# settings.get_version() - Get the Application version (for the Arg Parser)
29# settings.get_general() - Get the current filename and editor mode
30# settings.set_general() - Set the filename and editor mode
31# settings.get_logging() - Set the default log level
32# settings.get_sprog() - to get the current SPROG settings
33# settings.get_gpio() - to get the current track sensor GPIO mappings
34# settings.restore_defaults() - Following user selection of "new"
35# common.scrollable_text_box - to display a list of warnings on file load
36# menubar_windows.edit_mqtt_settings(root, mqtt_connect_callback, mqtt_update_callback)
37# menubar_windows.edit_sprog_settings(root, sprog_connect_callback, sprog_update_callback)
38# menubar_windows.edit_logging_settings(root, logging_update_callback)
39# menubar_windows.edit_canvas_settings(root, canvas_update_callback)
40# menubar_windows.edit_gpio_settings(root, gpio_update_callback)
41# menubar_windows.display_help(parent_window) - opens the config window
42# menubar_windows.display_about(parent_window) - opens the config window
43# menubar_windows.edit_layout_info(parent_window) - opens the config window
44# utilities.dcc_programming(root, dcc_power_off_callback, dcc_power_on_callback)
45# utilities.dcc_mappings(root)
46#
47# Makes the following external API calls to library modules:
48# library_common.find_root_window (widget) - To set the root window
49# library_common.on_closing (ask_to_save_state) - To shutdown gracefully
50# file_interface.load_schematic - To load all settings and objects
51# file_interface.save_schematic - To save all settings and objects
52# file_interface.purge_loaded_state_information - Called following a re-load
53# pi_sprog_interface.initialise_pi_sprog - After update of Pi Sprog Settings
54# pi_sprog_interface.sprog_shutdown - Disconnect from the Pi-SPROG
55# pi_sprog_interface.request_dcc_power_off - To turn off the track power
56# pi_sprog_interface.request_dcc_power_on - To turn on the track power
57# mqtt_interface.mqtt_broker_connect - MQTT Broker connection configuration
58# mqtt_interface.mqtt_broker_disconnect - disconnect prior to reconfiguration
59# mqtt_interface.configure_mqtt_client - configure client network details
60# dcc_control.reset_mqtt_configuration - reset all publish/subscribe
61# dcc_control.set_node_to_publish_dcc_commands - set note to publish DCC
62# dcc_control.subscribe_to_dcc_command_feed - subscribe to DCC from other nodes
63# gpio_sensors.running_on_raspberry_pi - is the app running on a Raspberry Pi
64#
65#------------------------------------------------------------------------------------
67import os
68import tkinter as Tk
69import logging
70import argparse
72from . import objects
73from . import settings
74from . import schematic
75from . import run_layout
76from . import menubar_windows
77from . import utilities
78from ..library import file_interface
79from ..library import pi_sprog_interface
80from ..library import mqtt_interface
81from ..library import gpio_sensors
82from ..library import dcc_control
83from ..library import common as library_common
85# The following imports are only used for the advanced debugging functions
86import linecache
87import tracemalloc
89#------------------------------------------------------------------------------------
90# Top level class for the toolbar window
91#------------------------------------------------------------------------------------
93class main_menubar:
94 def __init__(self, root):
95 self.root = root
96 # Configure the logger (log level gets set later)
97 logging.basicConfig(format='%(levelname)s: %(message)s')
98 # Create the menu bar
99 self.mainmenubar = Tk.Menu(self.root)
100 self.root.configure(menu=self.mainmenubar)
101 # Create the various menubar items for the File Dropdown
102 self.file_menu = Tk.Menu(self.mainmenubar, tearoff=False)
103 self.file_menu.add_command(label=" New", command=self.new_schematic)
104 self.file_menu.add_command(label=" Open...", command=self.load_schematic)
105 self.file_menu.add_command(label=" Save", command=lambda:self.save_schematic(False)) 105 ↛ exitline 105 didn't run the lambda on line 105
106 self.file_menu.add_command(label=" Save as...", command=lambda:self.save_schematic(True)) 106 ↛ exitline 106 didn't run the lambda on line 106
107 self.file_menu.add_separator()
108 self.file_menu.add_command(label=" Quit",command=lambda:self.quit_schematic()) 108 ↛ exitline 108 didn't run the lambda on line 108
109 self.mainmenubar.add_cascade(label="File", menu=self.file_menu)
110 # Create the various menubar items for the Mode Dropdown
111 self.mode_label = "Mode:xxx"
112 self.mode_menu = Tk.Menu(self.mainmenubar,tearoff=False)
113 self.mode_menu.add_command(label=" Edit ", command=self.edit_mode)
114 self.mode_menu.add_command(label=" Run ", command=self.run_mode)
115 self.mode_menu.add_command(label=" Reset", command=self.reset_layout)
116 self.mainmenubar.add_cascade(label=self.mode_label, menu=self.mode_menu)
117 # Create the various menubar items for the Automation Dropdown
118 self.auto_label = "Automation:xxx"
119 self.auto_menu = Tk.Menu(self.mainmenubar,tearoff=False)
120 self.auto_menu.add_command(label=" Enable ", command=self.automation_enable)
121 self.auto_menu.add_command(label=" Disable", command=self.automation_disable)
122 self.mainmenubar.add_cascade(label=self.auto_label, menu=self.auto_menu)
123 self.mainmenubar.entryconfigure(self.auto_label, state="disabled")
124 # Create the various menubar items for the SPROG Connection Dropdown
125 self.sprog_label = "SPROG:Disconnected"
126 self.sprog_menu = Tk.Menu(self.mainmenubar,tearoff=False)
127 self.sprog_menu.add_command(label=" Connect ", command=self.sprog_connect)
128 self.sprog_menu.add_command(label=" Disconnect ", command=self.sprog_disconnect)
129 self.mainmenubar.add_cascade(label=self.sprog_label, menu=self.sprog_menu)
130 # Create the various menubar items for the DCC Power Dropdown
131 self.power_label = "DCC Power:???"
132 self.power_menu = Tk.Menu(self.mainmenubar,tearoff=False)
133 self.power_menu.add_command(label=" OFF ", command=self.dcc_power_off)
134 self.power_menu.add_command(label=" ON ", command=self.dcc_power_on)
135 self.mainmenubar.add_cascade(label=self.power_label, menu=self.power_menu)
136 self.mainmenubar.entryconfigure(self.power_label, state="disabled")
137 # Create the various menubar items for the MQTT Connection Dropdown
138 self.mqtt_label = "MQTT:Disconnected"
139 self.mqtt_menu = Tk.Menu(self.mainmenubar,tearoff=False)
140 self.mqtt_menu.add_command(label=" Connect ", command=self.mqtt_connect)
141 self.mqtt_menu.add_command(label=" Disconnect ", command=self.mqtt_disconnect)
142 self.mainmenubar.add_cascade(label=self.mqtt_label, menu=self.mqtt_menu)
143 # Create the various menubar items for the Utilities Dropdown
144 self.utilities_menu = Tk.Menu(self.mainmenubar,tearoff=False)
145 self.utilities_menu.add_command(label =" DCC Programming...", 145 ↛ exitline 145 didn't jump to the function exit
146 command=lambda:utilities.dcc_programming(self.root, self.dcc_programming_enabled,
147 self.dcc_power_off, self.dcc_power_on))
148 self.utilities_menu.add_command(label =" DCC Mappings...", 148 ↛ exitline 148 didn't jump to the function exit
149 command=lambda:utilities.dcc_mappings(self.root))
150 self.mainmenubar.add_cascade(label = "Utilities", menu=self.utilities_menu)
151 # Create the various menubar items for the Settings Dropdown
152 self.settings_menu = Tk.Menu(self.mainmenubar,tearoff=False)
153 self.settings_menu.add_command(label =" Canvas...", 153 ↛ exitline 153 didn't jump to the function exit
154 command=lambda:menubar_windows.edit_canvas_settings(self.root, self.canvas_update))
155 self.settings_menu.add_command(label =" General...", 155 ↛ exitline 155 didn't jump to the function exit
156 command=lambda:menubar_windows.edit_general_settings(self.root, self.general_settings_update))
157 self.settings_menu.add_command(label =" GPIO...", 157 ↛ exitline 157 didn't jump to the function exit
158 command=lambda:menubar_windows.edit_gpio_settings(self.root, self.gpio_update))
159 self.settings_menu.add_command(label =" Logging...", 159 ↛ exitline 159 didn't jump to the function exit
160 command=lambda:menubar_windows.edit_logging_settings(self.root, self.logging_update))
161 self.settings_menu.add_command(label =" MQTT...", 161 ↛ exitline 161 didn't jump to the function exit
162 command=lambda:menubar_windows.edit_mqtt_settings(self.root, self.mqtt_connect, self.mqtt_update))
163 self.settings_menu.add_command(label =" SPROG...", 163 ↛ exitline 163 didn't jump to the function exit
164 command=lambda:menubar_windows.edit_sprog_settings(self.root, self.sprog_connect, self.sprog_update))
165 self.mainmenubar.add_cascade(label = "Settings", menu=self.settings_menu)
166 # Create the various menubar items for the Help Dropdown
167 self.help_menu = Tk.Menu(self.mainmenubar,tearoff=False)
168 self.help_menu.add_command(label =" Help...", command=lambda:menubar_windows.display_help(self.root)) 168 ↛ exitline 168 didn't run the lambda on line 168
169 self.help_menu.add_command(label =" About...", command=lambda:menubar_windows.display_about(self.root)) 169 ↛ exitline 169 didn't run the lambda on line 169
170 self.help_menu.add_command(label =" Info...", command=lambda:menubar_windows.edit_layout_info(self.root)) 170 ↛ exitline 170 didn't run the lambda on line 170
171 self.mainmenubar.add_cascade(label = "Help", menu=self.help_menu)
172 # Flag to track whether the new configuration has been saved or not
173 # Used to enforce a "save as" dialog on the initial save of a new layout
174 self.file_has_been_saved = False
175 # Initialise the schematic - the Edit Mode flag is the 2nd param in the returned tuple from get_general
176 width, height, grid, snap_to_grid = settings.get_canvas()
177 edit_mode = settings.get_general()[1]
178 schematic.initialise(self.root, self.handle_canvas_event, width, height, grid, snap_to_grid, edit_mode)
179 # Parse the command line arguments
180 parser = argparse.ArgumentParser(description = "Model railway signalling "+settings.get_general()[2],
181 formatter_class=lambda prog: argparse.HelpFormatter(prog,max_help_position=27))
182 parser.add_argument("-d","--debug",dest="debug_mode",action='store_true',help="run editor with debug functions")
183 parser.add_argument("-f","--file",dest="filename",metavar="FILE",help="schematic file to load on startup")
184 parser.add_argument("-l","--log",dest="log_level",metavar="LEVEL",
185 help="log level (DEBUG, INFO, WARNING, ERROR)")
186 args = parser.parse_args()
187 # Set the log level (default unless one has been specified as a command line argument)
188 if args.log_level == "ERROR": settings.set_logging(1) 188 ↛ 192line 188 didn't jump to line 192
189 elif args.log_level == "WARNING": settings.set_logging(2) 189 ↛ 192line 189 didn't jump to line 192
190 elif args.log_level == "INFO": settings.set_logging(3) 190 ↛ 192line 190 didn't jump to line 192
191 elif args.log_level == "DEBUG": settings.set_logging(4)
192 self.logging_update()
193 # Initialise the editor configuration at startup (using the default settings)
194 self.initialise_editor()
195 # If a filename has been specified as a command line argument then load it. The loaded
196 # settings will overwrite the default settings and initialise_editor will be called again
197 if args.filename is not None: self.load_schematic(args.filename)
198 # The following code is to help with advanced debugging (start the app with the -d flag)
199 if args.debug_mode: 199 ↛ 200line 199 didn't jump to line 200, because the condition on line 199 was never true
200 self.debug_menu = Tk.Menu(self.mainmenubar,tearoff=False)
201 self.debug_menu.add_command(label =" Start memory allocation reporting", command=self.start_memory_monitoring)
202 self.debug_menu.add_command(label =" Stop memory allocation reporting", command=self.stop_memory_monitoring)
203 self.debug_menu.add_command(label =" Report the top 10 users of memory", command=self.report_highest_memory_users)
204 self.mainmenubar.add_cascade(label = "Debug ", menu=self.debug_menu)
205 tracemalloc.start()
206 self.monitor_memory_usage = False
208 # --------------------------------------------------------------------------------------
209 # Advanced debugging functions (memory allocation monitoring/reporting)
210 # Full acknowledgements to stack overflow for the reporting functions used here
211 # --------------------------------------------------------------------------------------
213 def start_memory_monitoring(self):
214 if not self.monitor_memory_usage:
215 self.monitor_memory_usage=True
216 self.report_memory_usage()
218 def stop_memory_monitoring(self):
219 self.monitor_memory_usage=False
221 def report_memory_usage(self):
222 current, peak = tracemalloc.get_traced_memory()
223 print(f"Current memory usage is {current / 10**3}KB; Peak was {peak / 10**3}KB; Diff = {(peak - current) / 10**3}KB")
224 if self.monitor_memory_usage: self.root.after(5000,lambda:self.report_memory_usage())
226 def report_highest_memory_users(self):
227 key_type='lineno'
228 limit=10
229 snapshot = tracemalloc.take_snapshot()
230 snapshot = snapshot.filter_traces((tracemalloc.Filter(False, "<frozen importlib._bootstrap>"),
231 tracemalloc.Filter(False, "<unknown>"),))
232 top_stats = snapshot.statistics(key_type)
233 print("Top %s users of memory (lines of python code)" % limit)
234 for index, stat in enumerate(top_stats[:limit], 1):
235 frame = stat.traceback[0]
236 # replace "/path/to/module/file.py" with "module/file.py"
237 filename = os.sep.join(frame.filename.split(os.sep)[-2:])
238 print("#%s: %s:%s: %.1f KiB" % (index, filename, frame.lineno, stat.size / 1024))
239 line = linecache.getline(frame.filename, frame.lineno).strip()
240 if line: print(' %s' % line)
241 other = top_stats[limit:]
242 if other:
243 size = sum(stat.size for stat in other)
244 print("%s other: %.1f KiB" % (len(other), size / 1024))
245 total = sum(stat.size for stat in top_stats)
246 print("Total allocated size: %.1f KiB" % (total / 1024))
248 # --------------------------------------------------------------------------------------
249 # Common initialisation functions (called on editor start or layout load or new layout)
250 # --------------------------------------------------------------------------------------
252 def initialise_editor(self):
253 # Set the root window label to the name of the current file (split from the dir path)
254 # The fully qualified filename is the first parameter provided by 'get_general'
255 path, name = os.path.split(settings.get_general()[0])
256 self.root.title(name)
257 # Re-size the canvas to reflect the new schematic size
258 self.canvas_update()
259 # Reset the SPROG and MQTT connecions to their default states - the MQTT and SPROG
260 # configuration settings in the loaded file may be completely different so we
261 # want to close down everything before re-opening everything from scratch
262 if self.power_label == "DCC Power:On": self.dcc_power_off()
263 if self.sprog_label == "SPROG:Connected":self.sprog_disconnect()
264 if self.mqtt_label == "MQTT:Connected": self.mqtt_disconnect()
265 # Initialise the SPROG (if configured). Note that we use the menubar functions
266 # for connection and the DCC power so these are correctly reflected in the UI
267 # The "connect" and "power" flags are the 4th and 5th parameter returned
268 if settings.get_sprog()[3]: 268 ↛ 269line 268 didn't jump to line 269, because the condition on line 268 was never true
269 sprog_connected = self.sprog_connect()
270 if sprog_connected and settings.get_sprog()[4]:
271 self.dcc_power_on()
272 # Initialise the MQTT networking (if configured). Note that we use the menubar
273 # function for connection so the state is correctly reflected in the UI
274 # The "connect on startup" flag is the 8th parameter returned
275 self.mqtt_reconfigure_client()
276 if settings.get_mqtt()[7]: self.mqtt_connect()
277 self.mqtt_reconfigure_pub_sub()
278 # Set the Automation Mode (5th param in the returned tuple)
279 # Either of these calls will update 'run_layout'
280 if settings.get_general()[4]: self.automation_enable() 280 ↛ 281line 280 didn't jump to line 281, because the condition on line 280 was never false
281 else: self.automation_disable()
282 # Set the edit mode (2nd param in the returned tuple)
283 # Either of these calls will update run layout
284 if settings.get_general()[1]: self.edit_mode()
285 else: self.run_mode()
286 # Create all the track sensor objects that have been defined
287 self.gpio_update()
288 # Apply any other general settings
289 self.general_settings_update()
291 # --------------------------------------------------------------------------------------
292 # Callback function to handle the Toggle Mode Event ('m' key) from schematic.py
293 # --------------------------------------------------------------------------------------
295 def handle_canvas_event(self, event=None):
296 # Note that event.keysym returns the character (event.state would be 'Control')
297 if event.keysym == 'm':
298 # the Edit mode flag is the second parameter returned
299 if settings.get_general()[1]: self.run_mode() 299 ↛ exitline 299 didn't return from function 'handle_canvas_event'
300 else: self.edit_mode()
301 elif event.keysym == 's':
302 # the Snap to Grid flag is the fourth parameter returned
303 if settings.get_canvas()[3]: settings.set_canvas(snap_to_grid=False)
304 else: settings.set_canvas(snap_to_grid=True)
305 # Apply the new canvas settings
306 self.canvas_update()
307 elif event.keysym == 'a': 307 ↛ exitline 307 didn't return from function 'handle_canvas_event', because the condition on line 307 was never false
308 # the Automation flag is the fifth parameter returned
309 if settings.get_general()[4]: self.automation_disable() 309 ↛ 310line 309 didn't jump to line 310, because the condition on line 309 was never false
310 else: self.automation_enable()
312 #------------------------------------------------------------------------------------------
313 # Mode menubar functions
314 #------------------------------------------------------------------------------------------
316 def automation_enable(self):
317 new_label = "Automation:On"
318 self.mainmenubar.entryconfigure(self.auto_label, label=new_label)
319 self.auto_label = new_label
320 settings.set_general(automation=True)
321 run_layout.configure_automation(True)
323 def automation_disable(self):
324 new_label = "Automation:Off"
325 self.mainmenubar.entryconfigure(self.auto_label, label=new_label)
326 self.auto_label = new_label
327 settings.set_general(automation=False)
328 run_layout.configure_automation(False)
330 def edit_mode(self):
331 if self.mode_label != "Mode:Edit":
332 new_label = "Mode:Edit"
333 self.mainmenubar.entryconfigure(self.mode_label, label=new_label)
334 self.mode_label = new_label
335 settings.set_general(editmode=True)
336 schematic.enable_editing()
337 # Disable the automation menubar selection and set to "off" (automation is always disabled
338 # in Run mode so we just need to update the indication (no need to update 'run_layout')
339 new_label1 = "Automation:N/A"
340 self.mainmenubar.entryconfigure(self.auto_label, state="disabled")
341 self.mainmenubar.entryconfigure(self.auto_label, label=new_label1)
342 self.auto_label = new_label1
344 def run_mode(self):
345 if self.mode_label != "Mode:Run":
346 new_label = "Mode:Run"
347 self.mainmenubar.entryconfigure(self.mode_label, label=new_label)
348 self.mode_label = new_label
349 settings.set_general(editmode=False)
350 schematic.disable_editing()
351 # Enable the the automation menubar selection and update to reflect the current setting
352 # Note that automation is only enbled in Run mode so we just need to update the indication
353 # no need to update 'run_layout'. Note the Automation flag is the fifth parameter returned
354 if settings.get_general()[4]: new_label1 = "Automation:On" 354 ↛ 355line 354 didn't jump to line 355, because the condition on line 354 was never false
355 else: new_label1 = "Automation:Off"
356 self.mainmenubar.entryconfigure(self.auto_label, state="normal")
357 self.mainmenubar.entryconfigure(self.auto_label, label=new_label1)
358 self.auto_label = new_label1
360 def reset_layout(self, ask_for_confirm:bool=True):
361 if ask_for_confirm: 361 ↛ 362line 361 didn't jump to line 362, because the condition on line 361 was never true
362 if Tk.messagebox.askokcancel(parent=self.root, title="Reset Schematic",
363 message="Are you sure you want to reset all signals, points and "
364 +"track occupancy sections back to their default state"):
365 objects.reset_objects()
366 else:
367 objects.reset_objects()
369 #------------------------------------------------------------------------------------------
370 # SPROG menubar functions
371 #------------------------------------------------------------------------------------------
373 def update_sprog_menubar_controls(self, desired_state:bool, connected:bool, show_popup:bool):
374 if connected:
375 new_label = "SPROG:Connected"
376 self.mainmenubar.entryconfigure(self.power_label, state="normal")
377 if show_popup and connected != desired_state:
378 Tk.messagebox.showerror(parent=self.root, title="SPROG Error",
379 message="Error disconnecting from Serial Port - try rebooting")
380 else:
381 new_label = "SPROG:Disconnected"
382 self.mainmenubar.entryconfigure(self.power_label, state="disabled")
383 if show_popup and connected != desired_state:
384 Tk.messagebox.showerror(parent=self.root, title="SPROG Error",
385 message="SPROG connection failure - Check SPROG settings")
386 self.mainmenubar.entryconfigure(self.sprog_label, label=new_label)
387 self.sprog_label = new_label
389 def update_power_menubar_controls(self, desired_state:bool, power_on:bool):
390 if power_on:
391 new_label = "DCC Power:On"
392 if power_on != desired_state:
393 Tk.messagebox.showerror(parent=self.root, title="SPROG Error",
394 message="DCC power off failed - Check SPROG settings")
395 else:
396 new_label = "DCC Power:Off"
397 if power_on != desired_state:
398 Tk.messagebox.showerror(parent=self.root, title="SPROG Error",
399 message="DCC power on failed - Check SPROG settings")
400 self.mainmenubar.entryconfigure(self.power_label, label=new_label)
401 self.power_label = new_label
403 def sprog_connect(self, show_popup:bool=True):
404 # The connect request returns True if successful
405 port, baud, debug, startup, power = settings.get_sprog()
406 connected = pi_sprog_interface.sprog_connect(port, baud, debug)
407 self.update_sprog_menubar_controls(True, connected, show_popup)
408 return(connected)
410 def sprog_disconnect(self):
411 # The disconnect request returns True if successful
412 connected = not pi_sprog_interface.sprog_disconnect()
413 self.update_sprog_menubar_controls(False, connected, True)
415 def sprog_update(self):
416 # Only update the configuration if we are already connected - otherwise
417 # do nothing (wait until the next time the user attempts to connect)
418 if self.sprog_label == "SPROG:Connected": self.sprog_connect()
420 def dcc_power_off(self):
421 # The power off request returns True if successful
422 power_on = not pi_sprog_interface.request_dcc_power_off()
423 self.update_power_menubar_controls(False, power_on)
425 def dcc_power_on(self):
426 # The power on request returns True if successful
427 power_on = pi_sprog_interface.request_dcc_power_on()
428 self.update_power_menubar_controls(True, power_on)
430 def dcc_programming_enabled(self):
431 return (self.power_label=="DCC Power:On" and self.sprog_label=="SPROG:Connected")
433 #------------------------------------------------------------------------------------------
434 # MQTT menubar functions
435 #------------------------------------------------------------------------------------------
437 def update_mqtt_menubar_controls(self, desired_state:bool, connected:bool, show_popup:bool):
438 if connected:
439 new_label = "MQTT:Connected"
440 if show_popup and connected != desired_state: 440 ↛ 441line 440 didn't jump to line 441, because the condition on line 440 was never true
441 Tk.messagebox.showerror(parent=self.root, title="MQTT Error",
442 message="Broker disconnection failure - try rebooting")
443 else:
444 new_label = "MQTT:Disconnected"
445 if show_popup and connected != desired_state: 445 ↛ 446line 445 didn't jump to line 446, because the condition on line 445 was never true
446 Tk.messagebox.showerror(parent=self.root, title="MQTT Error",
447 message="Broker connection failure- Check MQTT settings")
448 self.mainmenubar.entryconfigure(self.mqtt_label, label=new_label)
449 self.mqtt_label = new_label
451 def mqtt_connect(self, show_popup:bool=True):
452 url = settings.get_mqtt()[0]
453 port = settings.get_mqtt()[1]
454 username = settings.get_mqtt()[4]
455 password = settings.get_mqtt()[5]
456 # The connect request returns True if successful
457 connected = mqtt_interface.mqtt_broker_connect(url, port, username, password)
458 self.update_mqtt_menubar_controls(True, connected, show_popup)
459 return(connected)
461 def mqtt_disconnect(self):
462 # The disconnect request returns True if successful
463 connected = not mqtt_interface.mqtt_broker_disconnect()
464 self.update_mqtt_menubar_controls(False, connected, True)
466 def mqtt_update(self):
467 # Apply the new signalling network confguration
468 self.mqtt_reconfigure_client()
469 # Only reset the broker connection if we are already connected - otherwise
470 # do nothing (wait until the next time the user attempts to connect)
471 if self.mqtt_label == "MQTT:Connected" : self.mqtt_connect()
472 # Reconfigure all publish and subscribe settings
473 self.mqtt_reconfigure_pub_sub()
475 def mqtt_reconfigure_client(self):
476 network = settings.get_mqtt()[2]
477 node = settings.get_mqtt()[3]
478 debug = settings.get_mqtt()[6]
479 publish_shutdown = settings.get_mqtt()[8]
480 act_on_shutdown = settings.get_mqtt()[9]
481 mqtt_interface.configure_mqtt_client(network, node, debug, publish_shutdown, act_on_shutdown, 481 ↛ exitline 481 didn't jump to the function exit
482 shutdown_callback = lambda:self.quit_schematic(ask_for_confirm=False))
484 def mqtt_reconfigure_pub_sub(self):
485 dcc_control.reset_mqtt_configuration()
486 dcc_control.set_node_to_publish_dcc_commands(settings.get_pub_dcc())
487 dcc_control.subscribe_to_dcc_command_feed(*settings.get_sub_dcc_nodes())
488 objects.mqtt_update_gpio_sensors(settings.get_pub_sensors(), settings.get_sub_sensors())
489 objects.mqtt_update_signals(settings.get_pub_signals(), settings.get_sub_signals())
490 objects.mqtt_update_sections(settings.get_pub_sections(), settings.get_sub_sections())
491 objects.mqtt_update_instruments(settings.get_pub_instruments(), settings.get_sub_instruments())
493 #------------------------------------------------------------------------------------------
494 # OTHER menubar functions
495 #------------------------------------------------------------------------------------------
497 def canvas_update(self):
498 width, height, grid, snap_to_grid = settings.get_canvas()
499 schematic.update_canvas(width, height, grid, snap_to_grid)
501 def logging_update(self):
502 log_level = settings.get_logging()
503 if log_level == 1: logging.getLogger().setLevel(logging.ERROR) 503 ↛ exitline 503 didn't return from function 'logging_update'
504 elif log_level == 2: logging.getLogger().setLevel(logging.WARNING) 504 ↛ 505line 504 didn't jump to line 505, because the condition on line 504 was never false
505 elif log_level == 3: logging.getLogger().setLevel(logging.INFO)
506 elif log_level == 4: logging.getLogger().setLevel(logging.DEBUG)
508 def gpio_update(self):
509 trigger, timeout, mappings = settings.get_gpio()
510 # Generate a pop-up warning if mappings have been defined but we are not running on a Pi
511 if len(mappings)>0 and not gpio_sensors.running_on_raspberry_pi: 511 ↛ 512line 511 didn't jump to line 512, because the condition on line 511 was never true
512 Tk.messagebox.showwarning(parent=self.root, title="GPIO Warning",
513 message="Not running on Raspberry Pi - no track sensors will be active")
514 # Delete all track sensor objects and then re-create from the updated settings - we do this
515 # even if not running on a Raspberry Pi (to enable transfer of layout files between platforms)
516 objects.update_local_gpio_sensors(trigger, timeout, mappings)
518 def general_settings_update(self):
519 # The spad popups enabled flag is the 6th parameter returned
520 run_layout.configure_spad_popups(settings.get_general()[5])
522 #------------------------------------------------------------------------------------------
523 # FILE menubar functions
524 #------------------------------------------------------------------------------------------
526 def quit_schematic(self, ask_for_confirm:bool=True):
527 # Note that 'confirmation' is defaulted to 'True' for normal use (i.e. when this function
528 # is called as a result of a menubar selection) to enforce the confirmation dialog. If
529 # 'confirmation' is False (system_test_harness use case) then the dialogue is surpressed
530 if not ask_for_confirm or Tk.messagebox.askokcancel(parent=self.root, title="Quit Schematic", 530 ↛ 538line 530 didn't jump to line 538, because the condition on line 530 was never false
531 message="Are you sure you want to discard all changes and quit the application"):
532 # Kill off the PhotoImage objects so we don't get spurious exceptions on window close and
533 # perform an orderly shutdown (cleanup and disconnect from the MQTT broker, Switch off DCC
534 # power and disconnect from the serial port, Revert all GPIO ports to their default states
535 # and then wait until all scheduled Tkinter tasks have completed before destroying root
536 schematic.shutdown()
537 library_common.shutdown()
538 return()
540 def new_schematic(self, ask_for_confirm:bool=True):
541 # Note that 'confirmation' is defaulted to 'True' for normal use (i.e. when this function
542 # is called as a result of a menubar selection) to enforce the confirmation dialog. If
543 if not ask_for_confirm or Tk.messagebox.askokcancel(parent=self.root, title="New Schematic", 543 ↛ 553line 543 didn't jump to line 553, because the condition on line 543 was never false
544 message="Are you sure you want to discard all changes and create a new blank schematic"):
545 # Delete all existing objects, restore the default settings and re-initialise the editor
546 schematic.delete_all_objects()
547 settings.restore_defaults()
548 self.initialise_editor()
549 # Save the current state (for undo/redo) - deleting all previous history
550 objects.save_schematic_state(reset_pointer=True)
551 # Set the file saved flag back to false (to force a "save as" on next save)
552 self.file_has_been_saved = False
553 return()
555 def save_schematic(self, save_as:bool=False):
556 settings_to_save = settings.get_all()
557 objects_to_save = objects.get_all()
558 # Filename is the first parameter returned from settings.get_general
559 filename_to_save = settings.get_general()[0]
560 # If the filename is the default "new_schematic.sig" then we force a 'save as'
561 if not self.file_has_been_saved:
562 save_as = True
563 filename_to_save = ""
564 # Call the library function to load the base configuration file
565 saved_filename = file_interface.save_schematic(settings_to_save, objects_to_save,
566 filename_to_save, save_as=save_as)
567 # Reset the filename / root window title to the name of the file we have saved
568 if saved_filename is not None:
569 settings.set_general(filename=saved_filename)
570 path, name = os.path.split(saved_filename)
571 self.root.title(name)
572 self.file_has_been_saved = True
573 return()
575 # Helper function to convert the version string into a Tuple to allow comparison
576 # Removes the leading "Version " prefix and handles version numbers without a patch number
577 def tuple_version(self, version:str):
578 if " " in version: version = version.split(" ")[1]
579 if len(version)==3: version += ".0"
580 return tuple(map(int,(version.split("."))))
582 def load_schematic(self, filename=None):
583 # Note that 'filename' is defaulted to 'None' for normal use (i.e. when this function
584 # is called as a result of a menubar selection) to enforce the file selection dialog. If
585 # a filename is specified (system_test_harness use case) then the dialogue is surpressed
586 file_loaded, layout_state = file_interface.load_schematic(filename)
587 # the 'file_loaded' will be the name of the file loaded or None (if not loaded)
588 if file_loaded is not None: 588 ↛ 636line 588 didn't jump to line 636, because the condition on line 588 was never false
589 # Do some basic validation that the file has the elements we need
590 if "settings" in layout_state.keys() and "objects" in layout_state.keys(): 590 ↛ 633line 590 didn't jump to line 633, because the condition on line 590 was never false
591 # Compare the version of the application to the version the file was saved under
592 sig_file_version = layout_state["settings"]["general"]["version"]
593 application_version = settings.get_general()[2]
594 if self.tuple_version(sig_file_version) > self.tuple_version(application_version):
595 # We don't provide forward compatibility (too difficult) - so fail fast
596 logging.error("LOAD LAYOUT - File was saved by "+sig_file_version)
597 logging.error("LOAD LAYOUT - Current version of the application is "+application_version)
598 Tk.messagebox.showerror(parent=self.root, title="Load Error",
599 message="File was saved by "+sig_file_version+". Upgrade application to "+
600 sig_file_version+" or later to support this layout file.")
601 elif self.tuple_version(sig_file_version) < self.tuple_version("3.5.0"):
602 # We only provide backward compatibility for a few versions - before that, fail fast
603 logging.error("LOAD LAYOUT - File was saved by application "+sig_file_version)
604 logging.error("LOAD LAYOUT - Current version of the application is "+application_version)
605 Tk.messagebox.showerror(parent=self.root, title="Load Error",
606 message="File was saved by "+sig_file_version+". "+
607 "This version of the application only supports files saved by version "+
608 "3.5.0 or later. Try loading/saving with an intermediate version first.")
609 else:
610 # We should now be OK to attempt the load, but if the file was saved under a
611 # previous version then we still want to flag a warning message to the user
612 if self.tuple_version(sig_file_version) < self.tuple_version(application_version):
613 logging.warning("LOAD LAYOUT - File was saved by application "+sig_file_version)
614 logging.warning("LOAD LAYOUT - Current version of the application is "+application_version)
615 Tk.messagebox.showwarning(parent=self.root, title="Load Warning",
616 message="File was saved by "+sig_file_version+". "+
617 "Re-save with current version to ensure forward compatibility.")
618 # Delete all existing objects
619 schematic.delete_all_objects()
620 settings.set_all(layout_state["settings"])
621 # Set the filename to reflect that actual name of the loaded file
622 settings.set_general(filename=file_loaded)
623 # Re-initialise the editor for the new settings to take effect
624 self.initialise_editor()
625 # Create the loaded layout objects then purge the loaded state information
626 objects.set_all(layout_state["objects"])
627 # Purge the loaded state (to stope it being erroneously inherited
628 # when items are deleted and then new items created with the same IDs)
629 file_interface.purge_loaded_state_information()
630 # Set the flag so we don't enforce a "save as" on next save
631 self.file_has_been_saved = True
632 else:
633 logging.error("LOAD LAYOUT - File does not contain all required elements")
634 Tk.messagebox.showerror(parent=self.root, title="Load Error",
635 message="File does not contain\nall required elements")
636 return()
638#------------------------------------------------------------------------------------
639# This is the main function to run up the schematic editor application
640#------------------------------------------------------------------------------------
642def run_editor():
643 print("Starting Model Railway Signalling application")
644 # Create the Main Root Window
645 root = Tk.Tk()
646 # Configure Tkinter to not show hidden files in the file open/save dialogs
647 # Full credit to Stack Overflow for the solution to this problem
648 try:
649 try:
650 root.tk.call('tk_getOpenFile', '-foobarbaz')
651 except Tk.TclError:
652 pass
653 root.tk.call('set', '::tk::dialog::file::showHiddenVar', '0')
654 except:
655 pass
656 # Limit the maximum window size to the size of the screen (layout can be scrolled in this)
657 # Note the slight adjustment for the window title bar - this makes it a perfect fit on the Pi
658 screen_width = root.winfo_screenwidth()
659 screen_height = root.winfo_screenheight()-30
660 root.maxsize(screen_width,screen_height)
661 # Store the root window reference for use by the library functions
662 library_common.set_root_window(root)
663 # Create the menubar and editor canvas (canvas size will be set on creation)
664 main_window_menubar = main_menubar(root)
665 # Bind the close window event to the editor quit function to perform an orderly shutdown
666 root.protocol("WM_DELETE_WINDOW", main_window_menubar.quit_schematic)
667 # Enter the TKinter main loop (with exception handling for keyboardinterrupt)
668 try: root.mainloop()
669 except KeyboardInterrupt:
670 logging.info("Keyboard Interrupt - Shutting down")
671 # Kill off the PhotoImage objects so we don't get spurious exceptions on window close and
672 # perform an orderly shutdown (cleanup and disconnect from the MQTT broker, Switch off DCC
673 # power and disconnect from the serial port, Revert all GPIO ports to their default states
674 # and then wait until all scheduled Tkinter tasks have completed before destroying root
675 schematic.shutdown()
676 library_common.shutdown()
677 print("Exiting Model Railway Signalling application")
679####################################################################################