Skip to content

1byone (classic): stop silently discarding valid weigh-ins - #1468

Open
forrcaho wants to merge 1 commit into
oliexdev:masterfrom
forrcaho:fix/onebyone-dropped-measurements
Open

1byone (classic): stop silently discarding valid weigh-ins#1468
forrcaho wants to merge 1 commit into
oliexdev:masterfrom
forrcaho:fix/onebyone-dropped-measurements

Conversation

@forrcaho

Copy link
Copy Markdown

The following was generated by Claude, as was the code in this PR. As a user, I can say that my scale was receiving a reading only about a third of the time before, and after this fix it works every time.

Summary

My 1byone "Health Scale" needed about three attempts per reading. It turned out the connection was never the problem — every connection succeeded on the first try. The readings were arriving, being parsed, and then silently discarded by OneByoneHandler, with no log line and no user-visible error, so a working connection looked identical to a broken one.

There were two independent causes. Both are fixed here, with regression tests built from the captured frames.

Cause 1: coalesced duplicate frames parsed as history

The scale sends its final measurement twice. Sometimes the copies arrive as two separate 11-byte notifications, and sometimes coalesced into one — the ATT payload caps at 20 bytes, so the buffer is a complete 11-byte frame followed by the first 9 bytes of its duplicate:

[00], [CF 92 0E 9C 27 1D 13 5F 00 00 B9 CF 92 0E 9C 27 1D 13 5F 00], [00 B9]
      └────────── complete frame ─────────┘└──── duplicate, truncated ────┘

parseMeasurementFrame decided history-vs-live on length alone:

val hasTimestamp = bytes.size >= 18

20 ≥ 18, so it read bytes 11–17 as a timestamp, got year 53138, month 14, day 156, 39:29:19, and the non-lenient Calendar threw into a catch { return } that dropped the reading.

The fix decides on the protocol's own XOR checksum at byte 10 plus a second 0xCF marker at byte 11. A genuine history frame carries the year high byte (0x07) at byte 11 and measurement data at byte 10, so the two cases separate cleanly — history reads on the Eufy C1/P1/A1 models sharing this handler are unaffected.

Cause 2: zero impedance threw away the weight

if (!impedancePresent || (isHistoric && !hasTimestamp)) return

The scale reports impedance 0 when it cannot run the bioimpedance measurement — socks, shoes, poor foot contact. The weight is still perfectly good, but the whole weigh-in was lost.

Now the weight is published and only the derived body composition is skipped, matching what EufyP2Handler and EufyC20Handler already do:

// EufyP2Handler.kt:110
if (reading.impedanceOhm > 0) impedance = reading.impedanceOhm.toDouble()

Two smaller fixes in the same paths

  • Lock-status gate. Live frames are now gated on byte 9 ∈ {0x00, 0x36} — the vendor app's "locked" values — so in-progress readings are not recorded. This replaces the older b9 != 1 heuristic with the protocol's actual rule.
  • Clock-ACK fallback. onConnected gated both the history request and the "step on the scale" prompt on an F1 00 ACK. This scale never sends one — the vendor app never even sends F1 on this model — so the user got no feedback whatsoever. A 3 s fallback now prompts anyway. waitAckClock is deliberately left set so a late ACK still starts the history read.

Verification

Confirmed on hardware — the same 103.00 kg weigh-in taken a minute apart:

Frame Result
socks CF 00 00 3C 28 00 00 00 01 00 DA 103.00 kg, impedance 0 — weight now saved instead of lost
barefoot CF B6 0D 3C 28 B4 B5 99 01 00 F9 CF B6 … 103.00 kg, 351 Ω, full body composition — coalesced frame now parsed

OneByoneHandlerTest covers both, plus the three earlier coalesced frames that were being dropped and a synthetic history frame confirming history detection still works. All frame vectors are verbatim from session logs, not synthesised.

Reverse-engineering was cross-checked against the decompiled vendor app ("New iWellness 4.0", com.lefu.es.*) — that's the source for the byte-9 lock values and for the fact that F1 is never sent on this model.

Deliberately not included

When a reading has no impedance, the user is still told nothing. A snackbar emitted at publish time is dismissed by BleConnector's saved-measurement snackbar ~700 ms later, so it never really appears. Fixing that properly belongs in the save path rather than in a handler, so I've raised it separately rather than working around it here.

Two other things I looked at and left alone, both mentioned in case they're of interest:

  • MGBHandler.tryPublishStreaming has the same "drop the weight if impedance never arrives" bug this PR fixes (val impedanceOhm = streamingImpedanceOhm ?: return), with no timeout fallback. I have no Dr. Trust hardware to test against, so I haven't touched it.
  • The vendor app connects with connectGatt(autoConnect = true) and rescans every 10 s indefinitely, where openScale uses a direct connect after a 650 ms delay. That's a real robustness difference, but the logs show it costing nothing here, so it isn't part of this change.

🤖 Generated with Claude Code

The 1byone "Health Scale" dropped roughly half its readings, with no log line
and no user-visible error, so a working connection was indistinguishable from a
broken one. Two independent causes, both found by diffing openScale session logs
against the decompiled vendor app ("New iWellness 4.0", com.lefu.es.*).

Coalesced duplicate frames were parsed as history. The scale sends its final
measurement twice, and the two copies can arrive in a single notification: the
ATT payload caps at 20 bytes, so the buffer is one complete 11-byte frame
followed by the first 9 bytes of its duplicate. parseMeasurementFrame decided
history-vs-live on `size >= 18` alone, so it read bytes 11..17 of that buffer as
a timestamp, produced year 53138 / day 156 / hour 39, and the non-lenient
Calendar threw into a `catch { return }` that discarded the reading. Decide on
the XOR checksum at byte 10 plus a second 0xCF marker at byte 11 instead. A
genuine history frame carries the year high byte (0x07) at byte 11 and
measurement data at byte 10, so the two cases separate cleanly and history reads
on the Eufy C1/P1/A1 models sharing this handler are unaffected.

Zero-impedance frames lost the weight as well. The scale reports impedance 0
when it cannot run the bioimpedance measurement -- socks, shoes, poor foot
contact -- and the handler returned early, losing an otherwise valid weigh-in.
Publish the weight and skip only the derived body composition, matching what
EufyP2Handler and EufyC20Handler already do.

Two smaller fixes in the same paths:

Gate live frames on the lock status (byte 9 in {0x00, 0x36}, the vendor app's
"locked" values) so in-progress readings are not recorded. This replaces the
older `b9 != 1` guess with the protocol's own rule.

Arm a 3s fallback for the `F1 00` clock ACK. This scale never sends it -- the
vendor app never even sends F1 on this model -- yet both the history request and
the "step on the scale" prompt hung off it, so the user got no feedback at all.
waitAckClock is deliberately left set so a late ACK still starts the history
read; only the prompt is forced, once per connection.

Confirmed on hardware with a 1byone Health Scale: the same 103.00 kg weigh-in
taken a minute apart in socks (impedance 0, weight now saved instead of lost)
and barefoot (coalesced 20-byte frame, impedance 351 ohm, full body composition
derived). The unit-test vectors are the captured frames from those sessions plus
the three earlier coalesced frames that were being dropped.

Known gap: when a reading has no impedance the user is told nothing, because a
snackbar emitted at publish time is dismissed by BleConnector's saved-measurement
snackbar ~700 ms later. Fixing that properly belongs in the save path rather
than in a handler, so it is left out of this change and raised separately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@oliexdev

Copy link
Copy Markdown
Owner

Two things before merging, both about the Eufy C1/P1/A1 that supportFor also matches and neither of us can test:

  1. The byte-9 whitelist. It narrows acceptance from "anything but 0x01" to only 0x00/0x36, and those values come from the 1byone vendor app — Eufy ships a different one. If a
    C1/P1/A1 uses another value, every live weigh-in there disappears silently: the same bug, relocated. supportFor already knows the model — could you apply the lock gate to
    the 1byone only and leave Eufy on the old b9 != 1?

  2. The rate limiter. Weight-only frames now reach DATE_TIME_THRESHOLD_MS and set lastSavedAt, where before they returned earlier. If a scale sends the settled weight first
    and the frame with impedance a second later, the second one is dropped and the user keeps a weight-only record. Starting the window only for frames carrying impedance would
    close that.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants