Skip to content

Statistics characteristic (fc540013) decoded: on-device drink log, plus a de-bounce that drops it in debug mode #97

Open

Description

@gwhillhouse

Summary

I decoded the statistics characteristic (fc540013, MugCharacteristic.STATISTICS) against an Ember Mug 2 (14 oz, firmware 406, hardware 10) and wrote it up here: https://github.com/gwhillhouse/ember-mug-app/blob/main/docs/statistics-stream.md. Sharing in case it's useful for the library, along with one thing I noticed in _notify_callback that would stop anyone from seeing the stream through debug=True.

What the stream is

It is the mug's on-device drink log, not live telemetry. The mug buffers records as things happen and drains the whole backlog to the first client that subscribes; a second subscribe afterwards gets only the 05 end marker. New records then arrive live on the open subscription, ~15–20 s after the event. (This looks like the feed behind the Ember app's analytics: it subscribes on connect and uploads to collector.embertech.com, so whichever client subscribes first gets the log.)

Framing. Each notification is one of four packet types, working within the 20-byte ATT MTU:

First byte Meaning
01 00 LL … Complete record, LL = payload length (8 or 10 seen)
02 00 10 … First fragment of a longer record, always 16 payload bytes
04 01 NN … Final fragment of the preceding 02 record
05 End of batch

Records (after reassembling 02+04): 00 | kind | sub | t (u32 BE, seconds since the pour) | ff | fields. Temperatures are big-endian °C×100 (the opposite of the GATT characteristics); 7fff is null.

kind meaning fields
05 liquid state change (sub = the LiquidState code) –
07 target set by the app target temp
0f heater engaged target temp
10 10-minute sample level (0–30), temp
15 snapshot at the pour (partly decoded) see doc

Everything marked confirmed in the doc was matched second-for-second against my own app's log of the same drink; the 0x15 snapshot fields and one constant are still hypotheses (one mug, one day). Raw capture available on request (stats-capture.jsonl, ~50 packets per drink).

The de-bounce in _notify_callback

event_id = data[0]
now = time()
if (last_time := self._latest_events.get(event_id)) and now - last_time < 5:
    return

This runs before the characteristic check, so it also applies to statistics packets. The backlog arrives as ~48 packets in about five seconds whose first byte is almost always 01, 02 or 04, so with debug=True the library logs three of them and drops the rest, and the 02/04 fragments can never be paired. Skipping the de-bounce when characteristic.uuid == MugCharacteristic.STATISTICS.uuid (or keying it on the characteristic as well as the byte) fixes it. Happy to send a PR for that if you'd like; I held off because it touches behaviour you may want to shape differently once the stream is actually parsed.

Related

Small PR alongside this one (#96): battery temperature from bytes 2–3 of fc540007, which orlopau documented and which explains the "on the coaster but not charging" behaviour on a hot mug.

Activity

  1. gwhillhouse commented on Sep 15, 2026

    @gwhillhouse
    ContributorAuthor

    Two follow-ups from today, both now in the same doc (https://github.com/gwhillhouse/ember-mug-app/blob/main/docs/statistics-stream.md):

    The clock changes the log. Writing fc540006 (uint32 LE Unix time + int8 UTC offset, the phone-app format) makes every subsequent statistics record carry an absolute Unix timestamp in its t field instead of seconds-since-pour. Reading the characteristic back only returns what was written, but the mug keeps time from then on. Confirmed with two target writes stamped 1789511796 / 1789511822. So a library that sets the clock on connect gets exact pour times for backlog drinks.

    Control register (fc540010/fc540011) is address-select-then-read. Scanned 0–255 (address side only): 00 → empty, 01 → the MAC little-endian, 02 → five 32-bit words that are nRF SRAM pointers (a debug view), 03 → ASCII TJO ;-), everything from 04 up → 0x12345678. Which means get_battery_voltage() (data register at address 0) reads 0 bytes on firmware 406; the attribute may be worth dropping or guarding.

  2. sopelj commented on Sep 16, 2026

    @sopelj
    Owner

    Wow! Thank you very much for this documentation as well as your PR for battery temp!
    Between this and orlopau's page there are a few more things that can be added to this library :)
    Thanks for your hard work and excellent documentation :)

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions