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.
| Eigenschaft | Wert |
|---|---|
| Modellname | Netgear Nighthawk M6 (MR6450) |
| 5G | Sub-6GHz (NSA/SA), bis zu 2,5 Gbit/s Download (theoretisch) |
| LTE | Cat-22, Carrier Aggregation bis zu 3 Bänder gleichzeitig |
| WiFi | WiFi 6 (802.11ax), 2,4 & 5 GHz, bis zu 3,6 Gbit/s |
| Ethernet | 1× USB-C-zu-2,5GbE (im Lieferumfang), alternativ USB-Tethering |
| USB | USB-C (Laden + Tethering/RNDIS) |
| Display | 2,8″ Touchscreen (Status, Einstellungen) |
| Akku | 5.040 mAh (Dauerbetrieb ca. 13h) |
| Anschlüsse | 1× USB-C, 1× Nano-SIM |
| Gewicht | ca. 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 einmalPOST /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:
GET /— Session starten,sessionId-Cookie einsammelnGET /api/model.json?internalapi=1— JSON holen,session.secTokenlesenPOST /Forms/configmitsession.password=PASS&token=<secToken>&ok_redirect=/index.htmlGET /api/model.json?internalapi=1nochmals — 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:
| Einstellung | Echter Feldname | Wert |
|---|---|---|
| Power-Profil Netzbetrieb | power.profileCurrent | 2 |
| WiFi Sendeleistung | wifi.txPower | Extended |
| Ethernet aktivieren | power.ethernetDrvEnable | true |
| Ethernet-Geschwindigkeit | power.ethernetSpeed | 2500M |
| Datenlimit aktiv | wwan.dataUsage.generic.billingCycleLimitEnabled | true |
| Datenlimit-Wert | wwan.dataUsage.generic.billingCycleLimit | Bytes (nicht MB/GB!) |
| Limit-Anzeige | datausage.dataLimitNoUnit | 40 (nur Zahl) |
| Abrechnungstag | datausage.billingDay | 23 |
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):
Weitere Befehle — Screenshots
Neben Status, Signal und Datenverbrauch bietet das Script noch fünf weitere Befehle:
wifi — SSID, Verschlüsselung und Gastnetz-Einstellungen.
apply-optimal --dry-run — Vorschau aller Optimierungen ohne tatsächliche Änderung.
get power — liest beliebige JSON-Pfade aus model.json.
set — ändert einzelne Felder via POST /Forms/config.
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:
- Token-Quelle: Das HTML-Formularfeld ist immer leer. Der CSRF-Token steckt in der JSON-API unter
session.secToken. - Content-Type: Nur
application/x-www-form-urlencodedfunktioniert. JSON und Multipart ergeben HTTP 400 ohne Erklärung. - 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:
| Eigenschaft | 0 — Balanced | 1 — Performance | 2 — ACPowered |
|---|---|---|---|
| WiFi Sendeleistung | Standard | Long | Extended (max) |
| Auto-Standby | 5 Minuten | 60 Minuten | Nie |
| Ethernet | Aus (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):
| Feld | Beispielwert | Beschreibung |
|---|---|---|
power.profileCurrent | 2 | Aktives Power-Profil (0/1/2) |
wwan.bandRegion | 4 | Bandmodus: 4 = LTE+5G (kein 3G-Fallback) |
wwan.connection | Connected | Verbindungsstatus |
wwan.currentPSserviceType | LTE | Aktiver Mobilfunk-Standard |
wwan.signalStrength.bars | 5 | Signalbalken (0–5) |
wwan.signalStrength.rsrp | -82 | RSRP in dBm (gut: > -100) |
wwan.signalStrength.sinr | 18 | SINR in dB (gut: > 10) |
power.ethernetSpeed | 2500M | Ethernet-Geschwindigkeit |
router.usbNetworkTethering | true | USB-Tethering aktiv |
wwan.dataUsage.generic.billingCycleLimit | 42949672960 | Datenlimit in Bytes (hier: 40 GB) |
ra.enabled | false | Netgear Cloud-Zugang — sollte aus sein |
lcd.brightness | 4 | Display-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
Kommentar schreiben