This commit is contained in:
Tower committed 2026-04-15 17:05:07 +02:00
1 parent 82b19fe5dc
commit 6b79415427
11 files changed
+1787

No files matched your search

View File
Whitespace-only changes.
+250
View File
@@ -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://<raspi-IP>: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!
================================================================================
+588
View File
@@ -0,0 +1,588 @@
// Heizungssteuerung – millis()-basiert, kein blockierendes delay()
// Kommunikation mit Raspberry Pi über Serial (JSON)
// Befehle vom Raspi: SET <key> <value>
#include <OneWire.h>
#include <DallasTemperature.h>
#include <RTClib.h>
#include <LiquidCrystal.h>
#include <max6675.h>
#include <Wire.h>
// 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 <key> <value>\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("\"}"));
}
+16
View File
@@ -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
+17
View File
@@ -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"]
+32
View File
@@ -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:
+4
View File
@@ -0,0 +1,4 @@
flask>=3.0
pyserial>=3.5
# PyMySQL ist optional – nur benötigt wenn DB_HOST gesetzt ist
# PyMySQL>=1.1
+334
View File
@@ -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)
+412
View File
@@ -0,0 +1,412 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Heizungssteuerung</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
<style>
body { font-family: system-ui, sans-serif; }
.card { @apply bg-white rounded-2xl shadow p-4; }
.val-big { @apply text-3xl font-bold; }
.label { @apply text-xs text-gray-500 uppercase tracking-wide; }
.badge-on { @apply inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-800; }
.badge-off { @apply inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-gray-100 text-gray-500; }
</style>
</head>
<body class="bg-gray-100 min-h-screen p-4">
<!-- DB-Fehler Banner (nur sichtbar wenn DB konfiguriert aber nicht erreichbar) -->
<div id="db-banner" class="hidden max-w-5xl mx-auto mb-4 bg-red-50 border border-red-200 rounded-xl px-4 py-3 flex items-start gap-3">
<svg class="w-5 h-5 text-red-500 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3m0 3h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/>
</svg>
<div>
<p class="font-semibold text-red-700 text-sm">Datenbankverbindung fehlgeschlagen</p>
<p class="text-red-600 text-xs mt-0.5" id="db-error-msg">–</p>
<p class="text-red-500 text-xs mt-1">Messwerte werden nicht gespeichert. Alle anderen Funktionen laufen normal weiter.</p>
</div>
</div>
<!-- Header -->
<div class="max-w-5xl mx-auto">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-800">🔥 Heizungssteuerung</h1>
<div class="flex items-center gap-2 text-sm text-gray-500">
<span id="serial-dot" class="w-2 h-2 rounded-full bg-gray-400"></span>
<span id="serial-label">Verbinde…</span>
<span class="ml-4 text-gray-400" id="last-update"></span>
</div>
</div>
<!-- Sensor-Karten -->
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4 mb-6">
<div class="card">
<div class="label">Aussentemperatur</div>
<div class="val-big text-blue-600" id="aussentemp">–</div>
<div class="text-sm text-gray-400">°C</div>
</div>
<div class="card">
<div class="label">Wohnungstemperatur</div>
<div class="val-big text-orange-500" id="wohnung">–</div>
<div class="text-sm text-gray-400">°C</div>
</div>
<div class="card col-span-2">
<div class="label">Vorlauf</div>
<div class="flex items-end gap-3">
<div>
<div class="text-xs text-gray-400">Ist</div>
<div class="val-big text-red-500" id="vorlauf_ist">–</div>
</div>
<div class="text-2xl text-gray-300 pb-1">→</div>
<div>
<div class="text-xs text-gray-400">Soll</div>
<div class="val-big text-gray-700" id="vorlauf_soll">–</div>
</div>
<div class="text-sm text-gray-400 pb-1">°C</div>
</div>
</div>
<div class="card">
<div class="label">Kessel Oben</div>
<div class="val-big text-amber-600" id="kessel_oben">–</div>
<div class="text-sm text-gray-400">°C</div>
</div>
<div class="card">
<div class="label">Kessel Unten</div>
<div class="val-big text-amber-500" id="kessel_unten">–</div>
<div class="text-sm text-gray-400">°C</div>
</div>
<div class="card">
<div class="label">Abgastemperatur</div>
<div class="val-big text-rose-700" id="abgas">–</div>
<div class="text-sm text-gray-400">°C</div>
</div>
<div class="card">
<div class="label">Mischer</div>
<div class="val-big text-indigo-600" id="stellung">–</div>
<div class="text-sm text-gray-400">% geöffnet</div>
<div class="mt-1" id="mischeron-badge"><span class="badge-off">aus</span></div>
</div>
</div>
<!-- Regelungs-Diagnose -->
<div class="card mb-6">
<h2 class="font-semibold text-gray-700 mb-3">Regelungsdiagnose</h2>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 text-sm">
<div>
<div class="label">Abweichung</div>
<div class="text-xl font-bold" id="abweichung">–</div>
<div class="text-xs text-gray-400">°C (Soll − Ist)</div>
</div>
<div>
<div class="label">Regelzone / Puls</div>
<div class="text-xl font-bold" id="regelzone">–</div>
<div class="text-xs text-gray-400" id="puls-info">–</div>
</div>
<div>
<div class="label">Check-Intervall</div>
<div class="text-xl font-bold" id="check-intervall">–</div>
<div class="text-xs text-gray-400">Sekunden</div>
</div>
<div>
<div class="label">Status</div>
<div class="mt-1 flex flex-col gap-1">
<span id="puls-aktiv-badge" class="badge-off">kein Puls</span>
<span id="warmup-badge" class="badge-off">normal</span>
</div>
</div>
</div>
</div>
<!-- Verlaufs-Chart -->
<div class="card mb-6">
<div class="label mb-2">Temperaturverlauf</div>
<canvas id="tempChart" height="120"></canvas>
</div>
<!-- Einstellungen -->
<div class="card mb-6">
<h2 class="font-semibold text-gray-700 mb-4">Einstellungen</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6" id="settings-grid">
<div class="setting-block" data-key="neigung" data-type="float" data-min="0.1" data-max="4.0" data-step="0.1">
<label class="label">Neigung (Heizkurve)</label>
<div class="flex items-center gap-2 mt-1">
<input type="range" class="flex-1 accent-red-500" id="input-neigung">
<span class="w-12 text-right font-mono" id="display-neigung">–</span>
</div>
<div class="text-xs text-gray-400">Bereich 0.1 – 4.0</div>
</div>
<div class="setting-block" data-key="rtsoll" data-type="int" data-min="10" data-max="30" data-step="1">
<label class="label">Raumsoll-Temperatur (°C)</label>
<div class="flex items-center gap-2 mt-1">
<input type="range" class="flex-1 accent-orange-400" id="input-rtsoll">
<span class="w-12 text-right font-mono" id="display-rtsoll">–</span>
</div>
<div class="text-xs text-gray-400">Bereich 10 – 30 °C</div>
</div>
<div class="setting-block" data-key="nachtabs" data-type="int" data-min="0" data-max="10" data-step="1">
<label class="label">Nachtabsenkung (°C)</label>
<div class="flex items-center gap-2 mt-1">
<input type="range" class="flex-1 accent-blue-400" id="input-nachtabs">
<span class="w-12 text-right font-mono" id="display-nachtabs">–</span>
</div>
<div class="text-xs text-gray-400">Bereich 0 – 10 °C</div>
</div>
<div class="setting-block" data-key="misch_begin" data-type="int" data-min="-15" data-max="25" data-step="1">
<label class="label">Heizung aus ab Außentemp (°C)</label>
<div class="flex items-center gap-2 mt-1">
<input type="range" class="flex-1 accent-green-500" id="input-misch_begin">
<span class="w-12 text-right font-mono" id="display-misch_begin">–</span>
</div>
<div class="text-xs text-gray-400">Heizung läuft wenn Außentemp &lt; diesem Wert</div>
</div>
<div class="setting-block" data-key="tag_anfang" data-type="int" data-min="0" data-max="12" data-step="1">
<label class="label">Tag beginnt (Uhr)</label>
<div class="flex items-center gap-2 mt-1">
<input type="range" class="flex-1 accent-yellow-400" id="input-tag_anfang">
<span class="w-12 text-right font-mono" id="display-tag_anfang">–</span>
</div>
<div class="text-xs text-gray-400">Bereich 0 – 12 Uhr</div>
</div>
<div class="setting-block" data-key="tag_ende" data-type="int" data-min="12" data-max="23" data-step="1">
<label class="label">Tag endet (Uhr)</label>
<div class="flex items-center gap-2 mt-1">
<input type="range" class="flex-1 accent-yellow-600" id="input-tag_ende">
<span class="w-12 text-right font-mono" id="display-tag_ende">–</span>
</div>
<div class="text-xs text-gray-400">Bereich 12 – 23 Uhr</div>
</div>
</div>
<!-- Status-Meldung -->
<div id="save-status" class="mt-4 text-sm text-gray-500 hidden"></div>
</div>
</div><!-- /max-w -->
<script>
// ── Chart ─────────────────────────────────────────────────────────────────────
const ctx = document.getElementById('tempChart').getContext('2d');
const chart = new Chart(ctx, {
type: 'line',
data: {
labels: [],
datasets: [
{ label: 'Vorlauf Ist', data: [], borderColor: '#ef4444', tension: 0.3, pointRadius: 0 },
{ label: 'Vorlauf Soll', data: [], borderColor: '#6b7280', tension: 0.3, pointRadius: 0, borderDash: [5,3] },
{ label: 'Aussen', data: [], borderColor: '#3b82f6', tension: 0.3, pointRadius: 0 },
{ label: 'Wohnung', data: [], borderColor: '#f97316', tension: 0.3, pointRadius: 0 },
]
},
options: {
animation: false,
scales: {
x: { display: false },
y: { grid: { color: '#f3f4f6' } }
},
plugins: { legend: { position: 'bottom', labels: { boxWidth: 12 } } }
}
});
function updateChart(hist) {
chart.data.labels = hist.map(h => new Date(h.ts * 1000).toLocaleTimeString('de'));
chart.data.datasets[0].data = hist.map(h => h.vorlauf_ist);
chart.data.datasets[1].data = hist.map(h => h.vorlauf_soll);
chart.data.datasets[2].data = hist.map(h => h.aussentemp);
chart.data.datasets[3].data = hist.map(h => h.wohnung);
chart.update();
}
// ── Einstellungen ─────────────────────────────────────────────────────────────
// Für jeden Slider: beim Loslassen Wert an Server senden
let sendTimer = null;
function setupSliders() {
document.querySelectorAll('.setting-block').forEach(block => {
const key = block.dataset.key;
const type = block.dataset.type;
const min = parseFloat(block.dataset.min);
const max = parseFloat(block.dataset.max);
const step = parseFloat(block.dataset.step);
const input = block.querySelector('input');
const disp = block.querySelector(`#display-${key}`);
input.min = min;
input.max = max;
input.step = step;
input.addEventListener('input', () => {
disp.textContent = formatVal(input.value, type);
});
input.addEventListener('change', () => {
const val = type === 'float' ? parseFloat(input.value) : parseInt(input.value);
sendSet(key, val);
});
});
}
function formatVal(v, type) {
return type === 'float' ? parseFloat(v).toFixed(1) : parseInt(v);
}
function applySlider(key, value) {
const input = document.getElementById(`input-${key}`);
const disp = document.getElementById(`display-${key}`);
if (!input) return;
const block = input.closest('.setting-block');
const type = block.dataset.type;
input.value = value;
disp.textContent = formatVal(value, type);
}
async function sendSet(key, value) {
const status = document.getElementById('save-status');
status.textContent = `Sende ${key} = ${value}…`;
status.className = 'mt-4 text-sm text-blue-500';
status.classList.remove('hidden');
try {
const res = await fetch('/api/set', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, value })
});
const data = await res.json();
if (data.ok) {
status.textContent = `✓ ${key} = ${value} gespeichert`;
status.className = 'mt-4 text-sm text-green-600';
} else {
status.textContent = `✗ Fehler: ${data.error}`;
status.className = 'mt-4 text-sm text-red-500';
}
} catch (e) {
status.textContent = `✗ Netzwerkfehler`;
status.className = 'mt-4 text-sm text-red-500';
}
}
// ── Daten polling ─────────────────────────────────────────────────────────────
function fmt(v, dec = 1) {
if (v === null || v === undefined) return '–';
return typeof v === 'number' ? v.toFixed(dec) : v;
}
async function refreshData() {
try {
const [dataRes, histRes] = await Promise.all([
fetch('/api/data'),
fetch('/api/history')
]);
const data = await dataRes.json();
const hist = await histRes.json();
// Verbindungsstatus
const dot = document.getElementById('serial-dot');
const label = document.getElementById('serial-label');
if (data.serial_connected && !data.data_stale) {
dot.className = 'w-2 h-2 rounded-full bg-green-500';
label.textContent = 'Arduino verbunden';
} else if (data.serial_connected && data.data_stale) {
dot.className = 'w-2 h-2 rounded-full bg-yellow-400';
label.textContent = 'Keine Daten (> 30 s)';
} else {
dot.className = 'w-2 h-2 rounded-full bg-red-500';
label.textContent = 'Arduino getrennt';
}
document.getElementById('last-update').textContent =
'Zuletzt: ' + new Date().toLocaleTimeString('de');
// DB-Status Banner
const dbBanner = document.getElementById('db-banner');
if (data.db_configured && !data.db_connected) {
dbBanner.classList.remove('hidden');
document.getElementById('db-error-msg').textContent = data.db_error || 'Unbekannter Fehler';
} else {
dbBanner.classList.add('hidden');
}
// Sensor-Werte
document.getElementById('aussentemp').textContent = fmt(data.aussentemp);
document.getElementById('wohnung').textContent = fmt(data.wohnung);
document.getElementById('vorlauf_ist').textContent = fmt(data.vorlauf_ist);
document.getElementById('vorlauf_soll').textContent = fmt(data.vorlauf_soll);
document.getElementById('kessel_oben').textContent = fmt(data.kessel_oben);
document.getElementById('kessel_unten').textContent = fmt(data.kessel_unten);
document.getElementById('abgas').textContent = fmt(data.abgas, 0);
// Mischerstellung in Prozent
if (data.stellung !== undefined && data.time_adjust) {
const proz = Math.round(data.stellung / data.time_adjust * 100);
document.getElementById('stellung').textContent = proz;
}
const badge = document.getElementById('mischeron-badge');
badge.innerHTML = data.mischeron
? '<span class="badge-on">Mischer an</span>'
: '<span class="badge-off">Mischer aus</span>';
// Regelungsdiagnose
if (data.abweichung !== undefined) {
const abw = data.abweichung;
const abwEl = document.getElementById('abweichung');
abwEl.textContent = (abw >= 0 ? '+' : '') + abw.toFixed(1);
abwEl.className = 'text-xl font-bold ' + (Math.abs(abw) > 8 ? 'text-red-600' :
Math.abs(abw) > 4 ? 'text-orange-500' :
Math.abs(abw) > 1.5 ? 'text-yellow-600' : 'text-green-600');
}
// Regelzone bestimmen (clientseitig aus Abweichung ableiten)
if (data.abweichung !== undefined && data.check_intervall !== undefined) {
const abwAbs = Math.abs(data.abweichung);
const zone = abwAbs > 8 ? 'Sprint' : abwAbs > 4 ? 'Jogging' : abwAbs > 1.5 ? 'Normal' : 'Totband';
document.getElementById('regelzone').textContent = zone;
document.getElementById('puls-info').textContent =
(data.puls_zeit || '–') + ' s Puls';
document.getElementById('check-intervall').textContent = data.check_intervall || '–';
}
document.getElementById('puls-aktiv-badge').innerHTML =
data.puls_aktiv ? '<span class="badge-on">Puls aktiv</span>' :
'<span class="badge-off">kein Puls</span>';
document.getElementById('warmup-badge').innerHTML =
data.warmup ? '<span class="badge-on">Anlaufphase</span>' :
'<span class="badge-off">Normalbetrieb</span>';
// Slider nur beim ersten Laden setzen (danach steuert der User)
const keys = ['neigung','rtsoll','nachtabs','misch_begin','tag_anfang','tag_ende'];
keys.forEach(k => {
const input = document.getElementById(`input-${k}`);
if (input && input.dataset.loaded !== 'true' && data[k] !== undefined) {
applySlider(k, data[k]);
input.dataset.loaded = 'true';
}
});
updateChart(hist);
} catch (e) {
console.warn('Refresh-Fehler:', e);
}
}
// ── Init ──────────────────────────────────────────────────────────────────────
setupSliders();
refreshData();
setInterval(refreshData, 5000); // alle 5 Sekunden aktualisieren
</script>
</body>
</html>
+99
View File
@@ -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;
*/
+35
View File
@@ -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!"