Skip to content

Instantly share code, notes, and snippets.

@csobrinho
Created February 27, 2026 19:59
Show Gist options
  • Select an option

  • Save csobrinho/8e48e0a85ee8c64219d39a4bac60af8a to your computer and use it in GitHub Desktop.

Select an option

Save csobrinho/8e48e0a85ee8c64219d39a4bac60af8a to your computer and use it in GitHub Desktop.

Query

I'm planning to do a big refactor to all scales inside the scales/ folder. My plan is to switch the hardcoded and forked logic for each scale implementation to use new C++ mixins. In order to reliably do that I need to know, for each scale, all the details that are common between them and all the smaller details that are very unique. For instance:
- are they all bluetooth
- do they use one uuid descriptor for weight or more than one
- same for command uuid (one or more)
- are they all using notifications (the class subscribes to the weight UUID, receives the payload and decodes the data, notifies the weight callback.
- do they contain warnings/miss used logic, bad patterns, unnecessary heap allocations, potential leaks
- do they need a heartbeat

Take your time analyzing each scale, write your findings in a REFACTOR.md file
Present both detailed info for each scale and a table with all the details, use a blank/green/yellow/red marker to note the scale does not use feature A, uses it, uses it but has warnings and errors

After you compiled all this data and written the REFACTOR.md file, create a list of all the mixins I might need. Make sure you cluster them by category. For instance one mixin category will be hearbeat. I would expect to have an empty one for scales that don't need a heartbeat but also one that only needs to call one or two bt commands or one that just needs a lambda for custom processing. Extract the rest of the details for the other categories.

Also extract info on which scales have binary protocols, which ones use add checksum vs xor, custom protocols, etc.

Ask questions if needed.

Scale Implementations: Refactor Analysis

Scales analysed: Acaia Β· Bookoo Β· Decent Β· Difluid Β· Eclair Β· Eureka Β· Felicita Β· myscale Β· Timemore Β· Varia Β· WeighMyBru

Marker legend

Symbol Meaning
βšͺ Not applicable / feature not used
🟒 Correct implementation
🟑 Used but has issues / warnings
πŸ”΄ Has bugs or errors

1. Per-Scale Detailed Analysis

1.1 Acaia

Handles: ACAIA, PYXIS, LUNAR, PEARL, PROCH, UMBRA (name prefix)

UUIDs:

Role UUID
Service (new) 49535343-fe7d-4ae5-8fa9-9fafd205e455
Service (old) 00001820-0000-1000-8000-00805f9b34fb
Service (Umbra) 0000fe40-cc7a-482a-984a-7f2ed5b3e58f
Weight char 49535343-1e4d-4bd9-ba61-23c647249616 / old=00002a80 / Umbra=0000fe42
Command char 49535343-8841-43f4-a8d4-ecbe34729bb3 / old=same as weight / Umbra=0000fe41

Protocol:

[EF DD] [MsgType] [PayloadLen] [Payload...] [CkSum1] [CkSum2]

Custom 2-byte alternating checksum: even bytes β†’ cksum1, odd bytes β†’ cksum2. Full framed binary with header scanning (cleanupJunkData). Accumulating dataBuffer.

Handshake: Writes CCCD {0x01,0x00} manually β†’ sendId() (15 0x2D bytes) β†’ sendNotificationRequest() (EVENT with notification flags).

Heartbeat: Every 2000 ms β€” sends SYSTEM, sendNotificationRequest(), HANDSHAKE (3 writes).

Weight decoding: 2-byte LE integer + 1-byte scaling factor (Γ·10…÷10000). Sign bit in payload[5] bit 1. Umbra variant reads weight at bytes 2–3 instead of 0–1.

Issues:

  • sendEvent() and sendMessage() allocate heap on every call via std::make_unique<uint8_t[]>. On a microcontroller this is frequent for a heartbeat every 2 s.
  • Old service uses the same characteristic for both weight and command β€” subscribed twice in subscribeToNotifications() which is harmless but redundant.
  • Subscribes commandCharacteristic too (for old model parity), even on new models where it cannot notify. Guard canNotify() prevents actual duplicate, but the code path is confusing.
  • Large commented-out blocks (timer, key, ACK event handling) left in production code.
  • PEARLS device name substring check is fragile (no trailing char check β€” PEARLSOMETHING would match).

1.2 Bookoo

Handles: BOOKOO_SC (name prefix)

UUIDs:

Role UUID
Service 0FFE
Weight char FF11
Command char FF12

Protocol: Fixed 20-byte binary packet. Product ID byte + message type byte + data fields + XOR checksum (last byte). Payload<N> template struct computes XOR checksum at compile time using constexpr. Clean struct layout with WeightPayload union.

Handshake: Sends eventNotification command. No CCCD write (NimBLE handles it through subscribe()).

Heartbeat: Every 2000 ms β€” 3 writes: cmdHeartbeat1, eventNotification, cmdHeartbeat2.

Extras exposed: BatteryExtra + TimerExtra (only scale exposing both).

Issues:

  • πŸ”΄ Return value bug in decodeAndHandleNotification: returns dataBuffer.size() < expectedWeightLength which evaluates to true (keep looping) when the buffer is empty. The correct condition should be >= expectedWeightLength. Effect: after processing one packet and clearing the buffer, the while-loop calls decodeAndHandleNotification once more (finds size=0 < 20 = true), then calls it again (same thing, returns false). Two extra no-op calls per packet instead of stopping. If two packets arrive in one notify, the second packet is silently dropped because dataBuffer.erase(begin, end) clears the entire buffer (not just the consumed 20 bytes).
  • πŸ”΄ Entire buffer erased, not just consumed bytes: dataBuffer.erase(dataBuffer.begin(), dataBuffer.end()) on the error path AND on the normal path wipes all buffered data. Should use dataBuffer.begin() + expectedWeightLength.
  • πŸ”΄ decodeAndHandleNotification calls BookooScales::tare() when it receives a BookooMessageType::System message β€” this looks like a copy-paste artifact; it's unclear why a received SYSTEM packet should trigger an outbound tare command.
  • 🟑 update() always logs "Heartbeat sent." even when sendHeartbeat() did nothing (interval not elapsed).

1.3 Decent

Handles: Decent Scale, EspressiScale (name prefix)

UUIDs:

Role UUID
Service FFF0
Read char FFF4
Write char 36F5

Protocol: 7 or 10 bytes: [03] [CA or CE] [WeightH] [WeightL] […] [XOR]. XOR checksum computed over all bytes, but skipped if the checksum byte is 0 (unusual). Weight is signed 16-bit big-endian Γ· 10. No accumulating buffer.

Handshake: Minimal β€” get service + characteristics. Then turnOnOLED(). No notification-enable command.

Heartbeat: Every 5000 ms (longest of all) β€” single command: {0x03, 0x0A, 0x03, 0xFF, 0xFF, 0x00, 0x0A}.

Special: OLED control on connect/disconnect (unique to this scale). Uses a verifyConnected() helper.

Issues:

  • πŸ”΄ #include <iostream> β€” never used, pulls in host-side STL code on Arduino.
  • 🟑 using namespace std; in the .cpp file β€” pollutes the namespace.
  • 🟑 Empty if (service != nullptr) {} block in performConnectionHandshake() β€” no-op body.
  • 🟑 Tare command comment says "should also send 01 as the last data byte" but the byte IS already present at position [5]. Stale comment.
  • 🟑 turnOffOLED() is called inside disconnect() which guards with if (isConnected()) β€” but clientCleanup() is called right after regardless; if BLE stack dropped the connection before disconnect() was called, the turnOffOLED() write is skipped silently.
  • 🟑 verifyConnected() pattern is correct but the distinct reconnection path (uses connect() which itself calls clientConnect() which calls clientCleanup()) means cleanup is done inside connect, not in the same pattern as other scales.

1.4 Difluid

Handles: Microbalance, Mb (name prefix)

UUIDs:

Role UUID
Service (primary) 00EE
Service (fallback) 00DD
Characteristic AA01 (used for BOTH write AND notify)

Protocol: [DF DF] [Func] [Cmd] [DataLen] [Data…] [Sum]. Additive checksum (sum of all bytes except last, mod 256). Big-endian signed 32-bit weight Γ· 10. No accumulating buffer.

Handshake: Subscribe β†’ setUnitToGram() β†’ enableAutoNotifications(). Two service UUIDs tried in order. Sends command to force gram unit on connect.

Heartbeat: Every 2000 ms β€” single command (Get Device Status: {0xDF,0xDF,0x03,0x05,0x00,checksum}).

Issues:

  • 🟑 notifyCallback logs each byte with a separate log() call in a loop β€” extremely noisy and inefficient (one formatted log call per byte vs one byteArrayToHexString call).
  • 🟑 Battery level is read from heartbeat acknowledgment (battery_capacity local variable in notifyCallback) but never stored or exposed β€” dead read.
  • 🟑 markedForReconnection = true is set inside sendHeartbeat() (when !isConnected()), but sendHeartbeat() is only called from update() when NOT already markedForReconnection. This inconsistency means the reconnection detection is driven by the heartbeat timer rather than BLE events. Different pattern from all other scales.
  • 🟑 Single characteristic used for both write and notify β€” valid but unusual. Commands are written with waitResponse = true which blocks.
  • 🟑 heartbeatCommand comment shows hardcoded 0xC6 which is wrong (actual XOR = 0xDE), but it gets overwritten by calculateChecksum so it's harmless.

1.5 Eclair

Handles: ECLAIR- (name prefix, note trailing dash)

UUIDs:

Role UUID
Service B905EAEA-2E63-0E04-7582-7913F10D8F81
Data char (weight/flow, notifies) AD736C5F-BBC9-1F96-D304-CB5D5F41E160
Config char (commands + battery acks, also notifies) 4F9A45BA-8E1B-4E07-E157-0814D393B968

Protocol: [Type] [Data…] [XOR]. XOR computed over data bytes only (excluding type byte). Weight is signed 32-bit little-endian int Γ· 1000 (milligram precision). Battery from config notifications. Two separate notification channels.

Handshake: Minimal β€” get service + characteristics. Calls subscribeToNotifications() for both chars separately (one callback, dispatches by UUID).

Heartbeat: Every 2000 ms β€” sends TIMER_STATUS (0x43) with {0x00}. Effectively sends [43 00 00] (XOR of {0x00} = 0x00). This looks like a placeholder β€” sending a timer-status query as a keepalive is unusual and undocumented.

Issues:

  • 🟑 Heartbeat is sendMessage(EclairMessageType::TIMER_STATUS, …) β€” confusing semantics; a real heartbeat command should be a no-op ping, not a timer status request.
  • 🟑 battery field is tracked but not exposed via getBattery() override.
  • 🟑 update() does not use markedForReconnection flag; it checks !isConnected() directly and calls connect(). Since connect() calls clientConnect() which calls clientCleanup() internally, this is functionally equivalent but inconsistent with most other scales.
  • 🟑 sendMessage() uses std::make_unique<uint8_t[]> for heap allocation on every send.

1.6 Eureka

Handles: CFS-9002, LSJ-001 (name prefix) OR manufacturer data containing a6bc or starting with 042 (when name is empty)

UUIDs:

Role UUID
Service FFF0
Weight char FFF1
Command char FFF2

⚠️ Same service/characteristic UUIDs as Varia β€” these scales must be disambiguated by name/manufacturer data at the detection stage.

Protocol: Fixed 11-byte binary. No header magic bytes, no checksum. Weight: 2-byte little-endian integer at bytes [7:8] Γ· 10. Sign byte at byte [6].

Handshake: Minimal β€” get service + characteristics. No CCCD write, no init command.

Heartbeat: sendHeartbeat() method exists but body is empty (comment: "No heartbeat necessary").

Issues:

  • 🟑 sendMessage() allocates heap via std::make_unique<uint8_t[]> then memcpy β€” a passthrough that could call commandCharacteristic->writeValue(payload, length, waitResponse) directly.
  • 🟑 productNumber = dataBuffer[0] is read but never used in decodeAndHandleNotification.
  • 🟑 Tare payload: uint8_t payload[6] = { CMD_HEADER, CMD_BASE, CMD_TARE, CMD_TARE } β€” array is 6 bytes but only 4 initialisers; trailing two bytes are implicitly 0. Whether this is intentional is undocumented.
  • 🟑 Subscribes commandCharacteristic to notifications even though in practice it likely only exists for writing.
  • 🟑 Manufacturer data detection: NimBLEUtils::buildHexData(nullptr, …) returns a char* allocated by NimBLE β€” this pointer is never freed, causing a heap leak on every scan result.
  • βšͺ No checksum validation on received packets.

1.7 Felicita

Handles: FELICITA (name prefix)

UUIDs:

Role UUID
Service FFE0
Data char (read + write) FFE1

Protocol: 18+ byte ASCII text packet. Weight encoded as ASCII digit characters at bytes [3..8]. Sign at byte [2] (0x2D = -). Weight Γ· 100.

Handshake: Minimal β€” get service + characteristic + subscribe. No init commands.

Heartbeat: None (field lastHeartbeat exists but is never read or written).

Issues:

  • πŸ”΄ toggleUnit() and togglePrecision() are declared in the header but never defined in the .cpp. If any code calls them, this is a linker error.
  • πŸ”΄ calculateChecksum() is defined but never called β€” dead code.
  • 🟑 parseWeight() validation: (data[3] | … | data[8]) < '0' ORs all six bytes and checks if result < 0x30. This is only true if all bytes are zero β€” a meaningless guard. Malformed ASCII will slip through.
  • 🟑 lastHeartbeat member declared but never used.
  • 🟑 verifyConnected() is called in update() but only marks for reconnection β€” it never triggers reconnection itself within the same call. Works, but on embedded hardware the next update() call handles the reconnect while this call silently returns false.

1.8 myscale

Handles: blackcoffee, my_scale, MY_SCALE (name prefix)

UUIDs:

Role UUID
Service 0000FFB0-0000-1000-8000-00805F9B34FB
Data char (notify) 0000FFB2-0000-1000-8000-00805F9B34FB
Write char 0000FFB1-0000-1000-8000-00805F9B34FB

Protocol: 15+ byte binary packet. Weight uses bit manipulation: sign from nibble (data[2] >> 4), magnitude from bytes [3..6] with masking. Weight Γ· 1000 (milligram precision).

Handshake: Get service + characteristics. Writes CCCD {0x01, 0x00} manually AND calls subscribe() β€” the manual CCCD write is redundant since NimBLE's subscribe() handles it.

Heartbeat: None (field lastHeartbeat exists but never written or read).

Issues:

  • πŸ”΄ toggleUnit() and togglePrecision() declared but never defined (identical issue to Felicita β€” likely copy-paste).
  • πŸ”΄ calculateChecksum() defined but never called β€” dead code.
  • 🟑 Class name is lowercase myscale β€” inconsistent with all other scale class names.
  • 🟑 Manual CCCD write is redundant (NimBLE subscribe() writes it automatically).
  • 🟑 lastHeartbeat member declared but never used.
  • 🟑 The tare command is a 20-byte blob with magic bytes; no protocol spec referenced.

1.9 Timemore

Handles: Timemore Scale (name prefix)

UUIDs:

Role UUID
Service 181D (standard BLE Weight Scale service)
Weight char 2A9D (standard BLE Weight Measurement characteristic)
Command char 553f4e49-bf21-4468-9c6c-0e4fb5b17697

Protocol: Fixed 9-byte binary. Type byte at [0] (0x10 = weight). Two 32-bit little-endian integers: dripper weight [1..4] and scale weight [5..8]. Scale weight Γ· 10.

Handshake: Writes CCCD {0x01, 0x00} manually (notifications). Sends sendNotificationRequest() ({0x02, 0x00} to weight char).

Heartbeat: Every 2000 ms β€” single write {0x00} to weight char via sendMessage(WEIGHT, …).

Issues:

  • πŸ”΄ Dual subscription conflict: performConnectionHandshake() writes CCCD descriptor manually for notifications ({0x01, 0x00}), but subscribeToNotifications() then calls canIndicate() / subscribe(false, callback, true) which subscribes to indications. These are different subscription types. The result is that BOTH notifications AND indications may be enabled simultaneously, or one may override the other depending on NimBLE internals.
  • 🟑 Dripper weight field (dataBuffer[1..4]) is parsed but immediately commented out β€” dead variable.
  • 🟑 performConnectionHandshake() logs "Got Service\n" twice (lines 122 and 127).
  • 🟑 sendMessage() dispatches to different characteristics based on msgType β€” an unusual pattern where the enum value determines which BLE characteristic is the write target.

1.10 Varia

Handles: AKU MINI SCALE, VARIA AKU, Varia AKU, AKU SCALE (name prefix)

UUIDs:

Role UUID
Service FFF0
Weight char (notify) FFF1
Command char (write) FFF2

⚠️ Same service/characteristic UUIDs as Eureka.

Protocol: Variable-length compound messages. Outer frame: [FA] [SubType] [Len] [Data…] [XOR]. A single BLE notification can contain multiple concatenated sub-messages (weight, timer, battery, timer events). Pointer arithmetic used to walk through them. XOR checksum validated per sub-message.

Handshake: Minimal β€” no init commands.

Heartbeat: None β€” update() has an empty body ({}).

Issues:

  • 🟑 update() is defined as empty in the header file (void update() override {}). This is valid C++ but unusual β€” behaviour cannot be changed without editing the header.
  • 🟑 batteryPercent and timerSeconds are tracked in notifyCallback but not exposed via getBattery() / getTimer() overrides (Bookoo, by contrast, exposes these properly).
  • 🟑 sendMessage() uses std::make_unique<uint8_t[]> for a variable-length message β€” could use a stack-allocated buffer for the typical short command sizes.
  • 🟑 No markedForReconnection flag and no reconnection logic anywhere.

1.11 WeighMyBru

Handles: WeighMyBru (name prefix)

UUIDs:

Role UUID
Service 6E400001-B5A3-F393-E0A9-E50E24DCCA9E (Nordic UART)
Weight char (notify) 6E400002-B5A3-F393-E0A9-E50E24DCCA9E
Command char 6E400003-B5A3-F393-E0A9-E50E24DCCA9E

Protocol: Fixed 20-byte binary. Product ID [0] + message type [1] + data + XOR checksum (last byte). Sign byte at [6] (45 = '-'). Weight: 3 bytes at [7..9] Γ· 0.01.

Handshake: Writes CCCD, sends sendNotificationRequest() (which calls sendEvent()).

Heartbeat: Every 2000 ms β€” 3 writes (mirrors Acaia pattern): {0x02,0x00}, sendNotificationRequest(), {0x00}.

Issues:

  • πŸ”΄ Wrong CCCD endianness: writes {0x00, 0x01} to the CCCD descriptor. The BLE standard stores notification-enable as little-endian 0x0001 = {0x01, 0x00}. {0x00, 0x01} = 0x0100 = reserved/undefined. Compare with Acaia which correctly writes {0x01, 0x00}. This may inadvertently enable indications on some BLE stacks.
  • πŸ”΄ decodeAndHandleNotification() calls WeighMyBrewScales::tare() when messageType == SYSTEM β€” same copy-paste artifact as Bookoo.
  • 🟑 update() always logs "Heartbeat sent." unconditionally.
  • 🟑 sendEvent() and sendMessage() both allocate heap via std::make_unique.
  • 🟑 Subscribes commandCharacteristic to notifications (NUS TX char) β€” this is the correct NUS pattern actually (RX notifies), but the naming (commandCharacteristic) is confusing: in NUS, 6E400002 is RX (you write to it) and 6E400003 is TX (it notifies you). The naming in this code is inverted vs. NUS convention.
  • 🟑 battery field declared but never read from protocol or exposed.

2. Summary Table

Feature Acaia Bookoo Decent Difluid Eclair Eureka Felicita myscale Timemore Varia WeighMyBru
Heartbeat 🟒 2000 ms 🟑 2000 ms 🟒 5000 ms 🟑 2000 ms 🟑 2000 ms βšͺ (method empty) βšͺ none βšͺ none 🟒 2000 ms βšͺ none 🟑 2000 ms
Heartbeat writes (per cycle) 3 3 1 1 1 0 0 0 1 0 3
Separate weight + cmd chars 🟑 (old=same) 🟒 🟒 βšͺ (one char) 🟒 (both notify) 🟒 βšͺ (one char) 🟒 🟒 🟒 🟒
Multiple service UUIDs 🟑 (3 variants) βšͺ βšͺ 🟑 (2 variants) βšͺ βšͺ βšͺ βšͺ βšͺ βšͺ βšͺ
BLE Notifications 🟒 🟒 🟒 🟒 🟒 🟒 🟒 🟒 πŸ”΄ (uses indications) 🟒 🟒
Manual CCCD write 🟒 βšͺ βšͺ βšͺ βšͺ βšͺ βšͺ 🟑 (redundant) πŸ”΄ (conflicts) βšͺ πŸ”΄ (wrong endian)
Accumulating data buffer 🟒 πŸ”΄ (erase bug) βšͺ βšͺ βšͺ 🟑 (erase all) βšͺ βšͺ 🟒 βšͺ 🟒
Protocol framing 🟒 (EF DD header) 🟒 (fixed 20B) 🟒 (fixed 7/10B) 🟒 (DF DF header) 🟒 (type byte) 🟑 (fixed 11B, no cksum) 🟑 (ASCII, no cksum) 🟑 (binary, no cksum) 🟒 (fixed 9B) 🟒 (variable, framed) 🟑 (fixed 20B)
Inbound checksum 🟒 2-byte alt-sum 🟒 XOR 🟑 XOR (opt-out if 0) 🟒 additive 🟒 XOR βšͺ none βšͺ none (fn unused) βšͺ none (fn unused) βšͺ none 🟒 XOR 🟒 XOR
Outbound checksum 🟒 2-byte alt-sum 🟒 XOR (compile-time) βšͺ none 🟒 additive 🟒 XOR βšͺ none βšͺ none βšͺ none βšͺ none 🟒 XOR 🟒 XOR
Init command(s) in handshake 🟒 sendId + notifReq 🟒 notifReq βšͺ 🟒 setUnit + enableNotif βšͺ βšͺ βšͺ βšͺ 🟒 notifReq βšͺ 🟒 notifReq
Reconnect pattern 🟒 flag 🟒 flag 🟑 flag+verifyConnected 🟑 flag (in heartbeat) 🟑 direct 🟒 flag 🟑 flag+verifyConnected 🟑 flag+verifyConnected 🟒 flag βšͺ none 🟒 flag
Battery exposed βšͺ (tracked, not exposed) 🟒 getBattery() βšͺ βšͺ (dead read) βšͺ (tracked, not exposed) βšͺ βšͺ βšͺ βšͺ βšͺ (tracked, not exposed) βšͺ
Timer exposed βšͺ 🟒 getTimer() βšͺ βšͺ βšͺ βšͺ βšͺ βšͺ βšͺ βšͺ (tracked, not exposed) βšͺ
OLED / display control βšͺ βšͺ 🟒 on connect/disconnect βšͺ βšͺ βšͺ βšͺ βšͺ βšͺ βšͺ βšͺ
Detection by manufacturer data βšͺ βšͺ βšͺ βšͺ βšͺ 🟑 (memory leak) βšͺ βšͺ βšͺ βšͺ βšͺ
Heap alloc per message send 🟑 make_unique βšͺ (direct write) βšͺ βšͺ 🟑 make_unique 🟑 make_unique (unnecessary) βšͺ βšͺ βšͺ 🟑 make_unique 🟑 make_unique
Dead code 🟑 large commented blocks βšͺ βšͺ βšͺ βšͺ βšͺ πŸ”΄ undefined decls πŸ”΄ undefined decls 🟑 dripper weight βšͺ βšͺ

3. Protocol Details

Scale Format Encoding Inbound cksum Outbound cksum Weight precision
Acaia Framed binary (EF DD header, variable len) Custom scaling byte (Γ·10…÷10000) 2-byte alternating sum 2-byte alternating sum 0.01 g (or 0.0001 g)
Bookoo Fixed 20-byte binary 3-byte unsigned packed Γ— 0.01 XOR (last byte) XOR (compile-time) 0.01 g
Decent Fixed 7 or 10 bytes Signed 16-bit BE Γ· 10 XOR (optional) None 0.1 g
Difluid Framed binary (DF DF header) Signed 32-bit BE Γ· 10 Additive sum Additive sum 0.1 g
Eclair Type-prefixed [T][D…][XOR] Signed 32-bit LE Γ· 1000 XOR XOR 0.001 g
Eureka Fixed 11-byte binary Unsigned 16-bit LE Γ· 10 + sign byte None None 0.1 g
Felicita ASCII text β‰₯18 bytes ASCII digits Γ— 0.01 None (fn unused) None 0.01 g
myscale Binary β‰₯15 bytes Bit-field extract Γ· 1000 None (fn unused) None 0.001 g
Timemore Fixed 9-byte binary Signed 32-bit LE Γ· 10 None None 0.1 g
Varia Variable, compound messages (FA outer) 3-byte packed Γ— 0.01 + sign bit XOR per sub-message XOR 0.01 g
WeighMyBru Fixed 20-byte binary 3-byte unsigned Γ— 0.01 + sign byte XOR (last byte) XOR 0.01 g

4. Proposed Mixin Categories

Each category is a set of related behaviours. Within each category, there will be one concrete mixin per distinct behaviour variant. Scales with no need for a feature get a NoX or EmptyX mixin.


Category A β€” Heartbeat

The update() call drives heartbeat. All variants track a lastHeartbeat timestamp and guard on an interval.

Mixin Interval Writes Used by
NoHeartbeatMixin β€” 0 Varia, Felicita, myscale
EmptyHeartbeatMixin β€” 0 Eureka (method stub)
SingleCommandHeartbeatMixin<Interval> Configurable 1 Decent (5000), Difluid (2000), Timemore (2000)
MultiCommandHeartbeatMixin<Interval> Configurable 3 Acaia (2000), Bookoo (2000), WeighMyBru (2000)

The multi-command variant needs to know which commands to send β€” this is scale-specific and best supplied as a small lambda or a virtual doHeartbeat() that the concrete class overrides, while the mixin owns the timer logic.


Category B β€” Notification Subscription

How the scale subscribes to BLE characteristic notifications.

Mixin Behaviour Used by
DirectSubscribeMixin characteristic->subscribe(true, callback) via canNotify() Bookoo, Decent, Eclair, Eureka, Felicita, myscale, Varia, WeighMyBru
ManualCCCDThenSubscribeMixin Write CCCD {0x01,0x00} + subscribe(true, …) Acaia (correct)
IndicationSubscribeMixin canIndicate() + subscribe(false, callback, true) Timemore (but currently broken β€” see Β§1.9)

ManualCCCDThenSubscribeMixin is only needed when NimBLE's subscribe() is not writing the CCCD automatically for some peripheral β€” all new scales should use DirectSubscribeMixin.


Category C β€” Connection Handshake / Init

Commands sent immediately after BLE connection, before entering steady-state operation.

Mixin Behaviour Used by
MinimalHandshakeMixin Just discover service + characteristics Eureka, Varia
SubscribeHandshakeMixin Discover β†’ subscribe (subscribe is done in handshake, not separately) Felicita, myscale, Difluid
NotificationRequestHandshakeMixin Discover β†’ subscribe β†’ send notification-enable command Bookoo, Timemore, WeighMyBru
IdentifyHandshakeMixin Discover β†’ send ID bytes β†’ send notification-enable command Acaia
UnitInitHandshakeMixin Discover β†’ subscribe β†’ set unit β†’ enable auto-notifications Difluid
OLEDHandshakeMixin Wraps another mixin β€” calls turnOnOLED() post-connect, turnOffOLED() pre-disconnect Decent

These can be composed. For example, Difluid is UnitInitHandshakeMixin + SubscribeHandshakeMixin.


Category D β€” Reconnection Strategy

How the scale detects and recovers from disconnection.

Mixin Behaviour Used by
FlaggedReconnectMixin Sets markedForReconnection = true in callback/heartbeat; update() checks flag and calls clientCleanup() + connect() Acaia, Bookoo, Decent, Difluid, Eureka, Felicita, myscale, Timemore, WeighMyBru
DirectReconnectMixin update() checks !isConnected() directly; calls connect() (which internally calls clientCleanup()) Eclair
NoReconnectMixin No reconnection logic Varia

Category E β€” Data Framing / Receive Buffer

How incoming BLE notification payloads are handled.

Mixin Behaviour Used by
SingleShotFramingMixin No buffer; parse each BLE notification payload as a complete, self-contained message Decent, Difluid, Eclair, Felicita, myscale
FixedLengthBufferMixin<N> Accumulate into a vector<uint8_t>; consume exactly N bytes per parse call Bookoo (20), WeighMyBru (20), Eureka (11), Timemore (9)
HeaderScanBufferMixin<H1, H2> Accumulate; scan for 2-byte magic header to find packet start Acaia (EF DD), Difluid (DF DF)
CompoundMessageMixin Single notification may contain multiple back-to-back sub-messages; walk with pointer arithmetic Varia

FixedLengthBufferMixin must erase only the consumed bytes from the front, not the entire buffer.


Category F β€” Inbound Checksum Verification

Validation of incoming packet integrity.

Mixin Algorithm Used by
NoInboundChecksumMixin No validation Eureka, Felicita, myscale, Timemore
XorChecksumMixin XOR all payload bytes, compare to trailing byte Decent, Eclair, Varia (per sub-msg), WeighMyBru
ConditionalXorChecksumMixin Skip validation if checksum byte == 0 Decent (special variant)
AdditiveChecksumMixin Sum all bytes (mod 256), compare to trailing byte Difluid
AlternatingByteChecksumMixin Compute two bytes by summing even/odd-indexed bytes separately Acaia
StructXorChecksumMixin XOR all struct bytes, compare to embedded field Bookoo (WeightPayload::valid())

Category G β€” Outbound Checksum Generation

Appended to commands sent to the scale.

Mixin Algorithm Used by
NoOutboundChecksumMixin No checksum appended Decent, Felicita, myscale, Timemore, Eureka
XorOutboundChecksumMixin XOR all payload bytes, append as last byte Varia, WeighMyBru, Eclair
CompileTimeXorPayloadMixin Payload<N> / CmdPayload β€” checksum computed by constexpr constructor at compile time Bookoo
AdditiveOutboundChecksumMixin Sum all bytes (mod 256), append as last byte Difluid
AlternatingByteOutboundChecksumMixin Two-byte alternating sum appended Acaia

Category H β€” Weight Decoding

How raw bytes from the notification are turned into a float weight.

Mixin Encoding Used by
AsciiDecimalWeightMixin ASCII digit characters in packet β†’ integer β†’ Γ·100 Felicita
Int16LittleEndianWeightMixin<Divisor> Signed 16-bit LE integer with sign bit + divisor Decent (Γ·10), Eureka (Γ·10, separate sign byte)
Int32LittleEndianWeightMixin<Divisor> Signed 32-bit LE integer Eclair (Γ·1000), Timemore (Γ·10)
Int32BigEndianWeightMixin<Divisor> Signed 32-bit BE integer Difluid (Γ·10)
PackedByteWeightMixin<Divisor> 3-byte unsigned packed + sign byte/bit Bookoo (Γ—0.01), WeighMyBru (Γ—0.01), Varia (Γ—0.01)
BitFieldWeightMixin Nibble + byte extraction with sign from nibble myscale
ScalingFactorWeightMixin 2-byte LE integer + scaling-factor byte (Γ·10^n) + sign bit Acaia

Category I β€” Service / Characteristic Discovery

How the scale finds its BLE service and characteristic handles.

Mixin Behaviour Used by
SingleServiceMixin Look up one fixed service UUID Bookoo, Decent, Eclair, Eureka, Felicita, myscale, Timemore, Varia, WeighMyBru
FallbackServiceMixin Try a primary UUID, then fall back to alternates Acaia (3 UUIDs), Difluid (2 UUIDs)
SingleReadWriteCharMixin One characteristic used for both notify + write Felicita, Difluid
SplitCharMixin Separate characteristics for read (notify) and write most scales
DualNotifyCharMixin Two characteristics both notifying; callback dispatches by UUID Eclair

Category J β€” Device Detection

How the plugin's handles() method identifies a device.

Mixin Behaviour Used by
NamePrefixDetectionMixin<Prefix…> deviceName.find(prefix) == 0 for one or more prefixes all except Eureka
ManufacturerDataDetectionMixin Parse manufacturer data hex string Eureka (fallback when name is empty)

The Eureka memory leak (heap-allocated hex string never freed) belongs in this category's fix.


Category K β€” Optional Extras

Scales that expose battery or timer state to callers.

Mixin Behaviour Used by (correctly) Should be added to
BatteryExtraMixin Holds BatteryExtra, overrides getBattery() Bookoo Eclair, Varia (track but don't expose), Difluid
TimerExtraMixin Holds TimerExtra, overrides getTimer() Bookoo Varia
OLEDControlMixin turnOnOLED()/turnOffOLED() commands Decent β€”

5. Cross-Cutting Issues to Fix in Refactor

Issue Affected scales Fix
Heap allocation on every BLE write (make_unique) Acaia, Eclair, Eureka, Varia, WeighMyBru Use stack-allocated fixed buffers; only heap-allocate for truly variable sizes > ~32 bytes
dataBuffer.erase(begin, end) erases entire buffer Bookoo, Eureka Erase only begin, begin + messageLength
Inverted loop-continue condition Bookoo return dataBuffer.size() >= expectedWeightLength
Calls tare() on received SYSTEM message Bookoo, WeighMyBru Remove β€” likely copy-paste artefact
Wrong CCCD endianness {0x00, 0x01} WeighMyBru Change to {0x01, 0x00}
Dual subscription (CCCD write + indicate subscribe) Timemore Decide on notifications or indications; remove the other
#include <iostream> + using namespace std Decent Remove both
Declared but undefined methods Felicita, myscale Either implement toggleUnit()/togglePrecision() or remove declarations
calculateChecksum defined but never called Felicita, myscale Remove or wire up
Memory leak: manufacturer data hex string not freed Eureka Free via free(pHex) after use
lastHeartbeat member declared but never used Felicita, myscale Remove field
Battery/timer tracked internally but not exposed Acaia, Eclair, Varia, Difluid Add getBattery() / getTimer() overrides with proper extra types
Unconditional "Heartbeat sent." log in update Bookoo, WeighMyBru Move log inside sendHeartbeat() after the interval check
Heartbeat command type semantically wrong Eclair Use a proper keepalive command; check protocol docs
markedForReconnection set inside sendHeartbeat() Difluid Use BLE disconnect callback pattern instead
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment