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,