jzland docs
Riferimento tecnico

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}&current=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.

CampoCosa
id[a-z0-9_]+, unico
nome, descrizioneper il configuratore e il portale
widgetl'id del widget che riceve i campi
ogniogni quanto: "90s", "10m", "1h"; da 1 minuto a 24 ore (sulla scheda ogni HTTPS costa 1–2 s)
configi campi di configurazione, nello stesso formato dei manifest (sotto)
richiestametodo (GET o POST), url, intestazioni, corpo
mappemappe con un nome, da usare in più campi ("mappa": "icona")
campii campi semplici: chiavi 0..14
listagli elementi della lista del widget: JZ_K(i, campo), fino a 14
erroririprova, 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 di config di tipo testo, numero, sì/no o scelta) e {segreto.nome} (un campo di tipo segreto). Nell'URL si codificano come encodeURIComponent; 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 il predefinito, 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'è un Content-Type si aggiunge application/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. Location può 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, da https:// a http://, con //host, spazi, \ o caratteri fuori dall'ASCII non si segue, ed è un errore http. Con 303, o con 301 e 302 dopo un POST, si continua con un GET senza corpo, come fa fetch. Le stesse regole nei due motori (redirectTarget e jz_connector_redirect_ok, con i casi in test/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:

ScritturaCosa
$la radice della risposta
@l'elemento corrente della lista (solo nei campi di lista, e in di)
.nomeun 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:

TrasformazioneCosa fa
1aggregada 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
2per, piumoltiplica, poi somma ("per": 0.001 dai millisecondi ai secondi)
3fascesoglie crescenti: il risultato è quante soglie il valore raggiunge ([20, 40]: 15 → 0, 20 → 1, 50 → 2)
4decimaliarrotonda a 0–6 decimali; è anche il numero di decimali quando il numero diventa testo
5mappavalore 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'è
6dataun istante nel formato chiesto (sotto)
7testomodello del testo finale, {} è il valore ("{} fa", "{}°")
8maxal massimo n caratteri, con … alla fine
9tipoint 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 decimali decimali (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.675 con due decimali fa 2,68 (perché 2.675×100 è 267.5 esatto), 1.005 fa 1.
  • int: arrotondato allo stesso modo e tenuto dentro int32 (3000000000 → 2147483647).
  • sì/no: 1/0 come numero, true/false come 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.

dataEsempioTipo
HH:mm14:05testo
HH14testo
giornoven 17 otttesto
relativaadesso, 12 min, 2 h, 3 gtesto
fa2 h fa (o adesso)testo
tratra 2 h (o adesso)testo
minutiminuti da adesso all'istante, negativi nel passato (arrotondati verso il basso)int
giornigiorni di calendario da oggi (0 oggi, 1 domani, -1 ieri)int
epochsecondiint

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.

TipoQuandoIl testo
configmanca un valore di configurazione o una chiave segretamanca "Città" nella configurazione
retela richiesta non arriva o non risponde in temponessuna risposta dal server
httpuno stato che non è 2xx; un rinvio che non si segueil server risponde 503; il server rinvia a un altro indirizzo
jsonla risposta non è JSON (vedi sopra)la risposta non è JSON
rispostanella risposta non c'è nessuno dei campi che vengono dalla risposta (di solito un errore dell'API con stato 200), o è oltre 64 KBnella risposta non c'è nessuno dei campi attesi; risposta oltre 64 KB
messaggioil messaggio non entra nella schedamessaggio 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 spegnerlo

stop() 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 il json di ESP-IDF: un cJSON solo), con CJSON_NESTING_LIMIT a 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 con jz_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) ritornano false e chi le chiama lo sa subito. Una risposta oltre 64 KB arriva con lo stato JZ_HTTP_TOO_BIG (errore risposta, come nel cervello). Sulla scheda firmware/main/http.c: esp_http_client in un task suo, i certificati del bundle di ESP-IDF, i rinvii decisi da jz_connector_redirect_ok, risposte e alberi JSON in PSRAM (l'allocatore di cJSON si installa all'avvio, in app_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/:

ConnettoreWidgetNote
meteometeoadesso, 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_fuoriaria_fuoriindice europeo, livello dalle fasce 20/40/60/80/100, PM2.5, PM10, ora dell'aggiornamento
oggi_meteooggisolo 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" }] }
    ]
  }
}
CampoCosa
idil nome dell'integrazione e della sua sezione in config.json
widgeti widget che alimenta (vuoto se manda solo messaggi di sistema, come la disposizione)
richiedepc: 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
sistemimac, windows, linux: su quali computer gira (con api e scheda, tutti)
permessirete (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
configforma 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 test in packages/connectors: funzioni pure, validazione, manifest, schemi su disco aggiornati, e i casi di test/casi.json passati da fetch verso un server locale (Bun.serve sulle 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 a casi.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.

On this page