|
| 1 | +package com.health.openscale.core.bluetooth.scales |
| 2 | + |
| 3 | +import com.health.openscale.R |
| 4 | +import com.health.openscale.core.bluetooth.data.ScaleMeasurement |
| 5 | +import com.health.openscale.core.bluetooth.data.ScaleUser |
| 6 | +import com.health.openscale.core.bluetooth.libs.EtekcityLib |
| 7 | +import com.health.openscale.core.data.WeightUnit |
| 8 | +import com.health.openscale.core.service.ScannedDeviceInfo |
| 9 | +import java.util.Date |
| 10 | +import java.util.UUID |
| 11 | + |
| 12 | +// Based on https://github.com/ronnnnnnnnnnnnn/etekcity_esf551_ble |
| 13 | + |
| 14 | +/** |
| 15 | + * Etekcity ESF-551 scale handler |
| 16 | + */ |
| 17 | +class EtekcityESF551Handler : ScaleDeviceHandler() { |
| 18 | + |
| 19 | + companion object { |
| 20 | + // TODO: Why can't uuid16 be used at the companion level? It's currently coupled to ScaleDeviceHandler. |
| 21 | + private val SCALE_SERVICE = UUID.fromString("0000fff0-0000-1000-8000-00805f9b34fb") |
| 22 | + private val WEIGHT_CHARACTERISTIC_NOTIFY = UUID.fromString("0000fff1-0000-1000-8000-00805f9b34fb") |
| 23 | + private val ALIRO_CHARACTERISTIC = UUID.fromString("0000fff2-0000-1000-8000-00805f9b34fb") |
| 24 | + |
| 25 | + // TODO: These services and characteristics probably should be defined globally for all handlers. |
| 26 | + private val DEVICE_INFORMATION_SERVICE = UUID.fromString("0000180a-0000-1000-8000-00805f9b34fb") |
| 27 | + private val HW_REVISION_STRING_CHARACTERISTIC = UUID.fromString("00002a27-0000-1000-8000-00805f9b34fb") |
| 28 | + private val SW_REVISION_STRING_CHARACTERISTIC = UUID.fromString("00002a28-0000-1000-8000-00805f9b34fb") |
| 29 | + } |
| 30 | + |
| 31 | + override fun supportFor(device: ScannedDeviceInfo): DeviceSupport? { |
| 32 | + if (!device.name.startsWith("Etekcity Smart Fitness Scale", ignoreCase = true)) { |
| 33 | + return null |
| 34 | + } |
| 35 | + |
| 36 | + return DeviceSupport( |
| 37 | + displayName = device.name, |
| 38 | + capabilities = setOf( |
| 39 | + DeviceCapability.LIVE_WEIGHT_STREAM, |
| 40 | + DeviceCapability.BODY_COMPOSITION, |
| 41 | + DeviceCapability.UNIT_CONFIG |
| 42 | + ), |
| 43 | + implemented = setOf( |
| 44 | + DeviceCapability.LIVE_WEIGHT_STREAM, |
| 45 | + DeviceCapability.BODY_COMPOSITION, |
| 46 | + DeviceCapability.UNIT_CONFIG |
| 47 | + ), |
| 48 | + linkMode = LinkMode.CONNECT_GATT |
| 49 | + ) |
| 50 | + } |
| 51 | + |
| 52 | + override fun onConnected(user: ScaleUser) { |
| 53 | + logI("ESF-551 connected, starting setup sequence") |
| 54 | + |
| 55 | + setUnit(user.scaleUnit) |
| 56 | + |
| 57 | + setNotifyOn(SCALE_SERVICE, WEIGHT_CHARACTERISTIC_NOTIFY) |
| 58 | + logD("Enabled notifications on weight characteristic") |
| 59 | + |
| 60 | + userInfo(R.string.bt_info_waiting_for_measurement) |
| 61 | + } |
| 62 | + |
| 63 | + override fun onDisconnected() { |
| 64 | + logI("ESF-551 disconnected") |
| 65 | + } |
| 66 | + |
| 67 | + override fun onNotification(characteristic: UUID, data: ByteArray, user: ScaleUser) { |
| 68 | + if (data.isEmpty()) return |
| 69 | + |
| 70 | + if (characteristic == WEIGHT_CHARACTERISTIC_NOTIFY) { |
| 71 | + val measurement = parsePayload(data, user) |
| 72 | + if (measurement != null) { |
| 73 | + publish(measurement) |
| 74 | + requestDisconnect() |
| 75 | + } |
| 76 | + return |
| 77 | + } |
| 78 | + |
| 79 | + logD("Notify $characteristic len: ${data.size} data: ${data.toHexPreview(32)}") |
| 80 | + } |
| 81 | + |
| 82 | + private fun parsePayload(data: ByteArray, user: ScaleUser): ScaleMeasurement? { |
| 83 | + if (data.size != 22 || |
| 84 | + data[0] != 0xa5.toByte() || |
| 85 | + data[1] != 0x02.toByte() || |
| 86 | + data[3] != 0x10.toByte() || |
| 87 | + data[4] != 0x00.toByte() || |
| 88 | + data[6] != 0x01.toByte() || |
| 89 | + data[7] != 0x61.toByte() || |
| 90 | + data[8] != 0xa1.toByte() || |
| 91 | + data[9] != 0x00.toByte() |
| 92 | + ) { |
| 93 | + logD("Invalid frame: len: ${data.size} data: ${data.toHexPreview(32)}") |
| 94 | + return null |
| 95 | + } |
| 96 | + |
| 97 | + val weightRaw = data[10].toUInt() or data[11].toUInt().shl(8) or data[12].toUInt().shl(16) |
| 98 | + val weightKg = weightRaw.toInt() / 1000.0 |
| 99 | + val impedance = (data[13].toUInt() or data[14].toUInt().shl(8)).toDouble() |
| 100 | +// val displayUnit = WeightUnit.fromInt(data[21].toInt()) |
| 101 | + val measurement = ScaleMeasurement( |
| 102 | + userId = user.id, |
| 103 | + dateTime = Date(), |
| 104 | + weight = weightKg.toFloat(), |
| 105 | + impedance = impedance, |
| 106 | + ) |
| 107 | + |
| 108 | + if (impedance > 0) { |
| 109 | + val lib = EtekcityLib( |
| 110 | + gender = user.gender, |
| 111 | + age = user.age, |
| 112 | + weightKg = weightKg, |
| 113 | + heightM = user.bodyHeight / 100.0, |
| 114 | + impedance = impedance, |
| 115 | + ) |
| 116 | + measurement.fat = lib.bodyFatPercentage.toFloat() |
| 117 | + measurement.water = lib.water.toFloat() |
| 118 | + measurement.muscle = lib.skeletalMusclePercentage.toFloat() |
| 119 | + measurement.visceralFat = lib.visceralFat.toFloat() |
| 120 | + measurement.bone = lib.boneMass.toFloat() |
| 121 | + measurement.bmr = lib.basalMetabolicRate.toFloat() |
| 122 | + |
| 123 | + // TODO: Add other measurements once supported |
| 124 | +// measurement.fatFreeWeight = lib.fatFreeWeight.toFloat() |
| 125 | +// measurement.subcutaneousFat = lib.subcutaneousFat.toFloat() |
| 126 | +// measurement.muscleMass = lib.muscleMass.toFloat() |
| 127 | +// measurement.proteinPercentage = lib.proteinPercentage.toFloat() |
| 128 | +// measurement.weightScore = lib.weightScore |
| 129 | +// measurement.fatScore = lib.fatScore |
| 130 | +// measurement.bmiScore = lib.bmiScore |
| 131 | +// measurement.healthScore = lib.healthScore |
| 132 | +// measurement.metabolicAge = lib.metabolicAge |
| 133 | + } |
| 134 | + |
| 135 | + if (data[20] == 1.toByte() && impedance > 0) { |
| 136 | + logD("Final measurement: $measurement") |
| 137 | + return measurement |
| 138 | + } |
| 139 | + |
| 140 | + return null |
| 141 | + } |
| 142 | + |
| 143 | + private fun setUnit(unit: WeightUnit) { |
| 144 | + val unitByte = unit.toInt().toByte() |
| 145 | + val cmd = byteArrayOf( |
| 146 | + 0xa5.toByte(), |
| 147 | + 0x22, |
| 148 | + 0x03, |
| 149 | + 0x05, |
| 150 | + 0x00, |
| 151 | + (43 - unitByte).toByte(), |
| 152 | + 0x01, |
| 153 | + 0x63, |
| 154 | + 0xa1.toByte(), |
| 155 | + 0x00, |
| 156 | + unitByte |
| 157 | + ) |
| 158 | + writeTo(SCALE_SERVICE, ALIRO_CHARACTERISTIC, cmd, withResponse = false) |
| 159 | + logD("Unit update command sent: $unit") |
| 160 | + } |
| 161 | +} |
0 commit comments