diff --git a/docs/widgets/services/homebox.md b/docs/widgets/services/homebox.md
new file mode 100644
index 00000000..af9ebad5
--- /dev/null
+++ b/docs/widgets/services/homebox.md
@@ -0,0 +1,23 @@
+---
+title: Homebox
+description: Homebox Widget Configuration
+---
+
+Learn more about [Homebox](https://github.com/hay-kot/homebox).
+
+Uses the same username and password used to login from the web.
+
+The `totalValue` field will attempt to format using the currency you have configured in Homebox.
+
+Allowed fields: `["items", "totalWithWarranty", "locations", "labels", "users", "totalValue"]`.
+
+If more than 4 fields are provided, only the first 4 are displayed.
+
+```yaml
+widget:
+ type: homebox
+ url: http://homebox.host.or.ip:port
+ username: username
+ password: password
+ fields: ["items", "locations", "totalValue"] # optional - default fields shown
+```
diff --git a/mkdocs.yml b/mkdocs.yml
index b7f8ec6a..a0994fad 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -66,6 +66,7 @@ nav:
- widgets/services/hdhomerun.md
- widgets/services/healthchecks.md
- widgets/services/homeassistant.md
+ - widgets/services/homebox.md
- widgets/services/homebridge.md
- widgets/services/iframe.md
- widgets/services/immich.md
diff --git a/public/locales/en/common.json b/public/locales/en/common.json
index 00279dec..9f4c4b13 100644
--- a/public/locales/en/common.json
+++ b/public/locales/en/common.json
@@ -863,5 +863,13 @@
"users": "Users",
"recipes": "Recipes",
"keywords": "Keywords"
+ },
+ "homebox": {
+ "items": "Items",
+ "totalWithWarranty": "With Warranty",
+ "locations": "Locations",
+ "labels": "Labels",
+ "users": "Users",
+ "totalValue": "Total Value"
}
}
diff --git a/src/widgets/components.js b/src/widgets/components.js
index 06502982..f3d567bb 100644
--- a/src/widgets/components.js
+++ b/src/widgets/components.js
@@ -40,6 +40,7 @@ const components = {
hdhomerun: dynamic(() => import("./hdhomerun/component")),
peanut: dynamic(() => import("./peanut/component")),
homeassistant: dynamic(() => import("./homeassistant/component")),
+ homebox: dynamic(() => import("./homebox/component")),
homebridge: dynamic(() => import("./homebridge/component")),
healthchecks: dynamic(() => import("./healthchecks/component")),
immich: dynamic(() => import("./immich/component")),
diff --git a/src/widgets/homebox/component.jsx b/src/widgets/homebox/component.jsx
new file mode 100644
index 00000000..18ea520e
--- /dev/null
+++ b/src/widgets/homebox/component.jsx
@@ -0,0 +1,58 @@
+import { useTranslation } from "next-i18next";
+
+import Container from "components/services/widget/container";
+import Block from "components/services/widget/block";
+import useWidgetAPI from "utils/proxy/use-widget-api";
+
+export const homeboxDefaultFields = ["items", "locations", "totalValue"];
+
+export default function Component({ service }) {
+ const { t } = useTranslation();
+ const { widget } = service;
+ const { data: homeboxData, error: homeboxError } = useWidgetAPI(widget);
+
+ if (homeboxError) {
+ return ;
+ }
+
+ // Default fields
+ if (!widget.fields?.length > 0) {
+ widget.fields = homeboxDefaultFields;
+ }
+ const MAX_ALLOWED_FIELDS = 4;
+ // Limits max number of displayed fields
+ if (widget.fields?.length > MAX_ALLOWED_FIELDS) {
+ widget.fields = widget.fields.slice(0, MAX_ALLOWED_FIELDS);
+ }
+
+ if (!homeboxData) {
+ return (
+
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/widgets/homebox/proxy.js b/src/widgets/homebox/proxy.js
new file mode 100644
index 00000000..0d6fdf13
--- /dev/null
+++ b/src/widgets/homebox/proxy.js
@@ -0,0 +1,103 @@
+import cache from "memory-cache";
+
+import { formatApiCall } from "utils/proxy/api-helpers";
+import { httpProxy } from "utils/proxy/http";
+import getServiceWidget from "utils/config/service-helpers";
+import createLogger from "utils/logger";
+
+const proxyName = "homeboxProxyHandler";
+const sessionTokenCacheKey = `${proxyName}__sessionToken`;
+const logger = createLogger(proxyName);
+
+async function login(widget, service) {
+ logger.debug("Homebox is rejecting the request, logging in.");
+
+ const loginUrl = new URL(`${widget.url}/api/v1/users/login`).toString();
+ const loginBody = `username=${encodeURIComponent(widget.username)}&password=${encodeURIComponent(widget.password)}`;
+ const loginParams = {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: loginBody,
+ };
+
+ const [, , data] = await httpProxy(loginUrl, loginParams);
+
+ try {
+ const { token, expiresAt } = JSON.parse(data.toString());
+ const expiresAtDate = new Date(expiresAt).getTime();
+ cache.put(`${sessionTokenCacheKey}.${service}`, token, expiresAtDate - Date.now());
+ return { token };
+ } catch (e) {
+ logger.error("Unable to login to Homebox API: %s", e);
+ }
+
+ return { token: false };
+}
+
+async function apiCall(widget, endpoint, service) {
+ const key = `${sessionTokenCacheKey}.${service}`;
+ const url = new URL(formatApiCall("{url}/api/v1/{endpoint}", { endpoint, ...widget }));
+ const headers = {
+ "Content-Type": "application/json",
+ Authorization: `${cache.get(key)}`,
+ };
+ const params = { method: "GET", headers };
+
+ let [status, contentType, data, responseHeaders] = await httpProxy(url, params);
+
+ if (status === 401 || status === 403) {
+ logger.debug("Homebox API rejected the request, attempting to obtain new access token");
+ const { token } = await login(widget, service);
+ headers.Authorization = `${token}`;
+
+ // retry request with new token
+ [status, contentType, data, responseHeaders] = await httpProxy(url, params);
+
+ if (status !== 200) {
+ logger.error("HTTP %d logging in to Homebox, data: %s", status, data);
+ return { status, contentType, data: null, responseHeaders };
+ }
+ }
+
+ if (status !== 200) {
+ logger.error("HTTP %d getting data from Homebox, data: %s", status, data);
+ return { status, contentType, data: null, responseHeaders };
+ }
+
+ return { status, contentType, data: JSON.parse(data.toString()), responseHeaders };
+}
+
+export default async function homeboxProxyHandler(req, res) {
+ const { group, service } = 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);
+ 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" });
+ }
+
+ if (!cache.get(`${sessionTokenCacheKey}.${service}`)) {
+ await login(widget, service);
+ }
+
+ // Get stats for the main blocks
+ const { data: groupStats } = await apiCall(widget, "groups/statistics", service);
+
+ // Get group info for currency
+ const { data: groupData } = await apiCall(widget, "groups", service);
+
+ return res.status(200).send({
+ items: groupStats?.totalItems,
+ locations: groupStats?.totalLocations,
+ labels: groupStats?.totalLabels,
+ totalWithWarranty: groupStats?.totalWithWarranty,
+ totalValue: groupStats?.totalItemPrice,
+ users: groupStats?.totalUsers,
+ currencyCode: groupData?.currency,
+ });
+}
diff --git a/src/widgets/homebox/widget.js b/src/widgets/homebox/widget.js
new file mode 100644
index 00000000..37b06a4f
--- /dev/null
+++ b/src/widgets/homebox/widget.js
@@ -0,0 +1,7 @@
+import homeboxProxyHandler from "./proxy";
+
+const widget = {
+ proxyHandler: homeboxProxyHandler,
+};
+
+export default widget;
diff --git a/src/widgets/widgets.js b/src/widgets/widgets.js
index 477f4ca9..a9cae230 100644
--- a/src/widgets/widgets.js
+++ b/src/widgets/widgets.js
@@ -33,6 +33,7 @@ import gotify from "./gotify/widget";
import grafana from "./grafana/widget";
import hdhomerun from "./hdhomerun/widget";
import homeassistant from "./homeassistant/widget";
+import homebox from "./homebox/widget";
import homebridge from "./homebridge/widget";
import healthchecks from "./healthchecks/widget";
import immich from "./immich/widget";
@@ -145,6 +146,7 @@ const widgets = {
grafana,
hdhomerun,
homeassistant,
+ homebox,
homebridge,
healthchecks,
ical: calendar,