Browse Source

Feature: Fritz!Box Widget

Thorben Grove 1 year ago
parent
commit
05f78c9448

+ 22 - 0
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
+  ```

+ 1 - 0
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

+ 18 - 0
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",

+ 18 - 0
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",

+ 1 - 0
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")),

+ 76 - 0
src/widgets/fritzbox/component.jsx

@@ -0,0 +1,76 @@
+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;
+};
+
+function formatBytes(bytes, decimals) {
+  if (bytes === 0) return "0 Bytes";
+  const k = 1024;
+  const dm = decimals || 2;
+  const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
+  const i = Math.floor(Math.log(bytes) / Math.log(k));
+  return `${parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`;
+}
+
+export default function Component({ service }) {
+  const { t } = useTranslation();
+  const { widget } = service;
+  const { data: fritzboxData, error: fritzboxError } = useWidgetAPI(widget, "status");
+
+  if (fritzboxError) {
+    return <Container service={service} error={fritzboxError} />;
+  }
+
+  // Default fields
+  if (widget.fields == null || widget.fields.length === 0) {
+    widget.fields = ["connectionStatus", "uptime", "maxDown", "maxUp"];
+  }
+  const MAX_ALLOWED_FIELDS = 4;
+  // Limits max number of displayed fields
+  if (widget.fields != null && widget.fields.length > MAX_ALLOWED_FIELDS) {
+    widget.fields = widget.fields.slice(0, MAX_ALLOWED_FIELDS);
+  }
+
+  if (!fritzboxData) {
+    return (
+      <Container service={service}>
+        <Block label="fritzbox.connectionStatus" />
+        <Block label="fritzbox.uptime" />
+        <Block label="fritzbox.maxDown" />
+        <Block label="fritzbox.maxUp" />
+        <Block label="fritzbox.down" />
+        <Block label="fritzbox.up" />
+        <Block label="fritzbox.received" />
+        <Block label="fritzbox.sent" />
+        <Block label="fritzbox.externalIPAddress" />
+      </Container>
+    );
+  }
+
+  return (
+    <Container service={service}>
+      <Block label="fritzbox.connectionStatus" value={t(`fritzbox.connectionStatus${fritzboxData.connectionStatus}`)} />
+      <Block label="fritzbox.uptime" value={formatUptime(fritzboxData.uptime)} />
+      <Block label="fritzbox.maxDown" value={`${formatBytes(fritzboxData.maxDown / 8)}/s`} />
+      <Block label="fritzbox.maxUp" value={`${formatBytes(fritzboxData.maxUp / 8)}/s`} />
+      <Block label="fritzbox.down" value={`${formatBytes(fritzboxData.down)}/s`} />
+      <Block label="fritzbox.up" value={`${formatBytes(fritzboxData.up)}/s`} />
+      <Block label="fritzbox.received" value={formatBytes(fritzboxData.received)} />
+      <Block label="fritzbox.sent" value={formatBytes(fritzboxData.sent)} />
+      <Block label="fritzbox.externalIPAddress" value={fritzboxData.externalIPAddress} />
+    </Container>
+  );
+}

+ 84 - 0
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:
+      "<?xml version='1.0' encoding='utf-8'?>" +
+      "<s:Envelope s:encodingStyle='http://schemas.xmlsoap.org/soap/encoding/' xmlns:s='http://schemas.xmlsoap.org/soap/envelope/'>" +
+      "<s:Body>" +
+      `<u:${action} xmlns:u='urn:schemas-upnp-org:service:${service}:1' />` +
+      "</s:Body>" +
+      "</s:Envelope>",
+  };
+  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 });
+    });
+}

+ 7 - 0
src/widgets/fritzbox/widget.js

@@ -0,0 +1,7 @@
+import fritzboxProxyHandler from "./proxy";
+
+const widget = {
+  proxyHandler: fritzboxProxyHandler,
+};
+
+export default widget;

+ 2 - 0
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,