From de0a03febc1eb4bb4fd23aee4e32f6c971df9174 Mon Sep 17 00:00:00 2001 From: Winz Date: Tue, 29 Sep 2026 18:08:16 +0200 Subject: [PATCH] unny-Portal-Login automatisieren und Fork dokumentieren MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Portal-URL und Pfade für Zugangsdaten und Cookies fest hinterlegen - SMA-Anmeldung mit Zugangsdaten aus JSON automatisieren - Cookies dauerhaft speichern und Demo-Funktionen entfernen - Startskript in sma-screensaver umbenennen - README mit Einrichtung und Testanleitung ergänzen - Fork-Hinweise ergänzen und sensible Dateien ignorieren --- .gitignore | 5 + README.md | 119 ++++++++++++++++++++ config.json.example | 4 + sma-screensaver | 266 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 394 insertions(+) create mode 100644 config.json.example create mode 100644 sma-screensaver diff --git a/.gitignore b/.gitignore index 36b13f1..e8abebf 100644 --- a/.gitignore +++ b/.gitignore @@ -174,3 +174,8 @@ cython_debug/ # PyPI configuration file .pypirc + +# Local SMA credentials and session data +credentials.json +config.json +cookies.txt diff --git a/README.md b/README.md index 9bc29e4..c908dcc 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,121 @@ # SMA-Screensaver +SMA-Screensaver zeigt das Sunny Portal powered by ennexOS in einem GTK/WebKit-Fenster an und automatisiert den Login. Das Programm kann eigenständig zum Testen oder eingebettet in XScreensaver laufen. + +Dieses Projekt ist ein Fork von [WebScreensaver](https://github.com/lmartinking/webscreensaver) von Lucas Martin-King und Thomas Reifenberger. Der Fork wird unter [Winz/SMA-Screensaver](https://gitea.diehanis.de/Winz/SMA-Screensaver) entwickelt. + +## Änderungen gegenüber WebScreensaver + +- Feste Startseite: `https://ennexos.sunnyportal.com/login`. +- Automatischer Klick auf „Anmelden“ und Weiterleitung zu `login.sma.energy`. +- Automatisches Ausfüllen und Absenden mit Zugangsdaten aus einer lokalen JSON-Datei. +- Fester Speicherort für Cookies. +- Demo-Seiten, Zufallsauswahl, Seitenwechsel und allgemeine Seitenkonfiguration entfernt. + +## Voraussetzungen + +- Linux mit grafischer Sitzung; die XScreensaver-Einbettung benötigt X11. +- Python 3 mit PyGObject (`gi`). +- GTK 3, GdkX11 3 und WebKit2 mit Introspection-Version `4.1`. +- Internetzugang und ein SMA-Konto mit Zugang zum Portal. +- XScreensaver für den Betrieb als Bildschirmschoner. + +Die Python- und Systembibliotheken müssen über die Paketverwaltung deiner Distribution verfügbar sein. Prüfe die Abhängigkeiten im Projektverzeichnis mit: + +```bash +python3 sma-screensaver --help +``` + +## Zugangsdaten einrichten + +Die Datei `config.json.example` ist eine Vorlage für die Zugangsdaten. Kopiere sie beim ersten Einrichten unter dem vom Programm erwarteten Namen: + +```bash +mkdir -p ~/.config/sma-screensaver +chmod 700 ~/.config/sma-screensaver +cp -n config.json.example ~/.config/sma-screensaver/credentials.json +chmod 600 ~/.config/sma-screensaver/credentials.json +``` + +Eine bereits vorhandene Datei wird dabei nicht überschrieben. Öffne `~/.config/sma-screensaver/credentials.json` in deinem Editor und trage deine Daten ein: + +```json +{ + "username": "deine@email.de", + "password": "dein-passwort" +} +``` + +Beide Werte müssen nicht leere Zeichenketten sein. In JSON müssen Anführungszeichen im Passwort als `\"` und Backslashes als `\\` geschrieben werden. + +Die Datei enthält das Passwort im Klartext. Die Dateirechte beschränken den Zugriff auf deinen Benutzer. Zugangsdaten und Cookies gehören nicht ins Repository oder in Fehlerberichte. Zum Testen genügt es, die Datei lokal zu bearbeiten; du musst die Zugangsdaten niemandem mitteilen. + +## Lokal starten und testen + +Starte das Programm aus einer grafischen Sitzung im Projektverzeichnis: + +```bash +python3 sma-screensaver +``` + +Erwarteter Ablauf: + +1. Das Fenster öffnet die Portal-Startseite. +2. Der Screensaver wartet auf den Anmeldebutton und klickt ihn automatisch an. +3. Auf der SMA-Anmeldeseite werden Benutzername und Passwort eingetragen und abgesendet. +4. Bei erfolgreicher Anmeldung öffnet sich das Portal. Eine erforderliche Zwei-Faktor-Bestätigung erledigst du selbst. + +Die Meldung `SMA-Anmeldeformular automatisch abgesendet.` bestätigt das Absenden. Prüfe im Fenster, ob die Anmeldung erfolgreich war. Pro Programmstart gibt es höchstens einen automatischen Anmeldeversuch. Nach einer Korrektur der Zugangsdaten musst du das Programm neu starten. + +Zum Beenden das Fenster schließen oder im Terminal `Strg+C` drücken. + +### Cookies und feste Pfade + +| Zweck | Pfad | +| --- | --- | +| Zugangsdaten | `~/.config/sma-screensaver/credentials.json` | +| Cookies | `~/.local/share/sma-screensaver/cookies.txt` | + +Der Cookie-Speicher wird beim Start automatisch angelegt und erhält Dateirechte `600`. Ob eine Anmeldung beim nächsten Start bestehen bleibt, hängt auch von der Sitzungsgültigkeit beim Portal ab. Für einen Test ohne gespeicherte Cookies das Programm beenden und die Cookie-Datei umbenennen. + +Die Pfade sind als `CREDENTIALS_FILE` und `COOKIE_FILE` im Skript hinterlegt. `config.json.example` wird nicht direkt eingelesen. + +## Einbindung in XScreensaver + +Verwende als Programmaufruf einen absoluten Pfad zum Skript, zum Beispiel: + +```text +python3 /absoluter/pfad/SMA-Screensaver/sma-screensaver +``` + +Füge diesen Aufruf in deine XScreensaver-Programmliste ein. XScreensaver kann die Ziel-Fenster-ID über `XSCREENSAVER_WINDOW` übergeben. Alternativ unterstützt das Skript `-window-id` und `--window-id` mit einer hexadezimalen Fenster-ID. + +Teste zuerst im eigenen Fenster, anschließend in der XScreensaver-Vorschau. Die Anmeldung und etwaige interaktive Bestätigungen lassen sich im eigenen Fenster leichter prüfen. + +## Optionen + +| Option | Funktion | +| --- | --- | +| `-h`, `--help` | Hilfe anzeigen | +| `-window-id ID`, `--window-id ID` | In ein bestehendes X11-Fenster einbetten | +| `-no-cache` | Explizite Aktivierung des WebKit-Browser-Caches überspringen | + +`-no-cache` löscht keine vorhandenen Daten und schaltet den separaten Cookie-Speicher nicht ab. + +## Fehlerbehebung und aktueller Stand + +- **`No module named gi` oder fehlender Namespace:** PyGObject und die benötigten GTK-/WebKit-Introspection-Pakete prüfen. +- **GTK kann nicht initialisiert werden:** Aus einer grafischen Sitzung starten; für die Einbettung muss ein X11-Display erreichbar sein. +- **Zugangsdaten fehlen oder JSON ist nicht lesbar:** Dateipfad, Leserechte und JSON-Syntax lokal prüfen. +- **Kein verwendbares Anmeldeformular gefunden:** Die Seite im Fenster prüfen. Änderungen am SMA-Formular oder zusätzliche Anmeldeschritte können eine Anpassung des Skripts erfordern. +- **Anmeldung abgelehnt:** Daten lokal korrigieren und das Programm neu starten. Es gibt keine automatische Wiederholungsschleife. + +Die Weiterleitung zur SMA-Anmeldeseite wurde mit WebKit erfolgreich getestet. Das Einlesen der JSON-Datei und die Formularlogik wurden mit Testdaten geprüft. Ein vollständiger Login mit echten Zugangsdaten steht noch aus. Eine automatische Auswahl einer bestimmten Anlage oder Dashboard-Seite ist bisher nicht implementiert. + +## Herkunft und Lizenz + +Grundlage ist [WebScreensaver](https://github.com/lmartinking/webscreensaver). + +Ursprünglicher Copyright-Hinweis: Copyright (C) 2012–2017 Lucas Martin-King & Thomas Reifenberger. Dieser Hinweis und die ursprüngliche Lizenzangabe bleiben im Quelltext erhalten. Die Anpassungen für diesen Fork sind im Dateikopf von `sma-screensaver` gekennzeichnet. + +Der übernommene Quelltext nennt die GNU General Public License, Version 2 oder neuer. Die im Repository enthaltene Datei [LICENSE](LICENSE) enthält den Text der GNU General Public License, Version 3. diff --git a/config.json.example b/config.json.example new file mode 100644 index 0000000..4e0563b --- /dev/null +++ b/config.json.example @@ -0,0 +1,4 @@ +{ + "username": "deine@email.de", + "password": "dein-passwort" +} \ No newline at end of file diff --git a/sma-screensaver b/sma-screensaver new file mode 100644 index 0000000..1b1ea9c --- /dev/null +++ b/sma-screensaver @@ -0,0 +1,266 @@ +#!/usr/bin/env python3 + +""" + SMA-Screensaver - Sunny Portal screensaver + Fork of WebScreensaver: https://github.com/lmartinking/webscreensaver + Fork repository: https://gitea.diehanis.de/Winz/SMA-Screensaver + Modified 2026-09-29: fixed Sunny Portal URL, automatic SMA login, + local JSON credentials and persistent cookies; removed demo selection. + Copyright (C) 2012-2017 Lucas Martin-King & Thomas Reifenberger + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +""" + +import os +import signal +import json +from pathlib import Path +from urllib.parse import urlsplit + +import gi +gi.require_version('Gtk', '3.0') +gi.require_version('GdkX11', '3.0') +gi.require_version('WebKit2', '4.1') + +from gi.repository import Gtk, GdkX11 +from gi.repository import WebKit2 as WebKit + + +SUNNY_PORTAL_URL = "https://ennexos.sunnyportal.com/login" +CREDENTIALS_FILE = Path.home() / ".config/sma-screensaver/credentials.json" +COOKIE_FILE = Path.home() / ".local/share/sma-screensaver/cookies.txt" + +SMA_LOGIN_SCRIPT = """ +(credentials => { + if (location.origin !== 'https://login.sma.energy') return false; + const username = document.getElementById('username'); + const password = document.getElementById('password'); + const form = password && password.form; + const button = form && form.querySelector('button[name="login"][type="submit"]'); + if (!username || !form || username.form !== form || !button || button.disabled || + new URL(form.action, location.href).origin !== location.origin) return false; + + for (const [field, value] of [[username, credentials.username], [password, credentials.password]]) { + field.value = value; + field.dispatchEvent(new Event('input', {bubbles: true})); + field.dispatchEvent(new Event('change', {bubbles: true})); + } + if (!form.checkValidity()) return false; + button.click(); + return true; +}) +""" + + +def load_credentials(): + try: + credentials = json.loads(CREDENTIALS_FILE.read_text(encoding='utf-8')) + except (OSError, ValueError): + raise ValueError(f"Zugangsdaten fehlen oder JSON ist nicht lesbar: {CREDENTIALS_FILE}") from None + if not isinstance(credentials, dict) or any( + not isinstance(credentials.get(key), str) or not credentials[key].strip() + for key in ('username', 'password') + ): + raise ValueError('credentials.json muss username und password als nicht leere Zeichenketten enthalten.') + return {key: credentials[key] for key in ('username', 'password')} + +# The portal renders its login button asynchronously through Angular. +PORTAL_LOGIN_SCRIPT = """ +(() => { + if (location.origin !== 'https://ennexos.sunnyportal.com' || + location.pathname.replace(/\\/$/, '') !== '/login') return; + + let attempts = 0; + const timer = setInterval(() => { + if (location.pathname.replace(/\\/$/, '') !== '/login') { + clearInterval(timer); + return; + } + const button = document.querySelector('ennexos-button#login button, button#login'); + if (button && !button.disabled && + button.getAttribute('aria-disabled') !== 'true' && + button.getClientRects().length > 0) { + clearInterval(timer); + button.click(); + } else if (++attempts >= 120) { + clearInterval(timer); + console.warn('Sunny Portal: login button unavailable after 60 seconds.'); + } + }, 500); +})(); +""" + + +class WebScreensaver(object): + ''' + A simple wrapper for WebKit which works as an XScreensaver Hack + ''' + + def __init__(self, window_id=None, disk_cache=True): + self.window_id = window_id + self.disk_cache = disk_cache + self.login_attempted = False + + self.w = 640 + self.h = 480 + + def setup_window(self): + '''Perform some magic (if needed) to set up a Gtk window''' + if self.window_id: + self.win = Gtk.Window(Gtk.WindowType.POPUP) + + gdk_display = GdkX11.X11Display.get_default() + self.gdk_win = GdkX11.X11Window.foreign_new_for_display(gdk_display, self.window_id) + + # We show the window so we get a Gdk Window, + # then we we can reparent it... + self.win.show() + self.win.get_window().reparent(self.gdk_win, 0, 0) + + x, y, w, h = self.gdk_win.get_geometry() + + # Make us cover our parent window + self.win.move(0, 0) + self.win.set_default_size(w, h) + self.win.set_size_request(w, h) + + self.w, self.h = w, h + else: + self.win = Gtk.Window() + self.win.set_default_size(self.w, self.h) + + def setup_browser(self): + '''Sets up WebKit in our window''' + self.browser = WebKit.WebView() + self.browser.connect('load-changed', self.handle_login) + self.browser.get_user_content_manager().add_script(WebKit.UserScript.new( + PORTAL_LOGIN_SCRIPT, + WebKit.UserContentInjectedFrames.TOP_FRAME, + WebKit.UserScriptInjectionTime.END, + None, + None, + )) + + settings = self.browser.get_settings() + + # Try to enable webgl + try: + settings.set_enable_webgl(True) + except Exception as err: + print("Could not enable WebGL: {}".format(err)) + + # Enable disk caching + if self.disk_cache: + context = self.browser.get_context() + data_manager = context.get_website_data_manager() + context.set_cache_model(WebKit.CacheModel.WEB_BROWSER) + print("Cache directory:", data_manager.props.disk_cache_directory) + + # Take a stab at guessing whether we are running in the + # XScreensaver preview window... + if self.w < 320 and self.h < 240: + self.browser.set_zoom_level(0.4) + + self.browser.set_size_request(self.w, self.h) + + def handle_login(self, view, event): + if event != WebKit.LoadEvent.FINISHED or self.login_attempted: + return + uri = urlsplit(view.get_uri() or '') + if uri.scheme != 'https' or uri.netloc != 'login.sma.energy': + return + # Never resubmit automatically after a rejected login or an MFA challenge. + self.login_attempted = True + try: + credentials = load_credentials() + except ValueError as error: + print(error) + return + script = SMA_LOGIN_SCRIPT + '(' + json.dumps(credentials) + ');' + view.run_javascript(script, None, self.handle_login_result) + + def handle_login_result(self, view, result): + try: + submitted = view.run_javascript_finish(result).get_js_value().to_boolean() + except Exception: + print('Automatischer SMA-Login konnte nicht bestätigt werden.') + return + if submitted: + print('SMA-Anmeldeformular automatisch abgesendet.') + else: + print('Kein verwendbares SMA-Anmeldeformular gefunden; bitte im Fenster prüfen.') + + def setup_cookie_jar(self): + COOKIE_FILE.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + COOKIE_FILE.touch(mode=0o600, exist_ok=True) + COOKIE_FILE.chmod(0o600) + context = self.browser.get_context() + cookie_manager = context.get_cookie_manager() + cookie_manager.set_accept_policy(WebKit.CookieAcceptPolicy.ALWAYS) + cookie_manager.set_persistent_storage(str(COOKIE_FILE), WebKit.CookiePersistentStorage.TEXT) + + def setup_layout(self): + '''Make sure the browser can expand without affecting the window''' + sw = Gtk.Layout() + sw.put(self.browser, 0, 0) + self.win.add(sw) + + def setup(self): + '''Do all the things!''' + self.setup_window() + self.setup_browser() + self.setup_layout() + self.setup_cookie_jar() + + def terminate(*args): + Gtk.main_quit() + + self.win.connect('destroy', terminate) + self.win.connect('delete-event', terminate) + + signal.signal(signal.SIGINT, signal.SIG_DFL) + signal.signal(signal.SIGTERM, terminate) + + self.win.show_all() + + self.browser.load_uri(SUNNY_PORTAL_URL) + + @classmethod + def determine_window_id(cls, win_id=None): + '''Try and get an XID to use as our parent window''' + if not win_id: + win_id = os.getenv('XSCREENSAVER_WINDOW') + + if win_id: + win_id = int(win_id, 16) + + return win_id + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(description='SMA-Screensaver: Sunny Portal screensaver (fork of WebScreensaver)') + parser.add_argument('-window-id', '--window-id', help='XID of Window to draw on') + parser.add_argument('-no-cache', action='store_true', help='Disable disk caching of website data') + args = parser.parse_args() + + saver = WebScreensaver( + window_id=WebScreensaver.determine_window_id(args.window_id), + disk_cache=not args.no_cache + ) + saver.setup() + + Gtk.main()