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
+ }
+}