D-PRS Parser


This is the javAPRSSrvr D-PRS parsing code. While dependent on other classes, the basic parsing structure is here. CCITTTCRC class is at end.
This is updated with up-to-date regex patterns and the ability to parse GPS from Kenwood TH-D74 radios.

DPRSIntf Class


package net.ae5pl.aprsigate;

import java.io.*;
import java.nio.*;
import java.time.*;
import java.util.regex.*;
import java.util.logging.*;
import java.util.*;
import net.ae5pl.aprs.*;
import net.ae5pl.aprssrvr.*;
import net.ae5pl.serintf.*;
import net.ae5pl.util.*;

import static java.lang.Math.*;
import static net.ae5pl.util.MiscTools.*;

/**
 * D-PRS Interface for D-STAR DV serial feeds.
 *
 * @author Pete Loveall AE5PL
 */
public final class DPRSIntf extends APRSIGate.PacketIntf
{

    private final static Logger classlogger = Logger.getLogger(DPRSIntf.class.getName());
    
    private final static int maxBufSize = 512;
    private final LineInputStream sis;
    private final SerialIntf.SerialInputStream.SerialOutputStream sos;
    private final long dupeDelay;
    private final boolean parseD74;

    /**
     * Support for APRSIGate.PacketIntf ServiceLoader
     */
    public DPRSIntf()
    {
        sis = null;
        sos = null;
        dupeDelay = 0L;
        parseD74 = false;
        emptyPacket = null;
    }
    
    /**
     * Support for D-STAR serial feeds (D-PRS) Uses dupeDelay=10 seconds from
     * APRSIGate portProps
     *
     * @param ig This is the APRSIGate container class as required by Java
     */
    private DPRSIntf(APRSIGate ig)
    {
        super(ig);
        SerialIntf serintf = Objects.requireNonNull(SerialIntf.getInterface(ig.portProps.getProperty("IntfName")), "No serial interface found for " + ig.portProps.getProperty("IntfName"));
        SerialIntf.SerialInputStream tsis = serintf.getInputStream();
        this.sis = new LineInputStream(tsis, maxBufSize);
        this.sos = tsis.getOutputStream();
        dupeDelay = ig.portProps.getProperty("dupeDelay", 10L);
        parseD74 = ig.portProps.getProperty("parseD74", false);
        emptyPacket = ig.getRawClientPacket(MiscTools.emptybyteArray);
    }

    private static final byte[] GPSACRC = getASCIIBytes("$$CRC0000,");
    private static final CallsignSSID[] dstarpath =
    {
        CallsignSSID.parse("DSTAR")
    };

    @Override
    public void SendPacket(APRSPacket outpacket)
    {
        APRSPacket tpack = outpacket.newPath(0, dstarpath);
        byte[] newline = Arrays.copyOf(GPSACRC, GPSACRC.length + tpack.getTNC2Bytes().length + 1);
        System.arraycopy(tpack.getTNC2Bytes(), 0, newline, GPSACRC.length, tpack.getTNC2Bytes().length);
        newline[newline.length - 1] = '\r';
        final CCITTCRC sendcrc = dstarcrc.get();
        sendcrc.reset();
        sendcrc.update(newline, GPSACRC.length, newline.length - GPSACRC.length);
        getCRCChars(newline, 5, sendcrc.getValue());
        try
        {
            sos.write(newline);
            updateXmtdTotals(newline.length, true, true);
        }
        catch (IOException e)
        {
        }
    }

    protected static final CallsignSSID dprstocall = CallsignSSID.parse("APDPRS");
    
    private final static ThreadLocal dstarcrc = ThreadLocal.withInitial(CCITTCRC::new);

    // lineCharBuf and lineMatcher are done here as GetPacket is only called from APRSIGate.run
    private final CharBuffer lineCharBuf = CharBuffer.allocate(maxBufSize);
    private final static Pattern GPS_Apat = Pattern.compile("(?>[$]CRC[0-9A-F]{4}+,[0-9A-Z-]{3,9}+>[0-9A-Z-]{1,9}+,DSTAR[*]:).+$");
    private final Matcher gpsaMatcher = GPS_Apat.matcher(lineCharBuf);
    private final static Pattern GPSpat = Pattern.compile("(?>[$]GP(?>RMC|GGA)(?:,[-.0-9A-Z]{0,10}+){10,16}+[*][0-9A-F]{2}+)$");
    private final Matcher gpsMatcher = GPSpat.matcher(lineCharBuf);
    
    /**
     * MsgLinePat groups:
     * group(1) = message line sans checksum, group(2) = callsign ID, group 3 = symbol, group 4 message sans symbol & checksum, group 5 checksum
     */
    
    private final static Pattern GenMsgPat = Pattern.compile("^(?>[ 0-9A-Z]{8}+,)\\p{Print}{20}+$");
    private final Matcher genMsgMatcher = GenMsgPat.matcher(lineCharBuf);
    private final static Pattern MsgLinePat = Pattern.compile("^(((?>[ABDHJLMNOPQS][0-9A-Z][ 0-9A-Z]))\\ (\\p{Print}{0,13}))[*]([0-9A-F]{2}+)\\ *$");
    private final Matcher msgLineMatcher = MsgLinePat.matcher(lineCharBuf);
    private final static Pattern dstarStationPat = Pattern.compile("^([0-9A-Z]{3,7}+)[ ]{0,4}([ 0-9A-Z])$");
    private final Matcher dstarStationMatcher = dstarStationPat.matcher(lineCharBuf);
    private final static Pattern D74Pat = Pattern.compile("^[ ]{20}+$");
    private final Matcher D74Matcher = D74Pat.matcher(lineCharBuf);
    private final static String[] D74Message = new String[]{"\\K", ""}; // getMessage() calls getSymbol() to get the same

    /**
     * This converts DSTAR station callsign ID combos to APRS TNC2 format
     * including Icom's mangling of 7 character callsigns with ID.
     *
     * @return APRS CallsignSSID (validated if 6 or less characters for
     * callsign) or emptyCall if invalid station callsign or ID==0
     */
    private CallsignSSID convertDSTARStation()
    {
        // reset() done in region()
        if (dstarStationMatcher.region(0, 8).matches())
        {
            // This handles Icom's mangling of D-PRS conversion for 7 character callsigns.
            if (dstarStationMatcher.end(1) == 7)
                return CallsignSSID.create(dstarStationMatcher.group(1), dstarStationMatcher.group(2).trim(), dstarStationMatcher.group().trim());
            return CallsignSSID.create(dstarStationMatcher.group(1), dstarStationMatcher.group(2).trim(), "");
        }
        return CallsignSSID.emptyCall;
    }

    /**
     * Processes a callsign,message line
     * 
     * @param readingGPS PositInfo from prior GPS sentences. Reset to null after method returns
     * @param curtime Time recorded for packet
     * @param isD74 True if matched empty message and parseD74 set
     * @return Packet or null if either not valid or to fast
     */
    private ClientRcv.ClientPacket processCallsignMessage(PositInfo readingGPS, Instant curtime, boolean isD74)
    {
        try
        {
            // Must be exact length, formatted properly, valid APRS callsign-SSID and have valid prior GPS lat/lon
            CallsignSSID dcall = convertDSTARStation();
            if (!dcall.isValidCallSSID)
                return null;

            // Get symbol & message
            String message[] = isD74 ? D74Message : getMessage();

            // Test for valid checksum
            if (message == null)
                return null;

            // Don't do multiples per transmission
            if (lastPacket != null
                    && lastPacket.OrgCall.equals(dcall)
                    && curtime.minusSeconds(dupeDelay).isBefore(lastPacket.timeCreated))
            {
                // This is a sliding window
                lastPacket = parent.new ClientPacket(lastPacket.OrgCall, lastPacket.DestCall, 0, readingGPS, lastPacket.path.toArray(CallsignSSID.emptyCallArray));
                updateRcvdTotals(0, false, true);
                return null;
            }

            // We don't send a posit if it isn't valid
            if (!readingGPS.GeoLocation.isValid())
                return null;

            readingGPS.symbol[0] = message[0].charAt(0);
            readingGPS.symbol[1] = message[0].charAt(1);
            if (!message[1].isEmpty())
                readingGPS.comment = message[1];
            readingGPS.canMessage = false;
            if (readingGPS.direction > 360 || readingGPS.direction < 0)
                readingGPS.direction = 0;
            else if (readingGPS.direction > 0)
                readingGPS.dataextension = new PositInfo.dirspdInfo(readingGPS.direction, (int) round(readingGPS.GeoLocation.M));
            lastPacket = parent.new ClientPacket(dcall, dprstocall, 0, readingGPS, dstarpath);
            updateRcvdTotals(0, false, true);
            return lastPacket;
        }
        catch (Exception e)
        {
            classlogger.log(Level.FINE, "Regex Exception: " + lineCharBuf.toString(), e);
        }
        return null;
    }

    /**
     * Same as ClientRcv.ClientPacket.isPosit but includes Objects and Items
     * 
     * @param retpack DPRS/GPS-A Packet
     * @return true if packet contains a valid posit
     */
    private boolean isDPRSPosit(ClientRcv.ClientPacket retpack)
    {
        final PacketInfo pi = retpack.getPacketInfo();
        return pi != null && pi.getPositInfo() != null
                && !(pi instanceof ThirdPartyInfo);
// Removed as CRC should prevent this && pi.getPositInfo().GeoLocation.isValid();
    }
    
    private ClientRcv.ClientPacket lastPacket = null; // Used to restrict packets to one per transmission

    @Override
    public ClientRcv.ClientPacket GetPacket()
    {
        Instant lastLine = (lastPacket == null) ? null : lastPacket.timeCreated;
        Instant curtime;
        PositInfo readingGPS = null; // Used for GPS mode; null = not parsing GPS
        byte[] linein;
        byte[] prevlinein;
        byte[] fulllinein; // Parsed line per DPRSPat
        for (;;)
        {
            // Look for $GP line, DSTAR Call Line, or $$CRC line
            try
            {
                for (;;)
                {
                    prevlinein = null;
                    for (;;)
                    {
                        linein = sis.readLine(false);
                        if (linein == null)
                            throw new EOFException();
                        if (linein.length == 0)
                        {
                            // Ignore empty lines
                            if (prevlinein == null)
                                continue;
                            linein = prevlinein;
                            prevlinein = null;
                            break;
                        }
                        updateRcvdTotals(linein.length, false, false);
                        if (linein.length == maxBufSize)
                            prevlinein = linein;
                        else
                            break;
                    }
                    updateRcvdTotals(0, true, false);
                    curtime = Instant.now();
                    if (lastLine == null || curtime.minusSeconds(dupeDelay).isAfter(lastLine))
                    {
                        // Reset packet/GPS counters because dupeDelay seconds since last line have elapsed
                        readingGPS = null;
                        lastPacket = null;
                    }
                    lastLine = curtime;

                    if (prevlinein != null)
                    {
                        // Only here if prevlinein.length == maxBufSize && linein.length < maxBufSize && linein.length > 0
                        fulllinein = prevlinein;
                        System.arraycopy(fulllinein, linein.length, fulllinein, 0, maxBufSize - linein.length);
                        System.arraycopy(linein, 0, fulllinein, maxBufSize - linein.length, linein.length);
                    }
                    else
                        fulllinein = linein;
                    
                    // lineCharBuf used for Regex and tests, getASCIIChars keeps arrays in sync positionally
                    lineCharBuf.clear(); // Set position=0 and limit=maxBufSize
                    MiscTools.getASCIIChars(fulllinein, 0, lineCharBuf.array(), 0, fulllinein.length);
                    lineCharBuf.limit(fulllinein.length); // Sets limit for all other functions (default is maxBufSize)

                    // Check for callsign,message line first if parsing GPS sentences
                    if (readingGPS != null && fulllinein.length >= 29 && fulllinein[fulllinein.length - 21] == ','
                            && genMsgMatcher.region(lineCharBuf.limit() - 29, lineCharBuf.limit()).matches())
                    {
                        lineCharBuf.position(lineCharBuf.limit() - 29);
                        // Only time we will parse a message line
                        if (msgLineMatcher.region(9, 29).matches())
                        {
                            ClientRcv.ClientPacket tpack = processCallsignMessage(readingGPS, curtime, false);
                            if (tpack != null)
                                return tpack;
                        }
                        else if (parseD74 && D74Matcher.region(9, 29).matches())
                        {
                            // This also clears any callsign,messages with no message
                            ClientRcv.ClientPacket tpack = processCallsignMessage(readingGPS, curtime, true);
                            if (tpack != null)
                                return tpack;
                        }
                        readingGPS = null;
                        continue;
                    }
                    
                    if (gpsaMatcher.reset().find())
                    {
                        lineCharBuf.position(gpsaMatcher.start());
                        for (; gpsaMatcher.region(1, lineCharBuf.length()).find();)
                            lineCharBuf.position(lineCharBuf.position() + gpsaMatcher.start());
                        ClientRcv.ClientPacket cp = processGPSA(Arrays.copyOfRange(fulllinein, lineCharBuf.position(), fulllinein.length));
                        if (cp != null)
                        {
                            if (cp.OrgCall.isValidCallSSID)
                                return cp;
                            readingGPS = null; // Passed CRC
                            continue;
                        }
                    }
                    
                    if (gpsMatcher.reset().find())
                    {
                        // Can occur after an invalid CRC but only once at the end
                        lineCharBuf.position(lineCharBuf.position() + gpsMatcher.start());
                        if (lineCharBuf.length() < 8)
                            continue;

                        // A bad line beginning with $GP does not reset GPS lookup
                        PositInfo tinfo = PositInfo.calcGPS(lineCharBuf.toString(), true);
                        if (tinfo != null)
                        {
                            if (readingGPS == null)
                                readingGPS = tinfo;
                            else
                            {
                                readingGPS.GeoLocation.setLat(tinfo.GeoLocation.getLat());
                                readingGPS.GeoLocation.setLong(tinfo.GeoLocation.getLong());
                                if (tinfo.direction != 0)
                                {
                                    readingGPS.GeoLocation.M = tinfo.GeoLocation.M;
                                    readingGPS.direction = tinfo.direction;
                                }
                                if (!Double.isNaN(tinfo.GeoLocation.Z))
                                    readingGPS.GeoLocation.Z = tinfo.GeoLocation.Z;
                            }
                        }
                    }
                }
            }
            catch (IndexOutOfBoundsException se)
            {
                // Added because find() might throw IndexOutOfBoundsException
                classlogger.log(Level.FINE, "Regex exception: " + lineCharBuf.toString(), se);
            }
            catch (Exception e)
            {
                classlogger.log(Level.WARNING, "Read Error", e);
                return null;
            }
        }
    }
    
    private final ClientRcv.ClientPacket emptyPacket;

    /**
     * Processes $CRC...
     * 
     * @param fulllinein byte array starting at the $CRC
     * @return Packet if to be passed on, emptyPacket if valid but not to be passed, or null for invalid
     */
    private ClientRcv.ClientPacket processGPSA(byte[] fulllinein)
    {
        try
        {
            int rcvdcrc = getCRC(lineCharBuf.subSequence(4, 8).toString());
            if (rcvdcrc == -1)
                return null; // Bad received CRC
            final CCITTCRC getcrc = dstarcrc.get();
            getcrc.reset();
            getcrc.update(fulllinein, 9, fulllinein.length - 9);
            getcrc.update('\r');
            if ((int) getcrc.getValue() != rcvdcrc)
                return null; // CRC does not match received data

            ClientRcv.ClientPacket retpack = parent.parseTNC2Header(fulllinein, 9);
            if (retpack == null)
                return null; // Even though CRC ok, not a packet.

            updateRcvdTotals(0, false, true);
            // This adjusts for Icom's mangling of DPRS conversion of 7 character callsigns with ID
            if (retpack.OrgCall.callsign.length() == 8 && retpack.OrgCall.callsign.equals(retpack.OrgCall.callSSID))
                retpack = retpack.changeOrgCall(CallsignSSID.create(retpack.OrgCall.callsign.substring(0, 7), retpack.OrgCall.callSSID.substring(7), retpack.OrgCall.callSSID));
            if (!retpack.OrgCall.isValidCallSSID)
                return emptyPacket; // bad packet
            retpack.parseAPRS();  // Do here so we know if it is a posit
            if (isDPRSPosit(retpack))
            {
                // Don't do multiple GPS-A posits within dupeDelay seconds (moving window)
                if (lastPacket != null
                    && retpack.OrgCall.equals(lastPacket.OrgCall)
                    && retpack.timeCreated.minusSeconds(dupeDelay).isBefore(lastPacket.timeCreated))
                {
                    lastPacket = retpack;  // Make moving window to keep from streaming
                    return emptyPacket; // too soon
                }
                lastPacket = retpack;
            }
            return retpack;
        }
        catch (Exception e)
        {
            classlogger.log(Level.INFO, "Error parsing: " + lineCharBuf.toString(), e);
        }
        return null;
    }
    
    private final static byte[] hexchars = new byte[]{'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F'};

    /**
     * Places 4 CRC hex characters at startpos
     *
     * @param buffer byte/character buffer
     * @param startpos position in buffer to put 4 characters
     * @param crc CRC to convert to hex characters
     */
    private static void getCRCChars(byte[] buffer, int startpos, long crc)
    {
        int tcrc = (int) crc;
        for (int i = startpos + 3; i >= startpos; --i)
        {
            buffer[i] = hexchars[tcrc & 0x0f];
            tcrc >>>= 4;
        }
    }

    /**
     * Read 4 hex bytes/characters at startpos and convert to int
     *
     * @param buffer buffer containing hex CRC
     * @return integer CRC, -1 for invalid
     */
    private static int getCRC(String buffer)
    {
        try
        {
            return Integer.parseUnsignedInt(buffer, 16);
        }
        catch (NumberFormatException e)
        {
        }
        return -1;
    }

    /**
     * Parses "TOCALL" symbol per APRS.
     *
     * @param message MsgLinePat.group(2) (must be from regex to validate input)
     * @return parsed symbol set/overlay and symbol
     */
    private static String getSymbol(String message)
    {
        int offset = -1;
        switch (message.charAt(0))
        {
            case 'B':
            case 'O':
                offset = -33;
                break;
            case 'P':
            case 'A':
                offset = 0;
                break;
            case 'M':
            case 'N':
                offset = -24;
                break;
            case 'H':
            case 'D':
                offset = 8;
                break;
            case 'L':
            case 'S':
                offset = 32;
                break;
            case 'J':
            case 'Q':
                offset = 74;
                break;
        }

        boolean altIcons = false;
        // x is valid, lets get y
        switch (message.charAt(0))
        {
            case 'O':
            case 'A':
            case 'N':
            case 'D':
            case 'S':
            case 'Q':
                altIcons = true;
                break;
        }
        char symbol = (char) (message.charAt(1) + offset);
        char overlay = '/';
        if (altIcons)
            if (message.charAt(2) == ' ')
                overlay = '\\';
            else
                overlay = message.charAt(2);
        return String.valueOf(new char[]
        {
            overlay, symbol
        });
    }

    /**
     * Parses Callsign,Message line and checks checksum
     *
     * @return symbol,trimmed message sans symbol and checksum; or
     * null if invalid
     */
    private String[] getMessage()
    {
        // msgLineMatcher has already been matched
        try
        {
            if (Integer.parseUnsignedInt(msgLineMatcher.group(4), 16)
                    == XORChecksum.getValue(lineCharBuf.subSequence(msgLineMatcher.start(1), msgLineMatcher.end(1))))
                return new String[]
                {
                    getSymbol(msgLineMatcher.group(2)), msgLineMatcher.group(3).trim()
                };
        }
        catch (NumberFormatException e)
        {
        }
        return null;
    }

    @Override
    protected APRSIGate.PacketIntf getPacketInterface(APRSIGate newparent)
    {
        if ("DPRS".equalsIgnoreCase(newparent.portProps.getProperty("PacketInterface")) || loadHelper(newparent.portProps.getProperty("PacketInterface")))
            return new DPRSIntf(newparent);
        
        return null;
    }
}
        

CCITTCRC Class


package net.ae5pl.util;

/**
 * AX.25/D-STAR CRC-16-CCITT (sometimes called CRC-CCITT-FALSE) from X.25 spec.
 * base=0xffff, polynomial=0x8408(Reversed 0x1021), XORed with 0xffff for value.
 *
 * @author Pete Loveall AE5PL
 */
public final class CCITTCRC implements java.util.zip.Checksum
{

    private int crc = 0x0ffff;

    @Override
    public void update(int b)
    {
        boolean xorflag;
        int ch = b & 0x0ff;
        for (int i = 0; i < 8; ++i)
        {
            xorflag = ((crc ^ ch) & 0x01) == 1;
            crc >>>= 1;
            if (xorflag)
                crc ^= 0x8408;
            ch >>>= 1;
        }
    }

    @Override
    public void update(byte[] b, int off, int len)
    {
        final int offlen = off + len;
        if (len < 1 || off < 0 || b.length < offlen)
            throw new ArrayIndexOutOfBoundsException("offset " + off + " or length " + len + " invalid");
        boolean xorflag;
        int ch;
        for (int i = off; i < offlen; ++i)
        {
            ch = b[i] & 0x0ff;
            for (int j = 0; j < 8; ++j)
            {
                xorflag = ((crc ^ ch) & 0x01) == 1;
                crc >>>= 1;
                if (xorflag)
                    crc ^= 0x8408;
                ch >>>= 1;
            }
        }
    }

    /**
     * CCITT 16 bit CRC
     *
     * @return internal crc XOR 0x0ffff
     */
    @Override
    public long getValue()
    {
        return crc ^ 0x0ffff;
    }

    /**
     * Resets internal crc to 0x0ffff
     */
    @Override
    public void reset()
    {
        crc = 0x0ffff;
    }
}