Coverage for /home/pi/Software/model-railway-signalling/model_railway_signals/editor/configure_point.py: 90%

218 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 Point objects 

3#------------------------------------------------------------------------------------ 

4# 

5# External API functions intended for use by other editor modules: 

6# edit_point - Open the edit point 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.point(point_id) - To get the object_id for a given point_id 

11# 

12# Accesses the following external editor objects directly: 

13# objects.point_index - To iterate through all the point objects 

14# objects.schematic_objects - To load/save the object configuration 

15# 

16# Makes the following external API calls to library modules: 

17# points.point_exists(point_id) - To see if a specified point ID exists (local) 

18# dcc_control.dcc_address_mapping - To see if a DCC address is already mapped 

19# 

20# Inherits the following common editor base classes (from common): 

21# common.int_item_id_entry_box 

22# common.Createtool_tip 

23# common.check_box 

24# common.dcc_entry_box 

25# common.object_id_selection 

26# common.selection_buttons 

27# common.signal_route_frame 

28# common.colour_selection 

29# common.window_controls 

30# 

31#------------------------------------------------------------------------------------ 

32 

33import copy 

34 

35import tkinter as Tk 

36from tkinter import ttk 

37 

38from . import common 

39from . import objects 

40 

41from ..library import points 

42from ..library import dcc_control 

43 

44#------------------------------------------------------------------------------------ 

45# We maintain a global dictionary of open edit windows (where the key is the UUID 

46# of the object being edited) to prevent duplicate windows being opened. If the user 

47# tries to edit an object which is already being edited, then we just bring the 

48# existing edit window to the front (expanding if necessary) and set focus on it 

49#------------------------------------------------------------------------------------ 

50 

51open_windows={} 

52 

53##################################################################################### 

54# Classes for the Point "Configuration" Tab 

55##################################################################################### 

56 

57#------------------------------------------------------------------------------------ 

58# Class for the "Also Switch" Entry Box - builds on the common int_item_id_entry_box.  

59# Class instance methods inherited/used from the parent classes are: 

60# "set_value" - set the initial value of the entry_box (str) - Also sets 

61# the current Point item ID (int) for validation purposes 

62# "get_value" - will return the last "valid" value of the entry box (int) 

63# Class instance methods provided/overridden by this class are: 

64# "validate" - Also validate the selected point is automatic and not already 'switched by' 

65# "set_switched_with" - to set the read-only value for the "switched_with" point 

66# Note that we use the current_item_id variable (from the base class) for validation. 

67#------------------------------------------------------------------------------------ 

68 

69class also_switch_selection(common.int_item_id_entry_box): 

70 def __init__(self, parent_frame): 

71 # We need to know the current Point ID (for validation) 

72 self.point_id = 0 

73 # Create the Label Frame for the "also switch" entry box 

74 self.frame = Tk.LabelFrame(parent_frame, text="Automatic switching") 

75 # Call the common base class init function to create the EB 

76 self.label1 = Tk.Label(self.frame,text="Switch point:") 

77 self.label1.pack(side=Tk.LEFT, padx=2, pady=2) 

78 super().__init__(self.frame, tool_tip = "Enter the ID of another (fully "+ 

79 "automatic) point to be switched with this point (or leave blank)", 

80 exists_function=points.point_exists) 

81 self.pack(side=Tk.LEFT, padx=2, pady=2) 

82 # This is the read-only element for the point this point is switched with 

83 self.switched_with = Tk.StringVar(parent_frame, "") 

84 self.label2 = Tk.Label(self.frame,text="Switched with:") 

85 self.label2.pack(side=Tk.LEFT, padx=2, pady=2) 

86 self.switched_eb = Tk.Entry(self.frame, width=3, textvariable=self.switched_with, 

87 justify='center',state="disabled") 

88 self.switched_eb.pack(side=Tk.LEFT, padx=2, pady=2) 

89 self.TT1 = common.CreateToolTip(self.switched_eb, "ID of the point that "+ 

90 "will automatically switch this point") 

91 

92 def validate(self): 

93 # Do the basic item validation first (exists and not current item ID) 

94 valid = super().validate(update_validation_status=False) 

95 if valid and self.entry.get() != "": 

96 autoswitch = int(self.entry.get()) 

97 # Validate the other point is fully automatic 

98 if not objects.schematic_objects[objects.point(autoswitch)]["automatic"]: 98 ↛ 99line 98 didn't jump to line 99, because the condition on line 98 was never true

99 self.TT.text = "Point "+str(autoswitch)+" is not 'fully automatic'" 

100 valid = False 

101 else: 

102 # Test to see if the entered point is already being autoswitched by another point 

103 for point_id in objects.point_index: 

104 other_autoswitch = objects.schematic_objects[objects.point(point_id)]["alsoswitch"] 

105 if other_autoswitch == autoswitch and point_id != str(self.current_item_id): 105 ↛ 106line 105 didn't jump to line 106

106 self.TT.text = ("Point "+str(autoswitch)+" is already configured "+ 

107 "to be switched with point "+point_id) 

108 valid = False 

109 self.set_validation_status(valid) 

110 return(valid) 

111 

112 def set_switched_with(self, point_id:int): 

113 if point_id > 0: self.switched_with.set(str(point_id)) 

114 else: self.switched_with.set("") 

115 

116#------------------------------------------------------------------------------------ 

117# Class for the General Settings UI Element. 

118# Class instance methods provided by this class: 

119# "validate" - validate the current settings and return True/false 

120# "set_values" - will set the checkbox states (rot, rev, auto, fpl, item_id) 

121# Note that the current item ID (int) is u=sed for for validation 

122# "get_values" - will return the checkbox states (rot, rev, auto, fpl) 

123# Validation on "Automatic" checkbox only - Invalid if 'fully automatic' is 

124# unchecked when another point is configured to "auto switch" this point 

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

126 

127class general_settings(): 

128 def __init__(self, parent_frame): 

129 # We need to know the current Item ID for validation 

130 self.current_item_id = 0 

131 # Create a Label frame to hold the general settings 

132 self.frame = Tk.LabelFrame(parent_frame,text="General configuration") 

133 # Create a subframe to hold the first 2 buttons 

134 self.subframe1 = Tk.Frame(self.frame) 

135 self.subframe1.pack() 

136 self.CB1 = common.check_box(self.subframe1, label="Rotated",width=9, 

137 tool_tip="Select to rotate point by 180 degrees") 

138 self.CB1.pack(side=Tk.LEFT, padx=2, pady=2) 

139 self.CB2 = common.check_box(self.subframe1, label="Facing point lock", width=16, 

140 tool_tip="Select to include a Facing Point Lock (manually switched points only)") 

141 self.CB2.pack(side=Tk.LEFT, padx=2, pady=2) 

142 # Create a subframe to hold the second 2 buttons 

143 self.subframe2 = Tk.Frame(self.frame) 

144 self.subframe2.pack() 

145 self.CB3 = common.check_box(self.subframe2, label="Reversed", width=9, 

146 tool_tip="Select to reverse the switching logic of the point blades") 

147 self.CB3.pack(side=Tk.LEFT, padx=2, pady=2) 

148 self.CB4 = common.check_box(self.subframe2, label="Fully automatic", width=16, 

149 tool_tip="Select to create the point without manual controls (to be switched "+ 

150 "with another point)", callback= self.automatic_updated) 

151 self.CB4.pack(side=Tk.LEFT, padx=2, pady=2) 

152 

153 def automatic_updated(self): 

154 self.validate() 

155 # Enable/disable the FPL checkbox based on the 'fully automatic' state 

156 if self.CB4.get_value(): self.CB2.disable() 

157 else: self.CB2.enable() 

158 

159 def validate(self): 

160 # "Automatic" checkbox validation = if the point is not "automatic" then the Point ID  

161 # must not be specified as an "auto switched" point in another point configuration 

162 valid = True 

163 if not self.CB4.get_value(): 

164 # Ensure the point isn't configured to "auto switch" with another point 

165 for point_id in objects.point_index: 

166 other_autoswitch = objects.schematic_objects[objects.point(point_id)]["alsoswitch"] 

167 if other_autoswitch == self.current_item_id: 167 ↛ 168line 167 didn't jump to line 168

168 self.CB4.TT.text = ("Point is configured to switch with point " + 

169 point_id + " so must remain 'fully automatic'") 

170 self.CB4.config(fg="red") 

171 valid = False 

172 if valid: 172 ↛ 176line 172 didn't jump to line 176, because the condition on line 172 was never false

173 self.CB4.TT.text = ("Select to enable this point to be " + 

174 "'also switched' by another point") 

175 self.CB4.config(fg="black") 

176 return(valid) 

177 

178 def set_values(self, rot:bool, rev:bool, auto:bool, fpl:bool, item_id:int): 

179 self.current_item_id = item_id 

180 self.CB1.set_value(rot) 

181 self.CB2.set_value(fpl) 

182 self.CB3.set_value(rev) 

183 self.CB4.set_value(auto) 

184 # Set the initial state (Enabled/Disabled) of the FPL selection 

185 self.automatic_updated() 

186 

187 def get_values(self): 

188 return (self.CB1.get_value(), self.CB3.get_value(), 

189 self.CB4.get_value(), self.CB2.get_value()) 

190 

191#------------------------------------------------------------------------------------ 

192# Class for a point_dcc_entry_box - builds on the common DCC Entry Box class 

193# Class instance methods inherited from the parent class are: 

194# "get_value" - will return the last valid entry box value (dcc address) 

195# Public class instance methods provided/overridden by this child class are 

196# "set_value" - set the initial value of the dcc_entry_box (int) - Also 

197# sets the current item ID (int) for validation purposes 

198# "validate" - Validates the DCC address is not mapped to another item 

199#------------------------------------------------------------------------------------ 

200 

201class point_dcc_entry_box(common.dcc_entry_box): 

202 def __init__(self, parent_frame, callback): 

203 # We need the current Point ID to validate the DCC Address entry 

204 self.current_item_id = 0 

205 super().__init__(parent_frame, callback=callback) 

206 

207 def validate(self): 

208 # Do the basic item validation first (exists and not current item ID) 

209 valid = super().validate(update_validation_status=False) 

210 if valid and self.entry.get() != "": 

211 # Ensure the address is not mapped to another signal or point 

212 dcc_address = int(self.entry.get()) 

213 dcc_mapping = dcc_control.dcc_address_mapping(dcc_address) 

214 if dcc_mapping is not None and (dcc_mapping[0] != "Point" or dcc_mapping[1] != self.current_item_id): 214 ↛ 216line 214 didn't jump to line 216, because the condition on line 214 was never true

215 # We need to correct the mapped signal ID for secondary distants 

216 if dcc_mapping[0] == "Signal" and dcc_mapping[1] > 99: dcc_mapping[1] = dcc_mapping[1] - 100 

217 self.TT.text = ("DCC address is already mapped to "+dcc_mapping[0]+" "+str(dcc_mapping[1])) 

218 valid = False 

219 self.set_validation_status(valid) 

220 return(valid) 

221 

222 def set_value(self, value:int, item_id:int): 

223 self.current_item_id = item_id 

224 super().set_value(value) 

225 

226#------------------------------------------------------------------------------------ 

227# Class for the point DCC Address settings UI element - provides the following functions 

228# "set_values" - will set the entry/checkboxes (address:int, reversed:bool) 

229# "get_values" - will return the entry/checkboxes (address:int, reversed:bool] 

230# "set_point_id" - sets the current point ID (for validation)  

231# "validate" - Ensure the DCC address is valid and not mapped to another item 

232#------------------------------------------------------------------------------------ 

233 

234class dcc_address_settings(): 

235 def __init__(self, parent_frame): 

236 # Create a Label frame to hold the DCC Address settings 

237 self.frame = Tk.LabelFrame(parent_frame,text="DCC Address and command logic") 

238 # Create a DCC Address element and a checkbox for the "reversed" selection 

239 # These are created in a seperate subframe so they are centered in the LabelFrame 

240 self.subframe = Tk.Frame(self.frame) 

241 self.subframe.pack() 

242 self.EB = point_dcc_entry_box(self.subframe, callback=self.entry_updated) 

243 self.EB.pack(side=Tk.LEFT, padx=2, pady=2) 

244 self.CB = common.check_box(self.subframe, label="Reversed", 

245 tool_tip="Select to reverse the DCC command logic") 

246 self.CB.pack(side=Tk.LEFT, padx=2, pady=2) 

247 

248 def entry_updated(self): 

249 if self.EB.entry.get()=="": self.CB.disable() 

250 else: self.CB.enable() 

251 

252 def validate(self): 

253 return(self.EB.validate()) 

254 

255 def set_values(self, add:int, rev:bool, item_id:int): 

256 self.EB.set_value(add, item_id) 

257 self.CB.set_value(rev) 

258 self.entry_updated() 

259 

260 def get_values(self): 

261 return (self.EB.get_value(), self.CB.get_value()) 

262 

263#------------------------------------------------------------------------------------ 

264# Top level Class for the Point Configuration Tab 

265#------------------------------------------------------------------------------------ 

266 

267class point_configuration_tab(): 

268 def __init__(self, parent_tab): 

269 # Create a Frame to hold the Point ID, Point Type and point colour Selections 

270 self.frame = Tk.Frame(parent_tab) 

271 self.frame.pack(padx=2, pady=2, fill='x') 

272 # Create the UI Element for Point ID selection 

273 self.pointid = common.object_id_selection(self.frame, "Point ID", 

274 exists_function = points.point_exists) 

275 self.pointid.frame.pack(side=Tk.LEFT, padx=2, pady=2, fill='y') 

276 # Create the UI Element for Point Type selection 

277 self.pointtype = common.selection_buttons(self.frame, "Point type", 

278 "Select Point Type", None, "RH", "LH") 

279 self.pointtype.frame.pack(side=Tk.LEFT, padx=2, pady=2, fill='y') 

280 # Create the Point colour selection element 

281 self.colour = common.colour_selection(self.frame, label="Colour") 

282 self.colour.frame.pack(side=Tk.LEFT,padx=2, pady=2, fill='y') 

283 # Create the UI element for the general settings 

284 self.settings = general_settings(parent_tab) 

285 self.settings.frame.pack(padx=2, pady=2, fill='x') 

286 # Create the UI element for the "Also Switch" entry  

287 self.alsoswitch = also_switch_selection(parent_tab) 

288 self.alsoswitch.frame.pack(padx=2, pady=2, fill='x') 

289 # Create the UI element for the DCC Settings  

290 self.dccsettings = dcc_address_settings(parent_tab) 

291 self.dccsettings.frame.pack(padx=2, pady=2, fill='x') 

292 

293#------------------------------------------------------------------------------------ 

294# Top level Class for the Point Interlocking Tab 

295#------------------------------------------------------------------------------------ 

296 

297class point_interlocking_tab(): 

298 def __init__(self, parent_tab): 

299 self.signals = common.signal_route_frame(parent_tab, label="Signals interlocked with point", 

300 tool_tip="Edit the appropriate signals to configure interlocking") 

301 self.signals.frame.pack(padx=2, pady=2, fill='x') 

302 

303##################################################################################### 

304# Top level Class for the Edit Point window 

305##################################################################################### 

306 

307class edit_point(): 

308 def __init__(self, root, object_id): 

309 global open_windows 

310 # If there is already a window open then we just make it jump to the top and exit 

311 if object_id in open_windows.keys(): 311 ↛ 312line 311 didn't jump to line 312, because the condition on line 311 was never true

312 open_windows[object_id].lift() 

313 open_windows[object_id].state('normal') 

314 open_windows[object_id].focus_force() 

315 else: 

316 # This is the UUID for the object being edited 

317 self.object_id = object_id 

318 # Creatre the basic Top Level window 

319 self.window = Tk.Toplevel(root) 

320 self.window.protocol("WM_DELETE_WINDOW", self.close_window) 

321 self.window.resizable(False, False) 

322 open_windows[object_id] = self.window 

323 # Create the common Apply/OK/Reset/Cancel buttons for the window (packed first to remain visible) 

324 self.controls = common.window_controls(self.window, self.load_state, self.save_state, self.close_window) 

325 self.controls.frame.pack(side=Tk.BOTTOM, padx=2, pady=2) 

326 # Create the Validation error message (this gets packed/unpacked on apply/save) 

327 self.validation_error = Tk.Label(self.window, text="Errors on Form need correcting", fg="red") 

328 # Create the Notebook (for the tabs)  

329 self.tabs = ttk.Notebook(self.window) 

330 # Create the Window tabs 

331 self.tab1 = Tk.Frame(self.tabs) 

332 self.tabs.add(self.tab1, text="Configuration") 

333 self.tab2 = Tk.Frame(self.tabs) 

334 self.tabs.add(self.tab2, text="Interlocking") 

335 self.tabs.pack() 

336 self.config = point_configuration_tab(self.tab1) 

337 self.locking = point_interlocking_tab(self.tab2) 

338 # load the initial UI state 

339 self.load_state() 

340 

341#------------------------------------------------------------------------------------ 

342# Function to return the read-only "switched with" parameter. This is the back-reference 

343# to the point that is configured to auto-switch the current point (else zero). This 

344# is only used within the UI so doesn't need to be tracked in the point object dict 

345#------------------------------------------------------------------------------------ 

346 

347 def switched_with_point(self, object_id): 

348 switched_with_point_id = 0 

349 for point_id in objects.point_index: 

350 also_switch_point_id = objects.schematic_objects[objects.point(point_id)]["alsoswitch"] 

351 if also_switch_point_id == objects.schematic_objects[object_id]["itemid"]: 

352 switched_with_point_id = int(point_id) 

353 return(switched_with_point_id) 

354 

355#------------------------------------------------------------------------------------ 

356# Functions for Load, Save and close window 

357#------------------------------------------------------------------------------------ 

358 

359 def load_state(self): 

360 # Check the point we are editing still exists (hasn't been deleted from the schematic) 

361 # If it no longer exists then we just destroy the window and exit without saving 

362 if self.object_id not in objects.schematic_objects.keys(): 362 ↛ 363line 362 didn't jump to line 363, because the condition on line 362 was never true

363 self.close_window() 

364 else: 

365 item_id = objects.schematic_objects[self.object_id]["itemid"] 

366 # Label the edit window with the Point ID 

367 self.window.title("Point "+str(item_id)) 

368 # Set the Initial UI state (Note the alsoswitch element needs the current point ID) 

369 self.config.pointid.set_value(item_id) 

370 self.config.alsoswitch.set_value(objects.schematic_objects[self.object_id]["alsoswitch"],item_id) 

371 self.config.alsoswitch.set_switched_with(self.switched_with_point(self.object_id)) 

372 self.config.pointtype.set_value(objects.schematic_objects[self.object_id]["itemtype"]) 

373 self.config.colour.set_value(objects.schematic_objects[self.object_id]["colour"]) 

374 # These are the general settings for the point (note the function also needs the current point id) 

375 auto = objects.schematic_objects[self.object_id]["automatic"] 

376 rev = objects.schematic_objects[self.object_id]["reverse"] 

377 fpl = objects.schematic_objects[self.object_id]["hasfpl"] 

378 if objects.schematic_objects[self.object_id]["orientation"] == 180: rot = True 

379 else:rot = False 

380 self.config.settings.set_values(rot, rev, auto, fpl, item_id) 

381 # Set the initial DCC address values (note the function also needs the current point id) 

382 add = objects.schematic_objects[self.object_id]["dccaddress"] 

383 rev = objects.schematic_objects[self.object_id]["dccreversed"] 

384 self.config.dccsettings.set_values (add, rev, item_id) 

385 # Set the read only list of Interlocked signals 

386 self.locking.signals.set_values(objects.schematic_objects[self.object_id]["siginterlock"]) 

387 # Hide the validation error message 

388 self.validation_error.pack_forget() 

389 return() 

390 

391 def save_state(self, close_window:bool): 

392 # Check the point we are editing still exists (hasn't been deleted from the schematic) 

393 # If it no longer exists then we just destroy the window and exit without saving 

394 if self.object_id not in objects.schematic_objects.keys(): 394 ↛ 395line 394 didn't jump to line 395, because the condition on line 394 was never true

395 self.close_window() 

396 # Validate all user entries prior to applying the changes. Each of these would have 

397 # been validated on entry, but changes to other objects may have been made since then 

398 elif (self.config.pointid.validate() and self.config.alsoswitch.validate() and 398 ↛ 425line 398 didn't jump to line 425, because the condition on line 398 was never false

399 self.config.settings.validate() and self.config.dccsettings.validate()): 

400 # Copy the original point Configuration (elements get overwritten as required) 

401 new_object_configuration = copy.deepcopy(objects.schematic_objects[self.object_id]) 

402 # Update the point coniguration elements from the current user selections 

403 new_object_configuration["itemid"] = self.config.pointid.get_value() 

404 new_object_configuration["itemtype"] = self.config.pointtype.get_value() 

405 new_object_configuration["alsoswitch"] = self.config.alsoswitch.get_value() 

406 new_object_configuration["colour"] = self.config.colour.get_value() 

407 # These are the general settings 

408 rot, rev, auto, fpl = self.config.settings.get_values() 

409 new_object_configuration["reverse"] = rev 

410 new_object_configuration["automatic"] = auto 

411 new_object_configuration["hasfpl"] = fpl 

412 if rot: new_object_configuration["orientation"] = 180 

413 else: new_object_configuration["orientation"] = 0 

414 # Get the DCC address 

415 add, rev = self.config.dccsettings.get_values() 

416 new_object_configuration["dccaddress"] = add 

417 new_object_configuration["dccreversed"] = rev 

418 # Save the updated configuration (and re-draw the object) 

419 objects.update_object(self.object_id, new_object_configuration) 

420 # Close window on "OK" or re-load UI for "apply" 

421 if close_window: self.close_window() 

422 else: self.load_state() 

423 else: 

424 # Display the validation error message 

425 self.validation_error.pack(side=Tk.BOTTOM, before=self.controls.frame) 

426 return() 

427 

428 def close_window(self): 

429 # Prevent the dialog being closed if the colour chooser is still open as 

430 # for some reason this doesn't get destroyed when the parent is destroyed 

431 if not self.config.colour.is_open(): 431 ↛ exitline 431 didn't return from function 'close_window', because the condition on line 431 was never false

432 self.window.destroy() 

433 del open_windows[self.object_id] 

434 

435#############################################################################################