bluetooth-lowenergyprotocolsreverse-engineeringchecksumbluetooth-gatt

Calculating last byte in packet - reverse-engineering communication protocol for BLE device


I'm attempting to reverse-engineer the communication protocol used to send GIF images to an LED matrix connected via BLE.

It's a 20x96 LED matrix, for what it's worth, and here is the product link. It appears quite similar to this product and this other product as well.

I am attempting to bypass the proprietary mobile app in order to upload custom GIF images to the screen.

However, before I release my code to GitHub, I would like to send my own custom GIF images to the screen. I'm this close to figuring out the communication protocol, but the final byte is eluding me somehow. It's probably some kind of checksum, but I can't figure it out.

======

Using an nRF52833 dongle and Wireshark, I was able to capture the communication between my phone and the screen. For simple commands like changing the brightness of the screen, or replaying known good commands, I think I'm set.

But here's the format of the command to send a portion of the data of a GIF. It gets sent in chunks that look a little something like this:

aa55ffff [aa] [bbbbbb] c1020901010c01000d01000e0100140301090a11040001000a1207 [cc] [dddddd] c40000 [eeeeee] [GIF DATA] [yy] [zz]

Where aa55ffff, c1020901010c01000d01000e0100140301090a11040001000a1207, and c40000 appear to be constant.

I haven't 100% figured it out yet, but I'm pretty sure I know what the others do:

======

Somehow I found a way to tell if a given packet is accepted by the device or not, so I was able to create some arbitrary payloads and see what changes to the data bytes end up doing to the final byte. For each of these, I added an arbitrary number of 9's to see how that affects the last byte. I also had to calculate the second-to-last byte when changing the rest of the data.

So, while artificially manipulated, these are all considered "valid" packets (spaces added for clarity). Although I should note this sequence wouldn't be valid, because these are all first packets.

aa55ffffed000e00c1020901010c01000d01000e0100140301090a11040001000a120745000e00c400001381c447de81f07d69d74a6ca1cf32e200034ccb761f5c4d3ed6d402cfda8057f0ac029c4770c1cf9a671e79a5da4cfe432d87861ef91c4a4860020c0be900f3e811d0334e1da9049202e8647feef9efc007cf7be7be0b5f3c136bd4f0818573147a7ce71230af0a0a8e2f510313a43f2f010a762841c01a3a604ffcf3c6976ffef910081f861d12ac0fa61ee999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999 74 61
aa55ffffed000e00c1020901010c01000d01000e0100140301090a11040001000a120745000e00c400001381c447de81f07d69d74a6ca1cf32e200034ccb761f5c4d3ed6d402cfda8057f0ac029c4770c1cf9a671e79a5da4cfe432d87861ef91c4a4860020c0be900f3e811d0334e1da9049202e8647feef9efc007cf7be7be0b5f3c136bd4f0818573147a7ce71230af0a0a8e2f510313a43f2f010a762841c01a3a604ffcf3c6976f9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999 EE 6a
aa55ffffed000e00c1020901010c01000d01000e0100140301090a11040001000a120745000e00c400001381c447de81f07d69d74a6ca1cf32e200034ccb761f5c4d3ed6d402cfda8057f0ac029c4770c1cf9a671e79a5da4cfe432d87861ef91c4a4860020c0be900f3e811d0334e1da9049202e8647feef9efc007cf7be7be0b5f3c136bd4f0818573147a7ce71230af0a0a8e2f510313a43f2f010a762841c01a3a604ffcf3c6976ffef910081f861999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999 8c 69
aa55ffffed000e00c1020901010c01000d01000e0100140301090a11040001000a120745000e00c400001381c447de81f07d69d74a6ca1cf32e200034ccb761f5c4d3ed6d402cfda8057f0ac029c4770c1cf9a671e79a5da4cfe432d87861ef91c4a4860020c0be900f3e811d0334e1da9049202e8647feef9efc007cf7be7be0b5f3c136bd4f0818573147a7ce71230af0a0a8e2f510313a43f2f010a762841c01a3a604ffcf3c6976ffef910081f861d12ac0fa9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999 A2 68
aa55ffffed000e00c1020901010c01000d01000e0100140301090a11040001000a120745000e00c400001381c447de81f07d69d74a6ca1cf32e200034ccb761f5c4d3ed6d402cfda8057f0ac029c4770c1cf9a671e79a5da4cfe432d87861ef91c4a4860020c0be900f3e811d0334e1da9049202e8647feef9efc007cf7be7be0b5f3c136bd4f0818573147a7ce71230af0a0a8e2f510313a43f2f010a762841c01a3a604ffcf3c6976ffef910081f861d12ac0fa61e9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999 24 68
aa55ffffed000e00c1020901010c01000d01000e0100140301090a11040001000a120745000e00c400001381c447de81f07d69d74a6ca1cf32e200034ccb761f5c4d3ed6d402cfda8057f0ac029c4770c1cf9a671e79a5da4cfe432d87861ef91c4a4860020c0be900f3e811d0334e1da9049202e8647feef9efc007cf7be7be0b5f3c136bd4f0818573147a7ce71230af0a0a8e2f510313a43f2f010a762841c01a3a604ffcf3c6976ffef910081f861d12ac0fa61ee999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999 74 61
aa55ffffed000e00c1020901010c01000d01000e0100140301090a11040001000a120745000e00c400001381c447de81f07d69d74a6ca1cf32e200034ccb761f5c4d3ed6d402cfda8057f0ac029c4770c1cf9a671e79a5da4cfe432d87861ef91c4a4860020c0be900f3e811d0334e1da9049202e8647feef9efc007cf7be7be0b5f3c136bd4f081857399999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999 7A 72
aa55ffffed000e00c1020901010c01000d01000e0100140301090a11040001000a120745000e00c400001381c499999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999 E7 7c

But here's an unmodified full capture from a proper upload of a GIF:

aa55ffff ed 000000 c1020901010c01000d01000e0100140301090a11040001000a1207 0c 000000 c40000 1381c4 47494638396160001400e66b001ad1ffe641ff23ff76ffa531ff57aa63faff0c85a351d0d523656707672b9326a312a349962f6214aed58755150553671bd5613da0a3a3681c67400d200324d58927a3356b3f0f26f553a3ff57a2241402f59f2f6b1978dd3ef54b0f543f25055c146757f9ff240614541735671e3e011c241b5254d43beb0e90b017c0eb1e6467671111b531c93aacb0d5478dbd7922541732175254872a54033f1854330956dbe0e04b9538093fc035d50d873b2f9296055422a32121 d5 53
aa55ffff ed 000100 c1020901010c01000d01000e0100140301090a11040001000a1207 0c 000100 c40000 1381c4 965f181feb6c19c95cbd3e772a8487bd3e7d1de067c9437f9f2ab0318487eb982c53f0f521f571086e8702323f3f0707ca38e0f5539ce0902a06232478244d15b7e0b03a74872a58ff3939862296aa2ebd17bd56eb4f9c379396791e87359fa302240b03435447d0d51096422403030a78343eb8bd3f0f2478244943c4c9123d3f671e42b0711f000000ffffff00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 ec 40
aa55ffff ed 000200 c1020901010c01000d01000e0100140301090a11040001000a1207 0c 000200 c40000 1381c4 000000000021ff0b4e45545343415045322e30030100000021ff0b584d502044617461584d503c3f787061636b657420626567696e3d22efbbbf222069643d2257354d304d7043656869487a7265537a4e54637a6b633964223f3e203c783a786d706d65746120786d6c6e733a783d2261646f62653a6e733a6d6574612f2220783a786d70746b3d2241646f626520584d5020436f726520372e312d633030302037392e3963636334646539332c20323032322f30332f31342d31343a30373a32322020 82 41
aa55ffff ed 000300 c1020901010c01000d01000e0100140301090a11040001000a1207 0c 000300 c40000 1381c4 202020202020223e203c7264663a52444620786d6c6e733a7264663d22687474703a2f2f7777772e77332e6f72672f313939392f30322f32322d7264662d73796e7461782d6e7323223e203c7264663a4465736372697074696f6e207264663a61626f75743d222220786d6c6e733a786d704d4d3d22687474703a2f2f6e732e61646f62652e636f6d2f7861702f312e302f6d6d2f2220786d6c6e733a73745265663d22687474703a2f2f6e732e61646f62652e636f6d2f7861702f312e302f73547970 05 47
aa55ffff ed 000400 c1020901010c01000d01000e0100140301090a11040001000a1207 0c 000400 c40000 1381c4 652f5265736f75726365526566232220786d6c6e733a786d703d22687474703a2f2f6e732e61646f62652e636f6d2f7861702f312e302f2220786d704d4d3a4f726967696e616c446f63756d656e7449443d22786d702e6469643a30396238366462632d383337352d633834312d626464372d6565336539313435376264652220786d704d4d3a446f63756d656e7449443d22786d702e6469643a41374636464635323743393831314546414341444336464642384536374235372220786d704d4d3a49 a5 44
aa55ffff ed 000500 c1020901010c01000d01000e0100140301090a11040001000a1207 0c 000500 c40000 1381c4 6e7374616e636549443d22786d702e6969643a41374636464635313743393831314546414341444336464642384536374235372220786d703a43726561746f72546f6f6c3d2241646f62652050686f746f73686f702032332e33202857696e646f777329223e203c786d704d4d3a4465726976656446726f6d2073745265663a696e7374616e636549443d22786d702e6969643a64343234643662312d306436652d643134662d623839312d613861623562613730316664222073745265663a646f6375 78 45
aa55ffff ed 000600 c1020901010c01000d01000e0100140301090a11040001000a1207 0c 000600 c40000 1381c4 6d656e7449443d2261646f62653a646f6369643a70686f746f73686f703a33633638373238642d353732372d333134342d396138302d343838323364626631353066222f3e203c2f7264663a4465736372697074696f6e3e203c2f7264663a5244463e203c2f783a786d706d6574613e203c3f787061636b657420656e643d2272223f3e01fffefdfcfbfaf9f8f7f6f5f4f3f2f1f0efeeedecebeae9e8e7e6e5e4e3e2e1e0dfdedddcdbdad9d8d7d6d5d4d3d2d1d0cfcecdcccbcac9c8c7c6c5c4c3c2c1 92 67
aa55ffff ed 000700 c1020901010c01000d01000e0100140301090a11040001000a1207 0c 000700 c40000 1381c4 c0bfbebdbcbbbab9b8b7b6b5b4b3b2b1b0afaeadacabaaa9a8a7a6a5a4a3a2a1a09f9e9d9c9b9a999897969594939291908f8e8d8c8b8a898887868584838281807f7e7d7c7b7a797877767574737271706f6e6d6c6b6a696867666564636261605f5e5d5c5b5a595857565554535251504f4e4d4c4b4a494847464544434241403f3e3d3c3b3a393837363534333231302f2e2d2c2b2a292827262524232221201f1e1d1c1b1a191817161514131211100f0e0d0c0b0a090807060504030201000021f9 f6 50
aa55ffff ed 000800 c1020901010c01000d01000e0100140301090a11040001000a1207 0c 000800 c40000 1381c4 040514006b002c0000000060001400000740806a82838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbd9d810021f904050a006b002c0a0004004c000c000007ff806b6b67114682871e0a2c0a14878e8e330b0b338230246b961a12121f873d12348e5e06068f87220c42420c22872a316baf26350505078e2001ba011d1ea68e10020209824e196b194e6b15032f8213031b1a8725520000bf175904dc 06 4b
aa55ffff ed 000900 c1020901010c01000d01000e0100140301090a11040001000a1207 0c 000900 c40000 1381c4 0418176b31215f2a21662607b4b78727011c140a0138bf8260c2c36b6419443019406b1f060ce8f46480034728ae61fbe582c19a0b360858581324840e1d2182084260cb518013879a04f0656a47922443f095c94042064b411206547020d3d10300291a2ca427c882c4352d904019237463c743bc0e150900e2574a31c188c920b246c63f411a36441b30815a0a000f74f214140503811140b900d57188e33a41edff8a78b0a24b81a905027eac893a966633840050ac114bcf27b72863dde2eab04ba4 aa 4f
aa55ffff ed 000a00 c1020901010c01000d01000e0100140301090a11040001000a1207 0c 000a00 c40000 1381c4 5d473b04f8e8b2171fcf17022b1cba294510e15f68a644c480969e624714386cb9c182e9a31ff76243a007edc811839e15ea2ec59301011b3c4f9ba2d0a1432347392449f221004b0e7acc1c4093b6460929525f0d3c30054ed0056ebf4c20d052a006020482385cb971034780c7251e2c31c5f717cd278298a531f599c98a306b30800103178ce002012efc72402d0c1620486bbbe070dc4dbc0163d923597125080d0285e2c8673c54b1c21a5498d50d69bf1811c18a2c1ec281020a70e0c812da9992 9d 55
aa55ffff 48 000b00 c1020901010c01000d01000e0100140301090a11040001000a1207 0c 000b00 c40000 13201c 25a67c20410f8e382041578e5877c80a3c3021082a1658c00a033d8100003b 2d 0e

I was able to confirm that the GIF data is valid, as it makes this animation when put together (flashing image warning):

Test GIF

There's a possibility it's missing a line or some pixels, since some of the numbers in the GIF look a bit cut-off at the bottom, but I'll troubleshoot that once this final byte situation has been dealt with.

======

I've tried making sense of the formula to calculate the last byte, but I'll admit this stuff goes a bit over my head. Yes, that includes decompiling the app with JADX, but that didn't yield any obvious answers for me.

But I do know the last byte is deterministic, clearly based on the data of the packet itself, not related to a timestamp/random value, probably not related to the frame number/length (at least not directly), and it's likely some other kind of (trivial?) checksum.

======

Sorry if this is too weird of a question, or if I'm rambling. I'd like to be able to release an open-source driver for this LED matrix. Like far too many, I think it's a neat, well-made product that's held back by its lackluster mobile app.

Let me know if I can provide any more context, perform additional tests, etc. Any help or constructive feedback would be greatly appreciated.


Solution

  • @MatthewPiercey has solved the problem and has posted information about it at https://github.com/mtpiercey/ble-led-matrix-controller and https://overscore.media/posts/reverse-engineering-a-ble-led-matrix.

    At the moment of the elaboration of this response, the linked solution is:

    def checksum_mod256(hex_string):
        return hex(sum(int(hex_string[i:i+2], 16) for i in range(0, len(hex_string), 2)) % 256).upper()[2:].zfill(2)
    

    This solution is valid although it can be greatly simplified. The checksum is a little endian 2 byte representation of the sum of the bytes of the frame.

    def checksum(frame: bytearray):
        return sum(frame).to_bytes(2, 'little')
    

    This has been verified by computing the value for the valid frames in the original question:

    from binascii import unhexlify, hexlify
    
    pkts = [
        unhexlify("aa55ffffed000000c1020901010c01000d01000e0100140301090a11040001000a12070c000000c400001381c447494638396160001400e66b001ad1ffe641ff23ff76ffa531ff57aa63faff0c85a351d0d523656707672b9326a312a349962f6214aed58755150553671bd5613da0a3a3681c67400d200324d58927a3356b3f0f26f553a3ff57a2241402f59f2f6b1978dd3ef54b0f543f25055c146757f9ff240614541735671e3e011c241b5254d43beb0e90b017c0eb1e6467671111b531c93aacb0d5478dbd7922541732175254872a54033f1854330956dbe0e04b9538093fc035d50d873b2f9296055422a32121d553"),
    ...
    ]
    
    def checksum(frame: bytearray):
        return sum(frame).to_bytes(2, 'little')
    
    for pkt in pkts:
        frame = pkt[0:-2]
        crc = pkt[-2:]
        crc_calc = checksum(frame)
        print(f'crc:   {hexlify(crc)}')
        print(f'calc:  {hexlify(crc_calc)}')
        print(f'match: {crc == crc_calc}')