1038 lines
40 KiB
C++
1038 lines
40 KiB
C++
#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
|