diff --git a/docs/widgets/services/fritzbox.md b/docs/widgets/services/fritzbox.md
new file mode 100644
index 00000000..de63d77f
--- /dev/null
+++ b/docs/widgets/services/fritzbox.md
@@ -0,0 +1,22 @@
+---
+title: FRITZ!Box
+description: FRITZ!Box Widget Configuration
+---
+
+Application access & UPnP must be activated on your device:
+
+```
+Home Network > Network > Network Settings > Access Settings in the Home Network
+[x] Allow access for applications
+[x] Transmit status information over UPnP
+```
+
+You don't need to provide any credentials.
+
+Allowed fields (limited to a max of 4): `["connectionStatus", "upTime", "maxDown", "maxUp", "down", "up", "received", "sent", "externalIPAddress"]`.
+
+```yaml
+widget:
+ type: fritzbox
+ url: https://192.168.178.1
+```
diff --git a/mkdocs.yml b/mkdocs.yml
index 97951650..706a4a5b 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -53,6 +53,7 @@ nav:
- widgets/services/fileflows.md
- widgets/services/flood.md
- widgets/services/freshrss.md
+ - widgets/services/fritzbox.md
- widgets/services/gamedig.md
- widgets/services/ghostfolio.md
- widgets/services/glances.md
diff --git a/public/locales/de/common.json b/public/locales/de/common.json
index 42c08537..fcd45f39 100644
--- a/public/locales/de/common.json
+++ b/public/locales/de/common.json
@@ -122,6 +122,24 @@
"subscriptions": "Abonnements",
"unread": "Ungelesen"
},
+ "fritzbox": {
+ "connectionStatus": "Status",
+ "connectionStatusUnconfigured": "Unkonfiguriert",
+ "connectionStatusConnecting": "Verbinde",
+ "connectionStatusAuthenticating": "Authenifiziere",
+ "connectionStatusPendingDisconnect": "Anstehende Trennung",
+ "connectionStatusDisconnecting": "Trenne",
+ "connectionStatusDisconnected": "Getrennt",
+ "connectionStatusConnected": "Verbunden",
+ "uptime": "Betriebszeit",
+ "maxDown": "Max. Down",
+ "maxUp": "Max. Up",
+ "down": "Down",
+ "up": "Up",
+ "received": "Empfangen",
+ "sent": "Gesendet",
+ "externalIPAddress": "Ext. IP"
+ },
"caddy": {
"upstreams": "Upstreams",
"requests": "Aktuelle Anfragen",
diff --git a/public/locales/en/common.json b/public/locales/en/common.json
index 23b7ec1d..ad94e3aa 100644
--- a/public/locales/en/common.json
+++ b/public/locales/en/common.json
@@ -122,6 +122,24 @@
"subscriptions": "Subscriptions",
"unread": "Unread"
},
+ "fritzbox": {
+ "connectionStatus": "Status",
+ "connectionStatusUnconfigured": "Unconfigured",
+ "connectionStatusConnecting": "Connecting",
+ "connectionStatusAuthenticating": "Authenticating",
+ "connectionStatusPendingDisconnect": "PendingDisconnect",
+ "connectionStatusDisconnecting": "Disconnecting",
+ "connectionStatusDisconnected": "Disconnected",
+ "connectionStatusConnected": "Connected",
+ "uptime": "Uptime",
+ "maxDown": "Max. Down",
+ "maxUp": "Max. Up",
+ "down": "Down",
+ "up": "Up",
+ "received": "Received",
+ "sent": "Sent",
+ "externalIPAddress": "Ext. IP"
+ },
"caddy": {
"upstreams": "Upstreams",
"requests": "Current requests",
diff --git a/src/widgets/components.js b/src/widgets/components.js
index 99da81ea..df9a7530 100644
--- a/src/widgets/components.js
+++ b/src/widgets/components.js
@@ -27,6 +27,7 @@ const components = {
fileflows: dynamic(() => import("./fileflows/component")),
flood: dynamic(() => import("./flood/component")),
freshrss: dynamic(() => import("./freshrss/component")),
+ fritzbox: dynamic(() => import("./fritzbox/component")),
gamedig: dynamic(() => import("./gamedig/component")),
ghostfolio: dynamic(() => import("./ghostfolio/component")),
glances: dynamic(() => import("./glances/component")),
diff --git a/src/widgets/fritzbox/component.jsx b/src/widgets/fritzbox/component.jsx
new file mode 100644
index 00000000..6e8bf11f
--- /dev/null
+++ b/src/widgets/fritzbox/component.jsx
@@ -0,0 +1,67 @@
+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";
+
+const formatUptime = (timestamp) => {
+ const hours = Math.floor(timestamp / 3600);
+ const minutes = Math.floor((timestamp % 3600) / 60);
+ const seconds = timestamp % 60;
+
+ const hourDuration = hours > 0 ? `${hours}h` : "00h";
+ const minDuration = minutes > 0 ? `${minutes}m` : "00m";
+ const secDuration = seconds > 0 ? `${seconds}s` : "00s";
+
+ return hourDuration + minDuration + secDuration;
+};
+
+export default function Component({ service }) {
+ const { t } = useTranslation();
+ const { widget } = service;
+ const { data: fritzboxData, error: fritzboxError } = useWidgetAPI(widget, "status");
+
+ if (fritzboxError) {
+ return ;
+ }
+
+ // Default fields
+ if (!widget.fields?.length > 0) {
+ widget.fields = ["connectionStatus", "uptime", "maxDown", "maxUp"];
+ }
+ 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 (!fritzboxData) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/widgets/fritzbox/proxy.js b/src/widgets/fritzbox/proxy.js
new file mode 100644
index 00000000..e4f0fefd
--- /dev/null
+++ b/src/widgets/fritzbox/proxy.js
@@ -0,0 +1,84 @@
+import { xml2json } from "xml-js";
+
+import { httpProxy } from "utils/proxy/http";
+import getServiceWidget from "utils/config/service-helpers";
+import createLogger from "utils/logger";
+
+const logger = createLogger("fritzboxProxyHandler");
+
+async function requestEndpoint(apiBaseUrl, service, action) {
+ const servicePath = service === "WANIPConnection" ? "WANIPConn1" : "WANCommonIFC1";
+ const params = {
+ method: "POST",
+ headers: {
+ "Content-Type": "text/xml; charset='utf-8'",
+ SoapAction: `urn:schemas-upnp-org:service:${service}:1#${action}`,
+ },
+ body:
+ "" +
+ "" +
+ "" +
+ `` +
+ "" +
+ "",
+ };
+ const apiUrl = `${apiBaseUrl}/igdupnp/control/${servicePath}`;
+ const [status, , data] = await httpProxy(apiUrl, params);
+ if (status !== 200) {
+ logger.debug(`HTTP ${status} performing SoapRequest for ${service}->${action}`, data);
+ throw new Error(`Failed fetching '${action}'`);
+ }
+ const response = {};
+ try {
+ const jsonData = JSON.parse(xml2json(data));
+ const responseElements = jsonData?.elements[0]?.elements[0]?.elements[0]?.elements || [];
+ responseElements.forEach((element) => {
+ response[element.name] = element.elements[0]?.text || "";
+ });
+ } catch (e) {
+ logger.debug(`Failed parsing ${service}->${action} response:`, data);
+ throw new Error(`Failed parsing '${action}' response`);
+ }
+
+ return response;
+}
+
+export default async function fritzboxProxyHandler(req, res) {
+ const { group, service } = req.query;
+ const serviceWidget = await getServiceWidget(group, service);
+ if (!serviceWidget) {
+ res.status(500).json({ error: "Service widget not found" });
+ return;
+ }
+ if (!serviceWidget.url) {
+ res.status(500).json({ error: "Service widget url not configured" });
+ return;
+ }
+
+ const serviceWidgetUrl = new URL(serviceWidget.url);
+ const port = serviceWidgetUrl.protocol === "https:" ? 49443 : 49000;
+ const apiBaseUrl = `${serviceWidgetUrl.protocol}//${serviceWidgetUrl.hostname}:${port}`;
+
+ await Promise.all([
+ requestEndpoint(apiBaseUrl, "WANIPConnection", "GetStatusInfo"),
+ requestEndpoint(apiBaseUrl, "WANIPConnection", "GetExternalIPAddress"),
+ requestEndpoint(apiBaseUrl, "WANCommonInterfaceConfig", "GetCommonLinkProperties"),
+ requestEndpoint(apiBaseUrl, "WANCommonInterfaceConfig", "GetAddonInfos"),
+ ])
+ .then(([statusInfo, externalIPAddress, linkProperties, addonInfos]) => {
+ res.status(200).json({
+ connectionStatus: statusInfo.NewConnectionStatus,
+ uptime: statusInfo.NewUptime,
+ maxDown: linkProperties.NewLayer1DownstreamMaxBitRate,
+ maxUp: linkProperties.NewLayer1UpstreamMaxBitRate,
+ down: addonInfos.NewByteReceiveRate,
+ up: addonInfos.NewByteSendRate,
+ received: addonInfos.NewX_AVM_DE_TotalBytesReceived64,
+ sent: addonInfos.NewX_AVM_DE_TotalBytesSent64,
+ externalIPAddress: externalIPAddress.NewExternalIPAddress,
+ });
+ })
+ .catch((error) => {
+ res.status(500).json({ error: error.message });
+ });
+}
diff --git a/src/widgets/fritzbox/widget.js b/src/widgets/fritzbox/widget.js
new file mode 100644
index 00000000..13193821
--- /dev/null
+++ b/src/widgets/fritzbox/widget.js
@@ -0,0 +1,7 @@
+import fritzboxProxyHandler from "./proxy";
+
+const widget = {
+ proxyHandler: fritzboxProxyHandler,
+};
+
+export default widget;
diff --git a/src/widgets/widgets.js b/src/widgets/widgets.js
index 0a2d24ed..a41d9306 100644
--- a/src/widgets/widgets.js
+++ b/src/widgets/widgets.js
@@ -21,6 +21,7 @@ import evcc from "./evcc/widget";
import fileflows from "./fileflows/widget";
import flood from "./flood/widget";
import freshrss from "./freshrss/widget";
+import fritzbox from "./fritzbox/widget";
import gamedig from "./gamedig/widget";
import ghostfolio from "./ghostfolio/widget";
import glances from "./glances/widget";
@@ -122,6 +123,7 @@ const widgets = {
fileflows,
flood,
freshrss,
+ fritzbox,
gamedig,
ghostfolio,
glances,