Skip to content

Commit

Permalink
Feature: Allow reordering of the inverters in the live view
Browse files Browse the repository at this point in the history
Reordering can be done in the inverter settings via drag&drop.
  • Loading branch information
tbnobody committed May 29, 2023
1 parent e0027d9 commit 1c8bd80
Show file tree
Hide file tree
Showing 14 changed files with 142 additions and 18 deletions.
1 change: 1 addition & 0 deletions include/Configuration.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions include/WebApi_errors.h
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ enum WebApiError {
InverterInvalidMaxChannel,
InverterChanged,
InverterDeleted,
InverterOrdered,

LimitBase = 5000,
LimitSerialZero,
Expand Down
1 change: 1 addition & 0 deletions include/WebApi_inverter.h
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ class WebApiInverterClass {
void onInverterAdd(AsyncWebServerRequest* request);
void onInverterEdit(AsyncWebServerRequest* request);
void onInverterDelete(AsyncWebServerRequest* request);
void onInverterOrder(AsyncWebServerRequest* request);

AsyncWebServer* _server;
};
2 changes: 2 additions & 0 deletions src/Configuration.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -247,6 +248,7 @@ bool ConfigurationClass::read()
JsonObject inv = inverters[i].as<JsonObject>();
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;
Expand Down
71 changes: 71 additions & 0 deletions src/WebApi_inverter.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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];
Expand Down Expand Up @@ -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<JsonArray>();
uint8_t order = 0;
for(JsonVariant id : orderArray) {
uint8_t inverter_id = id.as<uint8_t>();
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);
}
10 changes: 6 additions & 4 deletions src/WebApi_ws_live.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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<uint8_t>(c))]["name"]["u"] = inv_cfg->channel[c].Name;
}
chanTypeObj[String(static_cast<uint8_t>(c))]["name"]["u"] = inv_cfg->channel[c].Name;
}
addField(chanTypeObj, i, inv, t, c, FLD_PAC);
addField(chanTypeObj, i, inv, t, c, FLD_UAC);
Expand Down
2 changes: 2 additions & 0 deletions webapp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions webapp/src/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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!",
Expand Down Expand Up @@ -439,6 +440,7 @@
"StatusHint": "<b>Hinweis:</b> 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:",
Expand Down
2 changes: 2 additions & 0 deletions webapp/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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!",
Expand Down Expand Up @@ -439,6 +440,7 @@
"StatusHint": "<b>Hint:</b> 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:",
Expand Down
2 changes: 2 additions & 0 deletions webapp/src/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 !",
Expand Down Expand Up @@ -439,6 +440,7 @@
"StatusHint": "<b>Astuce :</b> 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",
Expand Down
1 change: 1 addition & 0 deletions webapp/src/types/LiveDataStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export interface InverterStatistics {
export interface Inverter {
serial: number;
name: string;
order: number;
data_age: number;
poll_enabled: boolean;
reachable: boolean;
Expand Down
4 changes: 3 additions & 1 deletion webapp/src/views/HomeView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
51 changes: 38 additions & 13 deletions webapp/src/views/InverterAdminView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,17 @@
<table class="table">
<thead>
<tr>
<th>#</th>
<th scope="col">{{ $t('inverteradmin.Status') }}</th>
<th>{{ $t('inverteradmin.Serial') }}</th>
<th>{{ $t('inverteradmin.Name') }}</th>
<th>{{ $t('inverteradmin.Type') }}</th>
<th>{{ $t('inverteradmin.Action') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="inverter in sortedInverters" v-bind:key="inverter.id">
<tbody ref="invList">
<tr v-for="inverter in inverters" v-bind:key="inverter.id" :data-id="inverter.id">
<td><BIconGripHorizontal class="drag-handle" /></td>
<td>
<span class="badge" :title="$t('inverteradmin.Receive')" :class="{
'text-bg-warning': !inverter.poll_enable_night,
Expand All @@ -63,6 +65,9 @@
</tbody>
</table>
</div>
<div class="ml-auto text-right">
<button class="btn btn-primary my-2" @click="onSaveOrder()">{{ $t('inverteradmin.SaveOrder') }}</button>
</div>
</CardElement>
</BasePage>

Expand Down Expand Up @@ -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 {
Expand All @@ -205,6 +211,7 @@ import {
BIconTrash,
BIconArrowDown,
BIconArrowUp,
BIconGripHorizontal,
} from 'bootstrap-icons-vue';
import { defineComponent } from 'vue';
Expand All @@ -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;
Expand All @@ -244,6 +252,7 @@ export default defineComponent({
BIconTrash,
BIconArrowDown,
BIconArrowUp,
BIconGripHorizontal,
},
data() {
return {
Expand All @@ -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() {
Expand All @@ -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) {
Expand Down Expand Up @@ -316,7 +332,16 @@ export default defineComponent({
},
onCloseModal(modal: bootstrap.Modal) {
modal.hide();
}
},
onSaveOrder() {
this.callInverterApiEndpoint("order", JSON.stringify({ order: this.sortable.toArray() }));
},
},
});
</script>
</script>

<style>
.drag-handle {
cursor: grab;
}
</style>
10 changes: 10 additions & 0 deletions webapp/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down

0 comments on commit 1c8bd80

Please sign in to comment.