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

Besonderheit:
Von den zu schaltenden Relais war nur eines mit dem GPIO des Raspberry Pi verbunden. Die anderen Relais befanden sich auf einer mittels USB-Anschluß verbundenen Relaisplatine von Abacom.
https://www.electronic-software-shop.com/hardware/usb-relaiskarte-lrb-8-fach.html

Von den dort vorhanden 8 Relais werden in diesem Projekt nur 6 verwendet. Die restlichen wurden in der Config.json als Reserve beschriftet. Dadurch werden sie von unserer Software automatisch als inaktiv behandelt.

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
  • ssl_cert
    vollständiger Dateiname Zertifikat für Webserver
  • ssl_key
    vollständiger Dateiname für Zertifikatsschlüssel-Datei
  • i2c_Bus
    Bezeichnung des i2c-Bus, an dem der Baustein ADS1115 angeschlossen ist
  • ads1115_address_1
    Die am ADS1115 eingestellte Bus-Adresse des ersten Bausteins
  • ads1115_address_2
    Die am zweiten ADS1115 eingestellte Bus-Adresse
  • name_adc1_ch0
    Bezeichnung für Anzeige des Spannugnswertes des ersten Kanals vom ersten Baustein
  • factor_adc1_ch0
    Spannungsfactor für den ersten Kanal des ersten Bausteines.
    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",
  "ssl_cert": "cert.pem",
  "ssl_key": "key.pem",
   "i2c_bus": "1",
  "ads1115_address_1": 72,
  "ads1115_address_2": 73,
  "name_adc1_ch0": "23cm",
  "factor_adc1_ch0": 2.0,
  "name_adc1_ch1": "13 cm",
  "factor_adc1_ch1": 2.0,
  "name_adc1_ch2": "9 cm",
  "factor_adc1_ch2": 2.0,
  "name_adc1_ch3": "6 cm",
  "factor_adc1_ch3": 2.0,
  "name_adc2_ch0": "3 cm",
  "factor_adc2_ch0": 2.0,
  "relais_pins": [
    {"id": 1, "name": "Relais 1 (230V Hauptschalter)", "gpio": "GPIO12"},
    {"id": 2, "name": "USB-Relais 1 (Reserve)", "gpio": "USB_CH1"},
    {"id": 3, "name": "USB-Relais 2 23 cm", "gpio": "USB_CH2"},
    {"id": 4, "name": "USB-Relais 3 13 cm", "gpio": "USB_CH3"},
    {"id": 5, "name": "USB-Relais 4  9 cm", "gpio": "USB_CH4"},
    {"id": 6, "name": "USB-Relais 5  6 cm", "gpio": "USB_CH5"},
    {"id": 7, "name": "USB-Relais 6 3 cm", "gpio": "USB_CH6"},
    {"id": 8, "name": "USB-Relais 7 (Reserve)", "gpio": "USB_CH7"},
    {"id": 9, "name": "USB-Relais 8 (Reserve)", "gpio": "USB_CH8"}
  ]
}

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"
	"strings"
	"sync"
	"os/signal"
	"syscall"

	"spannung/web" // Importiert Ihr eigenes web-Paket

	"github.com/google/gousb"
	"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"`
	SSLCert         string       `json:"ssl_cert"`
	SSLKey          string       `json:"ssl_key"`
	I2CBus          string       `json:"i2c_bus"`
	ADS1115Address1 uint16       `json:"ads1115_address_1"`
	ADS1115Address2 uint16       `json:"ads1115_address_2"`
	NameAdc1Ch0     string       `json:"name_adc1_ch0"`
	FactorAdc1Ch0   float64      `json:"factor_adc1_ch0"`
	NameAdc1Ch1     string       `json:"name_adc1_ch1"`
	FactorAdc1Ch1   float64      `json:"factor_adc1_ch1"`
	NameAdc1Ch2     string       `json:"name_adc1_ch2"`
	FactorAdc1Ch2   float64      `json:"factor_adc1_ch2"`
	NameAdc1Ch3     string       `json:"name_adc1_ch3"`
	FactorAdc1Ch3   float64      `json:"factor_adc1_ch3"`
	NameAdc2Ch0     string       `json:"name_adc2_ch0"`
	FactorAdc2Ch0   float64      `json:"factor_adc2_ch0"`
	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
	adsDevice1 *ads1x15.Dev
	adsDevice2 *ads1x15.Dev
	relaisMap  = make(map[int]gpio.PinIO)
	mutex      sync.Mutex
	Version    = "Entwicklung"
	BuildDate  = "Unbekannt"
)

func main() {
	// 1. Version aus Datei laden, falls vorhanden
	loadVersionInfo()

	// 2. Konfigurationsdatei einlesen
	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)
	}

	// 3. Periph Hardware initialisieren
	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)
	}

	// 4. Ersten ADS1115 initialisieren
	opts1 := ads1x15.DefaultOpts
	opts1.I2cAddress = config.ADS1115Address1
	adsDevice1, err = ads1x15.NewADS1115(bus, &opts1)
	if err != nil {
		log.Fatalf("ADS1115 #1 Init fehlgeschlagen: %v", err)
	}

	// 5. Zweiten ADS1115 initialisieren
	opts2 := ads1x15.DefaultOpts
	opts2.I2cAddress = config.ADS1115Address2
	adsDevice2, err = ads1x15.NewADS1115(bus, &opts2)
	if err != nil {
		log.Fatalf("ADS1115 #2 Init fehlgeschlagen: %v", err)
	}

	// 6. Internen Software-Zustand für ALLE Relais VORAB auf AUS (false) setzen
	for i := range config.Relais {
		config.Relais[i].State = false
	}

	// 7. GPIO-Pins registrieren (ID 1 ist der 230V Hauptschalter am Raspberry Pi)
	for _, r := range config.Relais {
		if r.ID == 1 {
			pin := gpioreg.ByName(r.GPIO)
			if pin == nil {
				log.Fatalf("GPIO-Pin %s existiert nicht", r.GPIO)
			}
			// Hauptschalter hardwareseitig sofort sicher ausschalten
			if err := pin.Out(gpio.Low); err != nil {
				log.Fatalf("Pin-Konfiguration für %s fehlgeschlagen: %v", r.GPIO, err)
			}
			relaisMap[r.ID] = pin
		}
	}

	// 8. SICHERHEITS-START: Alle USB-Relais initial zwingend ausschalten
	if err := updateUsbRelaysStrict(); err != nil {
		log.Fatalf("[❌] KRITISCHER FEHLER: USB-Relais-Reset beim Start fehlgeschlagen! Programm gestoppt: %v", err)
	}

	// NEU: Signal-Handling für sauberes Herunterfahren einrichten
	sigChan := make(chan os.Signal, 1)
	signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
	
	go func() {
		<-sigChan
		fmt.Println("\n[⚠️] Programm-Abbruch erkannt! Schalte Relais 1 (Hauptschalter) sicher aus...")
		mutex.Lock()
		if pin, exists := relaisMap[1]; exists {
			_ = pin.Out(gpio.Low)
		}
		mutex.Unlock()
		os.Exit(0)
	}()

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

	fmt.Printf("Verschlüsselter HTTPS-Webserver gestartet auf Port %s...\n", config.WebPort)
	
	// ÜBERGABE DER ZERTIFIKATE AUS DER CONFIG AN DIE NEUE START-METHODE
	log.Fatal(server.Start(config.SSLCert, config.SSLKey))
}

// Hilfsfunktion zum Laden der version.json
func loadVersionInfo() {
	type VersionInfo struct {
		Version   string `json:"version"`
		BuildDate string `json:"build_date"`
	}

	vFile, err := os.ReadFile("version.json")
	if err == nil {
		var vInfo VersionInfo
		if err := json.Unmarshal(vFile, &vInfo); err == nil {
			Version = vInfo.Version
			BuildDate = vInfo.BuildDate
		}
	}
}

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

	var targetDevice *ads1x15.Dev
	var adsChannel ads1x15.Channel
	var factor float64

	switch channel {
	case 0:
		targetDevice = adsDevice1
		adsChannel = ads1x15.Channel0
		factor = config.FactorAdc1Ch0
	case 1:
		targetDevice = adsDevice1
		adsChannel = ads1x15.Channel1
		factor = config.FactorAdc1Ch1
	case 2:
		targetDevice = adsDevice1
		adsChannel = ads1x15.Channel2
		factor = config.FactorAdc1Ch2
	case 3:
		targetDevice = adsDevice1
		adsChannel = ads1x15.Channel3
		factor = config.FactorAdc1Ch3
	case 4:
		targetDevice = adsDevice2
		adsChannel = ads1x15.Channel0
		factor = config.FactorAdc2Ch0
	default:
		fmt.Printf("Ungültiger Kanal abgefragt: %v\n", channel)
		return 0.0
	}

	pin, err := targetDevice.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
	
	//Unterdrückt Rauschen und negative Restspannungen bis einschließlich -0.01 V sowie kleine positive Offsets
	if voltValue >= -0.011 && voltValue <= 0.011 {
		return 0.0
	}
	return voltValue * factor
}

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

	for i := range config.Relais {
		if config.Relais[i].ID == id {
			
			// 1. Sperre: Prüfen auf das Wort "Reserve"
			if strings.Contains(strings.ToLower(config.Relais[i].Name), "reserve") {
				fmt.Printf("[⚠️] Schalten blockiert: '%s' (ID %d) ist als Reserve geschützt!\n", config.Relais[i].Name, id)
				return 
			}

			// Zustand des ausgewählten Relais umkehren
			config.Relais[i].State = !config.Relais[i].State

			if id == 1 {
				// Hardware-Schaltung Raspberry Pi GPIO (Hauptschalter bleibt unabhängig)
				pin, exists := relaisMap[id]
				if exists {
					if config.Relais[i].State {
						pin.Out(gpio.High)
					} else {
						pin.Out(gpio.Low)
					}
				}
			} else {
				// ERWEITERUNG: Gegenseitige Verriegelung für USB-Relais
				if config.Relais[i].State {
					for j := range config.Relais {
						if config.Relais[j].ID != id && config.Relais[j].ID >= 2 {
							config.Relais[j].State = false
						}
					}
					fmt.Printf("[🔄] Exklusiv-Modus: Relais ID %d aktiviert. Alle anderen USB-Relais wurden ausgeschaltet.\n", id)
				}

				// Standard-Schaltvorgang (Fehler werfen im UI-Log, blockiert aber nicht den Server)
				if err := updateUsbRelaysStrict(); err != nil {
					fmt.Printf("[❌] Fehler beim Schalten der USB-Relais: %v\n", err)
				}
			}
			break
		}
	}
}

// Optimierte Hilfsfunktion: Schaltet die Abacom-Karte und gibt Fehler an den Aufrufer zurück
func updateUsbRelaysStrict() error {
	fmt.Println("\n--- [USB ABACOM SCHALTVORGANG] ---")
	ctx := gousb.NewContext()
	defer ctx.Close()

	dev, err := ctx.OpenDeviceWithVIDPID(0x1a86, 0x5512)
	if err != nil {
		return fmt.Errorf("USB-Fehler beim Öffnen des Geräts: %w", err)
	}
	if dev == nil {
		return fmt.Errorf("Abacom-Karte wurde am USB-Port nicht gefunden")
	}
	defer dev.Close()

	_ = dev.SetAutoDetach(true)

	cfg, err := dev.Config(1)
	if err != nil {
		return fmt.Errorf("Konfigurationsfehler: %w", err)
	}
	defer cfg.Close()

	intf, err := cfg.Interface(0, 0)
	if err != nil {
		return fmt.Errorf("Interface-Fehler: %w", err)
	}
	defer intf.Close()

	outEP, err := intf.OutEndpoint(2)
	if err != nil {
		return fmt.Errorf("Endpunkt-Fehler: %w", err)
	}

	// Bitmaske berechnen (ID 2 -> Bit 0 bis ID 9 -> Bit 7)
	var bitmask byte = 0x00
	for _, r := range config.Relais {
		if r.ID == 1 {
			continue
		}
		if r.State && r.ID >= 2 && r.ID <= 9 {
			shiftWidth := r.ID - 2
			bitmask |= (1 << shiftWidth)
		}
	}

	// Konstanten für den CH341A-Chip
	const (
		ch341aSetOutput = 0xA1
		LATCH           = 0x01
		CLK             = 0x08
		DATA            = 0x20
	)

	// Anonyme Hilfsfunktion für USB-Anweisungen
	sendOutput := func(dataByte byte) {
		msg := []byte{
			ch341aSetOutput,
			0x6a, 0x1f, 0x00, 0x10,
			dataByte,
			0x3f, 0x00, 0x00, 0x00, 0x00,
		}
		_, _ = outEP.Write(msg)
	}

	fmt.Printf("[⚙️] Sende Bitmaske an Schieberegister: %08b\n", bitmask)

	// --- Protokoll-Sequenz ---
	sendOutput(0x00) // Latch initial auf Low halten

	for i := 0; i < 8; i++ {
		if (bitmask & (1 << (7 - i))) != 0 {
			sendOutput(DATA)
			sendOutput(CLK | DATA)
			sendOutput(DATA)
		} else {
			sendOutput(0x00)
			sendOutput(CLK)
			sendOutput(0x00)
		}
	}
	sendOutput(0x00) // Leitungen leeren

	sendOutput(LATCH)
	sendOutput(0x00)

	fmt.Println("[✅] ERFOLG! Signal-Bytes an Schieberegister übergeben.")
	fmt.Println("--- [USB DEBUG ENDE] ---\n")
	return nil
}

// Callback: Wird vom Webserver aufgerufen, um den aktuellen UI-Status abzufragen
func getRelaisStateCallback() []web.RelaisData {
	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
}

// Callback: Liefert die Namen der Kanäle an das Web-Paket
func getVoltNamesCallback() []string {
	return []string{
		config.NameAdc1Ch0,
		config.NameAdc1Ch1,
		config.NameAdc1Ch2,
		config.NameAdc1Ch3,
		config.NameAdc2Ch0,
	}
}

// Callback: Wird vom Webserver aufgerufen, um Version und Build-Datum zu erhalten
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 (
	"encoding/json"
	"html/template"
	"net/http"
	"strconv"
)

type RelaisData struct {
	ID    int    `json:"id"`
	Name  string `json:"name"`
	State bool   `json:"state"`
}

type StatusResponse struct {
	V1        float64      `json:"v1"`
	V2        float64      `json:"v2"`
	V3        float64      `json:"v3"`
	V4        float64      `json:"v4"`
	V5        float64      `json:"v5"`
	Names     []string     `json:"names"` // Neu: Sendet die Namen als Array mit
	Relais    []RelaisData `json:"relais"`
	Version   string       `json:"version"`
	BuildDate string       `json:"build_date"`
}

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

func NewServer(port string, readVolt func(int) float64, toggle func(int), getRelais func() []RelaisData, getVersion func() (string, string), getNames func() []string) *Server {
	return &Server{
		port:         port,
		readVoltFunc: readVolt,
		toggleFunc:   toggle,
		getRelais:    getRelais,
		versionFunc:  getVersion,
		getNamesFunc: getNames, // Neu hinzugefügt
	}
}

func (s *Server) Start(certFile, keyFile string) error {
	http.HandleFunc("/", s.handleIndex)
	http.HandleFunc("/api/status", s.handleAPIStatus)
	http.HandleFunc("/api/toggle", s.handleAPIToggle)
	return http.ListenAndServeTLS(s.port, certFile, keyFile, nil)
}

func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
	ver, date := s.versionFunc()
	tmpl, err := template.ParseFiles("web/static/index.html")
	if err != nil {
		http.Error(w, "Template nicht gefunden: "+err.Error(), http.StatusInternalServerError)
		return
	}

	data := StatusResponse{
		Names:     s.getNamesFunc(), // Namen für das erste Rendern bereitstellen
		Relais:    s.getRelais(),
		Version:   ver,
		BuildDate: date,
	}
	tmpl.Execute(w, data)
}

func (s *Server) handleAPIStatus(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "application/json")
	
	ver, date := s.versionFunc()
	resp := StatusResponse{
		V1:        s.readVoltFunc(0),
		V2:        s.readVoltFunc(1),
		V3:        s.readVoltFunc(2),
		V4:        s.readVoltFunc(3),
		V5:        s.readVoltFunc(4),
		Names:     s.getNamesFunc(),
		Relais:    s.getRelais(),
		Version:   ver,
		BuildDate: date,
	}
	json.NewEncoder(w).Encode(resp)
}

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

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; }
        /* Maximale Breite erhöht, um Platz für das zweispaltige Layout zu bieten */
        .container { max-width: 1000px; margin: 0 auto; }
        
        /* Das neue Haupt-Layout: Links Relais, rechts Spannungen */
        .main-layout {
            display: grid;
            grid-template-columns: 1fr 1fr;
            gap: 20px;
            align-items: start;
            text-align: left;
        }
        
        /* Spalten-Container für korrekte Ausrichtung */
        .layout-col {
            display: flex;
            flex-direction: column;
        }

        .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; text-align: center; }
        .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, transform 0.1s; color: white; }
        .btn:active { transform: scale(0.98); }
        .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; clear: both; }

        /* Responsive Anpassung: Schaltet auf Smartphones automatisch untereinander um */
        @media (max-width: 768px) {
            .main-layout { grid-template-columns: 1fr; gap: 0; }
        }
    </style>
</head>
<body>
    <div class="container">
        <h1>Funkstations-Monitor</h1>
        
        <div class="main-layout">
            <!-- LINKE SPALTE: Relaissteuerung -->
            <div class="layout-col">
                <div class="card">
                    <h3>Relaissteuerung</h3>
                    <div class="grid">
                        {{range .Relais}}
                        <button class="btn btn-off" id="relais-btn-{{.ID}}" onclick="toggleRelais({{.ID}})">
                            {{.Name}}: Laden...
                        </button>
                        {{end}}
                    </div>
                </div>
            </div>

            <!-- RECHTE SPALTE: Spannungsanzeigen -->
            <div class="layout-col">
                <div class="card">
                    <h3>{{index .Names 0}}</h3>
                    <div class="val" id="v1">--.-- V</div>
                </div>

                <div class="card">
                    <h3>{{index .Names 1}}</h3>
                    <div class="val" id="v2">--.-- V</div>
                </div>

                <div class="card">
                    <h3>{{index .Names 2}}</h3>
                    <div class="val" id="v3">--.-- V</div>
                </div>

                <div class="card">
                    <h3>{{index .Names 3}}</h3>
                    <div class="val" id="v4">--.-- V</div>
                </div>

                <div class="card">
                    <h3>{{index .Names 4}}</h3>
                    <div class="val" id="v5">--.-- V</div>
                </div>
            </div>
        </div>

        <div class="footer-nav">
            Live-Aktualisierung aktiv <br>
            <span style="font-size: 0.8em; color: #475569; margin-top: 5px; display: block;">
                Version {{.Version}} ({{.BuildDate}})
            </span>
        </div>
    </div>

    <script>
        function updateUI(data) {
            document.getElementById('v1').innerText = data.v1.toFixed(2) + ' V';
            document.getElementById('v2').innerText = data.v2.toFixed(2) + ' V';
            document.getElementById('v3').innerText = data.v3.toFixed(2) + ' V';
            document.getElementById('v4').innerText = data.v4.toFixed(2) + ' V';
            document.getElementById('v5').innerText = data.v5.toFixed(2) + ' V';

            data.relais.forEach(r => {
                const btn = document.getElementById(`relais-btn-${r.id}`);
                if (btn) {
                    if (r.name && r.name.toLowerCase().includes('reserve')) {
                        btn.className = 'btn btn-off';
                        btn.innerText = `${r.name}: GESPERRT`;
                        btn.disabled = true;
                        btn.style.opacity = '0.3';
                        btn.style.cursor = 'not-allowed';
                        return;
                    }

                    if (r.state) {
                        btn.className = 'btn btn-on';
                        btn.innerText = `${r.name}: AKTIV (Ausschalten)`;
                    } else {
                        btn.className = 'btn btn-off';
                        btn.innerText = `${r.name}: INAKTIV (Einschalten)`;
                    }
                }
            });
        }

        function loadStatus() {
            fetch('/api/status')
                .then(response => response.json())
                .then(data => updateUI(data))
                .catch(err => console.error('Fehler beim Laden des Status:', err));
        }

        function toggleRelais(id) {
            fetch(`/api/toggle?id=${id}`)
                .then(response => response.json())
                .then(data => updateUI(data))
                .catch(err => console.error('Fehler beim Schalten:', err));
        }

        loadStatus();
        setInterval(loadStatus, 2000);
    </script>
</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. Diese Datei ist für den Build-Prozess auf einem Ubuntu-Rechner. Dafür müssen die C-Bibliotheken für ARM-Prozessoren bereitgesstellt werden, damit di benötigt libusb compiliert werden kann. Wenn das Script auf einem Raspberry läuft, ist dies nicht notwendig.

Weiterhin wird bei jedem Build-Vorgang ein neues Zertifikat für den HTTPS-Server erzeugt.

# 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 $VERSION ($DATE) für Raspberry Pi 3 & 4"

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

# Zielverzeichnis 'app' bereinigen und neu erstellen
rm -rf app spannung-arm*
mkdir app

# --- BUILD-PROZESS MIT CGO ---

# Definiere die Pfade zu den lokalen sysroots
SYSROOT_64="$USER_HOME/spannung/sysroot-arm64"
SYSROOT_32="$USER_HOME/spannung/sysroot-arm32"

# 1. Für 64-Bit Raspberry Pi (Standard bei Pi 4 und neueren Pi 3 OS)
echo "📦 Kompiliere für ARM64 (64-Bit)..."
CGO_LDFLAGS="-L$SYSROOT_64/lib/aarch64-linux-gnu -L$SYSROOT_64/usr/lib/aarch64-linux-gnu -Wl,-rpath-link=$SYSROOT_64/lib/aarch64-linux-gnu -Wl,-rpath-link=$SYSROOT_64/usr/lib/aarch64-linux-gnu" \
CGO_ENABLED=1 GOOS=linux GOARCH=arm64 CC=aarch64-linux-gnu-gcc \
go build -ldflags "$LDFLAGS" -o ./app/spannung-arm64 .
echo "✅ Fertig gebaut: ./app/spannung-arm64"

# 2. Für 32-Bit Raspberry Pi (Klassisch für Pi 3 / ältere OS)
echo "📦 Kompiliere für ARM32 v7 (32-Bit)..."
CGO_LDFLAGS="-L$SYSROOT_32/lib/arm-linux-gnueabihf -L$SYSROOT_32/usr/lib/arm-linux-gnueabihf -Wl,-rpath-link=$SYSROOT_32/lib/arm-linux-gnueabihf -Wl,-rpath-link=$SYSROOT_32/usr/lib/arm-linux-gnueabihf" \
CGO_ENABLED=1 GOOS=linux GOARCH=arm GOARM=7 CC=arm-linux-gnueabihf-gcc \
go build -ldflags "$LDFLAGS" -o ./app/spannung-arm32_7 .
echo "✅ Fertig gebaut: ./app/spannung-arm32_7"

# Dateiberechtigungen im Zielordner setzen
chmod +x ./app/spannung-*

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

# 3. Die Service-Datei mit Platzhaltern generieren
cat << 'EOF' > ./app/spannung.service
[Unit]
Description=Spannung Control Service
After=network-online.target i2c.service
Wants=network-online.target
StartLimitIntervalSec=60s
StartLimitBurst=3

[Service]
Type=simple
# Vorprüfung ALS ROOT ausführen
ExecStartPre=/bin/bash -c 'for i in {1..5}; do /usr/sbin/i2cdetect -y 1 | grep -q 48 && /usr/sbin/i2cdetect -y 1 | grep -q 49 && exit 0; sleep 2; done; exit 1'

ExecStart={{TARGET_DIR}}/{{BINARY}}
WorkingDirectory={{TARGET_DIR}}
User={{TARGET_USER}}
Restart=on-failure
RestartSec=5s

# KORREKTUR: Verhindert, dass STRG+C über SSH den Dienst beim Beenden des Logs killt
KillMode=process

StandardOutput=append:{{TARGET_DIR}}/spannung.log
StandardError=append:{{TARGET_DIR}}/spannung.log

[Install]
WantedBy=multi-user.target
EOF


# 4. Ein automatisches Installations-Skript für den Pi im app-Ordner erstellen
cat << 'EOF' > ./app/install.sh
#!/bin/bash
set -e

echo "🚀 Starte automatische Einrichtung des Spannung-Monitors..."

# Pfade und Benutzer auf dem aktuellen Pi ermitteln
PI_DIR=$(pwd)
PI_USER=$(whoami)

# Architektur des Betriebssystems prüfen
ARCH=$(uname -m)
if [ "$ARCH" = "aarch64" ]; then
    BINARY="spannung-arm64"
    echo "🎯 64-Bit OS erkannt. Nutze: $BINARY"
else
    BINARY="spannung-arm32_7"
    echo "🎯 32-Bit OS erkannt. Nutze: $BINARY"
fi

# Platzhalter in der Service-Datei direkt auf dem Pi ersetzen
sed -i "s|{{TARGET_DIR}}|$PI_DIR|g" spannung.service
sed -i "s|{{TARGET_USER}}|$PI_USER|g" spannung.service
sed -i "s|{{BINARY}}|$BINARY|g" spannung.service

echo "📝 Service-Datei wurde erfolgreich für dieser Pi konfiguriert."

# Service in das Systemverzeichnis kopieren und aktivieren
echo "⚙️ Kopiere Service-Datei nach /etc/systemd/system/..."
sudo cp spannung.service /etc/systemd/system/spannung.service
sudo systemctl daemon-reload
sudo systemctl enable spannung.service

echo "▶️ Starte den Dienst..."
sudo systemctl restart spannung.service

echo "✅ Fertig! Der Spannung-Monitor läuft und der Autostart ist aktiv."
EOF

chmod +x ./app/install.sh

echo "📝 Service-Datei und install.sh generiert."
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:

Zum automatischen Ablauf er Installation vom Entwicklungs-PC aus, hier das passend Script.
Aufruf mit ./deploy.sh User Rechnername

geany deploy.sh
#!/bin/bash
set -e

# --- PARAMETER-PRÜFUNG ---
if [ $# -lt 2 ]; then
    echo "❌ Fehler: Fehlende Argumente."
    echo "Verwendung: $0 <user> <host>"
    echo "Beispiel:   $0 afu 100.64.0.4"
    exit 1
fi

PI_USER="$1"
PI_HOST="$2"
TARGET_DIR="spannung/app"
SERVICE_NAME="spannung.service"

echo "🚀 Starte Deployment auf $PI_USER@$PI_HOST..."

# --- ZERTIFIKATE ERZEUGEN ---
# Prüfen, ob die Zertifikate bereits im lokalen app-Ordner existieren
if [ ! -f ./app/cert.pem ] || [ ! -f ./app/key.pem ]; then
    echo "🔐 Generiere selbstsignierte SSL-Zertifikate für HTTPS..."
    openssl req -x509 -newkey rsa:4096 -nodes \
        -keyout ./app/key.pem \
        -out ./app/cert.pem \
        -days 365 \
        -subj "/C=DE/ST=Bavaria/L=Koeditz/O=info@lang-dieter.de/CN=$PI_HOST" 2>/dev/null
    echo "✅ SSL-Zertifikate lokal in ./app/ erstellt."
else
    echo "🔒 Bestehende SSL-Zertifikate in ./app/ gefunden. Werden wiederverwendet."
fi

# 1. Altes Zielverzeichnis auf dem Pi löschen und frisch erstellen
ssh "$PI_USER@$PI_HOST" "rm -rf \"\$HOME/$TARGET_DIR\" && mkdir -p \"\$HOME/$TARGET_DIR\""

# 2. Den INHALT des lokal fertig gebauten 'app'-Ordners (inkl. Zertifikate) zum Pi kopieren
echo "📦 Übertrage vorkompilierte Anwendung, Webseiten und Zertifikate..."
scp -r ./app/* "$PI_USER@$PI_HOST:$TARGET_DIR/"

# 3. Das Setup-Skript direkt auf dem Pi ausführen
ssh -t "$PI_USER@$PI_HOST" "
    cd \${HOME}/${TARGET_DIR}
    
    # Windows-Zeilenumbrüche zur Sicherheit entfernen
    sed -i 's/\r//' ./install.sh
    sed -i 's/\r//' ./${SERVICE_NAME}
    
    # Das Installationsskript auf dem Pi ausführen
    ./install.sh
"

echo "✅ Deployment erfolgreich abgeschlossen! Zeige Live-Logs (Beenden mit STRG+C)..."
ssh -t "$PI_USER@$PI_HOST" "sudo journalctl -f -u ${SERVICE_NAME}"

Die Datei install.sh richtet den Dienst auf dem Zielrechner ein und wird von deploy.sh aufgerufen.

geany install.sh
# 3. Die Service-Datei mit Platzhaltern generieren
cat << 'EOF' > ./app/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" && i2cdetect -y 1 | grep -E "40:" | grep -q -E "\b49\b" && exit 0; echo "warte auf ADS1115 Module..."; sleep 2; done; exit 1'

# Diese Platzhalter werden beim Installieren auf dem Pi automatisch ersetzt:
ExecStart={{TARGET_DIR}}/{{BINARY}}
WorkingDirectory={{TARGET_DIR}}
User={{TARGET_USER}}
Restart=on-failure
RestartSec=5s
StartLimitIntervalSec=60s
StartLimitBurst=3

StandardOutput=append:{{TARGET_DIR}}/spannung.log
StandardError=append:{{TARGET_DIR}}/spannung.log

[Install]
WantedBy=multi-user.target
EOF

# 4. Ein automatisches Installations-Skript für den Pi im app-Ordner erstellen
cat << 'EOF' > ./app/install.sh
#!/bin/bash
set -e

echo "🚀 Starte automatische Einrichtung des Spannung-Monitors..."

# Pfade und Benutzer auf dem aktuellen Pi ermitteln
PI_DIR=$(pwd)
PI_USER=$(whoami)

# Architektur des Betriebssystems prüfen
ARCH=$(uname -m)
if [ "$ARCH" = "aarch64" ]; then
    BINARY="spannung-arm64"
    echo "🎯 64-Bit OS erkannt. Nutze: $BINARY"
else
    BINARY="spannung-arm32_7"
    echo "🎯 32-Bit OS erkannt. Nutze: $BINARY"
fi

# Platzhalter in der Service-Datei direkt auf dem Pi ersetzen
sed -i "s|{{TARGET_DIR}}|$PI_DIR|g" spannung.service
sed -i "s|{{TARGET_USER}}|$PI_USER|g" spannung.service
sed -i "s|{{BINARY}}|$BINARY|g" spannung.service

echo "📝 Service-Datei wurde erfolgreich für diesen Pi konfiguriert."

# Service in das Systemverzeichnis kopieren und aktivieren
echo "⚙️ Kopiere Service-Datei nach /etc/systemd/system/..."
sudo cp spannung.service /etc/systemd/system/spannung.service
sudo systemctl daemon-reload
sudo systemctl enable spannung.service

echo "▶️ Starte den Dienst..."
sudo systemctl restart spannung.service

echo "✅ Fertig! Der Spannung-Monitor läuft und der Autostart ist aktiv."
EOF

chmod +x ./app/install.sh

echo "📝 Service-Datei und install.sh generiert."
echo "📂 Inhalt des fertigen app-Verzeichnisses:"
ls -la app

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

# Verzeichnis anlegen und dorthin wechseln
mkdir spannung
cd spannung

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

# Entpacken des ZIP-Archives
unzip spannung_app.zip

# In das app-Verzeichnis wechseln
cd app

# Installation durchführen
./install.sh

#Status des Service prüfen
sudo systemctl status spannung.service

#IP-Adresse des Rechners in tailscale anzeigen lassen
tailscale status