815 lines
30 KiB
C++
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
|