add thermostat implementation + add modbus relay implementation
This commit is contained in:
parent
e58f3fc139
commit
64d5621195
@ -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
|
||||
|
||||
@ -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; \
|
||||
})
|
||||
|
||||
|
||||
@ -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());
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
115
libs/ranczo-io/utils/time.hpp
Normal file
115
libs/ranczo-io/utils/time.hpp
Normal 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
|
||||
@ -9,6 +9,7 @@ target_link_libraries(ranczo-io_floorheating
|
||||
PUBLIC
|
||||
ranczo-io::utils
|
||||
fmt::fmt
|
||||
sml::sml
|
||||
)
|
||||
|
||||
include(GNUInstallDirs)
|
||||
|
||||
@ -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)));
|
||||
|
||||
@ -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{};
|
||||
}
|
||||
};
|
||||
|
||||
@ -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{};
|
||||
}
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user