element14 Community
element14 Community
    Register Log In
  • Site
  • Search
  • Log In Register
  • Community Hub
    Community Hub
    • What's New on element14
    • Feedback and Support
    • Benefits of Membership
    • Personal Blogs
    • Members Area
    • Achievement Levels
  • Learn
    Learn
    • Ask an Expert
    • eBooks
    • element14 presents
    • Learning Center
    • Tech Spotlight
    • STEM Academy
    • Webinars, Training and Events
    • Learning Groups
  • Technologies
    Technologies
    • 3D Printing
    • FPGA
    • Industrial Automation
    • Internet of Things
    • Power & Energy
    • Sensors
    • Technology Groups
  • Challenges & Projects
    Challenges & Projects
    • Design Challenges
    • element14 presents Projects
    • Project14
    • Arduino Projects
    • Raspberry Pi Projects
    • Project Groups
  • Products
    Products
    • Arduino
    • Avnet & Tria Boards Community
    • Dev Tools
    • Manufacturers
    • Multicomp Pro
    • Product Groups
    • Raspberry Pi
    • RoadTests & Reviews
  • About Us
    About the element14 Community
  • Store
    Store
    • Visit Your Store
    • Choose another store...
      • Europe
      •  Austria (German)
      •  Belgium (Dutch, French)
      •  Bulgaria (Bulgarian)
      •  Czech Republic (Czech)
      •  Denmark (Danish)
      •  Estonia (Estonian)
      •  Finland (Finnish)
      •  France (French)
      •  Germany (German)
      •  Hungary (Hungarian)
      •  Ireland
      •  Israel
      •  Italy (Italian)
      •  Latvia (Latvian)
      •  
      •  Lithuania (Lithuanian)
      •  Netherlands (Dutch)
      •  Norway (Norwegian)
      •  Poland (Polish)
      •  Portugal (Portuguese)
      •  Romania (Romanian)
      •  Russia (Russian)
      •  Slovakia (Slovak)
      •  Slovenia (Slovenian)
      •  Spain (Spanish)
      •  Sweden (Swedish)
      •  Switzerland(German, French)
      •  Turkey (Turkish)
      •  United Kingdom
      • Asia Pacific
      •  Australia
      •  China
      •  Hong Kong
      •  India
      •  Japan
      •  Korea (Korean)
      •  Malaysia
      •  New Zealand
      •  Philippines
      •  Singapore
      •  Taiwan
      •  Thailand (Thai)
      •  Vietnam
      • Americas
      •  Brazil (Portuguese)
      •  Canada
      •  Mexico (Spanish)
      •  United States
      Can't find the country/region you're looking for? Visit our export site or find a local distributor.
  • Translate
  • Profile
  • Settings
Spring Clean!
  • Challenges & Projects
  • Project14
  • Spring Clean!
  • More
  • Cancel
Spring Clean!
Spring Clean Projects 2026 The WindowTron - A Smart Home Environmental Control Platform
  • News and Projects
  • Forum
  • Members
  • More
  • Cancel
  • New
Join Spring Clean! to participate - click to join for free!
  • Share
  • More
  • Cancel
Group Actions
  • Group RSS
  • More
  • Cancel
Engagement
  • Author Author: vmate
  • Date Created: 31 May 2026 5:14 PM Date Created
  • Views 22 views
  • Likes 0 likes
  • Comments 0 comments
  • esp32
  • trinamic
  • stepper motors
  • 3D Printing
  • TMC2209
  • Spring Clean 2026
  • automation
  • smart home
Related
Recommended

The WindowTron - A Smart Home Environmental Control Platform

vmate
vmate
31 May 2026
The WindowTron - A Smart Home Environmental Control Platform

Overview

I live in a house with no central ‘unified’ HVAC system. There are manually controlled radiators for winter heating, and a standalone split AC for summer cooling. This gets very frustrating in the cooler parts of the year especially.

I love sleeping in relatively cold temperatures with a thick blanket, without overheating under it. This project started as the solution to this exact issue almost 4 years ago, got abandoned a few times, and finally got it working.

Here’s the issue: if I turn off the radiator, the room either won’t get cold enough, or will get too cold. Smart thermostats exist, so I could control the radiator myself, but it’s a pain to install, and not a complete solution. It might not always work either: if the heat load inside the room is too large, and the temperature delta between inside and outside is small, completely turning off the radiator won’t help anything.

So, what’s the solution? Control the window. If it’s too warm inside, and it’s colder outside, open the window. When it gets too cold, close it. This doesn’t just provide temperature control (as long as outside temperatures are below the desired inside temperature), but also lets in fresh air, which is a nice bonus.

Why not manually open the window before I go to bed, you might ask? Well, I’ve tried, but half the time, I wake up freezing to death, and with a sore throat. Having automation do it means we can precisely control the temperature, with no human intervention, opening and closing the window as many times as needed, even while I sleep.

By the end, this project turned into something way more complex and useful, far beyond just opening and closing a window, or regulating air temperature. Integration with various other smart devices lets the system handle tasks like air conditioner control, air quality management, and more.

The original prototype

This is the version I started with 4 years ago. The mechanism was crude but technically worked.

image

image

image

The rack gear was kept in place, pushed into the stepper’s gear, with a simple plastic post. This gave enough slack that even with terrible alignment and a bad design, the mechanism worked okay.

You don't have permission to edit metadata of this video.
Edit media
x
image
Upload Preview
image

The first problem is obvious from the video: it’s loud. Mainly the stepper, but two other things as well: when the stepper engages/disengages, there’s a loud click, and also the entire mechanism makes a rubbing noise (probably the flat top of the rack gear against the post).

Incremental Upgrades

The first upgrade was swapping the DRV8825 I used for a TMC2208, which has something called StealthChop. It’s basically motor control magic that makes steppers silent.

image

(apologies for the terrible picture, I didn’t know yet that this was going to end up as a project on Element14, so I didn’t take any decent pictures).

As the image shows, a Raspberry Pi Pico and the TMC2208 stepper driver are the main components. The yellow button manually opens and closes the window. 24V comes in at the top (cut off), and there’s a white cable carrying I2C, for sensors.

The TMC2208 fixed the stepper noise issue. The next goal was to fix the rubbing noise.

The solution ended up being a bit ugly, but helped somewhat:

image

Smearing the thing full of grease got rid of most of the rubbing noise, and the unfortunate color of the SHC 220 grease I used also served as a great warning about what happens when fingers go in places they don’t belong in.

Sensors

At minimum, two temperature sensors were needed, one outside, one inside. The former one was needed so the window wouldn’t be opened when it’s hotter outside than inside.

The inside temperature sensor ended up being an HTU21 temperature/humidity I2C module, mounted on my bedside table.

image

For the outside sensor, something a bit more complex was needed. I didn’t have a rain-protected spot to put it in, so I had to make some enclosure for it.

While I was browsing for temperature sensor modules, I found an I2C light sensor, along with the BME280 temperature/humidity/pressure sensor that I got, so the enclosure had to accommodate that too.

image

image

imageimage

image

I glued a transparent piece of plastic to the top, so light could still reach the BH1750 sensor, but water couldn’t. The BME280 sensor was placed at the bottom, where it is also protected, but doesn’t get stuck in hot/cold air pockets.

I mounted this assembly on the back of my AC’s outdoor unit, where it also benefits from the active airflow of the AC fan in the summer (the backside is the intake, so the warm air from the AC cannot influence readings).

image

Control Logic

This was the messy part. For some reason, I went with the idea to connect the Pi Pico over USB to my home server, and run most of the logic on there. The Pi Pico simply served as a way to query sensors and send window open/close commands.

On the Linux side, a Python script continuously requested sensor data, averaged, compared against a setpoint, and issued window open/close commands.

The setpoint could be adjusted over HTTP, and the temperature data was logged to InfluxDB; these were the reasons for using a Linux machine for network access instead of running everything on the Pi Pico itself.

The code was absolutely terrible, and thankfully I can’t even find half of it anymore.

Problems

The system had quite a few issues, but only one was a dealbreaker: gusts frequently moved the window, and since the mechanism had no position feedback, it either resulted in the window being forcefully slammed into the frame on close, or ran off the rack gear on open (or sometimes, moving off the rack from the gust by itself).

There was also no homing mechanism, the Pi Pico simply assumed that the window is closed on boot. This works, until there’s a power outage when the window is not closed.

As a last-ditch effort 3 years ago, I added passive braking with a relay setup, hoping it would hold the window strong enough to not move from gusts. I didn’t know at the time that the TMC2208 had a passive braking feature, so I simply used four relays to short the stepper motor’s coils, when the TMC2208’s enable pin was high.

Unfortunately, this wasn’t enough. It did sort of help, gusts that would’ve completely knocked the window off the rail now only resulted in smaller movements, but that’s still unacceptable.

I deliberately didn’t want to do active braking, because that requires a significant amount of power, needed 24/7. It would’ve technically solved my issue, but a constant 10W+ load is ugly and inefficient.

This is where I shelved the project in 2023.

Spring Clean 2026 Revival

Fast forward to 2026, there’s no better time to fix everything than Spring Clean.

I knew the idea was viable, the problematic part was execution.

I started by re-thinking the mechanical parts. As discussed earlier, the biggest problem was gusts moving the window. To fix this, I had two ideas:

  • Figure out a way to mechanically apply a braking force in some way, that can counteract the gusts.
  • Create a closed-loop feedback system that can detect when a gust moves the window, apply active braking, and move back to the ‘pre-gust’ position.

I didn’t want to risk another flop, so I decided to incorporate both of these ideas into the new design.

The first part was achieved by adding a massive gear reduction. This was by far the biggest upgrade in this entire project, because it solves a bunch of issues at once:

  • Active braking becomes viable: instead of needing 10W+ to hold the motor stationary, we only need a fraction of that.
  • Sensorless homing becomes viable: the stepper can now spin fast enough for StallGuard to work, which is a feature in some Trinamic stepper drivers, to detect stalls/skipped steps. This means we can properly home the stepper on boot, instead of relying on the potentially faulty assumption that the window is always closed on boot.
  • Effective torque increases immensely, and the window can be actuated slower, for less noise

The second part was achieved by adding a magnet to the shaft of the stepper motor, and mounting an AS5600 magnetic rotary encoder sensor next to it. This sensor can measure the exact rotational angle of a magnet positioned a few millimeters above it.

image

I also wanted to get rid of the crude plastic post that holds the rack gear pushed into the stepper motor’s gear. The best way I could think of was to make the rack gear ‘double sided’, and sandwich it between two gears, one driven by a stepper (and the gear reduction), the other freewheeling.

The rack gear got a curvature matching the path of the top edge of the window, so it wouldn’t have to constantly pivot on its mounting screw, and change its angle between the two gears.

Here's the final design of the mechanism:

image

image

image

image

The four square posts under the smallest gear(or above it, in the next picture) hold the AS5600 magnetic encoder module, and the gear has a small slot for a magnet:

image

I 3D printed the rack gear from PETG, to make it a bit more flexible. The two large gears that sandwich the rack are PLA, and the small stepper gear is PETG too.

The two gears have slots for two bearings each, and an M5 bolt secures them to the baseplate.

image

image

image

And here’s the end result, mounted on the window:

image

New Electronics

The Pi Pico + Linux setup from the previous version was a mess. It was clear that the microcontroller should handle everything by itself. I had an ESP32 lying around, and bought a TMC2209 stepper driver to go along with it.

The TMC2209 driver upgrade was needed for StallGuard, which is the feature that lets us do sensorless homing. Instead of assuming that the window is closed on boot, the driver carefully ‘bumps’ into the window frame on boot, to detect where the closed position is.

The sensors remained the same, I didn’t face any issues with them four years ago.

image

The new PCB is nothing fancy, I didn’t even make a schematic for it. I plan on making a nicer, custom PCB for it later, but for now, this is perfectly fine. I didn't make an enclosure for it yet, as I don't want to remake it each time I change something. 

24V goes in at the top right, on the unplugged 2-pin JST-XH connector. This directly powers the TMC2209, and also goes into a small buck converter, to power the ESP32.

The bottom left has a 4-pin JST-XH connector for I2C, which goes into a splitter, that creates two JST-SM connectors. One goes to the magnetic rotary encoder sensor, the other goes to the various environmental sensors.

Firmware

As mentioned earlier, the ESP32 now does everything on its own:

  • It controls the stepper motor
  • Makes decisions about when to open and close the window, based on sensor data
  • Exposes HTTP endpoints to adjust the temperature setpoint, and manually open/close the window
  • Logs sensor and window data to InfluxDB

 

I went with PlatformIO/Arduino Core for this, as every single sensor and device I used had mature libraries, and I didn’t need any of the features or advantages offered by ESP-IDF.

The gear reduction and low power active braking worked out so well, that the AS5600 wasn't even required (for now).

main.cpp

#include <Arduino.h>

#include "ButtonController.hpp"
#include "ClimateController.hpp"
#include "HttpApi.hpp"
#include "InfluxLogger.hpp"
#include "SensorController.hpp"
#include "WifiController.hpp"
#include "WindowController.hpp"

WindowController window;
ButtonController button;
SensorController sensors;
ClimateController climate;
WifiController wifi;
InfluxLogger influx;
HttpApi http(window, climate, sensors, influx, wifi, button);

void setup() {
  Serial.begin(115200);
  delay(1500);

  Serial.println();
  Serial.println("Starting...");
  Serial.printf("[CFG] MICROSTEPS=%u OPEN_MM=%.1f OPEN_STEPS=%ld MAX_HOMING_STEPS=%ld\n",
                Config::MICROSTEPS,
                Config::OPEN_MM,
                (long)Config::OPEN_STEPS,
                (long)Config::MAX_HOMING_STEPS);

  button.begin();
  window.begin();
  sensors.begin();
  wifi.begin();
  influx.begin();
  http.begin();
}

void loop() {
  if (button.consumePress()) {
    Serial.println("[BTN] toggle");
    window.toggle();
  }

  window.service();
  http.service();
  wifi.service();

  if (sensors.service() && sensors.readings().valid) {
    climate.addSample(sensors.readings());
    influx.writeReadings(sensors.readings());
  }

  influx.serviceWindowState(window);
  climate.service(window);

  delay(2);
}

AppConfig.hpp

#pragma once

#include <Arduino.h>
#include <math.h>

namespace Config {

static const char *const WIFI_SSID = "x";
static const char *const WIFI_PASSWORD = "x";

static const char *const INFLUXDB_URL = "http://x:8086";
static const char *const INFLUXDB_TOKEN = "x";
static const char *const INFLUXDB_ORG = "x";
static const char *const INFLUXDB_BUCKET = "sensors";

// Disable homing and assume window is closed on boot
static constexpr bool DEBUG_ASSUME_HOMED_ON_START = true;

static constexpr bool INVERT_MOTOR_DIR = true;

static constexpr uint8_t PIN_DIR = 32;
static constexpr uint8_t PIN_STEP = 33;
static constexpr uint8_t PIN_TMC_EN = 14;
static constexpr uint8_t PIN_TMC_RX = 26;
static constexpr uint8_t PIN_TMC_TX = 25;
static constexpr uint8_t PIN_BUTTON = 39;
static constexpr uint8_t PIN_I2C_SCL = 22;
static constexpr uint8_t PIN_I2C_SDA = 23;

static constexpr float DEFAULT_TEMP_TARGET_C = 23.0f;
static constexpr float DEFAULT_TEMP_VARIANCE_C = 0.1f;
static constexpr size_t TEMP_HISTORY_COUNT = 20;
static constexpr uint32_t SENSOR_SAMPLE_INTERVAL_MS = 1000;
static constexpr uint32_t MOTOR_CONTROL_INTERVAL_MS = 10000;
static constexpr uint32_t WIFI_RECONNECT_INTERVAL_MS = 10000;
static constexpr uint32_t WINDOW_STATE_RETRY_INTERVAL_MS = 1000;

static constexpr uint16_t MOTOR_FULL_STEPS_PER_REV = 200;
static constexpr uint16_t MICROSTEPS = 16;
static constexpr float MM_PER_MOTOR_REV = 15.0f;
static constexpr float OPEN_MM = 105.0f;
static constexpr float MAX_HOMING_MM = 230.0f;
static constexpr int32_t CLOSED_STEPS = 0;

inline int32_t mmToSteps(float mm) {
  const float stepsPerMm =
      static_cast<float>(MOTOR_FULL_STEPS_PER_REV * MICROSTEPS) /
      MM_PER_MOTOR_REV;
  return static_cast<int32_t>(lroundf(mm * stepsPerMm));
}

static const int32_t OPEN_STEPS = mmToSteps(OPEN_MM);
static const int32_t MAX_HOMING_STEPS = mmToSteps(MAX_HOMING_MM);

static constexpr float TMC_R_SENSE = 0.11f;
static constexpr uint8_t TMC_ADDRESS = 0b00;
static constexpr uint32_t TMC_UART_BAUD = 19200;

static constexpr uint16_t HOMING_CURRENT_MA = 800;
static constexpr uint32_t HOMING_SPEED_HZ = 5500;
static constexpr uint32_t HOMING_ACCEL_HZ_S2 = 30000;
static constexpr uint16_t SG_SOFTWARE_THRESHOLD = 180;
static constexpr uint8_t SG_CONSECUTIVE_HITS_REQUIRED = 2;
static constexpr uint32_t SG_POLL_MS = 10;
static constexpr uint32_t HOMING_EXTRA_IGNORE_MS = 120;
static constexpr uint8_t TMC_SGTHRS = SG_SOFTWARE_THRESHOLD / 2;

static constexpr uint16_t RUN_CURRENT_MA = 650;
static constexpr float HOLD_CURRENT_MULTIPLIER = 0.30f;
static constexpr uint32_t MOVE_SPEED_HZ = 1500;
static constexpr uint32_t MOVE_ACCEL_HZ_S2 = 5000;

static constexpr bool BUTTON_ACTIVE_LOW = true;
static constexpr uint32_t BUTTON_DEBOUNCE_MS = 120;
static constexpr uint32_t BUTTON_MIN_REPEAT_MS = 500;

}

ButtonController.hpp

#pragma once

#include <Arduino.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>

#include "AppConfig.hpp"

class ButtonController {
 public:
  void begin() {
    // external pull-up
    pinMode(Config::PIN_BUTTON, INPUT);

    rawLast_ = digitalRead(Config::PIN_BUTTON);
    stable_ = rawLast_;
    rawChangedMs_ = millis();
    lastPressMs_ = millis() - Config::BUTTON_MIN_REPEAT_MS;

    xTaskCreatePinnedToCore(&ButtonController::taskEntry,
                            "window-button",
                            2048,
                            this,
                            1,
                            &taskHandle_,
                            1);
  }

  bool consumePress() {
    portENTER_CRITICAL(&mutex_);
    const bool pending = pressPending_;
    pressPending_ = false;
    portEXIT_CRITICAL(&mutex_);
    return pending;
  }

  bool level() const {
    return stable_;
  }

  uint32_t lastPressMs() const {
    return lastPressMs_;
  }

 private:
  static void taskEntry(void *arg) {
    static_cast<ButtonController *>(arg)->taskLoop();
  }

  void taskLoop() {
    while (true) {
      poll();
      vTaskDelay(pdMS_TO_TICKS(10));
    }
  }

  void poll() {
    const uint32_t now = millis();
    const bool raw = digitalRead(Config::PIN_BUTTON);

    if (raw != rawLast_) {
      rawLast_ = raw;
      rawChangedMs_ = now;
    }

    if ((now - rawChangedMs_) < Config::BUTTON_DEBOUNCE_MS) return;
    if (raw == stable_) return;

    stable_ = raw;

    const bool pressed =
        Config::BUTTON_ACTIVE_LOW ? (stable_ == LOW) : (stable_ == HIGH);

    if (!pressed || (now - lastPressMs_) < Config::BUTTON_MIN_REPEAT_MS) {
      return;
    }

    lastPressMs_ = now;

    portENTER_CRITICAL(&mutex_);
    pressPending_ = true;
    portEXIT_CRITICAL(&mutex_);
  }

  TaskHandle_t taskHandle_ = nullptr;
  portMUX_TYPE mutex_ = portMUX_INITIALIZER_UNLOCKED;
  volatile bool pressPending_ = false;
  volatile bool rawLast_ = HIGH;
  volatile bool stable_ = HIGH;
  volatile uint32_t rawChangedMs_ = 0;
  volatile uint32_t lastPressMs_ = 0;
};

ClimateController.hpp

#pragma once

#include <Arduino.h>

#include "AppConfig.hpp"
#include "SensorController.hpp"
#include "WindowController.hpp"

class ClimateController {
 public:
  enum class WindowIntent {
    Unknown,
    Closed,
    Open,
  };

  void addSample(const SensorReadings &readings) {
    if (!readings.valid) return;

    outsideTempHistory_[historyNext_] = readings.outsideTemperature;
    insideTempHistory_[historyNext_] = readings.insideTemperature;

    historyNext_ = (historyNext_ + 1) % Config::TEMP_HISTORY_COUNT;
    if (historySize_ < Config::TEMP_HISTORY_COUNT) historySize_++;

    outsideTempAverageC_ = average(outsideTempHistory_, historySize_);
    insideTempAverageC_ = average(insideTempHistory_, historySize_);
  }

  void service(WindowController &window) {
    const uint32_t now = millis();
    if ((now - lastControlMs_) < Config::MOTOR_CONTROL_INTERVAL_MS) return;

    lastControlMs_ = now;

    if (window.isMoving()) {
      lastDecision_ = "skipped: movement in progress";
      return;
    }

    if (historySize_ == 0 ||
        !isfinite(outsideTempAverageC_) ||
        !isfinite(insideTempAverageC_)) {
      lastDecision_ = "skipped: no valid temperature history";
      return;
    }

    const bool shouldOpen =
        outsideTempAverageC_ < insideTempAverageC_ - varianceC_ &&
        insideTempAverageC_ > targetC_ + varianceC_;
    const bool shouldClose =
        insideTempAverageC_ < targetC_ - varianceC_ ||
        outsideTempAverageC_ > insideTempAverageC_;

    if (shouldOpen && windowIntent_ != WindowIntent::Open) {
      if (window.open()) {
        windowIntent_ = WindowIntent::Open;
        decisionCount_++;
        lastDecision_ = "open";
        Serial.printf("[CLIMATE] opening - In: %.2fC Out: %.2fC\n",
                      insideTempAverageC_, outsideTempAverageC_);
      } else {
        lastDecision_ = "open rejected";
      }
      return;
    }

    if (shouldClose && windowIntent_ != WindowIntent::Closed) {
      if (window.close()) {
        windowIntent_ = WindowIntent::Closed;
        decisionCount_++;
        lastDecision_ = "close";
        Serial.printf("[CLIMATE] closing - In: %.2fC Out: %.2fC\n",
                      insideTempAverageC_, outsideTempAverageC_);
      } else {
        lastDecision_ = "close rejected";
      }
      return;
    }

    lastDecision_ = "hold";
  }

  void setTarget(float value) {
    targetC_ = value;
  }

  void setVariance(float value) {
    varianceC_ = value;
  }

  void modifyTarget(float delta) {
    targetC_ += delta;
  }

  float target() const {
    return targetC_;
  }

  float variance() const {
    return varianceC_;
  }

  size_t historySize() const {
    return historySize_;
  }

  float outsideAverage() const {
    return outsideTempAverageC_;
  }

  float insideAverage() const {
    return insideTempAverageC_;
  }

  uint32_t decisionCount() const {
    return decisionCount_;
  }

  const String &lastDecision() const {
    return lastDecision_;
  }

  const char *windowIntentName() const {
    switch (windowIntent_) {
      case WindowIntent::Unknown: return "unknown";
      case WindowIntent::Closed:  return "closed";
      case WindowIntent::Open:    return "open";
    }

    return "unknown";
  }

 private:
  static float average(const float *values, size_t count) {
    float total = 0.0f;

    for (size_t i = 0; i < count; i++) {
      total += values[i];
    }

    return count ? total / count : NAN;
  }

  float outsideTempHistory_[Config::TEMP_HISTORY_COUNT];
  float insideTempHistory_[Config::TEMP_HISTORY_COUNT];
  size_t historySize_ = 0;
  size_t historyNext_ = 0;

  float targetC_ = Config::DEFAULT_TEMP_TARGET_C;
  float varianceC_ = Config::DEFAULT_TEMP_VARIANCE_C;
  float outsideTempAverageC_ = NAN;
  float insideTempAverageC_ = NAN;

  WindowIntent windowIntent_ = WindowIntent::Unknown;
  uint32_t lastControlMs_ = 0;
  uint32_t decisionCount_ = 0;
  String lastDecision_ = "none";
};

HttpApi.hpp

#pragma once

#include <Arduino.h>
#include <ArduinoJson.h>
#include <WebServer.h>

#include "AppConfig.hpp"
#include "ButtonController.hpp"
#include "ClimateController.hpp"
#include "InfluxLogger.hpp"
#include "SensorController.hpp"
#include "WifiController.hpp"
#include "WindowController.hpp"

class HttpApi {
 public:
  HttpApi(WindowController &window,
          ClimateController &climate,
          SensorController &sensors,
          InfluxLogger &influx,
          WifiController &wifi,
          ButtonController &button)
      : window_(window),
        climate_(climate),
        sensors_(sensors),
        influx_(influx),
        wifi_(wifi),
        button_(button) {}

  void begin() {
    server_.on("/status", HTTP_GET, [this]() {
      sendJson(200, buildStatusJson());
    });

    server_.on("/openWindow", HTTP_GET, [this]() {
      handleCommandResponse(window_.open());
    });

    server_.on("/closeWindow", HTTP_GET, [this]() {
      handleCommandResponse(window_.close());
    });

    server_.on("/getTemperature", HTTP_GET, [this]() {
      JsonDocument doc;
      doc["target"] = climate_.target();
      doc["variance"] = climate_.variance();
      sendJson(200, serialize(doc));
    });

    server_.on("/setTemperature", HTTP_GET, [this]() {
      float target = climate_.target();
      float variance = climate_.variance();

      if (server_.hasArg("target") &&
          !parseFiniteFloat(server_.arg("target"), target)) {
        sendText(400, "invalid target");
        return;
      }

      if (server_.hasArg("variance") &&
          !parseFiniteFloat(server_.arg("variance"), variance)) {
        sendText(400, "invalid variance");
        return;
      }

      climate_.setTarget(target);
      climate_.setVariance(variance);
      sendText(200, "Done");
    });

    server_.on("/modifyTemperature", HTTP_GET, [this]() {
      float delta = 0.0f;

      if (!server_.hasArg("value") ||
          !parseFiniteFloat(server_.arg("value"), delta)) {
        sendText(400, "invalid value");
        return;
      }

      climate_.modifyTarget(delta);
      sendText(200, "ok");
    });

    server_.onNotFound([this]() {
      sendText(404, "not found");
    });

    server_.begin();
    Serial.println("[HTTP] server started");
  }

  void service() {
    server_.handleClient();
  }

 private:
  static bool parseFiniteFloat(const String &text, float &value) {
    if (text.length() == 0) return false;

    char *end = nullptr;
    value = strtof(text.c_str(), &end);
    return end && *end == '\0' && isfinite(value);
  }

  static void setJsonFloat(JsonObject object, const char *key, float value) {
    if (isfinite(value)) {
      object[key] = value;
    } else {
      object[key] = nullptr;
    }
  }

  static String serialize(JsonDocument &doc) {
    String json;
    serializeJson(doc, json);
    return json;
  }

  void sendText(int code, const String &text) {
    server_.sendHeader("Access-Control-Allow-Origin", "*");
    server_.send(code, "text/plain", text);
  }

  void sendJson(int code, const String &json) {
    server_.sendHeader("Access-Control-Allow-Origin", "*");
    server_.send(code, "application/json", json);
  }

  void handleCommandResponse(bool accepted) {
    if (accepted) {
      sendText(200, "ok");
    } else if (window_.hasError()) {
      sendText(409, "error: " + window_.lastError());
    } else {
      sendText(409, "not ready");
    }
  }

  String buildStatusJson() {
    JsonDocument doc;
    doc["uptime_ms"] = millis();

    JsonObject config = doc["config"].to<JsonObject>();
    config["invert_motor_dir"] = Config::INVERT_MOTOR_DIR;
    config["debug_assume_homed_on_start"] =
        Config::DEBUG_ASSUME_HOMED_ON_START;
    config["i2c_scl_gpio"] = Config::PIN_I2C_SCL;
    config["i2c_sda_gpio"] = Config::PIN_I2C_SDA;

    JsonObject window = doc["window"].to<JsonObject>();
    window["state"] = window_.stateName();
    window["homed"] = window_.homed();
    window["error"] = window_.hasError();
    window["last_error"] = window_.lastError();
    window["moving"] = window_.isMoving();
    window["position_steps"] = window_.currentPosition();
    window["closed_steps"] = Config::CLOSED_STEPS;
    window["open_steps"] = Config::OPEN_STEPS;
    window["open_mm"] = Config::OPEN_MM;
    window["max_homing_steps"] = Config::MAX_HOMING_STEPS;

    JsonObject motion = doc["motion"].to<JsonObject>();
    motion["microsteps"] = Config::MICROSTEPS;
    motion["run_current_ma"] = Config::RUN_CURRENT_MA;
    motion["hold_current_multiplier"] = Config::HOLD_CURRENT_MULTIPLIER;
    motion["move_speed_hz"] = Config::MOVE_SPEED_HZ;
    motion["move_accel_hz_s2"] = Config::MOVE_ACCEL_HZ_S2;

    JsonObject homing = doc["homing"].to<JsonObject>();
    homing["current_ma"] = Config::HOMING_CURRENT_MA;
    homing["speed_hz"] = Config::HOMING_SPEED_HZ;
    homing["accel_hz_s2"] = Config::HOMING_ACCEL_HZ_S2;
    homing["sg_threshold"] = Config::SG_SOFTWARE_THRESHOLD;
    homing["sg_hits_required"] = Config::SG_CONSECUTIVE_HITS_REQUIRED;
    homing["last_min_sg"] = window_.lastHomeMinSg();
    homing["last_final_sg"] = window_.lastHomeFinalSg();
    homing["last_elapsed_ms"] = window_.lastHomeElapsedMs();

    JsonObject tmc = doc["tmc2209"].to<JsonObject>();
    tmc["ok"] = window_.tmcOk();
    tmc["test_connection"] = window_.lastTmcTestConnection();
    tmc["ifcnt_before"] = window_.lastIfc0();
    tmc["ifcnt_after_first_write"] = window_.lastIfc1();
    tmc["ifcnt_after_second_write"] = window_.lastIfc2();
    tmc["ioin_hex"] = "0x" + String(window_.lastIoin(), HEX);
    tmc["drv_status_hex"] = "0x" + String(window_.lastDrvStatus(), HEX);
    tmc["sg_initial"] = window_.lastSgInitial();

    const SensorReadings &readings = sensors_.readings();
    JsonObject sensors = doc["sensors"].to<JsonObject>();
    sensors["valid"] = readings.valid;
    sensors["updated_ms"] = readings.updatedMs;

    JsonObject outside = sensors["outside"].to<JsonObject>();
    outside["bme280_initialized"] = sensors_.bmeOk();
    outside["bh1750_initialized"] = sensors_.lightOk();
    setJsonFloat(outside, "temperature_c", readings.outsideTemperature);
    setJsonFloat(outside, "humidity_percent", readings.outsideHumidity);
    setJsonFloat(outside, "pressure_hpa", readings.outsidePressure);
    setJsonFloat(outside, "brightness_lux", readings.outsideBrightness);

    JsonObject inside = sensors["inside"].to<JsonObject>();
    inside["htu21df_initialized"] = sensors_.htuOk();
    setJsonFloat(inside, "temperature_c", readings.insideTemperature);
    setJsonFloat(inside, "humidity_percent", readings.insideHumidity);

    JsonObject climate = doc["climate_control"].to<JsonObject>();
    climate["target_c"] = climate_.target();
    climate["variance_c"] = climate_.variance();
    climate["history_size"] = climate_.historySize();
    climate["history_capacity"] = Config::TEMP_HISTORY_COUNT;
    setJsonFloat(climate, "outside_average_c", climate_.outsideAverage());
    setJsonFloat(climate, "inside_average_c", climate_.insideAverage());
    climate["decision_interval_ms"] = Config::MOTOR_CONTROL_INTERVAL_MS;
    climate["decision_count"] = climate_.decisionCount();
    climate["last_decision"] = climate_.lastDecision();
    climate["window_intent"] = climate_.windowIntentName();

    JsonObject wifi = doc["wifi"].to<JsonObject>();
    wifi["connected"] = wifi_.connected();
    wifi["hostname"] = wifi_.hostname();
    wifi["ip"] = wifi_.ip();
    wifi["rssi"] = wifi_.rssi();

    JsonObject influx = doc["influxdb"].to<JsonObject>();
    influx["url"] = Config::INFLUXDB_URL;
    influx["org"] = Config::INFLUXDB_ORG;
    influx["bucket"] = Config::INFLUXDB_BUCKET;
    influx["validated"] = influx_.validated();
    influx["connected"] = influx_.connected();
    influx["last_status_code"] = influx_.lastStatusCode();
    influx["write_attempts"] = influx_.writeAttempts();
    influx["write_successes"] = influx_.writeSuccesses();
    influx["write_failures"] = influx_.writeFailures();
    influx["last_write_ms"] = influx_.lastWriteMs();
    influx["last_error"] = influx_.lastError();

    JsonObject button = doc["button"].to<JsonObject>();
    button["gpio"] = Config::PIN_BUTTON;
    button["active_low"] = Config::BUTTON_ACTIVE_LOW;
    button["level"] = button_.level();
    button["last_press_ms"] = button_.lastPressMs();

    return serialize(doc);
  }

  WebServer server_{80};
  WindowController &window_;
  ClimateController &climate_;
  SensorController &sensors_;
  InfluxLogger &influx_;
  WifiController &wifi_;
  ButtonController &button_;
};

InfluxLogger.hpp

#pragma once

#include <Arduino.h>
#include <InfluxDbClient.h>
#include <WiFi.h>

#include "AppConfig.hpp"
#include "SensorController.hpp"
#include "WindowController.hpp"

class InfluxLogger {
 public:
  InfluxLogger()
      : client_(Config::INFLUXDB_URL,
                Config::INFLUXDB_ORG,
                Config::INFLUXDB_BUCKET,
                Config::INFLUXDB_TOKEN) {}

  void begin() {
    if (WiFi.status() != WL_CONNECTED) {
      lastError_ = "Wi-Fi is not connected";
      Serial.println("[INFLUX] skipped connection check: Wi-Fi is not connected");
      return;
    }

    validated_ = client_.validateConnection();

    if (validated_) {
      lastError_ = "";
      Serial.printf("[INFLUX] connected: %s\n", client_.getServerUrl().c_str());
    } else {
      lastError_ = client_.getLastErrorMessage();
      Serial.printf("[INFLUX] connection failed: %s\n", lastError_.c_str());
    }
  }

  void writeReadings(const SensorReadings &readings) {
    if (!readings.valid) return;

    Point outside("outside");
    outside.addField("temperature", readings.outsideTemperature);
    outside.addField("humidity", readings.outsideHumidity);
    outside.addField("pressure", readings.outsidePressure);
    outside.addField("brightness", readings.outsideBrightness);

    Point inside("inside");
    inside.addField("temperature", readings.insideTemperature);
    inside.addField("humidity", readings.insideHumidity);

    writeRecord(client_.pointToLineProtocol(outside) + "\n" +
                client_.pointToLineProtocol(inside));
  }

  void serviceWindowState(const WindowController &window) {
    if (!window.hasCompletedPosition()) return;

    const bool isOpen = window.isOpen();
    if (hasLoggedWindowState_ && lastLoggedWindowOpen_ == isOpen) return;

    const uint32_t now = millis();
    if (lastWindowStateAttemptMs_ != 0 &&
        (now - lastWindowStateAttemptMs_) <
            Config::WINDOW_STATE_RETRY_INTERVAL_MS) {
      return;
    }

    lastWindowStateAttemptMs_ = now;

    Point inside("inside");
    inside.addField("window_open", isOpen);

    if (writeRecord(client_.pointToLineProtocol(inside))) {
      hasLoggedWindowState_ = true;
      lastLoggedWindowOpen_ = isOpen;
    }
  }

  bool validated() const {
    return validated_;
  }

  bool connected() const {
    return client_.isConnected();
  }

  int lastStatusCode() const {
    return client_.getLastStatusCode();
  }

  uint32_t writeAttempts() const {
    return writeAttempts_;
  }

  uint32_t writeSuccesses() const {
    return writeSuccesses_;
  }

  uint32_t writeFailures() const {
    return writeFailures_;
  }

  uint32_t lastWriteMs() const {
    return lastWriteMs_;
  }

  const String &lastError() const {
    return lastError_;
  }

 private:
  bool writeRecord(const String &record) {
    if (WiFi.status() != WL_CONNECTED) {
      lastError_ = "Wi-Fi is not connected";
      return false;
    }

    writeAttempts_++;

    if (client_.writeRecord(record)) {
      validated_ = true;
      writeSuccesses_++;
      lastWriteMs_ = millis();
      lastError_ = "";
      return true;
    }

    writeFailures_++;
    lastError_ = client_.getLastErrorMessage();
    Serial.printf("[INFLUX] write failed: %s\n", lastError_.c_str());
    return false;
  }

  InfluxDBClient client_;
  bool validated_ = false;
  uint32_t writeAttempts_ = 0;
  uint32_t writeSuccesses_ = 0;
  uint32_t writeFailures_ = 0;
  uint32_t lastWriteMs_ = 0;
  String lastError_;

  bool hasLoggedWindowState_ = false;
  bool lastLoggedWindowOpen_ = false;
  uint32_t lastWindowStateAttemptMs_ = 0;
};

SensorController.hpp

#pragma once

#include <Adafruit_BME280.h>
#include <Arduino.h>
#include <BH1750.h>
#include <Wire.h>

#include "Adafruit_HTU21DF.h"
#include "AppConfig.hpp"

struct SensorReadings {
  float outsideTemperature = NAN;
  float outsideHumidity = NAN;
  float outsidePressure = NAN;
  float outsideBrightness = NAN;
  float insideTemperature = NAN;
  float insideHumidity = NAN;
  uint32_t updatedMs = 0;
  bool valid = false;
};

class SensorController {
 public:
  void begin() {
    Wire.begin(Config::PIN_I2C_SDA, Config::PIN_I2C_SCL);

    bmeOk_ = outsideBme_.begin(0x76, &Wire);
    lightOk_ =
        outsideLight_.begin(BH1750::CONTINUOUS_HIGH_RES_MODE, 0x23, &Wire);
    htuOk_ = insideHtu_.begin(&Wire);

    Serial.printf("[SENSOR] BME280=%s BH1750=%s HTU21DF=%s\n",
                  bmeOk_ ? "ok" : "failed",
                  lightOk_ ? "ok" : "failed",
                  htuOk_ ? "ok" : "failed");
  }

  bool service() {
    const uint32_t now = millis();
    if ((now - lastSampleMs_) < Config::SENSOR_SAMPLE_INTERVAL_MS) return false;

    lastSampleMs_ = now;
    sample();
    return true;
  }

  const SensorReadings &readings() const {
    return readings_;
  }

  bool bmeOk() const {
    return bmeOk_;
  }

  bool lightOk() const {
    return lightOk_;
  }

  bool htuOk() const {
    return htuOk_;
  }

 private:
  void sample() {
    SensorReadings next;

    if (bmeOk_) {
      next.outsideTemperature = outsideBme_.readTemperature();
      next.outsideHumidity = outsideBme_.readHumidity();
      next.outsidePressure = outsideBme_.readPressure() / 100.0f;
    }

    if (lightOk_) {
      next.outsideBrightness = outsideLight_.readLightLevel();
    }

    if (htuOk_) {
      next.insideTemperature = insideHtu_.readTemperature();
      next.insideHumidity = insideHtu_.readHumidity();
    }

    next.updatedMs = millis();
    next.valid =
        isfinite(next.outsideTemperature) &&
        isfinite(next.outsideHumidity) &&
        isfinite(next.outsidePressure) &&
        isfinite(next.outsideBrightness) &&
        next.outsideBrightness >= 0.0f &&
        isfinite(next.insideTemperature) &&
        isfinite(next.insideHumidity);

    readings_ = next;

    if (!next.valid) {
      Serial.println("[SENSOR] invalid reading; skipping logging and automation");
    }
  }

  Adafruit_BME280 outsideBme_;
  BH1750 outsideLight_;
  Adafruit_HTU21DF insideHtu_;

  SensorReadings readings_;
  bool bmeOk_ = false;
  bool lightOk_ = false;
  bool htuOk_ = false;
  uint32_t lastSampleMs_ = 0;
};

WifiController.hpp

#pragma once

#include <Arduino.h>
#include <WiFi.h>

#include "AppConfig.hpp"

class WifiController {
 public:
  void begin() {
    WiFi.mode(WIFI_STA);
    WiFi.setHostname("window-stepper");
    WiFi.begin(Config::WIFI_SSID, Config::WIFI_PASSWORD);

    Serial.print("[WIFI] connecting");
    const uint32_t startMs = millis();

    while (!connected() && (millis() - startMs) < 15000UL) {
      delay(300);
      Serial.print(".");
    }

    Serial.println();

    if (connected()) {
      Serial.printf("[WIFI] connected: %s\n", ip().c_str());
    } else {
      Serial.println("[WIFI] not connected");
    }
  }

  void service() {
    if (connected()) return;

    const uint32_t now = millis();
    if ((now - lastReconnectMs_) < Config::WIFI_RECONNECT_INTERVAL_MS) return;

    lastReconnectMs_ = now;
    Serial.println("[WIFI] reconnecting");
    WiFi.reconnect();
  }

  bool connected() const {
    return WiFi.status() == WL_CONNECTED;
  }

  String hostname() const {
    return WiFi.getHostname();
  }

  String ip() const {
    return WiFi.localIP().toString();
  }

  int32_t rssi() const {
    return WiFi.RSSI();
  }

 private:
  uint32_t lastReconnectMs_ = 0;
};

WindowController.hpp

#pragma once

#include <Arduino.h>
#include <FastAccelStepper.h>
#include <TMCStepper.h>

#include "AppConfig.hpp"

class WindowController {
 public:
  enum class State {
    Booting,
    Homing,
    Closed,
    Opening,
    Open,
    Closing,
    Error,
  };

  struct HomingResult {
    bool ok = false;
    uint16_t minSg = 65535;
    uint16_t finalSg = 65535;
    int32_t finalPosition = 0;
    uint32_t elapsedMs = 0;
  };

  WindowController()
      : tmcSerial_(2),
        tmcDriver_(&tmcSerial_, Config::TMC_R_SENSE, Config::TMC_ADDRESS) {}

  bool begin() {
    const bool tmcReady = configureTmc2209();
    const bool stepperReady = configureStepper();

    if (!tmcReady || !stepperReady) {
      setError(lastError_.length() ? lastError_ : "startup failed");
      return false;
    }

    if (Config::DEBUG_ASSUME_HOMED_ON_START) {
      stepper_->setCurrentPosition(Config::CLOSED_STEPS);
      homed_ = true;
      state_ = State::Closed;
      lastError_ = "";
      Serial.println("[HOME] skipped: DEBUG_ASSUME_HOMED_ON_START is enabled");
      return true;
    }

    return homeClosedBlocking().ok;
  }

  bool open() {
    if (!canAcceptMotionCommand()) {
      Serial.println("[CMD] open rejected: not homed or error state");
      return false;
    }

    setNormalMotionConfig();
    Serial.printf("[CMD] open -> %ld steps\n", (long)Config::OPEN_STEPS);
    stepper_->moveTo(Config::OPEN_STEPS);
    state_ = State::Opening;
    return true;
  }

  bool close() {
    if (!canAcceptMotionCommand()) {
      Serial.println("[CMD] close rejected: not homed or error state");
      return false;
    }

    setNormalMotionConfig();
    Serial.printf("[CMD] close -> %ld steps\n", (long)Config::CLOSED_STEPS);
    stepper_->moveTo(Config::CLOSED_STEPS);
    state_ = State::Closing;
    return true;
  }

  bool toggle() {
    if (!canAcceptMotionCommand()) {
      Serial.println("[CMD] toggle rejected: not homed or error state");
      return false;
    }

    switch (state_) {
      case State::Closed:
      case State::Closing:
        return open();

      case State::Open:
      case State::Opening:
        return close();

      default:
        break;
    }

    return currentPosition() < (Config::OPEN_STEPS / 2) ? open() : close();
  }

  void service() {
    if (!stepper_ || stepper_->isRunning()) return;

    if (state_ == State::Opening) {
      state_ = State::Open;
      Serial.printf("[STATE] open, pos=%ld\n", (long)currentPosition());
    } else if (state_ == State::Closing) {
      state_ = State::Closed;
      Serial.printf("[STATE] closed, pos=%ld\n", (long)currentPosition());
    }
  }

  State state() const {
    return state_;
  }

  const char *stateName() const {
    switch (state_) {
      case State::Booting: return "booting";
      case State::Homing:  return "homing";
      case State::Closed:  return "closed";
      case State::Opening: return "opening";
      case State::Open:    return "open";
      case State::Closing: return "closing";
      case State::Error:   return "error";
    }

    return "unknown";
  }

  bool isMoving() const {
    return stepper_ && stepper_->isRunning();
  }

  bool isOpen() const {
    return state_ == State::Open;
  }

  bool hasCompletedPosition() const {
    return state_ == State::Open || state_ == State::Closed;
  }

  bool homed() const {
    return homed_;
  }

  bool hasError() const {
    return state_ == State::Error;
  }

  const String &lastError() const {
    return lastError_;
  }

  int32_t currentPosition() const {
    return stepper_ ? stepper_->getCurrentPosition() : 0;
  }

  bool tmcOk() const {
    return tmcOk_;
  }

  uint8_t lastTmcTestConnection() const {
    return lastTmcTestConnection_;
  }

  uint8_t lastIfc0() const {
    return lastIfc0_;
  }

  uint8_t lastIfc1() const {
    return lastIfc1_;
  }

  uint8_t lastIfc2() const {
    return lastIfc2_;
  }

  uint32_t lastIoin() const {
    return lastIoin_;
  }

  uint32_t lastDrvStatus() const {
    return lastDrvStatus_;
  }

  uint16_t lastSgInitial() const {
    return lastSgInitial_;
  }

  uint16_t lastHomeMinSg() const {
    return lastHomeMinSg_;
  }

  uint16_t lastHomeFinalSg() const {
    return lastHomeFinalSg_;
  }

  uint32_t lastHomeElapsedMs() const {
    return lastHomeElapsedMs_;
  }

 private:
  void enableDriver(bool enable) {
    digitalWrite(Config::PIN_TMC_EN, enable ? LOW : HIGH);
  }

  void waitForStepper() {
    while (stepper_ && stepper_->isRunning()) {
      delay(1);
    }
  }

  void forceStopAndWait() {
    if (!stepper_) return;

    stepper_->forceStop();
    waitForStepper();
  }

  void setError(const String &message) {
    lastError_ = message;
    state_ = State::Error;
    homed_ = false;
    Serial.printf("[ERR] %s\n", message.c_str());
  }

  void setNormalMotionConfig() {
    if (!stepper_) return;

    tmcDriver_.rms_current(Config::RUN_CURRENT_MA,
                           Config::HOLD_CURRENT_MULTIPLIER);
    stepper_->setSpeedInHz(Config::MOVE_SPEED_HZ);
    stepper_->setAcceleration(Config::MOVE_ACCEL_HZ_S2);
  }

  bool verifyTmcUart() {
    lastTmcTestConnection_ = tmcDriver_.test_connection();

    Serial.printf(
        "[TMC] test_connection=%u  0=ideal, 1=all-ones, 2=all-zero DRV_STATUS\n",
        lastTmcTestConnection_);

    lastIfc0_ = tmcDriver_.IFCNT();

    tmcDriver_.pdn_disable(true);
    delay(5);
    lastIfc1_ = tmcDriver_.IFCNT();

    tmcDriver_.mstep_reg_select(true);
    delay(5);
    lastIfc2_ = tmcDriver_.IFCNT();

    lastIoin_ = tmcDriver_.IOIN();
    lastDrvStatus_ = tmcDriver_.DRV_STATUS();
    lastSgInitial_ = tmcDriver_.SG_RESULT();

    Serial.printf("[TMC] IFCNT: %u -> %u -> %u\n",
                  lastIfc0_, lastIfc1_, lastIfc2_);
    Serial.printf("[TMC] IOIN=0x%08lx DRV_STATUS=0x%08lx SG_RESULT=%u\n",
                  (unsigned long)lastIoin_,
                  (unsigned long)lastDrvStatus_,
                  lastSgInitial_);

    const bool writesWork =
        (lastIfc1_ != lastIfc0_) || (lastIfc2_ != lastIfc1_);
    const bool ioinLooksAlive =
        lastIoin_ != 0x00000000UL && lastIoin_ != 0xFFFFFFFFUL;

    if (!writesWork) {
      Serial.println("[TMC] UART verification failed: IFCNT did not change");
      return false;
    }

    if (!ioinLooksAlive) {
      Serial.println("[TMC] UART verification failed: IOIN is all-zero/all-one");
      return false;
    }

    Serial.println("[TMC] UART verification OK");
    return true;
  }

  bool configureTmc2209() {
    pinMode(Config::PIN_TMC_EN, OUTPUT);
    enableDriver(true);

    tmcSerial_.begin(Config::TMC_UART_BAUD, SERIAL_8N1,
                     Config::PIN_TMC_RX, Config::PIN_TMC_TX);
    tmcDriver_.begin();

    tmcDriver_.pdn_disable(true);
    tmcDriver_.mstep_reg_select(true);
    tmcDriver_.I_scale_analog(false);
    tmcDriver_.toff(4);
    tmcDriver_.blank_time(24);
    tmcDriver_.en_spreadCycle(false);
    tmcDriver_.pwm_autoscale(true);
    tmcDriver_.pwm_autograd(true);
    tmcDriver_.intpol(true);
    tmcDriver_.TPWMTHRS(0);
    tmcDriver_.TCOOLTHRS(0xFFFFF);
    tmcDriver_.SGTHRS(Config::TMC_SGTHRS);
    tmcDriver_.microsteps(Config::MICROSTEPS);
    tmcDriver_.rms_current(Config::RUN_CURRENT_MA,
                           Config::HOLD_CURRENT_MULTIPLIER);
    tmcDriver_.freewheel(0);
    tmcDriver_.TPOWERDOWN(20);

    delay(50);

    tmcOk_ = verifyTmcUart();
    if (!tmcOk_) {
      lastError_ = "TMC2209 UART verification failed";
      return false;
    }

    Serial.println("[TMC] configured");
    return true;
  }

  bool configureStepper() {
    stepperEngine_.init();

    stepper_ = stepperEngine_.stepperConnectToPin(Config::PIN_STEP);
    if (!stepper_) {
      lastError_ = "Failed to attach STEP pin";
      return false;
    }

    stepper_->setDirectionPin(Config::PIN_DIR, !Config::INVERT_MOTOR_DIR);
    stepper_->setSpeedInHz(Config::MOVE_SPEED_HZ);
    stepper_->setAcceleration(Config::MOVE_ACCEL_HZ_S2);
    stepper_->setCurrentPosition(Config::CLOSED_STEPS);

    Serial.println("[STEP] configured");
    return true;
  }

  HomingResult homeClosedBlocking() {
    HomingResult result;

    if (!tmcOk_) {
      setError("Cannot home: TMC UART is not OK");
      return result;
    }

    if (!stepper_) {
      setError("Cannot home: stepper is not configured");
      return result;
    }

    state_ = State::Homing;
    homed_ = false;
    lastError_ = "";

    Serial.println();
    Serial.println("[HOME] starting sensorless close-home");

    tmcDriver_.rms_current(Config::HOMING_CURRENT_MA,
                           Config::HOLD_CURRENT_MULTIPLIER);
    tmcDriver_.TCOOLTHRS(0xFFFFF);
    tmcDriver_.SGTHRS(Config::TMC_SGTHRS);
    delay(180);

    stepper_->setSpeedInHz(Config::HOMING_SPEED_HZ);
    stepper_->setAcceleration(Config::HOMING_ACCEL_HZ_S2);

    const uint32_t startMs = millis();
    const uint32_t accelTimeMs =
        (Config::HOMING_SPEED_HZ * 1000UL) / Config::HOMING_ACCEL_HZ_S2;
    const uint32_t ignoreUntilMs =
        startMs + accelTimeMs + Config::HOMING_EXTRA_IGNORE_MS;

    uint32_t lastPollMs = 0;
    uint8_t consecutiveHits = 0;

    Serial.printf("[HOME] max travel = %ld steps\n",
                  (long)Config::MAX_HOMING_STEPS);
    Serial.printf("[HOME] speed = %lu steps/s, current = %u mA RMS\n",
                  (unsigned long)Config::HOMING_SPEED_HZ,
                  Config::HOMING_CURRENT_MA);
    Serial.printf("[HOME] SG trigger <= %u for %u polls\n",
                  Config::SG_SOFTWARE_THRESHOLD,
                  Config::SG_CONSECUTIVE_HITS_REQUIRED);

    stepper_->move(-Config::MAX_HOMING_STEPS);

    while (stepper_->isRunning()) {
      const uint32_t now = millis();

      if ((now - lastPollMs) >= Config::SG_POLL_MS) {
        lastPollMs = now;

        const uint16_t sg = tmcDriver_.SG_RESULT();
        result.minSg = min(result.minSg, sg);
        result.finalSg = sg;
        result.finalPosition = stepper_->getCurrentPosition();

        const bool detectionEnabled = now >= ignoreUntilMs;
        const bool hit =
            detectionEnabled && (sg <= Config::SG_SOFTWARE_THRESHOLD);

        consecutiveHits = hit ? consecutiveHits + 1 : 0;

        Serial.printf("[HOME] pos=%ld sg=%u min=%u hits=%u%s\n",
                      (long)result.finalPosition,
                      sg,
                      result.minSg,
                      consecutiveHits,
                      detectionEnabled ? "" : " ignored");

        if (consecutiveHits >= Config::SG_CONSECUTIVE_HITS_REQUIRED) {
          result.ok = true;
          break;
        }
      }

      delay(1);
    }

    forceStopAndWait();

    result.elapsedMs = millis() - startMs;
    result.finalPosition = stepper_->getCurrentPosition();
    lastHomeMinSg_ = result.minSg;
    lastHomeFinalSg_ = result.finalSg;
    lastHomeElapsedMs_ = result.elapsedMs;

    if (result.ok) {
      stepper_->setCurrentPosition(Config::CLOSED_STEPS);
      homed_ = true;
      state_ = State::Closed;
      lastError_ = "";

      Serial.printf(
          "[HOME] OK: zeroed closed position, minSG=%u finalSG=%u elapsed=%lu ms\n",
          result.minSg,
          result.finalSg,
          (unsigned long)result.elapsedMs);
    } else {
      setError("Homing failed: max travel reached without stall");
      Serial.printf("[HOME] FAILED: minSG=%u finalSG=%u elapsed=%lu ms\n",
                    result.minSg,
                    result.finalSg,
                    (unsigned long)result.elapsedMs);
    }

    setNormalMotionConfig();
    delay(300);
    return result;
  }

  bool canAcceptMotionCommand() const {
    return stepper_ && tmcOk_ && homed_ && state_ != State::Error;
  }

  HardwareSerial tmcSerial_;
  TMC2209Stepper tmcDriver_;
  FastAccelStepperEngine stepperEngine_;
  FastAccelStepper *stepper_ = nullptr;

  State state_ = State::Booting;
  bool tmcOk_ = false;
  bool homed_ = false;
  String lastError_;

  uint8_t lastTmcTestConnection_ = 255;
  uint8_t lastIfc0_ = 0;
  uint8_t lastIfc1_ = 0;
  uint8_t lastIfc2_ = 0;
  uint32_t lastIoin_ = 0;
  uint32_t lastDrvStatus_ = 0;
  uint16_t lastSgInitial_ = 0;

  uint16_t lastHomeMinSg_ = 0;
  uint16_t lastHomeFinalSg_ = 0;
  uint32_t lastHomeElapsedMs_ = 0;
};

Control Panel

I used Grafana to make a control panel for the system. With some extensions, it is possible to create custom panels that work as buttons, and send HTTP requests when clicked, or display the value returned by an HTTP endpoint.

image

A red vertical bar shows when a window close event happened, while green shows open.

The Inside Temperature graph shows how the system is able to regulate indoor temperature, on its own. A future feature is to have more than just a fully open and fully closed state, and instead employ some PID-control like scheme, to continuously adjust the window position, and hold a specific temperature.

There are a few more graphs as well:

image

WindowTron In Operation

Homing on Boot

You don't have permission to edit metadata of this video.
Edit media
x
image
Upload Preview
image

This is what it looked like on the Control Panel:

image

Homing happened on boot, which reported a 'Window Closed' event, the vertical red line. Then, the algorithm saw that the inside temperature is over the setpoint, and sent an open command. 

An interesting detail is that outside temperature is higher than the setpoint too, but it's colder than the inside temp, so the window is still opened. If the outside temperature were to surpass the inside temperature, the window would close.

Open/Close Cycle

You don't have permission to edit metadata of this video.
Edit media
x
image
Upload Preview
image

In-Progress Features

From the start of the redesign, the scope of this project has expanded significantly. The goal was no longer just to control temperature through opening and closing a window, but to build a platform for full environmental control. Unfortunately, I couldn’t get all of the features fully ready and tested in time, however, they are all completely thought out, and partially implemented too.

AC Control Integration

I managed to figure out how to control my air conditioner over WiFi, so the ESP32 will be able to turn the AC on to maintain the setpoint temperature, in case the outside air is too warm.

I can already manually control the AC through the ESP32, but I didn’t have time to modify and experiment with control algorithms to fully incorporate this feature yet, and have it work seamlessly.

There are also a few extra hardware challenges, like the fact that AC condensate water collects into a bucket, which can overflow. I have a contactless water level sensor installed, which connects to the ESP32 as well, so it will be able to turn off the AC in case the bucket gets full.

image

Rainstorm Mode

In extreme cases, water can enter the room through an open window, in case of a rainstorm. Initially I wanted to mount a rain sensor next to the outside temperature sensor, but I found a significantly better way: since the ESP32 has internet access, it will query a Weather API, and close the window in cases of heavy rainfall.

 

Air Quality Protection

I have an Air Purifier that also connects over WiFi, and I can query its Air Quality sensor, and control the fan speed, from the ESP32.

An Air Quality sensor will be mounted outdoors, and connected to the ESP32.

The ESP32 logic will be modified so that both inside and outside Air Quality is monitored, and the window is controlled accordingly:

  • If the air quality gets bad outside, the window is closed, and the AC is used to maintain setpoint temperature instead
  • If the air quality gets bad inside, the window is opened, regardless of temperature. The AC is used to maintain setpoint temperature instead, either heating or cooling.

Future Plans

The biggest upgrade I want to do is setting up multi-zone temperature sensing indoors. The current setup has the inside sensor on the side of a bedside table, which works great when in bed, but not so much when I'm sitting next to a hot computer in the opposite corner of the room.

The plan is to make small ESP8266-SHT20 PCBs that wirelessly transmit temperature, put a handful of them around a room, then use a mmWave radar module for presence detection and figuring out which temperature source to use.

Integrating presence sensing would be incredibly useful, as it opens the door to integrating even more smart home features, like smart lamp control, AC power saving mode, etc.

  • Sign in to reply
element14 Community

element14 is the first online community specifically for engineers. Connect with your peers and get expert answers to your questions.

  • Members
  • Learn
  • Technologies
  • Challenges & Projects
  • Products
  • Store
  • About Us
  • Feedback & Support
  • FAQs
  • Terms of Use
  • Privacy Policy
  • Legal and Copyright Notices
  • Sitemap
  • Cookies

An Avnet Company © 2026 Premier Farnell Limited. All Rights Reserved.

Premier Farnell Ltd, registered in England and Wales (no 00876412), registered office: Farnell House, Forge Lane, Leeds LS12 2NE.

ICP 备案号 10220084.

Follow element14

  • X
  • Facebook
  • linkedin
  • YouTube