ranczo-io/services/floorheat_svc/temperature_controller.cpp
Bartosz Wieczorek 6da01a2f6b Add HTTP get
2025-12-12 16:57:05 +01:00

1038 lines
40 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#include "temperature_controller.hpp"
#include "relay.hpp"
#include "spdlog/spdlog.h"
#include "thermometer.hpp"
#include "config.hpp"
#include <boost/asio/awaitable.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/core/noncopyable.hpp>
#include <boost/json/object.hpp>
#include <boost/json/storage_ptr.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 <chrono>
#include <memory_resource>
#include <optional>
#include <ranczo-io/utils/asio_watchdog.hpp>
#include <ranczo-io/utils/config.hpp>
#include <ranczo-io/utils/date_utils.hpp>
#include <ranczo-io/utils/json_helpers.hpp>
#include <ranczo-io/utils/logger.hpp>
#include <ranczo-io/utils/memory_resource.hpp>
#include <ranczo-io/utils/mqtt_client.hpp>
#include <ranczo-io/utils/mqtt_topic_builder.hpp>
#include <services/floorheat_svc/temperature_measurements.hpp>
#include <boost/units/systems/si/resistance.hpp>
#include <string>
#include <string_view>
#include <type_traits>
#include <ranczo-io/utils/time.hpp>
namespace ranczo {
enum class ThermostatState { Enabled, Disabled, Error };
std::string to_string(ThermostatState state) {
switch(state) {
case ThermostatState::Enabled:
return "Enabled";
case ThermostatState::Disabled:
return "Disabled";
default:
return "Error";
}
}
template <>
std::optional< ThermostatState > from_string(std::optional< std::string_view > state, std::pmr::memory_resource * mr) {
BOOST_ASSERT(mr);
if(not state) {
return std::nullopt;
}
std::pmr::string s(state->begin(), state->end(), mr);
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;
}
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 = from_string< T >(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_diff";
};
struct StatusRequest {
static expected< StatusRequest > from_payload(const boost::json::value & payload) {
return {};
}
static constexpr std::string_view topic_suffix = "status";
};
struct ClearPanic {
static expected< ClearPanic > from_payload(const boost::json::value & payload) {
return {};
}
static constexpr std::string_view topic_suffix = "clear_panic";
};
} // namespace commands
enum RuntimeError {
NoTemperatureMeasurements = 1,
IoError,
RelayReadStateError,
RelaySetOffError,
RelaySetOnError,
TempRiseOnDisabledRelay,
TempFallDespiteEnabledRelay,
Unknown
};
struct RuntimeErrorCategory : public boost::system::error_category {
// error_category interface
public:
const char * name() const noexcept override {
return "RuntimeError";
}
std::string message(int ev) const override {
auto e = static_cast< RuntimeError >(ev);
switch(e) {
case NoTemperatureMeasurements:
return "NoTemperatureMeasurements";
case IoError:
return "IoError";
case RelayReadStateError:
return "RelayReadStateError";
case RelaySetOffError:
return "RelaySetOffError";
case RelaySetOnError:
return "RelaySetOnError";
case TempRiseOnDisabledRelay:
return "TempRiseOnDisabledRelay";
case TempFallDespiteEnabledRelay:
return "TempFallDespiteEnabledRelay";
default:
return "Unknown RuntimeError(" + std::to_string(ev) + ")";
}
}
};
inline const boost::system::error_category & runtime_error_category() {
static RuntimeErrorCategory cat;
return cat;
}
inline bool is_runtime_error(const boost::system::error_code & ec) {
return &ec.category() == &runtime_error_category();
}
inline boost::system::error_code make_error_code(ranczo::RuntimeError e) {
return {static_cast< int >(e), ranczo::runtime_error_category()};
}
} // namespace ranczo
namespace boost::system {
template <>
struct is_error_code_enum< ranczo::RuntimeError > : std::true_type {};
} // namespace boost::system
namespace ranczo {
/** implamentation of simple relay thermostat */
struct RelayThermostat::Impl : private boost::noncopyable {
private:
executor & _io;
ModuleLogger _log;
AsyncMqttClient & _mqtt;
ComponentSettingsStore _settings;
/// relay control
std::unique_ptr< Relay > _relay;
std::chrono::system_clock::time_point _lastStateChange{std::chrono::system_clock::now()};
ThermometerMeasurements _thermo; /// tempareture measurements with history
/// state
// --- nowy stan awaryjny / błędów ---
enum class PanicSeverity {
Safe, // relay off, no risk
Danger, // relay on, we may burn the house down, not ideal
};
bool _panic{false}; // "soft" panic (fail-safe)
PanicSeverity _panic_severity{PanicSeverity::Safe};
std::string _last_error_reason;
std::optional< std::chrono::system_clock::time_point > _panic_since{};
/// configuration
std::string _room; // string represanting a room in which we are controling our heater
int _zone{}; // zone starts from 1 (0 is broadcast)
ThermostatState _state{ThermostatState::Disabled}; // state of thermostat (should it work or not)
double _targetTemperature{0.0}; // set temperature
double _hysteresis{0.5}; // [°C]
std::chrono::nanoseconds _tickTime{std::chrono::seconds(60)}; // minimal ON or OFF time
std::chrono::nanoseconds _slopeWindow{std::chrono::minutes(5)}; // time of checking the temperature trend
double _slopeDT_c{0.2}; // [°C / min]
std::chrono::nanoseconds _sensorTimeout{std::chrono::minutes(5)}; // max time to wait for temperature
/// TODO dodanie resistance
///
// additional variables
Timer _statusTimer; // sends status of current endpoint
std::chrono::system_clock::time_point _startTP; // used only to prevent warnings at start of procedure
static boost::system::error_code make_error(RuntimeError ec) {
static auto TemperatureControlerCategory = RuntimeErrorCategory{};
return boost::system::error_code{static_cast< int >(ec), TemperatureControlerCategory};
}
awaitable< void >
publish_error_event(RuntimeError err, PanicSeverity severity, std::string_view reason, ThermostatState state) noexcept {
try {
memory_resource::HeapPoolResource mr{4096};
json::pmr_memory_resource_adapter adp{&mr};
boost::json::storage_ptr sp{&adp};
boost::json::object j{sp};
j["timestamp"] = date::to_iso_timestamp(std::chrono::system_clock::now(), &mr);
j["room"] = _room;
j["zone"] = _zone;
j["error"] = static_cast< int >(err);
j["reason"] = std::string(reason);
j["severity"] = (severity == PanicSeverity::Danger) ? "danger" : "safe";
switch(state) {
case ThermostatState::Enabled:
j["state"] = "enabled";
break;
case ThermostatState::Disabled:
j["state"] = "disabled";
break;
case ThermostatState::Error:
j["state"] = "error";
break;
default:
j["state"] = "unknown";
break;
}
auto _mqtt_error_topic = "ranczo-io/error"; /// todo fix error topic
auto res = co_await _mqtt.publish(_mqtt_error_topic, j);
if(!res) {
_log.warn("Failed to publish error event to MQTT: {}", res.error().message());
}
} catch(const std::exception & e) {
_log.error("publish_error_event exception: {}", e.what());
} catch(...) {
_log.error("publish_error_event unknown exception");
}
co_return;
}
// ───────────────────────────────────────────────────────────────────────────
// safe_off_zone() bezpieczne wyłączenie przekaźnika
// ───────────────────────────────────────────────────────────────────────────
std::string _mqtt_error_topic = "ranczo-io/error";
awaitable< bool > safe_off_zone() {
try {
auto res = co_await safe_off(); // expected<void>
if(!res) {
_log.error("safe_off_zone: failed to turn relay OFF: {}", res.error().message());
co_return false;
}
co_return true;
} catch(...) {
_log.error("safe_off_zone: exception");
co_return false;
}
}
awaitable< void > mark_zone_panic(RuntimeError err, std::string_view reason) {
try {
_panic = true;
_panic_since = std::chrono::system_clock::now();
_last_error_reason = std::string(reason);
_state = ThermostatState::Error;
_log.error("PANIC: {}", reason);
bool off_ok = co_await safe_off_zone();
_panic_severity = off_ok ? PanicSeverity::Safe : PanicSeverity::Danger;
co_await publish_error_event(err, _panic_severity, reason, _state);
} catch(...) {
_log.error("mark_zone_panic: unexpected exception");
}
co_return;
}
awaitable< void > raise_critical_alarm(RuntimeError err, std::string_view reason) {
try {
_log.error("CRITICAL: {}", reason);
co_await publish_error_event(err, _panic_severity, reason, _state);
} catch(...) {
_log.error("raise_critical_alarm: unexpected exception");
}
co_return;
}
awaitable< void > mark_zone_fault(RuntimeError err, std::string_view reason) {
try {
_last_error_reason = std::string(reason);
_log.warn("FAULT: {}", reason);
co_await publish_error_event(err,
PanicSeverity::Safe, // fault zawsze bez zagrożenia
reason,
_state);
} catch(...) {
_log.error("mark_zone_fault: unexpected exception");
}
co_return;
}
awaitable_expected< void > controlLoop() {
using namespace std::chrono;
boost::asio::steady_timer timer(_io);
for(;;) {
if(_panic && _panic_severity == PanicSeverity::Danger) {
// tylko upewniamy się, że przekaźnik jest OFF
ASYNC_CHECK_LOG(safe_off(), "safe_off in PANIC(Danger) failed");
} else {
auto expectedStep = co_await applyControlStep();
if(not expectedStep) {
RuntimeError err = static_cast< RuntimeError >(expectedStep.error().value());
switch(err) {
case RuntimeError::NoTemperatureMeasurements: {
_log.error("[{}:{}] NoTemperatureMeasurements entering panic mode", _room, _zone);
co_await mark_zone_panic(RuntimeError::NoTemperatureMeasurements, "NoTemperatureMeasurements");
break;
}
case RuntimeError::IoError: {
_log.error("[{}:{}] IoError entering panic mode", _room, _zone);
co_await mark_zone_panic(RuntimeError::IoError, "IoError");
break;
}
case RuntimeError::RelayReadStateError: {
_log.error("[{}:{}] RelayReadStateError entering panic mode", _room, _zone);
co_await mark_zone_panic(RuntimeError::RelayReadStateError, "RelayReadStateError");
break;
}
case RuntimeError::RelaySetOffError: {
_log.error("[{}:{}] RelaySetOffError HARD panic, cannot ensure relay is OFF", _room, _zone);
co_await mark_zone_panic(RuntimeError::RelaySetOffError, "RelaySetOffError");
co_await raise_critical_alarm(RuntimeError::RelaySetOffError, "RelaySetOffError");
break;
}
case RuntimeError::RelaySetOnError: {
_log.warn("[{}:{}] RelaySetOnError zone will not heat", _room, _zone);
co_await mark_zone_fault(RuntimeError::RelaySetOnError, "RelaySetOnError");
break;
}
case RuntimeError::TempRiseOnDisabledRelay: {
_log.error("[{}:{}] TempRiseOnDisabledRelay possible stuck relay, HARD panic", _room, _zone);
co_await mark_zone_panic(RuntimeError::TempRiseOnDisabledRelay, "TempRiseOnDisabledRelay");
co_await raise_critical_alarm(RuntimeError::TempRiseOnDisabledRelay, "TempRiseOnDisabledRelay");
break;
}
case RuntimeError::TempFallDespiteEnabledRelay: {
_log.warn("[{}:{}] TempFallDespiteEnabledRelay ineffective heating", _room, _zone);
co_await mark_zone_fault(RuntimeError::TempFallDespiteEnabledRelay, "TempFallDespiteEnabledRelay");
break;
}
case RuntimeError::Unknown:
default: {
_log.error(
"[{}:{}] Unknown RuntimeError (value={}) entering panic mode", _room, _zone, static_cast< int >(err));
co_await mark_zone_panic(RuntimeError::Unknown, "UnknownRuntimeError");
break;
}
}
}
}
// 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;
auto transform = [&](const boost::system::error_code & ec,
std::source_location _sl = std::source_location::current()) -> boost::system::error_code {
if(is_runtime_error(ec)) {
return ec;
}
_log.warn_at(_sl, "Got unknown {}", ec.what());
return make_error_code(RuntimeError::Unknown);
};
auto runsFor = std::chrono::system_clock::now() - _startTP;
auto justStarted = runsFor < _sensorTimeout;
if(justStarted && _thermo.size() == 0) {
/// Skip early to awoid getting a warning at start
_log.debug("Skiping early loop cycles");
co_return _void{};
}
switch(_state) {
case ThermostatState::Error:
ASYNC_CHECK_TRANSFORM_ERROR(handleErrorState(), transform);
break;
case ThermostatState::Disabled:
ASYNC_CHECK_TRANSFORM_ERROR(handleDisabledState(), transform);
break;
case ThermostatState::Enabled:
ASYNC_CHECK_TRANSFORM_ERROR(handleEnabledState(), transform);
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()) {
_log.warn("Temperature sensor timeout (> 5 minutes without update)");
co_return false;
}
auto tempOpt = _thermo.currentTemperature();
if(!tempOpt) {
_log.warn("No temperature samples");
co_return false;
}
auto trendOpt = _thermo.temperatureTrend(_slopeWindow, _slopeDT_c);
if(!trendOpt) {
_log.warn("No temperature samples for last {}s", 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::RelayReadStateError); };
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) {
_log.warn("relay stuck ON: temperature rising while relay is commanded OFF");
co_return false;
}
// 2b) relay ON, a trend != Rise => przekaźnik zawiesił się na OFF
if(relayOn && trend != Trend::Rise) {
_log.warn("relay stuck OFF: temperature not rising while relay is commanded ON");
co_return false;
}
co_return true;
}
awaitable_expected< void > handleErrorState() {
/// check preconditions, if ok release error state
auto preconditionsMet = ASYNC_TRY(preconditions());
if(!preconditionsMet) {
// dalej coś jest nie tak -> upewnij się, że grzanie jest OFF
ASYNC_CHECK(safe_off());
co_return _void{};
}
if(_panic) {
if(_panic_severity == PanicSeverity::Danger) {
_log.warn("ErrorState: preconditions met but PANIC(Danger) active staying in ERROR");
ASYNC_CHECK(safe_off());
co_return _void{};
} else {
_log.info("ErrorState: preconditions met auto-clear PANIC(Safe) and enable thermostat");
_panic = false;
_panic_severity = PanicSeverity::Safe;
_panic_since = std::nullopt;
_last_error_reason.clear();
}
} else {
_log.info("ErrorState: preconditions met switching back to Enabled");
}
_state = ThermostatState::Enabled;
co_return _void{};
}
awaitable_expected< void > handleDisabledState() {
auto st = ASYNC_TRY(_relay->state());
if(st == Relay::State::On) {
_log.info("disabling relay because thermostat is Disabled");
ASYNC_CHECK_LOG(safe_off(), "relay OFF failed");
}
co_return _void{};
}
awaitable_expected< void > handleEnabledState() {
using namespace std::chrono;
if(_panic) {
_log.error("handleEnabledState called while PANIC is active forcing ERROR");
_state = ThermostatState::Error;
co_return _void{};
}
auto preconditionsMet = ASYNC_TRY(preconditions());
if(not preconditionsMet) {
// preconditions should always be met
_log.warn("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_TRANSFORM_ERROR(_relay->state(), [](auto) { return make_error_code(RuntimeError::RelayReadStateError); });
const bool relayOn = (st == Relay::State::On);
// grzejemy jeżeli temp < setpoint - histereza
if(!relayOn) {
if(temp < _targetTemperature - _hysteresis && minElapsed >= _tickTime) {
_log.info("turning relay ON (temp = {}, setpoint = {}, h = {})", temp, _targetTemperature, _hysteresis);
ASYNC_CHECK_TRANSFORM_ERROR(_relay->on(), [](auto) { return make_error_code(RuntimeError::RelayReadStateError); });
_lastStateChange = now;
}
} else {
// wyłączamy grzanie jeżeli temp > setpoint + histereza
if(temp > _targetTemperature + _hysteresis && minElapsed >= _tickTime) {
_log.info("turning relay OFF (temp = {}, setpoint = {}, h = {})", temp, _targetTemperature, _hysteresis);
ASYNC_CHECK_LOG(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);
int retries_left = retries;
bool had_error = false;
RuntimeError last_runtime_error = RuntimeError::IoError; // sensowny domyślny fallback
while(retries_left > 0) {
_log.debug("get relay state");
auto state_res = co_await _relay->state();
if(!state_res) {
had_error = true;
auto ec = state_res.error();
if(!is_runtime_error(ec)) {
_log.warn("Cant get relay state orig: {}", ec.message());
ec = make_error_code(RuntimeError::RelayReadStateError);
}
last_runtime_error = static_cast< RuntimeError >(ec.value());
_log.warn("Cant get relay state: {}", ec.message());
--retries_left;
if(retries_left > 0)
co_await async_sleep_for(std::chrono::seconds{5});
continue;
}
auto st = *state_res;
if(st == Relay::State::On) {
_log.debug("try to turn relay off");
auto off_res = co_await _relay->off();
if(!off_res) {
had_error = true;
auto ec = off_res.error();
if(!is_runtime_error(ec)) {
_log.warn("Cant turn off relay orig: {}", ec.message());
ec = make_error_code(RuntimeError::RelaySetOffError);
}
last_runtime_error = static_cast< RuntimeError >(ec.value());
_log.warn("Cant turn off relay: {}", ec.message());
--retries_left;
if(retries_left > 0)
co_await async_sleep_for(std::chrono::milliseconds{500});
continue;
}
}
// sukces: albo był już OFF, albo udało się wyłączyć
co_return _void{};
}
// Jeśli tu doszliśmy, to mimo prób się nie udało
_log.error("After {} tries we cannot turn off relay", retries);
if(had_error) {
co_return unexpected(make_error_code(last_runtime_error));
}
// Teoretycznie nieosiągalne, ale na wszelki wypadek:
co_return unexpected(make_error_code(RuntimeError::RelaySetOffError));
}
awaitable_expected< void > error(std::string_view reason) {
if(_state == ThermostatState::Error) {
_log.error("additional error while already in ERROR state: {}", reason);
co_return _void{};
}
_log.error("entering ERROR state: {}", reason);
_state = ThermostatState::Error;
ASYNC_CHECK_LOG(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},
_log{spdlog::default_logger(),
[&]() -> std::string { return "RelayThermostat impl: " + std::string{room} + "/" + std::to_string(zone); }()},
_settings{setup, std::string{room}},
_mqtt{mqtt},
_relay{std::move(relay)},
_thermo{_io, std::move(thermometer)},
_room{room},
_zone{zone},
_statusTimer{_io, std::chrono::seconds{30}, [this]() -> awaitable_expected< void > {
auto ok = co_await this->publish_status();
if(not ok) {
_log.warn("Error during status publish");
}
co_return _void{};
}} {
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;
_log.info("Start");
auto toSec = [](auto t) { return seconds{t}.count(); };
_state = from_string< ThermostatState >(co_await _settings.async_get_store_default("state", 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}))};
/// TODO sprawdzić czy inne parametry również trzeba/można odczytać
// subscribe to a thermostat commands feed
_log.info("Start: subscribe to mqtt");
ASYNC_CHECK_LOG(subscribeToAllCommands(), "subscribe to command stream failed");
// detaching listening thread
_log.info("Start: listen");
ASYNC_CHECK_LOG(_thermo.start(), "Start thermometer service failed");
_statusTimer.start(true);
_startTP = std::chrono::system_clock::now();
// 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) {
_log.trace("Subscribing to {}", topic);
ASYNC_CHECK_LOG(_mqtt.subscribe(topic, std::move(cb)), "Heater faild to subscribe on: {}", topic);
_log.trace("Subscribing to {} DONE", 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 >());
ASYNC_CHECK(subscribeCommand< commands::StatusRequest >());
ASYNC_CHECK(subscribeCommand< commands::ClearPanic >());
co_return _void{};
}
template < typename Command >
awaitable_expected< void > subscribeCommand() {
// broadcast: zone 0
const auto broadcast_topic = topic::heating::subscribeToControl(_room, 0, Command::topic_suffix);
// konkretna strefa:
const auto zone_topic = topic::heating::subscribeToControl(_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;
auto expected = co_await handle_command(cmd);
if(expected) {
if(resp.has_value()) {
_log.trace("command {} request has a response topic, write response", Command::topic_suffix);
(*resp).get() = boost::json::object{{"status", *expected ? "ok" : "nok"}, {"details", "heater updated"}};
}
} else {
_log.error("command {} has failed", Command::topic_suffix);
if(resp.has_value()) {
_log.trace("command {} request has a response topic, write response", Command::topic_suffix);
(*resp).get() = boost::json::object{{"status", "nok"}, {"details", expected.error().what()}};
}
}
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) {
_log.warn("Failed to update configuration paremeter {}", key);
}
co_return;
}
// przeciążone handlery dla poszczególnych komend:
awaitable_expected< bool > handle_command(const commands::TemperatureSetpointChange & cmd) {
_log.info("Heater target temperature update {}", _targetTemperature);
_targetTemperature = cmd.setpoint_c;
co_await update_config("target_temperature", _targetTemperature);
co_return ASYNC_TRY(publish_status());
}
awaitable_expected< bool > handle_command(const commands::StateChange & cmd) {
_log.info("Heater state update {}", static_cast< int >(cmd.state));
// W stanie ERROR nie wolno włączyć przekaźnika (Enabled)
if((_state == ThermostatState::Error || _panic) && cmd.state == ThermostatState::Enabled) {
_log.warn("Ignoring attempt to enable thermostat in ERROR/PANIC state");
co_return false;
}
_state = cmd.state;
co_await update_config("state", to_string(_state));
co_return ASYNC_TRY(publish_status());
}
awaitable_expected< bool > handle_command(const commands::HisteresisChange & cmd) {
_log.info("Heater histeresis update {}", cmd.histeresis);
_hysteresis = cmd.histeresis;
/// TODO check if histeresis has ok value
co_await update_config("hysteresis", _hysteresis);
co_return ASYNC_TRY(publish_status());
}
awaitable_expected< bool > handle_command(const commands::TickTimeChange & cmd) {
_log.info("Heater tick time update {}ns", cmd.tickTime.count());
_tickTime = cmd.tickTime;
co_await update_config("tick_time_s", std::chrono::duration_cast< std::chrono::seconds >(_tickTime).count());
co_return ASYNC_TRY(publish_status());
}
awaitable_expected< bool > handle_command(const commands::SlopeWindowChange & cmd) {
_log.info("Heater slope window update {}ns", cmd.window.count());
_slopeWindow = cmd.window;
co_await update_config("slope_window_s", std::chrono::duration_cast< std::chrono::seconds >(_slopeWindow).count());
co_return ASYNC_TRY(publish_status());
}
awaitable_expected< bool > handle_command(const commands::SlopeTemperatureDiffChange & cmd) {
_log.info("Heater slope temperature update {}C", cmd.dT_c);
_slopeDT_c = cmd.dT_c;
co_await update_config("slope_delta_t", _slopeDT_c);
co_return ASYNC_TRY(publish_status());
}
awaitable_expected< bool > handle_command(const commands::StatusRequest & cmd) {
_log.info("Heater got status report request");
co_return ASYNC_TRY(publish_status());
}
awaitable_expected< bool > handle_command(const commands::ClearPanic & cmd) {
_log.warn("Clearing PANIC status");
if(!_panic) {
_log.warn("Panic mode not enabled, cmd ignored");
co_return false; // there is no panic mode in the first place
}
auto state = ASYNC_TRY(_relay->state());
if(state == Relay::State::On)
ASYNC_CHECK(safe_off());
_panic = false;
_panic_severity = PanicSeverity::Safe;
_panic_since = std::nullopt;
_last_error_reason = "";
_log.info("Going to safe state after clear panic");
_state = ThermostatState::Disabled;
co_return true;
}
awaitable_expected< bool > publish_status() {
_log.trace("Publish status");
_statusTimer.reset();
using namespace std::chrono;
memory_resource::MonotonicHeapResource mr{4096};
json::pmr_memory_resource_adapter adapter_req{&mr};
boost::json::storage_ptr sp{&adapter_req};
boost::json::object obj{sp};
// timestamp — aktualny stan obiektu
obj["timestamp"] = date::to_iso_timestamp(std::chrono::system_clock::now(), &mr);
obj["started"] = date::to_iso_timestamp(_startTP, &mr);
// proste pola
obj["room"] = _room;
obj["zone"] = _zone;
obj["state"] = static_cast< int >(_state);
obj["panic_mode"] = _panic;
if(_panic)
obj["panic_since"] = date::to_iso_timestamp(_panic_since.value_or(std::chrono::system_clock::now()), &mr);
if(not _last_error_reason.empty())
obj["last_error_reson"] = _last_error_reason;
obj["target_temperature"] = _targetTemperature;
auto trend = _thermo.temperatureTrend(_slopeWindow, _slopeDT_c);
if(trend) {
obj["temperature_trend"] = to_string(*trend);
}
obj["measurements_size"] = _thermo.size();
obj["hysteresis"] = _hysteresis;
obj["slope_dt_c"] = _slopeDT_c;
// durations → sekundy + "_s"
using seconds_d = duration< double >;
obj["tick_time_s"] = duration_cast< seconds_d >(_tickTime).count();
obj["slope_window_s"] = duration_cast< seconds_d >(_slopeWindow).count();
obj["sensor_timeout_s"] = duration_cast< seconds_d >(_sensorTimeout).count();
auto topic = topic::heating::publishState(_room, _zone, &mr);
ASYNC_CHECK(_mqtt.publish(topic, obj));
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