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}