A real-time air quality monitoring and runner participation tracking system for Bursa, Turkey. Built with Vert.x Event Bus, WebSocket, and multiple external API integrations.
[Airqoon API] [OpenWeatherMap API]
| GET /60s | GET /5min
v v
DataFetcherVerticle WeatherFetcherVerticle
| |
+--- EventBus -----------+
| publish/subscribe |
v v v
AlertVerticle RunSessionVerticle WebSocketVerticle
(PM2.5>100) (join/leave) (HTTP + 2x WebSocket)
|
+-------------------+-------------------+
| | |
index.html runners.html map.html
(AQI Dashboard) (Runner Tracker) (Leaflet Map)
Communication: Vert.x EventBus (publish/subscribe + request/reply)
Transport: WebSocket (server push to all connected browsers)
| Verticle | Role |
|---|---|
DataFetcherVerticle |
Polls 28 air quality sensors every 60s from Airqoon API, parses telemetry (PM2.5, PM10, NO2, SO2, temperature, humidity), publishes to EventBus |
WeatherFetcherVerticle |
Fetches weather data from OpenWeatherMap for 12 preset locations every 5 min, caches with 2-min TTL, serves via request/reply |
AlertVerticle |
Listens for PM2.5 > 100 events, broadcasts alerts to all WebSocket clients |
RunSessionVerticle |
In-memory runner participation: join/leave with name + location, publishes snapshots every 5s |
WebSocketVerticle |
HTTP REST API + 2 WebSocket endpoints, static file serving, CORS handling |
Real-time air quality sensor data for Bursa province.
Base URL: https://map.airqoon.com/bursa/api/v2
| Endpoint | Method | Description |
|---|---|---|
/devices |
GET | Returns all registered sensor devices (id, name, location, tenant) |
/devices/{deviceId}/telemetries?startTime={ms}&endTime={ms} |
GET | Returns measurement data for a device within a time range |
Headers:
Accept: application/json
Referer: https://map.airqoon.com/bursa/
Device Response:
{
"DeviceId": "63b70e27efae9654696a5517",
"Name": "BursaBB4 - Yıldırım",
"TenantName": "Bursa Metropolitan Municipality",
"Location": {
"Latitude": 40.185791,
"Longitude": 29.084729
}
}Telemetry Response:
{
"PM25": [
{ "Value": 12.5, "Date": "2026-06-04T16:18:24.499Z" }
],
"PM10": [
{ "Value": 28.0, "Date": "2026-06-04T16:18:24.499Z" }
],
"Temperature": [...],
"Humidity": [...]
}Monitored Sensors (28 total):
| Sensor | Location | Operator |
|---|---|---|
| BursaBB4 | Yildirim | Bursa Metropolitan Municipality |
| BursaBB5 | Gemlik | Bursa Metropolitan Municipality |
| BursaBB3 | Osmangazi Demirtas | Bursa Metropolitan Municipality |
| BursaBB6 | Osmangazi Kent Meydani | Bursa Metropolitan Municipality |
| BursaBB7 | Osmangazi Demirtaspasa (Hanlar Bolgesi) | Bursa Metropolitan Municipality |
| BursaBB8 | Gursu | Bursa Metropolitan Municipality |
| BursaBB9 | Kestel | Bursa Metropolitan Municipality |
| BursaBB2 | Kestel Barakfakih | Bursa Metropolitan Municipality |
| BursaBB13 | Orhaneli | Bursa Metropolitan Municipality |
| BursaBB14 | Gemlik Atatepe | Bursa Metropolitan Municipality |
| BursaBB1 | Orhangazi | Bursa Metropolitan Municipality |
| BursaBB10 | Nilufer Balkan | Bursa Metropolitan Municipality |
| BursaBB11 | Mustafakemalpasa | Bursa Metropolitan Municipality |
| BursaBB12 | Karacabey | Bursa Metropolitan Municipality |
| BursaBB15 | Keles | Bursa Metropolitan Municipality |
| airQuality_lena | Inegol Turankoy | Inegol Municipality |
| airQuality_modi | Inegol Merkez | Inegol Municipality |
| airQuality_merx, vult, novo, veru, vide, fera | Inegol (various) | Inegol Municipality |
| MudanyaB-1 to B-5 | Mudanya (5 sensors) | Mudanya Municipality |
Current weather conditions for runner locations.
Base URL: https://api.openweathermap.org/data/2.5/weather
| Parameter | Value |
|---|---|
lat |
Latitude |
lon |
Longitude |
appid |
API key (env: OPENWEATHER_API_KEY) |
units |
metric (Celsius) |
lang |
tr (Turkish descriptions) |
Response (parsed fields):
{
"temperature": 21.4,
"feelsLike": 20.8,
"humidity": 55,
"pressure": 1013,
"windSpeed": 3.5,
"cloudiness": 0,
"condition": "Clear",
"description": "acik",
"icon": "01d"
}On connect: Server sends a full snapshot of all station data.
{
"type": "snapshot",
"stations": [...],
"devices": [...]
}Every 60s: Server pushes individual station updates as telemetry is fetched.
{
"type": "stationUpdate",
"data": {
"deviceId": "63b70e27...",
"name": "BursaBB4 - Yıldırım",
"tenant": "Bursa Metropolitan Municipality",
"lat": 40.185791,
"lon": 29.084729,
"latest": {
"pm25": 12.5,
"pm10": 28.0,
"temperatureraw": 22.3,
"humidity": 45.2
},
"history": {
"pm25": [{"v": 12.5, "t": "2026-06-04T16:18:24Z"}, ...]
}
}
}Alert (PM2.5 > 100):
{
"type": "alert",
"station": "BursaBB4 - Yıldırım",
"pm25": 105.3,
"pm10": 180.0
}On connect: Server sends current participants + available locations.
{
"type": "snapshot",
"participants": [
{
"id": "uuid",
"name": "Kadir",
"locationId": "yildirim",
"locationName": "Yildirim",
"district": "Yildirim",
"joinedAt": 1780594624652
}
],
"locations": [...]
}When someone joins:
{ "type": "joined", "participant": {...} }When someone leaves:
{ "type": "left", "participant": {...} }Every 5s tick:
{ "type": "tick", "participants": [...] }| Method | Endpoint | Body | Response |
|---|---|---|---|
| GET | /api/stations |
— | All stations with latest telemetry |
| GET | /api/stations/:id |
— | Single station data |
| GET | /api/devices |
— | Raw device list from Airqoon |
| GET | /api/locations |
— | 12 preset runner locations |
| GET | /api/active |
— | All active participants |
| POST | /api/join |
{"name": "Kadir", "locationId": "yildirim"} |
{"id": "uuid", "name": "Kadir", "locationName": "Yildirim", ...} |
| POST | /api/leave |
{"id": "uuid"} |
{"ok": true} |
| POST | /api/refresh |
— | Triggers immediate sensor data fetch |
Internal communication between verticles via Vert.x EventBus:
| Topic | Pattern | Publisher | Consumer |
|---|---|---|---|
aqi.devices |
Publish | DataFetcher | WebSocket |
aqi.data.station |
Publish | DataFetcher | WebSocket, Alert |
aqi.alert |
Publish | DataFetcher | Alert |
aqi.ws.alert |
Publish | Alert | WebSocket |
aqi.fetch.now |
Request/Reply | WebSocket | DataFetcher |
weather.fetch |
Request/Reply | RunSession | Weather |
weather.data |
Publish | Weather | (broadcast) |
run.join |
Request/Reply | WebSocket | RunSession |
run.leave |
Request/Reply | WebSocket | RunSession |
run.active |
Request/Reply | WebSocket | RunSession |
run.started |
Publish | RunSession | WebSocket |
run.finished |
Publish | RunSession | WebSocket |
run.tick |
Publish (5s) | RunSession | WebSocket |
| Component | Technology |
|---|---|
| Runtime | Java 17 |
| Framework | Vert.x 4.5.11 (event-driven, non-blocking) |
| HTTP/WS Server | Vert.x Web + Vert.x Core |
| HTTP Client | Vert.x Web Client (async, non-blocking) |
| Inter-verticle Communication | Vert.x EventBus (in-process pub/sub + req/rep) |
| Map | Leaflet.js + OpenStreetMap (no API key required) |
| Packaging | Maven Shade Plugin (fat JAR) |
# Build
mvn clean package
# Run
java -jar target/vertx-event-bus-bursa-hava-kalite-izleme-1.0.0.jar
# With custom OpenWeatherMap key
OPENWEATHER_API_KEY=your_key java -jar target/vertx-event-bus-bursa-hava-kalite-izleme-1.0.0.jarPages:
http://localhost:8080— Air Quality Dashboard (28 stations, live sparklines)http://localhost:8080/runners.html— Runner Participation Trackerhttp://localhost:8080/map.html— Station Map (Leaflet)
src/main/java/com/example/hava/
MainVerticle.java — Bootstrap, deploys all verticles
dto/
PresetLocation.java — 12 preset Bursa locations
WeatherDTO.java — OpenWeatherMap response mapping
RunSessionDTO.java — Runner session model
LocationConditionsDTO.java — Location snapshot (AQI + weather + runner count)
verticle/
DataFetcherVerticle.java — Airqoon API poller (60s interval)
WeatherFetcherVerticle.java — OpenWeatherMap poller (5min interval)
AlertVerticle.java — PM2.5 threshold alerts
RunSessionVerticle.java — Join/leave participation tracker
WebSocketVerticle.java — HTTP REST + WebSocket server
src/main/resources/webroot/
index.html — AQI Dashboard (vanilla JS)
runners.html — Runner Tracker (vanilla JS)
map.html — Leaflet Map (vanilla JS)
MIT