Tema L1 · Laboratorio · ESP32 emulado y Bluetooth
Programación Dirigida por Eventos · Grado en Ingeniería Informática · 2026/27
Del ESP32 emulado a tu app Android: un firmware que corre en esp-emu manda un tick cada 5 s y responde con eco por Bluetooth clásico (SPP) gracias a Bumble, y una Activity Kotlin lo recibe como eventos que llegan desde otro hilo.
Montar la cadena: firmware, esp-emu, Bumble y emulador
Qué tienes que conseguir, las cuatro piezas del laboratorio, por qué hace falta Bumble, la cadena SPP completa con su orden de arranque, los siete pasos con hitos verificables y cómo se trabaja el firmware.
Teoría 1. A dónde tienes que llegar
En este laboratorio tu app Android conversa con un ESP32 que no existe físicamente. El firmware que compilas con PlatformIO corre en un emulador de ESP32-C3 dentro de tu ordenador y se comunica con la app por Bluetooth clásico (SPP), exactamente igual que lo haría con un módulo HC-05 real.
El comportamiento es sencillo a propósito:
- Cada 5 segundos el firmware manda un
tickcon los segundos que lleva en marcha. - Cuando le escribes algo desde la app, contesta con un eco numerado.
Esto es lo que verás en la app (emulador Pixel 8, conectada a ESP32-EMU-SPP):
<< tick 370 s
<< tick 375 s
>> hola desde la app
<< eco #4: hola desde la appLas flechas << marcan lo que llega del ESP32 y >> lo que envías tú.
¿Por qué es interesante para esta asignatura?
Hasta ahora casi todos tus eventos venían del dedo: onClick, onCheckedChanged... Aquí aparecen dos fuentes nuevas:
Evento temporal
El tick cada 5 s: nadie lo pide, llega solo.
Evento externo
Bytes que llegan por un socket cuando quieren, y en otro hilo.
Evento de usuario
Tu mensaje: lo escribes y lo envías, y provoca un eco.
- Nada: el ESP32 solo responde cuando le escribes
- Un
eco #Ncada 5 segundos - Un
tick N scada 5 segundos - Un mensaje cada vez que pulsas el botón BOOT
El firmware manda un tick cada 5 s por su cuenta. El eco #N solo aparece como respuesta a un mensaje tuyo.
- Wi-Fi, con peticiones HTTP
- Bluetooth clásico con el perfil SPP, como un HC-05
- Bluetooth Low Energy con GATT
- Cable USB emulado
Se usa Bluetooth clásico (SPP), el perfil de los módulos HC-05/HC-06. El modo BLE existe solo como ampliación.
Teoría 2. Cuatro piezas, cuatro herramientas
La práctica se monta con cuatro piezas. Solo esp-emu y Bumble son nuevas; el resto ya lo tienes del laboratorio anterior.
1 · Firmware
VS Code + PlatformIO. Proyecto demo-esp32, placa ESP32-C3, framework Arduino. Compila y genera la imagen de flash que carga el emulador.
2 · Chip
esp-emu (Espressif). Emulador oficial de ESP32-C3/C6/H2/P4. Ejecuta tu firmware y expone la UART1 por TCP. Es lo nuevo de esta práctica.
3 · Radio
Bumble (Google). Pila Bluetooth en Python. Hace de HC-05 virtual: convierte la UART del firmware en un servicio SPP visible desde Android.
4 · App
Android Studio. La plantilla pde-plantilla-sdk36 con una Activity nueva, BluetoothActivity. El emulador Pixel 8 (API 36) aporta la radio virtual (netsim).
Esta regla te ahorra mucho tiempo: si algo falla en medio, casi nunca es «tu código», sino el orden de arranque o una dirección. Lo veremos en el diagnóstico.
| Pieza | Lenguaje / herramienta | ¿La programas tú? |
|---|---|---|
| Firmware | C++ / Arduino en PlatformIO | Sí |
| esp-emu | Binario de Espressif | No, solo se arranca |
| Bumble | Paquete Python | No, solo se arranca |
| Emulador Android + netsim | Android Studio | No, solo se arranca |
| BluetoothActivity | Kotlin | Sí |
- El firmware
src/spp/main.cpp - El emulador esp-emu
- El puente Bumble
- La
BluetoothActivityen Kotlin - La radio virtual netsim
Escribes los dos extremos: firmware y Activity. esp-emu, Bumble y el emulador Android (con netsim) solo se arrancan con los scripts.
- esp-emu
- Bumble
- PlatformIO
- netsim, que arranca con el emulador Android
netsim es la radio Bluetooth virtual del emulador Android. Bumble publica el servicio SPP en esa radio, y esp-emu solo expone la UART por TCP.
Teoría 3. Por qué hace falta Bumble en medio
Parece que sobra una pieza: si el emulador ejecuta un ESP32, ¿por qué no habla Bluetooth directamente? Tres razones:
- esp-emu solo emula chips RISC-V: ESP32-C3, C6, H2 y P4. Ninguno tiene Bluetooth clásico: todos son BLE only. El ESP32 «clásico», que sí lo tiene, no se puede emular.
- La app usa SPP, el perfil de Bluetooth clásico de los módulos HC-05/HC-06 de Arduino: un socket con un flujo de bytes en cada sentido.
- Bumble hace de HC-05. El firmware habla por su UART1; Bumble publica el servicio SPP en la radio virtual y reenvía cada byte a esa UART. Tu código no cambia.
Compara las dos topologías:
La línea recta es un cable (o su equivalente, un socket TCP) y la ondulada es radio Bluetooth. La topología es la misma: solo cambia quién pone la radio.
- esp-emu es demasiado lento para gestionar la radio
- Los chips que emula esp-emu son BLE only y la app usa SPP, que es Bluetooth clásico
- Android no permite conectar con emuladores sin un intermediario Python
- PlatformIO no sabe compilar código Bluetooth
C3, C6, H2 y P4 no tienen Bluetooth clásico. Bumble actúa como un HC-05 virtual que convierte la UART en un servicio SPP.
- ESP32-C3
- ESP32 clásico
- ESP32-C6
- ESP32-H2
- ESP32-P4
esp-emu emula los RISC-V C3, C6, H2 y P4. El ESP32 «clásico» no se puede emular.
Teoría 4. La cadena completa en modo SPP
Esta es la cadena de extremo a extremo:
Direcciones y sentido de la conexión
- La UART1 del firmware se sirve en
127.0.0.1:5556. Escribe siempre la IP, nunca «localhost»:localhostpuede resolver a la dirección IPv6 (::1) y entonces no encuentra a esp-emu. - El puente RFCOMM se conecta a esp-emu, no al revés. esp-emu es el servidor TCP; Bumble es el cliente.
El orden de arranque
Como el puente necesita encontrar a esp-emu escuchando, el orden importa:
Los pasos 2 y 3 los hace ./run.sh spp, que ya arranca esp-emu primero y luego Bumble.
Emulador Android → esp-emu → puente Bumble → emparejar → app. esp-emu va antes que el puente porque es el puente quien se conecta a esp-emu.
5556La UART1 queda en 127.0.0.1:5556.
127.0.0.1 y nunca localhost?- Porque
localhostpuede resolver a IPv6 y no encontrar a esp-emu - Porque
localhostsolo funciona en Windows - Porque
127.0.0.1es más rápido de escribir - Porque Bumble no acepta nombres de máquina por licencia
localhost puede resolverse a ::1 (IPv6). Con la IPv4 explícita no hay ambigüedad.
Práctica 5. Lo que recibes y lo que instalas
Recibes dos ZIP del profesor e instalas dos herramientas nuevas.
ZIP demo-esp32
| Fichero | Para qué |
|---|---|
platformio.ini |
Dos entornos: esp32c3 (BLE) y esp32c3-spp (el nuestro) |
src/spp/main.cpp |
El firmware de partida |
build.sh y run.sh |
Compilar y arrancar la cadena |
tools/hc05.json |
Identidad del HC-05 virtual |
tools/verify_spp.py |
Prueba de la cadena sin app |
ZIP pde-plantilla-sdk36
La plantilla de la asignatura (Compose + vistas clásicas). Sus versiones están fijadas: no toques gradle/. Aquí añadirás BluetoothActivity, su layout y los permisos en el manifest. El emulador Pixel 8 API 36 se crea desde Device Manager.
Lo que instalas tú
- esp-emu: binario oficial de Espressif (instalador o release de GitHub). Hito:
esp-emu --helpresponde. - Bumble: paquete Python en un entorno virtual, con Python 3.10 o superior:
# desde la carpeta que CONTIENE demo-esp32 (los scripts esperan ../.venv)
python3 -m venv .venv
source .venv/bin/activate
pip install "bumble[all]"Ya tienes del laboratorio anterior VS Code + PlatformIO y Android Studio.
Las comillas en "bumble[all]" no son decorativas: en zsh los corchetes se interpretan como patrón de ficheros y sin comillas el comando puede fallar.
demo-esp32/.venv y run.sh no encuentra Bumble. ¿Por qué?- Bumble necesita instalarse con permisos de administrador
- Los scripts esperan el venv en
../.venv, una carpeta por encima del proyecto - El venv debe llamarse
bumble, no.venv - Bumble solo funciona instalado en
~/.platformio
Los scripts esperan ../.venv. O lo mueves ahí o ajustas build.sh y run.sh.
- Python 2.7
- Python 3.6
- Python 3.10
- Python 3.13
El material pide Python 3.10 o superior.
platformio.ini que usamos en esta práctica (el de SPP)?esp32c3-sppesp32c3-spp. El otro entorno, esp32c3, es el modo BLE de ampliación.
Práctica 6. Los siete pasos, con hitos
Los pasos 1 a 3 se hacen una sola vez; del 4 al 7 son el ciclo de trabajo de la práctica.
| # | Qué | Con qué | Hito: sabes que está cuando… |
|---|---|---|---|
| 1 | Instalar esp-emu y Bumble | Instalador de esp-emu · venv + pip install "bumble[all]" |
esp-emu --help responde |
| 2 | Compilar el firmware SPP | VS Code + PlatformIO · ./build.sh esp32c3-spp |
Existe merged_flash.bin |
| 3 | Arrancar el emulador Android | Android Studio · Pixel 8 API 36 | netsimd está corriendo |
| 4 | Arrancar esp-emu y el puente | ./run.sh spp (esp-emu primero, luego Bumble) |
Consola: «firmware SPP listo» |
| 5 | Emparejar (solo una vez) | Ajustes → Bluetooth → Pair new device | «ESP32-EMU-SPP» emparejado |
| 6 | Crear BluetoothActivity |
Layout, permisos, socket RFCOMM, hilo lector | El Spinner lista el dispositivo |
| 7 | Cerrar el bucle | Cambiar firmware y app; recompilar, reinstalar | Ticks y ecos en la app |
¿Por qué tanta insistencia? Porque en una cadena de cinco piezas, un fallo en la fila 2 se manifiesta como «la app no conecta» en la fila 7, y entonces no sabes dónde mirar. Avanzar hito a hito es aislar el fallo antes de que se esconda.
Instalar → compilar → emulador Android → esp-emu + puente → emparejar → Activity → cerrar el bucle.
- Existe
merged_flash.bin netsimdestá corriendo- El Spinner lista el dispositivo
- La consola muestra «firmware SPP listo»
Al arrancar la cadena, la consola de esp-emu debe mostrar el mensaje del firmware. merged_flash.bin es el hito del paso 2, netsimd del 3 y el Spinner del 6.
Práctica 7. El firmware: Serial, Serial1 y merged_flash.bin
Proyecto demo-esp32, entorno esp32c3-spp, placa esp32-c3-devkitm-1, framework Arduino, sin librerías extra.
Qué tocar: src/spp/main.cpp
El firmware usa dos puertos serie con papeles distintos:
| Puerto | Para qué | Dónde lo ves |
|---|---|---|
Serial |
Logs de depuración | Consola de esp-emu |
Serial1 (UART1, pines 4 y 5) |
Hablar con la app | Bumble → Bluetooth → app |
El firmware lee líneas de Serial1, responde con eco y manda un tick cada 5 s. Un boceto orientativo (la configuración exacta de Serial1 es la de la plantilla):
uint32_t ultimoTick = 0;
uint32_t ecos = 0;
String linea;
void setup() {
Serial.begin(115200); // logs → consola de esp-emu
// Serial1.begin(...): baudios y pines 4/5 como vienen en la plantilla
Serial.println("[APP] firmware SPP listo");
}
void loop() {
if (millis() - ultimoTick >= 5000) { // evento temporal: tick
ultimoTick = millis();
Serial1.printf("tick %lu s\n", (unsigned long) (millis() / 1000));
}
while (Serial1.available()) { // evento externo: bytes
char c = Serial1.read();
if (c == '\n') {
Serial.printf("[UART1] recibido: %s\n", linea.c_str());
Serial1.printf("eco #%lu: %s\n", (unsigned long) ++ecos, linea.c_str());
linea = "";
} else if (c != '\r') {
linea += c;
}
}
}Cómo compilar
./build.sh esp32c3-sppEl script hace pio run y después esptool merge_bin. esp-emu quiere la imagen unificada merged_flash.bin, no firmware.bin: necesita toda la flash montada (no solo tu aplicación), como si fuera el chip real recién grabado.
Serial.println("hola") esperando verlo en la app, pero solo aparece en la consola de esp-emu. ¿Qué ha pasado?- Bumble no está arrancado
Seriales el puerto de logs; para la app hay que escribir enSerial1- Hay que reinstalar la app
- Falta el permiso
BLUETOOTH_CONNECT
Serial va a la consola de esp-emu. Lo que viaja a la app es lo que se escribe en Serial1 (UART1), que Bumble reenvía por SPP.
merged_flash.binmerged_flash.bin, generada por build.sh con esptool merge_bin. No vale firmware.bin a secas.
Ejercicio 8. Ejercicio: ¿qué tengo que relanzar?
En el ciclo de trabajo (pasos 4 a 7) vas a cambiar cosas a ambos lados constantemente. Saber qué relanzar según qué has tocado te ahorra muchos minutos.
| Has cambiado… | Tienes que… | No hace falta… |
|---|---|---|
El firmware (main.cpp) |
./build.sh esp32c3-spp y relanzar esp-emu (./run.sh spp) |
Reinstalar la app |
| La Activity o el layout | Recompilar y reinstalar la app desde Android Studio | Recompilar el firmware |
| Nada, solo reiniciaste el puente | Puede que Android crea que sigue el enlace anterior (ver diagnóstico) | Volver a emparejar |
Piensa en la cadena como piezas independientes que se hablan por «tuberías» (TCP, Bluetooth). Cambiar una pieza solo obliga a reiniciar esa pieza y, como mucho, las que dependen de ella para conectarse.
Caso resuelto
Cambias el periodo del tick de 5 s a 2 s. Recompilas con ./build.sh esp32c3-spp y relanzas ./run.sh spp. Como esp-emu arranca de nuevo, el contador de segundos del tick vuelve a empezar. La app sigue instalada; si se queda «colgada» tras el reinicio del puente, mira el caso del enlace zombi.
- Ejecutar
./build.sh esp32c3-spp - Relanzar esp-emu con
./run.sh spp - Reinstalar la app
- Volver a emparejar ESP32-EMU-SPP
Cambio de firmware = recompilar y relanzar esp-emu. La app no se reinstala y el emparejamiento se conserva.
reset. ¿Qué recompilas?- Solo el firmware
- El firmware y la app
- Nada: Android recarga el layout solo
- Solo la app, y la reinstalas
Has tocado la Activity, así que recompilas y reinstalas la app. Si además quieres que el firmware reaccione de forma especial a reset, eso sería otro cambio, ya en el firmware.
Repaso 9. Repaso: la cadena en una página
| Idea | Recuerda |
|---|---|
| Objetivo | Tick cada 5 s y eco a cada mensaje, por Bluetooth clásico (SPP) |
| Regla de oro | Escribes los extremos (firmware y Activity); lo de en medio solo se arranca |
| ¿Por qué Bumble? | esp-emu emula C3/C6/H2/P4, todos BLE only; Bumble pone el SPP |
| Orden | Emulador Android → esp-emu → puente Bumble → emparejar → app |
| Direcciones | 127.0.0.1, nunca localhost; el puente se conecta a esp-emu |
| Firmware | Serial = logs; Serial1 (pines 4 y 5) = app |
| Imagen | esp-emu carga merged_flash.bin, no firmware.bin |
| Ciclo | Cambio de firmware → recompilar y relanzar esp-emu; la app no se reinstala |
| Hitos | Si no marcas el hito de una fila, no pases a la siguiente |
- esp-emu se conecta a Bumble
- Las dos piezas se conectan a netsim y este las une
- El puente Bumble se conecta a esp-emu
- La app Android abre la conexión TCP
El puente RFCOMM se conecta a esp-emu, no al revés. Por eso esp-emu debe arrancar antes.
- El ESP32-C3 emulado no tiene Bluetooth clásico
- Hay que emparejar el dispositivo cada vez que se relanza el puente
tools/verify_spp.pypermite probar la cadena sin la app- Los scripts
build.shyrun.shvienen en el ZIPdemo-esp32 - esp-emu carga directamente
firmware.bin
El emparejamiento se hace una sola vez y esp-emu carga merged_flash.bin. El resto es correcto.
./run.sh spp./run.sh spp arranca esp-emu primero y luego el puente Bumble.
La BluetoothActivity, eventos externos y diagnóstico
Permisos, dispositivos emparejados, socket RFCOMM con el UUID SPP en un hilo aparte, hilo lector con runOnUiThread y su relación con Observer. Cierra con Windows/WSL2, los tres casos de «no conecta» y la lista de llegada.
Práctica 1. Manifest y permiso BLUETOOTH_CONNECT
La Activity nueva se llama BluetoothActivity y su interfaz es XML con vistas clásicas, como la de ContadorVistas. La novedad es que algunos eventos ya no vienen de la pantalla, sino del socket.
Solo un permiso
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />BLUETOOTH_CONNECT: necesario para hablar con dispositivos ya emparejados. Es un permiso peligroso, así que además de declararlo se pide en tiempo de ejecución.- Sin
BLUETOOTH_SCAN: no buscamos dispositivos nuevos; usamos los ya emparejados (paso 5 del laboratorio).
private val REQ_BT = 1
private fun pedirPermiso() {
val concedido = ContextCompat.checkSelfPermission(
this, Manifest.permission.BLUETOOTH_CONNECT
) == PackageManager.PERMISSION_GRANTED
if (concedido) cargarEmparejados()
else ActivityCompat.requestPermissions(
this, arrayOf(Manifest.permission.BLUETOOTH_CONNECT), REQ_BT
)
}
override fun onRequestPermissionsResult(
requestCode: Int, permissions: Array<out String>, grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == REQ_BT && grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) {
cargarEmparejados()
}
}Fíjate en que la respuesta del usuario al diálogo de permisos también es un evento: onRequestPermissionsResult es un callback que el sistema invoca cuando el usuario decide.
BLUETOOTH_CONNECTBLUETOOTH_CONNECT. No hace falta BLUETOOTH_SCAN porque no buscamos dispositivos.
BLUETOOTH_SCAN?- Porque en API 36 ya no existe
- Porque el emulador concede todos los permisos automáticamente
- Porque no se buscan dispositivos: se usan los ya emparejados
- Porque Bumble hace el escaneo por la app
La app solo lista bondedDevices (emparejados). Sin búsqueda no hace falta BLUETOOTH_SCAN.
Práctica 2. Adaptador y dispositivos emparejados
El adaptador Bluetooth se obtiene a través de BluetoothManager. Si está apagado, se pide al usuario que lo encienda con ACTION_REQUEST_ENABLE. Después, bondedDevices (los dispositivos emparejados) rellena un Spinner.
private var emparejados: List<BluetoothDevice> = emptyList()
@SuppressLint("MissingPermission") // ya comprobado en pedirPermiso()
private fun cargarEmparejados() {
val manager = getSystemService(BluetoothManager::class.java)
val adapter = manager.adapter ?: return // sin Bluetooth
if (!adapter.isEnabled) {
startActivity(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE))
return
}
emparejados = adapter.bondedDevices.toList()
spinner.adapter = ArrayAdapter(
this,
android.R.layout.simple_spinner_dropdown_item,
emparejados.map { it.name ?: it.address }
)
}Hito del paso 6: el Spinner lista ESP32-EMU-SPP. Si no aparece, el problema está antes: ¿emparejaste en Ajustes → Bluetooth → Pair new device?
bondedDevicesadapter.bondedDevices (en Java, getBondedDevices()) devuelve el conjunto de dispositivos emparejados.
ESP32-EMU-SPP aparece en el Spinner. ¿Qué puedes concluir con seguridad?- Que esp-emu está ejecutando el firmware
- Que el puente Bumble está conectado a esp-emu
- Que el socket RFCOMM ya está abierto
- Que el dispositivo se emparejó en algún momento
bondedDevices refleja el emparejamiento guardado, no el estado actual de la cadena.
Práctica 3. Conectar: socket RFCOMM en un Thread
Para hablar SPP se abre un socket RFCOMM con el UUID estándar del perfil SPP, que empieza por 00001101-:
private val SPP_UUID: UUID = UUID.fromString("00001101-0000-1000-8000-00805F9B34FB")
private var socket: BluetoothSocket? = null
@SuppressLint("MissingPermission")
private fun conectar(device: BluetoothDevice) {
estado = "Conectando con ${device.name}…"
actualizarVista()
Thread {
try {
val s = device.createRfcommSocketToServiceRecord(SPP_UUID)
s.connect() // BLOQUEA hasta conectar o fallar
socket = s
runOnUiThread { estado = "Conectado"; actualizarVista() }
arrancarLector(s)
} catch (e: IOException) {
runOnUiThread { estado = "Error de conexión: ${e.message}"; actualizarVista() }
}
}.start()
}Dos reglas
connect()dentro de unThread, nunca en el hilo principal. Es una llamada bloqueante: puede tardar segundos o no volver hasta el timeout. En el hilo principal congelaría la interfaz (y Android acabaría mostrando «la app no responde»).- Las vistas solo se tocan desde el hilo principal: por eso el resultado vuelve con
runOnUiThread.
socket.connect() directamente en el onClick del botón Conectar. ¿Qué ocurrirá?- Nada grave:
connect()es asíncrono y vuelve enseguida - La interfaz se congela mientras
connect()bloquea el hilo principal - Android lanza la conexión en otro hilo automáticamente
- El emparejamiento se pierde
connect() es bloqueante. En el hilo principal impide procesar eventos de interfaz: la app se congela.
adapter.cancelDiscovery() antes de connect() porque lo viste en un tutorial y la app se cierra al conectar. ¿Por qué?cancelDiscovery()exigeBLUETOOTH_SCAN, que la app no tienecancelDiscovery()desempareja el dispositivocancelDiscovery()cierra el socket antes de abrirlocancelDiscovery()solo funciona con BLE
Es la trampa señalada en el material: cancelDiscovery() requiere BLUETOOTH_SCAN y, sin él, la app casca.
00001101El UUID SPP es 00001101-0000-1000-8000-00805F9B34FB.
Práctica 4. Hilo lector, runOnUiThread y enviar
Una vez conectado, los bytes del ESP32 llegan cuando quieren: un tick cada 5 s, un eco tras cada mensaje. Para recibirlos hace falta un hilo lector que espere sobre inputStream y convierta cada línea en un evento de interfaz:
private val log = mutableListOf<String>()
private fun arrancarLector(s: BluetoothSocket) {
Thread {
try {
val lector = s.inputStream.bufferedReader()
while (true) {
val linea = lector.readLine() ?: break // bloquea hasta que llega una línea
runOnUiThread {
log.add("<< $linea")
actualizarVista()
}
}
} catch (e: IOException) {
runOnUiThread { estado = "Desconectado"; actualizarVista() }
}
}.start()
}Enviar es lo contrario: escribir en outputStream. Añadimos el salto de línea porque el firmware lee líneas:
private fun enviar(texto: String) {
val s = socket ?: return
Thread { s.outputStream.write("$texto\n".toByteArray()) }.start()
log.add(">> $texto")
actualizarVista()
}Una sola función que pinta
Todos los eventos acaban en el mismo sitio:
private fun actualizarVista() {
tvEstado.text = estado
tvLog.text = log.joinToString("\n")
}| Acción | Hilo | Mecanismo |
|---|---|---|
connect() |
Secundario | Thread { } |
| Leer líneas | Secundario (lector) | inputStream + readLine() |
| Tocar vistas | Principal | runOnUiThread { } |
| Enviar | Escribe en outputStream |
"texto\n" |
Thread.sleep()runOnUiThread { }socket.connect()setContentView()
runOnUiThread encola el bloque en el hilo principal, el único que puede tocar las vistas.
hola sin salto de línea y el ESP32 nunca responde con el eco. ¿Por qué?- Porque el firmware lee por líneas y espera el
\npara procesar el mensaje - Porque
outputStreamsolo admite números - Porque falta el permiso
BLUETOOTH_SCAN - Porque el eco solo se manda cada 5 segundos
El firmware acumula caracteres hasta recibir un fin de línea; sin \n el mensaje nunca se da por completo.
Teoría 5. Eventos que no vienen del dedo
Esto es lo que de verdad enseña la práctica: eventos que llegan por un socket, en otro hilo.
Evento de usuario
onClick, onCheckedChanged. Llegan ya en el hilo principal, igual que en el contador de pulsaciones.
Evento externo
Bytes que llegan cuando quieren, en otro hilo. Hay que traerlos al hilo de la interfaz con runOnUiThread para repintar.
Misma regla
Un solo estado y una función que vuelca el estado en las vistas: actualizarVista(). Los dos tipos de evento la llaman.
Enlace con la Unidad 2: Observer con un hilo en medio
Es el Observer de la Clase 3 con una diferencia: el sujeto (el hilo lector) no vive en el mismo hilo que el observador (la Activity). runOnUiThread hace de puente: no llama al observador directamente, sino que encola el aviso en el hilo principal, que lo procesará en su bucle de eventos cuando le toque.
tick desde que llega por el socket hasta que lo ves.inputStream del socket → El hilo lector completa la línea con readLine() → runOnUiThread encola el aviso en el hilo principal → La Activity añade la línea al estado → actualizarVista() vuelca el estado en las vistasSocket → hilo lector (productor) → runOnUiThread (puente) → Activity (suscriptor) actualiza el estado → actualizarVista().
onClick no necesita runOnUiThread y una línea del socket sí?- Porque
onClickes más rápido - Porque el
onClickllega ya en el hilo principal; la línea del socket llega en el hilo lector - Porque los eventos de Bluetooth tienen más prioridad
- Porque
runOnUiThreadsolo funciona con datos de red
Los eventos de usuario se entregan en el hilo principal. Los del socket aparecen en otro hilo y hay que trasladarlos para tocar las vistas.
- Hilo lector → productor (sujeto)
- Activity → suscriptor (observador)
runOnUiThread→ suscriptorrunOnUiThread→ puente al hilo principalactualizarVista()→ productor
Productor = hilo lector, suscriptor = Activity, puente = runOnUiThread. actualizarVista() es la función que vuelca el estado.
Práctica 6. Si trabajas en Windows: WSL2
esp-emu solo tiene binarios para Linux y macOS. En Windows, el chip emulado corre en WSL2 (Ubuntu). El resto se queda en Windows:
| Pieza | Dónde corre | Nota |
|---|---|---|
| VS Code + PlatformIO (compilar) | Windows | Igual que en el lab anterior |
| esp-emu (ejecutar el firmware) | WSL2 (Ubuntu) | Instalador oficial dentro de WSL; el .bin se lee desde /mnt/c/… |
| Bumble (HC-05 virtual) | Windows (Python) | Llega a esp-emu por localhost: WSL2 reenvía los puertos |
| Android Studio + emulador | Windows | netsim arranca con el emulador, sin cambios |
build.sh y run.sh |
Git Bash o WSL | Son scripts bash; en PowerShell no funcionan tal cual |
/mnt/c/ es cómo ve WSL2 la unidad C:\ de Windows: así esp-emu, dentro de Linux, puede leer el merged_flash.bin que compilaste en Windows.
- En PowerShell, como un .exe
- Dentro del emulador Android
- En WSL2 (Ubuntu), porque solo hay binarios para Linux y macOS
- En Git Bash, que emula Linux
esp-emu no tiene binario para Windows: se instala en WSL2 y lee el .bin desde /mnt/c/….
./run.sh spp desde PowerShell y no funciona. ¿Qué haces?- Reescribirlo en PowerShell
- Ejecutarlo como administrador
- Instalar esp-emu para Windows
- Lanzarlo desde Git Bash o WSL, porque es un script bash
build.sh y run.sh son scripts bash: funcionan en Git Bash o en WSL, no en PowerShell tal cual.
Ejercicio 7. Ejercicio: cuando no conecta, tres casos
Tres síntomas típicos y sus tres causas. Lee las causas y resuelve los casos de abajo emparejando síntoma y causa.
A · No hay nadie al otro lado
esp-emu o el puente no están corriendo. El dispositivo sigue emparejado y por eso aparece en el Spinner. Arreglo: lanza ./run.sh spp y reintenta.
B · Enlace zombi
Reiniciaste el puente y Android cree que sigue el enlace anterior. Arreglo: reinicia el Bluetooth del emulador sin perder el emparejamiento.
C · Orden o dirección equivocados
En modo SPP esp-emu va antes que el puente, y siempre 127.0.0.1, no localhost (que resuelve a IPv6).
Para el enlace zombi:
adb shell cmd bluetooth_manager disable
adb shell cmd bluetooth_manager enable¿Por qué el caso A es tan engañoso?
Porque el Spinner se rellena con bondedDevices, que es memoria de Android, no estado real. Ver «ESP32-EMU-SPP» en la lista da sensación de que todo está en marcha, pero puede que no haya nada escuchando.
Error de conexión: read failed, socket might closed or timeout. ¿Causa más probable?- A · No hay nadie al otro lado: esp-emu o el puente no están corriendo
- B · Enlace zombi
- C · Orden o dirección equivocados
- Falta el permiso
BLUETOOTH_SCAN
El dispositivo sale en el Spinner porque está emparejado, pero no hay nadie escuchando. Solución: ./run.sh spp y reintentar.
- Desemparejar y volver a emparejar el dispositivo
- Reiniciar el Bluetooth del emulador con
adb shell cmd bluetooth_manager disable/enable - Cambiar
127.0.0.1porlocalhost - Añadir
cancelDiscovery()antes deconnect()
Es el enlace zombi: Android cree que sigue el enlace anterior. Se reinicia el Bluetooth del emulador sin perder el emparejamiento. cancelDiscovery() haría cascar la app.
Connection refused al arrancar esp-emu o el puente. ¿Causa?- A · No hay nadie al otro lado
- B · Enlace zombi
- Falta el permiso
BLUETOOTH_CONNECT - C · Orden o dirección equivocados: el puente arrancó antes que esp-emu, o se usó
localhost
El puente se conecta a esp-emu; si esp-emu no escucha aún, o la dirección resuelve a IPv6, la conexión se rechaza. Los permisos de Android no intervienen al arrancar los scripts.
Práctica 8. Cómo saber que has llegado
Cuatro comprobaciones. Las dos primeras demuestran que la cadena funciona; las dos últimas cierran el bucle, porque demuestran que controlas los dos extremos.
- La consola de esp-emu muestra
[APP] firmware SPP listoy, al enviar desde la app,[UART1] recibido: …. - La app recibe los
tick N ssin tocar nada y uneco #Npor cada mensaje enviado. - Has cambiado el firmware (el texto del tick, el periodo) y se ve en la app tras recompilar.
- Has cambiado la Activity (un botón nuevo, un comando) y el firmware reacciona.
Prueba sin app: verify_spp.py
Si la app no conecta y no sabes si el problema está en tu Activity o en la cadena, usa el script del ZIP:
# desde demo-esp32, con el venv de Bumble activado
python tools/verify_spp.pyRecorre el mismo camino que la app (TCP → RFCOMM → puente → UART1) y debe terminar en «OK».
| verify_spp.py | La app | Dónde está el problema |
|---|---|---|
| OK | Conecta | En ningún sitio |
| OK | No conecta | En tu Activity (permisos, UUID, hilos…) |
| Falla | No conecta | En la cadena (esp-emu, puente, orden, dirección) |
tools/verify_spp.py termina en «OK», pero tu app no conecta. ¿Dónde buscas?- En esp-emu: hay que reinstalarlo
- En el puente Bumble: hay que reiniciarlo
- En tu Activity: permisos, UUID, hilos…
- En el firmware: hay que recompilarlo
Si el script llega hasta la UART1, la cadena está bien. Lo que falla es lo único que el script no usa: tu Activity.
- Cambiar el firmware y ver el cambio en la app
- Ver
[APP] firmware SPP listoen la consola - Cambiar la Activity y ver que el firmware reacciona
- Ver los ticks en la app
Las dos últimas comprobaciones exigen modificar cada extremo y ver el efecto al otro lado.
Repaso 9. Repaso: la Activity y el diagnóstico
| Síntoma | Causa | Arreglo |
|---|---|---|
read failed, socket might closed or timeout |
Nadie al otro lado | ./run.sh spp y reintentar |
| «Conectando…» y nada | Enlace zombi | adb shell cmd bluetooth_manager disable / enable |
Connection refused al arrancar |
Orden o dirección | esp-emu antes que el puente; 127.0.0.1 |
Windows: esp-emu en WSL2; el resto en Windows; scripts en Git Bash o WSL. Ruta aún sin validar.
socket.connect()lector.readLine()en bucletvLog.text = …spinner.adapter = …
connect() y readLine() bloquean: van en hilos secundarios. Tocar vistas (tvLog, spinner) debe hacerse en el hilo principal.
adbadb shell cmd bluetooth_manager disable y luego enable: reinicia el Bluetooth sin perder el emparejamiento.
runOnUiThread + Activity?- Singleton
- Observer, con un hilo en medio
- Factory Method
- MVC sin controlador
El hilo lector produce eventos, la Activity los observa y runOnUiThread hace de puente entre hilos.