// 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 . * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ #include "integrationpluginopenmeteo.h" #include "plugininfo.h" #include #include #include #include #include #include #include // 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"); } }