feat(zigbee2mqtt): Rewrite from Docker to LXC Alpine container

Replace Docker-based zigbee2mqtt with a KISS LXC approach using Alpine
Linux container with Node.js + zigbee2mqtt, matching the HAProxy LXC
pattern. Adds USB serial passthrough for Sonoff Dongle Lite MG21.

- zigbee2mqttctl: Full LXC lifecycle (install, update, check, shell)
- RPCD: LXC diagnostics (lxc, cp210x, serial, container, service)
- api.js: Fix callApply missing params (payload was silently dropped)
- overview.js: Match new LXC diagnostics, fix applyConfig call
- Makefiles: Replace +dockerd +docker +containerd with +lxc +kmod-usb-serial-cp210x

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
CyberMind-FR 2026-02-04 18:32:57 +01:00
parent eab2e5d159
commit c5d40cf464
7 changed files with 645 additions and 410 deletions

View File

@ -7,7 +7,7 @@ PKG_LICENSE:=GPL-3.0-or-later
PKG_ARCH:=all
LUCI_TITLE:=LuCI Support for SecuBox Zigbee2MQTT App
LUCI_DESCRIPTION:=Graphical interface for managing the Zigbee2MQTT docker application.
LUCI_DESCRIPTION:=Graphical interface for managing the Zigbee2MQTT LXC application.
LUCI_DEPENDS:=+luci-base +luci-lib-jsonc +secubox-app-zigbee2mqtt
LUCI_PKGARCH:=all

View File

@ -4,12 +4,6 @@
'require ui';
'require poll';
'require zigbee2mqtt/api as API';
'require secubox-theme/theme as Theme';
var lang = (typeof L !== 'undefined' && L.env && L.env.lang) ||
(document.documentElement && document.documentElement.getAttribute('lang')) ||
(navigator.language ? navigator.language.split('-')[0] : 'en');
Theme.init({ language: lang });
return view.extend({
load: function() {
@ -18,9 +12,16 @@ return view.extend({
render: function(data) {
var config = data || {};
var self = this;
if (!document.querySelector('link[href*="zigbee2mqtt/common.css"]')) {
var link = document.createElement('link');
link.rel = 'stylesheet';
link.href = L.resource('zigbee2mqtt/common.css');
document.head.appendChild(link);
}
var container = E('div', { 'class': 'z2m-dashboard' }, [
E('link', { 'rel': 'stylesheet', 'href': L.resource('secubox-theme/secubox-theme.css') }),
E('link', { 'rel': 'stylesheet', 'href': L.resource('zigbee2mqtt/common.css') }),
this.renderHeader(config),
this.renderSetup(config),
this.renderForm(config),
@ -39,12 +40,9 @@ return view.extend({
},
renderHeader: function(cfg) {
var header = E('div', { 'class': 'z2m-card', 'id': 'z2m-status-card' }, [
return E('div', { 'class': 'z2m-card', 'id': 'z2m-status-card' }, [
E('div', { 'class': 'z2m-card-header' }, [
E('div', { 'class': 'sh-page-title' }, [
E('span', { 'class': 'sh-page-title-icon' }, '🧩'),
_('Zigbee2MQTT')
]),
E('h2', { 'style': 'margin:0;' }, _('Zigbee2MQTT')),
E('div', { 'class': 'z2m-status-badges' }, [
E('div', { 'class': 'z2m-badge ' + ((cfg.service && cfg.service.running) ? 'on' : 'off'), 'id': 'z2m-badge-running' },
cfg.service && cfg.service.running ? _('Running') : _('Stopped')),
@ -53,16 +51,14 @@ return view.extend({
])
]),
E('div', { 'class': 'z2m-actions' }, [
E('button', { 'class': 'sh-btn-secondary', 'click': this.handleCheck.bind(this) }, _('Run checks')),
E('button', { 'class': 'sh-btn-secondary', 'click': this.handleInstall.bind(this) }, _('Install prerequisites')),
E('button', { 'class': 'sh-btn-secondary', 'click': this.handleLogs.bind(this) }, _('Refresh logs')),
E('button', { 'class': 'sh-btn-secondary', 'click': this.handleUpdate.bind(this) }, _('Update Image')),
E('button', { 'class': 'sh-btn-secondary', 'click': this.handleControl.bind(this, 'restart') }, _('Restart')),
E('button', { 'class': 'sh-btn-secondary', 'click': this.handleControl.bind(this, 'start') }, _('Start')),
E('button', { 'class': 'sh-btn-secondary', 'click': this.handleControl.bind(this, 'stop') }, _('Stop'))
E('button', { 'class': 'cbi-button cbi-button-action', 'click': this.handleInstall.bind(this) }, _('Install')),
E('button', { 'class': 'cbi-button', 'click': this.handleCheck.bind(this) }, _('Check')),
E('button', { 'class': 'cbi-button', 'click': this.handleControl.bind(this, 'start') }, _('Start')),
E('button', { 'class': 'cbi-button', 'click': this.handleControl.bind(this, 'stop') }, _('Stop')),
E('button', { 'class': 'cbi-button', 'click': this.handleControl.bind(this, 'restart') }, _('Restart')),
E('button', { 'class': 'cbi-button', 'click': this.handleUpdate.bind(this) }, _('Update'))
])
]);
return header;
},
updateHeader: function(cfg) {
@ -81,19 +77,17 @@ return view.extend({
renderSetup: function(cfg) {
var diag = cfg.diagnostics || {};
return E('div', { 'class': 'z2m-card' }, [
E('div', { 'class': 'z2m-card-header' }, [
E('div', { 'class': 'sh-card-title' }, _('Prerequisites & Health'))
]),
E('h3', { 'style': 'margin:0 0 0.5em;' }, _('Prerequisites')),
this.renderDiagnostics(diag)
]);
},
renderDiagnostics: function(diag) {
var items = [
{ key: 'cgroups', label: _('cgroups mounted') },
{ key: 'docker', label: _('Docker daemon') },
{ key: 'usb_module', label: _('cdc_acm module') },
{ key: 'lxc', label: _('LXC') },
{ key: 'cp210x_module', label: _('cp210x module') },
{ key: 'serial_device', label: _('Serial device') },
{ key: 'container_exists', label: _('Container') },
{ key: 'service_file', label: _('Service script') }
];
return E('div', { 'class': 'z2m-diag-list' }, items.map(function(item) {
@ -109,7 +103,7 @@ return view.extend({
},
updateDiagnostics: function(diag) {
var keys = ['cgroups', 'docker', 'usb_module', 'serial_device', 'service_file'];
var keys = ['lxc', 'cp210x_module', 'serial_device', 'container_exists', 'service_file'];
diag = diag || {};
keys.forEach(function(key) {
var el = document.getElementById('z2m-diag-' + key);
@ -127,25 +121,22 @@ return view.extend({
var self = this;
var inputs = [
self.input('enabled', _('Enable service'), cfg.enabled ? '1' : '0', 'checkbox'),
self.input('serial_port', _('Serial device'), cfg.serial_port || '/dev/ttyACM0'),
self.input('serial_port', _('Serial device'), cfg.serial_port || '/dev/ttyUSB0'),
self.input('mqtt_host', _('MQTT host URL'), cfg.mqtt_host || 'mqtt://127.0.0.1:1883'),
self.input('mqtt_username', _('MQTT username'), cfg.mqtt_username || ''),
self.input('mqtt_password', _('MQTT password'), cfg.mqtt_password || '', 'password'),
self.input('base_topic', _('Base topic'), cfg.base_topic || 'zigbee2mqtt'),
self.input('frontend_port', _('Frontend port'), cfg.frontend_port || '8080', 'number'),
self.input('frontend_port', _('Frontend port'), cfg.frontend_port || '8099', 'number'),
self.input('channel', _('Zigbee channel'), cfg.channel || '11', 'number'),
self.input('data_path', _('Data path'), cfg.data_path || '/srv/zigbee2mqtt'),
self.input('image', _('Docker image'), cfg.image || 'ghcr.io/koenkk/zigbee2mqtt:latest'),
self.input('timezone', _('Timezone'), cfg.timezone || 'UTC')
self.input('permit_join', _('Permit join'), cfg.permit_join || '0', 'checkbox'),
self.input('data_path', _('Data path'), cfg.data_path || '/srv/zigbee2mqtt')
];
return E('div', { 'class': 'z2m-card' }, [
E('div', { 'class': 'z2m-card-header' }, [
E('div', { 'class': 'sh-card-title' }, _('Configuration'))
]),
E('h3', { 'style': 'margin:0 0 0.5em;' }, _('Configuration')),
E('div', { 'class': 'z2m-form-grid', 'id': 'z2m-form-grid' }, inputs),
E('div', { 'class': 'z2m-actions' }, [
E('button', { 'class': 'sh-btn-primary', 'click': this.handleSave.bind(this) }, _('Save & Apply'))
E('button', { 'class': 'cbi-button cbi-button-action', 'click': this.handleSave.bind(this) }, _('Save & Apply'))
])
]);
},
@ -169,14 +160,14 @@ return view.extend({
renderLogs: function() {
return E('div', { 'class': 'z2m-card' }, [
E('div', { 'class': 'z2m-card-header' }, [
E('div', { 'class': 'sh-card-title' }, _('Logs')),
E('div', { 'class': 'z2m-actions' }, [
E('input', { 'class': 'z2m-input', 'type': 'number', 'id': 'z2m-log-tail', 'value': '200', 'style': 'width:90px;' }),
E('button', { 'class': 'sh-btn-secondary', 'click': this.handleLogs.bind(this) }, _('Refresh'))
E('div', { 'style': 'display:flex; justify-content:space-between; align-items:center; margin-bottom:0.5em;' }, [
E('h3', { 'style': 'margin:0;' }, _('Logs')),
E('div', { 'style': 'display:flex; gap:0.5em; align-items:center;' }, [
E('input', { 'class': 'z2m-input', 'type': 'number', 'id': 'z2m-log-tail', 'value': '50', 'style': 'width:70px;' }),
E('button', { 'class': 'cbi-button', 'click': this.handleLogs.bind(this) }, _('Refresh'))
])
]),
E('pre', { 'class': 'z2m-log', 'id': 'z2m-log-output' }, _('Logs will appear here.'))
E('pre', { 'class': 'z2m-log', 'id': 'z2m-log-output' }, _('Click Refresh to load logs.'))
]);
},
@ -190,102 +181,99 @@ return view.extend({
base_topic: document.getElementById('base_topic').value,
frontend_port: document.getElementById('frontend_port').value,
channel: document.getElementById('channel').value,
data_path: document.getElementById('data_path').value,
image: document.getElementById('image').value,
timezone: document.getElementById('timezone').value
permit_join: document.getElementById('permit_join').checked ? '1' : '0',
data_path: document.getElementById('data_path').value
};
ui.showModal(_('Applying configuration'), [
E('p', {}, _('Saving settings and restarting service…')),
E('div', { 'class': 'spinning' })
ui.showModal(_('Saving'), [
E('p', { 'class': 'spinning' }, _('Saving settings and restarting service...'))
]);
API.applyConfig(payload).then(function() {
API.applyConfig(
payload.enabled, payload.serial_port, payload.mqtt_host,
payload.mqtt_username, payload.mqtt_password, payload.base_topic,
payload.frontend_port, payload.channel, payload.permit_join,
payload.data_path
).then(function() {
ui.hideModal();
ui.addNotification(null, E('p', {}, _('Configuration applied.')), 'info');
}).catch(function(err) {
ui.hideModal();
ui.addNotification(null, E('p', {}, err.message || err), 'error');
ui.addNotification(null, E('p', {}, err.message || String(err)), 'danger');
});
},
handleLogs: function() {
var tail = parseInt(document.getElementById('z2m-log-tail').value, 10) || 200;
var tail = parseInt(document.getElementById('z2m-log-tail').value, 10) || 50;
API.getLogs(tail).then(function(result) {
var box = document.getElementById('z2m-log-output');
if (box && result && result.lines) {
box.textContent = result.lines.join('\n');
if (box && result && result.log) {
box.textContent = result.log;
} else if (box) {
box.textContent = _('No logs available.');
}
});
},
handleControl: function(action) {
ui.showModal(_('Executing action'), [
E('p', {}, _('Performing %s…').format(action)),
E('div', { 'class': 'spinning' })
ui.showModal(_('Executing'), [
E('p', { 'class': 'spinning' }, action + '...')
]);
API.control(action).then(function(result) {
ui.hideModal();
if (result && result.success) {
ui.addNotification(null, E('p', {}, _('Action completed: %s').format(action)), 'info');
ui.addNotification(null, E('p', {}, _('Action completed: ') + action), 'info');
} else {
ui.addNotification(null, E('p', {}, _('Action failed')), 'error');
ui.addNotification(null, E('p', {}, _('Action failed')), 'danger');
}
}).catch(function(err) {
ui.hideModal();
ui.addNotification(null, E('p', {}, err.message || err), 'error');
ui.addNotification(null, E('p', {}, err.message || String(err)), 'danger');
});
},
handleUpdate: function() {
ui.showModal(_('Updating image'), [
E('p', {}, _('Pulling latest Zigbee2MQTT image…')),
E('div', { 'class': 'spinning' })
ui.showModal(_('Updating'), [
E('p', { 'class': 'spinning' }, _('Updating zigbee2mqtt...'))
]);
API.update().then(function(result) {
ui.hideModal();
if (result && result.success) {
ui.addNotification(null, E('p', {}, _('Image updated. Service restarted.')), 'info');
ui.addNotification(null, E('p', {}, _('Update complete.')), 'info');
} else {
ui.addNotification(null, E('p', {}, _('Update failed')), 'error');
ui.addNotification(null, E('p', {}, _('Update failed: ') + (result && result.output || '')), 'danger');
}
}).catch(function(err) {
ui.hideModal();
ui.addNotification(null, E('p', {}, err.message || err), 'error');
ui.addNotification(null, E('p', {}, err.message || String(err)), 'danger');
});
},
handleInstall: function() {
this.runCommand(_('Installing prerequisites…'), API.install);
this.runCommand(_('Installing...'), API.install);
},
handleCheck: function() {
this.runCommand(_('Running prerequisite checks…'), API.runCheck);
this.runCommand(_('Running checks...'), API.runCheck);
},
runCommand: function(title, fn) {
var self = this;
ui.showModal(title, [
E('p', {}, title),
E('div', { 'class': 'spinning' })
E('p', { 'class': 'spinning' }, title)
]);
fn().then(function(result) {
ui.hideModal();
self.showCommandOutput(result, title);
var output = (result && result.output) ? result.output : _('Done.');
var tone = (result && result.success) ? 'info' : 'danger';
ui.addNotification(null, E('div', {}, [
E('pre', { 'style': 'white-space:pre-wrap;' }, output)
]), tone);
self.refreshStatus();
}).catch(function(err) {
ui.hideModal();
self.showCommandOutput({ success: 0, output: err && err.message ? err.message : err }, title);
ui.addNotification(null, E('p', {}, err.message || String(err)), 'danger');
});
},
showCommandOutput: function(result, title) {
var output = (result && result.output) ? result.output : _('Command finished.');
var tone = (result && result.success) ? 'info' : 'error';
ui.addNotification(null, E('div', {}, [
E('strong', {}, title),
E('pre', { 'style': 'white-space:pre-wrap;margin-top:8px;' }, output)
]), tone);
},
refreshStatus: function() {
var self = this;
return API.getStatus().then(function(newData) {

View File

@ -11,7 +11,9 @@ var callStatus = rpc.declare({
var callApply = rpc.declare({
object: 'luci.zigbee2mqtt',
method: 'apply'
method: 'apply',
params: ['enabled', 'serial_port', 'mqtt_host', 'mqtt_username', 'mqtt_password',
'base_topic', 'frontend_port', 'channel', 'permit_join', 'data_path']
});
var callLogs = rpc.declare({

View File

@ -5,152 +5,7 @@
CONFIG="zigbee2mqtt"
SERVICE="/etc/init.d/zigbee2mqtt"
CTL="/usr/sbin/zigbee2mqttctl"
load_config() {
json_init
json_add_string "serial_port" "$(uci -q get ${CONFIG}.main.serial_port || echo /dev/ttyACM0)"
json_add_string "mqtt_host" "$(uci -q get ${CONFIG}.main.mqtt_host || echo mqtt://127.0.0.1:1883)"
json_add_string "mqtt_username" "$(uci -q get ${CONFIG}.main.mqtt_username || printf '')"
json_add_string "mqtt_password" "$(uci -q get ${CONFIG}.main.mqtt_password || printf '')"
json_add_string "base_topic" "$(uci -q get ${CONFIG}.main.base_topic || echo zigbee2mqtt)"
json_add_string "frontend_port" "$(uci -q get ${CONFIG}.main.frontend_port || echo 8080)"
json_add_string "channel" "$(uci -q get ${CONFIG}.main.channel || echo 11)"
json_add_string "data_path" "$(uci -q get ${CONFIG}.main.data_path || echo /srv/zigbee2mqtt)"
json_add_string "image" "$(uci -q get ${CONFIG}.main.image || echo ghcr.io/koenkk/zigbee2mqtt:latest)"
json_add_string "timezone" "$(uci -q get ${CONFIG}.main.timezone || echo UTC)"
json_add_boolean "enabled" "$( [ "$(uci -q get ${CONFIG}.main.enabled || echo 0)" = "1" ] && echo 1 || echo 0)"
}
diagnostics() {
local serial="$(uci -q get ${CONFIG}.main.serial_port || echo /dev/ttyACM0)"
json_add_object "diagnostics"
json_add_boolean "cgroups" "$( [ -d /sys/fs/cgroup ] && echo 1 || echo 0 )"
json_add_boolean "docker" "$( command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1 && echo 1 || echo 0 )"
json_add_boolean "serial_device" "$( [ -c "$serial" ] && echo 1 || echo 0 )"
json_add_boolean "usb_module" "$( lsmod 2>/dev/null | grep -q 'cdc_acm' && echo 1 || echo 0 )"
json_add_boolean "service_file" "$( [ -x "$SERVICE" ] && echo 1 || echo 0 )"
json_close_object
}
status() {
json_init
load_config
json_add_object "service"
json_add_boolean "enabled" "$( "$SERVICE" enabled >/dev/null 2>&1 && echo 1 || echo 0 )"
json_add_boolean "running" "$( "$SERVICE" status >/dev/null 2>&1 && echo 1 || echo 0 )"
json_close_object
diagnostics
json_add_array "container"
docker ps -a --filter "name=secbx-zigbee2mqtt" --format '{{.Names}}|{{.Status}}' 2>/dev/null | while IFS='|' read -r name st; do
json_add_object
json_add_string "name" "$name"
json_add_string "status" "$st"
json_close_object
done
json_close_array
json_dump
}
apply() {
read input
json_load "$input"
json_get_var serial_port serial_port
json_get_var mqtt_host mqtt_host
json_get_var mqtt_username mqtt_username
json_get_var mqtt_password mqtt_password
json_get_var base_topic base_topic
json_get_var frontend_port frontend_port
json_get_var channel channel
json_get_var data_path data_path
json_get_var image image
json_get_var timezone timezone
json_get_var enabled enabled
[ -n "$serial_port" ] && uci set ${CONFIG}.main.serial_port="$serial_port"
[ -n "$mqtt_host" ] && uci set ${CONFIG}.main.mqtt_host="$mqtt_host"
[ -n "$mqtt_username" ] && uci set ${CONFIG}.main.mqtt_username="$mqtt_username"
[ -n "$mqtt_password" ] && uci set ${CONFIG}.main.mqtt_password="$mqtt_password"
[ -n "$base_topic" ] && uci set ${CONFIG}.main.base_topic="$base_topic"
[ -n "$frontend_port" ] && uci set ${CONFIG}.main.frontend_port="$frontend_port"
[ -n "$channel" ] && uci set ${CONFIG}.main.channel="$channel"
[ -n "$data_path" ] && uci set ${CONFIG}.main.data_path="$data_path"
[ -n "$image" ] && uci set ${CONFIG}.main.image="$image"
[ -n "$timezone" ] && uci set ${CONFIG}.main.timezone="$timezone"
[ -n "$enabled" ] && uci set ${CONFIG}.main.enabled="$enabled"
uci commit ${CONFIG}
if [ "$enabled" = "1" ]; then
"$SERVICE" enable >/dev/null 2>&1
else
"$SERVICE" disable >/dev/null 2>&1
fi
"$SERVICE" restart >/dev/null 2>&1
json_init
json_add_boolean "success" 1
json_dump
}
logs() {
read input
json_load "$input"
json_get_var tail tail
tail=${tail:-200}
json_init
json_add_array "lines"
$CTL logs --tail "$tail" 2>&1 | while IFS= read -r line; do
json_add_string "" "$line"
done
json_close_array
json_dump
}
control() {
read input
json_load "$input"
json_get_var action action
case "$action" in
start) "$SERVICE" start ;;
stop) "$SERVICE" stop ;;
restart) "$SERVICE" restart ;;
*) json_init; json_add_boolean "success" 0; json_add_string "error" "invalid action"; json_dump; return ;;
esac
json_init
json_add_boolean "success" 1
json_dump
}
update() {
$CTL update >/dev/null 2>&1
json_init
json_add_boolean "success" 1
json_dump
}
run_helper() {
local command="$1"
shift
local output
output=$("$CTL" "$command" "$@" 2>&1)
local rc=$?
json_init
if [ "$rc" -eq 0 ]; then
json_add_boolean "success" 1
else
json_add_boolean "success" 0
fi
[ -n "$output" ] && json_add_string "output" "$output"
json_dump
}
install() {
run_helper install
}
check() {
run_helper check
}
LXC_NAME="zigbee2mqtt"
case "$1" in
list)
@ -167,15 +22,158 @@ case "$1" in
JSON
;;
call)
case "$2" in
status) status ;;
apply) apply ;;
logs) logs ;;
control) control ;;
update) update ;;
install) install ;;
check) check ;;
*) json_init; json_add_string "error" "unknown method"; json_dump ;;
handle_call() {
case "$1" in
status)
local serial_port=$(uci -q get ${CONFIG}.main.serial_port)
serial_port=${serial_port:-/dev/ttyUSB0}
json_init
# Config
json_add_string "serial_port" "$serial_port"
json_add_string "mqtt_host" "$(uci -q get ${CONFIG}.main.mqtt_host)"
json_add_string "base_topic" "$(uci -q get ${CONFIG}.main.base_topic)"
json_add_string "frontend_port" "$(uci -q get ${CONFIG}.main.frontend_port)"
json_add_string "channel" "$(uci -q get ${CONFIG}.main.channel)"
json_add_string "data_path" "$(uci -q get ${CONFIG}.main.data_path)"
json_add_string "permit_join" "$(uci -q get ${CONFIG}.main.permit_join)"
json_add_boolean "enabled" "$([ "$(uci -q get ${CONFIG}.main.enabled)" = "1" ] && echo 1 || echo 0)"
# Service state
json_add_object "service"
json_add_boolean "enabled" "$("$SERVICE" enabled >/dev/null 2>&1 && echo 1 || echo 0)"
local running=0
lxc-info -n "$LXC_NAME" -s 2>/dev/null | grep -q "RUNNING" && running=1
json_add_boolean "running" "$running"
json_close_object
# Diagnostics
json_add_object "diagnostics"
json_add_boolean "lxc" "$(command -v lxc-start >/dev/null 2>&1 && echo 1 || echo 0)"
json_add_boolean "serial_device" "$([ -c "$serial_port" ] && echo 1 || echo 0)"
json_add_boolean "cp210x_module" "$(lsmod 2>/dev/null | grep -q 'cp210x' && echo 1 || echo 0)"
json_add_boolean "container_exists" "$([ -d "/srv/lxc/$LXC_NAME/rootfs" ] && echo 1 || echo 0)"
json_add_boolean "service_file" "$([ -x "$SERVICE" ] && echo 1 || echo 0)"
json_close_object
json_dump
;;
apply)
local input
read input
json_load "$input"
local serial_port mqtt_host mqtt_username mqtt_password
local base_topic frontend_port channel data_path permit_join enabled
json_get_var serial_port serial_port
json_get_var mqtt_host mqtt_host
json_get_var mqtt_username mqtt_username
json_get_var mqtt_password mqtt_password
json_get_var base_topic base_topic
json_get_var frontend_port frontend_port
json_get_var channel channel
json_get_var data_path data_path
json_get_var permit_join permit_join
json_get_var enabled enabled
[ -n "$serial_port" ] && uci set ${CONFIG}.main.serial_port="$serial_port"
[ -n "$mqtt_host" ] && uci set ${CONFIG}.main.mqtt_host="$mqtt_host"
uci set ${CONFIG}.main.mqtt_username="${mqtt_username}"
uci set ${CONFIG}.main.mqtt_password="${mqtt_password}"
[ -n "$base_topic" ] && uci set ${CONFIG}.main.base_topic="$base_topic"
[ -n "$frontend_port" ] && uci set ${CONFIG}.main.frontend_port="$frontend_port"
[ -n "$channel" ] && uci set ${CONFIG}.main.channel="$channel"
[ -n "$data_path" ] && uci set ${CONFIG}.main.data_path="$data_path"
[ -n "$permit_join" ] && uci set ${CONFIG}.main.permit_join="$permit_join"
[ -n "$enabled" ] && uci set ${CONFIG}.main.enabled="$enabled"
uci commit ${CONFIG}
if [ "$enabled" = "1" ]; then
"$SERVICE" enable >/dev/null 2>&1
else
"$SERVICE" disable >/dev/null 2>&1
fi
"$SERVICE" restart >/dev/null 2>&1 &
json_init
json_add_boolean "success" 1
json_dump
;;
logs)
local input tail
read input
json_load "$input"
json_get_var tail tail
tail=${tail:-200}
json_init
local logfile="/srv/zigbee2mqtt/data/zigbee2mqtt.log"
if [ -f "$logfile" ]; then
json_add_string "log" "$(tail -n "$tail" "$logfile" 2>/dev/null)"
else
json_add_string "log" "No log file found"
fi
json_dump
;;
control)
local input action
read input
json_load "$input"
json_get_var action action
case "$action" in
start) "$SERVICE" start >/dev/null 2>&1 & ;;
stop) "$SERVICE" stop >/dev/null 2>&1 ;;
restart) "$SERVICE" restart >/dev/null 2>&1 & ;;
*)
json_init
json_add_boolean "success" 0
json_add_string "error" "invalid action"
json_dump
return
;;
esac
json_init
json_add_boolean "success" 1
json_dump
;;
update)
local output rc
output=$("$CTL" update 2>&1)
rc=$?
json_init
json_add_boolean "success" "$([ "$rc" -eq 0 ] && echo 1 || echo 0)"
[ -n "$output" ] && json_add_string "output" "$output"
json_dump
;;
install)
local output rc
output=$("$CTL" install 2>&1)
rc=$?
json_init
json_add_boolean "success" "$([ "$rc" -eq 0 ] && echo 1 || echo 0)"
[ -n "$output" ] && json_add_string "output" "$output"
json_dump
;;
check)
local output rc
output=$("$CTL" check 2>&1)
rc=$?
json_init
json_add_boolean "success" "$([ "$rc" -eq 0 ] && echo 1 || echo 0)"
[ -n "$output" ] && json_add_string "output" "$output"
json_dump
;;
*)
json_init
json_add_string "error" "unknown method"
json_dump
;;
esac
;;
}
handle_call "$2"
;;
esac

View File

@ -14,13 +14,13 @@ define Package/secubox-app-zigbee2mqtt
CATEGORY:=Utilities
PKGARCH:=all
SUBMENU:=SecuBox Apps
TITLE:=SecuBox Zigbee2MQTT docker app
DEPENDS:=kmod-usb-acm +dockerd +docker +containerd
TITLE:=SecuBox Zigbee2MQTT LXC app
DEPENDS:=+lxc +kmod-usb-serial-cp210x
endef
define Package/secubox-app-zigbee2mqtt/description
Installer, configuration, and service manager for running Zigbee2MQTT
inside Docker on SecuBox-powered OpenWrt systems.
inside an Alpine LXC container on SecuBox-powered OpenWrt systems.
endef
define Package/secubox-app-zigbee2mqtt/conffiles

View File

@ -1,12 +1,11 @@
config zigbee2mqtt 'main'
option enabled '0'
option image 'ghcr.io/koenkk/zigbee2mqtt:latest'
option serial_port '/dev/ttyACM0'
option serial_port '/dev/ttyUSB0'
option mqtt_host 'mqtt://127.0.0.1:1883'
option mqtt_username ''
option mqtt_password ''
option base_topic 'zigbee2mqtt'
option frontend_port '8080'
option frontend_port '8099'
option channel '11'
option data_path '/srv/zigbee2mqtt'
option timezone 'UTC'
option permit_join '0'

View File

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