diff --git a/packages/secubox-zigbee/README.md b/packages/secubox-zigbee/README.md index 767bdf9a..94cfdccc 100644 --- a/packages/secubox-zigbee/README.md +++ b/packages/secubox-zigbee/README.md @@ -1,39 +1,66 @@ -# πŸ“‘ Zigbee Gateway +# secubox-lyrion -Zigbee2MQTT gateway +[Lyrion Music Server](https://lyrion.org) (formerly Logitech Media Server / +Squeezebox Server) for SecuBox, hosted in a Debian bookworm LXC at +`10.100.0.100` on the SecuBox `br-lxc` bridge. -**Category:** IoT +Follows [`docs/MODULE-GUIDELINES.md`](../../docs/MODULE-GUIDELINES.md); opens +the **HOSTING** layer of the SecuBox CTL grammar (sister to streamlitctl, +metablogizerctl, etc.). -## Screenshot - -![Zigbee Gateway](../../docs/screenshots/vm/zigbee.png) - -## Features - -- Device pairing -- MQTT -- Groups -- OTA updates - -## Installation +## Quickstart ```bash -# Add SecuBox repository -curl -fsSL https://apt.secubox.in/install.sh | sudo bash - -# Install package -sudo apt install secubox-zigbee +apt install secubox-lyrion +lyrionctl install # provisions LXC at 10.100.0.100, installs Lyrion 9.0.4 +lyrionctl status # green when LXC + daemon + host-api all up ``` -## Configuration +Web admin at `http://10.100.0.100:9000/` (direct) or +`https:///lyrion/` (through the canonical hub vhost). -Configuration file: `/etc/secubox/zigbee.toml` +## CTL β€” `lyrionctl` -## API Endpoints +Three-fold + lifecycle (v1.0.0 ships install + reload; rest is v1.1.0): -- `GET /api/v1/zigbee/status` - Module status -- `GET /api/v1/zigbee/health` - Health check +```text +lyrionctl components | status | access [--json] +lyrionctl install | reload | repair (1.1.0) | wizard (1.1.0) | uninstall (1.1.0) -## License +lyrionctl player list | play | pause | next | prev | volume <0-100> +lyrionctl library scan | refresh | status | wipe +lyrionctl playlist list | add | remove | tracks +lyrionctl plugin list | install | uninstall +``` -MIT License - CyberMind Β© 2024-2026 +## Music library + +The `[library]` section of `/etc/secubox/lyrion.toml` points to the host's +music directory (default `/data/music`). On `lyrionctl install`, that path +is bind-mounted read-only into the LXC at the same path. LMS scans + +indexes from there; no writes to source files. + +## Ports + +| Port | Proto | Use | +|---|---|---| +| 9000 | tcp | Web UI + JSON-RPC API (proxied via nginx `/lyrion/`) | +| 9090 | tcp | CLI (telnet/netcat) | +| 3483 | tcp+udp | slimproto (Squeezebox players discover/connect) | + +3483 is **NOT exposed publicly** β€” players are expected on the LAN. Add an +nftables DNAT only if you have a remote Squeezebox. + +## Files + +```text +/etc/secubox/lyrion.toml # operator config +/etc/nginx/secubox.d/lyrion.conf # /api/v1/lyrion/ + /lyrion/ iframe +/etc/nginx/secubox-routes.d/lyrion.conf # idem (canonical hub vhost) +/usr/lib/secubox/lyrion/api/ # host FastAPI +/usr/share/secubox/lib/lyrion/install-lxc.sh +/usr/share/secubox/www/lyrion/ # SecuBox-themed iframe wrapper +/usr/share/secubox/menu.d/80-lyrion.json +/data/lxc/lyrion/ # LXC rootfs (created by lyrionctl install) +/data/music/ # operator music library (bind-mounted) +``` diff --git a/packages/secubox-zigbee/api/__init__.py b/packages/secubox-zigbee/api/__init__.py index 8b137891..e69de29b 100644 --- a/packages/secubox-zigbee/api/__init__.py +++ b/packages/secubox-zigbee/api/__init__.py @@ -1 +0,0 @@ - diff --git a/packages/secubox-zigbee/api/main.py b/packages/secubox-zigbee/api/main.py index 4c3b4cee..c77cdf1c 100644 --- a/packages/secubox-zigbee/api/main.py +++ b/packages/secubox-zigbee/api/main.py @@ -1,756 +1,157 @@ -"""secubox-zigbee β€” FastAPI application for Zigbee2MQTT Gateway. +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind β€” Gerald Kerma +# Source-Disclosed License β€” All rights reserved except as expressly granted. +# See LICENCE-CMSD-1.0.md for terms. -Ported from OpenWRT luci-app-zigbee2mqtt RPCD backend. -Provides Zigbee2MQTT container management and API proxy. """ -import asyncio -import shutil +SecuBox-Deb :: secubox-zigbee :: host FastAPI control plane. + +zigbee2mqtt runs in LXC at 10.100.0.111 (br-lxc). This API is on the host, +on Unix socket /run/secubox/zigbee.sock, reverse-proxied at /api/v1/zigbee/ +by the canonical hub vhost. + +v2.4.0 surface (minimum viable): + GET /components β€” three-fold component states + GET /status β€” overall green/yellow/red + GET /access β€” frontend + mqtt access points + GET /healthz β€” cheap liveness probe + +Deferred to v2.5 (Z2MBridge async pattern): /devices, /devices/{id}, +/topology, /network, /permit_join, /rename, /bind, /ota/*. +""" + +import json +import os +import socket import subprocess -import glob from pathlib import Path -from typing import List, Optional, Dict, Any -from fastapi import FastAPI, APIRouter, Depends, HTTPException -from pydantic import BaseModel -import httpx +from fastapi import FastAPI -from secubox_core.auth import router as auth_router, require_jwt -from secubox_core.logger import get_logger +LXC_NAME = os.environ.get("SECUBOX_LXC_NAME", "zigbee") +LXC_IP = os.environ.get("SECUBOX_LXC_IP", "10.100.0.111") +LXC_PATH = os.environ.get("SECUBOX_LXC_PATH", "/data/lxc") +FRONTEND_PORT = int(os.environ.get("SECUBOX_Z2M_FRONTEND_PORT", "8080")) +MQTT_LXC_IP = os.environ.get("SECUBOX_MQTT_LXC_IP", "10.100.0.110") +MQTT_PORT = int(os.environ.get("SECUBOX_MQTT_PORT", "1883")) +SECRETS_DIR = Path(os.environ.get("SECUBOX_SECRETS_DIR", "/etc/secubox/secrets")) -app = FastAPI(title="secubox-zigbee", version="1.0.0", root_path="/api/v1/zigbee") - -# ══════════════════════════════════════════════════════════════════ -# Health Check Endpoint (public, no auth) -# ══════════════════════════════════════════════════════════════════ - -@app.get("/health") -async def health_check(): - """Public health check endpoint for sidebar status.""" - return {"status": "ok", "module": "deb"} - -app.include_router(auth_router, prefix="/auth") -router = APIRouter() -log = get_logger("zigbee") - -# Configuration -CONFIG_FILE = Path("/etc/secubox/zigbee.toml") -DATA_PATH = Path("/srv/zigbee2mqtt") -DEFAULT_CONFIG = { - "serial_port": "/dev/ttyUSB0", - "mqtt_host": "mqtt://127.0.0.1:1883", - "mqtt_username": "", - "mqtt_password": "", - "base_topic": "zigbee2mqtt", - "frontend_port": 8099, - "channel": 11, - "permit_join": False, -} +app = FastAPI( + title="SecuBox Zigbee", + description="zigbee2mqtt coordinator control plane (MIND layer)", + version="2.4.0", +) -# ============================================================================ -# Models -# ============================================================================ - -class ZigbeeConfig(BaseModel): - serial_port: str = "/dev/ttyUSB0" - mqtt_host: str = "mqtt://127.0.0.1:1883" - mqtt_username: Optional[str] = "" - mqtt_password: Optional[str] = "" - base_topic: str = "zigbee2mqtt" - frontend_port: int = 8099 - channel: int = 11 - permit_join: bool = False - - -class PermitJoinRequest(BaseModel): - permit: bool - duration: int = 254 # seconds (254 = until disabled) - - -class DeviceRenameRequest(BaseModel): - old_name: str - new_name: str - - -class DeviceRemoveRequest(BaseModel): - device: str - force: bool = False - - -# ============================================================================ -# Helpers -# ============================================================================ - -def get_config() -> dict: - """Load zigbee configuration.""" - if CONFIG_FILE.exists(): - try: - import tomllib - return tomllib.loads(CONFIG_FILE.read_text()) - except Exception: - pass - return DEFAULT_CONFIG.copy() - - -def detect_runtime() -> Optional[str]: - """Detect container runtime (podman or docker).""" - if shutil.which("podman"): - return "podman" - if shutil.which("docker"): - return "docker" - return None - - -def is_running() -> bool: - """Check if Zigbee2MQTT container is running.""" - rt = detect_runtime() - if not rt: - return False +def _lxc_state() -> str: + """Infer LXC state without lxc-info (unprivileged user can't call it). + See secubox-mqtt #240 commit for the rationale.""" + rootfs = Path(LXC_PATH) / LXC_NAME try: - result = subprocess.run( - [rt, "ps", "--format", "{{.Names}}"], - capture_output=True, text=True, timeout=5 - ) - names = result.stdout.split() - return "zigbee2mqtt" in names or "z2m" in names - except Exception: + exists = rootfs.exists() + except PermissionError: + exists = True + if not exists: + return "absent" + # TCP probe the z2m frontend; if it answers, daemon is up + LXC is up. + try: + with socket.create_connection((LXC_IP, FRONTEND_PORT), timeout=1.0): + return "running" + except OSError: + # Frontend may be down (radio absent, z2m crash-loop). The LXC may + # still be running. Fall back to a probe on the gateway 10.100.0.1 + # β€” if we can't reach that, the host networking is broken anyway. + return "stopped" + + +def _radio_present() -> bool: + return Path("/dev/secubox-zgb").exists() + + +def _z2m_running() -> bool: + """TCP probe on the z2m frontend port β€” z2m only opens it after start.""" + try: + with socket.create_connection((LXC_IP, FRONTEND_PORT), timeout=1.0): + return True + except OSError: return False -def get_container_name() -> str: - """Get actual container name.""" - rt = detect_runtime() - if not rt: - return "zigbee2mqtt" +def _bridge_state() -> str: + """Read the retained zigbee2mqtt/bridge/state topic via mosquitto_sub.""" + pw_file = SECRETS_DIR / "mqtt-z2m" + if not pw_file.exists(): + return "no-creds" + pw = pw_file.read_text().strip() try: - result = subprocess.run( - [rt, "ps", "--format", "{{.Names}}"], - capture_output=True, text=True, timeout=5 + proc = subprocess.run( + ["mosquitto_sub", "-h", MQTT_LXC_IP, "-p", str(MQTT_PORT), + "-u", "z2m", "-P", pw, + "-t", "zigbee2mqtt/bridge/state", "-C", "1", "-W", "2"], + capture_output=True, text=True, timeout=4, ) - for name in result.stdout.split(): - if "zigbee" in name.lower() or "z2m" in name.lower(): - return name - except Exception: - pass - return "zigbee2mqtt" + except (subprocess.SubprocessError, FileNotFoundError): + return "unknown" + if proc.returncode != 0 or not proc.stdout.strip(): + return "unknown" + try: + obj = json.loads(proc.stdout.strip()) + return obj.get("state", "unknown") + except json.JSONDecodeError: + # z2m 1.x published the bare string. 2.x publishes JSON. + return proc.stdout.strip() -def detect_serial_devices() -> List[dict]: - """Detect available USB serial devices.""" - devices = [] - - # Check /dev/ttyUSB* and /dev/ttyACM* - for pattern in ["/dev/ttyUSB*", "/dev/ttyACM*"]: - for path in glob.glob(pattern): - dev_info = {"path": path, "type": "unknown", "vendor": "", "product": ""} - - # Try to get USB device info - try: - # Get the sysfs path - real_path = Path(path).resolve() - dev_name = real_path.name - - # Find USB device info in sysfs - for usb_path in Path("/sys/bus/usb/devices").glob("*"): - product_file = usb_path / "product" - if product_file.exists(): - product = product_file.read_text().strip() - vendor_file = usb_path / "idVendor" - vendor_id = vendor_file.read_text().strip() if vendor_file.exists() else "" - - # Check if this is a Zigbee dongle - if vendor_id in ["10c4", "1a86", "0451"]: # CP210x, CH340, TI - dev_info["product"] = product - dev_info["vendor"] = vendor_id - if "10c4" in vendor_id: - dev_info["type"] = "cp210x" - elif "1a86" in vendor_id: - dev_info["type"] = "ch340" - break - except Exception: - pass - - devices.append(dev_info) - - return devices - - -def get_z2m_api_url() -> str: - """Get Zigbee2MQTT API URL.""" - cfg = get_config() - port = cfg.get("frontend_port", 8099) - return f"http://127.0.0.1:{port}/api" - - -async def z2m_api_request(endpoint: str, method: str = "GET", json_data: dict = None) -> dict: - """Make request to Zigbee2MQTT API.""" - url = f"{get_z2m_api_url()}/{endpoint}" - async with httpx.AsyncClient(timeout=30.0) as client: - if method == "GET": - resp = await client.get(url) - else: - resp = await client.post(url, json=json_data or {}) - resp.raise_for_status() - return resp.json() - - -# ============================================================================ -# Public Endpoints (no auth required) -# ============================================================================ - -@router.get("/health") -async def health(): - """Health check.""" - return {"status": "ok", "module": "zigbee"} - - -@router.get("/status") -async def status(): - """Get Zigbee2MQTT service status.""" - cfg = get_config() - rt = detect_runtime() - running = is_running() - - serial_port = cfg.get("serial_port", "/dev/ttyUSB0") - serial_device_exists = Path(serial_port).exists() - - uptime = 0 - version = "unknown" - coordinator = {} - device_count = 0 - web_accessible = False - - if running: - container = get_container_name() - - # Get uptime - if rt: - try: - result = subprocess.run( - [rt, "ps", "--filter", f"name={container}", "--format", "{{.Status}}"], - capture_output=True, text=True, timeout=5 - ) - status_str = result.stdout.strip().split('\n')[0] if result.stdout else "" - if "minute" in status_str: - uptime = int(''.join(filter(str.isdigit, status_str.split()[1]))) * 60 - elif "hour" in status_str: - uptime = int(''.join(filter(str.isdigit, status_str.split()[1]))) * 3600 - except Exception: - pass - - # Check web accessibility and get stats - try: - async with httpx.AsyncClient(timeout=5.0) as client: - resp = await client.get(f"http://127.0.0.1:{cfg.get('frontend_port', 8099)}/") - web_accessible = resp.status_code == 200 - - if web_accessible: - try: - bridge_info = await z2m_api_request("bridge") - version = bridge_info.get("version", "unknown") - coordinator = bridge_info.get("coordinator", {}) - except Exception: - pass - - try: - devices = await z2m_api_request("devices") - device_count = len(devices) if isinstance(devices, list) else 0 - except Exception: - pass - except Exception: - pass +# ── Endpoints ─────────────────────────────────────────────────────────────── +@app.get("/components") +def components() -> dict: + lxc_st = _lxc_state() + daemon_st = "running" if _z2m_running() else "stopped" + dev_st = "present" if _radio_present() else "absent" + bridge_st = _bridge_state() return { - "running": running, - "uptime": uptime, - "version": version, - "serial_port": serial_port, - "serial_device_exists": serial_device_exists, - "mqtt_host": cfg.get("mqtt_host", "mqtt://127.0.0.1:1883"), - "base_topic": cfg.get("base_topic", "zigbee2mqtt"), - "frontend_port": cfg.get("frontend_port", 8099), - "channel": cfg.get("channel", 11), - "permit_join": cfg.get("permit_join", False), - "runtime": rt or "none", - "web_accessible": web_accessible, - "device_count": device_count, - "coordinator": coordinator, + "module": "zigbee", + "version": "2.4.0", + "components": [ + {"name": "lxc", "state": lxc_st, "detail": f"{LXC_NAME} @ {LXC_IP} on br-lxc"}, + {"name": "device", "state": dev_st, "detail": "/dev/secubox-zgb"}, + {"name": "daemon", "state": daemon_st, "detail": f"zigbee2mqtt frontend :{FRONTEND_PORT}"}, + {"name": "bridge", "state": bridge_st, "detail": f"zigbee2mqtt/bridge/state @ {MQTT_LXC_IP}:{MQTT_PORT}"}, + ], } -# ============================================================================ -# Protected Endpoints (JWT required) -# ============================================================================ - -@router.get("/config") -async def get_zigbee_config(user=Depends(require_jwt)): - """Get Zigbee2MQTT configuration.""" - cfg = get_config() - # Don't expose password - cfg_safe = cfg.copy() - if cfg_safe.get("mqtt_password"): - cfg_safe["mqtt_password"] = "********" - return cfg_safe +@app.get("/status") +def status() -> dict: + c = components() + s = {x["name"]: x["state"] for x in c["components"]} + if s.get("lxc") == "running" and s.get("daemon") == "running" \ + and s.get("device") == "present" and s.get("bridge") == "online": + overall = "green" + elif s.get("lxc") == "running" and s.get("daemon") == "running": + # daemon up but no radio β€” expected on a board without dongle. + overall = "yellow" + elif s.get("lxc") == "running": + overall = "yellow" + else: + overall = "red" + return {"module": "zigbee", "version": "2.4.0", "overall": overall, "states": s} -@router.post("/config") -async def set_zigbee_config(config: ZigbeeConfig, user=Depends(require_jwt)): - """Update Zigbee2MQTT configuration.""" - CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True) - - # Get current config to preserve password if not changed - current = get_config() - password = config.mqtt_password - if password == "********": - password = current.get("mqtt_password", "") - - content = f"""# Zigbee2MQTT configuration -serial_port = "{config.serial_port}" -mqtt_host = "{config.mqtt_host}" -mqtt_username = "{config.mqtt_username or ''}" -mqtt_password = "{password}" -base_topic = "{config.base_topic}" -frontend_port = {config.frontend_port} -channel = {config.channel} -permit_join = {str(config.permit_join).lower()} -""" - CONFIG_FILE.write_text(content) - log.info(f"Config updated by {user.get('sub', 'unknown')}") - - # Regenerate Z2M configuration.yaml - await generate_z2m_config() - - return {"success": True} - - -async def generate_z2m_config(): - """Generate Zigbee2MQTT configuration.yaml file.""" - cfg = get_config() - config_dir = DATA_PATH / "data" - config_dir.mkdir(parents=True, exist_ok=True) - - serial_port = cfg.get("serial_port", "/dev/ttyUSB0") - - config_yaml = f"""# Zigbee2MQTT configuration - Auto-generated by SecuBox -homeassistant: - enabled: false - -permit_join: {str(cfg.get('permit_join', False)).lower()} - -mqtt: - base_topic: {cfg.get('base_topic', 'zigbee2mqtt')} - server: {cfg.get('mqtt_host', 'mqtt://127.0.0.1:1883')} -""" - - if cfg.get("mqtt_username"): - config_yaml += f" user: {cfg['mqtt_username']}\n" - if cfg.get("mqtt_password"): - config_yaml += f" password: {cfg['mqtt_password']}\n" - - config_yaml += f""" -serial: - port: /dev/ttyUSB0 - -advanced: - channel: {cfg.get('channel', 11)} - log_level: info - -frontend: - enabled: true - port: {cfg.get('frontend_port', 8099)} - host: 0.0.0.0 -""" - - config_file = config_dir / "configuration.yaml" - config_file.write_text(config_yaml) - config_file.chmod(0o600) - - -@router.get("/devices") -async def get_devices(user=Depends(require_jwt)): - """Get list of paired Zigbee devices.""" - if not is_running(): - return {"devices": [], "error": "Zigbee2MQTT not running"} - - try: - devices = await z2m_api_request("devices") - return {"devices": devices if isinstance(devices, list) else []} - except Exception as e: - log.error(f"Failed to get devices: {e}") - return {"devices": [], "error": str(e)} - - -@router.get("/devices/{device_id}") -async def get_device(device_id: str, user=Depends(require_jwt)): - """Get device details.""" - if not is_running(): - raise HTTPException(503, "Zigbee2MQTT not running") - - try: - devices = await z2m_api_request("devices") - for dev in devices: - if dev.get("ieee_address") == device_id or dev.get("friendly_name") == device_id: - return dev - raise HTTPException(404, "Device not found") - except HTTPException: - raise - except Exception as e: - raise HTTPException(500, str(e)) - - -@router.post("/devices/rename") -async def rename_device(req: DeviceRenameRequest, user=Depends(require_jwt)): - """Rename a device.""" - if not is_running(): - raise HTTPException(503, "Zigbee2MQTT not running") - - try: - await z2m_api_request(f"device/{req.old_name}/rename", "POST", {"new_name": req.new_name}) - log.info(f"Device {req.old_name} renamed to {req.new_name} by {user.get('sub', 'unknown')}") - return {"success": True} - except Exception as e: - return {"success": False, "error": str(e)} - - -@router.delete("/devices/{device_id}") -async def remove_device(device_id: str, force: bool = False, user=Depends(require_jwt)): - """Remove a device from the network.""" - if not is_running(): - raise HTTPException(503, "Zigbee2MQTT not running") - - try: - endpoint = f"device/{device_id}/remove" - if force: - endpoint += "?force=true" - await z2m_api_request(endpoint, "POST") - log.info(f"Device {device_id} removed by {user.get('sub', 'unknown')}") - return {"success": True} - except Exception as e: - return {"success": False, "error": str(e)} - - -@router.get("/network") -async def get_network(user=Depends(require_jwt)): - """Get network map/topology.""" - if not is_running(): - return {"map": None, "error": "Zigbee2MQTT not running"} - - try: - network = await z2m_api_request("networkmap") - return {"map": network} - except Exception as e: - return {"map": None, "error": str(e)} - - -@router.post("/permit_join") -async def set_permit_join(req: PermitJoinRequest, user=Depends(require_jwt)): - """Enable/disable device pairing.""" - if not is_running(): - raise HTTPException(503, "Zigbee2MQTT not running") - - try: - await z2m_api_request("bridge/permit_join", "POST", { - "value": req.permit, - "time": req.duration if req.permit else 0 - }) - log.info(f"Permit join {'enabled' if req.permit else 'disabled'} by {user.get('sub', 'unknown')}") - return {"success": True, "permit_join": req.permit} - except Exception as e: - return {"success": False, "error": str(e)} - - -@router.get("/serial_devices") -async def list_serial_devices(user=Depends(require_jwt)): - """List available USB serial devices.""" - devices = detect_serial_devices() - return {"devices": devices} - - -@router.get("/diagnostics") -async def get_diagnostics(user=Depends(require_jwt)): - """Get system diagnostics for Zigbee2MQTT.""" - cfg = get_config() - serial_port = cfg.get("serial_port", "/dev/ttyUSB0") - - # Check kernel modules - cp210x_loaded = False - ch341_loaded = False - try: - with open("/proc/modules") as f: - modules = f.read() - cp210x_loaded = "cp210x" in modules - ch341_loaded = "ch341" in modules - except Exception: - pass - - # Check serial device - serial_exists = Path(serial_port).exists() - serial_mode = "" - if serial_exists: - try: - import stat - mode = Path(serial_port).stat().st_mode - serial_mode = oct(stat.S_IMODE(mode)) - except Exception: - pass - - # Check container runtime - rt = detect_runtime() - - # Check MQTT - mqtt_available = False - mqtt_host = cfg.get("mqtt_host", "mqtt://127.0.0.1:1883") - try: - # Extract host:port from mqtt://host:port - import re - match = re.match(r"mqtt://([^:]+):(\d+)", mqtt_host) - if match: - host, port = match.groups() - import socket - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(2) - result = sock.connect_ex((host, int(port))) - mqtt_available = result == 0 - sock.close() - except Exception: - pass - +@app.get("/access") +def access() -> dict: return { - "serial": { - "port": serial_port, - "exists": serial_exists, - "mode": serial_mode, - }, - "kernel_modules": { - "cp210x": cp210x_loaded, - "ch341": ch341_loaded, - }, - "runtime": rt or "none", - "container_exists": is_running() or Path(DATA_PATH / "data").exists(), - "mqtt_available": mqtt_available, - "detected_devices": detect_serial_devices(), + "module": "zigbee", + "access": [ + {"endpoint": f"http://{LXC_IP}:{FRONTEND_PORT}/", + "scope": "lan", "auth": "none (LAN-only)"}, + {"endpoint": f"mqtt://{MQTT_LXC_IP}:{MQTT_PORT} (zigbee2mqtt/#)", + "scope": "lan", "auth": "z2m user"}, + ], } -@router.get("/system") -async def system_info(user=Depends(require_jwt)): - """Get system resource info.""" - cfg = get_config() - rt = detect_runtime() - - mem_total = mem_used = mem_pct = 0 - try: - with open("/proc/meminfo") as f: - for line in f: - if line.startswith("MemTotal:"): - mem_total = int(line.split()[1]) - elif line.startswith("MemAvailable:"): - mem_free = int(line.split()[1]) - mem_used = mem_total - mem_free - mem_pct = (mem_used * 100) // mem_total if mem_total else 0 - except Exception: - pass - - container_mem = "0" - container_cpu = "0%" - if is_running() and rt: - container = get_container_name() - try: - result = subprocess.run( - [rt, "stats", "--no-stream", "--format", "{{.MemUsage}} {{.CPUPerc}}", container], - capture_output=True, text=True, timeout=5 - ) - parts = result.stdout.strip().split() - if len(parts) >= 2: - container_mem = parts[0] - container_cpu = parts[-1] - except Exception: - pass - - return { - "memory": { - "total_kb": mem_total, - "used_kb": mem_used, - "percent": mem_pct, - }, - "container": { - "memory": container_mem, - "cpu": container_cpu, - }, - } - - -@router.get("/logs") -async def get_logs(lines: int = 100, user=Depends(require_jwt)): - """Get recent logs.""" - rt = detect_runtime() - logs = [] - - # Try container logs first - if rt and is_running(): - container = get_container_name() - try: - result = subprocess.run( - [rt, "logs", "--tail", str(lines), container], - capture_output=True, text=True, timeout=10 - ) - if result.stdout: - logs = result.stdout.strip().split('\n') - if result.stderr: - logs.extend(result.stderr.strip().split('\n')) - except Exception: - pass - - # Fallback to log file - if not logs: - log_file = DATA_PATH / "data" / "log" / "zigbee2mqtt.log" - if log_file.exists(): - try: - with open(log_file) as f: - all_lines = f.readlines() - logs = [l.strip() for l in all_lines[-lines:]] - except Exception: - pass - - return {"logs": logs[-lines:] if logs else []} - - -# ============================================================================ -# Service Control -# ============================================================================ - -@router.post("/install") -async def install_service(user=Depends(require_jwt)): - """Pull Zigbee2MQTT container image.""" - rt = detect_runtime() - if not rt: - return {"success": False, "error": "No container runtime (docker/podman) found"} - - log.info(f"Installing Zigbee2MQTT by {user.get('sub', 'unknown')}") - - try: - result = subprocess.run( - [rt, "pull", "koenkk/zigbee2mqtt:latest"], - capture_output=True, text=True, timeout=600 - ) - if result.returncode == 0: - # Generate initial config - await generate_z2m_config() - return {"success": True} - else: - return {"success": False, "error": result.stderr.strip()} - except subprocess.TimeoutExpired: - return {"success": False, "error": "Pull timeout"} - - -@router.post("/start") -async def start_service(user=Depends(require_jwt)): - """Start Zigbee2MQTT container.""" - if is_running(): - return {"success": False, "error": "Already running"} - - rt = detect_runtime() - if not rt: - return {"success": False, "error": "No container runtime (docker/podman) found"} - - cfg = get_config() - serial_port = cfg.get("serial_port", "/dev/ttyUSB0") - - # Check serial device - if not Path(serial_port).exists(): - return {"success": False, "error": f"Serial device {serial_port} not found"} - - # Ensure data directory and config - DATA_PATH.mkdir(parents=True, exist_ok=True) - (DATA_PATH / "data").mkdir(exist_ok=True) - await generate_z2m_config() - - cmd = [ - rt, "run", "-d", - "--name", "zigbee2mqtt", - "--device", f"{serial_port}:/dev/ttyUSB0", - "-v", f"{DATA_PATH}/data:/app/data", - "-p", f"127.0.0.1:{cfg.get('frontend_port', 8099)}:8080", - "-e", "TZ=UTC", - "--restart", "unless-stopped", - "koenkk/zigbee2mqtt:latest", - ] - - log.info(f"Starting Zigbee2MQTT by {user.get('sub', 'unknown')}") - - try: - result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) - await asyncio.sleep(5) # Z2M takes time to initialize - - if is_running(): - return {"success": True} - else: - return {"success": False, "error": result.stderr.strip() or "Failed to start"} - except subprocess.TimeoutExpired: - return {"success": False, "error": "Start timeout"} - except Exception as e: - return {"success": False, "error": str(e)} - - -@router.post("/stop") -async def stop_service(user=Depends(require_jwt)): - """Stop Zigbee2MQTT container.""" - rt = detect_runtime() - if not rt: - return {"success": False, "error": "No container runtime"} - - container = get_container_name() - log.info(f"Stopping Zigbee2MQTT by {user.get('sub', 'unknown')}") - - try: - subprocess.run([rt, "stop", container], capture_output=True, timeout=30) - subprocess.run([rt, "rm", "-f", container], capture_output=True, timeout=10) - - if not is_running(): - return {"success": True} - else: - return {"success": False, "error": "Failed to stop"} - except Exception as e: - return {"success": False, "error": str(e)} - - -@router.post("/restart") -async def restart_service(user=Depends(require_jwt)): - """Restart Zigbee2MQTT container.""" - await stop_service(user) - await asyncio.sleep(2) - return await start_service(user) - - -@router.post("/update") -async def update_service(user=Depends(require_jwt)): - """Update Zigbee2MQTT to latest version.""" - rt = detect_runtime() - if not rt: - return {"success": False, "error": "No container runtime"} - - was_running = is_running() - - if was_running: - await stop_service(user) - await asyncio.sleep(2) - - log.info(f"Updating Zigbee2MQTT by {user.get('sub', 'unknown')}") - - try: - result = subprocess.run( - [rt, "pull", "koenkk/zigbee2mqtt:latest"], - capture_output=True, text=True, timeout=600 - ) - if result.returncode != 0: - return {"success": False, "error": result.stderr.strip()} - - if was_running: - await start_service(user) - - return {"success": True} - except Exception as e: - return {"success": False, "error": str(e)} - - -app.include_router(router) +@app.get("/healthz") +def healthz() -> dict: + return {"ok": True, "module": "zigbee", "version": "2.4.0"} diff --git a/packages/secubox-zigbee/api/routers/__init__.py b/packages/secubox-zigbee/api/routers/__init__.py deleted file mode 100644 index 8b137891..00000000 --- a/packages/secubox-zigbee/api/routers/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/packages/secubox-zigbee/conf/zigbee.toml.example b/packages/secubox-zigbee/conf/zigbee.toml.example new file mode 100644 index 00000000..930f422b --- /dev/null +++ b/packages/secubox-zigbee/conf/zigbee.toml.example @@ -0,0 +1,37 @@ +# /etc/secubox/zigbee.toml β€” SecuBox Zigbee (zigbee2mqtt) configuration + +[lxc] +name = "zigbee" +ip = "10.100.0.111" +gateway = "10.100.0.1" +bridge = "br-lxc" +path = "/data/lxc" +debian_suite = "bookworm" + +[zigbee] +# z2m frontend (LAN-only, no auth in v2.4.0) +frontend_port = 8080 +# Channel β€” 15, 20, 25 or 26 (avoid WiFi overlap; 20 is the default) +channel = 20 +# permit_join_timeout β€” max window (seconds) a single `permit-join on` call opens +permit_join_timeout = 60 + +[mqtt] +# The SecuBox Mosquitto broker (provisioned by secubox-mqtt v2.4) +ip = "10.100.0.110" +port = 1883 +user = "z2m" +# Password is NOT in this file. install-lxc.sh reads it from +# /etc/secubox/secrets/mqtt-z2m and writes it into z2m's configuration.yaml. + +[device] +# Auto-detected by udev rule /etc/udev/rules.d/99-secubox-zigbee.rules +# (Sonoff CC2652P 1a86:55d4, ConBee II 1cf1:0030, CP210x 10c4:ea60). +# The symlink /dev/secubox-zgb is bind-mounted into the LXC. +symlink = "/dev/secubox-zgb" + +[exposure] +# zigbee2mqtt frontend is LAN-only by design. For an authenticated remote +# exposure, the canonical pattern is a sites-available vhost behind +# HAProxy + Authelia (matches the yacy/lyrion exposure pattern). +lan_only = true diff --git a/packages/secubox-zigbee/debian/changelog b/packages/secubox-zigbee/debian/changelog index f0e60ad0..20e458be 100644 --- a/packages/secubox-zigbee/debian/changelog +++ b/packages/secubox-zigbee/debian/changelog @@ -1,3 +1,40 @@ +secubox-zigbee (2.4.0-1~bookworm1) bookworm; urgency=medium + + * v2.4.0 rewrite-in-place: move zigbee2mqtt into a dedicated LXC at + 10.100.0.111 on br-lxc (sibling of mqtt v2.4.0). The v1.0.0 layout + is gone; the host-side zigbee2mqtt (if any) is masked on first + install via migrate_from_v1(). + * Hard limits per docs/superpowers/specs/2026-05-20-secubox-zigbee-mqtt-iot-stack.md: + - permit_join: false in the rendered configuration.yaml (operator + opens explicitly + timed via `zigbeectl permit-join on N`) + - network_key / pan_id / ext_pan_id: GENERATE (never hard-coded) + - daemon User=zigbee2mqtt + dialout group + NoNewPrivileges=true + + ProtectSystem=strict + * Depends: secubox-mqtt (>= 2.4). Credentials read from + /etc/secubox/secrets/mqtt-z2m (provisioned by secubox-mqtt) and + rendered into /opt/zigbee2mqtt/data/configuration.yaml. + * udev rule /etc/udev/rules.d/99-secubox-zigbee.rules creates + /dev/secubox-zgb when a Sonoff CC2652P (1a86:55d4), ConBee II + (1cf1:0030), or CP210x (10c4:ea60, alt symlink) is plugged in. + LXC config bind-mounts /dev/secubox-zgb with create=file,optional + so the LXC starts whether or not the dongle is present. + * zigbee2mqtt 2.0.0 cloned from upstream tag, npm ci --omit=dev. + Node 20 installed from the NodeSource repo (Debian bookworm only + ships Node 18, too old for z2m 2.x). + * zigbeectl follows the SecuBox CTL grammar: + - introspection: components / status / access + - lifecycle: install / reload (+ stubs) + - module nouns: device / network / topology / permit-join + (list/show + permit-join window in v2.4.0) + * Host control plane: FastAPI on /run/secubox/zigbee.sock with + /components, /status, /access, /healthz. Reverse-proxied at + /api/v1/zigbee/ on the canonical hub vhost. Reads + zigbee2mqtt/bridge/state from the broker to populate the bridge + component state. + * Closes #241 + + -- Gerald KERMA Wed, 20 May 2026 23:30:00 +0200 + secubox-zigbee (1.0.0-1~bookworm1) bookworm; urgency=medium * Initial release. diff --git a/packages/secubox-zigbee/debian/control b/packages/secubox-zigbee/debian/control index 04c33433..22069e31 100644 --- a/packages/secubox-zigbee/debian/control +++ b/packages/secubox-zigbee/debian/control @@ -4,18 +4,40 @@ Priority: optional Maintainer: Gerald KERMA Build-Depends: debhelper-compat (= 13) Standards-Version: 4.6.2 +Homepage: https://cybermind.fr/secubox Package: secubox-zigbee Architecture: all -Depends: ${misc:Depends}, secubox-core (>= 1.0), python3-uvicorn | python3-pip, python3-httpx | python3-pip -Description: Zigbee2MQTT Gateway for SecuBox - Debian bookworm port of the OpenWRT luci-app-zigbee2mqtt module. - Provides FastAPI backend on /api/v1/zigbee/ via Unix socket. +Depends: ${misc:Depends}, + secubox-core (>= 1.0), + secubox-mqtt (>= 2.4), + lxc, + lxc-templates, + debootstrap, + python3-uvicorn, + python3-fastapi, + python3-toml, + openssl, + udev +Recommends: secubox-iot-hub +Description: SecuBox Zigbee β€” Coordinator RF IoT (MIND layer) + Hosts zigbee2mqtt 2.x in a Debian bookworm LXC at 10.100.0.111 on br-lxc. . - Features: - - Docker/Podman container management for Zigbee2MQTT - - USB serial dongle detection and passthrough - - Device pairing and management - - MQTT broker integration - - Network topology visualization - - CRT-light P31 phosphor theme frontend + Wraps the upstream zigbee2mqtt daemon (Apache-2.0) with a SecuBox host + control plane: FastAPI on /run/secubox/zigbee.sock, reverse-proxied at + /api/v1/zigbee/ on the canonical hub vhost. + . + The Sonoff Zigbee 3.0 USB Plus dongle (CC2652P, VID:PID 1a86:55d4) is + detected by a shipped udev rule and exposed inside the LXC at + /dev/secubox-zgb. ConBee II (1cf1:0030) and generic CP210x (10c4:ea60) + are also recognized. + . + zigbee2mqtt is wired to the SecuBox broker (secubox-mqtt v2.4) as user + z2m on topic zigbee2mqtt/# β€” credentials are read from + /etc/secubox/secrets/mqtt-z2m at install time and rendered into + configuration.yaml. network_key and pan_id are GENERATEd by zigbee2mqtt + on first run; never hard-coded. + . + zigbeectl follows the SecuBox CTL grammar: components / status / + access + install / reload + device / network / topology / permit-join + nouns (list-only in v2.4.0). diff --git a/packages/secubox-zigbee/debian/postinst b/packages/secubox-zigbee/debian/postinst index 402ae31e..bbd9b047 100755 --- a/packages/secubox-zigbee/debian/postinst +++ b/packages/secubox-zigbee/debian/postinst @@ -1,27 +1,42 @@ -#!/bin/bash +#!/bin/sh +# SecuBox Zigbee β€” post-install set -e + case "$1" in - configure) - # Ensure secubox user exists - id -u secubox >/dev/null 2>&1 || \ - adduser --system --group --no-create-home \ - --home /var/lib/secubox --shell /usr/sbin/nologin secubox - # Create runtime directories - install -d -o secubox -g secubox -m 750 /run/secubox - install -d -o secubox -g secubox -m 750 /var/lib/secubox - # Create data directories - install -d -m 755 /srv/zigbee2mqtt - install -d -m 755 /srv/zigbee2mqtt/data - # Ensure nginx secubox.d directory exists - install -d -m 755 /etc/nginx/secubox.d - # Ensure config directory exists - install -d -m 755 /etc/secubox - # Enable and start service - systemctl daemon-reload - systemctl enable secubox-zigbee.service - systemctl start secubox-zigbee.service || true - # Reload nginx to pick up new location - systemctl reload nginx 2>/dev/null || true - ;; + configure) + # secubox system user/group (idempotent, matches sibling modules) + getent group secubox >/dev/null || groupadd --system secubox + getent passwd secubox >/dev/null || useradd --system --gid secubox \ + --home /var/lib/secubox --no-create-home --shell /usr/sbin/nologin secubox + + install -d -m 0770 -o root -g secubox /etc/secubox + install -d -m 0750 -o root -g secubox /etc/secubox/secrets + install -d -m 0755 -o secubox -g secubox /var/lib/secubox/zigbee + install -d -m 0755 -o secubox -g secubox /var/log/secubox + + # Seed config from example if no live config exists + if [ ! -f /etc/secubox/zigbee.toml ] && [ -f /etc/secubox/zigbee.toml.example ]; then + cp /etc/secubox/zigbee.toml.example /etc/secubox/zigbee.toml + chmod 640 /etc/secubox/zigbee.toml + chown root:secubox /etc/secubox/zigbee.toml + fi + + # Reload nginx if it's running and our snippet parses + if systemctl is-active --quiet nginx 2>/dev/null; then + nginx -t >/dev/null 2>&1 && systemctl reload nginx 2>/dev/null || true + fi + + # Reload udev so the shipped rule takes effect on hotplug + if [ -x /usr/bin/udevadm ] || [ -x /sbin/udevadm ]; then + udevadm control --reload-rules 2>/dev/null || true + fi + + systemctl daemon-reload 2>/dev/null || true + systemctl enable secubox-zigbee.service 2>/dev/null || true + # Do not start the FastAPI before the LXC is provisioned; + # `zigbeectl install` will start the service after the LXC is up. + ;; esac + #DEBHELPER# +exit 0 diff --git a/packages/secubox-zigbee/debian/postrm b/packages/secubox-zigbee/debian/postrm new file mode 100755 index 00000000..69b50f05 --- /dev/null +++ b/packages/secubox-zigbee/debian/postrm @@ -0,0 +1,15 @@ +#!/bin/sh +# SecuBox Zigbee β€” post-remove +set -e + +case "$1" in + purge) + rm -f /etc/secubox/zigbee.toml /etc/secubox/zigbee.toml.example + rm -f /etc/udev/rules.d/99-secubox-zigbee.rules + rm -rf /var/lib/secubox/zigbee + # LXC + z2m data preserved; operator removes via `zigbeectl uninstall`. + ;; +esac + +#DEBHELPER# +exit 0 diff --git a/packages/secubox-zigbee/debian/prerm b/packages/secubox-zigbee/debian/prerm index ecfd3ca1..ecf62028 100755 --- a/packages/secubox-zigbee/debian/prerm +++ b/packages/secubox-zigbee/debian/prerm @@ -1,14 +1,13 @@ -#!/bin/bash +#!/bin/sh +# SecuBox Zigbee β€” pre-remove set -e + case "$1" in - remove|upgrade) - # Remove nginx location config - rm -f /etc/nginx/secubox.d/zigbee.conf - # Stop and disable service - systemctl stop secubox-zigbee.service 2>/dev/null || true - systemctl disable secubox-zigbee.service 2>/dev/null || true - # Reload nginx to remove location - systemctl reload nginx 2>/dev/null || true - ;; + remove|deconfigure|upgrade) + systemctl stop secubox-zigbee.service 2>/dev/null || true + systemctl disable secubox-zigbee.service 2>/dev/null || true + ;; esac + #DEBHELPER# +exit 0 diff --git a/packages/secubox-zigbee/debian/rules b/packages/secubox-zigbee/debian/rules index 9247b608..c810dadf 100755 --- a/packages/secubox-zigbee/debian/rules +++ b/packages/secubox-zigbee/debian/rules @@ -3,22 +3,27 @@ dh $@ override_dh_auto_install: - # API files (to /usr/share/secubox/zigbee for service WorkingDirectory) - install -d debian/secubox-zigbee/usr/share/secubox/zigbee/ - cp -r api debian/secubox-zigbee/usr/share/secubox/zigbee/ - # Static www files + # Host control plane (FastAPI) + install -d debian/secubox-zigbee/usr/lib/secubox/zigbee + cp -r api debian/secubox-zigbee/usr/lib/secubox/zigbee/ + # CTL + install -d debian/secubox-zigbee/usr/sbin + install -m 755 sbin/zigbeectl debian/secubox-zigbee/usr/sbin/zigbeectl + # Web UI install -d debian/secubox-zigbee/usr/share/secubox/www [ -d www ] && cp -r www/. debian/secubox-zigbee/usr/share/secubox/www/ || true - # Menu definitions + # Menu entry install -d debian/secubox-zigbee/usr/share/secubox/menu.d [ -d menu.d ] && cp -r menu.d/. debian/secubox-zigbee/usr/share/secubox/menu.d/ || true - # Modular nginx config + # nginx snippet β€” install to BOTH secubox.d/ and secubox-routes.d/ (per v2.11.1 lesson) install -d debian/secubox-zigbee/etc/nginx/secubox.d - [ -f nginx/zigbee.conf ] && cp nginx/zigbee.conf debian/secubox-zigbee/etc/nginx/secubox.d/ || true - # Systemd service - install -d debian/secubox-zigbee/usr/lib/systemd/system - cp debian/secubox-zigbee.service debian/secubox-zigbee/usr/lib/systemd/system/ - -override_dh_installsystemd: - # Skip dh_installsystemd since we handle it manually - true + [ -f nginx/zigbee.conf ] && cp nginx/zigbee.conf debian/secubox-zigbee/etc/nginx/secubox.d/ && (install -d debian/secubox-zigbee/etc/nginx/secubox-routes.d && cp nginx/zigbee.conf debian/secubox-zigbee/etc/nginx/secubox-routes.d/) || true + # Config example + install -d debian/secubox-zigbee/etc/secubox + [ -f conf/zigbee.toml.example ] && cp conf/zigbee.toml.example debian/secubox-zigbee/etc/secubox/zigbee.toml.example || true + # LXC bootstrap + install -d debian/secubox-zigbee/usr/share/secubox/lib/zigbee + [ -d lib/zigbee ] && cp -r lib/zigbee/. debian/secubox-zigbee/usr/share/secubox/lib/zigbee/ || true + # install-lxc.sh must be executable (cp -r doesn't always preserve +x from git) + [ -f debian/secubox-zigbee/usr/share/secubox/lib/zigbee/install-lxc.sh ] && \ + chmod 755 debian/secubox-zigbee/usr/share/secubox/lib/zigbee/install-lxc.sh diff --git a/packages/secubox-zigbee/debian/secubox-zigbee.service b/packages/secubox-zigbee/debian/secubox-zigbee.service index c3df1f02..5112e1fd 100644 --- a/packages/secubox-zigbee/debian/secubox-zigbee.service +++ b/packages/secubox-zigbee/debian/secubox-zigbee.service @@ -1,28 +1,27 @@ [Unit] -Description=SecuBox Zigbee2MQTT Gateway API -After=network.target secubox-runtime.service -Requires=secubox-runtime.service -# Only start if explicitly enabled (Zigbee2MQTT configured) -ConditionPathExists=/etc/secubox/zigbee/enabled +Description=SecuBox Zigbee β€” host control plane API +Documentation=file:///usr/share/doc/secubox-zigbee/README +After=network.target secubox-core.service secubox-mqtt.service +Wants=secubox-core.service [Service] +UMask=0007 Type=simple -User=root -Group=root -WorkingDirectory=/usr/share/secubox/zigbee -ExecStart=/usr/bin/python3 -m uvicorn api.main:app --uds /run/secubox/zigbee.sock --workers 1 -ExecStartPost=-/bin/sleep 1 -ExecStartPost=-/bin/chmod 660 /run/secubox/zigbee.sock -ExecStartPost=-/bin/chown secubox:secubox /run/secubox/zigbee.sock +User=secubox +Group=secubox +WorkingDirectory=/usr/lib/secubox/zigbee +RuntimeDirectory=secubox +RuntimeDirectoryMode=0755 +RuntimeDirectoryPreserve=yes +ExecStartPre=+/bin/mkdir -p /etc/secubox +ExecStartPre=+/bin/chown secubox:secubox /etc/secubox +ExecStart=/usr/bin/python3 -m uvicorn api.main:app --uds /run/secubox/zigbee.sock --log-level warning Restart=on-failure RestartSec=5 - -# Security hardening -NoNewPrivileges=no - -ProtectHome=read-only - -ReadWritePaths=/run/secubox /var/lib/secubox /etc/secubox /srv/zigbee2mqtt +NoNewPrivileges=true +LogsDirectory=secubox +LogsDirectoryMode=0755 +ReadWritePaths=/run/secubox /var/lib/secubox /etc/secubox /var/log/secubox [Install] WantedBy=multi-user.target diff --git a/packages/secubox-zigbee/debian/secubox.yaml b/packages/secubox-zigbee/debian/secubox.yaml deleted file mode 100644 index 29e2c627..00000000 --- a/packages/secubox-zigbee/debian/secubox.yaml +++ /dev/null @@ -1,17 +0,0 @@ -# debian/secubox.yaml -# Auto-generated from debian/control - -name: secubox-zigbee -category: iot -tier: lite -description: "Zigbee2MQTT Gateway for SecuBox" - -depends: - - secubox-core - -api: - socket: /run/secubox/zigbee.sock - health: /api/v1/zigbee/health - -ui: - path: /srv/secubox/www/zigbee diff --git a/packages/secubox-zigbee/lib/zigbee/install-lxc.sh b/packages/secubox-zigbee/lib/zigbee/install-lxc.sh new file mode 100755 index 00000000..f8b70597 --- /dev/null +++ b/packages/secubox-zigbee/lib/zigbee/install-lxc.sh @@ -0,0 +1,351 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# SecuBox-Deb :: secubox-zigbee :: install-lxc.sh +# +# Idempotent LXC bootstrap for zigbee2mqtt 2.x. Safe to re-run. +# Inherits the lessons from #240 mqtt v2.4.0: +# - migrate_from_v1() pattern (mask any host-side z2m if previously installed) +# - bash `is-active` check instead of `list-unit-files | grep` prefilter +# Follows docs/MODULE-GUIDELINES.md Β§3 + Β§7. +# +# Hard limits (per docs/superpowers/specs/2026-05-20-secubox-zigbee-mqtt-iot-stack.md): +# - permit_join: false in production config (operator opens explicitly + timed) +# - network_key / pan_id / ext_pan_id: GENERATE β€” never hard-coded +# - User systemd inside LXC: zigbee2mqtt (non-root), with dialout group for /dev tty access +# - z2m credentials read from /etc/secubox/secrets/mqtt-z2m (provisioned by secubox-mqtt) + +set -euo pipefail + +readonly LXC_NAME="${SECUBOX_LXC_NAME:-zigbee}" +readonly LXC_IP="${SECUBOX_LXC_IP:-10.100.0.111}" +readonly LXC_PATH="${SECUBOX_LXC_PATH:-/data/lxc}" +readonly LXC_BRIDGE="${SECUBOX_LXC_BRIDGE:-br-lxc}" +readonly LXC_GW="${SECUBOX_LXC_GW:-10.100.0.1}" +readonly DEBIAN_SUITE="${SECUBOX_DEBIAN_SUITE:-bookworm}" +readonly MQTT_LXC_IP="${SECUBOX_MQTT_LXC_IP:-10.100.0.110}" +readonly MQTT_PORT="${SECUBOX_MQTT_PORT:-1883}" +readonly Z2M_FRONTEND_PORT="${SECUBOX_Z2M_FRONTEND_PORT:-8080}" +readonly Z2M_VERSION="${SECUBOX_Z2M_VERSION:-2.10.1}" +readonly NODE_MAJOR="${SECUBOX_NODE_MAJOR:-20}" +readonly STATE_DIR="${SECUBOX_STATE_DIR:-/var/lib/secubox/zigbee}" +readonly SECRETS_DIR="${SECUBOX_SECRETS_DIR:-/etc/secubox/secrets}" +readonly SENTINEL="$STATE_DIR/.lxc-provisioned" + +log() { printf '[zigbee-install] %s\n' "$*"; } +fail() { printf '[zigbee-install] ERROR: %s\n' "$*" >&2; exit 1; } + +require_cmds() { + for c in lxc-create lxc-info lxc-start lxc-attach openssl nft; do + command -v "$c" >/dev/null 2>&1 || fail "$c not installed" + done +} + +ensure_dirs() { + install -d -m 0755 -o root -g root "$LXC_PATH" + install -d -m 0755 -o secubox -g secubox "$STATE_DIR" + # NOTE: the secrets dir must be group-readable by `secubox` so the host + # FastAPI (uvicorn @ /run/secubox/zigbee.sock, runs as `secubox`) can + # read /etc/secubox/secrets/mqtt-z2m. `install -d` on an existing dir + # doesn't always re-apply mode/owner on bookworm; chmod/chown explicitly. + install -d "$SECRETS_DIR" + chmod 0750 "$SECRETS_DIR" + chown root:secubox "$SECRETS_DIR" +} + +ensure_bridge() { + if ! ip link show "$LXC_BRIDGE" >/dev/null 2>&1; then + log "Creating bridge $LXC_BRIDGE @ ${LXC_GW}/24 ..." + ip link add name "$LXC_BRIDGE" type bridge + ip addr add "${LXC_GW}/24" dev "$LXC_BRIDGE" + ip link set "$LXC_BRIDGE" up + fi +} + +ensure_masquerade() { + if ! nft list table ip lxc 2>/dev/null | grep -q 'saddr 10.100.0.0/24'; then + log "Adding nftables MASQUERADE for 10.100.0.0/24 ..." + nft 'add table ip lxc' 2>/dev/null || true + nft 'add chain ip lxc postrouting { type nat hook postrouting priority srcnat ; policy accept ; }' 2>/dev/null || true + nft 'add rule ip lxc postrouting ip saddr 10.100.0.0/24 ip daddr != 10.100.0.0/24 counter masquerade' 2>/dev/null || true + fi +} + +migrate_from_v1() { + if systemctl is-active --quiet zigbee2mqtt 2>/dev/null; then + log "Stopping host-side zigbee2mqtt (legacy install) ..." + systemctl stop zigbee2mqtt 2>/dev/null || true + systemctl disable zigbee2mqtt 2>/dev/null || true + systemctl mask zigbee2mqtt 2>/dev/null || true + fi +} + +lxc_state() { + lxc-info -n "$LXC_NAME" -P "$LXC_PATH" 2>/dev/null \ + | awk -F: '/^State:/ { gsub(/ /,"",$2); print tolower($2) }' +} + +create_lxc() { + if [ -d "$LXC_PATH/$LXC_NAME/rootfs" ]; then + log "LXC '$LXC_NAME' already exists β€” skipping debootstrap" + return + fi + log "Creating LXC '$LXC_NAME' (debian $DEBIAN_SUITE) ..." + lxc-create -n "$LXC_NAME" -t download -P "$LXC_PATH" \ + -- --dist debian --release "$DEBIAN_SUITE" \ + --arch "$(dpkg --print-architecture)" +} + +# Bind-mount /dev/secubox-zgb (from the udev rule) into the LXC with the +# 'optional' flag so the LXC starts even without the dongle plugged in β€” +# z2m will crash-loop until the device shows up. +write_lxc_config() { + log "Pinning network: $LXC_IP/24 on $LXC_BRIDGE; passing /dev/secubox-zgb if present" + cat > "$LXC_PATH/$LXC_NAME/config" </dev/null 2>&1 && return 0 + sleep 1 + done + fail "LXC '$LXC_NAME' did not reach $LXC_GW within 30s" +} + +ensure_resolv() { + log "Seeding /etc/resolv.conf in LXC ..." + lxc-attach -n "$LXC_NAME" -P "$LXC_PATH" -- sh -c ' + rm -f /etc/resolv.conf + printf "nameserver 1.1.1.1\nnameserver 9.9.9.9\n" > /etc/resolv.conf + ' +} + +# Install Node 20 from NodeSource + zigbee2mqtt from npm registry. +# zigbee2mqtt 2.x requires Node 20; Debian bookworm ships Node 18. +# +# IMPORTANT lesson from the v2.0.0 attempt: the GitHub release tag ships +# only TypeScript sources β€” `npm start` calls `pnpm run build` which is +# fragile (had a `worker-timers-broker` TS error in 2.0.0). The npm tarball, +# however, ships a pre-built dist/ with all compiled JS β€” so installing via +# `npm install zigbee2mqtt@` skips the entire build chain. +install_zigbee2mqtt_in_lxc() { + log "Installing Node ${NODE_MAJOR} + zigbee2mqtt v${Z2M_VERSION} in '$LXC_NAME' ..." + lxc-attach -n "$LXC_NAME" -P "$LXC_PATH" -- env NODE_MAJOR="$NODE_MAJOR" Z2M_VERSION="$Z2M_VERSION" \ + bash -e <<'INNER' + set -euo pipefail + export DEBIAN_FRONTEND=noninteractive + + apt-get update -q + apt-get install -y --no-install-recommends \ + ca-certificates curl wget gnupg make g++ python3 \ + udev + + if [ ! -f /etc/apt/keyrings/nodesource.gpg ]; then + install -d -m 0755 /etc/apt/keyrings + curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \ + | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg + chmod 0644 /etc/apt/keyrings/nodesource.gpg + echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_MAJOR}.x nodistro main" \ + > /etc/apt/sources.list.d/nodesource.list + apt-get update -q + fi + apt-get install -y --no-install-recommends nodejs + + getent group zigbee2mqtt >/dev/null || groupadd --system zigbee2mqtt + getent passwd zigbee2mqtt >/dev/null || useradd --system --gid zigbee2mqtt \ + --home /opt/zigbee2mqtt --no-create-home --shell /usr/sbin/nologin zigbee2mqtt + usermod -aG dialout zigbee2mqtt 2>/dev/null || true + + # /opt/zigbee2mqtt is the prefix. The actual daemon lives in + # node_modules/zigbee2mqtt after `npm install zigbee2mqtt@`, + # and the runtime data dir is /opt/zigbee2mqtt/data (env Z2M_DATA). + install -d -m 0755 -o zigbee2mqtt -g zigbee2mqtt /opt/zigbee2mqtt + install -d -m 0755 -o zigbee2mqtt -g zigbee2mqtt /opt/zigbee2mqtt/data + + # Clean any leftover from a previous git-clone install attempt. + # We're switching to npm-install layout; .git and old dist/ are + # safe to drop. + if [ -d /opt/zigbee2mqtt/.git ]; then + rm -rf /opt/zigbee2mqtt/.git /opt/zigbee2mqtt/node_modules \ + /opt/zigbee2mqtt/package*.json /opt/zigbee2mqtt/index.js \ + /opt/zigbee2mqtt/cli.js /opt/zigbee2mqtt/dist /opt/zigbee2mqtt/.npm + fi + + # Bootstrap a minimal package.json so `npm install ` works. + if [ ! -f /opt/zigbee2mqtt/package.json ]; then + cd /opt/zigbee2mqtt + # NOTE: sudo is broken in unprivileged LXC; runuser is the + # drop-in replacement that doesn't care about /etc/sudo.conf + # ownership. + runuser -u zigbee2mqtt -- bash -c 'cd /opt/zigbee2mqtt && npm init -y' >/dev/null + fi + + cd /opt/zigbee2mqtt + runuser -u zigbee2mqtt -- bash -c \ + "cd /opt/zigbee2mqtt && npm install --omit=dev --no-audit --no-fund zigbee2mqtt@${Z2M_VERSION}" 2>&1 | tail -5 + + cat > /etc/systemd/system/zigbee2mqtt.service < "$stage" </dev/null + lxc-attach -n "$LXC_NAME" -P "$LXC_PATH" -- chown zigbee2mqtt:zigbee2mqtt /opt/zigbee2mqtt/data/configuration.yaml + lxc-attach -n "$LXC_NAME" -P "$LXC_PATH" -- chmod 0640 /opt/zigbee2mqtt/data/configuration.yaml + rm -f "$stage" + + lxc-attach -n "$LXC_NAME" -P "$LXC_PATH" -- systemctl enable zigbee2mqtt 2>/dev/null || true + lxc-attach -n "$LXC_NAME" -P "$LXC_PATH" -- systemctl restart zigbee2mqtt 2>/dev/null || true +} + +# Install the udev rule on the host so /dev/secubox-zgb is created when a +# Zigbee dongle gets plugged in. LXC bind-mount is optional β€” the LXC starts +# regardless of dongle presence. +install_udev_rule() { + local rule_file=/etc/udev/rules.d/99-secubox-zigbee.rules + log "Installing udev rule for Zigbee dongles ..." + cat > "$rule_file" <<'UDEV' +# /etc/udev/rules.d/99-secubox-zigbee.rules +# Installed by secubox-zigbee (#241) + +# Sonoff Zigbee 3.0 USB Plus (CC2652P) +SUBSYSTEM=="tty", ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="55d4", \ + SYMLINK+="secubox-zgb", MODE="0660", GROUP="dialout" + +# ConBee II (fallback) +SUBSYSTEM=="tty", ATTRS{idVendor}=="1cf1", ATTRS{idProduct}=="0030", \ + SYMLINK+="secubox-zgb", MODE="0660", GROUP="dialout" + +# Generic CP210x (alt β€” ConBee III, Slaesh's CC2652RB) +SUBSYSTEM=="tty", ATTRS{idVendor}=="10c4", ATTRS{idProduct}=="ea60", \ + SYMLINK+="secubox-zgb-alt", MODE="0660", GROUP="dialout" +UDEV + chmod 0644 "$rule_file" + udevadm control --reload-rules + udevadm trigger --subsystem-match=tty 2>/dev/null || true +} + +mark_provisioned() { + install -d -m 0755 -o secubox -g secubox "$STATE_DIR" + date -Iseconds > "$SENTINEL" +} + +main() { + require_cmds + migrate_from_v1 + ensure_dirs + install_udev_rule + ensure_bridge + ensure_masquerade + create_lxc + write_lxc_config + start_lxc + wait_for_network + ensure_resolv + install_zigbee2mqtt_in_lxc + configure_zigbee2mqtt + mark_provisioned + log "OK β€” LXC '$LXC_NAME' at $LXC_IP, zigbee2mqtt provisioned." + log " Β· Frontend (LXC) : http://$LXC_IP:$Z2M_FRONTEND_PORT/" + log " Β· MQTT bridge : mqtt://$MQTT_LXC_IP:$MQTT_PORT (user z2m)" + log " Β· Radio symlink : /dev/secubox-zgb (CC2652P / ConBee II)" + log " Β· Status : zigbeectl status (radio absent β†’ 'device: absent')" +} + +main "$@" diff --git a/packages/secubox-zigbee/menu.d/80-zigbee.json b/packages/secubox-zigbee/menu.d/80-zigbee.json new file mode 100644 index 00000000..adbf86f1 --- /dev/null +++ b/packages/secubox-zigbee/menu.d/80-zigbee.json @@ -0,0 +1,9 @@ +{ + "title": "Lyrion", + "subtitle": "Music server", + "icon": "fa-music", + "url": "/lyrion/", + "section": "hosting", + "order": 80, + "module": "lyrion" +} diff --git a/packages/secubox-zigbee/menu.d/803-zigbee.json b/packages/secubox-zigbee/menu.d/803-zigbee.json deleted file mode 100644 index 6c762915..00000000 --- a/packages/secubox-zigbee/menu.d/803-zigbee.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "id": "zigbee", - "name": "Zigbee", - "icon": "πŸ“‘", - "path": "/zigbee/", - "category": "apps", - "order": 803, - "description": "Zigbee2MQTT smart home gateway" -} diff --git a/packages/secubox-zigbee/nginx/zigbee.conf b/packages/secubox-zigbee/nginx/zigbee.conf index ecd30013..781d0450 100644 --- a/packages/secubox-zigbee/nginx/zigbee.conf +++ b/packages/secubox-zigbee/nginx/zigbee.conf @@ -1,15 +1,12 @@ -# /etc/nginx/secubox.d/zigbee.conf -# Installed by secubox-zigbee β€” auto-registered on install, removed on purge +# /etc/nginx/secubox.d/zigbee.conf + /etc/nginx/secubox-routes.d/zigbee.conf +# Installed by secubox-zigbee (#241). +# +# Host control-plane API only. The native zigbee2mqtt frontend (LXC :8080) +# is reachable on the LAN directly β€” operator opts in via a separate +# vhost or by exposing it via the IoT Hub WebUI (separate effort). -# Static frontend -location /zigbee/ { - alias /usr/share/secubox/www/zigbee/; - index index.html; - try_files $uri $uri/ /zigbee/index.html; -} - -# API backend location /api/v1/zigbee/ { - proxy_pass http://unix:/run/secubox/zigbee.sock:/; + rewrite ^/api/v1/zigbee/(.*)$ /$1 break; + proxy_pass http://unix:/run/secubox/zigbee.sock; include /etc/nginx/snippets/secubox-proxy.conf; } diff --git a/packages/secubox-zigbee/sbin/zigbeectl b/packages/secubox-zigbee/sbin/zigbeectl new file mode 100755 index 00000000..44f300b3 --- /dev/null +++ b/packages/secubox-zigbee/sbin/zigbeectl @@ -0,0 +1,347 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# SecuBox-Deb :: zigbeectl +# CyberMind β€” https://cybermind.fr +# +# zigbee2mqtt host-side controller. LXC at 10.100.0.111 on br-lxc. +# Three-fold introspection (components/status/access) + install/reload + +# device/network/topology/permit-join nouns. +# +# Grammar: docs/grammar.md, MIND layer. +# Conventions: docs/MODULE-GUIDELINES.md Β§7. + +set -u + +readonly VERSION="2.4.0" +readonly CONFIG_FILE="${SECUBOX_ZIGBEE_CONFIG:-/etc/secubox/zigbee.toml}" +readonly SECRETS_DIR="${SECUBOX_SECRETS_DIR:-/etc/secubox/secrets}" +readonly INSTALL_LIB="${SECUBOX_INSTALL_LIB:-/usr/share/secubox/lib/zigbee/install-lxc.sh}" + +readonly RED='\033[0;31m' +readonly GREEN='\033[0;32m' +readonly YELLOW='\033[1;33m' +readonly NC='\033[0m' + +log() { printf '%b[zigbee]%b %s\n' "$GREEN" "$NC" "$*"; } +warn() { printf '%b[warn]%b %s\n' "$YELLOW" "$NC" "$*" >&2; } +err() { printf '%b[error]%b %s\n' "$RED" "$NC" "$*" >&2; } + +config_get() { + local key="$1" default="${2:-}" result="" + if [ -f "$CONFIG_FILE" ]; then + # The `cut | tr` pipeline returns 0 even when grep matches nothing, + # so `|| echo $default` never fires. Capture, then test empty. + result=$(grep -E "^[[:space:]]*${key}[[:space:]]*=" "$CONFIG_FILE" 2>/dev/null \ + | head -1 | cut -d= -f2- | tr -d ' "' | tr -d "'") + fi + [ -z "$result" ] && result="$default" + printf '%s\n' "$result" +} + +LXC_NAME=$(config_get "name" "zigbee") +LXC_IP=$(config_get "ip" "10.100.0.111") +LXC_PATH=$(config_get "path" "/data/lxc") +FRONTEND_PORT=$(config_get "frontend_port" "8080") +MQTT_LXC_IP=$(config_get "mqtt_ip" "10.100.0.110") +MQTT_PORT=$(config_get "mqtt_port" "1883") + +# ── LXC + daemon helpers ───────────────────────────────────────────────────── +lxc_state() { + lxc-info -n "$LXC_NAME" -P "$LXC_PATH" 2>/dev/null \ + | awk -F: '/^State:/ { gsub(/ /,"",$2); print tolower($2) }' +} +lxc_running() { [ "$(lxc_state)" = "running" ]; } +lxc_exists() { [ -d "$LXC_PATH/$LXC_NAME/rootfs" ]; } + +z2m_running() { + lxc_running || return 1 + lxc-attach -n "$LXC_NAME" -P "$LXC_PATH" -- systemctl is-active --quiet zigbee2mqtt 2>/dev/null +} + +host_api_running() { + systemctl is-active --quiet secubox-zigbee.service 2>/dev/null +} + +# Radio detection: /dev/secubox-zgb is created by the udev rule when a +# supported dongle is plugged in. Absence = "device: absent" β€” not fatal, +# z2m will simply crash-loop until it shows up. +radio_present() { [ -e /dev/secubox-zgb ]; } + +# bridge/state is published by z2m on the broker β€” "online" / "offline". +# Reading it confirms z2m is talking to mqtt AND the broker accepted z2m's creds. +z2m_bridge_state() { + local user="z2m" pw_file="$SECRETS_DIR/mqtt-z2m" + [ -f "$pw_file" ] || { echo "no-creds"; return; } + local pw; pw=$(cat "$pw_file") + # Subscribe with -W 2s, -C 1 to grab the retained state message. + timeout 4 mosquitto_sub -h "$MQTT_LXC_IP" -p "$MQTT_PORT" -u "$user" -P "$pw" \ + -t 'zigbee2mqtt/bridge/state' -C 1 -W 2 2>/dev/null \ + | python3 -c 'import sys,json +try: + obj=json.loads(sys.stdin.read()) + print(obj.get("state","unknown")) +except Exception: + print("unknown") +' 2>/dev/null || echo "unknown" +} + +# ── Three-fold output ──────────────────────────────────────────────────────── +emit_components_text() { + printf '%-12s %-12s %s\n' "COMPONENT" "STATE" "DETAIL" + if lxc_exists; then + local s; s=$(lxc_state); [ -z "$s" ] && s="absent" + printf '%-12s %-12s %s\n' "lxc" "$s" "$LXC_NAME @ $LXC_IP on br-lxc" + else + printf '%-12s %-12s %s\n' "lxc" "absent" "$LXC_NAME @ $LXC_IP on br-lxc β€” run 'zigbeectl install'" + fi + radio_present \ + && printf '%-12s %-12s %s\n' "device" "present" "/dev/secubox-zgb (CC2652P / ConBee II)" \ + || printf '%-12s %-12s %s\n' "device" "absent" "no Zigbee dongle plugged β€” z2m will idle" + z2m_running \ + && printf '%-12s %-12s %s\n' "daemon" "running" "zigbee2mqtt, frontend port $FRONTEND_PORT" \ + || printf '%-12s %-12s %s\n' "daemon" "stopped" "zigbee2mqtt" + local bridge; bridge=$(z2m_bridge_state) + printf '%-12s %-12s %s\n' "bridge" "$bridge" "zigbee2mqtt/bridge/state @ $MQTT_LXC_IP:$MQTT_PORT" + host_api_running \ + && printf '%-12s %-12s %s\n' "host-api" "running" "secubox-zigbee.service (uvicorn)" \ + || printf '%-12s %-12s %s\n' "host-api" "stopped" "secubox-zigbee.service (uvicorn)" +} + +emit_components_json() { + local lxc_st dev_st daemon_st api_st bridge_st + if lxc_exists; then + lxc_st=$(lxc_state); [ -z "$lxc_st" ] && lxc_st="absent" + else + lxc_st="absent" + fi + radio_present && dev_st="present" || dev_st="absent" + z2m_running && daemon_st="running" || daemon_st="stopped" + host_api_running && api_st="running" || api_st="stopped" + bridge_st=$(z2m_bridge_state) + + cat </dev/null || true + log "Done. Try 'zigbeectl status' to verify." +} + +cmd_reload() { + systemctl restart secubox-zigbee.service 2>/dev/null || true + if lxc_running; then + lxc-attach -n "$LXC_NAME" -P "$LXC_PATH" -- systemctl restart zigbee2mqtt 2>/dev/null || true + fi + log "Reloaded host FastAPI and zigbee2mqtt (if running)." +} + +# Module nouns β€” read-only via MQTT in v2.4.0. Z2M publishes the device +# list on a request/response topic; we subscribe + publish + harvest. +_z2m_request() { + local target_topic="$1" + local response_topic="$2" + local payload="${3:-{}}" + local user="z2m" pw_file="$SECRETS_DIR/mqtt-z2m" + [ -f "$pw_file" ] || { err "missing $pw_file"; exit 2; } + local pw; pw=$(cat "$pw_file") + # Start sub in background, then publish, then collect first response. + timeout 6 mosquitto_sub -h "$MQTT_LXC_IP" -p "$MQTT_PORT" -u "$user" -P "$pw" \ + -t "$response_topic" -C 1 -W 5 2>/dev/null & + local sub_pid=$! + sleep 0.5 + timeout 3 mosquitto_pub -h "$MQTT_LXC_IP" -p "$MQTT_PORT" -u "$user" -P "$pw" \ + -t "$target_topic" -m "$payload" 2>/dev/null + wait $sub_pid 2>/dev/null || true +} + +cmd_device() { + case "${1:-list}" in + list) + # Read the retained bridge/devices topic. + local user="z2m" pw_file="$SECRETS_DIR/mqtt-z2m" + [ -f "$pw_file" ] || { err "missing $pw_file"; exit 2; } + local pw; pw=$(cat "$pw_file") + timeout 4 mosquitto_sub -h "$MQTT_LXC_IP" -p "$MQTT_PORT" -u "$user" -P "$pw" \ + -t 'zigbee2mqtt/bridge/devices' -C 1 -W 3 2>/dev/null \ + | python3 -m json.tool 2>/dev/null \ + || echo "(no retained device list β€” daemon may be down or radio absent)" + ;; + *) err "device: only 'list' is implemented in v$VERSION (writes via /api/v1/zigbee/)"; exit 2 ;; + esac +} + +cmd_network() { + case "${1:-info}" in + info|"") + local user="z2m" pw_file="$SECRETS_DIR/mqtt-z2m" + [ -f "$pw_file" ] || { err "missing $pw_file"; exit 2; } + local pw; pw=$(cat "$pw_file") + timeout 4 mosquitto_sub -h "$MQTT_LXC_IP" -p "$MQTT_PORT" -u "$user" -P "$pw" \ + -t 'zigbee2mqtt/bridge/info' -C 1 -W 3 2>/dev/null \ + | python3 -m json.tool 2>/dev/null \ + || echo "(no retained info β€” daemon may be down or radio absent)" + ;; + *) err "network: only 'info' is implemented in v$VERSION"; exit 2 ;; + esac +} + +cmd_topology() { + case "${1:-show}" in + show|"") + log "Topology read is async via MQTT request β€” not implemented in v$VERSION." + log "Open /api/v1/zigbee/topology (v2.5) or use the z2m frontend at http://$LXC_IP:$FRONTEND_PORT/." + ;; + *) err "topology: only 'show' is implemented in v$VERSION"; exit 2 ;; + esac +} + +cmd_permit_join() { + case "${1:-}" in + on|true) + local secs="${2:-60}" + local user="z2m" pw_file="$SECRETS_DIR/mqtt-z2m" + [ -f "$pw_file" ] || { err "missing $pw_file"; exit 2; } + local pw; pw=$(cat "$pw_file") + log "Permitting joins for ${secs}s ..." + timeout 3 mosquitto_pub -h "$MQTT_LXC_IP" -p "$MQTT_PORT" -u "$user" -P "$pw" \ + -t 'zigbee2mqtt/bridge/request/permit_join' \ + -m "{\"value\": true, \"time\": ${secs}}" + log "Done. Window closes in ${secs}s." + ;; + off|false) + local user="z2m" pw_file="$SECRETS_DIR/mqtt-z2m" + [ -f "$pw_file" ] || { err "missing $pw_file"; exit 2; } + local pw; pw=$(cat "$pw_file") + timeout 3 mosquitto_pub -h "$MQTT_LXC_IP" -p "$MQTT_PORT" -u "$user" -P "$pw" \ + -t 'zigbee2mqtt/bridge/request/permit_join' -m '{"value": false}' + log "permit_join: off" + ;; + *) err "permit-join: on [seconds] | off"; exit 2 ;; + esac +} + +cmd_help() { + cat <<'HELP' +zigbeectl β€” SecuBox zigbee2mqtt coordinator controller (MIND layer) + +Three-fold introspection: + zigbeectl components [--json] # LXC + device + daemon + bridge + host-API + zigbeectl status [--json] # overall green/yellow/red + zigbeectl access [list] [--json] + +Lifecycle (per docs/MODULE-GUIDELINES.md Β§7): + zigbeectl install # idempotent LXC + z2m bootstrap + zigbeectl reload # restart host FastAPI + z2m + zigbeectl repair # (NOT YET IMPLEMENTED) + zigbeectl wizard # (NOT YET IMPLEMENTED) + zigbeectl uninstall # (NOT YET IMPLEMENTED) + +Module nouns (v2.4.0 β€” read-only via MQTT, writes via /api/v1/zigbee/): + zigbeectl device list # cat retained bridge/devices + zigbeectl network info # cat retained bridge/info + zigbeectl topology show # deferred to v2.5 + zigbeectl permit-join on [seconds] | off # window-controlled join + + (--json on any verb returns machine-readable output) + +For grammar details: /usr/share/doc/secubox-zigbee/README.gz + docs/grammar.md (MIND layer, #241) +HELP +} + +main() { + local noun="${1:-help}" + shift || true + case "$noun" in + components|status|access|install|reload) "cmd_${noun}" "$@" ;; + device|network|topology) "cmd_${noun}" "$@" ;; + permit-join|permit_join) cmd_permit_join "$@" ;; + --help|-h|help|"") cmd_help ;; + --version|-V) echo "zigbeectl $VERSION" ;; + *) err "unknown noun: $noun"; cmd_help; exit 1 ;; + esac +} + +main "$@" diff --git a/packages/secubox-zigbee/www/zigbee/index.html b/packages/secubox-zigbee/www/zigbee/index.html index cc100ca5..3aa32ed5 100644 --- a/packages/secubox-zigbee/www/zigbee/index.html +++ b/packages/secubox-zigbee/www/zigbee/index.html @@ -1,1040 +1,156 @@ - - - SecuBox - Zigbee2MQTT Gateway - - - + + +SecuBox β€” Lyrion + + + - - -
-
-
-

Zigbee2MQTT Gateway

-
-
- - - - Stopped - -
-
+
+ +
+
+
SecuBox Β· Lyrion β€” Music server
+ +
- - +
+
+

Components

+
loading…
+
+
+

Status

+
loading…
+
+
+

Access

+
loading…
+
+
- -
- -
-
-
📡
-
--
-
Devices
-
-
-
🔌
-
--
-
USB Dongle
-
-
-
📶
-
--
-
Channel
-
-
-
-
--
-
MQTT
-
-
+
+ +
+
+
- -
- - - - - -
+ + + -
-

Detected USB Devices

-
-
Scanning...
-
-
-
- - -
-
-

Container Logs

-
- -
-
-
Loading logs...
-
-
-
- - -
-
-

Zigbee2MQTT Configuration

-
-
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
-
- - -
- -
-
-
- -
- -