Hallo allemaal,

Af en toe kom ik iets leuks tegen of bedenk ik een stukje code dat misschien interessant is om te delen. Misschien kan iemand er iets van leren of het toepassen in eigen projecten. Geen idee of hier behoefte aan is, maar ik probeer het gewoon eens uit.

Dit voorbeeld laat een implementatie van een state machine zien in C. Het is zo opgezet dat het direct kan werken op een Arduino. Ik heb het bewust eenvoudig gehouden zodat de essentie van deze oplossing duidelijk is. Uiteraard kun je deze basis verder uitbreiden of optimaliseren, maar voor de leesbaarheid heb ik het zo simpel mogelijk gehouden.

Een klassieke switch-case state machine kan snel groot en onoverzichtelijk worden. Door de states op te splitsen in aparte functies, blijft elke state op zichzelf redelijk klein en overzichtelijk. Deze aanpak scheidt de verschillende onderdelen van elkaar, wat helpt om duidelijk te zien welke data tussen states gedeeld wordt en welke variabelen alleen binnen één specifieke state worden gebruikt. Op deze manier kun je elke state los beheren en uitbreiden zonder dat het geheel onoverzichtelijk wordt.

De code is opgesplitst in drie delen:

Framework Code: Dit deel bevat de basisstructuren en functies die de kern van de state machine vormen.
User Code: Dit deel bevat de specifieke logica per state, zoals het aan- en uitzetten van de LED.
Arduino Setup en Loop: Hier wordt de state machine geïnitialiseerd en periodiek aangeroepen.

Uitbreiding:
Als je variabelen wilt delen tussen verschillende states, kun je deze toevoegen aan de StateContext. Als je meerdere state machines in je code wilt gebruiken, zou je dit kunnen oplossen door een void pointer toe te voegen aan StateContext, die dan kan verwijzen naar een aparte structuur voor iedere state machine. In C++ zou je dit nog verder kunnen verfijnen met templates om de state machine flexibeler en specifieker te maken.

Je kunt helper functies toevoegen om de code eenvoudiger en leesbaarder te maken:
TimeInState(context): Geeft terug hoe lang de huidige state actief is.
IsFirstRun(context): Controleert of het de eerste keer is dat de state wordt uitgevoerd.
SetNextState(context, nextState): Stelt eenvoudig de volgende state in en zorgt ervoor dat isFirstRun goed wordt bijgewerkt.

Ik ben benieuwd of jullie het interesant vinden. Niet alleen specifiek deze code, maar ook het idee om voorbeeldjes te delen met elkaar. Mochten er ideeen zijn of punten die beter kunnen, ik hoor het wel. O-)


#include <Arduino.h>
#include <stdbool.h>

// --------------------
// Framework Code
// --------------------

// Forward declaration for StateContext struct and state function pointer type
typedef struct StateContext StateContext;
typedef void (*StateFunction)(StateContext* ctx); 

// StateContext structure holding information about the current state
struct StateContext {
    bool isFirstRun;                      // True only on the first execution after a state switch
    unsigned long stateStartTimeMs;       // Timestamp in milliseconds when the state was entered
    StateFunction nextState;              // Pointer to the next state function to switch to
};

// StateMachine structure that holds context and current state function
struct StateMachine {
    StateContext context;                 // Context for state management
    StateFunction activeState;            // Currently active state function
};

// Initializes the state machine with a given initial state function
void InitializeStateMachine(StateMachine* stateMachine, StateFunction initialState) {
    stateMachine->activeState = initialState;
    stateMachine->context.isFirstRun = true;               // Set isFirstRun to true initially
    stateMachine->context.stateStartTimeMs = millis();     // Initialize the state start time
    stateMachine->context.nextState = NULL;                // Initialize nextState to NULL
}

// Runs the state machine, handling state transitions and executing the current state
void RunStateMachine(StateMachine* stateMachine) {
    // Check if a state transition has been requested
    if (stateMachine->context.nextState) {
        // Update the active state to the requested next state
        stateMachine->activeState = stateMachine->context.nextState;
        stateMachine->context.isFirstRun = true;               // Set isFirstRun to true for the new state
        stateMachine->context.stateStartTimeMs = millis();     // Record the time of entry into the new state
        stateMachine->context.nextState = NULL;                // Clear nextState after transition
    }

    // Call the active state function with the context
    stateMachine->activeState(&(stateMachine->context));

    // After the first execution in the new state, set isFirstRun to false
    stateMachine->context.isFirstRun = false;
}

// --------------------
// User Code
// --------------------

// Forward declarations of state functions
void InitState(StateContext* ctx);
void LedOnState(StateContext* ctx);
void LedOffState(StateContext* ctx);

// State functions

// Initialization state function: sets the next state to turn the LED on
void InitState(StateContext* ctx) {
    ctx->nextState = LedOnState;  // Transition to LED ON state
}

// LED ON state function: turns the LED on and, after 5 seconds, requests transition to LED OFF
void LedOnState(StateContext* ctx) {
    if (ctx->isFirstRun) {
        digitalWrite(LED_BUILTIN, HIGH);  // Turn LED on
    }

    // Transition to LED OFF state after 5 seconds
    if ((millis() - ctx->stateStartTimeMs) > 5000) {
        ctx->nextState = LedOffState;
    }
}

// LED OFF state function: turns the LED off and, after 5 seconds, requests transition back to LED ON
void LedOffState(StateContext* ctx) {
    if (ctx->isFirstRun) {
        digitalWrite(LED_BUILTIN, LOW);  // Turn LED off
    }

    // Transition back to LED ON state after 5 seconds
    if ((millis() - ctx->stateStartTimeMs) > 5000) {
        ctx->nextState = LedOnState;
    }
}

// --------------------
// Arduino Code (setup and loop)
// --------------------

StateMachine mainStateMachine;  // Declare the main state machine

void setup() {
    // Initialize the LED pin as output
    pinMode(LED_BUILTIN, OUTPUT);

    // Initialize the state machine with the initial state
    InitializeStateMachine(&mainStateMachine, InitState);
}

void loop() {
    // Run the state machine
    RunStateMachine(&mainStateMachine);
    delay(10);  // Small delay to prevent excessive looping
}

EDIT: Een stukje motivatie voor deze oplossing. (Ik heb me laten helpen door AI om de tekst beter leesbaar te maken)

Modulair Ontwerp met Functiepointers:
Het belangrijkste idee is om elke state op te splitsen in een eigen functie en te werken met functiepointers om van state naar state te gaan. Dit is anders dan de gebruikelijke switch-case benadering, waarin de volledige state machine in één enkele functie is opgenomen. Door states te verdelen in aparte functies:

- Blijft de code van elke state overzichtelijk en klein.
- Is het duidelijk welke variabelen en data door alle states gedeeld worden en welke alleen voor een specifieke state nodig zijn.
- Kun je eenvoudig nieuwe states toevoegen of bestaande states aanpassen zonder het grotere geheel te verstoren.

Gebruik van een StateContext Structuur:
Een tweede principe is het gebruik van een StateContext structuur, die informatie bijhoudt over de huidige state. Dit zorgt ervoor dat gegevens zoals de starttijd en de gewenste volgende state netjes op één plek worden bewaard. Hierdoor kun je eenvoudiger tussen states schakelen zonder dat variabelen overal in de code worden bijgehouden. Dit maakt het onderhoud eenvoudiger en verbetert de leesbaarheid.

Flexibele Transities tussen States:
Door een functiepointer (nextState) te gebruiken voor de volgende state, kan elke state zelf bepalen wanneer hij naar een andere state moet overgaan en welke dat zal zijn. Dit maakt het mogelijk om dynamische en conditionele overgangen te creëren.

Duidelijke Afbakening van Framework en User Code:
Het ontwerp verdeelt de code in twee delen: het framework en de user code. Het framework bevat de basis functionaliteit van de state machine, terwijl de user code enkel de specifieke logica van de states beheert. Dit helpt om de code netjes en gescheiden te houden. Het framework biedt een herbruikbare basis voor de state machine, terwijl de user code eenvoudig kan worden aangepast voor verschillende toepassingen.

Overzichtelijke Overgangen en Tijdbeheer:
Door met timestamps te werken (zoals stateStartTimeMs in StateContext), kan elke state eenvoudig bijhouden hoe lang hij actief is geweest, zonder dat complex tijdsbeheer nodig is. Dit maakt tijdgebaseerde transities simpel en robuust, zelfs als er een overflow in millis() optreedt. Het resultaat is een betrouwbaar, tijdsgedreven gedrag dat flexibel genoeg is voor allerlei toepassingen.

[Bericht gewijzigd door hardbass op (15%)]

sorry als ik je ahrde werk misschien afzeik, maar een switch case is in feite al een state machine


unsigned int state = 0;

switch (state)
{
  case 0:                                  // init case
        //uw init hier
        state = 1;
        break;

case 1:                                    // first state
       // bla bla
       if (state1_finished)
       {
            state=2;
       }
       break;

case 2: 
       // bla bla
       if (state2_finished)
       {
            state=1;
       }
       break;

  default:
        println("illegal state selected");
}

ik wordt alleen al allergisch van de delay op het einde: een delay in een programma is voor mij een no-go na het eerste programma blink a led

unsigned int state = 0;

Sorry als ik je misschien afzeik, maar hier word ik allergisch van.

Een kale integer variabele als state. Over Switch vs. state functies kun je discussieren (ik vind een goed geschreven switch van 1000 regels uitstekend leesbaar), maar die state moet echt een enum zijn.


typedef enum state {EEN, TWEE, DRIE};
 ...

case EEN:
    state = TWEE;

En dan natuurlijk state-namen die relevant zijn.

(ohja, en die delay is een makkelijke manier om te yielden op een multitasking omgeving (ESP32 bijv). En yielden in je while loop is heel belangrijk)

een typedef gaf vroeger een hoop extra geheugen (oudere compilers) en daarom heb ik mezelf aangeleerd om states gewoon nummers te geven, maar moet je wel een legenda bij hebben idd. jouw oplossing is chiquer.

ik ben zelf nogal nummer geil, omdat ik veel typevouten maak en me dan het apenzuur zoek: zet erachter dan in commentaar wel waar ik heen spring. Echter het static deel is wel chique in een enum. Kun je nooit perongeluk overschrijven.

het ging mij er meer om dat je niet zo'n hele uitgebreide structuur nodig hebt.

Een state machine maak ik vaak eerst met een diagram voordat ik begin te coderen als meer dan een paar states heeft.

https://raw.githubusercontent.com/atoomnetmarc/AVR-demo-board/refs/heads/main/at90s1200/Firmware/Reaction%20Time%20Game/statemachine.svg

@high, heb je begrepen wat ik probeer te berijken met deze code?

Ik snap natuurlijk dat een switch case ook een statemachine is, maar het hele idee is om dat op te splitsen in kleinere stukken code die allemaal een specifiek doel hebben.

Die delay is uiteraard niet nodig.

Ik denk dt ik snap wat je probeert te bereiken, maar vind het heel moeilijk gedoe..

je filosofie om code te delen vind ik wel heel nobel en netjes.

en geef toe dat je wel hele leuke features hebt als: hoe lang je in een state zit, of een state voor het eerst aan geroepen wordt.

maar ik ben altijd van de nogal lean en mean code en dat is dit ZEKER niet.

ik ben WEL fan van je multithreading optie en je tier om de led aan en uit te zetten...

en @dino: je moet ooke erst uitschrijven hoe je states samehnagen, anders wordt het een zooitje.

@Henk Hoeveel geheugen de compiler nodig heeft is (zeker voor embedded!) eigenlijk nooit meer relevant.

En die teipvauten ook niet, iedere fatsoenlijke IDE (ook vi) kan dat dynamisch voor je oplossen.

Wat ervoor zorgt dat jij je aandacht kunt besteden aan wat echt belangrijk is: de juiste code.

@hardbass: Ik blijf een "oplossing zoekt probleem" gevoel houden bij die state-machine met functies. Het is qua concept veel lastiger dan een switch en je kunt lastig een overzicht krijgen welke states en state-overgangen er zijn.

Een netjes geprogrammeerde switch is ook verdeelt in kleine stukken code die allemaal hun eigen specifieke doel hebben.
Ze staan alleen allemaal in dezelfde scope, maar dat is geen wezenlijk probleem als je anders de hele state in een (pointer naar)struct meegeeft.

Hoe ingewikkeld moet je state-machine worden zodat een switch niet meer behapbaar worden? En is je state-machine dan uberhaupt nog wel de geschikte oplossing?

Bedenk dat je prima state-specifieke code in een functie kunt zetten die je vanaf de case voor die state aanroept.

Ik vind het een heel mooi initiatief.
Het zou nog mooier worden als de code stap voor stap uitgelegd wordt.
Mijn mening is dat veel mensen het noorden kwijt raken bij uitdrukkingen als
stateMachine->activeState = initialState;
met name het kortere pijltje notatie bij gebruik van pointers in dit geval.
Ik weet het, dit wordt dan een cursus C ; C++.
Maar eigenlijk vindt je bitter weinig verstaanbare uitleg daar omtrent.

Ik gebruik ook gewoon een switch, heel overzichtelijk als states geen al te complexe handelingen benodigen.
(bij heel erg lange switch states maak ik er wel een aparte functie van anders wordt het een onoverzichtelijke boel)

Luister maar niet naar het gezijk van Henk en Blurp.

Ik gebruik zelf ook een funktie pointer om de huidige state te onthouden. Door van elke state een aparte funktie te maken breng je meer struktuur aan. Via een pointer naar een funktie springen is ook sneller dan door een "switch()" heen lopen, ook al is dat verschil marignaal.

Met "arduino" (Geen hoofdletter van mij) ben je al (ongeveer) C++ aan het schijven. In mijn eigen programma heb ik een class gemaakt van de hele statemachine. Daarmee houd je dingen nog netter bij elkaar. Zowel de states, als de context worden dan een onderdeel van de class.

Die "delay( 10)" waar blurp over zeurt is natuurlijk ook flauwekul. In je statemachine zelf gebruik je al een betere timer om een wacht tijd te genereren. Die "delay( 10) is ook gemakkelijk te vervangen door een sleep(). Mits eerst goed geinitialiseerd natuurlijk, slaapt je AVR dan tot de volgende timer tick. Maar verder is het maar een voorbeeldje. Je kunt deze delay() ook weglaten.

Een stuktuur zoals dit vind ik een goed compromis tussen flexibiliteit, overzicht en leesbaarheid. Met een switch, moet je dan 20 blz lager gaan kijken of een haakje nog op de goede plaats staat. Yuch!

Als een state machine complexer wordt (Meer dan 10 of 20 states ofzo), dan worden randcondities en bugs bij overgangen naar een andere state steeds moeilijker om met de hand bij te houden. Tegen die tijd is het vermoedelijk beter om gespecialiseerde software te gebruiken die de code van de state machine genereert.

Ik ben het niet eens met het

if (ctx->isFirstRun)

stuk. In jouw struktuur gaat dit bijna zeker in elke state terug komen. Denk eens aan de volgende stuktuur:

1). Als een state wordt uitgevoert, dan doet hij gelijk zijn ding (Led Aan/ uit)
2). Je zet een variable voor een delay in de state context, en zet daar een waarde in.
3). Je verandert de actieve state meteen naar een "delay" state. Je verantert hier de funktie pointer zelf, dus niet de ctx->nextState.
4). Netjes dat je voor je delay een tijdverschil uitrekent :) Deze code kun je (1 keer) in je "delay" state zetten. Als de delay is afgelopen wordt dan ctx->nextState in de funktie pointer gezet.

Op deze manier kun je de "delay state" gebruiken om alle state overgangen te timen. Als het voor bepaalde overgangen niet goed werkt, dan kun je voor die gevallen wat anders maken.

Op maandag 11 november 2024 16:34:56 schreef High met Henk:
en @dino: je moet ooke erst uitschrijven hoe je states samehnagen, anders wordt het een zooitje.

Voor mij ziet hij er compleet uit.
Ik begin altijd met een stuk 'Nederlandse' tekst. Dat is de specificatie.
Aan de hand van de specificatie maak je het diagram volgens het voorbeeld van @dino7. Dat is het bolletjesdiagram of oneerbiedig genoemd 'ballendiagram'.
Aan de hand van het ballendiagram schrijf je de code en die laat je lopen.
Als het niet goed is loopt op een bepaald moment je machine vast. Dan kijk je in welke toestand de 'ballen' (rondjes) en de 'vlaggen' (de lijnen tussen de rondjes) verkeren. Dan ga je heel goed kijken waarom hij niet deze toestand kan verlaten. Je herstelt dan de fout en indien nodig pas je je specificatie aan.
Het bolletjesdiagram is het skelet van je programma en is geen afbeelding van je programma, dit in tegenstelling tot een flowdiagram.
En uiteraard is het bolletjesdiagram taalonafhankelijk.

Op maandag 11 november 2024 17:29:40 schreef Kortsluiting_Online:
Luister maar niet naar het gezijk van Henk en Blurp.

Inderdaad.
Een switch / case constructie wordt in vrijwel alle gevallen als die wat groter wordt een grote bende.

@hardbass: Dit is een redelijk klassieke manier om netjes een statemachine te maken. Dus ben ik het er zeker mee eens.

Waar ik wel een bloedhekel aan heb is alle sub-functies boven aan zetten. Dus "bottom up" programmeren. (Ik noem het lamliederig programmeren)
Ik begin altijd met de main code boven aan dan naar onder werken met je functies, de belangrijkste eerst tot de onderste laag onder in. Gelukkig heb je wel de prototypes / aka forward declaraties er toch in staan maar de functies staan nog steeds boven de main().

Verder zou ik de statehandler zelf in een aparte file zetten. Dan is het ook duidelijk en gescheiden wat tot je "business" logic hoort en wat de onafhankelijke state handler is. Maar ja in de arduino commiunity rommelt men maar wat aan, dat is ook de reden dat ik zoiets vrijwel nooit gebruik.

Een nog mooiere method is ook echt C++ gebruiken. Een class maken per state afgeleid van een base-class. Met een entry/exit/run/test-condition state etc. as je toch een arduino hebt, want dat is hoofdzakelijk C++ wat onder water gebruikt wordt.

@dino7 : Ja dat is het eerste wat je moet doen, een zgn state-diagram maken, anders kun je niks fatsoenlijks maken.

Op maandag 11 november 2024 19:35:42 schreef henri62:
[...] Inderdaad.
Een switch / case constructie wordt in vrijwel alle gevallen als die wat groter wordt een grote bende.

@hardbass: Dit is een redelijk klassieke manier om netjes een statemachine te maken. Dus ben ik het er zeker mee eens.

Leuke invalshoek voor een state machine!

Wat ik zelf meestal doe met wachttijden is de nieuwe tijd één keer berekenen en daarmee vergelijken.
Dan verlies je niet steeds tijd, fwiw, met die berekening.
Bijv:


...
exitTime = millis() + 5000;
...
if(millis() > exitTime)...

Alleen jammer van die openingsaccolade op dezelfde regel <kuch>flamewar<kuch> ;)

Op maandag 11 november 2024 16:15:57 schreef blurp:
Een kale integer variabele als state. Over Switch vs. state functies kun je discussieren (ik vind een goed geschreven switch van 1000 regels uitstekend leesbaar), maar die state moet echt een enum zijn.

Dat hangt er van af. Als je een algemene "state machine interpreter" maakt, met een tabel van condities, nextstate en "action-to-perform" dan moet het een integer zijn.

Maar als je de states opsomt en uitwerkt in je code zonder een statemachine-tabel-interpreter te schrijven, dan heb je gelijk.

@picsels:
ALs je dan ook nog iets doet als


nextcheck = nextcheck + CHECKTIME_MS;

dan krijg je niet dat de check steeds iedere zeg 5.1 sec gebeurt terwijl je 5sec had gewild. (hetgeen je krijgt als de

nextcheck=millis () + CHECKTIME_MS; 

achter de "doe-de-check" staat en de check dus 0.1 sec in beslagneemt.

[Bericht gewijzigd door rew op (22%)]


...
exitTime = millis() + 5000;
...
if(millis() > exitTime)...

Ook jammer dat dit niet werkt igv overflow. 8)7

Op maandag 11 november 2024 22:05:18 schreef deKees:
Ook jammer dat dit niet werkt igv overflow. 8)7

Een kniesoor die daar op let. Meestal gaat het goed. :+

Meestal gaat het goed

Wie dat aanvaardt kan beter knollen gaan planten. Hardware kan al eens misgaan, want tegen de fysica kan niemand op; maar software moet 100% waterdicht zijn.

(maar het emoticon ging niet ongemerkt voorbij ;) )

[Bericht gewijzigd door Paulinha_B op (13%)]

Ik loste dit soort problemen op door, indien nodig, een offset toe te voegen.
Het is niks anders dat klokrekenen.
Vb: Wat is het kleinste tijdsverschil tussen 2 minuten voor het uur en 2 minuten na het uur?
Oplossing: Hier heb je te maken met een minutenoverflow.
Bij alle twee de tijden tel je 30 seconde erbij op en als er een nieuwe tijd is die groter is dan 60 seconde, dan trek je er 60 seconde vanaf. Met een beetje mazzel gaat dat aftrekken 'vanzelf' ivm de overflow.
t1 = 58s -> t1 = 58s + 30s = 88s -> t1 = 88s - 60s = 28s.
t2 = 2s -> t2 = 2s + 30s = 32s.
t2 - t1 = 32s - 28s = 4s
Soortgelijke berekening kan je ook met millis() doen.

Uit arduino documentatie:

millis() will wrap around to 0 after about 49 days

Als je binnen de 49 dagen je arduino uit- en aanzet, dan heb je geen last van overflow.

Dat kan, maar waarom zou je.

Als je eerst het verschil uitrekent dan gaat het altijd goed, ook igv overflow. Mits alle variabelen hetzelfde type hebben, in dit geval 'unsigned long'


  if ((millis() - StartTime) > Delay)
  {  StartTime += Delay;
     ... 

Ongeveer 49 dagen, inderdaad. Om precies te zijn 4294967296 milliseconden.

[Bericht gewijzigd door deKees op (12%)]

@deKees,
Ik snap je betoog en je korte programma niet, maar gevoelsmatig zal je toch hinder van je overflow hebben.

Gaat StartTime ongeveer naar 49 dagen toe en blijft het daar uiteindelijk staan?

[Bericht gewijzigd door ohm pi op (27%)]

Op maandag 11 november 2024 21:39:22 schreef rew:
[...]Dat hangt er van af. Als je een algemene "state machine interpreter" maakt, met een tabel van condities, nextstate en "action-to-perform" dan moet het een integer zijn.

Alleen als je state een rechtstreekse index in de tabel moet zijn, EN het niet mogelijk is om dat mogelijk te maken door de enum (onder water gewoon een int) zelf te assignen.

Natuurlijk kun je een situatie hebben waarin je state machine letterlijk bestaat uit enkel die tabel en de algemene opzoek/uitvoer functie. Dan refereer je elders in de code ook nooit aan de state, en maakt de leesbaarheid niet veel meer uit.

Op maandag 11 november 2024 17:29:40 schreef Kortsluiting_Online:
Via een pointer naar een funktie springen is ook sneller dan door een "switch()" heen lopen, ook al is dat verschil marignaal.

Premature optimization is the root of all evil.

Veel van de statemachines met functiepointer die ik gezien heb zijn een ongelofelijke ratjetoe van verwijzingen, gerelateerde code in verschillende source-files, en met een beetje pech ook nog een gruwelijke lasagne aan class-inheritance.

Dan is een switch van 10 paginas al snel overzichterlijker.

Dat betekend niet dat een switch altijd beter is, maar zoals altijd: kies de juiste methode voor je probleem, en laat je niet gek maken door mensen die zeggen hoe het heurt.

Met een switch, moet je dan 20 blz lager gaan kijken of een haakje nog op de goede plaats staat. Yuch!

Daar heb je een IDE voor.

Als een state machine complexer wordt (Meer dan 10 of 20 states ofzo), dan worden randcondities en bugs bij overgangen naar een andere state steeds moeilijker om met de hand bij te houden. Tegen die tijd is het vermoedelijk beter om gespecialiseerde software te gebruiken die de code van de state machine genereert.

Absoluut. Of je ontwerp te heroverwegen, of je wel zoveel states nodig hebt en of je het niet op (kunt) moet delen.

ik vind de benadering van je statemachine framework niet verkeerd.
Ik geef eerlijk toe dat ik voor het gemak ook vaak een switch case gebruik, maar dan zijn het vaak pure sequentiele statemachientjes van hooguit een state of 8.
Je ziet toch vaak dat je in elke state wat zelfde checks wil hebben, zoals timeout een first time etc.
Dat kun je dus prima generiek afhandelen. Met losse functies voor state focus je je inderdaad wel meer per state.
Dit soort oplossingen leent zich erg goed als je heel vaak statemachines implementeerd die allemaal in dit framework passen. je hoeft je dan na eenmalig goed testen niet meer te focussen op de functionaliteit of het statemachine framework werkt. Maar bekijk je het per case.
Ik heb ook soort gelijke implementaties gezien, maar dan nog een stap verder, en rew haalde die ook al aan.
Dan heb je echt een tabel, met state functie pointers, alle events voor die state als functie pointers en relaties met andere states.
Op deze manier kun je wel een stuk eenvoudiger een wat generiekere unittest maken om je statemachine door te testen, waarbij het ook belangrijk is niet de echte flow van je statemachine te doorlopen om een bepaalde state te testen.
Ik heb niet inhoudelijk gekeken naar je implementatie, maar vind het altijd wel mooi dat mensen proberen iets wat generieker te maken. Dat is inderdaad in eerste instantie niet altijd beter leesbaar of overzichtelijker, maar het kan wel rbooster, minder fout gevoelig en beter onderhoudbaar zijn

Haha, er barst een leuke discussie los, volledig volgens verwachting en precies de bedoeling. :) Interessant om te zien hoe anderen denken en werken. Dat helpt bij het vinden van oplossingen voor de nadelen van deze code.

Misschien goed om te vermelden, dit is natuurlijk voor een knipperledje totaal overbodig. Voor kleine statemachines is een switch case prima.

Complexiteit:
Ja, deze implementatie gebruikt pointers, waaronder functiepointers. Persoonlijk vind ik dat dit bij de basiskennis van een C-programmeur hoort. De pijlnotatie en het werken met functiepointers zijn nu eenmaal onderdeel van de taal. Zoals ik in mijn startpost al aangaf, kun je met helperfuncties de complexiteit verder abstraheren. Denk bijvoorbeeld aan een helperfunctie `SetNextState(context, MyState);`, die het instellen van de volgende state makkelijker maakt zonder direct met functiepointers te werken.

isFirstRun:
@Kortsluiting_Online: Ik begrijp je voorstel als alternatief voor `isFirstRun` niet helemaal. Zou je een voorbeeld kunnen geven om dit verder toe te lichten?

Structuur en Volgorde van Code:
Ik ben het eens met de opmerkingen over het opsplitsen van de code. Normaal gesproken houd ik de `main` klein. In dit specifieke voorbeeld zou ik de code opdelen in drie delen:
State Machine Code: Een bestand met de logica van de state machine zelf.
Main Code: Een bestand voor de `main` functie.
State Logic: Een bestand voor de verschillende states en hun specifieke logica.

TimeInState:
De manier waarop `TimeInState` is opgezet voorkomt overflow problemen, zoals deKees ook al aangeeft. Omdat de waarden unsigned zijn, wordt de berekening bij overflow automatisch gecorrigeerd. Dit is robuuster dan werken met absolute tijdsverschillen. Bovendien geeft het je flexibiliteit om bijvoorbeeld met echte timestamps te werken (zoals `time()`), wat handig kan zijn bij logging of bij andere applicaties. Als je enkel bijhoudt hoelang je al in een state zit, verlies je de precieze starttijd en daarmee een stuk detail. Met de starttijd kan je de rest berekenen.

C++:
Op mijn werk programmeren we in C++, en dat biedt extra mogelijkheden om dit concept mooier te maken. Ik zou bijvoorbeeld kiezen voor een `StateMachine` class, waarin de states en context beter gestructureerd zijn via templates. De states zelf zouden ook classes kunnen zijn, al brengt dat extra overhead met zich mee. Voor complexe state machines kan dit nuttig zijn. In dit voorbeeld houd ik het echter bewust simpel om het concept goed over te brengen.

Een extra voordeel in C++: je kunt variabelen in de context private maken en enkel toegankelijk via functies zoals `SetNextState(MyState);`. Dat houdt de context en de statemachine interface schoon.

Uitleg:
Ik zal inderdaad nog een korte uitleg toevoegen aan de startpost om de structuur en werkwijze van de code toe te lichten.

Op dinsdag 12 november 2024 01:44:26 schreef ohm pi:
@deKees,
Ik snap je betoog en je korte programma niet, maar gevoelsmatig zal je toch hinder van je overflow hebben.

Dat klopt... Maar je gevoel heeft het verkeerd. Om de getallen behapbaar te houden, doen we even alsof het in 8 bits is. Het verschil MOET je in een signed variabele zetten.

Stel het is nu tijdstip 5, we willen een timeout over 20 tijdstapjes. We rekenen uit eindtijd is 25. Doe je dan op t=10 je huidigetijd-einddtijd dan is dat 10-25 = -15... Nog geen tijd.
Wordt het t=26 dan krijg je 26-1 = 1: Het is tijd om iets te doen.

Dit werkt prima tot we op t=245 de timeout willen zetten... eindtijd wordt 245+20 = 265, maar dat past niet in 8 bits je krijgt een wraparound en eindtijd is 9.

Nu check je op t=250 en rekent uit: 250 - 9 = 241 = -15 in signed-8 bit. Nog geen tijd: niets doen.

Op t= 260 = 4 na de wraparound... krijg je 4-9 = -5 nog steeds VOOR de timeout -> Niets doen.

Op t=266=10 krijg je 10-9 = 1: positief: wel wat doen!

Ik raad aan om zowel je get_current_time() functie als de timeouts in signed te doen. Dan is het verschil van nature direct ook signed.

Je hebt er niets aan als je

if ((millis () - endtime) > 0) 

gaat doen. Omdat je nu nul NIET meeneemt krijg je geen waarschuwing van de compiler. Als je >= had geschreven had de compiler gezien: Dat is altijd waar als het om een unsigned gaat.

Als je dan vindt dat de belangrijke wraparounds bij 127 -> -128 gebeuren, mij best. Ook daar blijft alles werken, maar ik heb geen zin om dat nu te gaan uitschrijven. Oefening voor de lezer. :-)