From 09b10a8f9e62a4904b0b08294ffb8fb1e8083f95 Mon Sep 17 00:00:00 2001 From: Sebastien Vermeille Date: Sat, 20 Jan 2024 10:00:36 +0100 Subject: [PATCH] 161 signal errors via MQTT and Plugin API (example: Empty pellet container) (#162) * Add a new MQTT channel for notifications, a new extension for plugins to allow them react on rika errors That way we can react to RIKA errors in a more convenient way --- .code/docker-compose/docker-compose.yml | 5 +- README.md | 4 + .../cookiecode/rika2mqtt/bridge/Bridge.java | 16 +++ bridge/src/main/resources/application.yml | 1 + .../bridge/BridgeIntegrationTest.java | 14 +++ .../rika2mqtt/bridge/BridgeTest.java | 57 +++++++-- docs/docs/getting-started/configuration.md | 6 +- .../rika2mqtt/rika/mqtt/MqttService.java | 2 + .../rika2mqtt/rika/mqtt/MqttServiceImpl.java | 9 ++ .../configuration/MqttConfigProperties.java | 2 + .../mqtt/configuration/MqttConfiguration.java | 23 ++++ .../plugins/api/v1/StoveErrorExtension.java | 41 +++++++ .../plugins/api/v1/model/StoveError.java | 37 ++++++ .../internal/v1/Rika2MqttPluginService.java | 19 ++- .../internal/v1/event/StoveErrorEvent.java | 42 +++++++ .../internal/v1/mapper/StoveErrorMapper.java | 48 ++++++++ .../v1/Rika2MqttPluginServiceTest.java | 25 ++++ .../rika/firenet/model/StoveError.java | 41 +++++++ .../rika/firenet/model/StoveStatus.java | 19 +++ .../rika/firenet/model/StoveErrorTest.java | 59 ++++++++++ .../rika/firenet/model/StoveStatusTest.java | 108 ++++++++++++++++++ 21 files changed, 565 insertions(+), 13 deletions(-) create mode 100644 plugins-api/src/main/java/dev/cookiecode/rika2mqtt/plugins/api/v1/StoveErrorExtension.java create mode 100644 plugins-api/src/main/java/dev/cookiecode/rika2mqtt/plugins/api/v1/model/StoveError.java create mode 100644 plugins-internal/src/main/java/dev/cookiecode/rika2mqtt/plugins/internal/v1/event/StoveErrorEvent.java create mode 100644 plugins-internal/src/main/java/dev/cookiecode/rika2mqtt/plugins/internal/v1/mapper/StoveErrorMapper.java create mode 100644 rika-firenet/src/main/java/dev/cookiecode/rika2mqtt/rika/firenet/model/StoveError.java create mode 100644 rika-firenet/src/test/java/dev/cookiecode/rika2mqtt/rika/firenet/model/StoveErrorTest.java create mode 100644 rika-firenet/src/test/java/dev/cookiecode/rika2mqtt/rika/firenet/model/StoveStatusTest.java diff --git a/.code/docker-compose/docker-compose.yml b/.code/docker-compose/docker-compose.yml index b158e6d5..504ffd0f 100644 --- a/.code/docker-compose/docker-compose.yml +++ b/.code/docker-compose/docker-compose.yml @@ -26,8 +26,9 @@ services: #optional properties # - MQTT_URI_SCHEME=tcp:// # - MQTT_PORT=1883 -# - MQTT_COMMAND_TOPIC_NAME=cmnd/rika2mqtt -# - MQTT_TELEMETRY_REPORT_TOPIC_NAME=tele/rika2mqtt +# - MQTT_COMMAND_TOPIC_NAME=rika2mqtt/commands +# - MQTT_TELEMETRY_REPORT_TOPIC_NAME=rika2mqtt/telemetry +# - MQTT_NOTIFICATION_TOPIC_NAME=rika2mqtt/notifications # - PLUGINS=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar diff --git a/README.md b/README.md index 2056d1df..cb813f17 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,10 @@ Default: `cmnd/rika2mqtt` The MQTT topic used by RIKA2MQTT to publish RIKA status Default: `tele/rika2mqtt` +## MQTT_ERROR_TOPIC_NAME (Optional) +The MQTT topic used by RIKA2MQTT to publish RIKA errors +Default: `tele/rika2mqtt-errors` + ## MQTT_PORT (Optional) The port of your MQTT instance diff --git a/bridge/src/main/java/dev/cookiecode/rika2mqtt/bridge/Bridge.java b/bridge/src/main/java/dev/cookiecode/rika2mqtt/bridge/Bridge.java index f5911afc..d34591a6 100644 --- a/bridge/src/main/java/dev/cookiecode/rika2mqtt/bridge/Bridge.java +++ b/bridge/src/main/java/dev/cookiecode/rika2mqtt/bridge/Bridge.java @@ -27,6 +27,8 @@ import dev.cookiecode.rika2mqtt.bridge.misc.EmailObfuscator; import dev.cookiecode.rika2mqtt.plugins.internal.v1.Rika2MqttPluginService; import dev.cookiecode.rika2mqtt.plugins.internal.v1.event.PolledStoveStatusEvent; +import dev.cookiecode.rika2mqtt.plugins.internal.v1.event.StoveErrorEvent; +import dev.cookiecode.rika2mqtt.plugins.internal.v1.mapper.StoveErrorMapper; import dev.cookiecode.rika2mqtt.plugins.internal.v1.mapper.StoveStatusMapper; import dev.cookiecode.rika2mqtt.rika.firenet.RikaFirenetService; import dev.cookiecode.rika2mqtt.rika.firenet.exception.CouldNotAuthenticateToRikaFirenetException; @@ -81,6 +83,7 @@ public class Bridge { private final EmailObfuscator emailObfuscator; private final Gson gson; private final StoveStatusMapper stoveStatusMapper; + private final StoveErrorMapper stoveErrorMapper; private final Rika2MqttPluginService pluginManager; @@ -140,6 +143,19 @@ void publishToMqtt() { PolledStoveStatusEvent.builder() .stoveStatus(stoveStatusMapper.toApiStoveStatus(status)) .build()); + + status + .getError() + .ifPresent( + stoveError -> { + final var enrichedStoveError = + stoveErrorMapper.toApiStoveError(stoveId.id(), stoveError); + final var jsonError = gson.toJson(enrichedStoveError); + mqttService.publishNotification(jsonError); + + applicationEventPublisher.publishEvent( + StoveErrorEvent.builder().stoveError(enrichedStoveError).build()); + }); } catch (InvalidStoveIdException e) { // TODO: could occurs if a stove is added later (after deployment of this rika2mqtt // instance, might be valuable to perform a reload of stoves "periodically") -> should diff --git a/bridge/src/main/resources/application.yml b/bridge/src/main/resources/application.yml index 1fc5f47e..1d441314 100644 --- a/bridge/src/main/resources/application.yml +++ b/bridge/src/main/resources/application.yml @@ -9,6 +9,7 @@ mqtt: username: ${MQTT_USERNAME:""} password: ${MQTT_PASSWORD:""} telemetry-report-topic-name: ${MQTT_TELEMETRY_REPORT_TOPIC_NAME:tele/rika2mqtt} + notification-topic-name: ${MQTT_NOTIFICATION_TOPIC_NAME:tele/rika2mqtt-notifications} command-topic-name: ${MQTT_COMMAND_TOPIC_NAME:cmnd/rika2mqtt} bridge: reportInterval: PT60S diff --git a/bridge/src/test/java/dev/cookiecode/rika2mqtt/bridge/BridgeIntegrationTest.java b/bridge/src/test/java/dev/cookiecode/rika2mqtt/bridge/BridgeIntegrationTest.java index 6300450b..15a387db 100644 --- a/bridge/src/test/java/dev/cookiecode/rika2mqtt/bridge/BridgeIntegrationTest.java +++ b/bridge/src/test/java/dev/cookiecode/rika2mqtt/bridge/BridgeIntegrationTest.java @@ -67,6 +67,20 @@ void publishMqttMessageFromBridgeShouldEffectivelyPublishAMessageToMqtt() { () -> mqttService.publish(message)); } + @Test + void publishErrorMqttMessageFromBridgeShouldEffectivelyPublishAnErrorMessageToMqtt() { + String message = "some error"; + + // Here, another mqtt client connect to the telemetry topic + // after using the bridge mqttService to publish to mqtt, + // the MQTT test client (outside the rika2mqtt bridge) should be able to receive that message + getMqttTestClient() + .assertThatMessageWasPublishedToMqttTopic( + message, + mqttConfigProperties.getNotificationTopicName(), + () -> mqttService.publishNotification(message)); + } + private MqttTestClient getMqttTestClient() { try { var client = getRandomMqttClient(); diff --git a/bridge/src/test/java/dev/cookiecode/rika2mqtt/bridge/BridgeTest.java b/bridge/src/test/java/dev/cookiecode/rika2mqtt/bridge/BridgeTest.java index 99f0d744..3f0b0c9c 100644 --- a/bridge/src/test/java/dev/cookiecode/rika2mqtt/bridge/BridgeTest.java +++ b/bridge/src/test/java/dev/cookiecode/rika2mqtt/bridge/BridgeTest.java @@ -29,22 +29,23 @@ import static org.awaitility.Awaitility.await; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; import com.google.gson.Gson; import dev.cookiecode.rika2mqtt.bridge.misc.EmailObfuscator; import dev.cookiecode.rika2mqtt.plugins.internal.v1.Rika2MqttPluginService; +import dev.cookiecode.rika2mqtt.plugins.internal.v1.mapper.StoveErrorMapper; import dev.cookiecode.rika2mqtt.plugins.internal.v1.mapper.StoveStatusMapper; import dev.cookiecode.rika2mqtt.rika.firenet.RikaFirenetService; +import dev.cookiecode.rika2mqtt.rika.firenet.model.StoveError; import dev.cookiecode.rika2mqtt.rika.firenet.model.StoveId; +import dev.cookiecode.rika2mqtt.rika.firenet.model.StoveStatus; import dev.cookiecode.rika2mqtt.rika.mqtt.MqttService; import dev.cookiecode.rika2mqtt.rika.mqtt.event.MqttCommandEvent; import java.time.Duration; import java.util.HashMap; import java.util.List; +import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -70,6 +71,7 @@ class BridgeTest { @Mock EmailObfuscator emailObfuscator; @Mock Gson gson; @Mock StoveStatusMapper stoveStatusMapper; + @Mock StoveErrorMapper stoveErrorMapper; @Mock Rika2MqttPluginService pluginManager; @@ -82,10 +84,11 @@ void setUp() { } @Test - void initShouldInitStovesWithRetrieveStovesFromRikaFirenet() { + void initShouldInitStovesWithRetrieveStovesFromRikaFirenet() throws Exception { // GIVEN List stoveIds = List.of(StoveId.of(15L)); when(rikaFirenetService.getStoves()).thenReturn(stoveIds); + when(rikaFirenetService.getStatus(any())).thenReturn(mock(StoveStatus.class)); // WHEN bridge.init(); @@ -96,9 +99,10 @@ void initShouldInitStovesWithRetrieveStovesFromRikaFirenet() { } @Test - void initShouldInvokePrintStartupMessages() { + void initShouldInvokePrintStartupMessages() throws Exception { // GIVEN when(rikaFirenetService.getStoves()).thenReturn(List.of(StoveId.of(15L))); + when(rikaFirenetService.getStatus(any())).thenReturn(mock(StoveStatus.class)); // WHEN bridge.init(); @@ -168,10 +172,11 @@ void printStartupMessagesShouldPrintLogMessageWhenStovesAreLinkedToTheAccount( } @Test - void publishToMqttShouldInvokeMqttServicePublishForEachStove() { + void publishToMqttShouldInvokeMqttServicePublishForEachStove() throws Exception { // GIVEN final var stoves = List.of(StoveId.of(1L), StoveId.of(2L), StoveId.of(3L)); bridge.initStoves(stoves); + when(rikaFirenetService.getStatus(any())).thenReturn(mock(StoveStatus.class)); // WHEN bridge.publishToMqtt(); @@ -188,6 +193,7 @@ void publishToMqttShouldInvokeRikaFirenetServiceGetStatusForEachStove() throws E final var thirdStove = StoveId.of(3L); final var stoves = List.of(firstStove, secondStove, thirdStove); bridge.initStoves(stoves); + when(rikaFirenetService.getStatus(any())).thenReturn(mock(StoveStatus.class)); // WHEN bridge.publishToMqtt(); @@ -198,6 +204,43 @@ void publishToMqttShouldInvokeRikaFirenetServiceGetStatusForEachStove() throws E verify(rikaFirenetService).getStatus(thirdStove); } + @Test + void publishToMqttShouldInvokeMqttServicePublishErrorForEachStoveHavingAnError() + throws Exception { + // GIVEN + final var stoves = List.of(StoveId.of(1L), StoveId.of(2L), StoveId.of(3L)); + bridge.initStoves(stoves); + final var stoveStatus = mock(StoveStatus.class); + when(stoveStatus.getError()) + .thenReturn(Optional.of(StoveError.builder().statusError(1).statusSubError(12).build())); + + when(rikaFirenetService.getStatus(any())).thenReturn(stoveStatus); + + // WHEN + bridge.publishToMqtt(); + + // THEN + verify(mqttService, times(stoves.size())).publishNotification(any()); + } + + @Test + void publishToMqttShouldNotInvokeMqttServicePublishErrorForEachStoveHavingNoError() + throws Exception { + // GIVEN + final var stoves = List.of(StoveId.of(1L), StoveId.of(2L), StoveId.of(3L)); + bridge.initStoves(stoves); + final var stoveStatus = mock(StoveStatus.class); + when(stoveStatus.getError()).thenReturn(Optional.empty()); + + when(rikaFirenetService.getStatus(any())).thenReturn(stoveStatus); + + // WHEN + bridge.publishToMqtt(); + + // THEN + verify(mqttService, never()).publishNotification(any()); + } + @Test void onReceiveMqttCommandShouldInvokeUpdateControls() throws Exception { diff --git a/docs/docs/getting-started/configuration.md b/docs/docs/getting-started/configuration.md index 334f4672..5cdde4e9 100644 --- a/docs/docs/getting-started/configuration.md +++ b/docs/docs/getting-started/configuration.md @@ -31,6 +31,10 @@ Default: `cmnd/rika2mqtt` The MQTT topic used by RIKA2MQTT to publish RIKA status Default: `tele/rika2mqtt` +## MQTT_NOTIFICATION_TOPIC_NAME (Optional) +The MQTT topic used by RIKA2MQTT to publish RIKA errors, warnings (i.e. Empty pellet container) +Default: `tele/rika2mqtt-notifications` + ## MQTT_URI_SCHEME (Optional) The uri scheme to be used with MQTT_HOST (i.e: `tcp://`, `ssl://`) -Default: `tcp://` \ No newline at end of file +Default: `tcp://` diff --git a/mqtt/src/main/java/dev/cookiecode/rika2mqtt/rika/mqtt/MqttService.java b/mqtt/src/main/java/dev/cookiecode/rika2mqtt/rika/mqtt/MqttService.java index 8510d076..6d9c6a71 100644 --- a/mqtt/src/main/java/dev/cookiecode/rika2mqtt/rika/mqtt/MqttService.java +++ b/mqtt/src/main/java/dev/cookiecode/rika2mqtt/rika/mqtt/MqttService.java @@ -28,4 +28,6 @@ public interface MqttService { void publish(String message); + + void publishNotification(String message); } diff --git a/mqtt/src/main/java/dev/cookiecode/rika2mqtt/rika/mqtt/MqttServiceImpl.java b/mqtt/src/main/java/dev/cookiecode/rika2mqtt/rika/mqtt/MqttServiceImpl.java index 080443a2..cc652105 100644 --- a/mqtt/src/main/java/dev/cookiecode/rika2mqtt/rika/mqtt/MqttServiceImpl.java +++ b/mqtt/src/main/java/dev/cookiecode/rika2mqtt/rika/mqtt/MqttServiceImpl.java @@ -39,9 +39,18 @@ public class MqttServiceImpl implements MqttService { @Qualifier("mqttConfiguration.MqttGateway") private final MqttConfiguration.MqttGateway mqttGateway; + @Qualifier("mqttConfiguration.MqttNotificationGateway") + private final MqttConfiguration.MqttGateway mqttNotificationGateway; + @Override public void publish(final String message) { log.atInfo().log("Publish to mqtt:\n%s", message); mqttGateway.sendToMqtt(message); } + + @Override + public void publishNotification(String message) { + log.atInfo().log("Publish error to mqtt:\n%s", message); + mqttNotificationGateway.sendToMqtt(message); + } } diff --git a/mqtt/src/main/java/dev/cookiecode/rika2mqtt/rika/mqtt/configuration/MqttConfigProperties.java b/mqtt/src/main/java/dev/cookiecode/rika2mqtt/rika/mqtt/configuration/MqttConfigProperties.java index 87795c45..f192e07f 100644 --- a/mqtt/src/main/java/dev/cookiecode/rika2mqtt/rika/mqtt/configuration/MqttConfigProperties.java +++ b/mqtt/src/main/java/dev/cookiecode/rika2mqtt/rika/mqtt/configuration/MqttConfigProperties.java @@ -52,5 +52,7 @@ public class MqttConfigProperties { private String telemetryReportTopicName = "tele/rika2mqtt"; + private String notificationTopicName = "tele/rika2mqtt-notifications"; + private String commandTopicName = "cmnd/rika2mqtt"; } diff --git a/mqtt/src/main/java/dev/cookiecode/rika2mqtt/rika/mqtt/configuration/MqttConfiguration.java b/mqtt/src/main/java/dev/cookiecode/rika2mqtt/rika/mqtt/configuration/MqttConfiguration.java index 89fad3fa..22fd6a4a 100644 --- a/mqtt/src/main/java/dev/cookiecode/rika2mqtt/rika/mqtt/configuration/MqttConfiguration.java +++ b/mqtt/src/main/java/dev/cookiecode/rika2mqtt/rika/mqtt/configuration/MqttConfiguration.java @@ -90,6 +90,16 @@ public MessageHandler mqttOutbound() { return messageHandler; } + @Bean + @ServiceActivator(inputChannel = "mqttOutboundNotificationChannel", autoStartup = "true") + public MessageHandler mqttOutboundNotification() { + var messageHandler = + new MqttPahoMessageHandler(mqttConfigProperties.getClientName(), mqttClientFactory()); + messageHandler.setAsync(true); + messageHandler.setDefaultTopic(mqttConfigProperties.getNotificationTopicName()); + return messageHandler; + } + /** * @implNote this is using a workaround found here the doc simply mention to do: `return @@ -102,12 +112,25 @@ public MessageChannel mqttOutboundChannel() { return dc; } + @Bean + public MessageChannel mqttOutboundNotificationChannel() { + var dc = new DirectChannel(); + dc.subscribe(mqttOutboundNotification()); + return dc; + } + @MessagingGateway(defaultRequestChannel = "mqttOutboundChannel") public interface MqttGateway { void sendToMqtt(String data); } + @MessagingGateway(defaultRequestChannel = "mqttOutboundNotificationChannel") + public interface MqttNotificationGateway extends MqttGateway { + + void sendToMqtt(String data); + } + @Bean public MessageChannel mqttInputChannel() { return new DirectChannel(); diff --git a/plugins-api/src/main/java/dev/cookiecode/rika2mqtt/plugins/api/v1/StoveErrorExtension.java b/plugins-api/src/main/java/dev/cookiecode/rika2mqtt/plugins/api/v1/StoveErrorExtension.java new file mode 100644 index 00000000..08fd1a85 --- /dev/null +++ b/plugins-api/src/main/java/dev/cookiecode/rika2mqtt/plugins/api/v1/StoveErrorExtension.java @@ -0,0 +1,41 @@ +/* + * The MIT License + * Copyright © 2022 Sebastien Vermeille + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package dev.cookiecode.rika2mqtt.plugins.api.v1; + +import dev.cookiecode.rika2mqtt.plugins.api.Beta; +import dev.cookiecode.rika2mqtt.plugins.api.v1.model.StoveError; +import org.pf4j.ExtensionPoint; + +/** + * @author Sebastien Vermeille + */ +@Beta +public interface StoveErrorExtension extends ExtensionPoint { + + /** + * When an error is displayed on the RIKA Stove screen, it is also triggered here + * + * @param stoveError the error triggered + */ + void onStoveError(StoveError stoveError); +} diff --git a/plugins-api/src/main/java/dev/cookiecode/rika2mqtt/plugins/api/v1/model/StoveError.java b/plugins-api/src/main/java/dev/cookiecode/rika2mqtt/plugins/api/v1/model/StoveError.java new file mode 100644 index 00000000..a51a4e4d --- /dev/null +++ b/plugins-api/src/main/java/dev/cookiecode/rika2mqtt/plugins/api/v1/model/StoveError.java @@ -0,0 +1,37 @@ +/* + * The MIT License + * Copyright © 2022 Sebastien Vermeille + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package dev.cookiecode.rika2mqtt.plugins.api.v1.model; + +import dev.cookiecode.rika2mqtt.plugins.api.Beta; +import lombok.Data; + +/** + * @author Sebastien Vermeille + */ +@Data +@Beta +public class StoveError { + + private StoveId stoveId; + private String errorCode; +} diff --git a/plugins-internal/src/main/java/dev/cookiecode/rika2mqtt/plugins/internal/v1/Rika2MqttPluginService.java b/plugins-internal/src/main/java/dev/cookiecode/rika2mqtt/plugins/internal/v1/Rika2MqttPluginService.java index ee1ac83d..d3e63a8d 100644 --- a/plugins-internal/src/main/java/dev/cookiecode/rika2mqtt/plugins/internal/v1/Rika2MqttPluginService.java +++ b/plugins-internal/src/main/java/dev/cookiecode/rika2mqtt/plugins/internal/v1/Rika2MqttPluginService.java @@ -22,8 +22,10 @@ */ package dev.cookiecode.rika2mqtt.plugins.internal.v1; +import dev.cookiecode.rika2mqtt.plugins.api.v1.StoveErrorExtension; import dev.cookiecode.rika2mqtt.plugins.api.v1.StoveStatusExtension; import dev.cookiecode.rika2mqtt.plugins.internal.v1.event.PolledStoveStatusEvent; +import dev.cookiecode.rika2mqtt.plugins.internal.v1.event.StoveErrorEvent; import lombok.RequiredArgsConstructor; import lombok.extern.flogger.Flogger; import org.pf4j.PluginManager; @@ -61,9 +63,20 @@ public void handlePolledStoveStatusEvent(PolledStoveStatusEvent event) { "None of the %s plugin(s) registered a hook for extension %s, not forwarding stove status.", pluginManager.getPlugins().size(), StoveStatusExtension.class.getSimpleName()); } else { - extensions.forEach( - stoveStatusExtension -> - stoveStatusExtension.onPollStoveStatusSucceed(event.getStoveStatus())); + extensions.forEach(extension -> extension.onPollStoveStatusSucceed(event.getStoveStatus())); + } + } + + @EventListener + public void handleStoveErrorEvent(StoveErrorEvent event) { + var extensions = pluginManager.getExtensions(StoveErrorExtension.class); + + if (extensions.isEmpty()) { + log.atFinest().log( + "None of the %s plugin(s) registered a hook for extension %s, not forwarding stove error.", + pluginManager.getPlugins().size(), StoveErrorExtension.class.getSimpleName()); + } else { + extensions.forEach(extension -> extension.onStoveError(event.getStoveError())); } } } diff --git a/plugins-internal/src/main/java/dev/cookiecode/rika2mqtt/plugins/internal/v1/event/StoveErrorEvent.java b/plugins-internal/src/main/java/dev/cookiecode/rika2mqtt/plugins/internal/v1/event/StoveErrorEvent.java new file mode 100644 index 00000000..e3731fbd --- /dev/null +++ b/plugins-internal/src/main/java/dev/cookiecode/rika2mqtt/plugins/internal/v1/event/StoveErrorEvent.java @@ -0,0 +1,42 @@ +/* + * The MIT License + * Copyright © 2022 Sebastien Vermeille + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package dev.cookiecode.rika2mqtt.plugins.internal.v1.event; + +import dev.cookiecode.rika2mqtt.plugins.api.Beta; +import dev.cookiecode.rika2mqtt.plugins.api.v1.model.StoveError; +import lombok.*; + +/** + * Triggered when RIKA stove is printing an error + * + * @author Sebastien Vermeille + */ +@RequiredArgsConstructor +@Getter +@ToString +@EqualsAndHashCode +@Builder +@Beta +public class StoveErrorEvent implements Rika2MqttPluginEvent { + private final StoveError stoveError; +} diff --git a/plugins-internal/src/main/java/dev/cookiecode/rika2mqtt/plugins/internal/v1/mapper/StoveErrorMapper.java b/plugins-internal/src/main/java/dev/cookiecode/rika2mqtt/plugins/internal/v1/mapper/StoveErrorMapper.java new file mode 100644 index 00000000..1f7cdc7b --- /dev/null +++ b/plugins-internal/src/main/java/dev/cookiecode/rika2mqtt/plugins/internal/v1/mapper/StoveErrorMapper.java @@ -0,0 +1,48 @@ +/* + * The MIT License + * Copyright © 2022 Sebastien Vermeille + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package dev.cookiecode.rika2mqtt.plugins.internal.v1.mapper; + +import dev.cookiecode.rika2mqtt.plugins.api.Beta; +import dev.cookiecode.rika2mqtt.plugins.api.v1.model.StoveError; +import lombok.NonNull; +import org.mapstruct.Mapper; +import org.mapstruct.ReportingPolicy; + +/** + * @author Sebastien Vermeille + */ +@Beta +@Mapper( + unmappedTargetPolicy = ReportingPolicy.IGNORE, + uses = {StoveIdMapper.class}) // ignore as we are using a map +public interface StoveErrorMapper { + + default StoveError toApiStoveError( + @NonNull Long stoveId, + @NonNull dev.cookiecode.rika2mqtt.rika.firenet.model.StoveError stoveError) { + final var error = new StoveError(); + error.setStoveId(new StoveIdMapperImpl().map(stoveId)); + error.setErrorCode(stoveError.toString()); + return error; + } +} diff --git a/plugins-internal/src/test/java/dev/cookiecode/rika2mqtt/plugins/internal/v1/Rika2MqttPluginServiceTest.java b/plugins-internal/src/test/java/dev/cookiecode/rika2mqtt/plugins/internal/v1/Rika2MqttPluginServiceTest.java index 01ce7bef..440d7849 100644 --- a/plugins-internal/src/test/java/dev/cookiecode/rika2mqtt/plugins/internal/v1/Rika2MqttPluginServiceTest.java +++ b/plugins-internal/src/test/java/dev/cookiecode/rika2mqtt/plugins/internal/v1/Rika2MqttPluginServiceTest.java @@ -24,9 +24,12 @@ import static org.mockito.Mockito.*; +import dev.cookiecode.rika2mqtt.plugins.api.v1.StoveErrorExtension; import dev.cookiecode.rika2mqtt.plugins.api.v1.StoveStatusExtension; +import dev.cookiecode.rika2mqtt.plugins.api.v1.model.StoveError; import dev.cookiecode.rika2mqtt.plugins.api.v1.model.StoveStatus; import dev.cookiecode.rika2mqtt.plugins.internal.v1.event.PolledStoveStatusEvent; +import dev.cookiecode.rika2mqtt.plugins.internal.v1.event.StoveErrorEvent; import java.util.List; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -109,4 +112,26 @@ void startShouldInvokeStartPlugins() { verify(extensionAlpha, times(1)).onPollStoveStatusSucceed(stoveStatus); verify(extensionBeta, times(1)).onPollStoveStatusSucceed(stoveStatus); } + + @Test + void handleStoveErrorEventShouldPropagateTheEventToAllRegisteredExtensionsGivenThereAreTwo() { + + // GIVEN + final var event = mock(StoveErrorEvent.class); + final var stoveError = mock(StoveError.class); + when(event.getStoveError()).thenReturn(stoveError); + + // two plugins extensions + final var extensionAlpha = mock(StoveErrorExtension.class); + final var extensionBeta = mock(StoveErrorExtension.class); + final var extensions = List.of(extensionAlpha, extensionBeta); + when(pluginManager.getExtensions(StoveErrorExtension.class)).thenReturn(extensions); + + // WHEN + rika2MqttPluginService.handleStoveErrorEvent(event); + + // THEN + verify(extensionAlpha, times(1)).onStoveError(stoveError); + verify(extensionBeta, times(1)).onStoveError(stoveError); + } } diff --git a/rika-firenet/src/main/java/dev/cookiecode/rika2mqtt/rika/firenet/model/StoveError.java b/rika-firenet/src/main/java/dev/cookiecode/rika2mqtt/rika/firenet/model/StoveError.java new file mode 100644 index 00000000..32e484a0 --- /dev/null +++ b/rika-firenet/src/main/java/dev/cookiecode/rika2mqtt/rika/firenet/model/StoveError.java @@ -0,0 +1,41 @@ +/* + * The MIT License + * Copyright © 2022 Sebastien Vermeille + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package dev.cookiecode.rika2mqtt.rika.firenet.model; + +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; + +@Getter +@Builder +@EqualsAndHashCode +public class StoveError { + + private final int statusError; + private final int statusSubError; + + @Override + public String toString() { + return String.format("E%04d.%02d", statusError, statusSubError); + } +} diff --git a/rika-firenet/src/main/java/dev/cookiecode/rika2mqtt/rika/firenet/model/StoveStatus.java b/rika-firenet/src/main/java/dev/cookiecode/rika2mqtt/rika/firenet/model/StoveStatus.java index 6bb8bd09..9f077e45 100644 --- a/rika-firenet/src/main/java/dev/cookiecode/rika2mqtt/rika/firenet/model/StoveStatus.java +++ b/rika-firenet/src/main/java/dev/cookiecode/rika2mqtt/rika/firenet/model/StoveStatus.java @@ -23,6 +23,7 @@ package dev.cookiecode.rika2mqtt.rika.firenet.model; import com.google.gson.annotations.SerializedName; +import java.util.Optional; import lombok.Builder; import lombok.Data; @@ -32,6 +33,7 @@ @Data @Builder public class StoveStatus { + static final int RIKA_NO_ERROR_VALUE = 0; private String name; @@ -46,4 +48,21 @@ public class StoveStatus { private String stoveType; private Sensors sensors; private Controls controls; + + public Optional getError() { + final var statusError = + Optional.ofNullable(sensors) + .map(Sensors::getStatusError) + .filter(value -> value > RIKA_NO_ERROR_VALUE); + final var statusSubError = + Optional.ofNullable(sensors) + .map(Sensors::getStatusSubError) + .filter(value -> value > RIKA_NO_ERROR_VALUE); + return statusError.map( + integer -> + StoveError.builder() + .statusError(integer) + .statusSubError(statusSubError.orElse(RIKA_NO_ERROR_VALUE)) + .build()); + } } diff --git a/rika-firenet/src/test/java/dev/cookiecode/rika2mqtt/rika/firenet/model/StoveErrorTest.java b/rika-firenet/src/test/java/dev/cookiecode/rika2mqtt/rika/firenet/model/StoveErrorTest.java new file mode 100644 index 00000000..771cb0a3 --- /dev/null +++ b/rika-firenet/src/test/java/dev/cookiecode/rika2mqtt/rika/firenet/model/StoveErrorTest.java @@ -0,0 +1,59 @@ +/* + * The MIT License + * Copyright © 2022 Sebastien Vermeille + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package dev.cookiecode.rika2mqtt.rika.firenet.model; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +/** + * Test class + * + * @author Sebastien Vermeille + */ +class StoveErrorTest { + + @Test + void toStringShouldPrependDigitsInFrontOfSmallErrorCodes() { + // GIVEN + final var error = StoveError.builder().statusError(1).statusSubError(2).build(); + + // WHEN + final var result = error.toString(); + + // THEN + assertThat(result).isEqualTo("E0001.02"); + } + + @Test + void toStringShouldReplacePrependingZeroWhenHavingBigErrorCodes() { + // GIVEN + final var error = StoveError.builder().statusError(1000).statusSubError(23).build(); + + // WHEN + final var result = error.toString(); + + // THEN + assertThat(result).isEqualTo("E1000.23"); + } +} diff --git a/rika-firenet/src/test/java/dev/cookiecode/rika2mqtt/rika/firenet/model/StoveStatusTest.java b/rika-firenet/src/test/java/dev/cookiecode/rika2mqtt/rika/firenet/model/StoveStatusTest.java new file mode 100644 index 00000000..6ee3f1f5 --- /dev/null +++ b/rika-firenet/src/test/java/dev/cookiecode/rika2mqtt/rika/firenet/model/StoveStatusTest.java @@ -0,0 +1,108 @@ +/* + * The MIT License + * Copyright © 2022 Sebastien Vermeille + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package dev.cookiecode.rika2mqtt.rika.firenet.model; + +import static dev.cookiecode.rika2mqtt.rika.firenet.model.StoveStatus.RIKA_NO_ERROR_VALUE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +/** + * Test class + * + * @author Sebastien Vermeille + */ +@ExtendWith(MockitoExtension.class) +class StoveStatusTest { + + @Mock Controls controls; + + @Mock Sensors sensors; + + private StoveStatus stoveStatus; + + @BeforeEach + void setUp() { + stoveStatus = StoveStatus.builder().controls(controls).sensors(sensors).build(); + } + + @Test + void getErrorShouldReturnAnErrorGivenThereIsOne() { + + // GIVEN + final var statusError = 42; + final var statusSubError = 23; + when(sensors.getStatusError()).thenReturn(statusError); + when(sensors.getStatusSubError()).thenReturn(statusSubError); + + // WHEN + final var error = stoveStatus.getError(); + + // THEN + final var expectedError = + StoveError.builder().statusError(statusError).statusSubError(statusSubError).build(); + + assertThat(error).isPresent().isEqualTo(Optional.of(expectedError)); + } + + @Test + void getErrorShouldReturnAnErrorGivenThereIsOnlyErrorNoSubError() { + + // GIVEN + final var statusError = 42; + final var statusSubError = RIKA_NO_ERROR_VALUE; + when(sensors.getStatusError()).thenReturn(statusError); + when(sensors.getStatusSubError()).thenReturn(statusSubError); + + // WHEN + final var error = stoveStatus.getError(); + + // THEN + final var expectedError = + StoveError.builder().statusError(statusError).statusSubError(statusSubError).build(); + + assertThat(error).isPresent().isEqualTo(Optional.of(expectedError)); + } + + @Test + void getErrorShouldReturnNoErrorGivenThereIsNoMainError() { + + // GIVEN + final var statusError = RIKA_NO_ERROR_VALUE; + final var statusSubError = 42; + when(sensors.getStatusError()).thenReturn(statusError); + when(sensors.getStatusSubError()).thenReturn(statusSubError); + + // WHEN + final var error = stoveStatus.getError(); + + // THEN + assertThat(error).isEmpty(); + } +}