mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-29 09:14:33 +00:00
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:
parent
080a4e47a3
commit
c64b7e569e
|
|
@ -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
|
||||
|
||||

|
||||
|
||||
## 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)
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
|
||||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
|
||||
37
packages/secubox-zigbee/conf/zigbee.toml.example
Normal file
37
packages/secubox-zigbee/conf/zigbee.toml.example
Normal 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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
15
packages/secubox-zigbee/debian/postrm
Executable file
15
packages/secubox-zigbee/debian/postrm
Executable 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
351
packages/secubox-zigbee/lib/zigbee/install-lxc.sh
Executable file
351
packages/secubox-zigbee/lib/zigbee/install-lxc.sh
Executable 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 "$@"
|
||||
9
packages/secubox-zigbee/menu.d/80-zigbee.json
Normal file
9
packages/secubox-zigbee/menu.d/80-zigbee.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"title": "Lyrion",
|
||||
"subtitle": "Music server",
|
||||
"icon": "fa-music",
|
||||
"url": "/lyrion/",
|
||||
"section": "hosting",
|
||||
"order": 80,
|
||||
"module": "lyrion"
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
{
|
||||
"id": "zigbee",
|
||||
"name": "Zigbee",
|
||||
"icon": "📡",
|
||||
"path": "/zigbee/",
|
||||
"category": "apps",
|
||||
"order": 803,
|
||||
"description": "Zigbee2MQTT smart home gateway"
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
347
packages/secubox-zigbee/sbin/zigbeectl
Executable file
347
packages/secubox-zigbee/sbin/zigbeectl
Executable 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
Loading…
Reference in New Issue
Block a user