diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 17292684a..84eb343f9 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -7,7 +7,7 @@ on:
- cdot-release*
pull_request:
branches:
- - "**" # Run on PRs to any branch
+ - '**' # Run on PRs to any branch
jobs:
test_change_limit:
@@ -128,8 +128,8 @@ jobs:
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
- distribution: "adopt"
- java-version: "21"
+ distribution: 'adopt'
+ java-version: '21'
- name: Build and Test with Maven
run: |
@@ -190,6 +190,54 @@ jobs:
run: |
rm -rf $GITHUB_WORKSPACE/services/intersection-api/api/target
+ build_rsu_status_monitor:
+ needs: test_change_limit
+ if: ${{ always() && !failure() && !cancelled() }}
+ runs-on: ubuntu-latest
+ container:
+ image: maven:3.9.9-eclipse-temurin-22
+ options: --user root
+ steps:
+ - name: Checkout Repository
+ uses: actions/checkout@v4
+
+ - name: Cache Maven packages
+ uses: actions/cache@v4
+ with:
+ path: ~/.m2
+ key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}-${{ hashFiles('**/settings.xml') }}-${{ runner.os }}-java-22
+ restore-keys: |
+ ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}-${{ runner.os }}-java-22
+ ${{ runner.os }}-maven-${{ runner.os }}-java-22
+ ${{ runner.os }}-maven
+
+ - name: Install Dependencies
+ env:
+ MAVEN_GITHUB_TOKEN: ${{ secrets.MAVEN_GITHUB_TOKEN }}
+ MAVEN_GITHUB_ORG: ${{ github.repository_owner }}
+ run: |
+ cd $GITHUB_WORKSPACE/services/rsu-status-monitor/rsu-status-monitor
+ mvn clean install -DskipTests -s ./settings.xml
+ mvn dependency:go-offline -s ./settings.xml
+
+ - name: Run tests
+ env:
+ MAVEN_GITHUB_TOKEN: ${{ secrets.MAVEN_GITHUB_TOKEN }}
+ MAVEN_GITHUB_ORG: ${{ github.repository_owner }}
+ run: |
+ cd $GITHUB_WORKSPACE/services/rsu-status-monitor/rsu-status-monitor
+ mvn verify -s ./settings.xml
+
+ - name: Archive Test Results
+ uses: actions/upload-artifact@v4
+ with:
+ name: rsu-status-monitor-test-results
+ path: $GITHUB_WORKSPACE/services/rsu-status-monitor/rsu-status-monitor/target/surefire-reports
+
+ - name: Clean up
+ run: |
+ rm -rf $GITHUB_WORKSPACE/services/rsu-status-monitor/rsu-status-monitor/target
+
sonar:
if: github.event.pull_request.base.repo.owner.login == 'usdot-jpo-ode'
needs: [build_api, webapp]
@@ -224,8 +272,8 @@ jobs:
- uses: actions/setup-java@v4
with:
- distribution: "temurin"
- java-version: "17"
+ distribution: 'temurin'
+ java-version: '17'
- name: Setup SonarScanner
uses: warchant/setup-sonar-scanner@v7
diff --git a/.gitmodules b/.gitmodules
index fb7f2dfd7..27a80bfa6 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -1,4 +1,3 @@
[submodule "jpo-utils"]
path = jpo-utils
- url = https://github.com/usdot-jpo-ode/jpo-utils
- branch = master
+ url = https://github.com/neaeraconsulting/jpo-utils
diff --git a/docker-compose-intersection.yml b/docker-compose-intersection.yml
index f727a8e21..e726cb53b 100644
--- a/docker-compose-intersection.yml
+++ b/docker-compose-intersection.yml
@@ -50,6 +50,16 @@ services:
INTERSECTION_API_ENABLE_API: ${INTERSECTION_API_ENABLE_API}
INTERSECTION_API_ENABLE_EMAILER: ${INTERSECTION_API_ENABLE_EMAILER}
INTERSECTION_API_ENABLE_REPORTS: ${INTERSECTION_API_ENABLE_REPORTS}
+ INTERSECTION_API_ENABLE_REPORT_EMAIL: ${INTERSECTION_API_ENABLE_REPORT_EMAIL}
+
+ INTERSECTION_EMAIL_BROKER: ${INTERSECTION_EMAIL_BROKER:-SMTP}
+ INTERSECTION_SENDER_EMAIL: ${INTERSECTION_SENDER_EMAIL:-donotreply@cvmanager.com}
+ UNSUBSCRIBE_SECRET_KEY: ${UNSUBSCRIBE_SECRET_KEY:-replace_me}
+ CV_MANAGER_FRONT_END_URI: ${CV_MANAGER_FRONT_END_URI:-http://localhost:${WEBAPP_PORT:-3000}}
+ INTERSECTION_SMTP_SERVER_IP: ${INTERSECTION_SMTP_SERVER_IP:-localhost}
+ INTERSECTION_SMTP_SERVER_PORT: ${INTERSECTION_SMTP_SERVER_PORT:-1025}
+ SENDGRID_API_KEY: ${SENDGRID_API_KEY}
+ POSTMARK_API_KEY: ${POSTMARK_API_KEY}
BASE_ROUTE_PREFIX: ${INTERSECTION_API_ROUTE_PREFIX:-/}
entrypoint:
diff --git a/docker-compose-rsu-status-monitor.yml b/docker-compose-rsu-status-monitor.yml
new file mode 100644
index 000000000..beca2f956
--- /dev/null
+++ b/docker-compose-rsu-status-monitor.yml
@@ -0,0 +1,32 @@
+services:
+ rsu_status_monitor:
+ profiles:
+ - rsu_status_monitor
+ image: rsu-status-monitor:latest
+ build:
+ context: ./services
+ dockerfile: Dockerfile.rsu_status_monitor
+ args:
+ MAVEN_GITHUB_TOKEN: ${MAVEN_GITHUB_TOKEN:?error}
+ MAVEN_GITHUB_ORG: ${MAVEN_GITHUB_ORG:?error}
+ ports:
+ - '8086:8080'
+ - '42300:42300/udp'
+ environment:
+ LOGGING_LEVEL: ${RSU_STATUS_LOGGING_LEVEL}
+ KAFKA_CONCURRENCY: ${KAFKA_CONCURRENCY:-1}
+ KAFKA_BOOTSTRAP_SERVERS: ${KAFKA_BOOTSTRAP_SERVERS}
+ SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE:-default}
+ POSTGRES_SERVER_URL: jdbc:postgresql://${PG_DB_HOST}
+ POSTGRES_DB: ${PG_DB_NAME:-postgres}
+ POSTGRES_USER: ${PG_DB_USER:-postgres}
+ POSTGRES_PASSWORD: ${PG_DB_PASS:-postgres}
+ RSU_STATUS_MONITOR_ENABLE_MONITORING: ${RSU_STATUS_MONITOR_ENABLE_MONITORING:-true}
+ INTERSECTION_API_MONITOR_INTERVAL: ${INTERSECTION_API_MONITOR_INTERVAL:-300000}
+ depends_on:
+ cvmanager_postgres:
+ required: false
+ condition: service_started
+ kafka-setup:
+ required: false
+ condition: service_started
diff --git a/docker-compose.yml b/docker-compose.yml
index bd06227bc..853ba8779 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -3,6 +3,7 @@ include:
- docker-compose-intersection.yml
- docker-compose-conflictmonitor.yml
- docker-compose-obu-ota-server.yml
+ - docker-compose-rsu-status-monitor.yml
services:
cvmanager_api:
diff --git a/jpo-utils b/jpo-utils
index d9298120d..a57e34628 160000
--- a/jpo-utils
+++ b/jpo-utils
@@ -1 +1 @@
-Subproject commit d9298120d603b9ff9ce3a5520ce885c8bd7ec23d
+Subproject commit a57e346282e1a1f5196ceabd3f814812c1df02eb
diff --git a/resources/sql_scripts/CVManager_SampleData.sql b/resources/sql_scripts/CVManager_SampleData.sql
index c19999107..620e11426 100644
--- a/resources/sql_scripts/CVManager_SampleData.sql
+++ b/resources/sql_scripts/CVManager_SampleData.sql
@@ -72,7 +72,7 @@ INSERT INTO public.snmp_msgfwd_config(
INSERT INTO public.email_type(
email_type)
- VALUES ('Support Requests'), ('Firmware Upgrade Failures'), ('Daily Message Counts');
+ VALUES ('Support Requests'), ('Firmware Upgrade Failures'), ('Daily Message Counts'), ('Conflict Monitor Reports');
INSERT INTO public.intersections(
intersection_number, ref_pt, intersection_name)
@@ -84,4 +84,4 @@ INSERT INTO public.intersection_organization(
INSERT INTO public.rsu_intersection(
rsu_id, intersection_id)
- VALUES (1, 1);
\ No newline at end of file
+ VALUES (1, 1);
diff --git a/sample.env b/sample.env
index 1361ca207..07abfc0cb 100644
--- a/sample.env
+++ b/sample.env
@@ -209,6 +209,7 @@ INTERSECTION_API_ENABLE_API=true
INTERSECTION_API_ENABLE_EMAILER=true
INTERSECTION_API_ENABLE_REPORTS=true
INTERSECTION_API_ENABLE_HAAS=true
+INTERSECTION_API_ENABLE_REPORT_EMAIL=true
# Base Path Prefix - Insert the following path into the base route of all REST endpoints in the Intersection API, used for simplifying proxy routing
# Routes MUST start with a slash
diff --git a/services/Dockerfile.rsu_status_monitor b/services/Dockerfile.rsu_status_monitor
new file mode 100644
index 000000000..8bf3ae494
--- /dev/null
+++ b/services/Dockerfile.rsu_status_monitor
@@ -0,0 +1,37 @@
+FROM maven:3.9.6-eclipse-temurin-22-jammy AS builder
+
+WORKDIR /home/rsu-status-monitor
+
+COPY ./rsu-status-monitor/rsu-status-monitor/pom.xml .
+COPY ./rsu-status-monitor/rsu-status-monitor/settings.xml .
+
+ARG MAVEN_GITHUB_TOKEN
+ARG MAVEN_GITHUB_ORG
+ENV MAVEN_GITHUB_TOKEN=$MAVEN_GITHUB_TOKEN
+ENV MAVEN_GITHUB_ORG=$MAVEN_GITHUB_ORG
+
+RUN mvn -s settings.xml dependency:resolve
+
+# Copies source code after dependencies are resolved to leverage Docker caching
+COPY ./rsu-status-monitor/rsu-status-monitor/src ./src
+RUN mvn -s settings.xml install -DskipTests
+
+# Create a new stage for runtime
+FROM eclipse-temurin:22-jre-noble
+
+WORKDIR /home
+
+COPY --from=builder /home/rsu-status-monitor/src/main/resources/application.yaml /home
+COPY --from=builder /home/rsu-status-monitor/src/main/resources/application-confluent.yaml /home
+COPY --from=builder /home/rsu-status-monitor/target/rsu-status-monitor.jar /home
+
+ENTRYPOINT ["java", \
+ "-Djava.rmi.server.hostname=$DOCKER_HOST_IP", \
+ "-Dcom.sun.management.jmxremote.port=9090", \
+ "-Dcom.sun.management.jmxremote.rmi.port=9090", \
+ "-Dcom.sun.management.jmxremote", \
+ "-Dcom.sun.management.jmxremote.local.only=true", \
+ "-Dcom.sun.management.jmxremote.authenticate=false", \
+ "-Dcom.sun.management.jmxremote.ssl=false", \
+ "-jar", \
+ "/home/rsu-status-monitor.jar"]
\ No newline at end of file
diff --git a/services/intersection-api/api/pom.xml b/services/intersection-api/api/pom.xml
index 523a89964..131d9fd72 100644
--- a/services/intersection-api/api/pom.xml
+++ b/services/intersection-api/api/pom.xml
@@ -294,6 +294,11 @@
json-unit-assertj
5.0.0
test
+
+
+ com.sun.mail
+ jakarta.mail
+ 2.0.1
diff --git a/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/accessors/reports/ReportRepository.java b/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/accessors/reports/ReportRepository.java
index f17fbc58a..0185a1fe1 100644
--- a/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/accessors/reports/ReportRepository.java
+++ b/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/accessors/reports/ReportRepository.java
@@ -5,6 +5,8 @@
import us.dot.its.jpo.ode.api.models.DataLoader;
import us.dot.its.jpo.ode.api.models.ReportDocument;
+import java.util.List;
+
public interface ReportRepository extends DataLoader {
long count(String reportName, Integer intersectionID, Long startTime, Long endTime);
@@ -14,4 +16,6 @@ Page findLatest(String reportName, Integer intersectionID, Long
Page find(String reportName, Integer intersectionID, Long startTime, Long endTime,
boolean includeReportContents, Pageable pageable);
+ List findAll(String reportName, Integer intersectionID, Long startTime, Long endTime,
+ boolean includeReportContents);
}
\ No newline at end of file
diff --git a/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/accessors/reports/ReportRepositoryImpl.java b/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/accessors/reports/ReportRepositoryImpl.java
index 52a4a24cb..bf9c3823d 100644
--- a/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/accessors/reports/ReportRepositoryImpl.java
+++ b/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/accessors/reports/ReportRepositoryImpl.java
@@ -129,4 +129,31 @@ public Page find(
public void add(ReportDocument item) {
mongoTemplate.insert(item, collectionName);
}
+
+ public List findAll(
+ String reportName,
+ Integer intersectionID,
+ Long startTime,
+ Long endTime,
+ boolean includeReportContents) {
+ Criteria criteria = new IntersectionCriteria()
+ .whereOptional(REPORT_NAME_FIELD, reportName)
+ .whereOptional(INTERSECTION_ID_FIELD, intersectionID);
+
+ // Match exact start and stop times
+ criteria.and("reportStartTime").is(startTime);
+ criteria.and("reportStopTime").is(endTime);
+
+ Sort sort = Sort.by(Sort.Direction.DESC, DATE_FIELD);
+ List excludedFields = new ArrayList<>();
+ if (!includeReportContents) {
+ excludedFields.add("reportContents");
+ }
+ Query query = Query.query(criteria).with(sort);
+ // Exclude fields if necessary
+ if (!excludedFields.isEmpty()) {
+ query.fields().exclude(excludedFields.toArray(new String[0]));
+ }
+ return mongoTemplate.find(query, ReportDocument.class, collectionName);
+ }
}
\ No newline at end of file
diff --git a/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/accessors/rsuState/RsuStateRepository.java b/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/accessors/rsuState/RsuStateRepository.java
new file mode 100644
index 000000000..2dbbb3f51
--- /dev/null
+++ b/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/accessors/rsuState/RsuStateRepository.java
@@ -0,0 +1,14 @@
+package us.dot.its.jpo.ode.api.accessors.rsuState;
+
+import java.util.List;
+import us.dot.its.jpo.ode.api.models.snmp.RsuState;
+
+public interface RsuStateRepository {
+ List retrieveRsuStateWithinTimeInterval(String rsuIP, long start, long end);
+
+ RsuState findLatestByRsuIP(String rsuIP);
+
+ List retrieveRsuStateWithinTimeInterval(String rsuIP, long start, long end,
+ int intervalMinutes);
+
+}
\ No newline at end of file
diff --git a/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/accessors/rsuState/RsuStateRepositoryImpl.java b/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/accessors/rsuState/RsuStateRepositoryImpl.java
new file mode 100644
index 000000000..6329fa464
--- /dev/null
+++ b/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/accessors/rsuState/RsuStateRepositoryImpl.java
@@ -0,0 +1,86 @@
+package us.dot.its.jpo.ode.api.accessors.rsuState;
+
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.domain.Sort;
+import org.springframework.data.mongodb.core.MongoTemplate;
+import org.springframework.data.mongodb.core.aggregation.Aggregation;
+import org.springframework.data.mongodb.core.query.Criteria;
+import org.springframework.data.mongodb.core.query.Query;
+import org.springframework.stereotype.Component;
+import java.util.Date;
+import org.bson.Document;
+
+import us.dot.its.jpo.ode.api.models.snmp.RsuState;
+
+import java.time.Instant;
+import java.time.ZoneOffset;
+
+@Component
+public class RsuStateRepositoryImpl implements RsuStateRepository {
+
+ private final MongoTemplate mongoTemplate;
+ private final String collectionName = "RmMonitoringStatusRecords";
+
+ @Autowired
+ public RsuStateRepositoryImpl(MongoTemplate mongoTemplate) {
+ this.mongoTemplate = mongoTemplate;
+ }
+
+ @Override
+ public List retrieveRsuStateWithinTimeInterval(String rsuIP, long start, long end) {
+ Criteria criteria = Criteria.where("rsuIP").is(rsuIP)
+ .and("timestamp").gte(new Date(start)).lte(new Date(end))
+ .andOperator(
+ Criteria.where("uptime").ne(-1),
+ Criteria.where("temperature").ne(-1));
+ Query query = Query.query(criteria).with(Sort.by(Sort.Direction.ASC, "timestamp"));
+ return mongoTemplate.find(query, RsuState.class, collectionName);
+ }
+
+ @Override
+ public List retrieveRsuStateWithinTimeInterval(String rsuIP, long start, long end, int intervalMinutes) {
+ long intervalMs = intervalMinutes * 60 * 1000; // Convert minutes to milliseconds
+
+ Criteria criteria = Criteria.where("rsuIP").is(rsuIP)
+ .and("timestamp").gte(new Date(start)).lte(new Date(end))
+ .andOperator(
+ Criteria.where("uptime").ne(-1),
+ Criteria.where("temperature").ne(-1));
+
+ Aggregation aggregation = Aggregation.newAggregation(
+ Aggregation.match(criteria), // filter by rsuIP and timestamp
+ Aggregation.project("timestamp", "rsuIP", "temperature", "uptime", "mode")
+ .andExpression("{$convert: {input: \"$timestamp\", to: \"long\"}}").as("timestampMillis"),
+ Aggregation.project("timestamp", "rsuIP", "temperature", "uptime", "mode", "timestampMillis")
+ .andExpression("timestampMillis - (timestampMillis % " + intervalMs + ")").as("interval"),
+ Aggregation.group("interval") // Group by interval
+ .avg("temperature").as("temperature")
+ .avg("uptime").as("uptime")
+ .first("rsuIP").as("rsuIP")
+ .first("mode").as("mode")
+ .first("timestamp").as("timestamp"),
+ Aggregation.sort(Sort.by(Sort.Direction.ASC, "timestamp")) // Sort by timestamp
+ );
+
+ List results = mongoTemplate.aggregate(aggregation, collectionName, RsuState.class)
+ .getMappedResults();
+
+ return results;
+ }
+
+ @Override
+ public RsuState findLatestByRsuIP(String rsuIP) {
+ Criteria criteria = Criteria.where("rsuIP").is(rsuIP);
+ Query query = Query.query(criteria)
+ .with(Sort.by(Sort.Direction.DESC, "timestamp"))
+ .limit(1); // Limit the results to just one record
+
+ return mongoTemplate.findOne(query, RsuState.class, collectionName);
+ }
+}
\ No newline at end of file
diff --git a/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/controllers/ReportsController.java b/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/controllers/ReportsController.java
index 3cf31efe0..a4134a083 100644
--- a/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/controllers/ReportsController.java
+++ b/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/controllers/ReportsController.java
@@ -4,6 +4,7 @@
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
@@ -24,87 +25,91 @@
@RestController
@ConditionalOnProperty(name = "enable.api", havingValue = "true", matchIfMissing = false)
@ApiResponses(value = {
- @ApiResponse(responseCode = "401", description = "Unauthorized"),
- @ApiResponse(responseCode = "500", description = "Internal Server Error")
+ @ApiResponse(responseCode = "401", description = "Unauthorized"),
+ @ApiResponse(responseCode = "500", description = "Internal Server Error")
})
@RequestMapping("/reports")
public class ReportsController {
- private final ReportService reportService;
- private final ReportRepository reportRepo;
+ private final ReportService reportService;
+ private final ReportRepository reportRepo;
- @Autowired
- public ReportsController(
- ReportService reportService,
- ReportRepository reportRepo) {
- this.reportService = reportService;
- this.reportRepo = reportRepo;
- }
+ @Autowired
+ public ReportsController(
+ ReportService reportService,
+ ReportRepository reportRepo) {
+ this.reportService = reportService;
+ this.reportRepo = reportRepo;
+ }
+
+ @Operation(summary = "Generate a Report", description = "Generates a new report for the intersection specified, within the start and end time. This can take upwards of 15 minutes to complete for longer reports")
+ @RequestMapping(value = "/intersection/generate", method = RequestMethod.GET, produces = "application/json")
+ @PreAuthorize("@PermissionService.isSuperUser() || (@PermissionService.hasIntersection(#intersectionID, 'USER') and @PermissionService.hasRole('USER')) ")
+ @ApiResponses(value = {
+ @ApiResponse(responseCode = "200", description = "Success"),
+ @ApiResponse(responseCode = "403", description = "Forbidden - Requires SUPER_USER or USER role with access to the intersection requested"),
+ })
+ public ResponseEntity> generateReport(
+ @RequestParam(name = "intersection_id") int intersectionID,
+ @RequestParam(name = "start_time_utc_millis") long startTime,
+ @RequestParam(name = "end_time_utc_millis") long endTime) {
+ log.debug("Generating Report");
- @Operation(summary = "Generate a Report", description = "Generates a new report for the intersection specified, within the start and end time. This can take upwards of 15 minutes to complete for longer reports")
- @RequestMapping(value = "/intersection/generate", method = RequestMethod.GET, produces = "application/octet-stream")
- @PreAuthorize("@PermissionService.isSuperUser() || (@PermissionService.hasIntersection(#intersectionID, 'USER') and @PermissionService.hasRole('USER')) ")
- @ApiResponses(value = {
- @ApiResponse(responseCode = "200", description = "Success"),
- @ApiResponse(responseCode = "403", description = "Forbidden - Requires SUPER_USER or USER role with access to the intersection requested"),
- })
- public byte[] generateReport(
- @RequestParam(name = "intersection_id") int intersectionID,
- @RequestParam(name = "start_time_utc_millis") long startTime,
- @RequestParam(name = "end_time_utc_millis") long endTime) {
- log.debug("Generating Report");
+ ReportDocument document = reportService.buildReport(intersectionID, startTime, endTime);
+ PageRequest pageable = PageRequest.of(0, 1);
- ReportDocument document = reportService.buildReport(intersectionID, startTime, endTime);
+ return ResponseEntity.ok(new PageImpl(java.util.Collections.singletonList(document),
+ pageable, 1));
- return document.getReportContents();
- }
+ }
- @Operation(summary = "List Reports", description = "Returns a list of existing intersection reports, as aggregated data, filtered by name, intersection ID, start time, and end time. The latest parameter will return the most recent report.")
- @RequestMapping(value = "/intersection", method = RequestMethod.GET, produces = "application/json")
- @PreAuthorize("@PermissionService.isSuperUser() || @PermissionService.hasRole('USER')")
- @ApiResponses(value = {
- @ApiResponse(responseCode = "200", description = "Success"),
- @ApiResponse(responseCode = "403", description = "Forbidden - Requires SUPER_USER or USER role"),
- })
- public ResponseEntity> listReports(
- @RequestParam(name = "report_name", required = false) String reportName,
- @RequestParam(name = "intersection_id") int intersectionID,
- @RequestParam(name = "start_time_utc_millis") long startTime,
- @RequestParam(name = "end_time_utc_millis") long endTime,
- @RequestParam(name = "page", required = false, defaultValue = "0") int page,
- @RequestParam(name = "size", required = false, defaultValue = "10000") int size,
- @RequestParam(name = "latest") boolean latest) {
+ @Operation(summary = "List Reports", description = "Returns a list of existing intersection reports, as aggregated data, filtered by name, intersection ID, start time, and end time. The latest parameter will return the most recent report.")
+ @RequestMapping(value = "/intersection", method = RequestMethod.GET, produces = "application/json")
+ @PreAuthorize("@PermissionService.isSuperUser() || @PermissionService.hasRole('USER')")
+ @ApiResponses(value = {
+ @ApiResponse(responseCode = "200", description = "Success"),
+ @ApiResponse(responseCode = "403", description = "Forbidden - Requires SUPER_USER or USER role"),
+ })
+ public ResponseEntity> listReports(
+ @RequestParam(name = "report_name", required = false) String reportName,
+ @RequestParam(name = "intersection_id") int intersectionID,
+ @RequestParam(name = "start_time_utc_millis") long startTime,
+ @RequestParam(name = "end_time_utc_millis") long endTime,
+ @RequestParam(name = "page", required = false, defaultValue = "0") int page,
+ @RequestParam(name = "size", required = false, defaultValue = "10000") int size,
+ @RequestParam(name = "latest") boolean latest) {
- if (latest) {
- return ResponseEntity.ok(reportRepo.findLatest(reportName, intersectionID, startTime, endTime, false));
- } else {
- // Retrieve a paginated result from the repository
- PageRequest pageable = PageRequest.of(page, size);
- Page response = reportRepo.find(reportName, intersectionID, startTime, endTime,
- false, pageable);
- return ResponseEntity.ok(response);
+ if (latest) {
+ return ResponseEntity.ok(
+ reportRepo.findLatest(reportName, intersectionID, startTime, endTime, false));
+ } else {
+ // Retrieve a paginated result from the repository
+ PageRequest pageable = PageRequest.of(page, size);
+ Page response = reportRepo.find(reportName, intersectionID, startTime, endTime,
+ false, pageable);
+ return ResponseEntity.ok(response);
+ }
}
- }
- @Operation(summary = "Download a Report", description = "Returns the a report by name, as aggregated data")
- @RequestMapping(value = "/intersection/download", method = RequestMethod.GET, produces = "application/octet-stream")
- @PreAuthorize("@PermissionService.hasRole('USER')")
- @ApiResponses(value = {
- @ApiResponse(responseCode = "200", description = "Success"),
- @ApiResponse(responseCode = "403", description = "Forbidden - Requires USER role"),
- @ApiResponse(responseCode = "404", description = "Report not found"),
- })
- public ResponseEntity downloadReport(
- @RequestParam(name = "report_name") String reportName) {
+ @Operation(summary = "Download a Report", description = "Returns the a report by name, as aggregated data")
+ @RequestMapping(value = "/intersection/download", method = RequestMethod.GET, produces = "application/octet-stream")
+ @PreAuthorize("@PermissionService.hasRole('USER')")
+ @ApiResponses(value = {
+ @ApiResponse(responseCode = "200", description = "Success"),
+ @ApiResponse(responseCode = "403", description = "Forbidden - Requires USER role"),
+ @ApiResponse(responseCode = "404", description = "Report not found"),
+ })
+ public ResponseEntity downloadReport(
+ @RequestParam(name = "report_name") String reportName) {
- Page reports = reportRepo.findLatest(reportName, null, null, null, true);
+ Page reports = reportRepo.findLatest(reportName, null, null, null, true);
- if (!reports.isEmpty()) {
- return ResponseEntity.ok()
- .header("Content-Disposition", "attachment; filename=\"" + reportName + "\"")
- .body(reports.getContent().getFirst().getReportContents());
- } else {
- return ResponseEntity.status(HttpStatus.NOT_FOUND).body(null);
+ if (!reports.isEmpty()) {
+ return ResponseEntity.ok()
+ .header("Content-Disposition", "attachment; filename=\"" + reportName + "\"")
+ .body(reports.getContent().getFirst().getReportContents());
+ } else {
+ return ResponseEntity.status(HttpStatus.NOT_FOUND).body(null);
+ }
}
- }
}
\ No newline at end of file
diff --git a/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/controllers/data/RsuController.java b/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/controllers/data/RsuController.java
new file mode 100644
index 000000000..57e19791c
--- /dev/null
+++ b/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/controllers/data/RsuController.java
@@ -0,0 +1,89 @@
+package us.dot.its.jpo.ode.api.controllers.data;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import us.dot.its.jpo.ode.api.models.snmp.RsuState;
+import us.dot.its.jpo.ode.api.accessors.rsuState.RsuStateRepository;
+
+import java.util.List;
+
+@RestController
+@ConditionalOnProperty(name = "enable.api", havingValue = "true", matchIfMissing = false)
+@ApiResponses(value = {
+ @ApiResponse(responseCode = "401", description = "Unauthorized"),
+ @ApiResponse(responseCode = "500", description = "Internal Server Error")
+})
+@RequestMapping("/data/rsu-status")
+public class RsuController {
+
+ private final RsuStateRepository rsuStateRepository;
+
+ @Autowired
+ public RsuController(RsuStateRepository rsuStateRepository) {
+ this.rsuStateRepository = rsuStateRepository;
+ }
+
+ @Operation(summary = "Get historical RSU Status", description = "Returns all RSU status records for the given RSU IP and time range (UTC ms)")
+ @GetMapping(value = "/historical", produces = "application/json")
+ @PreAuthorize("@PermissionService.isSuperUser() || @PermissionService.hasRole('USER')")
+ @ApiResponses(value = {
+ @ApiResponse(responseCode = "200", description = "Success"),
+ @ApiResponse(responseCode = "403", description = "Forbidden - Requires SUPER_USER or USER role"),
+ @ApiResponse(responseCode = "400", description = "Invalid parameters")
+ })
+ public ResponseEntity> getHistoricalRsuStatus(
+ @RequestParam String rsuIp,
+ @RequestParam long startTime,
+ @RequestParam long endTime) {
+ if (startTime > endTime) {
+ return ResponseEntity.badRequest().build();
+ }
+ List results = rsuStateRepository.retrieveRsuStateWithinTimeInterval(rsuIp, startTime, endTime);
+ return ResponseEntity.ok(results);
+ }
+
+ @Operation(summary = "Get latest RSU Status", description = "Returns the most recent RSU status record for the given RSU IP")
+ @GetMapping(value = "/latest", produces = "application/json")
+ @PreAuthorize("@PermissionService.isSuperUser() || @PermissionService.hasRole('USER')")
+ @ApiResponses(value = {
+ @ApiResponse(responseCode = "200", description = "Success"),
+ @ApiResponse(responseCode = "403", description = "Forbidden - Requires SUPER_USER or USER role"),
+ @ApiResponse(responseCode = "404", description = "No RSU status found for this RSU IP")
+ })
+ public ResponseEntity getLatestRsuStatus(@RequestParam String rsuIp) {
+ RsuState latest = rsuStateRepository.findLatestByRsuIP(rsuIp);
+ if (latest != null) {
+ return ResponseEntity.ok(latest);
+ } else {
+ return ResponseEntity.notFound().build();
+ }
+ }
+
+ @Operation(summary = "Get aggregated RSU Status", description = "Returns aggregated RSU status records for the given RSU IP and time range (UTC ms)")
+ @GetMapping(value = "/aggregated", produces = "application/json")
+ @PreAuthorize("@PermissionService.isSuperUser() || @PermissionService.hasRole('USER')")
+ @ApiResponses(value = {
+ @ApiResponse(responseCode = "200", description = "Success"),
+ @ApiResponse(responseCode = "403", description = "Forbidden - Requires SUPER_USER or USER role"),
+ @ApiResponse(responseCode = "400", description = "Invalid parameters")
+ })
+ public ResponseEntity> getAggregatedRsuStatus(
+ @RequestParam String rsuIp,
+ @RequestParam long startTime,
+ @RequestParam long endTime,
+ @RequestParam int intervalMinutes) {
+ if (startTime > endTime || intervalMinutes <= 0) {
+ return ResponseEntity.badRequest().build();
+ }
+ List results = rsuStateRepository.retrieveRsuStateWithinTimeInterval(rsuIp, startTime,
+ endTime, intervalMinutes);
+ return ResponseEntity.ok(results);
+ }
+}
\ No newline at end of file
diff --git a/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/models/postgres/tables/EmailType.java b/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/models/postgres/tables/EmailType.java
new file mode 100644
index 000000000..bff5af02c
--- /dev/null
+++ b/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/models/postgres/tables/EmailType.java
@@ -0,0 +1,23 @@
+package us.dot.its.jpo.ode.api.models.postgres.tables;
+
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.Setter;
+import lombok.ToString;
+
+@ToString
+@Setter
+@EqualsAndHashCode
+@Getter
+@Entity
+@Table(name = "email_type")
+public class EmailType {
+
+ @Id
+ private int email_type_id;
+ private String email_type;
+
+}
\ No newline at end of file
diff --git a/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/models/postgres/tables/UserEmailNotification.java b/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/models/postgres/tables/UserEmailNotification.java
new file mode 100644
index 000000000..9a3e9e136
--- /dev/null
+++ b/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/models/postgres/tables/UserEmailNotification.java
@@ -0,0 +1,24 @@
+package us.dot.its.jpo.ode.api.models.postgres.tables;
+
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.Setter;
+import lombok.ToString;
+
+@ToString
+@Setter
+@EqualsAndHashCode
+@Getter
+@Entity
+@Table(name = "user_email_notification")
+public class UserEmailNotification {
+
+ @Id
+ private int user_email_notification_id;
+ private int user_id;
+ private int email_type_id;
+
+}
\ No newline at end of file
diff --git a/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/models/snmp/RsuState.java b/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/models/snmp/RsuState.java
new file mode 100644
index 000000000..7b998d493
--- /dev/null
+++ b/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/models/snmp/RsuState.java
@@ -0,0 +1,39 @@
+package us.dot.its.jpo.ode.api.models.snmp;
+
+import lombok.Getter;
+import lombok.Setter;
+
+@Setter
+@Getter
+public class RsuState {
+ /**
+ * Time in UTC Milliseconds that the RSU State was generated
+ */
+ public long timestamp;
+
+ /**
+ * IntersectionID of the intersection corresponding to the RSU
+ */
+ public String intersectionID;
+
+ /**
+ * IP Address of the RSU unit
+ */
+ public String rsuIP;
+
+ /**
+ * Internal Temperature of the RSU Unit
+ */
+ public double temperature;
+
+ /**
+ * Time in seconds since the RSU unit last rebooted
+ */
+ public Long uptime;
+
+ /**
+ * The Mode of the RSU. There are 3 common modes, Operational(4), Standby (2)
+ * and Off (16)
+ */
+ public Long mode;
+}
diff --git a/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/services/EmailService.java b/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/services/EmailService.java
index c550f02de..10e160e22 100644
--- a/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/services/EmailService.java
+++ b/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/services/EmailService.java
@@ -33,14 +33,17 @@ public class EmailService {
private final SendGrid sendGrid;
private final ApiClient postmark;
private final ConflictMonitorApiProperties props;
+ private final PostgresService postgresService;
@Autowired
public EmailService(JavaMailSender mailSender, SendGrid sendGrid, ApiClient postmark,
- ConflictMonitorApiProperties props) {
+ ConflictMonitorApiProperties props, PostgresService postgresService) {
this.mailSender = mailSender;
this.sendGrid = sendGrid;
this.postmark = postmark;
this.props = props;
+ this.postgresService = postgresService;
+
}
public void sendEmailViaSendGrid(String to, String subject, String text) {
@@ -79,6 +82,7 @@ public void sendEmailViaPostmark(String to, String subject, String text) {
public void sendEmailViaSpringMail(String to, String subject, String text) {
try {
SimpleMailMessage message = new SimpleMailMessage();
+ message.setFrom(props.getEmailFromAddress());
message.setTo(to);
message.setSubject(subject);
message.setText(text);
@@ -112,4 +116,12 @@ public List getNotificationEmailList(EmailFrequency frequenc
// TODO: Pull email list from Postgres
return new ArrayList<>();
}
+
+ public List getUsersForConflictMonitorReports() {
+ return postgresService.getUsersByNotificationType("Conflict Monitor Reports");
+ }
+
+ public List getAllowedIntersectionIdsByEmail(String email) {
+ return postgresService.getAllowedIntersectionIdsByEmail(email);
+ }
}
\ No newline at end of file
diff --git a/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/services/PostgresService.java b/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/services/PostgresService.java
index 9026c6a73..59b39ffdb 100644
--- a/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/services/PostgresService.java
+++ b/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/services/PostgresService.java
@@ -172,6 +172,18 @@ public boolean checkIntersectionWithOrg(String intersectionId, List orga
return !result.isEmpty() && result.get(0).equals(intersectionId);
}
+ private final String findUsersByNotificationTypeQuery = "SELECT u.email " +
+ "FROM UserEmailNotification uen " +
+ "JOIN Users u ON uen.user_id = u.user_id " +
+ "JOIN EmailType et ON uen.email_type_id = et.email_type_id " +
+ "WHERE et.email_type = :notification_type";
+
+ public List getUsersByNotificationType(String notificationType) {
+ TypedQuery query = entityManager.createQuery(findUsersByNotificationTypeQuery, String.class);
+ query.setParameter("notification_type", notificationType);
+ return query.getResultList();
+ }
+
public List getQualifiedOrgList(String email, String requiredRole) {
List organizationRoles = findUserOrgRoles(email);
return organizationRoles.stream()
diff --git a/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/tasks/ReportEmailTask.java b/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/tasks/ReportEmailTask.java
new file mode 100644
index 000000000..2baea56ef
--- /dev/null
+++ b/services/intersection-api/api/src/main/java/us/dot/its/jpo/ode/api/tasks/ReportEmailTask.java
@@ -0,0 +1,139 @@
+package us.dot.its.jpo.ode.api.tasks;
+
+import java.io.BufferedReader;
+import java.io.InputStreamReader;
+import java.text.SimpleDateFormat;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.time.ZonedDateTime;
+import java.util.*;
+import java.util.stream.Collectors;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.springframework.data.domain.Pageable;
+import us.dot.its.jpo.ode.api.models.IntersectionReferenceData;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import lombok.RequiredArgsConstructor;
+import us.dot.its.jpo.ode.api.models.ReportDocument;
+import us.dot.its.jpo.ode.api.services.ReportService;
+import us.dot.its.jpo.ode.api.services.EmailService;
+
+@Component
+@ConditionalOnProperty(name = "enable.report-email", havingValue = "true", matchIfMissing = false)
+@RequiredArgsConstructor
+public class ReportEmailTask {
+
+ private static final Logger log = LoggerFactory.getLogger(ReportEmailTask.class);
+ private static final SimpleDateFormat dateFormat = new SimpleDateFormat("HH:mm:ss");
+
+ private final us.dot.its.jpo.ode.api.accessors.reports.ReportRepository reportRepo;
+ private final us.dot.its.jpo.ode.api.accessors.map.ProcessedMapRepository processedMapRepo;
+ private final EmailService emailService;
+
+ private static final String WEEKLY_EMAIL_CRON = "0 0 1 ? * SUN"; // Runs weekly at 1 am on Sundays
+
+ @Scheduled(cron = WEEKLY_EMAIL_CRON)
+ public void sendWeeklyReportEmails() {
+ log.info("Sending Weekly Report Emails at {}", dateFormat.format(new Date()));
+ ZonedDateTime now = ZonedDateTime.now().withHour(0).withMinute(0).withSecond(0).withNano(0);
+ ZonedDateTime expectedStartTime = now.minusWeeks(1);
+ ZonedDateTime expectedStopTime = now;
+ sendEmailsForReportsInRange(expectedStartTime.toInstant(), expectedStopTime.toInstant());
+ }
+
+ void sendEmailsForReportsInRange(Instant expectedStartTime, Instant expectedStopTime) {
+ log.info("Fetching users subscribed to Conflict Monitor Reports...");
+ List users = emailService.getUsersForConflictMonitorReports();
+
+ Map reportCache = new HashMap<>();
+
+ for (String user : users) {
+ log.info("Processing user: {}", user);
+
+ List allowedIntersections = emailService.getAllowedIntersectionIdsByEmail(user);
+
+ List validIntersectionIds = fetchReportsForIntersections(allowedIntersections, expectedStartTime,
+ expectedStopTime, reportCache);
+
+ if (validIntersectionIds.isEmpty()) {
+ continue;
+ }
+
+ String emailBody = constructEmailBody(validIntersectionIds, reportCache, expectedStartTime,
+ expectedStopTime);
+
+ String subject = String.format("Weekly Conflict Monitor Reports (%s)",
+ ZonedDateTime.now(ZoneOffset.UTC).toLocalDate());
+ emailService.sendSimpleMessage(user, subject, emailBody);
+ }
+ }
+
+ List fetchReportsForIntersections(List intersectionIds, Instant startTime,
+ Instant stopTime, Map reportCache) {
+ long startRange = startTime.toEpochMilli();
+ long endRange = stopTime.toEpochMilli();
+
+ List processedIntersectionIds = new ArrayList<>();
+
+ for (Integer intersectionId : intersectionIds) {
+ if (reportCache.containsKey(intersectionId)) {
+ processedIntersectionIds.add(intersectionId);
+ continue;
+ }
+
+ List reports = reportRepo.findAll(
+ null,
+ intersectionId,
+ startRange,
+ endRange,
+ true);
+
+ if (!reports.isEmpty()) {
+ ReportDocument report = reports.get(0);
+ String serializedReport = serializeReportToJson(report);
+ reportCache.put(intersectionId, serializedReport);
+ processedIntersectionIds.add(intersectionId);
+ }
+ }
+
+ return processedIntersectionIds;
+ }
+
+ String constructEmailBody(List validIntersectionIds, Map reportCache,
+ Instant startTime, Instant stopTime) {
+ StringBuilder emailBody = new StringBuilder();
+
+ String startDate = ZonedDateTime.ofInstant(startTime, ZoneOffset.UTC).toLocalDate().toString();
+ String endDate = ZonedDateTime.ofInstant(stopTime, ZoneOffset.UTC).toLocalDate().toString();
+ emailBody.append(
+ String.format("The following Conflict Monitor Reports were generated for the week of %s to %s:\n\n",
+ startDate, endDate));
+
+ for (Integer intersectionId : validIntersectionIds) {
+ String serializedReport = reportCache.get(intersectionId);
+
+ if (serializedReport != null) {
+ emailBody.append(String.format("Intersection %d:\n%s\n\n", intersectionId, serializedReport));
+ }
+ }
+
+ return emailBody.toString();
+ }
+
+ private String serializeReportToJson(ReportDocument report) {
+ try {
+ ObjectMapper objectMapper = new ObjectMapper();
+ return objectMapper.writeValueAsString(report);
+ } catch (JsonProcessingException e) {
+ log.error("Error serializing report to JSON", e);
+ return "{}";
+ }
+ }
+}
\ No newline at end of file
diff --git a/services/intersection-api/api/src/main/resources/application.yaml b/services/intersection-api/api/src/main/resources/application.yaml
index 68a0eea85..4d5a527fb 100644
--- a/services/intersection-api/api/src/main/resources/application.yaml
+++ b/services/intersection-api/api/src/main/resources/application.yaml
@@ -84,6 +84,7 @@ enable:
report: ${INTERSECTION_API_ENABLE_REPORTS:true}
haas: ${INTERSECTION_API_ENABLE_HAAS:true}
asn1-decoder: ${INTERSECTION_API_ENABLE_ASN1_DECODER:true}
+ report-email: ${INTERSECTION_API_ENABLE_REPORT_EMAIL:true}
cmServerURL: ${CM_SERVER_URL:http://localhost:8082}
mongoTimeoutMs: ${CM_MONGO_TIMEOUT_MS:5000}
diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/ReportsControllerTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/ReportsControllerTest.java
index 7b0758d10..1a817b6b2 100644
--- a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/ReportsControllerTest.java
+++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/controllers/ReportsControllerTest.java
@@ -42,9 +42,10 @@ void testGenerateReport() {
doc.setReportContents(new byte[] { 1, 2, 3 });
when(reportService.buildReport(anyInt(), anyLong(), anyLong())).thenReturn(doc);
- byte[] result = controller.generateReport(1, 1000L, 2000L);
+ ResponseEntity> result = controller.generateReport(1, 1000L, 2000L);
- assertThat(result).containsExactly(1, 2, 3);
+ assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(result.getBody()).contains(doc);
verify(reportService).buildReport(1, 1000L, 2000L);
}
diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/services/EmailServiceTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/services/EmailServiceTest.java
index a2ac7f9c4..175747179 100644
--- a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/services/EmailServiceTest.java
+++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/services/EmailServiceTest.java
@@ -42,6 +42,9 @@ class EmailServiceTest {
@Mock
private ConflictMonitorApiProperties props;
+ @Mock
+ private PostgresService postgresService;
+
@InjectMocks
private EmailService emailService;
@@ -237,4 +240,34 @@ void testGetNotificationEmailList() {
// TODO: Test underlying logic when method is further implemented
assertTrue(result.isEmpty());
}
+
+ @Test
+ void testGetUsersForConflictMonitorReports() {
+ List mockUsers = List.of("user1@example.com", "user2@example.com");
+ when(postgresService.getUsersByNotificationType("Conflict Monitor Reports")).thenReturn(mockUsers);
+
+ List result = emailService.getUsersForConflictMonitorReports();
+
+ assertNotNull(result);
+ assertEquals(2, result.size());
+ assertTrue(result.contains("user1@example.com"));
+ assertTrue(result.contains("user2@example.com"));
+ verify(postgresService, times(1)).getUsersByNotificationType("Conflict Monitor Reports");
+ }
+
+ @Test
+ void testGetAllowedIntersectionIdsByEmail() {
+ String email = "user@example.com";
+ List mockIntersectionIds = List.of(1, 2, 3);
+ when(postgresService.getAllowedIntersectionIdsByEmail(email)).thenReturn(mockIntersectionIds);
+
+ List result = emailService.getAllowedIntersectionIdsByEmail(email);
+
+ assertNotNull(result);
+ assertEquals(3, result.size());
+ assertTrue(result.contains(1));
+ assertTrue(result.contains(2));
+ assertTrue(result.contains(3));
+ verify(postgresService, times(1)).getAllowedIntersectionIdsByEmail(email);
+ }
}
\ No newline at end of file
diff --git a/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/tasks/ReportEmailTaskTest.java b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/tasks/ReportEmailTaskTest.java
new file mode 100644
index 000000000..5140f2e08
--- /dev/null
+++ b/services/intersection-api/api/src/test/java/us/dot/its/jpo/ode/api/tasks/ReportEmailTaskTest.java
@@ -0,0 +1,125 @@
+package us.dot.its.jpo.ode.api.tasks;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import us.dot.its.jpo.ode.api.services.EmailService;
+import us.dot.its.jpo.ode.api.accessors.reports.ReportRepository;
+import us.dot.its.jpo.ode.api.accessors.map.ProcessedMapRepository;
+
+import java.time.Instant;
+import java.time.ZonedDateTime;
+import java.util.*;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+class ReportEmailTaskTest {
+
+ @Mock
+ private EmailService emailService;
+
+ @Mock
+ private ReportRepository reportRepo;
+
+ @Mock
+ private ProcessedMapRepository processedMapRepo;
+
+ @InjectMocks
+ private ReportEmailTask reportEmailTask;
+
+ @BeforeEach
+ void setUp() {
+ MockitoAnnotations.openMocks(this);
+ }
+
+ @Test
+ void testSendWeeklyReportEmails() {
+ // Arrange
+ List mockUsers = List.of("user1@example.com", "user2@example.com");
+ when(emailService.getUsersForConflictMonitorReports()).thenReturn(mockUsers);
+
+ List mockIntersectionIds = List.of(1, 2, 3);
+ when(emailService.getAllowedIntersectionIdsByEmail(anyString())).thenReturn(mockIntersectionIds);
+
+ // Act
+ reportEmailTask.sendWeeklyReportEmails();
+
+ // Assert
+ verify(emailService, times(2)).getAllowedIntersectionIdsByEmail(anyString());
+ verify(emailService, times(2)).sendSimpleMessage(anyString(), anyString(), anyString());
+ }
+
+ @Test
+ void testSendEmailsForReportsInRange() {
+ // Arrange
+ Instant startTime = Instant.now().minusSeconds(604800); // 1 week ago
+ Instant stopTime = Instant.now();
+
+ List mockUsers = List.of("user1@example.com");
+ when(emailService.getUsersForConflictMonitorReports()).thenReturn(mockUsers);
+
+ List mockIntersectionIds = List.of(1);
+ when(emailService.getAllowedIntersectionIdsByEmail("user1@example.com")).thenReturn(mockIntersectionIds);
+
+ // Act
+ reportEmailTask.sendEmailsForReportsInRange(startTime, stopTime);
+
+ // Assert
+ verify(emailService, times(1)).getAllowedIntersectionIdsByEmail("user1@example.com");
+ verify(emailService, times(1)).sendSimpleMessage(anyString(), anyString(), anyString());
+ }
+
+ @Test
+ void testFetchReportsForIntersections() {
+ // Arrange
+ Instant startTime = Instant.now().minusSeconds(604800); // 1 week ago
+ Instant stopTime = Instant.now();
+ Map reportCache = new HashMap<>();
+
+ List intersectionIds = List.of(1, 2);
+
+ // Act
+ List result = reportEmailTask.fetchReportsForIntersections(intersectionIds, startTime, stopTime,
+ reportCache);
+
+ // Assert
+ assertNotNull(result);
+ ArgumentCaptor intersectionIdCaptor = ArgumentCaptor.forClass(Integer.class);
+ ArgumentCaptor startTimeCaptor = ArgumentCaptor.forClass(Long.class);
+ ArgumentCaptor endTimeCaptor = ArgumentCaptor.forClass(Long.class);
+
+ verify(reportRepo, times(intersectionIds.size())).findAll(
+ eq(null), // Assuming reportName is null in this case
+ intersectionIdCaptor.capture(),
+ startTimeCaptor.capture(),
+ endTimeCaptor.capture(),
+ eq(true) // Assuming includeReportContents is true
+ );
+
+ // Verify captured arguments
+ assertEquals(intersectionIds, intersectionIdCaptor.getAllValues());
+ assertTrue(startTimeCaptor.getAllValues().stream().allMatch(time -> time.equals(startTime.toEpochMilli())));
+ assertTrue(endTimeCaptor.getAllValues().stream().allMatch(time -> time.equals(stopTime.toEpochMilli())));
+
+ }
+
+ @Test
+ void testConstructEmailBody() {
+ // Arrange
+ Map reportCache = Map.of(1, "Report 1", 2, "Report 2");
+ List validIntersectionIds = List.of(1, 2);
+ Instant startTime = Instant.now().minusSeconds(604800); // 1 week ago
+ Instant stopTime = Instant.now();
+
+ // Act
+ String emailBody = reportEmailTask.constructEmailBody(validIntersectionIds, reportCache, startTime, stopTime);
+
+ // Assert
+ assertTrue(emailBody.contains("Report 1"));
+ assertTrue(emailBody.contains("Report 2"));
+ }
+}
\ No newline at end of file
diff --git a/services/rsu-status-monitor/README.md b/services/rsu-status-monitor/README.md
new file mode 100644
index 000000000..5b9762c0f
--- /dev/null
+++ b/services/rsu-status-monitor/README.md
@@ -0,0 +1,50 @@
+# RSU Status Monitor
+
+## Overview
+
+The RSU Status Monitor is a service designed to track and report the operational status of Roadside Units (RSUs) within a connected vehicle environment. It collects status data, performs health checks, and provides alerts for any detected issues.
+
+## Features
+
+### Core Monitoring Capabilities
+
+- **Real-time RSU Status Monitoring**: Continuously monitors all accessible Roadside Units (RSUs) in the deployment detailed by the CV Manager PostgreSQL database
+- **Scheduled Data Collection**: Configurable monitoring intervals with fixed rate execution
+- **Parallel Processing**: Utilizes a fixed thread pool (10 threads) with CompletableFuture for concurrent RSU querying
+
+### SNMP Integration
+
+- **SNMP v2 & v3 Support**: Communicates with RSUs using both SNMP v2c (community-based) and SNMP v3 (username/password with authentication and encryption)
+- **Security Protocols**: Implements SHA authentication and AES-128 encryption for secure SNMP v3 communications
+- **Comprehensive OID Mapping**: Supports extensive NTCIP 1218 standard Object Identifiers (OIDs) including:
+ - System status and operational modes
+ - GPS/GNSS positioning data
+ - Temperature and environmental sensors
+ - Message forwarding configurations
+ - Firmware and MIB version information
+ - Clock source and synchronization status
+
+### Statistical Data Collection
+
+The application collects and reports the following RSU statistics:
+
+- **Uptime**: Time in seconds since the RSU last rebooted (`rsuTimeSincePowerOn`)
+- **Temperature**: Internal temperature readings of the RSU hardware (`rsuIntTemp`)
+- **Mode Status**: Operational state including Operational (4), Standby (2), and Off (16) modes (`rsuModeStatus`)
+- **Timestamp**: UTC millisecond-precision timestamps for all collected data
+
+### Database and Event Streaming Integration
+
+- **PostgreSQL Integration**: Retrieves RSU credentials and configuration from a PostgreSQL database using JPA/Hibernate
+- **Multi-RSU Support**: Queries all RSUs with their associated SNMP credentials, intersection mappings, and protocol configurations
+- **Intersection Correlation**: Links RSU data with intersection identifiers for geographic context
+- **Kafka Producer**: Publishes RSU status updates to Kafka topics for downstream processing and analytics
+
+## License Information
+
+Licensed under the Apache License, Version 2.0 (the "License"); you may not use this
+file except in compliance with the License.
+You may obtain a copy of the License at
+Unless required by applicable law or agreed to in writing, software distributed under
+the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either expressed or implied. See the License for the specific language governing
+permissions and limitations under the [License](http://www.apache.org/licenses/LICENSE-2.0).
diff --git a/services/rsu-status-monitor/rsu-status-monitor/.gitignore b/services/rsu-status-monitor/rsu-status-monitor/.gitignore
new file mode 100644
index 000000000..dc6805a22
--- /dev/null
+++ b/services/rsu-status-monitor/rsu-status-monitor/.gitignore
@@ -0,0 +1,40 @@
+HELP.md
+target/
+bin/
+!**/src/main/**/target/
+!**/src/test/**/target/
+
+### Application.yaml Profiles ###
+application-dev.yaml
+
+### STS ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+*.class
+
+### IntelliJ IDEA ###
+.idea
+*.iws
+*.iml
+*.ipr
+
+### NetBeans ###
+/nbproject/private/
+/nbbuild/
+/dist/
+/nbdist/
+/.nb-gradle/
+build/
+!**/src/main/**/build/
+!**/src/test/**/build/
+
+### VS Code ###
+.vscode/\
+
+### Mvn Wrapper ###
+.mvn/wrapper/*
\ No newline at end of file
diff --git a/services/rsu-status-monitor/rsu-status-monitor/mvnw b/services/rsu-status-monitor/rsu-status-monitor/mvnw
new file mode 100644
index 000000000..a1ba1bf55
--- /dev/null
+++ b/services/rsu-status-monitor/rsu-status-monitor/mvnw
@@ -0,0 +1,233 @@
+#!/bin/sh
+# ----------------------------------------------------------------------------
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+# ----------------------------------------------------------------------------
+
+# ----------------------------------------------------------------------------
+# Maven2 Start Up Batch script
+#
+# Required ENV vars:
+# ------------------
+# JAVA_HOME - location of a JDK home dir
+#
+# Optional ENV vars
+# -----------------
+# M2_HOME - location of maven2's installed home dir
+# MAVEN_OPTS - parameters passed to the Java VM when running Maven
+# e.g. to debug Maven itself, use
+# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000
+# MAVEN_SKIP_RC - flag to disable loading of mavenrc files
+# ----------------------------------------------------------------------------
+
+if [ -z "$MAVEN_SKIP_RC" ] ; then
+
+ if [ -f /etc/mavenrc ] ; then
+ . /etc/mavenrc
+ fi
+
+ if [ -f "$HOME/.mavenrc" ] ; then
+ . "$HOME/.mavenrc"
+ fi
+
+fi
+
+# OS specific support. $var _must_ be set to either true or false.
+cygwin=false;
+darwin=false;
+mingw=false
+case "`uname`" in
+ CYGWIN*) cygwin=true ;;
+ MINGW*) mingw=true;;
+ Darwin*) darwin=true
+ #
+ # Look for the Apple JDKs first to preserve the existing behaviour, and then look
+ # for the new JDKs provided by Oracle.
+ #
+ if [ -z "$JAVA_HOME" ] && [ -L /System/Library/Frameworks/JavaVM.framework/Versions/CurrentJDK ] ; then
+ #
+ # Apple JDKs
+ #
+ export JAVA_HOME=/System/Library/Frameworks/JavaVM.framework/Versions/CurrentJDK/Home
+ fi
+
+ if [ -z "$JAVA_HOME" ] && [ -L /System/Library/Java/JavaVirtualMachines/CurrentJDK ] ; then
+ #
+ # Apple JDKs
+ #
+ export JAVA_HOME=/System/Library/Java/JavaVirtualMachines/CurrentJDK/Contents/Home
+ fi
+
+ if [ -z "$JAVA_HOME" ] && [ -L "/Library/Java/JavaVirtualMachines/CurrentJDK" ] ; then
+ #
+ # Oracle JDKs
+ #
+ export JAVA_HOME=/Library/Java/JavaVirtualMachines/CurrentJDK/Contents/Home
+ fi
+
+ if [ -z "$JAVA_HOME" ] && [ -x "/usr/libexec/java_home" ]; then
+ #
+ # Apple JDKs
+ #
+ export JAVA_HOME=`/usr/libexec/java_home`
+ fi
+ ;;
+esac
+
+if [ -z "$JAVA_HOME" ] ; then
+ if [ -r /etc/gentoo-release ] ; then
+ JAVA_HOME=`java-config --jre-home`
+ fi
+fi
+
+if [ -z "$M2_HOME" ] ; then
+ ## resolve links - $0 may be a link to maven's home
+ PRG="$0"
+
+ # need this for relative symlinks
+ while [ -h "$PRG" ] ; do
+ ls=`ls -ld "$PRG"`
+ link=`expr "$ls" : '.*-> \(.*\)$'`
+ if expr "$link" : '/.*' > /dev/null; then
+ PRG="$link"
+ else
+ PRG="`dirname "$PRG"`/$link"
+ fi
+ done
+
+ saveddir=`pwd`
+
+ M2_HOME=`dirname "$PRG"`/..
+
+ # make it fully qualified
+ M2_HOME=`cd "$M2_HOME" && pwd`
+
+ cd "$saveddir"
+ # echo Using m2 at $M2_HOME
+fi
+
+# For Cygwin, ensure paths are in UNIX format before anything is touched
+if $cygwin ; then
+ [ -n "$M2_HOME" ] &&
+ M2_HOME=`cygpath --unix "$M2_HOME"`
+ [ -n "$JAVA_HOME" ] &&
+ JAVA_HOME=`cygpath --unix "$JAVA_HOME"`
+ [ -n "$CLASSPATH" ] &&
+ CLASSPATH=`cygpath --path --unix "$CLASSPATH"`
+fi
+
+# For Migwn, ensure paths are in UNIX format before anything is touched
+if $mingw ; then
+ [ -n "$M2_HOME" ] &&
+ M2_HOME="`(cd "$M2_HOME"; pwd)`"
+ [ -n "$JAVA_HOME" ] &&
+ JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`"
+ # TODO classpath?
+fi
+
+if [ -z "$JAVA_HOME" ]; then
+ javaExecutable="`which javac`"
+ if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then
+ # readlink(1) is not available as standard on Solaris 10.
+ readLink=`which readlink`
+ if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then
+ if $darwin ; then
+ javaHome="`dirname \"$javaExecutable\"`"
+ javaExecutable="`cd \"$javaHome\" && pwd -P`/javac"
+ else
+ javaExecutable="`readlink -f \"$javaExecutable\"`"
+ fi
+ javaHome="`dirname \"$javaExecutable\"`"
+ javaHome=`expr "$javaHome" : '\(.*\)/bin'`
+ JAVA_HOME="$javaHome"
+ export JAVA_HOME
+ fi
+ fi
+fi
+
+if [ -z "$JAVACMD" ] ; then
+ if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ fi
+ else
+ JAVACMD="`which java`"
+ fi
+fi
+
+if [ ! -x "$JAVACMD" ] ; then
+ echo "Error: JAVA_HOME is not defined correctly." >&2
+ echo " We cannot execute $JAVACMD" >&2
+ exit 1
+fi
+
+if [ -z "$JAVA_HOME" ] ; then
+ echo "Warning: JAVA_HOME environment variable is not set."
+fi
+
+CLASSWORLDS_LAUNCHER=org.codehaus.plexus.classworlds.launcher.Launcher
+
+# For Cygwin, switch paths to Windows format before running java
+if $cygwin; then
+ [ -n "$M2_HOME" ] &&
+ M2_HOME=`cygpath --path --windows "$M2_HOME"`
+ [ -n "$JAVA_HOME" ] &&
+ JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"`
+ [ -n "$CLASSPATH" ] &&
+ CLASSPATH=`cygpath --path --windows "$CLASSPATH"`
+fi
+
+# traverses directory structure from process work directory to filesystem root
+# first directory with .mvn subdirectory is considered project base directory
+find_maven_basedir() {
+ local basedir=$(pwd)
+ local wdir=$(pwd)
+ while [ "$wdir" != '/' ] ; do
+ if [ -d "$wdir"/.mvn ] ; then
+ basedir=$wdir
+ break
+ fi
+ wdir=$(cd "$wdir/.."; pwd)
+ done
+ echo "${basedir}"
+}
+
+# concatenates all lines of a file
+concat_lines() {
+ if [ -f "$1" ]; then
+ echo "$(tr -s '\n' ' ' < "$1")"
+ fi
+}
+
+export MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-$(find_maven_basedir)}
+MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS"
+
+# Provide a "standardized" way to retrieve the CLI args that will
+# work with both Windows and non-Windows executions.
+MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $@"
+export MAVEN_CMD_LINE_ARGS
+
+WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain
+
+exec "$JAVACMD" \
+ $MAVEN_OPTS \
+ -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \
+ "-Dmaven.home=${M2_HOME}" "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \
+ ${WRAPPER_LAUNCHER} "$@"
diff --git a/services/rsu-status-monitor/rsu-status-monitor/mvnw.cmd b/services/rsu-status-monitor/rsu-status-monitor/mvnw.cmd
new file mode 100644
index 000000000..2b934e89d
--- /dev/null
+++ b/services/rsu-status-monitor/rsu-status-monitor/mvnw.cmd
@@ -0,0 +1,145 @@
+@REM ----------------------------------------------------------------------------
+@REM Licensed to the Apache Software Foundation (ASF) under one
+@REM or more contributor license agreements. See the NOTICE file
+@REM distributed with this work for additional information
+@REM regarding copyright ownership. The ASF licenses this file
+@REM to you under the Apache License, Version 2.0 (the
+@REM "License"); you may not use this file except in compliance
+@REM with the License. You may obtain a copy of the License at
+@REM
+@REM http://www.apache.org/licenses/LICENSE-2.0
+@REM
+@REM Unless required by applicable law or agreed to in writing,
+@REM software distributed under the License is distributed on an
+@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+@REM KIND, either express or implied. See the License for the
+@REM specific language governing permissions and limitations
+@REM under the License.
+@REM ----------------------------------------------------------------------------
+
+@REM ----------------------------------------------------------------------------
+@REM Maven2 Start Up Batch script
+@REM
+@REM Required ENV vars:
+@REM JAVA_HOME - location of a JDK home dir
+@REM
+@REM Optional ENV vars
+@REM M2_HOME - location of maven2's installed home dir
+@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands
+@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a key stroke before ending
+@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven
+@REM e.g. to debug Maven itself, use
+@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000
+@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files
+@REM ----------------------------------------------------------------------------
+
+@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on'
+@echo off
+@REM enable echoing my setting MAVEN_BATCH_ECHO to 'on'
+@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO%
+
+@REM set %HOME% to equivalent of $HOME
+if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%")
+
+@REM Execute a user defined script before this one
+if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre
+@REM check for pre script, once with legacy .bat ending and once with .cmd ending
+if exist "%HOME%\mavenrc_pre.bat" call "%HOME%\mavenrc_pre.bat"
+if exist "%HOME%\mavenrc_pre.cmd" call "%HOME%\mavenrc_pre.cmd"
+:skipRcPre
+
+@setlocal
+
+set ERROR_CODE=0
+
+@REM To isolate internal variables from possible post scripts, we use another setlocal
+@setlocal
+
+@REM ==== START VALIDATION ====
+if not "%JAVA_HOME%" == "" goto OkJHome
+
+echo.
+echo Error: JAVA_HOME not found in your environment. >&2
+echo Please set the JAVA_HOME variable in your environment to match the >&2
+echo location of your Java installation. >&2
+echo.
+goto error
+
+:OkJHome
+if exist "%JAVA_HOME%\bin\java.exe" goto init
+
+echo.
+echo Error: JAVA_HOME is set to an invalid directory. >&2
+echo JAVA_HOME = "%JAVA_HOME%" >&2
+echo Please set the JAVA_HOME variable in your environment to match the >&2
+echo location of your Java installation. >&2
+echo.
+goto error
+
+@REM ==== END VALIDATION ====
+
+:init
+
+set MAVEN_CMD_LINE_ARGS=%*
+
+@REM Find the project base dir, i.e. the directory that contains the folder ".mvn".
+@REM Fallback to current working directory if not found.
+
+set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR%
+IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir
+
+set EXEC_DIR=%CD%
+set WDIR=%EXEC_DIR%
+:findBaseDir
+IF EXIST "%WDIR%"\.mvn goto baseDirFound
+cd ..
+IF "%WDIR%"=="%CD%" goto baseDirNotFound
+set WDIR=%CD%
+goto findBaseDir
+
+:baseDirFound
+set MAVEN_PROJECTBASEDIR=%WDIR%
+cd "%EXEC_DIR%"
+goto endDetectBaseDir
+
+:baseDirNotFound
+set MAVEN_PROJECTBASEDIR=%EXEC_DIR%
+cd "%EXEC_DIR%"
+
+:endDetectBaseDir
+
+IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig
+
+@setlocal EnableExtensions EnableDelayedExpansion
+for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a
+@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS%
+
+:endReadAdditionalConfig
+
+SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe"
+
+set WRAPPER_JAR="".\.mvn\wrapper\maven-wrapper.jar""
+set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain
+
+%MAVEN_JAVA_EXE% %JVM_CONFIG_MAVEN_PROPS% %MAVEN_OPTS% %MAVEN_DEBUG_OPTS% -classpath %WRAPPER_JAR% "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" %WRAPPER_LAUNCHER% %MAVEN_CMD_LINE_ARGS%
+if ERRORLEVEL 1 goto error
+goto end
+
+:error
+set ERROR_CODE=1
+
+:end
+@endlocal & set ERROR_CODE=%ERROR_CODE%
+
+if not "%MAVEN_SKIP_RC%" == "" goto skipRcPost
+@REM check for post script, once with legacy .bat ending and once with .cmd ending
+if exist "%HOME%\mavenrc_post.bat" call "%HOME%\mavenrc_post.bat"
+if exist "%HOME%\mavenrc_post.cmd" call "%HOME%\mavenrc_post.cmd"
+:skipRcPost
+
+@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on'
+if "%MAVEN_BATCH_PAUSE%" == "on" pause
+
+if "%MAVEN_TERMINATE_CMD%" == "on" exit %ERROR_CODE%
+
+exit /B %ERROR_CODE%
\ No newline at end of file
diff --git a/services/rsu-status-monitor/rsu-status-monitor/pom.xml b/services/rsu-status-monitor/rsu-status-monitor/pom.xml
new file mode 100644
index 000000000..c0cb7fd8f
--- /dev/null
+++ b/services/rsu-status-monitor/rsu-status-monitor/pom.xml
@@ -0,0 +1,273 @@
+
+
+ 4.0.0
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 3.5.4
+
+
+
+ usdot.jpo.ode
+ rsu-status-monitor
+ 0.1.0
+ jar
+ rsu-status-monitor
+ RSU Status Monitor for better RSU management integration with the JPO CV Manager
+
+
+ 22
+ 0.8.11
+ jacoco
+ ${project.basedir}/target/site/jacoco/jacoco.xml
+ java
+ **/*Config.java,**/kafka/KafkaTopics.java,**/snmp/*Properties.java,**/utils/DateJsonMapper.java
+ usdot-jpo-ode
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter
+
+
+ org.apache.logging.log4j
+ log4j-to-slf4j
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+ com.vaadin.external.google
+ android-json
+
+
+
+
+ org.springframework.boot
+ spring-boot-devtools
+ runtime
+
+
+ org.springframework.boot
+ spring-boot-starter-data-rest
+
+
+ org.springframework.boot
+ spring-boot-starter-data-jpa
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+ org.springframework.boot
+ spring-boot-configuration-processor
+ true
+
+
+ org.springframework.boot
+ spring-boot-starter-websocket
+
+
+ org.springframework.boot
+ spring-boot-starter-actuator
+
+
+ org.springframework.kafka
+ spring-kafka
+
+
+ org.springframework.kafka
+ spring-kafka-test
+ test
+
+
+
+
+ org.projectlombok
+ lombok
+ 1.18.30
+ provided
+
+
+ org.apache.commons
+ commons-lang3
+ 3.14.0
+
+
+ com.fasterxml.jackson.datatype
+ jackson-datatype-jsr310
+
+
+ com.fasterxml.jackson.dataformat
+ jackson-dataformat-xml
+ 2.19.0
+
+
+ org.jacoco
+ jacoco-maven-plugin
+ ${jacoco.version}
+ test
+
+
+ org.apache.kafka
+ kafka-streams
+
+
+ org.snmp4j
+ snmp4j
+ 3.9.3
+
+
+ org.postgresql
+ postgresql
+ runtime
+
+
+ org.locationtech.jts
+ jts-core
+ 1.19.0
+
+
+
+
+ usdot.jpo.ode
+ j2735-2024-ffm-lib
+ 2.0.2
+
+
+
+ usdot.jpo.ode
+ jpo-geojsonconverter
+ 3.2.0
+
+
+
+ org.apache.kafka
+ kafka-streams
+
+
+ org.apache.kafka
+ kafka-streams-test-utils
+
+
+
+
+
+ usdot.jpo.ode
+ jpo-conflictmonitor
+ 3.1.1
+
+
+
+
+ ${project.artifactId}
+
+
+
+ org.sonarsource.scanner.maven
+ sonar-maven-plugin
+ 3.10.0.2594
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+ build-info
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 3.2.5
+
+ all
+ exit
+
+
+
+ @{argLine}
+ -XX:+EnableDynamicAgentLoading
+
+
+
+
+
+ org.jacoco
+ jacoco-maven-plugin
+ ${jacoco.version}
+
+
+ default-prepare-agent
+
+ prepare-agent
+
+
+
+ default-report
+
+ report
+
+
+
+ **/*Config.class
+ **/kafka/KafkaTopics.class
+ **/snmp/*Properties.class
+ **/utils/DateJsonMapper.class
+
+
+
+
+
+
+
+
+
+
+
+ package-jar
+
+
+
+ org.apache.maven.plugins
+ maven-jar-plugin
+ 3.2.0
+
+
+
+ true
+ lib/
+ us.dot.its.jpo.rsustatusmonitor.RsuStatusMonitorApplication
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+ repackage
+ none
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/services/rsu-status-monitor/rsu-status-monitor/settings.xml b/services/rsu-status-monitor/rsu-status-monitor/settings.xml
new file mode 100644
index 000000000..3d1158913
--- /dev/null
+++ b/services/rsu-status-monitor/rsu-status-monitor/settings.xml
@@ -0,0 +1,28 @@
+
+
+
+ default
+
+
+
+ github
+ usdot-jpo-ode
+ ${env.MAVEN_GITHUB_TOKEN}
+
+
+
+
+ default
+
+
+ github
+ GitHub Apache Maven Packages
+ https://maven.pkg.github.com/${env.MAVEN_GITHUB_ORG}/*
+
+ false
+
+
+
+
+
+
\ No newline at end of file
diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/RsuStatusMonitorApplication.java b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/RsuStatusMonitorApplication.java
new file mode 100644
index 000000000..9727dff52
--- /dev/null
+++ b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/RsuStatusMonitorApplication.java
@@ -0,0 +1,33 @@
+package us.dot.its.jpo.rsustatusmonitor;
+
+import javax.management.InstanceAlreadyExistsException;
+import javax.management.MBeanRegistrationException;
+import javax.management.MalformedObjectNameException;
+import javax.management.NotCompliantMBeanException;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.CommandLineRunner;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.kafka.annotation.EnableKafka;
+import org.springframework.scheduling.annotation.EnableScheduling;
+import org.springframework.context.annotation.Bean;
+
+@SpringBootApplication
+@EnableKafka
+@EnableScheduling
+@EnableConfigurationProperties
+@Slf4j
+public class RsuStatusMonitorApplication {
+
+ public static void main(String[] args) throws MalformedObjectNameException, InterruptedException,
+ InstanceAlreadyExistsException, MBeanRegistrationException, NotCompliantMBeanException {
+ SpringApplication.run(RsuStatusMonitorApplication.class, args);
+ }
+
+ @Bean
+ CommandLineRunner init(RsuStatusMonitorProperties rsuStatusMonitorProperties) {
+ return args -> {
+ };
+ }
+}
diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/RsuStatusMonitorProperties.java b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/RsuStatusMonitorProperties.java
new file mode 100644
index 000000000..40e6323df
--- /dev/null
+++ b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/RsuStatusMonitorProperties.java
@@ -0,0 +1,32 @@
+package us.dot.its.jpo.rsustatusmonitor;
+
+import jakarta.annotation.PostConstruct;
+import lombok.Data;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.info.BuildProperties;
+import org.springframework.stereotype.Component;
+
+@Component
+@Data
+@Slf4j
+public class RsuStatusMonitorProperties {
+
+ final BuildProperties buildProperties;
+
+ @Autowired
+ public RsuStatusMonitorProperties(BuildProperties buildProperties) {
+ this.buildProperties = buildProperties;
+ }
+
+ @PostConstruct
+ void initialize() {
+ log.info("groupId: {}", buildProperties.getGroup());
+ log.info("artifactId: {}", buildProperties.getArtifact());
+ log.info("version: {}", buildProperties.getVersion());
+ }
+
+ public String getVersion() {
+ return buildProperties.getVersion();
+ }
+}
diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/config/AsyncConfig.java b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/config/AsyncConfig.java
new file mode 100644
index 000000000..fad11074b
--- /dev/null
+++ b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/config/AsyncConfig.java
@@ -0,0 +1,25 @@
+package us.dot.its.jpo.rsustatusmonitor.config;
+
+import java.util.concurrent.Executor;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.annotation.EnableAsync;
+import org.springframework.scheduling.annotation.EnableScheduling;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+
+@Configuration
+@EnableAsync
+@EnableScheduling
+public class AsyncConfig {
+
+ @Bean
+ public Executor taskExecutor() {
+ ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
+ executor.setCorePoolSize(5); // number of core threads
+ executor.setMaxPoolSize(10); // maximum allowed threads
+ executor.setQueueCapacity(1000); // queue before rejecting
+ executor.setThreadNamePrefix("async-exec-");
+ executor.initialize();
+ return executor;
+ }
+}
\ No newline at end of file
diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/config/KafkaProducerConfig.java b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/config/KafkaProducerConfig.java
new file mode 100644
index 000000000..5202e7537
--- /dev/null
+++ b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/config/KafkaProducerConfig.java
@@ -0,0 +1,38 @@
+package us.dot.its.jpo.rsustatusmonitor.config;
+
+import java.util.Map;
+import org.apache.kafka.clients.producer.ProducerConfig;
+import org.apache.kafka.common.serialization.StringSerializer;
+import org.springframework.boot.autoconfigure.kafka.KafkaProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.kafka.core.DefaultKafkaProducerFactory;
+import org.springframework.kafka.core.KafkaTemplate;
+import org.springframework.kafka.core.ProducerFactory;
+
+@Configuration
+public class KafkaProducerConfig {
+
+ private final KafkaProperties kafkaProperties;
+
+ public KafkaProducerConfig(KafkaProperties kafkaProperties) {
+ this.kafkaProperties = kafkaProperties;
+ }
+
+ @Bean
+ public ProducerFactory producerFactory() {
+ // Get all producer properties from KafkaProperties (includes SASL, SSL, etc.)
+ Map configProps = kafkaProperties.buildProducerProperties(null);
+
+ // Override serializers (these are required for our String producer)
+ configProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
+ configProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
+
+ return new DefaultKafkaProducerFactory<>(configProps);
+ }
+
+ @Bean
+ public KafkaTemplate kafkaTemplate() {
+ return new KafkaTemplate<>(producerFactory());
+ }
+}
diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/kafka/KafkaTopics.java b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/kafka/KafkaTopics.java
new file mode 100644
index 000000000..44579dc15
--- /dev/null
+++ b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/kafka/KafkaTopics.java
@@ -0,0 +1,15 @@
+package us.dot.its.jpo.rsustatusmonitor.kafka;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * Configuration class for Kafka topics.
+ */
+@Configuration
+@ConfigurationProperties(prefix = "rsu-status-monitor.kafka.topics")
+@Data
+public class KafkaTopics {
+ private String monitoringStatus;
+}
diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/derived/RsuData.java b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/derived/RsuData.java
new file mode 100644
index 000000000..2138f8916
--- /dev/null
+++ b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/derived/RsuData.java
@@ -0,0 +1,18 @@
+package us.dot.its.jpo.rsustatusmonitor.models.postgres.derived;
+
+import lombok.AllArgsConstructor;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.Setter;
+import lombok.ToString;
+
+@ToString
+@Setter
+@EqualsAndHashCode
+@Getter
+@AllArgsConstructor
+public class RsuData {
+ private int rsu_id;
+ private String ipv4_address;
+ private String intersection_id;
+}
\ No newline at end of file
diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/derived/RsuSnmpCredentials.java b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/derived/RsuSnmpCredentials.java
new file mode 100644
index 000000000..4d79121b6
--- /dev/null
+++ b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/derived/RsuSnmpCredentials.java
@@ -0,0 +1,22 @@
+package us.dot.its.jpo.rsustatusmonitor.models.postgres.derived;
+
+import lombok.AllArgsConstructor;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.Setter;
+import lombok.ToString;
+
+@ToString
+@Setter
+@EqualsAndHashCode
+@Getter
+@AllArgsConstructor
+public class RsuSnmpCredentials {
+ private int rsu_id;
+ private String ipv4_address;
+ private String username;
+ private String password;
+ private String encrypt_password;
+ private String protocol_code;
+ private String intersection_id;
+}
diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/keys/SnmpMsgfwdConfigId.java b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/keys/SnmpMsgfwdConfigId.java
new file mode 100644
index 000000000..db78b6f76
--- /dev/null
+++ b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/keys/SnmpMsgfwdConfigId.java
@@ -0,0 +1,16 @@
+package us.dot.its.jpo.rsustatusmonitor.models.postgres.keys;
+
+import java.io.Serializable;
+import lombok.AllArgsConstructor;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+
+@EqualsAndHashCode
+@NoArgsConstructor
+@AllArgsConstructor
+public class SnmpMsgfwdConfigId implements Serializable {
+ // Composite primary key elements for the snmp_msgfwd_config table
+ private int rsu_id;
+ private int msgfwd_type;
+ private int snmp_index;
+}
diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/Intersections.java b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/Intersections.java
new file mode 100644
index 000000000..d48d3286a
--- /dev/null
+++ b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/Intersections.java
@@ -0,0 +1,28 @@
+package us.dot.its.jpo.rsustatusmonitor.models.postgres.tables;
+
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.Setter;
+import lombok.ToString;
+import org.locationtech.jts.geom.Geometry;
+
+@ToString
+@Setter
+@EqualsAndHashCode
+@Getter
+@Entity
+@Table(name = "intersections")
+public class Intersections {
+
+ @Id
+ private int intersection_id;
+ private String intersection_number;
+ private Geometry ref_pt;
+ private Geometry bbox;
+ private String intersection_name;
+ private String origin_ip;
+
+}
\ No newline at end of file
diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/RsuIntersection.java b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/RsuIntersection.java
new file mode 100644
index 000000000..710de7a48
--- /dev/null
+++ b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/RsuIntersection.java
@@ -0,0 +1,22 @@
+package us.dot.its.jpo.rsustatusmonitor.models.postgres.tables;
+
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.Setter;
+import lombok.ToString;
+
+@ToString
+@Setter
+@EqualsAndHashCode
+@Getter
+@Entity
+@Table(name = "rsu_intersection")
+public class RsuIntersection {
+ @Id
+ private int rsu_intersection_id;
+ private int rsu_id;
+ private int intersection_id;
+}
\ No newline at end of file
diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/Rsus.java b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/Rsus.java
new file mode 100644
index 000000000..5639095b6
--- /dev/null
+++ b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/Rsus.java
@@ -0,0 +1,35 @@
+package us.dot.its.jpo.rsustatusmonitor.models.postgres.tables;
+
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.Setter;
+import lombok.ToString;
+import org.locationtech.jts.geom.Geometry;
+
+@ToString
+@Setter
+@EqualsAndHashCode
+@Getter
+@Entity
+@Table(name = "rsus")
+public class Rsus {
+
+ @Id
+ private int rsu_id;
+ private Geometry geography;
+ private float milepost;
+ private String ipv4_address;
+ private String serial_number;
+ private String iss_scms_id;
+ private String primary_route;
+ private int model;
+ private int credential_id;
+ private int snmp_credential_id;
+ private int snmp_protocol_id;
+ private int firmware_version;
+ private int target_firmware_version;
+
+}
\ No newline at end of file
diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/SnmpCredentials.java b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/SnmpCredentials.java
new file mode 100644
index 000000000..b8b8646e8
--- /dev/null
+++ b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/SnmpCredentials.java
@@ -0,0 +1,25 @@
+package us.dot.its.jpo.rsustatusmonitor.models.postgres.tables;
+
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.Setter;
+import lombok.ToString;
+
+@ToString
+@Setter
+@EqualsAndHashCode
+@Getter
+@Entity
+@Table(name = "snmp_credentials")
+public class SnmpCredentials {
+ @Id
+ private int snmp_credential_id;
+ private String username;
+ private String password;
+ private String encrypt_password;
+ private String nickname;
+
+}
\ No newline at end of file
diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/SnmpMsgfwdConfig.java b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/SnmpMsgfwdConfig.java
new file mode 100644
index 000000000..336a618ff
--- /dev/null
+++ b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/SnmpMsgfwdConfig.java
@@ -0,0 +1,35 @@
+package us.dot.its.jpo.rsustatusmonitor.models.postgres.tables;
+
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.IdClass;
+import jakarta.persistence.Table;
+import java.time.LocalDateTime;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.Setter;
+import lombok.ToString;
+import us.dot.its.jpo.rsustatusmonitor.models.postgres.keys.SnmpMsgfwdConfigId;
+
+@ToString
+@Setter
+@EqualsAndHashCode
+@Getter
+@Entity
+@IdClass(SnmpMsgfwdConfigId.class)
+@Table(name = "snmp_msgfwd_config")
+public class SnmpMsgfwdConfig {
+ @Id
+ private int rsu_id;
+ @Id
+ private int msgfwd_type;
+ @Id
+ private int snmp_index;
+ private String message_type;
+ private String dest_ipv4;
+ private int dest_port;
+ private LocalDateTime start_datetime;
+ private LocalDateTime end_datetime;
+ private boolean active;
+ private boolean security;
+}
\ No newline at end of file
diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/SnmpMsgfwdType.java b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/SnmpMsgfwdType.java
new file mode 100644
index 000000000..f8b7ac613
--- /dev/null
+++ b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/SnmpMsgfwdType.java
@@ -0,0 +1,22 @@
+package us.dot.its.jpo.rsustatusmonitor.models.postgres.tables;
+
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.Setter;
+import lombok.ToString;
+
+@ToString
+@Setter
+@EqualsAndHashCode
+@Getter
+@Entity
+@Table(name = "snmp_msgfwd_type")
+public class SnmpMsgfwdType {
+ @Id
+ private int snmp_msgfwd_type_id;
+ private String name;
+
+}
\ No newline at end of file
diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/SnmpProtocols.java b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/SnmpProtocols.java
new file mode 100644
index 000000000..069b09a31
--- /dev/null
+++ b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/SnmpProtocols.java
@@ -0,0 +1,22 @@
+package us.dot.its.jpo.rsustatusmonitor.models.postgres.tables;
+
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.Setter;
+import lombok.ToString;
+
+@ToString
+@Setter
+@EqualsAndHashCode
+@Getter
+@Entity
+@Table(name = "snmp_protocols")
+public class SnmpProtocols {
+ @Id
+ private int snmp_protocol_id;
+ private String protocol_code;
+ private String nickname;
+}
\ No newline at end of file
diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/snmp/OID.java b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/snmp/OID.java
new file mode 100644
index 000000000..bf083b056
--- /dev/null
+++ b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/snmp/OID.java
@@ -0,0 +1,26 @@
+package us.dot.its.jpo.rsustatusmonitor.models.snmp;
+
+import lombok.AllArgsConstructor;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.Setter;
+import lombok.ToString;
+
+@ToString
+@Setter
+@EqualsAndHashCode
+@Getter
+@AllArgsConstructor
+public class OID {
+
+ private String name;
+ private OID_TYPE type;
+ private String oid;
+}
+
+enum OID_TYPE {
+ SCALAR,
+ NODE,
+ TABLE,
+ ROW
+}
diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/snmp/OIDMap.java b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/snmp/OIDMap.java
new file mode 100644
index 000000000..74d8ff320
--- /dev/null
+++ b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/snmp/OIDMap.java
@@ -0,0 +1,150 @@
+package us.dot.its.jpo.rsustatusmonitor.models.snmp;
+
+import java.util.Map;
+
+public class OIDMap {
+
+ // This is a map of existing OIDs supported by RSU SNMP Units. This list is not
+ // exhaustive and will need to be updated with additional codes as needs arrive.
+ // There is not currently a good library available for MIB parsing for this
+ // project, so this maps allows loading OIDS by name for now.
+ public static Map oids = Map.ofEntries(
+ Map.entry("rsuModeStatus",
+ new OID("rsuModeStatus", OID_TYPE.SCALAR, "1.3.6.1.4.1.1.1206.4.2.18.16.3.0")),
+ Map.entry("rsuMode", new OID("rsuMode", OID_TYPE.NODE, "1.3.6.1.4.1.1.1206.4.2.18.16.2.0")),
+ Map.entry("rsuGnssStatus",
+ new OID("rsuGnssStatus", OID_TYPE.SCALAR, "1.3.6.1.4.1.1206.4.2.18.2.1.0")),
+ Map.entry("rsuGnssAugmentation",
+ new OID("rsuGnssAugmentation", OID_TYPE.SCALAR,
+ "1.3.6.1.4.1.1206.4.2.18.2.2.0")),
+ Map.entry("rsuGnssOutputString",
+ new OID("rsuGnssOutputString", OID_TYPE.SCALAR,
+ "1.3.6.1.4.1.1206.4.2.18.6.5.0")),
+ Map.entry("rsuGnssLat",
+ new OID("rsuGnssLat", OID_TYPE.SCALAR, "1.3.6.1.4.1.1206.4.2.18.6.6.0")),
+ Map.entry("rsuGnssLon",
+ new OID("rsuGnssLon", OID_TYPE.SCALAR, "1.3.6.1.4.1.1206.4.2.18.6.7.0")),
+ Map.entry("rsuGnssElv",
+ new OID("rsuGnssElv", OID_TYPE.SCALAR, "1.3.6.1.4.1.1206.4.2.18.6.8.0")),
+ Map.entry("rsuLocationLat",
+ new OID("rsuLocationLat", OID_TYPE.SCALAR, "1.3.6.1.4.1.1206.4.2.18.13.5.0")),
+ Map.entry("rsuLocationLon",
+ new OID("rsuLocationLon", OID_TYPE.SCALAR, "1.3.6.1.4.1.1206.4.2.18.13.6.0")),
+ Map.entry("rsuLocationElv",
+ new OID("rsuLocationElv", OID_TYPE.SCALAR, "1.3.6.1.4.1.1206.4.2.18.13.7.0")),
+ Map.entry("rsuGnssMaxDeviation",
+ new OID("rsuGnssMaxDeviation", OID_TYPE.SCALAR,
+ "1.3.6.1.4.1.1206.4.2.18.6.9.0")),
+ Map.entry("rsuLocationDeviation",
+ new OID("rsuLocationDeviation", OID_TYPE.SCALAR,
+ "1.3.6.1.4.1.1206.4.2.18.6.10.0")),
+ Map.entry("rsuGnssPositionError",
+ new OID("rsuGnssPositionError", OID_TYPE.SCALAR,
+ "1.3.6.1.4.1.1.1206.4.2.18.6.11.0")),
+ Map.entry("rsuStatus",
+ new OID("rsuStatus", OID_TYPE.SCALAR, "1.3.6.1.4.1.1.1206.4.2.18.16.10.0")),
+ Map.entry("rsuFirmwareVersion",
+ new OID("rsuFirmwareVersion", OID_TYPE.SCALAR,
+ "1.3.6.1.4.1.1206.4.2.18.13.2.0")),
+ Map.entry("rsuMibVersion",
+ new OID("rsuMibVersion", OID_TYPE.SCALAR, "1.3.6.1.4.1.1206.4.2.18.13.1.0")),
+ Map.entry("rsuID", new OID("rsuID", OID_TYPE.SCALAR, "1.3.6.1.4.1.1206.4.2.18.13.4.0")),
+ Map.entry("rsuReboot",
+ new OID("rsuReboot", OID_TYPE.SCALAR, "1.3.6.1.4.1.1.1206.4.2.18.16.4.0")),
+ Map.entry("rsuTimeSincePowerOn",
+ new OID("rsuTimeSincePowerOn", OID_TYPE.SCALAR,
+ "1.3.6.1.4.1.1206.4.2.18.12.1.0")),
+ Map.entry("rsuIntTemp",
+ new OID("rsuIntTemp", OID_TYPE.SCALAR, "1.3.6.1.4.1.1206.4.2.18.12.2.0")),
+ Map.entry("rsuClockSource",
+ new OID("rsuClockSource", OID_TYPE.SCALAR, "1.3.6.1.4.1.1.1206.4.2.18.16.5.0")),
+ Map.entry("rsuClockSourceStatus",
+ new OID("rsuClockSourceStatus", OID_TYPE.SCALAR,
+ "1.3.6.1.4.1.1.1206.4.2.18.16.6.0")),
+ Map.entry("rsuClockSourceTimeout",
+ new OID("rsuClockSourceTimeout", OID_TYPE.SCALAR,
+ "1.3.6.1.4.1.1.1206.4.2.18.16.7.0")),
+ Map.entry("rsuClockDeviationTolerance",
+ new OID("rsuClockDeviationTolerance", OID_TYPE.SCALAR,
+ "1.3.6.1.4.1.1.1206.4.2.18.16.9.0")),
+ Map.entry("maxRsuReceivedMsgs",
+ new OID("maxRsuReceivedMsgs", OID_TYPE.SCALAR,
+ "1.3.6.1.4.1.1206.4.2.18.5.1")),
+ Map.entry("rsuReceivedMsgTable",
+ new OID("rsuReceivedMsgTable", OID_TYPE.TABLE,
+ "1.3.6.1.4.1.1206.4.2.18.5.2")),
+ Map.entry("rsuReceivedMsgEntry",
+ new OID("rsuReceivedMsgEntry", OID_TYPE.ROW,
+ "1.3.6.1.4.1.1206.4.2.18.5.2.1")),
+ Map.entry("rsuReceivedMsgPsid",
+ new OID("rsuReceivedMsgPsid", OID_TYPE.SCALAR,
+ "1.3.6.1.4.1.1206.4.2.18.5.2.1.2")),
+ Map.entry("rsuReceivedMsgDestIpAddr",
+ new OID("rsuReceivedMsgDestIpAddr", OID_TYPE.SCALAR,
+ "1.3.6.1.4.1.1206.4.2.18.5.2.1.3")),
+ Map.entry("rsuReceivedMsgDestPort",
+ new OID("rsuReceivedMsgDestPort", OID_TYPE.SCALAR,
+ "1.3.6.1.4.1.1206.4.2.18.5.2.1.4")),
+ Map.entry("rsuReceivedMsgProtocol",
+ new OID("rsuReceivedMsgProtocol", OID_TYPE.SCALAR,
+ "1.3.6.1.4.1.1206.4.2.18.5.2.1.5")),
+ Map.entry("rsuReceivedMsgRssi",
+ new OID("rsuReceivedMsgRssi", OID_TYPE.SCALAR,
+ "1.3.6.1.4.1.1206.4.2.18.5.2.1.6")),
+ Map.entry("rsuReceivedMsgInterval",
+ new OID("rsuReceivedMsgInterval", OID_TYPE.SCALAR,
+ "1.3.6.1.4.1.1206.4.2.18.5.2.1.7")),
+ Map.entry("rsuReceivedMsgDeliveryStart",
+ new OID("rsuReceivedMsgDeliveryStart", OID_TYPE.SCALAR,
+ "1.3.6.1.4.1.1206.4.2.18.5.2.1.8")),
+ Map.entry("rsuReceivedMsgDeliveryStop",
+ new OID("rsuReceivedMsgDeliveryStop", OID_TYPE.SCALAR,
+ "1.3.6.1.4.1.1206.4.2.18.5.2.1.9")),
+ Map.entry("rsuReceivedMsgStatus",
+ new OID("rsuReceivedMsgStatus", OID_TYPE.SCALAR,
+ "1.3.6.1.4.1.1206.4.2.18.5.2.1.10")),
+ Map.entry("rsuReceivedMsgSecure",
+ new OID("rsuReceivedMsgSecure", OID_TYPE.SCALAR,
+ "1.3.6.1.4.1.1206.4.2.18.5.2.1.11")),
+ Map.entry("rsuReceivedMsgAuthMsgInterval",
+ new OID("rsuReceivedMsgAuthMsgInterval", OID_TYPE.SCALAR,
+ "1.3.6.1.4.1.1206.4.2.18.5.2.1.12")),
+ Map.entry("maxRsuMsgRepeat",
+ new OID("maxRsuMsgRepeat", OID_TYPE.SCALAR, "1.3.6.1.4.1.1206.4.2.18.3.1.0")),
+ Map.entry("rsuMsgRepeatStatusTable",
+ new OID("rsuMsgRepeatStatusTable", OID_TYPE.TABLE,
+ "1.3.6.1.4.1.1206.4.2.18.3.2.0")),
+ Map.entry("rsuMsgRepeatPsid",
+ new OID("rsuMsgRepeatPsid", OID_TYPE.SCALAR,
+ "1.3.6.1.4.1.1206.4.2.18.3.2.1.2.0")),
+ Map.entry("rsuMsgRepeatTxChannel",
+ new OID("rsuMsgRepeatTxChannel", OID_TYPE.SCALAR,
+ "1.3.6.1.4.1.1206.4.2.18.3.2.1.3.0")),
+ Map.entry("rsuMsgRepeatTxInterval",
+ new OID("rsuMsgRepeatTxInterval", OID_TYPE.SCALAR,
+ "1.3.6.1.4.1.1206.4.2.18.3.2.1.4.0")),
+ Map.entry("rsuMsgRepeatDeliveryStart",
+ new OID("rsuMsgRepeatDeliveryStart", OID_TYPE.SCALAR,
+ "1.3.6.1.4.1.1206.4.2.18.3.2.1.5.0")),
+ Map.entry("rsuMsgRepeatDeliveryStop",
+ new OID("rsuMsgRepeatDeliveryStop", OID_TYPE.SCALAR,
+ "1.3.6.1.4.1.1206.4.2.18.3.2.1.6.0")),
+ Map.entry("rsuMsgRepeatPayload",
+ new OID("rsuMsgRepeatPayload", OID_TYPE.SCALAR,
+ "1.3.6.1.4.1.1206.4.2.18.3.2.1.7.0")),
+ Map.entry("rsuMsgRepeatEnable",
+ new OID("rsuMsgRepeatEnable", OID_TYPE.SCALAR,
+ "1.3.6.1.4.1.1206.4.2.18.3.2.1.8.0")),
+ Map.entry("rsuMsgRepeatStatus",
+ new OID("rsuMsgRepeatStatus", OID_TYPE.SCALAR,
+ "1.3.6.1.4.1.1206.4.2.18.3.2.1.9.0")),
+ Map.entry("rsuMsgRepeatPriority",
+ new OID("rsuMsgRepeatPriority", OID_TYPE.SCALAR,
+ "1.3.6.1.4.1.1206.4.2.18.3.2.1.10.0")),
+ Map.entry("rsuMsgRepeatOptions",
+ new OID("rsuMsgRepeatOptions", OID_TYPE.SCALAR,
+ "1.3.6.1.4.1.1206.4.2.18.3.2.1.11.0")),
+ Map.entry("rsuMsgRepeatDeleteAll",
+ new OID("rsuMsgRepeatDeleteAll", OID_TYPE.SCALAR,
+ "1.3.6.1.4.1.1206.4.2.18.3.3.0")));
+}
diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/snmp/RsuMsgfwdValues.java b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/snmp/RsuMsgfwdValues.java
new file mode 100644
index 000000000..6bf0c014e
--- /dev/null
+++ b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/snmp/RsuMsgfwdValues.java
@@ -0,0 +1,33 @@
+package us.dot.its.jpo.rsustatusmonitor.models.snmp;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.Generated;
+import lombok.NoArgsConstructor;
+import us.dot.its.jpo.rsustatusmonitor.models.postgres.derived.RsuSnmpCredentials;
+
+// NTCIP-1218
+@Data
+@Generated
+@NoArgsConstructor
+@AllArgsConstructor
+@JsonInclude(JsonInclude.Include.NON_NULL)
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class RsuMsgfwdValues {
+ private RsuSnmpCredentials rsuSnmpCredentials;
+ private String rsuReceivedMsgPsid;
+ private String rsuReceivedMsgDestIpAddr;
+ private Integer rsuReceivedMsgDestPort;
+ private Integer rsuReceivedMsgProtocol;
+ private Integer rsuReceivedMsgRssi;
+ private Integer rsuReceivedMsgInterval;
+ // Formatted in an 8-byte hex string: YYYYMMddHHmmssSS
+ // Example: '2025-01-01 00:00:00.00' would be formatted like '07e9010100000000'
+ private String rsuReceivedMsgDeliveryStart;
+ private String rsuReceivedMsgDeliveryStop;
+ private Integer rsuReceivedMsgStatus;
+ private Integer rsuReceivedMsgSecure;
+ private Integer rsuReceivedMsgAuthMsgInterval;
+}
\ No newline at end of file
diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/snmp/RsuState.java b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/snmp/RsuState.java
new file mode 100644
index 000000000..06dfc70bc
--- /dev/null
+++ b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/models/snmp/RsuState.java
@@ -0,0 +1,39 @@
+package us.dot.its.jpo.rsustatusmonitor.models.snmp;
+
+import lombok.Getter;
+import lombok.Setter;
+
+@Setter
+@Getter
+public class RsuState {
+ /**
+ * Time in UTC Milliseconds that the RSU State was generated
+ */
+ public long timestamp;
+
+ /**
+ * IntersectionID of the intersection corresponding to the RSU
+ */
+ public String intersectionID;
+
+ /**
+ * IP Address of the RSU unit
+ */
+ public String rsuIP;
+
+ /**
+ * Internal Temperature of the RSU Unit
+ */
+ public double temperature;
+
+ /**
+ * Time in seconds since the RSU unit last rebooted
+ */
+ public int uptime;
+
+ /**
+ * The Mode of the RSU. There are 3 common modes, Operational(4), Standby (2)
+ * and Off (16)
+ */
+ public int mode;
+}
diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/services/KafkaProducerService.java b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/services/KafkaProducerService.java
new file mode 100644
index 000000000..2e08b24ed
--- /dev/null
+++ b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/services/KafkaProducerService.java
@@ -0,0 +1,45 @@
+package us.dot.its.jpo.rsustatusmonitor.services;
+
+import org.springframework.kafka.core.KafkaTemplate;
+import org.springframework.stereotype.Service;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import lombok.extern.slf4j.Slf4j;
+import us.dot.its.jpo.geojsonconverter.partitioner.RsuIntersectionKey;
+import us.dot.its.jpo.rsustatusmonitor.kafka.KafkaTopics;
+import us.dot.its.jpo.rsustatusmonitor.models.snmp.RsuState;
+
+@Service
+@Slf4j
+public class KafkaProducerService {
+
+ private final KafkaTemplate kafkaTemplate;
+
+ private ObjectMapper objectMapper;
+
+ private KafkaTopics kafkaTopics;
+
+ public KafkaProducerService(KafkaTemplate kafkaTemplate, ObjectMapper objectMapper,
+ KafkaTopics kafkaTopics) {
+ this.objectMapper = objectMapper;
+ this.kafkaTemplate = kafkaTemplate;
+ this.kafkaTopics = kafkaTopics;
+ }
+
+ public void sendMessage(String topic, String key, String message) {
+ kafkaTemplate.send(topic, key, message);
+ }
+
+ public void sendRsuStatus(RsuIntersectionKey key, RsuState message) {
+ try {
+ String jsonMessage = objectMapper.writeValueAsString(message);
+ String jsonKey = objectMapper.writeValueAsString(key);
+ kafkaTemplate.send(kafkaTopics.getMonitoringStatus(), jsonKey, jsonMessage);
+ } catch (JsonProcessingException e) {
+ log.error("Failed to serialize message" + message);
+ }
+
+ }
+}
diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/services/PostgresService.java b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/services/PostgresService.java
new file mode 100644
index 000000000..d4c7f996c
--- /dev/null
+++ b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/services/PostgresService.java
@@ -0,0 +1,33 @@
+package us.dot.its.jpo.rsustatusmonitor.services;
+
+import jakarta.persistence.EntityManager;
+import jakarta.persistence.PersistenceContext;
+import jakarta.persistence.TypedQuery;
+import java.util.List;
+import org.springframework.stereotype.Service;
+import us.dot.its.jpo.rsustatusmonitor.models.postgres.derived.RsuSnmpCredentials;
+
+@Service
+public class PostgresService {
+
+ @PersistenceContext
+ private EntityManager entityManager;
+
+ // Finds the RSU SNMP credentials for all RSUs, includes the intersection ID if
+ // available
+ private final String findRsuSnmpCredentials = "SELECT new us.dot.its.jpo.rsustatusmonitor.models.postgres.derived.RsuSnmpCredentials( "
+ +
+ "rsu.rsu_id, rsu.ipv4_address, snmp_creds.username, snmp_creds.password, snmp_creds.encrypt_password, snmp_proto.protocol_code, i.intersection_number) "
+ +
+ "FROM Rsus rsu " +
+ "JOIN SnmpCredentials snmp_creds ON rsu.snmp_credential_id = snmp_creds.snmp_credential_id " +
+ "JOIN SnmpProtocols snmp_proto ON rsu.snmp_protocol_id = snmp_proto.snmp_protocol_id " +
+ "LEFT JOIN RsuIntersection ri ON rsu.rsu_id = ri.rsu_id " +
+ "LEFT JOIN Intersections i ON ri.intersection_id = i.intersection_id";
+
+ public List getRsusWithCredentials() {
+ TypedQuery query = entityManager.createQuery(findRsuSnmpCredentials,
+ RsuSnmpCredentials.class);
+ return query.getResultList();
+ }
+}
diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/services/RsuQueryService.java b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/services/RsuQueryService.java
new file mode 100644
index 000000000..50feaa847
--- /dev/null
+++ b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/services/RsuQueryService.java
@@ -0,0 +1,112 @@
+package us.dot.its.jpo.rsustatusmonitor.services;
+
+import java.io.IOException;
+import java.time.Instant;
+
+import org.snmp4j.smi.Variable;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Service;
+
+import lombok.extern.slf4j.Slf4j;
+import us.dot.its.jpo.geojsonconverter.partitioner.RsuIntersectionKey;
+import us.dot.its.jpo.rsustatusmonitor.models.postgres.derived.RsuSnmpCredentials;
+import us.dot.its.jpo.rsustatusmonitor.models.snmp.OID;
+import us.dot.its.jpo.rsustatusmonitor.models.snmp.OIDMap;
+import us.dot.its.jpo.rsustatusmonitor.models.snmp.RsuState;
+import io.micrometer.core.instrument.Counter;
+import io.micrometer.core.instrument.MeterRegistry;
+
+@Service
+@Slf4j
+public class RsuQueryService {
+
+ private SNMPService snmpService;
+ private KafkaProducerService kafkaService;
+ private MeterRegistry meterRegistry;
+
+ @Autowired
+ public RsuQueryService(SNMPService snmpService, KafkaProducerService kafkaService, MeterRegistry meterRegistry) {
+ this.snmpService = snmpService;
+ this.kafkaService = kafkaService;
+ this.meterRegistry = meterRegistry;
+ }
+
+ @Async
+ public void getRsuInformation(RsuSnmpCredentials cred) {
+ String username = cred.getUsername();
+ String password = cred.getPassword();
+ String encPass = cred.getEncrypt_password();
+ String ip = cred.getIpv4_address();
+ String intersectionId = cred.getIntersection_id() != null ? cred.getIntersection_id() : "-1";
+
+ log.info("Pulling SNMP Status for RSU: " + ip + " IntersectionID: " + intersectionId);
+
+ Counter.builder("rsu.snmp.status")
+ .description("Number of snmp status queries by RSU")
+ .tags("rsu_ip", ip)
+ .register(meterRegistry)
+ .increment();
+
+ if (username == null || password == null || ip == null) {
+ log.warn("Cannot pull data from RSU unit. Missing Username, Password, or IP address. RSU ID: " + ip
+ + " Intersection ID: " + intersectionId);
+
+ Counter.builder("rsu.snmp.status.error")
+ .description("Number of snmp status errors queries by RSU")
+ .tags("rsu_ip", ip, "error", "credential-error")
+ .register(meterRegistry)
+ .increment();
+ return;
+ }
+
+ // enc password is not defined for all RSU units. Try using normal password
+ if (encPass == null) {
+ encPass = password;
+ }
+
+ RsuState state = new RsuState();
+ state.timestamp = Instant.now().toEpochMilli();
+ state.rsuIP = ip;
+ state.intersectionID = intersectionId;
+
+ state.uptime = getIntOID(ip, username, password, encPass, OIDMap.oids.get("rsuTimeSincePowerOn"));
+ state.temperature = getIntOID(ip, username, password, encPass, OIDMap.oids.get("rsuIntTemp"));
+ state.mode = getIntOID(ip, username, password, encPass, OIDMap.oids.get("rsuModeStatus"));
+
+ RsuIntersectionKey key = new RsuIntersectionKey();
+ key.setIntersectionId(Integer.parseInt(intersectionId));
+ key.setRsuId(ip);
+ key.setRegion(-1);
+
+ kafkaService.sendRsuStatus(key, state);
+
+ log.info("Retrieved RSU Information for RSU: " + ip + " IntersectionID: " + intersectionId
+ + " Uptime: " + state.uptime + " Temperature: " + state.temperature + " Mode: " + state.mode);
+ }
+
+ public int getIntOID(String ip, String username, String password, String encPass, OID oid) {
+ try {
+ Variable var = snmpService.getSnmpV3Value(ip, username, password, encPass, oid.getOid());
+
+ if (var != null) {
+ return var.toInt();
+ } else {
+ log.warn("Query of OID " + oid.getName() + " for Intersection" + ip + " returned no value");
+ Counter.builder("rsu.snmp.status.error")
+ .description("Number of snmp status errors queries by RSU")
+ .tags("rsu_ip", ip, "error", "no-value", "oid", oid.getName())
+ .register(meterRegistry)
+ .increment();
+ }
+ } catch (IOException e) {
+ log.warn("Unable to Retrieve value for OID: " + oid.getName() + " for Intersection" + ip);
+ Counter.builder("rsu.snmp.status.error")
+ .description("Number of snmp status errors queries by RSU")
+ .tags("rsu_ip", ip, "error", "IO Exception", "oid", oid.getName())
+ .register(meterRegistry)
+ .increment();
+ }
+ return -1;
+ }
+}
\ No newline at end of file
diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/services/SNMPService.java b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/services/SNMPService.java
new file mode 100644
index 000000000..804a0f8c4
--- /dev/null
+++ b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/services/SNMPService.java
@@ -0,0 +1,232 @@
+package us.dot.its.jpo.rsustatusmonitor.services;
+
+import java.util.Map;
+import org.snmp4j.*;
+import org.snmp4j.event.ResponseEvent;
+import org.snmp4j.mp.MPv3;
+import org.snmp4j.mp.SnmpConstants;
+import org.snmp4j.security.AuthSHA;
+import org.snmp4j.security.PrivAES128;
+import org.snmp4j.security.SecurityLevel;
+import org.snmp4j.security.SecurityModels;
+import org.snmp4j.security.SecurityProtocols;
+import org.snmp4j.security.USM;
+import org.snmp4j.security.UsmUser;
+import org.snmp4j.smi.*;
+import org.snmp4j.transport.DefaultUdpTransportMapping;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import lombok.extern.slf4j.Slf4j;
+import us.dot.its.jpo.rsustatusmonitor.snmp.SnmpProperties;
+
+import java.io.IOException;
+
+@Service
+@Slf4j
+public class SNMPService {
+
+ private SnmpProperties properties;
+
+ @Autowired
+ public SNMPService(SnmpProperties properties) {
+ this.properties = properties;
+ }
+
+ // Some RSUs do not require authentication to retrieve information. They may
+ // respond to SNMP V2 requests.
+ public String getSnmpV2Value(String ipAddress, String community, String oid) throws Exception {
+
+ TransportMapping transport = new DefaultUdpTransportMapping();
+ Snmp snmp = new Snmp(transport);
+ transport.listen();
+
+ CommunityTarget target = new CommunityTarget<>();
+ target.setCommunity(new OctetString(community));
+ target.setAddress(new UdpAddress(ipAddress + "/" + properties.getPort()));
+ target.setRetries(properties.getRetries());
+ target.setTimeout(properties.getTimeout());
+ target.setVersion(SnmpConstants.version2c);
+
+ PDU pdu = new PDU();
+ pdu.add(new VariableBinding(new OID(oid)));
+ pdu.setType(PDU.GET);
+
+ ResponseEvent responseEvent = snmp.send(pdu, target);
+ snmp.close();
+
+ if (responseEvent != null && responseEvent.getResponse() != null) {
+ VariableBinding vb = responseEvent.getResponse().get(0);
+ return vb.getVariable().toString();
+ } else {
+ throw new RuntimeException("SNMP GET timed out or returned null.");
+ }
+
+ }
+
+ public Variable getSnmpV3Value(String ip, String username, String authPass, String privPass, String oid)
+ throws IOException {
+
+ TransportMapping transport = new DefaultUdpTransportMapping();
+ Snmp snmp = new Snmp(transport);
+ transport.listen();
+
+ // Add USM and user
+ USM usm = new USM(
+ SecurityProtocols.getInstance(),
+ new OctetString(MPv3.createLocalEngineID()),
+ 0);
+ SecurityModels.getInstance().addSecurityModel(usm);
+
+ // Add Required Security Protocols
+ SecurityProtocols.getInstance().addAuthenticationProtocol(new AuthSHA());
+ SecurityProtocols.getInstance().addPrivacyProtocol(new PrivAES128());
+
+ snmp.getUSM().addUser(
+ new OctetString(username),
+ new UsmUser(
+ new OctetString(username),
+ AuthSHA.ID, new OctetString(authPass),
+ PrivAES128.ID, new OctetString(privPass)));
+
+ // Configure the target
+ UserTarget target = new UserTarget<>();
+ target.setAddress(new UdpAddress(ip + "/" + properties.getPort()));
+ target.setRetries(properties.getRetries());
+ target.setTimeout(properties.getTimeout());
+ target.setVersion(SnmpConstants.version3);
+ target.setSecurityLevel(SecurityLevel.AUTH_PRIV);
+ target.setSecurityName(new OctetString(username));
+
+ // Create a ScopedPDU for SET
+ ScopedPDU pdu = new ScopedPDU();
+ pdu.setType(PDU.GET);
+ pdu.add(new VariableBinding(new OID(oid)));
+
+ ResponseEvent response = snmp.send(pdu, target);
+ snmp.close();
+ if (response != null && response.getResponse() != null) {
+ VariableBinding vb = response.getResponse().get(0);
+ return vb.getVariable();
+ }
+ return null;
+
+ }
+
+ public void setSnmpV3Value(
+ String ipAddress,
+ String username,
+ String authPass,
+ String oid,
+ int intValue // directly passing integer for clarity
+ ) throws Exception {
+
+ // Setup SNMP and transport
+ TransportMapping transport = new DefaultUdpTransportMapping();
+ Snmp snmp = new Snmp(transport);
+ transport.listen();
+
+ // Add USM and user
+ USM usm = new USM(
+ SecurityProtocols.getInstance(),
+ new OctetString(MPv3.createLocalEngineID()),
+ 0);
+ SecurityModels.getInstance().addSecurityModel(usm);
+
+ SecurityProtocols.getInstance().addAuthenticationProtocol(new AuthSHA());
+ SecurityProtocols.getInstance().addPrivacyProtocol(new PrivAES128());
+
+ snmp.getUSM().addUser(
+ new OctetString(username),
+ new UsmUser(
+ new OctetString(username),
+ AuthSHA.ID, new OctetString(authPass),
+ PrivAES128.ID, new OctetString(authPass)));
+
+ // Configure the target
+ UserTarget target = new UserTarget<>();
+ target.setAddress(new UdpAddress(ipAddress + "/" + properties.getPort()));
+ target.setRetries(properties.getRetries());
+ target.setTimeout(properties.getTimeout());
+ target.setVersion(SnmpConstants.version3);
+ target.setSecurityLevel(SecurityLevel.AUTH_PRIV);
+ target.setSecurityName(new OctetString(username));
+
+ // Create a ScopedPDU for SET
+ ScopedPDU pdu = new ScopedPDU();
+ pdu.setType(PDU.SET);
+ pdu.add(new VariableBinding(new OID(oid), new Integer32(intValue)));
+
+ // Send and handle response
+ ResponseEvent response = snmp.send(pdu, target);
+ snmp.close();
+ if (response == null || response.getResponse() == null) {
+ log.warn("Received Null Response from RSU unit " + ipAddress);
+ }
+
+ if (response.getResponse().getErrorStatus() != PDU.noError) {
+ log.warn("Error while setting value on RSU unit " + ipAddress);
+ }
+ }
+
+ // Used for setting multiple OID values at the same time. RSUs require this for
+ // many configurations
+ public void setSnmpV3Values(
+ String ipAddress,
+ String username,
+ String authPass,
+ Map oidValuePairs) throws Exception {
+
+ // Setup SNMP and transport
+ TransportMapping transport = new DefaultUdpTransportMapping();
+ Snmp snmp = new Snmp(transport);
+ transport.listen();
+
+ // Add USM and user
+ USM usm = new USM(
+ SecurityProtocols.getInstance(),
+ new OctetString(MPv3.createLocalEngineID()),
+ 0);
+ SecurityModels.getInstance().addSecurityModel(usm);
+
+ SecurityProtocols.getInstance().addAuthenticationProtocol(new AuthSHA());
+ SecurityProtocols.getInstance().addPrivacyProtocol(new PrivAES128());
+
+ snmp.getUSM().addUser(
+ new OctetString(username),
+ new UsmUser(
+ new OctetString(username),
+ AuthSHA.ID, new OctetString(authPass),
+ PrivAES128.ID, new OctetString(authPass)));
+
+ // Configure the target
+ UserTarget target = new UserTarget<>();
+ target.setAddress(new UdpAddress(ipAddress + "/" + properties.getPort()));
+ target.setRetries(properties.getRetries());
+ target.setTimeout(properties.getTimeout());
+ target.setVersion(SnmpConstants.version3);
+ target.setSecurityLevel(SecurityLevel.AUTH_PRIV);
+ target.setSecurityName(new OctetString(username));
+
+ // Create a ScopedPDU for SET with multiple OIDs
+ ScopedPDU pdu = new ScopedPDU();
+ pdu.setType(PDU.SET);
+
+ // Add all OID-value pairs to the same PDU
+ for (java.util.Map.Entry entry : oidValuePairs.entrySet()) {
+ pdu.add(new VariableBinding(new OID(entry.getKey()), entry.getValue()));
+ }
+
+ // Send and handle response
+ ResponseEvent response = snmp.send(pdu, target);
+ snmp.close();
+ if (response == null || response.getResponse() == null) {
+ log.warn("Received Null Response from RSU unit" + ipAddress);
+ }
+
+ if (response.getResponse().getErrorStatus() != PDU.noError) {
+ log.warn("Error while setting values on RSU unit" + ipAddress + ". Error: " +
+ response.getResponse().getErrorStatusText());
+ }
+ }
+}
diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/snmp/SnmpProperties.java b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/snmp/SnmpProperties.java
new file mode 100644
index 000000000..b3f5f289f
--- /dev/null
+++ b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/snmp/SnmpProperties.java
@@ -0,0 +1,17 @@
+package us.dot.its.jpo.rsustatusmonitor.snmp;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * Configuration class for Kafka topics.
+ */
+@Configuration
+@ConfigurationProperties(prefix = "rsu-status-monitor.snmp")
+@Data
+public class SnmpProperties {
+ private int retries;
+ private int timeout;
+ private int port;
+}
diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/tasks/RsuMonitoringTask.java b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/tasks/RsuMonitoringTask.java
new file mode 100644
index 000000000..db98b65e0
--- /dev/null
+++ b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/tasks/RsuMonitoringTask.java
@@ -0,0 +1,63 @@
+package us.dot.its.jpo.rsustatusmonitor.tasks;
+
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import lombok.extern.slf4j.Slf4j;
+import us.dot.its.jpo.rsustatusmonitor.models.postgres.derived.RsuSnmpCredentials;
+import us.dot.its.jpo.rsustatusmonitor.services.PostgresService;
+import us.dot.its.jpo.rsustatusmonitor.services.RsuQueryService;
+
+@Component
+@ConditionalOnProperty(name = "enable.monitoring", havingValue = "true", matchIfMissing = false)
+@Slf4j
+public class RsuMonitoringTask {
+
+ private PostgresService postgresService;
+ private RsuQueryService rsuQueryService;
+ private final Executor taskExecutor;
+
+ @Autowired
+ public RsuMonitoringTask(
+ RsuQueryService rsuQueryService,
+ PostgresService postgresService) {
+ this.rsuQueryService = rsuQueryService;
+ this.postgresService = postgresService;
+ // Create a fixed thread pool with 10 threads for RSU monitoring
+ this.taskExecutor = Executors.newFixedThreadPool(10);
+ }
+
+ @Scheduled(fixedRateString = "${monitor.interval}")
+ public void queryRSUStats() {
+ List credentials = postgresService.getRsusWithCredentials();
+
+ // Process all RSU credentials in parallel using CompletableFuture
+ List> futures = credentials.stream()
+ .map(cred -> CompletableFuture.runAsync(() -> {
+ try {
+ log.debug("Processing RSU: {}", cred.getIpv4_address());
+ rsuQueryService.getRsuInformation(cred);
+ } catch (Exception e) {
+ log.error("Error processing status for RSU {}: {}", cred.getIpv4_address(), e.getMessage(), e);
+ }
+ }, taskExecutor))
+ .toList();
+
+ // Wait for all tasks to complete before the method returns
+ CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
+ .exceptionally(throwable -> {
+ log.error("Error in RSU monitoring task execution: {}", throwable.getMessage(), throwable);
+ return null;
+ })
+ .join();
+
+ log.debug("Completed RSU Monitoring Task for {} RSUs", credentials.size());
+ }
+}
\ No newline at end of file
diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/utils/DateJsonMapper.java b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/utils/DateJsonMapper.java
new file mode 100644
index 000000000..b0e18b92b
--- /dev/null
+++ b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/utils/DateJsonMapper.java
@@ -0,0 +1,28 @@
+package us.dot.its.jpo.rsustatusmonitor.utils;
+
+import com.fasterxml.jackson.annotation.JsonInclude.Include;
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializationFeature;
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * Provides a configured ObjectMapper instance for JSON date handling.
+ */
+@Configuration
+public class DateJsonMapper {
+ private static final ObjectMapper objectMapper;
+
+ static {
+ objectMapper = new ObjectMapper();
+ objectMapper.registerModule(new JavaTimeModule());
+ objectMapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
+ objectMapper.setSerializationInclusion(Include.NON_NULL);
+ objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
+ }
+
+ public static ObjectMapper getInstance() {
+ return objectMapper;
+ }
+}
\ No newline at end of file
diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/utils/SnmpHelperUtil.java b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/utils/SnmpHelperUtil.java
new file mode 100644
index 000000000..a5151ea93
--- /dev/null
+++ b/services/rsu-status-monitor/rsu-status-monitor/src/main/java/us/dot/its/jpo/rsustatusmonitor/utils/SnmpHelperUtil.java
@@ -0,0 +1,32 @@
+package us.dot.its.jpo.rsustatusmonitor.utils;
+
+import java.time.LocalDateTime;
+
+import org.snmp4j.smi.OctetString;
+
+public class SnmpHelperUtil {
+ public static String generateNtcip1218HexDateTimeString(LocalDateTime dateTime) {
+ String year = String.format("%04x", dateTime.getYear());
+ String month = String.format("%02x", dateTime.getMonthValue());
+ String day = String.format("%02x", dateTime.getDayOfMonth());
+ String hour = String.format("%02x", dateTime.getHour());
+ String minute = String.format("%02x", dateTime.getMinute());
+ String second = String.format("%02x", dateTime.getSecond());
+ return String.format("%s%s%s%s%s%s00", year, month, day, hour, minute, second);
+ }
+
+ /**
+ * Helper method to convert hex string to OctetString for SNMP - add comment
+ * explaining nuance logic
+ *
+ * @param hexString The hex string to convert (e.g., "07e9010100000000")
+ * @return OctetString with the converted byte array
+ */
+ public static OctetString hexStringToOctetString(String hexString) {
+ byte[] bytes = new byte[hexString.length() / 2];
+ for (int i = 0; i < bytes.length; i++) {
+ bytes[i] = (byte) Integer.parseInt(hexString.substring(2 * i, 2 * i + 2), 16);
+ }
+ return new OctetString(bytes);
+ }
+}
diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/main/resources/application-confluent.yaml b/services/rsu-status-monitor/rsu-status-monitor/src/main/resources/application-confluent.yaml
new file mode 100644
index 000000000..13d50f0db
--- /dev/null
+++ b/services/rsu-status-monitor/rsu-status-monitor/src/main/resources/application-confluent.yaml
@@ -0,0 +1,13 @@
+# Confluent properties
+spring:
+ config:
+ activate:
+ on-profile: 'confluent'
+ kafka:
+ properties:
+ ssl.endpoint.identification.algorithm: https
+ security.protocol: SASL_SSL
+ sasl.mechanism: PLAIN
+ sasl.jaas.config: >
+ org.apache.kafka.common.security.plain.PlainLoginModule required
+ username="${CONFLUENT_KEY:user}" password="${CONFLUENT_SECRET:pass}";
diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/main/resources/application-local.yaml b/services/rsu-status-monitor/rsu-status-monitor/src/main/resources/application-local.yaml
new file mode 100644
index 000000000..f1e32f813
--- /dev/null
+++ b/services/rsu-status-monitor/rsu-status-monitor/src/main/resources/application-local.yaml
@@ -0,0 +1,6 @@
+# Local properties
+spring:
+ config:
+ activate:
+ on-profile: 'local'
+ kafka.bootstrap-servers: localhost:9092
diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/main/resources/application.yaml b/services/rsu-status-monitor/rsu-status-monitor/src/main/resources/application.yaml
new file mode 100644
index 000000000..c52339229
--- /dev/null
+++ b/services/rsu-status-monitor/rsu-status-monitor/src/main/resources/application.yaml
@@ -0,0 +1,71 @@
+# RSU Status Monitor properties
+rsu-status-monitor:
+ kafka:
+ topics:
+ monitoring-status: 'topic.RmMonitoringStatusRecords'
+ snmp:
+ retries: ${RSU_STATUS_MONITOR_SNMP_RETRY:2} # Number of times to retry
+ timeout: ${RSU_STATUS_MONITOR_SNMP_TIMEOUT_MS:5000} # Number of milliseconds before timeout
+ port: ${RSU_STATUS_MONITOR_SNMP_PORT:161} # Port to use for SNMP Operations
+
+logging:
+ level:
+ root: WARN
+ org.springframework.kafka: WARN
+ org.springframework.web: WARN
+ guru.springframework.controllers: WARN
+ us.dot.its.jpo.rsustatusmonitor: ${LOGGING_LEVEL:INFO}
+ org.apache.kafka: WARN
+
+# microservice actuator
+management:
+ endpoints:
+ web:
+ exposure:
+ include: '*'
+ server:
+ port: 8082
+
+# Spring boot properties
+spring:
+ profiles:
+ default: 'none'
+ active: ${SPRING_PROFILES_ACTIVE:default}
+ # Postgres Configuration
+ datasource:
+ url: ${POSTGRES_SERVER_URL:jdbc:postgresql://localhost:5432}/${POSTGRES_DB:postgres}
+ username: ${POSTGRES_USER:postgres}
+ password: ${POSTGRES_PASSWORD:postgres}
+ driver-class-name: org.postgresql.Driver
+ jpa:
+ hibernate:
+ dialect: org.hibernate.dialect.PostgreSQLDialect
+ # Kafka Configuration
+ kafka:
+ bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:localhost:9092}
+ consumer:
+ group-id: rsu-status-monitor
+ fetch-min-size: 1
+ fetch-max-wait: 10
+ max-poll-records: 100
+ auto-offset-reset: latest
+ enable-auto-commit: true
+ auto-commit-interval: 50
+ key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
+ value-deserializer: org.apache.kafka.common.serialization.StringDeserializer
+ heartbeat-interval: 3000
+ listener:
+ ack-mode: BATCH
+ poll-timeout: 100
+ idle-event-interval: 100
+ idle-between-polls: 0
+ concurrency: ${KAFKA_CONCURRENCY:1}
+ config:
+ import:
+ - classpath:application-confluent.yaml
+
+enable:
+ monitoring: ${RSU_STATUS_MONITOR_ENABLE_MONITORING:true}
+
+monitor:
+ interval: ${INTERSECTION_API_MONITOR_INTERVAL:300000} # Number of Milliseconds between RSU Status Checks
diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/RsuStatusMonitorPropertiesTest.java b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/RsuStatusMonitorPropertiesTest.java
new file mode 100644
index 000000000..b054f1ce7
--- /dev/null
+++ b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/RsuStatusMonitorPropertiesTest.java
@@ -0,0 +1,45 @@
+package us.dot.its.jpo.rsustatusmonitor;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.boot.info.BuildProperties;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+public class RsuStatusMonitorPropertiesTest {
+
+ @Mock
+ private BuildProperties buildProperties;
+
+ @InjectMocks
+ private RsuStatusMonitorProperties rsuStatusMonitorProperties;
+
+ @Test
+ void testInitialize() {
+ when(buildProperties.getGroup()).thenReturn("test-group");
+ when(buildProperties.getArtifact()).thenReturn("test-artifact");
+ when(buildProperties.getVersion()).thenReturn("1.0.0");
+
+ rsuStatusMonitorProperties.initialize();
+
+ verify(buildProperties).getGroup();
+ verify(buildProperties).getArtifact();
+ verify(buildProperties).getVersion();
+ }
+
+ @Test
+ void testGetVersion() {
+ String expectedVersion = "1.0.0";
+ when(buildProperties.getVersion()).thenReturn(expectedVersion);
+
+ String actualVersion = rsuStatusMonitorProperties.getVersion();
+
+ assertEquals(expectedVersion, actualVersion);
+ }
+}
diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/TestConfig.java b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/TestConfig.java
new file mode 100644
index 000000000..e9379d722
--- /dev/null
+++ b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/TestConfig.java
@@ -0,0 +1,26 @@
+package us.dot.its.jpo.rsustatusmonitor;
+
+import java.util.Properties;
+import org.springframework.boot.info.BuildProperties;
+import org.springframework.boot.test.context.TestConfiguration;
+import org.springframework.context.annotation.Bean;
+
+@TestConfiguration
+public class TestConfig {
+
+ @Bean
+ public BuildProperties buildProperties() {
+ Properties properties = new Properties();
+ properties.setProperty("group", "usdot.jpo.ode");
+ properties.setProperty("artifact", "rsu-status-monitor");
+ properties.setProperty("version", "0.1.0-TEST");
+ properties.setProperty("name", "rsu-status-monitor");
+ properties.setProperty("time", "2025-11-21T00:00:00Z");
+ return new BuildProperties(properties);
+ }
+
+ @Bean
+ public RsuStatusMonitorProperties rsuStatusMonitorProperties(BuildProperties buildProperties) {
+ return new RsuStatusMonitorProperties(buildProperties);
+ }
+}
diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/derived/RsuDataTest.java b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/derived/RsuDataTest.java
new file mode 100644
index 000000000..ce74b7320
--- /dev/null
+++ b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/derived/RsuDataTest.java
@@ -0,0 +1,50 @@
+package us.dot.its.jpo.rsustatusmonitor.models.postgres.derived;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+public class RsuDataTest {
+
+ @Test
+ public void testConstructor() {
+ RsuData rsuData = new RsuData(1, "192.168.1.1", "12345");
+
+ assertEquals(1, rsuData.getRsu_id());
+ assertEquals("192.168.1.1", rsuData.getIpv4_address());
+ assertEquals("12345", rsuData.getIntersection_id());
+ }
+
+ @Test
+ public void testGettersAndSetters() {
+ RsuData rsuData = new RsuData(1, "192.168.1.1", "12345");
+
+ rsuData.setRsu_id(2);
+ rsuData.setIpv4_address("10.0.0.1");
+ rsuData.setIntersection_id("67890");
+
+ assertEquals(2, rsuData.getRsu_id());
+ assertEquals("10.0.0.1", rsuData.getIpv4_address());
+ assertEquals("67890", rsuData.getIntersection_id());
+ }
+
+ @Test
+ public void testEqualsAndHashCode() {
+ RsuData rsuData1 = new RsuData(1, "192.168.1.1", "12345");
+ RsuData rsuData2 = new RsuData(1, "192.168.1.1", "12345");
+ RsuData rsuData3 = new RsuData(2, "10.0.0.1", "67890");
+
+ assertEquals(rsuData1, rsuData2);
+ assertNotEquals(rsuData1, rsuData3);
+ assertEquals(rsuData1.hashCode(), rsuData2.hashCode());
+ }
+
+ @Test
+ public void testToString() {
+ RsuData rsuData = new RsuData(1, "192.168.1.1", "12345");
+ String result = rsuData.toString();
+
+ assertNotNull(result);
+ assertTrue(result.contains("192.168.1.1"));
+ }
+}
diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/derived/RsuSnmpCredentialsTest.java b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/derived/RsuSnmpCredentialsTest.java
new file mode 100644
index 000000000..af3fdc8f7
--- /dev/null
+++ b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/derived/RsuSnmpCredentialsTest.java
@@ -0,0 +1,74 @@
+package us.dot.its.jpo.rsustatusmonitor.models.postgres.derived;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+public class RsuSnmpCredentialsTest {
+
+ @Test
+ public void testConstructor() {
+ RsuSnmpCredentials credentials = new RsuSnmpCredentials(
+ 1, "192.168.1.1", "user", "pass", "encryptPass", "SHA", "12345"
+ );
+
+ assertEquals(1, credentials.getRsu_id());
+ assertEquals("192.168.1.1", credentials.getIpv4_address());
+ assertEquals("user", credentials.getUsername());
+ assertEquals("pass", credentials.getPassword());
+ assertEquals("encryptPass", credentials.getEncrypt_password());
+ assertEquals("SHA", credentials.getProtocol_code());
+ assertEquals("12345", credentials.getIntersection_id());
+ }
+
+ @Test
+ public void testGettersAndSetters() {
+ RsuSnmpCredentials credentials = new RsuSnmpCredentials(
+ 1, "192.168.1.1", "user", "pass", "encryptPass", "SHA", "12345"
+ );
+
+ credentials.setRsu_id(2);
+ credentials.setIpv4_address("10.0.0.1");
+ credentials.setUsername("newUser");
+ credentials.setPassword("newPass");
+ credentials.setEncrypt_password("newEncrypt");
+ credentials.setProtocol_code("MD5");
+ credentials.setIntersection_id("67890");
+
+ assertEquals(2, credentials.getRsu_id());
+ assertEquals("10.0.0.1", credentials.getIpv4_address());
+ assertEquals("newUser", credentials.getUsername());
+ assertEquals("newPass", credentials.getPassword());
+ assertEquals("newEncrypt", credentials.getEncrypt_password());
+ assertEquals("MD5", credentials.getProtocol_code());
+ assertEquals("67890", credentials.getIntersection_id());
+ }
+
+ @Test
+ public void testEqualsAndHashCode() {
+ RsuSnmpCredentials credentials1 = new RsuSnmpCredentials(
+ 1, "192.168.1.1", "user", "pass", "encryptPass", "SHA", "12345"
+ );
+ RsuSnmpCredentials credentials2 = new RsuSnmpCredentials(
+ 1, "192.168.1.1", "user", "pass", "encryptPass", "SHA", "12345"
+ );
+ RsuSnmpCredentials credentials3 = new RsuSnmpCredentials(
+ 2, "10.0.0.1", "user2", "pass2", "encryptPass2", "MD5", "67890"
+ );
+
+ assertEquals(credentials1, credentials2);
+ assertNotEquals(credentials1, credentials3);
+ assertEquals(credentials1.hashCode(), credentials2.hashCode());
+ }
+
+ @Test
+ public void testToString() {
+ RsuSnmpCredentials credentials = new RsuSnmpCredentials(
+ 1, "192.168.1.1", "user", "pass", "encryptPass", "SHA", "12345"
+ );
+ String result = credentials.toString();
+
+ assertNotNull(result);
+ assertTrue(result.contains("192.168.1.1"));
+ }
+}
diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/keys/SnmpMsgfwdConfigIdTest.java b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/keys/SnmpMsgfwdConfigIdTest.java
new file mode 100644
index 000000000..5749a3ffc
--- /dev/null
+++ b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/keys/SnmpMsgfwdConfigIdTest.java
@@ -0,0 +1,31 @@
+package us.dot.its.jpo.rsustatusmonitor.models.postgres.keys;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+public class SnmpMsgfwdConfigIdTest {
+
+ @Test
+ public void testNoArgsConstructor() {
+ SnmpMsgfwdConfigId id = new SnmpMsgfwdConfigId();
+ assertNotNull(id);
+ }
+
+ @Test
+ public void testAllArgsConstructor() {
+ SnmpMsgfwdConfigId id = new SnmpMsgfwdConfigId(1, 2, 3);
+ assertNotNull(id);
+ }
+
+ @Test
+ public void testEqualsAndHashCode() {
+ SnmpMsgfwdConfigId id1 = new SnmpMsgfwdConfigId(1, 2, 3);
+ SnmpMsgfwdConfigId id2 = new SnmpMsgfwdConfigId(1, 2, 3);
+ SnmpMsgfwdConfigId id3 = new SnmpMsgfwdConfigId(4, 5, 6);
+
+ assertEquals(id1, id2);
+ assertNotEquals(id1, id3);
+ assertEquals(id1.hashCode(), id2.hashCode());
+ }
+}
diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/IntersectionsTest.java b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/IntersectionsTest.java
new file mode 100644
index 000000000..d674e25e0
--- /dev/null
+++ b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/IntersectionsTest.java
@@ -0,0 +1,55 @@
+package us.dot.its.jpo.rsustatusmonitor.models.postgres.tables;
+
+import org.junit.jupiter.api.Test;
+import org.locationtech.jts.geom.Coordinate;
+import org.locationtech.jts.geom.GeometryFactory;
+import org.locationtech.jts.geom.Point;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+public class IntersectionsTest {
+
+ @Test
+ public void testGettersAndSetters() {
+ Intersections intersection = new Intersections();
+ GeometryFactory geometryFactory = new GeometryFactory();
+ Point point = geometryFactory.createPoint(new Coordinate(1.0, 2.0));
+
+ intersection.setIntersection_id(123);
+ intersection.setIntersection_number("INT-001");
+ intersection.setRef_pt(point);
+ intersection.setBbox(point);
+ intersection.setIntersection_name("Main St & 1st Ave");
+ intersection.setOrigin_ip("192.168.1.1");
+
+ assertEquals(123, intersection.getIntersection_id());
+ assertEquals("INT-001", intersection.getIntersection_number());
+ assertEquals(point, intersection.getRef_pt());
+ assertEquals(point, intersection.getBbox());
+ assertEquals("Main St & 1st Ave", intersection.getIntersection_name());
+ assertEquals("192.168.1.1", intersection.getOrigin_ip());
+ }
+
+ @Test
+ public void testEqualsAndHashCode() {
+ Intersections intersection1 = new Intersections();
+ intersection1.setIntersection_id(123);
+ intersection1.setIntersection_name("Test");
+
+ Intersections intersection2 = new Intersections();
+ intersection2.setIntersection_id(123);
+ intersection2.setIntersection_name("Test");
+
+ assertEquals(intersection1, intersection2);
+ assertEquals(intersection1.hashCode(), intersection2.hashCode());
+ }
+
+ @Test
+ public void testToString() {
+ Intersections intersection = new Intersections();
+ intersection.setIntersection_id(123);
+
+ String result = intersection.toString();
+ assertNotNull(result);
+ }
+}
diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/RsuIntersectionTest.java b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/RsuIntersectionTest.java
new file mode 100644
index 000000000..8b60153d7
--- /dev/null
+++ b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/RsuIntersectionTest.java
@@ -0,0 +1,44 @@
+package us.dot.its.jpo.rsustatusmonitor.models.postgres.tables;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+public class RsuIntersectionTest {
+
+ @Test
+ public void testGettersAndSetters() {
+ RsuIntersection rsuIntersection = new RsuIntersection();
+
+ rsuIntersection.setRsu_intersection_id(1);
+ rsuIntersection.setRsu_id(100);
+ rsuIntersection.setIntersection_id(200);
+
+ assertEquals(1, rsuIntersection.getRsu_intersection_id());
+ assertEquals(100, rsuIntersection.getRsu_id());
+ assertEquals(200, rsuIntersection.getIntersection_id());
+ }
+
+ @Test
+ public void testEqualsAndHashCode() {
+ RsuIntersection rsuIntersection1 = new RsuIntersection();
+ rsuIntersection1.setRsu_intersection_id(1);
+ rsuIntersection1.setRsu_id(100);
+
+ RsuIntersection rsuIntersection2 = new RsuIntersection();
+ rsuIntersection2.setRsu_intersection_id(1);
+ rsuIntersection2.setRsu_id(100);
+
+ assertEquals(rsuIntersection1, rsuIntersection2);
+ assertEquals(rsuIntersection1.hashCode(), rsuIntersection2.hashCode());
+ }
+
+ @Test
+ public void testToString() {
+ RsuIntersection rsuIntersection = new RsuIntersection();
+ rsuIntersection.setRsu_intersection_id(1);
+
+ String result = rsuIntersection.toString();
+ assertNotNull(result);
+ }
+}
diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/RsusTest.java b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/RsusTest.java
new file mode 100644
index 000000000..e9c40a521
--- /dev/null
+++ b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/RsusTest.java
@@ -0,0 +1,69 @@
+package us.dot.its.jpo.rsustatusmonitor.models.postgres.tables;
+
+import org.junit.jupiter.api.Test;
+import org.locationtech.jts.geom.Coordinate;
+import org.locationtech.jts.geom.GeometryFactory;
+import org.locationtech.jts.geom.Point;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+public class RsusTest {
+
+ @Test
+ public void testGettersAndSetters() {
+ Rsus rsu = new Rsus();
+ GeometryFactory geometryFactory = new GeometryFactory();
+ Point point = geometryFactory.createPoint(new Coordinate(1.0, 2.0));
+
+ rsu.setRsu_id(1);
+ rsu.setGeography(point);
+ rsu.setMilepost(12.5f);
+ rsu.setIpv4_address("192.168.1.1");
+ rsu.setSerial_number("SN12345");
+ rsu.setIss_scms_id("SCMS001");
+ rsu.setPrimary_route("Route 66");
+ rsu.setModel(1);
+ rsu.setCredential_id(10);
+ rsu.setSnmp_credential_id(20);
+ rsu.setSnmp_protocol_id(30);
+ rsu.setFirmware_version(100);
+ rsu.setTarget_firmware_version(101);
+
+ assertEquals(1, rsu.getRsu_id());
+ assertEquals(point, rsu.getGeography());
+ assertEquals(12.5f, rsu.getMilepost());
+ assertEquals("192.168.1.1", rsu.getIpv4_address());
+ assertEquals("SN12345", rsu.getSerial_number());
+ assertEquals("SCMS001", rsu.getIss_scms_id());
+ assertEquals("Route 66", rsu.getPrimary_route());
+ assertEquals(1, rsu.getModel());
+ assertEquals(10, rsu.getCredential_id());
+ assertEquals(20, rsu.getSnmp_credential_id());
+ assertEquals(30, rsu.getSnmp_protocol_id());
+ assertEquals(100, rsu.getFirmware_version());
+ assertEquals(101, rsu.getTarget_firmware_version());
+ }
+
+ @Test
+ public void testEqualsAndHashCode() {
+ Rsus rsu1 = new Rsus();
+ rsu1.setRsu_id(1);
+ rsu1.setIpv4_address("192.168.1.1");
+
+ Rsus rsu2 = new Rsus();
+ rsu2.setRsu_id(1);
+ rsu2.setIpv4_address("192.168.1.1");
+
+ assertEquals(rsu1, rsu2);
+ assertEquals(rsu1.hashCode(), rsu2.hashCode());
+ }
+
+ @Test
+ public void testToString() {
+ Rsus rsu = new Rsus();
+ rsu.setRsu_id(1);
+
+ String result = rsu.toString();
+ assertNotNull(result);
+ }
+}
diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/SnmpCredentialsTest.java b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/SnmpCredentialsTest.java
new file mode 100644
index 000000000..c85fdb14b
--- /dev/null
+++ b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/SnmpCredentialsTest.java
@@ -0,0 +1,48 @@
+package us.dot.its.jpo.rsustatusmonitor.models.postgres.tables;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+public class SnmpCredentialsTest {
+
+ @Test
+ public void testGettersAndSetters() {
+ SnmpCredentials credentials = new SnmpCredentials();
+
+ credentials.setSnmp_credential_id(1);
+ credentials.setUsername("testUser");
+ credentials.setPassword("testPass");
+ credentials.setEncrypt_password("encryptedPass");
+ credentials.setNickname("TestCred");
+
+ assertEquals(1, credentials.getSnmp_credential_id());
+ assertEquals("testUser", credentials.getUsername());
+ assertEquals("testPass", credentials.getPassword());
+ assertEquals("encryptedPass", credentials.getEncrypt_password());
+ assertEquals("TestCred", credentials.getNickname());
+ }
+
+ @Test
+ public void testEqualsAndHashCode() {
+ SnmpCredentials credentials1 = new SnmpCredentials();
+ credentials1.setSnmp_credential_id(1);
+ credentials1.setUsername("testUser");
+
+ SnmpCredentials credentials2 = new SnmpCredentials();
+ credentials2.setSnmp_credential_id(1);
+ credentials2.setUsername("testUser");
+
+ assertEquals(credentials1, credentials2);
+ assertEquals(credentials1.hashCode(), credentials2.hashCode());
+ }
+
+ @Test
+ public void testToString() {
+ SnmpCredentials credentials = new SnmpCredentials();
+ credentials.setSnmp_credential_id(1);
+
+ String result = credentials.toString();
+ assertNotNull(result);
+ }
+}
diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/SnmpMsgfwdConfigTest.java b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/SnmpMsgfwdConfigTest.java
new file mode 100644
index 000000000..333d849eb
--- /dev/null
+++ b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/SnmpMsgfwdConfigTest.java
@@ -0,0 +1,63 @@
+package us.dot.its.jpo.rsustatusmonitor.models.postgres.tables;
+
+import org.junit.jupiter.api.Test;
+import java.time.LocalDateTime;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+public class SnmpMsgfwdConfigTest {
+
+ @Test
+ public void testGettersAndSetters() {
+ SnmpMsgfwdConfig config = new SnmpMsgfwdConfig();
+ LocalDateTime startTime = LocalDateTime.of(2025, 11, 21, 10, 0);
+ LocalDateTime endTime = LocalDateTime.of(2025, 11, 21, 12, 0);
+
+ config.setRsu_id(1);
+ config.setMsgfwd_type(2);
+ config.setSnmp_index(3);
+ config.setMessage_type("BSM");
+ config.setDest_ipv4("192.168.1.1");
+ config.setDest_port(8080);
+ config.setStart_datetime(startTime);
+ config.setEnd_datetime(endTime);
+ config.setActive(true);
+ config.setSecurity(false);
+
+ assertEquals(1, config.getRsu_id());
+ assertEquals(2, config.getMsgfwd_type());
+ assertEquals(3, config.getSnmp_index());
+ assertEquals("BSM", config.getMessage_type());
+ assertEquals("192.168.1.1", config.getDest_ipv4());
+ assertEquals(8080, config.getDest_port());
+ assertEquals(startTime, config.getStart_datetime());
+ assertEquals(endTime, config.getEnd_datetime());
+ assertTrue(config.isActive());
+ assertFalse(config.isSecurity());
+ }
+
+ @Test
+ public void testEqualsAndHashCode() {
+ SnmpMsgfwdConfig config1 = new SnmpMsgfwdConfig();
+ config1.setRsu_id(1);
+ config1.setMsgfwd_type(2);
+ config1.setSnmp_index(3);
+
+ SnmpMsgfwdConfig config2 = new SnmpMsgfwdConfig();
+ config2.setRsu_id(1);
+ config2.setMsgfwd_type(2);
+ config2.setSnmp_index(3);
+
+ assertEquals(config1, config2);
+ assertEquals(config1.hashCode(), config2.hashCode());
+ }
+
+ @Test
+ public void testToString() {
+ SnmpMsgfwdConfig config = new SnmpMsgfwdConfig();
+ config.setRsu_id(1);
+
+ String result = config.toString();
+ assertNotNull(result);
+ }
+}
diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/SnmpMsgfwdTypeTest.java b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/SnmpMsgfwdTypeTest.java
new file mode 100644
index 000000000..479d95f60
--- /dev/null
+++ b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/SnmpMsgfwdTypeTest.java
@@ -0,0 +1,42 @@
+package us.dot.its.jpo.rsustatusmonitor.models.postgres.tables;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+public class SnmpMsgfwdTypeTest {
+
+ @Test
+ public void testGettersAndSetters() {
+ SnmpMsgfwdType type = new SnmpMsgfwdType();
+
+ type.setSnmp_msgfwd_type_id(1);
+ type.setName("BSM");
+
+ assertEquals(1, type.getSnmp_msgfwd_type_id());
+ assertEquals("BSM", type.getName());
+ }
+
+ @Test
+ public void testEqualsAndHashCode() {
+ SnmpMsgfwdType type1 = new SnmpMsgfwdType();
+ type1.setSnmp_msgfwd_type_id(1);
+ type1.setName("BSM");
+
+ SnmpMsgfwdType type2 = new SnmpMsgfwdType();
+ type2.setSnmp_msgfwd_type_id(1);
+ type2.setName("BSM");
+
+ assertEquals(type1, type2);
+ assertEquals(type1.hashCode(), type2.hashCode());
+ }
+
+ @Test
+ public void testToString() {
+ SnmpMsgfwdType type = new SnmpMsgfwdType();
+ type.setSnmp_msgfwd_type_id(1);
+
+ String result = type.toString();
+ assertNotNull(result);
+ }
+}
diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/SnmpProtocolsTest.java b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/SnmpProtocolsTest.java
new file mode 100644
index 000000000..da5a14c93
--- /dev/null
+++ b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/postgres/tables/SnmpProtocolsTest.java
@@ -0,0 +1,44 @@
+package us.dot.its.jpo.rsustatusmonitor.models.postgres.tables;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+public class SnmpProtocolsTest {
+
+ @Test
+ public void testGettersAndSetters() {
+ SnmpProtocols protocol = new SnmpProtocols();
+
+ protocol.setSnmp_protocol_id(1);
+ protocol.setProtocol_code("SHA");
+ protocol.setNickname("Secure Hash");
+
+ assertEquals(1, protocol.getSnmp_protocol_id());
+ assertEquals("SHA", protocol.getProtocol_code());
+ assertEquals("Secure Hash", protocol.getNickname());
+ }
+
+ @Test
+ public void testEqualsAndHashCode() {
+ SnmpProtocols protocol1 = new SnmpProtocols();
+ protocol1.setSnmp_protocol_id(1);
+ protocol1.setProtocol_code("SHA");
+
+ SnmpProtocols protocol2 = new SnmpProtocols();
+ protocol2.setSnmp_protocol_id(1);
+ protocol2.setProtocol_code("SHA");
+
+ assertEquals(protocol1, protocol2);
+ assertEquals(protocol1.hashCode(), protocol2.hashCode());
+ }
+
+ @Test
+ public void testToString() {
+ SnmpProtocols protocol = new SnmpProtocols();
+ protocol.setSnmp_protocol_id(1);
+
+ String result = protocol.toString();
+ assertNotNull(result);
+ }
+}
diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/snmp/OIDMapTest.java b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/snmp/OIDMapTest.java
new file mode 100644
index 000000000..09d8c6e6e
--- /dev/null
+++ b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/snmp/OIDMapTest.java
@@ -0,0 +1,49 @@
+package us.dot.its.jpo.rsustatusmonitor.models.snmp;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+public class OIDMapTest {
+
+ @Test
+ public void testOIDMapContainsKnownOIDs() {
+ assertNotNull(OIDMap.oids);
+ assertFalse(OIDMap.oids.isEmpty());
+
+ // Test some known OIDs
+ assertTrue(OIDMap.oids.containsKey("rsuModeStatus"));
+ assertTrue(OIDMap.oids.containsKey("rsuMode"));
+ assertTrue(OIDMap.oids.containsKey("rsuGnssStatus"));
+ assertTrue(OIDMap.oids.containsKey("rsuStatus"));
+ }
+
+ @Test
+ public void testOIDMapValues() {
+ OID rsuModeStatus = OIDMap.oids.get("rsuModeStatus");
+ assertNotNull(rsuModeStatus);
+ assertEquals("rsuModeStatus", rsuModeStatus.getName());
+ assertEquals(OID_TYPE.SCALAR, rsuModeStatus.getType());
+ assertEquals("1.3.6.1.4.1.1.1206.4.2.18.16.3.0", rsuModeStatus.getOid());
+ }
+
+ @Test
+ public void testOIDMapContainsTableEntries() {
+ assertTrue(OIDMap.oids.containsKey("rsuReceivedMsgTable"));
+ assertTrue(OIDMap.oids.containsKey("rsuReceivedMsgEntry"));
+
+ OID table = OIDMap.oids.get("rsuReceivedMsgTable");
+ assertEquals(OID_TYPE.TABLE, table.getType());
+ }
+
+ @Test
+ public void testOIDMapIsImmutable() {
+ int originalSize = OIDMap.oids.size();
+
+ assertThrows(UnsupportedOperationException.class, () -> {
+ OIDMap.oids.put("newOID", new OID("test", OID_TYPE.SCALAR, "1.2.3"));
+ });
+
+ assertEquals(originalSize, OIDMap.oids.size());
+ }
+}
diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/snmp/OIDTest.java b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/snmp/OIDTest.java
new file mode 100644
index 000000000..0c0eada32
--- /dev/null
+++ b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/snmp/OIDTest.java
@@ -0,0 +1,58 @@
+package us.dot.its.jpo.rsustatusmonitor.models.snmp;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+public class OIDTest {
+
+ @Test
+ public void testConstructor() {
+ OID oid = new OID("rsuModeStatus", OID_TYPE.SCALAR, "1.3.6.1.4.1.1.1206.4.2.18.16.3.0");
+
+ assertEquals("rsuModeStatus", oid.getName());
+ assertEquals(OID_TYPE.SCALAR, oid.getType());
+ assertEquals("1.3.6.1.4.1.1.1206.4.2.18.16.3.0", oid.getOid());
+ }
+
+ @Test
+ public void testGettersAndSetters() {
+ OID oid = new OID("test", OID_TYPE.NODE, "1.2.3.4");
+
+ oid.setName("rsuStatus");
+ oid.setType(OID_TYPE.TABLE);
+ oid.setOid("1.3.6.1.4.1");
+
+ assertEquals("rsuStatus", oid.getName());
+ assertEquals(OID_TYPE.TABLE, oid.getType());
+ assertEquals("1.3.6.1.4.1", oid.getOid());
+ }
+
+ @Test
+ public void testEqualsAndHashCode() {
+ OID oid1 = new OID("test", OID_TYPE.SCALAR, "1.2.3.4");
+ OID oid2 = new OID("test", OID_TYPE.SCALAR, "1.2.3.4");
+ OID oid3 = new OID("different", OID_TYPE.NODE, "5.6.7.8");
+
+ assertEquals(oid1, oid2);
+ assertNotEquals(oid1, oid3);
+ assertEquals(oid1.hashCode(), oid2.hashCode());
+ }
+
+ @Test
+ public void testToString() {
+ OID oid = new OID("rsuModeStatus", OID_TYPE.SCALAR, "1.3.6.1.4.1.1.1206.4.2.18.16.3.0");
+ String result = oid.toString();
+
+ assertNotNull(result);
+ assertTrue(result.contains("rsuModeStatus"));
+ }
+
+ @Test
+ public void testOIDTypes() {
+ assertEquals(OID_TYPE.SCALAR, OID_TYPE.valueOf("SCALAR"));
+ assertEquals(OID_TYPE.NODE, OID_TYPE.valueOf("NODE"));
+ assertEquals(OID_TYPE.TABLE, OID_TYPE.valueOf("TABLE"));
+ assertEquals(OID_TYPE.ROW, OID_TYPE.valueOf("ROW"));
+ }
+}
diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/snmp/RsuMsgfwdValuesTest.java b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/snmp/RsuMsgfwdValuesTest.java
new file mode 100644
index 000000000..0ace74f87
--- /dev/null
+++ b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/snmp/RsuMsgfwdValuesTest.java
@@ -0,0 +1,104 @@
+package us.dot.its.jpo.rsustatusmonitor.models.snmp;
+
+import org.junit.jupiter.api.Test;
+import us.dot.its.jpo.rsustatusmonitor.models.postgres.derived.RsuSnmpCredentials;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+public class RsuMsgfwdValuesTest {
+
+ @Test
+ public void testNoArgsConstructor() {
+ RsuMsgfwdValues values = new RsuMsgfwdValues();
+ assertNotNull(values);
+ }
+
+ @Test
+ public void testAllArgsConstructor() {
+ RsuSnmpCredentials credentials = new RsuSnmpCredentials(
+ 1, "192.168.1.1", "user", "pass", "encrypt", "SHA", "12345"
+ );
+
+ RsuMsgfwdValues values = new RsuMsgfwdValues(
+ credentials, "psid123", "10.0.0.1", 8080, 1, -50, 1000,
+ "07e9010100000000", "07e9123123595959", 1, 1, 500
+ );
+
+ assertEquals(credentials, values.getRsuSnmpCredentials());
+ assertEquals("psid123", values.getRsuReceivedMsgPsid());
+ assertEquals("10.0.0.1", values.getRsuReceivedMsgDestIpAddr());
+ assertEquals(8080, values.getRsuReceivedMsgDestPort());
+ assertEquals(1, values.getRsuReceivedMsgProtocol());
+ assertEquals(-50, values.getRsuReceivedMsgRssi());
+ assertEquals(1000, values.getRsuReceivedMsgInterval());
+ assertEquals("07e9010100000000", values.getRsuReceivedMsgDeliveryStart());
+ assertEquals("07e9123123595959", values.getRsuReceivedMsgDeliveryStop());
+ assertEquals(1, values.getRsuReceivedMsgStatus());
+ assertEquals(1, values.getRsuReceivedMsgSecure());
+ assertEquals(500, values.getRsuReceivedMsgAuthMsgInterval());
+ }
+
+ @Test
+ public void testGettersAndSetters() {
+ RsuMsgfwdValues values = new RsuMsgfwdValues();
+ RsuSnmpCredentials credentials = new RsuSnmpCredentials(
+ 1, "192.168.1.1", "user", "pass", "encrypt", "SHA", "12345"
+ );
+
+ values.setRsuSnmpCredentials(credentials);
+ values.setRsuReceivedMsgPsid("psid456");
+ values.setRsuReceivedMsgDestIpAddr("192.168.2.2");
+ values.setRsuReceivedMsgDestPort(9090);
+ values.setRsuReceivedMsgProtocol(2);
+ values.setRsuReceivedMsgRssi(-60);
+ values.setRsuReceivedMsgInterval(2000);
+ values.setRsuReceivedMsgDeliveryStart("07e9020200000000");
+ values.setRsuReceivedMsgDeliveryStop("07e9121231235959");
+ values.setRsuReceivedMsgStatus(2);
+ values.setRsuReceivedMsgSecure(0);
+ values.setRsuReceivedMsgAuthMsgInterval(1000);
+
+ assertEquals(credentials, values.getRsuSnmpCredentials());
+ assertEquals("psid456", values.getRsuReceivedMsgPsid());
+ assertEquals("192.168.2.2", values.getRsuReceivedMsgDestIpAddr());
+ assertEquals(9090, values.getRsuReceivedMsgDestPort());
+ assertEquals(2, values.getRsuReceivedMsgProtocol());
+ assertEquals(-60, values.getRsuReceivedMsgRssi());
+ assertEquals(2000, values.getRsuReceivedMsgInterval());
+ assertEquals("07e9020200000000", values.getRsuReceivedMsgDeliveryStart());
+ assertEquals("07e9121231235959", values.getRsuReceivedMsgDeliveryStop());
+ assertEquals(2, values.getRsuReceivedMsgStatus());
+ assertEquals(0, values.getRsuReceivedMsgSecure());
+ assertEquals(1000, values.getRsuReceivedMsgAuthMsgInterval());
+ }
+
+ @Test
+ public void testEqualsAndHashCode() {
+ RsuSnmpCredentials credentials = new RsuSnmpCredentials(
+ 1, "192.168.1.1", "user", "pass", "encrypt", "SHA", "12345"
+ );
+
+ RsuMsgfwdValues values1 = new RsuMsgfwdValues(
+ credentials, "psid123", "10.0.0.1", 8080, 1, -50, 1000,
+ "07e9010100000000", "07e9123123595959", 1, 1, 500
+ );
+
+ RsuMsgfwdValues values2 = new RsuMsgfwdValues(
+ credentials, "psid123", "10.0.0.1", 8080, 1, -50, 1000,
+ "07e9010100000000", "07e9123123595959", 1, 1, 500
+ );
+
+ assertEquals(values1, values2);
+ assertEquals(values1.hashCode(), values2.hashCode());
+ }
+
+ @Test
+ public void testToString() {
+ RsuMsgfwdValues values = new RsuMsgfwdValues();
+ values.setRsuReceivedMsgPsid("psid123");
+
+ String result = values.toString();
+ assertNotNull(result);
+ assertTrue(result.contains("psid123"));
+ }
+}
diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/snmp/RsuStateTest.java b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/snmp/RsuStateTest.java
new file mode 100644
index 000000000..deb560fa4
--- /dev/null
+++ b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/models/snmp/RsuStateTest.java
@@ -0,0 +1,80 @@
+package us.dot.its.jpo.rsustatusmonitor.models.snmp;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+public class RsuStateTest {
+
+ @Test
+ public void testGettersAndSetters() {
+ RsuState state = new RsuState();
+
+ state.setTimestamp(1700000000000L);
+ state.setIntersectionID("12345");
+ state.setRsuIP("192.168.1.1");
+ state.setTemperature(35.5);
+ state.setUptime(3600);
+ state.setMode(4);
+
+ assertEquals(1700000000000L, state.getTimestamp());
+ assertEquals("12345", state.getIntersectionID());
+ assertEquals("192.168.1.1", state.getRsuIP());
+ assertEquals(35.5, state.getTemperature());
+ assertEquals(3600, state.getUptime());
+ assertEquals(4, state.getMode());
+ }
+
+ @Test
+ public void testDefaultValues() {
+ RsuState state = new RsuState();
+
+ assertEquals(0L, state.getTimestamp());
+ assertNull(state.getIntersectionID());
+ assertNull(state.getRsuIP());
+ assertEquals(0.0, state.getTemperature());
+ assertEquals(0, state.getUptime());
+ assertEquals(0, state.getMode());
+ }
+
+ @Test
+ public void testModeValues() {
+ RsuState state = new RsuState();
+
+ // Test common RSU modes
+ state.setMode(2); // Standby
+ assertEquals(2, state.getMode());
+
+ state.setMode(4); // Operational
+ assertEquals(4, state.getMode());
+
+ state.setMode(16); // Off
+ assertEquals(16, state.getMode());
+ }
+
+ @Test
+ public void testTemperatureRange() {
+ RsuState state = new RsuState();
+
+ state.setTemperature(-40.0);
+ assertEquals(-40.0, state.getTemperature());
+
+ state.setTemperature(85.0);
+ assertEquals(85.0, state.getTemperature());
+ }
+
+ @Test
+ public void testUptimeValues() {
+ RsuState state = new RsuState();
+
+ // Test various uptime values
+ state.setUptime(0); // Just booted
+ assertEquals(0, state.getUptime());
+
+ state.setUptime(86400); // 1 day
+ assertEquals(86400, state.getUptime());
+
+ state.setUptime(2592000); // 30 days
+ assertEquals(2592000, state.getUptime());
+ }
+}
diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/services/KafkaProducerServiceTest.java b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/services/KafkaProducerServiceTest.java
new file mode 100644
index 000000000..19442f311
--- /dev/null
+++ b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/services/KafkaProducerServiceTest.java
@@ -0,0 +1,210 @@
+package us.dot.its.jpo.rsustatusmonitor.services;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.kafka.core.KafkaTemplate;
+import us.dot.its.jpo.geojsonconverter.partitioner.RsuIntersectionKey;
+import us.dot.its.jpo.rsustatusmonitor.kafka.KafkaTopics;
+import us.dot.its.jpo.rsustatusmonitor.models.snmp.RsuState;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+public class KafkaProducerServiceTest {
+
+ @Mock
+ private KafkaTemplate kafkaTemplate;
+
+ @Mock
+ private ObjectMapper objectMapper;
+
+ @Mock
+ private KafkaTopics kafkaTopics;
+
+ private KafkaProducerService service;
+
+ @BeforeEach
+ public void setup() {
+ service = new KafkaProducerService(kafkaTemplate, objectMapper, kafkaTopics);
+ }
+
+ @Test
+ public void testSendMessage_Success() {
+ String topic = "test-topic";
+ String key = "test-key";
+ String message = "test-message";
+
+ service.sendMessage(topic, key, message);
+
+ verify(kafkaTemplate).send(topic, key, message);
+ }
+
+ @Test
+ public void testSendMessage_WithNullValues() {
+ String topic = null;
+ String key = null;
+ String message = null;
+
+ service.sendMessage(topic, key, message);
+
+ verify(kafkaTemplate).send(topic, key, message);
+ }
+
+ @Test
+ public void testSendMessage_WithEmptyStrings() {
+ String topic = "";
+ String key = "";
+ String message = "";
+
+ service.sendMessage(topic, key, message);
+
+ verify(kafkaTemplate).send(topic, key, message);
+ }
+
+ @Test
+ public void testSendRsuStatus_Success() throws JsonProcessingException {
+ RsuIntersectionKey key = new RsuIntersectionKey();
+ key.setRsuId("rsu-123");
+ key.setIntersectionId(12345);
+ key.setRegion(1);
+
+ RsuState message = new RsuState();
+
+ String expectedTopic = "monitoring-status-topic";
+ String serializedKey = "{\"rsuId\":\"rsu-123\",\"intersectionId\":12345,\"region\":1}";
+ String serializedMessage = "{\"rsuState\":\"data\"}";
+
+ when(kafkaTopics.getMonitoringStatus()).thenReturn(expectedTopic);
+ when(objectMapper.writeValueAsString(key)).thenReturn(serializedKey);
+ when(objectMapper.writeValueAsString(message)).thenReturn(serializedMessage);
+
+ service.sendRsuStatus(key, message);
+
+ verify(objectMapper).writeValueAsString(key);
+ verify(objectMapper).writeValueAsString(message);
+ verify(kafkaTemplate).send(expectedTopic, serializedKey, serializedMessage);
+ }
+
+ @Test
+ public void testSendRsuStatus_JsonProcessingException_OnMessage() throws JsonProcessingException {
+ RsuIntersectionKey key = new RsuIntersectionKey();
+ key.setRsuId("rsu-123");
+ RsuState message = new RsuState();
+
+ when(objectMapper.writeValueAsString(message)).thenThrow(new JsonProcessingException("Serialization error") {
+ });
+
+ service.sendRsuStatus(key, message);
+
+ verify(objectMapper).writeValueAsString(message);
+ verify(objectMapper, never()).writeValueAsString(key);
+ verify(kafkaTemplate, never()).send(any(), any(), any());
+ }
+
+ @Test
+ public void testSendRsuStatus_JsonProcessingException_OnKey() throws JsonProcessingException {
+ RsuIntersectionKey key = new RsuIntersectionKey();
+ RsuState message = new RsuState();
+
+ String serializedMessage = "{\"state\":\"data\"}";
+
+ when(objectMapper.writeValueAsString(message)).thenReturn(serializedMessage);
+ doThrow(new JsonProcessingException("Serialization error") {
+ }).when(objectMapper).writeValueAsString(any(RsuIntersectionKey.class));
+
+ service.sendRsuStatus(key, message);
+
+ verify(objectMapper).writeValueAsString(message);
+ verify(objectMapper).writeValueAsString(key);
+ verify(kafkaTemplate, never()).send(any(), any(), any());
+ }
+
+ @Test
+ public void testSendRsuStatus_VerifyTopicFromKafkaTopics() throws JsonProcessingException {
+ RsuIntersectionKey key = new RsuIntersectionKey();
+ RsuState message = new RsuState();
+
+ String expectedTopic = "custom-monitoring-topic";
+ String serializedKey = "{}";
+ String serializedMessage = "{}";
+
+ when(kafkaTopics.getMonitoringStatus()).thenReturn(expectedTopic);
+ when(objectMapper.writeValueAsString(any())).thenReturn(serializedKey, serializedMessage);
+
+ service.sendRsuStatus(key, message);
+
+ ArgumentCaptor topicCaptor = ArgumentCaptor.forClass(String.class);
+ verify(kafkaTemplate).send(topicCaptor.capture(), eq(serializedKey), eq(serializedMessage));
+ assertEquals(expectedTopic, topicCaptor.getValue());
+ }
+
+ @Test
+ public void testSendRsuStatus_WithComplexRsuState() throws JsonProcessingException {
+ RsuIntersectionKey key = new RsuIntersectionKey();
+ key.setRsuId("rsu-456");
+ key.setIntersectionId(67890);
+
+ RsuState message = new RsuState();
+
+ String expectedTopic = "monitoring-status-topic";
+ String serializedKey = "{\"key\":\"data\"}";
+ String serializedMessage = "{\"complex\":\"state\",\"nested\":{\"data\":true}}";
+
+ when(kafkaTopics.getMonitoringStatus()).thenReturn(expectedTopic);
+ when(objectMapper.writeValueAsString(key)).thenReturn(serializedKey);
+ when(objectMapper.writeValueAsString(message)).thenReturn(serializedMessage);
+
+ service.sendRsuStatus(key, message);
+
+ verify(kafkaTemplate).send(expectedTopic, serializedKey, serializedMessage);
+ }
+
+ @Test
+ public void testSendRsuStatus_WithNullKey() throws JsonProcessingException {
+ RsuIntersectionKey key = null;
+ RsuState message = new RsuState();
+
+ String expectedTopic = "monitoring-status-topic";
+ String serializedMessage = "{\"state\":\"data\"}";
+ String serializedKey = "null";
+
+ when(kafkaTopics.getMonitoringStatus()).thenReturn(expectedTopic);
+ when(objectMapper.writeValueAsString(message)).thenReturn(serializedMessage);
+ when(objectMapper.writeValueAsString(key)).thenReturn(serializedKey);
+
+ service.sendRsuStatus(key, message);
+
+ verify(objectMapper).writeValueAsString(message);
+ verify(objectMapper).writeValueAsString(key);
+ verify(kafkaTemplate).send(expectedTopic, serializedKey, serializedMessage);
+ }
+
+ @Test
+ public void testSendRsuStatus_WithNullMessage() throws JsonProcessingException {
+ RsuIntersectionKey key = new RsuIntersectionKey();
+ RsuState message = null;
+
+ String expectedTopic = "monitoring-status-topic";
+ String serializedKey = "{}";
+ String serializedMessage = "null";
+
+ when(kafkaTopics.getMonitoringStatus()).thenReturn(expectedTopic);
+ when(objectMapper.writeValueAsString(message)).thenReturn(serializedMessage);
+ when(objectMapper.writeValueAsString(key)).thenReturn(serializedKey);
+
+ service.sendRsuStatus(key, message);
+
+ verify(objectMapper).writeValueAsString(message);
+ verify(objectMapper).writeValueAsString(key);
+ verify(kafkaTemplate).send(expectedTopic, serializedKey, serializedMessage);
+ }
+}
diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/services/PostgresServiceTest.java b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/services/PostgresServiceTest.java
new file mode 100644
index 000000000..fc2cca81a
--- /dev/null
+++ b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/services/PostgresServiceTest.java
@@ -0,0 +1,99 @@
+package us.dot.its.jpo.rsustatusmonitor.services;
+
+import jakarta.persistence.EntityManager;
+import jakarta.persistence.TypedQuery;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import us.dot.its.jpo.rsustatusmonitor.models.postgres.derived.RsuData;
+import us.dot.its.jpo.rsustatusmonitor.models.postgres.derived.RsuSnmpCredentials;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+public class PostgresServiceTest {
+
+ @Mock
+ private EntityManager entityManager;
+
+ @Mock
+ private TypedQuery rsuCredentialsQuery;
+
+ @Mock
+ private TypedQuery rsuDataQuery;
+
+ @InjectMocks
+ private PostgresService service;
+
+ @Test
+ public void testGetRsusWithCredentials_AllRsus_Success() {
+ RsuSnmpCredentials cred1 = new RsuSnmpCredentials(1, "192.168.1.1", "user1", "pass1", "encPass1", "SNMPv3",
+ "12345");
+ RsuSnmpCredentials cred2 = new RsuSnmpCredentials(2, "192.168.1.2", "user2", "pass2", "encPass2", "SNMPv2c",
+ "67890");
+ List expectedCredentials = Arrays.asList(cred1, cred2);
+
+ when(entityManager.createQuery(anyString(), eq(RsuSnmpCredentials.class))).thenReturn(rsuCredentialsQuery);
+ when(rsuCredentialsQuery.getResultList()).thenReturn(expectedCredentials);
+
+ List result = service.getRsusWithCredentials();
+
+ assertNotNull(result);
+ assertEquals(2, result.size());
+ assertEquals(1, result.get(0).getRsu_id());
+ assertEquals("192.168.1.1", result.get(0).getIpv4_address());
+ assertEquals("12345", result.get(0).getIntersection_id());
+ verify(entityManager).createQuery(anyString(), eq(RsuSnmpCredentials.class));
+ verify(rsuCredentialsQuery).getResultList();
+ }
+
+ @Test
+ public void testGetRsusWithCredentials_AllRsus_EmptyResult() {
+ when(entityManager.createQuery(anyString(), eq(RsuSnmpCredentials.class))).thenReturn(rsuCredentialsQuery);
+ when(rsuCredentialsQuery.getResultList()).thenReturn(Collections.emptyList());
+
+ List result = service.getRsusWithCredentials();
+
+ assertNotNull(result);
+ assertTrue(result.isEmpty());
+ verify(entityManager).createQuery(anyString(), eq(RsuSnmpCredentials.class));
+ verify(rsuCredentialsQuery).getResultList();
+ }
+
+ @Test
+ public void testGetRsusWithCredentials_AllRsus_WithNullIntersectionId() {
+ RsuSnmpCredentials credWithNullIntersection = new RsuSnmpCredentials(3, "192.168.1.3", "user3", "pass3",
+ "encPass3", "SNMPv3", null);
+ List expectedCredentials = Collections.singletonList(credWithNullIntersection);
+
+ when(entityManager.createQuery(anyString(), eq(RsuSnmpCredentials.class))).thenReturn(rsuCredentialsQuery);
+ when(rsuCredentialsQuery.getResultList()).thenReturn(expectedCredentials);
+
+ List result = service.getRsusWithCredentials();
+
+ assertNotNull(result);
+ assertEquals(1, result.size());
+ assertEquals(3, result.get(0).getRsu_id());
+ assertNull(result.get(0).getIntersection_id());
+ verify(entityManager).createQuery(anyString(), eq(RsuSnmpCredentials.class));
+ verify(rsuCredentialsQuery).getResultList();
+ }
+
+ @Test
+ public void testGetRsusWithCredentials_QueryExecutionException() {
+ when(entityManager.createQuery(anyString(), eq(RsuSnmpCredentials.class))).thenReturn(rsuCredentialsQuery);
+ when(rsuCredentialsQuery.getResultList()).thenThrow(new RuntimeException("Database error"));
+
+ assertThrows(RuntimeException.class, () -> service.getRsusWithCredentials());
+ verify(entityManager).createQuery(anyString(), eq(RsuSnmpCredentials.class));
+ verify(rsuCredentialsQuery).getResultList();
+ }
+}
diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/services/RsuQueryServiceTest.java b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/services/RsuQueryServiceTest.java
new file mode 100644
index 000000000..be260dea1
--- /dev/null
+++ b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/services/RsuQueryServiceTest.java
@@ -0,0 +1,351 @@
+package us.dot.its.jpo.rsustatusmonitor.services;
+
+import io.micrometer.core.instrument.MeterRegistry;
+import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.snmp4j.smi.Integer32;
+import org.snmp4j.smi.Variable;
+
+import us.dot.its.jpo.geojsonconverter.partitioner.RsuIntersectionKey;
+import us.dot.its.jpo.rsustatusmonitor.models.postgres.derived.RsuSnmpCredentials;
+import us.dot.its.jpo.rsustatusmonitor.models.snmp.OID;
+import us.dot.its.jpo.rsustatusmonitor.models.snmp.RsuState;
+
+import java.io.IOException;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+public class RsuQueryServiceTest {
+
+ @Mock
+ private SNMPService snmpService;
+
+ @Mock
+ private KafkaProducerService kafkaService;
+
+ private MeterRegistry meterRegistry;
+
+ @InjectMocks
+ private RsuQueryService service;
+
+ private RsuSnmpCredentials credentials;
+
+ @BeforeEach
+ public void setup() {
+ // Use a real SimpleMeterRegistry instead of mocking to avoid stubbing issues
+ meterRegistry = new SimpleMeterRegistry();
+ service = new RsuQueryService(snmpService, kafkaService, meterRegistry);
+
+ credentials = new RsuSnmpCredentials(1, "192.168.1.100", "testUser", "testPass", "encryptPass", "SNMPv3",
+ "12345");
+ }
+
+ @Test
+ public void testGetRsuInformation_Success() throws Exception {
+ Variable uptimeVar = new Integer32(3600);
+ Variable tempVar = new Integer32(45);
+ Variable modeVar = new Integer32(1);
+
+ when(snmpService.getSnmpV3Value(anyString(), anyString(), anyString(), anyString(), anyString()))
+ .thenReturn(uptimeVar, tempVar, modeVar);
+
+ doNothing().when(kafkaService).sendRsuStatus(any(RsuIntersectionKey.class), any(RsuState.class));
+
+ service.getRsuInformation(credentials);
+
+ // Allow async method to complete
+ Thread.sleep(100);
+
+ ArgumentCaptor keyCaptor = ArgumentCaptor.forClass(RsuIntersectionKey.class);
+ ArgumentCaptor stateCaptor = ArgumentCaptor.forClass(RsuState.class);
+
+ verify(kafkaService).sendRsuStatus(keyCaptor.capture(), stateCaptor.capture());
+
+ RsuIntersectionKey capturedKey = keyCaptor.getValue();
+ assertEquals(12345, capturedKey.getIntersectionId());
+ assertEquals("192.168.1.100", capturedKey.getRsuId());
+ assertEquals(-1, capturedKey.getRegion());
+
+ RsuState capturedState = stateCaptor.getValue();
+ assertEquals("192.168.1.100", capturedState.rsuIP);
+ assertEquals("12345", capturedState.intersectionID);
+ assertEquals(3600, capturedState.uptime);
+ assertEquals(45, capturedState.temperature);
+ assertEquals(1, capturedState.mode);
+ assertTrue(capturedState.timestamp > 0);
+
+ verify(snmpService, times(3)).getSnmpV3Value(anyString(), anyString(), anyString(), anyString(), anyString());
+ }
+
+ @Test
+ public void testGetRsuInformation_WithNullIntersectionId() throws Exception {
+ credentials = new RsuSnmpCredentials(2, "192.168.1.50", "user", "pass", "encPass", "SNMPv3", null);
+
+ Variable uptimeVar = new Integer32(7200);
+ Variable tempVar = new Integer32(50);
+ Variable modeVar = new Integer32(2);
+
+ when(snmpService.getSnmpV3Value(anyString(), anyString(), anyString(), anyString(), anyString()))
+ .thenReturn(uptimeVar, tempVar, modeVar);
+
+ doNothing().when(kafkaService).sendRsuStatus(any(RsuIntersectionKey.class), any(RsuState.class));
+
+ service.getRsuInformation(credentials);
+
+ // Allow async method to complete
+ Thread.sleep(100);
+
+ ArgumentCaptor keyCaptor = ArgumentCaptor.forClass(RsuIntersectionKey.class);
+ ArgumentCaptor stateCaptor = ArgumentCaptor.forClass(RsuState.class);
+
+ verify(kafkaService).sendRsuStatus(keyCaptor.capture(), stateCaptor.capture());
+
+ RsuIntersectionKey capturedKey = keyCaptor.getValue();
+ assertEquals(-1, capturedKey.getIntersectionId());
+
+ RsuState capturedState = stateCaptor.getValue();
+ assertEquals("-1", capturedState.intersectionID);
+ }
+
+ @Test
+ public void testGetRsuInformation_MissingUsername() throws Exception {
+ credentials = new RsuSnmpCredentials(3, "192.168.1.75", null, "pass", "encPass", "SNMPv3", "54321");
+
+ service.getRsuInformation(credentials);
+
+ // Allow async method to complete
+ Thread.sleep(100);
+
+ verify(snmpService, never()).getSnmpV3Value(anyString(), anyString(), anyString(), anyString(), anyString());
+ verify(kafkaService, never()).sendRsuStatus(any(RsuIntersectionKey.class), any(RsuState.class));
+ }
+
+ @Test
+ public void testGetRsuInformation_MissingPassword() throws Exception {
+ credentials = new RsuSnmpCredentials(4, "192.168.1.80", "user", null, "encPass", "SNMPv3", "99999");
+
+ service.getRsuInformation(credentials);
+
+ // Allow async method to complete
+ Thread.sleep(100);
+
+ verify(snmpService, never()).getSnmpV3Value(anyString(), anyString(), anyString(), anyString(), anyString());
+ verify(kafkaService, never()).sendRsuStatus(any(RsuIntersectionKey.class), any(RsuState.class));
+ }
+
+ @Test
+ public void testGetRsuInformation_MissingIpAddress() throws Exception {
+ credentials = new RsuSnmpCredentials(5, null, "user", "pass", "encPass", "SNMPv3", "11111");
+
+ // The service will throw NullPointerException when trying to create a metric
+ // tag with null IP
+ // This happens before the null check in the service
+ assertThrows(NullPointerException.class, () -> {
+ service.getRsuInformation(credentials);
+ // Allow async method to complete
+ Thread.sleep(100);
+ });
+ }
+
+ @Test
+ public void testGetRsuInformation_NullEncryptPassword() throws Exception {
+ credentials = new RsuSnmpCredentials(6, "192.168.1.90", "user", "pass", null, "SNMPv3", "22222");
+
+ Variable uptimeVar = new Integer32(1800);
+ Variable tempVar = new Integer32(40);
+ Variable modeVar = new Integer32(0);
+
+ when(snmpService.getSnmpV3Value(anyString(), anyString(), eq("pass"), eq("pass"), anyString()))
+ .thenReturn(uptimeVar, tempVar, modeVar);
+
+ doNothing().when(kafkaService).sendRsuStatus(any(RsuIntersectionKey.class), any(RsuState.class));
+
+ service.getRsuInformation(credentials);
+
+ // Allow async method to complete
+ Thread.sleep(100);
+
+ verify(snmpService, times(3)).getSnmpV3Value(anyString(), anyString(), eq("pass"), eq("pass"), anyString());
+ verify(kafkaService).sendRsuStatus(any(RsuIntersectionKey.class), any(RsuState.class));
+ }
+
+ @Test
+ public void testGetIntOID_Success() throws Exception {
+ OID testOid = new OID("testOid", null, "1.2.3.4.5");
+ Variable var = new Integer32(100);
+
+ when(snmpService.getSnmpV3Value(anyString(), anyString(), anyString(), anyString(), anyString()))
+ .thenReturn(var);
+
+ int result = service.getIntOID("192.168.1.100", "user", "pass", "encPass", testOid);
+
+ assertEquals(100, result);
+ verify(snmpService).getSnmpV3Value("192.168.1.100", "user", "pass", "encPass", "1.2.3.4.5");
+ }
+
+ @Test
+ public void testGetIntOID_NullVariable() throws Exception {
+ OID testOid = new OID("testOid", null, "1.2.3.4.5");
+
+ when(snmpService.getSnmpV3Value(anyString(), anyString(), anyString(), anyString(), anyString()))
+ .thenReturn(null);
+
+ int result = service.getIntOID("192.168.1.100", "user", "pass", "encPass", testOid);
+
+ assertEquals(-1, result);
+ verify(snmpService).getSnmpV3Value("192.168.1.100", "user", "pass", "encPass", "1.2.3.4.5");
+ }
+
+ @Test
+ public void testGetIntOID_IOException() throws Exception {
+ OID testOid = new OID("testOid", null, "1.2.3.4.5");
+
+ when(snmpService.getSnmpV3Value(anyString(), anyString(), anyString(), anyString(), anyString()))
+ .thenThrow(new IOException("Connection timeout"));
+
+ int result = service.getIntOID("192.168.1.100", "user", "pass", "encPass", testOid);
+
+ assertEquals(-1, result);
+ verify(snmpService).getSnmpV3Value("192.168.1.100", "user", "pass", "encPass", "1.2.3.4.5");
+ }
+
+ @Test
+ public void testGetIntOID_DifferentValues() throws Exception {
+ OID testOid = new OID("testOid", null, "1.2.3.4.5");
+ Variable var1 = new Integer32(0);
+ Variable var2 = new Integer32(-5);
+ Variable var3 = new Integer32(Integer.MAX_VALUE);
+
+ when(snmpService.getSnmpV3Value(anyString(), anyString(), anyString(), anyString(), anyString()))
+ .thenReturn(var1, var2, var3);
+
+ int result1 = service.getIntOID("192.168.1.100", "user", "pass", "encPass", testOid);
+ int result2 = service.getIntOID("192.168.1.100", "user", "pass", "encPass", testOid);
+ int result3 = service.getIntOID("192.168.1.100", "user", "pass", "encPass", testOid);
+
+ assertEquals(0, result1);
+ assertEquals(-5, result2);
+ assertEquals(Integer.MAX_VALUE, result3);
+ verify(snmpService, times(3)).getSnmpV3Value(anyString(), anyString(), anyString(), anyString(), anyString());
+ }
+
+ @Test
+ public void testGetRsuInformation_PartialSnmpFailures() throws Exception {
+ Variable uptimeVar = new Integer32(5000);
+
+ when(snmpService.getSnmpV3Value(anyString(), anyString(), anyString(), anyString(), anyString()))
+ .thenReturn(uptimeVar, null, null);
+
+ doNothing().when(kafkaService).sendRsuStatus(any(RsuIntersectionKey.class), any(RsuState.class));
+
+ service.getRsuInformation(credentials);
+
+ // Allow async method to complete
+ Thread.sleep(100);
+
+ ArgumentCaptor stateCaptor = ArgumentCaptor.forClass(RsuState.class);
+ verify(kafkaService).sendRsuStatus(any(RsuIntersectionKey.class), stateCaptor.capture());
+
+ RsuState capturedState = stateCaptor.getValue();
+ assertEquals(5000, capturedState.uptime);
+ assertEquals(-1, capturedState.temperature);
+ assertEquals(-1, capturedState.mode);
+ }
+
+ @Test
+ public void testGetRsuInformation_AllSnmpFailures() throws Exception {
+ when(snmpService.getSnmpV3Value(anyString(), anyString(), anyString(), anyString(), anyString()))
+ .thenThrow(new IOException("SNMP timeout"));
+
+ doNothing().when(kafkaService).sendRsuStatus(any(RsuIntersectionKey.class), any(RsuState.class));
+
+ service.getRsuInformation(credentials);
+
+ // Allow async method to complete
+ Thread.sleep(100);
+
+ ArgumentCaptor stateCaptor = ArgumentCaptor.forClass(RsuState.class);
+ verify(kafkaService).sendRsuStatus(any(RsuIntersectionKey.class), stateCaptor.capture());
+
+ RsuState capturedState = stateCaptor.getValue();
+ assertEquals(-1, capturedState.uptime);
+ assertEquals(-1, capturedState.temperature);
+ assertEquals(-1, capturedState.mode);
+ }
+
+ @Test
+ public void testGetRsuInformation_AsyncExecution() throws Exception {
+ Variable var = new Integer32(100);
+ when(snmpService.getSnmpV3Value(anyString(), anyString(), anyString(), anyString(), anyString()))
+ .thenReturn(var);
+ doNothing().when(kafkaService).sendRsuStatus(any(RsuIntersectionKey.class), any(RsuState.class));
+
+ long startTime = System.currentTimeMillis();
+ service.getRsuInformation(credentials);
+ long endTime = System.currentTimeMillis();
+
+ assertTrue(endTime - startTime < 50, "Async method should return immediately");
+
+ // Allow async method to complete
+ Thread.sleep(100);
+
+ verify(kafkaService).sendRsuStatus(any(RsuIntersectionKey.class), any(RsuState.class));
+ }
+
+ @Test
+ public void testGetRsuInformation_MultipleRsusSequentially() throws Exception {
+ RsuSnmpCredentials cred1 = new RsuSnmpCredentials(1, "192.168.1.10", "user1", "pass1", "enc1", "SNMPv3",
+ "1000");
+ RsuSnmpCredentials cred2 = new RsuSnmpCredentials(2, "192.168.1.20", "user2", "pass2", "enc2", "SNMPv3",
+ "2000");
+
+ Variable var = new Integer32(100);
+ when(snmpService.getSnmpV3Value(anyString(), anyString(), anyString(), anyString(), anyString()))
+ .thenReturn(var);
+ doNothing().when(kafkaService).sendRsuStatus(any(RsuIntersectionKey.class), any(RsuState.class));
+
+ service.getRsuInformation(cred1);
+ service.getRsuInformation(cred2);
+
+ // Allow async methods to complete
+ Thread.sleep(100);
+
+ verify(kafkaService, times(2)).sendRsuStatus(any(RsuIntersectionKey.class), any(RsuState.class));
+ verify(snmpService, times(6)).getSnmpV3Value(anyString(), anyString(), anyString(), anyString(), anyString());
+ }
+
+ @Test
+ public void testGetIntOID_WithDifferentOIDs() throws Exception {
+ OID oid1 = new OID("uptime", null, "1.3.6.1.4.1.1206.4.2.3.5.3.0");
+ OID oid2 = new OID("temperature", null, "1.3.6.1.4.1.1206.4.2.3.5.4.0");
+ OID oid3 = new OID("mode", null, "1.3.6.1.4.1.1206.4.2.3.5.5.0");
+
+ Variable var1 = new Integer32(1000);
+ Variable var2 = new Integer32(35);
+ Variable var3 = new Integer32(1);
+
+ when(snmpService.getSnmpV3Value(eq("192.168.1.100"), eq("user"), eq("pass"), eq("encPass"), eq(oid1.getOid())))
+ .thenReturn(var1);
+ when(snmpService.getSnmpV3Value(eq("192.168.1.100"), eq("user"), eq("pass"), eq("encPass"), eq(oid2.getOid())))
+ .thenReturn(var2);
+ when(snmpService.getSnmpV3Value(eq("192.168.1.100"), eq("user"), eq("pass"), eq("encPass"), eq(oid3.getOid())))
+ .thenReturn(var3);
+
+ int uptime = service.getIntOID("192.168.1.100", "user", "pass", "encPass", oid1);
+ int temp = service.getIntOID("192.168.1.100", "user", "pass", "encPass", oid2);
+ int mode = service.getIntOID("192.168.1.100", "user", "pass", "encPass", oid3);
+
+ assertEquals(1000, uptime);
+ assertEquals(35, temp);
+ assertEquals(1, mode);
+ }
+}
diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/services/SNMPServiceTest.java b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/services/SNMPServiceTest.java
new file mode 100644
index 000000000..253adb62f
--- /dev/null
+++ b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/services/SNMPServiceTest.java
@@ -0,0 +1,536 @@
+package us.dot.its.jpo.rsustatusmonitor.services;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+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.MockedConstruction;
+import org.mockito.MockedStatic;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.snmp4j.PDU;
+import org.snmp4j.ScopedPDU;
+import org.snmp4j.Snmp;
+import org.snmp4j.Target;
+import org.snmp4j.event.ResponseEvent;
+import org.snmp4j.mp.MPv3;
+import org.snmp4j.security.SecurityModels;
+import org.snmp4j.security.SecurityProtocols;
+import org.snmp4j.security.USM;
+import org.snmp4j.smi.Integer32;
+import org.snmp4j.smi.OID;
+import org.snmp4j.smi.OctetString;
+import org.snmp4j.smi.UdpAddress;
+import org.snmp4j.smi.Variable;
+import org.snmp4j.smi.VariableBinding;
+import org.snmp4j.transport.DefaultUdpTransportMapping;
+import us.dot.its.jpo.rsustatusmonitor.snmp.SnmpProperties;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+@SuppressWarnings({ "unchecked", "unused" })
+@ExtendWith(MockitoExtension.class)
+public class SNMPServiceTest {
+
+ @Mock
+ private SnmpProperties snmpProperties;
+
+ @Mock
+ private Snmp snmp;
+
+ @Mock
+ private DefaultUdpTransportMapping transport;
+
+ @Mock
+ private ResponseEvent responseEvent;
+
+ @Mock
+ private PDU responsePdu;
+
+ @Mock
+ private ScopedPDU scopedResponsePdu;
+
+ private SNMPService service;
+
+ @BeforeEach
+ public void setup() {
+ lenient().when(snmpProperties.getPort()).thenReturn(161);
+ lenient().when(snmpProperties.getRetries()).thenReturn(2);
+ lenient().when(snmpProperties.getTimeout()).thenReturn(5000);
+
+ service = new SNMPService(snmpProperties);
+ }
+
+ @Test
+ public void testGetSnmpV2Value_Success() throws Exception {
+ String ipAddress = "192.168.1.100";
+ String community = "public";
+ String oid = "1.3.6.1.2.1.1.1.0";
+ String expectedValue = "Test Device";
+
+ VariableBinding vb = new VariableBinding(new OID(oid), new OctetString(expectedValue));
+ when(responsePdu.get(0)).thenReturn(vb);
+ when(responseEvent.getResponse()).thenReturn(responsePdu);
+
+ try (MockedConstruction transportMock = mockConstruction(
+ DefaultUdpTransportMapping.class,
+ (mock, context) -> doNothing().when(mock).listen());
+ MockedConstruction snmpMock = mockConstruction(Snmp.class, (mock, context) -> {
+ when(mock.send(any(PDU.class), any(Target.class))).thenReturn(responseEvent);
+ doNothing().when(mock).close();
+ })) {
+
+ String result = service.getSnmpV2Value(ipAddress, community, oid);
+
+ assertEquals(expectedValue, result);
+ }
+ }
+
+ @Test
+ public void testGetSnmpV2Value_NullResponse() throws Exception {
+ String ipAddress = "192.168.1.100";
+ String community = "public";
+ String oid = "1.3.6.1.2.1.1.1.0";
+
+ try (MockedConstruction transportMock = mockConstruction(
+ DefaultUdpTransportMapping.class,
+ (mock, context) -> doNothing().when(mock).listen());
+ MockedConstruction snmpMock = mockConstruction(Snmp.class, (mock, context) -> {
+ when(mock.send(any(PDU.class), any(Target.class))).thenReturn(null);
+ doNothing().when(mock).close();
+ })) {
+
+ assertThrows(RuntimeException.class, () -> {
+ service.getSnmpV2Value(ipAddress, community, oid);
+ });
+ }
+ }
+
+ @Test
+ public void testGetSnmpV2Value_NullResponsePdu() throws Exception {
+ String ipAddress = "192.168.1.100";
+ String community = "public";
+ String oid = "1.3.6.1.2.1.1.1.0";
+
+ when(responseEvent.getResponse()).thenReturn(null);
+
+ try (MockedConstruction transportMock = mockConstruction(
+ DefaultUdpTransportMapping.class,
+ (mock, context) -> doNothing().when(mock).listen());
+ MockedConstruction snmpMock = mockConstruction(Snmp.class, (mock, context) -> {
+ when(mock.send(any(PDU.class), any(Target.class))).thenReturn(responseEvent);
+ doNothing().when(mock).close();
+ })) {
+
+ assertThrows(RuntimeException.class, () -> {
+ service.getSnmpV2Value(ipAddress, community, oid);
+ });
+ }
+ }
+
+ @Test
+ public void testGetSnmpV3Value_Success() throws Exception {
+ String ip = "192.168.1.100";
+ String username = "testUser";
+ String authPass = "authPassword";
+ String privPass = "privPassword";
+ String oid = "1.3.6.1.4.1.1206.4.2.3.5.3.0";
+
+ Variable expectedVariable = new Integer32(12345);
+ VariableBinding vb = new VariableBinding(new OID(oid), expectedVariable);
+ when(scopedResponsePdu.get(0)).thenReturn(vb);
+ when(responseEvent.getResponse()).thenReturn(scopedResponsePdu);
+
+ try (MockedConstruction transportMock = mockConstruction(
+ DefaultUdpTransportMapping.class,
+ (mock, context) -> doNothing().when(mock).listen());
+ MockedConstruction snmpMock = mockConstruction(Snmp.class, (mock, context) -> {
+ when(mock.send(any(ScopedPDU.class), any(Target.class))).thenReturn(responseEvent);
+ when(mock.getUSM()).thenReturn(mock(USM.class));
+ doNothing().when(mock).close();
+ });
+ MockedConstruction usmMock = mockConstruction(USM.class);
+ MockedStatic securityModelsMock = mockStatic(SecurityModels.class);
+ MockedStatic securityProtocolsMock = mockStatic(SecurityProtocols.class);
+ MockedStatic mpv3Mock = mockStatic(MPv3.class)) {
+
+ SecurityModels mockSecurityModels = mock(SecurityModels.class);
+ SecurityProtocols mockSecurityProtocols = mock(SecurityProtocols.class);
+
+ securityModelsMock.when(SecurityModels::getInstance).thenReturn(mockSecurityModels);
+ securityProtocolsMock.when(SecurityProtocols::getInstance).thenReturn(mockSecurityProtocols);
+ mpv3Mock.when(MPv3::createLocalEngineID).thenReturn(new byte[] { 1, 2, 3, 4, 5 });
+
+ Variable result = service.getSnmpV3Value(ip, username, authPass, privPass, oid);
+
+ assertNotNull(result);
+ assertEquals(12345, result.toInt());
+ }
+ }
+
+ @Test
+ public void testGetSnmpV3Value_NullResponse() throws Exception {
+ String ip = "192.168.1.100";
+ String username = "testUser";
+ String authPass = "authPassword";
+ String privPass = "privPassword";
+ String oid = "1.3.6.1.4.1.1206.4.2.3.5.3.0";
+
+ try (MockedConstruction transportMock = mockConstruction(
+ DefaultUdpTransportMapping.class,
+ (mock, context) -> doNothing().when(mock).listen());
+ MockedConstruction snmpMock = mockConstruction(Snmp.class, (mock, context) -> {
+ when(mock.send(any(ScopedPDU.class), any(Target.class))).thenReturn(null);
+ when(mock.getUSM()).thenReturn(mock(USM.class));
+ doNothing().when(mock).close();
+ });
+ MockedConstruction usmMock = mockConstruction(USM.class);
+ MockedStatic securityModelsMock = mockStatic(SecurityModels.class);
+ MockedStatic securityProtocolsMock = mockStatic(SecurityProtocols.class);
+ MockedStatic mpv3Mock = mockStatic(MPv3.class)) {
+
+ SecurityModels mockSecurityModels = mock(SecurityModels.class);
+ SecurityProtocols mockSecurityProtocols = mock(SecurityProtocols.class);
+
+ securityModelsMock.when(SecurityModels::getInstance).thenReturn(mockSecurityModels);
+ securityProtocolsMock.when(SecurityProtocols::getInstance).thenReturn(mockSecurityProtocols);
+ mpv3Mock.when(MPv3::createLocalEngineID).thenReturn(new byte[] { 1, 2, 3, 4, 5 });
+
+ Variable result = service.getSnmpV3Value(ip, username, authPass, privPass, oid);
+
+ assertNull(result);
+ }
+ }
+
+ @Test
+ public void testGetSnmpV3Value_NullResponsePdu() throws Exception {
+ String ip = "192.168.1.100";
+ String username = "testUser";
+ String authPass = "authPassword";
+ String privPass = "privPassword";
+ String oid = "1.3.6.1.4.1.1206.4.2.3.5.3.0";
+
+ when(responseEvent.getResponse()).thenReturn(null);
+
+ try (MockedConstruction transportMock = mockConstruction(
+ DefaultUdpTransportMapping.class,
+ (mock, context) -> doNothing().when(mock).listen());
+ MockedConstruction snmpMock = mockConstruction(Snmp.class, (mock, context) -> {
+ when(mock.send(any(ScopedPDU.class), any(Target.class))).thenReturn(responseEvent);
+ when(mock.getUSM()).thenReturn(mock(USM.class));
+ doNothing().when(mock).close();
+ });
+ MockedConstruction usmMock = mockConstruction(USM.class);
+ MockedStatic securityModelsMock = mockStatic(SecurityModels.class);
+ MockedStatic securityProtocolsMock = mockStatic(SecurityProtocols.class);
+ MockedStatic mpv3Mock = mockStatic(MPv3.class)) {
+
+ SecurityModels mockSecurityModels = mock(SecurityModels.class);
+ SecurityProtocols mockSecurityProtocols = mock(SecurityProtocols.class);
+
+ securityModelsMock.when(SecurityModels::getInstance).thenReturn(mockSecurityModels);
+ securityProtocolsMock.when(SecurityProtocols::getInstance).thenReturn(mockSecurityProtocols);
+ mpv3Mock.when(MPv3::createLocalEngineID).thenReturn(new byte[] { 1, 2, 3, 4, 5 });
+
+ Variable result = service.getSnmpV3Value(ip, username, authPass, privPass, oid);
+
+ assertNull(result);
+ }
+ }
+
+ @Test
+ public void testGetSnmpV3Value_IOException() throws Exception {
+ String ip = "192.168.1.100";
+ String username = "testUser";
+ String authPass = "authPassword";
+ String privPass = "privPassword";
+ String oid = "1.3.6.1.4.1.1206.4.2.3.5.3.0";
+
+ try (MockedConstruction transportMock = mockConstruction(
+ DefaultUdpTransportMapping.class,
+ (mock, context) -> doThrow(new IOException("Network error")).when(mock).listen());
+ MockedConstruction snmpMock = mockConstruction(Snmp.class);
+ MockedConstruction usmMock = mockConstruction(USM.class);
+ MockedStatic securityModelsMock = mockStatic(SecurityModels.class);
+ MockedStatic securityProtocolsMock = mockStatic(SecurityProtocols.class);
+ MockedStatic mpv3Mock = mockStatic(MPv3.class)) {
+
+ SecurityModels mockSecurityModels = mock(SecurityModels.class);
+ SecurityProtocols mockSecurityProtocols = mock(SecurityProtocols.class);
+
+ securityModelsMock.when(SecurityModels::getInstance).thenReturn(mockSecurityModels);
+ securityProtocolsMock.when(SecurityProtocols::getInstance).thenReturn(mockSecurityProtocols);
+ mpv3Mock.when(MPv3::createLocalEngineID).thenReturn(new byte[] { 1, 2, 3, 4, 5 });
+
+ assertThrows(IOException.class, () -> {
+ service.getSnmpV3Value(ip, username, authPass, privPass, oid);
+ });
+ }
+ }
+
+ @Test
+ public void testSetSnmpV3Value_Success() throws Exception {
+ String ipAddress = "192.168.1.100";
+ String username = "testUser";
+ String authPass = "authPassword";
+ String oid = "1.3.6.1.4.1.1206.4.2.3.5.5.0";
+ int intValue = 42;
+
+ when(scopedResponsePdu.getErrorStatus()).thenReturn(PDU.noError);
+ when(responseEvent.getResponse()).thenReturn(scopedResponsePdu);
+
+ try (MockedConstruction transportMock = mockConstruction(
+ DefaultUdpTransportMapping.class,
+ (mock, context) -> doNothing().when(mock).listen());
+ MockedConstruction snmpMock = mockConstruction(Snmp.class, (mock, context) -> {
+ when(mock.send(any(ScopedPDU.class), any(Target.class))).thenReturn(responseEvent);
+ when(mock.getUSM()).thenReturn(mock(USM.class));
+ doNothing().when(mock).close();
+ });
+ MockedConstruction usmMock = mockConstruction(USM.class);
+ MockedStatic securityModelsMock = mockStatic(SecurityModels.class);
+ MockedStatic securityProtocolsMock = mockStatic(SecurityProtocols.class);
+ MockedStatic mpv3Mock = mockStatic(MPv3.class)) {
+
+ SecurityModels mockSecurityModels = mock(SecurityModels.class);
+ SecurityProtocols mockSecurityProtocols = mock(SecurityProtocols.class);
+
+ securityModelsMock.when(SecurityModels::getInstance).thenReturn(mockSecurityModels);
+ securityProtocolsMock.when(SecurityProtocols::getInstance).thenReturn(mockSecurityProtocols);
+ mpv3Mock.when(MPv3::createLocalEngineID).thenReturn(new byte[] { 1, 2, 3, 4, 5 });
+
+ service.setSnmpV3Value(ipAddress, username, authPass, oid, intValue);
+
+ assertTrue(true);
+ }
+ }
+
+ @Test
+ public void testSetSnmpV3Value_NullResponse() throws Exception {
+ String ipAddress = "192.168.1.100";
+ String username = "testUser";
+ String authPass = "authPassword";
+ String oid = "1.3.6.1.4.1.1206.4.2.3.5.5.0";
+ int intValue = 42;
+
+ try (MockedConstruction transportMock = mockConstruction(
+ DefaultUdpTransportMapping.class,
+ (mock, context) -> doNothing().when(mock).listen());
+ MockedConstruction snmpMock = mockConstruction(Snmp.class, (mock, context) -> {
+ when(mock.send(any(ScopedPDU.class), any(Target.class))).thenReturn(null);
+ when(mock.getUSM()).thenReturn(mock(USM.class));
+ doNothing().when(mock).close();
+ });
+ MockedConstruction usmMock = mockConstruction(USM.class);
+ MockedStatic securityModelsMock = mockStatic(SecurityModels.class);
+ MockedStatic securityProtocolsMock = mockStatic(SecurityProtocols.class);
+ MockedStatic mpv3Mock = mockStatic(MPv3.class)) {
+
+ SecurityModels mockSecurityModels = mock(SecurityModels.class);
+ SecurityProtocols mockSecurityProtocols = mock(SecurityProtocols.class);
+
+ securityModelsMock.when(SecurityModels::getInstance).thenReturn(mockSecurityModels);
+ securityProtocolsMock.when(SecurityProtocols::getInstance).thenReturn(mockSecurityProtocols);
+ mpv3Mock.when(MPv3::createLocalEngineID).thenReturn(new byte[] { 1, 2, 3, 4, 5 });
+
+ assertThrows(NullPointerException.class, () -> {
+ service.setSnmpV3Value(ipAddress, username, authPass, oid, intValue);
+ });
+ }
+ }
+
+ @Test
+ public void testSetSnmpV3Value_ErrorStatus() throws Exception {
+ String ipAddress = "192.168.1.100";
+ String username = "testUser";
+ String authPass = "authPassword";
+ String oid = "1.3.6.1.4.1.1206.4.2.3.5.5.0";
+ int intValue = 42;
+
+ when(scopedResponsePdu.getErrorStatus()).thenReturn(PDU.genErr);
+ when(responseEvent.getResponse()).thenReturn(scopedResponsePdu);
+
+ try (MockedConstruction transportMock = mockConstruction(
+ DefaultUdpTransportMapping.class,
+ (mock, context) -> doNothing().when(mock).listen());
+ MockedConstruction snmpMock = mockConstruction(Snmp.class, (mock, context) -> {
+ when(mock.send(any(ScopedPDU.class), any(Target.class))).thenReturn(responseEvent);
+ when(mock.getUSM()).thenReturn(mock(USM.class));
+ doNothing().when(mock).close();
+ });
+ MockedConstruction usmMock = mockConstruction(USM.class);
+ MockedStatic securityModelsMock = mockStatic(SecurityModels.class);
+ MockedStatic securityProtocolsMock = mockStatic(SecurityProtocols.class);
+ MockedStatic mpv3Mock = mockStatic(MPv3.class)) {
+
+ SecurityModels mockSecurityModels = mock(SecurityModels.class);
+ SecurityProtocols mockSecurityProtocols = mock(SecurityProtocols.class);
+
+ securityModelsMock.when(SecurityModels::getInstance).thenReturn(mockSecurityModels);
+ securityProtocolsMock.when(SecurityProtocols::getInstance).thenReturn(mockSecurityProtocols);
+ mpv3Mock.when(MPv3::createLocalEngineID).thenReturn(new byte[] { 1, 2, 3, 4, 5 });
+
+ assertDoesNotThrow(() -> {
+ service.setSnmpV3Value(ipAddress, username, authPass, oid, intValue);
+ });
+ }
+ }
+
+ @Test
+ public void testSetSnmpV3Values_Success() throws Exception {
+ String ipAddress = "192.168.1.100";
+ String username = "testUser";
+ String authPass = "authPassword";
+ Map oidValuePairs = new HashMap<>();
+ oidValuePairs.put("1.3.6.1.4.1.1206.4.2.3.5.5.0", new Integer32(1));
+ oidValuePairs.put("1.3.6.1.4.1.1206.4.2.3.5.6.0", new Integer32(2));
+ oidValuePairs.put("1.3.6.1.4.1.1206.4.2.3.5.7.0", new OctetString("test"));
+
+ when(scopedResponsePdu.getErrorStatus()).thenReturn(PDU.noError);
+ when(responseEvent.getResponse()).thenReturn(scopedResponsePdu);
+
+ try (MockedConstruction transportMock = mockConstruction(
+ DefaultUdpTransportMapping.class,
+ (mock, context) -> doNothing().when(mock).listen());
+ MockedConstruction snmpMock = mockConstruction(Snmp.class, (mock, context) -> {
+ when(mock.send(any(ScopedPDU.class), any(Target.class))).thenReturn(responseEvent);
+ when(mock.getUSM()).thenReturn(mock(USM.class));
+ doNothing().when(mock).close();
+ });
+ MockedConstruction usmMock = mockConstruction(USM.class);
+ MockedStatic securityModelsMock = mockStatic(SecurityModels.class);
+ MockedStatic securityProtocolsMock = mockStatic(SecurityProtocols.class);
+ MockedStatic mpv3Mock = mockStatic(MPv3.class)) {
+
+ SecurityModels mockSecurityModels = mock(SecurityModels.class);
+ SecurityProtocols mockSecurityProtocols = mock(SecurityProtocols.class);
+
+ securityModelsMock.when(SecurityModels::getInstance).thenReturn(mockSecurityModels);
+ securityProtocolsMock.when(SecurityProtocols::getInstance).thenReturn(mockSecurityProtocols);
+ mpv3Mock.when(MPv3::createLocalEngineID).thenReturn(new byte[] { 1, 2, 3, 4, 5 });
+
+ service.setSnmpV3Values(ipAddress, username, authPass, oidValuePairs);
+
+ assertTrue(true);
+ }
+ }
+
+ @Test
+ public void testSetSnmpV3Values_EmptyMap() throws Exception {
+ String ipAddress = "192.168.1.100";
+ String username = "testUser";
+ String authPass = "authPassword";
+ Map oidValuePairs = new HashMap<>();
+
+ when(scopedResponsePdu.getErrorStatus()).thenReturn(PDU.noError);
+ when(responseEvent.getResponse()).thenReturn(scopedResponsePdu);
+
+ try (MockedConstruction transportMock = mockConstruction(
+ DefaultUdpTransportMapping.class,
+ (mock, context) -> doNothing().when(mock).listen());
+ MockedConstruction snmpMock = mockConstruction(Snmp.class, (mock, context) -> {
+ when(mock.send(any(ScopedPDU.class), any(Target.class))).thenReturn(responseEvent);
+ when(mock.getUSM()).thenReturn(mock(USM.class));
+ doNothing().when(mock).close();
+ });
+ MockedConstruction usmMock = mockConstruction(USM.class);
+ MockedStatic securityModelsMock = mockStatic(SecurityModels.class);
+ MockedStatic securityProtocolsMock = mockStatic(SecurityProtocols.class);
+ MockedStatic mpv3Mock = mockStatic(MPv3.class)) {
+
+ SecurityModels mockSecurityModels = mock(SecurityModels.class);
+ SecurityProtocols mockSecurityProtocols = mock(SecurityProtocols.class);
+
+ securityModelsMock.when(SecurityModels::getInstance).thenReturn(mockSecurityModels);
+ securityProtocolsMock.when(SecurityProtocols::getInstance).thenReturn(mockSecurityProtocols);
+ mpv3Mock.when(MPv3::createLocalEngineID).thenReturn(new byte[] { 1, 2, 3, 4, 5 });
+
+ service.setSnmpV3Values(ipAddress, username, authPass, oidValuePairs);
+
+ assertTrue(true);
+ }
+ }
+
+ @Test
+ public void testSetSnmpV3Values_NullResponse() throws Exception {
+ String ipAddress = "192.168.1.100";
+ String username = "testUser";
+ String authPass = "authPassword";
+ Map oidValuePairs = new HashMap<>();
+ oidValuePairs.put("1.3.6.1.4.1.1206.4.2.3.5.5.0", new Integer32(1));
+
+ try (MockedConstruction transportMock = mockConstruction(
+ DefaultUdpTransportMapping.class,
+ (mock, context) -> doNothing().when(mock).listen());
+ MockedConstruction snmpMock = mockConstruction(Snmp.class, (mock, context) -> {
+ when(mock.send(any(ScopedPDU.class), any(Target.class))).thenReturn(null);
+ when(mock.getUSM()).thenReturn(mock(USM.class));
+ doNothing().when(mock).close();
+ });
+ MockedConstruction usmMock = mockConstruction(USM.class);
+ MockedStatic securityModelsMock = mockStatic(SecurityModels.class);
+ MockedStatic securityProtocolsMock = mockStatic(SecurityProtocols.class);
+ MockedStatic mpv3Mock = mockStatic(MPv3.class)) {
+
+ SecurityModels mockSecurityModels = mock(SecurityModels.class);
+ SecurityProtocols mockSecurityProtocols = mock(SecurityProtocols.class);
+
+ securityModelsMock.when(SecurityModels::getInstance).thenReturn(mockSecurityModels);
+ securityProtocolsMock.when(SecurityProtocols::getInstance).thenReturn(mockSecurityProtocols);
+ mpv3Mock.when(MPv3::createLocalEngineID).thenReturn(new byte[] { 1, 2, 3, 4, 5 });
+
+ assertThrows(NullPointerException.class, () -> {
+ service.setSnmpV3Values(ipAddress, username, authPass, oidValuePairs);
+ });
+ }
+ }
+
+ @Test
+ public void testSetSnmpV3Values_ErrorStatus() throws Exception {
+ String ipAddress = "192.168.1.100";
+ String username = "testUser";
+ String authPass = "authPassword";
+ Map oidValuePairs = new HashMap<>();
+ oidValuePairs.put("1.3.6.1.4.1.1206.4.2.3.5.5.0", new Integer32(1));
+
+ when(scopedResponsePdu.getErrorStatus()).thenReturn(PDU.badValue);
+ when(scopedResponsePdu.getErrorStatusText()).thenReturn("Bad value");
+ when(responseEvent.getResponse()).thenReturn(scopedResponsePdu);
+
+ try (MockedConstruction transportMock = mockConstruction(
+ DefaultUdpTransportMapping.class,
+ (mock, context) -> doNothing().when(mock).listen());
+ MockedConstruction snmpMock = mockConstruction(Snmp.class, (mock, context) -> {
+ when(mock.send(any(ScopedPDU.class), any(Target.class))).thenReturn(responseEvent);
+ when(mock.getUSM()).thenReturn(mock(USM.class));
+ doNothing().when(mock).close();
+ });
+ MockedConstruction usmMock = mockConstruction(USM.class);
+ MockedStatic securityModelsMock = mockStatic(SecurityModels.class);
+ MockedStatic securityProtocolsMock = mockStatic(SecurityProtocols.class);
+ MockedStatic mpv3Mock = mockStatic(MPv3.class)) {
+
+ SecurityModels mockSecurityModels = mock(SecurityModels.class);
+ SecurityProtocols mockSecurityProtocols = mock(SecurityProtocols.class);
+
+ securityModelsMock.when(SecurityModels::getInstance).thenReturn(mockSecurityModels);
+ securityProtocolsMock.when(SecurityProtocols::getInstance).thenReturn(mockSecurityProtocols);
+ mpv3Mock.when(MPv3::createLocalEngineID).thenReturn(new byte[] { 1, 2, 3, 4, 5 });
+
+ // should log warning but not throw exception
+ assertDoesNotThrow(() -> {
+ service.setSnmpV3Values(ipAddress, username, authPass, oidValuePairs);
+ });
+ }
+ }
+
+ @Test
+ public void testConstructor() {
+ SNMPService newService = new SNMPService(snmpProperties);
+ assertNotNull(newService);
+ }
+}
diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/tasks/RsuMonitoringTaskTest.java b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/tasks/RsuMonitoringTaskTest.java
new file mode 100644
index 000000000..fb3bbdcbf
--- /dev/null
+++ b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/tasks/RsuMonitoringTaskTest.java
@@ -0,0 +1,315 @@
+package us.dot.its.jpo.rsustatusmonitor.tasks;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+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;
+import us.dot.its.jpo.rsustatusmonitor.models.postgres.derived.RsuSnmpCredentials;
+import us.dot.its.jpo.rsustatusmonitor.services.PostgresService;
+import us.dot.its.jpo.rsustatusmonitor.services.RsuQueryService;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+public class RsuMonitoringTaskTest {
+
+ @Mock
+ private PostgresService postgresService;
+
+ @Mock
+ private RsuQueryService rsuQueryService;
+
+ private RsuMonitoringTask task;
+
+ @BeforeEach
+ public void setup() {
+ task = new RsuMonitoringTask(rsuQueryService, postgresService);
+ }
+
+ @Test
+ public void testQueryRSUStats_NoCredentials() {
+ when(postgresService.getRsusWithCredentials()).thenReturn(new ArrayList<>());
+
+ task.queryRSUStats();
+
+ verify(postgresService).getRsusWithCredentials();
+ verify(rsuQueryService, never()).getRsuInformation(any());
+ }
+
+ @Test
+ public void testQueryRSUStats_SingleRsu() {
+ List credentials = new ArrayList<>();
+ RsuSnmpCredentials cred1 = new RsuSnmpCredentials(1, "192.168.1.1", "user1", "pass1", "encPass1", "SNMPv3",
+ "12345");
+ credentials.add(cred1);
+
+ when(postgresService.getRsusWithCredentials()).thenReturn(credentials);
+
+ task.queryRSUStats();
+
+ verify(postgresService).getRsusWithCredentials();
+ verify(rsuQueryService, times(1)).getRsuInformation(cred1);
+ }
+
+ @Test
+ public void testQueryRSUStats_MultipleRsus() {
+ List credentials = new ArrayList<>();
+ RsuSnmpCredentials cred1 = new RsuSnmpCredentials(1, "192.168.1.1", "user1", "pass1", "encPass1", "SNMPv3",
+ "12345");
+ RsuSnmpCredentials cred2 = new RsuSnmpCredentials(2, "192.168.1.2", "user2", "pass2", "encPass2", "SNMPv3",
+ "12346");
+ RsuSnmpCredentials cred3 = new RsuSnmpCredentials(3, "192.168.1.3", "user3", "pass3", "encPass3", "SNMPv3",
+ "12347");
+ credentials.add(cred1);
+ credentials.add(cred2);
+ credentials.add(cred3);
+
+ when(postgresService.getRsusWithCredentials()).thenReturn(credentials);
+
+ task.queryRSUStats();
+
+ verify(postgresService).getRsusWithCredentials();
+ verify(rsuQueryService, times(1)).getRsuInformation(cred1);
+ verify(rsuQueryService, times(1)).getRsuInformation(cred2);
+ verify(rsuQueryService, times(1)).getRsuInformation(cred3);
+ }
+
+ @Test
+ public void testQueryRSUStats_ParallelProcessing() throws InterruptedException {
+ List credentials = new ArrayList<>();
+ for (int i = 1; i <= 10; i++) {
+ credentials.add(new RsuSnmpCredentials(i, "192.168.1." + i, "user" + i, "pass" + i, "encPass" + i, "SNMPv3",
+ String.valueOf(10000 + i)));
+ }
+
+ when(postgresService.getRsusWithCredentials()).thenReturn(credentials);
+
+ CountDownLatch latch = new CountDownLatch(10);
+ AtomicInteger concurrentCalls = new AtomicInteger(0);
+ AtomicInteger maxConcurrent = new AtomicInteger(0);
+
+ doAnswer(invocation -> {
+ int current = concurrentCalls.incrementAndGet();
+ maxConcurrent.updateAndGet(max -> Math.max(max, current));
+
+ // Simulate some processing time
+ Thread.sleep(50);
+
+ concurrentCalls.decrementAndGet();
+ latch.countDown();
+ return null;
+ }).when(rsuQueryService).getRsuInformation(any(RsuSnmpCredentials.class));
+
+ task.queryRSUStats();
+
+ assertTrue(latch.await(10, TimeUnit.SECONDS), "All tasks should complete within timeout");
+ verify(rsuQueryService, times(10)).getRsuInformation(any(RsuSnmpCredentials.class));
+
+ // Verify parallel execution (should have more than 1 concurrent call)
+ assertTrue(maxConcurrent.get() > 1, "Should have parallel execution with multiple concurrent calls");
+ }
+
+ @Test
+ public void testQueryRSUStats_ExceptionHandling_SingleRsuFailure() {
+ List credentials = new ArrayList<>();
+ RsuSnmpCredentials cred1 = new RsuSnmpCredentials(1, "192.168.1.1", "user1", "pass1", "encPass1", "SNMPv3",
+ "12345");
+ RsuSnmpCredentials cred2 = new RsuSnmpCredentials(2, "192.168.1.2", "user2", "pass2", "encPass2", "SNMPv3",
+ "12346");
+ credentials.add(cred1);
+ credentials.add(cred2);
+
+ when(postgresService.getRsusWithCredentials()).thenReturn(credentials);
+
+ // First RSU throws exception, second succeeds
+ doThrow(new RuntimeException("SNMP connection failed")).when(rsuQueryService).getRsuInformation(cred1);
+ doNothing().when(rsuQueryService).getRsuInformation(cred2);
+
+ task.queryRSUStats();
+
+ verify(postgresService).getRsusWithCredentials();
+ verify(rsuQueryService, times(1)).getRsuInformation(cred1);
+ verify(rsuQueryService, times(1)).getRsuInformation(cred2);
+ }
+
+ @Test
+ public void testQueryRSUStats_ExceptionHandling_AllRsusFailure() {
+ List credentials = new ArrayList<>();
+ RsuSnmpCredentials cred1 = new RsuSnmpCredentials(1, "192.168.1.1", "user1", "pass1", "encPass1", "SNMPv3",
+ "12345");
+ RsuSnmpCredentials cred2 = new RsuSnmpCredentials(2, "192.168.1.2", "user2", "pass2", "encPass2", "SNMPv3",
+ "12346");
+ credentials.add(cred1);
+ credentials.add(cred2);
+
+ when(postgresService.getRsusWithCredentials()).thenReturn(credentials);
+ doThrow(new RuntimeException("Network error")).when(rsuQueryService)
+ .getRsuInformation(any(RsuSnmpCredentials.class));
+
+ task.queryRSUStats();
+
+ verify(postgresService).getRsusWithCredentials();
+ verify(rsuQueryService, times(2)).getRsuInformation(any(RsuSnmpCredentials.class));
+ }
+
+ @Test
+ public void testQueryRSUStats_LargeNumberOfRsus() {
+ // Test with 50 RSUs to verify thread pool handling
+ List credentials = new ArrayList<>();
+ for (int i = 1; i <= 50; i++) {
+ credentials.add(new RsuSnmpCredentials(i, "192.168.1." + i, "user" + i, "pass" + i, "encPass" + i, "SNMPv3",
+ String.valueOf(20000 + i)));
+ }
+
+ when(postgresService.getRsusWithCredentials()).thenReturn(credentials);
+
+ task.queryRSUStats();
+
+ verify(postgresService).getRsusWithCredentials();
+ verify(rsuQueryService, times(50)).getRsuInformation(any(RsuSnmpCredentials.class));
+ }
+
+ @Test
+ public void testQueryRSUStats_WaitsForAllTasksToComplete() throws InterruptedException {
+ List credentials = new ArrayList<>();
+ for (int i = 1; i <= 5; i++) {
+ credentials.add(new RsuSnmpCredentials(i, "192.168.1." + i, "user" + i, "pass" + i, "encPass" + i, "SNMPv3",
+ String.valueOf(30000 + i)));
+ }
+
+ when(postgresService.getRsusWithCredentials()).thenReturn(credentials);
+
+ AtomicInteger completedCount = new AtomicInteger(0);
+
+ doAnswer(invocation -> {
+ Thread.sleep(100); // Simulate processing time
+ completedCount.incrementAndGet();
+ return null;
+ }).when(rsuQueryService).getRsuInformation(any(RsuSnmpCredentials.class));
+
+ task.queryRSUStats();
+
+ assertEquals(5, completedCount.get(), "All tasks should be completed before method returns");
+ }
+
+ @Test
+ public void testQueryRSUStats_MixedSuccessAndFailure() {
+ List credentials = new ArrayList<>();
+ RsuSnmpCredentials cred1 = new RsuSnmpCredentials(1, "192.168.1.1", "user1", "pass1", "encPass1", "SNMPv3",
+ "12345");
+ RsuSnmpCredentials cred2 = new RsuSnmpCredentials(2, "192.168.1.2", "user2", "pass2", "encPass2", "SNMPv3",
+ "12346");
+ RsuSnmpCredentials cred3 = new RsuSnmpCredentials(3, "192.168.1.3", "user3", "pass3", "encPass3", "SNMPv3",
+ "12347");
+ RsuSnmpCredentials cred4 = new RsuSnmpCredentials(4, "192.168.1.4", "user4", "pass4", "encPass4", "SNMPv3",
+ "12348");
+ credentials.add(cred1);
+ credentials.add(cred2);
+ credentials.add(cred3);
+ credentials.add(cred4);
+
+ when(postgresService.getRsusWithCredentials()).thenReturn(credentials);
+
+ // Mix of success and failure
+ doNothing().when(rsuQueryService).getRsuInformation(cred1);
+ doThrow(new RuntimeException("Connection timeout")).when(rsuQueryService).getRsuInformation(cred2);
+ doNothing().when(rsuQueryService).getRsuInformation(cred3);
+ doThrow(new RuntimeException("Authentication failed")).when(rsuQueryService).getRsuInformation(cred4);
+
+ task.queryRSUStats();
+
+ verify(postgresService).getRsusWithCredentials();
+ verify(rsuQueryService, times(1)).getRsuInformation(cred1);
+ verify(rsuQueryService, times(1)).getRsuInformation(cred2);
+ verify(rsuQueryService, times(1)).getRsuInformation(cred3);
+ verify(rsuQueryService, times(1)).getRsuInformation(cred4);
+ }
+
+ @Test
+ public void testQueryRSUStats_VerifyCorrectCredentialsPassedToService() {
+ List credentials = new ArrayList<>();
+ RsuSnmpCredentials expectedCred = new RsuSnmpCredentials(1, "192.168.100.50", "testuser", "testpass",
+ "testencpass", "SNMPv3", "99999");
+ credentials.add(expectedCred);
+
+ when(postgresService.getRsusWithCredentials()).thenReturn(credentials);
+
+ task.queryRSUStats();
+
+ verify(rsuQueryService).getRsuInformation(expectedCred);
+ }
+
+ @Test
+ public void testConstructor() {
+ RsuMonitoringTask newTask = new RsuMonitoringTask(rsuQueryService, postgresService);
+ assertNotNull(newTask);
+ }
+
+ @Test
+ public void testQueryRSUStats_ThreadPoolHandles10ConcurrentRequests() throws InterruptedException {
+ // Exactly 10 RSUs (matches the thread pool size)
+ List credentials = new ArrayList<>();
+ for (int i = 1; i <= 10; i++) {
+ credentials.add(new RsuSnmpCredentials(i, "192.168.1." + i, "user" + i, "pass" + i, "encPass" + i, "SNMPv3",
+ String.valueOf(40000 + i)));
+ }
+
+ when(postgresService.getRsusWithCredentials()).thenReturn(credentials);
+
+ CountDownLatch startLatch = new CountDownLatch(1);
+ CountDownLatch completeLatch = new CountDownLatch(10);
+
+ doAnswer(invocation -> {
+ startLatch.await(); // Wait for all threads to be ready
+ Thread.sleep(50);
+ completeLatch.countDown();
+ return null;
+ }).when(rsuQueryService).getRsuInformation(any(RsuSnmpCredentials.class));
+
+ Thread taskThread = new Thread(() -> task.queryRSUStats());
+ taskThread.start();
+
+ Thread.sleep(100);
+ startLatch.countDown(); // Release all threads at once
+
+ taskThread.join(5000); // Wait for completion
+
+ assertTrue(completeLatch.await(2, TimeUnit.SECONDS), "All 10 tasks should complete");
+ verify(rsuQueryService, times(10)).getRsuInformation(any(RsuSnmpCredentials.class));
+ }
+
+ @Test
+ public void testQueryRSUStats_DifferentExceptionTypes() {
+ List credentials = new ArrayList<>();
+ RsuSnmpCredentials cred1 = new RsuSnmpCredentials(1, "192.168.1.1", "user1", "pass1", "encPass1", "SNMPv3",
+ "12345");
+ RsuSnmpCredentials cred2 = new RsuSnmpCredentials(2, "192.168.1.2", "user2", "pass2", "encPass2", "SNMPv3",
+ "12346");
+ RsuSnmpCredentials cred3 = new RsuSnmpCredentials(3, "192.168.1.3", "user3", "pass3", "encPass3", "SNMPv3",
+ "12347");
+ credentials.add(cred1);
+ credentials.add(cred2);
+ credentials.add(cred3);
+
+ when(postgresService.getRsusWithCredentials()).thenReturn(credentials);
+
+ // Different types of exceptions
+ doThrow(new RuntimeException("Network error")).when(rsuQueryService).getRsuInformation(cred1);
+ doThrow(new IllegalArgumentException("Invalid credentials")).when(rsuQueryService).getRsuInformation(cred2);
+ doThrow(new NullPointerException("Null response")).when(rsuQueryService).getRsuInformation(cred3);
+
+ task.queryRSUStats();
+
+ // Should handle all exception types gracefully
+ verify(postgresService).getRsusWithCredentials();
+ verify(rsuQueryService, times(3)).getRsuInformation(any(RsuSnmpCredentials.class));
+ }
+}
diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/utils/SnmpHelperUtilTest.java b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/utils/SnmpHelperUtilTest.java
new file mode 100644
index 000000000..97dd16990
--- /dev/null
+++ b/services/rsu-status-monitor/rsu-status-monitor/src/test/java/us/dot/its/jpo/rsustatusmonitor/utils/SnmpHelperUtilTest.java
@@ -0,0 +1,294 @@
+package us.dot.its.jpo.rsustatusmonitor.utils;
+
+import org.junit.jupiter.api.Test;
+import org.snmp4j.smi.OctetString;
+
+import java.time.LocalDateTime;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+public class SnmpHelperUtilTest {
+
+ @Test
+ public void testGenerateNtcip1218HexDateTimeString_BasicDateTime() {
+ LocalDateTime dateTime = LocalDateTime.of(2025, 11, 21, 14, 30, 45);
+
+ String result = SnmpHelperUtil.generateNtcip1218HexDateTimeString(dateTime);
+
+ // Format: YYYYMMDDHHMMSS00
+ // 2025 = 0x07e9, 11 = 0x0b, 21 = 0x15, 14 = 0x0e, 30 = 0x1e, 45 = 0x2d
+ assertEquals("07e90b150e1e2d00", result);
+ assertEquals(16, result.length());
+ }
+
+ @Test
+ public void testGenerateNtcip1218HexDateTimeString_Midnight() {
+ LocalDateTime dateTime = LocalDateTime.of(2025, 1, 1, 0, 0, 0);
+
+ String result = SnmpHelperUtil.generateNtcip1218HexDateTimeString(dateTime);
+
+ // 2025 = 0x07e9, 01 = 0x01, 01 = 0x01, 00 = 0x00, 00 = 0x00, 00 = 0x00
+ assertEquals("07e90101000000", result.substring(0, 14));
+ assertEquals("00", result.substring(14));
+ assertEquals(16, result.length());
+ }
+
+ @Test
+ public void testGenerateNtcip1218HexDateTimeString_MaxValues() {
+ LocalDateTime dateTime = LocalDateTime.of(2025, 12, 31, 23, 59, 59);
+
+ String result = SnmpHelperUtil.generateNtcip1218HexDateTimeString(dateTime);
+
+ // 2025 = 0x07e9, 12 = 0x0c, 31 = 0x1f, 23 = 0x17, 59 = 0x3b, 59 = 0x3b
+ assertEquals("07e90c1f173b3b00", result);
+ assertEquals(16, result.length());
+ }
+
+ @Test
+ public void testGenerateNtcip1218HexDateTimeString_LeapYearDate() {
+ LocalDateTime dateTime = LocalDateTime.of(2024, 2, 29, 12, 0, 0);
+
+ String result = SnmpHelperUtil.generateNtcip1218HexDateTimeString(dateTime);
+
+ // 2024 = 0x07e8, 02 = 0x02, 29 = 0x1d, 12 = 0x0c, 00 = 0x00, 00 = 0x00
+ assertEquals("07e8021d0c0000", result.substring(0, 14));
+ assertEquals(16, result.length());
+ }
+
+ @Test
+ public void testGenerateNtcip1218HexDateTimeString_Year2000() {
+ LocalDateTime dateTime = LocalDateTime.of(2000, 6, 15, 10, 20, 30);
+
+ String result = SnmpHelperUtil.generateNtcip1218HexDateTimeString(dateTime);
+
+ // 2000 = 0x07d0, 06 = 0x06, 15 = 0x0f, 10 = 0x0a, 20 = 0x14, 30 = 0x1e
+ assertEquals("07d0060f0a141e00", result);
+ }
+
+ @Test
+ public void testGenerateNtcip1218HexDateTimeString_FarFutureYear() {
+ LocalDateTime dateTime = LocalDateTime.of(9999, 12, 31, 23, 59, 59);
+
+ String result = SnmpHelperUtil.generateNtcip1218HexDateTimeString(dateTime);
+
+ // 9999 = 0x270f, 12 = 0x0c, 31 = 0x1f, 23 = 0x17, 59 = 0x3b, 59 = 0x3b
+ assertEquals("270f0c1f173b3b00", result);
+ assertEquals(16, result.length());
+ }
+
+ @Test
+ public void testGenerateNtcip1218HexDateTimeString_AlwaysEndsWithZeros() {
+ LocalDateTime dt1 = LocalDateTime.of(2025, 5, 10, 8, 15, 22);
+ LocalDateTime dt2 = LocalDateTime.of(2023, 1, 1, 0, 0, 1);
+ LocalDateTime dt3 = LocalDateTime.of(2030, 12, 25, 18, 45, 30);
+
+ String result1 = SnmpHelperUtil.generateNtcip1218HexDateTimeString(dt1);
+ String result2 = SnmpHelperUtil.generateNtcip1218HexDateTimeString(dt2);
+ String result3 = SnmpHelperUtil.generateNtcip1218HexDateTimeString(dt3);
+
+ assertTrue(result1.endsWith("00"), "Result should end with 00");
+ assertTrue(result2.endsWith("00"), "Result should end with 00");
+ assertTrue(result3.endsWith("00"), "Result should end with 00");
+ }
+
+ @Test
+ public void testGenerateNtcip1218HexDateTimeString_AllLowercaseHex() {
+ LocalDateTime dateTime = LocalDateTime.of(2025, 10, 31, 15, 45, 59);
+
+ String result = SnmpHelperUtil.generateNtcip1218HexDateTimeString(dateTime);
+
+ assertEquals(result.toLowerCase(), result, "Hex string should be lowercase");
+ assertTrue(result.matches("[0-9a-f]+"), "Should only contain lowercase hex characters");
+ }
+
+ @Test
+ public void testGenerateNtcip1218HexDateTimeString_PaddingForSingleDigits() {
+ LocalDateTime dateTime = LocalDateTime.of(2025, 1, 5, 3, 7, 9);
+
+ String result = SnmpHelperUtil.generateNtcip1218HexDateTimeString(dateTime);
+
+ // 2025 = 0x07e9, 01 = 0x01, 05 = 0x05, 03 = 0x03, 07 = 0x07, 09 = 0x09
+ assertEquals("07e90105030709", result.substring(0, 14));
+ assertEquals(16, result.length());
+ }
+
+ @Test
+ public void testHexStringToOctetString_BasicConversion() {
+ String hexString = "07e90b150e1e2d00";
+
+ OctetString result = SnmpHelperUtil.hexStringToOctetString(hexString);
+
+ assertNotNull(result);
+ assertEquals(8, result.length()); // 16 hex chars = 8 bytes
+
+ byte[] bytes = result.toByteArray();
+ assertEquals((byte) 0x07, bytes[0]);
+ assertEquals((byte) 0xe9, bytes[1]);
+ assertEquals((byte) 0x0b, bytes[2]);
+ assertEquals((byte) 0x15, bytes[3]);
+ assertEquals((byte) 0x0e, bytes[4]);
+ assertEquals((byte) 0x1e, bytes[5]);
+ assertEquals((byte) 0x2d, bytes[6]);
+ assertEquals((byte) 0x00, bytes[7]);
+ }
+
+ @Test
+ public void testHexStringToOctetString_EmptyString() {
+ String hexString = "";
+
+ OctetString result = SnmpHelperUtil.hexStringToOctetString(hexString);
+
+ assertNotNull(result);
+ assertEquals(0, result.length());
+ }
+
+ @Test
+ public void testHexStringToOctetString_SingleByte() {
+ String hexString = "FF";
+
+ OctetString result = SnmpHelperUtil.hexStringToOctetString(hexString);
+
+ assertNotNull(result);
+ assertEquals(1, result.length());
+ assertEquals((byte) 0xFF, result.toByteArray()[0]);
+ }
+
+ @Test
+ public void testHexStringToOctetString_AllZeros() {
+ String hexString = "00000000";
+
+ OctetString result = SnmpHelperUtil.hexStringToOctetString(hexString);
+
+ assertNotNull(result);
+ assertEquals(4, result.length());
+ byte[] bytes = result.toByteArray();
+ for (byte b : bytes) {
+ assertEquals((byte) 0x00, b);
+ }
+ }
+
+ @Test
+ public void testHexStringToOctetString_AllOnes() {
+ String hexString = "FFFFFFFF";
+
+ OctetString result = SnmpHelperUtil.hexStringToOctetString(hexString);
+
+ assertNotNull(result);
+ assertEquals(4, result.length());
+ byte[] bytes = result.toByteArray();
+ for (byte b : bytes) {
+ assertEquals((byte) 0xFF, b);
+ }
+ }
+
+ @Test
+ public void testHexStringToOctetString_MixedCase() {
+ String hexString = "AbCdEf01";
+
+ OctetString result = SnmpHelperUtil.hexStringToOctetString(hexString);
+
+ assertNotNull(result);
+ assertEquals(4, result.length());
+ byte[] bytes = result.toByteArray();
+ assertEquals((byte) 0xAB, bytes[0]);
+ assertEquals((byte) 0xCD, bytes[1]);
+ assertEquals((byte) 0xEF, bytes[2]);
+ assertEquals((byte) 0x01, bytes[3]);
+ }
+
+ @Test
+ public void testHexStringToOctetString_LongString() {
+ String hexString = "0123456789ABCDEF0123456789ABCDEF";
+
+ OctetString result = SnmpHelperUtil.hexStringToOctetString(hexString);
+
+ assertNotNull(result);
+ assertEquals(16, result.length());
+ }
+
+ @Test
+ public void testRoundTrip_GenerateAndConvert() {
+ LocalDateTime dateTime = LocalDateTime.of(2025, 11, 21, 14, 30, 45);
+
+ String hexString = SnmpHelperUtil.generateNtcip1218HexDateTimeString(dateTime);
+ OctetString octetString = SnmpHelperUtil.hexStringToOctetString(hexString);
+
+ assertNotNull(hexString);
+ assertNotNull(octetString);
+ assertEquals(16, hexString.length());
+ assertEquals(8, octetString.length());
+
+ byte[] bytes = octetString.toByteArray();
+ assertEquals((byte) 0x07, bytes[0]); // Year high byte
+ assertEquals((byte) 0xe9, bytes[1]); // Year low byte (2025 = 0x07e9)
+ }
+
+ @Test
+ public void testGenerateNtcip1218HexDateTimeString_ConsistentFormat() {
+ LocalDateTime dateTime1 = LocalDateTime.of(2025, 6, 15, 10, 20, 30);
+ LocalDateTime dateTime2 = LocalDateTime.of(2025, 6, 15, 10, 20, 30);
+
+ String result1 = SnmpHelperUtil.generateNtcip1218HexDateTimeString(dateTime1);
+ String result2 = SnmpHelperUtil.generateNtcip1218HexDateTimeString(dateTime2);
+
+ assertEquals(result1, result2, "Same date/time should produce identical results");
+ }
+
+ @Test
+ public void testGenerateNtcip1218HexDateTimeString_DifferentTimes() {
+ LocalDateTime dateTime1 = LocalDateTime.of(2025, 6, 15, 10, 20, 30);
+ LocalDateTime dateTime2 = LocalDateTime.of(2025, 6, 15, 10, 20, 31);
+
+ String result1 = SnmpHelperUtil.generateNtcip1218HexDateTimeString(dateTime1);
+ String result2 = SnmpHelperUtil.generateNtcip1218HexDateTimeString(dateTime2);
+
+ assertNotEquals(result1, result2, "Different times should produce different results");
+ }
+
+ @Test
+ public void testHexStringToOctetString_WithLeadingZeros() {
+ String hexString = "00010203";
+
+ OctetString result = SnmpHelperUtil.hexStringToOctetString(hexString);
+
+ assertNotNull(result);
+ byte[] bytes = result.toByteArray();
+ assertEquals((byte) 0x00, bytes[0]);
+ assertEquals((byte) 0x01, bytes[1]);
+ assertEquals((byte) 0x02, bytes[2]);
+ assertEquals((byte) 0x03, bytes[3]);
+ }
+
+ @Test
+ public void testGenerateNtcip1218HexDateTimeString_EarlyMorningHour() {
+ LocalDateTime dateTime = LocalDateTime.of(2025, 3, 10, 1, 5, 8);
+
+ String result = SnmpHelperUtil.generateNtcip1218HexDateTimeString(dateTime);
+
+ // 2025 = 0x07e9, 03 = 0x03, 10 = 0x0a, 01 = 0x01, 05 = 0x05, 08 = 0x08
+ assertEquals("07e9030a01050800", result);
+ }
+
+ @Test
+ public void testGenerateNtcip1218HexDateTimeString_NoonExactly() {
+ LocalDateTime dateTime = LocalDateTime.of(2025, 7, 4, 12, 0, 0);
+
+ String result = SnmpHelperUtil.generateNtcip1218HexDateTimeString(dateTime);
+
+ // 2025 = 0x07e9, 07 = 0x07, 04 = 0x04, 12 = 0x0c, 00 = 0x00, 00 = 0x00
+ assertEquals("07e907040c000000", result);
+ }
+
+ @Test
+ public void testHexStringToOctetString_ByteValueRange() {
+ String hexString = "00FF7F80";
+
+ OctetString result = SnmpHelperUtil.hexStringToOctetString(hexString);
+
+ byte[] bytes = result.toByteArray();
+ assertEquals((byte) 0x00, bytes[0]); // Min unsigned
+ assertEquals((byte) 0xFF, bytes[1]); // Max unsigned
+ assertEquals((byte) 0x7F, bytes[2]); // Max positive signed
+ assertEquals((byte) 0x80, bytes[3]); // Min negative signed
+ }
+}
diff --git a/services/rsu-status-monitor/rsu-status-monitor/src/test/resources/logback-test.xml b/services/rsu-status-monitor/rsu-status-monitor/src/test/resources/logback-test.xml
new file mode 100644
index 000000000..1c136fc43
--- /dev/null
+++ b/services/rsu-status-monitor/rsu-status-monitor/src/test/resources/logback-test.xml
@@ -0,0 +1,11 @@
+
+
+
+ %msg%n
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/webapp/src/apis/intersections/rsu-api.test.ts b/webapp/src/apis/intersections/rsu-api.test.ts
new file mode 100644
index 000000000..eb17afe7d
--- /dev/null
+++ b/webapp/src/apis/intersections/rsu-api.test.ts
@@ -0,0 +1,227 @@
+import RsuApi from './rsu-api'
+import { authApiHelper } from './api-helper-cviz'
+
+// Mock the api-helper-cviz module
+jest.mock('./api-helper-cviz', () => ({
+ authApiHelper: {
+ invokeApi: jest.fn(),
+ },
+}))
+
+const mockInvokeApi = authApiHelper.invokeApi as jest.MockedFunction
+
+beforeEach(() => {
+ mockInvokeApi.mockClear()
+})
+
+describe('RsuApi', () => {
+ describe('getHistoricalRsuStatus', () => {
+ it('should call invokeApi with correct parameters and return response', async () => {
+ const expectedResponse = [
+ {
+ timestamp: 1717622387534,
+ intersectionID: '1234',
+ rsuIP: '10.0.0.1',
+ temperature: 37,
+ uptime: 1294615,
+ mode: 4,
+ },
+ {
+ timestamp: 1717622447534,
+ intersectionID: '1234',
+ rsuIP: '10.0.0.1',
+ temperature: 38,
+ uptime: 1294675,
+ mode: 4,
+ },
+ ]
+
+ mockInvokeApi.mockResolvedValue(expectedResponse)
+
+ const startTime = new Date('2025-06-05T21:00:00Z')
+ const endTime = new Date('2025-06-05T23:00:00Z')
+
+ const result = await RsuApi.getHistoricalRsuStatus({
+ token: 'testToken',
+ rsuIp: '10.0.0.1',
+ startTime,
+ endTime,
+ })
+
+ expect(result).toEqual(expectedResponse)
+ expect(mockInvokeApi).toHaveBeenCalledTimes(1)
+ expect(mockInvokeApi).toHaveBeenCalledWith({
+ path: '/data/rsu-status/historical',
+ token: 'testToken',
+ queryParams: {
+ rsuIp: '10.0.0.1',
+ startTime: startTime.getTime().toString(),
+ endTime: endTime.getTime().toString(),
+ },
+ abortController: undefined,
+ failureMessage: 'Failed to fetch historical RSU status',
+ tag: 'rsu',
+ })
+ })
+
+ it('should pass abort controller when provided', async () => {
+ const expectedResponse = []
+ mockInvokeApi.mockResolvedValue(expectedResponse)
+
+ const abortController = new AbortController()
+ const startTime = new Date('2025-06-05T21:00:00Z')
+ const endTime = new Date('2025-06-05T23:00:00Z')
+
+ await RsuApi.getHistoricalRsuStatus({
+ token: 'testToken',
+ rsuIp: '10.0.0.1',
+ startTime,
+ endTime,
+ abortController,
+ })
+
+ expect(mockInvokeApi).toHaveBeenCalledWith(
+ expect.objectContaining({
+ abortController,
+ })
+ )
+ })
+ })
+
+ describe('getLatestRsuStatus', () => {
+ it('should call invokeApi with correct parameters and return response', async () => {
+ const expectedResponse = {
+ timestamp: 1717622387534,
+ intersectionID: '1234',
+ rsuIP: '10.0.0.1',
+ temperature: 37,
+ uptime: 1294615,
+ mode: 4,
+ }
+
+ mockInvokeApi.mockResolvedValue(expectedResponse)
+
+ const result = await RsuApi.getLatestRsuStatus({
+ token: 'testToken',
+ rsuIp: '10.0.0.1',
+ })
+
+ expect(result).toEqual(expectedResponse)
+ expect(mockInvokeApi).toHaveBeenCalledTimes(1)
+ expect(mockInvokeApi).toHaveBeenCalledWith({
+ path: '/data/rsu-status/latest',
+ token: 'testToken',
+ queryParams: {
+ rsuIp: '10.0.0.1',
+ },
+ abortController: undefined,
+ failureMessage: 'Failed to fetch latest RSU status',
+ tag: 'rsu',
+ })
+ })
+
+ it('should pass abort controller when provided', async () => {
+ const expectedResponse = {
+ timestamp: 1717622387534,
+ intersectionID: '1234',
+ rsuIP: '10.0.0.1',
+ temperature: 37,
+ uptime: 1294615,
+ mode: 4,
+ }
+
+ mockInvokeApi.mockResolvedValue(expectedResponse)
+
+ const abortController = new AbortController()
+
+ await RsuApi.getLatestRsuStatus({
+ token: 'testToken',
+ rsuIp: '10.0.0.1',
+ abortController,
+ })
+
+ expect(mockInvokeApi).toHaveBeenCalledWith(
+ expect.objectContaining({
+ abortController,
+ })
+ )
+ })
+ })
+
+ describe('getAggregatedRsuStatus', () => {
+ it('should call invokeApi with correct parameters and return response', async () => {
+ const expectedResponse = [
+ {
+ timestamp: 1717622387534,
+ intersectionID: '1234',
+ rsuIP: '10.0.0.1',
+ temperature: 37,
+ uptime: 1294615,
+ mode: 4,
+ },
+ {
+ timestamp: 1717626000000,
+ intersectionID: '1234',
+ rsuIP: '10.0.0.1',
+ temperature: 39,
+ uptime: 1298228,
+ mode: 4,
+ },
+ ]
+
+ mockInvokeApi.mockResolvedValue(expectedResponse)
+
+ const startTime = new Date('2025-06-05T21:00:00Z')
+ const endTime = new Date('2025-06-06T21:00:00Z')
+ const intervalMinutes = 60
+
+ const result = await RsuApi.getAggregatedRsuStatus({
+ token: 'testToken',
+ rsuIp: '10.0.0.1',
+ startTime,
+ endTime,
+ intervalMinutes,
+ })
+
+ expect(result).toEqual(expectedResponse)
+ expect(mockInvokeApi).toHaveBeenCalledTimes(1)
+ expect(mockInvokeApi).toHaveBeenCalledWith({
+ path: '/data/rsu-status/aggregated',
+ token: 'testToken',
+ queryParams: {
+ rsuIp: '10.0.0.1',
+ startTime: startTime.getTime().toString(),
+ endTime: endTime.getTime().toString(),
+ intervalMinutes: intervalMinutes.toString(),
+ },
+ abortController: undefined,
+ failureMessage: 'Failed to fetch aggregated RSU status',
+ tag: 'rsu',
+ })
+ })
+
+ it('should pass abort controller when provided', async () => {
+ const expectedResponse = []
+ mockInvokeApi.mockResolvedValue(expectedResponse)
+
+ const abortController = new AbortController()
+ const startTime = new Date('2025-06-05T21:00:00Z')
+ const endTime = new Date('2025-06-06T21:00:00Z')
+
+ await RsuApi.getAggregatedRsuStatus({
+ token: 'testToken',
+ rsuIp: '10.0.0.1',
+ startTime,
+ endTime,
+ intervalMinutes: 60,
+ abortController,
+ })
+
+ expect(mockInvokeApi).toHaveBeenCalledWith(
+ expect.objectContaining({
+ abortController,
+ })
+ )
+ })
+ })
+})
diff --git a/webapp/src/apis/intersections/rsu-api.ts b/webapp/src/apis/intersections/rsu-api.ts
new file mode 100644
index 000000000..6eeeed645
--- /dev/null
+++ b/webapp/src/apis/intersections/rsu-api.ts
@@ -0,0 +1,105 @@
+import { authApiHelper } from './api-helper-cviz'
+
+// TypeScript type for RSU State (should match your backend model)
+export type RsuState = {
+ timestamp: number
+ intersectionID: string
+ rsuIP: string
+ temperature: number
+ uptime: number
+ mode: number
+}
+
+class RsuApi {
+ // Fetch historical RSU states for a given RSU IP and time range
+ async getHistoricalRsuStatus({
+ token,
+ rsuIp,
+ startTime,
+ endTime,
+ abortController,
+ }: {
+ token: string
+ rsuIp: string
+ startTime: Date
+ endTime: Date
+ abortController?: AbortController
+ }): Promise {
+ const queryParams: Record = {
+ rsuIp,
+ startTime: startTime.getTime().toString(),
+ endTime: endTime.getTime().toString(),
+ }
+
+ const response = await authApiHelper.invokeApi({
+ path: `/data/rsu-status/historical`,
+ token,
+ queryParams,
+ abortController,
+ failureMessage: 'Failed to fetch historical RSU status',
+ tag: 'rsu',
+ })
+
+ return response
+ }
+
+ // Fetch the latest RSU state for a given RSU IP
+ async getLatestRsuStatus({
+ token,
+ rsuIp,
+ abortController,
+ }: {
+ token: string
+ rsuIp: string
+ abortController?: AbortController
+ }): Promise {
+ const queryParams: Record = { rsuIp }
+
+ const response = await authApiHelper.invokeApi({
+ path: `/data/rsu-status/latest`,
+ token,
+ queryParams,
+ abortController,
+ failureMessage: 'Failed to fetch latest RSU status',
+ tag: 'rsu',
+ })
+
+ return response
+ }
+
+ async getAggregatedRsuStatus({
+ token,
+ rsuIp,
+ startTime,
+ endTime,
+ intervalMinutes,
+ abortController,
+ }: {
+ token: string
+ rsuIp: string
+ startTime: Date
+ endTime: Date
+ intervalMinutes: number
+ abortController?: AbortController
+ }): Promise {
+ const queryParams: Record = {
+ rsuIp,
+ startTime: startTime.getTime().toString(),
+ endTime: endTime.getTime().toString(),
+ intervalMinutes: intervalMinutes.toString(),
+ }
+
+ const response = await authApiHelper.invokeApi({
+ path: `/data/rsu-status/aggregated`,
+ token,
+ queryParams,
+ abortController,
+ failureMessage: 'Failed to fetch aggregated RSU status',
+ tag: 'rsu',
+ })
+
+ return response
+ }
+}
+
+export default new RsuApi()
diff --git a/webapp/src/components/AdminFormManager.test.tsx b/webapp/src/components/AdminFormManager.test.tsx
index 2b29863fe..0e579fab6 100644
--- a/webapp/src/components/AdminFormManager.test.tsx
+++ b/webapp/src/components/AdminFormManager.test.tsx
@@ -11,7 +11,7 @@ import { BrowserRouter } from 'react-router-dom'
it('snapshot rsu', () => {
const { container } = render(
-
+
@@ -25,7 +25,7 @@ it('snapshot rsu', () => {
it('snapshot user', () => {
const { container } = render(
-
+
@@ -39,7 +39,7 @@ it('snapshot user', () => {
it('snapshot organization', () => {
const { container } = render(
-
+
diff --git a/webapp/src/components/__snapshots__/AdminFormManager.test.tsx.snap b/webapp/src/components/__snapshots__/AdminFormManager.test.tsx.snap
index 1edf556ec..202d413eb 100644
--- a/webapp/src/components/__snapshots__/AdminFormManager.test.tsx.snap
+++ b/webapp/src/components/__snapshots__/AdminFormManager.test.tsx.snap
@@ -814,7 +814,7 @@ exports[`snapshot rsu 1`] = `
|
{
- Primary Route
+
+ Primary Route
+
|