Rewrite electricity-logger to use an sqlite3 database
This commit is contained in:
60
src/electricity-logger/database.cpp
Normal file
60
src/electricity-logger/database.cpp
Normal file
@@ -0,0 +1,60 @@
|
||||
#include <electricity/logger/database.hpp>
|
||||
#include <exception>
|
||||
#include <iomanip>
|
||||
#include <spdlog/spdlog.h>
|
||||
#include <sstream>
|
||||
#include <util/date.hpp>
|
||||
|
||||
sqlite3 * OpenDatabase(std::string const & databaseFilePath)
|
||||
{
|
||||
sqlite3 * connectionPtr;
|
||||
if(sqlite3_open(databaseFilePath.c_str(), &connectionPtr) != SQLITE_OK)
|
||||
{
|
||||
throw std::runtime_error("Error opening SQLite3 database");
|
||||
}
|
||||
sqlite3_extended_result_codes(connectionPtr, 1);
|
||||
|
||||
return connectionPtr;
|
||||
}
|
||||
|
||||
std::string ToSqlInsertStatement(DSMR::Data const & data, time_t const time)
|
||||
{
|
||||
std::stringstream ss;
|
||||
ss << "INSERT INTO ElectricityLog VALUES('" << Util::GetSqliteDate(time) << "','" << Util::GetSqliteUtcTime(time)
|
||||
<< "'," << std::fixed << std::setprecision(2) << data.currentPowerUsageKw << ','
|
||||
<< data.totalPowerConsumptionDayKwh << ',' << data.totalPowerConsumptionNightKwh << ','
|
||||
<< data.currentPowerReturnKw << ',' << data.totalPowerReturnedDayKwh << ',' << data.totalPowerReturnedNightKwh
|
||||
<< ',' << data.usingDayTarif << ',' << data.gasConsumptionCubicMeters << ");";
|
||||
|
||||
return ss.str();
|
||||
}
|
||||
|
||||
void Database::Insert(DSMR::Data const & data, time_t const time)
|
||||
{
|
||||
std::stringstream transaction;
|
||||
transaction << "BEGIN TRANSACTION;" << ToSqlInsertStatement(data, time) << "COMMIT;";
|
||||
|
||||
if(sqlite3_exec(connectionPtr, transaction.str().c_str(), nullptr, nullptr, nullptr) != SQLITE_OK)
|
||||
{
|
||||
spdlog::error("Failed to insert DSMR record into SQLite database: {}", sqlite3_errmsg(connectionPtr));
|
||||
throw std::runtime_error("Failed to insert DSMR record into SQLite databas");
|
||||
}
|
||||
}
|
||||
|
||||
Database::Database(std::string const & databaseFilePath)
|
||||
{
|
||||
if(sqlite3_open(databaseFilePath.c_str(), &connectionPtr) != SQLITE_OK)
|
||||
{
|
||||
spdlog::error("Error whilst opening SQLite database {}", databaseFilePath);
|
||||
throw std::runtime_error("Error opening SQLite3 database");
|
||||
}
|
||||
sqlite3_extended_result_codes(connectionPtr, 1);
|
||||
}
|
||||
|
||||
Database::~Database()
|
||||
{
|
||||
if(connectionPtr)
|
||||
{
|
||||
sqlite3_close(connectionPtr);
|
||||
}
|
||||
}
|
||||
174
src/electricity-logger/dsmr.cpp
Normal file
174
src/electricity-logger/dsmr.cpp
Normal file
@@ -0,0 +1,174 @@
|
||||
#include <electricity/logger/dsmr.hpp>
|
||||
#include <sstream>
|
||||
#include <utility>
|
||||
|
||||
namespace DSMR
|
||||
{
|
||||
const std::unordered_map<std::string, LineTag> & Data::GetMap()
|
||||
{
|
||||
static std::unordered_map<std::string, LineTag> map = {
|
||||
{ "1-3:0.2.8", LineTag::DSMRversion },
|
||||
{ "0-0:1.0.0", LineTag::DateTimeStamp },
|
||||
{ "0-0:96.1.1", LineTag::SerialNo },
|
||||
{ "1-0:1.8.1", LineTag::TotalPowerConsumedTariff1 },
|
||||
{ "1-0:1.8.2", LineTag::TotalPowerConsumedTariff2 },
|
||||
{ "1-0:2.8.1", LineTag::TotalReturnedPowerTariff1 },
|
||||
{ "1-0:2.8.2", LineTag::TotalReturnedPowerTariff2 },
|
||||
{ "0-0:96.14.0", LineTag::CurrentTarif }, // 1 == night, 2 == day,
|
||||
{ "1-0:1.7.0", LineTag::CurrentPowerConsumption },
|
||||
{ "1-0:2.7.0", LineTag::CurrentPowerReturn },
|
||||
{ "0-0:96.7.21", LineTag::PowerFailureCount },
|
||||
{ "0-0:96.7.9", LineTag::PowerFailureLongCount },
|
||||
{ "1-0:99.97.0", LineTag::PowerFailureEventLog },
|
||||
{ "1-0:32.32.0", LineTag::VoltageL1SagCount },
|
||||
{ "1-0:52.32.0", LineTag::VoltageL2SagCount },
|
||||
{ "1-0:72.32.0", LineTag::VoltageL3SagCount },
|
||||
{ "1-0:32.36.0", LineTag::VoltageL1SwellCount },
|
||||
{ "1-0:52.36.0", LineTag::VoltageL2SwellCount },
|
||||
{ "1-0:72.36.0", LineTag::VoltageL3SwellCount },
|
||||
{ "0-0:96.13.0", LineTag::TextMessageMaxChar },
|
||||
{ "1-0:32.7.0", LineTag::InstantL1VoltageResolution },
|
||||
{ "1-0:52.7.0", LineTag::InstantL2VoltageResolution },
|
||||
{ "1-0:72.7.0", LineTag::InstantL3VoltageResolution },
|
||||
{ "1-0:31.7.0", LineTag::InstantL1CurrentResolution },
|
||||
{ "1-0:51.7.0", LineTag::InstantL2CurrentResolution },
|
||||
{ "1-0:71.7.0", LineTag::InstantL3CurrentResolution },
|
||||
{ "1-0:21.7.0", LineTag::InstantL1ActivePowerResolution },
|
||||
{ "1-0:41.7.0", LineTag::InstantL2ActivePowerResolution },
|
||||
{ "1-0:61.7.0", LineTag::InstantL3ActivePowerResolution },
|
||||
{ "1-0:22.7.0", LineTag::InstantL1ActivePowerResolutionA },
|
||||
{ "1-0:42.7.0", LineTag::InstantL2ActivePowerResolutionA },
|
||||
{ "1-0:62.7.0", LineTag::InstantL3ActivePowerResolutionA },
|
||||
{ "0-1:24.1.0", LineTag::DeviceType },
|
||||
{ "0-1:96.1.0", LineTag::GasDeviceIdentifier },
|
||||
{ "0-1:24.2.1", LineTag::GasTotalConsumptionLog },
|
||||
};
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
void Data::RemoveUnit(std::string & value)
|
||||
{
|
||||
const auto fresult = value.find('*');
|
||||
if(fresult == std::string::npos)
|
||||
{
|
||||
return;
|
||||
}
|
||||
value.resize(fresult);
|
||||
}
|
||||
|
||||
std::pair<std::string, std::string> Data::GetKeyValuePair(const std::string & line)
|
||||
{
|
||||
const auto bracketOpen = line.find('(');
|
||||
if(bracketOpen == std::string::npos)
|
||||
{
|
||||
return std::make_pair("", "");
|
||||
}
|
||||
const auto bracketClose = line.find(')', bracketOpen);
|
||||
if(bracketClose == std::string::npos)
|
||||
{
|
||||
return std::make_pair(line.substr(0, bracketOpen), "");
|
||||
}
|
||||
|
||||
return std::make_pair(
|
||||
line.substr(0, bracketOpen),
|
||||
line.substr(bracketOpen + 1, bracketClose - (bracketOpen + 1)));
|
||||
}
|
||||
|
||||
void Data::ParseLine(const std::string & line)
|
||||
{
|
||||
const auto & map = GetMap();
|
||||
|
||||
auto pair = GetKeyValuePair(line);
|
||||
auto & key = pair.first;
|
||||
auto & value = pair.second;
|
||||
|
||||
const auto fresult = map.find(key);
|
||||
if(fresult == map.end())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
RemoveUnit(value);
|
||||
std::stringstream ss;
|
||||
ss.str(value);
|
||||
|
||||
switch(fresult->second)
|
||||
{
|
||||
case LineTag::CurrentPowerConsumption:
|
||||
ss >> currentPowerUsageKw;
|
||||
break;
|
||||
|
||||
case LineTag::CurrentPowerReturn:
|
||||
ss >> currentPowerReturnKw;
|
||||
break;
|
||||
|
||||
case LineTag::TotalPowerConsumedTariff2:
|
||||
ss >> totalPowerConsumptionDayKwh;
|
||||
break;
|
||||
|
||||
case LineTag::TotalPowerConsumedTariff1:
|
||||
ss >> totalPowerConsumptionNightKwh;
|
||||
break;
|
||||
|
||||
case LineTag::TotalReturnedPowerTariff1:
|
||||
ss >> totalPowerReturnedDayKwh;
|
||||
break;
|
||||
|
||||
case LineTag::TotalReturnedPowerTariff2:
|
||||
ss >> totalPowerReturnedNightKwh;
|
||||
break;
|
||||
|
||||
case LineTag::CurrentTarif:
|
||||
{
|
||||
int tarif = 1;
|
||||
ss >> tarif;
|
||||
usingDayTarif = (tarif == 2);
|
||||
}
|
||||
break;
|
||||
|
||||
case LineTag::GasTotalConsumptionLog:
|
||||
ss >> gasTimestamp;
|
||||
{
|
||||
const auto secondBracket = line.find('(', key.size() + value.size() + 2);
|
||||
if(secondBracket == std::string::npos)
|
||||
{
|
||||
break;
|
||||
}
|
||||
const auto unitSymbol = line.find('*', secondBracket);
|
||||
if(secondBracket == std::string::npos)
|
||||
{
|
||||
break;
|
||||
}
|
||||
const std::string consumption = line.substr(secondBracket + 1, unitSymbol - (secondBracket + 1));
|
||||
ss.clear();
|
||||
ss.str(consumption);
|
||||
ss >> gasConsumptionCubicMeters;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void Data::ParseLines(const std::vector<std::string> & lines)
|
||||
{
|
||||
for(const auto & line: lines)
|
||||
{
|
||||
ParseLine(line);
|
||||
}
|
||||
}
|
||||
|
||||
std::string Data::GetFormattedString(char const separator) const
|
||||
{
|
||||
std::stringstream ss;
|
||||
ss << currentPowerUsageKw << separator << totalPowerConsumptionDayKwh << separator
|
||||
<< totalPowerConsumptionNightKwh << separator << currentPowerReturnKw << separator
|
||||
<< totalPowerReturnedDayKwh << separator << totalPowerReturnedNightKwh << separator
|
||||
<< (usingDayTarif ? "day-tarif" : "night-tarif") << separator << gasTimestamp << separator
|
||||
<< gasConsumptionCubicMeters;
|
||||
|
||||
return ss.str();
|
||||
}
|
||||
}
|
||||
117
src/electricity-logger/main.cpp
Normal file
117
src/electricity-logger/main.cpp
Normal file
@@ -0,0 +1,117 @@
|
||||
#include <cxxopts.hpp>
|
||||
#include <electricity/logger/database.hpp>
|
||||
#include <electricity/logger/dsmr.hpp>
|
||||
#include <electricity/logger/serialport.hpp>
|
||||
#include <exception>
|
||||
#include <fcntl.h>
|
||||
#include <optional>
|
||||
#include <spdlog/spdlog.h>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
||||
std::optional<cxxopts::ParseResult> ExtractArgs(int argc, char ** argv)
|
||||
{
|
||||
cxxopts::Options options(
|
||||
"electricity-logger",
|
||||
"electricity-logger is a small program that fetches power and gas statistics from an attached Landis Gyr E350 electricity meter and outputs this on stdout.");
|
||||
|
||||
options.add_options()(
|
||||
"d,serial-device",
|
||||
"Absolute path to the serial device to read from",
|
||||
cxxopts::value<std::string>())(
|
||||
"connection-string",
|
||||
"Path to the sqlite3 database file",
|
||||
cxxopts::value<std::string>());
|
||||
|
||||
if(argc == 1)
|
||||
{
|
||||
std::cout << options.help() << std::endl;
|
||||
return {};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
auto const parsed = options.parse(argc, argv);
|
||||
return parsed;
|
||||
}
|
||||
catch(cxxopts::OptionException const & e)
|
||||
{
|
||||
spdlog::error(e.what());
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
DSMR::Data ReadData(std::string const & devicePath)
|
||||
{
|
||||
char const * serialDeviceValue = devicePath.c_str();
|
||||
int fd = open(serialDeviceValue, O_RDONLY | O_NOCTTY | O_SYNC);
|
||||
if(fd < 0)
|
||||
{
|
||||
spdlog::error("Error opening device {}", serialDeviceValue);
|
||||
throw std::runtime_error("Error opening serial device for reading");
|
||||
}
|
||||
SerialPort serial(fd);
|
||||
|
||||
spdlog::info("Starting logging operation with serial device {}", serialDeviceValue);
|
||||
|
||||
// Find the first line, which starts with a forward slash
|
||||
bool waiting = true;
|
||||
do
|
||||
{
|
||||
try
|
||||
{
|
||||
auto line = serial.ReadLine();
|
||||
if(line.size() > 0 && line[0] == '/')
|
||||
{
|
||||
waiting = false;
|
||||
}
|
||||
}
|
||||
catch(std::exception const & ex)
|
||||
{
|
||||
spdlog::error("Error whilst seeking first output line from device {}: {}", serialDeviceValue, ex.what());
|
||||
throw std::runtime_error("Error reading from serial device");
|
||||
}
|
||||
} while(waiting);
|
||||
|
||||
// We reached the interesting bits, parse them and keep what we want
|
||||
DSMR::Data data;
|
||||
int i = 1;
|
||||
while(i < 37)
|
||||
{
|
||||
try
|
||||
{
|
||||
auto line = serial.ReadLine();
|
||||
if(line.size() > 5)
|
||||
{
|
||||
data.ParseLine(line);
|
||||
++i;
|
||||
}
|
||||
}
|
||||
catch(std::exception const & ex)
|
||||
{
|
||||
spdlog::error("Error whilst parsing line from device {}: {}", serialDeviceValue, ex.what());
|
||||
throw std::runtime_error("Error parsing data from serial device");
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
int main(int argc, char ** argv)
|
||||
{
|
||||
auto const maybeArgs = ExtractArgs(argc, argv);
|
||||
if(!maybeArgs.has_value())
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
auto const & args = maybeArgs.value();
|
||||
|
||||
auto const data = ReadData(args["serial-device"].as<std::string>());
|
||||
|
||||
auto const connectionStringValue = args["connection-string"].as<std::string>();
|
||||
Database db(connectionStringValue);
|
||||
db.Insert(data, std::time(nullptr));
|
||||
|
||||
return 0;
|
||||
}
|
||||
55
src/electricity-logger/serialport.cpp
Normal file
55
src/electricity-logger/serialport.cpp
Normal file
@@ -0,0 +1,55 @@
|
||||
#include <electricity/logger/serialport.hpp>
|
||||
#include <stdexcept>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
void SerialPort::SetAttributes(const speed_t baudrate)
|
||||
{
|
||||
cfsetospeed(&configuration, baudrate);
|
||||
cfsetispeed(&configuration, baudrate);
|
||||
|
||||
configuration.c_cflag = (configuration.c_cflag & ~CSIZE) | CS8; // 8 bit chars
|
||||
configuration.c_iflag &= ~IGNBRK;
|
||||
configuration.c_lflag = 0;
|
||||
configuration.c_oflag = 0;
|
||||
configuration.c_cc[VMIN] = 0; // no blocking read
|
||||
configuration.c_cc[VTIME] = 5;
|
||||
|
||||
configuration.c_iflag &= ~(IXON | IXOFF | IXANY);
|
||||
configuration.c_cflag |= (CLOCAL | CREAD);
|
||||
configuration.c_cflag &= ~(PARENB | PARODD);
|
||||
configuration.c_cflag &= ~(CSTOPB | CRTSCTS);
|
||||
|
||||
if(tcsetattr(device, TCSANOW, &configuration) != 0)
|
||||
{
|
||||
throw std::runtime_error("Error setting configuration of device.");
|
||||
}
|
||||
}
|
||||
|
||||
std::string SerialPort::ReadLine()
|
||||
{
|
||||
std::string retval;
|
||||
char c;
|
||||
while(read(device, &c, 1) == 1)
|
||||
{
|
||||
retval += c;
|
||||
if(c == '\n')
|
||||
break;
|
||||
}
|
||||
|
||||
return retval;
|
||||
}
|
||||
|
||||
SerialPort::SerialPort(const int fd) : device(fd)
|
||||
{
|
||||
memset(&configuration, 0, sizeof(termios));
|
||||
if(tcgetattr(fd, &configuration) != 0)
|
||||
{
|
||||
throw std::runtime_error("Error getting attributes from file descriptor.");
|
||||
}
|
||||
oldConfiguration = configuration;
|
||||
|
||||
SetAttributes();
|
||||
}
|
||||
|
||||
SerialPort::~SerialPort() { tcsetattr(device, TCSANOW, &oldConfiguration); }
|
||||
51
src/electricity-server/main.cpp
Normal file
51
src/electricity-server/main.cpp
Normal file
@@ -0,0 +1,51 @@
|
||||
#include <electricity/server/api.hpp>
|
||||
#include <electricity/server/configuration.hpp>
|
||||
#include <pistache/endpoint.h>
|
||||
#include <tclap/CmdLine.h>
|
||||
|
||||
int main(int argc, char ** argv)
|
||||
{
|
||||
TCLAP::CmdLine cmd(
|
||||
"electricity-server is a small Pistache based HTTP content server with a REST API to access the solar log database",
|
||||
' ',
|
||||
"1.0.0");
|
||||
|
||||
TCLAP::ValueArg<unsigned>
|
||||
listeningPortArg("p", "listening-port", "TCP listening port number", true, 0u, "TCP listening port number");
|
||||
cmd.add(listeningPortArg);
|
||||
|
||||
TCLAP::ValueArg<std::string> logDirectoryPath(
|
||||
"d",
|
||||
"data-directory",
|
||||
"Absolute path pointing to the logging directory",
|
||||
true,
|
||||
"",
|
||||
"Absolute path pointing to the logging directory");
|
||||
cmd.add(logDirectoryPath);
|
||||
|
||||
TCLAP::ValueArg<std::string> serverDomain(
|
||||
"s",
|
||||
"server-domain",
|
||||
"Domain this server is hosted on",
|
||||
true,
|
||||
"",
|
||||
"Domain this server is hosted on");
|
||||
cmd.add(serverDomain);
|
||||
|
||||
cmd.parse(argc, argv);
|
||||
|
||||
auto & config = Server::Configuration::Get();
|
||||
config.Setup(logDirectoryPath.getValue(), serverDomain.getValue());
|
||||
|
||||
Pistache::Address address(Pistache::Ipv4::any(), listeningPortArg.getValue());
|
||||
Pistache::Http::Endpoint server(address);
|
||||
|
||||
auto options = Pistache::Http::Endpoint::options().threads(2);
|
||||
server.init(options);
|
||||
|
||||
Pistache::Rest::Router router;
|
||||
Server::Api::SetupRouting(router);
|
||||
server.setHandler(router.handler());
|
||||
|
||||
server.serve();
|
||||
}
|
||||
96
src/electricity-server/server/api.cpp
Normal file
96
src/electricity-server/server/api.cpp
Normal file
@@ -0,0 +1,96 @@
|
||||
#include <electricity/server/api.hpp>
|
||||
#include <electricity/server/configuration.hpp>
|
||||
#include <electricity/server/database.hpp>
|
||||
#include <fstream>
|
||||
#include <iomanip>
|
||||
#include <spdlog/spdlog.h>
|
||||
#include <sstream>
|
||||
#include <util/date.hpp>
|
||||
#include <vector>
|
||||
|
||||
namespace Server::Api
|
||||
{
|
||||
using Pistache::Http::Mime::Subtype;
|
||||
using Pistache::Http::Mime::Type;
|
||||
|
||||
// Only allow serving to ourselves, all behind the same NAT address
|
||||
bool IsRequesteeAllowed(Pistache::Http::Request const & request)
|
||||
{
|
||||
Configuration & config = Configuration::Get();
|
||||
auto const & serverAddress = config.GetExternalServerIp();
|
||||
|
||||
auto realIpHeader = request.headers().tryGetRaw("X-Real-IP");
|
||||
if(realIpHeader.isEmpty())
|
||||
{
|
||||
spdlog::error("Blocking request without X-Real-IP header");
|
||||
return false;
|
||||
}
|
||||
|
||||
if(realIpHeader.unsafeGet().value() != serverAddress)
|
||||
{
|
||||
spdlog::info(
|
||||
"Blocking request {} due to host mismatch (expected {})",
|
||||
realIpHeader.unsafeGet().value(),
|
||||
serverAddress);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void GetDay(Pistache::Http::Request const & request, Pistache::Http::ResponseWriter responseWrite)
|
||||
{
|
||||
spdlog::info(
|
||||
"{} {} {}",
|
||||
Pistache::Http::methodString(request.method()),
|
||||
request.resource(),
|
||||
request.query().as_str());
|
||||
|
||||
if(!IsRequesteeAllowed(request))
|
||||
{
|
||||
responseWrite.send(Pistache::Http::Code::Unauthorized);
|
||||
return;
|
||||
}
|
||||
|
||||
auto const startQuery = request.query().get("start");
|
||||
if(startQuery.isEmpty())
|
||||
{
|
||||
responseWrite.send(Pistache::Http::Code::Bad_Request);
|
||||
return;
|
||||
}
|
||||
|
||||
Util::Date const startDate(startQuery.unsafeGet());
|
||||
if(!startDate.IsValid())
|
||||
{
|
||||
responseWrite.send(Pistache::Http::Code::Bad_Request);
|
||||
return;
|
||||
}
|
||||
|
||||
auto const stopQuery = request.query().get("stop");
|
||||
if(stopQuery.isEmpty())
|
||||
{
|
||||
responseWrite.send(
|
||||
Pistache::Http::Code::Ok,
|
||||
Database::GetDetailedJsonOf(startDate),
|
||||
MIME(Application, Json));
|
||||
return;
|
||||
}
|
||||
|
||||
Util::Date const stopDate(stopQuery.unsafeGet());
|
||||
if(!stopDate.IsValid() || stopDate.IsBefore(startDate))
|
||||
{
|
||||
responseWrite.send(Pistache::Http::Code::Bad_Request);
|
||||
return;
|
||||
}
|
||||
|
||||
responseWrite.send(
|
||||
Pistache::Http::Code::Ok,
|
||||
Database::GetSummaryJsonOf(startDate, stopDate),
|
||||
MIME(Application, Json));
|
||||
}
|
||||
|
||||
void SetupRouting(Pistache::Rest::Router & router)
|
||||
{
|
||||
Pistache::Rest::Routes::Get(router, "/day", Pistache::Rest::Routes::bind(&GetDay));
|
||||
}
|
||||
}
|
||||
98
src/electricity-server/server/configuration.cpp
Normal file
98
src/electricity-server/server/configuration.cpp
Normal file
@@ -0,0 +1,98 @@
|
||||
#include <arpa/inet.h>
|
||||
#include <electricity/server/configuration.hpp>
|
||||
#include <errno.h>
|
||||
#include <netdb.h>
|
||||
#include <regex>
|
||||
#include <spdlog/spdlog.h>
|
||||
#include <sys/socket.h>
|
||||
|
||||
namespace Server
|
||||
{
|
||||
Configuration::Configuration() : logDirectory() { }
|
||||
|
||||
void Configuration::RefreshExternalIp()
|
||||
{
|
||||
addrinfo hints;
|
||||
hints.ai_family = AF_INET;
|
||||
hints.ai_socktype = SOCK_STREAM;
|
||||
hints.ai_flags = AI_PASSIVE;
|
||||
hints.ai_protocol = 0;
|
||||
hints.ai_canonname = nullptr;
|
||||
hints.ai_addr = nullptr;
|
||||
hints.ai_next = nullptr;
|
||||
|
||||
addrinfo * serverInfo;
|
||||
auto const result = getaddrinfo(serverDomain.c_str(), "http", &hints, &serverInfo);
|
||||
if(result)
|
||||
{
|
||||
spdlog::error(
|
||||
"Error {} when parsing domain {} to determine external IP address",
|
||||
gai_strerror(result),
|
||||
serverDomain);
|
||||
lastExternalIp.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
char addressBuffer[INET_ADDRSTRLEN];
|
||||
sockaddr_in * socketAddress = reinterpret_cast<sockaddr_in *>(serverInfo->ai_addr);
|
||||
if(inet_ntop(AF_INET, &socketAddress->sin_addr, addressBuffer, INET_ADDRSTRLEN) != nullptr)
|
||||
{
|
||||
lastIpCheckTimePoint = std::chrono::steady_clock::now();
|
||||
lastExternalIp = std::string(addressBuffer);
|
||||
spdlog::info("External IP address {} cached", lastExternalIp);
|
||||
}
|
||||
else
|
||||
{
|
||||
auto const error = errno;
|
||||
spdlog::error("Error {} returned by inet_ntop when trying to determine external IP address", error);
|
||||
lastExternalIp.clear();
|
||||
}
|
||||
|
||||
freeaddrinfo(serverInfo);
|
||||
return;
|
||||
}
|
||||
|
||||
bool Configuration::ExternalIpRequiresRefresh() const
|
||||
{
|
||||
if(!std::regex_match(lastExternalIp, std::regex("^([0-9]+\\.){3}[0-9]+$")))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
auto timeSinceLastRefresh = std::chrono::steady_clock::now() - lastIpCheckTimePoint;
|
||||
return timeSinceLastRefresh >= std::chrono::minutes(5);
|
||||
}
|
||||
|
||||
void Configuration::Setup(std::string & electricityLogDirectory, std::string const & _serverDomain)
|
||||
{
|
||||
logDirectory = electricityLogDirectory;
|
||||
if(electricityLogDirectory.size() > 0 && electricityLogDirectory[electricityLogDirectory.size() - 1] != '/')
|
||||
{
|
||||
logDirectory += '/';
|
||||
}
|
||||
|
||||
serverDomain = _serverDomain;
|
||||
}
|
||||
|
||||
std::string const & Configuration::GetLogDirectory() const { return logDirectory; }
|
||||
|
||||
std::string const & Configuration::GetExternalServerIp()
|
||||
{
|
||||
if(ExternalIpRequiresRefresh())
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(externalIpRefreshMutex);
|
||||
if(ExternalIpRequiresRefresh())
|
||||
{
|
||||
RefreshExternalIp();
|
||||
}
|
||||
}
|
||||
|
||||
return lastExternalIp;
|
||||
}
|
||||
|
||||
Configuration & Configuration::Get()
|
||||
{
|
||||
static Configuration c;
|
||||
return c;
|
||||
}
|
||||
}
|
||||
211
src/electricity-server/server/database.cpp
Normal file
211
src/electricity-server/server/database.cpp
Normal file
@@ -0,0 +1,211 @@
|
||||
#include <electricity/server/configuration.hpp>
|
||||
#include <electricity/server/database.hpp>
|
||||
#include <fstream>
|
||||
#include <iomanip>
|
||||
#include <sstream>
|
||||
#include <util/date.hpp>
|
||||
#include <vector>
|
||||
|
||||
namespace Server::Database
|
||||
{
|
||||
struct Record
|
||||
{
|
||||
private:
|
||||
bool isValid;
|
||||
|
||||
public:
|
||||
long epoch;
|
||||
double currentPowerUsage;
|
||||
double totalPowerUsageDay;
|
||||
double totalPowerUsageNight;
|
||||
double currentPowerReturn;
|
||||
double totalPowerReturnDay;
|
||||
double totalPowerReturnNight;
|
||||
double totalGasUsage;
|
||||
|
||||
bool IsValid() const { return isValid; }
|
||||
|
||||
void AppendAsJson(std::stringstream & ss) const
|
||||
{
|
||||
ss << "{\"dateTime\":" << epoch << ",\"totalPowerUse\":" << totalPowerUsageDay + totalPowerUsageNight
|
||||
<< ",\"totalPowerReturn\":" << totalPowerReturnDay + totalPowerReturnNight
|
||||
<< ",\"totalGasUse\":" << totalGasUsage << '}';
|
||||
}
|
||||
|
||||
Record()
|
||||
: isValid(false), currentPowerUsage(0.0), totalPowerUsageDay(0.0), totalPowerUsageNight(0.0),
|
||||
currentPowerReturn(0.0), totalPowerReturnDay(0.0), totalPowerReturnNight(0.0), totalGasUsage(0.0)
|
||||
{ }
|
||||
|
||||
Record(std::string const & line)
|
||||
: isValid(false), currentPowerUsage(0.0), totalPowerUsageDay(0.0), totalPowerUsageNight(0.0),
|
||||
currentPowerReturn(0.0), totalPowerReturnDay(0.0), totalPowerReturnNight(0.0), totalGasUsage(0.0)
|
||||
{
|
||||
std::vector<std::string> values;
|
||||
|
||||
char const delimiter = ',';
|
||||
std::size_t previousIndex = 0;
|
||||
while(true)
|
||||
{
|
||||
auto const commaIndex = line.find(delimiter, previousIndex + 1);
|
||||
if(commaIndex != std::string::npos)
|
||||
{
|
||||
values.push_back(line.substr(previousIndex, commaIndex - previousIndex));
|
||||
previousIndex = commaIndex + 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if(previousIndex < line.size())
|
||||
{
|
||||
values.push_back(line.substr(previousIndex));
|
||||
}
|
||||
|
||||
if(values.size() != 10)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Util::Date const date(values[0]);
|
||||
if(!date.IsValid())
|
||||
{
|
||||
return;
|
||||
}
|
||||
int hours, minutes, seconds;
|
||||
if(std::sscanf(values[0].substr(11).c_str(), "%d:%d:%d", &hours, &minutes, &seconds) != 3)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
/* Fields are separated by comma's and represent:
|
||||
1 datetime
|
||||
2 currentPowerUsage (kw)
|
||||
3 totalPowerConsumptionDay (kwh)
|
||||
4 totalPowerConsumptionNight (kwh)
|
||||
5 currentPowerReturn (kw)
|
||||
6 totalPowerReturnedDay (kwh)
|
||||
7 totalPowerReturnedNight (kwh)
|
||||
8 tarif type (unused)
|
||||
9 gas timestamp (unused)
|
||||
10 gas consumption (M^3)
|
||||
*/
|
||||
epoch = date.ToEpoch() + hours * 3600 + minutes * 60 + seconds - 3600;
|
||||
currentPowerUsage = std::atof(values[1].c_str());
|
||||
totalPowerUsageDay = std::atof(values[2].c_str());
|
||||
totalPowerUsageNight = std::atof(values[3].c_str());
|
||||
currentPowerReturn = std::atof(values[4].c_str());
|
||||
totalPowerReturnDay = std::atof(values[5].c_str());
|
||||
totalPowerReturnNight = std::atof(values[6].c_str());
|
||||
totalGasUsage = std::atof(values[9].c_str());
|
||||
|
||||
isValid = true;
|
||||
}
|
||||
};
|
||||
|
||||
std::string GetFileName(Util::Date const & date)
|
||||
{
|
||||
std::stringstream filePath;
|
||||
filePath << Configuration::Get().GetLogDirectory() << date.Year() << '/' << std::setw(2) << std::setfill('0')
|
||||
<< date.Month() << '_' << std::setw(2) << date.Day() << ".txt";
|
||||
|
||||
return filePath.str();
|
||||
}
|
||||
|
||||
std::vector<Record> GetFileContents(Util::Date const & date)
|
||||
{
|
||||
std::ifstream logFile(GetFileName(date));
|
||||
if(!logFile.is_open())
|
||||
{
|
||||
return std::vector<Record>();
|
||||
}
|
||||
|
||||
std::vector<Record> retval;
|
||||
std::string line;
|
||||
while(std::getline(logFile, line))
|
||||
{
|
||||
Record record(line);
|
||||
if(record.IsValid())
|
||||
{
|
||||
retval.push_back(record);
|
||||
}
|
||||
}
|
||||
logFile.close();
|
||||
|
||||
return retval;
|
||||
}
|
||||
|
||||
std::string GetDetailedJsonOf(Util::Date const & date)
|
||||
{
|
||||
auto const records = GetFileContents(date);
|
||||
std::stringstream json;
|
||||
json << '[';
|
||||
|
||||
bool first = true;
|
||||
for(std::size_t i = 0; i < records.size(); ++i)
|
||||
{
|
||||
if(!first)
|
||||
{
|
||||
json << ',';
|
||||
}
|
||||
first = false;
|
||||
|
||||
records[i].AppendAsJson(json);
|
||||
}
|
||||
|
||||
json << ']';
|
||||
return json.str();
|
||||
}
|
||||
|
||||
bool GetSummaryJsonOf(Util::Date const & date, bool const prependComma, std::stringstream & json)
|
||||
{
|
||||
if(prependComma)
|
||||
{
|
||||
json << ',';
|
||||
}
|
||||
|
||||
auto const records = GetFileContents(date);
|
||||
if(records.size() < 2)
|
||||
{
|
||||
Record record;
|
||||
record.epoch = date.ToEpoch();
|
||||
|
||||
record.AppendAsJson(json);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
auto const firstRecord = records[0];
|
||||
auto lastRecord = records[records.size() - 1];
|
||||
lastRecord.totalPowerUsageDay -= firstRecord.totalPowerUsageDay;
|
||||
lastRecord.totalPowerUsageNight -= firstRecord.totalPowerUsageNight;
|
||||
lastRecord.totalPowerReturnDay -= firstRecord.totalPowerReturnDay;
|
||||
lastRecord.totalPowerReturnNight -= firstRecord.totalPowerReturnNight;
|
||||
lastRecord.totalGasUsage -= firstRecord.totalGasUsage;
|
||||
|
||||
lastRecord.AppendAsJson(json);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string GetSummaryJsonOf(Util::Date const & startDate, Util::Date const & stopDate)
|
||||
{
|
||||
std::stringstream json;
|
||||
json << '[';
|
||||
|
||||
long const dayInSeconds = 24 * 60 * 60;
|
||||
long const start = startDate.ToEpoch();
|
||||
long const stop = stopDate.ToEpoch();
|
||||
bool first = true;
|
||||
for(long current = start; current <= stop; current += dayInSeconds)
|
||||
{
|
||||
GetSummaryJsonOf(Util::Date(current), !first, json);
|
||||
first = false;
|
||||
}
|
||||
|
||||
json << ']';
|
||||
return json.str();
|
||||
}
|
||||
}
|
||||
32
src/migrator/main.cpp
Normal file
32
src/migrator/main.cpp
Normal file
@@ -0,0 +1,32 @@
|
||||
#include <cstdio>
|
||||
#include <migrator/migrations.hpp>
|
||||
#include <tclap/CmdLine.h>
|
||||
|
||||
int main(int argc, char ** argv)
|
||||
{
|
||||
TCLAP::CmdLine cmd("migrator is a small program designed to run migrations", ' ', "1.0");
|
||||
|
||||
TCLAP::ValueArg<std::string> migrationArg("m", "migration", "", true, "", "Name of the migration to run");
|
||||
cmd.add(migrationArg);
|
||||
|
||||
TCLAP::ValueArg<std::string> srcArg("s", "source", "", false, "", "Source file or directory to use");
|
||||
cmd.add(srcArg);
|
||||
|
||||
TCLAP::ValueArg<std::string> dstArg("d", "destination", "", false, "", "Destination file or directory to use");
|
||||
cmd.add(dstArg);
|
||||
|
||||
cmd.parse(argc, argv);
|
||||
|
||||
auto const & migrationToRun = migrationArg.getValue();
|
||||
if(migrationToRun == "epochToDateTime")
|
||||
{
|
||||
return Migrations::EpochToDateTime(srcArg.getValue(), dstArg.getValue());
|
||||
}
|
||||
if(migrationToRun == "updateSummaryTable")
|
||||
{
|
||||
return Migrations::UpdateSummaryTable(srcArg.getValue(), dstArg.getValue());
|
||||
}
|
||||
|
||||
std::printf("Unknown migration %s, aborting.\n", migrationToRun.c_str());
|
||||
return 1;
|
||||
}
|
||||
91
src/migrator/migrations/epochtodatetime.cpp
Normal file
91
src/migrator/migrations/epochtodatetime.cpp
Normal file
@@ -0,0 +1,91 @@
|
||||
#include <ctime>
|
||||
#include <migrator/transaction.hpp>
|
||||
#include <sqlite3.h>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
|
||||
std::string ZeroPadTwoDigitNumber(int number)
|
||||
{
|
||||
auto result = std::to_string(number);
|
||||
if(result.size() == 1)
|
||||
{
|
||||
return "0" + result;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
int SourceCallback(void * data, int argc, char ** argv, char ** columnNames)
|
||||
{
|
||||
if(argc != 3)
|
||||
{
|
||||
std::puts("Expected source database to have 3 columns: DateTimeUtc, Watt and KilowattHour.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
Transaction * const transactionPtr = reinterpret_cast<Transaction *>(data);
|
||||
auto const epochTime = std::atol(argv[0]);
|
||||
auto const dateTime = *std::gmtime(&epochTime);
|
||||
|
||||
std::stringstream insertStream;
|
||||
insertStream << "INSERT INTO SolarPanelOutput VALUES(" << '\'' << (dateTime.tm_year + 1900) << '-'
|
||||
<< ZeroPadTwoDigitNumber(dateTime.tm_mon + 1) << '-' << ZeroPadTwoDigitNumber(dateTime.tm_mday)
|
||||
<< "','" << ZeroPadTwoDigitNumber(dateTime.tm_hour) << ':' << ZeroPadTwoDigitNumber(dateTime.tm_min)
|
||||
<< ':' << ZeroPadTwoDigitNumber(dateTime.tm_sec) << "'," << argv[1] << ',' << argv[2] << ");";
|
||||
|
||||
transactionPtr->AddStatement(insertStream.str());
|
||||
|
||||
if(transactionPtr->StatementCount() > 1000)
|
||||
{
|
||||
return transactionPtr->Execute();
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
namespace Migrations
|
||||
{
|
||||
int EpochToDateTime(std::string const & sourceDatabase, std::string const & destinationDatabase)
|
||||
{
|
||||
if(sourceDatabase == destinationDatabase)
|
||||
{
|
||||
std::puts("The EpochToDateTime is not meant to be run on the same database.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
sqlite3 * source;
|
||||
if(sqlite3_open(sourceDatabase.c_str(), &source))
|
||||
{
|
||||
std::printf("Error opening source database %s\n", sourceDatabase.c_str());
|
||||
return 1;
|
||||
}
|
||||
|
||||
sqlite3 * destination;
|
||||
if(sqlite3_open(destinationDatabase.c_str(), &destination))
|
||||
{
|
||||
std::printf("Error opening destination database %s\n", destinationDatabase.c_str());
|
||||
return 1;
|
||||
}
|
||||
|
||||
Transaction transaction(destination);
|
||||
auto const sqlResult
|
||||
= sqlite3_exec(source, "SELECT * FROM SolarPanelOutput;", SourceCallback, &transaction, nullptr);
|
||||
if(sqlResult)
|
||||
{
|
||||
std::printf("Error %i during insertion of records into destination database\n", sqlResult);
|
||||
return 1;
|
||||
}
|
||||
|
||||
auto const commitResult = transaction.Execute();
|
||||
if(commitResult)
|
||||
{
|
||||
std::printf("Error %i when committing last transaction\n", commitResult);
|
||||
return 1;
|
||||
}
|
||||
|
||||
sqlite3_close(source);
|
||||
sqlite3_close(destination);
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
68
src/migrator/migrations/updatesummarytable.cpp
Normal file
68
src/migrator/migrations/updatesummarytable.cpp
Normal file
@@ -0,0 +1,68 @@
|
||||
#include <migrator/transaction.hpp>
|
||||
#include <sqlite3.h>
|
||||
#include <sstream>
|
||||
|
||||
namespace Migrations
|
||||
{
|
||||
int SourceCallback(void * data, int argc, char ** argv, char ** columnNames)
|
||||
{
|
||||
// Expect Date and KilowattHour
|
||||
if(argc != 2)
|
||||
{
|
||||
std::puts("Wrong number of columns received");
|
||||
return 1;
|
||||
}
|
||||
|
||||
Transaction * transactionPtr = reinterpret_cast<Transaction *>(data);
|
||||
std::stringstream insertStream;
|
||||
insertStream << "INSERT INTO SolarPanelSummary(Date,KilowattHour) VALUES(" << '\'' << argv[0] << "'," << argv[1]
|
||||
<< ')' << "ON CONFLICT(Date) DO UPDATE SET KilowattHour = excluded.KilowattHour;";
|
||||
|
||||
transactionPtr->AddStatement(insertStream.str());
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
int UpdateSummaryTable(std::string const & sourceDatabase, std::string const & destinationDatabase)
|
||||
{
|
||||
sqlite3 * source;
|
||||
if(sqlite3_open(sourceDatabase.c_str(), &source))
|
||||
{
|
||||
std::printf("Error opening source database %s\n", sourceDatabase.c_str());
|
||||
return 1;
|
||||
}
|
||||
|
||||
sqlite3 * destination;
|
||||
if(sqlite3_open(destinationDatabase.c_str(), &destination))
|
||||
{
|
||||
std::printf("Error opening destination database %s\n", destinationDatabase.c_str());
|
||||
return 1;
|
||||
}
|
||||
|
||||
Transaction transaction(destination);
|
||||
auto const sqlResult = sqlite3_exec(
|
||||
source,
|
||||
"SELECT Date, MAX(KilowattHour) AS KilowattHour FROM SolarPanelOutput GROUP BY Date;",
|
||||
SourceCallback,
|
||||
&transaction,
|
||||
nullptr);
|
||||
if(sqlResult)
|
||||
{
|
||||
std::printf("Error %i during fetching of source database records\n", sqlResult);
|
||||
return 1;
|
||||
}
|
||||
|
||||
std::printf("UpdateSummaryTable: Upserting %u records...\n", transaction.StatementCount());
|
||||
auto const commitResult = transaction.Execute();
|
||||
if(commitResult)
|
||||
{
|
||||
std::printf("Error %i when committing transaction\n", commitResult);
|
||||
return 1;
|
||||
}
|
||||
|
||||
sqlite3_close(source);
|
||||
sqlite3_close(destination);
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
35
src/migrator/transaction.cpp
Normal file
35
src/migrator/transaction.cpp
Normal file
@@ -0,0 +1,35 @@
|
||||
#include <migrator/transaction.hpp>
|
||||
|
||||
void Transaction::Reset()
|
||||
{
|
||||
queryStream.clear();
|
||||
queryStream.str(std::string());
|
||||
queryCount = 0u;
|
||||
|
||||
queryStream << "PRAGMA journal_mode = OFF;"
|
||||
<< "BEGIN TRANSACTION;";
|
||||
}
|
||||
|
||||
void Transaction::AddStatement(std::string const & statement)
|
||||
{
|
||||
++queryCount;
|
||||
queryStream << statement;
|
||||
}
|
||||
|
||||
unsigned Transaction::StatementCount() const { return queryCount; }
|
||||
|
||||
int Transaction::Execute()
|
||||
{
|
||||
queryStream << "COMMIT;";
|
||||
|
||||
auto const result = sqlite3_exec(destination, queryStream.str().c_str(), nullptr, nullptr, nullptr);
|
||||
Reset();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
Transaction::Transaction(sqlite3 * const databaseToInsertIn)
|
||||
: destination(databaseToInsertIn), queryCount(0u), queryStream()
|
||||
{
|
||||
Reset();
|
||||
}
|
||||
53
src/solar-logger/database.cpp
Normal file
53
src/solar-logger/database.cpp
Normal file
@@ -0,0 +1,53 @@
|
||||
#include <cstdlib>
|
||||
#include <ctime>
|
||||
#include <iomanip>
|
||||
#include <solar/logger/database.hpp>
|
||||
#include <sstream>
|
||||
#include <stdexcept>
|
||||
#include <util/date.hpp>
|
||||
|
||||
std::string Database::ToSqlInsertStatement(Row const & row) const
|
||||
{
|
||||
std::stringstream ss;
|
||||
ss << "INSERT INTO SolarPanelOutput VALUES(" << '\'' << Util::GetSqliteDate(row.epochTime) << "'," << '\''
|
||||
<< Util::GetSqliteUtcTime(row.epochTime) << "'," << row.watt << ", " << std::fixed << std::setprecision(2)
|
||||
<< row.kilowattPerHour << ");";
|
||||
|
||||
return ss.str();
|
||||
}
|
||||
|
||||
std::string Database::ToSqlUpsertStatement(Row const & row) const
|
||||
{
|
||||
std::stringstream ss;
|
||||
ss << "INSERT INTO SolarPanelSummary(Date,KilowattHour) VALUES(" << '\'' << Util::GetSqliteDate(row.epochTime)
|
||||
<< "'," << std::fixed << std::setprecision(2) << row.kilowattPerHour << ')'
|
||||
<< "ON CONFLICT(Date) DO UPDATE SET KilowattHour = excluded.KilowattHour WHERE excluded.KilowattHour > SolarPanelSummary.KilowattHour;";
|
||||
|
||||
return ss.str();
|
||||
}
|
||||
|
||||
bool Database::Insert(Row & row)
|
||||
{
|
||||
std::stringstream transaction;
|
||||
transaction << "BEGIN TRANSACTION;" << ToSqlInsertStatement(row) << ToSqlUpsertStatement(row) << "COMMIT;";
|
||||
|
||||
return sqlite3_exec(connectionPtr, transaction.str().c_str(), nullptr, nullptr, nullptr);
|
||||
}
|
||||
|
||||
Database::Database(std::string const & databasePath) : connectionPtr(nullptr)
|
||||
{
|
||||
if(sqlite3_open(databasePath.c_str(), &connectionPtr) != SQLITE_OK)
|
||||
{
|
||||
throw std::runtime_error("Error opening SQLite3 database");
|
||||
}
|
||||
|
||||
sqlite3_extended_result_codes(connectionPtr, 1);
|
||||
}
|
||||
|
||||
Database::~Database()
|
||||
{
|
||||
if(connectionPtr)
|
||||
{
|
||||
sqlite3_close(connectionPtr);
|
||||
}
|
||||
}
|
||||
60
src/solar-logger/main.cpp
Normal file
60
src/solar-logger/main.cpp
Normal file
@@ -0,0 +1,60 @@
|
||||
#include <cstdio>
|
||||
#include <iomanip>
|
||||
#include <solar/logger/database.hpp>
|
||||
#include <solar/logger/zeverdata.hpp>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <tclap/CmdLine.h>
|
||||
|
||||
int main(int argc, char ** argv)
|
||||
{
|
||||
TCLAP::CmdLine cmd(
|
||||
"solar-logger is a small program that retrieves solarpower generation statistics from Zeverlution Sxxxx smart power inverters and stores it into a database",
|
||||
' ',
|
||||
"1.0");
|
||||
|
||||
TCLAP::ValueArg<std::string> urlArg(
|
||||
"u",
|
||||
"url",
|
||||
"Fully qualified URL path and protocol to the home.cgi resource",
|
||||
true,
|
||||
"",
|
||||
"Fully qualified URL path and protocol to the home.cgi resource");
|
||||
cmd.add(urlArg);
|
||||
|
||||
TCLAP::ValueArg<unsigned int>
|
||||
timeoutArg("t", "timeout", "Fetch time out in milliseconds", false, 1000U, "Fetch time out in milliseconds");
|
||||
cmd.add(timeoutArg);
|
||||
|
||||
TCLAP::ValueArg<std::string> databasePathArg(
|
||||
"d",
|
||||
"database-path",
|
||||
"Absolute path pointing to the solar SQLite *.db file",
|
||||
true,
|
||||
"",
|
||||
"Absolute path pointing to the solar SQLite *.db file");
|
||||
cmd.add(databasePathArg);
|
||||
|
||||
cmd.parse(argc, argv);
|
||||
|
||||
ZeverData zeverData;
|
||||
if(!zeverData.FetchDataFromURL(urlArg.getValue(), timeoutArg.getValue()))
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
Row row;
|
||||
row.epochTime = std::time(nullptr); // now
|
||||
row.watt = zeverData.watt;
|
||||
row.kilowattPerHour = zeverData.kilowattPerHour;
|
||||
|
||||
Database db(databasePathArg.getValue());
|
||||
auto const insertionResult = db.Insert(row);
|
||||
if(insertionResult)
|
||||
{
|
||||
std::printf("Error %i during insertion of new value into database\n", insertionResult);
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
88
src/solar-logger/zeverdata.cpp
Normal file
88
src/solar-logger/zeverdata.cpp
Normal file
@@ -0,0 +1,88 @@
|
||||
#include <solar/logger/zeverdata.hpp>
|
||||
|
||||
namespace detail
|
||||
{
|
||||
// libcurl callback
|
||||
size_t write_to_string(void * ptr, size_t size, size_t nmemb, void * stream)
|
||||
{
|
||||
std::string line((char *)ptr, nmemb);
|
||||
std::string * buffer = (std::string *)stream;
|
||||
buffer->append(line);
|
||||
|
||||
return nmemb * size;
|
||||
}
|
||||
}
|
||||
|
||||
bool ZeverData::ParseString(const std::string & str)
|
||||
{
|
||||
bool isValid = true;
|
||||
std::stringstream ss;
|
||||
ss.str(str);
|
||||
|
||||
ss >> number0;
|
||||
ss >> number1;
|
||||
ss >> registeryID;
|
||||
ss >> registeryKey;
|
||||
ss >> hardwareVersion;
|
||||
std::string versionInfo;
|
||||
ss >> versionInfo;
|
||||
size_t splitPos = versionInfo.find('+');
|
||||
appVersion = versionInfo.substr(0, splitPos);
|
||||
++splitPos;
|
||||
wifiVersion = versionInfo.substr(splitPos, versionInfo.size());
|
||||
|
||||
ss >> timeValue;
|
||||
ss >> dateValue;
|
||||
ss >> zeverCloudStatus;
|
||||
ss >> number3;
|
||||
ss >> inverterSN;
|
||||
isValid = (ss >> watt && ss >> kilowattPerHour);
|
||||
ss >> OKmsg;
|
||||
ss >> ERRORmsg;
|
||||
|
||||
if(!isValid)
|
||||
{
|
||||
std::fprintf(stderr, "Error during parsing of zever data:\n%s\n", str.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ZeverData::FetchDataFromURL(const std::string & url, const unsigned int timeout)
|
||||
{
|
||||
curl_global_init(CURL_GLOBAL_ALL);
|
||||
|
||||
CURL * curl = curl_easy_init();
|
||||
|
||||
curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 1L);
|
||||
//curl_easy_setopt(curl, CURLOPT_VERBOSE, 1L);
|
||||
|
||||
std::string buffer;
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, detail::write_to_string);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &buffer);
|
||||
|
||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, timeout);
|
||||
|
||||
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
|
||||
|
||||
buffer.reserve(256);
|
||||
if(curl_easy_perform(curl))
|
||||
{
|
||||
watt = 0;
|
||||
kilowattPerHour = 0.0;
|
||||
|
||||
std::fprintf(stderr, "Failed to fetch zever data from URL <%s>!\n", url.c_str());
|
||||
|
||||
curl_easy_cleanup(curl);
|
||||
return false;
|
||||
}
|
||||
|
||||
buffer.shrink_to_fit();
|
||||
|
||||
curl_easy_cleanup(curl);
|
||||
|
||||
return ParseString(buffer);
|
||||
}
|
||||
|
||||
ZeverData::ZeverData() : watt(0), kilowattPerHour(0.0) { }
|
||||
93
src/solar-server/api.cpp
Normal file
93
src/solar-server/api.cpp
Normal file
@@ -0,0 +1,93 @@
|
||||
#include <solar/server/api.hpp>
|
||||
#include <solar/server/configuration.hpp>
|
||||
#include <util/date.hpp>
|
||||
|
||||
namespace Api
|
||||
{
|
||||
using Pistache::Http::Mime::Subtype;
|
||||
using Pistache::Http::Mime::Type;
|
||||
|
||||
Util::Date ParseUtcDate(std::string const & date, Util::Date const & fallbackValue)
|
||||
{
|
||||
Util::Date result;
|
||||
if(!result.TryParse(date))
|
||||
{
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
Util::Date ParseUtcDate(Pistache::Optional<std::string> const & date, Util::Date const & fallbackValue)
|
||||
{
|
||||
if(date.isEmpty() || date.unsafeGet().size() != 10)
|
||||
{
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
return ParseUtcDate(date.unsafeGet(), fallbackValue);
|
||||
}
|
||||
|
||||
void GetDay(Pistache::Http::Request const & request, Pistache::Http::ResponseWriter responseWrite)
|
||||
{
|
||||
Util::Date const start = ParseUtcDate(request.query().get("start"), Util::Date::UtcNow());
|
||||
auto const stopQuery = request.query().get("stop");
|
||||
if(stopQuery.isEmpty())
|
||||
{
|
||||
responseWrite.send(
|
||||
Pistache::Http::Code::Ok,
|
||||
Configuration::Get().GetDatabaseConnection().GetEntireDay(start),
|
||||
MIME(Application, Json));
|
||||
return;
|
||||
}
|
||||
|
||||
Util::Date const stop = ParseUtcDate(request.query().get("stop"), start);
|
||||
if(!start.IsBefore(stop))
|
||||
{
|
||||
responseWrite.send(Pistache::Http::Code::Bad_Request);
|
||||
return;
|
||||
}
|
||||
|
||||
responseWrite.send(
|
||||
Pistache::Http::Code::Ok,
|
||||
Configuration::Get().GetDatabaseConnection().GetSummarizedPerDayRecords(start, stop),
|
||||
MIME(Application, Json));
|
||||
}
|
||||
|
||||
void GetMonth(Pistache::Http::Request const & request, Pistache::Http::ResponseWriter responseWrite)
|
||||
{
|
||||
auto const startQuery = request.query().get("start");
|
||||
auto const stopQuery = request.query().get("stop");
|
||||
if(startQuery.isEmpty() || stopQuery.isEmpty())
|
||||
{
|
||||
responseWrite.send(Pistache::Http::Code::Bad_Request);
|
||||
return;
|
||||
}
|
||||
|
||||
auto const start = Util::Date(startQuery.unsafeGet());
|
||||
auto stop = Util::Date(stopQuery.unsafeGet());
|
||||
if(!start.IsValid() || !stop.IsValid())
|
||||
{
|
||||
responseWrite.send(Pistache::Http::Code::Bad_Request);
|
||||
return;
|
||||
}
|
||||
|
||||
stop.SetDayToEndOfMonth();
|
||||
if(stop.IsBefore(start))
|
||||
{
|
||||
responseWrite.send(Pistache::Http::Code::Bad_Request);
|
||||
return;
|
||||
}
|
||||
|
||||
responseWrite.send(
|
||||
Pistache::Http::Code::Ok,
|
||||
Configuration::Get().GetDatabaseConnection().GetSummarizedPerMonthRecords(start, stop),
|
||||
MIME(Application, Json));
|
||||
}
|
||||
|
||||
void SetupRouting(Pistache::Rest::Router & router)
|
||||
{
|
||||
Pistache::Rest::Routes::Get(router, "/day", Pistache::Rest::Routes::bind(&Api::GetDay));
|
||||
Pistache::Rest::Routes::Get(router, "/month", Pistache::Rest::Routes::bind(&Api::GetMonth));
|
||||
}
|
||||
}
|
||||
24
src/solar-server/configuration.cpp
Normal file
24
src/solar-server/configuration.cpp
Normal file
@@ -0,0 +1,24 @@
|
||||
#include <filesystem>
|
||||
#include <solar/server/configuration.hpp>
|
||||
#include <stdexcept>
|
||||
|
||||
Configuration::Configuration() : database() { }
|
||||
|
||||
Configuration & Configuration::SetupDatabase(std::string const & filePath)
|
||||
{
|
||||
if(!database.Connect(filePath))
|
||||
{
|
||||
throw std::runtime_error("Cannot open SQLite database at " + filePath);
|
||||
}
|
||||
|
||||
return *this;
|
||||
}
|
||||
|
||||
Database::Connection Configuration::GetDatabaseConnection() const { return database.GetConnection(); }
|
||||
|
||||
Configuration & Configuration::Get()
|
||||
{
|
||||
static Configuration c;
|
||||
|
||||
return c;
|
||||
}
|
||||
241
src/solar-server/database/connection.cpp
Normal file
241
src/solar-server/database/connection.cpp
Normal file
@@ -0,0 +1,241 @@
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <iomanip>
|
||||
#include <solar/server/database/connection.hpp>
|
||||
#include <sstream>
|
||||
#include <vector>
|
||||
|
||||
namespace Database
|
||||
{
|
||||
Connection::Connection(sqlite3 * const databaseConnectionPtr) : connectionPtr(databaseConnectionPtr) { }
|
||||
|
||||
class JsonResult {
|
||||
private:
|
||||
long dateEpoch;
|
||||
std::stringstream jsonStream;
|
||||
unsigned insertions;
|
||||
|
||||
void Reset()
|
||||
{
|
||||
insertions = 0u;
|
||||
|
||||
jsonStream.clear();
|
||||
jsonStream.str(std::string());
|
||||
jsonStream << std::fixed << std::setprecision(2);
|
||||
jsonStream << '[';
|
||||
}
|
||||
|
||||
public:
|
||||
bool AddRecord(long const epoch, char const * watt, char const * kwh)
|
||||
{
|
||||
++insertions;
|
||||
jsonStream << "{\"time\":" << epoch << ",\"watt\":" << watt << ",\"kwh\":" << kwh << "},";
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool AddRecord(char const * timeUtc, char const * watt, char const * kwh)
|
||||
{
|
||||
auto const timeInSeconds = Util::ToSecondsFromTimeString(timeUtc);
|
||||
if(timeInSeconds < 0)
|
||||
{
|
||||
std::printf("AddRecord: cannot parse %s to hours, minutes and seconds\n", timeUtc);
|
||||
return false;
|
||||
}
|
||||
|
||||
return AddRecord(timeInSeconds + dateEpoch, watt, kwh);
|
||||
}
|
||||
|
||||
bool
|
||||
AddRecord(unsigned const year, unsigned const month, unsigned const day, char const * watt, char const * kwh)
|
||||
{
|
||||
return AddRecord(Util::GetEpoch(year, month, day), watt, kwh);
|
||||
}
|
||||
|
||||
// Returns the records added thus far as JSON array and resets
|
||||
// itself to an empty array afterwards.
|
||||
std::string GetJsonArray()
|
||||
{
|
||||
if(insertions)
|
||||
{
|
||||
// Replace last inserted comma
|
||||
jsonStream.seekp(-1, std::ios_base::end);
|
||||
jsonStream << ']';
|
||||
}
|
||||
else
|
||||
{
|
||||
jsonStream << ']';
|
||||
}
|
||||
|
||||
auto const result = jsonStream.str();
|
||||
Reset();
|
||||
return result;
|
||||
}
|
||||
|
||||
JsonResult(long _dateEpoch) : dateEpoch(_dateEpoch), jsonStream(), insertions(0u) { Reset(); }
|
||||
};
|
||||
|
||||
int DetailedCallback(void * data, int argc, char ** argv, char ** columnNames)
|
||||
{
|
||||
// TimeUtc, Watt and KilowattHour
|
||||
if(argc != 3)
|
||||
{
|
||||
std::printf("DetailedCallback: unexpected number of arguments %i\n", argc);
|
||||
return -1;
|
||||
}
|
||||
|
||||
JsonResult * result = reinterpret_cast<JsonResult *>(data);
|
||||
result->AddRecord(argv[0], argv[1], argv[2]);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
std::string Connection::GetEntireDay(Util::Date const & date)
|
||||
{
|
||||
std::stringstream queryStream;
|
||||
queryStream << "SELECT TimeUtc, Watts, KilowattHour FROM SolarPanelOutput WHERE Date = " << '\''
|
||||
<< date.ToISOString() << '\'' << " ORDER BY TimeUtc ASC;";
|
||||
|
||||
JsonResult result(date.ToEpoch());
|
||||
auto const sqlResult
|
||||
= sqlite3_exec(connectionPtr, queryStream.str().c_str(), DetailedCallback, &result, nullptr);
|
||||
if(sqlResult)
|
||||
{
|
||||
std::printf("GetEntireDay: SQLite error code %i, returning empty JSON array.\n", sqlResult);
|
||||
return "[]";
|
||||
}
|
||||
|
||||
return result.GetJsonArray();
|
||||
}
|
||||
|
||||
int SummaryCallback(void * data, int argc, char ** argv, char ** columnNames)
|
||||
{
|
||||
// Date and KilowattHour
|
||||
if(argc != 2)
|
||||
{
|
||||
std::printf("SummaryCallback: unexpected number of arguments %i\n", argc);
|
||||
return -1;
|
||||
}
|
||||
|
||||
JsonResult * result = reinterpret_cast<JsonResult *>(data);
|
||||
Util::Date recordDate(argv[0]);
|
||||
result->AddRecord(recordDate.Year(), recordDate.Month(), recordDate.Day(), "0", argv[1]);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
std::string Connection::GetSummarizedPerDayRecords(Util::Date const & startDate, Util::Date const & endDate)
|
||||
{
|
||||
std::stringstream queryStream;
|
||||
queryStream << "SELECT Date, KilowattHour FROM SolarPanelSummary"
|
||||
<< " WHERE Date >= '" << startDate.ToISOString() << '\'' << " AND Date <= '"
|
||||
<< endDate.ToISOString() << '\'' << " ORDER BY Date;";
|
||||
|
||||
JsonResult result(0);
|
||||
auto const sqlResult
|
||||
= sqlite3_exec(connectionPtr, queryStream.str().c_str(), SummaryCallback, &result, nullptr);
|
||||
if(sqlResult)
|
||||
{
|
||||
std::printf("GetSummarizedPerDayRecords: SQLite error code %i, returning empty JSON array.\n", sqlResult);
|
||||
return "[]";
|
||||
}
|
||||
|
||||
return result.GetJsonArray();
|
||||
}
|
||||
|
||||
struct YearResult
|
||||
{
|
||||
int year;
|
||||
std::array<double, 12> monthValues;
|
||||
|
||||
// month is in range 1 to 12
|
||||
void AddMonthValue(int month, double value) { monthValues[month - 1] += value; }
|
||||
|
||||
YearResult(int _year) : year(_year), monthValues() { }
|
||||
};
|
||||
|
||||
int MonthSummaryCallback(void * data, int argc, char ** argv, char ** columnNames)
|
||||
{
|
||||
// Date and KilowattHour
|
||||
if(argc != 2)
|
||||
{
|
||||
std::printf("MonthSummaryCallback: unexpected number of arguments %i\n", argc);
|
||||
return -1;
|
||||
}
|
||||
|
||||
Util::Date recordDate;
|
||||
if(!recordDate.TryParse(argv[0]))
|
||||
{
|
||||
std::printf("MonthSummaryCallback: error parsing date %s\n", argv[0]);
|
||||
return -1;
|
||||
}
|
||||
|
||||
std::vector<YearResult> & yearResults = *reinterpret_cast<std::vector<YearResult> *>(data);
|
||||
double const kwh = std::atof(argv[1]);
|
||||
if(std::isnan(kwh) || kwh < 0.0)
|
||||
{
|
||||
// This value makes no sense, ignore it
|
||||
std::printf("MonthSummaryCallback: ignoring bogus value for year month %s\n", argv[1]);
|
||||
return 0;
|
||||
}
|
||||
|
||||
for(std::size_t i = 0; i < yearResults.size(); ++i)
|
||||
{
|
||||
if(yearResults[i].year == recordDate.Year())
|
||||
{
|
||||
yearResults[i].AddMonthValue(recordDate.Month(), kwh);
|
||||
return 0;
|
||||
}
|
||||
if(yearResults[i].year > recordDate.Year())
|
||||
{
|
||||
yearResults.insert(yearResults.begin() + i, YearResult(recordDate.Year()));
|
||||
yearResults[i].AddMonthValue(recordDate.Month(), kwh);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
yearResults.push_back(YearResult(recordDate.Year()));
|
||||
yearResults[yearResults.size() - 1].AddMonthValue(recordDate.Month(), kwh);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
std::string Connection::GetSummarizedPerMonthRecords(Util::Date const & startDate, Util::Date const & endDate)
|
||||
{
|
||||
std::stringstream queryStream;
|
||||
queryStream << "SELECT Date, KilowattHour FROM SolarPanelSummary"
|
||||
<< " WHERE Date >= '" << startDate.ToISOString() << '\'' << " AND Date <= '"
|
||||
<< endDate.ToISOString() << "';";
|
||||
|
||||
std::vector<YearResult> yearResults;
|
||||
auto const sqlResult
|
||||
= sqlite3_exec(connectionPtr, queryStream.str().c_str(), MonthSummaryCallback, &yearResults, nullptr);
|
||||
if(sqlResult || yearResults.size() == 0)
|
||||
{
|
||||
std::printf(
|
||||
"GetSummarizedPerMonthRecords: SQLite return code %i and %lu years retrieved, returning empty JSON array.\n",
|
||||
sqlResult,
|
||||
yearResults.size());
|
||||
return "[]";
|
||||
}
|
||||
|
||||
JsonResult result(0);
|
||||
for(std::size_t i = 0; i < yearResults.size(); ++i)
|
||||
{
|
||||
auto const year = yearResults[i].year;
|
||||
for(int month = 0; month < yearResults[i].monthValues.size(); ++month)
|
||||
{
|
||||
if(startDate.IsAfter(year, month + 1, 1) || endDate.IsBefore(year, month + 1, 1))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
auto const epoch = Util::GetEpoch(year, month + 1, 1);
|
||||
auto const kwh = yearResults[i].monthValues[month];
|
||||
result.AddRecord(epoch, "0", std::to_string(kwh).c_str());
|
||||
}
|
||||
}
|
||||
|
||||
return result.GetJsonArray();
|
||||
}
|
||||
}
|
||||
33
src/solar-server/database/database.cpp
Normal file
33
src/solar-server/database/database.cpp
Normal file
@@ -0,0 +1,33 @@
|
||||
#include <solar/server/database/database.hpp>
|
||||
|
||||
namespace Database
|
||||
{
|
||||
bool Database::Connect(std::string const & path)
|
||||
{
|
||||
if(connectionPtr)
|
||||
{
|
||||
// Already connected
|
||||
return true;
|
||||
}
|
||||
|
||||
if(sqlite3_open(path.c_str(), &connectionPtr))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
sqlite3_extended_result_codes(connectionPtr, 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
Connection Database::GetConnection() const { return Connection(connectionPtr); }
|
||||
|
||||
Database::Database() : connectionPtr(nullptr) { }
|
||||
|
||||
Database::~Database()
|
||||
{
|
||||
if(connectionPtr)
|
||||
{
|
||||
sqlite3_close(connectionPtr);
|
||||
}
|
||||
}
|
||||
}
|
||||
42
src/solar-server/main.cpp
Normal file
42
src/solar-server/main.cpp
Normal file
@@ -0,0 +1,42 @@
|
||||
#include <pistache/endpoint.h>
|
||||
#include <solar/server/api.hpp>
|
||||
#include <solar/server/configuration.hpp>
|
||||
#include <tclap/CmdLine.h>
|
||||
|
||||
int main(int argc, char ** argv)
|
||||
{
|
||||
TCLAP::CmdLine cmd(
|
||||
"solar-server is a small Pistache based HTTP content server with a REST API to access the solar log database",
|
||||
' ',
|
||||
"1.0.0");
|
||||
|
||||
TCLAP::ValueArg<unsigned>
|
||||
listeningPortArg("p", "listening-port", "TCP listening port number", true, 0u, "TCP listening port number");
|
||||
cmd.add(listeningPortArg);
|
||||
|
||||
TCLAP::ValueArg<std::string> databaseFileArg(
|
||||
"d",
|
||||
"database-file",
|
||||
"Absolute path pointing to the solar SQLite *.db file",
|
||||
true,
|
||||
"",
|
||||
"Absolute path pointing to the solar SQLite *.db file");
|
||||
cmd.add(databaseFileArg);
|
||||
|
||||
cmd.parse(argc, argv);
|
||||
|
||||
Configuration & config = Configuration::Get();
|
||||
config.SetupDatabase(databaseFileArg.getValue());
|
||||
|
||||
Pistache::Address address(Pistache::Ipv4::any(), listeningPortArg.getValue());
|
||||
Pistache::Http::Endpoint server(address);
|
||||
|
||||
auto options = Pistache::Http::Endpoint::options().threads(2);
|
||||
server.init(options);
|
||||
|
||||
Pistache::Rest::Router router;
|
||||
Api::SetupRouting(router);
|
||||
server.setHandler(router.handler());
|
||||
|
||||
server.serve();
|
||||
}
|
||||
Reference in New Issue
Block a user