001package jmri.jmrix.openlcb;
002
003import jmri.CollectingReporter;
004import jmri.DccLocoAddress;
005import jmri.IdTag;
006import jmri.InstanceManager;
007import jmri.NamedBean;
008import jmri.RailCom;
009import jmri.RailComManager;
010import jmri.Reporter;
011import jmri.implementation.AbstractIdTagReporter;
012import jmri.jmrix.can.CanSystemConnectionMemo;
013
014import org.openlcb.Connection;
015import org.openlcb.ConsumerRangeIdentifiedMessage;
016import org.openlcb.EventID;
017import org.openlcb.EventState;
018import org.openlcb.IdentifyProducersMessage;
019import org.openlcb.Message;
020import org.openlcb.OlcbInterface;
021import org.openlcb.ProducerConsumerEventReportMessage;
022import org.openlcb.ProducerIdentifiedMessage;
023import org.openlcb.implementations.EventTable;
024
025import java.util.ArrayList;
026import java.util.Collection;
027import java.util.Collections;
028import java.util.List;
029
030import javax.annotation.CheckReturnValue;
031import javax.annotation.Nonnull;
032import javax.annotation.OverridingMethodsMustInvokeSuper;
033
034/**
035 * Implement jmri.AbstractReporter for OpenLCB protocol.
036 *
037 * @author Bob Jacobsen Copyright (C) 2008, 2010, 2011
038 * @author Balazs Racz Copyright (C) 2023
039 * @since 5.3.5
040 */
041public final class OlcbReporter extends AbstractIdTagReporter implements CollectingReporter {
042
043    /// How many bits does a reporter event range contain.
044    private static final int REPORTER_BIT_COUNT = 16;
045    /// Next bit in the event ID beyond the reporter event range.
046    private static final long REPORTER_LSB = (1L << REPORTER_BIT_COUNT);
047    /// Mask for the bits which are the actual report.
048    private static final long REPORTER_EVENT_MASK = REPORTER_LSB - 1;
049
050    // the four cases for the MS bits in the report
051    private static final int REPORTER_UNOCCUPIED_EXIT = 0;
052    private static final int REPORTER_OCCUPIED_WEST_ENTRY = 0x1;
053    private static final int REPORTER_OCCUPIED_EAST_ENTRY = 0x2;
054    private static final int REPORTER_OCCUPIED_UNKNOWN_ENTRY = 0x3;
055
056    /// Mask for the address bits of the reporter.
057    private static final long ADDRESS_MASK = (1L << 14) - 1;
058    /// The high bits of the address report for a DCC short address.
059    private static final int HIBITS_SHORTADDRESS = 0x38;
060    /// The high bits of the address report for a DCC consist address.
061    private static final int HIBITS_CONSIST = 0x39;
062
063    private OlcbAddress baseAddress;    // event ID for zero report
064    private EventID baseEventID;
065    private long baseEventNumber;
066    private final OlcbInterface iface;
067    private final CanSystemConnectionMemo memo;
068    private final Connection messageListener = new Receiver();
069
070    EventTable.EventTableEntryHolder baseEventTableEntryHolder = null;
071
072    private final List<IdTag> entryOrder = new ArrayList<>();
073
074    /**
075     * {@inheritDoc}
076     * <p>
077     * OpenLCB provides explicit exit events and section-cleared messages.
078     */
079    @Override
080    public boolean hasExitReports() {
081        return true;
082    }
083    
084    public OlcbReporter(String prefix, String address, CanSystemConnectionMemo memo) {
085        super(prefix + "R" + address);
086        this.memo = memo;
087        if (memo != null) { // greatly simplify testing
088            this.iface = memo.get(OlcbInterface.class);
089        } else {
090            this.iface = null;
091        }
092        init(address);
093    }
094
095    /**
096     * Common initialization for both constructors.
097     * <p>
098     *
099     */
100    private void init(String address) {
101        iface.registerMessageListener(messageListener);
102        // build local addresses
103        OlcbAddress a = new OlcbAddress(address, memo);
104        OlcbAddress[] v = a.split(memo);
105        if (v == null) {
106            log.error("Did not find usable system name: {}", address);
107            return;
108        }
109        switch (v.length) {
110            case 1:
111                baseAddress = v[0];
112                baseEventID = baseAddress.toEventID();
113                baseEventNumber = baseEventID.toLong();
114                break;
115            default:
116                log.error("Can't parse OpenLCB Reporter system name: {}", address);
117        }
118    }
119
120    /**
121     * Helper function that will be invoked after construction once the properties have been
122     * loaded. Used specifically for preventing double initialization when loading sensors from
123     * XML.
124     */
125    void finishLoad() {
126        if (baseEventTableEntryHolder != null) {
127            baseEventTableEntryHolder.release();
128            baseEventTableEntryHolder = null;
129        }
130        if (baseEventID != null && iface != null) {
131            baseEventTableEntryHolder = iface.getEventTable().addEvent(baseEventID, getEventName());
132            // Reports identified message.
133            Message m = new ConsumerRangeIdentifiedMessage(iface.getNodeId(), getEventRangeID());
134            iface.getOutputConnection().put(m, messageListener);
135            // Queries current state of the reporter.
136            m = new IdentifyProducersMessage(iface.getNodeId(), baseEventID);
137            iface.getOutputConnection().put(m, messageListener);
138        }
139    }
140
141    /**
142     * Computes the 64-bit representation of the event range covered by this reporter.
143     * This is defined for the Producer/Consumer Range identified messages in the OpenLCB
144     * standards.
145     * @return Event ID representing the event base address and the mask.
146     */
147    private EventID getEventRangeID() {
148        long eventRange = baseEventNumber;
149        if ((baseEventNumber & REPORTER_LSB) == 0) {
150            eventRange |= REPORTER_EVENT_MASK;
151        }
152        byte[] contents = new byte[8];
153        for (int i = 1; i <= 8; i++) {
154            contents[8-i] = (byte)(eventRange & 0xff);
155            eventRange >>= 8;
156        }
157        return new EventID(contents);
158    }
159
160    /**
161     * Computes the display name of a given event to be entered into the Event Table.
162     * @return user-visible string to represent this event.
163     */
164    private String getEventName() {
165        String name = getUserName();
166        if (name == null) name = mSystemName;
167        return Bundle.getMessage("ReporterEventName", name);
168    }
169
170    /**
171     * Updates event table entries when the user name changes.
172     * @param s new user name
173     * @throws BadUserNameException see {@link NamedBean}
174     */
175    @Override
176    @OverridingMethodsMustInvokeSuper
177    public void setUserName(String s) throws BadUserNameException {
178        super.setUserName(s);
179        if (baseEventTableEntryHolder != null) {
180            baseEventTableEntryHolder.getEntry().updateDescription(getEventName());
181        }
182    }
183
184    @Override
185    public void dispose() {
186        if (baseEventTableEntryHolder != null) {
187            baseEventTableEntryHolder.release();
188            baseEventTableEntryHolder = null;
189        }
190        iface.unRegisterMessageListener(messageListener);
191        super.dispose();
192    }
193
194    /**
195     *  {@inheritDoc}
196     */
197    @Override
198    @CheckReturnValue
199    @SuppressWarnings("unchecked")
200    public Collection<Object> getCollection() {
201        return (Collection<Object>)(Collection<?>) Collections.unmodifiableCollection(entryOrder);
202    }
203    
204    /**
205     * {@inheritDoc}
206     *
207     * Sorts by decoded EventID(s)
208     */
209    @CheckReturnValue
210    @Override
211    public int compareSystemNameSuffix(@Nonnull String suffix1, @Nonnull String suffix2, @Nonnull NamedBean n) {
212        return OlcbAddress.compareSystemNameSuffix(suffix1, suffix2, memo);
213    }
214
215    /**
216     * State is always an integer, which is the numeric value from the last loco
217     * address that we reported, or -1 if the last update was an exit.
218     *
219     * @return loco address number or -1 if the last message specified exiting
220     */
221    @Override
222    public int getState() {
223        return lastLoco;
224    }
225
226    /**
227     * {@inheritDoc}
228     */
229    @Override
230    public void setState(int s) {
231        lastLoco = s;
232    }
233    int lastLoco = -1;
234
235    /**
236     * Callback from the message decoder when a relevant event message arrives.
237     * @param reportBits The bottom 14 bits of the event report. (THe top bits are already checked against our base event number)
238     * @param isEntry true for entry, false for exit
239     */
240    private void handleReport(long reportBits, boolean isEntry) {
241        log.trace("handleReport {} with isEntry {}", this, isEntry);
242        
243        DccLocoAddress.Protocol protocol;
244        boolean isConsist;
245        long addressBits = reportBits & ADDRESS_MASK;
246        int address = 0;
247        int hiBits = (int) ((addressBits >> 8) & 0x3f);
248        if (addressBits < 0x2800) {
249            address = (int) addressBits;
250            protocol = DccLocoAddress.Protocol.DCC_LONG;
251            isConsist = false;
252        } else if (hiBits == HIBITS_SHORTADDRESS) {
253            address = (int) (addressBits & 0xff);
254            protocol = DccLocoAddress.Protocol.DCC_SHORT;
255            isConsist = false;
256        } else if (hiBits == HIBITS_CONSIST) {
257            address = (int) (addressBits & 0x7f);
258            protocol = DccLocoAddress.Protocol.DCC_SHORT;
259            isConsist = true;
260        } else {
261            log.warn("Unexpected address field formatting, treating as DCC_LONG: {}", Long.toHexString(reportBits));
262            protocol = DccLocoAddress.Protocol.DCC_LONG;
263            isConsist = false;
264        }
265        
266        RailCom.Orientation orientation;
267        
268        int orientationBits = (int)(reportBits >> 14) & 0x3;
269        
270        switch ( orientationBits ) {
271            case REPORTER_OCCUPIED_WEST_ENTRY:
272                orientation = RailCom.Orientation.WEST;
273                break;
274            case REPORTER_OCCUPIED_EAST_ENTRY:
275                orientation = RailCom.Orientation.EAST;
276                break;
277            case REPORTER_OCCUPIED_UNKNOWN_ENTRY:
278            case REPORTER_UNOCCUPIED_EXIT:
279            default:        // needed to keep static checker happy
280                orientation = RailCom.Orientation.UNKNOWN;
281                break;
282        }
283
284        // address 0x3800 is a special case:  Arrival means reporter is unoccupied, departure is ignored
285        if (addressBits == 0x3800) {
286            if (orientationBits == REPORTER_UNOCCUPIED_EXIT) {
287                return;
288            } else {
289                log.trace("{} clearing collection", this);
290                entryOrder.clear();
291                notify(null);
292                return; // having cleared the reporter earlier
293            }
294        }
295
296        RailCom tag = (RailCom) InstanceManager.getDefault(RailComManager.class).provideIdTag("" + address);
297
298        if (!isEntry || orientationBits == REPORTER_UNOCCUPIED_EXIT) {
299            log.trace("{} removes tag {}", this,  tag);
300            entryOrder.remove(tag);
301            if (getCurrentReport() == tag) {
302                updateCurrentReportAfterExit();
303            } else {
304                notifyCollectionUpdated();
305            }
306            return; // having cleared the reporter earlier
307        }
308        
309        entryOrder.remove(tag);
310        entryOrder.add(tag);
311        tag.setOrientation(orientation);
312        tag.setDccAddress(new DccLocoAddress(address, protocol, isConsist));
313        notify(tag);
314    }
315
316    /**
317     * Updates {@link #getCurrentReport()} after an IdTag has exited this reporter's block.
318     * <p>
319     * When a locomotive exits, this method selects an appropriate fallback report from the
320     * remaining tags in {@link #entryOrder}. It prioritizes the most recently entered tag
321     * that still considers this reporter its current location (i.e. {@code candidate.getWhereLastSeen() == this}).
322     * If no such locomotive exists but the block is not empty (for example, if other locomotives
323     * were temporarily seen in bridging or nested detectors), it falls back to the most recently
324     * entered tag remaining in the block without stealing {@code whereLastSeen}.
325     * If no tags remain, {@code notify(null)} clears the report.
326     */
327    private void updateCurrentReportAfterExit() {
328        if (entryOrder.isEmpty()) {
329            notify(null);
330            return;
331        }
332        for (int i = entryOrder.size() - 1; i >= 0; i--) {
333            IdTag candidate = entryOrder.get(i);
334            if (candidate.getWhereLastSeen() == this) {
335                notify(candidate);
336                return;
337            }
338        }
339        // Fall back to the most recently entered remaining tag without modifying its
340        // whereLastSeen or notifying the reporter where it was seen elsewhere.
341        IdTag fallback = entryOrder.get(entryOrder.size() - 1);
342        setReport(fallback);
343        setState(IdTag.SEEN);
344    }
345
346    /**
347     * {@inheritDoc}
348     */
349    @Override
350    public void notifySeenElsewhere(IdTag tag, Reporter newReporter) {
351        if (!this.equals(newReporter) && getCurrentReport() == tag) {
352            // The tag that just moved to another reporter was our current report.
353            // In OpenLCB, physical presence is governed by explicit hardware exit messages,
354            // so we do not remove `tag` from `entryOrder` here.
355            // However, to keep getCurrentReport() pointing to an ID that is currently present
356            // in this block, we check whether any other locomotive in entryOrder still has
357            // whereLastSeen == this. If so, we fall back to reporting that locomotive.
358            // If no other locomotive has whereLastSeen == this, we do NOT call notify(candidate)
359            // or notify(null), because doing so would re-assert whereLastSeen back to this reporter
360            // and trigger an infinite ping-pong between reporters.
361            for (int i = entryOrder.size() - 1; i >= 0; i--) {
362                IdTag candidate = entryOrder.get(i);
363                if (candidate != tag && candidate.getWhereLastSeen() == this) {
364                    notify(candidate);
365                    return;
366                }
367            }
368        }
369    }
370    private class Receiver extends org.openlcb.MessageDecoder {
371        @Override
372        public void handleProducerConsumerEventReport(ProducerConsumerEventReportMessage msg, Connection sender) {
373            long id = msg.getEventID().toLong();
374            if ((id & ~REPORTER_EVENT_MASK) != baseEventNumber) {
375                // Not for us.
376                return;
377            }
378            boolean entry = ((id >>14) & 0x3) != REPORTER_UNOCCUPIED_EXIT;
379            handleReport(id & REPORTER_EVENT_MASK, entry);
380        }
381
382        @Override
383        public void handleProducerIdentified(ProducerIdentifiedMessage msg, Connection sender) {
384            long id = msg.getEventID().toLong();
385            if ((id & ~REPORTER_EVENT_MASK) != baseEventNumber) {
386                // Not for us.
387                return;
388            }
389            long reportBits = id & REPORTER_EVENT_MASK;
390            int orientationBits = (int) (reportBits >> 14) & 0x3;
391            if (orientationBits == REPORTER_UNOCCUPIED_EXIT) {
392                // For exit events (0x0000..), Valid means the locomotive has exited (is absent).
393                // Invalid means the exit did not occur (the locomotive is present).
394                if (msg.getEventState() == EventState.Valid) {
395                    handleReport(reportBits, false);
396                }
397            } else {
398                // For entry / orientation events (0x4000, 0x8000, 0xC000):
399                // Valid means the locomotive is present with this orientation.
400                if (msg.getEventState() == EventState.Valid) {
401                    handleReport(reportBits, true);
402                } else if (msg.getEventState() == EventState.Invalid &&
403                        orientationBits == REPORTER_OCCUPIED_UNKNOWN_ENTRY) {
404                    // Invalid on unknown orientation means the locomotive is not present at all.
405                    handleReport(reportBits, false);
406                }
407            }
408        }
409    }
410
411    private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(OlcbReporter.class);
412
413}