diff --git a/package/secubox/luci-app-zigbee2mqtt/Makefile b/package/secubox/luci-app-zigbee2mqtt/Makefile index 31a912a8..5b0822c1 100644 --- a/package/secubox/luci-app-zigbee2mqtt/Makefile +++ b/package/secubox/luci-app-zigbee2mqtt/Makefile @@ -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 diff --git a/package/secubox/luci-app-zigbee2mqtt/htdocs/luci-static/resources/view/zigbee2mqtt/overview.js b/package/secubox/luci-app-zigbee2mqtt/htdocs/luci-static/resources/view/zigbee2mqtt/overview.js index e13dda10..6c569ea9 100644 --- a/package/secubox/luci-app-zigbee2mqtt/htdocs/luci-static/resources/view/zigbee2mqtt/overview.js +++ b/package/secubox/luci-app-zigbee2mqtt/htdocs/luci-static/resources/view/zigbee2mqtt/overview.js @@ -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) { diff --git a/package/secubox/luci-app-zigbee2mqtt/htdocs/luci-static/resources/zigbee2mqtt/api.js b/package/secubox/luci-app-zigbee2mqtt/htdocs/luci-static/resources/zigbee2mqtt/api.js index f1161cba..765aa37d 100644 --- a/package/secubox/luci-app-zigbee2mqtt/htdocs/luci-static/resources/zigbee2mqtt/api.js +++ b/package/secubox/luci-app-zigbee2mqtt/htdocs/luci-static/resources/zigbee2mqtt/api.js @@ -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({ diff --git a/package/secubox/luci-app-zigbee2mqtt/root/usr/libexec/rpcd/luci.zigbee2mqtt b/package/secubox/luci-app-zigbee2mqtt/root/usr/libexec/rpcd/luci.zigbee2mqtt index 84765deb..d412fa62 100755 --- a/package/secubox/luci-app-zigbee2mqtt/root/usr/libexec/rpcd/luci.zigbee2mqtt +++ b/package/secubox/luci-app-zigbee2mqtt/root/usr/libexec/rpcd/luci.zigbee2mqtt @@ -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 diff --git a/package/secubox/secubox-app-zigbee2mqtt/Makefile b/package/secubox/secubox-app-zigbee2mqtt/Makefile index 4d192be8..0e3e8fe0 100644 --- a/package/secubox/secubox-app-zigbee2mqtt/Makefile +++ b/package/secubox/secubox-app-zigbee2mqtt/Makefile @@ -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 diff --git a/package/secubox/secubox-app-zigbee2mqtt/files/etc/config/zigbee2mqtt b/package/secubox/secubox-app-zigbee2mqtt/files/etc/config/zigbee2mqtt index 1bfd7735..4bbb20e5 100644 --- a/package/secubox/secubox-app-zigbee2mqtt/files/etc/config/zigbee2mqtt +++ b/package/secubox/secubox-app-zigbee2mqtt/files/etc/config/zigbee2mqtt @@ -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' diff --git a/package/secubox/secubox-app-zigbee2mqtt/files/usr/sbin/zigbee2mqttctl b/package/secubox/secubox-app-zigbee2mqtt/files/usr/sbin/zigbee2mqttctl index 91677bee..c412de86 100644 --- a/package/secubox/secubox-app-zigbee2mqtt/files/usr/sbin/zigbee2mqttctl +++ b/package/secubox/secubox-app-zigbee2mqtt/files/usr/sbin/zigbee2mqttctl @@ -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 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" < "$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