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