diff --git a/include/Configuration.h b/include/Configuration.h index 3b99c38bb..7994c8e3e 100644 --- a/include/Configuration.h +++ b/include/Configuration.h @@ -324,6 +324,19 @@ struct CONFIG_T { float Auto_Power_Target_Power_Consumption; } Huawei; + struct { + bool Enabled; + bool VerboseLogging; + bool Auto_Power_BatterySoC_Limits_Enabled; + bool Emergency_Charge_Enabled; + uint8_t stop_batterysoc_threshold; + char url[1025]; + int32_t POWER_ON_threshold; + int32_t POWER_OFF_threshold; + bool POWER_ON; + bool POWER_OFF; + } Shelly; + INVERTER_CONFIG_T Inverter[INV_MAX_COUNT]; char Dev_PinMapping[DEV_MAX_MAPPING_NAME_STRLEN + 1]; diff --git a/include/ShellyACPlug.h b/include/ShellyACPlug.h new file mode 100644 index 000000000..d73819d34 --- /dev/null +++ b/include/ShellyACPlug.h @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#pragma once +#include +#include +#include "HttpGetter.h" +#include "Configuration.h" +#include +#include +#include +#include +#include +#include + +class ShellyACPlugClass { + public: + bool init(Scheduler& scheduler); + void loop(); + void PowerON(); + void PowerOFF(); + float _readpower; + private: + bool _initialized = false; + Task _loopTask; + const uint16_t _period = 2000; + float _acPower; + float _SoC; + bool _emergcharge; + bool send_http(String uri); + float read_http(String uri); + std::unique_ptr _HttpGetter; +}; + +extern ShellyACPlugClass ShellyACPlug; diff --git a/include/WebApi.h b/include/WebApi.h index c995ecfca..3973e685c 100644 --- a/include/WebApi.h +++ b/include/WebApi.h @@ -31,9 +31,12 @@ #include "WebApi_ws_Huawei.h" #include "WebApi_Huawei.h" #include "WebApi_ws_battery.h" +#include "WebApi_Shelly.h" +#include "WebApi_ws_Shelly.h" #include #include + class WebApiClass { public: WebApiClass(); @@ -82,6 +85,8 @@ class WebApiClass { WebApiHuaweiClass _webApiHuaweiClass; WebApiWsHuaweiLiveClass _webApiWsHuaweiLive; WebApiWsBatteryLiveClass _webApiWsBatteryLive; + WebApiShellyClass _webApiShellyClass; + WebApiWsShellyLiveClass _webApiWsShellyLive; }; extern WebApiClass WebApi; diff --git a/include/WebApi_Shelly.h b/include/WebApi_Shelly.h new file mode 100644 index 000000000..1bdf4056d --- /dev/null +++ b/include/WebApi_Shelly.h @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#pragma once + +#include +#include +#include + +class WebApiShellyClass { +public: + void init(AsyncWebServer& server, Scheduler& scheduler); +private: + void onStatus(AsyncWebServerRequest* request); + void onAdminGet(AsyncWebServerRequest* request); + void onAdminPost(AsyncWebServerRequest* request); + + AsyncWebServer* _server; +}; diff --git a/include/WebApi_ws_Shelly.h b/include/WebApi_ws_Shelly.h new file mode 100644 index 000000000..f8963a609 --- /dev/null +++ b/include/WebApi_ws_Shelly.h @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#pragma once + +#include "ArduinoJson.h" +#include +#include +#include + +class WebApiWsShellyLiveClass { +public: + WebApiWsShellyLiveClass(); + void init(AsyncWebServer& server, Scheduler& scheduler); + void reload(); + +private: + void generateCommonJsonResponse(JsonVariant& root); + void onLivedataStatus(AsyncWebServerRequest* request); + void onWebsocketEvent(AsyncWebSocket* server, AsyncWebSocketClient* client, AwsEventType type, void* arg, uint8_t* data, size_t len); + + AsyncWebServer* _server; + AsyncWebSocket _ws; + AuthenticationMiddleware _simpleDigestAuth; + + std::mutex _mutex; + + Task _wsCleanupTask; + void wsCleanupTaskCb(); + + Task _sendDataTask; + void sendDataTaskCb(); +}; diff --git a/include/WebApi_ws_live.h b/include/WebApi_ws_live.h index e02f9a8c1..fca820097 100644 --- a/include/WebApi_ws_live.h +++ b/include/WebApi_ws_live.h @@ -35,7 +35,7 @@ class WebApiWsLiveClass { uint32_t _lastPublishHuawei = 0; uint32_t _lastPublishBattery = 0; uint32_t _lastPublishPowerMeter = 0; - + uint32_t _lastPublishShelly = 0; uint32_t _lastPublishStats[INV_MAX_COUNT] = { 0 }; std::mutex _mutex; diff --git a/include/defaults.h b/include/defaults.h index 2ab8ec9e8..10c635abb 100644 --- a/include/defaults.h +++ b/include/defaults.h @@ -167,4 +167,12 @@ #define HUAWEI_AUTO_POWER_STOP_BATTERYSOC_THRESHOLD 95 #define HUAWEI_AUTO_POWER_TARGET_POWER_CONSUMPTION 0 +#define SHELLY_ENABLED false +#define SHELLY_POWER_ON_THRESHOLD -500 +#define SHELLY_POWER_OFF_THRESHOLD -100 +#define SHELLY_STOP_BATTERYSOC_THRESHOLD 95 +#define SHELLY_IPADDRESS "http://192.168.2.100" +#define SHELLY_POWER_ON false +#define SHELLY_POWER_OFF false + #define VERBOSE_LOGGING true diff --git a/src/Configuration.cpp b/src/Configuration.cpp index 61ee7758d..f7b12ea48 100644 --- a/src/Configuration.cpp +++ b/src/Configuration.cpp @@ -292,6 +292,18 @@ bool ConfigurationClass::write() huawei["stop_batterysoc_threshold"] = config.Huawei.Auto_Power_Stop_BatterySoC_Threshold; huawei["target_power_consumption"] = config.Huawei.Auto_Power_Target_Power_Consumption; + JsonObject shelly = doc["shelly"].to(); + shelly["enabled"] = config.Shelly.Enabled; + shelly["verbose_logging"] = config.Shelly.VerboseLogging; + shelly["auto_power_batterysoc_limits_enabled"]= config.Shelly.Auto_Power_BatterySoC_Limits_Enabled ; + shelly["emergency_charge_enabled"]= config.Shelly.Emergency_Charge_Enabled; + shelly["stop_batterysoc_threshold"] = config.Shelly.stop_batterysoc_threshold; + shelly["url"] = config.Shelly.url; + shelly["power_on_threshold"] = config.Shelly.POWER_ON_threshold; + shelly["power_off_threshold"] = config.Shelly.POWER_OFF_threshold; + shelly["power_on"] = config.Shelly.POWER_ON; + shelly["power_off"] = config.Shelly.POWER_OFF; + if (!Utils::checkJsonAlloc(doc, __FUNCTION__, __LINE__)) { return false; } @@ -657,6 +669,18 @@ bool ConfigurationClass::read() config.Huawei.Auto_Power_Stop_BatterySoC_Threshold = huawei["stop_batterysoc_threshold"] | HUAWEI_AUTO_POWER_STOP_BATTERYSOC_THRESHOLD; config.Huawei.Auto_Power_Target_Power_Consumption = huawei["target_power_consumption"] | HUAWEI_AUTO_POWER_TARGET_POWER_CONSUMPTION; + JsonObject shelly = doc["shelly"]; + config.Shelly.Enabled = shelly["enabled"] | SHELLY_ENABLED; + config.Shelly.VerboseLogging = shelly["verbose_logging"] | VERBOSE_LOGGING; + config.Shelly.Auto_Power_BatterySoC_Limits_Enabled = shelly["auto_power_batterysoc_limits_enabled"] | false; + config.Shelly.Emergency_Charge_Enabled = shelly["emergency_charge_enabled"] | false; + config.Shelly.stop_batterysoc_threshold = shelly["stop_batterysoc_threshold"] | SHELLY_STOP_BATTERYSOC_THRESHOLD; + strlcpy(config.Shelly.url, shelly["url"] | SHELLY_IPADDRESS, sizeof(config.Shelly.url)); + config.Shelly.POWER_ON_threshold = shelly["power_on_threshold"] | SHELLY_POWER_ON_THRESHOLD; + config.Shelly.POWER_OFF_threshold = shelly["power_off_threshold"] | SHELLY_POWER_OFF_THRESHOLD; + config.Shelly.POWER_ON = shelly["power_on"] | false; + config.Shelly.POWER_OFF = shelly["power_off"] | false; + f.close(); return true; } diff --git a/src/ShellyACPlug.cpp b/src/ShellyACPlug.cpp new file mode 100644 index 000000000..5b5573a2e --- /dev/null +++ b/src/ShellyACPlug.cpp @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#include "Utils.h" +#include "ShellyACPlug.h" +#include "MessageOutput.h" +#include +#include +#include +#include "Configuration.h" +#include "Datastore.h" +#include "PowerMeter.h" +#include "Battery.h" + +ShellyACPlugClass ShellyACPlug; + + +bool ShellyACPlugClass::init(Scheduler& scheduler) +{ + MessageOutput.printf("ShellyACPlug Initializing ... \r\n"); + _initialized = true; + scheduler.addTask(_loopTask); + _loopTask.setCallback([this] { loop(); }); + _loopTask.setIterations(TASK_FOREVER); + _loopTask.setInterval(_period); + _loopTask.enable(); + return false; +} + +void ShellyACPlugClass::loop() +{ + const CONFIG_T& config = Configuration.get(); + if (!config.Shelly.Enabled || !_initialized || !Configuration.get().PowerMeter.Enabled ) { + return; + } + _loopTask.setInterval(_period); + _acPower = PowerMeter.getPowerTotal(); + _SoC = Battery.getStats()->getSoC(); + _emergcharge = Battery.getStats()->getImmediateChargingRequest(); + _readpower = read_http("/rpc/Switch.GetStatus?id=0"); + if ((_acPower < config.Shelly.POWER_ON_threshold && !config.Shelly.POWER_ON && _SoC < config.Shelly.stop_batterysoc_threshold) || (_emergcharge && config.Shelly.Emergency_Charge_Enabled)) + { + PowerON(); + } + else if ((_acPower > config.Shelly.POWER_OFF_threshold && !config.Shelly.POWER_OFF) || (_SoC >= config.Shelly.stop_batterysoc_threshold && !config.Shelly.POWER_OFF)) + { + PowerOFF(); + } + if (config.Shelly.VerboseLogging) { + MessageOutput.print("[ShellyACPlug] Loop \r\n"); + MessageOutput.printf("[ShellyACPlug] %f W \r\n", _acPower ); + MessageOutput.printf("[ShellyACPlug] ON: %d OFF: %d \r\n", config.Shelly.POWER_ON, config.Shelly.POWER_OFF ); + MessageOutput.printf("[ShellyACPlug] Battery SoC %f \r\n", _SoC); + MessageOutput.printf("[ShellyACPlug] Verbrauch %f W \r\n", _readpower ); + } +} + +void ShellyACPlugClass::PowerON() +{ + if (!send_http("/relay/0?turn=on")) + { + return; + } + CONFIG_T& config = Configuration.get(); + config.Shelly.POWER_ON = true; + config.Shelly.POWER_OFF = false; + Configuration.write(); + if (config.Shelly.VerboseLogging) { + MessageOutput.print("[ShellyACPlug] Power ON \r\n"); + } +} + +void ShellyACPlugClass::PowerOFF() +{ + if (!send_http("/relay/0?turn=off")) + { + return; + }; + CONFIG_T& config = Configuration.get(); + config.Shelly.POWER_ON = false; + config.Shelly.POWER_OFF = true; + Configuration.write(); + if (config.Shelly.VerboseLogging) { + MessageOutput.print("[ShellyACPlug] Power OFF \r\n"); + } +} + +bool ShellyACPlugClass::send_http(String uri) +{ + CONFIG_T& config = Configuration.get(); + String url = config.Shelly.url; + url += uri; + HttpRequestConfig HttpRequest; + strlcpy(HttpRequest.Url, url.c_str(), sizeof(HttpRequest.Url)); + HttpRequest.Timeout = 60; + _HttpGetter = std::make_unique(HttpRequest); + if (config.Shelly.VerboseLogging) { + MessageOutput.printf("[ShellyACPlug] send_http Initializing: %s\r\n",url.c_str()); + } + if (!_HttpGetter->init()) { + MessageOutput.printf("[ShellyACPlug] INIT %s\r\n", _HttpGetter->getErrorText()); + return false; + } + if (!_HttpGetter->performGetRequest()) { + MessageOutput.printf("[ShellyACPlug] GET %s\r\n", _HttpGetter->getErrorText()); + return false; + } + _HttpGetter = nullptr; + return true; +} +float ShellyACPlugClass::read_http(String uri) +{ + CONFIG_T& config = Configuration.get(); + String url = config.Shelly.url; + url += uri; + HttpRequestConfig HttpRequest; + JsonDocument jsonResponse; + strlcpy(HttpRequest.Url, url.c_str(), sizeof(HttpRequest.Url)); + HttpRequest.Timeout = 60; + _HttpGetter = std::make_unique(HttpRequest); + if (config.Shelly.VerboseLogging) { + MessageOutput.printf("[ShellyACPlug] read_http Initializing: %s\r\n",url.c_str()); + } + if (!_HttpGetter->init()) { + MessageOutput.printf("[ShellyACPlug] INIT %s\r\n", _HttpGetter->getErrorText()); + return 0; + } + _HttpGetter->addHeader("Content-Type", "application/json"); + _HttpGetter->addHeader("Accept", "application/json"); + auto res = _HttpGetter->performGetRequest(); + if (!res) { + MessageOutput.printf("[ShellyACPlug] GET %s\r\n", _HttpGetter->getErrorText()); + return 0; + } + auto pStream = res.getStream(); + if (!pStream) { + MessageOutput.printf("Programmer error: HTTP request yields no stream"); + return 0; + } + + const DeserializationError error = deserializeJson(jsonResponse, *pStream); + if (error) { + String msg("[ShellyACPlug] Unable to parse server response as JSON: "); + MessageOutput.printf((msg + error.c_str()).c_str()); + return 0; + } + auto pathResolutionResult = Utils::getJsonValueByPath(jsonResponse, "apower"); + if (!pathResolutionResult.second.isEmpty()) { + MessageOutput.printf("[ShellyACPlug] second %s\r\n",pathResolutionResult.second.c_str()); + } + + _HttpGetter = nullptr; + return pathResolutionResult.first; +} + + diff --git a/src/WebApi.cpp b/src/WebApi.cpp index 7d23e5fb5..f45d87358 100644 --- a/src/WebApi.cpp +++ b/src/WebApi.cpp @@ -43,7 +43,8 @@ void WebApiClass::init(Scheduler& scheduler) _webApiWsHuaweiLive.init(_server, scheduler); _webApiHuaweiClass.init(_server, scheduler); _webApiWsBatteryLive.init(_server, scheduler); - + _webApiShellyClass.init(_server, scheduler); + _webApiWsShellyLive.init(_server, scheduler); _server.begin(); } @@ -54,6 +55,7 @@ void WebApiClass::reload() _webApiWsBatteryLive.reload(); _webApiWsVedirectLive.reload(); _webApiWsHuaweiLive.reload(); + _webApiWsShellyLive.reload(); } bool WebApiClass::checkCredentials(AsyncWebServerRequest* request) diff --git a/src/WebApi_Shelly.cpp b/src/WebApi_Shelly.cpp new file mode 100644 index 000000000..c9ec02917 --- /dev/null +++ b/src/WebApi_Shelly.cpp @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +/* + * Copyright (C) 2022-2024 HSS + */ +#include "WebApi_Shelly.h" +#include "Configuration.h" +#include "MessageOutput.h" +#include "WebApi.h" +#include "WebApi_errors.h" +#include +#include +#include "ShellyACPlug.h" + +void WebApiShellyClass::init(AsyncWebServer& server, Scheduler& scheduler) +{ + using std::placeholders::_1; + + _server = &server; + + _server->on("/api/shelly/status", HTTP_GET, std::bind(&WebApiShellyClass::onStatus, this, _1)); + _server->on("/api/shelly/config", HTTP_GET, std::bind(&WebApiShellyClass::onAdminGet, this, _1)); + _server->on("/api/shelly/config", HTTP_POST, std::bind(&WebApiShellyClass::onAdminPost, this, _1)); +} + +void WebApiShellyClass::onStatus(AsyncWebServerRequest* request) +{ + if (!WebApi.checkCredentialsReadonly(request)) { + return; + } + + AsyncJsonResponse* response = new AsyncJsonResponse(); + + response->setLength(); + request->send(response); +} + +void WebApiShellyClass::onAdminGet(AsyncWebServerRequest* request) +{ + if (!WebApi.checkCredentials(request)) { + return; + } + + AsyncJsonResponse* response = new AsyncJsonResponse(); + auto& root = response->getRoot(); + const CONFIG_T& config = Configuration.get(); + + root["enabled"] = config.Shelly.Enabled; + root["verbose_logging"] = config.Shelly.VerboseLogging; + root["auto_power_batterysoc_limits_enabled"] = config.Shelly.Auto_Power_BatterySoC_Limits_Enabled; + root["emergency_charge_enabled"] = config.Shelly.Emergency_Charge_Enabled; + root["stop_batterysoc_threshold"] = config.Shelly.stop_batterysoc_threshold; + root["url"] = config.Shelly.url; + root["power_on_threshold"] = config.Shelly.POWER_ON_threshold; + root["power_off_threshold"] = config.Shelly.POWER_OFF_threshold; + + response->setLength(); + request->send(response); + MessageOutput.println("Read Shelly AC charger config... "); +} + +void WebApiShellyClass::onAdminPost(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["enabled"].is()) || + !(root["emergency_charge_enabled"].is())){ + retMsg["message"] = "Values are missing!"; + retMsg["code"] = WebApiError::GenericValueMissing; + response->setLength(); + request->send(response); + return; + } + + CONFIG_T& config = Configuration.get(); + config.Shelly.Enabled = root["enabled"].as(); + config.Shelly.VerboseLogging = root["verbose_logging"]; + config.Shelly.Auto_Power_BatterySoC_Limits_Enabled = root["auto_power_batterysoc_limits_enabled"].as(); + config.Shelly.Emergency_Charge_Enabled = root["emergency_charge_enabled"].as(); + config.Shelly.stop_batterysoc_threshold = root["stop_batterysoc_threshold"]; + strlcpy( config.Shelly.url, root["url"].as().c_str(), sizeof(config.Shelly.url)); + config.Shelly.POWER_ON_threshold = root["power_on_threshold"]; + config.Shelly.POWER_OFF_threshold = root["power_off_threshold"]; + WebApi.writeConfig(retMsg); + WebApi.sendJsonResponse(request, response, __FUNCTION__, __LINE__); + + + yield(); + delay(1000); + yield(); + + if (config.Shelly.Enabled) { + MessageOutput.println("Initialize Shelly AC charger interface... "); + } + + if (!config.Shelly.Enabled) { + ShellyACPlug.PowerOFF(); + return; + } +} diff --git a/src/WebApi_ws_Shelly.cpp b/src/WebApi_ws_Shelly.cpp new file mode 100644 index 000000000..80ed90dc1 --- /dev/null +++ b/src/WebApi_ws_Shelly.cpp @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +/* + * Copyright (C) 2022-2024 Thomas Basler and others + */ +#include "WebApi_ws_Shelly.h" +#include "AsyncJson.h" +#include "ShellyACPlug.h" +#include "Configuration.h" +#include "MessageOutput.h" +#include "Utils.h" +#include "WebApi.h" +#include "defaults.h" + +WebApiWsShellyLiveClass::WebApiWsShellyLiveClass() + : _ws("/shellylivedata") +{ +} + +void WebApiWsShellyLiveClass::init(AsyncWebServer& server, Scheduler& scheduler) +{ + using std::placeholders::_1; + using std::placeholders::_2; + using std::placeholders::_3; + using std::placeholders::_4; + using std::placeholders::_5; + using std::placeholders::_6; + + _server = &server; + _server->on("/api/shellylivedata/status", HTTP_GET, std::bind(&WebApiWsShellyLiveClass::onLivedataStatus, this, _1)); + + _server->addHandler(&_ws); + _ws.onEvent(std::bind(&WebApiWsShellyLiveClass::onWebsocketEvent, this, _1, _2, _3, _4, _5, _6)); + + scheduler.addTask(_wsCleanupTask); + _wsCleanupTask.setCallback(std::bind(&WebApiWsShellyLiveClass::wsCleanupTaskCb, this)); + _wsCleanupTask.setIterations(TASK_FOREVER); + _wsCleanupTask.setInterval(1 * TASK_SECOND); + _wsCleanupTask.enable(); + + scheduler.addTask(_sendDataTask); + _sendDataTask.setCallback(std::bind(&WebApiWsShellyLiveClass::sendDataTaskCb, this)); + _sendDataTask.setIterations(TASK_FOREVER); + _sendDataTask.setInterval(1 * TASK_SECOND); + _sendDataTask.enable(); + + _simpleDigestAuth.setUsername(AUTH_USERNAME); + _simpleDigestAuth.setRealm("AC charger websocket"); + + reload(); +} + +void WebApiWsShellyLiveClass::reload() +{ + _ws.removeMiddleware(&_simpleDigestAuth); + + auto const& config = Configuration.get(); + + if (config.Security.AllowReadonly) { return; } + + _ws.enable(false); + _simpleDigestAuth.setPassword(config.Security.Password); + _ws.addMiddleware(&_simpleDigestAuth); + _ws.closeAll(); + _ws.enable(true); +} + +void WebApiWsShellyLiveClass::wsCleanupTaskCb() +{ + // see: https://github.com/me-no-dev/ESPAsyncWebServer#limiting-the-number-of-web-socket-clients + _ws.cleanupClients(); +} + +void WebApiWsShellyLiveClass::sendDataTaskCb() +{ + // do nothing if no WS client is connected + if (_ws.count() == 0) { + return; + } + + try { + std::lock_guard lock(_mutex); + JsonDocument root; + JsonVariant var = root; + + generateCommonJsonResponse(var); + + if (Utils::checkJsonAlloc(root, __FUNCTION__, __LINE__)) { + String buffer; + serializeJson(root, buffer); + + _ws.textAll(buffer); + } + } catch (std::bad_alloc& bad_alloc) { + MessageOutput.printf("Calling /api/shellylivedata/status has temporarily run out of resources. Reason: \"%s\".\r\n", bad_alloc.what()); + } catch (const std::exception& exc) { + MessageOutput.printf("Unknown exception in /api/shellylivedata/status. Reason: \"%s\".\r\n", exc.what()); + } +} + +void WebApiWsShellyLiveClass::generateCommonJsonResponse(JsonVariant& root) +{ + root["input_power"]["v"] = ShellyACPlug._readpower; + root["input_power"]["u"] = "W"; + root["enabled"] = true; + +} + +void WebApiWsShellyLiveClass::onWebsocketEvent(AsyncWebSocket* server, AsyncWebSocketClient* client, AwsEventType type, void* arg, uint8_t* data, size_t len) +{ + if (type == WS_EVT_CONNECT) { + char str[64]; + snprintf(str, sizeof(str), "Websocket: [%s][%u] connect", server->url(), client->id()); + Serial.println(str); + MessageOutput.println(str); + } else if (type == WS_EVT_DISCONNECT) { + char str[64]; + snprintf(str, sizeof(str), "Websocket: [%s][%u] disconnect", server->url(), client->id()); + Serial.println(str); + MessageOutput.println(str); + } +} + +void WebApiWsShellyLiveClass::onLivedataStatus(AsyncWebServerRequest* request) +{ + if (!WebApi.checkCredentialsReadonly(request)) { + return; + } + try { + std::lock_guard lock(_mutex); + AsyncJsonResponse* response = new AsyncJsonResponse(); + auto& root = response->getRoot(); + + generateCommonJsonResponse(root); + + WebApi.sendJsonResponse(request, response, __FUNCTION__, __LINE__); + + } catch (std::bad_alloc& bad_alloc) { + MessageOutput.printf("Calling /api/shellylivedata/status has temporarily run out of resources. Reason: \"%s\".\r\n", bad_alloc.what()); + WebApi.sendTooManyRequests(request); + } catch (const std::exception& exc) { + MessageOutput.printf("Unknown exception in /api/shellylivedata/status. Reason: \"%s\".\r\n", exc.what()); + WebApi.sendTooManyRequests(request); + } +} diff --git a/src/WebApi_ws_live.cpp b/src/WebApi_ws_live.cpp index e3fc7570f..7444c0833 100644 --- a/src/WebApi_ws_live.cpp +++ b/src/WebApi_ws_live.cpp @@ -13,6 +13,7 @@ #include "VictronMppt.h" #include "defaults.h" #include +#include "ShellyACPlug.h" WebApiWsLiveClass::WebApiWsLiveClass() : _ws("/livedata") @@ -99,6 +100,16 @@ void WebApiWsLiveClass::generateOnBatteryJsonResponse(JsonVariant& root, bool al if (!all) { _lastPublishHuawei = millis(); } } + if (all || config.Shelly.Enabled ) { + auto shellyObj = root["shelly"].to(); + shellyObj["enabled"] = config.Shelly.Enabled; + + if (config.Shelly.Enabled) { + addTotalField(shellyObj, "Power", ShellyACPlug._readpower, "W", 2); + } + if (!all) { _lastPublishShelly = millis(); } + } + auto spStats = Battery.getStats(); if (all || spStats->updateAvailable(_lastPublishBattery)) { auto batteryObj = root["battery"].to(); @@ -367,7 +378,7 @@ void WebApiWsLiveClass::onLivedataStatus(AsyncWebServerRequest* request) generateOnBatteryJsonResponse(root, true); WebApi.sendJsonResponse(request, response, __FUNCTION__, __LINE__); - + } catch (const std::bad_alloc& bad_alloc) { MessageOutput.printf("Calling /api/livedata/status has temporarily run out of resources. Reason: \"%s\".\r\n", bad_alloc.what()); WebApi.sendTooManyRequests(request); diff --git a/src/main.cpp b/src/main.cpp index 00d38bb3f..5fa0768a7 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -39,6 +39,7 @@ #include #include #include +#include "ShellyACPlug.h" #include @@ -202,6 +203,10 @@ void setup() MessageOutput.println("Invalid pin config"); } + // Initialize Shelly AC-charger + MessageOutput.println("Initialize Shelly AC charger interface... "); + ShellyACPlug.init(scheduler); + Battery.init(scheduler); } diff --git a/webapp/src/components/InverterTotalInfo.vue b/webapp/src/components/InverterTotalInfo.vue index a1f0a3157..53f446f41 100644 --- a/webapp/src/components/InverterTotalInfo.vue +++ b/webapp/src/components/InverterTotalInfo.vue @@ -182,11 +182,24 @@ +
+ +

+ {{ + $n(shellyData.Power.v, 'decimal', { + minimumFractionDigits: shellyData.Power.d, + maximumFractionDigits: shellyData.Power.d, + }) + }} + {{ shellyData.Power.u }} +

+
+
diff --git a/webapp/src/locales/de.json b/webapp/src/locales/de.json index 1a16673b3..8933bd289 100644 --- a/webapp/src/locales/de.json +++ b/webapp/src/locales/de.json @@ -429,7 +429,8 @@ "BatteryCharge": "Batterie Ladezustand", "BatteryPower": "Batterie Leistung", "HomePower": "Leistung / Netz", - "HuaweiPower": "Huawei AC Leistung" + "HuaweiPower": "Huawei AC Leistung", + "ShellyPower": "AC Ladegerät Leistung" }, "inverterchannelproperty": { "Power": "Leistung", @@ -920,6 +921,14 @@ "ChargerSettings": "AC Ladegerät Einstellungen", "Configuration": "AC Ladegerät Konfiguration", "EnableHuawei": "Huawei R4850G2 an CAN Bus Interface aktiv", + "EnableShelly": "AC Charger an Shelly Plug S", + "ShellyStartThreshold": "Powermeter Start-Schwellwert", + "ShellyStopThreshold": "Powermeter Stop-Schwellwert", + "ShellySettings": "Shelly Plug S Konfiguration", + "ShellyAddress": "Url mit IP-Adresse oder Hostname", + "ShellyAddressHint": "URL mit Ip oder voller Hostname des Shelly Plug S z.b. http://192.168.2.100 oder https://shelly123.domain.local", + "ShellyStartThresholdHint": "Wert ab welcher Netzleistung mit AC Charger geladen werden soll", + "ShellyStopThresholdHint": "Wert ab welcher Netzleistung der AC Charger gestoppt werden soll", "VerboseLogging": "@:base.VerboseLogging", "CanControllerFrequency": "Frequenz des Quarzes am CAN Controller", "EnableAutoPower": "Automatische Leistungssteuerung", diff --git a/webapp/src/locales/en.json b/webapp/src/locales/en.json index 69a288778..c29011012 100644 --- a/webapp/src/locales/en.json +++ b/webapp/src/locales/en.json @@ -431,7 +431,8 @@ "BatteryCharge": "Battery Charge", "BatteryPower": "Battery Power", "HomePower": "Grid Power", - "HuaweiPower": "Huawei AC Power" + "HuaweiPower": "Huwawei AC Power", + "ShellyPower": "AC Charger Power" }, "inverterchannelproperty": { "Power": "Power", @@ -924,6 +925,14 @@ "ChargerSettings": "AC Charger Settings", "Configuration": "AC Charger Configuration", "EnableHuawei": "Enable Huawei R4850G2 on CAN Bus Interface", + "EnableShelly": "AC Charger with Shelly Plug S", + "ShellyStartThreshold": "Powermeter Start-Threshold", + "ShellyStopThreshold": "Powermeter Stop-Threshold", + "ShellySettings": "Shelly Plug S configuration", + "ShellyAddress": "url with IP-Address or Hostname", + "ShellyAddressHint": "url with Ip or full Hostname of Shelly Plug S", + "ShellyStartThresholdHint": "Value where AC Charger should start", + "ShellyStopThresholdHint": "Value where AC Charger should stop", "VerboseLogging": "@:base.VerboseLogging", "CanControllerFrequency": "CAN controller quarz frequency", "EnableAutoPower": "Automatic power control", diff --git a/webapp/src/locales/fr.json b/webapp/src/locales/fr.json index 58e157962..58c5482f5 100644 --- a/webapp/src/locales/fr.json +++ b/webapp/src/locales/fr.json @@ -446,7 +446,8 @@ "BatteryCharge": "Battery Charge", "BatteryPower": "Battery Power", "HomePower": "Grid Power", - "HuaweiPower": "Huawei AC Power" + "HuaweiPower": "Huawei AC Power", + "ShellyPower": "AC Charger Power" }, "inverterchannelproperty": { "Power": "Puissance", @@ -874,6 +875,14 @@ "ChargerSettings": "AC Charger Settings", "Configuration": "AC Charger Configuration", "EnableHuawei": "Enable Huawei R4850G2 on CAN Bus Interface", + "EnableShelly": "AC Charger with Shelly Plug S", + "ShellyStartThreshold": "Powermeter Start-Threshold", + "ShellyStopThreshold": "Powermeter Stop-Threshold", + "ShellySettings": "Shelly Plug S configuration", + "ShellyAddress": "IP-Address or Hostname", + "ShellyAddressHint": "Ip or full Hostname of Shelly Plug S", + "ShellyStartThresholdHint": "Value where AC Charger should start", + "ShellyStopThresholdHint": "Value where AC Charger should stop", "VerboseLogging": "@:base.VerboseLogging", "CanControllerFrequency": "CAN controller quarz frequency", "EnableAutoPower": "Automatic power control", diff --git a/webapp/src/types/AcChargerConfig.ts b/webapp/src/types/AcChargerConfig.ts index ec9fc7a44..dfb35a770 100644 --- a/webapp/src/types/AcChargerConfig.ts +++ b/webapp/src/types/AcChargerConfig.ts @@ -12,3 +12,14 @@ export interface AcChargerConfig { stop_batterysoc_threshold: number; target_power_consumption: number; } + +export interface AcChargerShellyConfig { + enabled: boolean; + verbose_logging: boolean; + auto_power_batterysoc_limits_enabled: boolean; + emergency_charge_enabled: boolean; + stop_batterysoc_threshold: number; + url: string; + power_on_threshold: number; + power_off_threshold: number; +} diff --git a/webapp/src/types/LiveDataStatus.ts b/webapp/src/types/LiveDataStatus.ts index 591e01129..84df82693 100644 --- a/webapp/src/types/LiveDataStatus.ts +++ b/webapp/src/types/LiveDataStatus.ts @@ -70,6 +70,11 @@ export interface Huawei { Power: ValueObject; } +export interface Shelly { + enabled: boolean; + Power: ValueObject; +} + export interface Battery { enabled: boolean; soc?: ValueObject; @@ -91,4 +96,5 @@ export interface LiveData { huawei: Huawei; battery: Battery; power_meter: PowerMeter; + shelly: Shelly; } diff --git a/webapp/src/views/AcChargerAdminView.vue b/webapp/src/views/AcChargerAdminView.vue index d8ae820e6..cdab173ee 100644 --- a/webapp/src/views/AcChargerAdminView.vue +++ b/webapp/src/views/AcChargerAdminView.vue @@ -12,6 +12,12 @@ type="checkbox" wide /> +
+ + + + + +
+ +
+
+ + +
+
+
+
+ +
+
+ + W +
+
+
+
+ +
+
+ + W +
+
+
+ +
+ +
+
+ + % +
+
+
+
+
@@ -217,6 +340,7 @@ import FormFooter from '@/components/FormFooter.vue'; import InputElement from '@/components/InputElement.vue'; import { BIconInfoCircle } from 'bootstrap-icons-vue'; import type { AcChargerConfig } from '@/types/AcChargerConfig'; +import type { AcChargerShellyConfig } from '@/types/AcChargerConfig'; import { authHeader, handleResponse } from '@/utils/authentication'; import { defineComponent } from 'vue'; @@ -233,6 +357,7 @@ export default defineComponent({ return { dataLoading: true, acChargerConfigList: {} as AcChargerConfig, + acChargerShellyConfigList: {} as AcChargerShellyConfig, alertMessage: '', alertType: 'info', showAlert: false, @@ -254,12 +379,32 @@ export default defineComponent({ this.acChargerConfigList = data; this.dataLoading = false; }); + fetch('/api/shelly/config', { headers: authHeader() }) + .then((response) => handleResponse(response, this.$emitter, this.$router)) + .then((data) => { + this.acChargerShellyConfigList = data; + this.dataLoading = false; + }); }, saveChargerConfig(e: Event) { e.preventDefault(); const formData = new FormData(); + const formDataShelly = new FormData(); formData.append('data', JSON.stringify(this.acChargerConfigList)); + formDataShelly.append('data', JSON.stringify(this.acChargerShellyConfigList)); + + fetch('/api/shelly/config', { + method: 'POST', + headers: authHeader(), + body: formDataShelly, + }) + .then((response) => handleResponse(response, this.$emitter, this.$router)) + .then((response) => { + this.alertMessage = this.$t('apiresponse.' + response.code, response.param); + this.alertType = response.type; + this.showAlert = true; + }); fetch('/api/huawei/config', { method: 'POST', diff --git a/webapp/src/views/HomeView.vue b/webapp/src/views/HomeView.vue index ba4eb0509..d00f26709 100644 --- a/webapp/src/views/HomeView.vue +++ b/webapp/src/views/HomeView.vue @@ -14,6 +14,7 @@ :totalBattData="liveData.battery" :powerMeterData="liveData.power_meter" :huaweiData="liveData.huawei" + :shellyData="liveData.shelly" />
@@ -702,20 +703,21 @@ export default defineComponent({ console.log(event); if (event.data != '{}') { const newData = JSON.parse(event.data); - if (typeof newData.vedirect !== 'undefined') { Object.assign(this.liveData.vedirect, newData.vedirect); } if (typeof newData.huawei !== 'undefined') { Object.assign(this.liveData.huawei, newData.huawei); } + if (typeof newData.shelly !== 'undefined') { + Object.assign(this.liveData.shelly, newData.shelly); + } if (typeof newData.battery !== 'undefined') { Object.assign(this.liveData.battery, newData.battery); } if (typeof newData.power_meter !== 'undefined') { Object.assign(this.liveData.power_meter, newData.power_meter); } - if (typeof newData.total === 'undefined') { return; }