#!/usr/bin/env bash # # homeESS Zigbee Bridge -- Installer # # Macht aus einem frisch installierten Debian (Minimalinstallation, root-Login) # eine Zigbee-Bridge: ein lokal angeschlossener serieller Zigbee-Coordinator # wird transparent ueber TCP bereitgestellt. # # Aufruf auf dem Zielsystem: # # curl -fsSL https://www.homeess.de/zigbee-bridge/install.sh | bash # # Mit Optionen: # # curl -fsSL https://www.homeess.de/zigbee-bridge/install.sh | bash -s -- --port 6638 # # Installiert wird: # /usr/local/bin/zigbee-bridge # /etc/homeess/zigbee-bridge.yaml # /etc/systemd/system/zigbee-bridge.service # # Das Script ist idempotent: ein erneuter Aufruf aktualisiert die Binary und # laesst eine vorhandene Konfiguration unangetastet. set -euo pipefail # --- Grundeinstellungen --------------------------------------------------- BIN_NAME="zigbee-bridge" BIN_DIR="/usr/local/bin" CONFIG_DIR="/etc/homeess" CONFIG_FILE="${CONFIG_DIR}/${BIN_NAME}.yaml" SERVICE_NAME="${BIN_NAME}.service" SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}" # Basisadresse der Releases. Ueberschreibbar fuer Tests gegen eine andere # Quelle: BASE_URL=http://10.8.1.50/zigbee-bridge ./install.sh BASE_URL="${BASE_URL:-https://www.homeess.de/zigbee-bridge}" # "latest" oder eine konkrete Version (z.B. 0.1.0). Beides sind Verzeichnisse # unterhalb von BASE_URL. CHANNEL="${CHANNEL:-latest}" # --- Optionen ------------------------------------------------------------- BINARY_PATH="" DEVICE="" PORT="" LISTEN="" NO_ENABLE=0 NO_START=0 SKIP_VERIFY=0 DO_UNINSTALL=0 PURGE=0 usage() { cat <&2; usage >&2; exit 1 ;; esac done PORT="${PORT:-6638}" LISTEN="${LISTEN:-0.0.0.0}" # --- Ausgabe -------------------------------------------------------------- if [ -t 1 ]; then C_OK=$'\033[32m'; C_WARN=$'\033[33m'; C_ERR=$'\033[31m'; C_OFF=$'\033[0m' else C_OK=""; C_WARN=""; C_ERR=""; C_OFF="" fi log() { printf '%s==>%s %s\n' "$C_OK" "$C_OFF" "$*"; } warn() { printf '%s[!]%s %s\n' "$C_WARN" "$C_OFF" "$*" >&2; } die() { printf '%s[x]%s %s\n' "$C_ERR" "$C_OFF" "$*" >&2; exit 1; } # --- Vorbedingungen ------------------------------------------------------- [ "$(id -u)" -eq 0 ] || die "root-rechte erforderlich" [ "$(uname -s)" = "Linux" ] || die "nicht unterstuetztes betriebssystem: $(uname -s)" have_systemd() { command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ] } # --- Deinstallation ------------------------------------------------------- if [ "$DO_UNINSTALL" -eq 1 ]; then if have_systemd && [ -f "$SERVICE_FILE" ]; then systemctl disable --now "$SERVICE_NAME" >/dev/null 2>&1 || true rm -f "$SERVICE_FILE" systemctl daemon-reload log "service entfernt" fi rm -f "${BIN_DIR}/${BIN_NAME}" log "binary entfernt" if [ "$PURGE" -eq 1 ]; then rm -rf "$CONFIG_DIR" log "konfiguration entfernt" else log "konfiguration bleibt erhalten: ${CONFIG_FILE}" fi exit 0 fi # --- Plattform erkennen --------------------------------------------------- detect_arch() { local machine machine="$(uname -m)" case "$machine" in x86_64|amd64) echo "linux-amd64" ;; aarch64|arm64) echo "linux-arm64" ;; armv7l|armv6l|armhf|arm) echo "linux-armv7" ;; *) die "nicht unterstuetzte architektur: ${machine}" ;; esac } ARCH="$(detect_arch)" ASSET="${BIN_NAME}-${ARCH}" PRETTY="$( . /etc/os-release 2>/dev/null && printf '%s' "${PRETTY_NAME:-Linux}" )" log "system : ${PRETTY}" log "plattform: $(uname -m) -> ${ASSET}" have_systemd || warn "kein laufendes systemd erkannt -- der service wird uebersprungen" # --- Downloader ----------------------------------------------------------- if command -v curl >/dev/null 2>&1; then fetch() { curl -fsSL --retry 3 --connect-timeout 15 "$1" -o "$2"; } elif command -v wget >/dev/null 2>&1; then fetch() { wget -q --tries=3 --timeout=15 "$1" -O "$2"; } else die "weder curl noch wget vorhanden -- bitte 'apt-get install -y curl' ausfuehren" fi TMP_DIR="" cleanup() { if [ -n "$TMP_DIR" ]; then rm -rf "$TMP_DIR" fi return 0 } trap cleanup EXIT # --- Binary beschaffen ---------------------------------------------------- download_binary() { TMP_DIR="$(mktemp -d)" local target="${TMP_DIR}/${BIN_NAME}" local url="${BASE_URL}/${CHANNEL}/${ASSET}" log "lade ${url}" >&2 fetch "$url" "$target" || die "download fehlgeschlagen: ${url}" [ -s "$target" ] || die "heruntergeladene datei ist leer: ${url}" if [ "$SKIP_VERIFY" -eq 1 ]; then warn "pruefsumme wird auf wunsch nicht geprueft" elif ! command -v sha256sum >/dev/null 2>&1; then warn "sha256sum nicht vorhanden -- pruefsumme wird uebersprungen" else local sums="${TMP_DIR}/SHA256SUMS" if fetch "${BASE_URL}/${CHANNEL}/SHA256SUMS" "$sums"; then local expected actual # "exit" statt "| head -1": eine Pipe, deren Leser frueh endet, # laesst den Schreiber mit SIGPIPE sterben -- unter pipefail waere # das ein Abbruch. expected="$(awk -v asset="$ASSET" '$2 == asset || $2 == "*" asset { print $1; exit }' "$sums")" [ -n "$expected" ] || die "keine pruefsumme fuer ${ASSET} gefunden" actual="$(sha256sum "$target" | awk '{ print $1 }')" [ "$expected" = "$actual" ] \ || die "pruefsumme falsch fuer ${ASSET} (erwartet ${expected}, erhalten ${actual})" log "pruefsumme ok (sha256 ${actual:0:16}...)" >&2 else die "SHA256SUMS nicht abrufbar -- abbruch (umgehbar mit --skip-verify)" fi fi echo "$target" } # Liegt das Script neben einer passenden Binary (ausgepacktes Release oder # Entwicklungsbaum), wird diese bevorzugt. Bei "curl | bash" gibt es kein # Scriptverzeichnis -- dann wird immer heruntergeladen. SCRIPT_DIR="" if [ -n "${BASH_SOURCE[0]:-}" ] && [ -f "${BASH_SOURCE[0]}" ]; then SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" fi if [ -z "$BINARY_PATH" ] && [ -n "$SCRIPT_DIR" ]; then for candidate in "${SCRIPT_DIR}/dist/${ASSET}" "${SCRIPT_DIR}/${ASSET}"; do if [ -f "$candidate" ]; then BINARY_PATH="$candidate" log "lokale binary wird verwendet: ${BINARY_PATH}" break fi done fi if [ -z "$BINARY_PATH" ]; then BINARY_PATH="$(download_binary)" fi [ -f "$BINARY_PATH" ] || die "binary nicht gefunden: ${BINARY_PATH}" # --- Coordinator suchen --------------------------------------------------- # Ausgegeben wird eine Liste serieller Geraetepfade, eine pro Zeile. Pfade # unterhalb von /dev/serial/by-id/ werden bevorzugt, weil sie sich beim # Neustart nicht aendern. list_serial_devices() { local dev base seen="" if [ -d /dev/serial/by-id ]; then for dev in /dev/serial/by-id/*; do [ -e "$dev" ] || continue base="$(basename "$(readlink -f "$dev")")" seen="${seen} ${base}" printf '%s\n' "$dev" done fi # Fallback fuer Adapter ohne Seriennummer (z.B. manche CH340-Klone), die # keinen by-id-Pfad bekommen. for dev in /dev/ttyUSB* /dev/ttyACM*; do [ -e "$dev" ] || continue base="$(basename "$dev")" case " ${seen} " in *" ${base} "*) continue ;; esac printf '%s\n' "$dev" done } DEVICES="$(list_serial_devices 2>/dev/null || true)" DEVICE_COUNT=0 if [ -n "$DEVICES" ]; then DEVICE_COUNT="$(printf '%s\n' "$DEVICES" | wc -l)" fi if [ -n "$DEVICE" ]; then log "coordinator vorgegeben: ${DEVICE}" elif [ "$DEVICE_COUNT" -eq 0 ]; then DEVICE="/dev/ttyUSB0" warn "kein serieller coordinator gefunden -- eingetragen wird ${DEVICE}" warn "die bridge wartet zur laufzeit auf das geraet; nach dem einstecken" warn "ggf. serial.device in ${CONFIG_FILE} anpassen" elif [ "$DEVICE_COUNT" -eq 1 ]; then DEVICE="$DEVICES" log "coordinator erkannt: ${DEVICE}" else DEVICE="$(printf '%s\n' "$DEVICES" | sed -n '1p')" warn "mehrere serielle geraete gefunden:" printf '%s\n' "$DEVICES" | sed 's/^/ /' >&2 warn "verwendet wird das erste (${DEVICE}) -- bei bedarf in" warn "${CONFIG_FILE} korrigieren" fi # --- Laufenden Dienst stoppen --------------------------------------------- if have_systemd && systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null; then log "stoppe laufenden dienst" systemctl stop "$SERVICE_NAME" fi # --- Binary installieren -------------------------------------------------- install -d -m 0755 "$BIN_DIR" "$CONFIG_DIR" install -m 0755 "$BINARY_PATH" "${BIN_DIR}/${BIN_NAME}" INSTALLED_VERSION="$("${BIN_DIR}/${BIN_NAME}" --version 2>/dev/null)" \ || die "installierte binary ist auf diesem system nicht lauffaehig" log "installiert: ${BIN_DIR}/${BIN_NAME} (${INSTALLED_VERSION})" # --- Konfiguration -------------------------------------------------------- if [ -f "$CONFIG_FILE" ]; then log "vorhandene konfiguration bleibt unveraendert: ${CONFIG_FILE}" else cat > "$CONFIG_FILE" </dev/null || echo zigbee-bridge) log: # debug, info, warn oder error. level: info reconnect: # Abstand zwischen zwei Versuchen, den seriellen Port erneut zu oeffnen. interval: 3s EOF chmod 0644 "$CONFIG_FILE" log "konfiguration angelegt: ${CONFIG_FILE}" fi "${BIN_DIR}/${BIN_NAME}" --check-config --config "$CONFIG_FILE" >/dev/null 2>&1 \ || die "konfiguration ist ungueltig: ${CONFIG_FILE}" # --- systemd -------------------------------------------------------------- if ! have_systemd; then warn "ohne systemd wurde kein service eingerichtet. manueller start:" warn " ${BIN_DIR}/${BIN_NAME} --config ${CONFIG_FILE}" exit 0 fi cat > "$SERVICE_FILE" </dev/null 2>&1 log "service fuer den systemstart aktiviert" fi if [ "$NO_START" -eq 1 ]; then log "service wurde auf wunsch nicht gestartet" exit 0 fi systemctl restart "$SERVICE_NAME" # Der Start wird verifiziert: ein noch nicht eingesteckter Coordinator ist # kein Fehler -- die Bridge wartet dann auf das Geraet. Ein abgestuerzter # Dienst ist einer. sleep 1 if ! systemctl is-active --quiet "$SERVICE_NAME"; then warn "service laeuft nicht -- letzte meldungen:" journalctl -u "$SERVICE_NAME" -n 20 --no-pager >&2 || true exit 1 fi log "service laeuft" # --- Firewall ------------------------------------------------------------- if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -qi "^Status: active"; then if ufw allow "${PORT}/tcp" >/dev/null 2>&1; then log "ufw-regel fuer port ${PORT}/tcp ergaenzt" else warn "ufw-regel fuer port ${PORT}/tcp konnte nicht gesetzt werden" fi elif command -v nft >/dev/null 2>&1 && nft list ruleset 2>/dev/null | grep -q "policy drop"; then warn "nftables filtert -- port ${PORT}/tcp ggf. manuell freigeben" fi # --- Abschluss ------------------------------------------------------------ # Eine Minimalinstallation hat weder iproute2 noch zwingend hostname. Ohne # die Fallbacks (und ohne "|| true" gegen pipefail) wuerde das Script hier # abbrechen, obwohl die Installation laengst erfolgreich war. IP="" if command -v ip >/dev/null 2>&1; then IP="$(ip -4 -o addr show scope global 2>/dev/null | awk '{ print $4 }' | cut -d/ -f1 | head -1 || true)" elif command -v hostname >/dev/null 2>&1; then IP="$(hostname -I 2>/dev/null | awk '{ print $1 }' || true)" fi IP="${IP:-}" cat <