/** * This program is free software: you can redistribute it and/or modify it under * the terms of the GNU General Public License as published by the * Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program 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 General Public License for more details. * * You should have received a copy of the GNU General Public License along with this program. * If not, see <http://www.gnu.org/licenses/>. */ package org.openstreetmap.josm.plugins.columbusCSV; import static org.openstreetmap.josm.tools.I18n.tr; import java.io.BufferedReader; import java.io.DataInputStream; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStreamReader; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.StringTokenizer; import java.util.zip.DataFormatException; import org.openstreetmap.josm.Main; import org.openstreetmap.josm.data.coor.LatLon; import org.openstreetmap.josm.data.gpx.GpxData; import org.openstreetmap.josm.data.gpx.GpxLink; import org.openstreetmap.josm.data.gpx.GpxTrack; import org.openstreetmap.josm.data.gpx.ImmutableGpxTrack; import org.openstreetmap.josm.data.gpx.WayPoint; import org.openstreetmap.josm.io.IllegalDataException; /** * This class reads a native CSV of the Columbus V-900 data logger and converts * them into a GPX data layer. This class supports as well the simple as the * extended mode. By default, the V-900 runs in simple mode, which contains the * most important data like * <ol> * <li>Position (Longitude and Latitude) * <li>Date & Time * <li>Speed * <li>Heading * </ol> * Audio recordings are way points which references the audio file (.wav file). * Make sure that all audio files are in the same folder as the CSV file. * * The extended mode contains additional data regarding GPS data quality like * all DOP and GPS mode. * * To activate the extended mode, just put a file named <tt>config.txt</tt> on * the microSD card which has been shipped with the device. * * Then change the content of the <tt>config.txt</tt> to <code> * 1,000,001, notes: 1 Professional mode 000 Over-speed tag 001 Spy mode timer * </code> * * @author Oliver Wieland <oliver.wieland@online.de> * */ public class ColumbusCSVReader { public static final String AUDIO_WAV_LINK = "audio/wav"; /* GPX tags not provided by the GPXReader class */ private static final String VDOP_TAG = "vdop"; private static final String HDOP_TAG = "hdop"; private static final String PDOP_TAG = "pdop"; private static final String ELEVATIONHEIGHT_TAG = "ele"; private static final String TIME_TAG = "time"; private static final String COMMENT_TAG = "cmt"; private static final String DESC_TAG = "desc"; private static final String FIX_TAG = "fix"; private static final String TYPE_TAG = "columbus:type"; private static String[] EMPTY_LINE = new String[] {}; private static final String SEPS = ","; /* Lines to read before deciding on Columbus file yes/no */ private static final int MAX_SCAN_LINES = 20; private static final int MIN_SCAN_LINES = 10; private int dopConversionErrors = 0; private int dateConversionErrors = 0; private int firstVoxNumber = -1, lastVoxNumber = -1; private final Map<String, WayPoint> voxFiles = new HashMap<>(); private final Collection<Collection<WayPoint>> allTrackPts = new ArrayList<>(); private final List<WayPoint> trackPts = new ArrayList<>(); private final List<WayPoint> allWpts = new ArrayList<>(); private String fileDir; /** * Transforms a Columbus V-900 CSV file into a JOSM GPX layer. * * @param fileName The Columbus file to import. * @return GPX representation of Columbus track file. * @throws IOException * @throws DataFormatException */ public GpxData transformColumbusCSV(String fileName) throws IOException, IllegalDataException { if (fileName == null || fileName.length() == 0) { throw new IllegalArgumentException( "File name must not be null or empty"); } // GPX data structures GpxData gpxData = new GpxData(); File f = new File(fileName); fileDir = f.getParent(); FileInputStream fstream = new FileInputStream(fileName); // Get the object of DataInputStream DataInputStream in = new DataInputStream(fstream); BufferedReader br = new BufferedReader(new InputStreamReader(in)); String strLine; // Initial values int line = 1; initImport(); dropBufferLists(); int waypts = 0, trkpts = 0, audiopts = 0, missaudio = 0, rescaudio = 0; try { // Read File Line By Line while ((strLine = br.readLine()) != null) { String[] csvFields = getCSVLine(strLine); // Get the columns of // the current line if (csvFields.length == 0 || line <= 1) { // Skip, if line is // header or contains // no data ++line; continue; } try { WayPoint wpt = createWayPoint(csvFields, fileDir); String wptType = (String) wpt.attr.get(TYPE_TAG); String oldWptType = csvFields[1]; if ("T".equals(wptType)) { // point of track (T) trackPts.add(wpt); trkpts++; } else { // way point (C) / have voice file: V) if (!wptType.equals(oldWptType)) { // type changed? if ("V".equals(oldWptType)) { // missing audiofile missaudio++; } if ("C".equals(oldWptType)) { // rescued audiofile rescaudio++; } } else { if ("V".equals(wptType)) { // wpt with vox audiopts++; } } gpxData.waypoints.add(wpt); // add the waypoint to the track waypts++; } allWpts.add(wpt); wpt.attr.remove(TYPE_TAG); } catch (Exception ex) { br.close(); throw new IllegalDataException(tr("Error in line " + line + ": " + ex.toString()), ex); } ++line; } } finally { // Close the input stream br.close(); } // do some sanity checks assert trackPts.size() == trkpts; assert gpxData.waypoints.size() == waypts; assert firstVoxNumber <= lastVoxNumber; rescaudio += searchForLostAudioFiles(gpxData); // compose the track allTrackPts.add(trackPts); GpxTrack trk = new ImmutableGpxTrack(allTrackPts, Collections.<String, Object> emptyMap()); gpxData.tracks.add(trk); assert gpxData.routes.size() == 1; // Issue conversion warning, if needed if (ColumbusCSVPreferences.warnConversion() && (dateConversionErrors > 0 || dopConversionErrors > 0)) { String message = String.format( "%d date conversion faults and %d DOP conversion errors", dateConversionErrors, dopConversionErrors); ColumbusCSVUtils.showWarningMessage(tr(message)); } // Show summary if (ColumbusCSVPreferences.showSummary()) { showSummary(waypts, trkpts, audiopts, missaudio, rescaudio); } String desc = String.format( "Converted by ColumbusCSV plugin from track file '%s'", f.getName()); gpxData.attr.put(GpxData.META_DESC, desc); gpxData.storageFile = f; return gpxData; } /** * Checks a (CSV) file for Columbus tags. This method is a simplified copy * of the @link transformColumbusCSV and just checks a small amount of * lines. * * @param file The file to check. * @return true, if given file is a Columbus file; otherwise false. */ public static boolean isColumbusFile(File file) throws IOException { if (file == null) return false; FileInputStream fstream = new FileInputStream(file); // Get the object of DataInputStream DataInputStream in = new DataInputStream(fstream); BufferedReader br = new BufferedReader(new InputStreamReader(in)); String strLine; // Initial values int line = 0; int columbusLines = 0; try { // Read File Line By Line until we either exceed the maximum scan // lines or we are sure that we have a columbus file while ((strLine = br.readLine()) != null && (line < MAX_SCAN_LINES || columbusLines > MIN_SCAN_LINES)) { String[] csvFields = getCSVLine(strLine); // Get the columns of // the current line ++line; if (csvFields.length == 0 || line <= 1) { // Skip, if line is // header or contains // no data continue; } String wptType = csvFields[1]; // Check for columbus tag if ("T".equals(wptType) || "V".equals(wptType) || "C".equals(wptType)) { // ok, we found one line but still not convinced ;-) columbusLines++; } } } finally { // Close the input stream br.close(); } return columbusLines > MIN_SCAN_LINES; } /** * Searches for unlinked audio files and tries to link them with the closest * way point. This requires that the file date of the wav files is kept - * there is no other way to assign the audio files to way points. * * @param gpx * @return */ private int searchForLostAudioFiles(GpxData gpx) { Map<String, WayPoint> voxFiles = getVoxFileMap(); int first, last; first = getFirstVoxNumber(); last = getLastVoxNumber(); int rescuedFiles = 0; for (int i = first; i < last; i++) { String voxFile = String.format("vox%05d.wav", i); String nextVoxFile = String.format("vox%05d.wav", i + 1); if (!voxFiles.containsKey(voxFile)) { Main.info("Found lost vox file " + voxFile); File f = getVoxFilePath(voxFile); WayPoint nearestWpt = null; List<WayPoint> wpts = getAllWayPoints(); // Attach recording to the way point right before the next vox // file if (voxFiles.containsKey(nextVoxFile)) { WayPoint nextWpt = voxFiles.get(nextVoxFile); int idx = getAllWayPoints().indexOf(nextWpt) - 5; if (idx >= 0) { nearestWpt = wpts.get(idx); } else { nearestWpt = wpts.get(0); } } else { // attach to last way point nearestWpt = wpts.get(wpts.size() - 1); } // Add link to found way point if (nearestWpt != null) { if (addLinkToWayPoint(nearestWpt, "*" + voxFile + "*", f)) { Main.info(String.format( "Linked file %s to position %s", voxFile, nearestWpt.getCoor().toDisplayString())); // Add linked way point to way point list of GPX; otherwise it would not be shown correctly gpx.waypoints.add(nearestWpt); rescuedFiles++; } else { Main.error(String.format("Could not link vox file %s due to invalid parameters.", voxFile)); } } } } return rescuedFiles; } /** * */ private void initImport() { dateConversionErrors = 0; dopConversionErrors = 0; firstVoxNumber = Integer.MAX_VALUE; lastVoxNumber = Integer.MIN_VALUE; } /** * Clears all temporary buffers. */ void dropBufferLists() { allTrackPts.clear(); trackPts.clear(); voxFiles.clear(); } /** * Shows the summary to the user. * * @param waypts * The number of imported way points * @param trkpts * The number of imported track points * @param audiopts * The number of imported way points with vox * @param missaudio * The number of missing audio files * @param rescaudio * The number of rescued audio files */ private void showSummary(int waypts, int trkpts, int audiopts, int missaudio, int rescaudio) { String message = ""; if (missaudio > 0) { message = String .format("Imported %d track points and %d way points (%d with audio, %d rescued).%n"+ "Note: %d audio files could not be found, please check marker comments!", trkpts, waypts, audiopts, rescaudio, missaudio); } else { message = String .format("Imported %d track points and %d way points (%d with audio, %d rescued).", trkpts, waypts, audiopts, rescaudio); } ColumbusCSVUtils.showInfoMessage(tr(message)); } /** * Creates a GPX way point from a tokenized CSV line. The attributes of the * way point depends on whether the Columbus logger runs in simple or * professional mode. * * @param csvLine * The columns of a single CSV line. * @return The corresponding way point instance. * @throws DataFormatException */ private WayPoint createWayPoint(String[] csvLine, String fileDir) throws IOException { // Sample line in simple mode // INDEX,TAG,DATE,TIME,LATITUDE N/S,LONGITUDE // E/W,HEIGHT,SPEED,HEADING,VOX // 1,T,090430,194134,48.856330N,009.089779E,318,20,0, // Sample line in extended mode // INDEX,TAG,DATE,TIME,LATITUDE N/S,LONGITUDE // E/W,HEIGHT,SPEED,HEADING,FIX MODE,VALID,PDOP,HDOP,VDOP,VOX // 1,T,090508,191448,48.856928N,009.091153E,330,3,0,3D,SPS ,1.4,1.2,0.8, if (csvLine.length != 10 && csvLine.length != 15) throw new IOException("Invalid number of tokens: " + csvLine.length); boolean isExtMode = csvLine.length > 10; // Extract latitude/longitude first String lat = csvLine[4]; double latVal = Double.parseDouble(lat.substring(0, lat.length() - 1)); if (lat.endsWith("S")) { latVal = -latVal; } String lon = csvLine[5]; double lonVal = Double.parseDouble(lon.substring(0, lon.length() - 1)); if (lon.endsWith("W")) { lonVal = -lonVal; } LatLon pos = new LatLon(latVal, lonVal); WayPoint wpt = new WayPoint(pos); // set wpt type wpt.attr.put(TYPE_TAG, csvLine[1]); // Check for audio file and link it, if present String voxFile = null; if (isExtMode) { voxFile = csvLine[14]; } else { voxFile = csvLine[9]; } if (!ColumbusCSVUtils.isStringNullOrEmpty(voxFile)) { voxFile = voxFile + ".wav"; File file = getVoxFilePath(fileDir, voxFile); if (file != null && file.exists()) { // link vox file int voxNum = getNumberOfVoxfile(voxFile); lastVoxNumber = Math.max(voxNum, lastVoxNumber); firstVoxNumber = Math.min(voxNum, firstVoxNumber); addLinkToWayPoint(wpt, voxFile, file); if (!"V".equals(csvLine[1])) { Main.info("Rescued unlinked audio file " + voxFile); } voxFiles.put(voxFile, wpt); // set type to way point with vox wpt.attr.put(TYPE_TAG, "V"); } else { // audio file not found -> issue warning Main.error("File " + voxFile + " not found!"); String warnMsg = tr("Missing audio file") + ": " + voxFile; Main.error(warnMsg); if (ColumbusCSVPreferences.warnMissingAudio()) { ColumbusCSVUtils.showInfoMessage(warnMsg); } wpt.attr.put(ColumbusCSVReader.COMMENT_TAG, warnMsg); // set type to ordinary way point wpt.attr.put(TYPE_TAG, "C"); } } // Extract date/time SimpleDateFormat sdf = new java.text.SimpleDateFormat("yyMMdd/HHmmss"); Date d = null; try { d = sdf.parse(csvLine[2] + "/" + csvLine[3]); // format date according to GPX SimpleDateFormat f = new SimpleDateFormat( "yyyy-MM-dd'T'HH:mm:ss'Z'"); wpt.attr.put(ColumbusCSVReader.TIME_TAG, f.format(d).toString()); wpt.setTime(); } catch (ParseException ex) { dateConversionErrors++; Main.error(ex); } // Add further attributes // Elevation height (altitude provided by GPS signal) wpt.attr.put(ColumbusCSVReader.ELEVATIONHEIGHT_TAG, csvLine[6]); // Add data of extended mode, if applicable if (isExtMode && !ColumbusCSVPreferences.ignoreDOP()) { addExtendedGPSData(csvLine, wpt); } return wpt; } /** * Gets the full path of the audio file. Same as * <code>getVoxFilePath(getWorkingDirOfImport(), voxFile)</code>. * * @param voxFile * The name of the audio file without dir and extension. * @return * */ public File getVoxFilePath(String voxFile) { return getVoxFilePath(getWorkingDirOfImport(), voxFile); } /** * Gets the full path of the audio file. * * @param fileDir * The directory containing the audio file. * @param voxFile * The name of the audio file without dir and extension. * @return */ public File getVoxFilePath(String fileDir, String voxFile) { // The FAT16 file name is interpreted differently from case-sensitive // file systems, so we have to test several variants String[] fileNameVariants = new String[] { voxFile, voxFile.toLowerCase(), voxFile.toUpperCase() }; for (int i = 0; i < fileNameVariants.length; i++) { File file = new File(fileDir + File.separator + fileNameVariants[i]); if (file.exists()) { return file; } } return null; // give up... } /** * Adds extended GPS data (*DOP and fix mode) to the way point * * @param csvLine * @param wpt */ private void addExtendedGPSData(String[] csvLine, WayPoint wpt) { // Fix mode wpt.attr.put(FIX_TAG, csvLine[9].toLowerCase()); Float f; // Position errors (dop = dilution of position) f = ColumbusCSVUtils.floatFromString(csvLine[11]); if (!Float.isNaN(f)) { wpt.attr.put(ColumbusCSVReader.PDOP_TAG, f); } else { dopConversionErrors++; } f = ColumbusCSVUtils.floatFromString(csvLine[12]); if (!Float.isNaN(f)) { wpt.attr.put(ColumbusCSVReader.HDOP_TAG, f); } else { dopConversionErrors++; } f = ColumbusCSVUtils.floatFromString(csvLine[13]); if (!Float.isNaN(f)) { wpt.attr.put(ColumbusCSVReader.VDOP_TAG, f); } else { dopConversionErrors++; } } /** * Adds a link to a way point. * * @param wpt * The way point to add the link to. * @param voxFile * @param file * @return True, if link has been added; otherwise false */ public boolean addLinkToWayPoint(WayPoint wpt, String voxFile, File file) { if (file == null || wpt == null || voxFile == null) return false; GpxLink lnk = new GpxLink(file.toURI().toString()); lnk.type = ColumbusCSVReader.AUDIO_WAV_LINK; lnk.text = voxFile; // JOSM expects a collection of links here... Collection<GpxLink> linkList = new ArrayList<>(1); linkList.add(lnk); wpt.attr.put(GpxData.META_LINKS, linkList); wpt.attr.put(ColumbusCSVReader.COMMENT_TAG, "Audio recording"); wpt.attr.put(ColumbusCSVReader.DESC_TAG, voxFile); return true; } /** * Splits a line of the CSV files into it's tokens. * * @param line * @return Array containing the tokens of the CSV file. */ private static String[] getCSVLine(String line) { if (line == null || line.length() == 0) return EMPTY_LINE; StringTokenizer st = new StringTokenizer(line, SEPS, false); int n = st.countTokens(); String[] res = new String[n]; for (int i = 0; i < n; i++) { res[i] = st.nextToken().trim(); } return res; } /** * Extracts the number from a VOX file name, e. g. for a file named * "VOX01524" this method will return 1524. * * @param fileName * The vox file name. * @return The number of the vox file or -1; if the given name was not * valid. */ private int getNumberOfVoxfile(String fileName) { if (fileName == null) return -1; try { String num = fileName.substring(3); return Integer.parseInt(num); } catch (NumberFormatException e) { return -1; } } /** * Return the number of date conversion errors. * * @return */ public int getNumberOfDateConversionErrors() { return dateConversionErrors; } /** * Return the number of pdop/vdop/hdop conversion errors. * * @return */ public int getNumberOfDOPConversionErrors() { return dopConversionErrors; } /** * Gets the number of first vox file. * * @return */ public int getFirstVoxNumber() { return firstVoxNumber; } /** * Gets the number of last vox file. * * @return */ public int getLastVoxNumber() { return lastVoxNumber; } /** * Gets the map containing the vox files with their associated way point. * * @return */ public Map<String, WayPoint> getVoxFileMap() { return voxFiles; } /** * Gets the list containing all imported track and way points. * * @return */ public List<WayPoint> getAllWayPoints() { return allWpts; } /** * Gets the import directory. * * @return */ public String getWorkingDirOfImport() { return fileDir; } }