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