ZeTime BLE Protocol Reference

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.


1. Transport / GATT map

Device advertises as ZeTime#<serial> (e.g. ZeTime#12345), standard public BLE address, no special advertising-based discovery trickery.

Primary command service — 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.

Extended service — 00007006-0000-1000-8000-00805f9b34fb

Referenced 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.

Standard BLE services present

DFU / firmware-update service — 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).

Confirmed identical across both official app generations

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.


2. Packet framing ✅

6F [cmd:1] [action:1] [len:2, little-endian] [payload:len bytes] 8F

The request-length pattern (corrected — it's not always len=1) ✅ / cross-confirmed

Early 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).

The "ack kick" — required, and only once per logical message ✅ / refined

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)

A second, distinct reply mechanism exists: characteristic 0x8003

Don'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.

Two-part message reassembly

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.

Bidirectional protocol — the watch can initiate requests

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.

No bind/pairing handshake needed ✅

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.

Timing constraints — the firmware chokes on bursts

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.

Connection quirks ✅


3. Action / status byte reference

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.


4. Main opcode table (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.

Goal types (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

Notification/message push types

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 Email
4 Calendar
5 Incoming call
6 Call ended
7 WeChat
8 Viber
9 Snapchat
10 WhatsApp
11 QQ
12 Facebook
13 Hangouts
14 Gmail
15 Messenger
16 Instagram
17 Twitter
18 LinkedIn
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.

Reminder/alarm types (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

Vibration / shock modes (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

Settings switch bit-flags (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.


5. Legacy 0x6E-framed opcode family

The 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

6. Full payload formats

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.

Device info & identity

Time & settings

Goals

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].

Sport / health data

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."

Notifications

Weather (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

Calendar

Alarms/reminders

⚠️ 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:

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.

Confirmed wire format (from Gadgetbridge's real source, extended by live testing)

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.

Music (0xD0) — watch-initiated

The watch, not the phone, initiates this exchange.

Camera remote (0xD1) and quick-reply (0xDD/0xDE/0xE6) — watch-initiated

Live-tested on real hardware. Payload shapes remain undocumented for all three, but a reply is genuinely expected:

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.

Call control (0xDC) — watch-initiated

Only 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).

Payment (payment-enabled ZeTime variant only)

Factory / production test — ⚠️ avoid outside real diagnostics

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.

Extended / remote-control

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 upZeAntiLostProtocol.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.


7. Language index table (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.


8. Watchface upload protocol (✅ live-confirmed working end-to-end, including widgets and switching between installed faces)

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.

8.1 Image format

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.

8.2 Slot negotiation (over the 0x6006 service, normal framing)

Two independent, parallel APIs exist for thisCustomizeWatchFaceSet (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:

  1. CRC-only checkaction=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.

  1. Full negotiationaction=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 (17 = 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.

8.2b Deletion (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).

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.

8.3 Trailer

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 0xa10xa4 — exactly 4 valid values — independently confirming only 4 custom watchface slots are valid (matching §1's "only 4 fit in watch memory at once").

8.4 Transfer — Nordic "Legacy DFU"-style

✅✅✅ 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.

Entering upgrade/DFU mode

Two different commands both put the watch into the same upgrade-mode listening state:

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.

The transfer sequence

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.

  1. Note{0x01, 0x01}. Ack: [0x01, 0x00].
  2. Init{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].
  3. Set — 15 bytes: {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].
  4. Data — the content (pixel payload + trailer) streamed to 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.
  5. CRC check{0x04}. Ack: [0x04, 0x01].
  6. Activate{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.

Widgets and switching

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.

8.5 CRC16 algorithm

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).

Known limitations


9. Known bugs in the official MyKronoz app — do not replicate

Found while reading the decompiled source; these are defects in MyKronoz's own client, not protocol features:

  1. 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.
  2. Reminder date fields (0x95/0x97REMIND_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.
  3. 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.

10. Methodology and further reading

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:


MyKronoz Cloud API Reference (defunct)

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.

Hosts

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.

Authentication

Three modes, selected per-request by the calling code (AuthenticationType enum: BASIC / TOKEN / NONE), implemented as OkHttp interceptors:

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).

Endpoints

Base path /v1/. Grouped by area; method/path/request/response taken directly from the Retrofit interface's annotations.

Account & authentication

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.

Device pairing

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.

Firmware distribution

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.

Watchface store

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).

Health/activity data sync

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.

Challenges & misc

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.

What this confirms about the project's own premise

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.