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
« 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#------------------------------------------------------------------------------------
33import copy
35import tkinter as Tk
36from tkinter import ttk
38from . import common
39from . import objects
41from ..library import points
42from ..library import dcc_control
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#------------------------------------------------------------------------------------
51open_windows={}
53#####################################################################################
54# Classes for the Point "Configuration" Tab
55#####################################################################################
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#------------------------------------------------------------------------------------
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")
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)
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("")
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#------------------------------------------------------------------------------------
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)
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()
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)
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()
187 def get_values(self):
188 return (self.CB1.get_value(), self.CB3.get_value(),
189 self.CB4.get_value(), self.CB2.get_value())
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#------------------------------------------------------------------------------------
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)
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)
222 def set_value(self, value:int, item_id:int):
223 self.current_item_id = item_id
224 super().set_value(value)
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#------------------------------------------------------------------------------------
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)
248 def entry_updated(self):
249 if self.EB.entry.get()=="": self.CB.disable()
250 else: self.CB.enable()
252 def validate(self):
253 return(self.EB.validate())
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()
260 def get_values(self):
261 return (self.EB.get_value(), self.CB.get_value())
263#------------------------------------------------------------------------------------
264# Top level Class for the Point Configuration Tab
265#------------------------------------------------------------------------------------
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')
293#------------------------------------------------------------------------------------
294# Top level Class for the Point Interlocking Tab
295#------------------------------------------------------------------------------------
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')
303#####################################################################################
304# Top level Class for the Edit Point window
305#####################################################################################
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()
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#------------------------------------------------------------------------------------
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)
355#------------------------------------------------------------------------------------
356# Functions for Load, Save and close window
357#------------------------------------------------------------------------------------
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()
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()
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]
435#############################################################################################