ranczo-io/services/floorheat_svc/temperature_controller.cpp
2025-11-18 14:58:48 +01:00

815 lines
30 KiB
C++

#include "temperature_controller.hpp"
#include "config.hpp"
#include <boost/asio/co_spawn.hpp>
#include <boost/asio/detached.hpp>
#include <boost/asio/experimental/parallel_group.hpp>
#include <boost/asio/redirect_error.hpp>
#include <boost/asio/steady_timer.hpp>
#include <boost/smart_ptr/shared_ptr.hpp>
#include <boost/system/detail/errc.hpp>
#include <boost/system/detail/error_category.hpp>
#include <boost/system/detail/error_code.hpp>
#include <boost/system/errc.hpp>
#include <boost/system/result.hpp>
#include <functional>
#include <memory>
#include <optional>
#include <spdlog/spdlog.h>
#include "ranczo-io/utils/config.hpp"
#include "services/floorheat_svc/relay.hpp"
#include "thermometer.hpp"
#include <ranczo-io/utils/json_helpers.hpp>
#include <ranczo-io/utils/mqtt_client.hpp>
#include <ranczo-io/utils/mqtt_topic_builder.hpp>
#include <chrono>
#include <boost/asio/awaitable.hpp>
#include <boost/circular_buffer.hpp>
#include <boost/core/noncopyable.hpp>
#include <boost/json/object.hpp>
#include <string>
#include <string_view>
#include <type_traits>
#include <ranczo-io/utils/time.hpp>
namespace ranczo {
enum class ThermostatState { Enabled, Disabled, Error };
enum class Trend { Fall, Const, Rise };
std::optional< ThermostatState > ThermostatState_from_string(std::optional< std::string > state) {
if(not state) {
return std::nullopt;
}
std::string s(state->begin(), state->end());
std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return static_cast< char >(std::tolower(c)); });
if(s == "enabled")
return ThermostatState::Enabled;
if(s == "disabled")
return ThermostatState::Disabled;
if(s == "error")
return ThermostatState::Error;
return std::nullopt;
}
std::string ThermostatState_to_string(ThermostatState state) {
switch(state) {
case ThermostatState::Enabled:
return "Enabled";
case ThermostatState::Disabled:
return "Disabled";
default:
return "Error";
}
}
/**
* @brief readValue
* @param jv
* @param key
* @return
*/
template < typename T >
inline expected< T > readValue(const boost::json::value & jv, std::string_view key) {
if(auto * obj = jv.if_object()) {
if(auto * pv = obj->if_contains(key)) {
if constexpr(std::is_same_v< double, T >) {
auto ovalue = json::as_number(*pv).value();
if(ovalue)
return ovalue;
} else if constexpr(std::is_same_v< ThermostatState, T >) {
if(!pv->is_string()) {
return unexpected{make_error_code(boost::system::errc::invalid_argument)};
}
auto v = ThermostatState_from_string(std::make_optional< std::string >(pv->as_string()));
if(not v)
return unexpected{make_error_code(boost::system::errc::invalid_argument)};
return *v;
} else if constexpr(is_chrono_duration_v< T >) {
if(pv->is_string()) {
auto sv = pv->as_string();
std::string_view sv_view(sv.data(), sv.size());
return parse_duration_from_string< T >(sv_view);
}
}
}
}
return unexpected{make_error_code(boost::system::errc::invalid_argument)};
}
namespace commands {
/**
* @brief The TemperatureSetpointChange class, main temperature setpoint
*/
struct TemperatureSetpointChange {
double setpoint_c{};
static ranczo::expected< TemperatureSetpointChange > from_payload(const boost::json::value & payload) {
TemperatureSetpointChange cmd{};
cmd.setpoint_c = TRY(readValue< double >(payload, "value"));
return cmd;
}
static constexpr std::string_view topic_suffix = "setpoint";
};
/**
* @brief The StateChange class
*/
struct StateChange {
ThermostatState state{};
static expected< StateChange > from_payload(const boost::json::value & payload) {
StateChange cmd{};
cmd.state = TRY(readValue< ThermostatState >(payload, "value"));
return cmd;
}
static constexpr std::string_view topic_suffix = "state";
};
/**
* @brief The HisteresisChange class, represents the maximum histeresis
*/
struct HisteresisChange {
double histeresis{};
static expected< HisteresisChange > from_payload(const boost::json::value & payload) {
HisteresisChange cmd{};
cmd.histeresis = TRY(readValue< double >(payload, "value"));
return cmd;
}
static constexpr std::string_view topic_suffix = "hysteresis";
};
/**
* @brief The TickTimeChange class, represents the minimum time of on/off state of relay,
* so for example time of 1 minute means that a relay should be enabled (or disabled) by at least 1 minute at a time. (in normal
* situations) for any exception the relay should be disabled in 'emergency mode'
*/
struct TickTimeChange {
std::chrono::nanoseconds tickTime;
static expected< TickTimeChange > from_payload(const boost::json::value & payload) {
TickTimeChange cmd{};
cmd.tickTime = TRY(readValue< std::chrono::nanoseconds >(payload, "value"));
return cmd;
}
static constexpr std::string_view topic_suffix = "tick_time";
};
/**
* @brief The SlopeWindowChange class,
* represents time and value in which we test for temperature fluctuations.
* @example time 5 minutes, and dt = 1 means that if dT over last 5 min is greater than 1C, we are talking about rising temperature
*/
struct SlopeWindowChange {
std::chrono::nanoseconds window;
static expected< SlopeWindowChange > from_payload(const boost::json::value & payload) {
SlopeWindowChange cmd{};
cmd.window = TRY(readValue< std::chrono::nanoseconds >(payload, "value"));
return cmd;
}
static constexpr std::string_view topic_suffix = "slope_window";
};
struct SlopeTemperatureDiffChange {
double dT_c;
static expected< SlopeTemperatureDiffChange > from_payload(const boost::json::value & payload) {
SlopeTemperatureDiffChange cmd{};
cmd.dT_c = TRY(readValue< double >(payload, "value"));
return cmd;
}
static constexpr std::string_view topic_suffix = "slope_window";
};
} // namespace commands
/**
* @brief The ThermometerMeasurements class. It listens to thermometer async temperature update and stores those as a linear history
* @todo We can read the history of measurements from PG database that stores them,
* optional as we will not reload this service often so we can store our own measurements for couple of minutes
*/
struct ThermometerMeasurements {
struct Measurement {
std::chrono::system_clock::time_point when;
Thermometer::ThermometerData data;
};
executor & _io;
std::unique_ptr< Thermometer > _sensor;
boost::circular_buffer< Measurement > _history;
ThermometerMeasurements(executor & io, std::unique_ptr< Thermometer > sensor) : _io{io}, _sensor{std::move(sensor)}, _history{200} {}
/**
* @brief start the service, we can't make it happen in ctor so we need a different approach
* @return
*/
awaitable_expected< void > subscribeToTemperatureUpdate() {
return _sensor->on_update([&](auto data) { return this->temperatureUpdateCallback(data); });
}
awaitable_expected< void > temperatureUpdateCallback(Thermometer::ThermometerData data) {
// circular buffer, no need to clean
/// TODO thermometer sometimes gives a temp like 80C which is a fluck, we need to filter this
_history.push_back({std::chrono::system_clock::now(), data});
co_return _void{};
}
awaitable_expected< void > start() {
BOOST_ASSERT(_sensor);
// subscribe to a thermometer readings
ASYNC_CHECK_MSG(subscribeToTemperatureUpdate(), "subscribtion to temperature stream failed");
// spins own mqtt listener 'thread' and executes on update callback on every temperature update
boost::asio::co_spawn(_io, _sensor->listen(), boost::asio::detached);
co_return _void{};
}
/**
* @brief timeSinceLastRead
* @return a time that passed since last temperature read or nullopt if no measurements are available
*/
std::optional< std::chrono::nanoseconds > timeSinceLastRead() const noexcept {
if(_history.size() == 0)
return std::nullopt;
auto timeDiff = std::chrono::system_clock::now() - _history.back().when;
if(timeDiff < std::chrono::nanoseconds{0}) {
spdlog::warn("temperature measurements are from the future");
}
return timeDiff;
}
/**
* @brief currentTemperature
* @return temperature or nothing if no temp is yet available
*/
std::optional< double > currentTemperature() const noexcept {
if(auto last = timeSinceLastRead(); not last.has_value()) {
return std::nullopt;
}
return _history.back().data.temp_c();
}
/**
* @brief temperatureTrend
* @param window which should be used to get the trend
* @param epsilon_deg_per_min, trend specyfier
* @return a trend if trent can be calculated or nullopt
*/
std::optional< Trend > temperatureTrend(std::chrono::nanoseconds window = std::chrono::minutes(5),
double epsilon_deg_per_min = 0.2) const {
if(auto last = timeSinceLastRead(); not last.has_value()) {
spdlog::debug("No temperature samples available");
return std::nullopt;
}
if(_history.size() < 2) {
spdlog::debug("Too few samples in the last {} minutes, no trend can be calculated",
std::chrono::duration_cast< std::chrono::duration< double, std::ratio< 60 > > >(window).count());
return Trend::Const;
}
const auto now = std::chrono::system_clock::now();
const auto window_start = now - window;
struct Point {
double t_s;
double temp;
std::chrono::system_clock::time_point when;
};
std::vector< Point > pts;
pts.reserve(_history.size());
for(const auto & m : _history) {
if(m.when < window_start || m.when > now)
continue;
double t_s = std::chrono::duration< double >(m.when - window_start).count();
double y = m.data.temp_c();
pts.push_back({t_s, y, m.when});
}
// Posortuj czasowo (przyda się w fallbacku)
std::sort(pts.begin(), pts.end(), [](const Point & a, const Point & b) { return a.when < b.when; });
// Regresja liniowa y = a + b * t
double sum_t{};
double sum_y{};
double sum_tt{};
double sum_ty{};
for(const auto & p : pts) {
sum_t += p.t_s;
sum_y += p.temp;
sum_tt += p.t_s * p.t_s;
sum_ty += p.t_s * p.temp;
}
const double n = static_cast< double >(pts.size());
const double denom = (n * sum_tt - sum_t * sum_t);
double slope_deg_per_s = 0.0;
if(denom != 0.0) {
slope_deg_per_s = (n * sum_ty - sum_t * sum_y) / denom; // b
} else {
// fallback: pochylona między pierwszą i ostatnią próbką
const double dt_s = std::chrono::duration< double >(pts.back().when - pts.front().when).count();
if(dt_s <= 0.0) {
spdlog::warn("No time spread in samples (dt = {}).", dt_s);
return Trend::Const;
}
slope_deg_per_s = (pts.back().temp - pts.front().temp) / dt_s;
}
const double slope_deg_per_min = slope_deg_per_s * 60.0;
spdlog::debug(
"Trend (5 min): samples={}, slope={:.4f} °C/min, threshold={:.4f} °C/min", pts.size(), slope_deg_per_min, epsilon_deg_per_min);
if(slope_deg_per_min > epsilon_deg_per_min)
return Trend::Rise;
if(slope_deg_per_min < -epsilon_deg_per_min)
return Trend::Fall;
return Trend::Const;
}
};
/** implamentation of simple relay thermostat */
struct RelayThermostat::Impl : private boost::noncopyable {
private:
executor & _io;
AsyncMqttClient & _mqtt;
ComponentSettingsStore _settings;
/// relay control
std::unique_ptr< Relay > _relay;
std::chrono::system_clock::time_point _lastStateChange{std::chrono::system_clock::now()};
/// tempareture measurements with history
ThermometerMeasurements _thermo;
/// configuration
std::string _room;
int _zone{};
ThermostatState _state{ThermostatState::Disabled};
double _targetTemperature{0.0};
double _hysteresis{0.5}; // [°C]
std::chrono::nanoseconds _tickTime{std::chrono::seconds(60)}; // minimalny czas ON/OFF
std::chrono::nanoseconds _slopeWindow{std::chrono::minutes(5)};
double _slopeDT_c{0.2}; // [°C / min]
std::chrono::nanoseconds _sensorTimeout{std::chrono::minutes(5)};
enum RuntimeError { NoTemperatureMeasurements = 1, IoError };
struct ErrorCategory : public boost::system::error_category {
// error_category interface
public:
const char * name() const noexcept override {
return "ErrorCategory";
}
std::string message(int ev) const override {
RuntimeError control = static_cast< RuntimeError >(ev);
switch(control) {
case RuntimeError::NoTemperatureMeasurements:
return "NoTemperatureMeasurements";
break;
case RuntimeError::IoError:
return "IoError";
default:
break;
}
}
};
static boost::system::error_code make_error(RuntimeError ec) {
static auto TemperatureControlerCategory = ErrorCategory{};
return boost::system::error_code{static_cast< int >(ec), TemperatureControlerCategory};
}
// --- nowe helpery ---
awaitable_expected< void > controlLoop() {
using namespace std::chrono;
boost::asio::steady_timer timer(_io);
for(;;) {
/// TODO this should be a good point for handling errors
// we got couple of errors to handle
// 1. We cannot disable relay
// 2. We have a relay disabled but temperature rises
// 3. other error, relay state is not knows etc
auto expectedStep = co_await applyControlStep();
if(not expectedStep) {
RuntimeError err = static_cast< RuntimeError >(expectedStep.error().value());
switch(err) {
case RuntimeError::NoTemperatureMeasurements:
/// TODO disable relay
/// TODO set state error
break;
default:
break;
}
/// He can handle uncought error here
/// All of them should be 'panic mode'
}
// Wait tick
timer.expires_after(duration_cast< steady_clock::duration >(_tickTime));
boost::system::error_code ec;
co_await timer.async_wait(boost::asio::redirect_error(boost::asio::use_awaitable, ec));
if(ec == boost::asio::error::operation_aborted) {
co_return _void{}; // zakończenie
}
if(ec) {
co_return unexpected{ec};
}
}
}
awaitable_expected< void > applyControlStep() {
using namespace std::chrono;
switch(_state) {
case ThermostatState::Error:
ASYNC_CHECK(handleErrorState());
break;
case ThermostatState::Disabled:
ASYNC_CHECK(handleDisabledState());
break;
case ThermostatState::Enabled:
ASYNC_CHECK(handleEnabledState());
break;
}
co_return _void{};
}
bool checkLastTemperatureRead() {
// brak update'u temperatury
auto dtLast = _thermo.timeSinceLastRead();
if(dtLast > _sensorTimeout) {
return false;
}
return true;
}
awaitable_expected< bool > preconditions() {
using namespace std::chrono;
// check if temperature is constantly read
if(not checkLastTemperatureRead()) {
spdlog::warn("temperature sensor timeout (> 5 minutes without update) for {}/{}", _room, _zone);
co_return false;
}
auto tempOpt = _thermo.currentTemperature();
if(!tempOpt) {
spdlog::warn("No temperature samples for {}/{}", _room, _zone);
co_return false;
}
auto trendOpt = _thermo.temperatureTrend(_slopeWindow, _slopeDT_c);
if(!trendOpt) {
spdlog::warn("No temperature samples for {}/{} for last {}s",
_room,
_zone,
std::chrono::duration_cast< std::chrono::seconds >(_slopeWindow).count());
co_return false;
}
auto trend = *trendOpt;
// state should be cached
auto mkerr = [](auto) { return make_error(RuntimeError::IoError); };
const bool relayOn = ASYNC_TRY_TRANSFORM_ERROR(_relay->state(), mkerr) == Relay::State::On;
// 2a) relay OFF, a temperatura rośnie => przekaźnik zawiesił się na ON
if(!relayOn && trend == Trend::Rise) {
spdlog::warn("relay stuck ON: temperature rising while relay is commanded OFF for {}/{}", _room, _zone);
co_return false;
}
// 2b) relay ON, a trend != Rise => przekaźnik zawiesił się na OFF
if(relayOn && trend != Trend::Rise) {
spdlog::warn("relay stuck OFF: temperature not rising while relay is commanded ON for {}/{}", _room, _zone);
co_return false;
}
co_return true;
}
awaitable_expected< void > handleErrorState() {
/// check preconditions, if ok release error state
// only relay->state can fail
auto preconditionsMet = ASYNC_TRY(preconditions());
if(preconditionsMet) {
spdlog::info("Switching back to Enabled due to met preconditions {}/{}", _room, _zone);
_state = ThermostatState::Enabled; // should be from previous
} else {
auto relay_on = ASYNC_TRY(_relay->state()) == Relay::State::On;
if(relay_on) {
spdlog::warn("Forcing relay OFF in ERROR state for {}/{}", _room, _zone);
ASYNC_CHECK_MSG(_relay->off(), "Emergency relay OFF failed!");
}
}
co_return _void{};
}
awaitable_expected< void > handleDisabledState() {
auto st = ASYNC_TRY(_relay->state());
if(st == Relay::State::On) {
spdlog::info("RelayThermostat disabling relay because thermostat is Disabled for {}/{}", _room, _zone);
ASYNC_CHECK_MSG(safe_off(), "relay OFF failed"); // TODO basically PANIC mode on fail
}
co_return _void{};
}
awaitable_expected< void > handleEnabledState() {
using namespace std::chrono;
auto preconditionsMet = ASYNC_TRY(preconditions());
if(not preconditionsMet) {
spdlog::warn("RelayThermostat turning set disabled state due to failed preconditions");
_state = ThermostatState::Error;
co_return _void{};
}
auto tempOpt = _thermo.currentTemperature();
const double temp = *tempOpt;
const auto now = system_clock::now();
const auto minElapsed = now - _lastStateChange;
auto st = ASYNC_TRY(_relay->state());
const bool relayOn = (st == Relay::State::On);
// grzejemy jeżeli temp < setpoint - histereza
if(!relayOn) {
if(temp < _targetTemperature - _hysteresis && minElapsed >= _tickTime) {
spdlog::info("RelayThermostat turning relay ON for {}/{} (temp = {}, setpoint = {}, h = {})",
_room,
_zone,
temp,
_targetTemperature,
_hysteresis);
ASYNC_CHECK_MSG(_relay->on(), "Enabling relay failed");
_lastStateChange = now;
}
} else {
// wyłączamy grzanie jeżeli temp > setpoint + histereza
if(temp > _targetTemperature + _hysteresis && minElapsed >= _tickTime) {
spdlog::info("RelayThermostat turning relay OFF for {}/{} (temp = {}, setpoint = {}, h = {})",
_room,
_zone,
temp,
_targetTemperature,
_hysteresis);
ASYNC_CHECK_MSG(safe_off(), "Disabling relay failed");
_lastStateChange = now;
}
}
co_return _void{};
}
awaitable_expected< void > safe_off() {
auto retries = co_await _settings.async_get_store_default("retries", 3);
while(retries) {
// Bezpieczeństwo: wyłącz przekaźnik natychmiastowo
auto expectedState = co_await _relay->state();
if(!expectedState) {
spdlog::warn("Cant get relay {} state", this->_room);
--retries;
continue;
}
auto st = *expectedState;
if(st == Relay::State::On) {
auto expectedOff = co_await _relay->off();
if(!expectedOff) {
spdlog::warn("Cant turn off relay {}", this->_room);
--retries;
continue;
}
}
}
co_return _void{};
}
awaitable_expected< void > error(std::string_view reason) {
if(_state == ThermostatState::Error) {
spdlog::error("RelayThermostat {}/{} additional error while already in ERROR state: {}", _room, _zone, reason);
co_return _void{};
}
spdlog::error("RelayThermostat {}/{} entering ERROR state: {}", _room, _zone, reason);
_state = ThermostatState::Error;
ASYNC_CHECK_MSG(safe_off(), "");
// TODO: tu możesz wysłać komunikat MQTT, zapisać do DB itp.
co_return _void{};
}
awaitable_expected< void > save_config();
public:
Impl(executor & io,
AsyncMqttClient & mqtt,
SettingsStore & setup,
std::unique_ptr< Relay > relay,
std::unique_ptr< Thermometer > thermometer,
std::string_view room,
int zone)
: _io{io},
_settings{setup, std::string{room}},
_mqtt{mqtt},
_relay{std::move(relay)},
_thermo{_io, std::move(thermometer)},
_room{room},
_zone{zone} {
BOOST_ASSERT(_relay);
BOOST_ASSERT(not _room.empty());
BOOST_ASSERT(_zone > 0);
}
~Impl() = default;
awaitable_expected< void > start() {
using namespace std::placeholders;
using namespace std::chrono;
spdlog::info("RelayThermostat::start room : {}", _room);
auto toSec = [](auto t) { return seconds{t}.count(); };
_state = ThermostatState_from_string(
co_await _settings.async_get_store_default("state", ThermostatState_to_string(ThermostatState::Disabled)))
.value_or(ThermostatState::Disabled);
_targetTemperature = co_await _settings.async_get_store_default("target_temperature", 20.0);
_hysteresis = co_await _settings.async_get_store_default("hysteresis", 2.0); // [°C]
_tickTime = seconds{co_await _settings.async_get_store_default("tick_time_s", toSec(minutes{1}))};
_slopeWindow = seconds{co_await _settings.async_get_store_default("slope_window_s", toSec(minutes{1}))};
_slopeDT_c = co_await _settings.async_get_store_default("slope_delta_t", 1); // [°C / min]
_sensorTimeout = seconds{co_await _settings.async_get_store_default("sensor_timeout_s", toSec(minutes{5}))};
// subscribe to a thermostat commands feed
spdlog::info("RelayThermostat::start room : {} subscribe to mqtt", _room);
ASYNC_CHECK_MSG(subscribeToAllCommands(), "subscribe to command stream failed");
// detaching listening thread
spdlog::info("RelayThermostat::start room : {} listen", _room);
ASYNC_CHECK_MSG(_thermo.start(), "Start thermometer service failed");
// pętla sterowania
boost::asio::co_spawn(_io, controlLoop(), boost::asio::detached);
co_return _void{};
}
awaitable_expected< void > subscribe(std::string_view topic,
std::function< awaitable_expected< void >(const AsyncMqttClient::CallbackData &, AsyncMqttClient::ResponseData & resp) > cb) {
spdlog::trace("RelayThermostat room {} subscribing to {}", _room, topic);
ASYNC_CHECK_MSG(_mqtt.subscribe(topic, std::move(cb)), "Heater faild to subscribe on: {}", topic);
co_return _void{};
}
awaitable_expected< void > subscribeToAllCommands() {
ASYNC_CHECK(subscribeCommand< commands::TemperatureSetpointChange >());
ASYNC_CHECK(subscribeCommand< commands::StateChange >());
ASYNC_CHECK(subscribeCommand< commands::HisteresisChange >());
ASYNC_CHECK(subscribeCommand< commands::TickTimeChange >());
ASYNC_CHECK(subscribeCommand< commands::SlopeWindowChange >());
ASYNC_CHECK(subscribeCommand< commands::SlopeTemperatureDiffChange >());
co_return _void{};
}
template < typename Command >
awaitable_expected< void > subscribeCommand() {
// broadcast: zone 0
const auto broadcast_topic = topic::heating::subscribeToCommand(_room, 0, Command::topic_suffix);
// konkretna strefa:
const auto zone_topic = topic::heating::subscribeToCommand(_room, _zone, Command::topic_suffix);
auto cb = [this](const AsyncMqttClient::CallbackData & data, AsyncMqttClient::ResponseData & resp) -> awaitable_expected< void > {
auto _result = Command::from_payload(data.request);
if(!_result)
co_return unexpected{_result.error()};
auto cmd = *_result;
/// TODO command can "throw an unexpected, this should be handled here
/// TODO command can return a true/false status for ok/nok case
auto status = ASYNC_TRY(handle_command(cmd));
if(resp) {
(*resp) = boost::json::object{{"status", status ? "ok" : "nok"}, {"details", "heater updated"}};
}
co_return _void{};
};
ASYNC_CHECK(subscribe(broadcast_topic, cb));
ASYNC_CHECK(subscribe(zone_topic, cb));
co_return _void{};
}
template < typename T >
awaitable< void > update_config(std::string_view key, const T & value) noexcept {
auto _result = co_await _settings.async_save("hysteresis", _hysteresis);
if(not _result) {
spdlog::warn("Failed to update configuration paremeter {} for {}/{}", key, _room, _zone);
}
co_return;
}
// przeciążone handlery dla poszczególnych komend:
awaitable_expected< bool > handle_command(const commands::TemperatureSetpointChange & cmd) {
spdlog::info("Heater target temperature update {} for {}/{}", _targetTemperature, _room, _zone);
_targetTemperature = cmd.setpoint_c;
co_await update_config("target_temperature", _targetTemperature);
co_return true;
}
awaitable_expected< bool > handle_command(const commands::StateChange & cmd) {
spdlog::info("Heater state update {} for {}/{}", static_cast< int >(cmd.state), _room, _zone);
// W stanie ERROR nie wolno włączyć przekaźnika (Enabled)
if(_state == ThermostatState::Error && cmd.state == ThermostatState::Enabled) {
spdlog::warn("Ignoring attempt to enable thermostat in ERROR state for {}/{}", _room, _zone);
co_return false;
}
_state = cmd.state;
co_return true;
}
awaitable_expected< bool > handle_command(const commands::HisteresisChange & cmd) {
spdlog::info("Heater histeresis update {} for {}/{}", cmd.histeresis, _room, _zone);
_hysteresis = cmd.histeresis;
/// TODO check if histeresis has ok value
co_await update_config("hysteresis", _hysteresis);
co_return true;
}
awaitable_expected< bool > handle_command(const commands::TickTimeChange & cmd) {
spdlog::info("Heater tick time update {}ns for {}/{}", cmd.tickTime.count(), _room, _zone);
_tickTime = cmd.tickTime;
co_await update_config("tick_time_s", std::chrono::duration_cast< std::chrono::seconds >(_tickTime).count());
co_return true;
}
awaitable_expected< bool > handle_command(const commands::SlopeWindowChange & cmd) {
spdlog::info("Heater slope window update {}ns for {}/{}", cmd.window.count(), _room, _zone);
_slopeWindow = cmd.window;
co_await update_config("slope_window_s", std::chrono::duration_cast< std::chrono::seconds >(_slopeWindow).count());
co_return true;
}
awaitable_expected< bool > handle_command(const commands::SlopeTemperatureDiffChange & cmd) {
spdlog::info("Heater slope temperature update {}C for {}/{}", cmd.dT_c, _room, _zone);
_slopeDT_c = cmd.dT_c;
co_await update_config("slope_delta_t", _slopeDT_c);
co_return true;
}
};
RelayThermostat::RelayThermostat(executor & io,
AsyncMqttClient & mqtt,
SettingsStore & setup,
std::unique_ptr< Relay > relay,
std::unique_ptr< Thermometer > thermometer,
std::string_view room,
int zone_id)
: _impl{std::make_unique< Impl >(io, mqtt, setup, std::move(relay), std::move(thermometer), room, zone_id)} {}
RelayThermostat::~RelayThermostat() = default;
awaitable_expected< void > RelayThermostat::start() noexcept {
BOOST_ASSERT(_impl);
return _impl->start();
}
void RelayThermostat::stop() noexcept {
BOOST_ASSERT(_impl);
}
} // namespace ranczo