/* Copyright (C) 2020 Andreas Shimokawa, Taavi Eomäe 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.pinetime; import android.bluetooth.BluetoothGatt; import android.bluetooth.BluetoothGattCharacteristic; import android.content.Context; import android.content.Intent; import android.net.Uri; import android.widget.Toast; import androidx.localbroadcastmanager.content.LocalBroadcastManager; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.nio.ByteBuffer; import java.util.ArrayList; import java.util.GregorianCalendar; import java.util.Locale; import java.util.UUID; import no.nordicsemi.android.dfu.DfuLogListener; import no.nordicsemi.android.dfu.DfuProgressListener; import no.nordicsemi.android.dfu.DfuProgressListenerAdapter; import no.nordicsemi.android.dfu.DfuServiceController; import no.nordicsemi.android.dfu.DfuServiceInitiator; import no.nordicsemi.android.dfu.DfuServiceListenerHelper; import nodomain.freeyourgadget.gadgetbridge.R; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventMusicControl; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventVersionInfo; import nodomain.freeyourgadget.gadgetbridge.devices.pinetime.PineTimeDFUService; import nodomain.freeyourgadget.gadgetbridge.devices.pinetime.PineTimeInstallHandler; 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.BLETypeConversions; import nodomain.freeyourgadget.gadgetbridge.service.btle.GattCharacteristic; 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.alertnotification.AlertCategory; import nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.alertnotification.AlertNotificationProfile; import nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.alertnotification.NewAlert; import nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.alertnotification.OverflowStrategy; import nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.deviceinfo.DeviceInfo; import nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.deviceinfo.DeviceInfoProfile; import nodomain.freeyourgadget.gadgetbridge.util.GB; public class PineTimeJFSupport extends AbstractBTLEDeviceSupport implements DfuLogListener { private static final Logger LOG = LoggerFactory.getLogger(PineTimeJFSupport.class); /** * These are used to keep track when long strings haven't changed, * thus avoiding unnecessary transfers that are (potentially) very slow. *

* Makes the device's UI more responsive. */ String lastAlbum; String lastTrack; String lastArtist; private final GBDeviceEventVersionInfo versionCmd = new GBDeviceEventVersionInfo(); private final DeviceInfoProfile deviceInfoProfile; PineTimeInstallHandler handler; DfuServiceController controller; private final DfuProgressListener progressListener = new DfuProgressListenerAdapter() { private final LocalBroadcastManager manager = LocalBroadcastManager.getInstance(getContext()); /** * Sets the progress bar to indeterminate or not, also makes it visible * * @param indeterminate if indeterminate */ public void setIndeterminate(boolean indeterminate) { manager.sendBroadcast(new Intent(GB.ACTION_SET_PROGRESS_BAR).putExtra(GB.PROGRESS_BAR_INDETERMINATE, indeterminate)); } /** * Sets the status text and logs it */ public void setProgress(int progress) { manager.sendBroadcast(new Intent(GB.ACTION_SET_PROGRESS_BAR).putExtra(GB.PROGRESS_BAR_PROGRESS, progress)); } /** * Sets the text that describes progress * * @param progressText text to display */ public void setProgressText(String progressText) { manager.sendBroadcast(new Intent(GB.ACTION_SET_PROGRESS_TEXT).putExtra(GB.DISPLAY_MESSAGE_MESSAGE, progressText)); } @Override public void onDeviceConnecting(final String mac) { this.setIndeterminate(true); this.setProgressText(getContext().getString(R.string.devicestatus_connecting)); } @Override public void onDeviceConnected(final String mac) { this.setIndeterminate(true); this.setProgressText(getContext().getString(R.string.devicestatus_connected)); } @Override public void onEnablingDfuMode(final String mac) { this.setIndeterminate(true); this.setProgressText(getContext().getString(R.string.devicestatus_upload_starting)); } @Override public void onDfuProcessStarting(final String mac) { this.setIndeterminate(true); this.setProgressText(getContext().getString(R.string.devicestatus_upload_starting)); } @Override public void onDfuProcessStarted(final String mac) { this.setIndeterminate(true); this.setProgressText(getContext().getString(R.string.devicestatus_upload_started)); } @Override public void onDeviceDisconnecting(final String mac) { this.setProgressText(getContext().getString(R.string.devicestatus_disconnecting)); } @Override public void onDeviceDisconnected(final String mac) { this.setIndeterminate(true); this.setProgressText(getContext().getString(R.string.devicestatus_disconnected)); } @Override public void onDfuCompleted(final String mac) { this.setProgressText(getContext().getString(R.string.devicestatus_upload_completed)); this.setIndeterminate(false); this.setProgress(100); handler = null; controller = null; DfuServiceListenerHelper.unregisterProgressListener(getContext(), progressListener); // TODO: Request reconnection } @Override public void onFirmwareValidating(final String mac) { this.setIndeterminate(true); this.setProgressText(getContext().getString(R.string.devicestatus_upload_validating)); } @Override public void onDfuAborted(final String mac) { this.setProgressText(getContext().getString(R.string.devicestatus_upload_aborted)); } @Override public void onError(final String mac, int error, int errorType, final String message) { this.setProgressText(getContext().getString(R.string.devicestatus_upload_failed)); } @Override public void onProgressChanged(final String mac, int percent, float speed, float averageSpeed, int segment, int totalSegments) { this.setProgress(percent); this.setIndeterminate(false); this.setProgressText(String.format(Locale.ENGLISH, getContext().getString(R.string.firmware_update_progress), percent, speed, averageSpeed, segment, totalSegments)); } }; public PineTimeJFSupport() { super(LOG); addSupportedService(GattService.UUID_SERVICE_ALERT_NOTIFICATION); addSupportedService(GattService.UUID_SERVICE_CURRENT_TIME); addSupportedService(GattService.UUID_SERVICE_DEVICE_INFORMATION); addSupportedService(PineTimeJFConstants.UUID_SERVICE_MUSIC_CONTROL); deviceInfoProfile = new DeviceInfoProfile<>(this); IntentListener mListener = new IntentListener() { @Override public void notify(Intent intent) { String action = intent.getAction(); if (DeviceInfoProfile.ACTION_DEVICE_INFO.equals(action)) { handleDeviceInfo((nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.deviceinfo.DeviceInfo) intent.getParcelableExtra(DeviceInfoProfile.EXTRA_DEVICE_INFO)); } } }; deviceInfoProfile.addListener(mListener); AlertNotificationProfile alertNotificationProfile = new AlertNotificationProfile<>(this); addSupportedProfile(alertNotificationProfile); addSupportedProfile(deviceInfoProfile); } @Override protected TransactionBuilder initializeDevice(TransactionBuilder builder) { builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.INITIALIZING, getContext())); requestDeviceInfo(builder); onSetTime(); builder.notify(getCharacteristic(UUID_CHARACTERISTICS_MUSIC_EVENT), true); setInitialized(builder); return builder; } private void setInitialized(TransactionBuilder builder) { builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.INITIALIZED, getContext())); } private void requestDeviceInfo(TransactionBuilder builder) { LOG.debug("Requesting Device Info!"); deviceInfoProfile.requestDeviceInfo(builder); } private void handleDeviceInfo(DeviceInfo info) { LOG.warn("Device info: " + info); versionCmd.hwVersion = info.getHardwareRevision(); versionCmd.fwVersion = info.getFirmwareRevision(); handleGBDeviceEvent(versionCmd); } @Override public boolean useAutoConnect() { return false; } @Override public void onNotification(NotificationSpec notificationSpec) { TransactionBuilder builder = new TransactionBuilder("notification"); NewAlert alert = new NewAlert(AlertCategory.CustomHuami, 1, notificationSpec.body + " "); // HACK: no idea why the last byte is swallowed AlertNotificationProfile profile = new AlertNotificationProfile<>(this); profile.newAlert(builder, alert, OverflowStrategy.TRUNCATE); builder.queue(getQueue()); } @Override public void onDeleteNotification(int id) { } @Override public void onSetTime() { // Since this is a standard we should generalize this in Gadgetbridge (properly) GregorianCalendar now = BLETypeConversions.createCalendar(); byte[] bytes = BLETypeConversions.calendarToRawBytes(now); byte[] tail = new byte[]{0, BLETypeConversions.mapTimeZone(now.getTimeZone(), BLETypeConversions.TZ_FLAG_INCLUDE_DST_IN_TZ)}; byte[] all = BLETypeConversions.join(bytes, tail); TransactionBuilder builder = new TransactionBuilder("set time"); builder.write(getCharacteristic(GattCharacteristic.UUID_CHARACTERISTIC_CURRENT_TIME), all); builder.queue(getQueue()); } @Override public void onSetAlarms(ArrayList alarms) { } @Override public void onSetCallState(CallSpec callSpec) { } @Override public void onSetCannedMessages(CannedMessagesSpec cannedMessagesSpec) { } @Override public void onEnableRealtimeSteps(boolean enable) { } @Override public void onInstallApp(Uri uri) { try { handler = new PineTimeInstallHandler(uri, getContext()); // TODO: Check validity more closely if (true) { DfuServiceInitiator starter = new DfuServiceInitiator(getDevice().getAddress()) .setDeviceName(getDevice().getName()) .setKeepBond(true) .setForeground(false) .setUnsafeExperimentalButtonlessServiceInSecureDfuEnabled(false) .setMtu(517) .setZip(uri); controller = starter.start(getContext(), PineTimeDFUService.class); DfuServiceListenerHelper.registerProgressListener(getContext(), progressListener); DfuServiceListenerHelper.registerLogListener(getContext(), this); LocalBroadcastManager.getInstance(getContext()).sendBroadcast(new Intent(GB.ACTION_SET_PROGRESS_BAR) .putExtra(GB.PROGRESS_BAR_INDETERMINATE, true) ); LocalBroadcastManager.getInstance(getContext()).sendBroadcast(new Intent(GB.ACTION_SET_PROGRESS_TEXT) .putExtra(GB.DISPLAY_MESSAGE_MESSAGE, getContext().getString(R.string.devicestatus_upload_starting)) ); } else { // TODO: Handle invalid firmware files } } catch (Exception ex) { GB.toast(getContext(), getContext().getString(R.string.updatefirmwareoperation_write_failed) + ":" + ex.getMessage(), Toast.LENGTH_LONG, GB.ERROR, ex); } } @Override public void onAppInfoReq() { } @Override public void onAppStart(UUID uuid, boolean start) { } @Override public void onAppDelete(UUID uuid) { } @Override public void onAppConfiguration(UUID appUuid, String config, Integer id) { } @Override public void onAppReorder(UUID[] uuids) { } @Override public void onFetchRecordedData(int dataTypes) { } @Override public void onReset(int flags) { } @Override public void onHeartRateTest() { } @Override public void onEnableRealtimeHeartRateMeasurement(boolean enable) { } @Override public void onFindDevice(boolean start) { TransactionBuilder builder = new TransactionBuilder("Enable alert"); builder.write(getCharacteristic(GattCharacteristic.UUID_CHARACTERISTIC_ALERT_LEVEL), new byte[]{(byte) (start ? 0x01 : 0x00)}); builder.queue(getQueue()); } @Override public void onSetConstantVibration(int intensity) { } @Override public void onScreenshotReq() { } @Override public void onEnableHeartRateSleepSupport(boolean enable) { } @Override public void onSetHeartRateMeasurementInterval(int seconds) { } @Override public void onAddCalendarEvent(CalendarEventSpec calendarEventSpec) { } @Override public void onDeleteCalendarEvent(byte type, long id) { } @Override protected TransactionBuilder initializeDevice(TransactionBuilder builder) { builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.INITIALIZING, getContext())); requestDeviceInfo(builder); onSetTime(); builder.notify(getCharacteristic(PineTimeJFConstants.UUID_CHARACTERISTICS_MUSIC_EVENT), true); setInitialized(builder); return builder; } @Override public void onSetMusicInfo(MusicSpec musicSpec) { try { TransactionBuilder builder = performInitialized("send playback info"); if (musicSpec.album != null && !musicSpec.album.equals(lastAlbum)) { lastAlbum = musicSpec.album; safeWriteToCharacteristic(builder, PineTimeJFConstants.UUID_CHARACTERISTICS_MUSIC_ALBUM, musicSpec.album.getBytes()); } if (musicSpec.track != null && !musicSpec.track.equals(lastTrack)) { lastTrack = musicSpec.track; safeWriteToCharacteristic(builder, PineTimeJFConstants.UUID_CHARACTERISTICS_MUSIC_TRACK, musicSpec.track.getBytes()); } if (musicSpec.artist != null && !musicSpec.artist.equals(lastArtist)) { lastArtist = musicSpec.artist; safeWriteToCharacteristic(builder, PineTimeJFConstants.UUID_CHARACTERISTICS_MUSIC_ARTIST, musicSpec.artist.getBytes()); } if (musicSpec.duration != MusicSpec.MUSIC_UNKNOWN) { safeWriteToCharacteristic(builder, PineTimeJFConstants.UUID_CHARACTERISTICS_MUSIC_LENGTH_TOTAL, intToBytes(musicSpec.duration)); } if (musicSpec.trackNr != MusicSpec.MUSIC_UNKNOWN) { safeWriteToCharacteristic(builder, PineTimeJFConstants.UUID_CHARACTERISTICS_MUSIC_TRACK_NUMBER, intToBytes(musicSpec.trackNr)); } if (musicSpec.trackCount != MusicSpec.MUSIC_UNKNOWN) { safeWriteToCharacteristic(builder, PineTimeJFConstants.UUID_CHARACTERISTICS_MUSIC_TRACK_TOTAL, intToBytes(musicSpec.trackCount)); } builder.queue(getQueue()); } catch (Exception e) { LOG.error("Error sending music info", e); } } @Override public void onSetMusicState(MusicStateSpec stateSpec) { try { TransactionBuilder builder = performInitialized("send playback state"); if (stateSpec.state != MusicStateSpec.STATE_UNKNOWN) { byte[] state = new byte[1]; if (stateSpec.state == MusicStateSpec.STATE_PLAYING) { state[0] = 0x01; } safeWriteToCharacteristic(builder, PineTimeJFConstants.UUID_CHARACTERISTICS_MUSIC_STATUS, state); } if (stateSpec.playRate != MusicStateSpec.STATE_UNKNOWN) { safeWriteToCharacteristic(builder, PineTimeJFConstants.UUID_CHARACTERISTICS_MUSIC_PLAYBACK_SPEED, intToBytes(stateSpec.playRate)); } if (stateSpec.position != MusicStateSpec.STATE_UNKNOWN) { safeWriteToCharacteristic(builder, PineTimeJFConstants.UUID_CHARACTERISTICS_MUSIC_POSITION, intToBytes(stateSpec.position)); } if (stateSpec.repeat != MusicStateSpec.STATE_UNKNOWN) { safeWriteToCharacteristic(builder, PineTimeJFConstants.UUID_CHARACTERISTICS_MUSIC_REPEAT, intToBytes(stateSpec.repeat)); } if (stateSpec.shuffle != MusicStateSpec.STATE_UNKNOWN) { safeWriteToCharacteristic(builder, PineTimeJFConstants.UUID_CHARACTERISTICS_MUSIC_SHUFFLE, intToBytes(stateSpec.repeat)); } builder.queue(getQueue()); } catch (Exception e) { LOG.error("Error sending music state", e); } } @Override public boolean onCharacteristicRead(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) { if (super.onCharacteristicRead(gatt, characteristic, status)) { return true; } UUID characteristicUUID = characteristic.getUuid(); LOG.info("Unhandled characteristic read: " + characteristicUUID); return false; } @Override public void onSendConfiguration(String config) { } @Override public void onReadConfiguration(String config) { } @Override public void onTestNewFunction() { } @Override public boolean onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) { if (super.onCharacteristicChanged(gatt, characteristic)) { return true; } UUID characteristicUUID = characteristic.getUuid(); if (characteristicUUID.equals(PineTimeJFConstants.UUID_CHARACTERISTICS_MUSIC_EVENT)) { byte[] value = characteristic.getValue(); GBDeviceEventMusicControl deviceEventMusicControl = new GBDeviceEventMusicControl(); switch (value[0]) { case 0: deviceEventMusicControl.event = GBDeviceEventMusicControl.Event.PLAY; break; case 1: deviceEventMusicControl.event = GBDeviceEventMusicControl.Event.PAUSE; break; case 3: deviceEventMusicControl.event = GBDeviceEventMusicControl.Event.NEXT; break; case 4: deviceEventMusicControl.event = GBDeviceEventMusicControl.Event.PREVIOUS; break; case 5: deviceEventMusicControl.event = GBDeviceEventMusicControl.Event.VOLUMEUP; break; case 6: deviceEventMusicControl.event = GBDeviceEventMusicControl.Event.VOLUMEDOWN; break; default: return false; } evaluateGBDeviceEvent(deviceEventMusicControl); return true; } LOG.info("Unhandled characteristic changed: " + characteristicUUID); return false; } @Override public void onSendWeather(WeatherSpec weatherSpec) { } /** * Helper function that just converts an integer into a byte array */ private static byte[] intToBytes(int source) { return ByteBuffer.allocate(4).putInt(source).array(); } /** * This will check if the characteristic exists and can be written *

* Keeps backwards compatibility with firmware that can't take all the information */ private void safeWriteToCharacteristic(TransactionBuilder builder, UUID uuid, byte[] data) { BluetoothGattCharacteristic characteristic = getCharacteristic(uuid); if (characteristic != null && (characteristic.getProperties() & BluetoothGattCharacteristic.PROPERTY_WRITE) > 0) { builder.write(characteristic, data); } } private void setInitialized(TransactionBuilder builder) { builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.INITIALIZED, getContext())); } private void requestDeviceInfo(TransactionBuilder builder) { LOG.debug("Requesting Device Info!"); deviceInfoProfile.requestDeviceInfo(builder); } private void handleDeviceInfo(nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.deviceinfo.DeviceInfo info) { LOG.warn("Device info: " + info); versionCmd.hwVersion = info.getHardwareRevision(); versionCmd.fwVersion = info.getFirmwareRevision(); handleGBDeviceEvent(versionCmd); } /** * Nordic DFU needs this function to log DFU-related messages */ @Override public void onLogEvent(final String deviceAddress, final int level, final String message) { LOG.debug(message); } }