diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/BangleJSActivityPoint.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/BangleJSActivityPoint.java new file mode 100644 index 000000000..e07382168 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/BangleJSActivityPoint.java @@ -0,0 +1,241 @@ +package nodomain.freeyourgadget.gadgetbridge.devices.banglejs; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; + +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileReader; +import java.io.IOException; +import java.util.Arrays; +import java.util.Date; +import java.util.LinkedList; +import java.util.List; + +import nodomain.freeyourgadget.gadgetbridge.model.ActivityPoint; +import nodomain.freeyourgadget.gadgetbridge.model.GPSCoordinate; +import nodomain.freeyourgadget.gadgetbridge.util.GBToStringBuilder; + +public class BangleJSActivityPoint { + private static final Logger LOG = LoggerFactory.getLogger(BangleJSActivityPoint.class); + + private final GPSCoordinate location; + private final long time; + private final int heartRate; + private final int hrConfidence; + private final String hrSource; + private final int steps; + private final int batteryPercentage; + private final double batteryVoltage; + private final boolean charging; + private final double barometerTemperature; + private final double barometerPressure; + private final double barometerAltitude; + + public BangleJSActivityPoint(final long time, + final GPSCoordinate location, + final int heartRate, + final int hrConfidence, + final String hrSource, + final int steps, + final int batteryPercentage, + final double batteryVoltage, + final boolean charging, + final double barometerTemperature, + final double barometerPressure, + final double barometerAltitude) { + this.time = time; + this.location = location; + this.heartRate = heartRate; + this.hrConfidence = hrConfidence; + this.hrSource = hrSource; + this.steps = steps; + this.batteryPercentage = batteryPercentage; + this.batteryVoltage = batteryVoltage; + this.charging = charging; + this.barometerTemperature = barometerTemperature; + this.barometerPressure = barometerPressure; + this.barometerAltitude = barometerAltitude; + } + + public long getTime() { + return time; + } + + @Nullable + public GPSCoordinate getLocation() { + return location; + } + + public int getHeartRate() { + return heartRate; + } + + public int getHrConfidence() { + return hrConfidence; + } + + public String getHrSource() { + return hrSource; + } + + public int getSteps() { + return steps; + } + + public int getBatteryPercentage() { + return batteryPercentage; + } + + public double getBatteryVoltage() { + return batteryVoltage; + } + + public boolean isCharging() { + return charging; + } + + public double getBarometerTemperature() { + return barometerTemperature; + } + + public double getBarometerPressure() { + return barometerPressure; + } + + public double getBarometerAltitude() { + return barometerAltitude; + } + + public ActivityPoint toActivityPoint() { + final ActivityPoint activityPoint = new ActivityPoint(); + activityPoint.setTime(new Date(time)); + if (heartRate > 0) { + activityPoint.setHeartRate(heartRate); + } + if (location != null) { + activityPoint.setLocation(location); + } + return activityPoint; + } + + @NonNull + @Override + public String toString() { + final GBToStringBuilder tsb = new GBToStringBuilder(this); + tsb.append("location", location); + tsb.append("time", time); + tsb.append("heartRate", heartRate); + tsb.append("hrConfidence", hrConfidence); + tsb.append("hrSource", hrSource); + tsb.append("steps", steps); + tsb.append("batteryPercentage", batteryPercentage); + tsb.append("batteryVoltage", batteryVoltage); + tsb.append("charging", charging); + tsb.append("barometerTemperature", barometerTemperature); + tsb.append("barometerPressure", barometerPressure); + tsb.append("barometerAltitude", barometerAltitude); + return tsb.toString(); + } + + public static List fromCsv(final File inputFile) { + final List points = new LinkedList<>(); + try (BufferedReader reader = new BufferedReader(new FileReader(inputFile))) { + final List header = Arrays.asList(reader.readLine().split(",")); + String line; + while ((line = reader.readLine()) != null) { + points.add(BangleJSActivityPoint.fromCsvLine(header, line)); + } + } catch (final IOException e) { + LOG.error("Failed to read {}", inputFile); + return null; + } + + return points; + } + + /** + * This parses all the standard fields from the recorder. + * Some apps such as bthrm add extra fields or modify others. We attempt to gracefully handle other formats (eg. source being "int" or "bthrm"). + */ + @Nullable + @VisibleForTesting + public static BangleJSActivityPoint fromCsvLine(final List header, final String csvLine) { + final String[] split = csvLine.trim().replace(",", ", ").split(","); + if (split.length != header.size()) { + LOG.error("csv line {} length {} differs from header {} length {}", csvLine, split.length, header, header.size()); + return null; + } + for (int i = 0; i < split.length; i++) { + split[i] = split[i].strip(); + } + + final int idxTime = header.indexOf("Time"); + final int idxLatitude = header.indexOf("Latitude"); + final int idxLongitude = header.indexOf("Longitude"); + final int idxAltitude = header.indexOf("Altitude"); + final int idxHeartrate = header.indexOf("Heartrate"); + final int idxConfidence = header.indexOf("Confidence"); + final int idxSource = header.indexOf("Source"); + final int idxSteps = header.indexOf("Steps"); + final int idxBatteryPercentage = header.indexOf("Battery Percentage"); + final int idxBatteryVoltage = header.indexOf("Battery Voltage"); + final int idxCharging = header.indexOf("Charging"); + final int idxBarometerTemperature = header.indexOf("Barometer Temperature"); + final int idxBarometerPressure = header.indexOf("Barometer Pressure"); + final int idxBarometerAltitude = header.indexOf("Barometer Altitude"); + + final long time = idxTime >= 0 && StringUtils.isNotBlank(split[idxTime]) ? ((long) (Double.parseDouble(split[idxTime]) * 1000L)) : 0L; + + try { + final GPSCoordinate location; + if (idxLatitude >= 0 && StringUtils.isNotBlank(split[idxLatitude]) && idxLongitude >= 0 && StringUtils.isNotBlank(split[idxLongitude])) { + final double latitude = Double.parseDouble(split[idxLatitude]); + final double longitude = Double.parseDouble(split[idxLongitude]); + final double altitude; + if (idxAltitude >= 0 && StringUtils.isNotBlank(split[idxAltitude])) { + altitude = Double.parseDouble(split[idxAltitude]); + } else { + altitude = GPSCoordinate.UNKNOWN_ALTITUDE; + } + location = new GPSCoordinate(longitude, latitude, altitude); + } else { + location = null; + } + final int heartRate = idxHeartrate >= 0 && StringUtils.isNotBlank(split[idxHeartrate]) ? (int) Math.round(Double.parseDouble(split[idxHeartrate])) : 0; + final int confidence = idxConfidence >= 0 && StringUtils.isNotBlank(split[idxConfidence]) ? Integer.parseInt(split[idxConfidence]) : 0; + final String source = idxSource >= 0 && StringUtils.isNotBlank(split[idxSource]) ? split[idxSource] : ""; + final int steps = idxSteps >= 0 && StringUtils.isNotBlank(split[idxSteps]) ? Integer.parseInt(split[idxSteps]) : 0; + final int batteryPercentage = idxBatteryPercentage >= 0 && StringUtils.isNotBlank(split[idxBatteryPercentage]) ? Integer.parseInt(split[idxBatteryPercentage]) : -1; + final double batteryVoltage = idxBatteryVoltage >= 0 && StringUtils.isNotBlank(split[idxBatteryVoltage]) ? Double.parseDouble(split[idxBatteryVoltage]) : -1; + final boolean charging = idxCharging >= 0 && StringUtils.isNotBlank(split[idxCharging]) && Boolean.parseBoolean(split[idxCharging]); + final double barometerTemperature = idxBarometerTemperature >= 0 && StringUtils.isNotBlank(split[idxBarometerTemperature]) ? Double.parseDouble(split[idxBarometerTemperature]) : 0; + final double barometerPressure = idxBarometerPressure >= 0 && StringUtils.isNotBlank(split[idxBarometerPressure]) ? Double.parseDouble(split[idxBarometerPressure]) : 0; + final double barometerAltitude = idxBarometerAltitude >= 0 && StringUtils.isNotBlank(split[idxBarometerAltitude]) ? Double.parseDouble(split[idxBarometerAltitude]) : GPSCoordinate.UNKNOWN_ALTITUDE; + + return new BangleJSActivityPoint( + time, + location, + heartRate, + confidence, + source, + steps, + batteryPercentage, + batteryVoltage, + charging, + barometerTemperature, + barometerPressure, + barometerAltitude + ); + } catch (final Exception e) { + LOG.error("failed to parse '{}'", csvLine, e); + // Salvage the time at least + return new BangleJSActivityPoint(time, null, 0, 0, "", 0, -1, -1, false, 0, 0, 0); + } + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/BangleJSCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/BangleJSCoordinator.java index 5f4fd4c3c..4d33a66fb 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/BangleJSCoordinator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/BangleJSCoordinator.java @@ -25,6 +25,7 @@ import android.net.Uri; import android.os.ParcelUuid; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import org.apache.commons.lang3.ArrayUtils; @@ -47,6 +48,7 @@ import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; import nodomain.freeyourgadget.gadgetbridge.entities.Device; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample; +import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryParser; import nodomain.freeyourgadget.gadgetbridge.model.BatteryConfig; import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport; import nodomain.freeyourgadget.gadgetbridge.service.devices.banglejs.BangleJSDeviceSupport; @@ -94,6 +96,12 @@ public class BangleJSCoordinator extends AbstractBLEDeviceCoordinator { return EnumSet.of(SleepAsAndroidFeature.ACCELEROMETER, SleepAsAndroidFeature.HEART_RATE, SleepAsAndroidFeature.NOTIFICATIONS, SleepAsAndroidFeature.ALARMS); } + @Nullable + @Override + public ActivitySummaryParser getActivitySummaryParser(final GBDevice device, final Context context) { + return new BangleJSWorkoutParser(context); + } + @Override public boolean supportsRealtimeData() { return true; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/BangleJSWorkoutParser.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/BangleJSWorkoutParser.java new file mode 100644 index 000000000..b02157ebb --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/BangleJSWorkoutParser.java @@ -0,0 +1,170 @@ +package nodomain.freeyourgadget.gadgetbridge.devices.banglejs; + +import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.ACTIVE_SECONDS; +import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.ALTITUDE_AVG; +import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.ALTITUDE_MAX; +import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.ALTITUDE_MIN; +import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.CADENCE_AVG; +import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.DISTANCE_METERS; +import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.HR_AVG; +import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.HR_MAX; +import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.HR_MIN; +import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.INTERNAL_HAS_GPS; +import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.SPEED_AVG; +import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.SPEED_MAX; +import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.SPEED_MIN; +import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.STEPS; +import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.STRIDE_AVG; +import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.STRIDE_MAX; +import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.STRIDE_MIN; +import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_BPM; +import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_METERS; +import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_METERS_PER_SECOND; +import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_SECONDS; +import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_SPM; +import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_STEPS; + +import android.content.Context; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.util.List; + +import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary; +import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryData; +import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryParser; +import nodomain.freeyourgadget.gadgetbridge.model.ActivityUser; +import nodomain.freeyourgadget.gadgetbridge.util.Accumulator; +import nodomain.freeyourgadget.gadgetbridge.util.FileUtils; + +public class BangleJSWorkoutParser implements ActivitySummaryParser { + private static final Logger LOG = LoggerFactory.getLogger(BangleJSWorkoutParser.class); + + private final Context mContext; + + public BangleJSWorkoutParser(final Context context) { + this.mContext = context; + } + + @Override + public BaseActivitySummary parseBinaryData(final BaseActivitySummary summary, final boolean forDetails) { + if (!forDetails) { + // Re-parsing the csv is too slow for summary + return summary; + } + + if (summary.getRawDetailsPath() == null) { + return summary; + } + + final File inputFile = FileUtils.tryFixPath(new File(summary.getRawDetailsPath())); + if (inputFile == null) { + return summary; + } + + final List points = BangleJSActivityPoint.fromCsv(inputFile); + if (points == null) { + return summary; + } + + summary.setSummaryData(dataFromPoints(points).toString()); + return summary; + } + + public static ActivitySummaryData dataFromPoints(final List points) { + final Accumulator accHeartRate = new Accumulator(); + final Accumulator accSpeed = new Accumulator(); + final Accumulator accAltitude = new Accumulator(); + final Accumulator accStride = new Accumulator(); + double totalDistance = 0; + int totalSteps = 0; + long totalTime = 0; + long totalActiveTime = 0; + boolean hasGps = false; + + final ActivityUser activityUser = new ActivityUser(); + + BangleJSActivityPoint previousPoint = null; + for (final BangleJSActivityPoint p : points) { + if (p.getHeartRate() > 0) { + accHeartRate.add(p.getHeartRate()); + } + final long timeDiff = previousPoint != null ? p.getTime() - previousPoint.getTime() : 0; + double distanceDiff; + // FIXME: GPS data can be missing for some entries which is handled here. + // Should use more complex logic to be more accurate. Use interpolation. + // Should distances be done via the GPX file we generate instead? + if (previousPoint != null && previousPoint.getLocation() != null && p.getLocation() != null) { + distanceDiff = p.getLocation().getDistance(previousPoint.getLocation()); + hasGps = true; + } else { + distanceDiff = p.getSteps() * activityUser.getStepLengthCm() * 0.01d; + } + if (p.getSteps() > 0) { + accStride.add(distanceDiff / p.getSteps()); + } + totalTime += timeDiff; + totalDistance += distanceDiff; + if (distanceDiff != 0) { + totalActiveTime += timeDiff; + } + if (timeDiff > 0) { + accSpeed.add(distanceDiff / (timeDiff / 1000d)); + } + + totalSteps += p.getSteps(); + + previousPoint = p; + } + + final ActivitySummaryData summaryData = new ActivitySummaryData(); + if (totalDistance != 0) { + summaryData.add(DISTANCE_METERS, (float) totalDistance, UNIT_METERS); + } + if (totalActiveTime > 0) { + summaryData.add(ACTIVE_SECONDS, Math.round(totalActiveTime / 1000d), UNIT_SECONDS); + } + + if (totalSteps != 0) { + summaryData.add(STEPS, totalSteps, UNIT_STEPS); + } + if (accHeartRate.getCount() > 0) { + summaryData.add(HR_AVG, accHeartRate.getAverage(), UNIT_BPM); + summaryData.add(HR_MAX, (int) accHeartRate.getMax(), UNIT_BPM); + summaryData.add(HR_MIN, (int) accHeartRate.getMin(), UNIT_BPM); + } + if (accStride.getCount() > 0) { + summaryData.add(STRIDE_AVG, accStride.getAverage(), UNIT_METERS); + summaryData.add(STRIDE_MAX, accStride.getMax(), UNIT_METERS); + summaryData.add(STRIDE_MIN, accStride.getMin(), UNIT_METERS); + } + if (accSpeed.getCount() > 0) { + summaryData.add(SPEED_AVG, accSpeed.getAverage(), UNIT_METERS_PER_SECOND); + summaryData.add(SPEED_MAX, accSpeed.getMax(), UNIT_METERS_PER_SECOND); + summaryData.add(SPEED_MIN, accSpeed.getMin(), UNIT_METERS_PER_SECOND); + } + if (accAltitude.getCount() != 0) { + summaryData.add(ALTITUDE_MAX, accAltitude.getMax(), UNIT_METERS); + summaryData.add(ALTITUDE_MIN, accAltitude.getMin(), UNIT_METERS); + summaryData.add(ALTITUDE_AVG, accAltitude.getAverage(), UNIT_METERS); + } + + if (totalTime > 0) { + // FIXME: Should cadence be steps/min or half that? https://www.polar.com/blog/what-is-running-cadence/ + // The Bangle.js App Loader has Cadence = (steps/min)/2, https://github.com/espruino/BangleApps/blob/master/apps/recorder/interface.html#L103, + // as discussed here: https://github.com/espruino/BangleApps/pull/3068#issuecomment-1790293879 . + summaryData.add(CADENCE_AVG, 0.5 * 60 * totalSteps / (totalTime / 1000d), UNIT_SPM); + } + + // TODO: Implement hrZones by doing calculations on Gadgetbridge side or make Bangle.js report + // this (Karvonen method implemented to a degree in watch app "Run+")? + + // TODO: Does Bangle.js report laps in recorder logs? + + summaryData.add(INTERNAL_HAS_GPS, String.valueOf(hasGps)); + + return summaryData; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivitySummaryData.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivitySummaryData.java index 970219db8..fee71d621 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivitySummaryData.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivitySummaryData.java @@ -34,11 +34,15 @@ public class ActivitySummaryData extends JSONObject { add(null, key, value, unit); } + public void add(final String key, final double value, final String unit) { + add(null, key, value, unit); + } + public void add(final String key, final String value) { add(null, key, value); } - public void add(final String group, final String key, final float value, final String unit) { + public void add(final String group, final String key, final double value, final String unit) { if (value > 0) { try { final JSONObject innerData = new JSONObject(); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/banglejs/BangleJSActivityTrack.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/banglejs/BangleJSActivityTrack.java index 17c85ebb1..9b80999d8 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/banglejs/BangleJSActivityTrack.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/banglejs/BangleJSActivityTrack.java @@ -1,9 +1,10 @@ package nodomain.freeyourgadget.gadgetbridge.service.devices.banglejs; import static java.lang.Integer.parseInt; -import static java.lang.Long.parseLong; -import static java.lang.Math.cos; -import static java.lang.Math.sqrt; + +import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.HR_AVG; +import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.INTERNAL_HAS_GPS; +import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.SPEED_AVG; import android.content.Context; import android.content.SharedPreferences; @@ -15,14 +16,12 @@ import org.json.JSONObject; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.io.BufferedReader; import java.io.File; -import java.io.FileReader; import java.io.IOException; -import java.util.Arrays; import java.util.Calendar; import java.util.Date; -import java.util.Objects; +import java.util.List; +import java.util.Locale; import java.util.Timer; import java.util.TimerTask; @@ -30,6 +29,8 @@ import nodomain.freeyourgadget.gadgetbridge.GBApplication; import nodomain.freeyourgadget.gadgetbridge.R; import nodomain.freeyourgadget.gadgetbridge.database.DBHandler; import nodomain.freeyourgadget.gadgetbridge.database.DBHelper; +import nodomain.freeyourgadget.gadgetbridge.devices.banglejs.BangleJSActivityPoint; +import nodomain.freeyourgadget.gadgetbridge.devices.banglejs.BangleJSWorkoutParser; import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary; import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; import nodomain.freeyourgadget.gadgetbridge.entities.Device; @@ -39,9 +40,8 @@ import nodomain.freeyourgadget.gadgetbridge.export.GPXExporter; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind; import nodomain.freeyourgadget.gadgetbridge.model.ActivityPoint; -import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries; +import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryData; import nodomain.freeyourgadget.gadgetbridge.model.ActivityTrack; -import nodomain.freeyourgadget.gadgetbridge.model.GPSCoordinate; import nodomain.freeyourgadget.gadgetbridge.util.FileUtils; import nodomain.freeyourgadget.gadgetbridge.util.GB; @@ -158,241 +158,32 @@ class BangleJSActivityTrack { stopTimeoutTask(); // Parsing can take a while if there are many data. Restart at end of parsing. File inputFile = new File(dir, filename); - try { // FIXME: There is maybe code inside this try-statement that should be outside of it. + try { + BaseActivitySummary summary = new BaseActivitySummary(); - // Read from the previously stored log into a string. - BufferedReader reader = new BufferedReader(new FileReader(inputFile)); - StringBuilder storedLogBuilder = new StringBuilder(reader.readLine() + "\n"); - String line; - while ((line = reader.readLine()) != null) { - storedLogBuilder.append(line).append("\n"); + final List banglePoints = BangleJSActivityPoint.fromCsv(inputFile); + if (banglePoints == null || banglePoints.isEmpty()) { + // Should never happen? + return; } - reader.close(); - String storedLog = String.valueOf(storedLogBuilder); - storedLog = storedLog.replace(",",", "); // So all rows (internal arrays) in storedLogArray2 get the same number of entries. - LOG.debug("Contents of log read from GB storage:\n" + storedLog); - - // Turn the string log into a 2d array in two steps. - String[] storedLogArray = storedLog.split("\n") ; - String[][] storedLogArray2 = new String[storedLogArray.length][1]; - - for (int i = 0; i < storedLogArray.length; i++) { - storedLogArray2[i] = storedLogArray[i].split(","); - for (int j = 0; j < storedLogArray2[i].length;j++) { - storedLogArray2[i][j] = storedLogArray2[i][j].trim(); // Remove the extra spaces we introduced above for getting the same number of entries on all rows. - } - } - - LOG.debug("Contents of storedLogArray2:\n" + Arrays.deepToString(storedLogArray2)); - - // Turn the 2d array into an object for easier access later on. - JSONObject storedLogObject = new JSONObject(); - JSONArray valueArray = new JSONArray(); - for (int i = 0; i < storedLogArray2[0].length; i++){ - for (int j = 1; j < storedLogArray2.length; j++) { - valueArray.put(storedLogArray2[j][i]); - } - storedLogObject.put(storedLogArray2[0][i], valueArray); - valueArray = new JSONArray(); - } - - // Clean out heartrate==0... - if (storedLogObject.has("Heartrate")) { - JSONArray heartrateArray = storedLogObject.getJSONArray("Heartrate"); - for (int i = 0; i < heartrateArray.length(); i++){ - if (Objects.equals(heartrateArray.getString(i), "0") || - Objects.equals(heartrateArray.getString(i), "0.0")) { - heartrateArray.put(i,""); - } - } - //storedLogObject.remove("Heartrate"); - storedLogObject.put("Heartrate", heartrateArray); - - } - - LOG.debug("storedLogObject:\n" + storedLogObject); - - // Calculate and store analytical data (distance, speed, cadence, etc.). - JSONObject analyticsObject = new JSONObject(); - JSONArray calculationsArray = new JSONArray(); - int logLength = storedLogObject.getJSONArray("Time").length(); - - // Add elapsed time since first reading (seconds). - valueArray = storedLogObject.getJSONArray("Time"); - for (int i = 0; i < logLength; i++) { - calculationsArray.put(valueArray.getDouble(i)-valueArray.getDouble(0)); - } - analyticsObject.put("Elapsed Time", calculationsArray); - - valueArray = new JSONArray(); - calculationsArray = new JSONArray(); - - JSONArray valueArray2 = new JSONArray(); - - //LOG.debug("check here 0"); - // Add analytics based on GPS coordinates. - if (storedLogObject.has("Latitude")) { - // Add distance between last and current reading. - valueArray = storedLogObject.getJSONArray("Latitude"); - valueArray2 = storedLogObject.getJSONArray("Longitude"); - for (int i = 0; i < logLength; i++) { - if (i == 0) { - calculationsArray.put("0"); - } else { - String distance; - if (Objects.equals(valueArray.getString(i), "") || - Objects.equals(valueArray.getString(i - 1), "")) { - // FIXME: GPS data can be missing for some entries which is handled here. - // Should use more complex logic to be more accurate. Use interpolation. - // Should distances be done via the GPX file we generate instead? - distance = "0"; - } else { - distance = distanceFromCoordinatePairs( - (String) valueArray.get(i - 1), - (String) valueArray2.get(i - 1), - (String) valueArray.get(i), - (String) valueArray2.get(i) - ); - } - calculationsArray.put(distance); - } - } - analyticsObject.put("Intermediate Distance", calculationsArray); - - valueArray = new JSONArray(); - valueArray2 = new JSONArray(); - calculationsArray = new JSONArray(); - - //LOG.debug("check here 1"); - // Add stride lengths between consecutive readings. - if (storedLogObject.has("Steps")) { - for (int i = 0; i < logLength; i++) { - if (Objects.equals(storedLogObject.getJSONArray("Steps").getString(i), "0") || - Objects.equals(storedLogObject.getJSONArray("Steps").getString(i), "")) { - calculationsArray.put(""); - } else if (Objects.equals(analyticsObject.getJSONArray("Intermediate Distance").getString(i), "0")) { - calculationsArray.put("0"); - } else { - double steps = storedLogObject.getJSONArray("Steps").getDouble(i); - double calculation = - analyticsObject.getJSONArray("Intermediate Distance").getDouble(i) / steps; - calculationsArray.put(calculation); - } - } - analyticsObject.put("Stride", calculationsArray); - - calculationsArray = new JSONArray(); - } - - //LOG.debug("check here 2"); - } else if (storedLogObject.has("Steps")) { - for (int i = 0; i < logLength; i++) { - if (i==0 || - Objects.equals(storedLogObject.getJSONArray("Steps").getString(i), "0") || - Objects.equals(storedLogObject.getJSONArray("Steps").getString(i), "")) { - calculationsArray.put(0); - } else { - double avgStep = (0.67+0.762)/2; // https://marathonhandbook.com/average-stride-length/ (female+male)/2 - double stride = 2*avgStep; // TODO: Depend on user defined stride length? - double calculation = stride * (storedLogObject.getJSONArray("Steps").getDouble(i)); - //if (calculation == 0) calculation = 0.001; // To avoid potential division by zero later on. - calculationsArray.put(calculation); - } - } - analyticsObject.put("Intermediate Distance", calculationsArray); - - calculationsArray = new JSONArray(); - - } - - //LOG.debug("check here 3"); - if (analyticsObject.has("Intermediate Distance")) { - // Add total distance from start of activity up to each reading. - for (int i = 0; i < logLength; i++) { - if (i==0) { - calculationsArray.put(0); - } else { - double calculation = calculationsArray.getDouble(i-1) + analyticsObject.getJSONArray("Intermediate Distance").getDouble(i); - calculationsArray.put(calculation); - } - } - analyticsObject.put("Total Distance", calculationsArray); - - calculationsArray = new JSONArray(); - - //LOG.debug("check here 4"); - // Add average speed between last and current reading (m/s). - for (int i = 0; i < logLength; i++) { - if (i==0) { - calculationsArray.put(""); - } else { - double timeDiff = - (analyticsObject.getJSONArray("Elapsed Time").getDouble(i) - - analyticsObject.getJSONArray("Elapsed Time").getDouble(i-1)); - if (timeDiff==0) timeDiff = 1; // On older versions of the Recorder Bangle.js app the time reporting could be the same for two data points due to rounding. - double calculation = - analyticsObject.getJSONArray("Intermediate Distance").getDouble(i) / timeDiff; - calculationsArray.put(calculation); - } - } - //LOG.debug("check " + calculationsArray); - analyticsObject.put("Speed", calculationsArray); - - calculationsArray = new JSONArray(); - - //LOG.debug("check here 5"); - // Add average pace between last and current reading (s/km). (Was gonna do this as min/km but summary seems to expect s/km). - for (int i = 0; i < logLength; i++) { - String speed = analyticsObject.getJSONArray("Speed").getString(i); - //LOG.debug("check: " + speed); - if (i==0 || Objects.equals(speed, "0") || Objects.equals(speed, "0.0") || Objects.equals(speed, "")) { - calculationsArray.put(""); - } else { - double calculation = (1000.0) * 1/ analyticsObject.getJSONArray("Speed").getDouble(i); - calculationsArray.put(calculation); - } - } - analyticsObject.put("Pace", calculationsArray); - - calculationsArray = new JSONArray(); - } - - //LOG.debug("check here 6"); - if (storedLogObject.has("Steps")) { - for (int i = 0; i < logLength; i++) { - if (i==0 || Objects.equals(storedLogObject.getJSONArray("Steps").getString(i), "")) { - calculationsArray.put(0); - } else { - // FIXME: Should cadence be steps/min or half that? https://www.polar.com/blog/what-is-running-cadence/ - // The Bangle.js App Loader has Cadence = (steps/min)/2, https://github.com/espruino/BangleApps/blob/master/apps/recorder/interface.html#L103, - // as discussed here: https://github.com/espruino/BangleApps/pull/3068#issuecomment-1790293879 . - double timeDiff = - (storedLogObject.getJSONArray("Time").getDouble(i) - - storedLogObject.getJSONArray("Time").getDouble(i-1)); - if (timeDiff==0) timeDiff = 1; - double calculation = 0.5 * 60 * - (storedLogObject.getJSONArray("Steps").getDouble(i) / timeDiff); - calculationsArray.put(calculation); - } - } - analyticsObject.put("Cadence", calculationsArray); - - calculationsArray = new JSONArray(); - } - //LOG.debug("check here AnalyticsObject:\n" + analyticsObject.toString()); - - //LOG.debug("check here 7"); - BaseActivitySummary summary = null; - - Date startTime = new Date(parseLong(storedLogArray2[1][0].split("\\.\\d")[0])*1000L); - Date endTime = new Date(parseLong(storedLogArray2[storedLogArray2.length-1][0].split("\\.\\d")[0])*1000L); - summary = new BaseActivitySummary(); + Date startTime = new Date(banglePoints.get(0).getTime()); + Date endTime = new Date(banglePoints.get(banglePoints.size() - 1).getTime()); summary.setName(log); summary.setStartTime(startTime); summary.setEndTime(endTime); + ActivitySummaryData summaryData = BangleJSWorkoutParser.dataFromPoints(banglePoints); + summary.setSummaryData(summaryData.toString()); ActivityKind activityKind; - if (analyticsObject.has("Speed")) { - if ((float) 3 > averageOfJSONArray(analyticsObject.getJSONArray("Speed"))) { + final JSONObject speedAvgObj = summaryData.optJSONObject(SPEED_AVG); + if (speedAvgObj != null) { + double speedAvg; + try { + speedAvg = speedAvgObj.getDouble("value"); + } catch (JSONException e) { + LOG.error("Failed to get speed avg"); + speedAvg = -1; + } + if ((float) 3 > speedAvg) { activityKind = ActivityKind.WALKING; } else { activityKind = ActivityKind.RUNNING; @@ -403,133 +194,6 @@ class BangleJSActivityTrack { summary.setActivityKind(activityKind.getCode()); // TODO: Make this depend on info from watch (currently this info isn't supplied in Bangle.js recorder logs). summary.setRawDetailsPath(String.valueOf(inputFile)); - - // FIXME: Many summaryData entries are commented out below. They currently don't report feasible results. Logic and calculation inside this function needs to be fixed. - JSONObject summaryData = new JSONObject(); - // put("Activity", Arrays.asList( - // "distanceMeters", "steps", "activeSeconds", "caloriesBurnt", "totalStride", - // "averageHR", "maxHR", "minHR", "averageStride", "maxStride", "minStride" - // )); - if (analyticsObject.has("Intermediate Distance")) summaryData = - addSummaryData(summaryData, ActivitySummaryEntries.DISTANCE_METERS, - (float) analyticsObject.getJSONArray("Total Distance").getDouble(logLength - 1), - "m"); - if (storedLogObject.has("Steps")) - summaryData = addSummaryData(summaryData, "steps", sumOfJSONArray(storedLogObject.getJSONArray("Steps")), "steps"); - //summaryData = addSummaryData(summaryData,ActivitySummaryEntries.ACTIVE_SECONDS,3,"mm"); // FIXME: Is this suppose to exclude the time of inactivity in a workout? - //summaryData = addSummaryData(summaryData,ActivitySummaryEntries.CALORIES_BURNT,3,"mm"); // TODO: Should this be calculated on Gadgetbridge side or be reported by Bangle.js? - //summaryData = addSummaryData(summaryData,ActivitySummaryEntries.STRIDE_TOTAL,3,"mm"); // FIXME: What is this? - if (storedLogObject.has("Heartrate")) { - summaryData = addSummaryData(summaryData, ActivitySummaryEntries.HR_AVG, averageOfJSONArray(storedLogObject.getJSONArray("Heartrate")), "bpm"); - summaryData = addSummaryData(summaryData, ActivitySummaryEntries.HR_MAX, maxOfJSONArray(storedLogObject.getJSONArray("Heartrate")), "bpm"); - summaryData = addSummaryData(summaryData, ActivitySummaryEntries.HR_MIN, minOfJSONArray(storedLogObject.getJSONArray("Heartrate")), "bpm"); - } - if (analyticsObject.has("Stride")) { - summaryData = addSummaryData(summaryData, ActivitySummaryEntries.STRIDE_AVG, - (float) (analyticsObject.getJSONArray("Total Distance").getDouble(logLength - 1) / - (0.5 * sumOfJSONArray(storedLogObject.getJSONArray("Steps")))), - "m"); // FIXME: Is this meant to be stride length as I've assumed? - //summaryData = addSummaryData(summaryData, ActivitySummaryEntries.STRIDE_MAX, maxOfJSONArray(analyticsObject.getJSONArray("Stride")), "m"); - //summaryData = addSummaryData(summaryData, ActivitySummaryEntries.STRIDE_MIN, minOfJSONArray(analyticsObject.getJSONArray("Stride")), "m"); - } - - // put("Speed", Arrays.asList( - // "averageSpeed", "maxSpeed", "minSpeed", "averageKMPaceSeconds", "minPace", - // "maxPace", "averageSpeed2", "averageCadence", "maxCadence", "minCadence" - // )); - try { - if (analyticsObject.has("Speed")) { - summaryData = addSummaryData(summaryData,ActivitySummaryEntries.SPEED_AVG, averageOfJSONArray(analyticsObject.getJSONArray("Speed")),"m/s"); // This seems to be calculated somewhere else automatically. - //summaryData = addSummaryData(summaryData, ActivitySummaryEntries.SPEED_MAX, maxOfJSONArray(analyticsObject.getJSONArray("Speed")), "m/s"); - //summaryData = addSummaryData(summaryData, ActivitySummaryEntries.SPEED_MIN, minOfJSONArray(analyticsObject.getJSONArray("Speed")), "m/s"); - //summaryData = addSummaryData(summaryData, ActivitySummaryEntries.PACE_AVG_SECONDS_KM, averageOfJSONArray(analyticsObject.getJSONArray("Pace")), "s/km"); // Is this also calculated automatically then? - //summaryData = addSummaryData(summaryData, ActivitySummaryEntries.PACE_AVG_SECONDS_KM, - // (float) (1000.0 * analyticsObject.getJSONArray("Elapsed Time").getDouble(logLength-1) / - // analyticsObject.getJSONArray("Total Distance").getDouble(logLength-1)), - // "s/km" - //); - //summaryData = addSummaryData(summaryData, ActivitySummaryEntries.PACE_MIN, maxOfJSONArray(analyticsObject.getJSONArray("Pace")), "s/km"); - //summaryData = addSummaryData(summaryData, ActivitySummaryEntries.PACE_MAX, minOfJSONArray(analyticsObject.getJSONArray("Pace")), "s/km"); - //summaryData = addSummaryData(summaryData,ActivitySummaryEntries.averageSpeed2,3,"mm"); - } - if (analyticsObject.has("Cadence")) { - //summaryData = addSummaryData(summaryData, ActivitySummaryEntries.averageCadence, averageOfJSONArray(analyticsObject.getJSONArray("Cadence")), "cycles/min"); // Is this also calculated automatically then? - summaryData = addSummaryData(summaryData, ActivitySummaryEntries.CADENCE_AVG, - (float) 0.5 * 60 * sumOfJSONArray(storedLogObject.getJSONArray("Steps")) / - (float) analyticsObject.getJSONArray("Elapsed Time").getDouble(logLength - 1), - "cycles/min" - ); - //summaryData = addSummaryData(summaryData, ActivitySummaryEntries.CADENCE_MAX, maxOfJSONArray(analyticsObject.getJSONArray("Cadence")), "cycles/min"); - //summaryData = addSummaryData(summaryData, ActivitySummaryEntries.CADENCE_MIN, minOfJSONArray(analyticsObject.getJSONArray("Cadence")), "cycles/min"); - } - } catch (Exception e) { - LOG.error(e + ". (thrown when trying to add summary data"); - } - // private JSONObject createActivitySummaryGroups(){ - // final Map> groupDefinitions = new HashMap>() {{ - // put("Strokes", Arrays.asList( - // "averageStrokeDistance", "averageStrokesPerSecond", "strokes" - // )); - - // put("Swimming", Arrays.asList( - // "swolfIndex", "swimStyle" - // )); - - // put("Elevation", Arrays.asList( - // "ascentMeters", "descentMeters", "maxAltitude", "minAltitude", "averageAltitude", - // "baseAltitude", "ascentSeconds", "descentSeconds", "flatSeconds", "ascentDistance", - // "descentDistance", "flatDistance", "elevationGain", "elevationLoss" - // )); - //} - if (storedLogObject.has("Altitude") || storedLogObject.has("Barometer Altitude")) { - String altitudeToUseKey = null; - if (storedLogObject.has("Altitude")) { - altitudeToUseKey = "Altitude"; - } else if (storedLogObject.has("Barometer Altitude")) { - altitudeToUseKey = "Barometer Altitude"; - } - //summaryData = addSummaryData(summaryData, ActivitySummaryEntries.ASCENT_METERS, 3, "m"); - //summaryData = addSummaryData(summaryData, ActivitySummaryEntries.ASCENT_DISTANCE, 3, "m"); - summaryData = addSummaryData(summaryData, ActivitySummaryEntries.ALTITUDE_MAX, maxOfJSONArray(storedLogObject.getJSONArray(altitudeToUseKey)), "m"); - summaryData = addSummaryData(summaryData, ActivitySummaryEntries.ALTITUDE_MIN, minOfJSONArray(storedLogObject.getJSONArray(altitudeToUseKey)), "m"); - summaryData = addSummaryData(summaryData, ActivitySummaryEntries.ALTITUDE_AVG, averageOfJSONArray(storedLogObject.getJSONArray(altitudeToUseKey)), "m"); - //summaryData = addSummaryData(summaryData, ActivitySummaryEntries.ALTITUDE_BASE, 3, "m"); - //summaryData = addSummaryData(summaryData, ActivitySummaryEntries.ASCENT_SECONDS, 3, "s"); - //summaryData = addSummaryData(summaryData, ActivitySummaryEntries.DESCENT_SECONDS, 3, "s"); - //summaryData = addSummaryData(summaryData, ActivitySummaryEntries.FLAT_SECONDS, 3, "s"); - //if (analyticsObject.has("Intermittent Distance")) { - // summaryData = addSummaryData(summaryData, ActivitySummaryEntries.ASCENT_DISTANCE, 3, "m"); - // summaryData = addSummaryData(summaryData, ActivitySummaryEntries.DESCENT_DISTANCE, 3, "m"); - // summaryData = addSummaryData(summaryData, ActivitySummaryEntries.FLAT_DISTANCE, 3, "m"); - //} - //summaryData = addSummaryData(summaryData, ActivitySummaryEntries.ELEVATION_GAIN, 3, "mm"); - //summaryData = addSummaryData(summaryData, ActivitySummaryEntries.ELEVATION_LOGG, 3, "mm"); - } - // put("HeartRateZones", Arrays.asList( - // "hrZoneNa", "hrZoneWarmUp", "hrZoneFatBurn", "hrZoneAerobic", "hrZoneAnaerobic", - // "hrZoneExtreme" - // )); - // TODO: Implement hrZones by doing calculations on Gadgetbridge side or make Bangle.js report this (Karvonen method implemented to a degree in watch app "Run+")? - //summaryData = addSummaryData(summaryData,ActivitySummaryEntries.HR_ZONE_NA,3,"mm"); - //summaryData = addSummaryData(summaryData,ActivitySummaryEntries.HR_ZONE_WARM_UP,3,"mm"); - //summaryData = addSummaryData(summaryData,ActivitySummaryEntries.HR_ZONE_FAT_BURN,3,"mm"); - //summaryData = addSummaryData(summaryData,ActivitySummaryEntries.HR_ZONE_AEROBIC,3,"mm"); - //summaryData = addSummaryData(summaryData,ActivitySummaryEntries.HR_ZONE_ANAEROBIC,3,"mm"); - //summaryData = addSummaryData(summaryData,ActivitySummaryEntries.HR_ZONE_EXTREME,3,"mm"); - // put("TrainingEffect", Arrays.asList( - // "aerobicTrainingEffect", "anaerobicTrainingEffect", "currentWorkoutLoad", - // "maximumOxygenUptake" - // )); - - // put("Laps", Arrays.asList( - // "averageLapPace", "laps" - // )); - // TODO: Does Bangle.js report laps in recorder logs? - //summaryData = addSummaryData(summaryData,ActivitySummaryEntries.LAP_PACE_AVERAGE,3,"mm"); - //summaryData = addSummaryData(summaryData,ActivitySummaryEntries.LAPS,3,"mm"); - // }}; - summary.setSummaryData(summaryData.toString()); - ActivityTrack track = new ActivityTrack(); // detailsParser.parse(buffer.toByteArray()); track.startNewSegment(); track.setBaseTime(startTime); @@ -543,36 +207,10 @@ class BangleJSActivityTrack { } catch (Exception ex) { GB.toast(context, "Error setting user for activity track.", Toast.LENGTH_LONG, GB.ERROR, ex); } - ActivityPoint point = new ActivityPoint(); - Date timeOfPoint = new Date(); - boolean hasGPXReading = false; - boolean hasHRMReading = false; - for (int i = 0; i < storedLogObject.getJSONArray("Time").length(); i++) { - timeOfPoint.setTime(storedLogObject.getJSONArray("Time").getLong(i)*1000L); - point.setTime((Date) timeOfPoint.clone()); - if (storedLogObject.has("Longitude")) { - if (!Objects.equals(storedLogObject.getJSONArray("Longitude").getString(i), "") - && !Objects.equals(storedLogObject.getJSONArray("Latitude").getString(i), "") - && !Objects.equals(storedLogObject.getJSONArray("Altitude").getString(i), "")) { - - point.setLocation(new GPSCoordinate( - storedLogObject.getJSONArray("Longitude").getDouble(i), - storedLogObject.getJSONArray("Latitude").getDouble(i), - storedLogObject.getJSONArray("Altitude").getDouble(i) - ) - ); - - if (!hasGPXReading) hasGPXReading = true; - } - } - if (storedLogObject.has("Heartrate") && !Objects.equals(storedLogObject.getJSONArray("Heartrate").getString(i), "")) { - point.setHeartRate(storedLogObject.getJSONArray("Heartrate").getInt(i)); - - if (!hasHRMReading) hasHRMReading = true; - } - track.addTrackPoint(point); - LOG.debug("Activity Point:\n" + point.getHeartRate()); - point = new ActivityPoint(); + boolean hasGPXReading = summaryData.has(INTERNAL_HAS_GPS); + boolean hasHRMReading = summaryData.has(HR_AVG); + for (final BangleJSActivityPoint banglePoint : banglePoints) { + track.addTrackPoint(banglePoint.toActivityPoint()); } ActivityTrackExporter exporter = new GPXExporter(); @@ -605,13 +243,7 @@ class BangleJSActivityTrack { if (hasGPXReading /*|| hasHRMReading*/) { try { exporter.performExport(track, targetFile); - - try (DBHandler dbHandler = GBApplication.acquireDB()) { - summary.setGpxTrack(targetFile.getAbsolutePath()); - //dbHandler.getDaoSession().getBaseActivitySummaryDao().update(summary); - } catch (Exception e) { - LOG.error("Could not add gpx track to summary:" + e); - } + summary.setGpxTrack(targetFile.getAbsolutePath()); } catch (ActivityTrackExporter.GPXTrackEmptyException ex) { GB.toast(context, "This activity does not contain GPX tracks.", Toast.LENGTH_LONG, GB.ERROR, ex); } @@ -634,8 +266,6 @@ class BangleJSActivityTrack { } catch (IOException e) { LOG.error("IOException when parsing fetched CSV: " + e); - } catch (JSONException e) { - LOG.error("JSONException when parsing fetched CSV: " + e); } stopAndRestartTimeout(device,context); @@ -793,7 +423,7 @@ class BangleJSActivityTrack { int month = date.get(Calendar.MONTH); int day = date.get(Calendar.DATE); - return String.format("%d%02d%02d", year, month, day); + return String.format(Locale.ROOT, "%d%02d%02d", year, month, day); } private static void writeToRecorderCSV(String lines, File dir, String filename) { @@ -811,86 +441,4 @@ class BangleJSActivityTrack { LOG.error("Could not write to file", e); } } - - private static JSONObject addSummaryData(JSONObject summaryData, String key, float value, String unit) { - if (value > 0) { - try { - JSONObject innerData = new JSONObject(); - innerData.put("value", value); - innerData.put("unit", unit); - summaryData.put(key, innerData); - } catch (JSONException ignore) { - } - } - return summaryData; - } - - // protected JSONObject addSummaryData(JSONObject summaryData, String key, String value) { - // if (key != null && !key.equals("") && value != null && !value.equals("")) { - // try { - // JSONObject innerData = new JSONObject(); - // innerData.put("value", value); - // innerData.put("unit", "string"); - // summaryData.put(key, innerData); - // } catch (JSONException ignore) { - // } - // } - // return summaryData; - // } - - private static String distanceFromCoordinatePairs(String latA, String lonA, String latB, String lonB) { - // https://en.wikipedia.org/wiki/Geographic_coordinate_system#Length_of_a_degree - //phi = latitude - //lambda = longitude - //length of 1 degree lat: - //111132.92 - 559.82*cos(2*phi) + 1.175*cos(4*phi) - 0.0023*cos(6*phi) - //length of 1 degree lon: - //111412.84*cos(phi) - 93.5*cos(3*phi) + 0.118*cos(5*phi) - double latADouble = Double.parseDouble(latA); - double latBDouble = Double.parseDouble(latB); - double lonADouble = Double.parseDouble(lonA); - double lonBDouble = Double.parseDouble(lonB); - - double lengthPerDegreeLat = 111132.92 - 559.82*cos(2*latADouble) + 1.175*cos(4*latADouble) - 0.0023*cos(6*latADouble); - double lengthPerDegreeLon = 111412.84*cos(latADouble) - 93.5*cos(3*latADouble) + 0.118*cos(5*latADouble); - - double latDist = (latBDouble-latADouble)*lengthPerDegreeLat; - double lonDist = (lonBDouble-lonADouble)*lengthPerDegreeLon; - - return String.valueOf(sqrt(latDist*latDist+lonDist*lonDist)); - } - - private static float sumOfJSONArray(JSONArray a) throws JSONException { - double sum = 0; - for (int i=0; i max) { + max = value; + } + if (value < min) { + min = value; + } + } + + public double getMin() { + return min; + } + + public double getMax() { + return max; + } + + public double getSum() { + return sum; + } + + public int getCount() { + return count; + } + + public double getAverage() { + if (count > 0) { + return sum / count; + } else { + return 0; + } + } +} diff --git a/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/BangleJSActivityPointTest.java b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/BangleJSActivityPointTest.java new file mode 100644 index 000000000..17162b303 --- /dev/null +++ b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/BangleJSActivityPointTest.java @@ -0,0 +1,143 @@ +package nodomain.freeyourgadget.gadgetbridge.devices.banglejs; + +import static org.junit.Assert.*; + +import org.junit.Test; + +import java.util.Arrays; +import java.util.List; + +import nodomain.freeyourgadget.gadgetbridge.model.GPSCoordinate; + +public class BangleJSActivityPointTest { + @Test + public void testParseCsvLine5s() { + testTemplate( + "Time,Latitude,Longitude,Altitude,Heartrate,Confidence,Source,Steps,Battery Percentage,Battery Voltage,Charging", + "1710610740,,,,92,50,,0,96,3.30644531249,false", + new BangleJSActivityPoint( + 1710610740_000L, + null, + 92, + 50, + "", + 0, + 96, + 3.30644531249, + false, + 0, + 0, + GPSCoordinate.UNKNOWN_ALTITUDE + ) + ); + } + + @Test + public void testParseCsvLine5sWithLocation() { + testTemplate( + "Time,Latitude,Longitude,Altitude,Heartrate,Confidence,Source,Steps,Battery Percentage,Battery Voltage,Charging", + "1710610740,-65.999000,10.12300,,92,50,,0,96,3.30644531249,false", + new BangleJSActivityPoint( + 1710610740_000L, + new GPSCoordinate(10.123d, -65.999d), + 92, + 50, + "", + 0, + 96, + 3.30644531249, + false, + 0, + 0, + GPSCoordinate.UNKNOWN_ALTITUDE + ) + ); + } + + @Test + public void testParseCsvLine5sWithLocationAndAltitude() { + testTemplate( + "Time,Latitude,Longitude,Altitude,Heartrate,Confidence,Source,Steps,Battery Percentage,Battery Voltage,Charging", + "1710610740,-65.999000,10.12300,55,92,50,,0,96,3.30644531249,false", + new BangleJSActivityPoint( + 1710610740_000L, + new GPSCoordinate(10.123d, -65.999d, 55d), + 92, + 50, + "", + 0, + 96, + 3.30644531249, + false, + 0, + 0, + GPSCoordinate.UNKNOWN_ALTITUDE + ) + ); + } + + @Test + public void testParseCsvLine1s() { + testTemplate( + "Time,Battery Percentage,Battery Voltage,Charging,Steps,Barometer Temperature,Barometer Pressure,Barometer Altitude,Heartrate,Confidence,Source,Latitude,Longitude,Altitude", + "1700265185.2,78,3.31787109374,false,0,33.39859771728,1012.66780596669,4.84829130165,95.7,0,,,,", + new BangleJSActivityPoint( + 1700265185_200L, + null, + 96, + 0, + "", + 0, + 78, + 3.31787109374, + false, + 33.39859771728, + 1012.66780596669, + 4.84829130165 + ) + ); + } + + @Test + public void testParseCsvLineBthrm() { + testTemplate( + "Time,Heartrate,Confidence,Source,Latitude,Longitude,Altitude,Int Heartrate,Int Confidence,BT Heartrate,BT Battery,Energy expended,Contact,RR,Barometer Temperature,Barometer Pressure,Barometer Altitude,Steps,Battery Percentage,Battery Voltage,Charging", + "1727544008.4,61,100,bthrm,,,,0,32,61,,,,1069,31.20888417561,994.92400814020,153.70596141680,0,88,3.32226562499,false", + new BangleJSActivityPoint( + 1727544008_400L, + null, + 61, + 100, + "bthrm", + 0, + 88, + 3.32226562499, + false, + 31.20888417561, + 994.92400814020, + 153.70596141680 + ) + ); + } + + private void testTemplate(final String headerStr, final String csvLine, final BangleJSActivityPoint expected) { + final List header = Arrays.asList(headerStr.split(",")); + final BangleJSActivityPoint point = BangleJSActivityPoint.fromCsvLine(header, csvLine); + assertPointEquals(expected, point); + } + + private void assertPointEquals(final BangleJSActivityPoint expected, final BangleJSActivityPoint actual) { + assertEquals("Mismatch on Time", expected.getTime(), actual.getTime()); + assertEquals("Mismatch on Location", expected.getLocation(), actual.getLocation()); + assertEquals("Mismatch on HeartRate", expected.getHeartRate(), actual.getHeartRate()); + assertEquals("Mismatch on HrConfidence", expected.getHrConfidence(), actual.getHrConfidence()); + assertEquals("Mismatch on HrSource", expected.getHrSource(), actual.getHrSource()); + assertEquals("Mismatch on Steps", expected.getSteps(), actual.getSteps()); + assertEquals("Mismatch on BatteryPercentage", expected.getBatteryPercentage(), actual.getBatteryPercentage()); + assertEquals("Mismatch on BatteryVoltage", expected.getBatteryVoltage(), actual.getBatteryVoltage(), 0.000001d); + assertEquals("Mismatch on Charging", expected.isCharging(), actual.isCharging()); + assertEquals("Mismatch on BarometerTemperature", expected.getBarometerTemperature(), actual.getBarometerTemperature(), 0.000001d); + assertEquals("Mismatch on BarometerPressure", expected.getBarometerPressure(), actual.getBarometerPressure(), 0.000001d); + assertEquals("Mismatch on BarometerAltitude", expected.getBarometerAltitude(), actual.getBarometerAltitude(), 0.000001d); + } +} diff --git a/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/BangleJSWorkoutParserTest.java b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/BangleJSWorkoutParserTest.java new file mode 100644 index 000000000..f4b68f43f --- /dev/null +++ b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/BangleJSWorkoutParserTest.java @@ -0,0 +1,23 @@ +package nodomain.freeyourgadget.gadgetbridge.devices.banglejs; + +import static org.junit.Assert.*; + +import org.junit.Ignore; +import org.junit.Test; + +import java.io.File; +import java.util.List; + +import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryData; +import nodomain.freeyourgadget.gadgetbridge.test.TestBase; + +public class BangleJSWorkoutParserTest extends TestBase { + @Test + @Ignore("helper test for development, remove this while debugging") + public void testLocal() { + final File file = new File("/storage/downloads/recorder.log20240317a.csv"); + final List pointsFromCsv = BangleJSActivityPoint.fromCsv(file); + assert pointsFromCsv != null; + final ActivitySummaryData summaryData = BangleJSWorkoutParser.dataFromPoints(pointsFromCsv); + } +}