├── cat.jpg ├── README.md ├── LICENSE ├── scan.py └── app.py /cat.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LynMoe/DingdangD1-PoC/HEAD/cat.jpg -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 叮当同学D1热敏打印机PoC 2 | 3 | > ⚠️ 本代码仅供学习交流,请勿用于商业用途。 4 | 5 | 需求就是远程打小纸条,咕咕机开发者平台好像没了,自己造又太粗糙,所以决定整一个现成的逆向。 6 | 7 | 商品名`叮当同学D1`,PDD很多,大概都是50左右,200dpi也还算够用,所以就选他了。(给个链接 https://mobile.yangkeduo.com/goods2.html?goods_id=215919711645 8 | 9 | ## 依赖 10 | 11 | ```bash 12 | $ pip3 install bleak 13 | ``` 14 | 15 | ## Quickstart 16 | 17 | ```bash 18 | $ python3 scan.py # 扫描你的打印机的MAC地址 19 | $ python3 app.py # 打印 20 | ``` 21 | 22 | ## 协议描述 23 | 24 | ~~看代码去吧~~ 25 | 26 | 有一些膜法还是提一下吧 27 | - `hexlen`就是图像数据转为16进制排好的行数,至于为什么+3我也不知道,实践出真知,少了会在图片末尾打乱码,然后这里发送的时候是小端模式,要倒过来发 28 | - 结束信号应该是`0x1B4A64`,逆向iOS App的结果是应该负载图像payload的后面,但是抓到的包都是新行,所以就新行吧 29 | 30 | ## 致谢 31 | 32 | - 感谢[Lakr](https://github.com/Lakr233)陪我折腾到三点,但就是这个家伙想的馊主意(°ㅂ° ╬) 33 | - 感谢【数据删除】治愈我一天还给了点灵感 34 | - 感谢学校的猫猫出镜 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Lyn Chen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /scan.py: -------------------------------------------------------------------------------- 1 | """ 2 | Service Explorer 3 | ---------------- 4 | An example showing how to access and print out the services, characteristics and 5 | descriptors of a connected GATT server. 6 | Created on 2019-03-25 by hbldh 7 | """ 8 | 9 | import sys 10 | import platform 11 | import asyncio 12 | import logging 13 | 14 | from bleak import BleakClient 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | ADDRESS = ( 19 | "24:71:89:cc:09:05" 20 | if platform.system() != "Darwin" 21 | else "1FA49315-821B-5735-4F8D-4958D73E5AD5" 22 | ) 23 | 24 | async def main(address): 25 | async with BleakClient(address) as client: 26 | logger.info(f"Connected: {client.is_connected}") 27 | 28 | for service in client.services: 29 | logger.info(f"[Service] {service}") 30 | for char in service.characteristics: 31 | if "read" in char.properties: 32 | try: 33 | value = bytes(await client.read_gatt_char(char.uuid)) 34 | logger.info( 35 | f"\t[Characteristic] {char} ({','.join(char.properties)}), Value: {value}" 36 | ) 37 | except Exception as e: 38 | logger.error( 39 | f"\t[Characteristic] {char} ({','.join(char.properties)}), Value: {e}" 40 | ) 41 | 42 | else: 43 | value = None 44 | logger.info( 45 | f"\t[Characteristic] {char} ({','.join(char.properties)}), Value: {value}" 46 | ) 47 | 48 | for descriptor in char.descriptors: 49 | try: 50 | value = bytes( 51 | await client.read_gatt_descriptor(descriptor.handle) 52 | ) 53 | logger.info(f"\t\t[Descriptor] {descriptor}) | Value: {value}") 54 | except Exception as e: 55 | logger.error(f"\t\t[Descriptor] {descriptor}) | Value: {e}") 56 | 57 | if __name__ == "__main__": 58 | logging.basicConfig(level=logging.INFO) 59 | asyncio.run(main(sys.argv[1] if len(sys.argv) == 2 else ADDRESS)) -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from PIL import Image 3 | from bleak import BleakClient 4 | 5 | ADDRESS = "1FA49315-821B-5735-4F8D-4958D73E5AD5" # for macOS 6 | # ADDRESS = "55:55:09:F0:2A:74" # for Windows 7 | 8 | CHARACTERISTIC = "0000ff02-0000-1000-8000-00805f9b34fb" 9 | 10 | # credits to https://www.emexee.com/2022/01/thermal-printer-image-converter.html 11 | # using floyd-steinberg dithering 12 | def applyDither(size, pixels): 13 | ditherBrightness = 0.35 14 | ditherContrast = 1.45 15 | 16 | def getValue(pixels, y, x): 17 | return int((pixels[x, y][0] + pixels[x, y][1] + pixels[x, y][2]) / 3) 18 | def setValue(pixels, y, x, v): 19 | pixels[x, y] = (v, v, v) 20 | def nudgeValue(pixels, y, x, v): 21 | v = int(v) 22 | pixels[x, y] = (pixels[x, y][0] + v, pixels[x, y][1] + v, pixels[x, y][2] + v) 23 | 24 | w, h = size 25 | brightness = float(ditherBrightness) 26 | contrast = float(ditherContrast) ** 2 27 | for y in range(h): 28 | for x in range(w): 29 | for i in range(3): 30 | r, g, b = pixels[x, y] 31 | arr = [r, g, b] 32 | arr[i] += (brightness - 0.5) * 256 33 | arr[i] = (arr[i] - 128) * contrast + 128 34 | arr[i] = int(min(max(arr[i], 0), 255)) 35 | pixels[x, y] = (arr[0], arr[1], arr[2]) 36 | 37 | for y in range(h): 38 | BOTTOM_ROW = y == h - 1 39 | for x in range(w): 40 | LEFT_EDGE = x == 0 41 | RIGHT_EDGE = x == w - 1 42 | i = (y * w + x) * 4 43 | level = getValue(pixels, y, x) 44 | newLevel = (level < 128) * 0 + (level >= 128) * 255 45 | setValue(pixels, y, x, newLevel) 46 | error = level - newLevel 47 | if not RIGHT_EDGE: 48 | nudgeValue(pixels, y, x + 1, error * 7 / 16) 49 | if not BOTTOM_ROW and not LEFT_EDGE: 50 | nudgeValue(pixels, y + 1, x - 1, error * 3 / 16) 51 | if not BOTTOM_ROW: 52 | nudgeValue(pixels, y + 1, x, error * 5 / 16) 53 | if not BOTTOM_ROW and not RIGHT_EDGE: 54 | nudgeValue(pixels, y + 1, x + 1, error * 1 / 16) 55 | 56 | imgBinStr = '' 57 | width = 384 58 | 59 | img = Image.open("cat.jpg") 60 | img = img.convert("RGB") 61 | img = img.resize((width, int(img.height * width / img.width)), Image.LANCZOS) 62 | pixels = img.load() 63 | applyDither(img.size, pixels) 64 | 65 | for y in range(img.size[1]): 66 | for x in range(img.size[0]): 67 | r, g, b = pixels[x, y] 68 | if r + g + b > 600: 69 | imgBinStr += '0' 70 | else: 71 | imgBinStr += '1' 72 | 73 | # start bits 74 | imgBinStr = '1' + '0' * 318 + imgBinStr 75 | 76 | # convert to hex 77 | imgHexStr = hex(int(imgBinStr, 2))[2:] 78 | 79 | def notification_handler(sender, data): 80 | print("0x{0}: {1}".format(sender, data)) 81 | # exit when complete 82 | if (data == b'\xaa'): 83 | exit() 84 | 85 | async def main(): 86 | async with BleakClient(ADDRESS) as client: 87 | # subscribe notifications 88 | await client.start_notify('0000ff01-0000-1000-8000-00805f9b34fb', notification_handler) 89 | await client.start_notify('0000ff03-0000-1000-8000-00805f9b34fb', notification_handler) 90 | await asyncio.sleep(0.2) 91 | 92 | # enable the printer(for model D1) 93 | await client.write_gatt_char(CHARACTERISTIC, bytes.fromhex('10FF40')) 94 | await client.write_gatt_char(CHARACTERISTIC, bytes.fromhex('10FFF103')) 95 | 96 | # set density (0000 for low, 0100 for normal, 0200 for high) 97 | await client.write_gatt_char(CHARACTERISTIC, bytes.fromhex('10FF10000200'.ljust(256, '0'))) 98 | 99 | # no need(maybe) 100 | for i in range(4): 101 | await client.write_gatt_char(CHARACTERISTIC, bytes.fromhex(''.ljust(256, '0'))) 102 | 103 | # magic number (a simple guess :D 104 | hexlen = hex(int(len(imgHexStr) / 96) + 3)[2:] 105 | 106 | # little-endian for the length of hex lines 107 | fronthex = hexlen 108 | endhex = '0' 109 | if (len(hexlen) > 2): 110 | fronthex = hexlen[1:3] 111 | endhex += hexlen[0:1] 112 | else: 113 | endhex += '0' 114 | # start command with data length 115 | await client.write_gatt_char(CHARACTERISTIC, bytes.fromhex(('1D7630003000' + fronthex + endhex).ljust(32, '0') + imgHexStr[0:224])) 116 | await asyncio.sleep(0.05) 117 | 118 | # send the image data in chunks 119 | for i in range(32 * 7, len(imgHexStr), 256): 120 | str = imgHexStr[i:i + 256] 121 | if (len(str) < 256): 122 | str = str.ljust(256, '0') 123 | await client.write_gatt_char(CHARACTERISTIC, bytes.fromhex(str)) 124 | await asyncio.sleep(0.035) 125 | 126 | # end signal 127 | await client.write_gatt_char(CHARACTERISTIC, bytes.fromhex('1B4A64'.rjust(256, '0'))) 128 | await client.write_gatt_char(CHARACTERISTIC, bytes.fromhex('10FFF145')) 129 | 130 | # wait for the complete notification 131 | await asyncio.sleep(6) 132 | 133 | if __name__ == "__main__": 134 | asyncio.run(main()) 135 | --------------------------------------------------------------------------------