Amateurfunkstation aus der Ferne steuern

Dieses Projekt befindet sich noch in der Entwicklungsphase. Die Angaben hier stellen nur den aktuellen Zwischenstand dar und können sich deshalb fortlaufend ändern.

Aufgabenstellung

Zu einer Amateuerfunkanlage soll eine sichere Verbindung zur Steuerung des Antennenrotors und des Funkgerätes aufgebaut werden. Die serielle RS232-Schnittstelle des Antennenrotors soll dabei remote über TCP angesprochen werden. Hierzu soll ein VPN-Netzwerk aufgebaut werden. Es können sich also nur in diesem Netzwerk angemeldete Geräte gegenseitig sehen.

Softwarekomponenten

Mittels der Software Tailscale kann eine Verbindung zwischen zwei und mehr Geräten vermittelt werden. Damit kein externer Cloud-Server von Tailscale verwendet werden muss, läuft auf einem Raspberry Pi, der mit einer festen öffentlichen IP-Adresse ausgestattet ist, die Software headscale in Form eines Docker-Containers. Diese Software dient also als „Vermittlungsstelle“ für die Tailscale-Software auf den beteiligten Clients. Nachdem diese von Headscale die IP-Adressen der Gegenstelle erhalten haben, kommunizieren die Geräte direkt miteinander. Der Headscale-Server ist an dieser Kommunikation nicht mehr beteiligt.

Die serielle Schnittstelle wird durch die Software ser2net mit dem TCP-Netwerk verbunden und mit socat werden die Daten zwischen zwei IP-Adressen transportiert.

Übersicht der Topologie

[Internet]

▼ (91.137.72.107)
[Haupt-Raspberry Pi]

├── [Traefik (Netzwerk: proxy)] ────┬──── [Headscale-Container] (Port 8080, Domain: headscale.lang-dieter.de)
│ │
│ ▼ (Tailscale-VPN)
│ [Externer Raspberry Pi]
│ │
│ ▼
│ [socat/ser2net] ──── [Funkstation (RS232)]

└── [Andere Container]

[Anwender mit Client-Rechner Windows]

├── [SPID-Softwar Rotorsteuerung]
│ │
│ ▼ (Tailscale-VPN)

headscale am bestehenden Traefik-Server mit öffentlicher IP ergänzen

headscale als Container einrichten

Headscale wird auf dem bestehenden Rasperry Pi mit Traefik im Hintergrund aus zusätzlicher Container hinzugefügt.

Ich verwende folgende Verzeichnisstruktur:
/srv/containers/
├── traefik/ # Bestehende Traefik-Instanz (Netzwerk: proxy)
│ └── docker-compose.yml

├── headscale/ # Headscale-Instanz
│ ├── docker-compose.yml
│ ├── config/
│ │ ├── config.yaml
│ │ └── acls.yaml
│ └── data/

└── … # Andere Container

Inhalt der Datei /src/containers/headscale/docker-compose.yml:

services:
  headscale:
    image: headscale/headscale:latest
    container_name: headscale
    restart: unless-stopped
    read_only: true
    tmpfs:
      - /var/run/headscale
    ports:
      - target: 3478
        published: 3478
        protocol: udp
        mode: host
    volumes:
      - ./config:/etc/headscale:ro
      - ./data:/var/lib/headscale
    command: serve
    healthcheck:
      test: ["CMD", "headscale", "health"]
    networks:
      - proxy
    labels:
      - traefik.enable=true

      # HTTP-Router
      - traefik.http.routers.headscale-http.rule=Host(`headscale.lang-dieter.de`)
      - traefik.http.routers.headscale-http.entrypoints=web

      # HTTPS-Router
      - traefik.http.routers.headscale-https.rule=Host(`headscale.lang-dieter.de`)
      - traefik.http.routers.headscale-https.entrypoints=websecure
      - traefik.http.routers.headscale-https.tls=true
      - traefik.http.routers.headscale-https.tls.certresolver=tls_resolver
      - traefik.http.routers.headscale-https.tls.domains[0].main=headscale.lang-dieter.de

      # Middleware für DERP Websockets
      - traefik.http.middlewares.headscale-headers.headers.customrequestheaders.Connection=Upgrade
      - traefik.http.middlewares.headscale-headers.headers.customrequestheaders.Upgrade=websocket

      # Service-Konfiguration
      - traefik.http.services.headscale.loadbalancer.server.scheme=http
      - traefik.http.services.headscale.loadbalancer.server.port=8087

networks:
  proxy:
    external: true


Die Konfiguration wird in der Datei /srv/containers/headscale/config/config.yaml hinterlegt:

# =============================================================================
# HEADSCALE KONFIGURATION
# Angepasst für Traefik Reverse Proxy & Subdomain headscale.lang-dieter.de
# =============================================================================

# Die externe URL unter der Traefik Ihr Headscale erreichbar macht
server_url: https://headscale.lang-dieter.de

# Adressen, auf denen Headscale INNERHALB des Docker-Containers lauscht
listen_addr: 0.0.0.0:8087
metrics_listen_addr: 0.0.0.0:9097

# =============================================================================
# DATENBANK (SQLite)
# =============================================================================
database:
  type: sqlite
  sqlite:
    path: /var/lib/headscale/db.sqlite

# =============================================================================
# NETZWERK-PRÄFIXE (Interne VPN-IPs für Ihre Geräte)
# =============================================================================
prefixes:
  v4: 100.64.0.0/10
  v6: fd7a:115c:a1e0::/48

# =============================================================================
# DNS EINSTELLUNGEN
# =============================================================================
dns:
  magic_dns: false
  base_domain: net.lang-dieter.de
  override_local_dns: true
  nameservers:
    global:
      - 1.1.1.1
      - 8.8.8.8
  search_domains: []
  extra_records: []

# =============================================================================
# KRYPTOGRAFIE SCHLÜSSEL
# =============================================================================
private_key_path: /var/lib/headscale/private.key
noise:
  private_key_path: /var/lib/headscale/noise_private.key

# =============================================================================
# DERP (RELAY SERVER) EINSTELLUNGEN
# =============================================================================
derp:
  # Keine externen, öffentlichen Tailscale-Server abfragen
  urls: []  
  paths: []
  auto_update_enabled: false

  # Integrierten DERP-Server aktivieren
  server:
    enabled: true
    region_id: 901
    region_code: "raspi-relay"
    region_name: "Mein privater Raspi DERP Server"
    
    # WICHTIG: Auf 8088 geändert, um Konflikt mit dem Hauptdienst (8087) zu vermeiden!
    listen_addr: "0.0.0.0:8088"  
    
    # STUN läuft über UDP und wird in der Compose direkt nach außen gereicht
    stun_listen_addr: "0.0.0.0:3478"
    private_key_path: /var/lib/headscale/derp_server_private.key

Nun starten wir den Container mit:

docker compose up -d &

Firewall-Regeln am Host ergänzen

sudo ufw allow 3478/udp   # STUN für DERP
sudo ufw enable

Headscale-ACLs (Zugangskontrolle einrichten)

sudo nano /srv/containers/headscale/config/acls.yaml
acls:
  - action: accept
    src: ["100.64.0.0/10"]  # Erlaube alle Headscale-IPs
    dst: ["100.64.0.0/10:5000"]  # Erlaube Zugriff auf Port 5000

Headscale-Benutzer für den Bereich Schlüsselverwaltung anlegen

docker exec -it headscale headscale user create funkstation-admin

Liste der Headscale-Benutzer
anzeigen lassen, da im nächsten Befehl die User-ID benötigt wird (in unserem Fall die Nummer 1)

docker exec -it headscale headscale user list

Auth-Key für den externen Raspberry Pi (an der Funkstation) erzeugen:

docker exec -it headscale headscale preauthkey create --user 1 --expiration=24h --reusable

Den damit erzeugten Key kopieren wir uns, damit er auf dem externen Raspi eingefügt werden kann.

Eine Liste der API-Keys kann man sich wie folgt anzeigen lassen:

docker exec -it headscale headscale apikeys list

Arbeiten auf externen Rasperry Pi an der Funkstation

Verbindung mit headscale einrichten

Tailscale installieren und den Geräte-Key hinterlegen (ersetze <DEIN_AUTH_KEY> durch den generierten Key):

curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up --login-server=https://headscale.lang-dieter.de --auth-key=<DEIN_AUTH_KEY> --hostname=funkstation-pi

Wenn alles funktioniert, sollte folgender Befehl eine IP-Adresse im Bereich 100.64.x.y anzeigen:

tailscale status

TCP-to-Serial-Bridge am Funkgeräte-Pi auf Port 5000 zur seriellen Schnittstelle einrichten:

socat TCP-LISTEN:5000,fork /dev/ttyUSB0,raw,echo=0,baud=9600

Für Dauerbetrieb richten wir es als systemd-Dienst ein:

sudo nano /etc/systemd/system/tcp-serial-bridge.service

mit folgendem Inhalt:

[Unit]
Description=TCP to Serial Bridge for Funkstation
After=network.target

[Service]
ExecStart=/usr/bin/socat TCP-LISTEN:5000,fork /dev/ttyUSB0,raw,echo=0,baud=9600
Restart=always
User=root

[Install]
WantedBy=multi-user.target

Liste der Dienste aktualisieren und den neuen Dienst starten:

sudo systemctl daemon-reload
sudo systemctl enable tcp-serial-bridge
sudo systemctl start tcp-serial-bridge

Firewall-Regeln am Funkgeräte-Rechner ergänzen

sudo ufw allow 5000/tcp   # TCP-Port für Funkstation
sudo ufw enable

Software zur Spannungsanzeige und Ansteuerung Relaisplatine

Die Software zur Ansteuerung der Relaisplatine und Messung der 12-V Versorgungsspanne wurde in der Programmiersprache Golang entwickelt. Das ADS-1115 Modul kann maximal 3 V messen, wenn es mit einer Betriebsspannung von 3,3V betrieben wird.. Deshalb muss die zu überwachende Spannung durch einen Spannungsteiler reduziert werden. Zur Sicherheit wurde mit einer zu überwachenden Spannung von 15 V gerechnet.
Für das Verhältnis wählen wir Standardwiderstände aus der E12-Reihe (R1 gegen 12V, R2 gegen Masse)):

R1 = 12 kOhm, R2 = 3 kOhm
Das ergibt ein Teileverhältnis von 5 (R1 + R2 / R2)
Bei maximal 15 V liegen 3,0 V am Pin des ADS1115 an. Im Programm wird dann der gemessene Wert einfach mit 5 multipliziert.

Die verwendeten GPIO-Ports und andere Angaben, sollen flexibel in einer Konfigurationsdatei im json-Format verwaltet werden.

# Projektverzeichnis anlegen
mkdir -p spannung/web/static

und wir erhalten folgende Verzeichnisstruktur:
.
└── spannung
└── web
└── statik

In der Konfigurationsdatei hinterlegen wir diverse Werte zur Steuerung der Anwendung

  • web_port
    Portadresse, unter der wir die Anzeige der Messwerte im Browser aufrufen können
  • i2c_Bus
    Bezeichnung des i2c-Bus, an dem der Baustein ADS1115 angeschlossen ist
  • ads115_address
    Die am ADS1115 eingestellte Bus-Adresse des Bausteins
  • voltage_factor_1 und voltage_factor_2
    Berücksichtigung des vorgeschalteten Spannungsteilers um die tatsächlich gemessenen Spannungen am Chip-Eingang in den tatsächliche überwachten Spannungswert umzurechnen
  • relais-pins
    Definition der GPIO-Ports, an denen die Relais-Karte mit ihren Eingängen verdrahtet ist
Verwendete Pin-Belegung am Raspi
https://www.elektronik-kompendium.de/sites/raspberry-pi/1907101.htm
# Config-Datei erzeugen
geany spannung/config.json
{
  "web_port": ":8080",
  "i2c_bus": "/dev/i2c-1",
  "ads1115_address": 72,
  "voltage_factor_1": 5.0,
  "voltage_factor_2": 5.0,
  "relais_pins": [
    {"id": 1, "name": "Relais 1 (Hauptschalter)", "gpio": "GPIO22"},
    {"id": 2, "name": "Relais 2 (unbenutzt)", "gpio": "GPIO23"},
    {"id": 3, "name": "Relais 3 (unbenutzt)", "gpio": "GPIO24"},
    {"id": 4, "name": "Relais 4 (unbenutzt)", "gpio": "GPIO25"}
  ]
}

Passen wir die Konfiguration so an, dass Sie auch den internen Gain (Verstärkungsfaktor) des ADS1115 konfigurieren können, da wir für die Messung bis 3,0 V die passende Full-Scale-Range (FSR) von 4,096 V im Chip aktivieren müssen. Da Sie das Projekt auf einem Raspberry Pi betreiben, nutzen wir weiterhin periph.io, stellen das Modul jedoch explizit auf den Messbereich FourV (4,096 V max) ein, um die 16-Bit-Auflösung optimal für das 0–3V-Signal auszunutzen. Das Hauptprogramm ermittelt die Messwerte, stellt die Daten als Webserver im HTML-Format zur Verfügung und reagiert auf die Betätigung der Buttons auf dieser HTML-Seite durch Ein-/Ausschalten des jeweiligen Relais

geany spannung/main.go
package main

import (
	"encoding/json"
	"fmt"
	"log"
	"os"
	"sync"

	"spannung/web" // Importiert das neue eigene web-Paket

	"periph.io/x/conn/v3/gpio"
	"periph.io/x/conn/v3/gpio/gpioreg"
	"periph.io/x/conn/v3/i2c/i2creg"
	"periph.io/x/conn/v3/physic"
	"periph.io/x/devices/v3/ads1x15"
	"periph.io/x/host/v3"
)

type Config struct {
	WebPort        string       `json:"web_port"`
	I2CBus         string       `json:"i2c_bus"`
	ADS1115Address uint16       `json:"ads1115_address"`
	Factor1        float64      `json:"voltage_factor_1"`
	Factor2        float64      `json:"voltage_factor_2"`
	Relais         []RelaisConf `json:"relais_pins"`
}

type RelaisConf struct {
	ID    int    `json:"id"`
	Name  string `json:"name"`
	GPIO  string `json:"gpio"`
	State bool   `json:"-"`
}

var (
	config    Config
	adsDevice *ads1x15.Dev
	relaisMap = make(map[int]gpio.PinIO)
	mutex     sync.Mutex
// Diese Variablen werden durch -ldflags beim Kompilieren befüllt:
	Version   = "Entwicklung"
	BuildDate = "Unbekannt"
)

func main() {
	confFile, err := os.ReadFile("config.json")
	if err != nil {
		log.Fatalf("Fehler beim Lesen der config.json: %v", err)
	}
	if err := json.Unmarshal(confFile, &config); err != nil {
		log.Fatalf("Fehler beim Parsen der config.json: %v", err)
	}

	if _, err = host.Init(); err != nil {
		log.Fatalf("Hardware-Init fehlgeschlagen: %v", err)
	}

	bus, err := i2creg.Open(config.I2CBus)
	if err != nil {
		log.Fatalf("I2C Bus Fehler: %v", err)
	}

	opts := ads1x15.DefaultOpts
	opts.I2cAddress = config.ADS1115Address

	adsDevice, err = ads1x15.NewADS1115(bus, &opts)
	if err != nil {
		log.Fatalf("ADS1115 Init fehlgeschlagen: %v", err)
	}

	// GPIO-Pins registrieren
	for i, r := range config.Relais {
		pin := gpioreg.ByName(r.GPIO)
		if pin == nil {
			log.Fatalf("GPIO-Pin %s existiert nicht", r.GPIO)
		}
		if err := pin.Out(gpio.Low); err != nil {
			log.Fatalf("Pin-Konfiguration für %s fehlgeschlagen: %v", r.GPIO, err)
		}
		relaisMap[r.ID] = pin
		config.Relais[i].State = false
	}

	// Instanziierung des Webservers mit Übergabe der Callback-Funktionen
	server := web.NewServer(config.WebPort, readVoltageCallback, toggleRelaisCallback, getRelaisStateCallback, callbackGetVersion)


	fmt.Printf("Webserver gestartet auf Port %s\n", config.WebPort)
	log.Fatal(server.Start())
}

// Callback: Wird vom Webserver aufgerufen, um Kanäle zu messen
func readVoltageCallback(channel int) float64 {
	mutex.Lock()
	defer mutex.Unlock()

	var adsChannel ads1x15.Channel
	var factor float64

	if channel == 0 {
		adsChannel = ads1x15.Channel0
		factor = config.Factor1
	} else {
		adsChannel = ads1x15.Channel1
		factor = config.Factor2
	}

	pin, err := adsDevice.PinForChannel(adsChannel, physic.Volt*4096/1000, 128*physic.Hertz, 0)
	if err != nil {
		fmt.Printf("Fehler beim Erzeugen des Pins für Kanal %v: %v\n", channel, err)
		return 0.0
	}

	sample, err := pin.Read()
	if err != nil {
		fmt.Printf("Fehler beim Lesen von Kanal %v: %v\n", channel, err)
		return 0.0
	}

	voltValue := float64(sample.V) / 1000000000.0
	return voltValue * factor
}

// Callback: Wird vom Webserver aufgerufen, um ein Relais zu schalten
func toggleRelaisCallback(id int) {
	mutex.Lock()
	defer mutex.Unlock()

	pin, exists := relaisMap[id]
	if !exists {
		return
	}

	for i, rel := range config.Relais {
		if rel.ID == id {
			newState := !rel.State
			config.Relais[i].State = newState

			if newState {
				pin.Out(gpio.High)
			} else {
				pin.Out(gpio.Low)
			}
			break
		}
	}
}

// Callback: Wird vom Webserver aufgerufen, um den aktuellen UI-Status abzufragen
func getRelaisStateCallback() []web.RelaisData {
	mutex.Lock()
	defer mutex.Unlock()

	data := make([]web.RelaisData, len(config.Relais))
	for i, r := range config.Relais {
		data[i] = web.RelaisData{
			ID:    r.ID,
			Name:  r.Name,
			State: r.State,
		}
	}
	return data
}

// Versionangaben aus version.json in Webserver übernehmen
func callbackGetVersion() (string, string) {
	return Version, BuildDate
}

Im Unterverzeichns web liegt der mit go programmierte Webserver. Er stellt die Verbindung zwischen der Hardware und den Elementen auf der angezeigten Webseite dar.

geany spannung/web/server.go
package web

import (
	"html/template"
	"net/http"
	"strconv"
)

type RelaisData struct {
	ID    int
	Name  string
	State bool
}

// TemplateData erweitern um Version und Datum
type TemplateData struct {
	V1        float64
	V2        float64
	Relais    []RelaisData
	Version   string
	BuildDate string
}

type Server struct {
	port         string
	readVoltFunc func(channel int) float64
	toggleFunc   func(id int)
	getRelais    func() []RelaisData
	versionFunc  func() (string, string) // Neu hinzugefügt
}

//Server-Objekt
func NewServer(port string, readVolt func(int) float64, toggle func(int), getRelais func() []RelaisData, getVersion func() (string, string)) *Server {
	return &Server{
		port:         port,
		readVoltFunc: readVolt,
		toggleFunc:   toggle,
		getRelais:    getRelais,
		versionFunc:  getVersion,
	}
}

func (s *Server) Start() error {
	http.HandleFunc("/", s.handleIndex)
	http.HandleFunc("/toggle", s.handleToggle)
	return http.ListenAndServe(s.port, nil)
}

func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
	v1 := s.readVoltFunc(0)
	v2 := s.readVoltFunc(1)
	ver, date := s.versionFunc() // Version abfragen

	tmpl, err := template.ParseFiles("web/static/index.html")
	if err != nil {
		http.Error(w, "Template nicht gefunden: "+err.Error(), http.StatusInternalServerError)
		return
	}

	data := TemplateData{
		V1:        v1,
		V2:        v2,
		Relais:    s.getRelais(),
		Version:   ver,  // Daten an Template übergeben
		BuildDate: date, // Daten an Template übergeben
	}
	tmpl.Execute(w, data)
}

func (s *Server) handleToggle(w http.ResponseWriter, r *http.Request) {
	idStr := r.URL.Query().Get("id")
	id, err := strconv.Atoi(idStr)
	if err == nil {
		s.toggleFunc(id)
	}
	http.Redirect(w, r, "/", http.StatusSeeOther)
}

Im weiteren Unterverzeichnis /web liegen die HTML-Seiten für den Webserver.

geany spannung/web/static/index.html
<!DOCTYPE html>
<html>
<head>
    <title>Funkstation Steuerung</title>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <style>
        body { font-family: 'Segoe UI', Arial, sans-serif; text-align: center; background: #0f172a; color: #f8fafc; padding: 20px; }
        .container { max-width: 500px; margin: 0 auto; }
        .card { background: #1e293b; padding: 20px; margin: 15px 0; border-radius: 12px; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.3); border: 1px solid #334155; }
        .val { font-size: 2.5em; color: #38bdf8; font-weight: bold; font-family: monospace; }
        .grid { display: grid; grid-template-columns: 1fr; gap: 12px; margin-top: 15px; }
        .btn { width: 100%; padding: 14px; font-size: 1.1em; border: none; border-radius: 8px; cursor: pointer; font-weight: bold; transition: background 0.2s; color: white; }
        .btn-on { background: #ef4444; }
        .btn-on:hover { background: #dc2626; }
        .btn-off { background: #10b981; }
        .btn-off:hover { background: #059669; }
        .footer-nav { margin-top: 25px; font-size: 0.9em; color: #64748b; }
    </style>
    <meta http-equiv="refresh" content="3">
</head>
<body>
    <div class="container">
        <h1>U12V Funkstations-Monitor</h1>
        
        <div class="card">
            <h3>Spannungsebene 1</h3>
            <div class="val">{{printf "%.2f" .V1}} V</div>
        </div>
        <div class="card">
            <h3>Spannungsebene 2</h3>
            <div class="val">{{printf "%.2f" .V2}} V</div>
        </div>

        <div class="card">
            <h3>Relaissteuerung</h3>
            <div class="grid">
                {{range .Relais}}
                <a href="/toggle?id={{.ID}}" style="text-decoration: none;">
                    <button class="btn {{if .State}}btn-on{{else}}btn-off{{end}}">
                        {{.Name}}: {{if .State}}AKTIV (Ausschalten){{else}}INAKTIV (Einschalten){{end}}
                    </button>
                </a>
                {{end}}
            </div>
        </div>
        <div class="footer-nav">
            Aktualisiert alle 3 Sekunden automatisch<br>
            <span style="font-size: 0.8em; color: #475569; margin-top: 5px; display: block;">
                Version v{{.Version}} ({{.BuildDate}})
            </span>
        </div>

    </div>
</body>
</html>

Erster Test des Programmcodes

# In das Hauptverzeichnis des Projektes gehen
cd spannung

# Interne Go-Strukturen aktualisieren
go get periph.io/x/devices/v3/ads1x15
go mod tidy

# Hauptprogramm starten
go run main.go

Als Ergebnis erhalten Sie die Ausgabe
Webserver gestartet auf Port :8080

Binärdateien aus Quellcode erstellen

Als nächstes können wir unser Programm kompilieren lassen. Wir verwenden hierzu die Datei build.sh. Dabei wird auch eine Datei namens spannung.service erzeugt. Diese Datei kann zur Einrichtung der Anwendung als automatischen Systemdienst verwendet werden.

# Batchdatei zum Bauen der Anwendung erzeugen
geany build.sh
#!/bin/bash
set -e

# 1. Aktuellen Linux-Benutzer ermitteln
CURRENT_USER=$(whoami)
USER_HOME=$HOME

# 2. Version & Datum aus version.json auslesen
if [ -f version.json ]; then
    VERSION=$(jq -r '.version' version.json)
    DATE=$(jq -r '.date' version.json)
else
    VERSION="1.0.0"
    DATE=$(date +'%Y-%m-%d')
fi

echo "🔧 Baue Spannung-Monitor v$VERSION ($DATE)"

# KORREKTUR: Einfache Anführungszeichen fangen die Leerzeichen für den Go-Linker ab
LDFLAGS="-X 'main.Version=$VERSION' -X 'main.BuildDate=$DATE'"

# Multi-Plattform Builds ausführen
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$LDFLAGS" -o spannung-amd64 .
echo "✅ Fertig gebaut: ./spannung-amd64"

CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$LDFLAGS" -o spannung-arm64 .
echo "✅ Fertig gebaut: ./spannung-arm64"

CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -ldflags "$LDFLAGS" -o spannung-arm32_7 .
echo "✅ Fertig gebaut: ./spannung-arm32_7"

CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -ldflags "$LDFLAGS" -o spannung-arm32_6 .
echo "✅ Fertig gebaut: ./spannung-arm32_6"

# Dateiberechtigungen setzen
chmod +x ./spannung-*

# Zielverzeichnis 'app' neu erstellen
rm -rf app
mkdir app

# Binaries, Konfiguration und Web-Ordner kopieren
cp spannung-* ./app/
cp config.json ./app/
cp version.json ./app/
cp web ./app/ -r

# 3. Die Service-Datei universell generiert (nutzt systemd %h/%u Specifiers)
cat << 'EOF' > ./app/spannung.service
# ==============================================================================
# KURZANLEITUNG ZUR INSTALLATION UND VERWALTUNG
# ==============================================================================
# 1. Datei kopieren nach: /etc/systemd/system/spannung.service
# 2. Systemd neu laden:   sudo systemctl daemon-reload
# 3. Autostart aktivieren: sudo systemctl enable spannung.service
# 4. Dienst starten:      sudo systemctl start spannung.service
# ==============================================================================

[Unit]
Description=Spannung 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 -E "^40:" | grep -q -E "\b48\b" && exit 0; echo "warte auf ADS1115..."; sleep 2; done; exit 1'
ExecStart=%h/spannung/app/spannung-arm32_7
WorkingDirectory=%h/spannung/app
User=%u
Restart=on-failure
RestartSec=5s
StartLimitIntervalSec=60s
StartLimitBurst=3

StandardOutput=append:%h/spannung/app/spannung.log
StandardError=append:%h/spannung/app/spannung.log

[Install]
WantedBy=multi-user.target
EOF

echo "📝 Service-Datei universell generiert (nutzt systemd %h/%u Specifiers)"
echo "📂 Inhalt des fertigen app-Verzeichnisses:"
ls -la app
# Die Batchdatei ausführbar machen
sudo chmod +x build.sh

#Den Buildvorgang starten
./build.sh

Nun steht die fertige Anwendung im Unterverzeichnis app zur Verfügung:

und kann mit rsync auf den Zielrechner übertragen werden.

 rsync -avz ./app/ benutzer@zielrechner:~/spannung/

Auf dem Zielrechner kann der Dienst wie folgt eingerichetet werden:

# 1. Datei kopieren nach: /etc/systemd/system/spannung.service
sudo cp ~/spannung/spannung.service /etc/systemd/system/

# 2. Systemd neu laden:
sudo systemctl daemon-reload

# 3. Autostart aktivieren:
sudo systemctl enable spannung.service

# 4. Dienst starten:
sudo systemctl start spannung.service

Aufruf der Weboberfläche im Tailscale-Netzwerk

Auf einem Client, der ebenfalls mit dem oben eingerichteten Tailscale-Netzwerk verbunden ist, können wir damit die Weboberfläche unseres Raspi aufrufen. Angenommen es wurde ihm die Adresse 100.64.0.1. zugewiesen:

http://100.64.0.1:8080

Quickstart

Spannungsüberwachung einrichten

# I2C-Schnittstelle freischalten
sudo raspi-config
# Menüfolge 3 -> I5 -> Schnittstelle aktivieren

#Eigenschaften anzeigen lassen
i2cdetect -y 1

#Falls i2cdetect fehlt -nachinstallieren
sudo apt install i2c-tools

# app herunterladen
wget https://www.lang-dieter.de/wp/wp-content/uploads/2026/06/app_spannung.zip

# Entpacken des ZIP-Archives
unzip app_spannung.zip

# Prüfung, welche ausführbare Datei zum Betriebssystem passt
cd app_spannung

# hierzu einfach zum Test die passende Datei aufrufen.
./spannung-arm64

# mit CTL-C das aufgerufenen Programm beenden

Prüfen, ob in der Datei spannung.service die korrekte Datei gestartet wird.

Den Dienst einrichten:

sudo cp spannung.service /etc/systemd/spannung.service
sudo systemctl daemon-reload
sudo systemctl enable spannung.service
sudo systemctl start spannung.service
sudo systemctl status spannung.service