├── .dockerignore ├── .gitattributes ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── assembly.xml ├── doc ├── api │ ├── DontChargeDischarge.md │ └── SwitchOnOff.md ├── input │ ├── Emlog.md │ ├── Fronius.md │ ├── GenericHttp.md │ ├── HomeAssistant.md │ ├── IoBroker.md │ ├── Mqtt.md │ ├── Shelly3Em.md │ ├── ShellyPro3Em.md │ ├── ShrDzm.md │ ├── SmaEnergyMeter.md │ ├── Smd120.md │ ├── Solaredge.md │ ├── Tasmota.md │ ├── TibberPulse.md │ └── VzLogger.md ├── install │ ├── BareMetal.md │ ├── Building.md │ ├── Docker.md │ └── HomeAssistant.md └── output │ ├── Common.md │ ├── EcoTracker.md │ └── ShellyPro3EM.md ├── docker ├── docker-compose.yaml ├── logback.xml └── uni-meter.conf ├── ha_addon ├── Dockerfile ├── config.yaml ├── repository.yaml ├── run.sh ├── translations │ ├── de.yaml │ └── en.yaml └── uni-meter-mdns.py ├── pom.xml ├── repository.yaml ├── samples ├── emlog │ └── uni-meter.conf ├── fronius │ └── uni-meter.conf ├── generic_http │ ├── mono-phase │ │ └── uni-meter.conf │ └── tri-phase │ │ └── uni-meter.conf ├── ioBroker │ └── uni-meter.conf ├── mqtt │ └── uni-meter.conf ├── sma │ └── uni-meter.conf ├── solaredge │ └── uni-meter.conf ├── srhdzm │ └── uni-meter.conf ├── tasmota │ └── uni-meter.conf ├── tibber │ └── uni-meter.conf └── vz_logger │ ├── classic │ └── uni-meter.conf │ └── generic_http │ └── uni-meter.conf └── src ├── main ├── java-templates │ └── Version.java ├── java │ └── com │ │ └── deigmueller │ │ └── uni_meter │ │ ├── application │ │ ├── Application.java │ │ ├── HttpServer.java │ │ ├── HttpServerController.java │ │ ├── UdpBindFlow.java │ │ ├── UdpServer.java │ │ ├── UniMeter.java │ │ ├── UniMeterHttpRoute.java │ │ ├── WebsocketInput.java │ │ └── WebsocketOutput.java │ │ ├── common │ │ ├── shelly │ │ │ ├── Rpc.java │ │ │ ├── RpcError.java │ │ │ └── RpcException.java │ │ └── utils │ │ │ ├── Json.java │ │ │ ├── MathUtils.java │ │ │ └── NetUtils.java │ │ ├── input │ │ ├── InputDevice.java │ │ └── device │ │ │ ├── common │ │ │ ├── generic │ │ │ │ ├── BaseChannelReader.java │ │ │ │ ├── ChannelReader.java │ │ │ │ ├── GenericInputDevice.java │ │ │ │ ├── JsonChannelReader.java │ │ │ │ └── ValueChannelReader.java │ │ │ └── http │ │ │ │ └── HttpInputDevice.java │ │ │ ├── generic_http │ │ │ └── GenericHttp.java │ │ │ ├── home_assistant │ │ │ ├── Entity.java │ │ │ └── HomeAssistant.java │ │ │ ├── modbus │ │ │ ├── Modbus.java │ │ │ ├── sdm120 │ │ │ │ └── Sdm120.java │ │ │ └── solaredge │ │ │ │ └── Solaredge.java │ │ │ ├── mqtt │ │ │ ├── JsonTopicReader.java │ │ │ ├── Mqtt.java │ │ │ ├── TopicReader.java │ │ │ └── ValueTopicReader.java │ │ │ ├── shelly │ │ │ ├── _3em │ │ │ │ └── Shelly3EM.java │ │ │ └── pro3em │ │ │ │ └── ShellyPro3EM.java │ │ │ ├── shrdzm │ │ │ ├── ShrDzm.java │ │ │ └── ShrDzmPacket.java │ │ │ ├── sma │ │ │ └── energy_meter │ │ │ │ ├── EnergyMeter.java │ │ │ │ ├── ObisChannel.java │ │ │ │ ├── ProtocolParser.java │ │ │ │ └── Telegram.java │ │ │ ├── sml │ │ │ └── Sml.java │ │ │ ├── tasmota │ │ │ └── Tasmota.java │ │ │ ├── tibber │ │ │ └── pulse │ │ │ │ └── Pulse.java │ │ │ └── vzlogger │ │ │ └── VzLogger.java │ │ ├── mdns │ │ ├── MDnsAvahi.java │ │ ├── MDnsHandle.java │ │ ├── MDnsHomeAssistant.java │ │ ├── MDnsKind.java │ │ ├── MDnsNone.java │ │ └── MDnsRegistrator.java │ │ └── output │ │ ├── ClientContext.java │ │ ├── ClientContextsInitializer.java │ │ ├── OutputDevice.java │ │ ├── TemporaryNotAvailableException.java │ │ ├── TimerOverride.java │ │ ├── UsageConstraint.java │ │ └── device │ │ ├── eco_tracker │ │ ├── EcoTracker.java │ │ └── HttpRoute.java │ │ └── shelly │ │ ├── HttpRoute.java │ │ ├── Shelly.java │ │ └── ShellyPro3EM.java ├── resources-filtered │ └── bin │ │ ├── uni-meter-and-avahi.sh │ │ └── uni-meter.sh └── resources │ ├── config │ ├── avahi │ │ ├── shellypro3em.http.tcp.service │ │ └── shellypro3em.shelly.tcp.service │ ├── logback.xml │ ├── systemd │ │ └── uni-meter.service │ └── uni-meter.conf │ ├── logback.xml │ └── reference.conf └── test └── java └── com └── deigmueller └── uni_meter ├── input └── device │ ├── mqtt │ └── MqttTestWriter.java │ ├── shelly │ └── _3em │ │ └── Shelly3EMTestServer.java │ └── tasmota │ └── TasmotaTestServer.java └── output └── TimerOverrideTest.java /.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | README.md 3 | samples/ 4 | target/ 5 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.sh text eol=lf -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | !.mvn/wrapper/maven-wrapper.jar 3 | !**/src/main/**/target/ 4 | !**/src/test/**/target/ 5 | 6 | ### IntelliJ IDEA ### 7 | .idea/** 8 | 9 | ### Eclipse ### 10 | .apt_generated 11 | .classpath 12 | .factorypath 13 | .project 14 | .settings 15 | .springBeans 16 | .sts4-cache 17 | 18 | ### NetBeans ### 19 | /nbproject/private/ 20 | /nbbuild/ 21 | /dist/ 22 | /nbdist/ 23 | /.nb-gradle/ 24 | build/ 25 | !**/src/main/**/build/ 26 | !**/src/test/**/build/ 27 | 28 | ### VS Code ### 29 | .vscode/ 30 | 31 | ### Mac OS ### 32 | .DS_Store 33 | 34 | ### Local things ### 35 | .local -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3 AS builder 2 | WORKDIR /src 3 | RUN apk add --no-cache openjdk17-jdk maven 4 | COPY . /src 5 | RUN mvn install 6 | 7 | # --- 8 | 9 | FROM alpine:3 10 | # Install Java 17 & avahi & disable d-bus 11 | RUN apk add --no-cache openjdk17-jre-headless avahi bash && \ 12 | sed -i 's/.*enable-dbus=.*/enable-dbus=no/' /etc/avahi/avahi-daemon.conf && \ 13 | sed -i 's/#allow-interfaces=eth0/allow-interfaces=eth0/' /etc/avahi/avahi-daemon.conf 14 | 15 | # Install uni-meter 16 | RUN --mount=type=bind,target=/helper,source=/src,from=builder \ 17 | mkdir -p /opt/uni-meter && \ 18 | tar --strip-components 1 -xzf /helper/target/uni-meter-*.tgz -C /opt/uni-meter && \ 19 | cp /opt/uni-meter/config/uni-meter.conf /etc 20 | 21 | ENTRYPOINT ["/opt/uni-meter/bin/uni-meter-and-avahi.sh"] 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # uni-meter 2 | 3 | `uni-meter` is a small tool that emulates an electrical meter like a Shelly Pro3EM or an EcoTracker based on the 4 | input data from a variety of input devices with the main purpose to control a storage system like the Hoymiles MS-A2 5 | or the Marstek Venus. The `uni-meter` is not a full implementation of the emulated devices. Only the parts that are 6 | needed by the storage systems are implemented. Especially the update of the data in the Shelly or Everhome cloud 7 | is not possible due to undocumented proprietary APIs. As a consequence, storage devices which are dependent on 8 | information from the Shelly or Everhome cloud are not supported. 9 | 10 | The idea is to further enhance the tool in the future by adding more input and output devices to get a universal 11 | converter between different electrical meters, inverters and storage systems. 12 | 13 | Currently, the following output devices are supported: 14 | 15 | * Shelly Pro 3EM 16 | * Everhome EcoTracker 17 | 18 | The real electrical meter data can be gathered from the following input devices: 19 | 20 | - Emlog smart meter 21 | - Fronius smart meter 22 | - Generic HTTP (configurable HTTP interface, usable for many devices) 23 | - Home Assistant sensors 24 | - ioBroker datapoints (via simple API adapter) 25 | - MQTT 26 | - Shelly 3EM 27 | - Shelly Pro 3EM 28 | - SHRDZM smartmeter interface module (UDP) 29 | - SMA energy meter / Sunny Home Manager (UDP protocol) 30 | - SMD120 modbus energy meter (via Protos PE11) (SMD630 could be added, I have no test device) 31 | - Solaredge 32 | - Tasmota IR read head (via HTTP) 33 | - Tibber Pulse (local API) 34 | - VzLogger webserver 35 | 36 | Storages known to be supported are 37 | 38 | * Hoymiles MS-A2 39 | * Marstek Venus 40 | 41 | The tool is built in Java and needs at least a Java 17 Runtime. If you want to use an ESP32 or a similar system which 42 | does not support Java 17, there is another project called [Energy2Shelly](https://github.com/TheRealMoeder/Energy2Shelly_ESP) which is written in C++ and can be used 43 | as an alternative on such systems. 44 | 45 | ## Installation 46 | 47 | There are different installation options available. You can choose to 48 | 49 | * **install the tool on a [Physical Server](doc/install/BareMetal.md)** 50 | * **install the tool as [Home Assistant Add-On](doc/install/HomeAssistant.md)** 51 | * **run the tool as [Docker Container](doc/install/Docker.md)** 52 | * **or build it from [Source Code](doc/install/Building.md)** 53 | 54 | ## Configuration 55 | 56 | The configuration is done using a configuration file in the [HOCON format](https://github.com/lightbend/config/blob/main/HOCON.md). 57 | 58 | The basic structure of that configuration file is common in all setups. 59 | 60 | You can configure the HTTP server port used by the ``uni-meter`` itself for its external API which defaults to port 80. 61 | Additionally, you have to choose which input device to use and which output device to use. Based on that choice, there 62 | are device-specific configuration sections for the input and output device. 63 | 64 | ```hocon 65 | uni-meter { 66 | output = "uni-meter.output-devices." 67 | 68 | input = "uni-meter.input-devices." 69 | 70 | http-server { 71 | port = 80 72 | } 73 | 74 | output-devices { 75 | { 76 | # ... 77 | } 78 | } 79 | 80 | input-devices { 81 | { 82 | # ... 83 | } 84 | } 85 | } 86 | ``` 87 | 88 | Some sample configurations for different devices can be found [here](https://github.com/sdeigm/uni-meter/tree/main/samples). 89 | 90 | ## Output device configuration 91 | 92 | To configure the output device, follow the instructions in these sections: 93 | 94 | * **[Common configuration for all devices](doc/output/Common.md)** 95 | * **[Eco-Tracker](doc/output/EcoTracker.md)** 96 | * **[Shelly Pro 3EM](doc/output/ShellyPro3EM.md)** 97 | 98 | ## Input device configuration 99 | 100 | To configure the input device, follow the instructions in these sections: 101 | 102 | * **[Emlog](doc/input/Emlog.md)** 103 | * **[Fronius](doc/input/Fronius.md)** 104 | * **[Generic HTTP](doc/input/GenericHttp.md)** 105 | * **[Home Assistant](doc/input/HomeAssistant.md)** 106 | * **[ioBroker](doc/input/IoBroker.md)** 107 | * **[MQTT](doc/input/Mqtt.md)** 108 | * **[Shelly 3EM](doc/input/Shelly3Em.md)** 109 | * **[Shelly Pro 3EM](doc/input/ShellyPro3Em.md)** 110 | * **[SHRDZM](doc/input/ShrDzm.md)** 111 | * **[SMA Energy Meter](doc/input/SmaEnergyMeter.md)** 112 | * **[SMD 120](doc/input/Smd120.md)** 113 | * **[Solaredge](doc/input/Solaredge.md)** 114 | * **[Tasmota](doc/input/Tasmota.md)** 115 | * **[Tibber Pulse](doc/input/TibberPulse.md)** 116 | * **[VzLogger](doc/input/VzLogger.md)** 117 | 118 | ## External API 119 | 120 | Starting with version 1.1.7, the `uni-meter` provides a REST API which can be used to externally control or change the 121 | behavior. That API might, for instance, be used via a cron job using curl or directly from a web-browser. 122 | 123 | * **[REST API to switch on/off](doc/api/SwitchOnOff.md)** 124 | * **[REST API to prevent charging/discharging](doc/api/DontChargeDischarge.md)** 125 | 126 | ## Troubleshooting 127 | 128 | `uni-meter` use the Java logback logging system. In the default setup only info, warning and error messages are written 129 | to the logfile/standard output. For debugging purposes you can set the log level to `DEBUG` or even 130 | `TRACE` within the logback configuration file. The location of that configuration file differs on your setup type. 131 | 132 | * For a physical server this is typically `/opt/uni-meter/config/logback.xml` 133 | * For a docker setup it is/can be provided as a parameter to your startup command 134 | * For a home assistant setup it can be placed in `/addon_configs/663b81ce_uni_meter` 135 | 136 | ```xml 137 | 138 | 139 | 140 | 141 | %d{yy-MM-dd HH:mm:ss.SSS} %-5level %-24logger - %msg%n 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | ``` 154 | 155 | A restart is necessary for these changes to take effect. 156 | 157 | -------------------------------------------------------------------------------- /assembly.xml: -------------------------------------------------------------------------------- 1 | 2 | distribution 3 | 4 | tgz 5 | 6 | true 7 | 8 | 9 | ${file.separator}lib 10 | 11 | 12 | 13 | 14 | 15 | unix 16 | ${project.build.outputDirectory}/config 17 | ${file.separator}config 18 | 19 | 20 | unix 21 | ${project.build.outputDirectory}/bin 22 | ${file.separator}bin 23 | 24 | 25 | 26 | 27 | 28 | ${project.build.outputDirectory}/bin/uni-meter.sh 29 | ${file.separator}bin 30 | uni-meter.sh 31 | unix 32 | 0755 33 | 34 | 35 | ${project.build.outputDirectory}/bin/uni-meter-and-avahi.sh 36 | ${file.separator}bin 37 | uni-meter-and-avahi.sh 38 | unix 39 | 0755 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /doc/api/DontChargeDischarge.md: -------------------------------------------------------------------------------- 1 | # Rest API to prevent charging/discharging of the storage 2 | 3 | This API is experimental. Since the `uni-meter` does not have any information about the current charging state of the 4 | storage, this feature relies on the storage's default behavior when no electrical meter readings are provided. So if 5 | charging shall be prevented, the `uni-meter` does not provide any electrical meter readings to the storage if electrical 6 | energy is feed into the grid. In the case of discharging prevention, it is the opposite. In both cases the storage 7 | should fall back to its default behavior, which should result in an inactive storage. 8 | 9 | To stop these operation modes again, you can use the `switch_on` API call as described [here](SwitchOnOff.md). 10 | 11 | ## Prevent charging 12 | 13 | Using this API call, you can prevent the storage from charging for a certain time. This might, for instance, be used to 14 | prevent charging before 11am to use your storage in a more "grid-friendly" way. The command takes an optional parameter for the duration of the behavior. 15 | Afterward, the `uni-meter` switches automatically back to its normal operation mode: 16 | 17 | `http://:/api/no_charge?seconds=3600` 18 | 19 | ## Prevent discharging 20 | 21 | Using this API call, you can prevent the storage from discharging for a certain time. This might, for instance, be used 22 | to prevent discharging while charging your BEV. The command takes an optional parameter for the duration of the behavior. 23 | Afterward, the `uni-meter` switches automatically back to its normal operation mode: 24 | 25 | `http://:/api/no_discharge?seconds=3600` 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /doc/api/SwitchOnOff.md: -------------------------------------------------------------------------------- 1 | # Rest API to switch on/off the `uni-meter` 2 | 3 | ## Switch off 4 | 5 | Using this API call, you can switch off the `uni-meter` for a certain time. When switched off, the connected storages 6 | do not receive any electrical meter readings and should fall back to their configured default behavior. That can, for 7 | instance, be used to avoid any interferences with the storage while loading your BEV. The command takes an optional 8 | parameter for the duration of the switch off which defaults to an unlimited duration. After the duration expires, the 9 | `uni-meter` is automatically switched on again: 10 | 11 | `http://:/api/switch_off?seconds=3600` 12 | 13 | ## Switch on 14 | 15 | If the `uni-meter` is switched off for a certain time period or unlimited, you can switch it on again using the 16 | following REST call: 17 | 18 | `http://:/api/switch_on` 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /doc/input/Emlog.md: -------------------------------------------------------------------------------- 1 | # Using an Emlog smart meter as the input source 2 | 3 | An Emlog smart meter can be accessed using the generic-http input source. To access the Emlog smart meter, use the 4 | following configuration in the `/etc/uni-meter.conf` file: 5 | 6 | ```hocon 7 | uni-meter { 8 | output = "uni-meter.output-devices.shelly-pro3em" 9 | 10 | input = "uni-meter.input-devices.generic-http" 11 | 12 | input-devices { 13 | generic-http { 14 | url = "http://192.168.x.x/pages/getinformation.php?export&meterindex=1" 15 | 16 | power-phase-mode = "tri-phase" 17 | energy-phase-mode = "mono-phase" 18 | 19 | channels = [{ 20 | type = "json" 21 | channel = "energy-consumption-total" 22 | json-path = "$.Zaehlerstand_Bezug.Stand180" 23 | scale = 1 24 | },{ 25 | type = "json" 26 | channel = "energy-production-total" 27 | json-path = "$.Zaehlerstand_Lieferung.Stand280" 28 | scale = 1 29 | },{ 30 | type = "json" 31 | channel = "power-l1" 32 | json-path = "$.Wirkleistung_Bezug.Leistung171" 33 | },{ 34 | type = "json" 35 | channel = "power-production-l1" 36 | json-path = "$.Wirkleistung_Lieferung.Leistung271" 37 | },{ 38 | type = "json" 39 | channel = "power-l2" 40 | json-path = "$.Wirkleistung_Bezug.Leistung172" 41 | },{ 42 | type = "json" 43 | channel = "power-production-l2" 44 | json-path = "$.Wirkleistung_Lieferung.Leistung272" 45 | },{ 46 | type = "json" 47 | channel = "power-l3" 48 | json-path = "$.Wirkleistung_Bezug.Leistung173" 49 | },{ 50 | type = "json" 51 | channel = "power-production-l3" 52 | json-path = "$.Wirkleistung_Lieferung.Leistung273" 53 | }] 54 | } 55 | } 56 | } 57 | ``` -------------------------------------------------------------------------------- /doc/input/Fronius.md: -------------------------------------------------------------------------------- 1 | # Using a Fronius smart meter as the input source 2 | 3 | A Fronius smart meter can be accessed using the generic-http input source. To access the Fronius smart meter, use the 4 | following configuration in the `/etc/uni-meter.conf` file: 5 | 6 | ```hocon 7 | uni-meter { 8 | output = "uni-meter.output-devices.shelly-pro3em" 9 | 10 | input = "uni-meter.input-devices.generic-http" 11 | 12 | input-devices { 13 | generic-http { 14 | url = "http://192.168.x.x/solar_api/v1/GetMeterRealtimeData.cgi?Scope=System" 15 | 16 | power-phase-mode = "tri-phase" 17 | 18 | channels = [{ 19 | type = "json" 20 | channel = "power-l1" 21 | json-path = "$.Body.Data.0.PowerReal_P_Phase_1" 22 | },{ 23 | type = "json" 24 | channel = "power-l2" 25 | json-path = "$.Body.Data.0.PowerReal_P_Phase_2" 26 | },{ 27 | type = "json" 28 | channel = "power-l3" 29 | json-path = "$.Body.Data.0.PowerReal_P_Phase_3" 30 | }] 31 | } 32 | } 33 | } 34 | ``` 35 | -------------------------------------------------------------------------------- /doc/input/GenericHttp.md: -------------------------------------------------------------------------------- 1 | # Using the generic HTTP input source 2 | 3 | The generic HTTP input source can be used to gather the electrical meter data from any device providing the data via 4 | an HTTP get request as JSON value. You have to configure the complete URL where the data is gathered from, including 5 | the entire path and query parameters. 6 | 7 | The input source can operate in two modes. Either in a `mono-phase` mode, where the power and/or the energy data is 8 | provided as a single value for all three phases, or in a `tri-phase` mode, where the power and/or the energy data is 9 | provided as a separate value for each phase. 10 | 11 | The input data is gathered through channels. The following channels exist for the different energy and power phase modes: 12 | * Power `mono-phase` 13 | * `power-total` - total current power 14 | * Power `tri-phase` 15 | * `power-l1` - current power phase 1 16 | * `power-l2` - current power phase 2 17 | * `power-l3` - current power phase 3 18 | * Energy `mono-phase` 19 | * `energy-consumption-total` - total energy consumption 20 | * `energy-production-total` - total energy production 21 | * Energy `tri-phase` 22 | * `energy-consumption-l1` - energy consumption phase 1 23 | * `energy-consumption-l2` - energy consumption phase 2 24 | * `energy-consumption-l3` - energy consumption phase 3 25 | * `energy-production-l1` - energy production phase 1 26 | * `energy-production-l2` - energy production phase 2 27 | * `energy-production-l3` - energy production phase 3 28 | 29 | If you have a setup where the power values are split up between power production and power consumption, you can 30 | additionally specify the channels for the production. 31 | 32 | * Power `mono-phase` 33 | * `power-production-total` - total current production power 34 | * Power `tri-phase` 35 | * `power-production-l1` - current production power phase 1 36 | * `power-production-l2` - current production power phase 2 37 | * `power-production-l3` - current production power phase 3 38 | 39 | The current power values are then calculated as 40 | 41 | ``current power = power-total - power-production-total`` 42 | 43 | or 44 | 45 | ``current power lx = power-lx - power-production-lx`` 46 | 47 | For each channel to be read, you have to configure where the data is gathered from and what type it is. Currently only 48 | the `json` type is supported, but in the future, other types might be added. 49 | 50 | For channels in JSON format, an additional JSON path has to be provided which specifies which part of the JSON data 51 | contains the actual value. 52 | 53 | Additionally, each channel has a `scale` property which can be used to scale the data. The default scale is 1.0 and can 54 | be omitted. 55 | 56 | So a `/etc/uni-meter.conf` file reading the data from a VzLogger webserver could look like this: 57 | 58 | ```hocon 59 | uni-meter { 60 | output = "uni-meter.output-devices.shelly-pro3em" 61 | 62 | input = "uni-meter.input-devices.generic-http" 63 | 64 | input-devices { 65 | generic-http { 66 | url = "http://vzlogger-server:8088" 67 | #username = "username" 68 | #password = "password" 69 | 70 | power-phase-mode = "mono-phase" 71 | energy-phase-mode = "mono-phase" 72 | 73 | channels = [{ 74 | type = "json" 75 | channel = "energy-consumption-total" 76 | json-path = "$.data[0].tuples[0][1]" 77 | scale = 0.001 78 | },{ 79 | type = "json" 80 | channel = "energy-production-total" 81 | json-path = "$.data[1].tuples[0][1]" 82 | scale = 0.001 83 | },{ 84 | type = "json" 85 | channel = "power-total" 86 | json-path = "$.data[2].tuples[0][1]" 87 | }] 88 | } 89 | } 90 | } 91 | ``` 92 | -------------------------------------------------------------------------------- /doc/input/HomeAssistant.md: -------------------------------------------------------------------------------- 1 | # Using Home Assistant sensors as the input source 2 | 3 | Home Assistant sensors can be used as the input source. To access the sensors, you have to create an access token in 4 | Home Assistant and configure the uni-meter with that ``access-token`` and the URL of your system. 5 | 6 | Also, with this input, the input source can operate in two modes. 7 | Either in a `mono-phase` mode, where the power and/or the energy data is provided as a single value for all three 8 | phases, or in a `tri-phase` mode, where the power and/or the energy data is provided as a separate value for each phase. 9 | 10 | Depending on the chosen phase mode, the sensors to be used can be configured by the following properties: 11 | 12 | * Power `mono-phase` 13 | * `power-sensor` - sensor providing the total current power 14 | * Power `tri-phase` 15 | * `power-l1-sensor` - sensor providing current power phase 1 16 | * `power-l2-sensor` - sensor providing current power phase 2 17 | * `power-l3-sensor` - sensor providing current power phase 3 18 | * Energy `mono-phase` 19 | * `energy-consumption-sensor` - sensor providing total energy consumption 20 | * `energy-production-sensor` - sensor providing total energy production 21 | * Energy `tri-phase` 22 | * `energy-consumption-l1-sensor` - sensor providing energy consumption phase 1 23 | * `energy-consumption-l2-sensor` - sensor providing energy consumption phase 2 24 | * `energy-consumption-l3-sensor` - sensor providing energy consumption phase 3 25 | * `energy-production-l1-sensor` - sensor providing energy production phase 1 26 | * `energy-production-l2-sensor` - sensor providing energy production phase 2 27 | * `energy-production-l3-sensor` - sensor providing energy production phase 3 28 | 29 | If you have a setup where the power values are split up between power production and power consumption, you can 30 | additionally specify the sensors for the production. 31 | 32 | * Power `mono-phase` 33 | * `power-production-sensor` - sensor providing the current production power 34 | * Power `tri-phase` 35 | * `power-production-l1-sensor` - sensor providing current production power phase 1 36 | * `power-production-l2-sensor` - sensor providing current production power phase 2 37 | * `power-production-l3-sensor` - sensor providing current production power phase 3 38 | 39 | The current power values are then calculated as 40 | 41 | ``current power = power-sensor - power-production-sensor`` 42 | 43 | or 44 | 45 | ``current power Lx = power-Lx-sensor - power-production-lx-sensor`` 46 | 47 | So the simplest `/etc/uni-meter.conf` file reading the data from Home Assistant sensors could look like this: 48 | 49 | ```hocon 50 | uni-meter { 51 | output = "uni-meter.output-devices.shelly-pro3em" 52 | 53 | input = "uni-meter.input-devices.home-assistant" 54 | 55 | input-devices { 56 | home-assistant { 57 | url = "http://192.168.178.51:8123" 58 | access-token = "eyJhbGciOiJIUzI1Ni...." 59 | 60 | power-phase-mode = "mono-phase" 61 | energy-phase-mode = "mono-phase" 62 | 63 | power-sensor = "sensor.current_power" 64 | energy-consumption-sensor = "sensor.energy_imported" 65 | energy-production-sensor = "sensor.energy_exported" 66 | } 67 | } 68 | } 69 | ``` 70 | -------------------------------------------------------------------------------- /doc/input/IoBroker.md: -------------------------------------------------------------------------------- 1 | # Using ioBroker as the input source 2 | 3 | Reading ioBroker datapoints as the input source can be done using the generic http interface on the uni-meter side and using 4 | the [simpleAPI](https://github.com/ioBroker/ioBroker.simple-api) adapter on the ioBroker side. When the simpleAPI adapter 5 | is installed and configured on the ioBroker, you can use the following configuration in the `/etc/uni-meter.conf` file: 6 | 7 | ```hocon 8 | uni-meter { 9 | output = "uni-meter.output-devices.shelly-pro3em" 10 | 11 | input = "uni-meter.input-devices.generic-http" 12 | 13 | input-devices { 14 | generic-http { 15 | # Adjust the IP address of the ioBroker and the datapoints to read according to your needs 16 | url = "http://192.168.x.x:8082/getBulk/smartmeter.0.1-0:1_8_0__255.value,smartmeter.0.1-0:2_8_0__255.value,smartmeter.0.1-0:16_7_0__255.value/?json" 17 | 18 | # sample ioBroker output: [ 19 | # {"id":"smartmeter.0.1-0:1_8_0__255.value","val":16464.7379,"ts":1740054549023,"ack":true}, 20 | # {"id":"smartmeter.0.1-0:2_8_0__255.value","val":16808.0592,"ts":1740054549029,"ack":true}, 21 | # {"id":"smartmeter.0.1-0:16_7_0__255.value","val":4.9,"ts":1740054549072,"ack":true} 22 | # ] 23 | 24 | power-phase-mode = "mono-phase" 25 | energy-phase-mode = "mono-phase" 26 | 27 | channels = [{ 28 | type = "json" 29 | channel = "energy-consumption-total" 30 | json-path = "$[0].val" 31 | scale = 0.001 32 | },{ 33 | type = "json" 34 | channel = "energy-production-total" 35 | json-path = "$[1].val" 36 | scale = 0.001 37 | },{ 38 | type = "json" 39 | channel = "power-total" 40 | json-path = "$[2].val" 41 | }] 42 | } 43 | } 44 | } 45 | ``` 46 | 47 | -------------------------------------------------------------------------------- /doc/input/Mqtt.md: -------------------------------------------------------------------------------- 1 | # Using MQTT as the input source 2 | 3 | The MQTT input source can operate in two modes. Either in a `mono-phase` mode, where the power and/or the energy data is 4 | provided as a single value for all three phases, or in a `tri-phase` mode, where the power and/or the energy data is 5 | provided as a separate value for each phase. 6 | 7 | The input data is gathered through channels. Each channel has a unique identifier and a topic where the data is gathered 8 | from. The following channels exist for the different energy and power phase modes: 9 | * Power `mono-phase` 10 | * `power-total` - total current power 11 | * Power `tri-phase` 12 | * `power-l1` - current power phase 1 13 | * `power-l2` - current power phase 2 14 | * `power-l3` - current power phase 3 15 | * Energy `mono-phase` 16 | * `energy-consumption-total` - total energy consumption 17 | * `energy-production-total` - total energy production 18 | * Energy `tri-phase` 19 | * `energy-consumption-l1` - energy consumption phase 1 20 | * `energy-consumption-l2` - energy consumption phase 2 21 | * `energy-consumption-l3` - energy consumption phase 3 22 | * `energy-production-l1` - energy production phase 1 23 | * `energy-production-l2` - energy production phase 2 24 | * `energy-production-l3` - energy production phase 3 25 | 26 | If you have a setup where the power values are split up between power production and power consumption, you can 27 | additionally specify the channels for the production. 28 | 29 | * Power `mono-phase` 30 | * `power-production-total` - total current production power 31 | * Power `tri-phase` 32 | * `power-production-l1` - current production power phase 1 33 | * `power-production-l2` - current production power phase 2 34 | * `power-production-l3` - current production power phase 3 35 | 36 | The current power values are then calculated as 37 | 38 | ``current power = power-total - power-production-total`` 39 | 40 | or 41 | 42 | ``current power lx = power-lx - power-production-lx`` 43 | 44 | 45 | Each channel is linked to a topic where the data is gathered from and has a type which specifies how the data is 46 | stored within the MQTT topic. Currently, two types are supported: `value` and `json`. Use the `value` type for data 47 | stored as a number string within the topic. Use the `json` type for data stored as JSON within the topic. For 48 | channels in JSON format, an additional JSON path has to be provided which specifies which part of the JSON data 49 | contains the actual value. 50 | 51 | Additionally, each channel has a `scale` property which can be used to scale the data. The default scale is 1.0 and can 52 | be omitted. 53 | 54 | So a `/etc/uni-meter.conf` file for a MQTT input source could look like this: 55 | 56 | ```hocon 57 | uni-meter { 58 | output = "uni-meter.output-devices.shelly-pro3em" 59 | 60 | input = "uni-meter.input-devices.mqtt" 61 | 62 | input-devices { 63 | mqtt { 64 | url = "tcp://127.0.0.1:1883" 65 | #username = "username" 66 | #password = "password" 67 | 68 | power-phase-mode = "mono-phase" 69 | energy-phase-mode = "mono-phase" 70 | 71 | channels = [{ 72 | type = "json" 73 | topic = "tele/smlreader/SENSOR" 74 | channel = "power-total" 75 | json-path = "$..power" 76 | scale = 1.0 # default, can be omitted 77 | },{ 78 | type = "json" 79 | topic = "tele/smlreader/SENSOR" 80 | channel = "energy-consumption-total" 81 | json-path = "$..counter_pos" 82 | },{ 83 | type = "json" 84 | topic = "tele/smlreader/SENSOR" 85 | channel = "energy-production-total" 86 | json-path = "$..counter_neg" 87 | }] 88 | } 89 | } 90 | } 91 | ``` 92 | -------------------------------------------------------------------------------- /doc/input/Shelly3Em.md: -------------------------------------------------------------------------------- 1 | # Using a Shelly 3EM as the input source 2 | 3 | To use a Shelly 3EM as an input source, set up the `/etc/uni-meter.conf` file as follows 4 | 5 | ```hocon 6 | uni-meter { 7 | output = "uni-meter.output-devices.shelly-pro3em" 8 | 9 | input = "uni-meter.input-devices.shelly-3em" 10 | 11 | input-devices { 12 | shelly-3em { 13 | url = "" 14 | } 15 | } 16 | } 17 | ``` 18 | 19 | Replace the `` placeholder with the actual URL of your Shelly 3EM device. 20 | 21 | -------------------------------------------------------------------------------- /doc/input/ShellyPro3Em.md: -------------------------------------------------------------------------------- 1 | # Using a Shelly Pro 3EM as the input source 2 | 3 | To use a Shelly Pro 3EM as an input source, set up the `/etc/uni-meter.conf` file as follows 4 | 5 | ```hocon 6 | uni-meter { 7 | output = "uni-meter.output-devices.shelly-pro3em" 8 | 9 | input = "uni-meter.input-devices.shelly-pro3em" 10 | 11 | input-devices { 12 | shelly-pro3em { 13 | url = "" 14 | } 15 | } 16 | } 17 | ``` 18 | 19 | Replace the `` placeholder with the actual URL of your Shelly Pro 3EM device. 20 | 21 | -------------------------------------------------------------------------------- /doc/input/ShrDzm.md: -------------------------------------------------------------------------------- 1 | # Using SHRDZM smartmeter interface as the input source 2 | 3 | To use a SHRDZM smartmeter interface providing the smart meter readings via UDP, set up the `/etc/uni-meter.conf` file 4 | as follows 5 | 6 | ```hocon 7 | uni-meter { 8 | output = "uni-meter.output-devices.shelly-pro3em" 9 | 10 | input = "uni-meter.input-devices.shrdzm" 11 | 12 | input-devices { 13 | shrdzm { 14 | port = 9522 15 | interface = "0.0.0.0" 16 | 17 | # The Marstek storage needs input data on a single phase. This can be controlled by 18 | # the configuration options below 19 | power-phase-mode = "mono-phase" 20 | power-phase = "l1" 21 | } 22 | } 23 | } 24 | ``` 25 | 26 | The above configuration shows the default values for the ShrDzm device which are used, if nothing is provided. If you 27 | want to use a different port or interface, you have to adjust the values accordingly. 28 | 29 | -------------------------------------------------------------------------------- /doc/input/SmaEnergyMeter.md: -------------------------------------------------------------------------------- 1 | # Using SMA energy meter as the input source 2 | 3 | To use a SMA energy meter or a Sunny Home Manager as an input source, set up the `/etc/uni-meter.conf` file as follows 4 | 5 | ```hocon 6 | uni-meter { 7 | output = "uni-meter.output-devices.shelly-pro3em" 8 | 9 | input = "uni-meter.input-devices.sma-energy-meter" 10 | 11 | input-devices { 12 | sma-energy-meter { 13 | port = 9522 14 | group = "239.12.255.254" 15 | //susy-id = 270 16 | //serial-number = 1234567 17 | network-interfaces =[ 18 | "eth0" 19 | "wlan0" 20 | // "192.168.178.222" 21 | ] 22 | } 23 | } 24 | } 25 | ``` 26 | 27 | The above configuration shows the default values which are used if nothing is provided. If your `port` and `group` are 28 | different, you have to adjust the values accordingly. 29 | 30 | If no `susy-id` and `serial-number` are provided, the first detected device will be used. Otherwise, provide the values 31 | of the device you want to use. 32 | 33 | The network interfaces to use are provided as a list of strings. Either specify the names or the IP addresses of the 34 | interfaces you want to use. 35 | 36 | -------------------------------------------------------------------------------- /doc/input/Smd120.md: -------------------------------------------------------------------------------- 1 | # Using SMD120 modbus energy meter as the input source 2 | 3 | To use a SMD120 modbus energy meter via a Protos PE11 as an input source, set up the `/etc/uni-meter.conf` file as 4 | follows: 5 | 6 | ```hocon 7 | uni-meter { 8 | output = "uni-meter.output-devices.shelly-pro3em" 9 | 10 | input = "uni-meter.input-devices.smd120" 11 | 12 | input-devices { 13 | smd120 { 14 | port = 8899 15 | 16 | # The Marstek storage needs input data on a single phase. This can be controlled by 17 | # the configuration options below 18 | power-phase-mode = "mono-phase" 19 | power-phase = "l1" 20 | } 21 | } 22 | } 23 | ``` 24 | 25 | -------------------------------------------------------------------------------- /doc/input/Solaredge.md: -------------------------------------------------------------------------------- 1 | # Using a Solaredge electrical meter input source 2 | 3 | To use a Solaredge electrical meter as an input source, set up the `/etc/uni-meter.conf` file as 4 | follows: 5 | 6 | * The address is the IP of your solaredge inverter, which is connected to the 7 | Modbus counter 8 | * To retrieve the data, the Modbus TCP interface must be activated on the 9 | inverter. To do this, you can either use the SetApp or connect directly to 10 | the inverter's WiFi from another PC / notebook / tablet and open the 11 | configuration page. 12 | * Default port is 1502 13 | 14 | ```hocon 15 | uni-meter { 16 | output = "uni-meter.output-devices.shelly-pro3em" 17 | 18 | input = "uni-meter.input-devices.solaredge" 19 | 20 | input-devices { 21 | solaredge { 22 | address = "192.168.178.125" 23 | port = 1502 24 | unit-id = 1 25 | } 26 | } 27 | } 28 | ``` 29 | 30 | -------------------------------------------------------------------------------- /doc/input/Tasmota.md: -------------------------------------------------------------------------------- 1 | # Using a Tasmota IR read head as the input source 2 | 3 | To use a Tasmota IR read head as an input source, set up the `/etc/uni-meter.conf` file as follows: 4 | 5 | ```hocon 6 | uni-meter { 7 | output = "uni-meter.output-devices.shelly-pro3em" 8 | 9 | input = "uni-meter.input-devices.tasmota" 10 | 11 | input-devices { 12 | tasmota { 13 | url = "http://" 14 | # username="" 15 | # password="" 16 | power-json-path = "$..curr_w" 17 | power-scale = 1.0 # default, can be omitted 18 | energy-consumption-json-path = "$..total_kwh" 19 | energy-consumption-scale = 1.0 # default, can be omitted 20 | energy-production-json-path = "$..export_total_kwh" 21 | energy-production-scale = 1.0 # default, can be omitted 22 | 23 | # The Marstek storage needs input data on a single phase. This can be controlled by 24 | # the configuration options below 25 | power-phase-mode = "mono-phase" 26 | power-phase = "l1" 27 | } 28 | } 29 | } 30 | ``` 31 | 32 | Replace the `` placeholder with the actual IP address of your Tasmota IR read head device. 33 | If you have set a username and password for the device, you have to provide them as well. 34 | 35 | Additionally, you have to configure the JSON paths for the power, energy consumption and energy production values to 36 | access the actual values within the JSON data. If you have to scale these values, you can provide a scale factor which 37 | is 1.0 as a default. 38 | 39 | To retrieve the needed data for the JSON paths, check 40 | `http:///cm?cmnd=Status%2010` which should give you 41 | something like: 42 | 43 | ```json 44 | { 45 | "StatusSNS": { 46 | "Time": "2025-04-23T09:28:35", 47 | "DWS7410": { 48 | "energy": 7418.4061, 49 | "en_out": 9032.9393, 50 | "power": -1962.05, 51 | "meter_id": "XXXXXX" 52 | } 53 | } 54 | } 55 | ``` 56 | 57 | matching config paths from that example would be: 58 | ```hocon 59 | power-json-path = "$.StatusSNS.DWS7410.power" 60 | energy-consumption-json-path = "$.StatusSNS.DWS7410.energy" 61 | energy-production-json-path = "$.StatusSNS.DWS7410.en_out" 62 | ``` 63 | 64 | -------------------------------------------------------------------------------- /doc/input/TibberPulse.md: -------------------------------------------------------------------------------- 1 | # Using Tibber Pulse as the input source 2 | 3 | The Tibber Pulse local API can be used as an input source. To use this API, the local HTTP server has to be enabled on 4 | the Pulse bridge. How this can be done is described for instance here 5 | [marq24/ha-tibber-pulse-local](https://github.com/marq24/ha-tibber-pulse-local). 6 | 7 | If this API is enabled on your Tibber bridge, you should set up the `/etc/uni-meter.conf` file as follows 8 | 9 | ```hocon 10 | uni-meter { 11 | output = "uni-meter.output-devices.shelly-pro3em" 12 | 13 | input = "uni-meter.input-devices.tibber-pulse" 14 | 15 | input-devices { 16 | tibber-pulse { 17 | url = "" 18 | node-id = 1 19 | user-id = "admin" 20 | password = "" 21 | 22 | # The Marstek storage needs input data on a single phase. This can be controlled by 23 | # the configuration options below 24 | power-phase-mode = "mono-phase" 25 | power-phase = "l1" 26 | } 27 | } 28 | } 29 | ``` 30 | 31 | Replace the `` and `` placeholders with the actual values from your environment. 32 | The `node-id` and `user-id` are optional and can be omitted if the default values from above are correct. Otherwise, 33 | adjust the values accordingly. 34 | 35 | -------------------------------------------------------------------------------- /doc/input/VzLogger.md: -------------------------------------------------------------------------------- 1 | # Using a VzLogger webserver as the input source 2 | 3 | To use the VzLogger webserver as an input device, there are two options. First, there is a VzLogger specific input device 4 | that directly takes the VzLogger channel UUIDs in its configuration. This was uni-meter's first input device and is still 5 | working, but has the limitation that it only supports mono phase data. With the availability of the GenericHttp input 6 | device, this might be the better choice for many users, especially if they want to use tri-phase data. The following 7 | sections describe both methods to access VzLogger data 8 | 9 | ## Using the classic VzLogger input device 10 | 11 | To use the ols VzLogger specific input source set up the `/etc/uni-meter.conf` file as follows and replace the 12 | `` and `` placeholders with the actual host and port of your VzLogger webserver. 13 | Additionally, provide the channel UUIDs of your system. 14 | 15 | ```hocon 16 | uni-meter { 17 | output = "uni-meter.output-devices.shelly-pro3em" 18 | 19 | input = "uni-meter.input-devices.vz-logger" 20 | 21 | input-devices { 22 | vz-logger { 23 | url = "http://:" 24 | energy-consumption-channel = "5478b110-b577-11ec-873f-179XXXXXXXX" 25 | energy-production-channel = "6fda4300-b577-11ec-8636-7348XXXXXXXX" 26 | power-channel = "e172f5b5-76cd-42da-abcc-effeXXXXXXXX" 27 | 28 | # The Marstek storage needs input data on a single phase. This can be controlled by 29 | # the configuration options below 30 | power-phase-mode = "mono-phase" 31 | power-phase = "l1" 32 | } 33 | } 34 | } 35 | ``` 36 | 37 | You will find that information in the VzLogger configuration file. As a 38 | default, the VzLogger is configured in the `/etc/vzlogger.conf` file. Make sure that the VzLogger provides its 39 | readings as a Web service and extracts the necessary information from that file: 40 | 41 | ```hocon 42 | { 43 | // ... 44 | 45 | // Build-in HTTP server 46 | "local": { 47 | "enabled": true, // This has to be enabled to provide the readings via HTTP 48 | "port": 8088, // Port used by the HTTP server 49 | 50 | // ... 51 | } 52 | // ... 53 | 54 | "meters": [ 55 | { 56 | // ... 57 | 58 | "channels": [{ 59 | "uuid" : "5478b110-b577-11ec-873f-179bXXXXXXXX", // UUID of the energy consumption channel 60 | "middleware" : "http://localhost/middleware.php", 61 | "identifier" : "1-0:1.8.0", // 1.8.0 is the energy consumption channel 62 | "aggmode" : "MAX" 63 | },{ 64 | "uuid" : "6fda4300-b577-11ec-8636-7348XXXXXXXX", // UUID of the energy production channel 65 | "middleware" : "http://localhost/middleware.php", 66 | "identifier" : "1-0:2.8.0", // 2.8.0 is the energy production channel 67 | "aggmode" : "MAX" 68 | },{ 69 | "uuid" : "e172f5b5-76cd-42da-abcc-effef3b895b2", // UUID of the power channel 70 | "middleware" : "http://localhost/middleware.php", 71 | "identifier" : "1-0:16.7.0", // 16.7.0 is the power channel 72 | }] 73 | } 74 | ] 75 | } 76 | ```` 77 | 78 | ## Using the GenericHttp input device for VzLogger 79 | 80 | To use the GenericHttp input device to access VzLogger data in tri-phase mode, a `/etc/uni-meter.conf` could look like 81 | the following one. Please adjust the JSON path index values behind `$.data[1]...` according to the configuration of your 82 | VzLogger: 83 | 84 | ```hocon 85 | uni-meter { 86 | output = "uni-meter.output-devices.shelly-pro3em" 87 | 88 | input = "uni-meter.input-devices.generic-http" 89 | 90 | input-devices { 91 | generic-http { 92 | url = "http://xxx.xxx.xxx.xxx:yyyy" # put your vzlogger web IP and port here 93 | #username = "username" 94 | #password = "password" 95 | 96 | power-phase-mode = "tri-phase" 97 | energy-phase-mode = "mono-phase" 98 | 99 | channels = [{ 100 | type = "json" 101 | channel = "energy-consumption-total" 102 | json-path = "$.data[4].tuples[0][1]" # adjust the index behind data according to your VzLogger setup 103 | scale = 1 104 | },{ 105 | type = "json" 106 | channel = "energy-production-total" 107 | json-path = "$.data[5].tuples[0][1]" 108 | scale = 1 109 | },{ 110 | type = "json" 111 | channel = "power-l1" 112 | json-path = "$.data[1].tuples[0][1]" 113 | },{ 114 | type = "json" 115 | channel = "power-l2" 116 | json-path = "$.data[2].tuples[0][1]" 117 | },{ 118 | type = "json" 119 | channel = "power-l3" 120 | json-path = "$.data[3].tuples[0][1]" 121 | }] 122 | } 123 | } 124 | } 125 | ``` 126 | -------------------------------------------------------------------------------- /doc/install/BareMetal.md: -------------------------------------------------------------------------------- 1 | # Installation on a physical server 2 | 3 | ``uni-meter`` is written in Java and therefore can be run on any operating system that provides at least a Java 17 4 | runtime environment. This can be either a Windows, Mac or Linux machine. 5 | 6 | ## Download 7 | 8 | The release versions can be downloaded from the [GitHub releases](https://github.com/sdeigm/uni-meter/releases). 9 | It is only necessary to download the `uni-meter-.tgz` archive, the source code archives are not needed. 10 | Since the installation target system is most likely a Raspberry Pi, the following sections describe the installation 11 | on a Raspberry Pi or a similar Unix system. The installation on a Windows or Mac system may slightly vary and is not 12 | covered here. 13 | 14 | ## Installation 15 | 16 | The `uni-meter` is dependent on an installed Java 17 runtime. To check if this is the case, type 17 | 18 | ```shell 19 | java -version 20 | ``` 21 | 22 | If the output shows a version 17 or later, you are good to go. If not, you can install the OpenJDK 17 using the following 23 | commands: 24 | 25 | ```shell 26 | sudo apt update 27 | sudo apt install openjdk-17-jre 28 | ``` 29 | 30 | Afterward, you can extract the downloaded `uni-meter-.tgz` archive. In theory, you can extract the archive 31 | to any location you like, but all the scripts and configuration files included assume an installation in the `/opt` 32 | directory. So preferably you should extract it to the `/opt` directory using the following commands: 33 | 34 | ```shell 35 | sudo tar xzvf uni-meter-.tgz -C /opt 36 | ``` 37 | 38 | This creates a `/opt/uni-meter-` folder on your system. It is a good practice to create a symbolic link 39 | `/opt/uni-meter` pointing to the current version. That allows an easy switch between different versions. The symbolic 40 | link can be created using 41 | 42 | ```shell 43 | sudo ln -s /opt/uni-meter- /opt/uni-meter 44 | ``` 45 | 46 | ## Configuration 47 | 48 | The configuration of the `uni-meter` is done using a configuration file in the [HOCON format](https://github.com/lightbend/config/blob/main/HOCON.md). 49 | 50 | The provided start script assumes the configuration file to be located under `/etc/uni-meter.conf`. As a starting 51 | point, copy the provided sample configuration file to that location: 52 | 53 | ```shell 54 | sudo cp /opt/uni-meter/config/uni-meter.conf /etc/uni-meter.conf 55 | ``` 56 | 57 | Then use your favorite editor to adjust the configuration file to your needs as described in the configuration sections. 58 | 59 | ## First test 60 | 61 | After you have adjusted the configuration file, you can start the tool using command 62 | 63 | ```shell 64 | sudo /opt/uni-meter/bin/uni-meter.sh 65 | ``` 66 | 67 | If everything is set up correctly, the tool should start up, and you should see an output like 68 | 69 | ```shell 70 | 24-12-04 07:29:08.006 INFO uni-meter - ################################################################## 71 | 24-12-04 07:29:08.030 INFO uni-meter - # Universal electric meter converter 1.1.5 (2025-04-10 09:16:00) # 72 | 24-12-04 07:29:08.031 INFO uni-meter - ################################################################## 73 | 24-12-04 07:29:08.033 INFO uni-meter - initializing actor system 74 | 24-12-04 07:29:10.902 INFO org.apache.pekko.event.slf4j.Slf4jLogger - Slf4jLogger started 75 | 24-12-04 07:29:11.707 INFO uni-meter.controller - creating Shelly3EM output device 76 | 24-12-04 07:29:11.758 INFO uni-meter.controller - creating VZLogger input device 77 | 24-12-04 07:29:16.254 INFO uni-meter.http.port-80 - HTTP server is listening on /[0:0:0:0:0:0:0:0]:80 78 | ``` 79 | 80 | To check if the configuration works and if its up, you can check 81 | http:///status with e.g. `curl "http://:/status" | jq 82 | ".emeters"` which returns some JSON output like: 83 | 84 | ```json 85 | [ 86 | { 87 | "power": 257.0, 88 | "pf": 0.88, 89 | "current": 1.2, 90 | "voltage": 231.26, 91 | "is_valid": true, 92 | "total": 2282.33, 93 | "total_returned": 4020.62 94 | }, 95 | { 96 | "power": 674.0, 97 | "pf": 0.99, 98 | "current": 3.0, 99 | "voltage": 228.33, 100 | "is_valid": true, 101 | "total": 3738.87, 102 | "total_returned": 2908.64 103 | }, 104 | { 105 | "power": 657.0, 106 | "pf": 0.97, 107 | "current": 2.9, 108 | "voltage": 229.51, 109 | "is_valid": true, 110 | "total": 4038.92, 111 | "total_returned": 1380.2 112 | } 113 | ] 114 | ``` 115 | 116 | ## Automatic start using systemd 117 | 118 | To start the tool automatically on boot, you can use the provided systemd service file. To do so, create a symlink 119 | within the `/etc/systemd/system` directory using the following command: 120 | 121 | ```shell 122 | sudo ln -s /opt/uni-meter/config/systemd/uni-meter.service /etc/systemd/system/uni-meter.service 123 | ``` 124 | 125 | Afterward, you can enable the service using the following command so that it will be automatically started on boot: 126 | 127 | ```shell 128 | sudo systemctl enable uni-meter 129 | ``` 130 | 131 | To start and stop the service immediately run 132 | 133 | ```shell 134 | sudo systemctl start uni-meter 135 | sudo systemctl stop uni-meter 136 | ``` 137 | The status of the service can be checked using 138 | 139 | ```shell 140 | sudo systemctl status uni-meter 141 | ``` 142 | 143 | ## Announcing the tool via mDNS 144 | 145 | The Hoymiles MS-A2 storage uses mDNS to discover the virtual shelly. So the following step is only necessary if mDNS 146 | support is needed. That is not necessary for the Marstek storage. 147 | 148 | To make the tool discoverable by the Hoymiles storage via mDNS, the `avahi-daemon` is used. On recent Raspbian versions, 149 | the `avahi-daemon` is already installed and running. To check if this is the case, type 150 | 151 | ```shell 152 | sudo systemctl status avahi-daemon 153 | ``` 154 | 155 | If you see an output like `active (running)`, you are good to go. If not, you can install the `avahi-daemon` using the 156 | 157 | ```shell 158 | sudo apt install avahi-daemon 159 | ``` 160 | 161 | and enable it using the following command: 162 | 163 | ```shell 164 | sudo systemctl enable avahi-daemon 165 | sudo systemctl start avahi-daemon 166 | ``` 167 | 168 | Starting with `uni-meter` version 1.1.5, a running avahi daemon is automatically detected and all necessary configuration 169 | files will be automatically created in `/etc/avahi/service`. 170 | 171 | 172 | 173 | -------------------------------------------------------------------------------- /doc/install/Building.md: -------------------------------------------------------------------------------- 1 | # Building from the source code 2 | 3 | ## Minimum requirements 4 | 5 | The project can be built using at least Java 17 and Maven 3.8.5. To check your Java and Maven versions, execute 6 | 7 | ```shell 8 | java --version 9 | ``` 10 | 11 | and 12 | 13 | ```shell 14 | mvn -v 15 | ``` 16 | 17 | If the versions do not fit the requirements, update them accordingly. 18 | 19 | ## Getting the source code 20 | 21 | Use 22 | 23 | ```shell 24 | git clone https://github.com/sdeigm/uni-meter.git 25 | ``` 26 | 27 | to clone the `uni-meter` GitHub repository to your local development machine. Afterward change into the created 28 | directory and execute 29 | 30 | ```shell 31 | mvn install 32 | ``` 33 | 34 | That will create a `uni-meter-.tgz` archive in the `target` directory. This archive can then be installed as 35 | described in [Installation on a physical server](BareMetal.md) 36 | -------------------------------------------------------------------------------- /doc/install/Docker.md: -------------------------------------------------------------------------------- 1 | # Running the docker image 2 | 3 | ## Using docker compose 4 | 5 | To easily run the `uni-meter` as docker image, a docker compose setup is available in a separate 6 | [uni-meter-docker](https://github.com/sdeigm/uni-meter-docker) repository. To use that repository, clone it to your 7 | local machine using 8 | 9 | ```shell 10 | git clone https://github.com/sdeigm/uni-meter-docker.git 11 | ``` 12 | 13 | Within the cloned directory you will find a `docker-compose.yaml`, a `uni-meter.conf` and a 14 | `logback.xml` file. These files can be adjusted to your needs. Afterward you bring up the system by just executing 15 | 16 | ```shell 17 | docker compose up 18 | ``` 19 | 20 | ## Using pure docker 21 | 22 | To run this project in a docker container, you can see the example below. This 23 | exposes UDP Port `1010` and also exposes the httpd daemon on `8080` which 24 | can be changed to your needs. 25 | 26 | ```sh 27 | docker run -d \ 28 | -p 1010:1010/udp -p 8080:80 --name uni-meter \ 29 | --restart=unless-stopped \ 30 | -v /var/run/docker.sock:/var/run/docker.sock \ 31 | -v $PWD/uni-meter.conf:/etc/uni-meter.conf \ 32 | sdeigm/uni-meter 33 | ``` 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /doc/install/HomeAssistant.md: -------------------------------------------------------------------------------- 1 | # Installing the Home Assistant Add-on 2 | 3 | ## Adding the uni-meter repository 4 | 5 | Add this GitHub repository to Home Assistant by pressing the button below 6 | 7 | [![Open your Home Assistant instance and show the add add-on repository dialog with a specific repository URL pre-filled.](https://my.home-assistant.io/badges/supervisor_add_addon_repository.svg)](https://my.home-assistant.io/redirect/supervisor_add_addon_repository/?repository_url=https%3A%2F%2Fgithub.com%2Fsdeigm%2Funi-meter) 8 | 9 | ## Installing 10 | 11 | After adding the repository, click on the "ADD-ON STORE" button in the bottom-right area of your Home Assistant browser 12 | window. This will bring up a list of all available add-ons that now includes the `uni-meter`. Please click on 13 | `uni-meter` and then install. 14 | 15 | ## Configuration 16 | 17 | The `uni-meter` is configured using a `uni-meter.conf` file which should be placed in the ``/addon_configs/663b81ce_uni_meter`` 18 | directory of your Home Assistant instance. To access that directory, it might be necessary to install additional add-ons 19 | which provide access to your Home Assistant instance via Samba or Sftp. 20 | 21 | For troubleshooting problems, it might be necessary to adjust the logging of the `uni-meter`. Therefore, it is possible 22 | to additionally put an optional `logback.xml` into that directory that can be used to adjust the logging settings. If 23 | existent, the file is automatically evaluated. 24 | 25 | ## mDNS support 26 | 27 | If your storage - like the Hoymiles MS-A2 - is discovering the virtual Shelly using mDNS, additional steps are necessary. 28 | This step is not needed for storages like the Marstek Venus. 29 | 30 | To use mDNS it is necessary to additionally install the Pyscript add-on. Afterward, copy the provided 31 | [uni-meter-mdns.py](https://github.com/sdeigm/uni-meter/blob/main/ha_addon/uni-meter-mdns.py) into the `/config/pyscript` 32 | directory of your instance. 33 | 34 | If that is done, the `uni-meter` automatically detects the provided services and announces itself using mDNS. 35 | 36 | ## Starting 37 | 38 | When all configuration steps have been done, you can start the `uni-meter` add-on. To check if it is working correctly, 39 | you can use your web-browser and open the URL 40 | 41 | ``` 42 | http:///rpc/EM.GetStatus?id=0 43 | ``` 44 | 45 | That should bring up the current electrical meter readings in your browser window. -------------------------------------------------------------------------------- /doc/output/Common.md: -------------------------------------------------------------------------------- 1 | # Common output device configuration options 2 | 3 | ## Configuring the forget interval 4 | 5 | If the physical input device is not reachable and no power values are available for a certain time, the uni-meter will 6 | not provide any output values to the storage anymore, so that the storage triggers its fallback behavior. 7 | 8 | Without any configuration that happens after one minute. If you need a different timeout, you can configure it using 9 | the following configuration below: 10 | 11 | ```hocon 12 | uni-meter { 13 | #... 14 | output-devices { 15 | #... 16 | shelly-pro3em { 17 | # These are the defaults used without any configuration: 18 | forget-interval = 1m 19 | } 20 | } 21 | } 22 | ``` 23 | 24 | ## Configuring a static power offset 25 | 26 | In some setups, it might be necessary to add a static offset to the power values. This can be the case if the real 27 | electrical meter readings are not 100% accurate to your household's electrical meter readings. 28 | 29 | You can either configure a power offset for the single phases or a total power offset. The phase power offsets take 30 | precedence over the total power offset. If at least one phase power offset is configured, the total power offset is 31 | ignored. 32 | 33 | Setting the power offset is done in the `/etc/uni-meter.conf` file: 34 | 35 | ```hocon 36 | uni-meter { 37 | #... 38 | output-devices { 39 | #... 40 | shelly-pro3em { 41 | #... 42 | power-offset-total =0 43 | 44 | power-offset-l1 = 0 45 | power-offset-l2 = 0 46 | power-offset-l3 = 0 47 | } 48 | } 49 | } 50 | ``` 51 | -------------------------------------------------------------------------------- /doc/output/EcoTracker.md: -------------------------------------------------------------------------------- 1 | # Configure the EcoTracker output device 2 | 3 | To use the Eco-Tracker output device, set up the `uni-meter.conf` file as follows: 4 | 5 | ```hocon 6 | uni-meter { 7 | output = "uni-meter.output-devices.eco-tracker" 8 | 9 | # ... 10 | output-devices { 11 | eco-tracker { 12 | # ... 13 | } 14 | } 15 | } 16 | ``` 17 | 18 | Use your browser or the curl utility and open the URL 19 | 20 | ``http:///v1/json`` 21 | 22 | to check if the virtual EcoTracker is providing the electrical meter readings. 23 | 24 | ## Changing the HTTP server port 25 | 26 | In its default configuration, the emulated EcoTracker listens on port 80 for incoming HTTP requests. That port can 27 | be changed to for instance port 4711 by adding the following parts to your `/etc/uni-meter.conf` file: 28 | 29 | ```hocon 30 | uni-meter { 31 | # ... 32 | output-devices { 33 | eco-tracker { 34 | # ... 35 | port = 4711 36 | } 37 | } 38 | } 39 | ``` 40 | 41 | Please be aware, that the `uni-meter` itself also provides some HTTP functionality on a port which can be configured 42 | separately. 43 | 44 | ## Changing the average interval 45 | 46 | The EcoTracker provides two power readings in its JSON output: the current power readings and as a standard the average 47 | of the last 60 seconds. That average interval can be configured using the following option: 48 | 49 | ```hocon 50 | uni-meter { 51 | # ... 52 | output-devices { 53 | eco-tracker { 54 | # ... 55 | average-interval = 120s 56 | } 57 | } 58 | } 59 | ``` 60 | -------------------------------------------------------------------------------- /doc/output/ShellyPro3EM.md: -------------------------------------------------------------------------------- 1 | # Configure the Shelly Pro 3EM output device 2 | 3 | To use the Shelly Pro 3EM output device, set up the `uni-meter.conf` file as follows: 4 | 5 | ```hocon 6 | uni-meter { 7 | output = "uni-meter.output-devices.shelly-pro3em" 8 | 9 | # ... 10 | output-devices { 11 | shelly-pro3em { 12 | # ... 13 | } 14 | } 15 | } 16 | ``` 17 | 18 | Use your browser or the curl utility and open the URL 19 | 20 | ``http:///rpc/EM.GetStatus?id=0`` 21 | 22 | to check if the virtual shelly is providing the electrical meter readings. 23 | 24 | ## Enabling JSON RPC over UDP (necessary for the Marstek storage) 25 | 26 | As a default, the JSON RPC over UDP interface of the Shelly Pro3EM emulator is disabled. To enable it, configure the 27 | `udp-port` and optionally the `udp-interface` in the `/etc/uni-meter.conf` file: 28 | 29 | ```hocon 30 | uni-meter { 31 | # ... 32 | output-devices { 33 | shelly-pro3em { 34 | #... 35 | udp-port = 1010 36 | udp-interface = "0.0.0.0" # default, can be omitted 37 | #... 38 | } 39 | } 40 | #... 41 | } 42 | ``` 43 | 44 | ## Throttling the sampling frequency of the Shelly device 45 | 46 | In some setups with a higher latency until the real electrical meter readings are available on the output side, it might 47 | be necessary to throttle the sampling frequency of the output data. Otherwise, it might be possible that the storage 48 | oversteers the power production and consumption values and that they are fluctuating too much around 0 (see the comments 49 | and findings to this [issue](https://github.com/sdeigm/uni-meter/issues/12)). 50 | 51 | To throttle the sampling frequency you can configure a `min-sample-period` in the `/etc/uni-meter.conf` file. This 52 | configuration value specifies the minimum time until the next output data is delivered to the storage. 53 | 54 | ```hocon 55 | uni-meter { 56 | #... 57 | output-devices { 58 | shelly-pro3em { 59 | #... 60 | min-sample-period = 5000ms 61 | #... 62 | } 63 | } 64 | #... 65 | } 66 | ``` 67 | 68 | ## Changing the HTTP server port 69 | 70 | In its default configuration, the emulated Shelly Pro 3EM listens on port 80 for incoming HTTP requests. That port can 71 | be changed to for instance port 4711 by adding the following parts to your `/etc/uni-meter.conf` file: 72 | 73 | ```hocon 74 | uni-meter { 75 | # ... 76 | output-devices { 77 | shelly-pro3em { 78 | # ... 79 | port = 4711 80 | } 81 | } 82 | } 83 | ``` 84 | 85 | Please be aware, that the `uni-meter` itself also provides some HTTP functionality on a port which can be configured 86 | separately. 87 | 88 | ## Configuring the Shelly device id 89 | 90 | Starting from version 1.1.5 on, it is normally not necessary anymore to configure the Shelly device id. It will be 91 | automatically set based on the first detected hardware mac address on the host machine. 92 | 93 | If it may, for whatever reason, be necessary to modify the device id, it can be done using the following configuration 94 | parameters: 95 | 96 | ```hocon 97 | uni-meter { 98 | # ... 99 | output-devices { 100 | shelly-pro3em { 101 | device { 102 | mac = "B827EB364242" 103 | hostname = "shellypro3em-b827eb364242" 104 | } 105 | } 106 | } 107 | #... 108 | } 109 | ``` 110 | -------------------------------------------------------------------------------- /docker/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | uni-meter: 3 | image: sdeigm/uni-meter:latest 4 | container_name: uni-meter 5 | network_mode: host 6 | restart: unless-stopped 7 | ports: 8 | - 80:80/tcp 9 | - 1010:1010/udp 10 | - 5353:5353/udp 11 | expose: 12 | - 80/tcp 13 | - 1010/udp 14 | - 5353/udp 15 | volumes: 16 | - ./uni-meter.conf:/etc/uni-meter.conf 17 | - ./logback.xml:/opt/uni-meter/config/logback.xml -------------------------------------------------------------------------------- /docker/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | %d{yy-MM-dd HH:mm:ss.SSS} %-5level %-24logger - %msg%n 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /docker/uni-meter.conf: -------------------------------------------------------------------------------- 1 | uni-meter { 2 | output = "uni-meter.output-devices.shelly-pro3em" 3 | 4 | input = "uni-meter.input-devices.generic-http" 5 | 6 | output-devices { 7 | shelly-pro3em { 8 | udp-port = 1010 9 | } 10 | } 11 | 12 | input-devices { 13 | generic-http { 14 | url = "http://192.168.178.99:8088" 15 | channels = [{ 16 | type = "json" 17 | channel = "energy-consumption-total" 18 | json-path = "$.data[0].tuples[0][1]" 19 | scale = 0.001 20 | },{ 21 | type = "json" 22 | channel = "energy-production-total" 23 | json-path = "$.data[1].tuples[0][1]" 24 | scale = 0.001 25 | },{ 26 | type = "json" 27 | channel = "power-total" 28 | json-path = "$.data[2].tuples[0][1]" 29 | }] 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /ha_addon/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG BUILD_FROM 2 | ARG UNI_METER_VERSION="1.1.11" 3 | FROM ${BUILD_FROM:-ghcr.io/hassio-addons/base:17.0.1} 4 | 5 | # Build stage 6 | ARG UNI_METER_VERSION 7 | 8 | # Install Java 17 runtime 9 | RUN apk update && \ 10 | apk add openjdk17-jre-headless 11 | 12 | # Download and install uni-meter 13 | RUN wget https://github.com/sdeigm/uni-meter/releases/download/${UNI_METER_VERSION}/uni-meter-${UNI_METER_VERSION}.tgz -O /tmp/uni-meter-${UNI_METER_VERSION}.tgz && \ 14 | tar xzf /tmp/uni-meter-${UNI_METER_VERSION}.tgz -C /opt && \ 15 | rm /tmp/uni-meter-${UNI_METER_VERSION}.tgz && \ 16 | ln -s /opt/uni-meter-${UNI_METER_VERSION} /opt/uni-meter && \ 17 | cp /opt/uni-meter/config/uni-meter.conf /etc/uni-meter.conf 18 | 19 | # Copy data for add-on 20 | COPY run.sh / 21 | RUN chmod a+x /run.sh 22 | 23 | CMD [ "/run.sh" ] 24 | -------------------------------------------------------------------------------- /ha_addon/config.yaml: -------------------------------------------------------------------------------- 1 | name: "uni-meter" 2 | version: "1.1.11" 3 | slug: "uni_meter" 4 | description: "A universal electrical meter converter" 5 | arch: 6 | - amd64 7 | - aarch64 8 | url: "https://github.com/sdeigm/uni-meter" 9 | image: "docker.io/sdeigm/uni-meter-addon" 10 | panel_icon: "mdi:barometer" 11 | init: false 12 | startup: services 13 | homeassistant_api: true 14 | hassio_api: true 15 | hassio_role: homeassistant 16 | host_network: true 17 | ports: 18 | 80/tcp: 80 19 | 1010/udp: 1010 20 | map: 21 | - type: addon_config 22 | read_only: true 23 | options: 24 | custom_config: "uni-meter.conf" 25 | schema: 26 | custom_config: str? 27 | logger: 28 | default: info 29 | -------------------------------------------------------------------------------- /ha_addon/repository.yaml: -------------------------------------------------------------------------------- 1 | name: "uni-meter" 2 | url: "https://github.com/sdeigm/uni-meter" 3 | maintainer: "sdeigm " -------------------------------------------------------------------------------- /ha_addon/run.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/with-contenv bashio 2 | 3 | if bashio::config.has_value 'custom_config' && [ -f "/config/$(bashio::config 'custom_config')" ]; then 4 | bashio::log.info "using custom configuration file: $(bashio::config 'custom_config')" 5 | cp "/config/$(bashio::config 'custom_config')" "/etc/uni-meter.conf" 6 | else 7 | echo "custom configuration file $(bashio::config 'custom_config') does not exist" 8 | exit 1 9 | fi 10 | 11 | if [ -f "/config/logback.xml" ]; then 12 | bashio::log.info "using logging configuration file logback.xml" 13 | cp "/config/logback.xml" "/opt/uni-meter/config/logback.xml" 14 | fi 15 | 16 | export UNI_HA_URL="http://supervisor/core" 17 | export UNI_HA_ACCESS_TOKEN=$SUPERVISOR_TOKEN 18 | 19 | /opt/uni-meter/bin/uni-meter.sh 20 | -------------------------------------------------------------------------------- /ha_addon/translations/de.yaml: -------------------------------------------------------------------------------- 1 | configuration: 2 | custom_config: 3 | name: "Konfigurationsdatei" 4 | description: "Name der Konfigurationsdatei im lokalen Konfigurationsverzeichis des Addons" -------------------------------------------------------------------------------- /ha_addon/translations/en.yaml: -------------------------------------------------------------------------------- 1 | configuration: 2 | custom_config: 3 | name: "Configuration File" 4 | description: "Name of the configuration file within the addon's local configuration directory" -------------------------------------------------------------------------------- /ha_addon/uni-meter-mdns.py: -------------------------------------------------------------------------------- 1 | from homeassistant.components import zeroconf 2 | from zeroconf.asyncio import AsyncServiceInfo 3 | 4 | def ip_to_bytes(ip): 5 | return bytes(map(int, ip.split('.'))) 6 | 7 | @service 8 | def uni_meter_mdns_register(type, name, ip, port, properties): 9 | aiozc = zeroconf.async_get_async_instance(hass) 10 | info = AsyncServiceInfo( 11 | type_=type + "._tcp.local.", 12 | name=name + "." + type + "._tcp.local.", 13 | addresses=[ip_to_bytes(ip)], 14 | port=port, 15 | properties=properties 16 | ) 17 | aiozc.async_register_service(info) 18 | 19 | @service 20 | def uni_meter_mdns_unregister(type, name, ip, port, properties): 21 | aiozc = zeroconf.async_get_async_instance(hass) 22 | info = AsyncServiceInfo( 23 | type_=type + "._tcp.local.", 24 | name=name + "." + type + "._tcp.local.", 25 | addresses=[ip_to_bytes(ip)], 26 | port=port, 27 | properties=properties 28 | ) 29 | aiozc.async_unregister_service(info) 30 | 31 | @service 32 | def uni_meter_unregister_all(): 33 | aiozc = zeroconf.async_get_async_instance(hass) 34 | aiozc.async_unregister_all_services() 35 | -------------------------------------------------------------------------------- /repository.yaml: -------------------------------------------------------------------------------- 1 | name: "uni-meter" 2 | url: "https://github.com/sdeigm/uni-meter" 3 | maintainer: "sdeigm " 4 | -------------------------------------------------------------------------------- /samples/emlog/uni-meter.conf: -------------------------------------------------------------------------------- 1 | uni-meter { 2 | output = "uni-meter.output-devices.shelly-pro3em" 3 | 4 | input = "uni-meter.input-devices.generic-http" 5 | 6 | http-server { 7 | port = 80 8 | } 9 | 10 | output-devices { 11 | shelly-pro3em { 12 | udp-port = 1010 13 | } 14 | } 15 | 16 | input-devices { 17 | generic-http { 18 | url = "http://xxx.xxx.xxx.xxx/pages/getinformation.php?export&meterindex=1" 19 | 20 | power-phase-mode = "tri-phase" 21 | energy-phase-mode = "mono-phase" 22 | 23 | channels = [{ 24 | type = "json" 25 | channel = "energy-consumption-total" 26 | json-path = "$.Zaehlerstand_Bezug.Stand180" 27 | scale = 1 28 | },{ 29 | type = "json" 30 | channel = "energy-production-total" 31 | json-path = "$.Zaehlerstand_Lieferung.Stand280" 32 | scale = 1 33 | },{ 34 | type = "json" 35 | channel = "power-l1" 36 | json-path = "$.Wirkleistung_Bezug.Leistung171" 37 | },{ 38 | type = "json" 39 | channel = "power-production-l1" 40 | json-path = "$.Wirkleistung_Lieferung.Leistung271" 41 | },{ 42 | type = "json" 43 | channel = "power-l2" 44 | json-path = "$.Wirkleistung_Bezug.Leistung172" 45 | },{ 46 | type = "json" 47 | channel = "power-production-l2" 48 | json-path = "$.Wirkleistung_Lieferung.Leistung272" 49 | },{ 50 | type = "json" 51 | channel = "power-l3" 52 | json-path = "$.Wirkleistung_Bezug.Leistung173" 53 | },{ 54 | type = "json" 55 | channel = "power-production-l3" 56 | json-path = "$.Wirkleistung_Lieferung.Leistung273" 57 | }] 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /samples/fronius/uni-meter.conf: -------------------------------------------------------------------------------- 1 | uni-meter { 2 | output = "uni-meter.output-devices.shelly-pro3em" 3 | 4 | input = "uni-meter.input-devices.generic-http" 5 | 6 | http-server { 7 | port = 80 8 | } 9 | 10 | output-devices { 11 | shelly-pro3em { 12 | udp-port = 1010 13 | } 14 | } 15 | 16 | input-devices { 17 | generic-http { 18 | url = "http://192.168.178.60/solar_api/v1/GetMeterRealtimeData.cgi?Scope=System" 19 | 20 | power-phase-mode = "tri-phase" 21 | 22 | channels = [{ 23 | type = "json" 24 | channel = "power-l1" 25 | json-path = "$.Body.Data.0.PowerReal_P_Phase_1" 26 | },{ 27 | type = "json" 28 | channel = "power-l2" 29 | json-path = "$.Body.Data.0.PowerReal_P_Phase_2" 30 | },{ 31 | type = "json" 32 | channel = "power-l3" 33 | json-path = "$.Body.Data.0.PowerReal_P_Phase_3" 34 | }] 35 | } 36 | } 37 | } -------------------------------------------------------------------------------- /samples/generic_http/mono-phase/uni-meter.conf: -------------------------------------------------------------------------------- 1 | uni-meter { 2 | output = "uni-meter.output-devices.shelly-pro3em" 3 | 4 | input = "uni-meter.input-devices.generic-http" 5 | 6 | http-server { 7 | port = 80 8 | } 9 | 10 | output-devices { 11 | shelly-pro3em { 12 | udp-port = 1010 13 | min-sample-period = 2500 14 | } 15 | } 16 | 17 | input-devices { 18 | generic-http { 19 | url = "http://192.168.178.99:8088" 20 | 21 | power-phase-mode = "mono-phase" 22 | energy-phase-mode = "mono-phase" 23 | 24 | channels = [{ 25 | type = "json" 26 | channel = "energy-consumption-total" 27 | json-path = "$.data[0].tuples[0][1]" 28 | scale = 0.001 29 | },{ 30 | type = "json" 31 | channel = "energy-production-total" 32 | json-path = "$.data[1].tuples[0][1]" 33 | scale = 0.001 34 | },{ 35 | type = "json" 36 | channel = "power-total" 37 | json-path = "$.data[2].tuples[0][1]" 38 | }] 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /samples/generic_http/tri-phase/uni-meter.conf: -------------------------------------------------------------------------------- 1 | uni-meter { 2 | output = "uni-meter.output-devices.shelly-pro3em" 3 | 4 | input = "uni-meter.input-devices.generic-http" 5 | 6 | http-server { 7 | port = 80 8 | } 9 | 10 | output-devices { 11 | shelly-pro3em { 12 | udp-port = 1010 13 | } 14 | } 15 | 16 | input-devices { 17 | generic-http { 18 | url = "http://xxx.xxx.xxx.xxx/pages/getinformation.php?export&meterindex=1" 19 | 20 | power-phase-mode = "tri-phase" 21 | energy-phase-mode = "mono-phase" 22 | 23 | # Sample demonstrates usage of 2 different channels for power consumption (power-l1) and power production 24 | # (power-production-l1) 25 | 26 | channels = [{ 27 | type = "json" 28 | channel = "energy-consumption-total" 29 | json-path = "$.Zaehlerstand_Bezug.Stand180" 30 | scale = 1 31 | },{ 32 | type = "json" 33 | channel = "energy-production-total" 34 | json-path = "$.Zaehlerstand_Lieferung.Stand280" 35 | scale = 1 36 | },{ 37 | type = "json" 38 | channel = "power-l1" 39 | json-path = "$.Wirkleistung_Bezug.Leistung171" 40 | },{ 41 | type = "json" 42 | channel = "power-production-l1" 43 | json-path = "$.Wirkleistung_Lieferung.Leistung271" 44 | },{ 45 | type = "json" 46 | channel = "power-l2" 47 | json-path = "$.Wirkleistung_Bezug.Leistung172" 48 | },{ 49 | type = "json" 50 | channel = "power-production-l2" 51 | json-path = "$.Wirkleistung_Lieferung.Leistung272" 52 | },{ 53 | type = "json" 54 | channel = "power-l3" 55 | json-path = "$.Wirkleistung_Bezug.Leistung173" 56 | },{ 57 | type = "json" 58 | channel = "power-production-l3" 59 | json-path = "$.Wirkleistung_Lieferung.Leistung273" 60 | }] 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /samples/ioBroker/uni-meter.conf: -------------------------------------------------------------------------------- 1 | uni-meter { 2 | output = "uni-meter.output-devices.shelly-pro3em" 3 | 4 | input = "uni-meter.input-devices.generic-http" 5 | 6 | http-server { 7 | port = 80 8 | } 9 | 10 | output-devices { 11 | shelly-pro3em { 12 | udp-port = 1010 13 | } 14 | } 15 | 16 | input-devices { 17 | generic-http { 18 | # Adjust the IP address of the ioBroker and the datapoints to read according to your needs 19 | url = "http://192.168.x.x:8082/getBulk/smartmeter.0.1-0:1_8_0__255.value,smartmeter.0.1-0:2_8_0__255.value,smartmeter.0.1-0:16_7_0__255.value/?json" 20 | 21 | # sample ioBroker output: [ 22 | # {"id":"smartmeter.0.1-0:1_8_0__255.value","val":16464.7379,"ts":1740054549023,"ack":true}, 23 | # {"id":"smartmeter.0.1-0:2_8_0__255.value","val":16808.0592,"ts":1740054549029,"ack":true}, 24 | # {"id":"smartmeter.0.1-0:16_7_0__255.value","val":4.9,"ts":1740054549072,"ack":true} 25 | # ] 26 | 27 | power-phase-mode = "mono-phase" 28 | energy-phase-mode = "mono-phase" 29 | 30 | channels = [{ 31 | type = "json" 32 | channel = "energy-consumption-total" 33 | json-path = "$[0].val" 34 | scale = 0.001 35 | },{ 36 | type = "json" 37 | channel = "energy-production-total" 38 | json-path = "$[1].val" 39 | scale = 0.001 40 | },{ 41 | type = "json" 42 | channel = "power-total" 43 | json-path = "$[2].val" 44 | }] 45 | } 46 | } 47 | } -------------------------------------------------------------------------------- /samples/mqtt/uni-meter.conf: -------------------------------------------------------------------------------- 1 | uni-meter { 2 | output = "uni-meter.output-devices.shelly-pro3em" 3 | 4 | input = "uni-meter.input-devices.mqtt" 5 | 6 | http-server { 7 | port = 80 8 | } 9 | 10 | output-devices { 11 | shelly-pro3em { 12 | udp-port = 1010 13 | } 14 | } 15 | 16 | input-devices { 17 | mqtt { 18 | url = "tcp://127.0.0.1:1883" 19 | 20 | power-phase-mode = "mono-phase" 21 | energy-phase-mode = "mono-phase" 22 | 23 | channels = [{ 24 | type = "json" 25 | topic = "tele/smlreader/SENSOR" 26 | channel = "power-total" 27 | json-path = "$..power" 28 | },{ 29 | type = "json" 30 | topic = "tele/smlreader/SENSOR" 31 | channel = "energy-consumption-total" 32 | json-path = "$..counter_pos" 33 | },{ 34 | type = "json" 35 | topic = "tele/smlreader/SENSOR" 36 | channel = "energy-production-total" 37 | json-path = "$..counter_neg" 38 | }] 39 | } 40 | } 41 | } -------------------------------------------------------------------------------- /samples/sma/uni-meter.conf: -------------------------------------------------------------------------------- 1 | uni-meter { 2 | output = "uni-meter.output-devices.shelly-pro3em" 3 | 4 | input = "uni-meter.input-devices.sma-energy-meter" 5 | 6 | http-server { 7 | port = 80 8 | } 9 | 10 | output-devices { 11 | shelly-pro3em { 12 | udp-port = 1010 13 | } 14 | } 15 | 16 | input-devices { 17 | sma-energy-meter { 18 | port = 9522 19 | group = "239.12.255.254" 20 | 21 | network-interfaces =[ 22 | "eth0" 23 | "wlan0" 24 | ] 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /samples/solaredge/uni-meter.conf: -------------------------------------------------------------------------------- 1 | uni-meter { 2 | output = "uni-meter.output-devices.shelly-pro3em" 3 | 4 | input = "uni-meter.input-devices.solaredge" 5 | 6 | http-server { 7 | port = 80 8 | } 9 | 10 | output-devices { 11 | shelly-pro3em { 12 | udp-port = 1010 13 | } 14 | } 15 | 16 | input-devices { 17 | solaredge { 18 | address = "192.168.178.125" 19 | port = 502 20 | unit-id = 1 21 | } 22 | } 23 | } -------------------------------------------------------------------------------- /samples/srhdzm/uni-meter.conf: -------------------------------------------------------------------------------- 1 | uni-meter { 2 | output = "uni-meter.output-devices.shelly-pro3em" 3 | 4 | input = "uni-meter.input-devices.shrdzm" 5 | 6 | http-server { 7 | port = 80 8 | } 9 | 10 | output-devices { 11 | shelly-pro3em { 12 | udp-port = 1010 13 | } 14 | } 15 | 16 | # Remark: for this to work, you have to enable the UDP unicast to uni-meter's IP address 17 | # and port 9522 on your SRHDZM device 18 | 19 | input-devices { 20 | shrdzm { 21 | port = 9522 22 | interface = "0.0.0.0" 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /samples/tasmota/uni-meter.conf: -------------------------------------------------------------------------------- 1 | uni-meter { 2 | output = "uni-meter.output-devices.shelly-pro3em" 3 | 4 | input = "uni-meter.input-devices.tasmota" 5 | 6 | http-server { 7 | port = 80 8 | } 9 | 10 | output-devices { 11 | shelly-pro3em { 12 | udp-port = 1010 13 | } 14 | } 15 | 16 | input-devices { 17 | tasmota { 18 | url = "http://" 19 | # username="" 20 | # password="" 21 | power-json-path = "$.StatusSNS.DWS7410.power" 22 | power-scale = 1.0 # default, can be omitted 23 | energy-consumption-json-path = "$.StatusSNS.DWS7410.energy" 24 | energy-consumption-scale = 1.0 # default, can be omitted 25 | energy-production-json-path = "$.StatusSNS.DWS7410.en_out" 26 | energy-production-scale = 1.0 # default, can be omitted 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /samples/tibber/uni-meter.conf: -------------------------------------------------------------------------------- 1 | uni-meter { 2 | output = "uni-meter.output-devices.shelly-pro3em" 3 | 4 | input = "uni-meter.input-devices.tibber-pulse" 5 | 6 | http-server { 7 | port = 80 8 | } 9 | 10 | output-devices { 11 | shelly-pro3em { 12 | udp-port = 1010 13 | } 14 | } 15 | 16 | input-devices { 17 | tibber-pulse { 18 | url = "http://" 19 | node-id = 1 20 | user-id = "admin" 21 | password = "" 22 | } 23 | } 24 | } -------------------------------------------------------------------------------- /samples/vz_logger/classic/uni-meter.conf: -------------------------------------------------------------------------------- 1 | uni-meter { 2 | output = "uni-meter.output-devices.shelly-pro3em" 3 | 4 | input = "uni-meter.input-devices.vz-logger" 5 | 6 | http-server { 7 | port = 80 8 | } 9 | 10 | output-devices { 11 | shelly-pro3em { 12 | udp-port = 1010 13 | } 14 | } 15 | 16 | input-devices { 17 | vz-logger { 18 | url = "http://:" 19 | energy-consumption-channel = "5478b110-b577-11ec-873f-179XXXXXXXX" 20 | energy-production-channel = "6fda4300-b577-11ec-8636-7348XXXXXXXX" 21 | power-channel = "e172f5b5-76cd-42da-abcc-effeXXXXXXXX" 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /samples/vz_logger/generic_http/uni-meter.conf: -------------------------------------------------------------------------------- 1 | uni-meter { 2 | output = "uni-meter.output-devices.shelly-pro3em" 3 | 4 | input = "uni-meter.input-devices.generic-http" 5 | 6 | http-server { 7 | port = 80 8 | } 9 | 10 | output-devices { 11 | shelly-pro3em { 12 | udp-port = 1010 13 | } 14 | } 15 | 16 | input-devices { 17 | generic-http { 18 | url = "http://xxx.xxx.xxx.xxx:yyyy" # put your vzlogger web IP and port here 19 | #username = "username" 20 | #password = "password" 21 | 22 | power-phase-mode = "tri-phase" 23 | energy-phase-mode = "mono-phase" 24 | 25 | channels = [{ 26 | type = "json" 27 | channel = "energy-consumption-total" 28 | json-path = "$.data[4].tuples[0][1]" # adjust the index behind data according to your VzLogger setup 29 | scale = 1 30 | },{ 31 | type = "json" 32 | channel = "energy-production-total" 33 | json-path = "$.data[5].tuples[0][1]" 34 | scale = 1 35 | },{ 36 | type = "json" 37 | channel = "power-l1" 38 | json-path = "$.data[1].tuples[0][1]" 39 | },{ 40 | type = "json" 41 | channel = "power-l2" 42 | json-path = "$.data[2].tuples[0][1]" 43 | },{ 44 | type = "json" 45 | channel = "power-l3" 46 | json-path = "$.data[3].tuples[0][1]" 47 | }] 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/main/java-templates/Version.java: -------------------------------------------------------------------------------- 1 | package com.deigmueller.uni_meter.application; 2 | 3 | public final class Version { 4 | 5 | private static final String VERSION = "${project.version}"; 6 | private static final String GROUP_ID = "${project.groupId}"; 7 | private static final String ARTIFACT_ID = "${project.artifactId}"; 8 | private static final String BUILD_TIME = "${buildTime}"; 9 | 10 | public static String getVersion() { 11 | return VERSION; 12 | } 13 | 14 | public static String getGroupId() { 15 | return GROUP_ID; 16 | } 17 | 18 | public static String getArtifactId() { 19 | return ARTIFACT_ID; 20 | } 21 | 22 | public static String getBuildTime() { 23 | return BUILD_TIME; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/com/deigmueller/uni_meter/application/Application.java: -------------------------------------------------------------------------------- 1 | package com.deigmueller.uni_meter.application; 2 | 3 | 4 | import com.typesafe.config.Config; 5 | import com.typesafe.config.ConfigFactory; 6 | import org.apache.commons.lang3.StringUtils; 7 | import org.apache.pekko.actor.typed.ActorSystem; 8 | import org.apache.pekko.actor.typed.javadsl.Behaviors; 9 | import org.slf4j.Logger; 10 | import org.slf4j.LoggerFactory; 11 | 12 | import java.util.Properties; 13 | 14 | public class Application { 15 | public static final Logger LOGGER = LoggerFactory.getLogger("uni-meter"); 16 | 17 | public static void main(String[] args) { 18 | Config config = ConfigFactory 19 | .parseProperties(configurationOverrides()) 20 | .withFallback(ConfigFactory.load()); 21 | 22 | logStartupBanner(); 23 | 24 | try { 25 | LOGGER.info("initializing actor system"); 26 | ActorSystem actorSystem = ActorSystem.create( 27 | Behaviors.setup(context -> UniMeter.create()), "uni-meter", config); 28 | 29 | actorSystem.getWhenTerminated().whenComplete((done, throwable) -> { 30 | LOGGER.info("actor system terminated"); 31 | }); 32 | 33 | Thread.sleep(1000); 34 | } catch (Exception e) { 35 | LOGGER.error("failed to initialize the actor system", e); 36 | } 37 | } 38 | 39 | private static void logStartupBanner() { 40 | String product = "# Universal electric meter converter " + Version.getVersion() + " (" + Version.getBuildTime() + ") #"; 41 | String hLine = StringUtils.repeat("#", product.length()); 42 | 43 | LOGGER.info(hLine); 44 | LOGGER.info(product); 45 | LOGGER.info(hLine); 46 | } 47 | 48 | private static Properties configurationOverrides() { 49 | Properties properties = new Properties(); 50 | 51 | properties.setProperty("pekko.http.parsing.illegal-header-warnings", "off"); 52 | 53 | return properties; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/main/java/com/deigmueller/uni_meter/application/HttpServer.java: -------------------------------------------------------------------------------- 1 | package com.deigmueller.uni_meter.application; 2 | 3 | import com.deigmueller.uni_meter.common.shelly.RpcException; 4 | import org.apache.pekko.actor.typed.Behavior; 5 | import org.apache.pekko.actor.typed.javadsl.*; 6 | import org.apache.pekko.http.javadsl.Http; 7 | import org.apache.pekko.http.javadsl.ServerBinding; 8 | import org.apache.pekko.http.javadsl.ServerBuilder; 9 | import org.apache.pekko.http.javadsl.model.HttpResponse; 10 | import org.apache.pekko.http.javadsl.model.StatusCodes; 11 | import org.apache.pekko.http.javadsl.server.*; 12 | import org.apache.pekko.http.javadsl.settings.ServerSettings; 13 | import org.apache.pekko.http.scaladsl.model.headers.Server; 14 | import org.slf4j.Logger; 15 | import org.slf4j.LoggerFactory; 16 | 17 | import org.jetbrains.annotations.NotNull; 18 | import java.util.ArrayList; 19 | import java.util.List; 20 | import java.util.NoSuchElementException; 21 | import java.util.Optional; 22 | 23 | public class HttpServer extends AbstractBehavior { 24 | // Instance members 25 | private final Logger logger; 26 | private final List registeredRoutes = new ArrayList<>(); 27 | private final String bindInterface; 28 | private final int bindPort; 29 | private ServerBinding httpServerBinding; 30 | private volatile Route innerRoute; 31 | 32 | public static Behavior create(@NotNull String bindInterface, 33 | int bindPort) { 34 | return Behaviors.setup(context -> new HttpServer(context, bindInterface, bindPort)); 35 | } 36 | 37 | protected HttpServer(@NotNull ActorContext context, 38 | @NotNull String bindInterface, 39 | int bindPort) { 40 | super(context); 41 | 42 | this.logger = LoggerFactory.getLogger("uni-meter.http.port-" + bindPort); 43 | 44 | this.bindInterface = bindInterface; 45 | this.bindPort = bindPort; 46 | } 47 | 48 | @Override 49 | public @NotNull Receive createReceive() { 50 | return newReceiveBuilder().build(); 51 | } 52 | 53 | @Override 54 | public @NotNull ReceiveBuilder newReceiveBuilder() { 55 | return super.newReceiveBuilder() 56 | .onMessage(RegisterRoute.class, this::onRegisterRoute) 57 | .onMessage(NotifyBindFailed.class, this::onNotifyBindFailed) 58 | .onMessage(NotifyBindSuccess.class, this::onNotifyBindSuccess) 59 | .onMessage(RetryStartHttpServer.class, this::onRetryStartHttpServer); 60 | } 61 | 62 | private @NotNull Behavior onRegisterRoute(@NotNull RegisterRoute message) { 63 | logger.trace("HttpServer.onRegisterRoute()"); 64 | 65 | if (! registeredRoutes.contains(message.route())) { 66 | registeredRoutes.add(message.route()); 67 | 68 | Route newRoute = registeredRoutes.get(0); 69 | for (int i=1; i onNotifyBindFailed(@NotNull NotifyBindFailed message) { 84 | logger.trace("HttpServer.onNotifyBindFailed()"); 85 | 86 | logger.error("failed to bind HTTP server: {}", message.throwable().getMessage()); 87 | 88 | getContext().getSystem().scheduler().scheduleOnce( 89 | getContext().getSystem().settings().config().getDuration("uni-meter.http-server.bind-retry-backoff"), 90 | () -> getContext().getSelf().tell(RetryStartHttpServer.INSTANCE), 91 | getContext().getSystem().executionContext()); 92 | 93 | return Behaviors.same(); 94 | } 95 | 96 | /** 97 | * Handle the successful binding of the HTTP server 98 | * @param message Notification that the HTTP server has been successfully bound 99 | * @return Same behavior 100 | */ 101 | private @NotNull Behavior onNotifyBindSuccess(@NotNull NotifyBindSuccess message) { 102 | logger.trace("HttpServer.onNotifyBindSuccess()"); 103 | 104 | logger.info("HTTP server is listening on {}", message.binding().localAddress()); 105 | httpServerBinding = message.binding(); 106 | 107 | return Behaviors.same(); 108 | } 109 | 110 | /** 111 | * Handle the request to retry starting the HTTP server 112 | * @param message Request to retry starting the HTTP server 113 | * @return Same behavior 114 | */ 115 | private @NotNull Behavior onRetryStartHttpServer(@NotNull RetryStartHttpServer message) { 116 | logger.trace("HttpServer.onRetryStartHttpServer()"); 117 | 118 | start(); 119 | 120 | return Behaviors.same(); 121 | } 122 | 123 | /** 124 | * Start the HTTP server 125 | */ 126 | private void start() { 127 | logger.trace("HttpServer.start()"); 128 | assert httpServerBinding == null; 129 | 130 | if (! registeredRoutes.isEmpty()) { 131 | final Http http = Http.get(getContext().getSystem()); 132 | 133 | ServerSettings serverSettings = ServerSettings 134 | .create(Adapter.toClassic(getContext().getSystem())) 135 | .withServerHeader(Optional.of(Server.apply("ShellyHTTP/1.0.0"))); 136 | 137 | ServerBuilder serverBuilder = http.newServerAt(bindInterface, bindPort) 138 | .withSettings(serverSettings); 139 | 140 | // if (useHttps()) { 141 | // serverBuilder = serverBuilder.enableHttps(createHttpsConnectionContext()); 142 | // } 143 | 144 | serverBuilder 145 | .bind(new RouteContainer().createMainRoute(innerRoute)) 146 | .whenComplete((binding, throwable) -> { 147 | if (throwable != null) { 148 | getContext().getSelf().tell(new NotifyBindFailed(throwable)); 149 | } else { 150 | getContext().getSelf().tell(new NotifyBindSuccess(binding)); 151 | } 152 | }); 153 | } 154 | 155 | // CompletionStage serverBindingFuture = 156 | // serverSource 157 | // .to(Sink.foreach(connection -> { 158 | // System.out.println("Accepted new connection from " + connection.remoteAddress()); 159 | // 160 | // connection.handleWith(new RouteContainer().createMainRoute(innerRoute), Materializer.createMaterializer(getContext())); 161 | // 162 | // // this is equivalent to 163 | // //connection.handleWith(Flow.of(HttpRequest.class).map(requestHandler), materializer); 164 | // })) 165 | // .run(Materializer.createMaterializer(getContext())); 166 | // } 167 | } 168 | 169 | private Route getInnerRoute() { 170 | return innerRoute; 171 | } 172 | 173 | public interface Command {} 174 | 175 | public record RegisterRoute( 176 | @NotNull Route route 177 | ) implements Command {} 178 | 179 | protected record NotifyBindFailed( 180 | @NotNull Throwable throwable 181 | ) implements Command {} 182 | 183 | protected record NotifyBindSuccess( 184 | @NotNull ServerBinding binding 185 | ) implements Command {} 186 | 187 | protected enum RetryStartHttpServer implements Command { 188 | INSTANCE 189 | } 190 | 191 | protected class RouteContainer extends AllDirectives { 192 | public @NotNull Route createMainRoute(@NotNull Route innerRoute) { 193 | return handleExceptions(createExceptionHandler(), () -> 194 | handleRejections(createRejectionHandler(), 195 | HttpServer.this::getInnerRoute 196 | ) 197 | ); 198 | } 199 | 200 | /** 201 | * Create the exception handler 202 | * @return ExceptionHandler instance 203 | */ 204 | private ExceptionHandler createExceptionHandler() { 205 | return ExceptionHandler.newBuilder() 206 | .match(NoSuchElementException.class, e -> { 207 | logger.debug("no such element: {}", e.getMessage()); 208 | return complete(StatusCodes.NOT_FOUND, e.getMessage()); 209 | }) 210 | .match(RpcException.class, e -> { 211 | logger.debug("rpc exception: {}", e.getMessage()); 212 | return complete(StatusCodes.SERVICE_UNAVAILABLE, e.getMessage()); 213 | }) 214 | .matchAny(e -> { 215 | logger.error("exception in HTTP server: {}", e.getMessage()); 216 | return complete(HttpResponse.create().withStatus(500)); 217 | }) 218 | .build(); 219 | } 220 | 221 | /** 222 | * Create the rejection handler 223 | * @return RejectionHandler instance 224 | */ 225 | private RejectionHandler createRejectionHandler() { 226 | return RejectionHandler.newBuilder() 227 | .handleNotFound( 228 | extractUnmatchedPath(path -> { 229 | logger.debug("requested url {} not found", path); 230 | return complete(StatusCodes.NOT_FOUND, "Resource not found!"); 231 | }) 232 | ) 233 | .build(); 234 | } 235 | } 236 | } 237 | -------------------------------------------------------------------------------- /src/main/java/com/deigmueller/uni_meter/application/HttpServerController.java: -------------------------------------------------------------------------------- 1 | package com.deigmueller.uni_meter.application; 2 | 3 | import org.apache.pekko.actor.typed.ActorRef; 4 | import org.apache.pekko.actor.typed.Behavior; 5 | import org.apache.pekko.actor.typed.javadsl.*; 6 | import org.apache.pekko.http.javadsl.server.Route; 7 | 8 | import org.jetbrains.annotations.NotNull; 9 | import java.util.HashMap; 10 | import java.util.Map; 11 | 12 | public class HttpServerController extends AbstractBehavior { 13 | // Instance members 14 | private final Map> servers = new HashMap<>(); 15 | 16 | public static Behavior create() { 17 | return Behaviors.setup(HttpServerController::new); 18 | } 19 | 20 | protected HttpServerController(@NotNull ActorContext context) { 21 | super(context); 22 | } 23 | 24 | @Override 25 | public Receive createReceive() { 26 | return newReceiveBuilder().build(); 27 | } 28 | 29 | @Override 30 | public ReceiveBuilder newReceiveBuilder() { 31 | return super.newReceiveBuilder() 32 | .onMessage(RegisterHttpRoute.class, this::onRegisterHttpRoute); 33 | } 34 | 35 | private @NotNull Behavior onRegisterHttpRoute(@NotNull RegisterHttpRoute command) { 36 | ActorRef server = servers.get(command.bindPort()); 37 | if (server == null) { 38 | server = getContext().spawn( 39 | HttpServer.create( 40 | command.bindInterface(), 41 | command.bindPort()), 42 | "http-server-" + command.bindPort()); 43 | getContext().watch(server); 44 | 45 | servers.put(command.bindPort(), server); 46 | } 47 | 48 | server.tell(new HttpServer.RegisterRoute(command.route)); 49 | 50 | return this; 51 | } 52 | 53 | public interface Command {} 54 | 55 | public record RegisterHttpRoute( 56 | @NotNull String bindInterface, 57 | int bindPort, 58 | @NotNull Route route 59 | ) implements Command {} 60 | } 61 | -------------------------------------------------------------------------------- /src/main/java/com/deigmueller/uni_meter/application/UdpBindFlow.java: -------------------------------------------------------------------------------- 1 | package com.deigmueller.uni_meter.application; 2 | 3 | import org.apache.pekko.actor.ActorRef; 4 | import org.apache.pekko.actor.Terminated; 5 | import org.apache.pekko.actor.typed.ActorSystem; 6 | import org.apache.pekko.io.Inet; 7 | import org.apache.pekko.io.Udp; 8 | import org.apache.pekko.io.UdpMessage; 9 | import org.apache.pekko.stream.*; 10 | import org.apache.pekko.stream.connectors.udp.Datagram; 11 | import org.apache.pekko.stream.stage.GraphStageLogic; 12 | import org.apache.pekko.stream.stage.GraphStageWithMaterializedValue; 13 | import org.jetbrains.annotations.NotNull; 14 | import org.slf4j.Logger; 15 | import org.slf4j.LoggerFactory; 16 | import scala.Tuple2; 17 | import scala.runtime.BoxedUnit; 18 | 19 | import java.net.InetSocketAddress; 20 | import java.util.ArrayList; 21 | import java.util.List; 22 | import java.util.concurrent.CompletableFuture; 23 | import java.util.concurrent.CompletionStage; 24 | 25 | public class UdpBindFlow extends GraphStageWithMaterializedValue, CompletionStage>{ 26 | // Instance members 27 | private final Inlet inlet = Inlet.create("UdpBindFlow.in"); 28 | private final Outlet outlet = Outlet.create("UdpBindFlow.out"); 29 | private final InetSocketAddress bindAddress; 30 | private final ActorRef udpMgr; 31 | 32 | public UdpBindFlow(@NotNull InetSocketAddress bindAddress, 33 | @NotNull ActorSystem actorSystem) { 34 | super(); 35 | 36 | this.bindAddress = bindAddress; 37 | this.udpMgr = Udp.get(actorSystem).getManager(); 38 | } 39 | 40 | @Override 41 | public FlowShape shape() { 42 | return FlowShape.of(inlet, outlet); 43 | } 44 | 45 | @Override 46 | public Tuple2> createLogicAndMaterializedValue(Attributes inheritedAttributes) { 47 | CompletableFuture addressFuture = new CompletableFuture<>(); 48 | return new Tuple2<>(new Logic(shape(), addressFuture, udpMgr, bindAddress), addressFuture); 49 | } 50 | 51 | private static class Logic extends GraphStageLogic { 52 | // Class members 53 | static final Logger logger = LoggerFactory.getLogger(Logic.class); 54 | 55 | // Instance members 56 | private final Inlet inlet; 57 | private final Outlet outlet; 58 | private final CompletableFuture addressFuture; 59 | private final ActorRef udpMgr; 60 | private final InetSocketAddress bindAddress; 61 | private StageActor stageActor; 62 | private ActorRef listener; 63 | 64 | public Logic(@NotNull FlowShape shape, 65 | @NotNull CompletableFuture addressFuture, 66 | @NotNull ActorRef udpMgr, 67 | @NotNull InetSocketAddress bindAddress) { 68 | super(shape); 69 | this.inlet = shape.in(); 70 | this.outlet = shape.out(); 71 | this.addressFuture = addressFuture; 72 | this.udpMgr = udpMgr; 73 | this.bindAddress = bindAddress; 74 | 75 | setHandler(inlet, () -> { 76 | Datagram datagram = grab(inlet); 77 | listener.tell(UdpMessage.send(datagram.data(), datagram.getRemote()), stageActor.ref()); 78 | pull(inlet); 79 | }); 80 | 81 | setHandler(outlet, () -> {}); 82 | } 83 | 84 | @Override 85 | public void preStart() throws Exception { 86 | super.preStart(); 87 | 88 | List options = new ArrayList<>(); 89 | 90 | stageActor = getStageActor(this::messageHandler); 91 | udpMgr.tell(UdpMessage.bind(stageActor.ref(), bindAddress, options), stageActor.ref()); 92 | } 93 | 94 | @Override 95 | public void postStop() throws Exception { 96 | super.postStop(); 97 | 98 | if (listener != null) { 99 | listener.tell(UdpMessage.unbind(), stageActor.ref()); 100 | } 101 | } 102 | 103 | /** 104 | * Message handler for the GraphStage's actor 105 | * @param tuple Tuple containing sender and received message 106 | */ 107 | private BoxedUnit messageHandler(Tuple2 tuple) { 108 | logger.trace("UdpBindFlow.messageHandler()"); 109 | 110 | final ActorRef sender = tuple._1(); 111 | final Object message = tuple._2(); 112 | 113 | if (message instanceof Udp.Bound udpBound) { 114 | addressFuture.complete(udpBound.localAddress()); 115 | listener = sender; 116 | stageActor.watch(listener); 117 | pull(inlet); 118 | } else if (message instanceof Udp.CommandFailed commandFailed) { 119 | if (commandFailed.cmd() instanceof Udp.Bind) { 120 | Exception ex = new IllegalArgumentException("Unable to bind to " + bindAddress); 121 | addressFuture.completeExceptionally(ex); 122 | failStage(ex); 123 | } 124 | } else if (message instanceof Udp.Received udpReceived) { 125 | if (isAvailable(outlet)) { 126 | push(outlet, Datagram.create(udpReceived.data(), udpReceived.sender())); 127 | } 128 | } else if (message instanceof Terminated) { 129 | listener = null; 130 | failStage(new IllegalStateException("UDP listener terminated unexpectedly")); 131 | } else { 132 | logger.debug("unhandled message: {}", message.getClass()); 133 | } 134 | 135 | return BoxedUnit.UNIT; 136 | } 137 | } 138 | 139 | } 140 | -------------------------------------------------------------------------------- /src/main/java/com/deigmueller/uni_meter/application/UdpServer.java: -------------------------------------------------------------------------------- 1 | package com.deigmueller.uni_meter.application; 2 | 3 | import org.apache.pekko.NotUsed; 4 | import org.apache.pekko.actor.typed.ActorRef; 5 | import org.apache.pekko.actor.typed.ActorSystem; 6 | import org.apache.pekko.stream.Materializer; 7 | import org.apache.pekko.stream.OverflowStrategy; 8 | import org.apache.pekko.stream.connectors.udp.Datagram; 9 | import org.apache.pekko.stream.javadsl.Keep; 10 | import org.apache.pekko.stream.typed.javadsl.ActorSink; 11 | import org.apache.pekko.stream.typed.javadsl.ActorSource; 12 | import org.jetbrains.annotations.NotNull; 13 | import org.slf4j.Logger; 14 | 15 | import java.net.InetSocketAddress; 16 | import java.util.Objects; 17 | import java.util.Optional; 18 | 19 | public class UdpServer { 20 | public static void createServer(@NotNull Logger logger, 21 | @NotNull ActorSystem system, 22 | @NotNull Materializer materializer, 23 | @NotNull InetSocketAddress bindAddress, 24 | @NotNull ActorRef input) { 25 | 26 | ActorSource.actorRef( 27 | Objects::isNull, 28 | object -> Optional.empty(), 29 | 10, 30 | OverflowStrategy.dropHead() 31 | ).map( 32 | datagram -> { 33 | if (logger.isDebugEnabled()) { 34 | logger.debug("send => {}", datagram.getData().utf8String()); 35 | } 36 | return datagram; 37 | } 38 | ).mapMaterializedValue(outputActor -> { 39 | input.tell(new SourceInitialized(outputActor)); 40 | return NotUsed.getInstance(); 41 | }).viaMat( 42 | new UdpBindFlow(bindAddress, system), Keep.right() 43 | ).map(datagram -> { 44 | if (logger.isDebugEnabled()) { 45 | logger.debug("recv => {}", datagram.getData().utf8String()); 46 | } 47 | return datagram; 48 | }).to( 49 | ActorSink.actorRefWithBackpressure( 50 | input, 51 | (replyTo, datagram) -> new DatagramReceived(datagram, replyTo), 52 | SinkInitialized::new, 53 | Ack.INSTANCE, 54 | SinkClosed.INSTANCE, 55 | SinkFailed::new 56 | ) 57 | ).run(materializer 58 | ).whenComplete((binding, failure) -> { 59 | if (binding != null) { 60 | input.tell(new NotifyBindSucceeded(binding)); 61 | } 62 | }); 63 | } 64 | 65 | public enum Ack { 66 | INSTANCE 67 | } 68 | 69 | public interface Notification {} 70 | 71 | public record SourceInitialized( 72 | @NotNull ActorRef output 73 | ) implements Notification {} 74 | 75 | public record SinkInitialized( 76 | @NotNull ActorRef replyTo 77 | ) implements Notification {} 78 | 79 | public enum SinkClosed implements Notification { 80 | INSTANCE 81 | } 82 | 83 | public record SinkFailed( 84 | @NotNull Throwable failure 85 | ) implements Notification {} 86 | 87 | public record NotifyBindSucceeded( 88 | @NotNull InetSocketAddress address 89 | ) implements Notification {} 90 | 91 | public record DatagramReceived( 92 | @NotNull Datagram datagram, 93 | @NotNull ActorRef replyTo 94 | ) implements Notification {} 95 | } 96 | -------------------------------------------------------------------------------- /src/main/java/com/deigmueller/uni_meter/application/UniMeterHttpRoute.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2018-2023 layline.io GmbH 3 | */ 4 | 5 | package com.deigmueller.uni_meter.application; 6 | 7 | import com.deigmueller.uni_meter.output.OutputDevice; 8 | import org.apache.pekko.actor.typed.ActorRef; 9 | import org.apache.pekko.actor.typed.ActorSystem; 10 | import org.apache.pekko.actor.typed.javadsl.AskPattern; 11 | import org.apache.pekko.http.javadsl.model.StatusCodes; 12 | import org.apache.pekko.http.javadsl.server.AllDirectives; 13 | import org.apache.pekko.http.javadsl.server.Route; 14 | import org.apache.pekko.http.javadsl.unmarshalling.StringUnmarshallers; 15 | import org.jetbrains.annotations.NotNull; 16 | 17 | import java.time.Duration; 18 | 19 | public class UniMeterHttpRoute extends AllDirectives { 20 | private final ActorSystem actorSystem; 21 | private final ActorRef outputDevice; 22 | 23 | public UniMeterHttpRoute(@NotNull ActorSystem actorSystem, 24 | @NotNull ActorRef outputDevice) { 25 | this.actorSystem = actorSystem; 26 | this.outputDevice = outputDevice; 27 | } 28 | 29 | public Route createRoute() { 30 | return pathPrefix("api", () -> concat( 31 | path("no_charge", () -> 32 | get(() -> 33 | parameterOptional(StringUnmarshallers.INTEGER, "seconds", seconds -> 34 | onNoCharge(seconds.orElse(Integer.MAX_VALUE)) 35 | ) 36 | ) 37 | ), 38 | path("no_discharge", () -> 39 | get(() -> 40 | parameterOptional(StringUnmarshallers.INTEGER, "seconds", seconds -> 41 | onNoDischarge(seconds.orElse(Integer.MAX_VALUE)) 42 | ) 43 | ) 44 | ), 45 | path("switch_on", () -> 46 | get(this::onSwitchOn) 47 | ), 48 | path("switch_off", () -> 49 | get(() -> 50 | parameterOptional(StringUnmarshallers.INTEGER, "seconds", seconds -> 51 | onSwitchOff(seconds.orElse(Integer.MAX_VALUE)) 52 | ) 53 | ) 54 | ) 55 | )); 56 | } 57 | 58 | private Route onNoCharge(int seconds) { 59 | return completeWithFutureStatus( 60 | AskPattern.ask( 61 | outputDevice, 62 | (ActorRef replyTo) -> new OutputDevice.NoCharge(Math.max(1, seconds), replyTo), 63 | Duration.ofSeconds(15), 64 | actorSystem.scheduler() 65 | ).thenApply(response -> StatusCodes.OK) 66 | ); 67 | } 68 | 69 | private Route onNoDischarge(int seconds) { 70 | return completeWithFutureStatus( 71 | AskPattern.ask( 72 | outputDevice, 73 | (ActorRef replyTo) -> new OutputDevice.NoDischarge(Math.max(1, seconds), replyTo), 74 | Duration.ofSeconds(15), 75 | actorSystem.scheduler() 76 | ).thenApply(response -> StatusCodes.OK) 77 | ); 78 | } 79 | 80 | private Route onSwitchOn() { 81 | return completeWithFutureStatus( 82 | AskPattern.ask( 83 | outputDevice, 84 | OutputDevice.SwitchOn::new, 85 | Duration.ofSeconds(15), 86 | actorSystem.scheduler() 87 | ).thenApply(response -> StatusCodes.OK) 88 | ); 89 | 90 | } 91 | 92 | private Route onSwitchOff(int seconds) { 93 | return completeWithFutureStatus( 94 | AskPattern.ask( 95 | outputDevice, 96 | (ActorRef replyTo) -> new OutputDevice.SwitchOff(Math.max(1, seconds), replyTo), 97 | Duration.ofSeconds(15), 98 | actorSystem.scheduler() 99 | ).thenApply(response -> StatusCodes.OK) 100 | ); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/main/java/com/deigmueller/uni_meter/application/WebsocketInput.java: -------------------------------------------------------------------------------- 1 | package com.deigmueller.uni_meter.application; 2 | 3 | import org.apache.pekko.NotUsed; 4 | import org.apache.pekko.actor.typed.ActorRef; 5 | import org.apache.pekko.http.javadsl.model.ws.Message; 6 | import org.apache.pekko.stream.Materializer; 7 | import org.apache.pekko.stream.javadsl.Flow; 8 | import org.apache.pekko.stream.javadsl.Sink; 9 | import org.apache.pekko.stream.typed.javadsl.ActorSink; 10 | 11 | import org.jetbrains.annotations.NotNull; 12 | import org.slf4j.Logger; 13 | 14 | import java.net.InetAddress; 15 | 16 | import static com.deigmueller.uni_meter.application.WebsocketOutput.toStrictMessage; 17 | 18 | public class WebsocketInput { 19 | public static Sink createSink(@NotNull Logger logger, 20 | @NotNull String connectionId, 21 | @NotNull InetAddress remoteAddress, 22 | @NotNull Materializer materializer, 23 | @NotNull ActorRef device) { 24 | return Flow.of(Message.class) 25 | .map(message -> toStrictMessage(message, materializer)) 26 | .mapAsync(1, cs -> cs) 27 | .map(message -> { 28 | if (logger.isDebugEnabled()) { 29 | String msg = message.isText() 30 | ? message.asTextMessage().getStrictText() 31 | : message.asBinaryMessage().getStrictData().utf8String(); 32 | logger.debug("recv <= {}", msg); 33 | } 34 | return message; 35 | }) 36 | .to( 37 | ActorSink.actorRefWithBackpressure( 38 | device, 39 | (replyTo, message) -> new NotifyMessageReceived(connectionId, remoteAddress, message, replyTo), 40 | (ack) -> new NotifyOpened(connectionId, remoteAddress, ack), 41 | Ack.INSTANCE, 42 | new NotifyClosed(connectionId), 43 | (failure) -> new NotifyFailed(connectionId, failure) 44 | ) 45 | ); 46 | } 47 | 48 | public enum Ack { 49 | INSTANCE 50 | } 51 | 52 | public interface Notification {} 53 | 54 | public record NotifyOpened( 55 | @NotNull String connectionId, 56 | @NotNull InetAddress remoteAddress, 57 | @NotNull ActorRef replyTo 58 | ) implements Notification {} 59 | 60 | public record NotifyClosed( 61 | @NotNull String connectionId 62 | ) implements Notification {} 63 | 64 | public record NotifyFailed( 65 | @NotNull String connectionId, 66 | @NotNull Throwable failure 67 | ) implements Notification {} 68 | 69 | public record NotifyMessageReceived( 70 | @NotNull String connectionId, 71 | @NotNull InetAddress remoteAddress, 72 | @NotNull Message message, 73 | @NotNull ActorRef replyTo 74 | ) implements Notification {} 75 | } 76 | -------------------------------------------------------------------------------- /src/main/java/com/deigmueller/uni_meter/application/WebsocketOutput.java: -------------------------------------------------------------------------------- 1 | package com.deigmueller.uni_meter.application; 2 | 3 | import org.apache.pekko.NotUsed; 4 | import org.apache.pekko.actor.typed.ActorRef; 5 | import org.apache.pekko.http.javadsl.model.ws.BinaryMessage; 6 | import org.apache.pekko.http.javadsl.model.ws.Message; 7 | import org.apache.pekko.http.javadsl.model.ws.TextMessage; 8 | import org.apache.pekko.stream.Materializer; 9 | import org.apache.pekko.stream.OverflowStrategy; 10 | import org.apache.pekko.stream.javadsl.Source; 11 | import org.apache.pekko.stream.typed.javadsl.ActorSource; 12 | 13 | import org.jetbrains.annotations.NotNull; 14 | import org.slf4j.Logger; 15 | 16 | import java.util.Optional; 17 | import java.util.concurrent.CompletionStage; 18 | import java.util.function.Consumer; 19 | 20 | public class WebsocketOutput { 21 | private WebsocketOutput() {} 22 | 23 | public static Optional failureMatcher(WebsocketOutput.Command command) { 24 | if (command instanceof WebsocketOutput.Failure) { 25 | return Optional.of(((WebsocketOutput.Failure) command).failure()); 26 | } else { 27 | return Optional.empty(); 28 | } 29 | } 30 | 31 | public static Source createSource(@NotNull Logger logger, 32 | @NotNull Materializer materializer, 33 | @NotNull Consumer> notifyServer) { 34 | return ActorSource.actorRef( 35 | c -> c instanceof WebsocketOutput.Close, 36 | WebsocketOutput::failureMatcher, 37 | 10, 38 | OverflowStrategy.fail() 39 | ).filter( 40 | command -> command instanceof WebsocketOutput.Send 41 | ).map( 42 | command -> ((WebsocketOutput.Send) command).message() 43 | ).map( 44 | message -> { 45 | if (logger.isDebugEnabled()) { 46 | String msg = message.isText() 47 | ? message.asTextMessage().getStrictText() 48 | : message.asBinaryMessage().getStrictData().utf8String(); 49 | logger.debug("send => {}", msg); 50 | } 51 | return message; 52 | } 53 | ).mapMaterializedValue(destinationRef -> { 54 | notifyServer.accept(destinationRef); 55 | return NotUsed.getInstance(); 56 | }).map(message -> WebsocketOutput.toStrictMessage(message, materializer)) 57 | .mapAsync(1, cs -> cs) 58 | .map(m -> m); 59 | } 60 | 61 | /** 62 | * Convert a Pekko WebSocket message into a strict message 63 | * @param message Message to convert 64 | * @param materializer Materializer to use 65 | * @return Completion stage which completes to a strict message 66 | */ 67 | public static CompletionStage toStrictMessage(Message message, Materializer materializer) { 68 | CompletionStage completionStage; 69 | 70 | if(message instanceof TextMessage t) { 71 | completionStage = t.toStrict(5000, materializer); 72 | } else { 73 | BinaryMessage b = (BinaryMessage) message; 74 | completionStage = b.toStrict(5000, materializer); 75 | } 76 | 77 | return completionStage; 78 | } 79 | 80 | public interface Command {} 81 | 82 | public enum Close implements Command { 83 | INSTANCE 84 | } 85 | 86 | public record Failure( 87 | @NotNull Throwable failure 88 | ) implements Command { } 89 | 90 | public record Send( 91 | @NotNull Message message 92 | ) implements Command { } 93 | } 94 | -------------------------------------------------------------------------------- /src/main/java/com/deigmueller/uni_meter/common/shelly/RpcError.java: -------------------------------------------------------------------------------- 1 | package com.deigmueller.uni_meter.common.shelly; 2 | 3 | import lombok.AccessLevel; 4 | import lombok.NoArgsConstructor; 5 | 6 | @NoArgsConstructor(access = AccessLevel.PRIVATE) 7 | public class RpcError { 8 | public static final int ERROR_NO_POWER_DATA = 1; 9 | public static final String ERROR_NO_POWER_DATA_MSG = "power data is currently not available (no data received from the input device)"; 10 | 11 | public static final int ERROR_USAGE_CONSTRAINT = 2; 12 | public static final String ERROR_USAGE_CONSTRAINT_MSG = "usage constraint violation (no charge/discharge allowed)"; 13 | } 14 | -------------------------------------------------------------------------------- /src/main/java/com/deigmueller/uni_meter/common/shelly/RpcException.java: -------------------------------------------------------------------------------- 1 | package com.deigmueller.uni_meter.common.shelly; 2 | 3 | import lombok.Getter; 4 | import org.jetbrains.annotations.NotNull; 5 | 6 | @Getter 7 | public class RpcException extends RuntimeException { 8 | private final int code; 9 | 10 | public RpcException(int code, 11 | @NotNull String message) { 12 | super(message); 13 | this.code = code; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/main/java/com/deigmueller/uni_meter/common/utils/Json.java: -------------------------------------------------------------------------------- 1 | package com.deigmueller.uni_meter.common.utils; 2 | 3 | import com.jayway.jsonpath.JsonPath; 4 | import org.jetbrains.annotations.NotNull; 5 | import org.jetbrains.annotations.Nullable; 6 | 7 | public class Json { 8 | public static @Nullable Double readDoubleValue(@NotNull String jsonPath, 9 | @NotNull String payload, 10 | double scale) { 11 | try { 12 | Object value = JsonPath.read(payload, jsonPath); 13 | if (value == null) { 14 | return null; 15 | } 16 | 17 | if (value instanceof java.util.List list) { 18 | if (list.isEmpty()) { 19 | return null; 20 | } 21 | 22 | value = list.get(0); 23 | } 24 | 25 | if (value instanceof Number number) { 26 | return number.doubleValue() * scale; 27 | } 28 | 29 | return Double.parseDouble(value.toString()) * scale; 30 | } catch (com.jayway.jsonpath.PathNotFoundException | NumberFormatException exception) { 31 | return null; 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/main/java/com/deigmueller/uni_meter/common/utils/MathUtils.java: -------------------------------------------------------------------------------- 1 | package com.deigmueller.uni_meter.common.utils; 2 | 3 | public class MathUtils { 4 | public static double round(double value, int places) { 5 | if (places < 0) { 6 | throw new IllegalArgumentException(); 7 | } 8 | 9 | long factor = (long) Math.pow(10, places); 10 | value = value * factor; 11 | long tmp = Math.round(value); 12 | return (double) tmp / factor; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/com/deigmueller/uni_meter/common/utils/NetUtils.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2018-2023 layline.io GmbH 3 | */ 4 | 5 | package com.deigmueller.uni_meter.common.utils; 6 | 7 | import org.jetbrains.annotations.NotNull; 8 | import org.jetbrains.annotations.Nullable; 9 | 10 | import java.net.DatagramSocket; 11 | import java.net.InetAddress; 12 | import java.net.NetworkInterface; 13 | import java.util.Iterator; 14 | import java.util.List; 15 | import java.util.Set; 16 | import java.util.TreeSet; 17 | 18 | public class NetUtils { 19 | public static @NotNull String detectPrimaryIpAddress() { 20 | try(final DatagramSocket socket = new DatagramSocket()){ 21 | socket.connect(InetAddress.getByName("8.8.8.8"), 10002); 22 | return socket.getLocalAddress().getHostAddress(); 23 | } catch (Exception e) { 24 | return "127.0.0.1"; 25 | } 26 | } 27 | 28 | public static @Nullable String detectPrimaryMacAddress() { 29 | Set macAddresses = new TreeSet<>(); 30 | 31 | try { 32 | Iterator iterator = NetworkInterface.getNetworkInterfaces().asIterator(); 33 | while (iterator.hasNext()) { 34 | try { 35 | NetworkInterface networkInterface = iterator.next(); 36 | if (networkInterface.isUp() && !networkInterface.isLoopback() && !networkInterface.isVirtual()) { 37 | byte[] macAddress = networkInterface.getHardwareAddress(); 38 | if (macAddress != null) { 39 | StringBuilder macAddressString = new StringBuilder(); 40 | for (byte address : macAddress) { 41 | macAddressString.append(String.format("%02X", address)); 42 | } 43 | macAddresses.add(macAddressString.toString()); 44 | } 45 | } 46 | } catch (Exception e) { 47 | // ignore 48 | } 49 | } 50 | } catch (Exception e) { 51 | // ignore 52 | } 53 | 54 | List list = macAddresses.stream().toList(); 55 | 56 | return list.isEmpty() ? null : list.get(list.size() - 1); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/main/java/com/deigmueller/uni_meter/input/InputDevice.java: -------------------------------------------------------------------------------- 1 | package com.deigmueller.uni_meter.input; 2 | 3 | import com.deigmueller.uni_meter.output.OutputDevice; 4 | import com.typesafe.config.Config; 5 | import lombok.AccessLevel; 6 | import lombok.Getter; 7 | import org.apache.pekko.actor.typed.ActorRef; 8 | import org.apache.pekko.actor.typed.Behavior; 9 | import org.apache.pekko.actor.typed.javadsl.AbstractBehavior; 10 | import org.apache.pekko.actor.typed.javadsl.ActorContext; 11 | import org.apache.pekko.actor.typed.javadsl.Behaviors; 12 | import org.apache.pekko.actor.typed.javadsl.Receive; 13 | import org.slf4j.Logger; 14 | import org.slf4j.LoggerFactory; 15 | 16 | import org.jetbrains.annotations.NotNull; 17 | 18 | @Getter(AccessLevel.PROTECTED) 19 | public abstract class InputDevice extends AbstractBehavior { 20 | // Class members 21 | public static final String PHASE_MODE_MONO = "mono-phase"; 22 | public static final String PHASE_MODE_TRI = "tri-phase"; 23 | 24 | // Instance members 25 | protected final Logger logger = LoggerFactory.getLogger("uni-meter.input"); 26 | private final ActorRef outputDeviceAckAdapter = getContext().messageAdapter( 27 | OutputDevice.Ack.class, WrappedOutputDeviceAck::new); 28 | private final ActorRef outputDevice; 29 | private final Config config; 30 | private final double defaultVoltage; 31 | private final double defaultFrequency; 32 | private final OutputDevice.PowerData nullPowerData; 33 | private final OutputDevice.EnergyData nullEnergyData; 34 | private int nextMessageId = 1; 35 | 36 | protected InputDevice(@NotNull ActorContext context, 37 | @NotNull ActorRef outputDevice, 38 | @NotNull Config config) { 39 | super(context); 40 | this.outputDevice = outputDevice; 41 | this.config = config; 42 | this.defaultVoltage = config.hasPath("default-voltage") ? getConfig().getDouble("default-voltage") : 230.0; 43 | this.defaultFrequency = config.hasPath("default-frequency") ? config.getDouble("default-frequency") : 50.0; 44 | this.nullPowerData = new OutputDevice.PowerData(0.0, 0.0, 1.0, 0.0, defaultVoltage, defaultFrequency); 45 | this.nullEnergyData = new OutputDevice.EnergyData(0, 0); 46 | } 47 | 48 | @Override 49 | public Receive createReceive() { 50 | return newReceiveBuilder() 51 | .onMessage(WrappedOutputDeviceAck.class, this::onWrappedOutputDeviceAck) 52 | .build(); 53 | } 54 | 55 | protected @NotNull Behavior onWrappedOutputDeviceAck(@NotNull WrappedOutputDeviceAck wrappedOutputDeviceAck) { 56 | logger.trace("InputDevice.onWrappedOutputDeviceAck()"); 57 | return Behaviors.same(); 58 | } 59 | 60 | protected void notifyPowerData(@NotNull PhaseMode powerPhaseMode, 61 | @NotNull String powerPhase, 62 | double power) { 63 | notifyPowerData( 64 | powerPhaseMode, 65 | powerPhase, 66 | power, 67 | power, 68 | 1.0, 69 | power / getDefaultVoltage(), 70 | getDefaultVoltage(), 71 | getDefaultFrequency()); 72 | } 73 | 74 | protected void notifyPowerData(@NotNull PhaseMode powerPhaseMode, 75 | @NotNull String powerPhase, 76 | double power, 77 | double apparentPower, 78 | double powerFactor, 79 | double current, 80 | double voltage, 81 | double frequency) { 82 | logger.trace("Pulse.notifyPowerData()"); 83 | 84 | OutputDevice.PowerData powerData = new OutputDevice.PowerData( 85 | power, 86 | apparentPower, 87 | powerFactor, 88 | current, 89 | voltage, 90 | frequency); 91 | 92 | if (powerPhaseMode == PhaseMode.TRI) { 93 | getOutputDevice().tell( 94 | new OutputDevice.NotifyTotalPowerData( 95 | getNextMessageId(), 96 | powerData, 97 | getOutputDeviceAckAdapter())); 98 | } else { 99 | getOutputDevice().tell(new OutputDevice.NotifyPhasesPowerData( 100 | getNextMessageId(), 101 | powerPhase.equals("l1") ? powerData : nullPowerData, 102 | powerPhase.equals("l2") ? powerData : nullPowerData, 103 | powerPhase.equals("l3") ? powerData : nullPowerData, 104 | getOutputDeviceAckAdapter())); 105 | } 106 | } 107 | 108 | protected void notifyEnergyData(@NotNull PhaseMode energyPhaseMode, 109 | @NotNull String energyPhase, 110 | double energyImport, 111 | double energyExport) { 112 | logger.trace("Pulse.notifyEnergyData()"); 113 | 114 | OutputDevice.EnergyData energyData = new OutputDevice.EnergyData( 115 | energyImport, 116 | energyExport); 117 | 118 | if (energyPhaseMode == PhaseMode.TRI) { 119 | getOutputDevice().tell( 120 | new OutputDevice.NotifyTotalEnergyData( 121 | getNextMessageId(), 122 | energyData, 123 | getOutputDeviceAckAdapter())); 124 | } else { 125 | getOutputDevice().tell( 126 | new OutputDevice.NotifyPhasesEnergyData( 127 | getNextMessageId(), 128 | energyPhase.equals("l1") ? energyData : nullEnergyData, 129 | energyPhase.equals("l2") ? energyData : nullEnergyData, 130 | energyPhase.equals("l3") ? energyData : nullEnergyData, 131 | getOutputDeviceAckAdapter())); 132 | } 133 | } 134 | 135 | protected @NotNull PhaseMode getPhaseMode(@NotNull String key) { 136 | String value = getConfig().getString(key); 137 | 138 | if (PHASE_MODE_MONO.compareToIgnoreCase(value) == 0) { 139 | return PhaseMode.MONO; 140 | } else if (PHASE_MODE_TRI.compareToIgnoreCase(value) == 0) { 141 | return PhaseMode.TRI; 142 | } else { 143 | throw new IllegalArgumentException("unknown phase mode: " + value); 144 | } 145 | } 146 | 147 | protected int getNextMessageId() { 148 | return nextMessageId++; 149 | } 150 | 151 | public interface Command {} 152 | 153 | public record WrappedOutputDeviceAck( 154 | @NotNull OutputDevice.Ack ack 155 | ) implements Command {} 156 | 157 | public enum PhaseMode { 158 | MONO, 159 | TRI 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/main/java/com/deigmueller/uni_meter/input/device/common/generic/BaseChannelReader.java: -------------------------------------------------------------------------------- 1 | package com.deigmueller.uni_meter.input.device.common.generic; 2 | 3 | import com.typesafe.config.Config; 4 | import lombok.Getter; 5 | import org.jetbrains.annotations.NotNull; 6 | 7 | @Getter 8 | public abstract class BaseChannelReader implements ChannelReader { 9 | // Instance members 10 | private final String channel; 11 | private final Double scale; 12 | 13 | public BaseChannelReader(@NotNull Config config) { 14 | this.channel = config.getString("channel"); 15 | if (config.hasPath("scale")) { 16 | this.scale = config.getDouble("scale"); 17 | } else { 18 | this.scale = 1.0; 19 | } 20 | } 21 | 22 | public String toString() { 23 | return "BaseChannelReader(channel=" + this.getChannel() + ", scale=" + this.getScale() + ")"; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/com/deigmueller/uni_meter/input/device/common/generic/ChannelReader.java: -------------------------------------------------------------------------------- 1 | package com.deigmueller.uni_meter.input.device.common.generic; 2 | 3 | import org.jetbrains.annotations.NotNull; 4 | import org.jetbrains.annotations.Nullable; 5 | import org.slf4j.Logger; 6 | 7 | public interface ChannelReader { 8 | @NotNull String getChannel(); 9 | 10 | @Nullable Double getValue(@NotNull Logger logger, @NotNull String payload); 11 | } 12 | -------------------------------------------------------------------------------- /src/main/java/com/deigmueller/uni_meter/input/device/common/generic/GenericInputDevice.java: -------------------------------------------------------------------------------- 1 | package com.deigmueller.uni_meter.input.device.common.generic; 2 | 3 | import com.deigmueller.uni_meter.input.InputDevice; 4 | import com.deigmueller.uni_meter.output.OutputDevice; 5 | import com.jayway.jsonpath.Configuration; 6 | import com.jayway.jsonpath.Option; 7 | import com.jayway.jsonpath.spi.json.JacksonJsonProvider; 8 | import com.jayway.jsonpath.spi.json.JsonProvider; 9 | import com.jayway.jsonpath.spi.mapper.JacksonMappingProvider; 10 | import com.jayway.jsonpath.spi.mapper.MappingProvider; 11 | import com.typesafe.config.Config; 12 | import lombok.AccessLevel; 13 | import lombok.Getter; 14 | import org.apache.pekko.actor.typed.ActorRef; 15 | import org.apache.pekko.actor.typed.javadsl.ActorContext; 16 | import org.apache.pekko.stream.Materializer; 17 | import org.jetbrains.annotations.NotNull; 18 | 19 | import java.util.EnumSet; 20 | import java.util.Set; 21 | 22 | @Getter(AccessLevel.PROTECTED) 23 | public abstract class GenericInputDevice extends InputDevice { 24 | // Instance members 25 | private final Materializer materializer = Materializer.createMaterializer(getContext()); 26 | private final PhaseMode powerPhaseMode = getPhaseMode("power-phase-mode"); 27 | private final PhaseMode energyPhaseMode = getPhaseMode("energy-phase-mode"); 28 | 29 | private double powerTotal; 30 | private double powerTotalProduction; 31 | private double powerL1; 32 | private double powerL1Production; 33 | private double powerL2; 34 | private double powerL2Production; 35 | private double powerL3; 36 | private double powerL3Production; 37 | 38 | private double energyConsumptionTotal; 39 | private double energyConsumptionL1; 40 | private double energyConsumptionL2; 41 | private double energyConsumptionL3; 42 | 43 | private double energyProductionTotal; 44 | private double energyProductionL1; 45 | private double energyProductionL2; 46 | private double energyProductionL3; 47 | 48 | /** 49 | * Constructor 50 | * @param context The actor context 51 | * @param outputDevice The output device to notify 52 | * @param config The configuration 53 | */ 54 | protected GenericInputDevice(@NotNull ActorContext context, 55 | @NotNull ActorRef outputDevice, 56 | @NotNull Config config) { 57 | super(context, outputDevice, config); 58 | 59 | initJsonPath(); 60 | } 61 | 62 | /** 63 | * Set the channel data. 64 | * @param channel The channel to set the data for. 65 | * @param value The value to set. 66 | */ 67 | protected void setChannelData(@NotNull String channel, double value) { 68 | switch (channel) { 69 | case "power-total": 70 | powerTotal = value; 71 | break; 72 | case "power-production-total": 73 | powerTotalProduction = value; 74 | break; 75 | case "power-l1": 76 | powerL1 = value; 77 | break; 78 | case "power-production-l1": 79 | powerL1Production = value; 80 | break; 81 | case "power-l2": 82 | powerL2 = value; 83 | break; 84 | case "power-production-l2": 85 | powerL2Production = value; 86 | break; 87 | case "power-l3": 88 | powerL3 = value; 89 | break; 90 | case "power-production-l3": 91 | powerL3Production = value; 92 | break; 93 | case "energy-consumption-total": 94 | energyConsumptionTotal = value; 95 | break; 96 | case "energy-consumption-l1": 97 | energyConsumptionL1 = value; 98 | break; 99 | case "energy-consumption-l2": 100 | energyConsumptionL2 = value; 101 | break; 102 | case "energy-consumption-l3": 103 | energyConsumptionL3 = value; 104 | break; 105 | case "energy-production-total": 106 | energyProductionTotal = value; 107 | break; 108 | case "energy-production-l1": 109 | energyProductionL1 = value; 110 | break; 111 | case "energy-production-l2": 112 | energyProductionL2 = value; 113 | break; 114 | case "energy-production-l3": 115 | energyProductionL3 = value; 116 | break; 117 | default: 118 | logger.warn("unknown channel: {}", channel); 119 | } 120 | } 121 | 122 | /** 123 | * Notify the current readings to the output device. 124 | */ 125 | protected void notifyOutputDevice() { 126 | notifyPowerData(); 127 | 128 | notifyEnergyData(); 129 | } 130 | 131 | /** 132 | * Notify the current power data to the output device. 133 | */ 134 | protected void notifyPowerData() { 135 | if (powerPhaseMode == PhaseMode.MONO) { 136 | double resultingPower = powerTotal - powerTotalProduction; 137 | 138 | getOutputDevice().tell(new OutputDevice.NotifyTotalPowerData( 139 | getNextMessageId(), 140 | new OutputDevice.PowerData( 141 | resultingPower, resultingPower, 1.0, resultingPower / getDefaultVoltage(), getDefaultVoltage(), getDefaultFrequency()), 142 | getOutputDeviceAckAdapter())); 143 | } else { 144 | double resultingPowerL1 = powerL1 - powerL1Production; 145 | double resultingPowerL2 = powerL2 - powerL2Production; 146 | double resultingPowerL3 = powerL3 - powerL3Production; 147 | 148 | getOutputDevice().tell(new OutputDevice.NotifyPhasesPowerData( 149 | getNextMessageId(), 150 | new OutputDevice.PowerData( 151 | resultingPowerL1, resultingPowerL1, 1.0, resultingPowerL1 / getDefaultVoltage(), getDefaultVoltage(), getDefaultFrequency()), 152 | new OutputDevice.PowerData( 153 | resultingPowerL2, resultingPowerL2, 1.0, resultingPowerL2 / getDefaultVoltage(), getDefaultVoltage(), getDefaultFrequency()), 154 | new OutputDevice.PowerData( 155 | resultingPowerL3, resultingPowerL3, 1.0, resultingPowerL3 / getDefaultVoltage(), getDefaultVoltage(), getDefaultFrequency()), 156 | getOutputDeviceAckAdapter())); 157 | } 158 | } 159 | 160 | /** 161 | * Notify the current energy data to the output device. 162 | */ 163 | protected void notifyEnergyData() { 164 | if (energyPhaseMode == PhaseMode.MONO) { 165 | getOutputDevice().tell(new OutputDevice.NotifyTotalEnergyData( 166 | getNextMessageId(), 167 | new OutputDevice.EnergyData(energyConsumptionTotal, energyProductionTotal), 168 | getOutputDeviceAckAdapter())); 169 | } else { 170 | getOutputDevice().tell(new OutputDevice.NotifyPhaseEnergyData( 171 | getNextMessageId(), 172 | 0, 173 | new OutputDevice.EnergyData(energyConsumptionL1, energyProductionL1), 174 | getOutputDeviceAckAdapter())); 175 | getOutputDevice().tell(new OutputDevice.NotifyPhaseEnergyData( 176 | getNextMessageId(), 177 | 1, 178 | new OutputDevice.EnergyData(energyConsumptionL2, energyProductionL2), 179 | getOutputDeviceAckAdapter())); 180 | getOutputDevice().tell(new OutputDevice.NotifyPhaseEnergyData( 181 | getNextMessageId(), 182 | 2, 183 | new OutputDevice.EnergyData(energyConsumptionL3, energyProductionL3), 184 | getOutputDeviceAckAdapter())); 185 | } 186 | } 187 | 188 | private void initJsonPath() { 189 | Configuration.setDefaults(new Configuration.Defaults() { 190 | 191 | private final JsonProvider jsonProvider = new JacksonJsonProvider(); 192 | private final MappingProvider mappingProvider = new JacksonMappingProvider(); 193 | 194 | @Override 195 | public JsonProvider jsonProvider() { 196 | return jsonProvider; 197 | } 198 | 199 | @Override 200 | public MappingProvider mappingProvider() { 201 | return mappingProvider; 202 | } 203 | 204 | @Override 205 | public Set