173 lines
6.6 KiB
Markdown
173 lines
6.6 KiB
Markdown
# Shining Mask — Python controller
|
||
|
||
A clean, async **Python controller for the "Shining Mask" LED face mask**, driven
|
||
over Bluetooth LE with [bleak](https://github.com/hbldh/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](https://github.com/GoneUp/mask-go)), the community protocol doc
|
||
([BrickCraftDream/Shining-Mask-stuff](https://github.com/BrickCraftDream/Shining-Mask-stuff)),
|
||
**real encrypted app traffic** ([beclamide/mask-controller](https://github.com/beclamide/mask-controller)),
|
||
and the custom-image upload recipe from
|
||
([BishopFox/shining-mask](https://github.com/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`:
|
||
|
||
```bash
|
||
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
|
||
|
||
```bash
|
||
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:
|
||
|
||
```python
|
||
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`](faceanim.py) + [`colorface`](colorface.py).
|
||
|
||
```bash
|
||
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:
|
||
|
||
```python
|
||
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:
|
||
|
||
```python
|
||
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
|
||
|
||
```python
|
||
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` (toggle `0x01`), then stream
|
||
the chunks **without** waiting for acks, then `DATCP` with a 4-byte timestamp
|
||
(no handshake — that was the trick). Frames persist on the mask's flash and
|
||
replay via `PLAY` with 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
|
||
|
||
```bash
|
||
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](https://github.com/GoneUp/mask-go),
|
||
[Shining-Mask-stuff](https://github.com/BrickCraftDream/Shining-Mask-stuff),
|
||
[mask-controller](https://github.com/beclamide/mask-controller),
|
||
[BishopFox/shining-mask](https://github.com/BishopFox/shining-mask).
|