D-PRS Parser


This is the javAPRSSrvr D-PRS parsing code. While dependent on other classes, the basic parsing structure is here.

package net.ae5pl.aprsigate;

import java.io.*;
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 parser for D-STAR to/from APRS parsing.
 * 
 * @author Pete Loveall AE5PL
 */
public class DPRSIntf extends APRSIGate.PacketIntf
{

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

    /**
     * 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
     */
    public DPRSIntf(APRSIGate ig)
    {
        ig.super();
        SerialIntf serintf = SerialIntf.getInterface(ig.portProps.getProperty("IntfName"));
        SerialIntf.SerialInputStream tsis = serintf.getInputStream();
        this.sis = new LineInputStream(tsis, 512);
        this.sos = tsis.getOutputStream();
        dupeDelay = ig.portProps.getProperty("dupeDelay", 10L) * 1000L;
    }

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

    private final static Pattern dstarpat = Pattern.compile("^([0-9A-Z]{3,7})[ ]{0,4}([ 0-9A-Z])$");

    /**
     * This converts DSTAR station callsign ID combos to APRS TNC2 format
     * including Icom's mangling of 7 character callsigns with ID.
     *
     * @param dstarStation 8 character D-STAR callsign ID string
     * @return APRS CallsignSSID (validated if 6 or less characters for
     * callsign) or emptyCall if invalid station callsign or ID==0
     */
    private static CallsignSSID convertDSTARStation(String dstarStation)
    {
        if (dstarStation.length() != 8)
            return CallsignSSID.emptyCall;
        Matcher dmatch = MiscTools.getMatcher(dstarpat, dstarStation);
        if (dmatch.matches())
        {
            // This handles Icom's mangling of D-PRS conversion for 7 character callsigns.
            if (dmatch.group(1).length() == 7)
                return CallsignSSID.create(dmatch.group(1), dmatch.group(2).trim(), dmatch.group(1) + dmatch.group(2).trim());
            return CallsignSSID.create(dmatch.group(1), dmatch.group(2).trim(), "");
        }
        return CallsignSSID.emptyCall;
    }

    @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, (int) sendcrc.getValue());
        try
        {
            sos.write(newline);
            updateXmtdTotals(newline.length, true, true);
        }
        catch (IOException e)
        {
        }
    }

    private static final CallsignSSID dprstocall = CallsignSSID.parse("APDPRS");
    private ClientRcv.ClientPacket lastPacket = null; // Used to restrict packets to one per transmission

    private final static ThreadLocal<CCITTCRC> dstarcrc = ThreadLocal.withInitial(CCITTCRC::new);

    // lineStrBldr and lineMatcher are done here as GetPacket is only called from APRSIGate.run
    private final static Pattern DPRSpat = Pattern.compile("(?:[$]CRC[0-9A-F]{4}+,[0-9A-Z-]{3,9}+>[0-9A-Z-]{1,9}+,DSTAR[*]:.+$)|(?:[$]GP(?:RMC|GGA)(?:,[.0-9A-Z]{0,10}+){10,16}+[*][0-9A-F]{2}+$)|(?:[0-9A-Z ]{8},\\p{Print}{20}$)");
    private final StringBuilder lineStrBldr = new StringBuilder(1024);
    private final Matcher lineMatcher = DPRSpat.matcher(lineStrBldr);

    @SuppressWarnings("deprecation")
    @Override
    public ClientRcv.ClientPacket GetPacket()
    {
        PositInfo readingGPS = null; // Used for GPS mode; null = not parsing GPS
        Instant lastLine = null;
        if (lastPacket != null)
            lastLine = lastPacket.timeCreated;
        String lineStr;
        for (;;)
        {
            // Look for $GP line, DSTAR Call Line, or $$CRC line
            try
            {
                for (;;)
                {
                    byte[] linein = null;
                    byte[] prevlinein = null;
                    for (;;)
                    {
                        linein = sis.readLine();
                        updateRcvdTotals(linein.length, false, false);
                        if (linein.length == 512)
                            prevlinein = linein;
                        else
                            break;
                    }
                    updateRcvdTotals(0, true, false);
                    lineStrBldr.setLength(0);
                    if (prevlinein != null)
                        lineStrBldr.append(new String(prevlinein, 0));
                    lineStrBldr.append(new String(linein, 0));
                    lineMatcher.reset(); // This tells lineMatcher there is new content in lineStrBldr
                    if (lineMatcher.find())
                    {
                        lineStr = lineMatcher.group();
                        break;
                    }
                }
            }
            catch (IOException e)
            {
                classlogger.log(Level.WARNING, "Read Error", e);
                return null;
            }
            Instant curtime = Instant.now();
            if (lastLine == null || curtime.minusMillis(dupeDelay).isAfter(lastLine))
            {
                // Reset GPS search
                readingGPS = null;
                lastPacket = null;
            }
            lastLine = curtime;

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

                // A bad line beginning with $GP does not reset GPS lookup
                PositInfo tinfo = PositInfo.calcGPS(lineStr, 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 (lineStr.startsWith("$CRC"))
            {
                // D-PRS or GPS-A line
                readingGPS = null;  // Reset GPS reading logic if in place
                final CCITTCRC getcrc = dstarcrc.get();
                getcrc.reset();
                lineStr.substring(9).chars().forEachOrdered(getcrc::update);
                getcrc.update('\r');
                if ((int) getcrc.getValue() != getCRC(lineStr, 4))
                    continue;

                try
                {
                    ClientRcv.ClientPacket retpack = getIGate().parseTNC2Header(getASCIIBytes(lineStr), 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
                    // Don't do multiple GPS-A gating per transmission
                    if (lastPacket != null
                            && retpack.isPosit()
                            && retpack.OrgCall.equals(lastPacket.OrgCall)
                            && retpack.timeCreated.minusMillis(dupeDelay).isBefore(lastPacket.timeCreated))
                    {
                        lastPacket = retpack;  // Make moving window to keep from streaming
                        continue;
                    }
                    if (retpack.isPosit())
                        lastPacket = retpack;
                    return retpack;
                }
                catch (Exception e)
                {
                }
                continue;
            }

            if (readingGPS != null)
            {
                // Will only get here if not $GP or $CRC and line is 29 characters and a comma is in position 8
                String message[] = getMessage(lineStr);
                if (message != null)
                {
                    // Must be exact length, formatted properly, valid APRS callsign-SSID and have valid prior GPS lat/lon
                    CallsignSSID dcall = convertDSTARStation(message[0]);
                    if (!dcall.isValidCallSSID)
                        continue;

                    // Don't do multiples per transmission
                    if (lastPacket != null
                            && lastPacket.OrgCall.equals(dcall)
                            && curtime.minusMillis(dupeDelay).isBefore(lastPacket.timeCreated))
                    {
                        // This is a sliding window
                        lastPacket = getIGate().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[1].charAt(0);
                    readingGPS.symbol[1] = message[1].charAt(1);
                    if (!message[2].isEmpty())
                        readingGPS.comment = message[2];
                    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 = getIGate().new ClientPacket(dcall, dprstocall, 0, readingGPS, dstarpath);
                    updateRcvdTotals(0, false, true);
                    return lastPacket;
                }
            }
        }
    }

    /**
     * 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, int crc)
    {
        getASCIIBytes(String.format("%04X", crc & 0x0ffff), buffer, startpos);
    }

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

    /**
     * group(1) = callsign and ID, group(2) = "front panel"/GPS message
     */
    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}+)$");

    /**
     * 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
     *
     * @param fullline
     * @return callsign id,symbol,trimmed message sans symbol and checksum; or
     * null if invalid
     */
    private static String[] getMessage(String fullline)
    {
        Matcher tmatch = getMatcher(MsgLinePat, fullline.trim());
        if (tmatch.matches())
            try
            {
                if (Integer.parseUnsignedInt(tmatch.group(5), 16)
                        == XORChecksum.getValue(tmatch.group(1)))
                    return new String[]
                    {
                        tmatch.group(2), getSymbol(tmatch.group(3)), tmatch.group(4)
                    };
            }
            catch (NumberFormatException e)
            {
            }
        return null;
    }
}