A small Android app that makes a Sony DualShock 3 controller actually charge over USB when plugged into an Android TV device (built/tested for an NVIDIA Shield TV Pro), instead of just powering on and sitting there.
Plug a genuine PS3 controller into anything that isn't a real PS3 (a PC, an Android TV box) and it powers on but never charges — because only a real PS3 (or software that knows the trick) sends the USB command that switches the controller into "operational mode," which is also what enables its charging circuit.
- Sends the full operational-mode handshake automatically the moment a DS3 is plugged in (auto-launches via a USB device-attach intent filter — no need to open the app manually), and also on device boot if a controller is already plugged in when the Shield powers on
- Per-device cards: each connected controller gets its own card (name/battery/status) with its own action buttons — plug in more than one via a hub and they're tracked and charged independently
- Low battery alert: pops an alert notification when a controller's reported level drops to 25% — toggle in Settings, on by default. 25%, not a rounder number, because it's the nearest tier the DS3 hardware can actually report. Works whether the controller is on USB (connected but not charging) or genuinely wireless over Bluetooth (Android 12+ only - see the note below)
- Reads and displays battery status by polling the controller's own USB HID input report while plugged in (interval adjustable in Settings — 1/5/15/30 min, default 5), and separately via Android's own input-device battery tracking while running wirelessly (Android 12+ only - see the note below). Live percentage only while genuinely not charging or fully charged — see the battery-byte note below for why a real number can't be shown while actively charging
- Runs as a foreground service with a persistent notification, so charging status keeps updating even after you leave the app
- Charge-complete alert: pops a separate, actually-alerting notification (not just a silent status update) the moment a controller finishes charging — toggle in Settings, on by default. This is the one real "next charging level" event the DS3 hardware exposes; it only reports live battery % while not charging (see How it works), so there's no granular 25/50/75% alert while plugged in, just the Charging → Full transition
- Handles more than one DS3 plugged in at once (e.g. via a USB hub) — the charge-complete alert fires independently per controller
- Check Authenticity: combines two independent signals. (1) Samples the analog stick/button ADC resolution while you move a stick or press a button, since genuine Sony hardware uses a real (~10-bit) ADC and cheap clone boards commonly upscale a coarser one — producing visibly stepped values on a slow sweep instead of smooth ones. (2) Reads the controller's raw USB HID Report Descriptor and byte-compares it against the genuine SIXAXIS/DS3's known 148-byte descriptor — firmware-baked, so it can't be affected by anything a legitimate user does (see the note below on why the Bluetooth "MAC" this app used to check isn't used anymore). If both signals agree you get one confident verdict; if they disagree, both are shown plainly rather than picking a side. Heuristic, not cryptographic proof, but based on real hardware behavior and firmware structure, not a spoofable identifier
- Test Rumble: fires both motors in an alternating 1-second pattern, so you can confirm both actually work
- Pair to Host...: writes a chosen host's Bluetooth MAC into the controller's stored "master" address over USB — the same mechanism a real PS3 (or a PC tool like SixaxisPairTool) uses, since DS3 pairing is USB-write-driven, not a self-contained discoverable Bluetooth mode. You type in the target MAC each time; nothing device-specific ships hardcoded
- Settings screen (TV-remote-friendly preset buttons, no on-screen keyboard needed) for the poll interval and the charge-complete alert toggle — changes apply on the next poll, no restart needed
Everything here is a raw USB HID control transfer via Android's UsbManager/UsbDeviceConnection APIs (no root needed) — no PS3, no proprietary driver. The exact bytes are verified against the real Linux kernel source (drivers/hid/hid-sony.c) and cross-checked against a second, independent, actively-maintained real driver (DsHidMini), not guessed:
- Enter operational mode / start charging:
sixaxis_set_operational_usb()— twoHID GET_REPORTcalls, not one. Step 1 is Feature report0xF2(17 bytes); step 2 is Feature report0xF5(8 bytes), which the kernel's own comment describes as needed by "some compatible controllers... to get operational." This app only ever sent step 1 until v1.3.0 — meaning a controller could enumerate and report a plausible-looking battery status while its charging circuit may never have actually engaged. If your controller was slow to charge or you're upgrading from an older release, this is why. - Battery level: byte 30 of the standard 49-byte input report.
0xee/0xef= charging/full; otherwise a 0–5 index into{0, 1, 25, 50, 75, 100}(not a raw percentage). Confirmed at this same offset independently in DsHidMini's ownDS3_RAW_INPUT_REPORTstruct. While actively charging (0xee), the DS3's own charge controller owns this byte and never reports a real percentage — only the Charging/Full state (distinguished by one bit). Earlier versions displayed a hardcoded100%for both, which was misleading since Charging could mean anywhere from empty to nearly full; as of this fix it showsCharging...with no number instead, and only shows a real100%once it's genuinely Full. This is a firmware limitation, not something fixable by better parsing, more frequent polling, or root — the protocol has been reverse-engineered thoroughly enough (by this project and by others, including DsHidMini) that there's high confidence no other USB report on this hardware carries a live charging percentage either. - Rumble: a 36-byte OUTPUT report (Report ID
0x01) — byte 3 = right (small) motor on/off, byte 5 = left (large) motor force (0–255). Sent via both the control-transferSET_REPORTpath and the controller's interrupt OUT endpoint, since the kernel driver notes some boards only honor the latter. - Bluetooth pairing write: an 8-byte
SET_REPORTto Feature report0xF5—[0x01, 0x00, mac0..mac5], MAC in natural byte order. Format sourced from Android's own historical bluezsixpair.c(set_master_bdaddr). - Authenticity check's stick/button offsets: bytes 6–9 (left/right stick X/Y), bytes 14–25 (12 analog pressure values: D-pad×4, L2/R2/L1/R1, Triangle/Circle/Cross/Square). These aren't hand-decoded by the kernel driver (generic HID, mapped from the device's own report descriptor), so they're sourced from a community protocol reference and cross-checked against DsHidMini's struct.
- Authenticity check's report-descriptor signal: a plain, read-only
GET_DESCRIPTOR(type0x22, HID Report) — never a write. Compared byte-for-byte against the genuine SIXAXIS/DS3's documented 148-byte descriptor (source, the firmware-native variant, captured via USBPcap). An earlier version of this check instead read the controller's Bluetooth "MAC" out of Feature report0xF2and flagged a mismatch against Sony's registered OUI blocks — that was wrong and reverted: that field is a rewritable pairing target address, not a serial, and a genuine controller that's ever been paired to a non-Sony host (e.g. for PC/emulator use) legitimately fails it. The report descriptor doesn't have this problem — it's baked into the firmware, not something any normal use of the controller can change. VID/PID (054C:0268) is deliberately not checked either — real clone boards (e.g. ShanWan) report the identical Sony IDs, so it would add false confidence, not real information.
The USB interface is claimed only for the duration of each individual transfer, not held continuously — holding it exclusively was tried early on and broke the controller's normal use as a Bluetooth gamepad while it was plugged in charging.
-
Test Rumble is real but weak — never got a clean, confirmed "it works." The raw 36-byte OUTPUT report is sent correctly (byte offsets verified against the kernel source and cross-checked against DsHidMini), but across several tuning attempts (max force, pulsed pattern, continuous 1s pattern, verified the byte-direction wasn't inverted against the kernel's own FF-effect translation) the user only ever reported "barely felt it," held in hand. Never resolved to a clean pass/fail — could be this specific controller's aged motors, could be something Shield-USB-specific, not settled. 2026-08-17 cross-test: separately confirmed the DS3 also gets zero rumble through RetroArch directly (Crash Bash, a rumble-heavy PS1 title) — checked both logcat and a raw kernel
geteventcapture on the controller's own device node, zeroEV_FFevents reached it on real, repeated bumps. Android never even classifies this controller's realhid-sony-driven input device with theVIBRATORdevice-class flag on this Shield (confirmed viadumpsys input:0x80000141, no0x200bit) — so this isn't a bug specific to this app's raw-USB approach or to RetroArch; it looks like a platform-level gap on this Android version. SeeRaphnet_GC-N64_Controller_Bridgefor the fuller writeup of the same underlying limitation, hit independently via a completely different controller/adapter. -
A full-charge LED indicator was tried and removed. Lighting all 4 LEDs when the battery hits Full worked at the USB protocol level (confirmed via a real interrupt-endpoint output-report write), but Android itself assigns a connected DS3 a real gamepad "player slot" (
ControllerNumberindumpsys input) and re-asserts its own single player-indicator LED on the same report — even a few-times-a-second re-write from this app couldn't hold a steady all-4 state against it. The notification's100% Fulltext is the actual full-charge indicator now. -
Android TV does not auto-pair a DS3 the way a real PS3 does — correcting an earlier, wrong assumption in this README. A previous version of this doc claimed Android's
hid-sonykernel driver automatically writes the host's Bluetooth address into a plugged-in controller (the same mechanism real PS3s and PC pairing tools use, Feature report0xF5). That's not actually true on Android TV:hid-sonyis a Linux desktop/server kernel driver, and there's no evidence Android's own Bluetooth/USB HID stack performs that write on plug-in. Live testing (2026-08-13) confirmed a DS3 does not wirelessly reconnect to a Shield on its own after its stored pairing address is reset, even after a correct USB re-pair write and a fresh Bluetooth bond attempt — the controller's own firmware terminated the connection every time (reason:19, remote-terminated), suggesting a real, unresolved Bluetooth-stack compatibility gap between the DS3's ~2006-era pairing protocol and at least this Shield's Android Bluetooth stack. The Pair to Host... button (new in v1.3.0) at least gets the correct address written into the controller — same mechanism a real PS3 uses — but full wireless reconnection to an Android TV device is not guaranteed to complete even so, and isn't something further changes to this app's USB code are likely to fix. If wireless use matters more than charging, a real PS3 (or a PC with a proper DS3 Bluetooth driver) remains the reliable way to pair one. -
Battery % while running wirelessly over Bluetooth (unplugged) may not work, depending on your Android version. This app tries two paths: the real public
InputDevice.getBatteryState()API (Android 12/API 31+, works cleanly, no extra permission), and a fallback for older versions usingBluetoothDevice.getBatteryLevel()- an undocumented,@hideAOSP method not in the public SDK, used here anyway since there's no public alternative pre-12. On Android 11 and below this fallback can still come back empty: the DS3's real battery data lives in a kernelpower_supplynode (/sys/class/power_supply/sony_controller_battery_.../capacity, confirmed present via the kernel'shid-sonydriver) that Android only started bridging into the input framework in API 31 - the hidden Bluetooth API draws from a different, unrelated data source and has nothing to report for a device paired that way on older OS versions. The only way around this is reading that kernel file directly, which needs root. Battery % over USB (plugged in) always works regardless of Android version, since that goes through this app's own direct HID reads, not either of the above.
Whether the charge-complete alert actually pops on screen depends on your Android TV launcher, not this app — unlike phones, Android TV doesn't render heads-up notification banners at the system level, that's up to whichever launcher app is set as default. Stock FLauncher, for example, doesn't implement notification overlays at all (the notification is still posted correctly, it just never displays). If yours doesn't show it, either check your launcher for a "notification overlay" style setting, or switch to one that has it (e.g. LTvLauncher).
If a DS3 is both wired (charging) and still paired to the Shield over Bluetooth, the controller
will automatically try reconnecting over Bluetooth the instant its wired connection drops (or
even just glitches) — Android's Bluetooth HID stack visibly chokes on the resulting duplicate
(bta_hh_co_open: Found an existing device with the same handle, already added, unexpected transition in logcat). The real user-facing symptom: RetroArch shows two
"PLAYSTATION(R)3 Controller" entries, and stops responding to input, because RetroArch tracks
controller port bindings by live ordinal position, not stable device ID — the flapping duplicate
shuffles which port everything's bound to, not just its own.
This isn't something this app (or RetroArch) can fix from software — it's the DS3's own firmware aggressively retrying its stored Bluetooth pairing. If you mainly use a DS3 wired, the real fix is to forget/unpair it from the Shield's Bluetooth settings entirely (Settings → Remotes & Accessories). With no stored bond, the controller has nothing to reconnect to. Wired charging and use are unaffected — this only costs wireless use, which you can re-pair later if you ever want it.
Every USB control-transfer call in this app (battery poll, authenticity check, charge
command, rumble test, host pairing) runs on one shared background thread that the battery
poll loop itself reschedules on. None of them were wrapped in a try/catch — an uncaught
exception on that thread is fatal to the whole app process by Android's default behavior,
and even in a hypothetical survived case the poll loop would never reach its own reschedule
call, silently stopping battery polling forever for every tracked controller, not just a
failing one. Fixed with safe wrappers at every background-thread post site and a guaranteed
reschedule in a finally block, plus per-device isolation so one dead/racing connection
can't skip polling the rest of a multi-controller batch. Live-tested against a real DS3
across multiple poll cycles post-fix with no regression.
A DS3 plugged in while fully powered off (no LEDs, no PS button press) could enumerate on USB but never actually start charging — confirmed live via a completely silent app log (zero charge-command activity at all) despite a real attach event firing and USB permission already being granted.
Real cause: once this app is set as the default handler for the DS3's USB device filter
(the normal state after first accepting the "Open with GIP Bridge"-style dialog), Android
delivers the attach event by launching MainActivity directly with the device attached to
the intent — it does not also send a general broadcast, so this app's own
service-registered USB receiver never saw a live attach event through that path at all.
MainActivity was discarding that intent's device info entirely instead of forwarding it
anywhere, so a controller plugged in while the service wasn't already tracking it — any
fresh attach, whether the controller was on or off — never triggered the charge-command
handshake. Earlier verified-working tests happened to coincide with a fresh service start
(which does its own one-time device scan), which is why this went unnoticed until a
controller was plugged in mid-session instead.
Fixed: MainActivity now forwards the real device through to the service on a live
attach, reusing all the already-correct connection/retry logic — no new charging logic
needed, just making sure the device info actually reaches it. Live-confirmed: the real
operational-mode handshake (0xF5 step) now completes within ~1 second of plugging in a
powered-off DS3, and the app shows Charging... within seconds.
pollBluetoothControllers() used to skip Bluetooth battery tracking entirely whenever any
USB controller was tracked — a reasonable-seeming assumption at the time (avoid double-tracking
the same physical unit across two code paths), but wrong for a genuine multi-controller setup:
one DS3 wired + a second, different DS3 connected wirelessly at the same time meant the wireless
one's battery never got tracked at all, silently.
Real constraint, not papered over: this app has no cheap way to prove two same-model DS3s across different transports are/aren't the same physical unit — there's no shared MAC/serial readable from both the USB and Bluetooth-input code paths at this app's level. Can't be made perfect. Made it strictly better instead: count-based suppression — only skip as many Bluetooth DS3 entries as there are wired ones (still correctly covers the real wired-controller-Bluetooth- ghost-reconnect case above), and track anything beyond that count as a real second controller.
Honestly scoped: verified by code review, a clean build, and a confirmed no-regression check against the existing single-controller/no-controller path (real controller still shows and polls correctly). Not live-tested against an actual second DS3 — none was available at fix time.
A DS3 could plug in, fire the attach event, and then silently and permanently fail to
charge — confirmed live via logcat showing UsbManager.openDevice() throwing
IllegalArgumentException: device /dev/bus/usb/001/003 does not exist or is restricted
on every one of the charge-command's automatic retry attempts.
Real cause: the retry path reused the same UsbDevice object captured at the original
attach event across every retry. If the kernel re-enumerated the controller under a
different bus path in the meantime — real, observed churn correlated with an NVIDIA system
app (com.nvidia.bluetooth.ps3usbpairer) that also claims and releases the same USB
interface on every attach — that stale object's path reference goes invalid, and every
retry fails identically no matter how many are attempted. This is also why charging
sometimes "just took a while" instead of failing outright: whether that re-enumeration race
actually happened on a given plug-in was down to timing luck.
Fixed: each retry now re-resolves the current UsbDevice from UsbManager's live
device list (matching on device ID, falling back to vendor/product ID if the ID itself
changed) instead of trusting the original reference. Also added logging to every retry and
give-up path in this chain, which was completely silent before — a real diagnosis gap in
its own right.
The kernel's sixaxis_set_operational_usb() (the sequence that brings a DS3 into
operational mode so its charge circuit engages) is three steps: GET_REPORT 0xF2,
GET_REPORT 0xF5, then a 1-byte write to the interrupt-OUT endpoint. The app did the
first two but never the third — even though the kernel comment it quotes says "some
compatible controllers... need another query plus a USB interrupt to get operational."
The two GET_REPORTs are the "another query" half; the interrupt-OUT write is the missing
"plus a USB interrupt" half.
Symptom: on a Shanwan/Gasia clone board (which is exactly the "compatible controller"
the kernel flag SHANWAN_GAMEPAD covers), the controller answers HID input reports fine —
so the app shows a plausible battery reading — but its charge circuit never actually turns
on. Same class of bug as the v1.4.3-era "step 2 was missing" issue, one step further down
the sequence.
Fixed: after the 0xF5 GET_REPORT, the charge command now also does a best-effort
1-byte bulkTransfer to the interface's interrupt-OUT endpoint, completing the port.
Two smaller follow-ups to the operational-sequence work above:
- Charge verification. The charge command used to declare success the moment the control
transfers returned without error — even if the controller never actually went operational.
It now reads the battery byte right after the handshake: while USB-connected it must report
charging/full (
>= 0xEE), not a0-5"on battery" index. If it still reads on-battery the handshake didn't take (some clones need more than one try even with step 3), so it retries within the existing attempt budget instead of reporting a controller that isn't charging as connected-and-fine. If every attempt fails to confirm, the status line saysUSB connected - charging not confirmedrather than painting a normal-looking reading. - Soft-claim first.
claimInterface(force = true)kernel-detaches whatever HID driver owns the interface, and Android has no API to re-attach it. The charge command now tries a non-forced claim first and only forces if that fails — for a real DS3 the OS gamepad driver is holding the interface so it still force-claims in practice, but it no longer needlessly evicts a driver when the interface is genuinely free.
USB-attach launches used to fall through into the exact same full-screen path as opening the
app from the launcher — yanking the whole UI over whatever was running (a game) on every
single plug-in, plus a USB-permission dialog to OK. When this activity is launched by the
plug-in event, it now forwards the controller straight to the background service and
finish()es immediately — no UI is ever shown for that launch. The manifest's
USB_DEVICE_ATTACHED intent-filter (matched via device_filter.xml) still auto-grants USB
permission to the app before onCreate even runs, so nothing is lost by skipping the
foreground path — the service's own permission check now finds it already granted and skips
the request dialog too.
The status jumped to 100% / Full after only a few minutes of charging and then stopped reporting progress. Byte 30 of the input report is the only charge signal the DS3 exposes, and its low bit ("done charging") flips well before the pack is actually topped off — worse on the Shanwan clone board this app targets, and on an old, degraded cell. A 2026-08-14 bench test showed it: "100% / Full" on the Shield, but a PC battery tool read only "High" seconds later, with no time to have drained.
A bare 0xEF is no longer relayed as Full on its own. It now has to (a) hold across three
consecutive polls and (b) come after the controller has been charging for at least 25 minutes.
Until both clear, the status reads "Charging (topping off)…" and the fast 30-second poll
stays active. A controller that already reads full on its first poll (so it was never seen
charging) skips the time gate but still needs the three-poll streak. The charge-complete alert
still fires once, on the real transition into Full. Each poll now also logs the raw byte-30
value (adb logcat -s Ds3Charger) so a future mismatch can be diagnosed from real data.
- An Android device with USB host support (tested on NVIDIA Shield TV Pro)
- A genuine Sony DualShock 3, or a Shanwan/Gasia clone — clones report the exact same USB vendor/product ID (
054c:0268) as a real DS3, so they're detected and charged the same way - Android 5.0 (API 21) or newer
Grab the APK from Releases and sideload it:
adb connect <device-ip>
adb install app-debug.apk
git clone <this-repo>
cd ds3-charger-app
./gradlew assembleDebug
Or open in Android Studio and hit Run.
assembleRelease is minified/shrunk (R8) and needs a signing keystore referenced from a local, gitignored keystore.properties (storeFile, storePassword, keyAlias, keyPassword) — without one it builds an unsigned APK that adb install will reject.
MIT — see LICENSE.
If this saved you time or you just want to say thanks:
Cash App: $CVanZetta