Gå till innehållet

Electrokit Education Shield

Electrokit Education Shield är en shield för Arduino Uno och kompatibla utvecklingskort. På kortet finns en kombination av sensorer, kontroller och andra komponenter som tillsammans möjliggör många experiment och projekt. Till kortet hör också den här guiden och många programexempel. Allt har skrivits specifikt för Edu Shield.

Edu shield

Edu Shield är lämplig för skolor och alla som vill lära sig grunderna i embedded-programmering, eller som plattform för utbildningar och laborationer inom IoT, gränssnitt, sensorer, automation m.m. Också för den som är van vid programmering kan Edu Shield vara en bra plattform för snabba skisser i elektroniklabbet.

Från grunderna bygger vi gradvis upp till mer avancerade program. Vi visar hur man kan använda sensorer, knappar, högtalaren och annat på kortet, både var för sig och tillsammans. Från en enkel blinkande lysdiod för vi gradvis in nya komponenter och delar av programmeringsspråket.

Exempelprogrammen bildar musikinstrument, larm, spel, mätinstrument, med mera, och går att kombinera och anpassa på olika sätt. I den här guiden går vi igenom alla programmen steg för steg, och kommentarer i koden förklarar ytterligare.

Första gången något dyker upp i ett program så får det också en kommentar. När de återkommer i senare program nämns de oftast inte.

Istället för att inleda med massor av teori så tar vi upp den gradvis, vart eftersom det blir relevant. Alla termer och tecken går att slå upp i index. Vi har också samlat lathundar med information om programmering, tabeller m.m.

Hårdvaran

Komponenterna på Edu Shield kan antingen ta in information (t.ex. knapptryck eller ljusstyrka) från omvärlden, eller påverka den på olika sätt (t.ex. styra servomotorer och spela toner). Alla är uppmärkta med den in- eller utgång de har i Arduinos programmeringsmiljö, t.ex. LED1 (pin D11) eller Temp (pin A3).

Kompatibla utvecklingskort

Programmen är utvecklade med en Uno R3, men har även testats på en Uno R4. Ett fåtal av programmen fungerar lite annorlunda på R4, men alla fungerar att köra. När det står Uno R3 i texten menar vi en Uno med en Atmega 328p som mikrokontroller, oavsett märke.

Fysiskt så är Edu Shield kompatibel med alla utvecklingskort i Uno-formatet, och den fungerar med kort som har 5V eller 3.3V arbetsspänning. Den går bra att använda med Unos med andra processorer, t.ex. Atmega 32u4, även om koden kan behöva anpassas.

Du ska också veta att du inte kan skada Edu Shields hårdvara genom att skriva ”skadliga” program – så experimentera fritt!

Kontrollerna

Det finns typer av kontroller på Edu Shield: tre taktila tryckknappar, en rotationsenkoder (med inbyggd tryckknapp), och en potentiometer. Genom att kombinera dem kan man styra Arduinon på många olika sätt.

tryckknappar rotationsenkoder potentiometer Tryckknappar, rotationsenkoder och potentiometer.

Sensorer

Edu shield har också två sensorer. En är en analog temperatursensor, som kan mäta temperaturer från -⁠40°C till +150°C. Observera att varken Edu Shield eller din Uno lär tåla det temperaturspannet! Den andra sensorn är en ljussensor, som också ger en analog signal. Den fungerar ungefär som en fotoresistor (LDR), och kan användas i många kul projekt.

temperatursensor ljussensor servoutgång Temperatursensor, ljussensor samt servoutgång.

Utgångar

Förutom att läsa av kontroller och sensorer kan Edu Shield också påverka omvärlden på olika sätt. Den kan till exempel lysa, med både tre lysdioder och en digital adresserbar RGB-lysdiod. RGB-LED:en är en WS2812 – mer känd som NeoPixel. Vid NeoPixeln finns det en kontakt för att expansdera med LED-slingor, ringar och andra NeoPixel-kort. På servo-utgången kan man enkelt koppla in och styra en servomotor. Edu Shield kan också låta, med en liten enkel högtalare som har en egen drivkrets. Den kan spela ljud för att t.ex. indikera, varna eller gratulera. Ihop med kontrollerna kan man göra många kul musikinstrument.

lysdioder neopixel högtalare Lysdioder, NeoPixel, och högtalare.

Seriella portar och GPIO

Edu Shield har också kontakter för seriell kommunikation. På Qwiic-kontakterna kan man koppla in mängder av olika påbyggnadskort med sensorer, displayer, kontroller, adaptrar och omvandlare, m.m. Man kan också koppla den till andra Edu Shields och bilda ett litet nätverk. Arduinons UART-port finns också framtagen på en egen kontakt. Där kan man koppla in en annan Uno, men såklart också mängder av andra kort och apparater som använder UART-protokollet. En vanlig stiftlist eller hankontakt sitter fast bra med bara friktion. Den symmetriska kontakten gör det lätt att koppla ihop två olika Edu Shields och kommunicera.

Till sist finns det en ”fri” pin från Unon, som fungerar som en GPIO(1). Där kan man koppla in t.ex. reläkort, drivkretsar, eller olika analoga sensorer. Det är helt enkelt en av Arduinons in/utgångar på en kontakt.

  1. GPIO står för General Purpose Input/Output – in- och utgångar för allmänt bruk.

qwiic UART potentiometer Qwiic-kontakter, UART och GPIO.

Reset och AREF

På kortet finns två ytterligare omkopplare, som inte rör Arduinons in/utgångar. Den ena är reset-knappen, som gör samma sak som Unons inbyggda: den startar om mikrokontrollern. Med den kan man starta om sitt program med ett knapptryck – väldigt användbart. Den andra är en skjutomkopplare som är märkt AREF. Den används för att välja referensspänning (3.3 eller 5V) för A/D-omvandlaren, sensorerna och potentiometern.

Reset-knapp och AREF-skjutomkopplare Reset-knappen och skjutomkopplaren för AREF. Dessa två omkopplare är inte kopplare till Arduinons vanliga in- och utgångar.

Förberedelser

För att komma igång behöver du följande:

  • En Electrokit Uno Education Shield
  • En Arduino Uno R3/R4 (eller annat Uno-kompatibelt utvecklingskort)
  • En USB-kabel till din Arduino
  • Vissa exempel kräver också externa komponenter, t.ex. en 3-polig labbsladd och stiftlister, eller ett servo.
  • Du behöver också ladda ner och installera Arduino IDE
  • Alla exempelprogrammen finns att ladda ner i en .zip-fil här

Arduino IDE

Den här texten utgår från att du använder Arduinos egen utvecklingsmiljö, programmet Arduino IDE. Det är lätt att använda och har inte så väldigt mycket olika inställningar – perfekt för nybörjare.

Du kan ladda ner programmet på https://www.arduino.cc/en/software/#ide.

Alla programmen för Edu Shield är skrivna i Arduino IDE. Vi går igenom vissa av programmets funktioner varteftersom.

Sketches

När du skapar egna program (Arduino kallar dem för sketches ­– skisser) kommer du snart märka vissa av Arduino IDE:s egenheter. Filerna får till exempel bara innehålla engelska bokstäver, siffror och punkt, bindestreck eller understreck. Inga mellanrum eller åäö är tillåtna (programmet varnar om du försöker). Själva programfilerna sparas i formatet .ino. De behöver också ligga i varsin mapp, som måste heta som programmet – annars kan IDE:n inte öppna dem. När du sparar ett program första gången skapas en mapp automatiskt.

Varning för autosave

Arduino IDE har en inbyggd autosave som vi vill varna för. Den gör inte riktigt som man vill eller väntar sig, och har en förmåga att spara över ens dokument, något som har erfarits under arbetet med detta material. När man programmerar vill man ofta prova saker fram och tillbaks i sin kod, och då behöver man kunna ångra sig. Om man låter IDE:n spara automatiskt finns en risk att dåliga ändringar blir kvar när man stänger sitt dokument (eller om något kraschar). Med autosave avstängt frågar programmet om man vill spara. Så stäng av det nu, eller ångra dig senare. För att stänga av funktionen, gå till inställningar och kryssa ur autosave.
Det är en bra idé att gradvis spara nya versioner av sina program (mittprogram1.ino, mittprogram2.ino, osv), eller testa saker i nya dokument där man klistrar in kod man vill experimentera med utan att riskera att skriva över något.

Läs mer och få hjälp

Programmen i den här texten är skrivna i C++, med en del Arduinospecifika funktioner. Om du vill läsa mer eller kör fast, så finns hjälp att finna i både Arduinos egen dokumentation på https://docs.arduino.cc/language-reference/ och på deras diskussionsforum, https://forum.arduino.cc/. Ofta har någon annan frågat om det du undrar, så sök! Det finns också bra information om C++ på https://cppreference.com/w/cpp.html.

Få kontakt med din Uno

Börja med att sätta in din Edu Shield i din Uno. Se till att alla stiften går in rakt i Unons hylslister. Du ska inte behöva trycka hårt. Koppla sedan in din Uno i datorn med en USB-kabel. Öppna sedan Arduino IDE.

Nyare UNO-varianter väljs ofta automatiskt av programmet. Om din arduino dyker upp kan du gå vidare till del 1. Annars, läs vidare.

Gå till menyn Tools > Board: och gå till listan Arduino AVR Boards och välj din Uno-modell. Den vanligaste sorten heter bara ”Arduino Uno”. Många Uno-kompatibla kort använder samma mikrokontroller (Atmega 328p) som Arduino Uno. Välj Arduino Uno i listan om du har ett sådant kort.

Välja port i Arduino IDE<

Om du har någon Uno-variant med en annan mikrokontroller behöver du välja den, och kanske också lägga till kortet i IDE:n. Det gör man Tools > Board: > Boards Manager.

När du har valt rätt typ av kort ska du gå till Tools > Port: och välja just din Uno (eller rättare sagt dess port) i listan. Om du är osäker på vilken port din Uno har så kan du koppla ur din UNO och titta vilken port i listan som försvinner – det är den du ska välja.

1: Grunderna

Nu ska vi programmera. Ett sätt att se på programmering är att man skriver instruktioner. Man kan tänka på det ungefär som ett recept, eller ett schema med olika uppgifter som ska utföras vid olika tillfällen eller händelser. Det behöver egentligen inte vara komplicerat. Till exempel:

”tänd lampan om någon är vaken och solen har gått ner”

eller

”koka makaronerna tills de är mjuka”

Sådana instruktioner går bra att ge till en annan människa, men vi kan inte skriva kod så. För att en dator ska förstå något måste instruktionerna skrivas på deras språk. Precis som för mänskliga språk så har olika programmeringsspråk olika regler. Reglerna brukar kallas för syntax.

Att programmera handlar till stor del om att bryta ner sitt ”recept” i små beståndsdelar och beskriva delarna så att datorn kan förstå. Sedan kan man kombinera dem på olika sätt och skapa ett större program.

Stavning och grammatik

Om du lsäer en menign med nårga stafvel så här förtsår du nog. Du kan gissa och tolka vad orden ska vara. Arduino IDE (och andra liknande program) kan inte göra det. Därför måste varje tecken vara rätt när man skriver kod. Programmet gör dessutom skillnad på stor och liten bokstav: för oss är arduino och Arduino (ungefär) samma sak, men för en Arduino är det stor skillnad!

Inte heller kan man slarva med ”grammatiken” Om vi glömmer att avsluta en mening med punkt så gör det inte så mycket. Men när man programmerar måste alla skiljetecken vara på rätt plats.

Arduino IDE kan hjälpa till genom att till exempel avsluta ord åt en, eller automatiskt skapa slutparenteser. Om man markerar en parentes kan IDE:n också visa dess ”partner”. Det kan underlätta när man har råkat få för många (eller för få) parenteser i sin kod.

Du kommer märka att olika ord och tecken har olika färger i Arduino IDE. Det är för att det ska vara lättare (för människor) att läsa programmen, och sker automatiskt.

Det svåra med programmering är egentligen inte stavningen och grammatiken, eller att komma ihåg vilka ord som betyder vad. Det lär man sig snart, och moderna språk och utvecklingsmiljöer blir allt mer tillåtande och hjälpsamma. Det kluriga är att veta vad man ska skriva. Det är det vi kommer fokusera på. Skrivreglerna tas upp vart eftersom, när det behövs.

Vi kan börja med ett viktigt exempel:

Parenteser

Parenteser är viktiga i C++ (och många andra programmeringsspråk). Det använder tre olika sorter, som har olika funktioner: (), [] och {}.

(Varje inledande parentes måste ha en avslutande slutparentes av samma sort.)

Det går att ha (parenteser [inuti andra] parenteser), men de måste paras ihop med samma sort. Att [skriva (så] här) fungerar inte. (Parenteser kan däremot börja på en rad,
och sluta på en annan).

Beståndsdelarna i ett program

Ett program i C++ kan delas in i tre delar: instruktioner (eng. statements), variabler och funktioner.

Instruktioner är kod som gör något. Till exempel att utföra en operation eller spara ett värde. Det är instruktionerna som styr programmets flöde, vad som händer när. En instruktion avslutas alltid med semikolon – alltså tecknet ;

Variabler är en sorts behållare, där information kan lagras och hämtas.

Funktioner är små program. De kan innehålla instruktioner, använda variabler, och också anropa andra funktioner.

När händer det som står i koden?

Koden i ett program utförs rad för rad, uppifrån och ner. Den ordning instruktioner står skrivna inuti en funktion är alltså viktigt. Koden utförs uppifrån och ner. När funktioner börjar anropa andra funktioner kan programmet börja ”hoppa runt”. Då blir det blir svårare att få en överblick över vad som sker och när. Om man inte håller reda på det kan ett program börja göra konstiga saker. Om man t.ex. inte har sparat värdet ”fem minuter” i variabeln ”koktid” innan instruktionen ”koka makaroner” läser tiden kan det bli så att makaronerna kokas noll minuter. Hårt!

Funktioner och argument

Alla funktioner måste ett namn, t.ex. minFunktion. Namnet får inte innehålla mellanrum, min funktion fungerar inte. Att det är stor F i Funktion är bara för att det ska bli lite lättare att läsa.

De flesta funktioner har också ett eller flera argument. Argument är sättet vi styr funktioner. De skrivs inom parenteser efter instruktionens namn. För att skilja argumenten åt används kommatecken: minFunktion(argument1, argument2, argument3).

Om en funktion inte behöver några argument så måste man ändå skriva ut parenteserna: minFunktion(). Så skriver vi också i den här texten, liksom i liknande guider och dokumentation.

Argumenten för en funktion måste också skrivas i en viss ordning. Vilken ordning beror på hur funktionen är skriven.

Ofta skriver man siffror som argument, t.ex. 5, 100 eller -12.3, men man kan också skriva variabler, t.ex. koktid, eller skriva in andra funktioner som argument.

Funktioner har ofta vissa krav på argumentens innehåll. Det kan t.ex. vara att talen måste vara positiva. Om man skriver en koktid på t.ex. -5 så kommer programmet inte fungera. I en del fall kan funktioner hantera det och t.ex. acceptera talet 2 när den egentligen skulle vilja ha något av talen 0 eller 1.

Hur vet man då vilka argument en funktion kräver? Det finns två bra sätt att ta reda på det. Ett är att hålla musen över en funktion, och så kommer det upp en liten ruta som listar argument, vad de heter och datatyp (mer om datatyper längre fram). Det andra sättet är läsa om funktionen i Arduinos dokumentation. Där står det lite mer utförligt och i klartext om hur funktioner fungerar. Det går också bra att bara experimentera och se vad som fungerar! Samt, att läsa vidare i den här texten.

what Funktionen tone() tar tre argument: en uint8_t för pin, en unsigned int för frekvens samt en unsigned int för duration. Den sistnämnda har ett standardvärde, 0. Oroa dig inte om du inte förstår - allt förklaras längre fram.

Returnera

Många funktioner kan returnera något. Att returnera betyder ungefär att en funktion ”ger” ett värde. Om en funktion returnerar talet 5 så blir resultatet samma som om vi hade skrivit 5 istället för funktionen. Det är så man kan använda funktioner som argument för andra funktioner. Fördelen med funktioner istället för att skriva siffror är att en funktion kan returnera olika saker vid olika tillfällen, medan siffran 5 ju alltid är siffran 5. Det finns också funktioner som inte returnerar något alls. De kan ändå göra andra användbara saker.

Längre fram kommer vi skapa egna funktioner. I början nöjer vi oss med de som ingår i Arduino. Två av dem måste finnas med i ett Arduinoprogram: setup() och loop(). De andra vi ska använda i det första programmet heter digitalWrite() delay() och pinMode().

Vi börjar med en klassisk Arduino-introduktion. För att vara lite pedagogiska har vi först gjort en väldigt avskalad version av programmet.

uppladdningsknappen Uppladdningsknappen laddar upp programmet till Unon.

Öppna filen 1a1_blink_basic.ino i IDE:n. Tryck på pilknappen uppe till vänster, eller ctrl + u / cmd + u om du föredrar snabbkommandon. Vänta på att programmet laddas upp till din Arduino.

Programmet:

1a1_blink_basic.ino
void setup() {
  pinMode(11, 1);
}

void loop() {
  digitalWrite(11, 1);
  delay(1000);
  digitalWrite(11, 0);
  delay(1000);
}

När uppladdningen är klar börjar den mittersta lysdioden, LED2, börja blinka – en sekund tänd, en sekund släckt. Så här går det till!

setup(): förberedelser

(Tills vidare struntar vi i vad void innebär).

setup() är som namnet antyder något man kan göra för att förbereda för ett program. Den kod som står i funktionen – alltså {innanför dess klamrar} – kommer bara att utföras en gång. Detta görs direkt när arduinon startar.

Förberedelserna kan t.ex. handla om att ställa in in- och utgångar, aktivera seriell kommunikation, eller att läsa av sensorer och göra olika beräkningar på mätdatan.

I detta program finns det en instruktion i setup(), nämligen en som anropar funktionen pinMode().

pinMode() används för att ställa in mikrokontrollerns in- och utgångar. Funktionen behöver två argument: vilken i/o-pin den ska ställa in, och hur. Här står det 10 (alltså pin 10) och 1, vilket betyder att pin 10 ska vara en utgång.

pinMode()() säger till mikrokontrollern att ändra hur dess in- och utgångar (stift, eller pins på svengelska) beter sig rent elektriskt. Om man glömmer att ställa in detta får utgångarna väldigt låg drivförmåga, och lysdioden skulle knappt synas.

Man kan använda pinMode() när som helst, men oftast gör man det bara i setup().

loop(): programmet

Funktionen loop() fungerar precis som setup(), med skillnaden att koden utförs om och om igen, till dess att strömmen bryts (eller programmet fastnar).

Allt som man kan göra i setup() kan man också göra i loop()(1) – och vice versa – den enda skillnaden är i hur koden körs: en gång i setup(), och om-, eller om och om igen för alltid.

  1. ibland behöver man kanske inte förbereda något innan huvudprogrammet kör. Man måste dock ha kvar funktionen setup(), om än utan någon kod inuti.

Funktionen digitalWrite() används för att styra Unons utgångar. Precis som pinMode() anger man först pin, och därefter vad man vill göra. digitalWrite(10, 1) gör att mikrokontrollern skapar en hög spänning på utgång 10. Eftersom spänningen är kopplad till en lysdiod så innebär det att lysdioden tänds.

  1. På samma sätt kan man ibland vilja göra något bara en gång, t.ex. när man håller på och programmerar och vill testa någon funktion utan en massa upprepningar. Då behöver programmet ändå innehålla en tom loop().

Normaltillståndet är en låg spänning, om man inte säger något annat till mikrokontrollern så är en utgång alltid låg. När man har gjort utgången hög med digitalWrite() så fortsätter den att vara hög fram till dess att man ändrar tillbaks det. För att släcka den skriver man istället digitalWrite(10, 0).

digitalWrite() kan såklart användas med andra saker än lysdioder.

Funktionen delay() är säger till processorn att vänta. Den tar bara ett argument: väntetiden i millisekunder (tusendelars sekund). delay(1000) ger en sekunds väntan.

delay() är enkel att använda, men har nackdelen att processorn är upptagen med att vänta. Under tiden att delay() pågår kan processorn inte göra något annat. I det här programmet gör det inget, och delay() är väldigt användbar i många enkla tester. Den kan också vara bra när man faktiskt vill att processorn ska vänta, t.ex. om man vill invänta något fysiskt händer under setup()-fasen.

Prova att ändra tiden i delay() om du vill. Du kan också testa att lägga till flera delay() med olika tider, och så digitalWrite() mellan dessa.

En tydligare version av programmet

Det finns bättre sätt att skriva programmet ovan. Här kommer ett exempel på det. Programmet ser annorlunda ut, men fungerar precis likadant som det förra programmet. Skillnaden är att koden är lättare att läsa och förstå. Det blir också lite smidigare att ändra i koden än förut än förut.

1a2_blink_better.ino
// blink, anpassat från originalet för Electrokit Uno Edu Shield
// av Max Wainwright/Electrokit Sweden AB
// tänder och släcker den mittersta lysdioden en gång i sekunden

const int led2 = 10;  // här deklarerar vi en konstant med namnet led2. Den tilldelas talet 10
int rate = 1000;      // en variabel för blink-hastigheten, 1000ms = en sekund

void setup() {            // kod inuti setup-funktionen utförs en gång, i början av programmet
  pinMode(led2, OUTPUT);  // säger att pin led2 (alltså pin 10) ska vara en utgång
}

void loop() {                // kod inom loop-funktionen utförs om och om igen
  digitalWrite(led2, HIGH);  // "skriver" en hög spänning på utgången led2
  delay(rate);               // gör att processorn väntar 1000 millisekunder (en sekund)
  digitalWrite(led2, LOW);   // "skriver" en låg spänning på utgång led2
  delay(rate);               // väntar 1000 millisekunder
}  // här är loop() slut och programet börjar om på rad 11

Som du ser finns det mycket mer text i det här programmet.

// kommentarer

Överst i programmet finns tre rader med grå text: kommentarer. Kommentarer är inte en del av programmet utan är bara till för oss människor. Oftast används kommentarer för att förklara vad koden gör.

En rad som börjar med // blir automatiskt en kommentar. Man kan också lägga kommentarer efter koden på en rad, som du kan se lite längre ner i programmet.

Även om kommentarer inte är en del av själva programmet så är de väldigt viktiga och användbara. De behövs inte bara för att förklara för andra vad koden gör – om du öppnar ett program några månader efter att du har skrivit det minns du nog inte vad allting gör. Utan kommentarer skulle du behöva klura för att förstå koden igen. Det kan göra att en enkel ändring i koden kan ta lång tid.

Särskilt när man samarbetar med andra är kommentarer viktiga, nästan lika viktiga som själva koden. Att kommentera är en god vana!

Variabler och konstanter

Variabler och konstanter är ”behållare” som används till många olika saker i C++. I variabler kan man lagra information, som kan användas för allt möjligt. Man kan också spara över informationen i en variabel under programmets gång.

Konstanter liknar variabler men går bara att läsa ur. Det värde de får när de skapas är låst.

Deklarera variabler och konstanter

Man skapar variabler och konstanter genom att helt enkelt säga att de finns. Det kallas att man deklarerar dem.(1)

  1. I det här sammanhanget har deklarera inget med skatter att göra, utan kommer från engelskans declare, som snarare betyder tillkännage.

Samtidigt som man deklarerar en variabel eller konstant namnger man den. Det är det namnet man sedan använder, t.ex. om man vill använda eller skriva över värdet i en variabel.

En variabel kan heta nästan vad som helst(1) så länge man håller sig till stora och små bokstäver från A-Z, siffror 0-9 (dock inte bara siffror) och understreck.

  1. Nästan – vissa ord betyder redan saker och är upptagna.

Det är smartast att döpa variabler och konstanter till något som beskriver vad de används för, eller vad de motsvarar för mätning, t.ex. Om man skriver en variabel ofta kan det underlätta att ge dem korta namn, men inte så korta att det blir obegripligt.

Tilldelning

När man deklarerar konstanter och variabler det kan man också ge dem ett värde. Det heter tilldelning och görs med ett likhetstecken. Om man inte tilldelar något värde så tilldelas automatiskt värdet 0.

led2 = 10;

Likhetstecknet ska inte läsas som en fråga (som på t.ex. matteprov) utan som en sorts instruktion: ”gör så att led2 är lika med 10”. Man säger att man tilldelar 10 till led2.

Konstanter kan som sagt bara tilldelas värden när de deklareras. Variabler kan vi tilldela till när som helst. Det behöver inte handla om siffror vi har skrivit i koden. Man kan också tilldela till variabler från en annan variabel (eller konstant), t.ex. variabelA = variabelB. Då antar variabelA det värde som variabelB har just då. Andra möjligheter är ekvationer (som kan innehålla variabler och/eller siffror), eller mätningar och annat.

Än så länge nöjer vi oss med att led2 ska vara 10.

Datatyp

Variabler och konstanter motsvarar en liten del av mikrokontrollerns minne. Hur stor del av minnet beror på vilken datatyp den har. Datatyp måste anges när man deklarerar något. Kortare namn är en typ, flera typer.

Vi kommer gå igenom de olika datatyperna längre fram. Så länge kan vi använda int som betyder heltal (eng. integer).

int led2 = 10;

Poängen med konstanter

Konstanter kan alltså bara ha ett och samma värde, det som tilldelas när den deklareras. Den främsta vinsten är kanske att man inte måste skriva talet 10 när man t.ex. vill tända lysdiod 2. Man slipper komma ihåg vilken utgång på Arduinon lysdiod nummer 2 är kopplad till. Det går lite lättare att programmera.

Men det blir också lättare att läsa koden. Om man ser ”led2” i koden kan man lätt gissa att det har något med lysdiod 2 att göra. Talet 10 säger ju ingenting. Vi kommer göra samma sak med de andra in- och utgångarna på Edu Shield.(1)

  1. Se pinout i dokumentationen för Edu Shield.

En annan vinst med konstanter är just att de inte går att ändra på. Saker som man vet inte kommer förändras kan lika gärna vara konstanter – och vi vet ju att led 2 är ansluten till pin 10. Om man råkar tilldela något till konstanten led2 kommer det orsaka ett felmeddelande (mer om felmeddelanden lite längre fram).

På rad 5 deklareras en konstant som heter led2 (radnumren ser du till vänster om koden). Konstantens datatyp(1), är int. Konstanten tilldelas också värdet 10.

  1. Datatyper tas mer upp längre ner.
const int led2 = 10;

Sedan deklareras variabeln rate, som tilldelas 1000.

Fördefinierade konstanter

I setup() ser det ungefär likadant ut som förut. Men förutom att det har tillkommit kommentarer så har pinMode() andra argument. Det första argumentet är led2, vilket är lika med 10 (som innan). Nästa argument är OUTPUT.

Du undrar kanske när vi deklarerade konstanten OUTPUT? Det är nämligen en konstant. Svaret är att vi inte har gjort det. OUTPUT har deklarerats i förväg i bakgrunden och är en del av Arduino. Det är en av flera fördefinierade konstanter som gör det lättare att skriva och läsa kod.

Samma sak syns i början av loop(). Funktionen digitalWrite() har fått argument som är funktionellt identiska med förra programmet, men mycket tydligare. Utgången är led2, och läget är HIGH (alltså en hög spänning) istället för 1. Motsvarande används LOW och inte 0 för att släcka lysdioden.

Variabeln rate används i båda delay()-funktionerna. Om du vill ändra blinkhastigheten kan du bara ändra tilldelningen till rate, och så ändras tiden för både tänd och släckt led automatiskt. (I det här fallet hade det kunnat vara en konstant, eftersom ingenting i koden ändrar värdet på rate).

Sammanfattning

Nu är det bara att börja ändra i koden och se vad som händer. Prova till exempel att få lysdioden att blinka fortare eller långsammare. Tiden den är tänd och släckt behöver inte heller vara samma. Du kan också testa att byta vilken lysdiod som blinkar, eller blinka flera samtidigt.(1)

  1. De andra lysdioderna har utgång 11 (led 1) och utgång 9 (led 3).

Det som inte går är att blinka flera lysdioder med olika hastighet. Det beror på att delay() gör att processorn är upptagen medan den väntar. Snart kommer vi visa bättre sätt att göra saker periodiskt, utan att låsa processorn.

Kort om kompilatorn i Arduino IDE

Att ägna sig åt Arduino-programmering är ofta så enkelt som att skriva kod och sedan trycka på pilen för att ladda upp koden. När man trycker på pilen sker två saker: först omvandlas koden du har skrivit till maskinkod. När det är klart startas mikrokontrollern på kortet om, och maskinkoden förs över genom USB-kabeln och läses in i programminnet. När det är klart startas mikrokontrollern om igen och programmet startar.

Kompilatorn är programmet som sköter omvandlingen till maskinkod. Maskinkoden är en typ av mycket enkla instruktioner som styr processorn inuti mikrokontrollern. En anledning till att vi inte skriver maskinkod direkt är för att den är väldigt svår att förstå. Det finns mycket färre olika instruktioner att välja på, så något som ser enkelt ut i programkoden – till exempel subtraktion – blir till många steg med olika instruktioner när det översätts till maskinkod.

Dessutom är maskinkod olika för olika mikrokontrollers, eftersom processorerna fungerar på olika sätt. Kompilatorn måste därför veta vilken processortyp programkoden (alltså det du har skrivit) ska kompileras till. Om man inte har kopplat in något kort till datorn och anslutit så måste man ändå välja ett. Det görs i menyn Tools > Board.

Kompilatorn kontrollerar också att programkoden är korrekt skriven. Det är här reglerna för ”stavning och grammatik” kommer in. Om man skriver fel, glömmer parenteser eller andra skiljetecken, så kan inte kompilatorn gissa vad man menade. Den kommer då avbryta kompileringen och visa ett felmeddelande med ilsken orange text, och inget laddas upp till mikrokontrollern.

Vi kan testa genom att ta bort ett semikolon (;). Du kan klicka på bocken för att bara kompilera, eller pilen, som både kompilerar och laddar upp programmet. Du kommer oavsett få ett felmeddelande, och programmet kommer inte kunna laddas upp.

felmeddelande från kompilatorn

Kompilatorn försöker förklara både var och hur det blev fel i textrutan under programmet. Den skuggar också raden där felet finns på (eller något i närheten). Som du kan se markerar den raden under det semikolon du tog bort.

Att kompilera ett program med bock-knappen (eller ctrl + r / cmd + r) är en praktisk och enkel stavningskontroll. Du kan när som helst kompilera för att se om du har skrivit fel. Det kompilatorn inte kan, är avgöra om programmet fungerar som du har tänkt - om Arduinon gör det du vill - lika lite som stavningskontrollen i en ordbehandlare kan säga om en text du skriver är rolig eller smart.

Serial Monitor och analogRead()

En av fördelarna med Arduino och Arduino IDE är den inbyggda seriekommunikationen. Med den kan mikrokontrollern skicka information till datorn och det går lätt att se vad koden gör. Särskilt för nybörjaren är det värdefullt att kunna dubbelkolla att det man har programmerat gör det man har tänkt. Det är inte alla fel som ger ett felmeddelande!

Det finns två sätt att titta på seriell data från Arduinon: Serial Monitor och Serial Plotter. Vi kommer främst använda Serial Monitor, men prova gärna Serial Plotter om du vill. Vi ska återvända till den senare. Både Serial Monitor och Serial Plotter hittar du under menyraden Tools i Arduino IDE. Där ser du också snabbkommandot(1) för Serial Monitor.

  1. Om du vill lära dig fler snabbkommandon hittar du dem i Arduino IDE - Advanced - Keyboard Shortcuts. Där kan du ändra knappkombinastionerna, eller skapa egna snabbkommandon.

Öppna 1b_analogRead_Serial.ino och ladda upp det till din Uno. Öppna sedan Serial Monitor. Du borde se olika värden från 0 till 1023 när du vrider på potentiometern.

1b_analogRead_Serial.ino
const int pot = A1;  // A1 = analog ingång 1
int potVal;          // deklarerar variablen potVal, datatyp int, utan tilldelning

void setup() {
  Serial.begin(115200);  // startar serieportens kommunikation, med en datahastighet på 115200 Baud
}

void loop() {
  potVal = analogRead(pot);  // tilldelar värdet från analogRead(pot) till potVal
  Serial.print(potVal);      // skickar potVal över serieporten
  delay(10);                 // väntar en hundradels sekund
}

Nu går vi igenom programmet:

Deklarationerna

Först har vi två bekanta rader. Det som är värt att nämna är kanske att konstanten pot tilldelas A1 – inte ett tal! Men som du kanske har gissat är A1 en fördefinierad konstant, som motsvarar Unons analoga ingång 1. (Om du undrar vilket tal som gömmer sig bakom A1 så är det 15).

Sedan deklareras variabeln potVal. Den får inget värde tilldelat.

Serial.begin()

Serial.begin() är en funktion som aktiverar kortets serieport. Det finns många andra funktioner för att skicka, ta emot och på andra sätt hantera seriell data. Alla skrivs i formatet Serial.funktion(). Observera att Serial alltid ska skrivas med stort S, till skillnad från de flesta andra funktioner.

Serial.begin() tar två argument, men det andra är valfritt (och vi kommer strunta i det). Argumentet vi bryr oss om är datahastigheten, som heter baud rate och motsvarar bitar per sekund. Det anges med heltal, men inte vilket tal som helst. Man måste välja en av de ”vanliga” hastigheterna. Du kan se en meny med olika baud till höger i Serial Monitor.

baud-rate menyn

Välj 115200 baud om det inte redan är valt. Det är den hastigheten som används i alla exempelprogrammen för Edu Shield. En annan vanlig hastighet är 9600. Den används ofta i Arduinos exempelprogram (som hittas i menyn File > Examples). Om du vill prova dem får du antingen ändra hastighet i koden, eller ställa om Serial Monitor till 9600 igen.

Vi har valt den högre hastigheten för att det går lite snabbare. I vissa fall kan långsammare datahastigheter faktiskt göra att andra processer påverkas (mer) av den seriella kommunikationen, eftersom data skickas under längre tid. Mer om detta nedan.

Om hastigheten inte stämmer mellan mikrokontroller och Serial Monitor blir datan oläslig.

Ingen pinMode() behövs

Vi vill också påpeka att programmet inte har någon pinMode(). Det skulle kunna stå pinMode(pot, INPUT), vilket skulle göra pot-ingången till just en ingång. Men det behövs inte, eftersom standardläget för Arduinons in- och utgångar är just att vara ingångar. I det läget är de väldigt känsliga (högimpedant läge).

Om man vill vara extra tydlig med vad de olika in- och utgångarna används för kan man ange pinMode() för alla.

Läsa analoga spänningar

Väl inne i loop-delen av programmet ser vi två nya saker, på samma rad. Först att vi tilldelar värden till potVal inuti själva programmet, men också att vi tilldelar den en annan funktion. Eller rättare sagt – det som returneras från den andra funktionen.

Funktionen heter analogRead(), och läser som namnet säger av en analog ingång. Den returnerar heltal (int) som motsvarar spänningen på ingången. Det enda argumentet den tar är ett pin-nummer.

Antalet olika värden från en A/D-omvandlare räknas ut med 2 upphöjt i antalet bitar. Vi utgår från mikrokontrollern på Uno R3, Atmega 328p, vars A/D-omvandlare har 10 bitars upplösning. 210 ger 1024 – alltså 1024 olika värden. 0 är ett av värdena, så det högsta talet blir 1023, inte 1024 – som vanligt med datorer räknar man från 0.

Seriell data

Därefter används funktionen Serial.println(). Den skickar innehållet i parenteserna (alltså dess första argument) över serieporten. Det som skickas här är variabeln potVal, som ju har tilldelats något värde från analogRead(). Istället för att se ordet potVal i Serial Monitor ser man alltså tal. Prova gärna Serial Plotter också.

If-satser och analoga utgångar

I 1c_dimming_leds.ino kommer det mer nyheter. Den viktigaste nyheten är de tre if-satserna, som styr programmets flöde. Programmet gör nu inte bara samma sak om och om igen, utan tar in data (spänningar) från omvärlden och gör olika saker beroende på dess input.

Det kommer också lite nya tecken: >> och ==, samt motsvarigheterna till analog in och digital ut, både vad gäller programmering och den bakomliggande elektronikens egenskaper.

Öppna programmet så tar vi det uppifrån och ner.

1c_dimming_leds.ino
const char pot = A1;
const char led1 = 11;
const char led2 = 10;
const char led3 = 9;
const char btn1 = 13;
const char btn2 = 7;
const char btn3 = 8;

int potVal;
unsigned char led1Val = 0;  // unsigned char är en datatyp som hanterar tal från 0-255
unsigned char led2Val = 0;
unsigned char led3Val = 0;

void setup() {
  pinMode(pot, INPUT);
  pinMode(led1, OUTPUT);
  pinMode(led2, OUTPUT);
  pinMode(led3, OUTPUT);
  pinMode(btn1, INPUT_PULLUP);  // ingång med pull-up-motstånd
  pinMode(btn2, INPUT_PULLUP);
  pinMode(btn3, INPUT_PULLUP);
  Serial.begin(115200);
}

void loop() {
  potVal = analogRead(pot);  // läser av poten

  if (digitalRead(btn1) == LOW) {  // kollar om knapp 1 är intryckt
    led1Val = potVal >> 2;         // om den är det tilldelas potVal till ledVal (med bitvis skifte två steg åt höger, 0-1023 blir 0-255)
    analogWrite(led1, led1Val);    // och detta
  }
  if (digitalRead(btn2) != 1) {  //kollar om knapp 2 är intryckt
    led2Val = potVal >> 2;       // osv, som ovan
    analogWrite(led2, led2Val);
  }
  if (!digitalRead(btn3)) {
    led3Val = potVal >> 2;
    analogWrite(led3, led3Val);
  }
}

Tal med eller utan minustecken

Först har vi deklaration av konstanter och variabler. Det ser bekant ut, förutom att de har blivit fler. En skillnad är dock att led1Val, led2Val och led3Val har en datatyp som inte har använts tidigare: unsigned char.

Datatyper skiljer sig åt på andra sätt än i storlek. Ordet unsigned betyder att datatypen inte kan representera negativa tal. Ordet sign syftar på själva minustecknet. Att en datatyp kan representera negativa tal kallas ibland för signedness, vilket ungefär blir ”teckenhet” på svenska – en egenskap som är användbar i vissa fall och problematisk i andra. Även int går att deklarera utan teckenbit.(1)

  1. En av bitarna i det binära talet används för att visa om ett tal är negativt – den kallas för teckenbiten.

Nästa ord, char, är en förkortning av character. Ordet kan betyda många saker men här menar man tecken, t.ex. a, B, #, 9 eller ). Datatypen char (utan unsigned före) används främst för att representera sådana tecken, men kan också användas för vanliga tal. Allting är ju egentligen ettor och nollor inne i mikrokontrollern, så det är främst en fråga om hur den tolkar siffrorna.

Olika stora datatyper

Skillnaden mot int är att char (med eller utan minustecken) bara använder 8 bitar, medan int använder 16.(1) En 16-bitars int kan representera tal från -32768 till 32767 (65536 olika värden, inklusive 0), jämfört med unsigned char som bara får plats med 0-255.(2)

  1. Eller 32 – det kan skilja mellan olika processorer.
  2. Vanliga char kan representera -127 till 126.

En char tar upp mindre plats i minnet, och de går dessutom fortare att räkna med i en åttabitars mikrokontroller, som Atmega 328p. Det är klokt att använda små datatyper om man vet att en variabel aldrig kommer behöva hantera stora tal. Att vi använder char här är dock främst för att visa upp en ny datatyp.

Vi ska inte ta upp fler datatyper så här tidigt, men om du vill veta mer finns det en tabell längre ner.

Aktiv låg

En till nyhet finner vi i setup(). Istället för att vara vanliga INPUT eller OUTPUT så konfigureras knapparnas ingångar som INPUT_PULLUP. Det innebär att mikrokontrollern ansluter ett internt motstånd. Motstånden går från ingången till matningsspänningen (5V) – och drar upp ingången så att den ser en hög spänning.

Tryckknapparna är kopplade till mikrokontrollerns ingångar på ena sidan och jord på den andra. När man trycker in en knapp ser ingången två anslutningar: ett internt motstånd (på cirka 20kOhm) till 5V, och en kortslutning (via knappen) till jord (0V). Kortslutningen ”vinner” och resultatet blir en låg spänning. Låg spänning motsvarar logiskt 0, eller falskt. Kopplingen kallas för aktiv låg. När man släpper knappen blir spänningen hög igen.

internal pullup resistors

Intuitivt kan det verka konstigt att en intryckt knapp ger 0, LOW, falskt, medan ingen handling alls ger 1, HIGH, sant. Det flera anledningar till att man gör på det här viset, den främsta är kanske att ingången alltid har ett känt tillstånd.

Om vi istället hade kopplat knapparna till 5V och struntat i motstånden så hade en intryckt knapp gett hög logiknivå – men en orörd knapp hade inte nödvändigtvis gett en låg. Ingången hade ”flutit”, varit okänd. Störningar från ett finger i närheten av knappen eller ingången, eller spänningar på omgivande ingångar och komponenter, skulle då kunna påverka spänningen på ingången. Istället för att ge låg logiknivå hade den pendlat okontrollerat mellan låg och hög.

Det relativt låga motståndet till 5V med INPUT_PULLUP gör att den logiskt höga nivån ”vinner” i kampen mot störningarna.

if-satser

Nästa nyhet är de tre if-satserna. if är en av flera sätt att styra ett programs flöde, så kallade kontrollstrukturer. De innehåller kod inuti {klarmarna}, som utförs under vissa omständigheter.

Villkoret

Varje if-sats har ett villkor, och om det uppfylls, så kommer koden inom satsen att utföras. Ordet if är bara ”om” eller ”ifall”, på engelska. En bra beskrivning av hur den fungerar.

Som sant räknas att villkoret är lika med 1.(1) Kod med villkoret if (1) skulle alltså göra satsens koden alltid utföras, medan det som stå i en if (0) aldrig skulle kunna hända. Ingen av exemplen gör någon större nytta.

  1. Om vi ska vara petiga så räknas allt som inte är lika med 0 som sant. -134 hade fungerat lika bra, eller 10000.

Jämförelseoperatorer

För att styra programmets flöde behöver vi lägga in något som antingen kan ge 1 eller 0 som svar. I det här programmet använder vi jämförelseoperatorn ==, som betyder ”är lika med”.(1)

  1. På engelska kallas de för comparison operators eller relational operators. Ett annat namn på svenska är villkorsoperatorer.

If (minVariabel == 1) utläses alltså ”om minVariabel är lika med 1”. Om det är sant returnerar == en etta – det är en typ av logisk ekvation. Logiska ekvationer fungerar ungefär som vanliga ekvationer, men istället för att ge svar som 4 (svaret på frågan "vad är 2 + 2?") så blir svaret antingen 1 eller 0. 0 och 1 motsvarar ja eller nej, sant eller falskt.

Ett ensamt likhetstecken (=) används ju för att tilldela, en instruktion: ”gör så att x har värdet 100”. Två likhetstecken (==) är istället en fråga: är ”x lika med 100?” Om man blandar ihop dem kan det lätt bli konstiga fel i ens program!

== kan också användas på andra ställen än i if-satser.

Andra sätt att göra samma sak

I den första if-satsen är frågan ”är ingången för knapp 1 lika med en låg logisk nivå?”.

För att visa några andra sätt att skriva så har de två följande if-satserna fått andra villkor. Resultatet blir likadant i praktiken.

Sats nummer två använder jämförelseoperatorn !=, som betyder ”inte lika med”. Om ingången inte är lika med 1 – alltså inte inte är intryckt – så körs koden.(1)

  1. Jämförelseoperatorerna är väldigt användbara. Det dyker upp fler varianter så småningom.

Den tredje satsen har ingen jämförelse alls. Istället använder vi ! direkt på funktionen som läser av knapp tre. Utropstecken kallas för logisk ICKE (logical NOT) och ”vänder på” det som kommer efter. Sant blir falskt, högt blir lågt, 1 blir 0 – och vice versa. I det här fallet görs en logisk invers på det som returneras av digitalRead().

Bitvis skiften: >> och <<

Nästa nyhet är att vi gör en tilldelning från potentiometerns värden, och samtidigt gör något med ett nytt tecken: >>.

>> är ett bitvis skifte. Det innebär att bitarna i ett binärt tal (t.ex. 10100110), flyttas ett antal steg åt något håll. Med >> 2 blir resultatet 00101001. De nya siffrorna som kommer in från sidan är normalt nollor. Motsvarande skifte åt vänster görs med tecknen <<.

Bitvis skifte åt höger (>>) fungerar nästan som division, men x >> 1 motsvarar x / 2, x >> 2 motsvarar x / 4, och x >> liknar x / 8, osv.

Bitvis skiften går snabbt för en 8-bitars mikrokontroller att utföra, och är praktiska om man vill dividera eller multiplicera med tvåpotenser – gånger två, delat med fyra, gånger åtta, osv. Resultatet blir dock inte alltid rätt vid division:

tal tal (binärt) skifte resultat resultat (binärt) motsv. ekvation + resultat
91 01011011 > 1 45 00101101 91 / 2 = 45,5
45 00101101 > 1 22 00010110 45 / 2 = 22,5
22 00010110 < 1 44 00101100 22 * 2 = 44
19 00010011 < 1 38 00100110 19 * 2 = 38
38 00100110 < 2 152 10011000 38 * 4 = 152

Här använder vi >> för att förminska talen från analogRead(), alltså tal från 0-1023. Bitvis skifte två steg åt höger ger samma sak som division med fyra, och 1024 / 4 blir 256. Varför vi gör det här får du strax veta.

Från tal till analog spänning

analogWrite() fungerar ungefär som digitalWrite(), men skickar analoga spänningar till en utgång. Istället för 1 eller HIGH för att t.ex. tända en lysdiod så behöver analogWrite() veta vilken spänning den ska skicka.

Det första argumentet för analogWrite() är utgången, t.ex. led1 (11). I det andra argumentet anges spänningen. Den anges dock inte med spänningar i volt, utan med tal från 0-255. D/A-omvandlaren(1) i Atmega 328p har nämligen åtta bitars upplösning – 28 = 256.

  1. Spänningarna från Atmega 328p är egentligen inte analoga spänningar, utan en digital signal med pulsbreddsmodulering (PWM). I praktiken spelar det ingen roll när vi dimrar lysdioder – det är faktiskt ett bra sätt att styra dem. Om man vill ha riktiga analoga spänningar kan man skapa dem med ett enkelt RC-filter.

Talet 0 ger 0V, medan 255 ger en hög spänning – 5V på en vanlig Uno R3. Mittenläget 127 ger ungefär 2.5V ut. Provmät gärna med en multimeter!

Tal över 255 gör inte att lysdioden lyser starkare. Om vi inte hade anpassat talen från potentiometern hade en fjärdedels varv täckt hela lysdiodens spann, vilket hade gjort den väldigt svår att styra – prova själv!(1)

  1. I själva verket blir det ännu konstigare. Eftersom variablerna för lysdioderna är av typen unsigned char, så kan de inte ens innehålla tal som är för stora för analogWrite(). Det som kommer hända är istället att tal över 255 börjar om igen från 0, så att potVal går från 0-255 fyra gånger på ett varv.

Programmet gör alltså följande:

  • Läser av potentiometern (en analog spänning).
  • Läser av knapparna 1-3.
  • Om någon är intryckt så tänds motsvarande lysdiod så mycket som potentiometern säger.
  • Om ingen knapp är intryckt har potentiometern ingen påverkan.

Experimentera vidare:

  • Vad händer om du trycker ner flera knappar samtidigt?
  • Vad händer om du inte rör potentiometern och bara trycker in en knapp? Varför?
  • Vad händer om du använder en annan analog ingång för att styra lysdioderna?
  • Kan du få samma knapp att styra alla lysdioder?
  • Vad ser du om du lägger in Serial.println() i någon av if-satserna och ”skriver ut” variabeln för ljusstyrka?

Sammanfattning

I del 1 har vi gått igenom grunderna i programmering för Arduino, bland annat:

  • Vad Electrokit Edu Shield innehåller
  • Hur Arduino IDE fungerar och hur man laddar upp program till en Arduino
  • Hur ett enkelt Arduino-program är uppbyggt
  • Hur (och varför) man konfigurerar Arduinons in- och utgångar
  • Om tilldelning, variabler och konstanter
  • Hur man använder serieporten
  • Hur man läser av de analoga och digitala ingångarna
  • Hur man styr de – skapar spänningar på – de analoga och digitala utgångarna
  • Om några datatyper
  • Hur if-satser fungerar

Nu har du flera bra verktyg för att skriva och förstå många projekt. Om du har förstått det som står i den här introduktionen är du väl förberedd för de lite större projekten som kommer.

Har du inte förstått allt kan du köra på ändå – programmering är roligt, och man lär sig mycket av att göra fel! Kompilatorn kommer fånga upp många nybörjarmissar. Se den som ett hjälpmedel.

Om du kör fast är det ofta en bra idé att plocka ut mindre beståndsdelar av ett program och felsöka dem för sig. När de fungerar kan du sedan klippa och klistra dem i andra program.

Glöm inte heller att du kan plocka ut delar av programmen som följer med den här texten och Arduinos exempelsketcher.

2: Skapa ljud och mäta ljus

Nu ska vi prova olika sätt att göra ett musikinstrument av Uno Edu Shield. På kortet finns en liten högtalare med en enkel drivkrets. Drivkretsen (och utgång 2 på en Uno R3) är digital. Det betyder att högtalarelementet bara kan röra sig i fyrkantiga pulser och inte gradvis som vanliga högtalare.

analog / digital loudspeaker

Små ”digitala” högtalare kallas ofta för sumrar (eller på engelska buzzers). De finns lite överallt – i mikrovågsugnar, multimetrar, väckarklockor, bilar, leksaker. Sumrar kan informera eller varna användaren om olika saker, allt ifrån att maskinen har startat, är redo att starta, att den är klar med sin uppgift, eller att något har gått fel och behöver åtgärdas.

Genom att spela olika toner, melodier eller variera antalet pip kan många saker uttryckas. I lite enklare gränssnitt utan display – t.ex. Edu Shield – blir en sådan liten högtalare extra värdefull.

Den digitala styrningen begränsar klangfärgen, och betyder också att ljudvolymen är låst – högtalaren kan låta, eller vara tyst, men det finns inga mellanlägen. Trots det finns det hyfsade möjligheter till olika ljud och melodier. Ihop med de begränsade kontrollerna på Edu Shield ger det en intressant kreativ utmaning.

tone()

Vi kommer använda funktionen tone(), för att skapa ljuden. Den genererar pulsvågor och behöver två argument: det stift tonen ska skickas ut på, samt vilken frekvens tonen ska ha. Ett tredje valfritt argument kan ges för att styra hur länge tonen ska låta. Precis som med delay() anges detta i millisekunder – 1000ms är en sekund. Frekvensen anges med heltal, i Hertz (Hz).

Om vi inte anger någon tid kommer tonen fortsätta låta för all framtid, vilket snabbt blir något irriterande. Man kan därför också stoppa tonen med funktionen noTone(), som stoppar tonen på den pin som anges i argumentet.

På grund av hur tone() använder räknare (timers) inuti mikrokontrollern fungerar den inte alltid likadant. Den mer kraftfulla och moderna mikrokontrollern i Uno R4 har andra sorters räknare, vilket gör att många av programmen låter (och beter sig) något annorlunda än en Uno R3.

Ett enkelt ”piano”

Vi börjar med ett enkelt program: 2a_spanien.ino.

2a_spanien.ino
// kan du spela Spanien?
// Spanien - är ett land - där man dansar - tango
// Allra helst - en spanjor - dansar sin fan - dango
// Melodi: C D E, C D E, D D D D, C C
// (text (C) Lennart Hellsing, 1957.)

const char btn1 = A2;
const char btn2 = 7;
const char btn3 = 8;
const char spkr = 2;

void setup() {
  pinMode(spkr, OUTPUT);
  pinMode(btn1, INPUT_PULLUP);
  pinMode(btn2, INPUT_PULLUP);
  pinMode(btn3, INPUT_PULLUP);
}

void loop() {
  if (digitalRead(btn1) == LOW) {  // on ingång för knapp 1 är låg betyder det att knapp 1 är intryckt
    tone(spkr, 262);               // 262 Hz = C
  }
  if (digitalRead(btn2) == LOW) {  // om knapp 2 är intryckt
    tone(spkr, 294);               // 294 Hz = D
  }
  if (digitalRead(btn3) == LOW) {  // om knapp 3 är intryckt
    tone(spkr, 330);               // 330 Hz = E
  }
  if (digitalRead(btn1) && digitalRead(btn2) && digitalRead(btn3) == HIGH) {  // om ingångarna för knapp 1 OCH knapp 2 OCH knapp 3 är höga
    noTone(spkr);                                                             // så tystas högtalaren (ingen ton spelas)
  }
}

Alla som har gått kommunala musikskolan känner väl till visan Spanien, av Lennart Hellsing? Melodin har bara tre toner, så den går att spela på Uno Edu Shields lilla ”klaviatur” med tre tangenter. Styrningen ordnar vi enkelt med fyra if-satser.

I den sista if-satsen används tecknet &&, som är en logisk OCH. Det gör att jämförelsen kollar om 1 OCH 2 OCH 3 är höga – alltså om ingen av knapparna är intryckt.

Programmet är enkelt, men det har vissa (lärorika) brister som vi ska åtgärda. Den första bristen handlar om if-satserna. Det är nämligen så att villkoret för flera if-satser kan vara sanna samtidigt. Om du trycker på två eller tre knappar samtidigt kommer Unon hoppa mellan de olika tonerna, så snabbt den kan. Det låter lite oväntat. Så fungerar ju inte ett piano, eller för den delen en monofon synt (monofon betyder att en synt bara kan spela en ton i taget). Edu Shield är en synt, och tone() är begränsad till en ton i taget.

else if och if...else

Vi kan lösa problemet och samtidigt få lite snyggare kod med else if. Den placeras efter en if-sats och fungerar som ännu en if, med ett annat villkor och annat kodblock. Efter den kan man ha ännu fler else if, om man vill.(1) I det nästa program (2a_spanien_elseif.ino) behöver vi bara tre, en per knapp.

  1. Om det blir väldigt många else if på rad kan det vara bra att titta på funktionen switch...case. Den fungerar lite annorlunda och utgår från att en variabel kan ha många olika värden, istället för att olika villkor ställs i flera olika else...if.
2a_spanien_elseif.ino
// kan du spela Spanien?
// Spanien - är ett land - där man dansar - tango
// Allra helst - en spanjor - dansar sin fan - dango
// Melodi: C D E, C D E, D D D D, C C
// text (C) Lennart Hellsing, 1957.
// – med else if()

const char btn1 = 13;
const char btn2 = 7;
const char btn3 = 8;
const char spkr = 2;

void setup() {
  pinMode(spkr, OUTPUT);
  pinMode(btn1, INPUT_PULLUP);
  pinMode(btn2, INPUT_PULLUP);
  pinMode(btn3, INPUT_PULLUP);
}

void loop() {
  if (digitalRead(btn1) == LOW) {         // om knapp 1 är intryckt
    tone(spkr, 262);                      // 262 Hz = C
  } else if (digitalRead(btn2) == LOW) {  // ELLER om knapp 2 är intryckt
    tone(spkr, 294);                      // 294 Hz = D
  } else if (digitalRead(btn3) == LOW) {  // ELLER om knapp 3 är intryckt
    tone(spkr, 330);                      // 330 Hz = E
  } else {                                // OM INTE ovan gäller så ..
    noTone(spkr);                         // tystas högtalaren (ingen ton spelas)
  }
}

Skillnaden mot när man använder flera parallella if är att de olika satserna är exklusiva – även om flera av villkoren kan vara sanna så kommer bara en av satserna köras. Satserna har olika prioritering. Först hamnar den inledande if-satsen, därefter kommer den första else if, osv. nedåt i listan. På det här ”pianot” så blir det den lägsta tonen som hörs om två eller flera tangenter trycks ner (så fungerar också en del monofona syntar).

Sist kan man också ha en valfri else utan if. En sådan avslutande else-sats har inga villkor. Eller rättare sagt – om ingen av de ovanstående satsernas villkor uppfylls, så utförs koden i den avslutande else. En lite snyggare lösning än den långa kodraden med &&-logik.

Både else if och else är valfria – man kan använda antingen båda eller bara en av dem (eller såklart ingen alls). Det går däremot inte att ha fler än en else på slutet.

Ljus-theremin

Okej – de två föregående programmen blev inte särskilt bra instrument, men du har i alla fall lärt dig om tone() och mer om logik. Nu ska vi direkt hoppa till ett roligare instrument: en sorts theremin(1) som styrs med ljussensorn. De tre knapparna aktiverar synten, och ljussensorn och ratten används för att påverka tonhöjden.

  1. En Theremin är ett tidigt elektroniskt instrument som styrs genom att röra händerna i luften runt två olika antenner. Händerna påverkar magnetfält runt dessa, vilket styr tonhöjd och ljudstyrka.

Om du vill kan du börja med att öppna 2c_optical_theremin.ino, ladda upp den till din Uno och spela en stund.

2c_optical_theremin.ino
// piano-toner till tone(). Sammanställt av Max Wainwright för Electrokit Sweden AB.
// tonerna är organiserade i oktaver nedan.
// oktav 1:     C C# D Eb E F F# G G# A Bb B
int pitches[] = { 31,                                                          // oktav 0
                  33, 35, 37, 39, 41, 44, 46, 49, 52, 55, 58, 62,              // oktav 1
                  65, 69, 73, 78, 82, 87, 93, 98, 104, 110, 117, 123,          // oktav 2
                  131, 139, 147, 156, 165, 175, 185, 196, 208, 220, 233, 247,  // oktav 3
                  262, 277, 294, 311, 330, 349, 370, 392, 415, 440, 466, 494,  // osv.
                  523, 554, 587, 622, 659, 698, 740, 784, 831, 880, 932, 988,
                  1047, 1109, 1175, 1245, 1319, 1397, 1480, 1568, 1661, 1760, 1865, 1976,
                  2093, 2217, 2349, 2489, 2637, 2794, 2960, 3136, 3322, 3520, 3729, 3951,
                  4186 };
// för den som vill tänka utifrån pianotangenterna:
// 1  2  3  4  5  6  7  8  9  10 11 12      <- tonklassernas index
//    C#      Eb      F#    Ab    Bb            <- svarta tangenter
// C     D     E     F     G     A     B    <- vita tangenter

// konstanter för pin-nummer
const char btn1 = 13;
const char btn2 = 7;
const char btn3 = 8;
const char led1 = 11;
const char led2 = 10;
const char led3 = 9;
const char spkr = 2;
const char ldr = A0;
const char pot = A1;

// diverse variabler – ändra gärna och se vad som händer
int dark = 0;       // lägsta möjliga värdet från ljussensorn, kan skrivas över i kalibreringen
int light = 1023;   // högsta möjliga värdet --- (som ovan)
int lowNote = 1;    // lägsta noten i ton-arrayen som kan väljas. 1 motsvarar C.
int highNote = 49;  // högsta möjliga noten som kan väljas. 49 är tre oktaver över C (1 + 12 + 12 + 12 + 12 = 49)

// dessa variabler skrivs hela tiden om av programmet, så det är ingen idé att ändra dem
int ldrVal;  // mätningar från ljussensorn hamnar här
int shift;   // värde för att höja tonhöjden, bygger på mätningar från potentiometern
int note;    // används för noter att hämta ur arrayen eller för rena frekvenser (i läge 3)

// variabler och en konstant för beräkning av genomsnitt
const char ldrReadsSize = 32;  // antalet mätningar som ingår i genomsnittet. Prova att ändra – fler mätningar ger mindre störningar och långsammare förändringar av tonhöjd.
int ldrReads[ldrReadsSize];    // array där mätningar lagras
char index = 0;                // index för att läsa och skriva i arrayen
int sum = 0;                   // variabel för summan av alla mätningar

void setup() {
  // Serial.begin(115200);
  pinMode(spkr, OUTPUT);  // pinMode() för högtalare, lysdioder och knappar
  pinMode(led1, OUTPUT);
  pinMode(led2, OUTPUT);
  pinMode(led3, OUTPUT);
  pinMode(btn1, INPUT_PULLUP);
  pinMode(btn2, INPUT_PULLUP);
  pinMode(btn3, INPUT_PULLUP);

  digitalWrite(led2, HIGH);              // lysdiod 2 visar att man kan nå kalibreringsläget
  delay(1000);                           // man har en sekund på sig
  if (digitalRead(btn2) == LOW) {        // om man håller inne knapp 2 när sekunden har gått
    while (digitalRead(btn1) == HIGH) {  // så hamnar man i den första while-loopen. Här är man kvar till dess att tillståndet är falskt – alltså tills knapp 1 trycks in
      digitalWrite(led2, LOW);           // först släcker vi led 2
      digitalWrite(led1, HIGH);          // led 1 visar att det är det låga värdet (mörker) som efterfrågas
      dark = analogRead(ldr);            // varje gång while loopar runt tilldelas ett nytt värde från ljussensorn till variabeln dark
    }                                    // det värde som tilldelats sist när knapp 1 trycks in är det som gäller
    while (digitalRead(btn3) == HIGH) {  // while-loopen för kalibrering av ljus fungerar som ovan
      digitalWrite(led1, LOW);           // led 1 släcks
      digitalWrite(led3, HIGH);          // led 3 tänds
      light = analogRead(ldr);           // och ett värde från ljussensorn tilldelas variabeln light
    }
    digitalWrite(led3, LOW);  // när båda kalibreringarna är gjorda släcks led 3
  }
  digitalWrite(led2, LOW);  // om ingen kalibrering gjordes släcks led2 här (i annat fall var den redan släckt, och raden gör ingen skillnad)
}

void loop() {
  if (digitalRead(btn1) == LOW) {                        // läge 1. Först beräknas genomsnittet:
    sum = sum - ldrReads[index];                         // föregående läsning subtraheras från summan
    ldrReads[index] = analogRead(ldr);                   // ljussensorn läses av och värdet lagras i arrayen på plats (index)
    sum = sum + ldrReads[index];                         // den nya mätningen adderas till till summan
    ldrVal = sum / ldrReadsSize;                         // skapar ett genomsnitt och tilldelar det till ldrVal
    index = index + 1;                                   // index ökas med 1
    index %= ldrReadsSize;                               // index får inte bli större än ldrReadsSize (minus ett eftersom den räknar från 0)
    note = map(ldrVal, dark, light, lowNote, highNote);  // genomsnittet av ljusmätningarna mappas, från mörkt till ljust motsvarar konstanterna för lägsta och högsta tonhöjd
    shift = map(analogRead(pot), 0, 1023, 0, 3);         // läser av potentiometern och mappar dess omfång från 0 till 4
    note = note + shift * 12;                            // värdena från potens map() multipliceras med 12 för att ge hela oktaver. detta summeras sedan med note
    note = constrain(note, 0, 85);                       // constrain() begränsar värden. inga värden utanför lägre än 0 eller större än 85 kan passera.
    tone(spkr, pitches[note]);                           // till sist används värdet note för att välja en frekvens i arrayen pitches. Frekvensen skickas till note() som spelar en ton
                                                         //
  } else if (digitalRead(btn2) == LOW) {                 // läge 2
    ldrVal = analogRead(ldr);                            // här tilldelas ljusmätning direkt till ldrVal utan att filtreras
    note = map(ldrVal, dark, light, lowNote, highNote);  // resten fungerar som ovan
    shift = map(analogRead(pot), 0, 1023, 0, 3);         //
    note = note + shift * 12;                            //
    note = constrain(note, 0, 85);                       //
    tone(spkr, pitches[note]);                           //
                                                         //
  } else if (digitalRead(btn3) == LOW) {                 // läge 3
    ldrVal = analogRead(ldr);                            // ljussensorn mäts direkt som i läge 2
    shift = 256 + (analogRead(pot) << 2);                // potentiometerns värden skiftas åt vänster, så att omfånget blir 0-4096. Det tilldelas till shift
    note = map(ldrVal, dark, light, 31, shift);          // map-funktionen fungerar utan konstanter för notvärden. Istället är 31 den lägsta frekvensen, medan shift ger den högsta (från 256 till 4352 Hz)
    note = constrain(note, 31, 4186);                    // begränsar frekvenserna till att ligga inom pianots omfång –
    tone(spkr, note);                                    //
                                                         //
  } else {                                               // annars – om ingen knapp trycks ner
    noTone(spkr);                                        // är det tyst
  }
}

Thereminens kontroller

De tre knapparna gör alla att synten börjar låta, men gör att ljussensorn styr tonhöjden på olika sätt. I alla program kan man använda potentiometern för att justera tonhöjden på ett mer kontrollerat sätt.

  • Knapp 1 begränsar instrumentet till att spela pianotangenternas toner. Ljusmätningen är också filtrerad. Ratten höjer instrumentets grundton oktav för oktav.
  • Knapp 2 är också begränsad till pianotangenternas toner. Ljusmätningen är inte filtrerad, vilket kan ge en lite grövre brusig klang. Den beror på att störningar i elektroniken påverkar tonhöjden slumpmässigt.
  • Knapp 3 innebär uppspelning utan begränsning till pianots tangenter. Alla toner(1) över pianots spann är spelbara – alltså även toner ”mellan” tangenterna. Ratten ställer nu in den högsta frekvensen, men flyttar inte upp den lägsta. Även detta läge använder ofiltrerade mätningar, men eftersom tonhöjden inte förändras i lika grova steg märks störningarna av lite mindre än i det läge 2.

  • Det vill säga, alla toner vars frekvens är ett heltal.

Lagra flera värden i en array[ ]

Så fort man gör något mer avancerat än Spanien-programmen blir det opraktiskt att mata in frekvenser manuellt. Ett bättre sätt är att spara frekvenser i en tabell och låta programmet hämta dem när det behövs, och så fungerar det också i ljusthereminen. Frekvenserna är lagrade i en array, som är en sorts tabell. På svenska brukar de också kallas för en array, flera arrayer.

Man kan se att en array är en array på hakparenteserna[]. Ungefär som med funktionernas () skrivs hakparenteserna ofta ut i texter för tydlighet.

För att deklarera en kan man skriva t.ex:

int minTabell[10];

Då skapas en array med 10 stycken heltal (int). Precis som med variabler blir innehållet 0 (tio stycken heltal med värdet 0) eftersom vi inte har tilldelat något. Vill man tilldela de ursprungliga värdena i en array gör man så här, med klamrar:

int minTabell2[] = {1, 33, 23, 8, 11, 9, 16, 16, -417, 4, 0, 15};

Då behöver man inte skriva hur lång arrayen är, kompilatorn räknar själv ut det.

Arrayer används på samma sätt som vanliga variabler och konstanter, med skillnaden att man behöver välja vilket tal i tabellen man menar. T.ex. kan man skriva:

tone(minTabell2[5]);

för att välja det sjätte talet i tabellen. Som vanligt i datorernas värld börjar vi räkna från 0 och inte 1.

Man kallar platsen i arrayen för index. På index 5 i minTabell2[] (ovan) hittar vi talet 9.

Också tilldelning görs som för vanliga variabler. Man anger den array och det index man vill skriva ett värde till. I exemplet är även index en variabel som heter plats:

minTabell3[plats] = analogRead(ldr);

Om en array är lång och datatypen tar mycket plats kan programminnet snabbt  åt,  det lönar sig att tänka efter hur stora tal man behöver.

#### Läsa utanför en array

Varje index i en array motsvarar en plats i mikrokontrollerns minne. När vi läser och skriver till arrayen  ser processorn till att vi läser och skriver  rätt plats. Om vi anger ett index som inte finns  kommer processorn läsa utanför arrayen. t.ex. en array med antal dagar i månaderna:

```arduino
const int antalDagar[] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
dagar = antalDagar[13];

Den trettonde månaden finns ju inte, så istället läser processorn från den minnesplats som ligger efter arrayen. Den börjar inte om, och kompilatorn kommer inte varna. Vad som ligger på minnesplatsen efter arrayen kan man inte riktigt veta – datan i ett program fördelas fysiskt i processons minne, där varje plats har en adress. Kompilatorn sköter det på sitt eget oförutsägbara sätt.

Om man råkar läsa sådan data så kan ens program bete sig väldigt underligt. Och det blir ännu värre om man skriver över värdena utanför arrayen – de minnesplatserna används antagligen till något annat, och processorn kan krascha helt när värdena där skrivs över. Om du vill se hur många dagar månaderna som kommer efter december så har vi skapat ett litet program som visar detta (och illustrerar varför man inte ska skriva sina program så här):

2d_read_outside_array.ino
// vad som  kan hända om man läser utanför en array
// antal dagar i alla månader från 1 till 65535
unsigned char daysInMonth[] = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };  // dagar i månader 1-12
int index = 0;

void setup() {
  Serial.begin(115200);
}

void loop() {
  Serial.print("månad ");
  Serial.print(index + 1);  // det vi kallar månad 1 har index 0, så vi lägger till 1
  Serial.print("har ");
  Serial.print(daysInMonth[index]);
  Serial.println("dagar.");
  index = index + 1;  // index bara ökar och ökar
  delay(500);
}

Ett bra sätt att begränsa index är med funktionen constrain(). Den tar tre argument: variabeln som ska begränsas, och så det lägsta och högsta värdet den ska tillåtas ha. Tal som är för små blir = det lägsta talet, medan tal som är för stora blir = det högsta. Inga tal utanför omfånget tillåts. Tal inom omfånget påverkas inte.

Ljussensorn och map()

Det som gör ljus-thereminen till en theremin är just ljussensorn. Liksom potentiometern ger ljussensorn analoga spänningar‚ så vi läser av den med analogRead(ldr);. Värdena vi får är också heltal från 0-1023.

Vi har inte 1023 olika toner att välja på, så vi kan använda funktionen map() för att mappa inkommande tal till andra. Att mappa betyder ungefär att man anpassar något till något annat (det finns ingen riktigt bra översättning till svenska).

map() behöver fem argument och fungerar så här:

  • den variabel vars data ska mappas
  • det lägsta inkommande talet
  • det högsta inkommande talet
  • det tal som det lägsta inkommande talet ska mappas till
  • det tal som det högsta inkommande talet ska mappas till

T.ex. kan man ha 250 och 500 som inkommande tal, och mappa dem till tal från 0 till 1200:

map(inkommandeTal, 250, 500, 0, 1200);

Om map() får in 250 får man ut 0. Om det istället kommer in 343 så kommer 446 ut, och så vidare. Funktionen fungerar dessutom utanför det spannet, så ett inkommande 700 ger 2160 ut.

Exempel på map() 1

I det här fallet är spannet för de utgående talen större, men det kan också vara mindre. Utöver att skala om inkommande talserier kan man också vända på dem (med eller utan skalning). Man byter bara ordning på de inkommande (eller utgående) talen så att de ”större” talen är mindre. Man kan kombinera det med skalning, eller bara vända på talserien:

map(minTalserie, 100, 800, 800, 100);

En väldigt användbar funktion!

Beräkning av genomsnitt

Om du märker att tonhöjden är väldigt ostadig trots att du håller handen stilla kan det bero på störningar i mätningarna. Störningar kan komma ifrån ljussensorns spänningsreferens, alltså spänningsmatningen till din Uno. USB-portar har ofta ganska mycket störningar i matningsspänningen. Flimrande lampor kan påverka sensorn och ge liknande resultat som elektriska störningar, även om det rent tekniskt är korrekta mätningar från ljussensorn.

Istället för att filtrera mätdatan analogt så kan en lösning vara att ta ett genomsnitt av flera mätningar. Tanken är att de relativt långsamma förändringarna mellan mätningar bibehålls, medan snabba fluktuationer från strömmatning eller flimrande lampor jämnar ut sig.

För att göra det använder vi en array. En gång per ”varv” i loopen görs en mätning av ljusstyrkan. Värdet skrivs till arrayen, på nuvarande index.

ldrReads[index] = analogRead(ldr);

Variabeln sum lagrar summan av de senaste mätningarna.

sum = sum + ldrReads[index];

genomsnittet får vi genom att dela summan på antalet mätningar:

average = sum / dataPoints;

Vi vill att sum ska vara summan av 8 olika mätningar, så vi kan inte bara lägga till varje ny mätning. Då skulle sum öka konstant. En av mätningarna behöver därför dras av. Det enklaste är att subtrahera en äldsta mätningen i början av loopen. Efter det kan vi skriva över med en ny mätning och summera. Raden nedan sker alltså innan de andra raderna, men koden blir lite lättare att förstå (och förklara) i den här ordningen.

sum = sum - ldrReads[index];

När en cykel av subtraktion, mätning och addition är gjord ökar index med 1.

index = index + 1;

Till sist behöver vi se till att index inte ökar okontrollerat. En if-sats är en enkel lösning: om index är lika med (eller större än) antalet datapunkter, så gör vi index till 0.

if (index >= dataPoints) {
index = 0;
}

Kalibrering med while

För bästa spelbarhet kan man kalibrera ljuskänsligheten innan man spelar.

Kalibreringen görs med while som fungerar ungefär som som if. Den har också ett villkor, och om det är sant körs koden inom loopens klamrar.

Skillnaden mot if är att while utförs om och om igen så länge tillståndet är sant. Programmet lämnar inte loopen förrän tillståndet är falskt. Koden i en if-sats kan också utföras många gånger, men efter varje gång går programmet vidare. Därför kan man använda while för att ”låsa in” programmet.

I fallet med kalibreringen ligger två while-loopar inuti en if-sats, i programmets setup-fas. Om man man håller ned knapp 2 inom en sekund (medan lysdiod 2 lyser) från omstart av Unon hamnar man i kalibreringsläget. Annars går programmet vidare till loop().

I kalibreringsläget kan man först spara ett värde för mörker, och sedan ett för ljus. Värdena används sedan i programmet, istället för standardvärdena 0 och 1023.

Först inväntar programmet ett exempel på mörker. Håll för ljussensorn och spara mätningen med knapp 1. Därefter väntar den på ett exempel på ljus – sluta skugga sensorn, och spara med knapp 3. Lysdiod 1 och 3 lyser i respektive lägen.

Om man vill kan man kalibrera synten tvärtom, och först låta ljus falla på sensorn och sedan skugga den. Då vänds map-funktionen uppochner och tonhöjden blir högre ju mörkare det blir.

Toner och oktaver

Du behöver inte kunna något om det västerländska tonsystemet för att spela på thereminen, men om du vill skriva om programmet (eller skapa ett egen) så kan det vara bra. Här kommer en kortfattad sammanfattning:

Det finns tolv olika tonklasser. De tolv tonklasserna namnges med bokstäver, men bara sju bokstäver används: A, B, C, D, E, F och G.(1)

  1. Det finns flera olika sätt att namnge tonerna. Vi använder det vanligaste och mer internationella systemet. Ett annat, mer traditionellt system används parallellt, främst i Tyskland och de skandinaviska länderna. Har du gått i Kommunala Musikskolan har du nog använt det.
    I det systemet heter tonen som vi här kallar B istället H, och Bb kallas istället B. Förvirrande? Japp! Tonernas namn är inte längre i alfabetisk ordning (A, H, B, C ...), och en av de svarta tangenterna har fått en egen bokstav.
    Å andra sidan – i Italien heter tonerna Do, Re, Mi, Fa, Sol, La, Ti. Inte så logisk ordning, men kanske lämpligt för en Arduino? Skämt åsido så kvittar det vad vi kallar tonerna. I programmet använder vi frekvenser i form av heltal!

Alla vita tangenter på ett piano motsvarar en av bokstäverna, från den lägsta tangenten (som är ett A) och uppåt, i alfabetisk ordning. Om man spelar stegvis uppåt så kommer man efter sju tangenter tillbaka till samma tonklass. Det avståndet kallas för en oktav.

Två olika A som ligger en oktav isär har frekvensförhållandet 1:2, t.ex. A (440Hz) och A (880Hz). De är olika tonehöjder men samma tonklass. Vi kan höra att två olika A är olika ljusa, men det är också tydligt att båda är samma ton, ett A. Nästa A, 1760Hz, är lika mycket A som de andra.

Om man flyttar man upp en melodi en hel oktav så låter den likadant, bara ljusare. Man kan till och med flytta upp (eller ner) bara en av tonerna i en melodi, och även om det kanske låter lite konstigt så hör man fortfarande att det är samma ton. Det här behöver man inte lära sig höra, utan är en naturlig effekt av att frekvenserna är dubbla, eller hälften.

De svarta tangenterna är antingen höjda eller sänkta ”versioner” av de vita tangenterna. Höjda toner anges med tecknet #, t.ex. F# (uttalas ”fiss”) eller med ett litet b, t.ex. Eb (uttalas ”ess”).

Oktaverna brukar ges nummer enligt något system. Vi har använt det vetenskapliga systemet. Där har den lägsta oktaven som man kan spela i på ett piano nummer 0(1).

  1. På ett vanligt piano finns bara en ton ur den oktaven, den högtsa: ett B.

Arrayen noter[]

I ljus-thereminen används en array med 86 toner från pianots register. (ett vanligt piano har 88 tangenter, men de lägsta två har så låg frekvens att tone() inte kan spela upp dem)

bild på en array med toner indelade i oktaver

I oktav 0 har vi alltså bara en ton – B0. På första platsen (på index 0) i noter[] ligger alltså tonen B.

Om vi vill spela tonen D1 får vi räkna upp: B, C, C#, D – alltså 0, 1, 2, 3.

Alltså får man skriva:

tone(spkr, noter[3]); // D1

Man kan också dela upp indexet som används med noter[] i två delar: tonklass och oktav. Då behöver man inte räkna lika mycket. Det är praktiskt att tone() börjar med frekvensen för ett B, för då börjar oktav 1 med ett C, som har nummer 1. Och C brukar betraktas som ”standardtonen” som man utgår ifrån.

Vill vi spela F# i oktav 3 får vi skriva:

tone(spkr, noter[7 + (2 * 12)]); // F#3

F# är den sjunde tonklassen, och lägger vi till (2 * 12) får vi en ton i oktav 3. Den första oktavens toner får vi ”gratis” med tal från 1-12, utan att lägga till oktaver. (Man kan tänka att (0 * 12) ger oktav 1.)

Genom att dela upp index i två delar slipper man räkna går det lätt att skriva program där man styr oktav och tonklass oberoende av varandra.

3: Servomotorer

Vad är en servomotor?

En servomotor är en motor som har ett inbyggt styrsystem, som kan läsa motorns position och använda informationen för att justera hur motorn drivs. Återkopplingen mellan avläst position och styrsystem gör att servomotorer kan arbeta mer precist än en enkel DC-motor, som bara kan rotera framåt eller bakåt (eller stå still, eller bromsas). Servomotorer kallas ofta bara för servon.

Återkopplingen gör också att styrningen av servon sker på ett annat sätt än hos enklare motorer. Istället för att styra hur motorn ska arbeta, så berättar man för styrenheten vad man vill ha för resultat, och så sköter den resten.

Servoutgången på Edu Shield är till för så kallade RC-servon. De styrs med en särskild sorts pulsmodulation som kallas PPM. PPM står för Pulse Position Modulation, och bygger på att en viss mängd av en 20ms-period är hög. Exakt hur PPM fungerar kan vi strunta i, eftersom det finns ett bibliotek för servomotorer som sköter det: Servo.h.

Edu Shield med ett servo inkopplat

Servo.h – ett av många bibliotek för Arduino

Bibliotek fungerar som en sorts expansioner, och kan möjliggöra nya funktioner eller göra vissa saker enklare. Varje bibliotek har en viss uppsättning kommandon och funktioner, som man kan läsa om i dess dokumentation.

Det finns mängder av olika bibliotek, med allt ifrån enstaka funktioner till stora komplicerade bibliotek som kräver mycket plats och processorkraft. Många bibliotek är skapade för en viss komponent, t.ex. en display eller en viss sensor.

Man kan behöva ladda ner och installera bibliotek, men många följer med i Arduino IDE. Servo.h är ett av standardbiblioteken som ingår, så vi lämnar hur man laddar ner och installerar bibliotek till en senare del.

För att använda ett bibliotek behöver man först säga till kompilatorn att det ska inkluderas i programmet, vilket man gör med kommandot #include. Detta ska man göra högst upp i dokumentet. Har man flera bibliotek behöver man en rad per bibliotek. Efter #include skriver man bibliotekets namn inom hakparenteser, <Servo.h>. Suffixet .h är egentligen inte en del av bibliotekets namn – bokstaven h står för header, som är ett namn för externa filer som ska användas i ett program.

Förutom bibliotek kan man använda header-filer med t.ex. stora mängder data som skulle ta onödigt mycket plats på skärmen om den fanns med i dokumentet. Vanliga header-filer läggs till på samma sätt som bibliotek, fast med citattecken istället för hakparenteser: #include "header.h". #include-raden ska inte avslutas med semikolon (;).

Styra servon med Servo.h

Servo.h är ett ganska enkelt bibliotek, och vi kommer bara använda ett fåtal kommandon i det. Först behöver servot namnges, vilket görs utanför setup() och loop() genom att skriva: Servo mittServo;

Man kan styra flera servon, upp till 12 med en Uno R3, och varje servo behöver ha ett namn. Efter att ha namngett servot behöver vi säga vilken utgång det styrs med. Det görs med funktionen attach(), som skrivs efter servonamnet: myServo.attach(pin);. Har man flera servon behöver såklart alla få varsitt namn och utgång.

I programmet finns även en pinMode() som gör servots pin till en utgång, men eftersom styr-ingången på servon inte drar särskilt mycket ström är egentligen inte ett krav.

Styrningen görs med kommandot write(), som bara har ett argument – en vinkel i grader, från 0 till 180.(1) Skriver vi myServo.write(90) så kommer servot ställa sig i sitt mittläge.

  1. Är du på hugget kanske du undrar om servot inte egentligen borde styras med vinklar från 0-179, eller 1-180? Svaret är nej. Talen 0-180 är bara ”namn” som inte nödvändigtvis motsvarar verkligheten. Ett riktigt servo kanske roterar över ett annat spann, t.ex. 0-90 grader eller 0-270. Vad servot verkligen gör kan inte Arduinon veta. Ungefär 180 grader är dock vanligast i enkla hobbyservon. Med den precisionen de har gör en grad hit eller dit ingen skillnad.

Från 0 till 180 på 4500 millisekunder

3b_servo_180_degrees.ino
#include <Servo.h>
Servo myServo;

const char pot = A1;
const char servo = 12;

int angle;

void setup() {
  myServo.attach(servo);
  pinMode(pot, INPUT);
}

void loop() {
  angle = analogRead(pot);                //potentiometern läses av, tilldelas variabeln angle
  angle = map(angle, 0, 1023, -50, 230);  // angle mappas till 180 grader plus det extra omgång poten har, tilldelas angle igen
  angle = constrain(angle, 0, 180);       // värden hos angle utanför 0-180 "klipps bort"
  myServo.write(angle);                   // resultatet skickas till servot
}

Servot styrs automatiskt, genom att räkna upp variabeln vinkel med 1 varje gång loopen körs. För att vinkeln inte bara ska öka och öka (över 180 grader) används också % 180. % är en operator – ett tecken som utför en beräkning – och betyder alltså inte procent.

% – rest efter division

Så vad gör tecknet, undrar du? % används för att beräkna rest efter en division, något som ofta kallas modulo eller modulus.

Moduloräkning kan göras på heltal, så det kan underlätta att tänka på t.ex. äpplen (som vi inte vill skära i mindre bitar). Om man rättvist delar 10 äpplen mellan nio personer, så får alla varsitt äpple, samtidigt som det blir ett äpple över. Det är resten – det som blir över – som vi är ute efter. 10 % 9 = 1.

När vinkeln räknas upp till 180, så blir resultatet 180 % 180 = 0 – inga äpplen över. Räkningen börjar om på 0. Resten kan aldrig bli större än talet efter %. 360 % 180 blir alltså också 0, och 361 % 180 blir 1, osv.

Om man beräknar rest med ett lägre tal i första ledet händer ingenting: 9 % 10 ger 9 (inga äpplen över). Vinklarna från 0 till 179 är alltså helt opåverkade av %-operationen.

% är väldigt användbar för att hålla tal inom ett visst omfång. Det förekommer lite här och var i programmen som följer.

Potentiometern som vinkelgivare

När du tänker på sensorer är kanske inte en potentiometer det första som dyker upp, men det faktiskt ett vanligt användningsområde för potentiometrar. När de används som spänningsdelare (som i Edu Shield) är spänningen de ger en produkt av hur man vrider ratten. Ett annat sätt att tänka på det är alltså att potentiometern mäter en vinkel.

Potentiometer som monteras i t.ex. robotik kallas ofta för vinkelgivare, men termen kan också syfta på rotationsenkodrar som mäter en vinkel.

I RC-servon finns det faktiskt redan en potentiometer som används som vinkelgivare – det är den som gör det möjligt för styrelektroniken att läsa av motorns position. När servot får instruktioner jämförs den uppmätta vinkeln med instruktionen, och om vinkeln är för stor eller för liten vrids motorn automatiskt åt rätt håll tills den har hamnat rätt. Servostyrningen i bilar fungerar ungefär så, även om de anpassar hur mycket hjulen svängs till bilens hastighet – ju fortare man kör, desto mindre känslig blir styrningen.

3b_servo_180_degrees.ino
#include <Servo.h>
Servo myServo;

const char pot = A1;
const char servo = 12;

int angle;

void setup() {
  myServo.attach(servo);
  pinMode(pot, INPUT);
}

void loop() {
  angle = analogRead(pot);                //potentiometern läses av, tilldelas variabeln angle
  angle = map(angle, 0, 1023, -50, 230);  // angle mappas till 180 grader plus det extra omgång poten har, tilldelas angle igen
  angle = constrain(angle, 0, 180);       // värden hos angle utanför 0-180 "klipps bort"
  myServo.write(angle);                   // resultatet skickas till servot
}

I detta lilla program styr vi servots vinkel med potentiometerns vinkel. Vi stöter direkt på ett problem: potentiometern på EK Edu Shield har en rotation på ungefär 280° – 100 grader mer än ett 180-gradersservo. Med andra ord kan potentiometern rotera 50 grader mer moturs och medurs, sett från mittpunkten.

Vi börjar med att använda map() för att anpassa värdena från poten (0-1023). Men istället för att mappa dessa till tal mellan 0 och 180 grader, ska vi mappa dem till tal mellan -50 och 230. Vi har lagt till 50 grader över och under 0-180, så att mittläget är det samma, och vinklar mellan 0-180 stämmer överens mellan pot och servo.

Sedan tar vi bort de extra graderna 100 med funktion en constrain(). Den begränsar ett tal så det hålls inom två värden. Det fungerar ungefär som %, men begränsar även ”nertill”. Med 0 och 180 som argument får vi ut tal mellan 0-180, oavsett vad som kommer in. Vinklar som servot inte kan hantera tas bort, och servot ”struntar i” när man vinklar potentiometern utanför 180°.

Spela in och spela upp rörelser

Det här programmet spelar in potentiometerns rörelser, och härmar dem med servot. Uppspelningen kan göras framlänges eller baklänges, och hastigheten kan varieras steglöst från halvfart upp till 10x hastigheten. Så här styr fungerar det:

  • Vid uppstart gör Edu Shield ingenting.
  • Knapp 1 aktiverar inspelningen, som pågår så länge knappen hålls intryckt.
  • När inspelningen är aktiv sparas potentiometerns vridningar i minnet.
  • Så fort inspelningsläget stoppas börjar inspelningen spelas upp med servot.
  • Värdena som går till servot visas också med lysdiod 1.
  • Knapp 2 ändrar till baklänges uppspelning. Då lyser lysdiod 2 och lysdiod 3 släcks.
  • Knapp 3 ändrar till framlänges uppspelning, och lysdiod 3 tänds (2 släcks). Detta är normalläget vid uppstart.
  • Uppspelningshastigheten styrs med potentiometern. Vrid den medurs (”höj volymen”) för att få snabbare uppspelning, och moturs för långsammare.
  • Man kan när som helst spela in en ny rörelse med knapp 1.
  • (Potentiometern påverkar bara hastigheten när uppspelning sker.)
  • (Maximal längd på inspelningen är 10 sekunder.)
3c_motion_recorder.ino
#include <Servo.h>
Servo myServo;

const char btn1 = 13;
const char btn2 = 7;
const char btn3 = 8;
const char led1 = 11;
const char led2 = 10;
const char led3 = 9;
const char servo = 12;
const char pot = A1;
const int recMemSize = 1000;  // hur mycket minne som finns till inspelningarna.
// På en Uno R3 finns det inte mycket minne över, men har du en R4 kan du prova att öka recMemSize.
// Då kan du spela in längre sekvenser, eller minska recTime och att få mer detaljerade inspelningar.
bool firstRec;          // används för att förhindra uppspelning förrän något har spelats in
bool btn1state = 0;     // om man inte skriver något så tilldelas ändå 0, men ibland vill man vara extra tydlig
bool btn1stateOld = 1;  // alla btn#state och btn#stateOld används på samma sätt.
bool btn2state = 0;
bool btn2stateOld = 1;
bool btn3state = 0;
bool btn3stateOld = 1;
char playDir = 1;                     // uppspelningsriktningen kan vara positiv (framåt) eller negativ (bakåt)
unsigned char recValues[recMemSize];  // en konstant från längre upp används här för en variabel. Så länge konstanten ligger ovanför variabeln är det inga problem alls
unsigned char angle;                  // angle är en unsigned char eftersom den kommer sparas i recValues, som också är unsigned char
int index;                            // skulle kunna vara unsigned, men pga. den enkla metoden för att backa ”uppspelningen” så går värdet ner till -1 när inspelningen spelas baklänges, vilket inte funkar med unsigned
unsigned int recLength;               // inspelningens längd sparas här
unsigned long newTime;                // newTime och oldTime används för att hantera timing av inspelning och uppspelning
unsigned long oldTime;                // unsigned long kan gå från 0 till 4294967295
unsigned char recTime = 10;
unsigned int playTime;

void setup() {
  pinMode(servo, OUTPUT);
  myServo.attach(12);
  pinMode(btn1, INPUT_PULLUP);
  pinMode(btn2, INPUT_PULLUP);
  pinMode(btn3, INPUT_PULLUP);
  pinMode(led1, OUTPUT);
  pinMode(led2, OUTPUT);
  pinMode(led3, OUTPUT);
  // Serial.begin(115200);
}

void loop() {
  // inspelning
  btn1state = digitalRead(btn1);    // läser av knapp 1 och tilldelar btn1state
  if (btn1state != btn1stateOld) {  // om nuvarande tillstånd inte är lika med föregående går programmet in i if-loopen
    index = 0;                      // först nollställs index
    digitalWrite(led1, 0);          // sedan släcks
    digitalWrite(led2, 0);          // alla tre
    digitalWrite(led3, 0);          // lysdioder
    btn1stateOld = btn1state;       // tilldelar nuvarande tillstånd till det gamla (båda = 0)

    while (digitalRead(btn1) == LOW) {                  // så länge knapp 1 hålls inne
      newTime = millis();                               // sparar ett klockslag i millisekunder i newTime
      if (newTime >= oldTime + recTime) {               // ser om "klockan" (newTime) är mer" än oldTime + recTime (normalt 10ms)
        oldTime = newTime;                              // sparar den nya tidpunkten till den oldTime
        angle = map(analogRead(pot), 0, 1023, 0, 180);  // läser av poten och det till 0-180, tilldelar resultatet till angle
        recValues[index] = angle;                       // skriver variabeln angle till arrayen recValues på platsen index
        recLength = index;                              // sparar nuvarande index till variabeln recLength
        index++;                                        // ökar index med 1
        if (index == recMemSize) {                      // om index blir för stort
          break;                                        // så lämnas while-loopen
        }
      }
    }                                   // efter while-loopen görs två saker:
    firstRec = 1;                       // markerar att en första inspelning har gjorts
    digitalWrite(led2, (1 - playDir));  // lysdioderna tänds enligt riktningen (framåt eller bakåt)
    digitalWrite(led3, (1 + playDir));  //
  }

  //uppspelning
  playTime = map(analogRead(pot), 0, 1023, (recTime * 10), (recTime / 2));
  newTime = millis();
  if ((newTime >= oldTime + playTime) && (firstRec == 1)) {
    oldTime = newTime;
    angle = recValues[index];
    myServo.write(angle);
    if (index < 0) { index = recLength; }  // om index blir mindre än 1 (när uppspelning görs baklänges)
    if (index > recLength) { index = 0; }  // om index blir för stort
    index += playDir;
    analogWrite(led1, map(index, 0, recLength, 0, 100));
  }

  // spela upp baklänges
  btn2state = digitalRead(btn2);
  if ((btn2state != btn2stateOld) && (firstRec == 1)) {
    if (btn2state == 0) {
      playDir = -1;
      digitalWrite(led2, 1);
      digitalWrite(led3, 0);
    }
    btn2stateOld = btn2state;
  }

  // spela upp framlänges
  btn3state = digitalRead(btn3);
  if ((btn3state != btn3stateOld && (firstRec == 1))) {
    if (btn3state == 0) {
      playDir = 1;
      digitalWrite(led2, 0);
      digitalWrite(led3, 1);
    }
    btn3stateOld = btn3state;
  }
}

Det finns en hel del nyheter i det här programmet. Vi tar det som vanligt uppifrån och ner, och fokuserar på det som är nytt.

Efter deklarationen av de vanliga konstanterna kommer det variabler. Här finns det lite nya datatyper som bool och unsigned long. De mer bekanta int och char förekommer i två former, vanlig (positiva och negativa tal) och unsigned (enbart positiva tal).

bool och long

Datatypen bool är minst av dem alla. Den använder bara en bit i minnet. Den kan ha två olika värden: 1 eller 0. En bool tar upp väldigt lite minne, och lämpar sig för saker som bara har två tillstånd, som ifall en lysdiod är tänd eller inte.

Namnet bool är en förkortning av boolean, som kommer från George Boole och den Booleska algebran – som rör just binära tal. (I Arduino kan man också skriva boolean istället för bool, men det rekommenderas inte.)

Liksom int är datatypen long heltal, men de använder 32-bitar istället för 16 int använder. Det gör att de kan lagra betydligt större tal än int – faktiskt 65536 gånger större. Som du kommer se behöver vi kanske inte riktigt så stora tal, men mer än vad som får plats i en 16-bitars int behövs definitivt.

Overflow – när ett tal blir för stort

Du undrar kanske vad det innebär att en ett tal ”får plats i” en datatyp. Vad händer när talen blir för stora? Det som händer kallas overflow, talen ”flödar över”. Det som händer när ett värde överskrids är att talet ”viks runt” och börjar om, ungefär som att klockan inte fortsätter bli mer efter 23:59.

Om en unsigned char har värdet 255 (max för datatypen) och vi lägger till 1, så kan inte resultatet bli 256. Istället blir det det lägsta möjliga för datatypen: 0. Det fungerar också med större tal: 255+10 ger 9, osv.(1)

  1. Det här beror på hur binära tal representeras. Talet 255 skrivet med binära tal blir 1111 1111 – åtta ettor. När man lägger till ett och får 256 behövs nio bitar: 1 0000 0000. Men en unsigned char har bara åtta bitar. När processorn har gjort klart additionen och ska spara den på variabelns minnesplats finns det inte plats för den den nionde biten (räknat från höger). Den försvinner, och det som sparas är de kvarvarande åtta nollorna.

Subtraherar vi 1 från 0 blir resultatet 255. Det kallas istället för underflow.

För datatyper med teckenbit fungerar det på motsvarande sätt. Plus 1 från 127 (i en char) ger -128, osv.

Undantaget från den här regeln är bool, som beter sig lite oväntat(1). Räknar man upp en bool från 1, så får man inte 0, utan 1 igen. Men om man räknar ner från 0, så får man 1. Räknar man ner igen, så får kommer man såklart tillbaks till 0!

  1. Öppna programmet extra_bool_addition_subtraction.ino om du vill se hur märkligt bools beter sig.

Större är inte alltid bättre

Data som över- eller underskrider datatypens omfång är en vanlig orsak till oväntade fel, så det gäller att välja rätt. Att bara välja de största datatyperna för säkerhets skull är en dålig vana, inte minst för att de tar upp mycket minne. Någon enstaka int eller long i onödan kvittar kanske, men om man skapar arrayer (som i detta program) blir det viktigare att välja rätt. Arrayer tar upp plats som dess datatyp, multiplicerad med antalet platser i arrayen, så det kan snabbt gå åt mycket minne.

Arrayen recValues har 1000 platser. Där lagras inspelningarna från potentiometerns rotationer. Om den hade haft datatypen int skulle arrayen behöva vara väldigt kort för att få plats i minnet på en Uno R3, och Vi skulle bara kunna spela in jättekorta sekvenser. Då är unsigned char ett bättre alternativ. Dess omfång på 0-255 är mer än nog för vinklar från 0-180 grader.

Tabell med datatyper

Datatyp Antal bytes Minsta värde Maximalt värde Exponent
bool 1/8 (en bit) 0 1 21
char 1 -128 127 -27 till 27-1
unsigned char 1 0 255 28
int (16-bit) 2 -32 768 32 767 -215 till 215-1
unsigned int (16-bit) 2 0 65 535 216
short 2 -32 768 32 767 -215 till 215-1
unsigned short 2 0 65 535 216
long 4 -2 147 483 648 2 147 483 647 -231 till 231-1
unsigned long 4 0 4 294 967 295 232
float 4 -3.402823538 3.402823538 -

Förutom namnen ovan kan datatyper också skrivas med teckenkombinationer. De kan vara lite kluriga att komma ihåg, men när man väl kan mönstret förstår man vad en variabel har för typ, och slipper memorisera vad en long eller short innebär. Mönstret är så här:

  • int = heltal med teckenbit
  • uint = positiva heltal
  • Efter något av dessa följer en siffra som anger storleken på variabeln, alltså 8, 16 eller 32.
  • Till sist lägger man till _t. Det står för typ. Varför det behövs kan vi strunta i.

Med den här kunskapen kan du deklarera ett 8-bitars heltal med teckenbit – int8_t – som inte automatiskt tolkas som tecken, som char annars gör.

Edge detection – när knappen trycks in eller släpps

Det första vi stöter på är inspelningsfunktionen. Den aktiveras med knapp 1, men funktionen är lite mer invecklad än tidigare. Knappens tillstånd kan sparas i två variabler, btn1state och btn1stateOld, som jämförs i villkorssatsen i en if. När Arduinon har startat är btn1state = 0, och btn1stateOld = 1. Loopen hade körts direkt, om det inte var för att knapp 1 först läses av, och värdet tilldelas till btn1state.

Eftersom knapparna ger 0 när de trycks in och 1 när de inte trycks in, så tilldelas 1 direk till btn1state (förutsatt att du inte håller inne knappen vid uppstart). Alltså hoppas resten av inspelningsfunktionen över.

När du trycker på knapp 1 kommer programmet in i den första if, eftersom btn1state inte är lika med btn1stateOld - tillståndssatsen använder (!=). Det som händer då är att variabeln index nollställs,(1) och alla tre lysdioder släcks (vid uppstart är de dock redan släckta).

  1. Vilket den i och för sig redan är vid uppstart – men när du spelar in den andra gången så behöver index nollställas.

Efter detta tilldelas värdet i btn1State till btn1stateOld. Båda är nu 0. Det blir användbart senare, när du har släppt knappen. Kom ihåg det!

Att spara och jämföra två olika tillstånd på det här viset brukar kallas för edge detection, eftersom det kan reagera på när ett tillstånd förändras, istället bara när något har ett eller ett annat tillstånd. Om du tänker dig knappens tillstånd över tid kan du förstå var namnet kommer från – när den trycks in eller släpps blir spänningen låg eller hög, med skarpa kanter när tillståndet förändras.

Att reagera på förändringar låter en göra saker en gång – antingen när knappen trycks in, eller när den släpps, eller båda två.

Om man t.ex. vill räkna upp ett värde varje gång en knapp trycks in kan man inte använda en if som körs om en knapp är intryckt. Koden i if skulle nämligen köras väldigt många gånger under tiden att knappen är intryckt, och Arduinon räkna upp värdet så fort den kan. Och även om du trycker väldigt fort, så kan vi lova att din Arduino är snabbare.

Om du vill läsa koden i en lite enklare version så finns det två exempel som fungerar likadant (men inte har lika mycket kod inuti) längst ner i programmet.

Inspelningen

Själva inspelningen görs inom en while-loop, så länge som knapp 1 hålls inne.

Först sparas nuvarande tidpunkt i newTime, med funktionen millis(). Det millis() gör är att rapportera det nuvarande ”klockslaget”, i millisekunder. Arduinon vet inte vad klockan är, men den har en intern räknare som räknar uppåt en gång i millisekunden. Den börjar räkna så fort Arduinon har kommit igång (innan setup() har hunnit börja).

Om newTime hade varit en unsigned int istället för unsigned long så hade tiden inte fått plats efter lite mer än en minut – 655536 millisekunder är lite mer än 65,5 sekunder. Det är därför vi använder en större datatyp. Den räcker i 4,294,967,296 millisekunder, vilket är lite mindre än 50 dagar.

Hur tidtagningen används

Därefter en if-sats. I dess villkor jämförs newTime med oldTime + recTime. recTime avgör hur ofta potentiometerns läge läses av. Vid uppstart är oldTime 0, och recTime är alltid 10.

newTime å andra sidan har ett värde motsvarande hur många millisekunder det har gått sedan Arduinon startade. Programmet körs alltså om newTime är mindre än 10, den första gången (försök trycka på knappen inom 10 millisekunder om du kan!). newTime är ju större än eller lika med oldTime + recTime.

Det första som händer i if-satsen är att tidpunkten i newTime tilldelas oldTime. När resten av if-satsen är klar så kommer programmet vänta i 10 millisekunder från den nya oldTime. När 10 millisekunder har gått så kommer newTime åter vara >= oldTime+recTime.

Istället för att använda delay() sparar vi och jämför tidpunkter, på ett sätt som liknar edge detection ovan. Den här tekniken är användbar – när delay() är aktiv är processorn upptagen med att vänta, och kan inte göra något annat – inte ens märka att du släpper en knapp. Med millis() kan du ha flera olika tidtagnings-funktioner igång samtidigt, som inte påverkar varandra.

Här är ett enklare program som använder edge detection, som du gärna får testa. Som du ser blir effekten väldigt olika när man inte använder delay() – de två blinkhastigheterna är oberoende av varandra.

3d_blink_without_delay.ino
// illustration av skillnaden mellan timing med delay() och timing med millis()
// håll inne knapp 1 för att blinka led 2 och 3 med varsin delay() med olika hastighet
// håll inne knapp 3 för att blinka led 1 och 2 med varsin millis() med olika hastighet

const char btn1 = 13;
const char btn2 = 7;
const char btn3 = 8;
const char led1 = 11;
const char led2 = 10;
const char led3 = 9;

bool led1state;
bool led2state;
unsigned long newTime1;
unsigned long oldTime1;
unsigned long newTime2;
unsigned long oldTime2;

void setup() {
  pinMode(btn1, INPUT_PULLUP);
  pinMode(btn2, INPUT_PULLUP);
  pinMode(btn3, INPUT_PULLUP);
  pinMode(led1, OUTPUT);
  pinMode(led2, OUTPUT);
  pinMode(led3, OUTPUT);
}

void loop() {
  if (digitalRead(btn1) == 0) {
    digitalWrite(led2, 1);
    delay(1000);
    digitalWrite(led2, 0);
    delay(1000);
    digitalWrite(led3, 1);
    delay(350);
    digitalWrite(led3, 0);
    delay(350);
  }
  if (digitalRead(btn3) == 0) {
    newTime1 = millis();
    newTime2 = millis();
    if (newTime1 >= oldTime1 + 1000) {
      oldTime1 = newTime1;
      led1state = !led1state;
      digitalWrite(led1, led1state);
    }
    if (newTime2 >= oldTime2 + 350) {
      oldTime2 = newTime2;
      led2state = !led2state;
      digitalWrite(led2, led2state);
    }
  }
}

Inspelningen – skriva i en array[]

Själva inspelningen av potentiometern är enkel. Först läses potentiometern av, och map() ser till att värdena hålls till ett omfång som passar servot, 0-180. Resultatet tilldelas variabeln angle. Innehållet i angle sparas sedan i arrayen recValues[].

Att tilldela till (skriva i) arrayer fungerar ungefär som när vi tilldelar värden till variabler. En array är ju i princip en lång lista med variabler. Precis som när vi läser ur arrayer så måste vi säga vilken av dem vi vill tilldela till. Det görs (som du säkert minns från pianotonerna) med ett index, som börjar på 0. Och index i det här programmet nollställdes ju precis, så det är bara att börja skriva.

Det index vi just skrev till sparas sedan till variabeln recLength. Den kommer användas i uppspelningen, för att se till att den inte spelar upp hela vår array, utan bara den information vi spelade in sist.

Därefter räknas index upp med 1. Här skriver vi det på ett nytt sätt: index++. Operatorn ++ kallas på engelska för increment och är ett smidigt sätt att öka något med 1. Det har skapats eftersom sådan uppräkning görs så ofta att man behöver ett snabbt sätt att skriva det. Man skriver inget likhetstecken, tilldelningen sker automatiskt. Skrivsättet minVariabel++ motsvarar alltså minVariabel = minVariabel +1. Det finns ett motsvarande sätt att minska med ett, som heter decrement och görs med minustecken: --, exempelvis: index--.

break avbryter loopar

När vi använde arrayen med pianotangenter behövde vi undvika att programmet läste utanför arrayen. När vi nu ska skriva i en array är detta mycket viktigare. När vi skriver i arrayer skriver vi i processorns minne, och om vi skriver utanför en array, så skriver koden på platsen bredvid arrayen i processorns minne. Vad som finns där kan man inte veta på förhand. Men att skriva över okända platser i minnet är aldrig en bra idé.

Förut använde vi constrain() för att begränsa index. Alla försök att läsa utanför arrayen ledde till att den sista platsen lästes. Nu när vi skriver skulle constrain() fungera hyfsat, men med ett problem. Programmet skulle nu skriva över den sista platsen i minnet. Ingen katastrof, men datan kan bli lite konstig. Istället för att begränsa index ska vi istället använda en if-sats för att helt lämna while-loopen, om vi försöker skriva efter arrayens slut.

if (index == recMemSize) {
break;
}

Kommandot break kan användas för att direkt lämna vissa loopar. De kontrollstrukturer som break kan användas i är dels tre sorters loopar: while, do...while och for; samt switch...case (som inte är en loop).(1) Om man försöker använda break utanför någon av dessa strukturer kommer programmet inte kompilera.

  1. Kort Om de andra looparna:
    do...while fungerar som while, med skillnaden att villkoret kontrolleras i slutet av loopen. En do-while körs därför alltid minst en gång, även om villkoret inte är sant.
    for ska vi återkomma till senare. Den är väldigt användbar.
    switch...case, är som en sorts if-sats med ”extra allt”, med många tillstånd med olika villkor och kodblock. Alla villkoren bygger på samma variabel, t.ex. kan olika kodblock köras beroende när variabeln är lika med, 0, 1, 2, 3, 4, 5 osv...

break påverkar alltså inte if-satsen som den ligger i, utan det som if-satsen själv finns i. Om if-satsen körs aktiveras break, och while-loopen lämnas direkt, precis som om villkoret blev falskt.

Ingen kod efter en break kommer utföras om den har aktiverats. I det här fallet finns det ingen kod efter break (inom dess while) så det spelar ingen roll. Men det är bra att veta att break bryter strukturen helt, direkt.

Efter inspelningen

När knappen släpps eller break aktiveras så lämnas inspelningsläget. Strax ska inspelningen spelas upp – men åt vilket håll? Lysdiod 2 och 3 används för att visa vilken riktning som används: 2 betyder baklänges, och 3 betyder framlänges. Istället för att tända och släcka lysdioderna i uppspelningen – alltså varje gång ett nytt värde spelas upp – så är det en bättre idé att tända och släcka dem en gång. När man trycker på knapparna (knapp 2 och 3) är det lätt gjort med if-satser. Men vi vill att uppspelningen ska gå i den riktning som valdes senast, och att lysdioderna ska tändas så for inspelningen är klar. Alltså måste vi använda variabeln playDir för att tända och släcka lysdioderna. Det görs så här:

När vi spelar upp framlänges är playDir lika med 1. För baklänges uppspelning är det istället ‑1. I funktionen digitalWrite() ger alla tal utom 0 en hög spänning, och alltså en tänd lysdiod. Vi kan därför inte använda playDir direkt för att styra lysdioderna. -1 är ju inte lika med 0. Istället kan vi subtrahera playDir från 1 för led2. Det ger ekvationen 1 - 1 = 0. 0 innebär en släckt lysdiod, vilket är rätt när uppspelningen går framåt.

För led3 adderar vi istället 1 till playDir, vilket ger 1 + 1 = 2, vilket gör att lysdiod 3 tänds.

När uppspelningen går baklänges sker det omvända.

Första inspelningen

Till sist ska en bool-variabel, firstRec, bli 1. Om firstRec är 0 spelas ingenting upp. Den är bara 0 innan en första inspelning är gjord. Variabeln firstRec är med för att Arduinon annars kommer spela upp ”ingenting” innan en inspelning finns. När firstRec är 1 kommer den vara det till Arduinon startas om – det finns inget i programmet som kan skriva över variabeln efter detta.

Lämna inspelningsläget

Kommer du ihåg att både btn1state och btn1stateOld var = 0? Nu blir det relevant:

Eftersom du just har släppt knappen(1) så är spänningen hög på knappens ingång. Nästa gång programmet läser av den ingången kommer alltså de två tillstånden inte vara samma – alltså går det in i inspelnings-if-satsen igen. Där nollas index, lysdioderna släcks (igen), och btn1stateOld får samma värde som btn1state: 1. Men eftersom knappen inte är intryckt, hamnar programmet inte i while-loopen, och ingen ny inspelning görs. Istället tänds lysdioderna som de ska (igen), och if-satsen lämnas. Nu kan uppspelningen börja. (tiden då lysdioderna släcks är så kort att du inte hinner se den.)

  1. Om programmet har lämnat while() för att du höll inne knappen i tio sekunder fungerar det här inte. Index kommer nollställas igen när du släpper den. Fundera gärna på hur du kan lösa det lilla problemet.

Uppspelningen

Uppspelningens timing sköts av en liknande bit kod som den som spelar in rörelser. Dess if-sats har två villkor, som kombineras med logisk OCH: &&. Ett villkor är samma sorts timing-kod som inspelningen har, men med variabeln playTime istället för recTime. Det andra villkoret är att firstRec ska vara 1, alltså att något har spelats in.

Uppspelningen består av att en vinkel läses ur recValues, och skickas till servot. Innan index räknas upp (eller ner) görs en koll att det nuvarande värdet inte är för litet eller för stort. Två if-satser ser till att index för avläsningen ”viker runt” och börjar om från början av recValues – eller ifrån slutet, beroende på vilken riktning rörelsen spelas upp.

Sammansatta operatorer

När den kontrollen är gjord räknas index vidare genom att adderas med playDir. Om playDir är -1 så minskar index stegvis, och om playDir är 1 så ökar det. Raden som sköter detta är skriven med en annan finurlig kortform: Istället för index = index + playDir står det index += playDir. Operatorer som skrivs så kallas för compound operators, eller sammansatta operatorer, och bygger på att operatorn (+) skrivs direkt före ett likhetstecken, utan mellanrum. Den fungerar med många andra operatorer (t.ex. +=, *=, %=), och det som står efter likhetstecknet kan vara siffror, variabler, funktioner, osv.

Skrivsättet sparar tid och plats, och du kommer stöta på det många gånger, här och i andra program. Det används särskilt för saker som utförs upprepade gånger.

Programmet visar också hur index stegas upp (eller ner), genom att tända lysdiod 1 olika mycket. En map()-funktion skalar om variabeln index så att analogWrite() får värden från 0-100. Att vi inte valde är 0-255 är för att det kan vara svårt att se skillnader i ljusstyrka när lysdioden är så stark – med 100 som ljusaste blir det lättare att se förändringen.

Byta riktning

Till sist kommer två bitar kod som använder samma sorts edge detection som förut, men där koden som utförs inom if-satserna är lite enklare. Det enda som händer i dem är att riktningen på uppspelning ändras. Knapp 2 gör playDir negativ (baklänges uppspelning), tänder led1 och släcker led3. Knapp 3 fungerar likadant fast tvärtom.

4: NeoPixlar och andra adresserbara LED

I den här delen får du lära dig om adresserbara lysdioder och hur du använder den som sitter på Electrokit Edu Shield. Kunskapen kan appliceras på alla liknande adresserbara lysdioder: på slingor, i ringar, matriser och liknande. Adresserbara lysdioder kallas ofta för NeoPixlar, som är Adafruits namn på dem. Det är också namnet på det bibliotek vi kommer använda för att styra Neopixeln (skapat av Adafruit).

Neopixlar (och andra adresserbara lysdioder) är egentligen små integrerade kretsar, med flera lysdioder bakom ett litet fönster, och en inbyggd mikrokontroller som direkt styr lysdioderna. Det finns flera modeller som kallas Neopixel, t.ex. WS2812, WS2811 och SK6812. Det vanligaste är tre lysdioder med färgerna röd, grön och blå. Genom att blanda färgerna med olika ljusstyrkor kan man få fram, gul, rosa, osv. Med samma ljusstyrka på röd, grön och blå får man ett hyfsat vitt ljus, men det finns också modeller med en fjärde vit lysdiod, eller med tre vita lysdioder.

Det finurliga med adresserbara lysdioder är att man kan koppla flera i serie. Man kan ha väldigt många NeoPixlar på rad. De begränsande faktorerna är främst mikrokontrollerns minne och lysdiodernas strömförbrukning.

Meddelandenas utformning

Neopixlar styrs med seriella meddelanden. Meddelandena består av värden för ljusstyrkan för de tre lysdioderna i varje Neopixel. När man styr en Led-slinga skickas en lång lista på sådana meddelanden, ett för varje Neopixel. Varje enskild rad i listan har värden från 0-255 för de tre färgerna (eller fyra, för RGBW-Leds).

Färgvärdena anges med tal från 0-255, precis som när vi styrde de vanliga lysdioderna analogt.

Den första NeoPixeln läser den första instruktionen och styr sina lysdioder, och skickar vidare resten av meddelandet. Nästa gör samma sak, tills alla NeoPixlar har fått sin instruktion. Meddelandet blir alltså kortare och kortare varteftersom det rör sig längs slingan.

Man kan alltså styra Neopixlarna individuellt, inte bara ändra färg på hela slingan. Det är därför de kallas för adresserbara lysdioder.

Även om man bara vill ändra färgen på den sista NeoPixeln i en lång kedja så måste någon data skickas till varenda NeoPixel fram till den. Om man vill att alla andra ska ha kvar sin förra färg behöver de alltså få samma instruktioner som de redan hade. Det finns inget meddelande som betyder ”fortsätt som förut”.

Eget minne

Innan de har fått några instruktioner så är alla NeoPixlar släckta. När de väl har fått instruktioner fortsätter de lysa tills de får någon annan instruktion. Precis som med digitalWrite() måste man aktivt släcka dem.

Instruktionen lagras i ett eget litet minne i varje NeoPixel så länge den får ström. Även om du startar om din Uno så kommer de fortsätta lysa som förut. Det gäller till och med om du laddar in ett helt annat program på Unon - om inte det nya programmet inte skickar några instruktioner, såklart.

Biblioteket Adafruit_NeoPixel.h

Det biblioteket Adafruit_Neopixel.h gör är att ta emot färgblandningar, och skapa de särskilda digitala pulser som Neopixlar förstår. Det enklaste sättet att använda det är att skapa färgvärden för alla Neopixlar i en slinga. Biblioteket har också funktioner för att skapa färgblandningar på olika sätt, styra ljusstyrkan (oberoende av färgen), korrigera ljusstyrkan utifrån mänsklig syn, återkalla olika NeoPixlars instruktioner ur minnet, m.m. Några av sätten används längre fram.

Förbereda för NeoPixlar

Precis som med Servo.h så behöver man göra vissa grundinställningar för att styra Neopixlar. Först inkluderas biblioteket, med #include <Adafruit_NeoPixel.h>. Därefter behöver man skapa ett NeoPixel-objekt, precis som med servon. Varje objekt motsvarar en utgång på Unon. När man skapar objekten behöver man ange: - Namn - Antal NeoPixlar (en eller fler) - Utgång - Färgordning - Datafrekvens

Det finns nämligen några olika varianter av NeoPixlar, som förväntar sig färgerna i olika ordning. Det finns också några olika datafrekvenser.

För NeoPixeln på Edu Shield gäller dessa inställningar:

Adafruit_NeoPixel neo_led(1, 6, NEO_GRB + NEO_KHZ800);

800KHz och GRB är vanligast.

Ordningen på färgerna behöver man inte bry sig om efter detta, utan sköts av biblioteket. Funktionerna i biblioteket förväntar sig alltid färgerna i ordningen röd, grön, blå (RGB). Om man byter mellan olika Led-modeller kan man alltså återanvända sin kod och bara ändra inställningarna för objektet.

Starta NeoPixel

Därefter behöver objektet aktiveras, och precis som för Serial heter funktionen begin(). Om man har flera slingor behöver alla startas. Det går också att stoppa NeoPixel-objekt med end().

Eftersom NeoPixlar fortsätter lysa även om programmet byts ut kan det vara bra att nollställa dem i setup() med funktionen clear(), som släcker alla NeoPixlar i en slinga. Då ser man lättare vad programmet gör.

neo_led.begin();
neo_led.clear();

Efter detta är NeoPixeln färdig att använda.

Begränsa ljusstyrka

Eftersom NeoPixlar kan lysa väldigt starkt kan det vara en bra idé att begränsa ljusstyrkan med setBrightness(). Annars blir man lätt trött i ögonen när man sitter och kodar och testar sitt program. Ange ett värde från 1-255 (0 rekommenderas inte). setBrightness() ”trycker ihop” färgvärdena man har skapat för att begränsa ljusstyrkan, Det gör att man får sämre detaljontroll över färgblandningen med låga värden. Samtidigt blir det lite lättare att kontrollera lägre värden med t.ex. en potentiometer. Och det är just det vi ska göra nu.

Blanda RGB

4a_neopixel_intro.ino
#include <Adafruit_NeoPixel.h>
// dimma lysdioderna i en neopixel med tre knappar och poten

Adafruit_NeoPixel neo_led(2, 6, NEO_GRB + NEO_KHZ800);  // antal NeoPixlar, utgångspin samt typ av NeoPixel
const char btn1 = 13;
const char btn2 = 7;
const char btn3 = 8;
const char pot = A1;

bool btn1mode;
bool btn2mode;
bool btn3mode;
bool change;  // 1 om färg har uppdaterats, annars 0
unsigned char potVal;
unsigned char red;  //färger
unsigned char grn;
unsigned char blu;
unsigned char brt = 64;  // ljusstyrka. om du vill ha mer ljus får du ändra själv, 0-255

void setup() {
  neo_led.begin();             // startar NeoPixel-objektet neo_led, ungefär som Serial.begin()
  neo_led.clear();             // nollställer alla NeoPixlar i en slinga.
  neo_led.setBrightness(brt);  //ställer in den totala ljusstyrkan
  pinMode(btn1, INPUT_PULLUP);
  pinMode(btn2, INPUT_PULLUP);
  pinMode(btn3, INPUT_PULLUP);
  // Serial.begin(115200);
}

void loop() {
  change = 0;                    // nollställer change - om ingen knapp är intryckt fortsätter det att vara 0
  btn1mode = digitalRead(btn1);  // läser in knapparna
  btn2mode = digitalRead(btn2);
  btn3mode = digitalRead(btn3);

  if ((btn1mode == 0) || (btn2mode == 0) || (btn3mode == 0)) {  // om knapp 1 eller knapp 2 eller knapp 3 är intryckt
    potVal = analogRead(pot) >> 2;                              // så läses poten av, bitarna skiftas höger 2 steg
    if (btn1mode == 0) red = potVal;                            // värdet tilldelas till de färger vars knapp är intryckt
    if (btn2mode == 0) grn = potVal;
    if (btn3mode == 0) blu = potVal;
    change = 1;  // noterar att något har ändrats och att en uppdatering bör skickas till NeoPixeln
  }
  if (change == 1) {                          // om färgen har förändrats
    neo_led.setPixelColor(0, red, grn, blu);  // sparas en färg i RAM för NeoPixel 0
    neo_led.show();                           // sedan uppdateras alla pixlar
  }
}

Detta program liknar programmet för att dimra de tre lysdioderna. Här styrs de tre lysdioderna i NeoPixeln.

Knapp 1 styr röd, knapp 2 grön, och knapp 3 blå. Håll inne en knapp och vrid på potentiometern: medurs ger mer ljusstyrka. Knapparna fungerar samtidigt, så du kan justera flera färger samtidigt. Du kan också vrida poten och sedan trycka på en knapp för att göra snabba hopp i ljusstyrka.

Jämfört med det programmet för att dimra lysdioder så är det här lite mer kompakt och slimmat. Jämför gärna! Istället för att läsa av poten i varje knapps if-sats görs det bara en gång. Dessutom görs det bara om någon av knapparna är intryckt. Först läses knapparna av, och deras 1 eller 0 sparas i varsin bool. Alla tre bools är med i if-satsens villkor:

if ((btn1mode == 0) || (btn2mode == 0) || (btn3mode == 0))

Tecknen mellan parenteserna ||(1) betyder logisk ELLER (OR): villkoret uppfylls alltså om någon av variablerna är lika med 0.

  1. Tecknet | heter lodstreck, men programmerare använder ofta det engelska namnet bar eller ibland pipe. Man skriver det med ctrl + alt + < på Windows, eller alt + 7 på macOS.

Inuti den första if-satsen finns ytterligare tre if som har de tre knapparna som villkor. Där avgörs vilka färgervariabler som ska få potens värde. De är skrivna på ett mer kompakt sätt än tidigare, utan klamrar. Skrivsättet fungerar om man skriver det som hade stått inuti klamrarna (if-satsens kod) direkt efter villkoret, och avslutar raden med semikolon. Det sparar plats, och passar när när if-satser inte innehåller så mycket kod.

Spara och skicka färger

Variabeln change tilldelas också väret 1. Detta markerar att någon färg har förändrats, och gör att programmet går in i den sista if-satsen, där NeoPixeln styrs. Först sparas färgernas värden i minnet (i mikrokontrollern, inte NeoPixlarnas minnen):

neo_led.setPixelColor(0, red, grn, blu);

Funktionen setPixelColor() ställer in färgen på en enda NeoPixel. Det första argumentet anger vilken NeoPixel, och man räknar som vanligt upp från 0.

Datan skickas sedan med funktionen show(), som inte har några argument. Den skickar bara ut instruktionerna till alla NeoPixlar i ett objektet.

Man kan (och bör) spara alla färgvärden innan man skickar – inte skicka nya meddelanden varje gång en enda NeoPixel har fått en ny färg. Med långa slingor blir annars det mycket data som ska skickas, något som tar förhållandevis lång tid. Detta kan påverka andra delar av koden, eftersom NeoPixlarnas styrning har en del egenheter som påverkar interrupts(1) och funktioner med precis timing negativt.

  1. En interrupt (ett avbrott) är när processorn på en signal utifrån avbryter det den gör, för att göra något tidskritiskt, t.ex. beräkna varvtal utifrån en pulsgivare på en axel. Om att sådana funktioner ska fungera korrekt kan de inte vänta på processorn. delay() och millis() är exempel på funktioner som använder interrupts, och som kan påverkas när data skickas till NeoPixlar.

I det här enkla exemplet kvittar det egentligen, precis som att vi inte sparar särskilt mycket tid på att läsa av potentiometern en gång istället för tre. Men det är alltid en bra idé att skriva kod så att operationer utförs så sällan som möjligt. Av den anledningen ligger setPixelColor() och show() i en egen if-sats, som bara utförs om change är lika med 1 (alltså om någon knapp har varit intryckt).

  • Prova vilka färger du kan få till. Om du sänker ljusstyrkan väldigt mycket börjar de individuella lysdioderna i NeoPixeln synas. Du lär också upptäcka att det är svårt att få till bra nyanser mellan röd, grön och blå med svagare ljusstyrka.
  • Fundera på hur du skulle kunna förändra programmet. Prova till exempel att plocka ut koden för edge detection och använda den.

Färgblandare med HSV

RGB är ett av många sätt att beskriva färgblandningar. Ett annat sätt kallas HSV, som står för Hue, Saturation och Value. På svenska heter termerna kulörton, mättnad och intensitet, men färgsystemet kallas ändå för HSV.

4b_neopixel_HSV.ino
#include <Adafruit_NeoPixel.h>
#include <RotaryEncoder.h>
Adafruit_NeoPixel neo_led(2, 6, NEO_GRB + NEO_KHZ800);
RotaryEncoder rotEncoder(5, 4, RotaryEncoder::LatchMode::FOUR0);

const char btn1 = 13;
const char btn2 = 7;
const char btn3 = 8;
const char led1 = 11;
const char led2 = 10;
const char led3 = 9;
const char pot = A1;
const char encBtn = 3;

bool encBtnState;
bool encBtnStateOld = 1;
bool btn2state;
bool btn2stateOld = 1;

char direction;
char step;
char encPosNew;
char encPosOld;
unsigned char stepScale;
unsigned char val = 64;
unsigned char sat;
int stepSize = 24;
unsigned int hue;
unsigned long rgbColour;
unsigned long newTime;
unsigned long oldTime;

void setup() {
  Serial.begin(115200);
  pinMode(btn1, INPUT_PULLUP);
  pinMode(btn2, INPUT_PULLUP);
  pinMode(btn3, INPUT_PULLUP);
  pinMode(led1, OUTPUT);
  pinMode(led2, OUTPUT);
  pinMode(led3, OUTPUT);
  pinMode(encBtn, INPUT_PULLUP);
  neo_led.begin();
  neo_led.clear();
  digitalWrite(led1, 1);
}

void loop() {
  // färgton (hue)
  rotEncoder.tick();  // läser av enkodern
  encPosNew = rotEncoder.getPosition();
  if (encPosNew != encPosOld) {
    encPosOld = encPosNew;
    direction = (char)rotEncoder.getDirection();
    step = direction * stepSize;
    hue += step;
  }
  encBtnState = digitalRead(encBtn);
  if (encBtnState != encBtnStateOld) {
    if (encBtnState == 0) {
      stepScale += 2;                        // stepScale ökar med två
      stepScale %= 6;                        // stepScale kan inte bli 6. ökar så: 0, 2, 4, 0, 2, 4
      stepSize = 6 << (2 + stepScale);       // stepScale räknas om till 2, 4, 6. ger stepSize = 24, 96, 384
      digitalWrite(led1, (stepScale == 0));  // tänder led 1 om stepScale är minst
      digitalWrite(led2, (stepScale == 2));  // motsv. led 2
      digitalWrite(led3, (stepScale == 4));  // motsv. led 4
    }
    encBtnStateOld = encBtnState;
  }
  // färgton klar
  //
  // mättnad (saturation)
  sat = analogRead(pot) >> 2;  // mättnad styrs kontinuerligt av potentiometern
  // mättnad klar
  //
  // intensitet (value)
  if (digitalRead(btn1) == 0) {  // minskar intensitet
    newTime = millis();
    if ((newTime >= oldTime + 25) && (val > 0)) {
      oldTime = newTime;
      val--;
    }
  } else if (digitalRead(btn3) == 0) {  // ökar intensitet
    newTime = millis();
    if ((newTime >= oldTime + 25) && (val < 255)) {
      oldTime = newTime;
      val++;
    }
  }
  // intensitet klar
  //
  // alla tre värden är klara - dags att skicka till NeoPixeln
  rgbColour = neo_led.ColorHSV(hue, sat, val);  // kombinerar HSV till RGB
  neo_led.fill(rgbColour);                      // gör att alla NeoPixlar får färgen rgbColour
  neo_led.show();                               // uppdaterar alla NeoPixlar

  // slutligen så kan man skicka information om färgen till datorn med knapp 2
  btn2state = digitalRead(btn2);
  if (btn2state != btn2stateOld) {
    if (btn2state == 0) {
      Serial.print("hue: ");
      Serial.println(hue);
      Serial.print("saturation: ");
      Serial.println(sat);
      Serial.print("value: ");
      Serial.println(val);
      Serial.print("combined RGB, hex: ");
      Serial.print("0x");
      Serial.println(rgbColour, HEX);
      Serial.println();
    }
    btn2stateOld = btn2state;
  }
}

I HSV-systemet är kulörtoner (H) ordnade i en cirkel, med gradvisa övergångar från en till en annan. Runt cirkeln har vi det vi till vardags kallar för färger.

Mättnad (S) anger hur starkt färgad en blandning är – höga värden ger klara färger, medan låga värden ger mer urvattnade färger. Man kan se det som att mättnad är ett mått på skillnaden mellan de tre färgernas (röd, grön och blå) ljusstyrka. Vid låga värden jämnas ljusstyrkan ut mellan de tre lysdioderna och ingen dominerar - färgblandningen drar mot vitt. Om mättnaden är 0 spelar kulörtonen ingen roll – alla tre lysdioder lyser lika mycket.

Sist kommer intensitet (V), som anger hur starkt färgen lyser. Intensiteten sätter i praktiken ett tak för hur ljust de tre lysdioderna kan lysa tillsammans. Med hög intensitet kan alla tre lysdioder styras med styrkor från 0-255. Med låga värden på intensitet blir det som sagt svårare att få till bra färgblandningar.

HSV circle Färgväljare med en cirkel för att välja nyans (hue), och en trekant med två separata axlar: mättnad (sat) från grå till röd, och intensitet (value) från svart till vit. Lysdioder kan ju inte vara svarta, men de kan vara släckta. Triangeln roterar när man väljer färg, och pekar på den aktuella färgen.

ColorHSV()

Med funktionen ColorHSV() kan man skapa blandningar HSV-systemet. Funktionen tar tre argument: kulörton (hue), mättnad (saturation) och intensitet (intensity). Argumenten anges med positiva heltal; kulörton med 16 bitar (0-65535), och de andra två med 8-bitars positiva heltal (0-255).

Funktionen tar de tre HSV-värdena och räknar ut motsvarande värden för röd, grön och blå, och packar ihop dem till ett 32-bitars tal (uint32_t). När blandningen är klar kan den skickas till en eller flera NeoPixlar med fill().

Funktionen fill() liknar setPixelColor() men kan hantera mycket fler NeoPixlar. Dess första argument är en färgblandning (32 bitar), därefter kommer start-pixel och hur många pixlar som ska fyllas med färgen. Man kan alltså skicka en färgblandning till bara 10 pixlar i en längre slinga, och t.ex. börja med NeoPixel nummer 15 (alltså pixlar 15-24). Funktionen skapar automatiskt ett meddelande så att de andra pixlarna har kvar sin tidigare färg. Man kan också strunta i argumenten för start-pixel och antal, då fylls alla pixlar i slingan. Om man bara anger start-pixel utan antal så fylls alla från och med den pixeln till slutet.

fill() skickar inte data, den sparar bara färger i det minne på mikrokontrollern som ägnas åt NeoPixel-färger. Därför behöver man också använda show().

Välja kulörtoner med rotationsenkodern

Eftersom kulörtonerna finns i en cirkel (utan slut) är inte potentiometern ett särskilt bra sätt att välja. Efter ungefär 280 grader tar det ju stopp, så vid någon kulörton skulle man behöva vrida tillbaka hela varvet för att fortsätta runt cirkeln. Dessutom kan man skapa kulörton med 16 bitars precision, medan upplösningen i A/D-omvandlaren som läser av potentiometern har bara 10 bitars upplösning. Många kulörtoner skulle därför vara omöjliga att nå.

Istället används rotationsenkodern. Rotationsenkodrar är digitala komponenter. Även om de ser ut som potentiometrar så fungerar de på ett helt annat sätt. För varje hack du känner så sluter och öppnas två strömbrytare i enkodern, i en viss ordning. Vilken ordning beror på vilket håll du vrider åt.

Det finns många olika sorters rotationsenkodrar med olika pulsmönster. Den på Edu Shield är inkrementell. Som du kanske gissat så är namnet relaterat till increment (++) och decrement (--). Enkodern kräver två digitala ingångar, varsin för de två inbyggda strömbrytarna. Enkodern har också en inbyggd tryckknapp, som har använder en tredje ingång.

Informationen som mikrokontrollern får från en rotationsenkoder är relativ. Det innebär att man kan räkna ut åt vilket håll rotationsenkodern har vridits, men inte dess absoluta position - vartåt den pekar. Pulserna ser alltid likadana ut (beroende på riktning), till skillnad från potentiometern som ger olika spänning beroende på vinkeln.

Inkrementella enkodrar är väldigt vanliga och förekommer i t.ex. tvättmaskiner och mikrovågsugnar, på kontrollpaneler i bilar, i ljudutrustning, labbutrustning och lödstationer, och otaliga andra ställen. Ibland kallas rotationsenkodrar för pulsgivare, men det namnet används oftare för absoluta rotationsenkodrar, som fungerar annorlunda. Sådana används i t.ex. robotik.

Rotary encoder pulse Pulserna från en rotationsenkoder. CW är rotation medurs, och CCR är rotation moturs. D står för _detent som är hacken man känner när man vrider enkodern._

Att läsa av de snabba pulserna korrekt och räkna upp eller ner utan fel är inte helt lätt. Pulserna kan komma tätt, och kontaktstudsar från brytarna i omkopplaren gör det ännu svårare att registrera varje hack korrekt. För att underlätta ska vi därför använda biblioteket RotaryEncoder.h.

Rotaryencoder.h

Först skapar vi ett objekt för rotationsenkodern.

RotaryEncoder rotEnkoder(5, 4, RotaryEncoder::LatchMode::TWO03);

Först anges digitala ingångar för de två omkopplarna. Ordningen 5 och 4 gör att RotaryEncoder.h räknar uppåt när vi vrider enkodern medurs, och neråt moturs. Det som kommer efter har att göra med hur rotationsenkodern fungerar. RotaryEncoder.h räknar ut flera olika saker från rotationsenkoderns pulser, bland annat har den ett internt värde som räknas upp (eller ner) för varje hack man vrider enkodern.

Kulörtoner från enkoderns riktning

Koden som använder rotationsenkodern och tar fram kulörtoner ser ut så här:

rotEnkoder.tick();
encValNew = rotEnkoder.getPosition();
if (encValNew != encValOld) {
encValOld = encValNew;
direction = (char)rotEnkoder.getDirection();
step = direction * stepSize;
hue += step;
}

Funktionen tick() är den som gör att enkodern läses av. Det skulle man kunna göra periodiskt med millis(), eller genom använda interrupts. I detta enkla program gör vi det helt enkelt varje gång loopen börjar om.

Värdet som räknas upp eller ner får man med funktionen getPosition(). Det tilldelas till variabeln encPosNew. Sedan jämförs detta nya värde med ett äldre (encPosOld) i villkoret hos en if-sats. Så fort värdet har ändrats går programmet in if. Det här sättet att upptäcka förändringar fungerar precis som när vi läste av knapparna med edge detection, men jämför flera olika tal istället för bara 0 och 1.

Efter detta tilldelas rotationsriktningen – som vi får med getDirection() – till variabeln direction.

Casting

Funktionen getDirection() ser ut som getPosition(), med en skillnad: det står (char) precis innan. Anledningen till detta är att funktionen getDirection inte returnerar en char. Den returnerar faktiskt inte värden med någon datatyp alls. Istället returnerar den en enum, vilket står för enumerated type. Värdet som returneras är ett av flera värden på en begränsad, numrerad lista. I det här fallet väljs en av riktningarna NOROTATION (0), CLOCKWISE (1) och COUNTERCLOCKWISE (-1).

En enum – och värdet som returneras – behöver inte ha någon datatyp alls. Så är det också i det här fallet. Om man försöker tilldela dem till en variabel så kommer kompilatorn därför ge ett felmeddelande. Lösningen är att ”påtvinga” en datatyp på funktionen, eller rättare sagt på på det funktionen returnerar. Att göra så kallas casting eller typecastning och görs genom att skriva datatypen inom parentes precis innan funktionen (utan mellanrum):

direction = (char)rotEnkoder.getDirection();

På det sättet kan man berätta för kompilatorn att den ska betrakta det som returneras som att det vore värden med den datatypen, och inte varna.

Eftersom kompilatorn inte kommer hindra när man castar så bör man göra det med försiktighet. Man måste välja en datatyp som är lämplig för alla tänkbara värden som kan returneras. Annars kan man få väldigt konstiga problem som är svåra att felsöka.

I det här fallet vet vi att datatypen char rymmer alla talen i listan (-1, 0 och 1) utan problem.

Olika stora steg

När riktningen har tagits fram beräknas en förändring i kulörton. Eftersom det finns 65536 olika kulörtoner så är det inte särskilt lyckat att bara öka eller minska variabeln hue – det skulle ta 65536 hack att komma runt cirkeln (med 30 hack per varv blir det ungefär 2184 varv).

Därför multipliceras variabeln direction med talet stepSize (som beskrivs längre ner), och tilldelas till variabeln step. Slutligen adderas step (som alltså kan vara negativt) med hue:

step = direction * stepSize;
hue += step;

Variabeln stepSize kan ha tre olika värden, som man stegar igenom genom att trycka in rotationsenkoderns knapp. Knappen fungerar precis som de andra knapparna på Edu Shield (aktiv låg), och är ansluten till pin 3, som har fått namnet encBtn. Den läses av med digitalRead() på samma sätt som tidigare.

För varje tryck på knappen ökar variabeln stepScale med 2. Variabeln börjar på 0, och precis efter additionen beräknas division med rest med %= 6. stepScale därför ökar från 0, till 2, till 4. Istället för att öka till 6 återgår den sedan till 0, osv.

stepSize räknas ut med ekvationen 6 << (2 + stepScale). Talet 2 läggs först till stepScale (0, 2 eller 4): och blir 2, 4 eller 6. Det resultatet används för att skifta talet 6 åt vänster: 6 << 2, eller 6 << 4, eller 6 << 6. Resultaten blir 24, 96 eller 384.

De tre vanliga lysdioderna visar den nuvarande skalningen.

Operatorernas prioritetsregler

Precis som i matematik utförs de olika räknesätten i en viss ordning. I C++ är ordningen inte bara viktig för uträkningar med räknesätten, utan också för andra operatorer som strukturerar programmet snarare än räkna fram ett resultat av flera tal.

Prioritetsreglerna för de fyra vanliga räknesätten kan du kanske redan, och de fungerar likadant i C++. Multiplikation och division görs före addition och subtraktion. Om både multiplikation och division (som har samma prioritet) görs i samma uträkning, så utförs den vänstra först. Det är lätt att komma ihåg: en rad med många beräkningar utförs som man läser, från vänster till höger. Men för andra operatorer gäller det motsatta, att de utförs från höger till vänster. och det finns väldigt många operatorer. Funktioner som max() eller pow(), många olika räknesätt, tilldelning och annat kan samsas på en enda rad.

Istället för att lära sig prioritetsordningen för allt detta kan man använda parenteser för att dela upp en komplicerad rad i mer begripliga beståndsdelar. Varje parentes “räknas klart” för sig, och kan sedan kombineras på ett lättbegripligt sätt. Och med parenteser inuti parenteser går det att reda ut även den mest invecklade kombinationen. Tilldelning med = har väldigt låg prioritet, så du kan vara säker på att dina uträkningar är klara när resultatet ska “sparas” i en variabel. Om du vill veta vilka regler som gäller så finns det en bra lista här.

Mättnad och intensitet

Mättnad (saturation) ställs in rakt av med potentiometern. Bitvis skifte två steg åt höger anpassar A/D-omvandlarens tio-bitars tal till mättnads-värdets 8 bitar.

Intensitet (value) styrs med knapparna 1 och 3. Båda styr varsin if-sats, i vilka variabeln val räknas upp eller ner så länge knappen hålls intryckt. Det görs med en millis().

Villkoren för att minska respektive öka val ser ut så här:

if ((newTime >= oldTime + 25) && (val > 0)) { ...
if ((newTime >= oldTime + 25) && (val < 255)) { ...

Det första ledet i båda villkoren är att 25 millisekunder har gått sedan newTime senast tilldelades oldTime. Variabeln ökar eller minskar alltså 40 steg per sekund.

Timingen kombineras med logisk OCH med en kontroll av variabeln val. Variabeln tillåts bara minska om den är större än 0, och öka om den är mindre än 255. Kontrollen görs före eventuell minskning eller ökning görs, så när val är 1 kan det minska en gång till, till 0. På samma sätt kan 254 öka till 255, men inte längre. val hålls inom 0 och 255 och kan inte öka eller minska ”runt” datatypens gränser.

Seriell dump

Med programmet ovan kan man få till många olika färger. Hur ska du göra om du vill spara en kombination? Edu shield ger ju ingen information om värdena på H, S och V. Om man vill se värdena så kan man skicka dem till datorn med knapp 2.

Meddelandena skickas med Serial.print() och Serial.println(). Skillnaden mellan dem är att print() inte gör något radbrott (println() står för print line). Det som skrivs ut efter en print() syns direkt efter det som har skrevs ut innan, på samma rad – ett bra sätt att kombinera textmeddelanden och variabler.

Det kombinerade värdet rgbColour skrivs ut med två argument: dels det som ska skrivas ut, och dels formatet HEX – mer om detta längre ner.

Några saker du kan testa

  • Förändringarna i intensitet upplevs inte helt linjär. Skillnaden i intensitet mellan 0 och 127 är betydligt större än den mellan 127 och 255. När ljusstyrkan börjar bli hög ser vi inte lika lätt att intensiteten ändras. Prova att använda funktionen gamma8() och se hur kurvan förändras. Hur du ska göra får du ta reda på själv!

0x123ABC – hexadecimala tal

Färgen för NeoPixlar anges alltså med åtta-bitars tal för färgerna röd, grön och blå (och vit, för sådana NeoPixlar). Som vi har såg tidigare kan man ange de tre kulörerna var för sig, eller skapa dem från kulörton, mättnad och ljusstyrka (HSV). I båda fallen skapas sedan ett ”packat” 32-bitars heltal, som är det som faktiskt används för att styra NeoPixeln.

Vi kan också ange färger med hexadecimala tal. Hexadecimala tal är inte annorlunda än andra tal, de skrivs bara på ett annat sätt. Hexadecimala tal används ofta i programmering och andra datorsammanhang. Skrivsättet kan bland annat göra vissa data mer överskådlig. De tar också mindre plats på skärmen.

Vi har tidigare gått igenom ett annat skrivsätt: binära ral. I det används bara två siffror, 0 och 1. Ett annat namn på det är bas 2. Hexadecimala tal har istället basen 16. Våra vardagliga decimala tal har bas 10.

Våra arabiska siffror (0-9) räcker inte till för att skriva med bas 16. Därför kompletterar man med bokstäver, A till F. När man räknar upp med hexadecimala tal kommer alltså A efter 9 - inte 10. Sedan kommer B, C osv. När tecknen tar slut börjar man om, på samma sätt som med bas 10 eller 2. Efter F (som betyder 15) kommer alltså 10 (som betyder 16). Precis som i bas 1 och bas 10 skriver man inte ut inledande nollor (vi skriver inte 0100 när vi menar hundra).

Så här räknar man till 33:

0, 1, 2, 3, 4, 5, 6, 7, 8, 9, A, B, C, D, E, F,
10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 1A, 1B, 1C, 1D, 1E, 1F,
20, 21 ... osv

För att berätta för kompilatorn att ett tal är skrivet i hexadecimalt format behöver man inleda med 0x, som i rubriken ovan (0xABC123). Motsvarande skrivsätt för binära tal är 0b: t.ex. 0b11101.

Ett annat sätt att visa basen är med en siffra i nedsänkt läge: 1D16, 111011 eller 2910 – tre olika sätt att skriva talet 29.

Hexadecimala tal för färger

En siffra i bas 16 (0-F) motsvarar ett av 16 olika värden. Något annat som också gör det är fyra bitar – 24. Ett hexadecimalt tecken motsvarar alltså fyra bitar. Nu förstår du kanske varför hexadecimala tal är populära hos programmerare.

Hexadecimala tal används ofta för att ange just RGB-färger. Jämfört med att förstå vad ett hexadecimalt tal motsvarar i bas 10 så är det ganska lätt att förstå dem som styrkor för olika färger. Så här fungerar det:

Tabell med hexadecimala tal för färger

Ett ”packat” 32-bitars färgvärde för en NeoPixel består av fyra stycken 8-bitars tal, för färgerna vit (som vi ignorerar), röd, grön och blå. Fyra gånger åtta är lika med åtta gånger fyra – därför passar det bra att skriva 32-bitars tal med åtta hexadeximala siffror – två siffror per färg.

Du behöver inte kunna läsa hexadecimala tal, men det är bra att känna igen dem. De går att räkna om med uträknare på olika hemsidor, eller direkt i sökmotorer. Många miniräknare på datorer har lägen för programmerare. Där kan manväxla mellan olika baser, och se talen man skriver in i binär form. I dem kan man också räkna med t.ex. bitvis skiften (>> och <<). Du kan också använda Arduino och visa tal i olika format i Serial Monitor.

Undrar du vad talet i rubriken, 0x123ABC, blir i bas 10, eller vilken färg det blir? Det får du ta reda på själv!

5: Personräknare

I det här projektet skapar vi en enkel personräknare med hjälp av ljussensorn. En lampa riktas mot ljussensorn, och varje gång en person korsar ljusstrålen reagerar sensorn på skuggan och räknar uppåt. Sensorn kan såklart räkna andra saker, t.ex. föremål som passerar på ett löpande band, bilar, eller kanske blixtar (om man ändrar så att programmet reagerar på ljus istället för mörker).

5_person_counter.ino
int btn1 = 13;
int btn2 = 7;
int btn3 = 8;
int ldr = A0;
int pot = A1;

unsigned int count;
unsigned int light;
unsigned int brightest;
unsigned int lowThreshold;
unsigned int highThreshold;
unsigned int hysteresis;
unsigned int darkest = 1024;

void setup() {
  Serial.begin(115200);
  pinMode(btn1, INPUT_PULLUP);
  pinMode(btn2, INPUT_PULLUP);
  pinMode(btn3, INPUT_PULLUP);

  for (char i = 0; i <= 31; i++) {                    // en for-loop som utförs 32 gånger
    light = analogRead(ldr);                          // sensorn läses av
    brightest = max(light, brightest);                // det ljusaste värdet sparas
    delay(7);                                         // en liten fördröjning mellan mätningarna, som annars hade gjorts så fort det går
  }                                                   // Eftersom många lampor kan flimra med nätfrekvensen 50Hz (eller multiplar av den) kan en udda fördröjning som 7ms vara bra.
                                                      // 50Hz nätfrekvens ger 20ms mellan topparna, så 10ms fördröjning hade kanske inte gett oss ett bra maxvärde.
  lowThreshold = brightest * 0.75;                    // som utgångspunkt är tröskelvärdet 75% av det ljusaste värdet
  highThreshold = lowThreshold + (lowThreshold / 5);  // det högre tröskelvärdet är 20% högre
  Serial.println(count);                              // visar 0 i Serial monitor
}

void loop() {
  light = analogRead(ldr);           // mätning av ljus görs alltid
  if (light < (lowThreshold)) {      // om den nuvarande ljusstyrkan är lägre än tröskelvärdet
    count++;                         // så räknas variabeln count upp med 1
    Serial.println(count);           // talet skickas över serieporten
                                     // eftersom talet bara skickas när det ändras skickas inte en massa onödig data
    while (light < highThreshold) {  // så länge det är mörkt är programmet kvar i den här loopen
      light = analogRead(ldr);       // och det som görs i loopen är att se om det fortfarande är mörkt
    }                                // annars hade programmet räknat upp kontinuerligt medan personen eller föremålet skuggade ljussensorn
  }                                  // nu räknas bara en skugga i taget

  if (digitalRead(btn1) == LOW) {               // knapp 1 startar kalibrering av mörker
    Serial.println("dark calibration ... ");    // ett meddelande om kalibrering skickas över serieporten
    darkest = 1023;                             // darkest ställs till maximalt ljusvärde
                                                // själva kalibreringen ligger i en while-loop
    while (digitalRead(btn1) == LOW) {          // medan knappen hålls nere
      light = analogRead(ldr);                  // läses ljussensorn av
      darkest = min(darkest, light);            // och det lägsta värdet från ljussensorn sparas
    }                                           //
    hysteresis = (brightest - darkest) / 5;     // beräkning av hysteres
    lowThreshold = darkest + hysteresis;        // beräkning av ett lägre tröskelvärde
    highThreshold = lowThreshold + hysteresis;  // beräkning av ett högre tröskelvärde
    Serial.println(count);                      // värdet skickas åter till serieporten
  }

  if (digitalRead(btn2) == LOW) {              // knapp 2 startar kalibrering av ljus
    Serial.println("light calibration ... ");  // ett meddelande om kalibrering skickas över serieporten
    brightest = 0;                             // variabeln brighest nollställs
                                               // själva kalibreringen ligger i en while-loop
    while (digitalRead(btn2) == LOW) {         // medan knappen hålls nere
      light = analogRead(ldr);                 // läses ljussensorn av
      brightest = max(brightest, light);       // och det lägsta värdet från ljussensorn sparas
    }                                          //
    Serial.println(count);                     // värdet skickas åter till serieporten
  }

  if (digitalRead(btn3) == LOW) {           // knapp 3 nollställer räknaren
    count = 0;                              // count tilldelas 0
    Serial.println("resetting count ...");  // och ett meddelande om återställning skickas över serieporten
    while (digitalRead(btn3) == LOW) {      // medan knappen hålls nere ...
                                            // så gör programmet ingenting.
    }                                       // annars hade det skickat nollor och reset-meddelenaden så fort det gick
    Serial.println(count);                  // och det nollställda värdet skickas sedan över UART
    delay(10);                              // en kort fördröjning förhindrar upprepade meddelanden från kontaktstudsar
  }

Personräknarens uppgift är enkel i teorin. För att fungera bra behöver programmet dock hantera störningar i spänningsmatningen och variationer i ljuset.

Kalibrering av ljus i setup()

Räknaren ska reagera när ljussensorn skuggas – alltså när värdena från analogRead(ldr) sjunker under ett visst tal. Men hur ska vi veta vilket? Programmeraren vet ju inte på förhand i vilken miljö sensorn kommer placeras, eller hur starkt lampan lyser. Därför har programmet två olika kalibreringsrutiner. Den första görs automatiskt i setup(). Kalibreringen ligger inuti en for-loop.

for

En av de mest använda looparna i C++ heter for. Den liknar både while och if. Liksom dem innehåller for-loopar ett kodblock, som skrivs inom {klamrar}. En viktig skillnad är att for-loopar har ett inbyggt sätt att bestämma hur många gånger koden utförs. Detta gör att de är särskilt användbara för att t.ex. läsa och skriva i arrayer.

Liksom while och if har for-loopen ett villkor, men den har också två andra parametrar. De skrivs inom en parentes och skiljs åt med semikolon. Båda handlar om en variabel som är central för hur for-loopar fungerar. Den kan heta olika saker (precis som andra variabler). I exempel på for brukar den få heta i.

for: initialisering

Den första parametern för for heter initialisering. Här anger vi ett startvärde för variabeln i. Instruktionen utförs en gång, innan något annat i for-loopen sker.

Om variabeln inte behövs utanför for-loopen är det enklast att skapa den i initialiseringen. Precis som när man deklarerar variabler på andra ställen så behöver man ange ett namn och en datatyp. I det här fallet deklareras en char med namnet i och startvärdet 0.

for (char i = 0; ...

Man kan strunta i initialiseringen helt. I så fall behöver variabeln deklareras någon annanstans. Då kan den även användas (och tilldelas) på andra ställen i programmet.

for: villkor

Den andra parametern är villkoret, som också utgår från variabeln i. Om villkoret uppfylls så utförs koden i for-loopens klamrar.

for (char i = 0; i <= 100;

Villkoret kontrolleras innan for-loopen utförs. Det kan göras många gånger – hela grejen med for-loopar är ju att göra saker ett visst antal gånger. Varje gång villkoret är sant så utförs koden inom for-loopens klamrar.

Villkoret behöver inte jämföras med ett fast tal, talet 100 i exemplet ovan skulle också kunna vara en annan variabel, t.ex. Precis som i if-satsens villkor kan jämförelsen också göras med ==, eller andra jämförelseoperatorer.

Eftersom villkoret kontrolleras först efter att variabeln har initialiserats så är det ofta redan sant. Om man bara vill att for-loopen ska köras under vissa omständigheter så kan man lägga hela loopen inuti en if

Ett annat sätt är att använda villkoret som ett villkor i en vanlig if-sats. I så fall kan man inte initialisera i till 1 i for-loopens parentes (för då är det ju alltid sant). Istället behöver man deklarera variabeln utanför loopen. Någonting i programmet behöver sedan tilldela den olika värden, för att avgöra om loopen ska köras eller inte.

for: inkrementering

Den sista parametern kallas inkrementering. Det här steget görs efter att loopens kodblock har utförts. Precis som jämförelsen till villkoret kan detta hända flera gånger. Här räknar man om i på något sätt. Ett vanligt sätt är att inkrementera, med i++:

for (char i = 0; i <= 100; i++)

Även om steget brukar kallas inkrementering, så måste man inte nödvändigtvis räkna upp i. Man kan till exempel räkna ner det med i--, eller räkna upp det på andra sätt, exempelvis i *= 2.

Variabeln i kan användas i loopen. Man kan till exempel använda det som index i en array:

for (char i = 0; i <= 100; i++) {
minArray[i] = analogRead(ldr);
}

Man kan också räkna om i inuti kodblocket, men man får såklart hålla koll på det så att inte for-loopen råkar hålla på för evigt – det hände om villkoret aldrig kan bli falskt.

Kort om scope

När man skapar en variabel inuti en loop eller funktion så ”finns” den bara där. När programmet lämnar loopen tas variabeln bort och går inte att använda utanför loopen (eller funktionen). Till exempel kanske man deklarerar en variabel i setup() – den kan då inte användas i loop(). Det här konceptet kallas scope. Vi kommer inte gå in på det närmare här. Alternativet till att deklarera variabler i funktioner och loopar är att deklarera dem utanför dem – som vi har gjort tidigare, ovanför setup().

Kalibreringsrutinen

Kalibreringsrutinen använder en for-loop som ligger i setup():

for (char i = 0; i <= 31; i++) {
light = analogRead(ldr);
brightest = max(light, brightest);
delay(7);
}

Den körs automatiskt (eftersom i initialiseras till 0, vilket är mindre än 31). Villkoret gör att loopen körs 32 gånger.

Först mäts ljusstyrkan med analogRead(), och tilldelas variabeln light. Därefter används funktionen max() för att jämföra det uppmätta värdet med variabeln brightest (som från början är 0). Det max() gör är att ta två tal, och returnera det högsta av dem.

Eftersom brightest är 0 kommer light vara större (om det inte är helt mörkt, eller sensorn är trasig). Resultatet tilldelas brightest. När det är klart väntar loopen med delay(). Därefter har koden i klamrarna utförts och inkrementeringen utförs: i räknas upp med i++.

Om nästa värde är ljusare sparas det över. Resultatet blir att brightest kommer ha det högsta resultatet av 32 olika mätningar.

I det här fallet används inte variabeln i alls i for-loopens kod, det används bara för att begränsa antalet mätningar.

Varför just delay(7)?

Om ljuset som ska skuggas lyser med ett konstant sken så ska man inte behöva göra flera mätningar över tid. Det finns dock fall då det är nödvändigt att mäta flera gånger. Om man bara gör en mätning och en lampa flimrar (kanske utan att vi kan se flimret) finns risken att ljussensorn ger ett väldigt lågt utslag. Även elektriska störningar i Unon kan påverka mätvärdet. En liten fördröjning på 7 ms gör att mätningarna sträcker sig över längre en tid än om processorn hade mätt 32 gånger så fort den kan.

Det lite udda värdet är valt av en anledning: många ljuskällor flimrar med nätspänningens frekvens (50Hz), eller multiplar av den, t.ex. 100Hz. En fördröjning på 10ms kan se mer ”normalt” ut, men det skulle faktiskt göra att mätningarna gjordes med en frekvens på 1000 / 10 = 100Hz. De skulle alltså ligga i fas med flimret. Vilket resultat man får med mätningar var 10:e ms skulle då bero på när i 50Hz- cykeln man råkar starta personräknaren. Har man otur mäter man bara när lampan lyser som svagast. Med 7ms mätintervall hamnar mätningarna på olika punkter i cykeln och man får ett urval som inte korrelerar med variationerna i lampans ljusstyrka.

Mätningen av ljus ger ingen information av hur mörkt det är när en person skuggar sensorn, så som utgångspunkt beräknas ett tröskelvärde för skugga, lowThreshold, som 75% av ljuset. Ett till tröskelvärde (highThreshold) beräknas – varför får du strax veta.

När kalibreringen av ljuset är klar lämnas setup(). Själva programmet har fyra delar: ljusmätningen, som normalt är aktiv och triggar personräknaren, samt tre funktioner som aktiveras med knappar: kalibrering av mörker (knapp 1), kalibrering av ljus (knapp 2) samt nollställning av räkningen (knapp 3).

Manuell kalibrering med min() och max()

Mörkerkalibreringen fungerar ungefär som kalibreringen av ljus i setup(), men kan bara göras manuellt.

Istället för max() används min(), som returnerar det lägsta av två värden. Precis som i mätningen i setup() används två variabler. En ljusmätning tilldelas variabeln light, som sedan jämförs med darkest i min(). Resultatet tilldelas till darkest, och så börjar det om igen.

Den manuella kalibreringen av ljus fungerar likadant, men använder max().

Innan en manuell kalibrering påbörjas återställs variablerna darkest och brightest till 1023 respektive 0. Annars finns risken att ett tidigare värde ”vinner” och ligger kvar. Med dessa startvärden garanteras det att de nya mätningarna sparas, eftersom det alltid kommer vara mörkare än 1023 och ljusare än 0.

Till skillnad från i setup() görs den manuella kalibreringen av mörker och ljus inte ett visst antal gånger. Istället sker de inom while-loopar, som upprepas så länge knappen hålls ne r. Medan en person håller inne knappen kan en annan gå förbi räknaren.

När kalibrering av ljus är gjord skrivs har brightest skrivit över, och används som vanligt. Mörkerkalibreringen har några steg till.

Hysteres ger immunitet från störningar

Skillnaden mellan ljus och mörker (brightest och darkest) är utgångspunkten för beräkningarna som görs i mörkerkalibreringen. Den första variabeln som beräknas heter hysteresis, som definieras som en femtedel av skillnaden mellan ljust och mörkt.

hysteresis = (brightest - darkest) / 5;

Hysteres (eng. hysteresis) är ett fysikaliskt fenomen som först beskrevs i forskning kring magnetfält. Fenomenet uppstår när en egenskap (t.ex. magnetism) inte bara beror på hur den påverkas utifrån just nu utan också beror på tidigare påverkan. Ta exemplet med en bit järn som magnetiseras. När ett yttre positivt magnetfält påverkar järnbiten blir den magnetiserad (+). När magnetfältet tas bort försvinner inte magnetiseringen. Järnbiten fortsätter vara magnetiserad. För att ta bort magnetiseringen till 0 behöver man tillföra ett motsvarande negativt magnetfält.

I vårt fall motsvarar magnetiserad och inte magnetiserad att sensorn skuggas eller inte. Om vi hade räknat en person med bara en en liten skuggning och systemet inte hade någon hysteres så skulle det kunna registreras många räkningar när ljusflödet (eller magnetiseringsgraden) varierade slumpmässigt runt tröskelvärdet. ”Minnet” från hysteresen gör personräknaren mindre känslig för små förändringar

Två olika tröskelvärden

Delen av programmet som räknar personer gör ständigt mätningar av ljusstyrkan. Mätningarna jämförs med ett tröskelvärde, lowThreshold. Om en mätning hamnar under det betyder det att sensorn har skuggats av en person, och variabeln count ökas med 1.

Programmet ska bara öka värdet med ett för varje gång sensorn skuggas. För att säkerställa detta så finns det en while-loop som körs medan sensorn är skuggad, precis efter att count har räknats upp. Utan den hade programmet räknat upp variabeln så fort som möjligt. While-loopen gör att processorn är upptagen med annat, oavsett hur länge eller kort sensorn är skuggad.

I while-loopen finns bara en rad kod, som mäter ljusnivån. Om inte det gjordes skulle loopens villkor aldrig kunna bli falskt, och programmet hade fastnat i loopen för evigt.

Villkoret för loopen är dock inte bara while (light < lowThreshold). Istället för att definiera ”det är ljust igen” som att ljusstyrkan går över variabeln lowThreshold, så används ett annat, högre värde – highThreshold. Det högre värdet motsvarar det negativa magnetfältet som avmagnetiserar magneten.

Gränsvärdet för när sensorn anses vara skuggad eller i ljuset beror i sig på om ifall sensorn anses skuggad eller i ljuset. Det nuvarande tillståndet påverkar känsligheten för ljus och skugga.

Metoden att använda två olika gränsvärden för en mätning är ett exempel på hur man kan använda hysteres för att stabilisera ett system. Hysteres av något slag är ett väldigt vanligt sätt att ”städa upp” signaler, och kan skapas både i mjukvara och med elektroniska kretsar.

En elektronisk krets (eller en ingång på en sådan) som har hysteres brukar kallas för en Schmitt trigger. Ibland kallas också kod som skapar hysteres för en Schmitt trigger.

Illustration av hysteres Signal a (i rött) är resultatet av att mäta med bara ett tröstkelvärde, tg M. Systemet reagerar på kortvariga dalar i den blåa signalen. Signal b är resultatet av mätning med hysteres (tg H och tg L). På så vis filtreras störningarna bort.

Poängen med hysteres är att undvika fel när signalen ligger nära tröskelvärdet. En signal kan röra sig nära tröskelvärdet på både för att det man mäter har naturliga variationer, eller på grund av störningar från t.ex. spänningsmatningen eller omgivande elektronik. Hysteres gör att sådana små förändringar inte ger utslag i räknaren.

Med ett högre tröskelvärde som återställer mätningen behöver störningarna vara större för att ge felaktiga mätningar – kretsen högre immunitet mot störningar. Hysteresen behöver givetvis anpassas så att känsligheten inte försvinner helt. De förändringar en sensor ska reagera på måste kunna tränga igenom hysteresens ”filter”.

Variationer på programmet

Några idéer till hur programmet kan utvecklas:

  • while() gör att processorn är upptagen medan sensorn är skuggad. Försök integrera edge detection från extra_edgeDetection.ino så att programmet istället kan göra flera saker samtidigt.
  • Visa antalet personer på en extern display med hjälp av qwiic-porten.
  • Använd tone() för att spela upp ett ljud när en person passerar.
  • Lägg till särskilda melodier när tio, hundra, osv. personer har passerat.
  • Gör så att programmet räknar antal personer per minut eller timme – prova även att använda Ardinon som en varvtalsmätare!
  • Ändra så att kalibrering av mörker startas och stoppas med tryck på knapp 2 så att samma person kan starta kalibrering och sedan gå förbi sensorn. Låt t.ex. en lysdiod visa att kalibreringsläget är aktivt.
  • Använd edge detection för att göra nollställningen omedelbar.
  • Gör en funktion som kalibrerar både ljus och skugga samtidigt.
  • Använd genomsnittsfunktionen i 2c_optical_theremin.ino för att få programmet att regelbundet mäta bakgrundsbelysningen och anpassa tröskelvärdena. Fundera på hur ofta den bör mäta.
  • Kan du få programmet att skilja på när ljusstyrkan minskar fort (för att t.ex. en bil passerar över den) och när ljuset minskar långsamt (för att det börjar skymma eller solen går i moln)?

6: Seriell kommunikation

Arduinon kan skicka och ta emot data med flera olika seriella protokoll. Vi har redan använt ett av dem, när vi har ”skrivit ut” saker med Serial.print(). Protokollet som används då heter UART. Två andra vanliga protokoll som stöds av de flesta Arduino-kort är I2C och SPI. Protokollen har tagits fram för olika användningsområden och har olika egenskaper, och används idag både för kommunikation mellan kretsar inom en apparat och mellan t.ex. en dator och extern utrustning eller mellan flera olika maskiner i ett litet ”nätverk”. De är så vanliga att många mikrokontrollers har dedikerade portar och rutiner för alla tre.

WiFi, Bluetooth, och Morsekod (telegraf) är några andra exempel på seriell kommunikation.

En i taget

Som namnet antyder skickas informationen i seriell kommunikation – bitarna – i en serie, en i taget. Protokollens standarder beskriver många saker, bland annat hur ett meddelande ska börjar och sluta, i vilken ordning bitar ska skickas, hur de grupperas, felkorrigering och hur fel hanteras om de uppstår.

Vi behöver inte gå in på djupet på hur protokollen fungerar, eftersom det finns bibliotek som säkerställer att saker går rätt till. Men när vi skickar och tar emot data är det bra att känna till på ett ungefär hur datan hamnar där den hamnar och vad man kan göra med den.

Motsatsen till seriell kommunikation är parallell kommunikation, där flera bitar bitar (t.ex. 8) skickas sida vid sida. De de många ledarna som krävs för parallell kommunikation gör att det idag mest används på korta avstånd, exempelvis mellan en A/D-omvandlare och processor, eller för den delen inuti processorer. Att datan går parallellt gör ju också att man lättare kan få högre överföringshastigheter än när bitarna ska turas om att föras över på samma ledare.

UART

UART (Universal Asynchronous Receiver-Transmitter) är ett av de äldsta seriella protokollen, och definitivt äldst av de som tas upp här. Standarden inkluderar inte så mycket mer än hur datan skickas: ordningen bitarna skickas i och hur enheter signalerar att ett meddelande är på väg med start- och stopp-bitar. Standardens enkla utformning innebär att den inte är lämplig för höga hastigheter eller stora avstånd. Att UART har funnits så länge och ställer låga krav på processorer gör å andra sidan att det stöds av nästan allt och är lätt att implementera – i den meningen är det ”universellt”.

Ordet asynchronous innebär att datan skickas utan medföljande klocka. Det räcker att de två kommunicerande enheterna skickar bitar med ungefär samma hastighet. Den mäts i bitar per sekund, vilket som bekant också kallas baud rate. Felmarginalen för baud rate är generösa 10%. UART är ett tillåtande protokoll. Det finns många olika standardiserade hastigheter, från låga hastigheter som 2400 baud, till den lite snabbare 115200 som används i programmen till Edu Shield. Många av Arduinos exempel använder istället 9600 baud.

Som så mycket annat inom elektronisk kommunikation härrör många baud rates från teleprinters eller teletypes (automatiserade telegrafer), för vilka 75 baud var standard. 9600 kommer från 75 * 128.

De två sista orden, receiver-transmitter, beskriver att UART är ett protokoll av typen full duplex. Det betyder att enheter kan skicka och ta emot data samtidigt, utan att den ena styr över den andra. Givetvis kan UART även användas med enkelriktad kommunikation, som i tidigare program där Unon har skickat data över USB till Serial Monitor. Då kan man klara sig med bara en signalledare.

UART på Arduino

Anslutningarna för att skicka och ta emot data kallas för Tx respektive Rx (T och R står för Transmit och Receive). Tx på en enhet kopplas till Rx på den andra, och vice versa. På en Uno R3 finns RX på pin 0 och TX på pin 1. När din Uno skickar och tar emot data över USB så syns pulserna här. Electrokit Edu Shield har en egen 3-pinnars kontakt för UART, med jord i mitten och Rx och Tx ytterst. Genom att vända kabeln 180° kan man koppla ihop två olika Unos – och det kommer vi snart göra.

En begränsning med UART är att man bara kan ansluta två enheter till varandra, t.ex. dator och Arduino. Det går inte att ha en tredje apparat som deltar i kommunikationen (även om det är möjligt för andra enheter att läsa den data som skickas). Om man vill att flera enheter ska kommunicera är t.ex. I2C ett bättre val.

Skicka data med Serial Monitor

Fram till nu har vi bara skickat information från Arduino till datorn. Men du har kanske märkt att det finns ett teckenfält ovanför Serial Monitor? Som texten i rutan avslöjar, så kan man skicka meddelanden till Arduinon. Man kan skriva data i fältet och skicka till Arduinon, över UART.

Teckenfält i serial monitor I teckenfältet kan man skriva meddelanden och skicka över UART.

Att skicka meddelanden är lätt, man bara skriver i rutan och trycker på enter. Precis som när Arduinon skickar så kan man skicka med eller utan radbrott. Det väljer man i menyn mellan skrivfältet och menyn för baud rate. Förutom radbrott (new line) kan man välja ett till kontrolltecken, vagnretur (carriage return). Man kan också välja att inte skicka något kontrolltecken alls.

Om man har valt att ta med något kontrolltecken så skickas detta i slutet av varje meddelande – alltså efter texten man har skrivit in i rutan.

Datan skickas som chars, mer bestämt som sifferkoder i teckenkodningen UTF-8, som liknar ASCII men är lite mer modernt. Att Serial Monitor bara kan skicka teckenkoder är en tråkig begränsning i Arduino IDE – själva Arduinon kan ju skicka heltal, flyttal, bools osv. Om vi skriver ”5” i rutan och skickar det, så är meddelandet som skickas inte siffran 5, utan tecknet 5. Det som når vår Uno är talet 53 – koden för tecknet 5 i UTF-8. Detta blir snabbt opraktiskt om vi t.ex. vill skicka vinklar från 0-180 för att testa ett servo. Det finns tyvärr inget bra sätt att skicka tal från Arduino IDE, även om man ju kan skriva program som tar emot tecken i UTF-8 och räknar om dem till talen de representerar. Testa!

PuTTY

För enkla tester duger Arduino IDE ändå bra. Om man har ett behov av att skicka data och styra olika enheter över UART bör man kanske hitta något annat program för ändamålet, men Arduino IDE duger för enkla tester. Ett mer kraftfullt alternativ är PuTTY: https://www.chiark.greenend.org.uk/~sgtatham/putty/latest.html

UART i praktiken

När en dator eller annan enhet skickar meddelanden över UART till en Arduino så hamnar meddelandena i ett minne, som ofta kallas för en buffert (buffer på engelska). Informationen skickas och lagras en byte i taget, och i minnet sparas de med den äldsta byten längst fram. När Arduinon läser i minnet läser den i samma ordning, framifrån och bak. Principen kallas FIFO (first in, first out).

Läsningen i minnet är destruktiv – den byte man läser tas automatiskt bort, och resterande bytes flyttas fram ett steg. System som ska skicka över UART brukar ha också en FIFO-buffert för ändamålet.

UART FIFO Illustration över en UART-buffert. Det nyaste meddelandet 78 hamnar längst ner i bufferten. Samtidigt läser Arduinon ur minnet, varpå det äldsta meddelandet 199 raderas. I bilden syns också hur det binära meddelandet kodas med en logikpuls.

Storleken på minnet i bufferten är inte obegränsad, vilket kan leda till problem. På en Uno R3 är bufferten bara 64 bytes stor. Om den får seriell data fortare än den läser ur bufferten – till exempel för att programmet är upptaget med en delay() – så kommer minnet snabbt bli fullt. Om det då fortsätter komma data raderas den äldsta informaionen innan den har hunnit läsas.

Eftersom UART normalt är full duplex kan man åtgärda detta genom att Arduinon t.ex. berättar hur stor buffert den har, och hela tiden berättar hur många bytes den läser ur bufferten. Den skickande enheten kan då vänta med att skicka data om den vet att Arduinon inte kan ta emot den. Den här sortens hantering finns ofta inbyggd i nyare protokoll. Ett annat sätt att lösa problemet är att snabbt läsa in bufferten och spara i ett annat minne, men inte använda datan direkt – om det finns tid och plats det vill säga. Ytterligare ett sätt kan vara att ha en dedikerad processor som bara kommunicerar seriellt, och inte behöver bekymra sig om annat.

UART och timing

Utöver att man behöver tänka på vilken data som skickas och hur mycket, så kan det vara bra att veta att andra processer kan påverka och påverkas av UART. Om ett program skickar eller tar emot (läser) mycket seriell information så kan det påverka andra processer i programmet. Om du märker att din data inte verkar komma fram korrekt så kan det vara en bra idé att byta baud rate (långsamma hastigheter kan vara problematiska) eller ta reda på om något annat i programmet uppehåller processorn – t.ex. delay() eller while().

Bortom Serial.print()

Precis som när vi har skickat data till datorn kommer i använda olika funktioner i Serial. Motsvarigheten till print() är read(), som läser den första byten i UART-bufferten. Man kan tilldela den informationen till något: minSeriellaByte = Serial.read(). Som sagt raderas så byten ur bufferten vid läsning. Om man läser igen får man istället nästa byte (om det finns någon mer information i bufferten). I motsats till arrayer behöver (eller kan) man alltså inte ange var i minnet man vill läsa.

Om minnet är tomt så returnerar Serial.read() talet -1. För att undvika att läsa ett tomt minne finns funktionen Serial.available(), som returnerar det antal bytes i minnet som är har oläst information. Om man t.ex. använder available() i villkoret i en if kan man läsa ur minnet så fort det finns något nytt att läsa.

Några andra funktioner i Serial är peek(), som fungerar som read() förutom att byten inte raderas, och find(), som kollar igenom minnet tills den hittar informationen i argumentet och returnerar 1 om den hittar den i minnet.

Tända lysdioder från Serial Monitor

Med det här programmet kan du styra din Arduinos lysdioder. Skickar du tecknet 1 så tänds lysdiod 1, om den är släckt. Om den redan är tänd så släcks den istället. Alla andra meddelanden ignoreras. Om vi vill att programmet ska klara fler saker, eller t.ex. varna för att den fått felaktig data, så behöver vi också sålla informationen.

6a1_serial_read_led.ino
const char led1 = 11;
const char led2 = 10;
const char led3 = 9;

char serialData;
bool led1State;
bool led2State;
bool led3State;

void setup() {
  pinMode(led1, OUTPUT);
  pinMode(led2, OUTPUT);
  pinMode(led3, OUTPUT);
  Serial.begin(115200);
}

void loop() {
  if (Serial.available() != 0) {  //
    serialData = Serial.read();
    if ((serialData != 10) && (serialData != 13)) {                          // om inte NL eller CR
      if (serialData == 49) { led1State = !led1State; }                      // '1'
      if (serialData == 50) { led2State = !led2State; }                      // '2'
      if (serialData == 51) { led3State = !led3State; }                      // '3'
      if ((serialData != 49) && (serialData != 50) && (serialData != 51)) {  // allt utom '1' '2' '3'
        led1State = 0;
        led2State = 0;
        led3State = 0;
      }
    }
    digitalWrite(led1, led1State);
    digitalWrite(led2, led2State);
    digitalWrite(led3, led3State);
    //
    Serial.print("received char: ");
    Serial.print(serialData);
    Serial.print("\t");
    Serial.print(" code: ");
    Serial.print(serialData, DEC);
    Serial.print("\n");  // tecknet \n betyder ny rad (newline)
  }
}

I nästa program tänds och släcks lysdioderna precis som i det förra. Men om det kommer andra tecken, t.ex. 4, f eller ö, så varnar programmet genom att spela upp en ton för varje felaktigt tecken. Tonen, och dess längd, bygger på teckenkoden.

6a2_serial_read_led_tone.ino
const char led1 = 11;
const char led2 = 10;
const char led3 = 9;
const char spkr = 2;

uint8_t serialByte;  // serialByte behöver begränsas till positiva heltal för att tone() ska fungera som den ska
bool led1State;
bool led2State;
bool led3State;

void setup() {
  Serial.begin(115200);
  pinMode(led1, OUTPUT);
  pinMode(led2, OUTPUT);
  pinMode(led3, OUTPUT);
  pinMode(spkr, OUTPUT);
}

void loop() {
  if (Serial.available()) {                                                // om det finns något i UART-bufferten
    serialByte = Serial.read();                                            // första byten i minnet läses (och raderas), tilldelas serialByte
    if (serialByte == 49) { led1State = !led1State; }                      // om serialByte är '1' görs detta
    if (serialByte == 50) { led2State = !led2State; }                      // om serialByte är '2'
    if (serialByte == 51) { led3State = !led3State; }                      // om serialByte är '3'
                                                                           //
    if ((serialByte != 49) && (serialByte != 50) && (serialByte != 51)) {  // om serialByte är något annat än '1' '2' '3' — felaktig data
      if ((serialByte != 10) && (serialByte != 13)) {                      // om serialByte inte är 10 (NL) och inte är 13 (CR)
        digitalWrite(led1, !led1State);                                    // så blinkar (eller anti-blinkar) lysdioderna för att varna
        digitalWrite(led2, !led2State);                                    //
        digitalWrite(led3, !led3State);                                    // och en kort ton spelas också upp om felaktig data tagits emot
        tone(spkr, serialByte * 10);                                       // frekvensen utgår från ascii-kod
        delay(serialByte >> 1);                                            // ton-tiden är ascii-kod, kortad med bitvis skifte åt höger
        noTone(spkr);                                                      //
        delay(serialByte >> 2);                                            //väntetiden (innan nästa ton) kortas på samma sätt, fast mer.
      }                                                                    //
    }
    digitalWrite(led1, led1State);  // till sist uppdateras lysdioderna
    digitalWrite(led2, led2State);
    digitalWrite(led3, led3State);
  }
}

Vanliga if-satser används för att bestämma om lysdioderna ska styras eller om en ton ska spelas upp. En if-sats ser till att inget händer om tecknet som har tagits emot är ny rad (kod 10 i UTF-8-) eller vagnretur (kod 13). De olika villkorssatserna använder != (inte lika med) och && (logisk OCH) för att inte göra saker vid vissa tecken, och för att kombinera teckenkoder. Det spelar alltså ingen roll för programmet om du ställer in Serial Monitor på att skicka kontrolltecken eller inte.

Hur låter ditt namn?

Tecken utanför ASCII

Att serialByte har datatypen uint8_t och inte char beror på att variabeln används som argument för tone() och delay(). Om vi hade använt char så hade vissa koder blivit negativa tal. Det fungerar inte med någon av funktionerna. Funktionen tone() väntar sig frekvenser positiva heltal, 31 eller högre. Negativa frekvenser finns inte, och låter bara konstigt i tone(). Med delay() blir det ännu värre: den tolkar negativa tal som jättestora positiva heltal, vilket ger pauser som dröjer flera sekunder. Om vi istället kodar om tecknen till uint8_t – som ju inte har teckenbit – blir alla tal positiva, och problemet försvinner.

En if utan ==

Den if som utgör själva programmet i båda dessa exempel verkar inte ha något villkor. Det står bara if (Serial.available()) { ..., utan någon jämförelseoperator. Men en if-sats måste faktiskt inte ha en logisk ekvation. Koden utförs om villkoret är något annat än 0 – exakt vad som står i parentesen är inte så noga. Det kan t.ex. vara en variabel som är 0 eller 1 (eller 100). I det här fallet returnerar Serial.available() antalet bytes data som finns i UART-bufferten. Om är antalet är något annat än 0 körs koden i if-satsen.

Koder för olika tecken

Om man vill se koder i ASCII och UTF-8 för olika tecken kan man använda detta lilla program. Alla tecken som Unon får skickas direkt tillbaka till datorn.

6a3_Serial_reply.ino
void setup() {
  Serial.begin(115200);
}

void loop() {
  if (Serial.available()) {
    char receivedChar = Serial.read();

    Serial.print("tecken: ");
    Serial.print(receivedChar);
    Serial.println();
    Serial.print("ASCII-kod: ");
    Serial.print(receivedChar, DEC);
    Serial.println();
    Serial.print("kod i uint8_t: ");
    Serial.print((uint8_t)receivedChar, DEC);
    Serial.println();
    Serial.print("kod i uint8_t (binärt format): ");
    Serial.print((uint8_t)receivedChar, BIN);
    Serial.println();
    Serial.println();
  }
}

Vissa tecken, t.ex. åäö eller €, behöver flera bytes – prova själv! Om UTF-8 bara hade åtta bytes skulle det inte kunna koda särskilt många tecken, så upp till fyra bytes kan användas. Många emojis använder till exempel hela fyra bytes 🙂. Som du kommer se kommer många tecken inte se korrekta ut i Serial Monitor, eftersom de kommer tillbaks i flera separata ”paket” – de bytes koden består av.

Kommunikation mellan två Unos med UART

Två Edu Shields redo att prata UART När man kopplar ihop två Unos (eller andra enheter) är det viktigt att RX på en enhet går till TX på den andra.

Den seriella kommunikationen bygger på att två Uno-kort kopplas samman via UART-kontakten som i bilden ovan. För att allt ska funka behöver båda Unos köra samma progam:

6b_UART_uno_to_uno.ino
#include <Adafruit_NeoPixel.h>
#include <RotaryEncoder.h>

#if defined(ARDUINO_UNOR4_MINIMA) || defined(ARDUINO_UNOR4_WIFI)  // om Unon är en R4 Minima eller R4 Wifi
#define Serial Serial1                                            // ersätt Serial i koden med Serial1
#endif

Adafruit_NeoPixel neo_led(2, 6, NEO_GRB + NEO_KHZ800);
RotaryEncoder rotEncoder(5, 4, RotaryEncoder::LatchMode::FOUR0);

const char btn1 = 13;
const char btn2 = 7;
const char btn3 = 8;
const char led1 = 11;
const char led2 = 10;
const char led3 = 9;
const char pot = A1;
const char encBtn = 3;

bool encBtnState;
bool encBtnStateOld = 1;
bool btn2state;
bool btn2stateOld = 1;

char direction;
char step;
char encPosNew;
char encPosOld;
unsigned char stepScale;
unsigned char val = 64;
unsigned char sat;
int stepSize = 24;
unsigned int hue;
unsigned long rgbColour;
unsigned long newTime;
unsigned long oldTime;

// färger
char red;
char grn;
char blu;

void setup() {
  Serial.begin(115200);
  pinMode(btn1, INPUT_PULLUP);
  pinMode(btn2, INPUT_PULLUP);
  pinMode(btn3, INPUT_PULLUP);
  pinMode(led1, OUTPUT);
  pinMode(led2, OUTPUT);
  pinMode(led3, OUTPUT);
  pinMode(encBtn, INPUT_PULLUP);
  neo_led.begin();
  neo_led.clear();
  digitalWrite(led1, 1);
}

void loop() {
  // färgton (hue)
  rotEncoder.tick();  // läser av enkodern
  encPosNew = rotEncoder.getPosition();
  if (encPosNew != encPosOld) {
    encPosOld = encPosNew;
    direction = (char)rotEncoder.getDirection();
    step = direction * stepSize;
    hue += step;
  }
  encBtnState = digitalRead(encBtn);
  if (encBtnState != encBtnStateOld) {
    if (encBtnState == 0) {
      stepScale += 2;                        // stepScale ökar med två
      stepScale %= 6;                        // stepScale kan inte bli 6. ökar så: 0, 2, 4, 0, 2, 4
      stepSize = 6 << (2 + stepScale);       // stepScale räknas om till 2, 4, 6. ger stepSize = 24, 96, 384
      digitalWrite(led1, (stepScale == 0));  // om stepScale = 0 blir resultatet 1 – tänd led1. annars släckt
      digitalWrite(led2, (stepScale == 2));  // motsv.
      digitalWrite(led3, (stepScale == 4));  // motsv
    }
    encBtnStateOld = encBtnState;
  }
  // färgton klar
  //
  // mättnad (saturation)
  sat = analogRead(pot) >> 2;  // mättnad styrs kontinuerligt av potentiometern
  // mättnad klar
  //
  // intensitet (value)
  if (digitalRead(btn1) == 0) {  // minskar intensitet
    newTime = millis();
    if ((newTime >= oldTime + 25) && (val > 0)) {
      oldTime = newTime;
      val--;
    }
  } else if (digitalRead(btn3) == 0) {  // ökar intensitet
    newTime = millis();
    if ((newTime >= oldTime + 25) && (val < 255)) {
      oldTime = newTime;
      val++;
    }
  }  // intensitet klar
  //
  // alla tre värden är klara och kan skickas till den andra unon med knapp 2
  btn2state = digitalRead(btn2);
  if (btn2state != btn2stateOld) {
    if (btn2state == 0) {
      rgbColour = neo_led.ColorHSV(hue, sat, val);
      red = (rgbColour & 0x00FF0000) >> 16;  // talet 0x00FF0000 hade kunnat skrivas 0xFF0000
      grn = (rgbColour & 0x0000FF00) >> 8;   // osv – 0xFF00
      blu = (rgbColour & 0x000000FF);        // och 0xFF
      Serial.print(red);
      Serial.print(grn);
      Serial.print(blu);
      Serial.print(rgbColour);
    }
    btn2stateOld = btn2state;
  }
  // här tas seriell data emot och tilldelas till variablerna red, grn och blu
  if (Serial.available() >= 3) {              // om det finns tre eller fler bytes i UART-bufferten
    red = Serial.read();                      // första byten läses och tilldelas red
    grn = Serial.read();                      // andra byten ...
    blu = Serial.read();                      // och tredje byten läses – och tas bort från UART-bufferten
    neo_led.setPixelColor(0, red, grn, blu);  // färgerna kombineras
    neo_led.show();                           // och NeoPixeln uppdateras
  }
}

Programmet bygger på 4b_neopixel_HSV.ino, och allt som har med färgblandning och reglagen fungerar precis som i det programmet. Skillnaden är att färgblandningen inte skickas till NeoPixeln på samma kort, utan istället skickas som seriell data till den andra Unon. Samtidigt kan Unon ta emot färgblandningar från sin kompis.

En annan skillnad mellan programmen är de tre raderna kod som kommer efter att biblioteken inkluderas – de rader som börjar med #. Mer om detta står längre ner, i stycket om förprocessordirektiv. Men innan vi går igenom det så ska vi titta på hur programmet skickar och tar emot seriella meddelanden.

Precis som i det förra programmet kombineras HSV-värden i funktionen ColorHSV(), och tilldelas variabeln rgbColour, ett 32-bitars tal (unsigned long). Att skicka ”stora” datatyper över ett protokoll som jobbar med 8 bitar kan ofta vara problematiskt. Anledningen är att större tal behöver delas upp innan de skickas. Ett 32-bitars tal behöver delas i fyra delar (bytes) och skickas en i taget.

Ordningen som bytesen skickas i är inte given, utan kan variera mellan olika processorer. Det finns två sätt: little-endian respektive big-endian. Själva konceptet byte-ordning kallas endianness. Det används inte bara för seriell kommunikation, utan också för t.ex. lagring på olika minnen. Minnesplatser är nämligen ofta 8 bitar stora, så större tal måste spridas ut på flera minnesadresser.

Endianness, MSB och LSB

Det finns alltså två ordningar att lagra och skicka bytes.

Big-endian innebär att den mest signifikanta byten skickas eller lagras först. Talar vi om lagring i ett minne så betyder ”först” att den byten hamnar på minnesadressen med lägst nummer.

Little-endian är motsatsen, den minst signifikanta byten skickas eller lagras först.

Vad betyder då mest och minst signifikant? Mest signifikant innebär att byten representerar det största värdet. Som exempel kan vi ta det 32-bitars talet 2598678998. Uttryckt med hexadecimala tal blir talet istället 0x9AE4B1D6 – varje teckenpar motsvarar som du minns en byte. Den mest signifikanta byten i det talet 9A, därefter E4, osv. Talet 0x9A000000 blir 2583691264 uttryckt i det decimala systemet, medan 0xE40000 bara är 14942208. Som du ser är talet som kodas av det första teckenparet betydligt större än det andra.

2583691264 > 14942208 = sant

Slår vi ihop talen så får vi:

2583691264 + 14942208 = 2598633472

eller, med hexadecimala tal:

0x9A000000 + 0xE40000 = 0x 9AE40000

Fortsätter vi att lägga till bytes B1 och D6 så får vi till sist talet 2598678998.

Om man skickar ett 32-bitars tal över UART (och många andra protokoll) så behöver den mottagande enheten göra just detta: ta emot en byte i taget och addera dem för att ”återskapa” det större 32-bitars talet. Som du nog inser så skulle det bli helt galet om sändaren och mottagaren inte var överens om ordningen. Om man byter byte-ordning på talet ovan blir resultatet 0xD6B1E49A (alltså 3601982618). Helt fel!

Den mest och minst signifikanta byten i ett tal med flera bitar heter på engelska most respektive least significant byte. Till skillnad från på svenska börjar ju orden för mest och minst på olika bokstäver, vilket ger oss förkortningarna MSB och LSB.

Begreppet mest och minst signifikant används oftare för bitar än för bytes. Bitar kan nämligen också lagras och skickas i olika ordningar. Förkortningen blir samma, men b för byte skrivs då helst med liten bokstav för att göra tydligt att det är en bit och inte en Byte man syftar på: MSb och LSb. (I UART skickas data med LSb först, men det är inget vi behöver tänka på.)

Dela upp ett tal i bytes

För att vara säkra på att ordningen röd-grön-blå blir rätt kommer vi dela upp färgvärdet själva, innan det skickas. När vi gör detta kan vi samtidigt hoppa över den fjärde byten i vårt 32-bitars tal. På så vis slipper vi skicka onödig data. I det här fallet är det ju inga stora datamängder, men när vi ändå separerar bytes kan vi lika gärna ta bort värdet för vit. Det finns ingen anledning att skicka en massa nollor i onödan. När talet är uppdelat kan de tre bytesen skickas någon ordning som sändare och mottagare är överens om.

Så hur delar man då upp ett tal? När vi läser hexadecimala tal är det ju lätt att dela upp dem i teckenpar. Det samma gäller ju decimala tal, som människor har vi inga problem att separera tusental, hundratal, tiotal och ental från talet 2378 – 2000, 300, 70 och 8. Hur man gör det i ett program är inte lika lätt att förstå. När det gäller 2378 kan man separera ut tusentalen genom att subtrahera 378, men det fungerar ju bara om talet är 1378, 2378, 3378 osv. Samma sak gäller för hexadecimala tal. Man kan inte utgå från talets storlek och göra konventionell matematik.

Istället behöver vi ett sätt att separera utifrån teckenplatsen i talet, oberoende av talets värde. Det betyder att vi måste titta på själva bitarna i talet. Genom att utföra logiska operationer på dem kan vi lösa problemet.

Bitvis logiska operationer

Bitvis operationer är vid det här laget inget nytt, vi har ju tidigare skiftat bitar med >>. Vi har också gjort logiska operationer med operanden && (OCH) i villkoret till för if. Nu ska vi göra den logiska operationen OCH, men istället för att som förut göra den på två logiska tillstånd (sant eller falskt) ska vi göra den bitvis. Logiskt fungerar det som förut, men istället för att se om två tal är ”sanna” (alltså något annat än 0) så görs det nu bit för bit mellan alla bitarna två tal:

Om den första biten i tal 1 OCH tal 2 är 1 (alltså sant) så blir den första biten i resultatet också 1. Om bara en (eller ingen) är sann blir resultatet istället 0 (falskt).

Tecknet för bitvis OCH är &, alltså bara ett &-tecken istället för två. Risken att man råkar blanda ihop dem när man programmerar är ganska stor, så man får vara försiktig.

Ekvationer med & skrivs precis som de vanliga räknesätten, t.ex. 74 & 211. Undrar du vad resultatet blir? Låt oss se:

Exempel på boolesk uträkning

Resultatet av en bitvis OCH kan aldrig bli större än något av talen operationen utförs på. Om du tänker efter lite grann så förstår du säkert varför.

Man kan göra bitvis logik med flera logiska operander. Bitvis ELLER skrivs med samma tecken som logisk ELLER, men bara ett tecken: |.

En tredje bitvis operand är bitvis EXKLUSIV ELLER (XOR), som skrivs med tecknet ^. Operanden ger sant om ett – men bara ett – av talen är sant:

0 ^ 0 = 0  inget är sant
0 ^ 1 = 1  ena är sant
1 ^ 1 = 0  båda är sanna

XOR finns inte som vanlig logisk (icke-bitvis) boolesk operator i C++.

Bit-mask

Genom att använda bitvis OCH kan vi ”ta bort” oönskade delar av ett tal. Konceptet är enklare att förstå om vi skriver talen hexadecimalt, eftersom varje teckenpar utgör en byte. Koden som gör detta ser ut så här:

red = (rgbColour & 0x00FF0000) >>  16;
grn = (rgbColour & 0x0000FF00) >>  8;
blu = (rgbColour & 0x000000FF);

Det som står inuti parenteserna utförs först, precis som när man skriver vanliga ekvationer.

När ColorHSV() skapar 32-bitars tal lägger den färgvärdena i ordningen vit (används ej), röd, grön, blå. Som du ser består talen till höger av fyra teckenpar. Nollorna till vänster (efter 0x) har ingen betydelse, utan står bara med av pedagogiska skäl. Man hade också kunnat skriva 0xFF0000, 0xFF00 och 0xFF. En bitvis OCH görs mellan rgbColour – 32 bitar – och de olika talen.

Om vi skriver om det första talet som ett binärt tal, grupperat i bytes så förstår du kanske poängen:

00000000 11111111 00000000 00000000

Gör man en bitvis OCH mellan ett 32-bitars tal och 0xFF0000 så försvinner alla ettor utom de i den andra byten – den som kodar färgen röd. Samma sak görs med grön och blå.

vvvvvvvv rrrrrrrr gggggggg bbbbbbbb

De hexadecimala talen används inte för att representera värden, utan för att göra något specifikt med bitarna i rgbColour. När man använder tal på det sättet burkar de kallas för en mask eller bitmask.

Efter att ha ”suddat ut” ettorna för grön och blå finns bara de ”röda” bitarna kvar, men de ligger fortfarande på fel plats. Att bara göra logisk OCH ger inte ett åttabitars tal. Vi måste också skifta talet åt höger, sexton steg (bitar). Grön behöver bara skiftas en byte – åtta bitar – medan blå redan ligger där den ska.

Efter att varje byte är klar kan de tilldelas till respektive variabel red, grn och blu, och därefter skickas till den andra Unon med print().

Ta emot färger seriellt

När Unon ska ta emot data vet vi att den skickas i ordningen röd, grön och blå. De tre värdena kan sammanfogas direkt i setPixelColor(). Detta görs om det finns tre eller fler bytes i UART-bufferten.

Att det är tre eller fler är för att programmet annars skulle kunna fastna. Om den sändande Unon skickade två färgblandningar – sex bytes – för snabbt för att mottagaren skulle hinna läsa, så skulle Serial.available() returnera 6, och ingen byte skulle någonsin läsas. Informationen i UART-bufferten skulle aldrig tas bort – det görs ju med Serial.read() – så den skulle gradvis fyllas med oanvända färgblandningar.

Med >= 3 kommer programmet läsa in färgerna, tre i taget, så fort en färdig blandning finns i bufferten.

Programmet måste vänta tills minst tre bytes finns. Om man började läsa med Serial.read() så fort en enda byte fanns i bufferten så skulle variabeln red tilldelas den, medan grn och blu skulle läsa i UART-bufferten fast det inte fanns något där. När nästa byte dök upp i bufferten så skulle även den tilldelas red, och grn och blu skulle åter tilldelas 0. Koden i if-satsen utförs ju uppifrån och ner.

Vikten av felkorrigering

I det här enkla programmet finns ingen felkorrigering. Det förväntas att Uno 1 och Uno 2 är överens om vilken byte som motsvarar röd, grön och blå. Om de två enheterna skulle hamna ur synk, t.ex. om en eller två bytes inte tas emot korrekt p.g.a. en glappande kabel eller för att UART-bufferten blev full, så skulle färgerna bli fel.

En annan potentiell källa till problem med seriell kommunikation är att en Uno R3 är betydligt snabbare än UART-protokollet. En processor hinner utföra väldigt många instruktioner under tiden mellan att två tecken skickas. Om ett program t.ex. genererar stora mängder data för snabbt kan det uppstå problem. Det samma gäller om ett program ”har för lite tålamod” och tömmer seriebufferten innan all data har kommit fram.

Program som används ”skarpt” behöver ha sätt att hantera fel i kommunikationen. Till exempel skulle Uno 1 kunna skicka meddelanden som anger ”nu kommer det tre bytes med färger” och ”nu har tre bytes skickats”, och/eller så skulle Uno 2 kunna berätta vilken information den har tagit emot, så att Uno 1 kan kontrollera att det stämmer och godkänna överföringen. Man kan också lägga till information om vilken färg som ska komma, så att ordningen bytes skickas i inte spelar någon roll. Då skulle korten inte kunna hamna ur synk.

All kommunikation bygger ju på att sändare och mottagare är överens om vad datan som skickas betyder.

I ett slutet system som t.ex. prototyper och experiment kan en enkel lösning duga väl. Problemet är att slutna system sällan förblir slutna. Kanske vill man lägga till en sensor i framtiden, eller integrera motordrivare eller relästyrning. Om ett program är skrivet på det här sättet, så att den enda seriella datan som kan hanteras är tre färger i rätt ordning, så blir det omöjligt att lägga till annan data i överföringen. Ny funktionalitet som borde vara enkel att lägga till kan då innebära att man får skriva om hela programmet från grunden.Den skräddarsydda lösningen som bara gör en sak må vara enkel och snabb, men kan innebära problem i framtiden.

Förprocessordirektiv

En sak som vi hoppade över i genomgången ovan syns högt upp i koden, precis efter att biblioteken har lagts till. Där finns följande rader kod:

**#if** **defined(ARDUINO_UNOR4_MINIMA)** **||** **defined(ARDUINO_UNOR4_WIFI)**
#define Serial Serial1
#endif

Dessa rader tillhör de kommandon som kallas preprocessor directives (på svenska kallas de förprocessordirektiv). #include är ett annat exempel.

Förprocessorn är ett program (inte en fysisk processor) som kan göra saker med koden du har skrivit, innan den kompileras. Förprocessordirektiven är instruktioner till förprocessorn – ett sätt att styra vad den gör.

#include lägger till filer utifrån, t.ex. bibliotek eller header-filer med data som du inte vill ha i samma textfil som ditt program. De blir då en del av programmet när det kompileras.

#define kan användas för att definiera konstanter eller skapa makron. För detta behövs två saker: ett namn på konstanten samt ett värde. Förprocessorn går sedan igenom koden och ersätter alla gånger konstantens namn står med värdet. Några exempe på fördefinierade(1) konstanter är som INPUT_PULLUP och TWOPI (två gånger pi).

  1. De fördefinierade konstanterna kan man hitta i headerfilen Common.h. Där syns t.ex. att TWOPI ersätts med 6.283185307179586476925286766559

Två exempel på makron är min() och max(). Trots att vi pratar om dem som det så är de egentligen inte funktioner. Det värde som ersätter konstanten måste inte vara ett tal. Det kan vara något annat ord, eller som i fallet med min() och max() en liten bit kod.

#define skiljer sig på flera sätt från hur vi vanligen deklarerar konstanter (med const), delvis i att den inte tar hänsyn till typer – kompilatorn kan inte varna om man gör något med fel datatyper i förprocessordirektiven. Därför är det oftast bättre att använda const istället för #define.

Men ibland är det just att byta ut något man vill göra. I det aktuella fallet handlar det om att Uno R4 (både Minima och Wifi) hanterar UART på ett annat sätt än Uno R3.

Ett program skrivet med Serial fungerar likadant på R3 och R4 när det gäller kommunikation med datorn, men de elektroniska pulserna tar inte samma väg på kortet.

Uno R3 har bara en serieport, som både behöver fungera för USB-kommunikation och för att kommunicera med andra enheter (via hylslisten, eller t.ex. Edu Shields UART-kontakt). UART-utgångarna på Unons mikrokontroller går därför både till pin 0 och 1, och vidare till en hjälpprocessor som omvandlar UART-pulserna till USB-data.

Uno R4 har flera serieportar (och ingen hjälpprocessor), så samma data behöver inte gå till flera ställen. När man använder Serial.print() på en R4 går datan direkt ut på USB-kabeln, men syns aldrig på pin 0 och 1 (och alltså inte på Edu Shields UART-kontakt). Om man vill att meddelandenna ska gå ut på den kontakten behöver man använda en till (likadan) serieport. Det gör man med Serial1.print(). Den fungerar exakt som Serial, men är obereoende. Vissa Arduinos har ännu flera portar: Serial1, Serial2, osv.

Problemet är alltså att vi behöver använda Serial1 på R4, och Serial på R3. Men en R3 har ingen Serial1 – vi skulle behöva två olika program. Men istället för det kan vi använda förprocessorn, som automatiskt skriver om programmet åt oss.

#if defined

Det hade ju inte gjort någon stor nytta att alltid byta ut Serial mot Serial1 med direktivet #define. Då skulle programmet bara fungera på Uno R4, och inte R3. Istället för det används #if defined. Det fungerar ungefär som if: om det som står efter (detta kallas identifier) har definierats så används direktiven som står mellan #if defined och #endif. Annars ignoreras de. I det här programmet finns två identifiers, med || (logisk ELLER) mellan dem.

Om man kompilerar koden för något av korten som står där så byts alla förekomster av Serial ut mot Serial1 innan kompilering. Serial.print() blir Serial1.print(), osv. Programmet kompileras från annan kod än den vi ser på skärmen.

#if defined skrivs ibland med förkortningen #ifdef som fungerar likadant. Det finns också ett motsatt direktiv som körs om identifiern inte har definierats. Det skrivs med #if !defined (utropstecknet innebär logisk invers), alternativt #ifndef med n för not.

Observera att identifiern till defined skrivs inom parentes. Den måste skrivas direkt efter defined, utan mellanrum. En #if kan som sagt ha flera identifiers, som då har varsin parentes.

Alla förprocessordirektiv skrivs utan avslutande semikolon.

I2C

I2C (Inter-integrated circuit protocol) är ett seriellt protokoll som skapades av Philips år 1982. Användningsområdet var kommunikation mellan de många kretsarna i konsumentelektronik, exempelvis processorer, EEPROM-minnen, displayer och A/D-omvandlare. Tekniken är anpassad för de korta avstånden på ett kretskort och hastigheter som idag är relativt långsamma (men ändå betydligt högre än UART).

Till skillnad från UART är I2C ett buss-protokoll. Det innebär att flera enheter delar på en gemensam ledare som går mellan alla enheter – bussen. I2C kräver två ledare, och såklart gemensam jord. En för data (SDA) och en för klocka (SCL). Båda dras vanligen höga av motstånd till matningsspänningen.

En stor del av protokollet handlar om hantering av av de många enheterna och att undvika krockar där flera enheter försöker skicka data samtidigt. Enheterna delas in i controller och _target. Controllern styr de andra enheterna – till exempel genom att be en viss sensor att skicka den senaste mätdatan. För att rätt enhet ska svara krävs det att alla enheter har en unik adress. I2C-adresser har normalt 7 bitar, vilket ger 27 = 128 möjliga adresser. Lite över hundra olika enheter skulle alltså kunna samsas på en buss.

I2C-buss

Genom att sända data över SDA och SCL på olika sätt kan enheter be om att få vara controller – flera controllers kan faktiskt samsas på en buss. Även om en Arduino kanske oftast används som controller kan den också agera target. Det kan behövas om man vill använda en analog sensor som inte själv kan kommunicera med I2C på en buss. Då kan en Arduinon vara ”hjärna” åt sensorn och skicka mätdatan seriellt.

I Arduino används biblioteket Wire.h för I2C-protokollet.

Wire.h

Först behöver vi lägga till wire-biblioteket. Precis som med Serial behöver vi starta kommunikationen med Wire.begin(). Den kan ta argument för alternativa portar för SDA (data) och SCL (klocka) – något vi inte behöver göra eftersom Edu Shields Qwiic-kontakt är kopplad till Unos vanliga I2C-pinnar. Ett tredje argument man kan ange är adress, något som behövs om Arduinon ska vara target.

Innan vi kan skicka någon data behöver vi säga till att vi ska göra det, samt adressera den enhet vi vill skicka till. Det gör vi med Wire.beginTransmission(adress). Därefter skickar vi datan med Wire.write(data), som alltså tar datan som argument. Man kan som mest skicka 32 bytes åt gången.

Efter att ha skickat data kan vi skicka mer, eller välja att avsluta kommunikationen med Wire.endTransmission(). Gör vi det så släpps I2C-bussen fri. Om bussen har andra controller-enheter kan de passa på och skicka meddelanden nu.

Att läsa in data görs på ett liknande sätt. Först säger vi att vi vill begära data från en enhet, men vi meddelar också mängden data (hur många bytes) vi vill ha: Wire.requestFrom(adress, antalBytes). Därefter kan vi läsa värden, och till exempel tilldela det till en variabel:

minVariabel = Wire.read(adress, antalBytes);

Qwiic använder I2C

Qwiic är en öppen standard för anslutning av seriella enheter framtagen av Sparkfun. Protokollet som används är I2C – det Qwiic handlar om är att ha en standardiserad kontakt (JST SH) och spänning (3.3V), så att olika kort kan kopplas samman snabbt och enkelt, utan lödning eller inkoppling av pull-up-motstånd.

Den öppna standarden innebär att det finns många olika kompatibla kort och adaptrar från olika tillverkare, med allt från sensorer, displayer, motordrivare till trådlös kommunikation, buffertar och drivkretsar för att uppnå längre räckvidd. Nya kort kommer hela tiden ut, och vem som helst kan använda standarden för egna ändamål.

Electrokit Edu Shield är helt kompatibelt med Qwiic-standarden, och har nivåomvandlare för anpassning till 3.3V logiknivå. Många Qwiic-kort har två kontakter för att möjliggöra seriekoppling av flera kort. Eftersom I2C är en buss är kontakterna utbytbara – det finns ingen ”ingång” eller ”utgång”. Även Edu Shield har två kontakter.

Edu Shield inkopplad i qwiic-modules

Kom igång snabbt med Qwiic

Nu ska vi visa hur snabbt man kan komma igång med Qwiic. Vi ska testa en Sparkfun Qwiic Alphanumeric Display, som är ett litet kort med en display, en drivkrets (VK16K33) som talar I2C, samt lite kringkomponenter och två Qwiic-kontakter. Displayen har fyra tecken med 14 segment var, samt kolon i mitten och en decimal innan det sista tecknet. På den kan man visa siffror och bokstäver, samt många andra tecken.

Biblioteket Sparkfun_Alphanumeric_Display.h

Sparkfun har inte bara tillverkat korten, de har också skapat ett bibliotek med öppen källkod. Utan det skulle vi behöva läsa i databladet för VK16K33 och ta reda på vilka kommandon vi behöver skicka. Nu behöver vi inte ens öppna det. Vi slipper dessutom tänka på exakt vilka segment som behöver tändas, och kan tänka i termer av tecken.

Förutom att välja vilka tecken de fyra displayerna ska visa samt styra kolon och decimaltecken så har biblioteket funktioner för att styra ljusstyrkan, och också för styrning av individuella segment. Biblioteket går att lägga till som vanligt i Arduino IDE eller ladda ner från Sparkfuns GitHub.

När man sitter med ett nytt bibliotek kan det vara svårt att veta var man ska börja. Som tur är finns det ofta exempelprogram som visar hur funktionerna kan användas. Exempelprogrammen laddas ner ihop med resten av biblioteket och går att öppna direkt inifrån Arduino IDE under File > Examples, där vi längst ner finner Examples from Custom Libraries.

opening examples from external libraries.ino

Undersökning av bibliotekets funktioner

Nu ska vi titta på ett exempel och snabbt pussla ihop ett program med det och några andra bitar kod som vi redan har liggande.

Målet är att skriva ett program som visar text på displayen. Eftersom det bara finns plats för fyra tecken ska programmet göra så att texten ”roterar” runt displayen. Då kan man visa längre meddelanden.

Istället för att bara berätta hur man gör ska vi gå igenom processen. Hur tar man reda på hur olika funktioner i ett program fungerar? Hur ska man tänka när man har ett mål och snabbt vill få ett resultat? Ofta finns det flera sätt att göra samma sak.

Vi har redan en ungefärlig bild av vad programmet bör innehålla: en array där vi lagrar texten, ++ för att inkrementera, och så en funktion med millis() så att texten inte roterar lagom snabbt. Men innan vi sätter igång med programmets struktur bör vi ta reda på hur displayen fungerar och styrs.

Vi börjar med att öppna det första exempelprogrammet från Sparkfun:

Example_01_PrintString.ino
/*****************************************************************************************
 * This example tests illuminating whole 4 letter strings on the 14-segment display.
 * 
 * Priyanka Makin @ SparkFun Electronics
 * Original Creation Date: February 3, 2020
 * 
 * SparkFun labored with love to create this code. Feel like supporting open source hardware?
 * Buy a board from SparkFun! https://www.sparkfun.com/products/16391
 * 
 * This code is Lemonadeware; if you see me (or any other SparkFun employee) at the 
 * local, and you've found our code helpful, please buy us a round!
 * 
 * Hardware Connections:
 * Attach Red Board to computer using micro-B USB cable.
 * Attach Qwiic Alphanumeric board to Red Board using Qwiic cable.
 * 
 * Distributed as-is; no warranty is given.
 ****************************************************************************************/
#include <Wire.h>

#include <SparkFun_Alphanumeric_Display.h> //Click here to get the library: http://librarymanager/All#SparkFun_Qwiic_Alphanumeric_Display by SparkFun
HT16K33 display;

void setup()
{
  Serial.begin(115200);
  Serial.println("SparkFun Qwiic Alphanumeric - Example 1: Print String");

  Wire.begin(); //Join I2C bus

  if (display.begin() == false)
  {
    Serial.println("Device did not acknowledge! Freezing.");
    while (1);
  }
  Serial.println("Display acknowledged.");

  display.print("Milk");
}

void loop()
{
}
Först ser vi två #include: en med Wire.h (för I2C) och en med Sparkfun_Alphanumeric_Display.h. Det senare biblioteket agerar mellanhand och skapar meddelanden med rätt format. De meddelandena skickas över I2C med Wire.h ”inuti” display-biblioteket. Därefter kommer en rad utan någon förklarande kommentar:

HT16K33 display;

Vår erfarenhet från biblioteken för servon och NeoPixlar säger oss att vi antagligen skapar ett ”objekt” med namnet display. Tittar vi längre ner ser vi olika kommandon med av typen ”display.funktion()”, och vi kan prova att byta ut texten ”display” mot ”minFinaDisplay” och kompilera koden – inga klagomål. Bra, då vet vi det.

Tips: Change all occurrences

Kommandot ctrl+F2 (cmd+F2 på macOS) aktiverar kommandot Change all occurrences (ändra alla förekomster). Prova att markera en bit kod, eller placera skrivmarkören inuti t.ex. en variabel eller ett tal, och utföra kommandot. Man kan också högerklicka när man har markerat koden (eller inuti t.ex. en variabel) och välja kommandot i menyn.
Det man skriver hamnar då på alla andra ställen som har samma text som den du markerat (eller variabelns namn). Praktiskt om man vill byta namn på en variabel, eller vill byta ut något återkommande tal mot en konstant eller variabel. Man får såklart vara försiktig så man inte råkar redigera saker man egentligen vill ha kvar - spara först, och testa sedan.
Funktionen varar till dess att du avbryter med Escape (ESC) eller klickar utanför texten du har markerat. Observera att du fortfarande kan flytta markören med piltangenterna, utan att avbryta funktionen. Om du gör det är det lätt att råka skriva över fel saker. Så glöm inte att avbryta när du är klar!

Därefter kommer ett Serial.begin(115200), olika seriella meddelanden, en if som verkar kolla om vi har fått kontakt med vår display. Viktigt, men inte så intressant. Själva koden som styr displayen finns längst ner i setup():

display.print("Milk");

Ja, det står ju Milk på vår display. Då vet vi hur man skriver ut text på displayen – det fungerar som Serial.print().Vi kan skriva texten inom citattecken och använda funktionen print(). Texten inom citattecken är en sträng – vad strängar är kommer du få veta lite längre ner.

Enkelt, men inte så värst användbart. Texten kan vara högst fyra tecken lång! En (dålig) lösning om man vill rotera en lite längre text är att ha flera strängar, så här:

display.print("min text "); // bara ”min ” syns
display.print("in text m"); // bara ”in t” syns
display.print("n text mi"); // bara ”n te” syns
display.print(" text min"); // bara ” tex” syns
// och så vidare

Förutom att det är väldigt besvärligt att skriva koden så finns det inget bra sätt att ändra texten – om man vill visa en temperatur eller vad klockan är så finns det ingen bra lösning. Man skulle kunna använda switch...case och ha en sträng för varje temperatur eller klockslag. Du inser nog att det skulle bli ett väldigt otympligt program.

Vi har gjort liknande saker med arrayer, så att ändra vad som ska visas är inga problem. Vi får leta vidare och se om och hur man kan styra individuella tecken på displayen, istället för att skicka färdiga meddelanden.

Lite längre ner i listan på exempelprogram ser vi en kandidat – skriva ut tecken (chars) låter ju rätt:

Example_03_PrintChar.ino
/*****************************************************************************************
 * This example tests illuminating whole characters on the 14-segment display.
 * 
 * Priyanka Makin @ SparkFun Electronics
 * Original Creation Date: February 3, 2020
 * 
 * SparkFun labored with love to create this code. Feel like supporting open source hardware?
 * Buy a board from SparkFun! https://www.sparkfun.com/products/16391
 * 
 * This code is Lemonadeware; if you see me (or any other SparkFun employee) at the
 * local, and you've found our code helpful, please buy us a round!
 * 
 * Hardware Connections:
 * Attach Red Board to computer using micro-B USB cable.
 * Attach Qwiic Alphanumeric board to Red Board using Qwiic cable.
 * 
 * Distributed as-is; no warranty is given.
 *****************************************************************************************/
#include <Wire.h>

#include <SparkFun_Alphanumeric_Display.h>  //Click here to get the library: http://librarymanager/All#SparkFun_Qwiic_Alphanumeric_Display by SparkFun
HT16K33 display;

void setup()
{
  Serial.begin(115200);
  Serial.println("SparkFun Qwiic Alphanumeric - Example 3: Print Character");
  Wire.begin(); //Join I2C bus

  //check if display will acknowledge
  if (display.begin() == false)
  {
    Serial.println("Device did not acknowledge! Freezing.");
    while(1);
  }
  Serial.println("Display acknowledged.");

  display.printChar('W', 0);
  display.printChar('H', 1);
  display.printChar('A', 2);
  display.printChar('T', 3);

  display.updateDisplay();
}

void loop(){
}

Början av programmet är likadan som förut, sedan skiljer det sig åt:

display.printChar('W', 0);
display.printChar('H', 1);
display.printChar('A', 2);
display.printChar('T', 3);
display.updateDisplay();

De första fyra raderna är enkla att förstå. printChar() vill uppenbarligen veta vilket tecken som ska skickas först och därefter på vilket av de fyra 14-segmentsdisplayerna det ska stå. Displayerna numreras från 0-3 – när vi för över programmet till Unon ser vi WHAT och inte TAHW.

På sista raden har vi funktionen updateDisplay(), som påminner om hur vi använde fill() för att uppdatera alla NeoPixlar. Vi kan kolla vad funktionen gör genom att kommentera bort den raden och föra över programmet till Arduinon. Mycket riktigt – displayen visar ingenting. Bra, då har vi alla verktyg vi behöver.

Nästa steg är att spara koden i ett eget program. Sedan kan vi att städa undan onödiga detaljer, t.ex. Sparkfuns långa inledande kommentar, som mest tar plats. De seriella meddelandena vid uppstart kan vi också strunta i. Vi kommenterar bort dem, så vi lätt kan ta tillbaks dem om vi behöver felsöka vår kod.

Displayen behöver dock startas med funktionen display.begin(), precis som servon och NeoPixlar tidigare. I exempelprogrammen körs funktionen i en if-sats. Om vi kommenterar bort hela startmeddelandet fungerar displayen inte.

Ett första steg kan vara att experimentera med olika tecken i printChar()s första argument. De är skrivna med enkla citattecken runtom – något vi inte har stött på tidigare. En ledtråd är att programmet heter PrintChar, och vi vet redan att char är en datatyp som handlar om tecken som &, Y eller 9. Om vi läser i Arduinos referens(1) om datatypen char ser vi denna exempelkod:

  1. Alltså Arduinos dokumentation.
char myChar = 'A';
char myChar = 65; // both are equivalent

Text inom enkla citattecken är alltså ett sätt berätta för kompilatorn att det vi skriver är ett tecken – en char.

printChar() vill ha en char – alltså ska det gå att ersätta 'W' och de andra fasta tecknen med variabler. Dessa variabler kan vi sedan tilldela olika tecken till. Låt oss prova!

En första test

Programmet nedan är en första snabb test av displayen.

6c1_qwiic_14seg_test1.ino
#include <Wire.h>
#include <SparkFun_Alphanumeric_Display.h>  //Click here to get the library: http://librarymanager/All#SparkFun_Qwiic_Alphanumeric_Display by SparkFun
HT16K33 display;

const char btn1 = 13;
const char btn2 = 7;
const char btn3 = 8;

char char0;

void setup() {
  // Serial.begin(115200);
  Wire.begin();
  pinMode(btn1, INPUT_PULLUP);
  pinMode(btn2, INPUT_PULLUP);
  pinMode(btn3, INPUT_PULLUP);
  // if (display.begin() == false) {
  //   Serial.println("Device did not acknowledge! Freezing.");
  //   while (1)
  //     ;
  // }
  display.begin(); // istället för det långa meddelandet ovan
}

void loop() {
  if (digitalRead(btn1) == LOW) {
    char0 = 77;  // 'M'
  }
  if (digitalRead(btn2) == LOW) {
    char0 = 65;  // 'A'
  }
  if (digitalRead(btn3) == LOW) {
    char0 = 88;  // 'X'
  }
  display.printChar(char0, 0);
  display.updateDisplay();
}

Programmet funkar inte bra! Men, det kan vara lärorikt att följa med i hur det skapas och i felsökningen. Lite längre ner finns en fungerande variant. Häng med!

Vi klistrar in konstant-deklarationer och pinMode()s för de tre knapparna. Knapparna styr varsin if. Varje if tilldelar ett nytt ”tecken” (i form av siffror) till char0, som ska bli vår variabel för tecknet till vänster.

if (digitalRead(btn1) == LOW) {
char0 = 77; // bokstaven M
}
if (digitalRead(btn2) == LOW) {
char0 = 65; // bokstaven A
}
if (digitalRead(btn3) == LOW) {
char0 = 88; // bokstaven X
}
display.printChar(char0, 0);
display.updateDisplay();

En första upptäckt upptäcker är att programmet inte fungerar bra om vi inte tilldelar något till char0. Det som händer då är att den får värdet 0. ASCII-koden 0 används för null, som inte är ett vanligt tecken – mer om det nedan. Resultatet är att alla 14 segment tänds. Vi kan tilldela tecknet mellanrum istället (ASCII-kod 32).

Därefter funkar programmet … hyfsat. Men det beter sig inte riktigt som vi vill. Nu vet vi att det går bra att ha en variabel som argument för printChar(), och att vi kan tilldela olika tecken till den. Problemet är att det förra tecknet som visades på displayen inte tas bort, så istället se en ny bokstav får vi alla föregående bokstäver ”ovanpå” varandra.

Displayen vet inte vad det nuvarande värdet på char0 är, den tar bara emot meddelanden om vilka segment som ska tändas. Vi behöver alltså något sätt att släcka segmenten.

En titt i bibliotekets header-fil

Baserat på deras namn så ser det inte ut som att de andra exempelprogrammen visar hur man tar bort tecken från displayen. Men det finns ett annat sätt att ta reda på vilka funktioner biblioteket har: vi kollar i header-filen. I den listas alla funktioner i biblioteket samt vilka argument de förväntar sig.

Man öppnar header-filen genom att högerklicka på SparkFun_Alphanumeric_Display.h och klicka på Go to Definition i menyn som dyker upp. Då öppnas header-filen i en ny flik i Arduino IDE. Efter att ha scrollat förbi de inledande raderna dyker funktionerna upp. Vi kan se begin(), isConnected(), m.m. Lite längre ner, på rad 127, finns en funktion som heter clear() – det ser lovande ut.

Vi kan testa:

if (digitalRead(btn1) == LOW) {
char0 = 77; // 'M'
}
if (digitalRead(btn2) == LOW) {
display.clear(); // tömmer detta displayen?
}
if (digitalRead(btn3) == LOW) {
char0 = 88; // 'X'
}
display.printChar(char0, 0);
display.updateDisplay();

Nu börjar det likna något! Displayen flimrar när vi trycker på knapp 2 – men char0 har ju inte uppdaterats, och direkt efter att displayen har tömts går programmet vidare och gör printChar() och updateDisplay() igen. Inget särskilt bra program, men ett har bekräftat att det är clear() vi behöver.

Lite längre ner i header-filen kan man notera att printChar faktiskt inte ska ha chars, utan uint8_t – heltal med 8 bitar, utan teckenbit. Det verkar som att kompilatorn (eller biblioteket) har löst det åt oss när vi har använt char, men vi byter till rätt datatyp i kommande program.

Det finns två andra sätt att få information om en funktion. Det ena är att högerklicka och välja Go to Definitions, som öppnar header-filen. Då hamnar vi i headern, och hamnar automatiskt på rätt rad för att se funktionen. Det andra sättet är att hålla musen över en funktion. Då dyker det upp en ruta som listar argumenten som står i headern.

Argumenten har namn, och om den som har skrivit programmet är snäll beskriver namnen vad argumentet styr.

function mouseover

6c2_qwiic_14seg_test2.ino
#include <Wire.h>
#include <SparkFun_Alphanumeric_Display.h>  //Click here to get the library: http://librarymanager/All#SparkFun_Qwiic_Alphanumeric_Display by SparkFun
HT16K33 display;

const char btn1 = 13;
const char btn2 = 7;
const char btn3 = 8;

char char0;

void setup() {
  // Serial.begin(115200);
  Wire.begin();
  pinMode(btn1, INPUT_PULLUP);
  pinMode(btn2, INPUT_PULLUP);
  pinMode(btn3, INPUT_PULLUP);
  // if (display.begin() == false) {
  //   Serial.println("Device did not acknowledge! Freezing.");
  //   while (1)
  //     ;
  // }
  display.begin(); // istället för det långa meddelandet ovan
}

void loop() {
  if (digitalRead(btn1) == LOW) {
    char0 = 77;  // 'M'
  }
  if (digitalRead(btn2) == LOW) {
    char0 = 65;  // 'A'
  }
  if (digitalRead(btn3) == LOW) {
    char0 = 88;  // 'X'
  }
  display.printChar(char0, 0);
  display.updateDisplay();
}

Vad finns i headern Arduino.h?

Till sist – du såg kanske texten #include <Arduino.h> flimra förbi? Det är header-filen för Arduino-biblioteket, och den kan vara värd att titta i. Header-filerna är skrivskyddade, så du kommer inte kunna förstöra något.

I Arduino.h kan du t.ex. se vad HIGH eller INPUT_PULLUP eller TWOPI är. Vi ser också att det vi trodde var funktionerna min() och max() skapas med #define:

#define min(a,b) ((a)<(b)?(a):(b))
#define max(a,b) ((a)>(b)?(a):(b))

De är alltså egentligen inte funktioner, utan något som kallas inline macros. Makron har flera fördelar, tydligast är kanske att de inte kräver någon särskild datatyp. Om de hade skrivits som funktioner hade det behövts separata min() och max() för alla datatyper – inte så praktiskt!

Nog om headers. Nu kommer det färdiga programmet:

Rotera text på 14-segmentsdisplay

6c3_qwiic_14-seg_rotate_text.ino
#include <Wire.h>
#include <SparkFun_Alphanumeric_Display.h>
HT16K33 display;

// konstanter
const char pot = A1;

// variabler för texten som ska roteras
uint8_t textToRotate[] = "Electrokit Edu Shield    ";
uint8_t textLength = sizeof(textToRotate) - 1;

// variabel för att rotera texten
uint8_t charOffset;

// variabler för timing
unsigned int rotateSpeed;
unsigned long newTime;
unsigned long oldTime;

// variabler för fyra tecken
uint8_t char0;
uint8_t char1;
uint8_t char2;
uint8_t char3;

void setup() {
  // Serial.begin(115200);
  Wire.begin();
  display.begin();  // displayen behöver startas med begin()
}

void loop() {
  rotateSpeed = 1123 - analogRead(pot);  // ställer in hastigheten – högre spänning ger mer minus

  newTime = millis();
  if (newTime >= oldTime + rotateSpeed) {
    oldTime = newTime;

    char0 = textToRotate[charOffset % textLength];        // tecknet längs till höger
    char1 = textToRotate[(charOffset + 1) % textLength];  //
    char2 = textToRotate[(charOffset + 2) % textLength];  //
    char3 = textToRotate[(charOffset + 3) % textLength];  // tecknet längst till höger
    display.clear();                                      // innan vi skickar de nya tecknen så töms displayen
    display.printChar(char0, 0);                          // sedan ges nya tecken till displayens fyra tecken
    display.printChar(char1, 1);                          //
    display.printChar(char2, 2);                          //
    display.printChar(char3, 3);                          //
    display.updateDisplay();                              // till sist kan displayen uppdateras
    charOffset++;                                         // flyttar fram "läsningen" ett hack i textToRotate[]
    charOffset %= textLength;                             // ser till att charOffset inte ökar okontrollerat
  }
}

Strängar – siffror, tecken och koder

Den första variabeln, ordAttRotera[], ser ut att vara en array, eftersom den har hakparenteser. Tidigare har vi deklarerat arrayers innehåll med en lista av siffror. Den skrevs med kommatecken mellan talen, och inom klammerparenteser:

int minArray[] = { 1, 3, 5, -31, 19 };

ordAttRotera[] deklareras istället med vanlig text, inom citattecken:

uint8_t textToRotate[] = "Electrokit Edu Shield ";

ordAttRotera[] är faktiskt inte en array, utan en sträng (eng. string). Strängar är väldigt lika arrayer, men har små och viktiga skillnader. Strängar innehåller tecken, till skillnad från arrayer som innehåller tal. Tecknen kan vara bokstäver, symboler som !, % och ?, men också siffror som 1, 4 eller 9.

Här får man hålla reda på termerna. Ett tal består av en eller flera siffror, och motsvarar ett numeriskt värde. En siffra är ett tecken, liksom som bokstäver och andra skrivtecken. Tecken lagras precis som tal med binära koder i något system, t.ex. ASCII.

Processorn som koderna lagras i har bara ett sätt att spara information – som binära tal. Talet 49 (en uint8_t) och koden 49 (en char som kodar tecknet 1) består av samma binära kod i processorns minne (00110001). Samma binära tal kan användas på olika vis av ett program. Om programmet skickar byten till Serial Monitor som ett tecken (datatypen char) kommer vi se tecknet 1, men om samma byte skickas som uint8_t skrivs istället talet 49 ut.(1)

  1. Exempelprogrammet extra_character_or_number.ino visar skillnaden.

En array och en sträng skulle kunna deklareras med samma text:

int talAttKalkyleraMed[] = {1,73,236,5+4};
char teckenAttSkrivaUt[] = "1,73,236,5+4";

Den första raden skapar en array, med fyra tal: 1, 73, 436 samt 9 (summan av 5+4).

Nästa rad skapar en sträng, som är tretton tecken lång. Innehållet är tolv tecken: 49, 44, 55, 51, 44, 50, 51, 54, 44, 53, 43 och 52. Notera att alla tal – alltså koderna – ryms inom 0-255. Tal som 236 har inga problem att rymmas i en int, men i en sträng tolkas det inte som ett tal (en byte) utan som tre siffror (tre bytes). Symbolerna , och + tas också med i strängen, medan de används för att separera olika tal och ”försvinner” när arrayen deklareras.

Strängar har ett slut

Nu undrar du kanske varför strängen ovan är tretton tecken lång, när den deklarerades med tolv tecken. Det trettonde tecknet är ett kontrolltecken som kallas null. Det har ASCII-koden 0 (binär kod: 00000000). Null används för att visa att en sträng är slut.

Null-tecknet är viktigt, eftersom man annars riskerar att läsa utanför en sträng. Risken finns också hos arrayer, men eftersom de innehåller tal och inte tecken så finns det inget ledigt tecken som kan visa att arrayen är slut – alla tal är ju tal. ASCII (och andra teckenkodningar) har dock olika kontrolltecken.

Att strängar har ett särskilt slut-tecken underlättar på många vis. Det går att använda strängar utan null-tecknet, och om man har väldigt ont om plats kan det vara nödvändigt. Men eftersom tecknet finns så förväntar sig många funktioner att det ska komma i slutet på en sträng – har man inte med det kan ens program få många konstiga fel.

Skapa strängar med eller utan \0

Serial.println("skriv ut detta tack");

I raden ovan skapar vi en sträng som argument för println(). När man skapar strängar med citattecken läggs det automatiskt till ett null-tecken på slutet av strängen. När tecknet dyker upp vet println() att det ska sluta skriva ut saker.

Det finns andra sätt att skapa strängar. Man kan ange varje tecken för sig. På samma sätt som (vanliga) citattecken visar att något är en sträng så visar enkla citattecken att något är ett tecken.

char minaFyraTecken[5] = { '1', '%', 'f', ' ' };

Koden ovan skapar en strängSträngen är fem tecken lång. Det femte tecknet blir automatiskt ett null-tecken.

Man kan också ange null-tecknet själv med \0.(1)

  1. Även \0 om det består av två tecken så tolkas det som ett – kom ihåg att det står inom citattecken. Andra kontrolltecken som går att skriva på det här sättet är t.ex. \n för ny rad och \t för tab.
char minaFyraTecken[5] = { '1', '%', 'f', ' ', '\0' };

Hur lång ska strängen vara?

Precis som med arrayer kan man låta kompilatorn avgöra hur lång strängen är genom att inte skriva något inom hakparenteserna. Detta fungerar såklart bara om man också skriver ett innehåll till strängen. Som med arrayer kan man skapa en tom sträng och bara ange datatyp, namn och storlek.

När man skapar en sträng bör man tänka på hur stor den ska vara. Om texten kan ändras av en användare – till exempel om de skriver in sitt namn – så bör det finnas plats också för långa namn.

Om man deklarerar en sträng så här:

char minaOrd[3] = "fler ord som tar mer plats";

… så varnar inte kompilatorn om att strängen inte får plats. När det gäller arrayer så säger kompilatorn däremot till.

Funktioner som skriver in data i strängen får såklart inte fortsätta skriva data om platsen tar slut. Programmet måste hantera sådana situationer på ett bra sätt och inte skriva över andra platser i processorns minne.

En sista sak att notera om strängar är att de inte kan ha vilken datatyp som helst. Vanligast är char, men uint8_t går också bra. Dock inte int eller long.

sizeof()

uint8_t textLength = sizeof(textToRotate) - 1;

Nästa rad använder sizeof(), som är ett sätt att enkelt få storleken av något. När programmet kompileras ersätts sizeof() med storleken på det som står i parentesen – i det här fallet strängen textToRotate[]. Strängen har 25 tecken (räkna själv!), så resultatet blir 26 inklusive \0. I det här fallet vill vi räkna bort null-tecknet eftersom programmet är skrivet på så vis att strängen läses av ett tecken i taget. Vi subtraherar alltså 1 från sizeof(). Resultatet 25 tilldelas variabeln textLength.

Man hade kunnat tilldela 25 till ´ direkt. Fördelen med sizeof() är att det går att byta ut strängen utan att behöva räkna tecken och ändra värdet på textLength.

Timing på rotationen

Som sagt så används millis() på samma sätt som förut. Istället för att skriva in kod på nytt kan vi leta upp millis()-koden i ett äldre projekt och klistra in den här.(1) Det kan vara bra att byta namn på någon eller några av variablerna.

  1. Eftersom koden används så ofta har vi skapat ett program som man kan plocka den ur: extra_millis.ino.

Potentiometern styr hastigheten på rotationen. Variabeln får heta rotateSpeed, även om den är egentligen är en tid att vänta, och inte en hastighet. Högre värden ger långsammare rotation. För att hastigheten ska öka när man vrider potentiometern medurs används den istället för att förminska ett tal. Talet är 1123, och från det subtraheras värden från potentiometern (0-1023). Resultatet blir tal från 100 till 1023 millisekunder – med högre fart när potentiometern vrids medurs.

Strängen läses av

Själva timing-koden fungerar precis som förut. Varje gång den aktiverar if-satsen görs följande:

  • Fyra tecken läses ur strängen textToRotate[]
  • Tecknen läses av på fyra platser: charOffset + 0, + 1, + 2 samt + 3. Varje plats begränsas samtidigt med %= textLength.
  • De fyra olika läsningarna tilldelas variablerna char0, char1, char2, char3¨
  • Displayen töms med clear()
  • De fyra tecknen i displayen (0-3) får innehållet i variablerna char0-char3
  • Displayen uppdateras med updateDisplay()
  • charOffset ökas med ++
  • charOffset begränsas med %= textLength

Qwiic-accelerometer och Serial Plotter

Sparkfun (och andra) har skapat många Qwiic-kort med olika sensorer på. Ett exempel är deras Triple Axis Accelerometer Breakout, som bygger på accelerometern BMA400 från Bosch Sensortec. Biblioteket för BMA400 har mängder av funktioner, till exempel automatisk väckning ut viloläge, rörelseigenkänning och stegräknare.

Här har vi ett typiskt exempelprogram för ett qwiic-kort. Man får veta hur man startar sensorn, läser av den, m.m. Istället för att gräva djupt i avancerade funktioner, ska vi skapa ett enkelt program för att testa sensorn.

Example02_BasicReadingsI2C.ino
#include <Wire.h>
#include "SparkFun_BMA400_Arduino_Library.h"

// Create a new sensor object
BMA400 accelerometer;

// I2C address selection
uint8_t i2cAddress = BMA400_I2C_ADDRESS_DEFAULT; // 0x14
//uint8_t i2cAddress = BMA400_I2C_ADDRESS_SECONDARY; // 0x15

void setup()
{
    // Start serial
    Serial.begin(115200);
    Serial.println("BMA400 Example 1 - Basic Readings I2C");

    // Initialize the I2C library
    Wire.begin();

    // Check if sensor is connected and initialize
    // Address is optional (defaults to 0x14)
    while(accelerometer.beginI2C(i2cAddress) != BMA400_OK)
    {
        // Not connected, inform user
        Serial.println("Error: BMA400 not connected, check wiring and I2C address!");

        // Wait a bit to see if connection is established
        delay(1000);
    }

    Serial.println("BMA400 connected!");
}

void loop()
{
    // Get measurements from the sensor. This must be called before accessing
    // the acceleration data, otherwise it will never update
    accelerometer.getSensorData();

    // Print acceleration data
    Serial.print("Acceleration in g's");
    Serial.print("\t");
    Serial.print("X: ");
    Serial.print(accelerometer.data.accelX, 3);
    Serial.print("\t");
    Serial.print("Y: ");
    Serial.print(accelerometer.data.accelY, 3);
    Serial.print("\t");
    Serial.print("Z: ");
    Serial.println(accelerometer.data.accelZ, 3);

    // Print 50x per second
    delay(20);
}

Serial Plotter

För att använda datan på bästa sätt är det bra att veta ungefär vilka värden vi får. Ett sätt är att använda Serial Monitor, vilket används i exemplet ovan. Ett annat sätt är Serial Plotter, som ofta kan ge en bättre överblick, särskilt när stora mängder data kommer på en gång. Den ligger under menyn Tools.

Man använder Serial Plotter ungefär som Serial Monitor, med Serial.print() och println(). Vissa skillnader finns dock. Till att börja med kan Serial Plotter inte visa text. Enstaka tecken går bra att visa (i form av koder, alltså heltal) men strängar kan få den att lägga av helt.

Om man vill visa flera värden samtidigt får man göra som i exemplet, och separera dem med tab (vilket är indrag, alltså det man får med tangenten med ⇥ på). Koden för tabb är \t. och mellan varje data behöver man skicka ett tab-tecken. Om man har tre dataströmmar (data1, data2 och data3) behövs alltså två tab.

Serial.print(data1);
Serial.print("\t");
Serial.print(data2);
Serial.print("\t");
Serial.print(data3);
Serial.print("\n");

Efter att datapunkterna har skickats ska man göra radbrott. Radbrottet är det som gör att Serial Plotter flyttar datan vidare och fortsätter att rita linjen – annars skulle man bara få fler och fler punkter ovanpå varandra. I exemplet ovan görs radbrottet med tecknet för ny rad, \n. Andra sätt är att göra som i Sparkfuns program (på rad 50) och använda println() på den sista datapunkten. Man kan också ha en println() utan något meddelande på sista raden. Om man vill byta ordning på raderna eller kommentera bort rader kan det underlätta att ha radbrottet på en egen rad, så att det alltid finns kvar på rätt plats.

Serial Plotters automatiska omskalning

En egenhet med Serial Plotter är att den konstant ändrar den vertikala skalan så att all data får plats. Det finns inget sätt att manuellt ändra skalan, eller låsa den.

Om datan ligger still men ibland har korta ”spikar” med högre eller lägre värden, så kommer skalan ändras så att spikarna får plats. Men så fort ett sådant värde har passerat ur grafen till vänster i bild förminskas skalan igen. Resultatet blir ofta att skalan snabbt förändras allt eftersom nya värden kommer in, och man får väldigt svårt att hinna vilka värden kurvan motsvarar.

En lösning på problemet är att hela tiden skicka fasta värden som fixerar skalan. Om man till exempel tittar på värden från hela potentiometerns spann så vet man att skalan behöver täcka in värden från 0-1023. Då kan man kontant skicka det högsta och lägsta värdet, eller ännu bättre värden strax utanför spannet (så att extra-värdena hamnar utanför datalinjen och inte kan täcka över den).

```arduino title= "extra_plotter_fix.ino" const char pot = A1;

void setup() { Serial.begin(115200); }

void loop() {

Serial.print(-5); // fix Serial.print("\t"); // tab Serial.print(analogRead(pot)); Serial.print("\t"); Serial.print(1028); // fix Serial.println(); // ny rad delay(10); }

Kommentera bort raderna med `// fix` för att se hur det brukar kunna bli med Serial Plotter.

En annan begränsning är att Serial Plotter bara kan visa värden på en och samma skala. Om man tittar på värden från potentiometern (0-1023) tillsammans med inomhustemperaturer (decimaltal från kanske 15-25°C) så kan blir det väldigt svårt att se detaljer i temperaturdatan, som alla kläms in trängas in allra längst ner på skalan 0-1023. I sådana fall är det en bra idé att skala om värdena så att de är ungefär lika stora. Man kan med fördel göra detta inuti `Serial.print()`, så att själva variablerna (som kanske används på andra ställen i programmet) inte påverkas. Till exempel:

```arduino
  Serial.print(temperature);  // temperaturer från ca 15.0 – 25.0
  Serial.print("\t");
  Serial.println(10 + (analogRead(pot)) / 100.0);  // räknar om tal från 1023 till tal från 10.0 – 10.33

7: Mäta temperatur med en analog sensor

Sensorn TMP235

På EK Edu Shield finns en TMP235 från Texas Instruments, en precis analog temperatursensor som klarar temperaturer från -40°C till +150°C. På utgången lämnas en spänning som linjärt motsvarar temperaturen. Spänningen ökar med 10mV per °C. mV betyder millivolt, alltså tusendels volt.

I grafen nedan ser vi också att spänningarna ut från TMP235 rör sig från strax över 0V till ungefär 2V. Tittar vi i Texas Instruments datablad så kan vi se att den lägsta utspänningen (offset voltage) är 100mV (motsvarande -40°C) och att den högsta spänningen – 2V – motsvarar 150°C.

Graf som visar spänningar för temperaturer för TMP235 Graf som visar förhållandet mellan temperatur och utspänning för TMP235 (den svarta linjen) ur databladet.

Analoga spänningar motsvarar alltså temperaturer. Men mätningar på Arduinons analoga ingångar med analogRead() ger oss ju inte spänningar, utan digitala heltal från 0-1023. 3. För att ta reda på temperaturen sensorn har mätt upp är därför det första steget att räkna ut vad talen motsvarar för spänningar.

Från heltal till decimaltal

Att räkna om digitala värden till spänningar är väldigt vanligt. Förutom när man arbetar med analoga sensorer som TMP235 kan man t.ex. behöva mäta spänningsmatningen från en strömförsörjning, laddningsnivån i ett batteri, m.m. Ibland kan orsaken vara så enkel som att man tycker det är lättare att skriva kod och räkna med decimaltal än heltal.

Som tur är så är uträkningen lätt att göra. Först omvandlar man heltalen från A/D-omvandlaren till decimaltal genom att dividera dem med decimaltalet 1024.0.

Observera punkten och nollan efter 1024. Punkten gör att talet är ett decimaltal (float), vilket ser till att resultatet från divisionen också blir decimaltal. Om vi hade delat med heltalet 1024 utan punkt, så hade alla tal från 0-1023 gett samma produkt – 0! Nollan behövs strikt talat inte, man kan bara dela med 1024., men den gör det tydligare för oss människor att divisionen görs med decimaltal. Resultatet av divisionen är decimaltal från 0.0 till ungefär 1.0.

Nästa steg är att multiplicera kvoten med ett decimaltal som motsvarar den högsta spänningen. För att få en spänning i millivolt måste vi också uttrycka spänningen i tusendelar av volt. I ett 5V-system ska man alltså multiplicera med 5000.0, inte 5.0, eftersom 5V = 5000mV.

På omvandlare med högre upplösning behöver man såklart dividera med motsvarande decimaltal, t.ex. 16384.0 på en 14-bitars omvandlare.

Beräkna temperatur från spänning

Nu har vi fått en spänning i rätt format. Stegen ovan är likadana oavsett vilken spänning man mäter, om det är ett batteri, en potentiometer eller en av alla sensorer som lämnar analoga spänningar. För analoga sensorer är nästa steg att utifrån spänningen räkna ut den uppmätta storheten – temperatur, lufttryck, osv.

7a1_tempsensor_TMP235.ino
const char temp = A3;

int tempInt;
float tempFloat;
float tempCelsius;
float milliVolt;

void setup() {
  Serial.begin(115200);
}

void loop() {
  tempInt = analogRead(temp);              // läser av temperatursensorns spänning
  tempFloat = tempInt / 1024.0;            // dividerar den med 1024.0 (ändra till t.ex. 4096.0 för 12-bitars omvandlare)
  milliVolt = tempFloat * 5000.0;          // dividerar med matningsspänningen i mV (ändra till 3300.0 om du har ett 3.3V-utvecklingskort)
  tempCelsius = (milliVolt * 0.1) - 50.0;  // räknar ut temperaturen
  Serial.print("spänning i mV: ");         // inget radbrott, men ett mellanrum efter :
  Serial.println(milliVolt);               // uppmätt spänning
  Serial.print("temperatur i °C: ");       // och ...
  Serial.println(tempCelsius);             // motsvarande temperatur
  Serial.println();                        // en tom rad mellan mätningarna
  delay(1000);
}

Olika sensorer har olika sätt att räkna ut detta, i vissa fall handlar det om komplicerade differentialekvationer. Tack och lov slipper man det med TMP235 – spänningen ökar ju linjärt med 10mV per °C. Om spänningen går upp 10mV motsvarar det 1°C. Om man dividerar spänningen med 10 får man alltså temperaturen. 1°C = 10mV

Division är tyvärr en besvärlig och långsam uträkning att göra för de flesta processorer. Även om vi inte direkt har bråttom att räkna ut temperaturen – den kan ju inte förändras så väldigt snabbt(1) – så är det en god vana (och roligt!) att skriva koden så att den går snabbt att utföra (så länge den inte blir oläslig.) Ett sätt är att använda andra räknesätt än divison, när det är möjligt.

  1. Om temperaturen ökar fortare än Arduinon kan räkna division så har du förmodligen andra problem än koden.

Istället för mV / 10.0 kan vi räkna mV * 0.1 och få samma resultat.

120mV / 10 = 12°C

blir till

120mV * 0.1 = 12°C

Det finns ett litet problem kvar, som är enkelt ordnat. Som du kanske minns är den högsta spänningen från TMP235 två Volt – 2000mV. Det motsvarar den högsta mätbara temperaturen, 150°C. Men 2000 * 0.1 blir ju 200, inte 150. För att resultatet ska motsvara en temperatur i °C behöver därför också subtrahera 50, efter att ha delat spänningen på tio.

Ekvationen blir alltså: temperatur i °C = spänning i mV * 0.1 - 50.0. T.ex:

500 * 0.1 = 50. 50  50 = 0.

Det stämmer bra – om du tittar i tabellen överst ser du att det svarta strecket korsar 0°C precis vid 0,5V (500mV). Ökar vi spänningen med 10mV får vi 510 * 0.1 = 51. Subtrahera 50 och du får 1°C – en grad varmare. Enkelt! Nu kan du få reda på hur varmt det är i rummet där du sitter.

Simulering av större temperaturspann

Hur gör man då om man vill se vilka spänningar som motsvarar mer extrema temperaturer som 129°C eller -35°C? Ibland kan det ju vara bra att dubbelkolla att en uträkning stämmer för alla tänkbara värden. För att mäta värma temperaturer skulle man kunna stoppa sin Uno och Edu Shield i ugnen – något vi inte rekommenderar. Kyla är inte lika skadligt för elektronik, men det är sällan minus 35°C utomhus, så också det blir svårt att testa. Man kan såklart skriva in exempelspänningar i programmet, eller bara räkna på en miniräknare. Ett alternativt och smidigare sätt är att tillfälligt ersätta temperatursensorn med potentiometern.

7b_tempsensor_TMP235_pot_simulation.ino
const char pot = A1;

float adcFloat;
float simuleradMilliVolt;
float simuleradTemp;
int adcVal;

void setup() {
  Serial.begin(115200);
}

void loop() {
  adcVal = analogRead(pot);
  adcFloat = adcVal / 1024.0;
  simuleradMilliVolt = adcFloat * 1900. + 100;  // simulerar att potentiometern lämnar spänningar från 100mV till 2000mV, istället för 0V till 5000mV
  simuleradTemp = (simuleradMilliVolt * 0.1) - 50.;
  Serial.print("simulerad spänning i mV: ");
  Serial.println(simuleradMilliVolt);
  Serial.print("simulerad temperatur i °C: ");
  Serial.println(simuleradTemp);
  Serial.println();  // en tom rad mellan mätningarna
  delay(50);         // kortare väntetid eftersom "temperaturen" ändras fortare
}

Det här programmet fungerar precis som det föregående gör, men använder potentiometern istället för temperatursensorn. Spänningarna från potentiometern rör sig i spannet 0-5V, men genom att multiplicera dem med 1900.0 och lägga till 100 kan man enkelt simulera en TMP235 som ger spänningar i rätt spann – 100mV till 2V.

Att simulera analoga sensorer med en potentiometer är något som många gånger kan underlätta i utvecklingsarbetet. Kanske håller man på och skapar en apparat som ska starta en värmepump när det blir nollgradigt, eller tända utomhusbelysningen när det skymmer. Ofta är det då enklast att simulera sensorn i början av projektet.

Högre precision med extern referensspänning

Multiplikation med 5000.0 bygger på en matningsspänning på 5V. I 3.3V-system behöver man istället multiplicera med 3300.0. A/D-omvandlarna i processorn mäter ju spänningar från 0V till den spänning processorn har, oftast 5V.

Att ändra i koden när man byter utvecklingskort fungerar, men är inte särskilt smidigt. Och det finns faktiskt en mycket bättre lösning: man låter A/D-omvandlarna använda en extern spänningsreferens istället för processorns interna referensspänning. Om man väljer 3.3V så kommer koden fungera likadant, oavsett om processorn använder 5V eller 3.3V. Att använda en extern spänningsreferens har flera fördelar, bland annat kan man välja en mer stabil referens som är isolerad från processorns spänningsmatning.

AREF

Processorns ingång för extern spänningsreferens heter AREF, som står för Analog Reference. På ett Uno-kort är ingenting anslutet till ingången, men på Edu Shield är ingången kopplad till en skjutomkopplare, som också heter AREF. Du har kanske redan undrat vad den är till för.

Med AREF-omkopplaren väljer man 5V eller 3.3V. Den spänning man väljer skickas till processorns externa referensingång. Men den går inte bara dit, utan också också till temperatursensorn, ljussensorn och potentiometern. Annars skulle ju potentiometern och ljussensorn (som ger spänningar över hela spannet från 0-5V) kunna skicka spänningar över A/D-omvandlarnas tak – inte bra. Förutom A/D-omvandlaren i Arduinon får även sensorerna en reglerad spänning, med mycket lite störningar utifrån.

5V-spänningen kan komma från två källor. Antingen direkt från datorns USB-port, en strömförsörjning som ofta har mycket störningar och brukar vara ”ungefär” på 5V. Det bättre alternativet är om man använder DC-kontakten. Den spänningsmatningen har troligen har lägre brus än USB-matningen, och oavsett regleras den till 5V vilket tar bort mycket störningar. Men också i det fallet drivs processorn med samma spänning som referensen.

analogReference()

Det räcker inte att ställa omkopplaren till 3.3V. Processorn måste också veta att den ska använda den externa spänningen, annars kommer den fortsätta utgå från sin interna 5V-referens. Ungefär som med pinMode() gör vi lämpligen inställningen i setup(), och vi använder funktionen analogReference(). Med analogReference(EXTERNAL) används just den externa spänning.

Om man inte åberopar funktionen så används Arduinons interna referens som vanligt. Om man av någon anledning vill växla mellan olika referenser kan man gå tillbaks till matningsspänningen med analogReference(DEFAULT).

På vissa processorer kan man välja mellan flera interna referensspänningar. Uno R3 har en extra spänningsreferens på ungefär 1.1V, medan Uno R4 har både 1.5, 2.0 och 2.5V. De väljs med argumenten INTERNAL på R3 respektive INTERNAL_1_V_5 (osv.) på R4.

Eftersom de interna referensspänningarna lämnas på AREF-pinnen (som en utgång) så går de inte att använda ihop med Edu Shield. Den ingången är ju alltid ansluten till 5V eller 3.3V, och kommer ”krocka” med en intern spänningsreferens.

7a2_tempsensor_TMP235_external_AREF.ino
#if defined(ARDUINO_UNOR4_MINIMA) || defined(ARDUINO_UNOR4_WIFI)  // om Unon är en R4 Minima eller R4 Wifi
#define EXTERNAL AR_EXTERNAL                                      // ersätts EXTERNAL i koden med AR_EXTERNAL
#endif

const char temp = A1;

int tempInt;
float tempFloat;
float tempCelsius;
float milliVolt;

void setup() {
  Serial.begin(115200);
  analogReference(EXTERNAL);  // glöm inte att ställa omkopplaren till 3.3V!
}

void loop() {
  tempInt = analogRead(temp);              // läser av temperatursensorns spänning
  tempFloat = tempInt / 1024.0;            // dividerar den med 1024.0 (ändra till t.ex. 4096.0 för 12-bitars omvandlare)
  milliVolt = tempFloat * 3300.0;          // dividerar med AREF 3300mV
  tempCelsius = (milliVolt * 0.1) - 50.0;  // räknar ut temperaturen
  Serial.print("spänning i mV: ");         // inget radbrott, men ett mellanrum efter :
  Serial.println(milliVolt);               // uppmätt spänning
  Serial.print("temperatur i °C: ");       // och ...
  Serial.println(tempCelsius);             // motsvarande temperatur
  Serial.print("kod: ");                   // och ...
  Serial.println(tempInt);                 // motsvarande temperatur
  Serial.println();                        // en tom rad mellan mätningarna
  delay(1000);
}

Fördelarna med 3.3V extern referensspänning

Som du redan vet så lämnar TMP235 spänningar från 100mV till 2V på sin utgång. Detta gäller oavsett om den matas med 5V eller 3.3V (den kan faktiskt drivas med så lite som 2.3V). A/D-omvandlarens upplösning ger i sin tur ett visst antal värden som kan läsas, t.ex. 1024 olika spänningar. Spänningarna är jämnt fördelade från 0V till referensspänningen, som ju normalt är 5V. Utspänningarna från TMP235 (0.1-2V) utnyttjar alltså inte ens hälften av omvandlarnas spann. Mer än halva upplösningen (från 2V till 5V) är i praktiken bortkastad. Om spänningsreferensen istället ställs till 3.3V kommer vi få bättre mätningar. En större del av omvandlarens diskreta värden täcker då in spänningarna från sensorn.

I tabellerna nedan ser du exempel på spänningar, motsvarande digital kod (10-bitars A/D) och temperatur för TMP235s ytterlägen, A/D-omvandlarens lägsta, mittersta och högsta värden, samt fryspunkten och rumstemperatur. Först ett 5V-system:

Digitalt värde Spänning Temperatur Kommentar
0 0V - A/D minimum
20 100mV -40°C lägsta temperatur
102 500mV 0°C fryspunkt
143 700mV 20°C rumstemperatur
409 2V 150°C max temperatur
512 2,5V - 9 bitar används
1023 5V - A/D max, 10 bitar

TMP235 med 5V AREF

Mer än halva upplösningen från omvandlaren (normalt tio bitar) är bortkastad med en spänningsreferens på 5V. En spänning på 2,5V motsvarar nio bitar, men TMP235 når inte ens dit. Om vi istället använder 3.3V som referens får vi följande tabell:

Digitalt värde Spänning Temperatur Kommentar
0 0V - A/D minimum
31 100mV -40°C lägsta temperatur
155 500mV 0°C fryspunkt
217 700mV 20°C rumstemperatur
512 1,65V 115°C 9 bitar används
620 2V 150°C max temperatur
1023 3.3V - A/D max, 10 bitar

TMP235 med 3.3V AREF

En liten vinst i precision, men också ett program som fungerar utan modifikation i system med både 3.3 och 5V logiknivå.

I många projekt är man dessutom främst intresserad av att mäta temperaturer inom ett mindre spann. Det finns väldigt få miljöer där hela spannet -40°C till +150°C kan förekomma. Ju mindre spann man mäter, desto mindre av precisionen i både TMP235 och A/D-omvandlare kommer till nytta.

Spänningsreferens vs. spänningsregulator

För mätningar med högre precision är det lämpligt att använda en särskild spänningsreferens, istället för en spänningsregulator. Spänningsreferenser är är mer exakta än regulatorer, men kan i gengäld inte mata nämnvärda mängder ström. Syftet är att lämna en referensspänning och inget annat. Precisionen på spänningsreferenser anges ofta med en procentsats, t.ex. 0.1%. En typisk spänningsregulator kan i jämförelse ha en felmarginal på omkring 5%.

Det är inte bara den absoluta precisionen skiljer, referenser presterar också bättre vad gäller örändring över tid och över temperatur – vilket ofta är viktigare än att referensen har exakt rätt spänning.

En dedikerad spänningsreferens är dessutom isolerad från andra komponenter. Dess spänning är opåverkad av både kortvariga spänningsfall när en regulator belastas intermittent, liksom störningar och brus som annars kan ledas över från switchande transistorer i processorer och NeoPixlar, liksom seriell kommunikation och PWM-signaler.

Det finns också spänningsreferenser med spänningar anpassade för mikrokontrollers och A/D-omvandlare, med spänningar som 2048mV eller 4096mV. Med en sådan referensspänning behöver man inte göra beräkningar med division, eller omvandling till decimaltal (med tillhörande risker för räknefel). I ett 12-bitars-system (med 212 = 4096 diskreta värden) passar en spänningsreferens med 4.096V utmärkt. Heltalen från A/D-omvandlaren motsvarar precis spänningen i millivolt. Heltalen 0 motsvarar 0V, 5 motsvarar 5mV, och 3497 motsvarar 3.497V osv. – smidigt!

Temperaturlarm

7c_temperature_alarm.ino
#include <Adafruit_NeoPixel.h>
#if defined(ARDUINO_UNOR4_MINIMA) || defined(ARDUINO_UNOR4_WIFI)
#define EXTERNAL AR_EXTERNAL
#endif
Adafruit_NeoPixel neo_led(1, 6, NEO_GRB + NEO_KHZ800);

const char temp = A3;  // ändra till A1 om du vill prova med potentiometern istället
const char spkr = 2;

bool alarmBool;
int tempInts;
int hysteresis = 0;
unsigned long currentMillis;
unsigned long previousMillis;
unsigned long readInterval = 1000;
float tempFloat;
float tempCelsius;
float milliVolt;
float alarmTemp = 23.0;

void setup() {
  // Serial.begin(115200);
  analogReference(EXTERNAL);  // glöm inte att ställa AREF-omkopplaren till 3.3V!
  neo_led.setBrightness(64);
  neo_led.begin();
  neo_led.show();
}

void loop() {
  tempInts = analogRead(temp);
  tempFloat = tempInts / 1024.0;
  milliVolt = tempFloat * 3300.;
  tempCelsius = (milliVolt * 0.1) - 50.;

  if (tempCelsius < (alarmTemp - hysteresis)) {
    noTone(spkr);
    neo_led.setPixelColor(0, 0xF00);  // grönt svagt ljus
    neo_led.show();
    hysteresis = 0;

  } else if (tempCelsius >= (alarmTemp - hysteresis)) {  // observera att hysteresis fortfarande är 0
    hysteresis = 1;                                      // här aktiveras hysteresen, och det behöver bli 22 grader (eller lägre) för att larmet ska stängas av
    currentMillis = millis();
    if (currentMillis - previousMillis >= readInterval) {
      previousMillis = currentMillis;
      alarmBool = !alarmBool;
      if (alarmBool == 1) {
        neo_led.setPixelColor(0, 0xFF0000);  // rött ljus, starkt
        tone(spkr, 800);
      } else if (alarmBool == 0) {
        neo_led.setPixelColor(0, 0);  // släckt NeoPixel
        noTone(spkr);
      }
      neo_led.show();
    }
  }
}

Det här programmet är ett enkelt temperaturlarm, som varnar med rött blinkande ljus och en ljudsignal när temperaturen blir för hög. I programmet återanvänds flera bekanta funktioner och kodstycken, t.ex if, else och ”tidtagaren” med millis().

Strukturen är enkel: först läses temperatursensorn av, och värdena räknas om till grader celsius med samma uträkning som förut. Därefter följer två if, som väljs utifrån den uppmätta temperaturen. Variabeln alarmTemp är den temperatur då larmet börjar varna. Det förinställda värdet är 23 grader, vilket oftast är varmare än rumstemperatur, men inte så varmt att du inte själv kan värma sensorn för att aktivera larmet.

Hysteres på ett annat sätt

Konceptet hysteres har redan beskrivits i beskrivningen av personräknaren. I korthet så är hysteres ett sätt att göra ett system mindre känsligt för små förändringar. Man gör det genom att flytta ett gränsvärde beroende på systemets nuvarande tillstånd.

I personräknaren skapades hysteres med två olika tröskelvärden, som användes för att avgöra när en person börjar respektive slutar skugga ljussensorn. I temperaturlarmet används istället en variabel, som tilldelas olika värden beroende på om temperaturen är under eller över gränsen för larmet. Resultatet blir ungefär likadant. Det finns många sätt att skapa hysteres. Att vi har valt detta sätt i temperaturlarmet är för att det ursprungliga värdet är 0: vi vet att larmet varnar när den uppmätta temperaturen är större än alarmtemperaturen. Först efter att larmet har aktiverats tilldelas ett nytt värde till hysteresen.

Färgerna

I detta program används två färger, en svag grön och en starkare röd. Eftersom programmet inte behöver något sätt för användaren att välja färg kan vi bara ange två 32-bitars tal direkt, och strunta i funktionerna för att packa ihop eller mappa från HSV till RGB.

De två färgerna vals med följande rader, där det första argumentet är vilken neoPixel det gäller, och det andra anger färgen:

neo_led.setPixelColor(0, 0xF00);
neo_led.setPixelColor(0, 0xFF0000);

Precis som när vi skriver tal med det decimala eller binära talsystemet skrivs inte de inledande nollorna ut. 0xF00 motsvarar 0x000F00, och ger alltså 0 rött ljus, 16 grönt ljus och 0 blått ljus.

Larmsignalen

Larmet består av ett blinkande rött ljus ihop med en tutande signal. Som nämnt återanvänds funktionen med millis() för att hantera tiden. En bool med namnet alarmBool används för att hantera de två tillstånden – ljud och ljus, eller tystnad och mörker.

Initialt är alarmBool lika med 0. När larmet aktiveras börjar millis()-loopen växla alarmBool från 0 till 1, en gång i sekunden. Det görs genom tilldelning av !alarmBool, alltså dess logiska motsats. Om alarmBool är = 1 så är !alarmBool lika med 0, och vice versa.

En av två if körs beroende på alarmBools värde. Om alarmBool är 1 så ställs NeoPixeln in till att lysa rött, och högtalaren börjar tuta med tone(). Är alarmBool istället 0 så släcks NeoPixeln (genom att få ”färgen” 0) och högtalaren tystas med noTone().

Sist i millis()-loopen skickas färgvärdet till NeoPixeln med show().

Vidare utveckling av programmet

Några möjligheter att prova:

  • Skapa ett larm som varnar när temperaturen är för låg. Då kanske NeoPixeln borde lysa blått. Kan du själv tänka ut en hexadecimal kod som ger blått ljus?
  • Skapa ett larm som har två nivåer, med en mildare varning när temperaturen är nära gränsen, men inte över den.
  • Skapa ett larm som varnar både för för höga och för låga temperaturer, med olika färger och läten.
  • Göra så att man kan nollställa tut-signalen med ett knapptryck.
  • Göra så att larmet varnar med högre intensitet – t.ex. snabbare blink och pip – ju mer temperaturen överskrids.
  • Skapa ett larm som kontrollerar temperaturen mer sällan, t.ex. en gång i minuten. Kanske kan du öka mäthastigheten när temperaturen börjar bli ”farligt” hög, och mäta mer sällan när den är långt ifrån larm-temperaturen?
  • Kombinationer av ovanstående förslag!

8: Klubba mullvaden-spel (Whac-a-mole)

Det här programmet är ett spel du kanske känner igen från nöjesfält – i miniatyrformat. Tre lysdioder tänds slumpvis, och spelaren ska trycka på motsvarande knapp så fort som möjligt. Varje tryck ger en poäng. Målet är att samla så många poäng som möjligt under spelets gång, 30 sekunder. Programmet bygger på projektet Reaction Game av BadMonkeyEdd. Programmet har förenklats och anpassats till Edu Shields begränsningar (tre knappar och lysdioder).

Klicka här för att hoppa direkt till genomgången av programmet.

8_whac-a-mole.ino
// baserad på Reaction Game av BadMonkeyEdd
// se https://www.instructables.com/Reaction-Game-1/ för originalspelet.
// anpassat till Electrokit Uno Edu Shield av Max Wainwright åt Electrokit Sweden AB. www.electrokit.se
// licens: CC-BY-NC-SA
// https://creativecommons.org/licenses/by-nc-sa/4.0/

// konstanter för pin-nummer:
const char btn1 = 13;
const char btn2 = 7;
const char btn3 = 8;
const char led1 = 11;
const char led2 = 10;
const char led3 = 9;
const char spkr = 2;
const char ldr = A0;

// konstanter för spelet
const int numOfBtns = 3;                             // ändra detta om du vill göra ett större spel. Skriv in antalet lysdioder och knappar (som ju måste vara lika många) här
const int LED[numOfBtns] = { led1, led2, led3 };     // array med led-pin-nummer
const int BUTTON[numOfBtns] = { btn1, btn2, btn3 };  // array med knapp-pin-nummer
// båda dessa arrayer gör att man kan anropa lysdioder och knappar med tal från 0-2, istället för de olika stiftnamnen de har.
// dessutom har knapparna 1-3 och lysdioderna 1-3 motsvarande tal, istället för helt orelaterade.
// det senare är användbart eftersom knapparna och lysdioderna hör ihop i detta spel.

// variabler i spelet
int gameDuration = 30;  // längden på ett parti, i sekunder. Ändra om du vill spela längre!
int highScore = 0;      // här sparas det bästa resultatet

void setup() {
  randomSeed(analogRead(ldr));  // läser av ljussensorn och använder talet för att påverka slumpgeneratorn. Annars blir den slumpade ordningen samma varje gång programmet körs.
  gameDuration *= 1000;        // räknar om spelets längd i sekunder till millisekunder
  Serial.begin(115200);

  // en massa pinmode()s
  pinMode(led1, OUTPUT);
  pinMode(led2, OUTPUT);
  pinMode(led3, OUTPUT);
  pinMode(btn1, INPUT_PULLUP);
  pinMode(btn2, INPUT_PULLUP);
  pinMode(btn3, INPUT_PULLUP);
  pinMode(spkr, OUTPUT);
}

void loop() {
  Serial.println("Press middle button to begin")
  while (digitalRead(btn2) == HIGH) {  // inväntar  ett knapptryck på mittenknappen
  digitalWrite(led2, HIGH);
  }
  digitalWrite(led2, LOW);
  CountDown();  // först körs nedräkningen
  PlayGame();   // sedan körs spelet
}  // sedan börjar loopen om igen

void CountDown() {  // nedräkningen:
  Serial.print("Try to beat the current highscore, which is: ");
  Serial.print(highScore);
  Serial.println(". Good luck!");  //
  Serial.println("Get ready!");    //

  delay(512);
  for (int i = 3; i > 0; i--) {  // räknar ner från 3, 2, 1
    SetAllLEDs(1);               // kör SetAllLEDs() med argumentet 1 – tänder alla lysdioder
    Serial.print(i);             // skriver 3, sedan 2, sedan 1
    tone(spkr, 1044 / i);           // ton baserad på nedräkningen – tonhöjden går upp
    delay(333 * i);              // väntan baserad på nedräkningen – längre och längre pauser
    notone(spkr);                   // stänger av tutet
    SetAllLEDs(0);               // kör SetAllLEDs med argumentet 0 – släcker alla lysdioder
    Serial.println("...");       // ...
    delay(222 * i);              // en lite kortare väntan, också baserad på nedräkningen
  }                              // sedan spelas och skickas detta, en gång
  delay(222);                    // väntan
  tone(spkr, 1044, 66);             // och så två små pip så att spelaren vet att det är dags
  Serial.println();
  delay(111);
  tone(spkr, 1044, 66);
  Serial.println("go!");  // kör!
  Serial.println();
  delay(111);
}

void SetAllLEDs(bool ledState) {         // SetAllLEDs styr alla lysdioder på en gång. En for-loop går igenom alla pins i arrayen LED och tänder eller släcker dem beroende på om funktionen fått 1 eller 0 som argumeent.
  for (int i = 0; i < numOfBtns; i++) {  // går från index 0 (första lysdioden) upp till maximum. Ökar index med 1 varje gång.
    digitalWrite(LED[i], ledState);      // tänder eller släcker lysdioden på pin LED[i]
  }
}

void SetLED(int button, int led) {  //
  if (button != HIGH)
    digitalWrite(led, LOW);
  else
    digitalWrite(led, HIGH);
}

void PlayGame() {                                   // själva spelet:
  unsigned long endTime = millis() + gameDuration;  // sluttiden är starttiden + spellängden (båda i millisekunder)
  unsigned int score = 0;                           // resultatet nollställs
  long randButton = random(numOfBtns);              // ett tal från 0-2 slumpas och tilldelas randButton.
  while (millis() < endTime) {                      // så länge tiden inte har gått ut så ...
    SetLED(HIGH, LED[randButton]);                  // tänds en av lysdioderna, baserat på det slumpade talet
    if (digitalRead(BUTTON[randButton]) == LOW) {   // om rätt knapp trycks in (den med samma tal som lysdioden)
      score++;                                      // så får man en poäng
      SetLED(LOW, LED[randButton]);                 // den slumpade lysdioden släcks
      int newButton = random(numOfBtns);            // ett nytt tal slumpas fram
      while (newButton == randButton) {             // men så länge som det nya talet är samma som det gamla talet
        newButton = random(numOfBtns);              // så kommer programmet slumpa ett nytt tal här
      }                                             // när det nya talet inte är samma kan while() lämnas
      randButton = newButton;                       // när och det nya talet ges till randButton
    }
  }  // när tiden har gått ut lämnas denna while-loop

  SetAllLEDs(0);                            // alla lysdioder släcks
  delay(250);                               // programmet väntar en fjärededels sekund
                                            // och meddelar resultatet:
  if (score > highScore) {                  // om du vann, alltså fick bättre resultat än sist
    Serial.println("You won! Well done.");  // så får du detta glada meddelande
    Serial.print("The new highscore is: ");
    Serial.println(score);
    Serial.print("The previous highscore was: ");
    Serial.println(highScore);
    Serial.println();
    highScore = score;  // sedan sparas det senaste resultatet (score) till highScore
                        // sedan får du höra...
    // en for-baserad slutmelodi som utgår ifrån highScore
    for (int i = 0; i < highScore; i++) {  // ju högre highscore, desto längre pågår loopen
      tone(spkr, (31 + (i * 31)));
      digitalWrite(12, HIGH);
      delay(1000 / highScore);
      notone(spkr);
      digitalWrite(12, LOW);
      delay(1000 / highScore);
    }
  } else {                                 // men om du inte vann så hamnar du här:
    Serial.println("You lost! too bad.");  // tyvärr :-(
    Serial.print("Your score was: ");      // ditt resultat visas
    Serial.println(score);
    Serial.print("The highscore is still: ");
    Serial.println(highScore);  // föregående resultat
    Serial.println("Try again?");
    Serial.println();

    tone(spkr, 440);  // en annan "melodi" spelas upp när man förlorar
    delay(150);
    tone(spkr, 311);
    delay(300);
    notone(spkr);
  }  // därefter lämnas PlayGame() och loop() kan börja om.
}

Genomgång av 8_whac-a-mole.ino

Strukturen för spelet är ganska enkel. Först väntar spelet på ett knapptryck, i en while-loop. När spelaren trycker på knapp 2 påbörjas en nedräkning, sedan börjar spelet. När spelet startar sparas tidpunkten i ett minne. Spelmomentet ligger i en while-loop som kör så länge nuvarande tid är mindre än start-tiden + spelets längd.

Först slumpas ett nummer från 0-2 fram. Numret används för att tända en lysdiod, som väljs ur arrayen LED[]. Sedan väntar spelet på att knappen med samma nummer (som hämtas i arrayen BUTTON[]) trycks in.

När spelaren har tryckt på rätt knapp får hen ett poäng, och ett nytt nummer slumpas. Om det nya numret är samma om det förra (vilket har 33% sannolikhet) slumpas istället ett nytt nummer fram. Kontrollen görs i en while som fortsätter slumpa fram tal tills ett nytt nummer har fåtts fram. När ett sådant finns släpps programmet vidare och en ny lysdiod tänds.

När tiden har gått ut stoppas spelet, lysdioderna släcks och spelet kontrollerar om spelarens poäng är högre än tidigare High Score. Ett högre poäng räknas som vinst, och en liten fanfar spelas. Om poängen är lägre har spelaren förlorat och får höra ett tråkigare ljud.

Efter detta återgår spelet till att vänta på ett knapptryck.

Seriella meddelanden med skickas till datorn under de olika spelmomenten, men spelet fungerar också fristående.

Att skriva egna funktioner

Som du kan se är loop-funktionen i programmet väldigt kort. Förutom väntan i while så anropas bara två andra funktioner, och ingen av dem har något argument.

Dessa två funktionerna – CountDown() och PlayGame() – finns inte med i Arduino från början. Olikt funktioner som delay() och pinMode() är de skapade specifikt för det här programmet.

Det finns flera vinster med att skriva egna funktioner. Till exempel kanske man vill göra samma beräkning på flera ställen i koden. Istället för att klistra in samma kod på alla ställen så kan man skapa en funktion som anropas där det behövs. Något som kanske tar upp flera rader kan bli betydligt mer kompakt. Alternativet vore att alla raderna stod med på flera ställen i koden. Funktioner kan alltså göra kod betydligt lättare att läsa.

De gör det också lättare att uppdatera koden, om man hittar en bugg eller vill att den ska fungera lite annorlunda. Om beräkningen istället var utspridd på flera ställen skulle detta bli krångligare, med större risk för fel.

Separata funktioner gör det också lättare att återanvända kod i andra projekt. Funktionerna blir utbytbara moduler. När man har samlat på sig ett gäng sådana kan man programmera snabbt och effektivt, eftersom man bara ”kopplar ihop” de delar man behöver.

Deklarera en funktion

Funktioner behöver precis som variabler deklareras. Då måste de även få en datatyp. Funktionens datatyp anger vad en funktion returnerar.

Till exempel returnerar funktionen analogRead() en int.(1)

  1. Om du vill se hur analogRead deklareras kan du högerklicka på analogRead i ett program och välja Go To Definition i menyn du får upp.

Vissa funktioner returnerar ingenting. Ett exempel är digitalWrite(). Den styr bara de digitala utgångarna på Arduinon. Funktioner som inte returnerar något deklareras med datatypen void – du kanske har undrat varför det står så före setup() och loop()? Nu vet du!

Funktioner kan ha argument, eller inte. Om en funktion ska ha argument behöver de listas när funktionen deklareras. Också argumenten måste ha varsin datatyp (de olika argumenten behöver alltså inte ha samma datatyp).

Vinsten med att deklarera datatyper för argument och funktioner är att kompilatorn kan kolla så att koden är skriven rätt, så kallad type checking. Om man anger argument med fel datatyp, eller t.ex. försöker få en funktion med void att returnera något så kommer kompilatorn varna och avbryta kompileringen.

Funktioner kan bara deklareras utanför andra funktioner – alltså inte i t.ex. setup() eller loop().

Funktionen sumsalabim()

Som exempel kan vi skapa en funktion, med namnet sumsalabim, som tar två heltal, t.ex. 2 och 5, och returnerar summan av talen och alla tal mellan dem (alltså 2 + 3 + 4 + 5 = 14).

Funktionen deklareras så här:

int sumsalabim(int firstInt, int secondInt) { ...
}

sumsalabim() tar två argument (firstInt och secondInt), som båda är heltal (int). Den ska också returnera heltal, så funktionens datatyp är också int.

Funktionens innehåll ser ut så här:

{
  int sum = firstInt + secondInt;              // först slå de två talen ihop
  int difference = secondInt - firstInt;       // sedan behöver man ta reda på hur långt ifrån varandra talen är. Om de är grannar (t.ex. 2 och 3) kommer loopen nedan inte köras.
  for (int i = 1; i < difference; i++) {       // loopen körs om talen inte är grannar (om skillnaden är större än 1)
    sum += (firstInt + i);                     // lägger till talet som är i mer än firstInt till sum
  }                                            // när i ökar betyder det att nästa tal i serien kommer summeras
  return sum;                                  // när loopen är klar har alla tal från och med firstInt till secondInt summerats
}

Som du kan se används båda argumenten inom funktionen. En till variabel, sum, deklareras inuti funktionen. Efter att for-loopen har summerat alla talen används return, som lämnar funktionen och returnerar det som står till höger, alltså sum.(1) Om du vill testa funktionen kan du öppna extra_writing_functions_sumsalabim.ino

  1. Man kan också skriva return (utan argument) i loop() för att ”kommentera ut” all kod (inom funktionen) under return. Loopen kommer istället börja om från början. Praktiskt vid felsökning!

Funktionen fungerar inte om argumentet enaSiffran är större än andraSiffran. Inte heller kan den hantera när talen blir större än int räcker till. Förutom detta fungerar den bra, och den klarar till och med negativa tal. Att automatiskt förstå vilket tal som är störst lämnas som uppgift till läsaren.

random() och randomSeed()

För att slumpa vilken lysdiod som lyser används random(). Den tar ett eller två argument och returnerar ett slumpmässigt tal från och med minimum till (men inte till och med) maximum. Om bara ett argument ges sätter det maximum. random(10) ger alltså slumpmässiga tal från 0-9.

Slumpgeneratorn i random() bygger på interna processer och klockor i mikrokontrollern. Det resulterar i att den slumpmässiga talserien bli likadan varje gång mikrokontrollern startar – interna klockor -och liknande startar alltid om på samma sätt. Om man vill säkerställa att spelet inte börjar med samma lysdiod varje gång kan man därför ge slumpgeneratorn en första ”knuff” med randomSeed().

Det tal man ger som argument till randomSeed() är utgångspunkten för slumpgeneratorn. För att de slumpade talen ska bli olika efter en omstart behöver randomSeed() få något tal utifrån mikrontrollern, som inte är likadant varje gång den startar om. En avläsning av ljussensorn fungerar till exempel bra. Det talet kommer ligga någonstans mellan 0-1023, men förmodligen inte samma varje gång.(1)

  1. Man kan dock fuska med en stark lampa, så att ljuset alltid är på max. Fundera gärna på hur man kan komma runt det. T.ex. genom att multiplicera ljusstyrkan med temperaturen?

Andra vanliga sätt att få ”bättre” slump är att mäta tiden till att användaren trycker på en knapp. Ingen människa kan trycka på en knapp efter samma antal millisekunder flera gånger i rad.

Några tips på förbättringar av spelet:

  • Höja svårighetsgraden med minuspoäng för feltryck.
  • Ge ännu fler minuspoäng för flera feltryck i rad.
  • Göra så att lysdioderna bara lyser en kort stund, så man måste vara snabbare.
  • Gradvis sänka tiden lysdioderna lyser.
  • Det finns ett fusk: om man håller inne alla tre knappar får man snabbt väldigt mycket poäng. Hur kan man förhindra det? (utan att använda minuspoäng)

9: Minnes-spel

Det här programmet är ett minnes-spel som går ut på att komma ihåg och härma ett mönster. Arduinon tänder de tre lysdioderna i en slumpmässig ordning. Spelaren ska sedan trycka på knapparna i samma ordning.

Mönstret avslöjas ett steg i taget. Varje gång spelaren lyckas härma mönstret visas ett steg till. Nivån ökar så länge spelaren gör rätt. Tiden som lysdioderna är tända minskar samtidigt, så spelet går snabbare och snabbare. När spelaren till slut misslyckas med att härma mönstret ett ledset fel-ljud, och ett nytt mönster slumpas fram. Spelet börjar också om på nivå 1.

Klicka här för att hoppa till genomgången.

9_memory_game.ino
//  minnes-spel
//  bygger på https://projecthub.arduino.cc/Arduino_Scuola/a-simple-simon-says-game-6f7fef
//  anpassad till Electrokit Edu Shield av Electrokit Sweden AB

const char btn1 = 13;
const char btn2 = 7;
const char btn3 = 8;
const char encBtn = 3;
const char led1 = 11;
const char led2 = 10;
const char led3 = 9;
const char spkr = 2;

const int maxLevel = 25;  // klarar du 25 steg?

uint8_t ledNum;
uint8_t led[3] = { led1, led2, led3 };
int currentLevel = 1;
int waitTimeInit = 500;
int waitTime = waitTimeInit;
int sequence[maxLevel];
int your_sequence[maxLevel];

void setup() {
  pinMode(btn1, INPUT_PULLUP);
  pinMode(btn2, INPUT_PULLUP);
  pinMode(btn3, INPUT_PULLUP);
  pinMode(encBtn, INPUT_PULLUP);
  pinMode(led1, OUTPUT);
  pinMode(led2, OUTPUT);
  pinMode(led3, OUTPUT);
  // Serial.begin(115200);
}

void loop() {
  digitalWrite(led1, HIGH);  // lysdioder tänds för att visa att spelet är redo för en ny omgång
  digitalWrite(led2, HIGH);
  digitalWrite(led3, HIGH);

  if (currentLevel != 1 || digitalRead(encBtn) == LOW)  // om man har kommit vidare i spelet eller trycker på start
  {                                                     // så går spelet vidare
    if (currentLevel == 1) {                            // om man är på nivå ett
      randomSeed(millis());                             // slumpas ett tal – tiden man tryckte på knappen styr hur det slumpas
      generate_sequence();                              // skapar ett slumpmässigt mönster
      delay(1000);                                      // paus innan första omgången
    }                                                   //
    show_sequence();                                    // visar mönstret som ska härmas
    get_sequence();                                     // läser av och kontrollerar spelarens mönster
  }
}

void show_sequence() {  // visar mönstret
  pinMode(led1, OUTPUT);
  pinMode(led2, OUTPUT);
  pinMode(led3, OUTPUT);
  for (int i = 0; i < currentLevel; i++) {  // for-loopen stegar igenom mönstret, så långt som man har kommit
    ledNum = led[sequence[i] - 1];       // sequence[] -  1 gör att talen 1-3 blir 0-2, vilket behövs för att läsa ur en array
    digitalWrite(ledNum, HIGH);             // tänder en lysdiod som väljs ur arrayet sequence[]
    delay(waitTime);                        // lysdioden lyser under väntetiden (som minskar när svårigheten ökar)
    digitalWrite(ledNum, LOW);              // samma lysdiod släcks
    delay(200);                             // vänta 200ms
  }                                         // om i är lägre än svårighetsnivån upprepas loopen
}

void get_sequence() {
  for (int i = 0; i < currentLevel; i++) {  // for-loopen körs tills man har lyckats räkna upp till nuvarande nivå
    int press = 0;
    while (press == 0) {                        // press används här. bara ett knapptryck kan göras per while-loop
      if (digitalRead(btn3) == LOW) {           // om knapp 3 är låg
        digitalWrite(led3, HIGH);               // tänder led 3
        your_sequence[i] = 3;                   // skriver 3 på nuvarande index
        press = 1;                              // registrerar att en knapp har tryckts in
        delay(200);                             // paus
        digitalWrite(led3, LOW);                // släcker led
        if (your_sequence[i] != sequence[i]) {  // om nuvarande värde i inmatat mönste inte är samma som i det genererade
          wrong_sequence();                     // så anropas wrong_sequence
          return;                               // sedan avbryts funktionen get_sequence – programmet återgår till början av loop()
        }
      }
      if (digitalRead(btn2) == LOW) {  // fungerar som ovan fast knapp 2 och led 2
        digitalWrite(led2, HIGH);
        your_sequence[i] = 2;
        press = 1;
        delay(200);
        digitalWrite(led2, LOW);
        if (your_sequence[i] != sequence[i]) {
          wrong_sequence();
          return;
        }
      }

      if (digitalRead(btn1) == LOW) {  // fungerar som ovan fast knapp 1 och led 1
        digitalWrite(led1, HIGH);
        your_sequence[i] = 1;
        press = 1;
        delay(200);
        digitalWrite(led1, LOW);
        if (your_sequence[i] != sequence[i]) {
          wrong_sequence();
          return;
        }
      }
    }                // när while() är klar återvänder programmet till for-loopen. Det finns inget mer att göra i den, så den börjar om med i++
  }                  // när i har blivit == currentLevel lämnas for-loopen
  right_sequence();  // då har man klarat nuvarande nivå, och funktionen right_sequence() körs
}

void generate_sequence() {
  for (int i = 0; i < maxLevel; i++) {
    sequence[i] = random(1, 4);  // slumpar tal från 1-3
  }
  digitalWrite(led1, LOW);
  digitalWrite(led2, LOW);
  digitalWrite(led3, LOW);
  waitTime = waitTimeInit;  // väntetiden återställs
}

void wrong_sequence() {
  tone(spkr, 440);
  delay(150);
  tone(spkr, 311);
  delay(300);
  noTone(spkr);
  currentLevel = 1;
}

void right_sequence() {
  tone(spkr, 220);
  delay(50);
  tone(spkr, 440);
  delay(50);
  tone(spkr, 1760);
  digitalWrite(led1, HIGH);
  digitalWrite(led2, HIGH);
  digitalWrite(led3, HIGH);
  delay(300);
  noTone(spkr);
  digitalWrite(led1, LOW);  // lysdioder tänds för att visa att spelet är redo för en ny omgång
  digitalWrite(led2, LOW);
  digitalWrite(led3, LOW);
  delay(600);
  if (currentLevel < maxLevel) {
    currentLevel++;
    waitTime -= waitTimeInit / maxLevel;  // hastigheten ökar med en fjärdedel (om maxLevel är 25)
  }
}

Genomgång av 9_memory_game.ino

Precis som i det förra programmet så är loopen kort, och fristående funktioner används.

Om spelet inte är aktivt så tänds alla tre lysdioder. Spelet startar när rotationsenkodern trycks. Eftersom man då är på nivå 1 genereras ett nytt mönster i funktionen generate_sequence().

generate_sequence() består av en for-loop som skapar ett antal slumpmässiga värden – 1, 2 eller 3 – och fyller upp arrayen sequence[]. Sedan släcks lysdioderna och väntetiden nollställs. När funktionen är klar väntar programmet en sekund, och sedan anropas show_sequence(). Den har också en for-loop, som stegar igenom sequence[] lika många steg som den nuvarande nivån – ett steg på första nivån, tre steg på tredje nivån, tio steg på tionde nivån, osv.

Värdet på nuvarande index i sequence[] – alltså tal från 1 till 3 – används som index i arrayen led[]. I led[] finns tal som motsvarar lysdiodernas utgångar: 11, 12 och 13. Detta tal tilldelas till variabeln ledNum, som sedan används för att tända och släcka rätt lysdiod. När alla steg har visats är funktionen klar.

Nästa steg är get_sequence(), som inväntar spelarens knapptryck. En for-loop räknas upp för varje knapptryck. Variabeln press blir 0, och därefter hamnar programmet i en while-loop så länge press är 0. När någon knapp trycks in så tänds motsvarande lysdiod, och lyddiodens nummer skrivs in i your_sequence() på index i. Därefter en kort paus (så att man hinner se lysdioden), och så kontrolleras om man har tryckt på rätt knapp. Om man inte har det så körs funktionen wrong_sequence(). Annars går programmet vidare och börjar om i for-loopen med i++.

Game over

wrong_sequence() spelar ett tråkigt ljud och ställer nivån till 0. När det är klart går programmet vidare till return, ett kommando som avbryter funktionen den ligger i – i det här fallet funktionen get_sequence().

Om det hade stått något efter return (till exempel ett tal eller en variabel) så hade det värdet returnerats till funktionen som anropade get_sequence(), men nu returneras ingenting. Däremot avbryts såklart while-loopen.

Eftersom get_sequence() ligger sist i programmet så startar programmet om från början av loop(), på nivå ett. Alltså tänds alla lysdioder, och spelet inväntar ett tryck på startknappen.

Nivån avklarad!

Om man in misslyckas, utan klarar av att komma ihåg mönstret för nivån, så lämnas for-loopen, och right_sequence() anropas. I den spelas ett gladare ljud, lysdioderna blinkar, och nivån ökar ett steg. Samtidigt minskas variabeln waitTime så att blinken kommer snabbare och snabbare.

Därefter börjar loopen om från början, på nästa svårighetsnivå. Inget nytt mönster genereras, men ett till steg avslöjas.

Om led[] och Arduinons i/o-numrering

Arrayen led[] används för att kunna tända lysdioderna baserat på talen i en array – sequence[] – i en for-loop. Tal från 1 till tre används som index i led[], och på plats 0, 1 och 2 mappas till lysdioderna i arrayen.

uint8_t led[3] = { led1, led2, led3 };

På Edu Shield råkar det vara så att lysdioderna är i sifferordning (11, 12, 13), så man skulle kunna slumpa tal mellan från ett till tre och addera 10. Men i en tidigare version av Edu Shield var det andra utgångar som drev lysdioderna, vilket gjorde att arrayen behövdes. Av pedagogiska skäl fick den vara kvar.

Lösningen med en array[] gör det lättare att modifiera programmet, till exempel om man vill göra en arkadmaskin med fyra eller ännu fler lysdioder (och knappar – men då behövs en array för dessa, och mer ändringar – se förslagen nedan).

Ju fler knappar (eller vad det nu är – lampor, servon, ljudkontakter) en maskin har, desto mer osannolikt blir det att det som användaren kallar 1, 2, 3, också heter så för mikrokontrollern. Alla in- och utgångar på en Arduino är nämligen inte likvärdiga, utan har olika funktioner och möjligheter. På en Uno R3 är t.ex. 0 och 1 kopplade till UART, och ska helst inte användas för andra saker då det kan ge problem när man laddar in program på kortet. Vissa utgångar klarar PWM, och om man har tänkt använda dem till det i arkadmaskinen så uppstår det ”gap” och man kan inte ha lysdioder eller knappar i enkel nummerordning.

Att läsa av knappar och blinka lysdioder – digital i/o – är en grundläggande funktion som i princip alla utgångar på en mikrokontroller klarar. Därför brukar sådant få hamna på de kontakter som blivit över när alla ”svårare” saker har fått en kontakt. Då kan det bli så att knapp 1 har pin A2, knapp 2 har pin 7 och knapp 3 har pin 8 – som på Edu Shield. Att tilldela dem till variabler – btn1, btn2, btn3 – underlättar såklart. Men om man vill styra utgångar (eller läsa ingångar) programmatiskt, t.ex. i en for-loop, så blir det svårare. A2 är ju inte ett tal, utan en konstant (skapad med #define i Arduinos källkod) som motsvarar talet 16. Så även om en for-loop kan ha ”i = A2” i sin initialisering så går det ju inte att öka till 7 med en enkel ++. Då kan arrayer som mappar in- eller utgångar till tal i en logisk ordning underlätta.

Idéer för att utveckla programmet

  • Göra pauserna mellan blinken slumpmässigt långa, så att spelaren måste vara på hugget.
  • Ändra så att lysdioderna lyser svagare och svagare ju högre nivån blir.
  • Få programmet att bara visa det sista (eller kanske de tre sista) steget i mönstret, så att man inte får se de första stegen flera gånger.
  • Låt spelaren göra fel ett par gånger innan spelet avbryts. Till exempel tre gånger under ett helt parti, eller en gång per svårighetsnivå?
  • Skapa en funktion för att tända/släcka alla lysdioder – du kan utgå från show_sequence().
  • Ändra så att mer av mönstret avslöjas när spelaren når högre nivåer – först kanske två steg, sedan tre, osv.
  • Kan du göra så att svårighetsnivån fortfarande ökar med ett, fast flera steg visas?
  • Hur långt behöver mönstret vara? Kan du skriva kod så att arrayen automatiskt har rätt längd?
  • Lägga till grönt/rött blink till fanfarerna.
  • Just nu händer ingenting när man har klarat spelets högsta nivå. Kan du komma på något? Kanske en särskild melodi som utgår från mönstret?
  • Ta tid på hur snabbt spelaren trycker och ge en bonus för snabba tryck.
  • Sätt en gräns för hur långsam spelaren får vara. Det kan stoppa fusk med papper och penna.
  • Skriv en funktion för att läsa av knapparna. Den bör ta ett argument för vilken knapp som ska läsas av. Sedan kan funktionen placeras i en for-loop som stegar igenom alla knappar. En array för knapp-ingångarna (som t.ex. kan heta btn[]) kommer också behövas.
  • Fundera på om funktionen bör returnera ett tal motsvarande knapparna, eller om du hellre vill att ”svaret” (den knapp som har tryckts in) skrivs in i your_sequence[] inne i knapp-funktionen.