Add HTTP get

This commit is contained in:
Bartosz Wieczorek 2025-12-12 16:57:05 +01:00
parent dbe89b64ca
commit 6da01a2f6b
17 changed files with 878 additions and 655 deletions

View File

@ -25,12 +25,12 @@ include_directories(${CMAKE_CURRENT_SOURCE_DIR}/include)
include(FetchContent) 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_ENABLE_CMAKE ON)
set(Boost_NO_SYSTEM_PATHS ON) set(Boost_NO_SYSTEM_PATHS ON)
set(BOOST_ROOT /usr/local/boost-1.89) 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 # spdlog
include(CheckCXXSourceCompiles) include(CheckCXXSourceCompiles)

View File

@ -4,6 +4,8 @@
#include <boost/system/errc.hpp> #include <boost/system/errc.hpp>
#include <boost/system/error_code.hpp> #include <boost/system/error_code.hpp>
#include <memory_resource>
#if __has_include(<expected>) #if __has_include(<expected>)
#include <expected> #include <expected>
#elif __has_include(<tl/expected.hpp>) #elif __has_include(<tl/expected.hpp>)
@ -14,6 +16,10 @@
namespace ranczo { namespace ranczo {
template<typename T>
std::optional<T> from_string(std::optional< std::string_view > state,
std::pmr::memory_resource * mr = std::pmr::get_default_resource());
#if __has_include(<expected>) #if __has_include(<expected>)
template < typename T > template < typename T >
using expected = std::expected< T, boost::system::error_code >; using expected = std::expected< T, boost::system::error_code >;

View File

@ -1,6 +1,7 @@
include(modbus.cmake) include(modbus.cmake)
find_package(SQLite3) find_package(SQLite3)
find_package(OpenSSL REQUIRED)
add_library(ranczo-io_utils add_library(ranczo-io_utils
mqtt_client.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ranczo-io/utils/mqtt_client.hpp 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 asio_watchdog.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ranczo-io/utils/asio_watchdog.hpp
modbus.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ranczo-io/utils/modbus.hpp modbus.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ranczo-io/utils/modbus.hpp
config.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ranczo-io/utils/config.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/mqtt_topic_builder.hpp
${CMAKE_CURRENT_SOURCE_DIR}/ranczo-io/utils/memory_resource.hpp ${CMAKE_CURRENT_SOURCE_DIR}/ranczo-io/utils/memory_resource.hpp
@ -30,6 +32,9 @@ target_link_libraries(ranczo-io_utils
fmt::fmt fmt::fmt
modbus modbus
date::date date::date
OpenSSL::SSL #http
OpenSSL::Crypto
Boost::url
PRIVATE PRIVATE
SQLite::SQLite3 SQLite::SQLite3
) )

176
libs/http.cpp Normal file
View File

@ -0,0 +1,176 @@
#include <boost/asio/redirect_error.hpp>
#include <boost/asio/use_awaitable.hpp>
#include <ranczo-io/utils/http.hpp>
#include <boost/asio/ssl.hpp>
#include <boost/beast/core.hpp>
#include <boost/beast/http.hpp>
#include <boost/beast/ssl.hpp>
#include <boost/url.hpp>
#include <string_view>
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

View File

@ -123,8 +123,11 @@ awaitable_expected< T > ModbusTcpContext::call_with_lock(F && op) {
out = ctx_.get(); out = ctx_.get();
return op(out); return op(out);
}); });
} }
template < typename T, typename Maker > template < typename T, typename Maker >
awaitable_expected< T > ModbusTcpContext::async_call(Maker && maker) { awaitable_expected< T > ModbusTcpContext::async_call(Maker && maker) {
namespace asio = boost::asio; 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); _log.error("modbus_set_slave address {} failed with {}", address, errno);
return unexpected(errno_errc()); return unexpected(errno_errc());
} }
uint16_t val = 0; uint16_t val = 0;
int rc = ::modbus_read_registers(c, static_cast< int >(address), 1, &val); int rc = ::modbus_read_registers(c, static_cast< int >(address), 1, &val);
if(rc == -1) { if(rc == -1) {
@ -165,6 +169,29 @@ awaitable_expected< uint16_t > ModbusDevice::async_read_holding_register(uint16_
}); });
} }
awaitable_expected<std::pmr::vector<uint16_t> > 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) { awaitable_expected< void > ModbusDevice::async_write_coil(uint16_t address, bool value) {
auto ctx = ctx_; auto ctx = ctx_;
co_return co_await ctx->call_with_lock< void >([this, address, value](modbus_t * c) -> expected< void > { co_return co_await ctx->call_with_lock< void >([this, address, value](modbus_t * c) -> expected< void > {

View File

@ -0,0 +1,84 @@
#include <chrono>
#include <config.hpp>
#include <cstddef>
#include <memory>
#include <memory_resource>
#include <ranczo-io/utils/logger.hpp>
#include <string>
#include <string_view>
#include <boost/asio/ip/tcp.hpp>
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

View File

@ -12,6 +12,7 @@
#include <chrono> #include <chrono>
#include <cstdint> #include <cstdint>
#include <memory> #include <memory>
#include <memory_resource>
#include <string> #include <string>
// libmodbus (C) // libmodbus (C)
#include <modbus/modbus.h> #include <modbus/modbus.h>
@ -78,10 +79,19 @@ class ModbusDevice {
} }
// FC 0x03: pojedynczy holding register // 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 // 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: private:
static boost::system::error_code errno_errc() { static boost::system::error_code errno_errc() {

View File

@ -7,7 +7,6 @@
#include <string> #include <string>
#include <string_view> #include <string_view>
/** /**
* Topic layout assumptions * Topic layout assumptions
* *
@ -41,7 +40,6 @@
* Jest wyłącznie kanałem, na którym urządzenie publikuje swój stan. * Jest wyłącznie kanałem, na którym urządzenie publikuje swój stan.
*/ */
namespace ranczo { namespace ranczo {
template < typename... Args > template < typename... Args >
constexpr size_t length(const Args &... 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()) { publishState(std::string_view room, int zone = 1, std::pmr::memory_resource * mr = std::pmr::get_default_resource()) {
using namespace std::string_view_literals; using namespace std::string_view_literals;
BOOST_ASSERT(mr); 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 } // namespace heating
@ -144,20 +142,46 @@ namespace topic {
} // namespace temperature } // namespace temperature
namespace utilities { namespace utilities {
// home/utilities/power/<meter>/<kind>/<channel>
/* /*
* <meter>: main, heating, housing group rodzaj wielkości (measurement / power / energy / flow / volume / )
* <kind>: active, reactive, apparent, voltage, current, frequency, pf medium nośnik (electricity / water / gas / )
* <channel>: total, L1, L2, L3 itp. 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
publishPowerReading(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 _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(mr);
BOOST_ASSERT(meter.size()); BOOST_ASSERT(meter.size());
BOOST_ASSERT(kind.size()); BOOST_ASSERT(kind.size());
BOOST_ASSERT(channel == "total" || channel == "L1" || channel == "L2" || channel == "L3"); BOOST_ASSERT(channel == "total" || channel == "L1" || channel == "L2" || channel == "L3");
using namespace std::string_view_literals; 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/<meter>/<kind>/<channel> // home/utilities/energy/<meter>/<kind>/<channel>
@ -166,18 +190,19 @@ namespace topic {
* <kind>: tatol_active_energy * <kind>: tatol_active_energy
* <channel>: total, L1, L2, L3 itp. * <channel>: total, L1, L2, L3 itp.
*/ */
inline std::pmr::string inline std::pmr::string publishEnergyReading(std::string_view meter,
publishEnergyReading(std::string_view meter, std::string_view kind, std::string_view channel, std::pmr::memory_resource * mr = std::pmr::get_default_resource()) { std::string_view measurement,
std::string_view channel,
std::pmr::memory_resource * mr = std::pmr::get_default_resource()) {
BOOST_ASSERT(mr); BOOST_ASSERT(mr);
BOOST_ASSERT(meter.size()); BOOST_ASSERT(meter.size());
BOOST_ASSERT(kind.size()); BOOST_ASSERT(kind.size());
BOOST_ASSERT(channel == "total" || channel == "L1" || channel == "L2" || channel == "L3"); BOOST_ASSERT(channel == "total" || channel == "L1" || channel == "L2" || channel == "L3");
using namespace std::string_view_literals; 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 topic
} // namespace ranczo } // namespace ranczo

View File

@ -7,6 +7,6 @@ include(GNUInstallDirs)
add_subdirectory(temperature_svc) add_subdirectory(temperature_svc)
add_subdirectory(floorheat_svc) add_subdirectory(floorheat_svc)
# add_subdirectory(energymeter_svc) add_subdirectory(energymeter_svc)
add_subdirectory(output_svc) add_subdirectory(output_svc)
add_subdirectory(input_svc) add_subdirectory(input_svc)

View File

@ -0,0 +1,207 @@
#include "ranczo-io/utils/date_utils.hpp"
#include "ranczo-io/utils/modbus.hpp"
#include <boost/asio/any_io_executor.hpp>
#include <chrono>
#include <cstdint>
#include <modbus/modbus.h>
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

View File

@ -0,0 +1,2 @@
#pragma once

View File

@ -1,413 +1,40 @@
#include <array>
#include <chrono>
#include <cstdint>
#include <span>
#include <string>
#include <string_view>
#include <vector>
#include <boost/asio.hpp> #include <boost/asio.hpp>
#include <boost/system/error_code.hpp> #include <boost/system/error_code.hpp>
#include <fmt/core.h> #include <fmt/core.h>
#include <modbus/modbus.h> #include <iostream>
#include <memory_resource>
#include <spdlog/spdlog.h> #include <spdlog/spdlog.h>
#include <config.hpp> #include <config.hpp>
// ─────────────────────────────────────────────────────────────────────────── #include <ranczo-io/utils/http.hpp>
// Pomocnicze: timestamp ISO
// ───────────────────────────────────────────────────────────────────────────
inline std::string to_iso_timestamp() namespace ranczo {
{
using clock = std::chrono::system_clock;
auto now = clock::now();
std::time_t t = clock::to_time_t(now);
std::tm tm{}; } // namespace ranczo
#if defined(_WIN32)
gmtime_s(&tm, &t);
#else
gmtime_r(&t, &tm);
#endif
char buf[32]; int main() {
std::strftime(buf, sizeof(buf), "%FT%TZ", &tm); boost::asio::io_context io;
return std::string(buf);
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;
// 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::size_t>(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::size_t>(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<const Register> 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<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 = to_iso_timestamp();
auto r1 = co_await _modbus.read_holding_registers(
1, _baseAddress, static_cast<std::uint16_t>(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<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 = 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<void>
{
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<const char*>(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<void>{};
};
// 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<void>{};
}
protected:
boost::asio::any_io_executor _ex;
MqttClient& _mqtt;
ModbusClient& _modbus;
std::span<const Register> _registers;
const char* _name;
const std::uint16_t _baseAddress;
std::vector<float> _housingUsage;
std::vector<float> _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<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,
MqttClient& mqtt,
ModbusClient& modbus)
: EnergymeterReadings(
ex,
mqtt,
modbus,
std::span<const Register>(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<void> run()
{
using boost::asio::co_spawn;
using boost::asio::detached;
co_spawn(_ex,
[this]() -> awaitable<void> {
co_await live_loop();
}, },
detached); boost::asio::detached);
co_spawn(_ex, io.run();
[this]() -> awaitable<void> {
co_await total_loop();
},
detached);
co_return;
} }
private:
awaitable<void> 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<void> 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;
};

View File

@ -1,6 +1,7 @@
add_executable(ranczo-io_floorheating add_executable(ranczo-io_floorheating
main.cpp main.cpp
temperature_controller.hpp temperature_controller.cpp temperature_controller.hpp temperature_controller.cpp
temperature_measurements.hpp temperature_measurements.cpp
relay.hpp relay.cpp relay.hpp relay.cpp
thermometer.hpp thermometer.cpp thermometer.hpp thermometer.cpp

View File

@ -40,6 +40,20 @@ namespace ranczo {
/// * Zapis danych w DB /// * Zapis danych w DB
/// * Zapis ustawień /// * Zapis ustawień
/// * Nasłuchiwanie na MQTT /// * 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 } // namespace ranczo
@ -272,4 +286,3 @@ int main() {
return 0; return 0;
} }

View File

@ -10,7 +10,6 @@
#include <boost/asio/experimental/parallel_group.hpp> #include <boost/asio/experimental/parallel_group.hpp>
#include <boost/asio/redirect_error.hpp> #include <boost/asio/redirect_error.hpp>
#include <boost/asio/steady_timer.hpp> #include <boost/asio/steady_timer.hpp>
#include <boost/circular_buffer.hpp>
#include <boost/core/noncopyable.hpp> #include <boost/core/noncopyable.hpp>
#include <boost/json/object.hpp> #include <boost/json/object.hpp>
#include <boost/json/storage_ptr.hpp> #include <boost/json/storage_ptr.hpp>
@ -32,6 +31,9 @@
#include <ranczo-io/utils/memory_resource.hpp> #include <ranczo-io/utils/memory_resource.hpp>
#include <ranczo-io/utils/mqtt_client.hpp> #include <ranczo-io/utils/mqtt_client.hpp>
#include <ranczo-io/utils/mqtt_topic_builder.hpp> #include <ranczo-io/utils/mqtt_topic_builder.hpp>
#include <services/floorheat_svc/temperature_measurements.hpp>
#include <boost/units/systems/si/resistance.hpp>
#include <string> #include <string>
#include <string_view> #include <string_view>
@ -41,10 +43,6 @@
namespace ranczo { namespace ranczo {
template<typename T>
std::optional<T> from_string(std::optional< std::string_view > state,
std::pmr::memory_resource * mr = std::pmr::get_default_resource());
enum class ThermostatState { Enabled, Disabled, Error }; enum class ThermostatState { Enabled, Disabled, Error };
std::string to_string(ThermostatState state) { std::string to_string(ThermostatState state) {
switch(state) { switch(state) {
@ -58,8 +56,7 @@ std::string to_string(ThermostatState state) {
} }
template <> template <>
std::optional< ThermostatState > from_string(std::optional< std::string_view > state, std::optional< ThermostatState > from_string(std::optional< std::string_view > state, std::pmr::memory_resource * mr) {
std::pmr::memory_resource * mr) {
BOOST_ASSERT(mr); BOOST_ASSERT(mr);
if(not state) { if(not state) {
@ -77,38 +74,6 @@ std::optional< ThermostatState > from_string(std::optional< std::string_view > s
return std::nullopt; 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 > template < typename T >
inline expected< T > readValue(const boost::json::value & jv, std::string_view key) { inline expected< T > readValue(const boost::json::value & jv, std::string_view key) {
if(auto * obj = jv.if_object()) { if(auto * obj = jv.if_object()) {
@ -247,173 +212,6 @@ namespace commands {
}; };
} // 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 { enum RuntimeError {
NoTemperatureMeasurements = 1, NoTemperatureMeasurements = 1,
IoError, IoError,
@ -509,6 +307,9 @@ struct RelayThermostat::Impl : private boost::noncopyable {
double _slopeDT_c{0.2}; // [°C / min] double _slopeDT_c{0.2}; // [°C / min]
std::chrono::nanoseconds _sensorTimeout{std::chrono::minutes(5)}; // max time to wait for temperature std::chrono::nanoseconds _sensorTimeout{std::chrono::minutes(5)}; // max time to wait for temperature
/// TODO dodanie resistance
///
// additional variables // additional variables
Timer _statusTimer; // sends status of current endpoint Timer _statusTimer; // sends status of current endpoint
std::chrono::system_clock::time_point _startTP; // used only to prevent warnings at start of procedure std::chrono::system_clock::time_point _startTP; // used only to prevent warnings at start of procedure
@ -997,8 +798,7 @@ struct RelayThermostat::Impl : private boost::noncopyable {
auto toSec = [](auto t) { return seconds{t}.count(); }; auto toSec = [](auto t) { return seconds{t}.count(); };
_state = from_string<ThermostatState>( _state = from_string< ThermostatState >(co_await _settings.async_get_store_default("state", to_string(ThermostatState::Disabled)))
co_await _settings.async_get_store_default("state", to_string(ThermostatState::Disabled)))
.value_or(ThermostatState::Disabled); .value_or(ThermostatState::Disabled);
_targetTemperature = co_await _settings.async_get_store_default("target_temperature", 20.0); _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}))}; _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] _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}))}; _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 // subscribe to a thermostat commands feed
_log.info("Start: subscribe to mqtt"); _log.info("Start: subscribe to mqtt");
@ -1195,7 +996,7 @@ struct RelayThermostat::Impl : private boost::noncopyable {
auto trend = _thermo.temperatureTrend(_slopeWindow, _slopeDT_c); auto trend = _thermo.temperatureTrend(_slopeWindow, _slopeDT_c);
if(trend) { if(trend) {
obj["current_trend"] = to_string(*trend); obj["temperature_trend"] = to_string(*trend);
} }
obj["measurements_size"] = _thermo.size(); obj["measurements_size"] = _thermo.size();
obj["hysteresis"] = _hysteresis; obj["hysteresis"] = _hysteresis;

View File

@ -0,0 +1,133 @@
#include "temperature_measurements.hpp"
#include "ranczo-io/utils/date_utils.hpp"
#include <boost/asio/co_spawn.hpp>
#include <boost/asio/detached.hpp>
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

View File

@ -0,0 +1,106 @@
#pragma once
#include "config.hpp"
#include "thermometer.hpp"
#include <boost/circular_buffer.hpp>
#include <memory>
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