Coverage for /home/pi/Software/model-railway-signalling/model_railway_signals/editor/configure_instrument.py: 82%
161 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 contains all the ui functions for configuring Block Instrument objects
3#------------------------------------------------------------------------------------
4#
5# External API functions intended for use by other editor modules:
6# edit_instrument - Open the edit block instrument top level window
7#
8# Makes the following external API calls to other editor modules:
9# objects.update_object(obj_id,new_obj) - Update the configuration on save
10# objects.signal(sig_id) - to get the object ID for a given item ID
11#
12# Accesses the following external editor objects directly:
13# objects.signal_index - To iterate through all the signal objects
14# objects.schematic_objects - To load/save the object configuration
15#
16# Makes the following external API calls to library modules:
17# block_instruments.instrument_exists(id) - To see if the instrument exists
18#
19# Inherits the following common editor base classes (from common):
20# common.str_int_item_id_entry_box
21# common.entry_box
22# common.create_tool_tip
23# common.object_id_selection
24# common.selection_buttons
25# common.signal_route_frame
26# common.window_controls
27#
28#------------------------------------------------------------------------------------
30import os
31import copy
32import importlib.resources
34import tkinter as Tk
35from tkinter import ttk
37from . import common
38from . import objects
40from ..library import block_instruments
42#------------------------------------------------------------------------------------
43# We maintain a global dictionary of open edit windows (where the key is the UUID
44# of the object being edited) to prevent duplicate windows being opened. If the user
45# tries to edit an object which is already being edited, then we just bring the
46# existing edit window to the front (expanding if necessary) and set focus on it
47#------------------------------------------------------------------------------------
49open_windows={}
51#------------------------------------------------------------------------------------
52# We can only use audio for the block instruments if 'simpleaudio' is installed
53# Although this package is supported across different platforms, for Windows
54# it has a dependency on Visual C++ 14.0. As this is quite a faff to install I
55# haven't made audio a hard and fast dependency for the 'model_railway_signals'
56# package as a whole - its up to the user to install if required
57#------------------------------------------------------------------------------------
59def is_simpleaudio_installed():
60 global simpleaudio
61 try:
62 import simpleaudio
63 return (True)
64 except Exception: pass
65 return (False)
66audio_enabled = is_simpleaudio_installed()
68#------------------------------------------------------------------------------------
69# Function to return a read-only list of "interlocked signals". This is the back
70# reference to any signals configured to be interlocked with the Block Section ahead
71# The signal interlocking table comprises a list of routes: [main, lh1, lh2, rh1, rh2]
72# Each route element comprises: [[p1, p2, p3, p4, p5, p6, p7], sig_id, block_id]
73#------------------------------------------------------------------------------------
75def interlocked_signals(instrument_id:int):
76 list_of_interlocked_signals = []
77 # Iterate through the list of signals
78 for signal_id in objects.signal_index:
79 # Everything is false by default- UNLESS specifically set
80 signal_interlocked_by_instrument = False
81 interlocked_routes = [False, False, False, False, False]
82 # Get the signal's interlocking table
83 signal_routes = objects.schematic_objects[objects.signal(signal_id)]["pointinterlock"]
84 # Iterate through each signal route in the interlocking table
85 for route_index, signal_route in enumerate(signal_routes):
86 # Test to see if the signal route is configured to be interlocked with this instrument
87 # Note that Both of the values we are comparing are integers
88 if signal_route[2] == instrument_id:
89 signal_interlocked_by_instrument = True
90 interlocked_routes[route_index] = True
91 if signal_interlocked_by_instrument:
92 list_of_interlocked_signals.append([signal_id, interlocked_routes])
93 return(list_of_interlocked_signals)
95#####################################################################################
96# Classes for the Block Instrument "Configuration" Tab
97#####################################################################################
99#------------------------------------------------------------------------------------
100# Class for the "Linked To" Entry Box - builds on the common str_int_item_id_entry_box.
101# Note that linked instrument can either be a local (int) or remote (str) instrument ID
102# Class instance methods inherited/used from the parent classes are:
103# "set_value" - will set the current value of the entry box (str) - Also sets
104# the current block Instrument item ID (int) for validation purposes
105# "get_value" - will return the last "valid" value of the entry box (str)
106# "validate" - Validates the instrument exists and not the current inst_id
107#------------------------------------------------------------------------------------
109class linked_to_selection(common.str_int_item_id_entry_box):
110 def __init__(self, parent_frame):
111 # The exists_function from the block_instruments module is used to validate that the
112 # entered ID exists on the schematic or has been subscribed to via mqtt networking
113 exists_function = block_instruments.instrument_exists
114 # Create the Label Frame for the "also switch" entry box
115 self.frame = Tk.LabelFrame(parent_frame, text="Block section")
116 # Call the common base class init function to create the EB
117 self.label1 = Tk.Label(self.frame,text="Linked block instrument:")
118 self.label1.pack(side=Tk.LEFT, padx=2, pady=2)
119 super().__init__(self.frame, tool_tip = "Enter the ID of the linked block instrument - "+
120 "This can be a local instrument ID or a remote instrument ID (in the form 'Node-ID') "+
121 "which has been subscribed to via MQTT networking",
122 exists_function=exists_function)
123 self.pack(side=Tk.LEFT, padx=2, pady=2)
125#------------------------------------------------------------------------------------
126# Class for the Sound file selection element (builds on the entry_box class)
127# Class instance methods inherited by this class are:
128# "set_value" - will set the current value of the entry box (str)
129# "get_value" - will return the current value of the entry box (str)
130# Class instance variables provided by this class are:
131# "full_filename" - the fully qualified filename of the audio file
132#------------------------------------------------------------------------------------
134class sound_file_element(common.entry_box):
135 def __init__(self, parent_frame, label:str, tool_tip:str):
136 # Flag to test if a load file or error dialog is open or not
137 self.child_windows_open = False
138 # Create a Frame to hold the various elements
139 self.frame = Tk.Frame(parent_frame)
140 # Only enable the audio file selections if simpleaudio is installed
141 if audio_enabled: 141 ↛ 145line 141 didn't jump to line 145, because the condition on line 141 was never false
142 button_tool_tip = "Browse to select audio file"
143 control_state = "normal"
144 else:
145 button_tool_tip = "Upload disabled - The simpleaudio package is not installed"
146 control_state = "disabled"
147 # This is the fully qualified filename (i.e. including the path)
148 self.full_filename = None
149 # Create the various UI elements
150 self.label = Tk.Label(self.frame,text=label)
151 self.label.pack(side=Tk.LEFT, padx=2, pady=2)
152 super().__init__(self.frame, width=20, callback=None, tool_tip=tool_tip)
153 self.configure(state="disabled")
154 self.pack(side=Tk.LEFT, padx=2, pady=2)
155 self.B1 = Tk.Button(self.frame, text="Browse",command=self.load, state=control_state)
156 self.B1.pack(side=Tk.LEFT, padx=2, pady=2)
157 self.TT1 = common.CreateToolTip(self.B1, button_tool_tip)
159 def load(self):
160 self.child_windows_open = True
161 # Use the library resources folder for the initial path for the file dialog
162 # But the user can navigate away and use another sound file from somewhere else
163 with importlib.resources.path ('model_railway_signals.library', 'resources') as initial_path:
164 filename = Tk.filedialog.askopenfilename(title='Select Audio File', initialdir = initial_path,
165 filetypes=(('audio files','*.wav'),('all files','*.*')), parent=self.frame)
166 # Try loading/playing the selected file - with an error popup if it fails
167 if filename != () and filename != "":
168 try:
169 simpleaudio.WaveObject.from_wave_file(filename).play()
170 except:
171 Tk.messagebox.showerror(parent=self.frame, title="Load Error",
172 message="Error loading audio file '"+str(filename)+"'")
173 else:
174 # Set the filename entry to the name of the current file (split from the dir path)
175 self.set_value(os.path.split(filename)[1])
176 # If a resources file has been chosen then strip off the path to aid cross-platform
177 # transfer of layout files (where the path to the resource folder may be different)
178 if os.path.split(filename)[0] == str(initial_path):
179 filename = os.path.split(filename)[1]
180 self.full_filename = filename
181 self.child_windows_open = False
183 def is_open(self):
184 return(self.child_windows_open)
186#------------------------------------------------------------------------------------
187# Class for the Sound file selections element - uses 2 instances of the element above)
188# Class instance methods provided by this class are:
189# "set_values" - will set the fully qualified audio filenames (bell, key)
190# "get_values" - will return the fully qualified audio filenames (bell, key)
191#------------------------------------------------------------------------------------
193class sound_file_selections():
194 def __init__(self, parent_frame):
195 # Create the Label Frame for the audio file selections
196 self.frame = Tk.LabelFrame(parent_frame, text="Sound Files")
197 # Create the selection elements
198 self.bell = sound_file_element(self.frame, label="Bell:", tool_tip="Audio file for the bell")
199 self.bell.frame.pack(padx=2, pady=2)
200 self.key = sound_file_element(self.frame, label="Key:", tool_tip="Audio file for telegraph key")
201 self.key.frame.pack(padx=2, pady=2)
203 def set_values(self, bell_sound:str,key_sound:str):
204 self.bell.full_filename = bell_sound
205 self.key.full_filename = key_sound
206 self.bell.set_value(os.path.split(bell_sound)[1])
207 self.key.set_value(os.path.split(key_sound)[1])
209 def get_values(self):
210 return ( self.bell.full_filename,
211 self.key.full_filename)
213 def is_open(self):
214 child_windows_open = self.bell.is_open() or self.key.is_open()
215 return (child_windows_open)
217#------------------------------------------------------------------------------------
218# Top level Class for the Block Instrument Configuration Tab
219#------------------------------------------------------------------------------------
221class instrument_configuration_tab():
222 def __init__(self, parent_tab):
223 # Create a Frame to hold the Inst ID and Inst Type Selections
224 self.frame = Tk.Frame(parent_tab)
225 self.frame.pack(padx=2, pady=2, fill='x')
226 # Create the UI Element for Item ID selection. Note that although the block_instruments.instrument_exists
227 # function will match both local and remote Instrument IDs, the object_id_selection only allows integers to
228 # be selected - so we can safely use this function here for consistency.
229 self.instid = common.object_id_selection(self.frame, "Inst ID",
230 exists_function = block_instruments.instrument_exists)
231 self.instid.frame.pack(side=Tk.LEFT, padx=2, pady=2, fill='y')
232 # Create the UI Element for Inst Type selection
233 self.insttype = common.selection_buttons(self.frame, "Point type",
234 "Select block Instrument Type", None, "Single line", "Double Line")
235 self.insttype.frame.pack(padx=2, pady=2, fill='x')
236 self.linkedto = linked_to_selection(parent_tab)
237 self.linkedto.frame.pack(padx=2, pady=2, fill='x')
238 self.sounds = sound_file_selections(parent_tab)
239 self.sounds.frame.pack(padx=2, pady=2, fill='x')
241#------------------------------------------------------------------------------------
242# Top level Class for the Block Instrument Interlocking Tab
243#------------------------------------------------------------------------------------
245class instrument_interlocking_tab():
246 def __init__(self, parent_tab):
247 self.signals = common.signal_route_frame(parent_tab, label="Signals interlocked with instrument",
248 tool_tip="Edit the appropriate signals to configure interlocking")
249 self.signals.frame.pack(padx=2, pady=2, fill='x')
251#####################################################################################
252# Top level Class for the Edit Block Instrument window
253#####################################################################################
255class edit_instrument():
256 def __init__(self, root, object_id):
257 global open_windows
258 # If there is already a window open then we just make it jump to the top and exit
259 if object_id in open_windows.keys(): 259 ↛ 260line 259 didn't jump to line 260, because the condition on line 259 was never true
260 open_windows[object_id].lift()
261 open_windows[object_id].state('normal')
262 open_windows[object_id].focus_force()
263 else:
264 # This is the UUID for the object being edited
265 self.object_id = object_id
266 # Creatre the basic Top Level window
267 self.window = Tk.Toplevel(root)
268 self.window.protocol("WM_DELETE_WINDOW", self.close_window)
269 self.window.resizable(False, False)
270 open_windows[object_id] = self.window
271 # Create the common Apply/OK/Reset/Cancel buttons for the window (packed first to remain visible)
272 self.controls = common.window_controls(self.window, self.load_state, self.save_state, self.close_window)
273 self.controls.frame.pack(side=Tk.BOTTOM, padx=2, pady=2)
274 # Create the Validation error message (this gets packed/unpacked on apply/save)
275 self.validation_error = Tk.Label(self.window, text="Errors on Form need correcting", fg="red")
276 # Create the Notebook (for the tabs)
277 self.tabs = ttk.Notebook(self.window)
278 # Create the Window tabs
279 self.tab1 = Tk.Frame(self.tabs)
280 self.tabs.add(self.tab1, text="Configuration")
281 self.tab2 = Tk.Frame(self.tabs)
282 self.tabs.add(self.tab2, text="Interlocking")
283 self.tabs.pack()
284 self.config = instrument_configuration_tab(self.tab1)
285 self.locking = instrument_interlocking_tab(self.tab2)
286 # load the initial UI state
287 self.load_state()
289#------------------------------------------------------------------------------------
290# Functions for load, save and close window
291#------------------------------------------------------------------------------------
293 def load_state(self):
294 # Check the instrument we are editing still exists (hasn't been deleted from the schematic)
295 # If it no longer exists then we just destroy the window and exit without saving
296 if self.object_id not in objects.schematic_objects.keys(): 296 ↛ 297line 296 didn't jump to line 297, because the condition on line 296 was never true
297 self.close_window()
298 else:
299 item_id = objects.schematic_objects[self.object_id]["itemid"]
300 # Label the edit window with the Instrument ID
301 self.window.title("Instrument "+str(item_id))
302 # Set the Initial UI state from the current object settings
303 self.config.instid.set_value(item_id)
304 self.config.insttype.set_value(objects.schematic_objects[self.object_id]["itemtype"])
305 self.config.linkedto.set_value(objects.schematic_objects[self.object_id]["linkedto"], item_id)
306 bell_sound = objects.schematic_objects[self.object_id]["bellsound"]
307 key_sound = objects.schematic_objects[self.object_id]["keysound"]
308 self.config.sounds.set_values(bell_sound, key_sound)
309 # Set the read only list of Interlocked signals
310 self.locking.signals.set_values(interlocked_signals(item_id))
311 # Hide the validation error message
312 self.validation_error.pack_forget()
313 return()
315 def save_state(self, close_window:bool):
316 # Check the object we are editing still exists (hasn't been deleted from the schematic)
317 # If it no longer exists then we just destroy the window and exit without saving
318 if self.object_id not in objects.schematic_objects.keys(): 318 ↛ 319line 318 didn't jump to line 319, because the condition on line 318 was never true
319 self.close_window()
320 # Validate all user entries prior to applying the changes. Each of these would have
321 # been validated on entry, but changes to other objects may have been made since then
322 # We don't bother validating the specified audio files as the code only allows valid
323 # audio files to be selected (otherwise the existing settings are retained). In the
324 # unlikely event that the files are invalid then popup error messages will be
325 # generated by the 'create_instrument' library function following save/apply
326 elif ( self.config.instid.validate() and self.config.linkedto.validate() ): 326 ↛ 343line 326 didn't jump to line 343, because the condition on line 326 was never false
327 # Copy the original object Configuration (elements get overwritten as required)
328 new_object_configuration = copy.deepcopy(objects.schematic_objects[self.object_id])
329 # Update the object coniguration elements from the current user selections
330 new_object_configuration["itemid"] = self.config.instid.get_value()
331 new_object_configuration["itemtype"] = self.config.insttype.get_value()
332 new_object_configuration["linkedto"] = self.config.linkedto.get_value()
333 bell_sound, key_sound = self.config.sounds.get_values()
334 new_object_configuration["bellsound"] = bell_sound
335 new_object_configuration["keysound"] = key_sound
336 # Save the updated configuration (and re-draw the object)
337 objects.update_object(self.object_id, new_object_configuration)
338 # Close window on "OK" or re-load UI for "apply"
339 if close_window: self.close_window()
340 else: self.load_state()
341 else:
342 # Display the validation error message
343 self.validation_error.pack(side=Tk.BOTTOM, before=self.controls.frame)
344 return()
346 def close_window(self):
347 # Prevent the dialog being closed if the file picker or a warning window is still
348 # open as for some reason this doesn't get destroyed when the parent is destroyed
349 if not self.config.sounds.is_open(): 349 ↛ exitline 349 didn't return from function 'close_window', because the condition on line 349 was never false
350 self.window.destroy()
351 del open_windows[self.object_id]
353#############################################################################################