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