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