[geocoding] Transformation contribution (#19921)

* first version

Signed-off-by: Bernd Weymann <bernd.weymann@gmail.com>
This commit is contained in:
Bernd Weymann
2026-01-02 17:58:03 +01:00
committed by GitHub
parent 91765f7463
commit 6f84bdd972
24 changed files with 2000 additions and 0 deletions
+1
View File
@@ -477,6 +477,7 @@
/bundles/org.openhab.transform.basicprofiles/ @cweitkamp @J-N-K
/bundles/org.openhab.transform.bin2json/ @paulianttila
/bundles/org.openhab.transform.exec/ @openhab/add-ons-maintainers
/bundles/org.openhab.transform.geocoding/ @weymann
/bundles/org.openhab.transform.jinja/ @jochen314
/bundles/org.openhab.transform.jsonpath/ @clinique
/bundles/org.openhab.transform.map/ @openhab/add-ons-maintainers
+5
View File
@@ -2366,6 +2366,11 @@
<artifactId>org.openhab.transform.exec</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.transform.geocoding</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.transform.jinja</artifactId>
@@ -0,0 +1,13 @@
This content is produced and maintained by the openHAB project.
* Project home: https://www.openhab.org
== Declared Project Licenses
This program and the accompanying materials are made available under the terms
of the Eclipse Public License 2.0 which is available at
https://www.eclipse.org/legal/epl-2.0/.
== Source Code
https://github.com/openhab/openhab-addons
@@ -0,0 +1,594 @@
# OpenHAB Geocoding Transformation Service - Project Review
## Executive Summary
This is a well-structured OpenHAB addon that provides geocoding transformation services using the Nominatim/OpenStreetMap API. The project implements both **reverse geocoding** (coordinates → address) and **geocoding** (address → coordinates) functionality as a reusable profile for OpenHAB items.
**Overall Assessment**: ✅ **SOLID IMPLEMENTATION** - The code is clean, well-organized, and follows OSGi component patterns. It includes proper error handling, configuration management, and comprehensive test coverage.
---
## 1. Project Structure & Organization
### ✅ Strengths
- **Clear Separation of Concerns**: Code is organized into logical packages:
- `profiles/` - Profile implementation
- `provider/` - Provider abstraction and factory pattern
- `config/` - Configuration classes
- `internal/` - Implementation details
- **Factory Pattern**: `GeoResolverFactory` and `GeoProfileFactory` provide clean extensibility for new providers
- **Test Resources**: Includes realistic JSON response fixtures for testing
### Package Structure
```
src/main/java/org/openhab/transform/geocoding/internal/
├── GeoProfileFactory.java # OSGi Component & Factory
├── GeoProfileConstants.java # Centralized constants
├── config/
│ └── GeoProfileConfig.java # Configuration bean
├── profiles/
│ └── GeoProfile.java # Main profile implementation
└── provider/
├── BaseGeoResolver.java # Abstract base class
├── GeoResolverFactory.java # Provider factory
└── nominatim/
└── OSMGeoResolver.java # OpenStreetMap implementation
```
---
## 2. Core Components Analysis
### 2.1 GeoProfileFactory
**File**: [GeoProfileFactory.java](src/main/java/org/openhab/transform/geocoding/internal/GeoProfileFactory.java#L1)
**Purpose**: OSGi component that implements `ProfileFactory` and `ProfileTypeProvider` to expose the geocoding profile to OpenHAB.
**Key Features**:
- ✅ Properly decorated with `@Component` for OSGi registration
- ✅ Dependency injection via constructor (HttpClientFactory, LocaleProvider)
- ✅ Defines supported item types (STRING) and channel types (LOCATION)
**Code Quality**: Excellent - concise and follows OpenHAB patterns.
---
### 2.2 GeoProfile
**File**: [GeoProfile.java](src/main/java/org/openhab/transform/geocoding/internal/profiles/GeoProfile.java#L1)
**Purpose**: Implements the core profile logic for both reverse geocoding and geocoding operations.
**Key Features**:
#### Reverse Geocoding Flow
- Receives state updates from handler (latitude/longitude)
- Implements throttling via `resolveInterval` (default 5 minutes, minimum 1 minute)
- Uses `ScheduledExecutorService` to schedule deferred resolution
- Thread-safe with `synchronized` blocks for shared state
**Thread Safety**: ✅ Good
- `lastResolveTime`, `resolverJob`, `lastState` are protected with `synchronized` blocks
- Avoids holding locks during blocking I/O operations
**Throttling Logic**: ✅ Well-Designed
- If no job is running and resolve interval has passed → resolve immediately
- If no job is running and interval hasn't passed → schedule for future
- If job already running → skip (prevents duplicate requests)
#### Geocoding Flow (Search)
- Receives string commands from items
- Encodes search string and calls API
- Returns coordinates as `PointType` to handler
**Configuration Handling**:
```java
if (!configuration.language.isBlank()) {
language = configuration.language;
} else {
language = locale.getLocale().getLanguage() + "-" + locale.getLocale().getCountry();
}
```
✅ Respects user override with fallback to system locale.
**Interval Parsing**:
```java
try {
refreshInterval = DurationUtils.parse(configuration.resolveInterval);
if (refreshInterval.getSeconds() < 60) {
refreshInterval = Duration.ofMinutes(1);
}
} catch (IllegalArgumentException e) {
refreshInterval = Duration.ofMinutes(5);
logger.warn("Could not parse interval '{}', using default interval {}", ...);
}
```
✅ Defensive - handles parse errors with reasonable defaults.
---
### 2.3 BaseGeoResolver
**File**: [BaseGeoResolver.java](src/main/java/org/openhab/transform/geocoding/internal/provider/BaseGeoResolver.java#L1)
**Purpose**: Abstract base class defining the resolver contract.
**Key Features**:
- ✅ Abstraction for different geocoding providers
- ✅ Handles both StringType (address) and PointType (coordinates)
- ✅ User-Agent supplier pattern for testing flexibility
- ✅ HTTP timeout constant (10 seconds)
**State Detection**:
```java
if (toBeResolved instanceof StringType stringType) {
geoSearchString = stringType.toFullString();
} else if (toBeResolved instanceof PointType pointType) {
geoLocation = pointType;
}
```
✅ Clean type checking to determine operation type.
---
### 2.4 OSMGeoResolver
**File**: [OSMGeoResolver.java](src/main/java/org/openhab/transform/geocoding/internal/provider/nominatim/OSMGeoResolver.java#L1)
**Purpose**: Concrete implementation for Nominatim/OpenStreetMap API.
**API Integration**: ✅ Solid
- Constructs correct Nominatim URLs with proper encoding
- Sets required headers (User-Agent, Accept-Language)
- Handles HTTP status codes properly
**Reverse Geocoding**:
```java
String jsonResponse = apiCall(String.format(Locale.US, REVERSE_URL,
localGeoLocation.getLatitude().doubleValue(),
localGeoLocation.getLongitude().doubleValue()));
resolvedString = formatAddress(jsonResponse);
```
✅ Uses `Locale.US` format specifier for consistent decimal separators in coordinates.
**Error Handling**: ✅ Robust
```java
} catch (TimeoutException | ExecutionException e) {
logger.debug("Resolving of {} failed with exception {}", ...);
} catch (InterruptedException ie) {
logger.debug("Resolving of {} interrupted {}", ...);
Thread.currentThread().interrupt(); // ✅ Proper interrupt handling
}
```
**Address Formatting**: ✅ Flexible with Multiple Options
1. **ROW_ADDRESS_FORMAT** (Default): Road + House Number, Postcode City District
```
"Am Friedrichshain 22, 10407 Berlin Pankow"
```
2. **US_ADDRESS_FORMAT**: House Number + Road, City District Postcode
```
"6 West 23rd Street, City of New York Manhattan 10010"
```
3. **JSON_FORMAT**: Returns only address object as JSON
4. **RAW_FORMAT**: Returns entire raw JSON response
**Fallback Mechanism**: If structured address parsing fails, it returns JSON representation. ✅ Good UX.
**Address Field Extraction**:
```java
@SafeVarargs
private final String decodeAddress(JSONObject jsonObject, List<String> streetPart1,
List<String> streetPart2, List<String>... cityKeys) {
if (jsonObject.has(ADDRESS_KEY)) {
JSONObject address = jsonObject.getJSONObject(ADDRESS_KEY);
String street = (get(address, streetPart1) + " " + get(address, streetPart2)).strip();
// ...
}
}
```
✅ Handles multiple key variants (e.g., "city", "town", "village") for flexibility across regions.
---
### 2.5 GeoProfileConfig
**File**: [config/GeoProfileConfig.java](src/main/java/org/openhab/transform/geocoding/internal/config/GeoProfileConfig.java#L1)
**Purpose**: Configuration bean with sensible defaults.
```java
public String provider = PROVIDER_NOMINATIM_OPENSTREETMAP;
public String format = ROW_ADDRESS_FORMAT;
public String resolveInterval = "5m";
public String language = "";
```
✅ Defaults are reasonable and documented.
---
## 3. Configuration & Constants
### 3.1 GeoProfileConstants
**File**: [GeoProfileConstants.java](src/main/java/org/openhab/transform/geocoding/internal/GeoProfileConstants.java#L1)
**Strengths**: ✅
- Centralized configuration reduces maintenance burden
- Comment references Nominatim documentation
- Comprehensive address field lists for different regions
**Address Fields Supported**:
```java
ROAD_KEYS = ["road"]
HOUSE_NUMBER_KEYS = ["house_number", "house_name"]
ZIP_CODE_KEYS = ["postcode"]
CITY_KEYS = ["municipality", "city", "town", "village"]
DISTRICT_KEYS = ["city_district", "district", "borough", "suburb", "subdivision"]
```
✅ Good coverage of regional variations.
### 3.2 Configuration Options (OH-INF)
**File**: [OH-INF/config/geocoding.xml](src/main/resources/OH-INF/config/geocoding.xml#L1)
✅ Well-documented configuration with:
- Clear labels and descriptions
- Default values
- Format options with human-readable labels
- Advanced flag for language override
---
## 4. Testing
### 4.1 OSMProviderTest
**File**: [OSMProviderTest.java](src/test/java/org/openhab/transform/geocoding/internal/service/nominatim/OSMProviderTest.java#L1)
**Coverage**: ✅ Comprehensive with 12+ test cases
**Test Cases Include**:
1. ✅ Invalid/missing configuration (null provider)
2. ✅ HTTP error responses (400, non-200 status)
3. ✅ Valid reverse geocoding responses
4. ✅ Different address formats (ROW, US, JSON)
5. ✅ Missing address fields (rural areas)
6. ✅ Valid geocoding (address search) requests
7. ✅ Invalid JSON responses
8. ✅ Empty search results
**Mock Setup**: ✅ Proper
- Uses Mockito to mock HttpClient, ContentResponse, etc.
- Allows flexible response status/content injection
**Test Data**: ✅ Real-world examples included
- `geo-reverse-result.json` - Berlin building
- `geo-reverse-nyc.json` - NYC address
- `geo-reverse-result-no-road.json` - Rural area without street
- `geo-search-result.json` - Search results
**Parametrized Tests**: ✅ Good practice using `@ParameterizedTest` with `@MethodSource`
---
## 5. Dependencies
### 5.1 Maven Dependencies
**Primary Dependency**:
```xml
<dependency>
<groupId>org.json</groupId>
<artifactId>json</artifactId>
<version>20231013</version>
<scope>compile</scope>
</dependency>
```
**Assessment**: ✅ Minimal and appropriate. Avoids bloat while providing JSON parsing.
**Implicit Dependencies** (from OpenHAB Core):
- `org.eclipse.jetty.client.HttpClient` - HTTP client
- `org.openhab.core.i18n.LocaleProvider` - Locale management
- `org.openhab.core.thing.profiles.*` - Profile framework
---
## 6. Code Quality Issues & Observations
### 6.1 Potential Issues
#### Issue 1: Limited Error Context in Logs
**Severity**: 🟡 Medium
**Location**: [OSMGeoResolver.java](src/main/java/org/openhab/transform/geocoding/internal/provider/nominatim/OSMGeoResolver.java#L135)
```java
} catch (TimeoutException | ExecutionException e) {
logger.debug("Resolving of {} failed with exception {}",
toBeResolved.toFullString(), e.getMessage());
}
```
**Issue**: Uses `e.getMessage()` which may be null or unhelpful.
**Recommendation**:
```java
logger.debug("Resolving of {} failed with exception {}",
toBeResolved.toFullString(), e.getClass().getSimpleName(), e);
```
---
#### Issue 2: Thread Interrupt Not Propagated
**Severity**: 🟡 Low
**Location**: [OSMGeoResolver.java](src/main/java/org/openhab/transform/geocoding/internal/provider/nominatim/OSMGeoResolver.java#L144)
```java
catch (InterruptedException ie) {
logger.debug("Resolving of {} interrupted {}", toBeResolved.toFullString(), ie.getMessage());
Thread.currentThread().interrupt();
}
```
**Issue**: Method doesn't return early after interrupt, continues execution.
**Current Behavior**: Harmless since `return "";` is next line, but slightly confusing.
---
#### Issue 3: Race Condition Possibility in GeoProfile
**Severity**: 🟡 Medium
**Location**: [GeoProfile.java](src/main/java/org/openhab/transform/geocoding/internal/profiles/GeoProfile.java#L115)
```java
synchronized (this) {
localLastState = lastState;
lastResolveTime = Instant.now();
resolverJob = null;
}
// do reverse geocoding and double check for success before sending update
localLastState.resolve(); // ← Not synchronized
if (localLastState.isResolved()) {
callback.sendUpdate(StringType.valueOf(localLastState.getResolved()));
}
```
**Issue**: `resolve()` is called outside synchronized block. If another thread updates `lastState` during this call, it could process stale state.
**Risk**: Low in practice due to scheduled nature, but not thread-safe per se.
**Recommendation**:
```java
synchronized (this) {
localLastState = lastState;
lastResolveTime = Instant.now();
resolverJob = null;
// Copy to avoid holding lock during I/O
PointType locationCopy = localLastState.geoLocation != null
? PointType.valueOf(localLastState.geoLocation.toFullString())
: null;
}
if (locationCopy != null) {
localLastState.resolve();
// ...
}
```
---
#### Issue 4: User-Agent Header May Not Respect Language
**Severity**: 🟢 Low (Design choice)
**Location**: [OSMGeoResolver.java](src/main/java/org/openhab/transform/geocoding/internal/provider/nominatim/OSMGeoResolver.java#L125)
```java
.header(HttpHeader.ACCEPT_LANGUAGE, config.language)
.header(HttpHeader.USER_AGENT, userAgentSupplier.get())
```
**Observation**: User-Agent is fixed (e.g., "openHAB/5.2.0") and doesn't include language. This is actually correct per HTTP standards, but worth noting.
---
#### Issue 5: No Retry Logic
**Severity**: 🟡 Low
**Location**: [OSMGeoResolver.java](src/main/java/org/openhab/transform/geocoding/internal/provider/nominatim/OSMGeoResolver.java#L125)
**Observation**: Single attempt per request. Transient network errors result in failure.
**Mitigation**: Throttling (`resolveInterval`) reduces impact. Retry logic would be nice but adds complexity.
---
### 6.2 Code Quality Strengths
✅ **Excellent Logging**:
- Appropriate log levels (trace, debug, warn)
- Contextual information in messages
- No sensitive data logged
✅ **Proper Resource Management**:
- HTTP timeout set (10 seconds)
- No resource leaks observed
- ScheduledExecutorService properly injected from context
✅ **Defensive Programming**:
- Null checks with `@Nullable` annotations
- Config validation with fallback defaults
- Checks for resolved state before returning results
✅ **Clean API**:
- Abstract base class allows easy provider additions
- Factory pattern enables testing
- Configuration is declarative
---
## 7. Documentation
### 7.1 README.md
**File**: [README.md](README.md)
**Coverage**: ✅ Excellent
- ✅ Clear feature description
- ✅ Configuration parameter explanation
- ✅ Usage policy warnings (Nominatim throttling)
- ✅ Address format examples
- ✅ Example configuration snippet
**Minor Improvements**:
- Could include example item configuration for both reverse geocoding and geocoding
- Could mention response time expectations
- No troubleshooting section
### 7.2 Code Comments
✅ Generally good
- Class-level JavaDoc on all public classes
- Method-level JavaDoc on public methods
- Inline comments for complex logic
**Missing**:
- Some magic constants (e.g., why 0,0 for default location?)
- No explanation of scheduler behavior
---
## 8. Compliance & Standards
### 8.1 OpenHAB Integration
✅ **Proper OSGi Pattern**:
- Component decorated with `@Component`
- Dependencies injected via constructor
- Service interfaces properly implemented
- Profile activation event handling
✅ **Nominatim Compliance**:
- Respects User-Agent requirement
- Implements throttling (configurable, minimum 1 minute)
- README includes usage policy link
### 8.2 Java Standards
✅ **Java 11+ Features**:
- Text blocks NOT used (for compatibility)
- Modern Stream API for test data
- Proper use of generics
- @NonNullByDefault annotations
---
## 9. Feature Completeness
| Feature | Status | Notes |
|---------|--------|-------|
| Reverse Geocoding | ✅ Complete | Throttled, configurable interval |
| Geocoding/Search | ✅ Complete | No throttling (per policy) |
| Multiple Address Formats | ✅ Complete | ROW, US, JSON, Raw options |
| Locale Support | ✅ Complete | Override via config |
| Error Handling | ✅ Good | Graceful degradation |
| Testing | ✅ Comprehensive | 12+ test cases with fixtures |
| Documentation | ✅ Good | README covers usage well |
| Configuration | ✅ Complete | 4 configurable parameters |
| Provider Extensibility | ✅ Good | Factory pattern allows additions |
---
## 10. Security Considerations
### ✅ Good Practices
- No hardcoded credentials
- HTTP timeout prevents DoS
- User-Agent header included (Nominatim policy)
- Configuration is profile-specific (not global)
### ⚠️ Considerations
1. **External API Dependency**: Relies on Nominatim API availability
2. **Rate Limiting**: Trusts user to configure adequate `resolveInterval`
3. **Data Privacy**: Coordinates/addresses sent to Nominatim servers (disclosed in docs)
---
## 11. Performance Observations
### Strengths
✅ **Throttling**: Reverse geocoding respects `resolveInterval` to avoid spam
**Async Scheduling**: Uses ScheduledExecutorService, doesn't block main thread
**Smart Scheduling**: Skips redundant requests if job already running
**Minimal Dependencies**: Single lightweight JSON library
### Potential Improvements
- 🟡 No caching: Every unique coordinate/address makes an API call
- Could implement simple in-memory cache
- Trade-off: Stale data vs. reduced API load
- 🟡 No batching: Each item's request is independent
- Could batch requests if multiple items update
- Low priority given typical update rates
---
## 12. Summary of Recommendations
### Critical (None)
No critical issues found.
### High Priority
None identified.
### Medium Priority
1. **Improve Exception Logging** - Include exception class name and stack trace
2. **Document Thread Safety** - Add JavaDoc explaining synchronization strategy
3. **Add Cache Layer** (Optional) - Implement simple in-memory cache for coordinates
### Low Priority
1. Add troubleshooting section to README
2. Include example item configuration for both use cases
3. Add retry logic for transient failures
4. Consider adding metrics/telemetry
---
## 13. Comparison to Best Practices
| Aspect | Standard | Project | Rating |
|--------|----------|---------|--------|
| Error Handling | Comprehensive | Good | ⭐⭐⭐⭐ |
| Logging | Contextual | Good | ⭐⭐⭐⭐ |
| Testing | >80% coverage | Good | ⭐⭐⭐⭐ |
| Documentation | Clear & Complete | Excellent | ⭐⭐⭐⭐⭐ |
| Code Organization | Logical packages | Excellent | ⭐⭐⭐⭐⭐ |
| Maintainability | Easy to extend | Excellent | ⭐⭐⭐⭐⭐ |
| Thread Safety | Explicit design | Good | ⭐⭐⭐⭐ |
| API Design | Clean abstractions | Excellent | ⭐⭐⭐⭐⭐ |
---
## 14. Overall Assessment
### ✅ **PRODUCTION READY**
This is a well-implemented OpenHAB addon that provides reliable geocoding services. The code is:
- **Clean** - Follows OpenHAB patterns and Java conventions
- **Maintainable** - Clear structure with good separation of concerns
- **Tested** - Comprehensive test suite with real-world test data
- **Documented** - README explains features and configuration clearly
- **Extensible** - Factory pattern allows adding new providers
- **Robust** - Good error handling and graceful degradation
### Key Strengths
1. ✅ Proper OSGi integration with dependency injection
2. ✅ Smart throttling mechanism for reverse geocoding
3. ✅ Support for multiple address formats
4. ✅ Comprehensive error handling
5. ✅ Good test coverage with realistic fixtures
6. ✅ Minimal dependencies
7. ✅ Clear documentation
### Areas for Enhancement
1. 🟡 Add retry logic for transient failures
2. 🟡 Consider simple caching strategy
3. 🟡 Improve exception logging detail
4. 🟡 Add troubleshooting guide
### Conclusion
The project is **production-ready** and demonstrates good software engineering practices. The implementation correctly integrates with OpenHAB's profile framework and properly respects Nominatim API policies. The code is maintainable and extensible for future enhancements.
---
**Review Date**: January 2, 2026
**Reviewer**: GitHub Copilot
**Project Version**: 5.2.0-SNAPSHOT
@@ -0,0 +1,77 @@
# Geocoding Profile Transformation Service
Transformation to convert
- geo coordinates into human readable string address (reverse geocoding)
- string address description into geo coordinates (geocoding)
## Reverse Geocoding
The reverse geocoding is applied when the channel updates the `Location` with latitude and longitude geo coordinates.
These will be resolved into a human readable address.
Reverse geocoding is throttled by the profile with `resolveInterval` to avoid frequent calling.
## Geocoding
Geocoding is applied if you send a string command towards the item.
The API is translating this search string into geo coordinates which are send via the channel towards the handler.
Of course this makes sense only if the channel is declared as writable.
Formulate your string command as precise as possible to avoid ambiguous results.
E.g. _Springfield US_ command will deliver multiple results and only one is chosen for the transformation.
Geocoding is not throttled by the profile.
## Configuration
| Configuration Parameter | Type | Description |
|-------------------------|------|--------------------------------------------------------------------------------------------------|
| `provider` | text | Provider which is used to execute geocoding request |
| `resolveInterval` | text | Interval of reverse geocoding executions. Minimum: 1 minute |
| `format` | text | Country specific address formatting |
| `language` | text | Preferred language of the result. Only necessary if openHAB locale settings shall be overwritten |
Select `provider` which shall be used to resolve addresses.
Currently one provider [Nominatim / OpenStreetMap](#nominatim--openstreetmap-provider) is available which is the default option.
The `resolveInterval` defines the minimum time between two reverse geocoding transformations.
An external API is called to resolve the geo coordinates and it shall not be queried too frequent.
Channel updates within the Interval are omitted.
After the configured interval is expired the last received location will be transformed.
Minimum configurable interval is 1 minute.
Default is 5 minutes (`5m`).
Select preferred display `format` for reverse geocoding.
Options:
- `row_address`: `street` `house-number`, `postcode` `city` `district`
- `us_address`: `house-number` `street`, `city` `district` `postcode`
- `json`: unformatted JSON response
Note that [address fields](https://nominatim.org/release-docs/latest/api/Output/#addressdetails) may be missing e.g. for rural areas.
The API calls are performed with your openHAB locale settings.
This can be overwritten with `language` configuration parameter using [Java Locale format](https://www.oracle.com/java/technologies/javase/jdk21-suported-locales.html).
## Provider
### Nominatim / OpenStreetMap Provider
Geocoding transformation provider using [Nominatim API for OpenStreetMap](https://nominatim.org/release-docs/latest/) to resolve
- geo coordinates into a human readable string ([reverse geocoding](https://nominatim.org/release-docs/latest/api/Reverse/))
- an address string into geo coordinates ([geocoding](https://nominatim.org/release-docs/latest/api/Search/))
**You must respect the** [Nominatim Usage Policy](https://operations.osmfoundation.org/policies/nominatim/)!
You need to estimate your call frequency towards the _Nominatim_ provider.
For reverse geocoding the configuration parameter `resolveInterval` with minimum resolve time of 1 minute shall fulfill the throttling requirements.
For geocoding there's no throttling.
Each user needs to respect the maximum allowed frequency of 1 call per second.
The required `User-Agent` is provided by this transformation profile.
Credits to [Nominatim](https://nominatim.org) and [OpenStreetMap](https://www.openstreetmap.org/) to provide this free service!
### Example
```java
String <itemName> { channel="<locationChannelUID>"[profile="transform:geocoding",provider="nominatim-osm",format="us_address",resolveInterval="10m",language="en-US"]}
```
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.addons.reactor.bundles</artifactId>
<version>5.2.0-SNAPSHOT</version>
</parent>
<artifactId>org.openhab.transform.geocoding</artifactId>
<name>openHAB Add-ons :: Bundles :: Transformation Service :: Geocoding</name>
<dependencies>
<!-- version needs to match with other projects like org.openhab.io.openhabcloud.pom.xml -->
<dependency>
<groupId>org.json</groupId>
<artifactId>json</artifactId>
<version>20231013</version>
<scope>compile</scope>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<features name="org.openhab.transform.geocoding-${project.version}" xmlns="http://karaf.apache.org/xmlns/features/v1.4.0">
<repository>mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features</repository>
<feature name="openhab-transformation-geocoding" description="Geocoding Transformations" version="${project.version}">
<feature>openhab-runtime-base</feature>
<bundle start-level="75">mvn:org.openhab.addons.bundles/org.openhab.transform.geocoding/${project.version}</bundle>
</feature>
</features>
@@ -0,0 +1,59 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.transform.geocoding.internal;
import java.util.List;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.thing.profiles.ProfileTypeUID;
import org.openhab.core.transform.TransformationService;
/**
* The {@link GeoProfileConstants} defines fields used for the profile and across providers.
*
* @author Bernd Weymann - Initial contribution
*/
@NonNullByDefault
public class GeoProfileConstants {
// Profile Type UID
public static final ProfileTypeUID GEOCODING_PROFILE_TYPE_UID = new ProfileTypeUID(
TransformationService.TRANSFORM_PROFILE_SCOPE, "geocoding");
// Constants for Provider
public static final String PROVIDER_NOMINATIM_OPENSTREETMAP = "nominatim-osm";
// JSON Keys
public static final String ADDRESS_KEY = "address";
public static final String LATITUDE_KEY = "lat";
public static final String LONGITUDE_KEY = "lon";
// Address options constants
public static final String ROW_ADDRESS_FORMAT = "row_address";
public static final String US_ADDRESS_FORMAT = "us_address";
public static final String JSON_FORMAT = "json";
public static final String RAW_FORMAT = "raw";
// see https://nominatim.org/release-docs/latest/api/Output/#addressdetails
public static final List<String> ROAD_KEYS = List.of("road");
public static final List<String> HOUSE_NUMBER_KEYS = List.of("house_number", "house_name");
public static final List<String> ZIP_CODE_KEYS = List.of("postcode");
public static final List<String> CITY_KEYS = List.of("municipality", "city", "town", "village");
public static final List<String> DISTRICT_KEYS = List.of("city_district", "district", "borough", "suburb",
"subdivision");
// Nominatim / OpenStreetMap URLs
public static final String BASE_URL = "https://nominatim.openstreetmap.org/";
public static final String SEARCH_URL = BASE_URL + "search?q=%s&format=jsonv2";
public static final String REVERSE_URL = BASE_URL + "reverse?lat=%.7f&lon=%.7f&format=jsonv2";
}
@@ -0,0 +1,73 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.transform.geocoding.internal;
import static org.openhab.transform.geocoding.internal.GeoProfileConstants.GEOCODING_PROFILE_TYPE_UID;
import java.util.Collection;
import java.util.List;
import java.util.Locale;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.i18n.LocaleProvider;
import org.openhab.core.io.net.http.HttpClientFactory;
import org.openhab.core.library.CoreItemFactory;
import org.openhab.core.thing.profiles.Profile;
import org.openhab.core.thing.profiles.ProfileCallback;
import org.openhab.core.thing.profiles.ProfileContext;
import org.openhab.core.thing.profiles.ProfileFactory;
import org.openhab.core.thing.profiles.ProfileType;
import org.openhab.core.thing.profiles.ProfileTypeBuilder;
import org.openhab.core.thing.profiles.ProfileTypeProvider;
import org.openhab.core.thing.profiles.ProfileTypeUID;
import org.openhab.transform.geocoding.internal.profiles.GeoProfile;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
/**
* {@link GeoProfileFactory} is the factory to create the geocoding profile
*
* @author Bernd Weymann - Initial contribution
*/
@NonNullByDefault
@Component(service = { ProfileFactory.class, ProfileTypeProvider.class })
public class GeoProfileFactory implements ProfileFactory, ProfileTypeProvider {
private final HttpClientFactory httpClientFactory;
private final LocaleProvider localeProvider;
@Activate
public GeoProfileFactory(final @Reference HttpClientFactory httpFactory, final @Reference LocaleProvider locale) {
this.httpClientFactory = httpFactory;
this.localeProvider = locale;
}
@Override
public Collection<ProfileType> getProfileTypes(@Nullable Locale locale) {
return List.of(ProfileTypeBuilder.newState(GEOCODING_PROFILE_TYPE_UID, "Geocoding")
.withSupportedItemTypes(CoreItemFactory.STRING)
.withSupportedItemTypesOfChannel(CoreItemFactory.LOCATION).build());
}
@Override
public @Nullable Profile createProfile(ProfileTypeUID profileTypeUID, ProfileCallback profileCallback,
ProfileContext profileContext) {
return new GeoProfile(profileCallback, profileContext, httpClientFactory.getCommonHttpClient(), localeProvider);
}
@Override
public Collection<ProfileTypeUID> getSupportedProfileTypeUIDs() {
return List.of(GEOCODING_PROFILE_TYPE_UID);
}
}
@@ -0,0 +1,31 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.transform.geocoding.internal.config;
import static org.openhab.transform.geocoding.internal.GeoProfileConstants.*;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* The {@link GeoProfileConfig} class contains the configuration parameters for the profile
*
* @author Bernd Weymann - initial contribution
*
*/
@NonNullByDefault
public class GeoProfileConfig {
public String provider = PROVIDER_NOMINATIM_OPENSTREETMAP;
public String format = ROW_ADDRESS_FORMAT;
public String resolveInterval = "5m";
public String language = "";
}
@@ -0,0 +1,182 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.transform.geocoding.internal.profiles;
import static org.openhab.transform.geocoding.internal.GeoProfileConstants.GEOCODING_PROFILE_TYPE_UID;
import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.HttpClient;
import org.openhab.core.i18n.LocaleProvider;
import org.openhab.core.library.types.PointType;
import org.openhab.core.library.types.StringType;
import org.openhab.core.thing.profiles.ProfileCallback;
import org.openhab.core.thing.profiles.ProfileContext;
import org.openhab.core.thing.profiles.ProfileTypeUID;
import org.openhab.core.thing.profiles.StateProfile;
import org.openhab.core.types.Command;
import org.openhab.core.types.State;
import org.openhab.core.util.DurationUtils;
import org.openhab.transform.geocoding.internal.config.GeoProfileConfig;
import org.openhab.transform.geocoding.internal.provider.BaseGeoResolver;
import org.openhab.transform.geocoding.internal.provider.GeoResolverFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link GeoProfile} handles the general profile behavior without knowing the used provider. The provider is
* evaluated in super class {@link GeoResolverFactory}.
*
* @author Bernd Weymann - Initial contribution
*/
@NonNullByDefault
public class GeoProfile extends GeoResolverFactory implements StateProfile {
private final Logger logger = LoggerFactory.getLogger(GeoProfile.class);
private final ProfileCallback callback;
private final ScheduledExecutorService scheduler;
private BaseGeoResolver lastState;
private Instant lastResolveTime = Instant.MIN;
private Duration refreshInterval;
private String language;
private @Nullable ScheduledFuture<?> resolverJob;
public GeoProfile(final ProfileCallback callback, final ProfileContext context, final HttpClient client,
final LocaleProvider locale) {
super(context.getConfiguration().as(GeoProfileConfig.class), client);
this.callback = callback;
this.scheduler = context.getExecutorService();
if (!configuration.language.isBlank()) {
language = configuration.language;
} else {
language = locale.getLocale().getLanguage() + "-" + locale.getLocale().getCountry();
}
try {
refreshInterval = DurationUtils.parse(configuration.resolveInterval);
// ensure minimal refresh interval of 1 minute
if (refreshInterval.getSeconds() < 60) {
refreshInterval = Duration.ofMinutes(1);
}
} catch (IllegalArgumentException e) {
// fallback to default interval of 5 minutes
refreshInterval = Duration.ofMinutes(5);
logger.warn("Could not parse interval '{}', using default interval {}", configuration.resolveInterval,
refreshInterval);
}
logger.debug("GeoProfile created with language: {} and resolve interval: {}", language, refreshInterval);
lastState = super.createResolver(PointType.valueOf("0,0"));
}
@Override
public ProfileTypeUID getProfileTypeUID() {
return GEOCODING_PROFILE_TYPE_UID;
}
@Override
public void onStateUpdateFromItem(State state) {
// no operation
}
@Override
public void onStateUpdateFromHandler(State state) {
processState(state);
}
/**
* Processes the incoming state and schedules reverse geocoding according to configured interval
*
* @param location to be resolved
*/
private void processState(State location) {
if (location instanceof PointType point) {
synchronized (this) {
lastState = super.createResolver(point);
Instant now = Instant.now();
Instant nextResolveTime = lastResolveTime.plus(refreshInterval);
if (resolverJob == null) {
// no job running so let's check last resolve timestamp
if (now.isAfter(nextResolveTime)) {
// resolve interval passed, do immediate resolve
logger.trace("Resolve now.");
resolverJob = scheduler.schedule(this::doResolve, 0, TimeUnit.SECONDS);
} else {
// schedule resolving for the future
long delaySeconds = Duration.between(now, nextResolveTime).toSeconds();
logger.trace("Resolve in {} seconds.", delaySeconds);
resolverJob = scheduler.schedule(this::doResolve, delaySeconds, TimeUnit.SECONDS);
}
} else {
logger.trace("Resolve job already scheduled, skipping new scheduling.");
}
}
}
}
/**
* Callback function of the resolverJob to perform reverse geocoding on the last stored state
*/
private void doResolve() {
BaseGeoResolver localLastState;
synchronized (this) {
localLastState = lastState;
lastResolveTime = Instant.now();
resolverJob = null;
}
// do reverse geocoding and double check for success before sending update
localLastState.resolve();
if (localLastState.isResolved()) {
callback.sendUpdate(StringType.valueOf(localLastState.getResolved()));
} else {
logger.debug("Could not resolve address for location: {}", localLastState.toString());
}
}
@Override
public void onCommandFromItem(Command command) {
search(command);
}
@Override
public void onCommandFromHandler(Command command) {
// no operation
}
/**
* Perform search for the given command string and take the first relevant result as geo coordinates
*
* @param command string with the search query
*/
private void search(Command command) {
if (command instanceof StringType string) {
BaseGeoResolver geoSearch = createResolver(string);
geoSearch.resolve();
if (geoSearch.isResolved()) {
String geoCoordinates = geoSearch.getResolved();
PointType point = PointType.valueOf(geoCoordinates);
logger.trace("Send coordinates {} for address {}", point.toFullString(), command.toFullString());
callback.handleCommand(point);
} else {
logger.debug("Geo search could not resolve coordinates for command {}", command.toFullString());
}
} else {
logger.trace("No possible geo search for command {}", command.toFullString());
}
}
}
@@ -0,0 +1,156 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.transform.geocoding.internal.provider;
import java.util.function.Supplier;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.HttpClient;
import org.openhab.core.OpenHAB;
import org.openhab.core.library.types.PointType;
import org.openhab.core.library.types.StringType;
import org.openhab.core.types.State;
import org.openhab.transform.geocoding.internal.config.GeoProfileConfig;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link BaseGeoResolver} abstract class is a helper to extend this transformation service with new providers
* without affecting the Profile itsself.
*
* @author Bernd Weymann - Initial contribution
*/
@NonNullByDefault
public abstract class BaseGeoResolver {
private final Logger logger = LoggerFactory.getLogger(BaseGeoResolver.class);
// HTTP timeout
protected static final int HTTP_TIMEOUT_SECONDS = 10;
// HttpClient to execute service API calls
protected HttpClient httpClient;
// Configuration parameters to be respected
protected GeoProfileConfig config;
// State to be resolved
protected State toBeResolved;
// Geo search string to be resolved
protected @Nullable String geoSearchString;
// Geo location to be resolved
protected @Nullable PointType geoLocation;
// resulting address after resolve call
protected @Nullable String resolvedString;
// provide user agent for all providers
protected Supplier<String> userAgentSupplier;
/**
* Creates a resolver object with all necessary data and configuration parameters.
*
* @param toBeResolved as State
* @param config with all configured parameters
* @param httpClient to perform service API calls
*/
public BaseGeoResolver(State toBeResolved, GeoProfileConfig config, HttpClient httpClient) {
this.config = config;
this.httpClient = httpClient;
userAgentSupplier = this::getUserAgent;
this.toBeResolved = toBeResolved;
// evaluate which state type is given to decide if it's a geocoding or reverse geocoding requets
if (toBeResolved instanceof StringType stringType) {
geoSearchString = stringType.toFullString();
} else if (toBeResolved instanceof PointType pointType) {
geoLocation = pointType;
} else {
logger.debug("State {} isn't supported for geocoding", toBeResolved);
}
}
/**
* Query to check State toBeResolved from constructor is successfully resolved
*
* @return true if address is resolved, false otherwise
*/
public boolean isResolved() {
return resolvedString != null;
}
/**
* Gets the resolved string, either with human readable readable address string or geo coordinates for given search
* address
*
* @return resolved String
*/
public String getResolved() {
String localResolved = resolvedString;
if (localResolved != null) {
return localResolved;
}
return "";
}
public String getProvider() {
return config.provider;
}
/**
* Starts resolved execution with the objects given in the constructor. Check afterwards with isResolved for
* successful execution and getAddress to get resolved String.
*/
public void resolve() {
if (isResolved()) {
logger.trace("State {} is already resolved {}", toBeResolved.toFullString(), getResolved());
return;
}
PointType localGeoLocation = geoLocation;
String localGeoSearchString = geoSearchString;
if (localGeoLocation != null) {
resolvedString = geoReverseSearch(localGeoLocation);
} else if (localGeoSearchString != null) {
resolvedString = geoSearch(localGeoSearchString);
}
}
/**
* Search coordinates for given address
*
* @param address as String
* @return resolved coordinates as String "lat,lon" or null if not resolved
*/
public abstract @Nullable String geoSearch(String address);
/**
* Search address for given coordinates
*
* @param coordinates as PointType
* @return address as String or null if not resolved
*/
public abstract @Nullable String geoReverseSearch(PointType coordinates);
/**
* Override the supplier for unit and release tests.
*
* @param userAgentSupplier the supplier providing the User-Agent header value
*/
public void setUserAgentSupplier(Supplier<String> userAgentSupplier) {
this.userAgentSupplier = userAgentSupplier;
}
/**
* Get user agent for productive code
*
* @return user agent String for http queries
*/
protected String getUserAgent() {
return "openHAB/" + OpenHAB.getVersion() + " (Geo Transformation Service)";
}
}
@@ -0,0 +1,64 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.transform.geocoding.internal.provider;
import static org.openhab.transform.geocoding.internal.GeoProfileConstants.PROVIDER_NOMINATIM_OPENSTREETMAP;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jetty.client.HttpClient;
import org.openhab.core.types.State;
import org.openhab.transform.geocoding.internal.config.GeoProfileConfig;
import org.openhab.transform.geocoding.internal.provider.nominatim.OSMGeoResolver;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link GeoResolverFactory} is responsible to create resolver objects for geocoding and reverse geocoding.
* {@link GeoProfileConfig} holds the information about the configured provider. The factory provides the necessary
* structure to integrate new geocoding providers.
*
* @author Bernd Weymann - Initial contribution
*/
@NonNullByDefault
public class GeoResolverFactory {
private final Logger logger = LoggerFactory.getLogger(GeoResolverFactory.class);
// HttpClient to execute service API calls
protected HttpClient httpClient;
// Configuration parameters to be respected
protected GeoProfileConfig configuration;
public GeoResolverFactory(GeoProfileConfig config, HttpClient httpClient) {
this.configuration = config;
this.httpClient = httpClient;
}
/**
* Factory function to create GeocodingResolver objects. Extend this function if new providers are introduced,
*
* @param toBeResolved state for transformation
* @return GeocodingResolver according to configured provider
*/
public BaseGeoResolver createResolver(State toBeResolved) {
switch (configuration.provider) {
case PROVIDER_NOMINATIM_OPENSTREETMAP: {
return new OSMGeoResolver(toBeResolved, configuration, httpClient);
}
default:
logger.warn("Configured geocoding provider '{}' is not supported, falling back to default provider.",
configuration.provider);
// default is Nominatiom / OpenStreetMap
return new OSMGeoResolver(toBeResolved, configuration, httpClient);
}
}
}
@@ -0,0 +1,219 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.transform.geocoding.internal.provider.nominatim;
import static org.openhab.transform.geocoding.internal.GeoProfileConstants.*;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.HttpClient;
import org.eclipse.jetty.client.api.ContentResponse;
import org.eclipse.jetty.http.HttpHeader;
import org.eclipse.jetty.http.HttpStatus;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.openhab.core.library.types.PointType;
import org.openhab.core.types.State;
import org.openhab.transform.geocoding.internal.config.GeoProfileConfig;
import org.openhab.transform.geocoding.internal.provider.BaseGeoResolver;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link OSMGeoResolver} is the reverse geocding resolver for Nomination / OpenStreetMap API. Given
* geo coordinates will be resolved into a human readable address string.
*
* @author Bernd Weymann - Initial contribution
*/
@NonNullByDefault
public class OSMGeoResolver extends BaseGeoResolver {
private final Logger logger = LoggerFactory.getLogger(OSMGeoResolver.class);
public OSMGeoResolver(State state, GeoProfileConfig config, HttpClient httpClient) {
super(state, config, httpClient);
}
/**
* Execute resolving. If any error occurs (API failure, exception, ...) corresponding trace is logged the
* resolvedString will remain null and isResolved() will return false.
*/
@Override
public void resolve() {
if (isResolved()) {
logger.trace("State {} is already resolved {}", toBeResolved.toFullString(), getResolved());
return;
}
PointType localGeoLocation = geoLocation;
String localGeoSearchString = geoSearchString;
if (localGeoLocation != null) {
String jsonResponse = apiCall(String.format(Locale.US, REVERSE_URL,
localGeoLocation.getLatitude().doubleValue(), localGeoLocation.getLongitude().doubleValue()));
resolvedString = formatAddress(jsonResponse);
} else if (localGeoSearchString != null) {
try {
String encodedSearch = URLEncoder.encode(localGeoSearchString, StandardCharsets.UTF_8.toString());
String jsonResponse = apiCall(String.format(SEARCH_URL, encodedSearch));
resolvedString = getGeoCoordinates(jsonResponse);
} catch (UnsupportedEncodingException e) {
logger.warn("Exception during decoding of address {}: {}", localGeoSearchString, e.getMessage());
}
}
}
@Override
public @Nullable String geoSearch(String address) {
try {
String encodedSearch = URLEncoder.encode(address, StandardCharsets.UTF_8.toString());
String jsonResponse = apiCall(String.format(SEARCH_URL, encodedSearch));
return getGeoCoordinates(jsonResponse);
} catch (UnsupportedEncodingException e) {
logger.warn("Exception during encoding of address {}: {}", address, e.getMessage());
}
return null;
}
@Override
public @Nullable String geoReverseSearch(PointType coordinates) {
String jsonResponse = apiCall(String.format(Locale.US, REVERSE_URL, coordinates.getLatitude().doubleValue(),
coordinates.getLongitude().doubleValue()));
return formatAddress(jsonResponse);
}
/**
* Execute the API call to the geocoding provider and return the JSON response as String.
*
* @param url for querying the geocoding provider
*/
private String apiCall(String url) {
try {
ContentResponse response = httpClient.newRequest(url).header(HttpHeader.ACCEPT_LANGUAGE, config.language)
.header(HttpHeader.USER_AGENT, userAgentSupplier.get())
.timeout(HTTP_TIMEOUT_SECONDS, TimeUnit.SECONDS).send();
int statusResponse = response.getStatus();
String jsonResponse = response.getContentAsString();
if (statusResponse == HttpStatus.OK_200) {
return jsonResponse;
} else {
logger.debug("Resolving of {} failed with status {} and response: {}", toBeResolved.toFullString(),
statusResponse, jsonResponse);
}
} catch (TimeoutException | ExecutionException e) {
logger.debug("Resolving of {} failed with exception {}", toBeResolved.toFullString(), e.getMessage());
} catch (InterruptedException ie) {
logger.debug("Resolving of {} interrupted {}", toBeResolved.toFullString(), ie.getMessage());
Thread.currentThread().interrupt();
}
return "";
}
@Override
public String toString() {
return toBeResolved.toFullString();
}
public @Nullable String formatAddress(String jsonResponse) {
try {
JSONObject jsonObject = new JSONObject(jsonResponse);
String resolvedAddress;
switch (config.format) {
case ROW_ADDRESS_FORMAT:
resolvedAddress = decodeAddress(jsonObject, ROAD_KEYS, HOUSE_NUMBER_KEYS, ZIP_CODE_KEYS, CITY_KEYS,
DISTRICT_KEYS);
return resolvedAddress.isBlank() ? decodeJson(jsonObject) : resolvedAddress;
case US_ADDRESS_FORMAT:
resolvedAddress = decodeAddress(jsonObject, HOUSE_NUMBER_KEYS, ROAD_KEYS, CITY_KEYS, DISTRICT_KEYS,
ZIP_CODE_KEYS);
return resolvedAddress.isBlank() ? decodeJson(jsonObject) : resolvedAddress;
case JSON_FORMAT:
return decodeJson(jsonObject);
case RAW_FORMAT:
return jsonObject.toString();
default:
return decodeJson(jsonObject);
}
} catch (JSONException e) {
logger.debug("Could not parse JSON response: {}", e.getMessage());
return null;
}
}
@SafeVarargs
private final String decodeAddress(JSONObject jsonObject, List<String> streetPart1, List<String> streetPart2,
List<String>... cityKeys) {
if (jsonObject.has(ADDRESS_KEY)) {
JSONObject address = jsonObject.getJSONObject(ADDRESS_KEY);
String street = (get(address, streetPart1) + " " + get(address, streetPart2)).strip();
StringBuilder fullAddress = new StringBuilder(street.isBlank() ? "" : street + ", ");
for (List<String> keys : cityKeys) {
fullAddress.append(get(address, keys)).append(" ");
}
return fullAddress.toString().strip();
}
return "";
}
private String get(JSONObject jsonObject, List<String> keys) {
for (String key : keys) {
if (jsonObject.has(key)) {
return jsonObject.getString(key);
}
}
return "";
}
/**
* Decode JSON object. If address is available return only the address part. Else return full JSON string.
*
* @param jsonObject to be decoded
* @return JSON formatted string
*/
private String decodeJson(JSONObject jsonObject) {
if (jsonObject.has(ADDRESS_KEY)) {
return jsonObject.getJSONObject(ADDRESS_KEY).toString();
}
return jsonObject.toString();
}
/**
* Get geo coordinates from JSON response
*
* @param jsonResponse from the geocoding API
* @return String with "lat,lon" or null if not found
*/
public @Nullable String getGeoCoordinates(String jsonResponse) {
try {
JSONArray searchResults = new JSONArray(jsonResponse);
logger.debug("Geo search found {} results", searchResults.length());
if (searchResults.length() > 0) {
JSONObject firstResult = searchResults.getJSONObject(0);
if (firstResult.has(LATITUDE_KEY) && firstResult.has(LONGITUDE_KEY)) {
return firstResult.getString(LATITUDE_KEY) + "," + firstResult.getString(LONGITUDE_KEY);
}
}
} catch (JSONException e) {
logger.debug("Could not parse JSON response: {}", e.getMessage());
}
return null;
}
}
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<addon:addon id="geocoding" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:addon="https://openhab.org/schemas/addon/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/addon/v1.0.0 https://openhab.org/schemas/addon-1.0.0.xsd">
<type>transformation</type>
<name>Geocoding</name>
<description>Converting geo coordinates into human readable addresses and vice versa.</description>
<connection>cloud</connection>
</addon:addon>
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<config-description:config-descriptions
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:config-description="https://openhab.org/schemas/config-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/config-description/v1.0.0 https://openhab.org/schemas/config-description-1.0.0.xsd">
<config-description uri="profile:transform:geocoding">
<parameter name="provider" type="text" required="false">
<label>Geocoding Provider</label>
<description>Provider which is used to execute geocoding requests</description>
<default>nominatim-osm</default>
<options>
<option value="nominatim-osm">Nominatim / OpenStreetMap</option>
</options>
</parameter>
<parameter name="resolveInterval" type="text" required="false">
<label>Resolve Interval</label>
<description>Interval of reverse geocoding executions. Minimum: 1 minute</description>
<default>5m</default>
</parameter>
<parameter name="format" type="text" required="false">
<label>Address Format</label>
<description>Country specific address formatting</description>
<limitToOptions>true</limitToOptions>
<default>row_address</default>
<options>
<option value="row_address">Rest of World Address Format</option>
<option value="us_address">US/UK Address Format</option>
<option value="json">JSON encoded</option>
</options>
</parameter>
<parameter name="language" type="text" required="false">
<label>Language (override)</label>
<description>Preferred language of the result. Only necessary if openHAB locale settings shall be overwritten</description>
<advanced>true</advanced>
</parameter>
</config-description>
</config-description:config-descriptions>
@@ -0,0 +1,17 @@
# add-on
addon.geocoding.name = Geocoding
addon.geocoding.description = Converting geo coordinates into human readable addresses and vice versa.
profile.config.transform.geocoding.format.label = Address Format
profile.config.transform.geocoding.format.description = Country specific address formatting
profile.config.transform.geocoding.format.option.row_address = Rest of World Address Format
profile.config.transform.geocoding.format.option.us_address = US/UK Address Format
profile.config.transform.geocoding.format.option.json = JSON encoded
profile.config.transform.geocoding.language.label = Language (override)
profile.config.transform.geocoding.language.description = Preferred language of the result. Only necessary if openHAB locale settings shall be overwritten
profile.config.transform.geocoding.provider.label = Geocoding Provider
profile.config.transform.geocoding.provider.description = Provider which is used to execute geocoding requests
profile.config.transform.geocoding.provider.option.nominatim-osm = Nominatim / OpenStreetMap
profile.config.transform.geocoding.resolveInterval.label = Resolve Interval
profile.config.transform.geocoding.resolveInterval.description = Interval of reverse geocoding executions. Minimum: 1 minute
@@ -0,0 +1,146 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.transform.geocoding.internal.service.nominatim;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import static org.openhab.transform.geocoding.internal.GeoProfileConstants.*;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Locale;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.stream.Stream;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.HttpClient;
import org.eclipse.jetty.client.api.ContentResponse;
import org.eclipse.jetty.client.api.Request;
import org.eclipse.jetty.http.HttpHeader;
import org.json.JSONObject;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.openhab.core.config.core.Configuration;
import org.openhab.core.i18n.LocaleProvider;
import org.openhab.core.library.types.PointType;
import org.openhab.core.library.types.StringType;
import org.openhab.core.thing.profiles.ProfileCallback;
import org.openhab.core.thing.profiles.ProfileContext;
import org.openhab.core.types.State;
import org.openhab.core.types.UnDefType;
import org.openhab.transform.geocoding.internal.profiles.GeoProfile;
import org.openhab.transform.geocoding.internal.provider.BaseGeoResolver;
/**
* The {@link OSMProviderTest} tests GeoResolverFactory and GeoResolver basic classes. Tests are executed with different
* configurations, responses and expected results.
*
* @author Bernd Weymann - Initial contribution
*/
@NonNullByDefault
class OSMProviderTest {
public static Stream<Arguments> testResponses() {
return Stream.of( //
Arguments.of(null, null, 200, "", PointType.valueOf("0,1"), false, ""), //
Arguments.of("nominatim-osm", null, 200, "", PointType.valueOf("0,1"), false, ""), //
Arguments.of("nominatim-osm", null, 200, "", UnDefType.UNDEF, false, ""), //
Arguments.of("nominatim-osm", null, 400, readFile("src/test/resources/geo-reverse-result.json"),
PointType.valueOf("0,1"), false, ""), //
Arguments.of("nominatim-osm", null, 200, readFile("src/test/resources/geo-reverse-result.json"),
PointType.valueOf("0,1"), true, "Am Friedrichshain 22, 10407 Berlin Pankow"), //
Arguments.of("nominatim-osm", US_ADDRESS_FORMAT, 200,
readFile("src/test/resources/geo-reverse-nyc.json"), PointType.valueOf("0,1"), true,
"6 West 23rd Street, City of New York Manhattan 10010"), //
Arguments.of("nominatim-osm", JSON_FORMAT, 200, readFile("src/test/resources/geo-reverse-result.json"),
PointType.valueOf("0,1"), true,
(new JSONObject(readFile("src/test/resources/geo-reverse-result.json")))
.getJSONObject(ADDRESS_KEY).toString()), //
Arguments.of("nominatim-osm", null, 200, readFile("src/test/resources/geo-reverse-result-no-road.json"),
PointType.valueOf("0,1"), true, "10407 Berlin Pankow"), //
Arguments.of("nominatim-osm", null, 400, "", new StringType("Not necessary"), false, ""), //
Arguments.of("nominatim-osm", null, 200, "Inavlid response", new StringType("Not necessary"), false,
""), //
Arguments.of("nominatim-osm", null, 200, "[]", new StringType("Not necessary"), false, ""), //
Arguments.of("nominatim-osm", US_ADDRESS_FORMAT, 200,
readFile("src/test/resources/geo-search-result.json"), new StringType("Not necessary"), true,
"52.5252949,13.3706843") //
);
}
@ParameterizedTest
@MethodSource
public void testResponses(@Nullable String provider, @Nullable String format, int mockStatus, String mockResponse,
State toBeResolved, boolean expectedResolved, String expectedResult) {
Configuration config = new Configuration();
if (provider != null) {
config.put("provider", provider);
}
if (format != null) {
config.put("format", format);
}
GeoProfile profile = getGeoProfile(config, mockStatus, mockResponse);
BaseGeoResolver resolver = profile.createResolver(toBeResolved);
assertEquals("nominatim-osm", resolver.getProvider());
resolver.setUserAgentSupplier(this::getUserAgent);
resolver.resolve();
assertEquals(expectedResolved, resolver.isResolved());
assertEquals(expectedResult, resolver.getResolved());
}
GeoProfile getGeoProfile(Configuration config, int responseStatus, String response) {
LocaleProvider localeProvider = mock(LocaleProvider.class);
when(localeProvider.getLocale()).thenReturn(Locale.ENGLISH);
ProfileContext context = mock(ProfileContext.class);
when(context.getConfiguration()).thenReturn(config);
HttpClient httpClient = mock(HttpClient.class);
Request request = mock(Request.class);
ContentResponse contentResponse = mock(ContentResponse.class);
when(httpClient.newRequest(anyString())).thenReturn(request);
when(request.header(any(HttpHeader.class), anyString())).thenReturn(request);
when(request.timeout(anyLong(), any(TimeUnit.class))).thenReturn(request);
try {
when(request.send()).thenReturn(contentResponse);
} catch (InterruptedException | TimeoutException | ExecutionException e) {
fail(e.getMessage());
}
when(contentResponse.getStatus()).thenReturn(responseStatus);
when(contentResponse.getContentAsString()).thenReturn(response);
GeoProfile profile = new GeoProfile(mock(ProfileCallback.class), context, httpClient, localeProvider);
return profile;
}
static String readFile(String fileName) {
try {
return Files.readString(Paths.get(fileName));
} catch (IOException e) {
fail(e.getMessage());
}
return "";
}
public String getUserAgent() {
return "openHAB/unitTest";
}
}
@@ -0,0 +1,70 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.transform.geocoding.internal.service.nominatim;
import static org.junit.jupiter.api.Assertions.fail;
import static org.openhab.transform.geocoding.internal.GeoProfileConstants.ROW_ADDRESS_FORMAT;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jetty.client.HttpClient;
import org.eclipse.jetty.util.ssl.SslContextFactory;
import org.openhab.core.library.types.PointType;
import org.openhab.core.library.types.StringType;
import org.openhab.transform.geocoding.internal.config.GeoProfileConfig;
import org.openhab.transform.geocoding.internal.provider.BaseGeoResolver;
import org.openhab.transform.geocoding.internal.provider.nominatim.OSMGeoResolver;
/**
* The {@link OSMRealTest} executes real API calls to Nominatim/OpenStreetMap therefore not marked with Test annotation.
*
* @author Bernd Weymann - Initial contribution
*/
@NonNullByDefault
class OSMRealTest {
void testReverseGeocoding() {
HttpClient httpClient = getHttpClient();
String coordinates = "40.74162115629083, -73.99000345325618";
GeoProfileConfig osmConfig = new GeoProfileConfig();
osmConfig.language = "de-DE";
osmConfig.format = ROW_ADDRESS_FORMAT;
BaseGeoResolver toObserve = new OSMGeoResolver(PointType.valueOf(coordinates), osmConfig, httpClient);
toObserve.setUserAgentSupplier(this::getUserAgent);
toObserve.resolve();
}
void testGeocoding() {
HttpClient httpClient = getHttpClient();
String search = "bimbambum";
GeoProfileConfig osmConfig = new GeoProfileConfig();
osmConfig.language = "de-DE";
osmConfig.format = ROW_ADDRESS_FORMAT;
OSMGeoResolver toObserve = new OSMGeoResolver(new StringType(search), osmConfig, httpClient);
toObserve.setUserAgentSupplier(this::getUserAgent);
toObserve.resolve();
}
private HttpClient getHttpClient() {
HttpClient httpClient = new HttpClient(new SslContextFactory.Client());
try {
httpClient.start();
} catch (Exception e) {
fail(e.getMessage());
}
return httpClient;
}
public String getUserAgent() {
return "openHAB/unitTest";
}
}
@@ -0,0 +1,34 @@
{
"osm_id": 2703044030,
"place_rank": 30,
"licence": "Data © OpenStreetMap contributors, ODbL 1.0. http://osm.org/copyright",
"boundingbox": [
"40.7415108",
"40.7416108",
"-73.9900812",
"-73.9899812"
],
"address": {
"country": "United States",
"country_code": "us",
"road": "West 23rd Street",
"city": "City of New York",
"neighbourhood": "Flatiron District",
"ISO3166-2-lvl4": "US-NY",
"county": "New York County",
"postcode": "10010",
"suburb": "Manhattan",
"house_number": "6",
"state": "New York"
},
"importance": 0.00009175936522464359,
"lon": "-73.9900312",
"type": "house",
"display_name": "6, West 23rd Street, Flatiron District, Manhattan Community Board 5, Manhattan, New York County, City of New York, New York, 10010, United States",
"osm_type": "node",
"name": "",
"addresstype": "place",
"category": "place",
"place_id": 330964168,
"lat": "40.7415608"
}
@@ -0,0 +1,32 @@
{
"place_id": 133893663,
"licence": "Data © OpenStreetMap contributors, ODbL 1.0. http://osm.org/copyright",
"osm_type": "way",
"osm_id": 51960750,
"lat": "52.5295514",
"lon": "13.4284712",
"category": "building",
"type": "commercial",
"place_rank": 30,
"importance": 0.18640586217879376,
"addresstype": "building",
"name": "Huss Medienhaus",
"display_name": "Huss Medienhaus, 22, Am Friedrichshain, Winsviertel, Prenzlauer Berg, Pankow, Berlin, 10407, Germany",
"address": {
"building": "Huss Medienhaus",
"quarter": "Winsviertel",
"suburb": "Prenzlauer Berg",
"borough": "Pankow",
"city": "Berlin",
"ISO3166-2-lvl4": "DE-BE",
"postcode": "10407",
"country": "Germany",
"country_code": "de"
},
"boundingbox": [
"52.5291601",
"52.5299426",
"13.4279307",
"13.4289798"
]
}
@@ -0,0 +1,34 @@
{
"place_id": 133893663,
"licence": "Data © OpenStreetMap contributors, ODbL 1.0. http://osm.org/copyright",
"osm_type": "way",
"osm_id": 51960750,
"lat": "52.5295514",
"lon": "13.4284712",
"category": "building",
"type": "commercial",
"place_rank": 30,
"importance": 0.18640586217879376,
"addresstype": "building",
"name": "Huss Medienhaus",
"display_name": "Huss Medienhaus, 22, Am Friedrichshain, Winsviertel, Prenzlauer Berg, Pankow, Berlin, 10407, Germany",
"address": {
"building": "Huss Medienhaus",
"house_number": "22",
"road": "Am Friedrichshain",
"quarter": "Winsviertel",
"suburb": "Prenzlauer Berg",
"borough": "Pankow",
"city": "Berlin",
"ISO3166-2-lvl4": "DE-BE",
"postcode": "10407",
"country": "Germany",
"country_code": "de"
},
"boundingbox": [
"52.5291601",
"52.5299426",
"13.4279307",
"13.4289798"
]
}
@@ -0,0 +1,107 @@
[
{
"place_id": 134029169,
"licence": "Data © OpenStreetMap contributors, ODbL 1.0. http://osm.org/copyright",
"osm_type": "node",
"osm_id": 2459919676,
"lat": "52.5252949",
"lon": "13.3706843",
"class": "railway",
"type": "proposed",
"place_rank": 30,
"importance": 0.5309436952669682,
"addresstype": "railway",
"name": "Hauptbahnhof (Tunnel)",
"display_name": "Hauptbahnhof (Tunnel), Friedrich-List-Ufer, Europacity, Moabit, Mitte, Berlin, 10557, Germany",
"boundingbox": [
"52.5252449",
"52.5253449",
"13.3706343",
"13.3707343"
]
},
{
"place_id": 134029231,
"licence": "Data © OpenStreetMap contributors, ODbL 1.0. http://osm.org/copyright",
"osm_type": "node",
"osm_id": 3856100106,
"lat": "52.5251951",
"lon": "13.3693321",
"class": "railway",
"type": "halt",
"place_rank": 30,
"importance": 0.5309436952669682,
"addresstype": "railway",
"name": "Hauptbahnhof",
"display_name": "Hauptbahnhof, 1, Europaplatz, Europacity, Moabit, Mitte, Berlin, 10557, Germany",
"boundingbox": [
"52.5251451",
"52.5252451",
"13.3692821",
"13.3693821"
]
},
{
"place_id": 133999171,
"licence": "Data © OpenStreetMap contributors, ODbL 1.0. http://osm.org/copyright",
"osm_type": "relation",
"osm_id": 3600565,
"lat": "52.5250175",
"lon": "13.3694480",
"class": "building",
"type": "train_station",
"place_rank": 30,
"importance": 0.5309436952669682,
"addresstype": "building",
"name": "Berlin Hauptbahnhof",
"display_name": "Berlin Hauptbahnhof, Washingtonplatz, Europacity, Moabit, Mitte, Berlin, 10557, Germany",
"boundingbox": [
"52.5242887",
"52.5257400",
"13.3682756",
"13.3706245"
]
},
{
"place_id": 134076080,
"licence": "Data © OpenStreetMap contributors, ODbL 1.0. http://osm.org/copyright",
"osm_type": "node",
"osm_id": 2459919675,
"lat": "52.5246361",
"lon": "13.3698610",
"class": "railway",
"type": "station",
"place_rank": 30,
"importance": 0.5309436952669682,
"addresstype": "railway",
"name": "Berlin Hauptbahnhof (tief)",
"display_name": "Berlin Hauptbahnhof (tief), 1, Europaplatz, Europacity, Moabit, Mitte, Berlin, 10557, Germany",
"boundingbox": [
"52.5196361",
"52.5296361",
"13.3648610",
"13.3748610"
]
},
{
"place_id": 132382003,
"licence": "Data © OpenStreetMap contributors, ODbL 1.0. http://osm.org/copyright",
"osm_type": "node",
"osm_id": 3856100103,
"lat": "52.5249451",
"lon": "13.3696614",
"class": "railway",
"type": "station",
"place_rank": 30,
"importance": 0.5309436952669682,
"addresstype": "railway",
"name": "Berlin Central Station",
"display_name": "Berlin Central Station, 1, Europaplatz, Europacity, Moabit, Mitte, Berlin, 10557, Germany",
"boundingbox": [
"52.5199451",
"52.5299451",
"13.3646614",
"13.3746614"
]
}
]
+1
View File
@@ -36,6 +36,7 @@
<module>org.openhab.transform.basicprofiles</module>
<module>org.openhab.transform.bin2json</module>
<module>org.openhab.transform.exec</module>
<module>org.openhab.transform.geocoding</module>
<module>org.openhab.transform.jinja</module>
<module>org.openhab.transform.jsonpath</module>
<module>org.openhab.transform.map</module>