Control the EQ on your JBL speaker from Windows, with a terminal UI or a CLI.
Every write is read back and compared band by band, so the tool tells you what the speaker actually did rather than what it was asked to do.
Independent interoperability project, built from static analysis of JBL Portable 6.9.12 and hardware validation. Not affiliated with or endorsed by JBL or Harman. All trademarks belong to their owners. Do not commit or redistribute APK or firmware files with this repository.
python -m pip install openjbl
openjbl-tuiPress SCAN. It finds the strongest JBL nearby, connects, verifies the EQ route, and opens the Equalizer. Pick a profile, press APPLY TO SPEAKER.
Close JBL Portable on any phone nearby first, or it will compete for the connection.
Prefer the command line?
openjbl scan # find your speaker
openjbl probe --address DEVICE_FROM_SCAN # read-only: what does it support?
openjbl set-profile --pid 20e3 bass # prints the packet, sends nothing
openjbl set-profile --pid 20e3 bass --address DEVICE --apply| Discovery | BLE scan with automatic JBL model/PID detection from Harman advertisement bytes, service UUIDs, names, and Windows paired metadata |
| Protocols | Legacy simple, advanced-level and parametric EQ; Protocol 4 0E02 parametric; Grip-style 0E7F quantized. Routed automatically from the PID across 37 catalogued models |
| Profiles | 87 curves in three tiers, plus your own saved ones |
| Evidence | Read-back verification, per-band comparison, protocol acknowledgement checks, and JSONL audit logs |
| Transports | BLE GATT, and Bluetooth Classic SPP over a COM port |
This writes to hardware you own, so the defaults are conservative.
- The CLI sends nothing without
--apply. Mutating commands print the encoded packet and stop. - The EQ stays locked until a speaker is verified. A live BLE connection has to answer with a supported EQ response first. Cached Windows pairing metadata never unlocks it, and changing the address or model locks it again.
- Verification means the exact link that was probed. If it drops and reconnects, authorisation is revoked rather than carried over.
- Gains outside the model's own UI range are refused unless you pass
--allow-extended, and the TUI asks a second time before writing a boost past it. - Nothing is installed automatically. The TUI can check PyPI and tell you a
release exists;
openjbl updateinstalls it when you ask. - No OTA, authentication bypass, factory reset, or limiter-overclock path is implemented.
APPLY TO SPEAKER reads the EQ before writing, writes, checks the
acknowledgement, then reads it again and compares every band. It reports
WRITE VERIFIED only when the acknowledgement was accepted and every decoded
band matches.
It distinguishes CHANGED + VERIFIED from ALREADY MATCHED + VERIFIED, and
says the latter only when the pre-write state was actually decoded -- if it was
not, it says so instead of guessing.
Activity records the transaction ID, route, before/write/after frames, decoded responses, requested and actual gains, per-band deltas, elapsed time, and the failure stage. Audit records store a hash of the target, not its address.
Run openjbl profiles for the full list, or see Sound profiles.
24 standard -- inside each model's own UI range, so they need no confirmation: Flat, Balanced, Bass Heavy, Deep Bass, Punch Bass, Warm, Loudness, Crystal Clear, Bright, Detail Monitor, Vocal, Podcast, Acoustic, Rock, Metal, Hip-Hop, EDM, Pop, Jazz, Classical, Cinema, Gaming, Outdoor, Night.
63 LAB -- up to +/-24 dB, needing --allow-extended. Most spend that range on
cuts, which is what it is good for. A large boost has to come out of the DSP's
headroom and the speaker's limiter, so it distorts and then gets quieter; a cut
costs level, which the volume knob gives back. The bass family leaves 125 Hz at
0 dB and lowers everything above it -- the tilt is what the ear hears as bass.
The 18 that do boost are labelled DANGER and need a second confirmation.
My profiles -- edit the gains, press SAVE AS, name it. Stored as a
seven-point tonal curve, so a profile saved on one speaker still means something
on a model with a different band count.
openjbl save-profile --name "Living Room" --pid 20e3 --gains 5 2 0 -1 -2 0 3
openjbl profiles --mine
openjbl delete-profile --key user-living-roomopenjbl scan # BLE advertisements + Windows paired JBLs
openjbl services --address DEVICE # GATT services and characteristics
openjbl probe --address DEVICE # which EQ generation does it speak?
openjbl models # the APK's model/PID database
openjbl models 20e3
openjbl get-simple --address DEVICE # read the EQ, change nothing
openjbl get-advanced --address DEVICE
openjbl get-p4-eq --address DEVICE
openjbl check-updateset-auto picks the wire format from the PID and refuses products the APK
declares no EQ support for. It is the recommended path.
openjbl set-auto --pid 20e3 5 3 -2.5 -3 -2 -.5 1
openjbl set-profile --pid 20e3 bass --address DEVICE --applyDirect codec control, for when you know exactly what you want:
# Legacy 3-band signed levels
openjbl set-simple --bass 4 --mid 1 --treble -1
# Legacy advanced signed levels
openjbl set-levels 3 2 1 0 -1 -2 -3
# Legacy parametric bands: type,frequency,gain,q
openjbl set-parametric --category custom_c2 `
--band "low_shelf,125,3,0.707" `
--band "peaking,250,2,2" `
--band "peaking,500,0,2" `
--band "peaking,1000,-1,2" `
--band "peaking,2000,0,2" `
--band "peaking,4000,1,2" `
--band "high_shelf,8000,2,0.707"
# Charge 6 custom-band order: 125, 250, 500, 1k, 2k, 4k, 8 kHz
openjbl set-charge6 5 3 -2.5 -3 -2 -.5 1Filter types: low_shelf, peaking, high_shelf, low_pass, high_pass.
Charge 6 custom band 1 takes -9..+6 dB with asymmetric negative steps; bands 2-7 take -6..+6 dB in 0.5 dB steps, matching the APK's UI mapping.
openjbl listen --address DEVICE --seconds 15 --log capture.log
openjbl decode "AA980000"
openjbl raw "AA6C00" # prints only
openjbl raw "AA6C00" --address DEVICE --apply --i-understand
openjbl raw "AA6C00" --port COM7 --apply --i-understand # Classic SPPRaw transmission needs both --apply and --i-understand.
from openjbl.protocol import describe_frame, set_simple_eq
packet = set_simple_eq(category=0xC1, bass=4, mid=1, treble=-1)
print(packet.hex(), describe_frame(packet))Protocol builders do no Bluetooth I/O, so they are deterministic and reusable.
Transmission is isolated in openjbl.connection; keep your own confirmation
layer in front of it.
Bluetooth must be on, and Windows must allow desktop apps to use Bluetooth and
location. Configuration, saved profiles and audit logs live in
%LOCALAPPDATA%\openjbl\.
The default BLE UUIDs, extracted from the APK:
service 65786365-6C70-6F69-6E74-2E636F6D0000
RX 65786365-6C70-6F69-6E74-2E636F6D0001
TX 65786365-6C70-6F69-6E74-2E636F6D0002
Some products derive their service UUID from PID/MID. Run services to find it,
then pass --service, --rx and --tx.
For SPP, pair the speaker first and find its outgoing COM port in Device Manager.
python -m pip install .For development, with the QA toolchain:
py -m venv .venv
.venv\Scripts\Activate.ps1
python -m pip install -e ".[dev]"- Protocol reference
- Supported models
- Hardware-confirmed Charge 6 profile
- Sound profiles
- Security and QA audit
ruff format --check .
ruff check .
mypy src/openjbl
pytest
bandit -q -c pyproject.toml -r src/openjbl
pip-audit .
python -m build
twine check dist/*GitHub Actions runs Windows and Linux on Python 3.10 and 3.12 with coverage, linting, typing, security and package validation. APK/XAPK files, decompiler output, captures, local configuration and audit logs are excluded from version control.
