feat: add luci interface for zigbee2mqtt

This commit is contained in:
CyberMind-FR 2025-12-29 15:55:12 +01:00
parent ec81952db1
commit 40e937a919
9 changed files with 601 additions and 0 deletions

View File

@ -0,0 +1,14 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-zigbee2mqtt
PKG_VERSION:=1.0.0
PKG_RELEASE:=1
LUCI_TITLE:=LuCI Support for SecuBox Zigbee2MQTT App
LUCI_DESCRIPTION:=Graphical interface for managing the Zigbee2MQTT docker application.
LUCI_DEPENDS:=+luci-base +luci-lib-jsonc +secubox-app-zigbee2mqtt
LUCI_PKGARCH:=all
include $(TOPDIR)/feeds/luci/luci.mk
# call BuildPackage - OpenWrt buildroot

View File

@ -0,0 +1,53 @@
# LuCI App Zigbee2MQTT
**Version:** 1.0.0
**Last Updated:** 2025-12-28
**Status:** Active
LuCI interface for managing the Docker-based Zigbee2MQTT service packaged in `secubox-app-zigbee2mqtt`.
## Features
- Displays service/container status, enablement, and quick actions (start/stop/restart/update).
- Provides a form to edit `/etc/config/zigbee2mqtt` (serial port, MQTT host, credentials, base topic, frontend port, channel, data path, docker image, timezone).
- Streams Docker logs directly in LuCI.
- Uses SecuBox design system and RPCD backend (`luci.zigbee2mqtt`).
## Requirements
- `secubox-app-zigbee2mqtt` package installed (provides CLI + procd service).
- Docker runtime (`dockerd`, `docker`, `containerd`) available on the router.
- Zigbee coordinator connected (e.g., `/dev/ttyACM0`).
## Installation
```sh
opkg update
opkg install secubox-app-zigbee2mqtt luci-app-zigbee2mqtt
```
Access via LuCI: **Services → SecuBox → Zigbee2MQTT**.
## Files
| Path | Purpose |
|------|---------|
| `htdocs/luci-static/resources/view/zigbee2mqtt/overview.js` | Main LuCI view. |
| `htdocs/luci-static/resources/zigbee2mqtt/api.js` | RPC bindings. |
| `root/usr/libexec/rpcd/luci.zigbee2mqtt` | RPC backend interacting with UCI and `zigbee2mqttctl`. |
| `root/usr/share/luci/menu.d/luci-app-zigbee2mqtt.json` | Menu entry. |
| `root/usr/share/rpcd/acl.d/luci-app-zigbee2mqtt.json` | ACL defaults. |
## RPC Methods
- `status` Return UCI config, service enable/running state, Docker container list.
- `apply` Update UCI fields, commit, and restart the service.
- `logs` Tail container logs.
- `control` Start/stop/restart service via init script.
- `update` Pull latest image and restart.
## Development Notes
- Follow SecuBox design tokens (see `DOCS/DEVELOPMENT-GUIDELINES.md`).
- Keep RPC filenames aligned with ubus object name (`luci.zigbee2mqtt`).
- Validate with `./secubox-tools/validate-modules.sh`.

View File

@ -0,0 +1,206 @@
'use strict';
'require view';
'require dom';
'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() {
return API.getStatus();
},
render: function(data) {
var config = data || {};
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.renderForm(config),
this.renderLogs()
]);
poll.add(L.bind(function() {
return API.getStatus().then(L.bind(function(newData) {
config = newData;
this.updateHeader(config);
}, this));
}, this), 10);
return container;
},
renderHeader: function(cfg) {
var header = 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('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')),
E('div', { 'class': 'z2m-badge ' + ((cfg.service && cfg.service.enabled) ? 'on' : 'off'), 'id': 'z2m-badge-enabled' },
cfg.service && cfg.service.enabled ? _('Enabled') : _('Disabled'))
])
]),
E('div', { 'class': 'z2m-actions' }, [
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'))
])
]);
return header;
},
updateHeader: function(cfg) {
var runBadge = document.getElementById('z2m-badge-running');
var enBadge = document.getElementById('z2m-badge-enabled');
if (runBadge) {
runBadge.className = 'z2m-badge ' + ((cfg.service && cfg.service.running) ? 'on' : 'off');
runBadge.textContent = (cfg.service && cfg.service.running) ? _('Running') : _('Stopped');
}
if (enBadge) {
enBadge.className = 'z2m-badge ' + ((cfg.service && cfg.service.enabled) ? 'on' : 'off');
enBadge.textContent = (cfg.service && cfg.service.enabled) ? _('Enabled') : _('Disabled');
}
},
renderForm: function(cfg) {
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('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('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')
];
return E('div', { 'class': 'z2m-card' }, [
E('div', { 'class': 'z2m-card-header' }, [
E('div', { 'class': 'sh-card-title' }, _('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'))
])
]);
},
input: function(id, label, value, type) {
type = type || 'text';
var attrs = { 'class': 'z2m-input', 'id': id, 'value': value };
if (type === 'checkbox') {
attrs.type = 'checkbox';
if (value === '1' || value === 1 || value === true) attrs.checked = true;
} else {
attrs.type = type;
}
if (id === 'mqtt_password')
attrs.autocomplete = 'new-password';
return E('div', { 'class': 'z2m-input-group' }, [
E('label', { 'for': id }, label),
E('input', attrs)
]);
},
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('pre', { 'class': 'z2m-log', 'id': 'z2m-log-output' }, _('Logs will appear here.'))
]);
},
handleSave: function() {
var payload = {
enabled: document.getElementById('enabled').checked ? '1' : '0',
serial_port: document.getElementById('serial_port').value,
mqtt_host: document.getElementById('mqtt_host').value,
mqtt_username: document.getElementById('mqtt_username').value,
mqtt_password: document.getElementById('mqtt_password').value,
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
};
ui.showModal(_('Applying configuration'), [
E('p', {}, _('Saving settings and restarting service…')),
E('div', { 'class': 'spinning' })
]);
API.applyConfig(payload).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');
});
},
handleLogs: function() {
var tail = parseInt(document.getElementById('z2m-log-tail').value, 10) || 200;
API.getLogs(tail).then(function(result) {
var box = document.getElementById('z2m-log-output');
if (box && result && result.lines) {
box.textContent = result.lines.join('\n');
}
});
},
handleControl: function(action) {
ui.showModal(_('Executing action'), [
E('p', {}, _('Performing %s…').format(action)),
E('div', { 'class': 'spinning' })
]);
API.control(action).then(function(result) {
ui.hideModal();
if (result && result.success) {
ui.addNotification(null, E('p', {}, _('Action completed: %s').format(action)), 'info');
} else {
ui.addNotification(null, E('p', {}, _('Action failed')), 'error');
}
}).catch(function(err) {
ui.hideModal();
ui.addNotification(null, E('p', {}, err.message || err), 'error');
});
},
handleUpdate: function() {
ui.showModal(_('Updating image'), [
E('p', {}, _('Pulling latest Zigbee2MQTT image…')),
E('div', { 'class': 'spinning' })
]);
API.update().then(function(result) {
ui.hideModal();
if (result && result.success) {
ui.addNotification(null, E('p', {}, _('Image updated. Service restarted.')), 'info');
} else {
ui.addNotification(null, E('p', {}, _('Update failed')), 'error');
}
}).catch(function(err) {
ui.hideModal();
ui.addNotification(null, E('p', {}, err.message || err), 'error');
});
}
});

View File

@ -0,0 +1,40 @@
/* global rpc */
'use strict';
'require rpc';
var callStatus = rpc.declare({
object: 'luci.zigbee2mqtt',
method: 'status',
expect: { }
});
var callApply = rpc.declare({
object: 'luci.zigbee2mqtt',
method: 'apply'
});
var callLogs = rpc.declare({
object: 'luci.zigbee2mqtt',
method: 'logs',
params: ['tail']
});
var callControl = rpc.declare({
object: 'luci.zigbee2mqtt',
method: 'control',
params: ['action']
});
var callUpdate = rpc.declare({
object: 'luci.zigbee2mqtt',
method: 'update'
});
return {
getStatus: callStatus,
applyConfig: callApply,
getLogs: callLogs,
control: callControl,
update: callUpdate
};

View File

@ -0,0 +1,94 @@
.z2m-dashboard {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
}
.z2m-card {
background: rgba(11, 15, 28, 0.92);
border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: 16px;
padding: 20px;
color: #e2e8f0;
box-shadow: 0 18px 30px rgba(2, 6, 23, 0.45);
}
.z2m-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.z2m-form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
}
.z2m-input-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.z2m-input-group label {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #94a3b8;
}
.z2m-input {
border-radius: 10px;
border: 1px solid rgba(148, 163, 184, 0.2);
background: rgba(15, 23, 42, 0.8);
color: #e2e8f0;
padding: 10px 12px;
}
.z2m-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
margin-top: 12px;
}
.z2m-log {
background: #020617;
color: #9efc6a;
border-radius: 12px;
padding: 12px;
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
max-height: 260px;
overflow-y: auto;
}
.z2m-status-badges {
display: flex;
gap: 12px;
}
.z2m-badge {
padding: 6px 12px;
border-radius: 999px;
border: 1px solid rgba(148, 163, 184, 0.2);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.z2m-badge.on {
color: #4ade80;
border-color: rgba(74, 222, 128, 0.4);
background: rgba(22, 163, 74, 0.15);
}
.z2m-badge.off {
color: #f87171;
border-color: rgba(248, 113, 113, 0.4);
background: rgba(248, 113, 113, 0.12);
}

View File

@ -0,0 +1,15 @@
'use strict';
function index()
if not nixio.fs.access('/etc/config/zigbee2mqtt') then
return
end
local root = node('admin', 'secubox')
if not root then
root = entry({'admin', 'secubox'}, firstchild(), _('SecuBox'), 10)
end
entry({'admin', 'secubox', 'zigbee2mqtt'}, firstchild(), _('Zigbee2MQTT'), 50).dependent = false
entry({'admin', 'secubox', 'zigbee2mqtt', 'overview'}, view('zigbee2mqtt/overview'), _('Overview'), 10).leaf = true
end

View File

@ -0,0 +1,141 @@
#!/bin/sh
. /usr/share/libubox/jshn.sh
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)"
}
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
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
}
case "$1" in
list)
cat <<'JSON'
{
"status": {},
"apply": {},
"logs": {},
"control": {},
"update": {}
}
JSON
;;
call)
case "$2" in
status) status ;;
apply) apply ;;
logs) logs ;;
control) control ;;
update) update ;;
*) json_init; json_add_string "error" "unknown method"; json_dump ;;
esac
;;
esac

View File

@ -0,0 +1,10 @@
{
"admin/secubox/zigbee2mqtt": {
"title": "Zigbee2MQTT",
"order": 50,
"action": {
"type": "view",
"path": "zigbee2mqtt/overview"
}
}
}

View File

@ -0,0 +1,28 @@
{
"luci-app-zigbee2mqtt": {
"description": "Access control for Zigbee2MQTT LuCI module",
"read": {
"ubus": {
"luci.zigbee2mqtt": [
"status",
"logs"
]
},
"file": {
"/etc/config/zigbee2mqtt": [ "read" ]
}
},
"write": {
"ubus": {
"luci.zigbee2mqtt": [
"apply",
"control",
"update"
]
},
"file": {
"/etc/config/zigbee2mqtt": [ "write" ]
}
}
}
}