Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
f9526bd
mDNS: add IP/interface config & NetUtils
thimo-seitz Feb 26, 2026
5c4bcf1
Update MDnsAvahi.java
thimo-seitz Feb 26, 2026
bffc3ca
Upgrade templating plugin; relocate Version
thimo-seitz Feb 26, 2026
8e7f3ea
Update NetUtils.java
thimo-seitz Feb 26, 2026
748a250
Update BareMetal.md
thimo-seitz Feb 26, 2026
ad2bec5
Update NetUtils.java
thimo-seitz Feb 26, 2026
67ff1c3
Update NetUtils.java
thimo-seitz Feb 26, 2026
30773e4
Create some unit tests
thimo-seitz Feb 26, 2026
741a5cb
Update pom.xml
thimo-seitz Mar 18, 2026
86ded62
Move mDNS IP resolution to OutputDevice
thimo-seitz Mar 18, 2026
47cdaf5
Add mDNS IP fallback and resolve overloads
thimo-seitz Mar 18, 2026
58bf0df
Update OutputDevice.java
thimo-seitz Mar 18, 2026
5fed43f
Update NetUtils.java
thimo-seitz Mar 18, 2026
b3cd78e
Update NetUtils.java
thimo-seitz Mar 18, 2026
7c0413d
Update OutputDevice.java
thimo-seitz Mar 18, 2026
b384672
Update OutputDevice.java
thimo-seitz Mar 18, 2026
f82bd28
Rename MDnsAvahiTest to OutputDeviceTest
thimo-seitz Mar 18, 2026
6af6d27
Use static logger and simplify IP resolver API
thimo-seitz Mar 18, 2026
de84c8a
Update OutputDeviceTest.java
thimo-seitz Mar 18, 2026
875c0dd
Update MDnsAvahi.java
thimo-seitz Mar 18, 2026
4d76955
Use output-device interface for mDNS address
thimo-seitz Mar 18, 2026
663ccdc
Update NetUtils.java
thimo-seitz Mar 18, 2026
85b4114
Merge remote-tracking branch 'upstream/main' into support-specify-ip-…
thimo-seitz Mar 18, 2026
9450064
Update reference.conf
thimo-seitz Mar 18, 2026
a5cef88
Merge remote-tracking branch 'upstream/main' into support-specify-ip-…
thimo-seitz Apr 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,11 @@ uni-meter {

Some sample configurations for different devices can be found [here](https://github.com/sdeigm/uni-meter/tree/main/samples).

If you use mDNS, the announced IP address is derived from the selected output device `interface` configuration.
If `interface` contains an IPv4 or IPv6 address, `uni-meter` uses it directly. Otherwise, it is treated as a network
interface name and `uni-meter` tries to resolve an IP address from it, preferring IPv4 over IPv6. If that does not
work, `uni-meter` falls back to the detected primary IPv4 address.

## Output device configuration

To configure the output device, follow the instructions in these sections:
Expand Down Expand Up @@ -173,4 +178,3 @@ type.
```

A restart is necessary for these changes to take effect.

16 changes: 14 additions & 2 deletions doc/install/BareMetal.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,5 +168,17 @@ sudo systemctl start avahi-daemon
Starting with `uni-meter` version 1.1.5, a running avahi daemon is automatically detected and all necessary configuration
files will be automatically created in `/etc/avahi/service`.



The IP address announced via mDNS is derived from the configuration of the selected output device.
If `interface` contains an IPv4 or IPv6 address, `uni-meter` uses it directly. Otherwise, it is treated as a network
interface name and `uni-meter` tries to resolve an IP address from it, preferring IPv4 over IPv6. If that does not
work, `uni-meter` falls back to the detected primary IPv4 address of the host.

```hocon
uni-meter {
output-devices {
shelly-pro3em {
interface = "eth0"
}
}
}
```
19 changes: 19 additions & 0 deletions doc/output/Common.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,3 +112,22 @@ uni-meter {
}
}
```

## Configuring the announced mDNS address

If mDNS is used, the announced IP address is derived from the selected output device configuration.
If `interface` contains an IPv4 or IPv6 address, `uni-meter` uses it directly. Otherwise, it is treated as a network
interface name and `uni-meter` tries to resolve an IP address from it, preferring IPv4 over IPv6. If that does not
work, `uni-meter` falls back to the detected primary IPv4 address.

```hocon
uni-meter {
#...
output-devices {
#...
shelly-pro3em {
interface = "eth0"
}
}
}
```
27 changes: 25 additions & 2 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@
<pekko.connectors.version>1.0.2</pekko.connectors.version>
<scala.binary.version>2.13</scala.binary.version>
<junit5.version>5.8.2</junit5.version>
<mockito.version>5.21.0</mockito.version>
<maven-surefire-plugin.version>3.5.5</maven-surefire-plugin.version>
<templating-maven-plugin.version>3.1.0</templating-maven-plugin.version>
</properties>

<profiles>
Expand Down Expand Up @@ -168,6 +171,18 @@
<version>${pekko.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>${mockito.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>${mockito.version}</version>
<scope>test</scope>
</dependency>

</dependencies>

Expand Down Expand Up @@ -226,11 +241,19 @@
</execution>
</executions>
</plugin>

<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>${maven-surefire-plugin.version}</version>
<configuration>
<!-- mockito: https://javadoc.io/doc/org.mockito/mockito-core/latest/org/mockito/Mockito.html#0.3 -->
<argLine>-XX:+EnableDynamicAgentLoading</argLine>
</configuration>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>templating-maven-plugin</artifactId>
<version>1.0.0</version>
<version>${templating-maven-plugin.version}</version>
<executions>
<execution>
<id>generate-version-class</id>
Expand Down
127 changes: 96 additions & 31 deletions src/main/java/com/deigmueller/uni_meter/common/utils/NetUtils.java
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
package com.deigmueller.uni_meter.common.utils;

import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

import java.net.DatagramSocket;
import java.net.InetAddress;
import java.net.NetworkInterface;
import java.util.Iterator;
import java.net.SocketException;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import java.util.Objects;
import java.util.TreeSet;
import java.util.stream.Collectors;
import org.apache.commons.lang3.stream.Streams;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

public class NetUtils {
public static @NotNull String detectPrimaryIpAddress() {
Expand All @@ -20,35 +22,98 @@ public class NetUtils {
return "127.0.0.1";
}
}

public static @Nullable String detectPrimaryMacAddress() {
Set<String> macAddresses = new TreeSet<>();


/**
* Detects the primary MAC address of the system by iterating through all network interfaces and returning the last valid MAC address found.
* Loopback and virtual interfaces are ignored.
* @return the primary MAC address as a string, or null if no valid MAC address is found
*/
public static @Nullable String detectPrimaryMacAddress() {
try {
var foundAddresses = Streams.of(NetworkInterface.getNetworkInterfaces()) //
.filter(n -> !isLoopbackOrVirtual(n)) // filter out loopback and virtual interfaces
.map(NetUtils::hardwareAddressToString) // convert to string representation of the MAC address
.filter(Objects::nonNull) // filter out interfaces without a hardware address
.collect(Collectors.toCollection(TreeSet::new)); // collect the results
return foundAddresses.isEmpty() ? null : foundAddresses.last();
} catch (SocketException e) {
// We dont't care
return null;
}
}

/**
* Detects the IP address associated with the specified network interface.
* @param interfaceName the name of the network interface (e.g., "eth0", "wlan0")
* @return the IP address as a string, or null if the interface is not found, not up, or has no valid IP address
*/
public static @Nullable String detectIpAddressFromInterface(@NotNull String interfaceName) {
try {
Iterator<NetworkInterface> iterator = NetworkInterface.getNetworkInterfaces().asIterator();
while (iterator.hasNext()) {
try {
NetworkInterface networkInterface = iterator.next();
if (networkInterface.isUp() && !networkInterface.isLoopback() && !networkInterface.isVirtual()) {
byte[] macAddress = networkInterface.getHardwareAddress();
if (macAddress != null) {
StringBuilder macAddressString = new StringBuilder();
for (byte address : macAddress) {
macAddressString.append(String.format("%02X", address));
}
macAddresses.add(macAddressString.toString());
}
}
} catch (Exception e) {
// ignore
}
NetworkInterface networkInterface = NetworkInterface.getByName(interfaceName);
if (networkInterface == null || !networkInterface.isUp()) {
return null; // interface not found or not up return null
}
List<InetAddress> addresses = Streams.of(networkInterface.getInetAddresses()) //
.filter(a -> !a.isLoopbackAddress()) // filter out loopback addresses
.toList();
return addresses.stream()
.filter(address -> address.getAddress().length == 4) // prefer IPv4 addresses
.findFirst() //
.or(() -> addresses.stream().findFirst()) // if no IPv4 address is found, return the first available address (which may be IPv6)
.map(InetAddress::getHostAddress) //
.orElse(null);
} catch (Exception e) {
// ignore
// We don't care
}
return null;
}

/**
* Lists the names of all network interfaces available on the system.
* @return a list of network interface names, sorted alphabetically
*/
public static @NotNull List<String> listNetworkInterfaceNames() {
try {
return Streams.of(NetworkInterface.getNetworkInterfaces()).map(NetworkInterface::getName).sorted().toList();
} catch (SocketException e) {
// We don't care about the exception, we just want to return an empty list in this case
}

List<String> list = macAddresses.stream().toList();

return list.isEmpty() ? null : list.get(list.size() - 1);
return Collections.emptyList();
}

/**
* Checks if the given network interface is a loopback or virtual interface.
* @param networkInterface the network interface to check
* @return true if the interface is a loopback or virtual interface, false otherwise
*/
private static boolean isLoopbackOrVirtual(NetworkInterface networkInterface) {
try {
return networkInterface.isLoopback() || networkInterface.isVirtual();
} catch (Exception e) {
// We don't care
return false;
}
}

/**
* Converts the hardware address (MAC address) of a network interface to a string representation.
* @param networkInterface the network interface whose hardware address is to be converted
* @return the string representation of the hardware address, or null if the hardware address cannot be retrieved
*/
private static @Nullable String hardwareAddressToString(NetworkInterface networkInterface) {
try {
byte[] macAddress = networkInterface.getHardwareAddress();
if (macAddress == null || macAddress.length == 0) {
return null;
}
StringBuilder macAddressString = new StringBuilder();
for (byte address : macAddress) {
macAddressString.append(String.format("%02X", address));
}
return macAddressString.toString();
} catch(SocketException e) {
return null;
}
}

}
3 changes: 2 additions & 1 deletion src/main/java/com/deigmueller/uni_meter/mdns/MDnsAvahi.java
Original file line number Diff line number Diff line change
Expand Up @@ -307,4 +307,5 @@ private enum RestartAvahiPublish implements Command {
}

private record NameAndIpAddress(String name, String ipAddress) {}
}
}

56 changes: 53 additions & 3 deletions src/main/java/com/deigmueller/uni_meter/output/OutputDevice.java
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
package com.deigmueller.uni_meter.output;

import com.deigmueller.uni_meter.application.UniMeter;
import com.deigmueller.uni_meter.common.utils.NetUtils;
import com.deigmueller.uni_meter.mdns.MDnsRegistrator;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.typesafe.config.Config;
import com.typesafe.config.ConfigFactory;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.Setter;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.Strings;
import org.apache.pekko.actor.typed.ActorRef;
import org.apache.pekko.actor.typed.Behavior;
import org.apache.pekko.actor.typed.PostStop;
Expand All @@ -26,6 +29,7 @@
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.function.Supplier;

/**
* Represents an output device responsible for handling power and energy data,
Expand All @@ -35,6 +39,11 @@
@Getter(AccessLevel.PROTECTED)
@Setter(AccessLevel.PROTECTED)
public abstract class OutputDevice extends AbstractBehavior<OutputDevice.Command> {

private static final String UNSPECIFIED_IPV4_ADRESS = "0.0.0.0";
private static final String UNSPECIFIED_IPV6_ADRESS = "::";
private static final Logger LOGGER = LoggerFactory.getLogger("uni-meter.output");

// Instance members
protected final Logger logger = LoggerFactory.getLogger("uni-meter.output");
protected final DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
Expand All @@ -47,6 +56,7 @@ public abstract class OutputDevice extends AbstractBehavior<OutputDevice.Command
private final double defaultVoltage;
private final double defaultFrequency;
private final double defaultClientPowerFactor;
private final String announcedIpAddress;
private final Map<InetAddress, ClientContext> clientContexts = new HashMap<>();

private Instant offUntil = Instant.MIN;
Expand Down Expand Up @@ -86,6 +96,7 @@ protected OutputDevice(@NotNull ActorContext<Command> context,
this.defaultFrequency = config.getDouble("default-frequency");
this.defaultClientPowerFactor = config.getDouble("default-client-power-factor");
this.usageConstraintInitDuration = config.getDuration("usage-constraint-init-duration");
this.announcedIpAddress = resolveAnnouncedIpAddress();

initPowerOffsets(config);

Expand Down Expand Up @@ -534,11 +545,50 @@ protected Map<String,Object> getParameters(@NotNull Map<String,Object> parameter

return parameters;
}

protected abstract Route createRoute();

protected abstract void eventPowerDataChanged();


protected @NotNull String resolveAnnouncedIpAddress() {
return resolveAnnouncedIpAddress(config, NetUtils::detectPrimaryIpAddress);
}

public static @NotNull String resolveAnnouncedIpAddress(@NotNull Config outputDeviceConfig,
@NotNull Supplier<String> fallbackIpAddressSupplier) {
String configuredInterface = StringUtils.trimToEmpty(outputDeviceConfig.getString("interface"));
if (StringUtils.isNotBlank(configuredInterface)
&& !Strings.CS.equalsAny(configuredInterface, UNSPECIFIED_IPV4_ADRESS, UNSPECIFIED_IPV6_ADRESS)) {
if (isIpAddress(configuredInterface)) {
return configuredInterface;
}

List<String> availableInterfaces = NetUtils.listNetworkInterfaceNames();
if (!availableInterfaces.contains(configuredInterface)) {
LOGGER.warn("configured output-device interface '{}' not found. available interfaces: {}",
configuredInterface,
String.join(", ", availableInterfaces));
} else {
String ipAddress = NetUtils.detectIpAddressFromInterface(configuredInterface);
if (StringUtils.isNotBlank(ipAddress)) {
return ipAddress;
}
LOGGER.warn("failed to resolve IP address for output-device interface '{}', falling back to configured default address",
configuredInterface);
}
}

return fallbackIpAddressSupplier.get();
}

private static boolean isIpAddress(@NotNull String value) {
try {
return InetAddress.getByName(value) != null;
} catch (Exception e) {
return false;
}
}

protected void initPowerOffsets(@NotNull Config config) {
offsetPhase0 = config.getDouble("power-offset-l1");
offsetPhase1 = config.getDouble("power-offset-l2");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ protected void registerMDns() {
logger.trace("EcoTracer.registerMDns()");

Map<String, String> txtRecords = new HashMap<>();
txtRecords.put("ip", NetUtils.detectPrimaryIpAddress());
txtRecords.put("ip", getAnnouncedIpAddress());

ConfigObject mdnsObject = getConfig().getObject("mdns");
mdnsObject.forEach((key, value) -> txtRecords.put(key, value.unwrapped().toString()));
Expand All @@ -234,7 +234,7 @@ protected void registerMDns() {
getBindPort(),
txtRecords,
getDefaultHostname() + ".local",
NetUtils.detectPrimaryIpAddress()
getAnnouncedIpAddress()
)
);
}
Expand Down
Loading