Skip to Content
Desarrollo de apps IoTPlano de la aplicación

Plano de la aplicación

Esta página muestra cómo construir una app de IronFlock para que use la plataforma tal y como está pensada. La idea, en una frase: tus contenedores se ejecutan en cada dispositivo y guardan todo el estado de la app en las tablas de la app, el panel es la interfaz de usuario de la app, y contenedores y panel nunca se comunican directamente entre sí. Una interfaz web que sirve el propio dispositivo llega a los usuarios a través de un túnel de acceso remoto y aparece dentro del panel.

Cada pieza se describe en detalle en su propia página. Esta página muestra cómo encajan entre sí.

Las partes de una app

Una app se ejecuta en muchos dispositivos a la vez, normalmente PCs industriales junto a las máquinas. Se compone de:

ParteQué esSe define en
Código edgeUn contenedor, o un stack de Docker Compose, en cada dispositivo donde está instalada la appDockerfile o docker-compose.yml
SDKEl único vínculo entre tu código y la plataforma: tablas, actualizaciones en tiempo real, funciones de dispositivo, archivosIronFlock SDK (Python, JavaScript, Go)
TablasEl estado de la app, en la base de datos propia del proyectodata-template.yml
PanelLa interfaz de usuario central de la app, enlazada en vivo a las tablas, para todos los dispositivosboard-template.yml
ParámetrosAjustes de un dispositivo o de un grupo de dispositivosenv-template.yml
Acceso remotoTúneles hacia los puertos que sirven tus contenedores, como una interfaz webport-template.yml
Agentes de IAAgentes para el asistente de IronFlock, con tus funciones de dispositivo como herramientasai-template.yml

Cuando un usuario instala tu app en un proyecto, IronFlock crea las tablas de la app en la base de datos de ese proyecto, instala su panel y abre un realm de mensajería privado que conecta en tiempo real los contenedores de la app en todos los dispositivos, sus tablas y su panel. El SDK se une a ese realm con la identidad que inyecta el agente del dispositivo, así que tu código no necesita ninguna dirección ni credenciales.

Las reglas

  1. Las tablas son el estado de la app. Todo lo que necesitan el panel, otro dispositivo, un contenedor reiniciado o un agente de IA se guarda en una tabla. Un contenedor solo conserva lo que puede reconstruir a partir de las tablas, como una copia en memoria de sus ajustes.
  2. Las partes se comunican a través de las tablas, no entre sí. Un dispositivo escribe lo que mide y el estado en que se encuentra, y el panel lo muestra. El panel escribe lo que deciden los usuarios, y los dispositivos lo aplican. Ninguno llama al otro, así que el panel funciona mientras los dispositivos están desconectados, y los dispositivos siguen funcionando mientras nadie tiene abierto el panel.
  3. Un solo escritor por tabla. Cada tabla la escriben los dispositivos (cada uno sobre sí mismo), el panel o un único servicio. Indica cuál en la description de la tabla. Dos escritores de las mismas filas se sobrescriben mutuamente los cambios.
  4. Estado deseado, no comandos. Para cambiar lo que hacen los dispositivos, escribe el valor objetivo en una tabla (un límite, una consigna, una receta, un horario) y deja que cada dispositivo aplique la fila más reciente. Un dispositivo que estaba desconectado lo aplica cuando vuelve, y aplicarlo dos veces no cambia nada.
  5. Escribe al cambiar, sigue en vivo, nunca sondees. Escribe la fila de una entidad cuando cambie uno de sus valores, y las mediciones a su frecuencia de muestreo. Todo lo que lee sigue las tablas en tiempo real: los enlaces de datos del panel, las suscripciones a tablas en los dispositivos, los websockets de la interfaz web de un dispositivo. Un temporizador que vuelve a leer una tabla es siempre un diseño equivocado.
  6. El panel es la interfaz de usuario; la interfaz web de un dispositivo la complementa. Sirve una página web desde un dispositivo solo para lo que un panel no puede mostrar: la HMI propia de una máquina, video, una vista en vivo de alta frecuencia, una pantalla de operador junto a la máquina. El panel la incrusta.

Dónde va cada tipo de dato

DatoDóndeEscrito porLeído por
Lecturas a lo largo del tiempoUna tabla con tsppublishToTable, publishRowsToTableGráficos con ventanas de tiempo y agregación
El estado actual de un dispositivo, una máquina o una ordenUna tabla de entidades (maintainLatestFlagFor)appendToTable: una fila con la clave, tsp y las columnas modificadasEl filtro latest
Límites, consignas y recetas que fijan los usuariosUna tabla que escribe el formulario del panelEl formularioCada dispositivo, como se muestra más abajo
Registros que los usuarios crean, editan y eliminanUna tabla de entidades con una columna deletedEl formulario, que activa el marcador al eliminarListas y páginas de edición en el panel, con el filtro latest y un filtro sobre deleted, en vivo; ver más abajo
Errores y avisos para los operadoresLa tabla error-logsreportError con un userMessageEl widget de toast del panel
Imágenes, documentos, grabacionesEl almacenamiento de archivos de la appfiles.putLa URL del archivo en una columna, mostrada por un widget
Contraseñas y claves de APIUna columna secret: trueCualquier escritura en la tablarevealSecrets, verifySecret, solo en los propios contenedores de la app
La instalación concreta de un dispositivo: la dirección de una máquina, un puerto serieParámetros de la aplicaciónLa configuración de la app del dispositivo o del grupoVariables de entorno
Mensajes entre tus partes que nadie necesita despuésTus propios topicspublishsubscribe
Una petición que necesita al momento la respuesta de un dispositivoUna función de dispositivoregisterDeviceFunctioncallDeviceFunction, acciones de widgets, herramientas de IA

¿Añadir o publicar? Desde el SDK 1.10.0 son lo mismo: publishToTable añade, así que ambos esperan a un backend de datos que se está reiniciando y te avisan cuando este rechaza una fila. Para las lecturas de alta frecuencia, envía lotes con publishRowsToTable.

¿Parámetros o una tabla de ajustes? Los parámetros pertenecen a un dispositivo o a un grupo de dispositivos: quien instala o configura el dispositivo los establece en la configuración de la app, y tu código los lee de su entorno. Úsalos para lo que cambia de una instalación a otra. Lo que los usuarios modifican mientras la app se ejecuta — límites, recetas, horarios, cualquier cosa que se aplique a muchos dispositivos — va en una tabla que escribe el panel. El panel es donde los usuarios trabajan con la app, todos los dispositivos siguen un cambio al instante, y la tabla conserva el historial de quién cambió qué y cuándo.

Tablas de entidades: estado actual, actualizaciones y eliminaciones

Una tabla de entidades (maintainLatestFlagFor) es el historial de sus entidades: cada cambio es una fila nueva con la clave, un tsp y las columnas modificadas. Nada edita ni elimina una fila, y nada tiene que hacerlo, porque cada lector elige el estado actual en el momento de leer:

  • El estado actual es un modo de lectura, no una vista. El filtro latest devuelve la fila más reciente de cada entidad, elegida por tsp cuando se ejecuta la lectura: el interruptor latest en los ajustes de filtro de un widget, {"latest": true} en getHistory. La tabla conserva todas las versiones; el filtro muestra una por entidad.
  • Es en vivo. Un widget enlazado a la tabla con el filtro latest recibe cada fila nueva a medida que llega y sustituye con ella la fila de la entidad. Un cambio se ve en menos de un segundo, sin nada que refrescar ni programar.
  • Una eliminación es una columna. Dale a la tabla una columna booleana deleted. El formulario del panel la escribe mediante su ajuste Delete-Flag Column: true al eliminar, false en cada guardado, así que guardar un registro eliminado lo restaura. Los widgets que muestran los registros filtran por la columna. El filtro latest elige primero la fila más reciente de cada entidad y aplica después las demás condiciones, así que una entidad cuya fila más reciente está eliminada desaparece del widget en cuanto llega esa fila, y una fila anterior no eliminada nunca ocupa su lugar.
  • Nunca construyas una transformación para esto. Una transformación que elige la fila más reciente de cada entidad u oculta las filas eliminadas reproduce lo que hacen los dos filtros, pero llega a un panel solo según su planificación, así que cada cambio se ve con retraso. Las transformaciones son para lo que una lectura no puede expresar: joins, aritmética, agregaciones sobre filas actuales. Cuando una necesita el estado actual de las entidades, selecciónalo en su SQL con DISTINCT ON (<key>) ... ORDER BY <key>, tsp DESC y filtra deleted allí.
  • Tu código lo lee de la misma manera. getHistory con el filtro latest y una condición sobre deleted da las entidades actuales no eliminadas, como en Crea tu primera app, y una suscripción a la tabla entrega cada fila nueva, que tu código aplica a su copia de la entidad, o descarta la entidad cuando la fila dice deleted.

Los detalles están en Seguimiento del último estado de una entidad y Eliminación lógica de registros.

Seguir lo que escribe el panel

Un dispositivo aplica el estado que escribe el panel, como un límite fijado en el panel, en tres pasos:

  1. Suscríbete a la tabla antes de leerla, para que no se pierda ninguna fila entre la lectura y la suscripción.
  2. Lee la fila más reciente una vez al arrancar, con el filtro latest, y aplícala.
  3. Aplica una fila solo si es más reciente (tsp) que la que está en vigor. Una fila guardada justo cuando arranca tu programa puede llegar tanto por la lectura como por la suscripción.
await ironflock.subscribe_to_table("settings", lambda row, *args, **kwargs: apply(row)) rows = await ironflock.getHistory("settings", {"limit": 1, "filterAnd": [{"latest": True}]}) for row in rows or []: apply(row)

Eso es todo, también para los dispositivos que se quedan sin conexión. El SDK se ocupa de la conexión para que tu código no tenga que hacerlo: se reconecta, restaura tus suscripciones y funciones de dispositivo y, a continuación, entrega a cada suscripción de tabla las filas almacenadas mientras el dispositivo estaba desconectado, de la más antigua a la más reciente, antes que las filas que llegan en vivo. Un límite guardado mientras un dispositivo estaba desconectado le llega en cuanto vuelve. La puesta al día se rige por tsp: entrega las filas posteriores a la más reciente que haya recibido tu handler, como máximo las 10.000 más recientes. La lectura que hace espera al backend de datos como cualquier lectura de historial, como máximo durante la ventana de reconexión; si falla, esas filas no se entregan y se registra una advertencia. La puesta al día forma parte del SDK desde la versión 1.10.0.

Comandos, funciones de dispositivo y topics

Prefiere el estado. La mayoría de las cosas que los usuarios “ordenan” son estado deseado: una consigna, encendido o apagado, una receta. Escríbelas en una tabla, como se ha descrito arriba.

Los comandos que deben ejecutarse una sola vez, como imprimir una etiqueta o iniciar un ciclo de limpieza, son filas con un id en una tabla que escribe el panel. El dispositivo registra en una tabla propia los ids que ya ha ejecutado, de modo que un comando que recibe dos veces se ejecuta una sola vez.

Las funciones de dispositivo son procedimientos que un dispositivo registra con registerDeviceFunction. Úsalas cuando quien llama necesita la respuesta del dispositivo en ese mismo momento:

  • Agentes de IA: una herramienta de ai-template.yml llama a la función en el dispositivo que elige el asistente. Consulta Agentes y Herramientas de IA.
  • Los widgets Interruptor y SCADA del panel las llaman mientras el dispositivo está conectado.
  • Tus propios contenedores y dispositivos las llaman entre sí con callDeviceFunction.

Devuelve desde una función de dispositivo texto legible o un JSON pequeño, y devuelve un fallo previsto (una máquina apagada) como resultado en lugar de lanzar un error.

Los topics transportan mensajes entre tus propias partes que nadie necesita después: publish y subscribe sobre nombres de tu propio espacio de nombres, como myapp.events. El panel no puede suscribirse a topics: todo lo que muestra tiene que estar en una tabla. Los mensajes publicados mientras un dispositivo está desconectado no se almacenan, así que usa una tabla para todo lo que tenga que llegar.

El contenedor en el dispositivo

Lo que el agente del dispositivo le da a tu contenedor y lo que espera de él:

  • Identidad: el agente establece DEVICE_KEY, DEVICE_NAME, DEVICE_SERIAL_NUMBER, SWARM_KEY, APP_KEY, APP_NAME, ENV (DEV o PROD), TUNNEL_DOMAIN, DEVICE_LAN_IP y DEVICE_PORT_FOR_<port>, además de la credencial propia de la app para el SDK. El SDK lee lo que necesita; nunca fijes ninguno de estos valores en el código.
  • Parámetros: una instalación de producción recibe los valores que establecieron los usuarios y, para el resto, el defaultValue de cada parámetro. Una ejecución de desarrollo recibe solo los valores por defecto. Da a los parámetros nombres específicos (PLC_HOST, no HOST).
  • Almacenamiento: una app de una sola imagen recibe /data, privado para la app, que se conserva entre reinicios y actualizaciones (y se elimina junto con la app), y /shared, compartido por todas las apps del dispositivo. Aun así, guarda el estado de la app en tablas y usa estas carpetas para archivos que pertenecen al dispositivo, como un modelo descargado o una caché.
  • Hardware: un contenedor de una sola imagen se ejecuta con los permisos predeterminados de Docker: sin /dev ni capacidades adicionales. Si tu app necesita hardware, declara el acceso privilegiado; cuando el usuario lo concede, el contenedor se ejecuta en modo privilegiado, con el /dev del dispositivo.
  • Logs: todo lo que tu programa escribe en stdout y stderr es el log de la app en IronFlock. Escríbelo sin búfer (python -u).
  • Arranque: el contenedor debe estar en ejecución 30 segundos después de arrancar; si no, el arranque se considera fallido.
  • Caídas: cuando una app de producción termina, el agente la vuelve a iniciar y espera más tiempo tras cada fallo. Una ejecución de desarrollo no se reinicia. Gestiona en tu programa los fallos previstos, como un sensor que no responde, y deja que termine ante los errores de los que no puede recuperarse.
  • Parada: tu programa es el proceso principal del contenedor (PID 1), que ignora la señal de parada SIGTERM a menos que la gestione. Haz que termine al recibir SIGTERM; de lo contrario, al detener la app hay que esperar a que Docker lo mate. Inicia el programa en forma exec (CMD ["python", "-u", "main.py"]), no a través de un shell.
  • Ejecuciones de desarrollo: ejecutar la app desde el IDE en la nube es una ejecución de desarrollo (ENV es DEV). Escribe en las tablas de desarrollo de la app y recibe los valores por defecto de los parámetros. Sus puertos http y https obtienen túneles que solo pueden abrir, desde el menú lateral del IDE, los usuarios con el privilegio Desarrollar en el dispositivo. Sus puertos tcp y udp también obtienen túneles, abiertos como en producción a cualquiera que tenga la dirección, y el SDK devuelve sus direcciones en cuanto están activos.

Docker Compose

Usa un docker-compose.yml cuando tu app ejecuta varios procesos o incluye servicios de terceros, como un broker MQTT o una base de datos, junto a tu propio código. Consulta Docker y Compose para lo básico. En comparación con una sola imagen:

  • Cada servicio recibe el entorno inyectado. El agente da a cada servicio su propio archivo de entorno con las variables anteriores; un env_file: que escribas tú se reemplaza. Un ${VAR} en el archivo se resuelve con los valores inyectados.
  • Sin /data. Los servicios no reciben ninguna carpeta de la app. Guarda los archivos en volúmenes con nombre: sobreviven a paradas y actualizaciones, y se eliminan cuando la app se quita del dispositivo.
  • Los puertos publicados se reasignan. Cada puerto publicado recibe un puerto libre del host en todas las interfaces del dispositivo, y DEVICE_PORT_FOR_<port> indica a tu código cuál es el real. En port-template.yml, usa el puerto del host tal como está escrito en el archivo compose. Publica solo lo que deban alcanzar los usuarios, y deja que tus servicios se comuniquen entre sí por la red de Compose mediante el nombre del servicio. Un puerto del host escrito como ${VAR} no se gestiona: no se reasigna ni recibe túnel.
  • Los ajustes privilegiados necesitan el permiso del usuario. El agente no añade privilegios ni dispositivos; un servicio recibe lo que declara el archivo. Ajustes como privileged, cap_add, devices, network_mode: host o un bind mount de una ruta del host necesitan una declaración de acceso privilegiado en data-template.yml, y sin el permiso del usuario el agente los elimina antes de arrancar la app. Solicita solo lo que la app necesita: devices: [/dev/ttyUSB0] en lugar de privileged: true. Una entrada devices: nombra hardware que tiene que existir; si no, el contenedor no arranca. device_cgroup_rules junto con un volumen /dev funciona en cualquier dispositivo.
  • Sin profiles:. Un servicio con perfiles queda fuera del release.
  • La misma regla de arranque. En un plazo de 30 segundos, todos los contenedores deben estar en ejecución o haber terminado con el código 0, y al menos uno debe estar en ejecución. Da a los servicios de larga duración restart: unless-stopped.

Interfaces web en el dispositivo

Un servidor web en tu contenedor pasa a ser accesible desde cualquier lugar a través del túnel de acceso remoto de su puerto, y el panel lo muestra:

  1. Sirve en un puerto fijo (escuchando en 0.0.0.0) y decláralo en port-template.yml con protocol: http. Una instalación de producción activa el túnel y lo sirve por HTTPS, websockets incluidos.
  2. Escribe la URL del túnel en una tabla. La función getRemoteAccessUrlForPort(port) del SDK devuelve la URL. Escríbela al arrancar en la fila del dispositivo de una tabla de dispositivos. Una ejecución de desarrollo obtiene la URL de su propio túnel, cuya etiqueta termina en -dev, y la escribe en las tablas de desarrollo de la app; solo los usuarios con el privilegio Desarrollar en el dispositivo pueden abrirla.
  3. Incrústala en el panel. Enlaza la URL del widget Embed a esa columna, en una página por dispositivo que filtre por el dispositivo. En el UI Builder, esto es un enlace de datos como cualquier otro.

Para la propia página:

  • Quién puede abrirla: un túnel http es privado. Solo pueden abrirlo las personas que han iniciado sesión en IronFlock y tienen acceso al dispositivo, salvo que un usuario cambie el puerto a público. En la red propia del dispositivo, la página está abierta a todo el mundo, así que no muestres ahí nada que no deba ver la red de la planta, y exige un inicio de sesión propio para todo lo que cambie el estado de la máquina.
  • URLs relativas: carga los recursos, llama a tu API y abre los websockets con rutas relativas a la página, para que funcione en la red del dispositivo, a través del túnel y detrás de un proxy inverso.
  • Actualizaciones por push, nunca por sondeo: envíalas por un websocket.
  • Sin credenciales de la plataforma en el navegador: la página habla con su propio servidor en el contenedor, y ese servidor mantiene la conexión del SDK.

Para mostrar la HMI web de una máquina de la red de la planta en lugar de una página propia, sírvela a través de un proxy inverso en tu contenedor. Consulta Integrar HMIs externas.

Varios dispositivos

  • Cada fila lleva su dispositivo: IronFlock guarda en device_key la clave del dispositivo que escribió la fila. Usa device_key como clave de las tablas por dispositivo, guarda el nombre del dispositivo en una columna con path: kwargs.DEVICE_NAME y desglosa los gráficos por ese nombre.
  • Una lista de dispositivos y una página por dispositivo: una navegación lateral generada a partir de la tabla de dispositivos, con una sola página que sirve para todos los dispositivos y filtra sus widgets por el dispositivo de la ruta. Consulta Crear paneles.
  • Ajustes por dispositivo o por máquina: usa como clave de la tabla de ajustes una columna que identifique el destino (las filas que escribe el panel no llevan un dispositivo propio) y rellénala en el formulario de la página del destino. Cada dispositivo aplica la fila más reciente de su propia clave.
  • Un dispositivo que se retira conserva sus filas. Si los usuarios deben poder quitarlo de las listas, añade a la tabla de dispositivos una columna deleted y al panel un formulario que la active.

Lista de verificación

  1. Todo el estado está en tablas, cada una con un único escritor indicado en su descripción, y cada columna que escribe el código está declarada en data-template.yml.
  2. Cada fila tiene un tsp, y las filas de estado se escriben al cambiar, solo con la clave y las columnas modificadas. Las tablas de entidades se leen con el filtro latest y un filtro sobre deleted, nunca a través de una transformación que las deduplique o las limpie.
  3. El estado que escribe el panel se sigue con los tres pasos de arriba, con el SDK 1.10.0 o posterior, y el código nunca se ocupa de la conexión ni de sus sesiones.
  4. Las escrituras en tablas dentro de bucles de larga duración gestionan el error que lanza una escritura cuando la plataforma lleva un minuto inaccesible, y los fallos previstos se notifican con reportError y un userMessage.
  5. El programa termina con SIGTERM, arranca en forma exec y escribe los logs sin búfer.
  6. Nada sondea: el panel se enlaza a las tablas, los dispositivos se suscriben y las interfaces web envían por push.
  7. Cada puerto que sirve tu app está declarado en port-template.yml, su URL se obtiene con getRemoteAccessUrlForPort y se escribe en una tabla, y el panel la incrusta desde allí.
  8. Una app de Compose no tiene env_file:, ni profiles:, ni puertos del host ${VAR}; guarda los archivos en volúmenes con nombre y publica solo los puertos que necesitan los usuarios.

Empezar con la app de partida

El plugin de IronFlock para Claude Code contiene una app de partida que sigue este plano: un programa que lee una máquina en cada dispositivo, guarda el estado y las lecturas de cada dispositivo en tablas, aplica el límite que los usuarios fijan en el panel y sirve una vista en vivo que el panel incrusta en la página de cada dispositivo. Claude Code crea una app nueva a partir de ella y la adapta a tu caso de uso.

Last updated on