diff options
| author | Arslaan Pathan <[email protected]> | 2026-03-29 21:52:37 +1300 |
|---|---|---|
| committer | Arslaan Pathan <[email protected]> | 2026-03-29 21:52:37 +1300 |
| commit | 528af273c97b74a710d5f620474da91c7557591b (patch) | |
| tree | 4e700fed76e847343c79c4624c104cad927ddeeb | |
| parent | b8f5b84bf1a02595a051bdb005ed50fa3518ff20 (diff) | |
| download | zwzn-freefit-re-528af273c97b74a710d5f620474da91c7557591b.tar.xz zwzn-freefit-re-528af273c97b74a710d5f620474da91c7557591b.zip | |
YES IT FINALLY WATCH FACE YESSSSSSSSSSS
| -rw-r--r-- | FereFit_enterMakeDial_BLE.py | 135 | ||||
| -rw-r--r-- | README.md | 90 |
2 files changed, 203 insertions, 22 deletions
diff --git a/FereFit_enterMakeDial_BLE.py b/FereFit_enterMakeDial_BLE.py new file mode 100644 index 0000000..096e986 --- /dev/null +++ b/FereFit_enterMakeDial_BLE.py @@ -0,0 +1,135 @@ +import asyncio +import sys +try: + from bleak import BleakScanner, BleakClient +except ModuleNotFoundError: + print("Error: pip3 install bleak") + sys.exit(1) + +try: + from PIL import Image +except ModuleNotFoundError: + print("Error: pip3 install Pillow") + sys.exit(1) + +WRITE_UUID = "6E40FC20-B5A3-F393-E0A9-E50E24DCCA9E" +NOTIFY_UUID = "6E40FC21-B5A3-F393-E0A9-E50E24DCCA9E" +WIDTH = 240 +HEIGHT = 296 +MTU = 148 +CHUNK_SIZE = 140 + +def image_to_rgb565(image_path): + """Load an image and convert to raw RGB565 format""" + # Load and resize image + img = Image.open(image_path) + img = img.resize((WIDTH, HEIGHT), Image.Resampling.LANCZOS) + + # Convert to RGB if needed + if img.mode != 'RGB': + img = img.convert('RGB') + + # Convert to raw RGB565 pixels + pixels = bytearray() + for y in range(HEIGHT): + for x in range(WIDTH): + r, g, b = img.getpixel((x, y)) + # RGB888 to RGB565 + rgb565 = ((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3) + pixels.append((rgb565 >> 8) & 0xFF) # High byte + pixels.append(rgb565 & 0xFF) # Low byte + + return bytes(pixels) + +def make_header(image, chunk_size): + total_bytes = len(image) + total_packets = (total_bytes + chunk_size - 1) // chunk_size + pixel_sum = sum(image) & 0xFFFF + + return bytes([ + 0xE4, 0x51, 0x01, 0x00, + (total_packets >> 8) & 0xFF, total_packets & 0xFF, + (total_bytes >> 24) & 0xFF, (total_bytes >> 16) & 0xFF, + (total_bytes >> 8) & 0xFF, total_bytes & 0xFF, + 0x00, + (chunk_size >> 8) & 0xFF, chunk_size & 0xFF, + 0x01, + 0x01, 0x01, 0x00, 0x00, 0x00, + (pixel_sum >> 8) & 0xFF, pixel_sum & 0xFF, + 0x01 + ]) + +def make_chunk(image, chunk_index, total_packets, chunk_size): + offset = chunk_index * chunk_size + data = image[offset:offset + chunk_size] + is_last = offset + len(data) >= len(image) + progress = ((chunk_index + 1) * 100) // total_packets + + packet = bytearray(MTU) + packet[0:4] = bytes([0xE4, 0x52, 0x01, 0x02]) + packet[4] = ((chunk_index + 1) >> 8) & 0xFF + packet[5] = (chunk_index + 1) & 0xFF + packet[6] = (offset >> 24) & 0xFF + packet[7] = (offset >> 16) & 0xFF + packet[8] = (offset >> 8) & 0xFF + packet[9] = offset & 0xFF + packet[10] = progress + packet[11] = 0x01 if is_last else 0x00 + packet[14:14 + len(data)] = data + + checksum = (sum(packet[:14]) + sum(data)) & 0xFFFF + packet[12] = (checksum >> 8) & 0xFF + packet[13] = checksum & 0xFF + + return bytes(packet) + +async def send_watchface(device_name, image_path): + print(f"Loading image: {image_path}") + try: + image = image_to_rgb565(image_path) + print(f"Image converted: {len(image)} bytes") + except Exception as e: + print(f"Failed to load image: {e}") + return + + print(f"Scanning for {device_name}...") + device = await BleakScanner.find_device_by_name(device_name, timeout=10) + if not device: + print("Watch not found!") + return + print(f"Found at {device.address}") + + total_packets = (len(image) + CHUNK_SIZE - 1) // CHUNK_SIZE + header = make_header(image, CHUNK_SIZE) + ack = asyncio.Event() + + def notification_handler(sender, data): + ack.set() + + async with BleakClient(device) as client: + await client.start_notify(NOTIFY_UUID, notification_handler) + + print("Sending header...") + await client.write_gatt_char(WRITE_UUID, header, response=False) + await asyncio.wait_for(ack.wait(), timeout=5.0) + + print(f"Sending {total_packets} chunks...") + for i in range(total_packets): + ack.clear() + chunk = make_chunk(image, i, total_packets, CHUNK_SIZE) + await client.write_gatt_char(WRITE_UUID, chunk, response=False) + try: + await asyncio.wait_for(ack.wait(), timeout=5.0) + except asyncio.TimeoutError: + print(f"Timeout on chunk {i}") + + if i % 100 == 0: + print(f"Progress: {i}/{total_packets}") + + await asyncio.sleep(2) + print("Done!") + +if __name__ == "__main__": + watch_name = input("Enter watch name (default: Watch ULTRA): ") or "Watch ULTRA" + image_path = input("Enter image file path: ") + asyncio.run(send_watchface(watch_name, image_path)) @@ -139,28 +139,74 @@ Send chunks sequentially with ~100ms delay between each. ### enterMakeDial (watch face) -Watch face header packet (write/6E40FC20) -``` -byte[0] = 0xE4 (ZK_DIAL command) -byte[1] = 0x51 (mode flag) -byte[2] = 0x01 (start) -byte[3] = 0x00 -byte[4-5] = total packet count (big endian) -byte[6-9] = total image bytes (big endian) -byte[10] = 0x00 -byte[11-12] = MTU size (big endian) -byte[13] = rotation flag -byte[14] = 0x01 -byte[15] = time text direction -byte[16] = 0x00 -byte[17-18] = transparent color (RGB565) -byte[19-20] = checksum (sum of all image bytes, big endian) -byte[21] = show date (0x01 = yes, 0x00 = no) -``` - -Header packet is followed by chunked RGB565 data, 1 chunk = MTU-14 bytes - -This function/protocol has not been tested yet, don't expect this structure of headers and RGB565 data to work at the moment. Could very well change drastically if other parts of the protocol are found +This protocol sends a custom watchface to the watch. The watch expects raw RGB565 pixel data, sent in chunks. + +Protocol flow: + +1. Send header packet (command 0x51) +2. Wait for ACK from watch +3. Send data chunks (command 0x52) +4. Wait for ACK after each chunk + +Watchface header packet (write/6E40FC20): +``` +byte[0] = 0xE4 (ZK_DIAL command) +byte[1] = 0x51 (mode flag) +byte[2] = 0x01 +byte[3] = 0x00 +byte[4-5] = total packet count (big endian) +byte[6-9] = total image bytes (big endian) = width * height * 2 +byte[10] = 0x00 (unknown, padding) +byte[11-12] = chunk size (big endian, usually 140) +byte[13] = dial type (0x01 = background, 0x02 = full watchface) +byte[14] = 0x01 (always) +byte[15] = UI overlay enable (0x00 = no UI, 0x01 = show time/date) +byte[16] = 0x00 (padding) +byte[17-18] = text color (RGB565, big endian) +byte[19-20] = checksum (sum of all pixel bytes, big endian) +byte[21] = hide date flag (0x00 = show date, 0x01 = hide date) +``` + +Data chunk packet (write/6E40FC20) +``` +byte[0-3] = 0xE4, 0x52, 0x01, 0x02 (command) +byte[4-5] = chunk number (1-indexed, big endian) +byte[6-9] = byte offset (big endian) +byte[10] = progress percentage (0-100) +byte[11] = last chunk flag (0x01 if last, else 0x00) +byte[12-13] = checksum (sum of header[0:14] + data bytes, big endian) +byte[14+] = raw RGB565 pixel data (up to chunk size bytes) +``` + + +**Image format:** +- Resolution: 240x296 (actual display size, not 240x280 as advertised) +- Format: Raw RGB565, no headers +- Byte order: High byte first, low byte second (big endian) +- Pixel layout: Top to bottom, left to right +- Color conversion: RGB888 -> RGB565 = ((R>>3)<<11) | ((G>>2)<<5) | (B>>3) + +**MTU/chunk settings:** +- Fixed packet size: 148 bytes +- Data payload per chunk: 140 bytes +- Header size: 14 bytes + +**Chunked data example:** +- 240x296 = 142,080 bytes total +- 142,080 / 140 = 1,015 chunks + +**Response (notify/6E40FC21):** +- Header ACK: `0xE4 0x51 0x02 ... 0x00` (byte[9] = 0x00 for success) +- Chunk ACK: `0xE4 0x52 0x02 ... 0x00` (byte[9] = 0x00 for success) + +**Test script:** `FereFit_enterMakeDial_BLE.py` + +**Notes:** +- UI overlay (time/date) only appears if byte[15] = 0x01 +- Text color is presumably set via bytes[17-18] (RGB565) [unconfirmed] +- The watch does NOT automatically overlay UI - you must enable it +- Actual display height is 296, not 280 (white bar appears at bottom with 280) +- Resolution and variables can vary quite a lot depending on watch variant ## Scripts and tests |
