Shining Mask — Python controller
A clean, async Python controller for the "Shining Mask" LED face mask, driven over Bluetooth LE with bleak (BlueZ on Linux).
A from-scratch port of the Shining Mask app protocol, cross-checked byte-for-byte against the canonical Go implementation (GoneUp/mask-go), the community protocol doc (BrickCraftDream/Shining-Mask-stuff), real encrypted app traffic (beclamide/mask-controller), and the custom-image upload recipe from (BishopFox/shining-mask).
This project is flat — every module is a plain .py file in this folder.
Run things from here with the env that has bleak/cryptography/Pillow:
cd ~/Robotics_workspace/yslootahtech/Project/Mask
python main.py # uses the g1_env python
No install/packaging — import mask, import colorface, etc. work because the
files sit in the working directory.
Features
- Connect over BLE (scan for
MASK-…, or by MAC) - Brightness, built-in images/animations
- Custom full-color images & animations — the headline feature
- Animated face with idle blink/glance + talking mouth (
FaceAnimator) - Convert any image / GIF to the mask (
image2mask.py) - Scrolling text (mode, speed, colors), DIY image PLAY/DELETE/COUNT
- A protocol layer with no BLE dependency, fully unit-tested without hardware
Display any image / GIF
python image2mask.py photo.jpg # fit + show a still image
python image2mask.py logo.png --fit cover --oval
python image2mask.py dance.gif --max-frames 12 --fps 8 --loops 5 # animate a GIF
python image2mask.py photo.jpg --preview # ASCII preview, no mask needed
python image2mask.py photo.jpg --save out.bin # just save the raw 8004-byte frame
--fit contain|cover|stretch controls how the image fits the 46×58 portrait
oval; --oval blacks out the corners to match the panel shape. In code:
import colorface
from mask import ShiningMask
img = colorface.load_image("photo.jpg", fit="cover", oval=True) # -> 46x58 RGB
async with ShiningMask(name_prefix="MASK") as mask:
await mask.upload_raw_image(colorface.encode(img)) # show it
# animated GIF -> frames -> DIY images -> PLAY-loop:
frames = colorface.load_frames("dance.gif", max_frames=12)
Animated face (Marcus)
The mask's image display is a portrait oval ~46×58 RGB, stored transposed
(display[x,y] = raw[y,x]). The firmware flashes an "uploading" logo during
every upload, so smooth animation can't re-upload per frame — instead a frame set
is uploaded once as DIY images, then animated with fast PLAY commands (no
logo). That's FaceAnimator + colorface.
python main.py # connect, load frames once, run a live face
python main.py --reload # force re-upload of the frame set
python main.py --talk # start talking and stay talking
Drive it from Marcus's speech:
from mask import ShiningMask
from faceanim import FaceAnimator
async with ShiningMask(name_prefix="MASK") as mask:
face = FaceAnimator(mask)
await face.start() # uploads frames once, starts the idle animation
face.set_speaking(True) # call when TTS playback starts
# ... Marcus talks; mouth animates, eyes blink/glance ...
face.set_speaking(False) # call when it ends
# or: face.set_mouth(0..3) from live audio amplitude for rough lip-sync
await face.stop()
Draw your own faces in 46×58 display space:
import colorface
img = colorface.build_face(mouth=2, look=-4) # PIL image, 46x58
await mask.upload_frame(colorface.encode(img), slot=1)
await mask.play_frame(1)
Other commands
import asyncio
from mask import ShiningMask
from constants import TextMode
async def main():
async with ShiningMask(name_prefix="MASK") as mask:
await mask.set_brightness(80)
await mask.show_image(3) # built-in image
await mask.set_text("HELLO", color=(0, 200, 255), mode=TextMode.SCROLL_LEFT)
asyncio.run(main())
CLI for quick one-offs: python cli.py light 80, python cli.py image 3,
python cli.py text "HELLO" --color 00ff00, python cli.py repl.
Utilities: python scan.py (find the mask), python selftest.py <MAC>,
python preview_text.py "HI".
Protocol notes
- Command/notify frames are AES-128-ECB, 16 bytes, fixed firmware key
32672f7974ad43451d9c6c894a0e8764(not a secret; same across vendors). - Command frame:
[len][ASCII name][args…]zero-padded to 16,len = name+args. - Custom image format (solved): the image display is portrait 46×58 RGB,
stored transposed. Upload it with an image
DATS(toggle0x01), then stream the chunks without waiting for acks, thenDATCPwith a 4-byte timestamp (no handshake — that was the trick). Frames persist on the mask's flash and replay viaPLAYwith no logo.
GATT characteristics
| Purpose | UUID | |
|---|---|---|
| Command (encrypted) | d44bc439-abfd-45a2-b575-925416129600 |
write |
| Notify (encrypted) | d44bc439-abfd-45a2-b575-925416129601 |
notify |
| Image/bitmap upload (raw) | d44bc439-abfd-45a2-b575-92541612960a |
write |
| Audio visualization (encrypted) | d44bc439-abfd-45a2-b575-92541612960b |
write |
Layout (flat)
main.py run a live animated face
image2mask.py convert any image / GIF and display it
mask.py ShiningMask high-level async API
faceanim.py FaceAnimator (load frames once -> PLAY-loop)
colorface.py 46x58 face frames + image->mask conversion
protocol.py command framing + encoders (no I/O)
crypto.py AES-128-ECB
transport.py bleak BLE: scan / connect / notify / writes
bitmap.py faces.py talking.py cli.py constants.py exceptions.py
scan.py selftest.py preview_text.py utility scripts
test_*.py pytest (no hardware)
NotoSans-Regular.ttf
Tests
python -m pytest -q # 57 tests, no hardware needed
The crypto tests decrypt real captured app frames and assert they resolve to the expected commands; the colorface tests validate image→mask conversion and the 46×58 transpose round-trip.
License
MIT. Reverse-engineering credit: mask-go, Shining-Mask-stuff, mask-controller, BishopFox/shining-mask.