Feature: Allow reordering of the inverters in the live view
Reordering can be done in the inverter settings via drag&drop.
This commit is contained in:
parent
e0027d951b
commit
1c8bd8091b
@ -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;
|
||||
|
||||
@ -28,6 +28,7 @@ enum WebApiError {
|
||||
InverterInvalidMaxChannel,
|
||||
InverterChanged,
|
||||
InverterDeleted,
|
||||
InverterOrdered,
|
||||
|
||||
LimitBase = 5000,
|
||||
LimitSerialZero,
|
||||
|
||||
@ -13,6 +13,7 @@ private:
|
||||
void onInverterAdd(AsyncWebServerRequest* request);
|
||||
void onInverterEdit(AsyncWebServerRequest* request);
|
||||
void onInverterDelete(AsyncWebServerRequest* request);
|
||||
void onInverterOrder(AsyncWebServerRequest* request);
|
||||
|
||||
AsyncWebServer* _server;
|
||||
};
|
||||
@ -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<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;
|
||||
|
||||
@ -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];
|
||||
@ -390,3 +392,72 @@ void WebApiInverterClass::onInverterDelete(AsyncWebServerRequest* request)
|
||||
|
||||
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);
|
||||
}
|
||||
@ -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<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);
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "<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:",
|
||||
|
||||
@ -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": "<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:",
|
||||
|
||||
@ -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": "<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",
|
||||
|
||||
@ -23,6 +23,7 @@ export interface InverterStatistics {
|
||||
export interface Inverter {
|
||||
serial: number;
|
||||
name: string;
|
||||
order: number;
|
||||
data_age: number;
|
||||
poll_enabled: boolean;
|
||||
reachable: boolean;
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -28,6 +28,7 @@
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th scope="col">{{ $t('inverteradmin.Status') }}</th>
|
||||
<th>{{ $t('inverteradmin.Serial') }}</th>
|
||||
<th>{{ $t('inverteradmin.Name') }}</th>
|
||||
@ -35,8 +36,9 @@
|
||||
<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,
|
||||
@ -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>
|
||||
|
||||
@ -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() }));
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
}
|
||||
</style>
|
||||
@ -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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user