Merge remote-tracking branch 'tbnobody/OpenDTU/master'

This commit is contained in:
helgeerbe 2022-11-16 16:39:01 +01:00
commit f35395e76f
68 changed files with 1576 additions and 535 deletions

8
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: 🤔 Have questions or need support?
url: https://discord.gg/WzhxEY62mB
about: Discuss with us on Discord
- name: 🤔 Have questions or need support?
url: https://github.com/tbnobody/OpenDTU/discussions
about: Use the GitHub Discussions feature

View File

@ -37,7 +37,7 @@ jobs:
- name: Get default environments
id: envs
run: |
echo "::set-output name=environments::$(pio project config --json-output | jq -cr '.[0][1][0][1]')"
echo "environments=$(pio project config --json-output | jq -cr '.[0][1][0][1]')" >> $GITHUB_OUTPUT
outputs:
environments: ${{ steps.envs.outputs.environments }}

3
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"C_Cpp.clang_format_style": "WebKit"
}

View File

@ -75,6 +75,7 @@ Sends text raw data as difined in VE.Direct spec.
* Time zone support
* Ve.Direct interface (via web-interface, REST-api, or MQTT)
* Ethernet support
* Prometheus API endpoint (/api/prometheus/metrics)
## Features for developers
* The microcontroller part
@ -206,7 +207,7 @@ Users report that [ESP_Flasher](https://github.com/Jason2866/ESP_Flasher/release
## First configuration
* After the initial flashing of the microcontroller, an Access Point called "OpenDTU-*" is opened. The default password is "openDTU42".
* Use a web browser to open the address [http://192.168.4.1](http://192.168.4.1)
* Navigate to Settings --> Network Settings and enter your WiFi credentials
* Navigate to Settings --> Network Settings and enter your WiFi credentials. The username to access the config menu is "admin" and the password the same as for accessing the Access Point (default: "openDTU42").
* OpenDTU then simultaneously connects to your WiFi AP with this credentials. Navigate to Info --> Network and look into section "Network Interface (Station)" for the IP address received via DHCP.
* When OpenDTU is connected to a configured WiFI AP, the "OpenDTU-*" Access Point is closed after 3 minutes.
* OpenDTU needs access to a working NTP server to get the current date & time. Both are sent to the inverter with each request. Default NTP server is pool.ntp.org. If your network has different requirements please change accordingly (Settings --> NTP Settings).
@ -225,6 +226,9 @@ After the successful upload, the OpenDTU immediately restarts into the new firmw
## MQTT Topic Documentation
A documentation of all available MQTT Topics can be found here: [MQTT Documentation](docs/MQTT_Topics.md)
## Web API Documentation
A documentation of the Web API can be found here: [Web-API Documentation](docs/Web-API.md)
## Available cases
* <https://www.thingiverse.com/thing:5435911>
* <https://www.printables.com/model/293003-sol-opendtu-esp32-nrf24l01-case>

6
docs/README.md Normal file
View File

@ -0,0 +1,6 @@
# Documents - Table of content
More detailed descriptions for some topics can be found here.
## [MQTT Topic Documentation](MQTT_Topics.md)
## [Web API Documentation](Web-API.md)

460
docs/Web-API.md Normal file
View File

@ -0,0 +1,460 @@
# Web API
Information in JSON format can be obtained through the web API
## List of URLs
may be incomplete
| GET/POST | Auth required | URL |
| -------- | --- | -- |
| Get | yes | /api/config/get |
| Post | yes | /api/config/delete |
| Post | yes | /api/config/upload |
| Get | no | /api/devinfo/status |
| Get+Post | yes | /api/dtu/config |
| Get | no | /api/eventlog/status?inv=inverter-serialnumber |
| Post | yes | /api/firmware/update |
| Get | yes | /api/inverter/list |
| Post | yes | /api/inverter/add |
| Post | yes | /api/inverter/del |
| Post | yes | /api/inverter/edit |
| Post | yes | /api/limit/config |
| Get | no | /api/limit/status |
| Get | no | /api/livedata/status |
| Get+Post | yes | /api/mqtt/config |
| Get | no | /api/mqtt/status |
| Get+Post | yes | /api/network/config |
| Get | no | /api/network/status |
| Get+Post | yes | /api/ntp/config |
| Get | no | /api/ntp/status |
| Get+Post | yes | /api/ntp/time |
| Get | no | /api/power/status |
| Post | yes | /api/power/config |
| Get+Post | yes | /api/security/password |
| Get | no | /api/system/status |
## Examples of Use
### Important notes:
- IP addresses and serial numbers in this examples are anonymized. Adjust to your own needs.
- The output from curl is without a linefeed at the end, so please be careful when copying the output - do not accidentally add the shell prompt directly after it.
- When POSTing config data to OpenDTU, always send all settings back, even if only one setting was changed. Sending single settings is not supported and you will receive a response `{"type":"warning","message":"Values are missing!"}`
- When POSTing, always put single quotes around the data part. Do not confuse the single quote `'` with the backtick `` ` ``. You have been warned.
- Some API calls have a single URL for GET and POST - e.g. `/api/ntp/config`
- Other API calls use e.g. `/api/limit/status` to GET data and a different URL `/api/limit/config` to POST data.
- If you want to investigate the web api communication, a good tool is [Postman](https://www.postman.com/)
- Settings API require username and password provided with Basic Authentication credentials
### Get information
You can "talk" to the OpenDTU with a command line tool like `curl`. The output is in plain JSON, without carriage return/linefeed and is therefore not very human readable.
#### Get current livedata
```
~$ curl http://192.168.10.10/api/livedata/status
{"inverters":[{"serial":"11418186xxxx","name":"HM600","data_age":4,"reachable":true,"producing":true,"limit_relative":100,"limit_absolute":600,"0":{"Power":{"v":70.69999695,"u":"W","d":1},"Voltage":{"v":233,"u":"V","d":1},"Current":{"v":0.300000012,"u":"A","d":2},"Power DC":{"v":74,"u":"W","d":2},"YieldDay":{"v":23,"u":"Wh","d":2},"YieldTotal":{"v":150.5050049,"u":"kWh","d":2},"Frequency":{"v":50.02000046,"u":"Hz","d":2},"Temperature":{"v":8.300000191,"u":"°C","d":1},"PowerFactor":{"v":1,"u":"","d":3},"ReactivePower":{"v":0.100000001,"u":"var","d":1},"Efficiency":{"v":95.54053497,"u":"%","d":2}},"1":{"Power":{"v":0,"u":"W","d":1},"Voltage":{"v":1,"u":"V","d":1},"Current":{"v":0.02,"u":"A","d":2},"YieldDay":{"v":0,"u":"Wh","d":0},"YieldTotal":{"v":49.0320015,"u":"kWh","d":3},"Irradiation":{"v":0,"u":"%","d":2}},"2":{"Power":{"v":74,"u":"W","d":1},"Voltage":{"v":42.40000153,"u":"V","d":1},"Current":{"v":1.74000001,"u":"A","d":2},"YieldDay":{"v":23,"u":"Wh","d":0},"YieldTotal":{"v":101.4729996,"u":"kWh","d":3},"Irradiation":{"v":18.04878044,"u":"%","d":2}},"events":3},{"serial":"11418180xxxx","name":"HM800","data_age":11,"reachable":true,"producing":true,"limit_relative":100,"limit_absolute":800,"0":{"Power":{"v":70.09999847,"u":"W","d":1},"Voltage":{"v":233.1000061,"u":"V","d":1},"Current":{"v":0.300000012,"u":"A","d":2},"Power DC":{"v":73.59999847,"u":"W","d":2},"YieldDay":{"v":48,"u":"Wh","d":2},"YieldTotal":{"v":48.5399971,"u":"kWh","d":2},"Frequency":{"v":50.02000046,"u":"Hz","d":2},"Temperature":{"v":11.39999962,"u":"°C","d":1},"PowerFactor":{"v":1,"u":"","d":3},"ReactivePower":{"v":0.100000001,"u":"var","d":1},"Efficiency":{"v":95.24456024,"u":"%","d":2}},"1":{"Power":{"v":36.5,"u":"W","d":1},"Voltage":{"v":39.09999847,"u":"V","d":1},"Current":{"v":0.930000007,"u":"A","d":2},"YieldDay":{"v":31,"u":"Wh","d":0},"YieldTotal":{"v":4.301000118,"u":"kWh","d":3},"Irradiation":{"v":8.902439117,"u":"%","d":2}},"2":{"Power":{"v":37.09999847,"u":"W","d":1},"Voltage":{"v":40.79999924,"u":"V","d":1},"Current":{"v":0.910000026,"u":"A","d":2},"YieldDay":{"v":17,"u":"Wh","d":0},"YieldTotal":{"v":44.23899841,"u":"kWh","d":3},"Irradiation":{"v":9.048780441,"u":"%","d":2}},"events":1}],"total":{"Power":{"v":140.7999878,"u":"W","d":1},"YieldDay":{"v":71,"u":"Wh","d":0},"YieldTotal":{"v":199.0449982,"u":"kWh","d":2}}}
```
To enhance readability (and filter information) use the JSON command line processor `jq`.
```
~$ curl --no-progress-meter http://192.168.10.10/api/livedata/status | jq
{
"inverters": [
{
"serial": "11418186xxxx",
"name": "HM600",
"data_age": 4,
"reachable": true,
"producing": true,
"limit_relative": 100,
"limit_absolute": 600,
"0": {
"Power": {
"v": 70.69999695,
"u": "W",
"d": 1
},
"Voltage": {
"v": 233,
"u": "V",
"d": 1
},
"Current": {
"v": 0.300000012,
"u": "A",
"d": 2
},
"Power DC": {
"v": 74,
"u": "W",
"d": 2
},
"YieldDay": {
"v": 23,
"u": "Wh",
"d": 2
},
"YieldTotal": {
"v": 150.5050049,
"u": "kWh",
"d": 2
},
"Frequency": {
"v": 50.02000046,
"u": "Hz",
"d": 2
},
"Temperature": {
"v": 8.300000191,
"u": "°C",
"d": 1
},
"PowerFactor": {
"v": 1,
"u": "",
"d": 3
},
"ReactivePower": {
"v": 0.100000001,
"u": "var",
"d": 1
},
"Efficiency": {
"v": 95.54053497,
"u": "%",
"d": 2
}
},
"1": {
"Power": {
"v": 0,
"u": "W",
"d": 1
},
"Voltage": {
"v": 1,
"u": "V",
"d": 1
},
"Current": {
"v": 0.02,
"u": "A",
"d": 2
},
"YieldDay": {
"v": 0,
"u": "Wh",
"d": 0
},
"YieldTotal": {
"v": 49.0320015,
"u": "kWh",
"d": 3
},
"Irradiation": {
"v": 0,
"u": "%",
"d": 2
}
},
"2": {
"Power": {
"v": 74,
"u": "W",
"d": 1
},
"Voltage": {
"v": 42.40000153,
"u": "V",
"d": 1
},
"Current": {
"v": 1.74000001,
"u": "A",
"d": 2
},
"YieldDay": {
"v": 23,
"u": "Wh",
"d": 0
},
"YieldTotal": {
"v": 101.4729996,
"u": "kWh",
"d": 3
},
"Irradiation": {
"v": 18.04878044,
"u": "%",
"d": 2
}
},
"events": 3
},
{
"serial": "11418180xxxx",
"name": "HM800",
"data_age": 11,
"reachable": true,
"producing": true,
"limit_relative": 100,
"limit_absolute": 800,
"0": {
"Power": {
"v": 70.09999847,
"u": "W",
"d": 1
},
"Voltage": {
"v": 233.1000061,
"u": "V",
"d": 1
},
"Current": {
"v": 0.300000012,
"u": "A",
"d": 2
},
"Power DC": {
"v": 73.59999847,
"u": "W",
"d": 2
},
"YieldDay": {
"v": 48,
"u": "Wh",
"d": 2
},
"YieldTotal": {
"v": 48.5399971,
"u": "kWh",
"d": 2
},
"Frequency": {
"v": 50.02000046,
"u": "Hz",
"d": 2
},
"Temperature": {
"v": 11.39999962,
"u": "°C",
"d": 1
},
"PowerFactor": {
"v": 1,
"u": "",
"d": 3
},
"ReactivePower": {
"v": 0.100000001,
"u": "var",
"d": 1
},
"Efficiency": {
"v": 95.24456024,
"u": "%",
"d": 2
}
},
"1": {
"Power": {
"v": 36.5,
"u": "W",
"d": 1
},
"Voltage": {
"v": 39.09999847,
"u": "V",
"d": 1
},
"Current": {
"v": 0.930000007,
"u": "A",
"d": 2
},
"YieldDay": {
"v": 31,
"u": "Wh",
"d": 0
},
"YieldTotal": {
"v": 4.301000118,
"u": "kWh",
"d": 3
},
"Irradiation": {
"v": 8.902439117,
"u": "%",
"d": 2
}
},
"2": {
"Power": {
"v": 37.09999847,
"u": "W",
"d": 1
},
"Voltage": {
"v": 40.79999924,
"u": "V",
"d": 1
},
"Current": {
"v": 0.910000026,
"u": "A",
"d": 2
},
"YieldDay": {
"v": 17,
"u": "Wh",
"d": 0
},
"YieldTotal": {
"v": 44.23899841,
"u": "kWh",
"d": 3
},
"Irradiation": {
"v": 9.048780441,
"u": "%",
"d": 2
}
},
"events": 1
}
],
"total": {
"Power": {
"v": 140.7999878,
"u": "W",
"d": 1
},
"YieldDay": {
"v": 71,
"u": "Wh",
"d": 0
},
"YieldTotal": {
"v": 199.0449982,
"u": "kWh",
"d": 2
}
}
}
```
The eventlog can be fetched with the inverter serial number as parameter:
```
martin@bln9716cm ~/swbuild/OpenDTU $ curl --no-progress-meter http://192.168.10.10/api/eventlog/status?inv=11418186xxxx | jq
{
"11418186xxxx": {
"count": 4,
"events": [
{
"message_id": 1,
"message": "Inverter start",
"start_time": 28028,
"end_time": 28028
},
{
"message_id": 209,
"message": "PV-1: No input",
"start_time": 28036,
"end_time": 0
},
{
"message_id": 2,
"message": "DTU command failed",
"start_time": 28092,
"end_time": 28092
},
{
"message_id": 207,
"message": "MPPT-A: Input undervoltage",
"start_time": 28336,
"end_time": 0
}
]
}
}
```
#### combine curl and jq
`jq` can filter specific fields from json output.
For example, filter out the current total power:
```
~$ curl --no-progress-meter http://192.168.10.10/api/livedata/status | jq '.total | .Power.v'
140.7999878
```
#### Get information where login is required
When config data is requested, username and password have to be provided to `curl`
Username is always `admin`, the default password is `openDTU42`. The password is used for both the admin login and the Admin-mode Access Point.
```
~$ curl --u admin:openDTU42 http://192.168.10.10/api/ntp/config
{"ntp_server":"pool.ntp.org","ntp_timezone":"CET-1CEST,M3.5.0,M10.5.0/3","ntp_timezone_descr":"Europe/Berlin"}
```
### Post information
With HTTP POST commands information can be written to the OpenDTU.
The Web API is designed to allow the web frontend in the web browser to communicate with the OpenDTU software running on the ESP32. It is not designed to be intuitive or user-friendly, so please follow the instructions here.
#### Example 1: change ntp settings
If you want to configure the ntp server setting, first fetch the information from the web API:
```
~$ curl -u "admin:password" http://192.168.10.10/api/ntp/config
{"ntp_server":"pool.ntp.org","ntp_timezone":"CET-1CEST,M3.5.0,M10.5.0/3","ntp_timezone_descr":"Europe/Berlin"}
```
Then, second step, send your new settings. Use the text output from curl in the first step, add `data=` and enclose the whole data with single quotes.
```
~$ curl -u "admin:password" http://192.168.10.10/api/ntp/config -d 'data={"ntp_server":"my.own.ntp.server.home","ntp_timezone":"CET-1CEST,M3.5.0,M10.5.0/3","ntp_timezone_descr":"Europe/Berlin"}'
{"type":"success","message":"Settings saved!"}
```
You will receive a json formatted response.
#### Example 2: change power limit
In the second example, I want to change the non persistent power limit of an inverter. Again, first fetch current data:
```
~$ curl http://192.168.10.10/api/limit/status
{"11418186xxxx":{"limit_relative":100,"max_power":600,"limit_set_status":"Ok"},"11418180xxxx":{"limit_relative":100,"max_power":800,"limit_set_status":"Ok"}}
```
I see data from two configured inverters.
Now I set the relative power limit of inverter with serialnumber `11418180xxxx` to 50%.
```
~$ curl -u "admin:password" http://192.168.10.10/api/limit/config -d 'data={"serial":"11418180xxxx", "limit_type":1, "limit_value":50}'
{"type":"success","message":"Settings saved!"}
```
Then I read again the limit status. In the first answer the status is `pending`, some seconds later it changed to `OK`.
```
~$ curl http://192.168.10.10/api/limit/status
{"11418186xxxx":{"limit_relative":100,"max_power":600,"limit_set_status":"Ok"},"11418180xxxx":{"limit_relative":100,"max_power":800,"limit_set_status":"Pending"}}
...
~$ curl http://192.168.10.10/api/limit/status
{"11418186xxxx":{"limit_relative":100,"max_power":600,"limit_set_status":"Ok"},"11418180xxxx":{"limit_relative":50,"max_power":800,"limit_set_status":"Ok"}}
```

View File

@ -12,6 +12,7 @@
#include "WebApi_network.h"
#include "WebApi_ntp.h"
#include "WebApi_power.h"
#include "WebApi_prometheus.h"
#include "WebApi_security.h"
#include "WebApi_sysstatus.h"
#include "WebApi_webapp.h"
@ -26,6 +27,8 @@ public:
void init();
void loop();
static bool checkCredentials(AsyncWebServerRequest* request);
private:
AsyncWebServer _server;
AsyncEventSource _events;
@ -41,6 +44,7 @@ private:
WebApiNetworkClass _webApiNetwork;
WebApiNtpClass _webApiNtp;
WebApiPowerClass _webApiPower;
WebApiPrometheusClass _webApiPrometheus;
WebApiSecurityClass _webApiSecurity;
WebApiSysstatusClass _webApiSysstatus;
WebApiWebappClass _webApiWebapp;

View File

@ -0,0 +1,17 @@
#pragma once
#include "Hoymiles.h"
#include <ESPAsyncWebServer.h>
class WebApiPrometheusClass {
public:
void init(AsyncWebServer* server);
void loop();
private:
void onPrometheusMetricsGet(AsyncWebServerRequest* request);
void addField(AsyncResponseStream* stream, String& serial, uint8_t idx, std::shared_ptr<InverterAbstract> inv, uint8_t channel, uint8_t fieldId, const char* channelName = NULL);
AsyncWebServer* _server;
};

View File

@ -12,5 +12,7 @@ private:
void onPasswordGet(AsyncWebServerRequest* request);
void onPasswordPost(AsyncWebServerRequest* request);
void onAuthenticateGet(AsyncWebServerRequest* request);
AsyncWebServer* _server;
};

View File

@ -9,6 +9,7 @@
#define ACCESS_POINT_NAME "OpenDTU-"
#define ACCESS_POINT_PASSWORD "openDTU42"
#define AUTH_USERNAME "admin"
#define ADMIN_TIMEOUT 180
#define WIFI_RECONNECT_TIMEOUT 15

View File

@ -4,10 +4,16 @@
#include "inverters/HM_4CH.h"
#include <Arduino.h>
#define HOY_SEMAPHORE_TAKE() xSemaphoreTake(_xSemaphore, portMAX_DELAY)
#define HOY_SEMAPHORE_GIVE() xSemaphoreGive(_xSemaphore)
HoymilesClass Hoymiles;
void HoymilesClass::init()
{
_xSemaphore = xSemaphoreCreateMutex();
HOY_SEMAPHORE_GIVE(); // release before first use
_pollInterval = 0;
_radio.reset(new HoymilesRadio());
_radio->init();
@ -15,49 +21,51 @@ void HoymilesClass::init()
void HoymilesClass::loop()
{
HOY_SEMAPHORE_TAKE();
_radio->loop();
if (getNumInverters() > 0) {
if (millis() - _lastPoll > (_pollInterval * 1000)) {
static uint8_t inverterPos = 0;
std::shared_ptr<InverterAbstract> iv = getInverterByPos(inverterPos);
if (iv != nullptr && _radio->isIdle()) {
Serial.print(F("Fetch inverter: "));
Serial.println(iv->serial(), HEX);
if (_radio->isIdle()) {
std::shared_ptr<InverterAbstract> iv = getInverterByPos(inverterPos);
if (iv != nullptr) {
Serial.print(F("Fetch inverter: "));
Serial.println(iv->serial(), HEX);
iv->sendStatsRequest(_radio.get());
iv->sendStatsRequest(_radio.get());
// Fetch event log
bool force = iv->EventLog()->getLastAlarmRequestSuccess() == CMD_NOK;
iv->sendAlarmLogRequest(_radio.get(), force);
// Fetch event log
bool force = iv->EventLog()->getLastAlarmRequestSuccess() == CMD_NOK;
iv->sendAlarmLogRequest(_radio.get(), force);
// Fetch limit
if ((iv->SystemConfigPara()->getLastLimitRequestSuccess() == CMD_NOK)
|| ((millis() - iv->SystemConfigPara()->getLastUpdateRequest() > HOY_SYSTEM_CONFIG_PARA_POLL_INTERVAL)
&& (millis() - iv->SystemConfigPara()->getLastUpdateCommand() > HOY_SYSTEM_CONFIG_PARA_POLL_MIN_DURATION))) {
Serial.println("Request SystemConfigPara");
iv->sendSystemConfigParaRequest(_radio.get());
// Fetch limit
if ((iv->SystemConfigPara()->getLastLimitRequestSuccess() == CMD_NOK)
|| ((millis() - iv->SystemConfigPara()->getLastUpdateRequest() > HOY_SYSTEM_CONFIG_PARA_POLL_INTERVAL)
&& (millis() - iv->SystemConfigPara()->getLastUpdateCommand() > HOY_SYSTEM_CONFIG_PARA_POLL_MIN_DURATION))) {
Serial.println("Request SystemConfigPara");
iv->sendSystemConfigParaRequest(_radio.get());
}
// Set limit if required
if (iv->SystemConfigPara()->getLastLimitCommandSuccess() == CMD_NOK) {
Serial.println(F("Resend ActivePowerControl"));
iv->resendActivePowerControlRequest(_radio.get());
}
// Set power status if required
if (iv->PowerCommand()->getLastPowerCommandSuccess() == CMD_NOK) {
Serial.println(F("Resend PowerCommand"));
iv->resendPowerControlRequest(_radio.get());
}
// Fetch dev info (but first fetch stats)
if (iv->Statistics()->getLastUpdate() > 0 && (iv->DevInfo()->getLastUpdateAll() == 0 || iv->DevInfo()->getLastUpdateSimple() == 0)) {
Serial.println(F("Request device info"));
iv->sendDevInfoRequest(_radio.get());
}
}
// Set limit if required
if (iv->SystemConfigPara()->getLastLimitCommandSuccess() == CMD_NOK) {
Serial.println(F("Resend ActivePowerControl"));
iv->resendActivePowerControlRequest(_radio.get());
}
// Set power status if required
if (iv->PowerCommand()->getLastPowerCommandSuccess() == CMD_NOK) {
Serial.println(F("Resend PowerCommand"));
iv->resendPowerControlRequest(_radio.get());
}
// Fetch dev info (but first fetch stats)
if (iv->Statistics()->getLastUpdate() > 0 && (iv->DevInfo()->getLastUpdateAll() == 0 || iv->DevInfo()->getLastUpdateSimple() == 0)) {
Serial.println(F("Request device info"));
iv->sendDevInfoRequest(_radio.get());
}
if (++inverterPos >= getNumInverters()) {
inverterPos = 0;
}
@ -66,6 +74,8 @@ void HoymilesClass::loop()
_lastPoll = millis();
}
}
HOY_SEMAPHORE_GIVE();
}
std::shared_ptr<InverterAbstract> HoymilesClass::addInverter(const char* name, uint64_t serial)
@ -135,7 +145,9 @@ void HoymilesClass::removeInverterBySerial(uint64_t serial)
{
for (uint8_t i = 0; i < _inverters.size(); i++) {
if (_inverters[i]->serial() == serial) {
HOY_SEMAPHORE_TAKE();
_inverters.erase(_inverters.begin() + i);
HOY_SEMAPHORE_GIVE();
return;
}
}

View File

@ -31,6 +31,8 @@ private:
std::vector<std::shared_ptr<InverterAbstract>> _inverters;
std::unique_ptr<HoymilesRadio> _radio;
SemaphoreHandle_t _xSemaphore;
uint32_t _pollInterval = 0;
uint32_t _lastPoll = 0;
};

View File

@ -127,7 +127,7 @@ void HoymilesRadio::loop()
}
} else {
// If inverter was not found, assume the command is invalid
Serial.println(F("Invalid inverter found"));
Serial.println(F("RX: Invalid inverter found"));
_commandQueue.pop();
_busyFlag = false;
}
@ -137,8 +137,13 @@ void HoymilesRadio::loop()
CommandAbstract* cmd = _commandQueue.front().get();
auto inv = Hoymiles.getInverterBySerial(cmd->getTargetAddress());
inv->clearRxFragmentBuffer();
sendEsbPacket(cmd);
if (nullptr != inv) {
inv->clearRxFragmentBuffer();
sendEsbPacket(cmd);
} else {
Serial.println(F("TX: Invalid inverter found"));
_commandQueue.pop();
}
}
}
}

View File

@ -14,7 +14,7 @@ HM_Abstract::HM_Abstract(uint64_t serial)
bool HM_Abstract::sendStatsRequest(HoymilesRadio* radio)
{
struct tm timeinfo;
if (!getLocalTime(&timeinfo, 0)) {
if (!getLocalTime(&timeinfo, 5)) {
return false;
}
@ -31,7 +31,7 @@ bool HM_Abstract::sendStatsRequest(HoymilesRadio* radio)
bool HM_Abstract::sendAlarmLogRequest(HoymilesRadio* radio, bool force)
{
struct tm timeinfo;
if (!getLocalTime(&timeinfo, 0)) {
if (!getLocalTime(&timeinfo, 5)) {
return false;
}
@ -59,7 +59,7 @@ bool HM_Abstract::sendAlarmLogRequest(HoymilesRadio* radio, bool force)
bool HM_Abstract::sendDevInfoRequest(HoymilesRadio* radio)
{
struct tm timeinfo;
if (!getLocalTime(&timeinfo, 0)) {
if (!getLocalTime(&timeinfo, 5)) {
return false;
}
@ -80,7 +80,7 @@ bool HM_Abstract::sendDevInfoRequest(HoymilesRadio* radio)
bool HM_Abstract::sendSystemConfigParaRequest(HoymilesRadio* radio)
{
struct tm timeinfo;
if (!getLocalTime(&timeinfo, 0)) {
if (!getLocalTime(&timeinfo, 5)) {
return false;
}

View File

@ -23,7 +23,7 @@ build_flags =
lib_deps =
https://github.com/yubox-node-org/ESPAsyncWebServer
bblanchon/ArduinoJson @ ^6.19.4
https://github.com/bertmelis/espMqttClient.git#v1.3.2
https://github.com/bertmelis/espMqttClient.git#v1.3.3
nrf24/RF24 @ ^1.4.5
extra_scripts =

View File

@ -152,7 +152,7 @@ void MqttHassPublishingClass::publishInverterButton(std::shared_ptr<InverterAbst
String cmdTopic = MqttSettings.getPrefix() + serial + "/" + subTopic;
DynamicJsonDocument root(1024);
root[F("name")] = caption;
root[F("name")] = String(inv->name()) + " " + caption;
root[F("uniq_id")] = serial + "_" + buttonId;
if (strcmp(icon, "")) {
root[F("ic")] = icon;
@ -191,7 +191,7 @@ void MqttHassPublishingClass::publishInverterNumber(
String statTopic = MqttSettings.getPrefix() + serial + "/" + stateTopic;
DynamicJsonDocument root(1024);
root[F("name")] = caption;
root[F("name")] = String(inv->name()) + " " + caption;
root[F("uniq_id")] = serial + "_" + buttonId;
if (strcmp(icon, "")) {
root[F("ic")] = icon;
@ -226,7 +226,7 @@ void MqttHassPublishingClass::publishInverterBinarySensor(std::shared_ptr<Invert
String statTopic = MqttSettings.getPrefix() + serial + "/" + subTopic;
DynamicJsonDocument root(1024);
root[F("name")] = caption;
root[F("name")] = String(inv->name()) + " " + caption;
root[F("uniq_id")] = serial + "_" + sensorId;
root[F("stat_t")] = statTopic;
root[F("pl_on")] = payload_on;

View File

@ -5,6 +5,7 @@
#include "WebApi.h"
#include "ArduinoJson.h"
#include "AsyncJson.h"
#include "Configuration.h"
#include "defaults.h"
WebApiClass::WebApiClass()
@ -28,6 +29,7 @@ void WebApiClass::init()
_webApiNetwork.init(&_server);
_webApiNtp.init(&_server);
_webApiPower.init(&_server);
_webApiPrometheus.init(&_server);
_webApiSecurity.init(&_server);
_webApiSysstatus.init(&_server);
_webApiWebapp.init(&_server);
@ -59,4 +61,22 @@ void WebApiClass::loop()
_webApiVedirect.loop();
}
bool WebApiClass::checkCredentials(AsyncWebServerRequest* request)
{
CONFIG_T& config = Configuration.get();
if (request->authenticate(AUTH_USERNAME, config.Security_Password)) {
return true;
}
AsyncWebServerResponse* r = request->beginResponse(401);
// WebAPI should set the X-Requested-With to prevent browser internal auth dialogs
if (!request->hasHeader("X-Requested-With")) {
r->addHeader(F("WWW-Authenticate"), F("Basic realm=\"Login Required\""));
}
request->send(r);
return false;
}
WebApiClass WebApi;

View File

@ -6,6 +6,7 @@
#include "ArduinoJson.h"
#include "AsyncJson.h"
#include "Configuration.h"
#include "WebApi.h"
#include <LittleFS.h>
void WebApiConfigClass::init(AsyncWebServer* server)
@ -32,11 +33,19 @@ void WebApiConfigClass::loop()
void WebApiConfigClass::onConfigGet(AsyncWebServerRequest* request)
{
if (!WebApi.checkCredentials(request)) {
return;
}
request->send(LittleFS, CONFIG_FILENAME_JSON, String(), true);
}
void WebApiConfigClass::onConfigDelete(AsyncWebServerRequest* request)
{
if (!WebApi.checkCredentials(request)) {
return;
}
AsyncJsonResponse* response = new AsyncJsonResponse();
JsonObject retMsg = response->getRoot();
retMsg[F("type")] = F("warning");
@ -93,6 +102,10 @@ void WebApiConfigClass::onConfigDelete(AsyncWebServerRequest* request)
void WebApiConfigClass::onConfigUploadFinish(AsyncWebServerRequest* request)
{
if (!WebApi.checkCredentials(request)) {
return;
}
// the request handler is triggered after the upload has finished...
// create the response, add header, and send response
@ -108,6 +121,10 @@ void WebApiConfigClass::onConfigUploadFinish(AsyncWebServerRequest* request)
void WebApiConfigClass::onConfigUpload(AsyncWebServerRequest* request, String filename, size_t index, uint8_t* data, size_t len, bool final)
{
if (!WebApi.checkCredentials(request)) {
return;
}
if (!index) {
// open the file on first call and store the file handle in the request object
request->_tempFile = LittleFS.open(CONFIG_FILENAME_JSON, "w");

View File

@ -7,6 +7,7 @@
#include "AsyncJson.h"
#include "Configuration.h"
#include "Hoymiles.h"
#include "WebApi.h"
void WebApiDtuClass::init(AsyncWebServer* server)
{
@ -24,6 +25,10 @@ void WebApiDtuClass::loop()
void WebApiDtuClass::onDtuAdminGet(AsyncWebServerRequest* request)
{
if (!WebApi.checkCredentials(request)) {
return;
}
AsyncJsonResponse* response = new AsyncJsonResponse();
JsonObject root = response->getRoot();
const CONFIG_T& config = Configuration.get();
@ -43,6 +48,10 @@ void WebApiDtuClass::onDtuAdminGet(AsyncWebServerRequest* request)
void WebApiDtuClass::onDtuAdminPost(AsyncWebServerRequest* request)
{
if (!WebApi.checkCredentials(request)) {
return;
}
AsyncJsonResponse* response = new AsyncJsonResponse();
JsonObject retMsg = response->getRoot();
retMsg[F("type")] = F("warning");

View File

@ -7,6 +7,7 @@
#include "AsyncJson.h"
#include "Configuration.h"
#include "Update.h"
#include "WebApi.h"
#include "helper.h"
void WebApiFirmwareClass::init(AsyncWebServer* server)
@ -31,6 +32,10 @@ void WebApiFirmwareClass::loop()
void WebApiFirmwareClass::onFirmwareUpdateFinish(AsyncWebServerRequest* request)
{
if (!WebApi.checkCredentials(request)) {
return;
}
// the request handler is triggered after the upload has finished...
// create the response, add header, and send response
@ -46,6 +51,10 @@ void WebApiFirmwareClass::onFirmwareUpdateFinish(AsyncWebServerRequest* request)
void WebApiFirmwareClass::onFirmwareUpdateUpload(AsyncWebServerRequest* request, String filename, size_t index, uint8_t* data, size_t len, bool final)
{
if (!WebApi.checkCredentials(request)) {
return;
}
// Upload handler chunks in data
if (!index) {
if (!request->hasParam("MD5", true)) {

View File

@ -8,6 +8,7 @@
#include "Configuration.h"
#include "Hoymiles.h"
#include "MqttHassPublishing.h"
#include "WebApi.h"
#include "helper.h"
void WebApiInverterClass::init(AsyncWebServer* server)
@ -28,6 +29,10 @@ void WebApiInverterClass::loop()
void WebApiInverterClass::onInverterList(AsyncWebServerRequest* request)
{
if (!WebApi.checkCredentials(request)) {
return;
}
AsyncJsonResponse* response = new AsyncJsonResponse(false, 4096U);
JsonObject root = response->getRoot();
JsonArray data = root.createNestedArray(F("inverter"));
@ -48,13 +53,16 @@ void WebApiInverterClass::onInverterList(AsyncWebServerRequest* request)
obj[F("serial")] = buffer;
auto inv = Hoymiles.getInverterBySerial(config.Inverter[i].Serial);
uint8_t max_channels;
if (inv == nullptr) {
obj[F("type")] = F("Unknown");
max_channels = INV_MAX_CHAN_COUNT;
} else {
obj[F("type")] = inv->typeName();
max_channels = inv->Statistics()->getChannelCount();
}
for (uint8_t c = 0; c < INV_MAX_CHAN_COUNT; c++) {
for (uint8_t c = 0; c < max_channels; c++) {
obj[F("max_power")][c] = config.Inverter[i].MaxChannelPower[c];
}
}
@ -66,6 +74,10 @@ void WebApiInverterClass::onInverterList(AsyncWebServerRequest* request)
void WebApiInverterClass::onInverterAdd(AsyncWebServerRequest* request)
{
if (!WebApi.checkCredentials(request)) {
return;
}
AsyncJsonResponse* response = new AsyncJsonResponse();
JsonObject retMsg = response->getRoot();
retMsg[F("type")] = F("warning");
@ -151,6 +163,10 @@ void WebApiInverterClass::onInverterAdd(AsyncWebServerRequest* request)
void WebApiInverterClass::onInverterEdit(AsyncWebServerRequest* request)
{
if (!WebApi.checkCredentials(request)) {
return;
}
AsyncJsonResponse* response = new AsyncJsonResponse();
JsonObject retMsg = response->getRoot();
retMsg[F("type")] = F("warning");
@ -210,7 +226,7 @@ void WebApiInverterClass::onInverterEdit(AsyncWebServerRequest* request)
}
JsonArray maxPowerArray = root[F("max_power")].as<JsonArray>();
if (maxPowerArray.size() != INV_MAX_CHAN_COUNT) {
if (maxPowerArray.size() == 0 || maxPowerArray.size() > INV_MAX_CHAN_COUNT) {
retMsg[F("message")] = F("Invalid amount of max channel setting given!");
response->setLength();
request->send(response);
@ -265,6 +281,10 @@ void WebApiInverterClass::onInverterEdit(AsyncWebServerRequest* request)
void WebApiInverterClass::onInverterDelete(AsyncWebServerRequest* request)
{
if (!WebApi.checkCredentials(request)) {
return;
}
AsyncJsonResponse* response = new AsyncJsonResponse();
JsonObject retMsg = response->getRoot();
retMsg[F("type")] = F("warning");

View File

@ -6,6 +6,7 @@
#include "ArduinoJson.h"
#include "AsyncJson.h"
#include "Hoymiles.h"
#include "WebApi.h"
void WebApiLimitClass::init(AsyncWebServer* server)
{
@ -54,6 +55,10 @@ void WebApiLimitClass::onLimitStatus(AsyncWebServerRequest* request)
void WebApiLimitClass::onLimitPost(AsyncWebServerRequest* request)
{
if (!WebApi.checkCredentials(request)) {
return;
}
AsyncJsonResponse* response = new AsyncJsonResponse();
JsonObject retMsg = response->getRoot();
retMsg[F("type")] = F("warning");

View File

@ -8,6 +8,7 @@
#include "Configuration.h"
#include "MqttHassPublishing.h"
#include "MqttSettings.h"
#include "WebApi.h"
#include "helper.h"
void WebApiMqttClass::init(AsyncWebServer* server)
@ -54,6 +55,10 @@ void WebApiMqttClass::onMqttStatus(AsyncWebServerRequest* request)
void WebApiMqttClass::onMqttAdminGet(AsyncWebServerRequest* request)
{
if (!WebApi.checkCredentials(request)) {
return;
}
AsyncJsonResponse* response = new AsyncJsonResponse(false, MQTT_JSON_DOC_SIZE);
JsonObject root = response->getRoot();
const CONFIG_T& config = Configuration.get();
@ -83,6 +88,10 @@ void WebApiMqttClass::onMqttAdminGet(AsyncWebServerRequest* request)
void WebApiMqttClass::onMqttAdminPost(AsyncWebServerRequest* request)
{
if (!WebApi.checkCredentials(request)) {
return;
}
AsyncJsonResponse* response = new AsyncJsonResponse(false, MQTT_JSON_DOC_SIZE);
JsonObject retMsg = response->getRoot();
retMsg[F("type")] = F("warning");

View File

@ -7,6 +7,7 @@
#include "AsyncJson.h"
#include "Configuration.h"
#include "NetworkSettings.h"
#include "WebApi.h"
#include "helper.h"
void WebApiNetworkClass::init(AsyncWebServer* server)
@ -52,6 +53,10 @@ void WebApiNetworkClass::onNetworkStatus(AsyncWebServerRequest* request)
void WebApiNetworkClass::onNetworkAdminGet(AsyncWebServerRequest* request)
{
if (!WebApi.checkCredentials(request)) {
return;
}
AsyncJsonResponse* response = new AsyncJsonResponse();
JsonObject root = response->getRoot();
const CONFIG_T& config = Configuration.get();
@ -72,6 +77,10 @@ void WebApiNetworkClass::onNetworkAdminGet(AsyncWebServerRequest* request)
void WebApiNetworkClass::onNetworkAdminPost(AsyncWebServerRequest* request)
{
if (!WebApi.checkCredentials(request)) {
return;
}
AsyncJsonResponse* response = new AsyncJsonResponse();
JsonObject retMsg = response->getRoot();
retMsg[F("type")] = F("warning");

View File

@ -7,6 +7,7 @@
#include "AsyncJson.h"
#include "Configuration.h"
#include "NtpSettings.h"
#include "WebApi.h"
#include "helper.h"
void WebApiNtpClass::init(AsyncWebServer* server)
@ -37,7 +38,7 @@ void WebApiNtpClass::onNtpStatus(AsyncWebServerRequest* request)
root[F("ntp_timezone_descr")] = config.Ntp_TimezoneDescr;
struct tm timeinfo;
if (!getLocalTime(&timeinfo, 0)) {
if (!getLocalTime(&timeinfo, 5)) {
root[F("ntp_status")] = false;
} else {
root[F("ntp_status")] = true;
@ -52,6 +53,10 @@ void WebApiNtpClass::onNtpStatus(AsyncWebServerRequest* request)
void WebApiNtpClass::onNtpAdminGet(AsyncWebServerRequest* request)
{
if (!WebApi.checkCredentials(request)) {
return;
}
AsyncJsonResponse* response = new AsyncJsonResponse();
JsonObject root = response->getRoot();
const CONFIG_T& config = Configuration.get();
@ -66,6 +71,10 @@ void WebApiNtpClass::onNtpAdminGet(AsyncWebServerRequest* request)
void WebApiNtpClass::onNtpAdminPost(AsyncWebServerRequest* request)
{
if (!WebApi.checkCredentials(request)) {
return;
}
AsyncJsonResponse* response = new AsyncJsonResponse();
JsonObject retMsg = response->getRoot();
retMsg[F("type")] = F("warning");
@ -142,11 +151,15 @@ void WebApiNtpClass::onNtpAdminPost(AsyncWebServerRequest* request)
void WebApiNtpClass::onNtpTimeGet(AsyncWebServerRequest* request)
{
if (!WebApi.checkCredentials(request)) {
return;
}
AsyncJsonResponse* response = new AsyncJsonResponse();
JsonObject root = response->getRoot();
struct tm timeinfo;
if (!getLocalTime(&timeinfo, 0)) {
if (!getLocalTime(&timeinfo, 5)) {
root[F("ntp_status")] = false;
} else {
root[F("ntp_status")] = true;
@ -165,6 +178,10 @@ void WebApiNtpClass::onNtpTimeGet(AsyncWebServerRequest* request)
void WebApiNtpClass::onNtpTimePost(AsyncWebServerRequest* request)
{
if (!WebApi.checkCredentials(request)) {
return;
}
AsyncJsonResponse* response = new AsyncJsonResponse();
JsonObject retMsg = response->getRoot();
retMsg[F("type")] = F("warning");

View File

@ -6,6 +6,7 @@
#include "ArduinoJson.h"
#include "AsyncJson.h"
#include "Hoymiles.h"
#include "WebApi.h"
void WebApiPowerClass::init(AsyncWebServer* server)
{
@ -47,6 +48,10 @@ void WebApiPowerClass::onPowerStatus(AsyncWebServerRequest* request)
void WebApiPowerClass::onPowerPost(AsyncWebServerRequest* request)
{
if (!WebApi.checkCredentials(request)) {
return;
}
AsyncJsonResponse* response = new AsyncJsonResponse();
JsonObject retMsg = response->getRoot();
retMsg[F("type")] = F("warning");

97
src/WebApi_prometheus.cpp Normal file
View File

@ -0,0 +1,97 @@
#include "WebApi_prometheus.h"
#include "Configuration.h"
#include "Hoymiles.h"
#include "NetworkSettings.h"
void WebApiPrometheusClass::init(AsyncWebServer* server)
{
using std::placeholders::_1;
_server = server;
_server->on("/api/prometheus/metrics", HTTP_GET, std::bind(&WebApiPrometheusClass::onPrometheusMetricsGet, this, _1));
}
void WebApiPrometheusClass::loop()
{
}
void WebApiPrometheusClass::onPrometheusMetricsGet(AsyncWebServerRequest* request)
{
auto stream = request->beginResponseStream("text/plain; charset=utf-8", 40960);
stream->print(F("# HELP opendtu_build Build info\n"));
stream->print(F("# TYPE opendtu_build gauge\n"));
stream->printf("opendtu_build{name=\"%s\",id=\"%s\",version=\"%d.%d.%d\"} 1\n",
NetworkSettings.getHostname().c_str(), AUTO_GIT_HASH, CONFIG_VERSION >> 24 & 0xff, CONFIG_VERSION >> 16 & 0xff, CONFIG_VERSION >> 8 & 0xff);
stream->print(F("# HELP opendtu_platform Platform info\n"));
stream->print(F("# TYPE opendtu_platform gauge\n"));
stream->printf("opendtu_platform{arch=\"%s\",mac=\"%s\"} 1\n", ESP.getChipModel(), WiFi.macAddress().c_str());
stream->print(F("# HELP opendtu_uptime Uptime in seconds\n"));
stream->print(F("# TYPE opendtu_uptime counter\n"));
stream->printf("opendtu_uptime %lld\n", esp_timer_get_time() / 1000000);
stream->print(F("# HELP opendtu_heap_size System memory size\n"));
stream->print(F("# TYPE opendtu_heap_size gauge\n"));
stream->printf("opendtu_heap_size %zu\n", ESP.getHeapSize());
stream->print(F("# HELP opendtu_free_heap_size System free memory\n"));
stream->print(F("# TYPE opendtu_free_heap_size gauge\n"));
stream->printf("opendtu_free_heap_size %zu\n", ESP.getFreeHeap());
stream->print(F("# HELP wifi_rssi WiFi RSSI\n"));
stream->print(F("# TYPE wifi_rssi gauge\n"));
stream->printf("wifi_rssi %d\n", WiFi.RSSI());
for (uint8_t i = 0; i < Hoymiles.getNumInverters(); i++) {
auto inv = Hoymiles.getInverterByPos(i);
String serial = inv->serialString();
const char* name = inv->name();
if (i == 0) {
stream->print(F("# HELP opendtu_last_update last update from inverter in s\n"));
stream->print(F("# TYPE opendtu_last_update gauge\n"));
}
stream->printf("opendtu_last_update{serial=\"%s\",unit=\"%d\",name=\"%s\"} %d\n",
serial.c_str(), i, name, inv->Statistics()->getLastUpdate() / 1000);
// Loop all channels
for (uint8_t c = 0; c <= inv->Statistics()->getChannelCount(); c++) {
addField(stream, serial, i, inv, c, FLD_PAC);
addField(stream, serial, i, inv, c, FLD_UAC);
addField(stream, serial, i, inv, c, FLD_IAC);
if (c == 0) {
addField(stream, serial, i, inv, c, FLD_PDC, "PowerDC");
} else {
addField(stream, serial, i, inv, c, FLD_PDC);
}
addField(stream, serial, i, inv, c, FLD_UDC);
addField(stream, serial, i, inv, c, FLD_IDC);
addField(stream, serial, i, inv, c, FLD_YD);
addField(stream, serial, i, inv, c, FLD_YT);
addField(stream, serial, i, inv, c, FLD_F);
addField(stream, serial, i, inv, c, FLD_T);
addField(stream, serial, i, inv, c, FLD_PF);
addField(stream, serial, i, inv, c, FLD_PRA);
addField(stream, serial, i, inv, c, FLD_EFF);
addField(stream, serial, i, inv, c, FLD_IRR);
}
}
stream->addHeader(F("Cache-Control"), F("no-cache"));
request->send(stream);
}
void WebApiPrometheusClass::addField(AsyncResponseStream* stream, String& serial, uint8_t idx, std::shared_ptr<InverterAbstract> inv, uint8_t channel, uint8_t fieldId, const char* channelName)
{
if (inv->Statistics()->hasChannelFieldValue(channel, fieldId)) {
const char* chanName = (channelName == NULL) ? inv->Statistics()->getChannelFieldName(channel, fieldId) : channelName;
if (idx == 0 && channel == 0) {
stream->printf("# HELP opendtu_%s in %s\n", chanName, inv->Statistics()->getChannelFieldUnit(channel, fieldId));
stream->printf("# TYPE opendtu_%s gauge\n", chanName);
}
stream->printf("opendtu_%s{serial=\"%s\",unit=\"%d\",name=\"%s\",channel=\"%d\"} %f\n", chanName, serial.c_str(), idx, inv->name(), channel, inv->Statistics()->getChannelFieldValue(channel, fieldId));
}
}

View File

@ -6,6 +6,7 @@
#include "ArduinoJson.h"
#include "AsyncJson.h"
#include "Configuration.h"
#include "WebApi.h"
#include "helper.h"
void WebApiSecurityClass::init(AsyncWebServer* server)
@ -16,6 +17,7 @@ void WebApiSecurityClass::init(AsyncWebServer* server)
_server->on("/api/security/password", HTTP_GET, std::bind(&WebApiSecurityClass::onPasswordGet, this, _1));
_server->on("/api/security/password", HTTP_POST, std::bind(&WebApiSecurityClass::onPasswordPost, this, _1));
_server->on("/api/security/authenticate", HTTP_GET, std::bind(&WebApiSecurityClass::onAuthenticateGet, this, _1));
}
void WebApiSecurityClass::loop()
@ -24,6 +26,10 @@ void WebApiSecurityClass::loop()
void WebApiSecurityClass::onPasswordGet(AsyncWebServerRequest* request)
{
if (!WebApi.checkCredentials(request)) {
return;
}
AsyncJsonResponse* response = new AsyncJsonResponse();
JsonObject root = response->getRoot();
const CONFIG_T& config = Configuration.get();
@ -36,6 +42,10 @@ void WebApiSecurityClass::onPasswordGet(AsyncWebServerRequest* request)
void WebApiSecurityClass::onPasswordPost(AsyncWebServerRequest* request)
{
if (!WebApi.checkCredentials(request)) {
return;
}
AsyncJsonResponse* response = new AsyncJsonResponse();
JsonObject retMsg = response->getRoot();
retMsg[F("type")] = F("warning");
@ -87,6 +97,21 @@ void WebApiSecurityClass::onPasswordPost(AsyncWebServerRequest* request)
retMsg[F("type")] = F("success");
retMsg[F("message")] = F("Settings saved!");
response->setLength();
request->send(response);
}
void WebApiSecurityClass::onAuthenticateGet(AsyncWebServerRequest* request)
{
if (!WebApi.checkCredentials(request)) {
return;
}
AsyncJsonResponse* response = new AsyncJsonResponse();
JsonObject retMsg = response->getRoot();
retMsg[F("type")] = F("success");
retMsg[F("message")] = F("Authentication successfull!");
response->setLength();
request->send(response);
}

View File

@ -14,23 +14,25 @@
"@popperjs/core": "^2.11.6",
"bootstrap": "^5.2.2",
"bootstrap-icons-vue": "^1.8.1",
"mitt": "^3.0.0",
"spark-md5": "^3.0.2",
"vue": "^3.2.41",
"vue": "^3.2.45",
"vue-router": "^4.1.6"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.2.0",
"@types/bootstrap": "^5.2.5",
"@types/node": "^18.11.8",
"@types/bootstrap": "^5.2.6",
"@types/node": "^18.11.9",
"@types/spark-md5": "^3.0.2",
"@vitejs/plugin-vue": "^3.2.0",
"@vue/eslint-config-typescript": "^11.0.2",
"@vue/tsconfig": "^0.1.3",
"eslint": "^8.26.0",
"eslint": "^8.27.0",
"eslint-plugin-vue": "^9.7.0",
"npm-run-all": "^4.1.5",
"sass": "^1.56.1",
"typescript": "^4.8.4",
"vite": "^3.2.2",
"vite": "^3.2.3",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-css-injected-by-js": "^2.1.1",
"vue-tsc": "^1.0.9"

View File

@ -14,7 +14,7 @@ import { defineComponent } from 'vue';
import { formatNumber } from '@/utils';
declare interface LimitData {
limit: number,
limit: number;
}
export default defineComponent({

View File

@ -7,7 +7,7 @@
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" ref="navbarCollapse" id="navbarNavAltMarkup">
<ul class="navbar-nav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<router-link @click="onClick" class="nav-link" to="/">Live Data</router-link>
</li>
@ -78,6 +78,10 @@
<router-link @click="onClick" class="nav-link" to="/about">About</router-link>
</li>
</ul>
<form class="d-flex" role="search">
<button v-if="isLogged" class="btn btn-outline-danger" @click="signout">Logout</button>
<button v-if="!isLogged" class="btn btn-outline-success" @click="signin">Login</button>
</form>
</div>
</div>
</nav>
@ -85,13 +89,39 @@
<script lang="ts">
import { defineComponent } from 'vue';
import { logout, isLoggedIn } from '@/utils/authentication';
import { BIconSun } from 'bootstrap-icons-vue';
export default defineComponent({
components: {
BIconSun,
},
data() {
return {
isLogged: this.isLoggedIn(),
}
},
created() {
this.$emitter.on("logged-in", () => {
this.isLogged = this.isLoggedIn();
});
this.$emitter.on("logged-out", () => {
this.isLogged = this.isLoggedIn();
});
},
methods: {
isLoggedIn,
logout,
signin(e: Event) {
e.preventDefault();
this.$router.push('/login');
},
signout(e: Event) {
e.preventDefault();
this.logout();
this.$emitter.emit("logged-out");
this.$router.push('/');
},
onClick() {
this.$refs.navbarCollapse && (this.$refs.navbarCollapse as HTMLElement).classList.remove("show");
}

9
webapp/src/emitter.d.ts vendored Normal file
View File

@ -0,0 +1,9 @@
import mitt from 'mitt';
declare module '@vue/runtime-core' {
interface ComponentCustomProperties {
$emitter: Emitter;
}
}
export { } // Important! See note.

View File

@ -1,12 +1,16 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import mitt from 'mitt';
import "bootstrap/dist/css/bootstrap.min.css"
import './scss/styles.scss'
import "bootstrap"
const app = createApp(App)
const emitter = mitt();
app.config.globalProperties.$emitter = emitter;
app.use(router)
app.mount('#app')

View File

@ -15,6 +15,7 @@ import ConfigAdminView from '@/views/ConfigAdminView.vue'
import VedirectAdminView from '@/views/VedirectAdminView.vue'
import VedirectInfoView from '@/views/VedirectInfoView.vue'
import SecurityAdminView from '@/views/SecurityAdminView.vue'
import LoginView from '@/views/LoginView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@ -25,6 +26,11 @@ const router = createRouter({
name: 'Home',
component: HomeView
},
{
path: '/login',
name: 'Login',
component: LoginView
},
{
path: '/about',
name: 'About',
@ -101,6 +107,22 @@ const router = createRouter({
component: SecurityAdminView
}
]
})
});
router.beforeEach((to, from, next) => {
// redirect to login page if not logged in and trying to access a restricted page
const publicPages = ['/', '/login', '/about', '/info/network', '/info/system', '/info/ntp', '/info/mqtt', ];
const authRequired = !publicPages.includes(to.path);
const loggedIn = localStorage.getItem('user');
if (authRequired && !loggedIn) {
return next({
path: '/login',
query: { returnUrl: to.path }
});
}
next();
});
export default router;

View File

@ -0,0 +1,2 @@
// Import all of Bootstrap's CSS
@import "~bootstrap/scss/bootstrap";

View File

@ -1,10 +1,10 @@
export interface DevInfoStatus {
valid_data: boolean,
fw_bootloader_version: number,
fw_build_version: number,
fw_build_datetime: Date,
hw_part_number: number,
hw_version: number,
hw_model_name: string,
max_power: number,
valid_data: boolean;
fw_bootloader_version: number;
fw_build_version: number;
fw_build_datetime: Date;
hw_part_number: number;
hw_version: number;
hw_model_name: string;
max_power: number;
}

View File

@ -1,5 +1,5 @@
export interface DtuConfig {
dtu_serial: number,
dtu_pollinterval: number,
dtu_palevel: number
dtu_serial: number;
dtu_pollinterval: number;
dtu_palevel: number;
}

View File

@ -1,11 +1,11 @@
export interface EventlogItem {
message_id: number,
message: string,
start_time: number,
end_time: number
message_id: number;
message: string;
start_time: number;
end_time: number;
}
export interface EventlogItems {
count: number,
events: Array<EventlogItem>,
count: number;
events: Array<EventlogItem>;
}

View File

@ -1,5 +1,5 @@
export interface LimitConfig {
serial: number,
limit_value: number,
limit_type: number
serial: number;
limit_value: number;
limit_type: number;
}

View File

@ -1,5 +1,5 @@
export interface LimitStatus {
limit_relative: number,
max_power: number,
limit_set_status: string,
limit_relative: number;
max_power: number;
limit_set_status: string;
}

View File

@ -1,43 +1,43 @@
export interface ValueObject {
v: number, // value
u: string, // unit
d: number, // digits
v: number; // value
u: string; // unit
d: number; // digits
};
export interface InverterStatistics {
Power?: ValueObject,
Voltage?: ValueObject,
Current?: ValueObject,
"Power DC"?: ValueObject,
YieldDay?: ValueObject,
YieldTotal?: ValueObject,
Frequency?: ValueObject,
Temperature?: ValueObject,
PowerFactor?: ValueObject,
ReactivePower?: ValueObject,
Efficiency?: ValueObject,
Irradiation?: ValueObject,
Power?: ValueObject;
Voltage?: ValueObject;
Current?: ValueObject;
"Power DC"?: ValueObject;
YieldDay?: ValueObject;
YieldTotal?: ValueObject;
Frequency?: ValueObject;
Temperature?: ValueObject;
PowerFactor?: ValueObject;
ReactivePower?: ValueObject;
Efficiency?: ValueObject;
Irradiation?: ValueObject;
}
export interface Inverter {
serial: number,
name: string,
data_age: number,
reachable: boolean,
producing: boolean,
limit_relative: number,
limit_absolute: number,
events: number,
[key: number]: InverterStatistics,
serial: number;
name: string;
data_age: number;
reachable: boolean;
producing: boolean;
limit_relative: number;
limit_absolute: number;
events: number;
[key: number]: InverterStatistics;
};
export interface Total {
Power: ValueObject,
YieldDay: ValueObject,
YieldTotal: ValueObject,
Power: ValueObject;
YieldDay: ValueObject;
YieldTotal: ValueObject;
};
export interface LiveData {
inverters: Inverter[],
total: Total,
inverters: Inverter[];
total: Total;
}

View File

@ -1,20 +1,20 @@
export interface MqttConfig {
mqtt_enabled: boolean,
mqtt_hostname: string,
mqtt_port: number,
mqtt_username: string,
mqtt_password: string,
mqtt_topic: string,
mqtt_publish_interval: number,
mqtt_retain: boolean,
mqtt_tls: boolean,
mqtt_root_ca_cert: string,
mqtt_lwt_topic: string,
mqtt_lwt_online: string,
mqtt_lwt_offline: string,
mqtt_hass_enabled: boolean,
mqtt_hass_expire: boolean,
mqtt_hass_retain: boolean,
mqtt_hass_topic: string,
mqtt_hass_individualpanels: boolean
mqtt_enabled: boolean;
mqtt_hostname: string;
mqtt_port: number;
mqtt_username: string;
mqtt_password: string;
mqtt_topic: string;
mqtt_publish_interval: number;
mqtt_retain: boolean;
mqtt_tls: boolean;
mqtt_root_ca_cert: string;
mqtt_lwt_topic: string;
mqtt_lwt_online: string;
mqtt_lwt_offline: string;
mqtt_hass_enabled: boolean;
mqtt_hass_expire: boolean;
mqtt_hass_retain: boolean;
mqtt_hass_topic: string;
mqtt_hass_individualpanels: boolean;
}

View File

@ -1,17 +1,17 @@
export interface MqttStatus {
mqtt_enabled: boolean,
mqtt_hostname: string,
mqtt_port: number,
mqtt_username: string,
mqtt_topic: string,
mqtt_publish_interval: number,
mqtt_retain: boolean,
mqtt_tls: boolean,
mqtt_root_ca_cert_info: string,
mqtt_connected: boolean,
mqtt_hass_enabled: boolean,
mqtt_hass_expire: boolean,
mqtt_hass_retain: boolean,
mqtt_hass_topic: string,
mqtt_hass_individualpanels: boolean
mqtt_enabled: boolean;
mqtt_hostname: string;
mqtt_port: number;
mqtt_username: string;
mqtt_topic: string;
mqtt_publish_interval: number;
mqtt_retain: boolean;
mqtt_tls: boolean;
mqtt_root_ca_cert_info: string;
mqtt_connected: boolean;
mqtt_hass_enabled: boolean;
mqtt_hass_expire: boolean;
mqtt_hass_retain: boolean;
mqtt_hass_topic: string;
mqtt_hass_individualpanels: boolean;
}

View File

@ -1,22 +1,22 @@
export interface NetworkStatus {
// WifiStationInfo
sta_status: boolean,
sta_ssid: string,
sta_rssi: number,
sta_status: boolean;
sta_ssid: string;
sta_rssi: number;
// WifiApInfo
ap_status: boolean,
ap_ssid: string,
ap_stationnum: number,
ap_status: boolean;
ap_ssid: string;
ap_stationnum: number;
// InterfaceNetworkInfo
network_hostname: string,
network_ip: string,
network_netmask: string,
network_gateway: string,
network_dns1: string,
network_dns2: string,
network_mac: string,
network_mode: string,
network_hostname: string;
network_ip: string;
network_netmask: string;
network_gateway: string;
network_dns1: string;
network_dns2: string;
network_mac: string;
network_mode: string;
// InterfaceApInfo
ap_ip: string,
ap_mac: string,
ap_ip: string;
ap_mac: string;
}

View File

@ -1,11 +1,11 @@
export interface NetworkConfig {
ssid: string,
password: string,
hostname: string,
dhcp: boolean,
ipaddress: string,
netmask: string,
gateway: string,
dns1: string,
dns2: string
ssid: string;
password: string;
hostname: string;
dhcp: boolean;
ipaddress: string;
netmask: string;
gateway: string;
dns1: string;
dns2: string;
}

View File

@ -1,5 +1,5 @@
export interface NtpConfig {
ntp_server: string,
ntp_timezone: string,
ntp_timezone_descr: string
ntp_server: string;
ntp_timezone: string;
ntp_timezone_descr: string;
}

View File

@ -1,7 +1,7 @@
export interface NtpStatus {
ntp_server: string,
ntp_timezone: string,
ntp_server: string;
ntp_timezone: string;
ntp_timezone_descr: string
ntp_status: boolean,
ntp_localtime: string
ntp_status: boolean;
ntp_localtime: string;
}

View File

@ -1,3 +1,3 @@
export interface SecurityConfig {
password: string
password: string;
}

View File

@ -1,29 +1,29 @@
export interface SystemStatus {
// HardwareInfo
chipmodel: string,
chiprevision: number,
chipcores: number,
cpufreq: number,
chipmodel: string;
chiprevision: number;
chipcores: number;
cpufreq: number;
// FirmwareInfo
hostname: string,
sdkversion: string,
config_version: string,
git_hash: string,
resetreason_0: string,
resetreason_1: string,
cfgsavecount: number,
uptime: number,
update_text: string,
update_url: string,
update_status: string,
hostname: string;
sdkversion: string;
config_version: string;
git_hash: string;
resetreason_0: string;
resetreason_1: string;
cfgsavecount: number;
uptime: number;
update_text: string;
update_url: string;
update_status: string;
// MemoryInfo
heap_total: number,
heap_used: number,
littlefs_total: number,
littlefs_used: number,
sketch_total: number,
sketch_used: number,
heap_total: number;
heap_used: number;
littlefs_total: number;
littlefs_used: number;
sketch_total: number;
sketch_used: number;
// RadioInfo
radio_connected: boolean,
radio_pvariant: boolean,
radio_connected: boolean;
radio_pvariant: boolean;
}

View File

@ -1,5 +1,5 @@
export interface VedirectConfig {
vedirect_enabled: boolean,
vedirect_pollinterval: number,
vedirect_updatesonly: boolean
vedirect_enabled: boolean;
vedirect_pollinterval: number;
vedirect_updatesonly: boolean;
}

View File

@ -2,24 +2,24 @@ import type { ValueObject } from '@/types/LiveDataStatus';
// Ve.Direct
export interface Vedirect {
SER: string,
PID: string,
FW: string,
age_critical: boolean,
data_age: 0,
LOAD: ValueObject,
CS: ValueObject,
MPPT: ValueObject,
OR: ValueObject,
ERR: ValueObject,
HSDS: ValueObject,
V: ValueObject,
I: ValueObject,
VPV: ValueObject,
PPV: ValueObject,
H19: ValueObject,
H20: ValueObject,
H21: ValueObject,
H22: ValueObject,
H23: ValueObject,
SER: string;
PID: string;
FW: string;
age_critical: boolean;
data_age: 0;
LOAD: ValueObject;
CS: ValueObject;
MPPT: ValueObject;
OR: ValueObject;
ERR: ValueObject;
HSDS: ValueObject;
V: ValueObject;
I: ValueObject;
VPV: ValueObject;
PPV: ValueObject;
H19: ValueObject;
H20: ValueObject;
H21: ValueObject;
H22: ValueObject;
H23: ValueObject;
}

View File

@ -1,4 +1,4 @@
export interface VedirectStatus {
vedirect_enabled: boolean,
vedirect_updatesonly: boolean
vedirect_enabled: boolean;
vedirect_updatesonly: boolean;
}

View File

@ -0,0 +1,84 @@
import type { Emitter, EventType } from "mitt";
export function authHeader(): Headers {
// return authorization header with basic auth credentials
let user = JSON.parse(localStorage.getItem('user') || "");
if (user && user.authdata) {
const headers = new Headers();
headers.append('Authorization', 'Basic ' + user.authdata);
headers.append('X-Requested-With', 'XMLHttpRequest')
return new Headers(headers);
} else {
return new Headers();
}
}
export function logout() {
// remove user from local storage to log user out
localStorage.removeItem('user');
}
export function isLoggedIn(): boolean {
return (localStorage.getItem('user') != null);
}
export function login(username: String, password: String) {
const requestOptions = {
method: 'GET',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Authorization': 'Basic ' + btoa(unescape(encodeURIComponent(username + ':' + password))),
},
};
return fetch('/api/security/authenticate', requestOptions)
.then(handleAuthResponse)
.then(retVal => {
// login successful if there's a user in the response
if (retVal) {
// store user details and basic auth credentials in local storage
// to keep user logged in between page refreshes
retVal.authdata = btoa(unescape(encodeURIComponent(username + ':' + password)));
localStorage.setItem('user', JSON.stringify(retVal));
}
return retVal;
});
}
export function handleResponse(response: Response, emitter: Emitter<Record<EventType, unknown>>) {
return response.text().then(text => {
const data = text && JSON.parse(text);
if (!response.ok) {
if (response.status === 401) {
// auto logout if 401 response returned from api
logout();
emitter.emit("logged-out");
location.reload();
}
const error = (data && data.message) || response.statusText;
return Promise.reject(error);
}
return data;
});
}
function handleAuthResponse(response: Response) {
return response.text().then(text => {
const data = text && JSON.parse(text);
if (!response.ok) {
if (response.status === 401) {
// auto logout if 401 response returned from api
logout();
}
const error = "Invalid credentials";
return Promise.reject(error);
}
return data;
});
}

View File

@ -1,12 +1,19 @@
import { timestampToString } from './time';
import { formatNumber } from './number';
import { login, logout, isLoggedIn } from './authentication';
export {
timestampToString,
formatNumber,
login,
logout,
isLoggedIn,
};
export default {
timestampToString,
formatNumber,
login,
logout,
isLoggedIn,
}

View File

@ -112,6 +112,7 @@ import {
} from 'bootstrap-icons-vue';
import * as bootstrap from 'bootstrap';
import BootstrapAlert from "@/components/BootstrapAlert.vue";
import { handleResponse, authHeader } from '@/utils/authentication';
export default defineComponent({
components: {
@ -152,15 +153,10 @@ export default defineComponent({
fetch("/api/config/delete", {
method: "POST",
headers: authHeader(),
body: formData,
})
.then(function (response) {
if (response.status != 200) {
throw response.status;
} else {
return response.json();
}
})
.then((response) => handleResponse(response, this.$emitter))
.then(
(response) => {
this.alertMessage = response.message;
@ -171,10 +167,17 @@ export default defineComponent({
this.modalFactoryReset.hide();
},
downloadConfig() {
const link = document.createElement('a')
link.href = "/api/config/get"
link.download = 'config.json'
link.click()
fetch("/api/config/get", { headers: authHeader() })
.then(res => res.blob())
.then(blob => {
var file = window.URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = file;
a.download = "config.json";
document.body.appendChild(a);
a.click();
a.remove();
});
},
uploadConfig(event: Event | null) {
this.uploading = true;
@ -206,6 +209,9 @@ export default defineComponent({
formData.append("config", this.file, "config");
request.open("post", "/api/config/upload");
authHeader().forEach((value, key) => {
request.setRequestHeader(key, value);
});
request.send(formData);
},
clear() {

View File

@ -49,6 +49,7 @@
import { defineComponent } from 'vue';
import BasePage from '@/components/BasePage.vue';
import BootstrapAlert from "@/components/BootstrapAlert.vue";
import { handleResponse, authHeader } from '@/utils/authentication';
import type { DtuConfig } from "@/types/DtuConfig";
export default defineComponent({
@ -77,8 +78,8 @@ export default defineComponent({
methods: {
getDtuConfig() {
this.dataLoading = true;
fetch("/api/dtu/config")
.then((response) => response.json())
fetch("/api/dtu/config", { headers: authHeader() })
.then((response) => handleResponse(response, this.$emitter))
.then(
(data) => {
this.dtuConfigList = data;
@ -94,15 +95,10 @@ export default defineComponent({
fetch("/api/dtu/config", {
method: "POST",
headers: authHeader(),
body: formData,
})
.then(function (response) {
if (response.status != 200) {
throw response.status;
} else {
return response.json();
}
})
.then((response) => handleResponse(response, this.$emitter))
.then(
(response) => {
this.alertMessage = response.message;

View File

@ -77,6 +77,7 @@ import {
BIconArrowRepeat,
BIconCheckCircle
} from 'bootstrap-icons-vue';
import { authHeader } from '@/utils/authentication';
export default defineComponent({
components: {
@ -160,6 +161,9 @@ export default defineComponent({
formData.append("MD5", (md5 as string));
formData.append("firmware", this.file, "firmware");
request.open("post", "/api/firmware/update");
authHeader().forEach((value, key) => {
request.setRequestHeader(key, value);
});
request.send(formData);
})
.catch(() => {

View File

@ -50,7 +50,7 @@
</div>
<div class="btn-toolbar p-2" role="toolbar">
<div class="btn-group me-2" role="group">
<button type="button" class="btn btn-sm btn-danger"
<button :disabled="!isLogged" type="button" class="btn btn-sm btn-danger"
@click="onShowLimitSettings(inverter.serial)" title="Show / Set Inverter Limit">
<BIconSpeedometer style="font-size:24px;" />
@ -58,7 +58,7 @@
</div>
<div class="btn-group me-2" role="group">
<button type="button" class="btn btn-sm btn-danger"
<button :disabled="!isLogged" type="button" class="btn btn-sm btn-danger"
@click="onShowPowerSettings(inverter.serial)" title="Turn Inverter on/off">
<BIconPower style="font-size:24px;" />
@ -340,6 +340,7 @@ import type { EventlogItems } from '@/types/EventlogStatus';
import type { LiveData, Inverter } from '@/types/LiveDataStatus';
import type { LimitStatus } from '@/types/LimitStatus';
import type { LimitConfig } from '@/types/LimitConfig';
import { isLoggedIn, handleResponse, authHeader } from '@/utils/authentication';
import { formatNumber } from '@/utils';
export default defineComponent({
@ -364,6 +365,8 @@ export default defineComponent({
},
data() {
return {
isLogged: this.isLoggedIn(),
socket: {} as WebSocket,
heartInterval: 0,
dataAgeInterval: 0,
@ -406,6 +409,12 @@ export default defineComponent({
this.getInitialData();
this.initSocket();
this.initDataAgeing();
this.$emitter.on("logged-in", () => {
this.isLogged = this.isLoggedIn();
});
this.$emitter.on("logged-out", () => {
this.isLogged = this.isLoggedIn();
});
},
mounted() {
this.eventLogView = new bootstrap.Modal('#eventView');
@ -449,6 +458,7 @@ export default defineComponent({
},
methods: {
formatNumber,
isLoggedIn,
getInitialData() {
this.dataLoading = true;
fetch("/api/livedata/status")
@ -568,15 +578,10 @@ export default defineComponent({
fetch("/api/limit/config", {
method: "POST",
headers: authHeader(),
body: formData,
})
.then(function (response) {
if (response.status != 200) {
throw response.status;
} else {
return response.json();
}
})
.then((response) => handleResponse(response, this.$emitter))
.then(
(response) => {
if (response.type == "success") {
@ -643,15 +648,10 @@ export default defineComponent({
fetch("/api/power/config", {
method: "POST",
headers: authHeader(),
body: formData,
})
.then(function (response) {
if (response.status != 200) {
throw response.status;
} else {
return response.json();
}
})
.then((response) => handleResponse(response, this.$emitter))
.then(
(response) => {
if (response.type == "success") {

View File

@ -1,7 +1,7 @@
<template>
<BasePage :title="'Inverter Settings'" :isLoading="dataLoading">
<BootstrapAlert v-model="showAlert" dismissible :variant="alertType">
{{ alertMessage }}
<BootstrapAlert v-model="alert.show" dismissible :variant="alert.type">
{{ alert.message }}
</BootstrapAlert>
<div class="card">
@ -10,12 +10,12 @@
<form class="form-inline" v-on:submit.prevent="onSubmit">
<div class="form-group">
<label>Serial</label>
<input v-model="inverterData.serial" type="number" class="form-control ml-sm-2 mr-sm-4 my-2"
<input v-model="newInverterData.serial" type="number" class="form-control ml-sm-2 mr-sm-4 my-2"
required />
</div>
<div class="form-group">
<label>Name</label>
<input v-model="inverterData.name" type="text" class="form-control ml-sm-2 mr-sm-4 my-2"
<input v-model="newInverterData.name" type="text" class="form-control ml-sm-2 mr-sm-4 my-2"
maxlength="31" required />
</div>
<div class="ml-auto text-right">
@ -44,22 +44,15 @@
</thead>
<tbody>
<tr v-for="inverter in sortedInverters" v-bind:key="inverter.id">
<td>
{{ inverter.serial }}
</td>
<td>
{{ inverter.name }}
</td>
<td>
{{ inverter.type }}
</td>
<td>{{ inverter.serial }}</td>
<td>{{ inverter.name }}</td>
<td>{{ inverter.type }}</td>
<td>
<a href="#" class="icon text-danger" title="Delete inverter">
<BIconTrash v-on:click="onDeleteModal(inverter)" />
<BIconTrash v-on:click="onOpenModal(modalDelete, inverter)" />
</a>&nbsp;
<a href="#" class="icon" title="Edit inverter">
<BIconPencil v-on:click="onEdit(inverter)" />
<BIconPencil v-on:click="onOpenModal(modal, inverter)" />
</a>
</td>
</tr>
@ -78,39 +71,36 @@
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form>
<div class="mb-3">
<label for="inverter-serial" class="col-form-label">Serial:</label>
<input v-model="editInverterData.serial" type="number" id="inverter-serial"
class="form-control" />
</div>
<div class="mb-3">
<input v-model="selectedInverterData.serial" type="number" id="inverter-serial"
class="form-control" />
<label for="inverter-name" class="col-form-label">Name:</label>
<input v-model="editInverterData.name" type="text" id="inverter-name" class="form-control"
maxlength="31" />
<input v-model="selectedInverterData.name" type="text" id="inverter-name"
class="form-control" maxlength="31" />
</div>
<div class="mb-3" v-for="(max, index) in editInverterData.max_power" :key="`${index}`">
<label :for="`inverter-max_${index}`" class="col-form-label">Max power string {{ index +
1
}}:</label>
<div class="input-group">
<input type="number" class="form-control" :id="`inverter-max_${index}`" min="0"
v-model="editInverterData.max_power[index]"
:aria-describedby="`inverter-maxDescription_${index} inverter-maxHelpText_${index}`" />
<span class="input-group-text" :id="`inverter-maxDescription_${index}`">W</span>
<div v-for="(max, index) in selectedInverterData.max_power" :key="`${index}`">
<label :for="`inverter-max_${index}`" class="col-form-label">Max power string {{ index +1 }}:</label>
<div class="d-flex mb-2">
<div class="input-group">
<input type="number" class="form-control" :id="`inverter-max_${index}`" min="0"
v-model="selectedInverterData.max_power[index]"
:aria-describedby="`inverter-maxDescription_${index} inverter-customizer`" />
<span class="input-group-text" :id="`inverter-maxDescription_${index}`">W<sup>*</sup></span>
</div>
</div>
<div :id="`inverter-maxHelpText_${index}`" class="form-text">This value is used to
calculate the Irradiation.</div>
</div>
<div :id="`inverter-customizer`" class="form-text">*) Input the kWp of the channel to
calculate irradiation.</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="onCancel"
<button type="button" class="btn btn-secondary" @click="onCloseModal(modal)"
data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" @click="onEditSubmit(editId)">Save
<button type="button" class="btn btn-primary" @click="onEditSubmit">Save
changes</button>
</div>
</div>
@ -125,13 +115,13 @@
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
Are you sure you want to delete the inverter "{{ deleteInverterData.name }}" with serial number
{{ deleteInverterData.serial }}?
Are you sure you want to delete the inverter "{{ selectedInverterData.name }}" with serial number
{{ selectedInverterData.serial }}?
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="onDeleteCancel"
<button type="button" class="btn btn-secondary" @click="onCloseModal(modalDelete)"
data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" @click="onDelete(deleteId.toString())">Delete</button>
<button type="button" class="btn btn-danger" @click="onDelete">Delete</button>
</div>
</div>
</div>
@ -147,13 +137,20 @@ import {
} from 'bootstrap-icons-vue';
import * as bootstrap from 'bootstrap';
import BootstrapAlert from "@/components/BootstrapAlert.vue";
import { handleResponse, authHeader } from '@/utils/authentication';
declare interface Inverter {
id: string,
serial: number,
name: string,
type: string
max_power: number[]
id: string;
serial: number;
name: string;
type: string;
max_power: number[];
}
declare interface AlertResponse {
message: string;
type: string;
show: boolean;
}
export default defineComponent({
@ -167,16 +164,11 @@ export default defineComponent({
return {
modal: {} as bootstrap.Modal,
modalDelete: {} as bootstrap.Modal,
deleteId: -1,
editId: "-1",
inverterData: {} as Inverter,
editInverterData: {} as Inverter,
deleteInverterData: {} as Inverter,
newInverterData: {} as Inverter,
selectedInverterData: {} as Inverter,
inverters: [] as Inverter[],
dataLoading: true,
alertMessage: "",
alertType: "info",
showAlert: false,
alert: {} as AlertResponse
};
},
mounted() {
@ -196,130 +188,49 @@ export default defineComponent({
methods: {
getInverters() {
this.dataLoading = true;
fetch("/api/inverter/list")
.then((response) => response.json())
fetch("/api/inverter/list", { headers: authHeader() })
.then((response) => handleResponse(response, this.$emitter))
.then((data) => {
this.inverters = data.inverter;
this.dataLoading = false;
});
},
callInverterApiEndpoint(endpoint: string, jsonData: string) {
const formData = new FormData();
formData.append("data", jsonData);
fetch("/api/inverter/" + endpoint, {
method: "POST",
headers: authHeader(),
body: formData,
})
.then((response) => handleResponse(response, this.$emitter))
.then((data) => {
this.getInverters();
this.alert = data;
this.alert.show = true;
});
},
onSubmit() {
const formData = new FormData();
formData.append("data", JSON.stringify(this.inverterData));
fetch("/api/inverter/add", {
method: "POST",
body: formData,
})
.then(function (response) {
if (response.status != 200) {
throw response.status;
} else {
return response.json();
}
})
.then(
(response) => {
this.alertMessage = response.message;
this.alertType = response.type;
this.showAlert = true;
}
)
.then(() => { this.getInverters() });
this.inverterData.serial = 0;
this.inverterData.name = "";
this.callInverterApiEndpoint("add", JSON.stringify(this.newInverterData));
this.newInverterData = {} as Inverter;
},
onDeleteModal(inverter: Inverter) {
this.modalDelete.show();
this.deleteInverterData.serial = inverter.serial;
this.deleteInverterData.name = inverter.name;
this.deleteInverterData.type = inverter.type;
this.deleteId = +inverter.id;
onDelete() {
this.callInverterApiEndpoint("del", JSON.stringify({ id: this.selectedInverterData.id }));
this.onCloseModal(this.modalDelete);
},
onDeleteCancel() {
this.deleteId = -1;
this.deleteInverterData.serial = 0;
this.deleteInverterData.name = "";
this.deleteInverterData.max_power = [];
this.modalDelete.hide();
onEditSubmit() {
this.callInverterApiEndpoint("edit", JSON.stringify(this.selectedInverterData));
this.onCloseModal(this.modal);
},
onDelete(id: string) {
const formData = new FormData();
formData.append("data", JSON.stringify({ id: id }));
fetch("/api/inverter/del", {
method: "POST",
body: formData,
})
.then(function (response) {
if (response.status != 200) {
throw response.status;
} else {
return response.json();
}
})
.then(
(response) => {
this.alertMessage = response.message;
this.alertType = response.type;
this.showAlert = true;
}
)
.then(() => { this.getInverters() });
this.deleteId = -1;
this.deleteInverterData.serial = 0;
this.deleteInverterData.name = "";
this.deleteInverterData.max_power = [];
this.modalDelete.hide();
},
onEdit(inverter: Inverter) {
this.modal.show();
this.editId = inverter.id;
this.editInverterData.serial = inverter.serial;
this.editInverterData.name = inverter.name;
this.editInverterData.type = inverter.type;
this.editInverterData.max_power = inverter.max_power;
},
onCancel() {
this.editId = "-1";
this.editInverterData.serial = 0;
this.editInverterData.name = "";
this.editInverterData.max_power = [];
this.modal.hide();
},
onEditSubmit(id: string) {
const formData = new FormData();
this.editInverterData.id = id;
formData.append("data", JSON.stringify(this.editInverterData));
fetch("/api/inverter/edit", {
method: "POST",
body: formData,
})
.then(function (response) {
if (response.status != 200) {
throw response.status;
} else {
return response.json();
}
})
.then(
(response) => {
this.alertMessage = response.message;
this.alertType = response.type;
this.showAlert = true;
}
)
.then(() => { this.getInverters() });
this.editId = "-1";
this.editInverterData.serial = 0;
this.editInverterData.name = "";
this.editInverterData.type = "";
this.editInverterData.max_power = [];
this.modal.hide();
onOpenModal(modal: bootstrap.Modal, inverter: Inverter) {
// deep copy inverter object for editing/deleting
this.selectedInverterData = JSON.parse(JSON.stringify(inverter)) as Inverter;
modal.show();
},
onCloseModal(modal: bootstrap.Modal) {
modal.hide();
}
},
});
</script>

View File

@ -0,0 +1,89 @@
<template>
<BasePage :title="'Login'" :isLoading="dataLoading">
<BootstrapAlert v-model="showAlert" dismissible :variant="alertType">
{{ alertMessage }}
</BootstrapAlert>
<div class="card">
<div class="card-header text-bg-danger">System Login</div>
<div class="card-body">
<form @submit.prevent="handleSubmit">
<div class="form-group">
<label for="username">Username</label>
<input type="text" v-model="username" name="username" class="form-control"
:class="{ 'is-invalid': submitted && !username }" />
<div v-show="submitted && !username" class="invalid-feedback">Username is required</div>
</div>
<div class="form-group">
<label htmlFor="password">Password</label>
<input type="password" v-model="password" name="password" class="form-control"
:class="{ 'is-invalid': submitted && !password }" />
<div v-show="submitted && !password" class="invalid-feedback">Password is required</div>
</div>
<div class="form-group">
<button class="btn btn-primary" :disabled="dataLoading">Login</button>
</div>
</form>
</div>
</div>
</BasePage>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import router from '@/router';
import { login } from '@/utils';
import BasePage from '@/components/BasePage.vue';
import BootstrapAlert from "@/components/BootstrapAlert.vue";
export default defineComponent({
components: {
BasePage,
BootstrapAlert,
},
data() {
return {
dataLoading: false,
alertMessage: "",
alertType: "info",
showAlert: false,
returnUrl: '',
username: '',
password: '',
submitted: false,
};
},
created() {
// get return url from route parameters or default to '/'
this.returnUrl = this.$route.query.returnUrl?.toString() || '/';
},
methods: {
handleSubmit(e: Event) {
this.submitted = true;
const { username, password } = this;
// stop here if form is invalid
if (!(username && password)) {
return;
}
this.dataLoading = true;
login(username, password)
.then(
() => {
this.$emitter.emit("logged-in");
router.push(this.returnUrl);
},
error => {
this.$emitter.emit("logged-out");
this.alertMessage = error;
this.alertType = 'danger';
this.showAlert = true;
this.dataLoading = false;
}
)
}
}
});
</script>

View File

@ -218,6 +218,7 @@
import { defineComponent } from 'vue';
import BasePage from '@/components/BasePage.vue';
import BootstrapAlert from "@/components/BootstrapAlert.vue";
import { handleResponse, authHeader } from '@/utils/authentication';
import type { MqttConfig } from "@/types/MqttConfig";
export default defineComponent({
@ -240,8 +241,8 @@ export default defineComponent({
methods: {
getMqttConfig() {
this.dataLoading = true;
fetch("/api/mqtt/config")
.then((response) => response.json())
fetch("/api/mqtt/config", { headers: authHeader() })
.then((response) => handleResponse(response, this.$emitter))
.then((data) => {
this.mqttConfigList = data;
this.dataLoading = false;
@ -255,15 +256,10 @@ export default defineComponent({
fetch("/api/mqtt/config", {
method: "POST",
headers: authHeader(),
body: formData,
})
.then(function (response) {
if (response.status != 200) {
throw response.status;
} else {
return response.json();
}
})
.then((response) => handleResponse(response, this.$emitter))
.then(
(response) => {
this.alertMessage = response.message;

View File

@ -104,6 +104,7 @@
import { defineComponent } from 'vue';
import BasePage from '@/components/BasePage.vue';
import BootstrapAlert from "@/components/BootstrapAlert.vue";
import { handleResponse, authHeader } from '@/utils/authentication';
import type { NetworkConfig } from "@/types/NetworkkConfig";
export default defineComponent({
@ -126,8 +127,8 @@ export default defineComponent({
methods: {
getNetworkConfig() {
this.dataLoading = true;
fetch("/api/network/config")
.then((response) => response.json())
fetch("/api/network/config", { headers: authHeader() })
.then((response) => handleResponse(response, this.$emitter))
.then((data) => {
this.networkConfigList = data;
this.dataLoading = false;
@ -141,15 +142,10 @@ export default defineComponent({
fetch("/api/network/config", {
method: "POST",
headers: authHeader(),
body: formData,
})
.then(function (response) {
if (response.status != 200) {
throw response.status;
} else {
return response.json();
}
})
.then((response) => handleResponse(response, this.$emitter))
.then(
(response) => {
this.alertMessage = response.message;

View File

@ -75,6 +75,7 @@
import { defineComponent } from 'vue';
import BasePage from '@/components/BasePage.vue';
import BootstrapAlert from "@/components/BootstrapAlert.vue";
import { handleResponse, authHeader } from '@/utils/authentication';
import type { NtpConfig } from "@/types/NtpConfig";
export default defineComponent({
@ -127,8 +128,8 @@ export default defineComponent({
},
getNtpConfig() {
this.dataLoading = true;
fetch("/api/ntp/config")
.then((response) => response.json())
fetch("/api/ntp/config", { headers: authHeader() })
.then((response) => handleResponse(response, this.$emitter))
.then(
(data) => {
this.ntpConfigList = data;
@ -142,8 +143,8 @@ export default defineComponent({
},
getCurrentTime() {
this.dataLoading = true;
fetch("/api/ntp/time")
.then((response) => response.json())
fetch("/api/ntp/time", { headers: authHeader() })
.then((response) => handleResponse(response, this.$emitter))
.then(
(data) => {
this.mcuTime = new Date(
@ -168,15 +169,10 @@ export default defineComponent({
fetch("/api/ntp/time", {
method: "POST",
headers: authHeader(),
body: formData,
})
.then(function (response) {
if (response.status != 200) {
throw response.status;
} else {
return response.json();
}
})
.then((response) => handleResponse(response, this.$emitter))
.then(
(response) => {
this.alertMessage = response.message;
@ -196,15 +192,10 @@ export default defineComponent({
fetch("/api/ntp/config", {
method: "POST",
headers: authHeader(),
body: formData,
})
.then(function (response) {
if (response.status != 200) {
throw response.status;
} else {
return response.json();
}
})
.then((response) => handleResponse(response, this.$emitter))
.then(
(response) => {
this.alertMessage = response.message;

View File

@ -26,8 +26,8 @@
<div class="alert alert-secondary" role="alert">
<b>Hint:</b>
The administrator password is used to connect to the device when in AP mode.
It must be 8..64 characters.
The administrator password is used to access this web interface (user 'admin'), but also to
connect to the device when in AP mode. It must be 8..64 characters.
</div>
</div>
@ -41,6 +41,7 @@
import { defineComponent } from 'vue';
import BasePage from '@/components/BasePage.vue';
import BootstrapAlert from "@/components/BootstrapAlert.vue";
import { handleResponse, authHeader } from '@/utils/authentication';
import type { SecurityConfig } from '@/types/SecurityConfig';
export default defineComponent({
@ -65,8 +66,8 @@ export default defineComponent({
methods: {
getPasswordConfig() {
this.dataLoading = true;
fetch("/api/security/password")
.then((response) => response.json())
fetch("/api/security/password", { headers: authHeader() })
.then((response) => handleResponse(response, this.$emitter))
.then(
(data) => {
this.securityConfigList = data;
@ -90,15 +91,10 @@ export default defineComponent({
fetch("/api/security/password", {
method: "POST",
headers: authHeader(),
body: formData,
})
.then(function (response) {
if (response.status != 200) {
throw response.status;
} else {
return response.json();
}
})
.then((response) => handleResponse(response, this.$emitter))
.then(
(response) => {
this.alertMessage = response.message;

View File

@ -51,6 +51,7 @@
import { defineComponent } from 'vue';
import BasePage from '@/components/BasePage.vue';
import BootstrapAlert from "@/components/BootstrapAlert.vue";
import { handleResponse, authHeader } from '@/utils/authentication';
import type { VedirectConfig } from "@/types/VedirectConfig";
export default defineComponent({
@ -73,8 +74,8 @@ export default defineComponent({
methods: {
getVedirectConfig() {
this.dataLoading = true;
fetch("/api/vedirect/config")
.then((response) => response.json())
fetch("api/vedirect/config", { headers: authHeader() })
.then((response) => handleResponse(response, this.$emitter))
.then((data) => {
this.vedirectConfigList = data;
this.dataLoading = false;
@ -88,15 +89,10 @@ export default defineComponent({
fetch("/api/vedirect/config", {
method: "POST",
headers: authHeader(),
body: formData,
})
.then(function (response) {
if (response.status != 200) {
throw response.status;
} else {
return response.json();
}
})
.then((response) => handleResponse(response, this.$emitter))
.then(
(response) => {
this.alertMessage = response.message;

View File

@ -6,6 +6,8 @@ import vue from '@vitejs/plugin-vue'
import viteCompression from 'vite-plugin-compression';
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'
const path = require('path')
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
@ -14,7 +16,8 @@ export default defineConfig({
cssInjectedByJsPlugin()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
'@': fileURLToPath(new URL('./src', import.meta.url)),
'~bootstrap': path.resolve(__dirname, 'node_modules/bootstrap'),
}
},
build: {

View File

@ -87,10 +87,10 @@
resolved "https://registry.yarnpkg.com/@rushstack/eslint-patch/-/eslint-patch-1.2.0.tgz#8be36a1f66f3265389e90b5f9c9962146758f728"
integrity sha512-sXo/qW2/pAcmT43VoRKOJbDOfV3cYpq3szSVfIThQXNt+E4DfKj361vaAt3c88U5tPUxzEswam7GW48PJqtKAg==
"@types/bootstrap@^5.2.5":
version "5.2.5"
resolved "https://registry.yarnpkg.com/@types/bootstrap/-/bootstrap-5.2.5.tgz#0bb5dea7720611b2bb7ba16bd8a64fafd86fb658"
integrity sha512-VnalUJ3E/oaV3DYrauEc/sSPpaEPxTV09twSEzY4KFRvyuGlrZUSqG95XZ6ReAi0YMZIs7rXxdngDK2X1YONQA==
"@types/bootstrap@^5.2.6":
version "5.2.6"
resolved "https://registry.yarnpkg.com/@types/bootstrap/-/bootstrap-5.2.6.tgz#e861b3aa1f4a1434da0bf50fbaa372b6f7e64d2f"
integrity sha512-BlAc3YATdasbHoxMoBWODrSF6qwQO/E9X8wVxCCSa6rWjnaZfpkr2N6pUMCY6jj2+wf0muUtLySbvU9etX6YqA==
dependencies:
"@popperjs/core" "^2.9.2"
@ -99,10 +99,10 @@
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.11.tgz#d421b6c527a3037f7c84433fd2c4229e016863d3"
integrity sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==
"@types/node@^18.11.8":
version "18.11.8"
resolved "https://registry.yarnpkg.com/@types/node/-/node-18.11.8.tgz#16d222a58d4363a2a359656dd20b28414de5d265"
integrity sha512-uGwPWlE0Hj972KkHtCDVwZ8O39GmyjfMane1Z3GUBGGnkZ2USDq7SxLpVIiIHpweY9DS0QTDH0Nw7RNBsAAZ5A==
"@types/node@^18.11.9":
version "18.11.9"
resolved "https://registry.yarnpkg.com/@types/node/-/node-18.11.9.tgz#02d013de7058cea16d36168ef2fc653464cfbad4"
integrity sha512-CRpX21/kGdzjOpFsZSkcrXMGIBWMGNIHXXBVFSH+ggkftxg+XYP20TESbh+zFvFj3EQOl5byk0HTRn1IL6hbqg==
"@types/spark-md5@^3.0.2":
version "3.0.2"
@ -283,6 +283,16 @@
estree-walker "^2.0.2"
source-map "^0.6.1"
"@vue/compiler-core@3.2.45":
version "3.2.45"
resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.2.45.tgz#d9311207d96f6ebd5f4660be129fb99f01ddb41b"
integrity sha512-rcMj7H+PYe5wBV3iYeUgbCglC+pbpN8hBLTJvRiK2eKQiWqu+fG9F+8sW99JdL4LQi7Re178UOxn09puSXvn4A==
dependencies:
"@babel/parser" "^7.16.4"
"@vue/shared" "3.2.45"
estree-walker "^2.0.2"
source-map "^0.6.1"
"@vue/compiler-dom@3.2.41", "@vue/compiler-dom@^3.2.40":
version "3.2.41"
resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.2.41.tgz#dc63dcd3ce8ca8a8721f14009d498a7a54380299"
@ -291,7 +301,31 @@
"@vue/compiler-core" "3.2.41"
"@vue/shared" "3.2.41"
"@vue/compiler-sfc@3.2.41", "@vue/compiler-sfc@^3.2.40":
"@vue/compiler-dom@3.2.45":
version "3.2.45"
resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.2.45.tgz#c43cc15e50da62ecc16a42f2622d25dc5fd97dce"
integrity sha512-tyYeUEuKqqZO137WrZkpwfPCdiiIeXYCcJ8L4gWz9vqaxzIQRccTSwSWZ/Axx5YR2z+LvpUbmPNXxuBU45lyRw==
dependencies:
"@vue/compiler-core" "3.2.45"
"@vue/shared" "3.2.45"
"@vue/compiler-sfc@3.2.45":
version "3.2.45"
resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-3.2.45.tgz#7f7989cc04ec9e7c55acd406827a2c4e96872c70"
integrity sha512-1jXDuWah1ggsnSAOGsec8cFjT/K6TMZ0sPL3o3d84Ft2AYZi2jWJgRMjw4iaK0rBfA89L5gw427H4n1RZQBu6Q==
dependencies:
"@babel/parser" "^7.16.4"
"@vue/compiler-core" "3.2.45"
"@vue/compiler-dom" "3.2.45"
"@vue/compiler-ssr" "3.2.45"
"@vue/reactivity-transform" "3.2.45"
"@vue/shared" "3.2.45"
estree-walker "^2.0.2"
magic-string "^0.25.7"
postcss "^8.1.10"
source-map "^0.6.1"
"@vue/compiler-sfc@^3.2.40":
version "3.2.41"
resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-3.2.41.tgz#238fb8c48318408c856748f4116aff8cc1dc2a73"
integrity sha512-+1P2m5kxOeaxVmJNXnBskAn3BenbTmbxBxWOtBq3mQTCokIreuMULFantBUclP0+KnzNCMOvcnKinqQZmiOF8w==
@ -315,6 +349,14 @@
"@vue/compiler-dom" "3.2.41"
"@vue/shared" "3.2.41"
"@vue/compiler-ssr@3.2.45":
version "3.2.45"
resolved "https://registry.yarnpkg.com/@vue/compiler-ssr/-/compiler-ssr-3.2.45.tgz#bd20604b6e64ea15344d5b6278c4141191c983b2"
integrity sha512-6BRaggEGqhWht3lt24CrIbQSRD5O07MTmd+LjAn5fJj568+R9eUD2F7wMQJjX859seSlrYog7sUtrZSd7feqrQ==
dependencies:
"@vue/compiler-dom" "3.2.45"
"@vue/shared" "3.2.45"
"@vue/devtools-api@^6.4.5":
version "6.4.5"
resolved "https://registry.yarnpkg.com/@vue/devtools-api/-/devtools-api-6.4.5.tgz#d54e844c1adbb1e677c81c665ecef1a2b4bb8380"
@ -340,43 +382,66 @@
estree-walker "^2.0.2"
magic-string "^0.25.7"
"@vue/reactivity@3.2.41", "@vue/reactivity@^3.2.40":
"@vue/reactivity-transform@3.2.45":
version "3.2.45"
resolved "https://registry.yarnpkg.com/@vue/reactivity-transform/-/reactivity-transform-3.2.45.tgz#07ac83b8138550c83dfb50db43cde1e0e5e8124d"
integrity sha512-BHVmzYAvM7vcU5WmuYqXpwaBHjsS8T63jlKGWVtHxAHIoMIlmaMyurUSEs1Zcg46M4AYT5MtB1U274/2aNzjJQ==
dependencies:
"@babel/parser" "^7.16.4"
"@vue/compiler-core" "3.2.45"
"@vue/shared" "3.2.45"
estree-walker "^2.0.2"
magic-string "^0.25.7"
"@vue/reactivity@3.2.45":
version "3.2.45"
resolved "https://registry.yarnpkg.com/@vue/reactivity/-/reactivity-3.2.45.tgz#412a45b574de601be5a4a5d9a8cbd4dee4662ff0"
integrity sha512-PRvhCcQcyEVohW0P8iQ7HDcIOXRjZfAsOds3N99X/Dzewy8TVhTCT4uXpAHfoKjVTJRA0O0K+6QNkDIZAxNi3A==
dependencies:
"@vue/shared" "3.2.45"
"@vue/reactivity@^3.2.40":
version "3.2.41"
resolved "https://registry.yarnpkg.com/@vue/reactivity/-/reactivity-3.2.41.tgz#0ad3bdf76d76822da1502dc9f394dafd02642963"
integrity sha512-9JvCnlj8uc5xRiQGZ28MKGjuCoPhhTwcoAdv3o31+cfGgonwdPNuvqAXLhlzu4zwqavFEG5tvaoINQEfxz+l6g==
dependencies:
"@vue/shared" "3.2.41"
"@vue/runtime-core@3.2.41":
version "3.2.41"
resolved "https://registry.yarnpkg.com/@vue/runtime-core/-/runtime-core-3.2.41.tgz#775bfc00b3fadbaddab77138f23322aee3517a76"
integrity sha512-0LBBRwqnI0p4FgIkO9q2aJBBTKDSjzhnxrxHYengkAF6dMOjeAIZFDADAlcf2h3GDALWnblbeprYYpItiulSVQ==
"@vue/runtime-core@3.2.45":
version "3.2.45"
resolved "https://registry.yarnpkg.com/@vue/runtime-core/-/runtime-core-3.2.45.tgz#7ad7ef9b2519d41062a30c6fa001ec43ac549c7f"
integrity sha512-gzJiTA3f74cgARptqzYswmoQx0fIA+gGYBfokYVhF8YSXjWTUA2SngRzZRku2HbGbjzB6LBYSbKGIaK8IW+s0A==
dependencies:
"@vue/reactivity" "3.2.41"
"@vue/shared" "3.2.41"
"@vue/reactivity" "3.2.45"
"@vue/shared" "3.2.45"
"@vue/runtime-dom@3.2.41":
version "3.2.41"
resolved "https://registry.yarnpkg.com/@vue/runtime-dom/-/runtime-dom-3.2.41.tgz#cdf86be7410f7b15c29632a96ce879e5b4c9ab92"
integrity sha512-U7zYuR1NVIP8BL6jmOqmapRAHovEFp7CSw4pR2FacqewXNGqZaRfHoNLQsqQvVQ8yuZNZtxSZy0FFyC70YXPpA==
"@vue/runtime-dom@3.2.45":
version "3.2.45"
resolved "https://registry.yarnpkg.com/@vue/runtime-dom/-/runtime-dom-3.2.45.tgz#1a2ef6ee2ad876206fbbe2a884554bba2d0faf59"
integrity sha512-cy88YpfP5Ue2bDBbj75Cb4bIEZUMM/mAkDMfqDTpUYVgTf/kuQ2VQ8LebuZ8k6EudgH8pYhsGWHlY0lcxlvTwA==
dependencies:
"@vue/runtime-core" "3.2.41"
"@vue/shared" "3.2.41"
"@vue/runtime-core" "3.2.45"
"@vue/shared" "3.2.45"
csstype "^2.6.8"
"@vue/server-renderer@3.2.41":
version "3.2.41"
resolved "https://registry.yarnpkg.com/@vue/server-renderer/-/server-renderer-3.2.41.tgz#ca64552c05878f94e8d191ac439141c06c0fb2ad"
integrity sha512-7YHLkfJdTlsZTV0ae5sPwl9Gn/EGr2hrlbcS/8naXm2CDpnKUwC68i1wGlrYAfIgYWL7vUZwk2GkYLQH5CvFig==
"@vue/server-renderer@3.2.45":
version "3.2.45"
resolved "https://registry.yarnpkg.com/@vue/server-renderer/-/server-renderer-3.2.45.tgz#ca9306a0c12b0530a1a250e44f4a0abac6b81f3f"
integrity sha512-ebiMq7q24WBU1D6uhPK//2OTR1iRIyxjF5iVq/1a5I1SDMDyDu4Ts6fJaMnjrvD3MqnaiFkKQj+LKAgz5WIK3g==
dependencies:
"@vue/compiler-ssr" "3.2.41"
"@vue/shared" "3.2.41"
"@vue/compiler-ssr" "3.2.45"
"@vue/shared" "3.2.45"
"@vue/shared@3.2.41", "@vue/shared@^3.2.40":
version "3.2.41"
resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.2.41.tgz#fbc95422df654ea64e8428eced96ba6ad555d2bb"
integrity sha512-W9mfWLHmJhkfAmV+7gDjcHeAWALQtgGT3JErxULl0oz6R6+3ug91I7IErs93eCFhPCZPHBs4QJS7YWEV7A3sxw==
"@vue/shared@3.2.45":
version "3.2.45"
resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.2.45.tgz#a3fffa7489eafff38d984e23d0236e230c818bc2"
integrity sha512-Ewzq5Yhimg7pSztDV+RH1UDKBzmtqieXQlpTVm2AwraoRL/Rks96mvd8Vgi7Lj+h+TH8dv7mXD3FRZR3TUvbSg==
"@vue/tsconfig@^0.1.3":
version "0.1.3"
resolved "https://registry.yarnpkg.com/@vue/tsconfig/-/tsconfig-0.1.3.tgz#4a61dbd29783d01ddab504276dcf0c2b6988654f"
@ -421,6 +486,14 @@ ansi-styles@^4.1.0:
dependencies:
color-convert "^2.0.1"
anymatch@~3.1.2:
version "3.1.2"
resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.2.tgz#c0557c096af32f106198f4f4e2a383537e378716"
integrity sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==
dependencies:
normalize-path "^3.0.0"
picomatch "^2.0.4"
argparse@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38"
@ -436,6 +509,11 @@ balanced-match@^1.0.0:
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
binary-extensions@^2.0.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d"
integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==
boolbase@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e"
@ -466,7 +544,7 @@ brace-expansion@^2.0.1:
dependencies:
balanced-match "^1.0.0"
braces@^3.0.2:
braces@^3.0.2, braces@~3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107"
integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==
@ -503,6 +581,21 @@ chalk@^4.0.0, chalk@^4.1.2:
ansi-styles "^4.1.0"
supports-color "^7.1.0"
"chokidar@>=3.0.0 <4.0.0":
version "3.5.3"
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd"
integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==
dependencies:
anymatch "~3.1.2"
braces "~3.0.2"
glob-parent "~5.1.2"
is-binary-path "~2.1.0"
is-glob "~4.0.1"
normalize-path "~3.0.0"
readdirp "~3.6.0"
optionalDependencies:
fsevents "~2.3.2"
color-convert@^1.9.0:
version "1.9.3"
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"
@ -831,10 +924,10 @@ eslint-visitor-keys@^3.3.0:
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz#f6480fa6b1f30efe2d1968aa8ac745b862469826"
integrity sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==
eslint@^8.26.0:
version "8.26.0"
resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.26.0.tgz#2bcc8836e6c424c4ac26a5674a70d44d84f2181d"
integrity sha512-kzJkpaw1Bfwheq4VXUezFriD1GxszX6dUekM7Z3aC2o4hju+tsR/XyTC3RcoSD7jmy9VkPU3+N6YjVU2e96Oyg==
eslint@^8.27.0:
version "8.27.0"
resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.27.0.tgz#d547e2f7239994ad1faa4bb5d84e5d809db7cf64"
integrity sha512-0y1bfG2ho7mty+SiILVf9PfuRA49ek4Nc60Wmmu62QlobNR+CeXa4xXIJgcuwSQgZiWaPH+5BDsctpIW0PR/wQ==
dependencies:
"@eslint/eslintrc" "^1.3.3"
"@humanwhocodes/config-array" "^0.11.6"
@ -1057,7 +1150,7 @@ get-symbol-description@^1.0.0:
call-bind "^1.0.2"
get-intrinsic "^1.1.1"
glob-parent@^5.1.2:
glob-parent@^5.1.2, glob-parent@~5.1.2:
version "5.1.2"
resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4"
integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==
@ -1168,6 +1261,11 @@ ignore@^5.2.0:
resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.0.tgz#6d3bac8fa7fe0d45d9f9be7bac2fc279577e345a"
integrity sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==
immutable@^4.0.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.1.0.tgz#f795787f0db780183307b9eb2091fcac1f6fafef"
integrity sha512-oNkuqVTA8jqG1Q6c+UglTOD1xhC1BtjKI7XkCXRkZHrN5m18/XsnUp8Q89GkQO/z+0WjonSvl0FLhDYftp46nQ==
import-fresh@^3.0.0, import-fresh@^3.2.1:
version "3.3.0"
resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b"
@ -1215,6 +1313,13 @@ is-bigint@^1.0.1:
dependencies:
has-bigints "^1.0.1"
is-binary-path@~2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09"
integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==
dependencies:
binary-extensions "^2.0.0"
is-boolean-object@^1.1.0:
version "1.1.2"
resolved "https://registry.yarnpkg.com/is-boolean-object/-/is-boolean-object-1.1.2.tgz#5c6dc200246dd9321ae4b885a114bb1f75f63719"
@ -1247,7 +1352,7 @@ is-extglob@^2.1.1:
resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=
is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3:
is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1:
version "4.0.3"
resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084"
integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==
@ -1434,6 +1539,11 @@ minimatch@^5.1.0:
dependencies:
brace-expansion "^2.0.1"
mitt@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/mitt/-/mitt-3.0.0.tgz#69ef9bd5c80ff6f57473e8d89326d01c414be0bd"
integrity sha512-7dX2/10ITVyqh4aOSVI9gdape+t9l2/8QxHrFmUXu4EEUpdlxl6RudZUPZoc+zuY2hk1j7XxVroIVIan/pD/SQ==
ms@2.1.2:
version "2.1.2"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
@ -1469,6 +1579,11 @@ normalize-package-data@^2.3.2:
semver "2 || 3 || 4 || 5"
validate-npm-package-license "^3.0.1"
normalize-path@^3.0.0, normalize-path@~3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"
integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==
npm-run-all@^4.1.5:
version "4.1.5"
resolved "https://registry.yarnpkg.com/npm-run-all/-/npm-run-all-4.1.5.tgz#04476202a15ee0e2e214080861bff12a51d98fba"
@ -1601,7 +1716,7 @@ picocolors@^1.0.0:
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c"
integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==
picomatch@^2.3.1:
picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1:
version "2.3.1"
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42"
integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
@ -1666,6 +1781,13 @@ read-pkg@^3.0.0:
normalize-package-data "^2.3.2"
path-type "^3.0.0"
readdirp@~3.6.0:
version "3.6.0"
resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7"
integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==
dependencies:
picomatch "^2.2.1"
regexp.prototype.flags@^1.4.3:
version "1.4.3"
resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.4.3.tgz#87cab30f80f66660181a3bb7bf5981a872b367ac"
@ -1729,6 +1851,15 @@ safe-regex-test@^1.0.0:
get-intrinsic "^1.1.3"
is-regex "^1.1.4"
sass@^1.56.1:
version "1.56.1"
resolved "https://registry.yarnpkg.com/sass/-/sass-1.56.1.tgz#94d3910cd468fd075fa87f5bb17437a0b617d8a7"
integrity sha512-VpEyKpyBPCxE7qGDtOcdJ6fFbcpOM+Emu7uZLxVrkX8KVU/Dp5UF7WLvzqRuUhB6mqqQt1xffLoG+AndxTZrCQ==
dependencies:
chokidar ">=3.0.0 <4.0.0"
immutable "^4.0.0"
source-map-js ">=0.6.2 <2.0.0"
"semver@2 || 3 || 4 || 5", semver@^5.5.0:
version "5.7.1"
resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7"
@ -1784,7 +1915,7 @@ slash@^3.0.0:
resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634"
integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==
source-map-js@^1.0.2:
"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"
integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==
@ -1983,10 +2114,10 @@ vite-plugin-css-injected-by-js@^2.1.1:
resolved "https://registry.yarnpkg.com/vite-plugin-css-injected-by-js/-/vite-plugin-css-injected-by-js-2.1.1.tgz#a79275241c61f1c8d55d228f5b2dded450a580e4"
integrity sha512-gjIG6iFWde32oRr/bK9CsfN+jdbura2y4GlDzeOiEm6py38ud8dXzFl9zwmHjOjZdr8XEgQ9TovzVGNzp47esw==
vite@^3.2.2:
version "3.2.2"
resolved "https://registry.yarnpkg.com/vite/-/vite-3.2.2.tgz#280762bfaf47bcea1d12698427331c0009ac7c1f"
integrity sha512-pLrhatFFOWO9kS19bQ658CnRYzv0WLbsPih6R+iFeEEhDOuYgYCX2rztUViMz/uy/V8cLCJvLFeiOK7RJEzHcw==
vite@^3.2.3:
version "3.2.3"
resolved "https://registry.yarnpkg.com/vite/-/vite-3.2.3.tgz#7a68d9ef73eff7ee6dc0718ad3507adfc86944a7"
integrity sha512-h8jl1TZ76eGs3o2dIBSsvXDLb1m/Ec1iej8ZMdz+PsaFUsftZeWe2CZOI3qogEsMNaywc17gu0q6cQDzh/weCQ==
dependencies:
esbuild "^0.15.9"
postcss "^8.4.18"
@ -2031,16 +2162,16 @@ vue-tsc@^1.0.9:
"@volar/vue-language-core" "1.0.9"
"@volar/vue-typescript" "1.0.9"
vue@^3.2.41:
version "3.2.41"
resolved "https://registry.yarnpkg.com/vue/-/vue-3.2.41.tgz#ed452b8a0f7f2b962f055c8955139c28b1c06806"
integrity sha512-uuuvnrDXEeZ9VUPljgHkqB5IaVO8SxhPpqF2eWOukVrBnRBx2THPSGQBnVRt0GrIG1gvCmFXMGbd7FqcT1ixNQ==
vue@^3.2.45:
version "3.2.45"
resolved "https://registry.yarnpkg.com/vue/-/vue-3.2.45.tgz#94a116784447eb7dbd892167784619fef379b3c8"
integrity sha512-9Nx/Mg2b2xWlXykmCwiTUCWHbWIj53bnkizBxKai1g61f2Xit700A1ljowpTIM11e3uipOeiPcSqnmBg6gyiaA==
dependencies:
"@vue/compiler-dom" "3.2.41"
"@vue/compiler-sfc" "3.2.41"
"@vue/runtime-dom" "3.2.41"
"@vue/server-renderer" "3.2.41"
"@vue/shared" "3.2.41"
"@vue/compiler-dom" "3.2.45"
"@vue/compiler-sfc" "3.2.45"
"@vue/runtime-dom" "3.2.45"
"@vue/server-renderer" "3.2.45"
"@vue/shared" "3.2.45"
which-boxed-primitive@^1.0.2:
version "1.0.2"

Binary file not shown.