Changeset 1167 in josm for trunk/src


Ignore:
Timestamp:
2008-12-23T14:32:30+01:00 (11 years ago)
Author:
stoecker
Message:

added better NMEA reader. Closes bug #1448

Location:
trunk/src/org/openstreetmap/josm
Files:
3 edited

Legend:

Unmodified
Added
Removed
  • trunk/src/org/openstreetmap/josm/actions/OpenFileAction.java

    r1146 r1167  
    119119                }
    120120    }
     121       
     122        private void showNmeaInfobox(boolean success, NmeaReader r) {
     123                String msg = tr("Coordinates imported: ") + r.getNumberOfCoordinates() + "\n" +
     124                tr("Malformed sentences: ") + r.getParserMalformed() + "\n" +
     125                tr("Checksum errors: ") + r.getParserChecksumErrors() + "\n";
     126                if(!success) // don't scare the user unneccessarily
     127                        msg += tr("Unknown sentences: ") + r.getParserUnknown() + "\n";
     128                msg += tr("Zero coordinates: ") + r.getParserZeroCoordinates();
     129                if(success) {   
     130                        JOptionPane.showMessageDialog(
     131                                Main.parent, msg,
     132                                tr("NMEA import success"),JOptionPane.INFORMATION_MESSAGE);
     133                } else {
     134                        JOptionPane.showMessageDialog(
     135                                Main.parent, msg,
     136                                tr("NMEA import faliure!"),JOptionPane.ERROR_MESSAGE);
     137                }
     138        }
    121139
    122140        private void openFileAsNmea(File file) throws IOException, FileNotFoundException {
     
    124142                if (ExtensionFileFilter.filters[ExtensionFileFilter.NMEA].acceptName(fn)) {
    125143                        NmeaReader r = new NmeaReader(new FileInputStream(file), file.getAbsoluteFile().getParentFile());
    126                         r.data.storageFile = file;
    127                         GpxLayer gpxLayer = new GpxLayer(r.data, fn);
    128                         Main.main.addLayer(gpxLayer);
    129                         if (Main.pref.getBoolean("marker.makeautomarkers", true)) {
    130                                 MarkerLayer ml = new MarkerLayer(r.data, tr("Markers from {0}", fn), file, gpxLayer);
    131                                 if (ml.data.size() > 0) {
    132                                         Main.main.addLayer(ml);
     144                        if(r.getNumberOfCoordinates()>0) {
     145                                r.data.storageFile = file;
     146                                GpxLayer gpxLayer = new GpxLayer(r.data, fn);
     147                                Main.main.addLayer(gpxLayer);
     148                                if (Main.pref.getBoolean("marker.makeautomarkers", true)) {
     149                                        MarkerLayer ml = new MarkerLayer(r.data, tr("Markers from {0}", fn), file, gpxLayer);
     150                                        if (ml.data.size() > 0) {
     151                                                Main.main.addLayer(ml);
     152                                        }
    133153                                }
    134154                        }
    135 
     155                        showNmeaInfobox(r.getNumberOfCoordinates()>0, r);
    136156                } else {
    137157                        throw new IllegalStateException();
  • trunk/src/org/openstreetmap/josm/data/gpx/WayPoint.java

    r813 r1167  
    3535         * Convert the time stamp of the waypoint into seconds from the epoch
    3636         */
     37        public final static SimpleDateFormat GPXTIMEFMT =
     38                new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS"); // ignore timezone
     39       
    3740        public void setTime() {
    3841                if (! attr.containsKey("time")) {
    3942                        return;
    4043                }
    41                 SimpleDateFormat f = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss"); // ignore timezone
    42                 Date d = f.parse(attr.get("time").toString(), new ParsePosition(0));
     44                Date d = GPXTIMEFMT.parse(attr.get("time").toString(), new ParsePosition(0));
    4345                if (d != null /* parsing ok */) {
    4446                        time = d.getTime() / 1000.0; /* ms => seconds */
  • trunk/src/org/openstreetmap/josm/io/NmeaReader.java

    r1056 r1167  
    88import java.io.InputStream;
    99import java.io.InputStreamReader;
     10import java.text.ParsePosition;
     11import java.text.SimpleDateFormat;
    1012import java.util.ArrayList;
    1113import java.util.Arrays;
    1214import java.util.Collection;
     15import java.util.Date;
    1316
    1417import org.openstreetmap.josm.data.coor.LatLon;
     
    2023 * Read a nmea file. Based on information from
    2124 * http://www.kowoma.de/gps/zusatzerklaerungen/NMEA.htm
    22  * 
     25 *
    2326 * @author cbrill
    2427 */
     
    3336                GPGGA("$GPGGA"),
    3437                /** SA = satellites active. */
    35                 GPGSA("$GPGSA");
     38                GPGSA("$GPGSA"),
     39                /** Course over ground and ground speed */
     40                GPVTG("$GPVTG");
    3641
    3742                private final String type;
     
    5055        }
    5156
    52         private static final int TYPE = 0;
     57        // GPVTG
     58        public static enum GPVTG {
     59                COURSE(1),COURSE_REF(2), // true course
     60                COURSE_M(3), COURSE_M_REF(4), // magnetic course
     61                SPEED_KN(5), SPEED_KN_UNIT(6), // speed in knots
     62                SPEED_KMH(7), SPEED_KMH_UNIT(8), // speed in km/h
     63                REST(9); // version-specific rest
     64
     65                public final int position;
     66
     67                GPVTG(int position) {
     68                        this.position = position;
     69                }
     70        }
    5371
    5472        // The following only applies to GPRMC
     
    5674                TIME(1),
    5775                /** Warning from the receiver (A = data ok, V = warning) */
    58                 RECEIVER_WARNING(2),
    59                 WIDTH_NORTH(3), WIDTH_NORTH_NAME(4),
    60                 LENGTH_EAST(5), LENGTH_EAST_NAME(6),
    61                 /** Speed in knots */
    62                 SPEED(7), COURSE(8), DATE(9),
    63                 /** magnetic declination */
    64                 MAGNETIC_DECLINATION(10), UNKNOWN(11),
     76                RECEIVER_WARNING(2),
     77                WIDTH_NORTH(3), WIDTH_NORTH_NAME(4), // Latitude, NS
     78                LENGTH_EAST(5), LENGTH_EAST_NAME(6), // Longitude, EW
     79                SPEED(7), COURSE(8), DATE(9),           // Speed in knots
     80                MAGNETIC_DECLINATION(10), UNKNOWN(11),  // magnetic declination
    6581                /**
    6682                 * Mode (A = autonom; D = differential; E = estimated; N = not valid; S
    6783                 * = simulated)
    68                  * 
     84                 *
    6985                 * @since NMEA 2.3
    7086                 */
     
    86102                 */
    87103                QUALITY(6), SATELLITE_COUNT(7),
    88                 /** HDOP (horizontal dilution of precision) */
    89                 HDOP(8),
    90                 /** height above NN (above geoid) */
    91                 HEIGHT(9), HEIGHT_UNTIS(10),
    92                 /** height geoid - height ellipsoid (WGS84) */
    93                 HEIGHT_2(11), HEIGHT_2_UNTIS(12);
     104                HDOP(8), // HDOP (horizontal dilution of precision)
     105                HEIGHT(9), HEIGHT_UNTIS(10), // height above NN (above geoid)
     106                HEIGHT_2(11), HEIGHT_2_UNTIS(12), // height geoid - height ellipsoid (WGS84)
     107                GPS_AGE(13),// Age of differential GPS data
     108                REF(14); // REF station
    94109
    95110                public final int position;
    96 
    97111                GPGGA(int position) {
    98112                        this.position = position;
     
    100114        }
    101115
    102         // The following only applies to GPGGA
    103116        public static enum GPGSA {
    104117                AUTOMATIC(1),
    105                 /** 1 = not fixed, 2 = 2D fixed, 3 = 3D fixed) */
    106                 FIX_TYPE(2),
     118                FIX_TYPE(2), // 1 = not fixed, 2 = 2D fixed, 3 = 3D fixed)
    107119                // PRN numbers for max 12 satellites
    108                 PRN_1(3), PRN_2(4), PRN_3(5), PRN_4(6), PRN_5(7), PRN_6(8), PRN_7(9), PRN_8(
    109                         10), PRN_9(11), PRN_10(12), PRN_11(13), PRN_12(14),
    110                 /** PDOP (precision) */
    111                 PDOP(15),
    112                 /** HDOP (horizontal precision) */
    113                 HDOP(16),
    114                 /** VDOP (vertical precision) */
    115                 VDOP(17), ;
     120                PRN_1(3), PRN_2(4), PRN_3(5), PRN_4(6), PRN_5(7), PRN_6(8),
     121                PRN_7(9), PRN_8(10), PRN_9(11), PRN_10(12), PRN_11(13), PRN_12(14),
     122                PDOP(15),   // PDOP (precision)
     123                HDOP(16),   // HDOP (horizontal precision)
     124                VDOP(17), ; // VDOP (vertical precision)
    116125
    117126                public final int position;
    118 
    119127                GPGSA(int position) {
    120128                        this.position = position;
     
    124132        public GpxData data;
    125133
     134//  private final static SimpleDateFormat GGATIMEFMT =
     135//      new SimpleDateFormat("HHmmss.SSS");
     136        private final static SimpleDateFormat RMCTIMEFMT =
     137                new SimpleDateFormat("ddMMyyHHmmss.SSS");
     138
     139        // functons for reading the error stats
     140        public NMEAParserState ps;
     141
     142        public int getParserUnknown() {
     143                return ps.unknown;
     144        }
     145        public int getParserZeroCoordinates() {
     146                return ps.zero_coord;
     147        }
     148        public int getParserChecksumErrors() {
     149                return ps.checksum_errors;
     150        }
     151        public int getParserMalformed() {
     152                return ps.malformed;
     153        }
     154        public int getNumberOfCoordinates() {
     155                return ps.success;
     156        }
     157
    126158        public NmeaReader(InputStream source, File relativeMarkerPath) {
     159
     160                // create the data tree
    127161                data = new GpxData();
    128162                GpxTrack currentTrack = new GpxTrack();
    129                 Collection<WayPoint> currentTrackSeg = new ArrayList<WayPoint>();
    130                 currentTrack.trackSegs.add(currentTrackSeg);
    131163                data.tracks.add(currentTrack);
    132164
    133                 BufferedReader rd;
    134                 String nmeaWithChecksum;
    135 
    136165                try {
    137                         rd = new BufferedReader(new InputStreamReader(source));
    138                         while ((nmeaWithChecksum = rd.readLine()) != null) {
    139                                 String[] nmeaAndChecksum = nmeaWithChecksum.split("\\*");
    140                                 String nmea = nmeaAndChecksum[0];
    141                                 // XXX: No need for it: String checksum = nmeaAndChecksum[1];
    142                                 String[] e = nmea.split(",");
    143                                 if (e.length == 0) {
    144                                     continue;
    145                                 }
    146                                 if (NMEA_TYPE.GPRMC.equals(e[TYPE])) {
    147                                         LatLon latLon = parseLatLon(e);
    148                                         if (latLon == null) {
    149                                                 continue;
     166                        BufferedReader rd =
     167                                new BufferedReader(new InputStreamReader(source));
     168
     169                        StringBuffer sb = new StringBuffer(1024);
     170                        int loopstart_char = rd.read();
     171                        if(loopstart_char == -1) {// zero size file
     172                                //TODO tell user about the problem?
     173                                return;
     174                        }
     175                        sb.append((char)loopstart_char);
     176                        ps = new NMEAParserState();
     177                        ps.p_Date="010100"; // TODO date problem
     178                        while(true) {
     179                                // don't load unparsable files completely to memory
     180                                if(sb.length()>=1020) sb.delete(0, sb.length()-1);
     181                                int c = rd.read();
     182                                if(c=='$') {
     183                                        ParseNMEASentence(sb.toString(), ps);
     184                                        sb.delete(0, sb.length());
     185                                        sb.append('$');
     186                                } else if(c == -1) {
     187                                        // EOF: add last WayPoint if it works out
     188                                        ParseNMEASentence(sb.toString(),ps);
     189                                        break;
     190                                } else sb.append((char)c);
     191                        }
     192                        rd.close();
     193                        Object[] wparr = ps.waypoints.toArray();
     194                        currentTrack.trackSegs.add(ps.waypoints);
     195                        data.recalculateBounds();
     196
     197                } catch (final IOException e) {
     198                        // TODO tell user about the problem?
     199                }
     200        }
     201        private class NMEAParserState {
     202                protected Collection<WayPoint> waypoints = new ArrayList<WayPoint>();
     203                protected String p_Time;
     204                protected String p_Date;
     205                protected WayPoint p_Wp;
     206
     207                protected int success = 0; // number of successfully parsend sentences
     208                protected int malformed = 0;
     209                protected int checksum_errors = 0;
     210                protected int unknown = 0;
     211                protected int zero_coord = 0;
     212        }
     213
     214        // Parses split up sentences into WayPoints which are stored
     215        // in the collection in the NMEAParserState object.
     216        // Returns true if the input made sence, false otherwise.
     217        private boolean ParseNMEASentence(String s, NMEAParserState ps) {
     218                try {
     219                        if(s.equals("")) throw(null);
     220
     221                        // checksum check:
     222                        // the bytes between the $ and the * are xored;
     223                        // if there is no * or other meanities it will throw
     224                        // and result in a malformed packet.
     225                        String[] chkstrings = s.split("\\*");
     226                        byte[] chb = chkstrings[0].getBytes();
     227                        int chk=0;
     228                        for(int i = 1; i < chb.length; i++) chk ^= chb[i];
     229                        if(Integer.parseInt(chkstrings[1].substring(0,2),16) != chk) {
     230                                //System.out.println("Checksum error");
     231                                ps.checksum_errors++;
     232                                ps.p_Wp=null;
     233                                return false;
     234                        }
     235                        // now for the content
     236                        String[] e = chkstrings[0].split(",");
     237                        String accu;
     238
     239                        WayPoint currentwp = ps.p_Wp;
     240                        String currentDate = ps.p_Date;
     241
     242                        // handle the packet content
     243                        if(e[0].equals("$GPGGA")) {
     244                                // Position
     245                                LatLon latLon = parseLatLon(
     246                                                e[GPGGA.LATITUDE_NAME.position],
     247                                                e[GPGGA.LONGITUDE_NAME.position],
     248                                                e[GPGGA.LATITUDE.position],
     249                                                e[GPGGA.LONGITUDE.position]
     250                                        );
     251                                if(latLon==null) throw(null); // malformed
     252
     253                                if((latLon.lat()==0.0) && (latLon.lon()==0.0)) {
     254                                        ps.zero_coord++;
     255                                        return false;
     256                                }
     257
     258                                // time
     259                                accu = e[GPGGA.TIME.position];
     260                                Date d = RMCTIMEFMT.parse(currentDate+accu, new ParsePosition(0));
     261                                if (d == null) throw(null); // malformed
     262
     263                                if((ps.p_Time==null) || (currentwp==null) || !ps.p_Time.equals(accu)) {
     264                                        // this node is newer than the previous, create a new waypoint.
     265                                        // no matter if previous WayPoint was null, we got something
     266                                        // better now.
     267                                        ps.p_Time=accu;
     268                                        currentwp = new WayPoint(latLon);
     269                                }
     270                                if(!currentwp.attr.containsKey("time")) {
     271                                        // As this sentence has no complete time only use it
     272                                        // if there is no time so far
     273                                        String gpxdate = WayPoint.GPXTIMEFMT.format(d);
     274                                        currentwp.attr.put("time", gpxdate);
     275                                }
     276                                // elevation
     277                                accu=e[GPGGA.HEIGHT_UNTIS.position];
     278                                if(accu.equals("M")) {
     279                                                // Ignore heights that are not in meters for now
     280                                                accu=e[GPGGA.HEIGHT.position];
     281                                                if(!accu.equals("")) {
     282                                                Double.parseDouble(accu);
     283                                                // if it throws it's malformed; this should only happen if the
     284                                                // device sends nonstandard data.
     285                                                if(!accu.equals("")) currentwp.attr.put("ele", accu);
    150286                                        }
    151                                         WayPoint currentWayPoint = new WayPoint(latLon);
    152                                         currentTrackSeg.add(currentWayPoint);
    153                                 }
    154                         }
    155                         rd.close();
    156                 } catch (final IOException e) {
    157                         System.out.println("Error reading file");
    158                 }
    159 
    160         }
    161 
    162         private LatLon parseLatLon(String[] e) throws NumberFormatException {
    163             // If the array looks bogus don't try to get valuable information from it
    164             // But remember that the array is stripped of checksum and GPRMC is only 12 elements and split strips empty trailing elements 
    165             if (e.length < 10) {
    166                 return null;
    167             }
    168         String widthNorth = e[GPRMC.WIDTH_NORTH.position].trim();
    169                 String lengthEast = e[GPRMC.LENGTH_EAST.position].trim();
    170                 if ("".equals(widthNorth) || "".equals(lengthEast)) {
    171                         return null;
    172                 }
     287                                }
     288                                // number of sattelites
     289                                accu=e[GPGGA.SATELLITE_COUNT.position];
     290                                int sat = 0;
     291                                if(!accu.equals("")) {
     292                                        sat = Integer.parseInt(accu);
     293                                        currentwp.attr.put("sat", accu);
     294                                }
     295                                // h-dilution
     296                                accu=e[GPGGA.HDOP.position];
     297                                if(!accu.equals("")) {
     298                                        Double.parseDouble(accu);
     299                                        currentwp.attr.put("hdop", accu);
     300                                }
     301                                // fix
     302                                accu=e[GPGGA.QUALITY.position];
     303                                if(!accu.equals("")) {
     304                                        int fixtype = Integer.parseInt(accu);
     305                                        switch(fixtype) {
     306                                        case 0:
     307                                                currentwp.attr.put("fix", "none");
     308                                                break;
     309                                        case 1:
     310                                                if(sat < 4) currentwp.attr.put("fix", "2d");
     311                                                else currentwp.attr.put("fix", "3d");
     312                                                break;
     313                                        case 2:
     314                                                currentwp.attr.put("fix", "dgps");
     315                                                break;
     316                                        default:
     317                                                break;
     318                                        }
     319                                }
     320                        } else if(e[0].equals("$GPVTG")) {
     321                                // COURSE
     322                                accu = e[GPVTG.COURSE_REF.position];
     323                                if(accu.equals("T")) {
     324                                        // other values than (T)rue are ignored
     325                                        accu = e[GPVTG.COURSE.position];
     326                                        if(!accu.equals("")) {
     327                                                Double.parseDouble(accu);
     328                                                currentwp.attr.put("course", accu);
     329                                        }
     330                                }
     331                                // SPEED
     332                                accu = e[GPVTG.SPEED_KMH_UNIT.position];
     333                                if(accu.startsWith("K")) {
     334                                        accu = e[GPVTG.SPEED_KMH.position];
     335                                        if(!accu.equals("")) {
     336                                                double speed = Double.parseDouble(accu);
     337                                                speed /= 3.6; // speed in m/s
     338                                                currentwp.attr.put("speed", Double.toString(speed));
     339                                        }
     340                                }
     341                        } else if(e[0].equals("$GPGSA")) {
     342                                // vdop
     343                                accu=e[GPGSA.VDOP.position];
     344                                if(!accu.equals("")) {
     345                                        Double.parseDouble(accu);
     346                                        currentwp.attr.put("vdop", accu);
     347                                }
     348                                // hdop
     349                                accu=e[GPGSA.HDOP.position];
     350                                if(!accu.equals("")) {
     351                                        Double.parseDouble(accu);
     352                                        currentwp.attr.put("hdop", accu);
     353                                }
     354                                // pdop
     355                                accu=e[GPGSA.PDOP.position];
     356                                if(!accu.equals("")) {
     357                                        Double.parseDouble(accu);
     358                                        currentwp.attr.put("pdop", accu);
     359                                }
     360                        }
     361                        else if(e[0].equals("$GPRMC")) {
     362                                // coordinates
     363                                LatLon latLon = parseLatLon(
     364                                                e[GPRMC.WIDTH_NORTH_NAME.position],
     365                                                e[GPRMC.LENGTH_EAST_NAME.position],
     366                                                e[GPRMC.WIDTH_NORTH.position],
     367                                                e[GPRMC.LENGTH_EAST.position]
     368                                        );
     369                                if(latLon==null) throw(null);
     370                                if((latLon.lat()==0.0) && (latLon.lon()==0.0)) {
     371                                        ps.zero_coord++;
     372                                        return false;
     373                                }
     374                                // time
     375                                currentDate = e[GPRMC.DATE.position];
     376                                String time = e[GPRMC.TIME.position];
     377
     378                                Date d = RMCTIMEFMT.parse(currentDate+time, new ParsePosition(0));
     379                                if (d == null) throw(null);
     380
     381                                if((ps.p_Time==null) || (currentwp==null) || !ps.p_Time.equals(time)) {
     382                                        // this node is newer than the previous, create a new waypoint.
     383                                        ps.p_Time=time;
     384                                        currentwp = new WayPoint(latLon);
     385                                }
     386                                // time: this sentence has complete time so always use it.
     387                                String gpxdate = WayPoint.GPXTIMEFMT.format(d);
     388                                currentwp.attr.put("time", gpxdate);
     389                                // speed
     390                                accu = e[GPRMC.SPEED.position];
     391                                if(!accu.equals("") && !currentwp.attr.containsKey("speed")) {
     392                                        double speed = Double.parseDouble(accu);
     393                                        speed *= 0.514444444; // to m/s
     394                                        currentwp.attr.put("speed", Double.toString(speed));
     395                                }
     396                                // course
     397                                accu = e[GPRMC.COURSE.position];
     398                                if(!accu.equals("") && !currentwp.attr.containsKey("course")) {
     399                                        Double.parseDouble(accu);
     400                                        currentwp.attr.put("course", accu);
     401                                }
     402
     403                                // TODO fix?
     404                                // * Mode (A = autonom; D = differential; E = estimated; N = not valid; S
     405                                // * = simulated)
     406                                // *
     407                                // * @since NMEA 2.3
     408                                //
     409                                //MODE(12);
     410                        } else {
     411                                ps.unknown++;
     412                                return false;
     413                        }
     414                        ps.p_Date = currentDate;
     415                        if(ps.p_Wp != currentwp) {
     416                                if(ps.p_Wp!=null) {
     417                                        ps.p_Wp.setTime();
     418                                }
     419                                ps.p_Wp = currentwp;
     420                                ps.waypoints.add(currentwp);
     421                                ps.success++;
     422                                return true;
     423                        }
     424                        return true;
     425
     426                } catch(Exception x) {
     427                        // out of bounds and such
     428                        //System.out.println("Malformed line: "+s.toString().trim());
     429                        ps.malformed++;
     430                        ps.p_Wp=null;
     431                        return false;
     432                }
     433        }
     434
     435        private LatLon parseLatLon(String ew, String ns, String dlat, String dlon)
     436                throws NumberFormatException {
     437                String widthNorth = dlat.trim();
     438                String lengthEast = dlon.trim();
     439
     440                // return a zero latlon instead of null so it is logged as zero coordinate
     441                // instead of malformed sentence
     442                if(widthNorth.equals("")&&lengthEast.equals("")) return new LatLon(0.0,0.0);
    173443
    174444                // The format is xxDDLL.LLLL
     
    177447                // LL.LLLL (double) latidude
    178448                int latdegsep = widthNorth.indexOf('.') - 2;
    179                 if (latdegsep < 0) {
    180                         return null;
    181                 }
     449                if (latdegsep < 0) return null;
     450
    182451                int latdeg = Integer.parseInt(widthNorth.substring(0, latdegsep));
    183452                double latmin = Double.parseDouble(widthNorth.substring(latdegsep));
    184453                double lat = latdeg + latmin / 60;
    185                 if ("S".equals(e[GPRMC.WIDTH_NORTH_NAME.position])) {
     454                if ("S".equals(ns)) {
     455                        if(!ew.equals("N")) return null;
    186456                        lat = -lat;
    187                 }       
     457                }
    188458
    189459                int londegsep = lengthEast.indexOf('.') - 2;
    190                 if (londegsep < 0) {
    191                         return null;
    192                 }
     460                if (londegsep < 0) return null;
     461
    193462                int londeg = Integer.parseInt(lengthEast.substring(0, londegsep));
    194463                double lonmin = Double.parseDouble(lengthEast.substring(londegsep));
    195464                double lon = londeg + lonmin / 60;
    196                 if ("W".equals(e[GPRMC.LENGTH_EAST_NAME.position])) {
     465                if ("W".equals(ew)) {
     466                        if(!ew.equals("E")) return null;
    197467                        lon = -lon;
    198468                }
    199                
    200469                return new LatLon(lat, lon);
    201470        }
Note: See TracChangeset for help on using the changeset viewer.