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 row "+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 log.trace("handleTableUpdated"); 830 final int DELAY = 500; 831 832 if (!pending) { 833 jmri.util.ThreadingUtil.runOnGUIDelayed(() -> { 834 pending = false; 835 log.debug("handleTableUpdated fires table changed"); 836 fireTableDataChanged(); 837 }, DELAY); 838 pending = true; 839 } 840 841 } 842 boolean pending = false; 843 844 /** 845 * Record an event-producer pair 846 * @param eventID Observed event 847 * @param nodeID Node that is known to produce the event 848 * @param rangeSuffix the range mask string or "" for single events 849 */ 850 void recordProducer(EventID eventID, NodeID nodeID, String rangeSuffix) { 851 log.debug("recordProducer of {} in {}", eventID, nodeID); 852 853 // update if the model has been cleared 854 if (memos.size() <= 1) { 855 handleTableUpdate(-1, -1); 856 } 857 858 var nodeMemo = store.findNode(nodeID); 859 String name = ""; 860 if (nodeMemo != null) { 861 var ident = nodeMemo.getSimpleNodeIdent(); 862 if (ident != null) { 863 name = ident.getUserName(); 864 if (name.isEmpty()) { 865 name = ident.getMfgName()+" - "+ident.getModelName()+" - "+ident.getHardwareVersion(); 866 } 867 } 868 } 869 870 871 // if this already exists, skip storing it 872 // if you can, find a matching memo with an empty consumer value 873 TripleMemo empty = null; // an existing empty cell // TODO: switch to int index for handle update below 874 TripleMemo bestEmpty = null;// an existing empty cell with matching consumer// TODO: switch to int index for handle update below 875 TripleMemo sameNodeID = null;// cell with matching consumer // TODO: switch to int index for handle update below 876 for (int i = 0; i < memos.size(); i++) { 877 var memo = memos.get(i); 878 if (memo.eventID.equals(eventID) && memo.rangeSuffix.equals(rangeSuffix) ) { 879 // if nodeID matches, already present; ignore 880 if (nodeID.equals(memo.producer)) { 881 // might be 2nd EventTablePane to process the data, 882 // hence memos would already have been processed. To 883 // handle that, need to fire a change to the table. 884 // On the other hand, this rapidly erases the 885 // popcorn display, so we disable it for that. 886 if (!popcornModeActive) { 887 handleTableUpdate(i, i); 888 } 889 return; 890 } 891 // if empty producer slot, remember it 892 if (memo.producer == null) { 893 empty = memo; 894 // best empty has matching consumer 895 if (nodeID.equals(memo.consumer)) bestEmpty = memo; 896 } 897 // if same consumer slot, remember it 898 if (nodeID == memo.consumer) { 899 sameNodeID = memo; 900 } 901 } 902 } 903 904 // can we use the bestEmpty? 905 if (bestEmpty != null) { 906 // yes 907 log.trace(" use bestEmpty"); 908 bestEmpty.producer = nodeID; 909 bestEmpty.producerName = name; 910 handleTableUpdate(-1, -1); // TODO: should be rows for bestEmpty, bestEmpty 911 return; 912 } 913 914 // can we just insert into the empty? 915 if (empty != null && sameNodeID == null) { 916 // yes 917 log.trace(" reuse empty"); 918 empty.producer = nodeID; 919 empty.producerName = name; 920 handleTableUpdate(-1, -1); // TODO: should be rows for empty, empty 921 return; 922 } 923 924 // is there a sameNodeID to insert into? 925 if (sameNodeID != null) { 926 // yes 927 log.trace(" switch to sameID"); 928 var fromSaveNodeID = sameNodeID.producer; 929 var fromSaveNodeIDName = sameNodeID.producerName; 930 sameNodeID.producer = nodeID; 931 sameNodeID.producerName = name; 932 // now leave behind old cell to make new one in next block 933 nodeID = fromSaveNodeID; 934 name = fromSaveNodeIDName; 935 } 936 937 // have to make a new one 938 var memo = new TripleMemo( 939 eventID, 940 rangeSuffix, 941 nodeID, 942 name, 943 null, 944 "" 945 ); 946 memos.add(memo); 947 handleTableUpdate(memos.size()-1, memos.size()-1); 948 } 949 950 /** 951 * Record an event-consumer pair 952 * @param eventID Observed event 953 * @param nodeID Node that is known to consume the event 954 * @param rangeSuffix the range mask string or "" for single events 955 */ 956 void recordConsumer(EventID eventID, NodeID nodeID, String rangeSuffix) { 957 log.debug("recordConsumer of {} in {}", eventID, nodeID); 958 959 // update if the model has been cleared 960 if (memos.size() <= 1) { 961 handleTableUpdate(-1, -1); 962 } 963 964 var nodeMemo = store.findNode(nodeID); 965 String name = ""; 966 if (nodeMemo != null) { 967 var ident = nodeMemo.getSimpleNodeIdent(); 968 if (ident != null) { 969 name = ident.getUserName(); 970 if (name.isEmpty()) { 971 name = ident.getMfgName()+" - "+ident.getModelName()+" - "+ident.getHardwareVersion(); 972 } 973 } 974 } 975 976 // if this already exists, skip storing it 977 // if you can, find a matching memo with an empty consumer value 978 TripleMemo empty = null; // an existing empty cell // TODO: switch to int index for handle update below 979 TripleMemo bestEmpty = null;// an existing empty cell with matching producer// TODO: switch to int index for handle update below 980 TripleMemo sameNodeID = null;// cell with matching consumer // TODO: switch to int index for handle update below 981 for (int i = 0; i < memos.size(); i++) { 982 var memo = memos.get(i); 983 if (memo.eventID.equals(eventID) && memo.rangeSuffix.equals(rangeSuffix) ) { 984 // if nodeID matches, already present; ignore 985 if (nodeID.equals(memo.consumer)) { 986 // might be 2nd EventTablePane to process the data, 987 // hence memos would already have been processed. To 988 // handle that, always fire a change to the table. 989 log.trace(" nodeDI == memo.consumer"); 990 handleTableUpdate(i, i); 991 return; 992 } 993 // if empty consumer slot, remember it 994 if (memo.consumer == null) { 995 empty = memo; 996 // best empty has matching producer 997 if (nodeID.equals(memo.producer)) bestEmpty = memo; 998 } 999 // if same producer slot, remember it 1000 if (nodeID == memo.producer) { 1001 sameNodeID = memo; 1002 } 1003 } 1004 } 1005 1006 // can we use the best empty? 1007 if (bestEmpty != null) { 1008 // yes 1009 log.trace(" use bestEmpty"); 1010 bestEmpty.consumer = nodeID; 1011 bestEmpty.consumerName = name; 1012 handleTableUpdate(-1, -1); // should be rows for bestEmpty, bestEmpty 1013 return; 1014 } 1015 1016 // can we just insert into the empty? 1017 if (empty != null && sameNodeID == null) { 1018 // yes 1019 log.trace(" reuse empty"); 1020 empty.consumer = nodeID; 1021 empty.consumerName = name; 1022 handleTableUpdate(-1, -1); // should be rows for empty, empty 1023 return; 1024 } 1025 1026 // is there a sameNodeID to insert into? 1027 if (sameNodeID != null) { 1028 // yes 1029 log.trace(" switch to sameID"); 1030 var fromSaveNodeID = sameNodeID.consumer; 1031 var fromSaveNodeIDName = sameNodeID.consumerName; 1032 sameNodeID.consumer = nodeID; 1033 sameNodeID.consumerName = name; 1034 // now leave behind old cell to make new one 1035 nodeID = fromSaveNodeID; 1036 name = fromSaveNodeIDName; 1037 } 1038 1039 // have to make a new one 1040 log.trace(" make a new one"); 1041 var memo = new TripleMemo( 1042 eventID, 1043 rangeSuffix, 1044 null, 1045 "", 1046 nodeID, 1047 name 1048 ); 1049 memos.add(memo); 1050 handleTableUpdate(memos.size()-1, memos.size()-1); 1051 } 1052 1053 // This causes the display to jump around as it tried to keep 1054 // the selected cell visible. 1055 // TODO: A better approach might be to change 1056 // the cell background color via a custom cell renderer 1057 void highlightProducer(EventID eventID, NodeID nodeID) { 1058 if (!popcornModeActive) return; 1059 log.trace("highlightProducer {} {}", eventID, nodeID); 1060 for (int i = 0; i < memos.size(); i++) { 1061 var memo = memos.get(i); 1062 if (eventID.equals(memo.eventID) && memo.rangeSuffix.equals("") && nodeID.equals(memo.producer)) { 1063 try { 1064 var viewRow = sorter.convertRowIndexToView(i); 1065 log.trace("highlight event ID {} row {} viewRow {}", eventID, i, viewRow); 1066 if (viewRow >= 0) { 1067 table.changeSelection(viewRow, COL_PRODUCER_NODE, false, false); 1068 } 1069 } catch (ArrayIndexOutOfBoundsException e) { 1070 // can happen on first encounter of an event before table is updated 1071 log.trace("failed to highlight event ID {} row {}", eventID.toShortString(), i); 1072 } 1073 } 1074 } 1075 } 1076 1077 // highlights (selects) all the eventID cells with a particular event, 1078 // Most LAFs will move the first of these on-scroll-view. 1079 void highlightEvent(EventID eventID) { 1080 log.trace("highlightEvent {}", eventID); 1081 table.clearSelection(); // clear existing selections 1082 for (int i = 0; i < memos.size(); i++) { 1083 var memo = memos.get(i); 1084 if (eventID.equals(memo.eventID) && memo.rangeSuffix.equals("") ) { 1085 try { 1086 var viewRow = sorter.convertRowIndexToView(i); 1087 log.trace("highlight event ID {} row {} viewRow {}", eventID, i, viewRow); 1088 if (viewRow >= 0) { 1089 table.changeSelection(viewRow, COL_EVENTID, true, false); 1090 } 1091 } catch (ArrayIndexOutOfBoundsException e) { 1092 // can happen on first encounter of an event before table is updated 1093 log.trace("failed to highlight event ID {} row {}", eventID.toShortString(), i); 1094 } 1095 } 1096 } 1097 } 1098 1099 boolean consumerPresent(EventID eventID) { 1100 for (var memo : memos) { 1101 if (memo.eventID.equals(eventID) && memo.rangeSuffix.equals("") ) { 1102 if (memo.consumer!=null) return true; 1103 } 1104 } 1105 return false; 1106 } 1107 1108 boolean producerPresent(EventID eventID) { 1109 for (var memo : memos) { 1110 if (memo.eventID.equals(eventID) && memo.rangeSuffix.equals("") ) { 1111 if (memo.producer!=null) return true; 1112 } 1113 } 1114 return false; 1115 } 1116 1117 static class TripleMemo { 1118 final EventID eventID; 1119 final String rangeSuffix; 1120 // Event name is stored in an OlcbEventNameStore, see getValueAt() 1121 NodeID producer; 1122 String producerName; 1123 NodeID consumer; 1124 String consumerName; 1125 1126 TripleMemo(EventID eventID, String rangeSuffix, NodeID producer, String producerName, 1127 NodeID consumer, String consumerName) { 1128 this.eventID = eventID; 1129 this.rangeSuffix = rangeSuffix; 1130 this.producer = producer; 1131 this.producerName = producerName; 1132 this.consumer = consumer; 1133 this.consumerName = consumerName; 1134 } 1135 } 1136 } 1137 1138 /** 1139 * Internal class to watch OpenLCB traffic 1140 */ 1141 1142 static class Monitor extends MessageDecoder { 1143 1144 Monitor(EventTableDataModel model) { 1145 this.model = model; 1146 } 1147 1148 EventTableDataModel model; 1149 1150 /** 1151 * Handle "Producer/Consumer Event Report" message 1152 * @param msg message to handle 1153 * @param sender connection where it came from 1154 */ 1155 @Override 1156 public void handleProducerConsumerEventReport(ProducerConsumerEventReportMessage msg, Connection sender){ 1157 ThreadingUtil.runOnGUIEventually(()->{ 1158 var nodeID = msg.getSourceNodeID(); 1159 var eventID = msg.getEventID(); 1160 model.recordProducer(eventID, nodeID, ""); 1161 model.highlightProducer(eventID, nodeID); 1162 }); 1163 } 1164 1165 /** 1166 * Handle "Consumer Identified" message 1167 * @param msg message to handle 1168 * @param sender connection where it came from 1169 */ 1170 @Override 1171 public void handleConsumerIdentified(ConsumerIdentifiedMessage msg, Connection sender){ 1172 ThreadingUtil.runOnGUIEventually(()->{ 1173 var nodeID = msg.getSourceNodeID(); 1174 var eventID = msg.getEventID(); 1175 model.recordConsumer(eventID, nodeID, ""); 1176 }); 1177 } 1178 1179 /** 1180 * Handle "Producer Identified" message 1181 * @param msg message to handle 1182 * @param sender connection where it came from 1183 */ 1184 @Override 1185 public void handleProducerIdentified(ProducerIdentifiedMessage msg, Connection sender){ 1186 ThreadingUtil.runOnGUIEventually(()->{ 1187 var nodeID = msg.getSourceNodeID(); 1188 var eventID = msg.getEventID(); 1189 model.recordProducer(eventID, nodeID, ""); 1190 }); 1191 } 1192 1193 @Override 1194 public void handleConsumerRangeIdentified(ConsumerRangeIdentifiedMessage msg, Connection sender){ 1195 ThreadingUtil.runOnGUIEventually(()->{ 1196 final var nodeID = msg.getSourceNodeID(); 1197 final var eventID = msg.getEventID(); 1198 1199 final long rangeSuffix = eventID.rangeSuffix(); 1200 // have to set low part of event ID to 0's as it might be 1's 1201 EventID zeroedEID = new EventID(eventID.toLong() & (~rangeSuffix)); 1202 1203 model.recordConsumer(zeroedEID, nodeID, (new EventID(eventID.toLong() | rangeSuffix)).toShortString()); 1204 }); 1205 } 1206 1207 @Override 1208 public void handleProducerRangeIdentified(ProducerRangeIdentifiedMessage msg, Connection sender){ 1209 ThreadingUtil.runOnGUIEventually(()->{ 1210 final var nodeID = msg.getSourceNodeID(); 1211 final var eventID = msg.getEventID(); 1212 1213 final long rangeSuffix = eventID.rangeSuffix(); 1214 // have to set low part of event ID to 0's as it might be 1's 1215 EventID zeroedEID = new EventID(eventID.toLong() & (~rangeSuffix)); 1216 1217 model.recordProducer(zeroedEID, nodeID, (new EventID(eventID.toLong() | rangeSuffix)).toShortString()); 1218 }); 1219 } 1220 1221 /* 1222 * We no longer handle "Simple Node Ident Info Reply" messages because of 1223 * excessive redisplays. Instead, we expect the MimicNodeStore to handle 1224 * these and provide the information when requested. 1225 */ 1226 } 1227 1228 /** 1229 * Nested class to create one of these using old-style defaults 1230 */ 1231 public static class Default extends jmri.jmrix.can.swing.CanNamedPaneAction { 1232 1233 public Default() { 1234 super("LCC Event Table", 1235 new jmri.util.swing.sdi.JmriJFrameInterface(), 1236 EventTablePane.class.getName(), 1237 jmri.InstanceManager.getNullableDefault(jmri.jmrix.can.CanSystemConnectionMemo.class)); 1238 } 1239 1240 public Default(String name, jmri.util.swing.WindowInterface iface) { 1241 super(name, 1242 iface, 1243 EventTablePane.class.getName(), 1244 jmri.InstanceManager.getNullableDefault(jmri.jmrix.can.CanSystemConnectionMemo.class)); 1245 } 1246 1247 public Default(String name, Icon icon, jmri.util.swing.WindowInterface iface) { 1248 super(name, 1249 icon, iface, 1250 EventTablePane.class.getName(), 1251 jmri.InstanceManager.getNullableDefault(jmri.jmrix.can.CanSystemConnectionMemo.class)); 1252 } 1253 } 1254 1255 private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(EventTablePane.class); 1256}