Add HTTP get
This commit is contained in:
parent
dbe89b64ca
commit
6da01a2f6b
@ -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)
|
||||
|
||||
@ -4,6 +4,8 @@
|
||||
#include <boost/system/errc.hpp>
|
||||
#include <boost/system/error_code.hpp>
|
||||
|
||||
#include <memory_resource>
|
||||
|
||||
#if __has_include(<expected>)
|
||||
#include <expected>
|
||||
#elif __has_include(<tl/expected.hpp>)
|
||||
@ -14,6 +16,10 @@
|
||||
|
||||
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>)
|
||||
template < typename T >
|
||||
using expected = std::expected< T, boost::system::error_code >;
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
176
libs/http.cpp
Normal file
176
libs/http.cpp
Normal 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
|
||||
@ -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<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) {
|
||||
auto ctx = ctx_;
|
||||
co_return co_await ctx->call_with_lock< void >([this, address, value](modbus_t * c) -> expected< void > {
|
||||
|
||||
84
libs/ranczo-io/utils/http.hpp
Normal file
84
libs/ranczo-io/utils/http.hpp
Normal 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
|
||||
@ -12,6 +12,7 @@
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <memory_resource>
|
||||
#include <string>
|
||||
// libmodbus (C)
|
||||
#include <modbus/modbus.h>
|
||||
@ -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() {
|
||||
|
||||
@ -7,7 +7,6 @@
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
@ -144,20 +142,46 @@ namespace topic {
|
||||
} // namespace temperature
|
||||
|
||||
namespace utilities {
|
||||
// home/utilities/power/<meter>/<kind>/<channel>
|
||||
/*
|
||||
* <meter>: main, heating, housing
|
||||
* <kind>: active, reactive, apparent, voltage, current, frequency, pf
|
||||
* <channel>: total, L1, L2, L3 itp.
|
||||
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
|
||||
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(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/<meter>/<kind>/<channel>
|
||||
@ -166,18 +190,19 @@ namespace topic {
|
||||
* <kind>: tatol_active_energy
|
||||
* <channel>: 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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
@ -0,0 +1,2 @@
|
||||
#pragma once
|
||||
|
||||
@ -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/system/error_code.hpp>
|
||||
|
||||
#include <fmt/core.h>
|
||||
#include <modbus/modbus.h>
|
||||
#include <iostream>
|
||||
#include <memory_resource>
|
||||
#include <spdlog/spdlog.h>
|
||||
|
||||
#include <config.hpp>
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Pomocnicze: timestamp ISO
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
#include <ranczo-io/utils/http.hpp>
|
||||
|
||||
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);
|
||||
namespace ranczo {
|
||||
|
||||
std::tm tm{};
|
||||
#if defined(_WIN32)
|
||||
gmtime_s(&tm, &t);
|
||||
#else
|
||||
gmtime_r(&t, &tm);
|
||||
#endif
|
||||
} // namespace ranczo
|
||||
|
||||
char buf[32];
|
||||
std::strftime(buf, sizeof(buf), "%FT%TZ", &tm);
|
||||
return std::string(buf);
|
||||
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);
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// 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();
|
||||
co_return;
|
||||
},
|
||||
detached);
|
||||
boost::asio::detached);
|
||||
|
||||
co_spawn(_ex,
|
||||
[this]() -> awaitable<void> {
|
||||
co_await total_loop();
|
||||
},
|
||||
detached);
|
||||
|
||||
co_return;
|
||||
io.run();
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
add_executable(ranczo-io_floorheating
|
||||
main.cpp
|
||||
temperature_controller.hpp temperature_controller.cpp
|
||||
temperature_measurements.hpp temperature_measurements.cpp
|
||||
relay.hpp relay.cpp
|
||||
thermometer.hpp thermometer.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;
|
||||
}
|
||||
|
||||
|
||||
@ -10,7 +10,6 @@
|
||||
#include <boost/asio/experimental/parallel_group.hpp>
|
||||
#include <boost/asio/redirect_error.hpp>
|
||||
#include <boost/asio/steady_timer.hpp>
|
||||
#include <boost/circular_buffer.hpp>
|
||||
#include <boost/core/noncopyable.hpp>
|
||||
#include <boost/json/object.hpp>
|
||||
#include <boost/json/storage_ptr.hpp>
|
||||
@ -32,6 +31,9 @@
|
||||
#include <ranczo-io/utils/memory_resource.hpp>
|
||||
#include <ranczo-io/utils/mqtt_client.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_view>
|
||||
@ -41,10 +43,6 @@
|
||||
|
||||
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 };
|
||||
std::string to_string(ThermostatState state) {
|
||||
switch(state) {
|
||||
@ -58,8 +56,7 @@ std::string to_string(ThermostatState state) {
|
||||
}
|
||||
|
||||
template <>
|
||||
std::optional< ThermostatState > from_string(std::optional< std::string_view > state,
|
||||
std::pmr::memory_resource * mr) {
|
||||
std::optional< ThermostatState > from_string(std::optional< std::string_view > state, std::pmr::memory_resource * mr) {
|
||||
BOOST_ASSERT(mr);
|
||||
|
||||
if(not state) {
|
||||
@ -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()) {
|
||||
@ -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
|
||||
@ -997,8 +798,7 @@ struct RelayThermostat::Impl : private boost::noncopyable {
|
||||
|
||||
auto toSec = [](auto t) { return seconds{t}.count(); };
|
||||
|
||||
_state = from_string<ThermostatState>(
|
||||
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");
|
||||
@ -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;
|
||||
|
||||
133
services/floorheat_svc/temperature_measurements.cpp
Normal file
133
services/floorheat_svc/temperature_measurements.cpp
Normal 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
|
||||
106
services/floorheat_svc/temperature_measurements.hpp
Normal file
106
services/floorheat_svc/temperature_measurements.hpp
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user