001package jmri.jmrix.openlcb.swing.eventtable; 002 003import java.awt.*; 004import java.awt.event.*; 005import java.beans.*; 006import java.nio.charset.StandardCharsets; 007import java.io.*; 008import java.util.*; 009 010import javax.swing.*; 011import javax.swing.table.*; 012 013import jmri.*; 014import jmri.jmrix.can.CanSystemConnectionMemo; 015import jmri.jmrix.openlcb.*; 016import jmri.util.ThreadingUtil; 017 018import jmri.swing.JmriJTablePersistenceManager; 019import jmri.util.swing.MultiLineCellRenderer; 020 021import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; 022 023import org.apache.commons.csv.CSVFormat; 024import org.apache.commons.csv.CSVPrinter; 025import org.apache.commons.csv.CSVRecord; 026 027import org.openlcb.*; 028import org.openlcb.implementations.*; 029import org.openlcb.swing.*; 030 031 032/** 033 * Pane for displaying a table of relationships of nodes, producers and consumers 034 * 035 * @author Bob Jacobsen Copyright (C) 2023 036 * @since 5.3.4 037 */ 038public class EventTablePane extends jmri.util.swing.JmriPanel 039 implements jmri.jmrix.can.swing.CanPanelInterface { 040 041 protected CanSystemConnectionMemo memo; 042 Connection connection; 043 NodeID nid; 044 OlcbEventNameStore nameStore; 045 OlcbNodeGroupStore groupStore; 046 047 MimicNodeStore mimcStore; 048 EventTableDataModel model; 049 JTable table; 050 Monitor monitor; 051 052 JComboBox<String> matchGroupName; // required group name to display; index <= 0 is all 053 JCheckBox showRequiresLabel; // requires a user-provided name to display 054 JCheckBox showRequiresMatch; // requires at least one consumer and one producer exist to display 055 JCheckBox popcorn; // popcorn mode displays events in real time 056 057 JFormattedTextField findID; 058 JTextField findTextID; 059 060 private transient TableRowSorter<EventTableDataModel> sorter; 061 062 public String getTitle(String menuTitle) { 063 return Bundle.getMessage("TitleEventTable"); 064 } 065 066 @Override 067 public void initComponents(CanSystemConnectionMemo memo) { 068 this.memo = memo; 069 this.connection = memo.get(Connection.class); 070 this.nid = memo.get(NodeID.class); 071 this.nameStore = memo.get(OlcbEventNameStore.class); 072 this.groupStore = InstanceManager.getDefault(OlcbNodeGroupStore.class); 073 this.mimcStore = memo.get(MimicNodeStore.class); 074 EventTable stdEventTable = memo.get(OlcbInterface.class).getEventTable(); 075 if (stdEventTable == null) log.warn("no OLCB EventTable found"); 076 077 model = new EventTableDataModel(mimcStore, stdEventTable, nameStore); 078 sorter = new TableRowSorter<>(model); 079 080 081 setLayout(new BoxLayout(this, BoxLayout.Y_AXIS)); 082 083 // Add to GUI here 084 085 table = new JTable(model); 086 087 model.table = table; 088 model.sorter = sorter; 089 table.setAutoCreateRowSorter(true); 090 table.setRowSorter(sorter); 091 table.setDefaultRenderer(String.class, new MultiLineCellRenderer()); 092 table.setShowGrid(true); 093 table.setGridColor(Color.BLACK); 094 table.getTableHeader().setBackground(Color.LIGHT_GRAY); 095 table.setName("jmri.jmrix.openlcb.swing.eventtable.EventTablePane.table"); // for persistence 096 table.setColumnSelectionAllowed(true); 097 table.setRowSelectionAllowed(true); 098 099 // render in fixed size font 100 var defaultFont = table.getFont(); 101 var fixedFont = new Font(Font.MONOSPACED, Font.PLAIN, defaultFont.getSize()); 102 table.setFont(fixedFont); 103 104 var scrollPane = new JScrollPane(table); 105 106 // restore the column layout and start monitoring it 107 InstanceManager.getOptionalDefault(JmriJTablePersistenceManager.class).ifPresent((tpm) -> { 108 tpm.resetState(table); 109 tpm.persist(table); 110 }); 111 112 add(scrollPane); 113 114 var buttonPanel = new JToolBar(); 115 buttonPanel.setLayout(new jmri.util.swing.WrapLayout()); 116 117 add(buttonPanel); 118 119 var updateButton = new JButton(Bundle.getMessage("ButtonUpdate")); 120 updateButton.addActionListener(this::sendRequestEvents); 121 updateButton.setToolTipText(Bundle.getMessage("ButtonUpdateTt")); 122 buttonPanel.add(updateButton); 123 124 matchGroupName = new JComboBox<>(); 125 updateMatchGroupName(); // before adding listener 126 matchGroupName.addActionListener((ActionEvent e) -> { 127 filter(); 128 }); 129 groupStore.addPropertyChangeListener((PropertyChangeEvent evt) -> { 130 updateMatchGroupName(); 131 }); 132 buttonPanel.add(matchGroupName); 133 134 showRequiresLabel = new JCheckBox(Bundle.getMessage("BoxShowRequiresLabel")); 135 showRequiresLabel.addActionListener((ActionEvent e) -> { 136 filter(); 137 }); 138 showRequiresLabel.setToolTipText(Bundle.getMessage("BoxShowRequiresLabelTt")); 139 showRequiresLabel.setOpaque(false); // make transparent 140 buttonPanel.add(showRequiresLabel); 141 142 showRequiresMatch = new JCheckBox(Bundle.getMessage("BoxShowRequiresMatch")); 143 showRequiresMatch.addActionListener((ActionEvent e) -> { 144 filter(); 145 }); 146 showRequiresMatch.setToolTipText(Bundle.getMessage("BoxShowRequiresMatchTt")); 147 showRequiresMatch.setOpaque(false); // make transparent 148 buttonPanel.add(showRequiresMatch); 149 150 popcorn = new JCheckBox(Bundle.getMessage("BoxPopcorn")); 151 popcorn.addActionListener((ActionEvent e) -> { 152 popcornButtonChanged(); 153 }); 154 popcorn.setToolTipText(Bundle.getMessage("BoxPopcornTt")); 155 popcorn.setOpaque(false); // make transparent 156 buttonPanel.add(popcorn); 157 158 JPanel findpanel = new JPanel(); // keep button and text together 159 findpanel.setOpaque(false); // make transparent 160 findpanel.setToolTipText(Bundle.getMessage("FindPanelFindEventTt")); 161 buttonPanel.add(findpanel); 162 163 JLabel find = new JLabel(Bundle.getMessage("FindPanelFindEvent")); 164 findpanel.add(find); 165 166 findID = new EventIdTextField(); 167 findID.setToolTipText(Bundle.getMessage("FindPanelFindEventFieldTt")); 168 findID.addActionListener(this::findRequested); 169 findID.addKeyListener(new KeyListener() { 170 @Override 171 public void keyTyped(KeyEvent keyEvent) { 172 } 173 174 @Override 175 public void keyReleased(KeyEvent keyEvent) { 176 // on release so the searchField has been updated 177 log.trace("keyTyped {} content {}", keyEvent.getKeyCode(), findTextID.getText()); 178 findRequested(null); 179 } 180 181 @Override 182 public void keyPressed(KeyEvent keyEvent) { 183 } 184 }); 185 findpanel.add(findID); 186 JButton addButton = new JButton(Bundle.getMessage("FindPanelButtonAdd")); 187 addButton.addActionListener(this::addRequested); 188 addButton.setToolTipText(Bundle.getMessage("FindPanelButtonAddTt")); 189 findpanel.add(addButton); 190 191 findpanel = new JPanel(); // keep button and text together 192 findpanel.setOpaque(false); // make transparent 193 findpanel.setToolTipText(Bundle.getMessage("FindPanelFindNameTt")); 194 buttonPanel.add(findpanel); 195 196 JLabel findText = new JLabel(Bundle.getMessage("FindPanelFindName")); 197 findpanel.add(findText); 198 199 findTextID = new JTextField(16); 200 findTextID.addActionListener(this::findTextRequested); 201 findTextID.setToolTipText(Bundle.getMessage("FindPanelFindNameTt")); 202 findTextID.addKeyListener(new KeyListener() { 203 @Override 204 public void keyTyped(KeyEvent keyEvent) { 205 } 206 207 @Override 208 public void keyReleased(KeyEvent keyEvent) { 209 // on release so the searchField has been updated 210 log.trace("keyTyped {} content {}", keyEvent.getKeyCode(), findTextID.getText()); 211 findTextRequested(null); 212 } 213 214 @Override 215 public void keyPressed(KeyEvent keyEvent) { 216 } 217 }); 218 findpanel.add(findTextID); 219 220 JButton sensorButton = new JButton(Bundle.getMessage("FindPanelButtonSensor")); 221 sensorButton.addActionListener(this::sensorRequested); 222 sensorButton.setToolTipText(Bundle.getMessage("FindPanelButtonSensorTt")); 223 buttonPanel.add(sensorButton); 224 225 JButton turnoutButton = new JButton(Bundle.getMessage("FindPanelButtonTurnout")); 226 turnoutButton.addActionListener(this::turnoutRequested); 227 turnoutButton.setToolTipText(Bundle.getMessage("FindPanelButtonTurnoutTt")); 228 buttonPanel.add(turnoutButton); 229 230 buttonPanel.setMaximumSize(buttonPanel.getPreferredSize()); 231 232 // hook up to receive traffic 233 monitor = new Monitor(model); 234 memo.get(OlcbInterface.class).registerMessageListener(monitor); 235 } 236 237 public EventTablePane() { 238 // interface and connections built in initComponents(..) 239 } 240 241 // load updateMatchGroup combobox with current contents 242 protected void updateMatchGroupName() { 243 matchGroupName.removeAllItems(); 244 matchGroupName.addItem(Bundle.getMessage("FrameAllGroups")); 245 246 var list = groupStore.getGroupNames(); 247 for (String group : list) { 248 matchGroupName.addItem(group); 249 } 250 251 matchGroupName.setVisible(matchGroupName.getItemCount() > 1); 252 } 253 254 @Override 255 public void dispose() { 256 // Save the column layout 257 InstanceManager.getOptionalDefault(JmriJTablePersistenceManager.class).ifPresent((tpm) -> { 258 tpm.stopPersisting(table); 259 }); 260 // remove traffic connection 261 memo.get(OlcbInterface.class).unRegisterMessageListener(monitor); 262 // drop model connections 263 model = null; 264 monitor = null; 265 // and complete this 266 super.dispose(); 267 } 268 269 @Override 270 public java.util.List<JMenu> getMenus() { 271 // create a file menu 272 var retval = new ArrayList<JMenu>(); 273 var fileMenu = new JMenu(Bundle.getMessage("PaneMenuFile")); 274 fileMenu.setMnemonic(KeyEvent.VK_F); 275 276 var csvWriteItem = new JMenuItem(Bundle.getMessage("PaneSaveToCsv"), KeyEvent.VK_S); 277 KeyStroke ctrlSKeyStroke = KeyStroke.getKeyStroke("control S"); 278 if (jmri.util.SystemType.isMacOSX()) { 279 ctrlSKeyStroke = KeyStroke.getKeyStroke("meta S"); 280 } 281 csvWriteItem.setAccelerator(ctrlSKeyStroke); 282 csvWriteItem.addActionListener(this::writeToCsvFile); 283 fileMenu.add(csvWriteItem); 284 285 var csvReadItem = new JMenuItem(Bundle.getMessage("PaneReadFromCsv"), KeyEvent.VK_O); 286 KeyStroke ctrlOKeyStroke = KeyStroke.getKeyStroke("control O"); 287 if (jmri.util.SystemType.isMacOSX()) { 288 ctrlOKeyStroke = KeyStroke.getKeyStroke("meta O"); 289 } 290 csvReadItem.setAccelerator(ctrlOKeyStroke); 291 csvReadItem.addActionListener(this::readFromCsvFile); 292 fileMenu.add(csvReadItem); 293 294 retval.add(fileMenu); 295 return retval; 296 } 297 298 @Override 299 public String getHelpTarget() { 300 return "package.jmri.jmrix.openlcb.swing.eventtable.EventTablePane"; 301 } 302 303 @Override 304 public String getTitle() { 305 if (memo != null) { 306 return (memo.getUserName() + " " + Bundle.getMessage("TitleEventTable")); 307 } 308 return getTitle(Bundle.getMessage("TitleEventTable")); 309 } 310 311 public void sendRequestEvents(java.awt.event.ActionEvent e) { 312 model.clear(); 313 314 model.loadIdTagEventIDs(); 315 model.handleTableUpdate(-1, -1); 316 317 final int IDENTIFY_EVENTS_DELAY = 125; // msec between operations - 64 events at speed 318 int nextDelay = 0; 319 320 // assumes that a VerifyNodes has been done and all nodes are in the MimicNodeStore 321 for (var memo : mimcStore.getNodeMemos()) { 322 323 jmri.util.ThreadingUtil.runOnLayoutDelayed(() -> { 324 var destNodeID = memo.getNodeID(); 325 log.trace("send IdentifyEventsAddressedMessage {} {}", nid, destNodeID); 326 Message m = new IdentifyEventsAddressedMessage(nid, destNodeID); 327 connection.put(m, null); 328 }, nextDelay); 329 330 nextDelay += IDENTIFY_EVENTS_DELAY; 331 } 332 // Our reference to the node names in the MimicNodeStore will 333 // trigger a SNIP request if we don't have them yet. In case that happens 334 // we want to trigger a table refresh to make sure they get displayed. 335 final int REFRESH_INTERVAL = 1000; 336 jmri.util.ThreadingUtil.runOnGUIDelayed(() -> { 337 model.handleTableUpdate(-1,-1); 338 }, nextDelay+REFRESH_INTERVAL); 339 jmri.util.ThreadingUtil.runOnGUIDelayed(() -> { 340 model.handleTableUpdate(-1,-1); 341 }, nextDelay+REFRESH_INTERVAL*2); 342 jmri.util.ThreadingUtil.runOnGUIDelayed(() -> { 343 model.handleTableUpdate(-1,-1); 344 }, nextDelay+REFRESH_INTERVAL*4); 345 346 } 347 348 void popcornButtonChanged() { 349 model.popcornModeActive = popcorn.isSelected(); 350 log.debug("Popcorn mode {}", model.popcornModeActive); 351 } 352 353 354 public void findRequested(java.awt.event.ActionEvent e) { 355 var text = findID.getText(); 356 // take off all the trailing .00 357 text = text.strip().replaceAll("(.00)*$", ""); 358 log.debug("Request find event [{}]", text); 359 // just search event ID 360 table.clearSelection(); 361 if (findTextSearch(text, EventTableDataModel.COL_EVENTID)) return; 362 } 363 364 public void findTextRequested(java.awt.event.ActionEvent e) { 365 String text = findTextID.getText(); 366 log.debug("Request find text {}", text); 367 // first search event name, then from config, then producer name, then consumer name 368 table.clearSelection(); 369 if (findTextSearch(text, EventTableDataModel.COL_EVENTNAME)) return; 370 if (findTextSearch(text, EventTableDataModel.COL_CONTEXT_INFO)) return; 371 if (findTextSearch(text, EventTableDataModel.COL_PRODUCER_NAME)) return; 372 if (findTextSearch(text, EventTableDataModel.COL_CONSUMER_NAME)) return; 373 return; 374 375 //model.highlightEvent(new EventID(findID.getText())); 376 } 377 378 protected boolean findTextSearch(String text, int column) { 379 text = text.toUpperCase(); 380 try { 381 for (int row = 0; row < model.getRowCount(); row++) { 382 var cell = table.getValueAt(row, column); 383 if (cell == null) continue; 384 var value = cell.toString().toUpperCase(); 385 if (value.startsWith(text)) { 386 table.changeSelection(row, column, false, false); 387 return true; 388 } 389 } 390 } catch (RuntimeException e) { 391 // we get ArrayIndexOutOfBoundsException occasionally for no known reason 392 log.debug("unexpected AIOOBE"); 393 } 394 return false; 395 } 396 397 public void addRequested(java.awt.event.ActionEvent e) { 398 var text = findID.getText(); 399 EventID eventID = new EventID(text); 400 // first, add the event 401 var memo = new EventTableDataModel.TripleMemo( 402 eventID, 403 "", 404 null, 405 "", 406 null, 407 "" 408 ); 409 // check to see if already in there: 410 boolean found = false; 411 for (var check : EventTableDataModel.memos) { 412 if (memo.eventID.equals(check.eventID)) { 413 found = true; 414 break; 415 } 416 } 417 if (! found) { 418 EventTableDataModel.memos.add(memo); 419 } 420 model.fireTableDataChanged(); 421 // now select that one 422 findRequested(e); 423 424 } 425 426 public void sensorRequested(java.awt.event.ActionEvent e) { 427 // loop over sensors to find the OpenLCB ones 428 var beans = InstanceManager.getDefault(SensorManager.class).getNamedBeanSet(); 429 for (NamedBean bean : beans ) { 430 if (bean instanceof OlcbSensor) { 431 oneSensorToTag(true, bean); // active 432 oneSensorToTag(false, bean); // inactive 433 } 434 } 435 } 436 437 private void oneSensorToTag(boolean isActive, NamedBean bean) { 438 var sensor = (OlcbSensor) bean; 439 var sensorID = sensor.getEventID(isActive); 440 if (! isEventNamePresent(sensorID)) { 441 // add the association 442 nameStore.addMatch(sensorID, sensor.getEventName(isActive)); 443 } 444 } 445 446 public void turnoutRequested(java.awt.event.ActionEvent e) { 447 // loop over turnouts to find the OpenLCB ones 448 var beans = InstanceManager.getDefault(TurnoutManager.class).getNamedBeanSet(); 449 for (NamedBean bean : beans ) { 450 if (bean instanceof OlcbTurnout) { 451 oneTurnoutToTag(true, bean); // thrown 452 oneTurnoutToTag(false, bean); // closed 453 } 454 } 455 } 456 457 private void oneTurnoutToTag(boolean isThrown, NamedBean bean) { 458 var turnout = (OlcbTurnout) bean; 459 var turnoutID = turnout.getEventID(isThrown); 460 if (! isEventNamePresent(turnoutID)) { 461 // add the association 462 nameStore.addMatch(turnoutID, turnout.getEventName(isThrown)); 463 } 464 } 465 466 467 // CSV file chooser 468 // static to remember choice from one use to another. 469 static JFileChooser fileChooser = null; 470 471 /** 472 * Write out contents in CSV form 473 * @param e Needed for signature of method, but ignored here 474 */ 475 public void writeToCsvFile(ActionEvent e) { 476 477 if (fileChooser == null) { 478 fileChooser = new jmri.util.swing.JmriJFileChooser(); 479 } 480 fileChooser.setDialogTitle(Bundle.getMessage("PaneSaveCsvFile")); 481 fileChooser.rescanCurrentDirectory(); 482 fileChooser.setSelectedFile(new File("eventtable.csv")); 483 484 int retVal = fileChooser.showSaveDialog(this); 485 486 if (retVal == JFileChooser.APPROVE_OPTION) { 487 File file = fileChooser.getSelectedFile(); 488 if (log.isDebugEnabled()) { 489 log.debug("start to export to CSV file {}", file); 490 } 491 492 try (CSVPrinter str = new CSVPrinter(new OutputStreamWriter(new FileOutputStream(file), StandardCharsets.UTF_8), CSVFormat.DEFAULT)) { 493 str.printRecord("Event ID", "Event Name", "Producer Node", "Producer Node Name", 494 "Consumer Node", "Consumer Node Name", "Paths"); 495 for (int i = 0; i < model.getRowCount(); i++) { 496 497 str.print(model.getValueAt(i, EventTableDataModel.COL_EVENTID)); 498 str.print(model.getValueAt(i, EventTableDataModel.COL_EVENTNAME)); 499 str.print(model.getValueAt(i, EventTableDataModel.COL_PRODUCER_NODE)); 500 str.print(model.getValueAt(i, EventTableDataModel.COL_PRODUCER_NAME)); 501 str.print(model.getValueAt(i, EventTableDataModel.COL_CONSUMER_NODE)); 502 str.print(model.getValueAt(i, EventTableDataModel.COL_CONSUMER_NAME)); 503 504 String[] contexts = model.getValueAt(i, EventTableDataModel.COL_CONTEXT_INFO).toString().split("\n"); // multi-line cell 505 for (String context : contexts) { 506 str.print(context); 507 } 508 509 str.println(); 510 } 511 str.flush(); 512 } catch (IOException ex) { 513 log.error("Error writing file", ex); 514 } 515 } 516 } 517 518 /** 519 * Read event names from a CSV file 520 * @param e Needed for signature of method, but ignored here 521 */ 522 public void readFromCsvFile(ActionEvent e) { 523 524 if (fileChooser == null) { 525 fileChooser = new jmri.util.swing.JmriJFileChooser(); 526 } 527 fileChooser.setDialogTitle(Bundle.getMessage("PaneOpenCsvFile")); 528 fileChooser.rescanCurrentDirectory(); 529 530 int retVal = fileChooser.showOpenDialog(this); 531 532 if (retVal == JFileChooser.APPROVE_OPTION) { 533 File file = fileChooser.getSelectedFile(); 534 if (log.isDebugEnabled()) { 535 log.debug("start to read from CSV file {}", file); 536 } 537 538 try (Reader in = new FileReader(file)) { 539 Iterable<CSVRecord> records = CSVFormat.RFC4180.parse(in); 540 541 for (CSVRecord record : records) { 542 String eventIDname = record.get(0); 543 // Is the 1st column really an event ID 544 EventID eid; 545 try { 546 eid = new EventID(eventIDname); 547 } catch (IllegalArgumentException e1) { 548 // really shouldn't happen, as table manages column contents 549 log.warn("Column 0 doesn't contain an EventID: {}", eventIDname); 550 continue; 551 } 552 // here we have a valid EventID, assign the name if currently blank 553 if (! isEventNamePresent(eid)) { 554 String eventName = record.get(1); 555 nameStore.addMatch(eid, eventName); 556 } 557 } 558 log.debug("File reading complete"); 559 // cause the table to update 560 model.fireTableDataChanged(); 561 562 } catch (IOException ex) { 563 log.error("Error reading file", ex); 564 } 565 } 566 } 567 568 /** 569 * Check whether a Event Name tag is defined or not. 570 * Check for other uses before changing this. 571 * @param eventID EventID in native form 572 * @return true is the event name tag is present 573 */ 574 public boolean isEventNamePresent(EventID eventID) { 575 return nameStore.hasEventName(eventID); 576 } 577 578 /** 579 * Set up filtering of displayed rows 580 */ 581 private void filter() { 582 RowFilter<EventTableDataModel, Integer> rf = new RowFilter<EventTableDataModel, Integer>() { 583 /** 584 * @return true if row is to be displayed 585 */ 586 @Override 587 public boolean include(RowFilter.Entry<? extends EventTableDataModel, ? extends Integer> entry) { 588 589 int row = entry.getIdentifier(); 590 591 var name = model.getValueAt(row, EventTableDataModel.COL_EVENTNAME); 592 if ( showRequiresLabel.isSelected() && (name == null || name.toString().isEmpty()) ) return false; 593 594 if ( showRequiresMatch.isSelected()) { 595 var memo = model.getTripleMemo(row); 596 597 if (memo.producer == null && !model.producerPresent(memo.eventID)) { 598 // no matching producer 599 return false; 600 } 601 602 if (memo.consumer == null && !model.consumerPresent(memo.eventID)) { 603 // no matching consumer 604 return false; 605 } 606 } 607 608 // check for group match 609 if ( matchGroupName.getSelectedIndex() > 0) { // -1 is empty combobox 610 String group = matchGroupName.getSelectedItem().toString(); 611 var memo = model.getTripleMemo(row); 612 if ( (! groupStore.isNodeInGroup(memo.producer, group)) 613 && (! groupStore.isNodeInGroup(memo.consumer, group)) ) { 614 return false; 615 } 616 } 617 618 // passed all filters 619 return true; 620 } 621 }; 622 sorter.setRowFilter(rf); 623 } 624 625 /** 626 * Nested class to hold data model 627 */ 628 protected static class EventTableDataModel extends AbstractTableModel { 629 630 EventTableDataModel(MimicNodeStore store, EventTable stdEventTable, OlcbEventNameStore nameStore) { 631 this.store = store; 632 this.stdEventTable = stdEventTable; 633 this.nameStore = nameStore; 634 635 loadIdTagEventIDs(); 636 } 637 638 static final int COL_EVENTID = 0; 639 static final int COL_EVENTNAME = 1; 640 static final int COL_PRODUCER_NODE = 2; 641 static final int COL_PRODUCER_NAME = 3; 642 static final int COL_CONSUMER_NODE = 4; 643 static final int COL_CONSUMER_NAME = 5; 644 static final int COL_CONTEXT_INFO = 6; 645 static final int COL_COUNT = 7; 646 647 MimicNodeStore store; 648 EventTable stdEventTable; 649 OlcbEventNameStore nameStore; 650 IdTagManager tagManager; 651 JTable table; 652 TableRowSorter<EventTableDataModel> sorter; 653 boolean popcornModeActive = false; 654 655 TripleMemo getTripleMemo(int row) { 656 if (row >= memos.size()) { 657 return null; 658 } 659 return memos.get(row); 660 } 661 662 void loadIdTagEventIDs() { 663 // are there events in the IdTags? If so, add them 664 for (var eventID: nameStore.getMatches()) { 665 var memo = new TripleMemo( 666 eventID, 667 "", 668 null, 669 "", 670 null, 671 "" 672 ); 673 // check to see if already in there: 674 boolean found = false; 675 for (var check : memos) { 676 if (memo.eventID.equals(check.eventID)) { 677 found = true; 678 break; 679 } 680 } 681 if (! found) { 682 memos.add(memo); 683 } 684 } 685 } 686 687 688 @Override 689 public Object getValueAt(int row, int col) { 690 if (row >= memos.size()) { 691 log.warn("request out of range: {} greater than {}", row, memos.size()); 692 return "Illegal col "+row+" "+col; 693 } 694 var memo = memos.get(row); 695 switch (col) { 696 case COL_EVENTID: 697 String retval = memo.eventID.toShortString(); 698 if (!memo.rangeSuffix.isEmpty()) retval += " - "+memo.rangeSuffix; 699 return retval; 700 case COL_EVENTNAME: 701 if (nameStore.hasEventName(memo.eventID)) { 702 return nameStore.getEventName(memo.eventID); 703 } else { 704 return ""; 705 } 706 707 case COL_PRODUCER_NODE: 708 return memo.producer != null ? memo.producer.toString() : ""; 709 case COL_PRODUCER_NAME: return memo.producerName; 710 case COL_CONSUMER_NODE: 711 return memo.consumer != null ? memo.consumer.toString() : ""; 712 case COL_CONSUMER_NAME: return memo.consumerName; 713 case COL_CONTEXT_INFO: 714 715 // When table is constrained, these rows don't match up, need to find constrained row 716 var viewRow = sorter.convertRowIndexToView(row); 717 718 if (lineIncrement <= 0) { // load cache variable? 719 if (viewRow >= 0) { 720 lineIncrement = table.getRowHeight(viewRow); // do this if valid row 721 } else { 722 lineIncrement = table.getFont().getSize()*13/10; // line spacing from font if not valid row 723 } 724 } 725 726 var result = new StringBuilder(); 727 728 var height = lineIncrement/3; // for margins 729 var first = true; // no \n before first line 730 731 // interpret eventID and start with that if present 732 String interp = memo.eventID.parse(); 733 if (interp != null && !interp.isEmpty()) { 734 height += lineIncrement; 735 result.append(interp); 736 first = false; 737 } 738 739 // scan the CD/CDI information as available 740 for (var entry : stdEventTable.getEventInfo(memo.eventID).getAllEntries()) { 741 if (!first) result.append("\n"); 742 first = false; 743 height += lineIncrement; 744 result.append(entry.getDescription()); 745 } 746 747 // set height for multi-line output in the cell 748 if (viewRow >= 0) { // make sure it's a valid visible row in the table; -1 signals not 749 // set height 750 if (height < lineIncrement) { 751 height = height+lineIncrement; // when no lines, assume 1 752 } 753 table.setRowHeight(viewRow, height); 754 } else { 755 lineIncrement = -1; // reload on next request, hoping for a viewed row 756 } 757 return new String(result); 758 default: return "Illegal column at "+row+" "+col; 759 } 760 } 761 762 int lineIncrement = -1; // cache the line spacing for multi-line cells; 763 // this gets the value before any adjustments done 764 765 @Override 766 public void setValueAt(Object value, int row, int col) { 767 if (col != COL_EVENTNAME) return; 768 if (row >= memos.size()) { 769 log.warn("request out of range: {} greater than {}", row, memos.size()); 770 return; 771 } 772 var memo = memos.get(row); 773 nameStore.addMatch(memo.eventID, value.toString()); 774 } 775 776 @Override 777 public int getColumnCount() { 778 return COL_COUNT; 779 } 780 781 @Override 782 public String getColumnName(int col) { 783 switch (col) { 784 case COL_EVENTID: return Bundle.getMessage("TableColEventId"); 785 case COL_EVENTNAME: return Bundle.getMessage("TableColEventName"); 786 case COL_PRODUCER_NODE: return Bundle.getMessage("TableColProducerNode"); 787 case COL_PRODUCER_NAME: return Bundle.getMessage("TableColProducerName"); 788 case COL_CONSUMER_NODE: return Bundle.getMessage("TableColConsumerNode"); 789 case COL_CONSUMER_NAME: return Bundle.getMessage("TableColConsumerName"); 790 case COL_CONTEXT_INFO: return Bundle.getMessage("TableColContextInfo"); 791 default: return "ERROR "+col; 792 } 793 } 794 795 @Override 796 public int getRowCount() { 797 return memos.size(); 798 } 799 800 @Override 801 public boolean isCellEditable(int row, int col) { 802 return col == COL_EVENTNAME; 803 } 804 805 @Override 806 public Class<?> getColumnClass(int col) { 807 return String.class; 808 } 809 810 /** 811 * Remove all existing data, generally just in advance of an update 812 */ 813 @SuppressFBWarnings(value = "ST_WRITE_TO_STATIC_FROM_INSTANCE_METHOD") // Swing thread deconflicts 814 void clear() { 815 memos = new ArrayList<>(); 816 fireTableDataChanged(); // don't queue this one, must be immediate 817 } 818 819 // static so the data remains available through a window close-open cycle 820 static ArrayList<TripleMemo> memos = new ArrayList<>(); 821 822 /** 823 * Notify the table that the contents have changed. 824 * To reduce CPU load, this batches the changes 825 * @param start first row changed; -1 means entire table (not used yet) 826 * @param end last row changed; -1 means entire table (not used yet) 827 */ 828 void handleTableUpdate(int start, int end) { 829 if (log.isTraceEnabled()) { // check logging level to avoid processing irrelevant traceback 830 log.trace("handleTableUpdated", jmri.util.LoggingUtil.shortenStacktrace(new Exception("traceback"))); 831 } 832 833 final int DELAY = 500; 834 835 if (!pending) { 836 jmri.util.ThreadingUtil.runOnGUIDelayed(() -> { 837 pending = false; 838 log.debug("handleTableUpdated fires table changed"); 839 fireTableDataChanged(); 840 }, DELAY); 841 pending = true; 842 } 843 844 } 845 boolean pending = false; 846 847 /** 848 * Record an event-producer pair 849 * @param eventID Observed event 850 * @param nodeID Node that is known to produce the event 851 * @param rangeSuffix the range mask string or "" for single events 852 * @param pcer true if this Producer was inferred from a PCER message, false if from a Producer Identified message 853 */ 854 void recordProducer(EventID eventID, NodeID nodeID, String rangeSuffix, boolean pcer) { 855 log.debug("recordProducer of {} in {}", eventID, nodeID); 856 857 // update if the model has been cleared 858 if (memos.size() <= 1) { 859 handleTableUpdate(-1, -1); 860 } 861 862 var nodeMemo = store.findNode(nodeID); 863 String name = ""; 864 if (nodeMemo != null) { 865 var ident = nodeMemo.getSimpleNodeIdent(); 866 if (ident != null) { 867 name = ident.getUserName(); 868 if (name.isEmpty()) { 869 name = ident.getMfgName()+" - "+ident.getModelName()+" - "+ident.getHardwareVersion(); 870 } 871 } 872 } 873 874 875 // if this already exists, skip storing it 876 // if you can, find a matching memo with an empty consumer value 877 TripleMemo empty = null; // an existing empty cell // TODO: switch to int index for handle update below 878 TripleMemo bestEmpty = null;// an existing empty cell with matching consumer// TODO: switch to int index for handle update below 879 TripleMemo sameNodeID = null;// cell with matching consumer // TODO: switch to int index for handle update below 880 for (int i = 0; i < memos.size(); i++) { 881 var memo = memos.get(i); 882 if (memo.eventID.equals(eventID) && memo.rangeSuffix.equals(rangeSuffix) ) { 883 // if nodeID matches, already present; ignore 884 if (nodeID.equals(memo.producer)) { 885 // The node ID is already registered (hence appearing in table) 886 // for this producer. 887 // 888 // This might be the 2nd EventTablePane to process the data, 889 // hence memos would already have been processed. To 890 // handle that, need to fire a change to the table. 891 // 892 // On the other hand, this rapidly erases the 893 // popcorn display, so we disable it for that. 894 // 895 // We also disable it if this call was from a PCER message, 896 // as those are routine and should have been preceeded 897 // by a Producer Identified. Leaving this in results in 898 // excessive refreshes and e.g. frustrating loss of 899 // cell selections. 900 // 901 if (! (popcornModeActive | pcer) ) { 902 handleTableUpdate(i, i); 903 } 904 return; 905 } 906 // if empty producer slot, remember it 907 if (memo.producer == null) { 908 empty = memo; 909 // best empty has matching consumer 910 if (nodeID.equals(memo.consumer)) bestEmpty = memo; 911 } 912 // if same consumer slot, remember it 913 if (nodeID == memo.consumer) { 914 sameNodeID = memo; 915 } 916 } 917 } 918 919 // can we use the bestEmpty? 920 if (bestEmpty != null) { 921 // yes 922 log.trace(" use bestEmpty"); 923 bestEmpty.producer = nodeID; 924 bestEmpty.producerName = name; 925 handleTableUpdate(-1, -1); // TODO: should be rows for bestEmpty, bestEmpty 926 return; 927 } 928 929 // can we just insert into the empty? 930 if (empty != null && sameNodeID == null) { 931 // yes 932 log.trace(" reuse empty"); 933 empty.producer = nodeID; 934 empty.producerName = name; 935 handleTableUpdate(-1, -1); // TODO: should be rows for empty, empty 936 return; 937 } 938 939 // is there a sameNodeID to insert into? 940 if (sameNodeID != null) { 941 // yes 942 log.trace(" switch to sameID"); 943 var fromSaveNodeID = sameNodeID.producer; 944 var fromSaveNodeIDName = sameNodeID.producerName; 945 sameNodeID.producer = nodeID; 946 sameNodeID.producerName = name; 947 // now leave behind old cell to make new one in next block 948 nodeID = fromSaveNodeID; 949 name = fromSaveNodeIDName; 950 } 951 952 // have to make a new one 953 var memo = new TripleMemo( 954 eventID, 955 rangeSuffix, 956 nodeID, 957 name, 958 null, 959 "" 960 ); 961 memos.add(memo); 962 handleTableUpdate(memos.size()-1, memos.size()-1); 963 } 964 965 /** 966 * Record an event-consumer pair 967 * @param eventID Observed event 968 * @param nodeID Node that is known to consume the event 969 * @param rangeSuffix the range mask string or "" for single events 970 */ 971 void recordConsumer(EventID eventID, NodeID nodeID, String rangeSuffix) { 972 log.debug("recordConsumer of {} in {}", eventID, nodeID); 973 974 // update if the model has been cleared 975 if (memos.size() <= 1) { 976 handleTableUpdate(-1, -1); 977 } 978 979 var nodeMemo = store.findNode(nodeID); 980 String name = ""; 981 if (nodeMemo != null) { 982 var ident = nodeMemo.getSimpleNodeIdent(); 983 if (ident != null) { 984 name = ident.getUserName(); 985 if (name.isEmpty()) { 986 name = ident.getMfgName()+" - "+ident.getModelName()+" - "+ident.getHardwareVersion(); 987 } 988 } 989 } 990 991 // if this already exists, skip storing it 992 // if you can, find a matching memo with an empty consumer value 993 TripleMemo empty = null; // an existing empty cell // TODO: switch to int index for handle update below 994 TripleMemo bestEmpty = null;// an existing empty cell with matching producer// TODO: switch to int index for handle update below 995 TripleMemo sameNodeID = null;// cell with matching consumer // TODO: switch to int index for handle update below 996 for (int i = 0; i < memos.size(); i++) { 997 var memo = memos.get(i); 998 if (memo.eventID.equals(eventID) && memo.rangeSuffix.equals(rangeSuffix) ) { 999 // if nodeID matches, already present; ignore 1000 if (nodeID.equals(memo.consumer)) { 1001 // might be 2nd EventTablePane to process the data, 1002 // hence memos would already have been processed. To 1003 // handle that, always fire a change to the table. 1004 log.trace(" nodeDI == memo.consumer"); 1005 handleTableUpdate(i, i); 1006 return; 1007 } 1008 // if empty consumer slot, remember it 1009 if (memo.consumer == null) { 1010 empty = memo; 1011 // best empty has matching producer 1012 if (nodeID.equals(memo.producer)) bestEmpty = memo; 1013 } 1014 // if same producer slot, remember it 1015 if (nodeID == memo.producer) { 1016 sameNodeID = memo; 1017 } 1018 } 1019 } 1020 1021 // can we use the best empty? 1022 if (bestEmpty != null) { 1023 // yes 1024 log.trace(" use bestEmpty"); 1025 bestEmpty.consumer = nodeID; 1026 bestEmpty.consumerName = name; 1027 handleTableUpdate(-1, -1); // should be rows for bestEmpty, bestEmpty 1028 return; 1029 } 1030 1031 // can we just insert into the empty? 1032 if (empty != null && sameNodeID == null) { 1033 // yes 1034 log.trace(" reuse empty"); 1035 empty.consumer = nodeID; 1036 empty.consumerName = name; 1037 handleTableUpdate(-1, -1); // should be rows for empty, empty 1038 return; 1039 } 1040 1041 // is there a sameNodeID to insert into? 1042 if (sameNodeID != null) { 1043 // yes 1044 log.trace(" switch to sameID"); 1045 var fromSaveNodeID = sameNodeID.consumer; 1046 var fromSaveNodeIDName = sameNodeID.consumerName; 1047 sameNodeID.consumer = nodeID; 1048 sameNodeID.consumerName = name; 1049 // now leave behind old cell to make new one 1050 nodeID = fromSaveNodeID; 1051 name = fromSaveNodeIDName; 1052 } 1053 1054 // have to make a new one 1055 log.trace(" make a new one"); 1056 var memo = new TripleMemo( 1057 eventID, 1058 rangeSuffix, 1059 null, 1060 "", 1061 nodeID, 1062 name 1063 ); 1064 memos.add(memo); 1065 handleTableUpdate(memos.size()-1, memos.size()-1); 1066 } 1067 1068 // This causes the display to jump around as it tried to keep 1069 // the selected cell visible. 1070 // TODO: A better approach might be to change 1071 // the cell background color via a custom cell renderer 1072 void highlightProducer(EventID eventID, NodeID nodeID) { 1073 if (!popcornModeActive) return; 1074 log.trace("highlightProducer {} {}", eventID, nodeID); 1075 for (int i = 0; i < memos.size(); i++) { 1076 var memo = memos.get(i); 1077 if (eventID.equals(memo.eventID) && memo.rangeSuffix.equals("") && nodeID.equals(memo.producer)) { 1078 try { 1079 var viewRow = sorter.convertRowIndexToView(i); 1080 log.trace("highlight event ID {} row {} viewRow {}", eventID, i, viewRow); 1081 if (viewRow >= 0) { 1082 table.changeSelection(viewRow, COL_PRODUCER_NODE, false, false); 1083 } 1084 } catch (ArrayIndexOutOfBoundsException e) { 1085 // can happen on first encounter of an event before table is updated 1086 log.trace("failed to highlight event ID {} row {}", eventID.toShortString(), i); 1087 } 1088 } 1089 } 1090 } 1091 1092 // highlights (selects) all the eventID cells with a particular event, 1093 // Most LAFs will move the first of these on-scroll-view. 1094 void highlightEvent(EventID eventID) { 1095 log.trace("highlightEvent {}", eventID); 1096 table.clearSelection(); // clear existing selections 1097 for (int i = 0; i < memos.size(); i++) { 1098 var memo = memos.get(i); 1099 if (eventID.equals(memo.eventID) && memo.rangeSuffix.equals("") ) { 1100 try { 1101 var viewRow = sorter.convertRowIndexToView(i); 1102 log.trace("highlight event ID {} row {} viewRow {}", eventID, i, viewRow); 1103 if (viewRow >= 0) { 1104 table.changeSelection(viewRow, COL_EVENTID, true, false); 1105 } 1106 } catch (ArrayIndexOutOfBoundsException e) { 1107 // can happen on first encounter of an event before table is updated 1108 log.trace("failed to highlight event ID {} row {}", eventID.toShortString(), i); 1109 } 1110 } 1111 } 1112 } 1113 1114 boolean consumerPresent(EventID eventID) { 1115 for (var memo : memos) { 1116 if (memo.eventID.equals(eventID) && memo.rangeSuffix.equals("") ) { 1117 if (memo.consumer!=null) return true; 1118 } 1119 } 1120 return false; 1121 } 1122 1123 boolean producerPresent(EventID eventID) { 1124 for (var memo : memos) { 1125 if (memo.eventID.equals(eventID) && memo.rangeSuffix.equals("") ) { 1126 if (memo.producer!=null) return true; 1127 } 1128 } 1129 return false; 1130 } 1131 1132 static class TripleMemo { 1133 final EventID eventID; 1134 final String rangeSuffix; 1135 // Event name is stored in an OlcbEventNameStore, see getValueAt() 1136 NodeID producer; 1137 String producerName; 1138 NodeID consumer; 1139 String consumerName; 1140 1141 TripleMemo(EventID eventID, String rangeSuffix, NodeID producer, String producerName, 1142 NodeID consumer, String consumerName) { 1143 this.eventID = eventID; 1144 this.rangeSuffix = rangeSuffix; 1145 this.producer = producer; 1146 this.producerName = producerName; 1147 this.consumer = consumer; 1148 this.consumerName = consumerName; 1149 } 1150 } 1151 } 1152 1153 /** 1154 * Internal class to watch OpenLCB traffic 1155 */ 1156 1157 static class Monitor extends MessageDecoder { 1158 1159 Monitor(EventTableDataModel model) { 1160 this.model = model; 1161 } 1162 1163 EventTableDataModel model; 1164 1165 /** 1166 * Handle "Producer/Consumer Event Report" message 1167 * @param msg message to handle 1168 * @param sender connection where it came from 1169 */ 1170 @Override 1171 public void handleProducerConsumerEventReport(ProducerConsumerEventReportMessage msg, Connection sender){ 1172 ThreadingUtil.runOnGUIEventually(()->{ 1173 var nodeID = msg.getSourceNodeID(); 1174 var eventID = msg.getEventID(); 1175 model.recordProducer(eventID, nodeID, "", true); 1176 model.highlightProducer(eventID, nodeID); 1177 }); 1178 } 1179 1180 /** 1181 * Handle "Consumer Identified" message 1182 * @param msg message to handle 1183 * @param sender connection where it came from 1184 */ 1185 @Override 1186 public void handleConsumerIdentified(ConsumerIdentifiedMessage msg, Connection sender){ 1187 ThreadingUtil.runOnGUIEventually(()->{ 1188 var nodeID = msg.getSourceNodeID(); 1189 var eventID = msg.getEventID(); 1190 model.recordConsumer(eventID, nodeID, ""); 1191 }); 1192 } 1193 1194 /** 1195 * Handle "Producer Identified" message 1196 * @param msg message to handle 1197 * @param sender connection where it came from 1198 */ 1199 @Override 1200 public void handleProducerIdentified(ProducerIdentifiedMessage msg, Connection sender){ 1201 ThreadingUtil.runOnGUIEventually(()->{ 1202 var nodeID = msg.getSourceNodeID(); 1203 var eventID = msg.getEventID(); 1204 model.recordProducer(eventID, nodeID, "", false); 1205 }); 1206 } 1207 1208 @Override 1209 public void handleConsumerRangeIdentified(ConsumerRangeIdentifiedMessage msg, Connection sender){ 1210 ThreadingUtil.runOnGUIEventually(()->{ 1211 final var nodeID = msg.getSourceNodeID(); 1212 final var eventID = msg.getEventID(); 1213 1214 final long rangeSuffix = eventID.rangeSuffix(); 1215 // have to set low part of event ID to 0's as it might be 1's 1216 EventID zeroedEID = new EventID(eventID.toLong() & (~rangeSuffix)); 1217 1218 model.recordConsumer(zeroedEID, nodeID, (new EventID(eventID.toLong() | rangeSuffix)).toShortString()); 1219 }); 1220 } 1221 1222 @Override 1223 public void handleProducerRangeIdentified(ProducerRangeIdentifiedMessage msg, Connection sender){ 1224 ThreadingUtil.runOnGUIEventually(()->{ 1225 final var nodeID = msg.getSourceNodeID(); 1226 final var eventID = msg.getEventID(); 1227 1228 final long rangeSuffix = eventID.rangeSuffix(); 1229 // have to set low part of event ID to 0's as it might be 1's 1230 EventID zeroedEID = new EventID(eventID.toLong() & (~rangeSuffix)); 1231 1232 model.recordProducer(zeroedEID, nodeID, (new EventID(eventID.toLong() | rangeSuffix)).toShortString(), false); 1233 }); 1234 } 1235 1236 /* 1237 * We no longer handle "Simple Node Ident Info Reply" messages because of 1238 * excessive redisplays. Instead, we expect the MimicNodeStore to handle 1239 * these and provide the information when requested. 1240 */ 1241 } 1242 1243 /** 1244 * Nested class to create one of these using old-style defaults 1245 */ 1246 public static class Default extends jmri.jmrix.can.swing.CanNamedPaneAction { 1247 1248 public Default() { 1249 super("LCC Event Table", 1250 new jmri.util.swing.sdi.JmriJFrameInterface(), 1251 EventTablePane.class.getName(), 1252 jmri.InstanceManager.getNullableDefault(jmri.jmrix.can.CanSystemConnectionMemo.class)); 1253 } 1254 1255 public Default(String name, jmri.util.swing.WindowInterface iface) { 1256 super(name, 1257 iface, 1258 EventTablePane.class.getName(), 1259 jmri.InstanceManager.getNullableDefault(jmri.jmrix.can.CanSystemConnectionMemo.class)); 1260 } 1261 1262 public Default(String name, Icon icon, jmri.util.swing.WindowInterface iface) { 1263 super(name, 1264 icon, iface, 1265 EventTablePane.class.getName(), 1266 jmri.InstanceManager.getNullableDefault(jmri.jmrix.can.CanSystemConnectionMemo.class)); 1267 } 1268 } 1269 1270 private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(EventTablePane.class); 1271}