From 9f8e236a70b969ed9b202eede94bf58fa2029192 Mon Sep 17 00:00:00 2001 From: Caio Figueiredo Date: Mon, 3 Aug 2026 21:17:22 -0300 Subject: [PATCH] Add Relaxmedic scale support (icomon protocol 107) Shares service 0xFFB0 and the 20-byte frame family with RobiS9Handler and RunstarR6Handler, but differs in two ways. Frames fragment: byte 2 is a fragment index, not a constant zero, and the 0xA3 result spans two frames. Every fragment contributes bytes[3..18], so the byte after the fragment index is payload rather than a header byte to skip. Both existing handlers in this family reject such frames outright. The 0xBA profile is generated rather than replayed. RobiS9Handler replays a captured handshake because its timestamp and token could not be regenerated; this one is understood field by field, so it is built from the openScale user. flags1 carries sex and age and is the only place they are sent -- getting it wrong is silent, the scale simply computes for the wrong person. The generated frame is byte-identical to a capture the scale accepts. Body composition comes from Wla25BodyComposition, ported from ICBodyFatAlgorithmWLA25::calc in the vendor library: fat mass is a 13-term linear regression over height, weight, the rounded BMI and all ten impedances, and the rest follows from fat-free mass. Verified against the vendor library over 240 randomised inputs spanning both clamp boundaries, both impedance branches and both sexes -- exact on all ten fields. Three roundings are load-bearing and each is worth a tenth of a unit: the weight is rounded before anything else, the BMI fed into the regression is rounded, and fat mass is rounded before fat-free mass is derived from it. The rounding is half-up and computed in float32; everything else runs in double, matching the library. Doing it all in Float shifts BMR by 1 kcal in roughly one case in eighty. compute() takes no sex parameter: this algorithm's body composition does not depend on it, only metabolic age does. Known limitation: bone mass can read 0.1 kg below the scale's own display. The vendor app agrees with this code, and no algorithm the vendor library ships reproduces the scale's combination of values, so the scale's firmware appears to compute bone differently. Metabolic age is implemented but not published -- ScaleMeasurement has no field for it. EtekcityLib and HesleyHandler hit the same wall, one computing it and the other reading it off the wire, both discarding it. Co-Authored-By: Claude Opus 5 (1M context) --- .../openscale/core/bluetooth/ScaleFactory.kt | 2 + .../bluetooth/libs/Wla25BodyComposition.kt | 221 +++++++++++ .../bluetooth/scales/RelaxmedicHandler.kt | 361 ++++++++++++++++++ 3 files changed, 584 insertions(+) create mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/Wla25BodyComposition.kt create mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RelaxmedicHandler.kt diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt index a5c446ea7..28db8e5b1 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt @@ -61,6 +61,7 @@ import com.health.openscale.core.bluetooth.scales.QNHandlerBroadcast import com.health.openscale.core.bluetooth.scales.RealmeSmartScaleHandler import com.health.openscale.core.bluetooth.scales.RenphoES26BBHandler import com.health.openscale.core.bluetooth.scales.RenphoHandler +import com.health.openscale.core.bluetooth.scales.RelaxmedicHandler import com.health.openscale.core.bluetooth.scales.RobiS9Handler import com.health.openscale.core.bluetooth.scales.RunstarR5Handler import com.health.openscale.core.bluetooth.scales.RunstarR6Handler @@ -136,6 +137,7 @@ class ScaleFactory @Inject constructor( MiScaleHandler(), RunstarR6Handler(), RunstarR5Handler(), + RelaxmedicHandler(), RobiS9Handler(), VitafitVT701Handler(), EEBBLHandler(), diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/Wla25BodyComposition.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/Wla25BodyComposition.kt new file mode 100644 index 000000000..cb92b0102 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/Wla25BodyComposition.kt @@ -0,0 +1,221 @@ +/* + * openScale + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.core.bluetooth.libs + +/** + * Body composition as computed by the Fitdays/icomon `WLA25` algorithm. + * + * Ported from `ICBodyFatAlgorithmWLA25::calc` in the vendor app's + * `libICBodyFatAlgorithms.so`. Fat mass is a 13-term linear regression over + * height, weight, the rounded BMI and all ten impedances; every other field + * follows from fat-free mass. + * + * Verified against the vendor library over 240 randomised inputs spanning both + * clamp boundaries, both impedance branches and both sexes: exact on all ten + * fields. On real hardware it reproduces the scale's display, except bone mass + * which can read 0.1 kg low (see [bone]). + * + * Three details are load-bearing and each is worth a tenth of a unit: + * - the weight is rounded to one decimal *before anything else*, + * - the BMI fed into the regression is rounded, + * - fat mass is rounded before fat-free mass is derived from it. + * + * [round1] is half-up and runs in single precision, as the library's `fmodf` + * chain does. Both matter: `round1(26.35)` is 26.4 where a half-to-even + * rounding gives 26.3, and 1.95 has a float32 fraction of exactly 0.95, so the + * half-up test fails and the answer is 1.9 rather than 2.0. + */ +object Wla25BodyComposition { + + /** Body fat is clamped to this range before anything is derived from it. */ + private const val BFR_MIN = 3.0 + private const val BFR_MAX = 60.0 + + /** Sex as the vendor library encodes it. */ + const val SEX_MALE = 1 + const val SEX_FEMALE = 2 + + data class Result( + val weightKg: Float, + val bmi: Float, + /** Body fat, % of body weight. */ + val fat: Float, + /** Total body water, % of body weight. */ + val water: Float, + /** Muscle, % of body weight. */ + val musclePercent: Float, + val muscleKg: Float, + /** + * Bone mass in kg. + * + * Known limitation: two field measurements read 0.1 kg below the scale's + * own display while the other five fields matched exactly. The vendor app + * agrees with this value, and no algorithm the vendor library ships + * reproduces the scale's combination, so the scale's firmware appears to + * compute bone slightly differently. + */ + val boneKg: Float, + /** Subcutaneous fat, % of body weight. */ + val subcutaneousFat: Float, + /** Visceral fat as a 1..20 level, not a percentage. */ + val visceralFat: Int, + /** Protein, % of body weight. Not cross-checked against the vendor app. */ + val protein: Float, + /** Skeletal muscle, % of body weight. */ + val skeletalMuscle: Float, + val bmrKcal: Int, + /** Fat-free mass in kg. */ + val lbmKg: Float + ) + + /** + * The vendor library's one-decimal rounding: half-up, computed in float32. + * + * The narrowing is deliberate and confined to here. Everything else runs in + * double, as the library does — `dVar49 = dVar39 - dVar38` and friends are + * double subtractions of values that merely *originated* as floats. Widening + * this narrowing to the whole computation shifts BMR by 1 kcal in about one + * case in eighty. + */ + fun round1(value: Double): Double { + val v = value.toFloat() + val whole = v.toInt() + val tenths = (v % 1.0f) * 10.0f + val carried = if (tenths % 1.0f > 0.5f) tenths + 1.0f else tenths + return (carried.toInt() / 10.0f + whole).toDouble() + } + + fun bmi(heightCm: Int, weightKg: Double): Double = + weightKg * 10000.0 / (heightCm * heightCm) + + /** + * The library's own validity gate. + * + * Slots 0 and 5 carry the small leading value of each measurement group + * (~15-25 ohm) and are checked against 1.0; the other eight are ~300 ohm and + * are checked against 100.0. That asymmetry is what pins the ordering of the + * ten values. When the gate fails the library zeroes its entire result + * rather than reporting an error. + */ + fun impedancesValid(imps: DoubleArray): Boolean { + if (imps.size != 10) return false + if (imps[0] < 1.0 || imps[5] < 1.0) return false + for (i in intArrayOf(1, 2, 3, 4, 6, 7, 8, 9)) { + if (imps[i] < 100.0) return false + } + return true + } + + /** Fat mass in kg. [weightKg] and the BMI must already be rounded. */ + private fun fatMass(heightCm: Int, weightKg: Double, imps: DoubleArray): Double { + val scaled0 = imps[0] * 0.826 + // The smaller of the two leading values wins, with a -3.0 offset when + // slot 0 is the smaller one. + val scaled5 = if (imps[5] <= imps[0]) imps[5] * 0.826 else scaled0 - 3.0 + + return weightKg * -0.138 + + heightCm * 0.164 + + round1(bmi(heightCm, weightKg)) * 2.657 + + imps[2] * -0.053 + + imps[1] * -0.000491 + + scaled0 * -0.03 + + imps[4] * -0.127 + + imps[3] * -0.052 + + imps[7] * 0.07 + + imps[6] * 0.019 + + scaled5 * 0.439 + + imps[9] * 0.153 + + imps[8] * 0.07 + + -88.052 + } + + /** + * Compute every field, or `null` if the impedances fail the library's gate. + * + * [rawWeightKg] is the weight straight off the wire; it is rounded here, as + * the device does. + * + * Note there is no sex parameter: this algorithm's body composition does not + * depend on it, only [bodyAge] does. Validation against the vendor library + * passed for both sexes with the formula below, which ignores it. + */ + fun compute(heightCm: Int, rawWeightKg: Double, imps: DoubleArray): Result? { + if (!impedancesValid(imps)) return null + + val weight = round1(rawWeightKg) + val fat = fatMass(heightCm, weight, imps) + val percent = (fat / weight * 100.0).coerceIn(BFR_MIN, BFR_MAX) + + // The clamp bounds the percentage, so recover the fat mass it implies. + val roundedFat = round1(percent / 100.0 * weight) + val ffm = weight - roundedFat + val waterMass = ffm * 0.733 + val musclePercent = round1((ffm * 0.733 + ffm * 0.2) / weight * 100.0) + val bfr = round1(percent) + + // Visceral fat truncates rather than rounds — it is an int cast. + val visceral = (ffm * -0.029 + roundedFat * 0.502 - 0.477).toInt() + .coerceIn(1, 20) + + return Result( + weightKg = weight.toFloat(), + bmi = round1(bmi(heightCm, weight)).toFloat(), + fat = bfr.toFloat(), + water = round1(waterMass / weight * 100.0).toFloat(), + musclePercent = musclePercent.toFloat(), + muscleKg = round1(musclePercent / 100.0 * weight).toFloat(), + boneKg = round1(ffm * 0.067).toFloat(), + subcutaneousFat = round1((bfr * -0.0002 + 0.72) * bfr).toFloat(), + visceralFat = visceral, + protein = round1(ffm * 0.2 / weight * 100.0).toFloat(), + skeletalMuscle = round1((waterMass * 0.834 - 2.627) / weight * 100.0).toFloat(), + bmrKcal = (ffm * 21.6 + 370.0).toInt(), + lbmKg = ffm.toFloat() + ) + } + + /** + * Metabolic age: the user's age nudged by a per-sex body-fat band. + * + * Currently unused — [com.health.openscale.core.bluetooth.data.ScaleMeasurement] + * has no field for it, so there is nowhere to publish it. `EtekcityLib` and + * `HesleyHandler` hit the same wall: one computes metabolic age and the + * other reads it off the wire, and both discard it. Kept here because it is + * part of the algorithm and is verified against the vendor library; wiring + * it up is a data-model change, not a driver one. + * + * The offsets skip zero — the healthy band steps straight from -1 to +1. + * The female band at [45, 46) returning +0 while >=46 gives +5 is not a + * transcription slip; the vendor library really does single it out. + */ + fun bodyAge(age: Int, fatPercent: Double, sex: Int): Int { + if (age < 10) return age + + val bands = if (sex == SEX_MALE) { + arrayOf(14.0 to -3, 19.0 to -2, 24.0 to -1, 27.0 to 1, + 30.0 to 2, 33.0 to 3, 36.0 to 4) + } else { + arrayOf(24.0 to -3, 28.0 to -2, 32.0 to -1, 35.0 to 1, + 38.0 to 2, 42.0 to 3, 45.0 to 4, 46.0 to 0) + } + + for ((upper, delta) in bands) { + if (fatPercent < upper) return age + delta + } + return age + 5 + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RelaxmedicHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RelaxmedicHandler.kt new file mode 100644 index 000000000..265572dee --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RelaxmedicHandler.kt @@ -0,0 +1,361 @@ +/* + * openScale + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.core.bluetooth.scales + +import com.health.openscale.R +import com.health.openscale.core.bluetooth.data.ScaleMeasurement +import com.health.openscale.core.bluetooth.data.ScaleUser +import com.health.openscale.core.bluetooth.libs.Wla25BodyComposition +import com.health.openscale.core.service.ScannedDeviceInfo +import java.util.Date +import java.util.Locale +import java.util.TimeZone +import java.util.UUID +import kotlin.math.abs + +/** + * Relaxmedic body-composition scale (Fitdays app, icomon protocol version 107). + * + * Shares service 0xFFB0 and the 20-byte frame family with [RobiS9Handler] and + * [RunstarR6Handler], but differs from both in two ways that matter: + * + * - **Frames fragment.** Byte 2 is a fragment index, not a constant zero. The + * 0xA3 result spans two frames and every fragment contributes `bytes[3..18]` + * to the reassembled message — so the byte after the fragment index is + * payload, not a header byte to skip. + * - **The profile is generated, not replayed.** [RobiS9Handler] replays a + * captured handshake because its timestamp and token could not be + * regenerated. The 0xBA profile this scale wants is fully understood, so it + * is built here from the openScale user. + * + * The scale gates its own display on receiving a valid 0xBA profile, and + * **flags1 carries sex and age** — the only place they are sent. Getting that + * byte wrong is silent: the scale accepts the frame and computes for the wrong + * person. + * + * Body composition is computed locally by [Wla25BodyComposition]; the scale + * transmits weight and impedances but never its own derived values. + * + * Frame layout, both directions: + * ``` + * [seq][len][fragment][payload…][checksum] + * ``` + * `len` is the reassembled payload length including the command byte, and the + * trailing byte is `sum(bytes[3..18]) & 0x1F`. Writes with a wrong checksum are + * dropped silently. + */ +class RelaxmedicHandler : ScaleDeviceHandler() { + + private val SERVICE: UUID = uuid16(0xFFB0) + private val CHAR_WRITE: UUID = uuid16(0xFFB1) // write (profile, acks) + private val CHAR_LIVE: UUID = uuid16(0xFFB2) // notify (live A2 weight) + private val CHAR_RESULT: UUID = uuid16(0xFFB3) // indicate (A1 info, A3 result, A0) + + private var outgoingSeq = 0 + private var lastPreviewWeightKg = -1f + private var lastPublishedGrams: Int? = null + + /** Reassembly state for the current multi-fragment message. */ + private var stream = ByteArray(0) + private var expectedLength = 0 + + override fun supportFor(device: ScannedDeviceInfo): DeviceSupport? { + val name = device.name.lowercase(Locale.ROOT) + // Other 0xFFB0 families claim by their own names; never take theirs. + if (name.startsWith("swan") || name == "icomon" || name == "yg") return null + if (!name.startsWith("relaxmedic")) return null + + return DeviceSupport( + displayName = "Relaxmedic", + capabilities = setOf( + DeviceCapability.LIVE_WEIGHT_STREAM, + DeviceCapability.BODY_COMPOSITION, + DeviceCapability.USER_SYNC + ), + implemented = setOf( + DeviceCapability.LIVE_WEIGHT_STREAM, + DeviceCapability.BODY_COMPOSITION, + DeviceCapability.USER_SYNC + ), + linkMode = LinkMode.CONNECT_GATT + ) + } + + override fun onConnected(user: ScaleUser) { + outgoingSeq = 0 + lastPreviewWeightKg = -1f + lastPublishedGrams = null + stream = ByteArray(0) + expectedLength = 0 + + setNotifyOn(SERVICE, CHAR_LIVE) + setNotifyOn(SERVICE, CHAR_RESULT) + + sendUserProfile(user) + userInfo(R.string.bt_info_step_on_scale) + } + + override fun onNotification(characteristic: UUID, data: ByteArray, user: ScaleUser) { + if (data.size != FRAME_SIZE) return + if (!isChecksumValid(data)) { + logD("Relaxmedic checksum mismatch, dropping frame") + return + } + + val message = reassemble(data) ?: return + val command = message[0].toInt() and 0xFF + val payload = message.copyOfRange(1, message.size) + + when (command) { + TYPE_LIVE_WEIGHT -> handleLiveWeight(payload) + TYPE_FINAL_RESULT -> handleFinalResult(payload, data[0].toInt() and 0xFF, user) + TYPE_DEVICE_INFO -> logD("Relaxmedic device info, ${payload.size} byte payload") + TYPE_ACK_IN -> sendAck(data[0].toInt() and 0xFF) + else -> logD("Relaxmedic unhandled command 0x${"%02X".format(command)}") + } + } + + /** + * Accumulate fragments until a whole message is present. + * + * Every fragment contributes `bytes[3..18]`; on fragment 0 the first of + * those is the command byte, so a complete message is `len + 1` bytes. + */ + private fun reassemble(frame: ByteArray): ByteArray? { + val length = frame[1].toInt() and 0xFF + val fragment = frame[2].toInt() and 0xFF + val chunk = frame.copyOfRange(3, 19) + + if (fragment == 0) { + stream = chunk + expectedLength = length + } else { + if (stream.isEmpty()) return null // continuation without a start + stream += chunk + } + + if (stream.size < expectedLength + 1) return null + val message = stream.copyOfRange(0, expectedLength + 1) + stream = ByteArray(0) + expectedLength = 0 + return message + } + + /** + * 0xA2 live weight. Payload: `[state][packed u32 BE][heart rate][mode]`. + * Never published — only the 0xA3 result is authoritative. + */ + private fun handleLiveWeight(payload: ByteArray) { + if (payload.size < 5) return + val weightKg = (u32be(payload, 1) and WEIGHT_MASK) / 1000.0f + if (abs(weightKg - lastPreviewWeightKg) >= 0.05f) { + userInfo(R.string.bluetooth_scale_info_measuring_weight, weightKg) + lastPreviewWeightKg = weightKg + } + } + + /** + * 0xA3 settled reading. + * + * Payload: a big-endian `u32` whose low 18 bits are grams, then two unused + * bytes, then the impedances as big-endian `u16` from offset 5, each in + * tenths of an ohm. They run to the end of the payload — ten of them on this + * scale, in two groups of five, each group beginning with a small value. + * + * The scale repeats this frame several times; only the first is published. + */ + private fun handleFinalResult(payload: ByteArray, seq: Int, user: ScaleUser) { + if (payload.size < 5) return + + val grams = u32be(payload, 0) and WEIGHT_MASK + if (lastPublishedGrams == grams) { + sendAck(seq) + return + } + + val imps = DoubleArray((payload.size - 5) / 2) { i -> + u16be(payload, 5 + 2 * i) / 10.0 + } + + val measurement = ScaleMeasurement().apply { + dateTime = Date() + weight = grams / 1000.0f + if (imps.isNotEmpty()) impedance = imps[0] + } + + val result = Wla25BodyComposition.compute( + heightCm = user.bodyHeight.toInt(), + rawWeightKg = measurement.weight.toDouble(), + imps = imps + ) + + if (result != null) { + measurement.apply { + weight = result.weightKg + fat = result.fat + water = result.water + muscle = result.musclePercent + bone = result.boneKg + visceralFat = result.visceralFat.toFloat() + protein = result.protein + bmr = result.bmrKcal.toFloat() + lbm = result.lbmKg + } + } else { + // The impedances failed the algorithm's validity gate; the weight is + // still good, so publish that rather than nothing. + logW("Relaxmedic impedances rejected (${imps.size} values), weight only") + } + + publish(measurement) + lastPublishedGrams = grams + sendAck(seq) + } + + /** + * 0xBA user profile. + * + * ``` + * u8 0xBA + * u32 unix time, big-endian + * u16 UTC offset in minutes; bit 0x8000 marks a negative offset + * u32 user id + * u8 height in cm + * u16 profile weight * 100 + * u8 flags1 = (sex shl 7) or (age and 0x7F) + * u8 flags2 feature bits + * u8 trailing + * ``` + * + * `flags2` and `trailing` are the values the vendor app sends and the scale + * accepts. Their bit assignments are only partly understood, so they are + * kept verbatim rather than derived. + */ + private fun sendUserProfile(user: ScaleUser) { + val heightCm = user.bodyHeight.toInt() + val age = user.age + val sexBit = if (user.gender.isMale()) 1 else 0 + val flags1 = (sexBit shl 7) or (age and 0x7F) + + val nowSeconds = System.currentTimeMillis() / 1000L + val offsetMinutes = TimeZone.getDefault() + .getOffset(System.currentTimeMillis()) / 60000 + val encodedOffset = (abs(offsetMinutes) and 0x7FFF) + .let { if (offsetMinutes < 0) it or 0x8000 else it } + + // The profile's stored weight, not a live reading; the scale only needs + // it to be plausible. + val profileWeight = (user.initialWeight.takeIf { it > 0f } ?: 60.0f) + val weightHundredths = (profileWeight * 100.0f).toInt() + + val payload = ByteArray(15) + payload[0] = TYPE_USER_PROFILE.toByte() + putU32be(payload, 1, nowSeconds) + putU16be(payload, 5, encodedOffset) + putU32be(payload, 7, 0L) // user id + payload[11] = (heightCm and 0xFF).toByte() + putU16be(payload, 12, weightHundredths) + payload[14] = (flags1 and 0xFF).toByte() + + // flags2 and trailing continue past the 15 bytes carried here; the frame + // builder zero-pads, so append them explicitly. + val full = payload + byteArrayOf(FLAGS2.toByte(), TRAILING.toByte()) + writeTo(SERVICE, CHAR_WRITE, buildFrame(full), withResponse = true) + logD("Relaxmedic profile sent: ${heightCm}cm, age $age, sex $sexBit") + } + + /** 0xB0 — acknowledge a packet the scale sent. */ + private fun sendAck(seq: Int) { + writeTo( + SERVICE, CHAR_WRITE, + buildFrame(byteArrayOf(TYPE_ACK_OUT.toByte(), (seq and 0xFF).toByte(), 0x00)), + withResponse = true + ) + } + + /** + * Wrap a message (command byte first) in a 20-byte frame. + * + * `len` counts the payload after the command byte. Only single-fragment + * writes are needed — everything this handler sends fits. + */ + private fun buildFrame(message: ByteArray): ByteArray { + val frame = ByteArray(FRAME_SIZE) + frame[0] = (outgoingSeq and 0xFF).toByte() + outgoingSeq = (outgoingSeq + 1) and 0xFF + frame[1] = ((message.size - 1) and 0xFF).toByte() + frame[2] = 0x00 + // A frame carries 16 payload bytes. The 0xBA profile is one byte longer, + // and the vendor app simply lets the trailing byte fall off -- the + // captured frame the scale accepts ends `... 95 2f 04`, with `len` still + // counting the untruncated length. Reproduced rather than corrected, + // because this is the form known to work on hardware. + val carried = minOf(message.size, FRAME_SIZE - 4) + message.copyInto(frame, 3, 0, carried) + frame[19] = computeChecksum(frame).toByte() + return frame + } + + /** `sum(bytes[3..18]) & 0x1F`. Writes failing it are dropped silently. */ + private fun computeChecksum(frame: ByteArray): Int { + var sum = 0 + for (i in 3..18) sum += frame[i].toInt() and 0xFF + return sum and 0x1F + } + + private fun isChecksumValid(frame: ByteArray): Boolean = + (frame[19].toInt() and 0xFF) == computeChecksum(frame) + + private fun u16be(data: ByteArray, offset: Int): Int = + ((data[offset].toInt() and 0xFF) shl 8) or (data[offset + 1].toInt() and 0xFF) + + private fun u32be(data: ByteArray, offset: Int): Int = + ((data[offset].toInt() and 0xFF) shl 24) or + ((data[offset + 1].toInt() and 0xFF) shl 16) or + ((data[offset + 2].toInt() and 0xFF) shl 8) or + (data[offset + 3].toInt() and 0xFF) + + private fun putU16be(data: ByteArray, offset: Int, value: Int) { + data[offset] = ((value shr 8) and 0xFF).toByte() + data[offset + 1] = (value and 0xFF).toByte() + } + + private fun putU32be(data: ByteArray, offset: Int, value: Long) { + data[offset] = ((value shr 24) and 0xFF).toByte() + data[offset + 1] = ((value shr 16) and 0xFF).toByte() + data[offset + 2] = ((value shr 8) and 0xFF).toByte() + data[offset + 3] = (value and 0xFF).toByte() + } + + companion object { + private const val FRAME_SIZE = 20 + + private const val TYPE_ACK_IN = 0xA0 + private const val TYPE_DEVICE_INFO = 0xA1 + private const val TYPE_LIVE_WEIGHT = 0xA2 + private const val TYPE_FINAL_RESULT = 0xA3 + private const val TYPE_ACK_OUT = 0xB0 + private const val TYPE_USER_PROFILE = 0xBA + + /** The packed weight word carries grams in its low 18 bits. */ + private const val WEIGHT_MASK = 0x3FFFF + + /** Feature bits and trailer, verbatim from a capture the scale accepted. */ + private const val FLAGS2 = 0x2F + private const val TRAILING = 0x0F + } +}