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:
parent
eab2e5d159
commit
c5d40cf464
@ -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
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user