Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,7 @@
/bundles/org.openhab.binding.ojelectronics/ @EvilPingu
/bundles/org.openhab.binding.omnikinverter/ @hansbogert
/bundles/org.openhab.binding.omnilink/ @ecdye
/bundles/org.openhab.binding.ondilo/ @MikeTheTux
/bundles/org.openhab.binding.onebusaway/ @sdwilsh
/bundles/org.openhab.binding.onewire/ @J-N-K
/bundles/org.openhab.binding.onewiregpio/ @aogorek
Expand Down
5 changes: 5 additions & 0 deletions bom/openhab-addons/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1406,6 +1406,11 @@
<artifactId>org.openhab.binding.omnilink</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.ondilo</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.onebusaway</artifactId>
Expand Down
13 changes: 13 additions & 0 deletions bundles/org.openhab.binding.ondilo/NOTICE
Original file line number Diff line number Diff line change
@@ -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
126 changes: 126 additions & 0 deletions bundles/org.openhab.binding.ondilo/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# Ondilo Binding

This binding integrates Ondilo ICO pool monitoring devices with openHAB, allowing you to monitor and automate your pool environment using openHAB’s rules and UI.

## Supported Things

`account:` Represents your Ondilo Account (authentication using OAuth2 flow)
`ondilo:` Represents an individual Ondilo ICO device

Ondilo ICO Pool as well as Spa devices are supported.
Chlor as well as salt water.

## Discovery

Ondilo ICOs are discovered automatically after the `account` is authorized and online.
Each Ondilo ICO will appear as a new Thing in the inbox.

## Thing Configuration

### `account` Thing Configuration

- **url**: The URL of the openHAB instance. Required for the redirect during OAuth2 authentication flow (e.g. `http://localhost:8080`)
- **refreshInterval**: Polling interval in seconds (default: `900 s`).

### `ondilo` Thing Configuration

- **id**: The Id of an Ondilo ICO device. Set via discovery service (e.g. `12345`)

Ondilo ICO takes measures every hour.
Higher polling will not increase the update interval.
The binding automatically adjusts the polling schedule to match the expected time of the next measure, which is typically 1 hour (plus 1.5 minutes buffer) after the previous measure.

The requests to the Ondilo Customer API are limited to the following per user quotas:

- 5 requests per second
- 30 requests per hour

`account` Thing performs 1 request per cycle - 4 per hour per Ondilo Account with default interval.
`ondilo` Thing performs 2 requests per cycle - 8 per hour per Ondilo ICO with default interval.

## Channels

### `account` Channels

| Channel ID | Type | Advanced | Access | Description |
|---------------------------|-------------------------|----------|--------|--------------------------------------------------------|
| poll-update | Switch | true | R/W | Poll status update from the cloud (get latest measures, not a trigger for new measures) |

### Measures Channels

| Channel ID | Type | Advanced | Access | Description |
|---------------------------|-------------------------|----------|--------|--------------------------------------------------------|
| temperature | Number:Temperature | false | R | Water temperature in the pool |
| ph | Number | false | R | pH value of the pool water |
| orp | Number:ElectricPotential| false | R | Oxidation-reduction potential (ORP) |
| salt | Number:Density | false | R | Salt concentration in the pool (salt pools only) |
| tds | Number:Density | false | R | Total dissolved solids in the pool (chlor pools only ) |
| battery | Number:Dimensionless | false | R | Battery level of the device |
| rssi | Number:Dimensionless | false | R | Signal strength (RSSI) |
| value-time | DateTime | true | R | Timestamp of the set of measures |

### Recommendations Channels

| Channel ID | Type | Advanced | Access | Description |
|---------------------------|-------------------------|----------|--------|--------------------------------------------------------|
| recommendation-id | Number | true | R | Unique ID of the current recommendation |
| recommendation-title | String | false | R | Title of the current recommendation |
| recommendation-message | String | false | R | Message of the current recommendation |
| recommendation-created-at | String | true | R | Creation time of the current recommendation |
| recommendation-updated-at | String | true | R | Last update time of the current recommendation |
| recommendation-status | String | false | R/W | Status of the current recommendation (`waiting`/`ok`)<br/>`sendCommand("ok")` to validate current `waiting` recommendation |
| recommendation-deadline | String | true | R | Deadline of the current recommendation |

## Full Example

### Thing Configuration

```Java
Bridge ondilo:account:ondiloAccount [ url="http://localhost:8080", refreshInterval=900 ] {
Thing ondilo "<id_received_from_discovery>" [ id="<id_received_from_discovery>" ] {
}
```

### Item Configuration

```java
Number:Temperature Ondilo_Temperature "Pool Temperature [%.1f %unit%]" { channel="ondilo:ondilo:ondiloAccount:12345:measure#temperature" }
Number Ondilo_pH "Pool pH [%d]" { channel="ondilo:ondilo:ondiloAccount:12345:measure#ph" }
Number:ElectricPotential Ondilo_ORP "Pool ORP [%.1f %unit%]" { channel="ondilo:ondilo:ondiloAccount:12345:measure#orp" }
Number:Density Ondilo_Salt "Pool Salt [%.0f %unit%]" { channel="ondilo:ondilo:ondiloAccount:12345:measure#salt" }
Number:Dimensionless Ondilo_Battery "Pool Battery [%d %]" { channel="ondilo:ondilo:ondiloAccount:12345:measure#battery" }
Number:Dimensionless Ondilo_RSSI "Pool RSSI [%.0f]" { channel="ondilo:ondilo:ondiloAccount:12345:measure#rssi" }

String Ondilo_RecTitle "Recommendation Title [%s]" { channel="ondilo:ondilo:ondiloAccount:12345:recommendation#title" }
String Ondilo_RecMessage "Recommendation Message [%s]" { channel="ondilo:ondilo:ondiloAccount:12345:recommendation#message" }
String Ondilo_RecStatus "Recommendation Status [%s]" { channel="ondilo:ondilo:ondiloAccount:12345:recommendation#status" }
```

### Sitemap Configuration

```perl
sitemap demo label="Ondilo ICO" {
Frame label="Measures" {
Text item=Ondilo_Temperature
Text item=Ondilo_pH
Text item=Ondilo_ORP
Text item=Ondilo_Battery
Text item=Ondilo_RSSI
}
Frame label="Recommendations" {
Text item=Ondilo_RecTitle
Text item=Ondilo_RecMessage
Text item=Ondilo_RecStatus
}
}
```

## Troubleshooting

- If authorization fails, check the openHAB log for error messages and verify your redirect URI `url`
- For more details, enable TRACE logging for `org.openhab.binding.ondilo`

## Resources

- [Ondilo API Documentation](https://interop.ondilo.com/docs/api/customer/v1)
- [openHAB Community Forum](https://community.openhab.org/t/request-ondilo-binding/98164)
17 changes: 17 additions & 0 deletions bundles/org.openhab.binding.ondilo/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?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.0.0-SNAPSHOT</version>
</parent>

<artifactId>org.openhab.binding.ondilo</artifactId>

<name>openHAB Add-ons :: Bundles :: Ondilo Binding</name>

</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<features name="org.openhab.binding.ondilo-${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-binding-ondilo" description="Ondilo Binding" version="${project.version}">
<feature>openhab-runtime-base</feature>
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.ondilo/${project.version}</bundle>
</feature>
</features>
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/*
* Copyright (c) 2010-2025 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.binding.ondilo.internal;

import java.io.IOException;
import java.io.InputStream;
import java.io.InterruptedIOException;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.time.Instant;
import java.util.Scanner;

import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.auth.client.oauth2.AccessTokenResponse;
import org.openhab.core.auth.client.oauth2.OAuthClientService;
import org.openhab.core.auth.client.oauth2.OAuthException;
import org.openhab.core.auth.client.oauth2.OAuthResponseException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* The {@link OndiloApiClient} for accessing the Ondilo API using OAuth2 authentication.
* handlers.
*
* @author MikeTheTux - Initial contribution
*/
@NonNullByDefault
public class OndiloApiClient {
private final Logger logger = LoggerFactory.getLogger(OndiloApiClient.class);
private @Nullable OAuthClientService oAuthService;
private @Nullable String bearer;
private @Nullable AccessTokenResponse accessTokenResponse;
private static final String ONDILO_API_URL = "https://interop.ondilo.com/api/customer/v1";

public OndiloApiClient(OAuthClientService oAuthService, AccessTokenResponse accessTokenResponse) {
this.oAuthService = oAuthService;
this.accessTokenResponse = accessTokenResponse;
this.bearer = accessTokenResponse.getAccessToken();
logger.trace("OndiloApiClient initialized with OAuth2 service and bearer token");
}

@Nullable
public synchronized String get(String endpoint) {
try {
refreshAccessTokenIfNeeded();
URL url = new URI(ONDILO_API_URL + endpoint).toURL();
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
conn.setRequestProperty("Authorization", "Bearer " + bearer);
conn.setRequestProperty("Accept", "application/json");
conn.setRequestProperty("Accept-Charset", "utf-8");
conn.setRequestProperty("Accept-Encoding", "gzip, deflate");
conn.connect();
int responseCode = conn.getResponseCode();
if (responseCode == 200) {
try (InputStream is = conn.getInputStream(); Scanner scanner = new Scanner(is, "UTF-8")) {
return scanner.useDelimiter("\\A").next();
}
} else {
logger.warn("Ondilo API request failed with code: {}", responseCode);
}
} catch (InterruptedIOException e) {
logger.debug("Ondilo API request interrupted: {}", e.getMessage());
Thread.currentThread().interrupt();
} catch (IOException | URISyntaxException e) {
logger.warn("Ondilo API request error", e);
}
return null;
}

@Nullable
public synchronized String put(String endpoint) {
try {
refreshAccessTokenIfNeeded();
URL url = new URI(ONDILO_API_URL + endpoint).toURL();
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("PUT");
conn.setRequestProperty("Authorization", "Bearer " + bearer);
conn.setRequestProperty("Accept", "application/json");
conn.setRequestProperty("Accept-Charset", "utf-8");
conn.setRequestProperty("Accept-Encoding", "gzip, deflate");
conn.connect();
int responseCode = conn.getResponseCode();
if (responseCode == 200) {
try (InputStream is = conn.getInputStream(); Scanner scanner = new Scanner(is, "UTF-8")) {
return scanner.useDelimiter("\\A").next();
}
} else {
logger.warn("Ondilo API request failed with code: {}", responseCode);
}
} catch (InterruptedIOException e) {
logger.debug("Ondilo API request interrupted: {}", e.getMessage());
Thread.currentThread().interrupt();
} catch (IOException | URISyntaxException e) {
logger.warn("Ondilo API request error", e);
}
return null;
}

private void refreshAccessTokenIfNeeded() {
OAuthClientService oAuthService = this.oAuthService;
AccessTokenResponse accessTokenResponse = this.accessTokenResponse;
if (oAuthService != null && accessTokenResponse != null) {
if (accessTokenResponse.isExpired(Instant.now(), 120)) {
try {
this.accessTokenResponse = oAuthService.refreshToken();
this.bearer = accessTokenResponse.getAccessToken();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Think you accidently assigned the expired token instead of the newly refreshed token. This will introduce a build warning (null), please also fix that warning.

Suggested change
this.bearer = accessTokenResponse.getAccessToken();
this.bearer = this.accessTokenResponse.getAccessToken();

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this.accessTokenResponse and accessTokenResponse are both pointing to the same global object:
AccessTokenResponse accessTokenResponse = this.accessTokenResponse;

accessTokenResponse is null-safe, compared to this.accessTokenResponse:
if (oAuthService != null && accessTokenResponse != null) {

Copy link
Contributor

@lsiepel lsiepel Jul 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the global var is re-assigned, the local var is still referencing the old object.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you are right - the assignment makes the difference

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

Copy link
Contributor

@lsiepel lsiepel Jul 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Almost, the mentioned warning is is introduced as expected. It is a race condition that should be checked.

logger.trace("AccessToken renewed: {}", bearer);
} catch (InterruptedIOException e) {
logger.debug("OAuth token refresh interrupted: {}", e.getMessage());
Thread.currentThread().interrupt();
} catch (OAuthException | OAuthResponseException | IOException e) {
logger.warn("Failed to refresh OAuth token for Ondilo API", e);
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/*
* Copyright (c) 2010-2025 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.binding.ondilo.internal;

import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.thing.ThingTypeUID;

/**
* The {@link OndiloBindingConstants} class defines common constants, which are
* used across the whole binding.
*
* @author MikeTheTux - Initial contribution
*/
@NonNullByDefault
public class OndiloBindingConstants {

private static final String BINDING_ID = "ondilo";

// List of all Thing Type UIDs
public static final ThingTypeUID THING_TYPE_BRIDGE = new ThingTypeUID(BINDING_ID, "account");
public static final ThingTypeUID THING_TYPE_ONDILO = new ThingTypeUID(BINDING_ID, "ondilo");

// Bridge Channel ids
public static final String CHANNEL_POLL_UPDATE = "poll-update";

// Ondilo Thing Properties
public static final String ONDILO_ID = "id";
public static final String ONDILO_NAME = "name";
public static final String ONDILO_TYPE = "type";
public static final String ONDILO_VOLUME = "volume";
public static final String ONDILO_DISINFECTION = "disinfection";
public static final String ONDILO_ADDRESS = "address";
public static final String ONDILO_LOCATION = "location";

// Ondilo Thing Measures Channel ids
public static final String GROUP_MEASURES = "measure#";

public static final String CHANNEL_TEMPERATURE = GROUP_MEASURES + "temperature";
public static final String CHANNEL_PH = GROUP_MEASURES + "ph";
public static final String CHANNEL_ORP = GROUP_MEASURES + "orp";
public static final String CHANNEL_SALT = GROUP_MEASURES + "salt";
public static final String CHANNEL_TDS = GROUP_MEASURES + "tds";
public static final String CHANNEL_BATTERY = GROUP_MEASURES + "battery";
public static final String CHANNEL_RSSI = GROUP_MEASURES + "rssi";
public static final String CHANNEL_VALUE_TIME = GROUP_MEASURES + "value-time";

// Ondilo Thing Recommendations Channel ids
public static final String GROUP_RECOMMENDATIONS = "recommendation#";

public static final String CHANNEL_RECOMMENDATION_ID = GROUP_RECOMMENDATIONS + "id";
public static final String CHANNEL_RECOMMENDATION_TITLE = GROUP_RECOMMENDATIONS + "title";
public static final String CHANNEL_RECOMMENDATION_MESSAGE = GROUP_RECOMMENDATIONS + "message";
public static final String CHANNEL_RECOMMENDATION_CREATED_AT = GROUP_RECOMMENDATIONS + "created-at";
public static final String CHANNEL_RECOMMENDATION_UPDATED_AT = GROUP_RECOMMENDATIONS + "updated-at";
public static final String CHANNEL_RECOMMENDATION_STATUS = GROUP_RECOMMENDATIONS + "status";
public static final String CHANNEL_RECOMMENDATION_DEADLINE = GROUP_RECOMMENDATIONS + "deadline";

// I18N keys for state details
public static final String I18N_URL_INVALID = "@text/thing.ondilo.bridge.config.url.invalid";
public static final String I18N_OAUTH2_PENDING = "@text/thing.ondilo.bridge.config.oauth2.pending";
public static final String I18N_OAUTH2_ERROR = "@text/thing.ondilo.bridge.config.oauth2.error";
public static final String I18N_OAUTH2_INTERRUPTED = "@text/thing.ondilo.bridge.config.oauth2.interrupted";
public static final String I18N_ID_INVALID = "@text/thing.ondilo.ondilo.config.id.invalid";
}
Loading