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 + } + + } +}