Skip to content

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

Open
forrcaho wants to merge 3 commits into
oliexdev:masterfrom
forrcaho:fix/onebyone-dropped-measurements
Open

1byone (classic): stop silently discarding valid weigh-ins#1468
forrcaho wants to merge 3 commits 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.

forrcaho and others added 2 commits August 19, 2026 23:05
Removes the weight-without-impedance path added in the previous commit, along
with the byte-9 lock gate that existed to make it safe. What remains is the
coalescing fix and the clock-ACK fallback, both confirmed on hardware.

Publishing a weight with no body composition looked like a strict improvement
over discarding the weigh-in. It is not. BleConnector drops any value that is
not > 0f, so the six body-composition fields reach the database as *missing*
rather than zero, and MeasurementTransformationUseCase.applyValueInheritance
then fills them in from the preceding measurement:

    Carry-over: inherited 6 value(s) from measurement 8.
    Carry-over: inherited 6 value(s) from measurement 10.

The second line is the tell: measurement 10 is itself a weight-only record, so
the stale values propagate forward from weigh-in to weigh-in. The user is shown
a complete-looking reading whose body fat, water and bone mass are days old. A
discarded reading is visibly absent; this one is quietly wrong.

The same capture also produced two records for one weigh-in. With socks on the
scale retries the bioimpedance measurement, and it re-sent the settled frame
nine seconds later, well outside any plausible de-duplication window.

Both review points are answered by this removal rather than by more machinery.
The lock-status gate is gone, so nothing guesses at Eufy's status byte values.
The rate limiter is untouched from master, so weight-only frames can no longer
consume its window -- there are none.

Weight-only measurements are still worth supporting, but not from a handler:
the carry-over interaction affects every handler that already publishes them
(EufyP2Handler, EufyC20Handler, VitafitVT701Handler's fallback). Raised
separately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The clock-ACK fallback fires 3s after connect, but a reading can arrive sooner
than that -- 2.4s in one capture. The prompt then lands after the weigh-in has
already completed and, because a new snackbar dismisses the current one, it
replaces the "measurement saved" notice with an instruction the user has just
carried out.

Any CF measurement frame proves the user is on the scale, so cancel the pending
prompt when one arrives. Done before parsing rather than at publish, so it also
covers frames that are subsequently discarded: a frame without impedance still
means someone is standing there.

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

Copy link
Copy Markdown
Author

After messing around with trying to get the weight with socks on to work correctly, we decided to drop that part and just have the reassembling frames part in this PR. As Claude puts it:

Thanks — both points led somewhere I didn't expect. I've dropped the feature they were about rather than defending it.

What changed

The second commit removes weight-only publishing entirely, along with the byte-9 lock gate that existed to make it safe. What's left is the coalescing fix and the clock-ACK fallback, both confirmed on hardware.

Why

Your first point sent me to check what a weight-only measurement actually looks like once it's saved, and the answer is: not weight-only. BleConnector drops any value that isn't > 0f, so the six body-composition fields arrive at the database as missing rather than zero, and applyValueInheritance then fills them from the preceding measurement:

Carry-over: inherited 6 value(s) from measurement 8.
Carry-over: inherited 6 value(s) from measurement 10.

The second line is the problem — measurement 10 is itself a weight-only record, so the stale values propagate forward from weigh-in to weigh-in. The reading looks complete, and its body fat, water and bone mass are days old.

That's worse than the bug I set out to fix. A discarded reading is visibly absent; this one is quietly wrong.

The same capture also produced two records for one weigh-in: with socks on, the scale retries the bioimpedance measurement and re-sent the settled frame nine seconds later, well outside any plausible de-duplication window. So the hold-and-flush scheme I'd written for your second point wouldn't have collapsed it either.

Where that leaves your two points

The lock gate is gone rather than scoped. Nothing guesses at Eufy's status byte values now, so the failure mode you identified can't occur. Eufy is byte-identical to master apart from the coalescing fix, which is protocol-level — it stops a duplicated frame being misread as history and doesn't depend on vendor semantics.

The rate limiter is untouched from master. It only became a problem because weight-only frames started reaching it; there are none now.

What's left, and how well it's tested

  • Coalescing fix — hardware-verified twice, including once on this exact build: a 20-byte coalesced frame parsed and published with full body composition (102.70 kg, 319 Ω).
  • Clock-ACK fallback — hardware-verified; the prompt now appears on a scale that never sends F1 00.
  • Eight unit tests over the framing decisions, all vectors verbatim from session logs.

Nothing speculative remains, and nothing in the change now depends on hardware I can't test.

Follow-up

Weight-only measurements are worth supporting — my scale produces them whenever bioimpedance fails, and simply losing the weigh-in isn't great either. But the carry-over interaction isn't handler-specific: EufyP2Handler, EufyC20Handler and VitafitVT701Handler's fallback all publish weight-only measurements today and will all be feeding it the same stale values. I'll raise that separately rather than work around it here.

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