/*
* Copyright ThinkTank Maths Limited 2006 - 2008
*
* This file is free software: you can redistribute it and/or modify it under
* the terms of the GNU Lesser General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
*
* This file is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
* details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this file. If not, see <http://www.gnu.org/licenses/>.
*/
package com.openlapi;
import java.io.IOException;
import java.io.InputStream;
import thinktank.j2me.TTUtils;
/**
* Generic NMEA-reading {@link Runnable} which is intended to run in a background
* {@link Thread}. Takes a {@link LocationBackendListener} during construction and will
* update it with {@link Location} objects as they become available. May be stopped by
* calling {@link #end()}, which will also close the input stream. Cannot be restarted
* once stopped.
*
* @see http://en.wikipedia.org/wiki/NMEA_0183
*/
final class NMEADaemon implements Runnable {
/**
* Callback interface for allowing the calling class to take actions
* depending on the validity of the (expected) NMEA stream.
*/
public interface ValidStreamCallback {
/**
* Called if the NMEA stream is determined to be valid. Will only be
* called a maximum of one time.
*/
public void onSuccess();
/**
* Called if the input stream is not a valid NMEA stream. Will only be
* called a maximum of one time.
* @param some offending data from the stream, possibly trimmed and
* possibly null.
*/
public void onFailure(String failed);
}
private volatile boolean end = false;
private final InputStream input;
private final LocationBackendListener listener;
private final ValidStreamCallback callback;
private volatile boolean running = false;
private volatile boolean seenGPGGA = false;
/**
* @param listener
* @param input
* @param callback may be null to ignore callbacks
* @throws NullPointerException
* if either parameter (except output) is null.
*/
public NMEADaemon(LocationBackendListener listener, InputStream input,
ValidStreamCallback callback) {
if ((listener == null) || (input == null))
throw new NullPointerException();
this.listener = listener;
this.input = input;
this.callback = callback;
}
/**
* Signal the daemon to stop at the next appropriate moment.
*/
public void end() {
synchronized (this) {
if (end)
// already ending in another thread
return;
// stop the loop in run
end = true;
}
// close the input
try {
input.close();
} catch (IOException e) {
}
// let the listener know that we are out of action.
listener.updateState(LocationProvider.OUT_OF_SERVICE);
}
/**
* @return true if this is running.
*/
public boolean isRunning() {
return running;
}
public void run() {
// only one thread permitted
synchronized (this) {
if (running)
return;
// we are now running
running = true;
}
String line;
while (!end) {
try {
line = TTUtils.readLine(input);
if (line == null) {
end();
break;
}
} catch (IOException e) {
TTUtils.log("IOException reading NMEA " + e.getMessage());
end();
break;
}
Location location = parseNMEA(line);
if (location != null) {
// avoid a minor race condition with end() saying we are OUT_OF_SERVICE
if (!end)
listener.updateState(LocationProvider.AVAILABLE);
listener.updateLocation(location);
System.out.println("Location: " + location.toString());
}
}
}
/**
* Calculates the checksum of a NMEA sentence and compares it to the one in the
* sentence.
* <p>
* The checksum is the 8-bit exclusive OR (no start or stop bits) of all characters in
* the sentence, including the "," delimiters, between (but not including) the "$" and
* "*" delimiters.
*
* @param sentence
* NMEA sentence, may contain starting '$' or ending newlines.
* @return true if the checksum passes, otherwise false
* @see http://www.garmin.com/support/faqs/faq.jsp?faq=40
*/
private boolean isNMEAValid(String sentence) {
int sum = 0;
String recvSum = null;
for (int i = 0; i < sentence.length(); i++) {
char c = sentence.charAt(i);
if (c == '$') {
continue;
}
if (c == '*') {
try {
recvSum = sentence.substring(i + 1, i + 3);
} catch (IndexOutOfBoundsException e) {
return false;
}
break;
}
sum = sum ^ c;
}
if (recvSum == null)
return false;
// specification is vague on case of the checksum string
recvSum = recvSum.toUpperCase();
String calcSum = Integer.toHexString(sum).toUpperCase();
if (calcSum.equals(recvSum))
return true;
return false;
}
private volatile boolean firstLine = true;
/**
* Ignore the date string in the sentence as it will not be synchronised to our clock.
*
* @param sentence
* an NMEA sentence
* @return a {@link Location} (which may be invalid, as defined in the sentence).
* Malformed (or unrecognised) sentences will return null.
*/
private Location parseNMEA(String sentence) {
// TTUtils.log(sentence);
boolean nmeaValid = isNMEAValid(sentence);
if (firstLine) {
firstLine = false;
if (callback != null) {
if (nmeaValid)
callback.onSuccess();
else {
if (sentence != null && sentence.length() > 32)
sentence = new String(sentence.substring(0, 32));
callback.onFailure(sentence);
}
}
}
if (!nmeaValid)
return null;
double latitude;
double longitude;
float altitude = Float.NaN;
boolean valid = false;
if (sentence.startsWith("$GPGGA")) {
if (!seenGPGGA)
seenGPGGA = true;
// GGA - essential fix data which provide 3D location and accuracy
// $GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47
String[] fields = TTUtils.stringSplit(sentence, ',');
// check validity. 1 or 2 for valid, 0 for invalid
if (!"0".equals(fields[6])) {
valid = true;
}
latitude = readNMEACoordinate(fields[2], fields[3]);
longitude = readNMEACoordinate(fields[4], fields[5]);
// if the height of geoid is missing, the altitude is suspect
if (!"".equals(fields[11])) {
altitude = Float.valueOf(fields[9]).floatValue();
}
} else if (!seenGPGGA && sentence.startsWith("$GPRMC")) {
// ignore GPRMC strings if we have observed GPGGA ones
// as they come at the same time and GPGGA contains more info
// The Recommended Minimum, which will look similar to:
// $GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A
String[] fields = TTUtils.stringSplit(sentence, ',');
// check validity. A for Active, V for inValid
if ("A".equals(fields[2])) {
valid = true;
}
latitude = readNMEACoordinate(fields[3], fields[4]);
longitude = readNMEACoordinate(fields[5], fields[6]);
} else
// no GGA or RMC to parse
return null;
// construct the "heavier" (for J2ME) objects only after confirming we have values
QualifiedCoordinates qc =
new QualifiedCoordinates(latitude, longitude, altitude,
Float.NaN, Float.NaN);
Location location = new Location();
location.valid = valid;
location.extraInfo_NMEA = sentence;
location.timestamp = System.currentTimeMillis();
location.locationMethod = Location.MTE_SATELLITE;
location.qualifiedCoordinates = qc;
// TTUtils.log("Seen a " + (valid ? "" : "non-") + "valid NMEA: " + location.extraInfo_NMEA);
return location;
}
/**
* Reads an NMEA coordinate pair of strings, such as 4807.038,N or 01131.000,E and
* returns the double point representation.
*
* @param decimal
* e.g. 4807.038 or 01131.000
* @param bearing
* e.g. N or W
* @return
*/
private double readNMEACoordinate(String decimal, String bearing) {
// first determine if this is a longitude or a latitude
String coordString = null;
if ("N".equals(bearing) || "S".equals(bearing)) {
coordString = decimal.substring(0, 2) + ":" + decimal.substring(2);
} else {
coordString = decimal.substring(0, 3) + ":" + decimal.substring(3);
}
double coord = Coordinates.convert(coordString);
// if it's opposite bearing, reverse the sign
if ("S".equals(bearing) || "W".equals(bearing)) {
coord = 0.0 - coord;
}
return coord;
}
}