- Aufgabenstellung
- Software zur Spannungsanzeige und Ansteuerung Relaisplatine
- Downloads
- Quickstart
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


# 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

Downloads
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