- Diseño y desarrollo de un entorno de fotogrametría con drones asistido por IA
- Aplicación Android para fotogrametría con IA. Verificación del entorno y Fase 1 de desarrollo inicial
- Aplicación Android para fotogrametría con IA. Fase 2: Primera versión capaz de volar
- Aplicación Android para fotogrametría con IA. Fase 3: Corrección de heading, visualización en vuelo y ajustes de UX
- Aplicación Android para fotogrametría con IA. La plataforma de procesado: OpenDroneMap desplegado con Claude Code
- Aplicación Android para fotogrametría con IA. Fase 4: Integración cloud, exportación KMZ y cierre del ciclo completo
- Fotogrametría con IA: Detección de hallazgos en tiempo real durante el vuelo. Módulo de auditoría IA con YOLOv8 y RTMP sobre el stack ODM
- Aplicación Android para fotogrametría con IA. Fase 5: Streaming RTMP y telemetría en tiempo real — cómo se coordina un sistema entre dos agentes de IA
En el artículo anterior de esta serie describí el módulo de auditoría IA desplegado en el servidor: un sistema capaz de ingestar vídeo RTMP, analizarlo con YOLOv8 y georreferenciar los hallazgos sobre el ortomosaico. El módulo estaba listo y esperando. Lo que faltaba era que la app Android lo alimentara: emitir el stream de vídeo del dron hacia el servidor durante el vuelo y enviar la telemetría GPS con la precisión temporal suficiente para que la georreferenciación funcionara. Eso es la Fase 5, y lo que la hace especialmente interesante como proceso de desarrollo es cómo se coordinaron los dos agentes de Claude Code —el del lado del servidor y el del lado de la app— para que todo encajara sin fricciones.

El problema de integrar dos sistemas desarrollados por separado
Cuando dos sistemas independientes necesitan comunicarse, el momento más crítico no es la implementación sino el acuerdo previo: qué datos se intercambian, en qué formato, quién genera cada identificador, cómo se manejan los errores. Si ese acuerdo no es explícito y preciso, la integración acaba en una serie de ajustes iterativos donde cada parte asume cosas distintas sobre la otra.
En este caso, «las dos partes» eran el agente de Claude Code trabajando en la app Android y el agente trabajando en el servidor. La solución fue establecer un contrato de API formal antes de escribir ningún código en ninguno de los dos lados. Ese contrato definía con precisión tres aspectos que son los que más fricción generan en una integración:
Quién genera el identificador de vuelo. La decisión fue que la app genera el flight_id como UUID v4 en el momento de iniciar la misión, y el servidor lo recibe y registra. Podría haber sido al revés —el servidor genera el ID y la app lo pide— pero eso crea una dependencia de red en el camino crítico del despegue: si la petición falla o tarda, el dron está esperando con los motores en marcha. Con el UUID generado localmente, el ID está disponible antes de abrir el stream RTMP y antes de enviar el primer paquete de telemetría, sin depender de ninguna respuesta de red.
Cómo se asocia el vídeo con el vuelo. MediaMTX no tiene un mecanismo nativo para etiquetar streams con metadatos de aplicación. Aquí hay dos opciones: que el servidor busque el stream activo cuando llega el registro de vuelo (por ventana temporal), o que el nombre del stream incluya el identificador. Se eligió la segunda: la URL RTMP lleva el flight_id en el path — rtmp://host:1935/live_<uuid>. El campo de ajustes de la app almacena solo la base (rtmp://192.168.0.100:1935/live) y el código añade _<flight_id> al conectar. El servidor asocia vídeo con vuelo por el path del stream, de forma determinista y sin ambigüedad.
Qué pasa cuando hay problemas de red. Una misión de fotogrametría puede transcurrir en campo abierto con cobertura irregular. El contrato definió que el flight_id se persiste en Room antes de cualquier llamada de red, que la telemetría se acumula localmente si no hay conexión y se sincroniza al recuperarla, y que si el servidor no está disponible al arrancar, la misión continúa exactamente igual que antes de que existiera el módulo de auditoría. Degradación elegante: sin URLs configuradas, los componentes de auditoría hacen skip silencioso y no afectan en absoluto al flujo de fotogrametría.
Con ese contrato acordado, ambos agentes pudieron implementar sus respectivas partes de forma independiente, sabiendo exactamente qué comportamiento esperaba el otro extremo.
La idempotencia como mecanismo de robustez
Uno de los detalles del contrato que más impacto tuvo en la robustez del sistema fue la definición del comportamiento ante reintentos. En una red móvil con cobertura irregular, cualquier petición puede perderse o recibirse duplicada. El contrato especificó que si la app envía el mismo flight_id dos veces al endpoint de registro de vuelo, el servidor responde HTTP 409 Conflict. Y que la app trata un 409 exactamente igual que un 201: el vuelo está registrado, continuar.
Esta convención de idempotencia se aplica también a la telemetría: los puntos duplicados son ignorados por el servidor sin error. La app puede reintentar con confianza sin preocuparse por estados inconsistentes. El WorkManager que gestiona el registro de vuelo usa backoff exponencial con un máximo de cinco intentos; si en alguno de ellos el servidor ya registró el vuelo (por un intento anterior que llegó tarde), el 409 detiene los reintentos sin marcar el worker como fallido.
La implementación en la app: diez ficheros nuevos
La implementación en Android añadió diez ficheros nuevos y modificó ocho existentes. Los componentes principales:
LiveStreamHelper: singleton que gestiona el streaming RTMP via elLiveStreamManagerdel DJI MSDK v5, a 720p y bitrate automático, con tres reintentos en caso de fallo de conexión.TelemetrySender: singleton que muestrea la posición GPS cada segundo, la envía al servidor via REST, y la acumula en Room si no hay red, con flush automático cada 5 segundos cuando la conexión se recupera.AuditRepository: repositorio con instancia de Retrofit construida de forma lazy y cacheada por URL, lo que evita el problema de la base URL obsoleta si el usuario cambia la configuración en ajustes sin reiniciar la app.FlightRegistrationWorker: worker de WorkManager con Hilt para reintentar el registro del vuelo en background si la petición inicial falla.
Un reto técnico interesante surgió al integrar el LiveStreamManager del SDK. Las APIs reales de la clase no coincidían con la documentación: el campo de bitrate se llama vbps, no bitrate. La forma de descubrirlo fue ejecutar javap sobre el JAR ofuscado del SDK para inspeccionar los nombres reales de los campos. No es la forma más cómoda de trabajar con una librería, pero es eficaz.
Otro reto fue la inyección de dependencias en singletons Kotlin (object). Hilt gestiona bien las clases normales, pero los object no son instanciados por el contenedor de inyección. La solución fue un patrón init() llamado desde PlannerApp.onCreate(), donde las dependencias inyectadas via Hilt se pasan explícitamente a los singletons al arrancar la aplicación.
El flujo de ejecución al iniciar una misión quedó así:
startMission()
1. flight_id = UUID.randomUUID() ← app genera el ID
2. Room.insert(FlightEntity) ← persiste ANTES de red
3. POST /audit/flights {flight_id, project_name} ← registro en servidor
└─ si falla → WorkManager reintentará en background
4. LiveStream.start("rtmp://host:1935/live_") ← stream RTMP
5. TelemetrySender.start(flight_id) ← telemetría cada ~1s
6. Virtual Stick → despegar → navegar → fotos
7. LiveStream.stop()
8. TelemetrySender.stop()
└─ PATCH /audit/flights/{id}/land ← aterrizaje
9. Room.markLanded(flight_id)
La prueba de integración: primero sin dron
Antes de llevar el sistema a campo, se realizó una prueba de integración completa desde el Mac Mini de desarrollo usando curl. La idea era validar el protocolo sin depender del dron físico: simular exactamente lo que haría la app —registrar un vuelo con un UUID propio, enviar diez puntos de telemetría GPS, marcar el aterrizaje— y verificar que el servidor respondía correctamente en cada paso.
| # | Prueba | Resultado |
|---|---|---|
| 1 | Healthcheck del servidor | 200 OK |
| 2 | Registro de vuelo con UUID propio | 201 Created |
| 3 | Reenvío del mismo UUID (idempotencia) | 409 Conflict — correcto, tratado como éxito |
| 4 | 10 puntos de telemetría GPS | 200 OK (10/10) — trayectoria en PostGIS |
| 5 | Marca de aterrizaje | 200 OK — status cambiado a «landed» |
| 6 | Historial de vuelos | 200 OK — vuelo visible con estado correcto |
| 7 | GeoJSON de hallazgos | 200 OK — FeatureCollection vacía (sin vídeo RTMP, sin detecciones) |
La confirmación más importante vino del servidor: el agente del lado del servidor verificó que los diez puntos de telemetría estaban almacenados correctamente en PostGIS, y que el ai-processor había detectado el nuevo flight_id y empezado a intentar conectar al stream RTMP —confirmando que cuando llegara vídeo real, el pipeline completo funcionaría sin modificaciones adicionales. El GeoJSON vacío era exactamente lo esperado: sin stream RTMP no hay frames, sin frames no hay detecciones. El sistema estaba listo.

El primer vuelo real: 37 waypoints, 234 puntos de telemetría, 7 detecciones
El 11 de abril de 2026, primer vuelo real con el módulo de auditoría IA activo. Una misión de 37 waypoints sobre una parcela en Galicia, a 20 metros de altura, a 3 m/s, con el gimbal en nadir (−90°). Duración: 4 minutos y 12 segundos. Consumo de batería: del 71% al 56%.

Los resultados de la parte fotogramétrica fueron los esperados: los 37 waypoints alcanzados con una precisión de aproximadamente 1,4 metros, 37 fotos disparadas, navegación Virtual Stick estable con rotaciones suaves entre filas, RTH al finalizar. El único problema en este apartado fue que solo 14 de las 37 fotos se descargaron al móvil durante la misión; el resto quedó en la tarjeta SD del dron, probablemente por un timeout de la transferencia.
Los resultados de la parte de auditoría fueron los que realmente importaban en este vuelo:
- El UUID generado por la app (
d2ca8b37-9906-4a70-be05-3dcd8fe6c74f) se usó de forma consistente en todos los subsistemas: stream RTMP, telemetría REST y proyecto WebODM. - El stream RTMP se estableció al despegar y se mantuvo durante todo el vuelo. El servidor grabó 99 MB de vídeo en segmentos de 5 minutos.
- 234 puntos de telemetría recibidos en el servidor, aproximadamente uno por segundo, con cobertura continua durante toda la misión.
- El PATCH de aterrizaje se ejecutó correctamente al completar la misión.
- 7 detecciones YOLO sobre el vídeo: objetos de clase bench con confianza entre 0,60 y 0,74, geolocalizados sobre la trayectoria de vuelo.
- El GeoJSON completo con trayectoria y detecciones estaba disponible en el servidor.
- El visor web Leaflet en
/audit/view/{flight_id}mostraba los marcadores sobre el mapa correctamente.
El pipeline completo funcionó en el primer vuelo real: App → RTMP → MediaMTX → YOLOv8 → PostGIS → API REST → Visor web. Sin ajustes de última hora, sin cambios en el protocolo. El contrato previo había hecho su trabajo.
Un problema de timing: el proyecto ODM llega después del vuelo
Tras el vuelo, el visor de hallazgos mostraba los marcadores sobre OpenStreetMap pero no sobre el ortomosaico. El motivo: el proyecto WebODM no existe en el momento del despegue —se crea cuando el usuario decide procesar las imágenes, que puede ser horas después— así que el vuelo quedaba registrado sin odm_project_id y el visor no podía vincularlo al ortomosaico.
La solución que adopté evitó mover la creación del proyecto ODM al momento del despegue, que habría complicado significativamente el flujo de ejecución: en su lugar se añadió un endpoint de parcheado post-vuelo: cuando OdmUploadWorker crea el proyecto WebODM y obtiene el projectId, inmediatamente llama a PATCH /audit/flights/{flight_id}/odm con ese ID. Si la llamada falla, se registra en el log pero no bloquea el procesamiento. El visor recoge el odm_project_id en la siguiente consulta y puede entonces cargar el ortomosaico correspondiente.
La coordinación entre agentes como metodología
Mirando el proceso en retrospectiva, lo que funcionó en esta fase fue establecer el contrato de integración como un documento compartido entre ambos agentes antes de empezar a codificar. No una especificación vaga de «la app envía telemetría al servidor», sino decisiones concretas y justificadas: quién genera el UUID y por qué, cómo se nombra el stream RTMP y por qué esa opción sobre las alternativas, qué código HTTP indica idempotencia y cómo lo trata cada parte.
La consecuencia práctica es que la prueba de integración fue una verificación, no un descubrimiento. No hubo negociaciones de último momento sobre formatos de datos ni malentendidos sobre quién debía hacer qué. El servidor esperaba exactamente lo que la app enviaba, y la app esperaba exactamente lo que el servidor devolvía. En un sistema con dos componentes desarrollados de forma independiente, eso no es trivial.
En próximos artículos de la serie cubriré las Fases 6 y 7 de la app: los resultados del procesado ODM visibles directamente en el mapa, la gestión de múltiples baterías para misiones largas, la reanudación de misiones interrumpidas, la edición de polígono post-confirmación y la caché offline de tiles. El proyecto está tomando una forma bastante completa.