Zepp OS: Implement workout fetching

This commit is contained in:
José Rebelo 2022-09-18 12:19:23 +01:00 committed by Gitea
parent 7f4bd16914
commit d1ae6cf225
34 changed files with 1140 additions and 133 deletions

View File

@ -43,7 +43,7 @@ public class GBDaoGenerator {
public static void main(String[] args) throws Exception {
final Schema schema = new Schema(43, MAIN_PACKAGE + ".entities");
final Schema schema = new Schema(44, MAIN_PACKAGE + ".entities");
Entity userAttributes = addUserAttributes(schema);
Entity user = addUserInfo(schema, userAttributes);
@ -638,6 +638,7 @@ public class GBDaoGenerator {
summary.addIntProperty("baseAltitude").javaDocGetterAndSetter("Temporary, bip-specific");
summary.addStringProperty("gpxTrack").codeBeforeGetter(OVERRIDE);
summary.addStringProperty("rawDetailsPath");
Property deviceId = summary.addLongProperty("deviceId").notNull().codeBeforeGetter(OVERRIDE).getProperty();
summary.addToOne(device, deviceId);

View File

@ -336,6 +336,17 @@ tasks.withType(SpotBugsTask) {
}
}
task cleanGenerated(type: Delete) {
delete fileTree('src/main/java/nodomain/freeyourgadget/gadgetbridge/proto') {
include '**/*.java'
}
delete fileTree('src/main/java/nodomain/freeyourgadget/gadgetbridge/entities') {
include '**/*.java'
exclude '**/Abstract*.java'
}
}
tasks.clean.dependsOn(tasks.cleanGenerated)
protobuf {
protoc {
@ -344,7 +355,12 @@ protobuf {
generateProtoTasks {
all().each { task ->
task.builtins {
java { option 'lite' }
java {
option 'lite'
// Uncomment this to get Android Studio to recognize the generated files
// this makes it think that all src files are generated though...
//outputSubDir = '../../../../../src/main/java/'
}
}
}
}

View File

@ -17,9 +17,11 @@
along with this program. If not, see <http://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.activities;
import android.app.AlertDialog;
import android.app.DatePickerDialog;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.SharedPreferences;
@ -232,13 +234,25 @@ public class ActivitySummariesActivity extends AbstractListActivity<BaseActivity
SparseBooleanArray checked = getItemListView().getCheckedItemPositions();
switch (menuItem.getItemId()) {
case R.id.activity_action_delete:
List<BaseActivitySummary> toDelete = new ArrayList<>();
final List<BaseActivitySummary> toDelete = new ArrayList<>();
for (int i = 0; i < checked.size(); i++) {
if (checked.valueAt(i)) {
toDelete.add(getItemAdapter().getItem(checked.keyAt(i)));
}
}
deleteItems(toDelete);
new AlertDialog.Builder(ActivitySummariesActivity.this)
.setTitle(ActivitySummariesActivity.this.getString(R.string.sports_activity_confirm_delete_title, toDelete.size()))
.setMessage(ActivitySummariesActivity.this.getString(R.string.sports_activity_confirm_delete_description, toDelete.size()))
.setIcon(R.drawable.ic_delete_forever)
.setPositiveButton(android.R.string.yes, new DialogInterface.OnClickListener() {
public void onClick(final DialogInterface dialog, final int whichButton) {
deleteItems(toDelete);
}
})
.setNegativeButton(android.R.string.no, null)
.show();
processed = true;
break;
case R.id.activity_action_export:

View File

@ -73,6 +73,7 @@ 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.DeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary;
import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
import nodomain.freeyourgadget.gadgetbridge.entities.Device;
@ -80,8 +81,10 @@ import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryItems;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryJsonSummary;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryParser;
import nodomain.freeyourgadget.gadgetbridge.util.AndroidUtils;
import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
import nodomain.freeyourgadget.gadgetbridge.util.DeviceHelper;
import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
import nodomain.freeyourgadget.gadgetbridge.util.SwipeEvents;
@ -414,13 +417,16 @@ public class ActivitySummaryDetail extends AbstractGBActivity {
}
private void makeSummaryContent(BaseActivitySummary item) {
final DeviceCoordinator coordinator = DeviceHelper.getInstance().getCoordinator(gbDevice);
final ActivitySummaryParser summaryParser = coordinator.getActivitySummaryParser(gbDevice);
//make view of data from summaryData of item
String units = GBApplication.getPrefs().getString(SettingsActivity.PREF_MEASUREMENT_SYSTEM, GBApplication.getContext().getString(R.string.p_unit_metric));
String UNIT_IMPERIAL = GBApplication.getContext().getString(R.string.p_unit_imperial);
TableLayout fieldLayout = findViewById(R.id.summaryDetails);
fieldLayout.removeAllViews(); //remove old widgets
ActivitySummaryJsonSummary activitySummaryJsonSummary = new ActivitySummaryJsonSummary(item);
ActivitySummaryJsonSummary activitySummaryJsonSummary = new ActivitySummaryJsonSummary(summaryParser, item);
JSONObject data = activitySummaryJsonSummary.getSummaryGroupedList(); //get list, grouped by groups
if (data == null) return;

View File

@ -874,7 +874,11 @@ public class DeviceSpecificSettingsFragment extends PreferenceFragmentCompat imp
if (supportedLanguages != null) {
supportedSettings = ArrayUtils.insert(0, supportedSettings, R.xml.devicesettings_language_generic);
}
supportedSettings = ArrayUtils.addAll(supportedSettings, coordinator.getSupportedDeviceSpecificAuthenticationSettings());
final int[] supportedAuthSettings = coordinator.getSupportedDeviceSpecificAuthenticationSettings();
if (supportedAuthSettings != null && supportedAuthSettings.length > 0) {
supportedSettings = ArrayUtils.add(supportedSettings, R.xml.devicesettings_header_authentication);
supportedSettings = ArrayUtils.addAll(supportedSettings, supportedAuthSettings);
}
}
final DeviceSpecificSettingsCustomizer deviceSpecificSettingsCustomizer = coordinator.getDeviceSpecificSettingsCustomizer(device);

View File

@ -42,13 +42,16 @@ import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.activities.SettingsActivity;
import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary;
import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummaryDao;
import nodomain.freeyourgadget.gadgetbridge.entities.Device;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryJsonSummary;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryParser;
import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
import nodomain.freeyourgadget.gadgetbridge.util.DeviceHelper;
import nodomain.freeyourgadget.gadgetbridge.util.FormatUtils;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
@ -184,6 +187,8 @@ public class ActivitySummariesAdapter extends AbstractActivityListingAdapter<Bas
ImageView activityIconView = view.findViewById(R.id.summary_dashboard_layout_activity_icon);
ImageView activityIconBigView = view.findViewById(R.id.summary_dashboard_layout_big_activity_icon);
final DeviceCoordinator coordinator = DeviceHelper.getInstance().getCoordinator(device);
for (BaseActivitySummary sportitem : getItems()) {
if (sportitem.getStartTime() == null) continue; //first item is empty, for dashboard
@ -199,8 +204,8 @@ public class ActivitySummariesAdapter extends AbstractActivityListingAdapter<Bas
}
}
ActivitySummaryJsonSummary activitySummaryJsonSummary = new ActivitySummaryJsonSummary(sportitem);
final ActivitySummaryParser summaryParser = coordinator.getActivitySummaryParser(device);
final ActivitySummaryJsonSummary activitySummaryJsonSummary = new ActivitySummaryJsonSummary(summaryParser, sportitem);
JSONObject summarySubdata = activitySummaryJsonSummary.getSummaryData();
if (summarySubdata != null) {

View File

@ -0,0 +1,38 @@
/* Copyright (C) 2022 José Rebelo
This file is part of Gadgetbridge.
Gadgetbridge is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Gadgetbridge is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.database.schema;
import android.database.sqlite.SQLiteDatabase;
import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
import nodomain.freeyourgadget.gadgetbridge.database.DBUpdateScript;
import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummaryDao;
public class GadgetbridgeUpdate_44 implements DBUpdateScript {
@Override
public void upgradeSchema(final SQLiteDatabase db) {
if (!DBHelper.existsColumn(BaseActivitySummaryDao.TABLENAME, BaseActivitySummaryDao.Properties.RawDetailsPath.columnName, db)) {
final String statement = "ALTER TABLE " + BaseActivitySummaryDao.TABLENAME + " ADD COLUMN "
+ BaseActivitySummaryDao.Properties.RawDetailsPath.columnName + " TEXT";
db.execSQL(statement);
}
}
@Override
public void downgradeSchema(final SQLiteDatabase db) {
}
}

View File

@ -46,6 +46,7 @@ import nodomain.freeyourgadget.gadgetbridge.capabilities.HeartRateCapability;
import nodomain.freeyourgadget.gadgetbridge.capabilities.password.PasswordCapabilityImpl;
import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiActivitySummaryParser;
import nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandConst;
import nodomain.freeyourgadget.gadgetbridge.entities.AlarmDao;
import nodomain.freeyourgadget.gadgetbridge.entities.BatteryLevelDao;
@ -54,6 +55,7 @@ import nodomain.freeyourgadget.gadgetbridge.entities.Device;
import nodomain.freeyourgadget.gadgetbridge.entities.DeviceAttributesDao;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryParser;
import nodomain.freeyourgadget.gadgetbridge.model.BatteryConfig;
import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
@ -150,6 +152,12 @@ public abstract class AbstractDeviceCoordinator implements DeviceCoordinator {
return device.isInitialized() && !device.isBusy() && supportsActivityDataFetching();
}
@Override
@Nullable
public ActivitySummaryParser getActivitySummaryParser(final GBDevice device) {
return null;
}
public boolean isHealthWearable(BluetoothDevice device) {
BluetoothClass bluetoothClass = device.getBluetoothClass();
if (bluetoothClass == null) {

View File

@ -40,6 +40,7 @@ import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryParser;
import nodomain.freeyourgadget.gadgetbridge.model.BatteryConfig;
import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
@ -215,6 +216,13 @@ public interface DeviceCoordinator {
*/
SampleProvider<? extends ActivitySample> getSampleProvider(GBDevice device, DaoSession session);
/**
* Returns the {@link ActivitySummaryParser} for the device being supported.
*
* @return
*/
ActivitySummaryParser getActivitySummaryParser(final GBDevice device);
/**
* Returns true if this device/coordinator supports installing files like firmware,
* watchfaces, gps, resources, fonts...

View File

@ -0,0 +1,123 @@
/* Copyright (C) 2022 José Rebelo
This file is part of Gadgetbridge.
Gadgetbridge is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Gadgetbridge is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.devices.huami;
import com.google.protobuf.InvalidProtocolBufferException;
import org.apache.commons.lang3.ArrayUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.Date;
import nodomain.freeyourgadget.gadgetbridge.proto.HuamiProtos;
import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary;
import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.AbstractHuamiActivityDetailsParser;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021ActivityDetailsParser;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021WorkoutTrackActivityType;
public class Huami2021ActivitySummaryParser extends HuamiActivitySummaryParser {
private static final Logger LOG = LoggerFactory.getLogger(Huami2021ActivitySummaryParser.class);
@Override
public AbstractHuamiActivityDetailsParser getDetailsParser(final BaseActivitySummary summary) {
return new Huami2021ActivityDetailsParser(summary);
}
@Override
protected void parseBinaryData(final BaseActivitySummary summary, final Date startTime) {
final byte[] rawData = summary.getRawSummaryData();
final int version = (rawData[0] & 0xff) | ((rawData[1] & 0xff) << 8);
if (version != 0x8000) {
LOG.warn("Unexpected binary data version {}, parsing might fail", version);
}
final byte[] protobufData = ArrayUtils.subarray(rawData, 2, rawData.length);
final HuamiProtos.WorkoutSummary summaryProto;
try {
summaryProto = HuamiProtos.WorkoutSummary.parseFrom(protobufData);
} catch (final InvalidProtocolBufferException e) {
LOG.error("Failed to parse summary protobuf data", e);
return;
}
if (summaryProto.hasType()) {
final Huami2021WorkoutTrackActivityType workoutTrackActivityType = Huami2021WorkoutTrackActivityType
.fromCode((byte) summaryProto.getType().getType());
final int activityKind;
if (workoutTrackActivityType != null) {
activityKind = workoutTrackActivityType.toActivityKind();
} else {
LOG.warn("Unknown workout activity type code {}", String.format("0x%X", summaryProto.getType().getType()));
activityKind = ActivityKind.TYPE_UNKNOWN;
}
summary.setActivityKind(activityKind);
}
if (summaryProto.hasTime()) {
int totalDuration = summaryProto.getTime().getTotalDuration();
summary.setEndTime(new Date(startTime.getTime() + totalDuration * 1000L));
addSummaryData("activeSeconds", summaryProto.getTime().getWorkoutDuration(), "seconds");
// TODO pause durations
}
if (summaryProto.hasLocation()) {
summary.setBaseLongitude(summaryProto.getLocation().getBaseLongitude());
summary.setBaseLatitude(summaryProto.getLocation().getBaseLatitude());
summary.setBaseAltitude(summaryProto.getLocation().getBaseAltitude() / 2);
// TODO: Min/Max Latitude/Longitude
}
if (summaryProto.hasHeartRate()) {
addSummaryData("averageHR", summaryProto.getHeartRate().getAvg(), "bpm");
addSummaryData("maxHR", summaryProto.getHeartRate().getMax(), "bpm");
addSummaryData("minHR", summaryProto.getHeartRate().getMin(), "bpm");
}
if (summaryProto.hasSteps()) {
addSummaryData("maxCadence", summaryProto.getSteps().getMaxCadence() * 60, "spm");
addSummaryData("averageCadence", summaryProto.getSteps().getAvgCadence() * 60, "spm");
addSummaryData("averageStride", summaryProto.getSteps().getAvgStride(), "cm");
addSummaryData("steps", summaryProto.getSteps().getSteps(), "steps_unit");
}
if (summaryProto.hasDistance()) {
addSummaryData("distanceMeters", summaryProto.getDistance().getDistance(), "meters");
}
if (summaryProto.hasPace()) {
addSummaryData("maxPace", summaryProto.getPace().getBest(), "seconds_m");
addSummaryData("averageKMPaceSeconds", summaryProto.getPace().getAvg() * 1000, "seconds_km");
}
if (summaryProto.hasCalories()) {
addSummaryData("caloriesBurnt", summaryProto.getCalories().getCalories(), "calories_unit");
}
if (summaryProto.hasHeartRateZones()) {
// TODO HR zones
}
if (summaryProto.hasTrainingEffect()) {
// TODO training effect
}
}
}

View File

@ -33,6 +33,7 @@ import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
import nodomain.freeyourgadget.gadgetbridge.entities.Device;
import nodomain.freeyourgadget.gadgetbridge.entities.HuamiExtendedActivitySampleDao;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryParser;
public abstract class Huami2021Coordinator extends HuamiCoordinator {
@Override
@ -68,8 +69,7 @@ public abstract class Huami2021Coordinator extends HuamiCoordinator {
@Override
public boolean supportsActivityTracks() {
// TODO: It's supported by the devices, but not yet implemented
return false;
return true;
}
@Override
@ -102,6 +102,11 @@ public abstract class Huami2021Coordinator extends HuamiCoordinator {
return new HuamiExtendedSampleProvider(device, session);
}
@Override
public ActivitySummaryParser getActivitySummaryParser(final GBDevice device) {
return new Huami2021ActivitySummaryParser();
}
@Override
public boolean supportsAlarmSnoozing() {
// All alarms snooze by default, there doesn't seem to be a flag that disables it
@ -194,6 +199,9 @@ public abstract class Huami2021Coordinator extends HuamiCoordinator {
R.xml.devicesettings_expose_hr_thirdparty,
R.xml.devicesettings_bt_connected_advertisement,
R.xml.devicesettings_high_mtu,
R.xml.devicesettings_header_developer,
R.xml.devicesettings_keep_activity_data_on_device,
};
}

View File

@ -30,6 +30,7 @@ import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary;
import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryParser;
import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.AbstractHuamiActivityDetailsParser;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiActivityDetailsParser;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiSportsActivityType;
@ -45,11 +46,17 @@ public class HuamiActivitySummaryParser implements ActivitySummaryParser {
LOG.error("Due to a bug, we can only parse the summary when startTime is already set");
return null;
}
return parseBinaryData(summary, startTime);
summaryData = new JSONObject();
parseBinaryData(summary, startTime);
summary.setSummaryData(summaryData.toString());
return summary;
}
private BaseActivitySummary parseBinaryData(BaseActivitySummary summary, Date startTime) {
summaryData = new JSONObject();
public AbstractHuamiActivityDetailsParser getDetailsParser(final BaseActivitySummary summary) {
return new HuamiActivityDetailsParser(summary);
}
protected void parseBinaryData(BaseActivitySummary summary, Date startTime) {
ByteBuffer buffer = ByteBuffer.wrap(summary.getRawSummaryData()).order(ByteOrder.LITTLE_ENDIAN);
short version = buffer.getShort(); // version
@ -372,13 +379,9 @@ public class HuamiActivitySummaryParser implements ActivitySummaryParser {
addSummaryData("swimStyle", swimStyleName);
addSummaryData("laps", laps, "laps");
}
summary.setSummaryData(summaryData.toString());
return summary;
}
private void addSummaryData(String key, float value, String unit) {
protected void addSummaryData(String key, float value, String unit) {
if (value > 0) {
try {
JSONObject innerData = new JSONObject();
@ -390,7 +393,7 @@ public class HuamiActivitySummaryParser implements ActivitySummaryParser {
}
}
private void addSummaryData(String key, String value) {
protected void addSummaryData(String key, String value) {
if (key != null && !key.equals("") && value != null && !value.equals("")) {
try {
JSONObject innerData = new JSONObject();

View File

@ -59,6 +59,7 @@ import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
import nodomain.freeyourgadget.gadgetbridge.entities.Device;
import nodomain.freeyourgadget.gadgetbridge.entities.MiBandActivitySampleDao;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryParser;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiVibrationPatternNotificationType;
import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
@ -140,6 +141,11 @@ public abstract class HuamiCoordinator extends AbstractBLEDeviceCoordinator {
return new MiBand2SampleProvider(device, session);
}
@Override
public ActivitySummaryParser getActivitySummaryParser(final GBDevice device) {
return new HuamiActivitySummaryParser();
}
public static DateTimeDisplay getDateDisplay(Context context, String deviceAddress) throws IllegalArgumentException {
SharedPreferences sharedPrefs = GBApplication.getDeviceSpecificSharedPrefs(deviceAddress);
String dateFormatTime = context.getString(R.string.p_dateformat_time);
@ -352,6 +358,11 @@ public abstract class HuamiCoordinator extends AbstractBLEDeviceCoordinator {
return prefs.getBoolean("overwrite_settings_on_connection", true);
}
public static boolean getKeepActivityDataOnDevice(String deviceAddress) {
Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(deviceAddress));
return prefs.getBoolean("keep_activity_data_on_device", false);
}
public static VibrationProfile getVibrationProfile(String deviceAddress, HuamiVibrationPatternNotificationType notificationType) {
final String defaultVibrationProfileId;
final int defaultVibrationCount;

View File

@ -115,8 +115,9 @@ public class HuamiService {
// maybe not really activity data, but steps?
public static final byte COMMAND_FETCH_DATA = 0x02;
// maybe delete/drop activity data?
// on Huami it's just 03 / on Huami 2021 it's 03:09
// delete/drop activity data
// on Huami it's just the single 03 byte
// on Huami 2021 it's followed by 09 to keep, 01 to drop from device
public static final byte COMMAND_ACK_ACTIVITY_DATA = 0x03;
public static final byte[] COMMAND_SET_FITNESS_GOAL_START = new byte[] { 0x10, 0x0, 0x0 };
@ -230,13 +231,6 @@ public class HuamiService {
public static final byte COMMAND_FIRMWARE_CHECKSUM = 0x04; // to UUID_CHARACTERISTIC_FIRMWARE
public static final byte COMMAND_FIRMWARE_REBOOT = 0x05; // to UUID_CHARACTERISTIC_FIRMWARE
public static final byte[] RESPONSE_FINISH_SUCCESS = new byte[] {RESPONSE, 2, SUCCESS };
public static final byte[] RESPONSE_ACK_SUCCESS = new byte[] {RESPONSE, 3, SUCCESS };
public static final byte[] RESPONSE_FIRMWARE_DATA_SUCCESS = new byte[] {RESPONSE, COMMAND_FIRMWARE_START_DATA, SUCCESS };
/**
* Received in response to any dateformat configuration request (byte 0 in the byte[] value.
*/
public static final byte[] RESPONSE_DATEFORMAT_SUCCESS = new byte[] { RESPONSE, ENDPOINT_DISPLAY, 0x0a, 0x0, 0x01 };
public static final byte[] RESPONSE_ACTIVITY_DATA_START_DATE_SUCCESS = new byte[] { RESPONSE, COMMAND_ACTIVITY_DATA_START_DATE, SUCCESS};
public static final byte[] WEAR_LOCATION_LEFT_WRIST = new byte[] { 0x20, 0x00, 0x00, 0x02 };

View File

@ -33,7 +33,7 @@ import nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLEDeviceSuppo
import nodomain.freeyourgadget.gadgetbridge.service.btle.GattService;
import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder;
import nodomain.freeyourgadget.gadgetbridge.service.btle.actions.SetDeviceStateAction;
import nodomain.freeyourgadget.gadgetbridge.SMAQ2OSSProtos;
import nodomain.freeyourgadget.gadgetbridge.proto.SMAQ2OSSProtos;
import nodomain.freeyourgadget.gadgetbridge.util.NotificationUtils;
import nodomain.freeyourgadget.gadgetbridge.util.StringUtils;

View File

@ -16,14 +16,9 @@
along with this program. If not, see <http://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.model;
import org.json.JSONObject;
import java.io.Serializable;
import java.util.Date;
import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiActivitySummaryParser;
import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary;
/**
* Summarized information about a temporal activity.
*

View File

@ -16,9 +16,11 @@ public class ActivitySummaryJsonSummary {
private JSONObject groupData;
private JSONObject summaryData;
private JSONObject summaryGroupedList;
private ActivitySummaryParser summaryParser;
private BaseActivitySummary baseActivitySummary;
public ActivitySummaryJsonSummary(BaseActivitySummary baseActivitySummary){
public ActivitySummaryJsonSummary(final ActivitySummaryParser summaryParser, BaseActivitySummary baseActivitySummary){
this.summaryParser=summaryParser;
this.baseActivitySummary=baseActivitySummary;
}
@ -67,8 +69,7 @@ public class ActivitySummaryJsonSummary {
private String getCorrectSummary(BaseActivitySummary item){
if (item.getRawSummaryData() != null) {
ActivitySummaryParser parser = new HuamiActivitySummaryParser(); // FIXME: if something else than huami supports that make sure to have the right parser
item = parser.parseBinaryData(item);
item = summaryParser.parseBinaryData(item);
}
return item.getSummaryData();
}

View File

@ -0,0 +1,2 @@
# This folder will contain auto-generated protobuf classes
*.java

View File

@ -149,6 +149,10 @@ public class BLETypeConversions {
return (bytes[0] & 0xff) | ((bytes[1] & 0xff) << 8);
}
public static int toUint16(byte[] bytes, int offset) {
return (bytes[offset + 0] & 0xff) | ((bytes[offset + 1] & 0xff) << 8);
}
public static int toInt16(byte... bytes) {
return (short) (bytes[0] & 0xff | ((bytes[1] & 0xff) << 8));
}

View File

@ -0,0 +1,48 @@
/* Copyright (C) 2017-2021 Andreas Shimokawa, AndrewH, Carsten Pfeiffer,
szilardx, José Rebelo
This file is part of Gadgetbridge.
Gadgetbridge is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Gadgetbridge is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.service.devices.huami;
import java.math.BigDecimal;
import java.math.RoundingMode;
import nodomain.freeyourgadget.gadgetbridge.GBException;
import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary;
import nodomain.freeyourgadget.gadgetbridge.model.ActivityTrack;
import nodomain.freeyourgadget.gadgetbridge.model.GPSCoordinate;
public abstract class AbstractHuamiActivityDetailsParser {
private static final BigDecimal HUAMI_TO_DECIMAL_DEGREES_DIVISOR = new BigDecimal("3000000.0");
public abstract ActivityTrack parse(final byte[] bytes) throws GBException;
public static double convertHuamiValueToDecimalDegrees(final long huamiValue) {
BigDecimal result = new BigDecimal(huamiValue)
.divide(HUAMI_TO_DECIMAL_DEGREES_DIVISOR, GPSCoordinate.GPS_DECIMAL_DEGREES_SCALE, RoundingMode.HALF_UP);
return result.doubleValue();
}
protected static String createActivityName(final BaseActivitySummary summary) {
String name = summary.getName();
String nameText = "";
Long id = summary.getId();
if (name != null) {
nameText = name + " - ";
}
return nameText + id;
}
}

View File

@ -0,0 +1,351 @@
/* Copyright (C) 2022 José Rebelo
This file is part of Gadgetbridge.
Gadgetbridge is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Gadgetbridge is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.service.devices.huami;
import androidx.annotation.Nullable;
import org.apache.commons.lang3.ArrayUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.TimeZone;
import nodomain.freeyourgadget.gadgetbridge.GBException;
import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary;
import nodomain.freeyourgadget.gadgetbridge.model.ActivityPoint;
import nodomain.freeyourgadget.gadgetbridge.model.ActivityTrack;
import nodomain.freeyourgadget.gadgetbridge.model.GPSCoordinate;
public class Huami2021ActivityDetailsParser extends AbstractHuamiActivityDetailsParser {
private static final Logger LOG = LoggerFactory.getLogger(Huami2021ActivityDetailsParser.class);
private static final SimpleDateFormat SDF = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS", Locale.US);
static {
SDF.setTimeZone(TimeZone.getTimeZone("UTC"));
}
private Date timestamp;
private long offset = 0;
private long longitude;
private long latitude;
private double altitude;
private final ActivityTrack activityTrack;
private ActivityPoint lastActivityPoint;
public Huami2021ActivityDetailsParser(final BaseActivitySummary summary) {
this.timestamp = summary.getStartTime();
this.longitude = summary.getBaseLongitude();
this.latitude = summary.getBaseLatitude();
this.altitude = summary.getBaseAltitude();
this.activityTrack = new ActivityTrack();
this.activityTrack.setUser(summary.getUser());
this.activityTrack.setDevice(summary.getDevice());
this.activityTrack.setName(createActivityName(summary));
}
@Override
public ActivityTrack parse(final byte[] bytes) throws GBException {
final ByteBuffer buf = ByteBuffer.wrap(bytes)
.order(ByteOrder.LITTLE_ENDIAN);
// Keep track of unknown type codes so we can print them without spamming the logs
final Map<Byte, Integer> unknownTypeCodes = new HashMap<>();
while (buf.position() < buf.limit()) {
final byte typeCode = buf.get();
final byte length = buf.get();
final int initialPosition = buf.position();
final Type type = Type.fromCode(typeCode);
if (type == null) {
if (!unknownTypeCodes.containsKey(typeCode)) {
unknownTypeCodes.put(typeCode, 0);
}
unknownTypeCodes.put(typeCode, unknownTypeCodes.get(typeCode) + 1);
//LOG.warn("Unknown type code {} of length {}", String.format("0x%X", typeCode), length);
// Consume the reported length
buf.get(new byte[length]);
continue;
} else if (length != type.getExpectedLength()) {
LOG.warn("Unexpected length {} for type {}", length, type);
// Consume the reported length
buf.get(new byte[length]);
continue;
}
// Consume
switch (type) {
case TIMESTAMP:
consumeTimestamp(buf);
break;
case GPS_COORDS:
consumeGpsCoords(buf);
break;
case GPS_DELTA:
consumeGpsDelta(buf);
break;
case STATUS:
consumeStatus(buf);
break;
case SPEED:
consumeSpeed(buf);
break;
case ALTITUDE:
consumeAltitude(buf);
break;
case HEARTRATE:
consumeHeartRate(buf);
break;
default:
LOG.warn("No consumer for for type {}", type);
// Consume the reported length
buf.get(new byte[length]);
continue;
}
final int expectedPosition = initialPosition + length;
if (buf.position() != expectedPosition) {
// Should never happen unless there's a bug in one of the consumers
throw new IllegalStateException("Unexpected position " + buf.position() + ", expected " + expectedPosition + ", after consuming " + type);
}
}
if (!unknownTypeCodes.isEmpty()) {
for (final Map.Entry<Byte, Integer> e : unknownTypeCodes.entrySet()) {
LOG.warn("Unknown type code {} seen {} times", String.format("0x%X", e.getKey()), e.getValue());
}
}
return this.activityTrack;
}
private void consumeTimestamp(final ByteBuffer buf) {
buf.getInt(); // ?
this.timestamp = new Date(buf.getLong());
this.offset = 0;
//trace("Consumed timestamp");
}
private void consumeTimestampOffset(final ByteBuffer buf) {
this.offset = buf.getShort();
}
private void consumeGpsCoords(final ByteBuffer buf) {
buf.get(new byte[6]); // ?
this.longitude = buf.getInt();
this.latitude = buf.getInt();
buf.get(new byte[6]); // ?
// TODO which one is the time offset? Not sure it is the first
addNewGpsCoordinates();
final double longitudeDeg = convertHuamiValueToDecimalDegrees(longitude);
final double latitudeDeg = convertHuamiValueToDecimalDegrees(latitude);
//trace("Consumed GPS coords: {} {}", longitudeDeg, latitudeDeg);
}
private void consumeGpsDelta(final ByteBuffer buf) {
consumeTimestampOffset(buf);
final short longitudeDelta = buf.getShort();
final short latitudeDelta = buf.getShort();
buf.getShort(); // ? seems to always be 2
this.longitude += longitudeDelta;
this.latitude += latitudeDelta;
if (lastActivityPoint == null) {
final String timestampStr = SDF.format(new Date(timestamp.getTime() + offset));
LOG.warn("{}: Got GPS delta before GPS coords, ignoring", timestampStr);
return;
}
addNewGpsCoordinates();
//trace("Consumed GPS delta: {} {}", longitudeDelta, latitudeDelta);
}
private void consumeStatus(final ByteBuffer buf) {
consumeTimestampOffset(buf);
final int statusCode = buf.getShort();
final String status;
switch (statusCode) {
case 1:
status = "start";
break;
case 4:
status = "pause";
break;
case 5:
status = "resume";
break;
case 6:
status = "stop";
break;
default:
status = String.format("unknown (0x%X)", statusCode);
LOG.warn("Unknown status code {}", String.format("0x%X", statusCode));
}
// TODO split track into multiple segments?
//trace("Consumed Status: {}", status);
}
private void consumeSpeed(final ByteBuffer buf) {
consumeTimestampOffset(buf);
final short cadence = buf.getShort(); // spm
final short stride = buf.getShort(); // cm
final short pace = buf.getShort(); // sec/km
// TODO integrate into gpx
//trace("Consumed speed: cadence={}, stride={}, ?={}", cadence, stride, );
}
private void consumeAltitude(final ByteBuffer buf) {
consumeTimestampOffset(buf);
altitude = (int) (buf.getInt() / 100.0f);
final ActivityPoint ap = getCurrentActivityPoint();
if (ap != null) {
final GPSCoordinate newCoordinate = new GPSCoordinate(
ap.getLocation().getLongitude(),
ap.getLocation().getLatitude(),
altitude
);
ap.setLocation(newCoordinate);
}
//trace("Consumed altitude: {}", altitude);
}
private void consumeHeartRate(final ByteBuffer buf) {
consumeTimestampOffset(buf);
final int heartRate = buf.get() & 0xff;
final ActivityPoint ap = getCurrentActivityPoint();
if (ap != null) {
ap.setHeartRate(heartRate);
}
//trace("Consumed HeartRate: {}", heartRate);
}
@Nullable
private ActivityPoint getCurrentActivityPoint() {
if (lastActivityPoint == null) {
return null;
}
// Round to the nearest second
final long currentTime = timestamp.getTime() + offset;
if (currentTime - lastActivityPoint.getTime().getTime() > 500) {
addNewGpsCoordinates();
return lastActivityPoint;
}
return lastActivityPoint;
}
private void addNewGpsCoordinates() {
final GPSCoordinate coordinate = new GPSCoordinate(
convertHuamiValueToDecimalDegrees(longitude),
convertHuamiValueToDecimalDegrees(latitude),
altitude
);
if (lastActivityPoint != null && lastActivityPoint.getLocation() != null && lastActivityPoint.getLocation().equals(coordinate)) {
// Ignore repeated location
return;
}
final ActivityPoint ap = new ActivityPoint(new Date(timestamp.getTime() + offset));
ap.setLocation(coordinate);
add(ap);
}
private void add(final ActivityPoint ap) {
if (ap == lastActivityPoint) {
LOG.debug("skipping point!");
return;
}
lastActivityPoint = ap;
activityTrack.addTrackPoint(ap);
}
private void trace(final String format, final Object... args) {
final Object[] argsWithDate = ArrayUtils.insert(0, args, SDF.format(new Date(timestamp.getTime() + offset)));
LOG.debug("{}: " + format, argsWithDate);
}
private enum Type {
TIMESTAMP(1, 12),
GPS_COORDS(2, 20),
GPS_DELTA(3, 8),
STATUS(4, 4),
SPEED(5, 8),
ALTITUDE(7, 6),
HEARTRATE(8, 3),
;
private final byte code;
private final int expectedLength;
Type(final int code, final int expectedLength) {
this.code = (byte) code;
this.expectedLength = expectedLength;
}
public byte getCode() {
return this.code;
}
public int getExpectedLength() {
return this.expectedLength;
}
public static Type fromCode(final byte code) {
for (final Type type : values()) {
if (type.getCode() == code) {
return type;
}
}
return null;
}
}
}

View File

@ -25,22 +25,123 @@ import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
* The workout types, used to start / when workout tracking starts on the band.
*/
public enum Huami2021WorkoutTrackActivityType {
// TODO 150 workouts :/
AerobicCombo(0x33),
Aerobics(0x6d),
AirWalker(0x90),
Archery(0x5d),
ArtisticSwimming(0x9c),
Badminton(0x5c),
Ballet(0x47),
BallroomDance(0x4b),
Baseball(0x4f),
Basketball(0x55),
BattleRope(0xa7),
BeachVolleyball(0x7a),
BellyDance(0x48),
Billiards(0x97),
bmx(0x30),
BoardGame(0xb1),
Bocce(0xaa),
Bowling(0x50),
Boxing(0x61),
Breaking(0xa8),
Bridge(0xb0),
CardioCombat(0x72),
Checkers(0xae),
Chess(0xad),
CoreTraining(0x32),
Cricket(0x4e),
CrossTraining(0x82),
Curling(0x29),
Dance(0x4c),
Darts(0x75),
Dodgeball(0x99),
DragonBoat(0x8a),
Elliptical(0x09),
Esports(0xbd),
Esquestrian(0x5e),
Fencing(0x94),
Finswimming(0x9b),
Fishing(0x40),
Flexibility(0x37),
Flowriding(0xac),
FolkDance(0x92),
Freestyle(0x05),
Frisbee(0x74),
Futsal(0xa4),
Gateball(0x57),
Gymnastics(0x3b),
HackySack(0xa9),
Handball(0x5b),
HIIT(0x31),
HipHop(0xa5),
HorizontalBar(0x95),
HulaHoop(0x73),
IceHockey(0x9e),
IceSkating(0x2c),
IndoorCycling(0x08),
IndoorFitness(0x18),
IndoorIceSkating(0x2d),
JaiAlai(0xab),
JazzDance(0x71),
Judo(0x62),
Jujitsu(0x93),
JumpRope(0x15),
Karate(0x60),
Kayaking(0x8c),
Kendo(0x5f),
Kickboxing(0x68),
KiteFlying(0x76),
LatinDance(0x70),
MartialArts(0x67),
MassGymnastics(0x6f),
ModernDance(0xb9),
MuayThai(0x65),
OutdoorCycling(0x04),
OutdoorRunning(0x01),
ParallelBars(0x96),
Parkour(0x81),
Pilates(0x3d),
PoleDance(0xa6),
PoolSwimming(0x06),
RaceWalking(0x83),
RockClimbing(0x46),
RollerSkating(0x45),
Rowing(0x17),
Sailing(0x41),
SepakTakraw(0x98),
Shuffleboard(0xa0),
Shuttlecock(0xa2),
Skateboarding(0x43),
Snorkeling(0x9d),
Soccer(0xbf),
Softball(0x56),
SomatosensoryGame(0xa3),
Spinning(0x8f),
SquareDance(0x49),
Squash(0x51),
StairClimber(0x36),
Stepper(0x39),
StreetDance(0x4a),
Strength(0x34),
Stretching(0x35),
Swinging(0x9f),
TableFootball(0xa1),
TableTennis(0x59),
TaiChi(0x64),
Taekwondo(0x66),
Tennis(0x11),
Treadmill(0x02),
TugOfWar(0x77),
Volleyball(0x58),
Walking(0x03),
WallBall(0x91),
WaterPolo(0x9a),
WaterRowing(0x42),
Weiqi(0xaf),
Wrestling(0x63),
Yoga(0x3c),
Zumba(0x4d),
;
private static final Logger LOG = LoggerFactory.getLogger(Huami2021WorkoutTrackActivityType.class);
@ -61,6 +162,9 @@ public enum Huami2021WorkoutTrackActivityType {
return ActivityKind.TYPE_BADMINTON;
case Elliptical:
return ActivityKind.TYPE_ELLIPTICAL_TRAINER;
case Freestyle:
case IndoorFitness:
return ActivityKind.TYPE_EXERCISE;
case IndoorCycling:
return ActivityKind.TYPE_INDOOR_CYCLING;
case JumpRope:
@ -78,6 +182,7 @@ public enum Huami2021WorkoutTrackActivityType {
case Treadmill:
return ActivityKind.TYPE_TREADMILL;
case Walking:
case RaceWalking:
return ActivityKind.TYPE_WALKING;
case Yoga:
return ActivityKind.TYPE_YOGA;

View File

@ -35,7 +35,7 @@ import nodomain.freeyourgadget.gadgetbridge.model.GPSCoordinate;
import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
public class HuamiActivityDetailsParser {
public class HuamiActivityDetailsParser extends AbstractHuamiActivityDetailsParser {
private static final Logger LOG = LoggerFactory.getLogger(HuamiActivityDetailsParser.class);
private static final byte TYPE_GPS = 0;
@ -47,7 +47,6 @@ public class HuamiActivityDetailsParser {
private static final byte TYPE_SPEED6 = 6;
private static final byte TYPE_SWIMMING = 8;
private static final BigDecimal HUAMI_TO_DECIMAL_DEGREES_DIVISOR = new BigDecimal(3000000.0);
private final ActivityTrack activityTrack;
private final Date baseDate;
private long baseLongitude;
@ -195,11 +194,6 @@ public class HuamiActivityDetailsParser {
return i;
}
private double convertHuamiValueToDecimalDegrees(long huamiValue) {
BigDecimal result = new BigDecimal(huamiValue).divide(HUAMI_TO_DECIMAL_DEGREES_DIVISOR, GPSCoordinate.GPS_DECIMAL_DEGREES_SCALE, RoundingMode.HALF_UP);
return result.doubleValue();
}
private int consumeHeartRate(byte[] bytes, int offset, long timeOffsetSeconds) {
int v1 = BLETypeConversions.toUint16(bytes[offset]);
int v2 = BLETypeConversions.toUint16(bytes[offset + 1]);
@ -295,14 +289,4 @@ public class HuamiActivityDetailsParser {
LOG.debug("got packet type 8 (swimming?): " + GB.hexdump(bytes, offset, 6));
return 6;
}
private String createActivityName(BaseActivitySummary summary) {
String name = summary.getName();
String nameText = "";
Long id = summary.getId();
if (name != null) {
nameText = name + " - ";
}
return nameText + id;
}
}

View File

@ -39,6 +39,7 @@ import java.util.concurrent.TimeUnit;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.Logging;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiService;
import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions;
import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder;
@ -84,6 +85,7 @@ public abstract class AbstractFetchOperation extends AbstractHuamiOperation {
}
protected void startFetching() throws IOException {
expectedDataLength = 0;
lastPacketCounter = -1;
TransactionBuilder builder = performInitialized(getName());
@ -122,13 +124,28 @@ public abstract class AbstractFetchOperation extends AbstractHuamiOperation {
}
}
/**
* Handles the finishing of fetching the activity.
* @param success whether fetching was successful
* @return whether handling the activity fetch finish was successful
*/
@CallSuper
protected void handleActivityFetchFinish(boolean success) {
protected boolean handleActivityFetchFinish(boolean success) {
GB.updateTransferNotification(null, "", false, 100, getContext());
operationFinished();
unsetBusy();
return true;
}
/**
* Validates that the received data has the expected checksum. Only
* relevant for Huami2021Support devices.
*
* @param crc32 the expected checksum
* @return whether the checksum was valid
*/
protected abstract boolean validChecksum(int crc32);
/**
* Method to handle the incoming activity data.
* There are two kind of messages we currently know:
@ -158,13 +175,18 @@ public abstract class AbstractFetchOperation extends AbstractHuamiOperation {
if (ArrayUtils.equals(value, HuamiService.RESPONSE_ACTIVITY_DATA_START_DATE_SUCCESS, 0)) {
handleActivityMetadata(value);
TransactionBuilder newBuilder = createTransactionBuilder(taskName + " Step 2");
newBuilder.notify(characteristicActivityData, true);
newBuilder.write(characteristicFetch, new byte[]{HuamiService.COMMAND_FETCH_DATA});
try {
performImmediately(newBuilder);
} catch (IOException ex) {
GB.toast(getContext(), "Error fetching debug logs: " + ex.getMessage(), Toast.LENGTH_LONG, GB.ERROR, ex);
if (expectedDataLength == 0 && getSupport() instanceof Huami2021Support) {
// Nothing to receive, if we try to fetch data it will fail
sendAck2021(true);
} else {
TransactionBuilder newBuilder = createTransactionBuilder(taskName + " Step 2");
newBuilder.notify(characteristicActivityData, true);
newBuilder.write(characteristicFetch, new byte[]{HuamiService.COMMAND_FETCH_DATA});
try {
performImmediately(newBuilder);
} catch (IOException ex) {
GB.toast(getContext(), "Error fetching debug logs: " + ex.getMessage(), Toast.LENGTH_LONG, GB.ERROR, ex);
}
}
return true;
} else {
@ -177,54 +199,119 @@ public abstract class AbstractFetchOperation extends AbstractHuamiOperation {
}
private void handleActivityMetadata(byte[] value) {
// it's 16 on the MB7, with a 0 at the end
if (value.length == 15 || (value.length == 16 && value[15] == 0x00)) {
// first two bytes are whether our request was accepted
if (ArrayUtils.equals(value, HuamiService.RESPONSE_ACTIVITY_DATA_START_DATE_SUCCESS, 0)) {
// the third byte (0x01 on success) = ?
// the 4th - 7th bytes represent the number of bytes/packets to expect, excluding the counter bytes
expectedDataLength = BLETypeConversions.toUint32(Arrays.copyOfRange(value, 3, 7));
if (value.length < 3) {
LOG.warn("Activity metadata too short: {}", Logging.formatBytes(value));
handleActivityFetchFinish(false);
return;
}
// last 8 bytes are the start date
Calendar startTimestamp = getSupport().fromTimeBytes(Arrays.copyOfRange(value, 7, value.length));
setStartTimestamp(startTimestamp);
if (value[0] != HuamiService.RESPONSE) {
LOG.warn("Activity metadata not a response: {}", Logging.formatBytes(value));
handleActivityFetchFinish(false);
return;
}
LOG.info("Will transfer {} packets since {}", expectedDataLength, startTimestamp.getTime());
GB.updateTransferNotification(getContext().getString(R.string.busy_task_fetch_activity_data),
getContext().getString(R.string.FetchActivityOperation_about_to_transfer_since,
DateFormat.getDateTimeInstance().format(startTimestamp.getTime())), true, 0, getContext());
} else {
switch (value[1]) {
case HuamiService.COMMAND_ACTIVITY_DATA_START_DATE:
handleStartDateResponse(value);
return;
case HuamiService.COMMAND_FETCH_DATA:
handleFetchDataResponse(value);
return;
case HuamiService.COMMAND_ACK_ACTIVITY_DATA:
// ignore, this is just the reply to the COMMAND_ACK_ACTIVITY_DATA
LOG.info("Got reply to COMMAND_ACK_ACTIVITY_DATA");
return;
default:
LOG.warn("Unexpected activity metadata: {}", Logging.formatBytes(value));
handleActivityFetchFinish(false);
}
} else if (ArrayUtils.startsWith(value, HuamiService.RESPONSE_FINISH_SUCCESS)) {
if (value.length == 3) {
// older Huami devices, just finish
handleActivityFetchFinish(true);
} else if (value.length == 7 && getSupport() instanceof Huami2021Support) {
// TODO: What do the extra 4 bytes mean?
try {
// not sure why we need to send this (it's acknowledging the data?) but it will get stuck otherwise
final TransactionBuilder builder = performInitialized(getName() + " end");
builder.write(characteristicFetch, new byte[]{HuamiService.COMMAND_ACK_ACTIVITY_DATA, 0x09});
builder.queue(getQueue());
} catch (final IOException e) {
LOG.error("Ending failed", e);
handleActivityFetchFinish(false);
return;
}
handleActivityFetchFinish(true);
} else {
LOG.warn("Unexpected activity metadata finish success: {}", Logging.formatBytes(value));
handleActivityFetchFinish(false);
}
} else if (Arrays.equals(HuamiService.RESPONSE_ACK_SUCCESS, value) && getSupport() instanceof Huami2021Support) {
// ignore, this is just the reply to the COMMAND_ACK_ACTIVITY_DATA
LOG.info("Got reply to COMMAND_ACK_ACTIVITY_DATA");
} else {
LOG.warn("Unexpected activity metadata: {}", Logging.formatBytes(value));
}
}
private void handleStartDateResponse(final byte[] value) {
if (value[2] != HuamiService.SUCCESS) {
LOG.warn("Start date unsuccessful response: {}", Logging.formatBytes(value));
handleActivityFetchFinish(false);
return;
}
// it's 16 on the MB7, with a 0 at the end
if (value.length != 15 && (value.length != 16 && value[15] != 0x00)) {
LOG.warn("Start date response length: {}", Logging.formatBytes(value));
handleActivityFetchFinish(false);
return;
}
// the third byte (0x01 on success) = ?
// the 4th - 7th bytes represent the number of bytes/packets to expect, excluding the counter bytes
expectedDataLength = BLETypeConversions.toUint32(Arrays.copyOfRange(value, 3, 7));
// last 8 bytes are the start date
Calendar startTimestamp = getSupport().fromTimeBytes(Arrays.copyOfRange(value, 7, value.length));
if (expectedDataLength == 0) {
LOG.info("No data to fetch since {}", startTimestamp.getTime());
handleActivityFetchFinish(true);
return;
}
setStartTimestamp(startTimestamp);
LOG.info("Will transfer {} packets since {}", expectedDataLength, startTimestamp.getTime());
GB.updateTransferNotification(getContext().getString(R.string.busy_task_fetch_activity_data),
getContext().getString(R.string.FetchActivityOperation_about_to_transfer_since,
DateFormat.getDateTimeInstance().format(startTimestamp.getTime())), true, 0, getContext());
}
private void handleFetchDataResponse(final byte[] value) {
if (value[2] != HuamiService.SUCCESS) {
LOG.warn("Fetch data unsuccessful response: {}", Logging.formatBytes(value));
handleActivityFetchFinish(false);
return;
}
if (value.length != 3 && value.length != 7) {
LOG.warn("Fetch data unexpected metadata length: {}", Logging.formatBytes(value));
handleActivityFetchFinish(false);
return;
}
if (value.length == 7 && !validChecksum(BLETypeConversions.toUint32(value, 3))) {
LOG.warn("Data checksum invalid");
handleActivityFetchFinish(false);
sendAck2021(true);
return;
}
boolean handleFinishSuccess;
try {
handleFinishSuccess = handleActivityFetchFinish(true);
} catch (final Exception e) {
LOG.warn("Failed to handle activity fetch finish", e);
handleFinishSuccess = false;
}
final boolean keepActivityDataOnDevice = HuamiCoordinator.getKeepActivityDataOnDevice(getDevice().getAddress());
sendAck2021(keepActivityDataOnDevice || !handleFinishSuccess);
}
private void sendAck2021(final boolean keepDataOnDevice) {
if (!(getSupport() instanceof Huami2021Support)) {
return;
}
// 0x01 to ACK, mark as saved on phone (drop from band)
// 0x09 to ACK, but keep it marked as not saved
// If 0x01 is sent, detailed information seems to be discarded, and is not sent again anymore
final byte ackByte = (byte) (keepDataOnDevice ? 0x09 : 0x01);
try {
final TransactionBuilder builder = performInitialized(getName() + " end");
builder.write(characteristicFetch, new byte[]{HuamiService.COMMAND_ACK_ACTIVITY_DATA, ackByte});
performImmediately(builder);
} catch (final IOException e) {
LOG.error("Ending failed", e);
}
}
@ -242,7 +329,6 @@ public abstract class AbstractFetchOperation extends AbstractHuamiOperation {
editor.apply();
}
protected GregorianCalendar getLastSuccessfulSyncTime() {
long timeStampMillis = GBApplication.getDeviceSpecificSharedPrefs(getDevice().getAddress()).getLong(getLastSyncTimeKey(), 0);
if (timeStampMillis != 0) {

View File

@ -45,6 +45,7 @@ import nodomain.freeyourgadget.gadgetbridge.entities.MiBandActivitySample;
import nodomain.freeyourgadget.gadgetbridge.entities.User;
import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiSupport;
import nodomain.freeyourgadget.gadgetbridge.util.CheckSums;
import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
import nodomain.freeyourgadget.gadgetbridge.util.DeviceHelper;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
@ -78,21 +79,31 @@ public class FetchActivityOperation extends AbstractFetchOperation {
startFetching(builder, HuamiService.COMMAND_ACTIVITY_DATA_TYPE_ACTIVTY, sinceWhen);
}
protected void handleActivityFetchFinish(boolean success) {
@Override
protected boolean handleActivityFetchFinish(boolean success) {
LOG.info("{} has finished round {}", getName(), fetchCount);
GregorianCalendar lastSyncTimestamp = saveSamples();
if (lastSyncTimestamp != null && needsAnotherFetch(lastSyncTimestamp)) {
try {
startFetching();
return;
return true;
} catch (IOException ex) {
LOG.error("Error starting another round of {}", getName(), ex);
return false;
}
}
super.handleActivityFetchFinish(success);
final boolean superSuccess = super.handleActivityFetchFinish(success);
GB.signalActivityDataFinish();
return superSuccess;
}
@Override
protected boolean validChecksum(int crc32) {
// TODO actually check it
LOG.warn("Checksum not implemented for activity data, assuming it's valid");
return true;
}
private boolean needsAnotherFetch(GregorianCalendar lastSyncTimestamp) {

View File

@ -24,6 +24,8 @@ import org.slf4j.LoggerFactory;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.GregorianCalendar;
import androidx.annotation.NonNull;
@ -39,8 +41,10 @@ import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
import nodomain.freeyourgadget.gadgetbridge.model.ActivityTrack;
import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions;
import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.AbstractHuamiActivityDetailsParser;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiSupport;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiActivityDetailsParser;
import nodomain.freeyourgadget.gadgetbridge.util.CheckSums;
import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
@ -51,15 +55,20 @@ import nodomain.freeyourgadget.gadgetbridge.util.GB;
*/
public class FetchSportsDetailsOperation extends AbstractFetchOperation {
private static final Logger LOG = LoggerFactory.getLogger(FetchSportsDetailsOperation.class);
private final AbstractHuamiActivityDetailsParser detailsParser;
private final BaseActivitySummary summary;
private final String lastSyncTimeKey;
private ByteArrayOutputStream buffer;
FetchSportsDetailsOperation(@NonNull BaseActivitySummary summary, @NonNull HuamiSupport support, @NonNull String lastSyncTimeKey) {
FetchSportsDetailsOperation(@NonNull BaseActivitySummary summary,
@NonNull AbstractHuamiActivityDetailsParser detailsParser,
@NonNull HuamiSupport support,
@NonNull String lastSyncTimeKey) {
super(support);
setName("fetching sport details");
this.summary = summary;
this.detailsParser = detailsParser;
this.lastSyncTimeKey = lastSyncTimeKey;
}
@ -72,7 +81,7 @@ public class FetchSportsDetailsOperation extends AbstractFetchOperation {
}
@Override
protected void handleActivityFetchFinish(boolean success) {
protected boolean handleActivityFetchFinish(boolean success) {
LOG.info(getName() + " has finished round " + fetchCount);
// GregorianCalendar lastSyncTimestamp = saveSamples();
// if (lastSyncTimestamp != null && needsAnotherFetch(lastSyncTimestamp)) {
@ -84,12 +93,14 @@ public class FetchSportsDetailsOperation extends AbstractFetchOperation {
// }
// }
boolean parseSuccess = true;
if (success) {
HuamiActivityDetailsParser parser = new HuamiActivityDetailsParser(summary);
parser.setSkipCounterByte(false); // is already stripped
if (success && buffer.size() > 0) {
if (detailsParser instanceof HuamiActivityDetailsParser) {
((HuamiActivityDetailsParser) detailsParser).setSkipCounterByte(false); // is already stripped
}
try {
ActivityTrack track = parser.parse(buffer.toByteArray());
ActivityTrack track = detailsParser.parse(buffer.toByteArray());
ActivityTrackExporter exporter = createExporter();
String trackType = "track";
switch (summary.getActivityKind()) {
@ -112,6 +123,8 @@ public class FetchSportsDetailsOperation extends AbstractFetchOperation {
trackType = getContext().getString(R.string.activity_type_swimming);
break;
}
final String rawBytesPath = saveRawBytes();
String fileName = FileUtils.makeValidFileName("gadgetbridge-"+trackType.toLowerCase()+"-" + DateTimeUtils.formatIso8601(summary.getStartTime()) + ".gpx");
File targetFile = new File(FileUtils.getExternalFilesDir(), fileName);
@ -120,21 +133,35 @@ public class FetchSportsDetailsOperation extends AbstractFetchOperation {
try (DBHandler dbHandler = GBApplication.acquireDB()) {
summary.setGpxTrack(targetFile.getAbsolutePath());
if (rawBytesPath != null) {
summary.setRawDetailsPath(rawBytesPath);
}
dbHandler.getDaoSession().getBaseActivitySummaryDao().update(summary);
}
} catch (ActivityTrackExporter.GPXTrackEmptyException ex) {
GB.toast(getContext(), "This activity does not contain GPX tracks.", Toast.LENGTH_LONG, GB.ERROR, ex);
}
GregorianCalendar endTime = BLETypeConversions.createCalendar();
endTime.setTime(summary.getEndTime());
saveLastSyncTimestamp(endTime);
} catch (Exception ex) {
GB.toast(getContext(), "Error getting activity details: " + ex.getMessage(), Toast.LENGTH_LONG, GB.ERROR, ex);
parseSuccess = false;
}
}
super.handleActivityFetchFinish(success);
if (success && parseSuccess) {
// Always increment the sync timestamp on success, even if we did not get data
GregorianCalendar endTime = BLETypeConversions.createCalendar();
endTime.setTime(summary.getEndTime());
saveLastSyncTimestamp(endTime);
}
final boolean superSuccess = super.handleActivityFetchFinish(success);
return superSuccess && parseSuccess;
}
@Override
protected boolean validChecksum(int crc32) {
return crc32 == CheckSums.getCRC32(buffer.toByteArray());
}
private ActivityTrackExporter createExporter() {
@ -198,4 +225,23 @@ public class FetchSportsDetailsOperation extends AbstractFetchOperation {
calendar.setTime(summary.getStartTime());
return calendar;
}
private String saveRawBytes() {
final String fileName = FileUtils.makeValidFileName(String.format("%s.bin", DateTimeUtils.formatIso8601(summary.getStartTime())));
FileOutputStream outputStream = null;
try {
final File targetFolder = new File(FileUtils.getExternalFilesDir(), "rawDetails");
targetFolder.mkdirs();
final File targetFile = new File(targetFolder, fileName);
outputStream = new FileOutputStream(targetFile);
outputStream.write(buffer.toByteArray());
outputStream.close();
return targetFile.getAbsolutePath();
} catch (final IOException e) {
LOG.error("Failed to save raw bytes", e);
}
return null;
}
}

View File

@ -32,14 +32,21 @@ import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.Logging;
import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.huami.Huami2021ActivitySummaryParser;
import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiActivitySummaryParser;
import nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitbip.AmazfitBipService;
import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary;
import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
import nodomain.freeyourgadget.gadgetbridge.entities.Device;
import nodomain.freeyourgadget.gadgetbridge.entities.User;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryParser;
import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.AbstractHuamiActivityDetailsParser;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021ActivityDetailsParser;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiSupport;
import nodomain.freeyourgadget.gadgetbridge.util.CheckSums;
import nodomain.freeyourgadget.gadgetbridge.util.DeviceHelper;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
/**
@ -63,7 +70,7 @@ public class FetchSportsSummaryOperation extends AbstractFetchOperation {
}
@Override
protected void handleActivityFetchFinish(boolean success) {
protected boolean handleActivityFetchFinish(boolean success) {
LOG.info(getName() + " has finished round " + fetchCount);
// GregorianCalendar lastSyncTimestamp = saveSamples();
@ -77,12 +84,16 @@ public class FetchSportsSummaryOperation extends AbstractFetchOperation {
// }
BaseActivitySummary summary = null;
if (success) {
final DeviceCoordinator coordinator = DeviceHelper.getInstance().getCoordinator(getDevice());
final ActivitySummaryParser summaryParser = coordinator.getActivitySummaryParser(getDevice());
boolean parseSummarySuccess = true;
if (success && buffer.size() > 0) {
summary = new BaseActivitySummary();
summary.setStartTime(getLastStartTimestamp().getTime()); // due to a bug this has to be set
summary.setRawSummaryData(buffer.toByteArray());
HuamiActivitySummaryParser parser = new HuamiActivitySummaryParser();
summary = parser.parseBinaryData(summary);
summary = summaryParser.parseBinaryData(summary);
if (summary != null) {
summary.setSummaryData(null); // remove json before saving to database,
try (DBHandler dbHandler = GBApplication.acquireDB()) {
@ -95,21 +106,32 @@ public class FetchSportsSummaryOperation extends AbstractFetchOperation {
session.getBaseActivitySummaryDao().insertOrReplace(summary);
} catch (Exception ex) {
GB.toast(getContext(), "Error saving activity summary", Toast.LENGTH_LONG, GB.ERROR, ex);
parseSummarySuccess = false;
}
}
}
super.handleActivityFetchFinish(success);
final boolean superSuccess = super.handleActivityFetchFinish(success);
boolean getDetailsSuccess = true;
if (summary != null) {
FetchSportsDetailsOperation nextOperation = new FetchSportsDetailsOperation(summary, getSupport(), getLastSyncTimeKey());
final AbstractHuamiActivityDetailsParser detailsParser = ((HuamiActivitySummaryParser) summaryParser).getDetailsParser(summary);
FetchSportsDetailsOperation nextOperation = new FetchSportsDetailsOperation(summary, detailsParser, getSupport(), getLastSyncTimeKey());
try {
nextOperation.perform();
} catch (IOException ex) {
GB.toast(getContext(), "Unable to fetch activity details: " + ex.getMessage(), Toast.LENGTH_LONG, GB.ERROR, ex);
getDetailsSuccess = false;
}
}
return parseSummarySuccess && superSuccess && getDetailsSuccess;
}
@Override
protected boolean validChecksum(int crc32) {
return crc32 == CheckSums.getCRC32(buffer.toByteArray());
}
@Override

View File

@ -36,6 +36,7 @@ import nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitbip.AmazfitBipS
import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions;
import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiSupport;
import nodomain.freeyourgadget.gadgetbridge.util.CheckSums;
import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
@ -80,16 +81,24 @@ public class HuamiFetchDebugLogsOperation extends AbstractFetchOperation {
}
@Override
protected void handleActivityFetchFinish(boolean success) {
LOG.info(getName() +" data has finished");
protected boolean handleActivityFetchFinish(boolean success) {
LOG.info("{} data has finished", getName());
try {
logOutputStream.close();
logOutputStream = null;
} catch (IOException e) {
LOG.warn("could not close output stream", e);
return;
return false;
}
super.handleActivityFetchFinish(success);
return super.handleActivityFetchFinish(success);
}
@Override
protected boolean validChecksum(int crc32) {
// TODO actually check it?
LOG.warn("Checksum not implemented for debug logs, assuming it's valid");
return true;
}
@Override

View File

@ -0,0 +1,80 @@
syntax = "proto3";
option java_package = "nodomain.freeyourgadget.gadgetbridge.proto";
option java_outer_classname = "HuamiProtos";
message WorkoutSummary {
string version = 1;
Location location = 2;
Type type = 3;
Distance distance = 4;
Steps steps = 11;
Time time = 7;
Pace pace = 10;
HeartRate heartRate = 19;
Calories calories = 16;
TrainingEffect trainingEffect = 21;
HeartRateZones heartRateZones = 22;
}
message Location {
// TODO 1, 2, 3
int32 baseLatitude = 5; // /6000000 -> coords
int32 baseLongitude = 6; // /-6000000 -> coords
int32 baseAltitude = 7; // /2 -> meters
int32 maxLatitude = 8; // /3000000 -> coords
int32 minLatitude = 9; // /3000000 -> coords
int32 maxLongitude = 10; // /3000000 -> coords
int32 minLongitude = 11; // /3000000 -> coords
}
message HeartRate {
int32 avg = 1; // bpm
int32 max = 2; // bpm
int32 min = 3; // bpm
}
message Steps {
float avgCadence = 1; // steps/sec
float maxCadence = 2; // steps/sec
int32 avgStride = 3; // cm
int32 steps = 4; // count
}
message Type {
int32 type = 1; // 1 = running, 4 = bike, 3 = walk
// TODO 2, always 0?
}
message Distance {
float distance = 1; // meters
}
message Time {
int32 totalDuration = 1; // seconds
int32 workoutDuration = 2; // seconds
int32 pauseDuration = 3; // seconds
}
message Pace {
float avg = 1; // val * 1000 / 60 -> min/km
float best = 2; // val * 1000 / 60 -> min/km
}
message Calories {
int32 calories = 1; // kcal
}
message HeartRateZones {
// TODO 1, is always = 1?
// Zones: N/A, Warm-up, Fat-burn time, Aerobic, Anaerobic, Extreme
repeated int32 zoneMax = 2; // bpm
repeated int32 zoneTime = 3; // seconds
}
message TrainingEffect {
float aerobicTrainingEffect = 4;
float anaerobicTrainingEffect = 5;
int32 currentWorkoutLoad = 6;
int32 maximumOxygenUptake = 7; // ml/kg/min
}

View File

@ -1,6 +1,6 @@
syntax = "proto3";
option java_package = "nodomain.freeyourgadget.gadgetbridge";
option java_package = "nodomain.freeyourgadget.gadgetbridge.proto";
option java_outer_classname = "SMAQ2OSSProtos";
message SetTime

View File

@ -246,6 +246,7 @@
<string name="pref_title_canned_messages_dismisscall">Call Dismissal</string>
<string name="pref_title_canned_messages_set">Update on device</string>
<string name="pref_header_development">Developer options</string>
<string name="pref_header_authentication">Authentication</string>
<string name="pref_title_development_miaddr">Mi Band address</string>
<string name="pref_title_pebble_settings">Pebble settings</string>
<string name="pref_header_activitytrackers">Activity trackers</string>
@ -723,7 +724,7 @@
<string name="average">Average: %1$s</string>
<string name="pref_title_dont_ack_transfer">Do not ACK activity data transfer</string>
<string name="pref_summary_dont_ack_transfers">If not ACKed to the band, activity data is not cleared. Useful if GB is used together with other apps.</string>
<string name="pref_summary_keep_data_on_device">Will keep activity data on the Mi Band even after synchronization. Useful if GB is used together with other apps.</string>
<string name="pref_summary_keep_data_on_device">Will keep activity data on the device even after synchronization. Useful if GB is used together with other apps.</string>
<string name="pref_title_low_latency_fw_update">Use low-latency mode for firmware flashing</string>
<string name="pref_summary_low_latency_fw_update">This might help on devices where firmware flashing fails.</string>
<string name="pref_title_third_party_app_device_settings">Allow 3rd party apps to change settings</string>
@ -1459,6 +1460,8 @@
<string name="sports_activity_quick_filter_7days">7 days</string>
<string name="sports_activity_quick_filter_30days">30 days</string>
<string name="sports_activity_quick_filter_select">Time period</string>
<string name="sports_activity_confirm_delete_title">Delete %d activities</string>
<string name="sports_activity_confirm_delete_description">Are you sure you want to delete %d activities?</string>
<string name="activity_summaries_all_devices">All devices</string>
<string name="activity_filter_from_placeholder">distant past</string>
<string name="activity_filter_to_placeholder">today</string>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.preference.PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
<PreferenceCategory
android:key="pref_header_authentication"
android:title="@string/pref_header_authentication" />
</androidx.preference.PreferenceScreen>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.preference.PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
<PreferenceCategory
android:key="pref_header_development"
android:title="@string/pref_header_development" />
</androidx.preference.PreferenceScreen>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.preference.PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
<SwitchPreference
android:icon="@drawable/ic_activity_unknown_small"
android:defaultValue="false"
android:key="keep_activity_data_on_device"
android:summary="@string/pref_summary_keep_data_on_device"
android:title="@string/pref_title_keep_data_on_device" />
</androidx.preference.PreferenceScreen>