This page describes how to interact via HUAWEI FreeBuds 4i via BT RFCOMM. It can be useful if you’re developing your own application to replace AI Life. The same content is actual for HONOR EarBuds 2 Lite.
All data was collected via reverse-engineering HUAWEI’s AudioAssistant android app. Also this post uses some information from TheLastGimbus’s research notes, like checksum algorithm name.
Device validation
HUAWEI’s BT-headphones uses a SPP (Serial Port Protocol) to communicate with official mobile app. In a nutshell, it’s a simple Bluetooth RFCOMM socket, which can be accessed from any OS and any programming language. On my device that service works on port 16.
But, in a good way, you should in first check that device has this communication protocol via SDP (Bluetooth Service Discover Protocol) via their UUID: 00001101-0000-1000-8000-00805f9b34fb
. If device supports SPP, you’ll got a port number associated with that UUID. Most common programming languages should have a ready-to-use library to perform that request. If not, you can just hard-code RFCOMM connection to port 16, it should work in most cases.
In python3, that can be done with:
import bluetooth
SPP_SERVICE_UUID = "00001101-0000-1000-8000-00805f9b34fb"
DEVICE_ADDR = "xx:xx:xx:xx:xx" # Replace with your device MAC addr
services = bluetooth.find_service(address=DEVICE_ADDR, uuid=SPP_SERVICE_UUID)
first_match = services[0]
print(f"Found control protocol on {first_match['host']}, port={first_match['port']}")
If your device really supports SPP protocol, you’ll got output like: Found control protocol on xx:xx:xx:xx:xx, port=16
. A good way is add this check into your application, to prevent communicating with non-supported devices. After that, you can simply connect to your device using this port number.
Package structure
Inside HUAWEI, this protocol is named MDN
. I don’t know what it means, so I’ll just name it device control protocol, or SPP (Serial Port Protocol), which is more relevant.
Each data that you will send to device or got will have these structure. Please note field names, it will be used in this documentation. Offset from package start and length is presented in bytes.
Offset |
Length |
Field name |
Description |
0 |
1 |
Constant |
Constant value 0x5a (ASCII Z, integer 90) |
1 |
2 |
Data length (byte order: big) |
Length of parameters + 3 |
3 |
1 |
Constant |
Constant zero (0x00) |
4 |
2 |
Command type |
Two bytes, describing what action do you want to perform. In app logs, first is named ServiceID, second CommandID. I’ll just make to constant for any command. |
6 |
varies |
Parameters |
Command parameters in Type-Length-Value schema, see table bellow. Each command has their own set of parameters. |
varies |
2 |
Checksum |
CTC16-Xmodem checksum of all bytes before this two (thanks to TheLastGimbus, in HUAWEI app it was named just crc16, and realized with a very big array of precalculated data) |
As said, command parameters are encoded in TLV schema. So, each parameter look like:
Offset |
Length |
Description |
0 |
1 |
Type, eg. parameter number |
1 |
1 |
Length, count of bytes in value |
2 |
varies |
Value, parameter value bytes |
If command has multiple parameters, it will be listed one after one.
Example package (in HEX):
Based on that, there’s two major parts for any package: their command ID and list of their parameters.
Big part of this page is a list of available commands and their parameters. And, in code, you should realize some function or class that will build them to a set of bytes and send to device. Also, to fetch information from device, you should parse set of bytes from them to extract command ID and parameters that have been send to your application.
Playground
For simpler testing of command in this reference, I’ve created a simple Python script, that allows you manually interact with your device. This script defines Package
class that can build or parse SPP package with structure, defined above. And it can send\receive that packages to\from your FreeBuds 4i, which allows you to build and send test packages directly from console. This will make testing of any command very simple.
To run them, you’ll need Python 3.10 or newer with pybluez package (pip install pybluez-edge
). Download script here (GitHub Gist), and change device address in line 9. Now, run that script (OpenFreebuds must be closed, headset should be connected to your PC). It will connect to your device and give you an interactive Python prompt:
(venv) 9 23:27:08 [~/Projects/MyScripts/HuaweiPlayground] master
$ python main.py
Found service at port 16
Trying to connect...
OK
>>>
Here you can get packages from your device, and build & send your own. For example, this command will enable noise cancellation:
>>> Package(b"\x2b\x04", [
(1, 2),
]).send()
Pacakge
— Class that implements SPP Package with structure described in previous section;
- First parameter —
b"\x2b\x04"
— command ID, two bytes, in that example (HEX 2B04
) will change noise cancellation mode;
- Second parameter — list of command parameters, each parameter is presented as turtle
(type, value)
, where value
can be byte-string (like first parameter) or integer (one-byte parameters only).
A bit more extended example, and this thing you can’t do from official app:
>>> Package(b"\x0C\x01", [
(1, b"en-GB"),
(2, 1),
]).send()
That command will change device language to English. You can also change it to Chinese, just replace en-GB
with zh-CN
. I prefer Chinese translation because their phrases are shorten and play faster =).
Device info & state
That section describes commands, that allow you to get information about current device, their battery level and in-ear state.
Fetch device info
Request: To fetch base information about device, you should send package with command ID 0107
with any parameters. Fun fact: official app sends a couple of empty parameters, but this isn’t required, device will response to empty package too.
GET device info |
Length |
Value (Description) |
Command ID |
2 |
Constant HEX 0107 (1, 7) |
In playground, you can send that via Package(b"\x01\x07", []).send()
.
Response: After sending 0107
command, device will send back that response:
Device info bundle |
Length |
Value (Description) |
Command ID |
2 |
Constant HEX 0107 (1, 7) |
Parameter 2 |
2 |
?? |
Parameter 3 |
varies |
Hardware version |
Parameter 7 |
varies |
Firmware version |
Parameter 9 |
varies |
Device S\N |
Parameter 10 |
varies |
Device model |
Parameter 15 |
varies |
Device model (shorter?) |
Parameter 24 |
varies |
?? (looks like another S/N, official app don’t parse this) |
Parameter 25 |
1 |
Some byte, always 1 |
Fetch battery info
Request: To get current battery level, build and send command with ID 0108
.
GET battery |
Length |
Value (Description) |
Command ID |
2 |
Constant HEX 0108 (1, 8) |
Playground example: Package(b"\x01\x08", []).send()
.
Response: After that, you’ll got package like this. And the same package device will send if battery state will change while socket is connected.
Battery state |
Length |
Value (Description) |
Command ID |
2 |
0108 (1, 8) — Response for request above;
0127 (1, 39) — Battery changed notification |
Parameter 1 |
1 |
Lowest battery value, looks like kept for old app versions |
Parameter 2 |
3 |
Battery level for
left headphone in first byte,
right headphone in second byte,
case in third byte |
Parameter 3 |
3 |
Charging state for headphones and case, three bytes in the same order as in parameter 2. 0 — not charging, 1 — charging |
In-ear state
This package describe current headphone state, is left\right put in ear or not,
Request: N\A
Response: Headphones drop that package when you put in\out it into ear.
In-ear state |
Length |
Value (Description) |
Command ID |
2 |
Constant HEX 2B03 (43, 3) |
Parameter 8 |
1 |
New in-ear state for left headphone (may not exist) 1 — in, 0 — out |
Parameter 9 |
1 |
New in-ear state for right headphone (may not exist) 1 — in, 0 — out |
Base commands
In that section I’ll describe base commands that you can use to read\write device status and main settings.
Noise-cancellation
Request: If you want to request current ANC mode, send command ID 2B2A
.
GET current mode |
Length |
Value (Description) |
Command ID |
2 |
Constant HEX 2B2A (43, 42) |
Playground example: Package(b"\x2b\x2a", []).send()
.
Response/State: When you send that command, or ANC mode was changed with button on device, you’ll got a incoming package like that:
Current ANC mode |
Length |
Value (Description) |
Command ID |
2 |
Constant HEX 2B2A (43, 42) |
Parameter 1 |
2 |
First byte unknown, looks like it didn’t do anything in that headphones model; Second — current ANC mode, 0 — Normal, 1 — Noise cancellation, 2 — Awareness |
Write: To change ANC mode, build and send this package:
Change ANC mode |
Length |
Value (Description) |
Command ID |
2 |
Constant HEX 2B04 (43, 4) |
Parameter 1 |
1 |
New ANC mode, integer 0 — Normal, 1 — Noise cancellation, 2 — Awareness |
Playground example: Package(b"\x2b\x04", [(1, NEW_MODE)]).send()
, where NEW_MODE
should be 0, 1 or 2.
Pause when plug out option
Request: To get current setting value, send that package.
GET auto-pause |
Length |
Value (Description) |
Command ID |
2 |
Constant HEX 2B11 (43, 17) |
Playground example: Package(b"\x2b\x11", []).send()
.
Response:
Response auto-pause |
Length |
Value (Description) |
Command ID |
2 |
Constant HEX 2B11 (43, 17) |
Parameter 1 |
1 |
1 — enabled, 0 —disabled |
Write: To change this option, send that package:
Response auto-pause |
Length |
Value (Description) |
Command ID |
2 |
Constant HEX 2B10 (43, 16) |
Parameter 1 |
1 |
New config value: 1 — enabled, 0 —disabled: |
Playground: Package(b"\x2b\x10", [(1, NEW_VALUE)]).send()
where NEW_VALUE
is 0 or 1.
Double-tap action option
Request: To fetch current option value, send package with command ID 0120
.
GET double-tap |
Length |
Value (Description) |
Command ID |
2 |
Constant HEX 0120 (1, 32) |
Playground: Package(b"\x01\x20", []).send()
.
Response:
Double-tap opt value |
Length |
Value (Description) |
Command ID |
2 |
Constant HEX 0120 (1, 32) |
Parameter 1 |
1 |
Signed integer, double tap action for left headphone (check table bellow for value description) |
Parameter 2 |
1 |
Signed integer, double tap action for right headphone (check table bellow for value description) |
Parameter 3 |
5 |
?? |
Write: To change this option, send package with command ID 011f
, and place new option value to parameters 1 or 2 for left or right headphones. NOTE: only one parameter can be preset at once, you can’t change double-tap action for two headphones with one request.
Set double-tap act |
Length |
Value (Description) |
Command ID |
2 |
Constant HEX 011f (1, 31) |
Parameter 1 |
1 |
Signed integer, double tap action for left headphone (check table bellow for value description) |
Parameter 2 |
1 |
Signed integer, double tap action for right headphone (check table bellow for value description) |
Playground: Package(b"\x01\x1f", [(HEADPHONE_ID, NEW_VALUE),]).send()
where HEADPHONE_ID
should be 1 or 2 (left or right) and NEW_VALUE
should be from table below.
Available options:
Value |
Description |
-1 |
Do nothing |
0 |
Voice assistant |
1 |
Pause/resume music |
2 |
Next track |
7 |
Previous track |
Long tap action option
Request: To fetch current option value, send package with command ID 2B17
.
GET long-tap |
Length |
Value (Description) |
Command ID |
2 |
Constant HEX 2B17 (43, 23) |
Playground: Package(b"\x2b\x17", []).send()
.
Response:
Long-tap opt value |
Length |
Value (Description) |
Command ID |
2 |
Constant HEX 2B17 (43, 23) |
Parameter 1 |
1 |
Signed integer, long tap action for left headphone (check table bellow for value description) |
Parameter 2 |
1 |
Signed integer, long tap action for right headphone (check table bellow for value description) |
Parameter 3 |
5 |
?? |
Write: All as in double-tap action, one parameter with one value. Command ID is 2b16
. But… If you think that you can set different modes for different headphones, no, you can’t. When you change one option, device will copy them to other headphone.
Set double-tap act |
Length |
Value (Description) |
Command ID |
2 |
Constant HEX 2b16 (43, 22) |
Parameter 1 |
1 |
Signed integer, double tap action for left headphone (check table bellow for value description) |
Parameter 2 |
1 |
Signed integer, double tap action for right headphone (check table bellow for value description) |
Playground: Package(b"\x2b\x16", [(1, NEW_VALUE),]).send()
where NEW_VALUE
should be from table below.
Available options: (maybe device support more options, but I don’t checked)
Value |
Description |
-1 |
Do nothing |
10 |
Switch ANC modes in user-preferred order (see next paragraph |
Preferred ANC modes option
When long-tap is enabled, device will cycle through selected ANC modes. And this commands allow you to change that modes. I don’t know way developers made an extra option for that configuration, but that how it is.
Request: To fetch currently selected ANC modes, send package with command ID 2b19
.
GET ANC list |
Length |
Value (Description) |
Command ID |
2 |
Constant HEX 2b19 (43, 25) |
Playground: Package(b"\x2b\x19", []).send()
.
Response:
Long-tap opt value |
Length |
Value (Description) |
Command ID |
2 |
Constant HEX 2b19 (43, 25) |
Parameter 1 |
1 |
Signed integer, preferred ANC’s for left headphone (check table bellow for value description) |
Parameter 2 |
1 |
Signed integer, preferred ANC’s for right headphone (check table bellow for value description) |
Parameter 3 |
11 |
?? (ordered integers from 1 to 10, idk why they are here) |
Write: All the same as in long-tap configuration write, only command ID another: 2b18
.
Set double-tap act |
Length |
Value (Description) |
Command ID |
2 |
Constant HEX 2b18 (43, 24) |
Parameter 1 |
1 |
Signed integer, long tap action for left headphone (check table bellow for value description) |
Parameter 2 |
1 |
Signed integer, long tap action for right headphone (check table bellow for value description) |
Playground: Package(b"\x2b\x18", [(1, NEW_VALUE),]).send()
where NEW_VALUE
should be from table below.
Available options:
Value |
Description |
1 |
Disable ANC and noise cancellation |
2 |
Cycle all ANC modes |
3 |
Noise cancellation and awareness |
4 |
Disable ANC and awareness |
Extras
This features works on our device, but isn’t available in official apps directly. I found them into sources.
Device logs
Sometime, when sound isn’t playing, device start sending their logs in that structure:
Logs |
Length |
Value (Description) |
Command ID |
2 |
Constant HEX 0a0d (10, 13) |
Parameter 1 |
1 |
Looks like always 3 |
Parameter 2 |
4 |
Looks like some event ID |
Parameter 4 |
varies |
JSON-encoded data as ASCII string |
My attempt to decode that didn’t success, so I can only give advice to ignore that packages.
Voice language
Request: This command will ask headphones for a list of available voice languages. Unfortunately, there’s no way to get current enabled language.
GET languages |
Length |
Value (Description) |
Command ID |
2 |
Constant HEX 0C02 (12, 2) |
Playground: Package(b"\x0c\x02", []).send()
.
Response:
Langs list |
Length |
Value (Description) |
Command ID |
2 |
Constant HEX 0C02 (12, 2) |
Parameter 3 |
varies |
Comma-separated list of available locale options, example: en-GH,zh-CN. |
Parameter 4 |
1 |
?? |
Write: To change current language, send package with command ID 0C01
in that struct:
Change lang |
Length |
Value (Description) |
Command ID |
2 |
Constant HEX 0C01 (12, 1) |
Parameter 1 |
5 |
New language from list of available, example zh-CN |
Parameter 2 |
1 |
Constant 1, idk why, but without that it won’t work |
Playground: Package(b"\x0c\x01", [(1, b"zh-CN"), (2, 1)]).send()
.
Unavailable in this reference
- I don’t research firmware update commands due to risk to brick my device. And as I know, all official firmware builds are signed, and device anyway won’t load customs. So, there’s no need to research that, for me;