// Thermostat: heating and cooling with a setpoint (Alexa.ThermostatController, Alexa.TemperatureSensor). // // Modes HEAT, COOL, AUTO and OFF. HEAT and COOL have one setpoint; AUTO has two, it heats below the lower and // cools above the upper one. Setpoints are 10 to 30 degrees Celsius and, in AUTO, at least 2 degrees apart. // A directive in Fahrenheit or Kelvin is converted; the thermostat reports in Celsius. // // What is refused, and with which error: // a setpoint outside of 10 to 30 degrees TEMPERATURE_VALUE_OUT_OF_RANGE, with the range // two setpoints closer than 2 degrees REQUESTED_SETPOINTS_TOO_CLOSE, with the distance // two setpoints in HEAT or COOL DUAL_SETPOINTS_UNSUPPORTED // a setpoint while the thermostat is OFF THERMOSTAT_IS_OFF // a mode other than the four UNSUPPORTED_THERMOSTAT_MODE // // The sensor is a TMP36 on A0 of a Wemos D1 mini (A0 takes 0 to 3.2 V); two outputs switch the heating and the // cooling. #include #include #include // Wi-Fi and the credentials of your Alex2MQTT account const char *WIFI_SSID = ""; const char *WIFI_PASSWORD = ""; const char *ALEXA_USERNAME = ""; const char *ALEXA_PASSWORD = ""; const char *ALEXA_ROOT_TOPIC = ""; const uint8_t HEAT_PIN = 5; // D1 on a Wemos D1 mini, high = on const uint8_t COOL_PIN = 4; // D2 const float LOWEST = 10; // degrees Celsius const float HIGHEST = 30; const float DISTANCE = 2; // between the two setpoints of AUTO const float HYSTERESIS = 0.5f; Alex2ESP alexa; AlexaThermostatMode mode = AlexaThermostatMode::HEAT; float target = 21; // HEAT and COOL float lower = 19; // AUTO float upper = 24; float temperature = 0; unsigned long lastRead = 0; // Joins the Wi-Fi network and returns after 30 s at the latest. Without a connection the sketch carries on: the // ESP8266 keeps trying, and alexa.loop() opens the MQTT session once Wi-Fi is up. void connectWiFi() { WiFi.mode(WIFI_STA); WiFi.begin(WIFI_SSID, WIFI_PASSWORD); Serial.printf("\n[WIFI] Connecting to \"%s\"\n", WIFI_SSID); unsigned long started = millis(); while (WiFi.status() != WL_CONNECTED && millis() - started < 30000) { delay(100); } if (WiFi.status() == WL_CONNECTED) { Serial.printf("[WIFI] Connected, IP address %s\n", WiFi.localIP().toString().c_str()); } else { Serial.printf("[WIFI] No connection after 30 s (status %d): check WIFI_SSID and WIFI_PASSWORD. Still trying.\n", WiFi.status()); } } // TMP36: 0.5 V at 0 degrees Celsius, 10 mV per degree float readTemperature() { float volts = analogRead(A0) * 3.2f / 1023; return (volts - 0.5f) * 100; } // A temperature of a directive, {"value": 70, "scale": "FAHRENHEIT"}, in degrees Celsius float toCelsius(JsonVariantConst temperature) { float value = temperature["value"] | 0.0f; const char *scale = temperature["scale"] | "CELSIUS"; if (strcmp(scale, "FAHRENHEIT") == 0) { return (value - 32) * 5 / 9; } return strcmp(scale, "KELVIN") == 0 ? value - 273.15f : value; } // A difference between two temperatures: one degree Fahrenheit is 5/9 of a degree Celsius float differenceInCelsius(JsonVariantConst difference) { float value = difference["value"] | 0.0f; return strcmp(difference["scale"] | "CELSIUS", "FAHRENHEIT") == 0 ? value * 5 / 9 : value; } void setCelsius(JsonObject temperature, float value) { temperature["value"] = value; temperature["scale"] = "CELSIUS"; } bool withinRange(float setpoint) { return setpoint >= LOWEST && setpoint <= HIGHEST; } void sendOutOfRange(AlexaDirective &directive) { AlexaStatusMessage error = directive.error(AlexaErrorType::TEMPERATURE_VALUE_OUT_OF_RANGE, "10 to 30 degrees Celsius"); JsonObject range = error.payload()["validRange"].to(); setCelsius(range["minimumValue"].to(), LOWEST); setCelsius(range["maximumValue"].to(), HIGHEST); error.send(); } // The state of the thermostat: what the answer to a directive and the answer to ReportState carry void sendState(AlexaStatusMessage message) { message.addHealthProp(EndpointHealth::OK) .addTemperatureSensorProp(temperature, TemperatureSensorScale::CELSIUS, millis() - lastRead) .addThermostatModeProp(mode); if (mode == AlexaThermostatMode::AUTO) { message.addThermostatDualSetpointProp(lower, upper); } else { message.addThermostatSetpointProp(target); } message.send(); } // Takes the setpoints when the thermostat can use them and returns true; sends the error and returns false when not. // One setpoint in AUTO moves both and keeps their distance. bool setSetpoints(AlexaDirective &directive, bool two, float first, float second) { if (mode == AlexaThermostatMode::OFF) { directive.error(AlexaErrorType::THERMOSTAT_IS_OFF, "Set a mode first").send(); return false; } if (two && mode != AlexaThermostatMode::AUTO) { directive.error(AlexaErrorType::DUAL_SETPOINTS_UNSUPPORTED, "Two setpoints in AUTO only").send(); return false; } if (!two && mode == AlexaThermostatMode::AUTO) { float half = (upper - lower) / 2; second = first + half; first = first - half; } if (mode != AlexaThermostatMode::AUTO) { if (!withinRange(first)) { sendOutOfRange(directive); return false; } target = first; return true; } if (!withinRange(first) || !withinRange(second)) { sendOutOfRange(directive); return false; } if (second - first < DISTANCE) { AlexaStatusMessage error = directive.error(AlexaErrorType::REQUESTED_SETPOINTS_TOO_CLOSE, "At least 2 degrees apart"); setCelsius(error.payload()["minimumTemperatureDelta"].to(), DISTANCE); error.send(); return false; } lower = first; upper = second; return true; } bool setMode(const char *name) { if (strcmp(name, "HEAT") == 0) { mode = AlexaThermostatMode::HEAT; } else if (strcmp(name, "COOL") == 0) { mode = AlexaThermostatMode::COOL; } else if (strcmp(name, "AUTO") == 0) { mode = AlexaThermostatMode::AUTO; } else if (strcmp(name, "OFF") == 0) { mode = AlexaThermostatMode::OFF; } else { return false; } return true; } void onDirective(AlexaDirective &directive) { if (directive.isReportState()) { sendState(directive.stateReport()); return; } JsonObjectConst payload = directive.payload; if (directive.is("SetTargetTemperature")) { bool two = !payload["lowerSetpoint"].isNull() && !payload["upperSetpoint"].isNull(); bool taken = two ? setSetpoints(directive, true, toCelsius(payload["lowerSetpoint"]), toCelsius(payload["upperSetpoint"])) : setSetpoints(directive, false, toCelsius(payload["targetSetpoint"]), 0); if (!taken) { return; } } else if (directive.is("AdjustTargetTemperature")) { float centre = mode == AlexaThermostatMode::AUTO ? (lower + upper) / 2 : target; if (!setSetpoints(directive, false, centre + differenceInCelsius(payload["targetSetpointDelta"]), 0)) { return; } } else if (directive.is("SetThermostatMode")) { if (!setMode(payload["thermostatMode"]["value"] | "")) { directive.error(AlexaErrorType::UNSUPPORTED_THERMOSTAT_MODE, "HEAT, COOL, AUTO or OFF").send(); return; } } else { directive.error(AlexaErrorType::INVALID_DIRECTIVE, "This thermostat takes setpoints and modes").send(); return; } sendState(directive.response()); } // The "configuration" of the capability in discovery void thermostatConfiguration(JsonObject configuration, void *) { JsonArray modes = configuration["supportedModes"].to(); modes.add("HEAT"); modes.add("COOL"); modes.add("AUTO"); modes.add("OFF"); configuration["supportsScheduling"] = false; } // Switches on below the setpoint and off above it, with half a degree between the two void control() { bool heats = mode == AlexaThermostatMode::HEAT || mode == AlexaThermostatMode::AUTO; bool cools = mode == AlexaThermostatMode::COOL || mode == AlexaThermostatMode::AUTO; float heatBelow = mode == AlexaThermostatMode::AUTO ? lower : target; float coolAbove = mode == AlexaThermostatMode::AUTO ? upper : target; if (!heats || temperature >= heatBelow + HYSTERESIS) { digitalWrite(HEAT_PIN, LOW); } else if (temperature <= heatBelow - HYSTERESIS) { digitalWrite(HEAT_PIN, HIGH); } if (!cools || temperature <= coolAbove - HYSTERESIS) { digitalWrite(COOL_PIN, LOW); } else if (temperature >= coolAbove + HYSTERESIS) { digitalWrite(COOL_PIN, HIGH); } } void setup() { Serial.begin(74880); pinMode(HEAT_PIN, OUTPUT); pinMode(COOL_PIN, OUTPUT); temperature = readTemperature(); connectWiFi(); // MQTT user name, MQTT password, root topic alexa.begin(ALEXA_USERNAME, ALEXA_PASSWORD, ALEXA_ROOT_TOPIC); // The name Alexa shows, and the id of the endpoint: every device of an account has its own AlexaDevice *thermostat = alexa.getDevice("Hallway Thermostat", "esp-thermostat"); thermostat->setDisplayCategory(DisplayCategory::THERMOSTAT); thermostat->addCapability(AlexaInterfaces::ThermostatController)->setConfiguration(thermostatConfiguration); thermostat->addCapability(AlexaInterfaces::TemperatureSensor); thermostat->addCapability(AlexaInterfaces::EndpointHealth); thermostat->onDirective(onDirective); } void loop() { alexa.loop(); if (millis() - lastRead >= 2000) { lastRead = millis(); temperature = readTemperature(); control(); } }