diff --git a/nymea-plugins.pro b/nymea-plugins.pro index 01b19cd3..b1e82f04 100644 --- a/nymea-plugins.pro +++ b/nymea-plugins.pro @@ -47,6 +47,7 @@ PLUGIN_DIRS = \ notifyevents \ nuki \ onewire \ + openmeteo \ openuv \ openweathermap \ osdomotics \ diff --git a/openmeteo/CLAUDE_CODE_PROMPT_build_test_deb.md b/openmeteo/CLAUDE_CODE_PROMPT_build_test_deb.md new file mode 100644 index 00000000..a3681575 --- /dev/null +++ b/openmeteo/CLAUDE_CODE_PROMPT_build_test_deb.md @@ -0,0 +1,66 @@ +# Prompt Claude Code — build, test et packaging du module `openmeteo` + +> À coller dans Claude Code, à la racine du dépôt `etm-powersync-plugins`. +> Le module `openmeteo/` existe déjà (`.json`, `.h`, `.cpp`, `.pro`, `meta.json`, `README.md`, `openmeteo.png`). +> Lis d'abord `CLAUDE.md` (conventions du repo) et le code existant **avant** de modifier quoi que ce soit. + +## Objectif + +Faire compiler, tester manuellement, puis packager en `.deb` le plugin d'intégration nymea `openmeteo`, sans en changer le comportement fonctionnel décrit dans `integrationpluginopenmeteo.cpp`. + +## Étape 1 — Intégration au build + +1. Ajouter `openmeteo` à la liste `SUBDIRS` de `nymea-plugins.pro` (ordre alphabétique, comme les autres modules). +2. Vérifier que `openmeteo/openmeteo.pro` inclut bien `../plugins.pri` et `QT *= network`. + +## Étape 2 — Compilation et correction des constantes générées + +1. Compiler le module (QMake, dans l'arbre, Qt5 d'abord) : + ``` + qmake && make -j$(nproc) + ``` +2. `plugininfo.h` est **généré** à partir de `integrationpluginopenmeteo.json` par l'outil nymea. Ne pas l'éditer. +3. Le `.cpp` référence des constantes (`openMeteoSite*StateTypeId`, `openMeteoPlugin*ParamTypeId`, `openMeteoPvPlane*`, `dcOpenMeteo`, etc.) déduites de la convention nymea. **Comparer ces noms au `plugininfo.h` réellement généré** et corriger toute divergence **dans le `.cpp`** (jamais dans le header généré). Lister les corrections faites. +4. Corriger les éventuels warnings de compilation (variables inutilisées, signedness, etc.). +5. Rejouer le build en **Qt6** et corriger les différences éventuelles. + +## Étape 3 — Vérification du point satellite + +Le `.cpp` contient un `TODO(verify)` dans `updateSiteSatellite()` : l'endpoint satellite (`{satelliteBaseUrl}/v1/archive`, modèle `satellite_radiation_seamless`) doit accepter `current=shortwave_radiation,shortwave_radiation_clear_sky`. + +1. Tester l'URL réellement construite (avec une vraie lat/lon, p.ex. 48.9 / 7.85) avec `curl`. +2. Si `current=` n'est pas supporté : basculer sur `hourly=` et prendre le dernier échantillon **non nul** (garder le calcul `clearSkyIndex = ghiObs/clearSky`, garde `clearSky > 20`). +3. Documenter le comportement retenu en commentaire. + +## Étape 4 — Test manuel (fonctionnel) + +Installer le plugin localement et, via `nymea-cli` ou l'app : + +1. Ajouter un thing **openMeteoSite** (latitude 48.9, longitude 7.85). Vérifier qu'au premier cycle se remplissent : `temperature`, `windSpeed`, `cloudiness`, `humidity`, `pressure`, `weatherCondition`, `ghi`, `dni`, `dhi`, `terrestrialRadiation`, `sunriseTime`, `sunsetTime`, `daylight`, et les daily (`temperatureMin/Max`, `shortwaveRadiationSumToday`). +2. Vérifier les states satellite : `ghiObserved`, `clearSkyGhi`, `clearSkyIndex`. +3. Ajouter un thing **openMeteoPvPlane** enfant du site (tilt 30, azimuth 180 = plein sud). Vérifier `gtiNow` cohérent (proche du GHI à midi pour un plan ~sud). +4. Vérifier la conversion d'azimut : un pan azimuth 90 (Est) doit produire un GTI matinal supérieur à un pan azimuth 270 (Ouest) le matin. +5. Contrôle **de nuit** : `daylight=false`, `ghi≈0`, `clearSkyIndex=0`, pas de division par zéro, pas de crash. +6. Déclencher l'action `refreshWeather` sur le site et vérifier que site + pans se mettent à jour immédiatement. + +## Étape 5 — Packaging `.deb` + +1. Utiliser les dossiers `debian-qt5` / `debian-qt6` existants du dépôt (le lien `debian -> debian-qt5` est en place). +2. Vérifier/compléter le `debian/control` et les fichiers d'install pour inclure le paquet `nymea-plugin-openmeteo` (binaire du plugin + `integrationpluginopenmeteo.json` + ressources). +3. Construire le paquet (p.ex. `dpkg-buildpackage -b -us -uc` ou la cible utilisée par le repo) et vérifier que le `.deb` contient bien la librairie du plugin et son métadonnée. +4. Indiquer le nom exact du `.deb` produit et son emplacement. + +## Contraintes + +- **Ne pas modifier les autres plugins** de l'arbre. +- **Aucune logique propriétaire** (cf. `CLAUDE.md` / frontière de licence) : ce plugin reste une source de données. +- Garder le code et les commentaires en anglais (convention nymea-plugins). +- Montrer chaque diff avant de l'appliquer ; procéder étape par étape. + +## Livrables attendus + +- Module compilé en Qt5 et Qt6, sans warning. +- Liste des corrections de constantes (le cas échéant). +- Comportement satellite confirmé/ajusté. +- Compte rendu du test manuel (states remplis jour/nuit, GTI cohérent par orientation). +- `.deb` `nymea-plugin-openmeteo` produit, nom et emplacement indiqués. diff --git a/openmeteo/README.md b/openmeteo/README.md new file mode 100644 index 00000000..56e30e0a --- /dev/null +++ b/openmeteo/README.md @@ -0,0 +1,49 @@ +# OpenMeteo + +This plugin allows to get current weather and solar irradiance data from [Open-Meteo](https://open-meteo.com), including plane-of-array irradiance (GTI) for photovoltaic surfaces and satellite-observed clear-sky data. + +## Usage + +The data is refreshed every 15 minutes automatically, and can also be refreshed manually. + +A **site** is added by entering its latitude and longitude (no discovery). It provides the shared weather and solar data for the location, including a satellite-observed GHI and clear-sky index. + +One or more **PV planes** can be added as children of a site. Each plane has its own tilt, azimuth and peak power, and reports the global tilted irradiance (GTI) for that orientation. Multiple orientations are handled by adding multiple planes. + +> Note: the azimuth is entered in geographic convention (180 = South, 90 = East, 270 = West). + +By default the plugin queries the public Open-Meteo API. For commercial deployments, the `Base URL` and `Base URL (satellite radiation)` plugin settings can be pointed to a self-hosted Open-Meteo instance (the public API is for non-commercial use only). + +## Supported Things + +* Open-Meteo site (interfaces: weather, daylightsensor) + * Weather condition + * Temperature (current, daily min and max) + * Humidity + * Pressure + * Wind speed and direction + * Cloudiness + * Snowfall + * Global, direct normal and diffuse irradiance (GHI / DNI / DHI) + * Terrestrial (top-of-atmosphere) radiation + * Daily shortwave radiation sum + * Satellite-observed GHI, clear-sky GHI and clear-sky index + * Sunrise and sunset time + * Daylight + +* Open-Meteo PV plane + * Global tilted irradiance (GTI) for the configured tilt and azimuth + +## Requirements + +* Internet connection, or a reachable self-hosted Open-Meteo instance +* The package 'nymea-plugin-openmeteo' must be installed +* No API key is required. For commercial use, a self-hosted instance is required (the public Open-Meteo API is non-commercial only). + +## More + +Open-Meteo https://open-meteo.com + +Weather data by Météo-France, satellite radiation by EUMETSAT, provided through Open-Meteo (CC-BY 4.0). + +For the overall architecture (role of this plugin, conventions, license boundary), see the `etm-powersync-docs` repository. diff --git a/openmeteo/integrationpluginopenmeteo.cpp b/openmeteo/integrationpluginopenmeteo.cpp new file mode 100644 index 00000000..4165b97c --- /dev/null +++ b/openmeteo/integrationpluginopenmeteo.cpp @@ -0,0 +1,367 @@ +// 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")) + thing->setStateValue(openMeteoSiteWeatherConditionStateTypeId, + weatherConditionFromCode(current.value("weather_code").toInt(), isDay)); + + // 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"; + } +} diff --git a/openmeteo/integrationpluginopenmeteo.h b/openmeteo/integrationpluginopenmeteo.h new file mode 100644 index 00000000..5d3273e7 --- /dev/null +++ b/openmeteo/integrationpluginopenmeteo.h @@ -0,0 +1,65 @@ +// 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 . +* +* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +#ifndef INTEGRATIONPLUGINOPENMETEO_H +#define INTEGRATIONPLUGINOPENMETEO_H + +#include +#include + +class IntegrationPluginOpenMeteo : public IntegrationPlugin +{ + Q_OBJECT + + Q_PLUGIN_METADATA(IID "io.nymea.IntegrationPlugin" FILE "integrationpluginopenmeteo.json") + Q_INTERFACES(IntegrationPlugin) + +public: + explicit IntegrationPluginOpenMeteo(); + + void init() override; + void setupThing(ThingSetupInfo *info) override; + void executeAction(ThingActionInfo *info) override; + void thingRemoved(Thing *thing) override; + +private: + PluginTimer *m_pluginTimer = nullptr; + + // Refresh dispatch + void refresh(Thing *thing); + + // Site (parent): two requests — forecast (Meteo-France) + satellite observation (MTG) + void updateSiteForecast(Thing *thing); + void updateSiteSatellite(Thing *thing); + + // PV plane (child): one GTI request, using the parent site coordinates + void updatePlane(Thing *thing); + + // WMO weather_code -> weatherCondition enum value (with -day/-night via isDay) + QString weatherConditionFromCode(int code, bool isDay) const; + + // Helper: resolve the site coordinates for a thing (site itself, or a plane's parent) + bool resolveCoordinates(Thing *thing, double &latitude, double &longitude) const; +}; + +#endif // INTEGRATIONPLUGINOPENMETEO_H diff --git a/openmeteo/integrationpluginopenmeteo.json b/openmeteo/integrationpluginopenmeteo.json new file mode 100644 index 00000000..7688f925 --- /dev/null +++ b/openmeteo/integrationpluginopenmeteo.json @@ -0,0 +1,353 @@ +{ + "name": "OpenMeteo", + "displayName": "Open-Meteo", + "id": "d1ed979d-b802-463e-a51f-fef79245cfdc", + "paramTypes": [ + { + "id": "590cf57d-c1ca-4645-a443-7c5a6c97db93", + "name": "baseUrl", + "displayName": "Base URL (forecast)", + "type": "QString", + "defaultValue": "https://api.open-meteo.com" + }, + { + "id": "6cd410dc-7f1c-4681-8e47-8df525252d2d", + "name": "satelliteBaseUrl", + "displayName": "Base URL (satellite radiation)", + "type": "QString", + "defaultValue": "https://satellite-api.open-meteo.com" + }, + { + "id": "f904db60-527a-47b2-9dc5-371ac5f7e164", + "name": "weatherModel", + "displayName": "Forecast model", + "type": "QString", + "defaultValue": "meteofrance_seamless" + } + ], + "vendors": [ + { + "name": "openMeteo", + "displayName": "Open-Meteo", + "id": "b1f520ea-dcac-4b16-8cab-89204a225c1b", + "thingClasses": [ + { + "id": "59e23cb4-4820-45d4-9c2e-e7bc7b8c7ca6", + "name": "openMeteoSite", + "displayName": "Open-Meteo site", + "interfaces": [ + "weather", + "daylightsensor" + ], + "createMethods": [ + "user" + ], + "paramTypes": [ + { + "id": "cc7f6cde-c3dd-4924-be5e-a014e20df624", + "name": "name", + "displayName": "Name", + "type": "QString", + "inputType": "TextLine", + "defaultValue": "" + }, + { + "id": "28b4574a-19ea-41d2-9e58-51f0de11165b", + "name": "latitude", + "displayName": "Latitude", + "type": "double", + "defaultValue": 48.9 + }, + { + "id": "8bb8ca04-7a28-4cf5-8a4d-1e1081e75c4b", + "name": "longitude", + "displayName": "Longitude", + "type": "double", + "defaultValue": 7.85 + }, + { + "id": "eb16b64a-588f-4d45-b50a-3ca6bc3decc2", + "name": "elevation", + "displayName": "Elevation (m, optional)", + "type": "double", + "defaultValue": 0 + } + ], + "actionTypes": [ + { + "id": "d31b0484-0149-4dcd-92dc-408392cc5ec3", + "name": "refreshWeather", + "displayName": "Refresh" + } + ], + "stateTypes": [ + { + "id": "3ac82ea4-b824-4c0c-8ca9-216a1fc9c587", + "name": "weatherCondition", + "displayName": "Weather condition", + "displayNameEvent": "Weather condition changed", + "type": "QString", + "defaultValue": "clear-day", + "possibleValues": [ + "clear-day", + "clear-night", + "few-clouds-day", + "few-clouds-night", + "clouds", + "overcast", + "light-rain", + "shower-rain", + "thunderstorm", + "snow", + "fog" + ] + }, + { + "id": "ed48e3ed-f3fd-4401-9b62-0766d7f5d315", + "name": "updateTime", + "displayName": "Last update", + "displayNameEvent": "Last update changed", + "type": "int", + "defaultValue": 0, + "unit": "UnixTime" + }, + { + "id": "b0089fa5-fe4b-4dfc-a1ed-dbd85ca75b20", + "name": "temperature", + "displayName": "Temperature", + "displayNameEvent": "Temperature changed", + "type": "double", + "defaultValue": 0, + "unit": "DegreeCelsius" + }, + { + "id": "348879d1-0433-416f-a9ce-a05d522a2d26", + "name": "temperatureMin", + "displayName": "Minimum temperature (today)", + "displayNameEvent": "Minimum temperature (today) changed", + "type": "double", + "defaultValue": 0, + "unit": "DegreeCelsius" + }, + { + "id": "6530fa30-d1bf-4f84-9a2f-30f1aa65e130", + "name": "temperatureMax", + "displayName": "Maximum temperature (today)", + "displayNameEvent": "Maximum temperature (today) changed", + "type": "double", + "defaultValue": 0, + "unit": "DegreeCelsius" + }, + { + "id": "36413dbc-245b-4067-b5dc-41e71b37c0e9", + "name": "humidity", + "displayName": "Humidity", + "displayNameEvent": "Humidity changed", + "type": "int", + "defaultValue": 0, + "unit": "Percentage" + }, + { + "id": "6bf903e5-6c04-492b-bc43-00db60d2edf4", + "name": "pressure", + "displayName": "Pressure", + "displayNameEvent": "Pressure changed", + "type": "double", + "defaultValue": 0, + "unit": "HectoPascal" + }, + { + "id": "41ec4108-3651-4162-b782-c957e14355b9", + "name": "windSpeed", + "displayName": "Wind speed", + "displayNameEvent": "Wind speed changed", + "type": "double", + "defaultValue": 0, + "unit": "MeterPerSecond" + }, + { + "id": "d64aa5ab-0b1a-477d-95a6-11b755154d88", + "name": "windDirection", + "displayName": "Wind direction", + "displayNameEvent": "Wind direction changed", + "type": "int", + "defaultValue": 0, + "unit": "Degree" + }, + { + "id": "758af353-7e2b-4073-961f-77c5f8bd3b32", + "name": "cloudiness", + "displayName": "Cloudiness", + "displayNameEvent": "Cloudiness changed", + "type": "int", + "defaultValue": 0, + "unit": "Percentage" + }, + { + "id": "175bf804-9881-4272-a83f-fb81a9a029b3", + "name": "snowfall", + "displayName": "Snowfall (preceding hour)", + "displayNameEvent": "Snowfall (preceding hour) changed", + "type": "double", + "defaultValue": 0, + "unit": "CentiMeter" + }, + { + "id": "c8325cc4-de67-4581-a3d7-8248ecbd70e9", + "name": "ghi", + "displayName": "GHI forecast (W/m2)", + "displayNameEvent": "GHI forecast (W/m2) changed", + "type": "double", + "defaultValue": 0 + }, + { + "id": "0b0c5565-e5c5-4927-8a89-1e6b05c19250", + "name": "dni", + "displayName": "DNI forecast (W/m2)", + "displayNameEvent": "DNI forecast (W/m2) changed", + "type": "double", + "defaultValue": 0 + }, + { + "id": "67e64a1f-3dfa-46fa-8438-89df0f7f5c2e", + "name": "dhi", + "displayName": "DHI forecast (W/m2)", + "displayNameEvent": "DHI forecast (W/m2) changed", + "type": "double", + "defaultValue": 0 + }, + { + "id": "c9681fca-1020-4138-8d6e-d6d73d5a002e", + "name": "terrestrialRadiation", + "displayName": "TOA radiation (W/m2)", + "displayNameEvent": "TOA radiation (W/m2) changed", + "type": "double", + "defaultValue": 0 + }, + { + "id": "a25a9740-749e-455a-b4e7-60fff3d402f3", + "name": "shortwaveRadiationSumToday", + "displayName": "GHI sum today (kWh/m2)", + "displayNameEvent": "GHI sum today (kWh/m2) changed", + "type": "double", + "defaultValue": 0 + }, + { + "id": "de0f16e8-8e88-4aa7-b00f-c744e1e13072", + "name": "ghiObserved", + "displayName": "GHI observed sat (W/m2)", + "displayNameEvent": "GHI observed sat (W/m2) changed", + "type": "double", + "defaultValue": 0 + }, + { + "id": "a8d0074b-5696-464b-a9c6-7da16f3cd021", + "name": "clearSkyGhi", + "displayName": "Clear-sky GHI sat (W/m2)", + "displayNameEvent": "Clear-sky GHI sat (W/m2) changed", + "type": "double", + "defaultValue": 0 + }, + { + "id": "c6b6ccb6-a5cb-458a-bb7d-9dd6f818a962", + "name": "clearSkyIndex", + "displayName": "Clear-sky index kt", + "displayNameEvent": "Clear-sky index kt changed", + "type": "double", + "defaultValue": 0 + }, + { + "id": "2c6918d0-9758-4c71-b75b-1f08c3701ddf", + "name": "sunriseTime", + "displayName": "Sunrise time", + "displayNameEvent": "Sunrise time changed", + "type": "int", + "defaultValue": 0, + "unit": "UnixTime" + }, + { + "id": "c005cf92-1e29-4033-a65b-578d5d541240", + "name": "sunsetTime", + "displayName": "Sunset time", + "displayNameEvent": "Sunset time changed", + "type": "int", + "defaultValue": 0, + "unit": "UnixTime" + }, + { + "id": "291eea88-0a6d-4ca2-be1c-8a291cfbbdef", + "name": "daylight", + "displayName": "Daylight", + "displayNameEvent": "Daylight changed", + "type": "bool", + "defaultValue": false + } + ] + }, + { + "id": "fdfb5cb3-e5da-49d1-8d4e-1a316a68061a", + "name": "openMeteoPvPlane", + "displayName": "Open-Meteo PV plane", + "interfaces": [], + "createMethods": [ + "user" + ], + "paramTypes": [ + { + "id": "f5c6d83f-32a2-4cf8-85ae-137eb9c60635", + "name": "name", + "displayName": "Name", + "type": "QString", + "inputType": "TextLine", + "defaultValue": "" + }, + { + "id": "e2914db5-d5d6-4c7b-9da6-5fba8a49e023", + "name": "tilt", + "displayName": "Tilt (deg, 0=horizontal)", + "type": "double", + "minValue": 0, + "maxValue": 90, + "defaultValue": 30 + }, + { + "id": "ecba346a-e5f5-4264-92ab-a92a8fb2cf38", + "name": "azimuth", + "displayName": "Azimuth (deg geographic, 180=South)", + "type": "double", + "minValue": 0, + "maxValue": 360, + "defaultValue": 180 + }, + { + "id": "a8291abd-8106-49a1-868e-8907e16bba8a", + "name": "kwp", + "displayName": "Peak power kWp", + "type": "double", + "defaultValue": 0 + } + ], + "stateTypes": [ + { + "id": "e0efeb24-d63c-48d7-93d7-34174c9f4e15", + "name": "gtiNow", + "displayName": "GTI current (W/m2)", + "displayNameEvent": "GTI current (W/m2) changed", + "type": "double", + "defaultValue": 0 + }, + { + "id": "95ed4db9-a231-41bb-b313-78735b63fe9c", + "name": "updateTime", + "displayName": "Last update", + "displayNameEvent": "Last update changed", + "type": "int", + "defaultValue": 0, + "unit": "UnixTime" + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/openmeteo/meta.json b/openmeteo/meta.json new file mode 100644 index 00000000..846c8516 --- /dev/null +++ b/openmeteo/meta.json @@ -0,0 +1,14 @@ +{ + "title": "OpenMeteo", + "tagline": "Get current weather data for your location.", + "icon": "openmeteo.png", + "stability": "consumer", + "offline": false, + "technologies": [ + "cloud" + ], + "categories": [ + "weather", + "online-service" + ] +} diff --git a/openmeteo/openmeteo.png b/openmeteo/openmeteo.png new file mode 100644 index 00000000..029a30bf Binary files /dev/null and b/openmeteo/openmeteo.png differ diff --git a/openmeteo/openmeteo.pro b/openmeteo/openmeteo.pro new file mode 100644 index 00000000..1ff8fc73 --- /dev/null +++ b/openmeteo/openmeteo.pro @@ -0,0 +1,9 @@ +include(../plugins.pri) + +QT *= network + +SOURCES += \ + integrationpluginopenmeteo.cpp + +HEADERS += \ + integrationpluginopenmeteo.h