diff --git a/docs/widgets/services/homebox.md b/docs/widgets/services/homebox.md new file mode 100644 index 000000000..af9ebad5c --- /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 b7f8ec6ae..a0994fadd 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 00279dec6..9f4c4b133 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 06502982c..f3d567bb7 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 000000000..18ea520e0 --- /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 000000000..0d6fdf13c --- /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 000000000..37b06a4f3 --- /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 477f4ca9c..a9cae230f 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,