add thermostat implementation + add modbus relay implementation

This commit is contained in:
Bartosz Wieczorek 2025-11-14 08:37:15 +01:00
parent e58f3fc139
commit 64d5621195
10 changed files with 726 additions and 221 deletions

View File

@ -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

View File

@ -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; \
})

View File

@ -1,4 +1,6 @@
#include "config.hpp"
#include <chrono>
#include <ratio>
#include <spdlog/spdlog.h>
#include <ranczo-io/utils/modbus.hpp>
@ -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());
}

View File

@ -3,9 +3,11 @@
#include "config.hpp"
#include <boost/asio/steady_timer.hpp>
#include <boost/asio/thread_pool.hpp>
#include <cerrno>
#include <chrono>
#include <cstdint>
#include <memory>
#include <string>
@ -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<void> 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

View File

@ -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:

View File

@ -0,0 +1,115 @@
#pragma once
#include <algorithm>
#include <cctype>
#include <charconv>
#include <chrono>
#include <string>
#include <string_view>
#include <config.hpp>
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: "<liczba> <jednostka>"
// 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

View File

@ -9,6 +9,7 @@ target_link_libraries(ranczo-io_floorheating
PUBLIC
ranczo-io::utils
fmt::fmt
sml::sml
)
include(GNUInstallDirs)

View File

@ -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)));

View File

@ -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{};
}
};

View File

@ -4,17 +4,19 @@
#include <boost/asio/co_spawn.hpp>
#include <boost/asio/detached.hpp>
#include <boost/asio/experimental/parallel_group.hpp>
#include <boost/asio/redirect_error.hpp>
#include <boost/asio/steady_timer.hpp>
#include <boost/smart_ptr/shared_ptr.hpp>
#include <boost/system/detail/errc.hpp>
#include <boost/system/errc.hpp>
#include <boost/system/result.hpp>
#include <functional>
#include <map>
#include <memory>
#include <optional>
#include <queue>
#include <spdlog/spdlog.h>
#include "ranczo-io/utils/asio_watchdog.hpp"
#include "services/floorheat_svc/relay.hpp"
#include "thermometer.hpp"
#include <ranczo-io/utils/json_helpers.hpp>
@ -27,84 +29,249 @@
#include <boost/circular_buffer.hpp>
#include <boost/core/noncopyable.hpp>
#include <boost/json/object.hpp>
#include <string_view>
#include <type_traits>
#include <ranczo-io/utils/time.hpp>
/*
* 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<void> 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{};
}
};