Rasperry Pi Zero W steuert Antennenrotor

Eigenbau-Rotorsteuerung als Ersatz für den PRO.SIS.TEL

In meinem Beitrag vom 31.10.2024 hatte ich beschrieben, wie der Antennenrotor von PRO.SIS.TEL mittels eines Raspberry Pi über das Netzwerk angesprochen werden kann.

Leider trat nun ein technischer Defekt am Rotorsteuergerät auf. Die Anzeige sprang nach wenigen Sekunden immer auf den fixen Wert von 230. Nach Rücksprache mit dem hilfreichen Support von PRO.SIS.TEL konnte ich feststellen, dass es sich dabei nicht um einen Code für eine Fehlermeldung handelt.

Nun dachte ich mir, die Abfrage eines Potentiometers und die Ansteuerung eines 12 V-Motors mittels Pulsweitenmodulation sind durchaus Dinge, die ein Raspberry Pi mit etwas Zubehör bewerkstelligen kann. Da ich mich außerdem in die Programmiersprache Go einarbeiten wollte, war ein neues Projekt für mich geboren.

Ich habe hierzu folgende Bauteile benötigt:

  • https://www.amazon.de/dp/B07PXFD3BH
    Das ADS1115-Modul enthält vier Analog-Digital-Wandler mit 16-Bit-Auflösung und kann Spannungen zwischen 2V und 5V messen. Es wird mittels I²C-Schnittstelle am Raspberry Pi angeschlossen. Ich frage damit das Potentiometer im Rotorgetriebe ab, um die aktuelle Drehrichtung zu ermitteln.
  • https://www.amazon.de/dp/B0C84VLLC9
    Hier handelt es sich um eine sogenannte H-Brücke zur Ansteuerung von Gleichstrommotoren. Sie wird am PWM-Ausgang des Raspberry Pi angeschlossen und liefert genügend Strom zur Bewegung des Motors. Die Stromstärke kann über die Pulsweitenmodulation eingestellt werden. Das ermöglicht die Programmierung einer sanften Beschleunigungs- und Abbremsfunktion. Das Modul kann zwei Motoren ansteuern, ich verwende hier nur einen Ausgang. Die Platine enthält zusätzlich einen Spannungswandler von 12V auf 5V. Damit kann der Raspberry Pi stabil mit 5 V versorgt werden und es wird nur ein Netzteil mit 12V Betriebsspannung benötigt.
  • https://www.reichelt.de/kaltgeraeteeinbaustecker_c14_laengsflansch-p263291.html?&nbc=1
    Kaltgeräteeinbaustecker C14 Längsflansch zur Versorgung mit 230V – Die Halterung für diese Buchse ist in den 3D-Druck-Dateien enthalten und enthält einen verlängerten Berührungsschutz
  • https://www.reichelt.de/schaltnetzteil_hutschiene_60_w_12_v_5_a-p85241.html?&nbc=1
    Schaltnetzteil 12 V – 5A zur Bereitstellung der Stromversorgung für den Motor. Der Raspi wird von der H-Brücke mit 5V versorgt.
  • https://www.reichelt.de/wannenstecker_40-polig_gerade-p22834.html?&nbc=1
    Wannenstecker für den GPIO-Anschluss des Raspberry Pi
  • https://www.reichelt.de/raspberry_pi_-_gpio_kabel_40-pin_30cm_grau-p293579.html?&nbc=1
    GPIO-Kabel da ich den Raspberry Pi über ein Kabel mit der Platine verbinden möchte.
  • https://www.reichelt.de/raspberry_pi_zero_wh_v_1_1_1_ghz_512_mb_ram_wlan_bt-p222531.html?&nbc=1
    Raspberry Pi Zero WH – die technische Ausstattung genügt vollkommen für das Projekt
  • Eine selbst entwickelte Platine, die aber nur die Verbindungen zwischen den Platinen ermöglicht. Sie enthält eine Möglichkeit, den Raspberry Pi mit seiner GPIO-Schnittstelle zu verbinden und über diverse Klemmen die beiden oben genannte Platinen, das Kabel zum Antennenrotor und das 12 V-Netzteil zu kontaktieren. Die Platine habe ich mit der Software KiCad erstellt und stelle die Daten hier zur Verfügung.
    Bei einem Nachbau vor Bestellung der Platine die Zeichnungen noch etwas überarbeiten und etwas mehr Platz um die Bohrungen frei lassen. Bei mir war eine Mutter etwas zu nah am Gehäuse des Steckverbinders.

Bereitstellung des Raspberry Pi

Ich habe auf einen Raspberry Pi Zero 1 W mit dem Raspian-Imager Version 2.0 das zum Zeitpunkt der Arbeiten aktuelle Betriebssystem Raspian Trixie 32-Bit vom 04.12.2025 aufgespielt und dabei den Hostnamen raspirotor vergeben, den Dienst ssh aktiviert und den Benutzer namens pi angelegt (Nicht zu empfehlen, da dies früher der Standardnutzer war)

Dann konnte ich mich mit ssh -X pi@raspirotor von meinem PC aus auf dem neuen Gerät raspirotor anmelden. Als ersten Schritt habe ich die I²C-Schnittstelle freigeschaltet:

#I²C-Schnittstelle öffnen
sudo raspi-config

# 3 Interface Options
# I5 I2C Enable/Disable
# Ja um die Schnittstelle zu aktivieren

# Anzeige aller an I²C angeschlossenen Geräte
i2cdetect -y 1

Als erster Schritt muss das Betriebssystem aktualisiert werden:

# Das Betriebssystem aktualisieren
sudo apt update
sudo apt upgrade

Nun habe ich mir den Editor Geany und ein paar Hilfsprogramme installiert.

  • libcanberra wird zur Anzeige der grafischen Oberfläche von geany benötigt
  • tree dient zur übersichtlichen Darstellung der Verzeichnisstruktur
  • jq wird von der build.sh zum Auslesen der Versionsinformation verwendet
sudo apt install geany geany-common libcanberra-gtk3-0 libcanberra-gtk3-module tree jq

Falls nach dem Aufruf von Geany kein sichtbares Fenster erscheint oder die Menüpunkte noch nicht in der Landessprache angezeigt werden, müssen noch ein paar Einstellungen in raspi-config durchgeführt werden:

#Falls Geany kein sichtbares Fenster öffnet und keine deutschsprachigen Menüs anzeigt die Lokalisierung manuell setzen
sudo raspi-config

# dann folgend Menüfolge:
# 6 Advanced Option
# A7 Wayland
# W1 X11 Openbox Window manager with X11 backend
# aktivieren		
# 
# falls in Geany keine deutschsprachige Oberfläche erscheint
# 5 Localisation Options
# L1 Locale
# und de_DE.UTF-8 einstellen.			
# nach einem Reboot sollte alles wie gewünscht funktionieren

Um aus den Quellcode unseres Programms eine ausführbare Datei erstellen zu können, benötigen wir die Programmierumgebung zur Programmiersprache Go (golang):

# Beispiele für Build-Aufrufe mit Angaben zum Zielsystm:

# Für Windows 64-bit aus Linux/macOS:
#GOOS=windows GOARCH=amd64 go build -o search_indexer main.go

# Für Raspian Trixie 64-Bit aus Linux Ubuntu heraus:
#GOOS=linux GOARCH=arm64 go build -o search_indexer main.go


# Die Programmiersprache Golang installieren
sudo apt install golang-go

# Version anzeigen
go version

Damit ist unser kleiner Computer vorbereitet und wir können mit der Erstellung unseres eigenen Programmcodes beginnen.

Eigenes Serverzertifikat erzeugen

Damit wir nicht ständig Warnungen im Browser erhalten, erzeugen wir ein eigenes Serverzertifikat. Wir beginnen mit der Generirung des privaten Schlüssels:

openssl genrsa -out server.key 2048

Nun erzeugen wir das Zertifikat mit einer Gültigkeit von einem Jahr:

openssl req -new -x509 -key server.key -out server.crt -days 365

Erstellung des Quellcodes zum Projekt antenna-rotor

Vorbereitung der Verzeichnisstruktur. Ich habe hierzu das Unterverzeichnis antenna-rotor im aktuellen home-Verzeichnis verwendet:

#In das Homeverzeichnis des Benutzers gehen
cd /home/pi/

# das Projektverzeichnis mit Unterverzeichnissen anlegen
mkdir -p /home/pi/antenna-rotor/{hardware,web/static}

Der Befehl tree dient zum Anzeigen der leeren Verzeichnisstruktur

tree antenna-rotor
Ansicht der leeren Verzeichnisstruktur

Das Verzeichnis antenna-rotor ist das Projektverzeichnis unseres Go-Programm. Darin befindet sich später die Datei main.go von der aus alle anderen Funktionen und Hilfsprogramme eingebunden werden. Im Unterverzeichnis hardware liegen die go-Routinen zur Steuerung der Hardware. Das Unterverzeichnis web enthält den mit Go programmierten Webserver und darunter befinden sich im Unterverzeichnis static die HTML-, CMS- und Javascript-Datei zur Darstellung unserer Weboberfläche im Browser.

Beginnen wir nun mit der Erstellung der Quellcode-Dateien. Im ersten Codeblock finden Sie den Aufruf des Editors und im zweiten jeweils den dazugehörigen Dateiinhalt.

Die Datei build.sh wird später die Erzeugung der ausführbaren Daten aus dem Quellcode durchführen:

geany /home/pi/antenna-rotor/build.sh
#!/bin/bash
set -e

# Lies Version & Datum aus version.json
VERSION=$(jq -r '.version' version.json)
DATE=$(jq -r '.date' version.json)

# Baue das Binary mit eingebetteten Werten
echo "🔧 Baue Antenna Rotor Controller v$VERSION ($DATE)"
go build -ldflags "-X main.Version=$VERSION -X main.BuildDate=$DATE" -o antenna-rotor

echo "✅ Fertig gebaut: ./antenna-rotor"

#Dateiberechtigung als ausführbare Datei setzen
sudo chmod +x ./antenna-rotor

#Der Webserver soll auch ohne Root-Zugriff auf Port 443 lauschen
sudo setcap cap_net_bind_service=+ep /home/pi/antenna-rotor/antenna-rotor

Aus der Datei mit den Versionsangaben verwendet das Programm nur die Angaben aus den ersten zwei Zeilen. Der Rest dient der Information für menschliche Betrachter:

geany /home/pi/antenna-rotor/version.json
{
  "version": "2.5",
  "date": "20.01.2026_16:15",
  "description": "PID-Regelung mit sanften Start und Abbremsen, manueller Stop",
  "features": [
    "Motorsteuerung über L298N (go-rpio/v4)",
    "ADS1115-Abfrage über go-i2c",
    "Weboberfläche mit Steuerbuttons und Kompassanzeige",
    "Ham-Locator-Berechnung (Richtung & Entfernung)",
	"I2C-Zugriff ohne sudo über i2c-Gruppe",
    "Pins zum L298N-Modul:",
    "GPIO 17 in1",
    "GPIO 27 in2",
    "GPIO 22 enable",
    "GND GND",
    "Pins zum ADS1115-Modul",
    "GPIO 1 SDA",
    "GPIO 3 SCL",
	"GPIO 1 3V3 VDD",
	"GND = GND"
  ]
}

Die Datei rotor.service enthält die Angaben zur Einrichtung unseres Programm antenna-rotor als Hintergrunddienst. Sie wird später noch in ein anderes Verzeichnis kopiert.

geany /home/pi/antenna-rotor/rotor.service
[Unit]
Description=Antenna Rotor Control Service
After=network-online.target i2c.service
Wants=network-online.target
StartLimitIntervalSec=0

[Service]
Type=simple
ExecStartPre=/bin/bash -c 'for i in {1..10}; do i2cdetect -y 1 | grep -q "48" && exit 0; echo "warte auf ADS1115..."; sleep 2; done; exit 1'
ExecStart=/home/pi/antenna-rotor/antenna-rotor
WorkingDirectory=/home/pi/antenna-rotor
User=pi
#Restart=always
Restart=on-failure # Startet den Dienst nur bei Fehlern neu
RestartSec=5s      # Wartet 5 Sekunden vor dem nächsten Versuch
StartLimitIntervalSec=60s # Innerhalb von 60 Sekunden
StartLimitBurst=3  # Maximal 3 Versuche in diesem Intervall

StandardOutput=append:/home/pi/antenna-rotor/rotor.log
StandardError=append:/home/pi/antenna-rotor/rotor.log

[Install]
WantedBy=multi-user.target

main.go enthält unser Hauptpgoramm. Es bindet die benötigten Hilfsprogramme und Funktionen ein.

geany /home/pi/antenna-rotor/main.go
package main

import (
    "flag"
    "fmt"
    "log"
    "net"
    "os"
    "os/signal"
    "syscall"
    "time"

    "antenna-rotor/hardware"
    "antenna-rotor/web"
    i2clog "github.com/d2r2/go-logger"
)

var (
    Version   = "dev"
    BuildDate = "BuildDate"
)

func main() {

	//Den Loglever für das ADS1115 Modul ändern
	i2clog.ChangePackageLogLevel("i2c", i2clog.InfoLevel)

    calibrate := flag.Bool("calibrate", false, "Startet Kalibrierung des Potentiometers")

    addr := flag.String("addr", ":443", "Adresse und Port (Standard: :443 für HTTPS)")
    flag.Parse()

    DatAktuell := time.Now().Format("02.01.2006 15:04:05")
    if BuildDate == "BuildDate" {
        Version = "aktueller Arbeitsstand"
        BuildDate = DatAktuell
    }
    flag.Parse()

    motor := hardware.NewMotor()
    defer motor.Close()

    // Signalbehandlung
    c := make(chan os.Signal, 1)
    signal.Notify(c, os.Interrupt, syscall.SIGTERM)
    go func() {
        <-c
        log.Println("🛑 Beende Programm – Motor wird gestoppt ...")
        motor.Close()
        os.Exit(0)
    }()

    ads, err := hardware.NewADS1115(1, 0x48)
    if err != nil {
        log.Fatalf("Fehler beim Initialisieren des ADS1115: %v", err)
    }
    defer ads.Close()

    if *calibrate {
        runCalibration(ads)
        return
    }

    // Aktuellen Winkel auslesen
    angle, _, _ := ads.ReadAngle()

    // Lokale IP-Adresse bestimmen
    ip := getLocalIP()

    log.Printf("🚀 Antennenrotor Version %s (%s) gestartet", Version, BuildDate)
    log.Printf("🌐 Webinterface erreichbar unter: http://%s:8080", ip)
    log.Printf("📡 Aktueller Winkel: %.1f°", angle)

    server := web.NewServer(motor, ads)
    //log.Fatal(server.Start(":8080", Version, BuildDate))
    
    //Damit https ohne root-Rechte funktioniert:
    //sudo setcap 'cap_net_bind_service=+ep' /home/pi/antenna-rotor/antenna-rotor

    if err := server.StartSecure(*addr, Version, BuildDate,"server.crt", "server.key"); err != nil {
        log.Fatalf("Fehler beim Starten des Servers: %v", err)
    }

}

// Lokale IP-Adresse bestimmen
func getLocalIP() string {
    addrs, err := net.InterfaceAddrs()
    if err != nil {
        return "unbekannt"
    }
    for _, addr := range addrs {
        if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
            if ipnet.IP.To4() != nil {
                return ipnet.IP.String()
            }
        }
    }
    return "127.0.0.1"
}

func runCalibration(ads *hardware.ADS1115) {
    fmt.Println("⚙️  Starte Kalibrierung...")
    fmt.Println("➡️  Drehe Potentiometer auf Minimum und drücke Enter.")
    fmt.Scanln()
    vMin, _ := ads.ReadVoltage()

    fmt.Println("➡️  Drehe Potentiometer auf Maximum und drücke Enter.")
    fmt.Scanln()
    vMax, _ := ads.ReadVoltage()

    ads.Calibration.MinVoltage = vMin
    ads.Calibration.MaxVoltage = vMax
    _ = ads.Calibration.Save("calibration.json")

    fmt.Printf("✅ Kalibrierung gespeichert: %.3f V – %.3f V\n", vMin, vMax)
}

server.go ist unser Webserver. Er empfängt die Kommandos und führt diese durch Ein- und Ausschalten der Pulsweiten-Modulation aus. Gleichzeitig liest er über die ADS1115-Platine das Potentiometer im Rotor und stellt die Messwerte zur Abfrage durch Javascript-Routinen bereit.

geany /home/pi/antenna-rotor/web/server.go
package web

import (
    "antenna-rotor/hardware"
    "encoding/json"
    "log"
    "math"
    "net/http"
    "os"
    "os/exec"
    "strconv"
    "sync"
    "time"
)

type Server struct {
    motor        *hardware.Motor
    ads          *hardware.ADS1115
    mu           sync.Mutex
    target       float64
    dist         float64
    current_alt  float64
    curr_voltage float64
    version      string
}

const earthRadius = 6371.0 // km

//Globale Variablen
const (
	MinAngle = -90.0	// kleinster Drehwinkel
	MaxAngle = 450.0	// größter Drehwinkel
)

func NewServer(m *hardware.Motor, ads *hardware.ADS1115) *Server {
    return &Server{motor: m, ads: ads}
}

func (s *Server) Start(addr string, version string, builddate string) error {
    fs := http.FileServer(http.Dir("web/static"))
    http.Handle("/", fs)
    http.HandleFunc("/angle", s.handleAngle)
    http.HandleFunc("/settarget", s.handleSetTarget)
    http.HandleFunc("/rotate", s.handleRotate)
    http.HandleFunc("/locator", s.handleLocator)
    http.HandleFunc("/version", s.handleVersion)
    http.HandleFunc("/manual", s.handleManual)
    http.HandleFunc("/calibrate", s.handleCalibrate)
    http.HandleFunc("/shutdown", s.handleShutdown)
    hostname, err := os.Hostname()
    if err != nil {
        log.Fatalf("Fehler beim Abrufen des Hostnamens: %v", err)
    }
    log.Printf("🌐 Webserver läuft auf %s%s ...", hostname, addr)

    s.version = version + " vom " + builddate

    current, voltage, err := s.ads.ReadAngle()
    if err == nil {
        s.target = current
        s.current_alt = current
        s.curr_voltage = voltage
        s.motor.Curr_angle = current
    }

    return http.ListenAndServe(addr, nil)
}

// ----------------------------------------------------------
// 🔒 HTTPS-Start (z. B. Port 443)
// ----------------------------------------------------------
func (s *Server) StartSecure(addr, version, builddate, certFile, keyFile string) error {
    s.setupHandlers()
    s.version = version + " vom " + builddate

    hostname, _ := os.Hostname()
    log.Printf("🔐 HTTPS-Server läuft auf https://%s%s", hostname, addr)

    current, voltage, _ := s.ads.ReadAngle()
    s.target = current
    s.current_alt = current
    s.curr_voltage = voltage

    server := &http.Server{
        Addr:              addr,
        ReadHeaderTimeout: 5 * time.Second,
    }

    return server.ListenAndServeTLS(certFile, keyFile)
}


// ----------------------------------------------------------
// Gemeinsame Handler-Registrierung
// ----------------------------------------------------------
func (s *Server) setupHandlers() {
    fs := http.FileServer(http.Dir("web/static"))
    http.Handle("/", fs)
    http.HandleFunc("/angle", s.handleAngle)
    http.HandleFunc("/settarget", s.handleSetTarget)
    http.HandleFunc("/rotate", s.handleRotate)
    http.HandleFunc("/locator", s.handleLocator)
    http.HandleFunc("/version", s.handleVersion)
    http.HandleFunc("/manual", s.handleManual)
    http.HandleFunc("/shutdown", s.handleShutdown)
    http.HandleFunc("/calibrate", s.handleCalibrate)
}


// --- aktuelle Winkel an die Webseite ---
func (s *Server) handleAngle(w http.ResponseWriter, r *http.Request) {
    s.mu.Lock()
    defer s.mu.Unlock()

    current, voltage, err := s.ads.ReadAngle()
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    diff := Abs(current - s.current_alt)
    //Eigenbewegungen loggen
    if !s.motor.IsRunning() && diff > 2 {
        log.Printf("Eigenbewegung um %.1f von %.1f nach %.1f festgestellt",diff,s.current_alt,current)
    }
    s.current_alt = current
    s.curr_voltage = voltage
    s.motor.Curr_angle = current



    resp := map[string]float64{
        "angle":  current,
        "target": s.target,
        "voltage": s.curr_voltage,
    }
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(resp)
}

// --- Sollwinkel setzen ---
func (s *Server) handleSetTarget(w http.ResponseWriter, r *http.Request) {
    str := r.URL.Query().Get("angle")
    angle, err := strconv.ParseFloat(str, 64)
    if err != nil {
        http.Error(w, "Ungültiger Winkel", http.StatusBadRequest)
        return
    }

    s.mu.Lock()
    s.target = angle
    s.mu.Unlock()

    log.Printf("🎯 Neuer Zielwinkel: %.1f°", angle)
    w.WriteHeader(http.StatusOK)

    if !s.motor.IsRunning() {
        go s.moveToTarget()
    }
}

// --- Stop-Button ---
func (s *Server) handleRotate(w http.ResponseWriter, r *http.Request) {
    dir := r.URL.Query().Get("dir")
    if dir == "stop" {
        log.Println("🛑 STOP gedrückt → Motor sanft abbremsen")
        s.target, _, _ = s.ads.ReadAngle()
        go s.motor.StopSoft()
    }
    w.WriteHeader(http.StatusOK)
}

// --- Locator-Berechnung ---
func (s *Server) handleLocator(w http.ResponseWriter, r *http.Request) {
    from := r.URL.Query().Get("from")
    to := r.URL.Query().Get("to")
    log.Printf("📡 Berechne Richtung von %s nach %s ...", from, to)

    f_lat, f_lon := MaidenheadToLatLon(from)
    t_lat, t_lon := MaidenheadToLatLon(to)
    s.dist, s.target = DistanceAndBearing(f_lat, f_lon, t_lat, t_lon)

    log.Printf("Zielrichtung: %.1f° – Entfernung: %.1f km", s.target, s.dist)

    resp := map[string]float64{
        "dist":   s.dist,
        "target": s.target,
    }
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(resp)

    if !s.motor.IsRunning() {
        go s.moveToTarget()
    }
}

// --- Motorsteuerung ---
func (s *Server) moveToTarget() {
    s.mu.Lock()
    if s.motor.IsRunning() {
        s.mu.Unlock()
        return
    }
    s.mu.Unlock()

    log.Println("🚀 Starte Bewegung zum Zielwinkel ...")

    for {
        s.mu.Lock()
        current, voltage, err := s.ads.ReadAngle()
        s.curr_voltage = voltage
        target := s.target
        s.mu.Unlock()

        if err != nil {
            log.Printf("Fehler beim Lesen des Winkels: %v", err)
            s.motor.StopSoft()
            break
        }

        diff := target - current
        var dir = "right"
        if diff < 0 {
            diff += 360
            dir = "left"
        }
        if diff > 180 {
            diff -= 360
        }

        absDiff := math.Abs(diff)

        if absDiff < 2.0 { // Ziel erreicht
            log.Printf("✅ Ziel erreicht bei %.1f°", current)
            s.motor.StopSoft()
            break
        }

        // Fahrgeschwindigkeit bestimmen
        var speed int
        if absDiff > 15 {
            speed = 100 // Vollgas
        } else if absDiff > 10 {
            speed = 70
        } else if absDiff > 5 {
            speed = 50
        } else {
            speed = 30 // Sanft abbremsen
        }

        s.motor.Run(dir, speed)

        time.Sleep(200 * time.Millisecond)
    }

    s.mu.Lock()
    s.mu.Unlock()
    log.Println("🏁 Bewegung abgeschlossen.")
}

// --- Locator-Umrechnung ---
func MaidenheadToLatLon(locator string) (lat, lon float64) {
    lon = (float64(locator[0]-'A')*20 - 180) +
        (float64(locator[2]-'0')*2) +
        (float64(locator[4]-'A')/12.0*2)
    lat = (float64(locator[1]-'A')*10 - 90) +
        (float64(locator[3]-'0')*1) +
        (float64(locator[5]-'A')/24.0*1)
    return
}

func DistanceAndBearing(lat1, lon1, lat2, lon2 float64) (dist, bearing float64) {
    φ1 := lat1 * math.Pi / 180
    φ2 := lat2 * math.Pi / 180
    Δλ := (lon2 - lon1) * math.Pi / 180

    bearing = math.Atan2(math.Sin(Δλ)*math.Cos(φ2),
        math.Cos(φ1)*math.Sin(φ2)-math.Sin(φ1)*math.Cos(φ2)*math.Cos(Δλ))
    bearing = math.Mod((bearing*180/math.Pi)+360, 360)

    dist = earthRadius * math.Acos(math.Sin(φ1)*math.Sin(φ2)+math.Cos(φ1)*math.Cos(φ2)*math.Cos(Δλ))
    return
}

// --- Versionsinfo ---
func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) {
    resp := map[string]string{
        "version": s.version,
    }
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(resp)
}

// --- Manuelle Steuerung: Links / Rechts ---
func (s *Server) handleManual(w http.ResponseWriter, r *http.Request) {
    dir := r.URL.Query().Get("dir")

    s.mu.Lock()
    defer s.mu.Unlock()

    current, voltage, err := s.ads.ReadAngle()

    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    s.curr_voltage = voltage

    if dir == "left" {
        if current <= MinAngle {
            log.Println("🚫 Linksanschlag erreicht.")
            s.motor.StopSoft()
            return
        }
        log.Println("↩️ Manuell nach links")
        go s.motor.Run("left", 100)

    } else if dir == "right" {
        if current >= MaxAngle {
            log.Println("🚫 Rechtsanschlag erreicht.")
            s.motor.StopSoft()
            return
        }
        log.Println("↪️ Manuell nach rechts")
        go s.motor.Run("right", 100)
    }

    w.WriteHeader(http.StatusOK)
}

// Kalibrierung über die Weboberfläche

func (s *Server) handleCalibrate(w http.ResponseWriter, r *http.Request) {
    typ := r.URL.Query().Get("type")
    voltage, err := s.ads.ReadVoltage()
    if err != nil {
        log.Println("Fehler beim Lesen des Poti")
        http.Error(w, "Fehler beim Lesen der Spannung", http.StatusInternalServerError)
        return
    }

    switch typ {
    case "min":
        s.ads.Calibration.MinVoltage = voltage
    case "max":
        s.ads.Calibration.MaxVoltage = voltage
    default:
        http.Error(w, "Ungültiger Typ", http.StatusBadRequest)
        return
    }

    // in Datei speichern
    if err := s.ads.Calibration.Save("calibration.json"); err != nil {
        http.Error(w, "Fehler beim Speichern", http.StatusInternalServerError)
        return
    }

    log.Printf("Kalibrierung (%s): %.3f V gespeichert", typ, voltage)

    resp := map[string]float64{"voltage": voltage}
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(resp)
}


// --- Raspberry Pi herunterfahren ---
func (s *Server) handleShutdown(w http.ResponseWriter, r *http.Request) {
    action := r.URL.Query().Get("action")
    log.Println("⚠️  Shutdown über Weboberfläche angefordert!")
    switch action {
    case "shutdown":
        go func() {
            time.Sleep(2 * time.Second)
            log.Println("🖥️  Raspberry Pi wird jetzt heruntergefahren ...")
            err := exec.Command("sudo", "shutdown", "-h", "now").Run()
            if err != nil {
                log.Printf("❌ Fehler beim Herunterfahren: %v", err)
            }
        }()
            
        w.Header().Set("Content-Type", "application/json")
        json.NewEncoder(w).Encode(map[string]string{"status": "shutdown initiated"})
    case "reboot":
        go func() {
            time.Sleep(2 * time.Second)
            log.Println("🖥️  Raspberry Pi wird neu gestartet ...")
            err := exec.Command("sudo", "reboot").Run()
            if err != nil {
                log.Printf("❌ Fehler beim Reboot: %v", err)
            }
        }()
            
        w.Header().Set("Content-Type", "application/json")
        json.NewEncoder(w).Encode(map[string]string{"status": "reboot initiated"})
    default:
        http.Error(w, "Ungültige action", http.StatusBadRequest)
        return
    }



}

// Abs returns the absolute value of x.
func Abs(x float64) float64 {
	if x < 0 {
		return -x
	}
	return x
}

Die index.html stellt die Weboberfläche im Browser dar

geany /home/pi/antenna-rotor/web/static/index.html
<!DOCTYPE html>
<html lang="de">
<head>
  <meta charset="UTF-8">
  <title>Antenna Rotor Steuerung</title>
  <link rel="stylesheet" href="style.css">
  <script src="script.js"></script>
</head>
<body onload = "showversion()">
  <h3>🎯 Antenna Rotor Steuerung</h3><div id="version">Version </div>
  <!-- Hauptsteuerung -->
  <div class="controls">
    <button onclick="setTarget(0)">0°</button>
    <button onclick="setTarget(90)">90°</button>
    <button onclick="setTarget(180)">180°</button>
    <button onclick="setTarget(270)">270°</button>
    <button onclick="setTarget(360)">360°</button>
    <button class="home" onclick="setTarget(345)">Home (345°)</button>
  </div>

  <!-- Feinsteuerung -->
  <div class="fine-controls">
    <!-- <h3>Feinjustierung</h3>  -->
    <button onclick="adjustTarget(-5)">−5°</button>
    <button onclick="adjustTarget(-1)">−1°</button>
    <button onclick="adjustTarget(1)">+1°</button>
    <button onclick="adjustTarget(5)">+5°</button>
    <button onclick="rotateManual('left')">⏪ Manuell Links</button>
    <button onclick="rotateManual('right')">⏩ Manuell Rechts</button>
    <button class="stop" onclick="rotate('stop')">🛑 Stop</button>
  </div>

  <!-- Anzeige -->
  <div id="dial">
    <!-- roter Zeiger = Ist -->
    <div id="needle"></div>
    <!-- blauer Zeiger = Soll -->
    <div id="target-needle"></div>
  </div>

  <div id="angle">–</div>
  <div id="status"></div>

  <!-- Locator-Funktion -->
  <div id="locator-inputs">
    <!-- <h3>📡 Ham Radio Locator</h3> -->
    <input type="text" id="loc1" placeholder="Locator 1 (z.B. JO52)">
    <input type="text" id="loc2" placeholder="Locator 2 (z.B. JM19)">
    <button onclick="calculateAzimuth()">Berechne Richtung</button>

    <div id="azimuth">Richtung: –</div>
    <div id="distance">Entfernung: –</div>
  </div>
  
  <!-- Kalibrierung -->
  <div id="calibration">
    <h3>⚙️ Kalibrierung</h3>
    <button onclick="calibrate('min')">Kalibrieren Min</button>
    <button onclick="calibrate('max')">Kalibrieren Max</button>
    <div id="calstatus"></div>
  </div>

  <!-- Shutdown/Reboot des Rechners -->
  <div>
    <button onclick="shutdown('shutdown')" style="background-color: #c00; color: white;">🔻 Raspberry Pi herunterfahren</button>
    <button onclick="shutdown('reboot')" style="background-color: #c00; color: white;">🔻 Raspberry Pi neu starten</button>
  </div>
</body>
</html>

script.js wird von der index.html eingebunden und stellt die Javascript-Funktionen zur Kommunikation mit dem Webserver zur Verfügung

geany /home/pi/antenna-rotor/web/static/script.js
let currentAngle = 0;
let targetAngle = 0;

// --- Holt aktuelle Daten vom Server und aktualisiert Anzeige ---
async function updateAngle() {
  try {
    const res = await fetch('/angle');
    const data = await res.json();
    currentAngle = data.angle;
    targetAngle = data.target;
    currentVoltage = data.voltage
    updateDisplay();
  } catch (err) {
    console.error("Fehler beim Abrufen:", err);
  }
}

// --- Aktualisiert Zeiger und Anzeige ---
function updateDisplay() {
  const needle = document.getElementById("needle");
  const targetNeedle = document.getElementById("target-needle");

  if (needle) needle.style.transform = `rotate(${currentAngle}deg)`;
  if (targetNeedle) targetNeedle.style.transform = `rotate(${targetAngle}deg)`;

  document.getElementById('angle').innerText = `${currentAngle.toFixed(1)}° (${currentVoltage.toFixed(3)} Volt)`;
  document.getElementById('status').innerText = `Ziel: ${targetAngle.toFixed(1)}°`;
}

// --- Setzt Zielwinkel absolut ---
function setTarget(angle) {
    targetAngle = angle;
  fetch(`/settarget?angle=${angle}`)
    .then(() => updateDisplay());
}

// --- Feinjustierung (+/- Grad) ---
function adjustTarget(delta) {
  newTarget = targetAngle + delta;
  if (newTarget < 0 ) {
    newTarget = 0
  }
  if (newTarget > 360 ) {
    newTarget = 360
  }

  targetAngle = newTarget;
  fetch(`/settarget?angle=${newTarget}`)
    .then(() => updateDisplay());
}

// --- Stop-Button ---
function rotate(dir) {
  fetch(`/rotate?dir=${dir}`);
}

// --- Ham-Locator Berechnung ---
function calculateAzimuth() {
  const loc1 = document.getElementById("loc1").value.toUpperCase();
  const loc2 = document.getElementById("loc2").value.toUpperCase();

  if (loc1.length < 6 || loc2.length < 6) {
    alert("Beide Locators müssen mindestens 6 Zeichen lang sein!");
    return;
  }

  fetch(`/locator?from=${loc1}&to=${loc2}`)
    .then(res => res.json())
    .then(data => {
      document.getElementById("azimuth").innerText = `Richtung: ${data.target.toFixed(1)}°`;
      document.getElementById("distance").innerText = `Entfernung: ${data.dist.toFixed(1)} km`;

      // neue Zielrichtung übernehmen
      targetAngle = data.target;
      updateDisplay();
      setTarget(data.target);
    })
    .catch(err => console.error("Fehler bei Locator-Berechnung:", err));
}

// --- Versionsabfrage ---
function showversion() {
  fetch(`/version`)
    .then(res => res.json())
    .then(data => {
      document.getElementById("version").innerText = "Version: " + data.version;

    })
    .catch(err => console.error("Fehler beim Versionsabruf:", err));

}

function rotateManual(dir) {
  fetch(`/manual?dir=${dir}`)
    .then(res => {
      if (!res.ok) throw new Error("Fehler bei manueller Steuerung");
    })
    .catch(err => console.error(err));
}

// --- Kalibrierung (neu) ---
function calibrate(type) {
  fetch("/calibrate?type=" + type)
    .then(res => res.json())
    .then(data => {
      document.getElementById("calstatus").innerText =
        "Kalibrierung (" + type + "): " + data.voltage.toFixed(3) + " V gespeichert";
    })
    .catch(err => {
      console.error("Kalibrierung fehlgeschlagen:", err);
      alert("Fehler bei der Kalibrierung!");
    });
}

//Button zum Herunterahren des Rechners gedrückt
function shutdown(action) {
    if (confirm("⚠️ Raspberry Pi wirklich " + action + "?")) {
        fetch("/shutdown?action=" + action)
            .then(r => r.json())
            .then(data => alert("🖥️ " + action + "wird jetzt ausgeführt."))
            .catch(err => alert("Fehler beim Herunterfahren/Reboot: " + err));
    }
}


// --- Regelmäßige Aktualisierung ---
setInterval(updateAngle, 200);
updateAngle();

stylce.css definiert die optische Darstellung der Elemente in der index.html

geany /home/pi/antenna-rotor/web/static/style.css
body {
  font-family: Arial, sans-serif;
  background: #f0f2f5;
  text-align: center;
  margin: 0;
  padding: 20px;
}

h1 {
  margin-bottom: 10px;
}

.controls {
  margin: 15px 0;
}

button {
  padding: 10px 20px;
  margin: 6px;
  border: none;
  border-radius: 5px;
  background: #007bff;
  color: white;
  font-size: 16px;
  cursor: pointer;
  transition: background 0.2s ease-in-out;
}

button:hover {
  background: #0056b3;
}

button.stop {
  background: #dc3545;
}

button.stop:hover {
  background: #b02a37;
}

button.home {
  background: #28a745;
}

button.home:hover {
  background: #1e7e34;
}

#dial {
  margin: 30px auto;
  width: 250px;
  height: 250px;
  border-radius: 50%;
  background: white;
  border: 3px solid #444;
  position: relative;
  box-shadow: 0 0 10px rgba(0,0,0,0.1);
}

#needle {
  position: absolute;
  width: 2px;
  height: 120px;
  background: red;
  top: 5px;
  left: 50%;
  transform-origin: bottom center;
  transition: transform 0.5s ease-out;
}

#target-needle {
  position: absolute;
  width: 3px;
  height: 120px;
  background: blue;
  top: 5px;
  left: 50%;
  transform-origin: bottom center;
  transition: transform 0.5s ease-out;
  opacity: 0.7;
}

#angle {
  font-size: 24px;
  margin-top: 10px;
  font-weight: bold;
}

#locator-inputs {
  margin-top: 20px;
}

input {
  padding: 5px;
  margin: 5px;
  font-size: 16px;
  border: 1px solid #ccc;
  border-radius: 4px;
}

#azimuth, #distance {
  font-size: 18px;
  margin-top: 10px;
}

Die Datei ads1115.go behandelt den Analaog-Digitalwandler zur Abfrage der Rotorstellung

geany /home/pi/antenna-rotor/hardware/ads1115.go
package hardware

import (
    "fmt"
    "time"
    "github.com/d2r2/go-i2c"
)

const (
    regConversion  = 0x00
    regConfig      = 0x01
    configSingleA0 = 0xC183 // AIN0, ±4.096V, single-shot
)

type ADS1115 struct {
    dev         *i2c.I2C
    Calibration *Calibration
}

func NewADS1115(bus int, addr uint8) (*ADS1115, error) {
    dev, err := i2c.NewI2C(addr, bus)
    if err != nil {
        return nil, fmt.Errorf("I2C-Init fehlgeschlagen: %w", err)
    }
    cal := LoadCalibration("calibration.json")
    return &ADS1115{dev: dev, Calibration: cal}, nil
}

func (a *ADS1115) ReadVoltage() (float64, error) {
    config := []byte{regConfig, byte(configSingleA0 >> 8), byte(configSingleA0 & 0xFF)}
    if _, err := a.dev.WriteBytes(config); err != nil {
        return 0, fmt.Errorf("Config schreiben fehlgeschlagen: %w", err)
    }

    time.Sleep(10 * time.Millisecond)
    if _, err := a.dev.WriteBytes([]byte{regConversion}); err != nil {
        return 0, fmt.Errorf("Conversion-Adresse setzen fehlgeschlagen: %w", err)
    }

    buf := make([]byte, 2)
    if _, err := a.dev.ReadBytes(buf); err != nil {
        return 0, fmt.Errorf("Lesen fehlgeschlagen: %w", err)
    }

    raw := int16(buf[0])<<8 | int16(buf[1])
    voltage := float64(raw) / 32767.0 * 4.096
    return voltage, nil
}

func (a *ADS1115) ReadAngle() (float64, float64, error) {
    voltage, err := a.ReadVoltage()
    //fmt.Println("ADS1115: Spannung = ", voltage," V")
    if err != nil {
        return 0,0, err
    }
    if voltage < a.Calibration.MinVoltage {
        //voltage = a.Calibration.MinVoltage
    }
    if voltage > a.Calibration.MaxVoltage {
        //voltage = a.Calibration.MaxVoltage
    }
    angle := (voltage - a.Calibration.MinVoltage) /
        (a.Calibration.MaxVoltage - a.Calibration.MinVoltage) * 360.0
    return angle, voltage, nil
}

func (a *ADS1115) Close() {
    a.dev.Close()
}

In der motor.go wird die H-Brücke zur Ansteuerung des Motors verwaltet.

geany /home/pi/antenna-rotor/hardware/motor.go
package hardware

import (
	"github.com/stianeikeland/go-rpio/v4"
	"log"
	"sync"
	"time"
)

type Motor struct {
	in1, in2, enable rpio.Pin
	pwmDuty          int
	running          bool
	direction        string
	stopChan         chan bool
	doneChan         chan bool
	mu               sync.Mutex
	Curr_angle		 float64
}

//Globale Variablen
const (
	MinAngle = -90.0	// kleinster Drehwinkel
	MaxAngle = 450.0	// größter Drehwinkel
)

func NewMotor() *Motor {
	if err := rpio.Open(); err != nil {
		log.Fatalf("GPIO konnte nicht geöffnet werden: %v", err)
	}

	m := &Motor{
		in1:     rpio.Pin(17),
		in2:     rpio.Pin(27),
		enable:  rpio.Pin(22),
		stopChan: make(chan bool),
		doneChan: make(chan bool),
	}

	m.in1.Output()
	m.in2.Output()
	m.enable.Output()
	m.enable.Low()
	m.running = false
	m.direction = "stop"
	return m
}

// --- Software-PWM ---
func (m *Motor) pwmLoop() {
	log.Println("🔄 PWM-Loop gestartet. Drehrichtung: ",m.direction)
	for {
		select {
		case <-m.stopChan:
			m.enable.Low()
			log.Println("🛑 PWM-Loop beendet")
			m.doneChan <- true
			return
		default:
			m.mu.Lock()
			duty := m.pwmDuty
			m.mu.Unlock()

			//Endanschläge erreicht
			//log.Println("minAngle:",MinAngle,", maxAngle",MaxAngle,", s.current:",m.Curr_angle,", m.direction",m.direction)
			if ((m.direction == "left" && m.Curr_angle < MinAngle) || (m.direction == "right" && m.Curr_angle > MaxAngle)){
				log.Println("🏁 Endanschlag erreicht.")
				m.enable.Low()
				m.doneChan <- true
				return
			}


			if duty <= 0 {
				m.enable.Low()
				time.Sleep(1 * time.Millisecond)
				continue
			}

			m.enable.High()
			time.Sleep(time.Duration(duty) * 80 * time.Microsecond)
			m.enable.Low()
			time.Sleep(time.Duration(100-duty) * 80 * time.Microsecond)
		}
	}
}

// Motor starten (mit sanftem Anfahren)
func (m *Motor) Run(direction string, speed int) {
	m.mu.Lock()
	defer m.mu.Unlock()

	if m.running && direction == m.direction && speed == m.pwmDuty {
		return // keine Änderung nötig
	}

	// Bei Richtungswechsel zuerst abbremsen
	if m.running && direction != m.direction {
		log.Println("↩️ Richtungswechsel erkannt → StopSoft()")
		go m.StopSoft()
		return
	}

	// Richtung setzen
	switch direction {
	case "right":
		m.in1.Low()
		m.in2.High()
	case "left":
		m.in1.High()
		m.in2.Low()
	default:
		m.in1.Low()
		m.in2.Low()
		m.direction = "stop"
		return
	}

	// PWM starten, wenn noch nicht aktiv
	if !m.running {
		m.stopChan = make(chan bool)
		m.doneChan = make(chan bool)
		go m.pwmLoop()
		m.running = true
	}

	m.direction = direction
	go m.RampUp(speed, 5, 50*time.Millisecond)
}

// Sanft hochfahren
func (m *Motor) RampUp(target, step int, delay time.Duration) {
	for d := m.pwmDuty; d < target; d += step {
		if m.pwmDuty != d{
			log.Println("🧘 RampUp d=",d)
		}
		m.mu.Lock()
		m.pwmDuty = d
		m.mu.Unlock()
		time.Sleep(delay)
	}
	m.mu.Lock()
	m.pwmDuty = target
	m.mu.Unlock()
	log.Printf("⚡️ Vollgas erreicht: %d%%", target)
}

// Sanft abbremsen
func (m *Motor) RampDown(step int, delay time.Duration) {
	for d := m.pwmDuty; d > 0; d -= step {
		if m.pwmDuty != d{
			log.Println("🧘 RampDown d=",d)
		}
		m.mu.Lock()
		m.pwmDuty = d
		m.mu.Unlock()
		time.Sleep(delay)
	}
	m.mu.Lock()
	m.pwmDuty = 0
	m.mu.Unlock()
}

// Sanft stoppen
func (m *Motor) StopSoft() {
	m.mu.Lock()
	if !m.running {
		m.mu.Unlock()
		return
	}
	m.mu.Unlock()

	log.Println("🧘 Sanftes Stoppen gestartet...")

	m.RampDown(5, 80*time.Millisecond)

	select {
	case m.stopChan <- true:
		<-m.doneChan
	case <-time.After(1 * time.Second):
		log.Println("⚠️ PWM-Loop reagiert nicht – Zwangsstopp")
	}

	m.mu.Lock()
	m.pwmDuty = 0
	m.running = false
	m.direction = "stop"
	m.mu.Unlock()

	m.in1.Low()
	m.in2.Low()
	m.enable.Low()
	log.Println("✅ Motor gestoppt.")
}

func (m *Motor) Close() {
	m.StopSoft()
	rpio.Close()
}

func (m *Motor) GetPWM() int {
	return m.pwmDuty
}

func (m *Motor) GetDirection() string {
	return m.direction
}

func (m *Motor) IsRunning() bool {
	return m.running
}

In der calibration.go befinden sich Routinen zur Kalibrierung der Anzeige

geany /home/pi/antenna-rotor/hardware/calibration.go
package hardware

import (
    "encoding/json"
    "log"
    "os"
)

type Calibration struct {
    MinVoltage float64 `json:"min_voltage"`
    MaxVoltage float64 `json:"max_voltage"`
}

// Standardkalibrierung (falls Datei fehlt)
func DefaultCalibration() *Calibration {
    return &Calibration{
        MinVoltage: 0.0,
        MaxVoltage: 3.3,
    }
}

// Speichert Kalibrierung in Datei
func (c *Calibration) Save(filename string) error {
    data, err := json.MarshalIndent(c, "", "  ")
    if err != nil {
        return err
    }
    return os.WriteFile(filename, data, 0644)
}

// Lädt Kalibrierung (oder erstellt Default)
func LoadCalibration(filename string) *Calibration {
    data, err := os.ReadFile(filename)
    if err != nil {
        log.Println("⚠️  Keine Kalibrierungsdatei gefunden, verwende Default.")
        return DefaultCalibration()
    }
    var c Calibration
    if err := json.Unmarshal(data, &c); err != nil {
        log.Println("⚠️  Fehler beim Lesen der Kalibrierung, verwende Default:", err)
        return DefaultCalibration()
    }
    return &c
}

Die Ausgabe des Befehls

tree -L 3 ~/antenna-rotor/

zeigt nun folgendes Bild

Hardcopy Verzeichnisstruktur mit Quellcode-Dateien

Jetzt müssen wir das Projekt in der Go-Programmierumgebung einrichten:

# In das Projektverzeichnis gehen
cd ~/antenna-rotor

# Das Projekt initialisieren
go mod init antenna-rotor

# Wir benötigen eine Bibliothek aus Github - das geben wir hiermit bekannt
go get //github.com

#Dateien go.mod und go.sum automatisch bereinigen
go mod tidy

Die Datei mit den Werten für die Endstellung des Rotors vorbelegen. Diese Werte sind nur für meine Bedingungen zutreffend ! Die Kalibrierung kann in der Weboberfläche durchgeführt werden.

geany /home/pi/antenna-rotor/calibration.json
{
  "min_voltage": 0.8514009826960052,
  "max_voltage": 0.6231440168462172
}

Jetzt können wir manuell das Programm zum ersten Mal starten. Da wir auf Port 80/443 zugreifen möchten, ist hier ein sudo notwendig. Später wird das nicht mehr notwendig sein, da in der Build.sh der ausführbare Datei der Zugriff auf die niedrigen Netzwerkports erlaubt wird durch die Zeile:

sudo setcap cap_net_bind_service=+ep /home/pi/antenna-rotor/antenna-rotor

Doch jetzt starten wir erstmal mit sudo davor:

sudo go run main.go

Wenn wir keine Fehlermeldungen erhalten, ist es nun an der Zeit, die ausführbare Datei zu erstellen.

sudo chmod +x build.sh
./build.sh

Die ausführbare Datei kann jetzt zum Test manuell gestartet werden:

./antenna-rotor

Sollte hier ein Problem mit Zugriffsberechtigung auf den Port 443 auftauchen, den setcap-Befehl nochmal von Hand ausführen:

sudo setcap cap_net_bind_service=+ep /home/pi/antenna-rotor/antenna-rotor

Und vom PC aus im Browser angezeigt werden:

http://raspirotor:8080/

Den Server im Hintergrund ausführen lassen

Mit STRG+C stoppen wir das manuell gestartete Programm um es als Hintergrunddienst einzurichten:

Die Datei rotor.service in das Verzeichnis der Systemdienste kopieren:

sudo cp /home/pi/antenna-rotor/rotor.service /etc/systemd/system/rotor.service

Damit systemd den neuen Dienst findet und startet, folgende Befehle ausführen lassen:

# System-Daemon neu laden
sudo systemctl daemon-reload

# Service aktivieren - damit startet er in Zukunft beim booten automatisch
sudo systemctl enable rotor.service

# Service sofort starten
sudo systemctl start rotor.service

# Status des neuen Service überprüfen
sudo systemctl status rotor.service

Die Statusanzeige mit STRG + C beenden und einen Reboot durchführen. Danach sollte automatisch die Rotorsteuerung im Browser des PC’s wieder aufrufbar sein.

sudo reboot

Um den Raspberry Pi vor dem Ausschalten ordentlich herunterzufahren, ist in der Weboberfläche eine entsprechende Schaltfläche vorhanden:

Hier noch ein paar Bilder der Hardware: