#!/bin/sh # SecuBox Zigbee2MQTT Controller — LXC-based (KISS) # Alpine container with Node.js + zigbee2mqtt # USB dongle passed through via cgroup device rules CONFIG="zigbee2mqtt" LXC_NAME="zigbee2mqtt" LXC_PATH="/srv/lxc" LXC_ROOTFS="$LXC_PATH/$LXC_NAME/rootfs" LXC_CONFIG="$LXC_PATH/$LXC_NAME/config" DATA_PATH="/srv/zigbee2mqtt" Z2M_VERSION="2.3.0" # Logging log_info() { echo "[INFO] $*"; logger -t zigbee2mqtt "$*"; } log_warn() { echo "[WARN] $*" >&2; logger -t zigbee2mqtt -p warning "$*"; } log_error() { echo "[ERROR] $*" >&2; logger -t zigbee2mqtt -p err "$*"; } # Helpers require_root() { [ "$(id -u)" -eq 0 ] || { log_error "Root required"; exit 1; }; } ensure_dir() { [ -d "$1" ] || mkdir -p "$1"; } uci_get() { uci -q get ${CONFIG}.$1; } load_config() { serial_port=$(uci_get main.serial_port) || serial_port="/dev/ttyUSB0" mqtt_host=$(uci_get main.mqtt_host) || mqtt_host="mqtt://127.0.0.1:1883" mqtt_user=$(uci_get main.mqtt_username) mqtt_pass=$(uci_get main.mqtt_password) base_topic=$(uci_get main.base_topic) || base_topic="zigbee2mqtt" frontend_port=$(uci_get main.frontend_port) || frontend_port="8099" channel=$(uci_get main.channel) || channel="11" data_path=$(uci_get main.data_path) || data_path="$DATA_PATH" permit_join=$(uci_get main.permit_join) || permit_join="0" } usage() { cat <<'EOF' SecuBox Zigbee2MQTT Controller (LXC) Usage: zigbee2mqttctl Commands: install Create Alpine LXC container with Node.js + zigbee2mqtt uninstall Remove container (keeps data) update Update zigbee2mqtt inside container status Show service status logs [N] Show last N lines of logs (default 50) check Validate USB dongle and prerequisites service-run Internal: invoked by procd service-stop Stop the container shell Open shell inside container help Show this message EOF } # ════════════════════════════════════════════ # LXC Management # ════════════════════════════════════════════ lxc_running() { lxc-info -n "$LXC_NAME" -s 2>/dev/null | grep -q "RUNNING"; } lxc_exists() { [ -f "$LXC_CONFIG" ] && [ -d "$LXC_ROOTFS" ]; } lxc_exec() { lxc-attach -n "$LXC_NAME" -- "$@"; } lxc_stop() { if lxc_running; then log_info "Stopping zigbee2mqtt container..." lxc-stop -n "$LXC_NAME" -k 2>/dev/null || true sleep 2 fi } lxc_create_rootfs() { log_info "Creating Alpine rootfs for Zigbee2MQTT..." ensure_dir "$LXC_PATH/$LXC_NAME" local arch="x86_64" case "$(uname -m)" in aarch64) arch="aarch64" ;; armv7l) arch="armv7" ;; esac local alpine_url="https://dl-cdn.alpinelinux.org/alpine/v3.21/releases/$arch/alpine-minirootfs-3.21.2-$arch.tar.gz" local rootfs_tar="/tmp/alpine-z2m.tar.gz" log_info "Downloading Alpine rootfs..." wget -q -O "$rootfs_tar" "$alpine_url" || { log_error "Failed to download Alpine rootfs" return 1 } log_info "Extracting rootfs..." ensure_dir "$LXC_ROOTFS" tar -xzf "$rootfs_tar" -C "$LXC_ROOTFS" || { log_error "Failed to extract rootfs" return 1 } rm -f "$rootfs_tar" # Configure Alpine cat > "$LXC_ROOTFS/etc/resolv.conf" << 'RESOLVEOF' nameserver 1.1.1.1 nameserver 8.8.8.8 RESOLVEOF cat > "$LXC_ROOTFS/etc/apk/repositories" << 'REPOEOF' https://dl-cdn.alpinelinux.org/alpine/v3.21/main https://dl-cdn.alpinelinux.org/alpine/v3.21/community REPOEOF # Install Node.js and dependencies log_info "Installing Node.js and build dependencies..." chroot "$LXC_ROOTFS" /bin/sh -c " apk update apk add --no-cache nodejs npm make gcc g++ linux-headers python3 git " || { log_error "Failed to install Node.js" return 1 } # Install zigbee2mqtt log_info "Installing zigbee2mqtt (this may take a few minutes)..." ensure_dir "$LXC_ROOTFS/opt/zigbee2mqtt" chroot "$LXC_ROOTFS" /bin/sh -c " cd /opt/zigbee2mqtt npm init -y >/dev/null 2>&1 npm install zigbee2mqtt@$Z2M_VERSION " || { log_error "Failed to install zigbee2mqtt" return 1 } # Clean up build deps to save space log_info "Cleaning up build dependencies..." chroot "$LXC_ROOTFS" /bin/sh -c " apk del make gcc g++ linux-headers python3 git 2>/dev/null || true rm -rf /var/cache/apk/* " log_info "Rootfs created successfully" } lxc_create_config() { load_config local arch="x86_64" case "$(uname -m)" in aarch64) arch="aarch64" ;; armv7l) arch="armhf" ;; esac # Get USB device major:minor for cgroup passthrough local usb_major usb_minor if [ -c "$serial_port" ]; then usb_major=$(stat -c '%t' "$serial_port" 2>/dev/null) usb_minor=$(stat -c '%T' "$serial_port" 2>/dev/null) # Convert hex to decimal usb_major=$((0x${usb_major:-bc})) usb_minor=$((0x${usb_minor:-0})) else usb_major=188 # ttyUSB default major usb_minor=0 fi cat > "$LXC_CONFIG" << EOF # Zigbee2MQTT LXC Configuration lxc.uts.name = $LXC_NAME lxc.rootfs.path = dir:$LXC_ROOTFS lxc.arch = $arch # Network: use host network (for MQTT and frontend) lxc.net.0.type = none # Mount points lxc.mount.auto = proc:mixed sys:ro cgroup:mixed lxc.mount.entry = $data_path/data opt/zigbee2mqtt/data none bind,create=dir 0 0 # USB serial passthrough lxc.cgroup2.devices.allow = c $usb_major:* rwm lxc.mount.entry = $serial_port dev/$(basename $serial_port) none bind,create=file 0 0 # Security lxc.cap.drop = sys_admin sys_module mac_admin mac_override sys_time # Resource limits lxc.cgroup2.memory.max = 512000000 # Init command lxc.init.cmd = /opt/start-zigbee2mqtt.sh EOF log_info "LXC config created" } generate_config() { load_config ensure_dir "$data_path/data" # Generate zigbee2mqtt configuration.yaml cat > "$data_path/data/configuration.yaml" << EOF version: 4 homeassistant: enabled: false permit_join: $([ "$permit_join" = "1" ] && echo "true" || echo "false") mqtt: base_topic: $base_topic server: $mqtt_host $([ -n "$mqtt_user" ] && echo " user: $mqtt_user") $([ -n "$mqtt_pass" ] && echo " password: $mqtt_pass") serial: port: /dev/$(basename $serial_port) adapter: ember advanced: channel: $channel log_level: info log_output: - console frontend: enabled: true port: $frontend_port host: 0.0.0.0 EOF chmod 600 "$data_path/data/configuration.yaml" log_info "Configuration generated" } generate_start_script() { load_config cat > "$LXC_ROOTFS/opt/start-zigbee2mqtt.sh" << 'STARTEOF' #!/bin/sh export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin export NODE_PATH=/opt/zigbee2mqtt/node_modules export ZIGBEE2MQTT_DATA=/opt/zigbee2mqtt/data cd /opt/zigbee2mqtt LOG_FILE="/opt/zigbee2mqtt/data/zigbee2mqtt.log" echo "=== Zigbee2MQTT startup: $(date) ===" >> "$LOG_FILE" # Check serial device SERIAL=$(grep -o 'port: /dev/[^ ]*' data/configuration.yaml 2>/dev/null | cut -d/ -f2-) if [ -n "$SERIAL" ] && [ ! -c "/dev/$SERIAL" ]; then echo "[ERROR] Serial device /dev/$SERIAL not found" >> "$LOG_FILE" sleep 5 exit 1 fi # Run zigbee2mqtt exec node node_modules/zigbee2mqtt/index.js 2>&1 | tee -a "$LOG_FILE" STARTEOF chmod +x "$LXC_ROOTFS/opt/start-zigbee2mqtt.sh" } # ════════════════════════════════════════════ # Commands # ════════════════════════════════════════════ cmd_install() { require_root load_config # Check USB dongle if [ ! -c "$serial_port" ]; then log_warn "Serial device $serial_port not found." log_warn "Plug in your Zigbee USB dongle and ensure kmod-usb-serial-cp210x is installed." fi # Create container if missing if ! lxc_exists; then lxc_create_rootfs || exit 1 else log_info "Container already exists, skipping rootfs creation" fi # Setup data dir and config ensure_dir "$data_path/data" generate_config lxc_create_config generate_start_script # Enable service uci set ${CONFIG}.main.enabled='1' uci commit ${CONFIG} /etc/init.d/zigbee2mqtt enable 2>/dev/null log_info "Installation complete." log_info "Start with: /etc/init.d/zigbee2mqtt start" log_info "Frontend will be at: http://192.168.255.1:${frontend_port}" } cmd_uninstall() { require_root lxc_stop if [ -d "$LXC_PATH/$LXC_NAME" ]; then log_info "Removing container (data at $DATA_PATH preserved)..." rm -rf "$LXC_PATH/$LXC_NAME" fi uci set ${CONFIG}.main.enabled='0' uci commit ${CONFIG} /etc/init.d/zigbee2mqtt disable 2>/dev/null log_info "Uninstalled. Data preserved at $DATA_PATH" } cmd_update() { require_root load_config if ! lxc_exists; then log_error "Container not installed. Run: zigbee2mqttctl install" return 1 fi lxc_stop log_info "Updating zigbee2mqtt..." chroot "$LXC_ROOTFS" /bin/sh -c " cd /opt/zigbee2mqtt npm install zigbee2mqtt@latest " || { log_error "Update failed" return 1 } log_info "Update complete. Restarting..." /etc/init.d/zigbee2mqtt restart 2>/dev/null } cmd_status() { load_config echo "Zigbee2MQTT Status" echo "==================" local enabled=$(uci_get main.enabled) echo "Enabled: $([ "$enabled" = "1" ] && echo "yes" || echo "no")" if lxc_running; then echo "Running: yes" else echo "Running: no" fi echo "Serial Port: $serial_port" if [ -c "$serial_port" ]; then echo "Dongle: detected" else echo "Dongle: NOT found" fi echo "Frontend: http://192.168.255.1:$frontend_port" echo "MQTT Broker: $mqtt_host" echo "Base Topic: $base_topic" echo "Channel: $channel" echo "Data Path: $data_path" if lxc_exists; then echo "Container: installed" else echo "Container: not installed" fi } cmd_logs() { local lines="${1:-50}" local logfile="$DATA_PATH/data/zigbee2mqtt.log" if [ -f "$logfile" ]; then tail -n "$lines" "$logfile" else echo "No log file found at $logfile" # Try container journal if lxc_running; then lxc_exec tail -n "$lines" /opt/zigbee2mqtt/data/zigbee2mqtt.log 2>/dev/null fi fi } cmd_check() { load_config echo "Zigbee2MQTT Prerequisites" echo "=========================" # USB serial module if lsmod 2>/dev/null | grep -q "cp210x"; then echo "[OK] cp210x kernel module loaded" else echo "[!!] cp210x kernel module NOT loaded" echo " Fix: opkg install kmod-usb-serial-cp210x && modprobe cp210x" fi # Serial device if [ -c "$serial_port" ]; then echo "[OK] Serial device $serial_port present" else echo "[!!] Serial device $serial_port NOT found" fi # USB devices local usb_count=0 for d in /sys/bus/usb/devices/[0-9]*; do local v=$(cat "$d/idVendor" 2>/dev/null) local p=$(cat "$d/idProduct" 2>/dev/null) local n=$(cat "$d/product" 2>/dev/null) [ "$v" = "10c4" ] && { echo "[OK] Sonoff/CP210x USB device: $n ($v:$p)" usb_count=$((usb_count + 1)) } done [ "$usb_count" -eq 0 ] && echo "[!!] No Sonoff/CP210x USB devices detected" # LXC if command -v lxc-start >/dev/null 2>&1; then echo "[OK] LXC available" else echo "[!!] LXC not installed" fi # Container if lxc_exists; then echo "[OK] Container installed" else echo "[--] Container not installed (run: zigbee2mqttctl install)" fi # Data directory if [ -d "$data_path/data" ]; then echo "[OK] Data directory exists" else echo "[--] Data directory not created yet" fi } cmd_service_run() { require_root load_config if ! lxc_exists; then log_error "Container not installed. Run: zigbee2mqttctl install" exit 1 fi # Regenerate config and start script each run generate_config lxc_create_config generate_start_script lxc_stop log_info "Starting zigbee2mqtt container..." exec lxc-start -n "$LXC_NAME" -F } cmd_service_stop() { require_root lxc_stop } # ════════════════════════════════════════════ # Main # ════════════════════════════════════════════ case "${1:-}" in install) shift; cmd_install "$@" ;; uninstall) shift; cmd_uninstall "$@" ;; update) shift; cmd_update "$@" ;; status) shift; cmd_status "$@" ;; logs) shift; cmd_logs "$@" ;; check) shift; cmd_check "$@" ;; service-run) shift; cmd_service_run "$@" ;; service-stop) shift; cmd_service_stop "$@" ;; shell) shift; lxc-attach -n "$LXC_NAME" -- /bin/sh ;; help|--help|-h|'') usage ;; *) echo "Unknown command: $1" >&2; usage >&2; exit 1 ;; esac