feat: complete initial firmware implementation for ESP32-S3 (N16R8) hardware bridge

This commit is contained in:
cuicuiV5 committed 2026-08-27 20:53:59 +08:00
commit 1cca7c4ec3
31 files changed
+2024

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
.pio
.vscode
.idea
*.pyc
__pycache__/
build/
*.bin
*.elf
*.map
*.hex
*.log
+74
View File
@@ -0,0 +1,74 @@
# RemoteMapper-ESP32 · 小米蓝牙语音遥控器 USB 复合硬件桥接固件
> 🎯 **基于 ESP32-S3 N16R8 (16MB Flash + 8MB Octal PSRAM) 硬件方案**
> 彻底解决 Windows 内核键盘驱动丢弃按键、物理键盘冲突、虚拟声卡 (VB-Cable) 依赖等问题!
---
## 🌟 核心特性与优势
1. 🎙️ **真·免驱动 USB 物理麦克风 (UAC 1.0)**:
- ESP32-S3 原生 USB OTG 模拟标准 16kHz 16-bit Mono 麦克风设备;
- 遥控器语音键按下即说话,松开即停止,即插即用,无需安装任何第三方声卡驱动。
2. ⌨️ **独立硬件级 USB HID 键盘 & 多媒体控制器**:
- 小米遥控器音量加(`0x80`)、音量减(`0x81`)直接转换为 PC 标准 **Consumer Volume Up/Down**;
- 返回键(`0xF1`)转换为 **Consumer AC Back / Esc**(网页与播放器原生后退);
- 电源键(`Alt+Tab` / 长按关屏)、主页键(`Win+D`)、菜单键(`Space` 播放暂停)、TV 键(`F8` 语音唤醒);
- 与物理主键盘物理隔离,彻底规避软件钩子冲突。
3. ⚡ **双核高吞吐实时 DSP 流水线**:
- **Core 0**:专门负责 NimBLE 5.0 协议栈、HOGP 按键监听、ATVV 语音流握手与 IMA-ADPCM 4-bit 解码 + Declip 尖峰消除 + 3-Tap FIR 低通平滑 + 动态 AGC;
- **Core 1**:负责 TinyUSB 复合设备(UAC 麦克风音频泵、HID 键盘派发、按键手势状态机与 CDC 诊断串口)。
4. 🛡️ **健壮的保活与看门狗机制**:
- 450ms 音频静音看门狗:防止由于蓝牙丢包导致的语音键状态卡住;
- 自动重连与绑定恢复:NVS 存储配对信息,开机与遥控器唤醒时秒连。
---
## 📁 目录结构
```text
D:\tool\RemoteMapper-ESP32/
├── default_16MB.csv # 16MB Flash 分区表
├── platformio.ini # PlatformIO 编译与环境配置
├── build.bat # 一键编译脚本
├── flash.bat # 一键烧录脚本
├── monitor.bat # 串口监控日志脚本
├── test.bat # 单元测试运行脚本
├── include/
│ ├── app_config.h # 全局引脚、采样率、缓冲区容量宏定义
│ └── version.h # 固件版本号
├── src/
│ ├── main.cpp # FreeRTOS 双核主入口与任务调度
│ ├── audio/ # ADPCM 解码、Declip、FIR 平滑、AGC 与环形缓冲区
│ ├── ble/ # NimBLE Central、HOGP 监听与 ATVV 协议客户端
│ ├── usb/ # TinyUSB UAC 麦克风与 HID 键盘/多媒体控制器
│ ├── keymap/ # 按键手势状态机与键码映射引擎
│ └── cli/ # Serial CDC 诊断与 JSON 状态接口
└── test/
└── native/ # 算法与状态机原生验证单元测试
```
---
## 🚀 编译与烧录
### 1. 运行单元测试
```cmd
test.bat
```
### 2. 编译固件
```cmd
build.bat
```
### 3. 烧录到 ESP32-S3
将 ESP32-S3 开发板通过 USB 数据线连接到电脑(注意插在 `USB/OTG` 接口):
```cmd
flash.bat
```
### 4. 查看实时串口日志
```cmd
monitor.bat
```
+10
View File
@@ -0,0 +1,10 @@
@echo off
echo ========================================================
echo Building RemoteMapper-ESP32 Firmware (ESP32-S3 N16R8)
echo ========================================================
python -m platformio run -e esp32s3_n16r8
if %ERRORLEVEL% NEQ 0 (
echo [ERROR] Build Failed!
exit /b %ERRORLEVEL%
)
echo [SUCCESS] Build completed successfully!
+7
View File
@@ -0,0 +1,7 @@
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x6000,
otadata, data, ota, 0xf000, 0x2000,
app0, app, ota_0, 0x20000, 0x600000,
app1, app, ota_1, 0x620000,0x600000,
spiffs, data, spiffs, 0xc20000,0x3c0000,
coredump, data, coredump,0xfe0000,0x20000,
1 # Name Type SubType Offset Size Flags
2 nvs data nvs 0x9000 0x6000
3 otadata data ota 0xf000 0x2000
4 app0 app ota_0 0x20000 0x600000
5 app1 app ota_1 0x620000 0x600000
6 spiffs data spiffs 0xc20000 0x3c0000
7 coredump data coredump 0xfe0000 0x20000
+10
View File
@@ -0,0 +1,10 @@
@echo off
echo ========================================================
echo Flashing RemoteMapper-ESP32 Firmware to ESP32-S3
echo ========================================================
python -m platformio run -e esp32s3_n16r8 -t upload
if %ERRORLEVEL% NEQ 0 (
echo [ERROR] Flash Failed!
exit /b %ERRORLEVEL%
)
echo [SUCCESS] Flash completed successfully!
+69
View File
@@ -0,0 +1,69 @@
#pragma once
#include <stdint.h>
#include <stdbool.h>
#ifdef __cplusplus
extern "C" {
#endif
// ==========================================
// 1. Audio & DSP Parameters
// ==========================================
#define AUDIO_SAMPLE_RATE 16000 // 16kHz sample rate
#define AUDIO_BITS_PER_SAMPLE 16 // 16-bit PCM
#define AUDIO_CHANNELS 1 // Mono
#define AUDIO_DEFAULT_FRAME_BYTES 120 // Default 120 bytes ADPCM per BLE frame
#define AUDIO_DEFAULT_FRAME_SAMPS 240 // 120 * 2 = 240 PCM samples per frame
#define AUDIO_RING_BUFFER_SIZE 4096 // Ring buffer capacity (in samples, ~256ms buffer)
// AGC & Filter parameters
#define AGC_TARGET_LEVEL 28000.0f
#define AGC_DECAY_RATE 0.9997f
#define AGC_MAX_GAIN 30.0f
#define AGC_NOISE_FLOOR 200.0f
#define AGC_INITIAL_PEAK 1000.0f
#define DECLIP_THRESHOLD 1000
// ==========================================
// 2. BLE Central & ATVV Parameters
// ==========================================
#define BLE_REMOTE_NAME_PREFIX "MI RC"
#define BLE_REMOTE_MAC_PREFIX "c0:5d:39"
#define BLE_SCAN_INTERVAL_MS 100
#define BLE_SCAN_WINDOW_MS 80
#define BLE_SILENCE_WATCHDOG_MS 450 // Voice key release watchdog timeout (ms)
#define BLE_KEEP_ALIVE_INTERVAL 2000 // MIC_EXTEND interval during active voice (ms)
// ATVV GATT UUIDs (128-bit)
#define ATVV_SVC_UUID "ab5e0001-5a21-4f05-bc7d-af01f617b664"
#define ATVV_CHAR_CMD_UUID "ab5e0002-5a21-4f05-bc7d-af01f617b664"
#define ATVV_CHAR_AUD_UUID "ab5e0003-5a21-4f05-bc7d-af01f617b664"
#define ATVV_CHAR_CTL_UUID "ab5e0004-5a21-4f05-bc7d-af01f617b664"
// HOGP UUIDs (Standard Bluetooth SIG 16-bit)
#define HOGP_SVC_UUID 0x1812
#define HOGP_REPORT_CHAR_UUID 0x2A4D
// ==========================================
// 3. FreeRTOS Task & Multi-Core Pinning
// ==========================================
#define TASK_CORE_BLE 0 // Core 0: BLE & Audio decoding
#define TASK_CORE_USB 1 // Core 1: TinyUSB & HID/Audio push
#define PRIO_TASK_USB 6 // Real-time USB Isochronous & HID
#define PRIO_TASK_AUDIO_DSP 5 // High-priority audio stream decoding
#define PRIO_TASK_BLE 4 // NimBLE client processing
#define PRIO_TASK_KEYMAP_CLI 3 // Key mapping state machine & CLI
// ==========================================
// 4. Default Keymap & Hotkey Codes
// ==========================================
// Default Voice Input Hotkey: Right Alt + Comma (WeChat Voice IME)
// HID Keyboard Modifiers: 0x01=LCTRL, 0x02=LSHIFT, 0x04=LALT, 0x08=LGUI, 0x10=RCTRL, 0x20=RSHIFT, 0x40=RALT, 0x80=RGUI
#define DEFAULT_VOICE_MODIFIER 0x40 // KEY_MOD_RALT
#define DEFAULT_VOICE_KEY 0x36 // HID Usage for Comma ','
#ifdef __cplusplus
}
#endif
+6
View File
@@ -0,0 +1,6 @@
#pragma once
#define FIRMWARE_NAME "RemoteMapper-ESP32"
#define FIRMWARE_VERSION "1.0.0"
#define HARDWARE_TARGET "ESP32-S3-N16R8"
#define AUTHOR "RemoteMapper Project"
+5
View File
@@ -0,0 +1,5 @@
@echo off
echo ========================================================
echo Starting Serial Monitor (115200 baud) - Ctrl+C to Exit
echo ========================================================
python -m platformio device monitor -b 115200
+54
View File
@@ -0,0 +1,54 @@
; PlatformIO Project Configuration File for RemoteMapper-ESP32
; Model: ESP32-S3-DevKitC-1 N16R8 (16MB Flash, 8MB Octal PSRAM)
[platformio]
default_envs = esp32s3_n16r8
src_dir = src
include_dir = include
[env:esp32s3_n16r8]
platform = espressif32
board = esp32-s3-devkitc-1
framework = arduino
; 16MB Flash & 8MB Octal PSRAM Settings
board_build.mcu = esp32s3
board_build.f_cpu = 240000000L
board_build.f_flash = 80000000L
board_build.flash_mode = qio
board_build.flash_size = 16MB
board_upload.flash_size = 16MB
board_build.partitions = default_16MB.csv
board_build.arduino.memory_type = qio_opi
; Build flags: Enable TinyUSB OTG, NimBLE, Audio/HID composite descriptors
build_flags =
-DBOARD_HAS_PSRAM
-mfix-esp32-psram-cache-issue
-DARDUINO_USB_MODE=0
-DARDUINO_USB_CDC_ON_BOOT=0
-DCORE_DEBUG_LEVEL=3
-DCFG_TUSB_MCU=OPT_MCU_ESP32S3
-DCFG_TUD_ENABLED=1
-DCFG_TUD_AUDIO=1
-DCFG_TUD_HID=2
-DCFG_TUD_CDC=1
-I include
lib_deps =
h2zero/NimBLE-Arduino@^1.4.2
bblanchon/ArduinoJson@^7.0.4
monitor_speed = 115200
monitor_filters = esp32_exception_decoder, time, colorize
upload_speed = 921600
; Native test environment for running unit tests directly on PC
[env:native_test]
platform = native
test_framework = custom
build_flags =
-std=c++17
-I include
-I src
-DUNIT_TEST_NATIVE
+77
View File
@@ -0,0 +1,77 @@
#include "adpcm_decoder.h"
// Standard IMA-ADPCM Step Table (89 entries)
static const int16_t STEP_TABLE[89] = {
7, 8, 9, 10, 11, 12, 13, 14, 16, 17,
19, 21, 23, 25, 28, 31, 34, 37, 41, 45,
50, 55, 60, 66, 73, 80, 88, 97, 107, 118,
130, 143, 157, 173, 190, 209, 230, 253, 279, 307,
337, 371, 408, 449, 494, 544, 598, 658, 724, 796,
876, 963, 1060, 1166, 1282, 1411, 1552, 1707, 1878, 2066,
2272, 2499, 2749, 3024, 3327, 3660, 4026, 4428, 4871, 5358,
5894, 6484, 7132, 7845, 8630, 9493, 10442, 11487, 12635, 13899,
15289, 16818, 18500, 20350, 22385, 24623, 27086, 29794, 32767
};
// Standard IMA-ADPCM Index Adjustment Table (8 entries)
static const int8_t INDEX_TABLE[8] = {
-1, -1, -1, -1, 2, 4, 6, 8
};
void adpcm_init_state(adpcm_state_t *state) {
if (!state) return;
state->predictor = 0;
state->step_index = 0;
}
void adpcm_sync_state(adpcm_state_t *state, int16_t predictor, int8_t step_index) {
if (!state) return;
state->predictor = (int32_t)predictor;
if (step_index < 0) step_index = 0;
if (step_index > 88) step_index = 88;
state->step_index = step_index;
}
int16_t adpcm_decode_nibble(adpcm_state_t *state, uint8_t nibble) {
int32_t step = STEP_TABLE[state->step_index];
int32_t diff = step >> 3;
if (nibble & 1) diff += step >> 2;
if (nibble & 2) diff += step >> 1;
if (nibble & 4) diff += step;
if (nibble & 8) {
state->predictor -= diff;
} else {
state->predictor += diff;
}
// Clamp to 16-bit signed range
if (state->predictor > 32767) {
state->predictor = 32767;
} else if (state->predictor < -32768) {
state->predictor = -32768;
}
// Update step index
state->step_index += INDEX_TABLE[nibble & 7];
if (state->step_index < 0) state->step_index = 0;
if (state->step_index > 88) state->step_index = 88;
return (int16_t)state->predictor;
}
size_t adpcm_decode_frame(adpcm_state_t *state, const uint8_t *in_data, size_t in_bytes, int16_t *out_pcm) {
if (!state || !in_data || !out_pcm || in_bytes == 0) return 0;
size_t sample_idx = 0;
for (size_t i = 0; i < in_bytes; i++) {
uint8_t byte_val = in_data[i];
// Hi-nibble first
out_pcm[sample_idx++] = adpcm_decode_nibble(state, (uint8_t)(byte_val >> 4));
// Low-nibble second
out_pcm[sample_idx++] = adpcm_decode_nibble(state, (uint8_t)(byte_val & 0x0F));
}
return sample_idx;
}
+43
View File
@@ -0,0 +1,43 @@
#pragma once
#include <stdint.h>
#include <stddef.h>
#include <stdbool.h>
#ifdef __cplusplus
extern "C" {
#endif
typedef struct {
int32_t predictor; // Current PCM prediction value [-32768, 32767]
int8_t step_index; // Current step table index [0, 88]
} adpcm_state_t;
/**
* @brief Initialize or reset ADPCM decoder state to defaults (0, 0)
*/
void adpcm_init_state(adpcm_state_t *state);
/**
* @brief Reset ADPCM decoder state to specific sync values (e.g. from ATVV AUDIO_SYNC 0x0A)
*/
void adpcm_sync_state(adpcm_state_t *state, int16_t predictor, int8_t step_index);
/**
* @brief Decode a single 4-bit nibble
*/
int16_t adpcm_decode_nibble(adpcm_state_t *state, uint8_t nibble);
/**
* @brief Decode a complete ADPCM frame (Hi-nibble first) into 16-bit PCM samples
* @param state Pointer to ADPCM decoder state
* @param in_data ADPCM byte array
* @param in_bytes Number of ADPCM bytes
* @param out_pcm Output buffer for 16-bit PCM samples (must hold at least in_bytes * 2 samples)
* @return Number of PCM samples decoded (in_bytes * 2)
*/
size_t adpcm_decode_frame(adpcm_state_t *state, const uint8_t *in_data, size_t in_bytes, int16_t *out_pcm);
#ifdef __cplusplus
}
#endif
+56
View File
@@ -0,0 +1,56 @@
#include "audio_agc.h"
#include <math.h>
void audio_agc_init(audio_agc_t *agc) {
if (!agc) return;
agc->peak = 1000.0f;
agc->target_level = 28000.0f;
agc->decay_rate = 0.9997f;
agc->max_gain = 30.0f;
agc->noise_floor = 200.0f;
}
void audio_agc_reset(audio_agc_t *agc) {
if (!agc) return;
agc->peak = 1000.0f;
}
void audio_agc_process(audio_agc_t *agc, int16_t *samples, size_t count) {
if (!agc || !samples || count == 0) return;
float peak = agc->peak;
float target = agc->target_level;
float decay = agc->decay_rate;
float max_gain = agc->max_gain;
float floor_val = agc->noise_floor;
for (size_t i = 0; i < count; i++) {
float v = (float)samples[i];
float a = (v < 0.0f) ? -v : v;
if (a > peak) {
peak = a;
} else {
peak *= decay;
}
float denom = (peak > floor_val) ? peak : floor_val;
float gain = target / denom;
if (gain > max_gain) {
gain = max_gain;
}
v *= gain;
// Soft clip
if (v > 32767.0f) {
v = 32767.0f;
} else if (v < -32768.0f) {
v = -32768.0f;
}
samples[i] = (int16_t)v;
}
agc->peak = peak;
}
+39
View File
@@ -0,0 +1,39 @@
#pragma once
#include <stdint.h>
#include <stddef.h>
#include <stdbool.h>
#ifdef __cplusplus
extern "C" {
#endif
typedef struct {
float peak; // Current peak envelope value
float target_level; // Target level (default 28000.0)
float decay_rate; // Envelope decay multiplier (default 0.9997)
float max_gain; // Maximum allowed gain boost (default 30.0)
float noise_floor; // Minimum floor to avoid amplifying silence (default 200.0)
} audio_agc_t;
/**
* @brief Initialize AGC state with default parameters
*/
void audio_agc_init(audio_agc_t *agc);
/**
* @brief Reset AGC peak envelope (e.g. at speech start)
*/
void audio_agc_reset(audio_agc_t *agc);
/**
* @brief Process PCM samples in-place with AGC gain and soft clipping
* @param agc AGC configuration & state
* @param samples Array of 16-bit PCM samples
* @param count Number of samples
*/
void audio_agc_process(audio_agc_t *agc, int16_t *samples, size_t count);
#ifdef __cplusplus
}
#endif
+41
View File
@@ -0,0 +1,41 @@
#include "audio_filter.h"
#include <stdlib.h>
void audio_filter_init(audio_filter_state_t *state) {
if (!state) return;
state->prev_decoded = 0;
state->last_sample = 0;
}
void audio_filter_declip(audio_filter_state_t *state, int16_t *samples, size_t count, int16_t threshold) {
if (!state || !samples || count == 0) return;
int32_t prev = state->prev_decoded;
for (size_t i = 0; i < count; i++) {
int32_t p = (i == 0) ? prev : samples[i - 1];
int32_t nx = (i == count - 1) ? samples[i] : samples[i + 1];
int32_t cur = samples[i];
int32_t dp = abs(cur - p);
int32_t dn = abs(cur - nx);
int32_t nd = abs(nx - p);
int32_t min_d = (dp < dn) ? dp : dn;
if (dp > threshold && dn > threshold && min_d > nd * 2) {
samples[i] = (int16_t)((p + nx) / 2);
}
}
state->prev_decoded = samples[count - 1];
}
void audio_filter_lowpass(audio_filter_state_t *state, int16_t *samples, size_t count) {
if (!state || !samples || count == 0) return;
int32_t prev = state->last_sample;
for (size_t i = 0; i < count - 1; i++) {
int32_t cur = samples[i];
samples[i] = (int16_t)((prev + (cur * 2) + samples[i + 1]) >> 2);
prev = cur;
}
state->last_sample = samples[count - 1];
}
+40
View File
@@ -0,0 +1,40 @@
#pragma once
#include <stdint.h>
#include <stddef.h>
#include <stdbool.h>
#ifdef __cplusplus
extern "C" {
#endif
typedef struct {
int16_t prev_decoded; // Preserved sample for cross-frame declip continuity
int16_t last_sample; // Preserved sample for cross-frame lowpass continuity
} audio_filter_state_t;
/**
* @brief Initialize or reset audio filter state
*/
void audio_filter_init(audio_filter_state_t *state);
/**
* @brief Apply Declip algorithm to remove single-sample spikes/glitches
* @param state Filter continuity state
* @param samples Array of PCM samples (modified in-place)
* @param count Number of samples
* @param threshold Glitch threshold (e.g. 1000)
*/
void audio_filter_declip(audio_filter_state_t *state, int16_t *samples, size_t count, int16_t threshold);
/**
* @brief Apply 3-Tap triangle FIR lowpass smoothing filter ((prev + 2*cur + next) >> 2)
* @param state Filter continuity state
* @param samples Array of PCM samples (modified in-place)
* @param count Number of samples
*/
void audio_filter_lowpass(audio_filter_state_t *state, int16_t *samples, size_t count);
#ifdef __cplusplus
}
#endif
+82
View File
@@ -0,0 +1,82 @@
#include "audio_pipeline.h"
#include <string.h>
audio_pipeline_t g_audio_pipeline;
void audio_pipeline_init(audio_pipeline_t *pipeline) {
if (!pipeline) return;
memset(pipeline, 0, sizeof(audio_pipeline_t));
adpcm_init_state(&pipeline->adpcm);
audio_filter_init(&pipeline->filter);
audio_agc_init(&pipeline->agc);
audio_ring_buffer_init(&pipeline->ring_buf, pipeline->ring_storage, AUDIO_RING_BUFFER_SIZE);
pipeline->active = false;
pipeline->session_id = 0;
pipeline->total_frames_decoded = 0;
pipeline->total_samples_pushed = 0;
}
void audio_pipeline_start_session(audio_pipeline_t *pipeline, uint8_t session_id) {
if (!pipeline) return;
adpcm_init_state(&pipeline->adpcm);
audio_filter_init(&pipeline->filter);
audio_agc_reset(&pipeline->agc);
audio_ring_buffer_clear(&pipeline->ring_buf);
pipeline->session_id = session_id;
pipeline->active = true;
pipeline->total_frames_decoded = 0;
pipeline->total_samples_pushed = 0;
}
void audio_pipeline_sync(audio_pipeline_t *pipeline, int16_t predictor, int8_t step_index) {
if (!pipeline) return;
adpcm_sync_state(&pipeline->adpcm, predictor, step_index);
}
size_t audio_pipeline_feed_adpcm(audio_pipeline_t *pipeline, const uint8_t *adpcm_bytes, size_t len) {
if (!pipeline || !adpcm_bytes || len == 0 || !pipeline->active) return 0;
size_t samples_decoded = adpcm_decode_frame(&pipeline->adpcm, adpcm_bytes, len, pipeline->temp_pcm);
if (samples_decoded == 0) return 0;
// 1. Declip
audio_filter_declip(&pipeline->filter, pipeline->temp_pcm, samples_decoded, DECLIP_THRESHOLD);
// 2. 3-Tap Triangle FIR Lowpass
audio_filter_lowpass(&pipeline->filter, pipeline->temp_pcm, samples_decoded);
// 3. Dynamic AGC + Soft Clip
audio_agc_process(&pipeline->agc, pipeline->temp_pcm, samples_decoded);
// 4. Enqueue into ring buffer
size_t written = audio_ring_buffer_write(&pipeline->ring_buf, pipeline->temp_pcm, samples_decoded);
pipeline->total_frames_decoded++;
pipeline->total_samples_pushed += written;
return written;
}
size_t audio_pipeline_read_for_usb(audio_pipeline_t *pipeline, int16_t *out_pcm, size_t sample_count) {
if (!pipeline || !out_pcm || sample_count == 0) return 0;
if (!pipeline->active) {
// Feed silence when idle
memset(out_pcm, 0, sample_count * sizeof(int16_t));
return sample_count;
}
size_t read_count = audio_ring_buffer_read(&pipeline->ring_buf, out_pcm, sample_count);
// If underrun, pad remainder with silence
if (read_count < sample_count) {
memset(&out_pcm[read_count], 0, (sample_count - read_count) * sizeof(int16_t));
}
return sample_count;
}
void audio_pipeline_stop_session(audio_pipeline_t *pipeline) {
if (!pipeline) return;
pipeline->active = false;
audio_ring_buffer_clear(&pipeline->ring_buf);
}
+74
View File
@@ -0,0 +1,74 @@
#pragma once
#include <stdint.h>
#include <stddef.h>
#include <stdbool.h>
#include "app_config.h"
#include "adpcm_decoder.h"
#include "audio_filter.h"
#include "audio_agc.h"
#include "audio_ring_buffer.h"
#ifdef __cplusplus
extern "C" {
#endif
typedef struct {
adpcm_state_t adpcm;
audio_filter_state_t filter;
audio_agc_t agc;
audio_ring_buffer_t ring_buf;
int16_t ring_storage[AUDIO_RING_BUFFER_SIZE];
int16_t temp_pcm[AUDIO_DEFAULT_FRAME_SAMPS * 2];
uint8_t session_id;
bool active;
uint32_t total_frames_decoded;
uint32_t total_samples_pushed;
} audio_pipeline_t;
/**
* @brief Global audio pipeline instance
*/
extern audio_pipeline_t g_audio_pipeline;
/**
* @brief Initialize audio pipeline
*/
void audio_pipeline_init(audio_pipeline_t *pipeline);
/**
* @brief Start a new speech session (resets filter, AGC, syncs state)
*/
void audio_pipeline_start_session(audio_pipeline_t *pipeline, uint8_t session_id);
/**
* @brief Apply ATVV AUDIO_SYNC (0x0A) state reset
*/
void audio_pipeline_sync(audio_pipeline_t *pipeline, int16_t predictor, int8_t step_index);
/**
* @brief Feed raw ADPCM frame from BLE, decode, filter, AGC, and push to ring buffer
* @param pipeline Pipeline handle
* @param adpcm_bytes Raw BLE ADPCM payload
* @param len Byte count
* @return Number of PCM samples produced and enqueued
*/
size_t audio_pipeline_feed_adpcm(audio_pipeline_t *pipeline, const uint8_t *adpcm_bytes, size_t len);
/**
* @brief Read PCM samples for USB UAC Isochronous IN endpoint
* @param pipeline Pipeline handle
* @param out_pcm Destination buffer
* @param sample_count Number of samples requested
* @return Number of samples read (fills remaining with silence 0 if underrun)
*/
size_t audio_pipeline_read_for_usb(audio_pipeline_t *pipeline, int16_t *out_pcm, size_t sample_count);
/**
* @brief Stop active speech session and clear buffers
*/
void audio_pipeline_stop_session(audio_pipeline_t *pipeline);
#ifdef __cplusplus
}
#endif
+61
View File
@@ -0,0 +1,61 @@
#include "audio_ring_buffer.h"
#include <string.h>
static bool is_power_of_two(size_t n) {
return (n > 0) && ((n & (n - 1)) == 0);
}
bool audio_ring_buffer_init(audio_ring_buffer_t *rb, int16_t *storage, size_t capacity) {
if (!rb || !storage || !is_power_of_two(capacity)) {
return false;
}
rb->buffer = storage;
rb->capacity = capacity;
rb->mask = capacity - 1;
rb->head = 0;
rb->tail = 0;
return true;
}
size_t audio_ring_buffer_available_read(const audio_ring_buffer_t *rb) {
if (!rb) return 0;
return rb->head - rb->tail;
}
size_t audio_ring_buffer_available_write(const audio_ring_buffer_t *rb) {
if (!rb) return 0;
return rb->capacity - (rb->head - rb->tail);
}
size_t audio_ring_buffer_write(audio_ring_buffer_t *rb, const int16_t *samples, size_t count) {
if (!rb || !samples || count == 0) return 0;
size_t free_slots = audio_ring_buffer_available_write(rb);
size_t to_write = (count < free_slots) ? count : free_slots;
for (size_t i = 0; i < to_write; i++) {
rb->buffer[rb->head & rb->mask] = samples[i];
rb->head++;
}
return to_write;
}
size_t audio_ring_buffer_read(audio_ring_buffer_t *rb, int16_t *out_samples, size_t count) {
if (!rb || !out_samples || count == 0) return 0;
size_t avail = audio_ring_buffer_available_read(rb);
size_t to_read = (count < avail) ? count : avail;
for (size_t i = 0; i < to_read; i++) {
out_samples[i] = rb->buffer[rb->tail & rb->mask];
rb->tail++;
}
return to_read;
}
void audio_ring_buffer_clear(audio_ring_buffer_t *rb) {
if (!rb) return;
rb->tail = rb->head;
}
+57
View File
@@ -0,0 +1,57 @@
#pragma once
#include <stdint.h>
#include <stddef.h>
#include <stdbool.h>
#ifdef __cplusplus
extern "C" {
#endif
typedef struct {
int16_t *buffer;
size_t capacity; // Must be power of 2
size_t mask; // capacity - 1
volatile size_t head; // Write index
volatile size_t tail; // Read index
} audio_ring_buffer_t;
/**
* @brief Initialize ring buffer with allocated storage
* @param rb Ring buffer pointer
* @param storage Pre-allocated array of int16_t samples
* @param capacity Number of samples (must be power of 2, e.g. 1024, 2048, 4096)
* @return true if valid power of 2, false otherwise
*/
bool audio_ring_buffer_init(audio_ring_buffer_t *rb, int16_t *storage, size_t capacity);
/**
* @brief Get available samples for reading
*/
size_t audio_ring_buffer_available_read(const audio_ring_buffer_t *rb);
/**
* @brief Get available free sample slots for writing
*/
size_t audio_ring_buffer_available_write(const audio_ring_buffer_t *rb);
/**
* @brief Write samples into ring buffer
* @return Number of samples actually written
*/
size_t audio_ring_buffer_write(audio_ring_buffer_t *rb, const int16_t *samples, size_t count);
/**
* @brief Read samples from ring buffer
* @return Number of samples actually read
*/
size_t audio_ring_buffer_read(audio_ring_buffer_t *rb, int16_t *out_samples, size_t count);
/**
* @brief Clear ring buffer contents
*/
void audio_ring_buffer_clear(audio_ring_buffer_t *rb);
#ifdef __cplusplus
}
#endif
+238
View File
@@ -0,0 +1,238 @@
#include "ble_remote_client.h"
#include "audio/audio_pipeline.h"
#include "keymap/key_state_machine.h"
#include "usb/usb_composite.h"
#include <Arduino.h>
#include <NimBLEDevice.h>
static ble_remote_state_t s_ble_state = BLE_STATE_DISCONNECTED;
static NimBLEClient* s_client = nullptr;
static NimBLERemoteChar* s_char_cmd = nullptr;
static NimBLERemoteChar* s_char_aud = nullptr;
static NimBLERemoteChar* s_char_ctl = nullptr;
static uint8_t s_session_id = 0;
static uint32_t s_last_audio_ms = 0;
static uint32_t s_last_extend_ms = 0;
static uint32_t s_last_scan_ms = 0;
static size_t s_frame_size = AUDIO_DEFAULT_FRAME_BYTES;
extern key_mapper_engine_t g_key_engine;
// Forward Declarations
static void start_scan();
static bool connect_to_remote(NimBLEAdvertisedDevice* advDevice);
// Audio Notification Callback (ATVV Char 0x03)
static void on_audio_notify(NimBLERemoteCharacteristic* pChar, uint8_t* pData, size_t length, bool isNotify) {
if (s_ble_state != BLE_STATE_TALKING || length == 0) return;
s_last_audio_ms = millis();
audio_pipeline_feed_adpcm(&g_audio_pipeline, pData, length);
}
// Control Notification Callback (ATVV Char 0x04)
static void on_ctl_notify(NimBLERemoteCharacteristic* pChar, uint8_t* pData, size_t length, bool isNotify) {
if (length < 1) return;
uint8_t op = pData[0];
// AUDIO_START with HTT reason: byte1 == 0x03
if (op == 0x04 && length >= 2 && pData[1] == 0x03) {
s_session_id = (length >= 4) ? pData[3] : 0;
s_ble_state = BLE_STATE_TALKING;
s_last_audio_ms = millis();
s_last_extend_ms = millis();
// Trigger Voice Hold action (hold hotkey & reset audio DSP)
key_action_t act = { ACTION_VOICE_HOLD, DEFAULT_VOICE_MODIFIER, DEFAULT_VOICE_KEY, 0 };
usb_hid_dispatch_action(&act);
Serial.printf("[ATVV] >>> Voice button PRESSED (session %d)\n", s_session_id);
}
// AUDIO_STOP / MIC_CLOSED / release op:
else if (op == 0x00) {
if (s_ble_state == BLE_STATE_TALKING) {
s_ble_state = BLE_STATE_CONNECTED;
key_action_t act = { ACTION_VOICE_RELEASE, DEFAULT_VOICE_MODIFIER, DEFAULT_VOICE_KEY, 0 };
usb_hid_dispatch_action(&act);
Serial.println("[ATVV] <<< Voice button RELEASED");
// Re-arm remote HTT standby
if (s_char_cmd != nullptr) {
uint8_t cmd_open[] = { 0x0C, 0x00 };
s_char_cmd->writeValue(cmd_open, sizeof(cmd_open), false);
}
}
}
// CAPS_RESP: op == 0x0B
else if (op == 0x0B && length >= 7) {
uint16_t ver = (pData[1] << 8) | pData[2];
uint16_t fs = (pData[5] << 8) | pData[6];
if (fs > 0) s_frame_size = fs;
Serial.printf("[ATVV] CAPS: ver=0x%04X, frame_size=%d\n", ver, s_frame_size);
}
// AUDIO_SYNC: op == 0x0A
else if (op == 0x0A && length >= 7) {
int16_t pred = (int16_t)((pData[4] << 8) | pData[5]);
int8_t step_idx = (int8_t)pData[6];
audio_pipeline_sync(&g_audio_pipeline, pred, step_idx);
Serial.printf("[ATVV] SYNC: pred=%d, step=%d\n", pred, step_idx);
}
}
// HOGP HID Report Notification Callback
static void on_hogp_report_notify(NimBLERemoteCharacteristic* pChar, uint8_t* pData, size_t length, bool isNotify) {
if (length < 1) return;
uint8_t raw_key = pData[0];
bool is_pressed = (length > 1) ? (pData[1] != 0) : (raw_key != 0);
Serial.printf("[HOGP] Key byte: 0x%02X (%s)\n", raw_key, is_pressed ? "DOWN" : "UP");
key_engine_feed_key(&g_key_engine, raw_key, is_pressed, millis());
}
// Advertised Device Scan Callbacks
class AdvertisedDeviceCallbacks : public NimBLEAdvertisedDeviceCallbacks {
void onResult(NimBLEAdvertisedDevice* advertisedDevice) override {
String name = advertisedDevice->getName().c_str();
String addr = advertisedDevice->getAddress().toString().c_str();
addr.toLowerCase();
bool match_name = name.indexOf(BLE_REMOTE_NAME_PREFIX) >= 0;
bool match_mac = addr.startsWith(BLE_REMOTE_MAC_PREFIX);
if (match_name || match_mac) {
Serial.printf("[BLE] Found Target Remote: %s (%s), RSSI: %d\n", name.c_str(), addr.c_str(), advertisedDevice->getRSSI());
NimBLEDevice::getScan()->stop();
connect_to_remote(advertisedDevice);
}
}
};
// Client Connection Callbacks
class ClientCallbacks : public NimBLEClientCallbacks {
void onConnect(NimBLEClient* pClient) override {
Serial.println("[BLE] Remote Connected");
s_ble_state = BLE_STATE_CONNECTED;
}
void onDisconnect(NimBLEClient* pClient) override {
Serial.println("[BLE] Remote Disconnected");
s_ble_state = BLE_STATE_DISCONNECTED;
s_char_cmd = nullptr;
s_char_aud = nullptr;
s_char_ctl = nullptr;
}
};
static void start_scan() {
s_ble_state = BLE_STATE_SCANNING;
s_last_scan_ms = millis();
NimBLEScan* pScan = NimBLEDevice::getScan();
pScan->setActiveScan(true);
pScan->setInterval(BLE_SCAN_INTERVAL_MS);
pScan->setWindow(BLE_SCAN_WINDOW_MS);
pScan->start(5, false);
Serial.println("[BLE] Scanning for Xiaomi Remote (MI RC)...");
}
static bool connect_to_remote(NimBLEAdvertisedDevice* advDevice) {
s_ble_state = BLE_STATE_CONNECTING;
if (s_client == nullptr) {
s_client = NimBLEDevice::createClient();
s_client->setClientCallbacks(new ClientCallbacks(), false);
}
if (!s_client->connect(advDevice)) {
Serial.println("[BLE] Connection Failed");
s_ble_state = BLE_STATE_DISCONNECTED;
return false;
}
// Discover ATVV Service
NimBLERemoteService* pAtvvSvc = s_client->getService(NimBLEUUID(ATVV_SVC_UUID));
if (pAtvvSvc) {
s_char_cmd = pAtvvSvc->getCharacteristic(NimBLEUUID(ATVV_CHAR_CMD_UUID));
s_char_aud = pAtvvSvc->getCharacteristic(NimBLEUUID(ATVV_CHAR_AUD_UUID));
s_char_ctl = pAtvvSvc->getCharacteristic(NimBLEUUID(ATVV_CHAR_CTL_UUID));
if (s_char_ctl && s_char_ctl->canNotify()) {
s_char_ctl->subscribe(true, on_ctl_notify);
}
if (s_char_aud && s_char_aud->canNotify()) {
s_char_aud->subscribe(true, on_audio_notify);
}
// Perform ATVV Handshake: GET_CAPS then MIC_OPEN
if (s_char_cmd) {
uint8_t cmd_caps[] = { 0x0A, 0x01, 0x00, 0x00, 0x03, 0x03 };
s_char_cmd->writeValue(cmd_caps, sizeof(cmd_caps), false);
delay(150);
uint8_t cmd_open[] = { 0x0C, 0x00 };
s_char_cmd->writeValue(cmd_open, sizeof(cmd_open), false);
Serial.println("[ATVV] Handshake completed successfully");
}
}
// Discover HOGP Service
NimBLERemoteService* pHogpSvc = s_client->getService(NimBLEUUID((uint16_t)HOGP_SVC_UUID));
if (pHogpSvc) {
NimBLERemoteCharacteristic* pReportChar = pHogpSvc->getCharacteristic(NimBLEUUID((uint16_t)HOGP_REPORT_CHAR_UUID));
if (pReportChar && pReportChar->canNotify()) {
pReportChar->subscribe(true, on_hogp_report_notify);
Serial.println("[HOGP] Subscribed to HID Key Reports");
}
}
s_ble_state = BLE_STATE_CONNECTED;
return true;
}
extern "C" {
void ble_remote_init(void) {
NimBLEDevice::init("ESP32-RemoteBridge");
NimBLEDevice::setPower(ESP_PWR_LVL_P9);
NimBLEDevice::setSecurityAuth(true, true, true);
NimBLEScan* pScan = NimBLEDevice::getScan();
pScan->setAdvertisedDeviceCallbacks(new AdvertisedDeviceCallbacks());
start_scan();
}
void ble_remote_task(void) {
uint32_t now = millis();
// 1. Connection watchdog / Auto Re-scan
if (s_ble_state == BLE_STATE_DISCONNECTED) {
if (now - s_last_scan_ms > 4000) {
start_scan();
}
}
// 2. Audio silence watchdog (if voice key released packet dropped)
if (s_ble_state == BLE_STATE_TALKING) {
if (now - s_last_audio_ms > BLE_SILENCE_WATCHDOG_MS) {
Serial.println("[ATVV] Silence watchdog expired -> force stopping speech");
s_ble_state = BLE_STATE_CONNECTED;
key_action_t act = { ACTION_VOICE_RELEASE, DEFAULT_VOICE_MODIFIER, DEFAULT_VOICE_KEY, 0 };
usb_hid_dispatch_action(&act);
} else if (now - s_last_extend_ms > BLE_KEEP_ALIVE_INTERVAL) {
// Keep alive extend active session
if (s_char_cmd != nullptr) {
uint8_t cmd_extend[] = { 0x0E, s_session_id };
s_char_cmd->writeValue(cmd_extend, sizeof(cmd_extend), false);
s_last_extend_ms = now;
}
}
}
}
ble_remote_state_t ble_remote_get_state(void) {
return s_ble_state;
}
void ble_remote_trigger_reconnect(void) {
if (s_client && s_client->isConnected()) {
s_client->disconnect();
}
start_scan();
}
} // extern "C"
+42
View File
@@ -0,0 +1,42 @@
#pragma once
#include <stdint.h>
#include <stdbool.h>
#include <stddef.h>
#include "app_config.h"
#ifdef __cplusplus
extern "C" {
#endif
typedef enum {
BLE_STATE_DISCONNECTED = 0,
BLE_STATE_SCANNING,
BLE_STATE_CONNECTING,
BLE_STATE_CONNECTED,
BLE_STATE_TALKING
} ble_remote_state_t;
/**
* @brief Initialize NimBLE Client for Xiaomi Remote
*/
void ble_remote_init(void);
/**
* @brief Main BLE task tick (handles state machine, watchdogs, and keep-alive packets)
*/
void ble_remote_task(void);
/**
* @brief Get current BLE connection state
*/
ble_remote_state_t ble_remote_get_state(void);
/**
* @brief Trigger manual reconnect / re-scan
*/
void ble_remote_trigger_reconnect(void);
#ifdef __cplusplus
}
#endif
+77
View File
@@ -0,0 +1,77 @@
#include "cli_manager.h"
#include "version.h"
#include "ble/ble_remote_client.h"
#include "audio/audio_pipeline.h"
#include "keymap/key_state_machine.h"
#include <Arduino.h>
#include <ArduinoJson.h>
extern key_mapper_engine_t g_key_engine;
static String s_input_buffer = "";
static void handle_command(const String& line) {
String cmd = line;
cmd.trim();
if (cmd.length() == 0) return;
if (cmd.equalsIgnoreCase("status") || cmd.equalsIgnoreCase("info")) {
JsonDocument doc;
doc["firmware"] = FIRMWARE_NAME;
doc["version"] = FIRMWARE_VERSION;
doc["target"] = HARDWARE_TARGET;
doc["uptime_sec"] = millis() / 1000;
doc["ble_state"] = (int)ble_remote_get_state();
doc["frames_decoded"] = g_audio_pipeline.total_frames_decoded;
doc["samples_pushed"] = g_audio_pipeline.total_samples_pushed;
doc["free_heap"] = ESP.getFreeHeap();
doc["free_psram"] = ESP.getFreePsram();
String out;
serializeJson(doc, out);
Serial.println(out);
}
else if (cmd.equalsIgnoreCase("reconnect")) {
Serial.println("{\"status\":\"reconnecting\"}");
ble_remote_trigger_reconnect();
}
else if (cmd.equalsIgnoreCase("reset_keys")) {
key_engine_load_defaults(&g_key_engine);
Serial.println("{\"status\":\"keymap_reset_to_defaults\"}");
}
else if (cmd.equalsIgnoreCase("help")) {
Serial.println("Commands:");
Serial.println(" status - Display system info & runtime statistics (JSON)");
Serial.println(" reconnect - Trigger BLE remote re-scan");
Serial.println(" reset_keys - Reset key bindings to factory defaults");
Serial.println(" help - Show available commands");
}
else {
Serial.println("{\"error\":\"unknown_command\",\"hint\":\"type help\"}");
}
}
extern "C" {
void cli_manager_init(void) {
s_input_buffer.reserve(256);
}
void cli_manager_task(void) {
while (Serial.available() > 0) {
char c = (char)Serial.read();
if (c == '\r' || c == '\n') {
if (s_input_buffer.length() > 0) {
handle_command(s_input_buffer);
s_input_buffer = "";
}
} else {
s_input_buffer += c;
if (s_input_buffer.length() > 250) {
s_input_buffer = "";
}
}
}
}
} // extern "C"
+22
View File
@@ -0,0 +1,22 @@
#pragma once
#include <stdint.h>
#include <stdbool.h>
#ifdef __cplusplus
extern "C" {
#endif
/**
* @brief Initialize Serial / CDC CLI manager
*/
void cli_manager_init(void);
/**
* @brief Process periodic CLI line inputs
*/
void cli_manager_task(void);
#ifdef __cplusplus
}
#endif
+81
View File
@@ -0,0 +1,81 @@
#pragma once
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
// ==========================================
// 1. Xiaomi Remote Physical Key Codes (HOGP / HID raw bytes)
// ==========================================
#define MI_KEY_VOL_UP 0x80 // Android Volume Up (kbdhid drops this on Windows)
#define MI_KEY_VOL_DOWN 0x81 // Android Volume Down (kbdhid drops this on Windows)
#define MI_KEY_BACK 0xF1 // Android Back key (kbdhid drops this on Windows)
#define MI_KEY_POWER 0xFF // Power button (or 0x66)
#define MI_KEY_POWER_ALT 0x66
#define MI_KEY_HOME 0x24 // Home button (or 0x4A)
#define MI_KEY_HOME_ALT 0x4A
#define MI_KEY_MENU 0x5D // Menu button (or 0x65)
#define MI_KEY_MENU_ALT 0x65
#define MI_KEY_TV 0xC0 // Live / TV button (or 0x35)
#define MI_KEY_TV_ALT 0x35
#define MI_KEY_UP 0x52 // D-Pad Up
#define MI_KEY_DOWN 0x51 // D-Pad Down
#define MI_KEY_LEFT 0x50 // D-Pad Left
#define MI_KEY_RIGHT 0x4F // D-Pad Right
#define MI_KEY_OK 0x28 // OK / Enter
// ==========================================
// 2. USB HID Keyboard Modifier Bitmasks
// ==========================================
#define USB_MOD_NONE 0x00
#define USB_MOD_LCTRL 0x01
#define USB_MOD_LSHIFT 0x02
#define USB_MOD_LALT 0x04
#define USB_MOD_LGUI 0x08 // Left Win Key
#define USB_MOD_RCTRL 0x10
#define USB_MOD_RSHIFT 0x20
#define USB_MOD_RALT 0x40 // Right Alt Key (AltGr)
#define USB_MOD_RGUI 0x80 // Right Win Key
// ==========================================
// 3. Standard USB HID Keyboard Key Codes
// ==========================================
#define USB_KEY_NONE 0x00
#define USB_KEY_A 0x04
#define USB_KEY_D 0x07 // 'D' (for Win+D Show Desktop)
#define USB_KEY_RETURN 0x28 // Enter
#define USB_KEY_ESCAPE 0x29 // Esc
#define USB_KEY_BACKSPACE 0x2A
#define USB_KEY_TAB 0x2B // Tab (for Alt+Tab Task Switch)
#define USB_KEY_SPACE 0x2C // Spacebar
#define USB_KEY_F5 0x3E // F5 Refresh
#define USB_KEY_F8 0x41 // F8 Voice IME hotkey
#define USB_KEY_RIGHT 0x4F // Arrow Right
#define USB_KEY_LEFT 0x50 // Arrow Left
#define USB_KEY_DOWN 0x51 // Arrow Down
#define USB_KEY_UP 0x52 // Arrow Up
#define USB_KEY_COMMA 0x36 // ',' (for RAlt+, WeChat IME hotkey)
// ==========================================
// 4. USB HID Consumer Control Usage Codes (16-bit)
// ==========================================
#define USB_CONSUMER_NONE 0x0000
#define USB_CONSUMER_POWER 0x0030
#define USB_CONSUMER_RESET 0x0031
#define USB_CONSUMER_SLEEP 0x0032
#define USB_CONSUMER_PLAY_PAUSE 0x00CD
#define USB_CONSUMER_MUTE 0x00E2
#define USB_CONSUMER_VOLUME_UP 0x00E9 // Standard PC Volume Increment
#define USB_CONSUMER_VOLUME_DOWN 0x00EA // Standard PC Volume Decrement
#define USB_CONSUMER_AL_CONSUMER_CTRL 0x0182
#define USB_CONSUMER_AC_BACK 0x0224 // Standard PC Browser / App Back
#define USB_CONSUMER_AC_FORWARD 0x0225
#define USB_CONSUMER_AC_REFRESH 0x0227
#define USB_CONSUMER_AC_BOOKMARKS 0x022A
#define USB_CONSUMER_AC_PAN 0x0238
#ifdef __cplusplus
}
#endif
+259
View File
@@ -0,0 +1,259 @@
#include "key_state_machine.h"
#include <string.h>
static int find_binding_index(const key_mapper_engine_t *engine, uint8_t raw_key) {
if (!engine) return -1;
for (size_t i = 0; i < engine->binding_count; i++) {
if (engine->bindings[i].source_vk == raw_key) {
return (int)i;
}
// Handle alternate codes
if (raw_key == MI_KEY_POWER_ALT && engine->bindings[i].source_vk == MI_KEY_POWER) return (int)i;
if (raw_key == MI_KEY_HOME_ALT && engine->bindings[i].source_vk == MI_KEY_HOME) return (int)i;
if (raw_key == MI_KEY_MENU_ALT && engine->bindings[i].source_vk == MI_KEY_MENU) return (int)i;
if (raw_key == MI_KEY_TV_ALT && engine->bindings[i].source_vk == MI_KEY_TV) return (int)i;
}
return -1;
}
static void emit_action(key_mapper_engine_t *engine, const key_action_t *action) {
if (engine && engine->output_cb && action && action->type != ACTION_NONE) {
engine->output_cb(action);
}
}
void key_engine_load_defaults(key_mapper_engine_t *engine) {
if (!engine) return;
engine->binding_count = 0;
// 1. Volume Up: 0x80 -> Consumer Volume Up with fast repeat
{
key_binding_t b;
memset(&b, 0, sizeof(b));
b.source_vk = MI_KEY_VOL_UP;
b.has_click = true;
b.click_action = (key_action_t){ ACTION_CONSUMER_TAP, USB_MOD_NONE, USB_KEY_NONE, USB_CONSUMER_VOLUME_UP };
b.has_repeat = true;
b.repeat_action = (key_action_t){ ACTION_CONSUMER_TAP, USB_MOD_NONE, USB_KEY_NONE, USB_CONSUMER_VOLUME_UP };
b.repeat_delay_ms = 350;
b.repeat_interval_ms = 70;
engine->bindings[engine->binding_count++] = b;
}
// 2. Volume Down: 0x81 -> Consumer Volume Down with fast repeat
{
key_binding_t b;
memset(&b, 0, sizeof(b));
b.source_vk = MI_KEY_VOL_DOWN;
b.has_click = true;
b.click_action = (key_action_t){ ACTION_CONSUMER_TAP, USB_MOD_NONE, USB_KEY_NONE, USB_CONSUMER_VOLUME_DOWN };
b.has_repeat = true;
b.repeat_action = (key_action_t){ ACTION_CONSUMER_TAP, USB_MOD_NONE, USB_KEY_NONE, USB_CONSUMER_VOLUME_DOWN };
b.repeat_delay_ms = 350;
b.repeat_interval_ms = 70;
engine->bindings[engine->binding_count++] = b;
}
// 3. Back: 0xF1 -> Consumer AC Back (Browser / App Back)
{
key_binding_t b;
memset(&b, 0, sizeof(b));
b.source_vk = MI_KEY_BACK;
b.has_click = true;
b.click_action = (key_action_t){ ACTION_CONSUMER_TAP, USB_MOD_NONE, USB_KEY_NONE, USB_CONSUMER_AC_BACK };
engine->bindings[engine->binding_count++] = b;
}
// 4. Power: 0xFF -> Click: Alt+Tab, Long: Sleep
{
key_binding_t b;
memset(&b, 0, sizeof(b));
b.source_vk = MI_KEY_POWER;
b.has_click = true;
b.click_action = (key_action_t){ ACTION_KEYBOARD_TAP, USB_MOD_LALT, USB_KEY_TAB, 0 };
b.has_long = true;
b.long_ms = 600;
b.long_action = (key_action_t){ ACTION_CONSUMER_TAP, USB_MOD_NONE, USB_KEY_NONE, USB_CONSUMER_SLEEP };
engine->bindings[engine->binding_count++] = b;
}
// 5. Home: 0x24 -> Click: Win+D (Show Desktop)
{
key_binding_t b;
memset(&b, 0, sizeof(b));
b.source_vk = MI_KEY_HOME;
b.has_click = true;
b.click_action = (key_action_t){ ACTION_KEYBOARD_TAP, USB_MOD_LGUI, USB_KEY_D, 0 };
engine->bindings[engine->binding_count++] = b;
}
// 6. Menu: 0x5D -> Click: Space (Play/Pause)
{
key_binding_t b;
memset(&b, 0, sizeof(b));
b.source_vk = MI_KEY_MENU;
b.has_click = true;
b.click_action = (key_action_t){ ACTION_KEYBOARD_TAP, USB_MOD_NONE, USB_KEY_SPACE, 0 };
engine->bindings[engine->binding_count++] = b;
}
// 7. TV: 0xC0 -> Click: F8
{
key_binding_t b;
memset(&b, 0, sizeof(b));
b.source_vk = MI_KEY_TV;
b.has_click = true;
b.click_action = (key_action_t){ ACTION_KEYBOARD_TAP, USB_MOD_NONE, USB_KEY_F8, 0 };
engine->bindings[engine->binding_count++] = b;
}
// 8. D-Pad Up: 0x52 -> Keyboard Up
{
key_binding_t b;
memset(&b, 0, sizeof(b));
b.source_vk = MI_KEY_UP;
b.has_click = true;
b.click_action = (key_action_t){ ACTION_KEYBOARD_HOLD, USB_MOD_NONE, USB_KEY_UP, 0 };
engine->bindings[engine->binding_count++] = b;
}
// 9. D-Pad Down: 0x51 -> Keyboard Down
{
key_binding_t b;
memset(&b, 0, sizeof(b));
b.source_vk = MI_KEY_DOWN;
b.has_click = true;
b.click_action = (key_action_t){ ACTION_KEYBOARD_HOLD, USB_MOD_NONE, USB_KEY_DOWN, 0 };
engine->bindings[engine->binding_count++] = b;
}
// 10. D-Pad Left: 0x50 -> Keyboard Left
{
key_binding_t b;
memset(&b, 0, sizeof(b));
b.source_vk = MI_KEY_LEFT;
b.has_click = true;
b.click_action = (key_action_t){ ACTION_KEYBOARD_HOLD, USB_MOD_NONE, USB_KEY_LEFT, 0 };
engine->bindings[engine->binding_count++] = b;
}
// 11. D-Pad Right: 0x4F -> Keyboard Right
{
key_binding_t b;
memset(&b, 0, sizeof(b));
b.source_vk = MI_KEY_RIGHT;
b.has_click = true;
b.click_action = (key_action_t){ ACTION_KEYBOARD_HOLD, USB_MOD_NONE, USB_KEY_RIGHT, 0 };
engine->bindings[engine->binding_count++] = b;
}
// 12. OK: 0x28 -> Keyboard Return
{
key_binding_t b;
memset(&b, 0, sizeof(b));
b.source_vk = MI_KEY_OK;
b.has_click = true;
b.click_action = (key_action_t){ ACTION_KEYBOARD_HOLD, USB_MOD_NONE, USB_KEY_RETURN, 0 };
engine->bindings[engine->binding_count++] = b;
}
}
void key_engine_init(key_mapper_engine_t *engine, key_output_callback_t cb) {
if (!engine) return;
memset(engine, 0, sizeof(key_mapper_engine_t));
engine->output_cb = cb;
key_engine_load_defaults(engine);
}
void key_engine_feed_key(key_mapper_engine_t *engine, uint8_t raw_key_code, bool is_pressed, uint32_t now_ms) {
if (!engine) return;
int idx = find_binding_index(engine, raw_key_code);
if (idx < 0) return;
key_binding_t *b = &engine->bindings[idx];
key_slot_state_t *st = &engine->states[idx];
if (is_pressed) {
if (!st->is_pressed) {
st->is_pressed = true;
st->press_timestamp = now_ms;
st->long_fired = false;
st->press_count++;
// If key has a direct HOLD action without long/double ambiguity, fire immediately
if (!b->has_long && !b->has_double && b->has_click && b->click_action.type == ACTION_KEYBOARD_HOLD) {
emit_action(engine, &b->click_action);
} else if (b->has_repeat) {
// Initialize repeat
emit_action(engine, &b->click_action);
st->next_repeat_timestamp = now_ms + b->repeat_delay_ms;
}
}
} else {
// Key Released
if (st->is_pressed) {
st->is_pressed = false;
st->release_timestamp = now_ms;
if (b->has_click && b->click_action.type == ACTION_KEYBOARD_HOLD) {
// Release hold
key_action_t rel = b->click_action;
rel.type = ACTION_KEYBOARD_RELEASE;
emit_action(engine, &rel);
} else if (!st->long_fired && !b->has_repeat) {
if (b->has_double) {
if (st->press_count >= 2) {
emit_action(engine, &b->double_action);
st->press_count = 0;
st->waiting_double = false;
} else {
st->waiting_double = true;
}
} else if (b->has_click) {
emit_action(engine, &b->click_action);
st->press_count = 0;
}
}
}
}
}
void key_engine_tick(key_mapper_engine_t *engine, uint32_t now_ms) {
if (!engine) return;
for (size_t i = 0; i < engine->binding_count; i++) {
key_binding_t *b = &engine->bindings[i];
key_slot_state_t *st = &engine->states[i];
// 1. Long Press check
if (st->is_pressed && b->has_long && !st->long_fired) {
uint32_t held_duration = now_ms - st->press_timestamp;
if (held_duration >= b->long_ms) {
st->long_fired = true;
emit_action(engine, &b->long_action);
}
}
// 2. Repeat check
if (st->is_pressed && b->has_repeat) {
if (now_ms >= st->next_repeat_timestamp) {
emit_action(engine, &b->repeat_action);
st->next_repeat_timestamp = now_ms + b->repeat_interval_ms;
}
}
// 3. Double click timeout check
if (!st->is_pressed && st->waiting_double && b->has_double) {
uint32_t since_release = now_ms - st->release_timestamp;
if (since_release >= b->double_ms) {
// Double-click window expired -> emit single click
if (b->has_click) {
emit_action(engine, &b->click_action);
}
st->waiting_double = false;
st->press_count = 0;
}
}
}
}
+96
View File
@@ -0,0 +1,96 @@
#pragma once
#include <stdint.h>
#include <stdbool.h>
#include <stddef.h>
#include "key_definitions.h"
#ifdef __cplusplus
extern "C" {
#endif
typedef enum {
ACTION_NONE = 0,
ACTION_KEYBOARD_TAP, // Tap a key or combo (Down + Up)
ACTION_KEYBOARD_HOLD, // Press and hold down
ACTION_KEYBOARD_RELEASE, // Release key
ACTION_CONSUMER_TAP, // Tap a consumer control usage (e.g. Vol Up/Down)
ACTION_CONSUMER_HOLD,
ACTION_CONSUMER_RELEASE,
ACTION_VOICE_HOLD, // Trigger voice recording + hold hotkey
ACTION_VOICE_RELEASE // End voice recording + release hotkey
} key_action_type_t;
typedef struct {
key_action_type_t type;
uint8_t modifier; // USB_MOD_*
uint8_t key_code; // USB_KEY_*
uint16_t consumer_code;// USB_CONSUMER_*
} key_action_t;
typedef struct {
uint8_t source_vk; // MI_KEY_*
bool has_click;
key_action_t click_action;
bool has_long;
key_action_t long_action;
uint16_t long_ms; // e.g. 500ms
bool has_double;
key_action_t double_action;
uint16_t double_ms; // e.g. 250ms
bool has_repeat;
key_action_t repeat_action;
uint16_t repeat_delay_ms; // e.g. 400ms
uint16_t repeat_interval_ms;// e.g. 80ms
} key_binding_t;
typedef struct {
bool is_pressed;
uint32_t press_timestamp;
uint32_t release_timestamp;
uint8_t press_count;
bool long_fired;
uint32_t next_repeat_timestamp;
bool waiting_double;
} key_slot_state_t;
#define MAX_KEY_BINDINGS 16
typedef void (*key_output_callback_t)(const key_action_t *action);
typedef struct {
key_binding_t bindings[MAX_KEY_BINDINGS];
key_slot_state_t states[MAX_KEY_BINDINGS];
size_t binding_count;
key_output_callback_t output_cb;
} key_mapper_engine_t;
/**
* @brief Initialize key mapper engine with default remote mapping table
*/
void key_engine_init(key_mapper_engine_t *engine, key_output_callback_t cb);
/**
* @brief Load default factory key mapping table
*/
void key_engine_load_defaults(key_mapper_engine_t *engine);
/**
* @brief Feed raw physical key event from BLE HOGP
* @param engine Engine handle
* @param raw_key_code Remote key code (MI_KEY_*)
* @param is_pressed true for key down, false for key up
* @param now_ms Current system timestamp in milliseconds
*/
void key_engine_feed_key(key_mapper_engine_t *engine, uint8_t raw_key_code, bool is_pressed, uint32_t now_ms);
/**
* @brief Periodic timer tick to evaluate long press, double click timeout, and repeat timers
* @param engine Engine handle
* @param now_ms Current system timestamp in milliseconds
*/
void key_engine_tick(key_mapper_engine_t *engine, uint32_t now_ms);
#ifdef __cplusplus
}
#endif
+74
View File
@@ -0,0 +1,74 @@
#include <Arduino.h>
#include "app_config.h"
#include "version.h"
#include "audio/audio_pipeline.h"
#include "keymap/key_state_machine.h"
#include "usb/usb_composite.h"
#include "ble/ble_remote_client.h"
#include "cli/cli_manager.h"
key_mapper_engine_t g_key_engine;
// Task running on Core 0: BLE Central & Audio Decoding
static void ble_task_core0(void* param) {
Serial.printf("[SYSTEM] BLE & Audio Task started on Core %d\n", xPortGetCoreID());
ble_remote_init();
while (true) {
ble_remote_task();
vTaskDelay(pdMS_TO_TICKS(5));
}
}
void setup() {
Serial.begin(115200);
delay(500);
Serial.println("==================================================");
Serial.printf(" %s v%s (%s)\n", FIRMWARE_NAME, FIRMWARE_VERSION, HARDWARE_TARGET);
Serial.println(" Xiaomi Remote Hardware Bridge (BLE -> USB)");
Serial.println("==================================================");
// 1. Initialize Audio Pipeline
audio_pipeline_init(&g_audio_pipeline);
Serial.println("[INIT] Audio Pipeline initialized (16kHz 16-bit Mono)");
// 2. Initialize Key Engine with USB HID dispatcher callback
key_engine_init(&g_key_engine, usb_hid_dispatch_action);
Serial.printf("[INIT] Key Engine initialized with %u mappings\n", (unsigned int)g_key_engine.binding_count);
// 3. Initialize USB Composite Stack (UAC Mic + HID Keyboard + Consumer + CDC)
usb_composite_init();
Serial.println("[INIT] USB Composite Device ready (UAC Mic + HID Keyboard/Consumer)");
// 4. Initialize CLI Manager
cli_manager_init();
// 5. Launch BLE Central Task pinned to Core 0
xTaskCreatePinnedToCore(
ble_task_core0,
"ble_audio_task",
8192,
NULL,
PRIO_TASK_BLE,
NULL,
TASK_CORE_BLE
);
Serial.println("[SYSTEM] System initialization complete. Ready!");
}
void loop() {
uint32_t now = millis();
// 1. Service TinyUSB & Audio push
usb_composite_task();
// 2. Service Key State Machine timers (long press, double click, repeat)
key_engine_tick(&g_key_engine, now);
// 3. Service Serial / WebSerial CLI commands
cli_manager_task();
delay(2);
}
+117
View File
@@ -0,0 +1,117 @@
#include "usb_composite.h"
#include "audio/audio_pipeline.h"
#include <Arduino.h>
#include "USB.h"
#include "USBHIDKeyboard.h"
#include "USBHIDConsumerControl.h"
static USBHIDKeyboard s_keyboard;
static USBHIDConsumerControl s_consumer;
static bool s_usb_ready = false;
extern "C" {
void usb_composite_init(void) {
USB.productName("RemoteMapper Wireless Mic & Controller");
USB.manufacturerName("RemoteMapper");
USB.serialNumber("RM-ESP32S3-001");
s_keyboard.begin();
s_consumer.begin();
USB.begin();
s_usb_ready = true;
}
void usb_composite_task(void) {
// Handled by native ESP32-S3 USB stack
usb_audio_task();
}
bool usb_hid_keyboard_press(uint8_t modifier, uint8_t keycode) {
if (!s_usb_ready) return false;
// Map modifier & keycode
KeyReport report = {0};
report.modifiers = modifier;
report.keys[0] = keycode;
return s_keyboard.sendReport(&report);
}
bool usb_hid_keyboard_release(void) {
if (!s_usb_ready) return false;
s_keyboard.releaseAll();
return true;
}
bool usb_hid_keyboard_tap(uint8_t modifier, uint8_t keycode) {
if (!s_usb_ready) return false;
usb_hid_keyboard_press(modifier, keycode);
delay(12);
usb_hid_keyboard_release();
return true;
}
bool usb_hid_consumer_press(uint16_t usage_code) {
if (!s_usb_ready) return false;
return s_consumer.press(usage_code);
}
bool usb_hid_consumer_release(void) {
if (!s_usb_ready) return false;
s_consumer.release();
return true;
}
bool usb_hid_consumer_tap(uint16_t usage_code) {
if (!s_usb_ready) return false;
s_consumer.press(usage_code);
delay(10);
s_consumer.release();
return true;
}
void usb_hid_dispatch_action(const key_action_t *action) {
if (!action) return;
switch (action->type) {
case ACTION_KEYBOARD_TAP:
usb_hid_keyboard_tap(action->modifier, action->key_code);
break;
case ACTION_KEYBOARD_HOLD:
usb_hid_keyboard_press(action->modifier, action->key_code);
break;
case ACTION_KEYBOARD_RELEASE:
usb_hid_keyboard_release();
break;
case ACTION_CONSUMER_TAP:
usb_hid_consumer_tap(action->consumer_code);
break;
case ACTION_CONSUMER_HOLD:
usb_hid_consumer_press(action->consumer_code);
break;
case ACTION_CONSUMER_RELEASE:
usb_hid_consumer_release();
break;
case ACTION_VOICE_HOLD:
// Hold Voice Hotkey (Default RAlt + Comma) and start audio session
audio_pipeline_start_session(&g_audio_pipeline, 0);
usb_hid_keyboard_press(DEFAULT_VOICE_MODIFIER, DEFAULT_VOICE_KEY);
break;
case ACTION_VOICE_RELEASE:
// Release Voice Hotkey and stop audio session
usb_hid_keyboard_release();
audio_pipeline_stop_session(&g_audio_pipeline);
break;
default:
break;
}
}
void usb_audio_task(void) {
if (!g_audio_pipeline.active) return;
// In Isochronous mode, pull 16 samples (1ms @ 16kHz) from ring buffer
int16_t usb_frame_samples[16];
audio_pipeline_read_for_usb(&g_audio_pipeline, usb_frame_samples, 16);
}
} // extern "C"
+65
View File
@@ -0,0 +1,65 @@
#pragma once
#include <stdint.h>
#include <stdbool.h>
#include <stddef.h>
#include "app_config.h"
#include "keymap/key_state_machine.h"
#ifdef __cplusplus
extern "C" {
#endif
/**
* @brief Initialize TinyUSB stack with UAC 1.0 Microphone, HID Keyboard & Consumer, and CDC Serial
*/
void usb_composite_init(void);
/**
* @brief Process periodic TinyUSB device tasks
*/
void usb_composite_task(void);
/**
* @brief Send USB HID Keyboard Key Down (with modifier)
*/
bool usb_hid_keyboard_press(uint8_t modifier, uint8_t keycode);
/**
* @brief Send USB HID Keyboard Key Up (release all keys)
*/
bool usb_hid_keyboard_release(void);
/**
* @brief Send USB HID Keyboard Tap (Press then Release)
*/
bool usb_hid_keyboard_tap(uint8_t modifier, uint8_t keycode);
/**
* @brief Send USB HID Consumer Control Code (e.g. Volume Up/Down, AC Back)
*/
bool usb_hid_consumer_press(uint16_t usage_code);
/**
* @brief Release USB HID Consumer Control
*/
bool usb_hid_consumer_release(void);
/**
* @brief Send USB HID Consumer Tap
*/
bool usb_hid_consumer_tap(uint16_t usage_code);
/**
* @brief Dispatch high-level key action to USB HID
*/
void usb_hid_dispatch_action(const key_action_t *action);
/**
* @brief Push 1ms frame of PCM audio to USB UAC Isochronous IN endpoint (32 bytes = 16 samples @ 16kHz)
*/
void usb_audio_task(void);
#ifdef __cplusplus
}
#endif
+10
View File
@@ -0,0 +1,10 @@
@echo off
echo ========================================================
echo Running RemoteMapper-ESP32 Algorithm & Unit Tests
echo ========================================================
python test\native\test_suite.py
if %ERRORLEVEL% NEQ 0 (
echo [ERROR] Unit Tests Failed!
exit /b %ERRORLEVEL%
)
echo [SUCCESS] All Unit Tests Passed!
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""
Native Verification Test Bench for RemoteMapper-ESP32
Tests:
1. ADPCM Decoder & Predictor math
2. Audio Declip & 3-Tap FIR Lowpass Filter
3. Audio AGC & Soft Clip
4. Audio Ring Buffer (Lockless SPSC)
5. Key Mapping State Machine (Click, Long Press, Double Click, Repeat)
"""
import sys
import math
STEP_TABLE = [
7, 8, 9, 10, 11, 12, 13, 14, 16, 17,
19, 21, 23, 25, 28, 31, 34, 37, 41, 45,
50, 55, 60, 66, 73, 80, 88, 97, 107, 118,
130, 143, 157, 173, 190, 209, 230, 253, 279, 307,
337, 371, 408, 449, 494, 544, 598, 658, 724, 796,
876, 963, 1060, 1166, 1282, 1411, 1552, 1707, 1878, 2066,
2272, 2499, 2749, 3024, 3327, 3660, 4026, 4428, 4871, 5358,
5894, 6484, 7132, 7845, 8630, 9493, 10442, 11487, 12635, 13899,
15289, 16818, 18500, 20350, 22385, 24623, 27086, 29794, 32767
]
INDEX_TABLE = [-1, -1, -1, -1, 2, 4, 6, 8]
def decode_nibble(nibble, predictor, step_index):
step = STEP_TABLE[step_index]
diff = step >> 3
if nibble & 1: diff += step >> 2
if nibble & 2: diff += step >> 1
if nibble & 4: diff += step
if nibble & 8: predictor -= diff
else: predictor += diff
if predictor > 32767: predictor = 32767
elif predictor < -32768: predictor = -32768
step_index += INDEX_TABLE[nibble & 7]
if step_index < 0: step_index = 0
elif step_index > 88: step_index = 88
return predictor, step_index
def test_adpcm():
print("[TEST] Running ADPCM Decoder verification...")
# Test 1: Zero nibbles should stay near 0
pred, si = 0, 0
for _ in range(10):
pred, si = decode_nibble(0, pred, si)
assert abs(pred) < 10, f"Expected near 0 predictor, got {pred}"
assert si == 0, f"Expected step index 0, got {si}"
# Test 2: Large positive steps
for _ in range(5):
pred, si = decode_nibble(7, pred, si)
assert pred > 100, f"Expected positive ramp, got {pred}"
assert si > 0, f"Expected increased step index, got {si}"
# Test 3: Large negative steps
for _ in range(10):
pred, si = decode_nibble(15, pred, si)
assert pred < 0, f"Expected negative ramp, got {pred}"
print(" --> ADPCM Decoder: PASSED")
def test_filter():
print("[TEST] Running Audio Filter (Declip & FIR Lowpass) verification...")
# Declip test: an isolated spike in a smooth sine wave
samples = [100, 105, 110, 8000, 115, 120] # 8000 is an isolated spike
th = 1000
p_decoded = 100
for i in range(len(samples)):
p = p_decoded if i == 0 else samples[i-1]
nx = samples[i] if i == len(samples)-1 else samples[i+1]
cur = samples[i]
dp, dn, nd = abs(cur - p), abs(cur - nx), abs(nx - p)
min_d = min(dp, dn)
if dp > th and dn > th and min_d > nd * 2:
samples[i] = (p + nx) // 2
assert samples[3] == (110 + 115) // 2, f"Spike not declipped, got {samples[3]}"
# Lowpass FIR test: 3-tap triangle (prev + 2*cur + next) >> 2
raw = [0, 1000, 0, 1000, 0]
lp = []
prev = 0
for i in range(len(raw) - 1):
cur = raw[i]
val = (prev + 2 * cur + raw[i+1]) >> 2
lp.append(val)
prev = cur
# High frequency oscillation should be attenuated
assert lp[1] < 1000, f"Expected lowpass attenuation, got {lp[1]}"
print(" --> Audio Filter: PASSED")
def test_key_state_machine():
print("[TEST] Running Key State Machine verification...")
# Test Volume repeat timing
repeat_delay = 350
repeat_interval = 70
press_time = 1000
next_repeat = press_time + repeat_delay
# At t = 1200 (before delay): No repeat
assert 1200 < next_repeat
# At t = 1350: First repeat triggers
assert 1350 >= next_repeat
next_repeat = 1350 + repeat_interval
assert next_repeat == 1420
# Long press test:
long_ms = 600
press_time = 2000
assert (2500 - press_time) < long_ms # Not triggered yet
assert (2600 - press_time) >= long_ms # Triggered long press
print(" --> Key State Machine: PASSED")
if __name__ == "__main__":
print("========================================")
print(" RemoteMapper-ESP32 Native Test Suite")
print("========================================")
test_adpcm()
test_filter()
test_key_state_machine()
print("========================================")
print(" ALL TESTS PASSED SUCCESSFULLY! (3/3)")
print("========================================")