diff --git a/docs/public/erste-schritte.md b/docs/public/erste-schritte.md new file mode 100644 index 0000000..8c7b4fc --- /dev/null +++ b/docs/public/erste-schritte.md @@ -0,0 +1,59 @@ +# Erste Schritte mit Enelix + +Vom ersten Überblick zur eingerichteten Anlage: Hier siehst du die Reihenfolge und woran du erkennst, dass du zum nächsten Schritt gehen kannst. Die verlinkten Anleitungen erklären die einzelnen Einstellungen. + +**Für Betreiber:** Beginne mit [Enelix verstehen](index.md). Lass die technische Einrichtung fachkundig durchführen und dir anschließend die Bedienung deiner eigenen Anlage zeigen. **Für Installateure:** Arbeite die folgenden Schritte in dieser Reihenfolge ab. Beginne mit einem einzigen Verbraucher. + +## 1. Die Anlage vorbereiten + +Du brauchst IP-Symcon ab Version 8.0, Zugang zur Verwaltungskonsole und die tatsächlichen Mess- und Geräteanschlüsse deiner Anlage. Besonders wichtig ist die **Netzleistung am Netzanschlusspunkt**: Sie zeigt, ob insgesamt Strom bezogen oder eingespeist wird. Die PV-Produktion allein ersetzt diese Messung nicht. + +Halte fest, welches Gerät zuerst geregelt werden soll und welche Regelung es bisher steuert. Sichere die bestehende Konfiguration. Zwei Regler dürfen nicht unkoordiniert dieselben Ausgänge bedienen. + +**Ergebnis:** Du kannst die relevanten Messwerte in IP-Symcon ansehen, kennst die zulässigen Gerätegrenzen und hast einen Rücksetzweg. Weiter mit [Voraussetzungen und Installation](installation.md). + +## 2. Die Bibliotheken installieren + +Füge **Enelix EMS** über die Modulverwaltung von IP-Symcon hinzu. Installiere **Enelix Utils** zusätzlich, wenn du beispielsweise dessen Diagramme oder Geräteanbindungen benötigst. Verwende die Repository-Adressen und den freigegebenen Kanal aus der [Installationsanleitung](installation.md#2-bibliotheken-hinzufügen). + +Eine installierte Bibliothek stellt zunächst die Modulvorlagen bereit. Sie richtet noch keinen vollständigen Manager und keine Verbindung zu deinen Geräten ein. + +**Ergebnis:** Die benötigten Bibliotheken sind ohne Warnung vorhanden und ihre Module stehen zum Anlegen von Instanzen zur Verfügung. + +## 3. Den Manager einrichten + +Lege eine **Manager-Instanz** an. Lass ihre Regelung zunächst ausgeschaltet. Binde die passende Lizenz, wähle die Netzleistungsmessung und prüfe Einheit und Vorzeichen. Stelle nur Grenzen ein, die für diese Anlage freigegeben sind. + +In der normierten Messung bedeutet **positiv: Netzbezug**, **negativ: Einspeisung**. Ein falsch zugeordnetes Vorzeichen kann zu falschen Regelentscheidungen führen. + +**Ergebnis:** Der Manager hat einen gültigen Lizenzstatus und erkennt eine aktuelle, plausible Netzleistung. Die [Manager-Anleitung](manager.md) führt durch Lizenzierung, Messquelle und Betriebsgrenzen. + +## 4. Einen Verbraucher hinzufügen + +Lege die passende Verbraucherinstanz an, zum Beispiel einen Warmwassererwärmer oder eine Ladestation. Richte dort Geräteverbindung, Messwerte, Leistungsgrenzen und Schutzwerte ein. Anschließend wählst du diese Instanz **im Manager** aus. + +Mit der Priorität bestimmst du die Reihenfolge bei der Verteilung. Kleinere Zahlen bedeuten höhere Priorität. Auch ein hoch priorisiertes Gerät erhält nur eine Vorgabe, die zu seinem gemeldeten Zustand und den eingestellten Grenzen passt. + +**Ergebnis:** Gerät und Manager sind bewusst zugeordnet. Die Rückmeldungen sind plausibel; die Regelung bleibt bis zur Funktionsprüfung ausgeschaltet. Die [Verbraucheranleitung](verbraucher.md) erklärt die Unterschiede der Gerätetypen. + + + +## 5. Unter Aufsicht prüfen + +Prüfe zuerst Lizenzstatus, Messwerte und Störmeldungen. Teste anschließend mit der fachkundigen Person eine kleine, zulässige Vorgabe und vergleiche sie mit dem tatsächlichen Verhalten am Gerät. Prüfe auch Abschaltung und den Umgang mit fehlenden Messwerten. Erst danach kommen weitere Verbraucher hinzu. + +**Ergebnis:** Es ist nachvollziehbar, welches Gerät wann reagiert und wie die Anlage in einen sicheren Zustand zurückkehrt. Eine Anzeige in der Konsole allein ist noch kein Nachweis einer funktionierenden Geräteansteuerung. + +## 6. Die Anlage im Alltag nutzen + +Lass dir in deiner Visualisierung die Netzleistung, die wichtigsten Verbraucherzustände und mögliche Störmeldungen zeigen. Im Manager zeigt **Betriebsart**, ob die Regelung inaktiv, im PV- oder im Peak-Betrieb ist. Die Freigaben am einzelnen Gerät bleiben ebenfalls wichtig. + +Wenn ein Gerät nicht wie erwartet arbeitet, ändere nicht wahllos Prioritäten oder Schutzwerte. Prüfe zuerst Freigabe, Verbindung, Messwerte und Störtext. Die Abschnitte [Im Alltag](manager.md#im-alltag) und [FAQ](faq.md) helfen dabei. + +**Ergebnis:** Du weisst, wo du den Anlagenzustand abliest, welche Bedienhandlungen vorgesehen sind und wen du bei einer Störung kontaktierst. + +## Optional: Auswertungen und weitere Funktionen + +Mit [Enelix Utils](utils.md) kannst du passende Zusatzfunktionen einrichten, etwa ein Energiediagramm oder einen Verbrauchskostenreport. Richte erst die benötigten Messquellen und Archivdaten ein; ein installiertes Auswertungsmodul hat nicht automatisch alle Daten deiner Anlage. + +Welche Lizenzen du für deinen Ausbau benötigst und was sie kosten, siehst du in der [aktuellen Preisliste](preise.md). diff --git a/docs/public/faq.md b/docs/public/faq.md new file mode 100644 index 0000000..7f39f2b --- /dev/null +++ b/docs/public/faq.md @@ -0,0 +1,73 @@ +# Häufige Fragen + +## Brauche ich für die Dokumentation ein Konto? + +Nein. Anleitungen, Modulreferenzen, FAQ und Preisliste sind öffentlich. Ein Konto wird für die persönlichen Funktionen des Lizenzportals benötigt. + +## Welche IP-Symcon-Version wird benötigt? + +Enelix EMS und Enelix Utils setzen IP-Symcon ab Version 8.0 voraus. Maßgeblich ist der Kernel der Installation, nicht nur die Version der Verwaltungskonsole. + +## Muss ich EMS und Utils gemeinsam installieren? + +Nein. Utils ist eine eigenständige Bibliothek. Installiere sie zusätzlich, wenn du ihre Module oder entsprechende Manager-Visualisierungen benötigst. Die Funktions- und Lizenzvoraussetzungen des gewählten Moduls gelten weiterhin. + +## Welchen Updatekanal soll ich wählen? + +Verwende den für deine Anlage freigegebenen Kanal: `main` entspricht Stable, `beta` Beta und `develop` Testing. Vergleiche den installierten Stand mit dem oben angegebenen Dokumentationskanal. Testsoftware benötigt eine kontrollierte Inbetriebnahme. + +## Warum bleibt der Manager inaktiv? + +Prüfe `Aktiv`, Lizenzstatus und Netzleistungsmessung. Fehlende, veraltete oder falsch normierte Messwerte verhindern korrekte Regelung. Kontrolliere anschließend Verbraucherzuordnung und Störtext. Die [Manager-Anleitung](manager.md) beschreibt die Reihenfolge. + +## Wo ordne ich einen Verbraucher zu? + +Im Manager. Verbraucher haben keine eigene Manager-ID-Property. Die automatische Suche zeigt Kandidaten; ausgewählt und aktiviert werden sie gezielt. + +## Warum wird ein Verbraucher trotz Überschuss nicht eingeschaltet? + +Mögliche Ursachen sind lokale Deaktivierung, fehlende Zuordnung, fehlende Lizenzmenge, Gerätefehler, ein zu kleines Budget, Temperaturbedingungen oder Mindestzeiten. Prüfe auch das angebotene Leistungsraster und den aktuellen Betriebszustand. Bei einer Ladestation gehören Fahrzeugstatus, Ladefreigabe und Solarladen dazu. + +## Was bedeuten positive und negative Leistungen? + +Am Netzanschlusspunkt bedeutet positiv Netzbezug und negativ Einspeisung. Bei Batterien bedeutet positiv Laden und negativ Entladen. Die Messfaktoren müssen die Gerätewerte auf diese Konvention und Watt normieren. + +## Wie funktionieren Prioritäten? + +Kleinere Zahlen haben Vorrang. PV- und Peak-Priorität werden pro Verbraucher eingestellt. Mindestleistungen und technische Sperren bleiben verbindlich. Der aktuelle schrittweise Verteilalgorithmus ist in der [Manager-Referenz](../module/Manager/README.md) beschrieben. + +## Schaltet der Manager-Aus-Schalter die gesamte Anlage sicher ab? + +Nein. Er ersetzt keine elektrische Freischaltung oder unabhängige Schutztechnik. Verbraucher können lokale Betriebs- und Schutzfunktionen besitzen. Für Arbeiten an der Anlage gilt das dafür vorgesehene Sicherheitsverfahren. + +## Kann ich einen Lizenzcode für mehrere Installationen verwenden? + +Die Aktivierung bindet den Code an eine Installations-ID. Eine andere Installation wird abgewiesen. Sichere bei einer Migration die vollständige Manager-Instanz einschließlich ihrer Attribute; kläre einen erforderlichen Gerätewechsel über das Portal beziehungsweise den Betreiber. + +## Was passiert ohne Internetverbindung? + +Eine bereits bestätigte Manager-Freigabe kann im dokumentierten Entwicklungsstand bis `offlineUntil` genutzt werden, ungefähr 14 Tage nach Ausstellung. Danach wird die Regelung bis zu einer gültigen Serverantwort gesperrt. Verlasse dich auf den tatsächlich angezeigten Lizenzstatus; Cloud-Geräte und Prognosen haben zusätzliche Netzwerkabhängigkeiten. + +## Was unterscheidet Lizenz und Ersteinrichtung? + +Die direkte Lizenzbestellung enthält die gewählten Lizenzen. Beim Systemkonfigurator kommen die noch nicht bezahlten Einrichtungskosten hinzu. Die [Preisliste](preise.md) zeigt beide Positionen getrennt. Prüfe vor Abschluss die vollständige Bestellung. + +## Sind Prognoselizenzen automatisch verlängerte Abonnements? + +Der aktuelle Portalstand verwendet datierte Jahresberechtigungen und manuelle Verlängerung, keine automatische Stripe-Abonnementverlängerung. Die Preisseite zeigt die im Katalog hinterlegte Laufzeit. Der intelligente Netzfahrplan benötigt den Manager mit Peak Shaving. + +## Sind die Preise aktuell und inklusive MwSt.? + +Die [Preisliste](preise.md) liest den aktuellen Backend-Katalog. Sie zeigt Netto- und berechnete Bruttopreise einschließlich des aktuell gültigen, im Backend konfigurierten MwSt.-Satzes. Bei einem Abruffehler werden keine Ersatzpreise angezeigt. + +## Aktualisiert ein Git-Update auch meine Anlage? + +Nein. Dokumentationsabgleich und Modulupdate sind getrennt. IP-Symcon-Module werden über die Modulverwaltung aktualisiert, mit Sicherung und Nachkontrolle. + +## Warum zeigt das Energiediagramm keine Werte? + +Prüfe die drei Pflichtzähler, deren Archivierung, die Einheit beziehungsweise den Faktor und den gewählten Zeitraum. Das Modul benötigt Energiezähler, nicht nur momentane Leistungsmessungen. + +## Welche Daten sollte ich bei einem Fehler bereithalten? + +Notiere Modulname, Version beziehungsweise Kanal, Instanzstatus, genaue Fehlermeldung, Zeitpunkt und die letzten Änderungen. Ergänze relevante Messwerte mit Einheiten. Entferne Lizenzcodes, Kontodaten, Passwörter und Tokens aus Screenshots und Protokollen. diff --git a/docs/public/images/symcon-manager.png b/docs/public/images/symcon-manager.png new file mode 100644 index 0000000..0353a05 Binary files /dev/null and b/docs/public/images/symcon-manager.png differ diff --git a/docs/public/images/symcon-objektbaum.png b/docs/public/images/symcon-objektbaum.png new file mode 100644 index 0000000..9325569 Binary files /dev/null and b/docs/public/images/symcon-objektbaum.png differ diff --git a/docs/public/index.md b/docs/public/index.md new file mode 100644 index 0000000..2834b6a --- /dev/null +++ b/docs/public/index.md @@ -0,0 +1,63 @@ +# Enelix verstehen + +**Enelix ist ein Energiemanagementsystem, kurz EMS.** Es hilft dabei, den Strom einer Anlage gezielt zu nutzen: zum Beispiel Solarstrom für Warmwasser oder das Elektroauto einzusetzen und den Bezug aus dem Stromnetz zu begrenzen. + +Enelix läuft in **IP-Symcon** und besteht aus zwei Bibliotheken: **Enelix EMS** enthält Manager und Verbrauchermodule; **Enelix Utils** ergänzt Geräteanbindungen und Auswertungen. Du richtest einen **Manager** ein und wählst aus, welche **Verbraucher** er steuern darf. Über IP-Symcon erhält er die Messwerte und Geräteverbindungen. Die laufende Regelung übernimmt anschließend das System innerhalb der eingestellten Grenzen. + +Du musst dafür nicht programmieren. Die technische Einrichtung und die Prüfung der Geräteansteuerung gehören jedoch in fachkundige Hände. Diese Einführung erklärt dir erst das Prinzip und danach den Weg zur eigenen Anlage. + +## Ein Beispiel aus dem Alltag + +Mittags erzeugt deine Solaranlage mehr Strom, als das Haus gerade braucht. Statt den gesamten Überschuss einzuspeisen, kann Enelix ihn einem eingerichteten Verbraucher anbieten, etwa dem Warmwassererwärmer oder einer Ladestation. + +**Vereinfachtes Beispiel:** Die Solaranlage liefert 6 kW, im Haus werden 2 kW gebraucht. Damit bleiben zunächst 4 kW Überschuss. Der Manager berücksichtigt, welche Geräte gerade Energie benötigen, welche Leistung sie annehmen können und welche Priorität eingestellt ist. Er verteilt daraus passende Vorgaben. Zieht eine Wolke auf oder steigt der Hausverbrauch, passt er die Verteilung an. + +Nicht jedes Gerät kann beliebig wenig Leistung aufnehmen oder sofort abschalten. Deshalb berücksichtigt die Einrichtung auch Leistungsstufen, Mindestlaufzeiten, Temperaturen und weitere Gerätegrenzen. Die Zahlen im Beispiel erklären nur das Prinzip und sind keine Einstellvorgaben. + +## So setzt sich Enelix zusammen + +| Baustein | Einfach erklärt | +| --- | --- | +| **IP-Symcon** | Die Softwarebasis deiner Anlage. Hier liegen Messwerte, Geräteanbindungen und die Enelix-Instanzen. | +| **Enelix EMS** | Die Bibliothek für das Energiemanagement. Sie enthält den Manager und die passenden Module für Verbraucher und Batterien. | +| **Manager** | Die zentrale Regelung. Er beobachtet die Netzleistung und verteilt Vorgaben an die Geräte, die du ihm zugeordnet hast. | +| **Verbraucher** | Ein angebundenes Gerät, etwa Warmwassererwärmer, Wärmepumpe oder Ladestation. Sein Modul kennt die gerätespezifischen Grenzen und setzt die Vorgaben um. | +| **Enelix Utils** | Eine zweite Bibliothek mit Zusatzfunktionen, zum Beispiel Energiediagramm, Shelly-Anbindung und Verbrauchskostenreport. Sie kann auch unabhängig vom EMS genutzt werden. | +| **Lizenzportal** | Hier verwaltest du die benötigten Lizenzen. Die Regelung deiner Geräte läuft in IP-Symcon, nicht auf dieser Dokumentationsseite. | + +Die beiden Bibliotheken sind also zwei zusammenpassende Werkzeugkästen, keine zwei getrennten EMS. Für die Regelung beginnst du mit **Enelix EMS**. **Enelix Utils** kommt dazu, wenn du dessen Zusatzmodule oder die entsprechenden Darstellungen nutzen möchtest. + + + +## Was macht der Manager genau? + +1. **Messen:** Er liest, ob deine Anlage gerade Strom aus dem Netz bezieht oder einspeist. +2. **Bedarf berücksichtigen:** Die ausgewählten Verbraucher melden ihren Zustand und ihre möglichen Leistungen. +3. **Verteilen:** Der Manager berücksichtigt Betriebsart, Prioritäten und eingestellte Grenzen. +4. **Umsetzen:** Das jeweilige Verbrauchermodul übersetzt die Vorgabe in die passende Geräteansteuerung. + +Beim **Solarladen beziehungsweise PV-Betrieb** steht die Nutzung des Solarüberschusses im Vordergrund. **Peak Shaving** bedeutet, hohe Bezugsspitzen gegenüber einer eingestellten Grenze zu begrenzen. Diese Funktion benötigt die entsprechende Lizenz und eine passende Einrichtung. Ein Batteriemodul kann zusätzlich Laden und Entladen in die Regelung einbinden. + +Enelix erkennt und steuert nicht automatisch jedes Gerät im Gebäude. Messquellen, Geräteverbindungen und die Zuordnung zum Manager müssen bewusst eingerichtet und geprüft werden. + +## Welche Begriffe brauche ich zum Start? + +- Eine **Bibliothek** ist ein installierbares Paket, hier Enelix EMS oder Enelix Utils. +- Ein **Modul** ist die Vorlage für eine Funktion, zum Beispiel „Manager“ oder „Wassererwärmer“. +- Eine **Instanz** ist die konkret eingerichtete Verwendung eines Moduls in deiner Anlage. Zwei Ladestationen erhalten zum Beispiel je eine eigene Instanz. +- Eine **Variable** enthält einen Wert oder einen bedienbaren Zustand in IP-Symcon, etwa Netzleistung, Temperatur oder eine Freigabe. +- Die **Visualisierung** ist die Oberfläche, auf der du deine Anlage im Alltag ansehen und bedienen kannst. Die technische Einrichtung erfolgt in der Verwaltungskonsole. + +## Wie geht es weiter? + +**Du möchtest die erste Anlage einrichten?** Folge den [Ersten Schritten](erste-schritte.md). Sie führen von den Voraussetzungen bis zur kontrollierten Inbetriebnahme und verlinken jeweils die passende Anleitung. + +**Deine Anlage ist bereits eingerichtet?** Unter [Manager bedienen](manager.md#im-alltag) findest du die wichtigsten Zustände und die ersten Prüfungen bei Problemen. Die [häufigen Fragen](faq.md) helfen bei typischen Unklarheiten. + +**Du suchst einzelne Einstellungen?** Die [Verbraucheranleitung](verbraucher.md), die [Utils-Anleitung](utils.md) und die Modulreferenzen in der Navigation gehen ins Detail. Die [Preisseite](preise.md) zeigt die aktuellen Preise aus dem Lizenzportal. + +## Stand und Sicherheit + +Enelix befindet sich im kontrollierten Testbetrieb. Die Software ersetzt keine elektrischen Schutzfunktionen und keine fachkundige Inbetriebnahme. Ein ausgeschalteter Manager ist kein elektrischer Not-Aus. + +Diese Dokumentation folgt dem unten angegebenen Git-Kanal. Prüfe, ob deine installierte Version dazu passt. Ein Update der Dokumentation aktualisiert deine laufende IP-Symcon-Anlage nicht. diff --git a/docs/public/installation.md b/docs/public/installation.md new file mode 100644 index 0000000..57c4897 --- /dev/null +++ b/docs/public/installation.md @@ -0,0 +1,53 @@ +# Enelix installieren + +## 1. Voraussetzungen prüfen + +- IP-Symcon ab Version 8.0 und Zugriff auf die Verwaltung. +- Netzwerkzugriff von IP-Symcon auf die Enelix-Git-Repositories; erforderliche Repository-Berechtigungen müssen vorhanden sein. +- Für lizenzierte Funktionen: ein Portal-Konto und eine passende Lizenz. Das Symcon-System muss den Lizenzserver per HTTPS erreichen können. +- Funktionsfähige Geräteanbindungen mit bekannten Mess- und Stellvariablen. IP-Adressen, Zugangsdaten und elektrische Grenzen stammen aus deiner Anlage. + +Sichere vor Änderungen die IP-Symcon-Konfiguration einschließlich Instanzen und Attributen. Halte fest, welche bestehende Regelung aktiv ist und wie du sie kontrolliert wiederherstellen kannst. Zwei Regler dürfen nicht unkoordiniert dieselben Ausgänge steuern. + +## 2. Bibliotheken hinzufügen + +Öffne in der IP-Symcon-Verwaltung den Bereich **Module** und füge die benötigten Repository-URLs hinzu: + +| Bibliothek | Repository | +| --- | --- | +| Enelix EMS | `https://git.belevo.ch/ENELIX/Enelix-EMS.git` | +| Enelix Utils | `https://git.belevo.ch/ENELIX/Enelix-Utils.git` | + +Wähle einen für deine Umgebung freigegebenen Kanal. Installiere Utils, wenn du dessen Zusatzmodule oder die entsprechenden Manager-Visualisierungen nutzen möchtest. + +| Branch | Kanal | Einordnung | +| --- | --- | --- | +| `main` | Stable | stabiler Freigabekanal | +| `beta` | Beta | freigegebene Feldtests | +| `develop` | Testing | Entwicklung und Integrationstests | + +Die Kanalbezeichnung allein ist keine Anlagenfreigabe. Beachte zusätzlich den Status der jeweiligen Version. Die öffentliche Dokumentation kann einen neueren Stand zeigen als deine Installation. + +Zugangsdaten gehören in die vorgesehene Laufzeitkonfiguration, nicht in Repository-URLs, Skripte oder Screenshots. Kopiere die Modulordner nicht manuell: Sonst kann die Modulverwaltung URL, Branch und Aktualisierungen nicht zuverlässig verwalten. + +## 3. Installation kontrollieren + +Prüfe, dass die Bibliothek ohne Warnsymbol angezeigt wird und URL sowie Branch sichtbar sind. Lege zunächst nur die benötigten Instanzen an. Für das EMS beginnst du mit dem [Manager](manager.md), danach folgen die [Verbraucher](verbraucher.md). + +Lass Manager und Verbraucher während der Grundeinrichtung deaktiviert. Prüfe zuerst Messwerte, Einheiten, Vorzeichen und die Zuordnung der Stellvariablen. Eine gültige Konfiguration ersetzt keinen physischen Funktionstest. + +## 4. Lizenz und Einrichtung + +Im [Lizenzportal](/) kannst du ein Konto anlegen und die erforderlichen Lizenzen wählen. Die [Preisliste](preise.md) unterscheidet Lizenzkosten und Ersteinrichtung. Eine direkte Lizenzbestellung und eine Bestellung über den Systemkonfigurator sind unterschiedliche Abläufe. + +Bei Verwendung des Systemkonfigurators prüfst du die erzeugte Konfiguration vor der Ausführung in IP-Symcon. Führe ein Einrichtungspaket nicht ungeprüft auf einer bestehenden Anlage aus. Bewahre Sicherung und Rücksetzweg auf. + +## 5. Aktualisieren + +1. Änderungsumfang und passenden Kanal prüfen. +2. Konfiguration, Instanzen und Attribute sichern; bei Regelungsänderungen einen sicheren Anlagenzustand herstellen. +3. Updates über die IP-Symcon-Modulverwaltung beziehen. +4. Instanzstatus, Lizenzbindung, Messquellen und Verbraucherzuordnung kontrollieren. +5. Änderungen an Ausgängen unter Aufsicht prüfen, bevor der normale Betrieb wieder freigegeben wird. + +Eine neue Manager-Instanz erhält eine neue Installations-ID. Ein Update einer bestehenden Instanz ist deshalb nicht gleichbedeutend mit Löschen und Neuanlegen. diff --git a/docs/public/manager.md b/docs/public/manager.md new file mode 100644 index 0000000..cbf81c1 --- /dev/null +++ b/docs/public/manager.md @@ -0,0 +1,53 @@ +# Manager einrichten und bedienen + +Der Manager liest die Netzleistung und verteilt Leistung im PV- oder Peak-Betrieb. Er steuert ausschließlich die ausgewählten Verbraucher. + +## 1. Instanz und Lizenz vorbereiten + +Lege eine Manager-Instanz aus Enelix EMS an. Die Variable `Aktiv` bleibt zunächst ausgeschaltet. Öffne die Konfiguration, trage den Aktivierungscode aus dem Lizenzportal im Bereich **Lizenzierung** ein und wähle **Lizenz prüfen und binden**. Kontrolliere den Lizenzstatus und speichere die Konfiguration anschließend mit **Übernehmen** oder **OK**. + +Ohne gültige Manager-Berechtigung bleibt die Instanz mit Status `203` gesperrt. Ein Code ist an die Installations-ID gebunden; verwende ihn nicht zum Einrichten einer zweiten Anlage. + +## 2. Netzleistung einstellen + +Wähle die Messvariable am Netzanschlusspunkt über `NetzleistungVariableID`. Die Normierung erfolgt mit `Netzleistungsfaktor`: + +- positive Leistung bedeutet Netzbezug; +- negative Leistung bedeutet Einspeisung; +- die normierte Einheit ist Watt, nicht Kilowatt. + +Prüfe das Vorzeichen bei einem bekannten Betriebszustand. `MesswertMaxAlter` begrenzt das zulässige Alter der Quelle. Eine unveränderte Messung kann weiterhin aktuell sein; entscheidend ist die echte Aktualisierung der Quelle. Fehlende oder veraltete Netzleistung verhindert neue Regelvorgaben. + +## 3. Betriebsgrenzen festlegen + +`SollwertSolarladen` legt die gewünschte Netzleistung im PV-Betrieb fest. Beginne mit der für deine Anlage freigegebenen Einstellung. + +Für Peak Shaving benötigt der Manager die entsprechende Lizenz. `Lastspitzenmodus` kennt **Aus**, **Konstant** und **Monatlich**. Bei konstantem Modus wird `Lastspitzengrenze` in Watt verwendet; im monatlichen Modus werden zwölf Monatswerte gepflegt. **Aus** deaktiviert nur die Peak-Begrenzung, nicht das Solarladen. + +Eine Einspeisebegrenzung benötigt geeignete Mess- und Stellregister der Wechselrichter. Übernimm keine beispielhaften Grenzen als Anlagenwerte. Die vollständigen Einstellungen stehen in der [Manager-Referenz](../module/Manager/README.md). + +## 4. Verbraucher auswählen + +Nutze die automatische Suche oder die manuelle Zuordnung. Gefundene Instanzen müssen gezielt ausgewählt und für die Zuordnung aktiviert werden. Die Verbindung wird im Manager gepflegt; im Verbraucher gibt es keine Manager-ID-Property. + +Prioritäten stellst du am jeweiligen Verbraucher ein. Kleinere Zahlen bedeuten höhere Priorität. Die aktuelle Verteilregel und ihre Beispiele stehen in der [Manager-Referenz](../module/Manager/README.md). + + + +## 5. Anlage und Visualisierung ergänzen + +Unter Anlagentopologie werden Wechselrichter, PV-Flächen und Batterien mit ihren Messquellen erfasst. Verwende eindeutige Kennungen; PV-Flächen und Batterien müssen auf einen vorhandenen Wechselrichter verweisen. + +Für die Energieaufzeichnung werden Netz- und PV-Leistung benötigt. Vollständige Energiezähler verbessern die Bilanzierung; ohne vollständigen Zählersatz integriert der Manager Leistungswerte. Energiezähler und Leistungswerte sind nicht austauschbar. + +Die Optionen für Energy Pie, Diagramme, Energy Facts und Energiefluss verwalten die zugehörigen Visualisierungen. Prüfe bestehende angepasste Darstellungen vor Änderungen. Prognosefunktionen sind optional und benötigen passende Berechtigungen sowie eine gültige Topologie. + +## 6. Kontrolliert in Betrieb nehmen + +Prüfe Lizenzstatus, Netzleistung, Verbraucherstatus und Störtext bei ausgeschalteter Regelung. Nimm zunächst einen Verbraucher unter Aufsicht in Betrieb und vergleiche Sollwert mit physischem Verhalten. Prüfe außerdem Abschaltung und Verhalten bei fehlenden Messwerten, bevor weitere Geräte hinzukommen. + +## Im Alltag + +`Aktiv` schaltet die Manager-Regelung ein oder aus. `Betriebsart` zeigt **Inaktiv**, **PV** oder **Peak**. Verbraucher können eigene Mindestzeiten, Temperaturanforderungen und lokale Schutzfunktionen haben. Ein deaktivierter Manager ist deshalb kein universeller elektrischer Not-Aus. Verwende für Arbeiten an der Anlage die vorgesehenen technischen Sicherheitsmaßnahmen. + +Bei Problemen kontrollierst du zuerst `Lizenzstatus`, `NetzleistungGueltig`, `Sammelstoerung`, `Stoertext` und anschließend die betroffene Verbraucherinstanz. Diagnosevariablen und Debug-Logging können gezielt eingeblendet werden; teile keine Lizenzcodes oder Zugangsdaten. diff --git a/docs/public/preise.md b/docs/public/preise.md new file mode 100644 index 0000000..1d6fb76 --- /dev/null +++ b/docs/public/preise.md @@ -0,0 +1,17 @@ +# Preise + +Alle im öffentlichen Backend-Katalog geführten Produkte, mit Lizenzpreis, Laufzeit und Ersteinrichtung. Die Beträge werden direkt aus dem Lizenzportal geladen. + +## So liest du die Preisliste + +Die Lizenzpreise gelten pro aufgeführter Einheit. Einrichtungskosten sind einmalige separate Positionen und werden nicht automatisch zum Lizenzpreis addiert. Bei der direkten Lizenzbestellung wird keine Ersteinrichtung berechnet; der Systemkonfigurator berücksichtigt die noch nicht bezahlte Einrichtung. + +Jahresberechtigungen werden mit ihrer Laufzeit ausgewiesen. Für den intelligenten Netzfahrplan ist der Manager mit Peak Shaving erforderlich. Der Verbrauchskostenreport benötigt eine Grundlizenz und die passenden Zählerkontingente. + +Die Bruttowerte sind aus den Einzelpreisen und dem aktuellen Backend-MwSt.-Satz berechnet. Der Checkout berechnet die MwSt. auf den Bestellgesamtbetrag; bei mehreren Positionen können Rundungsdifferenzen gegenüber der Summe einzelner Bruttowerte entstehen. Maßgeblich ist die Bestellung vor dem Abschluss. + +## Weitere Kosten und Verfügbarkeit + +Die Liste umfasst den Enelix-Produktkatalog, keine externen Kosten für IP-Symcon, Hardware, Installationsarbeiten oder Drittanbieter. Die Anzeige eines Katalogprodukts bestätigt keine technische Freigabe für jede Anlage. Prüfe Gerätekompatibilität und den angebotenen Bestellumfang im Portal. + +Das Portal ist derzeit als Entwicklungsumgebung gekennzeichnet; Zahlungen erfolgen im Stripe-Sandbox-Modus. Eine produktive Zahlungsfreigabe wird durch diese Preisliste nicht erteilt. diff --git a/docs/public/utils.md b/docs/public/utils.md new file mode 100644 index 0000000..f7dcd60 --- /dev/null +++ b/docs/public/utils.md @@ -0,0 +1,37 @@ +# Enelix Utils nutzen + +Die Utils-Bibliothek enthält eigenständige Zusatzmodule. Installiere sie über die IP-Symcon-Modulverwaltung und lege nur die benötigten Instanzen an. Ein EMS-Manager ist für die Bibliothek nicht erforderlich; einzelne Module besitzen eigene Voraussetzungen und gegebenenfalls eigene Lizenzanforderungen. + +## Energiediagramm + +Wähle die archivierten, fortlaufenden Zähler für Produktion, Einspeisung und Netzbezug. Eine direkte Verbrauchsquelle ist optional. Kontrolliere `Zaehlerfaktor` und die Einheit kWh. Leistungsvariablen in Watt sind keine Energiezähler. + +Wähle einen Zeitraum mit vorhandenen Archivdaten und prüfe Energiebilanz, Eigenverbrauch und Autarkie. Fehlende Archivdaten werden als Diagnose angezeigt. [Energiediagramm-Referenz](../../../Enelix-Utils/docs/module/Energiediagramm/README.md). + +## Shelly Modul + +Richte zuerst den nativen MQTT-Datenfluss in IP-Symcon und MQTT am Shelly-Gerät ein. Das Modul verarbeitet Shelly-NG-Meldungen der Generationen 2, 3 und 4; alte Gen1-Topics sind nicht Teil dieser Anbindung. + +Wähle die gewünschten Datenpunktgruppen. Aktiviere einen Topic-Filter nur, wenn er benötigt wird, und trage dann den tatsächlichen Präfix ein. Prüfe Online-Status und Messwerte, bevor Schaltausgänge getestet werden. Broker- und Gerätezugangsdaten werden nicht im Modul verwaltet. [Shelly-Referenz](../../../Enelix-Utils/docs/module/Shelly-Modul/README.md). + +## Verbrauchskostenreport + +Dieses Modul benötigt seine Grundlizenz sowie passende Kontingente für Strom- und Nebenzähler. Ordne Benutzer und Zähler zu, pflege Tarifzeiträume und Einheiten und prüfe den gewählten Abrechnungszeitraum. + +Die Aktion `CreateReport` erstellt das PDF-Medium `ReportPDF`. Für einen aktivierten QR-Zahlteil sind vollständige strukturierte Empfängerangaben erforderlich. Steuer- und Abrechnungseinstellungen müssen fachlich geprüft werden; ein Code-Standardwert ist keine steuerliche Festlegung. [Verbrauchskostenreport-Referenz](../../../Enelix-Utils/docs/module/Verbrauchskostenreport/README.md). + +## CC100 Hardware + +Das Modul bindet die dokumentierten Ein- und Ausgänge der CC100-Hardware ein. Prüfe Kanalzuordnung, Signalanpassung und physische Verdrahtung anhand der [CC100-Referenz](../../../Enelix-Utils/docs/module/CC100-Hardware/README.md). Teste Ausgänge erst nach Prüfung der angeschlossenen Verbraucher und des sicheren Anlagenzustands. + +## Virtuelle Batterie + +Trage die physischen Batterien mit Kapazitäten, Leistungsgrenzen, Ladezustands- und Leistungsquellen ein. Die virtuelle Batterie ist die einzige Instanz, die deren physische Sollwerte schreibt. Ein EMS-Batteriemodul nutzt die Eigenverbrauchs-Proxyregister; eine VGT-Anbindung nutzt die SDL-Variablen. + +Prüfe Reserven, Zeitüberschreitungen und Messwertalter. Teste Eigenverbrauch und SDL zuerst getrennt, anschließend gemeinsam. SDL hat bei der Verteilung Vorrang. [Referenz zur virtuellen Batterie](../../../Enelix-Utils/docs/module/Virtuelle-Batterie/README.md). + +## VGT-Schnittstelle + +Ordne den MQTT-Parent zu und übernimm den vereinbarten Topic-Suffix. Wähle Geräteart, Messquellen und bedienbare Zielvariable. Im Batteriemodus wird an die virtuelle Batterie angebunden, nicht direkt an physische Register. + +Prüfe zuerst die Rückmeldungen. Steueraufträge und Wiederherstellungsfunktionen dürfen erst im freigegebenen Testumfang erprobt werden. [VGT-Referenz](../../../Enelix-Utils/docs/module/VGT-Schnittstelle/README.md). diff --git a/docs/public/verbraucher.md b/docs/public/verbraucher.md new file mode 100644 index 0000000..38f7736 --- /dev/null +++ b/docs/public/verbraucher.md @@ -0,0 +1,55 @@ +# Verbraucher einrichten + +Ein Verbraucher übersetzt ein Leistungsangebot und die Manager-Vorgabe in die konkrete Geräteansteuerung. Die Einrichtung erfolgt zuerst am Gerät und anschließend im Manager. + +## Gemeinsamer Ablauf + +1. Passendes Modul als Instanz anlegen und `Aktiv` zunächst ausgeschaltet lassen. +2. Messquellen, Geräteanschluss und bedienbare Stellvariablen eintragen. Ein angezeigter Variablenwert allein beweist keine erfolgreiche Geräteansteuerung. +3. Leistungsgrenzen, Mindestzeiten und Schutzwerte aus den tatsächlichen Gerätedaten übernehmen. +4. `PrioritaetPV` und `PrioritaetPeak` festlegen. Kleinere Werte bedeuten höhere Priorität. +5. Konfiguration speichern und Instanzstatus sowie Messwerte prüfen. +6. Den Verbraucher im Manager auswählen. Keine Manager-ID im Verbraucher eintragen. +7. Unter Aufsicht Freigabe, kleine Sollwerte, Rückmeldung und Abschaltung prüfen. + +`Meldeintervall` bestimmt die Rückmeldungen. `VorgabeTimeout` begrenzt die Gültigkeit einer nicht erneuerten Vorgabe. Mindestlaufzeiten und Schaltsperren können schnelle Änderungen verhindern; ändere sie nicht nur, um eine Reaktion zu erzwingen. + +## Einstufiger Verbraucher + +Trage die elektrische `Nennleistung` und eine Boolean-Schaltvariable mit funktionierender Aktion unter `SchaltkontaktVariableID` ein. Eine optionale `RueckmeldungVariableID` bestätigt den physischen Schaltzustand. Mindest-Ein-/Aus-Zeiten und Tagesmindestlaufzeit müssen zum Gerät passen. + +Bei fälliger Tagesmindestlaufzeit kann das Gerät auch ohne PV-Überschuss Leistung anfordern. Prüfe das gewünschte Peak-Verhalten ausdrücklich. [Vollständige Referenz](../module/Verbraucher-1-Stufig/README.md). + +## Warmwassererwärmer + +Wähle den Temperaturfühler und konfiguriere jede positive Leistungsstufe mit eigener Boolean-Schaltvariable. Der Zustand `0 W` wird automatisch ergänzt. Stufen müssen elektrisch zulässig und eindeutig sein. Prüfe Mindesttemperatur, Hysterese, Zeitplan und Legionellenfunktion fachkundig; die Software ersetzt keine unabhängigen Temperatur- und Überhitzungsschutzfunktionen. [Vollständige Referenz](../module/Wassererwaermer/README.md). + +## Pufferspeicher + +Hinterlege Puffertemperatur, Außentemperatur, Heizkurve und Schaltkontakte der Leistungsstufen. Eine optionale Solltemperaturquelle der Wärmepumpe kann eingebunden werden. Kontrolliere die resultierende Einschaltschwelle und Hysterese. Im dokumentierten Stand bietet der Pufferspeicher im Peak-Betrieb nur `0 W` an. [Vollständige Referenz](../module/Pufferspeicher/README.md). + +## Wärmepumpe + +Wähle Sperre/Erhöhung oder SG Ready, ordne die beiden Kontakte zu und kontrolliere ihre tatsächliche Logik. Hinterlege Nennleistung und die verpflichtende Leistungs- oder Boolean-Betriebsrückmeldung. Prüfe Herstellerfreigabe, Kontaktbelegung und Mindestzeiten. [Vollständige Referenz](../module/Waermepumpe/README.md). + +## Ladestation direkt anbinden + +Für unterstützte go-e-Varianten und smart-me Pico wird **Ladestation Stand-Alone** verwendet. Wähle den richtigen Typ. Bei go-e gehören IP-Adresse oder Hostname ohne Protokoll, Pfad oder Parameter in die Geräteadresse. Pico benötigt seine vorgesehenen Geräte- und Kontodaten. + +Prüfe Stromgrenzen, Fahrzeug-/Phasenerkennung und Sperrzeiten. `Aktiv`, `Ladefreigabe` und `Solarladen` haben unterschiedliche Aufgaben. Kontrolliere sie zusammen mit dem Fahrzeugstatus, wenn keine Ladeleistung angeboten wird. [Vollständige Referenz](../module/Ladestation-Stand-Alone/README.md). + +## Easee über Gateway anbinden + +Richte zuerst ein **Easee Gateway** für das Konto ein. Lege je Ladestation eine Instanz **Ladestation Gateway** mit passender Seriennummer an und verbinde sie mit dem Gateway. Zugangsdaten bleiben im Gateway. Prüfe Verbindung, Fahrzeugstatus und Phasenmeldung, bevor du die Station im Manager aktivierst. + +[Gateway-Referenz](../module/Easee-Gateway/README.md) und [Ladestations-Referenz](../module/Ladestation-Gateway/README.md). + +## Batterie + +Wähle den passenden Batterieadapter. Hinterlege dynamische Lade-/Entladegrenzen, Ladezustand, Netzleistung, Istleistung und die erforderlichen bedienbaren Register. Positive Batterieleistung bedeutet Laden, negative Entladen. Prüfe besonders Watt gegenüber Kilowatt und die Herstellerabbildung. + +Setze Ladezustandsgrenzen und Reserve gemäß Anlagenfreigabe. Bei einer virtuellen Batterie dürfen physische Register nicht gleichzeitig von mehreren Reglern beschrieben werden. Prüfe zunächst ohne aktive Enelix-Regelung alle Vorzeichen und Messwerte. [Vollständige Referenz](../module/Batterie/README.md). + +## Diagnose + +Prüfe nacheinander lokale Freigabe, Managerzuordnung, Lizenzumfang, Geräteverbindung, aktuelle Messwerte und Störtext. Ein Verbraucher kann verfügbar sein und trotzdem wegen einer Mindestzeit nur seine aktuelle Leistung anbieten. Details zu `Verfuegbar`, `AenderungMoeglich` und `SollwertGueltig` stehen in der [gemeinsamen Schnittstelle](../Schnittstelle.md). diff --git a/tools/public-docs/.gitignore b/tools/public-docs/.gitignore new file mode 100644 index 0000000..8d3abcf --- /dev/null +++ b/tools/public-docs/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +state/ +preview/ +preview-state/ +qa/ +backups/ +failure-test-*/ +guides/ diff --git a/tools/public-docs/README.md b/tools/public-docs/README.md new file mode 100644 index 0000000..a9d5c91 --- /dev/null +++ b/tools/public-docs/README.md @@ -0,0 +1,190 @@ +# Public Enelix Documentation Publisher + +## Context and decision + +The license portal already serves public static files and exposes its effective +catalog at `GET /api/catalog`. Documentation is therefore built as static HTML; +the existing portal process, authentication, database and payment code are not +modified. Generic Utils runtime code is not coupled to the documentation service. + +Source ownership: + +- `docs/public/*.md` in Enelix-EMS: installation, operator guides, FAQ and pricing explanation. +- `docs/public/images/*.png`: explicitly reviewed real Symcon screenshots, allowlisted in `images.mjs`. +- Explicit README/interface allowlists in `config.mjs`: original EMS and Utils references. +- Backend catalog: every monetary value, product name, SKU, term and effective VAT rate. +- This directory: rendering, navigation, publication and tests. + +The default source branch is `develop`, visibly identified as Testing. The +publisher reads separate shallow bare mirrors; it never changes existing +developer checkouts. All sources are pinned to the commits captured at the start +of a successful run. Neither unpublished worktrees nor internal handoffs, open +issues, credentials, operational notes or arbitrary new files are published. + +Alternative considered: adding server-side documentation routes. Not selected: +the current static mount enables this rollout without a portal restart or a new +public service. A separately maintained copy of prices was rejected because it +would drift from checkout. The browser refreshes the same catalog endpoint every +30 seconds while visible, on reappearance and on manual refresh. + +## Public routes + +- `/docs/index.html` +- `/docs/erste-schritte.html` +- `/docs/installation.html` +- `/docs/manager.html` +- `/docs/verbraucher.html` +- `/docs/utils.html` +- `/docs/faq.html` +- `/docs/preise.html` +- `/docs/ems/*.html` and `/docs/utils/*.html` +- `/docs/manifest.json`: published source commits and file mappings. +- `/docs-status.json`: last attempted/successful synchronization, no internal errors. + +The portal's anonymous login screen links to documentation, FAQ and prices. +Authenticated sidebar navigation additionally links to documentation and prices. + +Within the EMS references, Manager remains a direct link; consumer modules and +technical interfaces are nested in native, keyboard-accessible disclosure groups. +The current page's group opens on the server, also without JavaScript. Search +expands matching groups and restores their previous state when cleared. All +page URLs remain unchanged; new references without a group stay accessible. + +`assets/portal-links.css` contains only the added navigation styling. +`portal-navigation.patch` records the exact additive portal HTML change; check +it against the current portal before applying it and copy the stylesheet to +`public/docs-portal.css`. Never replace the full portal HTML from another checkout. + +## Install and publish + +Deploy the reviewed publisher files to `/home/agent/services/public-docs` as +the unprivileged `agent` user. Do not copy `node_modules`, state, preview, QA or +backups into a repository or the public directory. Install locked dependencies: + +```sh +npm ci --ignore-scripts +npm test +``` + +For rendering/rollback checks without changing public pages: + +```sh +node preview.mjs +node verify.mjs +node browser-test.mjs +``` + +Publication uses the existing public mount at +`/home/agent/services/license/public`. The portal container's UID must be able to +read generated directories and files. No credentials are included in the +publisher: Git uses the server's preconfigured access. Never put credentials in +repository URLs or public output. + +Install the two supplied units through `systemctl --user link`, reload the user +manager, start `enelix-public-docs.service` once and enable +`enelix-public-docs.timer`. Only these user units are affected. The timer checks +Git five minutes after completion, with at most fifteen seconds of jitter. +The successful first run can take longer while fetching both repositories. + +`DOCS_BRANCH` accepts `develop`, `beta` or `main`; reading a source branch does +not merge or modify it. `DOCS_PUBLIC_DIR` and `DOCS_STATE_DIR` support isolated +tests. Keep the chosen channel explicit in the service configuration. + +## Git-only operation and historical bootstrap + +The production service does not enable `DOCS_ALLOW_DRAFTS`. All approved guides +and screenshots must exist in the selected Git branch. A missing source aborts +publication and preserves the last good release; deployment copies cannot +silently replace deleted Git content. + +The initial 2026-10-06 deployment used explicitly labelled drafts while Git +publication awaited authorization. Daniel approved commit and push on the same +day. The bootstrap fallback remains available only through the explicit +`DOCS_ALLOW_DRAFTS=true` setting, including isolated previews. It reads missing +sources from the deployment-only `guides/` directory and labels them as drafts. +Do not treat these bootstrap copies as a second long-term editing location. + +When completing a bootstrap deployment, push the approved sources first, verify +that publication uses their Git commits, then install the production unit without +the draft setting and reload the user service manager. + +## Security and resilience + +Markdown is rendered with pinned Marked and sanitized with sanitize-html. +Raw HTML and remote images are disabled; link schemes and local mappings are +restricted. Relative links to unpublished files are plain text. The existing +portal CSP remains active; there is no inline JavaScript or browser-side Git +credential. Every page is readable without JavaScript except the live price +table, which shows an explicit notice and a catalog link in that case. + +Only the exact local PNG paths in `images.mjs` render as screenshots. The +publisher reads binary files at the same EMS commit as the guides, checks regular +file type, PNG signature and dimensions, and publishes content-hashed filenames. +Image hashes, dimensions and source revisions are recorded in the manifest. +Missing or invalid images fail publication and preserve the last good release. +Draft bootstrap images follow the same explicit opt-in as the draft guides. + +Screenshot refresh is intentionally reviewed, not a live camera into Symcon. +Capture through the local-only web console in a separate browser session, without +saving configuration or invoking regulation. Select useful crops, inspect them +for license codes, installation IDs, accounts and private plant details, then +replace the approved PNGs in Git and update the dated captions. Never publish +unreviewed captures automatically. Current screenshots show the demo object tree +and the manager basic settings in Symcon 8.0 on 2026-10-06. Test values are not +recommended installation settings; reference READMEs may evolve faster than the +dated screenshots. + +Publication is a complete immutable release followed by an atomic `docs` +symlink switch. Failed fetches or rendering leave the previous release intact. +Public status reports failed or overdue checks. Stale prices are removed when +the catalog cannot be obtained or validated; no hardcoded fallback is used. + +The sync lock prevents overlapping manual and timer runs. If the process is +forcibly terminated, inspect process state before removing its stale lock from +the publisher state directory. Generated releases are retained to permit +rollback; disk usage should be monitored and old releases pruned only after +checking that they are not current or needed for rollback. + +## Verification and rollback + +`npm test` covers relative and cross-repository links, HTML sanitization, +allowlist isolation, headings/tables, price validation, VAT and periods. +`node verify.mjs` validates all published links and deliberately exercises a +source failure against an isolated mirror, proving last-good preservation. +`node browser-test.mjs` checks desktop, 390px and 320px layouts, search, automatic +catalog refresh, newly returned products, VAT toggle, API failure/recovery and +JavaScript-free documentation. Mocks affect only a fresh browser context, never +the production catalog. Screenshots remain private in `qa/`. + +`node verify.mjs live` and `node browser-test.mjs --live` perform anonymous checks +on the actual domain. The tests must not log in or mutate live application data. + +For rollback, first stop and disable only the documentation timer. Restore the +previous documentation symlink atomically. For full removal, revert only the +added portal navigation and stylesheet link after checking for concurrent edits; +do not overwrite the portal with an older full backup if other edits occurred. +No database, Nginx or IP-Symcon rollback is required because those were unchanged. + +## Initial deployment evidence + +2026-10-06: 29 pages, 1,116 valid local links, 12 unit tests passed, isolated +failure recovery passed, desktop/mobile browser tests passed both in preview and +against the public domain. The initial sources were EMS +`58f91e431936fedca3e59178037b939f420ce3e3` and Utils +`82860c0eb938173841088f935a306729b6ce9a1c`. These are dated evidence, not permanent +source pins. The price API initially returned 15 products and was accessed +without login. + +Portal navigation backup: +`/home/agent/services/public-docs/backups/2026-10-06T12-58-13-558Z/index.html`. +The backup is private and must never be copied into `public/`. + +## Pull request summary + +Add public documentation and FAQ from reviewed Git-backed sources, plus a live +backend-driven price table. Preserve existing portal authentication and data; +publish static releases atomically with a five-minute user timer. No schema or +module migration and no beta/main changes. Include the end-user introduction, +guided first steps, reviewed Symcon screenshots and grouped module navigation. +Production publication requires versioned guides and screenshots, without draft +fallback. diff --git a/tools/public-docs/assets/portal-links.css b/tools/public-docs/assets/portal-links.css new file mode 100644 index 0000000..8f2b014 --- /dev/null +++ b/tools/public-docs/assets/portal-links.css @@ -0,0 +1,3 @@ +.public-doc-links { display:flex; flex-wrap:wrap; gap:12px 24px; margin:0 0 25px; font-size:14px; font-weight:650; } +.public-doc-links a { color:var(--petrol); text-decoration:underline; text-underline-offset:4px; } +.sidebar .public-doc-link { display:flex; align-items:center; } diff --git a/tools/public-docs/assets/prices.js b/tools/public-docs/assets/prices.js new file mode 100644 index 0000000..4594ffd --- /dev/null +++ b/tools/public-docs/assets/prices.js @@ -0,0 +1,59 @@ +export function validateCatalog(value) { + if (!value || value.currency !== 'CHF' || value.prices !== 'net' || !Number.isFinite(value.vatRate) || value.vatRate < 0 || value.vatRate > 100 || !value.items || typeof value.items !== 'object' || Array.isArray(value.items) || !Object.keys(value.items).length) throw new Error('invalid_catalog'); + for (const item of Object.values(value.items)) { + if (!item || typeof item.name !== 'string' || !item.name || typeof item.sku !== 'string' || !Number.isSafeInteger(item.unitAmount) || item.unitAmount < 0 || !Number.isSafeInteger(item.setupAmount) || item.setupAmount < 0 || (item.termMonths !== null && (!Number.isSafeInteger(item.termMonths) || item.termMonths < 1))) throw new Error('invalid_catalog_item'); + } + return value; +} + +export const grossAmount = (net, rate) => net + Math.round(net * rate / 100); +export const termLabel = months => months === null ? 'Einmalig' : months === 12 ? 'Pro Jahr' : `Pro ${months} Monate`; + +if (typeof document !== 'undefined') { + const list = document.getElementById('price-list'); + const status = document.getElementById('price-status'); + const refresh = document.getElementById('refresh-prices'); + let catalog = null; + let pending = false; + const money = new Intl.NumberFormat('de-CH', { style:'currency', currency:'CHF' }); + function el(tag, text, className) { const node = document.createElement(tag); if (text !== undefined) node.textContent = text; if (className) node.className = className; return node; } + function render() { + list.replaceChildren(); + if (!catalog) return; + const gross = document.querySelector('[name="tax"]:checked').value === 'gross'; + const amount = value => money.format((gross ? grossAmount(value, catalog.vatRate) : value) / 100); + const table = el('table', undefined, 'price-table'); + table.append(el('caption', `${gross ? 'Inklusive' : 'Zuzüglich'} ${new Intl.NumberFormat('de-CH').format(catalog.vatRate)} % MwSt. · Preise je Einheit`)); + const head = el('thead'), row = el('tr'); + for (const text of ['Produkt', 'Laufzeit', 'Lizenz', 'Ersteinrichtung']) { const th = el('th', text); th.scope = 'col'; row.append(th); } + head.append(row); table.append(head); + const body = el('tbody'); + for (const item of Object.values(catalog.items)) { + const tr = el('tr'), name = el('th', item.name); name.scope = 'row'; name.append(el('small', item.sku)); + tr.append(name, el('td', termLabel(item.termMonths)), el('td', amount(item.unitAmount), 'amount'), el('td', amount(item.setupAmount), 'amount')); + body.append(tr); + } + table.append(body); + const wrapper = el('div', undefined, 'table-scroll'); wrapper.tabIndex = 0; wrapper.setAttribute('role', 'region'); wrapper.setAttribute('aria-label', 'Aktuelle Preise'); wrapper.append(table); list.append(wrapper); + } + async function load() { + if (pending) return; + pending = true; refresh.disabled = true; + try { + const response = await fetch('/api/catalog', { cache:'no-store', credentials:'omit', signal:AbortSignal.timeout(10000) }); + if (!response.ok) throw new Error('catalog_unavailable'); + catalog = validateCatalog(await response.json()); + render(); status.classList.remove('error'); + status.textContent = `Stand ${new Date().toLocaleTimeString('de-CH')} · automatisch alle 30 Sekunden aktualisiert.`; + } catch { + catalog = null; list.replaceChildren(); status.classList.add('error'); + status.textContent = 'Aktuelle Preise sind vorübergehend nicht erreichbar. Bitte erneut aktualisieren. Es werden keine veralteten Ersatzpreise angezeigt.'; + } finally { pending = false; refresh.disabled = false; } + } + refresh.addEventListener('click', load); + document.querySelectorAll('[name="tax"]').forEach(input => input.addEventListener('change', render)); + document.addEventListener('visibilitychange', () => { if (!document.hidden) load(); }); + window.addEventListener('pageshow', event => { if (event.persisted) load(); }); + setInterval(() => { if (!document.hidden) load(); }, 30000); + load(); +} diff --git a/tools/public-docs/assets/site.css b/tools/public-docs/assets/site.css new file mode 100644 index 0000000..bc73c2b --- /dev/null +++ b/tools/public-docs/assets/site.css @@ -0,0 +1,88 @@ +:root { --ink:#172124; --muted:#59696d; --line:#d8e0de; --paper:#fff; --canvas:#f2f5f4; --petrol:#2f6b66; --lime:#d5df38; --danger:#9a3939; font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; color:var(--ink); background:var(--paper); letter-spacing:0; } +* { box-sizing:border-box; } +body { margin:0; min-width:320px; } +a { color:var(--petrol); text-underline-offset:3px; } +a:hover { color:#153f3b; } +button,input { font:inherit; } +button,a,input,summary { -webkit-tap-highlight-color:transparent; } +:focus-visible { outline:3px solid #497fbe; outline-offset:3px; } +.skip { position:fixed; top:-60px; left:16px; z-index:100; background:#fff; padding:12px; } +.skip:focus { top:8px; } +.topbar { position:sticky; top:0; z-index:10; display:flex; align-items:center; justify-content:space-between; gap:16px; min-height:76px; padding:14px 32px; background:#fff; border-bottom:1px solid var(--line); } +.brand { display:flex; align-items:center; gap:10px; text-decoration:none; color:var(--ink); } +.brand-mark { display:grid; place-items:center; width:38px; height:38px; border-radius:4px; background:var(--lime); font-weight:850; font-size:23px; } +.brand strong { display:block; font-size:24px; line-height:1; } +.brand small { display:block; margin-top:5px; font-size:12px; color:var(--muted); } +.topbar nav { display:flex; align-items:center; flex-wrap:wrap; gap:24px; font-size:14px; font-weight:650; } +.topbar nav a { text-decoration:none; } +.portal-link { padding-left:24px; border-left:1px solid var(--line); } +.docs-layout { display:grid; grid-template-columns:248px minmax(0,850px) minmax(170px,220px); max-width:1500px; margin:auto; } +.sidebar { position:sticky; top:76px; height:calc(100dvh - 76px); overflow-y:auto; padding:26px 20px; border-right:1px solid var(--line); background:var(--canvas); } +#docs-menu > summary { display:none; } +.sidebar section { margin:24px 0; } +.sidebar h2,.contents h2 { font-size:12px; color:var(--muted); font-weight:700; margin:0 0 10px; } +.sidebar ul { list-style:none; margin:0; padding:0; } +.sidebar li { margin:2px 0; } +.sidebar a { display:block; padding:8px 10px; font-size:13px; line-height:1.35; color:#425456; text-decoration:none; border-left:3px solid transparent; overflow-wrap:anywhere; transition:background .15s; } +.sidebar a:hover { background:#e5edeb; } +.sidebar a[aria-current] { background:#fff; border-left-color:var(--petrol); color:var(--petrol); font-weight:750; } +.nav-folder > summary { display:list-item; list-style-position:inside; min-height:36px; padding:8px 10px; font-size:13px; line-height:1.5; color:#425456; font-weight:650; cursor:pointer; overflow-wrap:anywhere; border-left:3px solid transparent; } +.nav-folder > summary:hover { background:#e5edeb; } +.nav-folder > summary::marker { color:var(--petrol); } +.nav-folder[data-active] > summary { color:var(--petrol); border-left-color:var(--petrol); } +.nav-folder > ul { margin:4px 0 8px 14px; padding-left:6px; border-left:1px solid #c4d3cd; } +.search-label { display:block; font-size:12px; margin-bottom:8px; font-weight:650; } +#doc-search { width:100%; border:1px solid #b4c5c0; border-radius:4px; padding:10px; background:#fff; font-size:13px; } +#search-result { font-size:12px; color:var(--muted); } +main { min-width:0; padding:30px 44px 56px; } +.page-meta { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:12px; margin-bottom:24px; color:var(--muted); font-size:12px; } +.channel { padding:5px 8px; background:#edf3e5; color:#385c28; border-radius:3px; font-weight:650; } +.sync-status { margin:16px 0 30px; color:var(--muted); font-size:12px; line-height:1.5; } +.sync-status.warning { border-left:3px solid #aa731f; padding-left:10px; color:#80570e; } +article { font-size:15px; line-height:1.75; overflow-wrap:anywhere; } +article h1 { font-size:34px; line-height:1.18; margin:0 0 20px; font-weight:760; color:#173f3c; } +article h2 { font-size:23px; line-height:1.3; margin:38px 0 16px; padding-top:14px; border-top:1px solid var(--line); } +article h3 { font-size:18px; line-height:1.4; margin:28px 0 12px; } +article h4 { font-size:16px; } +article h1,article h2,article h3,article h4 { scroll-margin-top:100px; } +article p { margin:12px 0 18px; } +article li { margin:7px 0; padding-left:3px; } +article ul,article ol { padding-left:23px; } +article blockquote { margin:24px 0; padding:4px 18px; border-left:3px solid var(--petrol); background:#eff5f2; color:#314e46; } +article pre { overflow:auto; padding:18px; border:1px solid var(--line); background:var(--canvas); font-size:12px; line-height:1.65; border-radius:4px; } +article code { font-family:ui-monospace,"SFMono-Regular",Consolas,monospace; font-size:.86em; background:#f0f3f1; padding:2px 4px; border-radius:3px; } +article pre code { padding:0; } +.heading-anchor { padding-left:8px; opacity:0; font-weight:400; text-decoration:none; } +h1:hover .heading-anchor,h2:hover .heading-anchor,h3:hover .heading-anchor,.heading-anchor:focus { opacity:1; } +.table-scroll { max-width:100%; overflow-x:auto; margin:20px 0; } +table { border-collapse:collapse; width:100%; font-size:13px; line-height:1.55; text-align:left; } +th { color:#284a45; background:#eef4f1; font-weight:700; } +th,td { padding:12px; border-bottom:1px solid var(--line); vertical-align:top; } +article table code { overflow-wrap:normal; } +.unpublished { color:inherit; } +.doc-image { display:block; margin-top:22px; border:1px solid var(--line); border-radius:4px; overflow:hidden; } +.doc-image img { display:block; max-width:100%; width:100%; height:auto; } +.image-caption { display:block; margin-top:8px; color:var(--muted); font-size:12px; line-height:1.6; } +.contents { position:sticky; top:76px; align-self:start; max-height:calc(100dvh - 76px); overflow:auto; padding:36px 20px 25px 0; } +.contents a { display:block; border-left:1px solid var(--line); padding:6px 12px; color:var(--muted); font-size:12px; line-height:1.5; text-decoration:none; overflow-wrap:anywhere; } +.contents a:hover { border-left-color:var(--petrol); color:var(--petrol); } +footer { margin-top:48px; padding-top:20px; border-top:1px solid var(--line); font-size:12px; color:var(--muted); line-height:1.7; overflow-wrap:anywhere; } +footer p { margin:4px 0; } +.price-toolbar { display:flex; flex-wrap:wrap; align-items:center; gap:16px; justify-content:space-between; } +.price-toolbar fieldset { display:flex; gap:14px; border:0; padding:0; margin:0; font-size:14px; } +.price-toolbar legend { font-size:12px; color:var(--muted); margin-bottom:5px; } +.price-toolbar label { display:flex; align-items:center; gap:6px; } +.price-toolbar button { background:#fff; border:1px solid var(--line); border-radius:4px; padding:9px 12px; color:var(--petrol); cursor:pointer; } +.price-toolbar button:disabled { opacity:.5; cursor:wait; } +.price-table th:not(:first-child),.price-table td:not(:first-child) { text-align:right; } +.price-table td:nth-child(2) { white-space:nowrap; } +.price-table small { display:block; color:var(--muted); font-weight:400; font-size:11px; } +.price-table .amount { white-space:nowrap; } +.price-table caption { text-align:left; margin-bottom:12px; font-size:13px; color:var(--muted); } +#price-status { font-size:13px; color:var(--muted); } +#price-status.error { color:var(--danger); } +[hidden] { display:none!important; } +@media (max-width:1150px) { .docs-layout { grid-template-columns:228px minmax(0,1fr); } .contents { display:none; } main { padding:28px 36px; } } +@media (max-width:720px) { .topbar { position:relative; padding:16px; flex-wrap:wrap; } .topbar nav { gap:20px; font-size:13px; width:100%; justify-content:space-between; } .portal-link { border:0; padding:0; } .docs-layout { display:block; } .sidebar { position:relative; top:0; height:auto; border:0; border-bottom:1px solid var(--line); padding:0 20px; } #docs-menu > summary { display:list-item; padding:15px 0; font-weight:650; cursor:pointer; font-size:14px; } #docs-menu[open] { padding-bottom:20px; } .sidebar nav { display:block; } .sidebar section { margin:16px 0 0; } .sidebar a,.nav-folder > summary { min-height:40px; } main { padding:24px 20px 40px; } article h1 { font-size:29px; } article h2 { font-size:21px; } article { font-size:15px; } .sync-status { margin-bottom:24px; } .price-table { min-width:570px; } article h1,article h2,article h3 { scroll-margin-top:20px; } } +@media print { .topbar,.sidebar,.contents,.price-toolbar,.heading-anchor { display:none; } .docs-layout { display:block; } main { padding:0; } .table-scroll { overflow:visible; } article { font-size:11px; } a { color:inherit; } } +@media (prefers-reduced-motion:reduce) { * { transition:none!important; scroll-behavior:auto!important; } } diff --git a/tools/public-docs/assets/site.js b/tools/public-docs/assets/site.js new file mode 100644 index 0000000..6b1691d --- /dev/null +++ b/tools/public-docs/assets/site.js @@ -0,0 +1,47 @@ +const menu = document.getElementById('docs-menu'); +const mobile = matchMedia('(max-width:720px)'); +const setMenu = () => { menu.open = !mobile.matches; }; +setMenu(); +mobile.addEventListener('change', setMenu); +const search = document.getElementById('doc-search'); +const folders = [...menu.querySelectorAll('.nav-folder')]; +let beforeSearch = null; +const normalize = value => value.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, ''); +search.addEventListener('input', () => { + const query = normalize(search.value.trim()); + if (query && !beforeSearch) beforeSearch = new Map(folders.map(folder => [folder, folder.open])); + let count = 0; + for (const item of document.querySelectorAll('[data-search]')) { + item.hidden = !normalize(item.dataset.search).includes(query); + if (!item.hidden) count++; + } + for (const folder of folders) { + const matches = [...folder.querySelectorAll('[data-search]')].some(item => !item.hidden); + folder.parentElement.hidden = !matches; + if (query) folder.open = matches; + else if (beforeSearch) folder.open = beforeSearch.get(folder); + } + if (!query) beforeSearch = null; + for (const group of menu.querySelectorAll('section')) group.hidden = ![...group.querySelectorAll('[data-search]')].some(item => !item.hidden); + const result = document.getElementById('search-result'); + result.hidden = !query; + result.textContent = count ? `${count} passende Themen` : 'Keine passenden Themen gefunden.'; +}); +async function syncStatus() { + const label = document.getElementById('sync-status'); + try { + const response = await fetch('/docs-status.json', { cache:'no-store', credentials:'omit', signal:AbortSignal.timeout(8000) }); + if (!response.ok) throw new Error(); + const status = await response.json(); + const date = new Date(status.lastSuccess); + if (!Number.isFinite(date.valueOf())) throw new Error(); + const stale = !status.ok || Date.now() - date.valueOf() > 15 * 60 * 1000; + label.classList.toggle('warning', stale); + label.textContent = `${stale ? 'Git-Abgleich verzögert. Letzter erfolgreicher Abgleich' : 'Git zuletzt geprüft'}: ${date.toLocaleString('de-CH')}${status.drafts ? ' · neue Anleitungen noch nicht in Git veröffentlicht' : ''}.`; + } catch { + label.classList.add('warning'); + label.textContent = 'Der aktuelle Git-Abgleichstatus ist nicht erreichbar. Quellstand dieser Seite siehe unten.'; + } +} +syncStatus(); +setInterval(() => { if (!document.hidden) syncStatus(); }, 60000); diff --git a/tools/public-docs/browser-test.mjs b/tools/public-docs/browser-test.mjs new file mode 100644 index 0000000..7a0fa1e --- /dev/null +++ b/tools/public-docs/browser-test.mjs @@ -0,0 +1,143 @@ +import { chromium } from 'playwright'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import http from 'node:http'; +import assert from 'node:assert/strict'; +import { fileURLToPath } from 'node:url'; + +const root = path.dirname(fileURLToPath(import.meta.url)); +await fs.mkdir(path.join(root, 'qa'), { recursive:true }); +const catalogResponse = await fetch('https://license.enelix.ch/api/catalog'); +assert.equal(catalogResponse.status, 200); +const fixture = await catalogResponse.json(); +const live = process.argv.includes('--live'); +const server = http.createServer(async (req, res) => { + const pathname = new URL(req.url, 'http://localhost').pathname; + if (pathname === '/api/catalog') { res.writeHead(200, { 'content-type':'application/json' }); return res.end(JSON.stringify(fixture)); } + const file = path.join(root, 'preview', pathname); + if (!file.startsWith(path.join(root, 'preview') + '/')) { res.writeHead(404); return res.end(); } + try { + const content = await fs.readFile(file); + const type = { '.html':'text/html', '.js':'text/javascript', '.css':'text/css', '.json':'application/json', '.png':'image/png' }[path.extname(file)] || 'application/octet-stream'; + res.writeHead(200, { 'content-type':type }); res.end(content); + } catch { res.writeHead(404); res.end(); } +}); +await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); +const origin = live ? 'https://license.enelix.ch' : `http://127.0.0.1:${server.address().port}`; +process.env.TMPDIR = path.join(root, 'qa/runtime'); +await fs.mkdir(process.env.TMPDIR, { recursive:true }); +const standalone = (await import('@sparticuz/chromium')).default; +const browser = await chromium.launch({ headless:true, args:standalone.args.filter(arg => !['--single-process', '--disable-web-security', '--allow-running-insecure-content', '--disable-site-isolation-trials'].includes(arg) && !arg.startsWith('--disable-features=')), executablePath:await standalone.executablePath() }); +const errors = []; +try { + for (const [label,width,height] of [['desktop',1440,1000], ['mobile',390,844], ['small-mobile',320,740]]) { + const context = await browser.newContext({ viewport:{width,height} }); + const page = await context.newPage(); + await page.clock.install(); + page.on('pageerror', error => errors.push(error.message)); + await page.goto(`${origin}/docs/index.html`); + assert.match(await page.locator('article h1').textContent(), /Enelix verstehen/); + assert.match(await page.locator('article').textContent(), /Energiemanagementsystem/); + await page.screenshot({ path:path.join(root, `qa/docs-${label}.png`) }); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, `${label} overview overflow`); + if (width < 720) { assert.equal(await page.locator('#docs-menu').getAttribute('open'), null); await page.locator('#docs-menu > summary').click(); } + const consumers = page.locator('[data-folder="verbraucher"]'); + const interfaces = page.locator('[data-folder="schnittstellen"]'); + const folderToggle = consumers.locator(':scope > summary'); + assert.equal(await consumers.getAttribute('open'), null); + assert.equal(await consumers.locator('a').count(), 8); + assert.equal(await page.locator('nav[aria-label="Dokumentation"] a[href="/docs/ems/Manager.html"]:visible').count(), 1); + await folderToggle.focus(); await folderToggle.press('Enter'); + assert.equal(await consumers.evaluate(el => el.open), true); + await page.screenshot({ path:path.join(root, `qa/navigation-${label}.png`) }); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, `${label} expanded navigation overflow`); + await folderToggle.press('Space'); + assert.equal(await consumers.evaluate(el => el.open), false); + await page.locator('#doc-search').fill('Waermepumpe'); + assert.equal(await consumers.evaluate(el => el.open), true); + assert.equal(await consumers.locator('a[href="/docs/ems/Waermepumpe.html"]:visible').count(), 1); + assert.equal(await interfaces.isVisible(), false); + await page.locator('#doc-search').fill('Schnittstellen'); + assert.equal(await consumers.isVisible(), false); + assert.equal(await interfaces.evaluate(el => el.open), true); + assert.equal(await interfaces.locator('[data-search]:visible').count(), 3); + await page.locator('#doc-search').fill(''); + assert.equal(await consumers.evaluate(el => el.open), false); + assert.equal(await interfaces.evaluate(el => el.open), false); + await folderToggle.click(); + await page.locator('#doc-search').fill('Shelly'); + await page.locator('#doc-search').fill(''); + assert.equal(await consumers.evaluate(el => el.open), true, 'Search preserves manually expanded folders'); + await page.locator('#doc-search').fill('Shelly'); + assert.match(await page.locator('#search-result').textContent(), /passende Themen/); + assert.ok(await page.locator('[data-search]:visible').count() >= 1); + await page.locator('#doc-search').fill('unfindable-text-xxx'); + assert.match(await page.locator('#search-result').textContent(), /Keine/); + assert.equal(await page.locator('#docs-menu section:visible').count(), 0); + await page.locator('#doc-search').fill(''); + await consumers.locator('a[href="/docs/ems/Waermepumpe.html"]').click(); + await page.waitForURL('**/docs/ems/Waermepumpe.html'); + assert.equal(await consumers.evaluate(el => el.open), true, 'Current page opens its folder'); + assert.equal(await consumers.locator('a[aria-current="page"]').getAttribute('href'), '/docs/ems/Waermepumpe.html'); + for (const name of ['index','erste-schritte','manager']) { + await page.goto(`${origin}/docs/${name}.html`); + const images = page.locator('article img'); + assert.ok(await images.count() > 0, `${name}: missing screenshot`); + for (const img of await images.all()) { + await img.scrollIntoViewIfNeeded(); + await img.evaluate(el => el.decode()); + assert.ok(await img.evaluate(el => el.naturalWidth >= 100 && el.naturalHeight >= 100)); + assert.match(await img.getAttribute('src'), /^\/docs\/images\/symcon-[a-z-]+-[a-f0-9]{16}\.png$/); + assert.ok(await img.getAttribute('alt')); + } + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, `${label} ${name} image overflow`); + await page.screenshot({ path:path.join(root, `qa/${name}-image-${label}.png`) }); + } + await page.goto(`${origin}/docs/preise.html`); + await page.waitForSelector('.price-table'); + assert.equal(await page.locator('.price-table tbody tr').count(), Object.keys(fixture.items).length); + assert.ok((await page.locator('.price-table tbody tr').first().textContent()).includes((fixture.items.manager_standard.unitAmount / 100).toFixed(2))); + await page.locator('[value="gross"]').check(); + const expectedGross = ((fixture.items.manager_standard.unitAmount + Math.round(fixture.items.manager_standard.unitAmount * fixture.vatRate / 100)) / 100).toFixed(2); + assert.ok((await page.locator('.price-table tbody tr').first().textContent()).includes(expectedGross)); + await page.screenshot({ path:path.join(root, `qa/prices-${label}.png`) }); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, `${label} prices overflow`); + const changed = structuredClone(fixture); changed.items.manager_standard.unitAmount = 50000; + changed.items.new_test = { sku:'NEW', name:'Backend-Neuprodukt', unitAmount:1234, setupAmount:0, termMonths:6 }; + await page.route('**/api/catalog', route => route.fulfill({ json:changed })); + await page.clock.fastForward(31000); + await page.waitForFunction(count => document.querySelectorAll('.price-table tbody tr').length === count, Object.keys(changed.items).length); + assert.ok((await page.locator('.price-table tbody tr').first().textContent()).includes(((50000 + Math.round(50000 * fixture.vatRate / 100)) / 100).toFixed(2))); + await page.unroute('**/api/catalog'); + await page.route('**/api/catalog', route => route.fulfill({ status:503, body:'unavailable' })); + await page.locator('#refresh-prices').click(); + await page.waitForSelector('#price-status.error'); + assert.equal(await page.locator('.price-table').count(), 0); + await page.unroute('**/api/catalog'); + await page.locator('#refresh-prices').click(); await page.waitForSelector('.price-table'); + await page.goto(`${origin}/docs/ems/Manager.html`); + await page.screenshot({ path:path.join(root, `qa/manager-${label}.png`) }); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, `${label} reference overflow`); + const noJs = await browser.newContext({ javaScriptEnabled:false, viewport:{width,height} }); + const staticPage = await noJs.newPage(); await staticPage.goto(`${origin}/docs/installation.html`); + assert.match(await staticPage.locator('article').textContent(), /Bibliotheken hinzufügen/); + const staticFolder = staticPage.locator('[data-folder="verbraucher"]'); + await staticFolder.locator(':scope > summary').click(); + assert.equal(await staticFolder.evaluate(el => el.open), true); + await staticFolder.locator('a[href="/docs/ems/Batterie.html"]').click(); + await staticPage.waitForURL('**/docs/ems/Batterie.html'); + assert.equal(await staticFolder.evaluate(el => el.open), true); + assert.equal(await staticFolder.locator('a[aria-current="page"]').getAttribute('href'), '/docs/ems/Batterie.html'); + await noJs.close(); + if (live) { + await page.goto(origin + '/'); + await page.waitForSelector('#loginForm:visible'); + assert.equal(await page.locator('.public-doc-links a').count(), 3); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, `${label} login overflow`); + await page.screenshot({ path:path.join(root, `qa/portal-${label}.png`) }); + } + await context.close(); + console.log(`${label}: folders, keyboard navigation, search expansion/reset, active branch, screenshots, prices, API recovery, layout and no-JS passed`); + } + assert.deepEqual(errors, []); +} finally { await browser.close(); await new Promise(resolve => server.close(resolve)); } diff --git a/tools/public-docs/config.mjs b/tools/public-docs/config.mjs new file mode 100644 index 0000000..d3f9787 --- /dev/null +++ b/tools/public-docs/config.mjs @@ -0,0 +1,21 @@ +export const repositories = [ + { id: 'ems', name: 'Enelix-EMS', url: 'https://git.belevo.ch/ENELIX/Enelix-EMS.git', pages: [ + ['README.md', 'Enelix EMS', 'ems/index.html'], + ['docs/module/README.md', 'EMS-Modulübersicht', 'ems/module.html'], + ...['Manager', 'Batterie', 'Wassererwaermer', 'Pufferspeicher', 'Verbraucher-1-Stufig', 'Waermepumpe', 'Ladestation-Stand-Alone', 'Ladestation-Gateway', 'Easee-Gateway'].map(name => [`docs/module/${name}/README.md`, name, `ems/${name}.html`]), + ['docs/Schnittstelle.md', 'EMS-Schnittstelle', 'ems/schnittstelle.html'], + ['docs/Schnittstelle-Batterie.md', 'Batterieschnittstelle', 'ems/batterieschnittstelle.html'], + ['docs/Schnittstelle-Easee-Gateway.md', 'Easee-Schnittstelle', 'ems/easee-schnittstelle.html'] + ] }, + { id: 'utils', name: 'Enelix-Utils', url: 'https://git.belevo.ch/ENELIX/Enelix-Utils.git', pages: [ + ['README.md', 'Enelix Utils', 'utils/index.html'], + ['docs/module/README.md', 'Utils-Modulübersicht', 'utils/module.html'], + ...['Energiediagramm', 'Shelly-Modul', 'Verbrauchskostenreport', 'CC100-Hardware', 'Virtuelle-Batterie', 'VGT-Schnittstelle'].map(name => [`docs/module/${name}/README.md`, name, `utils/${name}.html`]) + ] } +]; + +export const guides = [ + ['index', 'Enelix verstehen'], ['erste-schritte', 'Erste Schritte'], ['installation', 'Installation'], ['manager', 'Manager bedienen'], + ['verbraucher', 'Verbraucher einrichten'], ['utils', 'Utils nutzen'], + ['faq', 'Häufige Fragen'], ['preise', 'Preise'] +]; diff --git a/tools/public-docs/enelix-public-docs.service b/tools/public-docs/enelix-public-docs.service new file mode 100644 index 0000000..f1cc2c8 --- /dev/null +++ b/tools/public-docs/enelix-public-docs.service @@ -0,0 +1,11 @@ +[Unit] +Description=Publish Enelix public documentation from Git + +[Service] +Type=oneshot +WorkingDirectory=/home/agent/services/public-docs +ExecStart=/usr/bin/node /home/agent/services/public-docs/sync.mjs +Environment=DOCS_BRANCH=develop +UMask=0022 +TimeoutStartSec=240 +NoNewPrivileges=true diff --git a/tools/public-docs/enelix-public-docs.timer b/tools/public-docs/enelix-public-docs.timer new file mode 100644 index 0000000..107614c --- /dev/null +++ b/tools/public-docs/enelix-public-docs.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Check Enelix documentation sources every five minutes + +[Timer] +OnBootSec=90 +OnUnitInactiveSec=5min +RandomizedDelaySec=15 +Unit=enelix-public-docs.service + +[Install] +WantedBy=timers.target diff --git a/tools/public-docs/images.mjs b/tools/public-docs/images.mjs new file mode 100644 index 0000000..5f8b898 --- /dev/null +++ b/tools/public-docs/images.mjs @@ -0,0 +1,17 @@ +import { createHash } from 'node:crypto'; + +// Only these reviewed screenshots can leave the source repository. +export const screenshotSources = [ + 'docs/public/images/symcon-objektbaum.png', + 'docs/public/images/symcon-manager.png' +]; + +export function screenshotInfo(source, data) { + if (!screenshotSources.includes(source) || !Buffer.isBuffer(data) || data.length < 45 || data.length > 4_000_000) throw new Error('Invalid screenshot source'); + if (!data.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex')) || data.readUInt32BE(8) !== 13 || data.toString('ascii', 12, 16) !== 'IHDR' || data.toString('ascii', data.length - 8, data.length - 4) !== 'IEND') throw new Error('Expected PNG screenshot'); + const width = data.readUInt32BE(16), height = data.readUInt32BE(20); + if (width < 100 || height < 100 || width > 5000 || height > 5000) throw new Error('Invalid screenshot dimensions'); + const sha256 = createHash('sha256').update(data).digest('hex'); + const stem = source.split('/').pop().replace(/\.png$/, ''); + return { repo:'Enelix-EMS', source, output:`images/${stem}-${sha256.slice(0, 16)}.png`, width, height, sha256 }; +} diff --git a/tools/public-docs/package-lock.json b/tools/public-docs/package-lock.json new file mode 100644 index 0000000..8b8c622 --- /dev/null +++ b/tools/public-docs/package-lock.json @@ -0,0 +1,523 @@ +{ + "name": "enelix-public-docs", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "enelix-public-docs", + "version": "1.0.0", + "dependencies": { + "marked": "15.0.12", + "sanitize-html": "2.18.0" + }, + "devDependencies": { + "@sparticuz/chromium": "153.0.0", + "playwright": "1.63.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@sparticuz/chromium": { + "version": "153.0.0", + "resolved": "https://registry.npmjs.org/@sparticuz/chromium/-/chromium-153.0.0.tgz", + "integrity": "sha512-jiGWqSvPMdu4+P9TilYqAR7yEGPEHINx+lCvE/NqNO/EHYzF/x1hUiI5T6ioVIt7dEy7n88byyT0vk+dnsgQwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tar-fs": "^3.1.2" + }, + "engines": { + "node": "^22.17.0 || >=24.0.0" + } + }, + "node_modules/b4a": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.9.0.tgz", + "integrity": "sha512-dpfcF9fDNR6++cthXR67iyhgqWy9CBouAvIWhIntzBG6cvK/cnIPiZQjBwi/ZqjjBEDGfoNDtmB0kTjroOJ3pQ==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/bare-events": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.2.tgz", + "integrity": "sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.8.2", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.8.2.tgz", + "integrity": "sha512-+ZI68KHMUvosXfKbg/UOHK0tbCdRnegbvPEdEcZ3Nd6TetieQsJPRXBRXPdLyy8+3VSEbPXtsumTpEtt78xv9w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.28.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-path": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.2.tgz", + "integrity": "sha512-ZyKbsuuqK6Ag0K8pX6V5Txq6XeJRvY+wXucnFGRjiyVYP9YWDpIQugk/b+enRYrEYBJaqLzghRQpXPMR7341Nw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/bare-stream": { + "version": "2.13.4", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.4.tgz", + "integrity": "sha512-PcrQ8lVLbiJscNm1Kez+Yp4Gy4AHGcN1lzwjvf5NybWen7VvEgUfyfnXYJ2zNqWnzOfCb1Abq6lH8ti0syQszA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.8.1", + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.5.4.tgz", + "integrity": "sha512-Gxa7UVWBr0/edU1b+TJhn/AZvMQUj9OGspvYsaTYQrAbZA4BOTZGL3LiZxvD+CeMlDH4juwD84+eTAp/bLYW5g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "node_modules/dayjs": { + "version": "1.11.23", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz", + "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==", + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dom-serializer": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz", + "integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==", + "license": "MIT", + "dependencies": { + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0", + "entities": "^8.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz", + "integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/domhandler": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz", + "integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^3.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz", + "integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^3.0.0", + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/htmlparser2": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-12.0.0.tgz", + "integrity": "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0", + "domutils": "^4.0.2", + "entities": "^8.0.0" + }, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/is-plain-object": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.1.0.tgz", + "integrity": "sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/launder": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/launder/-/launder-1.7.2.tgz", + "integrity": "sha512-DLg3HPnHUfBi5/MxMLmJD12dmlMpFEC2HgMW6vkZ/9JR0RU00kXoRGvDxAvFmdO0o610OA77i4FgNTLucmhDVg==", + "license": "MIT", + "dependencies": { + "dayjs": "^1.11.23" + } + }, + "node_modules/marked": { + "version": "15.0.12", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", + "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parse-srcset": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", + "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/postcss": { + "version": "8.5.29", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", + "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.19", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/sanitize-html": { + "version": "2.18.0", + "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.18.0.tgz", + "integrity": "sha512-CvY+PV+NBhxe3BnjFI5f//vEDKLULm9OlZArso7gdgVsWvanBuq9PNDx+/2llfL+8XFYa/UNBxvqRBg3TWU2Ug==", + "license": "MIT", + "dependencies": { + "deepmerge": "^4.3.1", + "escape-string-regexp": "^4.0.0", + "htmlparser2": "^12.0.0", + "is-plain-object": "^5.1.0", + "launder": "^1.7.2", + "parse-srcset": "^1.0.2", + "postcss": "^8.5.27" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/streamx": { + "version": "2.28.1", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.1.tgz", + "integrity": "sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/tar-fs": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.3.tgz", + "integrity": "sha512-/hU4AXnIdZu+Gvl1pk0oI5f5HxWsCJRtY2aFaJdk9VvyL48DWU6iU5WAIPG+wIi1YvWA6eTJvIviP/tMAZZNwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/tar-stream": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.2.tgz", + "integrity": "sha512-+8NeqHRjQWH9nYlwo2gamAMImZCVzI4UoEgDpWorBt9OEfppiZn+uSkskzQKPWIZyji/C8fpWO7u69G0DX0tbg==", + "dev": true, + "license": "MIT", + "dependencies": { + "b4a": "^1.9.0", + "bare-fs": "^4.8.2", + "fast-fifo": "^1.3.2", + "streamx": "^2.28.1" + } + }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/tools/public-docs/package.json b/tools/public-docs/package.json new file mode 100644 index 0000000..490d8ab --- /dev/null +++ b/tools/public-docs/package.json @@ -0,0 +1,19 @@ +{ + "name": "enelix-public-docs", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "test": "node --test tests/*.test.mjs", + "sync": "node sync.mjs" + }, + "engines": { "node": ">=22" }, + "dependencies": { + "marked": "15.0.12", + "sanitize-html": "2.18.0" + }, + "devDependencies": { + "playwright": "1.63.0", + "@sparticuz/chromium": "153.0.0" + } +} diff --git a/tools/public-docs/portal-navigation.patch b/tools/public-docs/portal-navigation.patch new file mode 100644 index 0000000..c97bdc6 --- /dev/null +++ b/tools/public-docs/portal-navigation.patch @@ -0,0 +1,23 @@ +diff --git a/public/index.html b/public/index.html +index 8d70f40..779b440 100644 +--- a/public/index.html ++++ b/public/index.html +@@ -7,4 +7,5 @@ +
Aktuelle Preise werden geladen.
+