Skip to content

Instantly share code, notes, and snippets.

@dlech
Last active October 3, 2025 06:54
Show Gist options
  • Save dlech/24e71cd18ef46ec0c3ad94ffa0fef49a to your computer and use it in GitHub Desktop.
Save dlech/24e71cd18ef46ec0c3ad94ffa0fef49a to your computer and use it in GitHub Desktop.
Python script to program Fossia BadgeMagic using Bluetooth.
# SPDX-License-Identifier: MIT
# Copyright (c) 2025 David Lechner <[email protected]>
#
# /// 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):
# 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...")
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))
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment