refactor(terraform): move shelly to projects/shelly_taupunktluefter and flatten core infra
This commit is contained in:
@@ -1,2 +1,3 @@
|
||||
.terraform
|
||||
|
||||
.terraform/
|
||||
crash.log
|
||||
crash.*.log
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Core Terraform Infrastructure Agent Documentation
|
||||
|
||||
This directory manages the core external cloud infrastructure, DNS zones, Authentik SSO, Google Cloud platform integrations, and Google Workspace configurations for Infrapuzzle.
|
||||
|
||||
## Key Files & Resources
|
||||
|
||||
* **[authentik.tf](file:///Users/moritz/src/infrapuzzle/terraform/authentik.tf)**: Provisions Authentik SSO flows, applications, groups, proxy outposts, OIDC providers, and custom styling with `haumdaucher-theme.css`.
|
||||
* **[authentik.secret.auto.tfvars](file:///Users/moritz/src/infrapuzzle/terraform/authentik.secret.auto.tfvars)**: Git-crypt encrypted token variables for authenticating with the cluster Authentik API.
|
||||
* **[haumdaucher-theme.css](file:///Users/moritz/src/infrapuzzle/terraform/haumdaucher-theme.css)**: Custom branded stylesheet injected into Authentik login and flow pages.
|
||||
* **[ovh.tf](file:///Users/moritz/src/infrapuzzle/terraform/ovh.tf)**: DNS zone and record definitions for `haumdaucher.de`, `moritzgraf.de`, and other domains.
|
||||
* **[gcp_apis.tf](file:///Users/moritz/src/infrapuzzle/terraform/gcp_apis.tf)**: Enables foundational Google Cloud APIs.
|
||||
* **[gcp_backups.tf](file:///Users/moritz/src/infrapuzzle/terraform/gcp_backups.tf)**: Provisions GCP service account and credentials for Velero Google Drive cluster backups (`k8s/velero/gdrive_sa.json.secret`).
|
||||
* **[gcp_calendar.tf](file:///Users/moritz/src/infrapuzzle/terraform/gcp_calendar.tf)**: Provisions GCP service account and credentials for OpenClaw Google Calendar integration (`k8s/openclaw/calendar_sa.json.secret`).
|
||||
* **[google_workspace.tf](file:///Users/moritz/src/infrapuzzle/terraform/google_workspace.tf)**: DNS records for Google Workspace (MX, SPF, DKIM, TXT verification).
|
||||
* **[versions.tf](file:///Users/moritz/src/infrapuzzle/terraform/versions.tf)**: Terraform provider versions and requirements.
|
||||
|
||||
## Usage & Execution
|
||||
|
||||
```bash
|
||||
# Initialize providers (OVH, Authentik, Google, Local, Random)
|
||||
terraform init
|
||||
|
||||
# Review execution plan
|
||||
terraform plan
|
||||
|
||||
# Apply infrastructure changes
|
||||
terraform apply
|
||||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Secrets in this directory (such as `authentik.secret.auto.tfvars`) and generated service account keys are encrypted using `git-crypt`. Ensure the repository is unlocked before planning or applying.
|
||||
@@ -0,0 +1,25 @@
|
||||
# Core Infrastructure (Terraform) ☁️
|
||||
|
||||
Declarative Infrastructure as Code managing external cloud services, DNS zones, and cluster identity.
|
||||
|
||||
## Managed Resources
|
||||
|
||||
- **OVH DNS**: Authoritative DNS zones and records (`haumdaucher.de`, `moritzgraf.de`, etc.).
|
||||
- **Authentik SSO**: Central IdP configuration, applications, proxy outposts, OIDC clients, and custom theme CSS.
|
||||
- **Google Cloud Platform**: Service accounts for Velero backups and OpenClaw calendar integration.
|
||||
- **Google Workspace**: Domain verification, MX, SPF, and DKIM DNS records.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Initialize Terraform
|
||||
terraform init
|
||||
|
||||
# Plan changes
|
||||
terraform plan
|
||||
|
||||
# Apply changes
|
||||
terraform apply
|
||||
```
|
||||
|
||||
For agent documentation and architecture guidelines, see [AGENTS.md](AGENTS.md).
|
||||
@@ -1,2 +0,0 @@
|
||||
.terraform
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
# Local .terraform directories
|
||||
**/.terraform/*
|
||||
|
||||
# .tfstate files
|
||||
*.tfstate
|
||||
*.tfstate.*
|
||||
|
||||
# Crash log files
|
||||
crash.log
|
||||
crash.*.log
|
||||
|
||||
# Exclude templated script outputs
|
||||
scripts/Taupi-4.0.templated.js
|
||||
Generated
-87
@@ -1,87 +0,0 @@
|
||||
# This file is maintained automatically by "terraform init".
|
||||
# Manual edits may be lost in future updates.
|
||||
|
||||
provider "registry.terraform.io/donrobo/shelly" {
|
||||
version = "0.4.1"
|
||||
constraints = "~> 0.4.1"
|
||||
hashes = [
|
||||
"h1:NxND2178u+OfQgiKtDr+m+IDCAHAyfrzQGM+TurzfcY=",
|
||||
"zh:494c3185a84cd9534da78e3a32b1523301835afdf9ada921675c7739f4565256",
|
||||
"zh:53597cbe0826b662933adddb65d7adfb952d0ea91c13d14aaa49c549273af671",
|
||||
"zh:5841856412803f8d5d6873aa1da61cb34efd6ce2f4a569f82c9edda078aa7b74",
|
||||
"zh:5a9835a6b7ef703b0e56a5cdd7b0843510561c25da366eb57c7758a0d90c8079",
|
||||
"zh:660251598da4c8196d88421eccd5db1451136165f800e41a0b11dbb722eabfe1",
|
||||
"zh:6d1e9b227d2f2088d25a90066137e2f0d93300028f00acf4b1febb80f333fb3f",
|
||||
"zh:80ac9b9ca8d1afd9615778e7cb20d66da24b98bdbc7d6ec41508b9414247a07f",
|
||||
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
|
||||
"zh:8cf57de63467e62a1378cb3a268adeb2489e7b654eeff3a57039b880d1fb9c95",
|
||||
"zh:9702ece653dc97c707c480ba25c9a7807f595a27887dbac032cec0972bff7f44",
|
||||
"zh:989ec7dcd21d2aec7f389aa1f3536c8501e2b5773f6c3b9ad9d6e5d8f232767e",
|
||||
"zh:aebef49c4efa03d05d2454041c670d5aa22f4707c62c685bd647437b0844ed7c",
|
||||
"zh:af2c5947276b00d514e7e94eda840e3382234b0ee1fc4a6f6990d704f6bf0e57",
|
||||
"zh:fdffcb420d380c80ca9464e9907e65c65600f0571e0081194caf4f4aae6460ca",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.terraform.io/hashicorp/external" {
|
||||
version = "2.4.0"
|
||||
constraints = "~> 2.3"
|
||||
hashes = [
|
||||
"h1:vvIsrZAD5eNoNtXvNcBUxkA9k2A4JcC8JTDFbM9d8gc=",
|
||||
"zh:0772afb42b658468ac5e15df33bf2080456f8f0b8ab163bfe9c50d2b2ea02135",
|
||||
"zh:0ac31a9aaa43dfcff5944b791596cdc94e153348e4bb4642282d034dff548134",
|
||||
"zh:32d8492b1bdcc956ca3c6d00c6392d0a83942ff11d4820c7ee63ca6796e06950",
|
||||
"zh:3c0482e894429f528ce6655a76ab0d8a9f7c0dacc6c828865e1515d4a7dbb852",
|
||||
"zh:61e68100b4db2f930b31491f23c602126382fd5e51252be1b551f0e17f8ddbee",
|
||||
"zh:6d60f615a0ad85eb962c9eb94f25e3eba7a72684ce276ba5dfb23f36b295a8f8",
|
||||
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
|
||||
"zh:9ced2745eb5f1346203027d2dd7bf856ad1d279a25730ff7dbc6eec187aaca0c",
|
||||
"zh:a8378558a177d43f55aa0d79d4fae91a704695122a1b109668c1daa8fb76f09d",
|
||||
"zh:aadd98086133d3ebea67437d56512fdcc6dfb3bd34dfc23f276c0db9272e27b4",
|
||||
"zh:beff701b653841e70441978137768f54e7dc6c27e7bf12a4589087f01f5bbcee",
|
||||
"zh:c91c2223b29fdbc0044d20e1936ccc051d010727a13f2ff1e75e51f09bff33a3",
|
||||
"zh:d491f9c2d32a39dc4031628469ae7c8aec0074312a7c1f0286b173cdcf854a54",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.terraform.io/hashicorp/local" {
|
||||
version = "2.9.0"
|
||||
constraints = "~> 2.4"
|
||||
hashes = [
|
||||
"h1:oF4vw1rikPMqQtMMBoUjc/pERp50DlWwO+C2I6/sNI8=",
|
||||
"zh:0baa4566cf77f1ff52f4293d1c8536202dd23edc197c3196413a28343c3ac3a0",
|
||||
"zh:16b5559c3c07088ddad11a9bb9e9c0799999363c2958e9a5be2bcbbf2cd9ca64",
|
||||
"zh:197c79015a10d1cce904a8ea722cbc750c42aeae2da53f44a6a0751d9fd1aa90",
|
||||
"zh:29d0b03e5343a80677ebfeb2e2c31cbe4b1f65e736e53417454a4277fec2544c",
|
||||
"zh:4896bfa6cf1d2fd562b47ef2e87f47862ae92a04f8ad5d764380f0c6653473b8",
|
||||
"zh:531f8529cbca49f681883e57761a05a8398afaef6d1ab0d205d26bf12f4428e8",
|
||||
"zh:6aaf5011d83161c86d2bfb80c0923ec934e578288758da2f37acb7aec129004b",
|
||||
"zh:7430275253d3d3c40aa6179e0ec0d63212874dbbc06c5a51b9d07ec590f9756c",
|
||||
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
|
||||
"zh:be17dc611e95e26cdf6cad79dfccf1064f0e32032a2efeb939a9bbe7fb1cbfe9",
|
||||
"zh:f0e3b0aa644202e1d79d2000dca91f6019425da71e9800fa23f27e51c034f195",
|
||||
"zh:f62bae4519e4ead49182ddc8afe8cf61e2a4c3ba3973b0fbba967736a2696aa3",
|
||||
"zh:fcafa360a5b0b96244f26f4e3a6d642b716a376557142c2442ff2fb12d11da18",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.terraform.io/hashicorp/null" {
|
||||
version = "3.3.0"
|
||||
constraints = "~> 3.2"
|
||||
hashes = [
|
||||
"h1:Gq+xRK4yvwDpBJ2KFJkrmcNZikDiAEuFhAWeDtzanJc=",
|
||||
"zh:021748b5ea3b5f6956f2e75c42c5cdc113b391fb98ac71364a4965d23b37000f",
|
||||
"zh:3b27956f8541d46704fda234e0d535c2ae2a4b33411848b1ee262a1ec03568b0",
|
||||
"zh:3de4ed47d6d0f4d8edba4a5092c7c9799950eda63989d8d0d2586e6afcb0aa20",
|
||||
"zh:57ed8935c7d56dbc91cf2673534582cacfaab7a2f105f51d9f797e99df0c0c47",
|
||||
"zh:58e176ba1d142827089e30e0711e007309a9f2726e8881986da5026e9778fdf4",
|
||||
"zh:5949c4a3d4a93f841f155cdb7e991c087e637145c1630572e21948224f8f4923",
|
||||
"zh:76d60f366b743003c1b085afa769b45b2198ee919927e45807d7d44fb42c067d",
|
||||
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
|
||||
"zh:79cd1bab1261a07f84e917191d7ddc4340ac5f5524283767256f7ffd7f87caf0",
|
||||
"zh:8ec9083038cf710b30e319eaa467c9df7fa52bbd9969b61053a35bc2cdd2e0a6",
|
||||
"zh:a6e502cb579685ab7aeb886c2bb11ddd9cfed74b41008592d57cbc3351a9218b",
|
||||
"zh:acb74d6b4f66ff6acfcda315df802a7432170ef3955c9b432cb4580767004006",
|
||||
"zh:f0ce55d8d9ffdb33dab612b1246f9bab060a9d54fc32ce2b4a038646155660af",
|
||||
]
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
# Shelly Fan Control Agent Documentation
|
||||
|
||||
This directory manages the configuration, code templating, and deployment for the Shelly Plug S (Gen 3) that controls the cellar ventilation fan to prevent mold.
|
||||
|
||||
## Domain Structure & Key Files
|
||||
|
||||
* **[main.tf](file:///Users/moritz/src/infrapuzzle/terraform/shelly/main.tf)**: Coordinates rendering the JavaScript template and executing the Python upload script.
|
||||
* **[variables.tf](file:///Users/moritz/src/infrapuzzle/terraform/shelly/variables.tf)**: Defines MAC addresses for the Bluetooth sensors, safety parameters, and the mold calculation coefficients.
|
||||
* **[scripts/Taupi-4.0.js](file:///Users/moritz/src/infrapuzzle/terraform/shelly/scripts/Taupi-4.0.js)**: The source Shelly control script containing the logic for parsing Bluetooth BTHome events, calculating dew points, evaluating mold danger, and switching the relay.
|
||||
* **[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 & Logic Gates
|
||||
|
||||
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.
|
||||
|
||||
### 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.
|
||||
|
||||
### 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}$).
|
||||
|
||||
### 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).
|
||||
|
||||
### 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.
|
||||
|
||||
### 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.
|
||||
|
||||
---
|
||||
|
||||
## 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 ]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sources & References
|
||||
|
||||
* **[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.
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
locals {
|
||||
templated_code = templatefile("${path.module}/scripts/Taupi-4.0.js", {
|
||||
sensor_aussen_mac = var.sensor_aussen_mac
|
||||
sensor_innen_mac = var.sensor_innen_mac
|
||||
taupunktschwelle = var.taupunktschwelle
|
||||
mindesttemperatur = var.mindesttemperatur
|
||||
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
|
||||
critical_humi_base = var.critical_humi_base
|
||||
critical_humi_temp_coeff = var.critical_humi_temp_coeff
|
||||
critical_humi_ref_temp = var.critical_humi_ref_temp
|
||||
critical_humi_buffer = var.critical_humi_buffer
|
||||
})
|
||||
}
|
||||
|
||||
resource "local_file" "templated_script" {
|
||||
content = local.templated_code
|
||||
filename = "${path.module}/scripts/Taupi-4.0.templated.js"
|
||||
}
|
||||
|
||||
resource "shelly_script_config" "taupi" {
|
||||
ip = var.shelly_ip
|
||||
id = 1
|
||||
name = "Taupi-4.0"
|
||||
enable = true
|
||||
}
|
||||
|
||||
resource "null_resource" "upload_script" {
|
||||
triggers = {
|
||||
code_hash = sha256(local_file.templated_script.content)
|
||||
ip = var.shelly_ip
|
||||
}
|
||||
|
||||
provisioner "local-exec" {
|
||||
command = "python3 ${path.module}/scripts/upload.py ${var.shelly_ip} 1 ${local_file.templated_script.filename}"
|
||||
}
|
||||
|
||||
depends_on = [shelly_script_config.taupi]
|
||||
}
|
||||
|
||||
data "external" "script_status" {
|
||||
program = ["python3", "${path.module}/scripts/get_status.py", var.shelly_ip, "1"]
|
||||
|
||||
# Trigger re-run whenever upload finishes
|
||||
query = {
|
||||
trigger = null_resource.upload_script.id
|
||||
}
|
||||
}
|
||||
|
||||
output "script_status" {
|
||||
value = data.external.script_status.result
|
||||
description = "The runtime status of the Shelly script"
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
terraform {
|
||||
required_providers {
|
||||
shelly = {
|
||||
source = "DonRobo/shelly"
|
||||
version = "~> 0.4.1"
|
||||
}
|
||||
null = {
|
||||
source = "hashicorp/null"
|
||||
version = "~> 3.2"
|
||||
}
|
||||
local = {
|
||||
source = "hashicorp/local"
|
||||
version = "~> 2.4"
|
||||
}
|
||||
external = {
|
||||
source = "hashicorp/external"
|
||||
version = "~> 2.3"
|
||||
}
|
||||
}
|
||||
required_version = ">= 1.0"
|
||||
}
|
||||
|
||||
provider "shelly" {}
|
||||
@@ -1,299 +0,0 @@
|
||||
////////////// TAUPI 4.5 @ Shelly //////////////
|
||||
// Dynamic Quality-Score Psychrometric Controller & Adaptive Thermal Governor
|
||||
|
||||
var MAC_OUT = "${sensor_aussen_mac}".toLowerCase();
|
||||
var MAC_IN = "${sensor_innen_mac}".toLowerCase();
|
||||
|
||||
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 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};
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
// 2. Adaptive Thermal Governor
|
||||
var reqSpread = DP_SPREAD_BASE;
|
||||
var targetHumi = TARGET_SUMMER;
|
||||
var govState = 2; // Comfort default
|
||||
|
||||
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 {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// 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 };
|
||||
}
|
||||
|
||||
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; }
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
function BLEScanCallback(event, result) {
|
||||
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() {
|
||||
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 });
|
||||
}
|
||||
BLE.Scanner.Subscribe(BLEScanCallback);
|
||||
}
|
||||
initBLE();
|
||||
|
||||
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) {
|
||||
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();
|
||||
});
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import sys
|
||||
import json
|
||||
import urllib.request
|
||||
|
||||
def get_status(ip, script_id):
|
||||
url = f"http://{ip}/rpc"
|
||||
payload = {
|
||||
"id": 1,
|
||||
"method": "Script.GetStatus",
|
||||
"params": {"id": int(script_id)}
|
||||
}
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=json.dumps(payload).encode('utf-8'),
|
||||
headers={'Content-Type': 'application/json'}
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
res = json.loads(resp.read().decode('utf-8'))
|
||||
if "result" in res:
|
||||
status = res["result"]
|
||||
# Convert all values to strings as HashiCorp External Provider only accepts string properties
|
||||
return {k: str(v) for k, v in status.items() if v is not None}
|
||||
elif "error" in res:
|
||||
return {"error": str(res["error"])}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
return {"error": "unknown"}
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 3:
|
||||
# Check stdin if arguments are missing (sometimes Terraform passes parameters to stdin)
|
||||
try:
|
||||
input_data = json.loads(sys.stdin.read())
|
||||
ip = input_data.get("ip")
|
||||
script_id = input_data.get("script_id")
|
||||
except:
|
||||
ip, script_id = None, None
|
||||
|
||||
if not ip or not script_id:
|
||||
print(json.dumps({"error": "Usage: get_status.py <ip> <script_id> or pass JSON via stdin"}))
|
||||
sys.exit(1)
|
||||
else:
|
||||
ip = sys.argv[1]
|
||||
script_id = sys.argv[2]
|
||||
|
||||
status = get_status(ip, script_id)
|
||||
print(json.dumps(status))
|
||||
@@ -1,82 +0,0 @@
|
||||
import sys
|
||||
import json
|
||||
import urllib.request
|
||||
|
||||
def upload(ip, script_id, code_file):
|
||||
# Read JS code
|
||||
with open(code_file, 'r', encoding='utf-8') as f:
|
||||
code = f.read()
|
||||
|
||||
import re
|
||||
# Remove comments and whitespace to save Shelly RAM
|
||||
# Remove multi-line comments
|
||||
code = re.sub(r'/\*.*?\*/', '', code, flags=re.DOTALL)
|
||||
# Remove single-line comments (ignoring http:// or https://)
|
||||
code = re.sub(r'(?<!:)\/\/.*', '', code)
|
||||
# Strip leading/trailing whitespace and drop empty lines
|
||||
code = '\n'.join([line.strip() for line in code.split('\n') if line.strip()])
|
||||
|
||||
url = f"http://{ip}/rpc"
|
||||
|
||||
# Stop the script first to prevent write locks
|
||||
print(f"Stopping script {script_id}...")
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=json.dumps({"id": 1, "method": "Script.Stop", "params": {"id": int(script_id)}}).encode('utf-8'),
|
||||
headers={'Content-Type': 'application/json'}
|
||||
)
|
||||
try:
|
||||
urllib.request.urlopen(req)
|
||||
except Exception as e:
|
||||
print(f"Stop request failed (might already be stopped): {e}")
|
||||
|
||||
# Chunk the code into 500 byte chunks (Shelly RPC size limit)
|
||||
chunk_size = 500
|
||||
chunks = [code[i:i + chunk_size] for i in range(0, len(code), chunk_size)]
|
||||
|
||||
for idx, chunk in enumerate(chunks):
|
||||
append = idx > 0
|
||||
print(f"Uploading chunk {idx + 1}/{len(chunks)} (append={append})...")
|
||||
payload = {
|
||||
"id": 1,
|
||||
"method": "Script.PutCode",
|
||||
"params": {
|
||||
"id": int(script_id),
|
||||
"code": chunk,
|
||||
"append": append
|
||||
}
|
||||
}
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=json.dumps(payload).encode('utf-8'),
|
||||
headers={'Content-Type': 'application/json'}
|
||||
)
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
res = json.loads(resp.read().decode('utf-8'))
|
||||
if "error" in res:
|
||||
raise Exception(f"Failed to upload chunk: {res['error']}")
|
||||
|
||||
print("Script upload complete!")
|
||||
|
||||
# Start the script after upload
|
||||
print(f"Starting script {script_id}...")
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=json.dumps({"id": 1, "method": "Script.Start", "params": {"id": int(script_id)}}).encode('utf-8'),
|
||||
headers={'Content-Type': 'application/json'}
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
res = json.loads(resp.read().decode('utf-8'))
|
||||
if "error" in res:
|
||||
print(f"Start request returned error: {res['error']}")
|
||||
else:
|
||||
print("Script started successfully.")
|
||||
except Exception as e:
|
||||
print(f"Start request failed: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 4:
|
||||
print("Usage: python3 upload.py <ip> <script_id> <code_file>")
|
||||
sys.exit(1)
|
||||
upload(sys.argv[1], sys.argv[2], sys.argv[3])
|
||||
@@ -1,2 +0,0 @@
|
||||
sensor_aussen_mac = "C0:2C:ED:21:4A:2B"
|
||||
sensor_innen_mac = "FC:4D:6A:12:D9:5F"
|
||||
@@ -1,130 +0,0 @@
|
||||
variable "shelly_ip" {
|
||||
type = string
|
||||
description = "The local IP address of the Shelly Plug S (Gen3) controller"
|
||||
default = "192.168.10.168"
|
||||
}
|
||||
|
||||
variable "sensor_aussen_mac" {
|
||||
type = string
|
||||
description = "The Bluetooth MAC address of the outdoor sensor (e.g. Shelly BLU H&T)"
|
||||
}
|
||||
|
||||
variable "sensor_innen_mac" {
|
||||
type = string
|
||||
description = "The Bluetooth MAC address of the indoor sensor (e.g. Shelly BLU H&T)"
|
||||
}
|
||||
|
||||
variable "taupunktschwelle" {
|
||||
type = number
|
||||
description = "Dew point difference threshold [°C] to trigger the fan (indoor_dp > outdoor_dp + threshold)"
|
||||
default = 2.0
|
||||
}
|
||||
|
||||
variable "mindesttemperatur" {
|
||||
type = number
|
||||
description = "Minimum indoor temperature [°C] required to run the fan (frost / cold protection)"
|
||||
default = 10.0
|
||||
}
|
||||
|
||||
variable "max_indoor_temp_heating" {
|
||||
type = number
|
||||
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" {
|
||||
type = number
|
||||
description = "Switch logic check interval in seconds"
|
||||
default = 180
|
||||
}
|
||||
|
||||
variable "battery_warngrenze" {
|
||||
type = number
|
||||
description = "Battery level warning threshold [%]"
|
||||
default = 20
|
||||
}
|
||||
|
||||
variable "lost_connection" {
|
||||
type = number
|
||||
description = "Time in seconds after which data is considered stale, causing the fan to turn off for safety"
|
||||
default = 600
|
||||
}
|
||||
|
||||
variable "critical_humi_base" {
|
||||
type = number
|
||||
description = "Critical indoor relative humidity [%] at reference temperature (for mold warning curve)"
|
||||
default = 80
|
||||
}
|
||||
|
||||
variable "critical_humi_temp_coeff" {
|
||||
type = number
|
||||
description = "Temperature coefficient [%/°C] for the mold warning curve"
|
||||
default = 0.5
|
||||
}
|
||||
|
||||
variable "critical_humi_ref_temp" {
|
||||
type = number
|
||||
description = "Reference temperature [°C] for the mold warning curve"
|
||||
default = 10
|
||||
}
|
||||
|
||||
variable "critical_humi_buffer" {
|
||||
type = number
|
||||
description = "Safety buffer [%] subtracted from the critical relative humidity threshold for mold alert"
|
||||
default = 10
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user