From fb96136b9c96be6facadb611ebeb847f54098093 Mon Sep 17 00:00:00 2001 From: Shabbar Vejlani Date: Mon, 15 Sep 2025 00:25:27 -0700 Subject: [PATCH] Added support for badge variant with vid=204c, pid=4359 - Only static text/bitmap which fits in 11x44 supported for now - Added logic to automatically detect which vid/pid combination badge is selected --- lednamebadge.py | 243 ++++++++++++++++++----- tests/test_lednamebadge_api.py | 7 +- tests/test_lednamebadge_select_method.py | 2 +- 3 files changed, 202 insertions(+), 50 deletions(-) diff --git a/lednamebadge.py b/lednamebadge.py index 757b30d..5f7713e 100755 --- a/lednamebadge.py +++ b/lednamebadge.py @@ -85,7 +85,8 @@ # * Preparation for further or updated write methods, like bluetooth. # * Automatic or manual write method and device selection, See -M and -D (substituting -H) resp. # get_available_methods() and get_available_device_ids(). - +# v0.15, 2025-09-15, detection is now fully automatic. +# * Supporting only static text and bitmap for now import argparse import os @@ -96,7 +97,7 @@ from array import array from datetime import datetime -__version = "0.14" +__version = "0.15" class SimpleTextAndIcons: @@ -459,13 +460,13 @@ class WriteMethod: """ raise NotImplementedError() - def open(self, device_id): + def open(self, device_id, vid, pid): """Opens the communication channel to the device, similar to open a file. The device id is one of the ids returned by get_available_devices() or 'auto', which selects just the first device in that dict. It is the common part of the opening process. The concrete open is done in _open() and is to be implemented individually. """ - if self.is_ready() and self.is_device_present(): + if self.is_ready() and self.is_device_present(vid, pid): actual_device_id = None if device_id == 'auto': actual_device_id = sorted(self.devices.keys())[0] @@ -483,20 +484,22 @@ class WriteMethod: """ raise NotImplementedError() - def get_available_devices(self): + def get_available_devices(self, vid, pid): """Get all devices available via the concrete write method. It returns a dict with the device ids as keys and the device descriptions as values. These device ids are used with 'open()' to specify the wanted device. It the common part of this process. The concrete part is to be implemented in _get_available_devices() individually. """ if self.is_ready() and not self.devices: - self.devices = self._get_available_devices() + self.devices = self._get_available_devices(vid, pid) return {did: data[0] for did, data in self.devices.items()} - def is_device_present(self): + def is_device_present(self, vid, pid): """Returns True if there is one or more devices available via the concrete write method, False otherwise. """ - self.get_available_devices() + # Clear previous device cache if VID/PID changes + self.devices = {} + self.get_available_devices(vid, pid) return self.devices and len(self.devices) > 0 def _open(self, device_id): @@ -508,7 +511,7 @@ class WriteMethod: """ raise NotImplementedError() - def _get_available_devices(self): + def _get_available_devices(self, vid, pid): """The concrete get-the-list action. This method is to be implemented in your concrete class. It shall Return a dict with one entry per available device. The key of an entry is the device id, like it will be used in open() / _open(). The value af an entry is a tuple with any data according to the needs of your @@ -603,8 +606,8 @@ class WriteLibUsb(WriteMethod): self.dev = None self.endpoint = None - def _get_available_devices(self): - devs = WriteLibUsb.usb.core.find(idVendor=0x0416, idProduct=0x5020, find_all=True) + def _get_available_devices(self, vid, pid): + devs = WriteLibUsb.usb.core.find(idVendor=vid, idProduct=pid, find_all=True) devices = {} for d in devs: try: @@ -705,8 +708,8 @@ class WriteUsbHidApi(WriteMethod): self.path = None self.dev = None - def _get_available_devices(self): - device_infos = WriteUsbHidApi.pyhidapi.hid_enumerate(0x0416, 0x5020) + def _get_available_devices(self, vid, pid): + device_infos = WriteUsbHidApi.pyhidapi.hid_enumerate(vid, pid) devices = {} for d in device_infos: did = "%s" % (str(d.path.decode('ascii')),) @@ -726,6 +729,7 @@ class WriteUsbHidApi(WriteMethod): print("Write using [%s] via hidapi" % (self.description,)) for i in range(int(len(buf) / 64)): + time.sleep(0.1) # sendbuf must contain "report ID" as first byte. "0" does the job here. sendbuf = array('B', [0]) # Then, put the 64 payload bytes into the buffer @@ -773,7 +777,7 @@ class WriteSerial(WriteMethod): self.path = None self.dev = None - def _get_available_devices(self): + def _get_available_devices(self, vid, pid): import serial.tools.list_ports ports = serial.tools.list_ports.comports() @@ -799,12 +803,25 @@ class WriteSerial(WriteMethod): class LedNameBadge: - _protocol_header_template = ( + _original_protocol_header_template = ( 0x77, 0x61, 0x6e, 0x67, 0x00, 0x00, 0x00, 0x00, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 ) + # New static packet definitions for custom logic + _header_packet_1 = array('B', [0x4c, 0x43, 0x59, 0xf2] + [0x00] * 60) + _header_packet_2 = array('B', [0x4c, 0x43, 0x59, 0xdd] + [0x00] * 60) + _header_packet_3 = array('B', [ + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x01, 0x02, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0xc8, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff] + ) + _header_packet_4 = array('B', [0xff] * 64) + _header_packet_5 = array('B', [0xff] * 64) + _footer_packet_1 = array('B', [0x00] * 64) + _footer_packet_2 = array('B', [0x4c, 0x43, 0x59, 0x99] + [0x00] * 60) @staticmethod def header(lengths, speeds, modes, blinks, ants, brightness=100, date=datetime.now()): @@ -824,7 +841,7 @@ class LedNameBadge: 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: + if lengths_sum > (8192 - len(LedNameBadge._original_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) @@ -834,7 +851,7 @@ class LedNameBadge: speeds = [x - 1 for x in speeds] - h = list(LedNameBadge._protocol_header_template) + h = list(LedNameBadge._original_protocol_header_template) if brightness <= 25: h[5] = 0x40 @@ -877,7 +894,7 @@ class LedNameBadge: raise TypeError("Please give a list or tuple with at least one number: " + str(iterable)) @staticmethod - def write(buf, method='auto', device_id='auto'): + def write(buf, method='auto', device_id='auto', vid=0x0416, pid=0x5020): """Write the given buffer to the given 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 @@ -888,7 +905,7 @@ class LedNameBadge: will print the implemented / available write methods resp. the available devices, 'auto' (default) will choose an appropriate write method resp. the first device found. """ - write_method = LedNameBadge._find_write_method(method, device_id) + write_method = LedNameBadge._find_write_method(method, device_id, vid, pid) if write_method: write_method.write(buf) write_method.close() @@ -905,18 +922,18 @@ class LedNameBadge: return {m.get_name(): (m.get_description(), m.is_ready()) for m in auto_order_methods} @staticmethod - def get_available_device_ids(method): + def get_available_device_ids(method, vid, pid): """Returns all devices available via the given write method as a dict. Each entry has the device id as the key and the device description as the value. The device id can be used as a parameter value for write(). """ auto_order_methods = LedNameBadge._get_auto_order_method_list() wanted_method = [m for m in auto_order_methods if m.get_name() == method] if wanted_method: - return wanted_method[0].get_available_devices() + return wanted_method[0].get_available_devices(vid, pid) return [] @staticmethod - def _find_write_method(method, device_id): + def _find_write_method(method, device_id, vid, pid): """Here we try to concentrate all special cases, decisions and messages around the manual or automatic selection of write methods and device. This way it is a bit easier to extend or modify the different working run time environments (think of operating system, python version, installed libraries and python @@ -926,12 +943,12 @@ class LedNameBadge: libusb = [m for m in auto_order_methods if m.get_name() == 'libusb'][0] if method == 'list': - LedNameBadge._print_available_methods(auto_order_methods) + LedNameBadge._print_available_methods(auto_order_methods, vid, pid) sys.exit(0) if method not in [m.get_name() for m in auto_order_methods] and method != 'auto': print("Unknown write method '%s'." % (method,)) - LedNameBadge._print_available_methods(auto_order_methods) + LedNameBadge._print_available_methods(auto_order_methods, vid, pid) sys.exit(1) if method == 'auto': @@ -988,9 +1005,9 @@ class LedNameBadge: if not first_method_found: first_method_found = m if device_id == 'list': - LedNameBadge._print_available_devices(m) + LedNameBadge._print_available_devices(m, vid, pid) sys.exit(0) - elif m.open(device_id): + elif m.open(device_id, vid, pid): return m device_id_str = '' @@ -999,8 +1016,8 @@ class LedNameBadge: print("The device is not available with write method '%s'%s." % (method, device_id_str)) if first_method_found: - LedNameBadge._print_available_devices(first_method_found) - print("* Is a led tag device with vendorID 0x0416 and productID 0x5020 connected?") + LedNameBadge._print_available_devices(first_method_found, vid, pid) + print("* Is a led tag device with vendorID %#04x and productID %#04x connected?" % (vid, pid)) if device_id != 'auto': print("* Have you given the right device_id?") print(" Find the available device ids with option -D list") @@ -1013,7 +1030,7 @@ class LedNameBadge: return [WriteUsbHidApi(), WriteLibUsb(), WriteSerial()] @staticmethod - def _print_available_methods(methods): + def _print_available_methods(methods, vid, pid): print("Available write methods:") print(" 'auto': selects the most appropriate of the available methods (default)") for m in methods: @@ -1024,10 +1041,10 @@ class LedNameBadge: print(" '%s': %s" % (m.get_name(), m.get_description())) @staticmethod - def _print_available_devices(method_obj): - if method_obj.is_device_present(): + def _print_available_devices(method_obj, vid, pid): + if method_obj.is_device_present(vid, pid): print("Known device ids with method '%s' are:" % (method_obj.get_name(),)) - for did, descr in sorted(method_obj.get_available_devices().items()): + for did, descr in sorted(method_obj.get_available_devices(vid, pid).items()): LedNameBadge._print_one_device(did, descr) else: print("No devices with method '%s' found." % (method_obj.get_name(),)) @@ -1091,6 +1108,103 @@ class LedNameBadge: print("* Best: add a udev rule like described in README.md.") +def get_pixel_map(msg_bitmaps): + """Converts the original column-major byte buffer into a 2D pixel map.""" + if not msg_bitmaps: + return [], 0, 0 + + total_width = sum(b[1] for b in msg_bitmaps) * 8 + height = 11 + pixel_map = [[0] * total_width for _ in range(height)] + + current_x = 0 + for buf, byte_cols in msg_bitmaps: + for i in range(byte_cols): + for row in range(height): + byte_val = buf[i * height + row] + for bit in range(8): + if (byte_val >> (7 - bit)) & 1: + pixel_map[row][current_x + bit] = 1 + current_x += 8 + return pixel_map, total_width, height + + +def encode_custom_logic(pixel_map, width, height): + """ + Encodes a 2D pixel map into the custom byte sequence. + Processes the map in 2-column chunks, using 3 bytes of data for each chunk. + """ + output_buf = array('B') + if width == 0 or height == 0: + return output_buf, 0 + + # Pad width to be a multiple of 2 + if width % 2 != 0: + width += 1 + for row in pixel_map: + row.append(0) + + # Iterate through the pixel map in 2-column wide chunks + for col_chunk_start in range(0, width, 2): + # Each chunk is represented by 3 bytes (24 bits) of data + bits = [] + # This chunk requires 22 bits for rendering (11 rows * 2 bits/row) + for row in range(height): + p1 = pixel_map[row][col_chunk_start] + # Handle edge case for the last column if width is odd + p2 = pixel_map[row][col_chunk_start + 1] if col_chunk_start + 1 < width else 0 + bits.extend([p1, p2]) + + if len(bits) < 22: + bits.extend([0] * (22 - len(bits))) # Pad if height < 11 + + # Pad with 2 discard bits to make it 24 bits (3 bytes) + bits.extend([0, 0]) + + # Convert the 24 bits into 3 bytes + for i in range(0, 24, 8): + byte_val = 0 + for bit_index in range(8): + if bits[i + bit_index] == 1: + byte_val |= 1 << (7 - bit_index) + output_buf.append(byte_val) + + # For the custom logic, the "length" in the header could mean the number + # of 2-column chunks, which is equivalent to the number of 3-byte chunks. + num_chunks = len(output_buf) // 3 + return output_buf, num_chunks + + +def find_supported_device(method_name): + """ + Scans for known devices and returns the properties of the first one found. + """ + KNOWN_DEVICES = [ + {'vid': 0x204c, 'pid': 0x4359, 'logic': 'custom'}, + {'vid': 0x0416, 'pid': 0x5020, 'logic': 'original'}, + ] + + possible_methods = LedNameBadge._get_auto_order_method_list() + + # If the user forced a method, only check that one. + if method_name != 'auto': + possible_methods = [m for m in possible_methods if m.get_name() == method_name] + + if not possible_methods: + return None, None, None + + # Iterate through available communication methods + for method in possible_methods: + if method.is_ready(): + # Check for each of our known devices + for device in KNOWN_DEVICES: + if method.is_device_present(device['vid'], device['pid']): + print(f"Detected {device['logic']} device (VID={hex(device['vid'])}, PID={hex(device['pid'])}) via {method.get_name()} method.") + return device['vid'], device['pid'], device['logic'] + + return None, None, None + + def main(): parser = argparse.ArgumentParser(formatter_class=argparse.RawDescriptionHelpFormatter, description='Upload messages or graphics to a 11x44 led badge via USB HID.\nVersion %s from https://github.com/jnweiger/led-badge-ls32\n -- see there for more examples and for updates.' % __version, @@ -1151,6 +1265,16 @@ def main(): """ % sys.argv[0]) args = parser.parse_args() + # --- AUTOMATIC DEVICE DETECTION --- + vid, pid, logic_type = find_supported_device(args.method) + + if logic_type is None: + print("\nError: Could not find any supported LED badge.") + print(" - Searched for Custom Device (VID=0x204c, PID=0x4359)") + print(" - Searched for Original Device (VID=0x0416, PID=0x5020)") + print("Please ensure one of these devices is connected and you have the correct permissions.") + sys.exit(1) + creator = SimpleTextAndIcons() if args.preload: @@ -1175,19 +1299,6 @@ def main(): else: print("Type: 11x44") - 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) - ants = split_to_ints(args.ants) - brightness = int(args.brightness) - - buf = array('B') - buf.extend(LedNameBadge.header(lengths, speeds, modes, blinks, ants, brightness)) - - for msg_bitmap in msg_bitmaps: - buf.extend(msg_bitmap[0]) - # Translate -H to -M parameter method = args.method if args.hid == 1: @@ -1197,7 +1308,47 @@ def main(): else: sys.exit("Parameter values are ambiguous. Please use -M only.") - LedNameBadge.write(buf, method, args.device_id) + # Open the device we found earlier + write_method = LedNameBadge._find_write_method(method, args.device_id, vid, pid) + + # --- EXECUTE LOGIC BASED ON DETECTED DEVICE --- + if logic_type == 'custom': + pixel_map, width, height = get_pixel_map(msg_bitmaps) + bitmap_only_buf, _ = encode_custom_logic(pixel_map, width, height) + + payload_prefix = array('B', [0xff] * 8) + final_payload_buf = payload_prefix + final_payload_buf.extend([0x00]) + final_payload_buf.extend(bitmap_only_buf) + + if write_method: + print("Sending Custom Logic Packet Sequence...") + write_method.write(LedNameBadge._header_packet_1) + write_method.write(LedNameBadge._header_packet_2) + write_method.write(LedNameBadge._header_packet_3) + write_method.write(LedNameBadge._header_packet_4) + write_method.write(LedNameBadge._header_packet_5) + write_method.write(final_payload_buf) + write_method.write(LedNameBadge._footer_packet_1) + write_method.write(LedNameBadge._footer_packet_2) + write_method.close() + + else: # Original Logic + 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) + ants = split_to_ints(args.ants) + brightness = int(args.brightness) + + original_buf = array('B') + original_buf.extend(LedNameBadge.header(lengths, speeds, modes, blinks, ants, brightness)) + for msg_buf in [b[0] for b in msg_bitmaps]: + original_buf.extend(msg_buf) + + if write_method: + write_method.write(original_buf) + write_method.close() def split_to_ints(list_str): diff --git a/tests/test_lednamebadge_api.py b/tests/test_lednamebadge_api.py index 9f7d9c4..e022821 100644 --- a/tests/test_lednamebadge_api.py +++ b/tests/test_lednamebadge_api.py @@ -9,8 +9,9 @@ class Test(abstract_write_method_test.AbstractWriteMethodTest): methods, output = self.call_info_methods() self.assertDictEqual({ 'hidapi': ('Program a device connected via USB using the pyhidapi package and libhidapi.', True), - 'libusb': ('Program a device connected via USB using the pyusb package and libusb.', True)}, - methods) + 'libusb': ('Program a device connected via USB using the pyusb package and libusb.', True), + 'pyserial': ('Program a device with the open-source firmware, connected via USB, using the pyserial package.', False) + }, methods) def test_get_device_ids(self): device_ids, output = self.call_info_ids('libusb') @@ -48,7 +49,7 @@ class Test(abstract_write_method_test.AbstractWriteMethodTest): def call_info_ids(self, method): self.print_test_conditions(True, True, True, '-', '-') method_obj, output, _ = self.prepare_modules(True, True, True, - lambda m: m.get_available_device_ids(method)) + lambda m: m.get_available_device_ids(method,vid=0x0416, pid=0x5020)) return method_obj, output def call_write(self, method): diff --git a/tests/test_lednamebadge_select_method.py b/tests/test_lednamebadge_select_method.py index 100f785..3d1b1ca 100644 --- a/tests/test_lednamebadge_select_method.py +++ b/tests/test_lednamebadge_select_method.py @@ -157,7 +157,7 @@ class Test(abstract_write_method_test.AbstractWriteMethodTest): def call_find(self, pyusb_available, pyhidapi_available, device_available, method, device_id): self.print_test_conditions(pyusb_available, pyhidapi_available, device_available, method, device_id) method_obj, output, _ = self.prepare_modules(pyusb_available, pyhidapi_available, device_available, - lambda m: m._find_write_method(method, device_id)) + lambda m: m._find_write_method(method, device_id, vid=0x0416, pid=0x5020)) self.assertEqual(pyusb_available, 'usb.core detected' in output) self.assertEqual(pyhidapi_available, 'pyhidapi detected' in output) return method_obj, output