Da Fit: Add device support, reverse engineering notes and base protocol implementation

This commit is contained in:
krzys-h 2019-12-28 14:11:42 +01:00 committed by Arjan Schrijver
parent c0883de546
commit ca7d9e19af
7 changed files with 1054 additions and 0 deletions

View File

@ -0,0 +1,258 @@
/* Copyright (C) 2019 krzys_h
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.dafit;
import java.util.UUID;
import nodomain.freeyourgadget.gadgetbridge.model.NotificationType;
import nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLEDeviceSupport;
public class DaFitConstants {
// (*) - based only on static reverse engineering of the original app code,
// not supported by my watch so not implemented
// (or at least I didn't manage to get any response out of it)
// (?) - not checked
// The device communicates by sending packets by writing to UUID_CHARACTERISTIC_DATA_OUT
// in MTU-sized chunks. The value of MTU seems to be somehow changeable (?), but the default
// is 20. Responses are received via notify on UUID_CHARACTERISTIC_DATA_IN in similar format.
// The write success notification comes AFTER the responses.
// Packet format:
// packet[0] = 0xFE;
// packet[1] = 0xEA;
// if (MTU == 20) // could be a protocol version check?
// {
// packet[2] = 16;
// packet[3] = packet.length;
// }
// else
// {
// packet[2] = 32 + (packet.length >> 8) & 0xFF;
// packet[3] = packet.length & 0xFF;
// }
// packet[4] = packetType;
// packet[5:] = payload;
// Protocol version is determined by reading manufacturer name. MOYOUNG for old fixed-size
// or MOYOUNG-V2 for MTU. The non-MTU version uses packets of size 256
// for firmware >= 1.6.5, and 64 otherwise.
// The firmware version is also used to detect availability of some features.
// Additionally, there seems to be a trace of special packets with cmd 1 and 2, that are sent
// to UUID_CHARACTERISTIC_DATA_SPECIAL_1 and UUID_CHARACTERISTIC_DATA_SPECIAL_2 instead.
// They don't appear on my watch though.
// The response to CMD_ECG is special and is returned using UUID_CHARACTERISTIC_DATA_ECG_OLD
// or UUID_CHARACTERISTIC_DATA_ECG_NEW. The old version is clearly labeled as old in the
// unobfuscated part of the code. If both of them exist, old is used (but I presume only one
// of them is supposed to exist at a time). They also don't appear on my watch as it doesn't
// support ECG.
// In addition to the proprietary protocol described above, the following standard BLE services
// are used:
// * org.bluetooth.service.generic_access for device name
// * org.bluetooth.service.device_information for manufacturer, model, serial number and
// firmware version
// * org.bluetooth.service.battery_service for battery level
// * org.bluetooth.service.heart_rate is exposed, but doesn't seem to work
// * org.bluetooth.service.human_interface_device is exposed, but not even mentioned
// in the official app (?) - needs further research
// * the custom UUID_CHARACTERISTIC_STEPS is used to sync the pedometer data in real time
// via READ or NOTIFY - it's identical to the "sync past data" packet
// ({distance:uint24, steps:uint24, calories:uint24})
// * (?) 0000FEE7-0000-1000-8000-00805F9B34FB another custom service
// (NOT UUID_CHARACTERISTIC_DATA_ECG_OLD!!!) not mentioned anywhere in the official app,
// containing the following characteristics:
// * 0000FEA1-0000-1000-8000-00805F9B34FB - READ, NOTIFY
// * 0000FEC9-0000-1000-8000-00805F9B34FB - READ
// The above standard services are internally handled by the app using the following
// "packet numbers":
// * 16 - query steps
// * 17 - firmware version
// * 18 - query battery
// * 19 - DFU status (queries model number, looks for the string DFU and a number == 0 or != 0)
// * 20 - protocol version (queries manufacturer name, see description above)
public static final UUID UUID_SERVICE_DAFIT = UUID.fromString(String.format(AbstractBTLEDeviceSupport.BASE_UUID, "feea"));
public static final UUID UUID_CHARACTERISTIC_STEPS = UUID.fromString(String.format(AbstractBTLEDeviceSupport.BASE_UUID, "fee1"));
public static final UUID UUID_CHARACTERISTIC_DATA_OUT = UUID.fromString(String.format(AbstractBTLEDeviceSupport.BASE_UUID, "fee2"));
public static final UUID UUID_CHARACTERISTIC_DATA_IN = UUID.fromString(String.format(AbstractBTLEDeviceSupport.BASE_UUID, "fee3"));
public static final UUID UUID_CHARACTERISTIC_DATA_SPECIAL_1 = UUID.fromString(String.format(AbstractBTLEDeviceSupport.BASE_UUID, "fee5")); // (*)
public static final UUID UUID_CHARACTERISTIC_DATA_SPECIAL_2 = UUID.fromString(String.format(AbstractBTLEDeviceSupport.BASE_UUID, "fee6")); // (*)
public static final UUID UUID_CHARACTERISTIC_DATA_ECG_OLD = UUID.fromString(String.format(AbstractBTLEDeviceSupport.BASE_UUID, "fee7")); // (*)
public static final UUID UUID_CHARACTERISTIC_DATA_ECG_NEW = UUID.fromString(String.format(AbstractBTLEDeviceSupport.BASE_UUID, "fee8")); // (*)
// Special
public static final byte CMD_SHUTDOWN = 81; // {-1}
public static final byte CMD_FIND_MY_WATCH = 97; // {}
public static final byte CMD_FIND_MY_PHONE = 98; // (*) outgoing {-1} to stop, incoming {0} start, {!=0} stop
public static final byte CMD_HS_DFU = 99; // (?) {1} - enableHsDfu(), {0} - queryHsDfuAddress()
// Activity tracking (?)
public static final byte CMD_QUERY_LAST_DYNAMIC_RATE = 52; // (?) {} -> ???
public static final byte CMD_QUERY_PAST_HEART_RATE_1 = 53; // (?) {4} - pastHeartRate(), {0} - todayHeartRate(1) -> ???
public static final byte CMD_QUERY_PAST_HEART_RATE_2 = 54; // (?) {0} - todayHeartRate(2) -> ???
public static final byte CMD_QUERY_MOVEMENT_HEART_RATE = 55; // (?) {} -> ???
// Health measurements
public static final byte CMD_QUERY_TIMING_MEASURE_HEART_RATE = 47; // (*) {} -> ???
public static final byte CMD_SET_TIMING_MEASURE_HEART_RATE = 31; // (*) {i}, i >= 0, 0 is disabled
public static final byte CMD_START_STOP_MEASURE_DYNAMIC_RATE = 104; // (*) {enabled ? 0 : -1}
public static final byte CMD_TRIGGER_MEASURE_BLOOD_PRESSURE = 105; // (?) {0, 0, 0} to start, {-1, -1, -1} to stop -> {unused?, num1, num2}
public static final byte CMD_TRIGGER_MEASURE_BLOOD_OXYGEN = 107; // (?) {start ? 0 : -1} -> {num}
public static final byte CMD_TRIGGER_MEASURE_HEARTRATE = 109; // {start ? 0 : -1} -> {bpm}
public static final byte CMD_ECG = 111; // (?) {heart_rate} or {1} to start or {0} to stop or {2} to query
// ECG data is special and comes from UUID_CHARACTERISTIC_DATA_ECG_OLD or UUID_CHARACTERISTIC_DATA_ECG_NEW
// Functionality
public static final byte CMD_SYNC_TIME = 49; // {time >> 24, time >> 16, time >> 8, time, 8}, time is a timestamp in seconds in GMT+8
public static final byte CMD_SYNC_SLEEP = 50; // {} -> {type, start_h, start_m}, repeating, type is SOBER(0),LIGHT(1),RESTFUL(2)
public static final byte CMD_SYNC_PAST_SLEEP_AND_STEP = 51; // {b (see below)} -> {x<=2, distance:uint24, steps:uint24, calories:uint24} or {x>2, (sleep data like above)} - two functions same CMD
public static final byte ARG_SYNC_YESTERDAY_STEPS = 1;
public static final byte ARG_SYNC_DAY_BEFORE_YESTERDAY_STEPS = 2;
public static final byte ARG_SYNC_YESTERDAY_SLEEP = 3;
public static final byte ARG_SYNC_DAY_BEFORE_YESTERDAY_SLEEP = 4;
public static final byte SLEEP_SOBER = 0;
public static final byte SLEEP_LIGHT = 1;
public static final byte SLEEP_RESTFUL = 2;
public static final byte CMD_QUERY_SLEEP_ACTION = 58; // (*) {i} -> {hour, x[60]}
public static final byte CMD_SEND_MESSAGE = 65; // {type, message[]}, message is encoded with manual splitting by String.valueOf(0x2080)
// CMD_SEND_CALL_OFF_HOOK = 65; // {-1} - the same ID as above, different arguments
public static final byte CMD_SET_WEATHER_FUTURE = 66; // {weatherId, low_temp, high_temp} * 7
public static final byte CMD_SET_WEATHER_TODAY = 67; // {have_pm25 ? 1 : 0, weatherId, temp[, pm25 >> 8, pm25], lunar_or_festival[8], city[8]}, names are UTF-16BE encoded (4 characters each!)
public static final byte CMD_GSENSOR_CALIBRATION = 82; // (?) {}
public static final byte CMD_QUERY_STEPS_CATEGORY = 89; // (*) {i} -> {0, data:uint16[*]}, {1}, {2, data:uint16[*]}, {3}, query 0+1 together and 2+3 together
//public static final byte ARG_QUERY_STEPS_CATEGORY_TODAY_STEPS = 0;
//public static final byte ARG_QUERY_STEPS_CATEGORY_YESTERDAY_STEPS = 2;
public static final byte CMD_SWITCH_CAMERA_VIEW = 102; // {} -> {}, outgoing open screen, incoming take photo
public static final byte CMD_NOTIFY_PHONE_OPERATION = 103; // ONLY INCOMING! -> {x}, x -> 0 = play/pause, 1 = prev, 2 = next, 3 = reject incoming call)
public static final byte CMD_NOTIFY_WEATHER_CHANGE = 100; // (?) ONLY INCOMING! -> {}
public static final byte ARG_OPERATION_PLAY_PAUSE = 0;
public static final byte ARG_OPERATION_PREV_SONG = 1;
public static final byte ARG_OPERATION_NEXT_SONG = 2;
public static final byte ARG_OPERATION_DROP_INCOMING_CALL = 3;
public static final byte CMD_QUERY_ALARM_CLOCK = 33; // (?) {} -> a list of entries like below
public static final byte CMD_SET_ALARM_CLOCK = 17; // (?) {id, enable ? 1 : 0, repeat, hour, minute, i >> 8, i, repeatMode}, repeatMode is 0(SINGLE), 127(EVERYDAY), or bitmask of 1,2,4,8,16,32,64(SUNDAY-SATURDAY) is 0,1,2, i is ((year << 12) + (month << 8) + day) for repeatMode=SINGLE and 0 otherwise, repeat is 0(SINGLE),1(EVERYDAY),2(OTHER)
// Settings
public static final byte CMD_SET_USER_INFO = 18; // (?) {height, weight, age, gender}, MALE = 0, FEMALE = 1
public static final byte CMD_QUERY_DOMINANT_HAND = 36; // (*) {} -> {value}
public static final byte CMD_SET_DOMINANT_HAND = 20; // (*) {value}
public static final byte CMD_QUERY_DISPLAY_DEVICE_FUNCTION = 37; // (*) {} - current, {-1} - list all supported -> {[-1, ], ...} (prefixed with -1 if lists supported, nothing otherwise)
public static final byte CMD_SET_DISPLAY_DEVICE_FUNCTION = 21; // (*) {..., 0} - null terminated list of functions to enable
public static final byte CMD_QUERY_GOAL_STEP = 38; // {} -> {value, value >> 8, value >> 16, value >> 24} // this has the endianness swapped between query and set
public static final byte CMD_SET_GOAL_STEP = 22; // {value >> 24, value >> 16, value >> 8, value} // yes, really
public static final byte CMD_QUERY_TIME_SYSTEM = 39; // {} -> {value}
public static final byte CMD_SET_TIME_SYSTEM = 23; // {value}
// quick view = enable display when wrist is lifted
public static final byte CMD_QUERY_QUICK_VIEW = 40; // {} -> {value}
public static final byte CMD_SET_QUICK_VIEW = 24; // {enabled ? 1 : 0}
public static final byte CMD_QUERY_DISPLAY_WATCH_FACE = 41; // {} -> {value}
public static final byte CMD_SET_DISPLAY_WATCH_FACE = 25; // {value}
public static final byte CMD_QUERY_METRIC_SYSTEM = 42; // {} -> {value}
public static final byte CMD_SET_METRIC_SYSTEM = 26; // {value}
public static final byte CMD_QUERY_DEVICE_LANGUAGE = 43; // {} -> {value, bitmask_of_supported_langs:uint32}
public static final byte CMD_SET_DEVICE_LANGUAGE = 27; // {new_value}
// enables "other" (as in "not a messaging app") on the notifications configuration screen in the official app
// seems to be used only in the app, not sure why they even store it on the watch
public static final byte CMD_QUERY_OTHER_MESSAGE_STATE = 44; // {} -> {value}
public static final byte CMD_SET_OTHER_MESSAGE_STATE = 28; // {enabled ? 1 : 0}
public static final byte CMD_QUERY_SEDENTARY_REMINDER = 45; // {} -> {value}
public static final byte CMD_SET_SEDENTARY_REMINDER = 29; // {enabled ? 1 : 0}
public static final byte CMD_QUERY_DEVICE_VERSION = 46; // {} -> {value}
public static final byte CMD_SET_DEVICE_VERSION = 30; // {new_value}
public static final byte CMD_QUERY_WATCH_FACE_LAYOUT = 57; // (*) {} -> {time_position, time_top_content, time_bottom_content, text_color >> 8, text_color, background_picture_md5[32]}
public static final byte CMD_SET_WATCH_FACE_LAYOUT = 56; // (*) {time_position, time_top_content, time_bottom_content, text_color >> 8, text_color, background_picture_md5[32]}, text_color is R5G6B5, background_picture is stored as hex digits (numbers 0-15 not chars '0'-'F' !)
public static final byte CMD_SET_STEP_LENGTH = 84; // (?) {value}
public static final byte CMD_QUERY_DO_NOT_DISTURB_TIME = -127; // {} -> {start >> 8, start, end >> 8, end} these are 16-bit values (somebody was drunk while writing this or what?)
public static final byte CMD_SET_DO_NOT_DISTURB_TIME = 113; // {start_hour, start_min, end_hour, end_min}
public static final byte CMD_QUERY_QUICK_VIEW_TIME = -126; // {} -> {start >> 8, start, end >> 8, end} these are 16-bit values (somebody was drunk while writing this or what?)
public static final byte CMD_SET_QUICK_VIEW_TIME = 114; // {start_hour, start_min, end_hour, end_min}
public static final byte CMD_QUERY_REMINDERS_TO_MOVE_PERIOD = -125; // {} -> {period, steps, start_hour, end_hour}
public static final byte CMD_SET_REMINDERS_TO_MOVE_PERIOD = 115; // {period, steps, start_hour, end_hour}
public static final byte CMD_QUERY_SUPPORT_WATCH_FACE = -124; // (*) {} -> {count >> 8, count, ...}
public static final byte CMD_QUERY_PSYCHOLOGICAL_PERIOD = -123; // (*) {} -> ??? (too lazy to check, sorry :P)
public static final byte CMD_SET_PSYCHOLOGICAL_PERIOD = 117; // (*) {encodeConfiguredReminders(info), 15, info.getPhysiologcalPeriod(), info.getMenstrualPeriod(), info.startDate.get(Calendar.MONTH), info.startDate.get(Calendar.DATE), info.getReminderHour(), info.getReminderMinute(), info.getReminderHour(), info.getReminderMinute(), info.getReminderHour(), info.getReminderMinute(), info.getReminderHour(), info.getReminderMinute()}
// encodeConfiguredReminders(CRPPhysiologcalPeriodInfo info) {
// int i = info.isMenstrualReminder() ? 241 : 240;
// if (info.isOvulationReminder())
// i += 2;
// if (info.isOvulationDayReminder())
// i += 4;
// if (info.isOvulationEndReminder())
// i += 8;
// return (byte) i;
// }
// no idea what this does
public static final byte CMD_QUERY_BREATHING_LIGHT = -120; // {} -> {value}
public static final byte CMD_SET_BREATHING_LIGHT = 120; // {enabled ? 1 : 0}
public static final byte TRAINING_TYPE_WALK = 0;
public static final byte TRAINING_TYPE_RUN = 1;
public static final byte TRAINING_TYPE_BIKING = 2;
public static final byte TRAINING_TYPE_ROPE = 3;
public static final byte TRAINING_TYPE_BADMINTON = 4;
public static final byte TRAINING_TYPE_BASKETBALL = 5;
public static final byte TRAINING_TYPE_FOOTBALL = 6;
public static final byte TRAINING_TYPE_SWIM = 7;
public static final byte TRAINING_TYPE_MOUNTAINEERING = 8;
public static final byte TRAINING_TYPE_TENNIS = 9;
public static final byte TRAINING_TYPE_RUGBY = 10;
public static final byte TRAINING_TYPE_GOLF = 11;
}

View File

@ -0,0 +1,194 @@
/* Copyright (C) 2019 krzys_h
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.dafit;
import android.annotation.TargetApi;
import android.app.Activity;
import android.bluetooth.le.ScanFilter;
import android.content.Context;
import android.net.Uri;
import android.os.Build;
import android.os.ParcelUuid;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.util.Collection;
import java.util.Collections;
import nodomain.freeyourgadget.gadgetbridge.GBException;
import nodomain.freeyourgadget.gadgetbridge.devices.AbstractDeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
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.model.ActivitySample;
import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
public class DaFitDeviceCoordinator extends AbstractDeviceCoordinator {
@Override
public DeviceType getDeviceType() {
return DeviceType.DAFIT;
}
@Override
public String getManufacturer() {
return "Media-Tech";
}
@NonNull
@Override
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
public Collection<? extends ScanFilter> createBLEScanFilters() {
ParcelUuid service = new ParcelUuid(DaFitConstants.UUID_SERVICE_DAFIT);
ScanFilter filter = new ScanFilter.Builder().setServiceUuid(service).build();
return Collections.singletonList(filter);
}
@NonNull
@Override
public DeviceType getSupportedType(GBDeviceCandidate candidate) {
// TODO: It would be nice to also filter on "manufacturer" (which is used as a protocol version) being MOYOUNG-V2 or MOYOUNG but I have no idea if it's possible to do that at this point
if (candidate.supportsService(DaFitConstants.UUID_SERVICE_DAFIT)) {
return DeviceType.DAFIT;
}
return DeviceType.UNKNOWN;
}
@Override
public int getBondingStyle() {
return BONDING_STYLE_NONE;
}
@Nullable
@Override
public Class<? extends Activity> getPairingActivity() {
return null;
}
@Override
protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) throws GBException {
}
@Override
public boolean supportsActivityDataFetching() {
return false;
}
@Override
public boolean supportsActivityTracking() {
return false;
}
@Override
public SampleProvider<? extends ActivitySample> getSampleProvider(GBDevice device, DaoSession session) {
return null;
}
@Override
public InstallHandler findInstallHandler(Uri uri, Context context) {
return null;
}
@Override
public boolean supportsScreenshots() {
return false;
}
@Override
public int getAlarmSlotCount() {
return 3;
}
@Override
public boolean supportsSmartWakeup(GBDevice device) {
return false;
}
@Override
public boolean supportsHeartRateMeasurement(GBDevice device) {
return false;
}
@Override
public boolean supportsAppsManagement() {
return false;
}
@Override
public Class<? extends Activity> getAppsManagementActivity() {
return null;
}
@Override
public boolean supportsCalendarEvents() {
return false;
}
@Override
public boolean supportsRealtimeData() {
return false;
}
@Override
public boolean supportsWeather() {
return false;
}
@Override
public boolean supportsFindDevice() {
return true;
}
@Override
public boolean supportsActivityTracks() {
return false;
}
@Override
public boolean supportsMusicInfo() {
return false;
}
@Override
public boolean supportsLedColor() {
return false;
}
@Override
public boolean supportsRgbLedColor() {
return false;
}
@NonNull
@Override
public int[] getColorPresets() {
return new int[0];
}
@Override
public boolean supportsUnicodeEmojis() { return false; }
@Override
public int[] getSupportedDeviceSpecificSettings(GBDevice device) {
return null;
}
}

View File

@ -0,0 +1,362 @@
/* Copyright (C) 2019 krzys_h
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.dafit;
import android.bluetooth.BluetoothGatt;
import android.bluetooth.BluetoothGattCharacteristic;
import android.content.Intent;
import android.net.Uri;
import android.util.Log;
import android.util.Pair;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Objects;
import java.util.UUID;
import nodomain.freeyourgadget.gadgetbridge.Logging;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventBatteryInfo;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventVersionInfo;
import nodomain.freeyourgadget.gadgetbridge.devices.dafit.DaFitConstants;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.Alarm;
import nodomain.freeyourgadget.gadgetbridge.model.CalendarEventSpec;
import nodomain.freeyourgadget.gadgetbridge.model.CallSpec;
import nodomain.freeyourgadget.gadgetbridge.model.CannedMessagesSpec;
import nodomain.freeyourgadget.gadgetbridge.model.MusicSpec;
import nodomain.freeyourgadget.gadgetbridge.model.MusicStateSpec;
import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec;
import nodomain.freeyourgadget.gadgetbridge.model.WeatherSpec;
import nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLEDeviceSupport;
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.service.btle.profiles.IntentListener;
import nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.battery.BatteryInfo;
import nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.battery.BatteryInfoProfile;
import nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.deviceinfo.DeviceInfo;
import nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.deviceinfo.DeviceInfoProfile;
// TODO: figure out the training data
public class DaFitDeviceSupport extends AbstractBTLEDeviceSupport {
private static final Logger LOG = LoggerFactory.getLogger(DaFitDeviceSupport.class);
private final DeviceInfoProfile<DaFitDeviceSupport> deviceInfoProfile;
private final BatteryInfoProfile<DaFitDeviceSupport> batteryInfoProfile;
private final GBDeviceEventVersionInfo versionCmd = new GBDeviceEventVersionInfo();
private final GBDeviceEventBatteryInfo batteryCmd = new GBDeviceEventBatteryInfo();
private final IntentListener mListener = new IntentListener() {
@Override
public void notify(Intent intent) {
String s = intent.getAction();
if (Objects.equals(s, DeviceInfoProfile.ACTION_DEVICE_INFO)) {
handleDeviceInfo((DeviceInfo) intent.getParcelableExtra(DeviceInfoProfile.EXTRA_DEVICE_INFO));
}
if (Objects.equals(s, BatteryInfoProfile.ACTION_BATTERY_INFO)) {
handleBatteryInfo((BatteryInfo) intent.getParcelableExtra(BatteryInfoProfile.EXTRA_BATTERY_INFO));
}
}
};
public static final int MTU = 20; // TODO: there seems to be some way to change this value...?
private DaFitPacketIn packetIn = new DaFitPacketIn();
public DaFitDeviceSupport() {
super(LOG);
addSupportedService(GattService.UUID_SERVICE_GENERIC_ACCESS);
addSupportedService(GattService.UUID_SERVICE_GENERIC_ATTRIBUTE);
addSupportedService(GattService.UUID_SERVICE_DEVICE_INFORMATION);
addSupportedService(GattService.UUID_SERVICE_BATTERY_SERVICE);
addSupportedService(DaFitConstants.UUID_SERVICE_DAFIT);
deviceInfoProfile = new DeviceInfoProfile<>(this);
deviceInfoProfile.addListener(mListener);
batteryInfoProfile = new BatteryInfoProfile<>(this);
batteryInfoProfile.addListener(mListener);
addSupportedProfile(deviceInfoProfile);
addSupportedProfile(batteryInfoProfile);
}
@Override
protected TransactionBuilder initializeDevice(TransactionBuilder builder) {
builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.INITIALIZING, getContext()));
builder.notify(getCharacteristic(DaFitConstants.UUID_CHARACTERISTIC_DATA_IN), true);
deviceInfoProfile.requestDeviceInfo(builder);
batteryInfoProfile.requestBatteryInfo(builder);
batteryInfoProfile.enableNotify(builder);
builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.INITIALIZED, getContext()));
return builder;
}
private BluetoothGattCharacteristic getTargetCharacteristicForPacketType(byte packetType)
{
if (packetType == 1)
return getCharacteristic(DaFitConstants.UUID_CHARACTERISTIC_DATA_SPECIAL_1);
else if (packetType == 2)
return getCharacteristic(DaFitConstants.UUID_CHARACTERISTIC_DATA_SPECIAL_2);
else
return getCharacteristic(DaFitConstants.UUID_CHARACTERISTIC_DATA_OUT);
}
public void sendPacket(TransactionBuilder builder, byte[] packet)
{
DaFitPacketOut packetOut = new DaFitPacketOut(packet);
byte[] fragment = new byte[MTU];
while(packetOut.getFragment(fragment))
{
builder.write(getTargetCharacteristicForPacketType(packet[4]), fragment.clone());
}
}
@Override
public boolean onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) {
UUID charUuid = characteristic.getUuid();
if (charUuid.equals(DaFitConstants.UUID_CHARACTERISTIC_DATA_IN))
{
if (packetIn.putFragment(characteristic.getValue())) {
Pair<Byte, byte[]> packet = DaFitPacketIn.parsePacket(packetIn.getPacket());
packetIn = new DaFitPacketIn();
if (packet != null) {
byte packetType = packet.first;
byte[] payload = packet.second;
Log.i("AAAAAAAAAAAAAAAA", "Response for: " + packetType);
if (handlePacket(packetType, payload))
return true;
}
}
}
return super.onCharacteristicChanged(gatt, characteristic);
}
private boolean handlePacket(byte packetType, byte[] payload)
{
LOG.warn("Got packet " + packetType + ": " + Logging.formatBytes(payload));
return false;
}
private void handleDeviceInfo(DeviceInfo info) {
LOG.warn("Device info: " + info);
versionCmd.hwVersion = info.getHardwareRevision();
versionCmd.fwVersion = info.getSoftwareRevision();
handleGBDeviceEvent(versionCmd);
}
private void handleBatteryInfo(BatteryInfo info) {
LOG.warn("Battery info: " + info);
batteryCmd.level = (short) info.getPercentCharged();
handleGBDeviceEvent(batteryCmd);
}
@Override
public boolean useAutoConnect() {
return true;
}
@Override
public void onNotification(NotificationSpec notificationSpec) {
// TODO
}
@Override
public void onDeleteNotification(int id) {
// not supported :(
}
@Override
public void onSetCallState(CallSpec callSpec) {
// TODO
}
@Override
public void onSetCannedMessages(CannedMessagesSpec cannedMessagesSpec) {
// not supported :(
}
@Override
public void onSetTime() {
// TODO
}
@Override
public void onSetAlarms(ArrayList<? extends Alarm> alarms) {
// TODO: set alarms
}
@Override
public void onSetMusicState(MusicStateSpec stateSpec) {
// not supported :(
}
@Override
public void onSetMusicInfo(MusicSpec musicSpec) {
// not supported :(
}
@Override
public void onInstallApp(Uri uri) {
throw new UnsupportedOperationException();
}
@Override
public void onAppInfoReq() {
throw new UnsupportedOperationException();
}
@Override
public void onAppStart(UUID uuid, boolean start) {
throw new UnsupportedOperationException();
}
@Override
public void onAppDelete(UUID uuid) {
throw new UnsupportedOperationException();
}
@Override
public void onAppConfiguration(UUID appUuid, String config, Integer id) {
throw new UnsupportedOperationException();
}
@Override
public void onAppReorder(UUID[] uuids) {
throw new UnsupportedOperationException();
}
@Override
public void onFetchRecordedData(int dataTypes) {
// TODO
}
@Override
public void onReset(int flags) {
// TODO: this shuts down the watch, rather than rebooting it - perhaps add a new operation type?
// (reboot is not supported, btw)
try {
TransactionBuilder builder = performInitialized("shutdown");
sendPacket(builder, DaFitPacketOut.buildPacket(DaFitConstants.CMD_SHUTDOWN, new byte[] { -1 }));
builder.queue(getQueue());
} catch (IOException e) {
e.printStackTrace();
}
}
@Override
public void onHeartRateTest() {
// TODO
}
// TODO: starting other tests
@Override
public void onEnableRealtimeSteps(boolean enable) {
// TODO
}
@Override
public void onEnableRealtimeHeartRateMeasurement(boolean enable) {
// TODO
}
@Override
public void onSetHeartRateMeasurementInterval(int seconds) {
throw new UnsupportedOperationException();
}
@Override
public void onEnableHeartRateSleepSupport(boolean enable) {
throw new UnsupportedOperationException();
}
@Override
public void onFindDevice(boolean start) {
if (start)
{
try {
TransactionBuilder builder = performInitialized("onFindDevice");
sendPacket(builder, DaFitPacketOut.buildPacket(DaFitConstants.CMD_FIND_MY_WATCH, new byte[0]));
builder.queue(getQueue());
} catch (IOException e) {
e.printStackTrace();
}
}
else
{
// Not supported - the device vibrates three times and then stops automatically
}
}
@Override
public void onSetConstantVibration(int integer) {
throw new UnsupportedOperationException();
}
@Override
public void onScreenshotReq() {
throw new UnsupportedOperationException();
}
@Override
public void onAddCalendarEvent(CalendarEventSpec calendarEventSpec) {
throw new UnsupportedOperationException();
}
@Override
public void onDeleteCalendarEvent(byte type, long id) {
throw new UnsupportedOperationException();
}
@Override
public void onSendConfiguration(String config) {
// TODO
}
@Override
public void onReadConfiguration(String config) {
// TODO
}
@Override
public void onTestNewFunction() {
}
@Override
public void onSendWeather(WeatherSpec weatherSpec) {
// TODO
}
@Override
public void onSetFmFrequency(float frequency) {
throw new UnsupportedOperationException();
}
@Override
public void onSetLedColor(int color) {
throw new UnsupportedOperationException();
}
}

View File

@ -0,0 +1,22 @@
/* Copyright (C) 2019 krzys_h
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.dafit;
public class DaFitPacket {
protected byte[] packet;
protected int position = 0;
}

View File

@ -0,0 +1,136 @@
/* Copyright (C) 2019 krzys_h
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.dafit;
import android.util.Log;
import android.util.Pair;
import androidx.annotation.NonNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import nodomain.freeyourgadget.gadgetbridge.Logging;
/**
* A class for handling fragmentation of incoming packets<br>
* <br>
* Usage:
* <pre>
* {@code
* if(packetIn.putFragment(fragment)) {
* Pair<Byte, byte[]> packet = DaFitPacketIn.parsePacket(packetIn.getPacket());
* packetIn = new DaFitPacketIn();
* if (packet != null) {
* byte packetType = packet.first;
* byte[] payload = packet.second;
* // ...
* }
* }
* </pre>
*/
public class DaFitPacketIn extends DaFitPacket {
private static final Logger LOG = LoggerFactory.getLogger(DaFitPacketIn.class);
public DaFitPacketIn()
{
}
/**
* Store the incoming fragment and try to reconstruct packet
*
* @param fragment The incoming fragment
* @return true if the packet is complete
*/
public boolean putFragment(byte[] fragment)
{
if (packet == null)
{
int len = parsePacketLength(fragment);
if (len < 0)
return false; // corrupted packet
packet = new byte[len];
}
int toCopy = Math.min(fragment.length, packet.length - position);
if (fragment.length > toCopy)
{
LOG.warn("Got fragment with more data than expected!");
}
System.arraycopy(fragment, 0, packet, position, toCopy);
position += fragment.length;
return position >= packet.length;
}
public byte[] getPacket()
{
if (packet == null || position < packet.length)
throw new IllegalStateException("Packet is not complete yet");
return packet;
}
/**
* Parse the packet header and return the length
* @param packetOrFragment The entire packet or it's first fragment
* @return The packet length, or -1 if packet is corrupted
*/
private static int parsePacketLength(@NonNull byte[] packetOrFragment)
{
if (packetOrFragment[0] != (byte)0xFE || packetOrFragment[1] != (byte)0xEA)
{
LOG.warn("Invalid packet header, ignoring! Fragment: " + Logging.formatBytes(packetOrFragment));
return -1;
}
int len_h = 0;
if (packetOrFragment[2] != 16)
{
if ((packetOrFragment[2] & 0xFF) < 32)
{
LOG.warn("Corrupted packet, unable to parse length");
return -1;
}
len_h = (packetOrFragment[2] & 0xFF) - 32;
}
int len_l = (packetOrFragment[3] & 0xFF);
return (len_h << 8) | len_l;
}
/**
* Parse the packet
* @param packet The complete packet
* @return A pair containing the packet type and payload
*/
public static Pair<Byte, byte[]> parsePacket(@NonNull byte[] packet)
{
int len = parsePacketLength(packet);
if (len < 0)
return null;
if (len != packet.length)
{
LOG.warn("Invalid packet length!");
return null;
}
byte packetType = packet[4];
byte[] payload = new byte[packet.length - 5];
System.arraycopy(packet, 5, payload, 0, payload.length);
return Pair.create(packetType, payload);
}
}

View File

@ -0,0 +1,81 @@
/* Copyright (C) 2019 krzys_h
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.dafit;
import androidx.annotation.NonNull;
/**
* A class for handling fragmentation of outgoing packets<br>
* <br>
* Usage:
* <pre>
* {@code
* DaFitPacketOut packetOut = new DaFitPacketOut(DaFitPacketOut.buildPacket(type, payload));
* byte[] fragment = new byte[MTU];
* while(packetOut.getFragment(fragment))
* send(fragment);
* }
* </pre>
*/
public class DaFitPacketOut extends DaFitPacket {
public DaFitPacketOut(byte[] packet)
{
this.packet = packet;
}
/**
* Get the next fragment of this packet to be sent
*
* @param fragmentBuffer The buffer to store the output in, of desired size (i.e. == MTU)
* @return true if there is more data to be sent, false otherwise
*/
public boolean getFragment(byte[] fragmentBuffer)
{
if (position >= packet.length)
return false;
int remainingToTransfer = Math.min(fragmentBuffer.length, packet.length - position);
System.arraycopy(packet, position, fragmentBuffer, 0, remainingToTransfer);
position += remainingToTransfer;
return true;
}
/**
* Encode the packet
* @param packetType The packet type
* @param payload The packet payload
* @return The encoded packet
*/
public static byte[] buildPacket(byte packetType, @NonNull byte[] payload)
{
byte[] packet = new byte[payload.length + 5];
packet[0] = (byte)0xFE;
packet[1] = (byte)0xEA;
if (DaFitDeviceSupport.MTU == 20)
{
packet[2] = 16;
packet[3] = (byte)(packet.length & 0xFF);
}
else
{
packet[2] = (byte)(32 + (packet.length >> 8) & 0xFF);
packet[3] = (byte)(packet.length & 0xFF);
}
packet[4] = packetType;
System.arraycopy(payload, 0, packet, 5, payload.length);
return packet;
}
}

View File

@ -1829,6 +1829,7 @@
<string name="devicetype_mijia_lywsd03">Mijia Temperature and Humidity Sensor 2</string>
<string name="devicetype_mijia_xmwsdj04">Mijia Temperature and Humidity Sensor 2 (E-ink)</string>
<string name="devicetype_mijia_mho_c303">Mijia MHO-C303</string>
<string name="devicetype_dafit">Da Fit</string>
<string name="devicetype_makibes_hr3">Makibes HR3</string>
<string name="devicetype_banglejs">Bangle.js</string>
<string name="devicetype_tlw64">TLW64</string>