├── README.md ├── requirements.txt ├── testdata ├── color.png ├── exp.gif ├── exp │ ├── 1.png │ ├── 2.png │ ├── 3.png │ ├── 4.png │ ├── 5.png │ ├── 6.png │ ├── 7.png │ └── 8.png ├── exp2.gif └── skull.png └── timebox └── timebox.py /README.md: -------------------------------------------------------------------------------- 1 | # Divoom Timebox CLI 2 | Control the divoom timebox using your terminal. 3 | Thanks to derHeinz and his [divoom-aurabox code](https://github.com/derHeinz/divoom-adapter) for giving some hints on how to interpret the protocol. 4 | 5 | ## Project status 6 | This project is WIP, so not every feature might work as expected yet. Also this tools is communicating with the timebox unidirectional. 7 | This means, that no answers from the box will be processed at the moment. 8 | 9 | ## What works 10 | * Switching "screens" : "clock","temp","anim","graph","image","stopwatch","scoreboard 11 | * display images, most formats should be supported thanks to [pillow](https://github.com/python-pillow/Pillow). The images will be scaled to fit the 11x11 matrix 12 | * display animations, either load a series of images from a directory OR a GIF animation. The loaded frames will also be scaled 13 | * display clock and set 12h/24h format as well as color. There are a dozen of possebilities to describe a color, take a look at [colour](https://github.com/vaab/colour) 14 | * display temperature set °C/°F as well as the color 15 | * switch radio on/off 16 | * setting time 17 | 18 | ## What does not work (or needs improvements) 19 | * Setting the radios frequency does not yet work 20 | * animations with a shorter framelength "as usual" (I am about to investigate this) might be "glued together" resulting in two or more animations shown after each other 21 | * Error handling is bad at the moment, so be careful what you type. The CLI might not inform you about what went wrong yet. 22 | * Python3 support 23 | * ... Documentation ;) ... 24 | * ... No Tests, only tested on my Linux boxes ... 25 | 26 | ## Windows integration 27 | To use this library on Windows, you need to install a version of the [pybluez](https://github.com/pybluez/pybluez) 28 | library that contains a fix for [getpeername support](https://github.com/pybluez/pybluez/pull/201). 29 | A pre-built binary for the library supporting Python 2.7 and AMD64 can be downloaded [here](https://1drv.ms/u/s!AtLn8ELpA_G9frRzQuMKJ1QixKw). 30 | 31 | ## Future plans 32 | * split CLI and library 33 | * support text "rendering" and marquees 34 | * support everything that could be done using the "original" android app 35 | * web server providing a timebox api 36 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | click 2 | click-spinner 3 | colour 4 | pillow 5 | appdirs 6 | python-dateutil 7 | -------------------------------------------------------------------------------- /testdata/color.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScR4tCh/timebox/7c1695353da64a5f91cf02a1bb7d35a1392e7280/testdata/color.png -------------------------------------------------------------------------------- /testdata/exp.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScR4tCh/timebox/7c1695353da64a5f91cf02a1bb7d35a1392e7280/testdata/exp.gif -------------------------------------------------------------------------------- /testdata/exp/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScR4tCh/timebox/7c1695353da64a5f91cf02a1bb7d35a1392e7280/testdata/exp/1.png -------------------------------------------------------------------------------- /testdata/exp/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScR4tCh/timebox/7c1695353da64a5f91cf02a1bb7d35a1392e7280/testdata/exp/2.png -------------------------------------------------------------------------------- /testdata/exp/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScR4tCh/timebox/7c1695353da64a5f91cf02a1bb7d35a1392e7280/testdata/exp/3.png -------------------------------------------------------------------------------- /testdata/exp/4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScR4tCh/timebox/7c1695353da64a5f91cf02a1bb7d35a1392e7280/testdata/exp/4.png -------------------------------------------------------------------------------- /testdata/exp/5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScR4tCh/timebox/7c1695353da64a5f91cf02a1bb7d35a1392e7280/testdata/exp/5.png -------------------------------------------------------------------------------- /testdata/exp/6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScR4tCh/timebox/7c1695353da64a5f91cf02a1bb7d35a1392e7280/testdata/exp/6.png -------------------------------------------------------------------------------- /testdata/exp/7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScR4tCh/timebox/7c1695353da64a5f91cf02a1bb7d35a1392e7280/testdata/exp/7.png -------------------------------------------------------------------------------- /testdata/exp/8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScR4tCh/timebox/7c1695353da64a5f91cf02a1bb7d35a1392e7280/testdata/exp/8.png -------------------------------------------------------------------------------- /testdata/exp2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScR4tCh/timebox/7c1695353da64a5f91cf02a1bb7d35a1392e7280/testdata/exp2.gif -------------------------------------------------------------------------------- /testdata/skull.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScR4tCh/timebox/7c1695353da64a5f91cf02a1bb7d35a1392e7280/testdata/skull.png -------------------------------------------------------------------------------- /timebox/timebox.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | import bluetooth 5 | import math 6 | from PIL import Image 7 | from binascii import unhexlify 8 | from itertools import product 9 | from math import modf 10 | from os import listdir 11 | from os.path import isfile, join 12 | 13 | import appdirs 14 | import click 15 | import click_spinner 16 | import datetime 17 | import dateutil.parser 18 | from colour import Color 19 | 20 | APPNAME = 'timebox' 21 | VENDOR = 'scratch' 22 | 23 | # configuration directory set using "appdirs" 24 | CONFDIR = appdirs.user_data_dir('timebox', 'scr4tch') 25 | 26 | # configuration file 27 | CONFFILE = os.path.join(CONFDIR, 'known_devices') 28 | 29 | # list of known devices (found by discovery, saved by user) 30 | KNOWN_DEVICES = [] 31 | 32 | # initial connection reply 33 | TIMEBOX_HELLO = [0, 5, 72, 69, 76, 76, 79, 0] 34 | FILTERS = { 35 | 'bicubic': Image.BICUBIC, 36 | 'cubic': Image.CUBIC, 37 | 'linear': Image.LINEAR, 38 | 'bilinear': Image.BILINEAR, 39 | 'normal': Image.NORMAL, 40 | 'box': Image.BOX, 41 | 'nearest': Image.NEAREST, 42 | 'none': Image.NONE, 43 | 'hamming': Image.HAMMING, 44 | 'lanczos': Image.LANCZOS, 45 | 'antialias': Image.ANTIALIAS 46 | } 47 | 48 | 49 | class Timebox: 50 | debug = False 51 | 52 | def __init__(self, target, debug=False): 53 | self.debug = debug 54 | 55 | if(isinstance(target, bluetooth.BluetoothSocket)): 56 | self.sock = target 57 | self.addr, _ = self.sock.getpeername() 58 | else: 59 | self.sock = bluetooth.BluetoothSocket(bluetooth.RFCOMM) 60 | self.addr = target 61 | self.sock.connect((self.addr, 4)) 62 | 63 | def connect(self): 64 | if(not self.sock): 65 | self.sock.connect((self.addr, 4)) 66 | ret = self.sock.recv(256) 67 | if(self.debug): 68 | click.echo("-> %s" % [ord(c) for c in self.sock.recv(256)]) 69 | 70 | def disconnect(self): 71 | self.sock.close() 72 | 73 | def send(self, package, recv=True): 74 | if (self.debug): 75 | click.echo("-> %s" % [hex(b)[2:].zfill(2) for b in package]) 76 | self.sock.send(str(bytearray(package))) 77 | 78 | if(recv): 79 | ret = [ord(c) for c in self.sock.recv(256)] 80 | 81 | if(self.debug): 82 | click.echo("<- %s" % [hex(h)[2:].zfill(2) for h in ret]) 83 | 84 | def send_raw(self, bts): 85 | self.sock.send(bts) 86 | 87 | 88 | VIEWTYPES = { 89 | "clock": 0x00, 90 | "temp": 0x01, 91 | "off": 0x02, 92 | "anim": 0x03, 93 | "graph": 0x04, 94 | "image": 0x05, 95 | "stopwatch": 0x06, 96 | "scoreboard": 0x07 97 | } 98 | 99 | 100 | def discover(ctx, lookup_known=True, spinner=click_spinner.Spinner()): 101 | if (lookup_known and len(KNOWN_DEVICES)): 102 | if (ctx.obj['debug']): 103 | click.echo('using knwon devices to find timebox') 104 | spinner.start() 105 | discovered = [(a, 'timebox') for a in KNOWN_DEVICES] 106 | else: 107 | if (ctx.obj['debug']): 108 | click.echo('scanning for timebox') 109 | spinner.start() 110 | discovered = bluetooth.discover_devices(duration=5, lookup_names=True) 111 | 112 | if (not len(discovered)): 113 | spinner.stop() 114 | click.echo("no devices discovered") 115 | ctx.abort() 116 | else: 117 | for a, n in discovered: 118 | if (n and 'timebox' in n.lower()): 119 | click.echo('checking device %s' % a) 120 | 121 | try: 122 | sock = bluetooth.BluetoothSocket(bluetooth.RFCOMM) 123 | sock.connect((a, 4)) 124 | hello = [ord(c) for c in sock.recv(256)] 125 | 126 | if(hello == TIMEBOX_HELLO): 127 | ctx.obj['address'] = a 128 | ctx.obj['sock'] = sock 129 | break 130 | else: 131 | click.echo('invalid hello received') 132 | 133 | sock.close() 134 | except bluetooth.BluetoothError as be: 135 | pass 136 | spinner.stop() 137 | if (not 'address' in ctx.obj): 138 | if(len(KNOWN_DEVICES) and lookup_known): 139 | discover(ctx, False) 140 | else: 141 | click.echo('could not find a timebox ...') 142 | ctx.abort() 143 | else: 144 | if (ctx.obj['address'].upper() not in KNOWN_DEVICES and 145 | click.confirm('would you like to add %s to known devices [y/n]?')): 146 | with open(CONFFILE, 'a') as f: 147 | f.write(ctx.obj['address'].upper() + "\n") 148 | 149 | 150 | @click.group() 151 | @click.option('--address') 152 | @click.option('--debug', is_flag=True) 153 | @click.option('--disconnect', 'disconnect', flag_value=True, default=True) 154 | @click.option('--keepconnected', 'disconnect', flag_value=False, default=True) 155 | @click.pass_context 156 | def cli(ctx, address, debug, disconnect): 157 | ctx.obj['debug']=debug 158 | 159 | if (not address): 160 | if(not os.path.exists(CONFDIR)): 161 | os.makedirs(CONFDIR) 162 | 163 | if(os.path.exists(CONFFILE)): 164 | with open(CONFFILE, 'r') as f: 165 | for l in f.readlines(): 166 | KNOWN_DEVICES.append(l.strip().upper()) 167 | 168 | discover(ctx) 169 | else: 170 | ctx.obj['address'] = address 171 | 172 | if(debug): 173 | click.echo('connecting to %s' % ctx.obj['address']) 174 | 175 | try: 176 | if('sock' in ctx.obj): 177 | dev = connect(ctx.obj['sock'], debug) 178 | else: 179 | dev = connect(ctx.obj['address'], debug) 180 | ctx.obj['dev'] = dev 181 | 182 | return dev, disconnect 183 | except bluetooth.BluetoothError as be: 184 | click.echo('error connecting to %s\n%s' % (ctx.obj['address'], be)) 185 | 186 | ctx.abort() 187 | 188 | 189 | @cli.command(short_help='change view') 190 | @click.argument('type', nargs=1) #, help="["+",".join(VIEWTYPES)+"]") 191 | @click.pass_context 192 | def view(ctx, type): 193 | if (type in VIEWTYPES): 194 | ctx.obj['dev'].send(switch_view(type)) 195 | 196 | 197 | @cli.command(short_help='display time') 198 | @click.option('--color', nargs=1) 199 | @click.option('--ampm', is_flag=True, help="12h format am/pm") 200 | @click.pass_context 201 | def clock(ctx, color, ampm): 202 | if (color): 203 | c = color_convert(Color(color).get_rgb()) 204 | ctx.obj['dev'].send(set_time_color(c[0], c[1], c[2], 0xff, not ampm)) 205 | else: 206 | ctx.obj['dev'].send(switch_view("clock")) 207 | 208 | 209 | @cli.command(short_help='display temperature, set color') 210 | @click.option('--color', nargs=1) 211 | @click.option('--f', is_flag=True, help="fahrenheit") 212 | @click.pass_context 213 | def temp(ctx, color, f): 214 | if (color): 215 | c = color_convert(Color(color).get_rgb()) 216 | ctx.obj['dev'].send(set_temp_color(c[0], c[1], c[2], 0xff, f)) 217 | else: 218 | ctx.obj['dev'].send(switch_view("temp")) 219 | 220 | 221 | def switch_view(type): 222 | h = [0x04, 0x00, 0x45, VIEWTYPES[type]] 223 | ck1, ck2 = checksum(sum(h)) 224 | return [0x01] + mask(h) + mask([ck1, ck2]) + [0x02] 225 | 226 | 227 | # 0x01 Start of message 228 | # 0x02 End of Message 229 | # 0x03 Mask following byte 230 | 231 | def color_comp_conv(cc): 232 | cc = max(0.0, min(1.0, cc)) 233 | return int(math.floor(255 if cc == 1.0 else cc * 256.0)) 234 | 235 | 236 | def color_convert(rgb): 237 | return [color_comp_conv(c) for c in rgb] 238 | 239 | 240 | def unmask(bytes, index=0): 241 | try: 242 | index = bytes.index(0x03, index) 243 | except ValueError: 244 | return bytes 245 | 246 | _bytes = bytes[:] 247 | _bytes[index + 1] = _bytes[index + 1] - 0x03 248 | _bytes.pop(index) 249 | return unmask(_bytes, index + 1) 250 | 251 | 252 | def mask(bytes): 253 | _bytes = [] 254 | for b in bytes: 255 | if (b == 0x01): 256 | _bytes = _bytes + [0x03, 0x04] 257 | elif (b == 0x02): 258 | _bytes = _bytes + [0x03, 0x05] 259 | elif (b == 0x03): 260 | _bytes = _bytes + [0x03, 0x06] 261 | else: 262 | _bytes += [b] 263 | 264 | return _bytes 265 | 266 | 267 | def checksum(s): 268 | ck1 = s & 0x00ff 269 | ck2 = s >> 8 270 | 271 | return ck1, ck2 272 | 273 | 274 | def set_time_color(r, g, b, x=0x00, h24=True): 275 | head = [0x09, 0x00, 0x45, 0x00, 0x01 if h24 else 0x00] 276 | s = sum(head) + sum([r, g, b, x]) 277 | ck1, ck2 = checksum(s) 278 | 279 | # create message mask 0x01,0x02,0x03 280 | msg = [0x01] + mask(head) + mask([r, g, b, x]) + mask([ck1, ck2]) + [0x02] 281 | 282 | return msg 283 | 284 | 285 | def set_temp_color(r, g, b, x, f=False): 286 | head = [0x09, 0x00, 0x45, 0x01, 0x01 if f else 0x00] 287 | s = sum(head) + sum([r, g, b, x]) 288 | ck1, ck2 = checksum(s) 289 | 290 | # create message mask 0x01,0x02,0x03 291 | msg = [0x01] + mask(head) + mask([r, g, b, x]) + mask([ck1, ck2]) + [0x02] 292 | 293 | return msg 294 | 295 | def set_temp_unit(f=False): 296 | head = [0x09, 0x00, 0x45, 0x01, 0x01 if f else 0x00] 297 | ck1, ck2 = checksum(sum(head)) 298 | 299 | # create message mask 0x01,0x02,0x03 300 | msg = [0x01] + mask(head) + mask([ck1, ck2]) + [0x02] 301 | 302 | return msg 303 | 304 | 305 | def analyseImage(im): 306 | ''' 307 | Pre-process pass over the image to determine the mode (full or additive). 308 | Necessary as assessing single frames isn't reliable. Need to know the mode 309 | before processing all frames. 310 | ''' 311 | results = { 312 | 'size': im.size, 313 | 'mode': 'full', 314 | } 315 | try: 316 | while True: 317 | if im.tile: 318 | tile = im.tile[0] 319 | update_region = tile[1] 320 | update_region_dimensions = update_region[2:] 321 | if update_region_dimensions != im.size: 322 | results['mode'] = 'partial' 323 | break 324 | im.seek(im.tell() + 1) 325 | except EOFError: 326 | pass 327 | im.seek(0) 328 | return results 329 | 330 | 331 | def getFrames(im): 332 | ''' 333 | Iterate the GIF, extracting each frame. 334 | ''' 335 | mode = analyseImage(im)['mode'] 336 | 337 | p = im.getpalette() 338 | last_frame = im.convert('RGBA') 339 | 340 | try: 341 | while True: 342 | ''' 343 | If the GIF uses local colour tables, each frame will have its own palette. 344 | If not, we need to apply the global palette to the new frame. 345 | ''' 346 | if not im.getpalette(): 347 | im.putpalette(p) 348 | 349 | new_frame = Image.new('RGBA', im.size) 350 | 351 | ''' 352 | Is this file a "partial"-mode GIF where frames update a region of a different size to the entire image? 353 | If so, we need to construct the new frame by pasting it on top of the preceding frames. 354 | ''' 355 | if mode == 'partial': 356 | new_frame.paste(last_frame) 357 | 358 | new_frame.paste(im, (0, 0), im.convert('RGBA')) 359 | yield new_frame 360 | 361 | last_frame = new_frame 362 | im.seek(im.tell() + 1) 363 | except EOFError: 364 | pass 365 | 366 | 367 | def process_image(imagedata, sz=11, scale=None): 368 | img = [0] 369 | bc = 0 370 | first = True 371 | 372 | if (scale): 373 | src = imagedata.resize((sz, sz), scale) 374 | else: 375 | src = imagedata.resize((sz, sz)) 376 | 377 | for c in product(range(sz), range(sz)): 378 | y, x = c 379 | r, g, b, a = src.getpixel((x, y)) 380 | 381 | if (first): 382 | img[-1] = ((r & 0xf0) >> 4) + (g & 0xf0) if a > 32 else 0 383 | img.append((b & 0xf0) >> 4) if a > 32 else img.append(0) 384 | first = False 385 | else: 386 | img[-1] += (r & 0xf0) if a > 32 else 0 387 | img.append(((g & 0xf0) >> 4) + (b & 0xf0)) if a > 32 else img.append(0) 388 | img.append(0) 389 | first = True 390 | bc += 1 391 | return img 392 | 393 | 394 | def load_image(file, sz=11, scale=None): 395 | with Image.open(file).convert("RGBA") as imagedata: 396 | return process_image(imagedata, sz) 397 | 398 | 399 | def load_gif_frames(file, sz=11, scale=None): 400 | with Image.open(file) as imagedata: 401 | for f in getFrames(imagedata): 402 | yield process_image(f, sz, scale) 403 | 404 | 405 | def conv_image(data): 406 | # should be 11x11 px => 407 | head = [0xbd, 0x00, 0x44, 0x00, 0x0a, 0x0a, 0x04] 408 | data = data 409 | ck1, ck2 = checksum(sum(head) + sum(data)) 410 | 411 | msg = [0x01] + head + mask(data) + mask([ck1, ck2]) + [0x02] 412 | return msg 413 | 414 | 415 | def prepare_animation(frames, delay=0): 416 | head = [0xbf, 0x00, 0x49, 0x00, 0x0a, 0x0a, 0x04] 417 | 418 | ret = [] 419 | 420 | fi = 0 421 | for f in frames: 422 | _head = head + [fi, delay] 423 | ck1, ck2 = checksum(sum(_head) + sum(f)) 424 | msg = [0x01] + mask(_head) + mask(f) + mask([ck1, ck2]) + [0x02] 425 | fi += 1 426 | ret.append(msg) 427 | 428 | return ret 429 | 430 | 431 | @cli.command(short_help='display_image') 432 | @click.argument('file', nargs=1) 433 | @click.option('--scaling', type=click.Choice(FILTERS.keys()), default='bicubic') 434 | @click.pass_context 435 | def image(ctx, file, scaling): 436 | ctx.obj['dev'].send(conv_image(load_image(file, scale=FILTERS[scaling]))) 437 | 438 | 439 | @cli.command(short_help='display_animation') 440 | @click.option('--gif', 'source', flag_value='gif') 441 | @click.option('--folder', 'source', flag_value='folder', default=True) 442 | @click.option('--delay', nargs=1) 443 | @click.argument('path', nargs=1) 444 | @click.option('--scaling', type=click.Choice(FILTERS.keys()), default='bicubic') 445 | @click.pass_context 446 | def animation(ctx, source, path, delay, scaling): 447 | frames = [] 448 | 449 | if (source == "folder"): 450 | for f in listdir(path): 451 | f = join(path, f) 452 | if isfile(f): 453 | frames.append(load_image(f)) 454 | elif (source == "gif"): 455 | for f in load_gif_frames(path, 11, scale=FILTERS[scaling]): 456 | frames.append(f) 457 | 458 | i = 0 459 | for f in prepare_animation(frames, delay=int(delay) if delay else 0): 460 | i = i + 1 461 | if(i==len(frames)): 462 | ctx.obj['dev'].send(f) 463 | else: 464 | ctx.obj['dev'].send(f, False) 465 | 466 | 467 | # TODO: a bit weird, if the animation has "less frames than usual", it might be "glued" to the previous ;) 468 | @cli.command(short_help='control fmradio') 469 | @click.option('--on', 'state', flag_value=True, default=True) 470 | @click.option('--off', 'state', flag_value=False) 471 | @click.option('--frequency', nargs=1) 472 | @click.pass_context 473 | def fmradio(ctx, state, frequency): 474 | if (state): 475 | ctx.obj['dev'].send([0x01] + mask([0x04, 0x00, 0x05, 0x01, 0x0a, 0x00]) + [0x02]) 476 | if (frequency): 477 | # TODO: WIP! setting frequency does not yet work as expected 478 | frequency = float(frequency) 479 | head = [0x05, 0x00] 480 | frac, whole = modf(frequency) 481 | frac = int(round(frac, 1) * 10) 482 | whole = (int(whole)) 483 | f = [whole, frac] 484 | 485 | ff = (frac & 0xF0) + (whole << 8) 486 | print(ff) 487 | 488 | #print f 489 | #print mask(f) 490 | ck1, ck2 = checksum(sum(head) + sum(f)) 491 | #print [ck1, ck2] 492 | #print mask([ck1, ck2]) 493 | ctx.obj['dev'].send([0x01] + head + mask(f) + mask([ck1, ck2]) + [0x02]) 494 | else: 495 | ctx.obj['dev'].send([0x01] + mask([0x04, 0x00, 0x05, 0x00, 0x09, 0x00]) + [0x02]) 496 | 497 | 498 | @cli.command(short_help='set volume') 499 | @click.argument('level', nargs=1, type=click.IntRange(0, 16)) 500 | @click.pass_context 501 | def volume(ctx, level): 502 | head = [0x04, 0x00, 0x08] 503 | ck1, ck2 = checksum(sum(head) + level) 504 | ctx.obj['dev'].send([0x01] + head + mask([level]) + mask([ck1, ck2]) + [0x02]) 505 | 506 | 507 | @cli.command(short_help='set time') 508 | @click.argument('date', nargs=1) 509 | @click.pass_context 510 | def settime(ctx, date): 511 | if(date == "now"): 512 | dt = datetime.datetime.now() 513 | else: 514 | try: 515 | dt = dateutil.parser.parse(date) 516 | except: 517 | click.echo("could not parse date \"%s\"" % date) 518 | return 519 | 520 | head = [0x0A, 0x00, 0x18, dt.year%100, int(dt.year/100), dt.month, dt.day, dt.hour, dt.minute, dt.second ] 521 | s = sum(head) 522 | ck1, ck2 = checksum(s) 523 | ctx.obj['dev'].send([0x01]+mask(head)+mask([ck1,ck2])+[0x02]) 524 | 525 | 526 | @cli.command(short_help='raw message') 527 | @click.option('--mask', '_mask', is_flag=True) 528 | @click.argument('hexbytes', nargs=1) 529 | @click.pass_context 530 | def raw(ctx, hexbytes, _mask): 531 | if (_mask): 532 | ctx.obj['dev'].send(bytearray(mask(unhexlify(hexbytes)))) 533 | else: 534 | ctx.obj['dev'].send(bytearray(unhexlify(hexbytes))) 535 | 536 | 537 | def connect(target, debug): 538 | dev = Timebox(target, debug) 539 | dev.connect() 540 | 541 | return dev 542 | 543 | 544 | if __name__ == '__main__': 545 | import sys 546 | dev, disconnect = cli(sys.argv[1:], obj={}) 547 | if (disconnect): 548 | dev.disconnect() 549 | --------------------------------------------------------------------------------