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.OlcbConstants; 015import jmri.jmrix.openlcb.OlcbSensor; 016import jmri.jmrix.openlcb.OlcbTurnout; 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; 025 026import org.openlcb.*; 027import org.openlcb.implementations.*; 028import org.openlcb.swing.*; 029 030 031/** 032 * Pane for displaying a table of relationships of nodes, producers and consumers 033 * 034 * @author Bob Jacobsen Copyright (C) 2023 035 * @since 5.3.4 036 */ 037public class EventTablePane extends jmri.util.swing.JmriPanel 038 implements jmri.jmrix.can.swing.CanPanelInterface { 039 040 protected CanSystemConnectionMemo memo; 041 Connection connection; 042 NodeID nid; 043 044 MimicNodeStore store; 045 EventTableDataModel model; 046 JTable table; 047 Monitor monitor; 048 049 JCheckBox showRequiresLabel; // requires a user-provided name to display 050 JCheckBox showRequiresMatch; // requires at least one consumer and one producer exist to display 051 JCheckBox popcorn; // popcorn mode displays events in real time 052 053 JFormattedTextField findID; 054 055 private transient TableRowSorter<EventTableDataModel> sorter; 056 057 public String getTitle(String menuTitle) { 058 return Bundle.getMessage("TitleEventTable"); 059 } 060 061 @Override 062 public void initComponents(CanSystemConnectionMemo memo) { 063 this.memo = memo; 064 this.connection = memo.get(Connection.class); 065 this.nid = memo.get(NodeID.class); 066 067 store = memo.get(MimicNodeStore.class); 068 EventTable stdEventTable = memo.get(OlcbInterface.class).getEventTable(); 069 if (stdEventTable == null) log.warn("no OLCB EventTable found"); 070 071 model = new EventTableDataModel(store, stdEventTable); 072 sorter = new TableRowSorter<>(model); 073 074 075 setLayout(new BoxLayout(this, BoxLayout.Y_AXIS)); 076 077 // Add to GUI here 078 079 table = new JTable(model); 080 081 model.table = table; 082 model.sorter = sorter; 083 table.setAutoCreateRowSorter(true); 084 table.setRowSorter(sorter); 085 table.setDefaultRenderer(String.class, new MultiLineCellRenderer()); 086 table.setShowGrid(true); 087 table.setGridColor(Color.BLACK); 088 table.getTableHeader().setBackground(Color.LIGHT_GRAY); 089 table.setName("jmri.jmrix.openlcb.swing.eventtable.EventTablePane.table"); // for persistence 090 table.setColumnSelectionAllowed(true); 091 table.setRowSelectionAllowed(true); 092 093 var scrollPane = new JScrollPane(table); 094 095 // restore the column layout and start monitoring it 096 InstanceManager.getOptionalDefault(JmriJTablePersistenceManager.class).ifPresent((tpm) -> { 097 tpm.resetState(table); 098 tpm.persist(table); 099 }); 100 101 add(scrollPane); 102 103 var buttonPanel = new JPanel(); 104 buttonPanel.setLayout(new jmri.util.swing.WrapLayout()); 105 106 add(buttonPanel); 107 108 var updateButton = new JButton(Bundle.getMessage("ButtonUpdate")); 109 updateButton.addActionListener(this::sendRequestEvents); 110 buttonPanel.add(updateButton); 111 112 showRequiresLabel = new JCheckBox(Bundle.getMessage("BoxShowRequiresLabel")); 113 showRequiresLabel.addActionListener((ActionEvent e) -> { 114 filter(); 115 }); 116 buttonPanel.add(showRequiresLabel); 117 118 showRequiresMatch = new JCheckBox(Bundle.getMessage("BoxShowRequiresMatch")); 119 showRequiresMatch.addActionListener((ActionEvent e) -> { 120 filter(); 121 }); 122 buttonPanel.add(showRequiresMatch); 123 124 popcorn = new JCheckBox(Bundle.getMessage("BoxPopcorn")); 125 popcorn.addActionListener((ActionEvent e) -> { 126 popcornButtonChanged(); 127 }); 128 buttonPanel.add(popcorn); 129 130 JPanel findpanel = new JPanel(); 131 buttonPanel.add(findpanel); 132 133 JButton find = new JButton("Find"); 134 findpanel.add(find); 135 find.addActionListener(this::findRequested); 136 137 findID = EventIdTextField.getEventIdTextField(); 138 findID.addActionListener(this::findRequested); 139 findpanel.add(findID); 140 141 JButton sensorButton = new JButton("Names from Sensors"); 142 sensorButton.addActionListener(this::sensorRequested); 143 buttonPanel.add(sensorButton); 144 145 JButton turnoutButton = new JButton("Names from Turnouts"); 146 turnoutButton.addActionListener(this::turnoutRequested); 147 buttonPanel.add(turnoutButton); 148 149 buttonPanel.setMaximumSize(buttonPanel.getPreferredSize()); 150 151 // hook up to receive traffic 152 monitor = new Monitor(model); 153 memo.get(OlcbInterface.class).registerMessageListener(monitor); 154 } 155 156 public EventTablePane() { 157 // interface and connections built in initComponents(..) 158 } 159 160 @Override 161 public void dispose() { 162 // Save the column layout 163 InstanceManager.getOptionalDefault(JmriJTablePersistenceManager.class).ifPresent((tpm) -> { 164 tpm.stopPersisting(table); 165 }); 166 // remove traffic connection 167 memo.get(OlcbInterface.class).unRegisterMessageListener(monitor); 168 // drop model connections 169 model = null; 170 monitor = null; 171 // and complete this 172 super.dispose(); 173 } 174 175 @Override 176 public java.util.List<JMenu> getMenus() { 177 // create a file menu 178 var retval = new ArrayList<JMenu>(); 179 var fileMenu = new JMenu("File"); 180 fileMenu.setMnemonic(KeyEvent.VK_F); 181 var csvItem = new JMenuItem("Save to CSV...", KeyEvent.VK_S); 182 KeyStroke ctrlSKeyStroke = KeyStroke.getKeyStroke("control S"); 183 if (jmri.util.SystemType.isMacOSX()) { 184 ctrlSKeyStroke = KeyStroke.getKeyStroke("meta S"); 185 } 186 csvItem.setAccelerator(ctrlSKeyStroke); 187 csvItem.addActionListener(this::writeToCsvFile); 188 fileMenu.add(csvItem); 189 retval.add(fileMenu); 190 return retval; 191 } 192 193 @Override 194 public String getHelpTarget() { 195 return "package.jmri.jmrix.openlcb.swing.eventtable.EventTablePane"; 196 } 197 198 @Override 199 public String getTitle() { 200 if (memo != null) { 201 return (memo.getUserName() + " Event Table"); 202 } 203 return getTitle(Bundle.getMessage("TitleEventTable")); 204 } 205 206 public void sendRequestEvents(java.awt.event.ActionEvent e) { 207 model.clear(); 208 209 model.loadIdTagEventIDs(); 210 model.handleTableUpdate(-1, -1); 211 212 final int IDENTIFY_EVENTS_DELAY = 125; // msec between operations - 64 events at speed 213 int nextDelay = 0; 214 215 // assumes that a VerifyNodes has been done and all nodes are in the MimicNodeStore 216 for (var memo : store.getNodeMemos()) { 217 218 jmri.util.ThreadingUtil.runOnLayoutDelayed(() -> { 219 var destNodeID = memo.getNodeID(); 220 log.trace("send IdentifyEventsAddressedMessage {} {}", nid, destNodeID); 221 Message m = new IdentifyEventsAddressedMessage(nid, destNodeID); 222 connection.put(m, null); 223 }, nextDelay); 224 225 nextDelay += IDENTIFY_EVENTS_DELAY; 226 } 227 // Our reference to the node names in the MimicNodeStore will 228 // trigger a SNIP request if we don't have them yet. In case that happens 229 // we want to trigger a table refresh to make sure they get displayed. 230 final int REFRESH_INTERVAL = 1000; 231 jmri.util.ThreadingUtil.runOnGUIDelayed(() -> { 232 model.handleTableUpdate(-1,-1); 233 }, nextDelay+REFRESH_INTERVAL); 234 jmri.util.ThreadingUtil.runOnGUIDelayed(() -> { 235 model.handleTableUpdate(-1,-1); 236 }, nextDelay+REFRESH_INTERVAL*2); 237 jmri.util.ThreadingUtil.runOnGUIDelayed(() -> { 238 model.handleTableUpdate(-1,-1); 239 }, nextDelay+REFRESH_INTERVAL*4); 240 241 } 242 243 void popcornButtonChanged() { 244 model.popcornModeActive = popcorn.isSelected(); 245 log.debug("Popcorn mode {}", model.popcornModeActive); 246 } 247 248 249 public void findRequested(java.awt.event.ActionEvent e) { 250 log.debug("Request find event {}", findID.getText()); 251 model.highlightEvent(new EventID(findID.getText())); 252 } 253 254 public void sensorRequested(java.awt.event.ActionEvent e) { 255 // loop over sensors to find the OpenLCB ones 256 var beans = InstanceManager.getDefault(SensorManager.class).getNamedBeanSet(); 257 var tagmgr = InstanceManager.getDefault(IdTagManager.class); 258 for (NamedBean bean : beans ) { 259 if (bean instanceof OlcbSensor) { 260 oneSensorToTag(true, bean, tagmgr); // active 261 oneSensorToTag(false, bean, tagmgr); // inactive 262 } 263 } 264 } 265 266 private void oneSensorToTag(boolean isActive, NamedBean bean, IdTagManager tagmgr) { 267 var sensor = (OlcbSensor) bean; 268 var sensorID = sensor.getEventID(isActive); 269 if (tagmgr.getIdTag(OlcbConstants.tagPrefix+sensorID.toShortString()) == null) { 270 // tag doesn't exist, make it. 271 tagmgr.provideIdTag(OlcbConstants.tagPrefix+sensorID.toShortString()) 272 .setUserName(sensor.getEventName(isActive)); 273 } 274 } 275 276 public void turnoutRequested(java.awt.event.ActionEvent e) { 277 // loop over turnouts to find the OpenLCB ones 278 var beans = InstanceManager.getDefault(TurnoutManager.class).getNamedBeanSet(); 279 var tagmgr = InstanceManager.getDefault(IdTagManager.class); 280 for (NamedBean bean : beans ) { 281 if (bean instanceof OlcbTurnout) { 282 oneTurnoutToTag(true, bean, tagmgr); // thrown 283 oneTurnoutToTag(false, bean, tagmgr); // closed 284 } 285 } 286 } 287 288 private void oneTurnoutToTag(boolean isThrown, NamedBean bean, IdTagManager tagmgr) { 289 var turnout = (OlcbTurnout) bean; 290 var turnoutID = turnout.getEventID(isThrown); 291 if (tagmgr.getIdTag(OlcbConstants.tagPrefix+turnoutID.toShortString()) == null) { 292 // tag doesn't exist, make it. 293 tagmgr.provideIdTag(OlcbConstants.tagPrefix+turnoutID.toShortString()) 294 .setUserName(turnout.getEventName(isThrown)); 295 } 296 } 297 298 299 // CSV file chooser 300 // static to remember choice from one use to another. 301 static JFileChooser fileChooser = null; 302 303 /** 304 * Write out contents in CSV form 305 * @param e Needed for signature of method, but ignored here 306 */ 307 public void writeToCsvFile(ActionEvent e) { 308 309 if (fileChooser == null) { 310 fileChooser = new jmri.util.swing.JmriJFileChooser(); 311 fileChooser.setDialogTitle("Save CSV file"); 312 } 313 fileChooser.rescanCurrentDirectory(); 314 fileChooser.setSelectedFile(new File("eventtable.csv")); 315 316 int retVal = fileChooser.showSaveDialog(this); 317 318 if (retVal == JFileChooser.APPROVE_OPTION) { 319 File file = fileChooser.getSelectedFile(); 320 if (log.isDebugEnabled()) { 321 log.debug("start to export to CSV file {}", file); 322 } 323 324 try (CSVPrinter str = new CSVPrinter(new OutputStreamWriter(new FileOutputStream(file), StandardCharsets.UTF_8), CSVFormat.DEFAULT)) { 325 str.printRecord("Event ID", "Event Name", "Producer Node", "Producer Node Name", 326 "Consumer Node", "Consumer Node Name", "Paths"); 327 for (int i = 0; i < model.getRowCount(); i++) { 328 329 str.print(model.getValueAt(i, EventTableDataModel.COL_EVENTID)); 330 str.print(model.getValueAt(i, EventTableDataModel.COL_EVENTNAME)); 331 str.print(model.getValueAt(i, EventTableDataModel.COL_PRODUCER_NODE)); 332 str.print(model.getValueAt(i, EventTableDataModel.COL_PRODUCER_NAME)); 333 str.print(model.getValueAt(i, EventTableDataModel.COL_CONSUMER_NODE)); 334 str.print(model.getValueAt(i, EventTableDataModel.COL_CONSUMER_NAME)); 335 336 String[] contexts = model.getValueAt(i, EventTableDataModel.COL_CONTEXT_INFO).toString().split("\n"); // multi-line cell 337 for (String context : contexts) { 338 str.print(context); 339 } 340 341 str.println(); 342 } 343 str.flush(); 344 } catch (IOException ex) { 345 log.error("Error writing file", ex); 346 } 347 } 348 } 349 350 /** 351 * Set up filtering of displayed rows 352 */ 353 private void filter() { 354 RowFilter<EventTableDataModel, Integer> rf = new RowFilter<EventTableDataModel, Integer>() { 355 /** 356 * @return true if row is to be displayed 357 */ 358 @Override 359 public boolean include(RowFilter.Entry<? extends EventTableDataModel, ? extends Integer> entry) { 360 361 int row = entry.getIdentifier(); 362 363 var name = model.getValueAt(row, EventTableDataModel.COL_EVENTNAME); 364 if ( showRequiresLabel.isSelected() && (name == null || name.toString().isEmpty()) ) return false; 365 366 if ( showRequiresMatch.isSelected()) { 367 var memo = model.getTripleMemo(row); 368 369 if (memo.producer == null && !model.producerPresent(memo.eventID)) { 370 // no matching producer 371 return false; 372 } 373 374 if (memo.consumer == null && !model.consumerPresent(memo.eventID)) { 375 // no matching consumer 376 return false; 377 } 378 } 379 380 return true; 381 } 382 }; 383 sorter.setRowFilter(rf); 384 } 385 386 /** 387 * Nested class to hold data model 388 */ 389 protected static class EventTableDataModel extends AbstractTableModel { 390 391 EventTableDataModel(MimicNodeStore store, EventTable stdEventTable) { 392 this.store = store; 393 this.stdEventTable = stdEventTable; 394 tagManager = InstanceManager.getDefault(IdTagManager.class); 395 396 loadIdTagEventIDs(); 397 } 398 399 static final int COL_EVENTID = 0; 400 static final int COL_EVENTNAME = 1; 401 static final int COL_PRODUCER_NODE = 2; 402 static final int COL_PRODUCER_NAME = 3; 403 static final int COL_CONSUMER_NODE = 4; 404 static final int COL_CONSUMER_NAME = 5; 405 static final int COL_CONTEXT_INFO = 6; 406 static final int COL_COUNT = 7; 407 408 MimicNodeStore store; 409 EventTable stdEventTable; 410 IdTagManager tagManager; 411 JTable table; 412 TableRowSorter<EventTableDataModel> sorter; 413 boolean popcornModeActive = false; 414 415 TripleMemo getTripleMemo(int row) { 416 if (row >= memos.size()) { 417 return null; 418 } 419 return memos.get(row); 420 } 421 422 void loadIdTagEventIDs() { 423 // are there events in the IdTags? If so, add them 424 log.debug("Found {} tags", tagManager.getNamedBeanSet().size()); 425 for (var tag: tagManager.getNamedBeanSet()) { 426 if (tag.getSystemName().startsWith(OlcbConstants.tagPrefix)) { 427 var id = tag.getSystemName().replace(OlcbConstants.tagPrefix, ""); 428 log.trace("Found initial entry for {}", id); 429 var eventID = new EventID(id); 430 var memo = new TripleMemo( 431 eventID, 432 null, 433 "", 434 null, 435 "" 436 ); 437 memos.add(memo); 438 } 439 } 440 } 441 442 443 @Override 444 public Object getValueAt(int row, int col) { 445 if (row >= memos.size()) { 446 log.warn("request out of range: {} greater than {}", row, memos.size()); 447 return "Illegal col "+row+" "+col; 448 } 449 var memo = memos.get(row); 450 switch (col) { 451 case COL_EVENTID: return memo.eventID.toShortString(); 452 case COL_EVENTNAME: 453 var tag = tagManager.getIdTag(OlcbConstants.tagPrefix+memo.eventID.toShortString()); 454 if (tag == null) return ""; 455 return tag.getUserName(); 456 case COL_PRODUCER_NODE: 457 return memo.producer != null ? memo.producer.toString() : ""; 458 case COL_PRODUCER_NAME: return memo.producerName; 459 case COL_CONSUMER_NODE: 460 return memo.consumer != null ? memo.consumer.toString() : ""; 461 case COL_CONSUMER_NAME: return memo.consumerName; 462 case COL_CONTEXT_INFO: 463 // set up for multi-line output in the cell 464 var result = new StringBuilder(); 465 if (lineIncrement <= 0) { // load cached value 466 lineIncrement = table.getFont().getSize()*13/10; // line spacing 467 } 468 var height = lineIncrement/3; // for margins 469 var first = true; // no \n before first line 470 471 // scan the event info as available 472 for (var entry : stdEventTable.getEventInfo(memo.eventID).getAllEntries()) { 473 if (!first) result.append("\n"); 474 first = false; 475 height += lineIncrement; 476 result.append(entry.getDescription()); 477 } 478 // When table is constrained, these rows don't match up, need to find constrained row 479 var viewRow = sorter.convertRowIndexToView(row); 480 if (viewRow >= 0) { // make sure it's a valid row in the table 481 // set height 482 if (height < lineIncrement) { 483 height = height+lineIncrement; // when no lines, assume 1 484 } 485 if (Math.abs(height - table.getRowHeight(row)) > lineIncrement/2) { 486 table.setRowHeight(viewRow, height); 487 } 488 } 489 return new String(result); 490 default: return "Illegal row "+row+" "+col; 491 } 492 } 493 494 int lineIncrement = -1; // cache the line spacing for multi-line cells 495 496 @Override 497 public void setValueAt(Object value, int row, int col) { 498 if (col != COL_EVENTNAME) return; 499 if (row >= memos.size()) { 500 log.warn("request out of range: {} greater than {}", row, memos.size()); 501 return; 502 } 503 var memo = memos.get(row); 504 var tag = tagManager.provideIdTag(OlcbConstants.tagPrefix+memo.eventID.toShortString()); 505 tag.setUserName(value.toString()); 506 } 507 508 @Override 509 public int getColumnCount() { 510 return COL_COUNT; 511 } 512 513 @Override 514 public String getColumnName(int col) { 515 switch (col) { 516 case COL_EVENTID: return "Event ID"; 517 case COL_EVENTNAME: return "Event Name"; 518 case COL_PRODUCER_NODE: return "Producer Node"; 519 case COL_PRODUCER_NAME: return "Producer Node Name"; 520 case COL_CONSUMER_NODE: return "Consumer Node"; 521 case COL_CONSUMER_NAME: return "Consumer Node Name"; 522 case COL_CONTEXT_INFO: return "Path(s) from Configure Dialog"; 523 default: return "ERROR "+col; 524 } 525 } 526 527 @Override 528 public int getRowCount() { 529 return memos.size(); 530 } 531 532 @Override 533 public boolean isCellEditable(int row, int col) { 534 return col == COL_EVENTNAME; 535 } 536 537 @Override 538 public Class<?> getColumnClass(int col) { 539 return String.class; 540 } 541 542 /** 543 * Remove all existing data, generally just in advance of an update 544 */ 545 @SuppressFBWarnings(value = "ST_WRITE_TO_STATIC_FROM_INSTANCE_METHOD") // Swing thread deconflicts 546 void clear() { 547 memos = new ArrayList<>(); 548 fireTableDataChanged(); // don't queue this one, must be immediate 549 } 550 551 // static so the data remains available through a window close-open cycle 552 static ArrayList<TripleMemo> memos = new ArrayList<>(); 553 554 /** 555 * Notify the table that the contents have changed. 556 * To reduce CPU load, this batches the changes 557 * @param start first row changed; -1 means entire table (not used yet) 558 * @param end last row changed; -1 means entire table (not used yet) 559 */ 560 void handleTableUpdate(int start, int end) { 561 log.trace("handleTableUpdated"); 562 final int DELAY = 500; 563 564 if (!pending) { 565 jmri.util.ThreadingUtil.runOnGUIDelayed(() -> { 566 pending = false; 567 log.debug("handleTableUpdated fires table changed"); 568 fireTableDataChanged(); 569 }, DELAY); 570 pending = true; 571 } 572 573 } 574 boolean pending = false; 575 576 /** 577 * Record an event-producer pair 578 * @param eventID Observed event 579 * @param nodeID Node that is known to produce the event 580 */ 581 void recordProducer(EventID eventID, NodeID nodeID) { 582 log.debug("recordProducer of {} in {}", eventID, nodeID); 583 584 // update if the model has been cleared 585 if (memos.size() <= 1) { 586 handleTableUpdate(-1, -1); 587 } 588 589 var nodeMemo = store.findNode(nodeID); 590 String name = ""; 591 if (nodeMemo != null) { 592 var ident = nodeMemo.getSimpleNodeIdent(); 593 if (ident != null) { 594 name = ident.getUserName(); 595 if (name.isEmpty()) { 596 name = ident.getMfgName()+" - "+ident.getModelName()+" - "+ident.getHardwareVersion(); 597 } 598 } 599 } 600 601 602 // if this already exists, skip storing it 603 // if you can, find a matching memo with an empty consumer value 604 TripleMemo empty = null; // an existing empty cell // TODO: switch to int index for handle update below 605 TripleMemo bestEmpty = null;// an existing empty cell with matching consumer// TODO: switch to int index for handle update below 606 TripleMemo sameNodeID = null;// cell with matching consumer // TODO: switch to int index for handle update below 607 for (int i = 0; i < memos.size(); i++) { 608 var memo = memos.get(i); 609 if (memo.eventID.equals(eventID) ) { 610 // if nodeID matches, already present; ignore 611 if (nodeID.equals(memo.producer)) { 612 // might be 2nd EventTablePane to process the data, 613 // hence memos would already have been processed. To 614 // handle that, need to fire a change to the table. 615 // On the other hand, this rapidly erases the 616 // popcorn display, so we disable it for that. 617 if (!popcornModeActive) { 618 handleTableUpdate(i, i); 619 } 620 return; 621 } 622 // if empty producer slot, remember it 623 if (memo.producer == null) { 624 empty = memo; 625 // best empty has matching consumer 626 if (nodeID.equals(memo.consumer)) bestEmpty = memo; 627 } 628 // if same consumer slot, remember it 629 if (nodeID == memo.consumer) { 630 sameNodeID = memo; 631 } 632 } 633 } 634 635 // can we use the bestEmpty? 636 if (bestEmpty != null) { 637 // yes 638 log.trace(" use bestEmpty"); 639 bestEmpty.producer = nodeID; 640 bestEmpty.producerName = name; 641 handleTableUpdate(-1, -1); // TODO: should be rows for bestEmpty, bestEmpty 642 return; 643 } 644 645 // can we just insert into the empty? 646 if (empty != null && sameNodeID == null) { 647 // yes 648 log.trace(" reuse empty"); 649 empty.producer = nodeID; 650 empty.producerName = name; 651 handleTableUpdate(-1, -1); // TODO: should be rows for empty, empty 652 return; 653 } 654 655 // is there a sameNodeID to insert into? 656 if (sameNodeID != null) { 657 // yes 658 log.trace(" switch to sameID"); 659 var fromSaveNodeID = sameNodeID.producer; 660 var fromSaveNodeIDName = sameNodeID.producerName; 661 sameNodeID.producer = nodeID; 662 sameNodeID.producerName = name; 663 // now leave behind old cell to make new one in next block 664 nodeID = fromSaveNodeID; 665 name = fromSaveNodeIDName; 666 } 667 668 // have to make a new one 669 var memo = new TripleMemo( 670 eventID, 671 nodeID, 672 name, 673 null, 674 "" 675 ); 676 memos.add(memo); 677 handleTableUpdate(memos.size()-1, memos.size()-1); 678 } 679 680 /** 681 * Record an event-consumer pair 682 * @param eventID Observed event 683 * @param nodeID Node that is known to consume the event 684 */ 685 void recordConsumer(EventID eventID, NodeID nodeID) { 686 log.debug("recordConsumer of {} in {}", eventID, nodeID); 687 688 // update if the model has been cleared 689 if (memos.size() <= 1) { 690 handleTableUpdate(-1, -1); 691 } 692 693 var nodeMemo = store.findNode(nodeID); 694 String name = ""; 695 if (nodeMemo != null) { 696 var ident = nodeMemo.getSimpleNodeIdent(); 697 if (ident != null) { 698 name = ident.getUserName(); 699 if (name.isEmpty()) { 700 name = ident.getMfgName()+" - "+ident.getModelName()+" - "+ident.getHardwareVersion(); 701 } 702 } 703 } 704 705 // if this already exists, skip storing it 706 // if you can, find a matching memo with an empty consumer value 707 TripleMemo empty = null; // an existing empty cell // TODO: switch to int index for handle update below 708 TripleMemo bestEmpty = null;// an existing empty cell with matching producer// TODO: switch to int index for handle update below 709 TripleMemo sameNodeID = null;// cell with matching consumer // TODO: switch to int index for handle update below 710 for (int i = 0; i < memos.size(); i++) { 711 var memo = memos.get(i); 712 if (memo.eventID.equals(eventID) ) { 713 // if nodeID matches, already present; ignore 714 if (nodeID.equals(memo.consumer)) { 715 // might be 2nd EventTablePane to process the data, 716 // hence memos would already have been processed. To 717 // handle that, always fire a change to the table. 718 log.trace(" nodeDI == memo.consumer"); 719 handleTableUpdate(i, i); 720 return; 721 } 722 // if empty consumer slot, remember it 723 if (memo.consumer == null) { 724 empty = memo; 725 // best empty has matching producer 726 if (nodeID.equals(memo.producer)) bestEmpty = memo; 727 } 728 // if same producer slot, remember it 729 if (nodeID == memo.producer) { 730 sameNodeID = memo; 731 } 732 } 733 } 734 735 // can we use the best empty? 736 if (bestEmpty != null) { 737 // yes 738 log.trace(" use bestEmpty"); 739 bestEmpty.consumer = nodeID; 740 bestEmpty.consumerName = name; 741 handleTableUpdate(-1, -1); // should be rows for bestEmpty, bestEmpty 742 return; 743 } 744 745 // can we just insert into the empty? 746 if (empty != null && sameNodeID == null) { 747 // yes 748 log.trace(" reuse empty"); 749 empty.consumer = nodeID; 750 empty.consumerName = name; 751 handleTableUpdate(-1, -1); // should be rows for empty, empty 752 return; 753 } 754 755 // is there a sameNodeID to insert into? 756 if (sameNodeID != null) { 757 // yes 758 log.trace(" switch to sameID"); 759 var fromSaveNodeID = sameNodeID.consumer; 760 var fromSaveNodeIDName = sameNodeID.consumerName; 761 sameNodeID.consumer = nodeID; 762 sameNodeID.consumerName = name; 763 // now leave behind old cell to make new one 764 nodeID = fromSaveNodeID; 765 name = fromSaveNodeIDName; 766 } 767 768 // have to make a new one 769 log.trace(" make a new one"); 770 var memo = new TripleMemo( 771 eventID, 772 null, 773 "", 774 nodeID, 775 name 776 ); 777 memos.add(memo); 778 handleTableUpdate(memos.size()-1, memos.size()-1); 779 } 780 781 // This causes the display to jump around as it tried to keep 782 // the selected cell visible. 783 // TODO: A better approach might be to change 784 // the cell background color via a custom cell renderer 785 void highlightProducer(EventID eventID, NodeID nodeID) { 786 if (!popcornModeActive) return; 787 log.trace("highlightProducer {} {}", eventID, nodeID); 788 for (int i = 0; i < memos.size(); i++) { 789 var memo = memos.get(i); 790 if (eventID.equals(memo.eventID) && nodeID.equals(memo.producer)) { 791 try { 792 var viewRow = sorter.convertRowIndexToView(i); 793 log.trace("highlight event ID {} row {} viewRow {}", eventID, i, viewRow); 794 if (viewRow >= 0) { 795 table.changeSelection(viewRow, COL_PRODUCER_NODE, false, false); 796 } 797 } catch (ArrayIndexOutOfBoundsException e) { 798 // can happen on first encounter of an event before table is updated 799 log.trace("failed to highlight event ID {} row {}", eventID.toShortString(), i); 800 } 801 } 802 } 803 } 804 805 // highlights (selects) all the eventID cells with a particular event, 806 // Most LAFs will move the first of these on-scroll-view. 807 void highlightEvent(EventID eventID) { 808 log.trace("highlightEvent {}", eventID); 809 table.clearSelection(); // clear existing selections 810 for (int i = 0; i < memos.size(); i++) { 811 var memo = memos.get(i); 812 if (eventID.equals(memo.eventID)) { 813 try { 814 var viewRow = sorter.convertRowIndexToView(i); 815 log.trace("highlight event ID {} row {} viewRow {}", eventID, i, viewRow); 816 if (viewRow >= 0) { 817 table.changeSelection(viewRow, COL_EVENTID, true, false); 818 } 819 } catch (ArrayIndexOutOfBoundsException e) { 820 // can happen on first encounter of an event before table is updated 821 log.trace("failed to highlight event ID {} row {}", eventID.toShortString(), i); 822 } 823 } 824 } 825 } 826 827 boolean consumerPresent(EventID eventID) { 828 for (var memo : memos) { 829 if (memo.eventID.equals(eventID) ) { 830 if (memo.consumer!=null) return true; 831 } 832 } 833 return false; 834 } 835 836 boolean producerPresent(EventID eventID) { 837 for (var memo : memos) { 838 if (memo.eventID.equals(eventID) ) { 839 if (memo.producer!=null) return true; 840 } 841 } 842 return false; 843 } 844 845 static class TripleMemo { 846 EventID eventID; 847 // Event name is stored as an IdTag 848 NodeID producer; 849 String producerName; 850 NodeID consumer; 851 String consumerName; 852 853 TripleMemo(EventID eventID, NodeID producer, String producerName, 854 NodeID consumer, String consumerName) { 855 this.eventID = eventID; 856 this.producer = producer; 857 this.producerName = producerName; 858 this.consumer = consumer; 859 this.consumerName = consumerName; 860 } 861 } 862 } 863 864 /** 865 * Internal class to watch OpenLCB traffic 866 */ 867 868 static class Monitor extends MessageDecoder { 869 870 Monitor(EventTableDataModel model) { 871 this.model = model; 872 } 873 874 EventTableDataModel model; 875 876 /** 877 * Handle "Producer/Consumer Event Report" message 878 * @param msg message to handle 879 * @param sender connection where it came from 880 */ 881 @Override 882 public void handleProducerConsumerEventReport(ProducerConsumerEventReportMessage msg, Connection sender){ 883 var nodeID = msg.getSourceNodeID(); 884 var eventID = msg.getEventID(); 885 model.recordProducer(eventID, nodeID); 886 model.highlightProducer(eventID, nodeID); 887 } 888 889 /** 890 * Handle "Consumer Identified" message 891 * @param msg message to handle 892 * @param sender connection where it came from 893 */ 894 @Override 895 public void handleConsumerIdentified(ConsumerIdentifiedMessage msg, Connection sender){ 896 var nodeID = msg.getSourceNodeID(); 897 var eventID = msg.getEventID(); 898 model.recordConsumer(eventID, nodeID); 899 } 900 901 /** 902 * Handle "Producer Identified" message 903 * @param msg message to handle 904 * @param sender connection where it came from 905 */ 906 @Override 907 public void handleProducerIdentified(ProducerIdentifiedMessage msg, Connection sender){ 908 var nodeID = msg.getSourceNodeID(); 909 var eventID = msg.getEventID(); 910 model.recordProducer(eventID, nodeID); 911 } 912 913 /* 914 * We no longer handle "Simple Node Ident Info Reply" messages because of 915 * excessive redisplays. Instead, we expect the MimicNodeStore to handle 916 * these and provide the information when requested. 917 */ 918 } 919 920 /** 921 * Nested class to create one of these using old-style defaults 922 */ 923 public static class Default extends jmri.jmrix.can.swing.CanNamedPaneAction { 924 925 public Default() { 926 super("Openlcb Event Table", 927 new jmri.util.swing.sdi.JmriJFrameInterface(), 928 EventTablePane.class.getName(), 929 jmri.InstanceManager.getDefault(jmri.jmrix.can.CanSystemConnectionMemo.class)); 930 } 931 } 932 933 private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(EventTablePane.class); 934}