de0a03febc
- 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
267 lines
8.6 KiB
Python
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()
|