С чего все началось

Мы с друзьями купили лед ленту с управлением по блютузу и повесили её в зале. В приложении была прикольная функция цветомузыки, но она была недостаточно хороша. Так что я решил реализовать её самому.

Шаги к реализации

  • Научиться отправлять свои данные на блютуз контроллер

  • Анализировать аудиопоток, находит резкие изменения в бите.

  • Скрестить два скрипта

  • Написать обертку для удобной работы

Как понять что отправлять на блютуз контроллер?

У нас есть рабочее приложение для контроля лед ленты через блютуз. На каждую кнопку в интерфейсе отправляется некий запрос. Нам надо "перехватить" его и расшифровать.

Скриншот из MohuanLED
Скриншот из 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==<MAC адресс вашего устройства>

Теперь у нас есть все данные которые мы отправляли на контроллер.

В моем случае все было достаточно просто. Основание запроса было всегда одинаковым - 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 /

Комментарии (2)


  1. 402d
    17.11.2023 15:37
    +5

    0000ee01-0000-1000-8000-00805f9b34fb

    могу предположить, что внутри ESP32 . по началу 02 00 04 типовая реализация spp over ble (com)