├── psbridge ├── gradle.properties ├── settings.gradle ├── gradle │ └── wrapper │ │ ├── gradle-wrapper.jar │ │ └── gradle-wrapper.properties ├── src │ ├── main │ │ ├── java │ │ │ └── com │ │ │ │ └── tomvd │ │ │ │ ├── services │ │ │ │ ├── SmartService.java │ │ │ │ ├── DeviceService.java │ │ │ │ ├── ApplicationService.java │ │ │ │ ├── ServiceLocator.java │ │ │ │ ├── SmartServiceImpl.java │ │ │ │ ├── EcoflowService.java │ │ │ │ └── HomeAssistantService.java │ │ │ │ ├── Application.java │ │ │ │ ├── model │ │ │ │ └── PowerStreamData.java │ │ │ │ ├── configuration │ │ │ │ ├── DevicesConfiguration.java │ │ │ │ ├── MQTTConfiguration.java │ │ │ │ └── SmartConfiguration.java │ │ │ │ └── converter │ │ │ │ └── ProtobufConverter.java │ │ ├── resources │ │ │ └── logback.xml │ │ └── proto │ │ │ └── com │ │ │ └── tomvd │ │ │ └── psbridge │ │ │ └── ecoflow.proto │ └── test │ │ └── java │ │ └── com │ │ └── tomvd │ │ └── PsbridgeTest.java ├── .gitignore ├── micronaut-cli.yml ├── README.md ├── build.gradle ├── gradlew.bat └── gradlew ├── mqttexplorer.png ├── mqttexplorer2.png ├── powerstream1.png ├── .idea ├── vcs.xml ├── misc.xml ├── .gitignore ├── modules.xml └── local-powerstream.iml ├── mqttserver ├── mosquitto │ ├── config │ │ └── mosquitto.conf │ └── data │ │ └── certs │ │ └── README.md ├── psbridge │ └── application.yml └── docker-compose.yml ├── LICENSE ├── honeypot └── honey.py └── README.md /psbridge/gradle.properties: -------------------------------------------------------------------------------- 1 | micronautVersion=4.7.6 2 | -------------------------------------------------------------------------------- /psbridge/settings.gradle: -------------------------------------------------------------------------------- 1 | 2 | 3 | rootProject.name="psbridge" 4 | 5 | -------------------------------------------------------------------------------- /mqttexplorer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomvd/local-powerstream/HEAD/mqttexplorer.png -------------------------------------------------------------------------------- /mqttexplorer2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomvd/local-powerstream/HEAD/mqttexplorer2.png -------------------------------------------------------------------------------- /powerstream1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomvd/local-powerstream/HEAD/powerstream1.png -------------------------------------------------------------------------------- /psbridge/gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomvd/local-powerstream/HEAD/psbridge/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /psbridge/src/main/java/com/tomvd/services/SmartService.java: -------------------------------------------------------------------------------- 1 | package com.tomvd.services; 2 | 3 | public interface SmartService { 4 | void setSl(ServiceLocator sl); 5 | } 6 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /psbridge/.gitignore: -------------------------------------------------------------------------------- 1 | Thumbs.db 2 | .DS_Store 3 | .gradle 4 | build/ 5 | target/ 6 | out/ 7 | .micronaut/ 8 | .idea 9 | *.iml 10 | *.ipr 11 | *.iws 12 | .project 13 | .settings 14 | .classpath 15 | .factorypath 16 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | # Datasource local storage ignored files 7 | /dataSources/ 8 | /dataSources.local.xml 9 | -------------------------------------------------------------------------------- /mqttserver/mosquitto/config/mosquitto.conf: -------------------------------------------------------------------------------- 1 | per_listener_settings true 2 | 3 | listener 1883 4 | allow_anonymous true 5 | 6 | listener 8883 7 | certfile /mosquitto/data/certs/certificate.pem 8 | keyfile /mosquitto/data/certs/key.pem 9 | allow_anonymous true 10 | -------------------------------------------------------------------------------- /psbridge/src/main/java/com/tomvd/Application.java: -------------------------------------------------------------------------------- 1 | package com.tomvd; 2 | 3 | import io.micronaut.runtime.Micronaut; 4 | 5 | public class Application { 6 | 7 | public static void main(String[] args) { 8 | Micronaut.run(Application.class, args); 9 | } 10 | } -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /psbridge/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /psbridge/src/main/java/com/tomvd/services/DeviceService.java: -------------------------------------------------------------------------------- 1 | package com.tomvd.services; 2 | 3 | import com.tomvd.model.PowerStreamData; 4 | 5 | public interface DeviceService { 6 | void publishPowerSetting(int i); 7 | void publishPowerSetting(int i, String deviceId); 8 | void setSl(ServiceLocator sl); 9 | PowerStreamData getPowerStreamData(); 10 | } 11 | -------------------------------------------------------------------------------- /psbridge/micronaut-cli.yml: -------------------------------------------------------------------------------- 1 | applicationType: default 2 | defaultPackage: com.tomvd 3 | testFramework: junit 4 | sourceLanguage: java 5 | buildTool: gradle 6 | features: [app-name, gradle, http-client-test, java, java-application, junit, logback, micronaut-aot, micronaut-build, micronaut-http-validation, netty-server, properties, readme, serialization-jackson, shade, static-resources] 7 | -------------------------------------------------------------------------------- /.idea/local-powerstream.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /mqttserver/psbridge/application.yml: -------------------------------------------------------------------------------- 1 | devices: 2 | powerstreams: 3 | - HW51xxxxxxxxxxxx 4 | batteries: 5 | - R621xxxxxxxxxxxx 6 | mqtt: 7 | client: 8 | server-uri: tcp://localhost:1883 9 | user-name: mosquitto 10 | password: secret 11 | enable-discovery: true 12 | smart: 13 | enabled: true 14 | meter-topic: smart/p1 15 | enabled-topic: smart/enabled 16 | charger-topic: smart/charger 17 | soc-topic: smart/soc 18 | max-power: 333 -------------------------------------------------------------------------------- /psbridge/src/main/java/com/tomvd/services/ApplicationService.java: -------------------------------------------------------------------------------- 1 | package com.tomvd.services; 2 | 3 | import org.eclipse.paho.client.mqttv3.MqttException; 4 | 5 | public interface ApplicationService { 6 | void setSl(ServiceLocator sl); 7 | boolean isOnline(); 8 | void publishJsonState(String id, String json) throws MqttException; 9 | Integer getGridPower(); 10 | Boolean getSmartEnabled(); 11 | Integer getSoc(); 12 | Boolean getChargerEnabled(); 13 | void setCharger(Boolean enabled); 14 | } 15 | -------------------------------------------------------------------------------- /mqttserver/mosquitto/data/certs/README.md: -------------------------------------------------------------------------------- 1 | Create a self-signed certificate here: 2 | 3 | ``` 4 | > openssl req -newkey rsa:2048 -nodes -keyout key.pem -x509 -days 3650 -out certificate.pem 5 | 6 | Country Name (2 letter code) [XX]:US 7 | State or Province Name (full name) []: 8 | Locality Name (eg, city) [Default City]: 9 | Organization Name (eg, company) [Default Company Ltd]:Let's Encrypt 10 | Organizational Unit Name (eg, section) []: 11 | Common Name (eg, your name or your server's hostname) []:mqtt-e.ecoflow.com 12 | Email Address []: 13 | ``` -------------------------------------------------------------------------------- /psbridge/src/main/java/com/tomvd/model/PowerStreamData.java: -------------------------------------------------------------------------------- 1 | package com.tomvd.model; 2 | 3 | public record PowerStreamData(double avgVoltage, int currentPower, String upstreamTopic,String commandTopic) { 4 | public PowerStreamData withAvgVoltage(double newAvgVoltage) { 5 | return new PowerStreamData(newAvgVoltage, currentPower, upstreamTopic, commandTopic); 6 | } 7 | 8 | public PowerStreamData withCurrentPower(int newCurrentPower) { 9 | return new PowerStreamData(avgVoltage, newCurrentPower, upstreamTopic, commandTopic); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /psbridge/src/main/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | %cyan(%d{HH:mm:ss.SSS}) %gray([%thread]) %highlight(%-5level) %magenta(%logger{36}) - %msg%n 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /psbridge/src/test/java/com/tomvd/PsbridgeTest.java: -------------------------------------------------------------------------------- 1 | package com.tomvd; 2 | 3 | import io.micronaut.runtime.EmbeddedApplication; 4 | import io.micronaut.test.extensions.junit5.annotation.MicronautTest; 5 | import org.junit.jupiter.api.Test; 6 | import org.junit.jupiter.api.Assertions; 7 | 8 | import jakarta.inject.Inject; 9 | 10 | @MicronautTest 11 | class PsbridgeTest { 12 | 13 | @Inject 14 | EmbeddedApplication application; 15 | 16 | @Test 17 | void testItWorks() { 18 | Assertions.assertTrue(application.isRunning()); 19 | } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /mqttserver/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | mqtt: 3 | image: eclipse-mosquitto:2.0.21-openssl 4 | container_name: "mqtt" 5 | restart: always 6 | ports: 7 | - 8883:8883 8 | - 1883:1883 9 | volumes: 10 | - ./mosquitto/data:/mosquitto/data 11 | - ./mosquitto/config:/mosquitto/config 12 | - ./mosquitto/logs:/mosquitto/log 13 | 14 | psbridge: 15 | image: ghcr.io/tomvd/psbridge:latest 16 | container_name: "psbridge" 17 | restart: always 18 | volumes: 19 | - ./psbridge/application.yml:/home/app/application.yml 20 | environment: 21 | MICRONAUT_CONFIG_FILES: '/home/app/application.yml' 22 | depends_on: 23 | - mqtt 24 | -------------------------------------------------------------------------------- /psbridge/src/main/java/com/tomvd/configuration/DevicesConfiguration.java: -------------------------------------------------------------------------------- 1 | package com.tomvd.configuration; 2 | 3 | import io.micronaut.context.annotation.ConfigurationProperties; 4 | 5 | import java.util.List; 6 | 7 | @ConfigurationProperties("devices") 8 | public class DevicesConfiguration { 9 | private List powerstreams; 10 | private List batteries; 11 | 12 | public List getPowerstreams() { 13 | return powerstreams; 14 | } 15 | 16 | public void setPowerstreams(List powerstreams) { 17 | this.powerstreams = powerstreams; 18 | } 19 | 20 | public List getBatteries() { 21 | return batteries; 22 | } 23 | 24 | public void setBatteries(List batteries) { 25 | this.batteries = batteries; 26 | } 27 | } -------------------------------------------------------------------------------- /psbridge/src/main/java/com/tomvd/configuration/MQTTConfiguration.java: -------------------------------------------------------------------------------- 1 | package com.tomvd.configuration; 2 | 3 | import io.micronaut.context.annotation.ConfigurationProperties; 4 | 5 | @ConfigurationProperties("mqtt.client") 6 | public class MQTTConfiguration { 7 | private boolean enableDiscovery; 8 | private String serverUri; 9 | private String userName; 10 | private String password; 11 | public String getServerUri() { return serverUri; } 12 | public void setServerUri(String url) { this.serverUri = url; } 13 | public String getUserName() { return userName; } 14 | public void setUserName(String username) { this.userName = username; } 15 | public String getPassword() { return password; } 16 | public void setPassword(String password) { this.password = password; } 17 | public boolean isEnableDiscovery() { return enableDiscovery; } 18 | public void setEnableDiscovery(boolean enableDiscovery) { this.enableDiscovery = enableDiscovery; } 19 | } 20 | -------------------------------------------------------------------------------- /psbridge/README.md: -------------------------------------------------------------------------------- 1 | ## PSBridge 2 | /!\ **beware this is work in progress, it can not be used just for testing** /!\ 3 | 4 | The application connects to a local mqtt broker that is set up to receive data from the powerstream. 5 | Optionally you can then connect (with MQTT plugin) home assistant to the same broker. 6 | 7 | You can set up your broker to allow anonymous connections (easy but not secure), or you can set it up to use passwords. 8 | When using passwords, make sure you first know the user/name password your ecoflow device uses to connect to mqtt (using the honeypot trick) 9 | 10 | For the smart battery functionality, I have set up some home automation tasks to publish certain data to certain topics: 11 | meter-topic: smart/p1 12 | enabled-topic: smart/enabled 13 | charger-topic: smart/charger 14 | soc-topic: smart/soc 15 | 16 | p1 is directly power in watts from my p1 meter 17 | soc is the state of charge of my battery (read out via bluetooth) 18 | when "on" is put in the charger topic, the charger goes on 19 | when "off" is put in the charger topic, the charger goes off 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Tom Van Dyck 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /psbridge/src/main/java/com/tomvd/services/ServiceLocator.java: -------------------------------------------------------------------------------- 1 | package com.tomvd.services; 2 | 3 | import io.micronaut.context.annotation.Context; 4 | import jakarta.annotation.PostConstruct; 5 | import jakarta.inject.Inject; 6 | 7 | // need this horrible thingy to solve circular dependency, no time for fancy stuff 8 | @Context 9 | public class ServiceLocator { 10 | private final DeviceService deviceService; 11 | private final ApplicationService applicationService; 12 | private final SmartService smartService; 13 | 14 | @Inject 15 | public ServiceLocator(DeviceService deviceService, ApplicationService applicationService, SmartService smartService) { 16 | this.deviceService = deviceService; 17 | this.applicationService = applicationService; 18 | this.smartService = smartService; 19 | } 20 | 21 | @PostConstruct 22 | public void init() { 23 | this.deviceService.setSl(this); 24 | this.applicationService.setSl(this); 25 | this.smartService.setSl(this); 26 | } 27 | 28 | public DeviceService getDeviceService() { 29 | return deviceService; 30 | } 31 | 32 | public ApplicationService getApplicationService() { 33 | return applicationService; 34 | } 35 | 36 | public SmartService getSmartService() {return smartService;} 37 | } 38 | -------------------------------------------------------------------------------- /psbridge/src/main/java/com/tomvd/configuration/SmartConfiguration.java: -------------------------------------------------------------------------------- 1 | package com.tomvd.configuration; 2 | 3 | import io.micronaut.context.annotation.ConfigurationProperties; 4 | 5 | @ConfigurationProperties("smart") 6 | public class SmartConfiguration { 7 | boolean enabled; 8 | String meterTopic; 9 | String enabledTopic; 10 | String chargerTopic; 11 | String socTopic; 12 | Integer maxPower; 13 | Integer lowSoc; 14 | public boolean isEnabled() { return enabled; } 15 | public void setEnabled(boolean enabled) { this.enabled = enabled; } 16 | public String getMeterTopic() { return meterTopic; } 17 | public void setMeterTopic(String meterTopic) { this.meterTopic = meterTopic; } 18 | public String getEnabledTopic() { return enabledTopic; } 19 | public void setEnabledTopic(String enabledTopic) { this.enabledTopic = enabledTopic; } 20 | public String getChargerTopic() { return chargerTopic; } 21 | public void setChargerTopic(String chargerTopic) { this.chargerTopic = chargerTopic; } 22 | public String getSocTopic() { return socTopic; } 23 | public void setSocTopic(String socTopic) { this.socTopic = socTopic; } 24 | public Integer getMaxPower() { return maxPower; } 25 | public void setMaxPower(Integer maxPower) { this.maxPower = maxPower; } 26 | public Integer getLowSoc() { return lowSoc==null?15:lowSoc; } 27 | public void setLowSoc(Integer lowSoc) { this.lowSoc = lowSoc; } 28 | } 29 | -------------------------------------------------------------------------------- /psbridge/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("com.github.johnrengelman.shadow") version "8.1.1" 3 | id("io.micronaut.application") version "4.4.4" 4 | id("io.micronaut.aot") version "4.4.4" 5 | id("com.google.protobuf") version "0.9.5" 6 | } 7 | 8 | version = "0.1" 9 | group = "com.tomvd" 10 | 11 | repositories { 12 | mavenCentral() 13 | } 14 | 15 | dependencies { 16 | annotationProcessor("io.micronaut:micronaut-http-validation") 17 | implementation("io.micronaut:micronaut-http-client") 18 | implementation("io.micronaut:micronaut-jackson-databind") 19 | implementation("io.micronaut:micronaut-runtime") 20 | implementation("org.eclipse.paho:org.eclipse.paho.client.mqttv3:1.2.5") 21 | implementation("com.fasterxml.jackson.core:jackson-databind") 22 | implementation 'com.google.protobuf:protobuf-java:4.28.2' 23 | 24 | compileOnly("io.micronaut:micronaut-http-client") 25 | runtimeOnly("ch.qos.logback:logback-classic") 26 | runtimeOnly("org.yaml:snakeyaml") 27 | testImplementation("io.micronaut:micronaut-http-client") 28 | } 29 | 30 | protobuf { 31 | protoc { 32 | artifact = "com.google.protobuf:protoc:3.25.3" 33 | } 34 | } 35 | 36 | 37 | application { 38 | mainClass = "com.tomvd.Application" 39 | } 40 | java { 41 | sourceCompatibility = JavaVersion.toVersion("21") 42 | targetCompatibility = JavaVersion.toVersion("21") 43 | } 44 | 45 | 46 | graalvmNative.toolchainDetection = false 47 | 48 | micronaut { 49 | runtime("netty") 50 | testRuntime("junit5") 51 | processing { 52 | incremental(true) 53 | annotations("com.tomvd.*") 54 | } 55 | aot { 56 | // Please review carefully the optimizations enabled below 57 | // Check https://micronaut-projects.github.io/micronaut-aot/latest/guide/ for more details 58 | optimizeServiceLoading = false 59 | convertYamlToJava = false 60 | precomputeOperations = true 61 | cacheEnvironment = true 62 | optimizeClassLoading = true 63 | deduceEnvironment = true 64 | optimizeNetty = true 65 | replaceLogbackXml = true 66 | } 67 | } 68 | 69 | 70 | tasks.named("dockerfileNative") { 71 | jdkVersion = "21" 72 | } 73 | 74 | 75 | -------------------------------------------------------------------------------- /psbridge/gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 74 | 75 | 76 | @rem Execute Gradle 77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 78 | 79 | :end 80 | @rem End local scope for the variables with windows NT shell 81 | if %ERRORLEVEL% equ 0 goto mainEnd 82 | 83 | :fail 84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 85 | rem the _cmd.exe /c_ return code! 86 | set EXIT_CODE=%ERRORLEVEL% 87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 89 | exit /b %EXIT_CODE% 90 | 91 | :mainEnd 92 | if "%OS%"=="Windows_NT" endlocal 93 | 94 | :omega 95 | -------------------------------------------------------------------------------- /honeypot/honey.py: -------------------------------------------------------------------------------- 1 | import socket 2 | import ssl 3 | import logging 4 | 5 | # Set up logging 6 | logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(message)s') 7 | logger = logging.getLogger('mqtt_honeypot') 8 | 9 | # Create SSL context 10 | ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) 11 | # Add your certificate and key 12 | ssl_context.load_cert_chain(certfile='/home/user/mosquitto/data/certs/certificate.pem', keyfile='/home/user/mosquitto/data/certs/key.pem') 13 | 14 | # Create a socket 15 | server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 16 | server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 17 | server_socket.bind(('0.0.0.0', 8883)) # MQTT TLS default port 18 | server_socket.listen(5) 19 | 20 | logger.info("MQTT TLS Honeypot started on port 8883") 21 | 22 | def extract_mqtt_connect_info(data): 23 | """Extract username and password from MQTT CONNECT packet""" 24 | if len(data) < 12: 25 | return None, None 26 | 27 | # Check for MQTT CONNECT packet (first byte should be 0x10) 28 | if data[0] != 0x10: 29 | return None, None 30 | 31 | try: 32 | # Skip to variable header 33 | i = 2 34 | remaining_length = data[1] 35 | while remaining_length > 0: 36 | protocol_name_len = (data[i] << 8) | data[i+1] 37 | i += 2 + protocol_name_len 38 | 39 | # Protocol level 40 | i += 1 41 | 42 | # Connect flags 43 | connect_flags = data[i] 44 | has_username = bool(connect_flags & 0x80) 45 | has_password = bool(connect_flags & 0x40) 46 | i += 1 47 | 48 | # Keep-alive 49 | i += 2 50 | 51 | # Client ID 52 | client_id_len = (data[i] << 8) | data[i+1] 53 | i += 2 54 | client_id = data[i:i+client_id_len].decode('utf-8') 55 | i += client_id_len 56 | 57 | username = password = None 58 | 59 | # Username 60 | if has_username: 61 | username_len = (data[i] << 8) | data[i+1] 62 | i += 2 63 | username = data[i:i+username_len].decode('utf-8') 64 | i += username_len 65 | 66 | # Password 67 | if has_password: 68 | password_len = (data[i] << 8) | data[i+1] 69 | i += 2 70 | password = data[i:i+password_len].decode('utf-8') 71 | 72 | return username, password 73 | 74 | except Exception as e: 75 | logger.error(f"Error parsing MQTT packet: {e}") 76 | return None, None 77 | 78 | while True: 79 | try: 80 | client_socket, address = server_socket.accept() 81 | logger.info(f"Connection from {address}") 82 | 83 | # Wrap the socket with SSL/TLS 84 | try: 85 | ssl_socket = ssl_context.wrap_socket(client_socket, server_side=True) 86 | logger.info(f"SSL/TLS handshake successful with {address}") 87 | 88 | # Receive MQTT CONNECT packet 89 | data = ssl_socket.recv(1024) 90 | logger.info(f"data recv{data}") 91 | 92 | username, password = extract_mqtt_connect_info(data) 93 | if username or password: 94 | logger.info(f"Captured credentials - Username: {username}, Password: {password}") 95 | 96 | # Close the connection 97 | ssl_socket.close() 98 | 99 | except ssl.SSLError as e: 100 | logger.error(f"SSL Error with {address}: {e}") 101 | client_socket.close() 102 | 103 | except Exception as e: 104 | logger.error(f"Error: {e}") 105 | -------------------------------------------------------------------------------- /psbridge/src/main/java/com/tomvd/services/SmartServiceImpl.java: -------------------------------------------------------------------------------- 1 | package com.tomvd.services; 2 | 3 | import com.tomvd.configuration.SmartConfiguration; 4 | import com.tomvd.model.PowerStreamData; 5 | import io.micronaut.scheduling.annotation.Scheduled; 6 | import jakarta.inject.Inject; 7 | import jakarta.inject.Singleton; 8 | 9 | @Singleton 10 | public class SmartServiceImpl implements SmartService { 11 | private ServiceLocator sl; 12 | private final SmartConfiguration config; 13 | 14 | @Inject 15 | public SmartServiceImpl(SmartConfiguration config) { 16 | this.config = config; 17 | } 18 | 19 | @Override 20 | public void setSl(ServiceLocator sl) { 21 | this.sl = sl; 22 | } 23 | 24 | @Scheduled(fixedDelay = "6s") 25 | public void run() { 26 | if (!config.isEnabled()) return; 27 | PowerStreamData data = sl.getDeviceService().getPowerStreamData(); 28 | Integer gridPower = sl.getApplicationService().getGridPower(); 29 | Boolean enabled = sl.getApplicationService().getSmartEnabled(); 30 | Boolean chargerEnabled = sl.getApplicationService().getChargerEnabled(); 31 | Integer soc = sl.getApplicationService().getSoc(); 32 | if (soc == null && data != null && data.currentPower() > 0) { 33 | // as safety measure, if we lost connection with the battery - but are using it - stop using it. 34 | sl.getDeviceService().publishPowerSetting(0); 35 | return; 36 | } 37 | if (data == null || gridPower == null || enabled == null || !enabled || soc == null) {return;} 38 | 39 | if (soc < config.getLowSoc()) { 40 | // battery soc dropped too low, make sure we shut off the inverter and dont do anything more 41 | if (data.currentPower() > 0) { 42 | sl.getDeviceService().publishPowerSetting(0); 43 | } 44 | return; 45 | } 46 | 47 | if (chargerEnabled == null || !chargerEnabled) { 48 | if (gridPower > 0) // we are (still) pulling power from the grid - increase output 49 | { 50 | int newPowerSetting = Math.min(config.getMaxPower() == null?666: config.getMaxPower(), gridPower + data.currentPower()); 51 | if (Math.abs(data.currentPower() - newPowerSetting) > 10) { // only publish a new powersetting if it changes > 10w 52 | sl.getDeviceService().publishPowerSetting(newPowerSetting); 53 | } 54 | } 55 | if (gridPower < 0 && data.currentPower() > 0) // we are sending battery power in the grid - lower output 56 | { 57 | int newPowerSetting = Math.max(0,data.currentPower()+gridPower); 58 | if (Math.abs(data.currentPower() - newPowerSetting) > 10) { // only publish a new powersetting if it changes > 10w 59 | sl.getDeviceService().publishPowerSetting(newPowerSetting); 60 | } 61 | } 62 | } 63 | } 64 | 65 | @Scheduled(fixedDelay = "60s") 66 | public void runCharger() { 67 | if (!config.isEnabled()) return; 68 | Integer gridPower = sl.getApplicationService().getGridPower(); 69 | Boolean enabled = sl.getApplicationService().getSmartEnabled(); 70 | Boolean chargerEnabled = sl.getApplicationService().getChargerEnabled(); 71 | Integer soc = sl.getApplicationService().getSoc(); 72 | if (gridPower == null || enabled == null || !enabled || soc == null) {return;} 73 | 74 | // charger only takes 400-500w but we take some 100W margin to avoid turning it on/off the whole time 75 | if (soc < 95 && gridPower < -600 && (chargerEnabled == null || !chargerEnabled)) { 76 | sl.getApplicationService().setCharger(true); 77 | sl.getDeviceService().publishPowerSetting(0); // makes sure we are not charging and giving power 78 | } 79 | // if battery is full or we are using gridpower - turn charger off 80 | if (soc > 99 || (gridPower > 100 && (chargerEnabled == null || chargerEnabled))) { 81 | sl.getApplicationService().setCharger(false); 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /psbridge/src/main/java/com/tomvd/converter/ProtobufConverter.java: -------------------------------------------------------------------------------- 1 | package com.tomvd.converter; 2 | 3 | import com.google.protobuf.InvalidProtocolBufferException; 4 | import com.tomvd.psbridge.HeaderMessage; 5 | import com.tomvd.psbridge.InverterHeartbeat; 6 | import com.tomvd.psbridge.SendMsgHart; 7 | import com.tomvd.psbridge.setMessage; 8 | import jakarta.inject.Singleton; 9 | import org.slf4j.Logger; 10 | import org.slf4j.LoggerFactory; 11 | 12 | @Singleton 13 | public class ProtobufConverter { 14 | private static final Logger LOG = LoggerFactory.getLogger(ProtobufConverter.class); 15 | 16 | public InverterHeartbeat convert(byte[] data) { 17 | //LOG.info("Transforming binary data of size: {}", data.length); 18 | HeaderMessage msgobj = null; 19 | try { 20 | msgobj = HeaderMessage.parseFrom(data); 21 | } catch (InvalidProtocolBufferException e) { 22 | LOG.error(e.getMessage()); 23 | } 24 | 25 | assert msgobj != null; 26 | return msgobj.getHeaderList().stream() 27 | .filter(header -> header.getCmdId() == 1) 28 | .map(header -> { 29 | try { 30 | return InverterHeartbeat.parseFrom(header.getPdata()); 31 | } catch (InvalidProtocolBufferException e) { 32 | throw new RuntimeException(e); 33 | } 34 | }).findFirst().orElse(null); 35 | } 36 | 37 | public byte[] getPowerSettingPayload(int watts, String sn) { 38 | int deciWatts = Math.max(1, watts*10); 39 | setMessage setMessage = com.tomvd.psbridge.setMessage.newBuilder() 40 | .setHeader(com.tomvd.psbridge.setHeader.newBuilder() 41 | .setPdata(com.tomvd.psbridge.setValue.newBuilder() 42 | .setValue(deciWatts) 43 | .build()) 44 | .setSrc(32) 45 | .setDest(53) 46 | .setDSrc(1) 47 | .setDDest(1) 48 | .setCheckType(3) 49 | .setCmdFunc(20) 50 | .setCmdId(129) 51 | .setDataLen(deciWatts > 127?3:2) 52 | .setNeedAck(1) 53 | .setSeq((int)(System.currentTimeMillis()/1000)) 54 | .setVersion(19) 55 | .setPayloadVer(1) 56 | .setFrom("ios") 57 | .setDeviceSn(sn) 58 | .build()) 59 | .build(); 60 | return setMessage.toByteArray(); 61 | } 62 | 63 | /* 64 | "link_id": 15, 65 | "src": 32, 66 | "dest": 53, 67 | "d_src": 1, 68 | "d_dest": 1, 69 | "enc_type": 0, 70 | "check_type": 0, 71 | "cmd_func": 32, 72 | "cmd_id": 10, 73 | "data_len": 2, 74 | "need_ack": 1, 75 | "is_ack": 0, 76 | "ack_type": 0, 77 | "seq": 1065348852, 78 | "time_snap": 0, 79 | "is_rw_cmd": 0, 80 | "is_queue": 0, 81 | "product_id": 0, 82 | "version": 0 83 | */ 84 | /* 85 | not sure what this message is to be honest. It is sent by the ecoflow mqtt server the moment a device connects to it. 86 | And it seems to keep the device chatting. Otherwise it falls back to a very slow rate of 48seconds status updates 87 | */ 88 | public byte[] convertHeartBeat() { 89 | SendMsgHart sendMsgHart = com.tomvd.psbridge.SendMsgHart.newBuilder() 90 | .setLinkId(15) 91 | .setSrc(32) 92 | .setDest(53) 93 | .setDSrc(1) 94 | .setDDest(1) 95 | .setEncType(0) 96 | .setCheckType(0) 97 | .setCmdFunc(32) 98 | .setCmdId(10) 99 | .setDataLen(2) 100 | .setNeedAck(1) 101 | .setIsAck(0) 102 | .setAckType(0) 103 | .setSeq((int)(System.currentTimeMillis()/1000)) 104 | .build(); 105 | return sendMsgHart.toByteArray(); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /psbridge/src/main/proto/com/tomvd/psbridge/ecoflow.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | option java_package = "com.tomvd.psbridge"; 4 | option java_outer_classname = "Ecoflow"; 5 | option java_multiple_files = true; 6 | 7 | message Header 8 | { 9 | bytes pdata = 1; 10 | int32 src = 2; 11 | int32 dest = 3; 12 | int32 d_src= 4; 13 | int32 d_dest = 5; 14 | int32 enc_type = 6; 15 | int32 check_type = 7; 16 | int32 cmd_func = 8; 17 | int32 cmd_id = 9; 18 | int32 data_len = 10; 19 | int32 need_ack = 11; 20 | int32 is_ack = 12; 21 | int32 seq = 14; 22 | int32 product_id = 15; 23 | int32 version = 16; 24 | int32 payload_ver = 17; 25 | int32 time_snap = 18; 26 | int32 is_rw_cmd = 19; 27 | int32 is_queue = 20; 28 | int32 ack_type= 21; 29 | string code = 22; 30 | string from = 23; 31 | string module_sn = 24; 32 | string device_sn = 25; 33 | } 34 | 35 | message HeaderMessage { 36 | repeated Header header = 1; 37 | } 38 | 39 | message InverterHeartbeat { 40 | uint32 inv_error_code = 1; 41 | uint32 inv_warning_code = 3; 42 | uint32 pv1_error_code = 2; 43 | uint32 pv1_warning_code = 4; 44 | uint32 pv2_error_code = 5; 45 | uint32 pv2_warning_code = 6; 46 | uint32 bat_error_code = 7; 47 | uint32 bat_warning_code = 8; 48 | uint32 llc_error_code = 9; 49 | uint32 llc_warning_code = 10; 50 | uint32 pv1_status = 11; 51 | uint32 pv2_status = 12; 52 | uint32 bat_status = 13; 53 | uint32 llc_status = 14; 54 | uint32 inv_status = 15; 55 | int32 pv1_input_volt = 16; 56 | int32 pv1_op_volt = 17; 57 | int32 pv1_input_cur = 18; 58 | int32 pv1_input_watts = 19; 59 | int32 pv1_temp = 20; 60 | int32 pv2_input_volt = 21; 61 | int32 pv2_op_volt = 22; 62 | int32 pv2_input_cur = 23; 63 | int32 pv2_input_watts = 24; 64 | int32 pv2_temp = 25; 65 | int32 bat_input_volt = 26; 66 | int32 bat_op_volt = 27; 67 | int32 bat_input_cur = 28; 68 | int32 bat_input_watts = 29; 69 | int32 bat_temp = 30; 70 | uint32 bat_soc = 31; 71 | int32 llc_input_volt = 32; 72 | int32 llc_op_volt = 33; 73 | int32 llc_temp = 34; 74 | int32 inv_input_volt = 35; 75 | int32 inv_op_volt = 36; 76 | int32 inv_output_cur = 37; 77 | int32 inv_output_watts = 38; 78 | int32 inv_temp = 39; 79 | int32 inv_freq = 40; 80 | int32 inv_dc_cur = 41; 81 | int32 bp_type = 42; 82 | int32 inv_relay_status = 43; 83 | int32 pv1_relay_status = 44; 84 | int32 pv2_relay_status = 45; 85 | uint32 install_country = 46; 86 | uint32 install_town = 47; 87 | uint32 permanent_watts = 48; 88 | uint32 dynamic_watts = 49; 89 | uint32 supply_priority = 50; 90 | uint32 lower_limit = 51; 91 | uint32 upper_limit = 52; 92 | uint32 inv_on_off = 53; 93 | uint32 wireless_error_code = 54; 94 | uint32 wireless_warning_code = 55; 95 | uint32 inv_brightness = 56; 96 | uint32 heartbeat_frequency = 57; 97 | uint32 rated_power = 58; 98 | uint32 battery_charge_remain = 59; 99 | uint32 battery_discharge_remain = 60; 100 | } 101 | 102 | message EventRecordItem { 103 | uint32 timestamp = 1; 104 | uint32 sys_ms = 2; 105 | uint32 event_no = 3; 106 | repeated float event_detail = 4; 107 | } 108 | message EventRecordReport { 109 | uint32 event_ver = 1; 110 | uint32 event_seq = 2; 111 | repeated EventRecordItem event_item = 3; 112 | } 113 | message EventInfoReportAck { 114 | uint32 result = 1; 115 | uint32 event_seq = 2; 116 | uint32 event_item_num = 3; 117 | } 118 | message ProductNameSet { 119 | string name = 1; 120 | } 121 | message ProductNameSetAck { 122 | uint32 result = 1; 123 | } 124 | message ProductNameGet {} 125 | message ProductNameGetAck { 126 | string name = 3; 127 | } 128 | message RTCTimeGet {} 129 | 130 | message RTCTimeGetAck { 131 | uint32 timestamp = 1; 132 | int32 timezone = 2; 133 | } 134 | message RTCTimeSet { 135 | uint32 timestamp = 1; 136 | int32 timezone = 2; 137 | } 138 | message RTCTimeSetAck { 139 | uint32 result = 1; 140 | } 141 | 142 | 143 | message Send_Header_Msg 144 | { 145 | Header msg = 1; 146 | } 147 | 148 | message SendMsgHart 149 | { 150 | int32 link_id = 1; 151 | int32 src = 2; 152 | int32 dest = 3; 153 | int32 d_src = 4; 154 | int32 d_dest = 5; 155 | int32 enc_type = 6; 156 | int32 check_type = 7; 157 | int32 cmd_func = 8; 158 | int32 cmd_id = 9; 159 | int32 data_len = 10; 160 | int32 need_ack = 11; 161 | int32 is_ack = 12; 162 | int32 ack_type = 13; 163 | int32 seq = 14; 164 | int32 time_snap = 15; 165 | int32 is_rw_cmd = 16; 166 | int32 is_queue = 17; 167 | int32 product_id = 18; 168 | int32 version = 19; 169 | } 170 | 171 | message setMessage { 172 | setHeader header = 1; 173 | } 174 | message setHeader { 175 | setValue pdata = 1; 176 | int32 src = 2; 177 | int32 dest = 3; 178 | int32 d_src = 4; 179 | int32 d_dest = 5; 180 | int32 enc_type = 6; 181 | int32 check_type = 7; 182 | int32 cmd_func = 8; 183 | int32 cmd_id = 9; 184 | int32 data_len = 10; 185 | int32 need_ack = 11; 186 | int32 is_ack = 12; 187 | int32 seq = 14; 188 | int32 product_id = 15; 189 | int32 version = 16; 190 | int32 payload_ver = 17; 191 | int32 time_snap = 18; 192 | int32 is_rw_cmd = 19; 193 | int32 is_queue = 20; 194 | int32 ack_type = 21; 195 | string code = 22; 196 | string from = 23; 197 | string module_sn = 24; 198 | string device_sn = 25; 199 | } 200 | message setValue { 201 | int32 value = 1; 202 | int32 value2 = 2; 203 | } 204 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # local-powerstream 2 | Knowledge gathering hub with the goal to control the ecoflow powerstream locally without internet 3 | 4 | # History 5 | I found the powerstream in my search for a DIY home battery solution (that started by reading this Dutch forum thread: https://gathering.tweakers.net/forum/list_messages/2253584/0) that met all the criteria: 6 | 1) the solar inputs can handle the 24V battery as input 7 | 2) the power output can be set from 0 to 800W through automation. 8 | 3) The device is certified (by our local regulator) to be plugged in a socket in the house. 9 | 10 | But there is one big problem left to solve: make it work without the Ecoflow cloud. (for various reasons) 11 | 12 | # Tricking the device into connecting to my own mosquitto server 13 | Using Adguard (you can also use pihole) I could see that the device is connecting to mqtt-e.ecoflow.com - it was pretty easy to add a DNS rewrite and route this to the IP of my own mosquitto server. 14 | Basically if you have any Ubuntu VM running, copy whatever is in the mqttserver subfolder in your home folder. Edit the docker-compose.yml to match the home folders name. Then go to certs subdir and follow the readme there to create a self-signed certificate. Compose it all up and off we go. 15 | 16 | If you connect with MQTT explorer to your local server and see topics appear and all kinds of telemetry from the device being published as shown below you are all set to controle the device locally: 17 | ![alt text](mqttexplorer.png) 18 | The data is in a protobuf binary format, as a next step we need to get this binary format decoded. Later I found out that using https://mqttx.app/ and set the output to base64 was actually much better to handle binary communication. The proto definitions are very similar to the ones already found in the decompiled android app. (There is a the inverter heartbeat for example that contains all kind of info like PV input voltage) 19 | 20 | # Impersonating a device to see what the possible downstream command could be 21 | In the local mosquitto log you can see it subscribes to a lot of topics: 22 | ```` 23 | /sys/.../thing/protobuf/downstream 24 | /sys/.../thing/rawData/downstream 25 | /sys/.../thing/property/cmd 26 | /sys/.../thing/property/set 27 | a few /ota/wifi topics - probably not interesting to us 28 | a few /ota/module topics 29 | ```` 30 | I probably want to find out what the ecoflow mothership is sending to the device on those 4 first topics... 31 | 32 | Using a honeypot-mosquitto server (little python app you can find in the honeypot folder) it is possible to log credentials from any connecting client. 33 | You should see something like this: 34 | ```` 35 | 2025-04-01 19:47:59,749 - MQTT TLS Honeypot started on port 8883 36 | 2025-04-01 19:48:01,303 - Connection from ('192.168.0.227', 50249) 37 | 2025-04-01 19:48:01,814 - SSL/TLS handshake successful with ('192.168.0.227', 50249) 38 | 2025-04-01 19:48:01,830 - data recvb"\x10h\x00\x04MQTT\x05\xc2\x00x\x00\x00\x10HW51012345678901\x00'device-01234567890123456789012345678901\x00 01234567890123456789012345678901" 39 | 2025-04-01 19:48:01,831 - Error parsing MQTT packet: index out of range 40 | ```` 41 | (I changed the credentials to fake ones) 42 | With the self-signed certificate, clientid HW51012345678901, userid device-01234567890123456789012345678901, password 01234567890123456789012345678901 43 | you can actually connect to mqtt-e.ecoflow.com:8883 using mqtt explorer. 44 | Then I used the nodered plugin to change the output power of the device, which actually mimics the command that is send by the app. 45 | Suddenly I see the topic and payload appear on MQTT explorer, which means we probably now can use that one to control the powerstream locally. It uses the cmd topic for that. 46 | ![alt text](mqttexplorer2.png) 47 | You can use this site to decode base64 protobuf messages (without needing the proto definition file): https://protobuf-decoder.netlify.app/ 48 | 49 | A few tests later, I could control the device by creating my own protobuf message, in my little java app like this: 50 | ```` 51 | public byte[] convert(int watts, String sn) { 52 | int deciWatts = Math.max(1, watts*10); 53 | setMessage setMessage = com.tomvd.psbridge.setMessage.newBuilder() 54 | .setHeader(com.tomvd.psbridge.setHeader.newBuilder() 55 | .setPdata(com.tomvd.psbridge.setValue.newBuilder() 56 | .setValue(deciWatts) 57 | .build()) 58 | .setSrc(32) 59 | .setDest(53) 60 | .setDSrc(1) 61 | .setDDest(1) 62 | .setCheckType(3) 63 | .setCmdFunc(20) 64 | .setCmdId(129) 65 | .setDataLen(deciWatts > 127?3:2) 66 | .setNeedAck(1) 67 | .setSeq((int)(System.currentTimeMillis()/1000)) 68 | .setVersion(19) 69 | .setPayloadVer(1) 70 | .setFrom("ios") 71 | .setDeviceSn(sn) 72 | .build()) 73 | .build(); 74 | return setMessage.toByteArray(); 75 | } 76 | ```` 77 | That is the code to create the payload. You just have to publish that to the /sys/.../thing/property/cmd topic. 78 | 79 | Firmware: 1.1.4.61 80 | 81 | # psbridge - a small bridge app between the ecoflow mosquitto server and my home assistant mosquitto server 82 | This is more a matter of taste but I wanted to use tiny Java/micronaut app which is also easy to build and deploy as a container. 83 | Java has excellent mqtt and protobuf libraries. And my mother tongue is Java, hence this choice. 84 | This app is still under development, but it already allows for getting powerstream parameters and setting the output voltage through homeassistant (it send an mqtt discovery topic and then your powerstream will appear): 85 | ![alt text](powerstream1.png) 86 | 87 | # disclaimer 88 | The app, docker file, python script and everything I described here comes without warranties and limited support, it was created for my own use and made public to inspire and educate other people to create or extend their own plugins or apps. If you get into trouble with unofficial use of the device, support will probably not help you. 89 | I will not take any requests to support other devices. 90 | The intention of this project is not to do harm to Ecoflow, only to make use of my device without giving it a constant internet connection. -------------------------------------------------------------------------------- /psbridge/gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | # SPDX-License-Identifier: Apache-2.0 19 | # 20 | 21 | ############################################################################## 22 | # 23 | # Gradle start up script for POSIX generated by Gradle. 24 | # 25 | # Important for running: 26 | # 27 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 28 | # noncompliant, but you have some other compliant shell such as ksh or 29 | # bash, then to run this script, type that shell name before the whole 30 | # command line, like: 31 | # 32 | # ksh Gradle 33 | # 34 | # Busybox and similar reduced shells will NOT work, because this script 35 | # requires all of these POSIX shell features: 36 | # * functions; 37 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 38 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 39 | # * compound commands having a testable exit status, especially «case»; 40 | # * various built-in commands including «command», «set», and «ulimit». 41 | # 42 | # Important for patching: 43 | # 44 | # (2) This script targets any POSIX shell, so it avoids extensions provided 45 | # by Bash, Ksh, etc; in particular arrays are avoided. 46 | # 47 | # The "traditional" practice of packing multiple parameters into a 48 | # space-separated string is a well documented source of bugs and security 49 | # problems, so this is (mostly) avoided, by progressively accumulating 50 | # options in "$@", and eventually passing that to Java. 51 | # 52 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 53 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 54 | # see the in-line comments for details. 55 | # 56 | # There are tweaks for specific operating systems such as AIX, CygWin, 57 | # Darwin, MinGW, and NonStop. 58 | # 59 | # (3) This script is generated from the Groovy template 60 | # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 61 | # within the Gradle project. 62 | # 63 | # You can find Gradle at https://github.com/gradle/gradle/. 64 | # 65 | ############################################################################## 66 | 67 | # Attempt to set APP_HOME 68 | 69 | # Resolve links: $0 may be a link 70 | app_path=$0 71 | 72 | # Need this for daisy-chained symlinks. 73 | while 74 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 75 | [ -h "$app_path" ] 76 | do 77 | ls=$( ls -ld "$app_path" ) 78 | link=${ls#*' -> '} 79 | case $link in #( 80 | /*) app_path=$link ;; #( 81 | *) app_path=$APP_HOME$link ;; 82 | esac 83 | done 84 | 85 | # This is normally unused 86 | # shellcheck disable=SC2034 87 | APP_BASE_NAME=${0##*/} 88 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 89 | APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s 90 | ' "$PWD" ) || exit 91 | 92 | # Use the maximum available, or set MAX_FD != -1 to use that value. 93 | MAX_FD=maximum 94 | 95 | warn () { 96 | echo "$*" 97 | } >&2 98 | 99 | die () { 100 | echo 101 | echo "$*" 102 | echo 103 | exit 1 104 | } >&2 105 | 106 | # OS specific support (must be 'true' or 'false'). 107 | cygwin=false 108 | msys=false 109 | darwin=false 110 | nonstop=false 111 | case "$( uname )" in #( 112 | CYGWIN* ) cygwin=true ;; #( 113 | Darwin* ) darwin=true ;; #( 114 | MSYS* | MINGW* ) msys=true ;; #( 115 | NONSTOP* ) nonstop=true ;; 116 | esac 117 | 118 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 119 | 120 | 121 | # Determine the Java command to use to start the JVM. 122 | if [ -n "$JAVA_HOME" ] ; then 123 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 124 | # IBM's JDK on AIX uses strange locations for the executables 125 | JAVACMD=$JAVA_HOME/jre/sh/java 126 | else 127 | JAVACMD=$JAVA_HOME/bin/java 128 | fi 129 | if [ ! -x "$JAVACMD" ] ; then 130 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 131 | 132 | Please set the JAVA_HOME variable in your environment to match the 133 | location of your Java installation." 134 | fi 135 | else 136 | JAVACMD=java 137 | if ! command -v java >/dev/null 2>&1 138 | then 139 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 140 | 141 | Please set the JAVA_HOME variable in your environment to match the 142 | location of your Java installation." 143 | fi 144 | fi 145 | 146 | # Increase the maximum file descriptors if we can. 147 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 148 | case $MAX_FD in #( 149 | max*) 150 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 151 | # shellcheck disable=SC2039,SC3045 152 | MAX_FD=$( ulimit -H -n ) || 153 | warn "Could not query maximum file descriptor limit" 154 | esac 155 | case $MAX_FD in #( 156 | '' | soft) :;; #( 157 | *) 158 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 159 | # shellcheck disable=SC2039,SC3045 160 | ulimit -n "$MAX_FD" || 161 | warn "Could not set maximum file descriptor limit to $MAX_FD" 162 | esac 163 | fi 164 | 165 | # Collect all arguments for the java command, stacking in reverse order: 166 | # * args from the command line 167 | # * the main class name 168 | # * -classpath 169 | # * -D...appname settings 170 | # * --module-path (only if needed) 171 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 172 | 173 | # For Cygwin or MSYS, switch paths to Windows format before running java 174 | if "$cygwin" || "$msys" ; then 175 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 176 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 177 | 178 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 179 | 180 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 181 | for arg do 182 | if 183 | case $arg in #( 184 | -*) false ;; # don't mess with options #( 185 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 186 | [ -e "$t" ] ;; #( 187 | *) false ;; 188 | esac 189 | then 190 | arg=$( cygpath --path --ignore --mixed "$arg" ) 191 | fi 192 | # Roll the args list around exactly as many times as the number of 193 | # args, so each arg winds up back in the position where it started, but 194 | # possibly modified. 195 | # 196 | # NB: a `for` loop captures its iteration list before it begins, so 197 | # changing the positional parameters here affects neither the number of 198 | # iterations, nor the values presented in `arg`. 199 | shift # remove old arg 200 | set -- "$@" "$arg" # push replacement arg 201 | done 202 | fi 203 | 204 | 205 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 206 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 207 | 208 | # Collect all arguments for the java command: 209 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 210 | # and any embedded shellness will be escaped. 211 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 212 | # treated as '${Hostname}' itself on the command line. 213 | 214 | set -- \ 215 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 216 | -classpath "$CLASSPATH" \ 217 | org.gradle.wrapper.GradleWrapperMain \ 218 | "$@" 219 | 220 | # Stop when "xargs" is not available. 221 | if ! command -v xargs >/dev/null 2>&1 222 | then 223 | die "xargs is not available" 224 | fi 225 | 226 | # Use "xargs" to parse quoted args. 227 | # 228 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 229 | # 230 | # In Bash we could simply go: 231 | # 232 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 233 | # set -- "${ARGS[@]}" "$@" 234 | # 235 | # but POSIX shell has neither arrays nor command substitution, so instead we 236 | # post-process each arg (as a line of input to sed) to backslash-escape any 237 | # character that might be a shell metacharacter, then use eval to reverse 238 | # that process (while maintaining the separation between arguments), and wrap 239 | # the whole thing up as a single "set" statement. 240 | # 241 | # This will of course break if any of these variables contains a newline or 242 | # an unmatched quote. 243 | # 244 | 245 | eval "set -- $( 246 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 247 | xargs -n1 | 248 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 249 | tr '\n' ' ' 250 | )" '"$@"' 251 | 252 | exec "$JAVACMD" "$@" 253 | -------------------------------------------------------------------------------- /psbridge/src/main/java/com/tomvd/services/EcoflowService.java: -------------------------------------------------------------------------------- 1 | package com.tomvd.services; 2 | 3 | import com.fasterxml.jackson.core.JsonParser; 4 | import com.fasterxml.jackson.databind.JsonNode; 5 | import com.fasterxml.jackson.databind.ObjectMapper; 6 | import com.fasterxml.jackson.databind.SerializationFeature; 7 | import com.fasterxml.jackson.databind.node.ObjectNode; 8 | import com.tomvd.configuration.DevicesConfiguration; 9 | import com.tomvd.configuration.MQTTConfiguration; 10 | import com.tomvd.converter.ProtobufConverter; 11 | import com.tomvd.model.PowerStreamData; 12 | import com.tomvd.psbridge.InverterHeartbeat; 13 | import io.micronaut.context.event.StartupEvent; 14 | import io.micronaut.runtime.event.annotation.EventListener; 15 | import io.micronaut.scheduling.annotation.Scheduled; 16 | import jakarta.inject.Inject; 17 | import jakarta.inject.Singleton; 18 | import org.eclipse.paho.client.mqttv3.*; 19 | import org.slf4j.Logger; 20 | import org.slf4j.LoggerFactory; 21 | 22 | import java.io.IOException; 23 | import java.util.HashMap; 24 | 25 | @Singleton 26 | public class EcoflowService implements DeviceService { 27 | private static final Logger LOG = LoggerFactory.getLogger(EcoflowService.class); 28 | private IMqttClient ecoflowClient; 29 | private final ObjectMapper objectMapper; 30 | private final ProtobufConverter converter; 31 | private final DevicesConfiguration devicesConfiguration; 32 | private final MQTTConfiguration mqttConfig; 33 | private ServiceLocator sl; 34 | String batteryTopic; 35 | 36 | private final HashMap data; 37 | 38 | @Inject 39 | public EcoflowService(ProtobufConverter converter, DevicesConfiguration devicesConfiguration, MQTTConfiguration mqttConfig) { 40 | this.objectMapper = new ObjectMapper(); 41 | this.converter = converter; 42 | this.devicesConfiguration = devicesConfiguration; 43 | this.mqttConfig = mqttConfig; 44 | data = new HashMap<>(); 45 | devicesConfiguration.getPowerstreams().forEach(device -> data.put(device, 46 | new PowerStreamData( 47 | 0, 48 | 0, 49 | "/sys/75/"+device+"/thing/protobuf/upstream", 50 | "/sys/75/"+device+"/thing/property/cmd" 51 | ))); 52 | batteryTopic = "/sys/72/" + devicesConfiguration.getBatteries().getFirst() 53 | + "/thing/property/post"; 54 | objectMapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false); 55 | } 56 | 57 | @Override 58 | public void setSl(ServiceLocator sl) { 59 | this.sl = sl; 60 | } 61 | 62 | @Override 63 | public PowerStreamData getPowerStreamData() {return data.get(devicesConfiguration.getPowerstreams().getFirst());} 64 | 65 | @EventListener 66 | public void onStartup(StartupEvent event) { 67 | if (devicesConfiguration.getPowerstreams().isEmpty()) { 68 | LOG.info("You forgot to configure any powerstream devices in the application.yml?"); 69 | } 70 | LOG.info("Starting MQTT bridge"); 71 | try { 72 | String mqttClientId = "psbridge-ec"; 73 | ecoflowClient = new MqttClient(mqttConfig.getServerUri(), mqttClientId); 74 | MqttConnectOptions options = new MqttConnectOptions(); 75 | options.setUserName(mqttConfig.getUserName()); 76 | options.setPassword(mqttConfig.getPassword() != null ? mqttConfig.getPassword().toCharArray() : null); 77 | 78 | // Set up some callbacks 79 | ecoflowClient.setCallback(new MqttCallback() { 80 | @Override 81 | public void connectionLost(Throwable throwable) { 82 | LOG.info("Disconnected from source broker: "+ throwable.toString()); 83 | } 84 | 85 | @Override 86 | public void messageArrived(String topic, MqttMessage mqttMessage) throws IOException { 87 | String[] parts = topic.split("/"); 88 | String deviceId = parts[3]; 89 | if (topic.equals(batteryTopic)) 90 | handleJsonMessage(mqttMessage); 91 | else { 92 | if (topic.equals(data.get(deviceId).upstreamTopic())) 93 | handleProtobufMessage(topic, mqttMessage, deviceId); 94 | } 95 | 96 | } 97 | 98 | @Override 99 | public void deliveryComplete(IMqttDeliveryToken iMqttDeliveryToken) { 100 | // do nothing 101 | } 102 | }); 103 | ecoflowClient.connect(options); 104 | devicesConfiguration.getPowerstreams().forEach(device -> { 105 | try { 106 | ecoflowClient.subscribe(data.get(device).upstreamTopic(), 1); 107 | } catch (MqttException e) { 108 | LOG.error("Error starting MQTT bridge", e); 109 | } 110 | }); 111 | if (!devicesConfiguration.getBatteries().isEmpty()) 112 | ecoflowClient.subscribe(batteryTopic, 1); 113 | if (ecoflowClient.isConnected()) { 114 | publishPowerSetting(0); 115 | } 116 | } catch (MqttException e) { 117 | LOG.error("Error starting MQTT bridge", e); 118 | } 119 | } 120 | 121 | @Override 122 | public void publishPowerSetting(int i) { 123 | publishPowerSetting(i, devicesConfiguration.getPowerstreams().getFirst()); 124 | } 125 | 126 | @Override 127 | public void publishPowerSetting(int i, String deviceId) { 128 | //LOG.info("Publishing to powerstream"); 129 | byte[] payload = converter.getPowerSettingPayload(i, deviceId); 130 | MqttMessage msg = new MqttMessage(); 131 | msg.setPayload(payload); 132 | msg.setQos(0); 133 | msg.setRetained(false); 134 | try { 135 | ecoflowClient.publish(data.get(deviceId).commandTopic(), msg); 136 | } catch (MqttException e) { 137 | throw new RuntimeException(e); 138 | } 139 | } 140 | 141 | @Scheduled(fixedDelay = "5s") 142 | void executeHeartBeat() { 143 | if (ecoflowClient != null && ecoflowClient.isConnected() && sl.getApplicationService().isOnline()) { 144 | devicesConfiguration.getPowerstreams().forEach(device -> { 145 | try { 146 | publishHeartBeat(device); 147 | } catch (MqttException e) { 148 | throw new RuntimeException(e); 149 | } 150 | }); 151 | } 152 | } 153 | 154 | private void publishHeartBeat(String deviceId) throws MqttException { 155 | byte[] payload = converter.convertHeartBeat(); 156 | MqttMessage msg = new MqttMessage(); 157 | msg.setPayload(payload); 158 | msg.setQos(0); 159 | msg.setRetained(false); 160 | ecoflowClient.publish(data.get(deviceId).commandTopic(), msg); 161 | } 162 | 163 | private void handleProtobufMessage(String topic, MqttMessage message, String deviceId) { 164 | try { 165 | LOG.debug("Received message on topic {}", topic); 166 | 167 | byte[] payload = message.getPayload(); 168 | InverterHeartbeat inverterHeartbeat = converter.convert(payload); 169 | if (inverterHeartbeat != null && sl.getApplicationService().isOnline()) { 170 | ObjectNode jsonNode = objectMapper.createObjectNode(); 171 | 172 | jsonNode.put("invOutputWatts", inverterHeartbeat.getInvOutputWatts()/10.0); 173 | jsonNode.put("llcTemp", inverterHeartbeat.getLlcTemp()/10.0); 174 | jsonNode.put("permanentWatts", inverterHeartbeat.getPermanentWatts()/10.0); 175 | jsonNode.put("pv1InputVolt", inverterHeartbeat.getPv1InputVolt()/10.0); 176 | jsonNode.put("pv1InputCur", inverterHeartbeat.getPv1InputCur()/10.0); 177 | jsonNode.put("pv2InputVolt", inverterHeartbeat.getPv2InputVolt()/10.0); 178 | jsonNode.put("pv2InputCur", inverterHeartbeat.getPv2InputCur()/10.0); 179 | jsonNode.put("last_updated", System.currentTimeMillis()); 180 | 181 | String json = objectMapper.writeValueAsString(jsonNode); 182 | sl.getApplicationService().publishJsonState(deviceId, json); 183 | 184 | data.replace(deviceId, data.get(deviceId).withCurrentPower(inverterHeartbeat.getInvOutputWatts()/10)); 185 | data.replace(deviceId, data.get(deviceId).withAvgVoltage((inverterHeartbeat.getPv1InputVolt()+inverterHeartbeat.getPv2InputVolt())/20.0)); 186 | } 187 | 188 | } catch (Exception e) { 189 | LOG.error("Error processing message", e); 190 | } 191 | } 192 | 193 | private void handleJsonMessage(MqttMessage mqttMessage) throws IOException { 194 | JsonParser parser = null; 195 | try { 196 | parser = objectMapper.createParser(mqttMessage.getPayload()); 197 | JsonNode rootNode = parser.readValueAsTree(); 198 | String typeCode = rootNode.get("typeCode").asText(); 199 | if (typeCode.equals("bmsStatus")) { 200 | JsonNode params = rootNode.get("params"); 201 | ObjectNode jsonNode = objectMapper.createObjectNode(); 202 | 203 | jsonNode.put("soc", params.get("f32ShowSoc").asDouble()); 204 | jsonNode.put("last_updated", System.currentTimeMillis()); 205 | 206 | String json = objectMapper.writeValueAsString(jsonNode); 207 | sl.getApplicationService().publishJsonState(devicesConfiguration.getBatteries().getFirst(), json); 208 | } 209 | } catch (IOException | MqttException e) { 210 | throw new RuntimeException(e); 211 | } finally { 212 | if (parser != null) parser.close(); 213 | } 214 | } 215 | 216 | } 217 | -------------------------------------------------------------------------------- /psbridge/src/main/java/com/tomvd/services/HomeAssistantService.java: -------------------------------------------------------------------------------- 1 | package com.tomvd.services; 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper; 4 | import com.fasterxml.jackson.databind.node.ObjectNode; 5 | import com.tomvd.configuration.DevicesConfiguration; 6 | import com.tomvd.configuration.MQTTConfiguration; 7 | import com.tomvd.configuration.SmartConfiguration; 8 | import io.micronaut.context.event.StartupEvent; 9 | import io.micronaut.runtime.event.annotation.EventListener; 10 | import jakarta.inject.Inject; 11 | import jakarta.inject.Singleton; 12 | import org.eclipse.paho.client.mqttv3.*; 13 | import org.slf4j.Logger; 14 | import org.slf4j.LoggerFactory; 15 | 16 | import java.nio.charset.StandardCharsets; 17 | 18 | @Singleton 19 | public class HomeAssistantService implements ApplicationService { 20 | private static final Logger LOG = LoggerFactory.getLogger(HomeAssistantService.class); 21 | private IMqttClient haClient; 22 | private final ObjectMapper objectMapper; 23 | private final DevicesConfiguration devicesConfiguration; 24 | private final MQTTConfiguration mqttConfig; 25 | private final SmartConfiguration smartConfiguration; 26 | private ServiceLocator sl; 27 | private final String batteryId; 28 | private static final String TARGET_TOPIC = "ecoflow/"; 29 | private Integer gridPower; 30 | private Boolean smartEnabled; 31 | private Integer soc; 32 | private Boolean chargerEnabled; 33 | 34 | @Inject 35 | public HomeAssistantService(DevicesConfiguration devicesConfiguration, MQTTConfiguration mqttConfig, SmartConfiguration smartConfiguration) { 36 | this.objectMapper = new ObjectMapper(); 37 | this.devicesConfiguration = devicesConfiguration; 38 | this.mqttConfig = mqttConfig; 39 | this.smartConfiguration = smartConfiguration; 40 | batteryId = devicesConfiguration.getBatteries().isEmpty()?null:devicesConfiguration.getBatteries().getFirst(); 41 | } 42 | 43 | @Override 44 | public void setSl(ServiceLocator sl) { 45 | this.sl = sl; 46 | } 47 | 48 | @EventListener 49 | public void onStartup(StartupEvent event) { 50 | try { 51 | String mqttClientId = "psbridge-ha"; 52 | haClient = new MqttClient(mqttConfig.getServerUri(), mqttClientId); 53 | MqttConnectOptions options = new MqttConnectOptions(); 54 | options.setUserName(mqttConfig.getUserName()); 55 | options.setPassword(mqttConfig.getPassword() != null ? mqttConfig.getPassword().toCharArray() : null); 56 | 57 | haClient.setCallback(getMqttCallback()); 58 | haClient.connect(options); 59 | devicesConfiguration.getPowerstreams().forEach(ps -> 60 | { 61 | try { 62 | haClient.subscribe(TARGET_TOPIC +ps+"/setpower", 1); 63 | } catch (MqttException e) { 64 | LOG.error("Error starting MQTT bridge", e); 65 | } 66 | } 67 | ); 68 | if (smartConfiguration.isEnabled()) { 69 | haClient.subscribe(smartConfiguration.getMeterTopic(), 1); 70 | haClient.subscribe(smartConfiguration.getEnabledTopic(), 1); 71 | haClient.subscribe(smartConfiguration.getSocTopic(), 1); 72 | } 73 | if (haClient.isConnected() && mqttConfig.isEnableDiscovery()) { 74 | publishHomeAssistantDiscovery(); 75 | if (batteryId != null) { 76 | publishHomeAssistantBatteryDiscovery(batteryId); 77 | } 78 | } 79 | 80 | } catch (MqttException e) { 81 | LOG.error("Error starting MQTT bridge", e); 82 | } 83 | } 84 | 85 | private MqttCallback getMqttCallback() { 86 | return new MqttCallback() { 87 | @Override 88 | public void connectionLost(Throwable throwable) { 89 | LOG.info("Disconnected from target broker: {}", throwable.toString()); 90 | } 91 | @Override 92 | public void messageArrived(String topic, MqttMessage mqttMessage) { 93 | if (topic.equals(smartConfiguration.getMeterTopic())) { 94 | handleMeterMessage(mqttMessage); 95 | } else if (topic.equals(smartConfiguration.getEnabledTopic())) { 96 | handleEnabledMessage(mqttMessage); 97 | } else if (topic.equals(smartConfiguration.getSocTopic())) { 98 | handleSocMessage(mqttMessage); 99 | } else { 100 | handlePowerMessage(mqttMessage, topic); 101 | } 102 | } 103 | @Override 104 | public void deliveryComplete(IMqttDeliveryToken iMqttDeliveryToken) { 105 | // nothing 106 | } 107 | }; 108 | } 109 | 110 | private void handleEnabledMessage(MqttMessage mqttMessage) { 111 | String str = new String(mqttMessage.getPayload(), StandardCharsets.UTF_8); 112 | smartEnabled = str.equalsIgnoreCase("ON"); 113 | } 114 | 115 | private void handleMeterMessage(MqttMessage mqttMessage) { 116 | String str = new String(mqttMessage.getPayload(), StandardCharsets.UTF_8); 117 | try { 118 | gridPower = (int) Double.parseDouble(str); 119 | } catch (NumberFormatException e) { 120 | gridPower = null; 121 | } 122 | } 123 | 124 | private void handlePowerMessage(MqttMessage mqttMessage, String topic) { 125 | String str = new String(mqttMessage.getPayload(), StandardCharsets.UTF_8); 126 | String[] parts = topic.split("/"); 127 | String deviceId = parts[1]; 128 | int value = Integer.parseInt(str); 129 | if (value >= 0 && value < 800) { 130 | sl.getDeviceService().publishPowerSetting(value, deviceId); 131 | } 132 | } 133 | 134 | private void handleSocMessage(MqttMessage mqttMessage) { 135 | String str = new String(mqttMessage.getPayload(), StandardCharsets.UTF_8); 136 | try { 137 | soc = (int) Double.parseDouble(str); 138 | }catch (NumberFormatException e) { 139 | soc = null; 140 | } 141 | } 142 | 143 | private void publishHomeAssistantDiscovery() { 144 | devicesConfiguration.getPowerstreams().forEach(this::publishHomeAssistantDiscovery); 145 | } 146 | 147 | private void publishHomeAssistantDiscovery(String powerstreamId) { 148 | String id = "ps"+(devicesConfiguration.getPowerstreams().indexOf(powerstreamId)+1); 149 | try { 150 | // Create Home Assistant discovery message 151 | ObjectNode discoveryInfo = objectMapper.createObjectNode(); 152 | discoveryInfo.put("state_topic", TARGET_TOPIC +powerstreamId+"/state"); 153 | discoveryInfo.put("qos", 2); 154 | 155 | ObjectNode device = discoveryInfo.putObject("dev"); 156 | device.put("ids", powerstreamId); 157 | device.put("name", powerstreamId); 158 | device.put("mf", "EcoFlow"); 159 | device.put("mdl", "PowerStream"); 160 | device.put("sw", ""); // TODO 161 | device.put("sn", powerstreamId); 162 | device.put("hw", ""); // TODO 163 | 164 | // Add origin information 165 | ObjectNode origin = discoveryInfo.putObject("o"); 166 | origin.put("name", "psbridge"); 167 | origin.put("sw", "0.1"); 168 | origin.put("url", "https://github.com/tomvd/local-powerstream"); 169 | 170 | // Add the description of the components within this device 171 | ObjectNode components = discoveryInfo.putObject("cmps"); 172 | ObjectNode cmp0 = components.putObject("SetOutputWatts"); 173 | cmp0.put("p", "number"); 174 | cmp0.put("command_topic", TARGET_TOPIC +powerstreamId+"/setpower"); 175 | cmp0.put("state_topic", TARGET_TOPIC +powerstreamId+"/state"); 176 | cmp0.put("value_template", "{{ value_json.permanentWatts}}"); 177 | cmp0.put("device_class", "power"); 178 | cmp0.put("unit_of_measurement", "W"); 179 | cmp0.put("min", 0); 180 | cmp0.put("max", 800); 181 | cmp0.put("unique_id", id+"_power_set"); 182 | cmp0.put("mode", "slider"); 183 | cmp0.put("name", "SetOutputWatts"); 184 | 185 | 186 | ObjectNode cmp1 = components.putObject("OutputWatts"); 187 | cmp1.put("p", "sensor"); 188 | cmp1.put("device_class", "power"); 189 | cmp1.put("unit_of_measurement", "W"); 190 | cmp1.put("value_template", "{{ value_json.invOutputWatts}}"); 191 | cmp1.put("unique_id", id+"_power_out"); 192 | cmp1.put("name", "OutputWatts"); 193 | 194 | ObjectNode cmp2 = components.putObject("PermanentWatts"); 195 | cmp2.put("p", "sensor"); 196 | cmp2.put("device_class", "power"); 197 | cmp2.put("unit_of_measurement", "W"); 198 | cmp2.put("value_template", "{{ value_json.permanentWatts}}"); 199 | cmp2.put("unique_id", id+"_permanent_watts"); 200 | cmp2.put("name", "PermanentWatts"); 201 | 202 | ObjectNode cmp3 = components.putObject("LlcTemp"); 203 | cmp3.put("p", "sensor"); 204 | cmp3.put("device_class", "temperature"); 205 | cmp3.put("unit_of_measurement", "°C"); 206 | cmp3.put("value_template", "{{ value_json.llcTemp}}"); 207 | cmp3.put("unique_id", id+"_llc_temp"); 208 | 209 | ObjectNode cmp4 = components.putObject("pv1InputVolt"); 210 | cmp4.put("p", "sensor"); 211 | cmp4.put("device_class", "voltage"); 212 | cmp4.put("unit_of_measurement", "V"); 213 | cmp4.put("value_template", "{{ value_json.pv1InputVolt}}"); 214 | cmp4.put("unique_id", id+"_pv1_volt"); 215 | cmp4.put("name", "pv1InputVolt"); 216 | 217 | ObjectNode cmp5 = components.putObject("pv1InputCur"); 218 | cmp5.put("p", "sensor"); 219 | cmp5.put("device_class", "current"); 220 | cmp5.put("unit_of_measurement", "A"); 221 | cmp5.put("value_template", "{{ value_json.pv1InputCur}}"); 222 | cmp5.put("unique_id", id+"_pv1_cur"); 223 | cmp5.put("name", "pv1InputCur"); 224 | 225 | ObjectNode cmp6 = components.putObject("pv2InputVolt"); 226 | cmp6.put("p", "sensor"); 227 | cmp6.put("device_class", "voltage"); 228 | cmp6.put("unit_of_measurement", "V"); 229 | cmp6.put("value_template", "{{ value_json.pv2InputVolt}}"); 230 | cmp6.put("unique_id", id+"_pv2_volt"); 231 | cmp6.put("name", "pv2InputVolt"); 232 | 233 | ObjectNode cmp7 = components.putObject("pv2InputCur"); 234 | cmp7.put("p", "sensor"); 235 | cmp7.put("device_class", "current"); 236 | cmp7.put("unit_of_measurement", "A"); 237 | cmp7.put("value_template", "{{ value_json.pv2InputCur}}"); 238 | cmp7.put("unique_id", id+"_pv2_cur"); 239 | cmp7.put("name", "pv2InputCur"); 240 | 241 | // Discovery topic format: homeassistant/device/HWxxx/config 242 | String discoveryTopic = String.format("homeassistant/device/%s/config", powerstreamId); 243 | 244 | // Publish discovery information 245 | MqttMessage discoveryMessage = new MqttMessage(); 246 | discoveryMessage.setPayload(objectMapper.writeValueAsBytes(discoveryInfo)); 247 | discoveryMessage.setQos(1); 248 | discoveryMessage.setRetained(true); 249 | 250 | haClient.publish(discoveryTopic, discoveryMessage); 251 | 252 | } catch (Exception e) { 253 | LOG.error("Error publishing Home Assistant discovery information", e); 254 | } 255 | } 256 | 257 | private void publishHomeAssistantBatteryDiscovery(String batteryId) { 258 | try { 259 | // Create Home Assistant discovery message 260 | ObjectNode discoveryInfo = objectMapper.createObjectNode(); 261 | discoveryInfo.put("state_topic", TARGET_TOPIC +batteryId+"/state"); 262 | discoveryInfo.put("qos", 2); 263 | 264 | ObjectNode device = discoveryInfo.putObject("dev"); 265 | device.put("ids", batteryId); 266 | device.put("name", "Battery1"); 267 | device.put("mf", "EcoFlow"); 268 | device.put("mdl", "River 2 Pro"); 269 | device.put("sw", ""); // TODO 270 | device.put("sn", batteryId); 271 | device.put("hw", ""); // TODO 272 | 273 | // Add origin information 274 | ObjectNode origin = discoveryInfo.putObject("o"); 275 | origin.put("name", "psbridge"); 276 | origin.put("sw", ""); // TODO 277 | origin.put("url", "https://github.com/tomvd/local-powerstream"); 278 | 279 | // Add the description of the components within this device 280 | ObjectNode components = discoveryInfo.putObject("cmps"); 281 | /* ObjectNode cmp0 = components.putObject("SetOutputWatts"); 282 | cmp0.put("p", "number"); 283 | cmp0.put("command_topic", targetTopic+powerstreamId+"/setpower"); 284 | cmp0.put("state_topic", targetTopic+powerstreamId+"/state"); 285 | cmp0.put("value_template", "{{ value_json.permanentWatts}}"); 286 | cmp0.put("device_class", "power"); 287 | cmp0.put("unit_of_measurement", "W"); 288 | cmp0.put("min", 0); 289 | cmp0.put("max", 800); 290 | cmp0.put("unique_id", id+"_power_set"); 291 | cmp0.put("mode", "slider"); 292 | cmp0.put("name", "SetOutputWatts"); 293 | 294 | */ 295 | ObjectNode cmp1 = components.putObject("SoC"); 296 | cmp1.put("p", "sensor"); 297 | cmp1.put("device_class", "battery"); 298 | cmp1.put("unit_of_measurement", "%"); 299 | cmp1.put("value_template", "{{ value_json.soc}}"); 300 | cmp1.put("unique_id", "bt1_soc"); 301 | cmp1.put("name", "State of charge"); 302 | 303 | // Discovery topic format: homeassistant/device/HWxxx/config 304 | String discoveryTopic = String.format("homeassistant/device/%s/config", batteryId); 305 | 306 | // Publish discovery information 307 | MqttMessage discoveryMessage = new MqttMessage(); 308 | discoveryMessage.setPayload(objectMapper.writeValueAsBytes(discoveryInfo)); 309 | discoveryMessage.setQos(1); 310 | discoveryMessage.setRetained(true); 311 | 312 | haClient.publish(discoveryTopic, discoveryMessage); 313 | 314 | } catch (Exception e) { 315 | LOG.error("Error publishing Home Assistant discovery information", e); 316 | } 317 | } 318 | 319 | @Override 320 | public boolean isOnline() { 321 | return haClient.isConnected(); 322 | } 323 | 324 | @Override 325 | public void publishJsonState(String id, String json) throws MqttException { 326 | // Publish to target broker 327 | MqttMessage targetMessage = new MqttMessage(); 328 | targetMessage.setPayload(json.getBytes(StandardCharsets.UTF_8)); 329 | targetMessage.setQos(1); 330 | targetMessage.setRetained(true); 331 | 332 | haClient.publish(TARGET_TOPIC +id+ "/state", targetMessage); 333 | } 334 | 335 | @Override 336 | public Integer getGridPower() { 337 | return gridPower; 338 | } 339 | 340 | @Override 341 | public Boolean getSmartEnabled() { 342 | return smartEnabled; 343 | } 344 | 345 | @Override 346 | public Integer getSoc() { 347 | return soc; 348 | } 349 | 350 | @Override 351 | public Boolean getChargerEnabled() { 352 | return chargerEnabled; 353 | } 354 | 355 | @Override 356 | public void setCharger(Boolean enabled) { 357 | chargerEnabled = enabled; 358 | // Publish to target broker 359 | MqttMessage targetMessage = new MqttMessage(); 360 | targetMessage.setPayload(Boolean.TRUE.equals(enabled)?"on".getBytes(StandardCharsets.UTF_8):"off".getBytes(StandardCharsets.UTF_8)); 361 | targetMessage.setQos(1); 362 | targetMessage.setRetained(true); 363 | 364 | try { 365 | haClient.publish(smartConfiguration.getChargerTopic(), targetMessage); 366 | } catch (MqttException e) { 367 | throw new RuntimeException(e); 368 | } 369 | } 370 | } 371 | --------------------------------------------------------------------------------