package de.saring.exerciseviewer.parser.impl; import de.saring.exerciseviewer.core.EVException; import de.saring.exerciseviewer.data.*; import de.saring.exerciseviewer.parser.AbstractExerciseParser; import de.saring.exerciseviewer.parser.ExerciseParserInfo; import de.saring.util.unitcalc.ConvertUtils; import java.time.LocalDateTime; /** * This implementation of an ExerciseParser is for reading RAW files of the * Polar S-Series devices. The exercises of these devices are very similar, * better models are containing more informations, which can be found on * different indices, depending on the model. * <br/> * Currently the following devices are supported: S610(i), S710(i), S720i * and S725. Users of Polar S725 should read the usage informations in * README.txt. * <br/> * Usually the exercise files have the extension ".srd". They can be read * from the Polar device with the tool "s710" (available for free at * http://daveb.net/s710), which works with Linux and similar systems. * For the format description of the RAW files see the README.file_format * of the "s710" utility (or in documentation directory). * * @author Stefan Saring * @version 1.0 */ public class PolarSRawParser extends AbstractExerciseParser { /** * Informations about this parser. */ private final ExerciseParserInfo info = new ExerciseParserInfo("Polar SRD", new String[]{"srd", "SRD"}); /** * The binary data of the exercise file. */ private int[] fileContent; @Override public ExerciseParserInfo getInfo() { return info; } @Override public EVExercise parseExercise(String filename) throws EVException { // read binary file content to array fileContent = readFileToByteArray(filename); // is the SRD file coming from an Polar S610 ? // => then we need on many places special handling, because the data is located on other places boolean fS610 = (fileContent[34] == 0) && (fileContent[36] == 251); // create an PVExercise object from this data and set file type EVExercise exercise = new EVExercise(); if (!fS610) { exercise.setFileType(EVExercise.ExerciseFileType.S710RAW); } else { exercise.setFileType(EVExercise.ExerciseFileType.S610RAW); } exercise.setDeviceName("Polar S6xx/S7xx Series"); // get bytes in file int bytesInFile = (fileContent[1] * 0x100) + fileContent[0]; if (bytesInFile != fileContent.length) { throw new EVException("The exercise file is not valid, the file length is not correct ..."); } // get exercise type (label) StringBuilder sbExerciseLabel = new StringBuilder(); for (int i = 0; i < 7; i++) { sbExerciseLabel.append(decodeChar(fileContent[i + 3])); } exercise.setType(sbExerciseLabel.toString()); // get exercise date int dateSeconds = decodeBCD(fileContent[10]); int dateMinutes = decodeBCD(fileContent[11]); int dateHours = decodeBCD(fileContent[12] & 0x7f); int dateDay = decodeBCD(fileContent[13] & 0x7f); int dateYear = 2000 + decodeBCD(fileContent[14]); int dateMonth = fileContent[15] & 0x0f; // when bit 7 of byte 12 is set, then the hour is in PM mode if (dateHours < 12 && (fileContent[12] & 0x80) == 0x80) { dateHours += 12; } exercise.setDateTime(LocalDateTime.of(dateYear, dateMonth, dateDay, dateHours, dateMinutes, dateSeconds)); // get duration int durationTenthSeconds = (fileContent[15] >> 4); int durationSeconds = decodeBCD(fileContent[16]); int durationMinutes = decodeBCD(fileContent[17]); int durationHours = decodeBCD(fileContent[18]); exercise.setDuration((durationHours * 60 * 60 * 10) + (durationMinutes * 60 * 10) + durationSeconds * 10 + durationTenthSeconds); // get heartrate data exercise.setHeartRateAVG((short) fileContent[19]); exercise.setHeartRateMax((short) fileContent[20]); // get number of laps int numberOfLaps = decodeBCD(fileContent[21]); // get unit format from bit 1 of byte 25 // => 0 = metric, 1 = english boolean fMetricUnits = (fileContent[25] & 0x02) == 0x00; // decode recording mode (heartrate is always recorded) // (not available on S610 files) RecordingMode recMode = new RecordingMode(); exercise.setRecordingMode(recMode); if (!fS610) { boolean fBike2 = (fileContent[26] & 0x20) == 0x20; boolean fBike1 = (fileContent[26] & 0x10) == 0x10; recMode.setPower((fileContent[26] & 0x08) == 0x08); recMode.setCadence((fileContent[26] & 0x04) == 0x04); recMode.setAltitude((fileContent[26] & 0x02) == 0x02); if (!fBike1 && !fBike2) { recMode.setSpeed(false); recMode.setBikeNumber((byte) 0); } else { recMode.setSpeed(true); if (fBike1) { recMode.setBikeNumber((byte) 1); } else { recMode.setBikeNumber((byte) 2); } } } // get recording interval short recInterval = 0; int indexRecInt = getProperIndex(27, 26, fS610); switch (fileContent[indexRecInt] & 15) { case 0: recInterval = 5; break; case 1: recInterval = 15; break; case 2: recInterval = 60; break; default: throw new EVException("Recording interval '" + fileContent[indexRecInt] + "' not valid ..."); } exercise.setRecordingInterval(recInterval); // does the heartrate ranges are specified by absolute or percentual values? // the bit 4 of byte 27 (or 26 for S610) is 0 for absolute and 1 for percentual ranges boolean fHeartRateRangeAbsolute = (fileContent[indexRecInt] & 0x10) == 0; // get the number of samples recorded. floor of (seconds / hertz). // Watch seems to always record a last entry, even partial seconds: +1; int numberOfSamples = ((exercise.getDuration() / 10) / recInterval) + 1; // get the heartrate limit data (Polar S710 has 3 limits) int indexHRLimitStart = getProperIndex(29, 28, fS610); exercise.setHeartRateLimits(new HeartRateLimit[3]); exercise.getHeartRateLimits()[0] = decodeHeartRateLimit(indexHRLimitStart + 0, indexHRLimitStart + 9); exercise.getHeartRateLimits()[1] = decodeHeartRateLimit(indexHRLimitStart + 2, indexHRLimitStart + 18); exercise.getHeartRateLimits()[2] = decodeHeartRateLimit(indexHRLimitStart + 4, indexHRLimitStart + 27); for (HeartRateLimit hrLimit : exercise.getHeartRateLimits()) { hrLimit.setAbsoluteRange(fHeartRateRangeAbsolute); } // get energy (in kCal) int indexEnergyStart = getProperIndex(70, 69, fS610); int energyPart1 = decodeBCD(fileContent[indexEnergyStart + 0]); int energyPart2 = decodeBCD(fileContent[indexEnergyStart + 1]); int energyPart3 = decodeBCD(fileContent[indexEnergyStart + 2]); exercise.setEnergy((energyPart1 + (energyPart2 * 100) + (energyPart3 * 10000)) / 10); // get total energy (in kCal) int indexTotalEnergyStart = getProperIndex(73, 72, fS610); int energyTotalPart1 = decodeBCD(fileContent[indexTotalEnergyStart + 0]); int energyTotalPart2 = decodeBCD(fileContent[indexTotalEnergyStart + 1]); int energyTotalPart3 = decodeBCD(fileContent[indexTotalEnergyStart + 2]); exercise.setEnergyTotal(energyTotalPart1 + (energyTotalPart2 * 100) + (energyTotalPart3 * 10000)); // get cumulative workout time int indexCumWorkoutStart = getProperIndex(76, 75, fS610); int cumWorkoutPart1 = decodeBCD(fileContent[indexCumWorkoutStart + 0]); int cumWorkoutPart2 = decodeBCD(fileContent[indexCumWorkoutStart + 1]); int cumWorkoutPart3 = decodeBCD(fileContent[indexCumWorkoutStart + 2]); exercise.setSumExerciseTime(cumWorkoutPart3 + (cumWorkoutPart1 * 60) + (cumWorkoutPart2 * 60 * 100)); if (!fS610) { // get cumulative ride time int cumRidePart1 = decodeBCD(fileContent[79]); int cumRidePart2 = decodeBCD(fileContent[80]); int cumRidePart3 = decodeBCD(fileContent[81]); exercise.setSumRideTime(cumRidePart3 + (cumRidePart1 * 60) + (cumRidePart2 * 60 * 100)); // get odometer int odometerPart1 = decodeBCD(fileContent[82]); int odometerPart2 = decodeBCD(fileContent[83]); int odometerPart3 = decodeBCD(fileContent[84]); int odometer = odometerPart1 + (odometerPart2 * 100) + (odometerPart3 * 10000); if (fMetricUnits) { exercise.setOdometer(odometer); } else { exercise.setOdometer(ConvertUtils.convertMiles2Kilometer(odometer)); } } // get speed (bicycle) related data of exercise (if recorded) if (recMode.isSpeed()) { ExerciseSpeed speed = new ExerciseSpeed(); exercise.setSpeed(speed); // get exercise distance (in 1/10th of km) int distance = (fileContent[85] + (fileContent[86] << 8)) * 100; if (fMetricUnits) { speed.setDistance(distance); } else { speed.setDistance(ConvertUtils.convertMiles2Kilometer(distance)); } // get AVG speed int avgSpeedPart1 = fileContent[87]; int avgSpeedPart2 = (fileContent[88] & 0x0f); float avgSpeed = ((avgSpeedPart2 << 8) | avgSpeedPart1) / 16f; if (fMetricUnits) { speed.setSpeedAVG(avgSpeed); } else { speed.setSpeedAVG((float) ConvertUtils.convertMiles2Kilometer(avgSpeed)); } // get max speed int maxSpeedPart1 = fileContent[88] >> 4; int maxSpeedPart2 = fileContent[89]; float maxSpeed = ((maxSpeedPart2 << 4) | maxSpeedPart1) / 16f; if (fMetricUnits) { speed.setSpeedMax(maxSpeed); } else { speed.setSpeedMax((float) ConvertUtils.convertMiles2Kilometer(maxSpeed)); } } // get cadence (bicycle) data of exercise (if recorded) if (recMode.isCadence()) { ExerciseCadence cadence = new ExerciseCadence(); exercise.setCadence(cadence); cadence.setCadenceAVG((short) fileContent[90]); cadence.setCadenceMax((short) fileContent[91]); } // get altitude data of exercise (if recorded) if (recMode.isAltitude()) { ExerciseAltitude altitude = new ExerciseAltitude(); exercise.setAltitude(altitude); altitude.setAltitudeMin(decodeAltitude(fileContent[92], fileContent[93])); altitude.setAltitudeAVG(decodeAltitude(fileContent[94], fileContent[95])); altitude.setAltitudeMax(decodeAltitude(fileContent[96], fileContent[97])); altitude.setAscent(fileContent[101] + (fileContent[102] << 8)); if (!fMetricUnits) { altitude.setAltitudeMin((short) ConvertUtils.convertFeet2Meter(altitude.getAltitudeMin())); altitude.setAltitudeAVG((short) ConvertUtils.convertFeet2Meter(altitude.getAltitudeAVG())); altitude.setAltitudeMax((short) ConvertUtils.convertFeet2Meter(altitude.getAltitudeMax())); altitude.setAscent(ConvertUtils.convertFeet2Meter(altitude.getAscent())); } // get temperature data of exercise (only available, when altitude recorded) ExerciseTemperature temperature = new ExerciseTemperature(); exercise.setTemperature(temperature); temperature.setTemperatureMin(decodeTemperature(fileContent[98], fMetricUnits)); temperature.setTemperatureAVG(decodeTemperature(fileContent[99], fMetricUnits)); temperature.setTemperatureMax(decodeTemperature(fileContent[100], fMetricUnits)); } ////////// decode lap data ///////////// // calculate lap length (bytes) => depends on what is recorded int lapSize = 6; if (recMode.isAltitude()) lapSize += 5; if (recMode.isSpeed()) { if (recMode.isCadence()) lapSize += 1; if (recMode.isPower()) lapSize += 4; lapSize += 4; } // calculate sample length (bytes) => depends on what is recorded // We do this before we calculate indexLapsStart, because // we need it. int sampleSize = 1; if (recMode.isAltitude()) sampleSize += 2; if (recMode.isSpeed()) { if (recMode.isAltitude()) sampleSize -= 1; if (recMode.isCadence()) sampleSize += 1; if (recMode.isPower()) sampleSize += 4; sampleSize += 2; } // We need to calculate where the lap data starts. // That way, we avoid guessing different Polar models. int totalLapSize = numberOfLaps * lapSize; int totalSampleSize = numberOfSamples * sampleSize; int indexLapsStart = bytesInFile - totalLapSize - totalSampleSize; // process all laps exercise.setLapList(new Lap[numberOfLaps]); for (int i = 0; i < numberOfLaps; i++) { // get offset where the current lap starts int lapOffset = indexLapsStart + i * lapSize; Lap lap = new Lap(); exercise.getLapList()[i] = lap; // get lap split time (in 1/10th seconds) int bLapEndHour = fileContent[lapOffset + 2]; int bLapEndMinute = fileContent[lapOffset + 1] & 0x3f; int bLapEndSecond = fileContent[lapOffset] & 0x3f; int bLapEndTenthSecond = ((fileContent[lapOffset + 1] & 0xc0) >> 4) | ((fileContent[lapOffset] & 0xc0) >> 6); lap.setTimeSplit(bLapEndTenthSecond + (bLapEndSecond * 10) + (bLapEndMinute * 60 * 10) + (bLapEndHour * 60 * 60 * 10)); // get heartrate data of lap lap.setHeartRateSplit((short) (fileContent[lapOffset + 3])); lap.setHeartRateAVG((short) (fileContent[lapOffset + 4])); lap.setHeartRateMax((short) (fileContent[lapOffset + 5])); lapOffset += 6; // get altitude related data of lap (if recorded) if (recMode.isAltitude()) { lap.setAltitude(new LapAltitude()); lap.setTemperature(new LapTemperature()); // get altitude at end of the lap (has on offset of 512) short lapEndAltitude = (short) (fileContent[lapOffset] + (fileContent[lapOffset + 1] << 8) - 512); if (fMetricUnits) { // metric units: meters without modification lap.getAltitude().setAltitude(lapEndAltitude); } else { // english units: multiples of 5 feets lap.getAltitude().setAltitude((short) ConvertUtils.convertFeet2Meter(lapEndAltitude * 5)); } // get ascent of the lap int lapAscent = (fileContent[lapOffset + 2] + (fileContent[lapOffset + 3] << 8)); if (fMetricUnits) { lap.getAltitude().setAscent(lapAscent); } else { lap.getAltitude().setAscent(ConvertUtils.convertFeet2Meter(lapAscent)); } // get temperature at end of the lap if (fMetricUnits) { // metric units: offset from -10 C lap.getTemperature().setTemperature((short) (fileContent[lapOffset + 4] - 10)); } else { // english units: offset from 14 F lap.getTemperature().setTemperature(ConvertUtils.convertFahrenheit2Celsius((short) (fileContent[lapOffset + 4] + 14))); } lapOffset += 5; } // get speed (bicycle) related data of lap (if recorded) if (recMode.isSpeed()) { lap.setSpeed(new LapSpeed()); // get cadence at end of the lap (if recorded) if (recMode.isCadence()) { lap.getSpeed().setCadence((short) fileContent[lapOffset]); lapOffset += 1; } // ignore power data, but increase offset if recorded if (recMode.isPower()) { lapOffset += 4; } // get lap distance (in 1/10th of km) int lapDistance = (fileContent[lapOffset] + (fileContent[lapOffset + 1] << 8)) * 100; if (fMetricUnits) { lap.getSpeed().setDistance(lapDistance); } else { lap.getSpeed().setDistance(ConvertUtils.convertMiles2Kilometer(lapDistance)); } // get lap speed float lapEndSpeed = ((float) (fileContent[lapOffset + 2] + ((fileContent[lapOffset + 3] & 0xf0) << 4)) / 16); if (fMetricUnits) { lap.getSpeed().setSpeedEnd(lapEndSpeed); } else { lap.getSpeed().setSpeedEnd((float) ConvertUtils.convertMiles2Kilometer(lapEndSpeed)); } lapOffset += 4; } } ////////// decode sample data ///////////// // get offset of first sample (the first sample is most recent - reverse order) int sampleOffset = indexLapsStart + (numberOfLaps * lapSize); // create sample list exercise.setSampleList(new ExerciseSample[numberOfSamples]); // process all recorded samples for (int i = 0; i < numberOfSamples; i++) { // store sample in list in reverse order int sampleIndex = numberOfSamples - i - 1; ExerciseSample exeSample = new ExerciseSample(); exeSample.setTimestamp(sampleIndex * exercise.getRecordingInterval() * 1000L); exercise.getSampleList()[sampleIndex] = exeSample; // get sample heartrate exeSample.setHeartRate((short) fileContent[sampleOffset]); sampleOffset++; // get sample altitude (if recorded) - (has on offset of 512) if (recMode.isAltitude()) { short sampleAltitude = (short) (fileContent[sampleOffset] + ((fileContent[sampleOffset + 1] & 0x1f) << 8) - 512); if (fMetricUnits) { // metric units: meters without modification exeSample.setAltitude(sampleAltitude); } else { // english units: multiples of 5 feets exeSample.setAltitude((short) ConvertUtils.convertFeet2Meter(sampleAltitude * 5)); } sampleOffset += 2; } // get bicycle related data (if recorded) if (recMode.isSpeed()) { // when altitude is always recorded, use last byte again if (recMode.isAltitude()) { sampleOffset -= 1; } // get sample speed float sampleSpeed = (float) (((fileContent[sampleOffset] & 0xe0) << 3) + fileContent[sampleOffset + 1]) / 16f; if (fMetricUnits) { exeSample.setSpeed(sampleSpeed); } else { exeSample.setSpeed((float) ConvertUtils.convertMiles2Kilometer(sampleSpeed)); } sampleOffset += 2; // ignore sample power, but add offset (if recorded) if (recMode.isPower()) { sampleOffset += 4; } // get sample cadence (if recorded) if (recMode.isCadence()) { exeSample.setCadence((short) fileContent[sampleOffset]); sampleOffset++; } } } // when speed is recorded => calculate distance for each recorded sample // (distance is not recorded for each sample) if (recMode.isSpeed()) { // helper for compute sample distance (meters) double distanceAccum = 0f; // process all recorded samples for (ExerciseSample exeSample : exercise.getSampleList()) { // compute sample distance (it's not recorded) exeSample.setDistance((int) distanceAccum); distanceAccum += (exeSample.getSpeed() * recInterval) / 3.6f; } } // repair distance values of samples exercise.repairSamples(); // calculate average lap speed, the data was not recorded here calculateAverageLapSpeed(exercise); // done :-) return exercise; } /** * Decodes a character in Polar format to normal character value. * * @param value value to decode * @return normal character value of Polar character value */ private char decodeChar(int value) { char cDecoded = '?'; switch (value) { case 0: case 1: case 2: case 3: case 4: case 5: case 6: case 7: case 8: case 9: cDecoded = (char) ('0' + value); break; case 10: cDecoded = ' '; break; case 11: case 12: case 13: case 14: case 15: case 16: case 17: case 18: case 19: case 20: case 21: case 22: case 23: case 24: case 25: case 26: case 27: case 28: case 29: case 30: case 31: case 32: case 33: case 34: case 35: case 36: cDecoded = (char) ('A' + value - 11); break; case 37: case 38: case 39: case 40: case 41: case 42: case 43: case 44: case 45: case 46: case 47: case 48: case 49: case 50: case 51: case 52: case 53: case 54: case 55: case 56: case 57: case 58: case 59: case 60: case 61: case 62: cDecoded = (char) ('a' + value - 37); break; case 63: cDecoded = '-'; break; case 64: cDecoded = '%'; break; case 65: cDecoded = '/'; break; case 66: cDecoded = '('; break; case 67: cDecoded = ')'; break; case 68: cDecoded = '*'; break; case 69: cDecoded = '+'; break; case 70: cDecoded = '.'; break; case 71: cDecoded = ':'; break; case 72: cDecoded = '?'; break; default: break; } return cDecoded; } /** * Decodes a BCD "byte" (in Polar format) to normal int value. * * @param value BCD "byte" to decode * @return normal int value of BCD value */ private int decodeBCD(int value) { // (upper 4 bits * 10) + lower 4 bits return ((value >> 4) * 10) + (value & 0x0f); } /** * This helper method is only for easy switching between the S710 and S610 * exercise indexes while parsing. * * @param indexS710 the index in S710 exercise files * @param indexS610 the index in S610 exercise files * @param fS610 true, when the exercise is from S610, false when from S710 * @return the index depending on the device */ private int getProperIndex(int indexS710, int indexS610, boolean fS610) { if (fS610) { return indexS610; } else { return indexS710; } } /** * This method decodes the data for a HeartRateLimit object (limits and times * below, within and above) and returns it. * * @param offsetLimits offset in fileContent, where the limit data starts * @param offsetTimes offset in fileContent, where the times data starts * @return the filled HeartRateLimit object */ private HeartRateLimit decodeHeartRateLimit(int offsetLimits, int offsetTimes) { HeartRateLimit hrLimit = new HeartRateLimit(); hrLimit.setLowerHeartRate((short) fileContent[offsetLimits + 0]); hrLimit.setUpperHeartRate((short) fileContent[offsetLimits + 1]); int hrLimitBelowSecs = decodeBCD(fileContent[offsetTimes + 0]); hrLimitBelowSecs += decodeBCD(fileContent[offsetTimes + 1]) * 60; hrLimitBelowSecs += decodeBCD(fileContent[offsetTimes + 2]) * 60 * 60; hrLimit.setTimeBelow(hrLimitBelowSecs); int hrLimitWithinSecs = decodeBCD(fileContent[offsetTimes + 3]); hrLimitWithinSecs += decodeBCD(fileContent[offsetTimes + 4]) * 60; hrLimitWithinSecs += decodeBCD(fileContent[offsetTimes + 5]) * 60 * 60; hrLimit.setTimeWithin(hrLimitWithinSecs); int hrLimitAboveSecs = decodeBCD(fileContent[offsetTimes + 6]); hrLimitAboveSecs += decodeBCD(fileContent[offsetTimes + 7]) * 60; hrLimitAboveSecs += decodeBCD(fileContent[offsetTimes + 8]) * 60 * 60; hrLimit.setTimeAbove(hrLimitAboveSecs); return hrLimit; } /** * This method decodes an altitude information from the specified high and * low "byte" values. * * @param lsb lower "byte" value * @param msb higher "byte" value * @return the decoded altitude value */ private short decodeAltitude(int lsb, int msb) { short altitude = (short) (lsb + ((msb & 0x7f) << 8)); if ((msb & 0x80) != 0x80) { altitude *= -1; } return altitude; } /** * This method decodes a temperature information from the specified "byte". * The result is in celsius or fahreinheit depending on the specified unit flag. * * @param temperature the encoded temperature "byte" * @param fMetricUnits true, when exercise was recorded in metric untis (false = english units) * @return the decoded temperature value */ private short decodeTemperature(int temperature, boolean fMetricUnits) { if (fMetricUnits) { // metric units: -79..+79, 8th bit 1/0 = +/- short tempValue = (byte) (temperature & 0x7f); if ((temperature & 0x80) != 0x80) { tempValue *= -1; } return tempValue; } else { // english units: temperature in degrees F, binary return ConvertUtils.convertFahrenheit2Celsius((short) temperature); } } }