These are some fairly unorganized notes from reverse engineering the SayoDevice O3C. I doubt this information would be useful for most people, but it's better to put it out there than to leave it publicly undocumented.
MounRiver Studio (toolchain for the CH32)
The latest firmware update can be found at https://a.sayobot.cn/firmware/update/9/firmware/app_O3C.bin
.
The firmware image is encrypted with AES-256-CBC with the key C4053DDF225E89F74868C1E1F4C00D514F02A8A8692F997869ABEB155250150C
and a zero IV. After decryption, it's written to flash and is accessible at address 0x4000
. The decrypted image can be directly loaded into IDA/Ghidra/whatever at that offset.
Some metadata is stored at 0x29F80
in the firmware image. On every boot and right before rebooting after a firmware update, the bootloader performs an integrity check on the firmware. The size of the image is stored at 0x29F84
and a MD5 hash is stored at 0x29FA0
. If the hash doesn't match, the device will either force itself into bootloader mode or refuse to reboot after the update.
A dump of the bootloader (along with an old firmware) can be found here. This gets loaded at 0x0
.
To configure the device, custom USB HID commands are sent. The device has a VID of 0x8089
, a PID of 0x0009
, and a usage page of 0xFF00
.
Commands and responses follow the following format:
struct hid_packet_v1_t {
uint8_t report_id; // 0x00, always 2
uint8_t id; // 0x01
uint8_t length; // 0x02
uint8_t data[length]; // 0x03
uint8_t checksum; // length + 0x03, sum of all previous bytes
// padded to 64 bytes
};
Command names are taken from an old version of the web UI's code.
TODO
TODO
TODO
TODO
TODO
TODO
TODO
TODO
TODO
TODO
TODO
TODO
TODO
TODO
TODO
TODO
The device for API v2 also has a VID of 0x8089
and a PID of 0x0009
, but the usage page is 0xFF12
for high-speed mode (8000hz) and 0xFF11
for the other polling rates.
Also, the commands follow a different format:
struct hid_cmd_v2_t {
uint16_t length; // 0x00
uint8_t id; // 0x02
uint8_t index; // 0x03, used for identifying which response corresponds to a command within a packet
uint8_t data[length - 4]; // 0x04
// padded to a multiple of 4 bytes
};
struct hid_packet_v2_t {
uint8_t report_id; // 0x00, 0x22 for high-speed mode, 0x21 otherwise
uint8_t echo; // 0x01, always 3 in the web UI, doesn't seem to get read?
uint16_t checksum; // 0x02, sum of struct reinterpreted as 16-bit words
hid_cmd_v2_t cmds[]; // 0x04
// padded to 1024 bytes for high-speed mode, 64 bytes otherwise
};
Command names are taken from the web UI's code.
struct info_res_t {
uint16_t model_code; // 0x00
uint16_t firmware_version; // 0x02
uint8_t unk4[4]; // 0x04
uint8_t battery; // 0x08, hardcoded to 0
uint8_t fn; // 0x09
uint8_t cpu_s; // 0x0A
uint8_t cpu_ms; // 0x0B
uint8_t unkC[0x17]; // 0x0C
};
// Can also be sent to set name
struct info_res_t {
uint32_t name[12]; // 0x00, unicode string
};
struct sys_req_t {
uint16_t width; // 0x00
uint16_t height; // 0x02
uint8_t refresh_rate; // 0x04
// 1 byte of padding
uint16_t sys_ms; // 0x06
uint32_t sys_s; // 0x08
uint16_t vid; // 0x0C
uint16_t pid; // 0x0E
uint8_t cpu_1m; // 0x10
uint8_t cpu_5m; // 0x11
// 2 bytes of padding
uint32_t cpu_freq; // 0x14
uint32_t hclk; // 0x18
uint32_t pclk_1; // 0x1C
uint32_t pclk_2; // 0x20
uint32_t adc_0; // 0x24
uint32_t adc_1; // 0x28
};
TODO
Referenced in the web UI, but not implemented on the O3C.
TODO
TODO
Referenced in the web UI, but not implemented on the O3C.
Referenced in the web UI, but not implemented on the O3C.
Referenced in the web UI, but not implemented on the O3C.
TODO
TODO
TODO
TODO
TODO
Referenced in the web UI, but not implemented on the O3C.
TODO
TODO
TODO
TODO
TODO
TODO
TODO
TODO
TODO
TODO
TODO
TODO
TODO
Dumps the keypad's framebuffer.
struct display_req_t {
uint32_t byte_offset; // 0x00
};
struct display_res_t {
uint32_t byte_offset; // 0x00
uint16_t framebuffer[0x1FA]; // 0x04, 0x1A elements on non-highspeed
};
Referenced in the web UI, but not implemented on the O3C.
The O3C lets the user customize 3 screens: main, sleep, and startup. Each screen is made up of 16 layers, each of which can be swapped out and configured.
Layer type names are taken from the web UI's code.
Does nothing.
Fills a rectangle with a configurable color, size, and position.
Displays a widget.
NOTE: IDs are off by 1 in the web UI!
Does nothing.
Can't be moved for some reason.
Displays a key counter.
Displays some text.
Displays a hardcoded image.
NOTE: IDs are off by 1 in the web UI!
(TODO: Get a GIF of this)
Displays a user-uploaded image.
(TODO: Reverse engineer the format)
Clears the entire screen with a configurable color.