├── 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 | 
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 | 
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 | 
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 |
--------------------------------------------------------------------------------