init
This commit is contained in:
1 parent
82b19fe5dc
commit
6b79415427
11 files changed
+1787
No files matched your search
Whitespace-only changes.
@@ -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
@@ -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
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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:
|
||||
@@ -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
@@ -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)
|
||||
@@ -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 < 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
@@ -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;
|
||||
*/
|
||||
@@ -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!"
|
||||
Reference in new issue
Block a user