Veröffentlicht: September 23, 2026

Der Netgear Nighthawk M6 (MR6450) ist ein mobiler 5G-Router, den ich als Backup-Internetleitung nutze. Per USB-C angesteckt meldet er sich unter Linux als RNDIS-Netzwerkgerät — praktisch für automatischen Failover, wenn der Haupt-Anschluss ausfällt. Was mich störte: Die Konfiguration läuft über ein Web-UI im Browser. Manuell klicken ist das Gegenteil von Automatisierung.

Also: API reverse-engineeren, CLI-Tool schreiben, fertig. Klingt simpel. War es nicht.

Hardware: Was ist der Netgear Nighthawk M6?

Der MR6450 ist ein kompakter 5G-Mobilrouter aus der Nighthawk-Serie. Auf den ersten Blick sieht er aus wie ein etwas zu dicker Taschenrechner — dahinter steckt aber eine vollständige Netzwerkinfrastruktur im Taschenformat.

EigenschaftWert
ModellnameNetgear Nighthawk M6 (MR6450)
5GSub-6GHz (NSA/SA), bis zu 2,5 Gbit/s Download (theoretisch)
LTECat-22, Carrier Aggregation bis zu 3 Bänder gleichzeitig
WiFiWiFi 6 (802.11ax), 2,4 & 5 GHz, bis zu 3,6 Gbit/s
Ethernet1× USB-C-zu-2,5GbE (im Lieferumfang), alternativ USB-Tethering
USBUSB-C (Laden + Tethering/RNDIS)
Display2,8″ Touchscreen (Status, Einstellungen)
Akku5.040 mAh (Dauerbetrieb ca. 13h)
Anschlüsse1× USB-C, 1× Nano-SIM
Gewichtca. 280 g

Im Dauerbetrieb am PC ist das Gerät per USB-C angesteckt: Es bezieht Strom vom Rechner und meldet sich als RNDIS-Netzwerkadapter. Das Betriebssystem sieht ein normales Ethernet-Interface — kein Treiber nötig, kein PPP, kein Modem-AT-Befehl. Für einen Linux-Failover-Daemon ist das ideal.

RNDIS

Remote Network Driver Interface Specification — ein Microsoft-Protokoll, das USB-Geräte als Netzwerkadapter erscheinen lässt. Viele Smartphones und mobile Router nutzen es für USB-Tethering. Unter Linux wird es vom Kernel-Modul rndis_host unterstützt, das Gerät erscheint als normales Ethernet-Interface ohne Zusatz-Treiber.

Einen Haken gibt es: Im Akkubetrieb schaltet der Router nach kurzer Inaktivität Ethernet und WiFi-Sendeleistung zurück. Für Dauerbetrieb muss das Power-Profil auf ACPowered gestellt werden — wie das geht, kommt weiter unten.

Was steckt dahinter — die API-Übersicht

Das Admin-Interface läuft unter http://192.168.1.1 — passwortgeschützt, HTTP (kein HTTPS). Für ein lokales Gerät im Heimnetz ist das vertretbar.

Dahinter liegt eine REST-ähnliche API mit zwei zentralen Endpunkten:

  • GET /api/model.json — liefert den gesamten Gerätezustand als ein großes JSON-Objekt: Signal, Verbindung, Datenverbrauch, Energieprofil, WLAN, LAN-Clients, alles auf einmal
  • POST /Forms/config — empfängt Einstellungen als Key-Value-Paare

Das model.json ist beeindruckend vollständig. Man bekommt RSRP, RSRQ, SINR, die aktiven LTE-CA-Bänder mit physikalischen Cell-IDs, NR5G-Signalwerte, Akkuladung, Temperatur, Billing-Zyklusdaten — alles in einem einzigen GET-Request.

LTE Carrier Aggregation (CA)

Moderne LTE-Verbindungen bündeln mehrere Frequenzbänder gleichzeitig, um höhere Datenraten zu erreichen. Ein Band dient als Primary Component Carrier (PCC), weitere als Secondary Component Carrier (SCC). Der MR6450 meldet alle aktiven Bänder einzeln mit Bandnummer, Kanalfrequenz und Bandbreite.

Authentifizierung — die erste und größte Tücke

Hier wird es interessant. Und frustrierend. Der erste Instinkt bei Web-Logins: HTML der Login-Seite holen, CSRF-Token aus dem Formular lesen, dann POST mit Passwort und Token. Standardvorgehen.

Das funktioniert hier nicht. Das HTML sieht so aus:

<input id="login_token" type="hidden" value="">

Leer. Immer. Der Browser zeigt einen befüllten Token — aber der kommt nicht aus dem HTML, sondern aus JavaScript. Die Login-Seite lädt beim Start GET /api/model.json?internalapi=1 und liest daraus session.secToken. Dieser Wert wird per JavaScript in das versteckte Formularfeld geschrieben, bevor das Login-Formular abgeschickt wird.

Wenn man das HTML parst, sieht man einen leeren Token. Sendet man diesen leeren Token im POST, schlägt der Login diskret fehl — kein HTTP-Fehlercode, nur eine Weiterleitung mit ?loginfailed in der URL.

CSRF-Token

Cross-Site Request Forgery-Schutz. Ein einmaliger, sitzungsgebundener Zufallswert, den der Server in Formulare einbettet. Beim POST muss dieser Wert mitgesendet werden — so kann ein fremder Tab im Browser keine heimlichen Anfragen an den Router stellen. Netgear liefert diesen Token nicht im HTML, sondern in der JSON-API.

Der korrekte Login-Ablauf:

  1. GET / — Session starten, sessionId-Cookie einsammeln
  2. GET /api/model.json?internalapi=1 — JSON holen, session.secToken lesen
  3. POST /Forms/config mit session.password=PASS&token=<secToken>&ok_redirect=/index.html
  4. GET /api/model.json?internalapi=1 nochmals — Token rotiert nach dem Login, neuen Token holen

Den Login-Erfolg erkennt man nicht am Response-Body, sondern an der finalen URL. Erfolg → /index.html, Misserfolg → /index.html?loginfailed. Das HTML von /index.html enthält nämlich den String „loginfailed“ als Link-Text — wer auf diesen String im Body prüft, bekommt auch bei erfolgreichem Login ein False Positive.

def connect(self):
    # Schritt 1: Cookie holen
    self._get("/")

    # Schritt 2: CSRF-Token aus der JSON-API lesen — NICHT aus dem HTML!
    data = json.loads(self._get("/api/model.json?internalapi=1"))
    self.token = data.get("session", {}).get("secToken")
    if not self.token:
        raise RuntimeError("CSRF-Token nicht gefunden — Router erreichbar?")

    # Schritt 3: Login
    body = urlencode({
        "session.password": self.password,
        "token": self.token,
        "ok_redirect": "/index.html",
        "err_redirect": "/index.html?loginfailed",
    }).encode()
    req = Request(f"{self.host}/Forms/config", data=body,
                  headers={"Content-Type": "application/x-www-form-urlencoded"})
    resp = self.opener.open(req, timeout=10)

    # Erfolg anhand der finalen URL prüfen — nicht am Body!
    if "loginfailed" in resp.url:
        raise RuntimeError("Login fehlgeschlagen — Passwort prüfen")

    # Schritt 4: Token rotiert nach Login — neuen holen
    data2 = json.loads(self._get("/api/model.json?internalapi=1"))
    new_token = data2.get("session", {}).get("secToken")
    if new_token:
        self.token = new_token

Content-Type — die zweite Tücke

Wäre die Token-Problematik allein genug, wartet noch eine weitere Falle. Wer moderne Web-APIs kennt, greift zu JSON oder Multipart-Formulardaten. Beides quittiert der Router mit HTTP 400 — ohne weiteren Hinweis.

Die Firmware erwartet ausschließlich application/x-www-form-urlencoded. Das ist das älteste Formular-Encoding-Format, das es gibt, bei modernen SPAs kaum noch anzutreffen. Die Netgear-Firmware ist sehr konservativ gebaut — was angesichts des Alters vieler Router-Plattformen nicht überrascht.

application/x-www-form-urlencoded

Das klassische HTML-Formular-Encoding: Schlüssel und Werte werden URL-kodiert und mit & verbunden, z.B. key1=wert1&key2=wert2. Sonderzeichen werden als %XX Hex-Sequenzen kodiert. Heute oft von JSON oder Multipart verdrängt, bei älteren Embedded-Systemen aber noch verbreitet.

Einstellungen — die echten Feldnamen finden

Wenn man endlich eingeloggt ist und Einstellungen ändern möchte, wartet die dritte Überraschung: Die Feldnamen in POST-Requests entsprechen weder den HTML-Label-Texten noch den offensichtlichen JSON-Pfaden aus model.json.

Die einzig zuverlässige Methode: Den Browser-Netzwerktraffic mitschneiden, während man Einstellungen über das Web-UI ändert. In den Firefox-DevTools (Tab „Netzwerkanalyse“, Filter „POST“) sieht man die tatsächlich gesendeten Formulardaten. Einige Entdeckungen:

EinstellungEchter FeldnameWert
Power-Profil Netzbetriebpower.profileCurrent2
WiFi Sendeleistungwifi.txPowerExtended
Ethernet aktivierenpower.ethernetDrvEnabletrue
Ethernet-Geschwindigkeitpower.ethernetSpeed2500M
Datenlimit aktivwwan.dataUsage.generic.billingCycleLimitEnabledtrue
Datenlimit-Wertwwan.dataUsage.generic.billingCycleLimitBytes (nicht MB/GB!)
Limit-Anzeigedatausage.dataLimitNoUnit40 (nur Zahl)
Abrechnungstagdatausage.billingDay23

Der Haken beim Datenlimit: Der Wert wird in Bytes angegeben, nicht in Gigabyte. 40 GB entsprechen 42.949.672.960 Bytes. Wer hier einfach „40“ einträgt, setzt ein Limit von 40 Byte.

# Data-Limit: 40 GB korrekt in Bytes umrechnen
LIMIT_GB = 40
limit_bytes = LIMIT_GB * 1024 * 1024 * 1024  # = 42949672960

router.post_config({
    "wwan.dataUsage.generic.billingCycleLimitEnabled": "true",
    "wwan.dataUsage.generic.billingCycleLimit": str(limit_bytes),
    "datausage.dataLimitNoUnit": str(LIMIT_GB),
})

Konfigurationsbackup

Das ist erfreulich unkompliziert: GET /dui/export.cfg?save=export.cfg mit dem Session-Cookie liefert eine binäre Backup-Datei der gesamten Router-Konfiguration. Kein Token nötig, einfach holen und speichern.

./netgear-mr6x00-cli.py backup /tmp/router_backup.cfg
# Backup gespeichert: /tmp/router_backup.cfg (8.7 KB)

Der NR5G-Sentinel-Wert

Im Signalobjekt gibt es Felder wie nr5gRsrp für den 5G-New-Radio-Anteil der Verbindung. Bei reinem LTE ohne aktiven 5G-Kanal liefert der Router den Wert -32768.

Das ist kein Messwert — das ist ein Sentinel, der „nicht verfügbar“ bedeutet. Der Wert entspricht dem Minimum eines vorzeichenbehafteten 16-Bit-Integers, ein klassisches Embedded-Systems-Pattern. Ohne dieses Wissen würde man „NR5G RSRP: -32768 dBm“ ausgeben.

Sentinel-Wert

Ein reservierter Sonderwert in einem Datenfeld, der „kein gültiger Wert“ bedeutet. Da -32768 als echtes Funksignal physikalisch unmöglich ist (RSRP liegt typischerweise zwischen -44 und -140 dBm), ist der Wert eindeutig als „nicht verfügbar“ interpretierbar. Häufig in Firmware auf eingebetteten Systemen, die Integer-Typen ohne separate None-Logik verwenden.

Das Python-CLI-Script

Das fertige Script nutzt ausschließlich Python-Stdlib — kein pip install nötig. Es läuft auf jedem System mit Python 3.6+. Das Passwort kommt aus der Umgebungsvariable MR6X00_PASS, damit es nicht im Prozess-Listing auftaucht.

# Passwort setzen
export MR6X00_PASS="mein_router_passwort"

# Status anzeigen
./netgear-mr6x00-cli.py status

# Signal-Werte mit CA-Band-Details
./netgear-mr6x00-cli.py signal

# Datenverbrauch diesen Monat
./netgear-mr6x00-cli.py data

# Einzelnen Wert aus der JSON-API lesen (dotted path)
./netgear-mr6x00-cli.py get power.profileCurrent
./netgear-mr6x00-cli.py get wwan.currentPSserviceType

# Einzelnen Wert setzen
./netgear-mr6x00-cli.py set power.profileCurrent 2

# Konfigurationsbackup erstellen
./netgear-mr6x00-cli.py backup /tmp/router_sicherung.cfg

# Alle optimalen Einstellungen für Dauerbetrieb am Strom anwenden
./netgear-mr6x00-cli.py apply-optimal

So sieht die Ausgabe der drei wichtigsten Befehle aus (IPs und Hostnamen geschwärzt):

Netgear MR6450 CLI: status-Ausgabe mit Verbindung, Signal, Power-Profil und Datenverbrauch
Status-Ausgabe: Verbindungstyp, Signal, Power-Profil, Datenlimit-Fortschritt
Netgear MR6450 CLI: signal-Ausgabe mit LTE CA-Bändern und Signalwerten
Signal-Details: RSRP, RSRQ, SINR plus alle aktiven LTE-CA-Bänder
Netgear MR6450 CLI: Datenverbrauch mit Fortschrittsbalken und Abrechnungszyklus
Datenverbrauch mit grafischem Fortschrittsbalken und Abrechnungszyklus

Weitere Befehle — Screenshots

Neben Status, Signal und Datenverbrauch bietet das Script noch fünf weitere Befehle:

Netgear MR6450 CLI: wifi-Befehl zeigt SSID, Verschlüsselung und Gastnetz
wifi — SSID, Verschlüsselung und Gastnetz-Einstellungen.
Netgear MR6450 CLI: apply-optimal --dry-run zeigt alle Einstellungen die optimiert werden
apply-optimal --dry-run — Vorschau aller Optimierungen ohne tatsächliche Änderung.
Netgear MR6450 CLI: get-Befehl liest beliebige Werte aus der Router-API (power-Sektion)
get power — liest beliebige JSON-Pfade aus model.json.
Netgear MR6450 CLI: set-Befehl ändert einzelne Router-Einstellungen
set — ändert einzelne Felder via POST /Forms/config.
Netgear MR6450 CLI: backup-Befehl speichert die Konfiguration als .cfg Datei
backup — lädt die vollständige Router-Konfiguration als .cfg Datei herunter.

Das vollständige Script (ca. 200 Zeilen) enthält außerdem die Befehle status, signal, data, backup, get <key>, set <key> <value> und apply-optimal:

#!/usr/bin/env python3
"""netgear-mr6x00-cli.py — Netgear MR6450/MR6500 CLI Manager (stdlib only)"""

import sys, os, json, time, argparse
from datetime import datetime
from urllib.request import Request, build_opener, HTTPCookieProcessor
from urllib.parse import urlencode
from urllib.error import URLError, HTTPError
from http.cookiejar import CookieJar

HOST     = os.environ.get("MR6X00_HOST", "http://192.168.1.1")
PASSWORD = os.environ.get("MR6X00_PASS", "admin")
TIMEOUT  = 10

class MR6x00:
    def __init__(self, host=HOST, password=PASSWORD):
        self.host = host.rstrip("/")
        self.password = password
        self.jar = CookieJar()
        self.opener = build_opener(HTTPCookieProcessor(self.jar))
        self.token = None

    def _get(self, path):
        resp = self.opener.open(Request(f"{self.host}{path}"), timeout=TIMEOUT)
        return resp.read()

    def _post(self, path, fields: dict):
        body = urlencode(fields).encode()
        req  = Request(f"{self.host}{path}", data=body,
                       headers={"Content-Type": "application/x-www-form-urlencoded"})
        resp = self.opener.open(req, timeout=TIMEOUT)
        return resp.read(), resp.status

    def connect(self):
        self._get("/")
        data = json.loads(self._get("/api/model.json?internalapi=1"))
        self.token = data["session"]["secToken"]          # NICHT aus HTML!
        body = urlencode({"session.password": self.password,
                          "token": self.token,
                          "ok_redirect": "/index.html",
                          "err_redirect": "/index.html?loginfailed"}).encode()
        req  = Request(f"{self.host}/Forms/config", data=body,
                       headers={"Content-Type": "application/x-www-form-urlencoded"})
        resp = self.opener.open(req, timeout=TIMEOUT)
        if "loginfailed" in resp.url:
            raise RuntimeError("Login fehlgeschlagen")
        data2 = json.loads(self._get("/api/model.json?internalapi=1"))
        self.token = data2["session"].get("secToken", self.token)

    def get_model(self) -> dict:
        return json.loads(self._get(f"/api/model.json?internalapi=1&_={int(time.time())}"))

    def post_config(self, fields: dict) -> bool:
        _, status = self._post("/Forms/config", {"token": self.token, **fields})
        return status in (200, 204)

    def get_setting(self, key: str):
        val = self.get_model()
        for part in key.split("."):
            val = val.get(part) if isinstance(val, dict) else None
        return val

    def set_setting(self, key: str, value: str) -> bool:
        return self.post_config({key: value})

    def backup(self) -> bytes:
        return self._get("/dui/export.cfg?save=export.cfg")

    def __enter__(self):  self.connect(); return self
    def __exit__(self, *_): pass

Kompatibilität mit anderen Netgear-MR-Routern

Die API-Struktur — /api/model.json für State, /Forms/config für Einstellungen, Token aus session.secToken — ist mit hoher Wahrscheinlichkeit auch für andere Router der MR-Serie nutzbar: MR5200, MR6110, MR6150. Die Feldnamen können abweichen. Das model.json ist der beste Ausgangspunkt: einfach ./netgear-mr6x00-cli.py get wwan aufrufen und die JSON-Struktur erkunden.

Fazit

Die API des MR6450 ist vollständig undokumentiert, aber vollständig nutzbar. Drei Hürden kosten die meiste Zeit:

  1. Token-Quelle: Das HTML-Formularfeld ist immer leer. Der CSRF-Token steckt in der JSON-API unter session.secToken.
  2. Content-Type: Nur application/x-www-form-urlencoded funktioniert. JSON und Multipart ergeben HTTP 400 ohne Erklärung.
  3. Echte Feldnamen: Browser-DevTools und tatsächliche Formular-Submissions mitschneiden. Besonders der Datenlimit-Wert in Bytes ist eine stille Falle.

Was sofort funktioniert hat: Backup-Download, GET /api/model.json für Monitoring. Was nicht funktioniert hat: Multipart-Login, JSON-Body, Token aus HTML-Parsing.

Das fertige Script ist auf Codeberg verfügbar: netgear-mr6x00-cli auf Codeberg

Hinweis (v2, September 2026): Beim Schreiben des Scripts fiel auf, dass das Netgear-API model.json die WiFi-Daten unter dem Key wifi ablegt — nicht unter wlan, wie man erwarten würde. Das führte dazu, dass der wifi-Befehl initial keine Ausgabe lieferte. Im aktuellen Script auf Codeberg ist das korrigiert.

Für mein Failover-Setup läuft das Script als Monitoring-Hook: Bei Failover auf 5G wird der Datenverbrauch regelmäßig geprüft und bei 80% des Limits eine Warnung ausgelöst. Die eigentliche Failover-Logik übernimmt ein separater Daemon, der Haupt- und Backup-Verbindung anhand von Ping-Tests gegeneinander abwägt — dazu eventuell mehr in einem eigenen Post.

Power-Profile im Vergleich

Der Router kennt drei Power-Profile. Für USB-Dauerbetrieb am PC ist ACPowered (ID 2) zwingend — nur damit bleibt Ethernet aktiv und der Router geht nicht in Standby:

Eigenschaft0 — Balanced1 — Performance2 — ACPowered
WiFi SendeleistungStandardLongExtended (max)
Auto-Standby5 Minuten60 MinutenNie
EthernetAus (100 Mbit)An (1 Gbit)An (2,5 Gbit)

Umschalten per CLI: python3 netgear-mr6x00-cli.py set power.profileCurrent 2 — oder einmalig mit apply-optimal, das setzt alle relevanten Felder auf einmal.

Wichtige API-Felder auf einen Blick

Die häufigsten Felder zum Lesen (get) und Schreiben (set):

FeldBeispielwertBeschreibung
power.profileCurrent2Aktives Power-Profil (0/1/2)
wwan.bandRegion4Bandmodus: 4 = LTE+5G (kein 3G-Fallback)
wwan.connectionConnectedVerbindungsstatus
wwan.currentPSserviceTypeLTEAktiver Mobilfunk-Standard
wwan.signalStrength.bars5Signalbalken (0–5)
wwan.signalStrength.rsrp-82RSRP in dBm (gut: > -100)
wwan.signalStrength.sinr18SINR in dB (gut: > 10)
power.ethernetSpeed2500MEthernet-Geschwindigkeit
router.usbNetworkTetheringtrueUSB-Tethering aktiv
wwan.dataUsage.generic.billingCycleLimit42949672960Datenlimit in Bytes (hier: 40 GB)
ra.enabledfalseNetgear Cloud-Zugang — sollte aus sein
lcd.brightness4Display-Helligkeit (1–7)

Alle Felder lassen sich mit get wwan oder get power als komplettes JSON-Objekt abfragen und gezielt mit set schreiben.

Das komplette Script

Das folgende Python-Script ist direkt einsatzbereit. Router-IP und Passwort werden über Umgebungsvariablen gesetzt:

export NETGEAR_HOST=http://192.168.1.1
export NETGEAR_PASS=dein-passwort

python3 netgear-mr6x00-cli.py status
python3 netgear-mr6x00-cli.py signal
python3 netgear-mr6x00-cli.py apply-optimal
#!/usr/bin/env python3
"""
netgear-mr6x00-cli.py — Netgear Nighthawk M6 (MR6450) CLI Manager
Router IP: 192.168.1.1 (USB tethering, enp2s0f0u6)

Usage:
  ./netgear-mr6x00-cli.py status
  ./netgear-mr6x00-cli.py backup [output.cfg]
  ./netgear-mr6x00-cli.py get <key>           # read from model.json
  ./netgear-mr6x00-cli.py set <key> <value>   # POST to /Forms/config
  ./netgear-mr6x00-cli.py apply-optimal       # apply best settings for USB-powered mode
  ./netgear-mr6x00-cli.py signal              # show signal details
  ./netgear-mr6x00-cli.py data               # show data usage

API notes:
  - Content-Type MUST be application/x-www-form-urlencoded (multipart = HTTP 400)
  - CSRF token: GET /api/model.json?internalapi=1 → session.secToken (JS fills form from this)
  - Login: POST /Forms/config with session.password + token (= secToken)
  - Settings: POST /Forms/config with token + field=value pairs
  - Backup: GET /dui/export.cfg?save=export.cfg
"""

import sys
import os
import json
import time
import argparse
from datetime import datetime
from urllib.request import Request
from urllib.parse import urlencode
from urllib.error import URLError, HTTPError
from http.cookiejar import CookieJar
from urllib.request import build_opener, HTTPCookieProcessor

ROUTER = os.environ.get("NETGEAR_HOST", "http://192.168.1.1")
PASSWORD = os.environ.get("NETGEAR_PASS", "")
TIMEOUT = 10

RED = "33[0;31m"
GREEN = "33[0;32m"
YELLOW = "33[1;33m"
CYAN = "33[0;36m"
BOLD = "33[1m"
DIM = "33[2m"
NC = "33[0m"


# ---------------------------------------------------------------------------
# Session management
# ---------------------------------------------------------------------------

class RouterSession:
    def __init__(self, host=ROUTER, password=PASSWORD):
        self.host = host.rstrip("/")
        self.password = password
        self.jar = CookieJar()
        self.opener = build_opener(HTTPCookieProcessor(self.jar))
        self.token = None
        self._logged_in = False

    def _get(self, path, timeout=TIMEOUT):
        req = Request(f"{self.host}{path}")
        resp = self.opener.open(req, timeout=timeout)
        return resp.read()

    def _post(self, path, fields: dict, timeout=TIMEOUT):
        body = urlencode(fields).encode()
        req = Request(
            f"{self.host}{path}",
            data=body,
            headers={"Content-Type": "application/x-www-form-urlencoded"},
        )
        resp = self.opener.open(req, timeout=timeout)
        return resp.read(), resp.status

    def connect(self):
        """Get CSRF token from model.json (session.secToken) and log in."""
        # Step 1: GET / to get the sessionId cookie
        self._get("/")

        # Step 2: fetch model.json — session.secToken IS the CSRF token (JS reads it and fills the form)
        data = json.loads(self._get("/api/model.json?internalapi=1"))
        self.token = data.get("session", {}).get("secToken")
        if not self.token:
            raise RuntimeError("CSRF token (session.secToken) not found in model.json")

        # Step 3: log in
        body = urlencode({
            "session.password": self.password,
            "token": self.token,
            "ok_redirect": "/index.html",
            "err_redirect": "/index.html?loginfailed",
        }).encode()
        req = Request(
            f"{self.host}/Forms/config",
            data=body,
            headers={"Content-Type": "application/x-www-form-urlencoded"},
        )
        resp = self.opener.open(req, timeout=TIMEOUT)
        if "loginfailed" in resp.url:
            raise RuntimeError("Login failed — check password")
        self._logged_in = True

        # Step 4: refresh token after login (it rotates)
        data2 = json.loads(self._get("/api/model.json?internalapi=1"))
        new_token = data2.get("session", {}).get("secToken")
        if new_token:
            self.token = new_token

    def model(self) -> dict:
        """GET /api/model.json"""
        data = self._get(f"/api/model.json?internalapi=1&_={int(time.time())}")
        return json.loads(data)

    def post_config(self, fields: dict) -> dict:
        """POST /Forms/config with token + fields. Returns {ok, status, body}."""
        payload = {"token": self.token, **fields}
        body, status = self._post("/Forms/config", payload)
        text = body.decode("utf-8", errors="replace")
        ok = status in (200, 204) or '"success":true' in text
        return {"ok": ok, "status": status, "body": text[:200]}

    def backup(self) -> bytes:
        """Download /dui/export.cfg"""
        return self._get("/dui/export.cfg?save=export.cfg", timeout=15)

    def __enter__(self):
        self.connect()
        return self

    def __exit__(self, *_):
        pass


# ---------------------------------------------------------------------------
# Helper formatters
# ---------------------------------------------------------------------------

def _bytes_to_gb(b) -> str:
    try:
        return f"{int(b) / 1e9:.2f} GB"
    except (TypeError, ValueError):
        return str(b)


def _signal_bar(bars: int) -> str:
    filled = "█" * bars
    empty = "░" * (5 - bars)
    return f"{GREEN}{filled}{DIM}{empty}{NC}"


def _ok(msg): print(f"  {GREEN}✓{NC} {msg}")
def _fail(msg): print(f"  {RED}✗{NC} {msg}")
def _info(msg): print(f"  {CYAN}·{NC} {msg}")


# ---------------------------------------------------------------------------
# Commands
# ---------------------------------------------------------------------------

def cmd_status(sess: RouterSession):
    m = sess.model()
    g = m.get("general", {})
    p = m.get("power", {})
    w = m.get("wwan", {})
    r = m.get("router", {})
    sim = m.get("sim", {})
    sig = w.get("signalStrength", {})
    du = w.get("dataUsage", {}).get("generic", {})

    print(f"n{BOLD}━━━ Netgear Nighthawk M6 — Status ━━━{NC}")
    print(f"{DIM}{g.get('model')} · FW {g.get('FWversion')} · Uptime {g.get('upTime',0)//60}min{NC}n")

    # Connection
    conn = w.get("connection", "?")
    rat = w.get("currentPSserviceType", "?")
    reg = w.get("registerNetworkDisplay", "?")
    bars = sig.get("bars", 0)
    print(f"{BOLD}Verbindung:{NC}")
    print(f"  {_signal_bar(bars)} {bars}/5  ·  {CYAN}{rat}{NC}  ·  {reg}")
    print(f"  RSSI {sig.get('rssi')} dBm · RSRP {sig.get('rsrp')} dBm · RSRQ {sig.get('rsrq')} dB · SINR {sig.get('sinr')} dB")
    print(f"  WAN IPv4: {w.get('IP','?')}  ·  IPv6: {w.get('IPv6','?')}")
    print(f"  Status: {GREEN if conn=='Connected' else RED}{conn}{NC}")
    print()

    # LTE bands
    bands = [b for b in w.get("lteBandInfo", []) if b.get("band")]
    if bands:
        band_str = " + ".join(
            f"B{b['band']} {'PCC' if b['isPcc'] else 'SCC'} {b['dlBandwidth']}"
            for b in bands
        )
        print(f"  LTE CA: {band_str}")
    print()

    # Power profile
    profiles = {b["id"]: b["name"] for b in p.get("profileList", []) if "id" in b}
    cur_profile = p.get("profileCurrent", "?")
    prof_name = profiles.get(cur_profile, cur_profile)
    batt = p.get("battChargeLevel", "?")
    batt_temp = p.get("batteryTemperature", "?")
    batt_state = p.get("batteryTempState", "?")
    batt_src = p.get("battChargeSource", "?")
    print(f"{BOLD}Power:{NC}")
    print(f"  Profil: {CYAN}{prof_name} ({cur_profile}){NC}  ·  Ethernet: {p.get('ethernetSpeed','?')} ({'an' if p.get('ethernetDrvEnable') else 'aus'})")
    print(f"  Akku: {batt}%  ·  Temp: {batt_temp}°C ({batt_state})  ·  Quelle: {batt_src}")
    print()

    # Data usage
    dt = _bytes_to_gb(w.get("dataTransferred", 0))
    limit = du.get("billingCycleLimit", 0)
    limit_str = _bytes_to_gb(limit) if limit else "kein Limit"
    limit_en = du.get("billingCycleLimitEnabled", False)
    warn = du.get("usageHighWarning", 80)
    bday = du.get("billingDay", "?")
    print(f"{BOLD}Datenverbrauch:{NC}")
    print(f"  Verbraucht: {YELLOW}{dt}{NC}  ·  Limit: {limit_str} ({'aktiv' if limit_en else 'inaktiv'})  ·  Warnung: {warn}%")
    print(f"  Abrechnungstag: {bday}")
    print()

    # LAN
    dhcp = r.get("DHCP", {})
    print(f"{BOLD}LAN:{NC}")
    dhcp_range = dhcp.get("range", {})
    print(f"  Gateway: {r.get('gatewayIP')}  ·  DHCP: {dhcp_range.get('low')}–{dhcp_range.get('high')}")
    print(f"  DNS: {dhcp.get('DNS1')} / {dhcp.get('DNS2')}  ({dhcp.get('DNSmode')})")
    print(f"  IP Passthrough: {'an' if r.get('ipPassThroughEnabled') else 'aus'}")
    clients = [c for c in r.get("clientList", []) if c.get("IP")]
    if clients:
        client_str = ", ".join(f"{c['name']}({c['IP']})" for c in clients)
        print(f"  Clients ({len(clients)}): {client_str}")
    print()


def cmd_signal(sess: RouterSession):
    m = sess.model()
    w = m.get("wwan", {})
    sig = w.get("signalStrength", {})
    diag = (w.get("diagInfo") or [{}])[0]
    bands = [b for b in w.get("lteBandInfo", []) if b.get("band")]
    ca = w.get("ca", {})

    print(f"n{BOLD}━━━ Signal Details ━━━{NC}")
    bars = sig.get("bars", 0)
    print(f"Stärke: {_signal_bar(bars)}  {bars}/5")
    print(f"RAT:    {w.get('currentPSserviceType','?')} / {w.get('connectionType','?')}")
    print(f"Netz:   {w.get('registerNetworkDisplay','?')}  ·  Roaming: {w.get('roaming')}")
    print()
    print(f"LTE Signalwerte:")
    print(f"  RSSI:  {sig.get('rssi')} dBm")
    print(f"  RSRP:  {sig.get('rsrp')} dBm  (gut > -100, sehr gut > -80)")
    print(f"  RSRQ:  {sig.get('rsrq')} dB   (gut > -10)")
    print(f"  SINR:  {sig.get('sinr')} dB   (gut > 10)")
    print()
    INVALID = -32768
    nr_rsrp = sig.get("nr5gRsrp")
    nr_valid = nr_rsrp is not None and int(nr_rsrp) != INVALID
    print(f"NR5G Signalwerte: {'(nicht aktiv / NSA-Anker via LTE)' if not nr_valid else ''}")
    if nr_valid:
        print(f"  RSRP:  {sig.get('nr5gRsrp')} dBm")
        print(f"  RSRQ:  {sig.get('nr5gRsrq')} dB")
        print(f"  SINR:  {sig.get('nr5gSinr')} dB")
    else:
        print(f"  {DIM}— NR5G-Signal wird über LTE-Anker gemeldet{NC}")
    print()
    print(f"LTE CA Bänder ({len(bands)}):")
    for b in bands:
        role = "PCC" if b.get("isPcc") else "SCC"
        print(f"  B{b.get('band')} {role}: DL CH {b.get('channel')} · {b.get('dlBandwidth')} · CID {b.get('phyCid')}")
    print()


def cmd_data(sess: RouterSession):
    m = sess.model()
    w = m.get("wwan", {})
    du = w.get("dataUsage", {}).get("generic", {})

    transferred = int(w.get("dataTransferred", 0))
    rx = int(w.get("dataTransferredRx", 0))
    tx = int(w.get("dataTransferredTx", 0))
    limit = du.get("billingCycleLimit", 0)
    limit_en = du.get("billingCycleLimitEnabled", False)
    warn = du.get("usageHighWarning", 80)
    bday = du.get("billingDay", "?")
    remainder = du.get("billingCycleRemainder", 0)

    print(f"n{BOLD}━━━ Datenverbrauch ━━━{NC}")
    print(f"  Total:   {BOLD}{_bytes_to_gb(transferred)}{NC}")
    print(f"  ↓ RX:    {_bytes_to_gb(rx)}")
    print(f"  ↑ TX:    {_bytes_to_gb(tx)}")
    print()

    if limit_en and limit > 0:
        pct = transferred / limit * 100
        bar_len = 30
        filled = int(bar_len * pct / 100)
        color = RED if pct > warn else YELLOW if pct > 60 else GREEN
        bar = f"{color}{'█'*filled}{DIM}{'░'*(bar_len-filled)}{NC}"
        print(f"  Limit:   {_bytes_to_gb(limit)}  [{bar}] {pct:.1f}%")
        print(f"  Warnung: bei {warn}%")
    else:
        print(f"  Limit:   kein (deaktiviert)")

    days_left = int(remainder) // 86400 if remainder else "?"
    print(f"  Abrechnungszyklus: Tag {bday} jeden Monat  ·  Verbleibend: ~{days_left} Tage")
    print()


def cmd_get(sess: RouterSession, key: str):
    """Get a value from model.json by dotted path."""
    m = sess.model()
    parts = key.split(".")
    val = m
    for part in parts:
        if isinstance(val, dict):
            val = val.get(part)
        else:
            val = None
            break
    print(json.dumps(val, indent=2, ensure_ascii=False))


def cmd_set(sess: RouterSession, key: str, value: str):
    """Set a single field via POST /Forms/config."""
    result = sess.post_config({key: value})
    if result["ok"]:
        _ok(f"{key} = {value!r}")
    else:
        _fail(f"{key}: HTTP {result['status']}n    {result['body'][:100]}")


def cmd_backup(sess: RouterSession, outfile: str = None):
    """Download /dui/export.cfg and save it."""
    data = sess.backup()
    if not outfile:
        ts = datetime.now().strftime("%Y-%m-%d_%H%M")
        outfile = f"netgear_backup_{ts}.cfg"
    with open(outfile, "wb") as f:
        f.write(data)
    size_kb = len(data) / 1024
    _ok(f"Backup gespeichert: {outfile} ({size_kb:.1f} KB)")


def cmd_apply_optimal(sess: RouterSession, dry_run=False):
    """Apply optimal settings for always-on USB-powered use."""
    print(f"n{BOLD}━━━ Optimale Einstellungen anwenden ━━━{NC}")
    if dry_run:
        print(f"  {YELLOW}DRY RUN — keine Änderungen{NC}n")

    settings = [
        # Power profile → ACPowered: Extended WiFi, Never standby, 2.5GbE
        ("power.profileCurrent", "2", "Power-Profil → ACPowered (Extended WiFi, nie Standby, 2.5GbE)"),
        ("wifi.txPower", "Extended", "WiFi Sendeleistung → Erweitert"),
        ("power.standbyTimeout", "0", "WiFi Standby → aus"),
        ("power.ethernetDrvEnable", "true", "Ethernet → aktiv"),
        ("power.ethernetSpeed", "2500M", "Ethernet-Geschwindigkeit → 2.5 GbE"),
        # Band mode: 4G+5G (optimal für NSA 5G)
        ("wwan.bandRegion", "4", "Bandregion → 4G+5G"),
    ]

    for key, val, desc in settings:
        if dry_run:
            print(f"  {CYAN}→{NC} {key} = {val!r}  ({desc})")
        else:
            result = sess.post_config({key: val})
            if result["ok"]:
                _ok(f"{desc}")
            else:
                _fail(f"{desc}: HTTP {result['status']}")
            time.sleep(0.5)

    # Data limit (special field names)
    print()
    if dry_run:
        print(f"  {CYAN}→{NC} Data limit 40 GB, Billing-Tag 23, Warnung 80%")
    else:
        result = sess.post_config({
            "wwan.dataUsage.generic.billingCycleLimitEnabled": "true",
            "wwan.dataUsage.generic.billingCycleLimit": str(40 * 1024 * 1024 * 1024),  # 40 GB in bytes
            "datausage.dataLimitNoUnit": "40",
            "datausage.dataLimitUnit": "GB",
            "datausage.dataWarnEnable": "true",
            "datausage.billingDay": "23",
        })
        if result["ok"]:
            _ok("Data-Limit → 40 GB, Billing-Tag 23, Warnung 80%")
        else:
            _fail(f"Data-Limit: HTTP {result['status']}")
    print()


def cmd_wifi_info(sess: RouterSession):
    """Show WiFi settings."""
    m = sess.model()
    wl = m.get("wlan", {})
    r = m.get("router", {})

    print(f"n{BOLD}━━━ WiFi-Einstellungen ━━━{NC}")
    for net_key, label in [("primary", "2.4/5/6 GHz Primary"), ("secondary", "6 GHz Secondary"), ("guestAP", "Gastnetz")]:
        net = wl.get(net_key, {})
        if net.get("SSID"):
            enabled = net.get("enabled", True)
            print(f"n  {BOLD}{label}{NC}  {'[aktiv]' if enabled else '[inaktiv]'}")
            print(f"    SSID:        {net.get('SSID')}")
            print(f"    Passwort:    {net.get('passPhrase','?')}")
            print(f"    Verschlüss.: {net.get('encryption','?')}")
    print()


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------

def main():
    parser = argparse.ArgumentParser(
        description="Netgear MR6450 (Nighthawk M6) CLI Manager",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Beispiele:
  %(prog)s status
  %(prog)s signal
  %(prog)s data
  %(prog)s backup /tmp/router.cfg
  %(prog)s get power.profileCurrent
  %(prog)s set power.profileCurrent 2
  %(prog)s apply-optimal --dry-run
  %(prog)s apply-optimal

Umgebungsvariablen:
  NETGEAR_HOST   Router-URL (Standard: http://192.168.1.1)
  NETGEAR_PASS   Admin-Passwort
"""
    )
    parser.add_argument("--host", default=ROUTER, help="Router-URL")
    parser.add_argument("--pass", dest="password", default=PASSWORD, help="Admin-Passwort")
    sub = parser.add_subparsers(dest="cmd", required=True)

    sub.add_parser("status", help="Übersicht anzeigen")
    sub.add_parser("signal", help="Signal-Details anzeigen")
    sub.add_parser("data", help="Datenverbrauch anzeigen")
    sub.add_parser("wifi", help="WiFi-Einstellungen anzeigen")

    bk = sub.add_parser("backup", help="Konfiguration sichern")
    bk.add_argument("outfile", nargs="?", help="Zieldatei (Standard: netgear_backup_DATUM.cfg)")

    g = sub.add_parser("get", help="Wert aus model.json lesen")
    g.add_argument("key", help="Dotted path (z.B. power.profileCurrent)")

    s = sub.add_parser("set", help="Einzelnen Wert setzen")
    s.add_argument("key", help="Field-Name")
    s.add_argument("value", help="Wert")

    ap = sub.add_parser("apply-optimal", help="Optimale Einstellungen anwenden")
    ap.add_argument("--dry-run", action="store_true", help="Nur anzeigen, nicht anwenden")

    args = parser.parse_args()

    try:
        with RouterSession(host=args.host, password=args.password) as sess:
            if args.cmd == "status":
                cmd_status(sess)
            elif args.cmd == "signal":
                cmd_signal(sess)
            elif args.cmd == "data":
                cmd_data(sess)
            elif args.cmd == "wifi":
                cmd_wifi_info(sess)
            elif args.cmd == "backup":
                cmd_backup(sess, args.outfile)
            elif args.cmd == "get":
                cmd_get(sess, args.key)
            elif args.cmd == "set":
                cmd_set(sess, args.key, args.value)
            elif args.cmd == "apply-optimal":
                cmd_apply_optimal(sess, dry_run=args.dry_run)
    except (URLError, HTTPError) as e:
        print(f"{RED}Verbindungsfehler:{NC} {e}", file=sys.stderr)
        sys.exit(1)
    except RuntimeError as e:
        print(f"{RED}Fehler:{NC} {e}", file=sys.stderr)
        sys.exit(1)


if __name__ == "__main__":
    main()

🔗 Kurzlink: https://nga.li/netgear-nighthawk-m6-0c0