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