This is the technical reference for the MyKronoz ZeTime's Bluetooth LE protocol, reconstructed by decompiling the official (now-defunct) MyKronoz Android apps and cross-checked against the open-source Gadgetbridge project, which has partial ZeTime support.
Three independent sources agree on every byte documented here: MyKronoz's own shipped code (both the original dedicated ZeTime app and the newer universal app), a community reverse-engineering write-up with no access to that code (gadgetbridge.org), and Gadgetbridge's actual working Java client. Where something has additionally been confirmed live against real hardware, it's marked ✅. Everything else is high-confidence static analysis (often corroborated across two or three of the above sources) but not yet empirically verified against a real device.
The watch runs a white-label BLE stack called Appscomm, used by many smartwatch brands — this is not a MyKronoz-specific protocol, which is partly why so much of it is independently documented elsewhere.
Hardware facts confirmed from the official user manual (a multi-language
consumer guide — no protocol-level content, but useful cross-checks):
Bluetooth BLE 4.2; display 240×240 px (independently confirms
the watchface image dimensions in §8); sensors are 3-axis accelerometer + optical
heart rate only — no on-board GPS chip, so COMMAND_CODE_GPS/GET_GPS_DATA (§4)
must relay location data the phone already has, not read it from the watch itself;
memory holds 10 days of offline data; up to 10 custom watchfaces can be designed
in the app, but only 4 fit in watch memory at once, each with up to 4 widgets —
independently matches the smallPathList.size < 4 limit found in the decompiled
watchface UI code (§8.2). Physical factory reset: hold both buttons for 10 seconds
(erases all data) — a manual fallback if a client bug ever gets the watch into a bad
state.
A note on precision: every negative (signed-byte) opcode value in this document
was converted to its wire-format hex byte with a script (unsigned = value & 0xFF),
not by hand — an earlier draft of this table had several manual-arithmetic errors
(caught by cross-checking against Gadgetbridge's independently-documented codes) that
are now fixed. If you're extending this table yourself, do the same.
Device advertises as ZeTime#<serial> (e.g. ZeTime#12345), standard public BLE
address, no special advertising-based discovery trickery.
00006006-0000-1000-8000-00805f9b34fb| Characteristic UUID | Short | Direction | Properties (confirmed live) | Purpose |
|---|---|---|---|---|
00008001-... |
8001 |
phone → watch | write-without-response | Send a command packet (or one 20-byte fragment of a longer one) |
00008002-... |
8002 |
watch → phone (+control) | notify, write-without-response | Primary ack/response channel for phone-initiated commands; also where the client must write a single 0x03 "ack kick" byte after sending a command (see §3) |
00008003-... |
8003 |
phone → watch (reply-only) | read, write-without-response (no notify) | Not a general channel — used exclusively by the phone to reply to watch-initiated exchanges (confirmed: Gadgetbridge only ever writes here for music-control replies). Never notifies because the watch doesn't push data through it, only reads it. |
00008004-... |
8004 |
watch → phone | notify, write-without-response | Async, watch-initiated pushes: real-time heart rate, and watch-initiated requests (music-control queries, call control) |
Confirmed live via bluetoothctl/bleak GATT enumeration against real hardware — see
scripts/enumerate_gatt.py. 0x8005 exists as a named constant in the decompiled
official app but is never referenced by Gadgetbridge's 2,251-line device-support
implementation — safe to ignore for a from-scratch client.
00007006-0000-1000-8000-00805f9b34fbReferenced in the decompiled app (UUID_SERVICE_EXTEND) and registered by
Gadgetbridge's coordinator, alongside characteristic 0x8005. Not observed in a
live GATT scan of this watch, and not used by Gadgetbridge for anything concrete —
possibly vestigial, or only relevant to other Appscomm-based devices.
00001800-... (Generic Access) — device name, appearance, etc.00001801-... (Generic Attribute) — service-changed indication0000180d-... (Heart Rate, standard BLE profile, 0x2a37 characteristic) —
registered by Gadgetbridge's coordinator; not yet confirmed present live on this
unit.00001530-0000-1000-8000-00805f9b34fb ("Apollo")"Apollo" isn't a project nickname — the main MCU is a real Ambiq Apollo2
(ultra-low-power Cortex-M4F, wearable/IoT-oriented), confirmed by leftover
am_hal_*.c debug paths in Apollo_2.0.55.bin. The firmware
also confirmed a separate external NAND flash and a named OTA subsystem that
tracks the heart-rate chip and touch-panel chip independently from the Apollo's
own update — matching §8.4's 3-chip descriptor theory below.
TouchPanel_3.3.bin (the touch-controller's own OTA payload) does not look
like general-purpose Cortex-M firmware the way Apollo_2.0.55.bin does — no
readable strings anywhere, no vector-table structure, and a Thumb disassembly
sanity check shows none of the bl/push/pop density real compiled code has. More consistent with a fixed calibration/parameter table for
a dedicated touch ASIC than replaceable firmware, though this isn't proven —
its actual format/architecture couldn't be determined with the tools available.
| Characteristic | Short | Purpose |
|---|---|---|
00001531-... |
1531 |
Control point (commands + notified acks) |
00001532-... |
1532 |
Packet/data channel |
This is a close match to Nordic Semiconductor's "Legacy DFU" protocol — a real,
publicly documented industry-standard firmware-update mechanism, not something
MyKronoz/Appscomm invented. Used both for actual firmware updates and for pushing
watchface images (see §8). Not observed in a live GATT scan in normal operating
mode — the working theory (untested) is that it only appears after the watch is put
into upgrade/bootloader mode via COMMAND_CODE_UPGRADE_MODE (opcode 0x0E).
Diffed the exact UUID constants between the older dedicated ZeTime app (v1.8.2,
unobfuscated) and the newer universal app (v2.0.23, R8/ProGuard-obfuscated but with
the equivalent class identifiable via a surviving decompiler comment,
cn/appscomm/bluetooth/d.java / BluetoothCommandConstant.java). All 12 UUIDs are
byte-for-byte identical — the core 0x6006 transport, the extend service, and the
DFU/Apollo service are unchanged across at least these two app/firmware generations.
The universal app additionally bundles a second, cleanly-named (non-obfuscated)
package, cn.appscomm.bluetoothsdk, with its own model classes (WeatherData,
HeartRateData, SleepData, RealTimeSportData, SportData, ReminderData,
ReminderExData, CalendarData, CustomizeReply, CustomizeWatchFaceExData,
SwitchType, SettingType), alongside the legacy cn.appscomm.bluetooth
package everything in this document is otherwise based on. ✅ Investigated: it's a friendlier Java-side wrapper, not a separate wire
protocol — its methods (confirmed for the watchface path specifically) delegate
straight through to the same underlying cn.appscomm.bluetooth/cn.appscomm.ota
classes. Same wire protocol either way, just a different entry point — see §8.2
and §8.4 for what this pass through it actually turned up.
6F [cmd:1] [action:1] [len:2, little-endian] [payload:len bytes] 8F
0x6F = start flag, 0x8F = end flag. (There is also an older 0x6E-prefixed
framing used only by the legacy "6E" opcode family — see §5 — which drops the
action byte: 6E 01 [cmd:1] [payload...] 8F.)6F 08 70 01 00 00 8F and receives
6F 08 80 01 00 56 8F back (0x56 = 86%).Apollo_2.0.55.bin turned up a function (0x2c65c) that builds an
outgoing 6-byte buffer [0x6f, 0x01, 0x01, <byte>, crc_lo, crc_hi] — previously
this format was only ever confirmed from the decompiled Android app side and live
BLE captures, never read directly out of the watch's own firmware. Not fully
chased down (the trailing checksum algorithm wasn't confirmed to match, and the
0x01/0x01 opcode+action meaning here is unknown) — a lead for later, not a
full independent re-confirmation of this section.msg[0]==0x6F and (msg[3] != 0 || msg[4] != 0) —
i.e. a declared length of 0x0000 is explicitly treated as invalid on the receiving
side too. This independently confirms why a len=0 outgoing request gets silently
dropped (see next point) — the convention holds in both directions.len=1) ✅ / cross-confirmedEarly testing found that a bare "request/check" command must use len=1 with a single
0x00 payload byte, not len=0 with no payload — sending the "obvious" 6-byte version
produced total silence. Both Gadgetbridge and the official gadgetbridge.org write-up
confirm this in more detail: the required length depends on the command type:
| Request type | len | payload | Example |
|---|---|---|---|
| Singleton value (battery, serial, availability count, most settings) | 1 | [0x00] |
6F 08 70 01 00 00 8F (battery) |
Paged data-fetch (steps 0x54, sleep 0x56) |
2 | [0x00, 0x00] |
6F 54 70 02 00 00 00 00 8F |
Heart-rate fetch (0x61) |
1 | [0x00] |
(uses the singleton pattern, not the 2-byte one, despite also being a records fetch) |
The 2-byte payload on steps/sleep fetches is very likely a start-index/pagination
argument (0x00,0x00 = "from the beginning"), consistent with the "packet number"
field present at the start of those subjects' response payloads (see §6).
Immediately after writing a command to 0x8001, the client must also write a
single byte 0x03 to characteristic 0x8002. Without this, the watch never replies.
Confirmed in the decompiled app as a method literally named send03ToDevice(...), and
in Gadgetbridge as builder.write(ackCharacteristic, new byte[]{CMD_ACK_WRITE}) where
CMD_ACK_WRITE = 0x03.
Refinement from reading Gadgetbridge's full source: for messages longer than 20
bytes (BLE's default single-packet payload size), the message is split into ≤20-byte
fragments, each written individually to 0x8001 in sequence — but the 0x03 ack byte
is written once, after all fragments, not once per fragment.
Working sequence for any command, long or short:
1. write 0x8001 <- fragment 1 (≤20 bytes)
2. write 0x8001 <- fragment 2 (≤20 bytes), if the message is longer than 20 bytes
... repeat for further fragments...
3. write 0x8002 <- [0x03]
4. wait for notify on 0x8002 (or 0x8004, depending on command)
0x8003Don't confuse this with the 0x8002 ack-kick above — it's a completely separate
mechanism used only when the watch itself initiates an exchange (see "Bidirectional
protocol" below). In that case the phone's reply is written directly to 0x8003
with action byte 0x80 (here meaning CMD_REQUEST_RESPOND, contextually distinct from
ACTION_CHECK_RESPONSE even though it's the same byte value — see §3), and no
0x03 ack-kick write to 0x8002 is involved at all.
If an incoming 0x8002 notification declares a payload length greater than 14 bytes,
Gadgetbridge buffers that fragment and waits for a second notification, then
concatenates the two raw received packets (not payload-only) before parsing as one
message. Only 2-part reassembly is implemented in Gadgetbridge — there's no evidence
of (or apparent need for) more than 2 fragments in practice.
Not just a phone→watch command/response protocol: the watch spontaneously initiates
its own exchanges for at least music control (querying current playback
state/volume from the phone) and hand-movement/analog-hand calibration
(WatchMoveKeep carries what looks like a ciphertext challenge/response). A real
client needs to listen for these and reply using the appropriate response-shaped
opcode/characteristic — not just send requests and wait.
The decompiled app has COMMAND_CODE_BIND_START (0x93) / COMMAND_CODE_BIND_END
(0x94) commands, apparently for first-time factory pairing. Do not send these for
normal use — empirically, doing so on an already-bonded watch caused the connection
to become unstable (watch showed a "broken link" icon, connection dropped shortly
after). Gadgetbridge's real working initializeDevice never sends either command;
it just enables notifications on 0x8002/0x8004 and goes straight to requesting
device/battery info. Gadgetbridge also explicitly sets BONDING_STYLE_NONE — it does
not even perform OS-level BLE bonding, only a GATT connection.
Mystery solved, from the official user manual's setup walkthrough: real pairing is
a two-sided interactive handshake — the phone app shows a pairing prompt the user
accepts, and then the watch itself shows its own pairing prompt that must be tapped
on its touchscreen before pairing completes. That explains the "broken link" seen
during this project's own BindStart/BindEnd experiment: the watch was very likely
waiting for that on-screen tap-to-accept, which no client here provided, so it timed
out and dropped the connection. This isn't just a raw protocol exchange — it's a UX
flow with a required human step on the watch. Reinforces treating bind as unnecessary
overhead to avoid entirely (per the Gadgetbridge-confirmed approach above) rather than
something to reverse-engineer more carefully.
Gadgetbridge's calendar-sync code inserts a mandatory 300ms delay between each event it sends, with the code comment "Urgh, seems it is a general problem when sending data too fast." Treat this as a general caution, not just a calendar-specific quirk: pace bursts of writes rather than firing them back-to-back.
Bonded/Trusted at the OS level. Reconnect with
bluetoothctl connect <mac> (or equivalent) before each session/burst of commands.connect attempt can easily straddle that brief window and time out even with
retries. The robust fix, confirmed by reading Gadgetbridge's generic BLE layer
(BtLEQueue.java's scanReconnect mode): actively scan for the advertisement
and connect the moment it's seen, rather than blindly retrying a direct connect
and hoping to land inside the window. Gadgetbridge's own code has a telling
comment on this: "connectGatt with true doesn't really work;( too often
connection problems" -- i.e. they tried Android's built-in auto-reconnect first
and found it unreliable too, hence their own scan-first approach. This project's
client implements the same two-phase strategy (try a fast direct connect for the
common already-connected case, then fall back to active scanning) in
core/connection.py.| Value | Hex | Name | Meaning |
|---|---|---|---|
| 112 | 0x70 |
CMD_REQUEST / ACTION_CHECK |
"Get" / query request |
| -128 | 0x80 |
ACTION_CHECK_RESPONSE (as a reply to a phone-initiated GET) or CMD_REQUEST_RESPOND (as the phone's reply to a watch-initiated request, over 0x8003) |
Same byte, two contextual meanings — direction/characteristic disambiguates |
| 113 | 0x71 |
CMD_SEND / ACTION_SET |
"Set" / push request, or a watch-initiated live command (e.g. music play/pause) |
| -127 | 0x81 |
ACTION_SET_RESPONSE |
Reply to a set (8-byte fixed length: cmd, action, len:2, [echoed_cmd, status], end) |
| 1 | 0x01 |
COMMAND_CODE_RESPONSE |
Generic response wrapper — payload is [echoed_command_code, status]. Also used by the phone to ack a watch-initiated command (SendResponse class) — confirms bidirectionality at this level too. |
Status byte inside a response payload:
| Value | Meaning |
|---|---|
| 0 | RESPONSE_SUCCESS |
| 1 | RESPONSE_FAIL |
| 2 | RESPONSE_ERROR (protocol parse error) |
Confirmed live ✅ — reading characteristic 0x8002 (before any commands of our
own were sent) returned a stale cached value 6f 01 81 02 00 5a 00 8f, decoding as:
cmd=RESPONSE(1), action=SET_RESPONSE(0x81), payload=[0x5a, 0x00] = opcode 0x5A
(COMMAND_CODE_DELETE_HEART_RATE_DATA) + status 0 — i.e. "your last 'delete heart
rate data' command succeeded," left over from a previous (real) session with the
official app.
0x6F-framed protocol)Every hex value below was computed by script (signed_byte & 0xFF) from the decompiled
app's BluetoothCommandConstant.java, not by hand. Cross-checked against
Gadgetbridge/gadgetbridge.org wherever they document the same command — every overlap
matches exactly.
| Hex | Dec | Name | Notes |
|---|---|---|---|
0x01 |
1 | COMMAND_CODE_RESPONSE |
generic response wrapper (see §3) |
0x02 |
2 | COMMAND_CODE_WATCH_ID |
serial number — ✅ full spec confirmed (gadgetbridge.org): request 6F 02 70 01 00 00 8F, response 6F 02 80 0C 00 <12 ASCII bytes> 8F |
0x03 |
3 | COMMAND_CODE_DEVICE_VERSION |
payload [type:1][version_ascii]; type 0=deviceType, 1/5=softVersion, 2=hardwareVersion, 3=commProtocol, 4=functionVersion, 6=deviceInfo; request content70=5 for firmware / 2 for hardware (two separate requests) |
0x04 |
4 | COMMAND_CODE_DATETIME |
time sync, see §6 |
0x05 |
5 | COMMAND_CODE_TIME_SURFACE_SETTING |
combined date/time/battery/lunar/screen-format setter, see §6 |
0x06 |
6 | COMMAND_CODE_PRIMARY_SURFACE_DISPLAY_SETTING |
widget display order, see §6 |
0x07 |
7 | COMMAND_CODE_SCREEN_BRIGHTNESS_SETTING |
[value:1] both directions |
0x08 |
8 | COMMAND_CODE_BATTERY_POWER |
✅ live-confirmed, [percent:1] |
0x09 |
9 | COMMAND_CODE_VOLUME |
[value:1] both directions |
0x0A |
10 | COMMAND_CODE_SHOCK_MODE |
per-event vibration style, see §6 |
0x0B |
11 | COMMAND_CODE_LANGUAGE |
[index:1], 0-18, see §7 |
0x0C |
12 | COMMAND_CODE_UNIT |
[value:1], metric/imperial |
0x0D |
13 | COMMAND_CODE_RESTORE_FACTORY |
⚠️ destructive, fire-and-forget |
0x0E |
14 | COMMAND_CODE_UPGRADE_MODE |
enters OTA/bootloader mode, see §8 — 3 overloaded forms, see §6 |
0x10 |
16 | COMMAND_CODE_SHOCK_STRENGTH |
[strength:1] |
0x11 |
17 | COMMAND_CODE_MAIN_ALARM_BACKGROUND_COLOR |
6-byte RGB+RGB, see §6 |
0x12 |
18 | COMMAND_CODE_WORK_MODE |
[mode:1] |
0x13 |
19 | COMMAND_CODE_BRIGHT_SCREEN_TIME |
[seconds:1], 0xFF="always bright" |
0x14 |
20 | COMMAND_CODE_SNOOZE |
[minutes:1] |
0x15 |
21 | COMMAND_CODE_DO_NOT_DISTURB |
5-byte schedule, see §6 |
0x16 |
22 | COMMAND_CODE_TRAN_SPEED |
[1=slow,2=normal,3=fast], negotiates BLE chunk speed pre-transfer |
0x17 |
23 | COMMAND_CODE_POWER_OFF_MODE |
[mode:1] — ✅ values decoded, : 0=hands keep running only (mechanical analog hands never truly stop; everything else pauses to save power), 1=hands running and activity/step tracking stays active too. Implemented (zetime settings get/set power-off-mode) — ❓ not live-tested. |
0x18 |
24 | COMMAND_CODE_TIME_ZONE |
see §6 |
0x19 |
25 | COMMAND_CODE_NOTIFICATIONS_TEXT_SIZE |
[size:1] |
0x1A |
26 | COMMAND_CODE_CONTROL_DEVICE |
sub-command byte, see §6 and sub-table below. Activity tracking on/off = 0x09/0x0A |
0x1E |
30 | COMMAND_CODE_CUSTOMIZE_WATCH_FACE_EX |
watchface slot query/negotiate, see §8 |
0x1F |
31 | COMMAND_CODE_CUSTOMIZE_WATCH_FACE_PRO |
delete-by-CRC (or delete-all) for the 0x20-family watchface slots, plus a bulk CRC/ID list GET; see §8.2b. ✅ Delete-all live-confirmed working; specific-CRC delete and the list GET are not. |
0x20 |
32 | COMMAND_CODE_CUSTOMIZE_WATCH_FACE_SET |
the real "push a face" command — full 23-byte layout in §8 |
0x21 |
33 | COMMAND_CODE_ANALOG_MODE |
[mode:1][scaleFlag:1], controls physical analog hands |
0x22 |
34 | COMMAND_CODE_WEATHER_SETTING |
[showType:1][set:1] |
0x23 |
35 | COMMAND_CODE_FIND_DEVICE |
✅ fully specified, ❌ live-tested with no response/effect on real hardware — see §6 |
0x25 |
37 | COMMAND_CODE_BRIGHT_SCREEN_TIME_EX |
extended-range version of 0x13 |
0x26 |
38 | COMMAND_CODE_EVENT_TIME_INTERVAL |
e.g. drink-water reminders; [type:1][value:1] |
0x27 |
39 | COMMAND_CODE_SPECIFIC_APPLICATION_UNIT |
[appType:1][unitType:1][value:0-4] |
0x28 |
40 | COMMAND_CODE_NB_IOT |
cellular variant only (IMEI/IMSI/CSQ) — N/A on standard ZeTime |
0x2A |
42 | COMMAND_CODE_APP_SETTING |
⚠️ new, fully specified, never reachable from any SDK/app entry point — sub-tagged: tag 1=pageQueueArray (a variable-length list of small positive byte IDs — the decoder zero-filters the response, dropping any 0 byte, consistent with 0 being an empty-slot sentinel in an ordered list rather than real data; confirms the exact filtering behavior but the field is unreachable from any UI, so which "pages" it orders was not resolved), tag 2=deviceTheme (1 byte) |
0x2E |
46 | COMMAND_CODE_CUSTOMIZE_BUTTON |
✅ fully specified, implemented (zetime button get/set) — ❓ not live-tested — assign an action to a physical button, see §6 |
0x2F |
47 | COMMAND_CODE_TIME_PERIOD_BRIGHTNESS |
⚠️ new, fully specified, never reachable from any SDK/app entry point — [enabled:1][startHour:1][startMin:1][endHour:1][endMin:1][brightness:1], scheduled/night brightness window, same shape as DND (0x15) |
0x30 |
48 | COMMAND_CODE_USER_INFO |
[sex:1][age:1][height_cm:1][weight_x10:2], see §6 |
0x31 |
49 | COMMAND_CODE_USAGE_HABITS |
[0=left,1=right] wrist |
0x32 |
50 | COMMAND_CODE_USER_NAME |
fixed 16-byte UTF-8 buffer — ⚠️ official app has an array-bounds bug here, don't replicate, see §6 |
0x50 |
80 | COMMAND_CODE_GOAL |
steps/calories/distance/sleep/sport-time goals, see §6 and enum below |
0x51 |
81 | COMMAND_CODE_SPORT_SLEEP_MODE |
read-only, [0=sport,1=sleep] |
0x52 |
82 | COMMAND_CODE_TOTAL_SPORT_SLEEP_COUNT |
also doubles as "information availability" — [stepsCount:2][sleepCount:2](+opt hr/mood/bp counts), see §6 |
0x53 |
83 | COMMAND_CODE_DELETE_SPORT_DATA |
⚠️ destructive |
0x54 |
84 | COMMAND_CODE_GET_SPORT_DATA |
activity records, len=2 request, see §6 |
0x55 |
85 | COMMAND_CODE_DELETE_SLEEP_DATA |
⚠️ destructive |
0x56 |
86 | COMMAND_CODE_GET_SLEEP_DATA |
sleep records, len=2 request, see §6 |
0x57 |
87 | COMMAND_CODE_DEVICE_DISPLAY_DATA |
get-only, controls watch's own display-rotation widgets |
0x58 |
88 | COMMAND_CODE_AUTO_SLEEP |
[enterH][enterM][quitH][quitM][remindCycle] |
0x59 |
89 | COMMAND_CODE_TOTAL_HEART_RATE_COUNT |
[count:2] |
0x5A |
90 | COMMAND_CODE_DELETE_HEART_RATE_DATA |
⚠️ destructive — the opcode seen in the live stale-cache ack (§3) |
0x5B |
91 | COMMAND_CODE_GET_HEART_RATE_DATA |
single-record variant, see §6 |
0x5C |
92 | COMMAND_CODE_AUTO_HEART_RATE |
[intervalMinutes:1] |
0x5D |
93 | COMMAND_CODE_HEART_RATE_ALARM_THRESHOLD |
[max:1][min:1][enabled:1] |
0x5E |
94 | COMMAND_CODE_INACTIVITY_ALERT |
day-of-week bitmask + schedule, see §6 |
0x5F |
95 | COMMAND_CODE_GET_MOOD_DATA |
[index:2][timestamp:4][fatigue:2][emotional:2] |
0x60 |
96 | COMMAND_CODE_CALORIES_TYPE |
[type:1] |
0x61 |
97 | COMMAND_CODE_GET_HEART_RATE_DATA_EX |
can pack 2 records per message, see §6 |
0x62 |
98 | COMMAND_CODE_TOTAL_BLOOD_PRESSURE_COUNT |
[count:2] |
0x63 |
99 | COMMAND_CODE_DELETE_BLOOD_PRESSURE_DATA |
⚠️ destructive |
0x64 |
100 | COMMAND_CODE_GET_BLOOD_PRESSURE_DATA |
payload format not fully documented by any source |
0x65 |
101 | COMMAND_CODE_BLOOD_PRESSURE_CHIP_LEARN |
sensor calibration, undocumented payload |
0x66 |
102 | COMMAND_CODE_TOTAL_REAL_TIME_SPORT_DATA_COUNT |
[count:2] |
0x67 |
103 | COMMAND_CODE_GET_REAL_TIME_SPORT_DATA |
timed-workout session record, see §6 |
0x68 |
104 | COMMAND_CODE_DELETE_REAL_TIME_SPORT_DATA |
⚠️ destructive |
0x6A |
106 | COMMAND_CODE_GPS |
undocumented payload beyond the GPS data-record format |
0x6B |
107 | COMMAND_CODE_GET_GPS_DATA |
see §6 |
0x70 |
112 | COMMAND_CODE_PHONE_NAME_PUSH |
incoming/missed-call caller name, [type:1][content:N] |
0x71 |
113 | COMMAND_CODE_SMS_PUSH |
[type:1][content:N] |
0x72 |
114 | COMMAND_CODE_MSG_COUNT_PUSH |
[msgType:1][msgCount:1], badge-only |
0x73 |
115 | COMMAND_CODE_SOCIAL_PUSH |
[type:1][content:N] |
0x74 |
116 | COMMAND_CODE_EMAIL_PUSH |
[type:1][content:N] |
0x75 |
117 | COMMAND_CODE_SCHEDULE_PUSH |
calendar, [type:1][content:N] |
0x76 |
118 | COMMAND_CODE_SOCIAL_EX_PUSH |
richer notification format, see §6 |
0x77 |
119 | COMMAND_CODE_WEATHER_PUSH |
see §6 |
0x90 |
-112 | COMMAND_CODE_SWITCH_SETTING |
global feature bit-flags, see enum below — note: distinct from COMMAND_CODE_6E_PHONE_SWITCH_SETTING (0xB4/-76), a different opcode in the legacy 0x6E family, not the same value |
0x91 |
-111 | COMMAND_CODE_REMIND_COUNT |
[count:1], get-only |
0x92 |
-110 | COMMAND_CODE_REMIND_SETTING |
base reminder/alarm protocol generation, see §6 |
0x93 |
-109 | COMMAND_CODE_BIND_START |
⚠️ avoid — see §2 |
0x94 |
-108 | COMMAND_CODE_BIND_END |
⚠️ avoid — see §2 |
0x95 |
-107 | COMMAND_CODE_REMIND_SETTING_EX_DATE |
reminder gen 2, see §6 — ⚠️ has a year-decode bug in the official app; ⚠️ also reused for an unrelated 1-byte boolean by a confirmed-dead code path (BluetoothSDK.jumpToRealHeartRateOld, superseded by the real jumpToRealHeartRate) — don't assume a captured 6F 95 71 01 00 xx 8F frame is a reminder-date write |
0x96 |
-106 | COMMAND_CODE_REMIND_SETTING_EX_SHOCK |
reminder gen 3 |
0x97 |
-105 | COMMAND_CODE_REMIND_SETTING_EX_DATE_SHOCK |
reminder gen 4 — ⚠️ same year-decode bug |
0x98 |
-104 | COMMAND_CODE_CALENDAR_MONTH_VIEW |
✅ byte layout confirmed, : fixed 32-byte SET payload, 8× 4-byte little-endian fields — but built from the 8 constructor args in the order [arg2][arg1][arg3][arg4][arg5][arg6][arg7][arg8] (args 1/2 swapped on the wire), traced to MBluetooth.java's real setCalendarMonthView. Field semantics beyond "probably year/month/day-of-month bitmask" still unclear — no real call site with populated values was found (the one app-side caller, ZeCalendarProtocol.setMonthlyView, itself has no visible caller, and the higher-level setCalendar failed to decompile). getCalendar is an empty stub — no read-back exists, same pattern as CALENDAR_DAY_VIEW's CRC-only GET. |
0x99 |
-103 | COMMAND_CODE_CALENDAR_DAY_VIEW |
see §6 |
0x9A |
-102 | COMMAND_CODE_PROTOCOL_SET |
opaque passthrough |
0x9B |
-101 | COMMAND_CODE_REMIND_SETTING_EX_DATE_SHOCK_REPEAT |
reminder gen 5 (final) — ⚠️ official app's SET constructor targets the wrong opcode (0x97 instead of 0x9B); use 0x9B for both directions in a real client |
0xA2 |
-94 | COMMAND_CODE_SET_CALORIES_DISTANCE_GOAL |
|
0xB0 |
-80 | COMMAND_CODE_PAY_CARD_NUMBER |
payment-enabled variant only, see §6 |
0xB1 |
-79 | COMMAND_CODE_PAY_MONEY |
balance query, see §6 |
0xB2 |
-78 | COMMAND_CODE_PAY_RECORD |
transaction history, see §6 |
0xB3 |
-77 | COMMAND_CODE_PAY_PASS_THROUGH |
transaction history, see §6 |
0xB6 |
-74 | COMMAND_CODE_WATCH_MOVE_ONE |
[hourMinFlag:1][direction:1][angle:2], fire-and-forget |
0xB8 |
-72 | COMMAND_CODE_WATCH_MOVE_KEEP |
hand-movement + LED color; also carries a ciphertext challenge/response — possible security handshake |
0xBA |
-70 | COMMAND_CODE_MACHINE_TIMING |
analog-hand calibration, [hourAngle:2][minAngle:2][hour][min][sec] |
0xD0 |
-48 | COMMAND_CODE_MUSIC |
watch-initiated, phone replies via 0x8003, see §2 and §6 |
0xD1 |
-47 | COMMAND_CODE_TAKE_PHOTO |
remote camera shutter, undocumented payload |
0xD2 |
-46 | COMMAND_CODE_FIND_PHONE |
undocumented payload |
0xD5 |
-43 | COMMAND_CODE_PHONE_CONTACT / THREE_AXES_SENSOR |
contact push: [attribute:1][ground:1][numberLen:1][nameLen:1][number+name] |
0xD6 |
-42 | COMMAND_CODE_WATCH_MOVE_ONE_OLD |
legacy variant of 0xB6 |
0xD8 |
-40 | COMMAND_CODE_UPLOAD_NFC / WATCH_MOVE_KEEP_OLD |
legacy variant of 0xB8 |
0xD9 |
-39 | COMMAND_CODE_SEND_SOS |
undocumented payload |
0xDA |
-38 | COMMAND_CODE_CUSTOMIZE_COUNT_CRC |
✅ corrected, live-confirmed — count+CRC prerequisite for 0xDB's preset quick-reply GET, see §6 |
0xDB |
-37 | COMMAND_CODE_CUSTOMIZE_REPLY |
✅ corrected, implemented, live-validated — preset SMS/social quick-reply text, GET/SET, see §6 |
0xDC |
-36 | COMMAND_CODE_INCOME_CALL_RESPONSE |
call control from watch, see §6 (only the "reject" case is decoded) |
0xDD |
-35 | COMMAND_CODE_SMS_REPLY |
quick-reply from watch, undocumented payload |
0xDE |
-34 | COMMAND_CODE_SMS_REPLY_CONTENT |
undocumented payload |
0xDF |
-33 | COMMAND_CODE_DEVICE_ACTIVE_RESPONSE |
undocumented payload |
0xE2 |
-30 | COMMAND_CODE_REAL_TIME_HEART_RATE |
✅ live-observed (cached push, [bpm:1], 82 bpm) |
0xE3 |
-29 | COMMAND_CODE_SEND_TRANSPARENT_COMMAND |
raw passthrough, response stored verbatim |
0xE4 |
-28 | COMMAND_CODE_OTA_PHOTO |
photo-transfer channel, likely caller-ID photo feature |
0xE5 |
-27 | COMMAND_CODE_OTA_PHOTO_ATTRIBUTE |
photo metadata: year/month/day/name/id |
0xE6 |
-26 | COMMAND_CODE_SOCIAL_REPLY |
quick-reply from watch, undocumented payload |
0xE9 |
-23 | (unnamed, SendWorkoutGPSDistance/...ConfirmStatus/...Status) |
⚠️ new gap, not fully specified — real-time GPS-workout exchange, phone→watch, action=0x80 (unusual — response-style action on an outbound send, consistent with this replying to a watch-initiated request). Dead in both apps' UI, like 0x23. Not implemented. |
0xEF |
-17 | COMMAND_CODE_WATCH_POINTER_POSITION |
[minCur:2][hourCur:2][minOffset:2][hourOffset:2], hand calibration, both directions |
0xF7 |
-9 | COMMAND_CODE_FLASH_TEST |
factory test, fire-and-forget |
0xFA |
-6 | COMMAND_CODE_SHOCK_TEST |
factory test, fire-and-forget |
0xFB |
-5 | COMMAND_CODE_TOUCH_TEST |
factory test, fire-and-forget |
0xFC |
-4 | COMMAND_CODE_SENSOR_TEST |
get→[x:2][y:2][z:2] raw accel/gyro |
0xFE |
-2 | COMMAND_CODE_LCD_TEST |
factory test, fire-and-forget |
0xFF |
-1 | COMMAND_CODE_WRITE_WATCH_ID |
⚠️ writes device identity — never use outside real factory provisioning |
There are also COMMAND_CODE_6E_*-prefixed constants (a separate, older opcode
namespace used only with the legacy 0x6E-start framing) — see §5.
COMMAND_CODE_CONTROL_DEVICE (0x1A) sub-commands| Value | Name |
|---|---|
| 0 | SET_LIGHT_SCREEN / GET_TRACKING (context-dependent on action) |
| 1 | SET_SHUT_DOWN |
| 2 | SET_ENTER_FLIGHT_MODE |
| 3 | SET_JUMP_REAL_TIME_HEART_RATE |
| 4 | SET_START_MACHINE_TIMING_HOUR |
| 5 | SET_START_MACHINE_TIMING_MINUTE |
| 7 | SET_AVI_APPLICATION_OPEN |
| 8 | SET_AVI_APPLICATION_CLOSE |
| 9 | SET_TRACKING_FORBID |
| 10 | SET_TRACKING_ALLOW |
| 11 | SET_JUMP_TAKE_PHOTO — ✅ new,: jump the watch to its camera-remote screen, BluetoothSDK.jumpToTakePhoto, result code 176 |
| 13 | SET_START_RECORD — found in PMBluetoothCall.java,; purpose still not directly confirmed (never called from any app code, and MBluetooth.controlDevice is a bare pass-through with no differentiated per-value logic) — but sits in the constant table directly alongside GET_TRACKING(0)/SET_TRACKING_FORBID(9)/SET_TRACKING_ALLOW(10)/SET_JUMP_REAL_TIME_HEART_RATE(3)/SET_EXITS_REAL_TIME_HEART_RATE(15) — a cluster of sport/GPS-tracking-session commands — making "start/end a workout recording session" the best-supported reading, over earlier voice-memo/screen-recording guesses. Inference from neighboring constants, not a confirmed call site. |
| 14 | SET_END_RECORD — same source, same caveat and same inference |
| 15 | SET_EXITS_REAL_TIME_HEART_RATE — same source |
| 16 | FIND_PHONE_SUCCESS — same source; odd fit for the otherwise-small-int block above, possibly a different namespace reusing this constant class, not confirmed |
Gadgetbridge's own get-response parsing for this opcode has an inversion worth
flagging: it treats activity tracking as on when msg[6] != 0x01 — i.e. 0x01
means off, anything else means on, the opposite of the "0=off/1=on" convention used
almost everywhere else in the protocol.
0x50 / COMMAND_CODE_GOAL)| Value | Name | Set-payload unit |
|---|---|---|
| 0 | GOAL_TYPE_STEP |
raw steps ÷ 100 |
| 1 | GOAL_TYPE_CALORIE |
raw |
| 2 | GOAL_TYPE_DISTANCE |
meters ÷ 1000 (km) |
| 3 | GOAL_TYPE_SLEEP |
raw |
| 4 | GOAL_TYPE_SPORT_TIME |
raw |
Used with opcodes 0x70-0x77 (PHONE_NAME_PUSH through WEATHER_PUSH). Two
sources disagree on part of this table — the official app's MSG_PUSH_TYPE_*
constants (below) go further (up to Skype at 21) than gadgetbridge.org's own
independently-reverse-engineered table, which stops at Instagram (16) and has slightly
different ordering/labels for a couple of entries (e.g. it lists 0x02 as an
unclear "yellow bubble" and 0x04 as "nothing" where the app's table has Social and
Calendar respectively). Documenting both rather than silently picking one:
From the official app (MSG_PUSH_TYPE_*, used by this document elsewhere):
| Value | App |
|---|---|
| 0 | Missed call |
| 1 | SMS |
| 2 | Social (generic) |
| 3 | |
| 4 | Calendar |
| 5 | Incoming call |
| 6 | Call ended |
| 7 | |
| 8 | Viber |
| 9 | Snapchat |
| 10 | |
| 11 | |
| 12 | |
| 13 | Hangouts |
| 14 | Gmail |
| 15 | Messenger |
| 16 | |
| 17 | |
| 18 | |
| 19 | Uber |
| 20 | Line |
| 21 | Skype |
From gadgetbridge.org's independent analysis (0x00-0x10 only): 00 missed-call,
01 SMS, 02 "yellow bubble" (unclear, possibly social), 03 email, 04 "nothing"
(possibly calendar or unused), 05 incoming call, 06 stop-incoming-call, 07 multi-user
message, 08 Viber, 09 Snapchat, 0a WhatsApp, 0b unknown, 0c Facebook, 0d Hangout, 0e
Gmail, 0f Messenger, 10 Instagram.
If implementing notifications yourself, prefer the app's table (it's the actual source, not a guess).
Live-confirmed, all 22 values: every value 0-21 in
the official app's table was pushed via notify social <app> and checked on
real hardware. All render a distinct, app-specific icon except 4
(Calendar) -- confirmed to accept the command (SUCCESS status) but display
nothing, resolving the old ambiguity in gadgetbridge.org's favor for that
one value ("nothing," not really a calendar icon). 2 ("Social/generic" in
the app's table vs. gadgetbridge.org's unclear "yellow bubble") is now
confirmed to render its own distinct icon, resolving that ambiguity in the
app table's favor instead. Values 17-21 (Twitter/LinkedIn/Uber/Line/Skype,
beyond gadgetbridge.org's 16-value table) also all confirmed working with
distinct icons, on top of already backing the app's table as the more
complete/accurate of the two sources.
0x92 and successors)| Value | Meaning |
|---|---|
| 0 | Eat |
| 1 | Medicine |
| 2 | Drink water |
| 3 | Sleep |
| 4 | Wake up |
| 5 | Sport |
| 6 | Meeting |
| 7 | Custom |
| 8 | Bills |
| 9 | Personnel |
0x0A / COMMAND_CODE_SHOCK_MODE)| Value | Meaning |
|---|---|
| 0 | No shock |
| 1 | Signal long shock |
| 2 | Signal short shock (also SHOCK_MODE_DEFAULT) |
| 3 | Two long shocks / SHOCK_MODE_custom |
| 4 | Two short shocks |
| 5 | Long+short interval |
| 6 | Always long shock |
| 7 | Always short shock |
| 8 | Five long shocks (interval) |
| 9 | Signal sound |
| 10 | Two sounds |
| 11 | Always sound |
| 12 | One shock + sound |
| 13 | Always shock + sound |
| 14 | Mute |
0x90 / COMMAND_CODE_SWITCH_SETTING)A multi-byte bitmask, confirmed byte-boundary grouping from the decompiled
SwitchSetting class: byte 0 = bits 0-7 (anti-lost.. social), byte 1 = bits
8-15 (email.. goal-achieved), byte 2 (optional) = bits 16+ (real heart-rate,
super alarm clock, heart-rate monitor, three-axes sensor, heart-rate variability).
| Bit value | Setting |
|---|---|
| 1 | Anti-lost |
| 2 | Auto-sync |
| 4 | Sleep tracking |
| 8 | Auto-sleep detection |
| 16 | Call notifications |
| 32 | Missed-call notifications |
| 64 | SMS notifications |
| 128 | Social notifications |
| 256 | Email notifications |
| 512 | Calendar notifications |
| 1024 | Sedentary alert |
| 2048 | Low-power alert |
| 4096 | Second reminder |
| 8192 | Ring |
| 16384 | Raise-to-wake |
| 32768 | Goal-achieved alert |
| 65536 | Real-time heart rate |
| 131072 | Super alarm clock |
| 262144 | Heart-rate monitor |
| 524288 | Three-axes sensor |
Five more bits confirmed via a real SWITCH_TYPE_* enum (PMBluetoothCall.java) reachable only through the narrow single-bit
setSwitchSetting(index, enabled) form (no SWITCH_BIT_*/full-bitmask constant
exists for these, unlike bits 0-19 above) — not live-tested:
| Bit index | Setting |
|---|---|
| 20 | Sliding vibration |
| 21 | Mood |
| 22 | Click vibration |
| 23 | Double-click-back |
| 24 | Always-bright |
Gadgetbridge's own read of this command (bit 0x40 of byte 1, i.e. bit 6 overall) maps
to the "raise to view"/display-on-movement toggle, sent as a separate 3-byte command
[0x01, 0x0E, enabled] (0x0E=14=SWITCH_TYPE_RAISE_WAKE) rather than the full
bitmask — i.e. individual switches can also be set one at a time via this narrower
form. See §6 for the exact wire form.
0x6E-framed opcode familyThe source also defines a separate, older set of COMMAND_CODE_6E_* constants,
used with a different, simpler frame: 6E 01 [cmd] [payload...] 8F (no action byte —
see Leaf.getSendData: when is6EProtocol is true, the frame is
[0x6E, 0x01, cmd,...content, 0x8F]). This looks like an earlier/simpler protocol
generation, largely superseded by the 0x6F family above, but some opcodes only exist
here (e.g. SEND_INCOME_CALL_NUMBER_NAME). Hex values below are script-verified.
✅ Confirmed dead code in the current app build: this whole
family is only ever built by MBluetooth6E.java (cn.appscomm.bluetooth.implement),
a full second implementation of PMBluetoothCall sitting alongside the MBluetooth.java
this project has always used — but every one of the ~10 SDK-layer call sites
(cn.appscomm.bluetoothsdk.a.*) hardcodes PMBluetoothCall c = MBluetooth.INSTANCE;
MBluetooth6E is never instantiated or referenced anywhere else in the decompiled
sources. Its ~29 command classes (cn.appscomm.bluetooth.c.*) cross-confirm the table
below byte-for-byte from an independent path (each class's own Leaf constructor call,
not just the constant names), and its reminder-editing logic is architecturally older
too — a genuine add-opcode/delete-opcode pair emulating "edit" as delete-then-add,
superseded by this project's own single-opcode 0x97 edit-flag design. Given
this, this family isn't a live alternate you'd ever need to fall back to on this
hardware/app combination — kept here for completeness only.
| Hex | Dec | Name |
|---|---|---|
0x00 |
0 | DEVICE_BATTERY_POWER |
0x02 |
2 | DEVICE_DEVICE_TYPE |
0x03 |
3 | PHONE_DEVICE_VERSION |
0x04 |
4 | DEVICE_WATCH_ID / PHONE_WATCH_ID |
0x05 |
5 | DEVICE_GET_SPORT_DATA |
0x06 |
6 | PHONE_GET_SPORT_DATA |
0x08 |
8 | DEVICE_FIRMWARE_INFO |
0x09 |
9 | DEVICE_SOFTWARE_INFO / PHONE_DELETE_ONE_REMIND |
0x0B |
11 | DEVICE_GET_USER_INFO |
0x0C |
12 | PHONE_SET_USER_INFO |
0x0D |
13 | PHONE_SET_STEP_GOAL |
0x0F |
15 | PHONE_BATTERY_POWER |
0x11 |
17 | DEVICE_GET_REMIND / PHONE_RESTORE_FACTORY |
0x12 |
18 | DEVICE_GET_SPORT_COUNT / PHONE_INIT_USER_INFO |
0x13 |
19 | DEVICE_GET_SLEEP_DATA / PHONE_INIT_SETTING |
0x14 |
20 | PHONE_GET_USER_INFO |
0x15 |
21 | DEVICE_GET_SLEEP_COUNT / PHONE_DATETIME |
0x1C |
28 | PHONE_UPGRADE_MODE |
0x1D |
29 | DEVICE_INACTIVITY_ALERT |
0x20 |
32 | PHONE_GET_REMIND |
0x21 |
33 | PHONE_DELETE_ALL_REMIND |
0x30 |
48 | PHONE_GET_SPORT_SLEEP_COUNT |
0x31 |
49 | PHONE_GET_SLEEP_DATA |
0x32 |
50 | PHONE_DELETE_SPORT_SLEEP_DATA |
0x34 |
52 | PHONE_TIME_UNIT_DATE_BATTERY |
0x36 |
54 | PHONE_SET_AUTO_SLEEP |
0x37 |
55 | PHONE_SCREEN_BRIGHTNESS_SETTING |
0x40 |
64 | PHONE_ADD_REMIND |
0x41 |
65 | PHONE_CHANGE_REMIND |
0x43 |
67 | PHONE_INACTIVITY_ALERT |
0x57 |
87 | DEVICE_RAISE_WAKE |
0x58 |
88 | DEVICE_SCREEN_BRIGHTNESS_SETTING |
0xA2 |
-94 | PHONE_SET_CALORIES_DISTANCE_GOAL |
0xB2 |
-78 | PHONE_SEND_INCOME_CALL_NUMBER_NAME |
0xB3 |
-77 | PHONE_MSG_COUNT_PUSH |
0xB4 |
-76 | PHONE_SWITCH_SETTING |
Byte offsets below are into the full frame ([0]=preamble 0x6F, [1]=cmd,
[2]=action, [3..4]=len LE, [5..]=payload) unless stated otherwise. All multi-byte
integers are little-endian unless noted.
0x02): request 6F 02 70 01 00 00 8F; response payload = 12
ASCII bytes (the serial itself).0x03): request payload is [type] where type=5→firmware,
type=2→hardware (two separate requests needed). Response: [5 or other] then
ASCII version string. The firmware string is later parsed for a release tag at
substring(8,12) (e.g. "R1.7") and a build tag in its last 4 characters (e.g.
"B4.1") — used to pick old vs. new weather-icon codes (see Weather below). Needs
≥24 characters or this check is skipped.
✅ Full letter-segment decode, from the real official app's own OTA
version-comparison code (ZeFotaProtocol), live-confirmed
format on this project's own watch: N2.0A2.0R3.0T3.3H0.5B5.5. Corrects an
earlier guess (previously R="radio/BLE stack" and H="hardware revision",
neither confirmed): A=Apollo main-MCU version, R=Picture/watchface asset
version, T=TouchPanel version, H=heart-rate chip's own firmware
version, B=build number. N is parsed as a main-chip version only on the
older, Nordic-based "zefit4" product line's own code path — ZeTime's own
version string also carries an N segment, but ZeTime's own parsing code never
reads or acts on it; whether it's vestigial or means something real for this
product specifically is unresolved.0x52): response = [stepsCount:2][sleepCount:2] at
minimum, optionally followed by [hrCount:2][moodCount:2][bpCount:2]. Drives a
natural fetch order: steps → heart rate → sleep.0x04), 12-byte SET payload:
year:2, month:1(1-12), day:1, hour:1, minute:1, second:1, 0x00(is24h,unused),
0x00("set-time-after-calibration"), 0x01(unit,fixed), tz_hour(incl. DST):1,
0x00(tz_minute,fixed). ✅ Live-confirmed for an arbitrary date, not just
"now" — zetime set-time --datetime 2026-12-25T09:00:00 correctly updated
the watch's own displayed date, not only the time-of-day.0x05, COMMAND_CODE_TIME_SURFACE_SETTING) — one combined
multi-field settings command; individual setters send the whole 8-byte structure
with 0xFF in every field except the one being changed (0xFF = "leave this field
alone"):[0xFF, type(1=24h/2=am-pm), 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF][type, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]dateFormat, timeFormat, batteryFormat,
lunarFormat, screenFormat (+ optionally backgroundStyle, sportDataFormat,
usernameFormat for 8 bytes total).0x06): payload is a raw array of
INTERFACE_DISPLAY_* constants (1=time, 2=step, 3=distance, 4=calorie, 5=sleep,
6=city-card, 7=bank-card) in the order you want them displayed — array index
becomes that widget's position.0x25): [seconds:2] LE16, clamped to [0x000A, 0xFFFF] by
Gadgetbridge (i.e. minimum 10 seconds).0x15): [enabled:1][startH:1][startM:1][endH:1][endM:1], 5
bytes both directions. ✅ Live-confirmed — zetime dnd get/set works both ways.0x18): [sign:1(0=neg,1=pos)][hour:1][min:1][cityCountry_utf8:N].
Drives a second "home city" line on watch faces that show two times.
⚠️ Live-confirmed interaction with 0x04 (time sync): the primary
clock is just whatever raw date/time was last pushed via 0x04,
unmodified; the second-timezone line is computed on the watch as
primary_time + (this opcode's stored offset - the tz_offset sent with
the last 0x04 call). The two only agree when those two offsets
happen to match -- pushing an arbitrary date via set-time --datetime
whose DST season differs from today's (so its auto-detected offset
differs from whatever 0x18 was last configured with) makes the
second-timezone line visibly diverge from the primary time by exactly
that difference. Not a bug in either opcode0x0B): [index:1], table in §7.0x0A): [eventType:1][mode:1] to set one
event's mode; bulk-get response is an array indexed by event type in this fixed
order: 0=anti-lost, 1=clock, 2=call, 3=missed-call, 4=SMS, 5=social,
6=email, 7=calendar, 8=sedentary, 9=low-power.0x10): [strength:1].0x5E): SET payload 8 bytes:
repeat_bitmask (bit7=enabled, bits0-6=Mon..Sun), threshold_minutes, 0x00, 0x00,
start_hh, start_mm, end_hh, end_mm. Disabling: same shape with bit7 clear and
padding zeros in place of the schedule fields.0x21): [mode:1][scaleFlag:1]. zetime analog-mode
get/set, GET live-confirmed, SET implemented and
unit-tested but not live-verified against physical hands (no dual-display
ZeTime available to check against).0x1A): [0x09]=forbid/off, [0x0A]=allow/on (note
the response-side inversion documented under the opcode table above).0x90, narrow form): [0x01, 0x0E,
enabled(0/1)].0x60): [type:1]. ✅ Live-confirmed — zetime
settings get/set calories-type, 0x30): [sex(0=M,1=F,2=other):1][age:1][height_cm:1][weight_x10:2]
(weight in units of 100g, LE16), both directions, 5 bytes.0x31): [0=left,1=right].0x32): fixed 16-byte UTF-8 buffer. ⚠️ The official app's own SET
constructor for this has an array-bounds bug (allocates a 1-byte array then
arraycopys 16 bytes into it — would throw at runtime). Don't replicate it; just
send a proper zero-padded 16-byte UTF-8 buffer.0x11): [mainR,mainG,mainB,alarmR,alarmG,alarmB],
6 bytes both directions.Set (0x50), 4 bytes: [type:1][value:2][flag:1] — one call per goal type (§4
table has the per-type unit conversions). Get is a single bulk response: N × 3
bytes in fixed order (step, calorie, distance, sleep, sport-time), each
[value:2][flag:1].
All bulk-fetch responses share a paging convention: the first record clears/creates the local list; later records append; if the list reaches the expected count, return success; if a record's own index matches the expected count but the list is short, data was lost — clear and signal "resend"; otherwise signal "more coming."
0x54), request len=2,[0x00,0x00]. Record layout (18
bytes minimum, up to 27):
index:2, timestamp:4 (+28800s correction, see below), steps:4, calories:4,
distance:4 [+ sportTime:4 if payload>18] [+ avgHeartRate:1 if payload>22]
[+ type:1 if payload>23 — if type>0, the record is also pushed into the real-time
sport list].0x56), request len=2,[0x00,0x00], 7-byte records:
index:2, timestamp:4 (+28800s), type:1. Gadgetbridge's actual decoding only
distinguishes 0=deep, 1=light, anything else=unknown — the richer
begin(0x10)/end(0x11)/awake(0x02) marker scheme from the public
gadgetbridge.org write-up is not implemented/acted on by the real client code,
so treat that richer scheme as unconfirmed.0x5B/0x61), request len=1,[0x00]. Payload length 7 =
one record: index:2, timestamp:4 (+28800s), bpm:1. Payload length 14 = two
records packed in one message (not documented anywhere else): record 1 at offset
5-11 as above, record 2 at offset 12-18 with the same shape.0x5F): index:2, timestamp:4, fatigue:2, emotional:2 (10 bytes).0x67): 22+ bytes:
index:2, start:4, steps:4, calories:4, distance:4, sportMinutes:4 [+ avgHR:4,
end:4 if payload>23, else HR is read as a single byte at offset 22 and
end = start + sportMinutes*60].0x6B): 16+ bytes: gpsId:1, index:2, timestamp:4, dirFlags:1
(bit0/1 = lat/lon sign), lat_raw:4, lon_raw:4 [+ distance:4 if payload>16] [+
speed:2 if payload>20]. Lat/lon = raw_int32 ÷ 1,000,000.+28800 seconds (8 hours) added before correcting for the phone's own
timezone/DST offset. This is a real, unexplained firmware behavior (Gadgetbridge's
own code just applies the constant without comment on why), not a bug in any
client — expect it and correct for it.0x5C): [minutes:1] (0 = off).0x5D): [max:1][min:1][enabled:1].0x51), read-only: [0=sport,1=sleep].0x58): [enterHour][enterMin][exitHour][exitMin][remindCycle].0x57), get-only: bulk 4 bytes × N,
one per widget type (step, calorie, distance, sleep, sport-time, heart-rate, mood).0x53,0x55,0x5A,0x63,0x68) are all ⚠️ destructive
single-byte fire-and-forget commands with no response parsing.0x76, SOCIAL_EX_PUSH) — ✅ replyCapable is
live-confirmed (: notify social whatsapp produces a
real quick-reply UI on the watch with it set, notify social facebook
correctly doesn't); subType/extra remain untested (no observed effect
either way, but no specific behavior was checked for):
type:1, subType:1, subjectLen:1, bodyLen:1, subject_utf8, body_utf8,
datetime[15], extra:1, replyCapable:1 — the datetime is ASCII digits,
format YYYYMMDDTHHMMSS (each character its own byte, literal 'T'
separator) — 15 bytes total, not binary. Traced all the way to the real
frame-serialization class (SocialExPush.java, cn.appscomm.bluetooth.b.b.f),
not guessed:subType — 0x00 in every real call site found except missed-call
notifications, where it carries the missed-call count instead (not
modeled — this library has no missed-call counter). Previously
documented (wrongly) as a fixed 0x01 "count" byte — that was this
library's own guess, empirically tolerated live but not what the real
app actually sends; encode_rich_push's default corrected to 0x00.extra (2nd-to-last byte) — a coarse secondary category byte. 0x02
for SMS/calls/schedule (and encode_rich_push's default); for the
general notification path the real app derives it from a
convertToAppscommType remap of the source app (facebook→12,
snapchat→9, gmail→14, calendar→4, whatsapp→10, else→2/generic-social) —
a different, coarser classification this library has no equivalent of,
so its default of 0x02 is a best-effort guess for that path.replyCapable (final byte) — the flag this project had been looking
for since ** ("notify social produces no visible reply option"),
✅ live-confirmed real in : notify social whatsapp
(replyCapable=true) produces an actual quick-reply UI on the watch
that notify social facebook (replyCapable=false) doesn't. Real app
call sites set it true only for SMS (always) and for the generic
notify path when the source app is WhatsApp or Messenger; false for
incoming/missed/ended calls and every other app. ZeTimeClient sets it
accordingly (_REPLY_CAPABLE_NOTIFICATION_TYPES in client.py). Note:
SMS showing a reply option isn't itself evidence this byte matters for
SMS specifically — found SMS already had one before this byte was
ever sent, consistent with the firmware keying SMS's reply UI off
type=0x01 alone; WhatsApp is the case that actually isolates this
byte's effect. The app-name check is a single hardcoded line in the
real app (messageTypeByAppName == 15 || messageTypeByAppName == 10,
i.e. Messenger or WhatsApp specifically) cross-referenced against its
~30-entry package-name table — exhaustive, not a guess**: every other
app in that table (Facebook itself, Instagram, Twitter, LinkedIn,
Snapchat, Skype, Telegram, WeChat, QQ, Viber, Line, KakaoTalk,
Pinterest, Flipboard, every email/calendar app) gets false. Only
WhatsApp and Facebook were live-tested; Messenger specifically wasn't,
but its code path is identical to WhatsApp's.0x76): type=0x05, subType=0, subjectLen, bodyLen=0,
caller_name_or_number_utf8, datetime[15], extra=0x02, replyCapable=false.0x76): type=0x06, subType=0, 0x00, 0x00, datetime[15],
extra=0x02, replyCapable=false (no subject/body at all).0x70 name-push, 0x71 SMS, 0x73 social, 0x74 email, 0x75
schedule): [type:1][content_bytes:N].0x72): [msgType:1][msgCount:1].0xD5, COMMAND_CODE_PHONE_CONTACT):
attribute:1, ground:1, numberLen:1, nameLen:1, number_bytes, name_bytes.0x77)Total length = 26 + location_name_utf8_length:
| Offset | Field |
|---|---|
| 5 | 0x00 (unit = Celsius, fixed) |
| 6 | current temp °C = kelvin − 273 (integer truncation, not −273.15) |
| 7 | today min °C |
| 8 | today max °C |
| 9 | current condition icon (see icon table below; firmware-version-gated — uses the newer icon mapping if the device's firmware release is ≥ R1.7 build 4.1, else an older mapping) |
| 10-14 | forecast day 1: unit(0x00), 0xFF(no "current" for a forecast day), min°C, max°C, icon |
| 15-19 | forecast day 2, same layout |
| 20-24 | forecast day 3, same layout |
| 25… | location name, UTF-8, length implicit from total message length |
Only 3 forecast days are sent (Gadgetbridge's loop bound is explicit < 3) — an
earlier draft of this document said 4, which was wrong.
Full weather icon enum (0-24, from the decompiled WeatherBT model class — a
genuinely new addition, not covered by gadgetbridge.org's page, which only lists 3):
| Value | Condition |
|---|---|
| 0 | Tornado |
| 1 | Typhoon |
| 2 | Hurricane |
| 3 | Thunderstorm |
| 4 | Rain + snow |
| 5 | Unavailable |
| 6 | Freezing rain |
| 7 | Drizzle |
| 8 | Showers |
| 9 | Snow flurries |
| 10 | Blowing snow |
| 11 | Snow |
| 12 | Sleet |
| 13 | Foggy |
| 14 | Windy |
| 15 | Cloudy |
| 16 | Partly cloudy (night) |
| 17 | Partly cloudy (day) |
| 18 | Clear (night) |
| 19 | Sunny |
| 20 | Thundershowers |
| 21 | Hot |
| 22 | Scattered thunderstorms |
| 23 | Snow showers |
| 24 | Heavy snow |
0x99), SET total length = 16 + title_utf8_length:
opcode:1 (1=first event in a batch, 2=subsequent — official app comment admits
uncertainty about other values: "0=delete-all-except-new?, 4=delete-all?,
2=add-event?, 1=first?, 3=last?" — not fully reverse-engineered even by
Gadgetbridge's own authors), year:2 (binary LE16 — note, NOT ASCII, unlike the
notification datetime format), month:1, day:1, hour:1, minute:1, 0x00, 0x00
(reserved), titleLen:1, title_utf8. Title = event title, with ": " + description
appended if present.0x98): raw passthrough array — likely a day-of-month bitmask, not
independently decoded by any source yet.[crc:2] → a CRC of the calendar
contents, presumably for change-detection.⚠️ The subsections below describing 5 opcode "generations" and an
"opaque handle" were reverse-engineered from the official app's
decompiled source only, and turned out to be misleading in two ways once
cross-checked against Gadgetbridge's actual, working ZeTimeDeviceSupport
.java/ZeTimeConstants.java source directly:
0x97
(REMIND_SETTING_EX_DATE_SHOCK), used for create, edit, and read.
0x9B is never used by Gadgetbridge at all (the official app's own SET
constructor for its richest class has a bug and targets 0x97 instead of
0x9B — Gadgetbridge simply targets 0x97 deliberately, matching the
official app's actual on-wire behavior rather than its buggy source).type, year:2, month, day, hour, minute,
cycle, enabled, shock) — Gadgetbridge caches its own just-sent create
request (or a real device read) locally and echoes those same bytes back
as an identifying prefix on edit. Fully recomputable by a client; no
response-capture "round-trip only" step is actually required. See the
confirmed wire format below.The 5-opcode table and type table immediately below are kept for reference (they may still be accurate for the official app's own behavior, and the 0-9 type table may be real for it), but this project's client uses the confirmed-working Gadgetbridge mechanism, not this table.
Five official-app opcodes represent successive supersets of the same feature
(0x92 base → 0x9B final), all using the same paging convention as sport/health
data (return 0=success / 3=more-coming / 5=resend).
| Opcode | Class | Payload (SET), beyond the base fields |
|---|---|---|
0x92 REMIND_SETTING (base) |
— | index:2, type:1, hour:1, min:1, cycle:1, enabled:1 (+ content:N only if type==7, custom) |
0x96 REMIND_SETTING_EX_SHOCK |
adds | + shock:1 after enabled |
0x95 REMIND_SETTING_EX_DATE |
adds | + year, month, day instead of shock — ⚠️ year is decoded via bitwise-AND rather than a proper LE16 combine in the official app; treat as an unverified/buggy field |
0x97 REMIND_SETTING_EX_DATE_SHOCK |
adds | date + shock combined — same year-decode caveat |
0x9B REMIND_SETTING_EX_DATE_SHOCK_REPEAT (final/richest) |
adds | + repeatType, repeatValue. ⚠️ The official app's own SET constructor for this class has a bug and actually targets opcode 0x97 instead of 0x9B. |
0x91 REMIND_COUNT |
get-only | [count:1] |
The 0-9 "Reminder/alarm types" table in §4 was official-app-only and
Gadgetbridge only ever uses REMINDER_ALARM = 0x04 -- but live-tested
and confirmed working for other values sent via this opcode (0x97),
not just for the official app: type=1 ("Medicine"),
type=2 ("Drink water"), and type=7 ("Custom", with free-text content --
see below) all render as distinct Reminders (separate from the Alarms
list), matching the manual's separate "Reminders" feature. Reminders and
Alarms genuinely ARE the same underlying mechanism -- initial live testing
looked like they might not be (a type=2 create seemed invisible to
read-back), but that turned out to be a bug in this project's own
get_alarms, not a real second storage table -- see below.
Opcode 0x97 (REMIND_SETTING_EX_DATE_SHOCK) for everything -- Alarms
and Reminders alike. Not actually capped at 3 slots -- Gadgetbridge's
own byte[3][10] array size is a self-imposed client limitation, not a
real watch-firmware cap: live testing filled a 4th slot successfully, and the manual separately claims 10-reminder capacity.
REMIND_COUNT (0x91, get-only, [count:1]) -- previously listed as
official-app-only and never sent by any source -- was live-confirmed to
correctly report the true total slot count (including reminders), and
this project's get_alarms now queries it upfront instead of assuming
a hardcoded limit. year/month/day are always sent as 0 by
Gadgetbridge for its plain recurring "Alarm" type; non-zero values
(dated/one-time reminders) are
unconfirmed.
index=0x00, type=0x04, year:2 LE(=0), month(=0), day(=0), hour, minute,
cycle, enabled, shock. index is always 0x00 -- the real slot
assignment happens watch-side (a source comment shows Gadgetbridge once
computed a real position here and commented it out).edit_flag=0x01, <previous alarm's 10 field bytes verbatim: type, year:2,
month, day, hour, minute, cycle, enabled, shock>, <new 10 field bytes,
same layout>. The prefix identifies which stored alarm to overwrite; the
tail is the new desired values.[0x00] (standard singleton
pattern); each existing alarm/reminder streams back as an 11-byte
response [slot_number(1-indexed), type, year:2, month, day, hour,
minute, cycle, enabled, shock]. No explicit end-of-stream signal, and
slots beyond the first few can respond noticeably slower than the
rest (live testing needed a ~15s idle window, not the original ~6s
default, to reliably catch a 4th slot) -- a client should either use a
generous idle timeout or, better, query REMIND_COUNT (0x91) first
and stop once that many responses have arrived. ✅ Live-confirmed —shock defaults to 11 in Gadgetbridge (PREF_ALARM_SIGNALING's
default) -- likely an index into the vibration/shock-mode table in §4,
not independently re-confirmed here.type==7 only: appending UTF-8 text after the standard 11-byte create payload
(or 21-byte edit payload) works and renders as the reminder's displayed
text on the watch, for type=7 ("Custom"). This mirrors the content:N
field documented only for the base opcode 0x92 in the official app's
table above (conditionally, "only if type==7") -- turns out to apply to
0x97 too, not just 0x92. Confirmed NOT to apply to preset types:
a type=1 ("Medicine") reminder sent with content attached showed only
the generic localized preset label ("Medicatie", Dutch for Medicine),
not the custom text -- content is silently ignored outside type=7.alarm_type. An
earlier round of live testing found the all-zero edit
was REJECTED (status 1) for any non-type=4 entry and fell back to
disable-in-place, which only hides an entry rather than freeing its
slot -- both now superseded.cycle's bit layout (which days repeat): ✅ partially confirmed live
— 127 (0x7F, all 7 low bits set) means "repeat every day" (the
watch showed all seven day-letters highlighted). The individual
bit-to-weekday mapping (does bit 0 mean Monday or Sunday?) is still
unconfirmed —type=4 only, or disable-in-place for
everything). No dedicated CMD_DELETE_*-style opcode was ever found in
either source for alarms (unlike step/sleep/heart-rate data, which do
have one in Gadgetbridge) -- the real mechanism instead exploits the
Edit frame's leading byte and the watch's own duplicate-content
handling:edit_flag here,
always 0x01 in the confirmed-working Gadgetbridge
path) is NOT a fixed constant the watch requires -- but it isn't a
target slot number either (an earlier theory traced a genuine
full-table wipe on this test watch back to that assumption). With
edit_flag=0x01, the watch REJECTS (status 1) any edit whose "new"
10-byte block would duplicate another already-stored entry's fields
-- a real safety check.edit_flag value bypasses that safety check. If the "new"
block doesn't collide with anything, this is a silent no-op (status
0, confirmed across several live attempts, nothing actually
changes). But if the "new" block collides with an existing entry's
fields, the watch deletes the targeted ("previous"/existing-match)
entry instead of erroring or creating a duplicate -- with proper
compaction (REMIND_COUNT drops by exactly one, no other entries
touched).edit_flag != 0x01 (e.g. 0x02). Confirmed live, repeatably,
for both type=4 Alarms and non-4-type Reminders alike -- no type
branching needed. This is protocol.encode_alarm_delete /
ZeTimeClient.delete_alarm as of 0xD0) — watch-initiatedThe watch, not the phone, initiates this exchange.
CMD_SEND/0x71): payload
[control:1] where 0=play, 1=pause, 2=previous, 3=next, 4=volume-change
(followed by [requestedVolume:1]). A real client should clamp volume to a
reasonable range in steps (Gadgetbridge uses [10,90] step 10) and reply.CMD_SEND): the watch wants current
song/playback state. Reply via characteristic 0x8003 (not 0x8001/0x8002),
action byte forced to CMD_REQUEST_RESPOND/0x80:[state:1(0=playing,1=paused)][title_utf8:N].[0x02][currentVolume:1].0xD1) and quick-reply (0xDD/0xDE/0xE6) — watch-initiatedLive-tested on real hardware. Payload shapes remain undocumented for all three, but a reply is genuinely expected:
0xD1): single frame, action=CMD_SEND/0x71, one-byte
payload observed as 0x00. The watch shows a 3→1 countdown after this
fires; if nothing is written back before it expires, it shows
"connection failed."0xDD): [reply content: 12 bytes, 0xFF-padded]
[original notification text: UTF-8]. The 12-byte content field is
either the reply's raw UTF-8 bytes left-aligned (an emoji reply:
f0 9f 98 8d = 😍, followed by 0xFF padding), or, for a canned-text
reply, a 2-byte marker+index pair (0x0D + a template index, seen as
both 0x02 and 0x03) at a fixed offset within the same 12 bytes,
0xFF elsewhere. One index confirmed by direct user report: 0x03 =
"Dat is oké!" on a watch set to Dutch -- canned-reply text is almost
certainly watch-UI-language-dependent, so this mapping is not assumed
to generalize across languages. The original notification's
text/caller-name follows, echoed back verbatim (not the reply text).
Confirmed to fire identically from an
incoming-call notification's "text back" action, not just SMS
notifications -- this opcode is a shared quick-text mechanism, not
SMS-specific. If nothing is written back, the watch's UI shows "wordt
verzonden" (sending) then "mislukt" (failed).0xDE/0xE6: never observed firing live (no 0xDE capture; 0xE6
wasn't reachable at all -- the one social notification type tried,
WhatsApp, offered no quick-reply UI on this firmware, only
swipe-to-dismiss). ⚠️ Doesn't necessarily mean unsupported — see below.✅ The newer app's SDK layer decodes both events into a common shape
(com.mykronoz.zetime.ZeSmsReplyProtocol/
ZeSocialReplyProtocol, internal SDK event codes 617/619):
{content, mode, presetIndex, phoneNumber} (social adds a timestampCrc).
If presetIndex != 0xFFFF, the phone matches it against
CustomizeReply.crc (§6 "Preset quick-reply text") to find which stored
preset the user picked on-device — the same CRC-centric identification this
project found live for that feature's EDIT operation. If
presetIndex == 0xFFFF, the phone instead falls back to one of 6
hardcoded, non-customizable canned replies baked into the phone app's own
string resources (R.string.reply1..reply6, chosen by the mode byte) —
a second tier entirely separate from CustomizeReply presets. ✅ Live-tested
and confirmed, : yes, this is the same mechanism the
watch's own on-screen quick-reply menu draws from — a zetime quick-reply
add preset appeared as a real, selectable option in the watch's own reply
menu (at its assigned index) immediately after being added, no separate
sync step needed. The small single-byte template index (0x02/0x03)
captured raw on the wire for 0xDD above is a different thing: that's the
watch echoing back which canned reply the user picked (an index into
either tier, resolved phone-side against CustomizeReply.crc or the
hardcoded 6), not a separate storage mechanism.
✅ A real social-reply mechanism exists in the newer app
(com.mykronoz.socialdirectreply.DirectReplyManager) —
listens for genuine Android notifications from 5 whitelisted apps
(Telegram, Viber, WhatsApp, Line, Messenger) and replies through each one's
own native Android notification-reply action, not via any MyKronoz server or
a different BLE opcode. This doesn't simply overturn the "unsupported"
finding above. Resolved, with a real answer, in : the
real app doesn't use raw SOCIAL_PUSH (0x73) for this at all — like this
library, it routes everything through SOCIAL_EX_PUSH (0x76) — but its
payload carries 2 trailing bytes (extra, replyCapable) this library's
encode_rich_push didn't use to send. replyCapable=true only for
WhatsApp/Messenger sources (and always for SMS); encode_rich_push now
sends both bytes accordingly. ✅ Live-tested in : this
is the actual explanation for the missing reply option, not a firmware
limitation — WhatsApp notifications now show a real reply UI they didn't
before, Facebook (not in the reply-capable set) correctly still doesn't.
Reply mechanism, live-confirmed for 0xD1 and 0xDD:
the same generic [echoed_cmd, status] shape the watch uses to ack a
phone-initiated SET (see §3) also works in reverse, sent by the phone over
REPLY_CHAR_UUID/0x8003 (encode_generic_ack / ZeTimeClient.
reply_generic_ack). Sending this immediately after either event resolves
the watch's failure state into a success, confirmed live for both opcodes.
0xDC) — watch-initiatedOnly one payload shape is understood/handled: an exact 7-byte message
[.., CMD_SEND, 0x01, 0x00, 0x01,..] means "user rejected the call from the watch."
No other call-control payload is documented by any source yet.
Live-confirmed: directly declining a real incoming-call
notification produces this exact 0xDC/[0x01] shape -- an initial live
attempt appeared to produce nothing, but a repeat confirmed it fires
reliably (arrived 3x in a short burst on the confirming attempt; a
subsequent no-op hangup press on the already-dismissed notification
correctly produced nothing further).
0xB0): SET [cardAID:1][cardNumber_utf8:≤90 bytes]; GET response
is the ASCII card number string.0xB1), query-only: 6-byte response, a 48-bit value ÷ 100.0xB2/0xB3): 13-byte records,
year(+2000):1, month:1, day:1, hour:1, min:1, sec:1, amount:6(BCD), txnType:1
(6=debit, 2=credit). Amount is BCD-encoded — each nibble is one decimal
digit, not a raw hex value — combined as a 12-digit fixed-point value ÷ 100.
Zero-amount records are dropped by the client. The two opcodes' header layouts
differ by 1 byte offset from each other.FlashTest(0xF7)/LCDTest(0xFE)/ShockTest(0xFA)/TouchTest(0xFB): all
[content:1], fire-and-forget. SensorTest(0xFC): get-response [x:2][y:2][z:2]
raw accelerometer/gyro. WriteWatchID(0xFF): ⚠️ writes the device's own
serial/identity — never use this outside genuine factory provisioning.
0xB6, WATCH_MOVE_ONE):
[hourMinFlag:1][direction:1][angle:2], fire-and-forget.0xB8, WATCH_MOVE_KEEP):
[isMinute:1][direction:1][mode:1][colorR:1][colorG:1][colorB:1][brightness:1]
(the last 4 bytes only present/populated when mode=4 and the caller uses
the color/brightness variant below). ✅ mode byte fully decoded
(traced through ExtendManager/cn.appscomm.bluetoothsdk.a.e
and its real caller, ZeAdjustTimeProtocol, the app's manual hand-calibration
UI): 0=stop, 1=start continuous rotation (direction from byte [1]),
2=unlock (enter calibration mode — hands become freely
phone-controllable), 3=lock (exit calibration mode, resume normal
time-tracking), 4=unlock with camera-calibration/color-brightness (see
below). The real app's calibration flow is: unlock (mode=2) → manually
rotate hands via 0xB6/this opcode's mode=1 start/mode=0 stop → push
the correct time (0x04) → lock (mode=3). Also carries what looks like
a ciphertext challenge/response (contents[0]==0 triggers extracting a
ciphertextArray from the response) — suggestive of some security
handshake for this operation, still not further decoded. Confirmed a same-opcode variant (watchMoveKeepWithColorBrightness) that
reuses the identical Leaf class with mode=4 and 4 extra payload bytes
populated (all 0 in the plain variant) — almost certainly the "LED
color" (RGB) + brightness part of the table's description, still not
decoded byte-by-byte beyond their positions.ZeAutoAdjustTimeProtocol/ZeAutoAdjustTimeActivity) — point the phone's camera at the watch face; a
third-party DynamicTimeSDK visually recognizes the physical hands'
current position from the camera feed, then presumably issues the
0xB6/0xB8 corrections above automatically. Genuinely undocumented
before this pass. Out of scope for this project's tooling (no camera/CV
capability) — noted for completeness, not pursued further.0xEF): [minCur:2][hourCur:2][minOffset:2][hourOffset:2], 8
bytes both directions — hand-position calibration/readback.0xBA): [hourAngle:2][minAngle:2][hour:1][min:1][sec:1].0x01, action 0x81): [echoedCmd:1]
[status:1] — this is also what the phone must send to ack a command the watch
itself issued, confirming the protocol's bidirectionality at the lowest level.0xE3): raw passthrough; response is stored verbatim,
not interpreted.0xE4) / OTA photo attribute (0xE5): likely a "caller-ID photo"
feature (attach a photo to a contact, shown on incoming calls) rather than the
watchface-image OTA path — attribute payload is
year:2, month:1, day:1, nameLen:1, idLen:1, name_utf8, id_utf8.0xDB, COMMAND_CODE_CUSTOMIZE_REPLY) — ✅
implemented and live-validated (zetime quick-reply list/add/edit/delete). Originally reverse-engineered from a clean, unobfuscated
decompiled class (cn.appscomm.bluetooth.protocol.Extend.CustomizeReply), not
previously connected to this opcode (this section's table previously guessed
"watchface add/change/delete ack", which was wrong). This is the list of canned
phrases (up to 10, both per the app's own local-cache loop bound and
live-confirmed as the real limit) offered to the user when quick-replying to an
SMS/social notification from the watch (§ above).GET (action=CHECK) — [type:1] + optionally [crc:2]×N (little-endian,
one pair per already-cached preset, only when type=1):
- type=0: fetch the full list, no CRC filter. ✅ Live-confirmed, including the
zero-presets case ((no presets), no stream even attempted).
- type=1: fetch only entries whose CRC differs from the given cached list (a
delta-sync optimization, same spirit as the watchface CRC dedup in §8.2). Not
implemented in this library (which has no local cache to diff against) or
live-tested.
- A real prerequisite exists and is required in practice: 0xDA
(COMMAND_CODE_CUSTOMIZE_COUNT_CRC) must be called first to learn the count
(and, for the type=1 path, the device's current CRC list) — action=CHECK,
payload=[0x00] (the standard singleton pattern), reply
[count:1][crc:2]×count in one frame (not streamed, unlike 0xDB's own GET).
✅ Live-confirmed.
Reply is streamed, one preset per frame (multiple notifications for one
request): [index:1][crc:2, little-endian][content: UTF-16 text, no length
prefix — runs to the end of the frame]. index == 1 on a reply means "this is
the start of a fresh list" (client should clear any previously cached entries
before appending). The app tracks how many entries it still expects (crcCount,
supplied in the request) and knows the list is complete once that many have
arrived. ✅ Live-confirmed for 1 and 2 stored presets.
SET (action=SET) — [operation:1][index:1][crc:2, little-endian][content:
UTF-16 text]. ⚠️ index/crc mean different things per operation — this
was live-confirmed the hard way, see below:
- operation=0 ADD: index = current_count + 1, crc = CRC of content
(the new text). ✅ Live-confirmed on the first attempt.
- operation=1 EDIT: index is always 0 — the watch identifies the
target entry by CRC instead of index. crc must be the CRC of the old
content being replaced, not the new text in content. ⚠️ Live-tested
and initially got this wrong: sending the entry's real index and the new
content's CRC (the seemingly obvious choice, matching ADD/DELETE's shape) was
rejected by the watch every time with status=1 (FAIL); switching to
index=0 + the old CRC fixed it immediately, confirmed by reading the
change back.
- operation=2 DELETE: index = the entry's real 1-based slot, crc =
CRC of content (here, the current content being deleted). ✅ Live-confirmed
— genuinely frees the slot, not just hides it (unlike alarms' original
disable-in-place limitation, see).
ℹ️ A second, independent reference implementation exists and uses
different field values — the newer app's com.mykronoz.zetime.
ZeDefaultSmsProtocol does the exact same CRUD with:
EDIT sending the entry's real index (not 0) plus the old CRC — a
third form, never live-tested by this project, alongside the two above
(index=0+old CRC, confirmed working; real index+new CRC, confirmed
rejected); ADD sending crc=0 rather than a computed CRC; DELETE
sending an empty string for content rather than the real content. None
of this contradicts this library's own live-confirmed values above — it
just shows the watch's real validation is looser than either single
reference implementation assumed on its own. Not worth changing this
library's working implementation over, just useful context.
Text encoding is UTF-16, not UTF-8 — a real, concrete difference from every
other free-text field already implemented in this library (notifications,
alarm/reminder content, city/location names, all .encode("utf-8")). ✅
Live-confirmed correct as plain utf-16-le: content round-tripped
byte-for-byte through write-then-read-back on real hardware. (The decompiled
encoder, ParseUtil.stringToUnicode, has more elaborate surrogate-pair/emoji
handling than a plain utf-16-le encode — untested whether it's needed for
actual emoji content specifically, only plain text was tried live.) CRC is this
project's standard CRC16 (§8.5), computed over the UTF-16LE content bytes,
confirmed via the app's own local-cache code
(CRCUtil.byteToCRC16(ParseUtil.stringToUnicode(content))) and independently
reconfirmed live (the watch's own stored CRC matched this library's computed
value across every add/edit).
- Customizable physical buttons (0x2E, COMMAND_CODE_CUSTOMIZE_BUTTON) —
✅ fully specified, traced from
com.mykronoz.zetime.ZeCustomWatchButtonsProtocol down through
cn.appscomm.bluetoothsdk.a.i/MBluetooth to the real Leaf class
(cn.appscomm.bluetooth.b.d.C0133h, jadx: compiled from: CustomizeButton.java).
Genuinely new — not previously documented anywhere in this project.
Assigns one of 20 real actions to each of 4 physical button positions.
GET (action=CHECK) — the standard singleton [0x00]. Reply is a
sequence of 3-byte records, [unused:1][buttonId:1][function:1], one per
configured button:
- buttonId: 1=UP, 2=BOTTOM/DOWN, 3=OTS_SHORT, 4=OTS_LONG (a
short vs. long press of the same third button — confirmed below,
not two different buttons). What "OTS" itself stands for is not
confirmed — no spelled-out expansion was found anywhere in either
app's source or the newer app's React Native JS bundle; "one-touch
shortcut" in an earlier revision of this doc was this project's own
unsourced guess, not something the code states.
✅ Confirmed to be a real third physical button, not a touchscreen
gesture — the newer app's setup screen (CustomWatchButtonWhichOne
in the RN bundle) renders three separate tap targets overlaid on a
watch case diagram image (selectButton1/2/3 for UP/DOWN/OTS),
and OTS's two states are labeled via i18n keys CWB_SHORT_PRESS/
CWB_LONG_PRESS ("Custom Watch Button" short/long press — press-
duration language for one control, not a description of two different
touch gestures). ℹ️ Mapped to real hardware (from the user's own
watch, not decompiled code): this model has two case buttons, one
above and one below the crown, plus the crown itself, which both
pushes (a third button) and rotates. UP/BOTTOM are almost
certainly the two case buttons; given the confirmed "one real button,
two press durations" structure above and no other physical button on
this watch, OTS_SHORT/OTS_LONG are most likely a short vs. long
press of the crown's push specifically — though this last step
(crown push, as opposed to some other button on a different ZeTime
hardware revision this doc hasn't seen) isn't independently confirmed
the way the "one button, two durations" structure is. The crown's
rotation is a separate physical input not covered by this opcode at
all — likely feeds the existing hand-calibration commands (0xB6/
0xB8, §6 "Extended / remote-control") or menu scrolling instead.
SET (action=SET) — a sequence of 3-byte records,
[0x02][buttonId][function], one per button being changed. Buttons the
caller doesn't want to touch are simply omitted from the payload
(variable length, not a fixed 4-slot structure with a "don't touch"
sentinel).
function is one of 20 named actions — the byte value is the literal
wire value, not an index:
| Value | Action | Value | Action |
|---|---|---|---|
| 0 | None | 10 | Notification list |
| 1 | Dim screen | 11 | SMS list |
| 2 | Heart rate | 12 | Stopwatch |
| 3 | Calendar | 13 | Missed calls |
| 4 | Music control | 14 | Activity data |
| 5 | Timer | 15 | Sleep data |
| 6 | Camera | 16 | Find my phone |
| 7 | Alarm | 17 | Do not disturb |
| 8 | Weather | 18 | Silent mode |
| 9 | Home timezone | 19 | Flight mode |
Untested live — this is pure static analysis from a decompiled APK.
Implemented in this library
(ZeTimeClient.get_button_assignments/set_button_assignments,
zetime button get/set), but not
live-tested — treat it as unconfirmed the same way as 0x23 find-device
until someone runs it against real hardware.
- Find device (0x23, COMMAND_CODE_FIND_DEVICE) — ✅ fully specified, traced from BluetoothSDK.startFindDevice/
endFindDevice through MBluetooth to the real Leaf class
(cn.appscomm.bluetooth.b.d.p, jadx: compiled from: FindDevice.java).
Genuinely new — fills a real, previously-unfilled gap in the opcode
table (between 0x22 and 0x25). The reverse of the already-implemented
find-phone feature: makes the watch locatable (almost certainly
vibrate/beep), triggered from the phone side.
SET (action=SET) — 1-byte payload: 6F 23 71 01 00 <b> 8F.
b=0x00 starts the find (startFindDevice), b=0x01 ends it
(endFindDevice). No GET form found.
⚠️ Notably, the newer official app itself never wires this up —
ZeAntiLostProtocol.findDevice is an empty,
unimplemented stub in the app's RN bridge, even though the underlying
SDK method and the watch-side opcode are both real and complete. This
library could implement a feature the official app itself never
shipped.
❌ Live-tested, no effect: implemented in this library and tested against real hardware (firmware/model at hand) both with and without waiting for an ack. The watch sent back no response frame at all within the normal ack window, and produced no observable vibrate/beep/screen change either. Raw BLE traffic was captured to rule out a decode-side bug: no false negative, genuinely nothing came back. Implemented exactly per the decompiled spec above (kept, since the static analysis is solid and other firmware/models may behave differently), but this opcode should be treated as unconfirmed / possibly unsupported on at least some real hardware until proven otherwise.
0x0B, COMMAND_CODE_LANGUAGE)Sourced from the decompiled official app -- ⚠️ live-tested and found to have real errors: indices 6/7 swapped, and 17/18 mismatched. ✅ Every index 0-18 is now live-confirmed except that 17's specific language remains unidentified beyond "renders in Hebrew script" (see below).
Note: the newer app's own ZeLanguageProtocol/SettingType only expose
15 locale codes (en/zh/zh-TW/zh-HK/mo/ko/th/ja/fr/es/de/it/pl/pt/ru/nl) --
no Swedish/Czech/Arabic/Greek/Hebrew string constants exist anywhere in
that app version at all. Indices 14-18 are real and do work live, but
aren't reachable through this particular app version's own UI (the
already-familiar "protocol supports more than this app build exposes"
pattern, e.g. 's 0xE9, 's dead 0x2A/0x2F) -- likely sourced
from an older app revision or a shared firmware-side string table instead.
| Index | Locale(s) |
|---|---|
| 0 | English (default/fallback) ✅ live-confirmed |
| 1 | Chinese ✅ live-confirmed (some CJK variant -- not distinguishable from index 2 by sight for a non-reader; both render as Chinese-looking script) |
| 2 | Chinese ✅ live-confirmed (see above -- indices 1/2 look identical to a non-Chinese-reading tester) |
| 3 | Korean ✅ live-confirmed (Hangul, visually distinct from Chinese) |
| 4 | Thai ✅ live-confirmed |
| 5 | Japanese ✅ live-confirmed |
| 6 | Spanish -- ✅ live-confirmed (earlier documentation had this swapped with 7, corrected) |
| 7 | French -- ✅ live-confirmed (earlier documentation had this swapped with 6, corrected) |
| 8 | German ✅ live-confirmed |
| 9 | Italian ✅ live-confirmed |
| 10 | Polish ✅ live-confirmed |
| 11 | Portuguese ✅ live-confirmed |
| 12 | Russian ✅ live-confirmed (Cyrillic, described by the live tester as "Russian, at least Slavic") |
| 13 | Dutch ✅ live-confirmed |
| 14 | Arabic ✅ live-confirmed |
| 15 | Greek ✅ live-confirmed |
| 16 | Hebrew ✅ live-confirmed |
| 17 | ⚠️ Not Swedish (that's index 18, see below) -- a real, distinct index (confirmed to genuinely apply: setting it after 18/Swedish visibly changed the display, ruling out a stuck-screen artifact) that renders in the same Hebrew script as index 16, indistinguishable from it by a non-Hebrew-reading tester. Possibly a duplicate Hebrew entry, possibly a different Hebrew-script language (e.g. Yiddish) -- genuinely unresolved which. |
| 18 | Swedish -- ✅ live-confirmed ("Allmänt"/"Ljud" visible in the settings menu, real Swedish words for "General"/"Sound"). Documented as Czech before , wrong -- where Czech actually lives (if anywhere in this range) is unknown. |
Note: mode/Protocol.java in the decompiled app defines a third, unrelated
numbering scheme (TYPE_WATCH_ID=0... TYPE_ANALOG_MODE=28) that does not
correspond to real wire opcodes at all — an internal enum for something else in the
app's own code. Don't confuse it with the actual BluetoothCommandConstant opcodes
used throughout this document.
Reconstructed from the decompiled official app. Not implemented in Gadgetbridge at all, so this is genuinely new ground with no independent working reference to cross-check against — but every step below is now live-confirmed working end to end against real hardware.
Two bitmaps per watchface: - Big face: 240×240 px - Small home-screen thumbnail: 180×180 px (0.75× scale of the big face)
Both converted from RGB888 to 16-bit BGR565: (B<<11) | (G<<5) | R (5/6/5 bits),
stored little-endian, row-major (top-to-bottom, left-to-right —
despite a confusingly-named scanDirection flag in the source, the actual
call used produces plain row-major order). Cross-confirmed from two
independent decompiled sources, one of which uses a misleadingly-named
function (rgb888ToRGB555, despite its actual bit math being 5-6-5, not
5-5-5 — likely a stale name left over from an earlier format revision).
Sizes: big = 240×240×2 = 115,200 bytes; small = 180×180×2 = 64,800 bytes. Concatenated (big first, then small) = 180,000 bytes total pixel payload.
0x6006 service, normal framing)Two independent, parallel APIs exist for this — CustomizeWatchFaceSet
(0x20, the one actually used for a real upload) and CustomizeWatchFaceEx
(0x1E, a newer, richer API with a type discriminator). 0x1E is
documented below for completeness, but isn't reachable on this hardware —
use 0x20.
0x20 (CustomizeWatchFaceSet) has two request forms:
action=CHECK (0x70), 2-byte payload = the CRC16
of the pixel payload alone, no widget-placement data. ✅ Live-confirmed
working: this is the request that actually returns the target
[watch_face_number][ota_address] — call this first for any upload, to
learn where the image should go.⚠️ The watch_face_number field is not a plain byte read — it's
computed as raw_byte & 4095 after sign-extending the raw byte as a
signed 8-bit value first (so a raw byte ≥ 0x80 sign-extends to
negative before the mask applies — e.g. raw 0xA1 decodes to 4001,
not 161). A plain unsigned-byte read gives the wrong answer for any
raw byte ≥ 0x80. Despite the name, 4001 doesn't look like a small
1–4 physical-slot index (the hardware holds 4 resident custom faces at
once) — more likely some kind of firmware-internal allocation token.
action=SET (0x71), the 23-byte payload:flag:1, crc:2,
heartRatePos:2, stepPos:2, caloriesPos:2, distancePos:2, datePos:2,
watchFaceType:1,
weatherPos:2, isExitPoint:1,
batteryPos:2, sportTimePos:2, homeTimeZonePos:2
flag (byte 0) is isOTA: 0x01 when this negotiation is meant to
be followed by a real transfer (§8.4), 0x00 when only repositioning
widgets on an already-installed face or switching to it, with no new
image coming. watchFaceType (byte 13, a style/type selector — the only
concrete value observed at a real call site is -1) is a plain integer,
not a boolean. isExitPoint (byte 16, corresponding to the SECOND_POINT
widget — a running seconds indicator) takes exactly two values: 0x01
(true) or 0xFF/-1 (false).
Each *Pos field is a plain [x:1, y:1] pixel coordinate into the
240×240 canvas — a widget you don't want to place gets {0xFF, 0xFF}.
sportTimePos is the app's own name for the ACTIVE_MINUTE widget.
homeTimeZonePos is the one exception — its pair is [position, color],
not [x, y]: position is 0 (default) or 1 (bottom); color is a
7-value palette index (1–7 = Black, Red, Orange, Green, Blue, Purple,
Pink; 0=default). Only these 10 widgets are placeable on this
hardware — a DIAL widget type also exists in the shared enum but has
no positioning logic anywhere in the real app, and 6 further widget
types from the same shared cross-product enum (TIME/GOALS/GPS/
BLUETOOTH/HAND, plus DIAL's own positioning) simply aren't wired up
for this product.
Watch replies with [watch_face_number:1][ota_address:4, little-endian]
— watch_face_number == 0 means this exact image (by CRC) is already
installed, no upload needed. This is a genuine CRC-based dedup: the watch
stores each installed custom face's CRC16 in a 4-slot table and compares
incoming CRCs against it directly.
✅✅✅ Live-confirmed working, widgets and all: sending this form with
isOTA=1 and real widget positions, immediately followed by the DFU
transfer (§8.4), produced a real custom watchface on the physical watch
with both widgets rendered at their specified positions. Widget
legibility against the source image is the uploader's own problem to
manage — there's no per-widget color control except home_time_zone's,
so a busy or dark area of the source image can make a widget hard to
read; plan the image with that in mind.
✅ Switching the active display between already-installed faces is a
separate, cheap operation confirmed working the same way: send the
CRC-check first form above; if it reports the image is already
installed, send this SET form again for the same image with isOTA=0
instead. This single exchange switches which installed face is
currently showing — no new transfer.
0x1E (CustomizeWatchFaceEx) is a separate, richer API (type=0=list
installed CRCs/IDs, type=1=get address for a CRC or delete it, type=2=
set/negotiate with widget data in an alternate encoding) that exists in the
SDK but produces no response at all when sent live on this hardware, and no
firmware handler for it was ever found — treat it as unimplemented on this
specific hardware, not a working second path. Use 0x20 above.
0x1F, CUSTOMIZE_WATCH_FACE_PRO)This is the delete mechanism for the 0x20 watchface family used above
(not 0x1E, which — as noted — isn't reachable on this hardware).
action=0x71) — delete by CRC: [0x00][crc:2], where crc is
the target watchface's CRC16. Deletion is by content match (CRC), not
an explicit slot number — the same identification scheme negotiation uses
(§8.2). This specific-CRC form is a real, reachable code path in the
shipping app (its watchface-management screen calls it directly), but
hasn't been live-tested with a real target CRC — the delete-all form
below, which shares identical framing, has been.With no CRC bytes given, the payload is just [0x01] — a single flag
byte meaning delete all. ✅ Live-confirmed: sent against a real
watch with 4 custom watchfaces installed — clean SUCCESS ack, and all 4
were confirmed gone, with the watch falling back to a built-in default
face.
- GET (action=0x70) also exists on the same opcode — intended
response is a bulk list of 4-byte little-endian CRC/ID values, one per
installed watchface. ❌ Live-tested and found to always fail: every
payload shape tried (including the exact one the real SDK itself sends)
got an identical generic failure response, never real list data. This
method has no caller anywhere in the shipping app — a well-built,
fully-wired code path that was apparently never connected to any actual
UI, and the firmware doesn't seem to implement it either.
Given negotiation (§8.2) already reports which slot a new upload will land in, with the firmware reusing/evicting slots automatically via CRC matching, an explicit delete may not even be a required step before uploading a replacement image — but it's available if you want to free a slot without uploading anything new.
An 8-byte trailer is appended after the raw pixel payload before transfer,
over the 0x20 path used for a real upload (§8.2):
[crc16_lo, crc16_hi, watch_face_number, 'U', 'F', 'A', 'C', 'E']
The CRC16 is computed over the pixel payload alone (§8.5) — the same value
already sent in the negotiation/CRC-check step. watch_face_number is the
value returned by that same CRC-check (§8.2), truncated to a single byte.
The 5-byte "UFACE" tag is genuinely 5 bytes, not 4 — a different, separate
watchface-upload path (0x1E, not reachable on this hardware — see §8.2)
uses its own unrelated 4-byte [crc_or_id:4]['FACE'] trailer shape instead;
don't mix the two up if you ever encounter 0x1E-path documentation
elsewhere.
Firmware requires this trailer's watch_face_number byte to fall in the
range 0xa1–0xa4 — exactly 4 valid values — independently confirming
only 4 custom watchface slots are valid (matching §1's "only 4 fit in
watch memory at once").
✅✅✅ Live-confirmed working end-to-end. A full ~180,008-byte watchface
image transfers completely, the watch verifies it and activates it, and the
real device genuinely displays the resulting custom watchface — confirmed
directly on physical hardware, with widgets rendering at their specified
positions too. zetime watchface upload <image> runs this whole sequence
automatically; the manual byte-level mechanics below are for anyone building
their own client from scratch.
Two different commands both put the watch into the same upgrade-mode listening state:
COMMAND_CODE_UPGRADE_MODE (0x0E) trigger — a 1-byte [0x00]
payload over the normal 0x6006 service. Confirmed safe and
self-recovering on its own: sent alone with nothing following, the watch
shows an "update in progress" screen, then times out gracefully and
reboots back into its previous state (existing watchface unchanged, no
data loss).0x20 SET negotiation (§8.2) with flag/isOTA=0x01 — the real
path used for an actual watchface upload, since it also carries the CRC
and widget-placement data for the incoming image in the same request.Either way, the watch enters a DFU-style loading state and re-advertises under a different BLE identity shortly after — both its advertised name and its BLE MAC address change. A client must disconnect and reconnect under this new identity to reach the DFU service at all; it isn't reachable under the watch's normal address.
Computing the renamed identity: the new advertised name is
<prefix><last 5 characters of the watch's serial number>. <prefix> is
looked up from characters 5–6 (0-indexed) of the 12-character serial number:
| Serial chars 5–6 | Prefix |
|---|---|
35 |
07B |
31 |
07A |
33 |
28Q |
34 |
38Q |
(Any other value means the rename scheme doesn't apply to that unit — uncommon in practice.) The renamed BLE MAC address also changes (each of the three trailing bytes increments by one from the normal address), but this doesn't need to be predicted — scanning for the computed name directly finds the device reliably, MAC address unknown in advance.
The DFU-mode GATT profile only exposes the DFU service (0x1530) plus the
two standard GAP/GATT services — the normal command service's
characteristics don't exist on this connection at all, so a client must not
try to use them there.
Once reconnected under the renamed identity, over service 0x1530
(control-point 0x1531, data channel 0x1532), six stages run in order.
Every stage's command is written to 0x1531 and gets its own notify-based
ack back on the same characteristic before the next stage begins — this is
not the 6F/8F-framed protocol used elsewhere in this document, and there
is no "ack kick" byte involved anywhere in this exchange.
{0x01, 0x01}. Ack: [0x01, 0x00].{0x01, LE32(total_length)}, where total_length is the
combined length of the pixel payload plus the 8-byte trailer (§8.3). Ack:
[0x01, 0x01].{0x02, update_type, LE32(ota_address),
LE32(content_length), crc16_lo, crc16_hi, 0x00, 0x00, 0x64}.
update_type=4 for a watchface upload. ota_address and content_length
match the values from the CRC-check negotiation (§8.2); the CRC here is
computed over the full content (pixel payload + trailer), not just the
pixel payload alone. Ack: [0x02, 0x01].0x1532 in
chunks, each chunk further auto-fragmented into 20-byte BLE writes. Within
each 2048-byte page: one ~2000-byte chunk, then the ~48-byte remainder —
each chunk individually acked before the next is sent. Ack shape:
[0x03, 0x01, <byte>, <cumulative bytes received, LE32>] — the
cumulative count lets a client verify every byte actually landed, chunk
by chunk, without needing to trust a single end-of-transfer summary.{0x04}. Ack: [0x04, 0x01].{0x05}. Ack: [0x05, 0x01]. This is the real commit
step — the watch applies the transferred image as the active watchface
from this point on.A full ~180KB transfer runs to roughly 175 chunks and takes several minutes at this pacing (dominated by the mandatory inter-fragment delay on each 20-byte BLE write, the same timing constraint noted in §2). Known limitation: over a multi-minute session, a real BLE link timeout can interrupt an in-progress transfer (observed once, partway through, with no data corruption — the watch simply never received its full declared length and reverted safely on its own). A straightforward retry of the whole sequence from the beginning is the current mitigation; the protocol also appears to support resuming an interrupted transfer from a partial byte offset rather than restarting, though this project hasn't implemented or tested that path.
Sending the 0x20 negotiation with real widget positions (§8.2) before
this transfer sequence — instead of the bare 0x0E trigger — carries the
widget-placement data along for the ride; the rest of the sequence above is
unchanged. The result is a custom watchface with live data widgets (date,
battery, heart rate, etc.) rendered directly over the uploaded image, all in
one upload.
Switching the watch's active display to an already-installed custom
watchface — no new transfer needed — is a separate, much cheaper operation:
send the CRC-check request (§8.2) for that image; if the watch reports it's
already installed (watch_face_number == 0), follow up with the same 0x20
negotiation but with flag/isOTA=0x00 instead. This single request/response
exchange switches the display without touching the DFU service at all.
The watch holds up to 4 custom watchfaces at once (§1); each independent
upload gets its own watch_face_number/ota_address allocation from the
CRC-check step, and multiple uploaded faces coexist without any special
handling.
Standard CRC-CCITT (polynomial 0x1021, initial value 0xFFFF,
"CCITT-FALSE" variant — no reflection, no final XOR) — the same algorithm
used in Nordic's own reference DFU bootloader implementation. Output stored
little-endian (low byte first). Verified against the standard CRC-CCITT
check value ("123456789" → 0x29B1).
.bin file's own header (a separate, narrower question from the
little-endian ota_address returned by the CRC-check negotiation in
§8.2, which is settled) remains unresolved — this only matters for real
firmware/chip updates, not watchface uploads, and hasn't been chased
further since watchface uploads don't depend on it.COMMAND_CODE_UPGRADE_MODE's other two payload forms (a legacy 16-byte
3-chip descriptor, and a 5-byte single-chip "Apollo" address form) exist
in the protocol but aren't needed for — and haven't been tested against —
a watchface upload specifically, which uses the bare-trigger or
negotiation-triggered path described above.Found while reading the decompiled source; these are defects in MyKronoz's own client, not protocol features:
UserName SET constructor (opcode 0x32): allocates a 1-byte content array,
then System.arraycopys 16 bytes into it — would throw
ArrayIndexOutOfBoundsException at runtime. Just send a properly-sized 16-byte
buffer in a real client.0x95/0x97 — REMIND_SETTING_EX_DATE and
REMIND_SETTING_EX_DATE_SHOCK): the year is decoded with a bitwise-AND instead of
a proper little-endian byte combine. Treat any year value from these two opcodes
as unverified.REMIND_SETTING_EX_DATE_SHOCK_REPEAT's own SET constructor targets opcode
0x97 instead of its own 0x9B — a copy-paste bug. Use 0x9B consistently for
both directions if implementing this independently.This reference was built by decompiling the official (now-defunct) MyKronoz Android apps — both the original dedicated ZeTime app and a later "universal" app covering multiple MyKronoz product lines — and cross-checking every byte against two independent, publicly-available sources with no access to that code: Gadgetbridge's open-source ZeTime device support, and gadgetbridge.org's own ZeTime protocol write-up. Firmware-level details (§1's Apollo MCU identification, the OTA address endianness confirmation in §8.2) came from disassembling the watch's own official firmware update packages directly.
One real, practically useful lesson from this process, worth calling out on its own: the official ZeTime app was updated over its lifetime, and the underlying protocol changed with it — most notably the exact chunk sizing and handshake sequence for the DFU data transfer (§8.4). An older app version and a newer one genuinely disagree on real wire-level details for the same opcode. If you're extending this reference from decompiled source yourself and something doesn't match a real watch's live behavior, check whether a different app version might be the actual explanation before assuming the watch (or this reference) is wrong.
Two known gaps in the independent sources above, noted for completeness: Gadgetbridge doesn't implement the watchface upload protocol (§8) at all — everything in that section comes from decompiled-source analysis plus live testing against real hardware, with no independent working client to cross-check against; and gadgetbridge.org's own write-up explicitly marks several opcodes as unknown/idle even to its original author — genuinely undocumented territory, not an extraction gap here.
Further resources, for anyone extending this work:
This documents the REST API the official MyKronoz apps used to talk to
api.mykronoz.com and related hosts — the backend that's actually dead, which
is the reason a BLE-only client is the only way to talk to the watch at all.
It has nothing to do with the watch itself — the watch only ever speaks
the BLE protocol documented above. This section is purely historical/technical
documentation of a third-party service this reference does not use, does not
contact, and cannot revive.
Reconstructed entirely by decompiling the official apps — specifically a clean, unobfuscated Retrofit service interface
(com.mykronoz.watch.cloudlibrary.services.KronozService, older ZeTime
v1.8.2 app) plus its request/response entity classes. No requests were ever
sent to any of these hosts — this is static analysis of decompiled Java only.
| Host | Purpose |
|---|---|
api.mykronoz.com (staging) / prod-api.mykronoz.com, api-prod.mykronoz.com (production) / api-preprod.mykronoz.com (pre-prod) |
The API documented below, base path /v1/ |
watchfaces.mykronoz.com / watchfaces-dev.mykronoz.com |
Not an API — an external website (/v2/mobile/index.html) embedded in-app via WebView. Watchface browsing/uploading happened there, backed by this same REST API's watchface/* endpoints |
osm.mykronoz.com |
A thin proxy in front of what looks like Nominatim/OpenStreetMap-style place search — ?format=json&featuretype=city&q=<name>, feeds the timezone/weather-location picker |
community.mykronoz.com, support.mykronoz.com, www.mykronoz.com, cdn-magento.mykronoz.com |
More embedded WebViews / static content — community feed, support site, marketing, product docs. Not investigated further |
wsp-prod.mykronoz.com, wsp-preprod.mykronoz.com |
A different MyKronoz product's API (a smart scale — myScaleBaseUrl, path /yolanda/wsp). Not ZeTime-related |
zebuds.mykronoz.com |
A download link for the companion app of MyKronoz's ZeBuds earbuds. Not ZeTime-related |
The newer "universal" app (v2.0.23, mostly React Native) covers MyKronoz's whole product line from one codebase — hence the scale/earbuds hosts showing up in the same binary.
Three modes, selected per-request by the calling code (AuthenticationType
enum: BASIC / TOKEN / NONE), implemented as OkHttp interceptors:
TOKEN — Authorization: Bearer <jwt>. The JWT comes from account/auth
or account/jwt (see below) and is cached locally (TokenStorage). Used for
anything user-specific (profile, activity data, watchface uploads by that
user, pairing).BASIC — standard HTTP Basic auth with an app-level client ID/secret
pair (appId/appPassword), not a user's credentials. Used before a user
token exists (e.g. account creation, login itself). The literal credential
value is embedded in the app but wasn't extracted here — pinning down that
this mechanism exists is what matters for this document; the actual secret
is a third-party credential this project has no reason to go after.NONE — no auth header (e.g. the public watchface list, place search).Every request also carries a User-Agent: <productName>/<productVersion> (<platform info>)
and Accept-Language: <device locale> header.
Custom pinned CA certificates are configured too (res/raw/hl, xh, xl,
dwhe in the newer app's network_security_config.xml) — certificate
pinning, consistent with a service that also handles account credentials and
payment card data (see the pay* opcodes above, which this API has no
equivalent of — payment goes through the watch/BLE side directly).
Base path /v1/. Grouped by area; method/path/request/response taken
directly from the Retrofit interface's annotations.
| Method | Path | Request body | Response |
|---|---|---|---|
| POST | account/jwt |
Account (password, active, nested AccountDetail: email, locale, tz, first/last name, Role) |
Token |
| POST | account/auth |
User (email, password) |
Token |
| GET | jwt/refresh |
— (Bearer) | Token |
| PUT | jwt/detail/self |
AccountInfo (locale, tz, first/last name) |
AccountDetail |
| GET | account/email/check/{email} |
— | MessageWithCode |
| PUT | jwt/account/email |
NewEmail |
Message |
| PUT | jwt/account/password |
Password |
Message |
| GET | account/password/reset/{email} |
— | Message |
| GET | jwt/validate/resend |
— | GenericResult |
| POST | jwt/account/migration |
AccountMigrationCode |
raw body |
| POST | connector/facebook/auth |
ThirdPartyToken |
ThirdPartyAuthenticationResult |
| POST | connector/google/auth |
ThirdPartyToken |
ThirdPartyAuthenticationResult |
| POST | connector/twitter/auth |
TwitterToken (token + secret) |
ThirdPartyAuthenticationResult |
ThirdPartyAuthenticationResult = {token, isNew} — presumably a MyKronoz
JWT usable the same way as one from account/auth, plus whether this
social login created a fresh account.
| Method | Path | Request | Response |
|---|---|---|---|
| POST | pairing/pair |
ProductInfo (DeviceInfo: id/model/sim/firmwareName/firmwareVersion; AppInfo: appName/appVersion/model/osVersion) |
ProductInfo |
| GET | pairing/paired |
— | List<DeviceDetails> |
| GET | pairing/paired/{model} |
— | DeviceInfo |
| GET | pairing/unpair/{deviceId} |
— | DeviceInfo |
DeviceDetails (the richer, server-side record) adds uid, paired,
firstPairing/lastPairing (epoch millis) on top of DeviceInfo's fields.
| Method | Path | Request | Response |
|---|---|---|---|
| GET | firmware/last/{product_name} |
— | Firmwares = {globalUrl, List<Firmware>} |
| GET | firmware/data/{id} (@Streaming) |
— | raw binary body |
Firmware: id, name, version (int), versionTxt, md5, description,
translations (list of strings — per-locale release notes, presumably),
force (mandatory-update flag), status, fileName, url, publishDate/
uploadDate/uploader (epoch millis), validators (long[], purpose
unclear — maybe a list of account/device IDs authorized to see this build
before general release), and a nested FirmwareProductInfo (id, name,
FirmwareBasicInfo family, FirmwareBasicInfo supplier — each just
{id, name}) describing which hardware family/vendor this build targets.
This is almost certainly the real distribution mechanism behind the 41 OTA
packages already in this project's Firmware/ dump — this
API is where the app would have fetched firmware/last/ZeTime (or similar)
and then streamed firmware/data/{id} to get the actual .zip.
| Method | Path | Request | Response |
|---|---|---|---|
| GET | watchface/public/list |
query: skip, max, search, tags, from, to, rmin, rmax |
WatchfaceFullResponse |
| GET | watchface/user/download/list |
query: as above + status |
WatchfaceFullResponse |
| GET | watchface/user/upload/list |
query: as above + status |
WatchfaceFullResponse |
| GET | watchface/{watchfaceId} |
— | WatchfaceResponse |
| GET | watchface/tag/list |
— | WatchfaceTagsList |
| GET | watchface/{watchfaceId}/rating |
— | WatchfaceRating |
| POST | watchface (@Multipart) |
data part = WatchfaceRequest (title, description, tags) + a binary file part |
WatchfaceResponse |
| PUT | watchface/{watchfaceId} |
WatchfaceRequest |
Message |
| DELETE | watchface/{watchfaceId} |
— | Message |
| PUT | watchface/{watchfaceId}/download |
— | Message (report a download) |
| DELETE | watchface/{watchfaceId}/download |
— | Message (report deleted) |
| PUT | watchface/{watchfaceId}/share |
— | Message |
| PUT | watchface/{watchfaceId}/rating/{rating} |
— | Message |
| POST | watchface/{watchfaceId}/report |
— | Message (flag for re-approval) |
| PUT | watchface/{watchfaceId}/report |
— | Message (re-approval by the uploader) |
WatchfaceResponse: id, WatchfaceCreator {id, name}, datetime,
status (a moderation/approval state string, given the report/re-approval
endpoints above), title, description, tags, image (a path/URL,
presumably a rendered preview — the actual watch-format binary is fetched
separately, not shown in this response shape), numDownloads, numShares,
numRatings, rating (float average), comment. WatchfaceFullResponse
wraps a page of these: {skip, max, count, data: [WatchfaceResponse]}.
Note this REST layer only carries metadata (title/tags/rating/moderation status) plus an uploaded file part — it says nothing about the actual pixel format. That's the BLE-side watchface-upload territory covered above, reverse-engineered separately from firmware/APK analysis; this API was never going to reveal that (it just relays an opaque uploaded file to storage).
| Method | Path | Request | Response |
|---|---|---|---|
| POST | data/tracking/activities |
ActivityPost = {ProductInfo, List<Activity>} |
ActivityPost |
| GET | data/tracking/activities/summary/{date}/{period} |
— | List<ActivitySummary> |
| GET | data/tracking/activities/daily/{activity}/{date}/{period} |
— | List<ActivityDaily> |
| GET | data/tracking/activities/daily/{activity}/range/{start}/{end} |
— | List<ActivityDaily> |
| GET | data/tracking/activities/hourly/{activity}/{date} |
— | ActivityHourly |
| GET | data/tracking/activities/hourly/{activity}/range/{start}/{end} |
— | List<ActivityHourly> |
| GET | data/tracking/activities/minute/{activity}/{date} |
— | ActivityMinute |
| POST | data/sleep/ |
SleepUpload = {beginTime, endTime, List<SleepDetail>, ProductInfo} |
SleepData |
| GET | data/sleep/{date}/{period}?withDetail=true |
— | List<SleepData> |
| GET | data/personal |
— | Personal |
| POST | data/personal |
PersonalPost = {Personal, ProductInfo} |
Personal |
| POST | data/personal/avatar (@Multipart) |
binary file part | AvatarPath |
| GET | data/personal/avatar |
— | AvatarPath |
| GET | data/storage/{key} |
— | arbitrary JsonObject |
| POST | data/storage/{key} |
arbitrary JsonObject |
arbitrary JsonObject |
{activity} is one of ActivityType: STEPS, DISTANCE, CAL, MINRATE,
MAXRATE, AVGRATE, AUTORATE, DURATION. Activity (the per-session
upload shape): beginTime, endTime, steps, distance, calories,
duration, minRate/maxRate/avgRate (heart rate). Personal: aboutMe,
nickname, Gender, country, state, city, dateOfBirth, height/
weight (BigDecimal) with their own HeightWeightUnit (IMPERIAL/
METRIC) each. SleepDetail: {time, SleepType}.
The data/storage/{key} pair is a generic per-user JSON key-value store —
presumably used for whatever app-side settings/state didn't warrant a
dedicated endpoint.
| Method | Path | Request | Response |
|---|---|---|---|
| GET | data/challenges/goals/{date} |
— | Challenge |
| POST | data/challenges/goals |
ChallengeRecordToPost |
Challenge |
| GET | data/challenges/records |
— | ChallengeRecord |
| POST | data/challenges/records |
ChallengeRecordToPost |
ChallengeRecord |
| GET | banner?filter=<...> |
— | List<Banner> |
GET (arbitrary @Url) |
— | — | ResponseBody (generic file download helper) |
ChallengeRecord: steps, distance, calories, sleepDuration,
activityDuration. Challenge extends it with a day field (so it's the
same shape, scoped to one day, vs. presumably a running/lifetime total for
the bare ChallengeRecord response). Banner: name, active, link,
url, filePath — an in-app promotional banner feed.
account/auth/account/jwt (TOKEN/BASIC auth against a host that no
longer resolves or answers) is exactly the hard dependency the
the README describes as making the official apps permanently
unusable — this API reconstruction is consistent with that, not just an
assumption. Everything else here (activity sync, watchface store, firmware
distribution) is the same story: real, fairly complete cloud functionality
that's now unreachable, which is the whole reason this project talks to the
watch directly over BLE instead.