
Идея устройства возникла у меня давно. Захотелось аппаратного менеджера паролей. Надоело перепечатывать 20-символьные пароли из телефонного KeyPass, а на каждый компьютер, где нужно что-то вводить, устанешь устанавливать. Посмотрел, что предлагают, посмотрел, что делают. В основном, не понравились размеры. Иногда цена. С другой стороны, телефон с паролями всегда с собой. А тут ещё бесплатный DeepSeek.
TL;DR Просто история мучений. В конце прошивка для контроллера + Android-приложение, работает. Хотя и на стадии «концепт».
Однажды я вспомнил, что есть такие ESP32, которые умеют работать с USB напрямую и эмулировать HID-устройства. У них есть bluetooth, то есть можно воткнуть в компьютер, прикинуться клавиатурой, и с телефона отправить текст. Размер контроллера совсем маленький, повесил на ключи, — и он всегда с тобой.
Купил у китайцев ESP32-C3. Пока оно ехало, узнал, что USB HID там нет. Купил ESP32-S3 zero mini micro nano (их по-разному называют) — это для тех, кто решит повторить мой путь.
В общем, из железа это всё. Дальше прошивка и приложение.
Вайб-кодинг это называется, когда сказать можешь, а программировать — нет. Удобно, что не пришлось изучать контроллер, BLE (я с ним до этого не сталкивался), Kotlin - ни разу не писал приложений для телефона, и до сих пор не умею. Я не настоящий программист.
Я почему-то решил, что написав в чат DeepSeek «Напиши программу для ESP32-S3 для эмуляции USB HID клавиатуры. ESP32-S3 получает текст через BLE и отправляет в USB. Напиши Android-приложение, которое отправляет текст по BLE из буфера обмена» получу всё и сразу. Сразу не заработало ничего :(
Пока ехал контроллер, я занялся приложением для телефона.

Вот перечень некоторых ошибок:Unresolved reference ‘R'
unable to instantiate activity componentinfo didnt find class mainactivivty
resource style/AppTheme not found
Attempt to invoke virtual method 'java.lang.String android.provider.MiuiSettings$SettingsCloudData$CloudData.getString(java.lang.String, java.lang.String)' on a null object reference
По большей части, я просто копировал текст ошибки из IDE в чат DeepSeek. Сначала добавлял «исправь ошибку» или «найди решение», потом забил даже на это. Нейросеть рассказывала, как исправить ошибки, которые она же и сделала. Странное ощущение. В итоге приложение заработало, я добавил тёмную тему и передвинул кнопочку:
Скрытый текст

MainActivity.kt
package com.mc.bleapp
import android.annotation.SuppressLint
import android.bluetooth.*
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanResult
import android.content.ClipboardManager
import android.content.Context
import android.content.pm.PackageManager
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.widget.Button
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import java.util.*
class MainActivity : AppCompatActivity() {
private lateinit var statusText: TextView
private lateinit var logText: TextView
private lateinit var sendButton: Button
private lateinit var reconnectButton: Button
private val DEVICE_NAME = "Secure BLE Keyboard"
private val SERVICE_UUID = UUID.fromString("4fafc201-1fb5-459e-8fcc-c5c9c331914b")
private val INPUT_CHAR_UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361b26a8")
private var bluetoothAdapter: BluetoothAdapter? = null
private var bluetoothGatt: BluetoothGatt? = null
private var inputCharacteristic: BluetoothGattCharacteristic? = null
private var isConnected = false
private val handler = Handler(Looper.getMainLooper())
private val PERMISSIONS = arrayOf(
android.Manifest.permission.ACCESS_FINE_LOCATION,
android.Manifest.permission.BLUETOOTH,
android.Manifest.permission.BLUETOOTH_ADMIN,
android.Manifest.permission.BLUETOOTH_CONNECT,
android.Manifest.permission.BLUETOOTH_SCAN
)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
statusText = findViewById(R.id.statusText)
logText = findViewById(R.id.logText)
sendButton = findViewById(R.id.sendButton)
reconnectButton = findViewById(R.id.reconnectButton)
reconnectButton.setOnClickListener { reconnect() }
sendButton.setOnClickListener { sendClipboardContent() }
checkPermissions()
}
private fun checkPermissions() {
if (PERMISSIONS.any { ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED }) {
ActivityCompat.requestPermissions(this, PERMISSIONS, 0)
} else {
initializeBluetooth()
}
}
@SuppressLint("MissingPermission")
private fun initializeBluetooth() {
bluetoothAdapter = BluetoothAdapter.getDefaultAdapter().takeIf { it.isEnabled }
?: run {
Toast.makeText(this, "Enable Bluetooth", Toast.LENGTH_LONG).show()
return
}
startDeviceScan()
}
@SuppressLint("MissingPermission")
private fun startDeviceScan() {
statusText.text = "Scanning..."
bluetoothAdapter?.bluetoothLeScanner?.startScan(scanCallback)
handler.postDelayed({ stopScan() }, 10000)
}
private fun stopScan() {
bluetoothAdapter?.bluetoothLeScanner?.stopScan(scanCallback)
}
private val scanCallback = object : ScanCallback() {
@SuppressLint("MissingPermission")
override fun onScanResult(callbackType: Int, result: ScanResult) {
if (result.device.name == DEVICE_NAME) {
stopScan()
connectToDevice(result.device)
}
}
}
@SuppressLint("MissingPermission")
private fun connectToDevice(device: BluetoothDevice) {
statusText.text = "Connecting..."
bluetoothGatt = device.connectGatt(this, false, gattCallback)
}
private val gattCallback = object : BluetoothGattCallback() {
@SuppressLint("MissingPermission")
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
when (newState) {
BluetoothProfile.STATE_CONNECTED -> {
isConnected = true
handler.post {
statusText.text = "Connected"
log("Connected")
}
gatt.discoverServices()
}
BluetoothProfile.STATE_DISCONNECTED -> {
isConnected = false
handler.post {
statusText.text = "Disconnected"
log("Connection lost")
}
}
}
}
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
if (status == BluetoothGatt.GATT_SUCCESS) {
inputCharacteristic = gatt.getService(SERVICE_UUID)
?.getCharacteristic(INPUT_CHAR_UUID)
handler.post {
if (inputCharacteristic != null) {
log("Service found")
sendButton.isEnabled = true
} else {
log("Service not found")
}
}
}
}
}
private fun sendClipboardContent() {
if (!isConnected) {
Toast.makeText(this, "Not connected", Toast.LENGTH_SHORT).show()
return
}
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.primaryClip?.getItemAt(0)?.text?.toString()?.let { text ->
if (text.isNotEmpty()) sendData(text)
} ?: Toast.makeText(this, "Clipboard empty", Toast.LENGTH_SHORT).show()
}
@SuppressLint("MissingPermission")
private fun sendData(text: String) {
inputCharacteristic?.let {
it.value = text.toByteArray()
bluetoothGatt?.writeCharacteristic(it)
log("Sent: ${text.take(20)}...")
} ?: log("Characteristic null")
}
private fun log(message: String) {
logText.append("\n$message")
}
@SuppressLint("MissingPermission")
private fun reconnect() {
log("Reconnect...")
bluetoothGatt?.disconnect()
bluetoothGatt?.close()
bluetoothGatt = null
inputCharacteristic = null
sendButton.isEnabled = false
startDeviceScan()
}
override fun onDestroy() {
super.onDestroy()
bluetoothGatt?.disconnect()
bluetoothGatt?.close()
}
}
activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/statusText"
android:layout_marginTop="20dp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Status: Disconnected"
android:textSize="18sp"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Log:"
android:textSize="16sp"/>
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<TextView
android:id="@+id/logText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="14sp"/>
</ScrollView>
<Button
android:id="@+id/reconnectButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Переподключиться"/>
<Button
android:id="@+id/sendButton"
android:layout_width="match_parent"
android:layout_height="87dp"
android:enabled="false"
android:text="Отправить"/>
</LinearLayout>
AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.mc.bleapp">
<uses-permission android:name="android.permission.BLUETOOTH"/>
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"/>
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/>
<uses-permission android:name="android.permission.BLUETOOTH_PRIVILEGED"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<application
android:theme="@style/Theme.AppCompat.DayNight.DarkActionBar"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="BLE Keyboard">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
Дальше приехал контроллер, и вот с ним я бился две недели.
После исправления ошибок компиляции оно прошилось, но не работало.
Первая ошибка -BT_GATT: gatts_write_attr_perm_check - GATT_INSUF_AUTHENTICATION,handle 002a, perm 0020
Она сыпалась в лог при попытке отправить текст с телефона. Я решил, что раз про аутентификацию - нужно добавить пин-код при bluetooth-спаривании. К этому моменту я уже понял, что, хоть нейросеть и понимает контекст, лучше начинать новый чат для новой идеи.

Дальше были ошибки и решения:error: lvalue required as unary '&' operand -> Заменили #define PIN_CODE на константную переменную const uint32_t PIN_CODE
error: conversion from 'String' to non-scalar type 'std::string' {aka 'std::_cxx11::basic_string'} requested -> std::string value = pCharacteristic->getValue().c_str();
И ещё штук пяток.
Причём, эти ошибки повторяются из итерации в итерацию. Исправил одну - вернул другую. Исправил другую, новая итерация - опа, вернулась первая. Я сначала копировал только текст ошибки, затем перешёл к варианту текст ошибки + строка с ошибкой - ответы значительно улучшились. В любом кодинге помогает опыт =)
В итоге, когда спаривание с пин-кодом случилось, я перешёл к имитации HID-клавиатуры. Тут вроде как проблем не случилось. Ну это только сначала.
error: 'BLEProperties' has not been declared BLEProperties::PROPERTY_NOTIFY -> Ошибка возникает из-за устаревшего синтаксиса. Нужно использовать BLECharacteristic::PROPERTY* вместо BLEProperties::.
Так зачем ты, собака, предлагаешь устаревший синтаксис???error: 'class BLEAdvertising' has no member named ‘setMinSecurity' -> Удалена строка pAdvertising->setMinSecurity() - эта функция не существует в текущей версии библиотеки
Ну и так далее. Почему сразу не писать без ошибок? Это что, имитация человека?
К этому моменту я прочитал статью про то, что можно в запрос прикладывать файл с кодом. Сохранил работающий код, в котором был только запрос пин-кода, и мы начали сначала:

Ну и снова заново:error: conversion from 'String' to non-scalar type ‘std::string'
Временами нейросеть что-то придумывала. Например, метод asciiToHID
— ей так удобнее конвертировать текст в HID-символы. Оказалось, что «в классе SecurityCallbacks не реализованы все чисто виртуальные методы из базового класса BLESecurityCallbacks». И много ещё всякого, через что мы с ней прошли.
В итоге, я не добился чего хотел в чистом виде. В некоторых вариантах ядро ESP падало в панике, часто после внесения изменений не запрашивало пин-код и не соединялось соответственно. У меня была программа, которая получает из телефона текст и выводит в терминал, но никак не хочет в USB HID. Я взял код из примера для имитации клавиатуры от DeepSeek и попросил дублировать вывод терминала при помощи Keyboard.press:
Пример
#include <USB.h>
#include <USBHIDKeyboard.h>
USBHIDKeyboard Keyboard;
void setup() {
USB.begin();
Keyboard.begin();
delay(2000); // Дать время для подключения USB
Keyboard.press(KEY_LEFT_CTRL);
Keyboard.press(KEY_ALT);
Keyboard.press('t');
Keyboard.releaseAll(); // Отправка Ctrl+Alt+T (открыть терминал в Linux)
}
void loop() {}

Тут заработало с первого раза. Но только латиница, цифры и спецсимволы. Впрочем, мне этого достаточно.
worked_hid_keyboard.ino
#include <BLEDevice.h>
#include <BLEUtils.h>
#include <BLEServer.h>
#include <BLE2902.h>
#include <USB.h>
#include <USBHIDKeyboard.h>
USBHIDKeyboard Keyboard;
#define DEVICE_NAME "Secure BLE Keyboard"
#define SERVICE_UUID "4fafc201-1fb5-459e-8fcc-c5c9c331914b"
#define CHARACTERISTIC_UUID "beb5483e-36e1-4688-b7f5-ea07361b26a8"
const uint32_t PIN_CODE = 123456;
bool deviceConnected = false;
class CharacteristicCallbacks : public BLECharacteristicCallbacks {
void onWrite(BLECharacteristic *pCharacteristic) override {
String arduinoString = pCharacteristic->getValue().c_str();
std::string value(arduinoString.c_str());
if (!value.empty()) {
Serial.print("Received: ");
Serial.println(value.c_str());
// Отправка текста через USB HID
for (char c : value) {
Keyboard.press(c); // Нажать клавишу
delay(10); // Короткая задержка
Keyboard.release(c); // Отпустить клавишу
}
}
}
};
class ServerCallbacks: public BLEServerCallbacks {
void onConnect(BLEServer* pServer) override {
deviceConnected = true;
Serial.println("Device connected");
};
void onDisconnect(BLEServer* pServer) override {
deviceConnected = false;
Serial.println("Device disconnected");
pServer->startAdvertising();
}
};
class SecurityCallbacks: public BLESecurityCallbacks {
bool onConfirmPIN(uint32_t pass_key) override {
Serial.print("Confirm PIN: ");
Serial.println(pass_key);
return (pass_key == PIN_CODE);
}
void onAuthenticationComplete(esp_ble_auth_cmpl_t cmpl) override {
if(cmpl.success) {
Serial.println("Authentication success");
} else {
Serial.println("Authentication failed");
}
}
uint32_t onPassKeyRequest() override {
return PIN_CODE;
}
void onPassKeyNotify(uint32_t pass_key) override {
Serial.print("PassKey Notify: ");
Serial.println(pass_key);
}
bool onSecurityRequest() override {
return true;
}
};
void setup() {
Serial.begin(115200);
USB.begin();
Keyboard.begin();
delay(2000); // Дать время для подключения USB
BLEDevice::init(DEVICE_NAME);
BLEDevice::setEncryptionLevel(ESP_BLE_SEC_ENCRYPT);
BLEServer *pServer = BLEDevice::createServer();
pServer->setCallbacks(new ServerCallbacks());
BLESecurity *pSecurity = new BLESecurity();
pSecurity->setAuthenticationMode(ESP_LE_AUTH_REQ_SC_BOND);
pSecurity->setCapability(ESP_IO_CAP_OUT);
pSecurity->setInitEncryptionKey(ESP_BLE_ENC_KEY_MASK | ESP_BLE_ID_KEY_MASK);
BLEDevice::setSecurityCallbacks(new SecurityCallbacks());
esp_ble_gap_set_security_param(
ESP_BLE_SM_SET_STATIC_PASSKEY,
const_cast<uint32_t*>(reinterpret_cast<const uint32_t*>(&PIN_CODE)),
sizeof(PIN_CODE)
);
BLEService *pService = pServer->createService(SERVICE_UUID);
BLECharacteristic *pCharacteristic = pService->createCharacteristic(
CHARACTERISTIC_UUID,
BLECharacteristic::PROPERTY_READ |
BLECharacteristic::PROPERTY_WRITE
);
// Добавлен обработчик входящих данных
pCharacteristic->setCallbacks(new CharacteristicCallbacks());
pCharacteristic->setValue("Hello World");
pCharacteristic->addDescriptor(new BLE2902());
pService->start();
BLEAdvertising *pAdvertising = pServer->getAdvertising();
pAdvertising->addServiceUUID(SERVICE_UUID);
pAdvertising->start();
}
void loop() {
delay(1000);
}
Осталось только добавить мигание светодиодом и нарисовать корпус (я и проектировщик не настоящий, умею только в SketchUp).
Итого:
вайб-программисту всё равно пришлось побыть программистом. Правил код, добавлял нужное, убирал лишнее. Если б я был условным электронщиком, и DeepSeek бы делал ошибки в рисовании схем - я бы не смог исправить;
действительно, лучше разрабатывать шажочками, а не приложение целиком. Один шажочек - один запрос;
сохранять работающий код в файл и прикладывать к запросу - это прям здорово, для меня было прорывом;
с Android-приложением получилось хорошо, я в этом совсем ноль был. Теперь умею кнопку по экрану двигать.
На весь процесс ушло 2 недели между делом. Чистого времени часа 2-3. Это, конечно, быстрее, нежели я бы читал доки и писал код самостоятельно.

Ссылка на Google Drive с исходниками, apk и stl для тех, кто хочет так же, а программировать лень.
В написании статьи нейросети участия не принимали, всё сам =)
Комментарии (6)
xSVPx
25.05.2025 21:48Найти бы тоже самое, но с хранением паролей внутри esp, как, к примеру мультипасс делает. Прям хоть пили... Ещеб ТОТР туда же...
PeterFukuyama
25.05.2025 21:48
lv333
Телефон может прикидывается блютуз клавиатурой... Можно обойтись без лишнего звена. https://play.google.com/store/apps/details?id=io.appground.blek
С рут правами можно и юсб клаву/мыш/флешку эмулировать https://habr.com/ru/companies/ruvds/articles/816595/