395 lines
17 KiB
C++
395 lines
17 KiB
C++
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
|
|
*
|
|
* Copyright (C) 2026, ETM-Schurig SARL
|
|
*
|
|
* This file is part of etm-powersync-plugins.
|
|
*
|
|
* This program is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU General Public License as published by
|
|
* the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
|
* General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU General Public License
|
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
*
|
|
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
|
|
|
|
#include "integrationpluginopenmeteo.h"
|
|
#include "plugininfo.h"
|
|
|
|
#include <QJsonDocument>
|
|
#include <QJsonParseError>
|
|
#include <QVariantMap>
|
|
#include <QUrl>
|
|
#include <QUrlQuery>
|
|
|
|
#include <integrations/thing.h>
|
|
#include <network/networkaccessmanager.h>
|
|
|
|
// NOTE: the *StateTypeId / *ParamTypeId / *ThingClassId / *ActionTypeId / dcOpenMeteo
|
|
// identifiers below are generated into plugininfo.h from integrationpluginopenmeteo.json.
|
|
// If a name does not match, align it with the generated header (do not edit plugininfo.h).
|
|
|
|
IntegrationPluginOpenMeteo::IntegrationPluginOpenMeteo()
|
|
{
|
|
}
|
|
|
|
void IntegrationPluginOpenMeteo::init()
|
|
{
|
|
// No API key required. Plugin parameters (baseUrl, satelliteBaseUrl, weatherModel)
|
|
// are read at request time via configValue().
|
|
}
|
|
|
|
void IntegrationPluginOpenMeteo::setupThing(ThingSetupInfo *info)
|
|
{
|
|
Thing *thing = info->thing();
|
|
|
|
if (thing->thingClassId() == openMeteoSiteThingClassId) {
|
|
double lat, lon;
|
|
if (!resolveCoordinates(thing, lat, lon)) {
|
|
info->finish(Thing::ThingErrorInvalidParameter, QT_TR_NOOP("Missing coordinates for this site."));
|
|
return;
|
|
}
|
|
updateSiteForecast(thing);
|
|
updateSiteSatellite(thing);
|
|
} else if (thing->thingClassId() == openMeteoPvPlaneThingClassId) {
|
|
double lat, lon;
|
|
if (!resolveCoordinates(thing, lat, lon)) {
|
|
info->finish(Thing::ThingErrorHardwareNotAvailable, QT_TR_NOOP("Parent site not available."));
|
|
return;
|
|
}
|
|
updatePlane(thing);
|
|
}
|
|
|
|
info->finish(Thing::ThingErrorNoError);
|
|
|
|
if (!m_pluginTimer) {
|
|
// 900 s = 15 min, aligned with the OpenWeatherMap reference plugin.
|
|
m_pluginTimer = hardwareManager()->pluginTimerManager()->registerTimer(900);
|
|
connect(m_pluginTimer, &PluginTimer::timeout, this, [this]() {
|
|
foreach (Thing *thing, myThings()) {
|
|
refresh(thing);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
void IntegrationPluginOpenMeteo::executeAction(ThingActionInfo *info)
|
|
{
|
|
Thing *thing = info->thing();
|
|
|
|
if (thing->thingClassId() == openMeteoSiteThingClassId
|
|
&& info->action().actionTypeId() == openMeteoSiteRefreshWeatherActionTypeId) {
|
|
refresh(thing);
|
|
// Also refresh the child PV planes of this site.
|
|
foreach (Thing *child, myThings()) {
|
|
if (child->thingClassId() == openMeteoPvPlaneThingClassId && child->parentId() == thing->id())
|
|
updatePlane(child);
|
|
}
|
|
}
|
|
|
|
info->finish(Thing::ThingErrorNoError);
|
|
}
|
|
|
|
void IntegrationPluginOpenMeteo::thingRemoved(Thing *thing)
|
|
{
|
|
Q_UNUSED(thing)
|
|
if (myThings().isEmpty() && m_pluginTimer) {
|
|
hardwareManager()->pluginTimerManager()->unregisterTimer(m_pluginTimer);
|
|
m_pluginTimer = nullptr;
|
|
}
|
|
}
|
|
|
|
void IntegrationPluginOpenMeteo::refresh(Thing *thing)
|
|
{
|
|
if (thing->thingClassId() == openMeteoSiteThingClassId) {
|
|
updateSiteForecast(thing);
|
|
updateSiteSatellite(thing);
|
|
} else if (thing->thingClassId() == openMeteoPvPlaneThingClassId) {
|
|
updatePlane(thing);
|
|
}
|
|
}
|
|
|
|
bool IntegrationPluginOpenMeteo::resolveCoordinates(Thing *thing, double &latitude, double &longitude) const
|
|
{
|
|
Thing *site = nullptr;
|
|
if (thing->thingClassId() == openMeteoSiteThingClassId) {
|
|
site = thing;
|
|
} else if (thing->thingClassId() == openMeteoPvPlaneThingClassId) {
|
|
site = myThings().findById(thing->parentId());
|
|
}
|
|
if (!site)
|
|
return false;
|
|
|
|
latitude = site->paramValue(openMeteoSiteLatitudeParamTypeId).toDouble();
|
|
longitude = site->paramValue(openMeteoSiteLongitudeParamTypeId).toDouble();
|
|
return true;
|
|
}
|
|
|
|
void IntegrationPluginOpenMeteo::updateSiteForecast(Thing *thing)
|
|
{
|
|
double lat, lon;
|
|
if (!resolveCoordinates(thing, lat, lon))
|
|
return;
|
|
|
|
const QString baseUrl = configValue(openMeteoPluginBaseUrlParamTypeId).toString();
|
|
const QString model = configValue(openMeteoPluginWeatherModelParamTypeId).toString();
|
|
|
|
QUrl url(baseUrl + "/v1/forecast");
|
|
QUrlQuery query;
|
|
query.addQueryItem("latitude", QString::number(lat));
|
|
query.addQueryItem("longitude", QString::number(lon));
|
|
const double elevation = thing->paramValue(openMeteoSiteElevationParamTypeId).toDouble();
|
|
if (elevation != 0.0)
|
|
query.addQueryItem("elevation", QString::number(elevation));
|
|
query.addQueryItem("models", model);
|
|
query.addQueryItem("wind_speed_unit", "ms");
|
|
query.addQueryItem("timezone", "Europe/Paris");
|
|
query.addQueryItem("timeformat", "unixtime");
|
|
query.addQueryItem("current", "temperature_2m,wind_speed_10m,wind_direction_10m,cloud_cover,"
|
|
"relative_humidity_2m,surface_pressure,weather_code,snowfall,is_day,"
|
|
"shortwave_radiation,direct_normal_irradiance,diffuse_radiation,"
|
|
"terrestrial_radiation");
|
|
query.addQueryItem("daily", "temperature_2m_max,temperature_2m_min,sunrise,sunset,shortwave_radiation_sum");
|
|
url.setQuery(query);
|
|
|
|
qCDebug(dcOpenMeteo()) << "Requesting forecast for" << thing->name() << url.toString();
|
|
|
|
QNetworkReply *reply = hardwareManager()->networkManager()->get(QNetworkRequest(url));
|
|
connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater);
|
|
connect(reply, &QNetworkReply::finished, thing, [this, thing, reply]() {
|
|
if (reply->error() != QNetworkReply::NoError) {
|
|
qCWarning(dcOpenMeteo()) << "Forecast reply error:" << reply->errorString();
|
|
return;
|
|
}
|
|
QJsonParseError error;
|
|
QJsonDocument doc = QJsonDocument::fromJson(reply->readAll(), &error);
|
|
if (error.error != QJsonParseError::NoError) {
|
|
qCWarning(dcOpenMeteo()) << "Forecast parse error:" << error.errorString();
|
|
return;
|
|
}
|
|
|
|
const QVariantMap map = doc.toVariant().toMap();
|
|
const QVariantMap current = map.value("current").toMap();
|
|
const QVariantMap daily = map.value("daily").toMap();
|
|
|
|
if (current.contains("time"))
|
|
thing->setStateValue(openMeteoSiteUpdateTimeStateTypeId, current.value("time").toUInt());
|
|
if (current.contains("temperature_2m"))
|
|
thing->setStateValue(openMeteoSiteTemperatureStateTypeId, current.value("temperature_2m").toDouble());
|
|
if (current.contains("wind_speed_10m"))
|
|
thing->setStateValue(openMeteoSiteWindSpeedStateTypeId, current.value("wind_speed_10m").toDouble());
|
|
if (current.contains("wind_direction_10m"))
|
|
thing->setStateValue(openMeteoSiteWindDirectionStateTypeId, current.value("wind_direction_10m").toInt());
|
|
if (current.contains("cloud_cover"))
|
|
thing->setStateValue(openMeteoSiteCloudinessStateTypeId, current.value("cloud_cover").toInt());
|
|
if (current.contains("relative_humidity_2m"))
|
|
thing->setStateValue(openMeteoSiteHumidityStateTypeId, current.value("relative_humidity_2m").toInt());
|
|
if (current.contains("surface_pressure"))
|
|
thing->setStateValue(openMeteoSitePressureStateTypeId, current.value("surface_pressure").toDouble());
|
|
if (current.contains("snowfall"))
|
|
thing->setStateValue(openMeteoSiteSnowfallStateTypeId, current.value("snowfall").toDouble());
|
|
if (current.contains("shortwave_radiation"))
|
|
thing->setStateValue(openMeteoSiteGhiStateTypeId, current.value("shortwave_radiation").toDouble());
|
|
if (current.contains("direct_normal_irradiance"))
|
|
thing->setStateValue(openMeteoSiteDniStateTypeId, current.value("direct_normal_irradiance").toDouble());
|
|
if (current.contains("diffuse_radiation"))
|
|
thing->setStateValue(openMeteoSiteDhiStateTypeId, current.value("diffuse_radiation").toDouble());
|
|
if (current.contains("terrestrial_radiation"))
|
|
thing->setStateValue(openMeteoSiteTerrestrialRadiationStateTypeId, current.value("terrestrial_radiation").toDouble());
|
|
|
|
bool isDay = current.value("is_day").toInt() == 1;
|
|
if (current.contains("is_day"))
|
|
thing->setStateValue(openMeteoSiteDaylightStateTypeId, isDay);
|
|
if (current.contains("weather_code")) {
|
|
int weatherCode = current.value("weather_code").toInt();
|
|
thing->setStateValue(openMeteoSiteWeatherConditionStateTypeId,
|
|
weatherConditionFromCode(weatherCode, isDay));
|
|
thing->setStateValue(openMeteoSiteWeatherDescriptionStateTypeId,
|
|
weatherDescriptionFromCode(weatherCode));
|
|
}
|
|
|
|
// Daily arrays: take index 0 (today).
|
|
const QVariantList tmax = daily.value("temperature_2m_max").toList();
|
|
const QVariantList tmin = daily.value("temperature_2m_min").toList();
|
|
const QVariantList sunrise = daily.value("sunrise").toList();
|
|
const QVariantList sunset = daily.value("sunset").toList();
|
|
const QVariantList swSum = daily.value("shortwave_radiation_sum").toList();
|
|
if (!tmax.isEmpty())
|
|
thing->setStateValue(openMeteoSiteTemperatureMaxStateTypeId, tmax.first().toDouble());
|
|
if (!tmin.isEmpty())
|
|
thing->setStateValue(openMeteoSiteTemperatureMinStateTypeId, tmin.first().toDouble());
|
|
if (!sunrise.isEmpty())
|
|
thing->setStateValue(openMeteoSiteSunriseTimeStateTypeId, sunrise.first().toUInt());
|
|
if (!sunset.isEmpty())
|
|
thing->setStateValue(openMeteoSiteSunsetTimeStateTypeId, sunset.first().toUInt());
|
|
if (!swSum.isEmpty())
|
|
// Open-Meteo shortwave_radiation_sum is in MJ/m^2; expose kWh/m^2 (/ 3.6).
|
|
thing->setStateValue(openMeteoSiteShortwaveRadiationSumTodayStateTypeId, swSum.first().toDouble() / 3.6);
|
|
});
|
|
}
|
|
|
|
void IntegrationPluginOpenMeteo::updateSiteSatellite(Thing *thing)
|
|
{
|
|
double lat, lon;
|
|
if (!resolveCoordinates(thing, lat, lon))
|
|
return;
|
|
|
|
const QString satelliteBaseUrl = configValue(openMeteoPluginSatelliteBaseUrlParamTypeId).toString();
|
|
|
|
// Observation product (geostationary satellite). Used for the observed GHI and the
|
|
// native clear-sky -> clear-sky index (kt). Observation only, no forward forecast.
|
|
// TODO(verify): confirm the satellite endpoint supports "current="; otherwise switch to
|
|
// "hourly=" and take the latest non-null sample.
|
|
QUrl url(satelliteBaseUrl + "/v1/archive");
|
|
QUrlQuery query;
|
|
query.addQueryItem("latitude", QString::number(lat));
|
|
query.addQueryItem("longitude", QString::number(lon));
|
|
query.addQueryItem("models", "satellite_radiation_seamless");
|
|
query.addQueryItem("timezone", "Europe/Paris");
|
|
query.addQueryItem("timeformat", "unixtime");
|
|
query.addQueryItem("current", "shortwave_radiation,shortwave_radiation_clear_sky");
|
|
url.setQuery(query);
|
|
|
|
qCDebug(dcOpenMeteo()) << "Requesting satellite observation for" << thing->name() << url.toString();
|
|
|
|
QNetworkReply *reply = hardwareManager()->networkManager()->get(QNetworkRequest(url));
|
|
connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater);
|
|
connect(reply, &QNetworkReply::finished, thing, [this, thing, reply]() {
|
|
if (reply->error() != QNetworkReply::NoError) {
|
|
qCWarning(dcOpenMeteo()) << "Satellite reply error:" << reply->errorString();
|
|
return;
|
|
}
|
|
QJsonParseError error;
|
|
QJsonDocument doc = QJsonDocument::fromJson(reply->readAll(), &error);
|
|
if (error.error != QJsonParseError::NoError) {
|
|
qCWarning(dcOpenMeteo()) << "Satellite parse error:" << error.errorString();
|
|
return;
|
|
}
|
|
|
|
const QVariantMap current = doc.toVariant().toMap().value("current").toMap();
|
|
double ghiObs = current.value("shortwave_radiation").toDouble();
|
|
double clearSky = current.value("shortwave_radiation_clear_sky").toDouble();
|
|
|
|
if (current.contains("shortwave_radiation"))
|
|
thing->setStateValue(openMeteoSiteGhiObservedStateTypeId, ghiObs);
|
|
if (current.contains("shortwave_radiation_clear_sky"))
|
|
thing->setStateValue(openMeteoSiteClearSkyGhiStateTypeId, clearSky);
|
|
|
|
// Clear-sky index kt = GHI_observed / GHI_clearsky, with a night guard.
|
|
double kt = 0.0;
|
|
if (clearSky > 20.0)
|
|
kt = qBound(0.0, ghiObs / clearSky, 1.2);
|
|
thing->setStateValue(openMeteoSiteClearSkyIndexStateTypeId, kt);
|
|
});
|
|
}
|
|
|
|
void IntegrationPluginOpenMeteo::updatePlane(Thing *thing)
|
|
{
|
|
double lat, lon;
|
|
if (!resolveCoordinates(thing, lat, lon))
|
|
return;
|
|
|
|
const QString baseUrl = configValue(openMeteoPluginBaseUrlParamTypeId).toString();
|
|
const QString model = configValue(openMeteoPluginWeatherModelParamTypeId).toString();
|
|
|
|
const double tilt = thing->paramValue(openMeteoPvPlaneTiltParamTypeId).toDouble();
|
|
// Stored azimuth is geographic (180 = South). Open-Meteo expects 0 = South,
|
|
// -90 = East, +90 = West. Convert here (see ARCHITECTURE.md, conventions).
|
|
const double azimuthOm = thing->paramValue(openMeteoPvPlaneAzimuthParamTypeId).toDouble() - 180.0;
|
|
|
|
QUrl url(baseUrl + "/v1/forecast");
|
|
QUrlQuery query;
|
|
query.addQueryItem("latitude", QString::number(lat));
|
|
query.addQueryItem("longitude", QString::number(lon));
|
|
query.addQueryItem("models", model);
|
|
query.addQueryItem("timezone", "Europe/Paris");
|
|
query.addQueryItem("timeformat", "unixtime");
|
|
query.addQueryItem("tilt", QString::number(tilt));
|
|
query.addQueryItem("azimuth", QString::number(azimuthOm));
|
|
query.addQueryItem("current", "global_tilted_irradiance");
|
|
url.setQuery(query);
|
|
|
|
qCDebug(dcOpenMeteo()) << "Requesting GTI for plane" << thing->name() << url.toString();
|
|
|
|
QNetworkReply *reply = hardwareManager()->networkManager()->get(QNetworkRequest(url));
|
|
connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater);
|
|
connect(reply, &QNetworkReply::finished, thing, [this, thing, reply]() {
|
|
if (reply->error() != QNetworkReply::NoError) {
|
|
qCWarning(dcOpenMeteo()) << "GTI reply error:" << reply->errorString();
|
|
return;
|
|
}
|
|
QJsonParseError error;
|
|
QJsonDocument doc = QJsonDocument::fromJson(reply->readAll(), &error);
|
|
if (error.error != QJsonParseError::NoError) {
|
|
qCWarning(dcOpenMeteo()) << "GTI parse error:" << error.errorString();
|
|
return;
|
|
}
|
|
|
|
const QVariantMap current = doc.toVariant().toMap().value("current").toMap();
|
|
if (current.contains("global_tilted_irradiance"))
|
|
thing->setStateValue(openMeteoPvPlaneGtiNowStateTypeId, current.value("global_tilted_irradiance").toDouble());
|
|
if (current.contains("time"))
|
|
thing->setStateValue(openMeteoPvPlaneUpdateTimeStateTypeId, current.value("time").toUInt());
|
|
});
|
|
}
|
|
|
|
QString IntegrationPluginOpenMeteo::weatherConditionFromCode(int code, bool isDay) const
|
|
{
|
|
// WMO weather interpretation codes -> weatherCondition enum (see metadata possibleValues).
|
|
switch (code) {
|
|
case 0:
|
|
return isDay ? "clear-day" : "clear-night";
|
|
case 1:
|
|
case 2:
|
|
return isDay ? "few-clouds-day" : "few-clouds-night";
|
|
case 3:
|
|
return "overcast";
|
|
case 45:
|
|
case 48:
|
|
return "fog";
|
|
case 51: case 53: case 55: case 56: case 57:
|
|
return "light-rain";
|
|
case 61: case 63: case 65: case 66: case 67:
|
|
case 80: case 81: case 82:
|
|
return "shower-rain";
|
|
case 71: case 73: case 75: case 77:
|
|
case 85: case 86:
|
|
return "snow";
|
|
case 95: case 96: case 99:
|
|
return "thunderstorm";
|
|
default:
|
|
return "clouds";
|
|
}
|
|
}
|
|
|
|
QString IntegrationPluginOpenMeteo::weatherDescriptionFromCode(int code) const
|
|
{
|
|
// WMO weather interpretation codes -> human-readable description.
|
|
switch (code) {
|
|
case 0: return tr("Clear sky");
|
|
case 1: return tr("Mainly clear");
|
|
case 2: return tr("Partly cloudy");
|
|
case 3: return tr("Overcast");
|
|
case 45: case 48: return tr("Fog");
|
|
case 51: case 53: case 55: return tr("Drizzle");
|
|
case 56: case 57: return tr("Freezing drizzle");
|
|
case 61: case 63: case 65: return tr("Rain");
|
|
case 66: case 67: return tr("Freezing rain");
|
|
case 71: case 73: case 75: return tr("Snow fall");
|
|
case 77: return tr("Snow grains");
|
|
case 80: case 81: case 82: return tr("Rain showers");
|
|
case 85: case 86: return tr("Snow showers");
|
|
case 95: return tr("Thunderstorm");
|
|
case 96: case 99: return tr("Thunderstorm with hail");
|
|
default: return tr("Unknown");
|
|
}
|
|
}
|