Add envoy support to solar logger

This commit is contained in:
2022-08-15 13:14:16 +02:00
parent 642170172a
commit 35e6ec08d7
16 changed files with 355 additions and 309 deletions

View File

@@ -2,15 +2,6 @@
A mono repository containing all the homebrew collectors and REST APIs running on the local server. See it in action [here](https://valkendaal.duckdns.org).
## Contents
This repository houses various projects. All are listed below.
- The Zeverlution S3000 logger, [documentation](docs/SOLAR_LOGGER.md)
- The Landis Gyr E350 logger, [documentation](docs/ELECTRICITY_LOGGER.md)
- Two pistache REST HTTP APIs, [documentation](docs/SERVER.md)
- A solar panel log server, [documentation](docs/SOLAR_API.md)
- An electricity log server, [documentation](docs/ELECTRICITY_API.md)
## Project Directory
@@ -19,10 +10,6 @@ This repository houses various projects. All are listed below.
|- `include` => A folder housing all the header files used by the source files in `src/` </br>
|- `script` => A folder with handy bash scripts to create and migrate databases </br>
|- `src` => A folder with all the source files of the different projects that require compilation </br>
|....|- `electricity-logger` => The Landis Gyr E350 logger source files </br>
|....|- `electricity-server` => The pistache REST API sources for serving the electricity logs </br>
|....|- `solar-logger` => The Zeverlution S3000 logger source files </br>
|....|- `solar-server` => The pistache REST API sources for serving the solar panel logs </br>
|- `systemd` => A folder with example systemd service files for the servers </br>
## Miscellaneous
@@ -33,16 +20,17 @@ A few benchmarks have been done for the solar-server project, which can be found
### Runtime (server)
- .NET 6, [website](https://dotnet.microsoft.com/en-us/download/dotnet/6.0)
- Curl: the multiprotocol file tranfser library, [website](https://curl.haxx.se/libcurl/)
- cxxopts, [website](https://github.com/jarro2783/cxxopts/tree/v3.0.0)
- Docker CE, [website](https://www.docker.com/)
- GNU Make, [website](https://www.gnu.org/software/make/)
- Pistache: an elegant C++ REST framework, [website](http://pistache.io/)
- Spdlog, [website](https://github.com/gabime/spdlog)
- Sqlite3: a small SQL database engine, [website](https://www.sqlite.org/index.html)
- Sqlite3: a small SQL database engine, v3.39.2, [website](https://www.sqlite.org/index.html)
### Development
In addition to all the runtime dependencies the following dependencies are required for development:
- A C++20 compatible compiler
- Nlohmann JSON, [website](https://github.com/nlohmann/json/tree/v3.11.2)
- cxxopts, [website](https://github.com/jarro2783/cxxopts/tree/v3.0.0)

View File

@@ -1,37 +0,0 @@
# The electricity-server REST API
This project depends on the `pistache` HTTP framework, see their [website](http://pistache.io/) for more info.
## Endpoints
All endpoints respond in JSON. Examples can be found in the Examples section. Dates are always in ISO format, meaning they take the shape of `year-month-day` with leading zeroes for month and day. Example: `2019-01-11` is the eleventh day of January, 2019.
- `/day?start=[yyyy-MM-dd]` This tells the server what date to serve all the collected records from the specified day.
- `/day?start=[yyyy-MM-dd]&stop=[yyyy-MM-dd]` This tells the server what date range to serve a per day summary for.
### Example Response /day?start=2020-01-10
Will give you as many objects as there are recorded values for the 10th of January, 2020. Ordered from earliest to latest. If there are no records for the given day an empty array is returned.
```json
[
{
"dateTime":1578614401,
"totalPowerUse":3282.95,
"totalPowerReturn":2790.88,
"totalGasUse":3769.23
},
...
]
```
### Example Response /day?start=2020-01-01&stop=2020-01-19
Will give you a summary for each recorded day in the range 1st of January 2020 to the 19th of January 2020. If no logs exist for the day on the server it will send an object with a valid dateTime for that day but with 0 values for the other fields.
```json
[
{
"dateTime":1578614401,
"totalPowerUse": 8.324,
"totalPowerReturn": 3.62,
"totalGasUse": 11.05
},
...
]
```

View File

@@ -1,22 +0,0 @@
# About
This software targets linux and has been tested on both x86_64 and armv7 hardware.
## solar-logger
Simple data collecting program for ZeverSolar's Zeverlution Sxxxx "smart" inverters with a network interface (referred to as "combox" by the manufacturer). It collects all the exposed data of the Zeverlution, including current power generation (watts) and today's cummulative power production (kilowatt/hours).
### Strange output
Zeverlution Smart Inverters currently have a bug that causes leading zeroes in a decimal number to be ignored. Concretely this means that the value 0.01 becomes 0.10, 3.09 becomes 3.9, etcetera. This is causing strange peaks in the logged data where sequences go from 2.80 to 2.90 to 2.10.
Another bug is the inverter turning itself off and back on again throughout the day. This happens if the yield get too low. This will cause the cumulative power to reset to zero (kilowatt per hour).
# Building
## Dependencies
### solar-logger
Dependencies for this program are:
- `libcurl` which can be installed using your package manager. It has been tested with the `openSSL` flavour, libcurl version 4.
- `sqlite3` which is sometimes also referred to as `libsqlite3-dev`.
## Building
The logger program can be build by running the makefile (`make all`) in the project root. It will create a `bin` folder where it puts the newly created binaries. Move these to wherever you keep your binaries or use the `install` make target.
Refer to the `--help` switch to find out more about supported launch parameters.

View File

@@ -3,22 +3,29 @@
#include <sqlite3.h>
#include <string>
struct Row
struct ZeverRow
{
time_t epochTime;
std::int32_t watt;
double kilowattPerHour;
};
struct EnvoyRow
{
time_t epochTime;
std::int32_t inverterCount;
std::int32_t currentWatt;
std::int64_t lifetimeWattHour;
std::int64_t inverterTime;
};
class Database {
private:
sqlite3 * connectionPtr;
std::string ToSqlInsertStatement(Row const & row) const;
std::string ToSqlUpsertStatement(Row const & row) const;
public:
bool Insert(Row & row);
bool Insert(ZeverRow & row);
bool Insert(EnvoyRow & row);
Database(std::string const & databasePath);
~Database();

View File

@@ -0,0 +1,14 @@
#pragma once
#include <optional>
#include <string>
struct EnvoyData
{
std::string type;
std::int32_t activeCount;
std::int64_t readingTime;
std::int32_t wNow;
std::int64_t whLifetime;
static std::optional<EnvoyData> ParseJson(std::string const & json);
};

View File

@@ -2,6 +2,7 @@
#include <cstdio>
#include <ctime>
#include <curl/curl.h> // tested with libcurl4-openSSH
#include <optional>
#include <sstream>
#include <string>
@@ -25,9 +26,7 @@ struct ZeverData
std::string OKmsg, ERRORmsg;
// Parses the data string coming from the zeverlution home.cgi webpage
bool ParseString(const std::string & str);
bool FetchDataFromURL(const std::string & url, const unsigned int timeout);
static std::optional<ZeverData> ParseString(const std::string & str);
ZeverData();
};

View File

@@ -1,7 +1,7 @@
CC = g++
CFLAGS = -DSPDLOG_FMT_EXTERNAL -Wall -Wextra -std=c++20 -O2 -Iinclude/ -MMD
ELECTRICITY_LOGGER_LFLAGS = -lsqlite3 -lspdlog -lfmt
SOLAR_LOGGER_LFLAGS = -lsqlite3 -lcurl
SOLAR_LOGGER_LFLAGS = -lsqlite3 -lcurl -lspdlog -lfmt
SERVER_LFLAGS = -lpistache -lsqlite3 -lstdc++fs -lspdlog -lfmt
SOLAR_LOGGER_CPPS = $(shell find src/solar-logger/ -name *.cpp)
@@ -16,21 +16,16 @@ ELECT_LOGGER_CPPS = $(shell find src/electricity-logger/ -name *.cpp)
ELECT_LOGGER_OBJS = $(patsubst src/%.cpp, build/%.o, ${ELECT_LOGGER_CPPS})
ELECT_LOGGER_DEPS = $(patsubst src/%.cpp, build/%.d, ${ELECT_LOGGER_CPPS})
ELECT_SRV_CPPS = $(shell find src/electricity-server/ -name *.cpp)
ELECT_SRV_OBJS = $(patsubst src/%.cpp, build/%.o, ${ELECT_SRV_CPPS})
ELECT_SRV_DEPS = $(patsubst src/%.cpp, build/%.d, ${ELECT_SRV_CPPS})
SOLAR_LOGGER_BINARY_PATH = bin/solar-logger
SOLAR_SRV_BINARY_PATH = bin/solar-server
ELECT_LOGGER_BINARY_PATH = bin/electricity-logger
ELECT_SRV_BINARY_PATH = bin/electricity-server
INSTALL_PATH = /usr/local/bin/
# Test variables
SOLAR_TEST_DATABASE = solarpaneloutput.db
all: ${SOLAR_LOGGER_BINARY_PATH} ${SOLAR_SRV_BINARY_PATH} ${ELECT_LOGGER_BINARY_PATH} ${ELECT_SRV_BINARY_PATH}
all: ${SOLAR_LOGGER_BINARY_PATH} ${SOLAR_SRV_BINARY_PATH} ${ELECT_LOGGER_BINARY_PATH}
clean:
-rm -rf bin/* build/*
@@ -44,10 +39,7 @@ check-solar-server: ${SOLAR_SRV_BINARY_PATH} ${SOLAR_TEST_DATABASE}
check-electricity-logger: ${ELECT_LOGGER_BINARY_PATH}
./${ELECT_LOGGER_BINARY_PATH} -d /dev/ttyUSB0 -c additional_column_value
check-electricity-server: ${ELECT_SRV_BINARY_PATH}
./${ELECT_SRV_BINARY_PATH} -d /var/data0/electricity -p 8081 -s valkendaal.duckdns.org
install: install-loggers install-servers
install: install-loggers install-solar-server
install-loggers: ${SOLAR_LOGGER_BINARY_PATH} ${ELECT_LOGGER_BINARY_PATH}
cp -uv ${SOLAR_LOGGER_BINARY_PATH} ${INSTALL_PATH}
@@ -56,18 +48,12 @@ install-loggers: ${SOLAR_LOGGER_BINARY_PATH} ${ELECT_LOGGER_BINARY_PATH}
install-solar-server: ${SOLAR_SRV_BINARY_PATH}
cp -uv ${SOLAR_SRV_BINARY_PATH} ${INSTALL_PATH}
install-electricity-server: ${ELECT_SRV_BINARY_PATH}
cp -uv ${ELECT_SRV_BINARY_PATH} ${INSTALL_PATH}
install-servers: install-solar-server install-electricity-server
.PHONY: all \
clean \
check-solar-logger check-solar-server \
install \
install-loggers \
install-solar-server install-electricity-server \
install-servers
install-solar-server
${SOLAR_TEST_DATABASE}:
./script/createdb.sh ${SOLAR_TEST_DATABASE}
@@ -84,16 +70,10 @@ ${ELECT_LOGGER_BINARY_PATH}: ${ELECT_LOGGER_OBJS}
mkdir -p ${@D}
${CC} $^ ${ELECTRICITY_LOGGER_LFLAGS} -o $@
${ELECT_SRV_BINARY_PATH}: ${ELECT_SRV_OBJS}
mkdir -p ${@D}
${CC} $^ ${SERVER_LFLAGS} -o $@
build/%.o: src/%.cpp
mkdir -p ${@D}
${CC} ${CFLAGS} -c $< -o $@
-include ${SOLAR_SRV_DEPS}
-include ${SOLAR_LOGGER_DEPS}
-include ${MIGRATOR_DEPS}
-include ${ELECT_LOGGER_DEPS}
-include ${ELECT_SRV_DEPS}

View File

@@ -1,12 +0,0 @@
# Scripts
This directory contains the following 2 scripts:
- `createdb.sh` is used to create the SQL database file used by the logger
- `migratedb.sh` is used to fill the newly created SQL database with the old file based logging content
The created database exists out of 3 rows, ignoring the `RowId` column:
- `DateTimeUtc` is an `INTEGER` and represents the UTC date and time in the Unix epoch format
- `Watts` is an `INTEGER` and represents the power output at the time of logging
- `KilowattHour` is a `REAL` and represents the cumulative power generated that day
Try running each script without any arguments for more help or read their source code.

18
script/solar/create.sql Normal file
View File

@@ -0,0 +1,18 @@
BEGIN TRANSACTION;
-- Create Zever tables
CREATE TABLE ZeverLogs (Id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, Date TEXT NOT NULL, TimeUtc TEXT NOT NULL, CurrentWatts INTEGER NOT NULL, TotalWatts BIGINT NOT NULL);
CREATE INDEX idx_ZeverLogs_Date ON ZeverLogs (Date);
CREATE INDEX idx_ZeverLogs_TimeUtc ON ZeverLogs (TimeUtc);
CREATE INDEX idx_ZeverLogs_TotalWatts ON ZeverLogs (TotalWatts);
CREATE TABLE ZeverSummary (Id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, Date TEXT NOT NULL UNIQUE, TotalWatts BIGINT NOT NULL);
CREATE UNIQUE INDEX idx_ZeverSummary_Date on ZeverSummary (Date);
-- Create Envoy table
CREATE TABLE EnvoyLogs (Id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, Date TEXT NOT NULL, TimeUtc TEXT NOT NULL, CurrentWatts INTEGER NOT NULL, TotalWatts BIGINT NOT NULL, Inverters INTEGER NOT NULL);
CREATE INDEX idx_EnvoyLogs_Date ON EnvoyLogs (Date);
CREATE INDEX idx_EnvoyLogs_TimeUtc ON EnvoyLogs (TimeUtc);
CREATE INDEX idx_EnvoyLogs_TotalWatts ON EnvoyLogs (TotalWatts);
COMMIT;

View File

@@ -1,30 +0,0 @@
#!/bin/bash
if [ "$#" -ne 1 ]; then
echo "Usage: $0 [/path/to/db/database].db";
exit 1;
fi
echo "Creating database file $1...";
touch "$1";
if ! [ -f "$1" ]; then
echo "Cannot open or create database file $1, aborting.";
exit 1;
fi
TABLE_NAME="SolarPanelOutput";
echo "Creating table $TABLE_NAME...";
sqlite3 $1 "CREATE TABLE IF NOT EXISTS $TABLE_NAME (Date TEXT NOT NULL, TimeUtc TEXT NOT NULL, Watts INTEGER NOT NULL, KilowattHour REAL NOT NULL);";
echo "Creating indexes on table $TABLE_NAME...";
sqlite3 $1 "CREATE INDEX IF NOT EXISTS idx_Date ON $TABLE_NAME (Date);"
sqlite3 $1 "CREATE INDEX IF NOT EXISTS idx_TimeUtc ON $TABLE_NAME (TimeUtc);"
TABLE2_NAME="SolarPanelSummary";
echo "Creating table $TABLE2_NAME...";
sqlite3 $1 "CREATE TABLE IF NOT EXISTS $TABLE2_NAME (Date TEXT NOT NULL UNIQUE, Kilowatthour REAL NOT NULL);"
echo "Creating indexes on table $TABLE2_NAME";
sqlite3 $1 "CREATE UNIQUE INDEX IF NOT EXISTS idx_Date_$TABLE2_NAME on $TABLE2_NAME (Date);"

31
script/solar/migrate.sql Normal file
View File

@@ -0,0 +1,31 @@
BEGIN TRANSACTION;
-- Alter metric columns
ALTER TABLE SolarPanelOutput RENAME COLUMN Watts TO CurrentWatts;
ALTER TABLE SolarPanelOutput ADD COLUMN TotalWatts BIGINT;
UPDATE SolarPanelOutput SET TotalWatts = CAST(ROUND(KilowattHour * 1000) AS BIGINT);
ALTER TABLE SolarPanelOutput DROP COLUMN KilowattHour;
ALTER TABLE SolarPanelSummary ADD COLUMN TotalWatts BIGINT;
UPDATE SolarPanelSummary SET TotalWatts = CAST(ROUND(Kilowatthour * 1000) AS BIGINT);
ALTER TABLE SolarPanelSummary DROP COLUMN Kilowatthour;
-- Optimize indice
CREATE INDEX idx_TotalWatts ON SolarPanelOutput (TotalWatts);
-- Fix date bug
UPDATE SolarPanelOutput SET Date = '2018-02-02' WHERE Date = '18-02-02';
-- Remove bogus data
DELETE FROM SolarPanelOutput WHERE Date = '2018-02-02' AND TimeUtc = '08:15:10';
DELETE FROM SolarPanelOutput WHERE Date = '2018-01-02' AND TimeUtc = '13:31:03';
-- Copy data
INSERT INTO ZeverLogs (Date, TimeUtc, CurrentWatts, TotalWatts) SELECT * FROM SolarPanelOutput;
INSERT INTO ZeverSummary (Date, TotalWatts) SELECT * FROM SolarPanelSummary;
-- Delete old table
DROP TABLE SolarPanelOutput;
DROP TABLE SolarPanelSummary;
COMMIT;

View File

@@ -1,41 +0,0 @@
#!/bin/bash
TABLE_NAME="SolarPanelOutput";
if [ "$#" -ne 2 ]; then
echo "Usage: $0 [/log/file/directory/] [/path/to/db/database].db";
fi
if ! [ -d "$1" ]; then
echo "Error opening log file directory $1, aborting.";
exit 1;
fi
if ! [ -f "$2" ]; then
echo "Error opening database file $2, aborting.";
exit 1;
fi
if [ "${$1}" != */ ]; then
$1 = "$1/";
fi
for DIRECTORY in $1*/; do
if [ -d "$DIRECTORY" ]; then
echo "Checking out directory $DIRECTORY...";
for FILE in $DIRECTORY*; do
if [ -f "$FILE" ]; then
echo "Processing file $FILE...";
while read line; do
PROCESSED_LINE="$(echo "$line" | tr -s ' ')";
ISODATE="$(echo "$PROCESSED_LINE" | cut -d ' ' -f1)";
WATT="$(echo "$PROCESSED_LINE" | cut -d ' ' -f2)";
KILOWATTHR="$(echo "$PROCESSED_LINE" | cut -d ' ' -f3)";
EPOCHDATE="$(date -d"$ISODATE" +%s)"
sqlite3 $2 "INSERT INTO $TABLE_NAME VALUES($EPOCHDATE, $WATT, $KILOWATTHR);";
done < "$FILE"
fi
done;
fi
done

View File

@@ -1,3 +1,4 @@
#include <cmath>
#include <cstdlib>
#include <ctime>
#include <iomanip>
@@ -6,27 +7,37 @@
#include <stdexcept>
#include <util/date.hpp>
std::string Database::ToSqlInsertStatement(Row const & row) const
std::string ToSqlInsertStatement(ZeverRow const & row)
{
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 << ");";
ss << "INSERT INTO ZeverLogs VALUES(" << '\'' << Util::GetSqliteDate(row.epochTime) << "'," << '\''
<< Util::GetSqliteUtcTime(row.epochTime) << "'," << row.watt << ','
<< static_cast<std::int64_t>(round(row.kilowattPerHour * 1000)) << ");";
return ss.str();
}
std::string Database::ToSqlUpsertStatement(Row const & row) const
std::string ToSqlInsertStatement(EnvoyRow const & row)
{
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;";
ss << "INSERT INTO EnvoyLogs VALUES(" << '\'' << Util::GetSqliteDate(row.epochTime) << "'," << '\''
<< Util::GetSqliteUtcTime(row.epochTime) << "'," << row.currentWatt << ',' << row.lifetimeWattHour << ','
<< row.inverterCount << ");";
return ss.str();
}
bool Database::Insert(Row & row)
std::string ToSqlUpsertStatement(ZeverRow const & row)
{
std::stringstream ss;
ss << "INSERT INTO ZeverSummary(Date,TotalWatts) VALUES(" << '\'' << Util::GetSqliteDate(row.epochTime) << "',"
<< std::fixed << std::setprecision(2) << static_cast<std::int64_t>(round(row.kilowattPerHour * 1000)) << ')'
<< "ON CONFLICT(Date) DO UPDATE SET TotalWatts = excluded.TotalWatts WHERE excluded.TotalWatts > ZeverSummary.TotalWatts;";
return ss.str();
}
bool Database::Insert(ZeverRow & row)
{
std::stringstream transaction;
transaction << "BEGIN TRANSACTION;" << ToSqlInsertStatement(row) << ToSqlUpsertStatement(row) << "COMMIT;";
@@ -34,6 +45,11 @@ bool Database::Insert(Row & row)
return sqlite3_exec(connectionPtr, transaction.str().c_str(), nullptr, nullptr, nullptr);
}
bool Database::Insert(EnvoyRow & row)
{
return sqlite3_exec(connectionPtr, ToSqlInsertStatement(row).c_str(), nullptr, nullptr, nullptr);
}
Database::Database(std::string const & databasePath) : connectionPtr(nullptr)
{
if(sqlite3_open(databasePath.c_str(), &connectionPtr) != SQLITE_OK)

View File

@@ -0,0 +1,69 @@
#include <exception>
#include <nlohmann/json.hpp>
#include <solar/logger/envoydata.hpp>
#include <spdlog/spdlog.h>
#include <stdexcept>
nlohmann::basic_json<> ExtractEnvoyProductionInvertersObject(std::string const & json)
{
auto const parsed = nlohmann::json::parse(json);
if(!parsed.is_object())
{
throw std::runtime_error("Invalid JSON");
}
auto const production = parsed["production"];
if(!production.is_array())
{
throw std::runtime_error("Invalid production property type");
}
auto const invertersObject = std::find_if(
production.begin(),
production.end(),
[](nlohmann::basic_json<> const & item)
{ return item.is_object() && item["type"].is_string() && item["type"].get<std::string>() == "inverters"; });
if(invertersObject == production.end())
{
throw std::runtime_error("Failed to find JSON object of type inverters in production array");
}
if(!(*invertersObject)["activeCount"].is_number_integer())
{
throw std::runtime_error("Invalid activeCount property in inverters object");
}
if(!(*invertersObject)["readingTime"].is_number_integer())
{
throw std::runtime_error("Invalid readingTime property in inverters object");
}
if(!(*invertersObject)["wNow"].is_number_integer())
{
throw std::runtime_error("Invalid wNow property in inverters object");
}
if(!(*invertersObject)["whLifetime"].is_number_integer())
{
throw std::runtime_error("Invalid whLifetime property in inverters object");
}
return *invertersObject;
}
std::optional<EnvoyData> EnvoyData::ParseJson(std::string const & json)
{
try
{
auto const parsed = ExtractEnvoyProductionInvertersObject(json);
return EnvoyData {
.type = parsed["type"].get<std::string>(),
.activeCount = parsed["activeCount"].get<std::int32_t>(),
.readingTime = parsed["readingTime"].get<std::int64_t>(),
.wNow = parsed["wNow"].get<std::int32_t>(),
.whLifetime = parsed["whLifetime"].get<std::int64_t>(),
};
}
catch(std::exception & ex)
{
spdlog::error("Unexpected exception {} whilst parsing JSON {}", ex.what(), json);
return {};
}
}

View File

@@ -1,58 +1,171 @@
#include <cstdio>
#include <cxxopts.hpp>
#include <iomanip>
#include <optional>
#include <solar/logger/database.hpp>
#include <solar/logger/envoydata.hpp>
#include <solar/logger/zeverdata.hpp>
#include <spdlog/spdlog.h>
#include <sstream>
#include <string>
#include <tclap/CmdLine.h>
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;
}
}
std::optional<cxxopts::ParseResult> ExtractArgs(int argc, char ** argv)
{
cxxopts::Options options(
"solar-logger",
"solar-logger is a small program that retrieves solarpower generation statistics from smart inverters. It supports the Zeverlution Sxxxx and the Envoy S Metered (v5.x.x)");
options.add_options()(
"u,url",
"Fully qualified URL path and protocol to the web resource containing the stastics. For the Zeverlution platform this is /home.cgi and for the Envoy S this is /production.json.",
cxxopts::value<std::string>())(
"connection-string",
"Path to the sqlite3 database file",
cxxopts::value<std::string>())(
"type",
"Type of web resource to fetch; \"Zeverlution\" or \"Envoy\"",
cxxopts::value<std::string>())(
"t,timeout",
"Fetch time out in milliseconds",
cxxopts::value<unsigned>()->default_value("1000"));
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 {};
}
}
std::optional<std::string> 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);
const auto result = curl_easy_perform(curl);
if(result)
{
spdlog::error("Failed to fetch data from URL {} with cURL error code {}", url.c_str(), result);
curl_easy_cleanup(curl);
return {};
}
buffer.shrink_to_fit();
curl_easy_cleanup(curl);
return buffer;
}
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");
auto const maybeArgs = ExtractArgs(argc, argv);
if(!maybeArgs.has_value())
{
return 1;
}
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);
auto const & args = maybeArgs.value();
TCLAP::ValueArg<unsigned int>
timeoutArg("t", "timeout", "Fetch time out in milliseconds", false, 1000U, "Fetch time out in milliseconds");
cmd.add(timeoutArg);
Database db(args["connection-string"].as<std::string>());
auto const now = std::time(nullptr);
auto const resourceType = args["type"].as<std::string>();
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()))
if(resourceType == "Zeverlution")
{
auto const webResource = FetchDataFromURL(args["url"].as<std::string>(), args["timeout"].as<unsigned>());
if(!webResource.has_value())
{
return -1;
}
Row row;
row.epochTime = std::time(nullptr); // now
row.watt = zeverData.watt;
row.kilowattPerHour = zeverData.kilowattPerHour;
auto const zeverData = ZeverData::ParseString(webResource.value());
if(!zeverData.has_value())
{
return -1;
}
ZeverRow row = {
.epochTime = now,
.watt = zeverData->watt,
.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);
spdlog::error("Error {} during insertion of new value into database", insertionResult);
return -1;
}
}
else if(resourceType == "Envoy")
{
auto const webResource = FetchDataFromURL(args["url"].as<std::string>(), args["timeout"].as<unsigned>());
if(!webResource.has_value())
{
return -1;
}
auto const parsed = EnvoyData::ParseJson(webResource.value());
if(!parsed.has_value())
{
return -1;
}
EnvoyRow row = {
.epochTime = now,
.inverterCount = parsed->activeCount,
.currentWatt = parsed->wNow,
.lifetimeWattHour = parsed->whLifetime,
.inverterTime = parsed->readingTime,
};
auto const insertionResult = db.Insert(row);
if(insertionResult)
{
spdlog::error("Error {} during insertion of new value into database", insertionResult);
return -1;
}
}
else
{
spdlog::error("Unrecognized Type argument {}", resourceType);
return -1;
}

View File

@@ -1,88 +1,41 @@
#include <solar/logger/zeverdata.hpp>
#include <spdlog/spdlog.h>
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)
std::optional<ZeverData> ZeverData::ParseString(const std::string & str)
{
ZeverData data;
bool isValid = true;
std::stringstream ss;
ss.str(str);
ss >> number0;
ss >> number1;
ss >> registeryID;
ss >> registeryKey;
ss >> hardwareVersion;
ss >> data.number0;
ss >> data.number1;
ss >> data.registeryID;
ss >> data.registeryKey;
ss >> data.hardwareVersion;
std::string versionInfo;
ss >> versionInfo;
size_t splitPos = versionInfo.find('+');
appVersion = versionInfo.substr(0, splitPos);
data.appVersion = versionInfo.substr(0, splitPos);
++splitPos;
wifiVersion = versionInfo.substr(splitPos, versionInfo.size());
data.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;
ss >> data.timeValue;
ss >> data.dateValue;
ss >> data.zeverCloudStatus;
ss >> data.number3;
ss >> data.inverterSN;
isValid = (ss >> data.watt && ss >> data.kilowattPerHour);
ss >> data.OKmsg;
ss >> data.ERRORmsg;
if(!isValid)
{
std::fprintf(stderr, "Error during parsing of zever data:\n%s\n", str.c_str());
return false;
spdlog::error("Error parsing zever data {}", str);
return {};
}
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);
return data;
}
ZeverData::ZeverData() : watt(0), kilowattPerHour(0.0) { }