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 PKG_ARCH:=all
LUCI_TITLE:=LuCI Support for SecuBox Zigbee2MQTT App 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_DEPENDS:=+luci-base +luci-lib-jsonc +secubox-app-zigbee2mqtt
LUCI_PKGARCH:=all LUCI_PKGARCH:=all

View File

@ -4,12 +4,6 @@
'require ui'; 'require ui';
'require poll'; 'require poll';
'require zigbee2mqtt/api as API'; '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({ return view.extend({
load: function() { load: function() {
@ -18,9 +12,16 @@ return view.extend({
render: function(data) { render: function(data) {
var config = 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' }, [ 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.renderHeader(config),
this.renderSetup(config), this.renderSetup(config),
this.renderForm(config), this.renderForm(config),
@ -39,12 +40,9 @@ return view.extend({
}, },
renderHeader: function(cfg) { 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': 'z2m-card-header' }, [
E('div', { 'class': 'sh-page-title' }, [ E('h2', { 'style': 'margin:0;' }, _('Zigbee2MQTT')),
E('span', { 'class': 'sh-page-title-icon' }, '🧩'),
_('Zigbee2MQTT')
]),
E('div', { 'class': 'z2m-status-badges' }, [ E('div', { 'class': 'z2m-status-badges' }, [
E('div', { 'class': 'z2m-badge ' + ((cfg.service && cfg.service.running) ? 'on' : 'off'), 'id': 'z2m-badge-running' }, E('div', { 'class': 'z2m-badge ' + ((cfg.service && cfg.service.running) ? 'on' : 'off'), 'id': 'z2m-badge-running' },
cfg.service && cfg.service.running ? _('Running') : _('Stopped')), cfg.service && cfg.service.running ? _('Running') : _('Stopped')),
@ -53,16 +51,14 @@ return view.extend({
]) ])
]), ]),
E('div', { 'class': 'z2m-actions' }, [ E('div', { 'class': 'z2m-actions' }, [
E('button', { 'class': 'sh-btn-secondary', 'click': this.handleCheck.bind(this) }, _('Run checks')), E('button', { 'class': 'cbi-button cbi-button-action', 'click': this.handleInstall.bind(this) }, _('Install')),
E('button', { 'class': 'sh-btn-secondary', 'click': this.handleInstall.bind(this) }, _('Install prerequisites')), E('button', { 'class': 'cbi-button', 'click': this.handleCheck.bind(this) }, _('Check')),
E('button', { 'class': 'sh-btn-secondary', 'click': this.handleLogs.bind(this) }, _('Refresh logs')), E('button', { 'class': 'cbi-button', 'click': this.handleControl.bind(this, 'start') }, _('Start')),
E('button', { 'class': 'sh-btn-secondary', 'click': this.handleUpdate.bind(this) }, _('Update Image')), E('button', { 'class': 'cbi-button', 'click': this.handleControl.bind(this, 'stop') }, _('Stop')),
E('button', { 'class': 'sh-btn-secondary', 'click': this.handleControl.bind(this, 'restart') }, _('Restart')), E('button', { 'class': 'cbi-button', 'click': this.handleControl.bind(this, 'restart') }, _('Restart')),
E('button', { 'class': 'sh-btn-secondary', 'click': this.handleControl.bind(this, 'start') }, _('Start')), E('button', { 'class': 'cbi-button', 'click': this.handleUpdate.bind(this) }, _('Update'))
E('button', { 'class': 'sh-btn-secondary', 'click': this.handleControl.bind(this, 'stop') }, _('Stop'))
]) ])
]); ]);
return header;
}, },
updateHeader: function(cfg) { updateHeader: function(cfg) {
@ -81,19 +77,17 @@ return view.extend({
renderSetup: function(cfg) { renderSetup: function(cfg) {
var diag = cfg.diagnostics || {}; var diag = cfg.diagnostics || {};
return E('div', { 'class': 'z2m-card' }, [ return E('div', { 'class': 'z2m-card' }, [
E('div', { 'class': 'z2m-card-header' }, [ E('h3', { 'style': 'margin:0 0 0.5em;' }, _('Prerequisites')),
E('div', { 'class': 'sh-card-title' }, _('Prerequisites & Health'))
]),
this.renderDiagnostics(diag) this.renderDiagnostics(diag)
]); ]);
}, },
renderDiagnostics: function(diag) { renderDiagnostics: function(diag) {
var items = [ var items = [
{ key: 'cgroups', label: _('cgroups mounted') }, { key: 'lxc', label: _('LXC') },
{ key: 'docker', label: _('Docker daemon') }, { key: 'cp210x_module', label: _('cp210x module') },
{ key: 'usb_module', label: _('cdc_acm module') },
{ key: 'serial_device', label: _('Serial device') }, { key: 'serial_device', label: _('Serial device') },
{ key: 'container_exists', label: _('Container') },
{ key: 'service_file', label: _('Service script') } { key: 'service_file', label: _('Service script') }
]; ];
return E('div', { 'class': 'z2m-diag-list' }, items.map(function(item) { return E('div', { 'class': 'z2m-diag-list' }, items.map(function(item) {
@ -109,7 +103,7 @@ return view.extend({
}, },
updateDiagnostics: function(diag) { 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 || {}; diag = diag || {};
keys.forEach(function(key) { keys.forEach(function(key) {
var el = document.getElementById('z2m-diag-' + key); var el = document.getElementById('z2m-diag-' + key);
@ -127,25 +121,22 @@ return view.extend({
var self = this; var self = this;
var inputs = [ var inputs = [
self.input('enabled', _('Enable service'), cfg.enabled ? '1' : '0', 'checkbox'), 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_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_username', _('MQTT username'), cfg.mqtt_username || ''),
self.input('mqtt_password', _('MQTT password'), cfg.mqtt_password || '', 'password'), self.input('mqtt_password', _('MQTT password'), cfg.mqtt_password || '', 'password'),
self.input('base_topic', _('Base topic'), cfg.base_topic || 'zigbee2mqtt'), 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('channel', _('Zigbee channel'), cfg.channel || '11', 'number'),
self.input('data_path', _('Data path'), cfg.data_path || '/srv/zigbee2mqtt'), self.input('permit_join', _('Permit join'), cfg.permit_join || '0', 'checkbox'),
self.input('image', _('Docker image'), cfg.image || 'ghcr.io/koenkk/zigbee2mqtt:latest'), self.input('data_path', _('Data path'), cfg.data_path || '/srv/zigbee2mqtt')
self.input('timezone', _('Timezone'), cfg.timezone || 'UTC')
]; ];
return E('div', { 'class': 'z2m-card' }, [ return E('div', { 'class': 'z2m-card' }, [
E('div', { 'class': 'z2m-card-header' }, [ E('h3', { 'style': 'margin:0 0 0.5em;' }, _('Configuration')),
E('div', { 'class': 'sh-card-title' }, _('Configuration'))
]),
E('div', { 'class': 'z2m-form-grid', 'id': 'z2m-form-grid' }, inputs), E('div', { 'class': 'z2m-form-grid', 'id': 'z2m-form-grid' }, inputs),
E('div', { 'class': 'z2m-actions' }, [ 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() { renderLogs: function() {
return E('div', { 'class': 'z2m-card' }, [ return E('div', { 'class': 'z2m-card' }, [
E('div', { 'class': 'z2m-card-header' }, [ E('div', { 'style': 'display:flex; justify-content:space-between; align-items:center; margin-bottom:0.5em;' }, [
E('div', { 'class': 'sh-card-title' }, _('Logs')), E('h3', { 'style': 'margin:0;' }, _('Logs')),
E('div', { 'class': 'z2m-actions' }, [ E('div', { 'style': 'display:flex; gap:0.5em; align-items:center;' }, [
E('input', { 'class': 'z2m-input', 'type': 'number', 'id': 'z2m-log-tail', 'value': '200', 'style': 'width:90px;' }), E('input', { 'class': 'z2m-input', 'type': 'number', 'id': 'z2m-log-tail', 'value': '50', 'style': 'width:70px;' }),
E('button', { 'class': 'sh-btn-secondary', 'click': this.handleLogs.bind(this) }, _('Refresh')) 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, base_topic: document.getElementById('base_topic').value,
frontend_port: document.getElementById('frontend_port').value, frontend_port: document.getElementById('frontend_port').value,
channel: document.getElementById('channel').value, channel: document.getElementById('channel').value,
data_path: document.getElementById('data_path').value, permit_join: document.getElementById('permit_join').checked ? '1' : '0',
image: document.getElementById('image').value, data_path: document.getElementById('data_path').value
timezone: document.getElementById('timezone').value
}; };
ui.showModal(_('Applying configuration'), [ ui.showModal(_('Saving'), [
E('p', {}, _('Saving settings and restarting service…')), E('p', { 'class': 'spinning' }, _('Saving settings and restarting service...'))
E('div', { 'class': 'spinning' })
]); ]);
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.hideModal();
ui.addNotification(null, E('p', {}, _('Configuration applied.')), 'info'); ui.addNotification(null, E('p', {}, _('Configuration applied.')), 'info');
}).catch(function(err) { }).catch(function(err) {
ui.hideModal(); ui.hideModal();
ui.addNotification(null, E('p', {}, err.message || err), 'error'); ui.addNotification(null, E('p', {}, err.message || String(err)), 'danger');
}); });
}, },
handleLogs: function() { 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) { API.getLogs(tail).then(function(result) {
var box = document.getElementById('z2m-log-output'); var box = document.getElementById('z2m-log-output');
if (box && result && result.lines) { if (box && result && result.log) {
box.textContent = result.lines.join('\n'); box.textContent = result.log;
} else if (box) {
box.textContent = _('No logs available.');
} }
}); });
}, },
handleControl: function(action) { handleControl: function(action) {
ui.showModal(_('Executing action'), [ ui.showModal(_('Executing'), [
E('p', {}, _('Performing %s…').format(action)), E('p', { 'class': 'spinning' }, action + '...')
E('div', { 'class': 'spinning' })
]); ]);
API.control(action).then(function(result) { API.control(action).then(function(result) {
ui.hideModal(); ui.hideModal();
if (result && result.success) { 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 { } else {
ui.addNotification(null, E('p', {}, _('Action failed')), 'error'); ui.addNotification(null, E('p', {}, _('Action failed')), 'danger');
} }
}).catch(function(err) { }).catch(function(err) {
ui.hideModal(); ui.hideModal();
ui.addNotification(null, E('p', {}, err.message || err), 'error'); ui.addNotification(null, E('p', {}, err.message || String(err)), 'danger');
}); });
}, },
handleUpdate: function() { handleUpdate: function() {
ui.showModal(_('Updating image'), [ ui.showModal(_('Updating'), [
E('p', {}, _('Pulling latest Zigbee2MQTT image…')), E('p', { 'class': 'spinning' }, _('Updating zigbee2mqtt...'))
E('div', { 'class': 'spinning' })
]); ]);
API.update().then(function(result) { API.update().then(function(result) {
ui.hideModal(); ui.hideModal();
if (result && result.success) { if (result && result.success) {
ui.addNotification(null, E('p', {}, _('Image updated. Service restarted.')), 'info'); ui.addNotification(null, E('p', {}, _('Update complete.')), 'info');
} else { } else {
ui.addNotification(null, E('p', {}, _('Update failed')), 'error'); ui.addNotification(null, E('p', {}, _('Update failed: ') + (result && result.output || '')), 'danger');
} }
}).catch(function(err) { }).catch(function(err) {
ui.hideModal(); ui.hideModal();
ui.addNotification(null, E('p', {}, err.message || err), 'error'); ui.addNotification(null, E('p', {}, err.message || String(err)), 'danger');
}); });
}, },
handleInstall: function() { handleInstall: function() {
this.runCommand(_('Installing prerequisites…'), API.install); this.runCommand(_('Installing...'), API.install);
}, },
handleCheck: function() { handleCheck: function() {
this.runCommand(_('Running prerequisite checks…'), API.runCheck); this.runCommand(_('Running checks...'), API.runCheck);
}, },
runCommand: function(title, fn) { runCommand: function(title, fn) {
var self = this; var self = this;
ui.showModal(title, [ ui.showModal(title, [
E('p', {}, title), E('p', { 'class': 'spinning' }, title)
E('div', { 'class': 'spinning' })
]); ]);
fn().then(function(result) { fn().then(function(result) {
ui.hideModal(); 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(); self.refreshStatus();
}).catch(function(err) { }).catch(function(err) {
ui.hideModal(); 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() { refreshStatus: function() {
var self = this; var self = this;
return API.getStatus().then(function(newData) { return API.getStatus().then(function(newData) {

View File

@ -11,7 +11,9 @@ var callStatus = rpc.declare({
var callApply = rpc.declare({ var callApply = rpc.declare({
object: 'luci.zigbee2mqtt', 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({ var callLogs = rpc.declare({

View File

@ -5,152 +5,7 @@
CONFIG="zigbee2mqtt" CONFIG="zigbee2mqtt"
SERVICE="/etc/init.d/zigbee2mqtt" SERVICE="/etc/init.d/zigbee2mqtt"
CTL="/usr/sbin/zigbee2mqttctl" CTL="/usr/sbin/zigbee2mqttctl"
LXC_NAME="zigbee2mqtt"
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
}
case "$1" in case "$1" in
list) list)
@ -167,15 +22,158 @@ case "$1" in
JSON JSON
;; ;;
call) call)
case "$2" in handle_call() {
status) status ;; case "$1" in
apply) apply ;; status)
logs) logs ;; local serial_port=$(uci -q get ${CONFIG}.main.serial_port)
control) control ;; serial_port=${serial_port:-/dev/ttyUSB0}
update) update ;;
install) install ;; json_init
check) check ;;
*) json_init; json_add_string "error" "unknown method"; json_dump ;; # 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 esac
;; }
handle_call "$2"
;;
esac esac

View File

@ -14,13 +14,13 @@ define Package/secubox-app-zigbee2mqtt
CATEGORY:=Utilities CATEGORY:=Utilities
PKGARCH:=all PKGARCH:=all
SUBMENU:=SecuBox Apps SUBMENU:=SecuBox Apps
TITLE:=SecuBox Zigbee2MQTT docker app TITLE:=SecuBox Zigbee2MQTT LXC app
DEPENDS:=kmod-usb-acm +dockerd +docker +containerd DEPENDS:=+lxc +kmod-usb-serial-cp210x
endef endef
define Package/secubox-app-zigbee2mqtt/description define Package/secubox-app-zigbee2mqtt/description
Installer, configuration, and service manager for running Zigbee2MQTT 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 endef
define Package/secubox-app-zigbee2mqtt/conffiles define Package/secubox-app-zigbee2mqtt/conffiles

View File

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

View File

@ -1,228 +1,476 @@
#!/bin/sh #!/bin/sh
# # SecuBox Zigbee2MQTT Controller — LXC-based (KISS)
# SecuBox Zigbee2MQTT manager # Alpine container with Node.js + zigbee2mqtt
# Handles prerequisite checks, Docker installation, container lifecycle, # USB dongle passed through via cgroup device rules
# and configuration generation using UCI.
CONFIG="zigbee2mqtt" CONFIG="zigbee2mqtt"
CONTAINER="secbx-zigbee2mqtt" LXC_NAME="zigbee2mqtt"
OPKG_UPDATED=0 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; } # Logging
info() { printf '[INFO] %s\n' "$*"; } log_info() { echo "[INFO] $*"; logger -t zigbee2mqtt "$*"; }
err() { printf '[ERROR] %s\n' "$*" >&2; } 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() { usage() {
cat <<'EOF' cat <<'EOF'
SecuBox Zigbee2MQTT Controller (LXC)
Usage: zigbee2mqttctl <command> Usage: zigbee2mqttctl <command>
Commands: Commands:
install Run prerequisite checks, install Docker packages, prepare data dir install Create Alpine LXC container with Node.js + zigbee2mqtt
check Validate kernel modules, cgroups, storage, and serial access uninstall Remove container (keeps data)
update Pull the latest Zigbee2MQTT image and restart the service update Update zigbee2mqtt inside container
status Show container and service status status Show service status
logs Show Docker logs (pass -f to follow) logs [N] Show last N lines of logs (default 50)
service-run Internal: invoked by procd to run the container check Validate USB dongle and prerequisites
service-stop Stop the running container service-run Internal: invoked by procd
service-stop Stop the container
shell Open shell inside container
help Show this message help Show this message
EOF EOF
} }
require_root() { # ════════════════════════════════════════════
if [ "$(id -u)" -ne 0 ]; then # LXC Management
echo "This command requires root privileges." >&2 # ════════════════════════════════════════════
exit 1
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 fi
} }
uci_get() { lxc_create_rootfs() {
uci -q get "${CONFIG}.main.$1" log_info "Creating Alpine rootfs for Zigbee2MQTT..."
} ensure_dir "$LXC_PATH/$LXC_NAME"
defaults() { local arch="x86_64"
serial_port="$(uci_get serial_port || echo /dev/ttyACM0)" case "$(uname -m)" in
mqtt_host="$(uci_get mqtt_host || echo mqtt://127.0.0.1:1883)" aarch64) arch="aarch64" ;;
mqtt_user="$(uci_get mqtt_username || printf '')" armv7l) arch="armv7" ;;
mqtt_pass="$(uci_get mqtt_password || printf '')" esac
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)"
}
ensure_dir() { local alpine_url="https://dl-cdn.alpinelinux.org/alpine/v3.21/releases/$arch/alpine-minirootfs-3.21.2-$arch.tar.gz"
local path="$1" local rootfs_tar="/tmp/alpine-z2m.tar.gz"
[ -d "$path" ] || mkdir -p "$path"
}
ensure_packages() { log_info "Downloading Alpine rootfs..."
local pkgs="$*" wget -q -O "$rootfs_tar" "$alpine_url" || {
require_root log_error "Failed to download Alpine rootfs"
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
return 1 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() { lxc_create_config() {
local port="$1" load_config
if [ ! -c "$port" ]; then
echo "[WARN] Serial device $port not found. Plug the Zigbee coordinator first." >&2
fi
}
check_prereqs() { local arch="x86_64"
defaults case "$(uname -m)" in
check_storage "$data_path" aarch64) arch="aarch64" ;;
check_cgroups || return 1 armv7l) arch="armhf" ;;
check_serial "$serial_port" esac
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
}
ensure_docker() { # Get USB device major:minor for cgroup passthrough
ensure_packages containerd docker dockerd || return 1 local usb_major usb_minor
/etc/init.d/dockerd enable >/dev/null 2>&1 if [ -c "$serial_port" ]; then
/etc/init.d/dockerd start >/dev/null 2>&1 usb_major=$(stat -c '%t' "$serial_port" 2>/dev/null)
if ! docker info >/dev/null 2>&1; then usb_minor=$(stat -c '%T' "$serial_port" 2>/dev/null)
warn "Docker daemon not ready yet. Retry once dockerd has finished starting." # 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 fi
}
generate_configuration() { cat > "$LXC_CONFIG" << EOF
defaults # Zigbee2MQTT LXC Configuration
ensure_dir "$data_path/data" lxc.uts.name = $LXC_NAME
cat > "$data_path/data/configuration.yaml" <<EOF lxc.rootfs.path = dir:$LXC_ROOTFS
homeassistant: true lxc.arch = $arch
permit_join: false
mqtt: # Network: use host network (for MQTT and frontend)
base_topic: "${base_topic}" lxc.net.0.type = none
server: "${mqtt_host}"
user: "${mqtt_user}" # Mount points
password: "${mqtt_pass}" lxc.mount.auto = proc:mixed sys:ro cgroup:mixed
serial: lxc.mount.entry = $data_path/data opt/zigbee2mqtt/data none bind,create=dir 0 0
port: "${serial_port}"
advanced: # USB serial passthrough
channel: ${channel} lxc.cgroup2.devices.allow = c $usb_major:* rwm
frontend: lxc.mount.entry = $serial_port dev/$(basename $serial_port) none bind,create=file 0 0
port: ${frontend_port}
# 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 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" chmod 600 "$data_path/data/configuration.yaml"
log_info "Configuration generated"
} }
pull_image() { generate_start_script() {
defaults load_config
docker pull "$image"
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 # Commands
docker rm "$CONTAINER" >/dev/null 2>&1 || true # ════════════════════════════════════════════
}
cmd_install() { cmd_install() {
require_root require_root
check_prereqs || exit 1 load_config
ensure_docker || exit 1
ensure_dir "$data_path" # Check USB dongle
generate_configuration if [ ! -c "$serial_port" ]; then
pull_image 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 set ${CONFIG}.main.enabled='1'
uci commit ${CONFIG} uci commit ${CONFIG}
/etc/init.d/zigbee2mqtt enable /etc/init.d/zigbee2mqtt enable 2>/dev/null
info "Installation complete. Start with: /etc/init.d/zigbee2mqtt start"
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() { cmd_uninstall() {
check_prereqs require_root
info "Prerequisite check completed." 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() { cmd_update() {
require_root require_root
defaults load_config
pull_image || exit 1
if /etc/init.d/zigbee2mqtt enabled >/dev/null 2>&1; then if ! lxc_exists; then
/etc/init.d/zigbee2mqtt restart log_error "Container not installed. Run: zigbee2mqttctl install"
else return 1
echo "Image updated. Restart manually to apply."
fi 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() { cmd_status() {
if ! command -v docker >/dev/null 2>&1; then load_config
err "docker command missing. Run zigbee2mqttctl install first."
return 1 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 fi
docker ps -a --filter "name=$CONTAINER"
} }
cmd_logs() { cmd_logs() {
if ! command -v docker >/dev/null 2>&1; then local lines="${1:-50}"
err "docker command missing." local logfile="$DATA_PATH/data/zigbee2mqtt.log"
return 1
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 fi
docker logs "$@" "$CONTAINER"
} }
cmd_service_run() { cmd_service_run() {
require_root require_root
check_prereqs || exit 1 load_config
ensure_docker || exit 1
generate_configuration if ! lxc_exists; then
stop_container log_error "Container not installed. Run: zigbee2mqttctl install"
defaults exit 1
exec docker run --rm \ fi
--name "$CONTAINER" \
--device "$serial_port" \ # Regenerate config and start script each run
-p "${frontend_port}:8080" \ generate_config
-v "$data_path/data:/app/data" \ lxc_create_config
-e TZ="$timezone" \ generate_start_script
"$image"
lxc_stop
log_info "Starting zigbee2mqtt container..."
exec lxc-start -n "$LXC_NAME" -F
} }
cmd_service_stop() { cmd_service_stop() {
require_root require_root
stop_container lxc_stop
} }
case "$1" in # ════════════════════════════════════════════
install) shift; cmd_install "$@";; # Main
check) shift; cmd_check "$@";; # ════════════════════════════════════════════
update) shift; cmd_update "$@";;
status) shift; cmd_status "$@";; case "${1:-}" in
logs) shift; cmd_logs "$@";; install) shift; cmd_install "$@" ;;
service-run) shift; cmd_service_run "$@";; uninstall) shift; cmd_uninstall "$@" ;;
service-stop) shift; cmd_service_stop "$@";; update) shift; cmd_update "$@" ;;
help|--help|-h|"") usage;; status) shift; cmd_status "$@" ;;
*) echo "Unknown command: $1" >&2; usage >&2; exit 1;; 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 esac