001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.io.nmea;
003
004import java.io.BufferedReader;
005import java.io.IOException;
006import java.io.InputStream;
007import java.io.InputStreamReader;
008import java.nio.charset.StandardCharsets;
009import java.text.ParsePosition;
010import java.text.SimpleDateFormat;
011import java.time.Instant;
012import java.util.ArrayList;
013import java.util.Arrays;
014import java.util.Collection;
015import java.util.Collections;
016import java.util.Date;
017import java.util.Locale;
018import java.util.Objects;
019import java.util.regex.Matcher;
020import java.util.regex.Pattern;
021
022import org.openstreetmap.josm.data.coor.LatLon;
023import org.openstreetmap.josm.data.gpx.GpxConstants;
024import org.openstreetmap.josm.data.gpx.GpxData;
025import org.openstreetmap.josm.data.gpx.GpxTrack;
026import org.openstreetmap.josm.data.gpx.WayPoint;
027import org.openstreetmap.josm.io.IGpxReader;
028import org.openstreetmap.josm.io.IllegalDataException;
029import org.openstreetmap.josm.tools.Logging;
030import org.openstreetmap.josm.tools.date.DateUtils;
031import org.xml.sax.SAXException;
032
033/**
034 * Reads a NMEA 0183 file. Based on information from
035 * <a href="http://www.catb.org/gpsd/NMEA.html">http://www.catb.org/gpsd</a>.
036 *
037 * NMEA files are in printable ASCII form and may include information such as position,
038 * speed, depth, frequency allocation, etc.
039 * Typical messages might be 11 to a maximum of 79 characters in length.
040 *
041 * NMEA standard aims to support one-way serial data transmission from a single "talker"
042 * to one or more "listeners". The type of talker is identified by a 2-character mnemonic.
043 *
044 * NMEA information is encoded through a list of "sentences".
045 *
046 * @author cbrill
047 */
048public class NmeaReader implements IGpxReader {
049
050    /**
051     * Course Over Ground and Ground Speed.
052     * <p>
053     * The actual course and speed relative to the ground
054     */
055    enum VTG {
056        COURSE(1), COURSE_REF(2), // true course
057        COURSE_M(3), COURSE_M_REF(4), // magnetic course
058        SPEED_KN(5), SPEED_KN_UNIT(6), // speed in knots
059        SPEED_KMH(7), SPEED_KMH_UNIT(8), // speed in km/h
060        REST(9); // version-specific rest
061
062        final int position;
063
064        VTG(int position) {
065            this.position = position;
066        }
067    }
068
069    /**
070     * Recommended Minimum Specific GNSS Data.
071     * <p>
072     * Time, date, position, course and speed data provided by a GNSS navigation receiver.
073     * This sentence is transmitted at intervals not exceeding 2-seconds.
074     * RMC is the recommended minimum data to be provided by a GNSS receiver.
075     * All data fields must be provided, null fields used only when data is temporarily unavailable.
076     */
077    enum RMC {
078        TIME(1),
079        /** Warning from the receiver (A = data ok, V = warning) */
080        RECEIVER_WARNING(2),
081        WIDTH_NORTH(3), WIDTH_NORTH_NAME(4), // Latitude, NS
082        LENGTH_EAST(5), LENGTH_EAST_NAME(6), // Longitude, EW
083        SPEED(7), COURSE(8), DATE(9),           // Speed in knots
084        MAGNETIC_DECLINATION(10), UNKNOWN(11),  // magnetic declination
085        /**
086         * Mode (A = autonom; D = differential; E = estimated; N = not valid; S = simulated)
087         *
088         * @since NMEA 2.3
089         */
090        MODE(12);
091
092        final int position;
093
094        RMC(int position) {
095            this.position = position;
096        }
097    }
098
099    /**
100     * Global Positioning System Fix Data.
101     * <p>
102     * Time, position and fix related data for a GPS receiver.
103     */
104    enum GGA {
105        TIME(1), LATITUDE(2), LATITUDE_NAME(3), LONGITUDE(4), LONGITUDE_NAME(5),
106        /**
107         * Quality (0 = invalid, 1 = GPS, 2 = DGPS, 6 = estimanted (@since NMEA 2.3))
108         */
109        QUALITY(6), SATELLITE_COUNT(7),
110        HDOP(8), // HDOP (horizontal dilution of precision)
111        HEIGHT(9), HEIGHT_UNTIS(10), // height above NN (above geoid)
112        HEIGHT_2(11), HEIGHT_2_UNTIS(12), // height geoid - height ellipsoid (WGS84)
113        GPS_AGE(13), // Age of differential GPS data
114        REF(14); // REF station
115
116        final int position;
117        GGA(int position) {
118            this.position = position;
119        }
120    }
121
122    /**
123     * GNSS DOP and Active Satellites.
124     * <p>
125     * GNSS receiver operating mode, satellites used in the navigation solution reported by the GGA or GNS sentence,
126     * and DOP values.
127     * If only GPS, GLONASS, etc. is used for the reported position solution the talker ID is GP, GL, etc.
128     * and the DOP values pertain to the individual system. If GPS, GLONASS, etc. are combined to obtain the
129     * reported position solution multiple GSA sentences are produced, one with the GPS satellites, another with
130     * the GLONASS satellites, etc. Each of these GSA sentences shall have talker ID GN, to indicate that the
131     * satellites are used in a combined solution and each shall have the PDOP, HDOP and VDOP for the
132     * combined satellites used in the position.
133     */
134    enum GSA {
135        AUTOMATIC(1),
136        FIX_TYPE(2), // 1 = not fixed, 2 = 2D fixed, 3 = 3D fixed)
137        // PRN numbers for max 12 satellites
138        PRN_1(3), PRN_2(4), PRN_3(5), PRN_4(6), PRN_5(7), PRN_6(8),
139        PRN_7(9), PRN_8(10), PRN_9(11), PRN_10(12), PRN_11(13), PRN_12(14),
140        PDOP(15),   // PDOP (precision)
141        HDOP(16),   // HDOP (horizontal precision)
142        VDOP(17);   // VDOP (vertical precision)
143
144        final int position;
145        GSA(int position) {
146            this.position = position;
147        }
148    }
149
150    /**
151     * Geographic Position - Latitude/Longitude.
152     * <p>
153     * Latitude and Longitude of vessel position, time of position fix and status.
154     */
155    enum GLL {
156        LATITUDE(1), LATITUDE_NS(2), // Latitude, NS
157        LONGITUDE(3), LONGITUDE_EW(4), // Latitude, EW
158        UTC(5), // Universal Time Coordinated
159        STATUS(6), // Status: A = Data valid, V = Data not valid
160        /**
161         * Mode (A = autonom; D = differential; E = estimated; N = not valid; S = simulated)
162         * @since NMEA 2.3
163         */
164        MODE(7);
165
166        final int position;
167        GLL(int position) {
168            this.position = position;
169        }
170    }
171
172    private final InputStream source;
173    GpxData data;
174
175    private static final Pattern DATE_TIME_PATTERN = Pattern.compile("(\\d{12})(\\.\\d+)?");
176
177    private final SimpleDateFormat rmcTimeFmt = new SimpleDateFormat("ddMMyyHHmmss.SSS", Locale.ENGLISH);
178
179    private Instant readTime(String p) throws IllegalDataException {
180        // NMEA defines time with "a variable number of digits for decimal-fraction of seconds"
181        // This variable decimal fraction cannot be parsed by SimpleDateFormat
182        Matcher m = DATE_TIME_PATTERN.matcher(p);
183        if (m.matches()) {
184            String date = m.group(1);
185            double milliseconds = 0d;
186            if (m.groupCount() > 1 && m.group(2) != null) {
187                milliseconds = 1000d * Double.parseDouble("0" + m.group(2));
188            }
189            // Add milliseconds on three digits to match SimpleDateFormat pattern
190            date += String.format(".%03d", (int) milliseconds);
191            Date d = rmcTimeFmt.parse(date, new ParsePosition(0));
192            if (d != null)
193                return d.toInstant();
194        }
195        throw new IllegalDataException("Date is malformed: '" + p + "'");
196    }
197
198    // functons for reading the error stats
199    public NMEAParserState ps;
200
201    public int getParserUnknown() {
202        return ps.unknown;
203    }
204
205    public int getParserZeroCoordinates() {
206        return ps.zeroCoord;
207    }
208
209    public int getParserChecksumErrors() {
210        return ps.checksumErrors+ps.noChecksum;
211    }
212
213    public int getParserMalformed() {
214        return ps.malformed;
215    }
216
217    @Override
218    public int getNumberOfCoordinates() {
219        return ps.success;
220    }
221
222    /**
223     * Constructs a new {@code NmeaReader}
224     * @param source NMEA file input stream
225     * @throws IOException if an I/O error occurs
226     */
227    public NmeaReader(InputStream source) throws IOException {
228        this.source = Objects.requireNonNull(source);
229        rmcTimeFmt.setTimeZone(DateUtils.UTC);
230    }
231
232    @Override
233    public boolean parse(boolean tryToFinish) throws SAXException, IOException {
234        // create the data tree
235        data = new GpxData();
236        Collection<Collection<WayPoint>> currentTrack = new ArrayList<>();
237
238        try (BufferedReader rd = new BufferedReader(new InputStreamReader(source, StandardCharsets.UTF_8))) {
239            StringBuilder sb = new StringBuilder(1024);
240            int loopstartChar = rd.read();
241            ps = new NMEAParserState();
242            if (loopstartChar == -1)
243                //TODO tell user about the problem?
244                return false;
245            sb.append((char) loopstartChar);
246            ps.pDate = "010100"; // TODO date problem
247            while (true) {
248                // don't load unparsable files completely to memory
249                if (sb.length() >= 1020) {
250                    sb.delete(0, sb.length()-1);
251                }
252                int c = rd.read();
253                if (c == '$') {
254                    parseNMEASentence(sb.toString(), ps);
255                    sb.delete(0, sb.length());
256                    sb.append('$');
257                } else if (c == -1) {
258                    // EOF: add last WayPoint if it works out
259                    parseNMEASentence(sb.toString(), ps);
260                    break;
261                } else {
262                    sb.append((char) c);
263                }
264            }
265            currentTrack.add(ps.waypoints);
266            data.tracks.add(new GpxTrack(currentTrack, Collections.<String, Object>emptyMap()));
267
268        } catch (IllegalDataException e) {
269            Logging.warn(e);
270            return false;
271        }
272        return true;
273    }
274
275    private static class NMEAParserState {
276        protected Collection<WayPoint> waypoints = new ArrayList<>();
277        protected String pTime;
278        protected String pDate;
279        protected WayPoint pWp;
280
281        protected int success; // number of successfully parsed sentences
282        protected int malformed;
283        protected int checksumErrors;
284        protected int noChecksum;
285        protected int unknown;
286        protected int zeroCoord;
287    }
288
289    /**
290     * Determines if the given address denotes the given NMEA sentence formatter of a known talker.
291     * @param address first tag of an NMEA sentence
292     * @param formatter sentence formatter mnemonic code
293     * @return {@code true} if the {@code address} denotes the given NMEA sentence formatter of a known talker
294     */
295    static boolean isSentence(String address, Sentence formatter) {
296        return Arrays.stream(TalkerId.values())
297                .anyMatch(talker -> address.equals('$' + talker.name() + formatter.name()));
298    }
299
300    // Parses split up sentences into WayPoints which are stored
301    // in the collection in the NMEAParserState object.
302    // Returns true if the input made sense, false otherwise.
303    private boolean parseNMEASentence(String s, NMEAParserState ps) throws IllegalDataException {
304        try {
305            if (s.isEmpty()) {
306                throw new IllegalArgumentException("s is empty");
307            }
308
309            // checksum check:
310            // the bytes between the $ and the * are xored
311            // if there is no * or other meanities it will throw
312            // and result in a malformed packet.
313            String[] chkstrings = s.split("\\*", -1);
314            if (chkstrings.length > 1) {
315                byte[] chb = chkstrings[0].getBytes(StandardCharsets.UTF_8);
316                int chk = 0;
317                for (int i = 1; i < chb.length; i++) {
318                    chk ^= chb[i];
319                }
320                if (Integer.parseInt(chkstrings[1].substring(0, 2), 16) != chk) {
321                    ps.checksumErrors++;
322                    ps.pWp = null;
323                    return false;
324                }
325            } else {
326                ps.noChecksum++;
327            }
328            // now for the content
329            String[] e = chkstrings[0].split(",", -1);
330            String accu;
331
332            WayPoint currentwp = ps.pWp;
333            String currentDate = ps.pDate;
334
335            // handle the packet content
336            if (isSentence(e[0], Sentence.GGA)) {
337                // Position
338                LatLon latLon = parseLatLon(
339                        e[GGA.LATITUDE_NAME.position],
340                        e[GGA.LONGITUDE_NAME.position],
341                        e[GGA.LATITUDE.position],
342                        e[GGA.LONGITUDE.position]
343                );
344                if (latLon == null) {
345                    throw new IllegalDataException("Malformed lat/lon");
346                }
347
348                if (LatLon.ZERO.equals(latLon)) {
349                    ps.zeroCoord++;
350                    return false;
351                }
352
353                // time
354                accu = e[GGA.TIME.position];
355                Instant instant = readTime(currentDate+accu);
356
357                if ((ps.pTime == null) || (currentwp == null) || !ps.pTime.equals(accu)) {
358                    // this node is newer than the previous, create a new waypoint.
359                    // no matter if previous WayPoint was null, we got something better now.
360                    ps.pTime = accu;
361                    currentwp = new WayPoint(latLon);
362                }
363                if (!currentwp.attr.containsKey("time")) {
364                    // As this sentence has no complete time only use it
365                    // if there is no time so far
366                    currentwp.setInstant(instant);
367                }
368                // elevation
369                accu = e[GGA.HEIGHT_UNTIS.position];
370                if ("M".equals(accu)) {
371                    // Ignore heights that are not in meters for now
372                    accu = e[GGA.HEIGHT.position];
373                    if (!accu.isEmpty()) {
374                        Double.parseDouble(accu);
375                        // if it throws it's malformed; this should only happen if the
376                        // device sends nonstandard data.
377                        if (!accu.isEmpty()) { // FIX ? same check
378                            currentwp.put(GpxConstants.PT_ELE, accu);
379                        }
380                    }
381                }
382                // number of satellites
383                accu = e[GGA.SATELLITE_COUNT.position];
384                int sat = 0;
385                if (!accu.isEmpty()) {
386                    sat = Integer.parseInt(accu);
387                    currentwp.put(GpxConstants.PT_SAT, accu);
388                }
389                // h-dilution
390                accu = e[GGA.HDOP.position];
391                if (!accu.isEmpty()) {
392                    currentwp.put(GpxConstants.PT_HDOP, Float.valueOf(accu));
393                }
394                // fix
395                accu = e[GGA.QUALITY.position];
396                if (!accu.isEmpty()) {
397                    int fixtype = Integer.parseInt(accu);
398                    switch(fixtype) {
399                    case 0:
400                        currentwp.put(GpxConstants.PT_FIX, "none");
401                        break;
402                    case 1:
403                        if (sat < 4) {
404                            currentwp.put(GpxConstants.PT_FIX, "2d");
405                        } else {
406                            currentwp.put(GpxConstants.PT_FIX, "3d");
407                        }
408                        break;
409                    case 2:
410                        currentwp.put(GpxConstants.PT_FIX, "dgps");
411                        break;
412                    case 3:
413                        currentwp.put(GpxConstants.PT_FIX, "pps");
414                        break;
415                    case 4:
416                        currentwp.put(GpxConstants.PT_FIX, "rtk");
417                        break;
418                    case 5:
419                        currentwp.put(GpxConstants.PT_FIX, "float rtk");
420                        break;
421                    case 6:
422                        currentwp.put(GpxConstants.PT_FIX, "estimated");
423                        break;
424                    case 7:
425                        currentwp.put(GpxConstants.PT_FIX, "manual");
426                        break;
427                    case 8:
428                        currentwp.put(GpxConstants.PT_FIX, "simulated");
429                        break;
430                    default:
431                        break;
432                    }
433                }
434            } else if (isSentence(e[0], Sentence.VTG)) {
435                // COURSE
436                accu = e[VTG.COURSE_REF.position];
437                if ("T".equals(accu)) {
438                    // other values than (T)rue are ignored
439                    accu = e[VTG.COURSE.position];
440                    if (!accu.isEmpty() && currentwp != null) {
441                        Double.parseDouble(accu);
442                        currentwp.put("course", accu);
443                    }
444                }
445                // SPEED
446                accu = e[VTG.SPEED_KMH_UNIT.position];
447                if (accu.startsWith("K")) {
448                    accu = e[VTG.SPEED_KMH.position];
449                    if (!accu.isEmpty() && currentwp != null) {
450                        double speed = Double.parseDouble(accu);
451                        currentwp.put("speed", Double.toString(speed)); // speed in km/h
452                    }
453                }
454            } else if (isSentence(e[0], Sentence.GSA)) {
455                // vdop
456                accu = e[GSA.VDOP.position];
457                if (!accu.isEmpty() && currentwp != null) {
458                    currentwp.put(GpxConstants.PT_VDOP, Float.valueOf(accu));
459                }
460                // hdop
461                accu = e[GSA.HDOP.position];
462                if (!accu.isEmpty() && currentwp != null) {
463                    currentwp.put(GpxConstants.PT_HDOP, Float.valueOf(accu));
464                }
465                // pdop
466                accu = e[GSA.PDOP.position];
467                if (!accu.isEmpty() && currentwp != null) {
468                    currentwp.put(GpxConstants.PT_PDOP, Float.valueOf(accu));
469                }
470            } else if (isSentence(e[0], Sentence.RMC)) {
471                // coordinates
472                LatLon latLon = parseLatLon(
473                        e[RMC.WIDTH_NORTH_NAME.position],
474                        e[RMC.LENGTH_EAST_NAME.position],
475                        e[RMC.WIDTH_NORTH.position],
476                        e[RMC.LENGTH_EAST.position]
477                );
478                if (LatLon.ZERO.equals(latLon)) {
479                    ps.zeroCoord++;
480                    return false;
481                }
482                // time
483                currentDate = e[RMC.DATE.position];
484                String time = e[RMC.TIME.position];
485
486                Instant instant = readTime(currentDate+time);
487
488                if (ps.pTime == null || currentwp == null || !ps.pTime.equals(time)) {
489                    // this node is newer than the previous, create a new waypoint.
490                    ps.pTime = time;
491                    currentwp = new WayPoint(latLon);
492                }
493                // time: this sentence has complete time so always use it.
494                currentwp.setInstant(instant);
495                // speed
496                accu = e[RMC.SPEED.position];
497                if (!accu.isEmpty() && !currentwp.attr.containsKey("speed")) {
498                    double speed = Double.parseDouble(accu);
499                    speed *= 0.514444444 * 3.6; // to km/h
500                    currentwp.put("speed", Double.toString(speed));
501                }
502                // course
503                accu = e[RMC.COURSE.position];
504                if (!accu.isEmpty() && !currentwp.attr.containsKey("course")) {
505                    Double.parseDouble(accu);
506                    currentwp.put("course", accu);
507                }
508
509                // TODO fix?
510                // * Mode (A = autonom; D = differential; E = estimated; N = not valid; S = simulated)
511                // *
512                // * @since NMEA 2.3
513                //
514                //MODE(12);
515            } else if (isSentence(e[0], Sentence.GLL)) {
516                // coordinates
517                LatLon latLon = parseLatLon(
518                        e[GLL.LATITUDE_NS.position],
519                        e[GLL.LONGITUDE_EW.position],
520                        e[GLL.LATITUDE.position],
521                        e[GLL.LONGITUDE.position]
522                );
523                if (LatLon.ZERO.equals(latLon)) {
524                    ps.zeroCoord++;
525                    return false;
526                }
527                // only consider valid data
528                if (!"A".equals(e[GLL.STATUS.position])) {
529                    return false;
530                }
531
532                // RMC sentences contain a full date while GLL sentences contain only time,
533                // so create new waypoints only of the NMEA file does not contain RMC sentences
534                if (ps.pTime == null || currentwp == null) {
535                    currentwp = new WayPoint(latLon);
536                }
537            } else {
538                ps.unknown++;
539                return false;
540            }
541            ps.pDate = currentDate;
542            if (ps.pWp != currentwp) {
543                if (ps.pWp != null) {
544                    ps.pWp.getInstant();
545                }
546                ps.pWp = currentwp;
547                ps.waypoints.add(currentwp);
548                ps.success++;
549                return true;
550            }
551            return true;
552
553        } catch (IllegalArgumentException | IndexOutOfBoundsException | IllegalDataException ex) {
554            if (ps.malformed < 5) {
555                Logging.warn(ex);
556            } else {
557                Logging.debug(ex);
558            }
559            ps.malformed++;
560            ps.pWp = null;
561            return false;
562        }
563    }
564
565    private static LatLon parseLatLon(String ns, String ew, String dlat, String dlon) {
566        String widthNorth = dlat.trim();
567        String lengthEast = dlon.trim();
568
569        // return a zero latlon instead of null so it is logged as zero coordinate
570        // instead of malformed sentence
571        if (widthNorth.isEmpty() && lengthEast.isEmpty()) return LatLon.ZERO;
572
573        // The format is xxDDLL.LLLL
574        // xx optional whitespace
575        // DD (int) degres
576        // LL.LLLL (double) latidude
577        int latdegsep = widthNorth.indexOf('.') - 2;
578        if (latdegsep < 0) return null;
579
580        int latdeg = Integer.parseInt(widthNorth.substring(0, latdegsep));
581        double latmin = Double.parseDouble(widthNorth.substring(latdegsep));
582        if (latdeg < 0) {
583            latmin *= -1.0;
584        }
585        double lat = latdeg + latmin / 60;
586        if ("S".equals(ns)) {
587            lat = -lat;
588        }
589
590        int londegsep = lengthEast.indexOf('.') - 2;
591        if (londegsep < 0) return null;
592
593        int londeg = Integer.parseInt(lengthEast.substring(0, londegsep));
594        double lonmin = Double.parseDouble(lengthEast.substring(londegsep));
595        if (londeg < 0) {
596            lonmin *= -1.0;
597        }
598        double lon = londeg + lonmin / 60;
599        if ("W".equals(ew)) {
600            lon = -lon;
601        }
602        return new LatLon(lat, lon);
603    }
604
605    @Override
606    public GpxData getGpxData() {
607        return data;
608    }
609}