diff --git a/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java b/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java index dd5bde65f..1c8cc8183 100644 --- a/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java +++ b/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java @@ -129,6 +129,7 @@ public class GBDaoGenerator { addWorldClocks(schema, user, device); addContacts(schema, user, device); addAppSpecificNotificationSettings(schema, device); + addCyclingSample(schema, user, device); Entity notificationFilter = addNotificationFilters(schema); @@ -624,6 +625,18 @@ public class GBDaoGenerator { return activitySample; } + private static Entity addCyclingSample(Schema schema, Entity user, Entity device){ + Entity cyclingSample = addEntity(schema, "CyclingSample"); + addCommonTimeSampleProperties("AbstractTimeSample", cyclingSample, user, device); + + cyclingSample.implementsSerializable(); + + cyclingSample.addIntProperty("RevolutionCount"); + cyclingSample.addFloatProperty("Distance"); + cyclingSample.addFloatProperty("Speed"); + return cyclingSample; + } + private static Entity addVivomoveHrActivitySample(Schema schema, Entity user, Entity device) { final Entity activitySample = addEntity(schema, "VivomoveHrActivitySample"); activitySample.implementsSerializable(); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/ActivityChartsActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/ActivityChartsActivity.java index d8fae3f2b..e53b0d5f8 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/ActivityChartsActivity.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/ActivityChartsActivity.java @@ -108,6 +108,9 @@ public class ActivityChartsActivity extends AbstractChartsActivity { if (!coordinator.supportsTemperatureMeasurement()) { tabList.remove("temperature"); } + if(!coordinator.supportsCyclingData()) { + tabList.remove("cycling"); + } return tabList; } @@ -151,6 +154,8 @@ public class ActivityChartsActivity extends AbstractChartsActivity { return new Spo2ChartFragment(); case "temperature": return new TemperatureChartFragment(); + case "cycling": + return new CyclingChartFragment(); } return null; } @@ -201,6 +206,8 @@ public class ActivityChartsActivity extends AbstractChartsActivity { return getString(R.string.pref_header_spo2); case "temperature": return getString(R.string.menuitem_temperature); + case "cycling": + return getString(R.string.title_cycling); } return super.getPageTitle(position); } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/CyclingChartFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/CyclingChartFragment.java new file mode 100644 index 000000000..0d07b5c45 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/CyclingChartFragment.java @@ -0,0 +1,288 @@ +package nodomain.freeyourgadget.gadgetbridge.activities.charts; + +import android.graphics.Color; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; + +import com.github.mikephil.charting.animation.Easing; +import com.github.mikephil.charting.charts.Chart; +import com.github.mikephil.charting.charts.LineChart; +import com.github.mikephil.charting.components.LegendEntry; +import com.github.mikephil.charting.components.LimitLine; +import com.github.mikephil.charting.components.XAxis; +import com.github.mikephil.charting.components.YAxis; +import com.github.mikephil.charting.data.Entry; +import com.github.mikephil.charting.data.LineData; +import com.github.mikephil.charting.data.LineDataSet; +import com.github.mikephil.charting.formatter.ValueFormatter; +import com.github.mikephil.charting.interfaces.datasets.ILineDataSet; + +import java.text.DateFormat; +import java.text.DecimalFormat; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Date; +import java.util.GregorianCalendar; +import java.util.List; +import java.util.Locale; + +import nodomain.freeyourgadget.gadgetbridge.GBApplication; +import nodomain.freeyourgadget.gadgetbridge.R; +import nodomain.freeyourgadget.gadgetbridge.database.DBHandler; +import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator; +import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider; +import nodomain.freeyourgadget.gadgetbridge.entities.CyclingSample; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.util.Prefs; + +public class CyclingChartFragment extends AbstractChartFragment{ + private LineChart cyclingHistoryChart; + + private int BACKGROUND_COLOR; + private int DESCRIPTION_COLOR; + private int CHART_TEXT_COLOR; + private int LEGEND_TEXT_COLOR; + private int CHART_LINE_COLOR_DISTANCE; + private int CHART_LINE_COLOR_SPEED; + private final Prefs prefs = GBApplication.getPrefs(); + + protected static class CyclingChartsData extends DefaultChartsData { + public CyclingChartsData(LineData lineData) { + super(lineData, new TimeFormatter()); + } + } + + @Override + public String getTitle() { + return "Cycling data"; + } + + @Override + protected void init() { + BACKGROUND_COLOR = GBApplication.getBackgroundColor(requireContext()); + LEGEND_TEXT_COLOR = DESCRIPTION_COLOR = GBApplication.getTextColor(requireContext()); + CHART_TEXT_COLOR = GBApplication.getSecondaryTextColor(requireContext()); + + if (prefs.getBoolean("chart_heartrate_color", false)) { + CHART_LINE_COLOR_DISTANCE = ContextCompat.getColor(getContext(), R.color.chart_activity_dark); + CHART_LINE_COLOR_SPEED = ContextCompat.getColor(getContext(), R.color.chart_heartrate); + } else { + CHART_LINE_COLOR_DISTANCE = ContextCompat.getColor(getContext(), R.color.chart_activity_light); + CHART_LINE_COLOR_SPEED = ContextCompat.getColor(getContext(), R.color.chart_heartrate_alternative); + } + } + + @Override + protected CyclingChartsData refreshInBackground(ChartsHost chartsHost, DBHandler db, GBDevice device) { + List samples = getSamples(db, device); + + return new CyclingChartsDataBuilder(samples).build(); + } + + protected class CyclingChartsDataBuilder { + private final List samples; + + private final List lineEntries = new ArrayList<>(); + + long averageSum; + long averageNumSamples; + + public CyclingChartsDataBuilder(final List samples) { + this.samples = samples; + } + + private void reset() { + lineEntries.clear(); + + averageSum = 0; + averageNumSamples = 0; + } + + public CyclingChartsData build() { + List distanceEntries = new ArrayList<>(); + List speedEntries = new ArrayList<>(); + + Float dayStart = 0f; + + if(!samples.isEmpty()){ + dayStart = samples.get(0).getDistance() / 1000f; + } + + int nextIndex = 0; + for (CyclingSample sample : samples) { + // add distance in Km + distanceEntries.add(new Entry(sample.getTimestamp(), (sample.getDistance() / 1000f) - dayStart)); + Float speed = sample.getSpeed(); + speedEntries.add(new Entry(sample.getTimestamp(), (speed != null) ? (sample.getSpeed() * 3.6f) : 0)); + + if(nextIndex < samples.size()){ + CyclingSample nextSample = samples.get(nextIndex); + if(nextSample.getSpeed() == null){ + // sensor is off, doesn't report zero speed. So let's inject it outselves + speedEntries.add(new Entry(sample.getTimestamp() + 30_000, 0)); + } + } + + nextIndex++; + } + + LineDataSet distanceSet = new LineDataSet(distanceEntries, "Cycling"); + distanceSet.setLineWidth(2.2f); + distanceSet.setColor(CHART_LINE_COLOR_DISTANCE); + distanceSet.setDrawCircles(false); + distanceSet.setDrawCircleHole(false); + distanceSet.setDrawValues(true); + distanceSet.setValueTextSize(10f); + distanceSet.setValueTextColor(CHART_TEXT_COLOR); + distanceSet.setHighlightEnabled(false); + distanceSet.setValueFormatter(new CyclingDistanceFormatter(CyclingChartFragment.this, dayStart)); + distanceSet.setAxisDependency(cyclingHistoryChart.getAxisLeft().getAxisDependency()); + LineData lineData = new LineData(distanceSet); + + LineDataSet speedSet = new LineDataSet(speedEntries, "Speed"); + speedSet.setLineWidth(2.2f); + speedSet.setColor(CHART_LINE_COLOR_SPEED); + speedSet.setDrawCircles(false); + speedSet.setDrawCircleHole(false); + speedSet.setDrawValues(true); + speedSet.setValueTextSize(10f); + speedSet.setValueTextColor(CHART_TEXT_COLOR); + speedSet.setHighlightEnabled(true); + speedSet.setValueFormatter(new CyclingSpeedFormatter(CyclingChartFragment.this)); + speedSet.setAxisDependency(cyclingHistoryChart.getAxisRight().getAxisDependency()); + + lineData.addDataSet(speedSet); + + return new CyclingChartsData(lineData); + } + } + + @Override + protected void renderCharts() { + cyclingHistoryChart.animateX(ANIM_TIME, Easing.EaseInOutQuart); + } + + @Override + protected void setupLegend(final Chart chart) { + final List legendEntries = new ArrayList<>(2); + + final LegendEntry distanceEntry = new LegendEntry(); + distanceEntry.label = getString(R.string.activity_list_summary_distance); + distanceEntry.formColor = CHART_LINE_COLOR_DISTANCE; + legendEntries.add(distanceEntry); + + final LegendEntry speedEntry = new LegendEntry(); + speedEntry.label = getString(R.string.Speed); + speedEntry.formColor = CHART_LINE_COLOR_SPEED; + legendEntries.add(speedEntry); + + chart.getLegend().setCustom(legendEntries); + chart.getLegend().setTextColor(LEGEND_TEXT_COLOR); + } + + @Override + protected void updateChartsnUIThread(CyclingChartsData cyclingData) { + cyclingHistoryChart.setData(null); // workaround for https://github.com/PhilJay/MPAndroidChart/issues/2317 + cyclingHistoryChart.getXAxis().setValueFormatter(cyclingData.getXValueFormatter()); + cyclingHistoryChart.getXAxis().setAvoidFirstLastClipping(true); + + cyclingHistoryChart.setData(cyclingData.getData()); + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + View rootView = inflater.inflate(R.layout.fragment_cycling, container, false); + + cyclingHistoryChart = rootView.findViewById(R.id.chart_cycling_history); + + cyclingHistoryChart.setBackgroundColor(BACKGROUND_COLOR); + cyclingHistoryChart.getDescription().setTextColor(DESCRIPTION_COLOR); + configureBarLineChartDefaults(cyclingHistoryChart); + + final XAxis x = cyclingHistoryChart.getXAxis(); + x.setDrawLabels(true); + x.setDrawGridLines(true); + x.setEnabled(true); + x.setTextColor(CHART_TEXT_COLOR); + x.setDrawLimitLinesBehindData(true); + + final YAxis yAxisLeft = cyclingHistoryChart.getAxisLeft(); + yAxisLeft.setDrawGridLines(true); + yAxisLeft.setTextColor(CHART_LINE_COLOR_DISTANCE); + yAxisLeft.setEnabled(true); + yAxisLeft.setGridColor(CHART_LINE_COLOR_DISTANCE); + + final YAxis yAxisRight = cyclingHistoryChart.getAxisRight(); + yAxisRight.setDrawGridLines(true); + yAxisRight.setTextColor(CHART_LINE_COLOR_SPEED); + yAxisRight.setEnabled(true); + yAxisRight.setGridColor(CHART_LINE_COLOR_SPEED); + + refresh(); + + return rootView; + } + + private List getSamples(final DBHandler db, final GBDevice device) { + final int tsStart = getTSStart(); + final int tsEnd = getTSEnd(); + final DeviceCoordinator coordinator = device.getDeviceCoordinator(); + final TimeSampleProvider sampleProvider = coordinator.getCyclingSampleProvider(device, db.getDaoSession()); + return sampleProvider.getAllSamples(tsStart * 1000L, tsEnd * 1000L); + } + + + + protected static class CyclingDistanceFormatter extends ValueFormatter { + // private final DecimalFormat formatter = new DecimalFormat("0.00 km"); + Float dayStartDistance; + CyclingChartFragment fragment; + + public CyclingDistanceFormatter(CyclingChartFragment fragment, Float dayStartDistance) { + this.dayStartDistance = dayStartDistance; + this.fragment = fragment; + } + + @Override + public String getPointLabel(Entry entry) { + return fragment.getString(R.string.chart_cycling_point_label_distance, entry.getY(), entry.getY() + dayStartDistance); + } + } + + protected static class CyclingSpeedFormatter extends ValueFormatter { + CyclingChartFragment fragment; + + public CyclingSpeedFormatter(CyclingChartFragment fragment) { + this.fragment = fragment; + } + + @Override + public String getPointLabel(Entry entry) { + return fragment.getString(R.string.chart_cycling_point_label_speed, entry.getY()); + } + } + + protected static class TimeFormatter extends ValueFormatter { + DateFormat annotationDateFormat = SimpleDateFormat.getTimeInstance(DateFormat.SHORT); + Calendar cal = GregorianCalendar.getInstance(); + + public TimeFormatter() { + } + + @Override + public String getFormattedValue(float value) { + cal.clear(); + cal.setTimeInMillis((long)(value)); + Date date = cal.getTime(); + return annotationDateFormat.format(date); + } + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSettingsPreferenceConst.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSettingsPreferenceConst.java index 50a0d9284..b1995a690 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSettingsPreferenceConst.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSettingsPreferenceConst.java @@ -460,4 +460,7 @@ public class DeviceSettingsPreferenceConst { public static final String PREF_AUTO_REPLY_INCOMING_CALL = "pref_auto_reply_phonecall"; public static final String PREF_AUTO_REPLY_INCOMING_CALL_DELAY = "pref_auto_reply_phonecall_delay"; public static final String PREF_SPEAK_NOTIFICATIONS_ALOUD = "pref_speak_notifications_aloud"; + + public static final String PREF_CYCLING_SENSOR_PERSISTENCE_INTERVAL = "pref_cycling_persistence_interval"; + public static final String PREF_CYCLING_SENSOR_WHEEL_DIAMETER = "pref_cycling_wheel_diameter"; } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsFragment.java index 5615318d7..3cc9562e0 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsFragment.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsFragment.java @@ -643,6 +643,9 @@ public class DeviceSpecificSettingsFragment extends AbstractPreferenceFragment i addPreferenceHandlerFor(PREF_HEARTRATE_AUTOMATIC_ENABLE); addPreferenceHandlerFor(PREF_SPO_AUTOMATIC_ENABLE); + addPreferenceHandlerFor(PREF_CYCLING_SENSOR_PERSISTENCE_INTERVAL); + addPreferenceHandlerFor(PREF_CYCLING_SENSOR_WHEEL_DIAMETER); + addPreferenceHandlerFor("lock"); String sleepTimeState = prefs.getString(PREF_SLEEP_TIME, PREF_DO_NOT_DISTURB_OFF); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinator.java index a3588f068..a82ed4d85 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinator.java @@ -61,6 +61,7 @@ import nodomain.freeyourgadget.gadgetbridge.database.DBHelper; import nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandConst; import nodomain.freeyourgadget.gadgetbridge.entities.AlarmDao; import nodomain.freeyourgadget.gadgetbridge.entities.BatteryLevelDao; +import nodomain.freeyourgadget.gadgetbridge.entities.CyclingSample; import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; import nodomain.freeyourgadget.gadgetbridge.entities.Device; import nodomain.freeyourgadget.gadgetbridge.entities.DeviceAttributesDao; @@ -226,6 +227,11 @@ public abstract class AbstractDeviceCoordinator implements DeviceCoordinator { return null; } + @Override + public TimeSampleProvider getCyclingSampleProvider(GBDevice device, DaoSession session) { + return null; + } + @Override public TimeSampleProvider getHeartRateMaxSampleProvider(GBDevice device, DaoSession session) { return null; @@ -567,6 +573,11 @@ public abstract class AbstractDeviceCoordinator implements DeviceCoordinator { return false; } + @Override + public boolean supportsCyclingData() { + return false; + } + @Override public boolean supportsRemSleep() { return false; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceCoordinator.java index 1de36fa03..80899dbdb 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceCoordinator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceCoordinator.java @@ -42,6 +42,7 @@ import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpec import nodomain.freeyourgadget.gadgetbridge.capabilities.HeartRateCapability; import nodomain.freeyourgadget.gadgetbridge.capabilities.password.PasswordCapabilityImpl; import nodomain.freeyourgadget.gadgetbridge.capabilities.widgets.WidgetManager; +import nodomain.freeyourgadget.gadgetbridge.entities.CyclingSample; import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate; @@ -56,6 +57,7 @@ import nodomain.freeyourgadget.gadgetbridge.model.SleepRespiratoryRateSample; import nodomain.freeyourgadget.gadgetbridge.model.Spo2Sample; import nodomain.freeyourgadget.gadgetbridge.model.StressSample; import nodomain.freeyourgadget.gadgetbridge.model.TemperatureSample; +import nodomain.freeyourgadget.gadgetbridge.model.TimeSample; import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport; import nodomain.freeyourgadget.gadgetbridge.service.ServiceDeviceSupport; import nodomain.freeyourgadget.gadgetbridge.service.SleepAsAndroidSender; @@ -189,6 +191,15 @@ public interface DeviceCoordinator { */ boolean supportsActivityTracking(); + /** + * Returns true if cycling data is supported by the device + * (with this coordinator). + * This enables the ChartsActivity. + * + * @return + */ + boolean supportsCyclingData(); + /** * Indicates whether the device supports recording dedicated activity tracks, like * walking, hiking, running, swimming, etc. and retrieving the recorded @@ -287,6 +298,11 @@ public interface DeviceCoordinator { */ TimeSampleProvider getSpo2SampleProvider(GBDevice device, DaoSession session); + /** + * Returns the sample provider for Cycling data, for the device being supported. + */ + TimeSampleProvider getCyclingSampleProvider(GBDevice device, DaoSession session); + /** * Returns the sample provider for max HR data, for the device being supported. */ diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cycling_sensor/coordinator/CyclingSensorCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cycling_sensor/coordinator/CyclingSensorCoordinator.java new file mode 100644 index 000000000..44f9cb014 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cycling_sensor/coordinator/CyclingSensorCoordinator.java @@ -0,0 +1,113 @@ +package nodomain.freeyourgadget.gadgetbridge.devices.cycling_sensor.coordinator; + +import android.app.Activity; +import android.content.Context; +import android.net.Uri; + +import androidx.annotation.NonNull; + +import nodomain.freeyourgadget.gadgetbridge.GBException; +import nodomain.freeyourgadget.gadgetbridge.R; +import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator; +import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler; +import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider; +import nodomain.freeyourgadget.gadgetbridge.devices.cycling_sensor.db.CyclingSampleProvider; +import nodomain.freeyourgadget.gadgetbridge.entities.CyclingSample; +import nodomain.freeyourgadget.gadgetbridge.entities.CyclingSampleDao; +import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; +import nodomain.freeyourgadget.gadgetbridge.entities.Device; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate; +import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport; +import nodomain.freeyourgadget.gadgetbridge.service.devices.cycling_sensor.support.CyclingSensorSupport; + +public class CyclingSensorCoordinator extends AbstractBLEDeviceCoordinator { + @Override + protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) throws GBException { + final Long deviceId = device.getId(); + + session.getCyclingSampleDao().queryBuilder() + .where(CyclingSampleDao.Properties.DeviceId.eq(deviceId)) + .buildDelete().executeDeleteWithoutDetachingEntities(); + } + + @Override + public boolean supports(GBDeviceCandidate candidate) { + return candidate.supportsService(CyclingSensorSupport.UUID_CYCLING_SENSOR_SERVICE); + } + + @Override + public boolean supportsCyclingData() { + return true; + } + + @Override + public boolean supportsActivityTracking() { + return true; + } + + @Override + public TimeSampleProvider getCyclingSampleProvider(GBDevice device, DaoSession session) { + return new CyclingSampleProvider(device, session); + } + + @Override + public boolean supportsSleepMeasurement() { + return false; + } + @Override + public boolean supportsStepCounter() { + return false; + } + @Override + public boolean supportsSpeedzones() { + return false; + } + @Override + public boolean supportsActivityTabs() { + return false; + } + + @Override + public InstallHandler findInstallHandler(Uri uri, Context context) { + return null; + } + + @Override + public String getManufacturer() { + return "Unknown"; + } + + @Override + public Class getAppsManagementActivity() { + return null; + } + + @Override + public boolean supportsRealtimeData() { + return false; + } + + @Override + public int getBondingStyle() { + return BONDING_STYLE_NONE; + } + + @Override + public int[] getSupportedDeviceSpecificSettings(GBDevice device) { + return new int[]{ + R.xml.devicesettings_cycling_sensor + }; + } + + @NonNull + @Override + public Class getDeviceSupportClass() { + return CyclingSensorSupport.class; + } + + @Override + public int getDeviceNameResource() { + return R.string.devicetype_cycling_sensor; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cycling_sensor/db/CyclingSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cycling_sensor/db/CyclingSampleProvider.java new file mode 100644 index 000000000..6e521b68a --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cycling_sensor/db/CyclingSampleProvider.java @@ -0,0 +1,41 @@ +package nodomain.freeyourgadget.gadgetbridge.devices.cycling_sensor.db; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import de.greenrobot.dao.AbstractDao; +import de.greenrobot.dao.Property; +import nodomain.freeyourgadget.gadgetbridge.devices.AbstractSampleProvider; +import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider; +import nodomain.freeyourgadget.gadgetbridge.entities.CyclingSample; +import nodomain.freeyourgadget.gadgetbridge.entities.CyclingSampleDao; +import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; + +public class CyclingSampleProvider extends AbstractTimeSampleProvider { + public CyclingSampleProvider(GBDevice device, DaoSession session) { + super(device, session); + } + + @Override + public AbstractDao getSampleDao() { + return getSession().getCyclingSampleDao(); + } + + @NonNull + @Override + protected Property getTimestampSampleProperty() { + return CyclingSampleDao.Properties.Timestamp; + } + + @NonNull + @Override + protected Property getDeviceIdentifierSampleProperty() { + return CyclingSampleDao.Properties.DeviceId; + } + + @Override + public CyclingSample createSample() { + return new CyclingSample(); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java index ee20b2d8f..9cafb6a01 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java @@ -37,6 +37,7 @@ import nodomain.freeyourgadget.gadgetbridge.devices.casio.gbx100.CasioGBX100Devi import nodomain.freeyourgadget.gadgetbridge.devices.casio.gwb5600.CasioGMWB5000DeviceCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.casio.gwb5600.CasioGWB5600DeviceCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.cmfwatchpro.CmfWatchProCoordinator; +import nodomain.freeyourgadget.gadgetbridge.devices.cycling_sensor.coordinator.CyclingSensorCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.divoom.PixooCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.domyos.DomyosT540Coordinator; import nodomain.freeyourgadget.gadgetbridge.devices.femometer.FemometerVinca2DeviceCoordinator; @@ -388,6 +389,7 @@ public enum DeviceType { FEMOMETER_VINCA2(FemometerVinca2DeviceCoordinator.class), PIXOO(PixooCoordinator.class), SCANNABLE(ScannableDeviceCoordinator.class), + CYCLING_SENSOR(CyclingSensorCoordinator.class), TEST(TestDeviceCoordinator.class); private DeviceCoordinator coordinator; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cycling_sensor/support/CyclingSensorBaseSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cycling_sensor/support/CyclingSensorBaseSupport.java new file mode 100644 index 000000000..805248032 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cycling_sensor/support/CyclingSensorBaseSupport.java @@ -0,0 +1,16 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.cycling_sensor.support; + +import org.slf4j.Logger; + +import nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLEDeviceSupport; + +public class CyclingSensorBaseSupport extends AbstractBTLEDeviceSupport { + public CyclingSensorBaseSupport(Logger logger) { + super(logger); + } + + @Override + public boolean useAutoConnect() { + return false; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cycling_sensor/support/CyclingSensorSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cycling_sensor/support/CyclingSensorSupport.java new file mode 100644 index 000000000..bdf5b0f88 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cycling_sensor/support/CyclingSensorSupport.java @@ -0,0 +1,267 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.cycling_sensor.support; + +import static nodomain.freeyourgadget.gadgetbridge.model.ActivityKind.TYPE_CYCLING; + +import android.bluetooth.BluetoothGatt; +import android.bluetooth.BluetoothGattCharacteristic; +import android.content.Intent; + +import androidx.annotation.NonNull; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.UUID; + +import nodomain.freeyourgadget.gadgetbridge.GBApplication; +import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst; +import nodomain.freeyourgadget.gadgetbridge.database.DBHandler; +import nodomain.freeyourgadget.gadgetbridge.database.DBHelper; +import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventBatteryInfo; +import nodomain.freeyourgadget.gadgetbridge.devices.cycling_sensor.db.CyclingSampleProvider; +import nodomain.freeyourgadget.gadgetbridge.entities.CyclingSample; +import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; +import nodomain.freeyourgadget.gadgetbridge.entities.Device; +import nodomain.freeyourgadget.gadgetbridge.entities.User; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.model.DeviceService; +import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder; +import nodomain.freeyourgadget.gadgetbridge.service.btle.actions.NotifyAction; +import nodomain.freeyourgadget.gadgetbridge.service.btle.actions.ReadAction; +import nodomain.freeyourgadget.gadgetbridge.service.btle.actions.SetDeviceStateAction; +import nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.battery.BatteryInfoProfile; +import nodomain.freeyourgadget.gadgetbridge.util.Prefs; + +public class CyclingSensorSupport extends CyclingSensorBaseSupport { + static class CyclingSpeedCadenceMeasurement { + private static final int FLAG_REVOLUTION_DATA_PRESENT = 1 << 0; + private static final int FLAG_CADENCE_DATA_PRESENT = 1 << 1; + public boolean revolutionDataPresent = false; + public boolean cadenceDataPresent = false; + public int revolutionCount; + public int lastRevolutionTimeTicks; + private int crankRevolutionCount; + private int lastCrankRevolutionTimeTicks; + + public static CyclingSpeedCadenceMeasurement fromPayload(byte[] payload) throws RuntimeException { + if(payload.length < 7){ + throw new RuntimeException("wrong payload length"); + } + + ByteBuffer buffer = ByteBuffer + .wrap(payload) + .order(ByteOrder.LITTLE_ENDIAN); + + byte flags = buffer.get(); + + boolean revolutionDataPresent = (flags | FLAG_REVOLUTION_DATA_PRESENT) == FLAG_REVOLUTION_DATA_PRESENT; + boolean cadenceDataPresent = (flags | FLAG_CADENCE_DATA_PRESENT) == FLAG_CADENCE_DATA_PRESENT; + CyclingSpeedCadenceMeasurement result = new CyclingSpeedCadenceMeasurement(); + + if(revolutionDataPresent){ + result.revolutionDataPresent = true; + result.revolutionCount = buffer.getInt() & 0xFFFFFFFF; // remove sign + result.lastRevolutionTimeTicks = buffer.getShort() & 0xFFFF; + } + + if(cadenceDataPresent){ + result.cadenceDataPresent = true; + result.crankRevolutionCount = buffer.getInt(); + result.lastCrankRevolutionTimeTicks = buffer.getShort(); + } + + return result; + } + + @NonNull + @Override + public String toString() { + return String.format("Measurement revolutions: %d, time ticks %d", revolutionCount, lastRevolutionTimeTicks); + } + } + + public final static UUID UUID_CYCLING_SENSOR_SERVICE = + UUID.fromString("00001816-0000-1000-8000-00805f9b34fb"); + public final static UUID UUID_CYCLING_SENSOR_CSC_MEASUREMENT = + UUID.fromString("00002a5b-0000-1000-8000-00805f9b34fb"); + + private static final Logger logger = LoggerFactory.getLogger(CyclingSensorSupport.class); + + private long persistenceInterval; + private long nextPersistenceTimestamp = 0; + + private float wheelCircumference; + + private CyclingSpeedCadenceMeasurement lastReportedMeasurement = null; + private long lastMeasurementTime = 0; + + private BluetoothGattCharacteristic batteryCharacteristic = null; + + public CyclingSensorSupport() { + super(logger); + + addSupportedService(UUID_CYCLING_SENSOR_SERVICE); + addSupportedService(BatteryInfoProfile.SERVICE_UUID); + } + + @Override + public void onSendConfiguration(String config) { + switch (config){ + case DeviceSettingsPreferenceConst.PREF_CYCLING_SENSOR_PERSISTENCE_INTERVAL: + case DeviceSettingsPreferenceConst.PREF_CYCLING_SENSOR_WHEEL_DIAMETER: + loadConfiguration(); + break; + } + } + + private void loadConfiguration(){ + Prefs deviceSpecificPrefs = new Prefs( + GBApplication.getDeviceSpecificSharedPrefs(getDevice().getAddress()) + ); + persistenceInterval = deviceSpecificPrefs.getInt(DeviceSettingsPreferenceConst.PREF_CYCLING_SENSOR_PERSISTENCE_INTERVAL, 60) * 1000; + nextPersistenceTimestamp = 0; + + float wheelDiameter = deviceSpecificPrefs.getFloat(DeviceSettingsPreferenceConst.PREF_CYCLING_SENSOR_WHEEL_DIAMETER, 29); + wheelCircumference = (float)(wheelDiameter * 2.54 * Math.PI) / 100; + } + + @Override + protected TransactionBuilder initializeDevice(TransactionBuilder builder) { + builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.INITIALIZING, getContext())); + + BluetoothGattCharacteristic measurementCharacteristic = + getCharacteristic(UUID_CYCLING_SENSOR_CSC_MEASUREMENT); + + builder.add(new NotifyAction(measurementCharacteristic, true)); + + builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.INITIALIZED, getContext())); + batteryCharacteristic = getCharacteristic(BatteryInfoProfile.UUID_CHARACTERISTIC_BATTERY_LEVEL); + + if(batteryCharacteristic != null){ + builder.add(new ReadAction(batteryCharacteristic)); + } + + loadConfiguration(); + + gbDevice.setFirmwareVersion("1.0.0"); + + return builder; + } + + private void handleMeasurementCharacteristic(BluetoothGattCharacteristic characteristic){ + byte[] value = characteristic.getValue(); + if(value == null || value.length < 7){ + logger.error("Measurement characteristic value length smaller than 7"); + return; + } + + CyclingSpeedCadenceMeasurement measurement = null; + + try { + measurement = CyclingSpeedCadenceMeasurement.fromPayload(value); + }catch (RuntimeException e){ + // do nothing, measurement stays null + } + + if(measurement == null){ + return; + } + + if(!measurement.revolutionDataPresent){ + return; + } + + handleCyclingSpeedMeasurement(measurement); + } + + private void handleCyclingSpeedMeasurement(CyclingSpeedCadenceMeasurement currentMeasurement) { + logger.debug("Measurement " + currentMeasurement); + + long now = System.currentTimeMillis(); + + Float speed = null; + + long lastMeasurementDelta = (now - lastMeasurementTime); + + if(lastMeasurementDelta <= 30_000){ + int ticksPassed = currentMeasurement.lastRevolutionTimeTicks - lastReportedMeasurement.lastRevolutionTimeTicks; + // every second is subdivided in 1024 ticks + int millisDelta = (int)(ticksPassed * (1000f / 1024f)); + + if(millisDelta > 0) { + int revolutionsDelta = currentMeasurement.revolutionCount - lastReportedMeasurement.revolutionCount; + + float revolutionsPerSecond = revolutionsDelta * (1000f / millisDelta); + + speed = revolutionsPerSecond * wheelCircumference; + } + } + + lastReportedMeasurement = currentMeasurement; + lastMeasurementTime = now; + + if(now < nextPersistenceTimestamp){ + // too early + return; + } + + nextPersistenceTimestamp = now + persistenceInterval; + + CyclingSample sample = new CyclingSample(); + + if (currentMeasurement.revolutionDataPresent) { + sample.setRevolutionCount(currentMeasurement.revolutionCount); + sample.setSpeed(speed); + sample.setDistance(currentMeasurement.revolutionCount * wheelCircumference); + } + + sample.setTimestamp(now); + + Intent liveIntent = new Intent(DeviceService.ACTION_REALTIME_SAMPLES); + liveIntent.putExtra(DeviceService.EXTRA_REALTIME_SAMPLE, sample); + LocalBroadcastManager.getInstance(getContext()) + .sendBroadcast(liveIntent); + + try(DBHandler handler = GBApplication.acquireDB()) { + DaoSession session = handler.getDaoSession(); + + CyclingSampleProvider sampleProvider = + new CyclingSampleProvider(getDevice(), session); + + Device databaseDevice = DBHelper.getDevice(getDevice(), session); + User databaseUser = DBHelper.getUser(session); + sample.setDevice(databaseDevice); + sample.setUser(databaseUser); + + sampleProvider.addSample(sample); + } catch (Exception e) { + // throw new RuntimeException(e); + logger.error("failed adding DB cycling sample"); + } + } + + @Override + public boolean onCharacteristicRead(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) { + byte[] value = characteristic.getValue(); + + if(characteristic.equals(batteryCharacteristic) && value != null && value.length == 1){ + GBDeviceEventBatteryInfo info = new GBDeviceEventBatteryInfo(); + info.level = characteristic.getValue()[0]; + handleGBDeviceEvent(info); + } + + return true; + } + + @Override + public boolean onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) { + if(characteristic.getUuid().equals(UUID_CYCLING_SENSOR_CSC_MEASUREMENT)){ + handleMeasurementCharacteristic(characteristic); + return true; + } + return false; + } +} diff --git a/app/src/main/res/layout/fragment_cycling.xml b/app/src/main/res/layout/fragment_cycling.xml new file mode 100644 index 000000000..9eaf93c7a --- /dev/null +++ b/app/src/main/res/layout/fragment_cycling.xml @@ -0,0 +1,16 @@ + + + + + + + + diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index ec81c7c4e..3229c9c26 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -3014,6 +3014,7 @@ + @string/p_cycling @string/p_activity @string/p_activity_list @string/p_sleep diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a8e2c00af..c0b204bd7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2832,4 +2832,14 @@ Huawei Account Huawei account used in pairing process. Setting it allows to pair without factory reset. Watchface resolution doesnt match device screen. Watchface is %1$s device screen is %2$s + Bicycle sensor + Cycling sensor + Cycling speed sensor + Cycling + Wheel diameter in inches. Typically 29, 27,5 or 26. + Wheel diameter + Interval in seconds when the current cycling data should be written to the database + Persistence interval + Today: %.1f km\nTotal: %.1f km + %.1f km/h diff --git a/app/src/main/res/values/values.xml b/app/src/main/res/values/values.xml index 979d1a8de..ae0b4c95c 100644 --- a/app/src/main/res/values/values.xml +++ b/app/src/main/res/values/values.xml @@ -96,6 +96,7 @@ number complete + cycling activity activitylist sleep diff --git a/app/src/main/res/xml/devicesettings_cycling_sensor.xml b/app/src/main/res/xml/devicesettings_cycling_sensor.xml new file mode 100644 index 000000000..21f7b3e1c --- /dev/null +++ b/app/src/main/res/xml/devicesettings_cycling_sensor.xml @@ -0,0 +1,21 @@ + + + + + + + + \ No newline at end of file