# AGENTS.md - Traccar Binding Development Guide
## Overview
This is a comprehensive GPS tracking binding that integrates Traccar server with openHAB. The binding supports real-time position updates via webhooks, extensive channel support, and handles multiple GPS protocols with their specific attribute differences.
**Key Achievement**: Full dual-mode operation (polling + webhooks) with 22 channels per device, protocol-agnostic attribute handling, and automatic unit conversions.
## Recent Updates (January 2026)
### Distance Channel Architecture
- **Three separate channels**: `odometer`, `totalDistance`, and `distance` for different tracking needs
- **Protocol-aware implementation**:
- Teltonika devices: Use `totalDistance` (actual vehicle odometer)
- OSMand phones: Use `odometer` (device reading)
- **No more protocol fallback logic**: Each channel directly maps to its Traccar field
- **Breaking change**: `Vehicle10_Odometer` renamed to `Vehicle10_TotalDistance` for Teltonika devices
- **Action required**: Update any rules referencing `Vehicle10_Odometer` to use `Vehicle10_TotalDistance`
- Example: Springfield_Ignition.rules updated in commit 0bf7c99
### Speed Threshold Filtering
- **GPS noise filtering**: Configurable speed threshold (0-10 km/h, default 2.0)
- **Bridge-level configuration**: Single setting applies to all devices
- **Implementation**: Speeds below threshold displayed as 0 km/h
- **Use case**: Eliminates false motion detection from GPS drift
### Nominatim Reverse Geocoding
- **Enhanced address resolution**: OpenStreetMap Nominatim integration
- **Formatted addresses**: "Street number, Postcode City, Province, Country"
- **English worldwide**: Transliterates Greek, Cyrillic, Arabic, Chinese names
- **Rate limiting**: 1 request/second compliance with OSM usage policy
- **Intelligent caching**: Haversine distance with 50m threshold
## Architecture
### Core Components
```
TraccarBindingConstants.java
├── Channel constants (CHANNEL_POSITION, CHANNEL_ALTITUDE, etc.)
└── Thing type UIDs (THING_TYPE_SERVER, THING_TYPE_DEVICE)
TraccarServerHandler.java (Bridge)
├── Traccar API client (authentication, device discovery)
├── Webhook server (TraccarWebhookServer on configurable port)
├── Polling mechanism (position refresh every N seconds)
└── Device registry (maps deviceId to TraccarDeviceHandler)
TraccarDeviceHandler.java (Thing)
├── Position channel updates (dual source: polling + webhooks)
├── Geofence event handling
├── Protocol-agnostic attribute extraction
└── Unit conversions (speed, distance, time)
TraccarWebhookServer.java
├── Jetty HTTP server (default port 8090)
├── POST/GET webhook receiver
├── JSON parsing (position + event data)
└── Handler dispatch to TraccarServerHandler
```
### Data Flow
**Polling Flow**:
1. `TraccarServerHandler.startPolling()` → scheduleWithFixedDelay every `refreshInterval`
2. HTTP GET `/api/positions?deviceId=X` to Traccar API
3. Parse JSON → `TraccarDeviceHandler.updatePositionChannels(position)`
4. Extract attributes → `updateState()` for each channel
**Webhook Flow**:
1. Traccar sends POST to `http://openhab:8090/webhook` with JSON body
2. `TraccarWebhookServer.handle()` receives request
3. Parse JSON → `TraccarServerHandler.handleWebhookEvent(data)`
4. Look up device by deviceId → `TraccarDeviceHandler.handleWebhookEvent(position)`
5. `updatePositionChannels()` extracts all attributes → channel updates
### Channel Implementation Pattern
All channels follow this pattern in `TraccarDeviceHandler.updatePositionChannels()`:
```java
// 1. Extract from position object or attributes
Object valueObj = position.get("fieldName"); // Main position fields
// OR
Object valueObj = attributes.get("attributeName"); // Protocol-specific attributes
// 2. Type check and conversion
if (valueObj instanceof Number) {
double value = ((Number) valueObj).doubleValue();
// 3. Apply unit conversion if needed
updateState(CHANNEL_NAME, new QuantityType<>(value, Units.UNIT));
}
// OR for boolean
if (valueObj instanceof Boolean boolValue) {
updateState(CHANNEL_NAME, OnOffType.from(boolValue));
}
// OR for string
if (valueObj != null) {
updateState(CHANNEL_NAME, new StringType(valueObj.toString()));
}
```
## Channel Implementation Details
### Position & Navigation Channels
**Source**: Main `position` object from Traccar API/webhook
| Channel | Type | Source Field | Unit | Notes |
|---------|------|--------------|------|-------|
| `position` | Location | `latitude`, `longitude`, `altitude` | - | PointType with 3 coordinates |
| `altitude` | Number:Length | `altitude` | SIUnits.METRE | Direct from position |
| `speed` | Number:Speed | `speed` | Configurable | Traccar sends knots, converted to kmh/mph |
| `course` | Number:Angle | `course` | Units.DEGREE_ANGLE | 0-359° compass bearing |
| `accuracy` | Number:Length | `accuracy` | SIUnits.METRE | GPS accuracy radius |
| `valid` | Switch | `valid` | - | Boolean: GPS fix validity |
| `address` | String | `address` | - | Reverse geocoded street address |
**Speed Conversion**:
```java
// Traccar sends speed in knots
double speedKnots = ((Number) speedObj).doubleValue();
double convertedSpeed;
Unit> speedUnit;
switch(speedUnitConfig) {
case "mph":
convertedSpeed = speedKnots * 1.15078;
speedUnit = ImperialUnits.MILES_PER_HOUR;
break;
case "knots":
convertedSpeed = speedKnots;
speedUnit = Units.KNOT;
break;
case "kmh":
default:
convertedSpeed = speedKnots * 1.852;
speedUnit = SIUnits.KILOMETRE_PER_HOUR;
}
updateState(CHANNEL_SPEED, new QuantityType<>(convertedSpeed, speedUnit));
```
### Distance Channels (Three Separate Channels)
**Critical Implementation**: Three distinct channels for different distance tracking needs
| Channel | Source Field | Description | Protocol |
|---------|--------------|-------------|----------|
| `odometer` | `attributes.odometer` | Device odometer reading | OSMand only |
| `totalDistance` | `attributes.totalDistance` | Server cumulative distance | All protocols |
| `distance` | `attributes.distance` | Trip distance since last update | All protocols |
**Implementation**:
```java
// Odometer (device-reported, mainly for OSMand)
Object odometerObj = attributes.get("odometer");
if (odometerObj instanceof Number) {
double odometerMeters = ((Number) odometerObj).doubleValue();
updateState(CHANNEL_ODOMETER, new QuantityType<>(odometerMeters, SIUnits.METRE));
}
// Total Distance (Traccar server cumulative distance, all protocols)
Object totalDistanceObj = attributes.get("totalDistance");
if (totalDistanceObj instanceof Number) {
double totalDistanceMeters = ((Number) totalDistanceObj).doubleValue();
updateState(CHANNEL_TOTAL_DISTANCE, new QuantityType<>(totalDistanceMeters, SIUnits.METRE));
}
// Distance (incremental distance since last update)
Object distanceObj = attributes.get("distance");
if (distanceObj instanceof Number) {
double distanceMeters = ((Number) distanceObj).doubleValue();
updateState(CHANNEL_DISTANCE, new QuantityType<>(distanceMeters, SIUnits.METRE));
}
```
**Protocol-Specific Usage**:
- **Teltonika devices**: Use `totalDistance` channel - contains actual vehicle odometer value
- Example: Springfield motorcycle shows 33,280 km (real odometer reading)
- Teltonika sends vehicle odometer in the `totalDistance` field
- **OSMand (phone tracking)**: Use `odometer` channel - contains device-reported distance
- Example: Dream Catcher phone shows 347.8 km (distance tracked by app)
- OSMand reports app's own tracking in the `odometer` field
- The `totalDistance` field for OSMand contains Traccar's cumulative calculation (often unrealistically high)
- **All protocols**: Use `distance` channel for real-time trip tracking since last position update
**Unit Conversion Philosophy**:
- Binding sends values in **base units** (meters, not kilometers)
- OpenHAB framework handles conversion based on item `unit="km"` metadata
- This allows users to choose any unit (km, mi, ft, etc.) without binding changes
### Vehicle Information Channels
**Source**: `attributes` object from Traccar webhook/API
| Channel | Type | Source Field | Conversion | Notes |
|---------|------|--------------|------------|-------|
| `ignition` | Switch | `attributes.ignition` | Boolean → OnOffType | Real-time ignition status |
| `hours` | Number:Time | `attributes.hours` | milliseconds → hours | Engine running time |
| `event` | Number | `attributes.event` | Direct | Teltonika AVL event codes |
**Ignition Implementation**:
```java
// Ignition status - available on compatible vehicle trackers (e.g., Teltonika)
Object ignitionObj = attributes.get("ignition");
if (ignitionObj instanceof Boolean) {
updateState(CHANNEL_IGNITION, OnOffType.from((Boolean) ignitionObj));
}
```
The `ignition` channel is essential for vehicle automation:
- Triggers ignition ON/OFF events in rules
- Enables automatic notifications when vehicle starts/parks
- Provides real-time ignition state monitoring
- Works with Traccar's `ignitionOn`/`ignitionOff` event webhooks
**Example ignition notification rule** (see `Springfield_Ignition.rules` in examples):
```java
// Global debounce variables (prevent duplicate notifications within 30 seconds)
var Long lastIgnitionOnTime = 0L
var Long lastIgnitionOffTime = 0L
rule "Springfield Ignition ON"
when
Item Vehicle10_Ignition changed to ON
then
try {
// Debounce check
val currentTime = new java.util.Date().time
if ((currentTime - lastIgnitionOnTime) < 30000) {
logInfo("springfield_ignition", "Ignition ON triggered too soon - skipping")
return
}
lastIgnitionOnTime = currentTime
// Extract values
val odometerState = Vehicle10_TotalDistance.state
val odometer = if (odometerState != NULL)
String.format("%.1f km", (odometerState as Number).doubleValue)
else "Unknown"
// Send email notification
sendHtmlMail("email@example.com", "Springfield Ignition ON",
"
Springfield Motorcycle
" +
"| Status: | Ignition ON |
" +
"| Odometer: | " + odometer + " |
" +
"")
} catch (Exception e) {
logError("springfield_ignition", "Error: {}", e.message)
}
end
```
**Key learnings from ignition notifications:**
- Use debounce (30-second minimum) to prevent duplicate notifications from multiple webhooks
- OpenHAB DSL requires `new java.util.Date().time` for timestamps (not `now.millis`)
- Use `Long` type for timestamp variables
- Protocol-aware odometer reading ensures correct values (Teltonika uses `totalDistance`)
**Hours (Engine Time) Conversion**:
```java
// Engine hours - convert milliseconds to hours
Object hoursObj = attributes.get("hours");
if (hoursObj instanceof Number) {
double hoursMillis = ((Number) hoursObj).doubleValue();
double hoursValue = hoursMillis / 1000.0 / 3600.0; // ms → seconds → hours
updateState(CHANNEL_HOURS, new QuantityType<>(hoursValue, Units.HOUR));
}
```
**Event Codes**:
```java
// Event code - device-specific event identifiers
Object eventObj = attributes.get("event");
if (eventObj instanceof Number) {
int eventCode = ((Number) eventObj).intValue();
updateState(CHANNEL_EVENT, new DecimalType(eventCode));
}
```
Teltonika event codes (see `transform/teltonika_event.map`):
- 239 = Ignition status change
- 240 = Movement status change
- 10828/10829/10831 = BLE beacon events
- 253 = Green driving (harsh acceleration/braking)
- 255 = Overspeeding
### Activity & Protocol Channels
- REST API shows converted state: `"state": "33279.52 km"` with `"unitSymbol": "km"`
### Vehicle Information Channels
**Engine Hours** (Teltonika-specific):
```java
// Engine hours (in milliseconds from Traccar)
Object hoursObj = attributes.get("hours");
if (hoursObj instanceof Number) {
double hoursMs = ((Number) hoursObj).doubleValue();
double hoursValue = hoursMs / 3600000.0; // Convert milliseconds to hours
logger.debug("Engine hours: {} ms = {} hours", hoursMs, hoursValue);
updateState(CHANNEL_HOURS, new QuantityType<>(hoursValue, Units.HOUR));
}
```
**Event Codes** (Teltonika-specific):
```java
// Event code (device-specific)
Object eventObj = attributes.get("event");
if (eventObj instanceof Number) {
int eventCode = ((Number) eventObj).intValue();
updateState(CHANNEL_EVENT, new DecimalType(eventCode));
}
```
### Activity Recognition (OSMand-specific)
```java
// Activity (OSMand-specific activity detection)
Object activityObj = attributes.get("activity");
if (activityObj != null) {
updateState(CHANNEL_ACTIVITY, new StringType(activityObj.toString()));
}
```
Values: `walking`, `in_vehicle`, `still`, `on_bicycle`, `on_foot`, `running`
### Protocol Identification
```java
// Protocol from main position object (not attributes)
Object protocolObj = position.get("protocol");
if (protocolObj != null) {
updateState(CHANNEL_PROTOCOL, new StringType(protocolObj.toString()));
}
```
## Protocol-Specific Attributes
### Teltonika (Industrial GPS Trackers)
**Webhook attributes example**:
```json
{
"attributes": {
"priority": 0,
"sat": 0,
"event": 10831,
"distance": 0.0,
"totalDistance": 33279520.98,
"motion": false,
"hours": 861239290
}
}
```
**Key points**:
- Uses `totalDistance` instead of `odometer`
- Provides `hours` in milliseconds (engine hours)
- Provides device-specific `event` codes (10828=ignition off, 10829=ignition on, 10831=movement)
- `sat` attribute for GPS satellite count
- No battery (wired devices) or activity recognition
### OSMand / Traccar Client (Mobile Apps)
**Webhook attributes example**:
```json
{
"attributes": {
"batteryLevel": 85.0,
"distance": 15.3,
"odometer": 179924.0,
"motion": true,
"activity": "walking"
}
}
```
**Key points**:
- Uses `odometer` attribute (not `totalDistance`)
- Provides `activity` recognition (Android/iOS motion APIs)
- Provides `batteryLevel` (percentage)
- No GPS satellite count, no engine hours, no event codes
### Implementation Strategy for Protocol Differences
1. **Always check both attribute names** (fallback pattern):
```java
Object value = attributes.get("preferredName");
if (value == null) {
value = attributes.get("alternativeName");
}
```
2. **Null-safe extraction** - channels show NULL if attribute missing (this is expected):
```java
if (valueObj instanceof Type) {
// Only update if present
updateState(CHANNEL_NAME, ...);
}
// No else needed - channel stays NULL if not supported by protocol
```
3. **Debug logging** helps identify protocol differences:
```java
logger.debug("Using totalDistance for odometer: {}", odometerObj);
logger.debug("Using odometer attribute: {}", odometerObj);
```
## Webhook Server Implementation
### Server Lifecycle
**Startup** (in `TraccarServerHandler.initialize()`):
```java
webhookServer = new TraccarWebhookServer(webhookPort, this);
webhookServer.start();
```
**Shutdown** (in `TraccarServerHandler.dispose()`):
```java
if (webhookServer != null) {
webhookServer.stop();
}
```
### Request Handling
`TraccarWebhookServer.java` extends Jetty `AbstractHandler`:
```java
@Override
public void handle(String target, Request baseRequest, HttpServletRequest request,
HttpServletResponse response) throws IOException {
if ("/webhook".equals(target) && "POST".equals(request.getMethod())) {
// Read JSON body
StringBuilder jsonBuilder = new StringBuilder();
try (BufferedReader reader = request.getReader()) {
String line;
while ((line = reader.readLine()) != null) {
jsonBuilder.append(line);
}
}
// Parse JSON
Gson gson = new Gson();
Type type = new TypeToken