Reverse-engineered from a PacketLogger capture of the official Dohm macOS app
(captures/capture-2133.pklg, 2026-06-02), then checked against the Android
Marpac Connect 1.0.7.0 APK. See Android extraction and source references.
App-derived details below are not a substitute for hardware verification.
- Command service
00005600-d102-11e1-9b23-00025b005aa5containing a single characteristic00005601-d102-11e1-9b23-00025b005aa5. Numeric ATT handles vary by capture/unit; discover them rather than hardcoding an old handle. Writable with response, notify, not readable — state arrives only via notifications. - Also present: Device Information Service
180a(model/serial/firmware) and Battery Service180fwith battery level2a19[read,notify]. Standard GATT firmware, serial, and battery reads are not implemented by the standalone client or HA integration. aa114b7e-…(originally mistaken for the characteristic) appears only in the advertisement, not the GATT table.- All payloads are UTF-8 strings terminated by
$.
Four kinds of notification come back:
- valid set →
OK$ - valid query → a typed report (
I,…$,S,0N$,M,1$,N,…$) - out-of-range value →
Failed NN$(space, not comma; e.g.Failed 03$) - unrecognized command → the input is echoed back verbatim (e.g. send
huteno$, gethuteno$)
LETTER[,ID[,VALUE...]]$
- lowercase letter = query — e.g.
s,0136C4$asks for speed. A query can include a selector, such as the slot inp,0136C4,1$. - UPPERCASE letter + value = set — e.g.
S,0136C4,3$sets speed 3. - The device reports/acknowledges in uppercase short form without the
ID — e.g.
S,02$,M,1$, orOK$.
Every set/query (except i$ itself) embeds the device's own ID. You don't need
to know it ahead of time — ask for it:
-> i$
<- I,0136C4$ # this unit's ID is 0136C4
The device ID is a required command prefix, not a secret key. BLE pairing/
encryption is also required on the observed hardware; the ID is not a substitute
for a bond. Android queries i$ during new-device setup and stores the reply.
HA supplies an ID derived from the final three MAC bytes, based on the existing
hardware observation, not independently confirmed by this APK. Standalone
DohmClient(device) defaults to device_id=None: connect enables notifications,
queries i$, and caches the actual ID. An explicitly supplied ID avoids that
opening query. get_device_id() always queries and retains the reported ID.
| Purpose | Send | Reply |
|---|---|---|
| Get device ID | i$ |
I,0136C4$ |
| Query power | m,0136C4$ |
M,1$ / M,0$ |
| Set power on/off | M,0136C4,1$ / M,0136C4,0$ |
OK$ |
| Query speed | s,0136C4$ |
S,02$ (zero-padded 2-digit) |
| Set speed | S,0136C4,3$ (single digit) |
OK$ |
| Query name | n,0136C4$ |
N,Marpac$ |
| Set name | N,0136C4,Marpac$ |
OK$ |
| Set time | T,0136C4,2133,2$ |
OK$ |
| Query schedule slot N | p,0136C4,1$ |
P,1,2030,0730,7F,02$ |
| Set schedule start | P,0136C4,1,1,2030$ |
OK$ in capture |
| Set schedule stop | P,0136C4,2,1,0730$ |
OK$ in capture |
| Set schedule days/volume | P,0136C4,3,1,7F,02$ |
OK$ in capture |
M,<id>,1$= on,M,<id>,0$= off, replyOK$.m,<id>$returnsM,1$orM,0$.
S,<id>,N$sets speed; replyOK$. Value is the bare integer, 1–2 digits (S,<id>,3$,S,<id>,10$).s,<id>$returnsS,0N$(zero-padded two digits).- Observed active levels: 1–10.
S,<id>,11$returnsFailed 03$. HA maps these 10 hardware levels to its entity's volume/speed controls. Saved evidence does not establish the firmware response toS,<id>,0$. The library accepts active speeds 1–10; HA volume/percentage 0 instead sends the known power-off commandM,<id>,0$and preserves the last speed.
The characteristic is not GATT-readable, so state is obtained by querying:
the original iOS capture polls m,<id>$ and s,<id>$ roughly every 5 seconds;
Android schedules both queries every second. HA currently polls every 30 seconds.
State comes from the resulting M,_$ / S,0_$ notifications, not a GATT read.
T,<id>,HHMM,D$ where HHMM is 24h local time (capture showed 2133 = 21:33)
and D is Monday=1 through Sunday=7, confirmed by Android's
LocalDate.getDayOfWeek().getValue(). No date/timezone/seconds are sent.
The app resyncs on pairing/reconnect. The standalone set_time(datetime.time, weekday) sends this command only when called; it discards seconds. Neither
standalone connect nor HA automatically syncs the clock. Clock changes can
change active onboard schedules. A t constant exists in the APK but has no
outbound caller or reply parser; the experimental query below is unconfirmed.
Query p,<id>,N$ returns a consolidated slot:
P,<slot>,<startHHMM>,<stopHHMM>,<daysBitmask>,<speed>$
e.g. P,1,2030,0730,7F,02$ -> slot 1, 20:30–07:30, days 0x7F (all 7), speed 2
Android confirms three slots (1–3) and three writes per update, in order:
P,<id>,1,<slot>,<startHHMM>$
P,<id>,2,<slot>,<stopHHMM>$
P,<id>,3,<slot>,<daysHex>,<speed02d>$
The field selector comes before the slot. Day bits are Monday=01,
Tuesday=02, Wednesday=04, Thursday=08, Friday=10, Saturday=20,
Sunday=40; weekdays=1F, all days=7F. Both day mask and speed are
zero-padded to two characters, mask in uppercase hex and speed in decimal.
In the app model, days=00 disables a slot and speed=00 deletes it.
Active speeds are 1–10. The default is 22:00–06:00, weekdays, speed 5.
Deletion still sends all three writes. These disable/delete semantics need
hardware verification. Overnight scheduling is supported by the app UI, but
firmware overlap, equal-time, and overnight-day behavior remain untested.
See Android findings for app quirks and validation steps.
The standalone client has get_schedule(slot), get_schedules() for all three
slots, and set_schedule(slot, start, stop, days, speed). The setter validates
all fields before sending, holds one operation lock for the three writes and
slot-matched readback, and returns a ScheduleReport only after all fields
match at minute precision. It stops at the first failure. Partial updates are
possible after failure or cancellation; there is no rollback. Serialization
is a client guarantee, not a firmware transaction.
COMMAND_GET_TIME = "t" and COMMAND_SET_UNIT_NUMBER = "I" in the APK
establish letters only. They do not establish outbound commands or parameters.
I is also the known ID report letter. The standalone library exposes these
inferred helpers without asserting firmware support:
| Method | Inferred send | Expected handling (also unproven) |
|---|---|---|
get_time() |
t,<id>$ |
Raw T,...$ bytes; uppercase response letter is inferred, fields unknown |
set_device_id(new_device_id) |
I,<current>,<new>$ |
OK$ (Ack), then actual ID query i$ |
The clock helper does not decode or invent a time schema. Either command can
be echoed, rejected, or time out. Accepted ID values and persistence are
unknown. The ID setter invalidates the cached ID before attempting the write,
and leaves it unknown until readback succeeds. A readback mismatch retains the
actual reported ID and raises DohmError; a match returns that ID. There is no
rollback guarantee. Changing an ID can break HA's MAC-derived addressing.
HA never calls these helpers.
The client matches typed reports, and schedule reads also match the slot.
Unrelated valid reports are ignored. There are no request IDs in this
protocol. Same-type stale reports and generic acknowledgements (OK$) cannot
be correlated reliably with a current request after retries or cancellation.
Queue draining, serialization, and verified readback reduce risk but cannot
prove which request caused a reply. A timeout or cancellation does not prove
that a write failed; read back state before retrying a deliberate write.
- For an unknown device: pair, enable notifications, query
i$, then use the returned ID. Standalone connect does this when no ID is supplied; HA supplies its hardware-observed MAC-derived ID instead. - Name and clock commands are housekeeping, not a recovered secret handshake. Hardware behavior still depends on bonding and notification setup.
- Android uses zero-padded ordinary speed writes (
S,<id>,03$); the existing integration's bare decimal (S,<id>,3$) is already captured as working. - The pure protocol module and standalone client include name, clock, and schedule operations, plus explicitly experimental clock-query/ID-write helpers. See the full standalone API for usage and errors.
- The selected HA entity still exposes only power and speed/volume. Setup chooses a media player or ten-step fan; it does not change BLE commands. HA does not sync device names, the clock, or schedules, or change device IDs.