diff --git a/notification_service/.gitignore b/notification_service/.gitignore
new file mode 100644
index 0000000..838f4a5
--- /dev/null
+++ b/notification_service/.gitignore
@@ -0,0 +1,6 @@
+target/
+.mvn/wrapper/maven-wrapper.jar
+!.mvn/wrapper/maven-wrapper.properties
+.idea/
+*.iml
+.DS_Store
diff --git a/notification_service/pom.xml b/notification_service/pom.xml
new file mode 100644
index 0000000..566fbdd
--- /dev/null
+++ b/notification_service/pom.xml
@@ -0,0 +1,64 @@
+
+
+ 4.0.0
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 3.3.5
+
+
+
+ ch.hesso.pi
+ notification-service
+ 0.0.1-SNAPSHOT
+ notification-service
+ Air quality alert notifications via Microsoft Teams
+
+
+ 21
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+ org.springframework.boot
+ spring-boot-starter-scheduling
+
+
+
+ org.projectlombok
+ lombok
+ true
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+ org.projectlombok
+ lombok
+
+
+
+
+
+
+
+
diff --git a/notification_service/src/main/java/ch/hesso/pi/notification/NotificationServiceApplication.java b/notification_service/src/main/java/ch/hesso/pi/notification/NotificationServiceApplication.java
new file mode 100644
index 0000000..2409ccc
--- /dev/null
+++ b/notification_service/src/main/java/ch/hesso/pi/notification/NotificationServiceApplication.java
@@ -0,0 +1,14 @@
+package ch.hesso.pi.notification;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.scheduling.annotation.EnableScheduling;
+
+@SpringBootApplication
+@EnableScheduling
+public class NotificationServiceApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(NotificationServiceApplication.class, args);
+ }
+}
diff --git a/notification_service/src/main/java/ch/hesso/pi/notification/config/NotificationProperties.java b/notification_service/src/main/java/ch/hesso/pi/notification/config/NotificationProperties.java
new file mode 100644
index 0000000..ca17635
--- /dev/null
+++ b/notification_service/src/main/java/ch/hesso/pi/notification/config/NotificationProperties.java
@@ -0,0 +1,59 @@
+package ch.hesso.pi.notification.config;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+@ConfigurationProperties(prefix = "notification")
+public class NotificationProperties {
+
+ private Teams teams = new Teams();
+ private AirQuality airQuality = new AirQuality();
+
+ public Teams getTeams() { return teams; }
+ public void setTeams(Teams teams) { this.teams = teams; }
+
+ public AirQuality getAirQuality() { return airQuality; }
+ public void setAirQuality(AirQuality airQuality) { this.airQuality = airQuality; }
+
+ public static class Teams {
+ private String webhookUrl;
+
+ public String getWebhookUrl() { return webhookUrl; }
+ public void setWebhookUrl(String webhookUrl) { this.webhookUrl = webhookUrl; }
+ }
+
+ public static class AirQuality {
+ private String apiUrl = "http://localhost:8080";
+ private long pollIntervalMs = 60_000;
+ private Thresholds thresholds = new Thresholds();
+ private String alertFromLevel = "poor";
+
+ public String getApiUrl() { return apiUrl; }
+ public void setApiUrl(String apiUrl) { this.apiUrl = apiUrl; }
+
+ public long getPollIntervalMs() { return pollIntervalMs; }
+ public void setPollIntervalMs(long pollIntervalMs) { this.pollIntervalMs = pollIntervalMs; }
+
+ public Thresholds getThresholds() { return thresholds; }
+ public void setThresholds(Thresholds thresholds) { this.thresholds = thresholds; }
+
+ public String getAlertFromLevel() { return alertFromLevel; }
+ public void setAlertFromLevel(String alertFromLevel) { this.alertFromLevel = alertFromLevel; }
+ }
+
+ public static class Thresholds {
+ private int moderate = 1000;
+ private int poor = 1400;
+ private int critical = 2000;
+
+ public int getModerate() { return moderate; }
+ public void setModerate(int moderate) { this.moderate = moderate; }
+
+ public int getPoor() { return poor; }
+ public void setPoor(int poor) { this.poor = poor; }
+
+ public int getCritical() { return critical; }
+ public void setCritical(int critical) { this.critical = critical; }
+ }
+}
diff --git a/notification_service/src/main/java/ch/hesso/pi/notification/config/RestClientConfig.java b/notification_service/src/main/java/ch/hesso/pi/notification/config/RestClientConfig.java
new file mode 100644
index 0000000..1e18e68
--- /dev/null
+++ b/notification_service/src/main/java/ch/hesso/pi/notification/config/RestClientConfig.java
@@ -0,0 +1,14 @@
+package ch.hesso.pi.notification.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.client.RestClient;
+
+@Configuration
+public class RestClientConfig {
+
+ @Bean
+ public RestClient restClient() {
+ return RestClient.builder().build();
+ }
+}
diff --git a/notification_service/src/main/java/ch/hesso/pi/notification/model/Co2Level.java b/notification_service/src/main/java/ch/hesso/pi/notification/model/Co2Level.java
new file mode 100644
index 0000000..9946859
--- /dev/null
+++ b/notification_service/src/main/java/ch/hesso/pi/notification/model/Co2Level.java
@@ -0,0 +1,24 @@
+package ch.hesso.pi.notification.model;
+
+public enum Co2Level {
+
+ EXCELLENT("β
Excellent", "#4caf50"),
+ GOOD ("π’ Good", "#8bc34a"),
+ MODERATE ("π‘ Moderate", "#ffc107"),
+ POOR ("π Poor", "#ff9800"),
+ CRITICAL ("π΄ Critical", "#f44336");
+
+ private final String label;
+ private final String themeColor;
+
+ Co2Level(String label, String themeColor) {
+ this.label = label;
+ this.themeColor = themeColor;
+ }
+
+ public String getLabel() { return label; }
+ public String getThemeColor() { return themeColor; }
+
+ /** Returns the hex color without the leading '#' (Teams themeColor format). */
+ public String getThemeColorHex() { return themeColor.substring(1); }
+}
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
new file mode 100644
index 0000000..b035de3
--- /dev/null
+++ b/notification_service/src/main/java/ch/hesso/pi/notification/model/SensorReading.java
@@ -0,0 +1,13 @@
+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/model/teams/Fact.java b/notification_service/src/main/java/ch/hesso/pi/notification/model/teams/Fact.java
new file mode 100644
index 0000000..b59c3eb
--- /dev/null
+++ b/notification_service/src/main/java/ch/hesso/pi/notification/model/teams/Fact.java
@@ -0,0 +1,3 @@
+package ch.hesso.pi.notification.model.teams;
+
+public record Fact(String name, String value) {}
diff --git a/notification_service/src/main/java/ch/hesso/pi/notification/model/teams/MessageCard.java b/notification_service/src/main/java/ch/hesso/pi/notification/model/teams/MessageCard.java
new file mode 100644
index 0000000..9fe8ec9
--- /dev/null
+++ b/notification_service/src/main/java/ch/hesso/pi/notification/model/teams/MessageCard.java
@@ -0,0 +1,30 @@
+package ch.hesso.pi.notification.model.teams;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import java.util.List;
+
+/**
+ * Office 365 Connector Card (MessageCard) payload for Teams incoming webhooks.
+ * Spec: https://learn.microsoft.com/en-us/outlook/actionable-messages/message-card-reference
+ *
+ * Adaptive Cards require a Bot Framework registration; this format works with
+ * any incoming webhook URL without extra infrastructure.
+ */
+public record MessageCard(
+
+ @JsonProperty("@type")
+ String type,
+
+ @JsonProperty("@context")
+ String context,
+
+ String themeColor,
+ String summary,
+ String title,
+ String text,
+ List sections
+) {
+ public static final String TYPE = "MessageCard";
+ public static final String CONTEXT = "https://schema.org/extensions";
+}
diff --git a/notification_service/src/main/java/ch/hesso/pi/notification/model/teams/Section.java b/notification_service/src/main/java/ch/hesso/pi/notification/model/teams/Section.java
new file mode 100644
index 0000000..f0d52a0
--- /dev/null
+++ b/notification_service/src/main/java/ch/hesso/pi/notification/model/teams/Section.java
@@ -0,0 +1,14 @@
+package ch.hesso.pi.notification.model.teams;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+
+import java.util.List;
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public record Section(
+ String activityTitle,
+ String activitySubtitle,
+ String activityImage,
+ List facts,
+ boolean markdown
+) {}
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
new file mode 100644
index 0000000..8ba9b42
--- /dev/null
+++ b/notification_service/src/main/java/ch/hesso/pi/notification/scheduler/AirQualityScheduler.java
@@ -0,0 +1,59 @@
+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.service.AirQualityService;
+import ch.hesso.pi.notification.service.TeamsNotificationService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+
+@Component
+public class AirQualityScheduler {
+
+ private static final Logger log = LoggerFactory.getLogger(AirQualityScheduler.class);
+
+ private final AirQualityService airQualityService;
+ private final TeamsNotificationService teamsService;
+ private final NotificationProperties props;
+
+ public AirQualityScheduler(AirQualityService airQualityService,
+ TeamsNotificationService teamsService,
+ NotificationProperties props) {
+ this.airQualityService = airQualityService;
+ this.teamsService = teamsService;
+ this.props = props;
+ }
+
+ /**
+ * Polls air quality data at the configured interval and sends a Teams alert
+ * for each room whose COβ level reaches or exceeds the configured threshold.
+ *
+ * The interval is read from the environment variable POLL_INTERVAL_MS
+ * (default: 60 000 ms). Spring's @Scheduled only supports fixed values at
+ * compile-time, so we use fixedDelayString with a SpEL expression instead.
+ */
+ @Scheduled(fixedDelayString = "${notification.air-quality.poll-interval-ms:60000}",
+ initialDelayString = "5000")
+ public void checkAirQuality() {
+ log.debug("Polling air quality dataβ¦");
+
+ List readings = airQualityService.fetchLatestReadings();
+ if (readings.isEmpty()) {
+ log.warn("No readings returned β skipping this cycle");
+ return;
+ }
+
+ for (SensorReading reading : readings) {
+ Co2Level level = airQualityService.resolveLevel(reading.co2());
+ if (airQualityService.isAlertable(level)) {
+ log.info("Alertable reading: room={} co2={} level={}", reading.roomId(), reading.co2(), level);
+ teamsService.sendAlert(reading, 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
new file mode 100644
index 0000000..8b56100
--- /dev/null
+++ b/notification_service/src/main/java/ch/hesso/pi/notification/service/AirQualityService.java
@@ -0,0 +1,70 @@
+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 org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.core.ParameterizedTypeReference;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestClient;
+import org.springframework.web.client.RestClientException;
+
+import java.util.List;
+
+@Service
+public class AirQualityService {
+
+ private static final Logger log = LoggerFactory.getLogger(AirQualityService.class);
+
+ private final RestClient restClient;
+ private final NotificationProperties props;
+
+ public AirQualityService(RestClient restClient, NotificationProperties props) {
+ this.restClient = restClient;
+ this.props = props;
+ }
+
+ /**
+ * Fetches the latest sensor reading for every room from the backend API.
+ * Returns an empty list on error so the scheduler can continue safely.
+ */
+ public List fetchLatestReadings() {
+ String url = props.getAirQuality().getApiUrl() + "/sensors/latest";
+ try {
+ List readings = restClient.get()
+ .uri(url)
+ .retrieve()
+ .body(new ParameterizedTypeReference<>() {});
+ return readings != null ? readings : List.of();
+ } catch (RestClientException e) {
+ log.error("Failed to fetch latest readings 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 < 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/TeamsNotificationService.java b/notification_service/src/main/java/ch/hesso/pi/notification/service/TeamsNotificationService.java
new file mode 100644
index 0000000..af0ebec
--- /dev/null
+++ b/notification_service/src/main/java/ch/hesso/pi/notification/service/TeamsNotificationService.java
@@ -0,0 +1,92 @@
+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.teams.Fact;
+import ch.hesso.pi.notification.model.teams.MessageCard;
+import ch.hesso.pi.notification.model.teams.Section;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Service;
+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.List;
+
+@Service
+public class TeamsNotificationService {
+
+ private static final Logger log = LoggerFactory.getLogger(TeamsNotificationService.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;
+
+ public TeamsNotificationService(RestClient restClient, NotificationProperties props) {
+ this.restClient = restClient;
+ this.props = props;
+ }
+
+ /**
+ * Builds and sends a MessageCard to the configured Teams webhook.
+ * Does nothing if the webhook URL is not configured.
+ */
+ public void sendAlert(SensorReading reading, Co2Level level) {
+ String webhookUrl = props.getTeams().getWebhookUrl();
+ if (!StringUtils.hasText(webhookUrl)) {
+ log.warn("Teams webhook URL not configured β skipping alert for room {}", reading.roomId());
+ return;
+ }
+
+ MessageCard card = buildCard(reading, level);
+
+ try {
+ restClient.post()
+ .uri(webhookUrl)
+ .contentType(MediaType.APPLICATION_JSON)
+ .body(card)
+ .retrieve()
+ .toBodilessEntity();
+
+ log.info("Alert sent for room {} β {} ({} ppm)", reading.roomId(), level.getLabel(), reading.co2());
+ } catch (RestClientException e) {
+ log.error("Failed to send Teams alert for room {}: {}", reading.roomId(), e.getMessage());
+ }
+ }
+
+ private MessageCard buildCard(SensorReading reading, Co2Level level) {
+ String timestamp = reading.timestamp() != null
+ ? TIME_FMT.format(reading.timestamp())
+ : "β";
+
+ Section section = new Section(
+ level.getLabel() + " β " + reading.roomName(),
+ "Detected at " + timestamp,
+ null,
+ List.of(
+ new Fact("Room", reading.roomName()),
+ new Fact("COβ", reading.co2() + " ppm"),
+ new Fact("Temperature", reading.temperature() + " Β°C"),
+ new Fact("Humidity", reading.humidity() + " %"),
+ new Fact("Windows", reading.windowState())
+ ),
+ true
+ );
+
+ return new MessageCard(
+ MessageCard.TYPE,
+ MessageCard.CONTEXT,
+ level.getThemeColorHex(),
+ "Air quality alert: " + reading.roomName(),
+ "π¬οΈ Air Quality Alert",
+ null,
+ List.of(section)
+ );
+ }
+}
diff --git a/notification_service/src/main/resources/application.yml b/notification_service/src/main/resources/application.yml
new file mode 100644
index 0000000..ce26747
--- /dev/null
+++ b/notification_service/src/main/resources/application.yml
@@ -0,0 +1,17 @@
+spring:
+ application:
+ name: notification-service
+
+notification:
+ teams:
+ webhook-url: ${TEAMS_WEBHOOK_URL:}
+ air-quality:
+ api-url: ${AIR_QUALITY_API_URL:http://localhost:8080}
+ poll-interval-ms: ${POLL_INTERVAL_MS:60000}
+ # Levels matching the UI co2-levels.config.ts thresholds
+ thresholds:
+ moderate: 1000
+ poor: 1400
+ critical: 2000
+ # Minimum level that triggers a notification (excellent / good / moderate / poor / critical)
+ alert-from-level: poor
diff --git a/notification_service/src/test/java/ch/hesso/pi/notification/NotificationServiceApplicationTests.java b/notification_service/src/test/java/ch/hesso/pi/notification/NotificationServiceApplicationTests.java
new file mode 100644
index 0000000..468ccb6
--- /dev/null
+++ b/notification_service/src/test/java/ch/hesso/pi/notification/NotificationServiceApplicationTests.java
@@ -0,0 +1,17 @@
+package ch.hesso.pi.notification;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.TestPropertySource;
+
+@SpringBootTest
+@TestPropertySource(properties = {
+ "notification.teams.webhook-url=http://localhost:9999/test",
+ "notification.air-quality.api-url=http://localhost:9998"
+})
+class NotificationServiceApplicationTests {
+
+ @Test
+ void contextLoads() {
+ }
+}