initial commit

This commit is contained in:
Joachim Friberg committed 2026-08-25 14:10:21 +02:00
1 parent af59a90c31
commit ef498e4a72
114 files changed
+11047

No files matched your search

+126
View File
@@ -0,0 +1,126 @@
import os
from urllib.parse import urlparse
import requests
from bs4 import BeautifulSoup
BASE_URL = os.environ.get("SWITCH_URL", "http://192.168.2.10")
SESSION_COOKIE_FILE = os.path.join(os.path.dirname(__file__), ".session_cookie")
def login(password: str = "", session: requests.Session | None = None) -> requests.Session:
"""POST to /hp_login.html, return a Session with the SID cookie set."""
session = session or requests.Session()
# A bare POST occasionally comes back without setting SID (the switch
# seems to want a session cookie already present, even an empty one,
# before it will issue a real one) -- a GET first makes login reliable.
session.get(f"{BASE_URL}/")
resp = session.post(
f"{BASE_URL}/hp_login.html",
data={"pwd": password, "login": "Login", "err_flag": "", "err_msg": ""},
)
resp.raise_for_status()
if "<title>login</title>" in resp.text.lower():
raise RuntimeError(
"Login failed, response still looks like the login page. "
f"Body snippet: {resp.text[:500]!r}"
)
# The switch only serves the main frameset as the direct response to this
# POST — a later GET "/" re-renders the login page regardless of session
# state, so stash it here for menu.get_treeframe_url() to use.
session.frameset_html = resp.text
return session
def _load_cached_session() -> requests.Session | None:
try:
with open(SESSION_COOKIE_FILE) as f:
sid = f.read().strip()
except FileNotFoundError:
return None
if not sid:
return None
session = requests.Session()
session.cookies.set("SID", sid, domain=urlparse(BASE_URL).hostname, path="/")
return session
def _is_logged_in(session: requests.Session) -> bool:
resp = session.get(f"{BASE_URL}/SysDescription.html")
resp.raise_for_status()
return "<title>login</title>" not in resp.text.lower()
def _cache_session(session: requests.Session) -> None:
sid = session.cookies.get("SID")
if sid:
with open(SESSION_COOKIE_FILE, "w") as f:
f.write(sid)
def get_session(password: str = "", force_login: bool = False) -> requests.Session:
"""Reuse a still-valid cached session if one exists, otherwise log in.
The switch only allows one active web session at a time and briefly
refuses a fresh login right after a previous one (its ~5 minute session
timeout hasn't elapsed yet), so avoiding a needless re-login here is
what makes running a script repeatedly in short succession reliable.
"""
if not force_login:
cached = _load_cached_session()
if cached is not None and _is_logged_in(cached):
return cached
session = login(password)
_cache_session(session)
return session
def _resolve_url(path: str) -> str:
if path.startswith("http://") or path.startswith("https://"):
return path
if path.startswith("/"):
return f"{BASE_URL}{path}"
return f"{BASE_URL}/{path}"
def fetch(session: requests.Session, path: str) -> str:
"""GET BASE_URL + path (path may be relative or absolute), return response.text."""
resp = session.get(_resolve_url(path))
resp.raise_for_status()
return resp.text
def submit_form(session: requests.Session, path: str, fields: dict) -> str:
"""POST fields to path, merged with the firmware's standard bookkeeping
fields (submit_flag, submit_target, err_flag, err_msg, clazz_information).
Raises RuntimeError if the response's err_flag comes back "1" -- the
firmware's own validation-failure signal. NOTE: most value validation in
this firmware happens client-side in JS, not on the server -- an invalid
enum value, for example, is just silently ignored (state left unchanged)
rather than reported via err_flag. So this check catches *some* rejected
writes, but a caller that needs certainty should read the value back
afterwards and compare, rather than trusting the absence of an error.
"""
target = path[1:] if path.startswith("/") else path
data = {
# 8 == xui_operation_submit (per the firmware's own _xe_jsvars.js);
# 1 is xui_operation_reload and silently applies nothing.
"submit_flag": "8",
"submit_target": target,
"err_flag": "0",
"err_msg": "",
"clazz_information": target,
}
data.update(fields)
resp = session.post(_resolve_url(path), data=data)
resp.raise_for_status()
soup = BeautifulSoup(resp.text, "html.parser")
err_flag = soup.find("input", attrs={"name": "err_flag"})
if err_flag is not None and err_flag.get("value") == "1":
err_msg = soup.find("input", attrs={"name": "err_msg"})
msg = err_msg.get("value") if err_msg is not None else "unknown error"
raise RuntimeError(f"Switch rejected write to {path!r}: {msg}")
return resp.text