diff --git a/docs/widgets/services/apcups.md b/docs/widgets/services/apcups.md
new file mode 100644
index 00000000000..9f0b9195edb
--- /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
+ url: tcp://your.acpupsd.host:3551
+```
diff --git a/docs/widgets/services/index.md b/docs/widgets/services/index.md
index 2e3965820ae..58b1409d198 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 958c3398c89..c5f3a03888e 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 9e390867008..65eb8256738 100644
--- a/public/locales/en/common.json
+++ b/public/locales/en/common.json
@@ -1016,5 +1016,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 00000000000..c1c26b5ccdd
--- /dev/null
+++ b/src/widgets/apcups/component.jsx
@@ -0,0 +1,33 @@
+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);
+
+ 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 00000000000..8e1d7ffcfbf
--- /dev/null
+++ b/src/widgets/apcups/proxy.js
@@ -0,0 +1,112 @@
+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");
+
+function 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);
+ output.push(asciiData);
+ ptr += 2 + lineLen;
+ }
+
+ return output;
+}
+
+function statusAsJSON(statusOutput) {
+ return 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;
+ }, {});
+}
+
+async function getStatus(host = "127.0.0.1", port = 3551) {
+ return new Promise((resolve, reject) => {
+ const socket = new net.Socket();
+ socket.setTimeout(5000);
+ socket.connect({ host, port });
+
+ const response = [];
+
+ socket.on("connect", () => {
+ const CMD = "status";
+ logger.debug(`Connecting to ${host}:${port}`);
+ const buffer = Buffer.alloc(CMD.length + 2);
+ buffer.writeUInt16BE(CMD.length, 0);
+ buffer.write(CMD, 2);
+ socket.write(buffer);
+ });
+
+ socket.on("data", (data) => {
+ response.push(data);
+
+ if (data.readUInt16BE(data.length - 2) === 0) {
+ try {
+ const buffer = Buffer.concat(response);
+ 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 url = new URL(widget.url);
+ const data = {};
+
+ try {
+ const statusData = await getStatus(url.hostname, url.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);
+}
diff --git a/src/widgets/apcups/widget.js b/src/widgets/apcups/widget.js
new file mode 100644
index 00000000000..68f6371b83f
--- /dev/null
+++ b/src/widgets/apcups/widget.js
@@ -0,0 +1,7 @@
+import apcupsProxyHandler from "./proxy";
+
+const widget = {
+ proxyHandler: apcupsProxyHandler,
+};
+
+export default widget;
diff --git a/src/widgets/components.js b/src/widgets/components.js
index 67d17d50bd7..c34b9a4d26d 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 b78f5b9c2b8..bb7748ec99d 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";
@@ -134,6 +135,7 @@ import zabbix from "./zabbix/widget";
const widgets = {
adguard,
+ apcups,
argocd,
atsumeru,
audiobookshelf,