AI;DR – This blog post was mostly generated by Claude (via Claude Code), as was the script which is described here. If you don’t want to read “AI slop”, stop reading now.
One small thing about my Motorola G7 Power (codename ocean) had been bugging me ever since I put LineageOS on it: the little LED that lights up while the phone is charging glows in a harsh, bright white. On the original stock firmware I remembered it as a softer, dimmer light. I wanted that back. What follows is how I chased it down, what I got wrong along the way, the one-line change that actually fixes it, and the considerably less small effort it takes to make that change survive a reboot.
Poking at the LED live, without rebuilding anything
The nice thing about Android LED control is that it lives in sysfs, under /sys/class/leds/. You can write to those files as root and the LED reacts instantly, which makes it perfect for trial and error: no compiling, no flashing, and nothing is permanent until you decide it should be.
LineageOS already ships a root option. Under Settings, System, Developer options there is “Rooted debugging”, which lets adb root work from a PC. (Note that this grants root to ADB only, not to apps on the phone. That distinction matters later.) With that enabled:
adb root adb shell ls /sys/class/leds/
On ocean that lists, among other things, three interesting nodes: charging, green and blue. Notably there is no red. While the phone was charging I read the live values and found green at 255, blue at 127 and charging at 226. So the “white” looked like it might actually be green mixed with some blue. That turned out to be a red herring.
Which node is the actual LED?
To test a channel in isolation you first have to detach its kernel trigger, otherwise the system keeps driving it and fights your writes. Then you set a value and simply look at the LED:
adb shell 'echo none > /sys/class/leds/green/trigger' adb shell 'echo 255 > /sys/class/leds/green/brightness' # watch the LED
The result was a surprise. The green and blue nodes, even at full brightness, lit up nothing visible at all. Only the charging node lit the LED, and it was plain white. Adding green or blue on top of it did not shift the colour either.
The conclusion: on this hardware the charging light is a single white-only LED on the charging node. The colour channels exist in software (inherited from the shared device tree) but are not wired to anything you can see. In other words, a green charging LED is simply not reachable on this phone. My memory of a green stock LED was probably just a dimmer white. The best I could realistically do was make it dim.
The trap: the system keeps overwriting you
My first dimming attempts seemed to do nothing. The reason is that the LineageOS lights service continuously re-drives the charging LED back to its bright value. By the time you glance at the phone, your write is already gone. The giveaway is reading the value back a second later and seeing it has reverted to 226.
My next idea was a mistake worth sharing so you do not repeat it. I tried to silence the lights service by stopping it:
adb shell 'stop vendor.light-default' # do NOT do this
Pulling that HAL out from under Android crashed system_server and threw the phone into a very long boot-animation soft-reboot. Nothing was permanently harmed (none of my sysfs changes survive a reboot), but it was a good reminder that the LED service is load-bearing.
The fix: cap max_brightness
The clean solution is not to fight the service over the brightness value, but to put a ceiling on it. Each LED node also exposes a writable max_brightness, and the kernel clamps any brightness write down to it. So if I cap the maximum, it does not matter what the service writes:
adb shell 'echo 1 > /sys/class/leds/charging/max_brightness'
After that, the service can write 226 all it likes and the LED still comes out at level 1, the dimmest setting that is still visible. I verified it:
adb shell 'echo 226 > /sys/class/leds/charging/brightness' adb shell 'cat /sys/class/leds/charging/brightness' 1
The cap even survives the service cycling the LED’s trigger. Pick whatever value looks right to you: 1 is barely there, 5 or 10 or 20 give progressively more glow, and 0 turns the charging light off entirely. The only thing that resets the cap is a reboot, which returns it to 255.
Making it stick across reboots
Since the cap resets on every boot, I needed something to re-apply it at startup, running as root. This is where “Rooted debugging” is not enough: it only roots ADB from a PC, not a script running on the phone itself. For an on-device boot script I installed Magisk.
And here we come to the part that contradicts my claim of this being a “small change”: the one-line cap is a small change, but rooting the phone and patching its boot image to make it stick is decidedly not. It is a real commitment, an unlocked bootloader, a custom boot image and a root manager, for what is after all a cosmetic tweak. If you are not already comfortable with fastboot and flashing, this is a fair point to ask whether a slightly-too-bright LED is worth it. My phone of course was already unlocked because I installed LineageOS on it and I do not use it for anything important, so for me it was fine.
The safest way to root with Magisk is to patch the phone’s own boot image, so the patched image always matches the installed ROM exactly. Because I had ADB root, I could dump the live boot partition straight off the device rather than hunting for a matching factory image:
adb shell 'dd if=/dev/block/by-name/boot_b of=/sdcard/Download/boot.img' adb pull /sdcard/Download/boot.img
(ocean is an A/B device, and the active slot here was _b, hence boot_b.) I then installed the Magisk app, used its “Select and Patch a File” option on that boot.img, and flashed the patched result back with fastboot:
fastboot flash boot magisk_patched.img fastboot reboot
One thing that can look alarming: on an unlocked Motorola bootloader, fastboot prints Image not signed or corrupt for any custom boot image. That warning is normal and harmless; the flash still succeeds. (Keep a copy of the original boot.img first, so you can always flash it back if anything goes wrong.)
With Magisk in place, any executable script in /data/adb/service.d/ runs as root late in every boot. Mine is two lines of actual work:
#!/system/bin/sh # /data/adb/service.d/dim-charge-led.sh (chmod 0755, owner root:root) LED=/sys/class/leds/charging/max_brightness CAP=1 for i in $(seq 1 30); do [ -w "$LED" ] && break; sleep 1; done echo "$CAP" > "$LED"
After a clean reboot, with no PC attached and nothing typed, max_brightness is already 1 and the charging light comes up as a calm dim glow. Done. The only knob left is the CAP=1 line in that script: change the number, reboot, and you have a different brightness.
What about LineageOS updates?
This is the catch with the Magisk approach. My script lives in /data/adb/service.d/, and the data partition is not touched by a system update, so the file itself survives an update just fine. What does not survive is Magisk. A LineageOS update flashes a fresh, stock boot image (on this A/B device, to the currently inactive slot), and that stock image has no Magisk in it. After the update reboots you are back to an unrooted phone: the service script is still sitting there, but nothing runs it, root is gone, and the charging LED is bright white again.
Magisk has a built-in answer for exactly this. After the LineageOS updater has downloaded and installed the update, do not reboot yet. Instead open Magisk, choose Install and then Install to Inactive Slot (After OTA), and only then reboot. That patches the freshly written slot before the slot switch, so Magisk, and with it the dimming script, carries over to the new version. The details are in the Magisk OTA guide.
If you forget that dance and reboot straight into the update, nothing is broken, you just have to re-root afterwards exactly as before: dump the new boot image off the device, patch it in Magisk, and flash it back with fastboot. The moment Magisk is back the untouched service.d script does its job again on the next boot. Just remember that the boot image backups from the first install are specific to that build, so grab a fresh boot.img from the updated version before patching.
If you want to try this on a different phone
The exact node names are specific to ocean, but the method is general. List the LED nodes with ls /sys/class/leds/, read their values while charging, then probe each one in isolation (detaching its trigger first) until you find the node that actually controls the charging light. Common names on other devices are a true red / green / blue trio, a single white, or names like indicator or led:rgb_*. Once you know the node, the same max_brightness cap and the same Magisk service script apply.
What I would have done without Magisk
If you build LineageOS from source anyway, you can skip Magisk entirely and bake the cap into the image: an init.ocean.rc action on sys.boot_completed that writes max_brightness, plus a small SELinux rule to allow it. I went with the Magisk service script because it was the lighter path for a single tweak.