feat(notification-service): adapt to Go backend /api/v1/rooms/high-co2 endpoint

Replace SensorReading model with HighCo2Room matching the Go API response
{room, is_high, co2}. Update AirQualityService to call /api/v1/rooms/high-co2.
Simplify scheduler: backend already filters alertable rooms, recovery detected
by absence from response. Adapt Telegram message and all unit tests (20 passing).
This commit is contained in:
khalil-bot
2026-06-01 12:03:57 +02:00
parent 8cba5f7710
commit c26896df02
7 changed files with 105 additions and 152 deletions

View File

@@ -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
) {}

View File

@@ -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
) {}

View File

@@ -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<SensorReading> 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<HighCo2Room> 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<String, Co2Level> 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<SensorReading> readings = mockMode
? MOCK_READINGS
: airQualityService.fetchLatestReadings();
List<HighCo2Room> 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());
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={} {} -> {}", 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);
}
log.info("Alert level changed: room={} {} -> {} ({} ppm)",
room.room(), previous, level, room.co2());
telegramService.sendAlert(room, level);
lastAlertedLevel.put(room.room(), level);
}
}
}

View File

@@ -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<SensorReading> fetchLatestReadings() {
String url = props.getAirQuality().getApiUrl() + "/sensors/latest";
public List<HighCo2Room> fetchHighCo2Rooms() {
String url = props.getAirQuality().getApiUrl() + "/api/v1/rooms/high-co2";
String username = props.getAirQuality().getApiUsername();
String password = props.getAirQuality().getApiPassword();
@@ -42,21 +42,21 @@ 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<SensorReading> readings = request
List<HighCo2Room> 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;
@@ -65,19 +65,4 @@ public class AirQualityService {
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;
}
}
}

View File

@@ -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(
"<b>%s — Air Quality Alert</b>\n\n" +
"<b>Room:</b> %s\n" +
"<b>CO₂:</b> %d ppm\n" +
"<b>Temperature:</b> %.1f °C\n" +
"<b>Humidity:</b> %d %%\n" +
"<b>Windows:</b> %s\n" +
"<b>Time:</b> %s",
"<b>CO₂:</b> %d ppm",
level.getLabel(),
reading.roomName(),
reading.co2(),
reading.temperature(),
reading.humidity(),
reading.windowState(),
timestamp
room.room(),
room.co2()
);
}
}

View File

@@ -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<SensorReading> expected = List.of(
new SensorReading("A1", "Salle A1", 1500, 22.0, 50, "closed", Instant.now())
void fetchHighCo2Rooms_returnsRoomsOnSuccess() {
List<HighCo2Room> 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));
}
}

View File

@@ -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);
}
}