diff --git a/include/Configuration.h b/include/Configuration.h index a21b05be5..75503aa8f 100644 --- a/include/Configuration.h +++ b/include/Configuration.h @@ -35,6 +35,8 @@ #define DEV_MAX_MAPPING_NAME_STRLEN 63 #define LOCALE_STRLEN 2 +#define INTEGRATIONS_GOE_MAX_HOSTNAME_STRLEN 128 + struct CHANNEL_CONFIG_T { uint16_t MaxChannelPower; char Name[CHAN_MAX_NAME_STRLEN]; @@ -161,6 +163,14 @@ struct CONFIG_T { INVERTER_CONFIG_T Inverter[INV_MAX_COUNT]; char Dev_PinMapping[DEV_MAX_MAPPING_NAME_STRLEN + 1]; + + struct { + // go-e Controller + bool GoeControllerEnabled; + bool GoeControllerPublishHomeCategory; + char GoeControllerHostname[INTEGRATIONS_GOE_MAX_HOSTNAME_STRLEN + 1]; + uint32_t GoeControllerUpdateInterval; + } Integrations; }; class ConfigurationClass { diff --git a/include/IntegrationsGoeController.h b/include/IntegrationsGoeController.h new file mode 100644 index 000000000..56ccf044b --- /dev/null +++ b/include/IntegrationsGoeController.h @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#pragma once + +#include +#include "NetworkSettings.h" +#include + +class IntegrationsGoeControllerClass { +public: + IntegrationsGoeControllerClass(); + void init(Scheduler& scheduler); + +private: + void loop(); + void NetworkEvent(network_event event); + + Task _loopTask; + + bool _networkConnected = false; + HTTPClient _http; +}; + +extern IntegrationsGoeControllerClass IntegrationsGoeController; diff --git a/include/WebApi.h b/include/WebApi.h index 6e85bafde..25acd609d 100644 --- a/include/WebApi.h +++ b/include/WebApi.h @@ -10,6 +10,7 @@ #include "WebApi_firmware.h" #include "WebApi_gridprofile.h" #include "WebApi_i18n.h" +#include "WebApi_integrations.h" #include "WebApi_inverter.h" #include "WebApi_limit.h" #include "WebApi_maintenance.h" @@ -68,6 +69,7 @@ class WebApiClass { WebApiWebappClass _webApiWebapp; WebApiWsConsoleClass _webApiWsConsole; WebApiWsLiveClass _webApiWsLive; + WebApiIntegrationsClass _webApiIntegrations; }; extern WebApiClass WebApi; diff --git a/include/WebApi_errors.h b/include/WebApi_errors.h index 68e107d42..64e81cb29 100644 --- a/include/WebApi_errors.h +++ b/include/WebApi_errors.h @@ -94,4 +94,8 @@ enum WebApiError { HardwareBase = 12000, HardwarePinMappingLength, + + IntegrationsBase = 13000, + IntegrationsGoeControllerHostnameLength, + IntegrationsGoeControllerUpdateInterval, }; diff --git a/include/WebApi_integrations.h b/include/WebApi_integrations.h new file mode 100644 index 000000000..89db5a7f0 --- /dev/null +++ b/include/WebApi_integrations.h @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#pragma once + +#include +#include + +class WebApiIntegrationsClass { +public: + void init(AsyncWebServer& server, Scheduler& scheduler); + +private: + void onIntegrationsAdminGet(AsyncWebServerRequest* request); + void onIntegrationsAdminPost(AsyncWebServerRequest* request); +}; diff --git a/include/defaults.h b/include/defaults.h index bd1616912..cd2d0f8f8 100644 --- a/include/defaults.h +++ b/include/defaults.h @@ -110,3 +110,8 @@ #define MAX_INVERTER_LIMIT 2250 #define LANG_PACK_SUFFIX ".lang.json" + +#define INTEGRATIONS_GOE_CTRL_HOSTNAME "" +#define INTEGRATIONS_GOE_CTRL_ENABLED false +#define INTEGRATIONS_GOE_CTRL_ENABLE_HOME_CATEGORY false +#define INTEGRATIONS_GOE_CTRL_UPDATE_INTERVAL 3U diff --git a/src/Configuration.cpp b/src/Configuration.cpp index dd8ff158a..00209b570 100644 --- a/src/Configuration.cpp +++ b/src/Configuration.cpp @@ -151,6 +151,12 @@ bool ConfigurationClass::write() } } + JsonObject integrations = doc["integrations"].to(); + integrations["goe_ctrl_hostname"] = config.Integrations.GoeControllerHostname; + integrations["goe_ctrl_enabled"] = config.Integrations.GoeControllerEnabled; + integrations["goe_ctrl_publish_home_category"] = config.Integrations.GoeControllerPublishHomeCategory; + integrations["goe_ctrl_update_interval"] = config.Integrations.GoeControllerUpdateInterval; + if (!Utils::checkJsonAlloc(doc, __FUNCTION__, __LINE__)) { return false; } @@ -327,6 +333,12 @@ bool ConfigurationClass::read() } } + JsonObject integrations = doc["integrations"]; + strlcpy(config.Integrations.GoeControllerHostname, integrations["goe_ctrl_hostname"] | INTEGRATIONS_GOE_CTRL_HOSTNAME, sizeof(config.Integrations.GoeControllerHostname)); + config.Integrations.GoeControllerEnabled = integrations["goe_ctrl_enabled"] | INTEGRATIONS_GOE_CTRL_ENABLED; + config.Integrations.GoeControllerPublishHomeCategory = integrations["goe_ctrl_publish_home_category"] | INTEGRATIONS_GOE_CTRL_ENABLE_HOME_CATEGORY; + config.Integrations.GoeControllerUpdateInterval = integrations["goe_ctrl_update_interval"] | INTEGRATIONS_GOE_CTRL_UPDATE_INTERVAL; + f.close(); // Check for default DTU serial diff --git a/src/IntegrationsGoeController.cpp b/src/IntegrationsGoeController.cpp new file mode 100644 index 000000000..83006c3f2 --- /dev/null +++ b/src/IntegrationsGoeController.cpp @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +/* + * Copyright (C) 2023-2024 Thomas Basler and others + */ +#include "IntegrationsGoeController.h" +#include "Configuration.h" +#include "Datastore.h" +#include "MessageOutput.h" +#include "NetworkSettings.h" +#include + +IntegrationsGoeControllerClass IntegrationsGoeController; + +IntegrationsGoeControllerClass::IntegrationsGoeControllerClass() + : _loopTask(TASK_IMMEDIATE, TASK_FOREVER, std::bind(&IntegrationsGoeControllerClass::loop, this)) +{ +} + +void IntegrationsGoeControllerClass::init(Scheduler& scheduler) +{ + using std::placeholders::_1; + + NetworkSettings.onEvent(std::bind(&IntegrationsGoeControllerClass::NetworkEvent, this, _1)); + + scheduler.addTask(_loopTask); + _loopTask.setInterval(Configuration.get().Integrations.GoeControllerUpdateInterval * TASK_SECOND); + _loopTask.enable(); +} + +void IntegrationsGoeControllerClass::NetworkEvent(network_event event) +{ + switch (event) { + case network_event::NETWORK_GOT_IP: + _networkConnected = true; + break; + case network_event::NETWORK_DISCONNECTED: + _networkConnected = false; + break; + default: + break; + } +} + +void IntegrationsGoeControllerClass::loop() +{ + const auto& integrationsConfig = Configuration.get().Integrations; + + const bool reachable = Datastore.getIsAllEnabledReachable(); + + _loopTask.setInterval((reachable ? integrationsConfig.GoeControllerUpdateInterval : std::min(integrationsConfig.GoeControllerUpdateInterval, 5U)) * TASK_SECOND); + + if (!integrationsConfig.GoeControllerEnabled) { + return; + } + + if (!_networkConnected || !Hoymiles.isAllRadioIdle()) { + _loopTask.forceNextIteration(); + return; + } + + const auto value = reachable ? Datastore.getTotalAcPowerEnabled() : 0; + + // home, grid, car, relais, solar + // ecp is an array of numbers or null: [{power}, null, null, null, {power}] + // setting the home category to the power should be configurable + // url is this: http://{hostname}/api/set?ecp= + + auto url = "http://" + String(integrationsConfig.GoeControllerHostname) + "/api/set?ecp="; + + url += "["; + url += integrationsConfig.GoeControllerPublishHomeCategory ? String(value) : "null"; + url += ",null,null,null,"; + url += value; + url += "]"; + + const auto timeout = std::max(2U, std::min(integrationsConfig.GoeControllerUpdateInterval-1, 3U)) * 1000U; + + _http.setConnectTimeout(timeout); + _http.setTimeout(timeout); + _http.setReuse(true); + _http.begin(url); + + int httpCode = _http.GET(); + + if (httpCode > 0) { + if (httpCode == HTTP_CODE_OK) { + MessageOutput.println("go-e Controller updated"); + } else { + MessageOutput.printf("HTTP error: %d\n", httpCode); + } + } else { + MessageOutput.println("HTTP error"); + } +} diff --git a/src/NetworkSettings.cpp b/src/NetworkSettings.cpp index cb3af62ef..a7b069cbe 100644 --- a/src/NetworkSettings.cpp +++ b/src/NetworkSettings.cpp @@ -293,7 +293,7 @@ void NetworkSettingsClass::applyConfig() MessageOutput.print("existing credentials... "); WiFi.begin(); } - MessageOutput.println("done"); + MessageOutput.println("done. Connecting to " + String(Configuration.get().WiFi.Ssid)); setStaticIp(); } diff --git a/src/WebApi.cpp b/src/WebApi.cpp index 835a98dca..3d51e21cc 100644 --- a/src/WebApi.cpp +++ b/src/WebApi.cpp @@ -36,6 +36,7 @@ void WebApiClass::init(Scheduler& scheduler) _webApiWebapp.init(_server, scheduler); _webApiWsConsole.init(_server, scheduler); _webApiWsLive.init(_server, scheduler); + _webApiIntegrations.init(_server, scheduler); _server.begin(); } diff --git a/src/WebApi_integrations.cpp b/src/WebApi_integrations.cpp new file mode 100644 index 000000000..628b0dca0 --- /dev/null +++ b/src/WebApi_integrations.cpp @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +/* + * Copyright (C) 2022-2024 Thomas Basler and others + */ +#include "WebApi_integrations.h" +#include "Configuration.h" +#include "WebApi.h" +#include "WebApi_errors.h" +#include "helper.h" +#include + +void WebApiIntegrationsClass::init(AsyncWebServer& server, Scheduler& scheduler) +{ + using std::placeholders::_1; + + server.on("/api/integrations/config", HTTP_GET, std::bind(&WebApiIntegrationsClass::onIntegrationsAdminGet, this, _1)); + server.on("/api/integrations/config", HTTP_POST, std::bind(&WebApiIntegrationsClass::onIntegrationsAdminPost, this, _1)); +} + +void WebApiIntegrationsClass::onIntegrationsAdminGet(AsyncWebServerRequest* request) +{ + if (!WebApi.checkCredentials(request)) { + return; + } + + AsyncJsonResponse* response = new AsyncJsonResponse(); + auto& root = response->getRoot(); + const CONFIG_T& config = Configuration.get(); + + root["goe_ctrl_hostname"] = config.Integrations.GoeControllerHostname; + root["goe_ctrl_enabled"] = config.Integrations.GoeControllerEnabled; + root["goe_ctrl_publish_home_category"] = config.Integrations.GoeControllerPublishHomeCategory; + root["goe_ctrl_update_interval"] = config.Integrations.GoeControllerUpdateInterval; + + WebApi.sendJsonResponse(request, response, __FUNCTION__, __LINE__); +} + +void WebApiIntegrationsClass::onIntegrationsAdminPost(AsyncWebServerRequest* request) +{ + if (!WebApi.checkCredentials(request)) { + return; + } + + AsyncJsonResponse* response = new AsyncJsonResponse(); + JsonDocument root; + if (!WebApi.parseRequestData(request, response, root)) { + return; + } + + auto& retMsg = response->getRoot(); + + if (!(root["goe_ctrl_hostname"].is() + && root["goe_ctrl_enabled"].is() + && root["goe_ctrl_publish_home_category"].is() + && root["goe_ctrl_update_interval"].is())) { + retMsg["message"] = "Values are missing!"; + retMsg["code"] = WebApiError::GenericValueMissing; + WebApi.sendJsonResponse(request, response, __FUNCTION__, __LINE__); + return; + } + + if (root["goe_ctrl_enabled"].as()) { + if (root["goe_ctrl_hostname"].as().length() == 0 || root["goe_ctrl_hostname"].as().length() > INTEGRATIONS_GOE_MAX_HOSTNAME_STRLEN) { + retMsg["message"] = "go-e Controller hostname must between 1 and " STR(INTEGRATIONS_GOE_MAX_HOSTNAME_STRLEN) " characters long!"; + retMsg["code"] = WebApiError::IntegrationsGoeControllerHostnameLength; + retMsg["param"]["max"] = INTEGRATIONS_GOE_MAX_HOSTNAME_STRLEN; + WebApi.sendJsonResponse(request, response, __FUNCTION__, __LINE__); + return; + } + + if (root["goe_ctrl_update_interval"].as() < 3 || root["goe_ctrl_update_interval"].as() > 65535) { + retMsg["message"] = "go-e Controller update interval must between 3 and 65535!"; + retMsg["code"] = WebApiError::IntegrationsGoeControllerUpdateInterval; + retMsg["param"]["min"] = 3; + retMsg["param"]["max"] = 65535; + WebApi.sendJsonResponse(request, response, __FUNCTION__, __LINE__); + return; + } + } + + { + auto guard = Configuration.getWriteGuard(); + auto& config = guard.getConfig(); + + config.Integrations.GoeControllerEnabled = root["goe_ctrl_enabled"].as(); + config.Integrations.GoeControllerPublishHomeCategory = root["goe_ctrl_publish_home_category"].as(); + config.Integrations.GoeControllerUpdateInterval = root["goe_ctrl_update_interval"].as(); + strlcpy(config.Integrations.GoeControllerHostname, root["goe_ctrl_hostname"].as().c_str(), sizeof(config.Integrations.GoeControllerHostname)); + } + + WebApi.writeConfig(retMsg); + + WebApi.sendJsonResponse(request, response, __FUNCTION__, __LINE__); +} diff --git a/src/main.cpp b/src/main.cpp index 0851b19bc..f2c0c8b96 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -5,6 +5,7 @@ #include "Configuration.h" #include "Datastore.h" #include "Display_Graphic.h" +#include "IntegrationsGoeController.h" #include "I18n.h" #include "InverterSettings.h" #include "Led_Single.h" @@ -121,6 +122,11 @@ void setup() MqttHandleHass.init(scheduler); MessageOutput.println("done"); + // Initialize go-e Integration + MessageOutput.print("Initialize go-e Integration... "); + IntegrationsGoeController.init(scheduler); + MessageOutput.println("done"); + // Initialize WebApi MessageOutput.print("Initialize WebApi... "); WebApi.init(scheduler); diff --git a/webapp/.nvmrc b/webapp/.nvmrc new file mode 100644 index 000000000..728f7de5c --- /dev/null +++ b/webapp/.nvmrc @@ -0,0 +1 @@ +22.9.0 diff --git a/webapp/package.json b/webapp/package.json index a646f6c0f..799f7ba53 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -47,5 +47,8 @@ "vite-plugin-css-injected-by-js": "^3.5.2", "vue-tsc": "^2.1.10" }, + "engines": { + "node": ">=21.1.0" + }, "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } diff --git a/webapp/src/components/InputElement.vue b/webapp/src/components/InputElement.vue index 8e21f9a78..4bb72a263 100644 --- a/webapp/src/components/InputElement.vue +++ b/webapp/src/components/InputElement.vue @@ -5,7 +5,7 @@ :class="[wide ? 'col-sm-4' : 'col-sm-2', isCheckbox ? 'form-check-label' : 'col-form-label']" > {{ label }} - +
diff --git a/webapp/src/components/NavBar.vue b/webapp/src/components/NavBar.vue index 2cae5d09c..2111ee6d0 100644 --- a/webapp/src/components/NavBar.vue +++ b/webapp/src/components/NavBar.vue @@ -73,6 +73,11 @@ $t('menu.DeviceManager') }} +
  • + {{ + $t('menu.Integrations') + }} +
  • diff --git a/webapp/src/locales/de.json b/webapp/src/locales/de.json index e7890a972..2e03131e2 100644 --- a/webapp/src/locales/de.json +++ b/webapp/src/locales/de.json @@ -111,7 +111,9 @@ "10002": "Authentifizierung erfolgreich!", "11001": "@:apiresponse.2001", "11002": "@:apiresponse:5004", - "12001": "Profil muss zwischen 1 und {max} Zeichen lang sein!" + "12001": "Profil muss zwischen 1 und {max} Zeichen lang sein!", + "13001": "Hostname muss zwischen 1 und {max} Zeichen lang sein!", + "13002": "Aktualisierungsintervall muss zwischen {min} und {max} Sekunden liegen!" }, "home": { "LiveData": "Live-Daten", @@ -662,6 +664,18 @@ "EqualBrightness": "Gleiche Helligkeit", "LedBrightness": "LED {led} Helligkeit ({brightness})" }, + "integrationsadmin": { + "IntegrationSettings": "Integrationseinstellungen", + "Save": "@:base.Save", + "Cancel": "@:base.Cancel", + "goecontroller": "go-e Controller", + "goecontrollerEnabled": "go-e Controller aktivieren", + "goecontrollerEnabledHint": "Aktiviere die go-e Controller-Integration. Dadurch wird der 'ecp' API-Key gesetzt, um dem Controller mitzuteilen, wie viel Solarenergie produziert wird.", + "goecontrollerHostname": "Hostname", + "goecontrollerUpdateInterval": "Aktualisierungsintervall", + "goecontrollerEnableHomeCategory": "Heim-Kategorie aktivieren", + "goecontrollerEnableHomeCategoryHint": "Setze auch den Wert für die Heim-Kategorie. Auf diese Weise kannst du den korrekten Verbrauch deines Hauses haben, wenn dein Wechselrichter auch in der Heim-Kategorie enthalten ist." + }, "pininfo": { "Category": "Kategorie", "Name": "Name", diff --git a/webapp/src/locales/en.json b/webapp/src/locales/en.json index 71c72efd5..442dac54a 100644 --- a/webapp/src/locales/en.json +++ b/webapp/src/locales/en.json @@ -9,6 +9,7 @@ "SecuritySettings": "Security Settings", "DTUSettings": "DTU Settings", "DeviceManager": "Device-Manager", + "Integrations": "Integrations", "ConfigManagement": "Config Management", "FirmwareUpgrade": "Firmware Upgrade", "DeviceReboot": "Device Reboot", @@ -111,7 +112,9 @@ "10002": "Authentication successful!", "11001": "@:apiresponse.2001", "11002": "@:apiresponse:5004", - "12001": "Profil must between 1 and {max} characters long!" + "12001": "Profil must between 1 and {max} characters long!", + "13001": "Hostname must between 1 and {max} characters long!", + "13002": "Update interval must between {min} and {max} seconds!" }, "home": { "LiveData": "Live Data", @@ -662,6 +665,18 @@ "EqualBrightness": "Equal brightness", "LedBrightness": "LED {led} brightness ({brightness})" }, + "integrationsadmin": { + "IntegrationSettings": "Integration Settings", + "Save": "@:base.Save", + "Cancel": "@:base.Cancel", + "goecontroller": "go-e Controller", + "goecontrollerEnabled": "Enable go-e Controller", + "goecontrollerEnabledHint": "Enable the go-e controller integration. This will set the 'ecp' api-key to let the controller know how much solar energy is produced", + "goecontrollerHostname": "Hostname", + "goecontrollerUpdateInterval": "Update Interval", + "goecontrollerEnableHomeCategory": "Enable Home Category", + "goecontrollerEnableHomeCategoryHint": "Also set the value for the home category. This way you can have the correct consumption of your home if your inverter is also included in the home category." + }, "pininfo": { "Category": "Category", "Name": "Name", diff --git a/webapp/src/locales/fr.json b/webapp/src/locales/fr.json index 40b2f2caa..9395fcd0c 100644 --- a/webapp/src/locales/fr.json +++ b/webapp/src/locales/fr.json @@ -111,7 +111,9 @@ "10002": "Authentification réussie !", "11001": "@:apiresponse.2001", "11002": "@:apiresponse:5004", - "12001": "Le profil doit comporter entre 1 et {max} caractères !" + "12001": "Le profil doit comporter entre 1 et {max} caractères !", + "13001": "Hostname must between 1 and {max} characters long!", + "13002": "Update interval must between {min} and {max} seconds!" }, "home": { "LiveData": "Données en direct", @@ -644,6 +646,18 @@ "EqualBrightness": "Même luminosité", "LedBrightness": "LED {led} luminosité ({brightness})" }, + "integrationsadmin": { + "IntegrationSettings": "Integration Settings", + "Save": "@:base.Save", + "Cancel": "@:base.Cancel", + "goecontroller": "go-e Controller", + "goecontrollerEnabled": "Enable go-e Controller", + "goecontrollerEnabledHint": "Enable the go-e controller integration. This will set the 'ecp' api-key to let the controller know how much solar energy is produced", + "goecontrollerHostname": "Hostname", + "goecontrollerUpdateInterval": "Update Interval", + "goecontrollerEnableHomeCategory": "Enable Home Category", + "goecontrollerEnableHomeCategoryHint": "Also set the value for the home category. This way you can have the correct consumption of your home if your inverter is also included in the home category." + }, "pininfo": { "Category": "Catégorie", "Name": "Nom", diff --git a/webapp/src/router/index.ts b/webapp/src/router/index.ts index cd3bdbecb..43b2ae55e 100644 --- a/webapp/src/router/index.ts +++ b/webapp/src/router/index.ts @@ -18,6 +18,7 @@ import NtpInfoView from '@/views/NtpInfoView.vue'; import SecurityAdminView from '@/views/SecurityAdminView.vue'; import SystemInfoView from '@/views/SystemInfoView.vue'; import WaitRestartView from '@/views/WaitRestartView.vue'; +import IntegrationsAdminView from '@/views/IntegrationsAdminView.vue'; import { createRouter, createWebHistory } from 'vue-router'; const router = createRouter({ @@ -106,6 +107,11 @@ const router = createRouter({ name: 'Device Manager', component: DeviceAdminView, }, + { + path: '/settings/integrations', + name: 'Integrations', + component: IntegrationsAdminView, + }, { path: '/firmware/upgrade', name: 'Firmware Upgrade', diff --git a/webapp/src/types/IntegrationsConfig.ts b/webapp/src/types/IntegrationsConfig.ts new file mode 100644 index 000000000..fc4dded2d --- /dev/null +++ b/webapp/src/types/IntegrationsConfig.ts @@ -0,0 +1,6 @@ +export interface IntegrationsConfig { + goe_ctrl_enabled: boolean; + goe_ctrl_hostname: string; + goe_ctrl_publish_home_category: boolean; + goe_ctrl_update_interval: number; +} diff --git a/webapp/src/views/IntegrationsAdminView.vue b/webapp/src/views/IntegrationsAdminView.vue new file mode 100644 index 000000000..7a2c1150e --- /dev/null +++ b/webapp/src/views/IntegrationsAdminView.vue @@ -0,0 +1,109 @@ + + + diff --git a/webapp/src/views/MqttAdminView.vue b/webapp/src/views/MqttAdminView.vue index da62b781a..db588b5a7 100644 --- a/webapp/src/views/MqttAdminView.vue +++ b/webapp/src/views/MqttAdminView.vue @@ -80,7 +80,7 @@ v-model="mqttConfigList.mqtt_publish_interval" type="number" min="5" - max="86400" + max="65535" :postfix="$t('mqttadmin.Seconds')" />