Skip to content

Instantly share code, notes, and snippets.

@dlech
Last active March 9, 2026 12:19
Show Gist options
  • Select an option

  • Save dlech/24e71cd18ef46ec0c3ad94ffa0fef49a to your computer and use it in GitHub Desktop.

Select an option

Save dlech/24e71cd18ef46ec0c3ad94ffa0fef49a to your computer and use it in GitHub Desktop.

Revisions

  1. dlech revised this gist Oct 3, 2025. 1 changed file with 4 additions and 2 deletions.
    6 changes: 4 additions & 2 deletions badgemagic.py
    Original file line number Diff line number Diff line change
    @@ -1,5 +1,5 @@
    # SPDX-License-Identifier: MIT
    # Copyright (c) 2024 David Lechner <dlechner@baylibre.com>
    # Copyright (c) 2025 David Lechner <dlechner@baylibre.com>
    #
    # /// script
    # dependencies = [
    @@ -309,14 +309,16 @@ def convert(data: Data) -> list[bytes]:


    async def main(data: Data):
    # try this first so that it can fail early before connecting to the device
    chunks = convert(data)

    print("Scanning for LSLED device...")
    device = await BleakScanner.find_device_by_name("LSLED")
    if device is None:
    print("Device not found")
    return

    print("Found LSLED, connecting...")
    chunks = convert(data)
    async with BleakClient(device) as client:
    for chunk in chunks:
    await client.write_gatt_char(LSLED_CHAR_UUID, chunk, response=True)
  2. dlech created this gist Oct 2, 2025.
    339 changes: 339 additions & 0 deletions badgemagic.py
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,339 @@
    # SPDX-License-Identifier: MIT
    # Copyright (c) 2024 David Lechner <dlechner@baylibre.com>
    #
    # /// script
    # dependencies = [
    # "bleak",
    # ]
    # ///

    """
    Simple script to send messages to a LSLED badge over BLE.
    Run with:
    uv run --script badgemagic.py
    """

    import asyncio
    from dataclasses import dataclass
    from datetime import datetime
    from enum import IntEnum

    from bleak import BleakClient, BleakScanner

    LSLED_CHAR_UUID = "0000fee1-0000-1000-8000-00805f9b34fb"

    CHAR_CODES = {
    "0": "007CC6CEDEF6E6C6C67C00",
    "1": "0018387818181818187E00",
    "2": "007CC6060C183060C6FE00",
    "3": "007CC606063C0606C67C00",
    "4": "000C1C3C6CCCFE0C0C1E00",
    "5": "00FEC0C0FC060606C67C00",
    "6": "007CC6C0C0FCC6C6C67C00",
    "7": "00FEC6060C183030303000",
    "8": "007CC6C6C67CC6C6C67C00",
    "9": "007CC6C6C67E0606C67C00",
    "#": "006C6CFE6C6CFE6C6C0000",
    "&": "00386C6C3876DCCCCC7600",
    "_": "00000000000000000000FF",
    "-": "0000000000FE0000000000",
    "?": "007CC6C60C181800181800",
    "@": "00003C429DA5ADB6403C00",
    "(": "000C183030303030180C00",
    ")": "0030180C0C0C0C0C183000",
    "=": "0000007E00007E00000000",
    "+": "00000018187E1818000000",
    "!": "00183C3C3C181800181800",
    "'": "1818081000000000000000",
    ":": "0000001818000018180000",
    "%": "006092966C106CD2920C00",
    "/": "000002060C183060C08000",
    '"': "6666222200000000000000",
    "[": "003C303030303030303C00",
    "]": "003C0C0C0C0C0C0C0C3C00",
    " ": "0000000000000000000000",
    "*": "000000663CFF3C66000000",
    ",": "0000000000000030301020",
    ".": "0000000000000000303000",
    "$": "107CD6D6701CD6D67C1010",
    "~": "0076DC0000000000000000",
    "{": "000E181818701818180E00",
    "}": "00701818180E1818187000",
    "<": "00060C18306030180C0600",
    ">": "006030180C060C18306000",
    "^": "386CC60000000000000000",
    "`": "1818100800000000000000",
    ";": "0000001818000018180810",
    "\\": "0080C06030180C06020000",
    "|": "0018181818001818181800",
    "a": "00000000780C7CCCCC7600",
    "b": "00E060607C666666667C00",
    "c": "000000007CC6C0C0C67C00",
    "d": "001C0C0C7CCCCCCCCC7600",
    "e": "000000007CC6FEC0C67C00",
    "f": "001C363078303030307800",
    "g": "00000076CCCCCC7C0CCC78",
    "h": "00E060606C76666666E600",
    "i": "0018180038181818183C00",
    "j": "0C0C001C0C0C0C0CCCCC78",
    "k": "00E06060666C78786CE600",
    "l": "0038181818181818183C00",
    "m": "00000000ECFED6D6D6C600",
    "n": "00000000DC666666666600",
    "o": "000000007CC6C6C6C67C00",
    "p": "000000DC6666667C6060F0",
    "q": "0000007CCCCCCC7C0C0C1E",
    "r": "00000000DE76606060F000",
    "s": "000000007CC6701CC67C00",
    "t": "00103030FC303030341800",
    "u": "00000000CCCCCCCCCC7600",
    "v": "00000000C6C6C66C381000",
    "w": "00000000C6D6D6D6FE6C00",
    "x": "00000000C66C38386CC600",
    "y": "000000C6C6C6C67E060CF8",
    "z": "00000000FE8C183062FE00",
    "A": "00386CC6C6FEC6C6C6C600",
    "B": "00FC6666667C666666FC00",
    "C": "007CC6C6C0C0C0C6C67C00",
    "D": "00FC66666666666666FC00",
    "E": "00FE66626878686266FE00",
    "F": "00FE66626878686060F000",
    "G": "007CC6C6C0C0CEC6C67E00",
    "H": "00C6C6C6C6FEC6C6C6C600",
    "I": "003C181818181818183C00",
    "J": "001E0C0C0C0C0CCCCC7800",
    "K": "00E6666C6C786C6C66E600",
    "L": "00F060606060606266FE00",
    "M": "0082C6EEFED6C6C6C6C600",
    "N": "0086C6E6F6DECEC6C6C600",
    "O": "007CC6C6C6C6C6C6C67C00",
    "P": "00FC6666667C606060F000",
    "Q": "007CC6C6C6C6C6D6DE7C06",
    "R": "00FC6666667C6C6666E600",
    "S": "007CC6C660380CC6C67C00",
    "T": "007E7E5A18181818183C00",
    "U": "00C6C6C6C6C6C6C6C67C00",
    "V": "00C6C6C6C6C6C66C381000",
    "W": "00C6C6C6C6D6FEEEC68200",
    "X": "00C6C66C7C387C6CC6C600",
    "Y": "00666666663C1818183C00",
    "Z": "00FEC6860C183062C6FE00",
    "Á": "0810386cc6c6fec6c6c600",
    "À": "2010386cc6c6fec6c6c600",
    "Â": "1028386CC6C6FEC6C6C600",
    "Ä": "2800386CC6C6FEC6C6C600",
    "Å": "1028107CC6C6FEC6C6C600",
    "É": "0810FE626878686266FE00",
    "È": "2010FE626878686266FE00",
    "Ê": "1028FE626878686266FE00",
    "Ë": "2800FE626878686266FE00",
    "Ě": "2810FE626878686266FE00",
    "Í": "04083C1818181818183C00",
    "Ì": "10083C1818181818183C00",
    "Î": "08143C1818181818183C00",
    "Ï": "14003C1818181818183C00",
    "Ó": "08107CC6C6C6C6C6C67C00",
    "Ò": "20107CC6C6C6C6C6C67C00",
    "Ô": "10287CC6C6C6C6C6C67C00",
    "Ö": "28007CC6C6C6C6C6C67C00",
    "Ő": "14287CC6C6C6C6C6C67C00",
    "Ú": "0810C6C6C6C6C6C6C67C00",
    "Ù": "2010C6C6C6C6C6C6C67C00",
    "Û": "1028C6C6C6C6C6C6C67C00",
    "Ü": "2800C6C6C6C6C6C6C67C00",
    "Ű": "1428C6C6C6C6C6C6C67C00",
    "Ů": "102810C6C6C6C6C6C67C00",
    "Ý": "04086666663C1818183C00",
    "Ÿ": "14006666663C1818183C00",
    "á": "00000810780C7CCCCC7600",
    "à": "00002010780C7CCCCC7600",
    "â": "00102800780C7CCCCC7600",
    "ä": "00002800780C7CCCCC7600",
    "å": "00102810780C7CCCCC7600",
    "é": "000008107CC6FEC0C67C00",
    "è": "000020107CC6FEC0C67C00",
    "ê": "001028007CC6FEC0C67C00",
    "ë": "000028007CC6FEC0C67C00",
    "ě": "000028107CC6FEC0C67C00",
    "í": "0000081038181818183C00",
    "ì": "0000201038181818183C00",
    "î": "0008140038181818183C00",
    "ï": "0000140038181818183C00",
    "ó": "000008107CC6C6C6C67C00",
    "ò": "000020107CC6C6C6C67C00",
    "ô": "001028007CC6C6C6C67C00",
    "ö": "000028007CC6C6C6C67C00",
    "ő": "000014287CC6C6C6C67C00",
    "ú": "00000810CCCCCCCCCC7600",
    "ù": "00002010CCCCCCCCCC7600",
    "û": "00102800CCCCCCCCCC7600",
    "ü": "00002800CCCCCCCCCC7600",
    "ű": "00001428CCCCCCCCCC7600",
    "ů": "00102810CCCCCCCCCC7600",
    "ý": "000810C6C6C6C67E060CF8",
    "ÿ": "002800C6C6C6C67E060CF8",
    "Ç": "007CC6C6C0C0C0C67C1030",
    "ç": "000000007CC6C0467C1030",
    "Ñ": "342CC6E6F6DECEC6C6C600",
    "ñ": "00342C00DC666666666600",
    "Č": "28107CC6C6C0C0C6C67C00",
    "č": "000028107CC6C0C0C67C00",
    "Ď": "2810FC666666666666FC00",
    "ď": "02061C0C7CCCCCCCCC7600",
    "Ň": "2810C6E6F6DECEC6C6C600",
    "ň": "00002810DC666666666600",
    "Ř": "2810FC66667C6C6666E600",
    "ř": "00002810DE76606060F000",
    "Š": "28107CC6E0380CC6C67C00",
    "š": "000028107CC6701CC67C00",
    "Ť": "14087E7E5A181818183C00",
    "ť": "00143430FC303030341800",
    "Ž": "2810FE860C183062C6FE00",
    "ž": "00002810FE8C183062FE00",
    }

    MAX_MESSAGES = 8
    PACKET_START = "77616E670000"
    PACKET_BYTE_SIZE = 16


    class Speed(IntEnum):
    ONE = 0x00
    TWO = 0x10
    THREE = 0x20
    FOUR = 0x30
    FIVE = 0x40
    SIX = 0x50
    SEVEN = 0x60
    EIGHT = 0x70


    class Mode(IntEnum):
    LEFT = 0x00
    RIGHT = 0x01
    UP = 0x02
    DOWN = 0x03
    FIXED = 0x04
    ANIMATION = 0x05
    SNOWFLAKE = 0x06
    PICTURE = 0x07
    LASER = 0x08
    PACMAN = 0x09
    CHEVRONLEFT = 0x0A
    DIAMOND = 0x0B
    FEET = 0x0C
    BROKENHEARTS = 0x0D
    CUPID = 0x0E
    CYCLE = 0x0F


    @dataclass(frozen=True)
    class Message:
    text: list[str]
    flash: bool
    marquee: bool
    speed: Speed
    mode: Mode
    animation_index: int | None = None


    @dataclass(frozen=True)
    class Data:
    messages: list[Message]


    def get_flash(data: Data) -> str:
    flash_byte = 0
    for idx, message in enumerate(data.messages):
    flash_flag = 1 if message.flash else 0
    flash_byte |= (flash_flag << idx) & 0xFF
    return f"{flash_byte:02x}"


    def get_marquee(data: Data) -> str:
    marquee_byte = 0
    for idx, message in enumerate(data.messages):
    marquee_flag = 1 if message.marquee else 0
    marquee_byte |= (marquee_flag << idx) & 0xFF
    return f"{marquee_byte:02x}"


    def get_options(data: Data) -> str:
    opt_str = ["00"] * MAX_MESSAGES

    for idx, message in enumerate(data.messages):
    opt_str[idx] = f"{(message.speed | message.mode):02x}"

    return "".join(opt_str)


    def get_sizes(data: Data) -> str:
    size_str = ["0000"] * MAX_MESSAGES

    for idx, message in enumerate(data.messages):
    size_str[idx] = f"{len(message.text):04x}"

    return "".join(size_str)


    def get_time(now: datetime) -> str:
    return f"{now.year % 100:02x}{now.month:02x}{now.day:02x}{now.hour:02x}{now.minute:02x}{now.second:02x}"


    def get_message(data: Data) -> str:
    return "".join("".join(message.text) for message in data.messages)


    def convert(data: Data) -> list[bytes]:
    assert len(data.messages) <= MAX_MESSAGES, f"Max messages={MAX_MESSAGES}"
    import datetime

    message = (
    f"{PACKET_START}"
    f"{get_flash(data)}"
    f"{get_marquee(data)}"
    f"{get_options(data)}"
    f"{get_sizes(data)}"
    "000000000000"
    f"{get_time(datetime.datetime.now())}"
    "0000000000000000000000000000000000000000"
    f"{get_message(data)}"
    )
    message += "00" * (
    (PACKET_BYTE_SIZE - (len(message) // 2) % PACKET_BYTE_SIZE) % PACKET_BYTE_SIZE
    )
    chunk_size = PACKET_BYTE_SIZE * 2
    chunks = [message[i : i + chunk_size] for i in range(0, len(message), chunk_size)]
    return [bytes.fromhex(chunk) for chunk in chunks]


    async def main(data: Data):
    print("Scanning for LSLED device...")
    device = await BleakScanner.find_device_by_name("LSLED")
    if device is None:
    print("Device not found")
    return

    print("Found LSLED, connecting...")
    chunks = convert(data)
    async with BleakClient(device) as client:
    for chunk in chunks:
    await client.write_gatt_char(LSLED_CHAR_UUID, chunk, response=True)

    print("All data sent!")


    if __name__ == "__main__":
    sample_data = Data(
    messages=[
    Message(
    text=[CHAR_CODES[c] for c in "Hello, World!"],
    flash=True,
    marquee=True,
    speed=Speed.FOUR,
    mode=Mode.LEFT,
    ),
    ]
    )
    asyncio.run(main(sample_data))