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 as dotted-hex string 565 * @return true is the event name tag is present 566 */ 567 public boolean isEventNamePresent(EventID eventID) { 568 var name = nameStore.getEventName(eventID); 569 if (name == null) return false; 570 return ! name.isEmpty(); 571 } 572 573 /** 574 * Set up filtering of displayed rows 575 */ 576 private void filter() { 577 RowFilter<EventTableDataModel, Integer> rf = new RowFilter<EventTableDataModel, Integer>() { 578 /** 579 * @return true if row is to be displayed 580 */ 581 @Override 582 public boolean include(RowFilter.Entry<? extends EventTableDataModel, ? extends Integer> entry) { 583 584 int row = entry.getIdentifier(); 585 586 var name = model.getValueAt(row, EventTableDataModel.COL_EVENTNAME); 587 if ( showRequiresLabel.isSelected() && (name == null || name.toString().isEmpty()) ) return false; 588 589 if ( showRequiresMatch.isSelected()) { 590 var memo = model.getTripleMemo(row); 591 592 if (memo.producer == null && !model.producerPresent(memo.eventID)) { 593 // no matching producer 594 return false; 595 } 596 597 if (memo.consumer == null && !model.consumerPresent(memo.eventID)) { 598 // no matching consumer 599 return false; 600 } 601 } 602 603 // check for group match 604 if ( matchGroupName.getSelectedIndex() > 0) { // -1 is empty combobox 605 String group = matchGroupName.getSelectedItem().toString(); 606 var memo = model.getTripleMemo(row); 607 if ( (! groupStore.isNodeInGroup(memo.producer, group)) 608 && (! groupStore.isNodeInGroup(memo.consumer, group)) ) { 609 return false; 610 } 611 } 612 613 // passed all filters 614 return true; 615 } 616 }; 617 sorter.setRowFilter(rf); 618 } 619 620 /** 621 * Nested class to hold data model 622 */ 623 protected static class EventTableDataModel extends AbstractTableModel { 624 625 EventTableDataModel(MimicNodeStore store, EventTable stdEventTable, OlcbEventNameStore nameStore) { 626 this.store = store; 627 this.stdEventTable = stdEventTable; 628 this.nameStore = nameStore; 629 630 loadIdTagEventIDs(); 631 } 632 633 static final int COL_EVENTID = 0; 634 static final int COL_EVENTNAME = 1; 635 static final int COL_PRODUCER_NODE = 2; 636 static final int COL_PRODUCER_NAME = 3; 637 static final int COL_CONSUMER_NODE = 4; 638 static final int COL_CONSUMER_NAME = 5; 639 static final int COL_CONTEXT_INFO = 6; 640 static final int COL_COUNT = 7; 641 642 MimicNodeStore store; 643 EventTable stdEventTable; 644 OlcbEventNameStore nameStore; 645 IdTagManager tagManager; 646 JTable table; 647 TableRowSorter<EventTableDataModel> sorter; 648 boolean popcornModeActive = false; 649 650 TripleMemo getTripleMemo(int row) { 651 if (row >= memos.size()) { 652 return null; 653 } 654 return memos.get(row); 655 } 656 657 void loadIdTagEventIDs() { 658 // are there events in the IdTags? If so, add them 659 for (var eventID: nameStore.getMatches()) { 660 var memo = new TripleMemo( 661 eventID, 662 "", 663 null, 664 "", 665 null, 666 "" 667 ); 668 // check to see if already in there: 669 boolean found = false; 670 for (var check : memos) { 671 if (memo.eventID.equals(check.eventID)) { 672 found = true; 673 break; 674 } 675 } 676 if (! found) { 677 memos.add(memo); 678 } 679 } 680 } 681 682 683 @Override 684 public Object getValueAt(int row, int col) { 685 if (row >= memos.size()) { 686 log.warn("request out of range: {} greater than {}", row, memos.size()); 687 return "Illegal col "+row+" "+col; 688 } 689 var memo = memos.get(row); 690 switch (col) { 691 case COL_EVENTID: 692 String retval = memo.eventID.toShortString(); 693 if (!memo.rangeSuffix.isEmpty()) retval += " - "+memo.rangeSuffix; 694 return retval; 695 case COL_EVENTNAME: 696 var name = nameStore.getEventName(memo.eventID); 697 if (name != null) { 698 return name; 699 } else { 700 return ""; 701 } 702 703 case COL_PRODUCER_NODE: 704 return memo.producer != null ? memo.producer.toString() : ""; 705 case COL_PRODUCER_NAME: return memo.producerName; 706 case COL_CONSUMER_NODE: 707 return memo.consumer != null ? memo.consumer.toString() : ""; 708 case COL_CONSUMER_NAME: return memo.consumerName; 709 case COL_CONTEXT_INFO: 710 711 // When table is constrained, these rows don't match up, need to find constrained row 712 var viewRow = sorter.convertRowIndexToView(row); 713 714 if (lineIncrement <= 0) { // load cache variable? 715 if (viewRow >= 0) { 716 lineIncrement = table.getRowHeight(viewRow); // do this if valid row 717 } else { 718 lineIncrement = table.getFont().getSize()*13/10; // line spacing from font if not valid row 719 } 720 } 721 722 var result = new StringBuilder(); 723 724 var height = lineIncrement/3; // for margins 725 var first = true; // no \n before first line 726 727 // interpret eventID and start with that if present 728 String interp = memo.eventID.parse(); 729 if (interp != null && !interp.isEmpty()) { 730 height += lineIncrement; 731 result.append(interp); 732 first = false; 733 } 734 735 // scan the CD/CDI information as available 736 for (var entry : stdEventTable.getEventInfo(memo.eventID).getAllEntries()) { 737 if (!first) result.append("\n"); 738 first = false; 739 height += lineIncrement; 740 result.append(entry.getDescription()); 741 } 742 743 // set height for multi-line output in the cell 744 if (viewRow >= 0) { // make sure it's a valid visible row in the table; -1 signals not 745 // set height 746 if (height < lineIncrement) { 747 height = height+lineIncrement; // when no lines, assume 1 748 } 749 table.setRowHeight(viewRow, height); 750 } else { 751 lineIncrement = -1; // reload on next request, hoping for a viewed row 752 } 753 return new String(result); 754 default: return "Illegal row "+row+" "+col; 755 } 756 } 757 758 int lineIncrement = -1; // cache the line spacing for multi-line cells; 759 // this gets the value before any adjustments done 760 761 @Override 762 public void setValueAt(Object value, int row, int col) { 763 if (col != COL_EVENTNAME) return; 764 if (row >= memos.size()) { 765 log.warn("request out of range: {} greater than {}", row, memos.size()); 766 return; 767 } 768 var memo = memos.get(row); 769 nameStore.addMatch(memo.eventID, value.toString()); 770 } 771 772 @Override 773 public int getColumnCount() { 774 return COL_COUNT; 775 } 776 777 @Override 778 public String getColumnName(int col) { 779 switch (col) { 780 case COL_EVENTID: return "Event ID"; 781 case COL_EVENTNAME: return "Event Name"; 782 case COL_PRODUCER_NODE: return "Producer Node"; 783 case COL_PRODUCER_NAME: return "Producer Node Name"; 784 case COL_CONSUMER_NODE: return "Consumer Node"; 785 case COL_CONSUMER_NAME: return "Consumer Node Name"; 786 case COL_CONTEXT_INFO: return "Also Known As"; 787 default: return "ERROR "+col; 788 } 789 } 790 791 @Override 792 public int getRowCount() { 793 return memos.size(); 794 } 795 796 @Override 797 public boolean isCellEditable(int row, int col) { 798 return col == COL_EVENTNAME; 799 } 800 801 @Override 802 public Class<?> getColumnClass(int col) { 803 return String.class; 804 } 805 806 /** 807 * Remove all existing data, generally just in advance of an update 808 */ 809 @SuppressFBWarnings(value = "ST_WRITE_TO_STATIC_FROM_INSTANCE_METHOD") // Swing thread deconflicts 810 void clear() { 811 memos = new ArrayList<>(); 812 fireTableDataChanged(); // don't queue this one, must be immediate 813 } 814 815 // static so the data remains available through a window close-open cycle 816 static ArrayList<TripleMemo> memos = new ArrayList<>(); 817 818 /** 819 * Notify the table that the contents have changed. 820 * To reduce CPU load, this batches the changes 821 * @param start first row changed; -1 means entire table (not used yet) 822 * @param end last row changed; -1 means entire table (not used yet) 823 */ 824 void handleTableUpdate(int start, int end) { 825 log.trace("handleTableUpdated"); 826 final int DELAY = 500; 827 828 if (!pending) { 829 jmri.util.ThreadingUtil.runOnGUIDelayed(() -> { 830 pending = false; 831 log.debug("handleTableUpdated fires table changed"); 832 fireTableDataChanged(); 833 }, DELAY); 834 pending = true; 835 } 836 837 } 838 boolean pending = false; 839 840 /** 841 * Record an event-producer pair 842 * @param eventID Observed event 843 * @param nodeID Node that is known to produce the event 844 * @param rangeSuffix the range mask string or "" for single events 845 */ 846 void recordProducer(EventID eventID, NodeID nodeID, String rangeSuffix) { 847 log.debug("recordProducer of {} in {}", eventID, nodeID); 848 849 // update if the model has been cleared 850 if (memos.size() <= 1) { 851 handleTableUpdate(-1, -1); 852 } 853 854 var nodeMemo = store.findNode(nodeID); 855 String name = ""; 856 if (nodeMemo != null) { 857 var ident = nodeMemo.getSimpleNodeIdent(); 858 if (ident != null) { 859 name = ident.getUserName(); 860 if (name.isEmpty()) { 861 name = ident.getMfgName()+" - "+ident.getModelName()+" - "+ident.getHardwareVersion(); 862 } 863 } 864 } 865 866 867 // if this already exists, skip storing it 868 // if you can, find a matching memo with an empty consumer value 869 TripleMemo empty = null; // an existing empty cell // TODO: switch to int index for handle update below 870 TripleMemo bestEmpty = null;// an existing empty cell with matching consumer// TODO: switch to int index for handle update below 871 TripleMemo sameNodeID = null;// cell with matching consumer // TODO: switch to int index for handle update below 872 for (int i = 0; i < memos.size(); i++) { 873 var memo = memos.get(i); 874 if (memo.eventID.equals(eventID) && memo.rangeSuffix.equals(rangeSuffix) ) { 875 // if nodeID matches, already present; ignore 876 if (nodeID.equals(memo.producer)) { 877 // might be 2nd EventTablePane to process the data, 878 // hence memos would already have been processed. To 879 // handle that, need to fire a change to the table. 880 // On the other hand, this rapidly erases the 881 // popcorn display, so we disable it for that. 882 if (!popcornModeActive) { 883 handleTableUpdate(i, i); 884 } 885 return; 886 } 887 // if empty producer slot, remember it 888 if (memo.producer == null) { 889 empty = memo; 890 // best empty has matching consumer 891 if (nodeID.equals(memo.consumer)) bestEmpty = memo; 892 } 893 // if same consumer slot, remember it 894 if (nodeID == memo.consumer) { 895 sameNodeID = memo; 896 } 897 } 898 } 899 900 // can we use the bestEmpty? 901 if (bestEmpty != null) { 902 // yes 903 log.trace(" use bestEmpty"); 904 bestEmpty.producer = nodeID; 905 bestEmpty.producerName = name; 906 handleTableUpdate(-1, -1); // TODO: should be rows for bestEmpty, bestEmpty 907 return; 908 } 909 910 // can we just insert into the empty? 911 if (empty != null && sameNodeID == null) { 912 // yes 913 log.trace(" reuse empty"); 914 empty.producer = nodeID; 915 empty.producerName = name; 916 handleTableUpdate(-1, -1); // TODO: should be rows for empty, empty 917 return; 918 } 919 920 // is there a sameNodeID to insert into? 921 if (sameNodeID != null) { 922 // yes 923 log.trace(" switch to sameID"); 924 var fromSaveNodeID = sameNodeID.producer; 925 var fromSaveNodeIDName = sameNodeID.producerName; 926 sameNodeID.producer = nodeID; 927 sameNodeID.producerName = name; 928 // now leave behind old cell to make new one in next block 929 nodeID = fromSaveNodeID; 930 name = fromSaveNodeIDName; 931 } 932 933 // have to make a new one 934 var memo = new TripleMemo( 935 eventID, 936 rangeSuffix, 937 nodeID, 938 name, 939 null, 940 "" 941 ); 942 memos.add(memo); 943 handleTableUpdate(memos.size()-1, memos.size()-1); 944 } 945 946 /** 947 * Record an event-consumer pair 948 * @param eventID Observed event 949 * @param nodeID Node that is known to consume the event 950 * @param rangeSuffix the range mask string or "" for single events 951 */ 952 void recordConsumer(EventID eventID, NodeID nodeID, String rangeSuffix) { 953 log.debug("recordConsumer of {} in {}", eventID, nodeID); 954 955 // update if the model has been cleared 956 if (memos.size() <= 1) { 957 handleTableUpdate(-1, -1); 958 } 959 960 var nodeMemo = store.findNode(nodeID); 961 String name = ""; 962 if (nodeMemo != null) { 963 var ident = nodeMemo.getSimpleNodeIdent(); 964 if (ident != null) { 965 name = ident.getUserName(); 966 if (name.isEmpty()) { 967 name = ident.getMfgName()+" - "+ident.getModelName()+" - "+ident.getHardwareVersion(); 968 } 969 } 970 } 971 972 // if this already exists, skip storing it 973 // if you can, find a matching memo with an empty consumer value 974 TripleMemo empty = null; // an existing empty cell // TODO: switch to int index for handle update below 975 TripleMemo bestEmpty = null;// an existing empty cell with matching producer// TODO: switch to int index for handle update below 976 TripleMemo sameNodeID = null;// cell with matching consumer // TODO: switch to int index for handle update below 977 for (int i = 0; i < memos.size(); i++) { 978 var memo = memos.get(i); 979 if (memo.eventID.equals(eventID) && memo.rangeSuffix.equals(rangeSuffix) ) { 980 // if nodeID matches, already present; ignore 981 if (nodeID.equals(memo.consumer)) { 982 // might be 2nd EventTablePane to process the data, 983 // hence memos would already have been processed. To 984 // handle that, always fire a change to the table. 985 log.trace(" nodeDI == memo.consumer"); 986 handleTableUpdate(i, i); 987 return; 988 } 989 // if empty consumer slot, remember it 990 if (memo.consumer == null) { 991 empty = memo; 992 // best empty has matching producer 993 if (nodeID.equals(memo.producer)) bestEmpty = memo; 994 } 995 // if same producer slot, remember it 996 if (nodeID == memo.producer) { 997 sameNodeID = memo; 998 } 999 } 1000 } 1001 1002 // can we use the best empty? 1003 if (bestEmpty != null) { 1004 // yes 1005 log.trace(" use bestEmpty"); 1006 bestEmpty.consumer = nodeID; 1007 bestEmpty.consumerName = name; 1008 handleTableUpdate(-1, -1); // should be rows for bestEmpty, bestEmpty 1009 return; 1010 } 1011 1012 // can we just insert into the empty? 1013 if (empty != null && sameNodeID == null) { 1014 // yes 1015 log.trace(" reuse empty"); 1016 empty.consumer = nodeID; 1017 empty.consumerName = name; 1018 handleTableUpdate(-1, -1); // should be rows for empty, empty 1019 return; 1020 } 1021 1022 // is there a sameNodeID to insert into? 1023 if (sameNodeID != null) { 1024 // yes 1025 log.trace(" switch to sameID"); 1026 var fromSaveNodeID = sameNodeID.consumer; 1027 var fromSaveNodeIDName = sameNodeID.consumerName; 1028 sameNodeID.consumer = nodeID; 1029 sameNodeID.consumerName = name; 1030 // now leave behind old cell to make new one 1031 nodeID = fromSaveNodeID; 1032 name = fromSaveNodeIDName; 1033 } 1034 1035 // have to make a new one 1036 log.trace(" make a new one"); 1037 var memo = new TripleMemo( 1038 eventID, 1039 rangeSuffix, 1040 null, 1041 "", 1042 nodeID, 1043 name 1044 ); 1045 memos.add(memo); 1046 handleTableUpdate(memos.size()-1, memos.size()-1); 1047 } 1048 1049 // This causes the display to jump around as it tried to keep 1050 // the selected cell visible. 1051 // TODO: A better approach might be to change 1052 // the cell background color via a custom cell renderer 1053 void highlightProducer(EventID eventID, NodeID nodeID) { 1054 if (!popcornModeActive) return; 1055 log.trace("highlightProducer {} {}", eventID, nodeID); 1056 for (int i = 0; i < memos.size(); i++) { 1057 var memo = memos.get(i); 1058 if (eventID.equals(memo.eventID) && memo.rangeSuffix.equals("") && nodeID.equals(memo.producer)) { 1059 try { 1060 var viewRow = sorter.convertRowIndexToView(i); 1061 log.trace("highlight event ID {} row {} viewRow {}", eventID, i, viewRow); 1062 if (viewRow >= 0) { 1063 table.changeSelection(viewRow, COL_PRODUCER_NODE, false, false); 1064 } 1065 } catch (ArrayIndexOutOfBoundsException e) { 1066 // can happen on first encounter of an event before table is updated 1067 log.trace("failed to highlight event ID {} row {}", eventID.toShortString(), i); 1068 } 1069 } 1070 } 1071 } 1072 1073 // highlights (selects) all the eventID cells with a particular event, 1074 // Most LAFs will move the first of these on-scroll-view. 1075 void highlightEvent(EventID eventID) { 1076 log.trace("highlightEvent {}", eventID); 1077 table.clearSelection(); // clear existing selections 1078 for (int i = 0; i < memos.size(); i++) { 1079 var memo = memos.get(i); 1080 if (eventID.equals(memo.eventID) && memo.rangeSuffix.equals("") ) { 1081 try { 1082 var viewRow = sorter.convertRowIndexToView(i); 1083 log.trace("highlight event ID {} row {} viewRow {}", eventID, i, viewRow); 1084 if (viewRow >= 0) { 1085 table.changeSelection(viewRow, COL_EVENTID, true, false); 1086 } 1087 } catch (ArrayIndexOutOfBoundsException e) { 1088 // can happen on first encounter of an event before table is updated 1089 log.trace("failed to highlight event ID {} row {}", eventID.toShortString(), i); 1090 } 1091 } 1092 } 1093 } 1094 1095 boolean consumerPresent(EventID eventID) { 1096 for (var memo : memos) { 1097 if (memo.eventID.equals(eventID) && memo.rangeSuffix.equals("") ) { 1098 if (memo.consumer!=null) return true; 1099 } 1100 } 1101 return false; 1102 } 1103 1104 boolean producerPresent(EventID eventID) { 1105 for (var memo : memos) { 1106 if (memo.eventID.equals(eventID) && memo.rangeSuffix.equals("") ) { 1107 if (memo.producer!=null) return true; 1108 } 1109 } 1110 return false; 1111 } 1112 1113 static class TripleMemo { 1114 final EventID eventID; 1115 final String rangeSuffix; 1116 // Event name is stored in an OlcbEventNameStore, see getValueAt() 1117 NodeID producer; 1118 String producerName; 1119 NodeID consumer; 1120 String consumerName; 1121 1122 TripleMemo(EventID eventID, String rangeSuffix, NodeID producer, String producerName, 1123 NodeID consumer, String consumerName) { 1124 this.eventID = eventID; 1125 this.rangeSuffix = rangeSuffix; 1126 this.producer = producer; 1127 this.producerName = producerName; 1128 this.consumer = consumer; 1129 this.consumerName = consumerName; 1130 } 1131 } 1132 } 1133 1134 /** 1135 * Internal class to watch OpenLCB traffic 1136 */ 1137 1138 static class Monitor extends MessageDecoder { 1139 1140 Monitor(EventTableDataModel model) { 1141 this.model = model; 1142 } 1143 1144 EventTableDataModel model; 1145 1146 /** 1147 * Handle "Producer/Consumer Event Report" message 1148 * @param msg message to handle 1149 * @param sender connection where it came from 1150 */ 1151 @Override 1152 public void handleProducerConsumerEventReport(ProducerConsumerEventReportMessage msg, Connection sender){ 1153 ThreadingUtil.runOnGUIEventually(()->{ 1154 var nodeID = msg.getSourceNodeID(); 1155 var eventID = msg.getEventID(); 1156 model.recordProducer(eventID, nodeID, ""); 1157 model.highlightProducer(eventID, nodeID); 1158 }); 1159 } 1160 1161 /** 1162 * Handle "Consumer Identified" message 1163 * @param msg message to handle 1164 * @param sender connection where it came from 1165 */ 1166 @Override 1167 public void handleConsumerIdentified(ConsumerIdentifiedMessage msg, Connection sender){ 1168 ThreadingUtil.runOnGUIEventually(()->{ 1169 var nodeID = msg.getSourceNodeID(); 1170 var eventID = msg.getEventID(); 1171 model.recordConsumer(eventID, nodeID, ""); 1172 }); 1173 } 1174 1175 /** 1176 * Handle "Producer Identified" message 1177 * @param msg message to handle 1178 * @param sender connection where it came from 1179 */ 1180 @Override 1181 public void handleProducerIdentified(ProducerIdentifiedMessage msg, Connection sender){ 1182 ThreadingUtil.runOnGUIEventually(()->{ 1183 var nodeID = msg.getSourceNodeID(); 1184 var eventID = msg.getEventID(); 1185 model.recordProducer(eventID, nodeID, ""); 1186 }); 1187 } 1188 1189 @Override 1190 public void handleConsumerRangeIdentified(ConsumerRangeIdentifiedMessage msg, Connection sender){ 1191 ThreadingUtil.runOnGUIEventually(()->{ 1192 final var nodeID = msg.getSourceNodeID(); 1193 final var eventID = msg.getEventID(); 1194 1195 final long rangeSuffix = eventID.rangeSuffix(); 1196 // have to set low part of event ID to 0's as it might be 1's 1197 EventID zeroedEID = new EventID(eventID.toLong() & (~rangeSuffix)); 1198 1199 model.recordConsumer(zeroedEID, nodeID, (new EventID(eventID.toLong() | rangeSuffix)).toShortString()); 1200 }); 1201 } 1202 1203 @Override 1204 public void handleProducerRangeIdentified(ProducerRangeIdentifiedMessage msg, Connection sender){ 1205 ThreadingUtil.runOnGUIEventually(()->{ 1206 final var nodeID = msg.getSourceNodeID(); 1207 final var eventID = msg.getEventID(); 1208 1209 final long rangeSuffix = eventID.rangeSuffix(); 1210 // have to set low part of event ID to 0's as it might be 1's 1211 EventID zeroedEID = new EventID(eventID.toLong() & (~rangeSuffix)); 1212 1213 model.recordProducer(zeroedEID, nodeID, (new EventID(eventID.toLong() | rangeSuffix)).toShortString()); 1214 }); 1215 } 1216 1217 /* 1218 * We no longer handle "Simple Node Ident Info Reply" messages because of 1219 * excessive redisplays. Instead, we expect the MimicNodeStore to handle 1220 * these and provide the information when requested. 1221 */ 1222 } 1223 1224 /** 1225 * Nested class to create one of these using old-style defaults 1226 */ 1227 public static class Default extends jmri.jmrix.can.swing.CanNamedPaneAction { 1228 1229 public Default() { 1230 super("LCC Event Table", 1231 new jmri.util.swing.sdi.JmriJFrameInterface(), 1232 EventTablePane.class.getName(), 1233 jmri.InstanceManager.getNullableDefault(jmri.jmrix.can.CanSystemConnectionMemo.class)); 1234 } 1235 1236 public Default(String name, jmri.util.swing.WindowInterface iface) { 1237 super(name, 1238 iface, 1239 EventTablePane.class.getName(), 1240 jmri.InstanceManager.getNullableDefault(jmri.jmrix.can.CanSystemConnectionMemo.class)); 1241 } 1242 1243 public Default(String name, Icon icon, jmri.util.swing.WindowInterface iface) { 1244 super(name, 1245 icon, iface, 1246 EventTablePane.class.getName(), 1247 jmri.InstanceManager.getNullableDefault(jmri.jmrix.can.CanSystemConnectionMemo.class)); 1248 } 1249 } 1250 1251 private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(EventTablePane.class); 1252}