Rearranging code: cleanup, documentation, version number increment.

rebase020124
Ben 6 months ago
parent 2d96e0d41a
commit 012d4415bd
  1. 110
      README.md
  2. 117
      lednamebadge.py
  3. BIN
      photos/bitmap_data_all.png
  4. 35
      photos/bitmap_data_descr.puml
  5. BIN
      photos/bitmap_data_onebyte.png
  6. BIN
      photos/bitmap_data_onescene.png
  7. 8
      tests/run_tests.py
  8. 1
      tests/test_lednamebadge_create.py
  9. 28
      tests/test_lednamebadge_write.py

@ -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"
</pre>
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/

@ -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)

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

@ -0,0 +1,8 @@
import sys
import unittest
if __name__ == '__main__':
sys.path.append("..")
suite = unittest.TestLoader().discover(".")
unittest.TextTestRunner().run(suite)

@ -1,4 +1,3 @@
import datetime
from array import array
from unittest import TestCase

@ -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)

Loading…
Cancel
Save