Connettori e manifest
Come si descrive un'integrazione con un file JSON e come la eseguono il cervello e la scheda.
Due formati in packages/connectors:
- Connettore: un file JSON che dice quale richiesta HTTP fare, ogni quanto, e come la risposta JSON
diventa i campi di un widget. Lo stesso file lo esegue il cervello (motore TypeScript,
src/engine.ts) e la scheda in standalone (motore C,ui/src/connector.c): un'integrazione "via API" si scrive una volta sola e arriva alla scheda dal portale senza ricompilare il firmware. - Manifest: com'è fatta un'integrazione (widget, cosa richiede, configurazione, permessi). Il configuratore genera i moduli da qui; la scheda in standalone mostra solo ciò che non richiede il PC.
Gli schemi JSON stanno in packages/connectors/schema/ (generati da src/schema.ts con bun run gen):
gli editor li usano con la riga "$schema" in testa ai file.
Un connettore
{
"$schema": "../schema/connettore.schema.json",
"id": "aria_fuori",
"nome": "Aria fuori da Open-Meteo",
"widget": "aria_fuori",
"ogni": "30m",
"config": [
{ "nome": "latitudine", "tipo": "numero", "etichetta": "Latitudine", "predefinito": 44.4949 },
{ "nome": "longitudine", "tipo": "numero", "etichetta": "Longitudine", "predefinito": 11.3426 },
{ "nome": "citta", "tipo": "testo", "etichetta": "Città", "predefinito": "Bologna" }
],
"richiesta": {
"url": "https://air-quality-api.open-meteo.com/v1/air-quality?latitude={config.latitudine}&longitude={config.longitudine}¤t=european_aqi,pm2_5,pm10&timezone=auto"
},
"campi": [
{ "chiave": 0, "da": "$.current.european_aqi", "tipo": "int" },
{ "chiave": 1, "da": "$.current.european_aqi", "fasce": [20, 40, 60, 80, 100] },
{ "chiave": 2, "da": "$.current.pm2_5", "tipo": "int" },
{ "chiave": 3, "da": "$.current.pm10", "tipo": "int" },
{ "chiave": 4, "valore": "{config.citta}" },
{ "chiave": 5, "da": "$.current.time", "data": "HH:mm" }
]
}Le chiavi sono quelle documentate in testa al widget (ui/widgets/aria_fuori.c): il connettore non
inventa niente, riempie il messaggio DATA che il widget sa già leggere.
| Campo | Cosa |
|---|---|
id | [a-z0-9_]+, unico |
nome, descrizione | per il configuratore e il portale |
widget | l'id del widget che riceve i campi |
ogni | ogni quanto: "90s", "10m", "1h"; da 1 minuto a 24 ore (sulla scheda ogni HTTPS costa 1–2 s) |
config | i campi di configurazione, nello stesso formato dei manifest (sotto) |
richiesta | metodo (GET o POST), url, intestazioni, corpo |
mappe | mappe con un nome, da usare in più campi ("mappa": "icona") |
campi | i campi semplici: chiavi 0..14 |
lista | gli elementi della lista del widget: JZ_K(i, campo), fino a 14 |
errori | riprova, massimo, chiave (sotto) |
La richiesta
- URL
https://…;http://solo verso questo computer (localhost,127.0.0.1,[::1]). L'host è scritto per intero, senza segnaposto: così si sa sempre chi contatta il connettore. - Segnaposto:
{config.nome}(un campo diconfigdi tipo testo, numero, sì/no o scelta) e{segreto.nome}(un campo di tiposegreto). Nell'URL si codificano comeencodeURIComponent; nelle intestazioni e nel corpo vanno così come sono. I numeri si scrivono con il punto e al massimo sei decimali (44.4949). Un valore vuoto vale come mancante: si usa ilpredefinito, se no è un errore. - I segreti si usano solo nella richiesta: mai nei campi, perché finirebbero sullo schermo. Nei log
l'URL compare con i segreti oscurati (
•••). - Intestazioni: nome → modello (
"Authorization": "Bearer {segreto.token}"), al massimo 8. - Corpo (solo
POST): JSON qualunque; i testi dentro sono modelli. Se non c'è unContent-Typesi aggiungeapplication/json. Va bene per GraphQL: le{che non aprono un segnaposto restano. - Rinvii (301, 302, 303, 307, 308): si seguono al massimo 3, e solo verso lo stesso schema e lo
stesso host della richiesta, perché le intestazioni (con le chiavi) seguono il rinvio.
Locationpuò essere un percorso (/v2/dati) o un URL intero con lo stesso schema e host (maiuscole e porta predefinita non contano); verso un altro host, dahttps://ahttp://, con//host, spazi,\o caratteri fuori dall'ASCII non si segue, ed è un errorehttp. Con 303, o con 301 e 302 dopo unPOST, si continua con unGETsenza corpo, come fafetch. Le stesse regole nei due motori (redirectTargetejz_connector_redirect_ok, con i casi intest/casi.json). - La risposta può essere al massimo di 64 KB, uguale sulla scheda e nel cervello, così un connettore che va sul computer va anche in standalone.
- Tempo: nel cervello al massimo 15 secondi per tutta la richiesta, rinvii compresi. Sulla scheda i
15 secondi valgono per ogni operazione di
esp_http_client(il collegamento, ogni lettura), quindi una risposta che arriva a gocce può durare di più: chi fa girare i connettori la dà per persa dopo 45 secondi.
Percorsi
Un sottoinsieme piccolo di JSONPath, uguale nei due motori:
| Scrittura | Cosa |
|---|---|
$ | la radice della risposta |
@ | l'elemento corrente della lista (solo nei campi di lista, e in di) |
.nome | un campo; il nome è fatto di A-Z a-z 0-9 _ - |
['nome qualunque'] o ["…"] | un campo con altri caratteri (spazi, punti…) |
[3], [-1] | un elemento di un array; i negativi contano dalla fine |
[#] | l'elemento con l'indice dell'elemento corrente della lista |
[#] serve per le risposte "a colonne", come quelle di Open-Meteo, dove ogni grandezza è un array a sé:
con "lista": { "da": "$.hourly.time" }, il campo "$.hourly.temperature_2m[#]" è la temperatura della
stessa ora. Niente filtri, niente *, niente ..: un percorso che non c'è dà un valore mancante (dentro
un null o un tipo sbagliato, idem). Con chiavi ripetute nel JSON vale l'ultima, come in JSON.parse
(e "aggrega": "conta" su un oggetto conta le chiavi diverse).
Il JSON della risposta
Sulla scheda il JSON lo legge cJSON, che da solo su alcune cose è più largo di JSON.parse e su altre più
stretto. Perché i due motori diano gli stessi campi, una risposta è JSON solo se:
- è UTF-8 valido (un
éin Latin-1 no; il BOM in testa sì); - ha al massimo 32 livelli di annidamento (array e oggetti uno dentro l'altro): sulla scheda cJSON analizza in modo ricorsivo, nel task di LVGL che ha 12 KB di stack;
- nei testi non ha surrogate sole (
"\ud800"; le coppie come"\ud83d\ude00"vanno bene) né\u0000(in C un testo finirebbe lì); - i numeri sono scritti come vuole JSON (niente
01,+1,.5,1.), e non ci sono caratteri di controllo fuori posto (nei testi nemmeno il tab: va scritto\t).
Altrimenti è un errore json, in tutti e due i motori. Le stesse regole valgono per la definizione che
arriva alla scheda: validateConnector rifiuta un connettore con più di 32 livelli o con testi che la
scheda non saprebbe tenere.
I campi
Ogni campo prende un valore in uno di due modi:
"da": "$.percorso", il valore JSON così com'è;"valore": una costante (0,true) o un modello di testo con{config.nome},{$.percorso},{@.percorso}("{$.current.weather_code}/{$.current.is_day}"). Se un segnaposto manca, manca il valore.
Poi le trasformazioni, sempre in quest'ordine, ognuna solo se è scritta:
| Trasformazione | Cosa fa | |
|---|---|---|
| 1 | aggrega | da un array a un numero: conta (elementi; anche le chiavi di un oggetto), somma, min, max. Con di: "@.campo" si prende quel campo di ogni elemento. Si contano i numeri (anche i testi numerici); somma di niente è 0, min e max di niente mancano |
| 2 | per, piu | moltiplica, poi somma ("per": 0.001 dai millisecondi ai secondi) |
| 3 | fasce | soglie crescenti: il risultato è quante soglie il valore raggiunge ([20, 40]: 15 → 0, 20 → 1, 50 → 2) |
| 4 | decimali | arrotonda a 0–6 decimali; è anche il numero di decimali quando il numero diventa testo |
| 5 | mappa | valore come testo → nuovo valore (testo o numero). Vince la chiave esatta, poi il prefisso più lungo tra le chiavi che finiscono con * ("8*", "85/*"), poi "*". Senza corrispondenza il valore resta com'è |
| 6 | data | un istante nel formato chiesto (sotto) |
| 7 | testo | modello del testo finale, {} è il valore ("{} fa", "{}°") |
| 8 | max | al massimo n caratteri, con … alla fine |
| 9 | tipo | int o testo nel protocollo. Se manca: testi → testo, numeri e sì/no → int |
Se il valore manca (percorso assente, null, testo non numerico dove serve un numero…) si usa
predefinito; senza, il campo non si manda. Le regole di conversione:
- testo → numero: cifre con il punto o la virgola, esponente, spazi attorno (
" 21,5 "); altro non è un numero. - numero → testo: la virgola come separatore e al massimo
decimalidecimali (2 se non c'è), senza zeri inutili (1234567,89,2,5). L'arrotondamento è "a metà lontano dallo zero" fatto sul numero in virgola mobile:2.675con due decimali fa2,68(perché 2.675×100 è 267.5 esatto),1.005fa1. - int: arrotondato allo stesso modo e tenuto dentro int32 (
3000000000→2147483647). - sì/no:
1/0come numero,true/falsecome testo. - un testo oltre 1024 byte si taglia senza spezzare i caratteri (la scheda non ne prende di più).
Date
L'ingresso è un testo ISO 8601 (2026-10-17, 2026-10-17T14:05, …:30Z, …+02:00, …+0200) o un
numero di secondi epoch (fino a 10^11: i millisecondi si portano a secondi con "per": 0.001). Un
testo senza fuso è un'ora locale, come quelle di Open-Meteo con timezone=auto; una data sola è la
mezzanotte locale.
data | Esempio | Tipo |
|---|---|---|
HH:mm | 14:05 | testo |
HH | 14 | testo |
giorno | ven 17 ott | testo |
relativa | adesso, 12 min, 2 h, 3 g | testo |
fa | 2 h fa (o adesso) | testo |
tra | tra 2 h (o adesso) | testo |
minuti | minuti da adesso all'istante, negativi nel passato (arrotondati verso il basso) | int |
giorni | giorni di calendario da oggi (0 oggi, 1 domani, -1 ieri) | int |
epoch | secondi | int |
L'ora locale è quella del computer per il cervello e quella della scheda in standalone.
La lista
"lista": {
"da": "$.hourly.time",
"max": 6,
"campi": [
{ "campo": 0, "da": "@", "data": "HH" },
{ "campo": 2, "da": "$.hourly.temperature_2m[#]", "tipo": "int" }
]
}da è un array della risposta; max gli elementi al massimo (14 se manca). Il motore manda
JZ_F_COUNT con il numero di elementi e, per ognuno, i campi JZ_K(i, campo). Con una lista di
oggetti si legge dentro l'elemento con @ ("@.name", "@.owner.login").
Il messaggio
I campi escono in quest'ordine: i campi semplici come sono scritti, la chiave dell'errore vuota (se
c'è errori.chiave), JZ_F_COUNT, gli elementi della lista. Al massimo 48 campi e 2048 byte (i
limiti di jz_msg_t sulla scheda): oltre è un errore, non un messaggio tagliato.
Errori e attese
Dopo un errore il connettore riprova dopo riprova (predefinito 1m), poi il doppio a ogni errore di
fila, fino a massimo (predefinito 30m, o ogni se è più lungo). Quando torna a funzionare, di
nuovo ogni ogni.
| Tipo | Quando | Il testo |
|---|---|---|
config | manca un valore di configurazione o una chiave segreta | manca "Città" nella configurazione |
rete | la richiesta non arriva o non risponde in tempo | nessuna risposta dal server |
http | uno stato che non è 2xx; un rinvio che non si segue | il server risponde 503; il server rinvia a un altro indirizzo |
json | la risposta non è JSON (vedi sopra) | la risposta non è JSON |
risposta | nella risposta non c'è nessuno dei campi che vengono dalla risposta (di solito un errore dell'API con stato 200), o è oltre 64 KB | nella risposta non c'è nessuno dei campi attesi; risposta oltre 64 KB |
messaggio | il messaggio non entra nella scheda | messaggio troppo grande per la scheda (…) |
Il testo è una frase breve e fissa, uguale nei due motori (MESSAGES in engine.ts, JZ_CONN_MSG_* in
jz/connector.h): è quello che va sullo schermo e nello stato della pagina. Il motivo tecnico (il codice
di Bun o di ESP-IDF, il testo dello stato HTTP, l'errore del parser, dove portava un rinvio) va solo nel
log, accanto all'URL con le chiavi oscurate.
Con "errori": { "chiave": 4 } il widget riceve il testo dell'errore in quella chiave, insieme agli
ultimi dati buoni; quando va tutto bene la chiave arriva vuota. Serve ai widget che hanno uno "stato del
collegamento" (per esempio linear_fissate key 4). Senza, il widget tiene semplicemente gli ultimi dati.
Validazione
validateConnector(json) controlla lo schema e le regole che lo schema non sa dire: segnaposto che
puntano a campi dichiarati (e di tipo giusto), segreti solo nella richiesta, @ e [#] solo nella lista,
chiavi ripetute, fasce crescenti, mappe che esistono, tipo: "int" con trasformazioni che fanno testi,
ogni tra 1 minuto e 24 ore. Gli errori sono frasi con il percorso (campi[2].mappa: "icone" non è tra le mappe). La scheda rifà i controlli che le servono per girare senza sorprese e rifiuta i campi che non
conosce; la validazione completa la fa chi manda il connettore (cervello, portale).
Nel cervello
import { validateConnector, start } from "@jz/connectors";
const v = validateConnector(json);
if (!v.ok) throw new Error(v.errors.join("\n"));
const running = start(v.connector, { config, segreti }, {
publish: (widget, fields) => hub.publish(widget, fields),
log: hub.logger(`connettore:${v.connector.id}`),
onStatus: (level, text) => hub.setStatus(v.connector.id, level, text),
});
// running.refresh() quando il widget viene mostrato e i dati sono vecchi; running.stop() per spegnerlostop() interrompe anche la richiesta in giro: quello che arriva dopo non tocca più il widget né lo
stato. Così, ricaricando un connettore con una configurazione nuova (stop del vecchio, start del nuovo), i
dati di quella vecchia non arrivano dopo quelli nuovi.
Ci sono anche i pezzi singoli: buildRequest, extract (risposta già letta → Field[]), run
(una richiesta con fetch), backoffMs. Il cervello non li usa ancora: collegarli (dove stanno i
connettori scelti, i valori in config.json, i segreti nel gestore delle chiavi) è il lavoro del cervello.
Sulla scheda, in standalone
- Motore (
ui/src/connector.c, puro):jz_connector_parse,jz_connector_request,jz_connector_extract(risposta →jz_msg_t),jz_connector_backoff_ms,jz_connector_redirect_ok. JSON con cJSON 1.7.19 (components/json, che nel firmware sostituisce iljsondi ESP-IDF: un cJSON solo), conCJSON_NESTING_LIMITa 32. - Chi li fa girare (
ui/src/connector_run.c):jz_connectors_add(def, config, segreti)con i testi JSON arrivati dal portale,jz_connectors_clear(). Un timer LVGL da un secondo lancia il connettore di turno; i messaggi arrivano ai widget conjz_ui_deliver, come se li mandasse il cervello. - Trasporto:
jz_platform_http_get/jz_platform_http_post(jz/platform.h), asincrone, con la risposta nel thread della UI:done()arriva una volta sola, anche per gli errori; se la richiesta non parte nemmeno (memoria finita) ritornanofalsee chi le chiama lo sa subito. Una risposta oltre 64 KB arriva con lo statoJZ_HTTP_TOO_BIG(errorerisposta, come nel cervello). Sulla schedafirmware/main/http.c:esp_http_clientin un task suo, i certificati del bundle di ESP-IDF, i rinvii decisi dajz_connector_redirect_ok, risposte e alberi JSON in PSRAM (l'allocatore di cJSON si installa all'avvio, inapp_main). Nei simulatori la rete non c'è: sullo schermo "nessuna risposta dal server", nel log "HTTP non disponibile nel simulatore". - Una richiesta alla volta, anche quando una data per persa dopo 45 s risponde tardi: la sua risposta non tocca il widget e non fa partire un'altra richiesta mentre ce n'è una in giro.
Limiti dello standalone:
- una richiesta alla volta e al massimo 8 connettori: ogni HTTPS costa 1–2 secondi e la RAM del TLS;
- niente modalità standalone ancora: oggi il firmware non avvia il Wi-Fi e nessuno chiama
jz_connectors_add. Senza rete la richiesta fallisce subito con "nessuna rete: il Wi-Fi non è acceso"; - niente azioni: i connettori leggono e basta. I tocchi sui widget che chiedono qualcosa al cervello (per esempio "domani" nel meteo) in standalone non fanno niente;
- una sola richiesta per connettore e un messaggio per widget: un widget che vuole dati da due API (stelle di GitHub e download di npm) non si fa con un connettore;
- niente OAuth: le chiavi sono quelle messe a mano nella configurazione;
- le chiavi segrete arrivano alla scheda cifrate dal portale (codice a 6 cifre) e restano nella memoria cifrata: è il lavoro del portale e dell'abbinamento, non del motore.
Connettori pronti
Senza chiavi, da API pubbliche (Open-Meteo), in packages/connectors/connettori/:
| Connettore | Widget | Note |
|---|---|---|
meteo | meteo | adesso, minima e massima di oggi, le prossime sei ore. Key 3 ("piove prima di sera") è 1 se nelle prossime sei ore la probabilità di pioggia arriva al 50%; key 8 (l'ora della pioggia) non si manda; key 4 è sempre 0 (oggi) |
aria_fuori | aria_fuori | indice europeo, livello dalle fasce 20/40/60/80/100, PM2.5, PM10, ora dell'aggiornamento |
oggi_meteo | oggi | solo la riga del meteo (key 9, 10, 11): le altre righe del widget restano a chi le manda |
Le icone e le condizioni vengono dai codici WMO di Open-Meteo con le mappe dentro i file; la notte
(is_day 0) mostra la luna quando è sereno. Le risposte d'esempio dei test (test/risposte/) hanno la
forma documentata da Open-Meteo: le API vere le prova chi usa i connettori.
Manifest
{
"$schema": "../schema/manifest.schema.json",
"id": "linear",
"nome": "Linear",
"descrizione": "Le tab fissate e i ticket assegnati a te…",
"widget": ["linear_fissate", "linear_todo"],
"richiede": "pc",
"sistemi": ["mac"],
"permessi": { "rete": ["api.linear.app"], "apreLink": true },
"config": {
"forma": "oggetto",
"campi": [
{ "nome": "api_key", "tipo": "segreto", "etichetta": "Chiave API personale", "obbligatorio": true },
{ "nome": "apri", "tipo": "scelta", "etichetta": "Apri i link", "predefinito": "app",
"opzioni": [{ "valore": "app", "etichetta": "Nell'app di Linear" }, { "valore": "browser", "etichetta": "Nel browser" }] }
]
}
}| Campo | Cosa |
|---|---|
id | il nome dell'integrazione e della sua sezione in config.json |
widget | i widget che alimenta (vuoto se manda solo messaggi di sistema, come la disposizione) |
richiede | pc: serve il cervello (programmi locali, aprire app, socket di Discord); api: basta internet, gira anche sulla scheda con un connettore (connettore ne dice l'id); scheda: niente oltre la scheda |
sistemi | mac, windows, linux: su quali computer gira (con api e scheda, tutti) |
permessi | rete (domini, anche *.dominio), reteDaConfig (campi con URL scelti da chi configura), apreLink, tastiera (i suoi widget premono scorciatoie con la tastiera USB della scheda), bluetooth |
config | forma oggetto (la sezione è { … }) o lista (la sezione è [ { … }, … ], con max), e i campi |
Tipi dei campi di configurazione (gli stessi nei connettori): testo (con formato url, link,
immagine o widget, e segnaposto), segreto, numero (min, max, passo), booleano, scelta
(opzioni), lista (di oggetti, con i loro campi e max), testi (lista di testi), mappa (testo →
testo, con etichettaChiave ed etichettaValore). Tutti hanno nome, etichetta, descrizione,
obbligatorio e, quando ha senso, predefinito.
I manifest delle integrazioni del cervello (apps/brain/src/integrations-js) stanno in
packages/connectors/manifest/: discord, chat, linear, layout, batterie, con i campi delle
loro sezioni di config.json come sono oggi. Un test controlla che ogni integrazione ne abbia uno.
I sistemi sono quelli su cui il codice è stato provato: oggi solo mac, tranne layout che non dipende
dal computer. Un connettore non ha bisogno di un manifest scritto a mano: manifestOf(connettore) lo
ricava (richiede api, tutti i sistemi, l'host dell'URL come permesso di rete: i rinvii restano su
quell'host).
Test
bun testinpackages/connectors: funzioni pure, validazione, manifest, schemi su disco aggiornati, e i casi ditest/casi.jsonpassati dafetchverso un server locale (Bun.servesulle porte 8840–8849) che risponde con le risposte d'esempio.cmake -G Ninja -B ui/test/build ui/test && ninja -C ui/test/build && ./ui/test/build/connector_test: gli stessi casi nel motore C (con AddressSanitizer e UBSan). Devono dare esattamente gli stessi campi: chi cambia una regola la cambia nei due motori e aggiunge un caso acasi.json../ui/test/build/runner_test: chi fa girare i connettori sulla scheda (connector_run.c), con LVGL, rete e orologio guidati dal test: una richiesta alla volta, le risposte arrivate tardi, i testi degli errori sullo schermo e il motivo nel log.
In casi.json un caso ha il connettore (un file o un nome in connettori), config e segreti, la
richiesta attesa (URL, URL nei log, intestazioni, corpo, o errore), la risposta (un file in
risposte/ o JSON scritto lì), rispostaTesto (il testo così com'è) o rispostaHex (i byte, per
l'UTF-8 sbagliato), l'ora e il fuso, e i campi atteso: la chiave è il numero, n per
JZ_F_COUNT, i.c per JZ_K(i, c); un testo è un campo testo, un numero un int. In rinvii ci sono i
rinvii che si seguono e quelli che no.