From 6b794154276babfb678443bdf66ef94245541546 Mon Sep 17 00:00:00 2001 From: Tower Date: Wed, 15 Apr 2026 17:05:07 +0200 Subject: [PATCH] init --- .gitignore | 0 VERKABELUNG_TODO.txt | 250 ++++++++++++++++ heizung.ino | 588 +++++++++++++++++++++++++++++++++++++ raspi/.env | 16 + raspi/Dockerfile | 17 ++ raspi/docker-compose.yml | 32 ++ raspi/requirements.txt | 4 + raspi/server.py | 334 +++++++++++++++++++++ raspi/templates/index.html | 412 ++++++++++++++++++++++++++ schema.sql | 99 +++++++ upload.sh | 35 +++ 11 files changed, 1787 insertions(+) create mode 100644 .gitignore create mode 100644 VERKABELUNG_TODO.txt create mode 100644 heizung.ino create mode 100644 raspi/.env create mode 100644 raspi/Dockerfile create mode 100644 raspi/docker-compose.yml create mode 100644 raspi/requirements.txt create mode 100644 raspi/server.py create mode 100644 raspi/templates/index.html create mode 100644 schema.sql create mode 100644 upload.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/VERKABELUNG_TODO.txt b/VERKABELUNG_TODO.txt new file mode 100644 index 0000000..ba90cb7 --- /dev/null +++ b/VERKABELUNG_TODO.txt @@ -0,0 +1,250 @@ +================================================================================ + HEIZUNGSSTEUERUNG – VERKABELUNG & AUFBAU TODO +================================================================================ + +HARDWARE-ÜBERSICHT +------------------ +- Arduino Mega 2560 +- Raspberry Pi (beliebiges Modell mit USB) +- 8-fach Relaismodul (ActiveLow, 5 V Steuerung) +- 5x DS18B20 Temperatursensor (OneWire, wasserfest) +- 1x MAX6675 Thermoelement-Modul (Abgastemperatur, Typ K) +- 1x RTC DS3231 (Echtzeituhr, I2C) +- 1x LCD Display 16x2 (HD44780, parallel) +- 1x OLED Display 128x64 (SSD1306, I2C) ← aktuell im Code vorhanden aber noch nicht genutzt +- 1x 4,7 kΩ Widerstand (Pullup für OneWire-Bus) +- Stromversorgung 5 V / mind. 2 A für Arduino + Relais + +================================================================================ +1. STROMVERSORGUNG +================================================================================ + +[ ] Arduino Mega über USB (vom Raspi) ODER extern 7-12V an Barrel-Jack + WICHTIG: Wenn Relaismodul separat versorgt wird → GND verbinden! + +[ ] Relaismodul: + - VCC → 5 V Arduino (oder externe 5-V-Quelle bei großem Modul) + - GND → GND Arduino + - Eingangsseite (IN1-IN8) → Arduino-Pins direkt (5V-kompatibel) + +[ ] Raspberry Pi separat mit offiziellen 5 V Netzteil versorgen + (NICHT über Arduino USB-Port – zu schwach) + +================================================================================ +2. RELAIS-MODUL → ARDUINO MEGA +================================================================================ + +Relais-Modul Arduino Mega Funktion +------------------------------------------------------------ +IN1 → Pin 34 Mischer MINUS (kälter drehen) +IN2 → Pin 32 Mischer PLUS (wärmer drehen) +IN3 → Pin 30 Heizkreispumpe (Mischer-Pumpe) +IN4 → Pin 28 (reserviert / ggf. defekt – prüfen!) +IN5 → Pin 26 Kesselpumpe / Pufferbefüllung +IN6 → Pin 24 (frei) +IN7 → Pin 22 (frei) +IN8 → Pin 36 (frei) +VCC → 5V +GND → GND + +ACHTUNG: Relais sind ActiveLow → HIGH = AUS, LOW = EIN + Arduino setzt beim Start alle Relais auf HIGH (sicher AUS) + +[ ] Mischer MINUS (IN1/Pin 34) an Mischer-Motor Klemme "ZU" / "–" +[ ] Mischer PLUS (IN2/Pin 32) an Mischer-Motor Klemme "AUF" / "+" +[ ] Heizkreispumpe (IN3/Pin 30) an Pumpe (230 V Schaltung über Relais!) +[ ] Kesselpumpe (IN5/Pin 26) an Pumpenstromkreis + +!! 230-V-SICHERHEIT !! + Netzspannung NUR über die Lastseite des Relais führen (COM + NO oder NC). + Steuerseite (IN-Pins, VCC, GND) IMMER galvanisch getrennt halten. + Sicherung und FI-Schutzschalter vorschalten! + +================================================================================ +3. DS18B20 TEMPERATURSENSOREN (OneWire-Bus) +================================================================================ + +Alle 5 Sensoren hängen am selben Bus (Pin 50, Arduino Mega). + +Verdrahtung je Sensor (3-Draht, gelbes Kabel = Daten): + Rotes Kabel → 5 V + Schwarzes Kabel → GND + Gelbes/weißes Kabel → OneWire-Bus (Pin 50) + +[ ] 4,7 kΩ Pullup-Widerstand zwischen Pin 50 und 5 V einlöten + (einmal für den ganzen Bus, nicht pro Sensor!) + +Sensor-Zuordnung (Adressen bereits im Code hinterlegt – bitte prüfen!): + [ ] wohnung – Raumtemperatur Wohnung + [ ] Kunten – Pufferspeicher UNTEN (Kessel unten) + [ ] Koben – Pufferspeicher OBEN (Kessel oben) + [ ] Vorlauf – Vorlaufrohr Heizkreis (außen am Rohr angebracht!) + [ ] Aussen – Außentemperatur (wetterfest montieren, beschatten!) + +HINWEIS Vorlauf-Sensor: + Der Sensor sitzt außen am Vorlaufrohr → er reagiert mit 2-5 Minuten + Verzögerung auf Wassertemperaturänderungen. Das ist im Sketch durch + die Zonalregelung und Anlaufphase berücksichtigt. + Besser wäre ein Tauchhülsen-Sensor im Rohr – falls nachrüsten möglich. + +Sensor-Adressen auslesen (falls Sensoren getauscht wurden): + → OneWire-Scanner Sketch hochladen, Adressen aus Serial Monitor kopieren + → Neue Adressen in heizung.ino eintragen (DeviceAddress Zeilen) + +================================================================================ +4. MAX6675 – ABGASTEMPERATUR (Thermoelement Typ K) +================================================================================ + +MAX6675 Modul Arduino Mega +------------------------------------ +VCC → 5 V +GND → GND +SO (MISO) → Pin 8 +CS → Pin 9 +SCK → Pin 10 + +[ ] Thermoelement Typ K an MAX6675-Klemmen anschließen (Polarität beachten!) +[ ] Thermoelement im Abgaskanal / Rauchrohr befestigen (max. ~1200°C Typ K) +[ ] Kabel hitzebeständig verlegen (mind. 5 cm Abstand zu heißen Flächen) + +================================================================================ +5. RTC DS3231 – ECHTZEITUHR (I2C) +================================================================================ + +DS3231 Modul Arduino Mega +------------------------------------ +VCC → 3,3 V oder 5 V (DS3231 ist beides kompatibel) +GND → GND +SDA → Pin 20 (SDA, Mega) +SCL → Pin 21 (SCL, Mega) + +[ ] Knopfzelle (CR2032) einlegen damit Uhrzeit bei Stromausfall erhalten bleibt +[ ] Uhrzeit einmalig stellen: Zeile in setup() einkommentieren: + rtc.adjust(DateTime(2026, 3, 18, 12, 0, 0)); + → nach Upload wieder auskommentieren und neu flashen! + +================================================================================ +6. LCD 16x2 (HD44780, parallel) +================================================================================ + +LCD Pin Arduino Mega Hinweis +-------------------------------------------- +VSS (1) → GND +VDD (2) → 5 V +V0 (3) → Schleifer 10kΩ Poti (Kontrast) +RS (4) → Pin 12 +RW (5) → GND (immer schreiben) +EN (6) → Pin 11 +D4 (11) → Pin 5 +D5 (12) → Pin 4 +D6 (13) → Pin 3 +D7 (14) → Pin 2 +A (15) → 5 V über 220 Ω (Hintergrundbeleuchtung +) +K (16) → GND (Hintergrundbeleuchtung –) + +[ ] 10 kΩ Poti für Kontrast einbauen (V0) +[ ] 220 Ω Widerstand für Backlight + +================================================================================ +7. OLED 128x64 SSD1306 (I2C) – IM CODE VORBEREITET, NOCH NICHT GENUTZT +================================================================================ + +OLED Pin Arduino Mega +------------------------------------ +VCC → 3,3 V +GND → GND +SDA → Pin 20 (gemeinsam mit DS3231) +SCL → Pin 21 (gemeinsam mit DS3231) + +[ ] I2C-Adresse prüfen (Standard: 0x3C oder 0x3D) + Im Code: Adafruit_SSD1306 display(...) – ggf. Adresse im Konstruktor angeben + +================================================================================ +8. RASPBERRY PI ↔ ARDUINO VERBINDUNG +================================================================================ + +[ ] Arduino Mega per USB-Kabel an Raspberry Pi anschließen + (USB-B auf Arduino-Seite, USB-A auf Raspi-Seite) + +[ ] Serial-Port auf Raspi prüfen: + ls /dev/ttyUSB* /dev/ttyACM* + → typisch /dev/ttyUSB0 oder /dev/ttyACM0 + +[ ] Falls nötig: Benutzer zur dialout-Gruppe hinzufügen + sudo usermod -a -G dialout pi + → danach einmal neu anmelden! + +[ ] Python-Pakete installieren: + cd /pfad/zum/projekt/raspi + pip install -r requirements.txt + +[ ] Serial-Port in server.py anpassen (Zeile SERIAL_PORT): + SERIAL_PORT = "/dev/ttyACM0" ← falls ttyUSB0 nicht gefunden wird + +[ ] Webserver starten: + python3 server.py + → Weboberfläche erreichbar unter http://:5000 + +[ ] Optional: Autostart beim Booten einrichten + sudo nano /etc/systemd/system/heizung.service + + [Unit] + Description=Heizungssteuerung Webserver + After=network.target + + [Service] + ExecStart=/usr/bin/python3 /home/pi/heizung/raspi/server.py + WorkingDirectory=/home/pi/heizung/raspi + Restart=always + User=pi + + [Install] + WantedBy=multi-user.target + + sudo systemctl enable heizung + sudo systemctl start heizung + +================================================================================ +9. INBETRIEBNAHME – CHECKLISTE +================================================================================ + +[ ] Vor erstem Start: Sketch hochladen, Serial Monitor öffnen (9600 Baud) + → JSON-Ausgabe sichtbar? Dann Arduino-Seite OK. + +[ ] Relais-Test: Jedes Relais kurz manuell schalten (LOW), prüfen ob + Summer/LED auf Modul reagiert, BEVOR 230V angeschlossen wird! + +[ ] Mischer-Test: + - Mischer manuell auf Mitte stellen + - Relais1 (Pin 34) kurz LOW → dreht in Kälter-Richtung? + - Relais2 (Pin 32) kurz LOW → dreht in Wärmer-Richtung? + - Bei verkehrter Drehrichtung: Motoranschlüsse tauschen ODER + relais1/relais2 im Sketch tauschen + +[ ] Sensor-Test: Serial Monitor → alle 5 DS18B20 zeigen plausible Werte? + Typische Fehler: -127°C (nicht angeschlossen), 85°C (Initialisierungswert) + +[ ] time_adjust kalibrieren: + Mischer von ganz-kalt bis ganz-warm messen → Sekunden in time_adjust eintragen + (Standard: 140 Sekunden – je nach Mischer-Motor variiert das!) + +[ ] Webserver starten, Webseite öffnen → alle Werte sichtbar? + +[ ] Parameter über Webseite anpassen und prüfen ob Arduino bestätigt + (JSON-Antwort: {"ok":true,...}) + +================================================================================ +BEKANNTE EINSCHRÄNKUNGEN / MÖGLICHE VERBESSERUNGEN +================================================================================ + +- Vorlauf-Sensor außen am Rohr: Tauchhülsen-Sensor direkt im Wasser wäre + deutlich reaktionsschneller und genauer + +- Keine Datenpersistenz auf Arduino: Einstellungen (neigung, rtsoll usw.) + gehen bei Neustart verloren → Raspi muss nach Arduino-Neustart Werte + erneut senden (oder im Sketch als Default-Werte hinterlegen) + +- Relais4 (Pin 28): laut ursprünglichem Code "wahrscheinlich defekt" – + vor Verwendung prüfen! + +================================================================================ diff --git a/heizung.ino b/heizung.ino new file mode 100644 index 0000000..03984af --- /dev/null +++ b/heizung.ino @@ -0,0 +1,588 @@ +// Heizungssteuerung – millis()-basiert, kein blockierendes delay() +// Kommunikation mit Raspberry Pi über Serial (JSON) +// Befehle vom Raspi: SET + +#include +#include +#include +#include +#include +#include +// Adafruit_GFX und Adafruit_SSD1306 entfernt – OLED aktuell nicht genutzt, +// das Objekt würde ~1 KB RAM als Frame-Buffer belegen.; + +#define ONE_WIRE_BUS 31 +RTC_DS3231 rtc; +OneWire oneWire(ONE_WIRE_BUS); +DallasTemperature sensors(&oneWire); + +// Relais Pins +const int relais1 = 34; // Mischer – +const int relais2 = 32; // Mischer + +const int relais3 = 30; // Heizkreispumpe +const int relais4 = 28; +const int relais5 = 26; // Kesselpumpe / Pufferbefüllung +const int relais6 = 24; +const int relais7 = 22; +const int relais8 = 36; + +// LCD +const int rs = 12, en = 11, d4 = 5, d5 = 4, d6 = 3, d7 = 2; +LiquidCrystal lcd(rs, en, d4, d5, d6, d7); + +// MAX6675 Abgastemperatur +const int max6675SO = 8; +const int max6675CS = 9; +const int max6675CLK = 10; +MAX6675 ktc(max6675CLK, max6675CS, max6675SO); + +// Sensor-Adressen +DeviceAddress wohnung = {0x28, 0xA3, 0x9A, 0x61, 0x2F, 0x21, 0x01, 0x9E}; +DeviceAddress Kunten = {0x28, 0x51, 0x4D, 0x56, 0x2F, 0x21, 0x01, 0x43}; +DeviceAddress Koben = {0x28, 0x81, 0x87, 0x64, 0x2F, 0x21, 0x01, 0x57}; +DeviceAddress Vorlauf = {0x28, 0x22, 0xF7, 0x60, 0x2F, 0x21, 0x01, 0x31}; +DeviceAddress Aussen = {0x28, 0xE8, 0x19, 0x60, 0x2F, 0x21, 0x01, 0x19}; + +// ============================================================ +// Einstellbare Parameter (änderbar per Serial-Befehl vom Raspi) +// ============================================================ +float time_adjust = 140.0; // Sekunden – Mischer-Laufzeit von ganz-zu bis ganz-auf +float neigung = 1.2; // Heizkurven-Neigung +int rtsoll = 20; // Raumtemperatur Soll (°C) +int nachtabs = 3; // Nachtabsenkung (Grad) +int tag_anfang = 4; // Stunde: Tagesbetrieb beginnt +int tag_ende = 20; // Stunde: Tagesbetrieb endet +int misch_begin = 12; // Heizung läuft wenn Aussentemperatur <= diesem Wert + +// ============================================================ +// Interne Zustandsvariablen +// ============================================================ +float temp_aussen = 0; +float temp_vorlauf = 0; +float temp_koben = 0; +float temp_kunten = 0; +float temp_wohnung = 0; +float temp_abgas = 0; + +float vtsoll = 0; +float niveau = 0; +float darw = 0; + +int stellung = 0; // aktuelle Mischerstellung in Sekunden (0 = kalt, time_adjust = warm) +int mischeron = 0; +const int arranz = 7; // Anzahl Anzeige-Einträge + +// ============================================================ +// System-Zustandsautomat +// ============================================================ +enum SystemState { STARTUP, RUNNING }; +SystemState systemState = STARTUP; +unsigned long startupStart = 0; + +// ============================================================ +// Sensor-Timing +// ============================================================ +unsigned long sensorTimer = 0; +const unsigned long SENSOR_INTERVAL = 10000UL; // alle 10 Sekunden Temp lesen +bool sensorsRequested = false; +unsigned long sensorRequestTime = 0; +const unsigned long SENSOR_WAIT = 800UL; // DS18B20 Konvertierungszeit + +// ============================================================ +// Mischer-Puls (non-blocking, abweichungsabhängige Pulszeit) +// ============================================================ +enum MischerPulse { PULSE_NONE, PULSE_PLUS, PULSE_MINUS }; +MischerPulse currentPulse = PULSE_NONE; +unsigned long mischerPulseStart = 0; +unsigned long activePulseTime = 4000UL; // wird vor jedem Puls gesetzt + +// Zonalregelung: Schwellwerte der Vorlauf-Abweichung (°C) +// Der Sensor sitzt außen am Rohr → Totzeit ca. 2-5 Min. +// Bei großer Abweichung aggressiv regeln, bei kleiner vorsichtig (kein Überschwingen) +const float ZONE_SPRINT = 8.0; // >8°C Abweichung → maximale Energie +const float ZONE_JOGGING = 4.0; // 4-8°C → zügig +const float ZONE_NORMAL = 1.5; // 1.5-4°C → fein / Totband-Grenze + +// Pulszeiten pro Zone (öffnen / Richtung +) +const unsigned long PULS_SPRINT = 12000UL; // 12 Sekunden +const unsigned long PULS_JOGGING = 6000UL; // 6 Sekunden +const unsigned long PULS_NORMAL = 3000UL; // 3 Sekunden +// Schließen (Richtung −): zonenabhängig, aber etwas kürzer als Plus (Sensor-Trägheit) +const unsigned long PULS_MINUS_SPRINT = 8000UL; // 8 Sekunden +const unsigned long PULS_MINUS_JOGGING = 4000UL; // 4 Sekunden +const unsigned long PULS_MINUS_NORMAL = 2000UL; // 2 Sekunden + +// Check-Intervalle pro Zone +const unsigned long CHECK_SPRINT = 25000UL; // 25 Sekunden +const unsigned long CHECK_JOGGING = 45000UL; // 45 Sekunden +const unsigned long CHECK_NORMAL = 70000UL; // 70 Sekunden +const unsigned long CHECK_TOTBAND = 90000UL; // 90 Sekunden (nur Überwachung) + +// Wartezeit zwischen Mischer-Korrekturen (wird dynamisch aus Zone gesetzt) +unsigned long mischerCheckTimer = 0; +unsigned long dynamicCheckInterval = CHECK_TOTBAND; // aktuell gültiges Intervall + +// Anlaufphase: nach mischeron 0→1 weiß der Regler dass das Rohr kalt ist. +// → erste Prüfung nach 20 s, danach 5 Min. lang aggressiverer Modus. +unsigned long mischeronStart = 0; +bool mischeronWarmup = false; // true = Anlaufphase aktiv +const unsigned long WARMUP_DAUER = 300000UL; // 5 Minuten Anlaufphase + +// ============================================================ +// Display-Zyklus (ersetzt delay(3000) im Display-Loop) +// ============================================================ +unsigned long displayTimer = 0; +const unsigned long DISPLAY_INTERVAL = 3000UL; // 3 Sekunden pro Sensor +int displayIndex = 0; + +bool showProzent = false; +unsigned long prozentTimer = 0; +const unsigned long PROZENT_DURATION = 3000UL; + +// ============================================================ +// Serial-Ausgabe an Raspberry Pi +// ============================================================ +unsigned long serialOutTimer = 0; +const unsigned long SERIAL_OUT_INTERVAL = 2000UL; // alle 2 Sekunden JSON senden + +// ============================================================ +// SETUP +// ============================================================ +void setup() { + Serial.begin(9600); + Serial.setTimeout(100); // readStringUntil() bricht nach 100ms ab statt 1s + //rtc.adjust(DateTime(F(__DATE__), F(__TIME__))); + sensors.begin(); + rtc.begin(); + + const int relaisPins[] = { + relais1, + relais2, + relais3, + relais4, + relais5, + relais6, + relais7, + relais8 + }; + + for (int i = 0; i < 8; i++) { + pinMode(relaisPins[i], OUTPUT); + digitalWrite(relaisPins[i], HIGH); // HIGH = Relais aus (ActiveLow) + } + + // Kesselpumpe sofort an wenn Abgas heiß + if (ktc.readCelsius() >= 40) { + digitalWrite(relais5, LOW); + } + + lcd.begin(16, 2); // einmalige Initialisierung – danach NICHT mehr aufrufen! + lcd.print(" Initialisiere"); + lcd.setCursor(0, 1); + lcd.print("Mischer -> kalt"); + + // Mischer auf ganz-kalt fahren (non-blocking: Relais an, Zeit merken) + digitalWrite(relais1, LOW); + startupStart = millis(); + systemState = STARTUP; +} + +// ============================================================ +// LOOP +// ============================================================ +void loop() { + unsigned long now = millis(); + + // --- STARTUP: Mischer vollständig zurückdrehen --- + if (systemState == STARTUP) { + if (now - startupStart >= (unsigned long)(time_adjust * 1000UL)) { + digitalWrite(relais1, HIGH); // Relais aus – Mischer stoppt + stellung = 0; + systemState = RUNNING; + + // Timer initialisieren + sensorTimer = now; + displayTimer = now; + serialOutTimer = now; + mischerCheckTimer = now; + + // Erste Sensorabfrage starten + sensors.requestTemperatures(); + sensorsRequested = true; + sensorRequestTime = now; + + lcd.clear(); + lcd.print("Bereit"); + } + // Während Startup: nichts weiter tun + return; + } + + // --- RUNNING --- + + handleSerialInput(); + + // Sensorabfrage periodisch starten + if (!sensorsRequested && (now - sensorTimer >= SENSOR_INTERVAL)) { + sensorTimer = now; + sensors.requestTemperatures(); + sensorsRequested = true; + sensorRequestTime = now; + } + + // Sensorwerte auslesen nach Konvertierungszeit + if (sensorsRequested && (now - sensorRequestTime >= SENSOR_WAIT)) { + sensorsRequested = false; + readSensors(); + updateLogic(now); + } + + // Mischer-Relais-Puls überwachen + handleMischerPulse(now); + + // Display-Zyklus + updateDisplay(now); + + // JSON an Raspberry Pi senden + if (now - serialOutTimer >= SERIAL_OUT_INTERVAL) { + serialOutTimer = now; + sendSerialJSON(); + } +} + +// ============================================================ +// Sensor-Werte auslesen +// ============================================================ +// DS18B20 Fehlerwerte: -127°C = Kabelbruch/nicht angeschlossen, 85°C = Initialisierungswert +// Bei Fehler: letzten gültigen Wert behalten (wird nicht überschrieben) +#define SENSOR_ERR_LOW -100.0 +#define SENSOR_ERR_HIGH 84.0 + +static void readSensor(float &dest, float raw) { + if (raw > SENSOR_ERR_LOW && raw < SENSOR_ERR_HIGH) { + dest = raw; + } else { + Serial.print(F("# Sensorfehler: ")); + Serial.println(raw, 1); + // dest bleibt unverändert (letzter gültiger Wert) + } +} + +void readSensors() { + readSensor(temp_aussen, sensors.getTempC(Aussen)); + readSensor(temp_vorlauf, sensors.getTempC(Vorlauf)); + readSensor(temp_koben, sensors.getTempC(Koben)); + readSensor(temp_kunten, sensors.getTempC(Kunten)); + readSensor(temp_wohnung, sensors.getTempC(wohnung)); + temp_abgas = ktc.readCelsius(); // MAX6675 hat eigenes Fehlerprotokoll +} + +// ============================================================ +// Heizungslogik +// ============================================================ +void updateLogic(unsigned long now) { + DateTime aktuell = rtc.now(); + int jetzt = aktuell.hour(); + + // Kesselpumpe: an wenn Abgas >= 40°C + if (temp_abgas >= 40) { + digitalWrite(relais5, LOW); + } else { + digitalWrite(relais5, HIGH); + } + + // Heizkurve: Vorlaufsoll berechnen + darw = temp_aussen - (float)rtsoll; + float rtsoll_eff = (float)rtsoll; + if (jetzt < tag_anfang || jetzt >= tag_ende) { + rtsoll_eff -= (float)nachtabs; // Nachtabsenkung + } + vtsoll = rtsoll_eff + niveau + - neigung * darw * (1.4347 + 0.021 * darw + 247.9e-6 * darw * darw); + // Sicherheitsbegrenzung: vtsoll physikalisch sinnvoll halten + // Schützt vor Extremwerten bei Sensorfehler (z.B. -127°C Kabelbruch) + if (vtsoll < 10.0) vtsoll = 10.0; + if (vtsoll > 75.0) vtsoll = 75.0; + + // Heizkreispumpe / Mischer-Freigabe + bool pufferOK = (temp_koben >= vtsoll); + bool aussenKalt = (temp_aussen <= (float)misch_begin); + bool abgasHot = (temp_abgas >= 40); + + int mischeronAlt = mischeron; + if (aussenKalt && (pufferOK || abgasHot)) { + digitalWrite(relais3, LOW); + mischeron = 1; + } else { + digitalWrite(relais3, HIGH); + mischeron = 0; + } + + // Anlaufphase erkennen: wenn Mischer gerade eingeschaltet wurde + if (mischeron == 1 && mischeronAlt == 0) { + // Übergang 0→1: Rohr ist kalt, sofort erste Prüfung in 20 Sekunden + mischeronWarmup = true; + mischeronStart = now; + mischerCheckTimer = now - CHECK_SPRINT + 20000UL; // erste Prüfung nach 20s + Serial.println(F("# Anlaufphase gestartet")); + } + if (mischeron == 0) { + mischeronWarmup = false; // Anlaufphase beenden wenn Heizung aus + } + bool inWarmup = mischeronWarmup && (now - mischeronStart < WARMUP_DAUER); + + // ── Zonalregelung ────────────────────────────────────────────────────────── + // Abweichung positiv = Vorlauf zu kalt, negativ = zu warm + float abweichung = vtsoll - temp_vorlauf; + float abweichungAbs = fabs(abweichung); + + // Regelzone bestimmen → gibt Pulszeit und Check-Intervall vor. + // WICHTIG: activePulseTime wird NUR aktualisiert wenn kein Puls läuft. + // Würden wir es mid-flight ändern, würde handleMischerPulse() den Puls + // zum falschen Zeitpunkt beenden und stellung falsch berechnen. + // Während Anlaufphase: eine Zone aggressiver, da das Rohr erst aufgeheizt werden muss. + if (abweichungAbs > ZONE_SPRINT || (inWarmup && abweichungAbs > ZONE_JOGGING)) { + dynamicCheckInterval = CHECK_SPRINT; + if (currentPulse == PULSE_NONE) activePulseTime = PULS_SPRINT; + } else if (abweichungAbs > ZONE_JOGGING || (inWarmup && abweichungAbs > ZONE_NORMAL)) { + dynamicCheckInterval = CHECK_JOGGING; + if (currentPulse == PULSE_NONE) activePulseTime = PULS_JOGGING; + } else if (abweichungAbs > ZONE_NORMAL) { + dynamicCheckInterval = CHECK_NORMAL; + if (currentPulse == PULSE_NONE) activePulseTime = PULS_NORMAL; + } else { + // Totband: Mischer sitzt richtig, nur überwachen + dynamicCheckInterval = CHECK_TOTBAND; + if (currentPulse == PULSE_NONE) activePulseTime = PULS_NORMAL; + } + + // Mischer regeln – nur wenn Heizkreis läuft, kein Puls aktiv + // und das zonenabhängige Check-Intervall abgelaufen ist + if (mischeron == 1 && currentPulse == PULSE_NONE && + (now - mischerCheckTimer >= dynamicCheckInterval)) { + + if (abweichung > ZONE_NORMAL && stellung < (int)time_adjust) { + // Mischer weiter öffnen (wärmer) + // activePulseTime wurde oben zonenabhängig gesetzt + currentPulse = PULSE_PLUS; + mischerPulseStart = now; + digitalWrite(relais2, LOW); + Serial.print(F("# Mische + Puls=")); + Serial.print(activePulseTime / 1000); + Serial.print(F("s Abw=")); + Serial.println(abweichung, 1); + } else if (abweichung < -ZONE_NORMAL && stellung > 0) { + // Mischer schließen (kälter) – zonenabhängig, etwas kürzer als Plus + if (abweichungAbs > ZONE_SPRINT) { + activePulseTime = PULS_MINUS_SPRINT; + } else if (abweichungAbs > ZONE_JOGGING) { + activePulseTime = PULS_MINUS_JOGGING; + } else { + activePulseTime = PULS_MINUS_NORMAL; + } + currentPulse = PULSE_MINUS; + mischerPulseStart = now; + digitalWrite(relais1, LOW); + Serial.print(F("# Mische - Abw=")); + Serial.println(abweichung, 1); + } + } +} + +// ============================================================ +// Mischer-Puls beenden wenn Zeit abgelaufen +// ============================================================ +void handleMischerPulse(unsigned long now) { + if (currentPulse == PULSE_NONE) return; + + if (now - mischerPulseStart >= activePulseTime) { + // Stellung um die tatsächliche Pulszeit (in Sekunden) aktualisieren + int pulsSekunden = (int)(activePulseTime / 1000UL); + if (currentPulse == PULSE_PLUS) { + digitalWrite(relais2, HIGH); + stellung += pulsSekunden; + if (stellung > (int)time_adjust) stellung = (int)time_adjust; + } else { + digitalWrite(relais1, HIGH); + stellung -= pulsSekunden; + if (stellung < 0) stellung = 0; + } + currentPulse = PULSE_NONE; + mischerCheckTimer = now; // Check-Timer neu starten (nächstes Intervall läuft ab jetzt) + + // Prozentanzeige für 3 Sekunden einblenden + showProzent = true; + prozentTimer = now; + //showProzentDisplay(); + } +} + +// ============================================================ +// Display-Zyklus (non-blocking) +// ============================================================ +void updateDisplay(unsigned long now) { + // Prozentanzeige: solange aktiv, nichts anderes zeigen + if (showProzent) { + if (now - prozentTimer >= PROZENT_DURATION) { + showProzent = false; + displayTimer = now; // Zyklus-Timer zurücksetzen + } + return; + } + + if (now - displayTimer >= DISPLAY_INTERVAL) { + displayTimer = now; + displayIndex = (displayIndex + 1) % arranz; + showSensor(); + } +} + +//void showProzentDisplay() { +// float proz = (stellung * 100.0) / time_adjust; +// lcd.clear(); +// lcd.print("Mischer "); +// lcd.print(proz, 0); +// lcd.print("%"); +// lcd.setCursor(0, 1); +// lcd.print("geoeffnet"); +//} + +void showSensor() { + char Uhr[] = "hh:mm"; + DateTime aktuell = rtc.now(); + + float sensorVal[7] = { + temp_aussen, temp_vorlauf, vtsoll, + temp_koben, temp_kunten, temp_wohnung, temp_abgas + }; + const char* ort[7] = { + "Aussentemp", "Vorlauf ist", "Vorlauf soll", + "Puffer Oben", "Puffer unt.", "Heizung", "Abgas" + }; + + lcd.clear(); + lcd.begin(16, 2); + lcd.print(ort[displayIndex]); + lcd.setCursor(0, 1); + lcd.print(sensorVal[displayIndex], 1); + lcd.print("\337C "); + lcd.setCursor(11, 1); + lcd.print(aktuell.toString(Uhr)); + // showProzentDisplay(); + + // Sonderanzeige Mischer-Grenzwerte – nur wenn Mischer aktiv läuft + //if (mischeron) { + // if (stellung >= (int)time_adjust) { + // lcd.clear(); + // lcd.print("Mischer max!"); + // lcd.setCursor(0, 1); + // lcd.print("voll geoeffnet"); + // } else if (stellung <= 0) { + // lcd.clear(); + // lcd.print("Mischer min"); + // lcd.setCursor(0, 1); + // lcd.print("sehr gut !"); + // } + //} +} + +// ============================================================ +// JSON-Ausgabe an Raspberry Pi +// ============================================================ +void sendSerialJSON() { + char buffer[512]; + + float abw = vtsoll - temp_vorlauf; + + snprintf(buffer, sizeof(buffer), + "{" + "\"aussentemp\":%.1f," + "\"vorlauf_ist\":%.1f," + "\"vorlauf_soll\":%.1f," + "\"kessel_oben\":%.1f," + "\"kessel_unten\":%.1f," + "\"wohnung\":%.1f," + "\"abgas\":%.1f," + "\"stellung\":%d," + "\"mischeron\":%d," + "\"neigung\":%.2f," + "\"rtsoll\":%d," + "\"misch_begin\":%d," + "\"nachtabs\":%d," + "\"tag_anfang\":%d," + "\"tag_ende\":%d," + "\"time_adjust\":%d," + "\"abweichung\":%.1f," + "\"check_intervall\":%ld," + "\"puls_zeit\":%ld," + "\"puls_aktiv\":%d," + "\"warmup\":%d" + "}", + + temp_aussen, + temp_vorlauf, + vtsoll, + temp_koben, + temp_kunten, + temp_wohnung, + temp_abgas, + + stellung, + mischeron, + neigung, + rtsoll, + misch_begin, + nachtabs, + tag_anfang, + tag_ende, + time_adjust, + + abw, + dynamicCheckInterval / 1000, + activePulseTime / 1000, + (currentPulse != PULSE_NONE) ? 1 : 0, + mischeronWarmup ? 1 : 0 + ); + + Serial.println(buffer); +} + +// ============================================================ +// Serielle Befehle vom Raspberry Pi verarbeiten +// Format: SET \n +// ============================================================ +void handleSerialInput() { + if (!Serial.available()) return; + + String line = Serial.readStringUntil('\n'); + line.trim(); + + if (!line.startsWith(F("SET "))) return; + + // "SET key value" parsen + int sp1 = line.indexOf(' '); // nach "SET" + int sp2 = line.indexOf(' ', sp1 + 1); + if (sp1 < 0 || sp2 < 0) return; + + String key = line.substring(sp1 + 1, sp2); + String val = line.substring(sp2 + 1); + val.trim(); + + bool ok = true; + if (key == F("neigung")) neigung = val.toFloat(); + else if (key == F("rtsoll")) rtsoll = val.toInt(); + else if (key == F("nachtabs")) nachtabs = val.toInt(); + else if (key == F("misch_begin")) misch_begin = val.toInt(); + else if (key == F("tag_anfang")) tag_anfang = val.toInt(); + else if (key == F("tag_ende")) tag_ende = val.toInt(); + else ok = false; + + // Bestätigung zurücksenden + Serial.print(F("{\"ok\":")); + Serial.print(ok ? F("true") : F("false")); + Serial.print(F(",\"key\":\"")); + Serial.print(key); + Serial.print(F("\",\"value\":\"")); + Serial.print(val); + Serial.println(F("\"}")); +} diff --git a/raspi/.env b/raspi/.env new file mode 100644 index 0000000..ab56e9b --- /dev/null +++ b/raspi/.env @@ -0,0 +1,16 @@ +# Serial-Port zum Arduino +SERIAL_PORT=/dev/ttyUSB0 + +# Externer MySQL-Server +DB_HOST=192.168.1.100 +DB_PORT=3306 +DB_USER=heizung +DB_PASSWORD=heizung_pw +DB_NAME=heizung + +# Wie oft Messwerte in die DB geschrieben werden (Sekunden) +DB_WRITE_INTERVAL=60 + +# Flask +FLASK_HOST=0.0.0.0 +FLASK_PORT=5000 diff --git a/raspi/Dockerfile b/raspi/Dockerfile new file mode 100644 index 0000000..34223e3 --- /dev/null +++ b/raspi/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.11-slim + +WORKDIR /app + +# System-Abhängigkeiten für pyserial auf ARM +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 5000 + +CMD ["python3", "server.py"] diff --git a/raspi/docker-compose.yml b/raspi/docker-compose.yml new file mode 100644 index 0000000..d971347 --- /dev/null +++ b/raspi/docker-compose.yml @@ -0,0 +1,32 @@ +services: + heizung-web: + build: . + container_name: heizung-web + restart: unless-stopped + + ports: + - "5000:5000" + + # Serial-Port zum Arduino durchreichen + # Falls /dev/ttyACM0 genutzt wird: beide Zeilen anpassen + devices: + - /dev/ttyUSB0:/dev/ttyUSB0 + + # Privileged-Modus damit der Container auf das Serial-Device zugreifen kann + # Alternativ: group_add mit der dialout-GID (auf Raspi OS meist 20) + privileged: true + + env_file: + - .env + + volumes: + # Logs persistent speichern (optional) + - heizung-logs:/app/logs + + # Neustart erst nach Netzwerk (wichtig für DB-Verbindung) + # Die App funktioniert auch ohne DB, daher kein hard depends_on + extra_hosts: + - "host.docker.internal:host-gateway" + +volumes: + heizung-logs: diff --git a/raspi/requirements.txt b/raspi/requirements.txt new file mode 100644 index 0000000..547163c --- /dev/null +++ b/raspi/requirements.txt @@ -0,0 +1,4 @@ +flask>=3.0 +pyserial>=3.5 +# PyMySQL ist optional – nur benötigt wenn DB_HOST gesetzt ist +# PyMySQL>=1.1 diff --git a/raspi/server.py b/raspi/server.py new file mode 100644 index 0000000..4eb82e1 --- /dev/null +++ b/raspi/server.py @@ -0,0 +1,334 @@ +#!/usr/bin/env python3 +""" +Heizungssteuerung – Raspberry Pi Webserver +Liest JSON-Daten vom Arduino über Serial, stellt Weboberfläche bereit +und schreibt Messwerte in eine externe MySQL-Datenbank. + +Verwendung: + cp .env.example .env # und Werte anpassen + docker compose up -d + +Ohne Docker: + pip install -r requirements.txt + python3 server.py +""" + +import json +import logging +import os +import threading +import time +from collections import deque + +try: + import pymysql + import pymysql.cursors + _PYMYSQL_AVAILABLE = True +except ImportError: + _PYMYSQL_AVAILABLE = False + +import readSerial +from flask import Flask, jsonify, render_template, request + +# ── Konfiguration aus Umgebungsvariablen ─────────────────────────────────────── +SERIAL_PORT = "/dev/ttyACM0" +BAUD_RATE = 9600 +HOST = "0.0.0.0" +PORT = "5000" + +DB_HOST = "10.233.1.244" +DB_PORT = "3306" +DB_USER = "worker" +DB_PASSWORD = "bBHofurUJWKzPE8Z" +DB_NAME = "HEIZUNG" +DB_WRITE_INTERVAL = "60" + +HISTORY_LEN = 120 # letzte 120 Datenpunkte im Live-Chart + +ALLOWED_KEYS = { + "neigung": {"type": float, "min": 0.1, "max": 4.0}, + "rtsoll": {"type": int, "min": 10, "max": 30}, + "nachtabs": {"type": int, "min": 0, "max": 10}, + "misch_begin": {"type": int, "min": -15, "max": 25}, + "tag_anfang": {"type": int, "min": 0, "max": 12}, + "tag_ende": {"type": int, "min": 12, "max": 23}, +} + +# ── Globaler Zustand ─────────────────────────────────────────────────────────── +latest_data: dict = {} +history: deque = deque(maxlen=HISTORY_LEN) +data_lock = threading.Lock() +serial_lock = threading.Lock() +ser: readSerial.Serial | None = None +serial_connected = False + +db_connected = False +db_error: str = "" # letzte Fehlermeldung, leer wenn OK + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)-8s %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", +) +log = logging.getLogger(__name__) + +app = Flask(__name__) + + +# ── MySQL-Hilfsfunktionen ────────────────────────────────────────────────────── +def _db_connect(): + """Baut eine neue DB-Verbindung auf. Gibt None zurück bei Fehler.""" + if not DB_HOST or not _PYMYSQL_AVAILABLE: + return None + try: + conn = pymysql.connect( + host=DB_HOST, + port=DB_PORT, + user=DB_USER, + password=DB_PASSWORD, + database=DB_NAME, + connect_timeout=5, + autocommit=True, + charset="utf8mb4", + cursorclass=pymysql.cursors.DictCursor, + ) + return conn + except pymysql.Error as e: + raise e + + +def write_measurement(data: dict) -> None: + """Schreibt einen Messdatensatz in die DB. Setzt db_connected/db_error.""" + global db_connected, db_error + + if not DB_HOST or not _PYMYSQL_AVAILABLE: + return # keine DB konfiguriert oder PyMySQL nicht installiert – still ignorieren + + time_adjust = data.get("time_adjust", 1) or 1 + stellung = data.get("stellung", 0) + stellung_proz = round(stellung / time_adjust * 100, 1) if time_adjust else 0 + + sql = """ + INSERT INTO messungen + (ts, aussentemp, vorlauf_ist, vorlauf_soll, + kessel_oben, kessel_unten, wohnung, abgas, + stellung, stellung_proz, mischeron, abweichung, + neigung, rtsoll, misch_begin, nachtabs) + VALUES + (NOW(), %s, %s, %s, + %s, %s, %s, %s, + %s, %s, %s, %s, + %s, %s, %s, %s) + """ + values = ( + data.get("aussentemp"), data.get("vorlauf_ist"), data.get("vorlauf_soll"), + data.get("kessel_oben"), data.get("kessel_unten"), data.get("wohnung"), + data.get("abgas"), + stellung, stellung_proz, data.get("mischeron"), data.get("abweichung"), + data.get("neigung"), data.get("rtsoll"), + data.get("misch_begin"), data.get("nachtabs"), + ) + + conn = None + try: + conn = _db_connect() + if conn is None: + return + with conn.cursor() as cur: + cur.execute(sql, values) + if not db_connected: + log.info("Datenbankverbindung hergestellt (%s:%d/%s)", DB_HOST, DB_PORT, DB_NAME) + db_connected = True + db_error = "" + except Exception as e: + db_connected = False + db_error = str(e) + log.warning("DB-Schreibfehler: %s", e) + finally: + if conn: + conn.close() + + +def log_parameter_change(key: str, old_value, new_value) -> None: + """Schreibt eine Parameteränderung in die DB.""" + if not DB_HOST: + return + sql = """ + INSERT INTO parameter_aenderungen (ts, parameter, alter_wert, neuer_wert) + VALUES (NOW(), %s, %s, %s) + """ + conn = None + try: + conn = _db_connect() + if conn is None: + return + with conn.cursor() as cur: + cur.execute(sql, (key, str(old_value) if old_value is not None else None, str(new_value))) + except Exception as e: + log.warning("DB-Fehler beim Protokollieren der Änderung: %s", e) + finally: + if conn: + conn.close() + + +# ── DB-Writer-Thread ─────────────────────────────────────────────────────────── +def db_writer(): + """Schreibt alle DB_WRITE_INTERVAL Sekunden den aktuellen Zustand in die DB.""" + # Kurz warten damit der Serial-Reader erst Daten sammeln kann + time.sleep(10) + while True: + time.sleep(DB_WRITE_INTERVAL) + with data_lock: + data = dict(latest_data) + if not data: + log.debug("Noch keine Daten vom Arduino – DB-Schreiben übersprungen") + continue + write_measurement(data) + + +# ── Serial-Lese-Thread ───────────────────────────────────────────────────────── +def serial_reader(): + global ser, serial_connected + while True: + if ser is None or not ser.is_open: + try: + ser = readSerial.Serial(SERIAL_PORT, BAUD_RATE, timeout=2) + serial_connected = True + log.info("Serial verbunden: %s", SERIAL_PORT) + except readSerial.SerialException as e: + serial_connected = False + log.warning("Serial nicht verfügbar: %s – retry in 5s", e) + time.sleep(5) + continue + + try: + with serial_lock: + raw = ser.readline().decode("utf-8", errors="replace").strip() + except readSerial.SerialException as e: + log.warning("Serial-Lesefehler: %s", e) + serial_connected = False + ser = None + continue + + if not raw.startswith("{"): + if raw: + log.debug("Arduino: %s", raw) + continue + + try: + data = json.loads(raw) + except json.JSONDecodeError: + log.debug("JSON-Fehler: %s", raw) + continue + + data["ts"] = time.time() + + with data_lock: + latest_data.update(data) + history.append({ + "ts": data["ts"], + "vorlauf_ist": data.get("vorlauf_ist"), + "vorlauf_soll": data.get("vorlauf_soll"), + "aussentemp": data.get("aussentemp"), + "wohnung": data.get("wohnung"), + }) + + +def send_command(key: str, value) -> bool: + """Sendet einen SET-Befehl an den Arduino.""" + cmd = f"SET {key} {value}\n" + # Lock vor der ser-Prüfung holen: verhindert TOCTOU-Race zwischen + # dieser Prüfung und dem serial_reader-Thread der ser=None setzen kann. + try: + with serial_lock: + if ser is None or not ser.is_open: + return False + ser.write(cmd.encode()) + return True + except (readSerial.SerialException, AttributeError) as e: + log.error("Sendefehler: %s", e) + return False + + +# ── Flask-Routen ─────────────────────────────────────────────────────────────── +@app.route("/") +def index(): + with data_lock: + data = dict(latest_data) + return render_template("index.html", data=data, connected=serial_connected) + + +@app.route("/api/data") +def api_data(): + with data_lock: + data = dict(latest_data) + data["serial_connected"] = serial_connected + ts = data.get("ts", 0) + data["data_stale"] = (time.time() - ts) > 30 if ts else True + data["db_connected"] = db_connected + data["db_error"] = db_error + data["db_configured"] = bool(DB_HOST) + return jsonify(data) + + +@app.route("/api/history") +def api_history(): + with data_lock: + hist = list(history) + return jsonify(hist) + + +@app.route("/api/set", methods=["POST"]) +def api_set(): + body = request.get_json(silent=True) or {} + key = body.get("key", "").strip() + value = body.get("value") + + if key not in ALLOWED_KEYS: + return jsonify({"ok": False, "error": f"Unbekannter Schlüssel: {key}"}), 400 + + spec = ALLOWED_KEYS[key] + try: + value = spec["type"](value) + except (TypeError, ValueError): + return jsonify({"ok": False, "error": "Ungültiger Wert"}), 400 + + if not (spec["min"] <= value <= spec["max"]): + return jsonify({ + "ok": False, + "error": f"Wert außerhalb des Bereichs [{spec['min']}, {spec['max']}]" + }), 400 + + ok = send_command(key, value) + if not ok: + return jsonify({"ok": False, "error": "Arduino nicht erreichbar"}), 503 + + # Alten Wert für DB-Protokoll merken + with data_lock: + old_value = latest_data.get(key) + latest_data[key] = value + + # Änderung in DB protokollieren (non-blocking, im Hintergrund) + threading.Thread( + target=log_parameter_change, + args=(key, old_value, value), + daemon=True, + ).start() + + return jsonify({"ok": True, "key": key, "value": value}) + + +# ── Start ────────────────────────────────────────────────────────────────────── +if __name__ == "__main__": + if not _PYMYSQL_AVAILABLE: + log.warning("PyMySQL nicht installiert – Datenbankanbindung deaktiviert") + elif not DB_HOST: + log.warning("DB_HOST nicht gesetzt – Datenbankanbindung deaktiviert") + + threading.Thread(target=serial_reader, daemon=True, name="serial-reader").start() + threading.Thread(target=db_writer, daemon=True, name="db-writer").start() + + log.info("Webserver gestartet auf http://%s:%d", HOST, PORT) + log.info("Datenbank: %s@%s:%d/%s", DB_USER, DB_HOST or "(nicht konfiguriert)", DB_PORT, DB_NAME) + log.info("DB-Schreibintervall: %d Sekunden", DB_WRITE_INTERVAL) + app.run(host=HOST, port=PORT, debug=False) diff --git a/raspi/templates/index.html b/raspi/templates/index.html new file mode 100644 index 0000000..8690456 --- /dev/null +++ b/raspi/templates/index.html @@ -0,0 +1,412 @@ + + + + + + Heizungssteuerung + + + + + + + + + + +
+
+

🔥 Heizungssteuerung

+
+ + Verbinde… + +
+
+ + +
+ +
+
Aussentemperatur
+
–
+
°C
+
+ +
+
Wohnungstemperatur
+
–
+
°C
+
+ +
+
Vorlauf
+
+
+
Ist
+
–
+
+
→
+
+
Soll
+
–
+
+
°C
+
+
+ +
+
Kessel Oben
+
–
+
°C
+
+ +
+
Kessel Unten
+
–
+
°C
+
+ +
+
Abgastemperatur
+
–
+
°C
+
+ +
+
Mischer
+
–
+
% geöffnet
+
aus
+
+ +
+ + +
+

Regelungsdiagnose

+
+
+
Abweichung
+
–
+
°C (Soll − Ist)
+
+
+
Regelzone / Puls
+
–
+
–
+
+
+
Check-Intervall
+
–
+
Sekunden
+
+
+
Status
+
+ kein Puls + normal +
+
+
+
+ + +
+
Temperaturverlauf
+ +
+ + +
+

Einstellungen

+
+ +
+ +
+ + – +
+
Bereich 0.1 – 4.0
+
+ +
+ +
+ + – +
+
Bereich 10 – 30 °C
+
+ +
+ +
+ + – +
+
Bereich 0 – 10 °C
+
+ +
+ +
+ + – +
+
Heizung läuft wenn Außentemp < diesem Wert
+
+ +
+ +
+ + – +
+
Bereich 0 – 12 Uhr
+
+ +
+ +
+ + – +
+
Bereich 12 – 23 Uhr
+
+ +
+ + + +
+ +
+ + + + diff --git a/schema.sql b/schema.sql new file mode 100644 index 0000000..07b58bd --- /dev/null +++ b/schema.sql @@ -0,0 +1,99 @@ +-- ============================================================ +-- Heizungssteuerung – MySQL Datenbankschema +-- Auf dem externen MySQL-Server ausführen: +-- mysql -u root -p < schema.sql +-- ============================================================ + +CREATE DATABASE IF NOT EXISTS heizung + CHARACTER SET utf8mb4 + COLLATE utf8mb4_unicode_ci; + +USE heizung; + +-- ── Messwerte ──────────────────────────────────────────────── +-- Wird alle 60 Sekunden vom Raspberry Pi befüllt +CREATE TABLE IF NOT EXISTS messungen ( + id BIGINT NOT NULL AUTO_INCREMENT, + ts DATETIME NOT NULL COMMENT 'Zeitstempel der Messung', + + -- Temperaturen + aussentemp DECIMAL(5,1) COMMENT 'Aussentemperatur °C', + vorlauf_ist DECIMAL(5,1) COMMENT 'Vorlauftemperatur gemessen °C', + vorlauf_soll DECIMAL(5,1) COMMENT 'Vorlauftemperatur Soll °C', + kessel_oben DECIMAL(5,1) COMMENT 'Pufferspeicher oben °C', + kessel_unten DECIMAL(5,1) COMMENT 'Pufferspeicher unten °C', + wohnung DECIMAL(5,1) COMMENT 'Raumtemperatur Wohnung °C', + abgas DECIMAL(5,1) COMMENT 'Abgastemperatur °C', + + -- Regelung + stellung SMALLINT COMMENT 'Mischerstellung in Sekunden', + stellung_proz DECIMAL(5,1) COMMENT 'Mischerstellung in Prozent', + mischeron TINYINT(1) COMMENT '1 = Mischer/Heizkreis aktiv', + abweichung DECIMAL(5,1) COMMENT 'Vorlauf Soll-Ist Abweichung °C', + + -- Einstellungen (Snapshot zum Zeitpunkt der Messung) + neigung DECIMAL(4,2) COMMENT 'Heizkurven-Neigung', + rtsoll TINYINT COMMENT 'Raumsoll-Temperatur °C', + misch_begin TINYINT COMMENT 'Einschalt-Schwelle Aussentemperatur °C', + nachtabs TINYINT COMMENT 'Nachtabsenkung °C', + + PRIMARY KEY (id), + INDEX idx_ts (ts), + INDEX idx_ts_vorlauf (ts, vorlauf_ist, vorlauf_soll) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + COMMENT='Periodische Messwerte der Heizungssteuerung'; + +-- ── Parameteränderungen ────────────────────────────────────── +-- Wird bei jeder Änderung über die Weboberfläche befüllt +CREATE TABLE IF NOT EXISTS parameter_aenderungen ( + id INT NOT NULL AUTO_INCREMENT, + ts DATETIME NOT NULL COMMENT 'Zeitstempel der Änderung', + parameter VARCHAR(50) NOT NULL COMMENT 'Name des Parameters', + alter_wert VARCHAR(50) COMMENT 'Alter Wert', + neuer_wert VARCHAR(50) NOT NULL COMMENT 'Neuer Wert', + + PRIMARY KEY (id), + INDEX idx_ts (ts), + INDEX idx_parameter (parameter) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + COMMENT='Protokoll aller Parameteränderungen über die Weboberfläche'; + +-- ── Datenbank-Benutzer anlegen ─────────────────────────────── +-- Anpassen: 'heizung_pw' durch ein sicheres Passwort ersetzen +-- '192.168.%.%' auf die eigene Netzwerkadresse einschränken +CREATE USER IF NOT EXISTS 'heizung'@'192.168.%.%' + IDENTIFIED BY 'heizung_pw'; + +GRANT SELECT, INSERT ON heizung.messungen + TO 'heizung'@'192.168.%.%'; + +GRANT SELECT, INSERT ON heizung.parameter_aenderungen + TO 'heizung'@'192.168.%.%'; + +FLUSH PRIVILEGES; + +-- ── Nützliche Beispiel-Abfragen ────────────────────────────── +/* +-- Letzten 24 Stunden anzeigen (1-Minuten-Auflösung) +SELECT ts, aussentemp, vorlauf_ist, vorlauf_soll, stellung_proz +FROM messungen +WHERE ts >= NOW() - INTERVAL 24 HOUR +ORDER BY ts DESC; + +-- Tagesstatistik +SELECT + DATE(ts) AS tag, + AVG(aussentemp) AS aussen_mittel, + MIN(aussentemp) AS aussen_min, + MAX(vorlauf_ist) AS vorlauf_max, + SUM(mischeron) / COUNT(*) * 100 AS heizung_aktiv_prozent +FROM messungen +GROUP BY DATE(ts) +ORDER BY tag DESC; + +-- Letzte Parameteränderungen +SELECT ts, parameter, alter_wert, neuer_wert +FROM parameter_aenderungen +ORDER BY ts DESC +LIMIT 20; +*/ diff --git a/upload.sh b/upload.sh new file mode 100644 index 0000000..70fdc01 --- /dev/null +++ b/upload.sh @@ -0,0 +1,35 @@ +#!/bin/bash + +set -e # Script bricht bei Fehlern sofort ab + +PORT="/dev/ttyACM0" +FQBN="arduino:avr:mega" + +echo "==> Prüfe angeschlossene Boards" +arduino-cli board list + +echo "==> Installiere benötigte Libraries" +LIBS=( + "OneWire" + "DallasTemperature" + "RTClib" + "LiquidCrystal" + "max6675" + "Wire" +) + +for LIB in "${LIBS[@]}"; do + echo "Installiere $LIB..." + arduino-cli lib install "$LIB" +done + +echo "==> Installierte Libraries:" +arduino-cli lib list + +echo "==> Kompiliere Sketch" +arduino-cli compile --fqbn "$FQBN" . --warnings all + +echo "==> Lade Sketch hoch auf $PORT" +arduino-cli upload -p "$PORT" --fqbn "$FQBN" . + +echo "==> Fertig!" \ No newline at end of file