ilk işlem
This commit is contained in:
1
ebuild/core/__init__.py
Normal file
1
ebuild/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
BIN
ebuild/core/__pycache__/__init__.cpython-39.pyc
Normal file
BIN
ebuild/core/__pycache__/__init__.cpython-39.pyc
Normal file
Binary file not shown.
BIN
ebuild/core/__pycache__/analog_sensors.cpython-39.pyc
Normal file
BIN
ebuild/core/__pycache__/analog_sensors.cpython-39.pyc
Normal file
Binary file not shown.
BIN
ebuild/core/__pycache__/building.cpython-39.pyc
Normal file
BIN
ebuild/core/__pycache__/building.cpython-39.pyc
Normal file
Binary file not shown.
BIN
ebuild/core/__pycache__/environment.cpython-39.pyc
Normal file
BIN
ebuild/core/__pycache__/environment.cpython-39.pyc
Normal file
Binary file not shown.
BIN
ebuild/core/__pycache__/season.cpython-39.pyc
Normal file
BIN
ebuild/core/__pycache__/season.cpython-39.pyc
Normal file
Binary file not shown.
BIN
ebuild/core/__pycache__/sunholiday.cpython-39.pyc
Normal file
BIN
ebuild/core/__pycache__/sunholiday.cpython-39.pyc
Normal file
Binary file not shown.
123
ebuild/core/analog_sensors.py
Normal file
123
ebuild/core/analog_sensors.py
Normal file
@@ -0,0 +1,123 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
__title__ = "analog_sensors"
|
||||
__author__ = 'Mehmet Karatay & "Saraswati" (ChatGPT)'
|
||||
__purpose__ = "MCP3008 üzerinden okunan analog sensörler için basit hub"
|
||||
__version__ = "0.1.0"
|
||||
__date__ = "2025-11-23"
|
||||
|
||||
"""
|
||||
ebuild/core/analog_sensors.py
|
||||
|
||||
Amaç
|
||||
-----
|
||||
- MCP3008 ADC üzerinden okunan analog kanalları tek noktada toplamak.
|
||||
- Basınç, gaz, yağmur ve LDR için basit arayüz sağlamak.
|
||||
|
||||
Notlar
|
||||
------
|
||||
- Şimdilik çok sade tutuldu; istersen ileride eşik, durum (state) ve alarm
|
||||
mantığını genişletebilirsin.
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
|
||||
class _SimpleChannelState:
|
||||
"""
|
||||
Tek bir analog kanal için durum tutucu.
|
||||
"""
|
||||
def __init__(self, name: str) -> None:
|
||||
self.name = name
|
||||
self.state: Optional[str] = None
|
||||
self.last_raw: Optional[int] = None
|
||||
# Gaz için latched alarm gibi ek alanlar:
|
||||
self.latched_alarm: bool = False
|
||||
|
||||
def update(self, raw: Optional[int]) -> None:
|
||||
self.last_raw = raw
|
||||
# Şimdilik state hesaplamıyoruz; ileride eşiğe göre doldurulabilir.
|
||||
self.state = None
|
||||
|
||||
|
||||
class AnalogSensorsHub:
|
||||
"""
|
||||
MCP3008 ADC üzerinden basınç, gaz, yağmur, LDR gibi sensörleri yöneten hub.
|
||||
|
||||
Beklenti:
|
||||
---------
|
||||
adc nesnesinin en azından şu fonksiyonu sağlaması:
|
||||
- read_channel(ch: int) -> int (0..1023 arası değer)
|
||||
"""
|
||||
|
||||
def __init__(self, adc) -> None:
|
||||
self.adc = adc
|
||||
|
||||
# Her kanal için basit state objeleri
|
||||
self.pressure = _SimpleChannelState("pressure")
|
||||
self.gas = _SimpleChannelState("gas")
|
||||
self.rain = _SimpleChannelState("rain")
|
||||
self.ldr = _SimpleChannelState("ldr")
|
||||
|
||||
# Kanalları sabitliyoruz; istersen config'ten de alabilirsin.
|
||||
self.pressure_ch = 0
|
||||
self.gas_ch = 1
|
||||
self.rain_ch = 2
|
||||
self.ldr_ch = 3
|
||||
|
||||
def _safe_read(self, ch: int) -> Optional[int]:
|
||||
"""
|
||||
ADC'den güvenli okuma. Hata olursa None döndürür.
|
||||
"""
|
||||
if self.adc is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
val = self.adc.read_channel(ch)
|
||||
return int(val)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def update_all(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Tüm kanalları okuyup state nesnelerini günceller.
|
||||
|
||||
Dönüş:
|
||||
{
|
||||
"pressure": <raw or None>,
|
||||
"gas": <raw or None>,
|
||||
"rain": <raw or None>,
|
||||
"ldr": <raw or None>,
|
||||
}
|
||||
"""
|
||||
vals: Dict[str, Any] = {}
|
||||
|
||||
p_raw = self._safe_read(self.pressure_ch)
|
||||
g_raw = self._safe_read(self.gas_ch)
|
||||
r_raw = self._safe_read(self.rain_ch)
|
||||
l_raw = self._safe_read(self.ldr_ch)
|
||||
|
||||
self.pressure.update(p_raw)
|
||||
self.gas.update(g_raw)
|
||||
self.rain.update(r_raw)
|
||||
self.ldr.update(l_raw)
|
||||
|
||||
vals["pressure"] = p_raw
|
||||
vals["gas"] = g_raw
|
||||
vals["rain"] = r_raw
|
||||
vals["ldr"] = l_raw
|
||||
|
||||
return vals
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Basit demo: sahte ADC ile
|
||||
class DummyADC:
|
||||
def read_channel(self, ch: int) -> int:
|
||||
return 512 + ch # uydurma değerler
|
||||
|
||||
hub = AnalogSensorsHub(DummyADC())
|
||||
values = hub.update_all()
|
||||
print("AnalogSensorsHub demo:", values)
|
||||
print("Gas latched_alarm:", hub.gas.latched_alarm)
|
||||
590
ebuild/core/building.py
Normal file
590
ebuild/core/building.py
Normal file
@@ -0,0 +1,590 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
__title__ = "building"
|
||||
__author__ = 'Mehmet Karatay & "Saraswati" (ChatGPT)'
|
||||
__purpose__ = "Bina / daire / sensör ve brülör topolojisi sürücüsü"
|
||||
__version__ = "0.4.3"
|
||||
__date__ = "2025-11-22"
|
||||
|
||||
"""
|
||||
ebuild/core/building.py
|
||||
|
||||
Revision : 2025-11-22
|
||||
Authors : Mehmet Karatay & "Saraswati" (ChatGPT)
|
||||
|
||||
Amaç
|
||||
-----
|
||||
- Statik konfigürasyondan (config_statics) bina, geo ve sensör topolojisini yükler.
|
||||
- FLAT_AREA içindeki DS18B20 sensörlerini daire/oda bazında yönetir.
|
||||
- Bina ortalama sıcaklık istatistiklerini üretir.
|
||||
- Brülör grupları (BURNER_GROUPS) ve hat sensörleri (dış, çıkış, dönüş) için
|
||||
başlangıçta okunabilir bir özet verir.
|
||||
- SeasonController ile mevsim / güneş / tatil bilgisi sağlar.
|
||||
|
||||
Not
|
||||
---
|
||||
Bu modül, hem bağımsız test aracı olarak `python3 -m ebuild.core.building`
|
||||
şeklinde çalışabilir, hem de BurnerController gibi üst seviye kontrol
|
||||
modülleri tarafından kullanılabilir.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import datetime
|
||||
import statistics
|
||||
from collections import defaultdict
|
||||
from typing import Dict, List, Optional, Any
|
||||
|
||||
from .season import SeasonController
|
||||
from ..io.dbtext import DBText
|
||||
from .. import config_statics as cfg_s
|
||||
from .. import config_runtime as cfg_v
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Yardımcı sabitler
|
||||
# ------------------------------------------------------------------
|
||||
FlatDirection = {
|
||||
0: "Kuzey-Kuzey",
|
||||
1: "Kuzey-Doğu",
|
||||
2: "Kuzey-Batı",
|
||||
3: "Güney-Güney",
|
||||
4: "Güney-Doğu",
|
||||
5: "Güney-Batı",
|
||||
6: "Doğu-Doğu",
|
||||
7: "Batı-Batı",
|
||||
}
|
||||
|
||||
ValveType = {
|
||||
0: "Yok",
|
||||
1: "Dijital",
|
||||
2: "PWM",
|
||||
3: "Analog",
|
||||
4: "Enkoder",
|
||||
5: "Modbus",
|
||||
6: "CANbus",
|
||||
}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DS18B20 sensör sınıfı
|
||||
# ------------------------------------------------------------------
|
||||
class EDSensor:
|
||||
"""Tek bir DS18B20 sensörünü temsil eder."""
|
||||
|
||||
def __init__(self, serial: str, flat_no: int, flat_direction: int, floor: int, room_no: int = 0) -> None:
|
||||
self.serial = serial
|
||||
self.flat_no = flat_no
|
||||
self.floor = floor
|
||||
self.room_no = room_no
|
||||
self.flat_direction = flat_direction
|
||||
self.device_path = f"/sys/bus/w1/devices/{serial}/w1_slave"
|
||||
self.flat_check = 0
|
||||
self.is_connected = True
|
||||
|
||||
def read_temperature(self) -> Optional[float]:
|
||||
try:
|
||||
with open(self.device_path, "r") as f:
|
||||
lines = f.readlines()
|
||||
if lines[0].strip().endswith("YES"):
|
||||
pos = lines[1].find("t=")
|
||||
if pos != -1:
|
||||
raw = lines[1][pos + 2 :]
|
||||
t_c = float(raw) / 1000.0
|
||||
self.is_connected = True
|
||||
return t_c
|
||||
except Exception:
|
||||
self.flat_check += 1
|
||||
self.is_connected = False
|
||||
return None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Daire nesnesi
|
||||
# ------------------------------------------------------------------
|
||||
class Flat:
|
||||
"""Daire nesnesi: yön, kat ve sensör ilişkisi."""
|
||||
|
||||
def __init__(self, flat_no: int, flat_direction: int, sensor: EDSensor, floor: int, room_no: int = 0) -> None:
|
||||
self.flat_no = flat_no
|
||||
self.flat_direction = flat_direction
|
||||
self.sensor = sensor
|
||||
self.floor = floor
|
||||
self.room_no = room_no
|
||||
self.last_heat: Optional[float] = None
|
||||
self.last_read_time: Optional[datetime.datetime] = None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Bina sınıfı
|
||||
# ------------------------------------------------------------------
|
||||
class Building:
|
||||
"""
|
||||
Bina seviyesi sürücü.
|
||||
|
||||
config_statics üzerinden:
|
||||
|
||||
BUILDING_NAME
|
||||
BUILDING_LOCATION
|
||||
BUILDING_LABEL
|
||||
|
||||
GEO_CITY
|
||||
GEO_COUNTRY
|
||||
GEO_TZ
|
||||
GEO_LAT
|
||||
GEO_LON
|
||||
|
||||
FLAT_AREA
|
||||
BURNER_GROUPS
|
||||
OUTSIDE_SENSOR_ID / NAME
|
||||
BURNER_OUT_SENSOR_ID / NAME
|
||||
RETURN_LINE_SENSOR_IDS / NAME_MAP
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: Optional[str] = None,
|
||||
location: Optional[str] = None,
|
||||
bina_adi: Optional[str] = None,
|
||||
logtime: bool = False,
|
||||
logger: Optional[DBText] = None,
|
||||
) -> None:
|
||||
# Bina bilgileri config_statics'ten
|
||||
self.name = name or getattr(cfg_s, "BUILDING_NAME", "Bina")
|
||||
self.location = location or getattr(cfg_s, "BUILDING_LOCATION", "Ankara")
|
||||
self.bina_adi = bina_adi or getattr(cfg_s, "BUILDING_LABEL", "A_Binasi")
|
||||
|
||||
# Coğrafi bilgiler
|
||||
self.geo_city = getattr(cfg_s, "GEO_CITY", "Ankara")
|
||||
self.geo_country = getattr(cfg_s, "GEO_COUNTRY", "Turkey")
|
||||
self.geo_tz = getattr(cfg_s, "GEO_TZ", "Europe/Istanbul")
|
||||
self.geo_lat = float(getattr(cfg_s, "GEO_LAT", 39.92077))
|
||||
self.geo_lon = float(getattr(cfg_s, "GEO_LON", 32.85411))
|
||||
|
||||
# Logger
|
||||
self.logger = logger or DBText(
|
||||
filename=getattr(cfg_s, "BUILDING_LOG_FILE", "ebina_log.sql"),
|
||||
table=getattr(cfg_s, "BUILDING_LOG_TABLE", "ebrulor_log"),
|
||||
app="ESYSTEM",
|
||||
)
|
||||
|
||||
# Çalışma aralığı: 3 dakika (eski koddaki gibi)
|
||||
interval_min = getattr(cfg_v, "BUILDING_SAMPLE_MINUTES", 3)
|
||||
self.interval = datetime.timedelta(seconds=60 * interval_min)
|
||||
self.next_run = datetime.datetime.now()
|
||||
|
||||
self.flats: Dict[int, Flat] = {}
|
||||
self.last_max_floor: Optional[int] = None
|
||||
self.logtime = logtime
|
||||
|
||||
# Season / tatil bilgisi
|
||||
try:
|
||||
self.season = SeasonController.from_now()
|
||||
except Exception as e:
|
||||
print(f"⚠️ SeasonController.from_now() hata: {e}")
|
||||
self.season = None
|
||||
|
||||
# Daireleri FLAT_AREA'dan oluştur
|
||||
self._load_statics_from_flat_area()
|
||||
|
||||
# Bina genel istatistiği (ilk deneme)
|
||||
try:
|
||||
self.get_building_temperature_stats()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def pretty_summary(self) -> str:
|
||||
"""
|
||||
Bina durumunu okunabilir bir metin olarak döndür.
|
||||
Konsola bastığımız bilgilerin string versiyonu.
|
||||
"""
|
||||
import io
|
||||
import contextlib
|
||||
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
# İstersen istatistiği de özetin içine kat:
|
||||
self.get_building_temperature_stats()
|
||||
|
||||
# Zaten mevcut olan konsol fonksiyonlarını kullanıyoruz
|
||||
self._print_header()
|
||||
self._print_sun_and_season()
|
||||
self._print_burner_topology()
|
||||
self._print_line_sensors()
|
||||
self.print_config()
|
||||
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def get_hat_sensor_by_id(self, sensor_id: str):
|
||||
"""
|
||||
Verilen DS18B20 ID'sine göre hat sensör nesnesini döndür.
|
||||
Yoksa None döndür.
|
||||
"""
|
||||
return self.hat_sensors.get(sensor_id) # yapıya göre uyarlarsın
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Statik konfigürasyondan daire/sensör yükleme
|
||||
# ------------------------------------------------------------------
|
||||
def _load_statics_from_flat_area(self) -> None:
|
||||
"""config_statics.FLAT_AREA içinden daire/sensörleri yükler."""
|
||||
self.flats = {}
|
||||
flat_area = getattr(cfg_s, "FLAT_AREA", [])
|
||||
for item in flat_area:
|
||||
serial = item.get("serial")
|
||||
flat_no = int(item.get("flat_no", 0))
|
||||
room_no = int(item.get("room_no", 0))
|
||||
floor = int(item.get("floor", 0))
|
||||
direction = int(item.get("direction", 0))
|
||||
if not serial:
|
||||
continue
|
||||
sensor = EDSensor(serial, flat_no, direction, floor, room_no=room_no)
|
||||
# bağlantı testi
|
||||
sensor.read_temperature()
|
||||
flat = Flat(flat_no, direction, sensor, floor, room_no=room_no)
|
||||
self.flats[flat_no] = flat
|
||||
|
||||
print("✅ Building: statics yüklendi")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Başlangıç çıktıları
|
||||
# ------------------------------------------------------------------
|
||||
def _print_header(self) -> None:
|
||||
print(f"🏢 Bina adı : {self.bina_adi}")
|
||||
print(f"📍 Konum : {self.name} / {self.location}")
|
||||
print(
|
||||
f"🌍 Geo : {self.geo_city}, {self.geo_country} "
|
||||
f"({self.geo_lat}, {self.geo_lon}) tz={self.geo_tz}"
|
||||
)
|
||||
print(f"🏠 Daire/oda sayısı: {len(self.flats)}\n")
|
||||
|
||||
def _print_sun_and_season(self) -> None:
|
||||
"""SeasonController üzerinden sunrise / sunset ve mevsim / tatil bilgisini yazar."""
|
||||
if not self.season or not getattr(self.season, "info", None):
|
||||
print("☀️ Sunrise/Sunset : --:-- / --:--")
|
||||
print("🧭 Mevsim / Tatil : (bilgi yok)\n")
|
||||
return
|
||||
|
||||
info = self.season.info
|
||||
sunrise = info.sunrise.strftime("%H:%M") if info.sunrise else "--:--"
|
||||
sunset = info.sunset.strftime("%H:%M") if info.sunset else "--:--"
|
||||
|
||||
print(f"☀️ Sunrise/Sunset : {sunrise} / {sunset}")
|
||||
if info.season_start and info.season_end:
|
||||
print(
|
||||
f"🧭 Mevsim / Tatil : {info.season} "
|
||||
f"({info.season_start} → {info.season_end}), "
|
||||
f"Tatil={info.is_holiday} {info.holiday_label}"
|
||||
)
|
||||
else:
|
||||
print(
|
||||
f"🧭 Mevsim / Tatil : {info.season}, "
|
||||
f"Tatil={info.is_holiday} {info.holiday_label}"
|
||||
)
|
||||
print()
|
||||
|
||||
def _print_burner_topology(self) -> None:
|
||||
"""config_statics.BURNER_GROUPS bilgisini okunaklı yazar."""
|
||||
groups = getattr(cfg_s, "BURNER_GROUPS", {})
|
||||
if not groups:
|
||||
print("🔥 Brülör topolojisi: tanımlı değil (BURNER_GROUPS boş).\n")
|
||||
return
|
||||
|
||||
print("🔥 Brülör Topolojisi (config_statics.BURNER_GROUPS):")
|
||||
for bid, info in groups.items():
|
||||
name = info.get("name", f"Burner{bid}")
|
||||
loc = info.get("location", "-")
|
||||
ign = info.get("igniter_pin", None)
|
||||
print(f" #{bid}: {name} @ {loc}")
|
||||
print(f" Igniter pin : {ign}")
|
||||
circ = info.get("circulation", {})
|
||||
if not circ:
|
||||
print(" Pompalar : (tanımlı değil)")
|
||||
else:
|
||||
print(" Pompalar :")
|
||||
for cname, cinfo in circ.items():
|
||||
pin = cinfo.get("pin")
|
||||
default = cinfo.get("default", 0)
|
||||
print(f" - {cname:<7} → pin={pin:<3} default={default}")
|
||||
print()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Hat sensörleri: dış / çıkış / dönüş
|
||||
# ------------------------------------------------------------------
|
||||
def _read_ds18b20_raw(self, serial: str) -> Optional[float]:
|
||||
"""Seri numarası verilen DS18B20'den tek seferlik okuma."""
|
||||
path = f"/sys/bus/w1/devices/{serial}/w1_slave"
|
||||
try:
|
||||
with open(path, "r") as f:
|
||||
lines = f.readlines()
|
||||
if not lines or not lines[0].strip().endswith("YES"):
|
||||
return None
|
||||
pos = lines[1].find("t=")
|
||||
if pos == -1:
|
||||
return None
|
||||
raw = lines[1][pos + 2 :]
|
||||
return float(raw) / 1000.0
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _print_line_sensors(self) -> None:
|
||||
"""
|
||||
Dış ısı, kazan çıkış ısısı ve dönüş hat sensörlerini (varsa) ekrana yazar.
|
||||
config_statics içindeki:
|
||||
OUTSIDE_SENSOR_ID / OUTSIDE_SENSOR_NAME
|
||||
BURNER_OUT_SENSOR_ID / BURNER_OUT_SENSOR_NAME
|
||||
RETURN_LINE_SENSOR_IDS
|
||||
RETURN_LINE_SENSOR_NAME_MAP
|
||||
kullanılır.
|
||||
"""
|
||||
outside_id = getattr(cfg_s, "OUTSIDE_SENSOR_ID", None)
|
||||
outside_nm = getattr(cfg_s, "OUTSIDE_SENSOR_NAME", "Dış Isı 1")
|
||||
burner_id = getattr(cfg_s, "BURNER_OUT_SENSOR_ID", None)
|
||||
burner_nm = getattr(cfg_s, "BURNER_OUT_SENSOR_NAME", "Çıkış Isı 1")
|
||||
return_ids = list(getattr(cfg_s, "RETURN_LINE_SENSOR_IDS", []))
|
||||
name_map = dict(getattr(cfg_s, "RETURN_LINE_SENSOR_NAME_MAP", {}))
|
||||
|
||||
if not (outside_id or burner_id or return_ids):
|
||||
# Hiç tanım yoksa sessizce geç
|
||||
return
|
||||
|
||||
print("🌡️ Hat sensörleri:")
|
||||
# Dış ısı
|
||||
if outside_id:
|
||||
t = self._read_ds18b20_raw(outside_id)
|
||||
if t is None:
|
||||
print(f" - {outside_nm:<15}: ❌ Okunamadı ({outside_id})")
|
||||
else:
|
||||
print(f" - {outside_nm:<15}: {t:5.2f}°C ({outside_id})")
|
||||
|
||||
# Kazan çıkış
|
||||
if burner_id:
|
||||
t = self._read_ds18b20_raw(burner_id)
|
||||
if t is None:
|
||||
print(f" - {burner_nm:<15}: ❌ Okunamadı ({burner_id})")
|
||||
else:
|
||||
print(f" - {burner_nm:<15}: {t:5.2f}°C ({burner_id})")
|
||||
|
||||
# Dönüş hatları
|
||||
for sid in return_ids:
|
||||
nm = name_map.get(sid, sid)
|
||||
t = self._read_ds18b20_raw(sid)
|
||||
if t is None:
|
||||
print(f" - Dönüş {nm:<10}: ❌ Okunamadı ({sid})")
|
||||
else:
|
||||
print(f" - Dönüş {nm:<10}: {t:5.2f}°C ({sid})")
|
||||
print()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# İstatistik fonksiyonları
|
||||
# ------------------------------------------------------------------
|
||||
def _summarize(self, values: List[float]) -> dict:
|
||||
if not values:
|
||||
return {"min": None, "max": None, "avg": None, "count": 0}
|
||||
return {
|
||||
"min": min(values),
|
||||
"max": max(values),
|
||||
"avg": round(statistics.mean(values), 2),
|
||||
"count": len(values),
|
||||
}
|
||||
|
||||
def get_temperature_stats(self) -> str:
|
||||
stats = {
|
||||
"floors": defaultdict(list),
|
||||
"directions": defaultdict(list),
|
||||
"top_floor": [],
|
||||
"building": [],
|
||||
}
|
||||
|
||||
max_floor = self.last_max_floor
|
||||
|
||||
for flat in self.flats.values():
|
||||
temp = flat.sensor.read_temperature()
|
||||
if temp is not None:
|
||||
flat.last_heat = temp
|
||||
flat.last_read_time = datetime.datetime.now()
|
||||
stats["floors"][flat.floor].append(temp)
|
||||
stats["directions"][flat.flat_direction].append(temp)
|
||||
stats["building"].append(temp)
|
||||
if max_floor is not None and flat.floor == max_floor:
|
||||
stats["top_floor"].append(temp)
|
||||
|
||||
result = {
|
||||
"floors": {f: self._summarize(v) for f, v in stats["floors"].items()},
|
||||
"directions": {
|
||||
d: self._summarize(v) for d, v in stats["directions"].items()
|
||||
},
|
||||
"top_floor": self._summarize(stats["top_floor"]),
|
||||
"building": self._summarize(stats["building"]),
|
||||
}
|
||||
return json.dumps(result, indent=4, ensure_ascii=False)
|
||||
|
||||
def get_last_heat_report(self) -> str:
|
||||
report = []
|
||||
for flat in self.flats.values():
|
||||
temp = flat.sensor.read_temperature()
|
||||
now = datetime.datetime.now()
|
||||
if temp is not None:
|
||||
flat.last_heat = temp
|
||||
flat.last_read_time = now
|
||||
report.append(
|
||||
{
|
||||
"flat_no": flat.flat_no,
|
||||
"room_no": flat.room_no,
|
||||
"direction": flat.flat_direction,
|
||||
"floor": flat.floor,
|
||||
"serial": flat.sensor.serial,
|
||||
"last_heat": flat.last_heat,
|
||||
"last_read_time": flat.last_read_time.strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
if flat.last_read_time
|
||||
else None,
|
||||
"is_connected": flat.sensor.is_connected,
|
||||
"flat_check": flat.sensor.flat_check,
|
||||
}
|
||||
)
|
||||
return json.dumps(report, indent=4, ensure_ascii=False)
|
||||
|
||||
def get_building_temperature_stats(self) -> Optional[dict]:
|
||||
print("\n🌡️ Bina Genel Isı Bilgisi:")
|
||||
all_temps: List[float] = []
|
||||
floor_temps: Dict[int, List[float]] = {}
|
||||
now = datetime.datetime.now()
|
||||
|
||||
# Log zamanı mı?
|
||||
if now >= self.next_run:
|
||||
self.logtime = True
|
||||
self.next_run = now + self.interval
|
||||
else:
|
||||
self.logtime = False
|
||||
|
||||
self.last_max_floor = None
|
||||
|
||||
for flat in self.flats.values():
|
||||
temp = flat.sensor.read_temperature()
|
||||
floor = flat.floor
|
||||
|
||||
if temp is not None:
|
||||
flat.last_heat = temp
|
||||
flat.last_read_time = now
|
||||
if self.last_max_floor is None or floor > self.last_max_floor:
|
||||
self.last_max_floor = floor
|
||||
else:
|
||||
flat.last_heat = None
|
||||
flat.last_read_time = None
|
||||
|
||||
floor_temps.setdefault(floor, [])
|
||||
if temp is not None:
|
||||
all_temps.append(temp)
|
||||
floor_temps[floor].append(temp)
|
||||
if self.logtime:
|
||||
self.logger.insert_event(
|
||||
source=f"Sensor:{flat.sensor.serial}",
|
||||
event_type="temperature",
|
||||
value=temp,
|
||||
unit="°C",
|
||||
timestamp=now,
|
||||
extra=f"Daire {flat.flat_no} Oda {flat.room_no} Kat {flat.floor}, Yön {flat.flat_direction}",
|
||||
)
|
||||
|
||||
if not all_temps or not floor_temps:
|
||||
print("❌ Hiç sıcaklık verisi alınamadı.\n")
|
||||
return None
|
||||
|
||||
min_temp = min(all_temps)
|
||||
max_temp = max(all_temps)
|
||||
avg_temp = sum(all_temps) / len(all_temps)
|
||||
|
||||
min_floor = min(floor_temps.keys())
|
||||
max_floor = max(floor_temps.keys())
|
||||
|
||||
avg_min_floor = sum(floor_temps[min_floor]) / max(len(floor_temps[min_floor]), 1)
|
||||
avg_max_floor = sum(floor_temps[max_floor]) / max(len(floor_temps[max_floor]), 1)
|
||||
delta_t = abs(avg_max_floor - avg_min_floor)
|
||||
|
||||
print(
|
||||
" Min: {:.2f}°C | Max: {:.2f}°C | Avg: {:.2f}°C | Count: {}".format(
|
||||
min_temp, max_temp, avg_temp, len(all_temps)
|
||||
)
|
||||
)
|
||||
print(f" En Alt Kat ({min_floor}): {avg_min_floor:.2f}°C")
|
||||
print(f" En Üst Kat ({max_floor}): {avg_max_floor:.2f}°C")
|
||||
print(f" 🔺 Delta Isı (Üst - Alt): {delta_t:.2f}°C")
|
||||
|
||||
if self.logtime:
|
||||
self.logger.insert_event(
|
||||
source="Sensor:min",
|
||||
event_type="temperature",
|
||||
value=min_temp,
|
||||
unit="°C",
|
||||
timestamp=now,
|
||||
extra="",
|
||||
)
|
||||
self.logger.insert_event(
|
||||
source="Sensor:max",
|
||||
event_type="temperature",
|
||||
value=max_temp,
|
||||
unit="°C",
|
||||
timestamp=now,
|
||||
extra="",
|
||||
)
|
||||
self.logger.insert_event(
|
||||
source="Sensor:avg",
|
||||
event_type="temperature",
|
||||
value=avg_temp,
|
||||
unit="°C",
|
||||
timestamp=now,
|
||||
extra="",
|
||||
)
|
||||
|
||||
return {
|
||||
"min": min_temp,
|
||||
"max": max_temp,
|
||||
"avg": avg_temp,
|
||||
"count": len(all_temps),
|
||||
"min_floor": min_floor,
|
||||
"max_floor": max_floor,
|
||||
"avg_min_floor": avg_min_floor,
|
||||
"avg_max_floor": avg_max_floor,
|
||||
"delta_t": delta_t,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Yardımcı
|
||||
# ------------------------------------------------------------------
|
||||
def print_config(self) -> None:
|
||||
if not self.flats:
|
||||
print(f"📦 {self.bina_adi} için tanımlı daire yok.")
|
||||
return
|
||||
|
||||
print(f"\n📦 {self.bina_adi} içeriği:")
|
||||
for flat_no, flat in self.flats.items():
|
||||
temp = flat.sensor.read_temperature()
|
||||
durum = "✅ Bağlı" if temp is not None else "❌ Yok"
|
||||
print(
|
||||
" Daire {:<3} Oda {:<1} | Kat: {:<2} | Yön: {:<12} | Sensör: {:<18} | Durum: {} | AKTIF".format(
|
||||
flat.flat_no,
|
||||
flat.room_no,
|
||||
flat.floor,
|
||||
FlatDirection.get(flat.flat_direction, "Bilinmiyor"),
|
||||
flat.sensor.serial,
|
||||
durum,
|
||||
)
|
||||
)
|
||||
|
||||
def run_forever(self, sleep_s: int = 5) -> None:
|
||||
"""Eski test aracı gibi: sürekli bina istatistiği üretir."""
|
||||
while True:
|
||||
self.get_building_temperature_stats()
|
||||
time.sleep(sleep_s)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# CLI
|
||||
# ----------------------------------------------------------------------
|
||||
if __name__ == "__main__":
|
||||
bina = Building() # Config'ten her şeyi alır
|
||||
bina.run_forever()
|
||||
574
ebuild/core/building.py~
Normal file
574
ebuild/core/building.py~
Normal file
@@ -0,0 +1,574 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
__title__ = "building"
|
||||
__author__ = 'Mehmet Karatay & "Saraswati" (ChatGPT)'
|
||||
__purpose__ = "Bina / daire / sensör ve brülör topolojisi sürücüsü"
|
||||
__version__ = "0.4.3"
|
||||
__date__ = "2025-11-22"
|
||||
|
||||
"""
|
||||
ebuild/core/building.py
|
||||
|
||||
Revision : 2025-11-22
|
||||
Authors : Mehmet Karatay & "Saraswati" (ChatGPT)
|
||||
|
||||
Amaç
|
||||
-----
|
||||
- Statik konfigürasyondan (config_statics) bina, geo ve sensör topolojisini yükler.
|
||||
- FLAT_AREA içindeki DS18B20 sensörlerini daire/oda bazında yönetir.
|
||||
- Bina ortalama sıcaklık istatistiklerini üretir.
|
||||
- Brülör grupları (BURNER_GROUPS) ve hat sensörleri (dış, çıkış, dönüş) için
|
||||
başlangıçta okunabilir bir özet verir.
|
||||
- SeasonController ile mevsim / güneş / tatil bilgisi sağlar.
|
||||
|
||||
Not
|
||||
---
|
||||
Bu modül, hem bağımsız test aracı olarak `python3 -m ebuild.core.building`
|
||||
şeklinde çalışabilir, hem de BurnerController gibi üst seviye kontrol
|
||||
modülleri tarafından kullanılabilir.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import datetime
|
||||
import statistics
|
||||
from collections import defaultdict
|
||||
from typing import Dict, List, Optional, Any
|
||||
|
||||
from .season import SeasonController
|
||||
from ..io.dbtext import DBText
|
||||
from .. import config_statics as cfg_s
|
||||
from .. import config_runtime as cfg_v
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Yardımcı sabitler
|
||||
# ------------------------------------------------------------------
|
||||
FlatDirection = {
|
||||
0: "Kuzey-Kuzey",
|
||||
1: "Kuzey-Doğu",
|
||||
2: "Kuzey-Batı",
|
||||
3: "Güney-Güney",
|
||||
4: "Güney-Doğu",
|
||||
5: "Güney-Batı",
|
||||
6: "Doğu-Doğu",
|
||||
7: "Batı-Batı",
|
||||
}
|
||||
|
||||
ValveType = {
|
||||
0: "Yok",
|
||||
1: "Dijital",
|
||||
2: "PWM",
|
||||
3: "Analog",
|
||||
4: "Enkoder",
|
||||
5: "Modbus",
|
||||
6: "CANbus",
|
||||
}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DS18B20 sensör sınıfı
|
||||
# ------------------------------------------------------------------
|
||||
class EDSensor:
|
||||
"""Tek bir DS18B20 sensörünü temsil eder."""
|
||||
|
||||
def __init__(self, serial: str, flat_no: int, flat_direction: int, floor: int, room_no: int = 0) -> None:
|
||||
self.serial = serial
|
||||
self.flat_no = flat_no
|
||||
self.floor = floor
|
||||
self.room_no = room_no
|
||||
self.flat_direction = flat_direction
|
||||
self.device_path = f"/sys/bus/w1/devices/{serial}/w1_slave"
|
||||
self.flat_check = 0
|
||||
self.is_connected = True
|
||||
|
||||
def read_temperature(self) -> Optional[float]:
|
||||
try:
|
||||
with open(self.device_path, "r") as f:
|
||||
lines = f.readlines()
|
||||
if lines[0].strip().endswith("YES"):
|
||||
pos = lines[1].find("t=")
|
||||
if pos != -1:
|
||||
raw = lines[1][pos + 2 :]
|
||||
t_c = float(raw) / 1000.0
|
||||
self.is_connected = True
|
||||
return t_c
|
||||
except Exception:
|
||||
self.flat_check += 1
|
||||
self.is_connected = False
|
||||
return None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Daire nesnesi
|
||||
# ------------------------------------------------------------------
|
||||
class Flat:
|
||||
"""Daire nesnesi: yön, kat ve sensör ilişkisi."""
|
||||
|
||||
def __init__(self, flat_no: int, flat_direction: int, sensor: EDSensor, floor: int, room_no: int = 0) -> None:
|
||||
self.flat_no = flat_no
|
||||
self.flat_direction = flat_direction
|
||||
self.sensor = sensor
|
||||
self.floor = floor
|
||||
self.room_no = room_no
|
||||
self.last_heat: Optional[float] = None
|
||||
self.last_read_time: Optional[datetime.datetime] = None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Bina sınıfı
|
||||
# ------------------------------------------------------------------
|
||||
class Building:
|
||||
"""
|
||||
Bina seviyesi sürücü.
|
||||
|
||||
config_statics üzerinden:
|
||||
|
||||
BUILDING_NAME
|
||||
BUILDING_LOCATION
|
||||
BUILDING_LABEL
|
||||
|
||||
GEO_CITY
|
||||
GEO_COUNTRY
|
||||
GEO_TZ
|
||||
GEO_LAT
|
||||
GEO_LON
|
||||
|
||||
FLAT_AREA
|
||||
BURNER_GROUPS
|
||||
OUTSIDE_SENSOR_ID / NAME
|
||||
BURNER_OUT_SENSOR_ID / NAME
|
||||
RETURN_LINE_SENSOR_IDS / NAME_MAP
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: Optional[str] = None,
|
||||
location: Optional[str] = None,
|
||||
bina_adi: Optional[str] = None,
|
||||
logtime: bool = False,
|
||||
logger: Optional[DBText] = None,
|
||||
) -> None:
|
||||
# Bina bilgileri config_statics'ten
|
||||
self.name = name or getattr(cfg_s, "BUILDING_NAME", "Bina")
|
||||
self.location = location or getattr(cfg_s, "BUILDING_LOCATION", "Ankara")
|
||||
self.bina_adi = bina_adi or getattr(cfg_s, "BUILDING_LABEL", "A_Binasi")
|
||||
|
||||
# Coğrafi bilgiler
|
||||
self.geo_city = getattr(cfg_s, "GEO_CITY", "Ankara")
|
||||
self.geo_country = getattr(cfg_s, "GEO_COUNTRY", "Turkey")
|
||||
self.geo_tz = getattr(cfg_s, "GEO_TZ", "Europe/Istanbul")
|
||||
self.geo_lat = float(getattr(cfg_s, "GEO_LAT", 39.92077))
|
||||
self.geo_lon = float(getattr(cfg_s, "GEO_LON", 32.85411))
|
||||
|
||||
# Logger
|
||||
self.logger = logger or DBText(
|
||||
filename=getattr(cfg_s, "BUILDING_LOG_FILE", "ebina_log.sql"),
|
||||
table=getattr(cfg_s, "BUILDING_LOG_TABLE", "ebrulor_log"),
|
||||
app="ESYSTEM",
|
||||
)
|
||||
|
||||
# Çalışma aralığı: 3 dakika (eski koddaki gibi)
|
||||
interval_min = getattr(cfg_v, "BUILDING_SAMPLE_MINUTES", 3)
|
||||
self.interval = datetime.timedelta(seconds=60 * interval_min)
|
||||
self.next_run = datetime.datetime.now()
|
||||
|
||||
self.flats: Dict[int, Flat] = {}
|
||||
self.last_max_floor: Optional[int] = None
|
||||
self.logtime = logtime
|
||||
|
||||
# Season / tatil bilgisi
|
||||
try:
|
||||
self.season = SeasonController.from_now()
|
||||
except Exception as e:
|
||||
print(f"⚠️ SeasonController.from_now() hata: {e}")
|
||||
self.season = None
|
||||
|
||||
# Daireleri FLAT_AREA'dan oluştur
|
||||
self._load_statics_from_flat_area()
|
||||
|
||||
# Bina genel istatistiği (ilk deneme)
|
||||
try:
|
||||
self.get_building_temperature_stats()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Konsol özet
|
||||
self._print_header()
|
||||
self._print_sun_and_season()
|
||||
self._print_burner_topology()
|
||||
self._print_line_sensors()
|
||||
self.print_config()
|
||||
|
||||
def get_hat_sensor_by_id(self, sensor_id: str):
|
||||
"""
|
||||
Verilen DS18B20 ID'sine göre hat sensör nesnesini döndür.
|
||||
Yoksa None döndür.
|
||||
"""
|
||||
return self.hat_sensors.get(sensor_id) # yapıya göre uyarlarsın
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Statik konfigürasyondan daire/sensör yükleme
|
||||
# ------------------------------------------------------------------
|
||||
def _load_statics_from_flat_area(self) -> None:
|
||||
"""config_statics.FLAT_AREA içinden daire/sensörleri yükler."""
|
||||
self.flats = {}
|
||||
flat_area = getattr(cfg_s, "FLAT_AREA", [])
|
||||
for item in flat_area:
|
||||
serial = item.get("serial")
|
||||
flat_no = int(item.get("flat_no", 0))
|
||||
room_no = int(item.get("room_no", 0))
|
||||
floor = int(item.get("floor", 0))
|
||||
direction = int(item.get("direction", 0))
|
||||
if not serial:
|
||||
continue
|
||||
sensor = EDSensor(serial, flat_no, direction, floor, room_no=room_no)
|
||||
# bağlantı testi
|
||||
sensor.read_temperature()
|
||||
flat = Flat(flat_no, direction, sensor, floor, room_no=room_no)
|
||||
self.flats[flat_no] = flat
|
||||
|
||||
print("✅ Building: statics yüklendi")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Başlangıç çıktıları
|
||||
# ------------------------------------------------------------------
|
||||
def _print_header(self) -> None:
|
||||
print(f"🏢 Bina adı : {self.bina_adi}")
|
||||
print(f"📍 Konum : {self.name} / {self.location}")
|
||||
print(
|
||||
f"🌍 Geo : {self.geo_city}, {self.geo_country} "
|
||||
f"({self.geo_lat}, {self.geo_lon}) tz={self.geo_tz}"
|
||||
)
|
||||
print(f"🏠 Daire/oda sayısı: {len(self.flats)}\n")
|
||||
|
||||
def _print_sun_and_season(self) -> None:
|
||||
"""SeasonController üzerinden sunrise / sunset ve mevsim / tatil bilgisini yazar."""
|
||||
if not self.season or not getattr(self.season, "info", None):
|
||||
print("☀️ Sunrise/Sunset : --:-- / --:--")
|
||||
print("🧭 Mevsim / Tatil : (bilgi yok)\n")
|
||||
return
|
||||
|
||||
info = self.season.info
|
||||
sunrise = info.sunrise.strftime("%H:%M") if info.sunrise else "--:--"
|
||||
sunset = info.sunset.strftime("%H:%M") if info.sunset else "--:--"
|
||||
|
||||
print(f"☀️ Sunrise/Sunset : {sunrise} / {sunset}")
|
||||
if info.season_start and info.season_end:
|
||||
print(
|
||||
f"🧭 Mevsim / Tatil : {info.season} "
|
||||
f"({info.season_start} → {info.season_end}), "
|
||||
f"Tatil={info.is_holiday} {info.holiday_label}"
|
||||
)
|
||||
else:
|
||||
print(
|
||||
f"🧭 Mevsim / Tatil : {info.season}, "
|
||||
f"Tatil={info.is_holiday} {info.holiday_label}"
|
||||
)
|
||||
print()
|
||||
|
||||
def _print_burner_topology(self) -> None:
|
||||
"""config_statics.BURNER_GROUPS bilgisini okunaklı yazar."""
|
||||
groups = getattr(cfg_s, "BURNER_GROUPS", {})
|
||||
if not groups:
|
||||
print("🔥 Brülör topolojisi: tanımlı değil (BURNER_GROUPS boş).\n")
|
||||
return
|
||||
|
||||
print("🔥 Brülör Topolojisi (config_statics.BURNER_GROUPS):")
|
||||
for bid, info in groups.items():
|
||||
name = info.get("name", f"Burner{bid}")
|
||||
loc = info.get("location", "-")
|
||||
ign = info.get("igniter_pin", None)
|
||||
print(f" #{bid}: {name} @ {loc}")
|
||||
print(f" Igniter pin : {ign}")
|
||||
circ = info.get("circulation", {})
|
||||
if not circ:
|
||||
print(" Pompalar : (tanımlı değil)")
|
||||
else:
|
||||
print(" Pompalar :")
|
||||
for cname, cinfo in circ.items():
|
||||
pin = cinfo.get("pin")
|
||||
default = cinfo.get("default", 0)
|
||||
print(f" - {cname:<7} → pin={pin:<3} default={default}")
|
||||
print()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Hat sensörleri: dış / çıkış / dönüş
|
||||
# ------------------------------------------------------------------
|
||||
def _read_ds18b20_raw(self, serial: str) -> Optional[float]:
|
||||
"""Seri numarası verilen DS18B20'den tek seferlik okuma."""
|
||||
path = f"/sys/bus/w1/devices/{serial}/w1_slave"
|
||||
try:
|
||||
with open(path, "r") as f:
|
||||
lines = f.readlines()
|
||||
if not lines or not lines[0].strip().endswith("YES"):
|
||||
return None
|
||||
pos = lines[1].find("t=")
|
||||
if pos == -1:
|
||||
return None
|
||||
raw = lines[1][pos + 2 :]
|
||||
return float(raw) / 1000.0
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _print_line_sensors(self) -> None:
|
||||
"""
|
||||
Dış ısı, kazan çıkış ısısı ve dönüş hat sensörlerini (varsa) ekrana yazar.
|
||||
config_statics içindeki:
|
||||
OUTSIDE_SENSOR_ID / OUTSIDE_SENSOR_NAME
|
||||
BURNER_OUT_SENSOR_ID / BURNER_OUT_SENSOR_NAME
|
||||
RETURN_LINE_SENSOR_IDS
|
||||
RETURN_LINE_SENSOR_NAME_MAP
|
||||
kullanılır.
|
||||
"""
|
||||
outside_id = getattr(cfg_s, "OUTSIDE_SENSOR_ID", None)
|
||||
outside_nm = getattr(cfg_s, "OUTSIDE_SENSOR_NAME", "Dış Isı 1")
|
||||
burner_id = getattr(cfg_s, "BURNER_OUT_SENSOR_ID", None)
|
||||
burner_nm = getattr(cfg_s, "BURNER_OUT_SENSOR_NAME", "Çıkış Isı 1")
|
||||
return_ids = list(getattr(cfg_s, "RETURN_LINE_SENSOR_IDS", []))
|
||||
name_map = dict(getattr(cfg_s, "RETURN_LINE_SENSOR_NAME_MAP", {}))
|
||||
|
||||
if not (outside_id or burner_id or return_ids):
|
||||
# Hiç tanım yoksa sessizce geç
|
||||
return
|
||||
|
||||
print("🌡️ Hat sensörleri:")
|
||||
# Dış ısı
|
||||
if outside_id:
|
||||
t = self._read_ds18b20_raw(outside_id)
|
||||
if t is None:
|
||||
print(f" - {outside_nm:<15}: ❌ Okunamadı ({outside_id})")
|
||||
else:
|
||||
print(f" - {outside_nm:<15}: {t:5.2f}°C ({outside_id})")
|
||||
|
||||
# Kazan çıkış
|
||||
if burner_id:
|
||||
t = self._read_ds18b20_raw(burner_id)
|
||||
if t is None:
|
||||
print(f" - {burner_nm:<15}: ❌ Okunamadı ({burner_id})")
|
||||
else:
|
||||
print(f" - {burner_nm:<15}: {t:5.2f}°C ({burner_id})")
|
||||
|
||||
# Dönüş hatları
|
||||
for sid in return_ids:
|
||||
nm = name_map.get(sid, sid)
|
||||
t = self._read_ds18b20_raw(sid)
|
||||
if t is None:
|
||||
print(f" - Dönüş {nm:<10}: ❌ Okunamadı ({sid})")
|
||||
else:
|
||||
print(f" - Dönüş {nm:<10}: {t:5.2f}°C ({sid})")
|
||||
print()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# İstatistik fonksiyonları
|
||||
# ------------------------------------------------------------------
|
||||
def _summarize(self, values: List[float]) -> dict:
|
||||
if not values:
|
||||
return {"min": None, "max": None, "avg": None, "count": 0}
|
||||
return {
|
||||
"min": min(values),
|
||||
"max": max(values),
|
||||
"avg": round(statistics.mean(values), 2),
|
||||
"count": len(values),
|
||||
}
|
||||
|
||||
def get_temperature_stats(self) -> str:
|
||||
stats = {
|
||||
"floors": defaultdict(list),
|
||||
"directions": defaultdict(list),
|
||||
"top_floor": [],
|
||||
"building": [],
|
||||
}
|
||||
|
||||
max_floor = self.last_max_floor
|
||||
|
||||
for flat in self.flats.values():
|
||||
temp = flat.sensor.read_temperature()
|
||||
if temp is not None:
|
||||
flat.last_heat = temp
|
||||
flat.last_read_time = datetime.datetime.now()
|
||||
stats["floors"][flat.floor].append(temp)
|
||||
stats["directions"][flat.flat_direction].append(temp)
|
||||
stats["building"].append(temp)
|
||||
if max_floor is not None and flat.floor == max_floor:
|
||||
stats["top_floor"].append(temp)
|
||||
|
||||
result = {
|
||||
"floors": {f: self._summarize(v) for f, v in stats["floors"].items()},
|
||||
"directions": {
|
||||
d: self._summarize(v) for d, v in stats["directions"].items()
|
||||
},
|
||||
"top_floor": self._summarize(stats["top_floor"]),
|
||||
"building": self._summarize(stats["building"]),
|
||||
}
|
||||
return json.dumps(result, indent=4, ensure_ascii=False)
|
||||
|
||||
def get_last_heat_report(self) -> str:
|
||||
report = []
|
||||
for flat in self.flats.values():
|
||||
temp = flat.sensor.read_temperature()
|
||||
now = datetime.datetime.now()
|
||||
if temp is not None:
|
||||
flat.last_heat = temp
|
||||
flat.last_read_time = now
|
||||
report.append(
|
||||
{
|
||||
"flat_no": flat.flat_no,
|
||||
"room_no": flat.room_no,
|
||||
"direction": flat.flat_direction,
|
||||
"floor": flat.floor,
|
||||
"serial": flat.sensor.serial,
|
||||
"last_heat": flat.last_heat,
|
||||
"last_read_time": flat.last_read_time.strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
if flat.last_read_time
|
||||
else None,
|
||||
"is_connected": flat.sensor.is_connected,
|
||||
"flat_check": flat.sensor.flat_check,
|
||||
}
|
||||
)
|
||||
return json.dumps(report, indent=4, ensure_ascii=False)
|
||||
|
||||
def get_building_temperature_stats(self) -> Optional[dict]:
|
||||
print("\n🌡️ Bina Genel Isı Bilgisi:")
|
||||
all_temps: List[float] = []
|
||||
floor_temps: Dict[int, List[float]] = {}
|
||||
now = datetime.datetime.now()
|
||||
|
||||
# Log zamanı mı?
|
||||
if now >= self.next_run:
|
||||
self.logtime = True
|
||||
self.next_run = now + self.interval
|
||||
else:
|
||||
self.logtime = False
|
||||
|
||||
self.last_max_floor = None
|
||||
|
||||
for flat in self.flats.values():
|
||||
temp = flat.sensor.read_temperature()
|
||||
floor = flat.floor
|
||||
|
||||
if temp is not None:
|
||||
flat.last_heat = temp
|
||||
flat.last_read_time = now
|
||||
if self.last_max_floor is None or floor > self.last_max_floor:
|
||||
self.last_max_floor = floor
|
||||
else:
|
||||
flat.last_heat = None
|
||||
flat.last_read_time = None
|
||||
|
||||
floor_temps.setdefault(floor, [])
|
||||
if temp is not None:
|
||||
all_temps.append(temp)
|
||||
floor_temps[floor].append(temp)
|
||||
if self.logtime:
|
||||
self.logger.insert_event(
|
||||
source=f"Sensor:{flat.sensor.serial}",
|
||||
event_type="temperature",
|
||||
value=temp,
|
||||
unit="°C",
|
||||
timestamp=now,
|
||||
extra=f"Daire {flat.flat_no} Oda {flat.room_no} Kat {flat.floor}, Yön {flat.flat_direction}",
|
||||
)
|
||||
|
||||
if not all_temps or not floor_temps:
|
||||
print("❌ Hiç sıcaklık verisi alınamadı.\n")
|
||||
return None
|
||||
|
||||
min_temp = min(all_temps)
|
||||
max_temp = max(all_temps)
|
||||
avg_temp = sum(all_temps) / len(all_temps)
|
||||
|
||||
min_floor = min(floor_temps.keys())
|
||||
max_floor = max(floor_temps.keys())
|
||||
|
||||
avg_min_floor = sum(floor_temps[min_floor]) / max(len(floor_temps[min_floor]), 1)
|
||||
avg_max_floor = sum(floor_temps[max_floor]) / max(len(floor_temps[max_floor]), 1)
|
||||
delta_t = abs(avg_max_floor - avg_min_floor)
|
||||
|
||||
print(
|
||||
" Min: {:.2f}°C | Max: {:.2f}°C | Avg: {:.2f}°C | Count: {}".format(
|
||||
min_temp, max_temp, avg_temp, len(all_temps)
|
||||
)
|
||||
)
|
||||
print(f" En Alt Kat ({min_floor}): {avg_min_floor:.2f}°C")
|
||||
print(f" En Üst Kat ({max_floor}): {avg_max_floor:.2f}°C")
|
||||
print(f" 🔺 Delta Isı (Üst - Alt): {delta_t:.2f}°C")
|
||||
|
||||
if self.logtime:
|
||||
self.logger.insert_event(
|
||||
source="Sensor:min",
|
||||
event_type="temperature",
|
||||
value=min_temp,
|
||||
unit="°C",
|
||||
timestamp=now,
|
||||
extra="",
|
||||
)
|
||||
self.logger.insert_event(
|
||||
source="Sensor:max",
|
||||
event_type="temperature",
|
||||
value=max_temp,
|
||||
unit="°C",
|
||||
timestamp=now,
|
||||
extra="",
|
||||
)
|
||||
self.logger.insert_event(
|
||||
source="Sensor:avg",
|
||||
event_type="temperature",
|
||||
value=avg_temp,
|
||||
unit="°C",
|
||||
timestamp=now,
|
||||
extra="",
|
||||
)
|
||||
|
||||
return {
|
||||
"min": min_temp,
|
||||
"max": max_temp,
|
||||
"avg": avg_temp,
|
||||
"count": len(all_temps),
|
||||
"min_floor": min_floor,
|
||||
"max_floor": max_floor,
|
||||
"avg_min_floor": avg_min_floor,
|
||||
"avg_max_floor": avg_max_floor,
|
||||
"delta_t": delta_t,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Yardımcı
|
||||
# ------------------------------------------------------------------
|
||||
def print_config(self) -> None:
|
||||
if not self.flats:
|
||||
print(f"📦 {self.bina_adi} için tanımlı daire yok.")
|
||||
return
|
||||
|
||||
print(f"\n📦 {self.bina_adi} içeriği:")
|
||||
for flat_no, flat in self.flats.items():
|
||||
temp = flat.sensor.read_temperature()
|
||||
durum = "✅ Bağlı" if temp is not None else "❌ Yok"
|
||||
print(
|
||||
" Daire {:<3} Oda {:<1} | Kat: {:<2} | Yön: {:<12} | Sensör: {:<18} | Durum: {} | AKTIF".format(
|
||||
flat.flat_no,
|
||||
flat.room_no,
|
||||
flat.floor,
|
||||
FlatDirection.get(flat.flat_direction, "Bilinmiyor"),
|
||||
flat.sensor.serial,
|
||||
durum,
|
||||
)
|
||||
)
|
||||
|
||||
def run_forever(self, sleep_s: int = 5) -> None:
|
||||
"""Eski test aracı gibi: sürekli bina istatistiği üretir."""
|
||||
while True:
|
||||
self.get_building_temperature_stats()
|
||||
time.sleep(sleep_s)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# CLI
|
||||
# ----------------------------------------------------------------------
|
||||
if __name__ == "__main__":
|
||||
bina = Building() # Config'ten her şeyi alır
|
||||
bina.run_forever()
|
||||
2
ebuild/core/devices.py
Normal file
2
ebuild/core/devices.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Device / Sensor / Actuator soyutlamaları için iskelet."""
|
||||
306
ebuild/core/environment.py
Normal file
306
ebuild/core/environment.py
Normal file
@@ -0,0 +1,306 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
__title__ = "environment"
|
||||
__author__ = 'Mehmet Karatay & "Saraswati" (ChatGPT)'
|
||||
__purpose__ = "Bina dış ortam sıcaklığı (DS18B20) ve analog sensörler için ortam merkezi"
|
||||
__version__ = "0.3.0"
|
||||
__date__ = "2025-11-23"
|
||||
|
||||
"""
|
||||
ebuild/core/environment.py
|
||||
|
||||
Amaç
|
||||
-----
|
||||
- Bina dış ortam sıcaklığı (DS18B20) ve MCP3008 üzerinden okunan analog sensörleri
|
||||
tek bir merkezde toplamak.
|
||||
- Üst seviye modüller (ör: BurnerController, sulama sistemi, alarm sistemi) bu
|
||||
sınıf üzerinden dış ortam ve analog bilgilerine erişir.
|
||||
|
||||
Notlar
|
||||
------
|
||||
- Bu katman donanım erişimini kapsüller:
|
||||
* io/ds18b20.py
|
||||
* io/adc_mcp3008.py
|
||||
* core/analog_sensors.py
|
||||
"""
|
||||
|
||||
from typing import Dict, Optional, Any
|
||||
import datetime
|
||||
|
||||
# Donanım bağımlı modüller
|
||||
try:
|
||||
from ..io.ds18b20 import DS18B20Sensor
|
||||
except ImportError:
|
||||
print("from ..io.ds18b20 import DS18B20Sensor NOT IMPORTED")
|
||||
DS18B20Sensor = None # type: ignore
|
||||
|
||||
try:
|
||||
from ..io.adc_mcp3008 import MCP3008ADC
|
||||
except ImportError:
|
||||
print("from ..io.adc_mcp3008 import MCP3008ADC NOT IMPORTED")
|
||||
MCP3008ADC = None # type: ignore
|
||||
|
||||
try:
|
||||
from .analog_sensors import AnalogSensorsHub
|
||||
except ImportError:
|
||||
print("from .analog_sensors import AnalogSensorsHub NOT IMPORTED")
|
||||
AnalogSensorsHub = None # type: ignore
|
||||
|
||||
# Config HER ZAMAN ayrı import edilmeli, donanıma bağlı değil
|
||||
try:
|
||||
from .. import config_statics as cfgs
|
||||
except ImportError as e:
|
||||
print("ENV: config_statics import edilemedi:", e)
|
||||
cfgs = None # type: ignore
|
||||
|
||||
|
||||
class BuildingEnvironment:
|
||||
"""
|
||||
Bina çevre sensörlerini yöneten merkez.
|
||||
|
||||
Özellikler:
|
||||
-----------
|
||||
- outside : DS18B20 dış ortam sensörü (OUTSIDE_SENSOR_ID üzerinden)
|
||||
- adc : MCP3008ADC örneği
|
||||
- analog : AnalogSensorsHub (basınç, gaz, yağmur, LDR)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str = "BuildingEnvironment",
|
||||
outside_sensor_id: Optional[str] = None,
|
||||
use_outside_ds18: bool = True,
|
||||
use_adc: bool = True,
|
||||
) -> None:
|
||||
#print("BuildingEnvironment Init:", name, outside_sensor_id, use_outside_ds18, use_adc)
|
||||
self.name = name
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Dış ortam sıcaklığı (DS18B20)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# 1) Önce parametreden gelen ID; yoksa config_statics.OUTSIDE_SENSOR_ID
|
||||
if not outside_sensor_id and cfgs is not None:
|
||||
try:
|
||||
outside_sensor_id = getattr(cfgs, "OUTSIDE_SENSOR_ID", None)
|
||||
except Exception as e:
|
||||
print("BuildingEnvironment: outside sensör ID okunamadı:", e)
|
||||
outside_sensor_id = None
|
||||
|
||||
#print( "BuildingEnvironment get outside_sensor_id:", outside_sensor_id, "use_outside_ds18", use_outside_ds18, "use_adc", use_adc )
|
||||
|
||||
# 2) self.outside_id ve self.outside MUTLAKA burada tanımlanmalı
|
||||
self.outside_id: str = outside_sensor_id or ""
|
||||
self.outside: Optional[DS18B20Sensor] = None # type: ignore
|
||||
|
||||
#print("DS18B20Sensor", type(DS18B20Sensor), DS18B20Sensor)
|
||||
|
||||
# 3) ID makulse DS18 sensörünü yarat
|
||||
if use_outside_ds18 and DS18B20Sensor is not None and len(self.outside_id) > 5:
|
||||
try:
|
||||
#print("BuildingEnvironment: DS18B20Sensor yaratılıyor, id =", self.outside_id)
|
||||
self.outside = DS18B20Sensor(serial=self.outside_id, name="OutsideTemp")
|
||||
#print("BuildingEnvironment: self.outside:", self.outside)
|
||||
except Exception as e:
|
||||
print("BuildingEnvironment: outside sensör oluşturulamadı:", e)
|
||||
self.outside = None
|
||||
else:
|
||||
print(
|
||||
"BuildingEnvironment: dış sensör yok veya devre dışı:",
|
||||
"use_outside_ds18 =", use_outside_ds18,
|
||||
"DS18B20Sensor is None =", DS18B20Sensor is None,
|
||||
"outside_id =", repr(self.outside_id),
|
||||
)
|
||||
|
||||
#print("BuildingEnvironment 5:", name, self.outside, outside_sensor_id, use_outside_ds18, use_adc)
|
||||
# Dış ısı cache'i (kontrol katmanları için)
|
||||
self._last_outside_temp_c: Optional[float] = None
|
||||
self._last_outside_read_ts: Optional[datetime.datetime] = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# MCP3008 ve analog sensör hub
|
||||
# ------------------------------------------------------------------
|
||||
self.adc: Optional[MCP3008ADC] = None # type: ignore
|
||||
self.analog: Optional[AnalogSensorsHub] = None # type: ignore
|
||||
|
||||
if use_adc and MCP3008ADC is not None and AnalogSensorsHub is not None:
|
||||
try:
|
||||
self.adc = MCP3008ADC()
|
||||
self.analog = AnalogSensorsHub(self.adc)
|
||||
except Exception:
|
||||
self.adc = None
|
||||
self.analog = None
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Okuma fonksiyonları
|
||||
# ----------------------------------------------------------------------
|
||||
def read_outside_temp(self) -> Dict[str, Optional[float]]:
|
||||
"""
|
||||
DS18B20 dış ortam sensöründen sıcaklık okur.
|
||||
|
||||
Dönüş:
|
||||
{"outside_temp_c": 23.4, "read_ts": datetime} # veya None
|
||||
"""
|
||||
temp: Optional[float] = None
|
||||
now = datetime.datetime.now()
|
||||
|
||||
if self.outside is not None:
|
||||
try:
|
||||
temp = self.outside.read_temperature()
|
||||
except Exception:
|
||||
temp = None
|
||||
|
||||
if temp is not None:
|
||||
self._last_outside_temp_c = temp
|
||||
self._last_outside_read_ts = now
|
||||
|
||||
return {
|
||||
"outside_temp_c": temp,
|
||||
"read_ts": now,
|
||||
}
|
||||
|
||||
def get_outside_temp_cached(self, max_age_sec: int = 60) -> Optional[float]:
|
||||
"""
|
||||
Dış ortam sıcaklığını cache üzerinden döndürür; gerekirse sensörden günceller.
|
||||
|
||||
- Eğer hiç okunmamışsa → sensörden okur.
|
||||
- Eğer son okuma max_age_sec saniyeden eskiyse → yeniden okur.
|
||||
"""
|
||||
now = datetime.datetime.now()
|
||||
|
||||
if (
|
||||
self._last_outside_read_ts is None
|
||||
or (now - self._last_outside_read_ts).total_seconds() > max_age_sec
|
||||
):
|
||||
snap = self.read_outside_temp()
|
||||
temp = snap.get("outside_temp_c")
|
||||
if temp is not None:
|
||||
self._last_outside_temp_c = temp
|
||||
self._last_outside_read_ts = now
|
||||
|
||||
return self._last_outside_temp_c
|
||||
|
||||
def read_analog_all(self) -> Dict[str, Any]:
|
||||
"""
|
||||
AnalogSensorsHub üzerinden tüm analog kanalları okur.
|
||||
|
||||
Dönüş sözlüğü:
|
||||
{
|
||||
"pressure_raw": ...,
|
||||
"pressure_state": ...,
|
||||
"gas_raw": ...,
|
||||
"gas_state": ...,
|
||||
"gas_latched_alarm": ...,
|
||||
"rain_raw": ...,
|
||||
"rain_state": ...,
|
||||
"ldr_raw": ...,
|
||||
"ldr_state": ...,
|
||||
}
|
||||
"""
|
||||
data: Dict[str, Any] = {
|
||||
"pressure_raw": None,
|
||||
"pressure_state": None,
|
||||
"gas_raw": None,
|
||||
"gas_state": None,
|
||||
"gas_latched_alarm": None,
|
||||
"rain_raw": None,
|
||||
"rain_state": None,
|
||||
"ldr_raw": None,
|
||||
"ldr_state": None,
|
||||
}
|
||||
|
||||
if self.analog is None:
|
||||
return data
|
||||
|
||||
values = self.analog.update_all()
|
||||
|
||||
# Basınç
|
||||
data["pressure_raw"] = values.get("pressure")
|
||||
data["pressure_state"] = getattr(self.analog.pressure, "state", None)
|
||||
|
||||
# Gaz
|
||||
data["gas_raw"] = values.get("gas")
|
||||
data["gas_state"] = getattr(self.analog.gas, "state", None)
|
||||
data["gas_latched_alarm"] = getattr(self.analog.gas, "latched_alarm", None)
|
||||
|
||||
# Yağmur
|
||||
data["rain_raw"] = values.get("rain")
|
||||
data["rain_state"] = getattr(self.analog.rain, "state", None)
|
||||
|
||||
# LDR
|
||||
data["ldr_raw"] = values.get("ldr")
|
||||
data["ldr_state"] = getattr(self.analog.ldr, "state", None)
|
||||
|
||||
return data
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Yüksek seviye snapshot
|
||||
# ----------------------------------------------------------------------
|
||||
def get_snapshot(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Dış sıcaklık (DS18B20) + tüm analog sensörler için tek sözlük döndürür.
|
||||
|
||||
Örnek:
|
||||
{
|
||||
"outside_temp_c": 14.3,
|
||||
"pressure_raw": 512,
|
||||
"pressure_state": "SAFE",
|
||||
"gas_raw": 80,
|
||||
"gas_state": "SAFE",
|
||||
"gas_latched_alarm": False,
|
||||
"rain_raw": 100,
|
||||
"rain_state": "DRY",
|
||||
"ldr_raw": 900,
|
||||
"ldr_state": "BRIGHT",
|
||||
}
|
||||
"""
|
||||
snap: Dict[str, Any] = {}
|
||||
snap.update(self.read_outside_temp())
|
||||
snap.update(self.read_analog_all())
|
||||
return snap
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Güvenlik yardımcıları
|
||||
# ----------------------------------------------------------------------
|
||||
def should_shutdown_system(self) -> bool:
|
||||
"""
|
||||
Analog sensör verilerine göre sistem acil kapatılmalı mı?
|
||||
|
||||
Örn:
|
||||
- Gaz sızıntısı
|
||||
- Aşırı basınç
|
||||
vs.
|
||||
|
||||
Şu an sadece gaz latched_alarm'a bakıyor.
|
||||
"""
|
||||
if self.analog is None:
|
||||
return False
|
||||
|
||||
if getattr(self.analog.gas, "latched_alarm", False):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Temel temsil
|
||||
# ----------------------------------------------------------------------
|
||||
def summary(self) -> str:
|
||||
"""
|
||||
Ortamın kısa özetini döndürür.
|
||||
"""
|
||||
parts = [f"env_name={self.name}"]
|
||||
parts.append(f"outside_id={self.outside_id!r}")
|
||||
parts.append(f"outside={'OK' if self.outside is not None else 'NONE'}")
|
||||
parts.append(f"adc={'OK' if self.adc is not None else 'NONE'}")
|
||||
parts.append(f"analog={'OK' if self.analog is not None else 'NONE'}")
|
||||
return "BuildingEnvironment(" + ", ".join(parts) + ")"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Basit demo
|
||||
env = BuildingEnvironment()
|
||||
print(env.summary())
|
||||
snap = env.get_snapshot()
|
||||
print("Snapshot:", snap)
|
||||
print("Gaz shutdown mu?:", env.should_shutdown_system())
|
||||
395
ebuild/core/season.py
Normal file
395
ebuild/core/season.py
Normal file
@@ -0,0 +1,395 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
__title__ = "season"
|
||||
__author__ = 'Mehmet Karatay & "Saraswati" (ChatGPT)'
|
||||
__purpose__ = "Güneş / tatil / mevsim + tasarruf sezonu bilgisini sağlayan yardımcı katman"
|
||||
__version__ = "0.3.1"
|
||||
__date__ = "2025-11-23"
|
||||
|
||||
"""
|
||||
ebuild/core/season.py
|
||||
|
||||
Amaç
|
||||
-----
|
||||
- SunHolidayInfo kullanarak:
|
||||
* gün doğumu / batımı / öğlen
|
||||
* resmi tatil bilgisi
|
||||
* mevsim ve sezon süresi
|
||||
* tasarruf sezonu (saving) başlangıç/bitiş
|
||||
bilgilerini üretir.
|
||||
|
||||
- Bu bilgiyi BurnerController ve legacy_syslog gibi modüllere
|
||||
SeasonController.info üzerinden sağlar.
|
||||
|
||||
Not:
|
||||
- SunHolidayInfo import edilemez veya hata verirse, basit bir
|
||||
fallback mevsim + tasarruf sezonu bilgisi üretir.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, time, date, timedelta
|
||||
from typing import Optional, Tuple, Any
|
||||
|
||||
from .. import config_statics as cfg_s
|
||||
|
||||
# SunHolidayInfo'yu paket içinden al; yoksa fallback'e düş
|
||||
try:
|
||||
from .sunholiday import SunHolidayInfo # type: ignore
|
||||
except Exception:
|
||||
SunHolidayInfo = None # type: ignore
|
||||
|
||||
# Tasarruf sezonu parametreleri için runtime config
|
||||
try:
|
||||
from .. import config_runtime as cfg_v # type: ignore
|
||||
except Exception:
|
||||
cfg_v = None # type: ignore
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Veri yapısı
|
||||
# ---------------------------------------------------------------------
|
||||
@dataclass
|
||||
class SeasonInfo:
|
||||
"""SeasonController tarafından sağlanan özet bilgi."""
|
||||
date: date
|
||||
|
||||
sunrise: Optional[time]
|
||||
sunset: Optional[time]
|
||||
noon: Optional[time]
|
||||
|
||||
is_holiday: bool
|
||||
holiday_label: str
|
||||
|
||||
season: str # "İlkbahar", "Yaz", "Sonbahar", "Kış", ...
|
||||
season_start: str # "YYYY-MM-DD"
|
||||
season_end: str # "YYYY-MM-DD"
|
||||
season_day: int # sezonun toplam gün sayısı
|
||||
season_passed: int # sezon içinde geçen gün
|
||||
season_remaining: int # sezon sonuna kalan gün
|
||||
|
||||
# Tasarruf sezonu (saving) bilgileri
|
||||
is_season: bool = False # BUGÜN tasarruf sezonu içinde miyiz?
|
||||
saving_start: Optional[date] = None # tasarruf dönemi başlangıç tarihi
|
||||
saving_stop: Optional[date] = None # tasarruf dönemi bitiş tarihi
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Yardımcı fonksiyonlar
|
||||
# ---------------------------------------------------------------------
|
||||
def _parse_time_str(s: Optional[str]) -> Optional[time]:
|
||||
if not s:
|
||||
return None
|
||||
for fmt in ("%H:%M:%S", "%H:%M"):
|
||||
try:
|
||||
return datetime.strptime(s, fmt).time()
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _parse_date_any(v: Any) -> Optional[date]:
|
||||
"""
|
||||
v: datetime / date / str / None → date veya None.
|
||||
"""
|
||||
if v is None:
|
||||
return None
|
||||
if isinstance(v, date) and not isinstance(v, datetime):
|
||||
return v
|
||||
if isinstance(v, datetime):
|
||||
return v.date()
|
||||
if isinstance(v, str):
|
||||
s = v.strip()
|
||||
# "2025-09-23T22:42:51.508546+03:00" => "2025-09-23"
|
||||
if "T" in s:
|
||||
s = s.split("T", 1)[0]
|
||||
# ISO parse dene
|
||||
try:
|
||||
return datetime.fromisoformat(s).date()
|
||||
except Exception:
|
||||
pass
|
||||
# Son çare saf "YYYY-MM-DD"
|
||||
try:
|
||||
return datetime.strptime(s, "%Y-%m-%d").date()
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _as_int(x: Any, default: int = 0) -> int:
|
||||
try:
|
||||
return int(x)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _get_saving_param_for_season(season_name: str) -> Optional[Any]:
|
||||
"""
|
||||
Sezona göre ilgili tasarruf parametresini getirir.
|
||||
|
||||
- İlkbahar → cfg_v.SEASON_SPRING_SAVE_DAYS
|
||||
- Sonbahar → cfg_v.SEASON_AUTUMN_SAVE_DAYS
|
||||
|
||||
Yoksa None döner.
|
||||
"""
|
||||
if cfg_v is None:
|
||||
return None
|
||||
|
||||
if season_name == "İlkbahar":
|
||||
return getattr(cfg_v, "SEASON_SPRING_SAVE_DAYS", None)
|
||||
if season_name == "Sonbahar":
|
||||
return getattr(cfg_v, "SEASON_AUTUMN_SAVE_DAYS", None)
|
||||
return None
|
||||
|
||||
def tarih_gun_islemi(tarih_str, gun_sayisi):
|
||||
try:
|
||||
tarih = datetime.strptime(tarih_str, "%Y-%m-%d")
|
||||
yeni_tarih = tarih + timedelta(days=gun_sayisi)
|
||||
return yeni_tarih.strftime("%Y-%m-%d")
|
||||
except ValueError:
|
||||
return "Hata: Tarih formatı yanlış olmalı! Örnek: '2025-06-30'"
|
||||
|
||||
|
||||
def _compute_saving_window(
|
||||
season_name: str,
|
||||
season_start: Optional[date],
|
||||
season_end: Optional[date],
|
||||
today: date,
|
||||
) -> Tuple[bool, Optional[date], Optional[date]]:
|
||||
"""
|
||||
Tasarruf sezonu penceresini hesaplar.
|
||||
|
||||
Buradaki tanım:
|
||||
- SEASON_SPRING_SAVE_DAYS -> Nisan ayı için gün aralığı (nisan)
|
||||
- SEASON_AUTUMN_SAVE_DAYS -> Eylül ayı için gün aralığı (eylül)
|
||||
|
||||
Örnek:
|
||||
SEASON_SPRING_SAVE_DAYS = (5, 20) -> 5-20 Nisan arası tasarruf
|
||||
SEASON_AUTUMN_SAVE_DAYS = (15, 30) -> 15-30 Eylül arası tasarruf
|
||||
|
||||
Mantık:
|
||||
- Eğer bugün ay < 6 (Haziran'dan önce) ise -> Nisan aralığını kullan
|
||||
- Eğer bugün ay >= 6 ise -> Eylül aralığını kullan
|
||||
|
||||
Dönüş:
|
||||
(is_season, saving_start, saving_stop)
|
||||
"""
|
||||
if season_start is None or season_end is None or cfg_v is None:
|
||||
return False, None, None
|
||||
|
||||
# Bugünün tarihi (sadece date, saat yok)
|
||||
today = date.today()
|
||||
year = today.year
|
||||
|
||||
# Config'den değerleri güvenli şekilde al (varsayılan değerle)
|
||||
SPRING_SAVE_DAYS = getattr(cfg_v, "SEASON_SPRING_SAVE_DAYS", 20) # örnek: 20
|
||||
AUTUMN_SAVE_DAYS = getattr(cfg_v, "SEASON_AUTUMN_SAVE_DAYS", 13) # örnek: 13
|
||||
|
||||
# İlkbahar mı yoksa Sonbahar mı dönemi aktif?
|
||||
if today.month <= 5: # Ocak-Mayıs arası → ilkbahar dönemi aktif
|
||||
# İlkbaharın son gününden geriye doğru
|
||||
saving_stop = date(year, 5, 31) # 31 Mayıs (dahil)
|
||||
saving_start = saving_stop - timedelta(days=SPRING_SAVE_DAYS - 1) # dahil olduğu için -1
|
||||
|
||||
else: # Haziran-Aralık arası → sonbahar dönemi aktif
|
||||
# Sonbaharın ilk gününden ileri doğru
|
||||
saving_start = date(year, 9, 1) # 1 Eylül (dahil)
|
||||
saving_stop = saving_start + timedelta(days=AUTUMN_SAVE_DAYS - 1) # dahil olduğu için -1
|
||||
|
||||
# Sezon içinde miyiz?
|
||||
is_season = saving_start <= today <= saving_stop
|
||||
return is_season, saving_start, saving_stop
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Ana controller
|
||||
# ---------------------------------------------------------------------
|
||||
class SeasonController:
|
||||
"""
|
||||
Tek sorumluluğu: o anki (veya verilen tarihteki) güneş / tatil /
|
||||
mevsim + tasarruf sezonu bilgisini üst katmanlara taşımak.
|
||||
"""
|
||||
|
||||
def __init__(self, info: SeasonInfo) -> None:
|
||||
self.info = info
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Factory metodları
|
||||
# ---------------------------------------------------------
|
||||
@classmethod
|
||||
def from_now(cls) -> "SeasonController":
|
||||
"""
|
||||
Sistem saatine göre SeasonController üretir.
|
||||
GEO_* bilgilerini config_statics'ten alır.
|
||||
"""
|
||||
now = datetime.now()
|
||||
return cls(cls._build_info(now))
|
||||
|
||||
@classmethod
|
||||
def from_datetime(cls, dt: datetime) -> "SeasonController":
|
||||
return cls(cls._build_info(dt))
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# İç mantık
|
||||
# ---------------------------------------------------------
|
||||
@classmethod
|
||||
def _build_info(cls, now: datetime) -> SeasonInfo:
|
||||
"""
|
||||
SunHolidayInfo varsa onu kullanır; yoksa basit bir fallback
|
||||
sezon + tasarruf sezonu bilgisi üretir.
|
||||
"""
|
||||
geo_city = getattr(cfg_s, "GEO_CITY", "Ankara")
|
||||
geo_country = getattr(cfg_s, "GEO_COUNTRY", "Turkey")
|
||||
geo_tz = getattr(cfg_s, "GEO_TZ", "Europe/Istanbul")
|
||||
geo_lat = float(getattr(cfg_s, "GEO_LAT", 39.92077))
|
||||
geo_lon = float(getattr(cfg_s, "GEO_LON", 32.85411))
|
||||
|
||||
today = now.date()
|
||||
|
||||
# ------------------------------
|
||||
# 1) SunHolidayInfo kullanmayı dene
|
||||
# ------------------------------
|
||||
if SunHolidayInfo is not None:
|
||||
try:
|
||||
tracker = SunHolidayInfo(
|
||||
current_datetime=now,
|
||||
city_name=geo_city,
|
||||
country=geo_country,
|
||||
timezone=geo_tz,
|
||||
latitude=geo_lat,
|
||||
longitude=geo_lon,
|
||||
)
|
||||
data = tracker.get_info()
|
||||
|
||||
sunrise_t = _parse_time_str(data.get("sunrise"))
|
||||
sunset_t = _parse_time_str(data.get("sunset"))
|
||||
noon_t = _parse_time_str(data.get("noon"))
|
||||
|
||||
season_name = data.get("season") or "Bilinmiyor"
|
||||
ss_raw = data.get("season_start") or ""
|
||||
se_raw = data.get("season_end") or ""
|
||||
|
||||
ds = _parse_date_any(ss_raw)
|
||||
de = _parse_date_any(se_raw)
|
||||
|
||||
if ds is None:
|
||||
ds = today
|
||||
if de is None or de < ds:
|
||||
de = ds
|
||||
|
||||
total_days = _as_int(data.get("season_day"), 0)
|
||||
passed_days = _as_int(data.get("season_passed"), 0)
|
||||
remaining_days = _as_int(data.get("season_remaining"), 0)
|
||||
|
||||
# Eğer harici toplam/remaining güvenilmezse basit hesap
|
||||
if total_days <= 0 or remaining_days < 0:
|
||||
total_days = (de - ds).days + 1
|
||||
passed_days = (today - ds).days
|
||||
remaining_days = (de - today).days
|
||||
|
||||
is_season, saving_start, saving_stop = _compute_saving_window(
|
||||
season_name, ds, de, today
|
||||
)
|
||||
|
||||
return SeasonInfo(
|
||||
date = today,
|
||||
sunrise = sunrise_t,
|
||||
sunset = sunset_t,
|
||||
noon = noon_t,
|
||||
is_holiday = bool(data.get("is_holiday", False)),
|
||||
holiday_label = str(data.get("holiday_label", "")),
|
||||
season = season_name,
|
||||
season_start = ds.isoformat(),
|
||||
season_end = de.isoformat(),
|
||||
season_day = total_days,
|
||||
season_passed = passed_days,
|
||||
season_remaining = remaining_days,
|
||||
is_season = is_season,
|
||||
saving_start = saving_start,
|
||||
saving_stop = saving_stop,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"⚠️ SunHolidayInfo hata, fallback kullanılacak: {e}")
|
||||
|
||||
# ------------------------------
|
||||
# 2) Fallback: sadece mevsim tahmini + saving
|
||||
# ------------------------------
|
||||
month = now.month
|
||||
day = now.day
|
||||
|
||||
if (month == 12 and day >= 1) or (1 <= month <= 3 and (month < 3 or day <= 20)):
|
||||
season_label = "Kış"
|
||||
ss = f"{now.year}-12-01"
|
||||
se = f"{now.year+1}-03-20" if month == 12 else f"{now.year}-03-20"
|
||||
elif 3 <= month <= 6 and not (month == 6 and day > 20):
|
||||
season_label = "İlkbahar"
|
||||
ss = f"{now.year}-03-21"
|
||||
se = f"{now.year}-06-20"
|
||||
elif 6 <= month <= 9 and not (month == 9 and day > 22):
|
||||
season_label = "Yaz"
|
||||
ss = f"{now.year}-06-21"
|
||||
se = f"{now.year}-09-22"
|
||||
else:
|
||||
season_label = "Sonbahar"
|
||||
ss = f"{now.year}-09-23"
|
||||
se = f"{now.year}-11-30"
|
||||
|
||||
try:
|
||||
ds = datetime.fromisoformat(ss).date()
|
||||
de = datetime.fromisoformat(se).date()
|
||||
total_days = (de - ds).days + 1
|
||||
passed_days = (today - ds).days
|
||||
remaining_days = (de - today).days
|
||||
except Exception:
|
||||
ds = today
|
||||
de = today
|
||||
total_days = passed_days = remaining_days = 0
|
||||
|
||||
is_season, saving_start, saving_stop = _compute_saving_window(
|
||||
season_label, ds, de, today
|
||||
)
|
||||
|
||||
return SeasonInfo(
|
||||
date = today,
|
||||
sunrise = None,
|
||||
sunset = None,
|
||||
noon = None,
|
||||
is_holiday = False,
|
||||
holiday_label = "",
|
||||
season = season_label,
|
||||
season_start = ds.isoformat(),
|
||||
season_end = de.isoformat(),
|
||||
season_day = total_days,
|
||||
season_passed = passed_days,
|
||||
season_remaining = remaining_days,
|
||||
is_season = is_season,
|
||||
saving_start = saving_start,
|
||||
saving_stop = saving_stop,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Legacy / debug uyumlu satırlar
|
||||
# ---------------------------------------------------------
|
||||
def to_syslog_lines(self) -> list[str]:
|
||||
"""
|
||||
legacy_syslog.emit_header_season veya debug için özet satırlar üretir.
|
||||
Örnek:
|
||||
"season : İlkbahar 2025-03-21 - 2025-06-20 day:92 passed:10 remaining:82"
|
||||
"saving : True 2025-03-21 - 2025-04-10"
|
||||
"""
|
||||
i = self.info
|
||||
lines: list[str] = []
|
||||
|
||||
lines.append(
|
||||
f"season : {i.season} {i.season_start} - {i.season_end} "
|
||||
f"day:{i.season_day} passed:{i.season_passed} remaining:{i.season_remaining}"
|
||||
)
|
||||
|
||||
if i.saving_start and i.saving_stop:
|
||||
lines.append(
|
||||
f"saving : {i.is_season} {i.saving_start.isoformat()} - {i.saving_stop.isoformat()}"
|
||||
)
|
||||
else:
|
||||
lines.append("saving : False - -")
|
||||
|
||||
return lines
|
||||
191
ebuild/core/sunholiday.py
Normal file
191
ebuild/core/sunholiday.py
Normal file
@@ -0,0 +1,191 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
__title__ = "sunholiday"
|
||||
__author__ = 'Mehmet Karatay & "Saraswati" (ChatGPT)'
|
||||
__purpose__ = "Astral ve eholidays_tr kullanarak güneş / tatil / mevsim bilgisi üretir"
|
||||
__version__ = "0.1.1"
|
||||
__date__ = "2025-11-22"
|
||||
|
||||
"""
|
||||
ebuild/core/sunholiday.py
|
||||
|
||||
Revision : 2025-11-22
|
||||
Authors : Mehmet Karatay & "Saraswati" (ChatGPT)
|
||||
|
||||
Amaç
|
||||
-----
|
||||
Eski sistemde kullandığın SunHolidayInfo sınıfını, ebuild projesi
|
||||
için bağımsız bir modüle taşıdık. Bu modül:
|
||||
|
||||
- Astral ile gün doğumu / gün batımı / öğlen
|
||||
- eholidays_tr ile resmi tatil (yoksa graceful fallback)
|
||||
- Basit mevsim hesabı
|
||||
|
||||
bilgilerini üretir ve SeasonController tarafından kullanılır.
|
||||
|
||||
Dış arayüz:
|
||||
class SunHolidayInfo:
|
||||
- get_info() -> dict
|
||||
- print_info()
|
||||
- to_json()
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
from astral import LocationInfo
|
||||
from astral.sun import sun
|
||||
import pytz
|
||||
|
||||
# eholidays_tr yoksa sistem göçmesin → dummy tracker
|
||||
try:
|
||||
from eholidays_tr import HolidayTracker # type: ignore
|
||||
except Exception:
|
||||
class HolidayTracker: # type: ignore
|
||||
def __init__(self, year: int) -> None:
|
||||
self.year = year
|
||||
def is_holiday(self, dt) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class Season(Enum):
|
||||
KIS = "Kış"
|
||||
ILKBAHAR = "İlkbahar"
|
||||
YAZ = "Yaz"
|
||||
SONBAHAR = "Sonbahar"
|
||||
|
||||
|
||||
class SunHolidayInfo:
|
||||
"""
|
||||
Astral + eholidays_tr kombinasyonu ile gün / tatil / mevsim bilgisi üretir.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
current_datetime=None,
|
||||
city_name: str = "Ankara",
|
||||
country: str = "Turkey",
|
||||
timezone: str = "Europe/Istanbul",
|
||||
latitude: float = 39.92077,
|
||||
longitude: float = 32.85411,
|
||||
) -> None:
|
||||
self.city = city_name
|
||||
self.country = country
|
||||
self.timezone_str = timezone
|
||||
self.timezone = pytz.timezone(timezone)
|
||||
self.lat = latitude
|
||||
self.lon = longitude
|
||||
|
||||
if current_datetime is None:
|
||||
self.current_datetime = datetime.now(self.timezone)
|
||||
else:
|
||||
# Eğer naive datetime geldiyse timezone ekleyelim
|
||||
if current_datetime.tzinfo is None:
|
||||
self.current_datetime = self.timezone.localize(current_datetime)
|
||||
else:
|
||||
self.current_datetime = current_datetime.astimezone(self.timezone)
|
||||
|
||||
self.location = LocationInfo(
|
||||
self.city, self.country, self.timezone_str, self.lat, self.lon
|
||||
)
|
||||
self.work_date = self.current_datetime
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Yardımcılar
|
||||
# ---------------------------------------------------------
|
||||
def return_init(self) -> str:
|
||||
return (
|
||||
f"SunHolidayInfo: {self.country} {self.city} "
|
||||
f"{self.timezone_str} {self.lat} - {self.lon}"
|
||||
)
|
||||
|
||||
def get_season(self, date_obj: datetime):
|
||||
"""
|
||||
Astronomik mevsim aralıklarıyla kaba mevsim ve gün bilgisi üretir.
|
||||
"""
|
||||
ranges = {
|
||||
Season.KIS: [
|
||||
(date_obj.replace(month=1, day=1), date_obj.replace(month=3, day=20)),
|
||||
(date_obj.replace(month=12, day=21), date_obj.replace(month=12, day=31)),
|
||||
],
|
||||
Season.ILKBAHAR: [
|
||||
(date_obj.replace(month=3, day=21), date_obj.replace(month=6, day=20))
|
||||
],
|
||||
Season.YAZ: [
|
||||
(date_obj.replace(month=6, day=21), date_obj.replace(month=9, day=22))
|
||||
],
|
||||
Season.SONBAHAR: [
|
||||
(date_obj.replace(month=9, day=23), date_obj.replace(month=12, day=20))
|
||||
],
|
||||
}
|
||||
|
||||
for season, periods in ranges.items():
|
||||
for start, end in periods:
|
||||
if start <= date_obj <= end:
|
||||
total_days = (end - start).days + 1
|
||||
passed_days = (date_obj - start).days
|
||||
remaining_days = (end - date_obj).days
|
||||
return {
|
||||
"season": season.value,
|
||||
"season_start": start.isoformat(),
|
||||
"season_end": end.isoformat(),
|
||||
"season_day": total_days,
|
||||
"season_passed": passed_days,
|
||||
"season_remaining": remaining_days,
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Ana API
|
||||
# ---------------------------------------------------------
|
||||
def get_info(self) -> dict:
|
||||
"""
|
||||
Astral + HolidayTracker + mevsim hesabını bir dict olarak döner.
|
||||
"""
|
||||
sun_data = sun(
|
||||
self.location.observer,
|
||||
date=self.work_date,
|
||||
tzinfo=self.timezone,
|
||||
)
|
||||
|
||||
tracker = HolidayTracker(self.work_date.year)
|
||||
holiday_label = tracker.is_holiday(self.work_date)
|
||||
|
||||
season_info = self.get_season(self.work_date)
|
||||
|
||||
return {
|
||||
"date": self.work_date.isoformat(),
|
||||
"sunrise": sun_data["sunrise"].strftime("%H:%M:%S"),
|
||||
"sunset": sun_data["sunset"].strftime("%H:%M:%S"),
|
||||
"noon": sun_data["noon"].strftime("%H:%M:%S"),
|
||||
"is_holiday": bool(holiday_label),
|
||||
"holiday_label": holiday_label if holiday_label else "Yok",
|
||||
"season": season_info["season"] if season_info else None,
|
||||
"season_start": season_info["season_start"] if season_info else None,
|
||||
"season_end": season_info["season_end"] if season_info else None,
|
||||
"season_day": season_info["season_day"] if season_info else None,
|
||||
"season_passed": season_info["season_passed"] if season_info else None,
|
||||
"season_remaining": season_info["season_remaining"] if season_info else None,
|
||||
}
|
||||
|
||||
def print_info(self) -> None:
|
||||
info = self.get_info()
|
||||
print(f"Güneş bilgileri - {self.location.name}, {info['date']}")
|
||||
print(f"Doğuş: {info['sunrise']}")
|
||||
print(f"Batış: {info['sunset']}")
|
||||
print(f"Öğlen: {info['noon']}")
|
||||
print(f"Tatil mi?: {'Evet' if info['is_holiday'] else 'Hayır'}")
|
||||
if info["is_holiday"]:
|
||||
print(f"Etiket: {info['holiday_label']}")
|
||||
|
||||
def to_json(self) -> str:
|
||||
return json.dumps(self.get_info(), ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
s = SunHolidayInfo()
|
||||
print(s.return_init())
|
||||
print(s.to_json())
|
||||
1
ebuild/core/systems/__init__.py
Normal file
1
ebuild/core/systems/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
BIN
ebuild/core/systems/__pycache__/__init__.cpython-39.pyc
Normal file
BIN
ebuild/core/systems/__pycache__/__init__.cpython-39.pyc
Normal file
Binary file not shown.
BIN
ebuild/core/systems/__pycache__/burner.cpython-39.pyc
Normal file
BIN
ebuild/core/systems/__pycache__/burner.cpython-39.pyc
Normal file
Binary file not shown.
679
ebuild/core/systems/burner.py
Normal file
679
ebuild/core/systems/burner.py
Normal file
@@ -0,0 +1,679 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
__title__ = "burner"
|
||||
__author__ = 'Mehmet Karatay & "Saraswati" (ChatGPT)'
|
||||
__purpose__ = "Bina ve/veya dış ısıya göre brülör ve sirkülasyon kontrol çekirdeği"
|
||||
__version__ = "0.4.3"
|
||||
__date__ = "2025-11-22"
|
||||
|
||||
"""
|
||||
ebuild/core/systems/burner.py
|
||||
|
||||
Revision : 2025-11-22
|
||||
Authors : Mehmet Karatay & "Saraswati" (ChatGPT)
|
||||
|
||||
Amaç
|
||||
-----
|
||||
- BUILD_BURNER moduna göre (F/B) brülör ve sirkülasyon pompalarını yönetmek
|
||||
- Bina ortalaması (B mod) veya dış ısı (F mod) üzerinden ısıtma isteği üretmek
|
||||
- used_out_heat mantığı ile dış ısıya hafta sonu / konfor offset uygulamak
|
||||
|
||||
Bağımlılıklar
|
||||
--------------
|
||||
- building.Building
|
||||
- environment.BuildingEnvironment
|
||||
- season.SeasonController
|
||||
- io.relay_driver.RelayDriver
|
||||
- io.dbtext.DBText
|
||||
- io.legacy_syslog (syslog/console çıktıları için)
|
||||
- config_statics (cfg_s)
|
||||
- config_runtime (cfg_v)
|
||||
|
||||
Notlar
|
||||
------
|
||||
- Brülör, igniter ve pompalar relay_driver içinde isimlendirilmiş kanallarla
|
||||
temsil edilir.
|
||||
- Bu dosya, eski sistemle uyum için mümkün olduğunca log formatını korumaya
|
||||
çalışır.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import time as _time
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Dict, Any, List, Tuple
|
||||
|
||||
from ..building import Building
|
||||
from ..season import SeasonController
|
||||
from ..environment import BuildingEnvironment
|
||||
from ...io.relay_driver import RelayDriver
|
||||
from ...io.dbtext import DBText
|
||||
from ...io import legacy_syslog as lsys
|
||||
from ... import config_statics as cfg_s
|
||||
from ... import config_runtime as cfg_v
|
||||
|
||||
|
||||
# -------------------------------------------------------------
|
||||
# Yardımcı: DS18B20 okuma (hat sensörleri için)
|
||||
# -------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class BurnerState:
|
||||
burner_on: bool
|
||||
pumps_on: Tuple[str, ...]
|
||||
fire_setpoint_c: float
|
||||
last_change_ts: datetime.datetime
|
||||
reason: str
|
||||
last_building_avg: Optional[float]
|
||||
last_outside_c: Optional[float]
|
||||
last_used_out_c: Optional[float]
|
||||
last_mode: str
|
||||
|
||||
|
||||
# ----------------------------- Isı eğrisi --------------------
|
||||
|
||||
# Dış ısı → kazan çıkış setpoint haritası
|
||||
# Örnek bir eğri; config_runtime ile override edilebilir.
|
||||
BURNER_FIRE_SETPOINT_MAP: Dict[float, Dict[str, float]] = getattr(
|
||||
cfg_v,
|
||||
"BURNER_FIRE_SETPOINT_MAP",
|
||||
{
|
||||
-10.0: {"fire": 50.0},
|
||||
-5.0: {"fire": 48.0},
|
||||
0.0: {"fire": 46.0},
|
||||
5.0: {"fire": 44.0},
|
||||
10.0: {"fire": 40.0},
|
||||
15.0: {"fire": 35.0},
|
||||
20.0: {"fire": 30.0},
|
||||
25.0: {"fire": 26.0},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class BurnerConfig:
|
||||
"""
|
||||
Brülör çalışma parametreleri (runtime config'ten override edilebilir).
|
||||
"""
|
||||
|
||||
min_run_sec: int = 60 # brülör en az bu kadar saniye çalışsın
|
||||
min_stop_sec: int = 60 # brülör en az bu kadar saniye duruşta kalsın
|
||||
hysteresis_c: float = 0.5 # bina ortalaması için histerezis
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Yardımcı fonksiyon: bina istatistikleri
|
||||
# ---------------------------------------------------------
|
||||
|
||||
|
||||
def _safe_float(value: Any, default: Optional[float] = None) -> Optional[float]:
|
||||
try:
|
||||
if value is None:
|
||||
return default
|
||||
return float(value)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _merge_stats(old: Optional[Dict[str, Any]], new: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Bina istatistiği için min/avg/max birleştirme.
|
||||
"""
|
||||
if old is None:
|
||||
return dict(new)
|
||||
|
||||
def _pick(key: str, func):
|
||||
a = old.get(key)
|
||||
b = new.get(key)
|
||||
if a is None:
|
||||
return b
|
||||
if b is None:
|
||||
return a
|
||||
return func(a, b)
|
||||
|
||||
return {
|
||||
"min": _pick("min", min),
|
||||
"avg": new.get("avg"),
|
||||
"max": _pick("max", max),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# used_out_heat hesabı
|
||||
# ---------------------------------------------------------
|
||||
|
||||
|
||||
def _apply_weekend_and_comfort(
|
||||
used_out: Optional[float],
|
||||
now: datetime.datetime,
|
||||
weekend_boost_c: float,
|
||||
comfort_offset_c: float,
|
||||
) -> Optional[float]:
|
||||
"""
|
||||
Haftasonu ve konfor offset'ini used_out üzerine uygular.
|
||||
"""
|
||||
if used_out is None:
|
||||
return None
|
||||
|
||||
result = float(used_out)
|
||||
|
||||
# Haftasonu boost: Cumartesi / Pazar
|
||||
if now.weekday() >= 5 and weekend_boost_c != 0.0:
|
||||
result -= weekend_boost_c
|
||||
|
||||
# Konfor offset'i
|
||||
if comfort_offset_c != 0.0:
|
||||
result -= comfort_offset_c
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def pick_fire_setpoint(outside_c: Optional[float]) -> float:
|
||||
"""
|
||||
Dış ısı (used_out_heat) için en yakın fire setpoint'i döndürür.
|
||||
|
||||
Eğer outside_c None ise, MAX_OUTLET_C kullanılır.
|
||||
"""
|
||||
if outside_c is None:
|
||||
return float(getattr(cfg_v, "MAX_OUTLET_C", 45.0))
|
||||
|
||||
keys = sorted(BURNER_FIRE_SETPOINT_MAP.keys())
|
||||
nearest_key = min(keys, key=lambda k: abs(k - outside_c))
|
||||
mapping = BURNER_FIRE_SETPOINT_MAP.get(nearest_key, {})
|
||||
return float(mapping.get("fire", getattr(cfg_v, "MAX_OUTLET_C", 45.0)))
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Ana sınıf: BurnerController
|
||||
# ---------------------------------------------------------
|
||||
|
||||
|
||||
class BurnerController:
|
||||
"""
|
||||
F/B moduna göre brülör kontrolü yapan sınıf.
|
||||
|
||||
BUILD_BURNER = "B"
|
||||
→ bina ortalama sıcaklığına göre kontrol
|
||||
|
||||
BUILD_BURNER = "F"
|
||||
→ dış ısıya göre (OUTSIDE_LIMIT_HEAT_C) karar veren mod
|
||||
(burada dış ısı olarak *used_out_heat* kullanılır).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
building: Building,
|
||||
relay_driver: RelayDriver,
|
||||
logger: Optional[DBText] = None,
|
||||
config: Optional[BurnerConfig] = None,
|
||||
burner_id: Optional[int] = None,
|
||||
environment: Optional[BuildingEnvironment] = None,
|
||||
) -> None:
|
||||
self.building = building
|
||||
self.relays = relay_driver
|
||||
# Runtime konfig: varsayılan BurnerConfig + config_runtime override
|
||||
self.cfg = config or BurnerConfig()
|
||||
try:
|
||||
self.cfg.min_run_sec = int(getattr(cfg_v, "BURNER_MIN_RUN_SEC", self.cfg.min_run_sec))
|
||||
self.cfg.min_stop_sec = int(getattr(cfg_v, "BURNER_MIN_STOP_SEC", self.cfg.min_stop_sec))
|
||||
self.cfg.hysteresis_c = float(getattr(cfg_v, "BURNER_HYSTERESIS_C", self.cfg.hysteresis_c))
|
||||
except Exception as e:
|
||||
print("BurnerConfig override error:", e)
|
||||
|
||||
# Hangi brülör? → config_statics.BURNER_DEFAULT_ID veya parametre
|
||||
default_id = int(getattr(cfg_s, "BURNER_DEFAULT_ID", 0))
|
||||
self.burner_id = int(burner_id) if burner_id is not None else default_id
|
||||
|
||||
# DBText logger
|
||||
log_file = getattr(cfg_s, "BURNER_LOG_FILE", "ebuild_burner_log.sql")
|
||||
log_table = getattr(cfg_s, "BURNER_LOG_TABLE", "eburner_log")
|
||||
self.logger = logger or DBText(
|
||||
filename=log_file,
|
||||
table=log_table,
|
||||
app="EBURNER",
|
||||
)
|
||||
|
||||
max_out = float(getattr(cfg_v, "MAX_OUTLET_C", 45.0))
|
||||
|
||||
# Röle kanal isimleri (eski yapı ile uyum için fallback)
|
||||
self.igniter_ch: str = getattr(cfg_s, "BURNER_IGNITER_CH", "igniter")
|
||||
self.pump_channels: List[str] = list(
|
||||
getattr(cfg_s, "BURNER_PUMPS", ["circulation_a", "circulation_b"])
|
||||
)
|
||||
self.default_pumps: List[str] = list(
|
||||
getattr(cfg_s, "BURNER_DEFAULT_PUMPS", ["circulation_a"])
|
||||
)
|
||||
|
||||
# Bina okuma periyodu (BUILDING_READ_PERIOD_S)
|
||||
self._building_last_read_ts: Optional[datetime.datetime] = None
|
||||
self._building_read_period: float = float(
|
||||
getattr(cfg_v, "BUILDING_READ_PERIOD_S", 60.0)
|
||||
)
|
||||
self._building_last_stats: Optional[Dict[str, Any]] = None
|
||||
|
||||
# used_out_heat için parametreler
|
||||
self.used_out_c: Optional[float] = None
|
||||
self._last_used_update_ts: Optional[datetime.datetime] = None
|
||||
self.outside_smooth_sec: float = float(
|
||||
getattr(cfg_v, "OUTSIDE_SMOOTH_SECONDS", 900.0)
|
||||
)
|
||||
self.weekend_boost_c: float = float(
|
||||
getattr(cfg_v, "WEEKEND_HEAT_BOOST_C", 0.0)
|
||||
)
|
||||
self.comfort_offset_c: float = float(
|
||||
getattr(cfg_v, "BURNER_COMFORT_OFFSET_C", 0.0)
|
||||
)
|
||||
|
||||
# Ortam nesnesi (opsiyonel)
|
||||
self.environment = environment
|
||||
|
||||
# Ortamdan başlangıç dış ısı alınabiliyorsa used_out'u hemen doldur
|
||||
if self.environment is not None:
|
||||
try:
|
||||
first_out = self.environment.get_outside_temp_cached()
|
||||
except Exception:
|
||||
first_out = None
|
||||
if first_out is not None:
|
||||
self.used_out_c = first_out
|
||||
self._last_used_update_ts = datetime.datetime.now()
|
||||
|
||||
# Çalışma modu
|
||||
cfg_mode = str(getattr(cfg_s, "BUILD_BURNER", "F")).upper()
|
||||
initial_mode = cfg_mode if cfg_mode in ("F", "B") else "F"
|
||||
|
||||
# Başlangıç state
|
||||
self.state = BurnerState(
|
||||
burner_on=False,
|
||||
pumps_on=tuple(),
|
||||
fire_setpoint_c=max_out,
|
||||
last_change_ts=datetime.datetime.now(),
|
||||
reason="init",
|
||||
last_building_avg=None,
|
||||
last_outside_c=None,
|
||||
last_used_out_c=None,
|
||||
last_mode=initial_mode,
|
||||
)
|
||||
|
||||
# Mevsim / güneş bilgisi (syslog üst block için)
|
||||
try:
|
||||
self.season = SeasonController.from_now()
|
||||
except Exception as e:
|
||||
print("SeasonController.from_now() hata:", e)
|
||||
self.season = None
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Bina istatistikleri
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def _get_building_stats(self, now: datetime.datetime) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Bina ortalaması / min / max gibi istatistikleri periyodik olarak okur.
|
||||
BUILDING_READ_PERIOD_S içinde cache kullanır.
|
||||
"""
|
||||
if self._building_last_read_ts is None:
|
||||
need_read = True
|
||||
else:
|
||||
delta = (now - self._building_last_read_ts).total_seconds()
|
||||
need_read = delta >= self._building_read_period
|
||||
|
||||
if not need_read:
|
||||
return self._building_last_stats
|
||||
|
||||
self._building_last_read_ts = now
|
||||
return None
|
||||
try:
|
||||
stats = self.building.get_stats()
|
||||
except Exception as e:
|
||||
print("Building.get_stats() hata:", e)
|
||||
return self._building_last_stats
|
||||
|
||||
self._building_last_stats = stats
|
||||
return stats
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# used_out_heat güncelleme
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def _update_used_out(self, now: datetime.datetime, outside_c: Optional[float]) -> Optional[float]:
|
||||
"""
|
||||
Dış ısı okumasına göre used_out_heat günceller.
|
||||
- OUTSIDE_SMOOTH_SECONDS süresince eksponansiyel smoothing
|
||||
- Haftasonu ve konfor offset'i eklenir.
|
||||
"""
|
||||
raw = outside_c
|
||||
|
||||
if raw is None:
|
||||
return self.used_out_c
|
||||
|
||||
# Smooth
|
||||
if self.used_out_c is None or self._last_used_update_ts is None:
|
||||
smoothed = raw
|
||||
else:
|
||||
dt = (now - self._last_used_update_ts).total_seconds()
|
||||
if dt <= 0:
|
||||
smoothed = self.used_out_c
|
||||
else:
|
||||
tau = max(1.0, self.outside_smooth_sec)
|
||||
alpha = min(1.0, dt / tau)
|
||||
smoothed = (1.0 - alpha) * self.used_out_c + alpha * raw
|
||||
|
||||
self.used_out_c = smoothed
|
||||
self._last_used_update_ts = now
|
||||
|
||||
# Haftasonu / konfor offset'i uygula
|
||||
final_used = _apply_weekend_and_comfort(
|
||||
smoothed,
|
||||
now,
|
||||
self.weekend_boost_c,
|
||||
self.comfort_offset_c,
|
||||
)
|
||||
return final_used
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Isı ihtiyacı kararları
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def _should_heat_by_outside(self, used_out: Optional[float]) -> bool:
|
||||
"""
|
||||
F modunda (dış ısıya göre) ısıtma isteği.
|
||||
"""
|
||||
limit = float(getattr(cfg_v, "OUTSIDE_HEAT_LIMIT_C", 17.0))
|
||||
if used_out is None:
|
||||
return False
|
||||
|
||||
want = used_out < limit
|
||||
print(f"should_heat_by_outside: used={used_out:.3f}C limit={limit:.1f}C")
|
||||
return want
|
||||
|
||||
def _should_heat_by_building(self, building_avg: Optional[float], now: datetime.datetime) -> bool:
|
||||
"""
|
||||
B modunda bina ortalaması + konfor setpoint'e göre ısıtma isteği.
|
||||
"""
|
||||
comfort = float(getattr(cfg_v, "COMFORT_SETPOINT_C", 23.0))
|
||||
h = self.cfg.hysteresis_c
|
||||
|
||||
if building_avg is None:
|
||||
return False
|
||||
|
||||
if building_avg < (comfort - h):
|
||||
return True
|
||||
if building_avg > (comfort + h):
|
||||
return False
|
||||
|
||||
# Histerezis bandında önceki state'i koru
|
||||
return self.state.burner_on
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Min çalışma / durma süreleri
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def _respect_min_times(self, now: datetime.datetime, want_on: bool) -> bool:
|
||||
"""
|
||||
min_run_sec / min_stop_sec kurallarını uygular.
|
||||
- İlk açılışta (state.reason == 'init') kısıtlama uygulanmaz.
|
||||
"""
|
||||
# İlk tick: min_run/min_stop uygulama
|
||||
try:
|
||||
if getattr(self.state, "reason", "") == "init":
|
||||
return want_on
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
elapsed = (now - self.state.last_change_ts).total_seconds()
|
||||
|
||||
if self.state.burner_on:
|
||||
# Çalışırken min_run dolmadan kapatma
|
||||
if not want_on and elapsed < self.cfg.min_run_sec:
|
||||
return True
|
||||
else:
|
||||
# Kapalıyken min_stop dolmadan açma
|
||||
if want_on and elapsed < self.cfg.min_stop_sec:
|
||||
return False
|
||||
|
||||
return want_on
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Çıkışları rölelere uygulama
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def _apply_outputs(
|
||||
self,
|
||||
now: datetime.datetime,
|
||||
mode: str,
|
||||
burner_on: bool,
|
||||
pumps_on: Tuple[str, ...],
|
||||
fire_setpoint_c: float,
|
||||
reason: str,
|
||||
) -> None:
|
||||
"""
|
||||
Röleleri sürer, state'i günceller, log ve syslog üretir.
|
||||
"""
|
||||
# 1) Röle sürücüsü (igniter + pompalar)
|
||||
try:
|
||||
# Yeni API: RelayDriver brülör-aware ise
|
||||
if hasattr(self.relays, "set_igniter"):
|
||||
# Brülör ateşleme
|
||||
self.relays.set_igniter(self.burner_id, burner_on)
|
||||
|
||||
# Pompalar: her zaman kanal isimleri üzerinden sür
|
||||
if hasattr(self.relays, "all_pumps"):
|
||||
all_pumps = list(self.relays.all_pumps(self.burner_id)) # ['circulation_a', ...]
|
||||
for ch in all_pumps:
|
||||
self.relays.set_channel(ch, (ch in pumps_on))
|
||||
else:
|
||||
# all_pumps yoksa, config_statics'ten gelen pump_channels ile sür
|
||||
for ch in self.pump_channels:
|
||||
self.relays.set_channel(ch, (ch in pumps_on))
|
||||
else:
|
||||
# Eski/çok basit API: doğrudan kanal adları
|
||||
self.relays.set_channel(self.igniter_ch, burner_on)
|
||||
for ch in self.pump_channels:
|
||||
self.relays.set_channel(ch, (ch in pumps_on))
|
||||
except Exception as exc:
|
||||
# legacy_syslog.log_error YOK, bu yüzden ya loga yaz ya da print et
|
||||
try:
|
||||
msg = f"[relay_error] igniter_ch={self.igniter_ch} burner_on={burner_on} pumps_on={pumps_on} exc={exc}"
|
||||
lsys.send_legacy_syslog(lsys.format_line(98, msg))
|
||||
except Exception:
|
||||
print("Relay error in _apply_outputs:", exc)
|
||||
|
||||
# 2) State güncelle
|
||||
if burner_on != self.state.burner_on or tuple(pumps_on) != self.state.pumps_on:
|
||||
self.state.last_change_ts = now
|
||||
|
||||
self.state.burner_on = burner_on
|
||||
self.state.pumps_on = tuple(pumps_on)
|
||||
self.state.fire_setpoint_c = fire_setpoint_c
|
||||
self.state.reason = reason
|
||||
self.state.last_mode = mode
|
||||
|
||||
# 3) DBText logger'a yaz
|
||||
try:
|
||||
self.logger.insert(
|
||||
{
|
||||
"ts": now,
|
||||
"mode": mode,
|
||||
"burner_on": int(burner_on),
|
||||
"pumps": ",".join(pumps_on),
|
||||
"fire_sp": fire_setpoint_c,
|
||||
"reason": reason,
|
||||
"bavg": _safe_float(self.state.last_building_avg),
|
||||
"out": _safe_float(self.state.last_outside_c),
|
||||
"used": _safe_float(self.state.last_used_out_c),
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 4) Syslog / console üst blok
|
||||
try:
|
||||
lsys.log_burner_header(
|
||||
now=now,
|
||||
mode=mode,
|
||||
season=self.season,
|
||||
building_avg=self.state.last_building_avg,
|
||||
outside_c=self.state.last_outside_c,
|
||||
used_out_c=self.state.last_used_out_c,
|
||||
fire_sp=fire_setpoint_c,
|
||||
burner_on=burner_on,
|
||||
pumps_on=pumps_on,
|
||||
)
|
||||
except Exception as exc:
|
||||
# Burayı tamamen sessize almayalım, hatayı konsola basalım
|
||||
print("BRULOR lsys.log_burner_header error:", exc, "burner.py _apply_outputs()")
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Ana tick fonksiyonu
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def tick(self, outside_c: Optional[float] = None) -> BurnerState:
|
||||
"""
|
||||
Tek bir kontrol adımı.
|
||||
|
||||
- Bina istatistiği BUILDING_READ_PERIOD_S periyodunda bir kez okunur,
|
||||
aradaki tick'lerde cache kullanılır.
|
||||
- F modunda kararlar *used_out_heat* üzerinden verilir.
|
||||
"""
|
||||
now = datetime.datetime.now()
|
||||
cfg_mode = str(getattr(cfg_s, "BUILD_BURNER", "F")).upper()
|
||||
mode = cfg_mode
|
||||
|
||||
print("tick outside_c:", outside_c)
|
||||
# 0) dış ısı: parametre yoksa ortamdan al
|
||||
if outside_c is None and getattr(self, "environment", None) is not None:
|
||||
try:
|
||||
outside_c = self.environment.get_outside_temp_cached()
|
||||
except Exception:
|
||||
outside_c = None
|
||||
print("env:", getattr(self, "environment", None))
|
||||
print("tick outside_c 2:", outside_c)
|
||||
|
||||
# 1) bina istatistiği (periyodik)
|
||||
stats = self._get_building_stats(now)
|
||||
building_avg = stats.get("avg") if stats else None
|
||||
|
||||
# 2) used_out_heat güncelle
|
||||
used_out = self._update_used_out(now, outside_c)
|
||||
|
||||
self.state.last_building_avg = building_avg
|
||||
self.state.last_outside_c = outside_c
|
||||
self.state.last_used_out_c = used_out
|
||||
|
||||
# 3) ısıtma ihtiyacı
|
||||
if mode == "F":
|
||||
want_heat = self._should_heat_by_outside(used_out)
|
||||
else:
|
||||
mode = "B" # saçma değer gelirse B moduna zorla
|
||||
want_heat = self._should_heat_by_building(building_avg, now)
|
||||
|
||||
want_heat = self._respect_min_times(now, want_heat)
|
||||
|
||||
# 4) fire setpoint – F modunda da used_out üzerinden okunur
|
||||
fire_sp = pick_fire_setpoint(used_out)
|
||||
max_out = float(getattr(cfg_v, "MAX_OUTLET_C", 45.0))
|
||||
fire_sp = min(fire_sp, max_out)
|
||||
|
||||
# 5) pompalar
|
||||
if want_heat:
|
||||
if hasattr(self.relays, "enabled_pumps"):
|
||||
try:
|
||||
pumps_list = list(self.relays.enabled_pumps(self.burner_id))
|
||||
pumps = tuple(pumps_list)
|
||||
except Exception:
|
||||
pumps = tuple(self.default_pumps)
|
||||
else:
|
||||
pumps = tuple(self.default_pumps)
|
||||
else:
|
||||
pumps = tuple()
|
||||
|
||||
reason = (
|
||||
f"avg={building_avg}C "
|
||||
f"outside_raw={outside_c}C "
|
||||
f"used={used_out}C "
|
||||
f"want_heat={want_heat}"
|
||||
)
|
||||
print("tick reason", reason)
|
||||
|
||||
# 7) Rölelere uygula
|
||||
self._apply_outputs(
|
||||
now=now,
|
||||
mode=mode,
|
||||
burner_on=bool(want_heat),
|
||||
pumps_on=pumps,
|
||||
fire_setpoint_c=fire_sp,
|
||||
reason=reason,
|
||||
)
|
||||
print("state", self.state)
|
||||
return self.state
|
||||
|
||||
|
||||
# -------------------------------------------------------------
|
||||
# CLI / demo
|
||||
# -------------------------------------------------------------
|
||||
|
||||
|
||||
def _demo() -> None:
|
||||
"""
|
||||
Basit demo: Building + RelayDriver + BuildingEnvironment ile
|
||||
BurnerController'ı ayağa kaldır, tick() döngüsü yap.
|
||||
"""
|
||||
# 1) Bina
|
||||
try:
|
||||
building = Building()
|
||||
print("✅ Building: statics yüklendi\n")
|
||||
print(building.pretty_summary())
|
||||
except Exception as e:
|
||||
print("❌ Building oluşturulamadı:", e)
|
||||
raise SystemExit(1)
|
||||
|
||||
# 2) Ortam (dış ısı, ADC vs.)
|
||||
try:
|
||||
env = BuildingEnvironment()
|
||||
except Exception as e:
|
||||
print("⚠️ BuildingEnvironment oluşturulamadı:", e)
|
||||
env = None
|
||||
|
||||
# 3) Röle sürücüsü
|
||||
rel = RelayDriver(onoff=False)
|
||||
|
||||
# 4) Denetleyici
|
||||
ctrl = BurnerController(building, rel, environment=env)
|
||||
|
||||
print("🔥 BurnerController başlatıldı")
|
||||
print(f" Burner ID : {ctrl.burner_id}")
|
||||
print(f" Çalışma modu (BUILD_BURNER): {getattr(cfg_s, 'BUILD_BURNER', 'F')} (F=dış ısı, B=bina ort)")
|
||||
print(f" Igniter kanalı : {ctrl.igniter_ch}")
|
||||
print(f" Pompa kanalları : {ctrl.pump_channels}")
|
||||
print(f" Varsayılan pompalar : {ctrl.default_pumps}")
|
||||
print(f" Konfor setpoint (°C) : {getattr(cfg_v, 'COMFORT_SETPOINT_C', 23.0)}")
|
||||
print(f" Histerezis (°C) : {ctrl.cfg.hysteresis_c}")
|
||||
print(f" Dış ısı limiti (°C) : {getattr(cfg_v, 'OUTSIDE_HEAT_LIMIT_C', 17.0)}")
|
||||
print(f" Max kazan çıkış (°C) : {getattr(cfg_v, 'MAX_OUTLET_C', 45.0)}")
|
||||
print(f" Bina okuma periyodu (s) : {ctrl._building_read_period}")
|
||||
print(f" OUTSIDE_SMOOTH_SECONDS : {ctrl.outside_smooth_sec}")
|
||||
print(f" WEEKEND_HEAT_BOOST_C : {ctrl.weekend_boost_c}")
|
||||
print(f" BURNER_COMFORT_OFFSET_C : {ctrl.comfort_offset_c}")
|
||||
print("----------------------------------------------------")
|
||||
print("BurnerController demo (Ctrl+C ile çık)…")
|
||||
|
||||
try:
|
||||
while True:
|
||||
ctrl.tick()
|
||||
_time.sleep(5)
|
||||
except KeyboardInterrupt:
|
||||
print("\nCtrl+C alındı, çıkış hazırlanıyor…")
|
||||
finally:
|
||||
try:
|
||||
rel.all_off()
|
||||
print("🔌 Tüm röleler kapatıldı.")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Röleleri kapatırken hata: {e}")
|
||||
finally:
|
||||
try:
|
||||
rel.cleanup()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_demo()
|
||||
678
ebuild/core/systems/burner.py~
Normal file
678
ebuild/core/systems/burner.py~
Normal file
@@ -0,0 +1,678 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
__title__ = "burner"
|
||||
__author__ = 'Mehmet Karatay & "Saraswati" (ChatGPT)'
|
||||
__purpose__ = "Bina ve/veya dış ısıya göre brülör ve sirkülasyon kontrol çekirdeği"
|
||||
__version__ = "0.4.3"
|
||||
__date__ = "2025-11-22"
|
||||
|
||||
"""
|
||||
ebuild/core/systems/burner.py
|
||||
|
||||
Revision : 2025-11-22
|
||||
Authors : Mehmet Karatay & "Saraswati" (ChatGPT)
|
||||
|
||||
Amaç
|
||||
-----
|
||||
- BUILD_BURNER moduna göre (F/B) brülör ve sirkülasyon pompalarını yönetmek
|
||||
- Bina ortalaması (B mod) veya dış ısı (F mod) üzerinden ısıtma isteği üretmek
|
||||
- used_out_heat mantığı ile dış ısıya hafta sonu / konfor offset uygulamak
|
||||
|
||||
Bağımlılıklar
|
||||
--------------
|
||||
- building.Building
|
||||
- environment.BuildingEnvironment
|
||||
- season.SeasonController
|
||||
- io.relay_driver.RelayDriver
|
||||
- io.dbtext.DBText
|
||||
- io.legacy_syslog (syslog/console çıktıları için)
|
||||
- config_statics (cfg_s)
|
||||
- config_runtime (cfg_v)
|
||||
|
||||
Notlar
|
||||
------
|
||||
- Brülör, igniter ve pompalar relay_driver içinde isimlendirilmiş kanallarla
|
||||
temsil edilir.
|
||||
- Bu dosya, eski sistemle uyum için mümkün olduğunca log formatını korumaya
|
||||
çalışır.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import time as _time
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Dict, Any, List, Tuple
|
||||
|
||||
from ..building import Building
|
||||
from ..season import SeasonController
|
||||
from ..environment import BuildingEnvironment
|
||||
from ...io.relay_driver import RelayDriver
|
||||
from ...io.dbtext import DBText
|
||||
from ...io import legacy_syslog as lsys
|
||||
from ... import config_statics as cfg_s
|
||||
from ... import config_runtime as cfg_v
|
||||
|
||||
|
||||
# -------------------------------------------------------------
|
||||
# Yardımcı: DS18B20 okuma (hat sensörleri için)
|
||||
# -------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class BurnerState:
|
||||
burner_on: bool
|
||||
pumps_on: Tuple[str, ...]
|
||||
fire_setpoint_c: float
|
||||
last_change_ts: datetime.datetime
|
||||
reason: str
|
||||
last_building_avg: Optional[float]
|
||||
last_outside_c: Optional[float]
|
||||
last_used_out_c: Optional[float]
|
||||
last_mode: str
|
||||
|
||||
|
||||
# ----------------------------- Isı eğrisi --------------------
|
||||
|
||||
# Dış ısı → kazan çıkış setpoint haritası
|
||||
# Örnek bir eğri; config_runtime ile override edilebilir.
|
||||
BURNER_FIRE_SETPOINT_MAP: Dict[float, Dict[str, float]] = getattr(
|
||||
cfg_v,
|
||||
"BURNER_FIRE_SETPOINT_MAP",
|
||||
{
|
||||
-10.0: {"fire": 50.0},
|
||||
-5.0: {"fire": 48.0},
|
||||
0.0: {"fire": 46.0},
|
||||
5.0: {"fire": 44.0},
|
||||
10.0: {"fire": 40.0},
|
||||
15.0: {"fire": 35.0},
|
||||
20.0: {"fire": 30.0},
|
||||
25.0: {"fire": 26.0},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class BurnerConfig:
|
||||
"""
|
||||
Brülör çalışma parametreleri (runtime config'ten override edilebilir).
|
||||
"""
|
||||
|
||||
min_run_sec: int = 60 # brülör en az bu kadar saniye çalışsın
|
||||
min_stop_sec: int = 60 # brülör en az bu kadar saniye duruşta kalsın
|
||||
hysteresis_c: float = 0.5 # bina ortalaması için histerezis
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Yardımcı fonksiyon: bina istatistikleri
|
||||
# ---------------------------------------------------------
|
||||
|
||||
|
||||
def _safe_float(value: Any, default: Optional[float] = None) -> Optional[float]:
|
||||
try:
|
||||
if value is None:
|
||||
return default
|
||||
return float(value)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _merge_stats(old: Optional[Dict[str, Any]], new: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Bina istatistiği için min/avg/max birleştirme.
|
||||
"""
|
||||
if old is None:
|
||||
return dict(new)
|
||||
|
||||
def _pick(key: str, func):
|
||||
a = old.get(key)
|
||||
b = new.get(key)
|
||||
if a is None:
|
||||
return b
|
||||
if b is None:
|
||||
return a
|
||||
return func(a, b)
|
||||
|
||||
return {
|
||||
"min": _pick("min", min),
|
||||
"avg": new.get("avg"),
|
||||
"max": _pick("max", max),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# used_out_heat hesabı
|
||||
# ---------------------------------------------------------
|
||||
|
||||
|
||||
def _apply_weekend_and_comfort(
|
||||
used_out: Optional[float],
|
||||
now: datetime.datetime,
|
||||
weekend_boost_c: float,
|
||||
comfort_offset_c: float,
|
||||
) -> Optional[float]:
|
||||
"""
|
||||
Haftasonu ve konfor offset'ini used_out üzerine uygular.
|
||||
"""
|
||||
if used_out is None:
|
||||
return None
|
||||
|
||||
result = float(used_out)
|
||||
|
||||
# Haftasonu boost: Cumartesi / Pazar
|
||||
if now.weekday() >= 5 and weekend_boost_c != 0.0:
|
||||
result -= weekend_boost_c
|
||||
|
||||
# Konfor offset'i
|
||||
if comfort_offset_c != 0.0:
|
||||
result -= comfort_offset_c
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def pick_fire_setpoint(outside_c: Optional[float]) -> float:
|
||||
"""
|
||||
Dış ısı (used_out_heat) için en yakın fire setpoint'i döndürür.
|
||||
|
||||
Eğer outside_c None ise, MAX_OUTLET_C kullanılır.
|
||||
"""
|
||||
if outside_c is None:
|
||||
return float(getattr(cfg_v, "MAX_OUTLET_C", 45.0))
|
||||
|
||||
keys = sorted(BURNER_FIRE_SETPOINT_MAP.keys())
|
||||
nearest_key = min(keys, key=lambda k: abs(k - outside_c))
|
||||
mapping = BURNER_FIRE_SETPOINT_MAP.get(nearest_key, {})
|
||||
return float(mapping.get("fire", getattr(cfg_v, "MAX_OUTLET_C", 45.0)))
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Ana sınıf: BurnerController
|
||||
# ---------------------------------------------------------
|
||||
|
||||
|
||||
class BurnerController:
|
||||
"""
|
||||
F/B moduna göre brülör kontrolü yapan sınıf.
|
||||
|
||||
BUILD_BURNER = "B"
|
||||
→ bina ortalama sıcaklığına göre kontrol
|
||||
|
||||
BUILD_BURNER = "F"
|
||||
→ dış ısıya göre (OUTSIDE_LIMIT_HEAT_C) karar veren mod
|
||||
(burada dış ısı olarak *used_out_heat* kullanılır).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
building: Building,
|
||||
relay_driver: RelayDriver,
|
||||
logger: Optional[DBText] = None,
|
||||
config: Optional[BurnerConfig] = None,
|
||||
burner_id: Optional[int] = None,
|
||||
environment: Optional[BuildingEnvironment] = None,
|
||||
) -> None:
|
||||
self.building = building
|
||||
self.relays = relay_driver
|
||||
# Runtime konfig: varsayılan BurnerConfig + config_runtime override
|
||||
self.cfg = config or BurnerConfig()
|
||||
try:
|
||||
self.cfg.min_run_sec = int(getattr(cfg_v, "BURNER_MIN_RUN_SEC", self.cfg.min_run_sec))
|
||||
self.cfg.min_stop_sec = int(getattr(cfg_v, "BURNER_MIN_STOP_SEC", self.cfg.min_stop_sec))
|
||||
self.cfg.hysteresis_c = float(getattr(cfg_v, "BURNER_HYSTERESIS_C", self.cfg.hysteresis_c))
|
||||
except Exception as e:
|
||||
print("BurnerConfig override error:", e)
|
||||
|
||||
# Hangi brülör? → config_statics.BURNER_DEFAULT_ID veya parametre
|
||||
default_id = int(getattr(cfg_s, "BURNER_DEFAULT_ID", 0))
|
||||
self.burner_id = int(burner_id) if burner_id is not None else default_id
|
||||
|
||||
# DBText logger
|
||||
log_file = getattr(cfg_s, "BURNER_LOG_FILE", "ebuild_burner_log.sql")
|
||||
log_table = getattr(cfg_s, "BURNER_LOG_TABLE", "eburner_log")
|
||||
self.logger = logger or DBText(
|
||||
filename=log_file,
|
||||
table=log_table,
|
||||
app="EBURNER",
|
||||
)
|
||||
|
||||
max_out = float(getattr(cfg_v, "MAX_OUTLET_C", 45.0))
|
||||
|
||||
# Röle kanal isimleri (eski yapı ile uyum için fallback)
|
||||
self.igniter_ch: str = getattr(cfg_s, "BURNER_IGNITER_CH", "igniter")
|
||||
self.pump_channels: List[str] = list(
|
||||
getattr(cfg_s, "BURNER_PUMPS", ["circulation_a", "circulation_b"])
|
||||
)
|
||||
self.default_pumps: List[str] = list(
|
||||
getattr(cfg_s, "BURNER_DEFAULT_PUMPS", ["circulation_a"])
|
||||
)
|
||||
|
||||
# Bina okuma periyodu (BUILDING_READ_PERIOD_S)
|
||||
self._building_last_read_ts: Optional[datetime.datetime] = None
|
||||
self._building_read_period: float = float(
|
||||
getattr(cfg_v, "BUILDING_READ_PERIOD_S", 60.0)
|
||||
)
|
||||
self._building_last_stats: Optional[Dict[str, Any]] = None
|
||||
|
||||
# used_out_heat için parametreler
|
||||
self.used_out_c: Optional[float] = None
|
||||
self._last_used_update_ts: Optional[datetime.datetime] = None
|
||||
self.outside_smooth_sec: float = float(
|
||||
getattr(cfg_v, "OUTSIDE_SMOOTH_SECONDS", 900.0)
|
||||
)
|
||||
self.weekend_boost_c: float = float(
|
||||
getattr(cfg_v, "WEEKEND_HEAT_BOOST_C", 0.0)
|
||||
)
|
||||
self.comfort_offset_c: float = float(
|
||||
getattr(cfg_v, "BURNER_COMFORT_OFFSET_C", 0.0)
|
||||
)
|
||||
|
||||
# Ortam nesnesi (opsiyonel)
|
||||
self.environment = environment
|
||||
|
||||
# Ortamdan başlangıç dış ısı alınabiliyorsa used_out'u hemen doldur
|
||||
if self.environment is not None:
|
||||
try:
|
||||
first_out = self.environment.get_outside_temp_cached()
|
||||
except Exception:
|
||||
first_out = None
|
||||
if first_out is not None:
|
||||
self.used_out_c = first_out
|
||||
self._last_used_update_ts = datetime.datetime.now()
|
||||
|
||||
# Çalışma modu
|
||||
cfg_mode = str(getattr(cfg_s, "BUILD_BURNER", "F")).upper()
|
||||
initial_mode = cfg_mode if cfg_mode in ("F", "B") else "F"
|
||||
|
||||
# Başlangıç state
|
||||
self.state = BurnerState(
|
||||
burner_on=False,
|
||||
pumps_on=tuple(),
|
||||
fire_setpoint_c=max_out,
|
||||
last_change_ts=datetime.datetime.now(),
|
||||
reason="init",
|
||||
last_building_avg=None,
|
||||
last_outside_c=None,
|
||||
last_used_out_c=None,
|
||||
last_mode=initial_mode,
|
||||
)
|
||||
|
||||
# Mevsim / güneş bilgisi (syslog üst block için)
|
||||
try:
|
||||
self.season = SeasonController.from_now()
|
||||
except Exception as e:
|
||||
print("SeasonController.from_now() hata:", e)
|
||||
self.season = None
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Bina istatistikleri
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def _get_building_stats(self, now: datetime.datetime) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Bina ortalaması / min / max gibi istatistikleri periyodik olarak okur.
|
||||
BUILDING_READ_PERIOD_S içinde cache kullanır.
|
||||
"""
|
||||
if self._building_last_read_ts is None:
|
||||
need_read = True
|
||||
else:
|
||||
delta = (now - self._building_last_read_ts).total_seconds()
|
||||
need_read = delta >= self._building_read_period
|
||||
|
||||
if not need_read:
|
||||
return self._building_last_stats
|
||||
|
||||
try:
|
||||
stats = self.building.get_stats()
|
||||
except Exception as e:
|
||||
print("Building.get_stats() hata:", e)
|
||||
return self._building_last_stats
|
||||
|
||||
self._building_last_read_ts = now
|
||||
self._building_last_stats = stats
|
||||
return stats
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# used_out_heat güncelleme
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def _update_used_out(self, now: datetime.datetime, outside_c: Optional[float]) -> Optional[float]:
|
||||
"""
|
||||
Dış ısı okumasına göre used_out_heat günceller.
|
||||
- OUTSIDE_SMOOTH_SECONDS süresince eksponansiyel smoothing
|
||||
- Haftasonu ve konfor offset'i eklenir.
|
||||
"""
|
||||
raw = outside_c
|
||||
|
||||
if raw is None:
|
||||
return self.used_out_c
|
||||
|
||||
# Smooth
|
||||
if self.used_out_c is None or self._last_used_update_ts is None:
|
||||
smoothed = raw
|
||||
else:
|
||||
dt = (now - self._last_used_update_ts).total_seconds()
|
||||
if dt <= 0:
|
||||
smoothed = self.used_out_c
|
||||
else:
|
||||
tau = max(1.0, self.outside_smooth_sec)
|
||||
alpha = min(1.0, dt / tau)
|
||||
smoothed = (1.0 - alpha) * self.used_out_c + alpha * raw
|
||||
|
||||
self.used_out_c = smoothed
|
||||
self._last_used_update_ts = now
|
||||
|
||||
# Haftasonu / konfor offset'i uygula
|
||||
final_used = _apply_weekend_and_comfort(
|
||||
smoothed,
|
||||
now,
|
||||
self.weekend_boost_c,
|
||||
self.comfort_offset_c,
|
||||
)
|
||||
return final_used
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Isı ihtiyacı kararları
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def _should_heat_by_outside(self, used_out: Optional[float]) -> bool:
|
||||
"""
|
||||
F modunda (dış ısıya göre) ısıtma isteği.
|
||||
"""
|
||||
limit = float(getattr(cfg_v, "OUTSIDE_HEAT_LIMIT_C", 17.0))
|
||||
if used_out is None:
|
||||
return False
|
||||
|
||||
want = used_out < limit
|
||||
print(f"should_heat_by_outside: used={used_out:.3f}C limit={limit:.1f}C")
|
||||
return want
|
||||
|
||||
def _should_heat_by_building(self, building_avg: Optional[float], now: datetime.datetime) -> bool:
|
||||
"""
|
||||
B modunda bina ortalaması + konfor setpoint'e göre ısıtma isteği.
|
||||
"""
|
||||
comfort = float(getattr(cfg_v, "COMFORT_SETPOINT_C", 23.0))
|
||||
h = self.cfg.hysteresis_c
|
||||
|
||||
if building_avg is None:
|
||||
return False
|
||||
|
||||
if building_avg < (comfort - h):
|
||||
return True
|
||||
if building_avg > (comfort + h):
|
||||
return False
|
||||
|
||||
# Histerezis bandında önceki state'i koru
|
||||
return self.state.burner_on
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Min çalışma / durma süreleri
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def _respect_min_times(self, now: datetime.datetime, want_on: bool) -> bool:
|
||||
"""
|
||||
min_run_sec / min_stop_sec kurallarını uygular.
|
||||
- İlk açılışta (state.reason == 'init') kısıtlama uygulanmaz.
|
||||
"""
|
||||
# İlk tick: min_run/min_stop uygulama
|
||||
try:
|
||||
if getattr(self.state, "reason", "") == "init":
|
||||
return want_on
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
elapsed = (now - self.state.last_change_ts).total_seconds()
|
||||
|
||||
if self.state.burner_on:
|
||||
# Çalışırken min_run dolmadan kapatma
|
||||
if not want_on and elapsed < self.cfg.min_run_sec:
|
||||
return True
|
||||
else:
|
||||
# Kapalıyken min_stop dolmadan açma
|
||||
if want_on and elapsed < self.cfg.min_stop_sec:
|
||||
return False
|
||||
|
||||
return want_on
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Çıkışları rölelere uygulama
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def _apply_outputs(
|
||||
self,
|
||||
now: datetime.datetime,
|
||||
mode: str,
|
||||
burner_on: bool,
|
||||
pumps_on: Tuple[str, ...],
|
||||
fire_setpoint_c: float,
|
||||
reason: str,
|
||||
) -> None:
|
||||
"""
|
||||
Röleleri sürer, state'i günceller, log ve syslog üretir.
|
||||
"""
|
||||
# 1) Röle sürücüsü (igniter + pompalar)
|
||||
try:
|
||||
# Yeni API: RelayDriver brülör-aware ise
|
||||
if hasattr(self.relays, "set_igniter"):
|
||||
# Brülör ateşleme
|
||||
self.relays.set_igniter(self.burner_id, burner_on)
|
||||
|
||||
# Pompalar: her zaman kanal isimleri üzerinden sür
|
||||
if hasattr(self.relays, "all_pumps"):
|
||||
all_pumps = list(self.relays.all_pumps(self.burner_id)) # ['circulation_a', ...]
|
||||
for ch in all_pumps:
|
||||
self.relays.set_channel(ch, (ch in pumps_on))
|
||||
else:
|
||||
# all_pumps yoksa, config_statics'ten gelen pump_channels ile sür
|
||||
for ch in self.pump_channels:
|
||||
self.relays.set_channel(ch, (ch in pumps_on))
|
||||
else:
|
||||
# Eski/çok basit API: doğrudan kanal adları
|
||||
self.relays.set_channel(self.igniter_ch, burner_on)
|
||||
for ch in self.pump_channels:
|
||||
self.relays.set_channel(ch, (ch in pumps_on))
|
||||
except Exception as exc:
|
||||
# legacy_syslog.log_error YOK, bu yüzden ya loga yaz ya da print et
|
||||
try:
|
||||
msg = f"[relay_error] igniter_ch={self.igniter_ch} burner_on={burner_on} pumps_on={pumps_on} exc={exc}"
|
||||
lsys.send_legacy_syslog(lsys.format_line(98, msg))
|
||||
except Exception:
|
||||
print("Relay error in _apply_outputs:", exc)
|
||||
|
||||
# 2) State güncelle
|
||||
if burner_on != self.state.burner_on or tuple(pumps_on) != self.state.pumps_on:
|
||||
self.state.last_change_ts = now
|
||||
|
||||
self.state.burner_on = burner_on
|
||||
self.state.pumps_on = tuple(pumps_on)
|
||||
self.state.fire_setpoint_c = fire_setpoint_c
|
||||
self.state.reason = reason
|
||||
self.state.last_mode = mode
|
||||
|
||||
# 3) DBText logger'a yaz
|
||||
try:
|
||||
self.logger.insert(
|
||||
{
|
||||
"ts": now,
|
||||
"mode": mode,
|
||||
"burner_on": int(burner_on),
|
||||
"pumps": ",".join(pumps_on),
|
||||
"fire_sp": fire_setpoint_c,
|
||||
"reason": reason,
|
||||
"bavg": _safe_float(self.state.last_building_avg),
|
||||
"out": _safe_float(self.state.last_outside_c),
|
||||
"used": _safe_float(self.state.last_used_out_c),
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 4) Syslog / console üst blok
|
||||
try:
|
||||
lsys.log_burner_header(
|
||||
now=now,
|
||||
mode=mode,
|
||||
season=self.season,
|
||||
building_avg=self.state.last_building_avg,
|
||||
outside_c=self.state.last_outside_c,
|
||||
used_out_c=self.state.last_used_out_c,
|
||||
fire_sp=fire_setpoint_c,
|
||||
burner_on=burner_on,
|
||||
pumps_on=pumps_on,
|
||||
)
|
||||
except Exception as exc:
|
||||
# Burayı tamamen sessize almayalım, hatayı konsola basalım
|
||||
print("BRULOR lsys.log_burner_header error:", exc, "burner.py _apply_outputs()")
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Ana tick fonksiyonu
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def tick(self, outside_c: Optional[float] = None) -> BurnerState:
|
||||
"""
|
||||
Tek bir kontrol adımı.
|
||||
|
||||
- Bina istatistiği BUILDING_READ_PERIOD_S periyodunda bir kez okunur,
|
||||
aradaki tick'lerde cache kullanılır.
|
||||
- F modunda kararlar *used_out_heat* üzerinden verilir.
|
||||
"""
|
||||
now = datetime.datetime.now()
|
||||
cfg_mode = str(getattr(cfg_s, "BUILD_BURNER", "F")).upper()
|
||||
mode = cfg_mode
|
||||
|
||||
print("tick outside_c:", outside_c)
|
||||
# 0) dış ısı: parametre yoksa ortamdan al
|
||||
if outside_c is None and getattr(self, "environment", None) is not None:
|
||||
try:
|
||||
outside_c = self.environment.get_outside_temp_cached()
|
||||
except Exception:
|
||||
outside_c = None
|
||||
print("env:", getattr(self, "environment", None))
|
||||
print("tick outside_c 2:", outside_c)
|
||||
|
||||
# 1) bina istatistiği (periyodik)
|
||||
stats = self._get_building_stats(now)
|
||||
building_avg = stats.get("avg") if stats else None
|
||||
|
||||
# 2) used_out_heat güncelle
|
||||
used_out = self._update_used_out(now, outside_c)
|
||||
|
||||
self.state.last_building_avg = building_avg
|
||||
self.state.last_outside_c = outside_c
|
||||
self.state.last_used_out_c = used_out
|
||||
|
||||
# 3) ısıtma ihtiyacı
|
||||
if mode == "F":
|
||||
want_heat = self._should_heat_by_outside(used_out)
|
||||
else:
|
||||
mode = "B" # saçma değer gelirse B moduna zorla
|
||||
want_heat = self._should_heat_by_building(building_avg, now)
|
||||
|
||||
want_heat = self._respect_min_times(now, want_heat)
|
||||
|
||||
# 4) fire setpoint – F modunda da used_out üzerinden okunur
|
||||
fire_sp = pick_fire_setpoint(used_out)
|
||||
max_out = float(getattr(cfg_v, "MAX_OUTLET_C", 45.0))
|
||||
fire_sp = min(fire_sp, max_out)
|
||||
|
||||
# 5) pompalar
|
||||
if want_heat:
|
||||
if hasattr(self.relays, "enabled_pumps"):
|
||||
try:
|
||||
pumps_list = list(self.relays.enabled_pumps(self.burner_id))
|
||||
pumps = tuple(pumps_list)
|
||||
except Exception:
|
||||
pumps = tuple(self.default_pumps)
|
||||
else:
|
||||
pumps = tuple(self.default_pumps)
|
||||
else:
|
||||
pumps = tuple()
|
||||
|
||||
reason = (
|
||||
f"avg={building_avg}C "
|
||||
f"outside_raw={outside_c}C "
|
||||
f"used={used_out}C "
|
||||
f"want_heat={want_heat}"
|
||||
)
|
||||
print("tick reason", reason)
|
||||
|
||||
# 7) Rölelere uygula
|
||||
self._apply_outputs(
|
||||
now=now,
|
||||
mode=mode,
|
||||
burner_on=bool(want_heat),
|
||||
pumps_on=pumps,
|
||||
fire_setpoint_c=fire_sp,
|
||||
reason=reason,
|
||||
)
|
||||
print("state", self.state)
|
||||
return self.state
|
||||
|
||||
|
||||
# -------------------------------------------------------------
|
||||
# CLI / demo
|
||||
# -------------------------------------------------------------
|
||||
|
||||
|
||||
def _demo() -> None:
|
||||
"""
|
||||
Basit demo: Building + RelayDriver + BuildingEnvironment ile
|
||||
BurnerController'ı ayağa kaldır, tick() döngüsü yap.
|
||||
"""
|
||||
# 1) Bina
|
||||
try:
|
||||
building = Building()
|
||||
print("✅ Building: statics yüklendi\n")
|
||||
print(building.pretty_summary())
|
||||
except Exception as e:
|
||||
print("❌ Building oluşturulamadı:", e)
|
||||
raise SystemExit(1)
|
||||
|
||||
# 2) Ortam (dış ısı, ADC vs.)
|
||||
try:
|
||||
env = BuildingEnvironment()
|
||||
except Exception as e:
|
||||
print("⚠️ BuildingEnvironment oluşturulamadı:", e)
|
||||
env = None
|
||||
|
||||
# 3) Röle sürücüsü
|
||||
rel = RelayDriver(onoff=False)
|
||||
|
||||
# 4) Denetleyici
|
||||
ctrl = BurnerController(building, rel, environment=env)
|
||||
|
||||
print("🔥 BurnerController başlatıldı")
|
||||
print(f" Burner ID : {ctrl.burner_id}")
|
||||
print(f" Çalışma modu (BUILD_BURNER): {getattr(cfg_s, 'BUILD_BURNER', 'F')} (F=dış ısı, B=bina ort)")
|
||||
print(f" Igniter kanalı : {ctrl.igniter_ch}")
|
||||
print(f" Pompa kanalları : {ctrl.pump_channels}")
|
||||
print(f" Varsayılan pompalar : {ctrl.default_pumps}")
|
||||
print(f" Konfor setpoint (°C) : {getattr(cfg_v, 'COMFORT_SETPOINT_C', 23.0)}")
|
||||
print(f" Histerezis (°C) : {ctrl.cfg.hysteresis_c}")
|
||||
print(f" Dış ısı limiti (°C) : {getattr(cfg_v, 'OUTSIDE_HEAT_LIMIT_C', 17.0)}")
|
||||
print(f" Max kazan çıkış (°C) : {getattr(cfg_v, 'MAX_OUTLET_C', 45.0)}")
|
||||
print(f" Bina okuma periyodu (s) : {ctrl._building_read_period}")
|
||||
print(f" OUTSIDE_SMOOTH_SECONDS : {ctrl.outside_smooth_sec}")
|
||||
print(f" WEEKEND_HEAT_BOOST_C : {ctrl.weekend_boost_c}")
|
||||
print(f" BURNER_COMFORT_OFFSET_C : {ctrl.comfort_offset_c}")
|
||||
print("----------------------------------------------------")
|
||||
print("BurnerController demo (Ctrl+C ile çık)…")
|
||||
|
||||
try:
|
||||
while True:
|
||||
ctrl.tick()
|
||||
_time.sleep(5)
|
||||
except KeyboardInterrupt:
|
||||
print("\nCtrl+C alındı, çıkış hazırlanıyor…")
|
||||
finally:
|
||||
try:
|
||||
rel.all_off()
|
||||
print("🔌 Tüm röleler kapatıldı.")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Röleleri kapatırken hata: {e}")
|
||||
finally:
|
||||
try:
|
||||
rel.cleanup()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_demo()
|
||||
2
ebuild/core/systems/firealarm.py
Normal file
2
ebuild/core/systems/firealarm.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Yangın alarm sistemi iskeleti."""
|
||||
2
ebuild/core/systems/hydrophore.py
Normal file
2
ebuild/core/systems/hydrophore.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Hidrofor sistemi iskeleti."""
|
||||
2
ebuild/core/systems/irrigation.py
Normal file
2
ebuild/core/systems/irrigation.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Sulama sistemi iskeleti."""
|
||||
Reference in New Issue
Block a user