English · Русский
An Android app plus ESP32 firmware that turns phone notifications into light on an LED strip. Every app gets its own hue, effect and duration; anything still unread leaves a dim dot glowing on the strip until you deal with it.
Built for one lamp on one desk, opened up in case it is useful to someone else.
There is no cloud, no account and no INTERNET permission: the app can read every
notification on the phone, and nothing leaves the device except numbers over BLE.
- A rule per app — hue, saturation, brightness, speed, effect, duration.
- Nine effects: rainbow wave, pulse, fire, running lights, solid, blink, double flash, wipe, sparkle.
- Colour that matches the strip. Hues are rendered through a port of FastLED's
hsv2rgb_rainbow, not the browser's HSV — 64 is yellow there and chartreuse in Android. Every colour is also spelled out in words, because a coloured dot is useless to a colour-blind user and hues are harder to tell apart on a real strip than on a display. - Collision warning. Two apps whose colours look different on screen but light the same on the strip is the one failure you cannot see for yourself, so the app says so — and can spread the colliding hues apart in one action, moving only the rules that actually collide.
- Three layers on the strip: notification event → background (manual lamp) → unread trail → dark.
- Quiet hours and an option to follow the phone's Do Not Disturb.
- A decision log — why the strip stayed dark: cooldown, foreground service, quiet time, no link. Plus what is silencing it right now.
- Quick settings tile to drop and restore the link without opening the app.
- Backup of every rule into a plain text file you can read and edit.
| Part | What I used |
|---|---|
| MCU | ESP32-C3 (single core, USB JTAG/Serial on board) |
| Strip | WS2812B, 11 LEDs, data on GPIO 10, GRB order |
| Power | 18650 cell |
Nothing here is specific to that build: LED count, pin and colour order are three
constants at the top of firmware/ble_led/ble_led.ino.
| Android | 12+ (API 31) — the link is kept alive by CompanionDeviceManager |
| Permissions | BLUETOOTH_CONNECT and notification access. No scanning and no location: the system runs the device picker, not the app |
| Toolchain | JDK 17, Android SDK, arduino-cli with the esp32 core |
export JAVA_HOME=/path/to/jdk-17 # AGP 8.10 refuses anything newer
./gradlew assembleDebug # debug APK (applicationId suffix .debug)
./gradlew testDebugUnitTest # JVM unit tests
./gradlew lintDebug # Android LintA release build needs keystore.file / keystore.password / keystore.keyAlias /
keystore.keyPassword in local.properties and fails without them on purpose —
it used to fall back to the debug keystore, and a "successful" release then shipped
signed with a publicly known key.
Firmware:
arduino-cli config add board_manager.additional_urls \
https://espressif.github.io/arduino-esp32/package_esp32_index.json
arduino-cli core install esp32:esp32 # tested on 3.3.11
arduino-cli lib install FastLED # tested on 3.9.14, not part of the core
arduino-cli compile --fqbn esp32:esp32:esp32c3:CDCOnBoot=cdc firmware/ble_led
arduino-cli upload -p /dev/cu.usbmodemXXXX --fqbn esp32:esp32:esp32c3:CDCOnBoot=cdc firmware/ble_ledWiring, current budget and what to change for a different strip or board (LED count, pin, colour order, 3.3 V logic into a 5 V strip) are in firmware/README.md, together with what a healthy first boot prints over serial.
That document is in Russian, like most comments in this project; it also records the
traps that cost time to rediscover — why CDCOnBoot=cdc needs Serial.setTxTimeoutMs(0),
and why the CPU frequency must not change at runtime.
One command is one ASCII string written to the command characteristic. Full grammar, ranges, version history and firmware behaviour: docs/PROTOCOL.md. The short version:
Service 2964000e-3bc3-4435-930f-6e7ee5f4168d
Command af451ddc-52f6-4479-9be7-3911fdef391a (write)
Version 4adcac3a-3e30-4656-b5bf-506499744d6d (read)
O<0|1> B<0-255> C<0-255> S<0-255> [A<0-255>] E<0-8> T<1-30> [R<0|1>] [F<0|1>]
D[L<bri>]<hue><sat>… # unread trail: one hex byte pair per dot
C is a FastLED hue byte, not degrees. The protocol version lives in two places at
once — BluetoothLeService.PROTOCOL_VERSION and a #define in the sketch — so they
must move together; the app reads the firmware's version and adapts what it sends.
One module, Kotlin, Views + ViewBinding. No DI, no Compose, no Room.
| Layer | Where |
|---|---|
| Link | BluetoothLeService — a process-wide singleton; every GATT call on one HandlerThread |
| Product loop | NotificationListener — filter a notification, send a command |
| Screens | MainActivity (app list, device card), AppSettingsActivity, LampActivity, SettingsActivity, ActivityLogActivity |
| Data | SettingsRepository over a KeyValueStore; storage is SharedPreferences("LEDSettings") |
| Pure logic | LedCommand, NotificationFilter, QuietHours, UnreadTrail, HueCollision, HueSpread, FastLedRainbow, WriteQueue, SettingsBackup — all unit-tested on the JVM |
The reasoning behind the parts that look odd — why the link is held by the Bluetooth
controller instead of a foreground service, why close() must not be called on a soft
disconnect, why SharedPreferences is the right store here and DataStore would be a
regression — is written down in CLAUDE.md. It is aimed at coding agents,
but it reads as a design document for humans too.
This app has notification access, which means it can read every message, banking code and 2FA prompt that reaches the phone. That shapes the design:
- No
INTERNETpermission. There is physically nowhere for data to go. - Notification title and text are read inside a single function and never reach the log (only their lengths), the decision log, the backup file, or the air.
- Settings are excluded from Android cloud backup, and the lamp's address is excluded from the app's own backup file as well.
- What travels over BLE is on/off, brightness, hue, saturation, speed, effect and duration — numbers, nothing else.
- The BLE link has no bonding or encryption. Anyone in range can write to the characteristic: they can light your strip, and they can watch the timing of your notifications. Colour and brightness are all that is exposed, but if you point this protocol at anything other than a lamp, fix that first.
Issues and pull requests are welcome — see CONTRIBUTING.md. Comments and documentation in this project are written in Russian; English is fine in issues and pull requests, and mixed-language comments are fine too.
Copyright (C) 2025–2026 Aleksey Golikov.
GPL-3.0 — if you ship a modified version, ship its source as well. Earlier
commits in this repository carry an MIT LICENSE file from the initial scaffold; the
project was never published under it, and it is relicensed here by its only author.