This whole project started several months ago, when I discovered the most amazing thing: 5m of RGB LEDs can be had for less than 5€ on AliExpress!

The idea was simple: my phone has a notification LED that blinks with different colors to signal that there are unread notifications, but since my phone is usually on my desk in a flip case (and probably under a stack of paper), I don’t usually see it. So how neat would it be, if my entire room mirrored that LED so I never missed a notification again?

If you don’t care about the LEDs and control circuits themselves and just want to know how I achieved notification LED mirroring, skip to this section.

The LEDs

The RGB(W) strip I chose is the RGBWW 5050 model. The WW part stands for Warm White, meaning the actual RGB LEDs alternate with warm white LEDs and the 5050 refers to the RGB LED model, which is the stronger of the two most common ones (the other being 3528).

I opted for RGBW instead of standard RBG as I wanted to be able to flash a notification color while still illuminating my room enough to work. The warm white (as opposed to a more standard, cooler white) is just a personal preference.

The driver circuit

The control server

I was initially planning on using my home NAS server to control the LEDs with just a long USB extension cable running to them, but looking at the pile of ESP8266s on my desk gave me a better idea: use esp-link to expose the driver board’s serial port over the network and control it directly.

As the ESP8266 requires a 3,3V supply, I had to step down some of the existing 5V even further. Luckily, I had some step-down boards handy and just jerry-rigged them onto the main board with some wires.

I loaded the ESP8266 with esp-link, which involved first updating its bootloader and then flashing the esp-link binary onto the chip. The configuration was also rather simple and involved connecting to an open Wi-Fi hotspot, created by the chip and entering the credentials to my home Wi-Fi, after which the ESP switched to station mode and acted just like any other device on my network.

I proceeded to connect the TX, RX and RST pins of my MCU to the ESP according to the official instructions, which gave me access to its serial port from any device on the network, as well as allowed me to remotely flash the MCU’s firmware at any time.

The firmware

The original firmware consisted of some 200 lines of C that were about as fast and fexible as an 80-year-old with osteoporosis. You can see it [here], but I don’t advise you to.

The firmware that I use now is actually not terrible and is available on GitLab [here]. You’re free to use it if you want to – it’s all GPLv2.
Because I can’t be bothered with proper documentation, here’s a short snippet that shows off the functionality (> is the prompt)

> asdf
E Invalid command!
> P ("print" the current state of the LEDs)
PR255G0B0W150
> SB150W0 ("set" specified channels to new values)
PR255G0B150W0
> FR0G0B0W0 ("fade" specified channels to new values
PR0G0B0

The firmware allows you to define (at compile time) any number of channels, each with a channel name (a single character) and its corresponding PWM output pin. You can see some examples in the README.

The notification mirroring

Ok, now for the interesting part. The never-before-seen part. The only part that hasn’t been posted all over Instructables 100s of times. How can I monitor the state of my phone’s notification LED and send that information to the light server?

Not all that surprisingly, there isn’t a simple function in the Android SDK that would allow me to just do that. So, I had to get creative and dig deeper into the system itself.

Looking through the sources of the particular ROM I was using (based on LineageOS, although the same applies for stock Android, too), [………..]

Once I know what function I am interested in, I need a way to tap into it and mess with its execution. Fortunately, that is exactly what the XPosed Framework was designed to do. After taking care of all the boilerplate required for XPosed to load my module into the right binary (which can be found here), I was able to tap the […] function like so:

public class Main implements IXposedHookLoadPackage {
    private static final String NMS_CLASS = "com.android.server.notification.NotificationManagerService";
    @Override public void handleLoadPackage(XC_LoadPackage.LoadPackageParam lpparam) throws Throwable {
        if (lpparam.packageName.equals("android") && lpparam.processName.equals("android")) {
            XposedBridge.log("Hooked android");
            XposedHelpers.findAndHookMethod(NMS_CLASS, lpparam.classLoader, "updateLightsLocked", methodHook); 
        } 
    } 
}

This allows me to execute my code every time that function is called, just before it starts executing, giving me full control over the parameters it receives. Using a possibly one of the ugliest parts of Java – reflection, I was finally, after hours of staring at Android source code and dumping strange variables to logcat (because setting up an OS-level debugger is simply too much hassle), able to understand what all the variables meant and how they related to the LED’s action and came up with this beautiful mess of spaghetti code:

static XC_MethodHook methodHook = new XC_MethodHook() {
    @Override
    protected void afterHookedMethod(final MethodHookParam param) throws Throwable {
        // type: NotificationManagerService
        Object nms = param.thisObject;

        Field mNotificationLightField = NotificationManagerService.getDeclaredField("mNotificationLight");
        mNotificationLightField.setAccessible(true);

        // type: Light
        Object mNotificationLight = mNotificationLightField.get(nms);

        Class Light = mNotificationLight.getClass()

        Field mColorField = Light.getDeclaredField("mColor");
        mColorField.setAccessible(true);
        int mColor = mColorField.getInt(mNotificationLight);

        Field mFlashingField = Light.getDeclaredField("mFlashing");
        mFlashingField.setAccessible(true);
        boolean mFlashing = mFlashingField.getBoolean(mNotificationLight);

        XposedBridge.log("Light color: " + mColor);
        XposedBridge.log("Light flashing: " + mFlashing);
    }
};

Tying it all together

With all the pieces working, it was time to connect everything together. For every light event, after the above code decoded it, the details of the event (ON/OFF, color, blinking speed) would be send to the light server in the form of a special command. The server would forward it to the MCU, where the firmware would read the details and save them into its memory.

With the LED state information now synchronized to the controller, it was simply a matter of writing a fade function to mimic the one used by my phone and the project was done.