commit 1d458ca9f69d15f8449aea038227827d3e0be396 Author: root Date: Mon Nov 24 14:25:02 2025 +0300 ilk işlem diff --git a/README.md b/README.md new file mode 100644 index 0000000..181b4e0 --- /dev/null +++ b/README.md @@ -0,0 +1,100 @@ +# ebuild + +`ebuild`, Raspberry Pi 2 üzerinde çalışacak şekilde tasarlanmış, +**bina ısıtma / soğutma / sulama ve genel otomasyon** projesinin çekirdek Python kodunu içerir. + +Proje yapısı: +------------- +```text +ebuild/ +├─ README.md +├─ requirements.txt +├─ document/ # Tasarım PDF'leri, notlar, şemalar +├─ scripts/ +│ └─ run_ebuild_main.py # Ana döngüyü çalıştırmak için yardımcı script +├─ tests/ +│ └─ test_building.py +└─ ebuild/ + ├─ __init__.py + ├─ config_statics.py # Bina topolojisi, FLAT_AREA, sensör/pin map'leri + ├─ config_variables.py # Setpoint, haritalar, çalışma modu (hot-reload) + ├─ reloader.py # Statik/dinamik config için ConfigReloader + ├─ core/ + │ ├─ building.py # Building/Flat/EDSensor iskeleti + │ ├─ environment.py # BuildingEnvironment – dış ısı/nem/yağmur/ışık + │ ├─ devices.py # Sensör/aktüatör soyutlamaları + │ └─ systems/ + │ ├─ burner.py # Brülör sistemi ve BurnerController + │ ├─ irrigation.py # Sulama sistemi + │ ├─ firealarm.py # Yangın alarm sistemi + │ └─ hydrophore.py # Hidrofor sistemi + ├─ io/ + │ ├─ legacy_syslog.py # Eski Rasp2 syslog formatı köprüsü + │ ├─ dbtext.py # Metin tabanlı log/DB iskeleti + │ ├─ relay_driver.py # GPIO röle sürücüsü + │ ├─ sensor_ds18b20.py # DS18B20 sensör sürücü iskeleti + │ ├─ sensor_dht11.py # DHT11 sensör sürücü iskeleti + │ └─ adc_mcp3008.py # MCP3008 ADC (yağmur / LDR) iskeleti + └─ runtime/ + ├─ main.py # Ana çalışma döngüsü iskeleti + └─ __init__.py +``` + +Konfigürasyon tasarımı: +----------------------- +* `config_statics.py` + - Bina topolojisi (FLAT_AREA: `serial`, `flat_no`, `room_no`, `floor`, `direction`, `size_m2`) + - Donanım pin eşlemeleri (`RELAY_GPIO`, `OUTPUT_GPIO`, `INPUT_GPIO` vb.) + - Ortak sensör ID'leri (dış ısı, dönüş hattı, kazan çıkışı vb.) + +* `config_variables.py` + - Konfor sıcaklığı, aralık ve tolerans (`BUILDING_SETPOINT_C`, `BUILDING_RANGE_C`, `BUILD_HEAT_TOLERANCE`) + - Brülör setpoint haritaları (`BURNER_FIRE_SETPOINT_MAP`) + - Saat bazlı delta-T ve tasarruf haritaları + - Zaman bazlı override listesi (`HEAT_TIME_OVERRIDES`) + - Runtime sensör/dairesel aktif/pasif durumları (`FLAT_STATUS`) + +`reloader.ConfigReloader`, bu iki dosyanın değişimini ayrı ayrı izler: +- `statics_changed=True` → bina/sistem topolojisi değişmiş (genelde restart / re-init gerekir) +- `variables_changed=True` → parametreler değişmiş (hot-reload ile devam edilebilir) + +`document/` klasörü: +-------------------- +Proje ile ilgili **tüm dokümantasyon ve tasarım çalışmaları** bu klasörde +tutulmalıdır. Örneğin: + +- Mimari ve akış diyagramları (PDF) +- Donanım bağlantı şemaları +- Test raporları +- Eski sistemden yeni ebuild'e geçiş notları + +Her yeni tasarım veya önemli revizyon için, ilgili notları PDF olarak +`document/` klasörüne koymak, ileride bakım ve geri izleme açısından +işleri kolaylaştırır. + +Raspberry Pi 2 üzerinde kurulum: +-------------------------------- +1. Python 3 ve `pip` paket yöneticisinin kurulu olduğundan emin ol. +2. Proje klasörüne geç: + ```bash + cd ebuild + ``` +3. (İsteğe bağlı) Sanal ortam oluştur: + ```bash + python3 -m venv .venv + source .venv/bin/activate + ``` +4. Gerekli Python paketlerini yükle: + ```bash + pip install -r requirements.txt + ``` +5. Test amaçlı ana döngüyü çalıştır: + ```bash + python3 scripts/run_ebuild_main.py + ``` + +Notlar: +------- +* GPIO ve sensör kütüphaneleri gerçek Raspberry Pi üzerinde test edilmelidir. +* Bu repo şu anda **iskelet** durumundadır; `core/` ve `io/` altındaki dosyalar + senin gerçek sürücü ve kontrol kodlarınla yavaş yavaş doldurulacaktır. diff --git a/deltilda.sh b/deltilda.sh new file mode 100644 index 0000000..aca5faf --- /dev/null +++ b/deltilda.sh @@ -0,0 +1 @@ +find . -type f -name '*~' -print -delete diff --git a/document/Abrulor Kullanim Kilavuzu_V001.pdf b/document/Abrulor Kullanim Kilavuzu_V001.pdf new file mode 100644 index 0000000..9eed39a Binary files /dev/null and b/document/Abrulor Kullanim Kilavuzu_V001.pdf differ diff --git a/document/Abrulor Teknik Dokuman V001.pdf b/document/Abrulor Teknik Dokuman V001.pdf new file mode 100644 index 0000000..aa175ff Binary files /dev/null and b/document/Abrulor Teknik Dokuman V001.pdf differ diff --git a/document/Abrulor_Teknik_Dokuman_V002.pdf b/document/Abrulor_Teknik_Dokuman_V002.pdf new file mode 100644 index 0000000..66c447b --- /dev/null +++ b/document/Abrulor_Teknik_Dokuman_V002.pdf @@ -0,0 +1,118 @@ +%PDF-1.4 +% ReportLab Generated PDF document http://www.reportlab.com +1 0 obj +<< +/F1 2 0 R /F2 3 0 R /F3 4 0 R +>> +endobj +2 0 obj +<< +/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font +>> +endobj +3 0 obj +<< +/BaseFont /ZapfDingbats /Name /F2 /Subtype /Type1 /Type /Font +>> +endobj +4 0 obj +<< +/BaseFont /Symbol /Name /F3 /Subtype /Type1 /Type /Font +>> +endobj +5 0 obj +<< +/Contents 11 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 10 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +6 0 obj +<< +/Contents 12 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 10 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +7 0 obj +<< +/Contents 13 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 10 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +8 0 obj +<< +/PageMode /UseNone /Pages 10 0 R /Type /Catalog +>> +endobj +9 0 obj +<< +/Author (\(anonymous\)) /CreationDate (D:20251117152250+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20251117152250+00'00') /Producer (ReportLab PDF Library - www.reportlab.com) + /Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False +>> +endobj +10 0 obj +<< +/Count 3 /Kids [ 5 0 R 6 0 R 7 0 R ] /Type /Pages +>> +endobj +11 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 2144 +>> +stream +Gau0ED,91]&H9tYQqp_ldLbSqE(Kp-Kqg#/\O'KaUe&l[h@!QBleHfWPOn,;m)F]`?%fliN)LS6Og[*AHSo+a&?]Tg0ao4567^^Vh$elLL;1e@Q8qk^!P1Xtt;1Sr+Qf]7$ek[Sd0K7ED^]6n/"UnPuT61qNX"?ia.b!aX]m9Ut]XWk>OULC(X9uM&m%GjtBa^$Y#ncf!@C:7j/#l;dgBXV.4hXGd]jCb_/qL\^.c\]#N-]G:(TV(!Y3`#O9I1oZ?7i)qiHPZ>Ke"Bs+eHj<)pf%>/o[\TH\TQK'N>XA:NEOW(>@eKr@"*H(3JHeoSBrSeA;*n,8r#/g3'5s1Lhht$U]];kUsQaol;1;#Aumkpcm3)D>t/m,U")[&M7gHt2)dep+n+g\3f4-8o]1Gja.WbBKZ%XaCX`[I".K<4X&ZKb->.:036QcjnotiERRf?Y3,*sP^?9Z&>K/U7g;Qjuj.a$m3#G"2(KY.c^:NGoP?';h_[Gpk\]g'X+AguO"MQ=J<]Krqu]mkt6*Rg;3p"<4IHS(mRdLN(&QY2WQJ@6[W@t@!uN3fugOKRRN)]J^Xg:)egKc-OPXSUhFYXr\CeOQkR';(ehUX/oScqfMP]V0u4:Jti)aY5sh9'oo#*cRRL'N]Ym)JHRaS;Klmb.N`4@p$3I937ZZ4^E2:iN#`FX[2;11!;QhNA5K1VG>p5n*)LiQ0a4f*(tSr8^^ER%3!IuiJ8YJJbjYfJC$pKjEu#L%$-n4/mK`0i*<@2n`@8,XEfnf<#fAcE>D=p0lmqc:7O>/M4DJC0XpP%`lr>`@"7AOlXRPqn=0oUXf"U%'++^0@[b?a<)cDj6Ib+a.8">8]$;s9`i3QN84)5OBG"S;GX=-Y0K49?l[0_49^9]p=tO8BE"s?%Q9%T@OLUM#Q.bnUC"@m>'NULrGChu:km=NpR>6pGK]dTkPq-K-=oG73IW-qWWleOuPS9A&V?-j*P`aW$qdA+R-V.>!#c8$8Gh(AM;D&o=X#l`dCo**RS[*88(9b)L0HBIe>6&[Tug:p_FQRO6DgC?83;9C@6l=uChs`9hbt6.8<;2aBt^T]ljd@*,.eR4K#t(2R.C;II&QKm,>;>6EsRViMA(m?g%*:1l%^8<_Ii@:8o)-QEWO+_'cjA8ddgkFK18#JGF4d!eF6G+-dc>DHUO`kApqBcl+4S0_BmCJjU$CTpbC.nVDKQ%f,+`_;)Rn%_UO10#K&2:W%M[d*:Oh4_B'<]AWTPnJGi8Q_SDNFee1mLZ%#,tVrgnd;3f7E0Og.Mak]Ask"[;.bDJ>F"K9i;;\lK^^Q@VmXg/q9Gp0Br&Um!H)JW6Vq,56QkmJEO:9hTI:&dGSLbtIos<-88ke)dIPB12ZU(J.V(R3\3rb6\3,Vq'r(@MNJNk@0LW?nJNhA=Q=f242L9R4Sc0X$$)_*Bl0%=$W84WQi%qZ;XQD+tq4%%S?&lF=KQr;6KPETI/]1V1-2]m6>ZO`*,X=.Q9`&jP[kif=)eam4:fnb;&*endstream +endobj +12 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 1911 +>> +stream +Gb!SmgMYb*&:Ml+%'VD1,'BuF0/]sEn]".YUidWg"]?0c`YIl[,uH9@'e=e4%OicfdT+_8i4Tg/&j[k"*6ubgB4qN2oPs6T0/:7U#01?d3%iF@i:q.2AsY3C*IL"h[NkYOk]`Tk@.82%1Ci9n^ZtLgG@,b]+D]9Y!e%?h>K`Ap)BH.Ra+!Xf>$fs[&"F@1&8$F=&9g43SC1*9*Etb:$a-+4eSHI!Kc2q;_6b1ms@g:Tp[_PB6MYE/&f)5=t\e\Dcg*`m)mASELFd&;Wch!fYOf0[p%qKUT3p:?D;($Q2NA:N9=\73oSQYSjSpqM#RD;1q'!MZZn3JMC_pDFDJt8lKX!(4-rZ*aqC]0rV=^(hL@aY37*o/$MQH(3:/dp+8ZDotUNej1F+oqX=9?oM7+^V165hmuhpTE,E6aKM&MF"1c3\4[6T?S)8DCE%8Rn*MAA7.6Md,2qV6/%2cOuR/:IH4"QlD1_[,nQ9R]p(=I^n,SYJ35nHR2Y8e5e+`#2LO_]njQoXn%]UB1q1r(ll4'Q;Bh!WtbFaaQo,TEGR$:L#6,Iij?=;K?S*j]I?QnaOS5Xb\IS1G6tRi0/S?\TK6DMu+GVdfO!3Yce:gk(+GERm6mNp`8fWFVhYufq\eY%u">"HiL6#5,f5%0#hg=$E21saa<'u'\0DdNK#enF")2@$l;CJo*A6G5+*1J9NJNuS?OK^1GKX>W9QE2N_caJ+;E_I)ZM`PZcH'Utr"5/u=r^A>P.PsP&KjT_`Bos=-'4M_5KcARc/pa^sllEI%:R(<[U/YkQ#II@)Q.Y!Wd[MM7Tugnjkdn4sZO\EfmWtsJI(/!41sX:aU2c_O"Br8De+0.bp]jMcXqn+=V!ApjG:o,9?kcI>H]@@i;1i0E($3`0%_kQ?N0:I$&Ocp"NKV+^g2nWq%OpocC3ak_7Eh4JR+Dp6VG(9!n_CQ,K%WpTAI`MJnY39PReC5!4-bO,9=50AFZ`eZmgn"p=$I%_8okk9_h).VKL2f_fbkdI]+8!+A,?6%r9`h'lcE@W]__-+eiqIY.5caT6)3A"KsTeO`(?'J/mcXm)Z*,0@VMKIhD-"#$s"ggC:Rt.$^KF!BLWHY:]EL[\VB,DB@1(-j:qhSY`7]!($Z<2_&i?l]r7q+FLn53M9aI=/#Eq6eKgjt.r#J.H^i-8\i'sM5MEoRi:Su5[CS,)6oBtVF%A$dI>q`u4fVeOR>u>!e>3AO,X9Jrhi[B"=Ip&ocZ<*P5O:r@c?$8a/:=2qO7;.VXScjg)R5@+Mb1)H3pq^Ec[KCN!NV6g$A">5't2(2s3?pAbU`6"]0*to"r9b2OFCcD;*7k-bt>]g9=j$8WM[B&6YQ9c$Mo%=nnrYR+gmHerc$1K5cs+,=OS.+S(l).]S_Qi@',V],[uqGW1S'PCG_OZQ,-R_CXb4Rdc?]&GtiftI8\UloZkSG,"4l#jm=KGKe2BB<0TU_'hOePd=Z1aK>.CLZL%G~>endstream +endobj +13 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 1680 +>> +stream +Gb"/(95iiK&AIV:QqN"+FW=&[--*Jh;\oVC)`2P].!-=sN5LgA;G&^qrq.:0ngdkSS_Z0M6X`4lb_LuFpqC[6!!`'%h"*6*\=UOu1k6^%fb4M+rX`\NnC-a[?giSaAho)d9`!f>eX0Ca!%eMc]7_o_EjttTh.U1T)Rti2!6i04ZO;#]CAA!j1l>cfQEl#RYWu$PThuH&1eNKl<"dV5N2j29:>HB,a:Lt'#$!`ge\Xj/=/G/R_sG^o:L2_[PnoD^FLco/CH$Q"%6NL/2pleq8J_jXkABY=sP_@Qp(8c,fTF%OF$Qtt8bO9sKc@C._ZVlZ5lR.'p)gd,rXPs0u.$qAuR^)Dk6hD!EFa233>pYc=C"q/``Hq-r,0K'+'gi=5!DRnb^H*Z2cTuL80mXP,Dqs&o]N*e&@UaNV#JhRYoJ_<94+CXZ4:2XYXoqUH1RJ3Fn9MkgC(Nr:#:D,bLa5)\YREN0^K5LfE+<%_-hi1,;gK,5P?ELdsN@kZf-u4-(Chcg9F\ho*B#+pBM9/TVG)c1ZlUlOHmOdtY_s#6Ih!X,#*\GWf:j:ubfIHYIX(&!AUs#KemEbH)lVkQ*1V]^XH3;LJufYr!.*5`<6n]ed_oH!a=If:('f+FI_l'r=n-<*Aar1!5K=u.d1C4S8/i+CAE"^PmYJ6+N3t6D^'tbN,:?sh#,B)P1e]+>R6-5gd;o0W"j:+T3R$e@0"dGE$Sk#oq?DAlmE7b(h&adE)Kpd-'i$I632*\K2\Gu9LeMcE?L";E4piMVJjmeH&&Ej2GT=S`55Zt5N`'8AamYD=)kA4M5GWttHVXBeKo`-[qkWEPTliA=g#`W'76tZSYcPeOfp.Daeu.^#/1R1];LRGsPEY\H!K("t^>^./MXr5?N+-,p;@fuu7I4Sq79Y"o`iB(=]menSf^JJX5l0r_^+.ZKYB0@](Zqem8^D,r>[c3^CaVY@`*b-FBYr&Hb+8o-4JUM`Ops5%8QMRa=p4`Kj9;s;F&]:_2R8LbipmD`G(2@^InRJ:[\7m3F"JDo+0agSlVb6^:+7@:e8h.\UoLXs9#X8s=H-B>%7/XPp/6Q:C5+#-SWhpkX]FH19q)B*01`>)c[S;HjBB=V-#^=*4)D/%"sHTF>Fm1^SqaRgF'.Ym4si_Cb,WBp=oaLbgou>sJIk69]-=(%rRCDi[=P[s+qJMEZGID)L9^WL'brAc@n$\8cWVeRHCN_nA"*=oUl=9CMHP]nhX"uA>Q#61jf7"6V4Ao$<:'@oCC.0I7RA&V?%Spi.cU'476ghW3o,(etrm$3p@Vs5*jgn]TkL..%V(\ONc6[8L=rplAFm[VG\su)Gs3N\"H3Ea%1(US<=E1rf8!H\@0),ST7$Um'6Rj[HI$;8s>2'Zp:^E'Yg;lFY.XT4\^L#l>'_jQ-endstream +endobj +xref +0 14 +0000000000 65535 f +0000000073 00000 n +0000000124 00000 n +0000000231 00000 n +0000000314 00000 n +0000000391 00000 n +0000000596 00000 n +0000000801 00000 n +0000001006 00000 n +0000001075 00000 n +0000001358 00000 n +0000001430 00000 n +0000003666 00000 n +0000005669 00000 n +trailer +<< +/ID +[] +% ReportLab generated PDF document -- digest (http://www.reportlab.com) + +/Info 9 0 R +/Root 8 0 R +/Size 14 +>> +startxref +7441 +%%EOF diff --git a/document/GPIO_mapping_from_config.csv b/document/GPIO_mapping_from_config.csv new file mode 100644 index 0000000..74535db --- /dev/null +++ b/document/GPIO_mapping_from_config.csv @@ -0,0 +1,51 @@ +# ------------------------------------------------- +# GPIO ÖZETİ (Raspberry Pi 40-pin Header) +# - Tüm pinler BCM numarası ile konfigüre edilir. +# - Parantez içinde fiziksel pin numarası verilmiştir. +# +# Güç / Ortak hatlar: +# 3.3V : Fiziksel 1, 17 +# 5V : Fiziksel 2, 4 +# GND : Fiziksel 6, 9, 14, 20, 25, 30, 34, 39 +# +# 1-Wire (DS18B20 hattı – tipik kullanım): +# GPIO4 (BCM4) → Fiziksel 7 +# +# Dış ortam DHT11: +# DHT11_OUTSIDE_PIN = 5 → BCM5 (Fiziksel 29) +# +# ADC / MCP3008 (SPI0): +# ADC_SPI["ce"] = 8 → BCM8 (Fiziksel 24, CE0) +# ADC_SPI["miso"] = 9 → BCM9 (Fiziksel 21, MISO) +# ADC_SPI["mosi"] = 10 → BCM10 (Fiziksel 19, MOSI) +# ADC_SPI["sclk"] = 11 → BCM11 (Fiziksel 23, SCLK) +# +# Brülör grubu röleleri (BURNER_GROUPS[0]): +# igniter_pin = 16 → BCM16 (Fiziksel 36) +# circulation["circ_1"]= 26 → BCM26 (Fiziksel 37) +# circulation["circ_2"]= 24 → BCM24 (Fiziksel 18) +# +# PWM / LED çıkışları (OUTPUT_GPIO): +# "buzzer" → GPIO18 → BCM18 (Fiziksel 12) [PWM] +# "rgb_r" → GPIO12 → BCM12 (Fiziksel 32) [PWM] +# "rgb_g" → GPIO13 → BCM13 (Fiziksel 33) [PWM] +# "rgb_b" → GPIO19 → BCM19 (Fiziksel 35) [PWM] +# "led" → GPIO23 → BCM23 (Fiziksel 16) +# +# Girişler (INPUT_GPIO): +# "button" → GPIO21 → BCM21 (Fiziksel 40) +# "burner_contactor" → GPIO20 → BCM20 (Fiziksel 38) +# "circulation_contactor1" → GPIO27 → BCM27 (Fiziksel 13) +# "circulation_contactor2" → GPIO17 → BCM17 (Fiziksel 11) +# +# Notlar: +# - Basınç ve gaz sensörleri için: +# pressure_sensor / gas_sensor pinleri, sensör BESLEME/ENABLE hattıdır. +# Analog ölçüm, MCP3008 üzerindeki ADC_CHANNELS üzerinden yapılır: +# "pressure" → CH0 +# "gas" → CH1 +# "rain" → CH2 +# "ldr" → CH3 +# - GPIO2/3 (fiziksel 3/5) I2C hattı, şimdilik boş tutulmuştur. +# - GPIO14/15 (fiziksel 8/10) UART hattı, debug/ileride kullanım için boş. +# ------------------------------------------------- diff --git a/document/abrulor_pinout_annotated.png b/document/abrulor_pinout_annotated.png new file mode 100644 index 0000000..1ce24d5 Binary files /dev/null and b/document/abrulor_pinout_annotated.png differ diff --git a/ebina_log.sql b/ebina_log.sql new file mode 100644 index 0000000..bf878d5 --- /dev/null +++ b/ebina_log.sql @@ -0,0 +1,3 @@ +-- DBText log file for table ebrulor_log +-- created at 2025-11-22T16:18:17.441370 + diff --git a/ebuild/__init__.py b/ebuild/__init__.py new file mode 100644 index 0000000..7c08fc4 --- /dev/null +++ b/ebuild/__init__.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- +"""ebuild ana paketi.""" + +__title__ = "ebuild" +__author__ = 'Mehmet Karatay & "Saraswati" (ChatGPT)' +__purpose__ = "Bina otomasyonu, ısıtma/soğutma/sulama sistemleri için temel paket" +__version__ = "0.1.0" +__date__ = "2025-11-20" diff --git a/ebuild/__pycache__/__init__.cpython-39.pyc b/ebuild/__pycache__/__init__.cpython-39.pyc new file mode 100644 index 0000000..13ce0f9 Binary files /dev/null and b/ebuild/__pycache__/__init__.cpython-39.pyc differ diff --git a/ebuild/__pycache__/config_runtime.cpython-39.pyc b/ebuild/__pycache__/config_runtime.cpython-39.pyc new file mode 100644 index 0000000..09bf93d Binary files /dev/null and b/ebuild/__pycache__/config_runtime.cpython-39.pyc differ diff --git a/ebuild/__pycache__/config_statics.cpython-39.pyc b/ebuild/__pycache__/config_statics.cpython-39.pyc new file mode 100644 index 0000000..165c3e6 Binary files /dev/null and b/ebuild/__pycache__/config_statics.cpython-39.pyc differ diff --git a/ebuild/__pycache__/config_variables.cpython-39.pyc b/ebuild/__pycache__/config_variables.cpython-39.pyc new file mode 100644 index 0000000..e6b9474 Binary files /dev/null and b/ebuild/__pycache__/config_variables.cpython-39.pyc differ diff --git a/ebuild/config_runtime.py b/ebuild/config_runtime.py new file mode 100644 index 0000000..d2caeb9 --- /dev/null +++ b/ebuild/config_runtime.py @@ -0,0 +1,133 @@ +# -*- coding: utf-8 -*- +from __future__ import annotations + +__title__ = "ebuild_config_runtime" +__author__ = 'Mehmet Karatay & "Saraswati" (ChatGPT)' +__purpose__ = "Çalışma zamanı (runtime) ısıtma/soğutma parametreleri" +__version__ = "0.3.0" +__date__ = "2025-11-22" + +""" +ebuild/config_runtime.py + +Sık değişebilen, sistem durmadan güncellenebilen parametreler: +- ısı aralıkları +- kazan limitleri +- saatlik tasarruf haritaları +- override tabloları +""" + +# ------------------------------------------------- +# Sistem / Hedefler +# ------------------------------------------------- +CFG_DEBUG = 1 +BUILDING_AGG_STRATEGY = "robust" +BUILD_HEAT_TOLERANCE = 1.00 # ısı dengeleme toleransı +BUILD_EXIT_HEAT = 0 # 0= bina ort ısı, 1= üst kat, 2= alt kat, 3= en düşük daire ısısı +BUILDING_RANGE_C = (18.0, 28.0) # bina hedef aralığı +BUILDING_SETPOINT_C = 23.0 # seçili bina konfor sıcaklığı +BUILDING_INCREMENT_C = 0.5 # B modunda kademe adımı (ileride kullanacağız) + +MAX_OUTLET_C = 45.0 # kazan çıkış emniyet limiti +OUTSIDE_LIMIT_HEAT_C = 30.0 +USED_OUTSIDE_ELAPSED_S = 300 # 5 dk’da bir ‘used_outside’ güncelle +BURNER_COMFORT_OFFSET_C = 1.0 # seçili konfor sıcaklığı offset’i +CIRCULATION_MIN_RETURN_C = 25.0 # Çıkış ısısı bu değerin ALTINA düşerse pompalar DURUR +OUTSIDE_SMOOTH_SECONDS = 300 +BUILDING_READ_PERIOD_S = 300 # bina istatistik okuma periyodu + +# Hafta sonu lüks artırımı +WEEKEND_HEAT_BOOST_C = 2.0 +WEEKEND_DAYS = (5, 6) # 5=Cumartesi, 6=Pazar + # Böylece debug sırasında bu min süreleri config’ten 0 yapar gerçekte devreye alırken 60–300 saniye gibi gerçekçi değerler verirsin +BURNER_MIN_RUN_SEC = 0 +BURNER_MIN_STOP_SEC = 0 +BURNER_HYSTERESIS_C = 0 + +# ------------------------------------------------- +# Ateşleme bant haritaları +# ------------------------------------------------- +BURNER_FIRE_SETPOINT_MAP = { + -9: {"fire": 70}, -8: {"fire": 69}, -7: {"fire": 67}, -6: {"fire": 66}, -5: {"fire": 65}, + -4: {"fire": 63}, -3: {"fire": 62}, -2: {"fire": 61}, -1: {"fire": 59}, 0: {"fire": 58}, + 1: {"fire": 57}, 2: {"fire": 55}, 3: {"fire": 54}, 4: {"fire": 53}, 5: {"fire": 51}, + 6: {"fire": 50}, 7: {"fire": 48}, 8: {"fire": 47}, 9: {"fire": 46}, 10: {"fire": 44}, + 11: {"fire": 43}, 12: {"fire": 42}, 13: {"fire": 40}, 14: {"fire": 39}, 15: {"fire": 38}, + 16: {"fire": 36}, 17: {"fire": 35}, +} + +DELTA_T_MAP_HOURLY = { + 0: 6, 1: 6, 2: 6, 3: 6, 4: 6, 5: 6, 6: 7, 7: 7, 8: 7, 9: 7, 10: 7, 11: 7, + 12: 6, 13: 6, 14: 6, 15: 6, 16: 6, 17: 6, 18: 7, 19: 7, 20: 7, 21: 7, 22: 7, 23: 7, +} + +SAVING_T_PERCENT_HOURLY = { + 0: 95, 1: 90, 2: 85, 3: 85, 4: 90, 5: 95, 6: 95, 7: 100, 8: 100, 9: 95, 10: 90, 11: 95, + 12: 100, 13: 95, 14: 90, 15: 85, 16: 90, 17: 95, 18: 95, 19: 100, 20: 100, 21: 100, 22: 100, 23: 100, +} + +# ------------------------------------------------- +# Zaman bazlı override (örnekler) +# ------------------------------------------------- +HEAT_TIME_OVERRIDES = [ + { + "enable": True, + "start": "19:00", + "end": "22:00", + "days": [0, 1, 2, 3, 4, 5, 6], + "outside_c": [7, 18], + "set_fire_c": 45, + "note": "sabah konfor", + }, + { + "enable": False, + "start": "12:00", + "end": "13:30", + "days": [0, 1, 2, 3, 4], + "outside_c": [14, 20], + "building_c": 27, + "note": "öğle sıcak tut", + }, + { + "enable": False, + "start": "16:00", + "end": "18:00", + "days": [5, 6], + "outside_c": [14, 18], + "set_fire_c": 50, + "note": "akşam artır", + }, + { + "enable": False, + "start": "20:30", + "end": "22:00", + "days": [0, 1, 2, 3, 4, 5, 6], + "outside_c": [8, 15], + "set_fire_c": 38, + "building_c": 26, + "note": "gece ekonomik", + }, +] + +SHOW_OVERRIDE_NOTE = 0 # 0: not yazma, 1: yaz +SHOW_ALL_OVERRIDES = 1 # 1: dış ısı aralığı eşleşmese de listele (gün eşleşmesi yine aranır) + +# ------------------------------------------------- +# Mevsim parametreleri +# ------------------------------------------------- +SEASON_SPRING_SAVE_DAYS = 20 +SEASON_AUTUMN_SAVE_DAYS = 20 +xSEASON_SPRING_SAVE_DAYS = (5, 20) # 5–20 Nisan +xSEASON_AUTUMN_SAVE_DAYS = (15, 30) # 15–30 Eylül + +SEASON_PROVIDER = "SunHolidayInfo" +SEASON_LANG = "tr" +SEASON_NAMES = ("İlkbahar", "Yaz", "Sonbahar", "Kış") +SEASON_ENG = { + "İlkbahar": "Spring", + "Yaz": "Summer", + "Sonbahar": "Autumn", + "Kış": "Winter", +} +SPRING_HOURS = ["00:00", "22:00"] # 00 → sunset bazlı başlangıç +SPRING_START_MINUTE = 30 diff --git a/ebuild/config_statics.py b/ebuild/config_statics.py new file mode 100644 index 0000000..6c4a882 --- /dev/null +++ b/ebuild/config_statics.py @@ -0,0 +1,158 @@ +# -*- coding: utf-8 -*- +from __future__ import annotations + +__title__ = "ebuild_config_statics" +__author__ = 'Mehmet Karatay & "Saraswati" (ChatGPT)' +__purpose__ = "Bina ve donanım topolojisi için statik konfigürasyon" +__version__ = "0.3.0" +__date__ = "2025-11-22" + +""" +ebuild/config_statics.py + +Statik (mimari) konfigürasyon +----------------------------- +Bu dosyada bina ve donanım topolojisine ait, sık değişmeyen ayarlar tutulur. +Rasp2 pin eşlemeleri, brülör topolojisi, bina/geo bilgileri vb. +""" + +# ------------------------------------------------- +# Brülör çalışma modu +# ------------------------------------------------- +# F : Dış ısıya bağlı kazan kontrolü +# B : Bina ortalama sıcaklığa bağlı kontrol +BUILD_BURNER = "F" # ilk deneme F +BURNER_DEFAULT_ID = 0 # seçili brülör id + +# ------------------------------------------------- +# Bina kimliği / konum +# ------------------------------------------------- +BUILDING_NAME = "Gunes Apt" +BUILDING_LOCATION = "Ankara" +BUILDING_LABEL = "Resat Nuri" + +GEO_CITY = "Ankara" +GEO_COUNTRY = "Turkey" +GEO_TZ = "Europe/Istanbul" +GEO_LAT = 39.92077 +GEO_LON = 32.85411 + +# Lisans ve sistem ana anahtarı +BUILDING_LICENCEID = 10094 +BUILDING_SYSTEMONOFF = 1 # 1=On, 0=Off + +# ------------------------------------------------- +# Daire / oda topolojisi (DS18B20) +# ------------------------------------------------- +# Her kayıt: bina içindeki flat/room dağılımı +FLAT_AREA = [ + {"serial": "28-00000660e983", "flat_no": 2, "room_no": 0, "floor": 1, "direction": 5, "size_m2": 0.0}, + {"serial": "28-0000066144f9", "flat_no": 22, "room_no": 0, "floor": 1, "direction": 7, "size_m2": 0.0}, + {"serial": "28-000006605827", "flat_no": 11, "room_no": 0, "floor": 4, "direction": 1, "size_m2": 0.0}, + {"serial": "28-000005fd46c4", "flat_no": 24, "room_no": 0, "floor": 2, "direction": 7, "size_m2": 0.0}, + {"serial": "28-00000660dc99", "flat_no": 25, "room_no": 0, "floor": 2, "direction": 7, "size_m2": 0.0}, + {"serial": "28-00000141b977", "flat_no": 28, "room_no": 0, "floor": 2, "direction": 7, "size_m2": 0.0}, + {"serial": "28-000006616fc3", "flat_no": 6, "room_no": 0, "floor": 2, "direction": 1, "size_m2": 0.0}, + {"serial": "28-00000660ca02", "flat_no": 1, "room_no": 0, "floor": 1, "direction": 4, "size_m2": 0.0}, +] + +# ------------------------------------------------- +# Hat sensörleri (tamamen DS18B20) +# ------------------------------------------------- +# Dış ısı +OUTSIDE_SENSOR_ID = "28-000000b2aa05" +OUTSIDE_SENSOR_NAME = "Dış Isı 1" + +# Kazan çıkış +BURNER_OUT_SENSOR_ID = "28-946778126461" +BURNER_OUT_SENSOR_NAME = "Çıkış Isı 2" + +# Dönüş hatları +RETURN_LINE_SENSOR_IDS = [ + "28-566978126461", + "28-506478126461", + "28-46fa45126461", +] + +RETURN_LINE_SENSOR_NAME_MAP = { + "28-566978126461": "Kuzey Hat4", + "28-506478126461": "Güney Hat3", + "28-46fa45126461": "Guney Bat, 5", +} + +# ------------------------------------------------- +# Donanım / röle / pin eşlemeleri +# ------------------------------------------------- +GPIO_BOARD_REVISION = 3 + +BURNER_IGNITER_CH = "igniter" + +RELAY_GPIO = { + "igniter": 16, + "circulation_a": 26, + "circulation_b": 24, +} +# Brülör grupları – RelayDriver buradan beslenir +BURNER_GROUPS = { + 0: { + "name": "MainBurner", + "location": "Sol binada", + "igniter_pin": 16, # BCM 16 → phys 36 (örnek) + "circulation": { + "circ_1": {"channel": "circulation_a", "pin": 26, "default": 1}, + "circ_2": {"channel": "circulation_b", "pin": 24, "default": 0}, + }, + }, + # 1: {...} # ilerde ikinci brülör +} + +# Temel röle kanalları (soyut isim → GPIO) +RELAY_GPIO = { + "igniter": 16, # BCM 16, Fiziksel 36 + "circulation_a": 26, # BCM 26, Fiziksel 37 + "circulation_b": 24, # BCM 24, Fiziksel 18 + "gas_sensor": 22, # BCM 22, Fiziksel 15 + "pressure_sensor": 6, # BCM 6, Fiziksel 31 +} + +PUMP_HAS_DUAL_PUMPS = True +PUMP_FEEDBACK_INPUT_PINS = [] +PUMP_ROTATE_HOURS_DEFAULT = 24 + +# ------------------------------------------------- +# Buzzer + RGB LED +# ------------------------------------------------- +BUZZER_ENABLE = 1 +BUZZER_ACTIVE_HIGH = True +BUZZER_BASE_FREQ = 2000 # Hz + +BUZZER_PATTERNS = { + "cfg_reload": [ + (1, 10), + ], + "startup": [ + (1, 80), + (0, 0), + ], + "shutdown": [[200, 0, 1]], + "reload": [[300, 100, 1], [120, 100, 1], [300, 0, 1]], + "alarm": [[500, 200, 2], [500, 200, 2], [500, 200, 2]], +} + +OUTPUT_GPIO = { + "buzzer": 18, # BCM 18, Fiziksel 12 - PWM – buzzer + "rgb_r": 12, # BCM 12, Fiziksel 32 - PWM – kırmızı + "rgb_g": 13, # BCM 13, Fiziksel 33 - PWM – yeşil + "rgb_b": 19, # BCM 19, Fiziksel 35 - PWM – mavi + "led": 23, # BCM 23, Fiziksel 16 - basit LED +} +RGB_ORDER = "GRB" +RGB_COMMON_ANODE = False +RGB_BASE_FREQ = 1000 # Hz + +INPUT_GPIO = { + "button": 21, # BCM 21, Fiziksel 40 + "burner_contactor": 20, # BCM 20, Fiziksel 38 + "circulation_contactor1": 27, # BCM 27, Fiziksel 13 + "circulation_contactor2": 17, # BCM 17, Fiziksel 11 +} diff --git a/ebuild/core/__init__.py b/ebuild/core/__init__.py new file mode 100644 index 0000000..40a96af --- /dev/null +++ b/ebuild/core/__init__.py @@ -0,0 +1 @@ +# -*- coding: utf-8 -*- diff --git a/ebuild/core/__pycache__/__init__.cpython-39.pyc b/ebuild/core/__pycache__/__init__.cpython-39.pyc new file mode 100644 index 0000000..fa9a0ef Binary files /dev/null and b/ebuild/core/__pycache__/__init__.cpython-39.pyc differ diff --git a/ebuild/core/__pycache__/analog_sensors.cpython-39.pyc b/ebuild/core/__pycache__/analog_sensors.cpython-39.pyc new file mode 100644 index 0000000..f167c16 Binary files /dev/null and b/ebuild/core/__pycache__/analog_sensors.cpython-39.pyc differ diff --git a/ebuild/core/__pycache__/building.cpython-39.pyc b/ebuild/core/__pycache__/building.cpython-39.pyc new file mode 100644 index 0000000..2c0c1d6 Binary files /dev/null and b/ebuild/core/__pycache__/building.cpython-39.pyc differ diff --git a/ebuild/core/__pycache__/environment.cpython-39.pyc b/ebuild/core/__pycache__/environment.cpython-39.pyc new file mode 100644 index 0000000..78c2e45 Binary files /dev/null and b/ebuild/core/__pycache__/environment.cpython-39.pyc differ diff --git a/ebuild/core/__pycache__/season.cpython-39.pyc b/ebuild/core/__pycache__/season.cpython-39.pyc new file mode 100644 index 0000000..39cfce7 Binary files /dev/null and b/ebuild/core/__pycache__/season.cpython-39.pyc differ diff --git a/ebuild/core/__pycache__/sunholiday.cpython-39.pyc b/ebuild/core/__pycache__/sunholiday.cpython-39.pyc new file mode 100644 index 0000000..ff8c406 Binary files /dev/null and b/ebuild/core/__pycache__/sunholiday.cpython-39.pyc differ diff --git a/ebuild/core/analog_sensors.py b/ebuild/core/analog_sensors.py new file mode 100644 index 0000000..e987b6c --- /dev/null +++ b/ebuild/core/analog_sensors.py @@ -0,0 +1,123 @@ +# -*- coding: utf-8 -*- +from __future__ import annotations + +__title__ = "analog_sensors" +__author__ = 'Mehmet Karatay & "Saraswati" (ChatGPT)' +__purpose__ = "MCP3008 üzerinden okunan analog sensörler için basit hub" +__version__ = "0.1.0" +__date__ = "2025-11-23" + +""" +ebuild/core/analog_sensors.py + +Amaç +----- +- MCP3008 ADC üzerinden okunan analog kanalları tek noktada toplamak. +- Basınç, gaz, yağmur ve LDR için basit arayüz sağlamak. + +Notlar +------ +- Şimdilik çok sade tutuldu; istersen ileride eşik, durum (state) ve alarm + mantığını genişletebilirsin. +""" + +from typing import Dict, Any, Optional + + +class _SimpleChannelState: + """ + Tek bir analog kanal için durum tutucu. + """ + def __init__(self, name: str) -> None: + self.name = name + self.state: Optional[str] = None + self.last_raw: Optional[int] = None + # Gaz için latched alarm gibi ek alanlar: + self.latched_alarm: bool = False + + def update(self, raw: Optional[int]) -> None: + self.last_raw = raw + # Şimdilik state hesaplamıyoruz; ileride eşiğe göre doldurulabilir. + self.state = None + + +class AnalogSensorsHub: + """ + MCP3008 ADC üzerinden basınç, gaz, yağmur, LDR gibi sensörleri yöneten hub. + + Beklenti: + --------- + adc nesnesinin en azından şu fonksiyonu sağlaması: + - read_channel(ch: int) -> int (0..1023 arası değer) + """ + + def __init__(self, adc) -> None: + self.adc = adc + + # Her kanal için basit state objeleri + self.pressure = _SimpleChannelState("pressure") + self.gas = _SimpleChannelState("gas") + self.rain = _SimpleChannelState("rain") + self.ldr = _SimpleChannelState("ldr") + + # Kanalları sabitliyoruz; istersen config'ten de alabilirsin. + self.pressure_ch = 0 + self.gas_ch = 1 + self.rain_ch = 2 + self.ldr_ch = 3 + + def _safe_read(self, ch: int) -> Optional[int]: + """ + ADC'den güvenli okuma. Hata olursa None döndürür. + """ + if self.adc is None: + return None + + try: + val = self.adc.read_channel(ch) + return int(val) + except Exception: + return None + + def update_all(self) -> Dict[str, Any]: + """ + Tüm kanalları okuyup state nesnelerini günceller. + + Dönüş: + { + "pressure": , + "gas": , + "rain": , + "ldr": , + } + """ + vals: Dict[str, Any] = {} + + p_raw = self._safe_read(self.pressure_ch) + g_raw = self._safe_read(self.gas_ch) + r_raw = self._safe_read(self.rain_ch) + l_raw = self._safe_read(self.ldr_ch) + + self.pressure.update(p_raw) + self.gas.update(g_raw) + self.rain.update(r_raw) + self.ldr.update(l_raw) + + vals["pressure"] = p_raw + vals["gas"] = g_raw + vals["rain"] = r_raw + vals["ldr"] = l_raw + + return vals + + +if __name__ == "__main__": + # Basit demo: sahte ADC ile + class DummyADC: + def read_channel(self, ch: int) -> int: + return 512 + ch # uydurma değerler + + hub = AnalogSensorsHub(DummyADC()) + values = hub.update_all() + print("AnalogSensorsHub demo:", values) + print("Gas latched_alarm:", hub.gas.latched_alarm) diff --git a/ebuild/core/building.py b/ebuild/core/building.py new file mode 100644 index 0000000..4199adf --- /dev/null +++ b/ebuild/core/building.py @@ -0,0 +1,590 @@ +# -*- coding: utf-8 -*- +from __future__ import annotations + +__title__ = "building" +__author__ = 'Mehmet Karatay & "Saraswati" (ChatGPT)' +__purpose__ = "Bina / daire / sensör ve brülör topolojisi sürücüsü" +__version__ = "0.4.3" +__date__ = "2025-11-22" + +""" +ebuild/core/building.py + +Revision : 2025-11-22 +Authors : Mehmet Karatay & "Saraswati" (ChatGPT) + +Amaç +----- +- Statik konfigürasyondan (config_statics) bina, geo ve sensör topolojisini yükler. +- FLAT_AREA içindeki DS18B20 sensörlerini daire/oda bazında yönetir. +- Bina ortalama sıcaklık istatistiklerini üretir. +- Brülör grupları (BURNER_GROUPS) ve hat sensörleri (dış, çıkış, dönüş) için + başlangıçta okunabilir bir özet verir. +- SeasonController ile mevsim / güneş / tatil bilgisi sağlar. + +Not +--- +Bu modül, hem bağımsız test aracı olarak `python3 -m ebuild.core.building` +şeklinde çalışabilir, hem de BurnerController gibi üst seviye kontrol +modülleri tarafından kullanılabilir. +""" + +import os +import json +import time +import datetime +import statistics +from collections import defaultdict +from typing import Dict, List, Optional, Any + +from .season import SeasonController +from ..io.dbtext import DBText +from .. import config_statics as cfg_s +from .. import config_runtime as cfg_v + + +# ------------------------------------------------------------------ +# Yardımcı sabitler +# ------------------------------------------------------------------ +FlatDirection = { + 0: "Kuzey-Kuzey", + 1: "Kuzey-Doğu", + 2: "Kuzey-Batı", + 3: "Güney-Güney", + 4: "Güney-Doğu", + 5: "Güney-Batı", + 6: "Doğu-Doğu", + 7: "Batı-Batı", +} + +ValveType = { + 0: "Yok", + 1: "Dijital", + 2: "PWM", + 3: "Analog", + 4: "Enkoder", + 5: "Modbus", + 6: "CANbus", +} + + +# ------------------------------------------------------------------ +# DS18B20 sensör sınıfı +# ------------------------------------------------------------------ +class EDSensor: + """Tek bir DS18B20 sensörünü temsil eder.""" + + def __init__(self, serial: str, flat_no: int, flat_direction: int, floor: int, room_no: int = 0) -> None: + self.serial = serial + self.flat_no = flat_no + self.floor = floor + self.room_no = room_no + self.flat_direction = flat_direction + self.device_path = f"/sys/bus/w1/devices/{serial}/w1_slave" + self.flat_check = 0 + self.is_connected = True + + def read_temperature(self) -> Optional[float]: + try: + with open(self.device_path, "r") as f: + lines = f.readlines() + if lines[0].strip().endswith("YES"): + pos = lines[1].find("t=") + if pos != -1: + raw = lines[1][pos + 2 :] + t_c = float(raw) / 1000.0 + self.is_connected = True + return t_c + except Exception: + self.flat_check += 1 + self.is_connected = False + return None + + +# ------------------------------------------------------------------ +# Daire nesnesi +# ------------------------------------------------------------------ +class Flat: + """Daire nesnesi: yön, kat ve sensör ilişkisi.""" + + def __init__(self, flat_no: int, flat_direction: int, sensor: EDSensor, floor: int, room_no: int = 0) -> None: + self.flat_no = flat_no + self.flat_direction = flat_direction + self.sensor = sensor + self.floor = floor + self.room_no = room_no + self.last_heat: Optional[float] = None + self.last_read_time: Optional[datetime.datetime] = None + + +# ------------------------------------------------------------------ +# Bina sınıfı +# ------------------------------------------------------------------ +class Building: + """ + Bina seviyesi sürücü. + + config_statics üzerinden: + + BUILDING_NAME + BUILDING_LOCATION + BUILDING_LABEL + + GEO_CITY + GEO_COUNTRY + GEO_TZ + GEO_LAT + GEO_LON + + FLAT_AREA + BURNER_GROUPS + OUTSIDE_SENSOR_ID / NAME + BURNER_OUT_SENSOR_ID / NAME + RETURN_LINE_SENSOR_IDS / NAME_MAP + """ + + def __init__( + self, + name: Optional[str] = None, + location: Optional[str] = None, + bina_adi: Optional[str] = None, + logtime: bool = False, + logger: Optional[DBText] = None, + ) -> None: + # Bina bilgileri config_statics'ten + self.name = name or getattr(cfg_s, "BUILDING_NAME", "Bina") + self.location = location or getattr(cfg_s, "BUILDING_LOCATION", "Ankara") + self.bina_adi = bina_adi or getattr(cfg_s, "BUILDING_LABEL", "A_Binasi") + + # Coğrafi bilgiler + self.geo_city = getattr(cfg_s, "GEO_CITY", "Ankara") + self.geo_country = getattr(cfg_s, "GEO_COUNTRY", "Turkey") + self.geo_tz = getattr(cfg_s, "GEO_TZ", "Europe/Istanbul") + self.geo_lat = float(getattr(cfg_s, "GEO_LAT", 39.92077)) + self.geo_lon = float(getattr(cfg_s, "GEO_LON", 32.85411)) + + # Logger + self.logger = logger or DBText( + filename=getattr(cfg_s, "BUILDING_LOG_FILE", "ebina_log.sql"), + table=getattr(cfg_s, "BUILDING_LOG_TABLE", "ebrulor_log"), + app="ESYSTEM", + ) + + # Çalışma aralığı: 3 dakika (eski koddaki gibi) + interval_min = getattr(cfg_v, "BUILDING_SAMPLE_MINUTES", 3) + self.interval = datetime.timedelta(seconds=60 * interval_min) + self.next_run = datetime.datetime.now() + + self.flats: Dict[int, Flat] = {} + self.last_max_floor: Optional[int] = None + self.logtime = logtime + + # Season / tatil bilgisi + try: + self.season = SeasonController.from_now() + except Exception as e: + print(f"⚠️ SeasonController.from_now() hata: {e}") + self.season = None + + # Daireleri FLAT_AREA'dan oluştur + self._load_statics_from_flat_area() + + # Bina genel istatistiği (ilk deneme) + try: + self.get_building_temperature_stats() + except Exception: + pass + + def pretty_summary(self) -> str: + """ + Bina durumunu okunabilir bir metin olarak döndür. + Konsola bastığımız bilgilerin string versiyonu. + """ + import io + import contextlib + + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + # İstersen istatistiği de özetin içine kat: + self.get_building_temperature_stats() + + # Zaten mevcut olan konsol fonksiyonlarını kullanıyoruz + self._print_header() + self._print_sun_and_season() + self._print_burner_topology() + self._print_line_sensors() + self.print_config() + + return buf.getvalue() + + + def get_hat_sensor_by_id(self, sensor_id: str): + """ + Verilen DS18B20 ID'sine göre hat sensör nesnesini döndür. + Yoksa None döndür. + """ + return self.hat_sensors.get(sensor_id) # yapıya göre uyarlarsın + + # ------------------------------------------------------------------ + # Statik konfigürasyondan daire/sensör yükleme + # ------------------------------------------------------------------ + def _load_statics_from_flat_area(self) -> None: + """config_statics.FLAT_AREA içinden daire/sensörleri yükler.""" + self.flats = {} + flat_area = getattr(cfg_s, "FLAT_AREA", []) + for item in flat_area: + serial = item.get("serial") + flat_no = int(item.get("flat_no", 0)) + room_no = int(item.get("room_no", 0)) + floor = int(item.get("floor", 0)) + direction = int(item.get("direction", 0)) + if not serial: + continue + sensor = EDSensor(serial, flat_no, direction, floor, room_no=room_no) + # bağlantı testi + sensor.read_temperature() + flat = Flat(flat_no, direction, sensor, floor, room_no=room_no) + self.flats[flat_no] = flat + + print("✅ Building: statics yüklendi") + + # ------------------------------------------------------------------ + # Başlangıç çıktıları + # ------------------------------------------------------------------ + def _print_header(self) -> None: + print(f"🏢 Bina adı : {self.bina_adi}") + print(f"📍 Konum : {self.name} / {self.location}") + print( + f"🌍 Geo : {self.geo_city}, {self.geo_country} " + f"({self.geo_lat}, {self.geo_lon}) tz={self.geo_tz}" + ) + print(f"🏠 Daire/oda sayısı: {len(self.flats)}\n") + + def _print_sun_and_season(self) -> None: + """SeasonController üzerinden sunrise / sunset ve mevsim / tatil bilgisini yazar.""" + if not self.season or not getattr(self.season, "info", None): + print("☀️ Sunrise/Sunset : --:-- / --:--") + print("🧭 Mevsim / Tatil : (bilgi yok)\n") + return + + info = self.season.info + sunrise = info.sunrise.strftime("%H:%M") if info.sunrise else "--:--" + sunset = info.sunset.strftime("%H:%M") if info.sunset else "--:--" + + print(f"☀️ Sunrise/Sunset : {sunrise} / {sunset}") + if info.season_start and info.season_end: + print( + f"🧭 Mevsim / Tatil : {info.season} " + f"({info.season_start} → {info.season_end}), " + f"Tatil={info.is_holiday} {info.holiday_label}" + ) + else: + print( + f"🧭 Mevsim / Tatil : {info.season}, " + f"Tatil={info.is_holiday} {info.holiday_label}" + ) + print() + + def _print_burner_topology(self) -> None: + """config_statics.BURNER_GROUPS bilgisini okunaklı yazar.""" + groups = getattr(cfg_s, "BURNER_GROUPS", {}) + if not groups: + print("🔥 Brülör topolojisi: tanımlı değil (BURNER_GROUPS boş).\n") + return + + print("🔥 Brülör Topolojisi (config_statics.BURNER_GROUPS):") + for bid, info in groups.items(): + name = info.get("name", f"Burner{bid}") + loc = info.get("location", "-") + ign = info.get("igniter_pin", None) + print(f" #{bid}: {name} @ {loc}") + print(f" Igniter pin : {ign}") + circ = info.get("circulation", {}) + if not circ: + print(" Pompalar : (tanımlı değil)") + else: + print(" Pompalar :") + for cname, cinfo in circ.items(): + pin = cinfo.get("pin") + default = cinfo.get("default", 0) + print(f" - {cname:<7} → pin={pin:<3} default={default}") + print() + + # ------------------------------------------------------------------ + # Hat sensörleri: dış / çıkış / dönüş + # ------------------------------------------------------------------ + def _read_ds18b20_raw(self, serial: str) -> Optional[float]: + """Seri numarası verilen DS18B20'den tek seferlik okuma.""" + path = f"/sys/bus/w1/devices/{serial}/w1_slave" + try: + with open(path, "r") as f: + lines = f.readlines() + if not lines or not lines[0].strip().endswith("YES"): + return None + pos = lines[1].find("t=") + if pos == -1: + return None + raw = lines[1][pos + 2 :] + return float(raw) / 1000.0 + except Exception: + return None + + def _print_line_sensors(self) -> None: + """ + Dış ısı, kazan çıkış ısısı ve dönüş hat sensörlerini (varsa) ekrana yazar. + config_statics içindeki: + OUTSIDE_SENSOR_ID / OUTSIDE_SENSOR_NAME + BURNER_OUT_SENSOR_ID / BURNER_OUT_SENSOR_NAME + RETURN_LINE_SENSOR_IDS + RETURN_LINE_SENSOR_NAME_MAP + kullanılır. + """ + outside_id = getattr(cfg_s, "OUTSIDE_SENSOR_ID", None) + outside_nm = getattr(cfg_s, "OUTSIDE_SENSOR_NAME", "Dış Isı 1") + burner_id = getattr(cfg_s, "BURNER_OUT_SENSOR_ID", None) + burner_nm = getattr(cfg_s, "BURNER_OUT_SENSOR_NAME", "Çıkış Isı 1") + return_ids = list(getattr(cfg_s, "RETURN_LINE_SENSOR_IDS", [])) + name_map = dict(getattr(cfg_s, "RETURN_LINE_SENSOR_NAME_MAP", {})) + + if not (outside_id or burner_id or return_ids): + # Hiç tanım yoksa sessizce geç + return + + print("🌡️ Hat sensörleri:") + # Dış ısı + if outside_id: + t = self._read_ds18b20_raw(outside_id) + if t is None: + print(f" - {outside_nm:<15}: ❌ Okunamadı ({outside_id})") + else: + print(f" - {outside_nm:<15}: {t:5.2f}°C ({outside_id})") + + # Kazan çıkış + if burner_id: + t = self._read_ds18b20_raw(burner_id) + if t is None: + print(f" - {burner_nm:<15}: ❌ Okunamadı ({burner_id})") + else: + print(f" - {burner_nm:<15}: {t:5.2f}°C ({burner_id})") + + # Dönüş hatları + for sid in return_ids: + nm = name_map.get(sid, sid) + t = self._read_ds18b20_raw(sid) + if t is None: + print(f" - Dönüş {nm:<10}: ❌ Okunamadı ({sid})") + else: + print(f" - Dönüş {nm:<10}: {t:5.2f}°C ({sid})") + print() + + # ------------------------------------------------------------------ + # İstatistik fonksiyonları + # ------------------------------------------------------------------ + def _summarize(self, values: List[float]) -> dict: + if not values: + return {"min": None, "max": None, "avg": None, "count": 0} + return { + "min": min(values), + "max": max(values), + "avg": round(statistics.mean(values), 2), + "count": len(values), + } + + def get_temperature_stats(self) -> str: + stats = { + "floors": defaultdict(list), + "directions": defaultdict(list), + "top_floor": [], + "building": [], + } + + max_floor = self.last_max_floor + + for flat in self.flats.values(): + temp = flat.sensor.read_temperature() + if temp is not None: + flat.last_heat = temp + flat.last_read_time = datetime.datetime.now() + stats["floors"][flat.floor].append(temp) + stats["directions"][flat.flat_direction].append(temp) + stats["building"].append(temp) + if max_floor is not None and flat.floor == max_floor: + stats["top_floor"].append(temp) + + result = { + "floors": {f: self._summarize(v) for f, v in stats["floors"].items()}, + "directions": { + d: self._summarize(v) for d, v in stats["directions"].items() + }, + "top_floor": self._summarize(stats["top_floor"]), + "building": self._summarize(stats["building"]), + } + return json.dumps(result, indent=4, ensure_ascii=False) + + def get_last_heat_report(self) -> str: + report = [] + for flat in self.flats.values(): + temp = flat.sensor.read_temperature() + now = datetime.datetime.now() + if temp is not None: + flat.last_heat = temp + flat.last_read_time = now + report.append( + { + "flat_no": flat.flat_no, + "room_no": flat.room_no, + "direction": flat.flat_direction, + "floor": flat.floor, + "serial": flat.sensor.serial, + "last_heat": flat.last_heat, + "last_read_time": flat.last_read_time.strftime( + "%Y-%m-%d %H:%M:%S" + ) + if flat.last_read_time + else None, + "is_connected": flat.sensor.is_connected, + "flat_check": flat.sensor.flat_check, + } + ) + return json.dumps(report, indent=4, ensure_ascii=False) + + def get_building_temperature_stats(self) -> Optional[dict]: + print("\n🌡️ Bina Genel Isı Bilgisi:") + all_temps: List[float] = [] + floor_temps: Dict[int, List[float]] = {} + now = datetime.datetime.now() + + # Log zamanı mı? + if now >= self.next_run: + self.logtime = True + self.next_run = now + self.interval + else: + self.logtime = False + + self.last_max_floor = None + + for flat in self.flats.values(): + temp = flat.sensor.read_temperature() + floor = flat.floor + + if temp is not None: + flat.last_heat = temp + flat.last_read_time = now + if self.last_max_floor is None or floor > self.last_max_floor: + self.last_max_floor = floor + else: + flat.last_heat = None + flat.last_read_time = None + + floor_temps.setdefault(floor, []) + if temp is not None: + all_temps.append(temp) + floor_temps[floor].append(temp) + if self.logtime: + self.logger.insert_event( + source=f"Sensor:{flat.sensor.serial}", + event_type="temperature", + value=temp, + unit="°C", + timestamp=now, + extra=f"Daire {flat.flat_no} Oda {flat.room_no} Kat {flat.floor}, Yön {flat.flat_direction}", + ) + + if not all_temps or not floor_temps: + print("❌ Hiç sıcaklık verisi alınamadı.\n") + return None + + min_temp = min(all_temps) + max_temp = max(all_temps) + avg_temp = sum(all_temps) / len(all_temps) + + min_floor = min(floor_temps.keys()) + max_floor = max(floor_temps.keys()) + + avg_min_floor = sum(floor_temps[min_floor]) / max(len(floor_temps[min_floor]), 1) + avg_max_floor = sum(floor_temps[max_floor]) / max(len(floor_temps[max_floor]), 1) + delta_t = abs(avg_max_floor - avg_min_floor) + + print( + " Min: {:.2f}°C | Max: {:.2f}°C | Avg: {:.2f}°C | Count: {}".format( + min_temp, max_temp, avg_temp, len(all_temps) + ) + ) + print(f" En Alt Kat ({min_floor}): {avg_min_floor:.2f}°C") + print(f" En Üst Kat ({max_floor}): {avg_max_floor:.2f}°C") + print(f" 🔺 Delta Isı (Üst - Alt): {delta_t:.2f}°C") + + if self.logtime: + self.logger.insert_event( + source="Sensor:min", + event_type="temperature", + value=min_temp, + unit="°C", + timestamp=now, + extra="", + ) + self.logger.insert_event( + source="Sensor:max", + event_type="temperature", + value=max_temp, + unit="°C", + timestamp=now, + extra="", + ) + self.logger.insert_event( + source="Sensor:avg", + event_type="temperature", + value=avg_temp, + unit="°C", + timestamp=now, + extra="", + ) + + return { + "min": min_temp, + "max": max_temp, + "avg": avg_temp, + "count": len(all_temps), + "min_floor": min_floor, + "max_floor": max_floor, + "avg_min_floor": avg_min_floor, + "avg_max_floor": avg_max_floor, + "delta_t": delta_t, + } + + # ------------------------------------------------------------------ + # Yardımcı + # ------------------------------------------------------------------ + def print_config(self) -> None: + if not self.flats: + print(f"📦 {self.bina_adi} için tanımlı daire yok.") + return + + print(f"\n📦 {self.bina_adi} içeriği:") + for flat_no, flat in self.flats.items(): + temp = flat.sensor.read_temperature() + durum = "✅ Bağlı" if temp is not None else "❌ Yok" + print( + " Daire {:<3} Oda {:<1} | Kat: {:<2} | Yön: {:<12} | Sensör: {:<18} | Durum: {} | AKTIF".format( + flat.flat_no, + flat.room_no, + flat.floor, + FlatDirection.get(flat.flat_direction, "Bilinmiyor"), + flat.sensor.serial, + durum, + ) + ) + + def run_forever(self, sleep_s: int = 5) -> None: + """Eski test aracı gibi: sürekli bina istatistiği üretir.""" + while True: + self.get_building_temperature_stats() + time.sleep(sleep_s) + + +# ---------------------------------------------------------------------- +# CLI +# ---------------------------------------------------------------------- +if __name__ == "__main__": + bina = Building() # Config'ten her şeyi alır + bina.run_forever() diff --git a/ebuild/core/building.py~ b/ebuild/core/building.py~ new file mode 100644 index 0000000..a95be4f --- /dev/null +++ b/ebuild/core/building.py~ @@ -0,0 +1,574 @@ +# -*- coding: utf-8 -*- +from __future__ import annotations + +__title__ = "building" +__author__ = 'Mehmet Karatay & "Saraswati" (ChatGPT)' +__purpose__ = "Bina / daire / sensör ve brülör topolojisi sürücüsü" +__version__ = "0.4.3" +__date__ = "2025-11-22" + +""" +ebuild/core/building.py + +Revision : 2025-11-22 +Authors : Mehmet Karatay & "Saraswati" (ChatGPT) + +Amaç +----- +- Statik konfigürasyondan (config_statics) bina, geo ve sensör topolojisini yükler. +- FLAT_AREA içindeki DS18B20 sensörlerini daire/oda bazında yönetir. +- Bina ortalama sıcaklık istatistiklerini üretir. +- Brülör grupları (BURNER_GROUPS) ve hat sensörleri (dış, çıkış, dönüş) için + başlangıçta okunabilir bir özet verir. +- SeasonController ile mevsim / güneş / tatil bilgisi sağlar. + +Not +--- +Bu modül, hem bağımsız test aracı olarak `python3 -m ebuild.core.building` +şeklinde çalışabilir, hem de BurnerController gibi üst seviye kontrol +modülleri tarafından kullanılabilir. +""" + +import os +import json +import time +import datetime +import statistics +from collections import defaultdict +from typing import Dict, List, Optional, Any + +from .season import SeasonController +from ..io.dbtext import DBText +from .. import config_statics as cfg_s +from .. import config_runtime as cfg_v + + +# ------------------------------------------------------------------ +# Yardımcı sabitler +# ------------------------------------------------------------------ +FlatDirection = { + 0: "Kuzey-Kuzey", + 1: "Kuzey-Doğu", + 2: "Kuzey-Batı", + 3: "Güney-Güney", + 4: "Güney-Doğu", + 5: "Güney-Batı", + 6: "Doğu-Doğu", + 7: "Batı-Batı", +} + +ValveType = { + 0: "Yok", + 1: "Dijital", + 2: "PWM", + 3: "Analog", + 4: "Enkoder", + 5: "Modbus", + 6: "CANbus", +} + + +# ------------------------------------------------------------------ +# DS18B20 sensör sınıfı +# ------------------------------------------------------------------ +class EDSensor: + """Tek bir DS18B20 sensörünü temsil eder.""" + + def __init__(self, serial: str, flat_no: int, flat_direction: int, floor: int, room_no: int = 0) -> None: + self.serial = serial + self.flat_no = flat_no + self.floor = floor + self.room_no = room_no + self.flat_direction = flat_direction + self.device_path = f"/sys/bus/w1/devices/{serial}/w1_slave" + self.flat_check = 0 + self.is_connected = True + + def read_temperature(self) -> Optional[float]: + try: + with open(self.device_path, "r") as f: + lines = f.readlines() + if lines[0].strip().endswith("YES"): + pos = lines[1].find("t=") + if pos != -1: + raw = lines[1][pos + 2 :] + t_c = float(raw) / 1000.0 + self.is_connected = True + return t_c + except Exception: + self.flat_check += 1 + self.is_connected = False + return None + + +# ------------------------------------------------------------------ +# Daire nesnesi +# ------------------------------------------------------------------ +class Flat: + """Daire nesnesi: yön, kat ve sensör ilişkisi.""" + + def __init__(self, flat_no: int, flat_direction: int, sensor: EDSensor, floor: int, room_no: int = 0) -> None: + self.flat_no = flat_no + self.flat_direction = flat_direction + self.sensor = sensor + self.floor = floor + self.room_no = room_no + self.last_heat: Optional[float] = None + self.last_read_time: Optional[datetime.datetime] = None + + +# ------------------------------------------------------------------ +# Bina sınıfı +# ------------------------------------------------------------------ +class Building: + """ + Bina seviyesi sürücü. + + config_statics üzerinden: + + BUILDING_NAME + BUILDING_LOCATION + BUILDING_LABEL + + GEO_CITY + GEO_COUNTRY + GEO_TZ + GEO_LAT + GEO_LON + + FLAT_AREA + BURNER_GROUPS + OUTSIDE_SENSOR_ID / NAME + BURNER_OUT_SENSOR_ID / NAME + RETURN_LINE_SENSOR_IDS / NAME_MAP + """ + + def __init__( + self, + name: Optional[str] = None, + location: Optional[str] = None, + bina_adi: Optional[str] = None, + logtime: bool = False, + logger: Optional[DBText] = None, + ) -> None: + # Bina bilgileri config_statics'ten + self.name = name or getattr(cfg_s, "BUILDING_NAME", "Bina") + self.location = location or getattr(cfg_s, "BUILDING_LOCATION", "Ankara") + self.bina_adi = bina_adi or getattr(cfg_s, "BUILDING_LABEL", "A_Binasi") + + # Coğrafi bilgiler + self.geo_city = getattr(cfg_s, "GEO_CITY", "Ankara") + self.geo_country = getattr(cfg_s, "GEO_COUNTRY", "Turkey") + self.geo_tz = getattr(cfg_s, "GEO_TZ", "Europe/Istanbul") + self.geo_lat = float(getattr(cfg_s, "GEO_LAT", 39.92077)) + self.geo_lon = float(getattr(cfg_s, "GEO_LON", 32.85411)) + + # Logger + self.logger = logger or DBText( + filename=getattr(cfg_s, "BUILDING_LOG_FILE", "ebina_log.sql"), + table=getattr(cfg_s, "BUILDING_LOG_TABLE", "ebrulor_log"), + app="ESYSTEM", + ) + + # Çalışma aralığı: 3 dakika (eski koddaki gibi) + interval_min = getattr(cfg_v, "BUILDING_SAMPLE_MINUTES", 3) + self.interval = datetime.timedelta(seconds=60 * interval_min) + self.next_run = datetime.datetime.now() + + self.flats: Dict[int, Flat] = {} + self.last_max_floor: Optional[int] = None + self.logtime = logtime + + # Season / tatil bilgisi + try: + self.season = SeasonController.from_now() + except Exception as e: + print(f"⚠️ SeasonController.from_now() hata: {e}") + self.season = None + + # Daireleri FLAT_AREA'dan oluştur + self._load_statics_from_flat_area() + + # Bina genel istatistiği (ilk deneme) + try: + self.get_building_temperature_stats() + except Exception: + pass + + # Konsol özet + self._print_header() + self._print_sun_and_season() + self._print_burner_topology() + self._print_line_sensors() + self.print_config() + + def get_hat_sensor_by_id(self, sensor_id: str): + """ + Verilen DS18B20 ID'sine göre hat sensör nesnesini döndür. + Yoksa None döndür. + """ + return self.hat_sensors.get(sensor_id) # yapıya göre uyarlarsın + + # ------------------------------------------------------------------ + # Statik konfigürasyondan daire/sensör yükleme + # ------------------------------------------------------------------ + def _load_statics_from_flat_area(self) -> None: + """config_statics.FLAT_AREA içinden daire/sensörleri yükler.""" + self.flats = {} + flat_area = getattr(cfg_s, "FLAT_AREA", []) + for item in flat_area: + serial = item.get("serial") + flat_no = int(item.get("flat_no", 0)) + room_no = int(item.get("room_no", 0)) + floor = int(item.get("floor", 0)) + direction = int(item.get("direction", 0)) + if not serial: + continue + sensor = EDSensor(serial, flat_no, direction, floor, room_no=room_no) + # bağlantı testi + sensor.read_temperature() + flat = Flat(flat_no, direction, sensor, floor, room_no=room_no) + self.flats[flat_no] = flat + + print("✅ Building: statics yüklendi") + + # ------------------------------------------------------------------ + # Başlangıç çıktıları + # ------------------------------------------------------------------ + def _print_header(self) -> None: + print(f"🏢 Bina adı : {self.bina_adi}") + print(f"📍 Konum : {self.name} / {self.location}") + print( + f"🌍 Geo : {self.geo_city}, {self.geo_country} " + f"({self.geo_lat}, {self.geo_lon}) tz={self.geo_tz}" + ) + print(f"🏠 Daire/oda sayısı: {len(self.flats)}\n") + + def _print_sun_and_season(self) -> None: + """SeasonController üzerinden sunrise / sunset ve mevsim / tatil bilgisini yazar.""" + if not self.season or not getattr(self.season, "info", None): + print("☀️ Sunrise/Sunset : --:-- / --:--") + print("🧭 Mevsim / Tatil : (bilgi yok)\n") + return + + info = self.season.info + sunrise = info.sunrise.strftime("%H:%M") if info.sunrise else "--:--" + sunset = info.sunset.strftime("%H:%M") if info.sunset else "--:--" + + print(f"☀️ Sunrise/Sunset : {sunrise} / {sunset}") + if info.season_start and info.season_end: + print( + f"🧭 Mevsim / Tatil : {info.season} " + f"({info.season_start} → {info.season_end}), " + f"Tatil={info.is_holiday} {info.holiday_label}" + ) + else: + print( + f"🧭 Mevsim / Tatil : {info.season}, " + f"Tatil={info.is_holiday} {info.holiday_label}" + ) + print() + + def _print_burner_topology(self) -> None: + """config_statics.BURNER_GROUPS bilgisini okunaklı yazar.""" + groups = getattr(cfg_s, "BURNER_GROUPS", {}) + if not groups: + print("🔥 Brülör topolojisi: tanımlı değil (BURNER_GROUPS boş).\n") + return + + print("🔥 Brülör Topolojisi (config_statics.BURNER_GROUPS):") + for bid, info in groups.items(): + name = info.get("name", f"Burner{bid}") + loc = info.get("location", "-") + ign = info.get("igniter_pin", None) + print(f" #{bid}: {name} @ {loc}") + print(f" Igniter pin : {ign}") + circ = info.get("circulation", {}) + if not circ: + print(" Pompalar : (tanımlı değil)") + else: + print(" Pompalar :") + for cname, cinfo in circ.items(): + pin = cinfo.get("pin") + default = cinfo.get("default", 0) + print(f" - {cname:<7} → pin={pin:<3} default={default}") + print() + + # ------------------------------------------------------------------ + # Hat sensörleri: dış / çıkış / dönüş + # ------------------------------------------------------------------ + def _read_ds18b20_raw(self, serial: str) -> Optional[float]: + """Seri numarası verilen DS18B20'den tek seferlik okuma.""" + path = f"/sys/bus/w1/devices/{serial}/w1_slave" + try: + with open(path, "r") as f: + lines = f.readlines() + if not lines or not lines[0].strip().endswith("YES"): + return None + pos = lines[1].find("t=") + if pos == -1: + return None + raw = lines[1][pos + 2 :] + return float(raw) / 1000.0 + except Exception: + return None + + def _print_line_sensors(self) -> None: + """ + Dış ısı, kazan çıkış ısısı ve dönüş hat sensörlerini (varsa) ekrana yazar. + config_statics içindeki: + OUTSIDE_SENSOR_ID / OUTSIDE_SENSOR_NAME + BURNER_OUT_SENSOR_ID / BURNER_OUT_SENSOR_NAME + RETURN_LINE_SENSOR_IDS + RETURN_LINE_SENSOR_NAME_MAP + kullanılır. + """ + outside_id = getattr(cfg_s, "OUTSIDE_SENSOR_ID", None) + outside_nm = getattr(cfg_s, "OUTSIDE_SENSOR_NAME", "Dış Isı 1") + burner_id = getattr(cfg_s, "BURNER_OUT_SENSOR_ID", None) + burner_nm = getattr(cfg_s, "BURNER_OUT_SENSOR_NAME", "Çıkış Isı 1") + return_ids = list(getattr(cfg_s, "RETURN_LINE_SENSOR_IDS", [])) + name_map = dict(getattr(cfg_s, "RETURN_LINE_SENSOR_NAME_MAP", {})) + + if not (outside_id or burner_id or return_ids): + # Hiç tanım yoksa sessizce geç + return + + print("🌡️ Hat sensörleri:") + # Dış ısı + if outside_id: + t = self._read_ds18b20_raw(outside_id) + if t is None: + print(f" - {outside_nm:<15}: ❌ Okunamadı ({outside_id})") + else: + print(f" - {outside_nm:<15}: {t:5.2f}°C ({outside_id})") + + # Kazan çıkış + if burner_id: + t = self._read_ds18b20_raw(burner_id) + if t is None: + print(f" - {burner_nm:<15}: ❌ Okunamadı ({burner_id})") + else: + print(f" - {burner_nm:<15}: {t:5.2f}°C ({burner_id})") + + # Dönüş hatları + for sid in return_ids: + nm = name_map.get(sid, sid) + t = self._read_ds18b20_raw(sid) + if t is None: + print(f" - Dönüş {nm:<10}: ❌ Okunamadı ({sid})") + else: + print(f" - Dönüş {nm:<10}: {t:5.2f}°C ({sid})") + print() + + # ------------------------------------------------------------------ + # İstatistik fonksiyonları + # ------------------------------------------------------------------ + def _summarize(self, values: List[float]) -> dict: + if not values: + return {"min": None, "max": None, "avg": None, "count": 0} + return { + "min": min(values), + "max": max(values), + "avg": round(statistics.mean(values), 2), + "count": len(values), + } + + def get_temperature_stats(self) -> str: + stats = { + "floors": defaultdict(list), + "directions": defaultdict(list), + "top_floor": [], + "building": [], + } + + max_floor = self.last_max_floor + + for flat in self.flats.values(): + temp = flat.sensor.read_temperature() + if temp is not None: + flat.last_heat = temp + flat.last_read_time = datetime.datetime.now() + stats["floors"][flat.floor].append(temp) + stats["directions"][flat.flat_direction].append(temp) + stats["building"].append(temp) + if max_floor is not None and flat.floor == max_floor: + stats["top_floor"].append(temp) + + result = { + "floors": {f: self._summarize(v) for f, v in stats["floors"].items()}, + "directions": { + d: self._summarize(v) for d, v in stats["directions"].items() + }, + "top_floor": self._summarize(stats["top_floor"]), + "building": self._summarize(stats["building"]), + } + return json.dumps(result, indent=4, ensure_ascii=False) + + def get_last_heat_report(self) -> str: + report = [] + for flat in self.flats.values(): + temp = flat.sensor.read_temperature() + now = datetime.datetime.now() + if temp is not None: + flat.last_heat = temp + flat.last_read_time = now + report.append( + { + "flat_no": flat.flat_no, + "room_no": flat.room_no, + "direction": flat.flat_direction, + "floor": flat.floor, + "serial": flat.sensor.serial, + "last_heat": flat.last_heat, + "last_read_time": flat.last_read_time.strftime( + "%Y-%m-%d %H:%M:%S" + ) + if flat.last_read_time + else None, + "is_connected": flat.sensor.is_connected, + "flat_check": flat.sensor.flat_check, + } + ) + return json.dumps(report, indent=4, ensure_ascii=False) + + def get_building_temperature_stats(self) -> Optional[dict]: + print("\n🌡️ Bina Genel Isı Bilgisi:") + all_temps: List[float] = [] + floor_temps: Dict[int, List[float]] = {} + now = datetime.datetime.now() + + # Log zamanı mı? + if now >= self.next_run: + self.logtime = True + self.next_run = now + self.interval + else: + self.logtime = False + + self.last_max_floor = None + + for flat in self.flats.values(): + temp = flat.sensor.read_temperature() + floor = flat.floor + + if temp is not None: + flat.last_heat = temp + flat.last_read_time = now + if self.last_max_floor is None or floor > self.last_max_floor: + self.last_max_floor = floor + else: + flat.last_heat = None + flat.last_read_time = None + + floor_temps.setdefault(floor, []) + if temp is not None: + all_temps.append(temp) + floor_temps[floor].append(temp) + if self.logtime: + self.logger.insert_event( + source=f"Sensor:{flat.sensor.serial}", + event_type="temperature", + value=temp, + unit="°C", + timestamp=now, + extra=f"Daire {flat.flat_no} Oda {flat.room_no} Kat {flat.floor}, Yön {flat.flat_direction}", + ) + + if not all_temps or not floor_temps: + print("❌ Hiç sıcaklık verisi alınamadı.\n") + return None + + min_temp = min(all_temps) + max_temp = max(all_temps) + avg_temp = sum(all_temps) / len(all_temps) + + min_floor = min(floor_temps.keys()) + max_floor = max(floor_temps.keys()) + + avg_min_floor = sum(floor_temps[min_floor]) / max(len(floor_temps[min_floor]), 1) + avg_max_floor = sum(floor_temps[max_floor]) / max(len(floor_temps[max_floor]), 1) + delta_t = abs(avg_max_floor - avg_min_floor) + + print( + " Min: {:.2f}°C | Max: {:.2f}°C | Avg: {:.2f}°C | Count: {}".format( + min_temp, max_temp, avg_temp, len(all_temps) + ) + ) + print(f" En Alt Kat ({min_floor}): {avg_min_floor:.2f}°C") + print(f" En Üst Kat ({max_floor}): {avg_max_floor:.2f}°C") + print(f" 🔺 Delta Isı (Üst - Alt): {delta_t:.2f}°C") + + if self.logtime: + self.logger.insert_event( + source="Sensor:min", + event_type="temperature", + value=min_temp, + unit="°C", + timestamp=now, + extra="", + ) + self.logger.insert_event( + source="Sensor:max", + event_type="temperature", + value=max_temp, + unit="°C", + timestamp=now, + extra="", + ) + self.logger.insert_event( + source="Sensor:avg", + event_type="temperature", + value=avg_temp, + unit="°C", + timestamp=now, + extra="", + ) + + return { + "min": min_temp, + "max": max_temp, + "avg": avg_temp, + "count": len(all_temps), + "min_floor": min_floor, + "max_floor": max_floor, + "avg_min_floor": avg_min_floor, + "avg_max_floor": avg_max_floor, + "delta_t": delta_t, + } + + # ------------------------------------------------------------------ + # Yardımcı + # ------------------------------------------------------------------ + def print_config(self) -> None: + if not self.flats: + print(f"📦 {self.bina_adi} için tanımlı daire yok.") + return + + print(f"\n📦 {self.bina_adi} içeriği:") + for flat_no, flat in self.flats.items(): + temp = flat.sensor.read_temperature() + durum = "✅ Bağlı" if temp is not None else "❌ Yok" + print( + " Daire {:<3} Oda {:<1} | Kat: {:<2} | Yön: {:<12} | Sensör: {:<18} | Durum: {} | AKTIF".format( + flat.flat_no, + flat.room_no, + flat.floor, + FlatDirection.get(flat.flat_direction, "Bilinmiyor"), + flat.sensor.serial, + durum, + ) + ) + + def run_forever(self, sleep_s: int = 5) -> None: + """Eski test aracı gibi: sürekli bina istatistiği üretir.""" + while True: + self.get_building_temperature_stats() + time.sleep(sleep_s) + + +# ---------------------------------------------------------------------- +# CLI +# ---------------------------------------------------------------------- +if __name__ == "__main__": + bina = Building() # Config'ten her şeyi alır + bina.run_forever() diff --git a/ebuild/core/devices.py b/ebuild/core/devices.py new file mode 100644 index 0000000..2c961e9 --- /dev/null +++ b/ebuild/core/devices.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +"""Device / Sensor / Actuator soyutlamaları için iskelet.""" diff --git a/ebuild/core/environment.py b/ebuild/core/environment.py new file mode 100644 index 0000000..b11a6a2 --- /dev/null +++ b/ebuild/core/environment.py @@ -0,0 +1,306 @@ +# -*- coding: utf-8 -*- +from __future__ import annotations + +__title__ = "environment" +__author__ = 'Mehmet Karatay & "Saraswati" (ChatGPT)' +__purpose__ = "Bina dış ortam sıcaklığı (DS18B20) ve analog sensörler için ortam merkezi" +__version__ = "0.3.0" +__date__ = "2025-11-23" + +""" +ebuild/core/environment.py + +Amaç +----- +- Bina dış ortam sıcaklığı (DS18B20) ve MCP3008 üzerinden okunan analog sensörleri + tek bir merkezde toplamak. +- Üst seviye modüller (ör: BurnerController, sulama sistemi, alarm sistemi) bu + sınıf üzerinden dış ortam ve analog bilgilerine erişir. + +Notlar +------ +- Bu katman donanım erişimini kapsüller: + * io/ds18b20.py + * io/adc_mcp3008.py + * core/analog_sensors.py +""" + +from typing import Dict, Optional, Any +import datetime + +# Donanım bağımlı modüller +try: + from ..io.ds18b20 import DS18B20Sensor +except ImportError: + print("from ..io.ds18b20 import DS18B20Sensor NOT IMPORTED") + DS18B20Sensor = None # type: ignore + +try: + from ..io.adc_mcp3008 import MCP3008ADC +except ImportError: + print("from ..io.adc_mcp3008 import MCP3008ADC NOT IMPORTED") + MCP3008ADC = None # type: ignore + +try: + from .analog_sensors import AnalogSensorsHub +except ImportError: + print("from .analog_sensors import AnalogSensorsHub NOT IMPORTED") + AnalogSensorsHub = None # type: ignore + +# Config HER ZAMAN ayrı import edilmeli, donanıma bağlı değil +try: + from .. import config_statics as cfgs +except ImportError as e: + print("ENV: config_statics import edilemedi:", e) + cfgs = None # type: ignore + + +class BuildingEnvironment: + """ + Bina çevre sensörlerini yöneten merkez. + + Özellikler: + ----------- + - outside : DS18B20 dış ortam sensörü (OUTSIDE_SENSOR_ID üzerinden) + - adc : MCP3008ADC örneği + - analog : AnalogSensorsHub (basınç, gaz, yağmur, LDR) + """ + + def __init__( + self, + name: str = "BuildingEnvironment", + outside_sensor_id: Optional[str] = None, + use_outside_ds18: bool = True, + use_adc: bool = True, + ) -> None: + #print("BuildingEnvironment Init:", name, outside_sensor_id, use_outside_ds18, use_adc) + self.name = name + + # ------------------------------------------------------------------ + # Dış ortam sıcaklığı (DS18B20) + # ------------------------------------------------------------------ + + # 1) Önce parametreden gelen ID; yoksa config_statics.OUTSIDE_SENSOR_ID + if not outside_sensor_id and cfgs is not None: + try: + outside_sensor_id = getattr(cfgs, "OUTSIDE_SENSOR_ID", None) + except Exception as e: + print("BuildingEnvironment: outside sensör ID okunamadı:", e) + outside_sensor_id = None + + #print( "BuildingEnvironment get outside_sensor_id:", outside_sensor_id, "use_outside_ds18", use_outside_ds18, "use_adc", use_adc ) + + # 2) self.outside_id ve self.outside MUTLAKA burada tanımlanmalı + self.outside_id: str = outside_sensor_id or "" + self.outside: Optional[DS18B20Sensor] = None # type: ignore + + #print("DS18B20Sensor", type(DS18B20Sensor), DS18B20Sensor) + + # 3) ID makulse DS18 sensörünü yarat + if use_outside_ds18 and DS18B20Sensor is not None and len(self.outside_id) > 5: + try: + #print("BuildingEnvironment: DS18B20Sensor yaratılıyor, id =", self.outside_id) + self.outside = DS18B20Sensor(serial=self.outside_id, name="OutsideTemp") + #print("BuildingEnvironment: self.outside:", self.outside) + except Exception as e: + print("BuildingEnvironment: outside sensör oluşturulamadı:", e) + self.outside = None + else: + print( + "BuildingEnvironment: dış sensör yok veya devre dışı:", + "use_outside_ds18 =", use_outside_ds18, + "DS18B20Sensor is None =", DS18B20Sensor is None, + "outside_id =", repr(self.outside_id), + ) + + #print("BuildingEnvironment 5:", name, self.outside, outside_sensor_id, use_outside_ds18, use_adc) + # Dış ısı cache'i (kontrol katmanları için) + self._last_outside_temp_c: Optional[float] = None + self._last_outside_read_ts: Optional[datetime.datetime] = None + + # ------------------------------------------------------------------ + # MCP3008 ve analog sensör hub + # ------------------------------------------------------------------ + self.adc: Optional[MCP3008ADC] = None # type: ignore + self.analog: Optional[AnalogSensorsHub] = None # type: ignore + + if use_adc and MCP3008ADC is not None and AnalogSensorsHub is not None: + try: + self.adc = MCP3008ADC() + self.analog = AnalogSensorsHub(self.adc) + except Exception: + self.adc = None + self.analog = None + + # ---------------------------------------------------------------------- + # Okuma fonksiyonları + # ---------------------------------------------------------------------- + def read_outside_temp(self) -> Dict[str, Optional[float]]: + """ + DS18B20 dış ortam sensöründen sıcaklık okur. + + Dönüş: + {"outside_temp_c": 23.4, "read_ts": datetime} # veya None + """ + temp: Optional[float] = None + now = datetime.datetime.now() + + if self.outside is not None: + try: + temp = self.outside.read_temperature() + except Exception: + temp = None + + if temp is not None: + self._last_outside_temp_c = temp + self._last_outside_read_ts = now + + return { + "outside_temp_c": temp, + "read_ts": now, + } + + def get_outside_temp_cached(self, max_age_sec: int = 60) -> Optional[float]: + """ + Dış ortam sıcaklığını cache üzerinden döndürür; gerekirse sensörden günceller. + + - Eğer hiç okunmamışsa → sensörden okur. + - Eğer son okuma max_age_sec saniyeden eskiyse → yeniden okur. + """ + now = datetime.datetime.now() + + if ( + self._last_outside_read_ts is None + or (now - self._last_outside_read_ts).total_seconds() > max_age_sec + ): + snap = self.read_outside_temp() + temp = snap.get("outside_temp_c") + if temp is not None: + self._last_outside_temp_c = temp + self._last_outside_read_ts = now + + return self._last_outside_temp_c + + def read_analog_all(self) -> Dict[str, Any]: + """ + AnalogSensorsHub üzerinden tüm analog kanalları okur. + + Dönüş sözlüğü: + { + "pressure_raw": ..., + "pressure_state": ..., + "gas_raw": ..., + "gas_state": ..., + "gas_latched_alarm": ..., + "rain_raw": ..., + "rain_state": ..., + "ldr_raw": ..., + "ldr_state": ..., + } + """ + data: Dict[str, Any] = { + "pressure_raw": None, + "pressure_state": None, + "gas_raw": None, + "gas_state": None, + "gas_latched_alarm": None, + "rain_raw": None, + "rain_state": None, + "ldr_raw": None, + "ldr_state": None, + } + + if self.analog is None: + return data + + values = self.analog.update_all() + + # Basınç + data["pressure_raw"] = values.get("pressure") + data["pressure_state"] = getattr(self.analog.pressure, "state", None) + + # Gaz + data["gas_raw"] = values.get("gas") + data["gas_state"] = getattr(self.analog.gas, "state", None) + data["gas_latched_alarm"] = getattr(self.analog.gas, "latched_alarm", None) + + # Yağmur + data["rain_raw"] = values.get("rain") + data["rain_state"] = getattr(self.analog.rain, "state", None) + + # LDR + data["ldr_raw"] = values.get("ldr") + data["ldr_state"] = getattr(self.analog.ldr, "state", None) + + return data + + # ---------------------------------------------------------------------- + # Yüksek seviye snapshot + # ---------------------------------------------------------------------- + def get_snapshot(self) -> Dict[str, Any]: + """ + Dış sıcaklık (DS18B20) + tüm analog sensörler için tek sözlük döndürür. + + Örnek: + { + "outside_temp_c": 14.3, + "pressure_raw": 512, + "pressure_state": "SAFE", + "gas_raw": 80, + "gas_state": "SAFE", + "gas_latched_alarm": False, + "rain_raw": 100, + "rain_state": "DRY", + "ldr_raw": 900, + "ldr_state": "BRIGHT", + } + """ + snap: Dict[str, Any] = {} + snap.update(self.read_outside_temp()) + snap.update(self.read_analog_all()) + return snap + + # ---------------------------------------------------------------------- + # Güvenlik yardımcıları + # ---------------------------------------------------------------------- + def should_shutdown_system(self) -> bool: + """ + Analog sensör verilerine göre sistem acil kapatılmalı mı? + + Örn: + - Gaz sızıntısı + - Aşırı basınç + vs. + + Şu an sadece gaz latched_alarm'a bakıyor. + """ + if self.analog is None: + return False + + if getattr(self.analog.gas, "latched_alarm", False): + return True + + return False + + # ---------------------------------------------------------------------- + # Temel temsil + # ---------------------------------------------------------------------- + def summary(self) -> str: + """ + Ortamın kısa özetini döndürür. + """ + parts = [f"env_name={self.name}"] + parts.append(f"outside_id={self.outside_id!r}") + parts.append(f"outside={'OK' if self.outside is not None else 'NONE'}") + parts.append(f"adc={'OK' if self.adc is not None else 'NONE'}") + parts.append(f"analog={'OK' if self.analog is not None else 'NONE'}") + return "BuildingEnvironment(" + ", ".join(parts) + ")" + + +if __name__ == "__main__": + # Basit demo + env = BuildingEnvironment() + print(env.summary()) + snap = env.get_snapshot() + print("Snapshot:", snap) + print("Gaz shutdown mu?:", env.should_shutdown_system()) diff --git a/ebuild/core/season.py b/ebuild/core/season.py new file mode 100644 index 0000000..4a85baa --- /dev/null +++ b/ebuild/core/season.py @@ -0,0 +1,395 @@ +# -*- coding: utf-8 -*- +from __future__ import annotations + +__title__ = "season" +__author__ = 'Mehmet Karatay & "Saraswati" (ChatGPT)' +__purpose__ = "Güneş / tatil / mevsim + tasarruf sezonu bilgisini sağlayan yardımcı katman" +__version__ = "0.3.1" +__date__ = "2025-11-23" + +""" +ebuild/core/season.py + +Amaç +----- +- SunHolidayInfo kullanarak: + * gün doğumu / batımı / öğlen + * resmi tatil bilgisi + * mevsim ve sezon süresi + * tasarruf sezonu (saving) başlangıç/bitiş + bilgilerini üretir. + +- Bu bilgiyi BurnerController ve legacy_syslog gibi modüllere + SeasonController.info üzerinden sağlar. + +Not: +- SunHolidayInfo import edilemez veya hata verirse, basit bir + fallback mevsim + tasarruf sezonu bilgisi üretir. +""" + +from dataclasses import dataclass +from datetime import datetime, time, date, timedelta +from typing import Optional, Tuple, Any + +from .. import config_statics as cfg_s + +# SunHolidayInfo'yu paket içinden al; yoksa fallback'e düş +try: + from .sunholiday import SunHolidayInfo # type: ignore +except Exception: + SunHolidayInfo = None # type: ignore + +# Tasarruf sezonu parametreleri için runtime config +try: + from .. import config_runtime as cfg_v # type: ignore +except Exception: + cfg_v = None # type: ignore + + +# --------------------------------------------------------------------- +# Veri yapısı +# --------------------------------------------------------------------- +@dataclass +class SeasonInfo: + """SeasonController tarafından sağlanan özet bilgi.""" + date: date + + sunrise: Optional[time] + sunset: Optional[time] + noon: Optional[time] + + is_holiday: bool + holiday_label: str + + season: str # "İlkbahar", "Yaz", "Sonbahar", "Kış", ... + season_start: str # "YYYY-MM-DD" + season_end: str # "YYYY-MM-DD" + season_day: int # sezonun toplam gün sayısı + season_passed: int # sezon içinde geçen gün + season_remaining: int # sezon sonuna kalan gün + + # Tasarruf sezonu (saving) bilgileri + is_season: bool = False # BUGÜN tasarruf sezonu içinde miyiz? + saving_start: Optional[date] = None # tasarruf dönemi başlangıç tarihi + saving_stop: Optional[date] = None # tasarruf dönemi bitiş tarihi + + +# --------------------------------------------------------------------- +# Yardımcı fonksiyonlar +# --------------------------------------------------------------------- +def _parse_time_str(s: Optional[str]) -> Optional[time]: + if not s: + return None + for fmt in ("%H:%M:%S", "%H:%M"): + try: + return datetime.strptime(s, fmt).time() + except Exception: + continue + return None + + +def _parse_date_any(v: Any) -> Optional[date]: + """ + v: datetime / date / str / None → date veya None. + """ + if v is None: + return None + if isinstance(v, date) and not isinstance(v, datetime): + return v + if isinstance(v, datetime): + return v.date() + if isinstance(v, str): + s = v.strip() + # "2025-09-23T22:42:51.508546+03:00" => "2025-09-23" + if "T" in s: + s = s.split("T", 1)[0] + # ISO parse dene + try: + return datetime.fromisoformat(s).date() + except Exception: + pass + # Son çare saf "YYYY-MM-DD" + try: + return datetime.strptime(s, "%Y-%m-%d").date() + except Exception: + return None + return None + + +def _as_int(x: Any, default: int = 0) -> int: + try: + return int(x) + except Exception: + return default + + +def _get_saving_param_for_season(season_name: str) -> Optional[Any]: + """ + Sezona göre ilgili tasarruf parametresini getirir. + + - İlkbahar → cfg_v.SEASON_SPRING_SAVE_DAYS + - Sonbahar → cfg_v.SEASON_AUTUMN_SAVE_DAYS + + Yoksa None döner. + """ + if cfg_v is None: + return None + + if season_name == "İlkbahar": + return getattr(cfg_v, "SEASON_SPRING_SAVE_DAYS", None) + if season_name == "Sonbahar": + return getattr(cfg_v, "SEASON_AUTUMN_SAVE_DAYS", None) + return None + +def tarih_gun_islemi(tarih_str, gun_sayisi): + try: + tarih = datetime.strptime(tarih_str, "%Y-%m-%d") + yeni_tarih = tarih + timedelta(days=gun_sayisi) + return yeni_tarih.strftime("%Y-%m-%d") + except ValueError: + return "Hata: Tarih formatı yanlış olmalı! Örnek: '2025-06-30'" + + +def _compute_saving_window( + season_name: str, + season_start: Optional[date], + season_end: Optional[date], + today: date, +) -> Tuple[bool, Optional[date], Optional[date]]: + """ + Tasarruf sezonu penceresini hesaplar. + + Buradaki tanım: + - SEASON_SPRING_SAVE_DAYS -> Nisan ayı için gün aralığı (nisan) + - SEASON_AUTUMN_SAVE_DAYS -> Eylül ayı için gün aralığı (eylül) + + Örnek: + SEASON_SPRING_SAVE_DAYS = (5, 20) -> 5-20 Nisan arası tasarruf + SEASON_AUTUMN_SAVE_DAYS = (15, 30) -> 15-30 Eylül arası tasarruf + + Mantık: + - Eğer bugün ay < 6 (Haziran'dan önce) ise -> Nisan aralığını kullan + - Eğer bugün ay >= 6 ise -> Eylül aralığını kullan + + Dönüş: + (is_season, saving_start, saving_stop) + """ + if season_start is None or season_end is None or cfg_v is None: + return False, None, None + + # Bugünün tarihi (sadece date, saat yok) + today = date.today() + year = today.year + + # Config'den değerleri güvenli şekilde al (varsayılan değerle) + SPRING_SAVE_DAYS = getattr(cfg_v, "SEASON_SPRING_SAVE_DAYS", 20) # örnek: 20 + AUTUMN_SAVE_DAYS = getattr(cfg_v, "SEASON_AUTUMN_SAVE_DAYS", 13) # örnek: 13 + + # İlkbahar mı yoksa Sonbahar mı dönemi aktif? + if today.month <= 5: # Ocak-Mayıs arası → ilkbahar dönemi aktif + # İlkbaharın son gününden geriye doğru + saving_stop = date(year, 5, 31) # 31 Mayıs (dahil) + saving_start = saving_stop - timedelta(days=SPRING_SAVE_DAYS - 1) # dahil olduğu için -1 + + else: # Haziran-Aralık arası → sonbahar dönemi aktif + # Sonbaharın ilk gününden ileri doğru + saving_start = date(year, 9, 1) # 1 Eylül (dahil) + saving_stop = saving_start + timedelta(days=AUTUMN_SAVE_DAYS - 1) # dahil olduğu için -1 + + # Sezon içinde miyiz? + is_season = saving_start <= today <= saving_stop + return is_season, saving_start, saving_stop + + +# --------------------------------------------------------------------- +# Ana controller +# --------------------------------------------------------------------- +class SeasonController: + """ + Tek sorumluluğu: o anki (veya verilen tarihteki) güneş / tatil / + mevsim + tasarruf sezonu bilgisini üst katmanlara taşımak. + """ + + def __init__(self, info: SeasonInfo) -> None: + self.info = info + + # --------------------------------------------------------- + # Factory metodları + # --------------------------------------------------------- + @classmethod + def from_now(cls) -> "SeasonController": + """ + Sistem saatine göre SeasonController üretir. + GEO_* bilgilerini config_statics'ten alır. + """ + now = datetime.now() + return cls(cls._build_info(now)) + + @classmethod + def from_datetime(cls, dt: datetime) -> "SeasonController": + return cls(cls._build_info(dt)) + + # --------------------------------------------------------- + # İç mantık + # --------------------------------------------------------- + @classmethod + def _build_info(cls, now: datetime) -> SeasonInfo: + """ + SunHolidayInfo varsa onu kullanır; yoksa basit bir fallback + sezon + tasarruf sezonu bilgisi üretir. + """ + geo_city = getattr(cfg_s, "GEO_CITY", "Ankara") + geo_country = getattr(cfg_s, "GEO_COUNTRY", "Turkey") + geo_tz = getattr(cfg_s, "GEO_TZ", "Europe/Istanbul") + geo_lat = float(getattr(cfg_s, "GEO_LAT", 39.92077)) + geo_lon = float(getattr(cfg_s, "GEO_LON", 32.85411)) + + today = now.date() + + # ------------------------------ + # 1) SunHolidayInfo kullanmayı dene + # ------------------------------ + if SunHolidayInfo is not None: + try: + tracker = SunHolidayInfo( + current_datetime=now, + city_name=geo_city, + country=geo_country, + timezone=geo_tz, + latitude=geo_lat, + longitude=geo_lon, + ) + data = tracker.get_info() + + sunrise_t = _parse_time_str(data.get("sunrise")) + sunset_t = _parse_time_str(data.get("sunset")) + noon_t = _parse_time_str(data.get("noon")) + + season_name = data.get("season") or "Bilinmiyor" + ss_raw = data.get("season_start") or "" + se_raw = data.get("season_end") or "" + + ds = _parse_date_any(ss_raw) + de = _parse_date_any(se_raw) + + if ds is None: + ds = today + if de is None or de < ds: + de = ds + + total_days = _as_int(data.get("season_day"), 0) + passed_days = _as_int(data.get("season_passed"), 0) + remaining_days = _as_int(data.get("season_remaining"), 0) + + # Eğer harici toplam/remaining güvenilmezse basit hesap + if total_days <= 0 or remaining_days < 0: + total_days = (de - ds).days + 1 + passed_days = (today - ds).days + remaining_days = (de - today).days + + is_season, saving_start, saving_stop = _compute_saving_window( + season_name, ds, de, today + ) + + return SeasonInfo( + date = today, + sunrise = sunrise_t, + sunset = sunset_t, + noon = noon_t, + is_holiday = bool(data.get("is_holiday", False)), + holiday_label = str(data.get("holiday_label", "")), + season = season_name, + season_start = ds.isoformat(), + season_end = de.isoformat(), + season_day = total_days, + season_passed = passed_days, + season_remaining = remaining_days, + is_season = is_season, + saving_start = saving_start, + saving_stop = saving_stop, + ) + except Exception as e: + print(f"⚠️ SunHolidayInfo hata, fallback kullanılacak: {e}") + + # ------------------------------ + # 2) Fallback: sadece mevsim tahmini + saving + # ------------------------------ + month = now.month + day = now.day + + if (month == 12 and day >= 1) or (1 <= month <= 3 and (month < 3 or day <= 20)): + season_label = "Kış" + ss = f"{now.year}-12-01" + se = f"{now.year+1}-03-20" if month == 12 else f"{now.year}-03-20" + elif 3 <= month <= 6 and not (month == 6 and day > 20): + season_label = "İlkbahar" + ss = f"{now.year}-03-21" + se = f"{now.year}-06-20" + elif 6 <= month <= 9 and not (month == 9 and day > 22): + season_label = "Yaz" + ss = f"{now.year}-06-21" + se = f"{now.year}-09-22" + else: + season_label = "Sonbahar" + ss = f"{now.year}-09-23" + se = f"{now.year}-11-30" + + try: + ds = datetime.fromisoformat(ss).date() + de = datetime.fromisoformat(se).date() + total_days = (de - ds).days + 1 + passed_days = (today - ds).days + remaining_days = (de - today).days + except Exception: + ds = today + de = today + total_days = passed_days = remaining_days = 0 + + is_season, saving_start, saving_stop = _compute_saving_window( + season_label, ds, de, today + ) + + return SeasonInfo( + date = today, + sunrise = None, + sunset = None, + noon = None, + is_holiday = False, + holiday_label = "", + season = season_label, + season_start = ds.isoformat(), + season_end = de.isoformat(), + season_day = total_days, + season_passed = passed_days, + season_remaining = remaining_days, + is_season = is_season, + saving_start = saving_start, + saving_stop = saving_stop, + ) + + # --------------------------------------------------------- + # Legacy / debug uyumlu satırlar + # --------------------------------------------------------- + def to_syslog_lines(self) -> list[str]: + """ + legacy_syslog.emit_header_season veya debug için özet satırlar üretir. + Örnek: + "season : İlkbahar 2025-03-21 - 2025-06-20 day:92 passed:10 remaining:82" + "saving : True 2025-03-21 - 2025-04-10" + """ + i = self.info + lines: list[str] = [] + + lines.append( + f"season : {i.season} {i.season_start} - {i.season_end} " + f"day:{i.season_day} passed:{i.season_passed} remaining:{i.season_remaining}" + ) + + if i.saving_start and i.saving_stop: + lines.append( + f"saving : {i.is_season} {i.saving_start.isoformat()} - {i.saving_stop.isoformat()}" + ) + else: + lines.append("saving : False - -") + + return lines diff --git a/ebuild/core/sunholiday.py b/ebuild/core/sunholiday.py new file mode 100644 index 0000000..37c1220 --- /dev/null +++ b/ebuild/core/sunholiday.py @@ -0,0 +1,191 @@ +# -*- coding: utf-8 -*- +from __future__ import annotations + +__title__ = "sunholiday" +__author__ = 'Mehmet Karatay & "Saraswati" (ChatGPT)' +__purpose__ = "Astral ve eholidays_tr kullanarak güneş / tatil / mevsim bilgisi üretir" +__version__ = "0.1.1" +__date__ = "2025-11-22" + +""" +ebuild/core/sunholiday.py + +Revision : 2025-11-22 +Authors : Mehmet Karatay & "Saraswati" (ChatGPT) + +Amaç +----- +Eski sistemde kullandığın SunHolidayInfo sınıfını, ebuild projesi +için bağımsız bir modüle taşıdık. Bu modül: + + - Astral ile gün doğumu / gün batımı / öğlen + - eholidays_tr ile resmi tatil (yoksa graceful fallback) + - Basit mevsim hesabı + +bilgilerini üretir ve SeasonController tarafından kullanılır. + +Dış arayüz: + class SunHolidayInfo: + - get_info() -> dict + - print_info() + - to_json() +""" + +import json +from datetime import datetime +from enum import Enum + +from astral import LocationInfo +from astral.sun import sun +import pytz + +# eholidays_tr yoksa sistem göçmesin → dummy tracker +try: + from eholidays_tr import HolidayTracker # type: ignore +except Exception: + class HolidayTracker: # type: ignore + def __init__(self, year: int) -> None: + self.year = year + def is_holiday(self, dt) -> None: + return None + + +class Season(Enum): + KIS = "Kış" + ILKBAHAR = "İlkbahar" + YAZ = "Yaz" + SONBAHAR = "Sonbahar" + + +class SunHolidayInfo: + """ + Astral + eholidays_tr kombinasyonu ile gün / tatil / mevsim bilgisi üretir. + """ + + def __init__( + self, + current_datetime=None, + city_name: str = "Ankara", + country: str = "Turkey", + timezone: str = "Europe/Istanbul", + latitude: float = 39.92077, + longitude: float = 32.85411, + ) -> None: + self.city = city_name + self.country = country + self.timezone_str = timezone + self.timezone = pytz.timezone(timezone) + self.lat = latitude + self.lon = longitude + + if current_datetime is None: + self.current_datetime = datetime.now(self.timezone) + else: + # Eğer naive datetime geldiyse timezone ekleyelim + if current_datetime.tzinfo is None: + self.current_datetime = self.timezone.localize(current_datetime) + else: + self.current_datetime = current_datetime.astimezone(self.timezone) + + self.location = LocationInfo( + self.city, self.country, self.timezone_str, self.lat, self.lon + ) + self.work_date = self.current_datetime + + # --------------------------------------------------------- + # Yardımcılar + # --------------------------------------------------------- + def return_init(self) -> str: + return ( + f"SunHolidayInfo: {self.country} {self.city} " + f"{self.timezone_str} {self.lat} - {self.lon}" + ) + + def get_season(self, date_obj: datetime): + """ + Astronomik mevsim aralıklarıyla kaba mevsim ve gün bilgisi üretir. + """ + ranges = { + Season.KIS: [ + (date_obj.replace(month=1, day=1), date_obj.replace(month=3, day=20)), + (date_obj.replace(month=12, day=21), date_obj.replace(month=12, day=31)), + ], + Season.ILKBAHAR: [ + (date_obj.replace(month=3, day=21), date_obj.replace(month=6, day=20)) + ], + Season.YAZ: [ + (date_obj.replace(month=6, day=21), date_obj.replace(month=9, day=22)) + ], + Season.SONBAHAR: [ + (date_obj.replace(month=9, day=23), date_obj.replace(month=12, day=20)) + ], + } + + for season, periods in ranges.items(): + for start, end in periods: + if start <= date_obj <= end: + total_days = (end - start).days + 1 + passed_days = (date_obj - start).days + remaining_days = (end - date_obj).days + return { + "season": season.value, + "season_start": start.isoformat(), + "season_end": end.isoformat(), + "season_day": total_days, + "season_passed": passed_days, + "season_remaining": remaining_days, + } + + return None + + # --------------------------------------------------------- + # Ana API + # --------------------------------------------------------- + def get_info(self) -> dict: + """ + Astral + HolidayTracker + mevsim hesabını bir dict olarak döner. + """ + sun_data = sun( + self.location.observer, + date=self.work_date, + tzinfo=self.timezone, + ) + + tracker = HolidayTracker(self.work_date.year) + holiday_label = tracker.is_holiday(self.work_date) + + season_info = self.get_season(self.work_date) + + return { + "date": self.work_date.isoformat(), + "sunrise": sun_data["sunrise"].strftime("%H:%M:%S"), + "sunset": sun_data["sunset"].strftime("%H:%M:%S"), + "noon": sun_data["noon"].strftime("%H:%M:%S"), + "is_holiday": bool(holiday_label), + "holiday_label": holiday_label if holiday_label else "Yok", + "season": season_info["season"] if season_info else None, + "season_start": season_info["season_start"] if season_info else None, + "season_end": season_info["season_end"] if season_info else None, + "season_day": season_info["season_day"] if season_info else None, + "season_passed": season_info["season_passed"] if season_info else None, + "season_remaining": season_info["season_remaining"] if season_info else None, + } + + def print_info(self) -> None: + info = self.get_info() + print(f"Güneş bilgileri - {self.location.name}, {info['date']}") + print(f"Doğuş: {info['sunrise']}") + print(f"Batış: {info['sunset']}") + print(f"Öğlen: {info['noon']}") + print(f"Tatil mi?: {'Evet' if info['is_holiday'] else 'Hayır'}") + if info["is_holiday"]: + print(f"Etiket: {info['holiday_label']}") + + def to_json(self) -> str: + return json.dumps(self.get_info(), ensure_ascii=False, indent=2) + + +if __name__ == "__main__": + s = SunHolidayInfo() + print(s.return_init()) + print(s.to_json()) diff --git a/ebuild/core/systems/__init__.py b/ebuild/core/systems/__init__.py new file mode 100644 index 0000000..40a96af --- /dev/null +++ b/ebuild/core/systems/__init__.py @@ -0,0 +1 @@ +# -*- coding: utf-8 -*- diff --git a/ebuild/core/systems/__pycache__/__init__.cpython-39.pyc b/ebuild/core/systems/__pycache__/__init__.cpython-39.pyc new file mode 100644 index 0000000..9ba1b78 Binary files /dev/null and b/ebuild/core/systems/__pycache__/__init__.cpython-39.pyc differ diff --git a/ebuild/core/systems/__pycache__/burner.cpython-39.pyc b/ebuild/core/systems/__pycache__/burner.cpython-39.pyc new file mode 100644 index 0000000..d67ee29 Binary files /dev/null and b/ebuild/core/systems/__pycache__/burner.cpython-39.pyc differ diff --git a/ebuild/core/systems/burner.py b/ebuild/core/systems/burner.py new file mode 100644 index 0000000..bee797d --- /dev/null +++ b/ebuild/core/systems/burner.py @@ -0,0 +1,679 @@ +# -*- coding: utf-8 -*- +from __future__ import annotations + +__title__ = "burner" +__author__ = 'Mehmet Karatay & "Saraswati" (ChatGPT)' +__purpose__ = "Bina ve/veya dış ısıya göre brülör ve sirkülasyon kontrol çekirdeği" +__version__ = "0.4.3" +__date__ = "2025-11-22" + +""" +ebuild/core/systems/burner.py + +Revision : 2025-11-22 +Authors : Mehmet Karatay & "Saraswati" (ChatGPT) + +Amaç +----- +- BUILD_BURNER moduna göre (F/B) brülör ve sirkülasyon pompalarını yönetmek +- Bina ortalaması (B mod) veya dış ısı (F mod) üzerinden ısıtma isteği üretmek +- used_out_heat mantığı ile dış ısıya hafta sonu / konfor offset uygulamak + +Bağımlılıklar +-------------- +- building.Building +- environment.BuildingEnvironment +- season.SeasonController +- io.relay_driver.RelayDriver +- io.dbtext.DBText +- io.legacy_syslog (syslog/console çıktıları için) +- config_statics (cfg_s) +- config_runtime (cfg_v) + +Notlar +------ +- Brülör, igniter ve pompalar relay_driver içinde isimlendirilmiş kanallarla + temsil edilir. +- Bu dosya, eski sistemle uyum için mümkün olduğunca log formatını korumaya + çalışır. +""" + +import datetime +import time as _time +from dataclasses import dataclass +from typing import Optional, Dict, Any, List, Tuple + +from ..building import Building +from ..season import SeasonController +from ..environment import BuildingEnvironment +from ...io.relay_driver import RelayDriver +from ...io.dbtext import DBText +from ...io import legacy_syslog as lsys +from ... import config_statics as cfg_s +from ... import config_runtime as cfg_v + + +# ------------------------------------------------------------- +# Yardımcı: DS18B20 okuma (hat sensörleri için) +# ------------------------------------------------------------- + + +@dataclass +class BurnerState: + burner_on: bool + pumps_on: Tuple[str, ...] + fire_setpoint_c: float + last_change_ts: datetime.datetime + reason: str + last_building_avg: Optional[float] + last_outside_c: Optional[float] + last_used_out_c: Optional[float] + last_mode: str + + +# ----------------------------- Isı eğrisi -------------------- + +# Dış ısı → kazan çıkış setpoint haritası +# Örnek bir eğri; config_runtime ile override edilebilir. +BURNER_FIRE_SETPOINT_MAP: Dict[float, Dict[str, float]] = getattr( + cfg_v, + "BURNER_FIRE_SETPOINT_MAP", + { + -10.0: {"fire": 50.0}, + -5.0: {"fire": 48.0}, + 0.0: {"fire": 46.0}, + 5.0: {"fire": 44.0}, + 10.0: {"fire": 40.0}, + 15.0: {"fire": 35.0}, + 20.0: {"fire": 30.0}, + 25.0: {"fire": 26.0}, + }, +) + + +class BurnerConfig: + """ + Brülör çalışma parametreleri (runtime config'ten override edilebilir). + """ + + min_run_sec: int = 60 # brülör en az bu kadar saniye çalışsın + min_stop_sec: int = 60 # brülör en az bu kadar saniye duruşta kalsın + hysteresis_c: float = 0.5 # bina ortalaması için histerezis + + +# --------------------------------------------------------- +# Yardımcı fonksiyon: bina istatistikleri +# --------------------------------------------------------- + + +def _safe_float(value: Any, default: Optional[float] = None) -> Optional[float]: + try: + if value is None: + return default + return float(value) + except Exception: + return default + + +def _merge_stats(old: Optional[Dict[str, Any]], new: Dict[str, Any]) -> Dict[str, Any]: + """ + Bina istatistiği için min/avg/max birleştirme. + """ + if old is None: + return dict(new) + + def _pick(key: str, func): + a = old.get(key) + b = new.get(key) + if a is None: + return b + if b is None: + return a + return func(a, b) + + return { + "min": _pick("min", min), + "avg": new.get("avg"), + "max": _pick("max", max), + } + + +# --------------------------------------------------------- +# used_out_heat hesabı +# --------------------------------------------------------- + + +def _apply_weekend_and_comfort( + used_out: Optional[float], + now: datetime.datetime, + weekend_boost_c: float, + comfort_offset_c: float, +) -> Optional[float]: + """ + Haftasonu ve konfor offset'ini used_out üzerine uygular. + """ + if used_out is None: + return None + + result = float(used_out) + + # Haftasonu boost: Cumartesi / Pazar + if now.weekday() >= 5 and weekend_boost_c != 0.0: + result -= weekend_boost_c + + # Konfor offset'i + if comfort_offset_c != 0.0: + result -= comfort_offset_c + + return result + + +def pick_fire_setpoint(outside_c: Optional[float]) -> float: + """ + Dış ısı (used_out_heat) için en yakın fire setpoint'i döndürür. + + Eğer outside_c None ise, MAX_OUTLET_C kullanılır. + """ + if outside_c is None: + return float(getattr(cfg_v, "MAX_OUTLET_C", 45.0)) + + keys = sorted(BURNER_FIRE_SETPOINT_MAP.keys()) + nearest_key = min(keys, key=lambda k: abs(k - outside_c)) + mapping = BURNER_FIRE_SETPOINT_MAP.get(nearest_key, {}) + return float(mapping.get("fire", getattr(cfg_v, "MAX_OUTLET_C", 45.0))) + + +# --------------------------------------------------------- +# Ana sınıf: BurnerController +# --------------------------------------------------------- + + +class BurnerController: + """ + F/B moduna göre brülör kontrolü yapan sınıf. + + BUILD_BURNER = "B" + → bina ortalama sıcaklığına göre kontrol + + BUILD_BURNER = "F" + → dış ısıya göre (OUTSIDE_LIMIT_HEAT_C) karar veren mod + (burada dış ısı olarak *used_out_heat* kullanılır). + """ + + def __init__( + self, + building: Building, + relay_driver: RelayDriver, + logger: Optional[DBText] = None, + config: Optional[BurnerConfig] = None, + burner_id: Optional[int] = None, + environment: Optional[BuildingEnvironment] = None, + ) -> None: + self.building = building + self.relays = relay_driver + # Runtime konfig: varsayılan BurnerConfig + config_runtime override + self.cfg = config or BurnerConfig() + try: + self.cfg.min_run_sec = int(getattr(cfg_v, "BURNER_MIN_RUN_SEC", self.cfg.min_run_sec)) + self.cfg.min_stop_sec = int(getattr(cfg_v, "BURNER_MIN_STOP_SEC", self.cfg.min_stop_sec)) + self.cfg.hysteresis_c = float(getattr(cfg_v, "BURNER_HYSTERESIS_C", self.cfg.hysteresis_c)) + except Exception as e: + print("BurnerConfig override error:", e) + + # Hangi brülör? → config_statics.BURNER_DEFAULT_ID veya parametre + default_id = int(getattr(cfg_s, "BURNER_DEFAULT_ID", 0)) + self.burner_id = int(burner_id) if burner_id is not None else default_id + + # DBText logger + log_file = getattr(cfg_s, "BURNER_LOG_FILE", "ebuild_burner_log.sql") + log_table = getattr(cfg_s, "BURNER_LOG_TABLE", "eburner_log") + self.logger = logger or DBText( + filename=log_file, + table=log_table, + app="EBURNER", + ) + + max_out = float(getattr(cfg_v, "MAX_OUTLET_C", 45.0)) + + # Röle kanal isimleri (eski yapı ile uyum için fallback) + self.igniter_ch: str = getattr(cfg_s, "BURNER_IGNITER_CH", "igniter") + self.pump_channels: List[str] = list( + getattr(cfg_s, "BURNER_PUMPS", ["circulation_a", "circulation_b"]) + ) + self.default_pumps: List[str] = list( + getattr(cfg_s, "BURNER_DEFAULT_PUMPS", ["circulation_a"]) + ) + + # Bina okuma periyodu (BUILDING_READ_PERIOD_S) + self._building_last_read_ts: Optional[datetime.datetime] = None + self._building_read_period: float = float( + getattr(cfg_v, "BUILDING_READ_PERIOD_S", 60.0) + ) + self._building_last_stats: Optional[Dict[str, Any]] = None + + # used_out_heat için parametreler + self.used_out_c: Optional[float] = None + self._last_used_update_ts: Optional[datetime.datetime] = None + self.outside_smooth_sec: float = float( + getattr(cfg_v, "OUTSIDE_SMOOTH_SECONDS", 900.0) + ) + self.weekend_boost_c: float = float( + getattr(cfg_v, "WEEKEND_HEAT_BOOST_C", 0.0) + ) + self.comfort_offset_c: float = float( + getattr(cfg_v, "BURNER_COMFORT_OFFSET_C", 0.0) + ) + + # Ortam nesnesi (opsiyonel) + self.environment = environment + + # Ortamdan başlangıç dış ısı alınabiliyorsa used_out'u hemen doldur + if self.environment is not None: + try: + first_out = self.environment.get_outside_temp_cached() + except Exception: + first_out = None + if first_out is not None: + self.used_out_c = first_out + self._last_used_update_ts = datetime.datetime.now() + + # Çalışma modu + cfg_mode = str(getattr(cfg_s, "BUILD_BURNER", "F")).upper() + initial_mode = cfg_mode if cfg_mode in ("F", "B") else "F" + + # Başlangıç state + self.state = BurnerState( + burner_on=False, + pumps_on=tuple(), + fire_setpoint_c=max_out, + last_change_ts=datetime.datetime.now(), + reason="init", + last_building_avg=None, + last_outside_c=None, + last_used_out_c=None, + last_mode=initial_mode, + ) + + # Mevsim / güneş bilgisi (syslog üst block için) + try: + self.season = SeasonController.from_now() + except Exception as e: + print("SeasonController.from_now() hata:", e) + self.season = None + + # --------------------------------------------------------- + # Bina istatistikleri + # --------------------------------------------------------- + + def _get_building_stats(self, now: datetime.datetime) -> Optional[Dict[str, Any]]: + """ + Bina ortalaması / min / max gibi istatistikleri periyodik olarak okur. + BUILDING_READ_PERIOD_S içinde cache kullanır. + """ + if self._building_last_read_ts is None: + need_read = True + else: + delta = (now - self._building_last_read_ts).total_seconds() + need_read = delta >= self._building_read_period + + if not need_read: + return self._building_last_stats + + self._building_last_read_ts = now + return None + try: + stats = self.building.get_stats() + except Exception as e: + print("Building.get_stats() hata:", e) + return self._building_last_stats + + self._building_last_stats = stats + return stats + + # --------------------------------------------------------- + # used_out_heat güncelleme + # --------------------------------------------------------- + + def _update_used_out(self, now: datetime.datetime, outside_c: Optional[float]) -> Optional[float]: + """ + Dış ısı okumasına göre used_out_heat günceller. + - OUTSIDE_SMOOTH_SECONDS süresince eksponansiyel smoothing + - Haftasonu ve konfor offset'i eklenir. + """ + raw = outside_c + + if raw is None: + return self.used_out_c + + # Smooth + if self.used_out_c is None or self._last_used_update_ts is None: + smoothed = raw + else: + dt = (now - self._last_used_update_ts).total_seconds() + if dt <= 0: + smoothed = self.used_out_c + else: + tau = max(1.0, self.outside_smooth_sec) + alpha = min(1.0, dt / tau) + smoothed = (1.0 - alpha) * self.used_out_c + alpha * raw + + self.used_out_c = smoothed + self._last_used_update_ts = now + + # Haftasonu / konfor offset'i uygula + final_used = _apply_weekend_and_comfort( + smoothed, + now, + self.weekend_boost_c, + self.comfort_offset_c, + ) + return final_used + + # --------------------------------------------------------- + # Isı ihtiyacı kararları + # --------------------------------------------------------- + + def _should_heat_by_outside(self, used_out: Optional[float]) -> bool: + """ + F modunda (dış ısıya göre) ısıtma isteği. + """ + limit = float(getattr(cfg_v, "OUTSIDE_HEAT_LIMIT_C", 17.0)) + if used_out is None: + return False + + want = used_out < limit + print(f"should_heat_by_outside: used={used_out:.3f}C limit={limit:.1f}C") + return want + + def _should_heat_by_building(self, building_avg: Optional[float], now: datetime.datetime) -> bool: + """ + B modunda bina ortalaması + konfor setpoint'e göre ısıtma isteği. + """ + comfort = float(getattr(cfg_v, "COMFORT_SETPOINT_C", 23.0)) + h = self.cfg.hysteresis_c + + if building_avg is None: + return False + + if building_avg < (comfort - h): + return True + if building_avg > (comfort + h): + return False + + # Histerezis bandında önceki state'i koru + return self.state.burner_on + + # --------------------------------------------------------- + # Min çalışma / durma süreleri + # --------------------------------------------------------- + + def _respect_min_times(self, now: datetime.datetime, want_on: bool) -> bool: + """ + min_run_sec / min_stop_sec kurallarını uygular. + - İlk açılışta (state.reason == 'init') kısıtlama uygulanmaz. + """ + # İlk tick: min_run/min_stop uygulama + try: + if getattr(self.state, "reason", "") == "init": + return want_on + except Exception: + pass + + elapsed = (now - self.state.last_change_ts).total_seconds() + + if self.state.burner_on: + # Çalışırken min_run dolmadan kapatma + if not want_on and elapsed < self.cfg.min_run_sec: + return True + else: + # Kapalıyken min_stop dolmadan açma + if want_on and elapsed < self.cfg.min_stop_sec: + return False + + return want_on + + # --------------------------------------------------------- + # Çıkışları rölelere uygulama + # --------------------------------------------------------- + + def _apply_outputs( + self, + now: datetime.datetime, + mode: str, + burner_on: bool, + pumps_on: Tuple[str, ...], + fire_setpoint_c: float, + reason: str, + ) -> None: + """ + Röleleri sürer, state'i günceller, log ve syslog üretir. + """ + # 1) Röle sürücüsü (igniter + pompalar) + try: + # Yeni API: RelayDriver brülör-aware ise + if hasattr(self.relays, "set_igniter"): + # Brülör ateşleme + self.relays.set_igniter(self.burner_id, burner_on) + + # Pompalar: her zaman kanal isimleri üzerinden sür + if hasattr(self.relays, "all_pumps"): + all_pumps = list(self.relays.all_pumps(self.burner_id)) # ['circulation_a', ...] + for ch in all_pumps: + self.relays.set_channel(ch, (ch in pumps_on)) + else: + # all_pumps yoksa, config_statics'ten gelen pump_channels ile sür + for ch in self.pump_channels: + self.relays.set_channel(ch, (ch in pumps_on)) + else: + # Eski/çok basit API: doğrudan kanal adları + self.relays.set_channel(self.igniter_ch, burner_on) + for ch in self.pump_channels: + self.relays.set_channel(ch, (ch in pumps_on)) + except Exception as exc: + # legacy_syslog.log_error YOK, bu yüzden ya loga yaz ya da print et + try: + msg = f"[relay_error] igniter_ch={self.igniter_ch} burner_on={burner_on} pumps_on={pumps_on} exc={exc}" + lsys.send_legacy_syslog(lsys.format_line(98, msg)) + except Exception: + print("Relay error in _apply_outputs:", exc) + + # 2) State güncelle + if burner_on != self.state.burner_on or tuple(pumps_on) != self.state.pumps_on: + self.state.last_change_ts = now + + self.state.burner_on = burner_on + self.state.pumps_on = tuple(pumps_on) + self.state.fire_setpoint_c = fire_setpoint_c + self.state.reason = reason + self.state.last_mode = mode + + # 3) DBText logger'a yaz + try: + self.logger.insert( + { + "ts": now, + "mode": mode, + "burner_on": int(burner_on), + "pumps": ",".join(pumps_on), + "fire_sp": fire_setpoint_c, + "reason": reason, + "bavg": _safe_float(self.state.last_building_avg), + "out": _safe_float(self.state.last_outside_c), + "used": _safe_float(self.state.last_used_out_c), + } + ) + except Exception: + pass + + # 4) Syslog / console üst blok + try: + lsys.log_burner_header( + now=now, + mode=mode, + season=self.season, + building_avg=self.state.last_building_avg, + outside_c=self.state.last_outside_c, + used_out_c=self.state.last_used_out_c, + fire_sp=fire_setpoint_c, + burner_on=burner_on, + pumps_on=pumps_on, + ) + except Exception as exc: + # Burayı tamamen sessize almayalım, hatayı konsola basalım + print("BRULOR lsys.log_burner_header error:", exc, "burner.py _apply_outputs()") + + # --------------------------------------------------------- + # Ana tick fonksiyonu + # --------------------------------------------------------- + + def tick(self, outside_c: Optional[float] = None) -> BurnerState: + """ + Tek bir kontrol adımı. + + - Bina istatistiği BUILDING_READ_PERIOD_S periyodunda bir kez okunur, + aradaki tick'lerde cache kullanılır. + - F modunda kararlar *used_out_heat* üzerinden verilir. + """ + now = datetime.datetime.now() + cfg_mode = str(getattr(cfg_s, "BUILD_BURNER", "F")).upper() + mode = cfg_mode + + print("tick outside_c:", outside_c) + # 0) dış ısı: parametre yoksa ortamdan al + if outside_c is None and getattr(self, "environment", None) is not None: + try: + outside_c = self.environment.get_outside_temp_cached() + except Exception: + outside_c = None + print("env:", getattr(self, "environment", None)) + print("tick outside_c 2:", outside_c) + + # 1) bina istatistiği (periyodik) + stats = self._get_building_stats(now) + building_avg = stats.get("avg") if stats else None + + # 2) used_out_heat güncelle + used_out = self._update_used_out(now, outside_c) + + self.state.last_building_avg = building_avg + self.state.last_outside_c = outside_c + self.state.last_used_out_c = used_out + + # 3) ısıtma ihtiyacı + if mode == "F": + want_heat = self._should_heat_by_outside(used_out) + else: + mode = "B" # saçma değer gelirse B moduna zorla + want_heat = self._should_heat_by_building(building_avg, now) + + want_heat = self._respect_min_times(now, want_heat) + + # 4) fire setpoint – F modunda da used_out üzerinden okunur + fire_sp = pick_fire_setpoint(used_out) + max_out = float(getattr(cfg_v, "MAX_OUTLET_C", 45.0)) + fire_sp = min(fire_sp, max_out) + + # 5) pompalar + if want_heat: + if hasattr(self.relays, "enabled_pumps"): + try: + pumps_list = list(self.relays.enabled_pumps(self.burner_id)) + pumps = tuple(pumps_list) + except Exception: + pumps = tuple(self.default_pumps) + else: + pumps = tuple(self.default_pumps) + else: + pumps = tuple() + + reason = ( + f"avg={building_avg}C " + f"outside_raw={outside_c}C " + f"used={used_out}C " + f"want_heat={want_heat}" + ) + print("tick reason", reason) + + # 7) Rölelere uygula + self._apply_outputs( + now=now, + mode=mode, + burner_on=bool(want_heat), + pumps_on=pumps, + fire_setpoint_c=fire_sp, + reason=reason, + ) + print("state", self.state) + return self.state + + +# ------------------------------------------------------------- +# CLI / demo +# ------------------------------------------------------------- + + +def _demo() -> None: + """ + Basit demo: Building + RelayDriver + BuildingEnvironment ile + BurnerController'ı ayağa kaldır, tick() döngüsü yap. + """ + # 1) Bina + try: + building = Building() + print("✅ Building: statics yüklendi\n") + print(building.pretty_summary()) + except Exception as e: + print("❌ Building oluşturulamadı:", e) + raise SystemExit(1) + + # 2) Ortam (dış ısı, ADC vs.) + try: + env = BuildingEnvironment() + except Exception as e: + print("⚠️ BuildingEnvironment oluşturulamadı:", e) + env = None + + # 3) Röle sürücüsü + rel = RelayDriver(onoff=False) + + # 4) Denetleyici + ctrl = BurnerController(building, rel, environment=env) + + print("🔥 BurnerController başlatıldı") + print(f" Burner ID : {ctrl.burner_id}") + print(f" Çalışma modu (BUILD_BURNER): {getattr(cfg_s, 'BUILD_BURNER', 'F')} (F=dış ısı, B=bina ort)") + print(f" Igniter kanalı : {ctrl.igniter_ch}") + print(f" Pompa kanalları : {ctrl.pump_channels}") + print(f" Varsayılan pompalar : {ctrl.default_pumps}") + print(f" Konfor setpoint (°C) : {getattr(cfg_v, 'COMFORT_SETPOINT_C', 23.0)}") + print(f" Histerezis (°C) : {ctrl.cfg.hysteresis_c}") + print(f" Dış ısı limiti (°C) : {getattr(cfg_v, 'OUTSIDE_HEAT_LIMIT_C', 17.0)}") + print(f" Max kazan çıkış (°C) : {getattr(cfg_v, 'MAX_OUTLET_C', 45.0)}") + print(f" Bina okuma periyodu (s) : {ctrl._building_read_period}") + print(f" OUTSIDE_SMOOTH_SECONDS : {ctrl.outside_smooth_sec}") + print(f" WEEKEND_HEAT_BOOST_C : {ctrl.weekend_boost_c}") + print(f" BURNER_COMFORT_OFFSET_C : {ctrl.comfort_offset_c}") + print("----------------------------------------------------") + print("BurnerController demo (Ctrl+C ile çık)…") + + try: + while True: + ctrl.tick() + _time.sleep(5) + except KeyboardInterrupt: + print("\nCtrl+C alındı, çıkış hazırlanıyor…") + finally: + try: + rel.all_off() + print("🔌 Tüm röleler kapatıldı.") + except Exception as e: + print(f"⚠️ Röleleri kapatırken hata: {e}") + finally: + try: + rel.cleanup() + except Exception: + pass + + +if __name__ == "__main__": + _demo() diff --git a/ebuild/core/systems/burner.py~ b/ebuild/core/systems/burner.py~ new file mode 100644 index 0000000..7c5f4ef --- /dev/null +++ b/ebuild/core/systems/burner.py~ @@ -0,0 +1,678 @@ +# -*- coding: utf-8 -*- +from __future__ import annotations + +__title__ = "burner" +__author__ = 'Mehmet Karatay & "Saraswati" (ChatGPT)' +__purpose__ = "Bina ve/veya dış ısıya göre brülör ve sirkülasyon kontrol çekirdeği" +__version__ = "0.4.3" +__date__ = "2025-11-22" + +""" +ebuild/core/systems/burner.py + +Revision : 2025-11-22 +Authors : Mehmet Karatay & "Saraswati" (ChatGPT) + +Amaç +----- +- BUILD_BURNER moduna göre (F/B) brülör ve sirkülasyon pompalarını yönetmek +- Bina ortalaması (B mod) veya dış ısı (F mod) üzerinden ısıtma isteği üretmek +- used_out_heat mantığı ile dış ısıya hafta sonu / konfor offset uygulamak + +Bağımlılıklar +-------------- +- building.Building +- environment.BuildingEnvironment +- season.SeasonController +- io.relay_driver.RelayDriver +- io.dbtext.DBText +- io.legacy_syslog (syslog/console çıktıları için) +- config_statics (cfg_s) +- config_runtime (cfg_v) + +Notlar +------ +- Brülör, igniter ve pompalar relay_driver içinde isimlendirilmiş kanallarla + temsil edilir. +- Bu dosya, eski sistemle uyum için mümkün olduğunca log formatını korumaya + çalışır. +""" + +import datetime +import time as _time +from dataclasses import dataclass +from typing import Optional, Dict, Any, List, Tuple + +from ..building import Building +from ..season import SeasonController +from ..environment import BuildingEnvironment +from ...io.relay_driver import RelayDriver +from ...io.dbtext import DBText +from ...io import legacy_syslog as lsys +from ... import config_statics as cfg_s +from ... import config_runtime as cfg_v + + +# ------------------------------------------------------------- +# Yardımcı: DS18B20 okuma (hat sensörleri için) +# ------------------------------------------------------------- + + +@dataclass +class BurnerState: + burner_on: bool + pumps_on: Tuple[str, ...] + fire_setpoint_c: float + last_change_ts: datetime.datetime + reason: str + last_building_avg: Optional[float] + last_outside_c: Optional[float] + last_used_out_c: Optional[float] + last_mode: str + + +# ----------------------------- Isı eğrisi -------------------- + +# Dış ısı → kazan çıkış setpoint haritası +# Örnek bir eğri; config_runtime ile override edilebilir. +BURNER_FIRE_SETPOINT_MAP: Dict[float, Dict[str, float]] = getattr( + cfg_v, + "BURNER_FIRE_SETPOINT_MAP", + { + -10.0: {"fire": 50.0}, + -5.0: {"fire": 48.0}, + 0.0: {"fire": 46.0}, + 5.0: {"fire": 44.0}, + 10.0: {"fire": 40.0}, + 15.0: {"fire": 35.0}, + 20.0: {"fire": 30.0}, + 25.0: {"fire": 26.0}, + }, +) + + +class BurnerConfig: + """ + Brülör çalışma parametreleri (runtime config'ten override edilebilir). + """ + + min_run_sec: int = 60 # brülör en az bu kadar saniye çalışsın + min_stop_sec: int = 60 # brülör en az bu kadar saniye duruşta kalsın + hysteresis_c: float = 0.5 # bina ortalaması için histerezis + + +# --------------------------------------------------------- +# Yardımcı fonksiyon: bina istatistikleri +# --------------------------------------------------------- + + +def _safe_float(value: Any, default: Optional[float] = None) -> Optional[float]: + try: + if value is None: + return default + return float(value) + except Exception: + return default + + +def _merge_stats(old: Optional[Dict[str, Any]], new: Dict[str, Any]) -> Dict[str, Any]: + """ + Bina istatistiği için min/avg/max birleştirme. + """ + if old is None: + return dict(new) + + def _pick(key: str, func): + a = old.get(key) + b = new.get(key) + if a is None: + return b + if b is None: + return a + return func(a, b) + + return { + "min": _pick("min", min), + "avg": new.get("avg"), + "max": _pick("max", max), + } + + +# --------------------------------------------------------- +# used_out_heat hesabı +# --------------------------------------------------------- + + +def _apply_weekend_and_comfort( + used_out: Optional[float], + now: datetime.datetime, + weekend_boost_c: float, + comfort_offset_c: float, +) -> Optional[float]: + """ + Haftasonu ve konfor offset'ini used_out üzerine uygular. + """ + if used_out is None: + return None + + result = float(used_out) + + # Haftasonu boost: Cumartesi / Pazar + if now.weekday() >= 5 and weekend_boost_c != 0.0: + result -= weekend_boost_c + + # Konfor offset'i + if comfort_offset_c != 0.0: + result -= comfort_offset_c + + return result + + +def pick_fire_setpoint(outside_c: Optional[float]) -> float: + """ + Dış ısı (used_out_heat) için en yakın fire setpoint'i döndürür. + + Eğer outside_c None ise, MAX_OUTLET_C kullanılır. + """ + if outside_c is None: + return float(getattr(cfg_v, "MAX_OUTLET_C", 45.0)) + + keys = sorted(BURNER_FIRE_SETPOINT_MAP.keys()) + nearest_key = min(keys, key=lambda k: abs(k - outside_c)) + mapping = BURNER_FIRE_SETPOINT_MAP.get(nearest_key, {}) + return float(mapping.get("fire", getattr(cfg_v, "MAX_OUTLET_C", 45.0))) + + +# --------------------------------------------------------- +# Ana sınıf: BurnerController +# --------------------------------------------------------- + + +class BurnerController: + """ + F/B moduna göre brülör kontrolü yapan sınıf. + + BUILD_BURNER = "B" + → bina ortalama sıcaklığına göre kontrol + + BUILD_BURNER = "F" + → dış ısıya göre (OUTSIDE_LIMIT_HEAT_C) karar veren mod + (burada dış ısı olarak *used_out_heat* kullanılır). + """ + + def __init__( + self, + building: Building, + relay_driver: RelayDriver, + logger: Optional[DBText] = None, + config: Optional[BurnerConfig] = None, + burner_id: Optional[int] = None, + environment: Optional[BuildingEnvironment] = None, + ) -> None: + self.building = building + self.relays = relay_driver + # Runtime konfig: varsayılan BurnerConfig + config_runtime override + self.cfg = config or BurnerConfig() + try: + self.cfg.min_run_sec = int(getattr(cfg_v, "BURNER_MIN_RUN_SEC", self.cfg.min_run_sec)) + self.cfg.min_stop_sec = int(getattr(cfg_v, "BURNER_MIN_STOP_SEC", self.cfg.min_stop_sec)) + self.cfg.hysteresis_c = float(getattr(cfg_v, "BURNER_HYSTERESIS_C", self.cfg.hysteresis_c)) + except Exception as e: + print("BurnerConfig override error:", e) + + # Hangi brülör? → config_statics.BURNER_DEFAULT_ID veya parametre + default_id = int(getattr(cfg_s, "BURNER_DEFAULT_ID", 0)) + self.burner_id = int(burner_id) if burner_id is not None else default_id + + # DBText logger + log_file = getattr(cfg_s, "BURNER_LOG_FILE", "ebuild_burner_log.sql") + log_table = getattr(cfg_s, "BURNER_LOG_TABLE", "eburner_log") + self.logger = logger or DBText( + filename=log_file, + table=log_table, + app="EBURNER", + ) + + max_out = float(getattr(cfg_v, "MAX_OUTLET_C", 45.0)) + + # Röle kanal isimleri (eski yapı ile uyum için fallback) + self.igniter_ch: str = getattr(cfg_s, "BURNER_IGNITER_CH", "igniter") + self.pump_channels: List[str] = list( + getattr(cfg_s, "BURNER_PUMPS", ["circulation_a", "circulation_b"]) + ) + self.default_pumps: List[str] = list( + getattr(cfg_s, "BURNER_DEFAULT_PUMPS", ["circulation_a"]) + ) + + # Bina okuma periyodu (BUILDING_READ_PERIOD_S) + self._building_last_read_ts: Optional[datetime.datetime] = None + self._building_read_period: float = float( + getattr(cfg_v, "BUILDING_READ_PERIOD_S", 60.0) + ) + self._building_last_stats: Optional[Dict[str, Any]] = None + + # used_out_heat için parametreler + self.used_out_c: Optional[float] = None + self._last_used_update_ts: Optional[datetime.datetime] = None + self.outside_smooth_sec: float = float( + getattr(cfg_v, "OUTSIDE_SMOOTH_SECONDS", 900.0) + ) + self.weekend_boost_c: float = float( + getattr(cfg_v, "WEEKEND_HEAT_BOOST_C", 0.0) + ) + self.comfort_offset_c: float = float( + getattr(cfg_v, "BURNER_COMFORT_OFFSET_C", 0.0) + ) + + # Ortam nesnesi (opsiyonel) + self.environment = environment + + # Ortamdan başlangıç dış ısı alınabiliyorsa used_out'u hemen doldur + if self.environment is not None: + try: + first_out = self.environment.get_outside_temp_cached() + except Exception: + first_out = None + if first_out is not None: + self.used_out_c = first_out + self._last_used_update_ts = datetime.datetime.now() + + # Çalışma modu + cfg_mode = str(getattr(cfg_s, "BUILD_BURNER", "F")).upper() + initial_mode = cfg_mode if cfg_mode in ("F", "B") else "F" + + # Başlangıç state + self.state = BurnerState( + burner_on=False, + pumps_on=tuple(), + fire_setpoint_c=max_out, + last_change_ts=datetime.datetime.now(), + reason="init", + last_building_avg=None, + last_outside_c=None, + last_used_out_c=None, + last_mode=initial_mode, + ) + + # Mevsim / güneş bilgisi (syslog üst block için) + try: + self.season = SeasonController.from_now() + except Exception as e: + print("SeasonController.from_now() hata:", e) + self.season = None + + # --------------------------------------------------------- + # Bina istatistikleri + # --------------------------------------------------------- + + def _get_building_stats(self, now: datetime.datetime) -> Optional[Dict[str, Any]]: + """ + Bina ortalaması / min / max gibi istatistikleri periyodik olarak okur. + BUILDING_READ_PERIOD_S içinde cache kullanır. + """ + if self._building_last_read_ts is None: + need_read = True + else: + delta = (now - self._building_last_read_ts).total_seconds() + need_read = delta >= self._building_read_period + + if not need_read: + return self._building_last_stats + + try: + stats = self.building.get_stats() + except Exception as e: + print("Building.get_stats() hata:", e) + return self._building_last_stats + + self._building_last_read_ts = now + self._building_last_stats = stats + return stats + + # --------------------------------------------------------- + # used_out_heat güncelleme + # --------------------------------------------------------- + + def _update_used_out(self, now: datetime.datetime, outside_c: Optional[float]) -> Optional[float]: + """ + Dış ısı okumasına göre used_out_heat günceller. + - OUTSIDE_SMOOTH_SECONDS süresince eksponansiyel smoothing + - Haftasonu ve konfor offset'i eklenir. + """ + raw = outside_c + + if raw is None: + return self.used_out_c + + # Smooth + if self.used_out_c is None or self._last_used_update_ts is None: + smoothed = raw + else: + dt = (now - self._last_used_update_ts).total_seconds() + if dt <= 0: + smoothed = self.used_out_c + else: + tau = max(1.0, self.outside_smooth_sec) + alpha = min(1.0, dt / tau) + smoothed = (1.0 - alpha) * self.used_out_c + alpha * raw + + self.used_out_c = smoothed + self._last_used_update_ts = now + + # Haftasonu / konfor offset'i uygula + final_used = _apply_weekend_and_comfort( + smoothed, + now, + self.weekend_boost_c, + self.comfort_offset_c, + ) + return final_used + + # --------------------------------------------------------- + # Isı ihtiyacı kararları + # --------------------------------------------------------- + + def _should_heat_by_outside(self, used_out: Optional[float]) -> bool: + """ + F modunda (dış ısıya göre) ısıtma isteği. + """ + limit = float(getattr(cfg_v, "OUTSIDE_HEAT_LIMIT_C", 17.0)) + if used_out is None: + return False + + want = used_out < limit + print(f"should_heat_by_outside: used={used_out:.3f}C limit={limit:.1f}C") + return want + + def _should_heat_by_building(self, building_avg: Optional[float], now: datetime.datetime) -> bool: + """ + B modunda bina ortalaması + konfor setpoint'e göre ısıtma isteği. + """ + comfort = float(getattr(cfg_v, "COMFORT_SETPOINT_C", 23.0)) + h = self.cfg.hysteresis_c + + if building_avg is None: + return False + + if building_avg < (comfort - h): + return True + if building_avg > (comfort + h): + return False + + # Histerezis bandında önceki state'i koru + return self.state.burner_on + + # --------------------------------------------------------- + # Min çalışma / durma süreleri + # --------------------------------------------------------- + + def _respect_min_times(self, now: datetime.datetime, want_on: bool) -> bool: + """ + min_run_sec / min_stop_sec kurallarını uygular. + - İlk açılışta (state.reason == 'init') kısıtlama uygulanmaz. + """ + # İlk tick: min_run/min_stop uygulama + try: + if getattr(self.state, "reason", "") == "init": + return want_on + except Exception: + pass + + elapsed = (now - self.state.last_change_ts).total_seconds() + + if self.state.burner_on: + # Çalışırken min_run dolmadan kapatma + if not want_on and elapsed < self.cfg.min_run_sec: + return True + else: + # Kapalıyken min_stop dolmadan açma + if want_on and elapsed < self.cfg.min_stop_sec: + return False + + return want_on + + # --------------------------------------------------------- + # Çıkışları rölelere uygulama + # --------------------------------------------------------- + + def _apply_outputs( + self, + now: datetime.datetime, + mode: str, + burner_on: bool, + pumps_on: Tuple[str, ...], + fire_setpoint_c: float, + reason: str, + ) -> None: + """ + Röleleri sürer, state'i günceller, log ve syslog üretir. + """ + # 1) Röle sürücüsü (igniter + pompalar) + try: + # Yeni API: RelayDriver brülör-aware ise + if hasattr(self.relays, "set_igniter"): + # Brülör ateşleme + self.relays.set_igniter(self.burner_id, burner_on) + + # Pompalar: her zaman kanal isimleri üzerinden sür + if hasattr(self.relays, "all_pumps"): + all_pumps = list(self.relays.all_pumps(self.burner_id)) # ['circulation_a', ...] + for ch in all_pumps: + self.relays.set_channel(ch, (ch in pumps_on)) + else: + # all_pumps yoksa, config_statics'ten gelen pump_channels ile sür + for ch in self.pump_channels: + self.relays.set_channel(ch, (ch in pumps_on)) + else: + # Eski/çok basit API: doğrudan kanal adları + self.relays.set_channel(self.igniter_ch, burner_on) + for ch in self.pump_channels: + self.relays.set_channel(ch, (ch in pumps_on)) + except Exception as exc: + # legacy_syslog.log_error YOK, bu yüzden ya loga yaz ya da print et + try: + msg = f"[relay_error] igniter_ch={self.igniter_ch} burner_on={burner_on} pumps_on={pumps_on} exc={exc}" + lsys.send_legacy_syslog(lsys.format_line(98, msg)) + except Exception: + print("Relay error in _apply_outputs:", exc) + + # 2) State güncelle + if burner_on != self.state.burner_on or tuple(pumps_on) != self.state.pumps_on: + self.state.last_change_ts = now + + self.state.burner_on = burner_on + self.state.pumps_on = tuple(pumps_on) + self.state.fire_setpoint_c = fire_setpoint_c + self.state.reason = reason + self.state.last_mode = mode + + # 3) DBText logger'a yaz + try: + self.logger.insert( + { + "ts": now, + "mode": mode, + "burner_on": int(burner_on), + "pumps": ",".join(pumps_on), + "fire_sp": fire_setpoint_c, + "reason": reason, + "bavg": _safe_float(self.state.last_building_avg), + "out": _safe_float(self.state.last_outside_c), + "used": _safe_float(self.state.last_used_out_c), + } + ) + except Exception: + pass + + # 4) Syslog / console üst blok + try: + lsys.log_burner_header( + now=now, + mode=mode, + season=self.season, + building_avg=self.state.last_building_avg, + outside_c=self.state.last_outside_c, + used_out_c=self.state.last_used_out_c, + fire_sp=fire_setpoint_c, + burner_on=burner_on, + pumps_on=pumps_on, + ) + except Exception as exc: + # Burayı tamamen sessize almayalım, hatayı konsola basalım + print("BRULOR lsys.log_burner_header error:", exc, "burner.py _apply_outputs()") + + # --------------------------------------------------------- + # Ana tick fonksiyonu + # --------------------------------------------------------- + + def tick(self, outside_c: Optional[float] = None) -> BurnerState: + """ + Tek bir kontrol adımı. + + - Bina istatistiği BUILDING_READ_PERIOD_S periyodunda bir kez okunur, + aradaki tick'lerde cache kullanılır. + - F modunda kararlar *used_out_heat* üzerinden verilir. + """ + now = datetime.datetime.now() + cfg_mode = str(getattr(cfg_s, "BUILD_BURNER", "F")).upper() + mode = cfg_mode + + print("tick outside_c:", outside_c) + # 0) dış ısı: parametre yoksa ortamdan al + if outside_c is None and getattr(self, "environment", None) is not None: + try: + outside_c = self.environment.get_outside_temp_cached() + except Exception: + outside_c = None + print("env:", getattr(self, "environment", None)) + print("tick outside_c 2:", outside_c) + + # 1) bina istatistiği (periyodik) + stats = self._get_building_stats(now) + building_avg = stats.get("avg") if stats else None + + # 2) used_out_heat güncelle + used_out = self._update_used_out(now, outside_c) + + self.state.last_building_avg = building_avg + self.state.last_outside_c = outside_c + self.state.last_used_out_c = used_out + + # 3) ısıtma ihtiyacı + if mode == "F": + want_heat = self._should_heat_by_outside(used_out) + else: + mode = "B" # saçma değer gelirse B moduna zorla + want_heat = self._should_heat_by_building(building_avg, now) + + want_heat = self._respect_min_times(now, want_heat) + + # 4) fire setpoint – F modunda da used_out üzerinden okunur + fire_sp = pick_fire_setpoint(used_out) + max_out = float(getattr(cfg_v, "MAX_OUTLET_C", 45.0)) + fire_sp = min(fire_sp, max_out) + + # 5) pompalar + if want_heat: + if hasattr(self.relays, "enabled_pumps"): + try: + pumps_list = list(self.relays.enabled_pumps(self.burner_id)) + pumps = tuple(pumps_list) + except Exception: + pumps = tuple(self.default_pumps) + else: + pumps = tuple(self.default_pumps) + else: + pumps = tuple() + + reason = ( + f"avg={building_avg}C " + f"outside_raw={outside_c}C " + f"used={used_out}C " + f"want_heat={want_heat}" + ) + print("tick reason", reason) + + # 7) Rölelere uygula + self._apply_outputs( + now=now, + mode=mode, + burner_on=bool(want_heat), + pumps_on=pumps, + fire_setpoint_c=fire_sp, + reason=reason, + ) + print("state", self.state) + return self.state + + +# ------------------------------------------------------------- +# CLI / demo +# ------------------------------------------------------------- + + +def _demo() -> None: + """ + Basit demo: Building + RelayDriver + BuildingEnvironment ile + BurnerController'ı ayağa kaldır, tick() döngüsü yap. + """ + # 1) Bina + try: + building = Building() + print("✅ Building: statics yüklendi\n") + print(building.pretty_summary()) + except Exception as e: + print("❌ Building oluşturulamadı:", e) + raise SystemExit(1) + + # 2) Ortam (dış ısı, ADC vs.) + try: + env = BuildingEnvironment() + except Exception as e: + print("⚠️ BuildingEnvironment oluşturulamadı:", e) + env = None + + # 3) Röle sürücüsü + rel = RelayDriver(onoff=False) + + # 4) Denetleyici + ctrl = BurnerController(building, rel, environment=env) + + print("🔥 BurnerController başlatıldı") + print(f" Burner ID : {ctrl.burner_id}") + print(f" Çalışma modu (BUILD_BURNER): {getattr(cfg_s, 'BUILD_BURNER', 'F')} (F=dış ısı, B=bina ort)") + print(f" Igniter kanalı : {ctrl.igniter_ch}") + print(f" Pompa kanalları : {ctrl.pump_channels}") + print(f" Varsayılan pompalar : {ctrl.default_pumps}") + print(f" Konfor setpoint (°C) : {getattr(cfg_v, 'COMFORT_SETPOINT_C', 23.0)}") + print(f" Histerezis (°C) : {ctrl.cfg.hysteresis_c}") + print(f" Dış ısı limiti (°C) : {getattr(cfg_v, 'OUTSIDE_HEAT_LIMIT_C', 17.0)}") + print(f" Max kazan çıkış (°C) : {getattr(cfg_v, 'MAX_OUTLET_C', 45.0)}") + print(f" Bina okuma periyodu (s) : {ctrl._building_read_period}") + print(f" OUTSIDE_SMOOTH_SECONDS : {ctrl.outside_smooth_sec}") + print(f" WEEKEND_HEAT_BOOST_C : {ctrl.weekend_boost_c}") + print(f" BURNER_COMFORT_OFFSET_C : {ctrl.comfort_offset_c}") + print("----------------------------------------------------") + print("BurnerController demo (Ctrl+C ile çık)…") + + try: + while True: + ctrl.tick() + _time.sleep(5) + except KeyboardInterrupt: + print("\nCtrl+C alındı, çıkış hazırlanıyor…") + finally: + try: + rel.all_off() + print("🔌 Tüm röleler kapatıldı.") + except Exception as e: + print(f"⚠️ Röleleri kapatırken hata: {e}") + finally: + try: + rel.cleanup() + except Exception: + pass + + +if __name__ == "__main__": + _demo() diff --git a/ebuild/core/systems/firealarm.py b/ebuild/core/systems/firealarm.py new file mode 100644 index 0000000..1af8737 --- /dev/null +++ b/ebuild/core/systems/firealarm.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +"""Yangın alarm sistemi iskeleti.""" diff --git a/ebuild/core/systems/hydrophore.py b/ebuild/core/systems/hydrophore.py new file mode 100644 index 0000000..ad26990 --- /dev/null +++ b/ebuild/core/systems/hydrophore.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +"""Hidrofor sistemi iskeleti.""" diff --git a/ebuild/core/systems/irrigation.py b/ebuild/core/systems/irrigation.py new file mode 100644 index 0000000..6f2adc1 --- /dev/null +++ b/ebuild/core/systems/irrigation.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +"""Sulama sistemi iskeleti.""" diff --git a/ebuild/io/__init__.py b/ebuild/io/__init__.py new file mode 100644 index 0000000..40a96af --- /dev/null +++ b/ebuild/io/__init__.py @@ -0,0 +1 @@ +# -*- coding: utf-8 -*- diff --git a/ebuild/io/__pycache__/__init__.cpython-39.pyc b/ebuild/io/__pycache__/__init__.cpython-39.pyc new file mode 100644 index 0000000..b31b323 Binary files /dev/null and b/ebuild/io/__pycache__/__init__.cpython-39.pyc differ diff --git a/ebuild/io/__pycache__/adc_mcp3008.cpython-39.pyc b/ebuild/io/__pycache__/adc_mcp3008.cpython-39.pyc new file mode 100644 index 0000000..f5654d0 Binary files /dev/null and b/ebuild/io/__pycache__/adc_mcp3008.cpython-39.pyc differ diff --git a/ebuild/io/__pycache__/config_ini.cpython-39.pyc b/ebuild/io/__pycache__/config_ini.cpython-39.pyc new file mode 100644 index 0000000..1b2bb5d Binary files /dev/null and b/ebuild/io/__pycache__/config_ini.cpython-39.pyc differ diff --git a/ebuild/io/__pycache__/dbtext.cpython-39.pyc b/ebuild/io/__pycache__/dbtext.cpython-39.pyc new file mode 100644 index 0000000..22015ea Binary files /dev/null and b/ebuild/io/__pycache__/dbtext.cpython-39.pyc differ diff --git a/ebuild/io/__pycache__/ds18b20.cpython-39.pyc b/ebuild/io/__pycache__/ds18b20.cpython-39.pyc new file mode 100644 index 0000000..89a7072 Binary files /dev/null and b/ebuild/io/__pycache__/ds18b20.cpython-39.pyc differ diff --git a/ebuild/io/__pycache__/legacy_syslog.cpython-39.pyc b/ebuild/io/__pycache__/legacy_syslog.cpython-39.pyc new file mode 100644 index 0000000..f12770a Binary files /dev/null and b/ebuild/io/__pycache__/legacy_syslog.cpython-39.pyc differ diff --git a/ebuild/io/__pycache__/relay_driver.cpython-39.pyc b/ebuild/io/__pycache__/relay_driver.cpython-39.pyc new file mode 100644 index 0000000..d9753d8 Binary files /dev/null and b/ebuild/io/__pycache__/relay_driver.cpython-39.pyc differ diff --git a/ebuild/io/adc_mcp3008.py b/ebuild/io/adc_mcp3008.py new file mode 100644 index 0000000..3da5615 --- /dev/null +++ b/ebuild/io/adc_mcp3008.py @@ -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() diff --git a/ebuild/io/config_ini.py b/ebuild/io/config_ini.py new file mode 100644 index 0000000..5f939e1 --- /dev/null +++ b/ebuild/io/config_ini.py @@ -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") diff --git a/ebuild/io/dbtext.py b/ebuild/io/dbtext.py new file mode 100644 index 0000000..35118c1 --- /dev/null +++ b/ebuild/io/dbtext.py @@ -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 (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[^']*)',\s*'(?P[^']*)',\s*'(?P[^']*)',\s*" + r"'(?P[^']*)',\s*(?PNULL|[-0-9.]+),\s*(?PNULL|'[^']*'),\s*" + r"'(?P[^']*)'\);" + ) + + # ------------------------------------------------- + # 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
(...) 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) diff --git a/ebuild/io/ds18b20.py b/ebuild/io/ds18b20.py new file mode 100644 index 0000000..396f9dd --- /dev/null +++ b/ebuild/io/ds18b20.py @@ -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()})") + diff --git a/ebuild/io/edm_db.py b/ebuild/io/edm_db.py new file mode 100644 index 0000000..8591fd1 --- /dev/null +++ b/ebuild/io/edm_db.py @@ -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) diff --git a/ebuild/io/legacy_syslog.py b/ebuild/io/legacy_syslog.py new file mode 100644 index 0000000..e86d9fc --- /dev/null +++ b/ebuild/io/legacy_syslog.py @@ -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 = "" if burner_on else "" + 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 = "" if pumps_on else "" + 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)) diff --git a/ebuild/io/relay_driver.py b/ebuild/io/relay_driver.py new file mode 100644 index 0000000..2a9c9ea --- /dev/null +++ b/ebuild/io/relay_driver.py @@ -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()) diff --git a/ebuild/io/sensor_dht11.py b/ebuild/io/sensor_dht11.py new file mode 100644 index 0000000..209b975 --- /dev/null +++ b/ebuild/io/sensor_dht11.py @@ -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)) + diff --git a/ebuild/io/z2legacy_syslog.py b/ebuild/io/z2legacy_syslog.py new file mode 100644 index 0000000..1b94ef6 --- /dev/null +++ b/ebuild/io/z2legacy_syslog.py @@ -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}" + + # config’teki 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 .0’sı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 = "" if burner_on else "" + 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 : [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 + + # Config’te 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 = "" if pump_count > 0 else "" + + 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: [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)) diff --git a/ebuild/io/z2relay_driver.py b/ebuild/io/z2relay_driver.py new file mode 100644 index 0000000..c32bb59 --- /dev/null +++ b/ebuild/io/z2relay_driver.py @@ -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()) diff --git a/ebuild/io/z3legacy_syslog.py b/ebuild/io/z3legacy_syslog.py new file mode 100644 index 0000000..e37f573 --- /dev/null +++ b/ebuild/io/z3legacy_syslog.py @@ -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 = "" if burner_on else "" + 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 = "" if pump_count > 0 else "" + 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=(), + ) diff --git a/ebuild/io/zlegacy_syslog.py b/ebuild/io/zlegacy_syslog.py new file mode 100644 index 0000000..1b94ef6 --- /dev/null +++ b/ebuild/io/zlegacy_syslog.py @@ -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}" + + # config’teki 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 .0’sı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 = "" if burner_on else "" + 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 : [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 + + # Config’te 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 = "" if pump_count > 0 else "" + + 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: [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)) diff --git a/ebuild/io/zrelay_driver.py b/ebuild/io/zrelay_driver.py new file mode 100644 index 0000000..4551b7c --- /dev/null +++ b/ebuild/io/zrelay_driver.py @@ -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()) diff --git a/ebuild/reloader.py b/ebuild/reloader.py new file mode 100644 index 0000000..198fa9f --- /dev/null +++ b/ebuild/reloader.py @@ -0,0 +1,95 @@ +# -*- coding: utf-8 -*- +""" ebuild/reloader.py + +İki ayrı konfigürasyon modülünü (config_statics, config_variables) +izleyen ve dosya değişikliklerinde importlib.reload çağıran yardımcı sınıf. +""" + +import os +import importlib + +from . import config_statics as cfg_s +from . import config_variables as cfg_v + + +class ConfigReloader(object): + """ Statik ve dinamik config dosyalarını izler. + + maybe_reload() çağrıldığında: + - Değişmiş dosyaları yeniden yükler + - Hangi grubun değiştiğini dict olarak döner. + """ + + def __init__(self): + self._statics_path = None + self._statics_last = None + + self._vars_path = None + self._vars_last = None + + # Statics + try: + p = getattr(cfg_s, "__file__", None) + if p and p.endswith((".pyc", ".pyo")): + p = p[:-1] + self._statics_path = os.path.abspath(p) if p else None + self._statics_last = ( + os.path.getmtime(self._statics_path) + if self._statics_path else None + ) + except Exception: + self._statics_path, self._statics_last = None, None + + # Variables + try: + p = getattr(cfg_v, "__file__", None) + if p and p.endswith((".pyc", ".pyo")): + p = p[:-1] + self._vars_path = os.path.abspath(p) if p else None + self._vars_last = ( + os.path.getmtime(self._vars_path) + if self._vars_path else None + ) + except Exception: + self._vars_path, self._vars_last = None, None + + def _check_and_reload(self, path_attr, last_attr, module): + path = getattr(self, path_attr) + if not path: + return False + + try: + cur = os.path.getmtime(path) + except Exception: + return False + + last = getattr(self, last_attr) + if last is None or cur > last: + try: + importlib.reload(module) + setattr(self, last_attr, os.path.getmtime(path)) + return True + except Exception: + return False + + return False + + def maybe_reload(self): + """ Değişiklik algılanırsa ilgili config'i yeniden yükler. + + return: + { + "statics_changed": bool, + "variables_changed": bool, + } + """ + statics_changed = self._check_and_reload( + "_statics_path", "_statics_last", cfg_s + ) + variables_changed = self._check_and_reload( + "_vars_path", "_vars_last", cfg_v + ) + return { + "statics_changed": statics_changed, + "variables_changed": variables_changed, + } diff --git a/ebuild/runtime/__init__.py b/ebuild/runtime/__init__.py new file mode 100644 index 0000000..40a96af --- /dev/null +++ b/ebuild/runtime/__init__.py @@ -0,0 +1 @@ +# -*- coding: utf-8 -*- diff --git a/ebuild/runtime/main.py b/ebuild/runtime/main.py new file mode 100644 index 0000000..90482f4 --- /dev/null +++ b/ebuild/runtime/main.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +""" ebuild/runtime/main.py + +Uygulamanın ana döngüsü için iskelet. +Şu an sadece config reloader'ı test eder. +""" + +from ..reloader import ConfigReloader + + +def main() -> None: + """ ebuild ana çalışma fonksiyonu (iskelet). + + Gerçek sistemde: + - ConfigReloader izlenir + - Building + Environment + Systems kurulup döngü içinde çalıştırılır. + """ + reloader = ConfigReloader() + result = reloader.maybe_reload() + print("Config kontrol sonucu:", result) + + +if __name__ == "__main__": + main() diff --git a/ebuild/tools/__pycache__/relay_test.cpython-39.pyc b/ebuild/tools/__pycache__/relay_test.cpython-39.pyc new file mode 100644 index 0000000..ec0f00f Binary files /dev/null and b/ebuild/tools/__pycache__/relay_test.cpython-39.pyc differ diff --git a/ebuild/tools/relay_test.py b/ebuild/tools/relay_test.py new file mode 100644 index 0000000..b4c55a0 --- /dev/null +++ b/ebuild/tools/relay_test.py @@ -0,0 +1,149 @@ +# -*- coding: utf-8 -*- +__title__ = "relay_test" +__author__ = 'Mehmet Karatay & "Saraswati" (ChatGPT)' +__purpose__ = "Röle pin eşlemesini ve brülör gruplarını sahada test aracı" +__version__ = "0.1.0" +__date__ = "2025-11-20" + +""" +ebuild/tools/relay_test.py + +Revision : 2025-11-20 +Authors : Mehmet Karatay & "Saraswati" (ChatGPT) + +Amaç +----- +- config_statics.RELAY_GPIO ve BURNER_GROUPS'e göre röle sürücüsünü yüklemek, +- Her kanalı sırayla açıp kapatarak kablolamanın doğru olup olmadığını + sahada test etmek, +- Brülör bazlı test: + * Belirli bir burner_id için igniter kanalı, + * default pompalar (circulation.default=1 olanlar) + ayrı ayrı ON/OFF denemesi. + +Kullanım +-------- +Rasp2 üzerinde: + + cd /home/karatay/ebuild + python3 -m ebuild.tools.relay_test + +UYARI: Bu test gerçek röleleri tetikler. + Kazan/şebeke enerjisi bağlıysa dikkatli kullan. +""" + +import time +from typing import Optional + +from ..io.relay_driver import RelayDriver +from .. import config_statics as cfg_s + + +def _sleep(s: float) -> None: + try: + time.sleep(s) + except KeyboardInterrupt: + raise + + +def test_all_channels(driver: RelayDriver, on_time: float = 1.0, off_time: float = 0.5) -> None: + """ + Tüm röle kanallarını sırayla: + - ON (on_time saniye) + - OFF (off_time saniye) + yaparak test eder. + """ + print("\n=== Tüm kanalların sırayla test edilmesi ===") + channels = sorted(driver.channels()) + if not channels: + print("⚠️ Tanımlı kanal yok (RELAY_GPIO boş?).") + return + + for ch in channels: + print(f"\n→ Kanal: {ch} -> ON") + driver.set_channel(ch, True) + _sleep(on_time) + + print(f"→ Kanal: {ch} -> OFF") + driver.set_channel(ch, False) + _sleep(off_time) + + print("\n✅ Kanal döngüsü tamamlandı.\n") + + +def test_burner(driver: RelayDriver, burner_id: int, on_time: float = 1.0, off_time: float = 0.5) -> None: + """ + Belirli bir brülör grubunu test eder: + + - Igniter ON/OFF + - default pompalar ON/OFF + + Burner grubu bilgisi config_statics.BURNER_GROUPS'tan gelir. + """ + burners = driver.burners() + if burner_id not in burners: + print(f"⚠️ Burner id {burner_id} tanımlı değil.") + return + + info = burners[burner_id] + name = info.get("name", f"Burner{burner_id}") + loc = info.get("location", "-") + + print(f"\n=== Brülör Testi: #{burner_id} - {name} @ {loc} ===") + + # Igniter test + print("\n→ Igniter ON") + driver.set_igniter(burner_id, True) + _sleep(on_time) + print("→ Igniter OFF") + driver.set_igniter(burner_id, False) + _sleep(off_time) + + # Default pompalar + enabled_pumps = driver.enabled_pumps(burner_id) + if not enabled_pumps: + print("⚠️ Bu brülör için default (default=1) pompa tanımlı değil.") + else: + print(f"\nDefault pompalar: {enabled_pumps}") + for pname in enabled_pumps: + print(f"\n→ Pompa {pname} ON") + driver.set_pump(burner_id, pname, True) + _sleep(on_time) + print(f"→ Pompa {pname} OFF") + driver.set_pump(burner_id, pname, False) + _sleep(off_time) + + print(f"\n✅ Brülör #{burner_id} testi tamamlandı.\n") + + +def main(burner_id: Optional[int] = 0) -> None: + print("RelayDriver yükleniyor…") + drv = RelayDriver() + drv.summary() + + # Önce tüm kanalları hızlıca test et + test_all_channels(drv, on_time=0.7, off_time=0.3) + + # Eğer config'te burner grubu varsa, onu da test et + burners = getattr(cfg_s, "BURNER_GROUPS", {}) + if burners and burner_id is not None and burner_id in burners: + test_burner(drv, burner_id=burner_id, on_time=1.0, off_time=0.5) + else: + print("\nℹ️ BURNER_GROUPS boş veya burner_id geçersiz; brülör test atlandı.\n") + + print("Tüm röle testleri bitti. Tüm kanalları kapatıyorum…") + drv.all_off() + print("✅ Röleler güvenli moda alındı.") + + +if __name__ == "__main__": + try: + main(burner_id=0) + except KeyboardInterrupt: + print("\nKullanıcı tarafından kesildi, röleler kapatılıyor…") + try: + drv = RelayDriver() + drv.all_off() + except Exception: + pass + print("Çıkış.") diff --git a/ebuild_building_log.sql b/ebuild_building_log.sql new file mode 100644 index 0000000..f898876 --- /dev/null +++ b/ebuild_building_log.sql @@ -0,0 +1,3 @@ +-- DBText log file for table ebrulor_log +-- created at 2025-11-20T18:55:06.091200 + diff --git a/ebuild_burner_log.sql b/ebuild_burner_log.sql new file mode 100644 index 0000000..51e38ae --- /dev/null +++ b/ebuild_burner_log.sql @@ -0,0 +1,198 @@ +-- DBText log file for table eburner_log +-- created at 2025-11-20T20:45:06.451003 + +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-20 20:45:06', 'EBURNER', 'burner', 'relay_error', NULL, NULL, '0'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-20 20:45:06', 'EBURNER', 'relay:igniter', 'state', 0.000, 'bool', 'F:mode=F avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-20 20:45:06', 'EBURNER', 'relay:circulation_a', 'state', 0.000, 'bool', 'F:mode=F avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-20 20:45:06', 'EBURNER', 'relay:circulation_b', 'state', 0.000, 'bool', 'F:mode=F avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-20 20:45:06', 'EBURNER', 'burner:mode=F', 'fire_setpoint', 44.000, '°C', 'mode=F avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-20 20:45:16', 'EBURNER', 'burner', 'relay_error', NULL, NULL, '0'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-20 20:56:15', 'EBURNER', 'burner', 'relay_error', NULL, NULL, '0'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-20 20:56:15', 'EBURNER', 'relay:igniter', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-20 20:56:15', 'EBURNER', 'relay:circulation_a', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-20 20:56:15', 'EBURNER', 'relay:circulation_b', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-20 20:56:15', 'EBURNER', 'burner:mode=F', 'fire_setpoint', 44.000, '°C', 'avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-20 20:56:20', 'EBURNER', 'burner', 'relay_error', NULL, NULL, '0'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-20 20:56:25', 'EBURNER', 'burner', 'relay_error', NULL, NULL, '0'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-20 20:56:30', 'EBURNER', 'burner', 'relay_error', NULL, NULL, '0'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-20 20:56:35', 'EBURNER', 'burner', 'relay_error', NULL, NULL, '0'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-20 20:56:40', 'EBURNER', 'burner', 'relay_error', NULL, NULL, '0'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-20 20:56:45', 'EBURNER', 'burner', 'relay_error', NULL, NULL, '0'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-20 20:56:50', 'EBURNER', 'burner', 'relay_error', NULL, NULL, '0'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-20 20:56:55', 'EBURNER', 'burner', 'relay_error', NULL, NULL, '0'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 13:10:22', 'EBURNER', 'relay:igniter', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 13:10:22', 'EBURNER', 'relay:circulation_a', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 13:10:22', 'EBURNER', 'relay:circulation_b', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 13:10:22', 'EBURNER', 'burner:mode=F', 'fire_setpoint', 44.000, '°C', 'avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 13:43:03', 'EBURNER', 'relay:igniter', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 13:43:03', 'EBURNER', 'relay:circulation_a', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 13:43:03', 'EBURNER', 'relay:circulation_b', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 13:43:03', 'EBURNER', 'burner:mode=F', 'fire_setpoint', 44.000, '°C', 'avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 13:48:46', 'EBURNER', 'relay:igniter', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 13:48:46', 'EBURNER', 'relay:circulation_a', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 13:48:46', 'EBURNER', 'relay:circulation_b', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 13:48:46', 'EBURNER', 'burner:mode=F', 'fire_setpoint', 44.000, '°C', 'avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 13:58:48', 'EBURNER', 'relay:igniter', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 13:58:48', 'EBURNER', 'relay:circulation_a', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 13:58:48', 'EBURNER', 'relay:circulation_b', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 13:58:48', 'EBURNER', 'burner:mode=F', 'fire_setpoint', 44.000, '°C', 'avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 13:59:49', 'EBURNER', 'relay:igniter', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 13:59:49', 'EBURNER', 'relay:circulation_a', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 13:59:49', 'EBURNER', 'relay:circulation_b', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 13:59:49', 'EBURNER', 'burner:mode=F', 'fire_setpoint', 44.000, '°C', 'avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 14:00:50', 'EBURNER', 'relay:igniter', 'state', 1.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=True'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 14:00:50', 'EBURNER', 'relay:circulation_a', 'state', 1.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=True'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 14:00:50', 'EBURNER', 'relay:circulation_b', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=True'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 14:00:50', 'EBURNER', 'burner:mode=F', 'fire_setpoint', 44.000, '°C', 'avg=NoneC outside=10.0C want_heat=True'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 14:09:38', 'EBURNER', 'relay:igniter', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False cfg=F'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 14:09:38', 'EBURNER', 'relay:circulation_a', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False cfg=F'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 14:09:38', 'EBURNER', 'relay:circulation_b', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False cfg=F'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 14:09:38', 'EBURNER', 'burner:mode=F', 'fire_setpoint', 44.000, '°C', 'avg=NoneC outside=10.0C want_heat=False cfg=F'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 14:10:38', 'EBURNER', 'relay:igniter', 'state', 1.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=True cfg=F'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 14:10:38', 'EBURNER', 'relay:circulation_a', 'state', 1.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=True cfg=F'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 14:10:38', 'EBURNER', 'relay:circulation_b', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=True cfg=F'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 14:10:38', 'EBURNER', 'burner:mode=F', 'fire_setpoint', 44.000, '°C', 'avg=NoneC outside=10.0C want_heat=True cfg=F'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 14:19:25', 'EBURNER', 'relay:igniter', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False cfg=F'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 14:19:25', 'EBURNER', 'relay:circulation_a', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False cfg=F'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 14:19:25', 'EBURNER', 'relay:circulation_b', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False cfg=F'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 14:19:25', 'EBURNER', 'burner:mode=F', 'fire_setpoint', 44.000, '°C', 'avg=NoneC outside=10.0C want_heat=False cfg=F'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 14:20:33', 'EBURNER', 'relay:igniter', 'state', 1.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=True cfg=F'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 14:20:33', 'EBURNER', 'relay:circulation_a', 'state', 1.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=True cfg=F'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 14:20:33', 'EBURNER', 'relay:circulation_b', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=True cfg=F'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 14:20:33', 'EBURNER', 'burner:mode=F', 'fire_setpoint', 44.000, '°C', 'avg=NoneC outside=10.0C want_heat=True cfg=F'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 14:24:26', 'EBURNER', 'relay:igniter', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False cfg=F'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 14:24:26', 'EBURNER', 'relay:circulation_a', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False cfg=F'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 14:24:26', 'EBURNER', 'relay:circulation_b', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False cfg=F'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 14:24:26', 'EBURNER', 'burner:mode=F', 'fire_setpoint', 44.000, '°C', 'avg=NoneC outside=10.0C want_heat=False cfg=F'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 14:25:35', 'EBURNER', 'relay:igniter', 'state', 1.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=True cfg=F'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 14:25:35', 'EBURNER', 'relay:circulation_a', 'state', 1.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=True cfg=F'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 14:25:35', 'EBURNER', 'relay:circulation_b', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=True cfg=F'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 14:25:35', 'EBURNER', 'burner:mode=F', 'fire_setpoint', 44.000, '°C', 'avg=NoneC outside=10.0C want_heat=True cfg=F'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 14:37:42', 'EBURNER', 'relay:igniter', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False cfg=F'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 14:37:42', 'EBURNER', 'relay:circulation_a', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False cfg=F'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 14:37:42', 'EBURNER', 'relay:circulation_b', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False cfg=F'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 14:37:42', 'EBURNER', 'burner:mode=F', 'fire_setpoint', 44.000, '°C', 'avg=NoneC outside=10.0C want_heat=False cfg=F'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 14:38:42', 'EBURNER', 'relay:igniter', 'state', 1.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=True cfg=F'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 14:38:42', 'EBURNER', 'relay:circulation_a', 'state', 1.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=True cfg=F'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 14:38:42', 'EBURNER', 'relay:circulation_b', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=True cfg=F'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 14:38:42', 'EBURNER', 'burner:mode=F', 'fire_setpoint', 44.000, '°C', 'avg=NoneC outside=10.0C want_heat=True cfg=F'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 14:51:23', 'EBURNER', 'relay:igniter', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False cfg=F'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 14:51:23', 'EBURNER', 'relay:circulation_a', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False cfg=F'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 14:51:23', 'EBURNER', 'relay:circulation_b', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False cfg=F'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 14:51:23', 'EBURNER', 'burner:mode=F', 'fire_setpoint', 44.000, '°C', 'avg=NoneC outside=10.0C want_heat=False cfg=F'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 14:52:14', 'EBURNER', 'relay:igniter', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False cfg=F'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 14:52:14', 'EBURNER', 'relay:circulation_a', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False cfg=F'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 14:52:14', 'EBURNER', 'relay:circulation_b', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False cfg=F'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 14:52:14', 'EBURNER', 'burner:mode=F', 'fire_setpoint', 44.000, '°C', 'avg=NoneC outside=10.0C want_heat=False cfg=F'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 15:18:00', 'EBURNER', 'relay:igniter', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False cfg=F'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 15:18:00', 'EBURNER', 'relay:circulation_a', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False cfg=F'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 15:18:00', 'EBURNER', 'relay:circulation_b', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False cfg=F'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 15:18:00', 'EBURNER', 'burner:mode=F', 'fire_setpoint', 44.000, '°C', 'avg=NoneC outside=10.0C want_heat=False cfg=F'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 16:18:24', 'EBURNER', 'relay:igniter', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False cfg=F'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 16:18:24', 'EBURNER', 'relay:circulation_a', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False cfg=F'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 16:18:24', 'EBURNER', 'relay:circulation_b', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False cfg=F'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 16:18:24', 'EBURNER', 'burner:mode=F', 'fire_setpoint', 44.000, '°C', 'avg=NoneC outside=10.0C want_heat=False cfg=F'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 16:18:54', 'EBURNER', 'relay:igniter', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False cfg=F'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 16:18:54', 'EBURNER', 'relay:circulation_a', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False cfg=F'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 16:18:54', 'EBURNER', 'relay:circulation_b', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False cfg=F'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 16:18:54', 'EBURNER', 'burner:mode=F', 'fire_setpoint', 44.000, '°C', 'avg=NoneC outside=10.0C want_heat=False cfg=F'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 16:19:55', 'EBURNER', 'relay:igniter', 'state', 1.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=True cfg=F'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 16:19:55', 'EBURNER', 'relay:circulation_a', 'state', 1.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=True cfg=F'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 16:19:55', 'EBURNER', 'relay:circulation_b', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=True cfg=F'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 16:19:55', 'EBURNER', 'burner:mode=F', 'fire_setpoint', 44.000, '°C', 'avg=NoneC outside=10.0C want_heat=True cfg=F'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 16:24:58', 'EBURNER', 'relay:igniter', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False cfg=F'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 16:24:58', 'EBURNER', 'relay:circulation_a', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False cfg=F'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 16:24:58', 'EBURNER', 'relay:circulation_b', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False cfg=F'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 16:24:58', 'EBURNER', 'burner:mode=F', 'fire_setpoint', 44.000, '°C', 'avg=NoneC outside=10.0C want_heat=False cfg=F'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 16:33:10', 'EBURNER', 'relay:igniter', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 16:33:10', 'EBURNER', 'relay:circulation_a', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 16:33:10', 'EBURNER', 'relay:circulation_b', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 16:33:10', 'EBURNER', 'burner:mode=F', 'fire_setpoint', 44.000, '°C', 'avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 17:19:04', 'EBURNER', 'relay:igniter', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 17:19:04', 'EBURNER', 'relay:circulation_a', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 17:19:04', 'EBURNER', 'relay:circulation_b', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 17:19:04', 'EBURNER', 'burner:mode=F', 'fire_setpoint', 44.000, '°C', 'avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 17:24:01', 'EBURNER', 'relay:igniter', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 17:24:01', 'EBURNER', 'relay:circulation_a', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 17:24:01', 'EBURNER', 'relay:circulation_b', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 17:24:01', 'EBURNER', 'burner:mode=F', 'fire_setpoint', 44.000, '°C', 'avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 17:50:28', 'EBURNER', 'relay:igniter', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 17:50:28', 'EBURNER', 'relay:circulation_a', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 17:50:28', 'EBURNER', 'relay:circulation_b', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 17:50:28', 'EBURNER', 'burner:mode=F', 'fire_setpoint', 44.000, '°C', 'avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 17:51:30', 'EBURNER', 'relay:igniter', 'state', 1.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=True'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 17:51:30', 'EBURNER', 'relay:circulation_a', 'state', 1.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=True'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 17:51:30', 'EBURNER', 'relay:circulation_b', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=True'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 17:51:30', 'EBURNER', 'burner:mode=F', 'fire_setpoint', 44.000, '°C', 'avg=NoneC outside=10.0C want_heat=True'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 17:58:59', 'EBURNER', 'relay:igniter', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 17:58:59', 'EBURNER', 'relay:circulation_a', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 17:58:59', 'EBURNER', 'relay:circulation_b', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 17:58:59', 'EBURNER', 'burner:mode=F', 'fire_setpoint', 44.000, '°C', 'avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 18:09:40', 'EBURNER', 'relay:igniter', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 18:09:40', 'EBURNER', 'relay:circulation_a', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 18:09:40', 'EBURNER', 'relay:circulation_b', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 18:09:40', 'EBURNER', 'burner:mode=F', 'fire_setpoint', 44.000, '°C', 'avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 18:23:41', 'EBURNER', 'relay:igniter', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 18:23:41', 'EBURNER', 'relay:circulation_a', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 18:23:41', 'EBURNER', 'relay:circulation_b', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 18:23:41', 'EBURNER', 'burner:mode=F', 'fire_setpoint', 44.000, '°C', 'avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 18:37:24', 'EBURNER', 'relay:igniter', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 18:37:24', 'EBURNER', 'relay:circulation_a', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 18:37:24', 'EBURNER', 'relay:circulation_b', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 18:37:24', 'EBURNER', 'burner:mode=F', 'fire_setpoint', 44.000, '°C', 'avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 18:43:05', 'EBURNER', 'relay:igniter', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 18:43:05', 'EBURNER', 'relay:circulation_a', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 18:43:05', 'EBURNER', 'relay:circulation_b', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 18:43:05', 'EBURNER', 'burner:mode=F', 'fire_setpoint', 44.000, '°C', 'avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 19:00:16', 'EBURNER', 'relay:igniter', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 19:00:16', 'EBURNER', 'relay:circulation_a', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 19:00:16', 'EBURNER', 'relay:circulation_b', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 19:00:16', 'EBURNER', 'burner:mode=F', 'fire_setpoint', 44.000, '°C', 'avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 19:02:44', 'EBURNER', 'relay:igniter', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 19:02:44', 'EBURNER', 'relay:circulation_a', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 19:02:44', 'EBURNER', 'relay:circulation_b', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 19:02:44', 'EBURNER', 'burner:mode=F', 'fire_setpoint', 44.000, '°C', 'avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 19:03:45', 'EBURNER', 'relay:igniter', 'state', 1.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=True'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 19:03:45', 'EBURNER', 'relay:circulation_a', 'state', 1.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=True'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 19:03:45', 'EBURNER', 'relay:circulation_b', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=True'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 19:03:45', 'EBURNER', 'burner:mode=F', 'fire_setpoint', 44.000, '°C', 'avg=NoneC outside=10.0C want_heat=True'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 19:10:53', 'EBURNER', 'relay:igniter', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 19:10:53', 'EBURNER', 'relay:circulation_a', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 19:10:53', 'EBURNER', 'relay:circulation_b', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 19:10:53', 'EBURNER', 'burner:mode=F', 'fire_setpoint', 44.000, '°C', 'avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 19:23:45', 'EBURNER', 'relay:igniter', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 19:23:45', 'EBURNER', 'relay:circulation_a', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 19:23:45', 'EBURNER', 'relay:circulation_b', 'state', 0.000, 'bool', 'F:avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 19:23:45', 'EBURNER', 'burner:mode=F', 'fire_setpoint', 44.000, '°C', 'avg=NoneC outside=10.0C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 19:50:54', 'EBURNER', 'relay:igniter', 'state', 0.000, 'bool', 'F:avg=NoneC outside_raw=22.312C used=19.312C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 19:50:54', 'EBURNER', 'relay:circulation_a', 'state', 0.000, 'bool', 'F:avg=NoneC outside_raw=22.312C used=19.312C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 19:50:54', 'EBURNER', 'relay:circulation_b', 'state', 0.000, 'bool', 'F:avg=NoneC outside_raw=22.312C used=19.312C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 19:50:54', 'EBURNER', 'burner:mode=F', 'fire_setpoint', 35.000, '°C', 'avg=NoneC outside_raw=22.312C used=19.312C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 20:11:38', 'EBURNER', 'relay:igniter', 'state', 0.000, 'bool', 'F:avg=NoneC outside_raw=22.687C used=19.687C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 20:11:38', 'EBURNER', 'relay:circulation_a', 'state', 0.000, 'bool', 'F:avg=NoneC outside_raw=22.687C used=19.687C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 20:11:38', 'EBURNER', 'relay:circulation_b', 'state', 0.000, 'bool', 'F:avg=NoneC outside_raw=22.687C used=19.687C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 20:11:38', 'EBURNER', 'burner:mode=F', 'fire_setpoint', 35.000, '°C', 'avg=NoneC outside_raw=22.687C used=19.687C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 20:51:02', 'EBURNER', 'relay:igniter', 'state', 0.000, 'bool', 'F:avg=NoneC outside_raw=22.875C used=19.875C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 20:51:02', 'EBURNER', 'relay:circulation_a', 'state', 0.000, 'bool', 'F:avg=NoneC outside_raw=22.875C used=19.875C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 20:51:02', 'EBURNER', 'relay:circulation_b', 'state', 0.000, 'bool', 'F:avg=NoneC outside_raw=22.875C used=19.875C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 20:51:02', 'EBURNER', 'burner:mode=F', 'fire_setpoint', 35.000, '°C', 'avg=NoneC outside_raw=22.875C used=19.875C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 20:51:16', 'EBURNER', 'relay:igniter', 'state', 0.000, 'bool', 'F:avg=NoneC outside_raw=22.812C used=14.073406354148933C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 20:51:16', 'EBURNER', 'relay:circulation_a', 'state', 0.000, 'bool', 'F:avg=NoneC outside_raw=22.812C used=14.073406354148933C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 20:51:16', 'EBURNER', 'relay:circulation_b', 'state', 0.000, 'bool', 'F:avg=NoneC outside_raw=22.812C used=14.073406354148933C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 20:51:16', 'EBURNER', 'burner:mode=F', 'fire_setpoint', 39.000, '°C', 'avg=NoneC outside_raw=22.812C used=14.073406354148933C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 20:57:22', 'EBURNER', 'relay:igniter', 'state', 0.000, 'bool', 'F:avg=NoneC outside_raw=22.875C used=19.875C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 20:57:22', 'EBURNER', 'relay:circulation_a', 'state', 0.000, 'bool', 'F:avg=NoneC outside_raw=22.875C used=19.875C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 20:57:22', 'EBURNER', 'relay:circulation_b', 'state', 0.000, 'bool', 'F:avg=NoneC outside_raw=22.875C used=19.875C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 20:57:22', 'EBURNER', 'burner:mode=F', 'fire_setpoint', 35.000, '°C', 'avg=NoneC outside_raw=22.875C used=19.875C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 20:57:36', 'EBURNER', 'relay:igniter', 'state', 0.000, 'bool', 'F:avg=NoneC outside_raw=22.875C used=14.07425982530695C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 20:57:36', 'EBURNER', 'relay:circulation_a', 'state', 0.000, 'bool', 'F:avg=NoneC outside_raw=22.875C used=14.07425982530695C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 20:57:36', 'EBURNER', 'relay:circulation_b', 'state', 0.000, 'bool', 'F:avg=NoneC outside_raw=22.875C used=14.07425982530695C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 20:57:36', 'EBURNER', 'burner:mode=F', 'fire_setpoint', 39.000, '°C', 'avg=NoneC outside_raw=22.875C used=14.07425982530695C want_heat=False'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 21:09:00', 'EBURNER', 'relay:igniter', 'state', 1.000, 'bool', 'F:avg=NoneC outside_raw=10.0C used=7.0C want_heat=True'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 21:09:00', 'EBURNER', 'relay:circulation_a', 'state', 1.000, 'bool', 'F:avg=NoneC outside_raw=10.0C used=7.0C want_heat=True'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 21:09:00', 'EBURNER', 'relay:circulation_b', 'state', 0.000, 'bool', 'F:avg=NoneC outside_raw=10.0C used=7.0C want_heat=True'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 21:09:00', 'EBURNER', 'burner:mode=F', 'fire_setpoint', 45.000, '°C', 'avg=NoneC outside_raw=10.0C used=7.0C want_heat=True'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 21:41:46', 'EBURNER', 'relay:igniter', 'state', 1.000, 'bool', 'F:avg=NoneC outside_raw=10.0C used=7.0C want_heat=True'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 21:41:46', 'EBURNER', 'relay:circulation_a', 'state', 1.000, 'bool', 'F:avg=NoneC outside_raw=10.0C used=7.0C want_heat=True'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 21:41:46', 'EBURNER', 'relay:circulation_b', 'state', 0.000, 'bool', 'F:avg=NoneC outside_raw=10.0C used=7.0C want_heat=True'); +INSERT INTO eburner_log (ts, app, source, event_type, value, unit, extra) VALUES ('2025-11-22 21:41:46', 'EBURNER', 'burner:mode=F', 'fire_setpoint', 45.000, '°C', 'avg=NoneC outside_raw=10.0C used=7.0C want_heat=True'); diff --git a/listtilda.sh b/listtilda.sh new file mode 100644 index 0000000..a6a908b --- /dev/null +++ b/listtilda.sh @@ -0,0 +1 @@ +find . -type f -name '*~' diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b9705eb --- /dev/null +++ b/requirements.txt @@ -0,0 +1,23 @@ +# ebuild – Raspberry Pi 2 runtime dependencies +# +# Not: Bazı paketler Raspbian üzerinde zaten sistem paketi olarak gelebilir. +# Buradaki liste, tipik bir Python 3 kurulumunda pip ile yüklenebilecek +# kütüphaneleri içerir. + +RPi.GPIO>=0.7.0 +gpiozero>=1.6.2 + +# DHT11 / DHT22 ve benzeri sensörler için +adafruit-circuitpython-dht>=4.0.0 +Adafruit-Blinka>=8.0.0 + +# MCP3008 vb. SPI ADC için +adafruit-circuitpython-mcp3xxx>=1.0.0 + +# Zaman / saat dilimi vb. yardımcılar +pytz>=2024.1 + +# İleride log işleme / debug kolaylığı için (opsiyonel) +psutil>=5.9.0 +astral +holidays diff --git a/run_burner.sh b/run_burner.sh new file mode 100644 index 0000000..9dfeffb --- /dev/null +++ b/run_burner.sh @@ -0,0 +1 @@ +python3 -m ebuild.core.systems.burner diff --git a/scripts/run_ebuild_main.py b/scripts/run_ebuild_main.py new file mode 100644 index 0000000..adffb9b --- /dev/null +++ b/scripts/run_ebuild_main.py @@ -0,0 +1,7 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Komut satırından ebuild.runtime.main modülünü çalıştırmak için yardımcı script.""" +from ebuild.runtime.main import main + +if __name__ == "__main__": + main() diff --git a/tail_burner.sh b/tail_burner.sh new file mode 100644 index 0000000..58dddce --- /dev/null +++ b/tail_burner.sh @@ -0,0 +1 @@ +tail -f /var/log/syslog | grep BRULOR diff --git a/tests/__pycache__/relay_test.cpython-39.pyc b/tests/__pycache__/relay_test.cpython-39.pyc new file mode 100644 index 0000000..6939490 Binary files /dev/null and b/tests/__pycache__/relay_test.cpython-39.pyc differ diff --git a/tests/test_building.py b/tests/test_building.py new file mode 100644 index 0000000..f21212e --- /dev/null +++ b/tests/test_building.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +"""Building ile ilgili temel test iskeleti.""" + +def test_placeholder(): + assert True