Flight Tracker AI with Quarkus and Ollama
Build a Local, Privacy-Preserving Aviation Assistant with Java and Real-Time Flight Data
What if you could ask your computer, “Are there any military aircraft over Berlin right now?” and get a smart, informed answer without relying on any cloud-based AI APIs or paid services?
In this tutorial, you’ll do exactly that.
We’ll build an AI-powered aviation assistant using Quarkus, LangChain4j, Ollama (for local LLM inference), and the open ADSB.fi API. Your assistant will understand natural language questions and answer them using live data from the skies.
No API keys. No cloud dependencies. Just Java, real-time aircraft telemetry, and AI. All running locally.
Prerequisites
Java 17+
Maven 3.8+
Quarkus Dev Services or Ollama installed and running with a model (e.g. llama3.1:8b with tool calling)
A basic understanding of Quarkus, REST, and Java development
Project Setup
Generate a new Quarkus project:
mvn io.quarkus.platform:quarkus-maven-plugin:create \
-DprojectGroupId=com.example \
-DprojectArtifactId=flight-tracker-ollama \
-DclassName="com.example.FlightTrackerResource" \
-Dpath="/api/flights" \
-Dextensions="rest-jackson,smallrye-openapi,quarkus-langchain4j-ollama,rest-client-jackson,hibernate-validator"
cd flight-tracker-ollama
As usual, you can find the complete, running example in my Github repository.
Configuration
Open src/main/resources/application.properties
and configure:
# Quarkus Application Configuration
quarkus.swagger-ui.always-include=true
quarkus.rest-client.logging.scope=all
quarkus.log.level=INFO
quarkus.log.category."com.example".level=DEBUG
# Langchain4j Ollama
quarkus.langchain4j.ollama.chat-model.model-name=llama3.1:8b
quarkus.langchain4j.ollama.timeout=120s
# ADSB-FI Client
quarkus.rest-client."com.example.client.AdsbFiClient".url=https://opendata.adsb.fi/api
quarkus.rest-client."com.example.client.AdsbFiClient".scope=jakarta.inject.Singleton
# Basic Rate Limiting
app.rate-limit.max-requests=1
app.rate-limit.window-ms=1000
Define Your Data Models
We’ll start by modeling the structure of aircraft data returned by the ADSB.fi API. This lets Quarkus automatically parse JSON into usable Java objects.
Aircraft Data Model
Create src/main/java/com/example/model/Aircraft.java:
package com.example.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
@JsonIgnoreProperties(ignoreUnknown = true)
public class Aircraft {
@JsonProperty("hex") private String hexCode;
@JsonProperty("flight") private String callsign;
@JsonProperty("r") private String registration;
@JsonProperty("t") private String aircraftType;
@JsonProperty("lat") private Double latitude;
@JsonProperty("lon") private Double longitude;
@JsonProperty("alt_baro") private Integer altitude;
@JsonProperty("gs") private Double groundSpeed;
@JsonProperty("track") private Double heading;
@JsonProperty("squawk") private String squawk;
@JsonProperty("emergency") private String emergency;
@JsonProperty("category") private String category;
@JsonProperty("mil") private Boolean military;
// Getters, setters, and toString omitted for brevity
}
API Response Model
Create the file src/main/java/com/example/model/FlightDataResponse.java
:
package com.example.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
@JsonIgnoreProperties(ignoreUnknown = true)
public class FlightDataResponse {
@JsonProperty("ac") private List<Aircraft> aircraft;
@JsonProperty("total") private Integer total;
@JsonProperty("now") private Long now;
public List<Aircraft> getAircraft() { return aircraft; }
public void setAircraft(List<Aircraft> aircraft) { this.aircraft = aircraft; }
}
Create the ADSB.fi REST Client
Quarkus supports typed, reactive REST clients. Let’s define one to call the ADSB.fi API.
Create the file src/main/java/com/example/client/AdsbFiClient.java
:
package com.example.client;
import com.example.model.FlightDataResponse;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
@RegisterRestClient
@Path("/v2")
public interface AdsbFiClient {
@GET @Path("/hex/{hex}")
@Produces(MediaType.APPLICATION_JSON)
FlightDataResponse getAircraftByHex(@PathParam("hex") String hex);
@GET @Path("/callsign/{callsign}")
@Produces(MediaType.APPLICATION_JSON)
FlightDataResponse getAircraftByCallsign(@PathParam("callsign") String callsign);
@GET @Path("/mil")
@Produces(MediaType.APPLICATION_JSON)
FlightDataResponse getMilitaryAircraft();
@GET @Path("/lat/{lat}/lon/{lon}/dist/{dist}")
@Produces(MediaType.APPLICATION_JSON)
FlightDataResponse getAircraftByLocation(
@PathParam("lat") double latitude,
@PathParam("lon") double longitude,
@PathParam("dist") int distanceNm
);
@GET @Path("/sqk/{squawk}")
@Produces(MediaType.APPLICATION_JSON)
FlightDataResponse getAircraftBySquawk(@PathParam("squawk") String squawk);
}
Create the Flight Data Service
We’ll now wrap the REST client in an injectable CDI service that handles exceptions and async processing using CompletionStage
.
Create the file src/main/java/com/example/service/FlightDataService.java
:
package com.example.service;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import org.eclipse.microprofile.rest.client.inject.RestClient;
import org.jboss.logging.Logger;
import com.example.client.AdsbFiClient;
import com.example.model.Aircraft;
import com.example.model.FlightDataResponse;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
@ApplicationScoped
public class FlightDataService {
private static final Logger LOG = Logger.getLogger(FlightDataService.class);
@Inject @RestClient
AdsbFiClient adsbFiClient;
public CompletionStage<List<Aircraft>> getMilitaryAircraft() {
return CompletableFuture.supplyAsync(() -> {
try {
FlightDataResponse res = adsbFiClient.getMilitaryAircraft();
return res.getAircraft() != null ? res.getAircraft() : Collections.emptyList();
} catch (Exception e) {
LOG.error("Failed to get military aircraft", e);
return Collections.emptyList();
}
});
}
// Other methods (by location, callsign, squawk) are similar and included in the full listing above
}
AI Tools for LangChain4j
Now you’ll expose the service to the AI agent via annotated tool methods.
Create the file src/main/java/com/example/ai/FlightDataTools.java
:
package com.example.ai;
import java.util.List;
import com.example.model.Aircraft;
import com.example.service.FlightDataService;
import dev.langchain4j.agent.tool.Tool;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
@ApplicationScoped
public class FlightDataTools {
@Inject
FlightDataService flightDataService;
@Tool("Get aircraft within a specified distance from coordinates")
public String getAircraftNearLocation(
double latitude,
double longitude,
int distanceNauticalMiles) {
try {
List<Aircraft> aircraft = flightDataService
.getAircraftByLocation(latitude, longitude, distanceNauticalMiles)
.toCompletableFuture()
.get();
if (aircraft.isEmpty()) {
return "No aircraft found in the specified area.";
}
return formatAircraftList(aircraft, "Aircraft near location");
} catch (Exception e) {
return "Error retrieving aircraft data: " + e.getMessage();
}
}
@Tool("Get all military aircraft currently tracked")
public String getMilitaryAircraft() {
try {
List<Aircraft> aircraft = flightDataService
.getMilitaryAircraft()
.toCompletableFuture()
.get();
if (aircraft.isEmpty()) {
return "No military aircraft currently tracked.";
}
return formatAircraftList(aircraft, "Military Aircraft");
} catch (Exception e) {
return "Error retrieving military aircraft data: " + e.getMessage();
}
}
@Tool("Find aircraft by callsign (flight number)")
public String findAircraftByCallsign(String callsign) {
try {
List<Aircraft> aircraft = flightDataService
.getAircraftByCallsign(callsign.toUpperCase())
.toCompletableFuture()
.get();
if (aircraft.isEmpty()) {
return "No aircraft found with callsign: " + callsign;
}
return formatAircraftList(aircraft, "Aircraft with callsign " + callsign);
} catch (Exception e) {
return "Error retrieving aircraft data: " + e.getMessage();
}
}
@Tool("Get aircraft in emergency status (squawk 7700)")
public String getEmergencyAircraft() {
try {
List<Aircraft> aircraft = flightDataService
.getEmergencyAircraft()
.toCompletableFuture()
.get();
if (aircraft.isEmpty()) {
return "No aircraft currently in emergency status.";
}
return formatAircraftList(aircraft, "Emergency Aircraft");
} catch (Exception e) {
return "Error retrieving emergency aircraft data: " + e.getMessage();
}
}
private String formatAircraftList(List<Aircraft> aircraft, String title) {
StringBuilder sb = new StringBuilder();
sb.append(title).append(" (").append(aircraft.size()).append(" found):\n\n");
for (Aircraft ac : aircraft) {
sb.append("• ");
if (ac.getCallsign() != null && !ac.getCallsign().trim().isEmpty()) {
sb.append("Flight: ").append(ac.getCallsign().trim()).append(" ");
}
if (ac.getRegistration() != null) {
sb.append("(").append(ac.getRegistration()).append(") ");
}
if (ac.getAircraftType() != null) {
sb.append("Type: ").append(ac.getAircraftType()).append(" ");
}
if (ac.getLatitude() != null && ac.getLongitude() != null) {
sb.append("Position: ").append(String.format("%.4f", ac.getLatitude()))
.append(", ").append(String.format("%.4f", ac.getLongitude())).append(" ");
}
if (ac.getAltitude() != null) {
sb.append("Alt: ").append(ac.getAltitude()).append("ft ");
}
if (ac.getGroundSpeed() != null) {
sb.append("Speed: ").append(ac.getGroundSpeed().intValue()).append("kts ");
}
if (Boolean.TRUE.equals(ac.getMilitary())) {
sb.append("[MILITARY] ");
}
if (ac.getEmergency() != null) {
sb.append("[EMERGENCY: ").append(ac.getEmergency()).append("] ");
}
sb.append("\n");
}
return sb.toString();
}
}
Build the AI Assistant Service
Let’s wrap everything into a LangChain4j-powered AI service using the tools defined above.
Create the file src/main/java/com/example/service/AviationAiService.java
:
package com.example.service;
import com.example.ai.FlightDataTools;
import dev.langchain4j.service.SystemMessage;
import dev.langchain4j.service.UserMessage;
import io.quarkiverse.langchain4j.RegisterAiService;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
@RegisterAiService(tools = FlightDataTools.class)
public interface AviationAiService {
@SystemMessage("""
You are an intelligent aviation assistant with access to real-time flight data.
Your primary function is to use the available tools to answer user questions about aviation.
- Always use tools when users ask about specific flights or locations.
- Provide context and explanations, not just raw data. Be helpful and educational.
- Alert users if you find emergency or unusual situations.
- Use nautical miles for distances.
Major airports coordinates for reference:
- Frankfurt (FRA): 50.0379, 8.5622
- Munich (MUC): 48.3537, 11.7863
- Berlin (BER): 52.3667, 13.5033
- London Heathrow (LHR): 51.4700, -0.4543
- Paris CDG (CDG): 49.0097, 2.5479
""")
String processQuery(@UserMessage String userMessage);
}
Add Rate Limiting and the REST Entry Point
The adsb.fi open data API has rate limiting in place to protect against abuse and misuse, and to help ensure everyone has fair access to the API. The public endpoints are rate limited to 1 request per second. Let’s make sure we are not breaking the rules.
Create Rate Limiter Binding
Create the file src/main/java/com/example/interceptor/RateLimited.java
:
package com.example.interceptor;
import jakarta.interceptor.InterceptorBinding;
import java.lang.annotation.*;
@InterceptorBinding
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimited {}
Rate Limiter Interceptor
Create the file src/main/java/com/example/interceptor/RateLimitInterceptor.java
:
package com.example.interceptor;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger;
import jakarta.annotation.Priority;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import jakarta.interceptor.AroundInvoke;
import jakarta.interceptor.Interceptor;
import jakarta.interceptor.InvocationContext;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.SecurityContext;
@RateLimited
@Interceptor
@Priority(Interceptor.Priority.APPLICATION + 1)
@Singleton
public class RateLimitInterceptor {
private static final Logger LOG = Logger.getLogger(RateLimitInterceptor.class);
@ConfigProperty(name = "app.rate-limit.max-requests", defaultValue = "30")
int maxRequests;
@ConfigProperty(name = "app.rate-limit.window-ms", defaultValue = "60000")
long windowMs;
private static class RateLimitStatus {
final AtomicInteger count = new AtomicInteger(0);
volatile long windowStart = System.currentTimeMillis();
}
private final Map<String, RateLimitStatus> clientRequestCounts = new ConcurrentHashMap<>();
@Inject
SecurityContext securityContext;
@AroundInvoke
public Object rateLimit(InvocationContext context) throws Exception {
String clientId = getClientId();
long now = System.currentTimeMillis();
RateLimitStatus status = clientRequestCounts.computeIfAbsent(clientId, k -> new RateLimitStatus());
if (now - status.windowStart > windowMs) {
synchronized (status) {
if (now - status.windowStart > windowMs) {
status.windowStart = now;
status.count.set(0);
}
}
}
if (status.count.incrementAndGet() > maxRequests) {
LOG.warnf("Rate limit exceeded for client: %s", clientId);
return Response.status(429, "Too Many Requests")
.entity("Rate limit exceeded. Please try again later.")
.build();
}
return context.proceed();
}
private String getClientId() {
if (securityContext != null && securityContext.getUserPrincipal() != null) {
return securityContext.getUserPrincipal().getName();
}
return "anonymous-client";
}
}
Update REST Endpoint
Update update the file src/main/java/com/example/FlightTrackerResource.java
:
package com.example;
import org.jboss.logging.Logger;
import com.example.interceptor.RateLimited;
import com.example.service.AviationAiService;
import jakarta.inject.Inject;
import jakarta.validation.constraints.NotBlank;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@Path("/api/flights")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class FlightTrackerResource {
private static final Logger LOG = Logger.getLogger(FlightTrackerResource.class);
@Inject
AviationAiService aviationAiService;
@POST
@Path("/query")
@Consumes(MediaType.TEXT_PLAIN)
@Produces(MediaType.TEXT_PLAIN)
@RateLimited
public Response processQuery(@NotBlank String query) {
try {
LOG.infof("Processing query: %s", query);
String response = aviationAiService.processQuery(query);
return Response.ok(response).build();
} catch (Exception e) {
LOG.errorf(e, "Error processing query: %s", query);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("Error processing your query: " + e.getMessage())
.build();
}
}
@GET
@Path("/health")
@Produces(MediaType.TEXT_PLAIN)
public Response health() {
return Response.ok("Flight Tracker AI (Ollama) is running!").build();
}
}
Running the Application
Make sure your Ollama application is running and you have pulled the model specified in your properties file.
mvn quarkus:dev
Now you can interact with your AI assistant via:
# Health Check
curl http://localhost:8080/api/flights/health
# AI Query
curl -X POST http://localhost:8080/api/flights/query \
-H "Content-Type: text/plain" \
-d "Show me military aircraft near Berlin"
# Find flights near Munich
curl -X POST http://localhost:8080/api/flights/query \
-H "Content-Type: text/plain" \
-d "What flights are currently within 30 nautical miles of Munich airport?"
If you want to use a simple UI, point your browser at: localhost:8080.
What You Just Built
This isn’t just another REST API.
You built a localized AI-powered agent that understands and answers aviation questions using:
Quarkus: for blazing-fast, modern Java backend
LangChain4j: for AI tool orchestration
Ollama: for self-hosted LLM inference
ADSB.fi: for real-world aircraft telemetry
And you did it all without a single cloud bill.