mirror of
https://github.com/danieldemus/openhab-addons
synced 2026-07-29 12:34:21 +02:00
[geocoding] Transformation contribution (#19921)
* first version Signed-off-by: Bernd Weymann <bernd.weymann@gmail.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
+59
@@ -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";
|
||||
}
|
||||
+73
@@ -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);
|
||||
}
|
||||
}
|
||||
+31
@@ -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 = "";
|
||||
}
|
||||
+182
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+156
@@ -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)";
|
||||
}
|
||||
}
|
||||
+64
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+219
@@ -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>
|
||||
+38
@@ -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>
|
||||
+17
@@ -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
|
||||
+146
@@ -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";
|
||||
}
|
||||
}
|
||||
+70
@@ -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"
|
||||
}
|
||||
+32
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user