From ee5c001572c5f3397bbf3541b438611e551e8b71 Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Mon, 29 Dec 2025 15:18:46 +0100 Subject: [PATCH] feat: mqtt daemon automations and presets --- .codex/WIP.md | 2 +- .codex/apps/mqtt-bridge/TODO.md | 17 +- .codex/apps/mqtt-bridge/WIP.md | 1 + DOCS/MQTT_BRIDGE.md | 29 ++ luci-app-mqtt-bridge/README.md | 10 +- .../luci-static/resources/mqtt-bridge/api.js | 15 +- .../resources/mqtt-bridge/common.css | 39 +++ .../resources/view/mqtt-bridge/settings.js | 64 +++- .../root/etc/config/mqtt-bridge | 28 ++ .../root/etc/init.d/mqtt-bridge | 4 +- .../root/usr/libexec/rpcd/luci.mqtt-bridge | 110 ++++++- .../root/usr/sbin/mqtt-bridge | 275 ++++++++++++++++++ .../root/usr/sbin/mqtt-bridge-monitor | 150 +--------- 13 files changed, 570 insertions(+), 174 deletions(-) create mode 100755 luci-app-mqtt-bridge/root/usr/sbin/mqtt-bridge diff --git a/.codex/WIP.md b/.codex/WIP.md index 7285c98a..75ac016f 100644 --- a/.codex/WIP.md +++ b/.codex/WIP.md @@ -4,7 +4,7 @@ - Introduced SecuBox cascade layout helper (CSS + JS) and migrated SecuNav + MQTT tabs to the new layered system. - MQTT Bridge now exposes Zigbee/SMSC USB2134B presets with dmesg hints, tty detection, and documentation updates. -- New `mqtt-bridge-monitor` daemon keeps adapter metadata (port/bus/health) synced and logs detection events for SecuBox. +- New `mqtt-bridge` daemon keeps adapter metadata (port/bus/health) synced, updates stats, and runs automation rules/templates. - Unified Monitoring + Modules filters and Help view with SecuNav styling. - Added Bonus tab to navbar, refreshed alerts action buttons, removed legacy hero blocks. - Verified on router (scp + cache reset) and tagged release v0.5.0-A. diff --git a/.codex/apps/mqtt-bridge/TODO.md b/.codex/apps/mqtt-bridge/TODO.md index cb1ed618..b97baee4 100644 --- a/.codex/apps/mqtt-bridge/TODO.md +++ b/.codex/apps/mqtt-bridge/TODO.md @@ -1,17 +1,12 @@ # TODO – MQTT Bridge -1. **Daemon Integration** - - Implement `/usr/sbin/mqtt-bridge` watcher handling USB serial adapters. - - Emit stats to `uci set mqtt-bridge.stats.*` for UI refresh. - -2. **Security** +1. **Security** - Support TLS options (CA, client certs) in Settings. - Add access control for pairing window. -3. **Automations** - - Add topic templates per device type (Zigbee, Modbus). - - Provide rules to forward payloads into SecuBox Alerts. +2. **Automations** + - Expand rules to trigger SecuBox Alerts via ubus +/- integrate with alerting UI. + - Wire topic templates into actual payload routing once the MQTT daemon is implemented. -4. **Profiles** - - Allow LuCI to edit adapter entries (enable/disable, rename, override serial port). - - Surface per-adapter health metrics/uptime graphs and expose actions (rescan, reset). +3. **Profiles** + - Visualize adapter health trends (sparklines) and expose multi-port mapping options. diff --git a/.codex/apps/mqtt-bridge/WIP.md b/.codex/apps/mqtt-bridge/WIP.md index 1e320411..d16e078e 100644 --- a/.codex/apps/mqtt-bridge/WIP.md +++ b/.codex/apps/mqtt-bridge/WIP.md @@ -5,6 +5,7 @@ - Added RPC backend (`luci.mqtt-bridge`) and UCI defaults for broker/bridge stats. - Added Zigbee/SMSC USB2134B preset detection (USB VID/PID scan, tty hinting, LuCI cards + docs). - Added `/usr/sbin/mqtt-bridge-monitor` + init.d service to keep adapter sections (port/bus/health) in sync. +- Promoted the monitor into `/usr/sbin/mqtt-bridge` daemon with stats tracking, automation rules, topic templates, and LuCI-side preset import/rescan/reset actions. ## In Progress - Flesh out real USB discovery and MQTT client integration. diff --git a/DOCS/MQTT_BRIDGE.md b/DOCS/MQTT_BRIDGE.md index f7d398fe..6c76a830 100644 --- a/DOCS/MQTT_BRIDGE.md +++ b/DOCS/MQTT_BRIDGE.md @@ -76,9 +76,38 @@ The package now installs a lightweight watcher (`/usr/sbin/mqtt-bridge-monitor`) - Writes state transitions to the system log (`logread -e mqtt-bridge-monitor`). - Updates each adapter section with `detected`, `port`, `bus`, `device`, `health`, and `last_seen`, which the LuCI Devices tab now surfaces. - The MQTT Settings view exposes the same adapter entries so you can enable/disable presets, rename labels, or override `/dev/tty*` assignments without leaving the UI. +- Buttons in the Settings view let you trigger a rescan (`API.rescanAdapters`) or clear cached data for a specific adapter (`API.resetAdapter`), which is helpful after re-flashing dongles or moving USB ports. Use `uci show mqtt-bridge.adapter` to inspect the persisted metadata, or `ubus call luci.mqtt-bridge status` to see the JSON payload consumed by the UI. +## Templates & automation rules + +`/etc/config/mqtt-bridge` now includes `config template` definitions for Zigbee and Modbus devices: + +```uci +config template 'zigbee_default' + option device_type 'zigbee' + option topic 'secubox/zigbee/{id}/state' + option qos '1' + option retain '1' +``` + +These are exported through the `status` RPC (`templates` array) so LuCI or external clients can suggest topic patterns per device type. Add your own sections to cover other buses or naming schemes. + +Automation rules (`config rule`) can react to adapter state transitions: + +```uci +config rule 'zigbee_disconnect' + option type 'adapter_status' + option adapter 'zigbee_usb2134' + option when 'missing' + option action 'alert' + option message 'Zigbee USB bridge disconnected' + option topic 'alerts/mqtt/zigbee' +``` + +When the daemon notices the adapter go `missing` or `online`, matching rules write to syslog and `/tmp/mqtt-bridge-alerts.log`, making it easy to forward events into SecuBox Alerts or any other pipeline. + ## Next steps - Add real daemon integration with Mosquitto. diff --git a/luci-app-mqtt-bridge/README.md b/luci-app-mqtt-bridge/README.md index a4c2069d..805f7e96 100644 --- a/luci-app-mqtt-bridge/README.md +++ b/luci-app-mqtt-bridge/README.md @@ -22,7 +22,15 @@ The LuCI views depend on the SecuBox theme bundle included in `luci-theme-secubo ## Daemon / Monitor -`/usr/sbin/mqtt-bridge-monitor` (started via `/etc/init.d/mqtt-bridge`) polls configured adapter presets, logs plug/unplug events, and updates `/etc/config/mqtt-bridge` with `detected`, `port`, `bus`, `device`, and `health` metadata. The Devices view consumes those values to surface Zigbee/serial presets along with `dmesg` hints for `/dev/tty*` alignment. +`/usr/sbin/mqtt-bridge` (started via `/etc/init.d/mqtt-bridge`) polls configured adapter presets, logs plug/unplug events, and updates `/etc/config/mqtt-bridge` with `detected`, `port`, `bus`, `device`, `health`, and `last_seen` metadata. The daemon also keeps `mqtt-bridge.stats.*` fresh (clients, messages/sec, uptime) and executes automation rules defined in the config. The Devices/Settings views consume those values to surface Zigbee/serial presets along with `dmesg` hints for `/dev/tty*` alignment. + +Legacy `/usr/sbin/mqtt-bridge-monitor` is kept as a wrapper for backwards compatibility and now simply execs the unified daemon. + +## Topic templates & rules + +`/etc/config/mqtt-bridge` ships with starter `config template` entries (Zigbee/Modbus) describing MQTT topic patterns per device type. You can add/override templates and the RPC API exposes them so LuCI (or automation tooling) can build device-specific topics dynamically. + +`config rule` sections define automation hooks. The daemon currently supports `type adapter_status` with `action alert|rescan`. When adapter health transitions (e.g. online → missing) the matching rule logs to syslog and appends to `/tmp/mqtt-bridge-alerts.log`, which you can ingest into SecuBox Alerts or other systems. ## Development Notes diff --git a/luci-app-mqtt-bridge/htdocs/luci-static/resources/mqtt-bridge/api.js b/luci-app-mqtt-bridge/htdocs/luci-static/resources/mqtt-bridge/api.js index 6e74ec7a..07a8cf1c 100644 --- a/luci-app-mqtt-bridge/htdocs/luci-static/resources/mqtt-bridge/api.js +++ b/luci-app-mqtt-bridge/htdocs/luci-static/resources/mqtt-bridge/api.js @@ -25,9 +25,22 @@ var callApplySettings = rpc.declare({ method: 'apply_settings' }); +var callRescanAdapters = rpc.declare({ + object: 'luci.mqtt-bridge', + method: 'rescan_adapters', + expect: {} +}); + +var callResetAdapter = rpc.declare({ + object: 'luci.mqtt-bridge', + method: 'reset_adapter' +}); + return baseclass.extend({ getStatus: callStatus, listDevices: callListDevices, triggerPairing: callTriggerPairing, - applySettings: callApplySettings + applySettings: callApplySettings, + rescanAdapters: callRescanAdapters, + resetAdapter: callResetAdapter }); diff --git a/luci-app-mqtt-bridge/htdocs/luci-static/resources/mqtt-bridge/common.css b/luci-app-mqtt-bridge/htdocs/luci-static/resources/mqtt-bridge/common.css index 470ba88b..99abce60 100644 --- a/luci-app-mqtt-bridge/htdocs/luci-static/resources/mqtt-bridge/common.css +++ b/luci-app-mqtt-bridge/htdocs/luci-static/resources/mqtt-bridge/common.css @@ -729,6 +729,45 @@ pre { height: 16px; } +.mb-adapter-footer { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.mb-adapter-actions { + display: flex; + gap: 8px; +} + +.mb-health { + text-transform: uppercase; + font-size: 12px; + font-weight: 600; + padding: 4px 10px; + border-radius: 999px; + border: 1px solid var(--mb-border); +} + +.mb-health.online { + color: #22c55e; + border-color: rgba(34, 197, 94, 0.6); + background: rgba(34, 197, 94, 0.12); +} + +.mb-health.missing { + color: #f97316; + border-color: rgba(249, 115, 22, 0.6); + background: rgba(249, 115, 22, 0.12); +} + +.mb-health.disabled, +.mb-health.unknown { + color: var(--mb-muted); +} + @media (max-width: 768px) { .mqtt-bridge-dashboard { padding: 16px; diff --git a/luci-app-mqtt-bridge/htdocs/luci-static/resources/view/mqtt-bridge/settings.js b/luci-app-mqtt-bridge/htdocs/luci-static/resources/view/mqtt-bridge/settings.js index fe58e59c..4282c404 100644 --- a/luci-app-mqtt-bridge/htdocs/luci-static/resources/view/mqtt-bridge/settings.js +++ b/luci-app-mqtt-bridge/htdocs/luci-static/resources/view/mqtt-bridge/settings.js @@ -6,6 +6,7 @@ 'require ui'; 'require form'; 'require dom'; +'require dom'; var lang = (typeof L !== 'undefined' && L.env && L.env.lang) || (document.documentElement && document.documentElement.getAttribute('lang')) || @@ -86,7 +87,9 @@ return view.extend({ E('strong', {}, adapter.label || id || _('Adapter')), E('div', { 'class': 'mb-profile-meta' }, [ adapter.vendor && adapter.product ? _('VID:PID ') + adapter.vendor + ':' + adapter.product : null, - adapter.port ? _('Port ') + adapter.port : null + adapter.port ? _('Port ') + adapter.port : null, + adapter.health ? _('Health ') + adapter.health : null, + adapter.last_seen ? _('Last seen ') + adapter.last_seen : null ].filter(Boolean).map(function(entry) { return E('span', {}, entry); })) @@ -102,6 +105,21 @@ return view.extend({ ]), this.input(this.makeAdapterInputId(id, 'custom-label'), _('Display label'), adapter.label || id), this.input(this.makeAdapterInputId(id, 'custom-port'), _('Preferred /dev/tty*'), adapter.port || '', 'text'), + E('div', { 'class': 'mb-adapter-footer' }, [ + E('span', { + 'class': 'mb-health ' + this.healthClass(adapter.health) + }, (adapter.health || _('unknown')).toString()), + E('div', { 'class': 'mb-adapter-actions' }, [ + E('button', { + 'class': 'mb-btn mb-btn-secondary', + 'click': this.handleRescan.bind(this, id) + }, ['🔄 ', _('Rescan')]), + E('button', { + 'class': 'mb-btn mb-btn-secondary', + 'click': this.handleReset.bind(this, id) + }, ['♻️ ', _('Reset')]) + ]) + ]), adapter.notes ? E('p', { 'class': 'mb-profile-notes' }, adapter.notes) : null ]); }, @@ -168,6 +186,10 @@ return view.extend({ return (id || '').replace(/[^a-z0-9_-]/ig, '_') || 'adapter_' + Math.random().toString(36).slice(2, 7); }, + healthClass: function(val) { + return (val || 'unknown').toString().toLowerCase().replace(/[^a-z0-9_-]/g, '-'); + }, + cloneAdapters: function(list) { var cloned = []; (list || []).forEach(function(item) { @@ -245,12 +267,52 @@ return view.extend({ product: profile.product || '', port: profile.port || '', enabled: profile.detected ? 1 : 0, + detected: profile.detected ? 1 : 0, + health: profile.detected ? 'online' : 'missing', preset: profile.id || profile.preset || '' }); this.refreshAdapterGrid(); ui.addNotification(null, E('p', {}, _('Preset added. Remember to save preferences.')), 'info'); }, + handleRescan: function(id) { + ui.showModal(_('Rescanning adapters'), [ + E('p', {}, _('Triggering daemon rescan…')), + E('div', { 'class': 'spinning' }) + ]); + return API.rescanAdapters().then(function() { + ui.hideModal(); + ui.addNotification(null, E('p', {}, _('Rescan triggered. Refresh status after a few seconds.')), 'info'); + }).catch(function(err) { + ui.hideModal(); + ui.addNotification(null, E('p', {}, err.message || err), 'error'); + }); + }, + + handleReset: function(id) { + if (!id) + return; + var self = this; + ui.showModal(_('Reset adapter'), [ + E('p', {}, _('Clear cached detection info for ') + id + '?'), + E('div', { 'class': 'right' }, [ + E('button', { 'class': 'btn', 'click': ui.hideModal }, _('Cancel')), + E('button', { + 'class': 'btn cbi-button-negative', + 'click': function() { + API.resetAdapter({ adapter: id }).then(function() { + ui.hideModal(); + ui.addNotification(null, E('p', {}, _('Adapter reset. Wait for next daemon scan.')), 'info'); + }).catch(function(err) { + ui.hideModal(); + ui.addNotification(null, E('p', {}, err.message || err), 'error'); + }); + } + }, _('Reset')) + ]) + ]); + }, + savePreferences: function() { var payload = this.collectSettings(); payload.adapters = this.collectAdapters(); diff --git a/luci-app-mqtt-bridge/root/etc/config/mqtt-bridge b/luci-app-mqtt-bridge/root/etc/config/mqtt-bridge index 807d82e9..23e93a5f 100644 --- a/luci-app-mqtt-bridge/root/etc/config/mqtt-bridge +++ b/luci-app-mqtt-bridge/root/etc/config/mqtt-bridge @@ -24,6 +24,34 @@ config adapter 'zigbee_usb2134' option device '' option health 'unknown' +config template 'zigbee_default' + option device_type 'zigbee' + option topic 'secubox/zigbee/{id}/state' + option qos '1' + option retain '1' + +config template 'modbus_default' + option device_type 'modbus' + option topic 'secubox/modbus/{id}/state' + option qos '0' + option retain '0' + +config rule 'zigbee_disconnect' + option type 'adapter_status' + option adapter 'zigbee_usb2134' + option when 'missing' + option action 'alert' + option message 'Zigbee USB bridge disconnected' + option topic 'alerts/mqtt/zigbee' + +config rule 'zigbee_online' + option type 'adapter_status' + option adapter 'zigbee_usb2134' + option when 'online' + option action 'alert' + option message 'Zigbee USB bridge is online again' + option topic 'alerts/mqtt/zigbee' + config stats 'stats' option clients '0' option mps '0' diff --git a/luci-app-mqtt-bridge/root/etc/init.d/mqtt-bridge b/luci-app-mqtt-bridge/root/etc/init.d/mqtt-bridge index 920d89ae..46f50a2b 100755 --- a/luci-app-mqtt-bridge/root/etc/init.d/mqtt-bridge +++ b/luci-app-mqtt-bridge/root/etc/init.d/mqtt-bridge @@ -2,11 +2,11 @@ START=95 USE_PROCD=1 -SERVICE_NAME="mqtt-bridge-monitor" +SERVICE_NAME="mqtt-bridge" start_service() { procd_open_instance - procd_set_param command /usr/sbin/mqtt-bridge-monitor + procd_set_param command /usr/sbin/mqtt-bridge procd_set_param respawn 0 5 5 procd_close_instance } diff --git a/luci-app-mqtt-bridge/root/usr/libexec/rpcd/luci.mqtt-bridge b/luci-app-mqtt-bridge/root/usr/libexec/rpcd/luci.mqtt-bridge index 1a51ab84..f1f2c720 100755 --- a/luci-app-mqtt-bridge/root/usr/libexec/rpcd/luci.mqtt-bridge +++ b/luci-app-mqtt-bridge/root/usr/libexec/rpcd/luci.mqtt-bridge @@ -113,6 +113,56 @@ append_configured_adapters() { json_close_array } +add_template_json() { + local section="$1" + local device_type topic qos retain + config_get device_type "$section" device_type + config_get topic "$section" topic + config_get qos "$section" qos + config_get retain "$section" retain + json_add_object + json_add_string "id" "$section" + [ -n "$device_type" ] && json_add_string "device_type" "$device_type" + [ -n "$topic" ] && json_add_string "topic" "$topic" + [ -n "$qos" ] && json_add_string "qos" "$qos" + [ -n "$retain" ] && json_add_string "retain" "$retain" + json_close_object +} + +append_templates() { + json_add_array "templates" + config_load mqtt-bridge + config_foreach add_template_json template + json_close_array +} + +add_rule_json() { + local section="$1" + local type adapter when action message topic + config_get type "$section" type + config_get adapter "$section" adapter + config_get when "$section" when + config_get action "$section" action + config_get message "$section" message + config_get topic "$section" topic + json_add_object + json_add_string "id" "$section" + [ -n "$type" ] && json_add_string "type" "$type" + [ -n "$adapter" ] && json_add_string "adapter" "$adapter" + [ -n "$when" ] && json_add_string "when" "$when" + [ -n "$action" ] && json_add_string "action" "$action" + [ -n "$message" ] && json_add_string "message" "$message" + [ -n "$topic" ] && json_add_string "topic" "$topic" + json_close_object +} + +append_rules() { + json_add_array "rules" + config_load mqtt-bridge + config_foreach add_rule_json rule + json_close_array +} + apply_adapter_settings() { local adapter_keys json_get_keys adapter_keys @@ -189,20 +239,22 @@ status() { done json_close_array -json_add_object "settings" -json_add_string "host" "$(uci -q get mqtt-bridge.broker.host || echo '127.0.0.1')" -json_add_int "port" "$(uci -q get mqtt-bridge.broker.port || echo 1883)" -json_add_string "username" "$(uci -q get mqtt-bridge.broker.username || echo '')" -json_add_string "password" "" -json_add_string "base_topic" "$(uci -q get mqtt-bridge.bridge.base_topic || echo 'secubox/+/state')" -json_add_int "retention" "$(uci -q get mqtt-bridge.bridge.retention || echo 7)" -json_close_object + json_add_object "settings" + json_add_string "host" "$(uci -q get mqtt-bridge.broker.host || echo '127.0.0.1')" + json_add_int "port" "$(uci -q get mqtt-bridge.broker.port || echo 1883)" + json_add_string "username" "$(uci -q get mqtt-bridge.broker.username || echo '')" + json_add_string "password" "" + json_add_string "base_topic" "$(uci -q get mqtt-bridge.bridge.base_topic || echo 'secubox/+/state')" + json_add_int "retention" "$(uci -q get mqtt-bridge.bridge.retention || echo 7)" + json_close_object json_add_array "profiles" append_zigbee_profile json_close_array append_configured_adapters +append_templates +append_rules json_dump } @@ -266,6 +318,42 @@ apply_settings() { json_dump } +rescan_adapters() { + /usr/sbin/mqtt-bridge --rescan >/dev/null 2>&1 & + json_init + json_add_boolean "success" 1 + json_add_string "message" "rescan_triggered" + json_dump +} + +reset_adapter() { + read input + json_load "$input" + json_get_var adapter adapter + json_cleanup + + if [ -z "$adapter" ]; then + json_init + json_add_boolean "success" 0 + json_add_string "error" "missing_adapter" + json_dump + return + fi + + uci delete mqtt-bridge.adapter."$adapter".port >/dev/null 2>&1 + uci delete mqtt-bridge.adapter."$adapter".bus >/dev/null 2>&1 + uci delete mqtt-bridge.adapter."$adapter".device >/dev/null 2>&1 + uci delete mqtt-bridge.adapter."$adapter".detected >/dev/null 2>&1 + uci delete mqtt-bridge.adapter."$adapter".health >/dev/null 2>&1 + uci delete mqtt-bridge.adapter."$adapter".last_seen >/dev/null 2>&1 + uci commit mqtt-bridge + + json_init + json_add_boolean "success" 1 + json_add_string "message" "adapter_reset" + json_dump +} + case "$1" in list) cat <<'JSON' @@ -273,7 +361,9 @@ case "$1" in "status": {}, "list_devices": {}, "trigger_pairing": {}, - "apply_settings": {} + "apply_settings": {}, + "rescan_adapters": {}, + "reset_adapter": {} } JSON ;; @@ -283,6 +373,8 @@ JSON list_devices) list_devices ;; trigger_pairing) trigger_pairing ;; apply_settings) apply_settings ;; + rescan_adapters) rescan_adapters ;; + reset_adapter) reset_adapter ;; *) json_init json_add_boolean "success" 0 diff --git a/luci-app-mqtt-bridge/root/usr/sbin/mqtt-bridge b/luci-app-mqtt-bridge/root/usr/sbin/mqtt-bridge new file mode 100755 index 00000000..31fe0544 --- /dev/null +++ b/luci-app-mqtt-bridge/root/usr/sbin/mqtt-bridge @@ -0,0 +1,275 @@ +#!/bin/sh +# +# SecuBox MQTT Bridge daemon +# Handles USB adapter discovery, stats tracking, and automation hooks. + +. /lib/functions.sh + +UCI_NAMESPACE="mqtt-bridge" +LOGTAG="mqtt-bridge" +SCAN_INTERVAL=10 +COMMIT_NEEDED=0 +START_TIME="$(date +%s)" +PREV_PAYLOAD_COUNT=0 +PREV_TIMESTAMP="$START_TIME" +LAST_EVENT="" + +log_msg() { + logger -t "$LOGTAG" "$*" +} + +format_duration() { + local seconds="$1" + local h=$((seconds / 3600)) + local m=$(( (seconds % 3600) / 60 )) + local s=$((seconds % 60)) + printf '%02dh %02dm %02ds' "$h" "$m" "$s" +} + +load_interval() { + config_load "$UCI_NAMESPACE" + config_get interval monitor interval + [ -n "$interval" ] && SCAN_INTERVAL="$interval" +} + +find_usb_device() { + local vendor="$1" + local product="$2" + local dev + + for dev in /sys/bus/usb/devices/*; do + [ -f "$dev/idVendor" ] || continue + [ -f "$dev/idProduct" ] || continue + local idVendor idProduct + idVendor="$(cat "$dev/idVendor" 2>/dev/null)" + idProduct="$(cat "$dev/idProduct" 2>/dev/null)" + [ "$idVendor" = "$vendor" ] || continue + [ "$idProduct" = "$product" ] || continue + echo "$dev" + return 0 + done + + return 1 +} + +find_usb_tty() { + local base="$1" + local path node + for path in "$base" "$base"/* "$base"/*/*; do + [ -d "$path/tty" ] || continue + for node in "$path"/tty/*; do + [ -e "$node" ] || continue + local tty + tty="$(basename "$node")" + [ -e "/dev/$tty" ] && { echo "/dev/$tty"; return 0; } + done + done + return 1 +} + +set_option_if_changed() { + local section="$1" + local key="$2" + local value="$3" + local current + current="$(uci -q get ${UCI_NAMESPACE}.adapter.${section}.${key} 2>/dev/null)" + [ "$current" = "$value" ] && return + uci set ${UCI_NAMESPACE}.adapter.${section}.${key}="$value" + COMMIT_NEEDED=1 +} + +clear_option_if_needed() { + local section="$1" + local key="$2" + local current + current="$(uci -q get ${UCI_NAMESPACE}.adapter.${section}.${key} 2>/dev/null)" + [ -z "$current" ] && return + uci delete ${UCI_NAMESPACE}.adapter.${section}.${key} + COMMIT_NEEDED=1 +} + +publish_alert() { + local message="$1" + local topic="$2" + [ -n "$message" ] || return + log_msg "ALERT: $message" + if [ -n "$topic" ]; then + printf '%s %s\n' "$(date -Iseconds)" "$message" >> /tmp/mqtt-bridge-alerts.log + fi +} + +RULE_MATCH_ADAPTER="" +RULE_MATCH_STATE="" + +apply_rule() { + local section="$1" + local target when action message topic + config_get target "$section" adapter + config_get when "$section" when + config_get action "$section" action + config_get message "$section" message + config_get topic "$section" topic + [ "$RULE_MATCH_ADAPTER" = "$target" ] || return + [ "$RULE_MATCH_STATE" = "$when" ] || return + + case "$action" in + alert) + publish_alert "$message" "$topic" + ;; + rescan) + log_msg "Rule $section triggered rescan for $target" + run_detection_once + ;; + esac +} + +run_rules() { + RULE_MATCH_ADAPTER="$1" + RULE_MATCH_STATE="$2" + config_foreach apply_rule rule +} + +update_adapter_section() { + local section="$1" + local enabled vendor product title preset + + config_get enabled "$section" enabled "1" + config_get vendor "$section" vendor + config_get product "$section" product + config_get preset "$section" preset + config_get title "$section" title + + if [ "$enabled" != "1" ]; then + set_option_if_changed "$section" detected "0" + set_option_if_changed "$section" health "disabled" + return + fi + + if [ -z "$vendor" ] || [ -z "$product" ]; then + set_option_if_changed "$section" detected "0" + set_option_if_changed "$section" health "unknown" + return + fi + + local dev_path + dev_path="$(find_usb_device "$vendor" "$product")" || dev_path="" + + local prev_detected + prev_detected="$(uci -q get ${UCI_NAMESPACE}.adapter.${section}.detected 2>/dev/null)" + + if [ -n "$dev_path" ]; then + local bus devnum port ts + bus="$(cat "$dev_path/busnum" 2>/dev/null)" + devnum="$(cat "$dev_path/devnum" 2>/dev/null)" + port="$(find_usb_tty "$dev_path")" + ts="$(date -Iseconds)" + + set_option_if_changed "$section" detected "1" + set_option_if_changed "$section" health "online" + [ -n "$bus" ] && set_option_if_changed "$section" bus "$bus" + [ -n "$devnum" ] && set_option_if_changed "$section" device "$devnum" + if [ -n "$port" ]; then + set_option_if_changed "$section" port "$port" + else + clear_option_if_needed "$section" port + fi + set_option_if_changed "$section" last_seen "$ts" + + if [ "$prev_detected" != "1" ]; then + LAST_EVENT="$ts" + log_msg "Adapter $section ($title) detected on bus $bus dev $devnum $port" + run_rules "$section" "online" + fi + else + set_option_if_changed "$section" detected "0" + set_option_if_changed "$section" health "missing" + clear_option_if_needed "$section" port + clear_option_if_needed "$section" bus + clear_option_if_needed "$section" device + if [ "$prev_detected" = "1" ]; then + LAST_EVENT="$(date -Iseconds)" + log_msg "Adapter $section ($title) disconnected" + run_rules "$section" "missing" + fi + fi +} + +count_enabled_clients() { + local count=0 + config_foreach _count_client adapter "$1" + echo "$count" +} + +_count_client() { + local section="$1" + local enabled detected + config_get enabled "$section" enabled 1 + config_get detected "$section" detected 0 + if [ "$enabled" = "1" ] && [ "$detected" = "1" ]; then + count=$((count + 1)) + fi +} + +payload_count() { + uci -q show mqtt-bridge.payloads 2>/dev/null | grep -c '=payload' +} + +update_stats() { + local now payloads clients delta count elapsed + now="$(date +%s)" + + config_load "$UCI_NAMESPACE" + count=0 + config_foreach _count_client adapter + clients="$count" + + payloads="$(payload_count)" + elapsed=$((now - PREV_TIMESTAMP)) + if [ "$elapsed" -gt 0 ]; then + delta=$((payloads - PREV_PAYLOAD_COUNT)) + if [ "$delta" -lt 0 ]; then + delta=0 + fi + local mps=$((delta / elapsed)) + uci set ${UCI_NAMESPACE}.stats.mps="$mps" + fi + PREV_PAYLOAD_COUNT="$payloads" + PREV_TIMESTAMP="$now" + + uci set ${UCI_NAMESPACE}.stats.clients="$clients" + uci set ${UCI_NAMESPACE}.stats.retained="${payloads:-0}" + uci set ${UCI_NAMESPACE}.stats.uptime="$(format_duration $((now - START_TIME)))" + [ -n "$LAST_EVENT" ] && uci set ${UCI_NAMESPACE}.stats.last_event="$LAST_EVENT" + + uci commit ${UCI_NAMESPACE} +} + +run_detection_once() { + COMMIT_NEEDED=0 + config_load "$UCI_NAMESPACE" + config_foreach update_adapter_section adapter + if [ "$COMMIT_NEEDED" -eq 1 ]; then + uci commit "$UCI_NAMESPACE" + fi + update_stats +} + +daemon_loop() { + while true; do + load_interval + run_detection_once + sleep "$SCAN_INTERVAL" + done +} + +case "$1" in + --rescan) + run_detection_once + ;; + --oneshot) + run_detection_once + ;; + *) + daemon_loop + ;; +esac diff --git a/luci-app-mqtt-bridge/root/usr/sbin/mqtt-bridge-monitor b/luci-app-mqtt-bridge/root/usr/sbin/mqtt-bridge-monitor index 07613ced..b0d05abf 100755 --- a/luci-app-mqtt-bridge/root/usr/sbin/mqtt-bridge-monitor +++ b/luci-app-mqtt-bridge/root/usr/sbin/mqtt-bridge-monitor @@ -1,149 +1,3 @@ #!/bin/sh -# -# MQTT Bridge monitor daemon -# Scans configured USB adapters/presets and updates UCI with live metadata. - -. /lib/functions.sh - -UCI_NAMESPACE="mqtt-bridge" -LOGTAG="mqtt-bridge-monitor" -SCAN_INTERVAL=10 -COMMIT_NEEDED=0 - -log_msg() { - logger -t "$LOGTAG" "$*" -} - -find_usb_device() { - local vendor="$1" - local product="$2" - local dev - - for dev in /sys/bus/usb/devices/*; do - [ -f "$dev/idVendor" ] || continue - [ -f "$dev/idProduct" ] || continue - local idVendor idProduct - idVendor="$(cat "$dev/idVendor" 2>/dev/null)" - idProduct="$(cat "$dev/idProduct" 2>/dev/null)" - [ "$idVendor" = "$vendor" ] || continue - [ "$idProduct" = "$product" ] || continue - echo "$dev" - return 0 - done - - return 1 -} - -find_usb_tty() { - local base="$1" - local path node - for path in "$base" "$base"/* "$base"/*/*; do - [ -d "$path/tty" ] || continue - for node in "$path"/tty/*; do - [ -e "$node" ] || continue - local tty - tty="$(basename "$node")" - [ -e "/dev/$tty" ] && { echo "/dev/$tty"; return 0; } - done - done - return 1 -} - -set_option_if_changed() { - local section="$1" - local key="$2" - local value="$3" - local current - current="$(uci -q get ${UCI_NAMESPACE}.adapter.${section}.${key} 2>/dev/null)" - [ "$current" = "$value" ] && return - uci set ${UCI_NAMESPACE}.adapter.${section}.${key}="$value" - COMMIT_NEEDED=1 -} - -clear_option_if_needed() { - local section="$1" - local key="$2" - local current - current="$(uci -q get ${UCI_NAMESPACE}.adapter.${section}.${key} 2>/dev/null)" - [ -z "$current" ] && return - uci delete ${UCI_NAMESPACE}.adapter.${section}.${key} - COMMIT_NEEDED=1 -} - -update_adapter_section() { - local section="$1" - local enabled vendor product title preset - - config_get enabled "$section" enabled "1" - config_get vendor "$section" vendor - config_get product "$section" product - config_get preset "$section" preset - config_get title "$section" title - - if [ "$enabled" != "1" ]; then - set_option_if_changed "$section" detected "0" - set_option_if_changed "$section" health "disabled" - return - fi - - if [ -z "$vendor" ] || [ -z "$product" ]; then - set_option_if_changed "$section" detected "0" - set_option_if_changed "$section" health "unknown" - return - fi - - local dev_path - dev_path="$(find_usb_device "$vendor" "$product")" || dev_path="" - - local prev_detected - prev_detected="$(uci -q get ${UCI_NAMESPACE}.adapter.${section}.detected 2>/dev/null)" - - if [ -n "$dev_path" ]; then - local bus devnum port ts - bus="$(cat "$dev_path/busnum" 2>/dev/null)" - devnum="$(cat "$dev_path/devnum" 2>/dev/null)" - port="$(find_usb_tty "$dev_path")" - ts="$(date -Iseconds)" - - set_option_if_changed "$section" detected "1" - set_option_if_changed "$section" health "online" - [ -n "$bus" ] && set_option_if_changed "$section" bus "$bus" - [ -n "$devnum" ] && set_option_if_changed "$section" device "$devnum" - if [ -n "$port" ]; then - set_option_if_changed "$section" port "$port" - else - clear_option_if_needed "$section" port - fi - set_option_if_changed "$section" last_seen "$ts" - - if [ "$prev_detected" != "1" ]; then - log_msg "Adapter $section ($title) detected on bus $bus dev $devnum $port" - fi - else - set_option_if_changed "$section" detected "0" - set_option_if_changed "$section" health "missing" - clear_option_if_needed "$section" port - clear_option_if_needed "$section" bus - clear_option_if_needed "$section" device - if [ "$prev_detected" = "1" ]; then - log_msg "Adapter $section ($title) disconnected" - fi - fi -} - -scan_loop() { - while true; do - COMMIT_NEEDED=0 - config_load "$UCI_NAMESPACE" - local interval - config_get interval monitor interval - [ -n "$interval" ] && SCAN_INTERVAL="$interval" - config_foreach update_adapter_section adapter - if [ "$COMMIT_NEEDED" -eq 1 ]; then - uci commit "$UCI_NAMESPACE" - fi - sleep "$SCAN_INTERVAL" - done -} - -scan_loop +# Legacy wrapper – execute the unified MQTT bridge daemon. +exec /usr/sbin/mqtt-bridge "$@"