diff --git a/notification_service/src/main/java/ch/hesso/pi/notification/model/HighCo2Room.java b/notification_service/src/main/java/ch/hesso/pi/notification/model/HighCo2Room.java new file mode 100644 index 0000000..76da903 --- /dev/null +++ b/notification_service/src/main/java/ch/hesso/pi/notification/model/HighCo2Room.java @@ -0,0 +1,9 @@ +package ch.hesso.pi.notification.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record HighCo2Room( + String room, + @JsonProperty("is_high") boolean isHigh, + int co2 +) {} diff --git a/notification_service/src/main/java/ch/hesso/pi/notification/model/SensorReading.java b/notification_service/src/main/java/ch/hesso/pi/notification/model/SensorReading.java deleted file mode 100644 index b035de3..0000000 --- a/notification_service/src/main/java/ch/hesso/pi/notification/model/SensorReading.java +++ /dev/null @@ -1,13 +0,0 @@ -package ch.hesso.pi.notification.model; - -import java.time.Instant; - -public record SensorReading( - String roomId, - String roomName, - int co2, - double temperature, - int humidity, - String windowState, - Instant timestamp -) {} diff --git a/notification_service/src/main/java/ch/hesso/pi/notification/scheduler/AirQualityScheduler.java b/notification_service/src/main/java/ch/hesso/pi/notification/scheduler/AirQualityScheduler.java index 5693052..380853a 100644 --- a/notification_service/src/main/java/ch/hesso/pi/notification/scheduler/AirQualityScheduler.java +++ b/notification_service/src/main/java/ch/hesso/pi/notification/scheduler/AirQualityScheduler.java @@ -2,7 +2,7 @@ package ch.hesso.pi.notification.scheduler; import ch.hesso.pi.notification.config.NotificationProperties; import ch.hesso.pi.notification.model.Co2Level; -import ch.hesso.pi.notification.model.SensorReading; +import ch.hesso.pi.notification.model.HighCo2Room; import ch.hesso.pi.notification.service.AirQualityService; import ch.hesso.pi.notification.service.TelegramNotificationService; import org.slf4j.Logger; @@ -10,7 +10,6 @@ import org.slf4j.LoggerFactory; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; -import java.time.Instant; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -20,17 +19,16 @@ public class AirQualityScheduler { private static final Logger log = LoggerFactory.getLogger(AirQualityScheduler.class); - private static final List MOCK_READINGS = List.of( - new SensorReading("A1", "Salle A1", 2150, 23.5, 55, "closed", Instant.now()), - new SensorReading("A2", "Salle A2", 1520, 22.0, 48, "open", Instant.now()), - new SensorReading("B1", "Salle B1", 780, 21.0, 42, "closed", Instant.now()) + private static final List MOCK_ROOMS = List.of( + new HighCo2Room("A1", true, 2150), + new HighCo2Room("A2", true, 1520) ); private final AirQualityService airQualityService; private final TelegramNotificationService telegramService; private final NotificationProperties props; - // last alerted level per room — null means no alert is active for that room + // last alerted level per room — cleared on recovery private final Map lastAlertedLevel = new ConcurrentHashMap<>(); public AirQualityScheduler(AirQualityService airQualityService, @@ -45,32 +43,37 @@ public class AirQualityScheduler { initialDelayString = "5000") public void checkAirQuality() { boolean mockMode = props.getAirQuality().isMockMode(); - log.debug("Polling air quality data… (mock={})", mockMode); + log.debug("Polling high-CO₂ rooms… (mock={})", mockMode); - List readings = mockMode - ? MOCK_READINGS - : airQualityService.fetchLatestReadings(); + List rooms = mockMode + ? MOCK_ROOMS + : airQualityService.fetchHighCo2Rooms(); - if (readings.isEmpty()) { - log.warn("No readings returned — skipping this cycle"); + // rooms not in the response have recovered — clear their deduplication state + if (!mockMode) { + var activeRooms = rooms.stream().map(HighCo2Room::room).collect(java.util.stream.Collectors.toSet()); + lastAlertedLevel.keySet().removeIf(roomId -> { + if (!activeRooms.contains(roomId)) { + log.info("Room {} recovered — resetting alert state", roomId); + return true; + } + return false; + }); + } + + if (rooms.isEmpty()) { + log.debug("No high-CO₂ rooms — skipping this cycle"); return; } - for (SensorReading reading : readings) { - Co2Level level = airQualityService.resolveLevel(reading.co2()); - - if (airQualityService.isAlertable(level)) { - Co2Level previous = lastAlertedLevel.get(reading.roomId()); - if (level != previous) { - log.info("Alert level changed: room={} {} -> {}", reading.roomId(), previous, level); - telegramService.sendAlert(reading, level); - lastAlertedLevel.put(reading.roomId(), level); - } - } else { - // room recovered — reset so next alert triggers a new notification - if (lastAlertedLevel.remove(reading.roomId()) != null) { - log.info("Room {} recovered to {}", reading.roomId(), level); - } + for (HighCo2Room room : rooms) { + Co2Level level = airQualityService.resolveLevel(room.co2()); + Co2Level previous = lastAlertedLevel.get(room.room()); + if (level != previous) { + log.info("Alert level changed: room={} {} -> {} ({} ppm)", + room.room(), previous, level, room.co2()); + telegramService.sendAlert(room, level); + lastAlertedLevel.put(room.room(), level); } } } diff --git a/notification_service/src/main/java/ch/hesso/pi/notification/service/AirQualityService.java b/notification_service/src/main/java/ch/hesso/pi/notification/service/AirQualityService.java index ee9c499..cca0686 100644 --- a/notification_service/src/main/java/ch/hesso/pi/notification/service/AirQualityService.java +++ b/notification_service/src/main/java/ch/hesso/pi/notification/service/AirQualityService.java @@ -2,7 +2,7 @@ package ch.hesso.pi.notification.service; import ch.hesso.pi.notification.config.NotificationProperties; import ch.hesso.pi.notification.model.Co2Level; -import ch.hesso.pi.notification.model.SensorReading; +import ch.hesso.pi.notification.model.HighCo2Room; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.core.ParameterizedTypeReference; @@ -30,11 +30,11 @@ public class AirQualityService { } /** - * Fetches the latest sensor reading for every room from the backend API. + * Fetches rooms whose CO₂ exceeds 1400 ppm from GET /api/v1/rooms/high-co2. * Returns an empty list on error so the scheduler can continue safely. */ - public List fetchLatestReadings() { - String url = props.getAirQuality().getApiUrl() + "/sensors/latest"; + public List fetchHighCo2Rooms() { + String url = props.getAirQuality().getApiUrl() + "/api/v1/rooms/high-co2"; String username = props.getAirQuality().getApiUsername(); String password = props.getAirQuality().getApiPassword(); @@ -42,42 +42,27 @@ public class AirQualityService { var request = restClient.get().uri(url); if (StringUtils.hasText(username) && StringUtils.hasText(password)) { - String encoded = Base64.getEncoder().encodeToString((username + ":" + password).getBytes(StandardCharsets.UTF_8)); + String encoded = Base64.getEncoder() + .encodeToString((username + ":" + password).getBytes(StandardCharsets.UTF_8)); request = request.header(HttpHeaders.AUTHORIZATION, "Basic " + encoded); } - List readings = request + List rooms = request .retrieve() .body(new ParameterizedTypeReference<>() {}); - return readings != null ? readings : List.of(); + return rooms != null ? rooms : List.of(); } catch (RestClientException e) { - log.error("Failed to fetch latest readings from {}: {}", url, e.getMessage()); + log.error("Failed to fetch high-CO₂ rooms from {}: {}", url, e.getMessage()); return List.of(); } } - /** Resolves the CO₂ level enum from a raw ppm value using configured thresholds. */ public Co2Level resolveLevel(int ppm) { NotificationProperties.Thresholds t = props.getAirQuality().getThresholds(); - if (ppm < 800) return Co2Level.EXCELLENT; + if (ppm < 800) return Co2Level.EXCELLENT; if (ppm < t.getModerate()) return Co2Level.GOOD; if (ppm < t.getPoor()) return Co2Level.MODERATE; if (ppm < t.getCritical()) return Co2Level.POOR; return Co2Level.CRITICAL; } - - /** Returns true if the given level meets or exceeds the configured alert threshold. */ - public boolean isAlertable(Co2Level level) { - Co2Level floor = parseAlertFromLevel(props.getAirQuality().getAlertFromLevel()); - return level.ordinal() >= floor.ordinal(); - } - - private Co2Level parseAlertFromLevel(String value) { - try { - return Co2Level.valueOf(value.toUpperCase()); - } catch (IllegalArgumentException e) { - log.warn("Unknown alert-from-level '{}', defaulting to POOR", value); - return Co2Level.POOR; - } - } } diff --git a/notification_service/src/main/java/ch/hesso/pi/notification/service/TelegramNotificationService.java b/notification_service/src/main/java/ch/hesso/pi/notification/service/TelegramNotificationService.java index 7bc258e..1b54a46 100644 --- a/notification_service/src/main/java/ch/hesso/pi/notification/service/TelegramNotificationService.java +++ b/notification_service/src/main/java/ch/hesso/pi/notification/service/TelegramNotificationService.java @@ -2,7 +2,7 @@ package ch.hesso.pi.notification.service; import ch.hesso.pi.notification.config.NotificationProperties; import ch.hesso.pi.notification.model.Co2Level; -import ch.hesso.pi.notification.model.SensorReading; +import ch.hesso.pi.notification.model.HighCo2Room; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.MediaType; @@ -11,16 +11,12 @@ import org.springframework.util.StringUtils; import org.springframework.web.client.RestClient; import org.springframework.web.client.RestClientException; -import java.time.ZoneId; -import java.time.format.DateTimeFormatter; import java.util.Map; @Service public class TelegramNotificationService { private static final Logger log = LoggerFactory.getLogger(TelegramNotificationService.class); - private static final DateTimeFormatter TIME_FMT = - DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneId.systemDefault()); private final RestClient restClient; private final NotificationProperties props; @@ -30,17 +26,17 @@ public class TelegramNotificationService { this.props = props; } - public void sendAlert(SensorReading reading, Co2Level level) { + public void sendAlert(HighCo2Room room, Co2Level level) { String token = props.getTelegram().getBotToken(); String chatId = props.getTelegram().getChatId(); if (!StringUtils.hasText(token) || !StringUtils.hasText(chatId)) { - log.warn("Telegram credentials not configured — skipping alert for room {}", reading.roomId()); + log.warn("Telegram credentials not configured — skipping alert for room {}", room.room()); return; } String url = "https://api.telegram.org/bot" + token + "/sendMessage"; - String text = buildMessage(reading, level); + String text = buildMessage(room, level); try { restClient.post() @@ -50,32 +46,20 @@ public class TelegramNotificationService { .retrieve() .toBodilessEntity(); - log.info("Telegram alert sent for room {} — {} ({} ppm)", reading.roomId(), level.getLabel(), reading.co2()); + log.info("Telegram alert sent for room {} — {} ({} ppm)", room.room(), level.getLabel(), room.co2()); } catch (RestClientException e) { - log.error("Failed to send Telegram alert for room {}: {}", reading.roomId(), e.getMessage()); + log.error("Failed to send Telegram alert for room {}: {}", room.room(), e.getMessage()); } } - private String buildMessage(SensorReading reading, Co2Level level) { - String timestamp = reading.timestamp() != null - ? TIME_FMT.format(reading.timestamp()) - : "—"; - + private String buildMessage(HighCo2Room room, Co2Level level) { return String.format( "%s — Air Quality Alert\n\n" + "Room: %s\n" + - "CO₂: %d ppm\n" + - "Temperature: %.1f °C\n" + - "Humidity: %d %%\n" + - "Windows: %s\n" + - "Time: %s", + "CO₂: %d ppm", level.getLabel(), - reading.roomName(), - reading.co2(), - reading.temperature(), - reading.humidity(), - reading.windowState(), - timestamp + room.room(), + room.co2() ); } } diff --git a/notification_service/src/test/java/ch/hesso/pi/notification/service/AirQualityServiceTest.java b/notification_service/src/test/java/ch/hesso/pi/notification/service/AirQualityServiceTest.java index 6472fac..5ddb5db 100644 --- a/notification_service/src/test/java/ch/hesso/pi/notification/service/AirQualityServiceTest.java +++ b/notification_service/src/test/java/ch/hesso/pi/notification/service/AirQualityServiceTest.java @@ -2,7 +2,7 @@ package ch.hesso.pi.notification.service; import ch.hesso.pi.notification.config.NotificationProperties; import ch.hesso.pi.notification.model.Co2Level; -import ch.hesso.pi.notification.model.SensorReading; +import ch.hesso.pi.notification.model.HighCo2Room; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -14,7 +14,6 @@ import org.springframework.core.ParameterizedTypeReference; import org.springframework.web.client.RestClient; import org.springframework.web.client.RestClientException; -import java.time.Instant; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -38,7 +37,7 @@ class AirQualityServiceTest { service = new AirQualityService(restClient, props); } - // ── resolveLevel ───────────────────────────────────────────────────────── + // ── resolveLevel ────────────────────────────────────────────────────────── @ParameterizedTest(name = "{0} ppm -> {1}") @CsvSource({ @@ -57,70 +56,61 @@ class AirQualityServiceTest { assertThat(service.resolveLevel(ppm)).isEqualTo(expected); } - // ── isAlertable ─────────────────────────────────────────────────────────── - - @Test - void isAlertable_defaultThresholdIsPoor() { - assertThat(service.isAlertable(Co2Level.EXCELLENT)).isFalse(); - assertThat(service.isAlertable(Co2Level.GOOD)).isFalse(); - assertThat(service.isAlertable(Co2Level.MODERATE)).isFalse(); - assertThat(service.isAlertable(Co2Level.POOR)).isTrue(); - assertThat(service.isAlertable(Co2Level.CRITICAL)).isTrue(); - } - - @Test - void isAlertable_respectsConfiguredLevel() { - props.getAirQuality().setAlertFromLevel("moderate"); - - assertThat(service.isAlertable(Co2Level.GOOD)).isFalse(); - assertThat(service.isAlertable(Co2Level.MODERATE)).isTrue(); - assertThat(service.isAlertable(Co2Level.POOR)).isTrue(); - } - - @Test - void isAlertable_unknownLevelDefaultsToPoor() { - props.getAirQuality().setAlertFromLevel("invalid"); - - assertThat(service.isAlertable(Co2Level.MODERATE)).isFalse(); - assertThat(service.isAlertable(Co2Level.POOR)).isTrue(); - } - - // ── fetchLatestReadings ─────────────────────────────────────────────────── + // ── fetchHighCo2Rooms ───────────────────────────────────────────────────── @SuppressWarnings("unchecked") @Test - void fetchLatestReadings_returnsReadingsOnSuccess() { - List expected = List.of( - new SensorReading("A1", "Salle A1", 1500, 22.0, 50, "closed", Instant.now()) + void fetchHighCo2Rooms_returnsRoomsOnSuccess() { + List expected = List.of( + new HighCo2Room("A2", true, 1718), + new HighCo2Room("A3", true, 1653) ); doReturn(uriSpec).when(restClient).get(); - doReturn(uriSpec).when(uriSpec).uri("http://test-api/sensors/latest"); + doReturn(uriSpec).when(uriSpec).uri("http://test-api/api/v1/rooms/high-co2"); doReturn(responseSpec).when(uriSpec).retrieve(); doReturn(expected).when(responseSpec).body(any(ParameterizedTypeReference.class)); - assertThat(service.fetchLatestReadings()).isEqualTo(expected); + assertThat(service.fetchHighCo2Rooms()).isEqualTo(expected); } @SuppressWarnings("unchecked") @Test - void fetchLatestReadings_returnsEmptyListOnApiError() { + void fetchHighCo2Rooms_returnsEmptyListOnApiError() { doReturn(uriSpec).when(restClient).get(); - doReturn(uriSpec).when(uriSpec).uri("http://test-api/sensors/latest"); + doReturn(uriSpec).when(uriSpec).uri("http://test-api/api/v1/rooms/high-co2"); doReturn(responseSpec).when(uriSpec).retrieve(); - doThrow(new RestClientException("connection refused")).when(responseSpec).body(any(ParameterizedTypeReference.class)); + doThrow(new RestClientException("connection refused")) + .when(responseSpec).body(any(ParameterizedTypeReference.class)); - assertThat(service.fetchLatestReadings()).isEmpty(); + assertThat(service.fetchHighCo2Rooms()).isEmpty(); } @SuppressWarnings("unchecked") @Test - void fetchLatestReadings_returnsEmptyListWhenApiReturnsNull() { + void fetchHighCo2Rooms_returnsEmptyListWhenApiReturnsNull() { doReturn(uriSpec).when(restClient).get(); - doReturn(uriSpec).when(uriSpec).uri("http://test-api/sensors/latest"); + doReturn(uriSpec).when(uriSpec).uri("http://test-api/api/v1/rooms/high-co2"); doReturn(responseSpec).when(uriSpec).retrieve(); doReturn(null).when(responseSpec).body(any(ParameterizedTypeReference.class)); - assertThat(service.fetchLatestReadings()).isEmpty(); + assertThat(service.fetchHighCo2Rooms()).isEmpty(); + } + + @SuppressWarnings("unchecked") + @Test + void fetchHighCo2Rooms_sendsBasicAuthWhenCredentialsConfigured() { + props.getAirQuality().setApiUsername("user"); + props.getAirQuality().setApiPassword("pass"); + + doReturn(uriSpec).when(restClient).get(); + doReturn(uriSpec).when(uriSpec).uri(anyString()); + doReturn(uriSpec).when(uriSpec).header(anyString(), anyString()); + doReturn(responseSpec).when(uriSpec).retrieve(); + doReturn(List.of()).when(responseSpec).body(any(ParameterizedTypeReference.class)); + + service.fetchHighCo2Rooms(); + + verify(uriSpec).header(eq("Authorization"), any(String.class)); } } diff --git a/notification_service/src/test/java/ch/hesso/pi/notification/service/TelegramNotificationServiceTest.java b/notification_service/src/test/java/ch/hesso/pi/notification/service/TelegramNotificationServiceTest.java index af90416..9252d9a 100644 --- a/notification_service/src/test/java/ch/hesso/pi/notification/service/TelegramNotificationServiceTest.java +++ b/notification_service/src/test/java/ch/hesso/pi/notification/service/TelegramNotificationServiceTest.java @@ -2,7 +2,7 @@ package ch.hesso.pi.notification.service; import ch.hesso.pi.notification.config.NotificationProperties; import ch.hesso.pi.notification.model.Co2Level; -import ch.hesso.pi.notification.model.SensorReading; +import ch.hesso.pi.notification.model.HighCo2Room; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.http.HttpMethod; @@ -10,8 +10,6 @@ import org.springframework.http.MediaType; import org.springframework.test.web.client.MockRestServiceServer; import org.springframework.web.client.RestClient; -import java.time.Instant; - import static org.springframework.test.web.client.match.MockRestRequestMatchers.*; import static org.springframework.test.web.client.response.MockRestResponseCreators.*; @@ -21,9 +19,7 @@ class TelegramNotificationServiceTest { private TelegramNotificationService service; private NotificationProperties props; - private static final SensorReading READING = new SensorReading( - "A1", "Salle A1", 1500, 22.5, 55, "closed", Instant.parse("2026-01-01T10:00:00Z") - ); + private static final HighCo2Room ROOM = new HighCo2Room("A2", true, 1718); @BeforeEach void setUp() { @@ -44,7 +40,7 @@ class TelegramNotificationServiceTest { .andExpect(content().contentType(MediaType.APPLICATION_JSON)) .andRespond(withSuccess()); - service.sendAlert(READING, Co2Level.POOR); + service.sendAlert(ROOM, Co2Level.POOR); server.verify(); } @@ -54,11 +50,11 @@ class TelegramNotificationServiceTest { server.expect(requestTo("https://api.telegram.org/bottest-token/sendMessage")) .andExpect(jsonPath("$.chat_id").value("123456789")) .andExpect(jsonPath("$.parse_mode").value("HTML")) - .andExpect(jsonPath("$.text").value(org.hamcrest.Matchers.containsString("Salle A1"))) - .andExpect(jsonPath("$.text").value(org.hamcrest.Matchers.containsString("1500"))) + .andExpect(jsonPath("$.text").value(org.hamcrest.Matchers.containsString("A2"))) + .andExpect(jsonPath("$.text").value(org.hamcrest.Matchers.containsString("1718"))) .andRespond(withSuccess()); - service.sendAlert(READING, Co2Level.POOR); + service.sendAlert(ROOM, Co2Level.POOR); server.verify(); } @@ -67,18 +63,18 @@ class TelegramNotificationServiceTest { void sendAlert_skipsWhenTokenMissing() { props.getTelegram().setBotToken(""); - service.sendAlert(READING, Co2Level.POOR); + service.sendAlert(ROOM, Co2Level.POOR); - server.verify(); // expects no requests + server.verify(); } @Test void sendAlert_skipsWhenChatIdMissing() { props.getTelegram().setChatId(""); - service.sendAlert(READING, Co2Level.POOR); + service.sendAlert(ROOM, Co2Level.POOR); - server.verify(); // expects no requests + server.verify(); } @Test @@ -86,7 +82,6 @@ class TelegramNotificationServiceTest { server.expect(requestTo("https://api.telegram.org/bottest-token/sendMessage")) .andRespond(withServerError()); - // must not propagate the exception - service.sendAlert(READING, Co2Level.CRITICAL); + service.sendAlert(ROOM, Co2Level.CRITICAL); } }