Automatische Firmware-Updates für Microcontroller mit Gitlab und PlatformIO

Automatische Firmware-Updates für Microcontroller mit Gitlab und PlatformIO

Bereits im Artikel ESP8266 stürzt in nur einem WLAN ab und hängt in einer Bootschleife hatte ich schon einmal angesprochen, dass ich ein selbst aktualisierendes Netzwerk von Controllern bauen möchte, welches mein Haus überwacht. Heute möchte ich Dir einmal das System des automatischen Ausrollen der Firmware-Updates etwas genauer erklären.

Voraussetzungen für die Umsetzung

Auf Grund der Komplexität des Systems, ist dies nicht für Anfänger geeignet. Hier sollte man schon einiges an Wissen über die Nutzung der einzelnen Programmiersprachen und Systems besitzen. Im Speziellen verwende ich folgende Sprachen/Tools:

  • PHP (für die REST-API)
  • MySQL (zur Speicherung der Firmware[-Daten])
  • GitLab als Quellcodeverwaltung
  • CI/CD von GitLab (Build und Deploy der Firmware bei Commit)
  • Docker (für die Ausführung der CI/CD)
  • Erste Erfahrungen mit PlatformIO
  • Python (Anpassung der Custom-Build-Prozeduren von PlatformIO)
  • Grundlegende Ubuntu-/Linux-Kenntnisse, im Speziellen Bash-Befehle (da ich hier mit einem Ubuntu-Image innerhalb der GitLab CI/CD arbeite)
  • Du solltest Zeit mitbringen, da sich die Integration leider etwas komplex darstellt. Die investiere Zeit wird sich aber durchaus am Ende lohnen, wenn man die Controller nicht per Hand updaten muss.

Anmerkung:
Das Verfahren ist wirklich komplex und sollte ich etwas „unzureichend“ beschrieben haben oder etwas ist unklar, schreibe mir einfach einen Kommentar, dann helfe ich gerne weiter oder werde den Artikel entsprechend korrigieren oder verständlicher ausarbeiten.

Grundlegende Überlegungen

Im Vorfeld sollte man sich natürlich darüber bewusst sein, dass man, bei einer einheitlichen Überwachung der Temperatur und Luftfeuchtigkeit im Haus, einige Controller benötigt. In meinem Falle benötige ich für folgende Räume eine Überwachung:

  • Erdgeschoss
    • Wohnzimmer
    • Küche
    • Badezimmer
    • Flur des Hauseingangs
    • Flur zum Garten
    • Hauswirtschaftsraum
  • Keller
  • Garten
    • Schuppen
    • Wetterstation
  • 1. Obergeschoss
    • Schlafzimmer
    • Kinderzimmer
    • Flur
  • Dachgeschoss
    • Galerie
    • Hobbyraum

Mit dieser Anordnung der Controller überwache ich prinzipiell mein ganzes Haus. Alles in allem sind das 14 Controller ±. Jetzt wird aber irgendwann einmal der Punkt kommen, wo ich etwas an der Firmware verbessere oder mir ein Bug auffällt, den ich fixen muss. Ohne ein entsprechendes System, müsste ich nun alle Controller manuell an einen seriellen Anschluss (via USB) hängen und die Firmware am Rechner/Laptop flashen. Ist mir ehrlich gesagt zu blöd und zu zeitaufwendig. Aus dem Grund habe ich mir ein kleines aber nettes System ausgedacht, womit man hier komplett drum herum kommt.

OTA-Update ist das Zauberwort

OTA (over the air) ist in dem Falle wirklich das Zauberwort, mit dem man dies realisieren kann. Es gibt bei OTA-Updates eigentlich zwei Richtungen für die Controller, welche ich beide nutze:

  1. Aktiv: Der Controller sucht sich via HTTP seine Firmware und installiert diese selbstständig.
  2. Passiv: Der Controller wartet bis er ein Update zugesendet bekommt, z.B. via PlatformIO oder einem Webbrowser. Wie du PlatformIO mit Visual-Studio-Code nutzen kannst, habe ich hier beschrieben.

Arduino ist sogar so freundlich und hat schon ein entsprechendes System erstellt, was man aber etwas aufbohren muss/kann. Das Projekt von Arduino findest du auf in der OTA-Dokumentation von Arduino.

Das System als solches – komplex aber doch wieder sexy

Wie das System grob aufgebaut ist, habe ich bereits im Artikel ESP8266 stürzt in nur einem WLAN ab und hängt in einer Bootschleife kurz angeschnitten, möchte ich hier aber gerne noch etwas näher ausführen, anhand der nachfolgende Visualisierung:

Vollständiges System vom Commit der Firmware (blau), über die Erstellung der Firmware im Gitlab (gelb) bis hin zum Ablegen der Firmware-Binary und Speichern der Firmware-Info (rot) und dem Abschließenden Holen der Firmwares durch die Clients (grün)

Der Ablauf ist eigentlich recht schnell zusammengefasst:

  1. Ich programmiere lokal eine Firmware und teste diese in meinem lokalen WLAN mit einem Controller
  2. Ist die Firmware funktionfähig, committe  ich diese nach Gitlab (Quellcodeverwaltung). Warum eine Versions- / Quellcodeverwaltung sinnvoll ist, habe ich bereits im Artikel ESP-Mikrocontroller mit Visual Studio Code programmieren beschrieben
  3. Dieser Commit löst im Gitlab eine Pipeline mit Jobs aus: Build und Deploy
  4. Im Job Build werden die Dateien vorab vorbereitet (einfügen der aktuellen Versionsnummer und Parametern) und schliesslich die Firmware erzeugt
  5. Die erzeugte Firmware wird an den Job „Deploy“ übergeben und übertragt die Firmware an meinen FTP-Server
  6. Die REST-API wird durch die Clients angesprochen (jeweils beim Boot und in einem festen Intervall) und verifiziert, ob die Firmware für ihn notwendig ist oder ob diese Inkompatibel ist (weil eventuell das Board ein anderes ist)
  7. Wird durch die Clients ermittelt, dass eine neue Firmware zur Verfügung steht, laden diese die Firmware runter und installieren diese. Schlägt dies fehl, wird ein Rollback durchgeführt.

Definition der GitLab CI/CD

Achtung:
Ab hier wird es nun etwas komplexer. Solltest du noch keine Erfahrung mit GitLab haben, empfehle ich dir dringend, dass du dir GitLab einmal ansiehst und die Vorteile einer Quellcodeverwaltung verinnerlichst.

GitLab selbst bietet an, dass bei jedem Commit die interne CI/CD ausgeführt wird, mit den entsprechenden Daten des Commits bzw. des Repositories nach dem Commit. Mit Hilfe der CI/CD kannst du z.B. deinen Quellcode vorher prüfen lassen oder andere Aktionen durchführen, wie in unserem Falle das Bauen einer Firmware und der entsprechende Deploy.

Damit die CI/CD funktioniert, musst du in deinem Projekt eine neue Datei namens .gitlab-ci.yml anlegen. Die Syntax der Datei muss dem YAML-Standard entsprechen. Es gibt aber auch im GitLab einen entsprechenden Validator, der dir sagt, ob deine Syntax ok ist. Alternativ gibt es dazu auch eine Extension für Visual Studio Code.

Innerhalb dieser Datei definierst du die Umgebung und die Jobs, die die CI/CD, respektive die Pipeline, durchzuführen haben. Wenn du mehr technische Infos über die Zusammenhänge zwischen den Pipelines und den Jobs suchst, empfehle ich dir wärmstens die Doku von GitLab hierzu. Mit Hilfe der detaillierten Doku habe ich auch die CI/CD kennengelernt.

In meinem konkreten Fall sieht die Konfiguration der Pipeline (innerhalb der Datei .gitlab-ci.yml) wie folgt aus:

image: python:2.7

stages:
 - build
 - deploy

build:
  stage: build
  script: 
    - pip install -U platformio
    - pip install gitpython
    - pio run -d $CI_PROJECT_DIR
  variables: 
    PLATFORMIO_CI_SRC: "$CI_PROJECT_DIR/src/" 
    PLATFORMIO_LIB_DIR: "$CI_PROJECT_DIR/lib/"
  artifacts:
    paths:
    - build/

deploy:
    stage: deploy
    script:
    - apt-get update -qq && apt-get install -y -qq ncftp
    - ncftpput -R -v -u $FTP_USERNAME -p $FTP_PW  $FTP_HOST ota/files build/*

Auslösekriterien der CI

Ohne weitere Anpassungen startet die CI mit jedem Commit. Das kann man aber recht einfach ändern, in dem man die Definition der Yaml-Datei erweitert mit dem Stichwort only oder except. Für meine Fälle reicht das Auslösen der CI bei einem Commit vollkommen aus.
Sollte dich interessieren, wie man andere Trigger definiert, ist auf jeden Fall die Doku von Gitlab hierzu sehr detailliert.

Pipelines, Jobs und Stages

Wie man sieht, gibt es in meiner Pipeline zwei Jobs, build und deploy. Du kannst deine Jobs also in mehrere Stages ausführen, in Abhängigkeit ob eine Stage erfolgreich verlief oder nicht:

stages:
 - build
 - deploy

So kannst du z.B. ein einem Job mehrere Stages ausführen, die durchaus auch parallel laufen könnten. Ein Beispiel der GitLab-Doku zeigt das sehr gut:

Möglichkeiten der Jobs und Stages innerhalb einer Pipeline

Der Build-Job

Der Build-Job führt vor der Ausführung einige Installationen durch (z.B. PlatformIO und GitPython), da ich diese später benötige. Die Installation von Komponenten kann man ganz einfach via pip install durchführen. Alternativ geht natürlich auch ein apt-get update -qq && apt-get install -y -qq .

Wichtig ist hierbei, dass der Befehl auch so ausgeführt wird, ein einfaches apt-get install reicht leider nicht aus. Danach wird die Firmware mit PlatformIO erstellt:

script: 
    - pip install -U platformio
    - pip install gitpython
    - pio run -d $CI_PROJECT_DIR

Damit die Ausführung von PlatformIO klappt, müssen einige Variablen gesetzt sein, ebenso auch die Artifacts:

variables: 
    PLATFORMIO_CI_SRC: "$CI_PROJECT_DIR/src/" 
    PLATFORMIO_LIB_DIR: "$CI_PROJECT_DIR/lib/"
  artifacts:
    paths:
    - build/

Artifacts sind Dateien oder Ordner, die von einem Job zum anderen Job übergeben werden. In diesem konkreten Fall wird durch PlatformIO eine Firmware (.bin) erzeugt, die dann in build/ geschrieben wird. Die Deklaration als Artifact ist wichtig, da sonst keine Dateien an weiterführende Jobs weitergeben werden. Diese Artifacts brauche ich nämlich im nächsten Job deploy.

Der Deploy-Job

Der Deploy-Job nimmt die Artifacts entgegen und schiebt diese via FTP auf meinen Server. Wichtig ist hier, dass auch in dem Job einige Installation durchgeführt werden müssen:

deploy:
    stage: deploy
    script:
    - apt-get update -qq && apt-get install -y -qq ncftp
    - ncftpput -R -v -u $FTP_USERNAME -p $FTP_PW  $FTP_HOST ota/files build/*

Hier nutze ich das FTP-Tool ncftp welches ich via apt-get update -qq && apt-get install -y -qq installiere. Im Job sieht man, dass ich Variablen verwende (z.B. $FTP_USERNAME), die nirgends gesetzt werden. Das liegt daran, dass es sich dabei um so genannte secret variables handelt, die GitLab anbietet. Dies bietet den Vorteil, dass man sensible Daten, wie es ein FTP- oder SSH-Zugang wäre, in GitLab definiert, diese aber nur von Admins eingesehen werden können. Ein normaler Entwickler sieht hier nur die Variablen, aber nicht den Inhalt. Die Werte werden erst zur Laufzeit der Pipeline zur Verfügung gestellt.

Wichtig:
Ist der FTP-Server über das Internet erreichbar, was bei der Nutzung mit Gitlab quasi der Fall sein muss, solltest du unbedingt auf entsprechende Sicherheitsmaßnahmen achten. Daher rate ich dringend von einer Nutzung eines normalen FTP-Servers ab und empfehle eher die Nutzung von FTPS/SFTP!

Kompilieren der Firmware mit PlatformIO

Im Job build führe ich PlatformIO via pio run -d $CI_PROJECT_DIR aus. PlatformIO bietet nämlich auch eine CLI (command line interface) an.

Damit die CLI die Firmware sauber erstellen kann, muss PlatformIO korrekt konfiguriert sein. Die Konfiguration nimmt man, innerhalb eines PlatformIO-Projektes, in der Datei platformio.ini vor.

In dieser kannst du mehre Platformen und Umgebungen anlegen. Ich selber habe aktuell einige ESP32 und einige ESP8266 im Einsatz, weswegen ich zwei Umgebungen eingerichtet habe:

[env:lolin32]
platform = https://github.com/platformio/platform-espressif32.git#feature/stage
board = lolin32
framework = arduino
lib_dir = /lib/
extra_scripts = prepareBuild.py

lib_deps =
    https://github.com/zhouhan0126/WIFIMANAGER-ESP32.git
    https://github.com/zhouhan0126/WebServer-esp32.git
    https://github.com/zhouhan0126/DNSServer---esp32.git
    https://github.com/taranais/NTPClient.git
    https://github.com/squix78/esp8266-oled-ssd1306.git
    https://github.com/marcos69/EspProwl.git
    Ticker
    ArduinoJson
    RemoteDebug

[env:d1_mini]
platform = https://github.com/platformio/platform-espressif8266.git#feature/stage
framework = arduino
board = d1_mini
lib_dir = /lib/
extra_scripts = prepareBuild.py
lib_deps =
    https://github.com/zhouhan0126/WIFIMANAGER-ESP32.git
    https://github.com/zhouhan0126/WebServer-esp32.git
    https://github.com/zhouhan0126/DNSServer---esp32.git
    https://github.com/taranais/NTPClient.git
    https://github.com/squix78/esp8266-oled-ssd1306.git
    https://github.com/marcos69/EspProwl.git
    Ticker
    ArduinoJson
    RemoteDebug

Bitte beachte, dass der Libary Dependency Finder von PlatformIO scheinbar nicht sauber via CLI funktioniert. Daher reicht es nicht aus, die Dependencies einfach nur im Code bekannt zu machen. Das ist auch der Grund, warum ich hier einzelne Abhängigkeiten mit Hilfe der Option lib_deps = definieren muss.

Ausführung von Extra-Scripten vor und nach dem Build der Firmware

Ganz interessant ist nämlich die Option extra_scripts von PlatformIO. Mit Hilfe dieser extra Scripts kann man gewisse Aktionen vor und nach einem Build durchführen lassen. Für das OTA-System benutze ich die extra Scripts zur Ermittlung der aktuellen Firmware- bzw. Buildnummer und dem Eintragen der Firmware in meiner MySQL-Datenbank. Diese extra Scripts werden in Python geschrieben.

Auch hier lege ich die die Dokumentation von PlatformIO zu den extra Scripts wärmstens ans Herz:

docs.platformio.org docs.platformio.org

Meine Python-File sieht dann so aus:

Import("env")
import os
import git
import time
import subprocess
import shutil
import requests
import json

BUILDNR = int(round(time.time()))
VERSION_FILE = "version.h"
CONFIG_FILE = "config.h"
BUILD_DIR = "build/"
GITVERSION = ""
BOARD = env['BOARD']
API_URL = ""
FILENAME = ""

cwd = os.path.abspath(os.getcwd())
r = git.repo.Repo(cwd)
GITVERSION = r.git.describe();


def in_docker():
    """ Returns: True if running in a docker container, else False """
    try:
        with open('/proc/1/cgroup', 'rt') as ifh:
            return 'docker' in ifh.read()
    except:
        return False

print "----------------------- BUILD STARTED -----------------------"
print "Info:"
print "-------------------------------------------------------------"
print "Current build targets: \t", map(str, BUILD_TARGETS)
print "Board: \t"+env['BOARD']
print "Version: \t"+GITVERSION
print "In Docker: \t"+str(in_docker())


if in_docker():
    print "CI-Process, generate build version"
    print 'Docker container: No deletion of build files'
else:
    print "No CI-Process, generate dev version"
    GITVERSION = GITVERSION + "_dev"
print "-------------------------------------------------------------"

if not os.path.exists(BUILD_DIR):
    os.mkdir(BUILD_DIR)
else:
    if not in_docker():
        shutil.rmtree(BUILD_DIR)
        os.mkdir(BUILD_DIR)

def getBoardID(name):
    print "[getBoardID] Getting Board-ID"
    payload = {'columns': 'ID', 'filter': 'InternalName,eq,'+name}
    r = requests.get(API_URL + "/Boards/?transform=1" , params=payload)
    r.raise_for_status()

    ret = r.json()
    BoardID = ret['Boards'][0]['ID']
    print "[getBoardID] Board-ID: "+str(BoardID)
    return BoardID

def archiveVersion(source, target, env):
    if in_docker():
        print "[archiveVersion] Save Version"
        BoardID = getBoardID(BOARD)

        if BoardID != 0:
        
            payload = {'Version': GITVERSION, 'Build': BUILDNR, 'Path':FILENAME, 'Board_ID':BoardID, 'DevVersion':'false'}

            r = requests.post(API_URL + "/Boards_Firmware",  data=payload)
            # print "Return: "+r.text
            r.raise_for_status()
            print "[archiveVersion] Version-ID: "+str(r.text)
        else:
            print "[archiveVersion] Keine Board-ID ermittelt"
    else:
        print "No CI process, don't archive version to database"


def writeversion(source, target, env):
    print "----------------------- writeversion Start -----------------------"
       
    binPath = os.path.join('.pioenvs',BOARD,'firmware.bin')
    destPath = os.path.join('build', BOARD+"_"+str(BUILDNR)+'.bin')

    global FILENAME 
    FILENAME = BOARD+"_"+str(BUILDNR)+'.bin'
    print "Copying File: "+binPath+' to '+destPath
    # os.rename(binPath, destPath)
    shutil.copyfile(binPath, destPath)
    
    print "----------------------- writeversion End -----------------------"

def before_build(source, target, env):
    # print "Build in docker: "+str(in_docker())
    writeversion(source, target, env)

def after_build(source, target, env):
    print "----------------------- archiveVersion Start -----------------------"
    archiveVersion(source, target, env)
    print "----------------------- archiveVersion End -----------------------"
    # do some actions

def prepareVersionAndConfig(source, target, env):
    print "----------------------- prepareVersionAndConfig Start -----------------------"

    FILE = os.path.join(os.path.join(cwd, "src"), VERSION_FILE)
    os.remove(FILE)

    if os.path.exists(FILE):
        f = file(FILE, "r+")
    else:
        f = file(FILE, "w")


    f.write('const int FW_VERSION = '+str(BUILDNR)+';\n#ifndef VERSION_H\n#define VERSION_H\n#define _VER_ ( "'+GITVERSION+'" )\n#endif //VERSION_H')
    f.close()

    FILE = os.path.join(os.path.join(cwd, "src"), CONFIG_FILE)
    os.remove(FILE)

    if os.path.exists(FILE):
        f = file(FILE, "r+")
    else:
        f = file(FILE, "w")
    
    f.write('#ifndef CONFIG_H\n#define CONFIG_H\n#define _BOARDNAME_ ( "'+BOARD+'" )\n#define _BOARDID_ ("'+str(getBoardID(BOARD))+'")\n#endif //VERSION_H')
    f.close()
    print "----------------------- prepareVersionAndConfig End -----------------------"


env.AddPreAction("buildprog", before_build)
env.AddPostAction("buildprog", after_build)
env.AddPreAction("$BUILD_DIR/src/main.o", prepareVersionAndConfig)

Hier werden einige Aktionen durchgeführt, bevor ein Build passiert oder nach dem der Build fertig ist. Mir ist jedoch aufgefallen, dass die Aktion AddPreAction("buildprog") nicht vor dem Build der „.bin“-File passiert, sondern erst später. Dadurch hatte ich das Problem, das meine Buildnummern nicht zur Firmware passten. Daher muss man Datei-Operationen, die mit den in den Build einfliessen sollten, mit env.AddPreAction("$BUILD_DIR/src/main.o") aufrufen und durchführen.

Wie sich das verhält, sieht man sehr gut innerhalb der Build-Logs von PlatformIO:

----------------------- BUILD STARTED -----------------------
Info:
-------------------------------------------------------------
Current build targets:  ['buildprog', 'size']
Board:  lolin32
Version:        v0.2-68-g6bba28c
In Docker:      False
No CI-Process, generate dev version
-------------------------------------------------------------
prepareVersionAndConfig([".pioenvs\lolin32\src\main.o"], ["src\main.cpp"])
----------------------- prepareVersionAndConfig Start -----------------------
[getBoardID] Getting Board-ID
Generating partitions .pioenvs\lolin32\partitions.bin
Archiving .pioenvs\lolin32\libFrameworkArduinoVariant.a
Compiling .pioenvs\lolin32\FrameworkArduino\Esp.o
[getBoardID] Board-ID: 1
----------------------- prepareVersionAndConfig End -----------------------
Compiling .pioenvs\lolin32\src\main.o
Calculating size .pioenvs\lolin32\firmware.elf
text       data     bss     dec     hex filename
646901   129684   31584  808169   c54e9 .pioenvs\lolin32\firmware.elf
esptool.py v2.1
before_build(["buildprog"], [".pioenvs\lolin32\firmware.bin"])
----------------------- writeversion Start -----------------------
Copying File: .pioenvs\lolin32\firmware.bin to build\lolin32_1516094067.bin
----------------------- writeversion End -----------------------
after_build(["buildprog"], [".pioenvs\lolin32\firmware.bin"])
----------------------- archiveVersion Start -----------------------
No CI process, don't archive version to database
----------------------- archiveVersion End -----------------------
=============================== [SUCCESS] Took 15.72 seconds ===============================

Zudem prüfe ich auch noch, ob der aktuelle Prozess innerhalb eines Docker-Containers läuft, also innerhalb der CI/CD von Gitlab. Das ist wichtig, da sonst auch lokale Builds der Firmware z.B. in meine MySQL-Datenbank übertragen werden würden, was recht unschön wäre.

Ich führe aktuell drei Aktionen durch:

  • Vor dem Build der Binary:
    Erstellung einer Version.h-Header mit der aktuellen Buildnummer und Firmware anhand des Befehls git.describe(). Siehe hierzu Funktion prepareVersionAndConfig(). Zudem Erzeuge ich auch eine Datei Config.h, die generelle Informationen über das aktuelle Board enthalten, da diese Informationen später zur Ermittlung der korrekten Firmware zwingend benötigt werden.
    Diese werden in der Firmware entsprechend eingebunden:

    #include "version.h"
    #include "config.h"

    Inhalt der Version.h:

    const int FW_VERSION = 1516094083;
    #ifndef VERSION_H
    #define VERSION_H
    #define _VER_ ( "v0.2-68-g6bba28c_dev" )
    #endif //VERSION_H

    Inhalt der Config.h:

    #ifndef CONFIG_H
    #define CONFIG_H
    #define _BOARDNAME_ ( "d1_mini" )
    #define _BOARDID_ ("2")
    #endif //VERSION_H

    Diese beiden Dateien müssen schon vorher im Repository bzw. Projektverzeichnis enthalten sein, da sonst der Compiler das Fehlen als Error ansieht und entsprechend abbricht.

  • Nach dem Build der Binary:
    Verschieben der Firmware-Files in ein separates Verzeichnis build.
  • Nach dem kompletten Build-Vorgang:
    Abspeichern der Firmware-Informationen (Buildnummer, Board-ID / -Name etc.) in meiner MySQL-Datenbank über eine REST-API. Als Backend nutze ich hierfür PHP und eine Library namens „PHP-CRUD-API„, die automatisch aus den Tabellen der Datenbank eine REST-API zaubert.

Das API-Backend und die MySQL-Datenbank

Damit nun meine Controller die Firmware checken können und auch der Buildprozess die neuen Firmwares eintragen können, benötigen wir eine MySQL-Datenbank und eine entsprechende Schicht, die uns eine REST-API bereitstellt. Dafür habe ich auch direkt zwei Subdomains auf meinem Server angelegt:

  • ota.name.tld: Hinterlegung des PHP-Skriptes zur Ermittlung der Firmware-Versionen und ggf. Download, wenn eine neue zur Verfügung steht.
  • api.name.tld: URL zum Aufruf der API

Für die API selbst verwende ich die Bilbliothek PHP-CRUD-API, womit ich in andere Projekten schon gute Erfahrung machen konnte. Da es sich dabei um eine automatisierte REST-API handelt, die sich aus der Definition der Datenbank ableitet, müssen hier die Relationen zwischen den Tabellen (Primary- und Foreign-Keys) zwingend angegeben werden, da sonst die API nicht weiß, wie die Daten geholt oder geschrieben werden sollen.

Die MySQL-Datenbank

Zugegebenermaßen ist die Struktur der MySQL-Datenbank wirklich unspektakulär, da es nur zwei Tabellen gibt Boards und Boards_firmware:

CREATE TABLE `Boards` (
  `ID` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
  `Name` VARCHAR(50) NOT NULL DEFAULT '0',
  `InternalName` VARCHAR(50) NOT NULL,
  `OTAPath` VARCHAR(255) NOT NULL,
  `OLED` BIT(1) NOT NULL,
  `Chip` VARCHAR(10) NOT NULL,
  PRIMARY KEY (`ID`)
)
COLLATE='latin1_swedish_ci'
ENGINE=InnoDB
AUTO_INCREMENT=3
;
CREATE TABLE `Boards_Firmware` (
  `ID` INT(11) NOT NULL AUTO_INCREMENT,
  `Version` VARCHAR(50) NOT NULL,
  `Build` INT(11) NOT NULL,
  `DevVersion` INT(11) NOT NULL,
  `Path` VARCHAR(50) NOT NULL,
  `Board_ID` INT(1) UNSIGNED NOT NULL,
  `ReleaseDatum` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`ID`),
  INDEX `FK_Firmware_Boards` (`Board_ID`),
  CONSTRAINT `FK_Firmware_Boards` FOREIGN KEY (`Board_ID`) REFERENCES `Boards` (`ID`) ON UPDATE NO ACTION ON DELETE NO ACTION
)
COLLATE='latin1_swedish_ci'
ENGINE=InnoDB
AUTO_INCREMENT=81
;

Mehr wird auch eigentlich nicht benötigt. Diese SQL-Skripte könnt ihr einfach in einer beliebigen MySQL-Datenbank einspielen.

Wichtig ist, dass Ihr die Tabelle „Boards“ mit entsprechenden Daten füllt, da diese auch innerhalb der CI/CD ermittelt und in die Firmware geschrieben werden. Beispielhafte Datensätze könnten dann, wie in meinem Falle, wie folgt aussehen:

Beispieldaten der Tabelle „Boards“

Wie Ihr seht, sind die internen Namen (Feld „InternalName“) identisch mit den Board-Namen in den Enviroments innerhalb der Konfiguration von PlatformIO. Dies wird über die extra Scripts erledigt, da der Boardname als Umgebungsvariable an den Build-Prozess übergeben wird:

Import("env")
BOARD = env['BOARD']

Im Buildvorgang wird dann die passende Board-ID ermittelt, damit diese dann später in die Firmware-Tabelle „Boards_Firmware“ gespeichert werden kann:

def getBoardID(name):
    print "[getBoardID] Getting Board-ID"
    payload = {'columns': 'ID', 'filter': 'InternalName,eq,'+name}
    r = requests.get(API_URL + "/Boards/?transform=1" , params=payload)
    r.raise_for_status()

    ret = r.json()
    BoardID = ret['Boards'][0]['ID']
    print "[getBoardID] Board-ID: "+str(BoardID)
    return BoardID

All das passiert innerhalb des angesprochenen extra Scripts prepareBuild.py welches in der Konfiguration von PlatformIO für die einzelnen Enviroments definiert worden ist:

def archiveVersion(source, target, env):
    if in_docker():
        print "[archiveVersion] Save Version"
        BoardID = getBoardID(BOARD)

        if BoardID != 0:
        
            payload = {'Version': GITVERSION, 'Build': BUILDNR, 'Path':FILENAME, 'Board_ID':BoardID, 'DevVersion':'false'}

            r = requests.post(API_URL + "/Boards_Firmware",  data=payload)
            # print "Return: "+r.text
            r.raise_for_status()
            print "[archiveVersion] Version-ID: "+str(r.text)
        else:
            print "[archiveVersion] Keine Board-ID ermittelt"
    else:
        print "No CI process, don't archive version to database"

Die extra Scripts werden pro Umgebung aufgerufen, hier könnten auch unterschiedliche Scripte hinterlegt werden, für unterschiedliche Build-Prozesse. Ist der Build fertig, würde die Tabelle Boards_Firmware wie folgt aussehen:

Beispieldaten der Tabelle „Boards_Firmware“

Wie man sieht, gibt es nun pro Board einen eigenen Eintrag innerhalb der Firmware-Tabelle, die mit der Board_ID auf die Board-Tabelle referenziert. Zudem werden hier die Versionen (Git-Describe) und die Buildnummern gespeichert (Linux-Zeit). Auch enthält die Tabelle den Namen der Firmware, damit dies bei der Ermittlung der Firmware genutzt werden kann.

Das OTA-Backend

Wenn die vorherigen Steps alle durchgearbeitet sind, fehlt eigentlich nur noch das Backend zur Ermittlung der Firmwares, also die Schnittstelle vom Controller zur Datenbank/Fileserver. Diese Schnittstelle liegt bei mir, wie bereits beschrieben, in einer separaten Subdomain ota.name.tld. Hier ist ein schlichtes PHP-Skript, welches die Zugriffe auf die „OTA-API“ handelt, zuständig:

setTrace (true);

$myfile = fopen("OTA-Log.txt", "a+") or die("Unable to open file!");
fwrite($myfile, "---------------------------".$_SERVER['REQUEST_METHOD']."--------------------------------\n");
fwrite($myfile, "Debug: ".$debug."\n");
foreach($_SERVER as $key => $value) {
        if(strpos($key, 'HTTP_') === 0) {
            $headers = str_replace(' ', '-', ucwords(str_replace('_', ' ', strtolower(substr($key, 5)))));
            $line = $headers." : ". $i[$headers] = $value . "\n";
      fwrite($myfile, $line);
        }
    }
foreach($_POST as $key => $value){
  fwrite($myfile,"POST: ".$key." -> ".$value."\n");
}
foreach($_GET as $key => $value){
  fwrite($myfile,"GET: ".$key." -> ".$value."\n");
}


header('Content-type: text/plain; charset=utf8', true);

function check_header($name, $value = false) {
    if(!isset($_SERVER[$name])) {
        return false;
    }
    if($value && $_SERVER[$name] != $value) {
        return false;
    }
    return true;
}

function sendFile($path) {
    header($_SERVER["SERVER_PROTOCOL"].' 200 OK', true, 200);
    header('Content-Type: application/octet-stream', true);
    header('Content-Disposition: attachment; filename='.basename($path));
    header('Content-Length: '.filesize($path), true);
    header('x-MD5: '.md5_file($path), true);
    readfile($path);
}


if(!check_header('HTTP_USER_AGENT', 'ESP-http-Update')) {
  fwrite($myfile,$_SERVER["SERVER_PROTOCOL"].' 403 Forbidden\n');
    header($_SERVER["SERVER_PROTOCOL"].' 403 Forbidden', true, 403);
    echo "only for ESP updater!\n";
    exit();
}


if(
    !check_header('HTTP_X_ESP_STA_MAC') ||
    !check_header('HTTP_X_ESP_AP_MAC') ||
    //!check_header('HTTP_X_ESP_FREE_SPACE') ||
    //!check_header('HTTP_X_ESP_SKETCH_SIZE') ||
    //!check_header('HTTP_X_ESP_CHIP_SIZE') ||
    //!check_header('HTTP_X_ESP_SDK_VERSION') ||
    !check_header('HTTP_X_ESP_VERSION')
) {
  fwrite($myfile,$_SERVER["SERVER_PROTOCOL"].' 403 Forbidden\n');
    header($_SERVER["SERVER_PROTOCOL"].' 403 Forbidden', true, 403);
    echo "only for ESP updater! (header)\n";
    exit();
}


$db->join("Devices_Configs dc", "d.ID=dc.Device_ID", "LEFT");
$db->join("Boards_Firmware bfw", "dc.Board_ID=bfw.Board_ID", "LEFT");
$db->where("d.MAC", $_SERVER['HTTP_X_ESP_STA_MAC']);
$db->orderBy("bfw.ReleaseDatum","Desc");
$fws = $db->get("Devices d", 1, "bfw.Path, bfw.ReleaseDatum, bfw.Build")[0];

if(isset($fws['Path'])) {
    if($fws['Build'] > $_SERVER['HTTP_X_ESP_VERSION']) {
    $file = "files/".$fws['Path'];
    if(file_exists($file)){
      if(isset($_SERVER['HTTP_X_ESP_DEBUG'])){
        $debug = true;
      }else{
        $debug = false;
      }
      if(!$debug){
        		sendFile($file);
      }
    }else{
      fwrite($myfile,$_SERVER["SERVER_PROTOCOL"].' 204 No content\n');
      header($_SERVER["SERVER_PROTOCOL"].' 204 No content', true, 204);
    }
    } else {
    fwrite($myfile,$_SERVER["SERVER_PROTOCOL"].' 304 Not Modified\n');
        header($_SERVER["SERVER_PROTOCOL"].' 304 Not Modified', true, 304);
    }
    exit();
}
fwrite($myfile,$_SERVER["SERVER_PROTOCOL"].' 500 no version for ESP MAC\n');
header($_SERVER["SERVER_PROTOCOL"].' 500 no version for ESP MAC', true, 500);
fwrite($myfile, "----------------------------------------------------------------------------\n");

Als Datenschicht verwende ich die MySQLi-DB-Klasse womit man recht schnell und elegant SQL-Querys gegen eine Datenbank abfeuern kann. Hier wollte ich das Rad nicht neu erfinden. Wichtig ist hierbei, dass ich die Zugriffe per Header beschränke und entsprechende Rückgaben liefere.

So muss z.B. der User-Agent der Anfrage ESP-http-Update sein. Damit wird gewährleistet, dass nur „berechtigte“ Geräte darauf zugreifen können. Insgesamt werden folgende Header erwartet:

[wp_table id=227/]

Das ganze Verfahren basiert auf der offiziellen Implementierung von Arduino, welche ich etwas abgewandelt habe. Die offizielle Implementierung sieht nämlich vor, dass die Pfade innerhalb des PHP-Skriptes zusammengebaut und an den Client übertragen werden. Das habe ich geändert, da ich die Pfad-Informationen innerhalb der Datenbank gespeichert habe:

$db->join("Devices_Configs dc", "d.ID=dc.Device_ID", "LEFT");
$db->join("Boards_Firmware bfw", "dc.Board_ID=bfw.Board_ID", "LEFT");
$db->where("d.MAC", $_SERVER['HTTP_X_ESP_STA_MAC']);
$db->orderBy("bfw.ReleaseDatum","Desc");
$fws = $db->get("Devices d", 1, "bfw.Path, bfw.ReleaseDatum, bfw.Build")[0];

Hier ermittelte ich anhand der MAC-Adresse das Device, die Device-Config und damit dann die Board-Firmware.

Wie du siehst, gibt es noch einige andere Tabellen die ich benötige. Um diese nicht groß erklären zu müssen, habe ich ein ER-Diagram der Datenbank erstellt:

Die Tabelle Devices wird durch die Geräte selbst befüllt. Merkt also ein Gerät, dass es noch nicht registriert ist, anhand er MAC-Adresse, trägt es sich selbst dort ein und legt einen Eintrag in der Tabelle Devices_Configs an. Darum brauche ich auch die interne ID des Boards.

Bedenke die Sicherheit!

Konsequenterweise muss man natürlich bei beiden APIs noch eine Authentifizierung einbauen damit es wirklich sicher ist. Dies habe ich aber bei mir weggelassen, da APIs nur zum Testen auf einem externen Server liegen. Später wenn das System läuft, wandern die APIs auf meinen Raspberry Pi3, der dann auch ein extra Management-WLAN für die Controller zur Verfügung stellt (mit eigenen IP-Adressen) und nur mit einer Route in meinem LAN hängt. So können die Controller mit dem Internet kommunizieren, aber andere Geräte nicht mit den Controllern, lediglich nur die Geräte innerhalb des „Managementnetzwerks“.

Implementierung im Controller

Die Implementierung in der Controller-Firmware ist recht simpel. Er checkt beim Booten ob es neue Firmware-Updates gibt und nach einer gewissen Zeitspanne. Das Wiederkehrende Suchen nach Updates habe ich mit Hilfe der Ticker-Library umgesetzt. Es gibt nur eine Routine checkForUpdate():

void checkForUpdates() {
  display_text("Checking for updates");
  // display.drawProgressBar(4, 40, 120, 8, 10);
  // display.display();

  Debug.println("[checkForUpdates]: Checking for firmware updates.");
  Debug.println("[checkForUpdates]: Firmware version URL: "+String(fwUrlBase));

  t_httpUpdate_return ret = ESPhttpUpdate.update(fwUrlBase, String(FW_VERSION));
  switch(ret) {
      case HTTP_UPDATE_FAILED:
          Debug.println("[checkForUpdates]: Update failed: "+String(ESPhttpUpdate.getLastErrorString()));
          break;
      case HTTP_UPDATE_NO_UPDATES:
          Debug.println("[checkForUpdates]: No Update needed");
          break;
      case HTTP_UPDATE_OK:
          Debug.println("[checkForUpdates]: Update ok."); // may not called we reboot the ESP
          break;
  }
  yield();
}

Im Großen und Ganzen war es das auch schon. Als Gimmick habe ich jedoch auch noch einen HTTP-Client auf den Controllern laufen, mit dem ich alle Controller manuell vom meinem gebauten Admin-Panel neustarten kann bzw. nach Updates suchen lassen kann.

Da ich die IP-Adressen sowieso innerhalb der Tabelle Devices_Pings habe, kann ich die Controller mittelt HTTP-Request ansprechen und Aktionen durchführen lassen:

rest.addRoute(GET, "/reboot", reboot);

Dies basiert auf der RestServer-Libary. Damit ist man flexibel genug, z.B. auch dringende Fixe schnell aufzuliefern, ohne auf die automatischen Updates der Geräte zu warten.

Dies muss man aber absichern, damit damit kein Unbefugter Schindluder mit treibt.

Zusammenfassung

Wie du sicherlich gemerkt hast, ist das viel an Informationen, verschiedene Systeme die in einander greifen und damit das System zu dem macht, was es ist: eine geniale Zeitersparnis beim Deployment der Firmware.

Dieses Verfahren zu implementieren (mit Studium der Ressourcen etc.) hat mich ca. 2 Wochen gekostet, erspart mir aber einiges an Arbeit, wenn ich wirklich das ganze Haus mit Controllern vernetzt habe.

Wäre ein solches System für dich interessant? Lass es mich in den Kommentaren wissen!