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;
    }
    
    /**
     * 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);
    }

    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)
        {
        }
    }

    private static final CallsignSSID dprstocall = CallsignSSID.parse("APDPRS");
    
    private final static ThreadLocal< CCITTCRC > 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 DPRSpat = Pattern.compile("(?:(?:(?[0-9A-Z-]{1,9}+,DSTAR[*]:.+))$");
    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 lineMatcher = DPRSpat.matcher(lineCharBuf);
    private final Matcher gpsaMatcher = GPS_Apat.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 MsgLinePat = Pattern.compile("^(([ 0-9A-Z]{8}+),((?>[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("^[ 0-9A-Z]{8}+,[ ]{20}$");
    private final Matcher D74Matcher = D74Pat.matcher(lineCharBuf);
    private final static String[] D74Message = new String[]{"\\K", ""}; // getMessage() calls getSymbol() to get here

    /**
     * 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.group(1).length() == 7)
                return CallsignSSID.create(dstarStationMatcher.group(1), dstarStationMatcher.group(2).trim(), dstarStationMatcher.group(1) + dstarStationMatcher.group(2).trim());
            return CallsignSSID.create(dstarStationMatcher.group(1), dstarStationMatcher.group(2).trim(), "");
        }
        return CallsignSSID.emptyCall;
    }


    /**
     * 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
        String matchedString;
        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;
                    lineCharBuf.clear(); // Set position=0 and limit=maxBufSize
                    int tpos = 0;
                    if (prevlinein != null)
                    {
                        // Only here if prevlinein.length == maxBufSize && linein.length < maxBufSize && linein.length > 0
                        MiscTools.getASCIIChars(prevlinein, linein.length, lineCharBuf.array(), 0, maxBufSize - linein.length);
                        tpos = maxBufSize - linein.length;
                    }
                    MiscTools.getASCIIChars(linein, 0, lineCharBuf.array(), tpos, linein.length);
                    if (prevlinein == null)
                        lineCharBuf.limit(linein.length); // Sets limit for all other functions (default is maxBufSize)

                    if (lineMatcher.reset().find())
                    {
                        lineCharBuf.position(lineMatcher.start());
                        if (lineCharBuf.charAt(1) == 'C') // GPS-A ($CRC...)
                        {
                            // Check for missing CR
                            for (tpos = 0; gpsaMatcher.region(tpos + 1, lineCharBuf.length()).find(); tpos = gpsaMatcher.start()) {}
                            if (tpos > 0)
                                lineCharBuf.position(lineCharBuf.position() + tpos); // Resets position to start of last found sequence
                        }
                        matchedString = lineCharBuf.toString(); // Fast array copy
                        fulllinein = MiscTools.getASCIIBytes(matchedString); // Fast depricated String.getBytes()
                        break;
                    }

                    if (lineCharBuf.length() >= 29 && readingGPS != null)
                    {
                        lineCharBuf.position(lineCharBuf.limit() - 29);
                        // Only time we will parse a message line
                        if (msgLineMatcher.reset().matches())
                        {
                            fulllinein = linein; // Not used in message parsing so this is fast and no heap used
                            matchedString = lineCharBuf.toString(); // Fast array copy, also not used in message parsing but used to get there
                            break;
                        }
                        if (parseD74 && D74Matcher.reset().matches())
                        {
                            fulllinein = null; // Used to indicate D74 match
                            matchedString = lineCharBuf.toString(); // Fast array copy, also not used in message parsing but used to get there
                            break;
                        }
                        // Garbled line but we don't reset reading GPS
                    }
                }
            }
            catch (IndexOutOfBoundsException se)
            {
                // Added because find() might throw IndexOutOfBoundsException
                classlogger.log(Level.FINE, "Regex find exception", se);
                continue;
            }
            catch (Exception e)
            {
                classlogger.log(Level.WARNING, "Read Error", e);
                return null;
            }

            if (matchedString.startsWith("$GP"))
            {
                // Handle GPS string
                if (fulllinein.length < 8)
                    continue;

                // A bad line beginning with $GP does not reset GPS lookup
                PositInfo tinfo = PositInfo.calcGPS(matchedString, 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;
                    }
                continue;  // Go no further with GPS strings
            }

            // Modified to work from only one $ instead of two
            if (matchedString.startsWith("$CRC"))
            {
                // D-PRS or GPS-A line
                readingGPS = null;  // Reset GPS reading logic if in place
                final CCITTCRC getcrc = dstarcrc.get();
                getcrc.reset();
                getcrc.update(fulllinein, 9, fulllinein.length - 9);
                getcrc.update('\r');
                if ((int) getcrc.getValue() != getCRC(matchedString.substring(4, 8)))
                    continue;

                try
                {
                    ClientRcv.ClientPacket retpack = parent.parseTNC2Header(fulllinein, 9);
                    if (retpack == null)
                        continue;

                    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)
                        continue;
                    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
                            continue;
                        }
                        lastPacket = retpack;
                    }
                    return retpack;
                }
                catch (Exception e)
                {
                }
                continue;
            }

            if (readingGPS != null)
            {
                // Must be exact length, formatted properly, valid APRS callsign-SSID and have valid prior GPS lat/lon
                CallsignSSID dcall = convertDSTARStation();
                if (!dcall.isValidCallSSID)
                {
                    readingGPS = null; // GPS must be from immediately preceding sequence
                    continue;
                }
                
                String message[];
                if (fulllinein == null)
                    // D74
                    message = D74Message;
                else
                    message = getMessage();

                // Will only get here if not $GP or $CRC and line is 29 characters and a comma is in position 8
                if (message == null)
                {
                    // Checksum failed
                    readingGPS = null; // GPS cannot be from prior sequence(s)
                    continue;
                }
                // 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));
                    readingGPS = null;
                    updateRcvdTotals(0, false, true);
                    continue;
                }

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

                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;
            }
        }
    }

    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 callsign id,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(5), 16)
                    == XORChecksum.getValue(msgLineMatcher.group(1)))
                return new String[]
                {
                    getSymbol(msgLineMatcher.group(3)), msgLineMatcher.group(4)
                };
        }
        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;
    }
}