diff --git a/CMakeLists.txt b/CMakeLists.txt index cffba35..0e8d5bb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -25,12 +25,12 @@ include_directories(${CMAKE_CURRENT_SOURCE_DIR}/include) include(FetchContent) -set(BOOST_INCLUDE_LIBRARIES asio assert core endian json mqtt5 fusion optional random range smart_ptr spirit type_traits) +set(BOOST_INCLUDE_LIBRARIES asio assert core endian json mqtt5 fusion optional random range smart_ptr spirit type_traits url) set(BOOST_ENABLE_CMAKE ON) set(Boost_NO_SYSTEM_PATHS ON) set(BOOST_ROOT /usr/local/boost-1.89) -find_package(Boost 1.89 REQUIRED COMPONENTS json mqtt5) +find_package(Boost 1.89 REQUIRED COMPONENTS json mqtt5 url) # spdlog include(CheckCXXSourceCompiles) diff --git a/config.hpp b/config.hpp index b41c9c0..ba6a094 100644 --- a/config.hpp +++ b/config.hpp @@ -4,6 +4,8 @@ #include #include +#include + #if __has_include() #include #elif __has_include() @@ -14,6 +16,10 @@ namespace ranczo { +template +std::optional from_string(std::optional< std::string_view > state, + std::pmr::memory_resource * mr = std::pmr::get_default_resource()); + #if __has_include() template < typename T > using expected = std::expected< T, boost::system::error_code >; diff --git a/libs/CMakeLists.txt b/libs/CMakeLists.txt index 4d2d477..1fd8389 100644 --- a/libs/CMakeLists.txt +++ b/libs/CMakeLists.txt @@ -1,6 +1,7 @@ include(modbus.cmake) find_package(SQLite3) +find_package(OpenSSL REQUIRED) add_library(ranczo-io_utils mqtt_client.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ranczo-io/utils/mqtt_client.hpp @@ -9,6 +10,7 @@ add_library(ranczo-io_utils asio_watchdog.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ranczo-io/utils/asio_watchdog.hpp modbus.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ranczo-io/utils/modbus.hpp config.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ranczo-io/utils/config.hpp + http.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ranczo-io/utils/http.hpp ${CMAKE_CURRENT_SOURCE_DIR}/ranczo-io/utils/mqtt_topic_builder.hpp ${CMAKE_CURRENT_SOURCE_DIR}/ranczo-io/utils/memory_resource.hpp @@ -30,6 +32,9 @@ target_link_libraries(ranczo-io_utils fmt::fmt modbus date::date + OpenSSL::SSL #http + OpenSSL::Crypto + Boost::url PRIVATE SQLite::SQLite3 ) diff --git a/libs/http.cpp b/libs/http.cpp new file mode 100644 index 0000000..ade5c18 --- /dev/null +++ b/libs/http.cpp @@ -0,0 +1,176 @@ +#include +#include +#include + +#include +#include +#include +#include +#include + +#include + +namespace ranczo { + +using tcp = boost::asio::ip::tcp; +namespace asio = boost::asio; +namespace beast = boost::beast; +namespace http = beast::http; + +using PmrCharAlloc = std::pmr::polymorphic_allocator< char >; +using PmrFields = http::basic_fields< PmrCharAlloc >; +using PmrStringBody = http::basic_string_body< char, std::char_traits< char >, PmrCharAlloc >; +using PmrFlatBuffer = boost::beast::basic_flat_buffer< PmrCharAlloc >; +using PmrResponse = http::response< PmrStringBody, PmrFields >; +using PmrRequest = http::request< PmrStringBody, PmrFields >; + +expected< HttpGetClient::ParsedUrl > HttpGetClient::parse_url_generic(std::string_view url) { + ParsedUrl out; + namespace urls = boost::urls; + + auto r = urls::parse_uri(url); + if(!r) { + return unexpected(r.error()); + } + + urls::url_view u = *r; + + out.scheme = std::string(u.scheme()); + out.host = std::string(u.host()); + + // ścieżka + out.target = std::string(u.encoded_path()); + if(out.target.empty()) + out.target = "/"; + + // query (bez fragmentu) + if(!u.encoded_query().empty()) { + out.target += "?"; + out.target += std::string(u.encoded_query()); + } + + // port + if(!u.port().empty()) { + out.port = u.port_number(); + } else { + if(out.scheme == "https") + out.port = 443; + else + out.port = 80; + } + + out.use_ssl = (out.scheme == "https"); + + return out; +} + +awaitable_expected< std::pmr::string > HttpGetClient::async_get(std::string_view url, std::pmr::memory_resource * mr) { + BOOST_ASSERT(mr); + namespace ssl = boost::asio::ssl; + + PmrCharAlloc alloc{mr}; + + // 1. Parse URL + auto parsed_exp = parse_url_generic(url); + if(!parsed_exp) { + co_return unexpected(parsed_exp.error()); + } + auto parsed = *parsed_exp; + + beast::error_code ec; + + // 2. Resolver + tcp::resolver resolver{executor()}; + auto results = co_await resolver.async_resolve(parsed.host, std::to_string(parsed.port), asio::redirect_error(asio::use_awaitable, ec)); + if(ec) { + co_return unexpected(ec); + } + + // 3. Request na PMR + http::request< PmrStringBody, PmrFields > req{http::verb::get, parsed.target, 11, PmrStringBody::value_type{alloc}, PmrFields{alloc}}; + req.set(http::field::host, parsed.host); + req.set(http::field::user_agent, "ranczo-http-client"); + + // 4. Wspólne rzeczy dla HTTP/HTTPS + PmrFlatBuffer buffer{alloc}; + PmrResponse res{std::piecewise_construct, std::make_tuple(PmrStringBody::value_type{alloc}), std::make_tuple(PmrFields{alloc})}; + + if(!parsed.use_ssl) { + // ---------------------- HTTP (bez TLS) ---------------------- + beast::tcp_stream stream{executor()}; + stream.expires_after(_timeout); + + co_await stream.async_connect(results, asio::redirect_error(asio::use_awaitable, ec)); + if(ec) { + co_return unexpected(ec); + } + + co_await http::async_write(stream, req, asio::redirect_error(asio::use_awaitable, ec)); + if(ec) { + co_return unexpected(ec); + } + + co_await http::async_read(stream, buffer, res, asio::redirect_error(asio::use_awaitable, ec)); + if(ec) { + co_return unexpected(ec); + } + + stream.socket().shutdown(tcp::socket::shutdown_both, ec); + // błąd przy shutdown zwykle ignorujemy + } else { + // ---------------------- HTTPS (TLS) ---------------------- + ssl::context ctx{ssl::context::tls_client}; + + // Uwaga: do prawdziwego użycia: + // - ctx.set_default_verify_paths(); + // - ctx.set_verify_mode(ssl::verify_peer); + // Tu dla testów najprościej wyłączyć weryfikację: + ctx.set_verify_mode(ssl::verify_none); + + beast::ssl_stream< beast::tcp_stream > stream{executor_, ctx}; + + auto & lowest = beast::get_lowest_layer(stream); + lowest.expires_after(_timeout); + + co_await lowest.async_connect(results, asio::redirect_error(asio::use_awaitable, ec)); + if(ec) { + co_return unexpected(ec); + } + + // (opcjonalnie) SNI – przydatne dla częsci serwerów + if(!SSL_set_tlsext_host_name(stream.native_handle(), parsed.host.c_str())) { + ec = beast::error_code(static_cast< int >(::ERR_get_error()), asio::error::get_ssl_category()); + co_return unexpected(ec); + } + + co_await stream.async_handshake(ssl::stream_base::client, asio::redirect_error(asio::use_awaitable, ec)); + if(ec) { + co_return unexpected(ec); + } + + co_await http::async_write(stream, req, asio::redirect_error(asio::use_awaitable, ec)); + if(ec) { + co_return unexpected(ec); + } + + co_await http::async_read(stream, buffer, res, asio::redirect_error(asio::use_awaitable, ec)); + if(ec) { + co_return unexpected(ec); + } + + // shutdown TLS + co_await stream.async_shutdown(asio::redirect_error(asio::use_awaitable, ec)); + // tu też często dostaniesz EOF – można zignorować + } + + // Opcjonalnie możesz sprawdzić status: + // if (res.result() != http::status::ok) { ... } + + // Body korzysta z tego samego PMR + std::pmr::string body{alloc}; + body = res.body(); // kopia na tym samym allocatorze (bez dużych kosztów w mr) + + co_return body; +} + +} // namespace ranczo diff --git a/libs/modbus.cpp b/libs/modbus.cpp index 154208c..6d0fb1c 100644 --- a/libs/modbus.cpp +++ b/libs/modbus.cpp @@ -123,8 +123,11 @@ awaitable_expected< T > ModbusTcpContext::call_with_lock(F && op) { out = ctx_.get(); return op(out); }); + + } + template < typename T, typename Maker > awaitable_expected< T > ModbusTcpContext::async_call(Maker && maker) { namespace asio = boost::asio; @@ -155,6 +158,7 @@ awaitable_expected< uint16_t > ModbusDevice::async_read_holding_register(uint16_ _log.error("modbus_set_slave address {} failed with {}", address, errno); return unexpected(errno_errc()); } + uint16_t val = 0; int rc = ::modbus_read_registers(c, static_cast< int >(address), 1, &val); if(rc == -1) { @@ -165,6 +169,29 @@ awaitable_expected< uint16_t > ModbusDevice::async_read_holding_register(uint16_ }); } +awaitable_expected > ModbusDevice::async_read_holding_registers(uint16_t address, std::size_t number, std::pmr::memory_resource *mr) +{ + BOOST_ASSERT(mr); + address -= 1; + auto ctx = ctx_; // kopia shared_ptr dla bezpieczeństwa w tasku + co_return co_await ctx->call_with_lock< std::pmr::vector< std::uint16_t > >( + [this, address, mr, number](modbus_t * c) -> expected< std::pmr::vector< std::uint16_t > > { + if(::modbus_set_slave(c, unit_id_) == -1) { + _log.error("modbus_set_slave address {} failed with {}", address, errno); + return unexpected(errno_errc()); + } + + std::pmr::vector< std::uint16_t > val{mr}; + val.resize(number); + int rc = ::modbus_read_registers(c, static_cast< int >(address), number, val.data()); + if(rc == -1) { + _log.error("modbus_read_registers address {} failed with {}", address, errno); + return unexpected(errno_errc()); + } + return val; + }); +} + 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 > { diff --git a/libs/ranczo-io/utils/http.hpp b/libs/ranczo-io/utils/http.hpp new file mode 100644 index 0000000..48094d9 --- /dev/null +++ b/libs/ranczo-io/utils/http.hpp @@ -0,0 +1,84 @@ + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace ranczo { +class BaseIO { + public: + using allocator_type = std::pmr::polymorphic_allocator< std::byte >; + + BaseIO() + : resource_(std::pmr::get_default_resource()), _log{spdlog::default_logger(), "Client"}, executor_{boost::asio::system_executor()} {} + + BaseIO(boost::asio::any_io_executor ex, + std::string_view log_name = "Client", + std::pmr::memory_resource * res = std::pmr::get_default_resource()) + : resource_(res), _log{spdlog::default_logger(), log_name}, executor_{ex} {} + + BaseIO(std::allocator_arg_t, const allocator_type & alloc, boost::asio::any_io_executor ex, std::string_view log_name = "Client") + : resource_(alloc.resource()), _log{spdlog::default_logger(), log_name}, executor_{ex} {} + + allocator_type get_allocator() const noexcept { + return allocator_type{resource_}; + } + + std::pmr::memory_resource * get_resource() const noexcept { + return resource_; + } + + boost::asio::any_io_executor executor() const noexcept { + return executor_; + } + + ModuleLogger & log() noexcept { + return _log; + } + const ModuleLogger & log() const noexcept { + return _log; + } + + ModuleLogger _log; + + protected: + // chronione, aby klasy pochodne mogły korzystać + std::pmr::memory_resource * resource_; + boost::asio::any_io_executor executor_; +}; + +class HttpGetClient : public BaseIO { + public: + explicit HttpGetClient(boost::asio::any_io_executor executor) + : BaseIO{std::allocator_arg, allocator_type{std::pmr::get_default_resource()}, executor, "HttpClient"} {} + + explicit HttpGetClient(std::allocator_arg_t t, const allocator_type & a, boost::asio::any_io_executor executor) + : BaseIO{t, a, executor, "HttpClient"} {} + + struct ParsedUrl { + std::string scheme; + std::string host; + std::uint16_t port; + std::string target; // path + query + bool use_ssl; + }; + /// Prosty helper: parsuje URL na scheme/host/port/target + expected< ParsedUrl > parse_url_generic(std::string_view url); + awaitable_expected< std::pmr::string > async_get(std::string_view url, std::pmr::memory_resource * mr); + + private: + std::chrono::steady_clock::duration _timeout{std::chrono::seconds{5}}; +}; + +} // namespace ranczo + +namespace std { +template <> +struct uses_allocator< ranczo::HttpGetClient, ranczo::HttpGetClient::allocator_type > : true_type {}; +} // namespace std diff --git a/libs/ranczo-io/utils/modbus.hpp b/libs/ranczo-io/utils/modbus.hpp index 7cc54a2..28a272a 100644 --- a/libs/ranczo-io/utils/modbus.hpp +++ b/libs/ranczo-io/utils/modbus.hpp @@ -12,6 +12,7 @@ #include #include #include +#include #include // libmodbus (C) #include @@ -78,10 +79,19 @@ class ModbusDevice { } // FC 0x03: pojedynczy holding register - awaitable_expected< std::uint16_t > async_read_holding_register(std::uint16_t address); + awaitable_expected< std::uint16_t > async_read_holding_register( // + std::uint16_t address); + + // FC 0x03: wiele holding registers + awaitable_expected< std::pmr::vector< std::uint16_t > > async_read_holding_registers( // + std::uint16_t address, + std::size_t number, + std::pmr::memory_resource * mr = std::pmr::get_default_resource()); // FC 0x05 – ZAPIS CEWKI (coil/bit): true=ON, false=OFF - awaitable_expected< void > async_write_coil(std::uint16_t address, bool value); + awaitable_expected< void > async_write_coil( // + std::uint16_t address, + bool value); private: static boost::system::error_code errno_errc() { diff --git a/libs/ranczo-io/utils/mqtt_topic_builder.hpp b/libs/ranczo-io/utils/mqtt_topic_builder.hpp index 04e6ce2..6e7b2ab 100644 --- a/libs/ranczo-io/utils/mqtt_topic_builder.hpp +++ b/libs/ranczo-io/utils/mqtt_topic_builder.hpp @@ -7,7 +7,6 @@ #include #include - /** * Topic layout assumptions * @@ -41,7 +40,6 @@ * Jest wyłącznie kanałem, na którym urządzenie publikuje swój stan. */ - namespace ranczo { template < typename... Args > constexpr size_t length(const Args &... args) { @@ -118,7 +116,7 @@ namespace topic { publishState(std::string_view room, int zone = 1, std::pmr::memory_resource * mr = std::pmr::get_default_resource()) { using namespace std::string_view_literals; BOOST_ASSERT(mr); - return buildPublishTopic(*mr, room, "heating"sv, "floor"sv, zone, "state"sv); + return buildPublishTopic(*mr, room, "heating"sv, "floor"sv, zone, "state"sv); // TODO powalczyć z prawidłowym nazwenictwem } } // namespace heating @@ -142,42 +140,69 @@ namespace topic { } } // namespace temperature - - namespace utilities{ - // home/utilities/power/// + + namespace utilities { /* - * : main, heating, housing - * : active, reactive, apparent, voltage, current, frequency, pf - * : total, L1, L2, L3 itp. - */ - inline std::pmr::string - publishPowerReading(std::string_view meter, std::string_view kind, std::string_view channel, std::pmr::memory_resource * mr = std::pmr::get_default_resource()) { + group – „rodzaj wielkości” (measurement / power / energy / flow / volume / …) + medium – nośnik (electricity / water / gas / …) + meter – który licznik (main / heating / housing / …) + measurement – co dokładnie (voltage / current / active / reactive / flow / volume / …) + channel – kanał / faza / obwód (L1 / L2 / total / loop1 / …) + + home/utilities/flow /water /main/flow /heating_loop + home/utilities/flow /water /main/flow /domestic + home/utilities/power /electricity/main/active /L1 + home/utilities/measurement/electricity/main/voltage/L1 + + # home/utilities/flow/water/main/flow/total + # group ^^^^ [flow | power | energy] + # medium ^^^^ [water | electricity] + # meter ^^^^ [main] + # measurement ^^^^ [flow | volume] + # channel ^^^^^ [domestic | well] + */ + + inline std::pmr::string _make_topic(std::pmr::memory_resource * mr, + std::string_view group, + std::string_view medium, + std::string_view meter, + std::string_view measurement, + std::string_view channel) { + using namespace std::string_view_literals; + return make_topic(*mr, "home"sv, "utilities"sv, group, medium, meter, measurement, channel); + } + + inline std::pmr::string publishPowerReading(std::string_view meter, + std::string_view measuremetn, + std::string_view channel, + std::pmr::memory_resource * mr = std::pmr::get_default_resource()) { BOOST_ASSERT(mr); BOOST_ASSERT(meter.size()); BOOST_ASSERT(kind.size()); BOOST_ASSERT(channel == "total" || channel == "L1" || channel == "L2" || channel == "L3"); using namespace std::string_view_literals; - return make_topic(*mr, "home"sv, "utilities"sv, "power"sv, "electricity"sv, meter, kind, channel); + return _make_topic(mr, "power"sv, "electricity"sv, meter, measuremetn, channel); } - + // home/utilities/energy/// /* * : main, heating, housing - * : tatol_active_energy + * : tatol_active_energy * : total, L1, L2, L3 itp. */ - inline std::pmr::string - publishEnergyReading(std::string_view meter, std::string_view kind, std::string_view channel, std::pmr::memory_resource * mr = std::pmr::get_default_resource()) { + inline std::pmr::string publishEnergyReading(std::string_view meter, + std::string_view measurement, + std::string_view channel, + std::pmr::memory_resource * mr = std::pmr::get_default_resource()) { BOOST_ASSERT(mr); BOOST_ASSERT(meter.size()); BOOST_ASSERT(kind.size()); BOOST_ASSERT(channel == "total" || channel == "L1" || channel == "L2" || channel == "L3"); using namespace std::string_view_literals; - return make_topic(*mr, "home"sv, "utilities"sv, "energy"sv, "electricity"sv, meter, kind, channel); + return _make_topic(mr, "energy"sv, "electricity"sv, meter, measurement, channel); } - - } + } // namespace utilities } // namespace topic } // namespace ranczo diff --git a/services/CMakeLists.txt b/services/CMakeLists.txt index 3dbde0c..4c32add 100644 --- a/services/CMakeLists.txt +++ b/services/CMakeLists.txt @@ -7,6 +7,6 @@ include(GNUInstallDirs) add_subdirectory(temperature_svc) add_subdirectory(floorheat_svc) -# add_subdirectory(energymeter_svc) +add_subdirectory(energymeter_svc) add_subdirectory(output_svc) add_subdirectory(input_svc) diff --git a/services/energymeter_svc/ORNO_517.cpp b/services/energymeter_svc/ORNO_517.cpp index e69de29..33ba019 100644 --- a/services/energymeter_svc/ORNO_517.cpp +++ b/services/energymeter_svc/ORNO_517.cpp @@ -0,0 +1,207 @@ + +#include "ranczo-io/utils/date_utils.hpp" +#include "ranczo-io/utils/modbus.hpp" +#include +#include +#include +#include + +namespace ranczo { + +// ─────────────────────────────────────────────────────────────────────────── +// Rejestry energomierza +// ─────────────────────────────────────────────────────────────────────────── + +struct Register { + const char * measurement_name; + const char * unit; + const char * register_name; + std::uint16_t offset; // offset w słowach 16-bit od _baseAddress + + float (*read)(const std::uint16_t *); + float (*total)(float lhs, float rhs); + + float do_read(const std::uint16_t * data) const { + return read(data + offset); + } +}; + +inline float read_float(const std::uint16_t * data) { + return modbus_get_float_abcd(data); +} + +inline float from_kilo(const std::uint16_t * data) { + return read_float(data) * 1000.0f; +} + +inline float add(float lhs, float rhs) { + return lhs + rhs; +} + +inline float avg(float lhs, float rhs) { + return (lhs + rhs) * 0.5f; +} + +// ─────────────────────────────────────────────────────────────────────────── +// Konkrety: odczyty "live" +// ─────────────────────────────────────────────────────────────────────────── + +class EnergymeterReadings { + public: + EnergymeterReadings(boost::asio::any_io_executor ex, + ModbusDevice & modbus, + std::span< const Register > regs, + const char * name, + std::uint16_t baseAddress) + : _ex(ex), + _modbus(modbus), + _registers(regs), + _name(name), + _baseAddress(baseAddress), + _housingUsage(regs.size(), 0.0f), + _heatingUsage(regs.size(), 0.0f) {} + + // slave 1: "housing", slave 2: "heating" + ranczo::awaitable_expected< void > publish() { + using namespace std::chrono_literals; + + const std::size_t LiveRegistersSize = _registers.size() * 2; + + // std::vector< std::uint16_t > housingRaw(LiveRegistersSize); + // std::vector< std::uint16_t > heatingRaw(LiveRegistersSize); + + // const auto housingTimepoint = date::to_iso_timestamp(std::chrono::system_clock::now()); + // auto r1 = + // co_await _modbus.async_read_holding_registers(_baseAddress, static_cast< std::uint16_t >(LiveRegistersSize), ); + // if(!r1) { + // spdlog::error("Modbus read (housing) failed: {}", r1.error().message()); + // co_return unexpected(r1.error()); + // } + + // const auto heatingTimepoint = date::to_iso_timestamp(std::chrono::system_clock::now()); + // auto r2 = + // co_await _modbus.read_holding_registers(2, _baseAddress, static_cast< std::uint16_t >(LiveRegistersSize), heatingRaw.data()); + // if(!r2) { + // spdlog::error("Modbus read (heating) failed: {}", r2.error().message()); + // co_return unexpected(r2.error()); + // } + + // const auto totalTimepoint = date::to_iso_timestamp(std::chrono::system_clock::now()); + + // auto housingPrev = _housingUsage.begin(); + // auto heatingPrev = _heatingUsage.begin(); + + // for(const auto & reg : _registers) { + // const float housingValue = reg.do_read(housingRaw.data()); + // const float heatingValue = reg.do_read(heatingRaw.data()); + // const float totalValue = reg.total(heatingValue, housingValue); + + // const bool housingUpdated = housingValue != *housingPrev; + // const bool heatingUpdated = heatingValue != *heatingPrev; + // const bool totalUpdated = housingUpdated || heatingUpdated; + + // *housingPrev = housingValue; + // *heatingPrev = heatingValue; + // ++housingPrev; + // ++heatingPrev; + // } + + co_return ranczo::expected< void >{}; + } + + protected: + boost::asio::any_io_executor _ex; + + ModbusDevice &_modbus; + std::span< const Register > _registers; + const char * _name; + const std::uint16_t _baseAddress; + + std::vector< float > _housingUsage; + std::vector< float > _heatingUsage; +}; + +class EnergymeterLiveReading : public EnergymeterReadings { + private: + static constexpr std::uint16_t _baseAddressLive = 0x000E; + + static constexpr Register liveRegisters_[] = { + {"Voltage", "V", "L1", std::uint16_t{0x000E} - _baseAddressLive, read_float, avg}, + {"Voltage", "V", "L2", std::uint16_t{0x0010} - _baseAddressLive, read_float, avg}, + {"Voltage", "V", "L3", std::uint16_t{0x0012} - _baseAddressLive, read_float, avg}, + {"Frequency", "Hz", "Grid", std::uint16_t{0x0014} - _baseAddressLive, read_float, avg}, + {"Current", "A", "L1", std::uint16_t{0x0016} - _baseAddressLive, read_float, add}, + {"Current", "A", "L2", std::uint16_t{0x0018} - _baseAddressLive, read_float, add}, + {"Current", "A", "L3", std::uint16_t{0x001A} - _baseAddressLive, read_float, add}, + {"ActivePower", "W", "Total", std::uint16_t{0x001C} - _baseAddressLive, from_kilo, add}, + {"ActivePower", "W", "L1", std::uint16_t{0x001E} - _baseAddressLive, from_kilo, add}, + {"ActivePower", "W", "L2", std::uint16_t{0x0020} - _baseAddressLive, from_kilo, add}, + {"ActivePower", "W", "L3", std::uint16_t{0x0022} - _baseAddressLive, from_kilo, add}, + {"ReactivePower", "Var", "Total", std::uint16_t{0x0024} - _baseAddressLive, from_kilo, add}, + {"ReactivePower", "Var", "L1", std::uint16_t{0x0026} - _baseAddressLive, from_kilo, add}, + {"ReactivePower", "Var", "L2", std::uint16_t{0x0028} - _baseAddressLive, from_kilo, add}, + {"ReactivePower", "Var", "L3", std::uint16_t{0x002A} - _baseAddressLive, from_kilo, add}, + {"ApparentPower", "VA", "Total", std::uint16_t{0x002C} - _baseAddressLive, from_kilo, add}, + {"ApparentPower", "VA", "L1", std::uint16_t{0x002E} - _baseAddressLive, from_kilo, add}, + {"ApparentPower", "VA", "L2", std::uint16_t{0x0030} - _baseAddressLive, from_kilo, add}, + {"ApparentPower", "VA", "L3", std::uint16_t{0x0032} - _baseAddressLive, from_kilo, add}, + {"PowerFactor", "", "Total", std::uint16_t{0x0034} - _baseAddressLive, read_float, avg}, + {"PowerFactor", "", "L1", std::uint16_t{0x0036} - _baseAddressLive, read_float, avg}, + {"PowerFactor", "", "L2", std::uint16_t{0x0038} - _baseAddressLive, read_float, avg}, + {"PowerFactor", "", "L3", std::uint16_t{0x003A} - _baseAddressLive, read_float, avg}, + }; + + public: + EnergymeterLiveReading(boost::asio::any_io_executor ex, ModbusDevice & modbus) + : EnergymeterReadings(ex, + modbus, + std::span< const Register >(liveRegisters_, std::size(liveRegisters_)), + "current", + _baseAddressLive) {} +}; + +// ─────────────────────────────────────────────────────────────────────────── +// Konkrety: odczyty "total" +// ─────────────────────────────────────────────────────────────────────────── + +class EnergymeterTotalReading : public EnergymeterReadings { + private: + static constexpr std::uint16_t _baseAddressTotal = 0x0100; + + static constexpr Register totalRegisters_[] = { + {"ActiveEnergy", "Wh", "Total", std::uint16_t{0x0100} - _baseAddressTotal, from_kilo, add}, + {"ActiveEnergy", "Wh", "L1", std::uint16_t{0x0102} - _baseAddressTotal, from_kilo, add}, + {"ActiveEnergy", "Wh", "L2", std::uint16_t{0x0104} - _baseAddressTotal, from_kilo, add}, + {"ActiveEnergy", "Wh", "L3", std::uint16_t{0x0106} - _baseAddressTotal, from_kilo, add}, + {"ForwardActiveEnergy", "Wh", "Total", std::uint16_t{0x0108} - _baseAddressTotal, from_kilo, add}, + {"ForwardActiveEnergy", "Wh", "L1", std::uint16_t{0x010A} - _baseAddressTotal, from_kilo, add}, + {"ForwardActiveEnergy", "Wh", "L2", std::uint16_t{0x010C} - _baseAddressTotal, from_kilo, add}, + {"ForwardActiveEnergy", "Wh", "L3", std::uint16_t{0x010E} - _baseAddressTotal, from_kilo, add}, + {"ReverseActiveEnergy", "Wh", "Total", std::uint16_t{0x0110} - _baseAddressTotal, from_kilo, add}, + {"ReverseActiveEnergy", "Wh", "L1", std::uint16_t{0x0112} - _baseAddressTotal, from_kilo, add}, + {"ReverseActiveEnergy", "Wh", "L2", std::uint16_t{0x0114} - _baseAddressTotal, from_kilo, add}, + {"ReverseActiveEnergy", "Wh", "L3", std::uint16_t{0x0116} - _baseAddressTotal, from_kilo, add}, + {"ReactiveEnergy", "Varh", "Total", std::uint16_t{0x0118} - _baseAddressTotal, from_kilo, add}, + {"ReactiveEnergy", "Varh", "L1", std::uint16_t{0x011A} - _baseAddressTotal, from_kilo, add}, + {"ReactiveEnergy", "Varh", "L2", std::uint16_t{0x011C} - _baseAddressTotal, from_kilo, add}, + {"ReactiveEnergy", "Varh", "L3", std::uint16_t{0x011E} - _baseAddressTotal, from_kilo, add}, + {"ForwardReactiveEnergy", "Varh", "Total", std::uint16_t{0x0120} - _baseAddressTotal, from_kilo, add}, + {"ForwardReactiveEnergy", "Varh", "L1", std::uint16_t{0x0122} - _baseAddressTotal, from_kilo, add}, + {"ForwardReactiveEnergy", "Varh", "L2", std::uint16_t{0x0124} - _baseAddressTotal, from_kilo, add}, + {"ForwardReactiveEnergy", "Varh", "L3", std::uint16_t{0x0126} - _baseAddressTotal, from_kilo, add}, + {"ReverseReactiveEnergy", "Varh", "Total", std::uint16_t{0x0128} - _baseAddressTotal, from_kilo, add}, + {"ReverseReactiveEnergy", "Varh", "L1", std::uint16_t{0x012A} - _baseAddressTotal, from_kilo, add}, + {"ReverseReactiveEnergy", "Varh", "L2", std::uint16_t{0x012C} - _baseAddressTotal, from_kilo, add}, + {"ReverseReactiveEnergy", "Varh", "L3", std::uint16_t{0x012E} - _baseAddressTotal, from_kilo, add}, + }; + + public: + EnergymeterTotalReading(boost::asio::any_io_executor ex, ModbusDevice & modbus) + : EnergymeterReadings(ex, + modbus, + std::span< const Register >(totalRegisters_, std::size(totalRegisters_)), + "total", + _baseAddressTotal) {} +}; + +} // namespace ranczo diff --git a/services/energymeter_svc/ORNO_517.hpp b/services/energymeter_svc/ORNO_517.hpp index e69de29..3f59c93 100644 --- a/services/energymeter_svc/ORNO_517.hpp +++ b/services/energymeter_svc/ORNO_517.hpp @@ -0,0 +1,2 @@ +#pragma once + diff --git a/services/energymeter_svc/main.cpp b/services/energymeter_svc/main.cpp index a532548..ceba355 100644 --- a/services/energymeter_svc/main.cpp +++ b/services/energymeter_svc/main.cpp @@ -1,413 +1,40 @@ -#include -#include -#include -#include -#include -#include -#include - #include #include + #include -#include +#include +#include #include #include -// ─────────────────────────────────────────────────────────────────────────── -// Pomocnicze: timestamp ISO -// ─────────────────────────────────────────────────────────────────────────── +#include -inline std::string to_iso_timestamp() -{ - using clock = std::chrono::system_clock; - auto now = clock::now(); - std::time_t t = clock::to_time_t(now); - - std::tm tm{}; -#if defined(_WIN32) - gmtime_s(&tm, &t); -#else - gmtime_r(&t, &tm); -#endif - - char buf[32]; - std::strftime(buf, sizeof(buf), "%FT%TZ", &tm); - return std::string(buf); +namespace ranczo { + +} // namespace ranczo + +int main() { + boost::asio::io_context io; + + boost::asio::co_spawn( + io, + [&]() -> ranczo::awaitable< void > { + + ranczo::HttpGetClient cli{io.get_executor()}; + auto r = co_await cli.async_get("http://192.168.20.11:80/state", std::pmr::get_default_resource()); + + if(!r) { + spdlog::error("HTTP GET failed: {}", r.error().message()); + } else { + std::cout << *r; + // spdlog::info("Response: {}", *r); + } + + co_return; + }, + boost::asio::detached); + + io.run(); } - -// ─────────────────────────────────────────────────────────────────────────── -// Rejestry energomierza -// ─────────────────────────────────────────────────────────────────────────── - -struct Register { - const char* measurement_name; - const char* unit; - const char* register_name; - std::uint16_t offset; // offset w słowach 16-bit od _baseAddress - - float (*read)(const std::uint16_t*); - float (*total)(float lhs, float rhs); - - float do_read(const std::uint16_t* data) const { - return read(data + offset); - } -}; - -inline float read_float(const std::uint16_t* data) { - return modbus_get_float_abcd(data); -} - -inline float from_kilo(const std::uint16_t* data) { - return read_float(data) * 1000.0f; -} - -inline float add(float lhs, float rhs) { - return lhs + rhs; -} - -inline float avg(float lhs, float rhs) { - return (lhs + rhs) * 0.5f; -} - -// ─────────────────────────────────────────────────────────────────────────── -// Formatowanie topiców / payloadów MQTT -// ─────────────────────────────────────────────────────────────────────────── - -inline std::size_t topic_to( - char* buffer, - const Register& reg, - std::string_view type, - std::string_view event) -{ - const auto end = fmt::format_to( - buffer, - "home/utilities/electricity/{}/{}/{}/{}", - type, - event, - reg.measurement_name, - reg.register_name); - - *end = '\0'; - auto len = static_cast(std::distance(buffer, end) + 1); - spdlog::debug("MQTT topic: {}", buffer); - return len; -} - -inline std::size_t payload_to( - std::uint8_t* buffer, - const Register& reg, - float value, - bool update, - std::string_view ts) -{ - const auto end = fmt::format_to( - buffer, - R"json({{"value":{},"unit":"{}","source":"energymeter_service","update":{},"timestamp":"{}"}})json", - value, - reg.unit, - update ? "true" : "false", - ts); - - auto len = static_cast(std::distance(buffer, end)); - return len; -} - -// ─────────────────────────────────────────────────────────────────────────── -// Bazowa klasa odczytu energomierza (async, z boost::asio) -// ─────────────────────────────────────────────────────────────────────────── - -class EnergymeterReadings { - public: - EnergymeterReadings( - boost::asio::any_io_executor ex, - MqttClient& mqtt, - ModbusClient& modbus, - std::span regs, - const char* name, - std::uint16_t baseAddress) - : _ex(ex) - , _mqtt(mqtt) - , _modbus(modbus) - , _registers(regs) - , _name(name) - , _baseAddress(baseAddress) - , _housingUsage(regs.size(), 0.0f) - , _heatingUsage(regs.size(), 0.0f) - {} - - // slave 1: "housing", slave 2: "heating" - awaitable_expected publish() - { - using namespace std::chrono_literals; - - const std::size_t LiveRegistersSize = _registers.size() * 2; - - std::vector housingRaw(LiveRegistersSize); - std::vector heatingRaw(LiveRegistersSize); - - const auto housingTimepoint = to_iso_timestamp(); - auto r1 = co_await _modbus.read_holding_registers( - 1, _baseAddress, static_cast(LiveRegistersSize), housingRaw.data()); - if (!r1) { - spdlog::error("Modbus read (housing) failed: {}", r1.error().message()); - co_return unexpected(r1.error()); - } - - const auto heatingTimepoint = to_iso_timestamp(); - auto r2 = co_await _modbus.read_holding_registers( - 2, _baseAddress, static_cast(LiveRegistersSize), heatingRaw.data()); - if (!r2) { - spdlog::error("Modbus read (heating) failed: {}", r2.error().message()); - co_return unexpected(r2.error()); - } - - const auto totalTimepoint = to_iso_timestamp(); - - auto housingPrev = _housingUsage.begin(); - auto heatingPrev = _heatingUsage.begin(); - - std::uint8_t payloadBuffer[256]; - char topicBuffer[256]; - - for (const auto& reg : _registers) { - const float housingValue = reg.do_read(housingRaw.data()); - const float heatingValue = reg.do_read(heatingRaw.data()); - const float totalValue = reg.total(heatingValue, housingValue); - - const bool housingUpdated = housingValue != *housingPrev; - const bool heatingUpdated = heatingValue != *heatingPrev; - const bool totalUpdated = housingUpdated || heatingUpdated; - - *housingPrev = housingValue; - *heatingPrev = heatingValue; - ++housingPrev; - ++heatingPrev; - - auto doPublish = [&](float value, - bool updated, - std::string_view ts, - const char* type) -> awaitable_expected - { - const std::size_t topicLen = topic_to(topicBuffer, reg, _name, type); - const std::size_t payloadLen = payload_to( - payloadBuffer, reg, value, updated, ts); - - std::string_view topic(topicBuffer, topicLen - 1); // bez '\0' - std::string_view payload( - reinterpret_cast(payloadBuffer), - payloadLen); - - auto res = co_await _mqtt.publish(topic, payload, 0); - if (!res) { - spdlog::warn("MQTT publish failed on topic {}: {}", - topic, res.error().message()); - co_return unexpected(res.error()); - } - - co_return expected{}; - }; - - // housing - (void) co_await doPublish(housingValue, housingUpdated, housingTimepoint, "housing"); - // heating - (void) co_await doPublish(heatingValue, heatingUpdated, heatingTimepoint, "heating"); - // total - (void) co_await doPublish(totalValue, totalUpdated, totalTimepoint, "ALL"); - } - - co_return expected{}; - } - - protected: - boost::asio::any_io_executor _ex; - MqttClient& _mqtt; - ModbusClient& _modbus; - - std::span _registers; - const char* _name; - const std::uint16_t _baseAddress; - - std::vector _housingUsage; - std::vector _heatingUsage; -}; - -// ─────────────────────────────────────────────────────────────────────────── -// Konkrety: odczyty "live" -// ─────────────────────────────────────────────────────────────────────────── - -class EnergymeterLiveReading : public EnergymeterReadings { - private: - static constexpr std::uint16_t _baseAddressLive = 0x000E; - - static constexpr Register liveRegisters_[] = { - {"Voltage", "V", "L1", std::uint16_t{0x000E} - _baseAddressLive, read_float, avg}, - {"Voltage", "V", "L2", std::uint16_t{0x0010} - _baseAddressLive, read_float, avg}, - {"Voltage", "V", "L3", std::uint16_t{0x0012} - _baseAddressLive, read_float, avg}, - {"Frequency", "Hz", "Grid", std::uint16_t{0x0014} - _baseAddressLive, read_float, avg}, - {"Current", "A", "L1", std::uint16_t{0x0016} - _baseAddressLive, read_float, add}, - {"Current", "A", "L2", std::uint16_t{0x0018} - _baseAddressLive, read_float, add}, - {"Current", "A", "L3", std::uint16_t{0x001A} - _baseAddressLive, read_float, add}, - {"ActivePower", "W", "Total", std::uint16_t{0x001C} - _baseAddressLive, from_kilo, add}, - {"ActivePower", "W", "L1", std::uint16_t{0x001E} - _baseAddressLive, from_kilo, add}, - {"ActivePower", "W", "L2", std::uint16_t{0x0020} - _baseAddressLive, from_kilo, add}, - {"ActivePower", "W", "L3", std::uint16_t{0x0022} - _baseAddressLive, from_kilo, add}, - {"ReactivePower", "Var", "Total", std::uint16_t{0x0024} - _baseAddressLive, from_kilo, add}, - {"ReactivePower", "Var", "L1", std::uint16_t{0x0026} - _baseAddressLive, from_kilo, add}, - {"ReactivePower", "Var", "L2", std::uint16_t{0x0028} - _baseAddressLive, from_kilo, add}, - {"ReactivePower", "Var", "L3", std::uint16_t{0x002A} - _baseAddressLive, from_kilo, add}, - {"ApparentPower", "VA", "Total", std::uint16_t{0x002C} - _baseAddressLive, from_kilo, add}, - {"ApparentPower", "VA", "L1", std::uint16_t{0x002E} - _baseAddressLive, from_kilo, add}, - {"ApparentPower", "VA", "L2", std::uint16_t{0x0030} - _baseAddressLive, from_kilo, add}, - {"ApparentPower", "VA", "L3", std::uint16_t{0x0032} - _baseAddressLive, from_kilo, add}, - {"PowerFactor", "", "Total", std::uint16_t{0x0034} - _baseAddressLive, read_float, avg}, - {"PowerFactor", "", "L1", std::uint16_t{0x0036} - _baseAddressLive, read_float, avg}, - {"PowerFactor", "", "L2", std::uint16_t{0x0038} - _baseAddressLive, read_float, avg}, - {"PowerFactor", "", "L3", std::uint16_t{0x003A} - _baseAddressLive, read_float, avg}, - }; - - public: - EnergymeterLiveReading( - boost::asio::any_io_executor ex, - MqttClient& mqtt, - ModbusClient& modbus) - : EnergymeterReadings( - ex, - mqtt, - modbus, - std::span(liveRegisters_, std::size(liveRegisters_)), - "current", - _baseAddressLive) - {} -}; - -// ─────────────────────────────────────────────────────────────────────────── -// Konkrety: odczyty "total" -// ─────────────────────────────────────────────────────────────────────────── - -class EnergymeterTotalReading : public EnergymeterReadings { - private: - static constexpr std::uint16_t _baseAddressTotal = 0x0100; - - static constexpr Register totalRegisters_[] = { - {"ActiveEnergy", "Wh", "Total", std::uint16_t{0x0100} - _baseAddressTotal, from_kilo, add}, - {"ActiveEnergy", "Wh", "L1", std::uint16_t{0x0102} - _baseAddressTotal, from_kilo, add}, - {"ActiveEnergy", "Wh", "L2", std::uint16_t{0x0104} - _baseAddressTotal, from_kilo, add}, - {"ActiveEnergy", "Wh", "L3", std::uint16_t{0x0106} - _baseAddressTotal, from_kilo, add}, - {"ForwardActiveEnergy", "Wh", "Total", std::uint16_t{0x0108} - _baseAddressTotal, from_kilo, add}, - {"ForwardActiveEnergy", "Wh", "L1", std::uint16_t{0x010A} - _baseAddressTotal, from_kilo, add}, - {"ForwardActiveEnergy", "Wh", "L2", std::uint16_t{0x010C} - _baseAddressTotal, from_kilo, add}, - {"ForwardActiveEnergy", "Wh", "L3", std::uint16_t{0x010E} - _baseAddressTotal, from_kilo, add}, - {"ReverseActiveEnergy", "Wh", "Total", std::uint16_t{0x0110} - _baseAddressTotal, from_kilo, add}, - {"ReverseActiveEnergy", "Wh", "L1", std::uint16_t{0x0112} - _baseAddressTotal, from_kilo, add}, - {"ReverseActiveEnergy", "Wh", "L2", std::uint16_t{0x0114} - _baseAddressTotal, from_kilo, add}, - {"ReverseActiveEnergy", "Wh", "L3", std::uint16_t{0x0116} - _baseAddressTotal, from_kilo, add}, - {"ReactiveEnergy", "Varh", "Total", std::uint16_t{0x0118} - _baseAddressTotal, from_kilo, add}, - {"ReactiveEnergy", "Varh", "L1", std::uint16_t{0x011A} - _baseAddressTotal, from_kilo, add}, - {"ReactiveEnergy", "Varh", "L2", std::uint16_t{0x011C} - _baseAddressTotal, from_kilo, add}, - {"ReactiveEnergy", "Varh", "L3", std::uint16_t{0x011E} - _baseAddressTotal, from_kilo, add}, - {"ForwardReactiveEnergy", "Varh", "Total", std::uint16_t{0x0120} - _baseAddressTotal, from_kilo, add}, - {"ForwardReactiveEnergy", "Varh", "L1", std::uint16_t{0x0122} - _baseAddressTotal, from_kilo, add}, - {"ForwardReactiveEnergy", "Varh", "L2", std::uint16_t{0x0124} - _baseAddressTotal, from_kilo, add}, - {"ForwardReactiveEnergy", "Varh", "L3", std::uint16_t{0x0126} - _baseAddressTotal, from_kilo, add}, - {"ReverseReactiveEnergy", "Varh", "Total", std::uint16_t{0x0128} - _baseAddressTotal, from_kilo, add}, - {"ReverseReactiveEnergy", "Varh", "L1", std::uint16_t{0x012A} - _baseAddressTotal, from_kilo, add}, - {"ReverseReactiveEnergy", "Varh", "L2", std::uint16_t{0x012C} - _baseAddressTotal, from_kilo, add}, - {"ReverseReactiveEnergy", "Varh", "L3", std::uint16_t{0x012E} - _baseAddressTotal, from_kilo, add}, - }; - - public: - EnergymeterTotalReading( - boost::asio::any_io_executor ex, - MqttClient& mqtt, - ModbusClient& modbus) - : EnergymeterReadings( - ex, - mqtt, - modbus, - std::span(totalRegisters_, std::size(totalRegisters_)), - "total", - _baseAddressTotal) - {} -}; - -// ─────────────────────────────────────────────────────────────────────────── -// Serwis: dwa taski asynchroniczne (live + total) na timerach -// ─────────────────────────────────────────────────────────────────────────── - -class EnergymeterService { - public: - EnergymeterService( - boost::asio::any_io_executor ex, - MqttClient& mqtt, - ModbusClient& modbus) - : _ex(ex) - , _live(ex, mqtt, modbus) - , _total(ex, mqtt, modbus) - {} - - // uruchamia oba taski; wołaj np. przez co_spawn(service.run(), detached) - awaitable run() - { - using boost::asio::co_spawn; - using boost::asio::detached; - - co_spawn(_ex, - [this]() -> awaitable { - co_await live_loop(); - }, - detached); - - co_spawn(_ex, - [this]() -> awaitable { - co_await total_loop(); - }, - detached); - - co_return; - } - - private: - awaitable live_loop() - { - using namespace std::chrono_literals; - boost::asio::steady_timer timer(_ex); - - while (true) { - timer.expires_after(3s); - 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; - - auto res = co_await _live.publish(); - if (!res) { - spdlog::warn("Live reading publish error: {}", res.error().message()); - } - } - } - - awaitable total_loop() - { - using namespace std::chrono_literals; - boost::asio::steady_timer timer(_ex); - - while (true) { - timer.expires_after(60s); - 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; - - auto res = co_await _total.publish(); - if (!res) { - spdlog::warn("Total reading publish error: {}", res.error().message()); - } - } - } - - boost::asio::any_io_executor _ex; - EnergymeterLiveReading _live; - EnergymeterTotalReading _total; -}; diff --git a/services/floorheat_svc/CMakeLists.txt b/services/floorheat_svc/CMakeLists.txt index 56e677d..af5bee8 100644 --- a/services/floorheat_svc/CMakeLists.txt +++ b/services/floorheat_svc/CMakeLists.txt @@ -1,8 +1,9 @@ add_executable(ranczo-io_floorheating main.cpp - temperature_controller.hpp temperature_controller.cpp - relay.hpp relay.cpp - thermometer.hpp thermometer.cpp + temperature_controller.hpp temperature_controller.cpp + temperature_measurements.hpp temperature_measurements.cpp + relay.hpp relay.cpp + thermometer.hpp thermometer.cpp ranczo-io_floorheating.service.in postinst diff --git a/services/floorheat_svc/main.cpp b/services/floorheat_svc/main.cpp index e94a053..f144fc9 100644 --- a/services/floorheat_svc/main.cpp +++ b/services/floorheat_svc/main.cpp @@ -40,6 +40,20 @@ namespace ranczo { /// * Zapis danych w DB /// * Zapis ustawień /// * Nasłuchiwanie na MQTT +/// static constexpr std::array< std::tuple< int, quantity< Resistance >, Line >, 8 > _idToResistance{ +// std::tuple{4, 38.9915503757583 * boost::units::si::ohm, Line::L1}, +// std::tuple{5, 116.973061767177 * boost::units::si::ohm, Line::L1}, +// std::tuple{25, 38.1843638931974 * boost::units::si::ohm, Line::L2}, // 2/9 playroom +// std::tuple{26, 49.9384951729712 * boost::units::si::ohm, Line::L3}, // 2/10 aska +// std::tuple{27, 50.8739911417796 * boost::units::si::ohm, Line::L3}, // 2/11 maciej +// std::tuple{28, 76.6462545974082 * boost::units::si::ohm, Line::L2}, // 2/12 office +// std::tuple{29, 94.2874894960184 * boost::units::si::ohm, Line::L1}, // 2/13 bathroom 3 +// std::tuple{49, 38.9915503757583 * boost::units::si::ohm, Line::L1}};// 16/1 utility +// 16/11 bathroom_1 + +// TODO subscribe to home/utilities/power/electricity/main/active/[L1|L2|L3] and listen to energy usage, disable some mats when energy usage is too high +// TODO subscribe to home/utilities/powerline/electricity/main/voltage/L1 and listen to energy usage, disable some mats when energy usage is too high +// TODO Procedure to check the } // namespace ranczo @@ -272,4 +286,3 @@ int main() { return 0; } - diff --git a/services/floorheat_svc/temperature_controller.cpp b/services/floorheat_svc/temperature_controller.cpp index 5c7e5ff..8f4353c 100644 --- a/services/floorheat_svc/temperature_controller.cpp +++ b/services/floorheat_svc/temperature_controller.cpp @@ -10,7 +10,6 @@ #include #include #include -#include #include #include #include @@ -32,6 +31,9 @@ #include #include #include +#include + +#include #include #include @@ -41,10 +43,6 @@ namespace ranczo { -template -std::optional from_string(std::optional< std::string_view > state, - std::pmr::memory_resource * mr = std::pmr::get_default_resource()); - enum class ThermostatState { Enabled, Disabled, Error }; std::string to_string(ThermostatState state) { switch(state) { @@ -57,17 +55,16 @@ std::string to_string(ThermostatState state) { } } -template<> -std::optional< ThermostatState > from_string(std::optional< std::string_view > state, - std::pmr::memory_resource * mr) { +template <> +std::optional< ThermostatState > from_string(std::optional< std::string_view > state, std::pmr::memory_resource * mr) { BOOST_ASSERT(mr); - + if(not state) { return std::nullopt; } std::pmr::string s(state->begin(), state->end(), mr); std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return static_cast< char >(std::tolower(c)); }); - + if(s == "enabled") return ThermostatState::Enabled; if(s == "disabled") @@ -77,38 +74,6 @@ std::optional< ThermostatState > from_string(std::optional< std::string_view > s return std::nullopt; } -enum class Trend { Fall, Const, Rise }; - -std::pmr::string to_string(Trend state) { - switch(state) { - case Trend::Fall: - return "Fall"; - case Trend::Const: - return "Const"; - default: - return "Rise"; - } -} -template < > -std::optional< Trend > from_string(std::optional< std::string_view > state, - std::pmr::memory_resource * mr) { - BOOST_ASSERT(mr); - - if(not state) { - return std::nullopt; - } - std::pmr::string s(state->begin(), state->end(), mr); - std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return static_cast< char >(std::tolower(c)); }); - - if(s == "fall") - return Trend::Fall; - if(s == "const") - return Trend::Const; - if(s == "rise") - return Trend::Rise; - return std::nullopt; -} - template < typename T > inline expected< T > readValue(const boost::json::value & jv, std::string_view key) { if(auto * obj = jv.if_object()) { @@ -121,7 +86,7 @@ inline expected< T > readValue(const boost::json::value & jv, std::string_view k if(!pv->is_string()) { return unexpected{make_error_code(boost::system::errc::invalid_argument)}; } - auto v = from_string(std::make_optional< std::string >(pv->as_string())); + auto v = from_string< T >(std::make_optional< std::string >(pv->as_string())); if(not v) return unexpected{make_error_code(boost::system::errc::invalid_argument)}; return *v; @@ -247,173 +212,6 @@ namespace commands { }; } // 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; - ModuleLogger _log; - std::unique_ptr< Thermometer > _sensor; - boost::circular_buffer< Measurement > _history; - - ThermometerMeasurements(executor & io, std::unique_ptr< Thermometer > sensor) - : _io{io}, _log{spdlog::default_logger(), "ThermometerMeasurements"}, _sensor{std::move(sensor)}, _history{200} {} - - /** - * @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); }); - } - - awaitable_expected< void > temperatureUpdateCallback(Thermometer::ThermometerData data) { - // circular buffer, no need to clean - _log.debug("Got new sample: {} total: {}", data.temp_c(), _history.size()); - /// 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{}; - } - - awaitable_expected< void > start() { - BOOST_ASSERT(_sensor); - - // subscribe to a thermometer readings - ASYNC_CHECK_LOG(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{}; - } - - /** - * @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}) { - memory_resource::MonotonicStackResource< 64 > mr; - auto now_ts = date::to_iso_timestamp(std::chrono::system_clock::now(), &mr); - auto meas_ts = date::to_iso_timestamp(_history.back().when, &mr); - _log.warn("measurements are from the future (measurement: {}, now: {})", meas_ts, now_ts); - } - - 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(); - } - - std::size_t size() const noexcept { - return _history.size(); - } - /** - * @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::nanoseconds window = std::chrono::minutes(5), - double epsilon_deg_per_min = 0.2) const { - if(auto last = timeSinceLastRead(); not last.has_value()) { - _log.debug("No temperature samples available"); - return std::nullopt; - } - - if(_history.size() < 2) { - _log.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 - window; - - struct Point { - double t_s; - double temp; - std::chrono::system_clock::time_point when; - }; - std::vector< Point > pts; - pts.reserve(_history.size()); - - 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(); - pts.push_back({t_s, y, m.when}); - } - - if(pts.size() < 2) { - _log.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; - } - - // 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{}; - double sum_y{}; - double sum_tt{}; - double sum_ty{}; - for(const auto & p : pts) { - sum_t += p.t_s; - sum_y += p.temp; - sum_tt += p.t_s * p.t_s; - sum_ty += p.t_s * p.temp; - } - - const double n = static_cast< double >(pts.size()); - const double denom = (n * sum_tt - sum_t * sum_t); - - double slope_deg_per_s = 0.0; - if(denom != 0.0) { - slope_deg_per_s = (n * sum_ty - sum_t * sum_y) / denom; // b - } else { - // fallback: pochylona między pierwszą i ostatnią próbką - const double dt_s = std::chrono::duration< double >(pts.back().when - pts.front().when).count(); - if(dt_s <= 0.0) { - _log.warn("No time spread in samples (dt = {}).", dt_s); - return Trend::Const; - } - slope_deg_per_s = (pts.back().temp - pts.front().temp) / dt_s; - } - - const double slope_deg_per_min = slope_deg_per_s * 60.0; - - _log.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) - return Trend::Rise; - if(slope_deg_per_min < -epsilon_deg_per_min) - return Trend::Fall; - return Trend::Const; - } -}; - enum RuntimeError { NoTemperatureMeasurements = 1, IoError, @@ -509,6 +307,9 @@ struct RelayThermostat::Impl : private boost::noncopyable { double _slopeDT_c{0.2}; // [°C / min] std::chrono::nanoseconds _sensorTimeout{std::chrono::minutes(5)}; // max time to wait for temperature + /// TODO dodanie resistance + /// + // additional variables Timer _statusTimer; // sends status of current endpoint std::chrono::system_clock::time_point _startTP; // used only to prevent warnings at start of procedure @@ -799,15 +600,15 @@ struct RelayThermostat::Impl : private boost::noncopyable { awaitable_expected< void > handleErrorState() { /// check preconditions, if ok release error state - + auto preconditionsMet = ASYNC_TRY(preconditions()); - + if(!preconditionsMet) { // dalej coś jest nie tak -> upewnij się, że grzanie jest OFF ASYNC_CHECK(safe_off()); co_return _void{}; } - + if(_panic) { if(_panic_severity == PanicSeverity::Danger) { _log.warn("ErrorState: preconditions met but PANIC(Danger) active – staying in ERROR"); @@ -823,7 +624,7 @@ struct RelayThermostat::Impl : private boost::noncopyable { } else { _log.info("ErrorState: preconditions met – switching back to Enabled"); } - + _state = ThermostatState::Enabled; co_return _void{}; } @@ -839,7 +640,7 @@ struct RelayThermostat::Impl : private boost::noncopyable { awaitable_expected< void > handleEnabledState() { using namespace std::chrono; - + if(_panic) { _log.error("handleEnabledState called while PANIC is active – forcing ERROR"); _state = ThermostatState::Error; @@ -997,8 +798,7 @@ struct RelayThermostat::Impl : private boost::noncopyable { auto toSec = [](auto t) { return seconds{t}.count(); }; - _state = from_string( - co_await _settings.async_get_store_default("state", to_string(ThermostatState::Disabled))) + _state = from_string< ThermostatState >(co_await _settings.async_get_store_default("state", to_string(ThermostatState::Disabled))) .value_or(ThermostatState::Disabled); _targetTemperature = co_await _settings.async_get_store_default("target_temperature", 20.0); @@ -1007,6 +807,7 @@ struct RelayThermostat::Impl : private boost::noncopyable { _slopeWindow = seconds{co_await _settings.async_get_store_default("slope_window_s", toSec(minutes{1}))}; _slopeDT_c = co_await _settings.async_get_store_default("slope_delta_t", 1); // [°C / min] _sensorTimeout = seconds{co_await _settings.async_get_store_default("sensor_timeout_s", toSec(minutes{5}))}; + /// TODO sprawdzić czy inne parametry również trzeba/można odczytać // subscribe to a thermostat commands feed _log.info("Start: subscribe to mqtt"); @@ -1051,14 +852,14 @@ struct RelayThermostat::Impl : private boost::noncopyable { // broadcast: zone 0 const auto broadcast_topic = topic::heating::subscribeToControl(_room, 0, Command::topic_suffix); // konkretna strefa: - const auto zone_topic = topic::heating::subscribeToControl(_room, _zone, Command::topic_suffix) ; + const auto zone_topic = topic::heating::subscribeToControl(_room, _zone, Command::topic_suffix); auto cb = [this](const AsyncMqttClient::CallbackData & data, AsyncMqttClient::ResponseData & resp) -> awaitable_expected< void > { auto _result = Command::from_payload(data.request); if(!_result) - co_return unexpected{_result.error()}; // + co_return unexpected{_result.error()}; // - auto cmd = *_result; + auto cmd = *_result; auto expected = co_await handle_command(cmd); if(expected) { if(resp.has_value()) { @@ -1162,9 +963,9 @@ struct RelayThermostat::Impl : private boost::noncopyable { _panic_severity = PanicSeverity::Safe; _panic_since = std::nullopt; _last_error_reason = ""; - + _log.info("Going to safe state after clear panic"); - _state = ThermostatState::Disabled; + _state = ThermostatState::Disabled; co_return true; } @@ -1195,7 +996,7 @@ struct RelayThermostat::Impl : private boost::noncopyable { auto trend = _thermo.temperatureTrend(_slopeWindow, _slopeDT_c); if(trend) { - obj["current_trend"] = to_string(*trend); + obj["temperature_trend"] = to_string(*trend); } obj["measurements_size"] = _thermo.size(); obj["hysteresis"] = _hysteresis; diff --git a/services/floorheat_svc/temperature_measurements.cpp b/services/floorheat_svc/temperature_measurements.cpp new file mode 100644 index 0000000..73c3a68 --- /dev/null +++ b/services/floorheat_svc/temperature_measurements.cpp @@ -0,0 +1,133 @@ +#include "temperature_measurements.hpp" +#include "ranczo-io/utils/date_utils.hpp" +#include +#include + +namespace ranczo { + +ThermometerMeasurements::ThermometerMeasurements(executor & io, std::unique_ptr< Thermometer > sensor) + : _io{io}, _log{spdlog::default_logger(), "ThermometerMeasurements"}, _sensor{std::move(sensor)}, _history{200} {} + +awaitable_expected< void > ThermometerMeasurements::temperatureUpdateCallback(Thermometer::ThermometerData data) { + // circular buffer, no need to clean + _log.debug("Got new sample: {} total: {}", data.temp_c(), _history.size()); + /// 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{}; +} + +awaitable_expected< void > ThermometerMeasurements::start() { + BOOST_ASSERT(_sensor); + + // subscribe to a thermometer readings + ASYNC_CHECK_LOG(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{}; +} + +std::optional< Trend > ThermometerMeasurements::temperatureTrend(std::chrono::nanoseconds window, double epsilon_deg_per_min) const { + if(auto last = timeSinceLastRead(); not last.has_value()) { + _log.debug("No temperature samples available"); + return std::nullopt; + } + + if(_history.size() < 2) { + _log.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 - window; + + struct Point { + double t_s; + double temp; + std::chrono::system_clock::time_point when; + }; + std::vector< Point > pts; // pmr vector + pts.reserve(_history.size()); + + 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(); + pts.push_back({t_s, y, m.when}); + } + + if(pts.size() < 2) { + _log.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; + } + + // 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{}; + double sum_y{}; + double sum_tt{}; + double sum_ty{}; + for(const auto & p : pts) { + sum_t += p.t_s; + sum_y += p.temp; + sum_tt += p.t_s * p.t_s; + sum_ty += p.t_s * p.temp; + } + + const double n = static_cast< double >(pts.size()); + const double denom = (n * sum_tt - sum_t * sum_t); + + double slope_deg_per_s = 0.0; + if(denom != 0.0) { + slope_deg_per_s = (n * sum_ty - sum_t * sum_y) / denom; // b + } else { + // fallback: pochylona między pierwszą i ostatnią próbką + const double dt_s = std::chrono::duration< double >(pts.back().when - pts.front().when).count(); + if(dt_s <= 0.0) { + _log.warn("No time spread in samples (dt = {}).", dt_s); + return Trend::Const; + } + slope_deg_per_s = (pts.back().temp - pts.front().temp) / dt_s; + } + + const double slope_deg_per_min = slope_deg_per_s * 60.0; + + _log.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) + return Trend::Rise; + if(slope_deg_per_min < -epsilon_deg_per_min) + return Trend::Fall; + return Trend::Const; +} + +std::optional< double > ThermometerMeasurements::currentTemperature() const { + if(auto last = timeSinceLastRead(); not last.has_value()) { + return std::nullopt; + } + return _history.back().data.temp_c(); +} + +std::optional< std::chrono::nanoseconds > ThermometerMeasurements::timeSinceLastRead() const { + if(_history.size() == 0) + return std::nullopt; + + auto timeDiff = std::chrono::system_clock::now() - _history.back().when; + + if(timeDiff < std::chrono::nanoseconds{0}) { + memory_resource::MonotonicStackResource< 64 > mr; + auto now_ts = date::to_iso_timestamp(std::chrono::system_clock::now(), &mr); + auto meas_ts = date::to_iso_timestamp(_history.back().when, &mr); + _log.warn("measurements are from the future (measurement: {}, now: {})", meas_ts, now_ts); + } + + return timeDiff; +} + +} // namespace ranczo diff --git a/services/floorheat_svc/temperature_measurements.hpp b/services/floorheat_svc/temperature_measurements.hpp new file mode 100644 index 0000000..ec0c0e6 --- /dev/null +++ b/services/floorheat_svc/temperature_measurements.hpp @@ -0,0 +1,106 @@ +#pragma once + +#include "config.hpp" +#include "thermometer.hpp" + +#include + +#include + +namespace ranczo { +class Thermometer; + +enum class Trend { Fall, Const, Rise }; + +inline std::pmr::string to_string(Trend state) { + switch(state) { + case Trend::Fall: + return "Fall"; + case Trend::Const: + return "Const"; + default: + return "Rise"; + } +} +template <> +inline std::optional< Trend > from_string(std::optional< std::string_view > state, std::pmr::memory_resource * mr) { + BOOST_ASSERT(mr); + + if(not state) { + return std::nullopt; + } + std::pmr::string s(state->begin(), state->end(), mr); + std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return static_cast< char >(std::tolower(c)); }); + + if(s == "fall") + return Trend::Fall; + if(s == "const") + return Trend::Const; + if(s == "rise") + return Trend::Rise; + return std::nullopt; +} + +/** + * @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; + ModuleLogger _log; + std::unique_ptr< Thermometer > _sensor; + boost::circular_buffer< Measurement > _history; + + ThermometerMeasurements(executor & io, std::unique_ptr< Thermometer > sensor); + + /** + * @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); }); + } + + /** + * @brief temperatureUpdateCallback + * @param data + * @return + */ + awaitable_expected< void > temperatureUpdateCallback(Thermometer::ThermometerData data); + + /** + * @brief start + * @return + */ + awaitable_expected< void > start(); + + /** + * @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; + + /** + * @brief currentTemperature + * @return temperature or nothing if no temp is yet available + */ + std::optional< double > currentTemperature() const; + + std::size_t size() const noexcept { + return _history.size(); + } + /** + * @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::nanoseconds window = std::chrono::minutes(5), + double epsilon_deg_per_min = 0.2) const; +}; +} // namespace ranczo