Add energymeter service to services

This commit is contained in:
Bartosz Wieczorek 2025-11-28 09:18:05 +01:00
parent 12f4a8ce25
commit dbe89b64ca
13 changed files with 572 additions and 1 deletions

View File

@ -142,6 +142,42 @@ 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.
*/
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()) {
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);
}
// home/utilities/energy/<meter>/<kind>/<channel>
/*
* <meter>: main, heating, housing
* <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()) {
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);
}
}
} // namespace topic
} // namespace ranczo

View File

@ -5,7 +5,8 @@ set(RANCZO_GROUP "ranczoio")
include(GNUInstallDirs)
add_subdirectory(floorheat_svc)
add_subdirectory(temperature_svc)
add_subdirectory(floorheat_svc)
# add_subdirectory(energymeter_svc)
add_subdirectory(output_svc)
add_subdirectory(input_svc)

View File

@ -0,0 +1,63 @@
add_executable(ranczo-io_energymeter
main.cpp
energymeter.hpp
ORNO_517.hpp ORNO_517.cpp
pstryk.hpp pstryk.cpp
ranczo-io_energymeter.service.in
postinst
prerm
postrm
)
target_link_libraries(ranczo-io_energymeter
PUBLIC
ranczo-io::utils
fmt::fmt
)
install(
TARGETS ranczo-io_energymeter
INCLUDES DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}
ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
COMPONENT energymeter
)
# Opis komponentu energymeter
set(CPACK_COMPONENT_ENERGYMETER_DISPLAY_NAME "Ranczo-IO Energy Reading Service" CACHE INTERNAL "package section")
set(CPACK_COMPONENT_ENERGYMETER_DESCRIPTION "Serwis odczytujący aktualne zużycie energii." CACHE INTERNAL "package section")
set(CPACK_COMPONENT_ENERGYMETER_REQUIRED ON CACHE INTERNAL "package section")
# Nazwa samego pakietu DEB dla komponentu
set(CPACK_DEBIAN_ENERGYMETER_PACKAGE_NAME "ranczo-io-energymeter" CACHE INTERNAL "package name")
set(CPACK_DEBIAN_ENERGYMETER_PACKAGE_SECTION "utils" CACHE INTERNAL "package section")
set(CPACK_DEBIAN_ENERGYMETER_PACKAGE_MAINTAINER "b.wieczorek@dx.net.pl" CACHE INTERNAL "package maintainer")
set(CPACK_DEBIAN_ENERGYMETER_PACKAGE_CONTROL_EXTRA
"${CMAKE_CURRENT_SOURCE_DIR}/postinst"
"${CMAKE_CURRENT_SOURCE_DIR}/prerm"
"${CMAKE_CURRENT_SOURCE_DIR}/postrm"
CACHE INTERNAL "package extra"
)
set(CPACK_DEBIAN_ENERGYMETER_PACKAGE_DEPENDS "libsqlite3-0, systemd (>= 245)" CACHE INTERNAL "package depends")
configure_file(
${CMAKE_CURRENT_SOURCE_DIR}/ranczo-io_energymeter.service.in
${CMAKE_CURRENT_BINARY_DIR}/ranczo-io_energymeter.service
@ONLY
)
# Instalacja unita systemd
install(
FILES ${CMAKE_CURRENT_BINARY_DIR}/ranczo-io_energymeter.service
DESTINATION ${SYSTEMD_UNIT_DIR}
COMPONENT energymeter
)
# tworzenie katalogów
install(DIRECTORY
DESTINATION /var/lib/ranczo-io/energymeter
COMPONENT energymeter
)

View File

View File

View File

View File

@ -0,0 +1,413 @@
#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 <spdlog/spdlog.h>
#include <config.hpp>
// ───────────────────────────────────────────────────────────────────────────
// Pomocnicze: timestamp ISO
// ───────────────────────────────────────────────────────────────────────────
inline std::string to_iso_timestamp()
{
using clock = std::chrono::system_clock;
auto now = clock::now();
std::time_t t = clock::to_time_t(now);
std::tm tm{};
#if defined(_WIN32)
gmtime_s(&tm, &t);
#else
gmtime_r(&t, &tm);
#endif
char buf[32];
std::strftime(buf, sizeof(buf), "%FT%TZ", &tm);
return std::string(buf);
}
// ───────────────────────────────────────────────────────────────────────────
// 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);
co_spawn(_ex,
[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

@ -0,0 +1,14 @@
#!/bin/sh
set -e
case "$1" in
configure)
# Jeżeli tworzysz usera, możesz to zrobić tutaj (albo w osobnym pakiecie)
# adduser --system --group --no-create-home ranczoio || true
systemctl daemon-reload || true
systemctl enable --now ranczo-io_energymeter.service || true
;;
esac
exit 0

View File

@ -0,0 +1,14 @@
#!/bin/sh
set -e
case "$1" in
remove)
systemctl daemon-reload || true
;;
purge)
systemctl disable ranczo-io_energymeter.service || true
systemctl daemon-reload || true
;;
esac
exit 0

View File

@ -0,0 +1,10 @@
#!/bin/sh
set -e
case "$1" in
remove|upgrade|deconfigure)
systemctl stop ranczo-io_energymeter.service || true
;;
esac
exit 0

View File

View File

View File

@ -0,0 +1,20 @@
[Unit]
Description=Ranczo-IO Energy Meters read service
After=network.target
Wants=network-online.target
[Service]
Type=simple
User=@RANCZO_USER@
Group=@RANCZO_GROUP@
ExecStart=@CMAKE_INSTALL_FULL_BINDIR@/ranczo-io_energymeter
WorkingDirectory=/var/lib/ranczo-io/energymeter
Restart=on-failure
RestartSec=5
# /run/ranczo-io-energymeter będzie robione automatycznie
RuntimeDirectory=ranczo-io_energymeter
RuntimeDirectoryMode=0755
[Install]
WantedBy=multi-user.target