Coverage for /home/pi/Software/model-railway-signalling/model_railway_signals/library/track_sections.py: 71%

190 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2024-04-10 15:08 +0100

1# -------------------------------------------------------------------------------- 

2# This module is used for creating and managing Track Occupancy objects (sections) 

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

4# 

5# Public types and functions: 

6#  

7# section_callback_type (tells the calling program what has triggered the callback): 

8# section_callback_type.section_updated - The section has been updated by the user 

9#  

10# create_section - Creates a Track Occupancy section object 

11# Mandatory Parameters: 

12# Canvas - The Tkinter Drawing canvas on which the section is to be displayed 

13# section_id:int - The ID to be used for the section  

14# x:int, y:int - Position of the section on the canvas (in pixels) 

15# Optional Parameters: 

16# section_callback - The function to call if the section is updated - default = None 

17# Note that the callback function returns (item_id, callback type) 

18# editable:bool - If the section can be manually toggled and/or edited (default = True) 

19# label:str - The label to display on the section when occupied - default: "OCCUPIED" 

20#  

21# section_occupied (section_id:int/str)- Returns the section state (True=Occupied, False=Clear) 

22# The Section ID can either be specified as an integer representing the ID of a  

23# section created on our schematic, or a string representing the compound  

24# identifier of a section on an remote MQTT network node. 

25#  

26# section_label (section_id:int/str)- Returns the 'label' of the section (as a string) 

27# The Section ID can either be specified as an integer representing the ID of a  

28# section created on our schematic, or a string representing the compound  

29# identifier of a section on an remote MQTT network node. 

30#  

31# set_section_occupied - Sets the section to "OCCUPIED" (and updates the 'label' if required) 

32# Mandatory Parameters: 

33# section_id:int - The ID to be used for the section  

34# Optional Parameters: 

35# label:str - An updated label to display when occupied (Default = No Change) 

36# publish:bool - Publish updates via MQTT Broker (Default = True) 

37#  

38# clear_section_occupied - Sets the section to "CLEAR" (and updates the 'label' if required) 

39# Returns the current value of the Section Lable (as a string) to allow this 

40# to be 'passed' to the next section (via the set_section_occupied function)  

41# Mandatory Parameters: 

42# section_id:int - The ID to be used for the section  

43# Optional Parameters: 

44# label:str - An updated label to display when occupied (Default = No Change) 

45# publish:bool - Publish updates via MQTT Broker (Default = True) 

46#  

47# ------------------------------------------------------------------------------------------ 

48# 

49# The following functions are associated with the MQTT networking Feature: 

50##  

51# subscribe_to_remote_section - Subscribes to a remote track section object 

52# Mandatory Parameters: 

53# remote_identifier:str - the remote identifier for the track section in the form 'node-id' 

54# Optional Parameters: 

55# section_callback - Function to call when a track section update is received - default = None 

56# 

57# set_sections_to_publish_state - Enable the publication of state updates for track sections. 

58# All subsequent changes will be automatically published to remote subscribers 

59# Mandatory Parameters: 

60# *sec_ids:int - The track sections to publish (multiple Section_IDs can be specified) 

61#  

62# -------------------------------------------------------------------------------- 

63 

64from . import common 

65from . import mqtt_interface 

66from . import file_interface 

67from typing import Union 

68import tkinter as Tk 

69import enum 

70import logging 

71 

72# ------------------------------------------------------------------------- 

73# Classes used by external functions when using track sections 

74# ------------------------------------------------------------------------- 

75 

76class section_callback_type(enum.Enum): 

77 section_updated = 21 # The section has been updated by the user 

78 

79# ------------------------------------------------------------------------- 

80# sections are to be added to a global dictionary when created 

81# ------------------------------------------------------------------------- 

82 

83sections: dict = {} 

84 

85# ------------------------------------------------------------------------- 

86# Global variables used by the Track Sections Module 

87# ------------------------------------------------------------------------- 

88 

89# Global references to the Tkinter Entry box and the associated window 

90text_entry_box = None 

91entry_box_window = None 

92# Global list of track sections to publish to the MQTT Broker 

93list_of_sections_to_publish=[] 

94 

95# ------------------------------------------------------------------------- 

96# The default "External" callback for the section buttons 

97# Used if this is not specified when the section is created 

98# ------------------------------------------------------------------------- 

99 

100def null_callback(section_id:int, callback_type): 

101 return(section_id, callback_type) 

102 

103# ------------------------------------------------------------------------- 

104# Internal Function to check if a section exists in the list of section 

105# Used in Most externally-called functions to validate the section ID 

106# ------------------------------------------------------------------------- 

107 

108def section_exists(section_id:int): 

109 return (str(section_id) in sections.keys() ) 

110 

111# ------------------------------------------------------------------------- 

112# Callback for processing Button presses (manual toggling of Track Sections) 

113# ------------------------------------------------------------------------- 

114 

115def section_button_event (section_id:int): 

116 logging.info ("Section "+str(section_id)+": Track Section Toggled *****************************************************") 

117 toggle_section(section_id) 

118 # Publish the state changes to the broker (for other nodes to consume). Note that changes will only 

119 # be published if the MQTT interface has been configured for publishing updates for this track section 

120 send_mqtt_section_updated_event(section_id) 

121 # Make the external callback (if one has been defined) 

122 sections[str(section_id)]["extcallback"] (section_id,section_callback_type.section_updated) 

123 return () 

124 

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

126# Internal function to flip the state of the section 

127# ------------------------------------------------------------------------- 

128 

129def toggle_section (section_id:int): 

130 global sections 

131 if sections[str(section_id)]["occupied"]: 

132 # section is on 

133 logging.info ("Section "+str(section_id)+": Changing to CLEAR - Label \'" 

134 +sections[str(section_id)]["labeltext"]+"\'") 

135 sections[str(section_id)]["occupied"] = False 

136 sections[str(section_id)]["button1"].config(relief="raised", bg="grey", fg="grey40", 

137 activebackground="grey", activeforeground="grey40") 

138 else: 

139 # section is off 

140 logging.info ("Section "+str(section_id)+": Changing to OCCUPIED - Label \'" 

141 +sections[str(section_id)]["labeltext"]+"\'") 

142 sections[str(section_id)]["occupied"] = True 

143 sections[str(section_id)]["button1"].config(relief="sunken", bg="black",fg="white", 

144 activebackground="black", activeforeground="white") 

145 return() 

146 

147# ------------------------------------------------------------------------- 

148# Internal function to get the new label text from the entry widget (on RETURN) 

149# ------------------------------------------------------------------------- 

150 

151def update_identifier(section_id): 

152 global sections 

153 global text_entry_box 

154 global entry_box_window 

155 logging.info ("Section "+str(section_id)+": Track Section Label Updated **************************************") 

156 # Set the new label for the section button and set the width to the width it was created with 

157 # If we get back an empty string then set the label back to the default (OCCUPIED) 

158 new_section_label =text_entry_box.get() 

159 if new_section_label=="": new_section_label="XXXX" 

160 sections[str(section_id)]["labeltext"] = new_section_label 

161 sections[str(section_id)]["button1"]["text"] = new_section_label 

162 sections[str(section_id)]["button1"].config(width=sections[str(section_id)]["labellength"]) 

163 # Assume that by entering a value the user wants to set the section to OCCUPIED. Note that the 

164 # toggle_section function will also publish the section state & label changes to the MQTT Broker 

165 if not sections[str(section_id)]["occupied"]: 

166 toggle_section(section_id) 

167 # Publish the label changes to the broker (for other nodes to consume). Note that changes will only 

168 # be published if the MQTT interface has been configured for publishing updates for this track section 

169 send_mqtt_section_updated_event(section_id) 

170 # Make an external callback to indicate the section has been switched 

171 sections[str(section_id)]["extcallback"] (section_id,section_callback_type.section_updated) 

172 # Clean up by destroying the entry box and the window we created it in 

173 text_entry_box.destroy() 

174 sections[str(section_id)]["canvas"].delete(entry_box_window) 

175 return() 

176 

177# ------------------------------------------------------------------------- 

178# Internal function to close the entry widget (on ESCAPE) 

179# ------------------------------------------------------------------------- 

180 

181def cancel_update(section_id): 

182 global text_entry_box 

183 global entry_box_window 

184 # Clean up by destroying the entry box and the window we created it in 

185 text_entry_box.destroy() 

186 sections[str(section_id)]["canvas"].delete(entry_box_window) 

187 return() 

188 

189# ------------------------------------------------------------------------- 

190# Internal function to create an entry widget (on right mouse button click) 

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

192 

193def open_entry_box(section_id): 

194 global text_entry_box 

195 global entry_box_window 

196 canvas = sections[str(section_id)]["canvas"] 

197 # If another text entry box is already open then close that first 

198 if entry_box_window is not None: 

199 text_entry_box.destroy() 

200 canvas.delete(entry_box_window) 

201 # Set the font size and length for the text entry box 

202 font_size = common.fontsize 

203 label_length = sections[str(section_id)]["labellength"] 

204 # Create the entry box and bind the RETURN, ESCAPE and FOCUSOUT events to it 

205 text_entry_box = Tk.Entry(canvas,width=label_length,font=('Ariel',font_size,"normal")) 

206 text_entry_box.bind('<Return>', lambda event:update_identifier(section_id)) 

207 text_entry_box.bind('<Escape>', lambda event:cancel_update(section_id)) 

208 text_entry_box.bind('<FocusOut>', lambda event:update_identifier(section_id)) 

209 # if the section button is already showing occupied then we EDIT the value 

210 if sections[str(section_id)]["occupied"]: 

211 text_entry_box.insert(0,sections[str(section_id)]["labeltext"]) 

212 # Create a window on the canvas for the Entry box (overlaying the section button) 

213 bbox = sections[str(section_id)]["canvas"].bbox("section"+str(section_id)) 

214 x = bbox[0] + (bbox[2]-bbox[0]) / 2 

215 y = bbox[1] + (bbox[3]-bbox[1]) / 2 

216 entry_box_window = canvas.create_window (x,y,window=text_entry_box) 

217 # Force focus on the entry box so it will accept the keyboard entry immediately 

218 text_entry_box.focus() 

219 return() 

220 

221# ------------------------------------------------------------------------- 

222# Public API function to create a section (drawing objects + state) 

223# ------------------------------------------------------------------------- 

224 

225def create_section (canvas, section_id:int, x:int, y:int, 

226 section_callback = null_callback, 

227 label:str = "OCCUPIED", 

228 editable:bool = True): 

229 global sections 

230 logging.info ("Section "+str(section_id)+": Creating Track Occupancy Section") 

231 # Verify that a section with the same ID does not already exist 

232 if section_exists(section_id): 232 ↛ 233line 232 didn't jump to line 233, because the condition on line 232 was never true

233 logging.error ("Section "+str(section_id)+": Section already exists") 

234 elif section_id < 1: 234 ↛ 235line 234 didn't jump to line 235, because the condition on line 234 was never true

235 logging.error ("Section "+str(section_id)+": Section ID must be greater than zero") 

236 else: 

237 # Create the button objects and their callbacks 

238 font_size = common.fontsize 

239 section_button = Tk.Button (canvas, text=label, state="normal", relief="raised", 239 ↛ exitline 239 didn't jump to the function exit

240 padx=common.xpadding, pady=common.ypadding, font=('Ariel',font_size,"normal"), 

241 bg="grey", fg="grey40", activebackground="grey", activeforeground="grey40", 

242 command = lambda:section_button_event(section_id), width = len(label)) 

243 # Note the "Tag" for the drawing objects for this track section (i.e. this window) 

244 canvas.create_window (x,y,window=section_button,tags="section"+str(section_id)) 

245 # Compile a dictionary of everything we need to track 

246 sections[str(section_id)] = {"canvas" : canvas, # canvas object 

247 "button1" : section_button, # drawing object 

248 "extcallback" : section_callback, # External callback to make 

249 "labeltext" : label, # The Text to display (when OCCUPIED) 

250 "labellength" : len(label), # The fixed length for the button 

251 "positionx" : x, # Position of the button on the canvas 

252 "positiony" : y, # Position of the button on the canvas 

253 "occupied" : False } # Current state 

254 # Bind the Middle and Right Mouse buttons to the section_button if the 

255 # Section is editable so that a "right click" will open the entry box  

256 # Disable the button(so the section cannot be toggled) if not editable 

257 if editable: 

258 section_button.bind('<Button-2>', lambda event:open_entry_box(section_id)) 258 ↛ exitline 258 didn't run the lambda on line 258

259 section_button.bind('<Button-3>', lambda event:open_entry_box(section_id)) 259 ↛ exitline 259 didn't run the lambda on line 259

260 else: 

261 section_button.config(state="disabled") 

262 # Get the initial state for the section (if layout state has been successfully loaded) 

263 loaded_state = file_interface.get_initial_item_state("sections",section_id) 

264 # Set the label to the loaded_label (loaded_label will be 'None' if no data was loaded) 

265 if loaded_state["labeltext"]: 

266 sections[str(section_id)]["labeltext"] = loaded_state["labeltext"] 

267 sections[str(section_id)]["button1"]["text"] = loaded_state["labeltext"] 

268 # Toggle the section if OCCUPIED (loaded_state_occupied will be 'None' if no data was loaded) 

269 if loaded_state["occupied"]: toggle_section(section_id) 

270 # Publish the initial state to the broker (for other nodes to consume). Note that changes will only 

271 # only be published if the MQTT interface has been configured for publishing updates for this track 

272 # section. This allows publish/subscribe to be configured prior to track section creation 

273 send_mqtt_section_updated_event(section_id) 

274 return() 

275 

276# ------------------------------------------------------------------------- 

277# Public API function to Return the current state of the section 

278# ------------------------------------------------------------------------- 

279 

280def section_occupied (section_id:Union[int,str]): 

281 # Validate the section exists 

282 if not section_exists(section_id): 282 ↛ 283line 282 didn't jump to line 283, because the condition on line 282 was never true

283 logging.error ("Section "+str(section_id)+": section_occupied - Section does not exist") 

284 occupied = False 

285 elif not sections[str(section_id)]["occupied"]: 

286 occupied = False 

287 else: 

288 occupied = True 

289 return(occupied) 

290 

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

292# Public API function to Return the current label of the section (train identifier) 

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

294 

295def section_label (section_id:Union[int,str]): 

296 # Validate the section exists 

297 if not section_exists(section_id): 297 ↛ 298line 297 didn't jump to line 298, because the condition on line 297 was never true

298 logging.error ("Section "+str(section_id)+": section_label - Section does not exist") 

299 section_label=None 

300 else: 

301 section_label = sections[str(section_id)]["labeltext"] 

302 return(section_label) 

303 

304# ------------------------------------------------------------------------- 

305# Public API function to Set a section to OCCUPIED (and optionally update the label) 

306# ------------------------------------------------------------------------- 

307 

308def set_section_occupied (section_id:int,label:str=None, publish:bool=True): 

309 # Validate the section exists 

310 if not section_exists(section_id): 310 ↛ 311line 310 didn't jump to line 311, because the condition on line 310 was never true

311 logging.error ("Section "+str(section_id)+": set_section_occupied - Section does not exist") 

312 else: 

313 if not section_occupied(section_id): 

314 # Need to toggle the section - ALSO update the label if that has been changed 

315 if label is not None and sections[str(section_id)]["labeltext"] != label: 

316 sections[str(section_id)]["button1"]["text"] = label 

317 sections[str(section_id)]["labeltext"]= label 

318 toggle_section(section_id) 

319 # Publish the state changes to the broker (for other nodes to consume). Note that changes will only 

320 # be published if the MQTT interface has been configured for publishing updates for this track section 

321 if publish: send_mqtt_section_updated_event(section_id) 

322 elif label is not None and sections[str(section_id)]["labeltext"] != label: 

323 # Section state remains unchanged but we need to update the Label 

324 sections[str(section_id)]["button1"]["text"] = label 

325 sections[str(section_id)]["labeltext"]= label 

326 # Publish the label changes to the broker (for other nodes to consume). Note that changes will only 

327 # be published if the MQTT interface has been configured for publishing updates for this track section 

328 if publish: send_mqtt_section_updated_event(section_id) 

329 return() 

330 

331# ------------------------------------------------------------------------- 

332# Public API function to Set a section to CLEAR (returns the label) 

333# ------------------------------------------------------------------------- 

334 

335def clear_section_occupied (section_id:int, label:str=None, publish:bool=True): 

336 # Validate the section exists 

337 if not section_exists(section_id): 337 ↛ 338line 337 didn't jump to line 338, because the condition on line 337 was never true

338 logging.error ("Section "+str(section_id)+": clear_section_occupied - Section does not exist") 

339 section_label = "" 

340 elif section_occupied(section_id): 

341 # Need to toggle the section - ALSO update the label if that has been changed 

342 if label is not None and sections[str(section_id)]["labeltext"] != label: 

343 sections[str(section_id)]["button1"]["text"] = label 

344 sections[str(section_id)]["labeltext"]= label 

345 toggle_section(section_id) 

346 # Publish the state changes to the broker (for other nodes to consume). Note that changes will only 

347 # be published if the MQTT interface has been configured for publishing updates for this track section 

348 if publish: send_mqtt_section_updated_event(section_id) 

349 section_label = sections[str(section_id)]["labeltext"] 

350 else: 

351 # Section state remains unchanged but we need to update the Label 

352 if label is not None and sections[str(section_id)]["labeltext"] != label: 

353 sections[str(section_id)]["button1"]["text"] = label 

354 sections[str(section_id)]["labeltext"]= label 

355 section_label = sections[str(section_id)]["labeltext"] 

356 return(section_label) 

357 

358#----------------------------------------------------------------------------------------------- 

359# Public API Function to "subscribe" to section updates published by remote MQTT "Node" 

360#----------------------------------------------------------------------------------------------- 

361 

362def subscribe_to_remote_section (remote_identifier:str,section_callback): 

363 global sections 

364 # Validate the remote identifier (must be 'node-id' where id is an int between 1 and 99) 

365 if mqtt_interface.split_remote_item_identifier(remote_identifier) is None: 365 ↛ 366line 365 didn't jump to line 366, because the condition on line 365 was never true

366 logging.error ("MQTT-Client: Section "+remote_identifier+": The remote identifier must be in the form of 'Node-ID'") 

367 logging.error ("with the 'Node' element a non-zero length string and the 'ID' element an integer between 1 and 99") 

368 else: 

369 if section_exists(remote_identifier): 369 ↛ 370line 369 didn't jump to line 370, because the condition on line 369 was never true

370 logging.warning("MQTT-Client: Section "+remote_identifier+" - has already been subscribed to via MQTT networking") 

371 sections[remote_identifier] = {} 

372 sections[remote_identifier]["occupied"] = False 

373 sections[remote_identifier]["labeltext"] = "XXXX" 

374 sections[remote_identifier]["extcallback"] = section_callback 

375 # Subscribe to updates from the remote section 

376 [node_id,item_id] = mqtt_interface.split_remote_item_identifier(remote_identifier) 

377 mqtt_interface.subscribe_to_mqtt_messages("section_updated_event",node_id,item_id, 

378 handle_mqtt_section_updated_event) 

379 return() 

380 

381#----------------------------------------------------------------------------------------------- 

382# Public API Function to set configure a section to publish state changes to remote MQTT nodes 

383#----------------------------------------------------------------------------------------------- 

384 

385def set_sections_to_publish_state(*sec_ids:int): 

386 global list_of_sections_to_publish 

387 for sec_id in sec_ids: 

388 logging.debug("MQTT-Client: Configuring section "+str(sec_id)+" to publish state changes via MQTT broker") 

389 if sec_id in list_of_sections_to_publish: 389 ↛ 390line 389 didn't jump to line 390, because the condition on line 389 was never true

390 logging.warning("MQTT-Client: Section "+str(sec_id)+" - is already configured to publish state changes") 

391 else: 

392 list_of_sections_to_publish.append(sec_id) 

393 # Publish the initial state now this has been added to the list of sections to publish 

394 # This allows the publish/subscribe functions to be configured after section creation 

395 if str(sec_id) in sections.keys(): send_mqtt_section_updated_event(sec_id) 

396 return() 

397 

398# -------------------------------------------------------------------------------- 

399# Callback for handling received MQTT messages from a remote track section 

400# -------------------------------------------------------------------------------- 

401 

402def handle_mqtt_section_updated_event(message): 

403 global sections 

404 if "sourceidentifier" in message.keys() and "occupied" in message.keys() and "labeltext" in message.keys(): 404 ↛ 411line 404 didn't jump to line 411, because the condition on line 404 was never false

405 section_identifier = message["sourceidentifier"] 

406 sections[section_identifier]["occupied"] = message["occupied"] 

407 sections[section_identifier]["labeltext"] = message["labeltext"] 

408 logging.info("Section "+section_identifier+": State update from remote section ***************************") 

409 # Make the external callback (if one has been defined) 

410 sections[section_identifier]["extcallback"] (section_identifier,section_callback_type.section_updated) 

411 return() 

412 

413# -------------------------------------------------------------------------------- 

414# Internal function for building and sending MQTT messages - but only if the 

415# section has been configured to publish updates via the mqtt broker 

416# -------------------------------------------------------------------------------- 

417 

418def send_mqtt_section_updated_event(section_id:int): 

419 if section_id in list_of_sections_to_publish: 

420 data = {} 

421 data["occupied"] = sections[str(section_id)]["occupied"] 

422 data["labeltext"] = sections[str(section_id)]["labeltext"] 

423 log_message = "Section "+str(section_id)+": Publishing section state to MQTT Broker" 

424 # Publish as "retained" messages so remote items that subscribe later will always pick up the latest state 

425 mqtt_interface.send_mqtt_message("section_updated_event",section_id,data=data,log_message=log_message,retain=True) 

426 return() 

427 

428# ------------------------------------------------------------------------------------------ 

429# Non public API function for deleting a section object (including all the drawing objects) 

430# This is used by the schematic editor for changing section types where we delete the existing 

431# section with all its data and then recreate it (with the same ID) in its new configuration. 

432# Note that we don't delete the section from the list_of_sections_to_publish (via MQTT) as 

433# the MQTT configuration can be set completely asynchronously from create/delete sections 

434# ------------------------------------------------------------------------------------------ 

435 

436def delete_section(section_id:int): 

437 global sections 

438 global entry_box_window 

439 global list_of_sections_to_publish 

440 if section_exists(section_id): 

441 # If a text entry box is open then we need to destroy it 

442 if entry_box_window is not None: 442 ↛ 443line 442 didn't jump to line 443, because the condition on line 442 was never true

443 text_entry_box.destroy() 

444 sections[str(section_id)]["canvas"].delete(entry_box_window) 

445 # Delete all the tkinter canvas drawing objects associated with the section 

446 sections[str(section_id)]["canvas"].delete("section"+str(section_id)) 

447 # Delete all the tkinter button objects created for the section 

448 sections[str(section_id)]["button1"].destroy() 

449 # Finally, delete the entry from the dictionary of sections 

450 del sections[str(section_id)] 

451 return() 

452 

453# ------------------------------------------------------------------------------------------ 

454# Non public API function to return the tkinter canvas 'tags' for the section 

455# ------------------------------------------------------------------------------------------ 

456 

457def get_tags(section_id:int): 

458 return("section"+str(section_id)) 

459 

460# ------------------------------------------------------------------------------------------ 

461# Non public API function to reset the list of published/subscribed sections. Used by 

462# the schematic editor for re-setting the MQTT configuration prior to re-configuring 

463# via the set_sections_to_publish_state and subscribe_to_section_updates functions 

464# ------------------------------------------------------------------------------------------ 

465 

466def reset_mqtt_configuration(): 

467 global sections 

468 global list_of_sections_to_publish 

469 # We only need to clear the list to stop any further section events being published 

470 list_of_sections_to_publish.clear() 

471 # For subscriptions we unsubscribe from all topics associated with the message_type 

472 mqtt_interface.unsubscribe_from_message_type("section_updated_event") 

473 # Finally remove all "remote" sections from the dictionary of sections - these 

474 # will be re-created if they are subsequently re-subscribed to. Note we don't iterate  

475 # through the dictionary of sections to remove items as it will change under us 

476 new_sections = {} 

477 for key in sections: 

478 if key.isdigit(): new_sections[key] = sections[key] 

479 sections = new_sections 

480 return() 

481 

482############################################################################### 

483