diff --git a/README.md b/README.md
index 4588735..373df7f 100644
--- a/README.md
+++ b/README.md
@@ -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:
@@ -173,4 +178,3 @@ type.
```
A restart is necessary for these changes to take effect.
-
diff --git a/doc/install/BareMetal.md b/doc/install/BareMetal.md
index ac800fa..be6bb6a 100644
--- a/doc/install/BareMetal.md
+++ b/doc/install/BareMetal.md
@@ -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"
+ }
+ }
+}
+```
diff --git a/doc/output/Common.md b/doc/output/Common.md
index f372bed..5be854f 100644
--- a/doc/output/Common.md
+++ b/doc/output/Common.md
@@ -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"
+ }
+ }
+}
+```
diff --git a/pom.xml b/pom.xml
index 3d487f4..894a549 100644
--- a/pom.xml
+++ b/pom.xml
@@ -22,6 +22,9 @@
1.0.2
2.13
5.8.2
+ 5.21.0
+ 3.5.5
+ 3.1.0
@@ -168,6 +171,18 @@
${pekko.version}
test
+
+ org.mockito
+ mockito-core
+ ${mockito.version}
+ test
+
+
+ org.mockito
+ mockito-junit-jupiter
+ ${mockito.version}
+ test
+
@@ -226,11 +241,19 @@
-
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ ${maven-surefire-plugin.version}
+
+
+ -XX:+EnableDynamicAgentLoading
+
+
org.codehaus.mojo
templating-maven-plugin
- 1.0.0
+ ${templating-maven-plugin.version}
generate-version-class
diff --git a/src/main/java-templates/Version.java b/src/main/java-templates/com/deigmueller/uni_meter/application/Version.java
similarity index 100%
rename from src/main/java-templates/Version.java
rename to src/main/java-templates/com/deigmueller/uni_meter/application/Version.java
diff --git a/src/main/java/com/deigmueller/uni_meter/common/utils/NetUtils.java b/src/main/java/com/deigmueller/uni_meter/common/utils/NetUtils.java
index 72f1d64..9fe3fbe 100644
--- a/src/main/java/com/deigmueller/uni_meter/common/utils/NetUtils.java
+++ b/src/main/java/com/deigmueller/uni_meter/common/utils/NetUtils.java
@@ -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() {
@@ -20,35 +22,98 @@ public class NetUtils {
return "127.0.0.1";
}
}
-
- public static @Nullable String detectPrimaryMacAddress() {
- Set 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 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 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 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 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;
+ }
+ }
+
}
diff --git a/src/main/java/com/deigmueller/uni_meter/mdns/MDnsAvahi.java b/src/main/java/com/deigmueller/uni_meter/mdns/MDnsAvahi.java
index b41c8d1..a650480 100644
--- a/src/main/java/com/deigmueller/uni_meter/mdns/MDnsAvahi.java
+++ b/src/main/java/com/deigmueller/uni_meter/mdns/MDnsAvahi.java
@@ -307,4 +307,5 @@ private enum RestartAvahiPublish implements Command {
}
private record NameAndIpAddress(String name, String ipAddress) {}
-}
\ No newline at end of file
+}
+
diff --git a/src/main/java/com/deigmueller/uni_meter/output/OutputDevice.java b/src/main/java/com/deigmueller/uni_meter/output/OutputDevice.java
index dd39f95..9d19c17 100644
--- a/src/main/java/com/deigmueller/uni_meter/output/OutputDevice.java
+++ b/src/main/java/com/deigmueller/uni_meter/output/OutputDevice.java
@@ -1,6 +1,7 @@
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;
@@ -8,6 +9,8 @@
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;
@@ -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,
@@ -35,6 +39,11 @@
@Getter(AccessLevel.PROTECTED)
@Setter(AccessLevel.PROTECTED)
public abstract class OutputDevice extends AbstractBehavior {
+
+ 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");
@@ -47,6 +56,7 @@ public abstract class OutputDevice extends AbstractBehavior clientContexts = new HashMap<>();
private Instant offUntil = Instant.MIN;
@@ -86,6 +96,7 @@ protected OutputDevice(@NotNull ActorContext 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);
@@ -534,11 +545,50 @@ protected Map getParameters(@NotNull Map 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 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 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");
diff --git a/src/main/java/com/deigmueller/uni_meter/output/device/eco_tracker/EcoTracker.java b/src/main/java/com/deigmueller/uni_meter/output/device/eco_tracker/EcoTracker.java
index 7611a12..9ba2e20 100644
--- a/src/main/java/com/deigmueller/uni_meter/output/device/eco_tracker/EcoTracker.java
+++ b/src/main/java/com/deigmueller/uni_meter/output/device/eco_tracker/EcoTracker.java
@@ -222,7 +222,7 @@ protected void registerMDns() {
logger.trace("EcoTracer.registerMDns()");
Map 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()));
@@ -234,7 +234,7 @@ protected void registerMDns() {
getBindPort(),
txtRecords,
getDefaultHostname() + ".local",
- NetUtils.detectPrimaryIpAddress()
+ getAnnouncedIpAddress()
)
);
}
diff --git a/src/main/java/com/deigmueller/uni_meter/output/device/shelly/ShellyPro3EM.java b/src/main/java/com/deigmueller/uni_meter/output/device/shelly/ShellyPro3EM.java
index 7c1c224..ca2b84d 100644
--- a/src/main/java/com/deigmueller/uni_meter/output/device/shelly/ShellyPro3EM.java
+++ b/src/main/java/com/deigmueller/uni_meter/output/device/shelly/ShellyPro3EM.java
@@ -1657,7 +1657,7 @@ protected void registerMDns() {
ConfigObject mdnsObject = getConfig().getObject("mdns");
mdnsObject.forEach((key, value) -> txtRecords.put(key, value.unwrapped().toString()));
- String primaryIpAddress = NetUtils.detectPrimaryIpAddress();
+ String announcedIpAddress = getAnnouncedIpAddress();
getMdnsRegistrator().tell(
new MDnsRegistrator.RegisterService(
@@ -1666,7 +1666,7 @@ protected void registerMDns() {
getBindPort(),
txtRecords,
getDefaultHostname() + ".local",
- primaryIpAddress
+ announcedIpAddress
)
);
@@ -1677,7 +1677,7 @@ protected void registerMDns() {
getBindPort(),
txtRecords,
getDefaultHostname() + ".local",
- primaryIpAddress
+ announcedIpAddress
)
);
}
diff --git a/src/test/java/com/deigmueller/uni_meter/common/utils/NetUtilsTest.java b/src/test/java/com/deigmueller/uni_meter/common/utils/NetUtilsTest.java
new file mode 100644
index 0000000..17f7180
--- /dev/null
+++ b/src/test/java/com/deigmueller/uni_meter/common/utils/NetUtilsTest.java
@@ -0,0 +1,36 @@
+package com.deigmueller.uni_meter.common.utils;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.CoreMatchers.notNullValue;
+import static org.hamcrest.MatcherAssert.assertThat;
+
+class NetUtilsTest {
+
+ @Test
+ @DisplayName("detectIpAddressFromInterface returns null for unknown interface")
+ void detectIpAddressFromInterfaceReturnsNullForUnknownInterface() {
+ String ipAddress = NetUtils.detectIpAddressFromInterface("definitely-not-a-real-interface-1234");
+
+ assertThat(ipAddress, is((String) null));
+ }
+
+ @Test
+ @DisplayName("listNetworkInterfaceNames returns a sorted list")
+ void listNetworkInterfaceNamesReturnsSortedList() {
+ List interfaceNames = NetUtils.listNetworkInterfaceNames();
+
+ assertThat(interfaceNames, is(notNullValue()));
+
+ List sortedInterfaceNames = new ArrayList<>(interfaceNames);
+ Collections.sort(sortedInterfaceNames);
+
+ assertThat(interfaceNames, is(sortedInterfaceNames));
+ }
+}
diff --git a/src/test/java/com/deigmueller/uni_meter/output/OutputDeviceTest.java b/src/test/java/com/deigmueller/uni_meter/output/OutputDeviceTest.java
new file mode 100644
index 0000000..be7904d
--- /dev/null
+++ b/src/test/java/com/deigmueller/uni_meter/output/OutputDeviceTest.java
@@ -0,0 +1,223 @@
+package com.deigmueller.uni_meter.output;
+
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.CoreMatchers.not;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.text.IsEmptyString.isEmptyOrNullString;
+import java.util.List;
+import org.apache.pekko.actor.typed.ActorRef;
+import org.apache.pekko.actor.typed.Behavior;
+import org.apache.pekko.actor.typed.javadsl.ActorContext;
+import org.apache.pekko.actor.typed.javadsl.Behaviors;
+import org.apache.pekko.actor.testkit.typed.javadsl.ActorTestKit;
+import org.apache.pekko.http.javadsl.server.Directives;
+import org.jetbrains.annotations.NotNull;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+import com.deigmueller.uni_meter.application.UniMeter;
+import com.deigmueller.uni_meter.common.utils.NetUtils;
+import com.deigmueller.uni_meter.mdns.MDnsRegistrator;
+import com.typesafe.config.Config;
+import com.typesafe.config.ConfigFactory;
+
+class OutputDeviceTest {
+
+ private ActorTestKit testKit;
+
+ @AfterEach
+ void tearDown() {
+ if (testKit != null) {
+ testKit.shutdownTestKit();
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Tests for the static resolveAnnouncedIpAddress(Config, Supplier) overload
+ // ---------------------------------------------------------------------------
+
+ @Test
+ @DisplayName("resolveAnnouncedIpAddress uses configured interface when it is an IPv4 address")
+ void resolveAnnouncedIpAddressUsesConfiguredIpv4Address() {
+ try (MockedStatic mockedNetUtils = Mockito.mockStatic(NetUtils.class)) {
+ mockedNetUtils.when(NetUtils::detectPrimaryIpAddress).thenReturn("10.0.0.5");
+
+ String result = resolveAnnouncedIpAddress(" 192.168.1.10 ");
+
+ assertThat(result, is("192.168.1.10"));
+ }
+ }
+
+ @Test
+ @DisplayName("resolveAnnouncedIpAddress uses configured interface when it is an IPv6 address")
+ void resolveAnnouncedIpAddressUsesConfiguredIpv6Address() {
+ try (MockedStatic mockedNetUtils = Mockito.mockStatic(NetUtils.class)) {
+ mockedNetUtils.when(NetUtils::detectPrimaryIpAddress).thenReturn("10.0.0.5");
+
+ String result = resolveAnnouncedIpAddress(" 2001:db8::10 ");
+
+ assertThat(result, is("2001:db8::10"));
+ }
+ }
+
+ @Test
+ @DisplayName("resolveAnnouncedIpAddress resolves IP from configured interface name")
+ void resolveAnnouncedIpAddressUsesConfiguredInterfaceName() {
+ try (MockedStatic mockedNetUtils = Mockito.mockStatic(NetUtils.class)) {
+ mockedNetUtils.when(NetUtils::listNetworkInterfaceNames).thenReturn(List.of("eth0", "wlan0"));
+ mockedNetUtils.when(() -> NetUtils.detectIpAddressFromInterface("eth0")).thenReturn("192.168.178.22");
+
+ String result = resolveAnnouncedIpAddress("eth0");
+
+ assertThat(result, is("192.168.178.22"));
+ }
+ }
+
+ @Test
+ @DisplayName("resolveAnnouncedIpAddress falls back to primary IP when configured interface is missing")
+ void resolveAnnouncedIpAddressFallsBackForMissingInterface() {
+ try (MockedStatic mockedNetUtils = Mockito.mockStatic(NetUtils.class)) {
+ mockedNetUtils.when(NetUtils::listNetworkInterfaceNames).thenReturn(List.of("eth0", "wlan0"));
+ mockedNetUtils.when(NetUtils::detectPrimaryIpAddress).thenReturn("10.0.0.5");
+
+ String result = resolveAnnouncedIpAddress("eth9");
+
+ assertThat(result, is("10.0.0.5"));
+ }
+ }
+
+ @Test
+ @DisplayName("resolveAnnouncedIpAddress falls back to primary IP when interface has no IP address")
+ void resolveAnnouncedIpAddressFallsBackForInterfaceWithoutIpAddress() {
+ try (MockedStatic mockedNetUtils = Mockito.mockStatic(NetUtils.class)) {
+ mockedNetUtils.when(NetUtils::listNetworkInterfaceNames).thenReturn(List.of("eth0", "wlan0"));
+ mockedNetUtils.when(() -> NetUtils.detectIpAddressFromInterface("eth0")).thenReturn(null);
+ mockedNetUtils.when(NetUtils::detectPrimaryIpAddress).thenReturn("10.0.0.5");
+
+ String result = resolveAnnouncedIpAddress("eth0");
+
+ assertThat(result, is("10.0.0.5"));
+ }
+ }
+
+ @Test
+ @DisplayName("resolveAnnouncedIpAddress falls back to primary IP when nothing is configured")
+ void resolveAnnouncedIpAddressFallsBackToPrimaryIp() {
+ try (MockedStatic mockedNetUtils = Mockito.mockStatic(NetUtils.class)) {
+ mockedNetUtils.when(NetUtils::detectPrimaryIpAddress).thenReturn("10.0.0.5");
+
+ String result = resolveAnnouncedIpAddress("");
+
+ assertThat(result, is("10.0.0.5"));
+ }
+ }
+
+ private static String resolveAnnouncedIpAddress(String configuredInterface) {
+ Config outputDeviceConfig = ConfigFactory.parseString("""
+ interface = "%s"
+ """.formatted(configuredInterface));
+
+ return OutputDevice.resolveAnnouncedIpAddress(outputDeviceConfig, NetUtils::detectPrimaryIpAddress);
+ }
+
+ // ---------------------------------------------------------------------------
+ // Tests for the instance resolveAnnouncedIpAddress() method
+ // (reads interface from the output-device config)
+ // ---------------------------------------------------------------------------
+
+ @Test
+ @DisplayName("instance resolveAnnouncedIpAddress uses configured IPv4 address from output-device config")
+ void instanceResolveAnnouncedIpAddressUsesConfiguredIpv4Address() {
+ Config systemConfig = buildSystemConfig("192.168.1.10");
+ testKit = ActorTestKit.create(systemConfig);
+ String result = spawnAndResolve(testKit, systemConfig);
+ assertThat(result, is("192.168.1.10"));
+ }
+
+ @Test
+ @DisplayName("instance resolveAnnouncedIpAddress uses configured IPv6 address from output-device config")
+ void instanceResolveAnnouncedIpAddressUsesConfiguredIpv6Address() {
+ Config systemConfig = buildSystemConfig("2001:db8::10");
+ testKit = ActorTestKit.create(systemConfig);
+ String result = spawnAndResolve(testKit, systemConfig);
+ assertThat(result, is("2001:db8::10"));
+ }
+
+ @Test
+ @DisplayName("instance resolveAnnouncedIpAddress falls back to primary IP when interface is unspecified")
+ void instanceResolveAnnouncedIpAddressFallsBackToPrimaryIpForUnspecifiedAddress() {
+ Config systemConfig = buildSystemConfig("0.0.0.0");
+ testKit = ActorTestKit.create(systemConfig);
+ String result = spawnAndResolve(testKit, systemConfig);
+ assertThat(result, is(not(isEmptyOrNullString())));
+ }
+
+ /**
+ * Builds a minimal system config with the given output-device interface.
+ */
+ private static Config buildSystemConfig(String deviceInterface) {
+ return ConfigFactory.parseString("""
+ test-output-device {
+ interface = "%s"
+ forget-interval = 1m
+ default-voltage = 230
+ default-frequency = 50
+ default-client-power-factor = 1.0
+ power-offset-total = 0
+ power-offset-l1 = 0
+ power-offset-l2 = 0
+ power-offset-l3 = 0
+ usage-constraint-init-duration = 60s
+ }
+ """.formatted(deviceInterface)).withFallback(ConfigFactory.load());
+ }
+
+ /**
+ * Spawns a TestOutputDevice actor inside the given testKit, lets it capture
+ * its own resolveAnnouncedIpAddress() result and returns it.
+ */
+ private static String spawnAndResolve(ActorTestKit testKit, Config systemConfig) {
+ Config deviceConfig = systemConfig.getConfig("test-output-device");
+ ActorRef controllerDummy = testKit.createTestProbe(UniMeter.Command.class).ref();
+ ActorRef mdnsDummy = testKit.createTestProbe(MDnsRegistrator.Command.class).ref();
+
+ var resultProbe = testKit.createTestProbe(CaptureResult.class);
+ testKit.spawn(TestOutputDevice.create(controllerDummy, mdnsDummy, deviceConfig, resultProbe.ref()));
+
+ return resultProbe.receiveMessage().ipAddress();
+ }
+
+ /** Simple message to capture the resolved IP address out of the actor. */
+ record CaptureResult(String ipAddress) {
+ }
+
+ /** Minimal concrete OutputDevice subclass for testing. */
+ static class TestOutputDevice extends OutputDevice {
+
+ static Behavior create(ActorRef controller, ActorRef mdnsRegistrator, Config config, ActorRef replyTo) {
+ return Behaviors.setup(ctx -> new TestOutputDevice(ctx, controller, mdnsRegistrator, config, replyTo));
+ }
+
+ private TestOutputDevice(ActorContext context, ActorRef controller, ActorRef mdnsRegistrator, Config config,
+ ActorRef replyTo) {
+ super(context, controller, mdnsRegistrator, config, (logger, cfgList, map) -> {
+ });
+ // resolveAnnouncedIpAddress() is called inside the parent constructor via
+ // this.announcedIpAddress = resolveAnnouncedIpAddress(); – capture it here.
+ replyTo.tell(new CaptureResult(getAnnouncedIpAddress()));
+ }
+
+ @Override
+ protected @NotNull org.apache.pekko.http.javadsl.server.Route createRoute() {
+ return Directives.reject();
+ }
+
+ @Override
+ protected void eventPowerDataChanged() {
+ // NOOP
+ }
+
+ }
+}