Как я реверс-инженерил китайский блютуз контроллер
С чего все началось
Мы с друзьями купили лед ленту с управлением по блютузу и повесили её в зале. В приложении была прикольная функция цветомузыки, но она была недостаточно хороша. Так что я решил реализовать её самому.
Шаги к реализации
Научиться отправлять свои данные на блютуз контроллер
Анализировать аудиопоток, находит резкие изменения в бите.
Скрестить два скрипта
Написать обертку для удобной работы
Как понять что отправлять на блютуз контроллер?
У нас есть рабочее приложение для контроля лед ленты через блютуз. На каждую кнопку в интерфейсе отправляется некий запрос. Нам надо «перехватить» его и расшифровать.
Скриншот из MohuanLED
Получаем информацию о устройстве
Чтобы разобраться в логах — нам надо вытащить мак адрес устройства.
Идем в настройки блютуза, ищем лед ленту и копируем мак адрес:
Пора доставать логи!
В инструментах разработчика находим пункт «Enable Bluetooth HCI snoop log»
Перезагружаем блютуз, теперь все блютуз запросы будут логироваться.
Получение логов
К сожалению вы не можете просто достать напрямую файл с логами, это придётся делать окольными путями.
Подключите телефон к компьютеру, поставьте adb на пк и включите отладку по юсб в настройках подключения.
Теперь надо сгенерировать «багрепорт» — это зип архив с логами системы. Команда для генерации: adb bugreport btsnoop_hci.log.
Теперь на устройстве хранится зип архив с данными о логах, выкачиваем его по имени которое появилось в выводе:
adb pull /data/user_de/0/com.android.shell/files/bugreports/bugreport-flame-TP1A.221005.002.B2–2023–11–02–19–00–13.zip
В домашней папке появился зип архив. По пути /FS/data/misc/bluetooth/logs/ внутри архива будут логи. Открываем их через WireShark (https://www.wireshark.org/) и начинаем анализ.
Что и как анализировать?
Для начала надо отсеять ненужные логи, вводим
bluetooth.addr==
Теперь у нас есть все данные которые мы отправляли на контроллер.
В моем случае все было достаточно просто. Основание запроса было всегда одинаковым — 6996060101, а после — шел некоторый код, очень сильно напоминавший 16-ричное представление цвета. То есть 69960501ffffffff — включит белый цвет, а 69960501fff000 — желтый.
Питон скрипт
Попробуем подключиться к контроллеру через питон.
Я написал простенький скрипт:
import sounddevice as sd
import numpy as np
from bluepy import btle
import sys
class LEDController(btle.Peripheral):
def __init__(self, device_addr):
print("Connecting to:", device_addr)
super().__init__(device_addr)
self.characteristic = self.getCharacteristics(uuid='0000ee01-0000-1000-8000-00805f9b34fb')[0]
def send_command(self, command):
self.characteristic.write(bytes.fromhex(command), withResponse=True)
def list_devices():
scanner = btle.Scanner()
devices = scanner.scan(5.0)
device_dict = {}
for i, device in enumerate(devices):
print(f"{i}. {device.addr} ({device.getValueText(9)})")
device_dict[i] = device
return device_dict
def main():
devices = list_devices()
device_list = list(devices.values()) # Convert values to a list
device_choice = int(input("Choose a device (number): "))
device_addr = device_list[device_choice].addr
print(f"Connecting to {device_addr}...")
device = LEDController(device_addr)
color_code = input("Enter the 16-hex digit color code: ")
try:
device.send_command(f'6996060101{color_code}')
finally:
device.disconnect() # Отсоединение устройства при завершении программы
if __name__ == "__main__":
main()
Он выводит список блютуз устройств и пытается подключиться к выбранному и , а затем — пробует отправить запрос со сменой цвета на выбранный пользователем.
Анализ аудиопотока
Для анализа аудио можно использовать библиотеку sounddevice для питона.
Напишем простой скрипт, который будет читать аудиопоток со стандартного вывода и выводить ON, в пиковых моментах, а OFF в моментах со средней амплитудой звука:
import sounddevice as sd
import numpy as np
import json
previous_volume = None
minVol = 0
maxVol = 0
count_of_zeros = 0
def callback(indata, frames, time, status):
global minVol
global maxVol
global previous_volume
global count_of_zeros
if status:
print(status, file=sys.stderr)
volume_norm = np.linalg.norm(indata) * 10
if volume_norm == 0:
return
if previous_volume is not None and volume_norm < previous_volume / 7:
count_of_zeros += 1
if(count_of_zeros > 20):
minVol = 0
maxVol = 0
count_of_zeros = 0
previous_volume = None
return
if maxVol < volume_norm:
maxVol = volume_norm
minVol = maxVol - 45
if minVol < 0:
minVol = 0
if volume_norm*10 < minVol:
minVol = volume_norm*10
maxVol = minVol + 45
if volume_norm < minVol:
print("OFF", volume_norm)
return
string_to_print = "ON " * 20
if count_of_zeros > 0:
count_of_zeros -= 1
previous_volume = volume_norm
stream = sd.InputStream(callback=callback)
with stream:
input("Press Enter to stop recording")
Следующим шагом было совместить два скрипта в один, но это привело к высокой задержке (около 5 секунд), стало понятно, что питон — не самый оптимальный вариант для риалтайм анализа.
Я решил переписать код на го, вот что получилось:
package main
import (
"fmt"
"log"
"math"
"strings"
"time"
"github.com/gordonklaus/portaudio"
"encoding/hex"
"flag"
"tinygo.org/x/bluetooth"
)
// Переменные для настройки параметров аудио и устройства Bluetooth
var (
SampleRate float64 = 44100
Threshold float64 = 800
BufferSize int = 512
CutoffFrequency float64 = 250
delay int = 3
color string = "ff0000"
)
// Константы для удобства работы с аудио и состоянием
const (
NumInputChannels = 1
StateOff = "off"
StateOn = "on"
)
// Инициализация флагов для настройки параметров программы
func init() {
flag.Float64Var(&SampleRate, "sampleRate", 44100, "Sample rate of the audio")
flag.Float64Var(&Threshold, "threshold", 800, "Volume threshold for triggering an action")
flag.IntVar(&BufferSize, "bufferSize", 256, "Size of the audio buffer")
flag.Float64Var(&CutoffFrequency, "cutoffFrequency", 250, "Cutoff frequency for the low-pass filter")
flag.IntVar(&delay, "delay", 3, "Delay between audio buffer reads")
flag.StringVar(&color, "color", "ff0000", "Color for the LED")
}
// Структура для отслеживания пиков звука
type PeakTracker struct {
state string
isRising bool
peakStartLevel float64
peakEndLevel float64
lastState string
}
// Структура для хранения данных для Bluetooth
type data struct {
off []byte
on []byte
color []byte
}
// Заранее определенные данные для Bluetooth
var sampleData = data{
off: []byte{0x69, 0x96, 0x02, 0x01, 0x00},
on: []byte{0x69, 0x96, 0x02, 0x01, 0x01},
//6996060101ffff to color red
color: []byte{0x69, 0x96, 0x06, 0x01, 0x01, 0xff, 0xff},
}
// Функция для создания нового объекта PeakTracker
func NewPeakTracker() *PeakTracker {
return &PeakTracker{
state: StateOff,
isRising: false,
}
}
// Функция для проверки изменения состояния и выполнения действий при изменении
func (pt *PeakTracker) CheckStateChange() {
if pt.state != pt.lastState {
pt.lastState = pt.state // Обновляем lastState после печати
switch pt.state {
case StateOff:
fmt.Println("off")
_, err := bluetoothChar.WriteWithoutResponse(sampleData.off)
if err != nil {
log.Fatalf("can't write characteristic: %s", err)
}
case StateOn:
fmt.Println("on")
_, err := bluetoothChar.WriteWithoutResponse(getColor(color))
if err != nil {
log.Fatalf("can't write characteristic: %s", err)
}
}
}
}
// Структура для отслеживания порога амплитуды звука
type ThresholdTracker struct {
maxMovingAverage *MovingAverage
minMovingAverage *MovingAverage
threshold float64
}
// Функция для создания нового объекта ThresholdTracker
func NewThresholdTracker(size int) *ThresholdTracker {
return &ThresholdTracker{
maxMovingAverage: NewMovingAverage(size),
minMovingAverage: NewMovingAverage(size),
}
}
// Функция для обновления порога на основе движущегося среднего максимума и минимума
func (tt *ThresholdTracker) Update(value float64) {
// Обновляем maxMovingAverage, если текущее значение выше текущего среднего максимума или окно еще не полное.
if value > tt.maxMovingAverage.Average() || !tt.maxMovingAverage.full {
tt.maxMovingAverage.Add(value)
}
// Обновляем minMovingAverage, если текущее значение ниже текущего среднего минимума или окно еще не полное.
if value < tt.minMovingAverage.Average() || !tt.minMovingAverage.full {
tt.minMovingAverage.Add(value)
}
// Обновляем порог на основе новых средних максимума и минимума.
tt.threshold = (tt.maxMovingAverage.Average() + tt.minMovingAverage.Average()) / 2
}
// Функция для вычисления среднего значения в окне
func (ma *MovingAverage) Average() float64 {
if ma.full {
return ma.sum / float64(ma.size)
}
// Если окно еще не полное, делим на фактическое количество элементов.
return ma.sum / float64(ma.index)
}
// Функция для обновления состояния PeakTracker с использованием порога
func (pt *PeakTracker) UpdateWithThreshold(amplitude, threshold float64) {
switch pt.state {
case StateOff:
if amplitude > threshold {
pt.isRising = true
pt.peakStartLevel = amplitude
}
if pt.isRising && amplitude < pt.peakStartLevel-threshold {
pt.state = StateOn
pt.peakEndLevel = amplitude
pt.isRising = false
}
case StateOn:
if amplitude < threshold {
pt.state = StateOff
pt.isRising = false
}
}
pt.CheckStateChange()
}
// Функция для обновления состояния PeakTracker с использованием динамического порога
func (pt *PeakTracker) UpdateWithDynamicThreshold(amplitude, threshold float64) {
switch pt.state {
case StateOff:
if amplitude > threshold {
pt.state = StateOn
}
case StateOn:
if amplitude < threshold {
pt.state = StateOff
}
}
pt.CheckStateChange()
}
// Структура для применения низкочастотного фильтра
type LowPassFilter struct {
a float64
y, yPrev float64
}
// Создание нового объекта LowPassFilter
func NewLowPassFilter(sampleRate, cutoffFrequency float64) *LowPassFilter {
dt := 1.0 /