├── README.md ├── pixoo.py └── requirements.txt /README.md: -------------------------------------------------------------------------------- 1 | Divoom Pixoo client for Python3 2 | =============================== 3 | 4 | This small python script provides a way to communicate with a Divoom Pixoo 5 | over Bluetooth. 6 | 7 | This script provides a class able to manage a Pixoo, but you need to create your 8 | own code to make it work. 9 | 10 | Dependencies 11 | ------------ 12 | 13 | Use a third-party software to bind your computer with your pixoo (BlueZ + blueman-applet for instance). 14 | Then you may use this python class to manage your Pixoo. 15 | 16 | How to use this class 17 | --------------------- 18 | 19 | This class provides many methods to connect and manage a Pixoo device. 20 | 21 | * `connect()̀`: creates a connection with the device and keeps it open while the script is active 22 | * `draw_pic()`: draws a picture (resized to 16x16 pixels) from a PNG file 23 | * `draw_anim()`: displays an animation on the Pixoo based on a GIF file (16x16 pixels) 24 | * `set_system_brightness`: set the global brightness to a specific level (0-100) 25 | 26 | 27 | -------------------------------------------------------------------------------- /pixoo.py: -------------------------------------------------------------------------------- 1 | """ 2 | Pixoo 3 | """ 4 | 5 | import sys 6 | import socket 7 | from time import sleep 8 | from PIL import Image 9 | from binascii import unhexlify, hexlify 10 | from math import log10, ceil 11 | 12 | class Pixoo(object): 13 | 14 | CMD_SET_SYSTEM_BRIGHTNESS = 0x74 15 | CMD_SPP_SET_USER_GIF = 0xb1 16 | CMD_DRAWING_ENCODE_PIC = 0x5b 17 | 18 | BOX_MODE_CLOCK=0 19 | BOX_MODE_TEMP=1 20 | BOX_MODE_COLOR=2 21 | BOX_MODE_SPECIAL=3 22 | 23 | instance = None 24 | 25 | def __init__(self, mac_address): 26 | """ 27 | Constructor 28 | """ 29 | self.mac_address = mac_address 30 | self.btsock = None 31 | 32 | 33 | @staticmethod 34 | def get(): 35 | if Pixoo.instance is None: 36 | Pixoo.instance = Pixoo(Pixoo.BDADDR) 37 | Pixoo.instance.connect() 38 | return Pixoo.instance 39 | 40 | def connect(self): 41 | """ 42 | Connect to SPP. 43 | """ 44 | self.btsock = socket.socket(socket.AF_BLUETOOTH, socket.SOCK_STREAM, socket.BTPROTO_RFCOMM) 45 | self.btsock.connect((self.mac_address, 1)) 46 | 47 | 48 | def __spp_frame_checksum(self, args): 49 | """ 50 | Compute frame checksum 51 | """ 52 | return sum(args[1:])&0xffff 53 | 54 | 55 | def __spp_frame_encode(self, cmd, args): 56 | """ 57 | Encode frame for given command and arguments (list). 58 | """ 59 | payload_size = len(args) + 3 60 | 61 | # create our header 62 | frame_header = [1, payload_size & 0xff, (payload_size >> 8) & 0xff, cmd] 63 | 64 | # concatenate our args (byte array) 65 | frame_buffer = frame_header + args 66 | 67 | # compute checksum (first byte excluded) 68 | cs = self.__spp_frame_checksum(frame_buffer) 69 | 70 | # create our suffix (including checksum) 71 | frame_suffix = [cs&0xff, (cs>>8)&0xff, 2] 72 | 73 | # return output buffer 74 | return frame_buffer+frame_suffix 75 | 76 | 77 | def send(self, cmd, args): 78 | """ 79 | Send data to SPP. 80 | """ 81 | spp_frame = self.__spp_frame_encode(cmd, args) 82 | if self.btsock is not None: 83 | nb_sent = self.btsock.send(bytes(spp_frame)) 84 | 85 | 86 | def set_system_brightness(self, brightness): 87 | """ 88 | Set system brightness. 89 | """ 90 | self.send(Pixoo.CMD_SET_SYSTEM_BRIGHTNESS, [brightness&0xff]) 91 | 92 | 93 | def set_box_mode(self, boxmode, visual=0, mode=0): 94 | """ 95 | Set box mode. 96 | """ 97 | self.send(0x45, [boxmode&0xff, visual&0xff, mode&0xff]) 98 | 99 | 100 | def set_color(self, r,g,b): 101 | """ 102 | Set color. 103 | """ 104 | self.send(0x6f, [r&0xff, g&0xff, b&0xff]) 105 | 106 | def encode_image(self, filepath): 107 | img = Image.open(filepath) 108 | return self.encode_raw_image(img) 109 | 110 | def encode_raw_image(self, img): 111 | """ 112 | Encode a 16x16 image. 113 | """ 114 | # ensure image is 16x16 115 | w,h = img.size 116 | if w == h: 117 | # resize if image is too big 118 | if w > 16: 119 | img = img.resize((16,16)) 120 | 121 | # create palette and pixel array 122 | pixels = [] 123 | palette = [] 124 | for y in range(16): 125 | for x in range(16): 126 | pix = img.getpixel((x,y)) 127 | 128 | if len(pix) == 4: 129 | r,g,b,a = pix 130 | elif len(pix) == 3: 131 | r,g,b = pix 132 | if (r,g,b) not in palette: 133 | palette.append((r,g,b)) 134 | idx = len(palette)-1 135 | else: 136 | idx = palette.index((r,g,b)) 137 | pixels.append(idx) 138 | 139 | # encode pixels 140 | bitwidth = ceil(log10(len(palette))/log10(2)) 141 | nbytes = ceil((256*bitwidth)/8.) 142 | encoded_pixels = [0]*nbytes 143 | 144 | encoded_pixels = [] 145 | encoded_byte = '' 146 | for i in pixels: 147 | encoded_byte = bin(i)[2:].rjust(bitwidth, '0') + encoded_byte 148 | if len(encoded_byte) >= 8: 149 | encoded_pixels.append(encoded_byte[-8:]) 150 | encoded_byte = encoded_byte[:-8] 151 | encoded_data = [int(c, 2) for c in encoded_pixels] 152 | encoded_palette = [] 153 | for r,g,b in palette: 154 | encoded_palette += [r,g,b] 155 | return (len(palette), encoded_palette, encoded_data) 156 | else: 157 | print('[!] Image must be square.') 158 | 159 | def draw_gif(self, filepath, speed=100): 160 | """ 161 | Parse Gif file and draw as animation. 162 | """ 163 | # encode frames 164 | frames = [] 165 | timecode = 0 166 | anim_gif = Image.open(filepath) 167 | for n in range(anim_gif.n_frames): 168 | anim_gif.seek(n) 169 | nb_colors, palette, pixel_data = self.encode_raw_image(anim_gif.convert(mode='RGB')) 170 | frame_size = 7 + len(pixel_data) + len(palette) 171 | frame_header = [0xAA, frame_size&0xff, (frame_size>>8)&0xff, timecode&0xff, (timecode>>8)&0xff, 0, nb_colors] 172 | frame = frame_header + palette + pixel_data 173 | frames += frame 174 | timecode += speed 175 | 176 | # send animation 177 | nchunks = ceil(len(frames)/200.) 178 | total_size = len(frames) 179 | for i in range(nchunks): 180 | chunk = [total_size&0xff, (total_size>>8)&0xff, i] 181 | self.send(0x49, chunk+frames[i*200:(i+1)*200]) 182 | 183 | 184 | def draw_anim(self, filepaths, speed=100): 185 | timecode=0 186 | 187 | # encode frames 188 | frames = [] 189 | n=0 190 | for filepath in filepaths: 191 | nb_colors, palette, pixel_data = self.encode_image(filepath) 192 | frame_size = 7 + len(pixel_data) + len(palette) 193 | frame_header = [0xAA, frame_size&0xff, (frame_size>>8)&0xff, timecode&0xff, (timecode>>8)&0xff, 0, nb_colors] 194 | frame = frame_header + palette + pixel_data 195 | frames += frame 196 | timecode += speed 197 | n += 1 198 | 199 | # send animation 200 | nchunks = ceil(len(frames)/200.) 201 | total_size = len(frames) 202 | for i in range(nchunks): 203 | chunk = [total_size&0xff, (total_size>>8)&0xff, i] 204 | self.send(0x49, chunk+frames[i*200:(i+1)*200]) 205 | 206 | 207 | def draw_pic(self, filepath): 208 | """ 209 | Draw encoded picture. 210 | """ 211 | nb_colors, palette, pixel_data = self.encode_image(filepath) 212 | frame_size = 7 + len(pixel_data) + len(palette) 213 | frame_header = [0xAA, frame_size&0xff, (frame_size>>8)&0xff, 0, 0, 0, nb_colors] 214 | frame = frame_header + palette + pixel_data 215 | prefix = [0x0, 0x0A,0x0A,0x04] 216 | self.send(0x44, prefix+frame) 217 | 218 | 219 | class PixooMax(Pixoo): 220 | """ 221 | PixooMax class, derives from Pixoo but does not support animation yet. 222 | """ 223 | 224 | def __init__(self, mac_address): 225 | super().__init__(mac_address) 226 | 227 | def draw_pic(self, filepath): 228 | """ 229 | Draw encoded picture. 230 | """ 231 | nb_colors, palette, pixel_data = self.encode_image(filepath) 232 | frame_size = 8 + len(pixel_data) + len(palette) 233 | frame_header = [0xAA, frame_size&0xff, (frame_size>>8)&0xff, 0, 0, 3, nb_colors&0xff, (nb_colors>>8)&0xff] 234 | frame = frame_header + palette + pixel_data 235 | prefix = [0x0, 0x0A,0x0A,0x04] 236 | self.send(0x44, prefix+frame) 237 | 238 | def draw_gif(self, filepath, speed=100): 239 | raise 'NotYetImplemented' 240 | 241 | def draw_anim(self, filepaths, speed=100): 242 | raise 'NotYetImplemented' 243 | 244 | def encode_image(self, filepath): 245 | img = Image.open(filepath) 246 | img = img.convert(mode="P", palette=Image.ADAPTIVE, colors=256).convert(mode="RGB") 247 | return self.encode_raw_image(img) 248 | 249 | def encode_raw_image(self, img): 250 | """ 251 | Encode a 32x32 image. 252 | """ 253 | # ensure image is 32x32 254 | w,h = img.size 255 | if w == h: 256 | # resize if image is too big 257 | if w > 32: 258 | img = img.resize((32,32)) 259 | 260 | # create palette and pixel array 261 | pixels = [] 262 | palette = [] 263 | for y in range(32): 264 | for x in range(32): 265 | pix = img.getpixel((x,y)) 266 | 267 | if len(pix) == 4: 268 | r,g,b,a = pix 269 | elif len(pix) == 3: 270 | r,g,b = pix 271 | if (r,g,b) not in palette: 272 | palette.append((r,g,b)) 273 | idx = len(palette)-1 274 | else: 275 | idx = palette.index((r,g,b)) 276 | pixels.append(idx) 277 | 278 | # encode pixels 279 | bitwidth = ceil(log10(len(palette))/log10(2)) 280 | nbytes = ceil((256*bitwidth)/8.) 281 | encoded_pixels = [0]*nbytes 282 | 283 | encoded_pixels = [] 284 | encoded_byte = '' 285 | 286 | # Create our pixels bitstream 287 | for i in pixels: 288 | encoded_byte = bin(i)[2:].rjust(bitwidth, '0') + encoded_byte 289 | 290 | # Encode pixel data 291 | while len(encoded_byte) >= 8: 292 | encoded_pixels.append(encoded_byte[-8:]) 293 | encoded_byte = encoded_byte[:-8] 294 | 295 | # If some bits left, pack and encode 296 | padding = 8 - len(encoded_byte) 297 | encoded_pixels.append(encoded_byte.rjust(bitwidth, '0')) 298 | 299 | # Convert into array of 8-bit values 300 | encoded_data = [int(c, 2) for c in encoded_pixels] 301 | encoded_palette = [] 302 | for r,g,b in palette: 303 | encoded_palette += [r,g,b] 304 | return (len(palette), encoded_palette, encoded_data) 305 | else: 306 | print('[!] Image must be square.') 307 | 308 | if __name__ == '__main__': 309 | if len(sys.argv) >= 3: 310 | pixoo_baddr = sys.argv[1] 311 | img_path = sys.argv[2] 312 | 313 | pixoo = PixooMax(pixoo_baddr) 314 | pixoo.connect() 315 | 316 | # mandatory to wait at least 1 second 317 | sleep(1) 318 | 319 | # draw image 320 | pixoo.draw_pic(img_path) 321 | else: 322 | print('Usage: %s ' % sys.argv[0]) 323 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Pillow --------------------------------------------------------------------------------