/* Copyright (C) 2024 enoint 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.hama.fit6900; import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_FIND_PHONE; import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_FIND_PHONE_DURATION; import android.bluetooth.BluetoothGatt; import android.bluetooth.BluetoothGattCharacteristic; import android.content.SharedPreferences; import android.os.Handler; import nodomain.freeyourgadget.gadgetbridge.GBApplication; import nodomain.freeyourgadget.gadgetbridge.R; import nodomain.freeyourgadget.gadgetbridge.activities.SettingsActivity; import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst; import nodomain.freeyourgadget.gadgetbridge.database.DBHelper; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventCallControl; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventCameraRemote; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventFindPhone; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventMusicControl; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventVersionInfo; import nodomain.freeyourgadget.gadgetbridge.devices.hama.fit6900.HamaFit6900Constants; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.IOException; import java.text.DateFormat; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Calendar; import java.util.GregorianCalendar; import java.util.Locale; import nodomain.freeyourgadget.gadgetbridge.devices.hama.fit6900.Message; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.model.ActivityUser; import nodomain.freeyourgadget.gadgetbridge.model.Alarm; import nodomain.freeyourgadget.gadgetbridge.model.CallSpec; import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec; 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.util.Prefs; import nodomain.freeyourgadget.gadgetbridge.util.StringUtils; public final class HamaFit6900DeviceSupport extends AbstractBTLEDeviceSupport { private static final Logger LOG = LoggerFactory.getLogger(HamaFit6900DeviceSupport.class); private BluetoothGattCharacteristic writeCharacteristic; private int notificationCount = 0; private final Handler findPhoneStopNotificationHandler = new Handler(); public HamaFit6900DeviceSupport() { super(LOG); addSupportedService(GattService.UUID_SERVICE_GENERIC_ATTRIBUTE); addSupportedService(HamaFit6900Constants.UUID_SERVICE_RXTX); } @Override public TransactionBuilder initializeDevice(TransactionBuilder builder) { builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.INITIALIZING, getContext())); writeCharacteristic = getCharacteristic(HamaFit6900Constants.UUID_CHARACTERISTIC_TX); builder.notify(getCharacteristic(HamaFit6900Constants.UUID_CHARACTERISTIC_RX), true); builder.setCallback(this); builder.write(writeCharacteristic, Message.encodeGetBatteryStatus()); builder.write(writeCharacteristic, Message.encodeGetFirmwareVersion()); if (GBApplication.getPrefs().getBoolean("datetime_synconconnect", true)) { builder.write(writeCharacteristic, makeSetDateTimeMessage()); } // sync all preferences to device builder.write(writeCharacteristic, makeSetSystemDataMessage()); builder.write(writeCharacteristic, makeSetUnitMessage()); builder.write(writeCharacteristic, makeSetUserInfoMessage()); builder.write(writeCharacteristic, makeSetAutoHeartRate()); builder.write(writeCharacteristic, makeSetDoNotDisturbMessage()); builder.write(writeCharacteristic, makeSetHydrationReminderMessage()); builder.write(writeCharacteristic, makeSetLiftWristMessage()); builder.write(writeCharacteristic, Message.encodeSetAlarms(new ArrayList(DBHelper.getAlarms(gbDevice)))); builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.INITIALIZED, getContext())); return builder; } @Override public void onSendConfiguration(final String config) { switch (config) { case DeviceSettingsPreferenceConst.PREF_LANGUAGE: case DeviceSettingsPreferenceConst.PREF_TIMEFORMAT: sendMessage("update-language+timeformat", makeSetSystemDataMessage()); return; case SettingsActivity.PREF_MEASUREMENT_SYSTEM: sendMessage("update-units", makeSetUnitMessage()); return; case ActivityUser.PREF_USER_WEIGHT_KG: case ActivityUser.PREF_USER_GENDER: case ActivityUser.PREF_USER_HEIGHT_CM: case ActivityUser.PREF_USER_DATE_OF_BIRTH: case DeviceSettingsPreferenceConst.PREF_USER_FITNESS_GOAL: sendMessage("update-user-info", makeSetUserInfoMessage()); return; case DeviceSettingsPreferenceConst.PREF_AUTOHEARTRATE_SWITCH: case DeviceSettingsPreferenceConst.PREF_AUTOHEARTRATE_START: case DeviceSettingsPreferenceConst.PREF_AUTOHEARTRATE_END: case DeviceSettingsPreferenceConst.PREF_AUTOHEARTRATE_INTERVAL: { byte[] msg = makeSetAutoHeartRate(); if (msg != null) { sendMessage("update-auto-heart-rate", msg); } return; } case DeviceSettingsPreferenceConst.PREF_DO_NOT_DISTURB_NOAUTO: case DeviceSettingsPreferenceConst.PREF_DO_NOT_DISTURB_NOAUTO_START: case DeviceSettingsPreferenceConst.PREF_DO_NOT_DISTURB_NOAUTO_END: { byte[] msg = makeSetDoNotDisturbMessage(); if (msg != null) sendMessage("update-do-not-disturb", msg); return; } case DeviceSettingsPreferenceConst.PREF_HYDRATION_SWITCH: case DeviceSettingsPreferenceConst.PREF_HYDRATION_PERIOD: { byte[] msg = makeSetHydrationReminderMessage(); if (msg != null) sendMessage("update-hydration-reminder", msg); return; } case DeviceSettingsPreferenceConst.PREF_ACTIVATE_DISPLAY_ON_LIFT: sendMessage("update-lift-wrist", makeSetLiftWristMessage()); return; } } @Override public boolean onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) { if (super.onCharacteristicChanged(gatt, characteristic)) { return true; } if (!characteristic.getUuid().equals(HamaFit6900Constants.UUID_CHARACTERISTIC_RX)) { return false; } byte[] receivedData = characteristic.getValue(); Message.CommandMessage cmdMsg = Message.decodeCommandMessage(receivedData); if (cmdMsg == null) { return false; } final byte[] cmdArgs = cmdMsg.cmdArgs; switch (cmdMsg.cmd) { case 1: switch (cmdMsg.key) { case 19: // response to GetFirmwareVersion final String fwVersion = String.format("%d.%d.%d", cmdArgs[0] & 0xFF, cmdArgs[1] & 0xFF, cmdArgs[2] & 0xFF); // data[3]: bracelet type // data[>3]: ? handleFirmwareVersion(fwVersion); return true; } break; case 2: switch (cmdMsg.key) { case 1: // response to SetUnit; no args case 32: // response to SetDateTime; no args case 33: // response to SetAlarms; no args case 35: // response to SetUserInfo; no args case 39: // response to SetSystemData; no args case 40: // response to SetHydrationReminder; no args return true; } break; case 4: switch (cmdMsg.key) { case 70: // notification msg: camera - open handleCameraRemote(GBDeviceEventCameraRemote.Event.OPEN_CAMERA); return true; case 71: // notification msg: camera - capture handleCameraRemote(GBDeviceEventCameraRemote.Event.TAKE_PICTURE); return true; case 72: // notification msg: camera - close handleCameraRemote(GBDeviceEventCameraRemote.Event.CLOSE_CAMERA); return true; case 74: // response to SetLiftWriteDisplayOn; no args return true; case 65: // response to GetBatteryStatus handleBatteryStatus(cmdArgs); return true; } break; case 5: switch (cmdMsg.key) { case 80: // response to FindDevice; length=1, [0] return true; case 81: // notification msg: find phone; watch sends no notification to stop it handleFindPhone(true); return true; } break; case 6: switch (cmdMsg.key) { case 96: // response to ShowNotification; no args case 100: // response to SetDoNotDisturb; no args return true; } break; case 9: switch (cmdMsg.key) { case 146: // response to SetAutoHeartRate; no args return true; } break; case 10: switch (cmdMsg.key) { case 171: // notification msg: heart rate update // int heartRate = cmdArgs[0] & 0xFF; break; case 172: // notification msg: steps update // int32 steps; float calories [kCal]; float distance [km] //int steps = Message.decodeInt32(cmdArgs, 0); break; } break; case 13: switch (cmdMsg.key) { case 2: // notification msg: hang up call // in case of ShowNotification(INCOMING_CALL,..) the watch shows a hang up // button which triggers this notification. Accepting a call is not supported. handleCallReject(); return true; case 4: // notification msg: media player - play case 5: // notification msg: media player - pause // Watch only shows a single play/pause button and has no idea of media player playing state. // So just toggle between play and pause. handleMusicControl(GBDeviceEventMusicControl.Event.PLAYPAUSE); return true; case 6: // notification msg: media player - previous handleMusicControl(GBDeviceEventMusicControl.Event.PREVIOUS); return true; case 7: // notification msg: media player - next handleMusicControl(GBDeviceEventMusicControl.Event.NEXT); return true; } break; } //LOG.debug(" command {},{} | NOT HANDLED", cmdMsg.cmd, cmdMsg.key); return false; } @Override public void onNotification(NotificationSpec notificationSpec) { if (!getDevicePrefsNotificationEnabled()) { return; } Message.NotificationType type = Message.NotificationType.UNKNOWN; switch (notificationSpec.type) { case FACEBOOK: case FACEBOOK_MESSENGER: type = Message.NotificationType.FACEBOOK; break; case GENERIC_SMS: type = Message.NotificationType.SMS; break; case INSTAGRAM: type = Message.NotificationType.INSTAGRAM; break; case LINKEDIN: type = Message.NotificationType.LINKEDIN; break; case TWITTER: type = Message.NotificationType.TWITTER; break; case WHATSAPP: type = Message.NotificationType.WHATSAPP; break; } final String notificationMsg = StringUtils.getFirstOf(notificationSpec.sender, notificationSpec.title); final String uniqueTaskName = "notification" + notificationCount; notificationCount++; sendMessage(uniqueTaskName, Message.encodeShowNotification(type, notificationMsg)); } @Override public void onSetTime() { sendMessage("set-datetime", makeSetDateTimeMessage()); } @Override public void onSetAlarms(ArrayList alarms) { sendMessage("set-alarms", Message.encodeSetAlarms(alarms)); } @Override public void onSetCallState(CallSpec callSpec) { switch (callSpec.command) { case CallSpec.CALL_INCOMING: { if (getDevicePrefsNotificationEnabled()) { final String text = StringUtils.getFirstOf(callSpec.name, callSpec.number); sendMessage("notification-call-incoming", Message.encodeShowNotification(Message.NotificationType.INCOMING_CALL, text)); } break; } case CallSpec.CALL_ACCEPT: // aborts INCOMING_CALL notification sendMessage("notification-call-accept", Message.encodeShowNotification(Message.NotificationType.CALL_ACCEPT, "")); break; case CallSpec.CALL_REJECT: case CallSpec.CALL_END: // aborts INCOMING_CALL notification sendMessage("notification-call-reject", Message.encodeShowNotification(Message.NotificationType.CALL_REJECT, "")); } } @Override public void onFindDevice(boolean start) { if (start) { sendMessage("find-device", Message.encodeFindDevice()); } } @Override public boolean useAutoConnect() { return true; } private boolean sendMessage(String taskName, byte[] message) { try { TransactionBuilder builder = performInitialized(taskName); builder.write(writeCharacteristic, message); builder.queue(getQueue()); } catch (IOException ex) { LOG.error(taskName, ex); return false; } return true; } private boolean getDevicePrefsNotificationEnabled() { final Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress())); return prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_NOTIFICATION_ENABLE, false); } private Message.TimeFormat getDevicePrefsTimeFormat() { Message.TimeFormat timeFormat = null; switch (getDevicePrefs().getTimeFormat()) { case DeviceSettingsPreferenceConst.PREF_TIMEFORMAT_24H: timeFormat = Message.TimeFormat.Format24H; break; case DeviceSettingsPreferenceConst.PREF_TIMEFORMAT_12H: timeFormat = Message.TimeFormat.Format12H; break; } return timeFormat; } private byte[] makeSetDateTimeMessage() { return Message.encodeSetDateTime(Calendar.getInstance(), getDevicePrefsTimeFormat()); } private byte[] makeSetSystemDataMessage() { final Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress())); final String localeString = prefs.getString(DeviceSettingsPreferenceConst.PREF_LANGUAGE, DeviceSettingsPreferenceConst.PREF_LANGUAGE_AUTO); String language; String country; if (localeString.equals(DeviceSettingsPreferenceConst.PREF_LANGUAGE_AUTO)) { language = Locale.getDefault().getLanguage(); country = Locale.getDefault().getCountry(); } else { language = localeString.substring(0, 2); country = localeString.substring(3, 5); } return Message.encodeSetSystemData(language, country, getDevicePrefsTimeFormat()); } private byte[] makeSetUnitMessage() { final Prefs prefs = GBApplication.getPrefs(); String unit = prefs.getString(SettingsActivity.PREF_MEASUREMENT_SYSTEM, "metric"); return Message.encodeSetUnit(unit.equals("metric")); } private byte[] makeSetUserInfoMessage() { final ActivityUser activityUser = new ActivityUser(); return Message.encodeSetUserInfo( (activityUser.getGender() == ActivityUser.GENDER_MALE) ? Message.Gender.MALE : Message.Gender.FEMALE, activityUser.getAge(), activityUser.getHeightCm(), activityUser.getWeightKg(), activityUser.getStepsGoal()); } private byte[] makeSetDoNotDisturbMessage() { final Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress())); final String enabled = prefs.getString(DeviceSettingsPreferenceConst.PREF_DO_NOT_DISTURB_NOAUTO, "off"); if (!enabled.equals(DeviceSettingsPreferenceConst.PREF_DO_NOT_DISTURB_SCHEDULED)) { return Message.encodeSetDoNotDisturb(false, 0, 0, 0, 0); } final String start = prefs.getString(DeviceSettingsPreferenceConst.PREF_DO_NOT_DISTURB_NOAUTO_START, "22:00"); final String end = prefs.getString(DeviceSettingsPreferenceConst.PREF_DO_NOT_DISTURB_NOAUTO_END, "6:00"); final Calendar startCalendar = GregorianCalendar.getInstance(); final Calendar endCalendar = GregorianCalendar.getInstance(); final DateFormat df = new SimpleDateFormat("HH:mm"); try { startCalendar.setTime(df.parse(start)); endCalendar.setTime(df.parse(end)); } catch (ParseException e) { return null; } return Message.encodeSetDoNotDisturb(true, startCalendar.get(Calendar.HOUR_OF_DAY), startCalendar.get(Calendar.MINUTE), endCalendar.get(Calendar.HOUR_OF_DAY), endCalendar.get(Calendar.MINUTE)); } private byte[] makeSetAutoHeartRate() { final Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress())); final boolean enabled = prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_AUTOHEARTRATE_SWITCH, false); if (!enabled) { return Message.encodeSetAutoHeartRate(false, 0, 0, 0, 0, 0); } // PREF_AUTOHEARTRATE_SLEEP is not supported final String start = prefs.getString(DeviceSettingsPreferenceConst.PREF_AUTOHEARTRATE_START, "22:00"); final String end = prefs.getString(DeviceSettingsPreferenceConst.PREF_AUTOHEARTRATE_END, "6:00"); final String intervalStr = prefs.getString(DeviceSettingsPreferenceConst.PREF_AUTOHEARTRATE_INTERVAL, "2"); final Calendar startCalendar = GregorianCalendar.getInstance(); final Calendar endCalendar = GregorianCalendar.getInstance(); final DateFormat df = new SimpleDateFormat("HH:mm"); final int intervalMinutes; try { startCalendar.setTime(df.parse(start)); endCalendar.setTime(df.parse(end)); } catch (ParseException e) { return null; } try { intervalMinutes = Integer.parseInt(intervalStr); } catch (NumberFormatException e) { return null; } return Message.encodeSetAutoHeartRate(true, startCalendar.get(Calendar.HOUR_OF_DAY), startCalendar.get(Calendar.MINUTE), endCalendar.get(Calendar.HOUR_OF_DAY), endCalendar.get(Calendar.MINUTE), intervalMinutes); } private byte[] makeSetHydrationReminderMessage() { final Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress())); boolean enabled = prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_HYDRATION_SWITCH, false); final String intervalStr = prefs.getString(DeviceSettingsPreferenceConst.PREF_HYDRATION_PERIOD, "60"); int intervalMinutes; try { intervalMinutes = Integer.parseInt(intervalStr); } catch (NumberFormatException e) { return null; } if (intervalMinutes == 0) { enabled = false; } // Drink reminder notifications honor the do-not-disturb time range. // Start time must be > then end time; e.g. start=19, end=1 won't work. // enable=true and start=end=0 behaves as disabled. int startHour = 0; int startMin = 0; int endHour = 23; int endMin = 59; return Message.encodeSetHydrationReminder(enabled, startHour, startMin, endHour, endMin, intervalMinutes); } private byte[] makeSetLiftWristMessage() { final Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress())); final String enabled = prefs.getString(DeviceSettingsPreferenceConst.PREF_ACTIVATE_DISPLAY_ON_LIFT, "off"); final boolean isEnabled = !enabled.equals("off"); return Message.encodeSetLiftWristDisplayOn(isEnabled); } private void handleFirmwareVersion(String fwVersion) { final GBDeviceEventVersionInfo event = new GBDeviceEventVersionInfo(); event.fwVersion = fwVersion; event.fwVersion2 = null; event.hwVersion = null; evaluateGBDeviceEvent(event); } private void handleCallReject() { final GBDeviceEventCallControl event = new GBDeviceEventCallControl(); event.event = GBDeviceEventCallControl.Event.REJECT; evaluateGBDeviceEvent(event); } private void handleCameraRemote(GBDeviceEventCameraRemote.Event eventType) { final Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress())); if (!prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_CAMERA_REMOTE, false)) return; final GBDeviceEventCameraRemote event = new GBDeviceEventCameraRemote(); event.event = eventType; evaluateGBDeviceEvent(event); } private void handleMusicControl(GBDeviceEventMusicControl.Event eventType) { final GBDeviceEventMusicControl event = new GBDeviceEventMusicControl(); event.event = eventType; evaluateGBDeviceEvent(event); } private void handleFindPhone(boolean start) { if (start) { SharedPreferences sharedPreferences = GBApplication.getDeviceSpecificSharedPrefs(getDevice().getAddress()); String findPhone = sharedPreferences.getString(PREF_FIND_PHONE, getContext().getString(R.string.p_off)); if (findPhone.equals("off")) return; String durationSecStr = sharedPreferences.getString(PREF_FIND_PHONE_DURATION, ""); int durationSec; try { durationSec = Integer.parseInt(durationSecStr); } catch (Exception ex) { durationSec = 60; } if (durationSec <= 0) return; GBDeviceEventFindPhone findPhoneEvent = new GBDeviceEventFindPhone(); findPhoneEvent.event = GBDeviceEventFindPhone.Event.START; evaluateGBDeviceEvent(findPhoneEvent); try { this.findPhoneStopNotificationHandler.postDelayed(new Runnable() { @Override public void run() { handleFindPhone(false); } }, durationSec * 1000); } catch (Exception ex) { handleFindPhone(false); } } else { GBDeviceEventFindPhone findPhoneEvent = new GBDeviceEventFindPhone(); findPhoneEvent.event = GBDeviceEventFindPhone.Event.STOP; evaluateGBDeviceEvent(findPhoneEvent); } } private void handleBatteryStatus(byte[] cmdArgs) { /* final int level = cmdArgs[0] & 0xFF; final int type = cmdArgs[1] & 0xFF; Disabled since watch always returns a level value of 60 Also missing: status needs to be polled regularly GBDeviceEventBatteryInfo event = new GBDeviceEventBatteryInfo(); event.level = level; evaluateGBDeviceEvent(event); */ } @Override public boolean getImplicitCallbackModify() { return true; } @Override public boolean getSendWriteRequestResponse() { return false; } }