Skip to content

feat(scale): reserve a second scale for weighing the dose - #834

Closed
SongPaul wants to merge 5 commits into
decentespresso:mainfrom
SongPaul:feat/dosing-scale
Closed

SongPaul wants to merge 5 commits into
decentespresso:mainfrom
SongPaul:feat/dosing-scale

Conversation

@SongPaul

Copy link
Copy Markdown

Summary

What changed, and why?

  • Baristas weigh the dose on one scale and the shot on another, and today the gateway holds exactly one scale so the second cannot be connected at all. This reserves a second scale by id for weighing the dose, and leaves the brewing path as it is.
  • DosingScaleController is a new class with its own slot, connection subscription and snapshot stream. ScaleController is not modified, so tare-before-pour, stop-at-weight and shot recording are not on this diff.
  • A dosingScaleId setting sits beside the existing preferredScaleId. The brewing path's exposure is a single exclusion in _runScalePhase: the reserved scale is filtered out of the list handed to _applyScalePolicy, and every other scale is accepted exactly as before. With no dosingScaleId set, _runScalePhase returns down the original path untouched.
  • New endpoints beside the existing ones rather than a role parameter on them, so no request an existing skin already sends changes: ws/v1/scale/dosing/snapshot and PUT /api/v1/scale/dosing/tare are new; ws/v1/scale/snapshot and PUT /api/v1/scale/tare are unchanged.
  • Device management gains a dosing-scale section with its own scan, and a scale reserved for dosing is no longer offered for brewing.
  • Unavailable while a Bengle is connected. The integrated scale takes the brewing slot and external discovery is skipped for the duration, so re-entering discovery for a dosing scale alone would contradict a decision taken for that machine.
  • Nothing in a shot reads it. A dose is weighed before extraction, so the dosing weight reaches the skin over its own socket and goes into the workflow as targetDoseWeight the way a typed dose does.

The design doc, including the alternatives rejected and why, is at doc/plans/archive/dosing-scale/2026-09-12-dosing-scale.md.

I understand role-keyed multi-scale is the better end state and is presumably where this lands eventually. If it lands, this should be removed rather than kept alongside it.

Linked Issue

Fixes #833

Verification

How did you verify the change? Include relevant tests and any manual or hardware testing.

  • flutter analyze — no new issues; the only output is four pre-existing asset_directory_does_not_exist warnings for asset dirs a fresh checkout does not have.

  • flutter test in full, against a baseline. My machine has a large number of environmental failures (core.autocrlf=true breaking byte-comparison tests, plus webui_support tests that bind port 3000, which was occupied), so rather than assert they were pre-existing I ran the same suite on origin/main in a second worktree and diffed the failure sets:

    passed failed
    origin/main (39c2156) 3769 384
    this branch 3790 384

    The two sets of 384 are identical — no test fails on this branch that does not also fail on main, and none of the 384 is in a file this branch touches. The 21 extra passes are the tests added here.

  • test/controllers/dosing_scale_controller_test.dart — 16 tests in three groups: the brewing selection's handling of a reserved id (4), which scales brewing is actually offered (5), and the controller's own connect / tare / disconnect / generation behaviour (7). Two of them pin the no-dosing-scale case: "accepts every scale when no dosing scale is set" and "no dosing scale set leaves the list untouched".

  • test/device_management_page_test.dart — 5 tests, including "a scale taken for dosing is not offered for brewing" and "with nothing reserved both scales are offered to brewing", which is the regression that matters.

  • test/unit/controllers/settings_controller_test.dartdosingScaleId round-trips through the settings service.

  • On hardware, by the reporter, with two BLE scales connected at once: both snapshots arrive on their own sockets, taring one does not tare the other, and the brewing scale behaves as it did. Hot water and steam were exercised as well as the dosing readout. Two scales connected simultaneously is not an open question here.

  • The first hardware attempt failed to connect the dosing scale while the same scale connected fine as a brew scale. Two differences from ScaleController were the cause: the dosing path claimed the scale before onConnect() returned and never verified the resulting state, and it ran outside _trackConnectionWork with no timeout, so a scan running concurrently on Android could stall it indefinitely. Both are fixed in de348cb5 and the connect now matches the brewing one.

Impact

Note any user-visible behavior, compatibility, migration, API/spec, documentation, or security impact. Write None if there is none.

  • API: two new endpoints, ws/v1/scale/dosing/snapshot and PUT /api/v1/scale/dosing/tare. assets/api/rest_v1.yml and assets/api/websocket_v1.yml are updated in the same commits. No existing endpoint changes shape or behaviour.
  • Behaviour: none unless a dosing scale is chosen. With dosingScaleId unset — which is every existing installation — the scale phase takes the same path it does today.
  • Storage: one new SharedPreferences key, dosingScaleId. Nullable, no migration.
  • Docs: doc/Api.md and doc/DeviceManagement.md updated; the design doc is archived under doc/plans/archive/dosing-scale/.
  • Security: none.

Contributor Responsibility

AI-assisted development is allowed. The submitter remains responsible for the submitted work.

  • I have reviewed and understand all changes in this PR and take responsibility for their correctness, security, behavior, licensing, and provenance, including any AI-assisted or AI-generated work.

🤖 Generated with Claude Code

songc-ai and others added 5 commits September 12, 2026 08:29
A barista weighs the dose on one scale and the shot on another, and only one
of them can be connected. `ScaleController` has a single slot, and the
brewing path reads its weight, its generation and its snapshot stream, so
turning that slot into a role map would put tare-before-pour, stop-at-weight
and the shot record on the diff for a reading that no part of brewing
consumes.

So this adds a second controller beside it instead. `DosingScaleController`
has its own slot, its own connection and its own snapshot stream, and knows
nothing about shots. `ScaleController` is untouched.

The brewing path learns exactly one thing: a `dosingScaleId` setting beside
the existing `preferredScaleId`, and a single exclusion in scale selection —
every scale except the one reserved for dosing. With no dosing scale set the
selection behaves as it did, which is the regression that matters and is
covered by a test.

Skeleton for hardware testing: the controller, the setting and the selection
rule. The REST and WebSocket surface (`/api/v1/scale/dosing/tare`,
`ws/v1/scale/dosing/snapshot`) follows, as does device-management UI for
choosing which scale is which.

Design: doc/plans/2026-09-12-dosing-scale.md

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Completes the skeleton into something usable.

The selection split moved to where selection actually happens. The exclusion
had gone into `ConnectionSelectionSession.acceptsScale`, which nothing in
lib/ calls — the brewing scale is picked by `resolveScalePolicy` from the
list `_runScalePhase` hands it. That list is now split: the reserved scale is
removed from what brewing is offered, and goes to `DosingScaleController`
instead. Without a `dosingScaleId` the list is passed through untouched and
the old path runs unchanged.

Its weight is served on `ws/v1/scale/dosing/snapshot` and tared with
`PUT /api/v1/scale/dosing/tare`, both beside the brewing scale's endpoints
rather than sharing them, so no skin has to learn anything to keep working.
The shot-lockout on tare does not apply to a scale no shot reads.

Device management gains a Dosing Scale section. Each list hides the scale the
other has taken, so the two cannot be set to the same device.

A Bengle still takes the brewing slot with its integrated scale and skips
external discovery, so no dosing scale is connected in that state.

Design: doc/plans/2026-09-12-dosing-scale.md

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The dosing scale is chosen by hand and never remembered on connect, so the
page offering that choice has to be able to find a scale that has not
connected yet. It lists whatever discovery has found, which before a scan is
nothing at all.

So it scans: on open when nothing has been discovered, and on demand from a
button that reports what it finds. Discovery only, never connecting —
connecting here would hand a scale to brewing before the user has said which
one weighs the dose.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two differences from `ScaleController.connectToScale` that only show on
hardware: the scale was claimed before `onConnect()` had returned and the
resulting connection state was never checked, so a scale that failed to come
up was still recorded as the dosing scale. And the connect ran outside
`_trackConnectionWork` with no timeout, so a scan could start on top of it —
connecting during an active scan is exactly what fails on Android.

Both now follow the brewing path. The attach also says what it decided, so a
dosing scale that is configured but never reached can be told apart from one
that was never looked for: `not among the N scales this scan found`,
`already connected`, `Connecting dosing scale <id>`, and the existing
success and failure lines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The design doc moves to doc/plans/archive/ before the PR, per the
pre-PR checklist. A stray dart format run on the test file comes with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@SongPaul

Copy link
Copy Markdown
Author

Design discussion for the shape of this — additive now vs waiting for role-keyed multi-scale — is at #835, since "should this exist in this form at all" is a maintainer call rather than a review comment. Happy to rework or withdraw this PR on the back of that conversation.

@SongPaul

Copy link
Copy Markdown
Author

Superseded by #869, which reworks this around the generic model asked for in #833: one semantically special scale, auxiliary connections that are explicitly requested, runtime-only, never taken by primary auto-selection, and never read by the brewing path. No core dosing role, no persisted dosingScaleId, no dosing-specific endpoints, and the Bengle restriction is gone.

Closing in favour of that PR.

@SongPaul SongPaul closed this Sep 14, 2026
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.

Reserve a second scale for weighing the dose

2 participants