From e43166697c147af0def1834f5761f96deca36a03 Mon Sep 17 00:00:00 2001 From: Nicu Pavel Date: Sun, 15 Dec 2024 20:32:25 +0000 Subject: [PATCH] Feature: Add APC UPS widget --- docs/widgets/services/apcups.md | 16 ++++ docs/widgets/services/index.md | 1 + mkdocs.yml | 1 + public/locales/en/common.json | 6 ++ src/widgets/apcups/component.jsx | 32 ++++++++ src/widgets/apcups/proxy.js | 122 +++++++++++++++++++++++++++++++ src/widgets/apcups/widget.js | 8 ++ src/widgets/components.js | 1 + src/widgets/widgets.js | 2 + 9 files changed, 189 insertions(+) create mode 100644 docs/widgets/services/apcups.md create mode 100644 src/widgets/apcups/component.jsx create mode 100644 src/widgets/apcups/proxy.js create mode 100644 src/widgets/apcups/widget.js diff --git a/docs/widgets/services/apcups.md b/docs/widgets/services/apcups.md new file mode 100644 index 00000000..7902db3c --- /dev/null +++ b/docs/widgets/services/apcups.md @@ -0,0 +1,16 @@ +--- +title: APC UPS Monitoring +description: Lightweight monitoring widget for APC UPSs using apcupsd daemon +--- + +This widget extracts UPS information from an apcupsd daemon. +Only works for [APC/Schneider](https://www.se.com/us/en/product-range/61915-smartups/#products) UPS products. + +*Note*: By default apcupsd daemon is bound to 127.0.0.1. Edit ```/etc/apcupsd.conf``` and change ```NISIP``` to an IP accessible from your homepage docker (usually your internal LAN interface). + +```yaml +widget: + type: apcups + host: IP address for apcupsd host + port: 3551 +``` diff --git a/docs/widgets/services/index.md b/docs/widgets/services/index.md index 894a31f6..66c0e663 100644 --- a/docs/widgets/services/index.md +++ b/docs/widgets/services/index.md @@ -8,6 +8,7 @@ search: You can also find a list of all available service widgets in the sidebar navigation. - [Adguard Home](adguard-home.md) +- [APC UPS](apcups.md) - [ArgoCD](argocd.md) - [Atsumeru](atsumeru.md) - [Audiobookshelf](audiobookshelf.md) diff --git a/mkdocs.yml b/mkdocs.yml index fa2188ad..6ff5bf6d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -31,6 +31,7 @@ nav: - "Service Widgets": - widgets/services/index.md - widgets/services/adguard-home.md + - widgets/services/apcups.md - widgets/services/argocd.md - widgets/services/atsumeru.md - widgets/services/audiobookshelf.md diff --git a/public/locales/en/common.json b/public/locales/en/common.json index e3670e80..51ba6220 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -1007,5 +1007,11 @@ "issues": "Issues", "merges": "Merge Requests", "projects": "Projects" + }, + "apcups": { + "status": "Status", + "load": "Load", + "bcharge":"Battery Charge", + "timeleft":"Time Left" } } diff --git a/src/widgets/apcups/component.jsx b/src/widgets/apcups/component.jsx new file mode 100644 index 00000000..579a7e1e --- /dev/null +++ b/src/widgets/apcups/component.jsx @@ -0,0 +1,32 @@ +import Container from "components/services/widget/container"; +import Block from "components/services/widget/block"; +import useWidgetAPI from "utils/proxy/use-widget-api"; + +export default function Component({ service }) { + const { widget } = service; + const { data, error } = useWidgetAPI(widget, "status"); + + if (error) { + return ; + } + + if (!data) { + return ( + + + + + + + ); + } + + return ( + + + + + + + ); +} diff --git a/src/widgets/apcups/proxy.js b/src/widgets/apcups/proxy.js new file mode 100644 index 00000000..53573b99 --- /dev/null +++ b/src/widgets/apcups/proxy.js @@ -0,0 +1,122 @@ +import net from 'node:net'; +import { Buffer } from 'node:buffer'; + +import getServiceWidget from "utils/config/service-helpers"; +import createLogger from "utils/logger"; + +const logger = createLogger("apcupsProxyHandler"); + +const DEBUG = false; + +const APC_COMMANDS = { + status: 'status', + events: 'events', +}; + +const dumpBuffer = (buffer) => { + logger.debug(buffer.toString('hex').match(/../g).join(' ')) +} + +const parseResponse = (buffer) => { + let ptr = 0; + const output = []; + while (ptr < buffer.length) { + const lineLen = buffer.readUInt16BE(ptr); + const asciiData = buffer.toString('ascii', ptr + 2, lineLen + ptr + 2); + if (DEBUG) logger.debug(ptr, lineLen, asciiData); + output.push(asciiData); + ptr += 2 + lineLen; + } + + return output; +} + +const statusAsJSON = (statusOutput) => statusOutput?.reduce((output, line) => { + if (!line || line.startsWith('END APC')) return output; + const [key, value] = line.trim().split(':'); + const newOutput = { ...output }; + newOutput[key.trim()] = value?.trim(); + return newOutput; +}, {}) + +const getStatus = async (_host, _port) => new Promise((resolve, reject) => { + const host = _host ?? '127.0.0.1'; + const port = _port ?? 3551; + + const socket = new net.Socket(); + socket.setTimeout(5000); + socket.connect({ host, port }); + + const fullResponse = []; + + socket.on('connect', () => { + logger.debug(`Connecting to ${host}:${port}`); + const buffer = Buffer.alloc(APC_COMMANDS.status.length + 2); + buffer.writeUInt16BE(APC_COMMANDS.status.length, 0); + buffer.write(APC_COMMANDS.status, 2); + socket.write(buffer); + }); + + socket.on('data', (data) => { + fullResponse.push(data); + + if (data.readUInt16BE(data.length - 2) === 0) { + try { + const buffer = Buffer.concat(fullResponse); + if (DEBUG) dumpBuffer(buffer); + const output = parseResponse(buffer); + resolve(output); + } catch (e) { + reject(e) + } + socket.end(); + } + }); + + socket.on('error', (err) => { + socket.destroy(); + reject(err); + }); + socket.on('timeout', () => { + socket.destroy(); + reject(new Error('socket timeout')); + }); + socket.on('end', () => { + logger.debug('socket end'); + }); + socket.on('close', () => { + logger.debug('socket closed'); + }); +}) + +export default async function apcupsProxyHandler(req, res) { + const { group, service, index } = req.query; + + if (!group || !service) { + logger.debug("Invalid or missing service '%s' or group '%s'", service, group); + return res.status(400).json({ error: "Invalid proxy service type" }); + } + + const widget = await getServiceWidget(group, service, index); + if (!widget) { + logger.debug("Invalid or missing widget for service '%s' in group '%s'", service, group); + return res.status(400).json({ error: "Invalid proxy service type" }); + } + + const data = {}; + + try { + const statusData = await getStatus(widget.host, widget.port); + const jsonData = statusAsJSON(statusData); + + data.status = jsonData.STATUS; + data.load = jsonData.LOADPCT; + data.bcharge = jsonData.BCHARGE; + data.timeleft = jsonData.TIMELEFT; + } catch (e) { + logger.error(e); + return res.status(500).json({ error: e.message }); + } + + return res.status(200).send(data); +} \ No newline at end of file diff --git a/src/widgets/apcups/widget.js b/src/widgets/apcups/widget.js new file mode 100644 index 00000000..a0eb5614 --- /dev/null +++ b/src/widgets/apcups/widget.js @@ -0,0 +1,8 @@ +import apcupsProxyHandler from "./proxy"; + +const widget = { + proxyHandler: apcupsProxyHandler, + allowedEndpoints: /status/, +}; + +export default widget; diff --git a/src/widgets/components.js b/src/widgets/components.js index 19f41d4a..a4a0aa65 100644 --- a/src/widgets/components.js +++ b/src/widgets/components.js @@ -2,6 +2,7 @@ import dynamic from "next/dynamic"; const components = { adguard: dynamic(() => import("./adguard/component")), + apcups: dynamic(() => import("./apcups/component")), argocd: dynamic(() => import("./argocd/component")), atsumeru: dynamic(() => import("./atsumeru/component")), audiobookshelf: dynamic(() => import("./audiobookshelf/component")), diff --git a/src/widgets/widgets.js b/src/widgets/widgets.js index 9d4bb935..37ae0253 100644 --- a/src/widgets/widgets.js +++ b/src/widgets/widgets.js @@ -1,4 +1,5 @@ import adguard from "./adguard/widget"; +import apcups from "./apcups/widget"; import argocd from "./argocd/widget"; import atsumeru from "./atsumeru/widget"; import audiobookshelf from "./audiobookshelf/widget"; @@ -133,6 +134,7 @@ import zabbix from "./zabbix/widget"; const widgets = { adguard, + apcups, argocd, atsumeru, audiobookshelf,