feat(secubox-zigbee): v2.4.0 rewrite-in-place to LXC + npm + z2m 2.10.1 (closes #241)

Replaces the v1.0.0 host-bound skeleton with the canonical SecuBox LXC
pattern (sibling of mqtt v2.4.0). Depends `secubox-mqtt (>= 2.4)` —
credentials are read from /etc/secubox/secrets/mqtt-z2m at install time
and rendered into z2m's configuration.yaml.

Minimum viable v2.4.0 (operator scope choice 2026-05-20):

* LXC at 10.100.0.111 on br-lxc. Node 20 from NodeSource (Debian
  bookworm only ships Node 18, too old for z2m 2.x).
* zigbee2mqtt 2.10.1 installed from the npm registry (NOT from the
  GitHub tag — the tag ships only TypeScript sources and `npm start`
  requires a working pnpm build chain that broke on v2.0.0 with a
  TS error in worker-timers-broker. The npm tarball ships a pre-built
  dist/ which sidesteps the entire build dance.)
* Hard limits per docs/superpowers/specs/2026-05-20-secubox-zigbee-mqtt-iot-stack.md:
  - permit_join: false in configuration.yaml
  - network_key / pan_id / ext_pan_id: GENERATE
  - daemon User=zigbee2mqtt + dialout group + NoNewPrivileges=true +
    ProtectSystem=strict
* 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) 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.
* zigbeectl follows the SecuBox CTL grammar:
  - introspection: components / status / access
  - lifecycle:     install / reload (+ stubs)
  - module nouns:  device / network / topology / permit-join
* Host control plane: FastAPI on /run/secubox/zigbee.sock with
  /components, /status, /access, /healthz. Reverse-proxied at
  /api/v1/zigbee/. Reads zigbee2mqtt/bridge/state from the broker
  to populate the bridge component state.

Board-validated on gk2 (no dongle plugged):
    $ zigbeectl status
    lxc       running      zigbee @ 10.100.0.111 on br-lxc
    device    absent       no Zigbee dongle plugged — z2m will idle
    daemon    running      zigbee2mqtt, frontend port 8080
    bridge    unknown      zigbee2mqtt/bridge/state @ 10.100.0.110:1883
    host-api  running      secubox-zigbee.service (uvicorn)
    overall: yellow

    $ curl -I http://10.100.0.111:8080/
    HTTP/1.1 200 OK   # z2m onboarding page (radio absent → onboarding)

    $ curl -sk -H 'Host: admin.gk2.secubox.in' https://192.168.1.200:9443/api/v1/zigbee/status
    {"overall": "yellow", "states": {"lxc":"running","device":"absent",
                                     "daemon":"running","bridge":"unknown"}}

Lessons folded in during the board cycle:

1. z2m 2.x GitHub tags ship only TS sources. The npm tarball ships
   pre-built dist/ — install via `npm install zigbee2mqtt@<ver>`,
   not `git clone + pnpm run build`.
2. `sudo` is broken inside unprivileged LXCs (/etc/sudo.conf is owned
   by mapped uid 100000). Use `runuser -u <user> --` instead.
3. `install -d` on an existing directory doesn't always re-apply
   mode/owner on bookworm — chmod/chown explicitly when widening
   /etc/secubox/secrets/ from the mqtt-default 0700 root:root.
4. Long-standing bug in `config_get` (shared with sibling ctls):
   `cut | tr` returns 0 even when grep matches nothing, so the
   `|| echo $default` never fires. Capture into a variable, test
   empty, then fall back to default.

Out of scope for v2.4.0 (deferred to v2.5):

* Z2MBridge async pub/sub pattern (full /devices, /topology,
  /permit_join, /bind, /ota/* endpoints with request/response
  correlation IDs)
* Frontend authentication (currently LAN-only no-auth)
* Coordinator firmware OTA via the host API

Closes #241
This commit is contained in:
CyberMind-FR 2026-05-20 14:08:05 +02:00
parent 080a4e47a3
commit c64b7e569e
19 changed files with 1243 additions and 1894 deletions

View File

@ -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://<host>/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 <id> | pause <id> | next <id> | prev <id> | volume <id> <0-100>
lyrionctl library scan | refresh | status | wipe
lyrionctl playlist list | add <name> | remove <name> | tracks <name>
lyrionctl plugin list | install <name> | uninstall <name>
```
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)
```

View File

@ -1 +0,0 @@

View File

@ -1,756 +1,157 @@
"""secubox-zigbee — FastAPI application for Zigbee2MQTT Gateway.
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gerald Kerma <devel@cybermind.fr>
# 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"}

View File

@ -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

View File

@ -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 <devel@cybermind.fr> Wed, 20 May 2026 23:30:00 +0200
secubox-zigbee (1.0.0-1~bookworm1) bookworm; urgency=medium
* Initial release.

View File

@ -4,18 +4,40 @@ Priority: optional
Maintainer: Gerald KERMA <devel@cybermind.fr>
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).

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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" <<EOF
# SecuBox-managed — see secubox-zigbee / install-lxc.sh
lxc.uts.name = $LXC_NAME
lxc.net.0.type = veth
lxc.net.0.link = $LXC_BRIDGE
lxc.net.0.flags = up
lxc.net.0.ipv4.address = $LXC_IP/24
lxc.net.0.ipv4.gateway = $LXC_GW
lxc.net.0.name = eth0
lxc.rootfs.path = dir:$LXC_PATH/$LXC_NAME/rootfs
lxc.include = /usr/share/lxc/config/common.conf
lxc.apparmor.profile = generated
lxc.start.auto = 1
lxc.start.delay = 5
lxc.mount.entry = /dev/secubox-zgb dev/secubox-zgb none bind,create=file,optional 0 0
lxc.cgroup2.devices.allow = c 188:* rwm
lxc.cgroup2.devices.allow = c 166:* rwm
EOF
}
start_lxc() {
[ "$(lxc_state)" = "running" ] && { log "Already running"; return; }
log "Starting LXC '$LXC_NAME' ..."
lxc-start -n "$LXC_NAME" -P "$LXC_PATH"
}
wait_for_network() {
log "Waiting for LXC network ..."
for _ in $(seq 1 30); do
lxc-attach -n "$LXC_NAME" -P "$LXC_PATH" -- ping -c1 -W1 "$LXC_GW" >/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@<ver>` 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@<ver>`,
# 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 <pkg>` 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 <<UNIT
[Unit]
Description=zigbee2mqtt
After=network.target
StartLimitIntervalSec=0
[Service]
Type=simple
User=zigbee2mqtt
WorkingDirectory=/opt/zigbee2mqtt/node_modules/zigbee2mqtt
Environment=Z2M_DATA=/opt/zigbee2mqtt/data
StandardOutput=journal
StandardError=journal
Restart=always
RestartSec=10
ExecStart=/usr/bin/node /opt/zigbee2mqtt/node_modules/zigbee2mqtt/index.js
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/zigbee2mqtt/data /opt/zigbee2mqtt/node_modules/zigbee2mqtt
DeviceAllow=/dev/secubox-zgb rw
DeviceAllow=/dev/ttyUSB0 rw
DeviceAllow=/dev/ttyACM0 rw
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
INNER
}
# Render configuration.yaml inside the LXC, wiring z2m to the SecuBox
# Mosquitto broker. Credentials pulled from /etc/secubox/secrets/mqtt-z2m
# (provisioned by secubox-mqtt v2.4 — that's a hard Depends).
configure_zigbee2mqtt() {
log "Rendering zigbee2mqtt configuration.yaml ..."
local z2m_pw_file="$SECRETS_DIR/mqtt-z2m"
[ -f "$z2m_pw_file" ] || fail "missing $z2m_pw_file — install secubox-mqtt first and run 'mqttctl install'"
local z2m_pw; z2m_pw=$(cat "$z2m_pw_file")
local stage=/tmp/zigbee2mqtt-config.$$
cat > "$stage" <<YAML
# /opt/zigbee2mqtt/data/configuration.yaml
# Generated by secubox-zigbee install-lxc.sh — operator overrides go in
# operator-overrides.yaml (loaded after this).
homeassistant:
enabled: false
permit_join: false
mqtt:
base_topic: zigbee2mqtt
server: 'mqtt://${MQTT_LXC_IP}:${MQTT_PORT}'
user: z2m
password: '${z2m_pw}'
keepalive: 60
serial:
port: /dev/secubox-zgb
adapter: zstack
advanced:
network_key: GENERATE
pan_id: GENERATE
ext_pan_id: GENERATE
channel: 20
log_level: warning
log_output: ['file']
log_directory: /opt/zigbee2mqtt/data/log
log_file: '%TIMESTAMP%.log'
timestamp_format: 'YYYY-MM-DD HH:mm:ss'
frontend:
enabled: true
port: ${Z2M_FRONTEND_PORT}
host: 0.0.0.0
device_options:
legacy: false
YAML
lxc-attach -n "$LXC_NAME" -P "$LXC_PATH" -- install -d -m 0755 -o zigbee2mqtt -g zigbee2mqtt /opt/zigbee2mqtt/data
cat "$stage" | lxc-attach -n "$LXC_NAME" -P "$LXC_PATH" -- tee /opt/zigbee2mqtt/data/configuration.yaml >/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 "$@"

View File

@ -0,0 +1,9 @@
{
"title": "Lyrion",
"subtitle": "Music server",
"icon": "fa-music",
"url": "/lyrion/",
"section": "hosting",
"order": 80,
"module": "lyrion"
}

View File

@ -1,9 +0,0 @@
{
"id": "zigbee",
"name": "Zigbee",
"icon": "📡",
"path": "/zigbee/",
"category": "apps",
"order": 803,
"description": "Zigbee2MQTT smart home gateway"
}

View File

@ -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;
}

View File

@ -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 <<EOF
{
"module": "zigbee",
"version": "$VERSION",
"components": [
{"name": "lxc", "state": "$lxc_st", "detail": "$LXC_NAME @ $LXC_IP on br-lxc"},
{"name": "device", "state": "$dev_st", "detail": "/dev/secubox-zgb (CC2652P / ConBee II)"},
{"name": "daemon", "state": "$daemon_st", "detail": "zigbee2mqtt, frontend port $FRONTEND_PORT"},
{"name": "bridge", "state": "$bridge_st", "detail": "zigbee2mqtt/bridge/state @ $MQTT_LXC_IP:$MQTT_PORT"},
{"name": "host-api", "state": "$api_st", "detail": "secubox-zigbee.service (uvicorn)"}
]
}
EOF
}
emit_status_json() {
local overall
if lxc_running && z2m_running && radio_present && [ "$(z2m_bridge_state)" = "online" ] && host_api_running; then
overall="green"
elif lxc_running && z2m_running && host_api_running; then
# daemon up but no device → expected on a board without dongle.
overall="yellow"
elif lxc_exists; then
overall="yellow"
else
overall="red"
fi
cat <<EOF
{ "module": "zigbee", "version": "$VERSION", "overall": "$overall" }
EOF
}
# Access: which clients connect to the zigbee surface? At v2.4.0 the
# only "access" surface is the frontend port — list it as one entry.
emit_access_json() {
cat <<EOF
{
"module": "zigbee",
"access": [
{"endpoint": "http://$LXC_IP:$FRONTEND_PORT/", "scope": "lan", "auth": "none (LAN-only)"},
{"endpoint": "mqtt://$MQTT_LXC_IP:$MQTT_PORT (zigbee2mqtt/#)", "scope": "lan", "auth": "z2m user"}
]
}
EOF
}
# ── Verbs ─────────────────────────────────────────────────────────────────────
cmd_components() {
case "${1:-}" in
--json) emit_components_json ;;
""|list|status) emit_components_text ;;
*) err "unknown verb for 'components': $1"; exit 1 ;;
esac
}
cmd_status() {
case "${1:-}" in
--json) emit_status_json ;;
"")
emit_components_text
echo
emit_status_json | python3 -c 'import json,sys; d=json.load(sys.stdin); print("overall:", d["overall"])'
;;
*) err "unknown verb for 'status': $1"; exit 1 ;;
esac
}
cmd_access() {
case "${1:-}" in
--json) emit_access_json ;;
""|list) emit_access_json | python3 -m json.tool ;;
*) err "unknown verb for 'access': $1"; exit 1 ;;
esac
}
cmd_install() {
if [ ! -x "$INSTALL_LIB" ]; then
err "install script not found at $INSTALL_LIB (package not installed correctly?)"
exit 2
fi
log "Running LXC bootstrap from $INSTALL_LIB ..."
SECUBOX_LXC_NAME="$LXC_NAME" SECUBOX_LXC_IP="$LXC_IP" SECUBOX_LXC_PATH="$LXC_PATH" \
SECUBOX_MQTT_LXC_IP="$MQTT_LXC_IP" SECUBOX_MQTT_PORT="$MQTT_PORT" \
SECUBOX_Z2M_FRONTEND_PORT="$FRONTEND_PORT" \
bash "$INSTALL_LIB"
log "Starting host-side FastAPI ..."
systemctl start secubox-zigbee.service 2>/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 "$@"

File diff suppressed because it is too large Load Diff