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

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

29 

30import os 

31import copy 

32import importlib.resources 

33 

34import tkinter as Tk 

35from tkinter import ttk 

36 

37from . import common 

38from . import objects 

39 

40from ..library import block_instruments 

41 

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

48 

49open_windows={} 

50 

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

58 

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

67 

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

74 

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) 

94 

95##################################################################################### 

96# Classes for the Block Instrument "Configuration" Tab 

97##################################################################################### 

98 

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

108 

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) 

124 

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

133 

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) 

158 

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 

182 

183 def is_open(self): 

184 return(self.child_windows_open) 

185 

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

192 

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) 

202 

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

208 

209 def get_values(self): 

210 return ( self.bell.full_filename, 

211 self.key.full_filename) 

212 

213 def is_open(self): 

214 child_windows_open = self.bell.is_open() or self.key.is_open() 

215 return (child_windows_open) 

216 

217#------------------------------------------------------------------------------------ 

218# Top level Class for the Block Instrument Configuration Tab 

219#------------------------------------------------------------------------------------ 

220 

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

240 

241#------------------------------------------------------------------------------------ 

242# Top level Class for the Block Instrument Interlocking Tab 

243#------------------------------------------------------------------------------------ 

244 

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

250 

251##################################################################################### 

252# Top level Class for the Edit Block Instrument window 

253##################################################################################### 

254 

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

288 

289#------------------------------------------------------------------------------------ 

290# Functions for load, save and close window 

291#------------------------------------------------------------------------------------ 

292 

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

314 

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

345 

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] 

352 

353#############################################################################################