diff --git a/README_onBattery.md b/README_onBattery.md index 41af14c09..3f484c5cb 100644 --- a/README_onBattery.md +++ b/README_onBattery.md @@ -96,32 +96,6 @@ Several screenshots of the frontend can be found here: [Screenshots](docs/screen * A documentation of the Web API can be found here: [Web-API Documentation](docs/Web-API.md) * Home Assistant auto discovery is supported. [Example image](https://user-images.githubusercontent.com/59169507/217558862-a83846c5-6070-43cd-9a0b-90a8b2e2e8c6.png) -### Dynamic Power Limiter - -The dynamic power limiter is responsible for automatic inverter power adjustment. It will take the Power Meter (i.e. currently consumed power), the solar power and the battery charge state into account. The dynamic power limiter supports a few different strategies that can be configured from the user interface: - -* Solar Passthrough is off - * When using this strategy the inverter is steered such that the currently consumed power (as provided by the power meter) is compensated for. This is done as long as the battery charge state is above the limit set by the stop threshold. The inverter is turned off if the battery reaches the limit and is only re-enabled if the battery charge state reaches the limit set by start threshold. -* Solar Passthrough is on and the battery drain strategy is empty when full - * This case applies the same strategy as the strategy above. In addition Solar Power will be used to compensate for the currently used energy in cases where the battery discharge is disabled. In this case the inverter power limit is constrained to the input solar power and the power meter value so that battery discharge is avoided. -* Solar Passthrough is on and the battery drain strategy is empty at night - * When using this strategy the inverter is steered such that the currently consumed power (as provided by the power meter) is compensated for. During daytime energy is taken from solar and from the battery, if the battery level is above the start threshold. At night battery power is used until the battery level reaches the stop threshold. When operating on solar power only (i.e. without using the battery) the inverter power limit is constrained to the input solar power and the power meter value so that battery discharge is avoided. The daytime / nighttime switch is based on the Victron MPPT Solar Charger power and 20W input are required in this case. - -Other settings are: -* The inverter ID configures the inverter that is controlled by the power limiter. The power limiter can only control a single inverter at this point in time. -* Channel ID is the inverter input channel ID that is used for battery voltage readings. -* Target power consumption specifies the power to be either consumed from the grid (when set to a positive value) or fed back into the grid (when set to a negative value). -* The hysteresis value helps optimize communication with the inverter by skipping unnecessary power limit updates. An update is only sent if the absolute difference between the newly computed power limit and the previously set limit matches or exceeds the hysteresis value. This approach can conserve both airtime and CPU resources. -* Power limits control the min / max limits of the inverter -* Inverter is behind power meter. Select this if your inverter power is measured by the power meter. This is typically the case. -* Battery start and stop threshold can be configured using voltage and / or state of charge values. Stage of charge values requires a Pylontech battery at this point. -* A Battery full solar passthrough threshold can be configured using voltage or state of charge value. Stage of charge values requires a Pylontech battery at this point. The option can be used if the battery is full and will steer the inverter according to solar power reported by the Victron MPPT Solar Charger. - -![image](https://user-images.githubusercontent.com/59169507/222155765-9fff47a4-8ffa-42cf-8671-6359288e0cab.png) - -#### Power Limiter States -![PowerLimiterInverterStates](https://github.com/helgeerbe/OpenDTU-OnBattery/blob/development/docs/PowerLimiterInverterStates.png) - ### Huawei PSU The Huawei PSU can be used to charge a battery. This can be be useful if an external (AC) connected solar system shall be utilized or if variable energy prices should be exploited. diff --git a/docs/DeviceProfiles.md b/docs/DeviceProfiles.md index edfad5466..3444acc61 100644 --- a/docs/DeviceProfiles.md +++ b/docs/DeviceProfiles.md @@ -1,12 +1,8 @@ # Device Profiles -It is possible to change hardware settings like pin assignments or ethernet support using a json file. The json file can be uploaded using the configuration management in the web interface. Just select "Pin Mapping (pin_mapping.json)" in the recovery section. +This documentation will has been moved and can be found here: -When the file is uploaded the ESP performs a reboot. This is required as the pin settings could have changed within the file. By default all the pin assignments are used as compiled into the firmware. - -To change the device profile, navigate to the "Device Manager" and selected the appropriate profile. You can see the current (Active) and the new (Selected) in assignment in the table below the combobox. - -## Structure of the json file +## Structure of the json file for openDTU-onBattery (outdated example) ```json [ @@ -92,48 +88,4 @@ To change the device profile, navigate to the "Device Manager" and selected the } } ] -``` - -The json file can contain multiple profiles. Each profile requires a name and different parameters. If one parameter is not set, the default value, as compiled into the firmware is used. The example above shows all the currently supported values. Others may follow. Sample files for some boards can be found [here](DeviceProfiles/). This means you can just flash the generic bin file and upload the json file. Then you select your board and everything works hopyfully as expected. - -## Implemented configuration values - -| Parameter | Data Type | Description | -| ------------- | --------- | ----------- | -| name | string | Unique name of the profile (max 63 characters) | -| nrf24.miso | number | MISO Pin | -| nrf24.mosi | number | MOSI Pin | -| nrf24.clk | number | Clock Pin | -| nrf24.irq | number | Interrupt Pin | -| nrf24.en | number | Enable Pin | -| nrf24.cs | number | Chip Select Pin | -| cmt.sdio | number | SDIO Pin | -| cmt.clk | number | CLK Pin | -| cmt.cs | number | CS Pin | -| cmt.fcs | number | FCS Pin | -| cmt.gpio2 | number | GPIO2 Pin (optional) | -| cmt.gpio3 | number | GPIO3 Pin (optional) | -| eth.enabled | boolean | Enable/Disable the ethernet stack | -| eth.phy_addr | number | Unique PHY addr | -| eth.power | number | Power Pin (if available). Use -1 for not assigned pins. | -| eth.mdc | number | Serial Management Interface MDC Pin. Use -1 for not assigned pins. | -| eth.mdio | number | Serial Management Interface MDIO Pin. Use -1 for not assigned pins. | -| eth.type | number | Possible values:
* 0 = ETH_PHY_LAN8720
* 1 = ETH_PHY_TLK110
* 2 = ETH_PHY_RTL8201
* 3 = ETH_PHY_DP83848
* 4 = ETH_PHY_DM9051
* 5 = ETH_PHY_KSZ8041
* 6 = ETH_PHY_KSZ8081 | -| eth.clk_mode | number | Possible values:
* 0 = ETH_CLOCK_GPIO0_IN
* 1 = ETH_CLOCK_GPIO0_OUT
* 2 = ETH_CLOCK_GPIO16_OUT
* 3 = ETH_CLOCK_GPIO17_OUT | -| display.type | number | Specify type of display. Possible values:
* 0 = None (default)
* 1 = PCD8544
* 2 = SSD1306
* 3 = SH1106 | -| display.data | number | Data Pin (e.g. SDA for i2c displays) required for all displays. Use 255 for not assigned pins. | -| display.clk | number | Clock Pin (e.g. SCL for i2c displays) required for SSD1306 and SH1106. Use 255 for not assigned pins. | -| display.cs | number | Chip Select Pin required for PCD8544. Use 255 for not assigned pins. | -| display.reset | number | Reset Pin required for PCD8544, optional for all other displays. Use 255 for not assigned pins. | -| victron.rx | number | Victron Ve.direct Rx pin | -| victron.tx | number | Victron Ve.direct Tx pin | -| battery.rx | number | Pylontech CAN bus battery Rx pin | -| battery.tx | number | Pylontech CAN bus battery Tx pin | -| huawei.miso | number | MISO Pin for Huawei CAN bus interface | -| huawei.mosi | number | MOSI Pin for Huawei CAN bus interface | -| huawei.clk | number | CLK Pin for Huawei CAN bus interface | -| huawei.cs | number | CS Pin for Huawei CAN bus interface | -| huawei.irq | number | IRQ Pin for Huawei CAN bus interface | -| huawei.power | number | Power Pin for Huawei power control (e.g. using slot detect) | -| led.led0 | number | LED pin for network indication. Blinking = WLAN connected but NTP & MQTT (if enabled) disconnected. On = WLAN, NTP, MQTT connected. Off = Network not connected | -| led.led1 | number | LED pin for inverter indication. On = All inverters reachable & producing. Blinking = All inverters reachable but not producing. Off = At least one inverter is not reachable. Only inverters with polling enabled are considered. | +``` \ No newline at end of file diff --git a/docs/DeviceProfiles/nodemcu_esp32.json b/docs/DeviceProfiles/nodemcu_esp32.json index d7f6a6141..0587dd88c 100644 --- a/docs/DeviceProfiles/nodemcu_esp32.json +++ b/docs/DeviceProfiles/nodemcu_esp32.json @@ -73,6 +73,25 @@ "clk": 22 } }, + { + "name": "NRF24 with SSD1309", + "nrf24": { + "miso": 19, + "mosi": 23, + "clk": 18, + "irq": 16, + "en": 4, + "cs": 5 + }, + "eth": { + "enabled": false + }, + "display": { + "type": 4, + "data": 21, + "clk": 22 + } + }, { "name": "CMT2300A with SSD1306", "nrf24": { @@ -127,6 +146,33 @@ "clk": 22 } }, + { + "name": "CMT2300A with SSD1309", + "nrf24": { + "miso": -1, + "mosi": -1, + "clk": -1, + "irq": -1, + "en": -1, + "cs": -1 + }, + "cmt": { + "clk": 18, + "cs": 4, + "fcs": 5, + "sdio": 23, + "gpio2": 19, + "gpio3": 16 + }, + "eth": { + "enabled": false + }, + "display": { + "type": 4, + "data": 21, + "clk": 22 + } + }, { "name": "NRF24 + CMT2300A", "nrf24": { diff --git a/docs/DeviceProfiles/olimex_esp32_poe.json b/docs/DeviceProfiles/olimex_esp32_poe.json index 27f8242f9..e0a81a010 100644 --- a/docs/DeviceProfiles/olimex_esp32_poe.json +++ b/docs/DeviceProfiles/olimex_esp32_poe.json @@ -77,5 +77,33 @@ "data": 33, "clk": 32 } + }, + { + "name": "Olimex ESP32-POE with SSD1309", + "links": [ + {"name": "Datasheet", "url": "https://www.olimex.com/Products/IoT/ESP32/ESP32-POE/open-source-hardware"} + ], + "nrf24": { + "miso": 15, + "mosi": 2, + "clk": 14, + "irq": 13, + "en": 16, + "cs": 5 + }, + "eth": { + "enabled": true, + "phy_addr": 0, + "power": 12, + "mdc": 23, + "mdio": 18, + "type": 0, + "clk_mode": 3 + }, + "display": { + "type": 4, + "data": 33, + "clk": 32 + } } ] \ No newline at end of file diff --git a/docs/DeviceProfiles/wt32-eth01.json b/docs/DeviceProfiles/wt32-eth01.json index 388db2094..8af112832 100644 --- a/docs/DeviceProfiles/wt32-eth01.json +++ b/docs/DeviceProfiles/wt32-eth01.json @@ -49,5 +49,33 @@ "data": 5, "clk": 17 } + }, + { + "name": "WT32-ETH01 with SSD1309", + "links": [ + {"name": "Datasheet", "url": "http://www.wireless-tag.com/portfolio/wt32-eth01/"} + ], + "nrf24": { + "miso": 4, + "mosi": 2, + "clk": 32, + "irq": 33, + "en": 14, + "cs": 15 + }, + "eth": { + "enabled": true, + "phy_addr": 1, + "power": 16, + "mdc": 23, + "mdio": 18, + "type": 0, + "clk_mode": 0 + }, + "display": { + "type": 4, + "data": 5, + "clk": 17 + } } ] \ No newline at end of file diff --git a/docs/MQTT_Topics.md b/docs/MQTT_Topics.md index 204916f7d..e9925f874 100644 --- a/docs/MQTT_Topics.md +++ b/docs/MQTT_Topics.md @@ -1,88 +1,3 @@ # MQTT Topics -The base topic, as configured in the web GUI is prepended to all follwing topics. - -## General topics - -| Topic | R / W | Description | Value / Unit | -| --------------------------------------- | ----- | ---------------------------------------------------- | -------------------------- | -| dtu/ip | R | IP address of OpenDTU | IP address | -| dtu/hostname | R | Current hostname of the dtu (as set in web GUI) | | -| dtu/rssi | R | WiFi network quality | db value | -| dtu/status | R | Indicates whether OpenDTU network is reachable | online / offline | -| dtu/uptime | R | Time in seconds since startup | seconds | - -## Inverter total topics - -Enabled inverter means, that only inverters with "Poll inverter data" enabled are considered. - -| Topic | R / W | Description | Value / Unit | -| --------------------------------------- | ----- | ---------------------------------------------------- | -------------------------- | -| ac/power | R | Sum of AC active power of all enabled inverters | W | -| ac/yieldtotal | R | Sum of energy converted to AC since reset watt hours of all enabled inverters | Kilo watt hours (kWh) | -| ac/yieldday | R | Sum of energy converted to AC per day in watt hours of all enabled inverters | Watt hours (Wh) -| ac/is_valid | R | Indicator whether all enabled inverters where reachable | 0 or 1 | -| dc/power | R | Sum of DC power of all enabled inverters | Watt (W) | -| dc/irradiation | R | Produced power of all enabled inverter stripes with defined irradiation settings divided by sum of all enabled inverters irradiation | % | -| dc/is_valid | R | Indicator whether all enabled inverters where reachable | 0 or 1 | - -## Inverter specific topics - -serial will be replaced with the serial number of the inverter. - -| Topic | R / W | Description | Value / Unit | -| --------------------------------------- | ----- | ---------------------------------------------------- | -------------------------- | -| [serial]/name | R | Name of the inverter as configured in web GUI | | -| [serial]/device/bootloaderversion | R | Bootloader version of the inverter | | -| [serial]/device/fwbuildversion | R | Firmware version of the inverter | | -| [serial]/device/fwbuilddatetime | R | Build date / time of inverter firmware | | -| [serial]/device/hwpartnumber | R | Hardware part number of the inverter | | -| [serial]/device/hwversion | R | Hardware version of the inverter | | -| [serial]/status/reachable | R | Indicates whether the inverter is reachable | 0 or 1 | -| [serial]/status/producing | R | Indicates whether the inverter is producing AC power | 0 or 1 | -| [serial]/status/last_update | R | Unix timestamp of last inverter statistics udpate | seconds since JAN 01 1970 (UTC) | - -### AC channel / global specific topics - -| Topic | R / W | Description | Value / Unit | -| --------------------------------------- | ----- | ---------------------------------------------------- | -------------------------- | -| [serial]/0/current | R | AC current in ampere | Ampere (A) | -| [serial]/0/efficiency | R | Ratio AC Power over DC Power in percent | % | -| [serial]/0/frequency | R | AC frequency in hertz | Hertz (Hz) | -| [serial]/0/power | R | AC active power in watts | Watt (W) | -| [serial]/0/powerdc | R | DC power in watts | Watt (W) | -| [serial]/0/powerfactor | R | Power factor in percent | % | -| [serial]/0/reactivepower | R | AC reactive power in VAr | VAr | -| [serial]/0/temperature | R | Temperature of inverter in degree celsius | Degree Celsius (°C) | -| [serial]/0/voltage | R | AC voltage in volt | Volt (V) | -| [serial]/0/yieldday | R | Energy converted to AC per day in watt hours | Watt hours (Wh) | -| [serial]/0/yieldtotal | R | Energy converted to AC since reset watt hours | Kilo watt hours (kWh) | - -### DC input channel topics - -[1-4] represents the different inputs. The amount depends on the inverter model. - -| Topic | R / W | Description | Value / Unit | -| --------------------------------------- | ----- | ---------------------------------------------------- | -------------------------- | -| [serial]/[1-4]/current | R | DC current of specific input in ampere | Ampere (A) | -| [serial]/[1-4]/name | R | Name of the DC input channel as configured in web GUI| | -| [serial]/[1-4]/irradiation | R | Ratio DC Power over set maximum power (in web GUI) | % | -| [serial]/[1-4]/power | R | DC power of specific input in watt | Watt (W) | -| [serial]/[1-4]/voltage | R | DC voltage of specific input in volt | Volt (V) | -| [serial]/[1-4]/yieldday | R | Energy converted to AC per day on specific input | Watt hours (Wh) | -| [serial]/[1-4]/yieldtotal | R | Energy converted to AC since reset on specific input | Kilo watt hours (kWh) | - -### Inverter limit specific topics - -cmd topics are used to set values. Status topics are updated from values set in the inverter. - -| Topic | R / W | Description | Value / Unit | -| ----------------------------------------- | ----- | ---------------------------------------------------- | -------------------------- | -| [serial]/status/limit_relative | R | Current applied production limit of the inverter | % of total possible output | -| [serial]/status/limit_absolute | R | Current applied production limit of the inverter | Watt (W) | -| [serial]/cmd/limit_persistent_relative | W | Set the inverter limit as a percentage of total production capability. The value will survive the night without power. The updated value will show up in the web GUI and limit_relative topic immediatly. | % | -| [serial]/cmd/limit_persistent_absolute | W | Set the inverter limit as a absolute value. The value will survive the night without power. The updated value will set immediatly within the inverter but show up in the web GUI and limit_relative topic after around 4 minutes. If you are using a already known inverter (known Hardware ID), the updated value will show up within a few seconds. | Watt (W) | -| [serial]/cmd/limit_nonpersistent_relative | W | Set the inverter limit as a percentage of total production capability. The value will reset to the last persistent value at night without power. The updated value will show up in the web GUI and limit_relative topic immediatly. The value must be published non-retained, otherwise it will be ignored! | % | -| [serial]/cmd/limit_nonpersistent_absolute | W | Set the inverter limit as a absolute value. The value will reset to the last persistent value at night without power. The updated value will set immediatly within the inverter but show up in the web GUI and limit_relative topic after around 4 minutes. If you are using a already known inverter (known Hardware ID), the updated value will show up within a few seconds. The value must be published non-retained, otherwise it will be ignored! | Watt (W) | -| [serial]/cmd/power | W | Turn the inverter on (1) or off (0) | 0 or 1 | -| [serial]/cmd/restart | W | Restarts the inverters (also resets YieldDay) | 1 | +This documentation will has been moved and can be found here: diff --git a/docs/Web-API.md b/docs/Web-API.md index 4ada94747..703d89dea 100644 --- a/docs/Web-API.md +++ b/docs/Web-API.md @@ -1,6 +1,6 @@ # Web API -Information in JSON format can be obtained through the web API +This documentation will has been moved and can be found here: ## List of URLs @@ -8,36 +8,6 @@ This list may be incomplete | GET/POST | Auth required | URL | | -------- | --- | -- | -| Get | yes | /api/config/get | -| Post | yes | /api/config/delete | -| Get | yes | /api/config/list | -| Post | yes | /api/config/upload | -| Get+Post | yes | /api/device/config | -| 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 | -| Post | yes | /api/maintenance/reboot | -| 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 | no | /api/prometheus/metrics | -| Get+Post | yes | /api/security/config | -| Get | yes | /api/security/authenticate | -| Get | no | /api/system/status | | Get | no | /api/vedirectlivedata/status | | Get | no | /api/vedirect/status | | Get | no | /api/huawei/status | @@ -47,455 +17,6 @@ This list may be incomplete | Get | no | /api/battery/status | | Get | no | /api/powerlimiter/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 -- If you disable the readonly access to the web API, every endpoint requires authentication - -### 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 - -```bash -$ curl http://192.168.10.10/api/livedata/status -{"inverters":[{"serial":"11617160xxxx","name":"Meine Solaranlage","data_age":6983,"reachable":false,"producing":false,"limit_relative":0,"limit_absolute":-1,"AC":{"0":{"Power":{"v":0,"u":"W","d":1},"Voltage":{"v":0,"u":"V","d":1},"Current":{"v":0,"u":"A","d":2},"Power DC":{"v":0,"u":"W","d":1},"YieldDay":{"v":0,"u":"Wh","d":0},"YieldTotal":{"v":0,"u":"kWh","d":3},"Frequency":{"v":0,"u":"Hz","d":2},"PowerFactor":{"v":0,"u":"","d":3},"ReactivePower":{"v":0,"u":"var","d":1},"Efficiency":{"v":0,"u":"%","d":3}}},"DC":{"0":{"name":{"u":""},"Power":{"v":0,"u":"W","d":1},"Voltage":{"v":0,"u":"V","d":1},"Current":{"v":0,"u":"A","d":2},"YieldDay":{"v":0,"u":"Wh","d":0},"YieldTotal":{"v":0,"u":"kWh","d":3},"Irradiation":{"v":0,"u":"%","d":3}},"1":{"name":{"u":""},"Power":{"v":0,"u":"W","d":1},"Voltage":{"v":0,"u":"V","d":1},"Current":{"v":0,"u":"A","d":2},"YieldDay":{"v":0,"u":"Wh","d":0},"YieldTotal":{"v":0,"u":"kWh","d":3},"Irradiation":{"v":0,"u":"%","d":3}},"2":{"name":{"u":""},"Power":{"v":0,"u":"W","d":1},"Voltage":{"v":0,"u":"V","d":1},"Current":{"v":0,"u":"A","d":2},"YieldDay":{"v":0,"u":"Wh","d":0},"YieldTotal":{"v":0,"u":"kWh","d":3},"Irradiation":{"v":0,"u":"%","d":3}},"3":{"name":{"u":""},"Power":{"v":0,"u":"W","d":1},"Voltage":{"v":0,"u":"V","d":1},"Current":{"v":0,"u":"A","d":2},"YieldDay":{"v":0,"u":"Wh","d":0},"YieldTotal":{"v":0,"u":"kWh","d":3}}},"INV":{"0":{"Temperature":{"v":0,"u":"°C","d":1}}},"events":0},{"serial":"11417160xxxx","name":"test","data_age":6983,"reachable":false,"producing":false,"limit_relative":0,"limit_absolute":-1,"AC":{"0":{"Power":{"v":0,"u":"W","d":1},"Voltage":{"v":0,"u":"V","d":1},"Current":{"v":0,"u":"A","d":2},"Power DC":{"v":0,"u":"W","d":1},"YieldDay":{"v":0,"u":"Wh","d":0},"YieldTotal":{"v":0,"u":"kWh","d":3},"Frequency":{"v":0,"u":"Hz","d":2},"PowerFactor":{"v":0,"u":"","d":3},"ReactivePower":{"v":0,"u":"var","d":1},"Efficiency":{"v":0,"u":"%","d":3}}},"DC":{"0":{"name":{"u":"test 1"},"Power":{"v":0,"u":"W","d":1},"Voltage":{"v":0,"u":"V","d":1},"Current":{"v":0,"u":"A","d":2},"YieldDay":{"v":0,"u":"Wh","d":0},"YieldTotal":{"v":0,"u":"kWh","d":3},"Irradiation":{"v":0,"u":"%","d":3}},"1":{"name":{"u":"test 2"},"Power":{"v":0,"u":"W","d":1},"Voltage":{"v":0,"u":"V","d":1},"Current":{"v":0,"u":"A","d":2},"YieldDay":{"v":0,"u":"Wh","d":0},"YieldTotal":{"v":0,"u":"kWh","d":3},"Irradiation":{"v":0,"u":"%","d":3}}},"INV":{"0":{"Temperature":{"v":0,"u":"°C","d":1}}},"events":0}],"total":{"Power":{"v":0,"u":"W","d":1},"YieldDay":{"v":0,"u":"Wh","d":0},"YieldTotal":{"v":0,"u":"kWh","d":2}},"hints":{"time_sync":false,"radio_problem":false,"default_password":false}} -``` - -To enhance readability (and filter information) use the JSON command line processor `jq`. - -```bash -$ curl --no-progress-meter http://192.168.10.10/api/livedata/status | jq -{ - "inverters": [ - { - "serial": "116171603546", - "name": "Meine Solaranlage", - "data_age": 7038, - "reachable": false, - "producing": false, - "limit_relative": 0, - "limit_absolute": -1, - "AC": { - "0": { - "Power": { - "v": 0, - "u": "W", - "d": 1 - }, - "Voltage": { - "v": 0, - "u": "V", - "d": 1 - }, - "Current": { - "v": 0, - "u": "A", - "d": 2 - }, - "Power DC": { - "v": 0, - "u": "W", - "d": 1 - }, - "YieldDay": { - "v": 0, - "u": "Wh", - "d": 0 - }, - "YieldTotal": { - "v": 0, - "u": "kWh", - "d": 3 - }, - "Frequency": { - "v": 0, - "u": "Hz", - "d": 2 - }, - "PowerFactor": { - "v": 0, - "u": "", - "d": 3 - }, - "ReactivePower": { - "v": 0, - "u": "var", - "d": 1 - }, - "Efficiency": { - "v": 0, - "u": "%", - "d": 3 - } - } - }, - "DC": { - "0": { - "name": { - "u": "" - }, - "Power": { - "v": 0, - "u": "W", - "d": 1 - }, - "Voltage": { - "v": 0, - "u": "V", - "d": 1 - }, - "Current": { - "v": 0, - "u": "A", - "d": 2 - }, - "YieldDay": { - "v": 0, - "u": "Wh", - "d": 0 - }, - "YieldTotal": { - "v": 0, - "u": "kWh", - "d": 3 - }, - "Irradiation": { - "v": 0, - "u": "%", - "d": 3 - } - }, - "1": { - "name": { - "u": "" - }, - "Power": { - "v": 0, - "u": "W", - "d": 1 - }, - "Voltage": { - "v": 0, - "u": "V", - "d": 1 - }, - "Current": { - "v": 0, - "u": "A", - "d": 2 - }, - "YieldDay": { - "v": 0, - "u": "Wh", - "d": 0 - }, - "YieldTotal": { - "v": 0, - "u": "kWh", - "d": 3 - }, - "Irradiation": { - "v": 0, - "u": "%", - "d": 3 - } - }, - "2": { - "name": { - "u": "" - }, - "Power": { - "v": 0, - "u": "W", - "d": 1 - }, - "Voltage": { - "v": 0, - "u": "V", - "d": 1 - }, - "Current": { - "v": 0, - "u": "A", - "d": 2 - }, - "YieldDay": { - "v": 0, - "u": "Wh", - "d": 0 - }, - "YieldTotal": { - "v": 0, - "u": "kWh", - "d": 3 - }, - "Irradiation": { - "v": 0, - "u": "%", - "d": 3 - } - }, - "3": { - "name": { - "u": "" - }, - "Power": { - "v": 0, - "u": "W", - "d": 1 - }, - "Voltage": { - "v": 0, - "u": "V", - "d": 1 - }, - "Current": { - "v": 0, - "u": "A", - "d": 2 - }, - "YieldDay": { - "v": 0, - "u": "Wh", - "d": 0 - }, - "YieldTotal": { - "v": 0, - "u": "kWh", - "d": 3 - } - } - }, - "INV": { - "0": { - "Temperature": { - "v": 0, - "u": "°C", - "d": 1 - } - } - }, - "events": 0 - }, - { - "serial": "114171603548", - "name": "test", - "data_age": 7038, - "reachable": false, - "producing": false, - "limit_relative": 0, - "limit_absolute": -1, - "AC": { - "0": { - "Power": { - "v": 0, - "u": "W", - "d": 1 - }, - "Voltage": { - "v": 0, - "u": "V", - "d": 1 - }, - "Current": { - "v": 0, - "u": "A", - "d": 2 - }, - "Power DC": { - "v": 0, - "u": "W", - "d": 1 - }, - "YieldDay": { - "v": 0, - "u": "Wh", - "d": 0 - }, - "YieldTotal": { - "v": 0, - "u": "kWh", - "d": 3 - }, - "Frequency": { - "v": 0, - "u": "Hz", - "d": 2 - }, - "PowerFactor": { - "v": 0, - "u": "", - "d": 3 - }, - "ReactivePower": { - "v": 0, - "u": "var", - "d": 1 - }, - "Efficiency": { - "v": 0, - "u": "%", - "d": 3 - } - } - }, - "DC": { - "0": { - "name": { - "u": "test 1" - }, - "Power": { - "v": 0, - "u": "W", - "d": 1 - }, - "Voltage": { - "v": 0, - "u": "V", - "d": 1 - }, - "Current": { - "v": 0, - "u": "A", - "d": 2 - }, - "YieldDay": { - "v": 0, - "u": "Wh", - "d": 0 - }, - "YieldTotal": { - "v": 0, - "u": "kWh", - "d": 3 - }, - "Irradiation": { - "v": 0, - "u": "%", - "d": 3 - } - }, - "1": { - "name": { - "u": "test 2" - }, - "Power": { - "v": 0, - "u": "W", - "d": 1 - }, - "Voltage": { - "v": 0, - "u": "V", - "d": 1 - }, - "Current": { - "v": 0, - "u": "A", - "d": 2 - }, - "YieldDay": { - "v": 0, - "u": "Wh", - "d": 0 - }, - "YieldTotal": { - "v": 0, - "u": "kWh", - "d": 3 - }, - "Irradiation": { - "v": 0, - "u": "%", - "d": 3 - } - } - }, - "INV": { - "0": { - "Temperature": { - "v": 0, - "u": "°C", - "d": 1 - } - } - }, - "events": 0 - } - ], - "total": { - "Power": { - "v": 0, - "u": "W", - "d": 1 - }, - "YieldDay": { - "v": 0, - "u": "Wh", - "d": 0 - }, - "YieldTotal": { - "v": 0, - "u": "kWh", - "d": 2 - } - }, - "hints": { - "time_sync": false, - "radio_problem": false, - "default_password": false - } -} -``` - -The eventlog can be fetched with the inverter serial number as parameter: - -```bash -$ 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 - } - ] - } -} -``` - ### Victron REST-API (/api/vedirectlivedata/status): ````JSON { @@ -520,79 +41,4 @@ $ curl --no-progress-meter http://192.168.10.10/api/eventlog/status?inv=11418186 "H22":{"v":1.43,"u":"kWh"}, "H23":{"v":737,"u":"W"} } -```` - -#### combine curl and jq - -`jq` can filter specific fields from json output. - -For example, filter out the current total power: - -```bash -$ 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. - -```bash -$ 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: - -```bash -$ 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. - -```bash -$ 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: - -```bash -$ 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%. - -```bash -$ 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`. - -```bash -$ 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"}} -``` +```` \ No newline at end of file diff --git a/include/BatteryStats.h b/include/BatteryStats.h index 1ba3b7f79..8ff129f41 100644 --- a/include/BatteryStats.h +++ b/include/BatteryStats.h @@ -28,16 +28,6 @@ class BatteryStats { bool isValid() const { return _lastUpdateSoC > 0 && _lastUpdate > 0; } protected: - template - void addLiveViewValue(JsonVariant& root, std::string const& name, - T&& value, std::string const& unit, uint8_t precision) const; - void addLiveViewText(JsonVariant& root, std::string const& name, - std::string const& text) const; - void addLiveViewWarning(JsonVariant& root, std::string const& name, - bool warning) const; - void addLiveViewAlarm(JsonVariant& root, std::string const& name, - bool alarm) const; - String _manufacturer = "unknown"; uint8_t _SoC = 0; uint32_t _lastUpdateSoC = 0; @@ -138,3 +128,16 @@ class VictronSmartShuntStats : public BatteryStats { bool _alarmLowTemperature; bool _alarmHighTemperature; }; + +class MqttBatteryStats : public BatteryStats { + public: + // since the source of information was MQTT in the first place, + // we do NOT publish the same data under a different topic. + void mqttPublish() const final { } + + // the SoC is the only interesting value in this case, which is already + // displayed at the top of the live view. do not generate a card. + void getLiveViewData(JsonVariant& root) const final { } + + void setSoC(uint8_t SoC) { _SoC = SoC; _lastUpdateSoC = _lastUpdate = millis(); } +}; diff --git a/include/Configuration.h b/include/Configuration.h index 38b915bd1..66049fd08 100644 --- a/include/Configuration.h +++ b/include/Configuration.h @@ -226,6 +226,7 @@ struct CONFIG_T { uint8_t Provider; uint8_t JkBmsInterface; uint8_t JkBmsPollingInterval; + char MqttTopic[MQTT_MAX_TOPIC_STRLEN + 1]; } Battery; struct { diff --git a/include/Display_Graphic.h b/include/Display_Graphic.h index 1d620e546..707812271 100644 --- a/include/Display_Graphic.h +++ b/include/Display_Graphic.h @@ -11,6 +11,8 @@ enum DisplayType_t { PCD8544, SSD1306, SH1106, + SSD1309, + DisplayType_Max, }; class DisplayGraphicClass { @@ -35,6 +37,7 @@ class DisplayGraphicClass { void printText(const char* text, const uint8_t line); void calcLineHeights(); void setFont(const uint8_t line); + bool isValidDisplay(); Task _loopTask; diff --git a/include/Huawei_can.h b/include/Huawei_can.h index db97bd012..e9a3a3d79 100644 --- a/include/Huawei_can.h +++ b/include/Huawei_can.h @@ -69,7 +69,7 @@ // Wait time/current before shuting down the PSU / charger // This is set to allow the fan to run for some time #define HUAWEI_AUTO_MODE_SHUTDOWN_DELAY 60000 -#define HUAWEI_AUTO_MODE_SHUTDOWN_CURRENT 1.0 +#define HUAWEI_AUTO_MODE_SHUTDOWN_CURRENT 0.75 // Updateinterval used to request new values from the PSU #define HUAWEI_DATA_REQUEST_INTERVAL_MS 2500 diff --git a/include/MessageOutput.h b/include/MessageOutput.h index 94f915a5d..fc06f385c 100644 --- a/include/MessageOutput.h +++ b/include/MessageOutput.h @@ -2,12 +2,13 @@ #pragma once #include -#include -#include #include +#include +#include #include - -#define BUFFER_SIZE 500 +#include +#include +#include class MessageOutputClass : public Print { public: @@ -21,13 +22,19 @@ class MessageOutputClass : public Print { Task _loopTask; + using message_t = std::vector; + + // we keep a buffer for every task and only write complete lines to the + // serial output and then move them to be pushed through the websocket. + // this way we prevent mangling of messages from different contexts. + std::unordered_map _task_messages; + std::queue _lines; + AsyncWebSocket* _ws = nullptr; - char _buffer[BUFFER_SIZE]; - uint16_t _buff_pos = 0; - uint32_t _lastSend = 0; - bool _forceSend = false; std::mutex _msgLock; + + void serialWrite(message_t const& m); }; extern MessageOutputClass MessageOutput; \ No newline at end of file diff --git a/include/MqttBattery.h b/include/MqttBattery.h new file mode 100644 index 000000000..83ff412d3 --- /dev/null +++ b/include/MqttBattery.h @@ -0,0 +1,22 @@ +#pragma once + +#include "Battery.h" +#include + +class MqttBattery : public BatteryProvider { + public: + MqttBattery() = default; + + bool init(bool verboseLogging) final; + void deinit() final; + void loop() final { return; } // this class is event-driven + std::shared_ptr getStats() const final { return _stats; } + + private: + bool _verboseLogging = false; + String _socTopic; + std::shared_ptr _stats = std::make_shared(); + + void onMqttMessage(espMqttClientTypes::MessageProperties const& properties, + char const* topic, uint8_t const* payload, size_t len, size_t index, size_t total); +}; diff --git a/include/MqttHandleHuawei.h b/include/MqttHandleHuawei.h index e25a82e0f..f518ed9d4 100644 --- a/include/MqttHandleHuawei.h +++ b/include/MqttHandleHuawei.h @@ -5,6 +5,9 @@ #include #include #include +#include +#include +#include class MqttHandleHuaweiClass { public: @@ -12,13 +15,30 @@ class MqttHandleHuaweiClass { private: void loop(); - void onMqttMessage(const espMqttClientTypes::MessageProperties& properties, const char* topic, const uint8_t* payload, size_t len, size_t index, size_t total); + + enum class Topic : unsigned { + LimitOnlineVoltage, + LimitOnlineCurrent, + LimitOfflineVoltage, + LimitOfflineCurrent, + Mode + }; + + void onMqttMessage(Topic t, + const espMqttClientTypes::MessageProperties& properties, + const char* topic, const uint8_t* payload, size_t len, + size_t index, size_t total); Task _loopTask; uint32_t _lastPublishStats; uint32_t _lastPublish; + // MQTT callbacks to process updates on subscribed topics are executed in + // the MQTT thread's context. we use this queue to switch processing the + // user requests into the main loop's context (TaskScheduler context). + mutable std::mutex _mqttMutex; + std::deque> _mqttCallbacks; }; extern MqttHandleHuaweiClass MqttHandleHuawei; \ No newline at end of file diff --git a/include/MqttHandlePowerLimiter.h b/include/MqttHandlePowerLimiter.h index b81917db6..d78c3f19d 100644 --- a/include/MqttHandlePowerLimiter.h +++ b/include/MqttHandlePowerLimiter.h @@ -4,6 +4,9 @@ #include "Configuration.h" #include #include +#include +#include +#include class MqttHandlePowerLimiterClass { public: @@ -18,6 +21,11 @@ class MqttHandlePowerLimiterClass { uint32_t _lastPublishStats; uint32_t _lastPublish; + // MQTT callbacks to process updates on subscribed topics are executed in + // the MQTT thread's context. we use this queue to switch processing the + // user requests into the main loop's context (TaskScheduler context). + mutable std::mutex _mqttMutex; + std::deque> _mqttCallbacks; }; extern MqttHandlePowerLimiterClass MqttHandlePowerLimiter; \ No newline at end of file diff --git a/lib/Hoymiles/src/parser/GridProfileParser.cpp b/lib/Hoymiles/src/parser/GridProfileParser.cpp index da1c80604..b6c1573b7 100644 --- a/lib/Hoymiles/src/parser/GridProfileParser.cpp +++ b/lib/Hoymiles/src/parser/GridProfileParser.cpp @@ -9,13 +9,16 @@ #include const std::array GridProfileParser::_profileTypes = { { - { 0x02, 0x00, "no data (yet)" }, - { 0x03, 0x00, "Germany - DE_VDE4105_2018" }, - { 0x0a, 0x00, "European - EN 50549-1:2019" }, - { 0x0c, 0x00, "AT Tor - EU_EN50438" }, - { 0x0d, 0x04, "France" }, - { 0x12, 0x00, "Poland - EU_EN50438" }, - { 0x37, 0x00, "Swiss - CH_NA EEA-NE7-CH2020" }, + { 0x02, 0x00, "US - NA_IEEE1547_240V" }, + { 0x03, 0x00, "DE - DE_VDE4105_2018" }, + { 0x03, 0x01, "XX - unknown" }, + { 0x0a, 0x00, "XX - EN 50549-1:2019" }, + { 0x0c, 0x00, "AT - AT_TOR_Erzeuger_default" }, + { 0x0d, 0x04, "FR -" }, + { 0x10, 0x00, "ES - ES_RD1699" }, + { 0x12, 0x00, "PL - EU_EN50438" }, + { 0x29, 0x00, "NL - NL_NEN-EN50549-1_2019" }, + { 0x37, 0x00, "CH - CH_NA EEA-NE7-CH2020" }, } }; constexpr frozen::map profileSection = { @@ -45,19 +48,19 @@ constexpr GridProfileItemDefinition_t make_value(frozen::string Name, frozen::st return v; } -constexpr frozen::map itemDefinitions = { +constexpr frozen::map itemDefinitions = { { 0x01, make_value("Nominale Voltage (NV)", "V", 10) }, { 0x02, make_value("Low Voltage 1 (LV1)", "V", 10) }, { 0x03, make_value("LV1 Maximum Trip Time (MTT)", "s", 10) }, { 0x04, make_value("High Voltage 1 (HV1)", "V", 10) }, { 0x05, make_value("HV1 Maximum Trip Time (MTT)", "s", 10) }, { 0x06, make_value("Low Voltage 2 (LV2)", "V", 10) }, - { 0x07, make_value("LV2 Maximum Trip Time (MTT)", "s", 10) }, + { 0x07, make_value("LV2 Maximum Trip Time (MTT)", "s", 100) }, { 0x08, make_value("High Voltage 2 (HV2)", "V", 10) }, - { 0x09, make_value("HV2 Maximum Trip Time (MTT)", "s", 10) }, + { 0x09, make_value("HV2 Maximum Trip Time (MTT)", "s", 100) }, { 0x0a, make_value("10mins Average High Voltage (AHV)", "V", 10) }, { 0x0b, make_value("High Voltage 3 (HV3)", "V", 10) }, - { 0x0c, make_value("HV3 Maximum Trip Time (MTT)", "s", 10) }, + { 0x0c, make_value("HV3 Maximum Trip Time (MTT)", "s", 100) }, { 0x0d, make_value("Nominal Frequency", "Hz", 100) }, { 0x0e, make_value("Low Frequency 1 (LF1)", "Hz", 100) }, { 0x0f, make_value("LF1 Maximum Trip Time (MTT)", "s", 10) }, @@ -94,7 +97,7 @@ constexpr frozen::map itemDefinition { 0x2e, make_value("Voltage Set Point V3", "V", 10) }, { 0x2f, make_value("Voltage Set Point V4", "V", 10) }, { 0x30, make_value("Reactive Set Point Q4", "%Pn", 10) }, - { 0x31, make_value("Setting Time (Tr)", "s", 10) }, + { 0x31, make_value("VV Setting Time (Tr)", "s", 10) }, { 0x32, make_value("SPF Function Activated", "bool", 1) }, { 0x33, make_value("Power Factor (PF)", "", 100) }, { 0x34, make_value("RPC Function Activated", "bool", 1) }, @@ -102,6 +105,15 @@ constexpr frozen::map itemDefinition { 0x36, make_value("WPF Function Activated", "bool", 1) }, { 0x37, make_value("Start of Power of WPF (Pstart)", "%Pn", 10) }, { 0x38, make_value("Power Factor ar Rated Power (PFRP)", "", 100) }, + { 0x39, make_value("Low Voltage 3 (LV3)", "V", 10) }, + { 0x3a, make_value("LV3 Maximum Trip Time (MTT)", "s", 100) }, + { 0x3b, make_value("Momentary Cessition Low Voltage", "V", 10) }, + { 0x3c, make_value("Momentary Cessition High Voltage", "V", 10) }, + { 0x3d, make_value("FW Settling Time (Tr)", "s", 10) }, + { 0x3e, make_value("LF2 Maximum Trip Time (MTT)", "s", 100) }, + { 0x3f, make_value("HF2 Maximum Trip time (MTT)", "s", 100) }, + { 0x40, make_value("Short Interruption Reconnect Time (SRT)", "s", 10) }, + { 0x41, make_value("Short Interruption Time (SIT)", "s", 10) }, { 0xff, make_value("Unkown Value", "", 1) }, }; @@ -114,6 +126,24 @@ const std::array GridProfileParse { 0x00, 0x00, 0x04 }, { 0x00, 0x00, 0x05 }, + // Version 0x01 + { 0x00, 0x01, 0x01 }, + { 0x00, 0x01, 0x02 }, + { 0x00, 0x01, 0x03 }, + { 0x00, 0x01, 0x04 }, + { 0x00, 0x01, 0x05 }, + { 0x00, 0x01, 0x08 }, + { 0x00, 0x01, 0x09 }, + + // Version 0x02 + { 0x00, 0x02, 0x01 }, + { 0x00, 0x02, 0x02 }, + { 0x00, 0x02, 0x03 }, + { 0x00, 0x02, 0x04 }, + { 0x00, 0x02, 0x05 }, + { 0x00, 0x02, 0x06 }, + { 0x00, 0x02, 0x07 }, + // Version 0x03 { 0x00, 0x03, 0x01 }, { 0x00, 0x03, 0x02 }, @@ -178,10 +208,10 @@ const std::array GridProfileParse { 0x00, 0x35, 0x07 }, { 0x00, 0x35, 0x08 }, { 0x00, 0x35, 0x09 }, - { 0x00, 0x35, 0xff }, - { 0x00, 0x35, 0xff }, - { 0x00, 0x35, 0xff }, - { 0x00, 0x35, 0xff }, + { 0x00, 0x35, 0x39 }, + { 0x00, 0x35, 0x3a }, + { 0x00, 0x35, 0x3b }, + { 0x00, 0x35, 0x3c }, // Frequency (H/LFRT) // Version 0x00 @@ -198,9 +228,9 @@ const std::array GridProfileParse { 0x10, 0x03, 0x10 }, { 0x10, 0x03, 0x11 }, { 0x10, 0x03, 0x12 }, - { 0x10, 0x03, 0x13 }, + { 0x10, 0x03, 0x3e }, { 0x10, 0x03, 0x14 }, - { 0x10, 0x03, 0x15 }, + { 0x10, 0x03, 0x3f }, // Island Detection (ID) // Version 0x00 @@ -220,8 +250,8 @@ const std::array GridProfileParse { 0x30, 0x07, 0x19 }, { 0x30, 0x07, 0x1a }, { 0x30, 0x07, 0x1b }, - { 0x30, 0x07, 0xff }, - { 0x30, 0x07, 0xff }, + { 0x30, 0x07, 0x40 }, + { 0x30, 0x07, 0x41 }, // Ramp Rates (RR) // Version 0x00 @@ -255,7 +285,7 @@ const std::array GridProfileParse { 0x50, 0x11, 0x1f }, { 0x50, 0x11, 0x20 }, { 0x50, 0x11, 0x21 }, - { 0x50, 0x11, 0x22 }, + { 0x50, 0x11, 0x3d }, // Volt Watt (VW) // Version 0x00 diff --git a/lib/Hoymiles/src/parser/GridProfileParser.h b/lib/Hoymiles/src/parser/GridProfileParser.h index 031891f3f..1be12e1d3 100644 --- a/lib/Hoymiles/src/parser/GridProfileParser.h +++ b/lib/Hoymiles/src/parser/GridProfileParser.h @@ -4,8 +4,8 @@ #include #define GRID_PROFILE_SIZE 141 -#define PROFILE_TYPE_COUNT 7 -#define SECTION_VALUE_COUNT 144 +#define PROFILE_TYPE_COUNT 10 +#define SECTION_VALUE_COUNT 158 typedef struct { uint8_t lIdx; diff --git a/platformio.ini b/platformio.ini index 16734b11a..d7f10e766 100644 --- a/platformio.ini +++ b/platformio.ini @@ -19,12 +19,16 @@ extra_configs = custom_ci_action = generic,generic_esp32,generic_esp32s3,generic_esp32s3_usb framework = arduino -platform = espressif32@6.3.2 +platform = espressif32@6.5.0 build_flags = -DPIOENV=\"$PIOENV\" -D_TASK_STD_FUNCTION=1 - -Wall -Wextra -Werror -Wunused -Wmisleading-indentation -Wduplicated-cond -Wlogical-op -Wnull-dereference + -Wall -Wextra -Wunused -Wmisleading-indentation -Wduplicated-cond -Wlogical-op -Wnull-dereference +; Have to remove -Werror because of +; https://github.com/espressif/arduino-esp32/issues/9044 and +; https://github.com/espressif/arduino-esp32/issues/9045 +; -Werror -std=c++17 -std=gnu++17 build_unflags = @@ -35,7 +39,7 @@ lib_deps = bblanchon/ArduinoJson @ ^6.21.4 https://github.com/bertmelis/espMqttClient.git#v1.5.0 nrf24/RF24 @ ^1.4.8 - olikraus/U8g2 @ ^2.35.8 + olikraus/U8g2 @ ^2.35.9 buelowp/sunset @ ^1.1.7 https://github.com/arkhipenko/TaskScheduler#testing https://github.com/arkhipenko/TaskScheduler#testing diff --git a/src/Battery.cpp b/src/Battery.cpp index d68db08e0..9fdc6e273 100644 --- a/src/Battery.cpp +++ b/src/Battery.cpp @@ -5,6 +5,7 @@ #include "PylontechCanReceiver.h" #include "JkBmsController.h" #include "VictronSmartShunt.h" +#include "MqttBattery.h" BatteryClass Battery; @@ -26,13 +27,14 @@ void BatteryClass::init(Scheduler& scheduler) _loopTask.setCallback(std::bind(&BatteryClass::loop, this)); _loopTask.setIterations(TASK_FOREVER); _loopTask.enable(); - std::lock_guard lock(_mutex); this->updateSettings(); } void BatteryClass::updateSettings() { + std::lock_guard lock(_mutex); + if (_upProvider) { _upProvider->deinit(); _upProvider = nullptr; @@ -52,6 +54,10 @@ void BatteryClass::updateSettings() _upProvider = std::make_unique(); if (!_upProvider->init(verboseLogging)) { _upProvider = nullptr; } break; + case 2: + _upProvider = std::make_unique(); + if (!_upProvider->init(verboseLogging)) { _upProvider = nullptr; } + break; case 3: _upProvider = std::make_unique(); if (!_upProvider->init(verboseLogging)) { _upProvider = nullptr; } @@ -62,7 +68,6 @@ void BatteryClass::updateSettings() } } - void BatteryClass::loop() { std::lock_guard lock(_mutex); diff --git a/src/BatteryStats.cpp b/src/BatteryStats.cpp index 34e2589ba..9e975a9c6 100644 --- a/src/BatteryStats.cpp +++ b/src/BatteryStats.cpp @@ -7,30 +7,44 @@ #include "JkBmsDataPoints.h" template -void BatteryStats::addLiveViewValue(JsonVariant& root, std::string const& name, - T&& value, std::string const& unit, uint8_t precision) const +static void addLiveViewInSection(JsonVariant& root, + std::string const& section, std::string const& name, + T&& value, std::string const& unit, uint8_t precision) { - auto jsonValue = root["values"][name]; + auto jsonValue = root["values"][section][name]; jsonValue["v"] = value; jsonValue["u"] = unit; jsonValue["d"] = precision; } -void BatteryStats::addLiveViewText(JsonVariant& root, std::string const& name, - std::string const& text) const +template +static void addLiveViewValue(JsonVariant& root, std::string const& name, + T&& value, std::string const& unit, uint8_t precision) +{ + addLiveViewInSection(root, "status", name, value, unit, precision); +} + +static void addLiveViewTextInSection(JsonVariant& root, + std::string const& section, std::string const& name, std::string const& text) { - root["values"][name] = text; + root["values"][section][name] = text; } -void BatteryStats::addLiveViewWarning(JsonVariant& root, std::string const& name, - bool warning) const +static void addLiveViewTextValue(JsonVariant& root, std::string const& name, + std::string const& text) +{ + addLiveViewTextInSection(root, "status", name, text); +} + +static void addLiveViewWarning(JsonVariant& root, std::string const& name, + bool warning) { if (!warning) { return; } root["issues"][name] = 1; } -void BatteryStats::addLiveViewAlarm(JsonVariant& root, std::string const& name, - bool alarm) const +static void addLiveViewAlarm(JsonVariant& root, std::string const& name, + bool alarm) { if (!alarm) { return; } root["issues"][name] = 2; @@ -57,9 +71,9 @@ void PylontechBatteryStats::getLiveViewData(JsonVariant& root) const addLiveViewValue(root, "current", _current, "A", 1); addLiveViewValue(root, "temperature", _temperature, "°C", 1); - addLiveViewText(root, "chargeEnabled", (_chargeEnabled?"yes":"no")); - addLiveViewText(root, "dischargeEnabled", (_dischargeEnabled?"yes":"no")); - addLiveViewText(root, "chargeImmediately", (_chargeImmediately?"yes":"no")); + addLiveViewTextValue(root, "chargeEnabled", (_chargeEnabled?"yes":"no")); + addLiveViewTextValue(root, "dischargeEnabled", (_dischargeEnabled?"yes":"no")); + addLiveViewTextValue(root, "chargeImmediately", (_chargeImmediately?"yes":"no")); // alarms and warnings go into the "Issues" card of the web application addLiveViewWarning(root, "highCurrentDischarge", _warningHighCurrentDischarge); @@ -108,38 +122,11 @@ void JkBmsBatteryStats::getJsonData(JsonVariant& root, bool verbose) const addLiveViewValue(root, "power", current * voltage , "W", 2); } - if (verbose) { - auto oTemperatureOne = _dataPoints.get(); - if (oTemperatureOne.has_value()) { - addLiveViewValue(root, "batOneTemp", *oTemperatureOne, "°C", 0); - } - } - - if (verbose) { - auto oTemperatureTwo = _dataPoints.get(); - if (oTemperatureTwo.has_value()) { - addLiveViewValue(root, "batTwoTemp", *oTemperatureTwo, "°C", 0); - } - } - auto oTemperatureBms = _dataPoints.get(); if (oTemperatureBms.has_value()) { addLiveViewValue(root, "bmsTemp", *oTemperatureBms, "°C", 0); } - if (_cellVoltageTimestamp > 0) { - if (verbose) { - addLiveViewValue(root, "cellMinVoltage", static_cast(_cellMinMilliVolt)/1000, "V", 3); - } - - addLiveViewValue(root, "cellAvgVoltage", static_cast(_cellAvgMilliVolt)/1000, "V", 3); - - if (verbose) { - addLiveViewValue(root, "cellMaxVoltage", static_cast(_cellMaxMilliVolt)/1000, "V", 3); - addLiveViewValue(root, "cellDiffVoltage", (_cellMaxMilliVolt - _cellMinMilliVolt), "mV", 0); - } - } - // labels BatteryChargeEnabled, BatteryDischargeEnabled, and // BalancingEnabled refer to the user setting. we want to show the // actual MOSFETs' state which control whether charging and discharging @@ -148,11 +135,32 @@ void JkBmsBatteryStats::getJsonData(JsonVariant& root, bool verbose) const if (oStatus.has_value()) { using Bits = JkBms::StatusBits; auto chargeEnabled = *oStatus & static_cast(Bits::ChargingActive); - addLiveViewText(root, "chargeEnabled", (chargeEnabled?"yes":"no")); + addLiveViewTextValue(root, "chargeEnabled", (chargeEnabled?"yes":"no")); auto dischargeEnabled = *oStatus & static_cast(Bits::DischargingActive); - addLiveViewText(root, "dischargeEnabled", (dischargeEnabled?"yes":"no")); + addLiveViewTextValue(root, "dischargeEnabled", (dischargeEnabled?"yes":"no")); + } + + auto oTemperatureOne = _dataPoints.get(); + if (oTemperatureOne.has_value()) { + addLiveViewInSection(root, "cells", "batOneTemp", *oTemperatureOne, "°C", 0); + } + + auto oTemperatureTwo = _dataPoints.get(); + if (oTemperatureTwo.has_value()) { + addLiveViewInSection(root, "cells", "batTwoTemp", *oTemperatureTwo, "°C", 0); + } + + if (_cellVoltageTimestamp > 0) { + addLiveViewInSection(root, "cells", "cellMinVoltage", static_cast(_cellMinMilliVolt)/1000, "V", 3); + addLiveViewInSection(root, "cells", "cellAvgVoltage", static_cast(_cellAvgMilliVolt)/1000, "V", 3); + addLiveViewInSection(root, "cells", "cellMaxVoltage", static_cast(_cellMaxMilliVolt)/1000, "V", 3); + addLiveViewInSection(root, "cells", "cellDiffVoltage", (_cellMaxMilliVolt - _cellMinMilliVolt), "mV", 0); + } + + if (oStatus.has_value()) { + using Bits = JkBms::StatusBits; auto balancingActive = *oStatus & static_cast(Bits::BalancingActive); - addLiveViewText(root, "balancingActive", (balancingActive?"yes":"no")); + addLiveViewTextInSection(root, "cells", "balancingActive", (balancingActive?"yes":"no")); } auto oAlarms = _dataPoints.get(); diff --git a/src/Configuration.cpp b/src/Configuration.cpp index 485d03519..9c166ab00 100644 --- a/src/Configuration.cpp +++ b/src/Configuration.cpp @@ -199,6 +199,7 @@ bool ConfigurationClass::write() battery["provider"] = config.Battery.Provider; battery["jkbms_interface"] = config.Battery.JkBmsInterface; battery["jkbms_polling_interval"] = config.Battery.JkBmsPollingInterval; + battery["mqtt_topic"] = config.Battery.MqttTopic; JsonObject huawei = doc.createNestedObject("huawei"); huawei["enabled"] = config.Huawei.Enabled; @@ -435,6 +436,7 @@ bool ConfigurationClass::read() config.Battery.Provider = battery["provider"] | BATTERY_PROVIDER; config.Battery.JkBmsInterface = battery["jkbms_interface"] | BATTERY_JKBMS_INTERFACE; config.Battery.JkBmsPollingInterval = battery["jkbms_polling_interval"] | BATTERY_JKBMS_POLLING_INTERVAL; + strlcpy(config.Battery.MqttTopic, battery["mqtt_topic"] | "", sizeof(config.Battery.MqttTopic)); JsonObject huawei = doc["huawei"]; config.Huawei.Enabled = huawei["enabled"] | HUAWEI_ENABLED; diff --git a/src/Display_Graphic.cpp b/src/Display_Graphic.cpp index 94a0de845..feb4ce804 100644 --- a/src/Display_Graphic.cpp +++ b/src/Display_Graphic.cpp @@ -12,6 +12,7 @@ std::map { DisplayType_t::PCD8544, [](uint8_t reset, uint8_t clock, uint8_t data, uint8_t cs) { return new U8G2_PCD8544_84X48_F_4W_HW_SPI(U8G2_R0, cs, data, reset); } }, { DisplayType_t::SSD1306, [](uint8_t reset, uint8_t clock, uint8_t data, uint8_t cs) { return new U8G2_SSD1306_128X64_NONAME_F_HW_I2C(U8G2_R0, reset, clock, data); } }, { DisplayType_t::SH1106, [](uint8_t reset, uint8_t clock, uint8_t data, uint8_t cs) { return new U8G2_SH1106_128X64_NONAME_F_HW_I2C(U8G2_R0, reset, clock, data); } }, + { DisplayType_t::SSD1309, [](uint8_t reset, uint8_t clock, uint8_t data, uint8_t cs) { return new U8G2_SSD1309_128X64_NONAME0_F_HW_I2C(U8G2_R0, reset, clock, data); } }, }; // Language defintion, respect order in languages[] and translation lists @@ -45,20 +46,20 @@ DisplayGraphicClass::~DisplayGraphicClass() void DisplayGraphicClass::init(Scheduler& scheduler, const DisplayType_t type, const uint8_t data, const uint8_t clk, const uint8_t cs, const uint8_t reset) { _display_type = type; - if (_display_type > DisplayType_t::None) { + if (isValidDisplay()) { auto constructor = display_types[_display_type]; _display = constructor(reset, clk, data, cs); _display->begin(); setContrast(DISPLAY_CONTRAST); setStatus(true); _diagram.init(scheduler, _display); - } - scheduler.addTask(_loopTask); - _loopTask.setCallback(std::bind(&DisplayGraphicClass::loop, this)); - _loopTask.setIterations(TASK_FOREVER); - _loopTask.setInterval(_period); - _loopTask.enable(); + scheduler.addTask(_loopTask); + _loopTask.setCallback(std::bind(&DisplayGraphicClass::loop, this)); + _loopTask.setIterations(TASK_FOREVER); + _loopTask.setInterval(_period); + _loopTask.enable(); + } } void DisplayGraphicClass::calcLineHeights() @@ -86,6 +87,11 @@ void DisplayGraphicClass::setFont(const uint8_t line) } } +bool DisplayGraphicClass::isValidDisplay() +{ + return _display_type > DisplayType_t::None && _display_type < DisplayType_Max; +} + void DisplayGraphicClass::printText(const char* text, const uint8_t line) { uint8_t dispX; @@ -102,7 +108,7 @@ void DisplayGraphicClass::printText(const char* text, const uint8_t line) void DisplayGraphicClass::setOrientation(const uint8_t rotation) { - if (_display_type == DisplayType_t::None) { + if (!isValidDisplay()) { return; } @@ -132,7 +138,7 @@ void DisplayGraphicClass::setLanguage(const uint8_t language) void DisplayGraphicClass::setStartupDisplay() { - if (_display_type == DisplayType_t::None) { + if (!isValidDisplay()) { return; } @@ -148,10 +154,6 @@ DisplayGraphicDiagramClass& DisplayGraphicClass::Diagram() void DisplayGraphicClass::loop() { - if (_display_type == DisplayType_t::None) { - return; - } - _loopTask.setInterval(_period); _display->clearBuffer(); @@ -215,7 +217,7 @@ void DisplayGraphicClass::loop() void DisplayGraphicClass::setContrast(const uint8_t contrast) { - if (_display_type == DisplayType_t::None) { + if (!isValidDisplay()) { return; } _display->setContrast(contrast * 2.55f); diff --git a/src/Display_Graphic_Diagram.cpp b/src/Display_Graphic_Diagram.cpp index 489f295fa..0fd136184 100644 --- a/src/Display_Graphic_Diagram.cpp +++ b/src/Display_Graphic_Diagram.cpp @@ -62,20 +62,28 @@ void DisplayGraphicDiagramClass::updatePeriod() void DisplayGraphicDiagramClass::redraw(uint8_t screenSaverOffsetX) { - const uint8_t graphPosX = DIAG_POSX + ((screenSaverOffsetX > 3) ? 1 : 0); // screenSaverOffsetX expected to be in range 0..6 + // screenSaverOffsetX expected to be in range 0..6 + const uint8_t graphPosX = DIAG_POSX + ((screenSaverOffsetX > 3) ? 1 : 0); const uint8_t graphPosY = DIAG_POSY + ((screenSaverOffsetX > 3) ? 1 : 0); + const uint8_t horizontal_line_y = graphPosY + CHART_HEIGHT - 1; + const uint8_t arrow_size = 2; + // draw diagram axis _display->drawVLine(graphPosX, graphPosY, CHART_HEIGHT); - _display->drawHLine(graphPosX, graphPosY + CHART_HEIGHT - 1, CHART_WIDTH); + _display->drawHLine(graphPosX, horizontal_line_y, CHART_WIDTH); + + // UP-arrow + _display->drawLine(graphPosX, graphPosY, graphPosX + arrow_size, graphPosY + arrow_size); + _display->drawLine(graphPosX, graphPosY, graphPosX - arrow_size, graphPosY + arrow_size); - _display->drawLine(graphPosX + 1, graphPosY + 1, graphPosX + 2, graphPosY + 2); // UP-arrow - _display->drawLine(graphPosX - 2, graphPosY + 2, graphPosX - 1, graphPosY + 1); // UP-arrow - _display->drawLine(graphPosX + CHART_WIDTH - 3, graphPosY + CHART_HEIGHT - 3, graphPosX + CHART_WIDTH - 2, graphPosY + CHART_HEIGHT - 2); // LEFT-arrow - _display->drawLine(graphPosX + CHART_WIDTH - 3, graphPosY + CHART_HEIGHT + 1, graphPosX + CHART_WIDTH - 2, graphPosY + CHART_HEIGHT); // LEFT-arrow + // LEFT-arrow + _display->drawLine(graphPosX + CHART_WIDTH - 1, horizontal_line_y, graphPosX + CHART_WIDTH - 1 - arrow_size, horizontal_line_y - arrow_size); + _display->drawLine(graphPosX + CHART_WIDTH - 1, horizontal_line_y, graphPosX + CHART_WIDTH - 1 - arrow_size, horizontal_line_y + arrow_size); // draw AC value - _display->setFont(u8g2_font_tom_thumb_4x6_mr); // 4 pixels per char + // 4 pixels per char + _display->setFont(u8g2_font_tom_thumb_4x6_mr); char fmtText[7]; const float maxWatts = *std::max_element(_graphValues.begin(), _graphValues.end()); if (maxWatts > 999) { @@ -84,25 +92,24 @@ void DisplayGraphicDiagramClass::redraw(uint8_t screenSaverOffsetX) snprintf(fmtText, sizeof(fmtText), "%dW", static_cast(maxWatts)); } const uint8_t textLength = strlen(fmtText); - const uint8_t space_and_arrow_pixels = 2; - _display->drawStr(graphPosX - space_and_arrow_pixels - (textLength * 4), graphPosY + 5, fmtText); + _display->drawStr(graphPosX - arrow_size - textLength * 4, graphPosY + 5, fmtText); // draw chart const float scaleFactor = maxWatts / CHART_HEIGHT; uint8_t axisTick = 1; - for (int i = 0; i < _graphValuesCount; i++) { - if (scaleFactor > 0) { - if (i == 0) { - _display->drawPixel(graphPosX + 1 + i, graphPosY + CHART_HEIGHT - ((_graphValues[i] / scaleFactor) + 0.5)); // + 0.5 to round mathematical - } else { - _display->drawLine(graphPosX + i, graphPosY + CHART_HEIGHT - ((_graphValues[i - 1] / scaleFactor) + 0.5), graphPosX + 1 + i, graphPosY + CHART_HEIGHT - ((_graphValues[i] / scaleFactor) + 0.5)); - } - } - + for (uint8_t i = 1; i < _graphValuesCount; i++) { // draw one tick per hour to the x-axis if (i * getSecondsPerDot() > (3600u * axisTick)) { _display->drawPixel(graphPosX + 1 + i, graphPosY + CHART_HEIGHT); axisTick++; } + + if (scaleFactor == 0) { + continue; + } + + _display->drawLine( + graphPosX + i - 1, horizontal_line_y - std::max(0, _graphValues[i - 1] / scaleFactor - 0.5), + graphPosX + i, horizontal_line_y - std::max(0, _graphValues[i] / scaleFactor - 0.5)); } } diff --git a/src/MessageOutput.cpp b/src/MessageOutput.cpp index f602bee15..027ce20c8 100644 --- a/src/MessageOutput.cpp +++ b/src/MessageOutput.cpp @@ -2,10 +2,9 @@ /* * Copyright (C) 2022-2023 Thomas Basler and others */ +#include #include "MessageOutput.h" -#include - MessageOutputClass MessageOutput; void MessageOutputClass::init(Scheduler& scheduler) @@ -18,46 +17,97 @@ void MessageOutputClass::init(Scheduler& scheduler) void MessageOutputClass::register_ws_output(AsyncWebSocket* output) { + std::lock_guard lock(_msgLock); + _ws = output; } +void MessageOutputClass::serialWrite(MessageOutputClass::message_t const& m) +{ + // on ESP32-S3, Serial.flush() blocks until a serial console is attached. + // operator bool() of HWCDC returns false if the device is not attached to + // a USB host. in general it makes sense to skip writing entirely if the + // default serial port is not ready. + if (!Serial) { return; } + + size_t written = 0; + while (written < m.size()) { + written += Serial.write(m.data() + written, m.size() - written); + } + Serial.flush(); +} + size_t MessageOutputClass::write(uint8_t c) { - if (_buff_pos < BUFFER_SIZE) { - std::lock_guard lock(_msgLock); - _buffer[_buff_pos] = c; - _buff_pos++; - } else { - _forceSend = true; + std::lock_guard lock(_msgLock); + + auto res = _task_messages.emplace(xTaskGetCurrentTaskHandle(), message_t()); + auto iter = res.first; + auto& message = iter->second; + + message.push_back(c); + + if (c == '\n') { + serialWrite(message); + _lines.emplace(std::move(message)); + _task_messages.erase(iter); } - return Serial.write(c); + return 1; } -size_t MessageOutputClass::write(const uint8_t* buffer, size_t size) +size_t MessageOutputClass::write(const uint8_t *buffer, size_t size) { std::lock_guard lock(_msgLock); - if (_buff_pos + size < BUFFER_SIZE) { - memcpy(&_buffer[_buff_pos], buffer, size); - _buff_pos += size; + + auto res = _task_messages.emplace(xTaskGetCurrentTaskHandle(), message_t()); + auto iter = res.first; + auto& message = iter->second; + + message.reserve(message.size() + size); + + for (size_t idx = 0; idx < size; ++idx) { + uint8_t c = buffer[idx]; + + message.push_back(c); + + if (c == '\n') { + serialWrite(message); + _lines.emplace(std::move(message)); + message.clear(); + message.reserve(size - idx - 1); + } } - _forceSend = true; - return Serial.write(buffer, size); + if (message.empty()) { _task_messages.erase(iter); } + + return size; } void MessageOutputClass::loop() { - // Send data via websocket if either time is over or buffer is full - if (_forceSend || (millis() - _lastSend > 1000)) { - std::lock_guard lock(_msgLock); - if (_ws && _buff_pos > 0) { - _ws->textAll(_buffer, _buff_pos); - _buff_pos = 0; + std::lock_guard lock(_msgLock); + + // clean up (possibly filled) buffers of deleted tasks + auto map_iter = _task_messages.begin(); + while (map_iter != _task_messages.end()) { + if (eTaskGetState(map_iter->first) == eDeleted) { + map_iter = _task_messages.erase(map_iter); + continue; } - if (_forceSend) { - _buff_pos = 0; + + ++map_iter; + } + + if (!_ws) { + while (!_lines.empty()) { + _lines.pop(); // do not hog memory } - _forceSend = false; + return; + } + + while (!_lines.empty() && _ws->availableForWriteAll()) { + _ws->textAll(std::make_shared(std::move(_lines.front()))); + _lines.pop(); } } \ No newline at end of file diff --git a/src/MqttBattery.cpp b/src/MqttBattery.cpp new file mode 100644 index 000000000..9e1992429 --- /dev/null +++ b/src/MqttBattery.cpp @@ -0,0 +1,65 @@ +#include + +#include "Configuration.h" +#include "MqttBattery.h" +#include "MqttSettings.h" +#include "MessageOutput.h" + +bool MqttBattery::init(bool verboseLogging) +{ + _verboseLogging = verboseLogging; + + auto const& config = Configuration.get(); + _socTopic = config.Battery.MqttTopic; + + if (_socTopic.isEmpty()) { return false; } + + MqttSettings.subscribe(_socTopic, 0/*QoS*/, + std::bind(&MqttBattery::onMqttMessage, + this, std::placeholders::_1, std::placeholders::_2, + std::placeholders::_3, std::placeholders::_4, + std::placeholders::_5, std::placeholders::_6) + ); + + if (_verboseLogging) { + MessageOutput.printf("MqttBattery: Subscribed to '%s'\r\n", + _socTopic.c_str()); + } + + return true; +} + +void MqttBattery::deinit() +{ + if (_socTopic.isEmpty()) { return; } + MqttSettings.unsubscribe(_socTopic); +} + +void MqttBattery::onMqttMessage(espMqttClientTypes::MessageProperties const& properties, + char const* topic, uint8_t const* payload, size_t len, size_t index, size_t total) +{ + float soc = 0; + std::string value(reinterpret_cast(payload), len); + + try { + soc = std::stof(value); + } + catch(std::invalid_argument const& e) { + MessageOutput.printf("MqttBattery: Cannot parse payload '%s' in topic '%s' as float\r\n", + value.c_str(), topic); + return; + } + + if (soc < 0 || soc > 100) { + MessageOutput.printf("MqttBattery: Implausible SoC '%.2f' in topic '%s'\r\n", + soc, topic); + return; + } + + _stats->setSoC(static_cast(soc)); + + if (_verboseLogging) { + MessageOutput.printf("MqttBattery: Updated SoC to %d from '%s'\r\n", + static_cast(soc), topic); + } +} diff --git a/src/MqttHandleHuawei.cpp b/src/MqttHandleHuawei.cpp index 06e5d22ad..4330dc7c2 100644 --- a/src/MqttHandleHuawei.cpp +++ b/src/MqttHandleHuawei.cpp @@ -10,12 +10,6 @@ #include "WebApi_Huawei.h" #include -#define TOPIC_SUB_LIMIT_ONLINE_VOLTAGE "limit_online_voltage" -#define TOPIC_SUB_LIMIT_ONLINE_CURRENT "limit_online_current" -#define TOPIC_SUB_LIMIT_OFFLINE_VOLTAGE "limit_offline_voltage" -#define TOPIC_SUB_LIMIT_OFFLINE_CURRENT "limit_offline_current" -#define TOPIC_SUB_MODE "mode" - MqttHandleHuaweiClass MqttHandleHuawei; void MqttHandleHuaweiClass::init(Scheduler& scheduler) @@ -25,19 +19,22 @@ void MqttHandleHuaweiClass::init(Scheduler& scheduler) _loopTask.setIterations(TASK_FOREVER); _loopTask.enable(); - using std::placeholders::_1; - using std::placeholders::_2; - using std::placeholders::_3; - using std::placeholders::_4; - using std::placeholders::_5; - using std::placeholders::_6; + String const& prefix = MqttSettings.getPrefix(); + + auto subscribe = [&prefix, this](char const* subTopic, Topic t) { + String fullTopic(prefix + "huawei/cmd/" + subTopic); + MqttSettings.subscribe(fullTopic.c_str(), 0, + std::bind(&MqttHandleHuaweiClass::onMqttMessage, this, t, + std::placeholders::_1, std::placeholders::_2, + std::placeholders::_3, std::placeholders::_4, + std::placeholders::_5, std::placeholders::_6)); + }; - String topic = MqttSettings.getPrefix(); - MqttSettings.subscribe(String(topic + "huawei/cmd/" + TOPIC_SUB_LIMIT_ONLINE_VOLTAGE).c_str(), 0, std::bind(&MqttHandleHuaweiClass::onMqttMessage, this, _1, _2, _3, _4, _5, _6)); - MqttSettings.subscribe(String(topic + "huawei/cmd/" + TOPIC_SUB_LIMIT_ONLINE_CURRENT).c_str(), 0, std::bind(&MqttHandleHuaweiClass::onMqttMessage, this, _1, _2, _3, _4, _5, _6)); - MqttSettings.subscribe(String(topic + "huawei/cmd/" + TOPIC_SUB_LIMIT_OFFLINE_VOLTAGE).c_str(), 0, std::bind(&MqttHandleHuaweiClass::onMqttMessage, this, _1, _2, _3, _4, _5, _6)); - MqttSettings.subscribe(String(topic + "huawei/cmd/" + TOPIC_SUB_LIMIT_OFFLINE_CURRENT).c_str(), 0, std::bind(&MqttHandleHuaweiClass::onMqttMessage, this, _1, _2, _3, _4, _5, _6)); - MqttSettings.subscribe(String(topic + "huawei/cmd/" + TOPIC_SUB_MODE).c_str(), 0, std::bind(&MqttHandleHuaweiClass::onMqttMessage, this, _1, _2, _3, _4, _5, _6)); + subscribe("limit_online_voltage", Topic::LimitOnlineVoltage); + subscribe("limit_online_current", Topic::LimitOnlineCurrent); + subscribe("limit_offline_voltage", Topic::LimitOfflineVoltage); + subscribe("limit_offline_current", Topic::LimitOfflineCurrent); + subscribe("mode", Topic::Mode); _lastPublish = millis(); @@ -46,13 +43,21 @@ void MqttHandleHuaweiClass::init(Scheduler& scheduler) void MqttHandleHuaweiClass::loop() { - if (!MqttSettings.getConnected() ) { + const CONFIG_T& config = Configuration.get(); + + std::unique_lock mqttLock(_mqttMutex); + + if (!config.Huawei.Enabled) { + _mqttCallbacks.clear(); return; } - const CONFIG_T& config = Configuration.get(); + for (auto& callback : _mqttCallbacks) { callback(); } + _mqttCallbacks.clear(); - if (!config.Huawei.Enabled) { + mqttLock.unlock(); + + if (!MqttSettings.getConnected() ) { return; } @@ -78,76 +83,79 @@ void MqttHandleHuaweiClass::loop() } -void MqttHandleHuaweiClass::onMqttMessage(const espMqttClientTypes::MessageProperties& properties, const char* topic, const uint8_t* payload, size_t len, size_t index, size_t total) +void MqttHandleHuaweiClass::onMqttMessage(Topic t, + const espMqttClientTypes::MessageProperties& properties, + const char* topic, const uint8_t* payload, size_t len, + size_t index, size_t total) { - const CONFIG_T& config = Configuration.get(); - - // ignore messages if Huawei is disabled - if (!config.Huawei.Enabled) { - return; + std::string strValue(reinterpret_cast(payload), len); + float payload_val = -1; + try { + payload_val = std::stof(strValue); } - - char token_topic[MQTT_MAX_TOPIC_STRLEN + 40]; // respect all subtopics - strncpy(token_topic, topic, MQTT_MAX_TOPIC_STRLEN + 40); // convert const char* to char* - - char* setting; - char* rest = &token_topic[strlen(config.Mqtt.Topic)]; - - strtok_r(rest, "/", &rest); // Remove "huawei" - strtok_r(rest, "/", &rest); // Remove "cmd" - - setting = strtok_r(rest, "/", &rest); - - if (setting == NULL) { + catch (std::invalid_argument const& e) { + MessageOutput.printf("Huawei MQTT handler: cannot parse payload of topic '%s' as float: %s\r\n", + topic, strValue.c_str()); return; } - char* strlimit = new char[len + 1]; - memcpy(strlimit, payload, len); - strlimit[len] = '\0'; - float payload_val = strtof(strlimit, NULL); - delete[] strlimit; - - if (!strcmp(setting, TOPIC_SUB_LIMIT_ONLINE_VOLTAGE)) { - // Set voltage limit - MessageOutput.printf("Limit Voltage: %f V\r\n", payload_val); - HuaweiCan.setValue(payload_val, HUAWEI_ONLINE_VOLTAGE); - - } else if (!strcmp(setting, TOPIC_SUB_LIMIT_OFFLINE_VOLTAGE)) { - // Set current limit - MessageOutput.printf("Offline Limit Voltage: %f V\r\n", payload_val); - HuaweiCan.setValue(payload_val, HUAWEI_OFFLINE_VOLTAGE); - - } else if (!strcmp(setting, TOPIC_SUB_LIMIT_ONLINE_CURRENT)) { - // Set current limit - MessageOutput.printf("Limit Current: %f A\r\n", payload_val); - HuaweiCan.setValue(payload_val, HUAWEI_ONLINE_CURRENT); - - } else if (!strcmp(setting, TOPIC_SUB_LIMIT_OFFLINE_CURRENT)) { - // Set current limit - MessageOutput.printf("Offline Limit Current: %f A\r\n", payload_val); - HuaweiCan.setValue(payload_val, HUAWEI_OFFLINE_CURRENT); - - } else if (!strcmp(setting, TOPIC_SUB_MODE)) { - // Control power on/off - if(payload_val == 3) { - MessageOutput.println("[Huawei MQTT::] Received MQTT msg. New mode: Full internal control"); - HuaweiCan.setMode(HUAWEI_MODE_AUTO_INT); - } - - if(payload_val == 2) { - MessageOutput.println("[Huawei MQTT::] Received MQTT msg. New mode: Internal on/off control, external power limit"); - HuaweiCan.setMode(HUAWEI_MODE_AUTO_EXT); - } - - if(payload_val == 1) { - MessageOutput.println("[Huawei MQTT::] Received MQTT msg. New mode: Turned ON"); - HuaweiCan.setMode(HUAWEI_MODE_ON); - } - - if(payload_val == 0) { - MessageOutput.println("[Huawei MQTT::] Received MQTT msg. New mode: Turned OFF"); - HuaweiCan.setMode(HUAWEI_MODE_OFF); - } - } + std::lock_guard mqttLock(_mqttMutex); + + switch (t) { + case Topic::LimitOnlineVoltage: + MessageOutput.printf("Limit Voltage: %f V\r\n", payload_val); + _mqttCallbacks.push_back(std::bind(&HuaweiCanClass::setValue, + &HuaweiCan, payload_val, HUAWEI_ONLINE_VOLTAGE)); + break; + + case Topic::LimitOfflineVoltage: + MessageOutput.printf("Offline Limit Voltage: %f V\r\n", payload_val); + _mqttCallbacks.push_back(std::bind(&HuaweiCanClass::setValue, + &HuaweiCan, payload_val, HUAWEI_OFFLINE_VOLTAGE)); + break; + + case Topic::LimitOnlineCurrent: + MessageOutput.printf("Limit Current: %f A\r\n", payload_val); + _mqttCallbacks.push_back(std::bind(&HuaweiCanClass::setValue, + &HuaweiCan, payload_val, HUAWEI_ONLINE_CURRENT)); + break; + + case Topic::LimitOfflineCurrent: + MessageOutput.printf("Offline Limit Current: %f A\r\n", payload_val); + _mqttCallbacks.push_back(std::bind(&HuaweiCanClass::setValue, + &HuaweiCan, payload_val, HUAWEI_OFFLINE_CURRENT)); + break; + + case Topic::Mode: + switch (static_cast(payload_val)) { + case 3: + MessageOutput.println("[Huawei MQTT::] Received MQTT msg. New mode: Full internal control"); + _mqttCallbacks.push_back(std::bind(&HuaweiCanClass::setMode, + &HuaweiCan, HUAWEI_MODE_AUTO_INT)); + break; + + case 2: + MessageOutput.println("[Huawei MQTT::] Received MQTT msg. New mode: Internal on/off control, external power limit"); + _mqttCallbacks.push_back(std::bind(&HuaweiCanClass::setMode, + &HuaweiCan, HUAWEI_MODE_AUTO_EXT)); + break; + + case 1: + MessageOutput.println("[Huawei MQTT::] Received MQTT msg. New mode: Turned ON"); + _mqttCallbacks.push_back(std::bind(&HuaweiCanClass::setMode, + &HuaweiCan, HUAWEI_MODE_ON)); + break; + + case 0: + MessageOutput.println("[Huawei MQTT::] Received MQTT msg. New mode: Turned OFF"); + _mqttCallbacks.push_back(std::bind(&HuaweiCanClass::setMode, + &HuaweiCan, HUAWEI_MODE_OFF)); + break; + + default: + MessageOutput.printf("[Huawei MQTT::] Invalid mode %.0f\r\n", payload_val); + break; + } + break; + } } \ No newline at end of file diff --git a/src/MqttHandlePowerLimiter.cpp b/src/MqttHandlePowerLimiter.cpp index 9a4a7f77e..b807fef0e 100644 --- a/src/MqttHandlePowerLimiter.cpp +++ b/src/MqttHandlePowerLimiter.cpp @@ -34,11 +34,21 @@ void MqttHandlePowerLimiterClass::init(Scheduler& scheduler) void MqttHandlePowerLimiterClass::loop() { - if (!MqttSettings.getConnected() ) { + std::unique_lock mqttLock(_mqttMutex); + + const CONFIG_T& config = Configuration.get(); + + if (!config.PowerLimiter.Enabled) { + _mqttCallbacks.clear(); return; } - const CONFIG_T& config = Configuration.get(); + for (auto& callback : _mqttCallbacks) { callback(); } + _mqttCallbacks.clear(); + + mqttLock.unlock(); + + if (!MqttSettings.getConnected() ) { return; } if ((millis() - _lastPublish) > (config.Mqtt.PublishInterval * 1000) ) { auto val = static_cast(PowerLimiter.getMode()); @@ -53,13 +63,6 @@ void MqttHandlePowerLimiterClass::loop() void MqttHandlePowerLimiterClass::onCmdMode(const espMqttClientTypes::MessageProperties& properties, const char* topic, const uint8_t* payload, size_t len, size_t index, size_t total) { - const CONFIG_T& config = Configuration.get(); - - // ignore messages if PowerLimiter is disabled - if (!config.PowerLimiter.Enabled) { - return; - } - std::string strValue(reinterpret_cast(payload), len); int intValue = -1; try { @@ -71,19 +74,24 @@ void MqttHandlePowerLimiterClass::onCmdMode(const espMqttClientTypes::MessagePro return; } + std::lock_guard mqttLock(_mqttMutex); + using Mode = PowerLimiterClass::Mode; switch (static_cast(intValue)) { case Mode::UnconditionalFullSolarPassthrough: MessageOutput.println("Power limiter unconditional full solar PT"); - PowerLimiter.setMode(Mode::UnconditionalFullSolarPassthrough); + _mqttCallbacks.push_back(std::bind(&PowerLimiterClass::setMode, + &PowerLimiter, Mode::UnconditionalFullSolarPassthrough)); break; case Mode::Disabled: MessageOutput.println("Power limiter disabled (override)"); - PowerLimiter.setMode(Mode::Disabled); + _mqttCallbacks.push_back(std::bind(&PowerLimiterClass::setMode, + &PowerLimiter, Mode::Disabled)); break; case Mode::Normal: MessageOutput.println("Power limiter normal operation"); - PowerLimiter.setMode(Mode::Normal); + _mqttCallbacks.push_back(std::bind(&PowerLimiterClass::setMode, + &PowerLimiter, Mode::Normal)); break; default: MessageOutput.printf("PowerLimiter - unknown mode %d\r\n", intValue); diff --git a/src/WebApi_battery.cpp b/src/WebApi_battery.cpp index 05897840a..1718dd6b6 100644 --- a/src/WebApi_battery.cpp +++ b/src/WebApi_battery.cpp @@ -43,6 +43,7 @@ void WebApiBatteryClass::onStatus(AsyncWebServerRequest* request) root[F("provider")] = config.Battery.Provider; root[F("jkbms_interface")] = config.Battery.JkBmsInterface; root[F("jkbms_polling_interval")] = config.Battery.JkBmsPollingInterval; + root[F("mqtt_topic")] = config.Battery.MqttTopic; response->setLength(); request->send(response); @@ -106,6 +107,7 @@ void WebApiBatteryClass::onAdminPost(AsyncWebServerRequest* request) config.Battery.Provider = root[F("provider")].as(); config.Battery.JkBmsInterface = root[F("jkbms_interface")].as(); config.Battery.JkBmsPollingInterval = root[F("jkbms_polling_interval")].as(); + strlcpy(config.Battery.MqttTopic, root[F("mqtt_topic")].as().c_str(), sizeof(config.Battery.MqttTopic)); Configuration.write(); retMsg[F("type")] = F("success"); diff --git a/src/WebApi_ws_live.cpp b/src/WebApi_ws_live.cpp index 20308d779..f3a427292 100644 --- a/src/WebApi_ws_live.cpp +++ b/src/WebApi_ws_live.cpp @@ -68,6 +68,14 @@ void WebApiWsLiveClass::loop() try { std::lock_guard lock(_mutex); DynamicJsonDocument root(4200 * INV_MAX_COUNT); // TODO(helge) check if this calculation is correct + + // TODO(helge) temporary dump of memory usage if allocation of DynamicJsonDocument fails (will be fixed in upstream repo) + if (root.capacity() == 0) { + MessageOutput.printf("Calling /api/livedata/status has temporarily run out of resources. Reason: Alloc memory for DynamicJsonDocument failed (FreeHeap = %d, MaxAllocHeap = %d, MinFreeHeap = %d).\r\n", ESP.getFreeHeap(), ESP.getMaxAllocHeap(), ESP.getMinFreeHeap()); + _lastWsPublish = millis(); + return; + } + JsonVariant var = root; generateJsonResponse(var); diff --git a/webapp/package.json b/webapp/package.json index 60e57e946..0b3f4b119 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -18,7 +18,7 @@ "mitt": "^3.0.1", "sortablejs": "^1.15.1", "spark-md5": "^3.0.2", - "vue": "^3.3.13", + "vue": "^3.4.3", "vue-i18n": "^9.8.0", "vue-router": "^4.2.5" }, @@ -27,21 +27,21 @@ "@rushstack/eslint-patch": "^1.6.1", "@tsconfig/node18": "^18.2.2", "@types/bootstrap": "^5.2.10", - "@types/node": "^20.10.5", + "@types/node": "^20.10.6", "@types/sortablejs": "^1.15.7", "@types/spark-md5": "^3.0.4", - "@vitejs/plugin-vue": "^4.5.2", + "@vitejs/plugin-vue": "^5.0.2", "@vue/eslint-config-typescript": "^12.0.0", "@vue/tsconfig": "^0.5.1", "eslint": "^8.56.0", "eslint-plugin-vue": "^9.19.2", "npm-run-all": "^4.1.5", - "sass": "^1.69.5", + "sass": "^1.69.6", "terser": "^5.26.0", "typescript": "^5.3.3", "vite": "^5.0.10", "vite-plugin-compression": "^0.5.1", "vite-plugin-css-injected-by-js": "^3.3.1", - "vue-tsc": "^1.8.26" + "vue-tsc": "^1.8.27" } } diff --git a/webapp/src/components/BatteryView.vue b/webapp/src/components/BatteryView.vue index 2c3bf11a9..fe539d9bf 100644 --- a/webapp/src/components/BatteryView.vue +++ b/webapp/src/components/BatteryView.vue @@ -5,7 +5,7 @@ -
+
@@ -27,9 +27,9 @@
-
+
-
{{ $t('battery.Status') }}
+
{{ $t('battery.' + section) }}
@@ -40,7 +40,7 @@ - +
{{ $t('battery.' + key) }} @@ -82,6 +82,7 @@ import BasePage from '@/components/BasePage.vue'; import BootstrapAlert from "@/components/BootstrapAlert.vue"; import CardElement from '@/components/CardElement.vue'; +import FormFooter from '@/components/FormFooter.vue'; import InputElement from '@/components/InputElement.vue'; import { BIconInfoCircle } from 'bootstrap-icons-vue'; import type { AcChargerConfig } from "@/types/AcChargerConfig"; @@ -93,6 +94,7 @@ export default defineComponent({ BasePage, BootstrapAlert, CardElement, + FormFooter, InputElement, BIconInfoCircle, }, diff --git a/webapp/src/views/BatteryAdminView.vue b/webapp/src/views/BatteryAdminView.vue index 4cf1e6735..88b67df4b 100644 --- a/webapp/src/views/BatteryAdminView.vue +++ b/webapp/src/views/BatteryAdminView.vue @@ -49,7 +49,21 @@ type="number" min="2" max="90" step="1" :postfix="$t('batteryadmin.Seconds')"/> - + +
+ +
+
+ +
+
+
+
+ + @@ -58,6 +72,7 @@ import BasePage from '@/components/BasePage.vue'; import BootstrapAlert from "@/components/BootstrapAlert.vue"; import CardElement from '@/components/CardElement.vue'; +import FormFooter from '@/components/FormFooter.vue'; import InputElement from '@/components/InputElement.vue'; import type { BatteryConfig } from "@/types/BatteryConfig"; import { authHeader, handleResponse } from '@/utils/authentication'; @@ -68,6 +83,7 @@ export default defineComponent({ BasePage, BootstrapAlert, CardElement, + FormFooter, InputElement, }, data() { @@ -80,6 +96,7 @@ export default defineComponent({ providerTypeList: [ { key: 0, value: 'PylontechCan' }, { key: 1, value: 'JkBmsSerial' }, + { key: 2, value: 'Mqtt' }, { key: 3, value: 'Victron' }, ], jkBmsInterfaceTypeList: [ diff --git a/webapp/src/views/PowerLimiterAdminView.vue b/webapp/src/views/PowerLimiterAdminView.vue index f70f82217..8f10a4cd1 100644 --- a/webapp/src/views/PowerLimiterAdminView.vue +++ b/webapp/src/views/PowerLimiterAdminView.vue @@ -265,7 +265,7 @@ - + @@ -276,6 +276,7 @@ import BasePage from '@/components/BasePage.vue'; import BootstrapAlert from "@/components/BootstrapAlert.vue"; import { handleResponse, authHeader } from '@/utils/authentication'; import CardElement from '@/components/CardElement.vue'; +import FormFooter from '@/components/FormFooter.vue'; import InputElement from '@/components/InputElement.vue'; import { BIconInfoCircle } from 'bootstrap-icons-vue'; import type { PowerLimiterConfig } from "@/types/PowerLimiterConfig"; @@ -285,6 +286,7 @@ export default defineComponent({ BasePage, BootstrapAlert, CardElement, + FormFooter, InputElement, BIconInfoCircle, }, diff --git a/webapp/src/views/PowerMeterAdminView.vue b/webapp/src/views/PowerMeterAdminView.vue index 287d94450..280171cf1 100644 --- a/webapp/src/views/PowerMeterAdminView.vue +++ b/webapp/src/views/PowerMeterAdminView.vue @@ -185,7 +185,7 @@ - +