Blueprint dell’App
Questa pagina mostra come costruire un’app IronFlock in modo che usi la piattaforma così come è stata pensata. L’idea in una frase: i tuoi container girano su ogni dispositivo e mantengono tutto lo stato dell’app nelle sue tabelle, la board è l’interfaccia utente dell’app, e le due parti non comunicano mai direttamente tra loro. Una web UI servita dal dispositivo stesso raggiunge gli utenti attraverso un tunnel di accesso remoto e compare all’interno della board.
I singoli elementi sono descritti in dettaglio nelle rispettive pagine. Questa pagina mostra come si combinano tra loro.
Le Parti di un’App
Un’app gira su molti dispositivi contemporaneamente, di solito PC industriali installati accanto alle macchine. È composta da:
| Parte | Che cos’è | Definita in |
|---|---|---|
| Codice edge | Un container, o uno stack Docker Compose, su ogni dispositivo su cui l’app è installata | Dockerfile o docker-compose.yml |
| SDK | L’unico collegamento tra il tuo codice e la piattaforma: tabelle, aggiornamenti in tempo reale, funzioni del dispositivo, file | IronFlock SDK (Python, JavaScript, Go) |
| Tabelle | Lo stato dell’app, nel database dedicato del progetto | data-template.yml |
| Board | L’interfaccia utente centrale dell’app, collegata in tempo reale alle tabelle, per tutti i dispositivi | board-template.yml |
| Parametri | Impostazioni di un dispositivo o di un gruppo di dispositivi | env-template.yml |
| Accesso remoto | Tunnel verso le porte servite dai tuoi container, come una web UI | port-template.yml |
| Agenti AI | Agenti per l’assistente IronFlock, con le tue funzioni del dispositivo come strumenti | ai-template.yml |
Quando un utente installa la tua app in un progetto, IronFlock crea le tabelle dell’app nel database di quel progetto, ne installa la board e apre un realm di messaggistica privato che collega in tempo reale i container dell’app su tutti i dispositivi, le sue tabelle e la sua board. L’SDK entra in quel realm con l’identità iniettata dall’agente del dispositivo, quindi il tuo codice non ha bisogno né di un indirizzo né di credenziali.
Le Regole
- Le tabelle sono lo stato dell’app. Tutto ciò di cui hanno bisogno la board, un altro dispositivo, un container riavviato o un agente AI è memorizzato in una tabella. Un container conserva solo ciò che può ricostruire dalle tabelle, come una copia in memoria delle sue impostazioni.
- Le parti comunicano attraverso le tabelle, non direttamente tra loro. Un dispositivo scrive ciò che misura e lo stato in cui si trova, e la board lo mostra. La board scrive ciò che decidono gli utenti, e i dispositivi lo applicano. Nessuna delle due parti chiama l’altra, quindi la board funziona anche quando i dispositivi sono offline, e i dispositivi continuano a lavorare anche quando nessuno ha la board aperta.
- Una sola fonte di scrittura per tabella. Ogni tabella viene scritta dai dispositivi (ciascuno con i propri dati), dalla board oppure da un unico servizio. Indica quale nella
descriptiondella tabella. Due fonti che scrivono le stesse righe si sovrascrivono a vicenda le modifiche. - Stato desiderato, non comandi. Per cambiare ciò che fanno i dispositivi, scrivi in una tabella lo stato da raggiungere (un limite, un setpoint, una ricetta, una pianificazione) e lascia che ogni dispositivo applichi la riga più recente. Un dispositivo che era offline la applica quando torna online, e applicarla due volte non cambia nulla.
- Scrivi quando qualcosa cambia, segui in tempo reale, non fare mai polling. Scrivi la riga di un’entità quando cambia uno dei suoi valori, e le misure alla loro frequenza di campionamento. Tutto ciò che legge segue le tabelle in tempo reale: i collegamenti ai dati della board, le sottoscrizioni alle tabelle sui dispositivi, i websocket nella web UI di un dispositivo. Un timer che rilegge periodicamente una tabella è sempre un errore di progettazione.
- La board è la UI; la web UI di un dispositivo la completa. Servi una pagina web da un dispositivo solo per ciò che una board non può mostrare: l’HMI della macchina stessa, un video, una vista live ad alta frequenza, una schermata per l’operatore a bordo macchina. La board la incorpora.
Dove Va Ogni Tipo di Dato
| Dato | Dove | Scritto da | Letto da |
|---|---|---|---|
| Letture nel tempo | Una tabella con tsp | publishToTable, publishRowsToTable | Grafici con finestre temporali e aggregazione |
| Lo stato corrente di un dispositivo, una macchina o un ordine | Una tabella entità (maintainLatestFlagFor) | appendToTable: una riga con la chiave, tsp e le colonne modificate | Il filtro latest |
| Limiti, setpoint, ricette impostati dagli utenti | Una tabella scritta dal form della board | Il form | Ogni dispositivo, come mostrato più avanti |
| Record che gli utenti creano, modificano ed eliminano | Una tabella entità con una colonna deleted | Il form, che imposta il flag all’eliminazione | Elenchi e pagine di modifica sulla board, con il filtro latest e un filtro su deleted, live; vedi sotto |
| Errori e avvisi per gli operatori | La tabella error-logs | reportError con un userMessage | Il widget toast della board |
| Immagini, documenti, registrazioni | L’archiviazione dei file dell’app | files.put | L’URL del file in una colonna, mostrato da un widget |
| Password e chiavi API | Una colonna secret: true | Qualsiasi scrittura in tabella | revealSecrets, verifySecret, solo nei container dell’app stessa |
| L’installazione di un singolo dispositivo: l’indirizzo di una macchina, una porta seriale | Parametri dell’app | Le impostazioni dell’app del dispositivo o del gruppo | Variabili d’ambiente |
| Messaggi tra le parti della tua app che non servono più dopo la consegna | I tuoi topic | publish | subscribe |
| Una richiesta che ha bisogno subito della risposta di un dispositivo | Una funzione del dispositivo | registerDeviceFunction | callDeviceFunction, azioni dei widget, strumenti AI |
Aggiungere o pubblicare? A partire dall’SDK 1.10.0 sono la stessa cosa: publishToTable aggiunge, quindi entrambi attendono un data backend che si sta riavviando e ti segnalano quando rifiuta una riga. Per le letture ad alta frequenza, invia batch con publishRowsToTable.
Parametri o una tabella di impostazioni? I parametri appartengono a un dispositivo o a un gruppo di dispositivi: chi installa o configura il dispositivo li imposta nelle impostazioni dell’app, e il tuo codice li legge dal proprio ambiente. Usali per ciò che varia da un’installazione all’altra. Ciò che gli utenti modificano mentre l’app è in esecuzione — limiti, ricette, pianificazioni, qualsiasi cosa valga per molti dispositivi — va in una tabella scritta dalla board. La board è il luogo in cui gli utenti lavorano con l’app, ogni dispositivo recepisce subito una modifica, e la tabella conserva la cronologia di chi ha cambiato cosa e quando.
Tabelle Entità: Stato Corrente, Aggiornamenti ed Eliminazioni
Una tabella entità (maintainLatestFlagFor) è la cronologia delle sue entità: ogni modifica è una nuova riga con la chiave, un tsp e le colonne modificate. Nulla modifica o elimina una riga, e nulla deve farlo, perché ogni lettore sceglie lo stato corrente al momento della lettura:
- Lo stato corrente è una modalità di lettura, non una vista. Il filtro
latestrestituisce la riga più recente per entità, scelta in base atspquando la lettura viene eseguita: l’interruttore latest nelle impostazioni del filtro di un widget,{"latest": true}ingetHistory. La tabella conserva ogni versione; il filtro ne mostra una per entità. - È live. Un widget collegato alla tabella con il filtro latest riceve ogni nuova riga man mano che arriva e con essa sostituisce la riga dell’entità. Una modifica compare entro un secondo, senza nulla da aggiornare o pianificare.
- Un’eliminazione è una colonna. Dai alla tabella una colonna booleana
deleted. Il form della board la scrive tramite la sua impostazione Delete-Flag Column:trueall’eliminazione,falsea ogni salvataggio, quindi salvare un record eliminato lo ripristina. I widget che mostrano i record filtrano sulla colonna. Il filtro latest sceglie prima la riga più recente per entità e applica dopo le altre condizioni, quindi un’entità la cui riga più recente è eliminata esce dal widget appena quella riga arriva, e una riga precedente, non eliminata, non prende mai il suo posto. - Non costruire mai una trasformazione per questo. Una trasformazione che seleziona la riga più recente per entità o nasconde le righe eliminate riproduce ciò che fanno i due filtri, ma raggiunge una board solo secondo la sua pianificazione, quindi ogni modifica compare in ritardo. Le trasformazioni servono per ciò che una lettura non può esprimere: join, aritmetica, aggregazioni sulle righe correnti. Quando una ha bisogno dello stato corrente delle entità, selezionalo nel suo SQL con
DISTINCT ON (<key>) ... ORDER BY <key>, tsp DESCe filtradeletedlì. - Il tuo codice lo legge nello stesso modo.
getHistorycon il filtro latest e una condizione sudeletedrestituisce le entità correnti non eliminate, come in Crea la Tua Prima App, e una sottoscrizione alla tabella consegna ogni nuova riga, che il tuo codice applica alla sua copia dell’entità, oppure elimina l’entità quando la riga dicedeleted.
I dettagli sono in Tracciare lo Stato Corrente di un’Entità e Eliminazione Logica dei Record.
Seguire Ciò che Scrive la Board
Un dispositivo applica lo stato scritto dalla board, come un limite impostato sulla board, in tre passi:
- Sottoscriviti alla tabella prima di leggerla, in modo che nessuna riga sfugga tra la lettura e la sottoscrizione.
- Leggi la riga più recente una volta all’avvio, con il filtro
latest, e applicala. - Applica una riga solo se è più recente (
tsp) di quella in vigore. Una riga salvata proprio mentre il tuo programma si avvia può arrivare sia dalla lettura sia dalla sottoscrizione.
Python
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)Non serve altro, nemmeno per i dispositivi che vanno offline. Della connessione si occupa l’SDK, non il tuo codice: si riconnette, ripristina le tue sottoscrizioni e funzioni del dispositivo, e poi consegna a ogni sottoscrizione di tabella le righe memorizzate mentre il dispositivo era offline, a partire dalla più vecchia, prima delle righe che arrivano in tempo reale. Un limite salvato mentre un dispositivo era offline lo raggiunge non appena torna online. Il recupero si basa su tsp: consegna le righe successive all’ultima ricevuta dal tuo gestore, al massimo le 10.000 più recenti. La sua lettura attende il data backend come qualsiasi lettura di storico, entro la finestra di riconnessione; se fallisce, quelle righe non vengono consegnate e viene registrato un avviso. Il recupero fa parte dell’SDK dalla versione 1.10.0.
Comandi, Funzioni del Dispositivo e Topic
Preferisci lo stato. Quasi tutto ciò che gli utenti “comandano” è uno stato desiderato: un setpoint, acceso o spento, una ricetta. Scrivilo in una tabella, come sopra.
I comandi che devono essere eseguiti una sola volta, come stampare un’etichetta o avviare un ciclo di pulizia, sono righe con un id in una tabella scritta dalla board. Il dispositivo registra gli id già eseguiti in una tabella propria, così un comando ricevuto due volte viene eseguito una volta sola.
Le funzioni del dispositivo sono procedure che un dispositivo registra con registerDeviceFunction. Usale quando chi chiama ha bisogno subito della risposta del dispositivo:
- Agenti AI: uno strumento in
ai-template.ymlchiama la funzione sul dispositivo scelto dall’assistente. Vedi Agenti e Strumenti AI. - I widget Interruttore e SCADA sulla board le chiamano mentre il dispositivo è online.
- I tuoi container e dispositivi si chiamano a vicenda con
callDeviceFunction.
Restituisci da una funzione del dispositivo testo leggibile o un piccolo JSON, e restituisci un esito negativo previsto (una macchina spenta) come risultato, invece di sollevare un errore.
I topic trasportano tra le parti della tua app messaggi che non servono più dopo la consegna: publish e subscribe su nomi nel tuo namespace, come myapp.events. La board non può sottoscriversi ai topic: tutto ciò che mostra deve trovarsi in una tabella. I messaggi pubblicati mentre un dispositivo è offline non vengono memorizzati, quindi usa una tabella per tutto ciò che deve arrivare a destinazione.
Il Container sul Dispositivo
Cosa fornisce l’agente del dispositivo al tuo container e cosa si aspetta da esso:
- Identità: l’agente imposta
DEVICE_KEY,DEVICE_NAME,DEVICE_SERIAL_NUMBER,SWARM_KEY,APP_KEY,APP_NAME,ENV(DEVoPROD),TUNNEL_DOMAIN,DEVICE_LAN_IPeDEVICE_PORT_FOR_<port>, oltre alla credenziale propria dell’app per l’SDK. L’SDK legge ciò di cui ha bisogno; non inserire mai nessuno di questi valori direttamente nel codice. - Parametri: un’installazione di produzione riceve i valori impostati dagli utenti e, per gli altri, il
defaultValuedi ciascun parametro. Un’esecuzione di sviluppo riceve solo i valori predefiniti. Dai ai parametri nomi specifici (PLC_HOST, nonHOST). - Archiviazione: un’app a immagine singola riceve
/data, una cartella privata dell’app e conservata tra riavvii e aggiornamenti (viene eliminata insieme all’app), e/shared, condivisa da tutte le app sul dispositivo. Mantieni comunque lo stato dell’app nelle tabelle e usa queste cartelle per i file che appartengono al dispositivo, come un modello scaricato o una cache. - Hardware: un container a immagine singola viene eseguito con i diritti predefiniti di Docker: niente
/deve nessuna capability aggiuntiva. Se la tua app ha bisogno di hardware, dichiara l’accesso privilegiato; quando l’utente lo concede, il container viene eseguito in modalità privilegiata, con accesso al/devdel dispositivo. - Log: tutto ciò che il tuo programma scrive su stdout e stderr costituisce il log dell’app in IronFlock. Scrivilo senza buffering (
python -u). - Avvio: 30 secondi dopo la partenza il container deve essere ancora in esecuzione, altrimenti l’avvio viene considerato fallito.
- Crash: quando un’app di produzione termina, l’agente la riavvia, attendendo più a lungo dopo ogni fallimento. Un’esecuzione di sviluppo non viene riavviata. Gestisci nel tuo programma i fallimenti previsti, come un sensore che non risponde, e lascia che termini in caso di errori da cui non può riprendersi.
- Arresto: il tuo programma è il processo principale del container (PID 1), che ignora il segnale di arresto SIGTERM a meno che non lo gestisca. Fai terminare il programma alla ricezione di SIGTERM, altrimenti l’arresto dell’app resta in attesa finché Docker non termina il processo forzatamente. Avvia il programma in forma exec (
CMD ["python", "-u", "main.py"]), non tramite una shell. - Esecuzioni di sviluppo: eseguire l’app dall’IDE cloud è un’esecuzione di sviluppo (
ENVèDEV). Scrive nelle tabelle di sviluppo dell’app e riceve i valori predefiniti dei parametri. Le sue portehttpehttpsricevono tunnel che possono aprire, dal menu laterale dell’IDE, solo gli utenti con il privilegio Sviluppa sul dispositivo. Anche le sue portetcpeudpricevono tunnel, aperti come in produzione a chiunque abbia l’indirizzo, e l’SDK restituisce i loro indirizzi non appena sono attivi.
Docker Compose
Usa un docker-compose.yml quando la tua app esegue più processi, oppure include servizi di terze parti, come un broker MQTT o un database, accanto al tuo codice. Vedi Docker & Compose per le basi. Rispetto a un’immagine singola:
- Ogni servizio riceve l’ambiente iniettato. L’agente fornisce a ogni servizio un proprio file d’ambiente con le variabili indicate sopra; un
env_file:scritto da te viene sostituito. Un${VAR}nel file viene risolto con i valori iniettati. - Nessun
/data. I servizi non ricevono alcuna cartella dell’app. Conserva i file in volumi con nome: sopravvivono ad arresti e aggiornamenti, e vengono eliminati quando l’app viene rimossa dal dispositivo. - Le porte pubblicate vengono rimappate. Ogni porta pubblicata riceve una porta host libera su tutte le interfacce del dispositivo, e
DEVICE_PORT_FOR_<port>indica al tuo codice quella reale. Inport-template.yml, usa la porta host così come è scritta nel file compose. Pubblica solo ciò che gli utenti devono raggiungere, e lascia che i tuoi servizi comunichino tra loro sulla rete compose tramite il nome del servizio. Una porta host scritta come${VAR}non viene gestita: non riceve né rimappatura né tunnel. - Le impostazioni privilegiate richiedono il consenso dell’utente. L’agente non aggiunge né privilegi né dispositivi; un servizio riceve ciò che il file dichiara. Impostazioni come
privileged,cap_add,devices,network_mode: hosto un bind mount di un percorso dell’host richiedono una dichiarazione di accesso privilegiato indata-template.yml, e senza il consenso dell’utente l’agente le rimuove prima di avviare l’app. Chiedi solo ciò che serve all’app:devices: [/dev/ttyUSB0]invece diprivileged: true. Una vocedevices:indica hardware che deve esistere, altrimenti il container non si avvia;device_cgroup_rulesinsieme a un volume/devfunziona su ogni dispositivo. - Niente
profiles:. Un servizio con profili viene escluso dalla release. - La stessa regola di avvio. Entro 30 secondi ogni container deve essere in esecuzione o essere terminato con codice 0, e almeno uno deve essere in esecuzione. Assegna
restart: unless-stoppedai servizi di lunga durata.
Web UI sul Dispositivo
Un web server nel tuo container diventa raggiungibile da ovunque attraverso il tunnel di accesso remoto della sua porta, e la board lo mostra:
- Servi su una porta fissa (in ascolto su
0.0.0.0) e dichiarala inport-template.ymlconprotocol: http. Un’installazione di produzione attiva il tunnel e lo serve su HTTPS, websocket compresi. - Scrivi l’URL del tunnel in una tabella. Il metodo
getRemoteAccessUrlForPort(port)dell’SDK restituisce l’URL. All’avvio, scrivilo nella riga del dispositivo in una tabella dei dispositivi. Un’esecuzione di sviluppo riceve l’URL del proprio tunnel, la cui etichetta termina con-dev, e lo scrive nelle tabelle di sviluppo dell’app; solo gli utenti con il privilegio Sviluppa sul dispositivo possono aprirlo. - Incorporalo nella board. Collega l’URL del widget Embed a quella colonna, su una pagina per dispositivo che filtra in base al dispositivo. Nell’UI Builder si tratta di un collegamento ai dati come un altro.
Per la pagina stessa:
- Chi può aprirla: un tunnel http è privato. Possono aprirlo solo le persone che hanno effettuato l’accesso a IronFlock e hanno accesso al dispositivo, a meno che un utente non renda pubblica la porta. Sulla rete del dispositivo stesso la pagina è invece aperta a tutti, quindi non mostrare lì nulla che la rete dello stabilimento non debba vedere, e richiedi un tuo login per qualsiasi operazione che modifichi lo stato della macchina.
- URL relativi: carica le risorse, chiama la tua API e apri i websocket con percorsi relativi alla pagina, così che funzioni sulla rete del dispositivo, attraverso il tunnel e dietro un reverse proxy.
- Push, mai polling: invia gli aggiornamenti tramite un websocket.
- Nessuna credenziale della piattaforma nel browser: la pagina comunica con il proprio server nel container, ed è quel server a mantenere la connessione SDK.
Per mostrare l’HMI web di una macchina sulla rete dello stabilimento al posto di una tua pagina, inoltralo tramite reverse proxy dal tuo container. Vedi Incorporare HMI Esterni.
Più Dispositivi
- Ogni riga porta con sé il proprio dispositivo: IronFlock memorizza in
device_keyla chiave del dispositivo che ha scritto una riga. Usadevice_keycome chiave delle tabelle per dispositivo, memorizza il nome del dispositivo in una colonna conpath: kwargs.DEVICE_NAMEe suddividi i grafici in base a quel nome. - Un elenco dei dispositivi e una pagina per dispositivo: una navigazione laterale costruita a partire dalla tabella dei dispositivi, con un’unica pagina che funziona per ogni dispositivo e filtra i propri widget in base al dispositivo indicato nel percorso. Vedi Creare Dashboard.
- Impostazioni per dispositivo o per macchina: usa come chiave della tabella delle impostazioni una colonna che identifica la destinazione (le righe scritte dalla board non hanno un proprio dispositivo), e compilala nel form sulla pagina della destinazione. Ogni dispositivo applica la riga più recente per la propria chiave.
- Un dispositivo rimosso conserva le sue righe. Se gli utenti devono poterlo togliere dagli elenchi, aggiungi alla tabella dei dispositivi una colonna
deletede alla board un form che la imposta.
Lista di Controllo
- Tutto lo stato è nelle tabelle, ciascuna con un’unica fonte di scrittura indicata nella sua descrizione, e ogni colonna scritta dal codice è dichiarata in
data-template.yml. - Ogni riga ha un
tsp, e le righe di stato vengono scritte quando qualcosa cambia, solo con la chiave e le colonne modificate. Le tabelle entità si leggono con il filtrolateste un filtro sudeleted, mai attraverso una trasformazione che le deduplica o le ripulisce. - Lo stato scritto dalla board viene seguito con i tre passi descritti sopra, con l’SDK 1.10.0 o successivo, e il codice non si occupa mai della connessione né delle sue sessioni.
- Le scritture in tabella nei cicli di lunga durata gestiscono l’errore che una scrittura solleva quando la piattaforma è irraggiungibile da un minuto, e i fallimenti previsti vengono segnalati con
reportErrore unuserMessage. - Il programma termina alla ricezione di SIGTERM, si avvia in forma exec e scrive i log senza buffering.
- Niente fa polling: la board si collega ai dati, i dispositivi si sottoscrivono, le web UI inviano gli aggiornamenti in push.
- Ogni porta servita dalla tua app è dichiarata in
port-template.yml, il suo URL proviene dagetRemoteAccessUrlForPorte viene scritto in una tabella, e la board lo incorpora da lì. - Un’app compose non ha
env_file:, néprofiles:, né porte host${VAR}, conserva i file in volumi con nome e pubblica solo le porte di cui gli utenti hanno bisogno.
Inizia dall’App di Partenza
Il plugin IronFlock per Claude Code contiene un’app di partenza che segue questo blueprint: un programma che su ogni dispositivo legge i dati di una macchina, mantiene nelle tabelle lo stato e le letture di ciascun dispositivo, applica il limite che gli utenti impostano sulla board e serve una vista live che la board incorpora nella pagina di ciascun dispositivo. Claude Code crea a partire da essa una nuova app e la adatta al tuo caso d’uso.