From 1c8bd8091ba504c4d7d2ce7ae8b00d6b6d1a897c Mon Sep 17 00:00:00 2001 From: Thomas Basler Date: Mon, 29 May 2023 20:17:07 +0200 Subject: [PATCH] Feature: Allow reordering of the inverters in the live view Reordering can be done in the inverter settings via drag&drop. --- include/Configuration.h | 1 + include/WebApi_errors.h | 1 + include/WebApi_inverter.h | 1 + src/Configuration.cpp | 2 + src/WebApi_inverter.cpp | 71 ++++++++++++++++++++++++++ src/WebApi_ws_live.cpp | 10 ++-- webapp/package.json | 2 + webapp/src/locales/de.json | 2 + webapp/src/locales/en.json | 2 + webapp/src/locales/fr.json | 2 + webapp/src/types/LiveDataStatus.ts | 1 + webapp/src/views/HomeView.vue | 4 +- webapp/src/views/InverterAdminView.vue | 51 +++++++++++++----- webapp/yarn.lock | 10 ++++ 14 files changed, 142 insertions(+), 18 deletions(-) diff --git a/include/Configuration.h b/include/Configuration.h index 85987c4..da90d22 100644 --- a/include/Configuration.h +++ b/include/Configuration.h @@ -40,6 +40,7 @@ struct CHANNEL_CONFIG_T { struct INVERTER_CONFIG_T { uint64_t Serial; char Name[INV_MAX_NAME_STRLEN + 1]; + uint8_t Order; bool Poll_Enable; bool Poll_Enable_Night; bool Command_Enable; diff --git a/include/WebApi_errors.h b/include/WebApi_errors.h index e4ec788..8107840 100644 --- a/include/WebApi_errors.h +++ b/include/WebApi_errors.h @@ -28,6 +28,7 @@ enum WebApiError { InverterInvalidMaxChannel, InverterChanged, InverterDeleted, + InverterOrdered, LimitBase = 5000, LimitSerialZero, diff --git a/include/WebApi_inverter.h b/include/WebApi_inverter.h index c2298ec..9f2b067 100644 --- a/include/WebApi_inverter.h +++ b/include/WebApi_inverter.h @@ -13,6 +13,7 @@ private: void onInverterAdd(AsyncWebServerRequest* request); void onInverterEdit(AsyncWebServerRequest* request); void onInverterDelete(AsyncWebServerRequest* request); + void onInverterOrder(AsyncWebServerRequest* request); AsyncWebServer* _server; }; \ No newline at end of file diff --git a/src/Configuration.cpp b/src/Configuration.cpp index c2cfabf..867adea 100644 --- a/src/Configuration.cpp +++ b/src/Configuration.cpp @@ -103,6 +103,7 @@ bool ConfigurationClass::write() JsonObject inv = inverters.createNestedObject(); inv["serial"] = config.Inverter[i].Serial; inv["name"] = config.Inverter[i].Name; + inv["order"] = config.Inverter[i].Order; inv["poll_enable"] = config.Inverter[i].Poll_Enable; inv["poll_enable_night"] = config.Inverter[i].Poll_Enable_Night; inv["command_enable"] = config.Inverter[i].Command_Enable; @@ -247,6 +248,7 @@ bool ConfigurationClass::read() JsonObject inv = inverters[i].as(); config.Inverter[i].Serial = inv["serial"] | 0ULL; strlcpy(config.Inverter[i].Name, inv["name"] | "", sizeof(config.Inverter[i].Name)); + config.Inverter[i].Order = inv["order"] | 0; config.Inverter[i].Poll_Enable = inv["poll_enable"] | true; config.Inverter[i].Poll_Enable_Night = inv["poll_enable_night"] | true; diff --git a/src/WebApi_inverter.cpp b/src/WebApi_inverter.cpp index 3f42d3c..495b5fa 100644 --- a/src/WebApi_inverter.cpp +++ b/src/WebApi_inverter.cpp @@ -21,6 +21,7 @@ void WebApiInverterClass::init(AsyncWebServer* server) _server->on("/api/inverter/add", HTTP_POST, std::bind(&WebApiInverterClass::onInverterAdd, this, _1)); _server->on("/api/inverter/edit", HTTP_POST, std::bind(&WebApiInverterClass::onInverterEdit, this, _1)); _server->on("/api/inverter/del", HTTP_POST, std::bind(&WebApiInverterClass::onInverterDelete, this, _1)); + _server->on("/api/inverter/order", HTTP_POST, std::bind(&WebApiInverterClass::onInverterOrder, this, _1)); } void WebApiInverterClass::loop() @@ -44,6 +45,7 @@ void WebApiInverterClass::onInverterList(AsyncWebServerRequest* request) JsonObject obj = data.createNestedObject(); obj["id"] = i; obj["name"] = String(config.Inverter[i].Name); + obj["order"] = config.Inverter[i].Order; // Inverter Serial is read as HEX char buffer[sizeof(uint64_t) * 8 + 1]; @@ -389,4 +391,73 @@ void WebApiInverterClass::onInverterDelete(AsyncWebServerRequest* request) request->send(response); MqttHandleHass.forceUpdate(); +} + +void WebApiInverterClass::onInverterOrder(AsyncWebServerRequest* request) +{ + if (!WebApi.checkCredentials(request)) { + return; + } + + AsyncJsonResponse* response = new AsyncJsonResponse(); + JsonObject retMsg = response->getRoot(); + retMsg["type"] = "warning"; + + if (!request->hasParam("data", true)) { + retMsg["message"] = "No values found!"; + retMsg["code"] = WebApiError::GenericNoValueFound; + response->setLength(); + request->send(response); + return; + } + + String json = request->getParam("data", true)->value(); + + if (json.length() > 1024) { + retMsg["message"] = "Data too large!"; + retMsg["code"] = WebApiError::GenericDataTooLarge; + response->setLength(); + request->send(response); + return; + } + + DynamicJsonDocument root(1024); + DeserializationError error = deserializeJson(root, json); + + if (error) { + retMsg["message"] = "Failed to parse data!"; + retMsg["code"] = WebApiError::GenericParseError; + response->setLength(); + request->send(response); + return; + } + + if (!(root.containsKey("order"))) { + retMsg["message"] = "Values are missing!"; + retMsg["code"] = WebApiError::GenericValueMissing; + response->setLength(); + request->send(response); + return; + } + + // The order array contains list or id in the right order + JsonArray orderArray = root["order"].as(); + uint8_t order = 0; + for(JsonVariant id : orderArray) { + uint8_t inverter_id = id.as(); + if (inverter_id < INV_MAX_COUNT) { + INVERTER_CONFIG_T& inverter = Configuration.get().Inverter[inverter_id]; + inverter.Order = order; + } + order++; + } + + Configuration.write(); + + retMsg["type"] = "success"; + retMsg["message"] = "Inverter order saved!"; + retMsg["code"] = WebApiError::InverterOrdered; + + response->setLength(); + request->send(response); } \ No newline at end of file diff --git a/src/WebApi_ws_live.cpp b/src/WebApi_ws_live.cpp index d562d9f..fb34763 100644 --- a/src/WebApi_ws_live.cpp +++ b/src/WebApi_ws_live.cpp @@ -99,9 +99,14 @@ void WebApiWsLiveClass::generateJsonResponse(JsonVariant& root) } JsonObject invObject = invArray.createNestedObject(); + INVERTER_CONFIG_T* inv_cfg = Configuration.getInverterConfig(inv->serial()); + if (inv_cfg == nullptr) { + continue; + } invObject["serial"] = inv->serialString(); invObject["name"] = inv->name(); + invObject["order"] = inv_cfg->Order; invObject["data_age"] = (millis() - inv->Statistics()->getLastUpdate()) / 1000; invObject["poll_enabled"] = inv->getEnablePolling(); invObject["reachable"] = inv->isReachable(); @@ -118,10 +123,7 @@ void WebApiWsLiveClass::generateJsonResponse(JsonVariant& root) JsonObject chanTypeObj = invObject.createNestedObject(inv->Statistics()->getChannelTypeName(t)); for (auto& c : inv->Statistics()->getChannelsByType(t)) { if (t == TYPE_DC) { - INVERTER_CONFIG_T* inv_cfg = Configuration.getInverterConfig(inv->serial()); - if (inv_cfg != nullptr) { - chanTypeObj[String(static_cast(c))]["name"]["u"] = inv_cfg->channel[c].Name; - } + chanTypeObj[String(static_cast(c))]["name"]["u"] = inv_cfg->channel[c].Name; } addField(chanTypeObj, i, inv, t, c, FLD_PAC); addField(chanTypeObj, i, inv, t, c, FLD_UAC); diff --git a/webapp/package.json b/webapp/package.json index 9c7096e..5ce132f 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -15,6 +15,7 @@ "bootstrap": "^5.3.0-alpha3", "bootstrap-icons-vue": "^1.10.3", "mitt": "^3.0.0", + "sortablejs": "^1.15.0", "spark-md5": "^3.0.2", "vue": "^3.3.4", "vue-i18n": "^9.2.2", @@ -26,6 +27,7 @@ "@tsconfig/node18": "^2.0.1", "@types/bootstrap": "^5.2.6", "@types/node": "^20.2.3", + "@types/sortablejs": "^1.15.1", "@types/spark-md5": "^3.0.2", "@vitejs/plugin-vue": "^4.2.3", "@vue/eslint-config-typescript": "^11.0.3", diff --git a/webapp/src/locales/de.json b/webapp/src/locales/de.json index 79641e9..d392b86 100644 --- a/webapp/src/locales/de.json +++ b/webapp/src/locales/de.json @@ -51,6 +51,7 @@ "4006": "Ungültige Anzahl an Kanalwerten übergeben!", "4007": "Wechselrichter geändert!", "4008": "Wechselrichter gelöscht!", + "4009": "Wechselrichter Reihenfolge gespeichert!", "5001": "@:apiresponse.2001", "5002": "Das Limit muss zwischen 1 und {max} sein!", "5003": "Ungültiten Typ angegeben!", @@ -439,6 +440,7 @@ "StatusHint": "Hinweis: Der Wechselrichter wird über seinen DC-Eingang mit Strom versorgt. Wenn keine Sonne scheint, ist der Wechselrichter aus. Es können trotzdem Anfragen gesendet werden.", "Type": "Typ", "Action": "Aktion", + "SaveOrder": "Reihenfolge speichern", "DeleteInverter": "Wechselrichter löschen", "EditInverter": "Wechselrichter bearbeiten", "InverterSerial": "Wechselrichter Seriennummer:", diff --git a/webapp/src/locales/en.json b/webapp/src/locales/en.json index 429b5a7..407c2d3 100644 --- a/webapp/src/locales/en.json +++ b/webapp/src/locales/en.json @@ -51,6 +51,7 @@ "4006": "Invalid amount of max channel setting given!", "4007": "Inverter changed!", "4008": "Inverter deleted!", + "4009": "Inverter order saved!", "5001": "@:apiresponse.2001", "5002": "Limit must between 1 and {max}!", "5003": "Invalid type specified!", @@ -439,6 +440,7 @@ "StatusHint": "Hint: The inverter is powered by its DC input. If there is no sun, the inverter is off. Requests can still be sent.", "Type": "Type", "Action": "Action", + "SaveOrder": "Save order", "DeleteInverter": "Delete inverter", "EditInverter": "Edit inverter", "InverterSerial": "Inverter Serial:", diff --git a/webapp/src/locales/fr.json b/webapp/src/locales/fr.json index f5cce1a..4a9b2a7 100644 --- a/webapp/src/locales/fr.json +++ b/webapp/src/locales/fr.json @@ -51,6 +51,7 @@ "4006": "Réglage du montant maximal de canaux invalide !", "4007": "Onduleur modifié !", "4008": "Onduleur supprimé !", + "4009": "Inverter order saved!", "5001": "@:apiresponse.2001", "5002": "La limite doit être comprise entre 1 et {max} !", "5003": "Type spécifié invalide !", @@ -439,6 +440,7 @@ "StatusHint": "Astuce : L'onduleur est alimenté par son entrée courant continu. S'il n'y a pas de soleil, l'onduleur est éteint, mais les requêtes peuvent toujours être envoyées.", "Type": "Type", "Action": "Action", + "SaveOrder": "Save order", "DeleteInverter": "Supprimer l'onduleur", "EditInverter": "Modifier l'onduleur", "InverterSerial": "Numéro de série de l'onduleur", diff --git a/webapp/src/types/LiveDataStatus.ts b/webapp/src/types/LiveDataStatus.ts index 66910af..c3e5682 100644 --- a/webapp/src/types/LiveDataStatus.ts +++ b/webapp/src/types/LiveDataStatus.ts @@ -23,6 +23,7 @@ export interface InverterStatistics { export interface Inverter { serial: number; name: string; + order: number; data_age: number; poll_enabled: boolean; reachable: boolean; diff --git a/webapp/src/views/HomeView.vue b/webapp/src/views/HomeView.vue index 08cf1cb..ed2d443 100644 --- a/webapp/src/views/HomeView.vue +++ b/webapp/src/views/HomeView.vue @@ -457,7 +457,9 @@ export default defineComponent({ 'decimalTwoDigits'); }, inverterData(): Inverter[] { - return this.liveData.inverters; + return this.liveData.inverters.slice().sort((a : Inverter, b: Inverter) => { + return a.order - b.order; + }); } }, methods: { diff --git a/webapp/src/views/InverterAdminView.vue b/webapp/src/views/InverterAdminView.vue index eb692ba..3660a49 100644 --- a/webapp/src/views/InverterAdminView.vue +++ b/webapp/src/views/InverterAdminView.vue @@ -28,6 +28,7 @@ + @@ -35,8 +36,9 @@ - - + + +
# {{ $t('inverteradmin.Status') }} {{ $t('inverteradmin.Serial') }} {{ $t('inverteradmin.Name') }}{{ $t('inverteradmin.Action') }}
+ + @@ -197,6 +202,7 @@ import BasePage from '@/components/BasePage.vue'; import BootstrapAlert from "@/components/BootstrapAlert.vue"; import CardElement from '@/components/CardElement.vue'; import InputElement from '@/components/InputElement.vue'; +import Sortable from 'sortablejs'; import { authHeader, handleResponse } from '@/utils/authentication'; import * as bootstrap from 'bootstrap'; import { @@ -205,6 +211,7 @@ import { BIconTrash, BIconArrowDown, BIconArrowUp, + BIconGripHorizontal, } from 'bootstrap-icons-vue'; import { defineComponent } from 'vue'; @@ -219,6 +226,7 @@ declare interface Inverter { serial: number; name: string; type: string; + order: number; poll_enable: boolean; poll_enable_night: boolean; command_enable: boolean; @@ -244,6 +252,7 @@ export default defineComponent({ BIconTrash, BIconArrowDown, BIconArrowUp, + BIconGripHorizontal, }, data() { return { @@ -253,7 +262,8 @@ export default defineComponent({ selectedInverterData: {} as Inverter, inverters: [] as Inverter[], dataLoading: true, - alert: {} as AlertResponse + alert: {} as AlertResponse, + sortable: {} as Sortable, }; }, mounted() { @@ -263,21 +273,27 @@ export default defineComponent({ created() { this.getInverters(); }, - computed: { - sortedInverters(): Inverter[] { - return this.inverters.slice().sort((a, b) => { - return a.serial - b.serial; - }); - }, - }, methods: { getInverters() { this.dataLoading = true; fetch("/api/inverter/list", { headers: authHeader() }) .then((response) => handleResponse(response, this.$emitter, this.$router)) .then((data) => { - this.inverters = data.inverter; + this.inverters = data.inverter.slice().sort((a : Inverter, b: Inverter) => { + return a.order - b.order; + }); this.dataLoading = false; + + this.$nextTick(() => { + const table = this.$refs.invList as HTMLElement; + + this.sortable = Sortable.create(table, { + sort: true, + handle: '.drag-handle', + animation: 150, + draggable: 'tr', + }); + }); }); }, callInverterApiEndpoint(endpoint: string, jsonData: string) { @@ -316,7 +332,16 @@ export default defineComponent({ }, onCloseModal(modal: bootstrap.Modal) { modal.hide(); - } + }, + onSaveOrder() { + this.callInverterApiEndpoint("order", JSON.stringify({ order: this.sortable.toArray() })); + }, }, }); - \ No newline at end of file + + + \ No newline at end of file diff --git a/webapp/yarn.lock b/webapp/yarn.lock index 30b8509..658606b 100644 --- a/webapp/yarn.lock +++ b/webapp/yarn.lock @@ -381,6 +381,11 @@ resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.3.13.tgz#da4bfd73f49bd541d28920ab0e2bf0ee80f71c91" integrity sha512-21cFJr9z3g5dW8B0CVI9g2O9beqaThGQ6ZFBqHfwhzLDKUxaqTIy3vnfah/UPkfOiF2pLq+tGz+W8RyCskuslw== +"@types/sortablejs@^1.15.1": + version "1.15.1" + resolved "https://registry.yarnpkg.com/@types/sortablejs/-/sortablejs-1.15.1.tgz#123abafbe936f754fee5eb5b49009ce1f1075aa5" + integrity sha512-g/JwBNToh6oCTAwNS8UGVmjO7NLDKsejVhvE4x1eWiPTC3uCuNsa/TD4ssvX3du+MLiM+SHPNDuijp8y76JzLQ== + "@types/spark-md5@^3.0.2": version "3.0.2" resolved "https://registry.yarnpkg.com/@types/spark-md5/-/spark-md5-3.0.2.tgz#da2e8a778a20335fc4f40b6471c4b0d86b70da55" @@ -2266,6 +2271,11 @@ slash@^3.0.0: resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== +sortablejs@^1.15.0: + version "1.15.0" + resolved "https://registry.yarnpkg.com/sortablejs/-/sortablejs-1.15.0.tgz#53230b8aa3502bb77a29e2005808ffdb4a5f7e2a" + integrity sha512-bv9qgVMjUMf89wAvM6AxVvS/4MX3sPeN0+agqShejLU5z5GX4C75ow1O2e5k4L6XItUyAK3gH6AxSbXrOM5e8w== + "source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c"