docs: STATE.md Updates - FuenfVorAcht Review-Schritt entfernt, Redakteur RSS-Integration, Flugpreisscanner

- fuenfvoracht/STATE.md: Review-Flow entfernt, direkt approved, neuer Changelog
- arakava-news/STATE.md: Aktualisiert
- redax-wp/STATE.md + src/app.py: Aktualisiert
- flugpreisscanner/STATE.md: Aktualisiert
- infrastructure/STATE.md: Aktualisiert
- fuenfvoracht READMEs und Kurzuebersichten hinzugefuegt

Made-with: Cursor
This commit is contained in:
root 2026-02-27 21:23:27 +07:00
parent 5e1397edee
commit e607b1fba0
16 changed files with 2619 additions and 12 deletions

View file

@ -24,9 +24,14 @@ $8.88 verbleibend
## URLs
- Blog: https://arakava-news-2.orbitalo.net
- Admin: https://arakava-news-2.orbitalo.net/wp-admin (admin / eJIyhW0p5PFacjvvKGufKeXS)
- RSS Manager: http://100.113.244.101:8080 (admin / astral66)
- Admin: https://arakava-news-2.orbitalo.net/wp-admin (admin / eJIyhW0p5PFacjvvKGufKeXS) — ⚠️ nur via Tailscale erreichbar!
- RSS Manager: https://rss-manager.orbitalo.net (astral66) — Cloudflare Tunnel
- RSS Manager (Tailscale): http://100.113.244.101:8080
- Matomo: https://matomo.orbitalo.net (admin / astral66)
- Google Search Console: https://search.google.com/search-console?resource_id=https://arakava-news-2.orbitalo.net/
- Google-Konto: Mila.Dek1968@gmail.com / Sicherlich-neue-658@1
- Verifizierung: HTML-Datei (google248e38a1e3540863.html im WP-Root)
- Sitemap: https://arakava-news-2.orbitalo.net/sitemap_index.xml
## Container
| CT | Dienst | Tailscale |
@ -54,10 +59,58 @@ $8.88 verbleibend
| 16 | Antispiegel | 08:30/14:30/20:30 |
| 17 | Riehle News | 09:00 Uhr |
## SEO (Rank Math)
- Plugin: Rank Math SEO (CT 101, WordPress)
- Sitemap: /sitemap_index.xml (automatisch, alle Posts)
- Open Graph + Twitter Cards: aktiv für alle Beiträge
- wp-login.php: öffentlich gesperrt (.htaccess → 403), nur Tailscale (100.x.x.x)
- Google-Verifizierung: /google248e38a1e3540863.html
- Phishing-Review: beantragt 27.02.2026 (wp-login war öffentlich sichtbar)
## Eigene Artikel (Serie: ESP32 Heizung)
| Teil | Status | Titel | WP Post-ID |
|------|--------|-------|------------|
| 1 | veröffentlicht | Vier Heizungen, ein Pufferspeicher: Warum ich meine Heizung smart mache | 1209 |
| 2 | geplant 06.03. | Hardware: ESP32, DS18B20-Sensoren, Verkabelung | — |
| 3 | geplant | Software: InfluxDB, Grafana-Dashboard | — |
| 4 | geplant | Display-Projekt: Layout, Wetterprognose, Kostenrechnung | — |
| 5 | geplant | Bonus: 2.8-Zoll-ESP32 als WLAN-Scanner | — |
### Quellen für Artikelinhalt
- Entwurf Teil 1: `/root/homelab-brain/arakava-news/artikel/esp32-serie-teil1-entwurf.md`
- Technische Doku (Display-Layouts, Regeln, MQTT, Hardware): `pct exec 999 -- cat /root/ESP32-Heizung-Projekt.md`
- Hardware + Pin-Belegung: `/root/homelab-brain/esp32/PLAN.md`
- Smart Home Kontext (ioBroker, Grafana, InfluxDB): `/root/homelab-brain/smart-home/STATE.md`
- Grafiken (Header + Schema): WP Media ID 1207, 1208
- Display-Mockups: Aus ASCII-Art in Doku generieren (noch offen)
- Heizung: 4 Quellen (Solar, Holzvergaser, Luft-Luft-WP, Ölkessel), 1800L Puffer, Luft-Luft-WP NICHT am Puffer!
### Seafile-Assets (lokal kopiert)
Quelle: `Seafile → Nextcloud-Migration/Meine Dateien/Server/ESP32 Projekt`
Lokal: `/root/homelab-brain/arakava-news/artikel/seafile-assets/`
- `Fließschaltbild v4 HTML.md` — Animiertes interaktives SVG-Schaltbild (Solar/Holz/Öl, Energieflüsse animiert, Buttons für Betriebsmodi) → in WP als Custom HTML Block einbettbar
- `Fließschaltbild von Clode 4.6 als HTML.md` — Vereinfachte Version
- `Sensoren und Bilder.md` — Detaillierte Sensor-Planung (17 Sensoren: 6 Erzeuger, 2 Heizung, 4 HK-Rückläufe, 5 Puffer-Zonen)
- `Kombipufferspeicher.webp` — Grafik Pufferspeicher
### Seafile: Echte Fotos der Heizungsanlage
Pfad: `Seafile → Nextcloud-Migration/Meine Dateien/Heizung und Lüftung/Bilder`
- Mein HV.JPG — Holzvergaser
- PT100 im Abgasrohr des Holzvergasers.JPG — Abgassensor
- Pufferspeicher mit Isolierung.JPG — Puffertank
- Puffertank ohne Isolierung.jpg — Tank nackt
- Solarwärmetauscher.jpg — Solarthermie
- Heizkreisverteiler vor dem Umbau.jpg — Verteiler
- Siemens Logo und Solarregler mit KWH Anzeige.JPG — Steuerung
## Code (CT 109: /opt/rss-manager/)
poster.py, scheduler.py, app.py, db.py
## Änderungshistorie
- 27.02.2026: Google Search Console eingerichtet + Sitemap eingereicht
- 27.02.2026: Rank Math SEO installiert (Open Graph, Sitemap, Meta Tags)
- 27.02.2026: wp-login.php öffentlich gesperrt (Anti-Phishing)
- 27.02.2026: ESP32-Heizung Artikel Teil 1 veröffentlicht (Post 1209)
- 24.02.2026: Scheduler Lock gegen Doppelstarts
- 24.02.2026: Telegram auf HTML-Modus (Sonderzeichen-Fix)
- 24.02.2026: Werbeartikel-Blacklist (Anzeige:, Sponsored, etc.)

View file

@ -0,0 +1,138 @@
# Vier Heizungen, ein Pufferspeicher: Warum ich meine Heizung smart mache
**Serie: Mein Smart-Home-Heizungsprojekt mit ESP32 — Teil 1 von 5**
---
## Das Problem: Vier Wärmequellen, null Überblick
In meinem Haus stecken vier verschiedene Heizungen. Klingt nach Luxus — ist im Alltag eher Chaos:
- **Solarthermie** auf dem Dach — gratis, aber nur wenn die Sonne scheint
- **Holzvergaser** — billigste Wärme, aber ich muss selbst nachlegen
- **Ölkessel** — zuverlässiges Backup, aber teuer
- **Luft-Luft-Wärmepumpe** — heizt Räume direkt mit warmer Luft, ohne Umweg über Wasser
Die ersten drei speisen in denselben **Pufferspeicher** — einen 1800-Liter-Tank, der die Wärme zwischenspeichert und über Heizkörper ans Haus verteilt. Die Luft-Luft-Wärmepumpe läuft separat: Sie bläst warme Luft direkt in den Raum und hat mit dem Puffer nichts zu tun.
Im Prinzip eine clevere Kombi. In der Praxis bedeutet es: Ich renne ständig in den Keller, schaue auf Thermometer, drehe Ventile und frage mich, ob der Holzvergaser noch läuft, die Sonne gleich rauskommt — oder ob ich einfach die Luft-Luft-WP anwerfe und den Puffer in Ruhe lasse.
**Die zentrale Frage jeden Morgen:** Ölheizung anmachen, Luft-Luft-WP laufen lassen — oder darauf wetten, dass die Sonne reicht?
---
## Was mich das kostet (wenn ich falsch rate)
Ein Beispiel aus dem letzten Winter:
Dienstagmorgen, minus 3 Grad. Puffer steht bei 45°C. Die Wetter-App sagt „teils sonnig". Ich lasse den Ölkessel aus — soll die Sonne machen. Um 14 Uhr ist es immer noch bewölkt, der Puffer ist auf 35°C gefallen, das Haus wird kalt. Jetzt muss der Ölkessel ran — im ungünstigsten Moment, weil der Puffer so weit runtergekühlt ist.
**Ergebnis:** 8 Liter Heizöl verbrannt — rund 12 Euro an einem einzigen Tag. Die Solarthermie hat null beigetragen. Hätte ich morgens den Holzvergaser angeworfen, wäre das Holz für die gleiche Wärme bei vielleicht 3 Euro gelegen. Vierfacher Preisunterschied, nur weil ich falsch geraten habe.
Das ist kein Einzelfall. Die Kombination aus vier Wärmequellen ist nur dann sparsam, wenn man **zur richtigen Zeit die richtige Quelle** einschaltet. Manuell ist das Glücksspiel.
---
## Die Idee: Ein ESP32 mit Display als Heizungs-Gehirn
Die Lösung ist überraschend günstig: Ein **ESP32-Mikrocontroller** mit einem **5-Zoll-Touchscreen** — zusammen für rund 30 Euro. Dazu acht Temperatursensoren (10 Euro) und etwas Kabel. Gesamtkosten: **unter 70 Euro**.
Was das kleine Gerät können soll:
1. **Alle Temperaturen live anzeigen** — Puffer oben/mitte/unten, Außentemperatur, Kollektor, Abgastemperatur
2. **Wetterprognose einbeziehen** — Wird es sonnig? Dann Öl sperren und auf Solar warten
3. **Automatisch entscheiden** — Nach fünf einfachen Regeln
4. **Ersparnisse berechnen** — Was hätte der Ölkessel gekostet? Was habe ich gespart?
---
## Die fünf Regeln — so einfach wie möglich
Nach monatelangem Beobachten meiner Heizung habe ich fünf Regeln destilliert, die fast jede Situation abdecken:
### Regel 1: Holzvergaser läuft? → Alles andere aus!
Der Holzvergaser ist die billigste Wärmequelle. Wenn er brennt, braucht niemand sonst zu laufen. Punkt.
### Regel 2: Sonne erwartet? → Morgens den Ölkessel sperren
Das ist die wichtigste Regel für die Geldbörse. Wenn die Wetterprognose mehr als 3 Sonnenstunden verspricht, bleibt der Ölkessel morgens gesperrt. Die Solarthermie übernimmt.
### Regel 3: Außentemperatur über +5°C? → Luft-Luft-WP erlaubt
Die Luft-Luft-Wärmepumpe heizt Räume direkt mit warmer Luft — ganz ohne Pufferspeicher. Ab +5°C aufwärts liefert sie einen guten COP (Leistungszahl) — sprich: aus 1 kWh Strom werden 34 kWh Wärme. Ideal als Ergänzung, wenn der Puffer gerade nicht nachgeladen werden muss.
### Regel 4: Außentemperatur unter +5°C? → Öl statt Luft-Luft-WP
Unter 5 Grad quält sich die Luft-Luft-WP — der COP sinkt auf 2 oder weniger, der Stromverbrauch steigt überproportional. Dann lieber den Ölkessel den Puffer laden lassen und die Räume über Heizkörper versorgen.
### Regel 5: Puffer unter 35°C? → Notstart!
Egal was die anderen Regeln sagen: Wenn der Puffer unter 35°C fällt, wird sofort die schnellste verfügbare Quelle gestartet. Kaltes Haus ist nicht verhandelbar.
---
## Die Prioritäten-Kette
Wenn keine Sonderregel greift, gilt diese Reihenfolge:
| Priorität | Quelle | Kosten ca. | Bedingung |
|-----------|--------|-----------|-----------|
| 1 | ☀️ Solarthermie | 0 €/kWh | Sonne scheint |
| 2 | 🪵 Holzvergaser | ~0,04 €/kWh | Manuell befeuern |
| 3 | 🌡️ Luft-Luft-WP | ~0,08 €/kWh | Außen > +5°C |
| 4 | 🛢️ Ölkessel | ~0,12 €/kWh | Immer verfügbar |
Die Logik: **Gratis vor billig vor effizient vor teuer.**
---
## Was auf dem Display zu sehen sein wird
Das 5-Zoll-Display zeigt auf einen Blick:
- **Oben:** Außentemperatur und Datum
- **Mitte:** Wetterprognose mit Empfehlung (z.B. „5 Stunden Sonne erwartet → Öl bleibt aus")
- **Puffer-Balken:** Wie voll ist der Speicher? Temperatur oben, mitte, unten
- **Status aller vier Quellen:** Welche läuft, welche wartet, welche ist gesperrt?
- **Unten:** Tagesersparnis in Euro — was hätte der Ölkessel alleine gekostet?
*[Grafik: Display-Layout mit Pufferbalken und Statusanzeige — folgt in Teil 2]*
---
## Was kommt in den nächsten Teilen?
| Teil | Thema |
|------|-------|
| **Teil 2** | Hardware: ESP32-8048S050, DS18B20-Sensoren, Verkabelung — die komplette Einkaufsliste unter 70€ |
| **Teil 3** | Software: Sensordaten sammeln, InfluxDB, Grafana-Dashboard — der digitale Blick in den Keller |
| **Teil 4** | Das Display-Projekt: Layout, Wetterprognose, Kostenrechnung live auf 5 Zoll |
| **Teil 5** | Bonus: Ein 2.8-Zoll-ESP32 als WLAN-Scanner und Diagnose-Tool |
---
## Fazit: 70 Euro gegen Bauchgefühl
Bisher entscheide ich nach Bauchgefühl, welche Heizung laufen soll. Manchmal liege ich richtig, oft nicht. Ein ESP32 mit acht Temperatursensoren und einer Wetterprognose kann das besser — für weniger als eine Tankfüllung Heizöl.
Im nächsten Teil geht es ans Eingemachte: Welche Hardware genau, wo bestellen, und wie sieht die Verkabelung aus. **Teil 2 erscheint am 6. März.**
---
*Dieser Artikel ist Teil 1 der Serie „Mein Smart-Home-Heizungsprojekt mit ESP32". Die Serie dokumentiert den kompletten Aufbau einer intelligenten Heizungssteuerung mit vier Wärmequellen — von der Idee bis zum fertigen Display.*
---
**Tags:** ESP32, Smart Home, Heizung, Pufferspeicher, Solarthermie, Holzvergaser, Wärmepumpe, DIY, InfluxDB, Grafana
**Kategorie:** Technik / Smart Home
**Lesezeit:** ca. 6 Minuten
---
### Grafik-Ideen für diesen Artikel
1. **Titelbild:** Heizungskeller mit Pufferspeicher, vier farbige Rohre (Solar=orange, Holz=braun, WP=blau, Öl=rot), ESP32-Display im Vordergrund
2. **Schaubild:** Die vier Wärmequellen mit Pfeilen zum Pufferspeicher — Prioritäten-Kette als Infografik
3. **Display-Mockup:** Screenshot des geplanten 5-Zoll-Layouts (Pufferbalken, Statusanzeige, Wetterprognose)
4. **Kostenvergleich:** Balkendiagramm — Heizkosten mit vs. ohne smartes System (geschätzt)

View file

@ -0,0 +1,488 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Heizungsanlage - Hydraulikplan</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{
background:#111820;color:#ddd;
font-family:'Segoe UI',Arial,sans-serif;
display:flex;flex-direction:column;align-items:center;
padding:15px;
}
h1{font-size:1.3em;margin:8px 0 4px}
.sub{color:#778;font-size:.85em;margin-bottom:10px}
.btns{display:flex;gap:6px;margin-bottom:12px;flex-wrap:wrap;justify-content:center}
.btns button{
padding:10px 20px;border-radius:6px;border:2px solid #333;
background:#181e26;color:#bbb;cursor:pointer;font-size:.95em;
transition:.3s;
}
.btns button:hover{background:#222a35}
.btns button.on-sol{border-color:#e6a800;color:#ffd33d;background:#1f1a00}
.btns button.on-holz{border-color:#e07030;color:#f0883e;background:#1f1000}
.btns button.on-oel{border-color:#4090e0;color:#58a6ff;background:#001828}
.btns button.on-all{border-color:#a070f0;color:#c8a0ff;background:#150028}
.wrap{
width:100%;max-width:1100px;
background:#0d1117;border:1px solid #222;border-radius:10px;
overflow:hidden;
}
svg{width:100%;height:auto;display:block}
@keyframes fliess{from{stroke-dashoffset:30}to{stroke-dashoffset:0}}
@keyframes fliessR{from{stroke-dashoffset:0}to{stroke-dashoffset:30}}
.flow{
fill:none;stroke-linecap:round;stroke-linejoin:round;
stroke-dasharray:10,20;animation:fliess .8s linear infinite;
opacity:0;transition:opacity .6s;
}
.flow.rev{animation:fliessR .8s linear infinite}
.flow.on{opacity:1}
.comp{transition:opacity .4s}
.comp.dim{opacity:.2}
</style>
</head>
<body>
<h1>Heizungsanlage - Hydraulikplan</h1>
<p class="sub">Klicke einen Betriebsmodus um die Energiefluesse zu sehen</p>
<div class="btns">
<button id="b-sol" onclick="setMode('sol')">Solar</button>
<button id="b-holz" onclick="setMode('holz')">Holzvergaser</button>
<button id="b-oel" onclick="setMode('oel')">Oelkessel</button>
<button id="b-all" onclick="setMode('all')">Alle Quellen</button>
</div>
<div class="wrap">
<svg viewBox="0 0 1000 880" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="tempG" x1="0" y1="1" x2="0" y2="0">
<stop offset="0%" stop-color="#112244" stop-opacity=".35"/>
<stop offset="100%" stop-color="#441111" stop-opacity=".35"/>
</linearGradient>
</defs>
<!-- ======== HINTERGRUND-ROHRE ======== -->
<g stroke="#1e242c" stroke-width="5" fill="none"
stroke-linecap="round" stroke-linejoin="round">
<!-- Öl-Kessel VL: Erzeuger → oberer Speicheranschluss -->
<path d="M130,155 L130,210 L320,210 L320,265"/>
<!-- Öl-Kessel RL: unterer Speicheranschluss → Erzeuger -->
<path d="M200,155 L200,225 L370,225 L370,580"/>
<!-- Holzvergaser VL: Erzeuger → oberer Speicheranschluss -->
<path d="M430,155 L430,210 L490,210 L490,265"/>
<!-- Holzvergaser RL: unterer Speicheranschluss → Erzeuger -->
<path d="M500,155 L500,225 L540,225 L540,580"/>
<!-- Solar VL: Kollektor → unterer interner Solar-WT -->
<path d="M800,155 L800,210 L680,210 L680,520"/>
<!-- Solar RL: aus WT → zurück zum Kollektor -->
<path d="M630,520 L630,220 L860,220 L860,155"/>
<!-- WW-Entnahme oben aus Speicher -->
<path d="M700,330 L770,330 L770,385 L890,385"/>
<!-- KW-Eingang -->
<path d="M890,500 L770,500 L770,475 L700,475"/>
<!-- Heizungs-VL: oben/mittig aus Speicher → Mischer → Verteiler -->
<path d="M540,380 L540,660 L540,710"/>
<path d="M540,745 L540,785"/>
<!-- Heizungs-RL: Sammler → unten in Speicher -->
<path d="M410,580 L410,710 L410,840"/>
<!-- Verteiler/Sammler Leitungen -->
<path d="M210,785 L810,785"/>
<path d="M210,840 L810,840"/>
<line x1="280" y1="785" x2="280" y2="800"/>
<line x1="440" y1="785" x2="440" y2="800"/>
<line x1="590" y1="785" x2="590" y2="800"/>
<line x1="740" y1="785" x2="740" y2="800"/>
<line x1="280" y1="825" x2="280" y2="840"/>
<line x1="440" y1="825" x2="440" y2="840"/>
<line x1="590" y1="825" x2="590" y2="840"/>
<line x1="740" y1="825" x2="740" y2="840"/>
</g>
<!-- ======== ANIMIERTE FLUSS-ROHRE ======== -->
<!-- Öl-Kessel: VL Erzeuger → oberer Speicheranschluss (M→L = Flussrichtung) -->
<path class="flow" id="f-oel-vl" stroke="#58a6ff" stroke-width="4"
d="M130,155 L130,210 L320,210 L320,265"/>
<!-- Öl-Kessel: RL unterer Speicheranschluss → Erzeuger (M→L, .rev für Rückfluss) -->
<path class="flow rev" id="f-oel-rl" stroke="#2255aa" stroke-width="4"
d="M200,155 L200,225 L370,225 L370,580"/>
<!-- Holzvergaser: VL Erzeuger → oberer Speicheranschluss -->
<path class="flow" id="f-holz-vl" stroke="#f0883e" stroke-width="4"
d="M430,155 L430,210 L490,210 L490,265"/>
<!-- Holzvergaser: RL unterer Speicheranschluss → Erzeuger -->
<path class="flow rev" id="f-holz-rl" stroke="#885522" stroke-width="4"
d="M500,155 L500,225 L540,225 L540,580"/>
<!-- Solar: VL Kollektor → unterer interner Solar-WT -->
<path class="flow" id="f-sol-vl" stroke="#ffd33d" stroke-width="4"
d="M800,155 L800,210 L680,210 L680,520"/>
<!-- Solar: RL aus WT → zurück zum Kollektor -->
<path class="flow rev" id="f-sol-rl" stroke="#997700" stroke-width="4"
d="M630,520 L630,220 L860,220 L860,155"/>
<!-- Warmwasser: Entnahme oben aus Speicher (keine Animation im Speicher) -->
<path class="flow" id="f-ww" stroke="#dd5500" stroke-width="3"
d="M700,330 L770,330 L770,385 L890,385"/>
<!-- Kaltwasser Eingang -->
<path class="flow rev" id="f-kw" stroke="#33aadd" stroke-width="3"
d="M890,500 L770,500 L770,475 L700,475"/>
<!-- Heizungs-VL: oben/mittig aus Speicher → Mischer → Verteiler -->
<path class="flow" id="f-vl-h" stroke="#cc3333" stroke-width="4"
d="M540,380 L540,660 L540,710"/>
<path class="flow" id="f-vl-v" stroke="#cc3333" stroke-width="3"
d="M540,745 L540,785"/>
<path class="flow" id="f-vl-vert" stroke="#cc3333" stroke-width="3"
d="M540,785 L810,785 M540,785 L210,785"/>
<path class="flow" id="f-hk-vl1" stroke="#cc3333" stroke-width="3" d="M280,785 L280,800"/>
<path class="flow" id="f-hk-vl2" stroke="#cc3333" stroke-width="3" d="M440,785 L440,800"/>
<path class="flow" id="f-hk-vl3" stroke="#cc3333" stroke-width="3" d="M590,785 L590,800"/>
<path class="flow" id="f-hk-vl4" stroke="#cc3333" stroke-width="3" d="M740,785 L740,800"/>
<!-- Heizungs-RL: Sammler → unten in Speicher -->
<path class="flow rev" id="f-rl-p" stroke="#2255aa" stroke-width="4"
d="M410,580 L410,710 L410,840"/>
<path class="flow rev" id="f-rl-vert" stroke="#2255aa" stroke-width="3"
d="M210,840 L810,840"/>
<path class="flow rev" id="f-hk-rl1" stroke="#2255aa" stroke-width="3" d="M280,825 L280,840"/>
<path class="flow rev" id="f-hk-rl2" stroke="#2255aa" stroke-width="3" d="M440,825 L440,840"/>
<path class="flow rev" id="f-hk-rl3" stroke="#2255aa" stroke-width="3" d="M590,825 L590,840"/>
<path class="flow rev" id="f-hk-rl4" stroke="#2255aa" stroke-width="3" d="M740,825 L740,840"/>
<!-- ======== PUFFERSPEICHER ======== -->
<rect x="270" y="255" width="450" height="355" rx="10"
fill="#0a1220" stroke="#336699" stroke-width="2.5"/>
<rect x="280" y="265" width="430" height="335" rx="6" fill="url(#tempG)"/>
<text x="495" y="290" text-anchor="middle" fill="#336699"
font-size="16" font-weight="bold" opacity=".5">PUFFERSPEICHER</text>
<!-- Anschluesse Rechts -->
<text x="695" y="340" fill="#dd5500" font-size="11" opacity=".6">WW</text>
<text x="695" y="480" fill="#33aadd" font-size="11" opacity=".6">KW</text>
<!-- Anschluesse Links oben (Öl/Holz VL-Einspeisung) -->
<text x="325" y="280" fill="#58a6ff" font-size="9" opacity=".6">Öl-VL</text>
<text x="495" y="280" fill="#f0883e" font-size="9" opacity=".6">Holz-VL</text>
<!-- Anschluesse Links unten (Öl/Holz RL-Abgang) -->
<text x="375" y="595" fill="#2255aa" font-size="9" opacity=".6">Öl-RL</text>
<text x="545" y="595" fill="#885522" font-size="9" opacity=".6">Holz-RL</text>
<!-- Heizungs-VL/RL Anschlüsse -->
<text x="555" y="395" fill="#cc3333" font-size="9" opacity=".6">Hz-VL</text>
<text x="395" y="595" fill="#2255aa" font-size="9" opacity=".6">Hz-RL</text>
<text x="290" y="310" fill="#cc4444" font-size="10" opacity=".4">heiss</text>
<text x="290" y="585" fill="#4488ff" font-size="10" opacity=".4">kalt</text>
<!-- Solar-Wärmetauscher im unteren Bereich des Puffers -->
<rect x="580" y="490" width="120" height="60" rx="5"
fill="#181400" stroke="#d4a000" stroke-width="1.5"/>
<text x="640" y="515" text-anchor="middle" fill="#e6a800"
font-size="10">SOLAR</text>
<text x="640" y="530" text-anchor="middle" fill="#e6a800"
font-size="10">Waermetauscher</text>
<!-- Solar-WT Anschlüsse -->
<text x="685" y="525" fill="#ffd33d" font-size="9" opacity=".6">VL</text>
<text x="625" y="555" fill="#997700" font-size="9" opacity=".6">RL</text>
<!-- WW-Tauscher im oberen Bereich des Puffers -->
<rect x="430" y="310" width="270" height="55" rx="5"
fill="#120800" stroke="#c05000" stroke-width="1.5"/>
<text x="565" y="333" text-anchor="middle" fill="#dd6600"
font-size="10">WARMWASSER</text>
<text x="565" y="352" text-anchor="middle" fill="#dd6600"
font-size="10">Waermetauscher</text>
<!-- ======== OEL-KESSEL ======== -->
<g class="comp" id="c-oel">
<rect x="65" y="50" width="200" height="105" rx="10"
fill="#0d1520" stroke="#4090e0" stroke-width="2.5"/>
<g transform="translate(165,82)">
<path d="M0,-22 C-5,-15 -12,-8 -12,2 C-12,10 -6,16 0,16 C6,16 12,10 12,2 C12,-8 5,-15 0,-22Z"
fill="none" stroke="#58a6ff" stroke-width="2"/>
<path d="M0,-12 C-3,-8 -6,-3 -6,2 C-6,5 -3,8 0,8 C3,8 6,5 6,2 C6,-3 3,-8 0,-12Z"
fill="#58a6ff" opacity=".4"/>
</g>
<text x="165" y="118" text-anchor="middle" fill="#58a6ff"
font-size="14" font-weight="bold">Oel-Kessel</text>
<text x="130" y="145" text-anchor="middle" fill="#58a6ff" font-size="9">VL</text>
<text x="200" y="145" text-anchor="middle" fill="#2255aa" font-size="9">RL</text>
<polygon points="130,155 125,165 135,165" fill="#58a6ff"/>
<polygon points="200,165 195,155 205,155" fill="#2255aa"/>
</g>
<!-- ======== HOLZVERGASER ======== -->
<g class="comp" id="c-holz">
<rect x="355" y="50" width="200" height="105" rx="10"
fill="#150e00" stroke="#e07830" stroke-width="2.5"/>
<g transform="translate(455,78)">
<rect x="-15" y="2" width="30" height="8" rx="3" fill="#885522" opacity=".6"/>
<rect x="-12" y="-4" width="24" height="8" rx="3" fill="#aa6633" opacity=".6"/>
<path d="M0,-22 C-4,-14 -9,-7 -9,1 C-9,7 -5,12 0,12 C5,12 9,7 9,1 C9,-7 4,-14 0,-22Z"
fill="none" stroke="#f0883e" stroke-width="2"/>
<path d="M0,-12 C-2,-7 -4,-2 -4,2 C-4,4 -2,6 0,6 C2,6 4,4 4,2 C4,-2 2,-7 0,-12Z"
fill="#f0883e" opacity=".5"/>
</g>
<text x="455" y="118" text-anchor="middle" fill="#f0883e"
font-size="13" font-weight="bold">Holzvergaser</text>
<text x="430" y="145" text-anchor="middle" fill="#f0883e" font-size="9">VL</text>
<text x="500" y="145" text-anchor="middle" fill="#885522" font-size="9">RL</text>
<polygon points="430,155 425,165 435,165" fill="#f0883e"/>
<polygon points="500,165 495,155 505,155" fill="#885522"/>
</g>
<!-- ======== THERMO-SOLAR ======== -->
<g class="comp" id="c-sol">
<rect x="745" y="50" width="200" height="105" rx="10"
fill="#181400" stroke="#d4a000" stroke-width="2.5"/>
<g transform="translate(845,82)" stroke="#ffd33d" stroke-width="2" fill="none">
<circle r="8" fill="#ffd33d" opacity=".3"/>
<line x1="0" y1="-14" x2="0" y2="-20"/>
<line x1="0" y1="14" x2="0" y2="20"/>
<line x1="-14" y1="0" x2="-20" y2="0"/>
<line x1="14" y1="0" x2="20" y2="0"/>
<line x1="-10" y1="-10" x2="-14" y2="-14"/>
<line x1="10" y1="-10" x2="14" y2="-14"/>
<line x1="-10" y1="10" x2="-14" y2="14"/>
<line x1="10" y1="10" x2="14" y2="14"/>
</g>
<text x="845" y="118" text-anchor="middle" fill="#e6a800"
font-size="13" font-weight="bold">Thermo-Solar</text>
<text x="800" y="145" text-anchor="middle" fill="#ffd33d" font-size="9">VL</text>
<text x="860" y="145" text-anchor="middle" fill="#997700" font-size="9">RL</text>
<polygon points="800,155 795,165 805,165" fill="#ffd33d"/>
<polygon points="860,165 855,155 865,155" fill="#997700"/>
</g>
<!-- Solar-Pumpe -->
<circle cx="800" cy="185" r="12" fill="#181400" stroke="#d4a000" stroke-width="1.5"/>
<polygon points="794,180 794,190 804,185" fill="#d4a000"/>
<!-- ======== WW / KW BOXEN ======== -->
<rect x="870" y="365" width="120" height="40" rx="6"
fill="#180800" stroke="#dd5500" stroke-width="1.5"/>
<text x="930" y="390" text-anchor="middle" fill="#dd5500"
font-size="11" font-weight="bold">WW-Ausgang</text>
<rect x="870" y="480" width="120" height="40" rx="6"
fill="#001520" stroke="#33aadd" stroke-width="1.5"/>
<text x="930" y="505" text-anchor="middle" fill="#33aadd"
font-size="11" font-weight="bold">KW-Eingang</text>
<!-- ======== MISCHER ======== -->
<circle cx="540" cy="728" r="18" fill="#111820" stroke="#888" stroke-width="2"/>
<polygon points="528,720 528,736 540,728" fill="#cc2222"/>
<polygon points="552,720 552,736 540,728" fill="#2255bb"/>
<text x="568" y="732" fill="#889" font-size="10">Mischer</text>
<!-- Heizkreis-Pumpe -->
<circle cx="410" cy="728" r="14" fill="#111820" stroke="#888" stroke-width="2"/>
<polygon points="404,722 404,734 416,728" fill="#888"/>
<text x="410" y="755" text-anchor="middle" fill="#778" font-size="9">Pumpe</text>
<!-- VL / RL Labels -->
<text x="552" y="640" fill="#cc3333" font-size="11" font-weight="bold">VL</text>
<text x="388" y="640" fill="#2255aa" font-size="11" font-weight="bold">RL</text>
<!-- ======== HEIZKREISE HK 1-4 ======== -->
<!-- HK-1 -->
<rect x="235" y="800" width="90" height="25" rx="3"
fill="#1a1200" stroke="#b08020" stroke-width="1.5"/>
<g stroke="#996622" stroke-width="1" opacity=".5">
<line x1="248" y1="803" x2="248" y2="822"/>
<line x1="256" y1="803" x2="256" y2="822"/>
<line x1="264" y1="803" x2="264" y2="822"/>
<line x1="272" y1="803" x2="272" y2="822"/>
<line x1="280" y1="803" x2="280" y2="822"/>
<line x1="288" y1="803" x2="288" y2="822"/>
<line x1="296" y1="803" x2="296" y2="822"/>
<line x1="304" y1="803" x2="304" y2="822"/>
<line x1="312" y1="803" x2="312" y2="822"/>
</g>
<text x="280" y="816" text-anchor="middle" fill="#d29922"
font-size="10" font-weight="bold">HK-1</text>
<!-- HK-2 -->
<rect x="395" y="800" width="90" height="25" rx="3"
fill="#1a1200" stroke="#b08020" stroke-width="1.5"/>
<g stroke="#996622" stroke-width="1" opacity=".5">
<line x1="408" y1="803" x2="408" y2="822"/>
<line x1="416" y1="803" x2="416" y2="822"/>
<line x1="424" y1="803" x2="424" y2="822"/>
<line x1="432" y1="803" x2="432" y2="822"/>
<line x1="440" y1="803" x2="440" y2="822"/>
<line x1="448" y1="803" x2="448" y2="822"/>
<line x1="456" y1="803" x2="456" y2="822"/>
<line x1="464" y1="803" x2="464" y2="822"/>
<line x1="472" y1="803" x2="472" y2="822"/>
</g>
<text x="440" y="816" text-anchor="middle" fill="#d29922"
font-size="10" font-weight="bold">HK-2</text>
<!-- HK-3 -->
<rect x="545" y="800" width="90" height="25" rx="3"
fill="#1a1200" stroke="#b08020" stroke-width="1.5"/>
<g stroke="#996622" stroke-width="1" opacity=".5">
<line x1="558" y1="803" x2="558" y2="822"/>
<line x1="566" y1="803" x2="566" y2="822"/>
<line x1="574" y1="803" x2="574" y2="822"/>
<line x1="582" y1="803" x2="582" y2="822"/>
<line x1="590" y1="803" x2="590" y2="822"/>
<line x1="598" y1="803" x2="598" y2="822"/>
<line x1="606" y1="803" x2="606" y2="822"/>
<line x1="614" y1="803" x2="614" y2="822"/>
<line x1="622" y1="803" x2="622" y2="822"/>
</g>
<text x="590" y="816" text-anchor="middle" fill="#d29922"
font-size="10" font-weight="bold">HK-3</text>
<!-- HK-4 -->
<rect x="695" y="800" width="90" height="25" rx="3"
fill="#1a1200" stroke="#b08020" stroke-width="1.5"/>
<g stroke="#996622" stroke-width="1" opacity=".5">
<line x1="708" y1="803" x2="708" y2="822"/>
<line x1="716" y1="803" x2="716" y2="822"/>
<line x1="724" y1="803" x2="724" y2="822"/>
<line x1="732" y1="803" x2="732" y2="822"/>
<line x1="740" y1="803" x2="740" y2="822"/>
<line x1="748" y1="803" x2="748" y2="822"/>
<line x1="756" y1="803" x2="756" y2="822"/>
<line x1="764" y1="803" x2="764" y2="822"/>
<line x1="772" y1="803" x2="772" y2="822"/>
</g>
<text x="740" y="816" text-anchor="middle" fill="#d29922"
font-size="10" font-weight="bold">HK-4</text>
<!-- Verteiler/Sammler Labels -->
<text x="175" y="789" fill="#cc3333" font-size="9">VL-Verteiler</text>
<text x="175" y="844" fill="#2255aa" font-size="9">RL-Sammler</text>
<!-- ======== LEGENDE ======== -->
<g transform="translate(30,860)">
<line x1="0" y1="0" x2="25" y2="0" stroke="#cc3333" stroke-width="3"/>
<text x="30" y="4" fill="#999" font-size="9">Vorlauf (heiss)</text>
<line x1="140" y1="0" x2="165" y2="0" stroke="#2255aa" stroke-width="3"/>
<text x="170" y="4" fill="#999" font-size="9">Ruecklauf (kalt)</text>
<line x1="300" y1="0" x2="325" y2="0" stroke="#ffd33d" stroke-width="3"/>
<text x="330" y="4" fill="#999" font-size="9">Solar</text>
<line x1="390" y1="0" x2="415" y2="0" stroke="#dd5500" stroke-width="3"/>
<text x="420" y="4" fill="#999" font-size="9">Warmwasser</text>
<line x1="520" y1="0" x2="545" y2="0" stroke="#33aadd" stroke-width="3"/>
<text x="550" y="4" fill="#999" font-size="9">Kaltwasser</text>
</g>
</svg>
</div>
<div id="info" style="margin-top:12px;padding:10px 20px;
background:#161b22;border:1px solid #222;border-radius:8px;
max-width:700px;font-size:.9em;text-align:center;color:#889">
Waehle oben einen Betriebsmodus aus.
</div>
<script>
var modes = {
sol: {
flows: ['f-sol-vl','f-sol-rl','f-ww','f-kw'],
active: ['c-sol'],
dim: ['c-oel','c-holz'],
btn: 'on-sol',
text: 'SOLAR-BETRIEB: Solaranlage laedt Puffer ueber unteren WT, Warmwasser wird bereitet'
},
holz: {
flows: [
'f-holz-vl','f-holz-rl','f-ww','f-kw',
'f-vl-h','f-vl-v','f-vl-vert',
'f-hk-vl1','f-hk-vl2','f-hk-vl3','f-hk-vl4',
'f-rl-p','f-rl-vert',
'f-hk-rl1','f-hk-rl2','f-hk-rl3','f-hk-rl4'
],
active: ['c-holz'],
dim: ['c-oel','c-sol'],
btn: 'on-holz',
text: 'HOLZVERGASER-BETRIEB: Holz laedt Puffer oben, Heizung + Warmwasser aktiv'
},
oel: {
flows: [
'f-oel-vl','f-oel-rl','f-ww','f-kw',
'f-vl-h','f-vl-v','f-vl-vert',
'f-hk-vl1','f-hk-vl2','f-hk-vl3','f-hk-vl4',
'f-rl-p','f-rl-vert',
'f-hk-rl1','f-hk-rl2','f-hk-rl3','f-hk-rl4'
],
active: ['c-oel'],
dim: ['c-holz','c-sol'],
btn: 'on-oel',
text: 'OELKESSEL-BETRIEB: Oel-Kessel laedt Puffer oben, Heizung + Warmwasser aktiv'
},
all: {
flows: [
'f-oel-vl','f-oel-rl','f-holz-vl','f-holz-rl',
'f-sol-vl','f-sol-rl','f-ww','f-kw',
'f-vl-h','f-vl-v','f-vl-vert',
'f-hk-vl1','f-hk-vl2','f-hk-vl3','f-hk-vl4',
'f-rl-p','f-rl-vert',
'f-hk-rl1','f-hk-rl2','f-hk-rl3','f-hk-rl4'
],
active: ['c-oel','c-holz','c-sol'],
dim: [],
btn: 'on-all',
text: 'ALLE QUELLEN AKTIV: Maximale Waermeleistung'
}
};
var cur = null;
function setMode(m) {
var i, el, allFlows, allComps, allBtns;
allFlows = document.querySelectorAll('.flow');
for (i = 0; i < allFlows.length; i++) allFlows[i].classList.remove('on');
allComps = document.querySelectorAll('.comp');
for (i = 0; i < allComps.length; i++) allComps[i].classList.remove('dim');
allBtns = document.querySelectorAll('.btns button');
for (i = 0; i < allBtns.length; i++) allBtns[i].className = '';
if (cur === m) {
cur = null;
document.getElementById('info').innerHTML = 'Waehle oben einen Betriebsmodus aus.';
return;
}
cur = m;
var cfg = modes[m];
for (i = 0; i < cfg.flows.length; i++) {
el = document.getElementById(cfg.flows[i]);
if (el) el.classList.add('on');
}
for (i = 0; i < cfg.dim.length; i++) {
el = document.getElementById(cfg.dim[i]);
if (el) el.classList.add('dim');
}
document.getElementById('b-' + m).className = cfg.btn;
document.getElementById('info').innerHTML = cfg.text;
}
</script>
</body>
</html>

View file

@ -0,0 +1,188 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Heizungsanlage Hydraulisches Schaltbild</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{background:#0d1117;color:#e6edf3;font-family:'Segoe UI',system-ui,sans-serif;
display:flex;flex-direction:column;align-items:center;padding:20px;min-height:100vh}
h1{font-size:1.45em;margin-bottom:6px}
.sub{color:#8b949e;margin-bottom:14px;font-size:.92em}
.ctrls{display:flex;gap:8px;margin-bottom:15px;flex-wrap:wrap;justify-content:center}
.ctrls button{padding:10px 22px;border:2px solid #30363d;border-radius:8px;
background:#161b22;color:#c9d1d9;font-size:.95em;cursor:pointer;transition:all .3s}
.ctrls button:hover{background:#21262d}
.ctrls button.a-sol{border-color:#ffd33d;background:#2a2000;color:#ffd33d}
.ctrls button.a-holz{border-color:#f0883e;background:#2a1500;color:#f0883e}
.ctrls button.a-oel{border-color:#58a6ff;background:#001a33;color:#58a6ff}
.ctrls button.a-all{border-color:#a371f7;background:#1a0033;color:#a371f7}
.w{width:100%;max-width:1250px;border:1px solid #21262d;border-radius:12px;
overflow:hidden;background:#0d1117}
svg{width:100%;height:auto;display:block}
.info{margin-top:12px;padding:12px 24px;background:#161b22;border:1px solid #21262d;
border-radius:8px;text-align:center;font-size:.93em;max-width:780px;line-height:1.6}
@keyframes dash{from{stroke-dashoffset:24}to{stroke-dashoffset:0}}
.pf{stroke-dasharray:8 16;animation:dash .7s linear infinite;
fill:none;stroke-linecap:round;stroke-linejoin:round}
.fg{opacity:0;transition:opacity .5s}.fg.on{opacity:1}
</style>
</head>
<body>
<h1>🏠 Heizungsanlage Hydraulisches Schaltbild</h1>
<p class="sub">Interaktive Energiefluss-Visualisierung · Klicke einen Betriebsmodus</p>
<div class="ctrls">
<button id="b-sol" onclick="go('sol')">☀️ Solar</button>
<button id="b-holz" onclick="go('holz')">🪵 Holzvergaser</button>
<button id="b-oel" onclick="go('oel')">🛢️ Ölkessel</button>
<button id="b-all" onclick="go('all')">⚡ Alle Quellen</button>
</div>
<div class="w">
<svg viewBox="0 0 1200 960" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="gP" x1="0" y1="1" x2="0" y2="0">
<stop offset="0%" stop-color="#0a1628"/>
<stop offset="100%" stop-color="#1a0808"/>
</linearGradient>
<linearGradient id="gT" x1="0" y1="1" x2="0" y2="0">
<stop offset="0%" stop-color="#223388" stop-opacity=".18"/>
<stop offset="100%" stop-color="#882222" stop-opacity=".18"/>
</linearGradient>
<filter id="gl">
<feGaussianBlur stdDeviation="4" result="b"/>
<feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<filter id="sh">
<feDropShadow dx="2" dy="2" stdDeviation="3" flood-opacity=".35"/>
</filter>
</defs>
<!-- ═══════════ BACKGROUND PIPES ═══════════ -->
<g stroke="#1c2128" stroke-width="6" fill="none"
stroke-linecap="round" stroke-linejoin="round">
<!-- Öl -->
<path d="M150,140 V195 H350 V250"/>
<path d="M390,250 V215 H220 V140"/>
<!-- Holz -->
<path d="M400,140 V195 H490 V250"/>
<path d="M530,250 V215 H470 V140"/>
<!-- Solar -->
<path d="M930,140 V195 H710 V250"/>
<path d="M670,250 V215 H1000 V140"/>
<!-- WW / KW -->
<path d="M700,490 H860 V375 H920"/>
<path d="M920,545 H840 V535 H700"/>
<!-- VL Heizung -->
<path d="M560,600 V780"/>
<!-- RL Heizung -->
<path d="M440,600 V920"/>
<!-- Verteiler VL -->
<line x1="240" y1="780" x2="850" y2="780"/>
<!-- Sammler RL -->
<line x1="240" y1="920" x2="850" y2="920"/>
<!-- HK VL-Drops -->
<line x1="285" y1="780" x2="285" y2="820"/>
<line x1="455" y1="780" x2="455" y2="820"/>
<line x1="625" y1="780" x2="625" y2="820"/>
<line x1="795" y1="780" x2="795" y2="820"/>
<!-- HK RL-Returns -->
<line x1="285" y1="875" x2="285" y2="920"/>
<line x1="455" y1="875" x2="455" y2="920"/>
<line x1="625" y1="875" x2="625" y2="920"/>
<line x1="795" y1="875" x2="795" y2="920"/>
</g>
<!-- ═══════════ PUFFERSPEICHER ═══════════ -->
<rect x="290" y="240" width="530" height="360" rx="12"
fill="url(#gP)" stroke="#336699" stroke-width="3" filter="url(#sh)"/>
<rect x="300" y="250" width="510" height="340" rx="8" fill="url(#gT)"/>
<text x="555" y="282" text-anchor="middle" fill="#336699"
font-size="17" font-weight="bold" opacity=".55">PUFFERSPEICHER</text>
<text x="310" y="305" fill="#ff6666" font-size="10" opacity=".4">▲ heiß</text>
<text x="310" y="585" fill="#6688ff" font-size="10" opacity=".4">▼ kalt</text>
<!-- Anschlüsse ⑦–⑪ -->
<g fill="#336699" font-size="12" opacity=".4">
<text x="808" y="310"></text>
<text x="808" y="360"></text>
<text x="808" y="410"></text>
<text x="808" y="460"></text>
<text x="808" y="510"></text>
</g>
<!-- WW-Wärmetauscher (im Puffer) -->
<rect x="410" y="475" width="290" height="65" rx="6"
fill="#1a0800" stroke="#dd5500" stroke-width="2"/>
<text x="555" y="514" text-anchor="middle" fill="#dd5500"
font-size="12" font-weight="bold">WW-Wärmetauscher</text>
<!-- ═══════════ WÄRMEQUELLEN ═══════════ -->
<!-- Öl-Kessel -->
<rect x="70" y="30" width="190" height="110" rx="10"
fill="#161b22" stroke="#58a6ff" stroke-width="2.5" filter="url(#sh)"/>
<text x="165" y="72" text-anchor="middle" fill="#58a6ff" font-size="24">🛢️</text>
<text x="165" y="100" text-anchor="middle" fill="#58a6ff"
font-size="14" font-weight="bold">Öl-Kessel</text>
<!-- Holzvergaser -->
<rect x="340" y="30" width="190" height="110" rx="10"
fill="#1a1208" stroke="#f0883e" stroke-width="2.5" filter="url(#sh)"/>
<text x="435" y="72" text-anchor="middle" fill="#f0883e" font-size="24">🪵</text>
<text x="435" y="100" text-anchor="middle" fill="#f0883e"
font-size="13" font-weight="bold">Holzvergaser</text>
<!-- Thermo-Solar -->
<rect x="860" y="30" width="200" height="110" rx="10"
fill="#1a1800" stroke="#e6a800" stroke-width="2.5" filter="url(#sh)"/>
<text x="960" y="72" text-anchor="middle" fill="#e6a800" font-size="24">☀️</text>
<text x="960" y="100" text-anchor="middle" fill="#e6a800"
font-size="14" font-weight="bold">Thermo-Solar</text>
<!-- Highlight-Ringe (wenn aktiv) -->
<rect id="hl-oel" x="66" y="26" width="198" height="118" rx="12"
fill="none" stroke="#58a6ff" stroke-width="3" filter="url(#gl)" opacity="0"/>
<rect id="hl-holz" x="336" y="26" width="198" height="118" rx="12"
fill="none" stroke="#f0883e" stroke-width="3" filter="url(#gl)" opacity="0"/>
<rect id="hl-sol" x="856" y="26" width="208" height="118" rx="12"
fill="none" stroke="#e6a800" stroke-width="3" filter="url(#gl)" opacity="0"/>
<!-- ═══════════ WW / KW BOXEN ═══════════ -->
<rect x="920" y="355" width="165" height="45" rx="8"
fill="#1a0800" stroke="#dd5500" stroke-width="2"/>
<text x="1002" y="383" text-anchor="middle" fill="#dd5500"
font-size="12" font-weight="bold">WW-Ausgang ⑬</text>
<rect x="920" y="525" width="165" height="45" rx="8"
fill="#001a2a" stroke="#33aadd" stroke-width="2"/>
<text x="1002" y="553" text-anchor="middle" fill="#33aadd"
font-size="12" font-weight="bold">KW-Eingang</text>
<text x="872" y="465" fill="#dd5500" font-size="10" font-weight="bold">⑫ Mischung</text>
<!-- ═══════════ MISCHER & PUMPEN ═══════════ -->
<!-- Heizkreis-Mischer (VL) -->
<circle cx="560" cy="660" r="20" fill="#161b22" stroke="#888" stroke-width="2"/>
<polygon points="545,652 545,668 560,660" fill="#cc2222"/>
<polygon points="575,652 575,668 560,660" fill="#2255bb"/>
<text x="590" y="665" fill="#888" font-size="10">Mischer</text>
<!-- Heizkreis-Pumpe (RL) -->
<circle cx="440" cy="660" r="16" fill="#161b22" stroke="#888" stroke-width="2"/>
<polygon points="432,653 432,667 448,660" fill="#888"/>
<text x="440" y="693" text-anchor="middle" fill="#888" font-size="10">Pumpe</text>
<!-- Solar-Pumpe -->
<circle cx="930" cy="175" r="14" fill="#1a1800" stroke="#e6a800" stroke-width="2"/>
<polygon points="923,169 923,181 937,175" fill="#e6a800"/>
<text x="930" y="202" text-anchor="middle" fill="#a08000" font-size="9">Pumpe</text>
<!-- ═══════════ HEIZKREISE HK 14 ═══════════ -->
<!-- HK-1 -->
<rect x="230" y="820" width="110" height="55" rx="4"
fill="#1a1200" stroke="#d29

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View file

@ -0,0 +1,274 @@
## 1) Erzeuger (jeweils Vorlauf + Rücklauf)
- **Ölkessel VL**
- **Ölkessel RL**
- **Thermosolar VL**
- **Thermosolar RL**
- **Holzvergaser VL**
- **Holzvergaser RL**
= **6 Sensoren**
## 2) Heizungsseite (nach dem Mischer)
Du willst wissen, was wirklich ins Haus geht und was zurückkommt.
- **Heizungs-VL nach Mischer (Gesamt)**
- **Heizungs-RL gesamt vor dem Eintritt in den Speicher / Sammelpunkt**
= **2 Sensoren** (Summe: 8)
## 3) Rückläufe der Heizkreise einzeln
Das ist genau die Stelle, wo du ohne hydraulischen Abgleich richtig Erkenntnis rausziehst (wer klaut Durchfluss, wer säuft Temperatur).
- **HK1 Rücklauf**
- **HK2 Rücklauf**
- **HK3 Rücklauf**
- **HK4 Rücklauf** (falls wirklich 4 aktiv)
= **34 Sensoren** (Summe: 1112)
> Hinweis: Den **Vorlauf je Heizkreis** brauchst du erstmal nicht zwingend, *wenn* alle hinter dem gleichen Mischer hängen. Die Spreizung kommt über die einzelnen Rückläufe + Gesamt-Vorlauf bereits gut raus. Später kann man je Kreis-VL nachrüsten, wenn du wirklich fein regeln willst.
---
## 4) Pufferspeicher: 3 Sensoren sind ok 5 sind besser (dein Fall: besser)
**Nur oben/mitte/unten** ist die Minimal-Variante. Funktioniert aber du wirst bei deiner Rücklauf-Rückkopplung und Takt-Thematik zu wenig “Auflösung” haben.
Ich würde **5 Zonen** machen, weil:
- du damit **Schichtung** siehst (die ist im echten Leben der Boss),
- du erkennst **wer den Puffer wo lädt/zieht**,
- und du kannst **Start/Stop-Logik** für Öl viel sauberer bauen (z.B. “oben unter X°C” ist zu grob, wenn du nicht weißt, ob Mitte kollabiert).
Empfehlung:
- **Puffer oben (Top)**
- **Puffer oben-unten / oberes Drittel** (bei Tank-in-Tank besonders spannend)
- **Puffer Mitte**
- **Puffer unteres Drittel**
- **Puffer ganz unten**
= **5 Sensoren**
Wenn du unbedingt sparsam sein willst: nimm **4** (oben, oberes Drittel, Mitte, unten). Aber **5** ist die sweet spot-Variante.
---
## Ergebnis als harte Stückliste
- Erzeuger: **6**
- Heizung nach Mischer gesamt: **2**
- Heizkreis-Rückläufe: **34**
- Puffer: **5**
➡️ **Total: 1617 Temperatursensoren**
Das ist nicht “Overkill”, das ist genau die Dichte, die aus Kaffeesatz ein System macht.
Kleiner Bonus (wenn du wirklich sauber debuggen willst, optional):
- **Kaltwasser-Zulauf Tank-in-Tank** und **Warmwasser-Ausgang** (2 Sensoren) zeigt dir Zapfprofile und ob der Puffer oben „wegbricht“.
┌──────────────────────────────────────────────────────────────────────────────┐
│ 🏠 HEIZUNG -3°C │ So 26.01. 14:32 │
├──────────────────────────────────────────────────────────────────────────────┤
│ │
│ ╔════════════════════════════════════════════════════════════════════════╗ │
│ ║ ☀️ PROGNOSE: 5 Stunden Sonne erwartet ║ │
│ ║ → Öl bleibt aus, warte auf Solar ║ │
│ ╚════════════════════════════════════════════════════════════════════════╝ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 📊 PUFFER │████████████████████░░░░░░░░░░│ 52°C 67% OK │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ Oben: 58°C Mitte: 45°C Unten: 32°C │
│ │
│ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐
│ │ ☀️ SOLAR │ │ 🪵 HOLZ │ │ ❄️ WP │ │ 🛢️ ÖL │
│ │ │ │ │ │ │ │ │
│ │ ⏸️ wartet │ │ ⏸️ aus │ │ ▶️ LÄUFT │ │ 🚫 gesperrt │
│ │ │ │ │ │ Regel aktiv │ │ (Solar!) │
│ └────────────────┘ └────────────────┘ └────────────────┘ └────────────────┘
│ │
├──────────────────────────────────────────────────────────────────────────────┤
│ 💰 GESPART: Heute ~4.20€ Monat: ~89€ │
├──────────────────────────────────────────────────────────────────────────────┤
│ [ 🏠 Home ] [ 📊 Statistik ] [ 📈 Verlauf ] [ ⚙️ System ] │
└──────────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────────┐
│ ❄️ WÄRMEPUMPE -3°C │ So 26.01. 14:32 │
├──────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────┐ ┌─────────────────────────────────┐
│ │ STATUS │ │ HEUTE │
│ │ ╔═══════════════════════════════╗ │ │ │
│ │ ║ ▶️ LÄUFT ║ │ │ Laufzeit: 4h 23m │
│ │ ║ Regel: WP > Öl ║ │ │ Starts: 3 │
│ │ ╚═══════════════════════════════╝ │ │ Verbrauch: ~6.2 kWh │
│ │ │ │ Kosten: ~1.86€ │
│ │ Außentemperatur: -3.1°C │ │ Wärme geliefert: ~18.6 kWh │
│ │ WP-Grenze: -5.0°C │ │ Wirtschaftlich: ❌ │
│ └─────────────────────────────────────┘ └─────────────────────────────────┘
│ │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ BETRIEBSBEREICH │ │
│ │ │ │
│ │ -15° -10° -5° 0° +5° +10° +15° │ │
│ │ │ │ │ │ │ │ │ │ │
│ │ ├──────┼──────┼─────┼──────┼──────┼──────┤ │ │
│ │ │░░░░░░│░░░░░░│█████│██████│██████│██████│ │ │
│ │ │ ÖL │ ÖL │ WP │ WP │ WP │ WP │ │ │
│ │ └──────┴──────┴──▲──┴──────┴──────┴──────┘ │ │
│ │ │ │ │
│ │ JETZT (-3°C) │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ PROGNOSE MORGEN │ │
│ │ Min: -7°C (05:00) → WP aus, Öl übernimmt │ │
│ │ Max: +2°C (14:00) → WP möglich │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
│ │
├──────────────────────────────────────────────────────────────────────────────┤
│ [ 🏠 Home ] [ 📊 Statistik ] [ 📈 Verlauf ] [ ⚙️ System ] │
└──────────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────────┐
│ ☀️ SOLAR-THERMIE +2°C │ So 26.01. 12:45 │
├──────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────┐ ┌─────────────────────────────────┐
│ │ KOLLEKTOR │ │ LEISTUNG │
│ │ │ │ │
│ │ ┌─────┐ │ │ ╔═══════════════════╗ │
│ │ / \ 68°C │ │ ║ 4.2 kW ║ │
│ │ │ ☀️☀️☀️ │ │ │ ╚═══════════════════╝ │
│ │ \_______/ │ │ Peak heute: 5.1 kW │
│ │ │ │ │ Durchschnitt: 3.8 kW │
│ │ ▼ │ │ │
│ │ ┌───────────┐ │ │ │
│ │ │ PUFFER │ 52°C │ │ │
│ │ └───────────┘ │ │ │
│ └─────────────────────────────────────┘ └─────────────────────────────────┘
│ │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ ERTRAG HEUTE │ │
│ │ Heute: 18.4 kWh = 2.58€ gespart (vs. Öl) │ │
│ │ Monat: 185 kWh = 25.90€ │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
│ │
├──────────────────────────────────────────────────────────────────────────────┤
│ [ 🏠 Home ] [ 📊 Statistik ] [ 📈 Verlauf ] [ ⚙️ System ] │
└──────────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────────┐
│ 🪵🔥 HOLZVERGASER -2°C │ So 26.01. 16:20 │
├──────────────────────────────────────────────────────────────────────────────┤
│ │
│ ╔═══════════════════════════════╗ │
│ ║ 🔥 AKTIV AUSBRAND ║ │
│ ║ Läuft seit: 3h 45m ║ │
│ ╚═══════════════════════════════╝ │
│ │
│ Abgas: 165°C | Ziel: 150200°C │
│ VL: 78°C RL: 62°C ΔT: 16°C │
│ │
│ PUFFER: Oben 68°C Mitte 52°C Unten 32°C │
│ Geliefert: ~42 kWh Rest: ~15 kWh Voll in: ~45 min │
│ │
│ 💰 HEUTE: 42 kWh Holz = ~5.88€ gespart │
│ │
├──────────────────────────────────────────────────────────────────────────────┤
│ [ 🏠 Home ] [ 📊 Statistik ] [ 📈 Verlauf ] [ ⚙️ System ] │
└──────────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────────┐
│ 🌤️ WETTER-PROGNOSE -3°C │ So 26.01. 14:32 │
├──────────────────────────────────────────────────────────────────────────────┤
│ HEUTE: ⛅ -1° / -6° ☀️ 5h │
│ MO: ☁️ +2° / -4° ☀️ 1h │
│ DI: 🌧️ +4° / +1° ☀️ 0h │
│ MI: ☀️ +3° / -2° ☀️ 7h │
│ DO: ⛅ +1° / -5° ☀️ 4h │
│ │
│ HEIZUNGS-EMPFEHLUNG │
│ HEUTE: Solar → WP bevorzugt │
│ MO: Wenig Solar → WP Grenzbereich │
│ DI: Kein Solar → Öl │
│ MI: Viel Solar → WP │
│ │
├──────────────────────────────────────────────────────────────────────────────┤
│ [ 🏠 Home ] [ 📊 Statistik ] [ 📈 Verlauf ] [ ⚙️ System ] │
└──────────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────────┐
│ 📊 JAHRES-VERGLEICH Januar 2026 │
├──────────────────────────────────────────────────────────────────────────────┤
│ Ölverbrauch Januar │
│ 2025: 95 L ████████████████████████████████████████ │
│ 2026: 38 L ███████████████░░░░░░░░░░░░░░░░░░░░░░░░ │
│ │
│ TREND: -60 % Ölverbrauch │
│ │
├──────────────────────────────────────────────────────────────────────────────┤
│ [ 🏠 Home ] [ 📊 Statistik ] [ 📈 Verlauf ] [ ⚙️ System ] │
└──────────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────────┐
│ ⚠️ EREIGNISSE So 26.01. 14:32 │
├──────────────────────────────────────────────────────────────────────────────┤
│ AKTUELL: keine Warnungen ✅ │
│ │
│ HEUTE │
│ 14:20 WP gestartet (Regel erfüllt) │
│ 11:45 Solar-Pumpe aus │
│ 09:30 Solar-Pumpe an │
│ 06:15 Öl-Sperre aktiviert │
│ │
│ GESTERN │
│ 22:30 WP gestoppt (Puffer voll) │
│ 05:30 🚨 NOTSTART Öl (Puffer 34°C) │
│ │
├──────────────────────────────────────────────────────────────────────────────┤
│ [ 🏠 Home ] [ 📊 Statistik ] [ 📈 Verlauf ] [ ⚙️ System ] │
└──────────────────────────────────────────────────────────────────────────────┘

View file

@ -126,6 +126,7 @@ Node antwortet:
|---------|--------|-----------|
| Kayak (Roundtrip) | ✅ Aktiv | Beste Datenquelle, GDPR-Consent automatisiert |
| **Kayak Multi-City CX via HKG** | ✅ Aktiv | Primärer Scanner — FRA→HKG→KTI→FRA |
| **Cathay Pacific direkt** | ✅ Aktiv | CX direkt buchen — FRA→KTI Roundtrip via cathaypacific.com |
| Trip.com | ✅ Aktiv | Gute Ergänzung, auch CX-Filter |
| Momondo | ✅ Aktiv | Nur auf Muldenstein (Geo-Block aus Asien) |
| Google Flights | ⚠ Eingeschränkt | Wenige Ergebnisse, Consent-Probleme |

417
fuenfvoacht/src/bot.py Normal file
View file

@ -0,0 +1,417 @@
#!/usr/bin/env python3
"""
FünfVorAcht Bot Review, Scheduling, Briefing, Fehler-Alarm
"""
import asyncio
import os
from datetime import datetime, timedelta
import pytz
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup, Bot
from telegram.ext import (Application, CallbackQueryHandler, CommandHandler,
MessageHandler, filters, ContextTypes)
from telegram.constants import ParseMode
from apscheduler.schedulers.asyncio import AsyncIOScheduler
import database as db
import logger as flog
BOT_TOKEN = os.environ['TELEGRAM_BOT_TOKEN']
CHANNEL_ID = os.environ.get('TELEGRAM_CHANNEL_ID', '')
TZ = pytz.timezone(os.environ.get('TIMEZONE', 'Europe/Berlin'))
POST_TIME = os.environ.get('POST_TIME', '19:55')
BRAND_MARKER = "Pax et Lux Terranaut01 https://t.me/DieneDemLeben"
BRAND_SIGNATURE = (
"Wir schützen die Zukunft unserer Kinder und das Leben❤\n\n"
"Pax et Lux Terranaut01 https://t.me/DieneDemLeben\n\n"
"Unterstützt die Menschen, die für Uns einstehen❗"
)
edit_pending = {}
def today_str():
return datetime.now(TZ).strftime('%Y-%m-%d')
def with_branding(content: str) -> str:
text = (content or "").rstrip()
if BRAND_MARKER in text:
return text
return f"{text}\n\n{BRAND_SIGNATURE}" if text else BRAND_SIGNATURE
def review_keyboard(date_str: str, post_time: str):
return InlineKeyboardMarkup([[
InlineKeyboardButton("✅ Freigeben", callback_data=f"approve:{date_str}:{post_time}"),
InlineKeyboardButton("✏️ Bearbeiten", callback_data=f"edit:{date_str}:{post_time}"),
]])
def is_reviewer(user_id: int) -> bool:
return user_id in db.get_reviewer_chat_ids()
async def notify_reviewers(bot: Bot, text: str, parse_mode=ParseMode.HTML,
reply_markup=None):
for chat_id in db.get_reviewer_chat_ids():
try:
await bot.send_message(chat_id, text,
parse_mode=parse_mode,
reply_markup=reply_markup)
except Exception as e:
flog.error('notify_reviewer_failed', chat_id=chat_id, reason=str(e))
# ── Commands ──────────────────────────────────────────────────────────────────
async def cmd_start(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
if not is_reviewer(update.effective_user.id):
return
await update.message.reply_text(
"🕗 <b>FünfVorAcht — Review Bot</b>\n\n"
"Artikel werden im Dashboard erstellt und eingeplant.\n"
"Hier kannst du sie freigeben oder letzte Änderungen vornehmen.\n\n"
"<b>Befehle:</b>\n"
"/heute — Alle Slots von heute\n"
"/queue — Nächste 3 Tage\n"
"/skip — Heutigen Hauptslot überspringen",
parse_mode=ParseMode.HTML
)
async def cmd_heute(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
if not is_reviewer(update.effective_user.id):
return
d = today_str()
articles = db.get_articles_by_date(d)
if not articles:
await update.message.reply_text(
f"📭 Noch keine Artikel für heute ({d}).\n"
"👉 Dashboard: http://100.73.171.62:8080"
)
return
status_map = {
'draft': '📝 Entwurf',
'scheduled': '🗓️ Eingeplant',
'sent_to_bot': '📱 Zum Review gesendet',
'approved': '✅ Freigegeben',
'posted': '📤 Gepostet ✓',
'skipped': '⏭️ Übersprungen',
'pending_review': '⏳ Wartet auf Freigabe',
}
lines = [f"📅 <b>{d}</b> — {len(articles)} Slot(s)\n"]
for art in articles:
lines.append(
f"<b>{art['post_time']} Uhr</b> · "
f"{status_map.get(art['status'], art['status'])} · "
f"v{art['version']}"
)
await update.message.reply_text('\n'.join(lines), parse_mode=ParseMode.HTML)
async def cmd_queue(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
if not is_reviewer(update.effective_user.id):
return
lines = ["📆 <b>Nächste 3 Tage:</b>\n"]
status_icons = {
'draft': '📝', 'scheduled': '🗓️', 'sent_to_bot': '📱',
'approved': '', 'posted': '📤', 'skipped': '⏭️',
}
for i in range(3):
d = (datetime.now(TZ) + timedelta(days=i)).strftime('%Y-%m-%d')
arts = db.get_articles_by_date(d)
if arts:
slots = ', '.join(
f"{a['post_time']} {status_icons.get(a['status'], '')}"
for a in arts
)
lines.append(f"<b>{d}</b>: {slots}")
else:
lines.append(f"❌ <b>{d}</b> — keine Artikel")
await update.message.reply_text('\n'.join(lines), parse_mode=ParseMode.HTML)
async def cmd_skip(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
if not is_reviewer(update.effective_user.id):
return
d = today_str()
channel = db.get_channel()
pt = channel.get('post_time', POST_TIME)
art = db.get_article_by_date(d, pt)
if not art:
db.create_article(d, "SKIP", "", None, "allgemein", pt)
db.update_article_status(d, 'skipped', post_time=pt)
flog.article_skipped(d, pt)
await update.message.reply_text(f"⏭️ {d} {pt} Uhr übersprungen.")
# ── Callbacks ─────────────────────────────────────────────────────────────────
async def handle_callback(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
query = update.callback_query
await query.answer()
parts = query.data.split(':', 2)
action = parts[0]
date_str = parts[1] if len(parts) > 1 else today_str()
post_time = parts[2] if len(parts) > 2 else POST_TIME
if action == "approve":
db.update_article_status(date_str, 'approved',
query.message.message_id,
query.message.chat_id,
post_time=post_time)
article = db.get_article_by_date(date_str, post_time)
flog.article_approved(date_str, post_time, query.message.chat_id)
await query.edit_message_text(
f"✅ <b>Freigegeben!</b>\n\n"
f"Wird automatisch um <b>{post_time} Uhr</b> gepostet.\n\n"
f"{article['content_final']}",
parse_mode=ParseMode.HTML
)
elif action == "edit":
article = db.get_article_by_date(date_str, post_time)
edit_pending[f"{date_str}:{post_time}"] = True
await query.edit_message_text(
f"✏️ <b>Bearbeiten</b> — {date_str} {post_time} Uhr\n\n"
f"Schick mir den neuen Text als nächste Nachricht.\n\n"
f"<i>Aktueller Text:</i>\n{article['content_final']}",
parse_mode=ParseMode.HTML
)
# ── Textnachrichten ───────────────────────────────────────────────────────────
async def handle_message(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
if not is_reviewer(update.effective_user.id):
return
# Suche ob ein Edit-Pending-Slot aktiv ist
pending_key = next((k for k in edit_pending), None)
if not pending_key:
await update.message.reply_text(
" Artikel werden im Dashboard erstellt.\n"
"http://100.73.171.62:8080"
)
return
date_str, post_time = pending_key.split(':', 1)
new_text = update.message.text.strip()
db.update_article_content(date_str, new_text, post_time=post_time)
del edit_pending[pending_key]
await update.message.reply_text(
f"✏️ <b>Aktualisiert</b> — {date_str} {post_time} Uhr\n\n{new_text}",
parse_mode=ParseMode.HTML,
reply_markup=review_keyboard(date_str, post_time)
)
db.update_article_status(date_str, 'sent_to_bot',
update.message.message_id,
update.effective_chat.id,
post_time=post_time)
# ── Scheduler Jobs ────────────────────────────────────────────────────────────
async def job_post_articles(bot: Bot):
"""Postet alle freigegebenen Artikel deren post_time jetzt fällig ist."""
now_berlin = datetime.now(TZ)
d = now_berlin.strftime('%Y-%m-%d')
current_slot = now_berlin.strftime('%H:%M')
articles = db.get_articles_by_date(d)
for article in articles:
if article['status'] != 'approved':
continue
if article['post_time'] != current_slot:
continue
if not CHANNEL_ID:
await notify_reviewers(bot, "⚠️ Kanal-ID nicht konfiguriert!")
flog.error('no_channel_id', date=d, post_time=current_slot)
return
try:
final_text = with_branding(article['content_final'])
msg = await bot.send_message(CHANNEL_ID, final_text, parse_mode=ParseMode.HTML)
db.update_article_status(d, 'posted', post_time=current_slot)
db.save_post_history(d, msg.message_id, post_time=current_slot)
flog.article_posted(d, current_slot, CHANNEL_ID, msg.message_id)
await notify_reviewers(
bot,
f"📤 <b>Fünf vor Acht gepostet!</b>\n{d} · {current_slot} Uhr"
)
except Exception as e:
flog.posting_failed(d, current_slot, str(e))
await notify_reviewers(
bot,
f"❌ <b>Posting fehlgeschlagen!</b>\n\n"
f"📅 {d} · ⏰ {current_slot} Uhr\n"
f"Kanal: {CHANNEL_ID}\n\n"
f"<b>Ursache:</b> {str(e)[:250]}\n\n"
f"👉 Dashboard: http://100.73.171.62:8080"
)
async def job_check_notify(bot: Bot):
"""Prüft alle 5 Min ob scheduled-Artikel zur Bot-Benachrichtigung fällig sind."""
due = db.get_due_notifications()
for article in due:
d = article['date']
pt = article['post_time']
text = (
f"📋 <b>Review: {d} · {pt} Uhr</b>\n"
f"Version {article['version']}\n"
f"──────────────────────\n\n"
f"{article['content_final']}\n\n"
f"──────────────────────\n"
f"Freigeben oder bearbeiten?"
)
await notify_reviewers(bot, text,
reply_markup=review_keyboard(d, pt))
db.update_article_status(d, 'sent_to_bot', post_time=pt)
flog.article_sent_to_bot(d, pt, db.get_reviewer_chat_ids())
async def job_morning_briefing(bot: Bot):
"""Morgen-Briefing: was ist heute geplant, was fehlt."""
d = today_str()
now_berlin = datetime.now(TZ)
channel = db.get_channel()
articles_today = db.get_articles_by_date(d)
approved = [a for a in articles_today if a['status'] in ('approved', 'scheduled', 'sent_to_bot')]
missing = [a for a in articles_today if a['status'] in ('draft', 'pending_review')]
# Nächste 3 Tage für Ausblick
plan_lines = []
for i in range(1, 4):
next_d = (now_berlin + timedelta(days=i)).strftime('%Y-%m-%d')
arts = db.get_articles_by_date(next_d)
if arts:
slots = ', '.join(f"{a['post_time']}" if a['status'] in ('approved', 'scheduled') else f"{a['post_time']} 📝" for a in arts)
plan_lines.append(f" {next_d}: {slots}")
else:
plan_lines.append(f" {next_d}: ❌ leer")
lines = [f"☀️ <b>Guten Morgen — FünfVorAcht Briefing</b>\n",
f"📅 <b>Heute: {d}</b>"]
if approved:
lines.append(f"✅ Eingeplant: {len(approved)} Slot(s)")
for a in approved:
lines.append(f"{a['post_time']} Uhr — {(a['content_final'] or '')[:50]}")
else:
lines.append("⚠️ Noch kein Artikel für heute freigegeben!")
if missing:
lines.append(f"📝 Entwürfe (noch nicht freigegeben): {len(missing)}")
if not articles_today:
lines.append("❌ Kein Artikel erstellt — bitte jetzt anlegen.")
lines.append(f"\n📆 <b>Nächste 3 Tage:</b>")
lines.extend(plan_lines)
lines.append(f"\n👉 Dashboard: http://100.73.171.62:8080")
await notify_reviewers(bot, '\n'.join(lines))
flog.info('morning_briefing_sent', date=d)
async def job_cleanup_db():
"""Woechentliche DB-Bereinigung: alte Eintraege loeschen."""
import sqlite3, os
db_path = os.environ.get('DB_PATH', '/data/fuenfvoacht.db')
con = sqlite3.connect(db_path)
r1 = con.execute("DELETE FROM post_history WHERE posted_at < datetime('now', '-90 days')").rowcount
r2 = con.execute("DELETE FROM article_versions WHERE created_at < datetime('now', '-90 days')").rowcount
r3 = con.execute("DELETE FROM articles WHERE status='posted' AND date < date('now', '-180 days')").rowcount
con.execute("VACUUM")
con.commit()
con.close()
flog.info('db_cleanup', ph_del=r1, av_del=r2, art_del=r3)
async def job_reminder_afternoon(bot: Bot):
"""Nachmittags-Reminder wenn Hauptslot noch nicht freigegeben."""
d = today_str()
channel = db.get_channel()
post_t = channel.get('post_time', POST_TIME)
art = db.get_article_by_date(d, post_t)
if art and art['status'] in ('sent_to_bot', 'pending_review', 'draft', 'scheduled'):
await notify_reviewers(
bot,
f"⚠️ <b>Noch nicht freigegeben!</b>\n\n"
f"Posting um {post_t} Uhr — bitte jetzt freigeben.",
reply_markup=review_keyboard(d, post_t) if art['status'] == 'sent_to_bot' else None
)
# ── Main ──────────────────────────────────────────────────────────────────────
def main():
db.init_db()
# Standard-Reviewer aus ENV falls DB leer
reviewers_in_db = db.get_reviewers()
if not reviewers_in_db:
import os as _os
raw = _os.environ.get('REVIEW_CHAT_IDS', '')
admin = _os.environ.get('ADMIN_CHAT_ID', '')
ids_raw = [x.strip() for x in raw.split(',') if x.strip()] if raw else []
if admin and admin not in ids_raw:
ids_raw.append(admin)
for cid in ids_raw:
try:
db.add_reviewer(int(cid), f"Redakteur {cid}")
except Exception:
pass
application = Application.builder().token(BOT_TOKEN).build()
bot = application.bot
channel = db.get_channel()
post_t = channel.get('post_time', POST_TIME) if channel else POST_TIME
location = db.get_current_location()
if location:
(rem_m_h, rem_m_m), (rem_a_h, rem_a_m) = db.get_reminder_times_in_berlin(location)
flog.info('scheduler_init',
location=location['name'],
morning=f"{rem_m_h:02d}:{rem_m_m:02d}",
afternoon=f"{rem_a_h:02d}:{rem_a_m:02d}")
else:
rem_m_h, rem_m_m = 10, 0
rem_a_h, rem_a_m = 17, 45
scheduler = AsyncIOScheduler(timezone=TZ)
# Jede Minute prüfen ob ein Artikel zu posten ist
scheduler.add_job(job_post_articles, 'cron', minute='*',
kwargs={'bot': bot})
# Alle 5 Min auf fällige Benachrichtigungen prüfen
scheduler.add_job(job_check_notify, 'cron', minute='*/5',
kwargs={'bot': bot})
# Morgen-Briefing 10:00 MEZ
scheduler.add_job(job_morning_briefing, 'cron',
hour=rem_m_h, minute=rem_m_m,
kwargs={'bot': bot})
# Nachmittags-Reminder
scheduler.add_job(job_reminder_afternoon, 'cron',
hour=rem_a_h, minute=rem_a_m,
kwargs={'bot': bot})
scheduler.add_job(job_cleanup_db, 'cron', day_of_week='sun', hour=3, minute=0)
scheduler.start()
flog.info('bot_started', post_time=post_t,
briefing=f"{rem_m_h:02d}:{rem_m_m:02d}",
afternoon=f"{rem_a_h:02d}:{rem_a_m:02d}")
application.add_handler(CommandHandler("start", cmd_start))
application.add_handler(CommandHandler("heute", cmd_heute))
application.add_handler(CommandHandler("queue", cmd_queue))
application.add_handler(CommandHandler("skip", cmd_skip))
application.add_handler(CallbackQueryHandler(handle_callback))
application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message))
application.run_polling(drop_pending_updates=True)
if __name__ == '__main__':
main()

View file

@ -0,0 +1,347 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>FünfVorAcht — Kurzübersicht</title>
<style>
@page { size: A4; margin: 18mm 18mm 18mm 18mm; }
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Segoe UI', Arial, sans-serif;
font-size: 10.5pt;
color: #1e293b;
background: #fff;
width: 210mm;
min-height: 297mm;
padding: 18mm;
}
/* Header */
.header {
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 3px solid #6366f1;
padding-bottom: 10px;
margin-bottom: 14px;
}
.header-left h1 {
font-size: 22pt;
font-weight: 800;
color: #6366f1;
letter-spacing: -0.5px;
}
.header-left .subtitle {
font-size: 9.5pt;
color: #64748b;
margin-top: 2px;
}
.header-right {
text-align: right;
font-size: 8.5pt;
color: #94a3b8;
}
.badge {
display: inline-block;
background: #6366f1;
color: white;
font-size: 8pt;
font-weight: 600;
padding: 2px 8px;
border-radius: 20px;
margin-bottom: 4px;
}
/* Intro */
.intro {
background: #f1f5f9;
border-left: 4px solid #6366f1;
padding: 8px 12px;
border-radius: 0 6px 6px 0;
margin-bottom: 14px;
font-size: 10pt;
color: #334155;
line-height: 1.5;
}
/* Zwei-Spalten-Layout */
.two-col {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 12px;
}
/* Boxen */
.box {
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 10px 12px;
}
.box h3 {
font-size: 9.5pt;
font-weight: 700;
color: #6366f1;
margin-bottom: 7px;
display: flex;
align-items: center;
gap: 5px;
border-bottom: 1px solid #e2e8f0;
padding-bottom: 5px;
}
.box ul {
list-style: none;
padding: 0;
}
.box ul li {
font-size: 9.5pt;
color: #374151;
padding: 2px 0;
padding-left: 14px;
position: relative;
line-height: 1.4;
}
.box ul li::before {
content: "";
position: absolute;
left: 0;
color: #6366f1;
font-weight: bold;
}
/* Workflow */
.workflow {
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 10px 14px;
margin-bottom: 12px;
}
.workflow h3 {
font-size: 9.5pt;
font-weight: 700;
color: #6366f1;
margin-bottom: 8px;
}
.workflow-steps {
display: flex;
align-items: center;
gap: 0;
flex-wrap: nowrap;
}
.step {
text-align: center;
flex: 1;
}
.step-icon {
font-size: 14pt;
display: block;
margin-bottom: 2px;
}
.step-text {
font-size: 7.5pt;
color: #475569;
line-height: 1.3;
}
.step-arrow {
color: #94a3b8;
font-size: 14pt;
flex-shrink: 0;
padding: 0 2px;
}
/* Zugangsdaten */
.access {
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 10px 14px;
margin-bottom: 12px;
}
.access h3 {
font-size: 9.5pt;
font-weight: 700;
color: #6366f1;
margin-bottom: 7px;
}
.access-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.access-item {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 5px;
padding: 5px 8px;
}
.access-item .label {
font-size: 7.5pt;
color: #94a3b8;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.access-item .value {
font-size: 9.5pt;
color: #1e293b;
font-weight: 600;
font-family: 'Courier New', monospace;
word-break: break-all;
}
/* Footer */
.footer {
border-top: 1px solid #e2e8f0;
padding-top: 8px;
display: flex;
justify-content: space-between;
align-items: center;
}
.footer-left {
font-size: 8pt;
color: #94a3b8;
}
.footer-right {
font-size: 8pt;
color: #94a3b8;
text-align: right;
}
@media print {
body { padding: 0; }
* { -webkit-print-color-adjust: exact !important; print-color-adjust: exact !important; }
}
</style>
</head>
<body>
<!-- Header -->
<div class="header">
<div class="header-left">
<div class="badge">🕗 Automatisierung</div>
<h1>FünfVorAcht</h1>
<div class="subtitle">KI-gestützter Telegram-Kanal-Poster mit Redaktions-Dashboard</div>
</div>
<div class="header-right">
Stand: Februar 2026<br>
CT 112 · pve-hetzner<br>
<strong>fuenfvoacht.orbitalo.net</strong>
</div>
</div>
<!-- Intro -->
<div class="intro">
FünfVorAcht automatisiert die tägliche Bespielung eines Telegram-Kanals. Artikel werden per KI generiert, im Dashboard redigiert, auf den Punkt genau eingeplant und automatisch zum gewünschten Zeitpunkt gepostet — ohne manuelles Eingreifen.
</div>
<!-- Workflow -->
<div class="workflow">
<h3>⚡ Workflow</h3>
<div class="workflow-steps">
<div class="step"><span class="step-icon">🔗</span><span class="step-text">Quelle /<br>Thema eingeben</span></div>
<div class="step-arrow"></div>
<div class="step"><span class="step-icon">🤖</span><span class="step-text">KI generiert<br>Artikel</span></div>
<div class="step-arrow"></div>
<div class="step"><span class="step-icon">✏️</span><span class="step-text">Redigieren +<br>Vorschau</span></div>
<div class="step-arrow"></div>
<div class="step"><span class="step-icon">📅</span><span class="step-text">Datum +<br>Uhrzeit wählen</span></div>
<div class="step-arrow"></div>
<div class="step"><span class="step-icon">📩</span><span class="step-text">Review per<br>Telegram</span></div>
<div class="step-arrow"></div>
<div class="step"><span class="step-icon"></span><span class="step-text">Freigeben<br>oder bearbeiten</span></div>
<div class="step-arrow"></div>
<div class="step"><span class="step-icon">📤</span><span class="step-text">Automatisch<br>gepostet</span></div>
</div>
</div>
<!-- Feature-Boxen -->
<div class="two-col">
<div class="box">
<h3>✍️ Artikel & KI</h3>
<ul>
<li>KI-Generierung via OpenRouter (GPT-4o, Claude, Mistral u.a.)</li>
<li>7 verschiedene Schreibstile wählbar (sarkastisch, sachlich, humorvoll…)</li>
<li>Echtzeit Telegram-Vorschau während dem Schreiben</li>
<li>Markenzeichen wird automatisch angehängt</li>
<li>Versionsverlauf bei Neu-Generierung</li>
</ul>
</div>
<div class="box">
<h3>📅 Zeitplanung</h3>
<ul>
<li>15-Minuten-Zeitraster (06:00 23:45 Uhr)</li>
<li>Mehrere Artikel pro Tag planbar</li>
<li>7-Tage-Redaktionskalender im Dashboard</li>
<li>Umplanen & Löschen direkt im Board</li>
<li>Slot-Konfliktschutz (kein doppeltes Buchen)</li>
</ul>
</div>
<div class="box">
<h3>🤖 Telegram Bot</h3>
<ul>
<li>Review-Anfrage an alle Redakteure</li>
<li>Inline-Buttons: ✅ Freigeben / ✏️ Bearbeiten</li>
<li>Morgen-Briefing täglich 10:00 Uhr</li>
<li>Nachmittags-Reminder um 18:00 Uhr</li>
<li>Sofort-Alarm bei Posting-Fehlern</li>
</ul>
</div>
<div class="box">
<h3>⚙️ Dashboard & Verwaltung</h3>
<ul>
<li>Webbasiertes Dashboard (Browser)</li>
<li>Mehrere Redakteure verwaltbar</li>
<li>Prompt-Bibliothek editierbar</li>
<li>Posting-History der letzten 30 Tage</li>
<li>OpenRouter-Kontostand abrufbar</li>
</ul>
</div>
</div>
<!-- Zugangsdaten -->
<div class="access">
<h3>🔑 Zugang & Technik</h3>
<div class="access-grid">
<div class="access-item">
<div class="label">Dashboard URL</div>
<div class="value">fuenfvoacht.orbitalo.net</div>
</div>
<div class="access-item">
<div class="label">Telegram Bot</div>
<div class="value">@Diendemleben_bot</div>
</div>
<div class="access-item">
<div class="label">Standard Posting-Zeit</div>
<div class="value">19:55 Uhr (konfigurierbar)</div>
</div>
<div class="access-item">
<div class="label">Server</div>
<div class="value">CT 112 · pve-hetzner</div>
</div>
<div class="access-item">
<div class="label">GitHub</div>
<div class="value">github.com/Orbitalo/F-nf-vor-Acht</div>
</div>
<div class="access-item">
<div class="label">Stack</div>
<div class="value">Python · Flask · Docker · SQLite</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="footer">
<div class="footer-left">
FünfVorAcht · Orbitalo Homelab · Stand Februar 2026
</div>
<div class="footer-right">
Zum Drucken: Browser → Drucken → DIN A4 · Ränder: Keine
</div>
</div>
</body>
</html>

231
fuenfvoracht/README.md Normal file
View file

@ -0,0 +1,231 @@
# FünfVorAcht
> KI-gestützter Telegram-Kanal-Poster mit Dashboard — selbst gehostet, Docker-basiert.
FünfVorAcht automatisiert die tägliche Bespielung eines Telegram-Kanals. Artikel werden per KI generiert, im Dashboard redigiert, zeitgenau eingeplant und automatisch gepostet.
---
## Was kann FünfVorAcht?
| Feature | Beschreibung |
|---------|-------------|
| ✍️ KI-Artikel | Artikel per KI generieren (OpenRouter / GPT-4o, Claude, Mistral...) |
| 📅 Zeitplanung | 15-Minuten-Zeitslots, mehrere Artikel pro Tag möglich |
| 🗓️ Redaktionskalender | 7-Tage-Übersicht mit Umplanen & Löschen direkt im Board |
| 👥 Multi-Reviewer | Mehrere Redakteure erhalten Review-Anfragen parallel |
| ✅ Bot-Review | Artikel per Telegram freigeben oder zur Bearbeitung zurückgeben |
| ⏰ Morgen-Briefing | Täglich 10:00 Uhr: Tagesplan + Ausblick 3 Tage |
| 🔔 Nachmittags-Reminder | 18:00 Uhr: Warnung wenn Artikel noch nicht freigegeben |
| 🚨 Fehler-Alarm | Sofortbenachrichtigung bei Posting-Fehlern |
| 🏷️ Markenzeichen | Wird automatisch unter jeden Beitrag angehängt |
| 📚 Prompt-Bibliothek | Mehrere KI-Stile verwaltbar (Sarkastisch, Humorvoll, Sachlich...) |
| 📊 JSON-Logging | Strukturiertes Logging aller Ereignisse |
| 🐳 Docker | Kompletter Stack per Docker Compose |
---
## Schnellstart
```bash
# 1. Repository klonen
git clone https://github.com/Orbitalo/F-nf-vor-Acht.git fuenfvoracht
cd fuenfvoracht
# 2. Konfiguration anlegen
cp .env.example .env
nano .env # Pflichtfelder ausfüllen
# 3. Stack starten
docker compose up -d --build
```
---
## Voraussetzungen
| Software | Version |
|----------|---------|
| Docker | 24+ |
| Docker Compose | 2.x |
| Telegram Bot | Via [@BotFather](https://t.me/BotFather) erstellen |
| OpenRouter Account | [openrouter.ai](https://openrouter.ai) |
---
## Konfiguration (.env)
```bash
cp .env.example .env
```
### Pflichtfelder
| Variable | Beschreibung |
|----------|-------------|
| `TELEGRAM_BOT_TOKEN` | Bot-Token von [@BotFather](https://t.me/BotFather) |
| `TELEGRAM_CHANNEL_ID` | Kanal-ID (z.B. `-1001234567890`) |
| `REVIEW_CHAT_IDS` | Chat-IDs der Redakteure (kommagetrennt) |
| `OPENROUTER_API_KEY` | API-Key von [openrouter.ai](https://openrouter.ai) |
| `AUTH_USER` | Dashboard-Benutzername |
| `AUTH_PASS` | Dashboard-Passwort |
### Optionale Felder
| Variable | Standard | Beschreibung |
|----------|---------|-------------|
| `POST_TIME` | `19:55` | Standard-Posting-Zeit (HH:MM) |
| `TIMEZONE` | `Europe/Berlin` | Zeitzone |
| `DB_PATH` | `/data/fuenfvoracht.db` | Datenbankpfad |
### Eigene Chat-ID herausfinden
Nachricht an [@userinfobot](https://t.me/userinfobot) senden — er antwortet mit der Chat-ID.
### Kanal-ID herausfinden
Den Bot zum Kanal als Admin hinzufügen, dann eine Nachricht senden und die Kanal-ID aus der Telegram API auslesen (beginnt mit `-100`).
---
## Architektur
```
┌─────────────────────────────────────────────────┐
│ Docker Stack │
│ │
│ ┌──────────────────┐ ┌──────────────────────┐ │
│ │ fuenfvoracht-web │ │ fuenfvoracht-bot │ │
│ │ Flask :8080 │ │ python-telegram-bot │ │
│ │ Dashboard │ │ APScheduler │ │
│ └────────┬─────────┘ └──────────┬───────────┘ │
│ │ │ │
│ └──────────┬─────────────┘ │
│ ▼ │
│ SQLite Datenbank │
│ /data/fuenfvoracht.db │
└──────────────────────┬──────────────────────────┘
┌────────┴────────┐
▼ ▼
Telegram Bot Telegram Kanal
(Review) (Posting)
```
---
## Workflow
```
1. Quelle/Thema im Dashboard eingeben
2. KI generiert Artikel (OpenRouter)
3. Redigieren + Telegram-Vorschau
4. Einplanen: Datum + 15-Min-Slot + Review-Zeit
5. Bot schickt Review an alle Redakteure
[✅ Freigeben] [✏️ Bearbeiten]
6. Scheduler postet automatisch zum geplanten Zeitpunkt
7. Markenzeichen wird automatisch angehängt
```
---
## Bot-Befehle
| Befehl | Funktion |
|--------|---------|
| `/start` | Übersicht & alle Befehle |
| `/heute` | Alle geplanten Slots des heutigen Tages |
| `/queue` | Nächste 3 Tage mit Slots |
| `/skip` | Hauptslot heute überspringen |
---
## Dashboard-Seiten
| Seite | URL | Funktion |
|-------|-----|---------|
| Studio | `/` | Artikel generieren, redigieren, einplanen |
| History | `/history` | Alle Posts der letzten 30 Tage |
| Prompts | `/prompts` | KI-Prompt Bibliothek verwalten |
| Einstellungen | `/settings` | Kanal, Reviewers, Posting-Zeit |
| Hilfe | `/hilfe` | Ausführliche Anleitung |
---
## Verzeichnisstruktur
```
fuenfvoracht/
├── docker-compose.yml ← Docker Stack Definition
├── .env.example ← Konfigurationsvorlage
├── README.md ← Diese Datei
├── .gitignore ← .env, data/, logs/ ausgeschlossen
└── src/
├── app.py ← Flask Dashboard
├── bot.py ← Telegram Bot + APScheduler
├── database.py ← SQLite Datenbankschicht
├── openrouter.py ← KI-API Client
├── logger.py ← JSON-Logging
├── requirements.txt ← Python-Abhängigkeiten
├── Dockerfile.web ← Dashboard Container
├── Dockerfile.bot ← Bot Container
└── templates/ ← HTML-Templates (Jinja2)
├── index.html ← Studio
├── history.html
├── prompts.html
├── settings.html
└── hilfe.html
```
---
## Öffentlicher Zugang via Cloudflare Tunnel
Für Zugriff ohne offene Firewall-Ports:
1. **[Cloudflare Zero Trust](https://one.dash.cloudflare.com)** → Networks → Tunnels → Create
2. Hostname: `fuenfvoracht.example.com`
3. Service: `http://localhost:8080`
4. Den `docker run cloudflare/cloudflared ...`-Befehl ausführen
---
## Bekannte Hinweise
**Tailscale + Docker DNS:** Falls der Host Tailscale verwendet, kann der Bot `api.telegram.org` nicht auflösen. Die `docker-compose.yml` enthält bereits den Fix:
```yaml
dns:
- 8.8.8.8
- 8.8.4.4
```
---
## Datensicherung
```bash
# Datenbank sichern
cp data/fuenfvoracht.db backups/fuenfvoracht_$(date +%Y%m%d).db
# Komplettes Verzeichnis sichern
tar -czf fuenfvoracht_backup_$(date +%Y%m%d).tar.gz data/ logs/ .env
```
---
## Lizenz
MIT — frei verwendbar, anpassbar und weitergabe erlaubt.
---
## Entwickelt von
[Orbitalo](https://github.com/Orbitalo) — Homelab & Automatisierungsprojekte

View file

@ -1,5 +1,5 @@
# STATE: FünfVorAcht
**Stand: 26.02.2026**
**Stand: 27.02.2026**
---
@ -43,11 +43,10 @@ KI generiert Artikel (OpenRouter)
Redigieren im Dashboard + Telegram-Vorschau
Einplanen: Datum + 15-Min-Zeitslot + Bot-Benachrichtigungszeit
Einplanen: Datum + 15-Min-Zeitslot
Scheduler schickt Review an alle Redakteure (notify_at)
[✅ Freigeben] [✏️ Bearbeiten]
Status → approved (direkt, kein Review nötig)
Info-Nachricht an Redakteure: "Artikel eingeplant für XX:XX"
APScheduler postet automatisch zum Zeitslot
@ -89,11 +88,11 @@ Bestätigung + Markenzeichen automatisch angehängt
### Article-Status-Lifecycle
```
draft → scheduled → sent_to_bot → approved → posted
↘ rejected
draft → approved → posted
↘ skipped
↘ pending_review
```
**Hinweis:** Review-Schritt (sent_to_bot → approve/reject) wurde am 27.02.2026 entfernt.
Einplanen setzt direkt auf `approved`, Info-Nachricht statt Review-Buttons.
### Zeitslot-System
- `UNIQUE(date, post_time)` — Konflikte technisch ausgeschlossen
@ -138,9 +137,9 @@ draft → scheduled → sent_to_bot → approved → posted
| `/heute` | Alle Slots des heutigen Tages |
| `/queue` | Nächste 3 Tage mit Slots |
| `/skip` | Hauptslot heute überspringen |
| Inline-Review | ✅ Freigeben / ✏️ Bearbeiten |
| Inline-Review | Legacy (noch im Code, nicht mehr aktiv genutzt) |
| Morgen-Briefing | 10:00 MEZ: Tagesplan + Ausblick 3 Tage |
| Nachmittags-Reminder | 18:00 MEZ: Warnung wenn nicht freigegeben |
| Nachmittags-Reminder | 18:00 MEZ: Warnung wenn kein Artikel eingeplant |
| Fehler-Alarm | Sofort bei Posting-Fehler: Ursache + Dashboard-Link |
| Willkommensnachricht | Automatisch bei neuem Redakteur |
@ -181,6 +180,15 @@ Events: `article_generated`, `article_saved`, `article_scheduled`, `article_sent
## Changelog
### 27.02.2026 — Review-Schritt entfernt
- **Einplanen setzt direkt auf `approved`** — kein `scheduled``sent_to_bot` → Review mehr
- **Info-Nachricht statt Review-Buttons:** Redakteure bekommen nur Hinweis "Artikel eingeplant für XX:XX"
- **Save-Endpoint Bug gefixt:** Artikel mit anderer `post_time` wurde nicht gefunden
- **Bot `job_check_notify`:** Setzt direkt auf `approved` statt `sent_to_bot`
- **Nachmittags-Reminder:** Nur noch wenn gar kein Artikel eingeplant
- **Dashboard:** Button "Einplanen" statt "Einplanen & zum Bot senden", Notify-Dropdown entfernt
- **Docker-Images neu gebaut** (Source ist ins Image gebaut, nicht gemountet)
### 26.02.2026 — Board-Interaktivität
- **Redaktionsplan komplett überarbeitet:** Mehrere Artikel pro Tag sichtbar (vorher: einer pro Tag)
- **🔄 Umplanen:** Inline-Panel direkt unter dem Artikel — Datum + Uhrzeit mit Live-Slot-Prüfung, 15-Minuten-Raster

View file

@ -315,6 +315,23 @@ async def job_morning_briefing(bot: Bot):
flog.info('morning_briefing_sent', date=d)
async def job_cleanup_db():
"""Wöchentliche DB-Bereinigung: alte Einträge löschen."""
import sqlite3, os
db_path = os.environ.get('DB_PATH', '/data/fuenfvoracht.db')
con = sqlite3.connect(db_path)
# post_history älter als 90 Tage
r1 = con.execute("DELETE FROM post_history WHERE posted_at < datetime('now', '-90 days')").rowcount
# article_versions älter als 90 Tage
r2 = con.execute("DELETE FROM article_versions WHERE created_at < datetime('now', '-90 days')").rowcount
# Artikel die bereits gepostet sind und älter als 180 Tage
r3 = con.execute("DELETE FROM articles WHERE status='posted' AND date < date('now', '-180 days')").rowcount
con.execute("VACUUM")
con.commit()
con.close()
flog.info('db_cleanup', post_history_deleted=r1, versions_deleted=r2, articles_deleted=r3)
async def job_reminder_afternoon(bot: Bot):
"""Nachmittags-Reminder wenn Hauptslot noch nicht freigegeben."""
d = today_str()
@ -382,6 +399,8 @@ def main():
scheduler.add_job(job_reminder_afternoon, 'cron',
hour=rem_a_h, minute=rem_a_m,
kwargs={'bot': bot})
# Wöchentliche DB-Bereinigung (Sonntags 03:00 Uhr)
scheduler.add_job(job_cleanup_db, 'cron', day_of_week='sun', hour=3, minute=0)
scheduler.start()
flog.info('bot_started', post_time=post_t,

View file

@ -37,6 +37,7 @@
## Routing
- Cloudflare Tunnel CT 101: arakava-news-2.orbitalo.net → :80
- Cloudflare Tunnel CT 109: matomo.orbitalo.net → :80
- Cloudflare Tunnel CT 109: rss-manager.orbitalo.net → :8080
- Kein Traefik, kein PBS-Gateway mehr
## Zugangsdaten

View file

@ -0,0 +1,408 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Redax-WP — Kurzübersicht</title>
<style>
@page { size: A4 landscape; margin: 14mm 16mm 14mm 16mm; }
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Segoe UI', Arial, sans-serif;
font-size: 9.5pt;
color: #1e293b;
background: #fff;
width: 297mm;
min-height: 210mm;
padding: 14mm 16mm;
}
/* Header */
.header {
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 3px solid #2563eb;
padding-bottom: 9px;
margin-bottom: 12px;
}
.header-left { display: flex; align-items: center; gap: 14px; }
.logo-icon {
font-size: 28pt;
line-height: 1;
}
.header-left h1 {
font-size: 20pt;
font-weight: 800;
color: #2563eb;
letter-spacing: -0.5px;
line-height: 1;
}
.header-left .subtitle {
font-size: 9pt;
color: #64748b;
margin-top: 3px;
}
.header-right {
text-align: right;
font-size: 8pt;
color: #94a3b8;
line-height: 1.6;
}
.badge {
display: inline-block;
background: #2563eb;
color: white;
font-size: 7.5pt;
font-weight: 700;
padding: 2px 8px;
border-radius: 20px;
margin-bottom: 3px;
}
/* Intro-Banner */
.intro {
background: linear-gradient(135deg, #eff6ff, #f0fdf4);
border: 1px solid #bfdbfe;
border-left: 4px solid #2563eb;
padding: 7px 12px;
border-radius: 0 6px 6px 0;
margin-bottom: 12px;
font-size: 9.5pt;
color: #334155;
line-height: 1.5;
}
/* Drei-Spalten-Layout */
.three-col {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 10px;
margin-bottom: 10px;
}
/* Zwei-Spalten-Layout */
.two-col {
display: grid;
grid-template-columns: 1.1fr 0.9fr;
gap: 10px;
margin-bottom: 10px;
}
/* Boxen */
.box {
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 9px 11px;
}
.box.blue { border-top: 3px solid #2563eb; }
.box.green { border-top: 3px solid #16a34a; }
.box.purple{ border-top: 3px solid #7c3aed; }
.box.orange{ border-top: 3px solid #ea580c; }
.box.teal { border-top: 3px solid #0891b2; }
.box.rose { border-top: 3px solid #e11d48; }
.box h3 {
font-size: 9pt;
font-weight: 700;
color: #1e293b;
margin-bottom: 6px;
padding-bottom: 5px;
border-bottom: 1px solid #f1f5f9;
}
.box ul {
list-style: none;
padding: 0;
}
.box ul li {
font-size: 8.5pt;
color: #374151;
padding: 1.5px 0 1.5px 12px;
position: relative;
line-height: 1.4;
}
.box ul li::before {
content: "";
position: absolute;
left: 0;
color: #2563eb;
font-weight: bold;
}
/* Workflow horizontal */
.workflow {
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 9px 12px;
margin-bottom: 10px;
}
.workflow h3 {
font-size: 9pt;
font-weight: 700;
color: #1e293b;
margin-bottom: 7px;
}
.workflow-steps {
display: flex;
align-items: center;
justify-content: space-between;
}
.step { text-align: center; flex: 1; }
.step-icon { font-size: 13pt; display: block; margin-bottom: 2px; }
.step-label {
font-size: 7pt;
color: #475569;
line-height: 1.3;
font-weight: 600;
}
.step-sub {
font-size: 6.5pt;
color: #94a3b8;
line-height: 1.3;
}
.arrow { color: #cbd5e1; font-size: 13pt; flex-shrink: 0; padding: 0 3px; }
/* Routing-Tabelle */
.routing {
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 9px 11px;
}
.routing h3 {
font-size: 9pt;
font-weight: 700;
color: #1e293b;
margin-bottom: 7px;
}
.route-row {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
border-bottom: 1px solid #f8fafc;
font-size: 8.5pt;
}
.route-row:last-child { border-bottom: none; }
.route-tag {
font-size: 7.5pt;
font-weight: 700;
padding: 1px 7px;
border-radius: 20px;
white-space: nowrap;
}
.tag-ki { background: #dbeafe; color: #1d4ed8; }
.tag-rss { background: #dcfce7; color: #166534; }
.route-arrow { color: #94a3b8; font-weight: bold; }
.route-dest { color: #374151; }
/* Zugangsdaten */
.access-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 5px;
margin-top: 4px;
}
.access-item {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 5px;
padding: 4px 8px;
}
.access-item .label {
font-size: 7pt;
color: #94a3b8;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.access-item .value {
font-size: 8.5pt;
color: #1e293b;
font-weight: 600;
font-family: 'Courier New', monospace;
}
/* Footer */
.footer {
border-top: 1px solid #e2e8f0;
padding-top: 7px;
display: flex;
justify-content: space-between;
align-items: center;
}
.footer span { font-size: 7.5pt; color: #94a3b8; }
@media print {
body { padding: 0; }
* { -webkit-print-color-adjust: exact !important; print-color-adjust: exact !important; }
}
</style>
</head>
<body>
<!-- Header -->
<div class="header">
<div class="header-left">
<div class="logo-icon">📝</div>
<div>
<div class="badge">WordPress · KI · RSS</div>
<h1>Redax-WP</h1>
<div class="subtitle">KI-gestütztes Redaktionssystem für WordPress — selbst gehostet</div>
</div>
</div>
<div class="header-right">
Stand: Februar 2026<br>
CT 113 · pve-hetzner<br>
<strong>redax.orbitalo.net</strong><br>
deutschlandblog.orbitalo.net
</div>
</div>
<!-- Intro -->
<div class="intro">
Redax-WP verbindet ein KI-Dashboard direkt mit WordPress. Artikel werden generiert, SEO-optimiert und mit einem Klick veröffentlicht. Parallel importiert ein RSS-Manager Fremdinhalte automatisch — ohne dass der Redakteur eingreifen muss.
</div>
<!-- Workflow -->
<div class="workflow">
<h3>⚡ Workflow</h3>
<div class="workflow-steps">
<div class="step"><span class="step-icon">🔗</span><span class="step-label">Quelle / Thema</span><span class="step-sub">URL oder Freitext</span></div>
<div class="arrow"></div>
<div class="step"><span class="step-icon">🤖</span><span class="step-label">KI generiert</span><span class="step-sub">OpenRouter API</span></div>
<div class="arrow"></div>
<div class="step"><span class="step-icon">✏️</span><span class="step-label">Redigieren</span><span class="step-sub">Markdown + WP-Preview</span></div>
<div class="arrow"></div>
<div class="step"><span class="step-icon">🔍</span><span class="step-label">SEO befüllen</span><span class="step-sub">Titel, Meta, Keyword</span></div>
<div class="arrow"></div>
<div class="step"><span class="step-icon">🖼️</span><span class="step-label">Bild & Kategorie</span><span class="step-sub">Featured Image</span></div>
<div class="arrow"></div>
<div class="step"><span class="step-icon">📅</span><span class="step-label">Einplanen</span><span class="step-sub">Datum & Uhrzeit</span></div>
<div class="arrow"></div>
<div class="step"><span class="step-icon">🚀</span><span class="step-label">Veröffentlichen</span><span class="step-sub">WordPress + Telegram</span></div>
</div>
</div>
<!-- Feature-Boxen (3 Spalten) -->
<div class="three-col">
<div class="box blue">
<h3>✍️ KI-Redaktion</h3>
<ul>
<li>Artikel generieren via OpenRouter</li>
<li>GPT-4o, Claude, Mistral wählbar</li>
<li>Mehrere Schreibstile & Töne</li>
<li>Markdown-Editor mit WP-Vorschau</li>
<li>Prompt-Bibliothek verwaltbar</li>
<li>Versionierung bei Neu-Generierung</li>
</ul>
</div>
<div class="box green">
<h3>📰 RSS Feed-Import</h3>
<ul>
<li>Beliebig viele Feeds verwaltbar</li>
<li>Automatischer Import (stündlich)</li>
<li>Duplikat-Schutz via GUID</li>
<li>Blacklist-Filter für Themen</li>
<li>Featured Image via og:image</li>
<li>Optional: KI-Umschreiben vor Publish</li>
</ul>
</div>
<div class="box purple">
<h3>🔍 WordPress & SEO</h3>
<ul>
<li>WordPress REST API Integration</li>
<li>Yoast SEO: Titel, Meta, Keyword</li>
<li>Kategorien & Tags direkt wählbar</li>
<li>Featured Image Upload automatisch</li>
<li>Permalink-Struktur /%postname%/</li>
<li>Entwurf, Geplant oder Sofort-Publish</li>
</ul>
</div>
</div>
<!-- Zweite Reihe: Routing + Planung + Zugang -->
<div class="two-col">
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
<div class="box orange">
<h3>📲 Telegram & Alerts</h3>
<ul>
<li>KI-Artikel als Teaser posten</li>
<li>Morgen-Briefing 10:00 Uhr</li>
<li>Fehler-Alarm bei Posting-Fehlern</li>
<li>Mehrere Reviewer unterstützt</li>
</ul>
</div>
<div class="box teal">
<h3>🗓️ Planung & History</h3>
<ul>
<li>7-Tage-Redaktionskalender</li>
<li>Umplanen & Löschen im Board</li>
<li>Veröffentlichungs-History</li>
<li>Strukturiertes JSON-Logging</li>
</ul>
</div>
</div>
<div class="routing">
<h3>🔀 Inhalts-Routing</h3>
<div class="route-row">
<span class="route-tag tag-ki">KI-Artikel</span>
<span class="route-arrow"></span>
<span class="route-dest">WordPress veröffentlichen</span>
</div>
<div class="route-row">
<span class="route-tag tag-ki">KI-Artikel</span>
<span class="route-arrow"></span>
<span class="route-dest">Telegram Kanal (Teaser)</span>
</div>
<div class="route-row">
<span class="route-tag tag-rss">RSS-Artikel</span>
<span class="route-arrow"></span>
<span class="route-dest">Nur WordPress</span>
</div>
<div class="route-row">
<span class="route-tag tag-rss">RSS-Artikel</span>
<span class="route-arrow"></span>
<span class="route-dest" style="color:#94a3b8;">Kein Telegram-Post</span>
</div>
<div style="margin-top: 8px;">
<div class="label" style="font-size:7.5pt;color:#94a3b8;font-weight:700;text-transform:uppercase;letter-spacing:0.3px;margin-bottom:4px;">Zugang</div>
<div class="access-grid">
<div class="access-item">
<div class="label">Dashboard</div>
<div class="value">redax.orbitalo.net</div>
</div>
<div class="access-item">
<div class="label">Blog</div>
<div class="value">deutschlandblog.orbitalo.net</div>
</div>
<div class="access-item">
<div class="label">WP-Admin Login</div>
<div class="value">admin / Redax2026!</div>
</div>
<div class="access-item">
<div class="label">Stack</div>
<div class="value">Flask · WordPress · MySQL · Docker</div>
</div>
</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="footer">
<span>Redax-WP · Orbitalo Homelab · Stand Februar 2026 · CT 113 pve-hetzner</span>
<span>github.com/Orbitalo/Redax-Wordpress</span>
<span>Drucken: Querformat · DIN A4 · Ränder: Minimal</span>
</div>
</body>
</html>

View file

@ -108,3 +108,15 @@ docker-compose.yml
- [ ] .env mit echten Credentials befüllen (OpenRouter, Telegram)
- [x] Cloudflare Tunnel → https://redax.orbitalo.net
- [ ] Erste Feeds hinzufügen
---
## Changelog
### 27.02.2026 — DB-Cleanup-Job + Datenbank-Hygiene
- **Automatischer Cleanup:** Neuer Scheduler-Job läuft jeden Sonntag 03:00 Uhr
- `feed_items` (published/rejected) älter als 60 Tage → automatisch gelöscht
- `feed_items` (new/unbearbeitet) älter als 30 Tage → automatisch gelöscht
- `post_history` älter als 90 Tage → automatisch gelöscht
- VACUUM nach Cleanup → DB bleibt kompakt

View file

@ -69,6 +69,26 @@ def job_fetch_feeds():
rss_fetcher.run_all_feeds()
def job_cleanup_db():
"""Woechentliche DB-Bereinigung: alte Feed-Items und Post-History loeschen."""
import sqlite3, os
db_path = os.environ.get('DB_PATH', '/data/redax.db')
con = sqlite3.connect(db_path)
r1 = con.execute(
"DELETE FROM feed_items WHERE status IN ('published', 'rejected') "
"AND fetched_at < datetime('now', '-60 days')"
).rowcount
r2 = con.execute(
"DELETE FROM feed_items WHERE status = 'new' "
"AND fetched_at < datetime('now', '-30 days')"
).rowcount
r3 = con.execute("DELETE FROM post_history WHERE posted_at < datetime('now', '-90 days')").rowcount
con.execute("VACUUM")
con.commit()
con.close()
flog.info('db_cleanup', fi_processed=r1, fi_stale=r2, ph_del=r3)
def job_morning_briefing():
today = date.today().strftime('%Y-%m-%d')
tomorrow = (date.today() + timedelta(days=1)).strftime('%Y-%m-%d')
@ -444,6 +464,8 @@ def start_scheduler():
scheduler.add_job(job_fetch_feeds, 'interval', minutes=30, id='fetch_feeds')
# Morgen-Briefing: täglich 10:00
scheduler.add_job(job_morning_briefing, 'cron', hour=10, minute=0, id='morning_briefing')
# Woechentliche DB-Bereinigung (Sonntags 03:00 Uhr)
scheduler.add_job(job_cleanup_db, 'cron', day_of_week='sun', hour=3, minute=0, id='db_cleanup')
scheduler.start()
flog.info('scheduler_started')