Het paneeltje op het bureau
Er staat een touchscreen van 4,3 inch op een bureau bij ons. 800x480 pixels, een ESP32-S3 erachter, geen toetsenbord, geen muis, geen enkel idee waarvoor het dient. Het boot, het adverteert één Bluetooth-service, en het wacht.
Wat het daarna wordt - een reservatiedisplay, een lichtenpaneel, een roadbook voor een oldtimerrally, een timer, een goedkeuringsknop voor een programmeeragent - wordt het in ongeveer een seconde, over Bluetooth, zonder dat iemand iets hercompileert.
Dat laatste stuk is het product. De rest is verpakking.
Waarom softwarestudio’s van hardware afblijven
Wij bouwen webapplicaties. Fastify, React, Postgres, het gewone werk. En toch bleven klanten iets fysieks vragen: een paneel aan de muur, een scherm aan een machine, een terminal aan de toog. Het eerlijke antwoord was elke keer dat een fysieke interface een aparte discipline is met een aparte kostenstructuur.
De reden is simpel. In de klassieke aanpak krijgt elke applicatie haar eigen firmware. Iemand schrijft C, definieert de schermen in code, flasht het bordje en itereert met een kabel erin. Wil de klant een vierde knop, dan is dat een firmwarewijziging, een nieuwe build, een nieuwe flash, en een nieuwe manier om een toestel in het veld onbruikbaar te maken. Hardware en applicatie zitten aan elkaar vastgelast. Je levert dan geen product, je levert voor eeuwig een firmwareproject per klant.
Dat is een slecht bedrijf om in te zitten als je met elf bent en je echte vak applicatielogica is.
Dus stelden we een andere vraag. Wat is het kleinste dat het scherm moet weten?
Het antwoord: bijna niets
De terminal kan zeven primitieven tekenen. Box, tekst, knop, lijst, voortgangsbalk, meter, afbeelding. Dat is de volledige woordenschat.
Een hostapplicatie verbindt over Bluetooth, stuurt één declaratieve configuratie met daarin de schermen opgebouwd uit die primitieven, en streamt daarna key-valuestate naar de bindings. De terminal rendert met LVGL en stuurt taps en antwoorden terug als events. Hij leert nooit wat een reservatie is, wat een kilowatt is, of van welke klant hij is.
Concreet ziet de draad er zo uit. Eén Bluetooth GATT-service met vier characteristics: een controlekanaal waar je naar schrijft, een eventkanaal dat notificeert, een infokanaal dat je uitleest, en een logkanaal. Elk frame is een 16-bit lengte, little endian, gevolgd door een CBOR-map met een veld t dat zegt wat het is.
Acht inkomende berichttypes. hello om jezelf voor te stellen, cfg om de interface te sturen, set om state te duwen, plus identify, unlink, ping, wifi en update. Zes uitgaande: status, event, pong, nack, netwerk, en de voortgang van een update over de lucht. Meer protocol is er niet.
Het interessante is wat er ná die eerste seconde gebeurt. De configuratie gaat één keer over. Daarna stuurt een applicatie die een temperatuur toont drie bytes en een sleutel. Bluetooth Low Energy is geen snelle link, en een ontwerp waarbij de host van op afstand pixels tekent zou ook zo aanvoelen. Een ontwerp waarbij de host {temp: 21.5} stuurt niet.
Grenzen, opgeschreven
De volledige configuratie moet in één frame van 64 kB passen. Zestien schermen. Vierenzestig kinderen per node. Vierenzestig afbeeldingen. Nestdiepte twaalf. Honderdachtentwintig statesleutels.
Dat zijn geen willekeurige cijfers. Het is wat een ESP32-S3 kan vasthouden zonder te gaan zwoegen, en die grenzen in een machineleesbaar compatibiliteitsbestand zetten was nuttiger dan om het even hoeveel documentatie. Elke encoder toetst eraan. Een host die zeventien schermen probeert te sturen krijgt nee, in code, bij het bouwen - in plaats van dat iemand iets raars ontdekt op een wandpaneel in een magazijn.
Tekstopmaak ondersteunt precies vier patronen: %d, %.1f, %.2f en mm:ss. Er is bewust geen float zonder decimalen. Rond af in de host en stuur een geheel getal. Elk formaat dat we toevoegen is een formaat waar drie aparte implementaties het voor altijd over eens moeten blijven.
Drie encoders, één set bytes
Er zijn drie onafhankelijke implementaties van dit protocol: de C-kern in de firmware, een Swift-package voor Mac- en iPhone-apps, en een TypeScript-package voor de browser. Ze moeten byte voor byte hetzelfde produceren, want de bytes bepalen een CRC-32-hash van de configuratie, en die hash bepaalt of de terminal zich opnieuw laat provisioneren.
Dus is de volgorde van de CBOR-mappen deel van de specificatie, geen implementatiedetail. Nodesleutels staan in een vaste volgorde met het type eerst en de kinderen laatst. De configuratie is versie, formaat, home, thema, schermen, assets. Het thema is altijd volledig. Assets staan gesorteerd. Een encoder die iets “onschuldig” herschikt, breekt de hash en veroorzaakt een herprovisionering bij elke verbinding.
Drie talen daarover eerlijk houden lukt niet met goede bedoelingen alleen, dus is er een specificatiekit: JSON-schema’s, het compatibiliteitsbestand, zeventien interfacevectoren en tweeëndertig scalaire CBOR-vectoren, elk met hun verwachte bytes, hun hash, hun frames en een referentierender.
Een primitief is pas af als het schema, de vectoren, de firmware en alle drie de SDK’s het eens zijn.
De simulator is het eerste scherm waar je naar kijkt
De renderer compileert ook voor macOS, niet enkel voor het bordje. Zelfde broncode, zelfde LVGL, een venster in plaats van een paneel. Hij compileert ook naar WebAssembly, zodat de editor op onze site de échte renderer toont en niet een benadering in CSS.
Dezelfde renderer draait in een venster en op het bord, dus een layout wordt beoordeeld lang voor er hardware aan te pas komt.
Van de 384.000 pixels in een frame verschilt er nul tussen de WebAssembly-build en de native build.
Dat getal is de reden dat ontwerpiteraties seconden duren in plaats van flashcycli. Je tekent een layout, rendert een PNG, kijkt ernaar, past aan. Het bordje komt er pas bij als de layout al klopt.
Twee panelen, één weigering
In september kwam er een tweede bordje bij: een vierkant paneel van 4 inch, 480x480, andere displaydriver, andere touchcontroller, en twee hardwarerevisies die je bij het opstarten moet aftasten omdat ze hun IO-expander op een ander adres hebben gezet.
De verleidelijke oplossing is layouts schalen. Dat hebben we niet gedaan. Een configuratie draagt nu het formaat waarvoor ze getekend is, en het infokanaal meldt het bord en zijn resolutie. Stuur een configuratie voor het verkeerde formaat en de terminal weigert ze met een reden, en tekent zijn eigen pagina “niet voor dit scherm”.
De applicatie bezit de layout, de terminal bezit de weigering.
Dat is de regel die we vastlegden. Een applicatie die meerdere formaten aankan, leest eerst de info en stuurt de juiste. Een applicatie die er één kent, stuurt die gewoon en faalt netjes en zichtbaar. Wat we afwezen: schalen in de firmware, layoutvarianten binnen één configuratie, en een aparte firmware per paneel. Eén broncode, één versie, één image per bord, en exact één map die pinnen en resolutie kent.
Waar het ons pijn deed
Versie 0.1.4 is ingetrokken en komt niet terug. Ze ging over de lucht de deur uit, panikeerde binnen haar eigen proefperiode, en rolde terug op elk bord dat ze had opgepikt.
Geheugen dat stilletjes verdwijnt, is het soort bug dat je pas tegenkomt nadat de update al buiten is.
De oorzaak was een instelling die stilletjes niets deed. Een vlag die de Wi-Fi-zendbuffers dynamisch moest maken had een afhankelijkheid die niet vervuld was, dus hielden zestien statische buffers ongeveer 25 kB intern RAM bezet. Zet daar nog ontvangstaggregatie bovenop en de allocatie van de Bluetooth-stack faalde bij het opstarten, wat afbrak, wat terugrolde. De fix in 0.1.5: zes statische buffers, aggregatie uit, een kleiner venster, en een regel dat een update over de lucht weigert te starten onder 16 kB vrije heap.
De tweede, minder dramatisch: schrijfacties via Web Bluetooth gaan in stukken van 180 bytes, met een bevestiging om de acht stukken. We begonnen met 512 bytes zonder bevestiging, wat prima werkte tot een demo JPEG-albumhoezen over de link duwde en het echte toestel begon te haperen en vast te lopen. De simulator klaagde nooit, want de simulator heeft geen radio.
Wat dit oplevert
Provisionering en updates lopen via Foundry, ons toestellenregister: het houdt de borden bij, de firmwarekanalen, en een pagina die een toestel flasht vanuit de browser over USB. De toestemming leeft op het glas zelf, dus de eerste keer dat een nieuwe applicatie hallo zegt, moet iemand dat fysiek goedkeuren op het scherm. Het token is van de applicatie, niet van een tussenliggende brug.
Het resultaat is dat een nieuwe fysieke interface toevoegen nu applicatiewerk is. Iemand schrijft een layout in TypeScript of Swift, checkt ze in de simulator, en levert ze op. Geen toolchain, geen kabel, geen firmwarerelease, geen nieuwe manier om iets in het veld stuk te maken.
Een softwarestudio kán hardware maken. Ze moet de hardware alleen dom houden.