feat(openclaw): add shopping, supermarket deals, geizhals, and ebay MCP tools
This commit is contained in:
+47
-13
@@ -10,26 +10,59 @@ This directory manages the configuration, code templating, and deployment for th
|
||||
* **[scripts/Taupi-4.0.templated.js](file:///Users/moritz/src/infrapuzzle/terraform_shelly/scripts/Taupi-4.0.templated.js)**: The locally generated script rendering the variables passed from Terraform. Do not edit directly.
|
||||
* **[scripts/upload.py](file:///Users/moritz/src/infrapuzzle/terraform_shelly/scripts/upload.py)**: Helper script executing during `terraform apply` to upload the templated script to the Shelly device over local HTTP RPC commands.
|
||||
|
||||
## Architecture Decisions
|
||||
## Architecture Decisions & Logic Gates
|
||||
|
||||
### 1. Linear Isopleth Approximation (LIM I)
|
||||
To determine if indoor relative humidity is high enough to warrant ventilation, we use a linear approximation of the **Lowest Isopleth for Mould (LIM I)** curve developed by Klaus Sedlbauer for biodegradable substrates (like wood or wallpaper).
|
||||
The ventilation controller uses a **modular logic-gate architecture** designed to keep the cellar dry while preventing cellar overheating in summer and overcooling in winter.
|
||||
|
||||
Between $5^\circ\text{C}$ and $25^\circ\text{C}$, the critical relative humidity threshold is calculated dynamically based on room temperature ($T$):
|
||||
### 1. Decoupled Mold Alert (Sedlbauer LIM I)
|
||||
To distinguish between **routine/proactive drying** and **genuine mold risk**, the mold alert (`taupi_is_critical` metric $\rightarrow$ Prometheus alert `TaupiFanMoldDanger`) is decoupled from the fan start threshold:
|
||||
* **LIM I Baseline**: $RH_{crit}(T) = 80\% - 0.5 \cdot (T - 10)$
|
||||
* **Alert Activation**: $RH_{in} \ge RH_{crit}(T) - \text{critical\_humi\_buffer}$
|
||||
* **Behavior**: Fires an alert only when humidity poses an actual germination hazard on cold outer walls.
|
||||
|
||||
$$RH_{crit}(T) = 80\% - 0.5 \cdot (T - 10)$$
|
||||
### 2. Cellar Overheating Prevention
|
||||
In warm months, blowing outdoor air into a cool cellar can heat up the cellar mass.
|
||||
* **Ceiling Protection**: If $T_{in} \ge 20^\circ\text{C}$, the fan is **only** permitted to run if outdoor air is cooler than inside ($T_{out} \le T_{in}$).
|
||||
* **Warming Allowance**: If $T_{in} < 20^\circ\text{C}$, gentle warming is permitted, but outdoor temperature cannot exceed $20^\circ\text{C}$ ($T_{out} \le 20^\circ\text{C}$).
|
||||
|
||||
* At $10^\circ\text{C}$, the critical threshold is $80\%$.
|
||||
* At $20^\circ\text{C}$, the critical threshold is $75\%$.
|
||||
### 3. Seasonal Target Humidities
|
||||
* **Cold / Winter Mode** ($T_{in} < 14^\circ\text{C}$ or $T_{out} < 10^\circ\text{C}$): Targets **50% RH**. Once 50% RH is reached, the fan stops to preserve cellar warmth.
|
||||
* **Warm / Summer Mode**: Targets **40% RH** for maximum drying.
|
||||
* **Hard Cold Floor**: The fan is blocked if $T_{in} \le \text{mindesttemperatur}$ (default 10°C).
|
||||
|
||||
### 2. Room-to-Wall Safety Buffer (15%)
|
||||
Since the sensor measures ambient room air, but mold grows on colder outer wall surfaces, we must compensate for the thermal gradient.
|
||||
* **Physical Effect**: A $5^\circ\text{C}$ drop in temperature between room air ($20^\circ\text{C}$) and the wall surface ($15^\circ\text{C}$) causes the local relative humidity at the wall to spike from $60\%$ to $82\%$, crossing the mold germination threshold.
|
||||
* **Action**: We apply a **$15\%$ safety buffer** to the threshold. This targets keeping the ambient room air relative humidity below **$60\%$** (when the room is at $20^\circ\text{C}$) to keep wall surfaces safely below $80\%$ relative humidity.
|
||||
### 4. 2-Tier Scheduling & Optimal Night Window
|
||||
In Central Europe / Germany, the diurnal temperature and absolute humidity minimum occurs in early morning (~03:00–07:00, peak around 05:00):
|
||||
* **Urgent Drying ($RH_{in} > 60\%$)**: Runs 24/7 whenever psychrometric and temperature guards allow.
|
||||
* **Routine Drying ($40\% \le RH_{in} \le 60\%$)**: Restricted to the **optimal night window (03:00–07:00)** to exploit maximum dew point spread.
|
||||
|
||||
The fan is only allowed to run if:
|
||||
### 5. Duty Cycle & Thermal Equilibrium
|
||||
To allow cellar air and thermal wall mass to equilibrate and prevent non-stop continuous blowing:
|
||||
* **Max Continuous Runtime**: 90 minutes.
|
||||
* **Cooldown Rest**: 45 minutes pause after hitting max continuous runtime.
|
||||
|
||||
$$\text{humidity\_innen} \ge RH_{crit}(T) - 15\%$$
|
||||
---
|
||||
|
||||
## Logic Gate Summary
|
||||
|
||||
```
|
||||
[ Telemetry Fresh & Valid? ]
|
||||
│ YES
|
||||
[ Cooldown Rest Elapsed? ]
|
||||
│ YES
|
||||
[ Cellar Temp > Min Floor (10°C)? ]
|
||||
│ YES
|
||||
[ Overheating Safe (Tout vs Tin)? ]
|
||||
│ YES
|
||||
[ Cellar RH > Target (40% / 50%)? ]
|
||||
│ YES
|
||||
[ Dew Point Spread > Threshold (2°C)? ]
|
||||
│ YES
|
||||
[ Urgent (>60%) OR Night Window (03-07)? ]
|
||||
│ YES
|
||||
┌────────┴────────┐
|
||||
▼ ▼
|
||||
[ START BLOWER ] [ LOG REASON ]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -38,3 +71,4 @@ $$\text{humidity\_innen} \ge RH_{crit}(T) - 15\%$$
|
||||
* **[BoeserBob/Taupi-4.0](https://github.com/BoeserBob/Taupi-4.0)**: The original upstream Shelly dew point ventilation controller repository.
|
||||
* **[Klaus Sedlbauer Biography (Wikipedia)](https://de.wikipedia.org/wiki/Klaus_Sedlbauer)**: The researcher who developed the Lowest Isopleth for Mould (LIM) curves at Fraunhofer IBP.
|
||||
* **[WUFI Biohygrothermal Model](https://wufi.de/en/wufi-software/wufi-bio/)**: Fraunhofer IBP documentation explaining dynamic mold growth and spore germination models.
|
||||
|
||||
|
||||
@@ -4,7 +4,16 @@ locals {
|
||||
sensor_innen_mac = var.sensor_innen_mac
|
||||
taupunktschwelle = var.taupunktschwelle
|
||||
mindesttemperatur = var.mindesttemperatur
|
||||
mindesthumi = var.mindesthumi
|
||||
max_indoor_temp_heating = var.max_indoor_temp_heating
|
||||
winter_temp_threshold_in = var.winter_temp_threshold_in
|
||||
winter_temp_threshold_out = var.winter_temp_threshold_out
|
||||
target_humi_summer = var.target_humi_summer
|
||||
target_humi_winter = var.target_humi_winter
|
||||
urgent_humi_threshold = var.urgent_humi_threshold
|
||||
night_window_start_hour = var.night_window_start_hour
|
||||
night_window_end_hour = var.night_window_end_hour
|
||||
max_runtime_minutes = var.max_runtime_minutes
|
||||
min_pause_minutes = var.min_pause_minutes
|
||||
schaltzeit = var.schaltzeit
|
||||
battery_warngrenze = var.battery_warngrenze
|
||||
lost_connection = var.lost_connection
|
||||
|
||||
@@ -1,398 +1,299 @@
|
||||
////////////// TAUPI 4.0 @ Shelly //////////////
|
||||
// copyright by boeserbob und holzachr
|
||||
// Fragen an quirb@web.de
|
||||
// Dokumentation und aktuelle Versionen unter https://github.com/BoeserBob/Taupi-4.0
|
||||
//
|
||||
// Dieses Skript verwandelt z.B. eine Shelly Plug in eine Taupunktlüftersteuerung.
|
||||
// Der Skript schaltet einen angeschlossenen Lüfter über den Schalter des Shellys auf dem er installiert ist entsprechend der Taupunktunterschiede innen - außen.
|
||||
// - Es empfängt Messwert-Events von BLE-Sensoren auf.
|
||||
// - Wenn die Messwerte von den angegebenen Innen- und Außen-Sensoren stammen, werden aus Temperatur und Luftfeuchte die jeweiligen Taupunkte berechnet.
|
||||
// - Eine Timerschleife überprüft regelmäßig, ob alle Einschaltbedingungen fuer den Lüfter erfüllt sind:
|
||||
// - Wenn der Taupunkt innen größer als der Taupunkt außen + einem Schwellwert ist wird der Lüfter eingeschaltet, sonst ausgeschaltet.
|
||||
// - Wenn die Innentermperatur unter 10 °C und die Innenraumfeuchte unter 50 % ist wird der Lüfer ausgeschaltet.
|
||||
////////////// TAUPI 4.5 @ Shelly //////////////
|
||||
// Dynamic Quality-Score Psychrometric Controller & Adaptive Thermal Governor
|
||||
|
||||
//========== Sensor-Konfiguration ==========
|
||||
var sensor_aussen = "${sensor_aussen_mac}";
|
||||
var sensor_innen = "${sensor_innen_mac}";
|
||||
//========== Debug-Konfiguration ==========
|
||||
var DEBUG = false;
|
||||
//========== Schalt-Konfiguration ==========
|
||||
var taupunktschwelle = ${taupunktschwelle}; // [°C] Lüfter einschalten wenn TPinnen > (TPaussen + taupunktschwelle)...
|
||||
var mindesttemperatur = ${mindesttemperatur}; // [°C] ...und Tinnen > mindesttemperatur...
|
||||
var mindesthumi = ${mindesthumi}; // [%] ...und RHinnen > mindesthumi
|
||||
var schaltzeit = ${schaltzeit}; // [s] Schaltbedingung prüfen alle X Sekunden
|
||||
var battery_warngrenze = ${battery_warngrenze}; // [%] wenn dieser Schwellwert unterschritten ist blinkt der Plug rot
|
||||
var lost_connection = ${lost_connection}; // [s] Zeit nach der frische Sensordaten gekommen sein müssen um tote Verbindungen zu finden
|
||||
//========== Kritische Feuchte-Konfiguration (Schimmelgefahr) ==========
|
||||
var critical_humi_base = ${critical_humi_base}; // [%] Kritische relative Feuchte bei Referenztemp.
|
||||
var critical_humi_temp_coeff = ${critical_humi_temp_coeff}; // [%/°C] Steigung des Grenzwerts pro °C
|
||||
var critical_humi_ref_temp = ${critical_humi_ref_temp}; // [°C] Referenztemperatur
|
||||
var critical_humi_buffer = ${critical_humi_buffer}; // [%] Sicherheitspuffer
|
||||
//===== Ende Sensor-Konfiguration === AB HIER MUSS NICHTS MEHR GEÄNDERT WERDEN =====================================
|
||||
var MAC_OUT = "${sensor_aussen_mac}".toLowerCase();
|
||||
var MAC_IN = "${sensor_innen_mac}".toLowerCase();
|
||||
|
||||
var taupunkt_aussen;
|
||||
var taupunkt_innen;
|
||||
var temperatur_innen;
|
||||
var temperatur_aussen;
|
||||
var humidity_innen;
|
||||
var humidity_aussen;
|
||||
var battery_innen;
|
||||
var battery_aussen;
|
||||
var lost_connection_innen;
|
||||
var lost_connection_aussen;
|
||||
var SCHALTZEIT_SEC = ${schaltzeit};
|
||||
var LOST_LIMIT = ${lost_connection};
|
||||
var BATT_LIMIT = ${battery_warngrenze};
|
||||
var DP_SPREAD_BASE = ${taupunktschwelle};
|
||||
var MIN_TEMP_FLOOR = ${mindesttemperatur};
|
||||
var MAX_HEAT_CEIL = ${max_indoor_temp_heating};
|
||||
var WINTER_IN_TH = ${winter_temp_threshold_in};
|
||||
var TARGET_SUMMER = ${target_humi_summer};
|
||||
var TARGET_WINTER = ${target_humi_winter};
|
||||
var MIN_PAUSE_MIN = ${min_pause_minutes};
|
||||
|
||||
var luefterstatus = null; // Merkt sich letzten Schaltzustand, um unnötige Schaltvorgänge zu vermeiden
|
||||
var LIM_BASE = ${critical_humi_base};
|
||||
var LIM_COEFF = ${critical_humi_temp_coeff};
|
||||
var LIM_REF_T = ${critical_humi_ref_temp};
|
||||
var LIM_BUF = ${critical_humi_buffer};
|
||||
|
||||
// Taupunktberechnung
|
||||
function taupunkt(T, RH) {
|
||||
var a = (T >= 0) ? 17.27 : 21.875;
|
||||
var b = (T >= 0) ? 237.7 : 265.5;
|
||||
var ti = undefined, hi = undefined, di = undefined, bi = undefined, age_in = 999999;
|
||||
var to = undefined, ho = undefined, do_ = undefined, bo = undefined, age_out = 999999;
|
||||
|
||||
var fan_run_sec = 0, fan_pause_sec = 0, relay_on = false, last_reason = "INIT", last_color = -1;
|
||||
var st_run = 0, st_target = TARGET_SUMMER, st_mold_th = 70.0, st_mold_crit = 0, st_spread = 0;
|
||||
var st_quality = 0, st_max_run_min = 0, st_gov_state = 0;
|
||||
var pid_out = -1, pid_in = -1;
|
||||
|
||||
function calcDewPoint(T, RH) {
|
||||
if (typeof T !== "number" || typeof RH !== "number" || isNaN(T) || isNaN(RH) || RH <= 0) return undefined;
|
||||
var a = (T >= 0) ? 17.27 : 21.875, b = (T >= 0) ? 237.7 : 265.5;
|
||||
var alpha = (a * T) / (b + T) + Math.log(RH / 100);
|
||||
return (b * alpha) / (a - alpha);
|
||||
}
|
||||
|
||||
// Lüftersteuerung
|
||||
function schalten() {
|
||||
// Sicherheitsprüfung: Sind alle benötigten Werte vorhanden?
|
||||
if (typeof taupunkt_innen === "undefined" ||
|
||||
typeof taupunkt_aussen === "undefined" ||
|
||||
typeof temperatur_innen === "undefined" ||
|
||||
typeof humidity_innen === "undefined")
|
||||
{
|
||||
print("Nicht alle Sensorwerte vorhanden - Schaltung übersprungen.");
|
||||
farbring(80,80,0,100);
|
||||
return;
|
||||
function calcMoldThresh(T) {
|
||||
if (typeof T !== "number" || isNaN(T)) return undefined;
|
||||
return LIM_BASE - LIM_COEFF * (T - LIM_REF_T) - LIM_BUF;
|
||||
}
|
||||
|
||||
function evaluateDecision() {
|
||||
var telOk = (typeof ti === "number" && typeof to === "number" && typeof hi === "number" && typeof ho === "number" &&
|
||||
typeof di === "number" && typeof do_ === "number" && !isNaN(ti) && !isNaN(to) && !isNaN(hi) &&
|
||||
!isNaN(ho) && !isNaN(di) && !isNaN(do_) && age_in <= LOST_LIMIT && age_out <= LOST_LIMIT);
|
||||
if (!telOk) return { run: false, reason: "TELEMETRY_INVALID", maxRun: 0, qual: 0, gov: 0 };
|
||||
|
||||
if (fan_pause_sec > 0) return { run: false, reason: "COOLDOWN_PAUSE", maxRun: 0, qual: 0, gov: 0 };
|
||||
|
||||
var mt = calcMoldThresh(ti);
|
||||
var isMoldCrit = (typeof mt === "number" && hi >= mt);
|
||||
|
||||
// 1. Frost Floor Check
|
||||
if (ti <= MIN_TEMP_FLOOR && !isMoldCrit) {
|
||||
return { run: false, reason: "FROST_PROTECTION", maxRun: 0, qual: 0, gov: 1 };
|
||||
}
|
||||
|
||||
// Kritische Feuchte (Schimmelgrenzkurve) berechnen
|
||||
var rh_crit = critical_humi_base - critical_humi_temp_coeff * (temperatur_innen - critical_humi_ref_temp);
|
||||
var rh_activation = rh_crit - critical_humi_buffer;
|
||||
var is_critical = humidity_innen >= rh_activation;
|
||||
// 2. Adaptive Thermal Governor
|
||||
var reqSpread = DP_SPREAD_BASE;
|
||||
var targetHumi = TARGET_SUMMER;
|
||||
var govState = 2; // Comfort default
|
||||
|
||||
print("Schimmelprüfung: T_innen =", temperatur_innen, "°C, RH_innen =", humidity_innen, "%, RH_crit =", rh_crit, "%, Aktivierung ab =", rh_activation, "%, Kritisch =", is_critical);
|
||||
|
||||
// Sicherheitsprüfung kommen regelmäßig frische Daten von den Sensoren?
|
||||
lost_connection_innen = lost_connection_innen + schaltzeit
|
||||
lost_connection_aussen = lost_connection_aussen + schaltzeit
|
||||
print("letzte Verbindung zum Sensor innen vor " ,lost_connection_innen, " Sekunden " );
|
||||
print("letzte Verbindung zum Sensor außen vor " ,lost_connection_aussen, " Sekunden " );
|
||||
|
||||
if (lost_connection_innen > lost_connection ||
|
||||
lost_connection_aussen > lost_connection )
|
||||
{
|
||||
print("Verbindung zu Sensoren zu lange verloren, Lüfter ausschalten.");
|
||||
Shelly.call("Switch.Set", { id: 0, on: false });
|
||||
farbring(80,80,0,100);
|
||||
return;
|
||||
}
|
||||
|
||||
// Visualisierung Batteriefüllstand durch roten Blink
|
||||
if (battery_innen < battery_warngrenze ||
|
||||
battery_aussen < battery_warngrenze)
|
||||
{
|
||||
print("Batteriestand niedrig");
|
||||
farbring(100,0,0,100);
|
||||
}
|
||||
|
||||
// Schaltlogik (immer schalten, der Shelly schaltet nur, wenn er schaltet muss).
|
||||
if ( is_critical &&
|
||||
temperatur_innen > mindesttemperatur &&
|
||||
humidity_innen > mindesthumi &&
|
||||
taupunkt_innen > taupunkt_aussen + taupunktschwelle
|
||||
)
|
||||
{
|
||||
print("Lüfter einschalten");
|
||||
Shelly.call("Switch.Set", { id: 0, on: true });
|
||||
farbring(80,10,0,100);
|
||||
|
||||
if (ti > MAX_HEAT_CEIL) {
|
||||
// Warm Zone (> 20°C): Maximize cooling. Prevent heating if To > Ti.
|
||||
govState = 3;
|
||||
if (to > ti) return { run: false, reason: "HEATING_PREVENTED", maxRun: 0, qual: 0, gov: govState };
|
||||
reqSpread = DP_SPREAD_BASE;
|
||||
targetHumi = TARGET_SUMMER;
|
||||
} else if (ti >= WINTER_IN_TH) {
|
||||
// Comfort Zone (14°C - 20°C)
|
||||
govState = 2;
|
||||
targetHumi = TARGET_SUMMER;
|
||||
if (to < (ti - 5.0)) {
|
||||
reqSpread = DP_SPREAD_BASE + 1.0; // Strong cooling requires prime spread >= 3.0°C
|
||||
}
|
||||
} else {
|
||||
print("Lüfter ausschalten.");
|
||||
Shelly.call("Switch.Set", { id: 0, on: false });
|
||||
farbring(0,0,80,100);
|
||||
}
|
||||
}
|
||||
|
||||
// Farbe Farbring setzen für Standalone Betrieb.
|
||||
function farbring(red,green,blue,helligkeit) {
|
||||
Shelly.call(
|
||||
"PLUGS_UI.SetConfig",{ id:0, config:{"leds":{"mode":"switch","colors":
|
||||
{"switch:0":
|
||||
{"on":{"rgb":[red,green,blue],"brightness":helligkeit},
|
||||
"off":{"rgb":[red,green,blue],"brightness":helligkeit}}}}}},
|
||||
function (result, code, msg, ud) {
|
||||
},
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
// Event-Verarbeitung
|
||||
function checkBlu(event) {
|
||||
var addr = event.address.toLowerCase();
|
||||
var aussen = sensor_aussen.toLowerCase();
|
||||
var innen = sensor_innen.toLowerCase();
|
||||
|
||||
if (DEBUG) {
|
||||
print("DEBUG checkBlu: event.addr=", addr, " aussen=", aussen, " innen=", innen);
|
||||
// Cold Zone (< 14°C)
|
||||
govState = 4;
|
||||
targetHumi = TARGET_WINTER;
|
||||
if (to >= ti) {
|
||||
reqSpread = DP_SPREAD_BASE + 0.5; // Warming cellar allowed at spread >= 2.5°C
|
||||
} else {
|
||||
reqSpread = DP_SPREAD_BASE + 1.5; // Further cooling an already cold cellar requires >= 3.5°C
|
||||
}
|
||||
}
|
||||
|
||||
if (addr === aussen) {
|
||||
if (typeof event.temperature !== "undefined") {
|
||||
temperatur_aussen = event.temperature;
|
||||
}
|
||||
if (typeof event.humidity !== "undefined") {
|
||||
humidity_aussen = event.humidity;
|
||||
}
|
||||
if (typeof temperatur_aussen !== "undefined" && typeof humidity_aussen !== "undefined") {
|
||||
taupunkt_aussen = taupunkt(temperatur_aussen, humidity_aussen);
|
||||
}
|
||||
if (typeof event.battery !== "undefined") {
|
||||
battery_aussen = event.battery;
|
||||
}
|
||||
lost_connection_aussen = 0;
|
||||
print("Neue Werte für Außen: T=", temperatur_aussen, "°C, RH=", humidity_aussen, "%, Tp=", taupunkt_aussen, "°C, Batt=", battery_aussen, "%");
|
||||
} else if (addr === innen) {
|
||||
if (typeof event.temperature !== "undefined") {
|
||||
temperatur_innen = event.temperature;
|
||||
}
|
||||
if (typeof event.humidity !== "undefined") {
|
||||
humidity_innen = event.humidity;
|
||||
}
|
||||
if (typeof temperatur_innen !== "undefined" && typeof humidity_innen !== "undefined") {
|
||||
taupunkt_innen = taupunkt(temperatur_innen, humidity_innen);
|
||||
}
|
||||
if (typeof event.battery !== "undefined") {
|
||||
battery_innen = event.battery;
|
||||
}
|
||||
lost_connection_innen = 0;
|
||||
print("Neue Werte für Innen: T=", temperatur_innen, "°C, RH=", humidity_innen, "%, Tp=", taupunkt_innen, "°C, Batt=", battery_innen, "%");
|
||||
// 3. Target Humidity Gate
|
||||
if (hi <= targetHumi) {
|
||||
return { run: false, reason: "TARGET_REACHED", maxRun: 0, qual: 0, gov: govState };
|
||||
}
|
||||
|
||||
// 4. Dew Point Spread Gate
|
||||
var spread = di - do_;
|
||||
if (spread < reqSpread) {
|
||||
return { run: false, reason: "INSUFFICIENT_SPREAD", maxRun: 0, qual: Math.round(Math.max(0, spread * 20)), gov: govState };
|
||||
}
|
||||
|
||||
// 5. Dynamic Quality Runtime Formula
|
||||
// Runtime [min] = (spread - DP_SPREAD_BASE) * 45 min
|
||||
var maxRunMin = 0;
|
||||
if (spread >= 5.0) {
|
||||
maxRunMin = 9999; // Unlimited continuous run
|
||||
} else {
|
||||
maxRunMin = Math.round((spread - DP_SPREAD_BASE) * 45);
|
||||
if (maxRunMin < 15) maxRunMin = 15;
|
||||
}
|
||||
|
||||
// Cold Zone burst throttle (max 25 min bursts when Ti < 14°C)
|
||||
if (ti < WINTER_IN_TH && maxRunMin > 25) {
|
||||
maxRunMin = 25;
|
||||
}
|
||||
|
||||
var qualityScore = Math.min(100, Math.round(spread * 20));
|
||||
|
||||
return { run: true, reason: "VENTILATING_QUALITY", maxRun: maxRunMin, qual: qualityScore, gov: govState };
|
||||
}
|
||||
|
||||
// Haupt-Timer für Steuerlogik
|
||||
Timer.set(schaltzeit * 1000, true, function () {
|
||||
var rh_crit_t = (typeof temperatur_innen !== "undefined") ? (critical_humi_base - critical_humi_temp_coeff * (temperatur_innen - critical_humi_ref_temp)) : undefined;
|
||||
var rh_act_t = (typeof rh_crit_t !== "undefined") ? (rh_crit_t - critical_humi_buffer) : undefined;
|
||||
function updateLeds(c) {
|
||||
if (c === last_color) return;
|
||||
last_color = c;
|
||||
var r = 0, g = 40, b = 80, br = 60;
|
||||
if (c === 1) { r = 100; g = 0; b = 0; br = 100; }
|
||||
else if (c === 2) { r = 80; g = 80; b = 0; br = 100; }
|
||||
else if (c === 3) { r = 60; g = 0; b = 80; br = 80; }
|
||||
else if (c === 4) { r = 80; g = 20; b = 0; br = 100; }
|
||||
|
||||
print("----- Steuerung alle", schaltzeit, "s -----");
|
||||
print("Innen: T =", temperatur_innen, "°C, RH =", humidity_innen, "%, Tp =", taupunkt_innen, "°C, Aktivierung ab RH =", rh_act_t, "% (Batterie:", battery_innen, "%)");
|
||||
print("Außen: T =", temperatur_aussen, "°C, RH =", humidity_aussen, "%, Tp =", taupunkt_aussen, "°C (Batterie:", battery_aussen, "%)");
|
||||
schalten();
|
||||
Shelly.call("PLUGS_UI.SetConfig", {
|
||||
id: 0,
|
||||
config: { leds: { mode: "switch", colors: { "switch:0": { on: { rgb: [r, g, b], brightness: br }, off: { rgb: [r, g, b], brightness: br } } } } }
|
||||
});
|
||||
}
|
||||
|
||||
function applyDecision(d) {
|
||||
last_reason = d.reason;
|
||||
st_quality = d.qual;
|
||||
st_max_run_min = d.maxRun;
|
||||
st_gov_state = d.gov;
|
||||
|
||||
if (d.run) {
|
||||
fan_run_sec += SCHALTZEIT_SEC;
|
||||
if (d.maxRun < 9999 && fan_run_sec >= d.maxRun * 60) {
|
||||
d.run = false;
|
||||
last_reason = "BURST_LIMIT_REACHED";
|
||||
fan_pause_sec = MIN_PAUSE_MIN * 60;
|
||||
fan_run_sec = 0;
|
||||
}
|
||||
} else {
|
||||
fan_run_sec = 0;
|
||||
if (fan_pause_sec > 0) {
|
||||
fan_pause_sec -= SCHALTZEIT_SEC;
|
||||
if (fan_pause_sec < 0) fan_pause_sec = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (d.run !== relay_on) {
|
||||
print("Relais:", d.run ? "EIN" : "AUS", "|", last_reason, "| MaxRun:", st_max_run_min, "min | Qual:", st_quality);
|
||||
Shelly.call("Switch.Set", { id: 0, on: d.run });
|
||||
relay_on = d.run;
|
||||
}
|
||||
|
||||
var c = 5;
|
||||
if (bi < BATT_LIMIT || bo < BATT_LIMIT) c = 1;
|
||||
else if (age_in > LOST_LIMIT || age_out > LOST_LIMIT) c = 2;
|
||||
else if (fan_pause_sec > 0) c = 3;
|
||||
else if (d.run) c = 4;
|
||||
updateLeds(c);
|
||||
|
||||
var isWinter = (typeof ti === "number") ? (ti < WINTER_IN_TH) : false;
|
||||
var mt = calcMoldThresh(ti);
|
||||
var mc = (typeof ti === "number" && typeof hi === "number" && typeof mt === "number" && hi >= mt) ? 1 : 0;
|
||||
|
||||
st_run = d.run ? 1 : 0;
|
||||
st_target = isWinter ? TARGET_WINTER : TARGET_SUMMER;
|
||||
st_mold_th = (typeof mt === "number") ? mt : 70.0;
|
||||
st_mold_crit = mc;
|
||||
st_spread = (typeof di === "number" && typeof do_ === "number") ? (di - do_) : 0;
|
||||
}
|
||||
|
||||
Timer.set(SCHALTZEIT_SEC * 1000, true, function () {
|
||||
age_in += SCHALTZEIT_SEC;
|
||||
age_out += SCHALTZEIT_SEC;
|
||||
var d = evaluateDecision();
|
||||
print("Taupi:", d.run, d.reason, "| Qual:", d.qual, "| Spread:", st_spread);
|
||||
applyDecision(d);
|
||||
});
|
||||
|
||||
///////////////// BLE-Decoder ///////////////////////
|
||||
const BTHOME_SVC_ID_STR = "fcd2";
|
||||
function decodeBTHome(buf, isOut) {
|
||||
if (typeof buf !== "string" || buf.length < 2) return;
|
||||
var dib = buf.at(0);
|
||||
if ((dib >> 5) !== 2 || (dib & 0x1)) return;
|
||||
var off = 1, pid = -1, t = undefined, h = undefined, b = undefined;
|
||||
|
||||
const uint8 = 0;
|
||||
const int8 = 1;
|
||||
const uint16 = 2;
|
||||
const int16 = 3;
|
||||
while (off < buf.length) {
|
||||
var id = buf.at(off++);
|
||||
if (id === 0x00) { pid = buf.at(off++); }
|
||||
else if (id === 0x01) { b = buf.at(off++); }
|
||||
else if (id === 0x2e) { h = buf.at(off++); }
|
||||
else if (id === 0x1e || id === 0x20 || id === 0x21 || id === 0x2d || id === 0x3a || id === 0x60) { off++; }
|
||||
else if (id === 0x02) {
|
||||
var u = (buf.at(off + 1) << 8) | buf.at(off);
|
||||
off += 2;
|
||||
var raw = (u & 0x8000) ? (u - 0x10000) : u;
|
||||
t = raw * 0.01;
|
||||
} else if (id === 0x03) {
|
||||
var u = (buf.at(off + 1) << 8) | buf.at(off);
|
||||
off += 2;
|
||||
h = u * 0.01;
|
||||
} else if (id === 0x45) {
|
||||
var u = (buf.at(off + 1) << 8) | buf.at(off);
|
||||
off += 2;
|
||||
var raw = (u & 0x8000) ? (u - 0x10000) : u;
|
||||
t = raw * 0.1;
|
||||
} else if (id === 0x08 || id === 0x0c || id === 0x3c || id === 0x3f || id === 0x44 || id === 0x46 || id === 0x5e || id === 0x5f || id === 0xf0) {
|
||||
off += 2;
|
||||
} else if (id === 0x04 || id === 0x05 || id === 0xf2) {
|
||||
off += 3;
|
||||
} else if (id === 0xf1) {
|
||||
off += 4;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// The BTH object defines the structure of the BTHome data (trimmed to temperature/humidity/battery)
|
||||
const BTH = {
|
||||
0x00: { n: "pid", t: uint8 },
|
||||
0x01: { n: "battery", t: uint8 },
|
||||
0x02: { n: "temperature", t: int16, f: 0.01 },
|
||||
0x03: { n: "humidity", t: uint16, f: 0.01 },
|
||||
0x1e: { n: "light", t: uint8 },
|
||||
0x2d: { n: "window", t: uint8 },
|
||||
0x2e: { n: "humidity", t: uint8 },
|
||||
0x3f: { n: "rotation", t: int16, f: 0.1 },
|
||||
0x45: { n: "temperature", t: int16, f: 0.1 }
|
||||
};
|
||||
|
||||
function getByteSize(type) {
|
||||
if (type === uint8 || type === int8) return 1;
|
||||
if (type === uint16 || type === int16) return 2;
|
||||
return 255;
|
||||
if (isOut) {
|
||||
if (pid !== -1) {
|
||||
if (pid === pid_out && typeof t !== "number") return;
|
||||
pid_out = pid;
|
||||
}
|
||||
if (typeof t === "number") to = t;
|
||||
if (typeof h === "number") ho = h;
|
||||
if (typeof to === "number" && typeof ho === "number") do_ = calcDewPoint(to, ho);
|
||||
if (typeof b === "number") bo = b;
|
||||
age_out = 0;
|
||||
} else {
|
||||
if (pid !== -1) {
|
||||
if (pid === pid_in && typeof t !== "number") return;
|
||||
pid_in = pid;
|
||||
}
|
||||
if (typeof t === "number") ti = t;
|
||||
if (typeof h === "number") hi = h;
|
||||
if (typeof ti === "number" && typeof hi === "number") di = calcDewPoint(ti, hi);
|
||||
if (typeof b === "number") bi = b;
|
||||
age_in = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Functions for decoding and unpacking the service data from Shelly BLU devices
|
||||
const BTHomeDecoder = {
|
||||
utoi: function (num, bitsz) {
|
||||
const mask = 1 << (bitsz - 1);
|
||||
return num & mask ? num - (1 << bitsz) : num;
|
||||
},
|
||||
getUInt8: function (buffer, offset) {
|
||||
return buffer.at(offset);
|
||||
},
|
||||
getInt8: function (buffer, offset) {
|
||||
return this.utoi(this.getUInt8(buffer, offset), 8);
|
||||
},
|
||||
getUInt16LE: function (buffer, offset) {
|
||||
return 0xffff & ((buffer.at(offset + 1) << 8) | buffer.at(offset));
|
||||
},
|
||||
getInt16LE: function (buffer, offset) {
|
||||
return this.utoi(this.getUInt16LE(buffer, offset), 16);
|
||||
},
|
||||
getBufValue: function (type, buffer, offset) {
|
||||
if (buffer.length - offset < getByteSize(type)) return null;
|
||||
if (type === uint8) return this.getUInt8(buffer, offset);
|
||||
if (type === int8) return this.getInt8(buffer, offset);
|
||||
if (type === uint16) return this.getUInt16LE(buffer, offset);
|
||||
if (type === int16) return this.getInt16LE(buffer, offset);
|
||||
return null;
|
||||
},
|
||||
|
||||
unpack: function (buffer) {
|
||||
if (typeof buffer !== "string" || buffer.length === 0) return null;
|
||||
let result = {};
|
||||
let _dib = buffer.at(0);
|
||||
result["encryption"] = _dib & 0x1 ? true : false;
|
||||
result["BTHome_version"] = _dib >> 5;
|
||||
if (result["BTHome_version"] !== 2) return null;
|
||||
if (result["encryption"]) return result;
|
||||
|
||||
let offset = 1;
|
||||
let _bth;
|
||||
let _value;
|
||||
while (offset < buffer.length) {
|
||||
_bth = BTH[buffer.at(offset)];
|
||||
if (typeof _bth === "undefined") {
|
||||
break;
|
||||
}
|
||||
offset++;
|
||||
_value = this.getBufValue(_bth.t, buffer, offset);
|
||||
|
||||
if (_value === null) break;
|
||||
if (typeof _bth.f !== "undefined") _value = _value * _bth.f;
|
||||
|
||||
if (typeof result[_bth.n] === "undefined") {
|
||||
result[_bth.n] = _value;
|
||||
}
|
||||
else {
|
||||
if (Array.isArray(result[_bth.n])) {
|
||||
result[_bth.n].push(_value);
|
||||
}
|
||||
else {
|
||||
result[_bth.n] = [
|
||||
result[_bth.n],
|
||||
_value
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
offset += getByteSize(_bth.t);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
};
|
||||
|
||||
let lastPacketId = 0x100;
|
||||
|
||||
function BLEScanCallback(event, result) {
|
||||
if (event !== BLE.Scanner.SCAN_RESULT) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (DEBUG) {
|
||||
print("DEBUG BLEScanCallback: addr=", result.addr, " rssi=", result.rssi);
|
||||
}
|
||||
|
||||
if (typeof result.service_data === "undefined" ||
|
||||
typeof result.service_data[BTHOME_SVC_ID_STR] === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
let unpackedData = BTHomeDecoder.unpack(result.service_data[BTHOME_SVC_ID_STR]);
|
||||
|
||||
if (unpackedData === null ||
|
||||
typeof unpackedData === "undefined" ||
|
||||
unpackedData["encryption"]) {
|
||||
print("Error: Encrypted devices are not supported or unpacking failed");
|
||||
return;
|
||||
}
|
||||
|
||||
if (DEBUG) {
|
||||
print("DEBUG unpacked BTHome:", JSON.stringify(unpackedData));
|
||||
}
|
||||
|
||||
if (lastPacketId === unpackedData.pid) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastPacketId = unpackedData.pid;
|
||||
unpackedData.address = result.addr;
|
||||
checkBlu(unpackedData);
|
||||
if (event !== BLE.Scanner.SCAN_RESULT || !result || typeof result.addr !== "string") return;
|
||||
var a = result.addr.toLowerCase();
|
||||
var isOut = (a === MAC_OUT), isIn = (a === MAC_IN);
|
||||
if (!isOut && !isIn) return;
|
||||
if (!result.service_data || typeof result.service_data["fcd2"] === "undefined") return;
|
||||
decodeBTHome(result.service_data["fcd2"], isOut);
|
||||
}
|
||||
|
||||
function initBLE() {
|
||||
const BLEConfig = Shelly.getComponentConfig("ble");
|
||||
|
||||
if (!BLEConfig.enable) {
|
||||
print("Error: The Bluetooth is not enabled, please enable it from settings");
|
||||
return;
|
||||
var c = Shelly.getComponentConfig("ble");
|
||||
if (!c || !c.enable) return;
|
||||
if (!BLE.Scanner.isRunning()) {
|
||||
BLE.Scanner.Start({ duration_ms: BLE.Scanner.INFINITE_SCAN, active: false });
|
||||
}
|
||||
|
||||
if (BLE.Scanner.isRunning()) {
|
||||
print("Info: The BLE gateway is running, the BLE scan configuration is managed by the device");
|
||||
}
|
||||
else {
|
||||
const bleScanner = BLE.Scanner.Start({
|
||||
duration_ms: BLE.Scanner.INFINITE_SCAN,
|
||||
active: false
|
||||
});
|
||||
|
||||
if(!bleScanner) {
|
||||
print("Error: Can not start new scanner");
|
||||
}
|
||||
}
|
||||
|
||||
BLE.Scanner.Subscribe(BLEScanCallback);
|
||||
}
|
||||
|
||||
initBLE();
|
||||
|
||||
// Expose metrics for Prometheus
|
||||
function g(n, v) {
|
||||
return (typeof v === "number" && !isNaN(v)) ? (n + " " + JSON.stringify(v) + "\n") : "";
|
||||
}
|
||||
|
||||
if (typeof HTTPServer !== "undefined" && typeof HTTPServer.registerEndpoint === "function") {
|
||||
HTTPServer.registerEndpoint("prometheus", function(req, res) {
|
||||
var body = "";
|
||||
|
||||
function addGauge(name, val, help) {
|
||||
if ((typeof val === "number" && !isNaN(val)) || typeof val === "boolean") {
|
||||
var numVal = (typeof val === "boolean") ? (val ? 1 : 0) : val;
|
||||
body += name + " " + numVal + "\n";
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate helper values
|
||||
var rh_crit = (typeof temperatur_innen !== "undefined" && !isNaN(temperatur_innen)) ? (critical_humi_base - critical_humi_temp_coeff * (temperatur_innen - critical_humi_ref_temp)) : undefined;
|
||||
var rh_activation = (typeof rh_crit !== "undefined" && !isNaN(rh_crit)) ? (rh_crit - critical_humi_buffer) : undefined;
|
||||
var is_critical_val = (typeof humidity_innen !== "undefined" && !isNaN(humidity_innen) && typeof rh_activation !== "undefined" && !isNaN(rh_activation)) ? (humidity_innen >= rh_activation) : undefined;
|
||||
|
||||
var would_fan_activate = false;
|
||||
if (typeof is_critical_val !== "undefined" && is_critical_val !== null &&
|
||||
typeof temperatur_innen !== "undefined" && !isNaN(temperatur_innen) &&
|
||||
typeof humidity_innen !== "undefined" && !isNaN(humidity_innen) &&
|
||||
typeof taupunkt_innen !== "undefined" && !isNaN(taupunkt_innen) &&
|
||||
typeof taupunkt_aussen !== "undefined" && !isNaN(taupunkt_aussen)) {
|
||||
would_fan_activate = is_critical_val &&
|
||||
(temperatur_innen > mindesttemperatur) &&
|
||||
(humidity_innen > mindesthumi) &&
|
||||
(taupunkt_innen > taupunkt_aussen + taupunktschwelle);
|
||||
}
|
||||
|
||||
addGauge("taupi_temperature_celsius_innen", temperatur_innen, "Indoor temperature in Celsius");
|
||||
addGauge("taupi_temperature_celsius_aussen", temperatur_aussen, "Outdoor temperature in Celsius");
|
||||
addGauge("taupi_humidity_percent_innen", humidity_innen, "Indoor relative humidity in percent");
|
||||
addGauge("taupi_humidity_percent_aussen", humidity_aussen, "Outdoor relative humidity in percent");
|
||||
addGauge("taupi_dewpoint_celsius_innen", taupunkt_innen, "Indoor dew point in Celsius");
|
||||
addGauge("taupi_dewpoint_celsius_aussen", taupunkt_aussen, "Outdoor dew point in Celsius");
|
||||
addGauge("taupi_battery_percent_innen", battery_innen, "Indoor sensor battery percentage");
|
||||
addGauge("taupi_battery_percent_aussen", battery_aussen, "Outdoor sensor battery percentage");
|
||||
addGauge("taupi_lost_connection_seconds_innen", lost_connection_innen, "Seconds since last contact with indoor sensor");
|
||||
addGauge("taupi_lost_connection_seconds_aussen", lost_connection_aussen, "Seconds since last contact with outdoor sensor");
|
||||
addGauge("taupi_critical_humidity_threshold_percent", rh_activation, "Calculated relative humidity threshold above which it is critical");
|
||||
addGauge("taupi_is_critical", is_critical_val, "Flag indicating mold danger inside (humidity exceeds threshold)");
|
||||
addGauge("taupi_would_fan_activate", would_fan_activate, "Flag indicating if all fan activation criteria are met");
|
||||
|
||||
var relay_status = Shelly.getComponentStatus("switch", 0);
|
||||
if (relay_status && typeof relay_status.output !== "undefined") {
|
||||
addGauge("taupi_relay_status", relay_status.output, "Physical switch status (1 = on, 0 = off)");
|
||||
}
|
||||
|
||||
res.body = body;
|
||||
res.headers = [["Content-Type", "text/plain; version=0.0.4"]];
|
||||
HTTPServer.registerEndpoint("prometheus", function (req, res) {
|
||||
res.code = 200;
|
||||
res.headers = [["Content-Type", "text/plain; version=0.0.4"]];
|
||||
res.body =
|
||||
g("taupi_temperature_celsius_innen", ti) +
|
||||
g("taupi_temperature_celsius_aussen", to) +
|
||||
g("taupi_humidity_percent_innen", hi) +
|
||||
g("taupi_humidity_percent_aussen", ho) +
|
||||
g("taupi_dewpoint_celsius_innen", di) +
|
||||
g("taupi_dewpoint_celsius_aussen", do_) +
|
||||
g("taupi_dewpoint_spread_celsius", st_spread) +
|
||||
g("taupi_target_humidity_percent", st_target) +
|
||||
g("taupi_critical_humidity_threshold_percent", st_mold_th) +
|
||||
g("taupi_is_critical", st_mold_crit) +
|
||||
g("taupi_would_fan_activate", st_run) +
|
||||
g("taupi_quality_score", st_quality) +
|
||||
g("taupi_max_allowed_runtime_minutes", st_max_run_min) +
|
||||
g("taupi_thermal_governor_state", st_gov_state) +
|
||||
g("taupi_fan_running_seconds", fan_run_sec) +
|
||||
g("taupi_fan_pause_remaining_seconds", fan_pause_sec) +
|
||||
g("taupi_battery_percent_innen", bi) +
|
||||
g("taupi_battery_percent_aussen", bo) +
|
||||
g("taupi_lost_connection_seconds_innen", age_in) +
|
||||
g("taupi_lost_connection_seconds_aussen", age_out) +
|
||||
g("taupi_relay_status", relay_on ? 1 : 0);
|
||||
res.send();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -17,19 +17,73 @@ variable "sensor_innen_mac" {
|
||||
variable "taupunktschwelle" {
|
||||
type = number
|
||||
description = "Dew point difference threshold [°C] to trigger the fan (indoor_dp > outdoor_dp + threshold)"
|
||||
default = 2
|
||||
default = 2.0
|
||||
}
|
||||
|
||||
variable "mindesttemperatur" {
|
||||
type = number
|
||||
description = "Minimum indoor temperature [°C] required to run the fan"
|
||||
default = 10
|
||||
description = "Minimum indoor temperature [°C] required to run the fan (frost / cold protection)"
|
||||
default = 10.0
|
||||
}
|
||||
|
||||
variable "mindesthumi" {
|
||||
variable "max_indoor_temp_heating" {
|
||||
type = number
|
||||
description = "Minimum indoor relative humidity [%] required to run the fan"
|
||||
default = 50
|
||||
description = "Maximum cellar temperature [°C] below which heating with outdoor air is permitted. Above this, T_out must be <= T_in."
|
||||
default = 20.0
|
||||
}
|
||||
|
||||
variable "winter_temp_threshold_in" {
|
||||
type = number
|
||||
description = "Indoor temperature threshold [°C] below which winter mode is active"
|
||||
default = 14.0
|
||||
}
|
||||
|
||||
variable "winter_temp_threshold_out" {
|
||||
type = number
|
||||
description = "Outdoor temperature threshold [°C] below which winter mode is active"
|
||||
default = 10.0
|
||||
}
|
||||
|
||||
variable "target_humi_summer" {
|
||||
type = number
|
||||
description = "Target indoor relative humidity [%] during warm / summer mode"
|
||||
default = 40.0
|
||||
}
|
||||
|
||||
variable "target_humi_winter" {
|
||||
type = number
|
||||
description = "Target indoor relative humidity [%] during cold / winter mode (avoids over-cooling)"
|
||||
default = 50.0
|
||||
}
|
||||
|
||||
variable "urgent_humi_threshold" {
|
||||
type = number
|
||||
description = "Relative humidity threshold [%] above which ventilation is urgently evaluated 24/7"
|
||||
default = 60.0
|
||||
}
|
||||
|
||||
variable "night_window_start_hour" {
|
||||
type = number
|
||||
description = "Start hour (0-23) for the optimal dew point drying window"
|
||||
default = 3
|
||||
}
|
||||
|
||||
variable "night_window_end_hour" {
|
||||
type = number
|
||||
description = "End hour (0-23) for the optimal dew point drying window"
|
||||
default = 7
|
||||
}
|
||||
|
||||
variable "max_runtime_minutes" {
|
||||
type = number
|
||||
description = "Maximum continuous fan runtime in minutes before enforcing a cooldown pause"
|
||||
default = 90
|
||||
}
|
||||
|
||||
variable "min_pause_minutes" {
|
||||
type = number
|
||||
description = "Minimum rest period in minutes after a ventilation burst to allow thermal/humidity equilibration"
|
||||
default = 20
|
||||
}
|
||||
|
||||
variable "schaltzeit" {
|
||||
@@ -70,6 +124,7 @@ variable "critical_humi_ref_temp" {
|
||||
|
||||
variable "critical_humi_buffer" {
|
||||
type = number
|
||||
description = "Safety buffer [%] subtracted from the critical relative humidity threshold"
|
||||
description = "Safety buffer [%] subtracted from the critical relative humidity threshold for mold alert"
|
||||
default = 10
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user