Skip to content

Commit

Permalink
Feature: Add APC UPS widget (#4840)
Browse files Browse the repository at this point in the history
Co-authored-by: shamoon <[email protected]>
  • Loading branch information
nicupavel and shamoon authored Mar 2, 2025
1 parent 9b8dd94 commit fdf405f
Show file tree
Hide file tree
Showing 9 changed files with 179 additions and 0 deletions.
16 changes: 16 additions & 0 deletions docs/widgets/services/apcups.md
Original file line number Diff line number Diff line change
@@ -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
```
1 change: 1 addition & 0 deletions docs/widgets/services/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions public/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -1016,5 +1016,11 @@
"issues": "Issues",
"merges": "Merge Requests",
"projects": "Projects"
},
"apcups": {
"status": "Status",
"load": "Load",
"bcharge":"Battery Charge",
"timeleft":"Time Left"
}
}
33 changes: 33 additions & 0 deletions src/widgets/apcups/component.jsx
Original file line number Diff line number Diff line change
@@ -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 <Container service={service} error={error} />;
}

if (!data) {
return (
<Container service={service}>
<Block label="apcups.status" />
<Block label="apcups.load" />
<Block label="apcups.bcharge" />
<Block label="apcups.timeleft" />
</Container>
);
}

return (
<Container service={service}>
<Block label="apcups.status" value={data.status} />
<Block label="apcups.load" value={data.load} />
<Block label="apcups.bcharge" value={data.bcharge} />
<Block label="apcups.timeleft" value={data.timeleft} />
</Container>
);
}
112 changes: 112 additions & 0 deletions src/widgets/apcups/proxy.js
Original file line number Diff line number Diff line change
@@ -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);
}
7 changes: 7 additions & 0 deletions src/widgets/apcups/widget.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import apcupsProxyHandler from "./proxy";

const widget = {
proxyHandler: apcupsProxyHandler,
};

export default widget;
1 change: 1 addition & 0 deletions src/widgets/components.js
Original file line number Diff line number Diff line change
Expand Up @@ -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")),
Expand Down
2 changes: 2 additions & 0 deletions src/widgets/widgets.js
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -134,6 +135,7 @@ import zabbix from "./zabbix/widget";

const widgets = {
adguard,
apcups,
argocd,
atsumeru,
audiobookshelf,
Expand Down

0 comments on commit fdf405f

Please sign in to comment.