1
0
mirror of https://codeberg.org/Freeyourgadget/Gadgetbridge synced 2024-12-28 03:25:49 +01:00

device-cycling-sensor (#3705)

Reviewed-on: https://codeberg.org/Freeyourgadget/Gadgetbridge/pulls/3705
Co-authored-by: Daniel Dakhno <dakhnod@gmail.com>
Co-committed-by: Daniel Dakhno <dakhnod@gmail.com>
This commit is contained in:
Daniel Dakhno 2024-05-10 20:43:45 +00:00 committed by José Rebelo
parent 25b1f8f3fa
commit 3b01422a45
17 changed files with 829 additions and 0 deletions

View File

@ -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();

View File

@ -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);
}

View File

@ -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<CyclingChartFragment.CyclingChartsData>{
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<LineData> {
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<CyclingSample> samples = getSamples(db, device);
return new CyclingChartsDataBuilder(samples).build();
}
protected class CyclingChartsDataBuilder {
private final List<CyclingSample> samples;
private final List<Entry> lineEntries = new ArrayList<>();
long averageSum;
long averageNumSamples;
public CyclingChartsDataBuilder(final List<CyclingSample> samples) {
this.samples = samples;
}
private void reset() {
lineEntries.clear();
averageSum = 0;
averageNumSamples = 0;
}
public CyclingChartsData build() {
List<Entry> distanceEntries = new ArrayList<>();
List<Entry> 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<LegendEntry> 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<CyclingSample> getSamples(final DBHandler db, final GBDevice device) {
final int tsStart = getTSStart();
final int tsEnd = getTSEnd();
final DeviceCoordinator coordinator = device.getDeviceCoordinator();
final TimeSampleProvider<CyclingSample> 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);
}
}
}

View File

@ -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";
}

View File

@ -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);

View File

@ -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<CyclingSample> getCyclingSampleProvider(GBDevice device, DaoSession session) {
return null;
}
@Override
public TimeSampleProvider<? extends HeartRateSample> 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;

View File

@ -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<? extends Spo2Sample> getSpo2SampleProvider(GBDevice device, DaoSession session);
/**
* Returns the sample provider for Cycling data, for the device being supported.
*/
TimeSampleProvider<CyclingSample> getCyclingSampleProvider(GBDevice device, DaoSession session);
/**
* Returns the sample provider for max HR data, for the device being supported.
*/

View File

@ -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<CyclingSample> 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<? extends Activity> 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<? extends DeviceSupport> getDeviceSupportClass() {
return CyclingSensorSupport.class;
}
@Override
public int getDeviceNameResource() {
return R.string.devicetype_cycling_sensor;
}
}

View File

@ -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<CyclingSample> {
public CyclingSampleProvider(GBDevice device, DaoSession session) {
super(device, session);
}
@Override
public AbstractDao<CyclingSample, ?> 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();
}
}

View File

@ -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;

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.gridlayout.widget.GridLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:grid="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
grid:columnCount="2">
<com.github.mikephil.charting.charts.LineChart
android:id="@+id/chart_cycling_history"
grid:layout_columnSpan="2"
grid:layout_columnWeight="1"
grid:layout_rowWeight="1">
</com.github.mikephil.charting.charts.LineChart>
</androidx.gridlayout.widget.GridLayout>

View File

@ -3014,6 +3014,7 @@
</string-array>
<string-array name="pref_charts_tabs_items_default">
<item>@string/p_cycling</item>
<item>@string/p_activity</item>
<item>@string/p_activity_list</item>
<item>@string/p_sleep</item>

View File

@ -2832,4 +2832,14 @@
<string name="pref_title_huawei_account">Huawei Account</string>
<string name="pref_summary_huawei_account">Huawei account used in pairing process. Setting it allows to pair without factory reset.</string>
<string name="watchface_resolution_doesnt_match">Watchface resolution doesnt match device screen. Watchface is %1$s device screen is %2$s</string>
<string name="device_name_bicycle_sensor">Bicycle sensor</string>
<string name="device_name_cycling_sensor">Cycling sensor</string>
<string name="devicetype_cycling_sensor">Cycling speed sensor</string>
<string name="title_cycling">Cycling</string>
<string name="pref_summary_wheel_diameter">Wheel diameter in inches. Typically 29, 27,5 or 26.</string>
<string name="pref_title_wheel_diameter">Wheel diameter</string>
<string name="pref_summary_cycling_persistence_interval">Interval in seconds when the current cycling data should be written to the database</string>
<string name="pref_title_cycling_persistence_interval">Persistence interval</string>
<string name="chart_cycling_point_label_distance">Today: %.1f km\nTotal: %.1f km</string>
<string name="chart_cycling_point_label_speed">%.1f km/h</string>
</resources>

View File

@ -96,6 +96,7 @@
<item name="p_call_privacy_mode_number" type="string">number</item>
<item name="p_call_privacy_mode_complete" type="string">complete</item>
<item name="p_cycling" type="string">cycling</item>
<item name="p_activity" type="string">activity</item>
<item name="p_activity_list" type="string">activitylist</item>
<item name="p_sleep" type="string">sleep</item>

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.preference.PreferenceScreen
xmlns:android="http://schemas.android.com/apk/res/android">
<EditTextPreference
android:defaultValue="29"
android:inputType="number"
android:title="@string/pref_title_wheel_diameter"
android:summary="@string/pref_summary_wheel_diameter"
android:icon="@drawable/ic_activity_biking"
android:key="pref_cycling_wheel_diameter" />
<EditTextPreference
android:defaultValue="60"
android:inputType="number"
android:title="@string/pref_title_cycling_persistence_interval"
android:summary="@string/pref_summary_cycling_persistence_interval"
android:icon="@drawable/ic_activity_biking"
android:key="pref_cycling_persistence_interval" />
</androidx.preference.PreferenceScreen>