// License: GPL. For details, see LICENSE file.
package org.openstreetmap.josm.io;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.text.ParsePosition;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import org.openstreetmap.josm.Main;
import org.openstreetmap.josm.data.coor.LatLon;
import org.openstreetmap.josm.data.gpx.GpxConstants;
import org.openstreetmap.josm.data.gpx.GpxData;
import org.openstreetmap.josm.data.gpx.ImmutableGpxTrack;
import org.openstreetmap.josm.data.gpx.WayPoint;
import org.openstreetmap.josm.tools.date.DateUtils;
/**
* Reads a NMEA file. Based on information from
* http://www.kowoma.de
*
* @author cbrill
*/
public class NmeaReader {
/** Handler for the different types that NMEA speaks. */
public static enum NMEA_TYPE {
/** RMC = recommended minimum sentence C. */
GPRMC("$GPRMC"),
/** GPS positions. */
GPGGA("$GPGGA"),
/** SA = satellites active. */
GPGSA("$GPGSA"),
/** Course over ground and ground speed */
GPVTG("$GPVTG");
private final String type;
NMEA_TYPE(String type) {
this.type = type;
}
public String getType() {
return this.type;
}
public boolean equals(String type) {
return this.type.equals(type);
}
}
// GPVTG
public static enum GPVTG {
COURSE(1),COURSE_REF(2), // true course
COURSE_M(3), COURSE_M_REF(4), // magnetic course
SPEED_KN(5), SPEED_KN_UNIT(6), // speed in knots
SPEED_KMH(7), SPEED_KMH_UNIT(8), // speed in km/h
REST(9); // version-specific rest
public final int position;
GPVTG(int position) {
this.position = position;
}
}
// The following only applies to GPRMC
public static enum GPRMC {
TIME(1),
/** Warning from the receiver (A = data ok, V = warning) */
RECEIVER_WARNING(2),
WIDTH_NORTH(3), WIDTH_NORTH_NAME(4), // Latitude, NS
LENGTH_EAST(5), LENGTH_EAST_NAME(6), // Longitude, EW
SPEED(7), COURSE(8), DATE(9), // Speed in knots
MAGNETIC_DECLINATION(10), UNKNOWN(11), // magnetic declination
/**
* Mode (A = autonom; D = differential; E = estimated; N = not valid; S
* = simulated)
*
* @since NMEA 2.3
*/
MODE(12);
public final int position;
GPRMC(int position) {
this.position = position;
}
}
// The following only applies to GPGGA
public static enum GPGGA {
TIME(1), LATITUDE(2), LATITUDE_NAME(3), LONGITUDE(4), LONGITUDE_NAME(5),
/**
* Quality (0 = invalid, 1 = GPS, 2 = DGPS, 6 = estimanted (@since NMEA
* 2.3))
*/
QUALITY(6), SATELLITE_COUNT(7),
HDOP(8), // HDOP (horizontal dilution of precision)
HEIGHT(9), HEIGHT_UNTIS(10), // height above NN (above geoid)
HEIGHT_2(11), HEIGHT_2_UNTIS(12), // height geoid - height ellipsoid (WGS84)
GPS_AGE(13),// Age of differential GPS data
REF(14); // REF station
public final int position;
GPGGA(int position) {
this.position = position;
}
}
public static enum GPGSA {
AUTOMATIC(1),
FIX_TYPE(2), // 1 = not fixed, 2 = 2D fixed, 3 = 3D fixed)
// PRN numbers for max 12 satellites
PRN_1(3), PRN_2(4), PRN_3(5), PRN_4(6), PRN_5(7), PRN_6(8),
PRN_7(9), PRN_8(10), PRN_9(11), PRN_10(12), PRN_11(13), PRN_12(14),
PDOP(15), // PDOP (precision)
HDOP(16), // HDOP (horizontal precision)
VDOP(17), ; // VDOP (vertical precision)
public final int position;
GPGSA(int position) {
this.position = position;
}
}
public GpxData data;
private final SimpleDateFormat rmcTimeFmt = new SimpleDateFormat("ddMMyyHHmmss.SSS");
private final SimpleDateFormat rmcTimeFmtStd = new SimpleDateFormat("ddMMyyHHmmss");
private Date readTime(String p) {
Date d = rmcTimeFmt.parse(p, new ParsePosition(0));
if (d == null) {
d = rmcTimeFmtStd.parse(p, new ParsePosition(0));
}
if (d == null)
throw new RuntimeException("Date is malformed"); // malformed
return d;
}
// functons for reading the error stats
public NMEAParserState ps;
public int getParserUnknown() {
return ps.unknown;
}
public int getParserZeroCoordinates() {
return ps.zeroCoord;
}
public int getParserChecksumErrors() {
return ps.checksumErrors+ps.noChecksum;
}
public int getParserMalformed() {
return ps.malformed;
}
public int getNumberOfCoordinates() {
return ps.success;
}
public NmeaReader(InputStream source) throws IOException {
// create the data tree
data = new GpxData();
Collection> currentTrack = new ArrayList<>();
try (BufferedReader rd = new BufferedReader(new InputStreamReader(source, StandardCharsets.UTF_8))) {
StringBuilder sb = new StringBuilder(1024);
int loopstart_char = rd.read();
ps = new NMEAParserState();
if(loopstart_char == -1)
//TODO tell user about the problem?
return;
sb.append((char)loopstart_char);
ps.pDate="010100"; // TODO date problem
while(true) {
// don't load unparsable files completely to memory
if(sb.length()>=1020) {
sb.delete(0, sb.length()-1);
}
int c = rd.read();
if(c=='$') {
parseNMEASentence(sb.toString(), ps);
sb.delete(0, sb.length());
sb.append('$');
} else if(c == -1) {
// EOF: add last WayPoint if it works out
parseNMEASentence(sb.toString(),ps);
break;
} else {
sb.append((char)c);
}
}
currentTrack.add(ps.waypoints);
data.tracks.add(new ImmutableGpxTrack(currentTrack, Collections.emptyMap()));
} catch (IllegalDataException e) {
Main.warn(e);
}
}
private static class NMEAParserState {
protected Collection waypoints = new ArrayList<>();
protected String pTime;
protected String pDate;
protected WayPoint pWp;
protected int success = 0; // number of successfully parsed sentences
protected int malformed = 0;
protected int checksumErrors = 0;
protected int noChecksum = 0;
protected int unknown = 0;
protected int zeroCoord = 0;
}
// Parses split up sentences into WayPoints which are stored
// in the collection in the NMEAParserState object.
// Returns true if the input made sence, false otherwise.
private boolean parseNMEASentence(String s, NMEAParserState ps) throws IllegalDataException {
try {
if (s.isEmpty()) {
throw new IllegalArgumentException("s is empty");
}
// checksum check:
// the bytes between the $ and the * are xored
// if there is no * or other meanities it will throw
// and result in a malformed packet.
String[] chkstrings = s.split("\\*");
if (chkstrings.length > 1) {
byte[] chb = chkstrings[0].getBytes(StandardCharsets.UTF_8);
int chk=0;
for (int i = 1; i < chb.length; i++) {
chk ^= chb[i];
}
if (Integer.parseInt(chkstrings[1].substring(0,2),16) != chk) {
ps.checksumErrors++;
ps.pWp=null;
return false;
}
} else {
ps.noChecksum++;
}
// now for the content
String[] e = chkstrings[0].split(",");
String accu;
WayPoint currentwp = ps.pWp;
String currentDate = ps.pDate;
// handle the packet content
if("$GPGGA".equals(e[0]) || "$GNGGA".equals(e[0])) {
// Position
LatLon latLon = parseLatLon(
e[GPGGA.LATITUDE_NAME.position],
e[GPGGA.LONGITUDE_NAME.position],
e[GPGGA.LATITUDE.position],
e[GPGGA.LONGITUDE.position]
);
if (latLon==null) {
throw new IllegalDataException("Malformed lat/lon");
}
if (LatLon.ZERO.equals(latLon)) {
ps.zeroCoord++;
return false;
}
// time
accu = e[GPGGA.TIME.position];
Date d = readTime(currentDate+accu);
if((ps.pTime==null) || (currentwp==null) || !ps.pTime.equals(accu)) {
// this node is newer than the previous, create a new waypoint.
// no matter if previous WayPoint was null, we got something
// better now.
ps.pTime=accu;
currentwp = new WayPoint(latLon);
}
if(!currentwp.attr.containsKey("time")) {
// As this sentence has no complete time only use it
// if there is no time so far
currentwp.put(GpxConstants.PT_TIME, DateUtils.fromDate(d));
}
// elevation
accu=e[GPGGA.HEIGHT_UNTIS.position];
if("M".equals(accu)) {
// Ignore heights that are not in meters for now
accu=e[GPGGA.HEIGHT.position];
if(!accu.isEmpty()) {
Double.parseDouble(accu);
// if it throws it's malformed; this should only happen if the
// device sends nonstandard data.
if(!accu.isEmpty()) { // FIX ? same check
currentwp.put(GpxConstants.PT_ELE, accu);
}
}
}
// number of sattelites
accu=e[GPGGA.SATELLITE_COUNT.position];
int sat = 0;
if(!accu.isEmpty()) {
sat = Integer.parseInt(accu);
currentwp.put(GpxConstants.PT_SAT, accu);
}
// h-dilution
accu=e[GPGGA.HDOP.position];
if(!accu.isEmpty()) {
currentwp.put(GpxConstants.PT_HDOP, Float.valueOf(accu));
}
// fix
accu=e[GPGGA.QUALITY.position];
if(!accu.isEmpty()) {
int fixtype = Integer.parseInt(accu);
switch(fixtype) {
case 0:
currentwp.put(GpxConstants.PT_FIX, "none");
break;
case 1:
if(sat < 4) {
currentwp.put(GpxConstants.PT_FIX, "2d");
} else {
currentwp.put(GpxConstants.PT_FIX, "3d");
}
break;
case 2:
currentwp.put(GpxConstants.PT_FIX, "dgps");
break;
default:
break;
}
}
} else if("$GPVTG".equals(e[0]) || "$GNVTG".equals(e[0])) {
// COURSE
accu = e[GPVTG.COURSE_REF.position];
if("T".equals(accu)) {
// other values than (T)rue are ignored
accu = e[GPVTG.COURSE.position];
if(!accu.isEmpty()) {
Double.parseDouble(accu);
currentwp.put("course", accu);
}
}
// SPEED
accu = e[GPVTG.SPEED_KMH_UNIT.position];
if(accu.startsWith("K")) {
accu = e[GPVTG.SPEED_KMH.position];
if(!accu.isEmpty()) {
double speed = Double.parseDouble(accu);
speed /= 3.6; // speed in m/s
currentwp.put("speed", Double.toString(speed));
}
}
} else if("$GPGSA".equals(e[0]) || "$GNGSA".equals(e[0])) {
// vdop
accu=e[GPGSA.VDOP.position];
if(!accu.isEmpty()) {
currentwp.put(GpxConstants.PT_VDOP, Float.valueOf(accu));
}
// hdop
accu=e[GPGSA.HDOP.position];
if(!accu.isEmpty()) {
currentwp.put(GpxConstants.PT_HDOP, Float.valueOf(accu));
}
// pdop
accu=e[GPGSA.PDOP.position];
if(!accu.isEmpty()) {
currentwp.put(GpxConstants.PT_PDOP, Float.valueOf(accu));
}
} else if("$GPRMC".equals(e[0]) || "$GNRMC".equals(e[0])) {
// coordinates
LatLon latLon = parseLatLon(
e[GPRMC.WIDTH_NORTH_NAME.position],
e[GPRMC.LENGTH_EAST_NAME.position],
e[GPRMC.WIDTH_NORTH.position],
e[GPRMC.LENGTH_EAST.position]
);
if (LatLon.ZERO.equals(latLon)) {
ps.zeroCoord++;
return false;
}
// time
currentDate = e[GPRMC.DATE.position];
String time = e[GPRMC.TIME.position];
Date d = readTime(currentDate+time);
if(ps.pTime==null || currentwp==null || !ps.pTime.equals(time)) {
// this node is newer than the previous, create a new waypoint.
ps.pTime=time;
currentwp = new WayPoint(latLon);
}
// time: this sentence has complete time so always use it.
currentwp.put(GpxConstants.PT_TIME, DateUtils.fromDate(d));
// speed
accu = e[GPRMC.SPEED.position];
if(!accu.isEmpty() && !currentwp.attr.containsKey("speed")) {
double speed = Double.parseDouble(accu);
speed *= 0.514444444; // to m/s
currentwp.put("speed", Double.toString(speed));
}
// course
accu = e[GPRMC.COURSE.position];
if(!accu.isEmpty() && !currentwp.attr.containsKey("course")) {
Double.parseDouble(accu);
currentwp.put("course", accu);
}
// TODO fix?
// * Mode (A = autonom; D = differential; E = estimated; N = not valid; S
// * = simulated)
// *
// * @since NMEA 2.3
//
//MODE(12);
} else {
ps.unknown++;
return false;
}
ps.pDate = currentDate;
if(ps.pWp != currentwp) {
if(ps.pWp!=null) {
ps.pWp.setTime();
}
ps.pWp = currentwp;
ps.waypoints.add(currentwp);
ps.success++;
return true;
}
return true;
} catch (RuntimeException x) {
// out of bounds and such
ps.malformed++;
ps.pWp=null;
return false;
}
}
private LatLon parseLatLon(String ns, String ew, String dlat, String dlon)
throws NumberFormatException {
String widthNorth = dlat.trim();
String lengthEast = dlon.trim();
// return a zero latlon instead of null so it is logged as zero coordinate
// instead of malformed sentence
if(widthNorth.isEmpty() && lengthEast.isEmpty()) return new LatLon(0.0,0.0);
// The format is xxDDLL.LLLL
// xx optional whitespace
// DD (int) degres
// LL.LLLL (double) latidude
int latdegsep = widthNorth.indexOf('.') - 2;
if (latdegsep < 0) return null;
int latdeg = Integer.parseInt(widthNorth.substring(0, latdegsep));
double latmin = Double.parseDouble(widthNorth.substring(latdegsep));
if(latdeg < 0) {
latmin *= -1.0;
}
double lat = latdeg + latmin / 60;
if ("S".equals(ns)) {
lat = -lat;
}
int londegsep = lengthEast.indexOf('.') - 2;
if (londegsep < 0) return null;
int londeg = Integer.parseInt(lengthEast.substring(0, londegsep));
double lonmin = Double.parseDouble(lengthEast.substring(londegsep));
if(londeg < 0) {
lonmin *= -1.0;
}
double lon = londeg + lonmin / 60;
if ("W".equals(ew)) {
lon = -lon;
}
return new LatLon(lat, lon);
}
}