Sida 1 av 1

IoT HAL/DAL med JSON-konfiguration och skriptmotor

Postat: 24 september 2025, 23:05:40
av manicken
Hej!

Jag håller på att utveckla ett IoT-ramverk
med primärt stöd för ESP8266 och ESP32.

Ramverket är dock inte bundet till någon specifik hårdvara,
vilket jag demonstrerat genom att göra större delen av utvecklingen på Windows (MinGW)
samt senare Linux för att kunna använda mer avancerade debug-verktyg för att hitta en Heisenbug.

Möjliga kanditatorer för framtida portning är Raspberry Pi Pico, Teensy.

För närvarande använder projektet Arduino-frameworket som bas,
men det finns i princip inget som hindrar att man kör det helt fristående från Arduino-miljön.
För utom då att man måste eventuellt göra egna 'drivers'.

WiFiManager-library används för lätt kunna flasha flera enheter med firmware för att sedan
lätt kunna registrera dessa på nätverket, detta kan komma att utvecklas vidare
mer i stil med Tasmota/ESPEasy så att nya enheter lättare kan initieras.


Det har följande funktionalitet:
  • Ramverket är designat för att vara modulärt och flexibelt,
    men samtidigt betydligt mindre i kodstorlek än exempelvis ESPEasy eller Tasmota.
    Enligt VS Code Counter omfattar projektet ~13000 rader kod,
    jämfört med Tasmota (~240000 rader) och ESPEasy (~200000 rader)
    På en ESP32 använder systemet i nuläget ungefär 100kB flash
    men det finns potential att minska ytterligare genom att reducera användningen av stora debug-strängar.
  • HAL/DAL konfigureras via JSON-filer, till en intern trädstruktur
    Detta gör det möjligt att lägga till sensorer och andra enheter utan att ändra firmware.
    Man behöver inte ens starta om systemet (om man inte uttryckligen vill göra en “fresh start”).
    Detta gör att utvecklingen går betydligt snabbare.
    Hela systemet bygger på enhetliga abstraktioner, där varje enhet identifieras med ett unikt
    UID (dock för nuvarande begränsat till 8 tecken ASCII, då jag internt konverterar det till uint64_t för snabb/effektiv åtkomst)

    Samtliga enheter ärver från en gemensam Device-klass, som innehåller en uppsättning funktioner som kan overridas.
    Funktioner som inte används returnerar helt enkelt “unsupported operation”.
    I framtiden kan systemet även utökas med ett Device Capabilities-interface,
    vilket gör det möjligt att automatiskt generera dokumentation direkt i GUI över tillgängliga funktioner och begränsningar per enhet.
    Detta skulle förenkla både utveckling och integration ytterligare.

    JSON-konfigurationens flexibilitet
    JSON-strukturen kan organiseras som ett träd för att underlätta hantering av både fysiska och
    virtuella/logiska enheter.

    Adresshanteringen följer trädstrukturen och använder : (kolon) som adress-separator.
    På så sätt kan varje enhet identifieras entydigt även när den ligger djupt i hierarkin.
    Anledningen till kolon är att det inte finns någon annat bra ledigt tecken som inte är \ eller /
    som desutom är möjligt att direkt skriva i en http url, _ är bäst om det är ledigt och - kan inte användas då det är en calc operator

    Ett exempel på användning av trädstrukturen är 1-Wire:
    • Man kan definiera en grupp av 1-Wire-bussar, där varje buss har sina egna enheter.
    • Grupperingen gör det möjligt att synkronisera mätningar: först skickas ett “start measure”
      till alla enheter, därefter väntar systemet en specifik tid innan avläsning sker samtidigt
      för samtliga sensorer. Detta är särskilt användbart för 1-Wire-temperatursensorer, som har en mättid.
    • Det går även att placera en 1-Wire-grupp i roten och därigenom ange en specifik refreshTime,
      eller om man endast använder en enstaka buss.
    • Om man bara har en enda 1-Wire-enhet kan den definieras direkt i roten,
      även där med stöd för refreshTime.
  • HALValue
    används som en generell datatyp för att hantera olika numeriska typer (int, uint, float) på ett entydigt sätt.
    Detta förenklar både läsning, skrivning och jämförelse av värden inom systemet.
  • ZeroCopyString
    ZeroCopyString fungerar i princip som std::string_view,
    men har mycket fler funktioner och används flitigt i hela ramverket för att minimera
    heap-fragmentering och onödig minnesanvändning.

    Exempel på funktioner:
    • ZeroCopyString SplitOffHead(char delimiter); - används för att skapa substrings utan att kopiera data.
    • Direkt konvertering till nummeriska värden: int32, uint32, float.
    • Hexvärden med prefix 0x känns automatiskt igen vid konvertering till uint32
  • Skriptmotor inspirerad av ESPEasy Rules.
    Motorn är tänkt att stödja både direkt exekvering av villkor i skriptets rot (t.ex. if-satser)
    samt händelsestyrd logik liknande Tasmota och ESPEasy. Event-hanteringen är ännu inte färdigutvecklad
    (har inte prioriterats).

    All logik bygger på att läsa och skriva värden från/till enheter. Namn på enheter baseras på de UID som
    tilldelas i JSON-konfigurationen.

    Viktiga detaljer
    • newlines normaliseras till endast /n
    • c/c++ kommentarer som // och /* */ är möjliga
      de fungerar enligt följande:
      // ersätter all text med space tills en newline hittas
      /* ersätter all text med space tills filens slut eller tills en */ hittas
      detta möjligör att tokenize funktionen inte behöver hantera onödig text och kommer därmed hoppa över alla whitespace
      egentligen nu när jag tänker på det så skulle det vara möjligt att göra det direkt i tokenize, men jag valde mest att göra så
      pga bättre debug möjlighet, men det gör också tokenize funktionen mycket enklare,
      se i slutet av inlägg för vidare funderingar hur detta kan förbättras
    • Skripten läses in ifrån en eller flera filer,
      men just nu i denna första utgåva så är multiskript inläsning inte helt implementerad
      de interna strukturena finns men för nuvarande är det hårdkodat till ett skript med följande katalog och namn: /scripts/script1.txt
      multiskript inläsning kommer ske genom att det finns en text-fil i scripts katalogen som helt enkelt kommer innehålla de script som ska vara aktiva
      kommentering inuti denna fil kommer följa samma struktur som scripts
      //inaktivtskript.txt
      aktivtskript.txt
    • Exekveringsmodell:
      • Villkor (if-satser) kan ligga direkt i skriptets rot.
      • Event-driven exekvering planeras men är ännu inte implementerad.
    • I/O: all interaktion sker mot enheter via deras UID.
    • Reserved keywords: if, then, do, elseif, else, endif, on, endon.
    • Identifierare:
      • Kan för närvarande börja med en siffra, men måste innehålla minst ett tecken ur
        a–z A–Z _ : # för att inte tolkas som ett numeriskt värde.
      • Numeriska värden känns igen om de består av endast siffror (0–9) och eventuellt en . för decimaler.
      • #-tecknet används för nuvarande för att kunna anropa enhetsspecifika funktioner eller värden
        t.ex. används det för att komma åt temperatur ifrån dht sensorer som primärt endast returnerar fuktighetsvärdet via read value funktionen
    • Operatorer: unära operatorer (!, -) stöds ännu inte (har ingen prioritet),
      vilket betyder att uttryck som !var eller -5*3 inte fungerar utan man måste
      explicit använda var == 0 samt (0-5)*3 .
  • Virtual devices
    fungerar som variabler och mellanlagring,
    vilket ger flexibla möjligheter att hantera data internt
    utan att lägga till extra hårdvaruenheter.
  • Systemet använder en indirekt dispatch-struktur för att köra skript effektivt på mikrokontroller,
    vilket ger låg overhead och stabil exekvering även vid många samtidiga enheter och triggers.
  • Webbaserad filhantering via FSBrowser.
    är det befintliga interfacet för att ändra cfg-json samt skript
    men kommer utveckla ett mer lätthanterligt interface senare
  • CommandExecutor (HAL_JSON_CommandExecutor.cpp)
    utför all utomstående HAL access samt funktioner som att 'reload' hal-cfg och scripts
  • REST API @ port 82
    och använder hela urls som kommandon exekveras av CommandExecutor
    följande grundfunktioner finns 'under' http://<ip_adress>:82

    /reloadcfg

    /scripts/reload
    /scripts/stop
    /scripts/start
    /getAvailableGPIOs
    /printDevices
    /printLog
    /exec/<enhets uid>

    /read/<typ>/<enhets uid>/<valfri enhets specifik cmd>
    /write/<typ>/<enhets uid>/<värde>

    <typ> ovan kan vara: bool, uint, float, string, json (som string men value är inte infogat inom "-tecken)
    <valfri enhets specifik cmd>: anropar enhetsspecifika värden och kan användas till DHT för att läsa av 'temp'
    <värde> i write funktion kan bestå av ytterligare / när string-typ används och gör att enhets-specifika kommandon kan utföras i en trädstruktur
    t.ex. har i2c bus subkommando raw som gör det möjligt att ha direkt åtkomst till i2c enheter utan att göra en specifik drivrutin
    detta är mest tänkt för att kunna testa små enheter direkt samt eventuell felsökning
  • framtida kommando källor så som mqtt/serial/websocket kommer också exekvera över CommandExecutor
    när utomstående access behövs
    windows miljön använder en command loop thread för att läsa kommandon som sedan skickas till CommandExecutor
  • framtida implemtering i home assistant kommer att göras när jag har tid för det.
  • GPL3
    jag har valt att använda GPL 3 som licens mest för att jag vill att det ska stanna vid att vara fullständigt open source
    dock har jag valt att göra enhetsregistren för root samt i2c
    MIT-licens så att man kan utveckla/implementera egna enhetstyper utan att behöva dela dem
    Jag är inte helt säker på om det ska vara MIT eller ska helt enkelt hela projektet bara vara GPL3
    vad tycker ni
Huvudsyftet är att ge en flexibel arkitektur för IoT-enheter,
där konfiguration och logik kan ändras utan omkompilering.

Systemet inkluderar stöd för tidsstyrda händelser med en modifierad version av TimeAlarms, där varje
alarm kan ha egna parametrar som skickas till callback-funktionen. För närvarande används en bas-klass,
OnTickExtParameters, för att lagra dessa parametrar, vilket även ger möjlighet till type-safe castning om
man vill. I praktiken skulle man kunna använda en enkel void*-pekare, eftersom det i den nuvarande
strukturen ändå är upp till användaren att hantera typ-säkerheten.
https://github.com/manicken/TimeAlarms


Lite information om hur strukturer laddas in samt vilka metoder jag använt för att minimera heap-fragmentering,
samt vilka framtida implemteringar som i framtiden kommer göra heap-fragmentering obetydlig

Inläsning as JSON-konfiguration:
  1. Filinläsning
    Konfigurationsfilen läses in som en const char*-buffer,
    vilket gör att ArduinoJSON kan använda en zerocopy-string-struktur (observera: detta är inte samma som min egen ZeroCopyString).
  2. Skapande av JSON-dokument:
    • Ett jsonDocument skapas med 1,5 gånger filstorleken som buffert, och används sedan för deserialize.
    • Dokumentet valideras först för att säkerställa att konfigurationen överensstämmer
      med de önskade strukturerna och obligatoriska värdena.
    • Om något obligatoriskt värde saknas eller är fel definieras hela JSON-konfigurationen som ogiltig.
  3. Inläsning till intern struktur:
    1. Räkna hur många giltiga konfigurationsposter (ej kommentarer eller disabled entries) som finns. Dessa markeras i en bool-array för steg 2.
    2. inläsning
      1. Radera befintlig konfiguration.
      2. Allokera plats för den nya konfigurationen – i basen bara en pekar-array av typen Device (bas-klass).
      3. Läsa in alla giltiga entries från JSON-dokumentet till den interna strukturen, detta allokerar ytterligare data.
    3. Deallokering av const char* buffer samt JSON-dokumentet
    Även om metoden med pekar-arrayer minimerar heap-fragmentering,
    är det faktiskt fördelaktigt att vissa allokeringar sprids i minnet.
    Detta utnyttjar RAM mer effektivt, eftersom små “hål” i heapen automatiskt fylls igen,
    vilket leder till bättre minnesutnyttjande över tid.

    Nackdelen med den nuvarande metoden är att det först allokeras minne för hela filen
    och därefter för JSON-dokumentets struktur. När dessa deallokeras i steg #4 skapas
    ett stort “hål” i heapen som endast kan fyllas av mindre allokeringar.

    En möjlig lösning är att, i steg #3, istället spara till en BSON-fil,
    som sedan kan läsas direkt utan extra minnesallokering efter steg #4.
    Detta skulle helt undvika det stora hål som annars uppstår och ge mer effektiv minneshantering.


    Inläsning av skript
    1. Validering:
      • Alla aktiva skript läses in ett efter ett.
      • Varje skript valideras för att säkerställa att:
        • Alla block är giltiga.
        • Alla enhetsnamn (device names) är korrekta.
        • Enheterna kan läsas eller skrivas till beroende på hur de används i skriptet.
      • Efter validering av ett skript så deallokeras använt minne.
    2. Inläsning till intern struktur:
    3. Om alla skript är giltiga läses de in igen ett efter ett, denna gång till den interna datastrukturen, redo för exekvering.

    Optimeringar för minimal heap-fragmentering och minnesanvändning
    • Kommentarer exkluderas redan vid inläsning:
      Innan filen laddas in görs en snabb scanning för att beräkna det faktiska minnesbehovet.
      Kommentarer räknas bort direkt och tas därför aldrig in i minnet,
      vilket både minskar storlek och fragmentering.
    • Kompilering av validerade skript:
      När skript väl har validerats översätts de till en optimerad, intern representation.
      Denna struktur är förkompilerad och kan laddas in direkt vid körning,
      utan onödiga tolksteg eller extra allokeringar.
    här finns ett hal cfg exempel:
    https://github.com/manicken/dalhalla-io ... r_cfg.json

    TimerAlarms json cfg exempel:
    https://github.com/manicken/dalhalla-io ... plate.json

    Script exempel:
    (den del som är bortkommenterad innehåller de olika funktioner som supportas)
    https://github.com/manicken/dalhalla-io ... cript1.txt



    Projektet heter Dalhalla och finns på GitHub:
    https://github.com/manicken/dalhalla-iot

    Jag tror det är ett bra namn,
    men kolliderar med utomhusarenan Dalhalla
    vad tycker ni om namnet?
    annars får det bara heta dalhal vilket nästan låter som en indisk maträtt, dal är redan det.

    Just nu använder hela namespace HAL_JSON som root namespace, och alla filer har även det som prefix.
    Detta kan komma att ändras när det slutliga namnet har fastställts.

    Dokumentationen är ännu inte färdig.
    Den här texten kommer att ligga till grund för GitHub README (på engelska),
    och jag tar gärna emot feedback och förbättringsförslag.