Files
Winz de0a03febc unny-Portal-Login automatisieren und Fork dokumentieren
- 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
2026-09-29 18:08:16 +02:00

267 lines
8.6 KiB
Python

#!/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()