/* Copyright (C) 2023-2024 Andreas Böhler, Johannes Krude 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.casio.gb6900; import android.bluetooth.BluetoothDevice; import android.bluetooth.BluetoothGatt; import android.bluetooth.BluetoothGattCharacteristic; import android.bluetooth.BluetoothGattDescriptor; import android.bluetooth.BluetoothGattService; import android.content.SharedPreferences; import android.widget.Toast; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.Calendar; import java.util.UUID; import java.util.concurrent.TimeUnit; import nodomain.freeyourgadget.gadgetbridge.GBApplication; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventCallControl; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventFindPhone; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventMusicControl; import nodomain.freeyourgadget.gadgetbridge.devices.casio.CasioConstants; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.model.Alarm; import nodomain.freeyourgadget.gadgetbridge.model.CallSpec; import nodomain.freeyourgadget.gadgetbridge.model.MusicSpec; import nodomain.freeyourgadget.gadgetbridge.model.MusicStateSpec; import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec; import nodomain.freeyourgadget.gadgetbridge.service.btle.GattCharacteristic; import nodomain.freeyourgadget.gadgetbridge.service.btle.GattService; import nodomain.freeyourgadget.gadgetbridge.service.btle.ServerTransactionBuilder; import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder; import nodomain.freeyourgadget.gadgetbridge.service.devices.casio.CasioSupport; import nodomain.freeyourgadget.gadgetbridge.util.GB; import nodomain.freeyourgadget.gadgetbridge.util.StringUtils; import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_DISCONNECTNOTIF_NOSHED; public class CasioGB6900DeviceSupport extends CasioSupport { private static final Logger LOG = LoggerFactory.getLogger(CasioGB6900DeviceSupport.class); private final ArrayList mCasioCharacteristics = new ArrayList(); private CasioGB6900HandlerThread mHandlerThread = null; private MusicSpec mBufferMusicSpec = null; private MusicStateSpec mBufferMusicStateSpec = null; private BluetoothGatt mBtGatt = null; private CasioConstants.Model mModel = CasioConstants.Model.MODEL_CASIO_GENERIC; private static final int mCasioSleepTime = 50; public CasioGB6900DeviceSupport() { super(LOG); addSupportedService(GattService.UUID_SERVICE_IMMEDIATE_ALERT); addSupportedService(CasioConstants.CASIO_VIRTUAL_SERVER_SERVICE); addSupportedService(CasioConstants.ALERT_SERVICE_UUID); addSupportedService(CasioConstants.CASIO_IMMEDIATE_ALERT_SERVICE_UUID); addSupportedService(CasioConstants.CURRENT_TIME_SERVICE_UUID); addSupportedService(CasioConstants.WATCH_CTRL_SERVICE_UUID); addSupportedService(CasioConstants.CASIO_PHONE_ALERT_STATUS_SERVICE); addSupportedService(CasioConstants.MORE_ALERT_SERVICE_UUID); addSupportedService(CasioConstants.TX_POWER_SERVICE_UUID); addSupportedService(CasioConstants.LINK_LOSS_SERVICE); addSupportedService(CasioConstants.IMMEDIATE_ALERT_SERVICE_UUID); BluetoothGattService casioGATTService = new BluetoothGattService(CasioConstants.WATCH_CTRL_SERVICE_UUID, BluetoothGattService.SERVICE_TYPE_PRIMARY); BluetoothGattCharacteristic bluetoothGATTCharacteristic = new BluetoothGattCharacteristic(CasioConstants.KEY_CONTAINER_CHARACTERISTIC_UUID, BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE, BluetoothGattCharacteristic.PERMISSION_WRITE); bluetoothGATTCharacteristic.setValue(new byte[0]); BluetoothGattCharacteristic bluetoothGATTCharacteristic2 = new BluetoothGattCharacteristic(CasioConstants.NAME_OF_APP_CHARACTERISTIC_UUID, BluetoothGattCharacteristic.PROPERTY_READ, BluetoothGattCharacteristic.PERMISSION_READ | BluetoothGattCharacteristic.PERMISSION_READ_ENCRYPTED); bluetoothGATTCharacteristic2.setValue(CasioConstants.MUSIC_MESSAGE.getBytes()); BluetoothGattDescriptor bluetoothGattDescriptor = new BluetoothGattDescriptor(CasioConstants.CCC_DESCRIPTOR_UUID, BluetoothGattDescriptor.PERMISSION_READ | BluetoothGattDescriptor.PERMISSION_WRITE); bluetoothGattDescriptor.setValue(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE); bluetoothGATTCharacteristic2.addDescriptor(bluetoothGattDescriptor); casioGATTService.addCharacteristic(bluetoothGATTCharacteristic); casioGATTService.addCharacteristic(bluetoothGATTCharacteristic2); addSupportedServerService(casioGATTService); } @Override public boolean useAutoConnect() { return true; } @Override public boolean connectFirstTime() { GB.toast(getContext(), "After first connect, disable and enable bluetooth on your Casio watch to really connect", Toast.LENGTH_SHORT, GB.INFO); mFirstConnect = true; return super.connect(); } @Override public void dispose() { LOG.info("Dispose"); close(); super.dispose(); } private void close() { if (mHandlerThread != null) { mHandlerThread.quit(); mHandlerThread.interrupt(); mHandlerThread = null; } } @Override public void onServicesDiscovered(BluetoothGatt gatt) { mBtGatt = gatt; super.onServicesDiscovered(gatt); } @Override protected TransactionBuilder initializeDevice(TransactionBuilder builder) { LOG.info("Initializing"); if(mFirstConnect) { setInitialized(); getDevice().setFirmwareVersion("N/A"); getDevice().setFirmwareVersion2("N/A"); builder.bond(); return builder; } String name = gbDevice.getName(); if(name.contains("5600B")) { mModel = CasioConstants.Model.MODEL_CASIO_5600B; } else if(name.contains("6900B")) { mModel = CasioConstants.Model.MODEL_CASIO_6900B; } else if(name.contains("STB-1000")) { mModel = CasioConstants.Model.MODEL_CASIO_STB1000; } else { mModel = CasioConstants.Model.MODEL_CASIO_GENERIC; } // We skip configuring BLE settings for the STB-1000 since it powers off // BLE on the watch. if(mModel != CasioConstants.Model.MODEL_CASIO_STB1000) { try { new InitOperation(this, builder).perform(); } catch (IOException e) { GB.toast(getContext(), "Initializing Casio watch failed", Toast.LENGTH_SHORT, GB.ERROR, e); } } getDevice().setFirmwareVersion("N/A"); getDevice().setFirmwareVersion2("N/A"); builder.setCallback(this); configureWatch(builder); addCharacteristics(); enableNotifications(builder, true); LOG.info("Initialization Done"); return builder; } CasioConstants.Model getModel() { return mModel; } private void configureWatch(TransactionBuilder builder) { if (mBtGatt == null) { return; } SharedPreferences sharedPreferences = GBApplication.getDeviceSpecificSharedPrefs(getDevice().getAddress()); boolean enable = sharedPreferences.getBoolean(PREF_DISCONNECTNOTIF_NOSHED, false); byte[] value = new byte[1]; if(enable) value[0] = GattCharacteristic.MILD_ALERT; else value[0] = GattCharacteristic.NO_ALERT; BluetoothGattService llService = mBtGatt.getService(CasioConstants.LINK_LOSS_SERVICE); BluetoothGattCharacteristic charact = llService.getCharacteristic(CasioConstants.ALERT_LEVEL_CHARACTERISTIC_UUID); builder.write(charact, value); builder.wait(mCasioSleepTime); } private void addCharacteristics() { mCasioCharacteristics.clear(); mCasioCharacteristics.add(getCharacteristic(CasioConstants.CASIO_A_NOT_COM_SET_NOT)); mCasioCharacteristics.add(getCharacteristic(CasioConstants.CASIO_A_NOT_W_REQ_NOT)); mCasioCharacteristics.add(getCharacteristic(CasioConstants.ALERT_LEVEL_CHARACTERISTIC_UUID)); mCasioCharacteristics.add(getCharacteristic(CasioConstants.RINGER_CONTROL_POINT)); } public boolean enableNotifications(TransactionBuilder builder, boolean enable) { for(BluetoothGattCharacteristic charact : mCasioCharacteristics) { builder.notify(charact, enable); builder.wait(mCasioSleepTime); } return true; } public void readTxPowerLevel() { try { TransactionBuilder builder = performInitialized("readTxPowerLevel"); builder.read(getCharacteristic(CasioConstants.TX_POWER_LEVEL_CHARACTERISTIC_UUID)); builder.queue(getQueue()); } catch (IOException e) { LOG.warn("readTxPowerLevel failed: " + e.getMessage()); } } private void writeCasioCurrentTime(TransactionBuilder builder) { byte[] arr = prepareCurrentTime(ZonedDateTime.now()); BluetoothGattCharacteristic charact = getCharacteristic(CasioConstants.CURRENT_TIME_CHARACTERISTIC_UUID); if(charact != null) { charact.setWriteType(BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT); builder.write(charact, arr); } else { LOG.warn("Characteristic not found: CURRENT_TIME_CHARACTERISTIC_UUID"); } } private void writeCasioLocalTimeInformation(TransactionBuilder builder) { Calendar cal = Calendar.getInstance(); int zoneOffset = (int)TimeUnit.MILLISECONDS.toMinutes(cal.get(Calendar.ZONE_OFFSET)); int dstOffset = (int)TimeUnit.MILLISECONDS.toMinutes(cal.get(Calendar.DST_OFFSET)); byte byte0 = (byte)(zoneOffset / 15); byte byte1 = (byte)(dstOffset / 15); BluetoothGattCharacteristic charact = getCharacteristic(CasioConstants.LOCAL_TIME_CHARACTERISTIC_UUID); if(charact != null) { builder.write(charact, new byte[]{byte0, byte1}); } else { LOG.warn("Characteristic not found: LOCAL_TIME_CHARACTERISTIC_UUID"); } } private void writeCasioVirtualServerFeature(TransactionBuilder builder) { byte byte0 = (byte)0; byte0 |= 1; // Casio Current Time Service byte0 |= 2; // Casio Alert Notification Service byte0 |= 4; // Casio Phone Alert Status Service byte0 |= 8; // Casio Immediate Alert Service BluetoothGattCharacteristic charact = getCharacteristic(CasioConstants.CASIO_VIRTUAL_SERVER_FEATURES); if(charact != null) { builder.write(charact, new byte[]{byte0, 0x00}); } else { LOG.warn("Characteristic not found: CASIO_VIRTUAL_SERVER_FEATURES"); } } private boolean handleInitResponse(byte data) { boolean handled = false; switch (data) { case (byte) 1: LOG.info("Initialization done, setting state to INITIALIZED"); if (mHandlerThread != null) { if (mHandlerThread.isAlive()) { mHandlerThread.quit(); mHandlerThread.interrupt(); } } mHandlerThread = new CasioGB6900HandlerThread(getDevice(), getContext(), this); mHandlerThread.start(); gbDevice.setState(GBDevice.State.INITIALIZED); gbDevice.sendDeviceUpdateIntent(getContext()); handled = true; break; default: LOG.warn("handleInitResponse: Error initializing device, received unexpected value: " + data); gbDevice.setState(GBDevice.State.NOT_CONNECTED); gbDevice.sendDeviceUpdateIntent(getContext()); handled = true; break; } return handled; } private boolean handleTimeRequests(byte data) { boolean handled = false; switch(data) // Request Type { case (byte) 1: try { TransactionBuilder builder = createTransactionBuilder("writeCasioCurrentTime"); writeCasioCurrentTime(builder); performConnected(builder.getTransaction()); handled = true; } catch (IOException e) { LOG.warn("handleTimeRequests::writeCasioCurrentTime failed: " + e.getMessage()); } break; case (byte) 2: try { TransactionBuilder builder = createTransactionBuilder("writeCasioLocalTimeInformation"); writeCasioLocalTimeInformation(builder); performConnected(builder.getTransaction()); handled = true; } catch (IOException e) { LOG.warn("handleTimeRequests::writeCasioLocalTimeInformation failed: " + e.getMessage()); } break; } return handled; } private boolean handleServerFeatureRequests(byte data) { try { TransactionBuilder builder = createTransactionBuilder("writeCasioVirtualServerFeature"); writeCasioVirtualServerFeature(builder); performConnected(builder.getTransaction()); } catch (IOException e) { LOG.warn("handleServerFeatureRequests failed: " + e.getMessage()); } return true; } private boolean handleCasioCom(byte[] data, boolean handleTime) { boolean handled = false; if(data.length < 3) { LOG.warn("handleCasioCom failed: Received unexpected request (too short)"); return false; } switch(data[0]) // ServiceID { case 0: handled = handleInitResponse(data[2]); break; case 2: if(handleTime) { handled = handleTimeRequests(data[2]); } else { handled = true; } break; case 7: handled = handleServerFeatureRequests(data[2]); break; } return handled; } @Override public boolean onCharacteristicRead(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) { UUID characteristicUUID = characteristic.getUuid(); byte[] data = characteristic.getValue(); if(data.length == 0) return true; if(characteristicUUID.equals(CasioConstants.TX_POWER_LEVEL_CHARACTERISTIC_UUID)) { StringBuilder str = new StringBuilder("onCharacteristicRead: Received power level: "); for(int i=0; i 18 ? 18 : titleBytes.length; byte[] msg = new byte[2 + len]; msg[0] = icon; msg[1] = 1; System.arraycopy(titleBytes, 0, msg, 2, len); builder.write(getCharacteristic(CasioConstants.ALERT_CHARACTERISTIC_UUID), msg); LOG.info("Showing notification, title: " + title + " message (not sent): " + message); builder.queue(getQueue()); } catch (IOException e) { LOG.warn("showNotification failed: " + e.getMessage()); } } @Override public void onNotification(NotificationSpec notificationSpec) { String notificationTitle = StringUtils.getFirstOf(notificationSpec.sender, notificationSpec.title); byte icon; switch (notificationSpec.type.getGenericType()) { case "generic_sms": icon = CasioConstants.SMS_NOTIFICATION_ID; break; case "generic_calendar": icon = CasioConstants.CALENDAR_NOTIFICATION_ID; break; case "generic_email": icon = CasioConstants.MAIL_NOTIFICATION_ID; break; default: icon = CasioConstants.SNS_NOTIFICATION_ID; break; } showNotification(icon, notificationTitle, notificationSpec.body); } @Override public void onSetAlarms(ArrayList alarms) { if(!isConnected()) return; try { new SetAlarmOperation(this, alarms).perform(); } catch (IOException e) { LOG.error("Setting alarm failed: " + e.getMessage()); } } @Override public void onSetTime() { if(!isConnected()) return; try { TransactionBuilder builder = performInitialized("SetTime"); writeCasioLocalTimeInformation(builder); writeCasioCurrentTime(builder); builder.queue(getQueue()); } catch(IOException e) { LOG.warn("onSetTime failed: " + e.getMessage()); } } @Override public void onSetCallState(CallSpec callSpec) { switch (callSpec.command) { case CallSpec.CALL_INCOMING: showNotification(CasioConstants.CALL_NOTIFICATION_ID, callSpec.name, callSpec.number); break; default: LOG.info("not sending CallSpec since only CALL_INCOMING is handled"); break; } } @Override public void onSetMusicState(MusicStateSpec stateSpec) { if(stateSpec != mBufferMusicStateSpec) { mBufferMusicStateSpec = stateSpec; sendMusicInfo(); } } private void sendMusicInfo() { if(!isConnected()) return; try { TransactionBuilder builder = performInitialized("sendMusicInfo"); String info = ""; if (mBufferMusicSpec.track != null && mBufferMusicSpec.track.length() > 0) { info += mBufferMusicSpec.track; } if (mBufferMusicSpec.album != null && mBufferMusicSpec.album.length() > 0) { info += mBufferMusicSpec.album; } if (mBufferMusicSpec.artist != null && mBufferMusicSpec.artist.length() > 0) { info += mBufferMusicSpec.artist; } byte[] bInfo = info.getBytes(StandardCharsets.US_ASCII); int len = bInfo.length > 17 ? 17 : bInfo.length; byte[] arr = new byte[len + 3]; arr[0] = 0; arr[1] = 10; arr[2] = 1; System.arraycopy(bInfo, 0, arr, 3, len); builder.write(getCharacteristic(CasioConstants.MORE_ALERT_FOR_LONG_UUID), arr); builder.queue(getQueue()); } catch (IOException e) { LOG.warn("sendMusicInfo failed: " + e.getMessage()); } } @Override public void onSetMusicInfo(MusicSpec musicSpec) { if(musicSpec != mBufferMusicSpec) { mBufferMusicSpec = musicSpec; sendMusicInfo(); } } @Override public void onFindDevice(boolean start) { if (!isConnected()) { return; } if (mBtGatt == null) { return; } if (start) { try { TransactionBuilder builder = performInitialized("findDevice"); byte[] value = new byte[]{GattCharacteristic.HIGH_ALERT}; BluetoothGattService service = mBtGatt.getService(CasioConstants.IMMEDIATE_ALERT_SERVICE_UUID); BluetoothGattCharacteristic charact = service.getCharacteristic(CasioConstants.ALERT_LEVEL_CHARACTERISTIC_UUID); builder.write(charact, value); LOG.info("onFindDevice sent"); builder.queue(getQueue()); } catch (IOException e) { LOG.warn("showNotification failed: " + e.getMessage()); } } } @Override public boolean onCharacteristicReadRequest(BluetoothDevice device, int requestId, int offset, BluetoothGattCharacteristic characteristic) { if (!characteristic.getUuid().equals(CasioConstants.NAME_OF_APP_CHARACTERISTIC_UUID)) { LOG.warn("unexpected read request"); return false; } LOG.info("will send response to read request from device: " + device.getAddress()); try { ServerTransactionBuilder builder = performServer("sendNameOfApp"); builder.writeServerResponse(device, requestId, 0, offset, CasioConstants.MUSIC_MESSAGE.getBytes()); builder.queue(getQueue()); } catch (IOException e) { LOG.warn("sendMusicInfo failed: " + e.getMessage()); } return true; } private GBDeviceEventMusicControl.Event parse5Button(int button) { GBDeviceEventMusicControl.Event event; switch(button) { case 5: event = GBDeviceEventMusicControl.Event.PLAYPAUSE; break; case 4: event = GBDeviceEventMusicControl.Event.NEXT; break; case 3: event = GBDeviceEventMusicControl.Event.VOLUMEUP; break; case 2: event = GBDeviceEventMusicControl.Event.PREVIOUS; break; case 1: event = GBDeviceEventMusicControl.Event.VOLUMEDOWN; break; default: LOG.warn("Unhandled button received: " + button); event = GBDeviceEventMusicControl.Event.UNKNOWN; } return event; } private GBDeviceEventMusicControl.Event parse3Button(int button) { GBDeviceEventMusicControl.Event event; switch(button) { case 3: event = GBDeviceEventMusicControl.Event.NEXT; break; case 2: event = GBDeviceEventMusicControl.Event.PREVIOUS; break; case 1: event = GBDeviceEventMusicControl.Event.PLAYPAUSE; break; default: LOG.warn("Unhandled button received: " + button); event = GBDeviceEventMusicControl.Event.UNKNOWN; } return event; } private GBDeviceEventMusicControl.Event parse2Button(int button) { GBDeviceEventMusicControl.Event event; switch(button) { case 2: event = GBDeviceEventMusicControl.Event.PLAYPAUSE; break; case 1: event = GBDeviceEventMusicControl.Event.NEXT; break; default: LOG.warn("Unhandled button received: " + button); event = GBDeviceEventMusicControl.Event.UNKNOWN; } return event; } @Override public boolean onCharacteristicWriteRequest(BluetoothDevice device, int requestId, BluetoothGattCharacteristic characteristic, boolean preparedWrite, boolean responseNeeded, int offset, byte[] value) { GBDeviceEventMusicControl musicCmd = new GBDeviceEventMusicControl(); if (!characteristic.getUuid().equals(CasioConstants.KEY_CONTAINER_CHARACTERISTIC_UUID)) { LOG.warn("unexpected write request"); return false; } if((value[0] & 0x03) == 0) { int button = value[1] & 0x0f; LOG.info("Button pressed: " + button); switch(getModel()) { case MODEL_CASIO_5600B: musicCmd.event = parse2Button(button); break; case MODEL_CASIO_6900B: case MODEL_CASIO_GENERIC: musicCmd.event = parse3Button(button); break; case MODEL_CASIO_STB1000: musicCmd.event = parse5Button(button); break; default: LOG.warn("Unhandled device"); return false; } evaluateGBDeviceEvent(musicCmd); } else { LOG.info("received from device: " + value.toString()); } return true; } }