diff --git a/README.md b/README.md index 815c22d..d68cba7 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # Led-Badge-44x11 -Upload tool for an led name tag with USB-HID interface +Upload tool for a LED name tag with USB-HID interface ![LED Mini Board](photos/blueBadge.jpg) @@ -25,7 +25,7 @@ In both configurations, the badge identifies itself on the USB as ## Warning There are many different versions of LED Badges on the market. -This one uses an USB-HID interface, while others use USB-Serial (see references below). +This one uses a USB-HID interface, while others use USB-Serial (see references below). ## Command Line Installation and Usage @@ -173,10 +173,116 @@ Example combining image and text: sudo ./led-badge-11x44.py "I:HEART2:you" +There are some options defining the default type: +- use lednamebadge.py directly: default type is 11x44 +- rename lednamebadge.py to something with '12' and use that: default type is 12x48 +- use led-badge-11x44.py: default type is 11x44 +- use led-badge-12x48.py: default type is 12x48 +For all these options you can override the default type with -t ### Animations See the gfx/starfield folder for examples. An animation of N frames is provided as an image N*48 pixels wide, for both 48 and 44 pixel wide devices. +## Usage as module + +### Writing to the device +You can use lednamebadge.py as a module in your own content creation code for writing your generated scenes to the device. + +- create the header +- append your own content +- write to device + +The method `header()` takes a number of parameters: + +- up to 8 lengths as a tuple of numbers + - each length is the number of byte-columns for the corresponding bitmap data, that is the number of bytes of the + corresponding bitmap data divided by 11 (for the 11x44 devices) respective 12 (for the 12x48-devices), where one + byte is 8 pixels wide. +- arguments comparable to the command line arguments: up to 8 speeds, modes, blink-flags, ants-flags each as tuple of + numbers, and an (optional) brightness as number. +- Optionally, you can give a timestamp as datetime. It is written to the device as part of the header, but not visible + at the devices display. + +Your own content has to be a byte array with the bitmap data for all scenes. Of course, it has to fit the given lengths. + +See the following graphic for better understanding: +![bitmap_data_onebyte.png](photos%2Fbitmap_data_onebyte.png) + +![bitmap_data_onescene.png](photos%2Fbitmap_data_onescene.png) + +For a 12x48 device there have to be 12 bytes for each byte-column instead of 11, of course. + +![bitmap_data_all.png](photos%2Fbitmap_data_all.png) + +Example: + +Let's say, you have 2 scenes, one is 11x32 pixels, one is 11x60 pixels. So, the first have 4 byte-columns and 44 bytes, +the second has to be padded with 4 empty bit-columns in the last byte-column to 11x64 pixels and has therefore +8 byte-columns and 88 bytes. + +We like to display both in mode 4, the first one with speed 3 and the second one with speed 2 and the second one shall +be displayed with ants. And we like to set the initial brightness to 50%. + +This would be achieved by these calls: + +```python +from lednamebadge import LedNameBadge + +buf = array('B') +buf.extend(LedNameBadge.header((4, 8), (3, 2), (4,), (0,), (0,1), 50)) +buf.extend(scene_one_bytes) +buf.extend(scene_two_bytes) +LedNameBadge.write(buf) +``` + +### Using the text generation + +You can also use the text/icon/graphic generation of this module to get the corresponding byte buffers. + +This is quite simple and just like with the command line usage. There is the additional option to create a bitmap just +and only from an image file by giving the filename instead of a message. + +```python +from lednamebadge import SimpleTextAndIcons + +creator = SimpleTextAndIcons() +scene_a_bitmap = creator.bitmap("Hello :HEART2: World!") +scene_b_bitmap = creator.bitmap("As you :gfx/bicycle3.png: like...") +scene_c_bitmap = creator.bitmap("gfx/starfield/starfield_020.png") +``` + +The resulting bitmaps are tuples with the byte array and the length each. These lengths can be used in header() directly +and the byte arrays can be concatenated to the header. Examle: + +```python +from lednamebadge import * + +creator = SimpleTextAndIcons() +scene_x_bitmap = creator.bitmap("Hello :HEART2: World!") +scene_y_bitmap = creator.bitmap("Complete example ahead.") +your_own_stuff = create_own_bitmap_data() + +lengths = (scene_x_bitmap[1], scene_y_bitmap[1], your_own_stuff.len) +buf = array('B') +buf.extend(LedNameBadge.header(lengths, (3,), (0,), (0,1,0), (0,0,1), 100)) +buf.extend(scene_x_bitmap[0]) +buf.extend(scene_y_bitmap[0]) +buf.extend(your_own_stuff.bytes) +LedNameBadge.write(buf) +``` + +## Development + +### Generating Plantuml graphics + +You will need PlantUML and potentially GraphViz dot to generate the diagrams from the *.puml files. + +Just run `plantuml "*.puml"` from the `photos` directory to regenerate all diagrams. + +### Running the unit tests + +Run `python run_tests.py` from the `tests` directory. + ## Related References (for USB-Serial devices) * https://github.com/Caerbannog/led-mini-board * http://zunkworks.com/projects/programmablelednamebadges/ diff --git a/lednamebadge.py b/lednamebadge.py index c176410..dd0dc4a 100755 --- a/lednamebadge.py +++ b/lednamebadge.py @@ -48,6 +48,19 @@ # v0.10, 2019-09-09, jw Support for loading monochrome images. Typos fixed. # v0.11, 2019-09-29, jw New option --brightness added. # v0.12, 2019-12-27, jw hint at pip3 -- as discussed in https://github.com/jnweiger/led-name-badge-ls32/issues/19 +# v0.13, 2023-11-14, bs modularization. +# Some comments about this big change: +# * I wanted to keep this one-python-file-for-complete-command-line-usage, but also needed to introduce importable +# classes for writing own content to the device (my upcoming GUI editor). Therefore, the file was renamed to an +# importable python file, and forwarding python files are introduced with the old file names for full +# compatibility. +# * A bit of code rearranging and cleanup was necessary for that goal, but I hope the original parts are still +# recognizable, as I tried to keep all this refactoring as less, as possible and sense-making, but did not do +# the full clean-codish refactoring. Keeping the classes in one file is part of that refactoring-omittance. +# * There is some initialization code executed in the classes not needed, if not imported. This is nagging me +# somehow, but it is acceptable, as we do not need to save every processor cycle, here :) +# * Have fun! + import argparse import os @@ -58,7 +71,7 @@ from array import array from datetime import datetime -__version = "0.12" +__version = "0.13" class SimpleTextAndIcons: @@ -225,7 +238,6 @@ class SimpleTextAndIcons: u"ÀÅÄÉÈÊËÖÔÜÛÙŸ" char_offsets = {} - pass for i in range(len(charmap)): char_offsets[charmap[i]] = 11 * i # print(i, charmap[i], char_offset[charmap[i]]) @@ -288,23 +300,25 @@ class SimpleTextAndIcons: self.bitmaps_preloaded_unused = False def add_preload_img(self, filename): + """Still used by main, but deprecated. PLease use ":"-notation for bitmap() / bitmap_text()""" self.bitmap_preloaded.append(SimpleTextAndIcons.bitmap_img(filename)) self.bitmaps_preloaded_unused = True def are_preloaded_unused(self): + """Still used by main, but deprecated. PLease use ":"-notation for bitmap() / bitmap_text()""" return self.bitmaps_preloaded_unused == True @staticmethod - def get_named_bitmaps_keys(): + def _get_named_bitmaps_keys(): return SimpleTextAndIcons.bitmap_named.keys() def bitmap_char(self, ch): - """ Returns a tuple of 11 bytes, - ch = '_' returns (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255) - The bits in each byte are horizontal, highest bit is left. + """Returns a tuple of 11 bytes, it is the bitmap data of given character. + Example: ch = '_' returns (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255). + The bits in each byte are horizontal, highest bit is left. """ if ord(ch) < 32: if ch in SimpleTextAndIcons.bitmap_builtin: @@ -318,7 +332,7 @@ class SimpleTextAndIcons: def bitmap_text(self, text): - """ returns a tuple of (buffer, length_in_byte_columns_aka_chars) + """Returns a tuple of (buffer, length_in_byte_columns_aka_chars) We preprocess the text string for substitution patterns "::" is replaced with a single ":" ":1: is replaced with CTRL-A referencing the first preloaded or loaded image. @@ -350,7 +364,10 @@ class SimpleTextAndIcons: @staticmethod def bitmap_img(file): - """ returns a tuple of (buffer, length_in_byte_columns) + """Returns a tuple of (buffer, length_in_byte_columns) representing the given image file. + It has to be an 8-bit grayscale image or a color image with 8 bit per channel. Color pixels are converted to + grayscale by arithmetic mean. Threshold for an active led is then > 127. + If the width is not a multiple on 8 it will be padded with empty pixel-columns. """ from PIL import Image @@ -383,8 +400,8 @@ class SimpleTextAndIcons: def bitmap(self, arg): - """ if arg is a valid and existing path name, we load it as an image. - Otherwise, we take it as a string. + """If arg is a valid and existing path name, we load it as an image. + Otherwise, we take it as a string (with ":"-notation, see bitmap_text()). """ if os.path.exists(arg): return SimpleTextAndIcons.bitmap_img(arg) @@ -408,7 +425,7 @@ class LedNameBadge: pyhidapi.hid_init() _have_pyhidapi = True print("Pyhidapi detected") - except Exception: + except: try: import usb.core print("Pyusb detected") @@ -429,27 +446,47 @@ class LedNameBadge: or sudo apt-get install python3-usb """) - else: # windows? + else: # windows? print("""Please with Linux or MacOS or help us implement support for """ + sys.platform) sys.exit(1) @staticmethod - def _expand_tuple(l): - l = l + (l[-1],) * (8 - len(l)) # repeat last element - return l + def _prepare_iterable(iterable, min_, max_): + try: + iterable = [min(max(x, min_), max_) for x in iterable] + iterable = tuple(iterable) + (iterable[-1],) * (8 - len(iterable)) # repeat last element + return iterable + except: + raise TypeError("Please give a list or tuple with at least one number: " + str(iterable)) @staticmethod def header(lengths, speeds, modes, blinks, ants, brightness=100, date=datetime.now()): - """ lengths[0] is the number of chars of the first text - - Speeds come in as 1..8, but are needed 0..7 here. + """Create a protocol header + * length, speeds, modes, blinks, ants are iterables with at least one element + * lengths[0] is the number of chars/byte-columns of the first text/bitmap, lengths[1] of the second, + and so on... + * len(length) should match the designated bitmap data + * speeds come in as 1..8, but will be decremented to 0..7, here. + * modes: 0..8 + * blinks and ants: 0..1 or even False..True, + * brightness, if given, is any number, but it'll be limited to 25, 50, 75, 100 (percent), here + * date, if given, is a datetime object. It will be written in the header, but is not to be seen on the + devices screen. """ - ants = LedNameBadge._expand_tuple(ants) - blinks = LedNameBadge._expand_tuple(blinks) - speeds = LedNameBadge._expand_tuple(speeds) - modes = LedNameBadge._expand_tuple(modes) + try: + lengths_sum = sum(lengths) + except: + raise TypeError("Please give a list or tuple with at least one number: " + str(lengths)) + if lengths_sum > (8192 - len(LedNameBadge._protocol_header_template)) / 11 + 1: + raise ValueError("The given lengths seem to be far too high: " + str(lengths)) + + + ants = LedNameBadge._prepare_iterable(ants, 0, 1) + blinks = LedNameBadge._prepare_iterable(blinks, 0, 1) + speeds = LedNameBadge._prepare_iterable(speeds, 1, 8) + modes = LedNameBadge._prepare_iterable(modes, 0, 8) speeds = [x - 1 for x in speeds] @@ -461,6 +498,7 @@ or h[5] = 0x20 elif brightness <= 75: h[5] = 0x10 + # else default 100% == 0x00 for i in range(8): h[6] += blinks[i] << i @@ -473,18 +511,31 @@ or h[17 + (2 * i) - 1] = lengths[i] // 256 h[17 + (2 * i)] = lengths[i] % 256 - h[38 + 0] = date.year % 100 - h[38 + 1] = date.month - h[38 + 2] = date.day - h[38 + 3] = date.hour - h[38 + 4] = date.minute - h[38 + 5] = date.second + try: + h[38 + 0] = date.year % 100 + h[38 + 1] = date.month + h[38 + 2] = date.day + h[38 + 3] = date.hour + h[38 + 4] = date.minute + h[38 + 5] = date.second + except: + raise TypeError("Please give a datetime object: " + str(date)) return h @staticmethod def write(buf): + """Write the given buffer to the device. + It has to begin with a protocol header as provided by header() and followed by the bitmap data. + In short: the bitmap data is organized in bytes with 8 horizontal pixels per byte and 11 resp. 12 + bytes per (8 pixels wide) byte-column. Then just put one byte-column after the other and one bitmap + after the other. + """ + need_padding = len(buf) % 64 + if need_padding: + buf.extend((0,) * (64 - need_padding)) + if len(buf) > 8192: print("Writing more than 8192 bytes damages the display!") sys.exit(1) @@ -522,7 +573,7 @@ or def split_to_ints(list_str): - return tuple([int(x) for x in re.split(r'[\s,]+', list_str)]) + return [int(x) for x in re.split(r'[\s,]+', list_str)] def main(): parser = argparse.ArgumentParser(formatter_class=argparse.RawDescriptionHelpFormatter, @@ -540,7 +591,7 @@ def main(): parser.add_argument('-p', '--preload', metavar='FILE', action='append', help=argparse.SUPPRESS) # "Load bitmap images. Use ^A, ^B, ^C, ... in text messages to make them visible. Deprecated, embed within ':' instead") parser.add_argument('-l', '--list-names', action='version', help="list named icons to be embedded in messages and exit", - version=':' + ': :'.join(SimpleTextAndIcons.get_named_bitmaps_keys()) + ': :: or e.g. :path/to/some_icon.png:') + version=':' + ': :'.join(SimpleTextAndIcons._get_named_bitmaps_keys()) + ': :: or e.g. :path/to/some_icon.png:') parser.add_argument('message', metavar='MESSAGE', nargs='+', help="Up to 8 message texts with embedded builtin icons or loaded images within colons(:) -- See -l for a list of builtins") parser.add_argument('--mode-help', action='version', help=argparse.SUPPRESS, version=""" @@ -590,7 +641,7 @@ def main(): else: print("Type: 11x44") - lengths = tuple([b[1] for b in msg_bitmaps]) + lengths = [b[1] for b in msg_bitmaps] speeds = split_to_ints(args.speed) modes = split_to_ints(args.mode) blinks = split_to_ints(args.blink) @@ -603,10 +654,6 @@ def main(): for msg_bitmap in msg_bitmaps: buf.extend(msg_bitmap[0]) - need_padding = len(buf) % 64 - if need_padding: - buf.extend((0,) * (64 - need_padding)) - LedNameBadge.write(buf) diff --git a/photos/bitmap_data_all.png b/photos/bitmap_data_all.png new file mode 100644 index 0000000..fbcee1d Binary files /dev/null and b/photos/bitmap_data_all.png differ diff --git a/photos/bitmap_data_descr.puml b/photos/bitmap_data_descr.puml new file mode 100644 index 0000000..7268993 --- /dev/null +++ b/photos/bitmap_data_descr.puml @@ -0,0 +1,35 @@ +@startuml bitmap_data_onebyte +salt +title One byte +{# + { most significant bit 7 + leftmost pixel } | bit 6 | ... | bit 1 | { least significant bit 0 + rightmost pixel } +} +@enduml + +@startuml bitmap_data_onescene +salt +title One scene +{# + byte 0 == 8 pixel | byte 11 == 8 pixel | byte 22 == 8 pixel | ... + byte 1 ... | byte 12 ... | byte 23 ... | ... + byte 2 ... | byte 13 ... | byte 24 ... | ... + byte 3 ... | byte 14 ... | byte 25 ... | ... + byte 4 ... | byte 15 ... | byte 26 ... | ... + byte 5 ... | byte 16 ... | byte 27 ... | ... + byte 6 ... | byte 17 ... | byte 28 ... | ... + byte 7 ... | byte 18 ... | byte 29 ... | ... + byte 8 ... | byte 19 ... | byte 30 ... | ... + byte 9 ... | byte 20 ... | byte 31 ... | ... + byte 10 ... | byte 21 ... | byte 32 ... | ... +} +@enduml + +@startuml bitmap_data_all +salt +title Complete bitmap data +{# + scene 0 == x bytes | ... | scene n == z bytes +} +@enduml diff --git a/photos/bitmap_data_onebyte.png b/photos/bitmap_data_onebyte.png new file mode 100644 index 0000000..6aca0be Binary files /dev/null and b/photos/bitmap_data_onebyte.png differ diff --git a/photos/bitmap_data_onescene.png b/photos/bitmap_data_onescene.png new file mode 100644 index 0000000..d845afd Binary files /dev/null and b/photos/bitmap_data_onescene.png differ diff --git a/tests/run_tests.py b/tests/run_tests.py new file mode 100644 index 0000000..7d6c8c7 --- /dev/null +++ b/tests/run_tests.py @@ -0,0 +1,8 @@ +import sys +import unittest + +if __name__ == '__main__': + sys.path.append("..") + suite = unittest.TestLoader().discover(".") + unittest.TextTestRunner().run(suite) + diff --git a/tests/test_lednamebadge_create.py b/tests/test_lednamebadge_create.py index 12de16a..cff662e 100644 --- a/tests/test_lednamebadge_create.py +++ b/tests/test_lednamebadge_create.py @@ -1,4 +1,3 @@ -import datetime from array import array from unittest import TestCase diff --git a/tests/test_lednamebadge_write.py b/tests/test_lednamebadge_write.py index af62a59..ac5f664 100644 --- a/tests/test_lednamebadge_write.py +++ b/tests/test_lednamebadge_write.py @@ -1,5 +1,4 @@ import datetime -from array import array from unittest import TestCase from lednamebadge import LedNameBadge as testee @@ -9,6 +8,27 @@ class Test(TestCase): def setUp(self): self.test_date = datetime.datetime(2022, 11, 13, 17, 38, 24) + def test_prepare_collection_expand(self): + self.assertEqual((1, 1, 1, 1, 1, 1, 1, 1), testee._prepare_iterable((1,), 1, 8)) + self.assertEqual((1, 2, 3, 4, 4, 4, 4, 4), testee._prepare_iterable([1, 2, 3, 4], 1, 8)) + self.assertEqual((1, 2, 3, 4, 5, 6, 7, 8), testee._prepare_iterable((1, 2, 3, 4, 5, 6, 7, 8), 1, 8)) + # Weired, but possible: + self.assertEqual(('A', 'B', 'C', 'D', 'D', 'D', 'D', 'D'), testee._prepare_iterable("ABCD", 'A', 'Z')) + self.assertEqual((True, False, True, True, True, True, True, True), testee._prepare_iterable((True, False, True), False, True)) + + def test_prepare_collection_limit(self): + self.assertEqual((1, 8, 8, 8, 8, 8, 8, 8), testee._prepare_iterable([-1, 9], 1, 8)) + # Weired, but possible: + self.assertEqual(('C', 'C', 'C', 'D', 'E', 'F', 'F', 'F'), testee._prepare_iterable("ABCDEFGH", 'C', 'F')) + self.assertEqual((True, 1, True, True, True, True, True, True), testee._prepare_iterable((True, False, True), 1, 8)) + self.assertEqual((0, False, 0, 0, 0, 0, 0, 0), testee._prepare_iterable((True, False, True), -2, 0)) + + def test_prepare_collection_type(self): + with self.assertRaises(TypeError): + testee._prepare_iterable(4, 1, 8) + with self.assertRaises(TypeError): + testee._prepare_iterable([], 1, 8) + def test_header_2msgs(self): buf = testee.header((6, 7), (5, 3), (6, 2), (0, 1), (1, 0), 75, self.test_date) self.assertEqual([119, 97, 110, 103, 0, 16, 254, 1, 70, 34, 34, 34, 34, 34, 34, 34, 0, 6, 0, 7, 0, 0, 0, 0, 0, @@ -51,3 +71,9 @@ class Test(TestCase): self.assertEqual(buf1[0:38], buf2[0:38]) self.assertEqual(buf1[38 + 6:], buf2[38 + 6:]) self.assertNotEqual(buf1[38:38 + 6], buf2[38:38 + 6]) + + def test_header_type(self): + with self.assertRaises(TypeError): + testee.header(("nan",), (4,), (4,), (0,), (0,), 80, self.test_date) + with self.assertRaises(ValueError): + testee.header((370,380), (4,), (4,), (0,), (0,), 80, self.test_date)