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/io/__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.

Binary file not shown.

423
ebuild/io/adc_mcp3008.py Normal file
View File

@@ -0,0 +1,423 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
__title__ = "analog_sensors"
__author__ = 'Mehmet Karatay & "Saraswati" (ChatGPT)'
__purpose__ = "MCP3008 tabanlı basınç, gaz, yağmur ve LDR sensörleri için sınıf bazlı arayüz"
__version__ = "0.1.0"
__date__ = "2025-11-21"
"""
ebuild/core/analog_sensors.py
Revision : 2025-11-21
Authors : Mehmet Karatay & "Saraswati" (ChatGPT)
Amaç
-----
- MCP3008 ADC üzerinden bağlı analog sensörler için tekil sınıf yapıları
sağlamak:
* PressureAnalogSensor : su hattı basınç sensörü
* GasAnalogSensor : MQ-4 veya benzeri gaz sensörü
* RainAnalogSensor : yağmur sensörü (wet/dry)
* LDRAnalogSensor : ışık seviyesi sensörü
- Her bir sensör:
* MCP3008ADC üzerinden ilgili kanalı okur (config_statics.ADC_CHANNELS).
* Ham raw (0..1023) ve volt cinsinden değer döndürebilir.
* Eşik ve basit state (SAFE/WARN/ALARM vb.) hesabını kendi içinde tutar.
- Güvenlik ve uyarı mantığı üst kattaki HeatEngine/Burner/Buzzer/Legacy
ile kolay entegre edilebilir.
Notlar
------
- Bu modül "karar mantığı" ve analog okuma katmanını birleştirir.
- Röle kapama, sistem shutdown, buzzer vb. aksiyonlar yine üst katmanda
yapılmalıdır.
"""
from dataclasses import dataclass, field
from typing import Optional, Dict
try:
from ..io.adc_mcp3008 import MCP3008ADC
from .. import config_statics as cfg
except ImportError:
MCP3008ADC = None # type: ignore
cfg = None # type: ignore
# -------------------------------------------------------------
# Ortak durum enum'u
# -------------------------------------------------------------
class SafetyState:
SAFE = "SAFE"
WARN = "WARN"
ALARM = "ALARM"
# -------------------------------------------------------------
# Ortak base sınıf
# -------------------------------------------------------------
@dataclass
class BaseAnalogSensor:
"""
MCP3008 üzerinden tek bir analog kanalı temsil eden temel sınıf.
Özellikler:
-----------
- adc : MCP3008ADC örneği
- channel : int kanal no (0..7)
- name : mantıksal isim (örn: "gas", "pressure")
- last_raw : son okunan ham değer (0..1023)
- last_volt : son okunan volt cinsinden değer
"""
adc: MCP3008ADC
name: str
channel: Optional[int] = None
last_raw: Optional[int] = None
last_volt: Optional[float] = None
def __post_init__(self) -> None:
# Eğer kanal configten alınacaksa burada çöz
if self.channel is None and cfg is not None:
ch_map = getattr(cfg, "ADC_CHANNELS", {})
if self.name in ch_map:
self.channel = int(ch_map[self.name])
if self.channel is None:
raise ValueError(f"{self.__class__.__name__}: '{self.name}' için kanal bulunamadı.")
# ------------------------------------------------------------------
def read_raw(self) -> Optional[int]:
"""
ADC'den ham değeri okur (0..1023).
"""
if self.channel is None:
return None
raw = self.adc.read_raw(self.channel)
self.last_raw = raw
return raw
def read_voltage(self) -> Optional[float]:
"""
ADC'den raw okur ve volt cinsine çevirir.
"""
if self.channel is None:
return None
volt = self.adc.read_voltage(self.channel)
self.last_volt = volt
return volt
def update(self) -> Optional[int]:
"""
Varsayılan olarak sadece raw okur; alt sınıflar state hesaplarını
kendi override'larında yapar.
"""
return self.read_raw()
def summary(self) -> str:
return f"{self.__class__.__name__}(name={self.name}, ch={self.channel}, raw={self.last_raw}, V={self.last_volt})"
# -------------------------------------------------------------
# Gaz sensörü (MQ-4) kill switch mantığı ile
# -------------------------------------------------------------
@dataclass
class GasAnalogSensor(BaseAnalogSensor):
"""
Gaz sensörü (MQ-4) için analog ve güvenlik mantığı.
State mantığı:
- raw >= alarm_threshold → ALARM, latched
- raw >= warn_threshold → WARN (latched varsa ALARM)
- trend (slope) ile hızlı artış + warn üstü → ALARM
- Diğer durumlarda SAFE (latched yoksa).
latched_alarm True olduğu sürece:
- state ALARM olarak kalır
- should_shutdown_system() True döner
"""
warn_threshold: int = field(default=150)
alarm_threshold: int = field(default=250)
slope_min_delta: int = field(default=30)
slope_window: int = field(default=5)
history_len: int = field(default=20)
state: str = SafetyState.SAFE
latched_alarm: bool = False
_history: list[int] = field(default_factory=list)
def __post_init__(self) -> None:
super().__post_init__()
# Konfig override
if cfg is not None:
self.warn_threshold = int(getattr(cfg, "GAS_WARN_THRESHOLD_RAW", self.warn_threshold))
self.alarm_threshold = int(getattr(cfg, "GAS_ALARM_THRESHOLD_RAW", self.alarm_threshold))
self.slope_min_delta = int(getattr(cfg, "GAS_SLOPE_MIN_DELTA", self.slope_min_delta))
self.slope_window = int(getattr(cfg, "GAS_SLOPE_WINDOW", self.slope_window))
# ------------------------------------------------------------------
def reset_latch(self) -> None:
"""
Gaz alarm latch'ini manuel olarak sıfırlar.
"""
self.latched_alarm = False
# state sonraki update ile yeniden değerlendirilecek.
def update(self) -> Optional[int]:
"""
Ham değeri okur ve state hesaplar.
"""
raw = self.read_raw()
if raw is None:
return None
# history güncelle
self._history.append(raw)
if len(self._history) > self.history_len:
self._history = self._history[-self.history_len:]
self._evaluate_state(raw)
return raw
def _evaluate_state(self, raw: int) -> None:
# Trend kontrolü
slope_alarm = False
if len(self._history) >= self.slope_window:
first = self._history[-self.slope_window]
delta = raw - first
if delta >= self.slope_min_delta:
slope_alarm = True
# Eşik mantığı
if raw >= self.alarm_threshold or (slope_alarm and raw >= self.warn_threshold):
self.state = SafetyState.ALARM
self.latched_alarm = True
elif raw >= self.warn_threshold:
if self.latched_alarm:
self.state = SafetyState.ALARM
else:
self.state = SafetyState.WARN
else:
if self.latched_alarm:
self.state = SafetyState.ALARM
else:
self.state = SafetyState.SAFE
def should_shutdown_system(self) -> bool:
"""
Gaz açısından sistemin tamamen kapatılması gerekip gerekmediğini
söyler.
"""
return self.latched_alarm
def summary(self) -> str:
return (
f"GasAnalogSensor(ch={self.channel}, raw={self.last_raw}, "
f"state={self.state}, latched={self.latched_alarm})"
)
# -------------------------------------------------------------
# Basınç sensörü limit kontrolü
# -------------------------------------------------------------
@dataclass
class PressureAnalogSensor(BaseAnalogSensor):
"""
Su hattı basınç sensörü.
State mantığı:
- raw < (min_raw - warn_hyst) veya raw > (max_raw + warn_hyst) → WARN
- min_raw <= raw <= max_raw → SAFE
- Aradaki buffer bölgede state korunur.
"""
min_raw: int = field(default=200)
max_raw: int = field(default=900)
warn_hyst: int = field(default=20)
state: str = SafetyState.SAFE
def __post_init__(self) -> None:
super().__post_init__()
if cfg is not None:
self.min_raw = int(getattr(cfg, "PRESSURE_MIN_RAW", self.min_raw))
self.max_raw = int(getattr(cfg, "PRESSURE_MAX_RAW", self.max_raw))
self.warn_hyst = int(getattr(cfg, "PRESSURE_WARN_HYST", self.warn_hyst))
def update(self) -> Optional[int]:
raw = self.read_raw()
if raw is None:
return None
self._evaluate_state(raw)
return raw
def _evaluate_state(self, raw: int) -> None:
if raw < (self.min_raw - self.warn_hyst) or raw > (self.max_raw + self.warn_hyst):
self.state = SafetyState.WARN
elif self.min_raw <= raw <= self.max_raw:
self.state = SafetyState.SAFE
# Buffer bölgede state korunur.
def is_pressure_ok(self) -> bool:
return self.state == SafetyState.SAFE
def summary(self) -> str:
return (
f"PressureAnalogSensor(ch={self.channel}, raw={self.last_raw}, "
f"state={self.state}, min={self.min_raw}, max={self.max_raw})"
)
# -------------------------------------------------------------
# Yağmur sensörü basit dry/wet mantığı
# -------------------------------------------------------------
@dataclass
class RainAnalogSensor(BaseAnalogSensor):
"""
Yağmur sensörü (analog).
Basit model:
- raw <= dry_threshold → DRY
- raw >= wet_threshold → WET
- arası → MID
Gerektiğinde bu sınıf geliştirilebilir (ör. şiddetli yağmur vs.).
"""
dry_threshold: int = field(default=100)
wet_threshold: int = field(default=400)
state: str = "UNKNOWN" # "DRY", "MID", "WET"
def __post_init__(self) -> None:
super().__post_init__()
# İleride configten override eklenebilir:
if cfg is not None:
self.dry_threshold = int(getattr(cfg, "RAIN_DRY_THRESHOLD_RAW", self.dry_threshold))
self.wet_threshold = int(getattr(cfg, "RAIN_WET_THRESHOLD_RAW", self.wet_threshold))
def update(self) -> Optional[int]:
raw = self.read_raw()
if raw is None:
return None
self._evaluate_state(raw)
return raw
def _evaluate_state(self, raw: int) -> None:
if raw <= self.dry_threshold:
self.state = "DRY"
elif raw >= self.wet_threshold:
self.state = "WET"
else:
self.state = "MID"
def is_raining(self) -> bool:
return self.state == "WET"
def summary(self) -> str:
return (
f"RainAnalogSensor(ch={self.channel}, raw={self.last_raw}, "
f"state={self.state}, dry_th={self.dry_threshold}, wet_th={self.wet_threshold})"
)
# -------------------------------------------------------------
# LDR sensörü ışık seviyesi
# -------------------------------------------------------------
@dataclass
class LDRAnalogSensor(BaseAnalogSensor):
"""
LDR (ışık) sensörü.
Basit model:
- raw <= dark_threshold → DARK
- raw >= bright_threshold → BRIGHT
- arası → MID
Bu bilgi:
- dış ortam karanlık/aydınlık
- gece/gündüz teyidi
- aydınlatma / gösterge kararları
için kullanılabilir.
"""
dark_threshold: int = field(default=200)
bright_threshold: int = field(default=800)
state: str = "UNKNOWN" # "DARK", "MID", "BRIGHT"
def __post_init__(self) -> None:
super().__post_init__()
if cfg is not None:
self.dark_threshold = int(getattr(cfg, "LDR_DARK_THRESHOLD_RAW", self.dark_threshold))
self.bright_threshold = int(getattr(cfg, "LDR_BRIGHT_THRESHOLD_RAW", self.bright_threshold))
def update(self) -> Optional[int]:
raw = self.read_raw()
if raw is None:
return None
self._evaluate_state(raw)
return raw
def _evaluate_state(self, raw: int) -> None:
if raw <= self.dark_threshold:
self.state = "DARK"
elif raw >= self.bright_threshold:
self.state = "BRIGHT"
else:
self.state = "MID"
def is_dark(self) -> bool:
return self.state == "DARK"
def is_bright(self) -> bool:
return self.state == "BRIGHT"
def summary(self) -> str:
return (
f"LDRAnalogSensor(ch={self.channel}, raw={self.last_raw}, "
f"state={self.state}, dark_th={self.dark_threshold}, bright_th={self.bright_threshold})"
)
# -------------------------------------------------------------
# Tümünü toplayan minik hub (opsiyonel)
# -------------------------------------------------------------
class AnalogSensorsHub:
"""
MCP3008 üstündeki tüm analog sensörleri yöneten yardımcı sınıf.
- pressure : PressureAnalogSensor
- gas : GasAnalogSensor
- rain : RainAnalogSensor
- ldr : LDRAnalogSensor
"""
def __init__(self, adc: MCP3008ADC) -> None:
self.adc = adc
self.pressure = PressureAnalogSensor(adc=self.adc, name="pressure")
self.gas = GasAnalogSensor(adc=self.adc, name="gas")
self.rain = RainAnalogSensor(adc=self.adc, name="rain")
self.ldr = LDRAnalogSensor(adc=self.adc, name="ldr")
def update_all(self) -> Dict[str, Optional[int]]:
"""
Tüm sensörleri günceller ve ham değerleri döndürür.
"""
return {
"pressure": self.pressure.update(),
"gas": self.gas.update(),
"rain": self.rain.update(),
"ldr": self.ldr.update(),
}
def should_shutdown_system(self) -> bool:
"""
Gaz sensörü açısından kill-switch gerekip gerekmediğini söyler.
"""
return self.gas.should_shutdown_system()

333
ebuild/io/config_ini.py Normal file
View File

@@ -0,0 +1,333 @@
# -*- coding: utf-8 -*-
__title__ = "config_ini"
__author__ = 'Mehmet Karatay & "Saraswati" (ChatGPT)'
__purpose__ = "INI tabanlı konfigürasyon ve değer depolama yardımcıları"
__version__ = "0.2.0"
__date__ = "2025-11-20"
"""
ebuild/io/config_ini.py
Revision : 2025-11-20
Authors : Mehmet Karatay & "Saraswati" (ChatGPT)
Amaç
-----
Eski EdmConfig yapısının modernize edilmiş, hatalardan arındırılmış ama
aynı API'yi koruyan sürümü.
Bu modül:
- INI dosyalarını ConfigParser ile yönetir
- Eksik section/item gördüğünde otomatik oluşturur
- Dosya değişimini (mtime) izleyerek reload imkanı verir
- Basit bir kilit (lock) mekanizması ile aynı dosyaya birden fazla
yazma girişimini sıraya sokar.
Kullanım Örnekleri
------------------
- EdmConfig:
cfg = EdmConfig("/home/karatay/ebuild/config.ini")
port = cfg.item("serial", "ttyUSB0", "/dev/ttyUSB0")
- ConfigDict:
section = cfg.get_section("serial")
tty0 = section.get_item("ttyUSB0")
- KilitliDosya:
log = KilitliDosya("/var/log/ebuild.log")
log.yaz("merhaba dünya\\n")
"""
import os
import time
from configparser import ConfigParser
import traceback as tb # eski davranışla uyum için bırakıldı
# ---------------------------------------------------------------------------
# ConfigDict tek bir section'ın sözlük hali
# ---------------------------------------------------------------------------
class ConfigDict:
"""
Bir INI dosyasındaki tek bir section'ı sözlük benzeri interface ile
kullanmaya yarar.
- Anahtar yoksa, otomatik olarak section içine eklenir ve boş string
ile başlatılır.
- Değerler her zaman string olarak saklanır.
"""
def __init__(self, cfgfile: str, section_name: str):
self.cfgfile = cfgfile
self.section_name = section_name
self.cfg = ConfigParser()
self.cfg.read(cfgfile)
if not self.cfg.has_section(section_name):
self.cfg.add_section(section_name)
self._write()
self.section = dict(self.cfg.items(section_name))
def _write(self) -> None:
"""INI dosyasını diske yazar."""
with open(self.cfgfile, "w") as f:
self.cfg.write(f)
def get_item(self, item_name: str) -> str:
"""
Section içindeki bir itemı döndürür.
Yoksa item'i oluşturur, boş string ile başlatır.
"""
try:
return self.section[item_name]
except KeyError:
# Yoksa ekle
self.cfg.set(self.section_name, item_name, "")
self._write()
# Yeniden oku
self.cfg.read(self.cfgfile)
self.section = dict(self.cfg.items(self.section_name))
return self.section.get(item_name, "")
def set_item(self, item_name: str, value) -> None:
"""
Section içindeki bir itemın değerini günceller (string'e çevirerek).
"""
value = str(value)
self.cfg.set(self.section_name, item_name, value)
self._write()
self.section[item_name] = value
# ---------------------------------------------------------------------------
# EdmConfig bir INI dosyasını yöneten üst sınıf
# ---------------------------------------------------------------------------
class EdmConfig:
"""
Tek bir INI dosyası için üst seviye sarmalayıcı.
Özellikler:
- Dosya yoksa otomatik oluşturur.
- item(section, name, default) ile değer okur; yoksa default yazar.
- reload() ile mtime kontrolü yaparak dosya değişimini algılar.
"""
def __init__(self, cfg_file_name: str):
self.fname = cfg_file_name
# Dosya yoksa oluştur
if not os.path.isfile(cfg_file_name):
with open(cfg_file_name, "w") as f:
f.write("")
self.cfg = ConfigParser()
self.cfg.read(cfg_file_name)
# İlk yükleme zamanı
try:
self.originalTime = os.path.getmtime(cfg_file_name)
except OSError:
self.originalTime = None
# ---------------------------
# Reload mekanizması
# ---------------------------
def get_loadtime(self) -> float:
"""Ini dosyasının son yüklenme zamanını (mtime) döndürür."""
return self.originalTime
def reload(self) -> bool:
"""
Dosya değişmişse yeniden okur, True döner.
Değişmemişse False döner.
"""
if self.fname is None:
return False
try:
current = os.path.getmtime(self.fname)
except FileNotFoundError:
return False
if self.originalTime is None or current > self.originalTime:
self.cfg.read(self.fname)
self.originalTime = current
return True
return False
# ---------------------------
# Section & item işlemleri
# ---------------------------
def add_section(self, section: str) -> None:
"""Yeni bir section ekler (yoksa)."""
if not self.cfg.has_section(section):
self.cfg.add_section(section)
self._write()
def add_item(self, section: str, item: str, value: str = "") -> None:
"""
İlgili section yoksa oluşturur, item yoksa ekler ve default
değerini yazar.
"""
if not self.cfg.has_section(section):
self.add_section(section)
if not self.cfg.has_option(section, item):
self.cfg.set(section, item, str(value))
self._write()
def set_item(self, section: str, name: str, value="") -> None:
"""
Belirli bir section/key için değeri ayarlar (string'e çevirir).
"""
self.add_section(section)
self.cfg.set(section, name, str(value))
self._write()
def item(self, section: str, name: str, default="") -> str:
"""
INI'den item okur; yoksa veya boşsa default değeri yazar ve onu döndürür.
eski davranışla uyumlu: her zaman string döner.
"""
try:
val = self.cfg.get(section, name).strip()
if val == "":
self.set_item(section, name, default)
return str(default)
return val
except Exception:
# Eski koddaki gibi stack trace istersen:
# print(tb.format_exc())
self.add_item(section, name, default)
return str(default)
def get_items(self, section: str) -> dict:
"""Verilen section'daki tüm key/value çiftlerini dict olarak döndürür."""
return dict(self.cfg.items(section))
def get_section(self, section_name: str) -> ConfigDict:
"""
Verilen section için ConfigDict nesnesi döndürür.
Section yoksa oluşturur.
"""
if not self.cfg.has_section(section_name):
self.cfg.add_section(section_name)
self._write()
return ConfigDict(self.fname, section_name)
def get_section_names(self):
"""INI içindeki tüm section isimlerini döndürür."""
return self.cfg.sections()
def get_key_names(self, section_name: str = ""):
"""
section_name verilirse o section altındaki key listesi,
verilmezse her section için key listelerini döndürür.
"""
if section_name:
if not self.cfg.has_section(section_name):
return []
return list(self.cfg[section_name].keys())
return {s: list(self.cfg[s].keys()) for s in self.cfg.sections()}
def get_section_values(self, section_name: str = ""):
"""
section_name verilirse o section'ın dict halini,
verilmezse tüm section'ların dict halini döndürür.
"""
if section_name:
if not self.cfg.has_section(section_name):
return {}
return dict(self.cfg.items(section_name))
return {s: dict(self.cfg.items(s)) for s in self.cfg.sections()}
def _write(self) -> None:
"""Ini dosyasını diske yazar."""
with open(self.fname, "w") as f:
self.cfg.write(f)
# ---------------------------------------------------------------------------
# KilitliDosya çok basit file lock mekanizması
# ---------------------------------------------------------------------------
class KilitliDosya:
"""
Çok basit bir dosya kilit mekanizması.
Aynı dosyaya birden fazla sürecin/yazmanın çakışmasını azaltmak için
`fname.LCK` dosyasını lock olarak kullanır.
"""
def __init__(self, fname: str):
self.fname = fname
def _lockfile(self) -> str:
return f"{self.fname}.LCK"
def kontrol(self) -> bool:
"""Lock dosyası var mı? True/False."""
return os.path.exists(self._lockfile())
def kilitle(self) -> bool:
"""
Lock almaya çalışır. Lock yoksa oluşturur ve True döner.
Varsa False döner.
"""
if not self.kontrol():
with open(self._lockfile(), "w") as f:
f.write(" ")
return True
return False
def kilit_ac(self) -> None:
"""Lock dosyasını kaldırır (yoksa sessizce geçer)."""
try:
os.remove(self._lockfile())
except FileNotFoundError:
pass
def oku(self):
"""Ana dosyayı satır satır okur ve liste döndürür."""
with open(self.fname, "r") as f:
return f.readlines()
def yaz(self, text: str) -> bool:
"""
Dosyaya kilitleyerek ekleme yapar.
Lock alamazsa 10 deneme yapar, her seferinde 0.2s bekler.
"""
for _ in range(10):
if self.kilitle():
try:
with open(self.fname, "a") as f:
f.write(text)
finally:
self.kilit_ac()
return True
time.sleep(0.2)
return False
# ---------------------------------------------------------------------------
# Modül test / örnek kullanım
# ---------------------------------------------------------------------------
if __name__ == "__main__":
# Basit smoke-test
test_ini = "test_config.ini"
cfg = EdmConfig(test_ini)
# Birkaç değer dene
port = cfg.item("serial", "ttyUSB0", "/dev/ttyUSB0")
mode = cfg.item("general", "mode", "auto")
print("serial.ttyUSB0 =", port)
print("general.mode =", mode)
# KilitliDosya testi
log = KilitliDosya("test_log.txt")
log.yaz("Config_ini self test OK\n")

338
ebuild/io/dbtext.py Normal file
View File

@@ -0,0 +1,338 @@
# -*- coding: utf-8 -*-
__title__ = "dbtext"
__author__ = 'Mehmet Karatay & "Saraswati" (ChatGPT)'
__purpose__ = "Sensör ve röle olaylarını metin tabanlı SQL log olarak saklayan yardımcı sınıf"
__version__ = "0.2.0"
__date__ = "2025-11-20"
"""
ebuild/io/dbtext.py
Revision : 2025-11-20
Authors : Mehmet Karatay & "Saraswati" (ChatGPT)
Amaç
-----
Her sensör ve rölenin:
- Ne zaman açıldığı / kapandığı
- Hangi değeri ürettiği
- Hangi kaynaktan geldiği
bilgisini tarih-saat bazlı olarak düz bir metin dosyasında tutmak.
Kayıt formatı (satır başına bir olay):
INSERT INTO <table> (ts, app, source, event_type, value, unit, extra)
VALUES ('YYYY-MM-DD HH:MM:SS', 'APP', 'SOURCE', 'EVENT', VALUE, 'UNIT', 'EXTRA');
Örnek:
INSERT INTO ebrulor_log (ts, app, source, event_type, value, unit, extra)
VALUES ('2025-11-20 12:34:56', 'ESYSTEM', 'relay:circulation_a', 'state', 1, 'bool', 'on');
Böylece:
- Dosya istenirse direkt PostgreSQL'e pipe edilip çalıştırılabilir
- Aynı zamanda bu modül basit bir parser ile geri okunabilir
"""
#from __future__ import annotations
import os
import datetime as _dt
import re
from typing import List, Optional, Dict, Any
from .config_ini import KilitliDosya
class DBText:
""" Metin tabanlı SQL log dosyası için yardımcı sınıf.
Parametreler
-----------
filename : str
Log dosyasının yolu (örnek: "ebina_log.sql").
table : str
SQL INSERT komutlarında kullanılacak tablo adı.
app : str
Uygulama adı (örn. "ESYSTEM").
use_lock : bool
True ise yazarken KilitliDosya kullanılır (çoklu süreç için daha güvenli).
"""
def __init__(
self,
filename: str,
table: str = "ebrulor_log",
app: str = "EBUILD",
use_lock: bool = True,
) -> None:
self.filename = filename
self.table = table
self.app = app
self.use_lock = use_lock
# Dosya yoksa basit bir header ile oluştur
if not os.path.isfile(self.filename):
with open(self.filename, "w", encoding="utf-8") as f:
f.write(f"-- DBText log file for table {self.table}\n")
f.write(f"-- created at {_dt.datetime.now().isoformat()}\n\n")
self._locker = KilitliDosya(self.filename) if use_lock else None
# SQL satırlarını parse etmek için basit regex
self._re_values = re.compile(
r"VALUES \('(?P<ts>[^']*)',\s*'(?P<app>[^']*)',\s*'(?P<source>[^']*)',\s*"
r"'(?P<etype>[^']*)',\s*(?P<value>NULL|[-0-9.]+),\s*(?P<unit>NULL|'[^']*'),\s*"
r"'(?P<extra>[^']*)'\);"
)
# -------------------------------------------------
# Yardımcılar
# -------------------------------------------------
@staticmethod
def _escape(value: str) -> str:
"""SQL için tek tırnak kaçışı yapar."""
return value.replace("'", "''")
def _write_line(self, line: str) -> None:
""" Tek bir satırı log dosyasına yazar.
use_lock=True ise KilitliDosya üzerinden, değilse doğrudan append.
"""
if self.use_lock and self._locker is not None:
self._locker.yaz(line + "\n")
else:
with open(self.filename, "a", encoding="utf-8") as f:
f.write(line + "\n")
# -------------------------------------------------
# Genel amaçlı event yazma
# -------------------------------------------------
def insert_event(
self,
source: str,
event_type: str,
value: Optional[float] = None,
unit: Optional[str] = None,
timestamp: Optional[_dt.datetime] = None,
extra: str = "",
) -> None:
""" Genel amaçlı bir olay kaydı ekler.
Örnek kullanım:
logger.insert_event(
source="Sensor:28-00000660e983",
event_type="temperature",
value=23.5,
unit="°C",
timestamp=datetime.now(),
extra="Daire 2 Kat 1 Yön 5",
)
"""
ts = timestamp or _dt.datetime.now()
ts_str = ts.strftime("%Y-%m-%d %H:%M:%S")
# Değerler
v_str = "NULL" if value is None else f"{float(value):.3f}"
u_str = "NULL" if unit is None else f"'{self._escape(str(unit))}'"
extra_str = self._escape(extra or "")
src_str = self._escape(source)
etype_str = self._escape(event_type)
app_str = self._escape(self.app)
line = (
f"INSERT INTO {self.table} (ts, app, source, event_type, value, unit, extra) "
f"VALUES ('{ts_str}', '{app_str}', '{src_str}', '{etype_str}', {v_str}, {u_str}, '{extra_str}');"
)
self._write_line(line)
# -------------------------------------------------
# Sensör / röle özel kısayol metotları
# -------------------------------------------------
def log_state_change(
self,
device_kind: str,
name: str,
is_on: bool,
timestamp: Optional[_dt.datetime] = None,
extra: str = "",
) -> None:
""" Röle / dijital çıkış / giriş gibi ON/OFF durumlarını loglar.
device_kind : "relay", "sensor", "pump" vb.
name : cihaz ismi ("circulation_a", "burner_contactor" vb.)
is_on : True → 1 (on), False → 0 (off)
Event:
source = f"{device_kind}:{name}"
event_type = "state"
value = 1.0 / 0.0
unit = "bool"
"""
source = f"{device_kind}:{name}"
val = 1.0 if is_on else 0.0
ex = extra or ("on" if is_on else "off")
self.insert_event(
source=source,
event_type="state",
value=val,
unit="bool",
timestamp=timestamp,
extra=ex,
)
def log_sensor_value(
self,
name: str,
value: float,
unit: str = "",
timestamp: Optional[_dt.datetime] = None,
extra: str = "",
) -> None:
""" Analog / sayısal sensör değerlerini loglar.
Örnek:
logger.log_sensor_value("outside_temp", 12.3, "°C")
"""
source = f"sensor:{name}"
self.insert_event(
source=source,
event_type="measurement",
value=value,
unit=unit,
timestamp=timestamp,
extra=extra,
)
# -------------------------------------------------
# Okuma API'si
# -------------------------------------------------
def _parse_line(self, line: str) -> Optional[Dict[str, Any]]:
""" Tek bir INSERT satırını dict'e çevirir.
Beklenen format:
INSERT INTO <table> (...) VALUES ('ts', 'app', 'source', 'etype', value, unit, 'extra');
"""
line = line.strip()
if not line or not line.upper().startswith("INSERT INTO"):
return None
m = self._re_values.search(line)
if not m:
return None
gd = m.groupdict()
ts_str = gd.get("ts", "")
try:
ts = _dt.datetime.strptime(ts_str, "%Y-%m-%d %H:%M:%S")
except Exception:
ts = None
# value
raw_v = gd.get("value", "NULL")
if raw_v == "NULL":
value = None
else:
try:
value = float(raw_v)
except Exception:
value = None
# unit
raw_u = gd.get("unit", "NULL")
if raw_u == "NULL":
unit = None
else:
# 'C' şeklindeki stringten tek tırnakları atıyoruz
unit = raw_u.strip("'")
return {
"ts": ts,
"app": gd.get("app", ""),
"source": gd.get("source", ""),
"event_type": gd.get("etype", ""),
"value": value,
"unit": unit,
"extra": gd.get("extra", ""),
}
def iter_events(
self,
source: Optional[str] = None,
event_type: Optional[str] = None,
since: Optional[_dt.datetime] = None,
until: Optional[_dt.datetime] = None,
):
""" Log dosyasındaki olayları satır satır okur ve filtre uygular.
Parametreler:
source : None veya tam eşleşen source string
event_type : None veya tam eşleşen event_type
since : None veya bu tarihten SONRAKİ kayıtlar
until : None veya bu tarihten ÖNCEKİ kayıtlar
Yield:
dict: {ts, app, source, event_type, value, unit, extra}
"""
if not os.path.isfile(self.filename):
return
with open(self.filename, "r", encoding="utf-8") as f:
for line in f:
rec = self._parse_line(line)
if not rec:
continue
ts = rec["ts"]
if since and ts and ts < since:
continue
if until and ts and ts > until:
continue
if source and rec["source"] != source:
continue
if event_type and rec["event_type"] != event_type:
continue
yield rec
def get_state_history(
self,
device_kind: str,
name: str,
limit: int = 100,
since: Optional[_dt.datetime] = None,
until: Optional[_dt.datetime] = None,
) -> List[Dict[str, Any]]:
""" Belirli bir cihazın (sensör / röle) son durum değişikliklerini döndürür.
device_kind : "relay", "sensor", "pump" vb.
name : cihaz adı
limit : maksimum kaç kayıt döneceği (en yeni kayıtlar)
"""
src = f"{device_kind}:{name}"
events = list(self.iter_events(
source=src,
event_type="state",
since=since,
until=until,
))
# En yeni kayıtlar sondadır; tersten limit al
events.sort(key=lambda r: (r["ts"] or _dt.datetime.min), reverse=True)
return events[:limit]
if __name__ == "__main__":
# Basit self-test
logger = DBText(filename="test_dbtext_log.sql", table="ebrulor_log", app="ESYSTEM")
now = _dt.datetime.now()
logger.log_state_change("relay", "circulation_a", True, timestamp=now, extra="manual test on")
logger.log_state_change("relay", "circulation_a", False, timestamp=now + _dt.timedelta(seconds=10), extra="manual test off")
print("Son durum değişiklikleri:")
history = logger.get_state_history("relay", "circulation_a", limit=10)
for h in history:
print(h)

158
ebuild/io/ds18b20.py Normal file
View File

@@ -0,0 +1,158 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
__title__ = "ds18b20"
__author__ = 'Mehmet Karatay & "Saraswati" (ChatGPT)'
__purpose__ = "DS18B20 1-Wire sıcaklık sensörü sürücüsü"
__version__ = "0.1.0"
__date__ = "2025-11-21"
"""
ebuild/io/ds18b20.py
Revision : 2025-11-21
Authors : Mehmet Karatay & "Saraswati" (ChatGPT)
Amaç
-----
- DS18B20 sensörlerini 1-Wire üzerinden /sys/bus/w1/devices yolundan okuyarak
sıcaklık (°C) bilgisi sağlamak.
- Tek sensör için DS18B20Sensor sınıfı,
- Otomatik cihaz keşfi için DS18B20Bus yardımcı sınıfı sunar.
Notlar
------
- 1-Wire kernel modüllerinin (w1_gpio, w1_therm) yüklü olması gerekir.
- Bu sürücü yalnızca dosya sisteminden okuma yapar; filtreleme, smoothing,
bina/daire eşlemesi gibi işlemler üst katmanlarda yapılır.
"""
import glob
import os
from typing import Dict, List, Optional
class DS18B20Sensor:
"""
Tek bir DS18B20 sensörünü temsil eder.
Özellikler:
-----------
- serial : 1-Wire cihaz id'si (örn: "28-00000660e983")
- base_path : /sys/bus/w1/devices (varsayılan)
- read_temperature() : son sıcaklığı °C cinsinden döndürür
"""
def __init__(
self,
serial: str,
base_path: str = "/sys/bus/w1/devices",
name: Optional[str] = None,
) -> None:
self.serial = serial
self.base_path = base_path
self.device_path = os.path.join(base_path, serial, "w1_slave")
self.name = name or serial
self.is_connected: bool = True
self.error_count: int = 0
self.last_temperature: Optional[float] = None
# ------------------------------------------------------------------
def read_temperature(self) -> Optional[float]:
"""
Sensörden anlık sıcaklık okur (°C).
Dönüş:
- Başarı: float (örn: 23.437)
- Hata: None (error_count artar, is_connected False olur)
"""
try:
with open(self.device_path, "r") as f:
lines = f.readlines()
if not lines:
raise IOError("w1_slave boş okundu")
# İlk satır CRC ve 'YES/NO' bilgisini içerir.
if not lines[0].strip().endswith("YES"):
raise IOError("CRC hatalı veya sensör doğrulanamadı")
# İkinci satırda 't=xxxxx' ifadesini arıyoruz.
pos = lines[1].find("t=")
if pos == -1:
raise ValueError("t= alanı bulunamadı")
raw = lines[1][pos + 2 :].strip()
t_c = float(raw) / 1000.0
self.is_connected = True
self.last_temperature = t_c
return t_c
except Exception:
self.error_count += 1
self.is_connected = False
self.last_temperature = None
return None
# ------------------------------------------------------------------
def exists(self) -> bool:
"""
Cihaz dosyasının mevcut olup olmadığını kontrol eder.
"""
return os.path.exists(self.device_path)
def summary(self) -> str:
"""
Sensör hakkında kısa bir özet döndürür.
"""
status = "OK" if self.is_connected else "ERR"
return f"DS18B20Sensor(name={self.name}, serial={self.serial}, status={status}, errors={self.error_count})"
class DS18B20Bus:
"""
Bir 1-Wire hattı üzerindeki DS18B20 cihazlarını keşfetmek için yardımcı sınıf.
"""
def __init__(self, base_path: str = "/sys/bus/w1/devices") -> None:
self.base_path = base_path
def discover(self) -> List[str]:
"""
Sistemdeki tüm DS18B20 cihazlarının seri numaralarını listeler.
Örnek:
["28-00000660e983", "28-0000066144f9", ...]
"""
pattern = os.path.join(self.base_path, "28-*")
devices = glob.glob(pattern)
serials = [os.path.basename(p) for p in devices]
return serials
def get_sensors(self) -> Dict[str, DS18B20Sensor]:
"""
Otomatik keşif yaparak her seri için bir DS18B20Sensor nesnesi döndürür.
"""
result: Dict[str, DS18B20Sensor] = {}
for serial in self.discover():
result[serial] = DS18B20Sensor(serial=serial, base_path=self.base_path)
return result
# ----------------------------------------------------------------------
# Basit test
# ----------------------------------------------------------------------
if __name__ == "__main__":
bus = DS18B20Bus()
serials = bus.discover()
print("Bulunan DS18B20 cihazları:")
for s in serials:
print(" -", s)
sensors = bus.get_sensors()
for serial, sensor in sensors.items():
t = sensor.read_temperature()
print(f"{serial}: {t} °C ({sensor.summary()})")

454
ebuild/io/edm_db.py Normal file
View File

@@ -0,0 +1,454 @@
# -*- coding: utf-8 -*-
__title__ = "edm_db"
__author__ = 'Mehmet Karatay & "Saraswati" (ChatGPT)'
__purpose__ = "PostgreSQL'e EDM tarzı veri yazan yardımcı sınıf"
__version__ = "0.2.0"
__date__ = "2025-11-20"
"""
edm_db.py
Revision : 2025-11-20
Authors : Mehmet Karatay & "Saraswati" (ChatGPT)
Amaç
-----
Eski Rasp2 tabanlı sistemin PostgreSQL'e veri yazma işlerini üstlenen
yardımcı sınıfın (EdmDB) temizlenmiş, hatalardan arındırılmış ve
okunabilirliği artırılmış sürümü.
Özellikler
----------
- Veritabanı bağlantı parametrelerini edmConfig.conf içinden okur
(section: [database] varsayımıyla).
- Bağlantıyı (opsiyonel olarak) açar; bağlantı yoksa fonksiyonlar
sessizce False döndürebilir veya sadece log dosyasına SQL basabilir.
- Eski koddaki ana fonksiyonlar korunmuştur:
- db_exec()
- avg_head()
- db_write_861(), db_write_861_data(), db_write()
- Diğer SELECT/UPDATE fonksiyonları (read_0861_order, write_0861_order, ...)
Not
---
Aşağıdaki kodda bazı yerlerde güvenlik açısından tavsiye edilen
`parametrized query` kullanımı yerine eski string formatlama
kullanılmıştır; bu modül legacy uyumluluk öncelikli olduğu için
bu haliyle korunmuştur.
"""
import psycopg2 as psql
from datetime import datetime
import edmConfig # Senin eski EdmConfig modülün (conf içinde EdmConfig örneği bekliyoruz)
class EdmDB:
"""
EDM veritabanı yardımcı sınıfı.
- Bağlantı parametrelerini edmConfig.conf üzerinden okur.
Örn. config.ini içinde:
[database]
tcpip = 10.10.2.44
database = edm_10094
user = root
password = system
port = 5432
- db_exec() ile self.sql içinde tutulan komutu çalıştırır.
"""
def __init__(self, ini_name: str = "database", auto_connect: bool = False):
"""
ini_name: config.ini içindeki section ismi (varsayılan: [database])
auto_connect: True verilirse __init__ sırasında PostgreSQL bağlantısı açmayı dener.
"""
self.conf = edmConfig.conf
self.sql = ""
# Bağlantı parametrelerini INI'den okuyoruz
self.w_ip = self.conf.item(ini_name, "tcpip") # host
self.w_db = self.conf.item(ini_name, "database") # db name
self.w_us = self.conf.item(ini_name, "user") # user
self.w_pw = self.conf.item(ini_name, "password") # password
self.w_pt = self.conf.item(ini_name, "port") # port (string)
self.con = None
if auto_connect:
self.connect()
# -------------------------------------------------
# Bağlantı yönetimi
# -------------------------------------------------
def connect(self) -> bool:
"""
PostgreSQL bağlantısını açar.
Başarılıysa True, hata olursa False döner.
"""
try:
self.con = psql.connect(
host=self.w_ip,
user=self.w_us,
password=self.w_pw,
database=self.w_db,
port=int(self.w_pt),
)
self.con.autocommit = True
# print("EdmDB: connection ok") # İstersen açarsın
return True
except Exception as ex:
print("EdmDB: connection error:", ex)
self.con = None
return False
def close(self) -> None:
"""Veritabanı bağlantısını kapatır."""
if self.con is not None:
try:
self.con.close()
except Exception:
pass
finally:
self.con = None
# -------------------------------------------------
# Temel SQL yürütme
# -------------------------------------------------
def db_exec(self) -> bool:
"""
self.sql değişkeninde tutulan komutu çalıştırır.
Bağlantı yoksa:
- Şimdilik sadece True döndürüyoruz (test amaçlı).
Bağlantı varsa:
- execute + commit, hata varsa False döner.
"""
if not self.sql:
return True
if self.con is None:
# Bağlantı yok; legacy davranışa yakın olması için
# burada True döndürüp sadece SQL'i debug amaçlı yazabilirsin.
# print("EdmDB: no connection, sql skipped:", self.sql)
return True
try:
with self.con.cursor() as cr:
cr.execute(self.sql)
return True
except Exception as ex:
print("EdmDB.db_exec ERROR:", ex)
return False
# -------------------------------------------------
# Örnek veri okuma fonksiyonu
# -------------------------------------------------
def avg_head(self):
"""
AVG_HEAT_OUTSIDE tablosundan örnek bir kayıt okur.
Dönüş:
[avg, max, min, saat] şeklinde liste
Eğer okuma yapılamazsa:
[-9990.0, -9999.0, -9999.0, -99]
"""
avg_heat = [-9990.0, -9999.0, -9999.0, -99]
if self.con is None:
return avg_heat
try:
sql = "SELECT avgr, maxr, minr, saatr FROM AVG_HEAT_OUTSIDE WHERE saatr = 2;"
with self.con.cursor() as cr:
cr.execute(sql)
row = cr.fetchone()
if row:
avg_heat[0] = row[0]
avg_heat[1] = row[1]
avg_heat[2] = row[2]
avg_heat[3] = row[3]
except Exception as ex:
print("EdmDB.avg_head ERROR:", ex)
return avg_heat
# -------------------------------------------------
# Eski sistem fonksiyonları (istatistik / görev takibi)
# -------------------------------------------------
def old_datas(self):
"""
edm_0861_data_brulor_percent tablosundan eski verileri okur.
"""
if self.con is None:
return []
sql = "SELECT endusuk, enfazla, toplam_harcama, toplam_sure, oran FROM public.edm_0861_data_brulor_percent"
try:
with self.con.cursor() as cr:
cr.execute(sql)
return cr.fetchall()
except Exception as ex:
print("EdmDB.old_datas ERROR:", ex)
return []
def old_values(self):
"""
edm_0861_data_start_stop_brulor tablosundan, bugüne ait bazı
start/stop verilerini okur.
"""
if self.con is None:
return []
sql = (
"SELECT createdate, prev_createdate, elpsetime "
"FROM edm_0861_data_start_stop_brulor "
"WHERE createdate > current_date "
"AND sensor_value = 1 "
"ORDER BY 1"
)
try:
with self.con.cursor() as cr:
cr.execute(sql)
return cr.fetchall()
except Exception as ex:
print("EdmDB.old_values ERROR:", ex)
return []
def read_0861_order(self, xfunc_group="0", xfunc_sub_item="0"):
"""
edm_0861_orders tablosundan çalışmaya hazır (exec_status=0) kayıtları okur.
"""
if self.con is None:
return []
sql = (
"SELECT exec_status, uniqueid, func_group, func_sub_item, roleid, "
" work_minute, param_count, startdate, stopdate, "
" (work_minute * 4) - 0 = param_count as mstatus "
"FROM public.edm_0861_orders "
"WHERE exec_status = 0 "
" AND licenseid = 10094 "
" AND activeid = true "
" AND func_group = '%s' "
" AND current_timestamp < stopdate "
" AND startdate < current_timestamp "
" AND func_sub_item = '%s' "
"ORDER BY startdate;"
) % (xfunc_group, xfunc_sub_item)
try:
with self.con.cursor() as cr:
cr.execute(sql)
return cr.fetchall()
except Exception as ex:
print("EdmDB.read_0861_order ERROR:", ex)
return []
def write_0861_order(self, uid):
"""
edm_0861_orders tablosunda param_count değerini 1 artırır.
"""
if self.con is None:
return
sql = (
"UPDATE public.edm_0861_orders "
"SET param_count = param_count + 1 "
"WHERE exec_status = 0 "
" AND licenseid = 10094 "
" AND activeid = true "
" AND uniqueid = '%s' "
" AND param_count < (work_minute * 4) "
" AND current_timestamp < stopdate;"
) % uid
try:
with self.con.cursor() as cr:
cr.execute(sql)
except Exception as ex:
print("EdmDB.write_0861_order ERROR:", ex)
def close_0861_order(self, uid):
"""
Belirli bir order'ı exec_status=5 yaparak kapatır.
"""
if self.con is None:
return
sql = (
"UPDATE public.edm_0861_orders "
"SET exec_status = 5, stopdate = current_timestamp "
"WHERE exec_status = 0 "
" AND licenseid = 10094 "
" AND activeid = true "
" AND uniqueid = '%s';"
) % uid
try:
with self.con.cursor() as cr:
cr.execute(sql)
except Exception as ex:
print("EdmDB.close_0861_order ERROR:", ex)
def update_0861_order(self, uid):
"""
Verilen uniqueid için startdate/stopdate'i günceller ve
yeni startdate'i döndürür.
"""
if self.con is None:
return None
try:
sql = (
"UPDATE public.edm_0861_orders "
"SET startdate = current_timestamp, "
" stopdate = current_timestamp + INTERVAL '2 day' "
"WHERE exec_status = 0 AND uniqueid = %d"
) % int(uid)
with self.con.cursor() as cr:
cr.execute(sql)
sql = (
"SELECT startdate as mstatus "
"FROM public.edm_0861_orders "
"WHERE exec_status = 0 AND uniqueid = %d"
) % int(uid)
with self.con.cursor() as cr:
cr.execute(sql)
rows = cr.fetchall()
for row in rows:
return row[0]
except Exception as ex:
print("EdmDB.update_0861_order ERROR:", ex)
return None
# -------------------------------------------------
# 0861 / 0861_data yazma fonksiyonları
# -------------------------------------------------
def db_write_861(self, licenseid, siteid, locationid, device_group, device_code, device_value):
"""
edm_0861 tablosuna temel bir kayıt ekler.
NOT: device_value burada sadece varlık için kullanılıyor;
asıl anlık değerler 0861_data tablosuna yazılıyor.
"""
self.sql = (
"INSERT INTO public.edm_0861("
"licenseid, siteid, locationid, hardware_type, "
"hardware_model_code, hardwareuniquecode, "
"hardwarejobcode, hardwarecomment, jobcode"
") VALUES ('%s','%s','%s','%s','%s','%s','%s','%s','%s')"
) % (
licenseid,
siteid,
locationid,
"D", # hardware_type
device_group, # hardware_model_code
device_code, # hardwareuniquecode
device_code, # hardwarejobcode
device_code, # hardwarecomment
device_code, # jobcode
)
if self.db_exec():
return True
return False
def get_edm_0861(self, licenseid, siteid, locationid, device_code):
"""
İlgili cihaz için aktif edm_0861 kaydının uniqueid'sini döndürür.
Bağlantı yoksa veya kayıt bulunamazsa 0 döner.
NOT: Eski koddaki "yoksa oluştur sonra tekrar ara" davranışı
burada yorum satırı olarak bırakıldı; istersen geri açarsın.
"""
if self.con is None:
return 0
sql = (
"SELECT uniqueid "
"FROM public.edm_0861 "
"WHERE licenseid = '%s' "
" AND siteid = '%s' "
" AND locationid = '%s' "
" AND NOW() BETWEEN startdate AND stopdate "
" AND activeid = True "
" AND deleteid = False "
" AND hardwarejobcode = '%s'"
) % (licenseid, siteid, locationid, device_code)
try:
with self.con.cursor() as cr:
cr.execute(sql)
rows = cr.fetchall()
for row in rows:
return row[0]
except Exception as ex:
print("EdmDB.get_edm_0861 ERROR:", ex)
# Eski davranış: kayıt yoksa oluşturmayı denerdi.
# İstersen buraya geri koyabilirsin.
return 0
def db_write_861_data(self, licenseid, siteid, locationid, device_group, device_code, device_value):
"""
edm_0861_data tablosuna cihaz verisi (sensor_value) yazar.
Bağlantı yoksa SQL'i LOG_device_group.log dosyasına basar.
"""
xdevice_code = "%s" % (device_code)
device_str = ""
# Değer tipini normalize et
if isinstance(device_value, (float, int)):
numeric_value = float(device_value)
else:
device_str = str(device_value)
numeric_value = 0.0
self.sql = (
"INSERT INTO public.edm_0861_data("
"licenseid, uniqueid, sensor_value, init_value"
") VALUES ('%s','%s','%f','%s')"
) % (licenseid, xdevice_code, numeric_value, 0)
# LOG_DEVICEGROUP.log dosyasına da yaz
fname = "LOG_%s.log" % (device_group)
fsql = "%s:%s\n" % (datetime.now(), self.sql)
try:
with open(fname, "a") as file_object:
file_object.write(fsql)
except Exception as ex:
print("EdmDB.db_write_861_data LOG ERROR:", ex)
if self.db_exec():
return True
# DB yazılamadıysa, fallback olarak edm_0861 kaydı oluşturmaya çalış
return self.db_write_861(licenseid, siteid, locationid, device_group, device_code, device_value)
def db_write(self, licenseid, siteid, locationid, device_group, device_code, device_value):
"""
0861_data'ya yazmayı 3 kez dener.
Hata alma durumunda db_write_861_data içindeki fallback devreye girer.
"""
result = False
i = 0
while not result and i < 3:
i += 1
result = self.db_write_861_data(
licenseid, siteid, locationid,
device_group, device_code, device_value
)
if __name__ == "__main__":
# Basit bir smoke-test
db = EdmDB(auto_connect=False) # Bağlanmadan da oluşturulabilir
print("EdmDB instance created. Host:", db.w_ip, "DB:", db.w_db)

453
ebuild/io/legacy_syslog.py Normal file
View File

@@ -0,0 +1,453 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
__title__ = "legacy_syslog"
__author__ = 'Mehmet Karatay & "Saraswati" (ChatGPT)'
__purpose__ = "Legacy tarzı syslog çıktısı üreten köprü"
__version__ = "0.2.1"
__date__ = "2025-11-22"
"""
ebuild/io/legacy_syslog.py
Revision : 2025-11-22
Authors : Mehmet Karatay & "Saraswati" (ChatGPT)
Amaç
-----
- Eski /brulor.py'nin syslog formatına yakın satırlar üretmek.
- Python logging + SysLogHandler kullanarak:
program adı: BRULOR
mesaj : "[ 1 .... ]" formatında
Bu modül:
- send_legacy_syslog(message) → tek satır yazar
- emit_top_block(now, SeasonController) →
1) versiyon satırı
2) güneş (sunrise/sunset) + sistem/licence
3) mevsim + bahar dönemi + tatil satırları
- log_burner_header(...) → BurnerController.tick() için üst blok +
sistem ısı + motor bilgilerini basar.
"""
from datetime import datetime, time, timedelta, date
from typing import Optional
import logging
import logging.handlers
try:
# SeasonController ve konfig
from ..core.season import SeasonController
from .. import config_statics as cfg
from .. import config_runtime as cfg_v
except ImportError: # test / standalone
SeasonController = None # type: ignore
cfg = None # type: ignore
cfg_v = None # type: ignore
# ----------------------------------------------------------------------
# Logger kurulumu (Syslog + stdout)
# ----------------------------------------------------------------------
_LOGGER: Optional[logging.Logger] = None
def _get_logger() -> logging.Logger:
global _LOGGER
if _LOGGER is not None:
return _LOGGER
logger = logging.getLogger("BRULOR")
logger.setLevel(logging.INFO)
# Aynı handler'ları ikinci kez eklemeyelim
if not logger.handlers:
# Syslog handler (Linux: /dev/log)
try:
syslog_handler = logging.handlers.SysLogHandler(address="/dev/log")
# Syslog mesaj formatı: "BRULOR: [ 1 ... ]"
fmt = logging.Formatter("%(name)s: %(message)s")
syslog_handler.setFormatter(fmt)
logger.addHandler(syslog_handler)
except Exception:
# /dev/log yoksa sessizce geç; sadece stdout'a yazacağız
pass
# Konsol çıktısı (debug için)
stream_handler = logging.StreamHandler()
stream_fmt = logging.Formatter("INFO:BRULOR:%(message)s")
stream_handler.setFormatter(stream_fmt)
logger.addHandler(stream_handler)
_LOGGER = logger
return logger
# ----------------------------------------------------------------------
# Temel çıktı fonksiyonları
# ----------------------------------------------------------------------
def send_legacy_syslog(message: str) -> None:
"""
Verilen mesajı legacy syslog formatına uygun şekilde ilgili hedefe gönderir.
- Syslog (/dev/log) → program adı: BRULOR
- Aynı zamanda stdout'a da yazar (DEBUG amaçlı)
"""
try:
logger = _get_logger()
logger.info(message)
except Exception as e:
# Logger bir sebeple çökerse bile BRULOR satırını kaybetmeyelim
print("BRULOR:", message, f"(logger error: {e})")
def format_line(line_no: int, body: str) -> str:
"""
BRULOR satırını klasik formata göre hazırlar.
Örnek:
line_no = 2, body = "Sunrise:07:39 Sunset:17:29 Sistem: On Lic:10094"
"[ 2 Sunrise:07:39 Sunset:17:29 Sistem: On Lic:10094]"
Not:
Burada "BRULOR" yazmıyoruz; syslog program adı zaten BRULOR olacak.
"""
return f"[{line_no:3d} {body}]"
def _format_version_3part(ver: str) -> str:
"""
__version__ string'ini "00.02.01" formatına çevirir.
Örnek:
"0.2.1""00.02.01"
"""
parts = (ver or "").split(".")
nums = []
for p in parts:
try:
nums.append(int(p))
except ValueError:
nums.append(0)
while len(nums) < 3:
nums.append(0)
return f"{nums[0]:02d}.{nums[1]:02d}.{nums[2]:02d}"
# ----------------------------------------------------------------------
# Üst blok: versiyon + güneş + mevsim + tatil
# ----------------------------------------------------------------------
def emit_header_version(line_no: int, now: datetime) -> int:
"""
1. satır: Versiyon ve zaman bilgisi.
Örnek:
[ 1 ************** 00.02.01 2025-11-22 22:20:19 *************]
"""
ver = _format_version_3part(__version__)
ts = now.strftime("%Y-%m-%d %H:%M:%S")
body = f"************** {ver} {ts} *************"
send_legacy_syslog(format_line(line_no, body))
return line_no + 1
def emit_header_sunrise_sunset(
line_no: int,
sunrise: Optional[time],
sunset: Optional[time],
system_on: bool,
licence_id: int,
) -> int:
"""
2. satır: Güneş bilgisi + Sistem On/Off + Lisans id.
Örnek:
[ 2 Sunrise:07:39 Sunset:17:29 Sistem: On Lic:10094]
"""
sun_str = ""
if sunrise is not None:
sun_str += f"Sunrise:{sunrise.strftime('%H:%M')} "
if sunset is not None:
sun_str += f"Sunset:{sunset.strftime('%H:%M')} "
sys_str = "On" if system_on else "Off"
body = f"{sun_str}Sistem: {sys_str} Lic:{licence_id}"
send_legacy_syslog(format_line(line_no, body))
return line_no + 1
def _normalize_iso_date(s: Optional[str]) -> str:
"""
SeasonInfo.season_start / season_end alanlarını sadeleştirir.
Örn: '2025-09-23T16:33:10.687982+03:00''2025-09-23'
"""
if not s:
return "--"
s = s.strip()
if "T" in s:
return s.split("T", 1)[0]
return s
def emit_header_season(
line_no: int,
season_ctrl: SeasonController,
) -> int:
"""
Sunrise satırının altına mevsim + (varsa) bahar tasarruf dönemi satırını basar.
Beklenen format:
BRULOR [ 3 season : Sonbahar 2025-09-23 - 2025-12-20 [89 pass:60 kalan:28] ]
BRULOR [ 4 bahar : 2025-09-23 - 2025-10-13 ]
Notlar:
- Bilgiler SeasonController.info içinden okunur (dict veya obje olabilir).
- bahar_tasarruf True DEĞİLSE bahar satırı hiç basılmaz.
"""
if season_ctrl is None:
return line_no
info = getattr(season_ctrl, "info", None)
if info is None:
return line_no
# dataclass benzeri objeden alanları çek
season = getattr(info, "season", "Unknown")
s_start = _normalize_iso_date(getattr(info, "season_start", ""))
s_end = _normalize_iso_date(getattr(info, "season_end", ""))
s_day = int(getattr(info, "season_day", 0) or 0)
s_pass = int(getattr(info, "season_passed", 0) or 0)
s_rem = int(getattr(info, "season_remaining", 0) or 0)
body = (
f"season : {season} {s_start} - {s_end} "
f"[{s_day} pass:{s_pass} kalan:{s_rem}]"
)
send_legacy_syslog(format_line(line_no, body))
line_no += 1
# Bahar / tasarruf dönemi bilgileri
is_season = bool(getattr(info, "is_season", False))
saving_start = getattr(info, "saving_start", None)
saving_stop = getattr(info, "saving_stop", None)
if is_season and isinstance(saving_start, date) and isinstance(saving_stop, date):
# Kullanıcı isteği: öncesi/sonrası 3'er gün göster
show_start = saving_start - timedelta(days=3)
show_stop = saving_stop + timedelta(days=3)
body = (
f"bahar : {show_start.isoformat()} - {show_stop.isoformat()}"
)
send_legacy_syslog(format_line(line_no, body))
line_no += 1
# Eğer resmi tatil bilgisi de varsa opsiyonel satır
is_holiday = bool(getattr(info, "is_holiday", False))
holiday_label = getattr(info, "holiday_label", "")
if not is_holiday:
return line_no
label = holiday_label or ""
body = f"Tatil: True Adı: {label}"
send_legacy_syslog(format_line(line_no, body))
return line_no + 1
# ----------------------------------------------------------------------
# Dışarıdan çağrılacak üst-blok helper
# ----------------------------------------------------------------------
def emit_top_block(
now: datetime,
season_ctrl: SeasonController,
) -> int:
"""
F veya B modundan bağımsız olarak, her tick başında üst bilgiyi üretir.
Sıra:
1) Versiyon + zaman
2) Sunrise / Sunset / Sistem: On/Off / Lic
3) Mevsim bilgisi (SeasonController.to_syslog_lines() → sadeleştirilmiş)
4) Tatil bilgisi (sadece tatil varsa)
5) Bir sonraki satır numarasını döndürür (bina ısı satırları için).
"""
line_no = 1
# 1) Versiyon
line_no = emit_header_version(line_no, now)
# Konfigten sistem ve lisans bilgileri
if cfg is not None:
licence_id = int(getattr(cfg, "BUILDING_LICENCEID", 0))
system_onoff = int(getattr(cfg, "BUILDING_SYSTEMONOFF", 1))
else:
licence_id = 0
system_onoff = 1
# SeasonController.info'dan sunrise/sunset okumayı dene
sunrise = None
sunset = None
if season_ctrl is not None:
info = getattr(season_ctrl, "info", None)
if info is not None:
sunrise = getattr(info, "sunrise", None)
sunset = getattr(info, "sunset", None)
line_no = emit_header_sunrise_sunset(
line_no=line_no,
sunrise=sunrise,
sunset=sunset,
system_on=bool(system_onoff),
licence_id=licence_id,
)
# Mevsim + bahar dönemi
line_no = emit_header_season(line_no, season_ctrl)
# Sonraki satır: bina ısı / dış ısı / F-B detayları için kullanılacak
return line_no
# ----------------------------------------------------------------------
# BurnerController entegrasyonu
# ----------------------------------------------------------------------
def _fmt_c(val: Optional[float]) -> str:
"""Dereceyi 'None°C' veya '23.4°C' gibi tek tip formatlar."""
if val is None:
return "None°C"
try:
return f"{float(val):.2f}°C"
except Exception:
return "None°C"
def log_burner_header(
now: datetime,
mode: str,
season,
building_avg: Optional[float],
outside_c: Optional[float],
used_out_c: Optional[float],
fire_sp: float,
burner_on: bool,
pumps_on,
) -> None:
"""BurnerController.tick() için tek entry-point.
Buradan:
1) Versiyon + güneş + mevsim / bahar / tatil blokları
2) Bina / sistem ısı bilgisi
3) Brülör ve devirdaim motor satırları
syslog'a basılır.
"""
# 1) Üst blok
try:
line_no = emit_top_block(now, season)
except Exception as exc:
# Üst blok patlasa bile alttakileri basalım ki log tamamen kaybolmasın
send_legacy_syslog(format_line(1, f"emit_top_block error: {exc}"))
line_no = 2
# 2) Bina/sistem ısı satırı
try:
# Çalışma modu: F (dış ısı) / B (bina ort)
cfg_mode = getattr(cfg, "BUILD_BURNER", "F") if cfg is not None else "F"
mode_cfg = str(cfg_mode).upper()
# Isı limiti (dış ısı limiti)
limit = None
if cfg_v is not None:
try:
limit = float(getattr(cfg_v, "OUTSIDE_HEAT_LIMIT_C", 0.0))
except Exception:
limit = None
body = (
f"Build [{mode}-{mode_cfg}] "
f"Heats[Min:{_fmt_c(None)} Avg:{_fmt_c(building_avg)} Max:{_fmt_c(None)}]"
)
if limit is not None:
# Son köşeli parantezi atmamak için ufak hack
body = body[:-1] + f" L:{limit:.1f}]"
send_legacy_syslog(format_line(line_no, body))
line_no += 1
# Eski formata yakın "Sistem Isı" satırı
w_boost = 0.0
c_off = 0.0
if cfg_v is not None:
try:
w_boost = float(getattr(cfg_v, "WEEKEND_HEAT_BOOST_C", 0.0))
except Exception:
pass
try:
c_off = float(getattr(cfg_v, "BURNER_COMFORT_OFFSET_C", 0.0))
except Exception:
pass
body = f"Sistem Isı : {_fmt_c(used_out_c)} [w:{int(w_boost)} c:{int(c_off)}]"
send_legacy_syslog(format_line(line_no, body))
line_no += 1
# 3) Brülör motor satırı
br_state = "<CALISIYOR>" if burner_on else "<CALISMIYOR>"
br_flag = 1 if burner_on else 0
body = (
f"Brulor Motor : {br_state} [{br_flag}] 0 "
f"00:00:00 00:00:00 L:{fire_sp:.1f}"
)
send_legacy_syslog(format_line(line_no, body))
line_no += 1
# 4) Devirdaim pompa satırı
pumps_on = tuple(pumps_on or ())
pump_state = "<CALISIYOR>" if pumps_on else "<CALISMIYOR>"
pump_flag = 1 if pumps_on else 0
pump_label = pumps_on[0] if pumps_on else "-"
circ_limit = None
if cfg_v is not None:
try:
circ_limit = float(getattr(cfg_v, "CIRCULATION_MIN_RETURN_C", 25.0))
except Exception:
circ_limit = None
if circ_limit is None:
body = (
f"Devirdaim Mot: {pump_state} [{pump_flag}] 0 "
f"00:00:00 00:00:00 L:{pump_label}"
)
else:
body = (
f"Devirdaim Mot: {pump_state} [{pump_flag}] 0 "
f"00:00:00 00:00:00 L:{pump_label} {circ_limit:.1f}"
)
send_legacy_syslog(format_line(line_no, body))
except Exception as exc:
# Her türlü hata durumunda sessiz kalmak yerine loga yaz
send_legacy_syslog(format_line(line_no, f"log_burner_header error: {exc}"))
# ----------------------------------------------------------------------
# Örnek kullanım (standalone test)
# ----------------------------------------------------------------------
if __name__ == "__main__":
# Bu blok sadece modülü tek başına test etmek için:
# python3 -m ebuild.io.legacy_syslog
if SeasonController is None:
raise SystemExit("SeasonController import edilemedi (test ortamı).")
now = datetime.now()
# SeasonController.from_now() kullanıyorsan:
try:
season = SeasonController.from_now()
except Exception as e:
raise SystemExit(f"SeasonController.from_now() hata: {e}")
next_line = emit_top_block(now, season)
# Test için bina ısısını dummy bas:
body = "Bina Isı : [ 20.10 - 22.30 - 24.50 ]"
send_legacy_syslog(format_line(next_line, body))

376
ebuild/io/relay_driver.py Normal file
View File

@@ -0,0 +1,376 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
__title__ = "relay_driver"
__author__ = 'Mehmet Karatay & "Saraswati" (ChatGPT)'
__purpose__ = "GPIO röle sürücüsü + brülör grup soyutlaması"
__version__ = "0.4.0"
__date__ = "2025-11-22"
"""
ebuild/io/relay_driver.py
Revision : 2025-11-22
Authors : Mehmet Karatay & "Saraswati" (ChatGPT)
Amaç
-----
- Raspberry Pi GPIO üzerinden röle sürmek için basit bir soyutlama.
- Soyut kanal isimleri (ör: "igniter", "circulation_a") → BCM pin eşlemesi
config_statics.RELAY_GPIO üzerinden gelir.
- Brülör grupları için BURNER_GROUPS kullanılır:
BURNER_GROUPS = {
0: {
"name": "MainBurner",
"location": "Sol binada",
"igniter_pin": 16,
"circulation": {
"circ_1": {"channel": "circulation_a", "pin": 26, "default": 1},
"circ_2": {"channel": "circulation_b", "pin": 24, "default": 0},
},
},
...
}
Bu modül:
- Tek tek kanal ON/OFF (set_channel)
- Tüm kanalları kapatma (all_off)
- Brülör → igniter kanalını ve pompalarını soyutlayan yardımcılar
- Kanal bazlı basit istatistik (RelayStats) sağlar.
"""
import time
from dataclasses import dataclass
from typing import Dict, Iterable, List, Optional
try:
from .. import config_statics as cfg
except ImportError: # test / standalone
cfg = None # type: ignore
# ----------------------------------------------------------------------
# GPIO soyutlama (RPi.GPIO yoksa dummy)
# ----------------------------------------------------------------------
try:
import RPi.GPIO as GPIO # type: ignore
_HAS_GPIO = True
except Exception: # Raspi dışı ortam
GPIO = None # type: ignore
_HAS_GPIO = False
# ----------------------------------------------------------------------
# İstatistik yapısı
# ----------------------------------------------------------------------
@dataclass
class RelayStats:
"""
Tek bir röle kanalı için istatistikler.
- on_count : kaç defa ON'a çekildi
- last_on_ts : en son ON'a çekildiği zaman (epoch saniye)
- last_off_ts : en son OFF olduğu zaman (epoch saniye)
- last_duration_s : en son ON periyodunun süresi (saniye)
- total_on_s : bugüne kadar toplam ON kalma süresi (saniye)
"""
on_count: int = 0
last_on_ts: Optional[float] = None
last_off_ts: Optional[float] = None
last_duration_s: float = 0.0
total_on_s: float = 0.0
def on(self, now: float) -> None:
"""
Kanal ON'a çekildiğinde çağrılır.
Aynı ON periyodu içinde tekrar çağrılırsa sayaç artmaz.
"""
if self.last_on_ts is None:
self.last_on_ts = now
self.on_count += 1
def off(self, now: float) -> None:
"""
Kanal OFF'a çekildiğinde çağrılır.
Son ON zamanına göre süre hesaplanır, last_duration_s ve total_on_s güncellenir.
"""
if self.last_on_ts is not None:
dur = max(0.0, now - self.last_on_ts)
self.last_duration_s = dur
self.total_on_s += dur
self.last_on_ts = None
self.last_off_ts = now
def current_duration(self, now: Optional[float] = None) -> float:
"""
Kanal şu anda ON ise, bu ON periyodunun şu ana kadarki süresini döndürür.
OFF ise 0.0 döner.
"""
if self.last_on_ts is None:
return 0.0
if now is None:
now = time.time()
return max(0.0, now - self.last_on_ts)
# ----------------------------------------------------------------------
# Ana sürücü
# ----------------------------------------------------------------------
class RelayDriver:
"""
Basit bir röle sürücüsü.
- Soyut kanal isimleri: RELAY_GPIO dict'indeki anahtarlar
- Brülör grup API'si:
* burners() → mevcut brülör id listesi
* burner_info(bid) → config_statics.BURNER_GROUPS[bid]
* igniter_channel(bid) → ateşleme kanal adı
* set_igniter(bid, state)
* set_pump(bid, pump_name, state)
* enabled_pumps(bid) → default=1 olan isimler (konfig default)
* all_pumps(bid) → tüm pompa isimleri
* active_pumps(bid) → şu anda ON olan pompa isimleri
"""
def __init__(self, onoff: bool = False) -> None:
print("RelayDriver yükleniyor…")
# Konfigten kanal → GPIO pin map
self._pin_map: Dict[str, int] = dict(getattr(cfg, "RELAY_GPIO", {})) if cfg else {}
# Her kanal için istatistik objesi
self._stats: Dict[str, RelayStats] = {
ch: RelayStats() for ch in self._pin_map.keys()
}
# Brülör grupları
self._burner_groups: Dict[int, dict] = dict(getattr(cfg, "BURNER_GROUPS", {})) if cfg else {}
# GPIO kurulumu
if _HAS_GPIO and self._pin_map:
GPIO.setmode(GPIO.BCM)
for ch, pin in self._pin_map.items():
GPIO.setup(pin, GPIO.OUT)
# Güvenli başlangıç: tüm kanallar kapalı
GPIO.output(pin, GPIO.LOW)
elif not self._pin_map:
print("⚠️ RELAY_GPIO konfigürasyonu boş; donanım pin eşlemesi yok.")
# igniter_pin → kanal adı map'ini BURNER_GROUPS içine enjekte et
if self._burner_groups and self._pin_map:
pin_to_channel = {pin: ch for ch, pin in self._pin_map.items()}
for bid, info in self._burner_groups.items():
if not isinstance(info, dict):
continue
ign_pin = info.get("igniter_pin")
if ign_pin is not None:
ch = pin_to_channel.get(ign_pin)
if ch:
info.setdefault("igniter", ch)
# İstenirse tüm röleleri açılışta kapat
if onoff is False:
self.all_off()
# -----------------------------------------------------
# Düşük seviye kanal kontrolü
# -----------------------------------------------------
def set_channel(self, channel: str, state: bool) -> None:
"""
Verilen kanal adını ON/OFF yapar.
"""
if channel not in self._pin_map:
# Tanımsız kanal sessiz geç
return
pin = self._pin_map[channel]
now = time.time()
# İstatistik güncelle
st = self._stats.get(channel)
if st is None:
st = RelayStats()
self._stats[channel] = st
if state:
st.on(now)
else:
st.off(now)
# Donanım
if _HAS_GPIO:
# Aktif-high röle kartı varsayıyoruz; gerekiyorsa buraya
# ACTIVE_LOW/ACTIVE_HIGH gibi bir bayrak eklenebilir.
GPIO.output(pin, GPIO.HIGH if state else GPIO.LOW)
def get_stats(self, channel: str) -> RelayStats:
"""
Kanal için istatistik objesini döndürür (yoksa yaratır).
"""
st = self._stats.get(channel)
if st is None:
st = RelayStats()
self._stats[channel] = st
return st
def get_channel_state(self, channel: str) -> bool:
"""
Kanal şu anda ON mu? (last_on_ts None değilse ON kabul edilir)
"""
st = self._stats.get(channel)
if st is None:
return False
return st.last_on_ts is not None
# -----------------------------------------------------
# Tüm kanalları güvenli moda çek
# -----------------------------------------------------
def all_off(self) -> None:
"""
Tüm kanalları OFF yapar.
"""
now = time.time()
for ch in list(self._pin_map.keys()):
st = self._stats.get(ch)
if st is not None and st.last_on_ts is not None:
st.off(now)
if _HAS_GPIO:
GPIO.output(self._pin_map[ch], GPIO.LOW)
# -----------------------------------------------------
# Brülör grup API'si
# -----------------------------------------------------
def burners(self) -> List[int]:
"""
Mevcut brülör id listesini döndürür.
"""
return sorted(self._burner_groups.keys())
def burner_info(self, burner_id: int) -> Optional[dict]:
"""
Verilen brülör id için BURNER_GROUPS kaydını döndürür.
"""
return self._burner_groups.get(burner_id)
def igniter_channel(self, burner_id: int) -> Optional[str]:
"""
Brülörün igniter kanal adını döndürür.
- Eğer BURNER_GROUPS kaydında 'igniter' alanı varsa doğrudan onu kullanır.
- Yoksa 'igniter_pin' alanından pin numarasını alır ve
RELAY_GPIO'daki pin → kanal eşlemesini kullanarak kanalı bulur.
"""
info = self.burner_info(burner_id)
if not info:
return None
# BURNER_GROUPS konfiginde igniter_pin veriliyor; bunu kanala çevir.
ch = info.get("igniter")
if ch:
return ch
pin = info.get("igniter_pin")
if pin is None:
return None
# pin → channel eşlemesini RELAY_GPIO'dan bul
for cname, cpin in self._pin_map.items():
if cpin == pin:
return cname
return None
def all_pumps(self, burner_id: int) -> Iterable[str]:
"""
Konfigte tanımlı tüm pompa kanal adlarını döndürür (circulation altı).
"""
info = self.burner_info(burner_id)
if not info:
return []
circ = info.get("circulation", {}) or {}
# circ_x → {channel: "circulation_a", pin: ..}
for logical_name, entry in circ.items():
ch = entry.get("channel")
if ch:
yield ch
def enabled_pumps(self, burner_id: int) -> Iterable[str]:
"""
Varsayılan olarak açık olması gereken pompa kanal adlarını döndürür.
(circulation altındaki default=1 kayıtları)
"""
info = self.burner_info(burner_id)
if not info:
return []
circ = info.get("circulation", {}) or {}
for logical_name, entry in circ.items():
ch = entry.get("channel")
default = int(entry.get("default", 0))
if ch and default == 1:
yield ch
def active_pumps(self, burner_id: int) -> Iterable[str]:
"""
Şu anda ON olan pompa kanal adlarını döndürür.
"""
for ch in self.all_pumps(burner_id):
if self.get_channel_state(ch):
yield ch
def set_igniter(self, burner_id: int, state: bool) -> None:
"""
İlgili brülörün igniter kanalını ON/OFF yapar.
"""
ch = self.igniter_channel(burner_id)
if ch:
self.set_channel(ch, state)
def set_pump(self, burner_id: int, pump_name: str, state: bool) -> None:
"""
Belirtilen brülörün belirtilen pompasını ON/OFF yapar.
pump_name: BURNER_GROUPS[..]["circulation"][pump_name]
"""
info = self.burner_info(burner_id)
if not info:
return
circ = info.get("circulation", {})
if pump_name in circ:
ch = circ[pump_name]["channel"]
self.set_channel(ch, state)
# -----------------------------------------------------
# Yardımcı: özet
# -----------------------------------------------------
def summary(self) -> str:
"""
Kanallar ve brülör gruplarının kısa bir özetini döndürür (debug amaçlı).
"""
lines: List[str] = []
chans = ", ".join(sorted(self._pin_map.keys()))
lines.append(f"Kanallar: {chans}")
for bid in self.burners():
info = self.burner_info(bid) or {}
name = info.get("name", "?")
loc = info.get("location", "?")
ign = self.igniter_channel(bid)
pumps = list(self.all_pumps(bid))
defaults = list(self.enabled_pumps(bid))
lines.append(
f" #{bid}: {name} @ {loc} | igniter={ign} | "
f"pumps={pumps} | default_on={defaults}"
)
return "\n".join(lines)
# -----------------------------------------------------
# Temizlik
# -----------------------------------------------------
def cleanup(self) -> None:
"""
GPIO pinlerini serbest bırakır.
"""
if _HAS_GPIO:
GPIO.cleanup()
if __name__ == "__main__":
drv = RelayDriver()
print("\n🧰 RelayDriver Summary")
print(drv.summary())

141
ebuild/io/sensor_dht11.py Normal file
View File

@@ -0,0 +1,141 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
__title__ = "sensor_dht11"
__author__ = 'Mehmet Karatay & "Saraswati" (ChatGPT)'
__purpose__ = "DHT11 tabanlı dış ortam sıcaklık / nem sensörü sürücüsü"
__version__ = "0.1.0"
__date__ = "2025-11-21"
"""
ebuild/io/sensor_dht11.py
Revision : 2025-11-21
Authors : Mehmet Karatay & "Saraswati" (ChatGPT)
Amaç
-----
- DHT11 sensöründen sıcaklık (°C) ve bağıl nem (%) okumak.
- Varsayılan olarak config_statics.DHT11_OUTSIDE_PIN üzerinde çalışır.
- Adafruit_DHT kütüphanesi mevcut değilse veya donanım erişiminde sorun
varsa, mock / güvenli modda çalışarak None veya sabit değerler
döndürür; böylece sistemin geri kalanı göçmez.
Notlar
------
- DHT11, tek data pini üzerinden dijital haberleşme kullanır; ADC gerekmez.
- Bu sürücü yalnızca ham okuma yapar. Filtreleme, smoothing, alarm üretimi
gibi üst seviye işlemler Environment/HeatEngine katmanına bırakılır.
"""
from typing import Optional, Tuple
try:
import Adafruit_DHT # type: ignore
except ImportError:
Adafruit_DHT = None # type: ignore
try:
from .. import config_statics as cfg
except ImportError:
cfg = None # type: ignore
class DHT11Sensor:
"""
Tek bir DHT11 sensörünü temsil eder.
Özellikler:
-----------
- BCM pin numarası (örn: config_statics.DHT11_OUTSIDE_PIN)
- read() ile (temperature_c, humidity_percent) döndürür.
- Donanım veya kütüphane sorunu durumunda mock moda geçer.
"""
def __init__(
self,
bcm_pin: Optional[int] = None,
sensor_type: int = 11,
name: str = "DHT11_Outside",
) -> None:
"""
Parametreler:
bcm_pin : BCM pin numarası. None ise configten okunur.
sensor_type: Adafruit_DHT sensör tipi (11 → DHT11).
name : Sensörün mantıksal adı (log / debug için).
"""
if bcm_pin is None and cfg is not None:
bcm_pin = int(getattr(cfg, "DHT11_OUTSIDE_PIN", 5))
if bcm_pin is None:
raise ValueError("DHT11Sensor için BCM pin numarası verilmeli veya configte DHT11_OUTSIDE_PIN tanımlı olmalı.")
self.bcm_pin = int(bcm_pin)
self.sensor_type = sensor_type
self.name = name
self.mock_mode = Adafruit_DHT is None
# Son başarı/başarısızlık bilgisi
self.last_temperature: Optional[float] = None
self.last_humidity: Optional[float] = None
self.last_ok: bool = False
# ------------------------------------------------------------------
# Okuma
# ------------------------------------------------------------------
def read(self, retries: int = 3) -> Tuple[Optional[float], Optional[float]]:
"""
Sensörden sıcaklık ve nem okur.
Dönüş:
(temperature_c, humidity_percent)
- Başarı durumunda her iki değer de float (örn: 23.4, 45.0)
- Başarısızlıkta (None, None)
- Mock modda -> (None, None) veya ileride sabit test değeri
"""
if self.mock_mode:
# Donanım veya Adafruit_DHT yok → güvenli fallback.
self.last_temperature = None
self.last_humidity = None
self.last_ok = False
return (None, None)
# Gerçek okuma
temperature: Optional[float] = None
humidity: Optional[float] = None
for _ in range(retries):
# Adafruit_DHT.read_retry(DHT11, pin) → (humidity, temperature)
hum, temp = Adafruit_DHT.read_retry(self.sensor_type, self.bcm_pin) # type: ignore
if hum is not None and temp is not None:
humidity = float(hum)
temperature = float(temp)
break
self.last_temperature = temperature
self.last_humidity = humidity
self.last_ok = (temperature is not None and humidity is not None)
return (temperature, humidity)
# ------------------------------------------------------------------
# Bilgiler
# ------------------------------------------------------------------
def summary(self) -> str:
"""
Sensör hakkında kısa bir özet döndürür.
"""
mode = "MOCK" if self.mock_mode else "HW"
return f"DHT11Sensor(name={self.name}, pin=BCM{self.bcm_pin}, mode={mode})"
# ----------------------------------------------------------------------
# Basit test
# ----------------------------------------------------------------------
if __name__ == "__main__":
sensor = DHT11Sensor()
print(sensor.summary())
t, h = sensor.read()
print("Okuma sonucu: T={0}°C, H={1}%".format(t, h))

View File

@@ -0,0 +1,608 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
__title__ = "legacy_syslog"
__author__ = 'Mehmet Karatay & "Saraswati" (ChatGPT)'
__purpose__ = "Legacy tarzı syslog çıktısı üreten köprü"
__version__ = "0.2.1"
__date__ = "2025-11-22"
"""
ebuild/io/legacy_syslog.py
Revision : 2025-11-22
Authors : Mehmet Karatay & "Saraswati" (ChatGPT)
Amaç
-----
Eski Rasp2 tabanlı sistemin syslog çıktısını, yeni ebuild mimarisi ile
uyumlu ve okunaklı şekilde üretir. Çıktı şu ana bloklardan oluşur:
1) Üst bilgi:
- Versiyon ve zaman satırı
- Güneş bilgisi (sunrise / sunset, sistem On/Off, lisans id)
- Mevsim bilgisi (season, bahar dönemi bilgisi)
- Tatil bilgisi (varsa adıyla)
2) Bina ısı bilgisi
- Bina Isı : [ min - avg - max ]
3) Hat sensörleri (burner.py içinden doldurulan kısım):
- Dış Isı 1
- Çıkış Isı 2
- Dönüş hatları (isim map'inden)
4) Used dış ısı
5) Brülör / devirdaim / özet satırı
Not
---
Bu modül sadece formatlama ve çıktı üretiminden sorumludur. Gerçek
ölçümler ve kontrol kararları üst katmanlardan (HeatEngine, Burner,
Building, Environment, SeasonController vb.) alınır.
"""
# Bu modül gerçekten hangi path'ten import ediliyor, görmek için:
# ---------------------------------------------------------
def _safe_import(desc, import_func):
"""
desc: ekranda görünecek ad (örn: 'Building', 'legacy_syslog')
import_func: gerçek import'u yapan lambda
"""
try:
obj = import_func()
#print(f"legacy_syslog.py [IMPORT OK] {desc} ->", obj)
return obj
except Exception as e:
print(f"legacy_syslog.py [IMPORT FAIL] {desc}: {e}")
traceback.print_exc()
return None
from datetime import datetime, time
from typing import Optional
import logging
import logging.handlers
try:
# SeasonController ve konfig
from ..core.season import SeasonController
cfg = _safe_import( "config_statics", lambda: __import__("ebuild.config_statics", fromlist=["*"]),)
cfv = _safe_import( "config_runtime", lambda: __import__("ebuild.config_runtime", fromlist=["*"]),)
#from .. import config_statics as cfg
except ImportError: # test / standalone
SeasonController = None # type: ignore
cfg = None # type: ignore
cfv = None
print("SeasonController, config_statics import ERROR")
# ----------------------------------------------------------------------
# Logger kurulumu (Syslog + stdout)
# ----------------------------------------------------------------------
_LOGGER: Optional[logging.Logger] = None
def _get_logger() -> logging.Logger:
global _LOGGER
if _LOGGER is not None:
return _LOGGER
#print("logger..1:", stream_fmt)
logger = logging.getLogger("BRULOR")
logger.setLevel(logging.INFO)
# Aynı handler'ları ikinci kez eklemeyelim
if not logger.handlers:
# Syslog handler (Linux: /dev/log)
try:
syslog_handler = logging.handlers.SysLogHandler(address="/dev/log")
# Syslog mesaj formatı: "BRULOR: [ 1 ... ]"
fmt = logging.Formatter("%(name)s: %(message)s")
syslog_handler.setFormatter(fmt)
logger.addHandler(syslog_handler)
except Exception:
# /dev/log yoksa sessizce geç; sadece stdout'a yazacağız
pass
# Konsol çıktısı (debug için)
stream_handler = logging.StreamHandler()
stream_fmt = logging.Formatter("INFO:BRULOR:%(message)s")
stream_handler.setFormatter(stream_fmt)
logger.addHandler(stream_handler)
print("logger..2:", stream_fmt)
_LOGGER = logger
return logger
# ----------------------------------------------------------------------
# Temel çıktı fonksiyonları
# ----------------------------------------------------------------------
def send_legacy_syslog(message: str) -> None:
"""
Verilen mesajı legacy syslog formatına uygun şekilde ilgili hedefe gönderir.
- Syslog (/dev/log) → program adı: BRULOR
- Aynı zamanda stdout'a da yazar (DEBUG amaçlı)
"""
#print("send_legacy_syslog BRULOR:", message)
try:
logger = _get_logger()
logger.info(message)
except Exception as e:
# Logger bir sebeple çökerse bile BRULOR satırını kaybetmeyelim
print("BRULOR:", message, f"(logger error: {e})")
def format_line(line_no: int, body: str) -> str:
"""
BRULOR satırını klasik formata göre hazırlar.
Örnek:
line_no = 2, body = "Sunrise:07:39 Sunset:17:29 Sistem: On Lic:10094"
"[ 2 Sunrise:07:39 Sunset:17:29 Sistem: On Lic:10094]"
Not:
Burada "BRULOR" yazmıyoruz; syslog program adı zaten BRULOR olacak.
"""
return f"[{line_no:3d} {body}]"
def _format_version_3part(ver: str) -> str:
"""
__version__ string'ini "00.02.01" formatına çevirir.
Örnek:
"0.2.1""00.02.01"
"""
parts = (ver or "").split(".")
nums = []
for p in parts:
try:
nums.append(int(p))
except ValueError:
nums.append(0)
while len(nums) < 3:
nums.append(0)
return f"{nums[0]:02d}.{nums[1]:02d}.{nums[2]:02d}"
# ----------------------------------------------------------------------
# Üst blok (header) üreticiler
# ----------------------------------------------------------------------
def emit_header_version(line_no: int, now: datetime) -> int:
"""
1. satır: versiyon + zaman bilgisi.
Örnek:
************** 00.02.01 2025-11-22 18:15:00 *************
"""
v_str = _format_version_3part(__version__)
body = f"************** {v_str} {now.strftime('%Y-%m-%d %H:%M:%S')} *************"
send_legacy_syslog(format_line(line_no, body))
return line_no + 1
def emit_header_sun_and_system(
line_no: int,
sunrise: Optional[time],
sunset: Optional[time],
system_on: bool,
licence_id: int,
) -> int:
"""
2. satır: Güneş bilgisi + Sistem On/Off + Lisans id.
Örnek:
[ 2 Sunrise:07:39 Sunset:17:29 Sistem: On Lic:10094]
"""
sun_str = ""
if sunrise is not None:
sun_str += f"Sunrise:{sunrise.strftime('%H:%M')} "
if sunset is not None:
sun_str += f"Sunset:{sunset.strftime('%H:%M')} "
sys_str = "On" if system_on else "Off"
body = f"{sun_str}Sistem: {sys_str} Lic:{licence_id}"
send_legacy_syslog(format_line(line_no, body))
return line_no + 1
def _only_date(s: str) -> str:
"""
ISO tarih-zaman stringinden sadece YYYY-MM-DD kısmını alır.
Örn: '2025-09-23T16:33:10.687982+03:00''2025-09-23'
"""
if not s:
return "--"
s = s.strip()
if "T" in s:
return s.split("T", 1)[0]
return s
def emit_header_season(
line_no: int,
season_ctrl: SeasonController,
) -> int:
"""
Sunrise satırının altına mevsim + (varsa) bahar tasarruf dönemi satırını basar.
Beklenen format:
BRULOR [ 3 season : Sonbahar 2025-09-23 - 2025-12-20 [89 pass:60 kalan:28] ]
BRULOR [ 4 bahar : 2025-09-23 - 2025-10-13 ]
Notlar:
- Bilgiler SeasonController.info içinden okunur (dict veya obje olabilir).
- bahar_tasarruf True DEĞİLSE bahar satırı hiç basılmaz.
"""
# SeasonController.info hem dict hem obje olabilir, ikisini de destekle
info = getattr(season_ctrl, "info", season_ctrl)
def _get(field: str, default=None):
if isinstance(info, dict):
return info.get(field, default)
return getattr(info, field, default)
# ---- 3. satır: season ----
season_name = _get("season", "Unknown")
season_start = _only_date(_get("season_start", ""))
season_end = _only_date(_get("season_end", ""))
season_day = _get("season_day", "")
season_passed = _get("season_passed", "")
season_remain = _get("season_remaining", "")
body = (
f"season : {season_name} {season_start} - {season_end} "
f"[{season_day} pass:{season_passed} kalan:{season_remain}]"
)
send_legacy_syslog(format_line(line_no, body))
line_no += 1
# ---- 4. satır: bahar dönemi (SADECE aktifse) ----
bahar_tasarruf = bool(_get("bahar_tasarruf", False))
if bahar_tasarruf:
bahar_basx = _only_date(_get("bahar_basx", ""))
bahar_bitx = _only_date(_get("bahar_bitx", ""))
body = f"bahar : {bahar_basx} - {bahar_bitx}"
send_legacy_syslog(format_line(line_no, body))
line_no += 1
return line_no
def emit_header_holiday(
line_no: int,
is_holiday: bool,
holiday_label: str,
) -> int:
"""
Tatil satırı (sunrise + season altına).
Kurallar:
- Tatil yoksa (False) HİÇ satır basma.
- Tatil varsa:
[ 5 Tatil: True Adı: Cumhuriyet Bayramı]
"""
if not is_holiday:
return line_no
label = holiday_label or ""
body = f"Tatil: True Adı: {label}"
send_legacy_syslog(format_line(line_no, body))
return line_no + 1
# ----------------------------------------------------------------------
# Dışarıdan çağrılacak üst-blok helper
# ----------------------------------------------------------------------
def emit_top_block(
now: datetime,
season_ctrl: SeasonController,
) -> int:
"""
F veya B modundan bağımsız olarak, her tick başında üst bilgiyi üretir.
Sıra:
1) Versiyon + zaman
2) Sunrise / Sunset / Sistem: On/Off / Lic
3) Mevsim bilgisi (SeasonController.to_syslog_lines() → sadeleştirilmiş)
4) Tatil bilgisi (sadece tatil varsa)
5) Bir sonraki satır numarasını döndürür (bina ısı satırları için).
"""
line_no = 1
# 1) Versiyon
line_no = emit_header_version(line_no, now)
# Konfigten sistem ve lisans bilgileri
if cfg is not None:
licence_id = int(getattr(cfg, "BUILDING_LICENCEID", 0))
system_onoff = int(getattr(cfg, "BUILDING_SYSTEMONOFF", 1))
else:
licence_id = 0
system_onoff = 1
system_on = (system_onoff == 1)
# 2) Güneş + Sistem / Lisans
sunrise = season_ctrl.info.sunrise
sunset = season_ctrl.info.sunset
line_no = emit_header_sun_and_system(
line_no=line_no,
sunrise=sunrise,
sunset=sunset,
system_on=system_on,
licence_id=licence_id,
)
# 3) Mevsim bilgisi (sunrise ALTINA)
line_no = emit_header_season(line_no, season_ctrl)
# 4) Tatil bilgisi (sadece True ise)
line_no = emit_header_holiday(
line_no=line_no,
is_holiday=season_ctrl.info.is_holiday,
holiday_label=season_ctrl.info.holiday_label,
)
# Sonraki satır: bina ısı / dış ısı / F-B detayları için kullanılacak
return line_no
def _fmt_temp(val: Optional[float]) -> str:
return "None" if val is None else f"{val:.2f}"
PUMP_SHORT_MAP = {
"circulation_a": "A",
"circulation_b": "B",
"circ_1": "A",
"circ_2": "B",
}
def _short_pump_name(ch: str) -> str:
if ch in PUMP_SHORT_MAP:
return PUMP_SHORT_MAP[ch]
# sonu _a/_b ise yine yakala
if ch.endswith("_a"):
return "A"
if ch.endswith("_b"):
return "B"
return ch # tanımıyorsak orijinal ismi yaz
def log_burner_header(
now: datetime,
mode: str,
season,
building_avg: Optional[float],
outside_c: Optional[float],
used_out_c: Optional[float],
fire_sp: float,
burner_on: bool,
pumps_on,
line_temps: Optional[Dict[str, Optional[float]]] = None,
ign_stats=None,
circ_stats=None,
) -> None:
"""
BurnerController'dan tek çağrıyla BRULOR bloğunu basar.
- Önce üst blok (versiyon + güneş + mevsim + tatil)
- Sonra bina ısı satırı
- Dış ısı / used dış ısı
- Son satırda brülör ve pompaların durumu
"""
#print("log_burner_header CALLED", season)
# 1) Üst header blok
if season is None:
# SeasonController yoksa, sadece versiyon ve zaman bas
line_no = 1
v_str = _format_version_3part(__version__)
body = f"************** {v_str} {now.strftime('%Y-%m-%d %H:%M:%S')} *************"
send_legacy_syslog(format_line(line_no, body))
line_no += 1
else:
line_no = emit_top_block(now, season)
# 2) Bina ısı satırı
if building_avg is None:
min_s = "None"
avg_s = "None"
max_s = "None"
else:
# Şimdilik min=avg=max gibi davranalım; ileride gerçek min/max eklenebilir
min_s = f"{building_min:5.2f}"
avg_s = f"{building_avg:5.2f}"
max_s = f"{building_max:5.2f}"
# configteki mod
cfg_mode = getattr(cfg, "BUILD_BURNER", "?") if cfg is not None else "?"
body = f"Build [{mode}-{cfg_mode}] Heats[Min:{min_s}°C Avg:{avg_s}°C Max:{max_s}°C]"
send_legacy_syslog(format_line(line_no, body))
line_no += 1
# line_temps yoksa, burayı pas geç
if line_temps is not None:
# CONFIG'TEN ID'LERİ AL
outside_id = getattr(cfg, "OUTSIDE_SENSOR_ID", None) if cfg is not None else None
out_id = getattr(cfg, "BURNER_OUT_SENSOR_ID", None) if cfg is not None else None
ret_ids = getattr(cfg, "RETURN_LINE_SENSOR_IDS", []) if cfg is not None else []
ret_map = getattr(cfg, "RETURN_LINE_SENSOR_NAME_MAP", {}) if cfg is not None else {}
line_no = 4 # dış ısı satırı numarası
# 4: Dis isi
if outside_id and outside_id in line_temps:
t = line_temps.get(outside_id)
namex = getattr(cfg, "OUTSIDE_SENSOR_NAME", "Dis isi") if cfg is not None else "Dis isi"
msg = f"{namex:<15.15}: {_fmt_temp(t)}°C - {outside_id} "
send_legacy_syslog(format_line(line_no, msg))
line_no += 1
# 5: Cikis isi
if out_id and out_id in line_temps:
t = line_temps.get(out_id)
namex = getattr(cfg, "BURNER_OUT_SENSOR_NAME", "Cikis isi") if cfg is not None else "Cıkıs isi"
msg = f"{namex:<15.15}: {_fmt_temp(t)}°C - {out_id} "
send_legacy_syslog(format_line(line_no, msg))
line_no += 1
# 6..N: Donus isi X
namex = getattr(cfg, "RETURN_LINE_SENSOR_NAME_MAP",[])
for sid in ret_ids:
if sid not in line_temps:
continue
t = line_temps.get(sid)
try:
namexx = ret_map.get(sid)
except:
namex = '???'
msg = f"{namexx:<15.15}: {_fmt_temp(t)}°C - {sid} "
send_legacy_syslog(format_line(line_no, msg))
line_no += 1
# 3) Dış ısı / used dış ısı
out_str = "--"
used_str = "--"
if outside_c is not None:
out_str = f"{outside_c:5.2f}"
if used_out_c is not None:
used_str = f"{used_out_c:5.2f}"
usedxx = "Sistem Isı"
#------------------------------------------------------------------
# 9: Sistem Isı - Used + [WEEKEND_HEAT_BOOST_C, BURNER_COMFORT_OFFSET_C]
# ------------------------------------------------------------------
used_val = used_out_c if used_out_c is not None else None
used_str = "None" if used_val is None else f"{used_val:.2f}"
if cfv is not None:
w_val = float(getattr(cfv, "WEEKEND_HEAT_BOOST_C", 0.0) or 0.0)
c_val = float(getattr(cfv, "BURNER_COMFORT_OFFSET_C", 0.0) or 0.0)
else:
w_val = 0.0
c_val = 0.0
# Sayıları [2, 1] gibi, gereksiz .0sız yazalım
def _fmt_num(x: float) -> str:
if x == int(x):
return str(int(x))
return f"{x:g}"
sabitler_str = f"[w:{_fmt_num(w_val)} c:{_fmt_num(c_val)}]"
body = f"{usedxx:<15.15}: {used_str}°C {sabitler_str} "
send_legacy_syslog(format_line(line_no, body))
line_no += 1
# ------------------------------------------------------------------
# 11: Brülör Motor satırı (MAX_OUTLET_C ile)
# ------------------------------------------------------------------
if cfv is not None:
max_out = float(getattr(cfv, "MAX_OUTLET_C", 45.0) or 45.0)
else:
max_out = 45.0
if cfv is not None:
min_ret = float(getattr(cfv, "CIRCULATION_MIN_RETURN_C", 25.0) or 25.0)
else:
min_ret = 25.0
br_status = "<CALISIYOR>" if burner_on else "<CALISMIYOR>"
br_flag = 1 if burner_on else 0
ign_sw = 0
ign_total = "00:00:00"
ign_today = "00:00:00"
if ign_stats:
ign_sw = ign_stats.get("switch_count", 0)
ign_total = ign_stats.get("total_on_str", "00:00:00")
ign_today = ign_stats.get("today_on_str", "00:00:00")
# Eski stile benzeteceğiz:
# [ 11 Brulor Motor : <CALISMIYOR> [0] 0 00:00:00 00:00:00 L:45.0 ]
body11 = (
f"Brulor Motor : {br_status} "
f"[{br_flag}] {ign_sw} {ign_total} {ign_today} L:{max_out:.1f}"
)
send_legacy_syslog(format_line(line_no, body11))
line_no += 1
# ------------------------------------------------------------------
# 12: Devirdaim Motor satırı (CIRCULATION_MIN_RETURN_C ile)
# ------------------------------------------------------------------
ch_to_logical = {}
pumps_on_list = list(pumps_on) if pumps_on else []
# --- circulation mapping: channel -> logical ('circ_1', 'circ_2') ---
ch_to_logical = {}
cfg_groups = getattr(cfg, "BURNER_GROUPS", {})
# ileride çoklu brülör olursa buraya burner_id parametresi de geçirsin istersen
grp = cfg_groups.get(0, {})
circ_cfg = grp.get("circulation", {}) or {}
for logical_name, info in circ_cfg.items():
ch = info.get("channel")
if ch:
ch_to_logical[ch] = logical_name
# Configte default=1 olan pompaları da topla (cfg_default_pumps)
cfg_default_pumps = []
for logical_name, info in circ_cfg.items():
ch = info.get("channel")
if ch and info.get("default", 0):
cfg_default_pumps.append(ch)
# Kısa isim A/B istersek:
def _logical_to_short(name: str) -> str:
if name == "circ_1":
return "A"
if name == "circ_2":
return "B"
return name
pump_count = len(cfg_default_pumps)
dev_status = "<CALISIYOR>" if pump_count > 0 else "<CALISMIYOR>"
pump_labels = []
for ch in cfg_default_pumps:
logical = ch_to_logical.get(ch)
if logical is not None:
pump_labels.append(_logical_to_short(logical))
else:
pump_labels.append(ch)
pumps_str = ",".join(pump_labels) if pump_labels else "-"
cir_sw = 0
cir_total = "00:00:00"
cir_today = "00:00:00"
if circ_stats:
cir_sw = circ_stats.get("switch_count", 0)
cir_total = circ_stats.get("total_on_str", "00:00:00")
cir_today = circ_stats.get("today_on_str", "00:00:00")
# [ 12 Devirdaim Mot: <CALISMIYOR> [0] 0 00:00:00 00:00:00 L:25.0]
body12 = (
f"Devirdaim Mot: {dev_status} "
f"[{pump_count}] {br_flag}] {cir_sw} {cir_total} {cir_today} L:{pumps_str} {min_ret:.1f}"
)
send_legacy_syslog(format_line(line_no, body12))
line_no += 1
return line_no
# ----------------------------------------------------------------------
# Örnek kullanım (standalone test)
# ----------------------------------------------------------------------
if __name__ == "__main__":
# Bu blok sadece modülü tek başına test etmek için:
# python3 -m ebuild.io.legacy_syslog
if SeasonController is None:
raise SystemExit("SeasonController import edilemedi (test ortamı).")
now = datetime.now()
# SeasonController.from_now() kullanıyorsan:
try:
season = SeasonController.from_now()
except Exception as e:
raise SystemExit(f"SeasonController.from_now() hata: {e}")
next_line = emit_top_block(now, season)
# Test için bina ısısını dummy bas:
body = "Bina Isı : [ 20.10 - 22.30 - 24.50 ]"
send_legacy_syslog(format_line(next_line, body))

388
ebuild/io/z2relay_driver.py Normal file
View File

@@ -0,0 +1,388 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
__title__ = "relay_driver"
__author__ = 'Mehmet Karatay & "Saraswati" (ChatGPT)'
__purpose__ = "GPIO röle sürücüsü + brülör grup soyutlaması"
__version__ = "0.4.0"
__date__ = "2025-11-22"
"""
ebuild/io/relay_driver.py
Revision : 2025-11-22
Authors : Mehmet Karatay & "Saraswati" (ChatGPT)
Amaç
-----
- Soyut kanal isimleri ile (igniter, circulation_a, ...) GPIO pin sürmek.
- config_statics.BURNER_GROUPS üzerinden brülör gruplarını yönetmek.
- Her kanal için:
* ON/OFF sayacı
* Son çalışma süresi
* Toplam çalışma süresi
* Şu anki çalışma süresi (eğer röle ON ise, anlık akan süre)
istatistiklerini tutmak.
Kullanım
--------
- Temel kanal API:
drv.channels() → ['igniter', 'circulation_a', ...]
drv.set_channel("igniter", True/False)
drv.get_stats("igniter") → RelayStats
drv.get_channel_state("igniter") → bool (şu an ON mu?)
- Brülör grup API:
drv.burners() → [0, 1, ...]
drv.burner_info(0) → config_statics.BURNER_GROUPS[0]
drv.igniter_channel(0) → "igniter"
drv.all_pumps(0) → ['circulation_a', 'circulation_b', ...]
drv.enabled_pumps(0) → default=1 olan pompalar
drv.active_pumps(0) → şu anda gerçekten ON olan pompalar
Bu API'ler burner.py ve legacy_syslog.py tarafından kullanılmak üzere tasarlanmıştır.
"""
import time
from dataclasses import dataclass, field
from typing import Dict, Optional, Iterable, Tuple, List
try:
import RPi.GPIO as GPIO
_HAS_GPIO = True
except ImportError:
_HAS_GPIO = False
from .. import config_statics as cfg
# GPIO aktif seviyesini seç
# Birçok Çin röle kartı ACTIVE_LOW çalışır:
# - LOW → röle ÇEKER
# - HIGH → röle BIRAKIR
# Eğer kartın tam tersi ise bunu False yaparsın.
ACTIVE_LOW = True
# -------------------------------------------------------------------
# İstatistik yapısı
# -------------------------------------------------------------------
@dataclass
class RelayStats:
"""
Tek bir röle kanalı için istatistikler.
- on_count : kaç defa ON'a çekildi
- last_on_ts : en son ON'a çekildiği zaman (epoch saniye)
- last_off_ts : en son OFF olduğu zaman (epoch saniye)
- last_duration_s : en son ON periyodunun süresi (saniye)
- total_on_s : bugüne kadar toplam ON kalma süresi (saniye)
"""
on_count: int = 0
last_on_ts: Optional[float] = None
last_off_ts: Optional[float] = None
last_duration_s: float = 0.0
total_on_s: float = 0.0
def on(self, now: float) -> None:
"""
Kanal ON'a çekildiğinde çağrılır.
Aynı ON periyodu içinde tekrar çağrılırsa sayaç artmaz.
"""
if self.last_on_ts is None:
self.last_on_ts = now
self.on_count += 1
def off(self, now: float) -> None:
"""
Kanal OFF'a çekildiğinde çağrılır.
Son ON zamanına göre süre hesaplanır, last_duration_s ve total_on_s güncellenir.
"""
if self.last_on_ts is not None:
dur = max(0.0, now - self.last_on_ts)
self.last_duration_s = dur
self.total_on_s += dur
self.last_on_ts = None
self.last_off_ts = now
def current_duration(self, now: Optional[float] = None) -> float:
"""
Kanal şu anda ON ise, bu ON periyodunun şu ana kadarki süresini döndürür.
OFF ise 0.0 döner.
"""
if self.last_on_ts is None:
return 0.0
if now is None:
now = time.time()
return max(0.0, now - self.last_on_ts)
# -------------------------------------------------------------------
# Ana sürücü
# -------------------------------------------------------------------
class RelayDriver:
"""
Basit bir röle sürücüsü.
- Soyut kanal isimleri: RELAY_GPIO dict'indeki anahtarlar
- Brülör grup API'si:
* burners() → mevcut brülör id listesi
* burner_info(bid) → config_statics.BURNER_GROUPS[bid]
* igniter_channel(bid) → ateşleme kanal adı
* set_igniter(bid, state)
* set_pump(bid, pump_name, state)
* enabled_pumps(bid) → default=1 olan isimler (konfig default)
* all_pumps(bid) → tüm pompa isimleri
* active_pumps(bid) → şu anda ON olan pompa isimleri
"""
def __init__(self, onoff=False) -> None:
print("RelayDriver yükleniyor…")
# Konfigten kanal → GPIO pin map
self._pin_map: Dict[str, int] = dict(getattr(cfg, "RELAY_GPIO", {}))
# Her kanal için istatistik objesi
self._stats: Dict[str, RelayStats] = {
ch: RelayStats() for ch in self._pin_map.keys()
}
# Brülör grupları
self._burner_groups: Dict[int, dict] = dict(getattr(cfg, "BURNER_GROUPS", {}))
if not self._pin_map:
raise RuntimeError("RelayDriver: RELAY_GPIO boş.")
if _HAS_GPIO:
GPIO.setmode(GPIO.BCM)
GPIO.setwarnings(False) # aynı pini yeniden kullanırken uyarı verme
for ch, pin in self._pin_map.items():
GPIO.setup(pin, GPIO.OUT)
GPIO.output(pin, GPIO.LOW)
else:
print("⚠️ GPIO bulunamadı, DRY-RUN modunda çalışıyorum.")
# Başlangıçta HER ŞEYİ KAPALIYA ÇEK
try:
if onoff:
self.all_off()
except Exception:
# Çok dert etmeyelim, en kötü GPIO yoktur, vs.
pass
# -----------------------------------------------------
# Temel kanal API
# -----------------------------------------------------
def channels(self) -> Iterable[str]:
"""
Mevcut kanal isimlerini döndürür.
"""
return self._pin_map.keys()
def channel_pin(self, channel: str) -> Optional[int]:
"""
Verilen kanalın GPIO pin numarasını döndürür.
"""
return self._pin_map.get(channel)
def set_channel(self, channel: str, state: bool) -> None:
"""
Belirtilen kanalı ON/OFF yapar, GPIO'yu sürer ve istatistikleri günceller.
"""
if channel not in self._pin_map:
return
pin = self._pin_map[channel]
now = time.time()
if _HAS_GPIO:
# Aktif-low kartlar için:
if ACTIVE_LOW:
gpio_state = GPIO.LOW if state else GPIO.HIGH
else:
gpio_state = GPIO.HIGH if state else GPIO.LOW
GPIO.output(pin, gpio_state)
st = self._stats[channel]
if state:
st.on(now)
else:
st.off(now)
def get_stats(self, channel: str) -> RelayStats:
"""
Kanalın istatistik objesini döndürür.
"""
return self._stats[channel]
def get_channel_state(self, channel: str) -> bool:
"""
Kanal şu anda ON mu? (last_on_ts None değilse ON kabul edilir)
"""
st = self._stats.get(channel)
if st is None:
return False
return st.last_on_ts is not None
# -----------------------------------------------------
# Tüm kanalları güvenli moda çek
# -----------------------------------------------------
def all_off(self) -> None:
"""
Tüm röle kanallarını KAPALI (LOW) yapar ve istatistikleri günceller.
Özellikle:
- Uygulama başlatıldığında "her şey kapalı" garantisi
- Çıkış/KeyboardInterrupt anında güvenli kapanış için kullanılır.
"""
now = time.time()
for ch, pin in self._pin_map.items():
if _HAS_GPIO:
GPIO.output(pin, GPIO.LOW)
# stats güncelle
st = self._stats.get(ch)
if st is not None:
st.off(now)
# -----------------------------------------------------
# Brülör grup API
# -----------------------------------------------------
def burners(self) -> Iterable[int]:
"""
Mevcut brülör id'lerini döndürür.
"""
return self._burner_groups.keys()
def burner_info(self, burner_id: int) -> Optional[dict]:
"""
İlgili brülörün BURNER_GROUPS içindeki konfig dict'ini döndürür.
"""
return self._burner_groups.get(burner_id)
def igniter_channel(self, burner_id: int) -> Optional[str]:
"""
Brülörün igniter kanal adını döndürür.
"""
info = self.burner_info(burner_id)
if not info:
return None
return info.get("igniter", None)
def all_pumps(self, burner_id: int) -> Iterable[str]:
"""
Konfigte tanımlı tüm pompa kanal adlarını döndürür (circulation altı).
"""
info = self.burner_info(burner_id)
if not info:
return []
circ = info.get("circulation", {})
# Her pompa için { "channel": "circulation_a", "pin": 26, "default": 1 } beklenir.
return [data["channel"] for _, data in circ.items()]
def enabled_pumps(self, burner_id: int) -> Iterable[str]:
"""
Konfigte default=1 işaretli pompa kanal adlarını döndürür.
Bu, sistem açıldığında / ısıtma başladığında devreye alınacak default pompaları temsil eder.
"""
info = self.burner_info(burner_id)
if not info:
return []
circ = info.get("circulation", {})
return [
data["channel"]
for _, data in circ.items()
if int(data.get("default", 0)) == 1
]
def active_pumps(self, burner_id: int) -> Tuple[str, ...]:
"""
Şu anda gerçekten ON olan pompa isimlerini döndürür.
(GPIO'da HIGH durumda olan kanallar; RelayStats.last_on_ts None değilse ON kabul edilir)
"""
info = self.burner_info(burner_id)
if not info:
return tuple()
circ = info.get("circulation", {})
active: List[str] = []
for pname, pdata in circ.items():
ch = pdata.get("channel")
if ch in self._stats and self._stats[ch].last_on_ts is not None:
active.append(pname)
return tuple(active)
def set_igniter(self, burner_id: int, state: bool) -> None:
"""
İlgili brülörün igniter kanalını ON/OFF yapar.
"""
ch = self.igniter_channel(burner_id)
if ch:
self.set_channel(ch, state)
def set_pump(self, burner_id: int, pump_name: str, state: bool) -> None:
"""
Belirtilen brülörün belirtilen pompasını ON/OFF yapar.
pump_name normalde BURNER_GROUPS[..]["circulation"].keys()
(örn: "circ_1", "circ_2") olmalıdır; ancak geriye dönük uyumluluk
için doğrudan kanal adı ("circulation_a" vb.) verilirse de kabul edilir.
"""
info = self.burner_info(burner_id)
if not info:
return
circ = info.get("circulation", {}) or {}
# 1) pump_name bir logical ad ise ("circ_1" gibi)
if pump_name in circ:
ch = circ[pump_name].get("channel")
if ch:
self.set_channel(ch, state)
return
# 2) Geriyedönük: pump_name doğrudan kanal adı ("circulation_a" gibi)
for logical_name, pdata in circ.items():
ch = pdata.get("channel")
if ch == pump_name:
self.set_channel(ch, state)
return
# -----------------------------------------------------
# Yardımcı: özet
# -----------------------------------------------------
def summary(self) -> str:
"""
Kanallar ve brülör gruplarının kısa bir özetini döndürür (debug amaçlı).
"""
lines: List[str] = []
chans = ", ".join(sorted(self._pin_map.keys()))
lines.append(f"Kanallar: {chans}")
lines.append("Brülör grupları:")
for bid, info in self._burner_groups.items():
name = info.get("name", f"Burner{bid}")
loc = info.get("location", "-")
ign = info.get("igniter", "igniter")
circ = info.get("circulation", {})
pumps = []
defaults = []
for pname, pdata in circ.items():
ch = pdata.get("channel", "?")
pumps.append(f"{pname}->{ch}")
if int(pdata.get("default", 0)) == 1:
defaults.append(pname)
lines.append(
f" #{bid}: {name} @ {loc} | igniter={ign} | "
f"pumps={pumps} | default_on={defaults}"
)
return "\n".join(lines)
# -----------------------------------------------------
# Temizlik
# -----------------------------------------------------
def cleanup(self) -> None:
"""
GPIO pinlerini serbest bırakır.
"""
if _HAS_GPIO:
GPIO.cleanup()
if __name__ == "__main__":
drv = RelayDriver()
print("\n🧰 RelayDriver Summary")
print(drv.summary())

View File

@@ -0,0 +1,463 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
"""
ebuild/io/legacy_syslog.py
Legacy tarzı BRULOR syslog çıktısı üretmek için yardımcı fonksiyonlar.
- Syslog (/dev/log) + stdout'a aynı formatta basar.
- burner.BurnerController tarafından her tick'te çağrılan log_burner_header()
ile eski sistemdeki benzer satırları üretir.
"""
__title__ = "legacy_syslog"
__author__ = 'Mehmet Karatay & "Saraswati" (ChatGPT)'
__purpose__ = "Legacy tarzı syslog çıktısı üreten köprü"
__version__ = "0.3.0"
__date__ = "2025-11-23"
from datetime import datetime, time, timedelta
from typing import Optional, Iterable, Tuple, Dict
import logging
import logging.handlers
try:
# Mevsim + güneş bilgileri için
from ..core.season import SeasonController # type: ignore
from .. import config_statics as cfg # type: ignore
except ImportError: # test / standalone
SeasonController = None # type: ignore
cfg = None # type: ignore
try:
# Çalışma parametreleri (konfor offset, max çıkış vb.)
from .. import config_runtime as cfg_v # type: ignore
except ImportError:
cfg_v = None # type: ignore
try:
# Hat sensörlerinden doğrudan okuma için
from ..io.ds18b20 import DS18B20Sensor # type: ignore
except Exception:
DS18B20Sensor = None # type: ignore
#print("legacy_syslog IMPORT EDİLDİ:", __file__)
# ----------------------------------------------------------------------
# Logger kurulumu (Syslog + stdout)
# ----------------------------------------------------------------------
_LOGGER: Optional[logging.Logger] = None
def _get_logger() -> logging.Logger:
global _LOGGER
if _LOGGER is not None:
return _LOGGER
logger = logging.getLogger("BRULOR")
logger.setLevel(logging.INFO)
if not logger.handlers:
# Syslog handler (Linux: /dev/log)
try:
syslog_handler = logging.handlers.SysLogHandler(address="/dev/log")
# Syslog mesaj formatı: "BRULOR: [ 1 ... ]"
fmt = logging.Formatter("%(name)s: %(message)s")
syslog_handler.setFormatter(fmt)
logger.addHandler(syslog_handler)
except Exception as e:
print("legacy_syslog: SysLogHandler eklenemedi:", e)
# Ayrıca stdout'a da yaz (debug için)
stream_handler = logging.StreamHandler()
stream_fmt = logging.Formatter("BRULOR: %(message)s")
stream_handler.setFormatter(stream_fmt)
logger.addHandler(stream_handler)
_LOGGER = logger
return logger
def send_legacy_syslog(message: str) -> None:
"""
Verilen mesajı legacy syslog formatına uygun şekilde ilgili hedefe gönderir.
- Syslog (/dev/log) → program adı: BRULOR
- Aynı zamanda stdout'a da yazar (DEBUG amaçlı)
"""
try:
logger = _get_logger()
logger.info(message)
except Exception as e:
# Logger bir sebeple çökerse bile BRULOR satırını kaybetmeyelim
print("BRULOR:", message, f"(logger error: {e})")
def format_line(line_no: int, body: str) -> str:
"""
BRULOR satırını klasik formata göre hazırlar.
line_no = 2, body = "Sunrise:07:39 Sunset:17:29 Sistem: On Lic:10094"
"[ 2 Sunrise:07:39 Sunset:17:29 Sistem: On Lic:10094]"
"""
return f"[{line_no:3d} {body}]"
# ----------------------------------------------------------------------
# Header yardımcıları
# ----------------------------------------------------------------------
def _format_version_3part(ver: str) -> str:
"""
"0.2.1""00.02.01" formatı.
"""
parts = (ver or "").split(".")
nums = []
for p in parts:
try:
nums.append(int(p))
except ValueError:
nums.append(0)
while len(nums) < 3:
nums.append(0)
return f"{nums[0]:02d}.{nums[1]:02d}.{nums[2]:02d}"
def emit_header_version(line_no: int, now: datetime) -> int:
"""
1. satır: versiyon + zaman bilgisi.
************** 00.02.01 2025-11-22 18:15:00 *************
"""
v_str = _format_version_3part(__version__)
body = f"************** {v_str} {now.strftime('%Y-%m-%d %H:%M:%S')} *************"
send_legacy_syslog(format_line(line_no, body))
return line_no + 1
def emit_header_sunrise(
line_no: int,
sunrise: time,
sunset: time,
system_on: bool,
licence_id: int,
) -> int:
"""
2. satır: Sunrise/Sunset + sistem On/Off + Lisans id satırı.
Sunrise:07:39 Sunset:17:29 Sistem: On Lic:10094
"""
def _fmt_t(t: time) -> str:
if not t:
return "--:--"
return t.strftime("%H:%M")
sun_str = f"Sunrise:{_fmt_t(sunrise)} Sunset:{_fmt_t(sunset)} "
sys_str = "On" if system_on else "Off"
body = f"{sun_str}Sistem: {sys_str} Lic:{licence_id}"
send_legacy_syslog(format_line(line_no, body))
return line_no + 1
def _only_date(s: str) -> str:
"""
ISO tarih-zaman stringinden sadece YYYY-MM-DD kısmını alır.
"""
if not s:
return "--"
s = s.strip()
if "T" in s:
s = s.split("T", 1)[0]
return s
def emit_header_season(line_no: int, season_ctrl) -> int:
info = getattr(season_ctrl, "info", season_ctrl)
# 1) SEASON satırı (bunu zaten yazıyorsun)
season_name = getattr(info, "season", "Unknown")
s_start = getattr(info, "season_start", "")
s_end = getattr(info, "season_end", "")
day_total = getattr(info, "season_day", 0) or 0
day_passed = getattr(info, "season_passed", 0) or 0
day_left = getattr(info, "season_remaining", 0) or 0
body = (
f"season : {season_name} {s_start} - {s_end} "
f"[{day_total} pass:{day_passed} kalan:{day_left}]"
)
send_legacy_syslog(format_line(line_no, body))
line_no += 1
# 2) BAHAR / TASARRUF SATIRI
# SeasonInfo'dan tasarruf penceresini alıyoruz
saving_start = getattr(info, "saving_start", None)
saving_stop = getattr(info, "saving_stop", None)
today = getattr(info, "date", None)
# saving_start/stop yoksa bahar satırı YOK
if saving_start is None or saving_stop is None or today is None:
return line_no
# Gösterim penceresi: saving_start - 3 gün .. saving_stop + 3 gün
window_start = saving_start - timedelta(days=3)
window_stop = saving_stop + timedelta(days=3)
if window_start <= today <= window_stop:
# Syslog'ta görünen tarih aralığı GERÇEK tasarruf aralığı olsun:
# saving_start / saving_stop
bahar_bas = saving_start.isoformat()
bahar_bit = saving_stop.isoformat()
body2 = f"bahar : {bahar_bas} - {bahar_bit}"
send_legacy_syslog(format_line(line_no, body2))
line_no += 1
return line_no
def emit_header_holiday(
line_no: int,
is_holiday: bool,
holiday_label: str,
) -> int:
"""
Tatil satırı (sunrise + season altına).
"""
if not is_holiday:
return line_no
label = holiday_label or ""
body = f"Tatil: True Adı: {label}"
send_legacy_syslog(format_line(line_no, body))
return line_no + 1
def emit_top_block(
now: datetime,
season_ctrl: "SeasonController",
) -> int:
"""
F veya B modundan bağımsız olarak, her tick başında üst bilgiyi üretir.
Dönüş: bir sonraki satır numarası.
"""
line_no = 1
# 1: versiyon + zaman
line_no = emit_header_version(line_no, now)
# 2: güneş bilgisi
info = getattr(season_ctrl, "info", season_ctrl)
sunrise = getattr(info, "sunrise", None)
sunset = getattr(info, "sunset", None)
system_on = bool(getattr(info, "system_on", True))
licence_id = int(
getattr(info, "licence_id", getattr(cfg, "BUILDING_LICENCEID", 0))
if cfg is not None
else 0
)
line_no = emit_header_sunrise(
line_no=line_no,
sunrise=sunrise,
sunset=sunset,
system_on=system_on,
licence_id=licence_id,
)
# 3: mevsim
line_no = emit_header_season(line_no, season_ctrl)
# 4: tatil (varsa)
is_hol = bool(getattr(info, "is_holiday", False))
hol_label = getattr(info, "holiday_label", "")
line_no = emit_header_holiday(line_no, is_hol, hol_label)
return line_no
# ----------------------------------------------------------------------
# Hat sensörleri + brülör header
# ----------------------------------------------------------------------
def _fmt_temp(t: Optional[float]) -> str:
if t is None:
return "None°C"
return f"{t:5.2f}°C"
def _read_line_temps_from_config() -> Dict[str, Optional[float]]:
"""
OUTSIDE_SENSOR_ID, BURNER_OUT_SENSOR_ID ve RETURN_LINE_SENSOR_IDS
için DS18B20 üzerinden anlık sıcaklıkları okur.
"""
temps: Dict[str, Optional[float]] = {}
if DS18B20Sensor is None or cfg is None:
return temps
outside_id = getattr(cfg, "OUTSIDE_SENSOR_ID", "") or ""
out_id = getattr(cfg, "BURNER_OUT_SENSOR_ID", "") or ""
ret_ids = list(getattr(cfg, "RETURN_LINE_SENSOR_IDS", []) or [])
all_ids = []
if outside_id:
all_ids.append(outside_id)
if out_id:
all_ids.append(out_id)
all_ids.extend([sid for sid in ret_ids if sid])
for sid in all_ids:
try:
sensor = DS18B20Sensor(serial=sid)
temps[sid] = sensor.read_temperature()
except Exception:
temps[sid] = None
return temps
def log_burner_header(
now: datetime,
mode: str,
season: "SeasonController",
building_avg: Optional[float],
outside_c: Optional[float],
used_out_c: Optional[float],
fire_sp: float,
burner_on: bool,
pumps_on: Iterable[str],
) -> None:
"""
burner.BurnerController.tick() her çağrıldığında; header + bina özet +
hat sensörleri + sistem ısı + brülör/devirdaim satırlarını üretir.
"""
try:
# 1) Üst blok
if season is not None and SeasonController is not None:
line_no = emit_top_block(now, season)
else:
line_no = emit_header_version(1, now)
# 2) Bina ısı satırı
mode = (mode or "?").upper()
cfg_mode = ( str(getattr(cfg, "BUILD_BURNER", mode)).upper() if cfg is not None else mode )
outside_limit = float(getattr(cfg_v, "OUTSIDE_LIMIT_HEAT_C", 0.0))
min_c = None
max_c = None
avg_c = building_avg
body_build = (
f"Build [{mode}-{cfg_mode}] "
f"Heats[Min:{_fmt_temp(min_c)} Avg:{_fmt_temp(avg_c)} Max:{_fmt_temp(max_c)}] L:{outside_limit}"
)
send_legacy_syslog(format_line(line_no, body_build))
line_no += 1
# 3) Hat sensörleri
line_temps = _read_line_temps_from_config()
outside_id = getattr(cfg, "OUTSIDE_SENSOR_ID", "") if cfg is not None else ""
outside_name = (
getattr(cfg, "OUTSIDE_SENSOR_NAME", "Dış Isı 1")
if cfg is not None
else "Dış Isı 1"
)
out_id = getattr(cfg, "BURNER_OUT_SENSOR_ID", "") if cfg is not None else ""
out_name = (
getattr(cfg, "BURNER_OUT_SENSOR_NAME", "Çıkış Isı 2")
if cfg is not None
else "Çıkış Isı 2"
)
ret_ids = (
list(getattr(cfg, "RETURN_LINE_SENSOR_IDS", []) or [])
if cfg is not None
else []
)
name_map = getattr(cfg, "RETURN_LINE_SENSOR_NAME_MAP", {}) if cfg is not None else {}
if outside_id:
t = line_temps.get(outside_id, outside_c)
body = f"{outside_name:<15}: {_fmt_temp(t)} - {outside_id} "
send_legacy_syslog(format_line(line_no, body))
line_no += 1
if out_id:
t = line_temps.get(out_id)
body = f"{out_name:<15}: {_fmt_temp(t)} - {out_id} "
send_legacy_syslog(format_line(line_no, body))
line_no += 1
for sid in ret_ids:
if not sid:
continue
t = line_temps.get(sid)
nm = name_map.get(sid, sid)
body = f"{nm:<15}: {_fmt_temp(t)} - {sid} "
send_legacy_syslog(format_line(line_no, body))
line_no += 1
# 4) Sistem ısı satırı (used_out + weekend/comfort offset)
weekend_boost = (
float(getattr(cfg_v, "WEEKEND_HEAT_BOOST_C", 0.0))
if cfg_v is not None
else 0.0
)
comfort_off = (
float(getattr(cfg_v, "BURNER_COMFORT_OFFSET_C", 0.0))
if cfg_v is not None
else 0.0
)
body_sys = (
f"Sistem Isı : {_fmt_temp(used_out_c)} "
f"[w:{weekend_boost:g} c:{comfort_off:g}]"
)
send_legacy_syslog(format_line(line_no, body_sys))
line_no += 1
# 5) Brülör / devirdaim satırları (istatistikler şimdilik 0)
max_out = (
float(getattr(cfg_v, "MAX_OUTLET_C", fire_sp))
if cfg_v is not None
else fire_sp
)
br_status = "<CALISIYOR>" if burner_on else "<CALISMIYOR>"
br_flag = 1 if burner_on else 0
body_br = (
f"Brulor Motor : {br_status} [{br_flag}] "
f"0 00:00:00 00:00:00 L:{max_out:.1f}"
)
send_legacy_syslog(format_line(line_no, body_br))
line_no += 1
pumps_on_list = list(pumps_on or [])
pump_count = len(pumps_on_list)
dev_status = "<CALISIYOR>" if pump_count > 0 else "<CALISMIYOR>"
min_ret = (
float(getattr(cfg_v, "CIRCULATION_MIN_RETURN_C", 25.0))
if cfg_v is not None
else 25.0
)
pumps_str = ",".join(pumps_on_list) if pumps_on_list else "-"
body_dev = (
f"Devirdaim Mot: {dev_status} "
f"[{pump_count}] 0 00:00:00 00:00:00 "
f"L:{pumps_str} {min_ret:.1f}"
)
send_legacy_syslog(format_line(line_no, body_dev))
except Exception as exc:
print("BRULOR log_burner_header ERROR:", exc)
if __name__ == "__main__":
# Basit standalone test
now = datetime.now()
if SeasonController is None:
emit_header_version(1, now)
else:
sc = SeasonController()
log_burner_header(
now=now,
mode="F",
season=sc,
building_avg=None,
outside_c=None,
used_out_c=None,
fire_sp=45.0,
burner_on=False,
pumps_on=(),
)

608
ebuild/io/zlegacy_syslog.py Normal file
View File

@@ -0,0 +1,608 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
__title__ = "legacy_syslog"
__author__ = 'Mehmet Karatay & "Saraswati" (ChatGPT)'
__purpose__ = "Legacy tarzı syslog çıktısı üreten köprü"
__version__ = "0.2.1"
__date__ = "2025-11-22"
"""
ebuild/io/legacy_syslog.py
Revision : 2025-11-22
Authors : Mehmet Karatay & "Saraswati" (ChatGPT)
Amaç
-----
Eski Rasp2 tabanlı sistemin syslog çıktısını, yeni ebuild mimarisi ile
uyumlu ve okunaklı şekilde üretir. Çıktı şu ana bloklardan oluşur:
1) Üst bilgi:
- Versiyon ve zaman satırı
- Güneş bilgisi (sunrise / sunset, sistem On/Off, lisans id)
- Mevsim bilgisi (season, bahar dönemi bilgisi)
- Tatil bilgisi (varsa adıyla)
2) Bina ısı bilgisi
- Bina Isı : [ min - avg - max ]
3) Hat sensörleri (burner.py içinden doldurulan kısım):
- Dış Isı 1
- Çıkış Isı 2
- Dönüş hatları (isim map'inden)
4) Used dış ısı
5) Brülör / devirdaim / özet satırı
Not
---
Bu modül sadece formatlama ve çıktı üretiminden sorumludur. Gerçek
ölçümler ve kontrol kararları üst katmanlardan (HeatEngine, Burner,
Building, Environment, SeasonController vb.) alınır.
"""
# Bu modül gerçekten hangi path'ten import ediliyor, görmek için:
# ---------------------------------------------------------
def _safe_import(desc, import_func):
"""
desc: ekranda görünecek ad (örn: 'Building', 'legacy_syslog')
import_func: gerçek import'u yapan lambda
"""
try:
obj = import_func()
#print(f"legacy_syslog.py [IMPORT OK] {desc} ->", obj)
return obj
except Exception as e:
print(f"legacy_syslog.py [IMPORT FAIL] {desc}: {e}")
traceback.print_exc()
return None
from datetime import datetime, time
from typing import Optional
import logging
import logging.handlers
try:
# SeasonController ve konfig
from ..core.season import SeasonController
cfg = _safe_import( "config_statics", lambda: __import__("ebuild.config_statics", fromlist=["*"]),)
cfv = _safe_import( "config_runtime", lambda: __import__("ebuild.config_runtime", fromlist=["*"]),)
#from .. import config_statics as cfg
except ImportError: # test / standalone
SeasonController = None # type: ignore
cfg = None # type: ignore
cfv = None
print("SeasonController, config_statics import ERROR")
# ----------------------------------------------------------------------
# Logger kurulumu (Syslog + stdout)
# ----------------------------------------------------------------------
_LOGGER: Optional[logging.Logger] = None
def _get_logger() -> logging.Logger:
global _LOGGER
if _LOGGER is not None:
return _LOGGER
#print("logger..1:", stream_fmt)
logger = logging.getLogger("BRULOR")
logger.setLevel(logging.INFO)
# Aynı handler'ları ikinci kez eklemeyelim
if not logger.handlers:
# Syslog handler (Linux: /dev/log)
try:
syslog_handler = logging.handlers.SysLogHandler(address="/dev/log")
# Syslog mesaj formatı: "BRULOR: [ 1 ... ]"
fmt = logging.Formatter("%(name)s: %(message)s")
syslog_handler.setFormatter(fmt)
logger.addHandler(syslog_handler)
except Exception:
# /dev/log yoksa sessizce geç; sadece stdout'a yazacağız
pass
# Konsol çıktısı (debug için)
stream_handler = logging.StreamHandler()
stream_fmt = logging.Formatter("INFO:BRULOR:%(message)s")
stream_handler.setFormatter(stream_fmt)
logger.addHandler(stream_handler)
print("logger..2:", stream_fmt)
_LOGGER = logger
return logger
# ----------------------------------------------------------------------
# Temel çıktı fonksiyonları
# ----------------------------------------------------------------------
def send_legacy_syslog(message: str) -> None:
"""
Verilen mesajı legacy syslog formatına uygun şekilde ilgili hedefe gönderir.
- Syslog (/dev/log) → program adı: BRULOR
- Aynı zamanda stdout'a da yazar (DEBUG amaçlı)
"""
#print("send_legacy_syslog BRULOR:", message)
try:
logger = _get_logger()
logger.info(message)
except Exception as e:
# Logger bir sebeple çökerse bile BRULOR satırını kaybetmeyelim
print("BRULOR:", message, f"(logger error: {e})")
def format_line(line_no: int, body: str) -> str:
"""
BRULOR satırını klasik formata göre hazırlar.
Örnek:
line_no = 2, body = "Sunrise:07:39 Sunset:17:29 Sistem: On Lic:10094"
"[ 2 Sunrise:07:39 Sunset:17:29 Sistem: On Lic:10094]"
Not:
Burada "BRULOR" yazmıyoruz; syslog program adı zaten BRULOR olacak.
"""
return f"[{line_no:3d} {body}]"
def _format_version_3part(ver: str) -> str:
"""
__version__ string'ini "00.02.01" formatına çevirir.
Örnek:
"0.2.1""00.02.01"
"""
parts = (ver or "").split(".")
nums = []
for p in parts:
try:
nums.append(int(p))
except ValueError:
nums.append(0)
while len(nums) < 3:
nums.append(0)
return f"{nums[0]:02d}.{nums[1]:02d}.{nums[2]:02d}"
# ----------------------------------------------------------------------
# Üst blok (header) üreticiler
# ----------------------------------------------------------------------
def emit_header_version(line_no: int, now: datetime) -> int:
"""
1. satır: versiyon + zaman bilgisi.
Örnek:
************** 00.02.01 2025-11-22 18:15:00 *************
"""
v_str = _format_version_3part(__version__)
body = f"************** {v_str} {now.strftime('%Y-%m-%d %H:%M:%S')} *************"
send_legacy_syslog(format_line(line_no, body))
return line_no + 1
def emit_header_sun_and_system(
line_no: int,
sunrise: Optional[time],
sunset: Optional[time],
system_on: bool,
licence_id: int,
) -> int:
"""
2. satır: Güneş bilgisi + Sistem On/Off + Lisans id.
Örnek:
[ 2 Sunrise:07:39 Sunset:17:29 Sistem: On Lic:10094]
"""
sun_str = ""
if sunrise is not None:
sun_str += f"Sunrise:{sunrise.strftime('%H:%M')} "
if sunset is not None:
sun_str += f"Sunset:{sunset.strftime('%H:%M')} "
sys_str = "On" if system_on else "Off"
body = f"{sun_str}Sistem: {sys_str} Lic:{licence_id}"
send_legacy_syslog(format_line(line_no, body))
return line_no + 1
def _only_date(s: str) -> str:
"""
ISO tarih-zaman stringinden sadece YYYY-MM-DD kısmını alır.
Örn: '2025-09-23T16:33:10.687982+03:00''2025-09-23'
"""
if not s:
return "--"
s = s.strip()
if "T" in s:
return s.split("T", 1)[0]
return s
def emit_header_season(
line_no: int,
season_ctrl: SeasonController,
) -> int:
"""
Sunrise satırının altına mevsim + (varsa) bahar tasarruf dönemi satırını basar.
Beklenen format:
BRULOR [ 3 season : Sonbahar 2025-09-23 - 2025-12-20 [89 pass:60 kalan:28] ]
BRULOR [ 4 bahar : 2025-09-23 - 2025-10-13 ]
Notlar:
- Bilgiler SeasonController.info içinden okunur (dict veya obje olabilir).
- bahar_tasarruf True DEĞİLSE bahar satırı hiç basılmaz.
"""
# SeasonController.info hem dict hem obje olabilir, ikisini de destekle
info = getattr(season_ctrl, "info", season_ctrl)
def _get(field: str, default=None):
if isinstance(info, dict):
return info.get(field, default)
return getattr(info, field, default)
# ---- 3. satır: season ----
season_name = _get("season", "Unknown")
season_start = _only_date(_get("season_start", ""))
season_end = _only_date(_get("season_end", ""))
season_day = _get("season_day", "")
season_passed = _get("season_passed", "")
season_remain = _get("season_remaining", "")
body = (
f"season : {season_name} {season_start} - {season_end} "
f"[{season_day} pass:{season_passed} kalan:{season_remain}]"
)
send_legacy_syslog(format_line(line_no, body))
line_no += 1
# ---- 4. satır: bahar dönemi (SADECE aktifse) ----
bahar_tasarruf = bool(_get("bahar_tasarruf", False))
if bahar_tasarruf:
bahar_basx = _only_date(_get("bahar_basx", ""))
bahar_bitx = _only_date(_get("bahar_bitx", ""))
body = f"bahar : {bahar_basx} - {bahar_bitx}"
send_legacy_syslog(format_line(line_no, body))
line_no += 1
return line_no
def emit_header_holiday(
line_no: int,
is_holiday: bool,
holiday_label: str,
) -> int:
"""
Tatil satırı (sunrise + season altına).
Kurallar:
- Tatil yoksa (False) HİÇ satır basma.
- Tatil varsa:
[ 5 Tatil: True Adı: Cumhuriyet Bayramı]
"""
if not is_holiday:
return line_no
label = holiday_label or ""
body = f"Tatil: True Adı: {label}"
send_legacy_syslog(format_line(line_no, body))
return line_no + 1
# ----------------------------------------------------------------------
# Dışarıdan çağrılacak üst-blok helper
# ----------------------------------------------------------------------
def emit_top_block(
now: datetime,
season_ctrl: SeasonController,
) -> int:
"""
F veya B modundan bağımsız olarak, her tick başında üst bilgiyi üretir.
Sıra:
1) Versiyon + zaman
2) Sunrise / Sunset / Sistem: On/Off / Lic
3) Mevsim bilgisi (SeasonController.to_syslog_lines() → sadeleştirilmiş)
4) Tatil bilgisi (sadece tatil varsa)
5) Bir sonraki satır numarasını döndürür (bina ısı satırları için).
"""
line_no = 1
# 1) Versiyon
line_no = emit_header_version(line_no, now)
# Konfigten sistem ve lisans bilgileri
if cfg is not None:
licence_id = int(getattr(cfg, "BUILDING_LICENCEID", 0))
system_onoff = int(getattr(cfg, "BUILDING_SYSTEMONOFF", 1))
else:
licence_id = 0
system_onoff = 1
system_on = (system_onoff == 1)
# 2) Güneş + Sistem / Lisans
sunrise = season_ctrl.info.sunrise
sunset = season_ctrl.info.sunset
line_no = emit_header_sun_and_system(
line_no=line_no,
sunrise=sunrise,
sunset=sunset,
system_on=system_on,
licence_id=licence_id,
)
# 3) Mevsim bilgisi (sunrise ALTINA)
line_no = emit_header_season(line_no, season_ctrl)
# 4) Tatil bilgisi (sadece True ise)
line_no = emit_header_holiday(
line_no=line_no,
is_holiday=season_ctrl.info.is_holiday,
holiday_label=season_ctrl.info.holiday_label,
)
# Sonraki satır: bina ısı / dış ısı / F-B detayları için kullanılacak
return line_no
def _fmt_temp(val: Optional[float]) -> str:
return "None" if val is None else f"{val:.2f}"
PUMP_SHORT_MAP = {
"circulation_a": "A",
"circulation_b": "B",
"circ_1": "A",
"circ_2": "B",
}
def _short_pump_name(ch: str) -> str:
if ch in PUMP_SHORT_MAP:
return PUMP_SHORT_MAP[ch]
# sonu _a/_b ise yine yakala
if ch.endswith("_a"):
return "A"
if ch.endswith("_b"):
return "B"
return ch # tanımıyorsak orijinal ismi yaz
def log_burner_header(
now: datetime,
mode: str,
season,
building_avg: Optional[float],
outside_c: Optional[float],
used_out_c: Optional[float],
fire_sp: float,
burner_on: bool,
pumps_on,
line_temps: Optional[Dict[str, Optional[float]]] = None,
ign_stats=None,
circ_stats=None,
) -> None:
"""
BurnerController'dan tek çağrıyla BRULOR bloğunu basar.
- Önce üst blok (versiyon + güneş + mevsim + tatil)
- Sonra bina ısı satırı
- Dış ısı / used dış ısı
- Son satırda brülör ve pompaların durumu
"""
#print("log_burner_header CALLED", season)
# 1) Üst header blok
if season is None:
# SeasonController yoksa, sadece versiyon ve zaman bas
line_no = 1
v_str = _format_version_3part(__version__)
body = f"************** {v_str} {now.strftime('%Y-%m-%d %H:%M:%S')} *************"
send_legacy_syslog(format_line(line_no, body))
line_no += 1
else:
line_no = emit_top_block(now, season)
# 2) Bina ısı satırı
if building_avg is None:
min_s = "None"
avg_s = "None"
max_s = "None"
else:
# Şimdilik min=avg=max gibi davranalım; ileride gerçek min/max eklenebilir
min_s = f"{building_min:5.2f}"
avg_s = f"{building_avg:5.2f}"
max_s = f"{building_max:5.2f}"
# configteki mod
cfg_mode = getattr(cfg, "BUILD_BURNER", "?") if cfg is not None else "?"
body = f"Build [{mode}-{cfg_mode}] Heats[Min:{min_s}°C Avg:{avg_s}°C Max:{max_s}°C]"
send_legacy_syslog(format_line(line_no, body))
line_no += 1
# line_temps yoksa, burayı pas geç
if line_temps is not None:
# CONFIG'TEN ID'LERİ AL
outside_id = getattr(cfg, "OUTSIDE_SENSOR_ID", None) if cfg is not None else None
out_id = getattr(cfg, "BURNER_OUT_SENSOR_ID", None) if cfg is not None else None
ret_ids = getattr(cfg, "RETURN_LINE_SENSOR_IDS", []) if cfg is not None else []
ret_map = getattr(cfg, "RETURN_LINE_SENSOR_NAME_MAP", {}) if cfg is not None else {}
line_no = 4 # dış ısı satırı numarası
# 4: Dis isi
if outside_id and outside_id in line_temps:
t = line_temps.get(outside_id)
namex = getattr(cfg, "OUTSIDE_SENSOR_NAME", "Dis isi") if cfg is not None else "Dis isi"
msg = f"{namex:<15.15}: {_fmt_temp(t)}°C - {outside_id} "
send_legacy_syslog(format_line(line_no, msg))
line_no += 1
# 5: Cikis isi
if out_id and out_id in line_temps:
t = line_temps.get(out_id)
namex = getattr(cfg, "BURNER_OUT_SENSOR_NAME", "Cikis isi") if cfg is not None else "Cıkıs isi"
msg = f"{namex:<15.15}: {_fmt_temp(t)}°C - {out_id} "
send_legacy_syslog(format_line(line_no, msg))
line_no += 1
# 6..N: Donus isi X
namex = getattr(cfg, "RETURN_LINE_SENSOR_NAME_MAP",[])
for sid in ret_ids:
if sid not in line_temps:
continue
t = line_temps.get(sid)
try:
namexx = ret_map.get(sid)
except:
namex = '???'
msg = f"{namexx:<15.15}: {_fmt_temp(t)}°C - {sid} "
send_legacy_syslog(format_line(line_no, msg))
line_no += 1
# 3) Dış ısı / used dış ısı
out_str = "--"
used_str = "--"
if outside_c is not None:
out_str = f"{outside_c:5.2f}"
if used_out_c is not None:
used_str = f"{used_out_c:5.2f}"
usedxx = "Sistem Isı"
#------------------------------------------------------------------
# 9: Sistem Isı - Used + [WEEKEND_HEAT_BOOST_C, BURNER_COMFORT_OFFSET_C]
# ------------------------------------------------------------------
used_val = used_out_c if used_out_c is not None else None
used_str = "None" if used_val is None else f"{used_val:.2f}"
if cfv is not None:
w_val = float(getattr(cfv, "WEEKEND_HEAT_BOOST_C", 0.0) or 0.0)
c_val = float(getattr(cfv, "BURNER_COMFORT_OFFSET_C", 0.0) or 0.0)
else:
w_val = 0.0
c_val = 0.0
# Sayıları [2, 1] gibi, gereksiz .0sız yazalım
def _fmt_num(x: float) -> str:
if x == int(x):
return str(int(x))
return f"{x:g}"
sabitler_str = f"[w:{_fmt_num(w_val)} c:{_fmt_num(c_val)}]"
body = f"{usedxx:<15.15}: {used_str}°C {sabitler_str} "
send_legacy_syslog(format_line(line_no, body))
line_no += 1
# ------------------------------------------------------------------
# 11: Brülör Motor satırı (MAX_OUTLET_C ile)
# ------------------------------------------------------------------
if cfv is not None:
max_out = float(getattr(cfv, "MAX_OUTLET_C", 45.0) or 45.0)
else:
max_out = 45.0
if cfv is not None:
min_ret = float(getattr(cfv, "CIRCULATION_MIN_RETURN_C", 25.0) or 25.0)
else:
min_ret = 25.0
br_status = "<CALISIYOR>" if burner_on else "<CALISMIYOR>"
br_flag = 1 if burner_on else 0
ign_sw = 0
ign_total = "00:00:00"
ign_today = "00:00:00"
if ign_stats:
ign_sw = ign_stats.get("switch_count", 0)
ign_total = ign_stats.get("total_on_str", "00:00:00")
ign_today = ign_stats.get("today_on_str", "00:00:00")
# Eski stile benzeteceğiz:
# [ 11 Brulor Motor : <CALISMIYOR> [0] 0 00:00:00 00:00:00 L:45.0 ]
body11 = (
f"Brulor Motor : {br_status} "
f"[{br_flag}] {ign_sw} {ign_total} {ign_today} L:{max_out:.1f}"
)
send_legacy_syslog(format_line(line_no, body11))
line_no += 1
# ------------------------------------------------------------------
# 12: Devirdaim Motor satırı (CIRCULATION_MIN_RETURN_C ile)
# ------------------------------------------------------------------
ch_to_logical = {}
pumps_on_list = list(pumps_on) if pumps_on else []
# --- circulation mapping: channel -> logical ('circ_1', 'circ_2') ---
ch_to_logical = {}
cfg_groups = getattr(cfg, "BURNER_GROUPS", {})
# ileride çoklu brülör olursa buraya burner_id parametresi de geçirsin istersen
grp = cfg_groups.get(0, {})
circ_cfg = grp.get("circulation", {}) or {}
for logical_name, info in circ_cfg.items():
ch = info.get("channel")
if ch:
ch_to_logical[ch] = logical_name
# Configte default=1 olan pompaları da topla (cfg_default_pumps)
cfg_default_pumps = []
for logical_name, info in circ_cfg.items():
ch = info.get("channel")
if ch and info.get("default", 0):
cfg_default_pumps.append(ch)
# Kısa isim A/B istersek:
def _logical_to_short(name: str) -> str:
if name == "circ_1":
return "A"
if name == "circ_2":
return "B"
return name
pump_count = len(cfg_default_pumps)
dev_status = "<CALISIYOR>" if pump_count > 0 else "<CALISMIYOR>"
pump_labels = []
for ch in cfg_default_pumps:
logical = ch_to_logical.get(ch)
if logical is not None:
pump_labels.append(_logical_to_short(logical))
else:
pump_labels.append(ch)
pumps_str = ",".join(pump_labels) if pump_labels else "-"
cir_sw = 0
cir_total = "00:00:00"
cir_today = "00:00:00"
if circ_stats:
cir_sw = circ_stats.get("switch_count", 0)
cir_total = circ_stats.get("total_on_str", "00:00:00")
cir_today = circ_stats.get("today_on_str", "00:00:00")
# [ 12 Devirdaim Mot: <CALISMIYOR> [0] 0 00:00:00 00:00:00 L:25.0]
body12 = (
f"Devirdaim Mot: {dev_status} "
f"[{pump_count}] {br_flag}] {cir_sw} {cir_total} {cir_today} L:{pumps_str} {min_ret:.1f}"
)
send_legacy_syslog(format_line(line_no, body12))
line_no += 1
return line_no
# ----------------------------------------------------------------------
# Örnek kullanım (standalone test)
# ----------------------------------------------------------------------
if __name__ == "__main__":
# Bu blok sadece modülü tek başına test etmek için:
# python3 -m ebuild.io.legacy_syslog
if SeasonController is None:
raise SystemExit("SeasonController import edilemedi (test ortamı).")
now = datetime.now()
# SeasonController.from_now() kullanıyorsan:
try:
season = SeasonController.from_now()
except Exception as e:
raise SystemExit(f"SeasonController.from_now() hata: {e}")
next_line = emit_top_block(now, season)
# Test için bina ısısını dummy bas:
body = "Bina Isı : [ 20.10 - 22.30 - 24.50 ]"
send_legacy_syslog(format_line(next_line, body))

362
ebuild/io/zrelay_driver.py Normal file
View File

@@ -0,0 +1,362 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
__title__ = "relay_driver"
__author__ = 'Mehmet Karatay & "Saraswati" (ChatGPT)'
__purpose__ = "GPIO röle sürücüsü + brülör grup soyutlaması"
__version__ = "0.4.0"
__date__ = "2025-11-22"
"""
ebuild/io/relay_driver.py
Revision : 2025-11-22
Authors : Mehmet Karatay & "Saraswati" (ChatGPT)
Amaç
-----
- Soyut kanal isimleri ile (igniter, circulation_a, ...) GPIO pin sürmek.
- config_statics.BURNER_GROUPS üzerinden brülör gruplarını yönetmek.
- Her kanal için:
* ON/OFF sayacı
* Son çalışma süresi
* Toplam çalışma süresi
* Şu anki çalışma süresi (eğer röle ON ise, anlık akan süre)
istatistiklerini tutmak.
Kullanım
--------
- Temel kanal API:
drv.channels() → ['igniter', 'circulation_a', ...]
drv.set_channel("igniter", True/False)
drv.get_stats("igniter") → RelayStats
drv.get_channel_state("igniter") → bool (şu an ON mu?)
- Brülör grup API:
drv.burners() → [0, 1, ...]
drv.burner_info(0) → config_statics.BURNER_GROUPS[0]
drv.igniter_channel(0) → "igniter"
drv.all_pumps(0) → ['circulation_a', 'circulation_b', ...]
drv.enabled_pumps(0) → default=1 olan pompalar
drv.active_pumps(0) → şu anda gerçekten ON olan pompalar
Bu API'ler burner.py ve legacy_syslog.py tarafından kullanılmak üzere tasarlanmıştır.
"""
import time
from dataclasses import dataclass, field
from typing import Dict, Optional, Iterable, Tuple, List
try:
import RPi.GPIO as GPIO
_HAS_GPIO = True
except ImportError:
_HAS_GPIO = False
from .. import config_statics as cfg
# -------------------------------------------------------------------
# İstatistik yapısı
# -------------------------------------------------------------------
@dataclass
class RelayStats:
"""
Tek bir röle kanalı için istatistikler.
- on_count : kaç defa ON'a çekildi
- last_on_ts : en son ON'a çekildiği zaman (epoch saniye)
- last_off_ts : en son OFF olduğu zaman (epoch saniye)
- last_duration_s : en son ON periyodunun süresi (saniye)
- total_on_s : bugüne kadar toplam ON kalma süresi (saniye)
"""
on_count: int = 0
last_on_ts: Optional[float] = None
last_off_ts: Optional[float] = None
last_duration_s: float = 0.0
total_on_s: float = 0.0
def on(self, now: float) -> None:
"""
Kanal ON'a çekildiğinde çağrılır.
Aynı ON periyodu içinde tekrar çağrılırsa sayaç artmaz.
"""
if self.last_on_ts is None:
self.last_on_ts = now
self.on_count += 1
def off(self, now: float) -> None:
"""
Kanal OFF'a çekildiğinde çağrılır.
Son ON zamanına göre süre hesaplanır, last_duration_s ve total_on_s güncellenir.
"""
if self.last_on_ts is not None:
dur = max(0.0, now - self.last_on_ts)
self.last_duration_s = dur
self.total_on_s += dur
self.last_on_ts = None
self.last_off_ts = now
def current_duration(self, now: Optional[float] = None) -> float:
"""
Kanal şu anda ON ise, bu ON periyodunun şu ana kadarki süresini döndürür.
OFF ise 0.0 döner.
"""
if self.last_on_ts is None:
return 0.0
if now is None:
now = time.time()
return max(0.0, now - self.last_on_ts)
# -------------------------------------------------------------------
# Ana sürücü
# -------------------------------------------------------------------
class RelayDriver:
"""
Basit bir röle sürücüsü.
- Soyut kanal isimleri: RELAY_GPIO dict'indeki anahtarlar
- Brülör grup API'si:
* burners() → mevcut brülör id listesi
* burner_info(bid) → config_statics.BURNER_GROUPS[bid]
* igniter_channel(bid) → ateşleme kanal adı
* set_igniter(bid, state)
* set_pump(bid, pump_name, state)
* enabled_pumps(bid) → default=1 olan isimler (konfig default)
* all_pumps(bid) → tüm pompa isimleri
* active_pumps(bid) → şu anda ON olan pompa isimleri
"""
def __init__(self, onoff=False) -> None:
print("RelayDriver yükleniyor…")
# Konfigten kanal → GPIO pin map
self._pin_map: Dict[str, int] = dict(getattr(cfg, "RELAY_GPIO", {}))
# Her kanal için istatistik objesi
self._stats: Dict[str, RelayStats] = {
ch: RelayStats() for ch in self._pin_map.keys()
}
# Brülör grupları
self._burner_groups: Dict[int, dict] = dict(getattr(cfg, "BURNER_GROUPS", {}))
if not self._pin_map:
raise RuntimeError("RelayDriver: RELAY_GPIO boş.")
if _HAS_GPIO:
GPIO.setmode(GPIO.BCM)
GPIO.setwarnings(False) # aynı pini yeniden kullanırken uyarı verme
for ch, pin in self._pin_map.items():
GPIO.setup(pin, GPIO.OUT)
GPIO.output(pin, GPIO.LOW)
else:
print("⚠️ GPIO bulunamadı, DRY-RUN modunda çalışıyorum.")
# Başlangıçta HER ŞEYİ KAPALIYA ÇEK
try:
if onoff:
self.all_off()
except Exception:
# Çok dert etmeyelim, en kötü GPIO yoktur, vs.
pass
# -----------------------------------------------------
# Temel kanal API
# -----------------------------------------------------
def channels(self) -> Iterable[str]:
"""
Mevcut kanal isimlerini döndürür.
"""
return self._pin_map.keys()
def channel_pin(self, channel: str) -> Optional[int]:
"""
Verilen kanalın GPIO pin numarasını döndürür.
"""
return self._pin_map.get(channel)
def set_channel(self, channel: str, state: bool) -> None:
"""
Belirtilen kanalı ON/OFF yapar, GPIO'yu sürer ve istatistikleri günceller.
"""
if channel not in self._pin_map:
return
pin = self._pin_map[channel]
now = time.time()
if _HAS_GPIO:
GPIO.output(pin, GPIO.HIGH if state else GPIO.LOW)
st = self._stats[channel]
if state:
st.on(now)
else:
st.off(now)
def get_stats(self, channel: str) -> RelayStats:
"""
Kanalın istatistik objesini döndürür.
"""
return self._stats[channel]
def get_channel_state(self, channel: str) -> bool:
"""
Kanal şu anda ON mu? (last_on_ts None değilse ON kabul edilir)
"""
st = self._stats.get(channel)
if st is None:
return False
return st.last_on_ts is not None
# -----------------------------------------------------
# Tüm kanalları güvenli moda çek
# -----------------------------------------------------
def all_off(self) -> None:
"""
Tüm röle kanallarını KAPALI (LOW) yapar ve istatistikleri günceller.
Özellikle:
- Uygulama başlatıldığında "her şey kapalı" garantisi
- Çıkış/KeyboardInterrupt anında güvenli kapanış için kullanılır.
"""
now = time.time()
for ch, pin in self._pin_map.items():
if _HAS_GPIO:
GPIO.output(pin, GPIO.LOW)
# stats güncelle
st = self._stats.get(ch)
if st is not None:
st.off(now)
# -----------------------------------------------------
# Brülör grup API
# -----------------------------------------------------
def burners(self) -> Iterable[int]:
"""
Mevcut brülör id'lerini döndürür.
"""
return self._burner_groups.keys()
def burner_info(self, burner_id: int) -> Optional[dict]:
"""
İlgili brülörün BURNER_GROUPS içindeki konfig dict'ini döndürür.
"""
return self._burner_groups.get(burner_id)
def igniter_channel(self, burner_id: int) -> Optional[str]:
"""
Brülörün igniter kanal adını döndürür.
"""
info = self.burner_info(burner_id)
if not info:
return None
return info.get("igniter", None)
def all_pumps(self, burner_id: int) -> Iterable[str]:
"""
Konfigte tanımlı tüm pompa kanal adlarını döndürür (circulation altı).
"""
info = self.burner_info(burner_id)
if not info:
return []
circ = info.get("circulation", {})
# Her pompa için { "channel": "circulation_a", "pin": 26, "default": 1 } beklenir.
return [data["channel"] for _, data in circ.items()]
def enabled_pumps(self, burner_id: int) -> Iterable[str]:
"""
Konfigte default=1 işaretli pompa kanal adlarını döndürür.
Bu, sistem açıldığında / ısıtma başladığında devreye alınacak default pompaları temsil eder.
"""
info = self.burner_info(burner_id)
if not info:
return []
circ = info.get("circulation", {})
return [
data["channel"]
for _, data in circ.items()
if int(data.get("default", 0)) == 1
]
def active_pumps(self, burner_id: int) -> Tuple[str, ...]:
"""
Şu anda gerçekten ON olan pompa isimlerini döndürür.
(GPIO'da HIGH durumda olan kanallar; RelayStats.last_on_ts None değilse ON kabul edilir)
"""
info = self.burner_info(burner_id)
if not info:
return tuple()
circ = info.get("circulation", {})
active: List[str] = []
for pname, pdata in circ.items():
ch = pdata.get("channel")
if ch in self._stats and self._stats[ch].last_on_ts is not None:
active.append(pname)
return tuple(active)
def set_igniter(self, burner_id: int, state: bool) -> None:
"""
İlgili brülörün igniter kanalını ON/OFF yapar.
"""
ch = self.igniter_channel(burner_id)
if ch:
self.set_channel(ch, state)
def set_pump(self, burner_id: int, pump_name: str, state: bool) -> None:
"""
Belirtilen brülörün belirtilen pompasını ON/OFF yapar.
pump_name: BURNER_GROUPS[..]["circulation"][pump_name]
"""
info = self.burner_info(burner_id)
if not info:
return
circ = info.get("circulation", {})
if pump_name in circ:
ch = circ[pump_name]["channel"]
self.set_channel(ch, state)
# -----------------------------------------------------
# Yardımcı: özet
# -----------------------------------------------------
def summary(self) -> str:
"""
Kanallar ve brülör gruplarının kısa bir özetini döndürür (debug amaçlı).
"""
lines: List[str] = []
chans = ", ".join(sorted(self._pin_map.keys()))
lines.append(f"Kanallar: {chans}")
lines.append("Brülör grupları:")
for bid, info in self._burner_groups.items():
name = info.get("name", f"Burner{bid}")
loc = info.get("location", "-")
ign = info.get("igniter", "igniter")
circ = info.get("circulation", {})
pumps = []
defaults = []
for pname, pdata in circ.items():
ch = pdata.get("channel", "?")
pumps.append(f"{pname}->{ch}")
if int(pdata.get("default", 0)) == 1:
defaults.append(pname)
lines.append(
f" #{bid}: {name} @ {loc} | igniter={ign} | "
f"pumps={pumps} | default_on={defaults}"
)
return "\n".join(lines)
# -----------------------------------------------------
# Temizlik
# -----------------------------------------------------
def cleanup(self) -> None:
"""
GPIO pinlerini serbest bırakır.
"""
if _HAS_GPIO:
GPIO.cleanup()
if __name__ == "__main__":
drv = RelayDriver()
print("\n🧰 RelayDriver Summary")
print(drv.summary())