From 64d562119578bf7dd1023d341cde2d2b40e17beb Mon Sep 17 00:00:00 2001 From: Bartosz Wieczorek Date: Fri, 14 Nov 2025 08:37:15 +0100 Subject: [PATCH] add thermostat implementation + add modbus relay implementation --- CMakeLists.txt | 20 +- config.hpp | 2 +- libs/modbus.cpp | 43 +- libs/ranczo-io/utils/modbus.hpp | 6 + libs/ranczo-io/utils/mqtt_topic_builder.hpp | 4 +- libs/ranczo-io/utils/time.hpp | 115 +++ services/floorheat_svc/CMakeLists.txt | 1 + services/floorheat_svc/main.cpp | 2 +- services/floorheat_svc/relay.hpp | 57 +- .../floorheat_svc/temperature_controller.cpp | 697 +++++++++++++----- 10 files changed, 726 insertions(+), 221 deletions(-) create mode 100644 libs/ranczo-io/utils/time.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 7aee3b6..07f801b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -7,8 +7,6 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON) # add_compile_options(-g -fsanitize=address,undefined,float-divide-by-zero,float-cast-overflow,null -fsanitize-address-use-after-scope -fno-sanitize-recover=all -fno-sanitize=alignment -fno-omit-frame-pointer) # add_link_options(-g -fsanitize=address,undefined,float-divide-by-zero,float-cast-overflow,null -fsanitize-address-use-after-scope -fno-sanitize-recover=all -fno-sanitize=alignment -fno-omit-frame-pointer) -add_compile_options(-g -fno-omit-frame-pointer) - include(CheckIPOSupported) check_ipo_supported(RESULT supported OUTPUT error) @@ -70,12 +68,20 @@ create_boost_header_only_target(type_traits) # spdlog FetchContent_Declare( spdlog - GIT_REPOSITORY https://github.com/gabime/spdlog - GIT_TAG v1.14.1 - GIT_SHALLOW TRUE + GIT_REPOSITORY https://github.com/gabime/spdlog + GIT_TAG v1.14.1 + GIT_SHALLOW TRUE +) + +FetchContent_Declare( + sml + GIT_REPOSITORY https://github.com/boost-ext/sml.git + GIT_TAG v1.1.12 + GIT_SHALLOW TRUE ) FetchContent_GetProperties(spdlog) + if(NOT spdlog_POPULATED) FetchContent_Populate(spdlog) # set(SPDLOG_FMT_EXTERNAL_HO ON) @@ -97,7 +103,7 @@ FetchContent_Declare( set(MQTT_BUILD_TESTS OFF) set(MQTT_BUILD_EXAMPLES OFF) set(async-mqtt5_INCLUDES_WITH_SYSTEM OFF) -FetchContent_MakeAvailable(asyncmqtt5) +FetchContent_MakeAvailable(asyncmqtt5 sml) # c++20 format library, super usefull, super fast set(FMT_TEST OFF CACHE INTERNAL "disabling fmt tests") @@ -113,5 +119,3 @@ include_directories(${CMAKE_CURRENT_SOURCE_DIR}) add_subdirectory(libs) add_subdirectory(services) add_subdirectory(tests) - - # 139929 bartosz+ 20 0 20,0t 157036 57964 S 0,0 1,0 0:02.58 ranczo-io_flor diff --git a/config.hpp b/config.hpp index a98c511..4cec76e 100644 --- a/config.hpp +++ b/config.hpp @@ -38,7 +38,7 @@ using ::boost::system::errc::make_error_code; ({ \ auto _result = failable; \ if(!_result) \ - co_return unexpected{_result.error()}; \ + return unexpected{_result.error()}; \ *_result; \ }) diff --git a/libs/modbus.cpp b/libs/modbus.cpp index 532fa72..ee8c0be 100644 --- a/libs/modbus.cpp +++ b/libs/modbus.cpp @@ -1,4 +1,6 @@ #include "config.hpp" +#include +#include #include #include @@ -57,6 +59,28 @@ bool ModbusTcpContext::connected() const { return _connected; } +awaitable_expected< void > ranczo::ModbusTcpContext::wait_between_commands() { + namespace asio = boost::asio; + using namespace std::chrono; + + const auto now = steady_clock::now(); + const auto expiry = delay_timer_.expiry(); + + // Jeśli timer ma expiry w przyszłości – czekamy + if(expiry > now) { + boost::system::error_code ec; + spdlog::trace("Timer waits {%f.02}us to expire time between ", + std::chrono::duration_cast< std::chrono::duration< double, std::micro > >(expiry - now).count()); + + co_await delay_timer_.async_wait(asio::redirect_error(asio::use_awaitable, ec)); + if(ec && ec != asio::error::operation_aborted) { + co_return unexpected(ec); + } + } + + co_return _void{}; +} + awaitable_expected< void > ModbusTcpContext::async_close() { co_return co_await async_call< void >([this](modbus_t *&) -> expected< void > { std::scoped_lock lk(mx_); @@ -78,9 +102,24 @@ ModbusTcpContext::create(boost::asio::io_context & io, std::string host, int por return std::make_shared< ModbusTcpContext >(io, std::move(host), port, pool_size); } +struct on_exit { + std::function< void() > _fn; + + ~on_exit() { + if(_fn) + _fn(); + } +}; + template < typename T, typename F > awaitable_expected< T > ModbusTcpContext::call_with_lock(F && op) { + // Throttling – zapewnij kilka ms przerwy między komendami + ASYNC_CHECK(wait_between_commands()); + co_return co_await async_call< T >([this, op = std::forward< F >(op)](modbus_t *& out) -> expected< T > { + on_exit _{[&]() { // Ustaw nowe "okno" – od teraz za command_interval_ ms + delay_timer_.expires_at(std::chrono::steady_clock::now() + command_interval_); + }}; std::scoped_lock lk(mx_); if(!ctx_) return unexpected(make_error_code(boost::system::errc::not_connected)); @@ -132,12 +171,12 @@ awaitable_expected< uint16_t > ModbusDevice::async_read_holding_register(uint16_ awaitable_expected< void > ModbusDevice::async_write_coil(uint16_t address, bool value) { auto ctx = ctx_; co_return co_await ctx->call_with_lock< void >([this, address, value](modbus_t * c) -> expected< void > { - if(::modbus_set_slave(c, unit_id_) == -1){ + if(::modbus_set_slave(c, unit_id_) == -1) { spdlog::error("Modbus modbus_set_slave for {}/{} failed with {}", unit_id_, address, errno); return unexpected(errno_errc()); } const int rc = ::modbus_write_bit(c, static_cast< int >(address), value ? 1 : 0); - if(rc == -1){ + if(rc == -1) { spdlog::error("Modbus modbus_write_bit for {}/{} failed with {}", unit_id_, address, errno); return unexpected(errno_errc()); } diff --git a/libs/ranczo-io/utils/modbus.hpp b/libs/ranczo-io/utils/modbus.hpp index 6c79ef0..db7c957 100644 --- a/libs/ranczo-io/utils/modbus.hpp +++ b/libs/ranczo-io/utils/modbus.hpp @@ -3,9 +3,11 @@ #include "config.hpp" +#include #include #include +#include #include #include #include @@ -30,6 +32,8 @@ class ModbusTcpContext : public std::enable_shared_from_this< ModbusTcpContext > // Udostępnione wywołanie: lambda dostaje wskaźnik na aktywny kontekst libmodbus template < typename T, typename F > awaitable_expected< T > call_with_lock(F && op); + + awaitable_expected wait_between_commands(); private: struct CtxDeleter { @@ -53,6 +57,8 @@ class ModbusTcpContext : public std::enable_shared_from_this< ModbusTcpContext > std::mutex mx_; std::unique_ptr< modbus_t, CtxDeleter > ctx_; bool _connected{false}; + boost::asio::steady_timer delay_timer_{io_.get_executor()}; + std::chrono::milliseconds command_interval_{3}; // np. 5 ms pomiędzy komendami }; // Lekki „Device” – trzyma unit_id i korzysta ze współdzielonego kontekstu TCP diff --git a/libs/ranczo-io/utils/mqtt_topic_builder.hpp b/libs/ranczo-io/utils/mqtt_topic_builder.hpp index 4c5a77f..5c07295 100644 --- a/libs/ranczo-io/utils/mqtt_topic_builder.hpp +++ b/libs/ranczo-io/utils/mqtt_topic_builder.hpp @@ -75,9 +75,9 @@ namespace topic { // response_topic: "client/myid/response", // correlation_data: "uuid-123" // }); - inline std::string command(std::string_view room, int zone = 1) { + inline std::string subscribeToCommand(std::string_view room, int zone, std::string_view command) { using namespace std::string_view_literals; - return buildTopic(room, "heating"sv, "floor"sv, zone, "command"sv); + return buildTopic(room, "heating"sv, "floor"sv, zone, command); } // Topic: diff --git a/libs/ranczo-io/utils/time.hpp b/libs/ranczo-io/utils/time.hpp new file mode 100644 index 0000000..83505b6 --- /dev/null +++ b/libs/ranczo-io/utils/time.hpp @@ -0,0 +1,115 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include + +namespace ranczo { + +template < typename T > +struct is_chrono_duration : std::false_type {}; + +template < typename Rep, typename Period > +struct is_chrono_duration< std::chrono::duration< Rep, Period > > : std::true_type {}; + +template < typename T > +inline constexpr bool is_chrono_duration_v = is_chrono_duration< T >::value; + +inline std::string to_lower(std::string_view sv) { + std::string out(sv.begin(), sv.end()); + std::transform(out.begin(), out.end(), out.begin(), [](unsigned char c) { return static_cast< char >(std::tolower(c)); }); + return out; +} + +template < typename T > +expected< T > parse_duration_from_string(std::string_view sv) { + static_assert(is_chrono_duration_v< T >, "T must be a std::chrono::duration"); + + // Oczekujemy formatu: " " + // np. "1 minute", "5 min", "10 s", "250 ms" + // Dopuszczamy też brak spacji: "1min", "5s" (opcjonalnie). + + auto trim = [](std::string_view & s) { + auto not_space = [](unsigned char c) { return !std::isspace(c); }; + while(!s.empty() && !not_space(static_cast< unsigned char >(s.front()))) { + s.remove_prefix(1); + } + while(!s.empty() && !not_space(static_cast< unsigned char >(s.back()))) { + s.remove_suffix(1); + } + }; + trim(sv); + if(sv.empty()) { + return unexpected{make_error_code(boost::system::errc::invalid_argument)}; + } + + // Znajdź pierwszą spację – oddziel liczbę od jednostki + std::string_view num_part = sv; + std::string_view unit_part; + + if(auto pos = sv.find_first_of(" \t"); pos != std::string_view::npos) { + num_part = sv.substr(0, pos); + unit_part = sv.substr(pos + 1); + trim(unit_part); + } else { + // Jeżeli nie ma spacji – spróbuj rozdzielić liczbę i tekst po pierwszej nieliczbowej + std::size_t i = 0; + for(; i < sv.size(); ++i) { + if(!(std::isdigit(static_cast< unsigned char >(sv[i])) || sv[i] == '.' || sv[i] == ',')) { + break; + } + } + num_part = sv.substr(0, i); + unit_part = sv.substr(i); + trim(unit_part); + } + + if(num_part.empty()) { + return unexpected{make_error_code(boost::system::errc::invalid_argument)}; + } + + // zamień przecinek na kropkę (np. "1,5 min") + std::string num_str(num_part.begin(), num_part.end()); + std::replace(num_str.begin(), num_str.end(), ',', '.'); + + double value = 0.0; + try { + value = std::stod(num_str); + } catch(...) { + return unexpected{make_error_code(boost::system::errc::invalid_argument)}; + } + + if(unit_part.empty()) { + // domyślnie sekundy + auto base = std::chrono::duration< double >(value); + return std::chrono::duration_cast< T >(base); + } + + auto unit = to_lower(unit_part); + + std::chrono::duration< double > base; + + if(unit == "ns" || unit == "nanosecond" || unit == "nanoseconds") { + base = std::chrono::duration< double, std::nano >(value); + } else if(unit == "us" || unit == "microsecond" || unit == "microseconds") { + base = std::chrono::duration< double, std::micro >(value); + } else if(unit == "ms" || unit == "millisecond" || unit == "milliseconds") { + base = std::chrono::duration< double, std::milli >(value); + } else if(unit == "s" || unit == "sec" || unit == "secs" || unit == "second" || unit == "seconds") { + base = std::chrono::duration< double >(value); + } else if(unit == "m" || unit == "min" || unit == "mins" || unit == "minute" || unit == "minutes") { + base = std::chrono::duration< double, std::ratio< 60 > >(value); + } else if(unit == "h" || unit == "hr" || unit == "hrs" || unit == "hour" || unit == "hours") { + base = std::chrono::duration< double, std::ratio< 3600 > >(value); + } else { + return unexpected{make_error_code(boost::system::errc::invalid_argument)}; + } + + return std::chrono::duration_cast< T >(base); +} +} // namespace ranczo diff --git a/services/floorheat_svc/CMakeLists.txt b/services/floorheat_svc/CMakeLists.txt index e5b5852..bca221b 100644 --- a/services/floorheat_svc/CMakeLists.txt +++ b/services/floorheat_svc/CMakeLists.txt @@ -9,6 +9,7 @@ target_link_libraries(ranczo-io_floorheating PUBLIC ranczo-io::utils fmt::fmt + sml::sml ) include(GNUInstallDirs) diff --git a/services/floorheat_svc/main.cpp b/services/floorheat_svc/main.cpp index 2dccc66..b101af8 100644 --- a/services/floorheat_svc/main.cpp +++ b/services/floorheat_svc/main.cpp @@ -223,7 +223,7 @@ int main() { // Floor 1 _heaters.emplace_back(make_ramp_thermostat("office"sv, 1, make_relay(2, 12))); - /// TODO czujnik temperatury + /// TODO fizycznie podłączyć czujnik temperatury // _heaters.emplace_back(relayThermostatFactory("bathroom_up"sv, 1, relay(0,0))); _heaters.emplace_back(make_ramp_thermostat("aska_room"sv, 1, make_relay(2, 10))); _heaters.emplace_back(make_ramp_thermostat("maciej_room"sv, 1, make_relay(2, 11))); diff --git a/services/floorheat_svc/relay.hpp b/services/floorheat_svc/relay.hpp index c3a5868..74a327a 100644 --- a/services/floorheat_svc/relay.hpp +++ b/services/floorheat_svc/relay.hpp @@ -15,53 +15,20 @@ namespace ranczo { class Relay { public: virtual ~Relay() = default; - + enum class State{ + On,Off + }; + + virtual awaitable_expected< State > state() const noexcept = 0; virtual awaitable_expected< void > on() noexcept = 0; virtual awaitable_expected< void > off() noexcept = 0; }; -class MqttRelay : public Relay { - // Relay interface - boost::asio::any_io_executor _executor; - AsyncMqttClient & _mqtt; - int _board, _channel; - - public: - MqttRelay(boost::asio::any_io_executor ex, AsyncMqttClient & mqtt, int board, int channel) - : _executor{ex}, _mqtt{mqtt}, _board{board}, _channel{channel} {} - - awaitable_expected< void > on() noexcept override { - spdlog::info("relay {}/{} ON", _board, _channel); - - auto topic = fmt::format("intra/relay_board/{}/{}", _board, _channel); - - boost::json::object payload; - payload["state"] = "on"; - payload["time_s"] = 60; - - ASYNC_CHECK(_mqtt.publish(topic, payload)); - - co_return _void{}; - } - - awaitable_expected< void > off() noexcept override { - spdlog::info("relay {}/{} OFF", _board, _channel); - - auto topic = fmt::format("intra/relay_board/{}/{}", _board, _channel); - - boost::json::object payload; - payload["state"] = "off"; - - ASYNC_CHECK(_mqtt.publish(topic, payload)); - - co_return _void{}; - } -}; - class ModbusRelay : public Relay { boost::asio::any_io_executor _executor; std::shared_ptr< ranczo::ModbusDevice > _dev; int _channel; + Relay::State _state; std::uint16_t _coil_addr; // adres cewki (0-based) public: @@ -69,7 +36,7 @@ class ModbusRelay : public Relay { ModbusRelay(boost::asio::any_io_executor ex, std::shared_ptr< ranczo::ModbusDevice > dev, int channel, std::uint16_t base_coil_addr = 0) : _executor{ex}, _dev{std::move(dev)}, _channel{channel}, _coil_addr{static_cast< std::uint16_t >(base_coil_addr + channel)} { boost::asio::co_spawn( - ex, + _executor, [=, this]() -> awaitable_expected< void > { auto state = co_await _dev->async_read_holding_register(channel); if(state.has_value()) { @@ -81,6 +48,10 @@ class ModbusRelay : public Relay { }, boost::asio::detached); } + + awaitable_expected< Relay::State > state() const noexcept override { + co_return _state; + } awaitable_expected< void > on() noexcept override { BOOST_ASSERT(_dev); @@ -90,7 +61,8 @@ class ModbusRelay : public Relay { // auto r = co_await _dev->async_write_coil(_coil_addr, true); // if(!r) // co_return unexpected(r.error()); - + + _state = Relay::State::On ; co_return _void{}; } @@ -102,7 +74,8 @@ class ModbusRelay : public Relay { // auto r = co_await _dev->async_write_coil(_coil_addr, false); // if(!r) // co_return unexpected(r.error()); - + + _state = Relay::State::Off; co_return _void{}; } }; diff --git a/services/floorheat_svc/temperature_controller.cpp b/services/floorheat_svc/temperature_controller.cpp index 828bb14..9c9ccdd 100644 --- a/services/floorheat_svc/temperature_controller.cpp +++ b/services/floorheat_svc/temperature_controller.cpp @@ -4,17 +4,19 @@ #include #include #include +#include #include #include #include #include #include #include -#include #include +#include +#include #include -#include "ranczo-io/utils/asio_watchdog.hpp" +#include "services/floorheat_svc/relay.hpp" #include "thermometer.hpp" #include @@ -27,84 +29,249 @@ #include #include #include +#include +#include + +#include /* - * TODO - * * odbieranie configa - * * KLASA do sterowania przekaźnikiem - * * KLASA do sterowania temperaturą - * * zapis do bazy danych - * * historia użycie i monitorowanie temperatury - * * Handle ERRORS + * TODO odbieranie i zapisywanie konfiguracji na dysku. + * Dopisać klase która będzie handlowała aktualną konfiguracją + * + * TODO dopisać maszynę stanów, idealnie korzystając z boost::sml + * Maszyna powinna zarządzać przejściami pomiedzy stanami w regulatorze */ namespace ranczo { -struct TemperatureMeasurement { - std::chrono::system_clock::time_point when; - Thermometer::ThermometerData data; -}; -enum Trend { Fall, Const, Rise }; +enum class ThermostatState { Enabled, Disabled, Error }; +enum class Trend { Fall, Const, Rise }; -struct RelayThermostat::Impl : private boost::noncopyable { +/** + * @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 sv = pv->as_string(); + std::string s(sv.begin(), sv.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 unexpected{make_error_code(boost::system::errc::invalid_argument)}; + } 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; - AsyncMqttClient & _mqtt; + ThermometerMeasurements(executor & io, std::unique_ptr< Thermometer > sensor) : _io{io}, _sensor{std::move(sensor)}, _history{200} {} - std::unique_ptr< Relay > _relay; - std::unique_ptr< Thermometer > _temp; - - ranczo::SoftWatchdog _watchdogTimer; - ranczo::SimpleTimer _tickTimer; - - std::chrono::system_clock::time_point _lastStateChange{std::chrono::system_clock::now()}; - - std::string _room; - boost::circular_buffer< TemperatureMeasurement > _measurements; - - int _zone{}; - - double temperature{0.0}; - double targetTemperature{0.0}; - - bool _update = false; - bool enabled = false; - - Impl(executor & io, - AsyncMqttClient & mqtt, - std::unique_ptr< Relay > relay, - std::unique_ptr< Thermometer > thermometer, - std::string_view room, - int zone) - : _io{io}, - _mqtt{mqtt}, - _relay{std::move(relay)}, - _temp{std::move(thermometer)}, - _watchdogTimer{_io, std::chrono::seconds{60}, [this]() { return this->watchdog(); }, false}, - _tickTimer{_io, std::chrono::seconds{1}, [this]() { return this->heaterControlLoopTick(); } }, - _room{room}, - _measurements{200}, - _zone{zone} { - BOOST_ASSERT(_relay); - BOOST_ASSERT(not _room.empty()); - BOOST_ASSERT(_zone > 0); + /** + * @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); }); } - ~Impl() = default; - - void setHEaterOn() { - _lastStateChange = std::chrono::system_clock::now(); + 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{}; } - void setHeaterOff() { - _lastStateChange = std::chrono::system_clock::now(); + 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{}; } - /// TODO sprawdź po 1 min czy temp faktycznie spada jeśli jest off czy rośnie jeśli jest on - Trend computeTrendLast5Min(double epsilon_deg_per_min = 0.02)const - { + /** + * @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::seconds 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 - std::chrono::minutes(5); + const auto window_start = now - window; struct Point { double t_s; @@ -112,28 +279,25 @@ struct RelayThermostat::Impl : private boost::noncopyable { std::chrono::system_clock::time_point when; }; std::vector< Point > pts; - pts.reserve(_measurements.size()); + pts.reserve(_history.size()); - for(const auto & m : _measurements) { - // odfiltruj spoza okna 5 min + 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(); // asercja BOOST_ASSERT na jednostkę + double y = m.data.temp_c(); pts.push_back({t_s, y, m.when}); } - if(pts.size() < 2) { - spdlog::warn("Too few samples in the last 5 minutes: {}", pts.size()); - return Trend::Const; - } - // 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 = 0.0, sum_y = 0.0, sum_tt = 0.0, sum_ty = 0.0; + 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; @@ -159,7 +323,7 @@ struct RelayThermostat::Impl : private boost::noncopyable { const double slope_deg_per_min = slope_deg_per_s * 60.0; - spdlog::info( + 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) @@ -168,119 +332,322 @@ struct RelayThermostat::Impl : private boost::noncopyable { return Trend::Fall; return Trend::Const; } +}; - void goToEmergencyMode() { - enabled = false; - } +/** implamentation of simple relay thermostat */ +struct RelayThermostat::Impl : private boost::noncopyable { + private: + executor & _io; + AsyncMqttClient & _mqtt; - awaitable_expected heaterControlLoopTick() { - /// Fun - spdlog::trace("heaterControlLoopTick: {}", _room); - if(true) { - /// TODO if temp to low enable heater - /// TODO if temp to high disable heater - spdlog::debug("heaterControlLoopTick got update"); + /// relay control + std::unique_ptr< Relay > _relay; + std::chrono::system_clock::time_point _lastStateChange{std::chrono::system_clock::now()}; - switch(computeTrendLast5Min(1)) { - case Const: - spdlog::debug("No temp change"); - break; - case Fall: - spdlog::debug("Temp FALL"); - break; - case Rise: - spdlog::debug("Temp RISE"); - break; - default: - break; - } + /// tempareture measurements with history + ThermometerMeasurements _thermo; - // auto avg = _measurements.size() ? std::accumulate(measurements.begin(), measurements.end(), 0.0) `/ _measurements.size() : - // 0.0; spdlog::info("got readout nr {} last temp {} avg {}", _measurements.size(), _measurements.back().temperature_C, avg); - _update = false; - } - - co_return _void{}; - } + /// configuration + std::string _room; + int _zone{}; + ThermostatState _state{ThermostatState::Disabled}; + double _targetTemperature{0.0}; - awaitable_expected< void > subscribe(std::string_view topic, - std::function< awaitable_expected< void >(const AsyncMqttClient::CallbackData &) > cb) { - /// TODO fix - 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{}; - } + 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::minutes _sensorTimeout{std::chrono::minutes(5)}; - inline expected< double > get_value(const boost::json::value & jv, std::string_view key) { - if(auto * obj = jv.if_object()) { - if(auto * pv = obj->if_contains(key)) { - auto ovalue = json::as_number(*pv).value(); - if(ovalue) - return ovalue; - } - } - return unexpected{make_error_code(boost::system::errc::invalid_argument)}; - } + // --- nowe helpery --- + awaitable_expected< void > controlLoop() { + using namespace std::chrono; - awaitable_expected< void > subscribeToCommandUpdate() { - // 0 controls all zones - auto broadcast_topic = topic::heating::command(_room, 0); - auto topic = topic::heating::command(_room, _zone); + boost::asio::steady_timer timer(_io); - auto cb = [=, this](const AsyncMqttClient::CallbackData & data) -> awaitable_expected< void > { - targetTemperature = TRY(get_value(data.payload, "value")); - spdlog::trace("Heater target temperature update {} for {}/{}", targetTemperature, _room, _zone); - _update = true; - co_return _void{}; - }; - - ASYNC_CHECK(subscribe(broadcast_topic, std::move(cb))); - ASYNC_CHECK(subscribe(topic, std::move(cb))); - - co_return _void{}; - } - - awaitable_expected< void > temperatureUpdate(Thermometer::ThermometerData data) { - spdlog::info("Got temperature update for: {}", _room); - _watchdogTimer.touch(); - - _measurements.push_back({std::chrono::system_clock::now(), data}); - - /// TODO sprawdzenie czy temp < treshold - /// TODO ustawienie przekaźniczka - co_return _void{}; - } - - void watchdog() { - //// TODO OFF for(;;) { - spdlog::warn("watchdog triggered"); - return; + ASYNC_CHECK(applyControlStep()); + + // odczekaj minimalny 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; + + // 1) sprawdzenie czy mamy odczyty temperatury + auto dtLastOpt = _thermo.timeSinceLastRead(); + if(!dtLastOpt) { + spdlog::debug("No temperature samples yet for {}/{}", _room, _zone); + co_return _void{}; + } + + auto dtLast = *dtLastOpt; + + // 2) spójne sprawdzenie timeoutu, trendu i ewentualnych stuck-relay + ASYNC_CHECK(checkStateAndTrend(dtLast)); + + // 3) logika zależna od stanu termostatu + 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{}; + } + + awaitable_expected< void > checkStateAndTrend(std::chrono::nanoseconds dtLast) { + using namespace std::chrono; + + // brak update'u temperatury + if(dtLast > _sensorTimeout) { + ASYNC_CHECK(error("temperature sensor timeout (> 5 minutes without update)")); + } + + // jeśli już jesteśmy w ERROR, to nie ma sensu dalej analizować trendu + if(_state == ThermostatState::Error) { + co_return _void{}; + } + + // aktualny trend temperatury (na konfigurowalnym oknie) + auto trendOpt = _thermo.temperatureTrend(duration_cast< seconds >(_slopeWindow), _slopeDT_c); + if(!trendOpt) { + co_return _void{}; + } + + auto trend = *trendOpt; + const bool relayOn = (_relay->state() == Relay::State::On); + + // 2a) relay OFF, a temperatura rośnie => przekaźnik zawiesił się na ON + if(!relayOn && trend == Trend::Rise) { + ASYNC_CHECK(error("relay stuck ON: temperature rising while relay is commanded OFF")); + } + + // 2b) relay ON, a trend != Rise => przekaźnik zawiesił się na OFF + if(relayOn && trend != Trend::Rise) { + ASYNC_CHECK(error("relay stuck OFF: temperature not rising while relay is commanded ON")); + } + + co_return _void{}; + } + + awaitable_expected< void > handleErrorState() { + if(_relay->state() == Relay::State::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() { + if(_relay->state() == Relay::State::On) { + spdlog::info("RelayThermostat disabling relay because thermostat is Disabled for {}/{}", _room, _zone); + ASYNC_CHECK_MSG(_relay->off(), "relay OFF failed"); + } + co_return _void{}; + } + + awaitable_expected< void > handleEnabledState() { + using namespace std::chrono; + + auto tempOpt = _thermo.currentTemperature(); + if(!tempOpt) { + co_return _void{}; + } + const double temp = *tempOpt; + + const auto now = system_clock::now(); + const auto minElapsed = now - _lastStateChange; + const bool relayOn = (_relay->state() == 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(_relay->off(), "Disabling relay failed"); + _lastStateChange = now; + } + } + + 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; + + // Bezpieczeństwo: wyłącz przekaźnik natychmiastowo + if(_relay->state() == Relay::State::On) { + ASYNC_CHECK(_relay->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, + std::unique_ptr< Relay > relay, + std::unique_ptr< Thermometer > thermometer, + std::string_view room, + int zone) + : _io{io}, _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; spdlog::info("RelayThermostat::start room : {}", _room); BOOST_ASSERT(_temp); - + // subscribe to a thermostat commands feed spdlog::info("RelayThermostat::start room : {} subscribe to mqtt", _room); - ASYNC_CHECK_MSG(subscribeToCommandUpdate(), "subscribe to command stream failed"); - - // subscribe to a thermometer readings - spdlog::info("RelayThermostat::start room : {} subscribe to temp", _room); - ASYNC_CHECK(_temp->on_update([&](auto data) { return this->temperatureUpdate(data); })); - - /// subscribe to energy measurements - /// ommited, any controls should be handled in nodered not here. + ASYNC_CHECK_MSG(subscribeToAllCommands(), "subscribe to command stream failed"); // detaching listening thread spdlog::info("RelayThermostat::start room : {} listen", _room); - _watchdogTimer.start(); - _tickTimer.start(true); - boost::asio::co_spawn(_io, _temp->listen(), boost::asio::detached); + 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 &) > 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_TRY(subscribeCommand< commands::TemperatureSetpointChange >()); + ASYNC_TRY(subscribeCommand< commands::StateChange >()); + ASYNC_TRY(subscribeCommand< commands::HisteresisChange >()); + ASYNC_TRY(subscribeCommand< commands::TickTimeChange >()); + ASYNC_TRY(subscribeCommand< commands::SlopeWindowChange >()); + ASYNC_TRY(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) -> awaitable_expected< void > { + auto _result = Command::from_payload(data.payload); + if(!_result) + co_return unexpected{_result.error()}; + + auto cmd = *_result; + + ASYNC_CHECK(handle_command(cmd)); + co_return _void{}; + }; + + ASYNC_CHECK(subscribe(broadcast_topic, cb)); + ASYNC_CHECK(subscribe(zone_topic, cb)); + + co_return _void{}; + } + + // przeciążone handlery dla poszczególnych komend: + awaitable_expected< void > handle_command(const commands::TemperatureSetpointChange & cmd) { + spdlog::info("Heater target temperature update {} for {}/{}", _targetTemperature, _room, _zone); + _targetTemperature = cmd.setpoint_c; + co_return _void{}; + } + + awaitable_expected< void > 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 _void{}; + } + + _state = cmd.state; + + // Bezpiecznik: jeśli zostało ustawione Disabled – wyłącz przekaźnik + if(_state == ThermostatState::Disabled && _relay->state() == Relay::State::On) { + ASYNC_CHECK(_relay->off()); + } + + co_return _void{}; + } + + awaitable_expected< void > handle_command(const commands::HisteresisChange & cmd) { + spdlog::info("Heater histeresis update {} for {}/{}", cmd.histeresis, _room, _zone); + _hysteresis = cmd.histeresis; + co_return _void{}; + } + + awaitable_expected< void > handle_command(const commands::TickTimeChange & cmd) { + spdlog::info("Heater tick time update {}ns for {}/{}", cmd.tickTime.count(), _room, _zone); + _tickTime = cmd.tickTime; + co_return _void{}; + } + + awaitable_expected< void > handle_command(const commands::SlopeWindowChange & cmd) { + spdlog::info("Heater slope window update {}ns for {}/{}", cmd.window, _room, _zone); + _slopeWindow = cmd.window; + co_return _void{}; + } + + awaitable_expected< void > 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_return _void{}; } };