ilk işlem

This commit is contained in:
root
2025-11-24 14:25:02 +03:00
commit 1d458ca9f6
72 changed files with 9732 additions and 0 deletions

1
ebuild/core/__init__.py Normal file
View File

@@ -0,0 +1 @@
# -*- coding: utf-8 -*-

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View 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
View 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
View 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
View File

@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
"""Device / Sensor / Actuator soyutlamaları için iskelet."""

306
ebuild/core/environment.py Normal file
View 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ı
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ı
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
View 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
View 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())

View File

@@ -0,0 +1 @@
# -*- coding: utf-8 -*-

Binary file not shown.

View 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()

View 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()

View File

@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
"""Yangın alarm sistemi iskeleti."""

View File

@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
"""Hidrofor sistemi iskeleti."""

View File

@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
"""Sulama sistemi iskeleti."""