---

###############################################################################
# MicroEnv v2.0 ESPHome configuration
###############################################################################
#
#    Copyright (C) 2025 Joshua M. Boniface <joshua@boniface.me>
#
#    This program is free software: you can redistribute it and/or modify
#    it under the terms of the GNU General Public License as published by
#    the Free Software Foundation, version 3.
#
#    This program is distributed in the hope that it will be useful,
#    but WITHOUT ANY WARRANTY; without even the implied warranty of
#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#    GNU General Public License for more details.
#
#    You should have received a copy of the GNU General Public License
#    along with this program.  If not, see <https://www.gnu.org/licenses/>.
#
###############################################################################

esphome:
  name: microenv
  name_add_mac_suffix: true
  friendly_name: "MicroEnv Sensor"
  project:
    name: "Joshua Boniface.microenv"
    version: "2.0"
  min_version: 2025.11.0

dashboard_import:
  package_import_url: github://joshuaboniface/microenv/microenv.yaml@v2.x

esp32:
  board: esp32-c3-devkitm-1
  variant: esp32c3
  framework:
    type: esp-idf

preferences:
  flash_write_interval: 15sec

globals:
  - id: temperature_offset
    type: float
    restore_value: true
    initial_value: "0.0"

  - id: humidity_offset
    type: float
    restore_value: true
    initial_value: "0.0"

  #
  # Room Health settings
  #
  - id: room_health_temperature_min
    type: float
    restore_value: true
    initial_value: "21.0"

  - id: room_health_temperature_max
    type: float
    restore_value: true
    initial_value: "24.0"

  - id: room_health_temperature_penalty
    type: float
    restore_value: true
    initial_value: "10.0"

  - id: room_health_humidity_min
    type: float
    restore_value: true
    initial_value: "40.0"

  - id: room_health_humidity_max
    type: float
    restore_value: true
    initial_value: "60.0"

  - id: room_health_humidity_penalty
    type: float
    restore_value: true
    initial_value: "5.0"

  - id: room_health_voc_weight
    type: float
    restore_value: true
    initial_value: "0.4"

  - id: room_health_temperature_weight
    type: float
    restore_value: true
    initial_value: "0.3"

  - id: room_health_humidity_weight
    type: float
    restore_value: true
    initial_value: "0.3"

logger:
  level: DEBUG
  baud_rate: 115200

api:
  reboot_timeout: 15min

ota:
  platform: esphome

web_server:
  port: 80

captive_portal:

mdns:
  disabled: false

wifi:
  ap: {}
  domain: ""
  output_power: 8.5dB
  reboot_timeout: 15min
  power_save_mode: none

i2c:
  - id: i2c_bus
    sda: GPIO21
    scl: GPIO20
    scan: true

sensor:
  - platform: sgp4x
    voc:
      name: "SGP41 VOC Index"
      id: sgp41_voc_index
      accuracy_decimals: 0
      icon: mdi:waves-arrow-up
      filters:
        - sliding_window_moving_average:          # We take a reading every 5 seconds, but calculate the sliding
            window_size: 12                       # average over 12 readings i.e. 60 seconds/1 minute to normalize
            send_every: 3                         # brief spikes while still sending a value every 15 seconds.
    nox:
      name: "SGP41 NOx Index"
      id: sgp41_nox_index
      accuracy_decimals: 0
      icon: mdi:waves-arrow-up
      filters:
        - sliding_window_moving_average:
            window_size: 12
            send_every: 3
    compensation:
      temperature_source: sht45_temperature
      humidity_source: sht45_humidity
    store_baseline: true
    update_interval: 5s

  - platform: template
    name: "SGP41 TVOC (µg/m³)"
    id: sgp41_tvoc_ugm3
    icon: mdi:molecule
    lambda: |-
      float i = id(sgp41_voc_index).state;
      if (i < 1) return NAN;
      float tvoc = (log(501.0 - i) - 6.24) * -878.53;
      return tvoc;
    unit_of_measurement: "µg/m³"
    accuracy_decimals: 0
    update_interval: 15s

  - platform: template
    name: "SGP41 TVOC (ppb)"
    id: sgp41_tvoc_ppb
    icon: mdi:molecule
    lambda: |-
      float tvoc_ugm3 = id(sgp41_tvoc_ugm3).state;
      float tvoc_ppm = tvoc_ugm3 * 0.436;  // ppb estimated using isobutylene MW (56.1 g/mol)
      return tvoc_ppm;
    unit_of_measurement: "ppb"
    accuracy_decimals: 0
    update_interval: 15s

  - platform: template
    name: "SGP41 eCO2 (appr.)"
    id: sgp41_eco2_appr
    icon: mdi:molecule-co2
    lambda: |-
      float tvoc_ppb = id(sgp41_tvoc_ppb).state;
      float eco2_ppm = 400.0 + 1.5 * tvoc_ppb;
      if (eco2_ppm > 2000) eco2_ppm = 2000;
      return eco2_ppm;
    unit_of_measurement: "ppm"
    accuracy_decimals: 0
    update_interval: 15s

  - platform: sht4x
    temperature:
      name: "SHT45 Temperature"
      id: sht45_temperature
      accuracy_decimals: 1
      filters:
        - offset: !lambda return id(temperature_offset);
        - sliding_window_moving_average:
            window_size: 4
            send_every: 1
    humidity:
      name: "SHT45 Relative Humidity"
      id: sht45_humidity
      accuracy_decimals: 1
      filters:
        - lambda: |-
            // Grab measured and corrected temperatures
            float t_meas = id(sht45_temperature).state - id(temperature_offset);
            float t_corr = id(sht45_temperature).state;
            float rh_meas = x;

            // Compute saturation vapor pressures (Magnus formula)
            auto es = [](float T) { return 6.112 * exp((17.62 * T) / (243.12 + T)); };
            float rh_corr = rh_meas * es(t_meas) / es(t_corr);

            // Clamp to 0–100 %
            if (rh_corr < 0) rh_corr = 0;
            if (rh_corr > 100) rh_corr = 100;
            return rh_corr;
        - offset: !lambda return id(humidity_offset);
        - sliding_window_moving_average:
            window_size: 4
            send_every: 1
    heater_max_duty: 0.0
    update_interval: 15s

  - platform: absolute_humidity
    name: "SHT45 Absolute Humidity"
    temperature: sht45_temperature
    humidity: sht45_humidity
    id: sht45_absolute_humidity

  - platform: template
    name: "SHT45 Dew Point"
    icon: mdi:thermometer-water
    id: sht45_dew_point
    unit_of_measurement: "°C"
    lambda: |-
      float temp = id(sht45_temperature).state;
      float rh = id(sht45_humidity).state;
      if (isnan(temp) || isnan(rh)) return NAN;
      float a = 17.27, b = 237.7;
      float alpha = ((a * temp) / (b + temp)) + log(rh / 100.0);
      return (b * alpha) / (a - alpha);
    update_interval: 15s

  - platform: template
    name: "Room Health Score"
    id: room_health_score
    unit_of_measurement: "%"
    icon: mdi:home-heart
    lambda: |-
      float voc = id(sgp41_tvoc_ppb).state;
      if (isnan(voc) || voc < 1) voc = 1;
      float temp = id(sht45_temperature).state;
      float humidity = id(sht45_humidity).state;

      float temp_min = id(room_health_temperature_min);
      float temp_max = id(room_health_temperature_max);
      float temp_penalty = id(room_health_temperature_penalty);
      float humid_min = id(room_health_humidity_min);
      float humid_max = id(room_health_humidity_max);
      float humid_penalty = id(room_health_humidity_penalty);
      float voc_weight = id(room_health_voc_weight);
      float temp_weight = id(room_health_temperature_weight);
      float humid_weight = id(room_health_humidity_weight);

      // VOC score (0–100) mapped to categories from Chemical Pollution levels below
      float voc_score;
      if (voc <= 200) {
        voc_score = 100.0;
      } else if (voc <= 400) {
        // 200–400: 100 → 90
        voc_score = 100.0 - (voc - 200) * (10.0 / 200.0);
      } else if (voc <= 600) {
        // 400–600: 90 → 70
        voc_score = 90.0 - (voc - 400) * (20.0 / 200.0);
      } else if (voc <= 1500) {
        // 600–1500: 70 → 40
        voc_score = 70.0 - (voc - 600) * (30.0 / 900.0);
      } else if (voc <= 3000) {
        // 1500–3000: 40 → 0
        voc_score = 40.0 - (voc - 1500) * (40.0 / 1500.0);
      } else {
        voc_score = 0.0;
      }

      // Temperature score
      float temp_score = 100;
      if (temp < temp_min) temp_score = 100 - (temp_min - temp) * temp_penalty;
      else if (temp > temp_max) temp_score = 100 - (temp - temp_max) * temp_penalty;
      if (temp_score < 0) temp_score = 0;

      // Humidity score
      float humidity_score = 100;
      if (humidity < humid_min) humidity_score = 100 - (humid_min - humidity) * humid_penalty;
      else if (humidity > humid_max) humidity_score = 100 - (humidity - humid_max) * humid_penalty;
      if (humidity_score < 0) humidity_score = 0;

      // Weighted average
      float total_weights = voc_weight + temp_weight + humid_weight;
      if (total_weights <= 0) total_weights = 1.0;
      voc_weight /= total_weights;
      temp_weight /= total_weights;
      humid_weight /= total_weights;
      float overall_score = (voc_score * voc_weight + temp_score * temp_weight + humidity_score * humid_weight);

      return (int) round(overall_score);
    update_interval: 15s

  - platform: wifi_signal
    name: "WiFi Signal"
    update_interval: 60s
    entity_category: diagnostic

  - platform: uptime
    name: "Uptime"
    update_interval: 60s
    entity_category: diagnostic

text_sensor:
  - platform: version
    name: "ESPHome Version"
    entity_category: diagnostic

  - platform: wifi_info
    ip_address:
      name: "WiFi IP Address"
    ssid:
      name: "WiFi SSID"
    bssid:
      name: "WiFi BSSID"
    mac_address:
      name: "WiFi MAC Address"

  - platform: template
    name: "Chemical Pollution"
    id: sgp41_chemical_pollution
    icon: mdi:molecule
    lambda: |-
      float voc = id(sgp41_tvoc_ppb).state;
      if (isnan(voc) || voc < 1) return {"Unknown"};
      else if (voc <= 200) return {"Excellent"};
      else if (voc <= 400) return {"Good"};
      else if (voc <= 600) return {"Moderate"};
      else if (voc <= 1500) return {"Unhealthy"};
      else return {"Hazardous"};
    update_interval: 15s

  - platform: template
    name: "Room Health"
    id: room_health_text
    icon: mdi:home-heart
    lambda: |-
      float score = id(room_health_score).state;
      if (score < 0) return {"Unknown"};
      else if (score >= 100.0) return {"Excellent"};
      else if (score >= 95.0) return {"Great"};
      else if (score >= 90.0) return {"Good"};
      else if (score >= 80.0) return {"Fair"};
      else if (score >= 60.0) return {"Poor"};
      else return {"Bad"};
    update_interval: 15s

button:
  - platform: restart
    name: "ESP32 Restart"
    icon: mdi:power-cycle
    entity_category: diagnostic

  - platform: factory_reset
    name: "ESP32 Factory Reset"
    icon: mdi:restart-alert
    entity_category: diagnostic

number:
  # Temperature offset:
  # A calibration from -30 to +10 for the temperature sensor
  - platform: template
    name: "Temperature Offset"
    id: temperature_offset_setter
    min_value: -30
    max_value: 10
    step: 0.1
    lambda: |-
      return id(temperature_offset);
    set_action:
      then:
        - globals.set:
            id: temperature_offset
            value: !lambda 'return float(x);'

  # Humidity offset:
  # A calibration from -50 to +50 for the humidity sensor
  - platform: template
    name: "Humidity Offset"
    id: humidity_offset_setter
    min_value: -50
    max_value: 50
    step: 0.1
    lambda: |-
      return id(humidity_offset);
    set_action:
      then:
        - globals.set:
            id: humidity_offset
            value: !lambda 'return float(x);'

  # Room Health Calibration Values
  # These values allow the user to tweak the values of the room health calculation
  - platform: template
    name: "Room Health Min Temperature"
    id: room_health_temperature_min_setter
    min_value: 15
    max_value: 30
    step: 0.5
    lambda: |-
      return id(room_health_temperature_min);
    set_action:
      then:
        - globals.set:
            id: room_health_temperature_min
            value: !lambda 'return float(x);'
  - platform: template
    name: "Room Health Max Temperature"
    id: room_health_temperature_max_setter
    min_value: 15
    max_value: 30
    step: 0.5
    lambda: |-
      return id(room_health_temperature_max);
    set_action:
      then:
        - globals.set:
            id: room_health_temperature_max
            value: !lambda 'return float(x);'
  - platform: template
    name: "Room Health Temperature Penalty"
    id: room_health_temperature_penalty_setter
    min_value: 1
    max_value: 20
    step: 1
    lambda: |-
      return int(id(room_health_temperature_penalty));
    set_action:
      then:
        - globals.set:
            id: room_health_temperature_penalty
            value: !lambda 'return float(x);'
  - platform: template
    name: "Room Health Min Humidity"
    id: room_health_humidity_min_setter
    min_value: 20
    max_value: 80
    step: 1.0
    lambda: |-
      return id(room_health_humidity_min);
    set_action:
      then:
        - globals.set:
            id: room_health_humidity_min
            value: !lambda 'return float(x);'
  - platform: template
    name: "Room Health Max Humidity"
    id: room_health_humidity_max_setter
    min_value: 20
    max_value: 80
    step: 1.0
    lambda: |-
      return id(room_health_humidity_max);
    set_action:
      then:
        - globals.set:
            id: room_health_humidity_max
            value: !lambda 'return float(x);'
  - platform: template
    name: "Room Health Humidity Penalty"
    id: room_health_humidity_penalty_setter
    min_value: 1
    max_value: 10
    step: 1
    lambda: |-
      return int(id(room_health_humidity_penalty));
    set_action:
      then:
        - globals.set:
            id: room_health_humidity_penalty
            value: !lambda 'return float(x);'
  - platform: template
    name: "Room Health VOC Weight"
    id: room_health_voc_weight_setter
    min_value: 0.00
    max_value: 1.00
    step: 0.01
    lambda: |-
      return id(room_health_voc_weight);
    set_action:
      - if:
          condition:
            lambda: |-
              float total = x + id(room_health_temperature_weight) + id(room_health_humidity_weight);
              return (total > 0.0) && (total <= 1.0);
          then:
            - globals.set:
                id: room_health_voc_weight
                value: !lambda 'return float(x);'
          else:
            - logger.log:
                format: "Rejected VOC weight %.2f (total would be out of range: must be > 0.0 and ≤ 1.0)"
                args: [ 'x' ]
  - platform: template
    name: "Room Health Temperature Weight"
    id: room_health_temperature_weight_setter
    min_value: 0.00
    max_value: 1.00
    step: 0.01
    lambda: |-
      return id(room_health_temperature_weight);
    set_action:
      - if:
          condition:
            lambda: |-
              float total = x + id(room_health_voc_weight) + id(room_health_humidity_weight);
              return (total > 0.0) && (total <= 1.0);
          then:
            - globals.set:
                id: room_health_temperature_weight
                value: !lambda 'return float(x);'
          else:
            - logger.log:
                format: "Rejected Temperature weight %.2f (total would be out of range: must be > 0.0 and ≤ 1.0)"
                args: [ 'x' ]
  - platform: template
    name: "Room Health Humidity Weight"
    id: room_health_humidity_weight_setter
    min_value: 0.00
    max_value: 1.00
    step: 0.01
    lambda: |-
      return id(room_health_humidity_weight);
    set_action:
      - if:
          condition:
            lambda: |-
              float total = x + id(room_health_temperature_weight) + id(room_health_voc_weight);
              return (total > 0.0) && (total <= 1.0);
          then:
            - globals.set:
                id: room_health_humidity_weight
                value: !lambda 'return float(x);'
          else:
            - logger.log:
                format: "Rejected Humidity weight %.2f (total would be out of range: must be > 0.0 and ≤ 1.0)"
                args: [ 'x' ]

