Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: Add APC UPS widget #4840

Merged
merged 6 commits into from
Mar 2, 2025
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
host: IP address for apcupsd host
port: 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"
}
}
32 changes: 32 additions & 0 deletions src/widgets/apcups/component.jsx
Original file line number Diff line number Diff line change
@@ -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 <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>
);
}
122 changes: 122 additions & 0 deletions src/widgets/apcups/proxy.js
Original file line number Diff line number Diff line change
@@ -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);
}
8 changes: 8 additions & 0 deletions src/widgets/apcups/widget.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import apcupsProxyHandler from "./proxy";

const widget = {
proxyHandler: apcupsProxyHandler,
allowedEndpoints: /status/,
};

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