diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/dafit/DaFitConstants.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/dafit/DaFitConstants.java new file mode 100644 index 000000000..2972de7a8 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/dafit/DaFitConstants.java @@ -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 . */ +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; +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/dafit/DaFitDeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/dafit/DaFitDeviceCoordinator.java new file mode 100644 index 000000000..e59f86500 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/dafit/DaFitDeviceCoordinator.java @@ -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 . */ +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 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 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 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 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; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/dafit/DaFitDeviceSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/dafit/DaFitDeviceSupport.java new file mode 100644 index 000000000..6f7192d94 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/dafit/DaFitDeviceSupport.java @@ -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 . */ +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 deviceInfoProfile; + private final BatteryInfoProfile 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 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 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(); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/dafit/DaFitPacket.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/dafit/DaFitPacket.java new file mode 100644 index 000000000..3550968b2 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/dafit/DaFitPacket.java @@ -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 . */ +package nodomain.freeyourgadget.gadgetbridge.service.devices.dafit; + +public class DaFitPacket { + protected byte[] packet; + protected int position = 0; +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/dafit/DaFitPacketIn.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/dafit/DaFitPacketIn.java new file mode 100644 index 000000000..9c60e6d1e --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/dafit/DaFitPacketIn.java @@ -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 . */ +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
+ *
+ * Usage: + *
+ * {@code
+ * if(packetIn.putFragment(fragment)) {
+ *     Pair packet = DaFitPacketIn.parsePacket(packetIn.getPacket());
+ *     packetIn = new DaFitPacketIn();
+ *     if (packet != null) {
+ *         byte packetType = packet.first;
+ *         byte[] payload = packet.second;
+ *         // ...
+ *     }
+ * }
+ * 
+ */ +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 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); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/dafit/DaFitPacketOut.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/dafit/DaFitPacketOut.java new file mode 100644 index 000000000..a1c9b8bb2 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/dafit/DaFitPacketOut.java @@ -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 . */ +package nodomain.freeyourgadget.gadgetbridge.service.devices.dafit; + +import androidx.annotation.NonNull; + +/** + * A class for handling fragmentation of outgoing packets
+ *
+ * Usage: + *
+ * {@code
+ * DaFitPacketOut packetOut = new DaFitPacketOut(DaFitPacketOut.buildPacket(type, payload));
+ * byte[] fragment = new byte[MTU];
+ * while(packetOut.getFragment(fragment))
+ *     send(fragment);
+ * }
+ * 
+ */ +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; + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5156b8e6e..daf5a1720 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1829,6 +1829,7 @@ Mijia Temperature and Humidity Sensor 2 Mijia Temperature and Humidity Sensor 2 (E-ink) Mijia MHO-C303 + Da Fit Makibes HR3 Bangle.js TLW64