[flume] Initial contribution (#17152)

* Initial submission

Signed-off-by: Jeff James <jeff@james-online.com>
This commit is contained in:
jsjames 2024-09-08 13:41:27 -07:00 committed by GitHub
parent 6f6787b794
commit 8f62374b28
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 2504 additions and 0 deletions

View File

@ -566,6 +566,11 @@
<artifactId>org.openhab.binding.flicbutton</artifactId> <artifactId>org.openhab.binding.flicbutton</artifactId>
<version>${project.version}</version> <version>${project.version}</version>
</dependency> </dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.flume</artifactId>
<version>${project.version}</version>
</dependency>
<dependency> <dependency>
<groupId>org.openhab.addons.bundles</groupId> <groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.fmiweather</artifactId> <artifactId>org.openhab.binding.fmiweather</artifactId>

View File

@ -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

View File

@ -0,0 +1,120 @@
# Flume Binding
This binding will interface with the cloud API to retrieve water usage from your [Flume](https://flumewater.com/) water monitor.
## Introduction
The Cloud Connector is required as a "bridge" to interface to the cloud service from Flume.
While the Flume API supports a rich querying of historical usage data, this binding only retrieves the cumulative water used and instantaneous water used, thus relying on openHAB's rich persistence services for exploring historical values.
The binding does support querying historical data through the use of the Rule Action.
## Supported Things
This binding supports the following things:
| Thing | id | Type | Description |
|---------- |--------- |-------- |------------------------------ |
| Flume Cloud Connector | cloud | Bridge | This represents the cloud account to interface with the Flume API. |
| Flume Meter Device | meter-device | Thing | This interfaces to a specific Flume water monitor associated with the account. |
This binding should work with multiple Flume monitors associated with the account, however it is currently only tested with a single device.
## Discovery
Once a Flume Cloud Connector is created and established, the binding will automatically discover any Flume Meter Devices' associated with the account.
## Flume Cloud Connector (Bridge) Configuration
The only configuration required is to create a Flume Cloud Connector thing and fill in the appropriate configuration parameters.
The client id and client secret can be found under Settings/API access from the [Flume portal online](https://portal.flumewater.com/settings).
Note, there is a rate limit of 120 queries per hour imposed by Flume so use caution when selecting the Refresh Interfacl.
| Name | id | Type | Description | Default | Required | Advanced |
|------- |------ |--------- |--------- |------- |------ |----- |
| Flume Username | username | text | Username to access Flume cloud | N/A | yes | no |
| Flume Password | password | text | Password to access Flume cloud | N/A | yes | no |
| Flume Client ID | clientId | text | ID retrieved from Flume cloud | N/A | yes | no |
| Flume Client Secret | clientSecret | text | Secret retrieved from Flume cloud | N/A | yes | no |
| Instantaneous Refresh Interval | refreshIntervalInstantaneous | integer | Polling interval (minutes) for instantaneous usage (rate limited to 120 queries/sec) | 1 | no | yes |
| Cumulative Refresh Interval | refreshIntervalCumulative | integer | Polling interval (minutes) for cumulative usage (rate-limited with above) | 5 | no | yes |
## Flume Meter Device Configuration
| Name | id | Type | Description | Default | Required | Advanced |
|------- |--------- |------ |--------- |------- |------ |----- |
| ID | id | text | ID of the Flume device | N/A | yes | no |
## Flume Meter Device Channels
| Channel | id | Type | Read/Write | Description |
|---------- |-------- |-------- |-------- |-------- |
| Instant Water Usage | instant-usage | Number:VolumetricFlowRate | R | Flow rate of water over the last minute |
| Cumulative Used | cumulative-usage | Number:Volume | R | Total volume of water used since the beginning of Flume install |
| Battery Level | battery-level | Number:Dimensionless | R | Estimate of percent of remaining battery level |
| Low Battery | low-battery | Switch | R | Indicator of low battery level |
| Last Seen | last-seen | DateTime | R | Date/Time when meter was last seen on the network |
| Usage Alert | usage-alert | Trigger | n/a | Trigger channel for usage alert notification |
## Full Example
### Thing Configuration
Please note that the device meter ID is only available through the API and not available on the Flume portal.
When the Bridge device is first created, there will be a log message with the ID of the discovered device which can be used in further configuring the device via the text files.
```
Bridge flume:cloud:cloudconnector [ username="xxx", password="xxx", clientId="xxx", clientSecret="xxx" ] {
meter-device meter [ id="xxx" ]
}
```
### Item Configuration
```
Number:VolumetricFlowRate InstantUsage "Instant Usage" { channel = "flume:meter-device:1:meter:instant-usage" }
Number:Volume CumulativeUsed "Cumulative Used" { channel = "flume:meter-device:1:meter:cumulative-usage" }
Number:Dimensionless BatteryLevel "Battery Level" { channel = "flume:meter-device:1:meter:battery-level" }
DateTime LastSeen "Last Seen" { channel = "flume:meter-device:1:meter:last-seen" }
Switch LowPower "Battery Low Power" { channel = "flume:meter-device:1:meter:low-battery" }
```
### Rules
```java
rule "Flume Usage Alert"
when
Channel 'flume:device:cloud:meter:usageAlert' triggered
then
logInfo("Flume Usage Alert", "Message: {}", receivedEvent)
end
```
## Rule Actions
There is an action where you can query the Flume Cloud for water usage as shown in the blow example:
```java
val flumeActions = getActions("flume", "flume:device:cloud:meter")
if(null === flumeActions) {
logInfo("actions", "flumeActions not found, check thing ID")
return
}
val LocalDateTime untilDateTime = LocalDateTime.now
val LocalDateTime sinceDateTime = untilDateTime.minusHours(24)
val usage = flumeActions.queryWaterUsage(sinceDateTime, untilDateTime, "MIN", "SUM")
logInfo("Flume", "Water usage is {}", usage.toString())
```
### queryWaterUsage(sinceDateTime, untilDateTime, bucket, operation)
Queries the cloud for water usage between the two dates.
- sinceDateTime (LocalDateTime): begin date/time of query range
- untilDateTime (LocalDateTime): end date/time of query range
- bucket (String), values: YR, MON, DAY, HR, MIN
- operation (String), values: SUM, AVG, MIN, MAX, CNT

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://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>4.3.0-SNAPSHOT</version>
</parent>
<artifactId>org.openhab.binding.flume</artifactId>
<name>openHAB Add-ons :: Bundles :: Flume Binding</name>
</project>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<features name="org.openhab.binding.flume-${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-flume" description="Flume Binding" version="${project.version}">
<feature>openhab-runtime-base</feature>
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.flume/${project.version}</bundle>
</feature>
</features>

View File

@ -0,0 +1,54 @@
/**
* Copyright (c) 2010-2024 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.flume.internal;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.thing.ThingTypeUID;
/**
* The {@link FlumeBindingConstants} class defines common constants, which are
* used across the whole binding.
*
* @author Jeff James - Initial contribution
*/
@NonNullByDefault
public class FlumeBindingConstants {
private static final String BINDING_ID = "flume";
// List of all Thing Type UIDs
public static final ThingTypeUID THING_TYPE_CLOUD = new ThingTypeUID(BINDING_ID, "cloud");
public static final ThingTypeUID THING_TYPE_METER = new ThingTypeUID(BINDING_ID, "meter-device");
// Config options
public static final String PARAM_USERNAME = "username";
public static final String PARAM_PASSWORD = "password";
public static final String PARAM_CLIENTID = "clientId";
public static final String PARAM_CLIENTSECRET = "clientSecret";
public static final String PARAM_REFRESH_INTERVAL_INSTANTANEOUS = "refreshIntervalInstanteous";
public static final String PARAM_REFRESH_INTERVAL_CUMULATIVE = "refreshIntervalCumulative";
// List of all Device Channel ids
public static final String CHANNEL_DEVICE_CUMULATIVEUSAGE = "cumulative-usage";
public static final String CHANNEL_DEVICE_INSTANTUSAGE = "instant-usage";
public static final String CHANNEL_DEVICE_BATTERYLEVEL = "battery-level";
public static final String CHANNEL_DEVICE_LOWBATTERY = "low-battery";
public static final String CHANNEL_DEVICE_LASTSEEN = "last-seen";
public static final String CHANNEL_DEVICE_USAGEALERT = "usage-alert";
// Properties
public static final String PROPERTY_ID = "id";
public static final int DEFAULT_POLLING_INTERVAL_INSTANTANEOUS = 1;
public static final int DEFAULT_POLLING_INTERVAL_CUMULATIVE = 5;
}

View File

@ -0,0 +1,33 @@
/**
* Copyright (c) 2010-2024 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.flume.internal;
import static org.openhab.binding.flume.internal.FlumeBindingConstants.*;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* The {@link FlumeBridgeConfig} class contains fields mapping thing configuration parameters.
*
* @author Jeff James - Initial contribution
*/
@NonNullByDefault
public class FlumeBridgeConfig {
public String clientId = "";
public String clientSecret = "";
public String username = "";
public String password = "";
public int refreshIntervalInstantaneous = DEFAULT_POLLING_INTERVAL_INSTANTANEOUS;
public int refreshIntervalCumulative = DEFAULT_POLLING_INTERVAL_CUMULATIVE;
}

View File

@ -0,0 +1,25 @@
/**
* Copyright (c) 2010-2024 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.flume.internal;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* The {@link FlumeDeviceConfig} class contains fields mapping thing configuration parameters.
*
* @author Jeff James - Initial contribution
*/
@NonNullByDefault
public class FlumeDeviceConfig {
public String id = "";
}

View File

@ -0,0 +1,82 @@
/**
* Copyright (c) 2010-2024 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.flume.internal;
import static org.openhab.binding.flume.internal.FlumeBindingConstants.*;
import java.util.Set;
import javax.measure.spi.SystemOfUnits;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.flume.internal.handler.FlumeBridgeHandler;
import org.openhab.binding.flume.internal.handler.FlumeDeviceHandler;
import org.openhab.core.i18n.LocaleProvider;
import org.openhab.core.i18n.TranslationProvider;
import org.openhab.core.i18n.UnitProvider;
import org.openhab.core.io.net.http.HttpClientFactory;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingTypeUID;
import org.openhab.core.thing.binding.BaseThingHandlerFactory;
import org.openhab.core.thing.binding.ThingHandler;
import org.openhab.core.thing.binding.ThingHandlerFactory;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
/**
* The {@link FlumeHandlerFactory} is responsible for creating things and thing
* handlers.
*
* @author Jeff James - Initial contribution
*/
@NonNullByDefault
@Component(configurationPid = "binding.flume", service = ThingHandlerFactory.class)
public class FlumeHandlerFactory extends BaseThingHandlerFactory {
private static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_CLOUD, THING_TYPE_METER);
private final HttpClientFactory httpClientFactory;
private final TranslationProvider i18nProvider;
private final LocaleProvider localeProvider;
public final SystemOfUnits systemOfUnits;
@Activate
public FlumeHandlerFactory(@Reference UnitProvider unitProvider, @Reference HttpClientFactory httpClientFactory,
final @Reference TranslationProvider i18nProvider, final @Reference LocaleProvider localeProvider) {
this.systemOfUnits = unitProvider.getMeasurementSystem();
this.httpClientFactory = httpClientFactory;
this.i18nProvider = i18nProvider;
this.localeProvider = localeProvider;
}
@Override
public boolean supportsThingType(ThingTypeUID thingTypeUID) {
return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
}
@Override
protected @Nullable ThingHandler createHandler(Thing thing) {
ThingTypeUID thingTypeUID = thing.getThingTypeUID();
if (THING_TYPE_CLOUD.equals(thingTypeUID)) {
return new FlumeBridgeHandler((Bridge) thing, systemOfUnits, this.httpClientFactory.getCommonHttpClient(),
i18nProvider, localeProvider);
} else if (THING_TYPE_METER.equals(thingTypeUID)) {
return new FlumeDeviceHandler(thing);
}
return null;
}
}

View File

@ -0,0 +1,144 @@
/**
* Copyright (c) 2010-2024 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.flume.internal.actions;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeoutException;
import javax.measure.quantity.Volume;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.flume.internal.api.FlumeApi;
import org.openhab.binding.flume.internal.api.FlumeApiException;
import org.openhab.binding.flume.internal.api.dto.FlumeApiQueryWaterUsage;
import org.openhab.binding.flume.internal.handler.FlumeDeviceHandler;
import org.openhab.core.automation.annotation.ActionInput;
import org.openhab.core.automation.annotation.ActionOutput;
import org.openhab.core.automation.annotation.RuleAction;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.library.unit.ImperialUnits;
import org.openhab.core.library.unit.Units;
import org.openhab.core.thing.binding.ThingActions;
import org.openhab.core.thing.binding.ThingActionsScope;
import org.openhab.core.thing.binding.ThingHandler;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.ServiceScope;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link FlumeDeviceActions} class defines actions for the Flume Device
*
* @author Jeff James - Initial contribution
*/
@Component(scope = ServiceScope.PROTOTYPE, service = FlumeDeviceActions.class)
@ThingActionsScope(name = "flume")
@NonNullByDefault
public class FlumeDeviceActions implements ThingActions {
private final Logger logger = LoggerFactory.getLogger(FlumeDeviceActions.class);
private static final String QUERYID = "action_query";
private @Nullable FlumeDeviceHandler deviceHandler;
@Override
public void setThingHandler(@Nullable ThingHandler handler) {
if (handler instanceof FlumeDeviceHandler deviceHandler) {
this.deviceHandler = deviceHandler;
}
}
@Override
public @Nullable ThingHandler getThingHandler() {
return deviceHandler;
}
/**
* Query water usage
*/
@RuleAction(label = "query water usage", description = "Queries water usage over a period of time.")
public @Nullable @ActionOutput(name = "value", type = "QuantityType<Volume>") QuantityType<Volume> queryWaterUsage(
@ActionInput(name = "sinceDateTime", label = "Since Date/Time", required = true, description = "Restrict the query range to data samples since this datetime.") @Nullable LocalDateTime sinceDateTime,
@ActionInput(name = "untilDateTime", label = "Until Date/Time", required = true, description = "Restrict the query range to data samples until this datetime.") @Nullable LocalDateTime untilDateTime,
@ActionInput(name = "bucket", label = "Bucket size", required = true, description = "The bucket grouping of the data we are querying (MIN, HR, DAY, MON, YR).") @Nullable String bucket,
@ActionInput(name = "operation", label = "Operation", required = true, description = "The aggregate/accumulate operation to perform (SUM, AVG, MIN, MAX, CNT).") @Nullable String operation) {
logger.info("queryWaterUsage called");
FlumeApiQueryWaterUsage query = new FlumeApiQueryWaterUsage();
FlumeDeviceHandler localDeviceHandler = deviceHandler;
if (localDeviceHandler == null) {
logger.debug("querying device usage, but device is undefined.");
return null;
}
boolean imperialUnits = localDeviceHandler.isImperial();
if (operation == null || bucket == null || sinceDateTime == null || untilDateTime == null) {
logger.warn("queryWaterUsage called with null inputs");
return null;
}
if (!FlumeApi.OperationType.contains(operation)) {
logger.warn("Invalid aggregation operation in call to queryWaterUsage");
return null;
} else {
query.operation = FlumeApi.OperationType.valueOf(operation);
}
if (!FlumeApi.BucketType.contains(bucket)) {
logger.warn("Invalid bucket type in call to queryWaterUsage");
return null;
} else {
query.bucket = FlumeApi.BucketType.valueOf(bucket);
}
if (untilDateTime.isBefore(sinceDateTime)) {
logger.warn("sinceDateTime must be earlier than untilDateTime");
return null;
}
query.requestId = QUERYID;
query.sinceDateTime = sinceDateTime;
query.untilDateTime = untilDateTime;
query.bucket = FlumeApi.BucketType.valueOf(bucket);
query.units = imperialUnits ? FlumeApi.UnitType.GALLONS : FlumeApi.UnitType.LITERS;
Float usage;
try {
usage = localDeviceHandler.getApi().queryUsage(localDeviceHandler.getId(), query);
} catch (FlumeApiException | IOException | InterruptedException | TimeoutException | ExecutionException e) {
logger.warn("queryWaterUsage function failed - {}", e.getMessage());
return null;
}
if (usage == null) {
return null;
}
return new QuantityType<Volume>(usage, imperialUnits ? ImperialUnits.GALLON_LIQUID_US : Units.LITRE);
}
// Static method for Rules DSL backward compatibility
public static @Nullable QuantityType<Volume> queryWaterUsage(ThingActions actions,
@Nullable LocalDateTime sinceDateTime, @Nullable LocalDateTime untilDateTime, @Nullable String bucket,
@Nullable String operation) {
if (actions instanceof FlumeDeviceActions localActions) {
return localActions.queryWaterUsage(sinceDateTime, untilDateTime, bucket, operation);
} else {
throw new IllegalArgumentException("Instance is not a FlumeDeviceActions class.");
}
}
}

View File

@ -0,0 +1,439 @@
/**
* Copyright (c) 2010-2024 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.flume.internal.api;
import java.io.IOException;
import java.lang.reflect.Type;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Base64;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import javax.ws.rs.core.MediaType;
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.client.util.StringContentProvider;
import org.eclipse.jetty.http.HttpHeader;
import org.eclipse.jetty.http.HttpMethod;
import org.openhab.binding.flume.internal.api.dto.FlumeApiCurrentFlowRate;
import org.openhab.binding.flume.internal.api.dto.FlumeApiDevice;
import org.openhab.binding.flume.internal.api.dto.FlumeApiGetToken;
import org.openhab.binding.flume.internal.api.dto.FlumeApiQueryBucket;
import org.openhab.binding.flume.internal.api.dto.FlumeApiQueryWaterUsage;
import org.openhab.binding.flume.internal.api.dto.FlumeApiRefreshToken;
import org.openhab.binding.flume.internal.api.dto.FlumeApiToken;
import org.openhab.binding.flume.internal.api.dto.FlumeApiTokenPayload;
import org.openhab.binding.flume.internal.api.dto.FlumeApiUsageAlert;
import org.openhab.binding.flume.utils.JsonInstantSerializer;
import org.openhab.binding.flume.utils.JsonLocalDateTimeSerializer;
import org.openhab.core.thing.ThingUID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.google.gson.reflect.TypeToken;
/**
* The {@link FlumeApi} implements the interface to the Flume cloud service (using http). The documentation for the API
* is located here: https://flumetech.readme.io/reference
*
* @author Jeff James - Initial contribution
*/
@NonNullByDefault
public class FlumeApi {
private final Logger logger = LoggerFactory.getLogger(FlumeApi.class);
// --------------- Flume Cloud API
public static final String APIURL_BASE = "https://api.flumewater.com/";
public static final String APIURL_TOKEN = "oauth/token";
public static final String APIURL_GETUSERSDEVICES = "users/%s/devices?user=%s&location=%s";
public static final String APIURL_GETDEVICEINFO = "users/%s/devices/%s";
public static final String APIURL_QUERYUSAGE = "users/%s/devices/%s/query";
public static final String APIURL_FETCHUSAGEALERTS = "users/%s/usage-alerts?device_id=%s&limit=%d&sort_field=%s&sort_direction=%s";
public static final String APIURL_FETCHNOTIFICATIONS = "users/%s/notifications?device_id=%s&limit=%d&sort_field=%s&sort_direction=%s";
public static final String APIURL_GETCURRENTFLOWRATE = "users/%s/devices/%s/query/active";
private static final int API_TIMEOUT = 15;
// @formatter:off
public enum UnitType {
GALLONS, LITERS, CUBIC_FEET, CUBIC_METERS
}
public enum OperationType {
SUM, AVG, MIN, MAX, CNT;
public static boolean contains(String value) {
return Arrays.stream(values()).anyMatch((t) -> t.name().equals(value));
}
}
public enum BucketType {
YR, MON, DAY, HR, MIN;
public static boolean contains(String value) {
return Arrays.stream(values()).anyMatch((t) -> t.name().equals(value));
}
}
public enum SortDirectionType {
ASC, DESC
}
// @formatter:on
protected String clientId = "";
protected String clientSecret = "";
protected String username = "";
protected String password = "";
protected Gson gson;
private String accessToken = "";
private String refreshToken = "";
private int userId;
private LocalDateTime tokenExpiresAt = LocalDateTime.now();
private HttpClient httpClient;
public FlumeApi(HttpClient httpClient) {
this.httpClient = httpClient;
this.gson = new GsonBuilder()
.registerTypeAdapter(LocalDateTime.class, new JsonLocalDateTimeSerializer("yyyy-MM-dd HH:mm:ss")) // 2022-07-13
// 20:14:00
.registerTypeAdapter(Instant.class, new JsonInstantSerializer()) // 2022-07-14T03:13:00.000Z
.create();
}
public String getClientId() {
return clientId;
}
public void initialize(String clientId, String clientSecret, String username, String password, ThingUID bridgeUID)
throws FlumeApiException, IOException, InterruptedException, TimeoutException, ExecutionException {
this.clientId = clientId;
this.clientSecret = clientSecret;
this.username = username;
this.password = password;
getToken();
}
private void getToken() throws FlumeApiException, IOException, InterruptedException, TimeoutException,
ExecutionException, NullPointerException {
FlumeApiGetToken getToken = new FlumeApiGetToken();
getToken.clientId = clientId;
getToken.clientSecret = clientSecret;
getToken.username = username;
getToken.password = password;
String url = APIURL_BASE + APIURL_TOKEN;
Request request = httpClient.newRequest(url).method(HttpMethod.POST)
.content(new StringContentProvider(gson.toJson(getToken)), MediaType.APPLICATION_JSON);
JsonObject jsonResponse = sendAndValidate(request, false);
final FlumeApiToken[] data = gson.fromJson(jsonResponse.get("data").getAsJsonArray(), FlumeApiToken[].class);
if (data == null) {
throw new FlumeApiException("@text/api.response-invalid", jsonResponse.get("code").getAsInt(), true);
}
processToken(data[0]);
}
private void refreshToken()
throws IOException, InterruptedException, TimeoutException, ExecutionException, FlumeApiException {
FlumeApiRefreshToken token = new FlumeApiRefreshToken();
token.clientId = clientId;
token.clientSecret = clientSecret;
token.refeshToken = refreshToken;
String url = APIURL_BASE + APIURL_TOKEN;
Request request = httpClient.newRequest(url).method(HttpMethod.POST)
.content(new StringContentProvider(gson.toJson(token)), MediaType.APPLICATION_JSON);
JsonObject jsonResponse = sendAndValidate(request, false);
final FlumeApiToken[] data = gson.fromJson(jsonResponse.get("data").getAsJsonArray(), FlumeApiToken[].class);
if (data == null || data.length < 1) {
throw new FlumeApiException("@text/api.response-invalid", jsonResponse.get("code").getAsInt(), true);
}
processToken(data[0]);
}
private void processToken(FlumeApiToken token) throws FlumeApiException {
accessToken = token.accessToken;
// access_token contains 3 parts: header, payload, signature - decode the payload portion
String accessTokenPayload[] = accessToken.split("\\.");
byte decoded[] = Base64.getDecoder().decode(accessTokenPayload[1]);
String jsonPayload = new String(decoded);
final FlumeApiTokenPayload payload = gson.fromJson(jsonPayload, FlumeApiTokenPayload.class);
if (payload == null) {
throw new FlumeApiException("@text/api.response-invalid", 0, true);
}
userId = payload.userId;
refreshToken = token.refreshToken;
tokenExpiresAt = LocalDateTime.now().plusSeconds(token.expiresIn * 2 / 3);
logger.debug("Token expires at: {}", tokenExpiresAt);
}
public void verifyToken()
throws FlumeApiException, IOException, InterruptedException, TimeoutException, ExecutionException {
if (LocalDateTime.now().isAfter(tokenExpiresAt)) {
refreshToken();
}
}
public List<FlumeApiDevice> getDeviceList()
throws FlumeApiException, IOException, InterruptedException, TimeoutException, ExecutionException {
String url = APIURL_BASE + String.format(APIURL_GETUSERSDEVICES, this.userId, false, false);
Request request = httpClient.newRequest(url).method(HttpMethod.GET);
JsonObject jsonResponse = sendAndValidate(request);
final FlumeApiDevice[] listDevices = gson.fromJson(jsonResponse.get("data").getAsJsonArray(),
FlumeApiDevice[].class);
return Arrays.asList(listDevices);
}
/**
* gets Flume device info
*
* @param deviceId for the device
* @return FlumeApiDevice dto structure
*
* @throws FlumeApiException
* @throws IOException
* @throws InterruptedException
* @throws TimeoutException
* @throws ExecutionException
*/
public @Nullable FlumeApiDevice getDeviceInfo(String deviceId)
throws FlumeApiException, IOException, InterruptedException, TimeoutException, ExecutionException {
String url = APIURL_BASE + String.format(APIURL_GETDEVICEINFO, this.userId, deviceId);
Request request = httpClient.newRequest(url).method(HttpMethod.GET);
JsonObject jsonResponse = sendAndValidate(request);
final FlumeApiDevice[] apiDevices = gson.fromJson(jsonResponse.get("data").getAsJsonArray(),
FlumeApiDevice[].class);
return (apiDevices == null || apiDevices.length == 0) ? null : apiDevices[0];
}
/**
* makes a single query to the API.
*
* @param deviceID for the device
* @param query FlumeApiQueryWaterUsage class with query parameters
* @return the result of the single query
*
* @throws FlumeApiException
* @throws IOException
* @throws InterruptedException
* @throws TimeoutException
* @throws ExecutionException
*/
public @Nullable Float queryUsage(String deviceID, FlumeApiQueryWaterUsage query)
throws FlumeApiException, IOException, InterruptedException, TimeoutException, ExecutionException {
List<FlumeApiQueryWaterUsage> listQuery = new ArrayList<FlumeApiQueryWaterUsage>();
List<HashMap<String, List<FlumeApiQueryBucket>>> queryData;
listQuery.add(query);
queryData = queryUsage(deviceID, listQuery);
if (queryData == null) {
return null;
}
Map<String, List<FlumeApiQueryBucket>> queryBuckets = queryData.get(0);
List<FlumeApiQueryBucket> queryBucket = queryBuckets.get(query.requestId);
return (queryBucket == null || queryBucket.isEmpty()) ? null : queryBucket.get(0).value;
}
/**
* makes multiple queries to the API combined into a single Rest API request.
*
* @param deviceID for the device
* @param listQuery a List of FlumeApiQueryWaterUsage query parameters
* @return a list of HashMap
*
* @throws FlumeApiException
* @throws IOException
* @throws InterruptedException
* @throws TimeoutException
* @throws ExecutionException
*/
public @Nullable List<HashMap<String, List<FlumeApiQueryBucket>>> queryUsage(String deviceID,
List<FlumeApiQueryWaterUsage> listQuery)
throws FlumeApiException, IOException, InterruptedException, TimeoutException, ExecutionException {
if (listQuery.isEmpty()) {
return null;
}
String jsonQuery = "{\"queries\":" + gson.toJson(listQuery) + "}";
String url = APIURL_BASE + String.format(APIURL_QUERYUSAGE, this.userId, deviceID);
Request request = httpClient.newRequest(url).method(HttpMethod.POST)
.content(new StringContentProvider(jsonQuery), MediaType.APPLICATION_JSON);
logger.debug("METADATA: {}", jsonQuery);
JsonObject jsonResponse = sendAndValidate(request);
final Type queryResultType = new TypeToken<List<HashMap<String, List<FlumeApiQueryBucket>>>>() {
}.getType();
List<HashMap<String, List<FlumeApiQueryBucket>>> listQueryResult = gson.fromJson(jsonResponse.get("data"),
queryResultType);
return listQueryResult;
}
public @Nullable FlumeApiCurrentFlowRate getCurrentFlowRate(String deviceId)
throws FlumeApiException, IOException, InterruptedException, TimeoutException, ExecutionException {
String url = APIURL_BASE + String.format(APIURL_GETCURRENTFLOWRATE, this.userId, deviceId);
Request request = httpClient.newRequest(url).method(HttpMethod.GET);
JsonObject jsonResponse = sendAndValidate(request);
final FlumeApiCurrentFlowRate[] currentFlowRates = gson.fromJson(jsonResponse.get("data").getAsJsonArray(),
FlumeApiCurrentFlowRate[].class);
return (currentFlowRates == null || currentFlowRates.length < 1) ? null : currentFlowRates[0];
}
public List<FlumeApiUsageAlert> fetchUsageAlerts(String deviceId, int limit)
throws FlumeApiException, IOException, InterruptedException, TimeoutException, ExecutionException {
String url = APIURL_BASE
+ String.format(APIURL_FETCHUSAGEALERTS, userId, deviceId, limit, "triggered_datetime", "DESC");
Request request = httpClient.newRequest(url).method(HttpMethod.GET);
JsonObject jsonResponse = sendAndValidate(request);
final FlumeApiUsageAlert[] listUsageAlerts = gson.fromJson(jsonResponse.get("data").getAsJsonArray(),
FlumeApiUsageAlert[].class);
return Arrays.asList(listUsageAlerts);
}
public List<FlumeApiUsageAlert> fetchNotificatinos(String deviceId, int limit)
throws FlumeApiException, IOException, InterruptedException, TimeoutException, ExecutionException {
String url = APIURL_BASE
+ String.format(APIURL_FETCHNOTIFICATIONS, userId, deviceId, limit, "triggered_datetime", "DEC");
Request request = httpClient.newRequest(url).method(HttpMethod.GET);
JsonObject jsonResponse = sendAndValidate(request);
final FlumeApiUsageAlert[] listUsageAlerts = gson.fromJson(jsonResponse.get("data").getAsJsonArray(),
FlumeApiUsageAlert[].class);
return Arrays.asList(listUsageAlerts);
}
private JsonObject sendAndValidate(Request request)
throws FlumeApiException, InterruptedException, TimeoutException, ExecutionException, IOException {
return sendAndValidate(request, true);
}
/**
* does routine setup, validation and conversion to JsonObject for http requests
*
* @param request to be sent
* @param verifyToken whether the exisitng access token should be validate and refreshed if needed
* @return JsonObject from the Rest API call
*
* @throws FlumeApiException
* @throws InterruptedException
* @throws TimeoutException
* @throws ExecutionException
* @throws IOException
*/
private JsonObject sendAndValidate(Request request, boolean verifyToken)
throws FlumeApiException, InterruptedException, TimeoutException, ExecutionException, IOException {
ContentResponse response;
if (verifyToken) {
verifyToken();
}
setHeaders(request);
logger.debug("REQUEST: {}", request.toString());
response = request.send();
logger.trace("RESPONSE: {}", response.getContentAsString());
switch (response.getStatus()) {
case 200:
break;
case 400:
// Flume API sense response code 400 (vs. normal 401) on invalid user credentials
throw new FlumeApiException("@text/api.invalid-user-credentials [\"" + response.getReason() + "\"]",
response.getStatus(), true);
case 401:
throw new FlumeApiException("@text/api.invalid-user-credentials [\"" + response.getReason() + "\"]",
response.getStatus(), true);
case 429:
logger.trace("rate limit response: {}", response.getContentAsString());
throw new FlumeApiException("@text/api.rate-limit-exceeded", 429, false);
default:
throw new FlumeApiException("", response.getStatus(), false);
}
JsonObject jsonResponse = JsonParser.parseString(response.getContentAsString()).getAsJsonObject();
boolean success = jsonResponse.get("success").getAsBoolean();
if (!success) {
String message = jsonResponse.get("message").getAsString();
throw new FlumeApiException("@text/api.query-fail [\"" + message + "\"]",
jsonResponse.get("code").getAsInt(), false);
}
return jsonResponse;
}
private Request setHeaders(Request request) {
if (!accessToken.isEmpty()) {
request.header(HttpHeader.AUTHORIZATION, "Bearer " + accessToken);
}
request.timeout(API_TIMEOUT, TimeUnit.SECONDS);
return request;
}
}

View File

@ -0,0 +1,47 @@
/**
* Copyright (c) 2010-2024 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.flume.internal.api;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
/**
* {@link FlumeApiException} exception class for any api exception
*
* @author Jeff James - Initial contribution
*/
@NonNullByDefault
public class FlumeApiException extends Exception {
private static final long serialVersionUID = -7050804598914012847L;
private int code;
private boolean configurationIssue;
public FlumeApiException(String message, int code, boolean configurationIssue) {
super(message);
this.code = code;
this.configurationIssue = configurationIssue;
}
public int getCode() {
return code;
}
public boolean isConfigurationIssue() {
return configurationIssue;
}
@Override
public @Nullable String getMessage() {
return super.getMessage();
}
}

View File

@ -0,0 +1,26 @@
/**
* Copyright (c) 2010-2024 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.flume.internal.api.dto;
import java.time.LocalDateTime;
/**
* The {@link FlumeApiCurrentFlowRate} dto for getCurrentFlowRate
*
* @author Jeff James - Initial contribution
*/
public class FlumeApiCurrentFlowRate {
public boolean active;
public float gpm;
public LocalDateTime datetime;
}

View File

@ -0,0 +1,37 @@
/**
* Copyright (c) 2010-2024 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.flume.internal.api.dto;
import java.time.Instant;
import com.google.gson.annotations.SerializedName;
/**
* The {@link FlumeApiDevice} dto for FetchUsersDevices.
*
* @author Jeff James - Initial contribution
*/
public class FlumeApiDevice {
public String id = ""; // "id": "6248148189204194987",
@SerializedName("bridge_id")
public String bridgeId; // "bridge_id": "6248148189204155555",
public int type; // Bridge devices have type=1. Sensor devices have type=2
public String name;
public String description;
@SerializedName("added_datetime")
public String addedDateTime; // "added_datetime": "2017-03-16T14:30:13.284Z",
@SerializedName("last_seen")
public Instant lastSeen; // "last_seen": "2017-04-13T01:31:36.000Z",
@SerializedName("battery_level")
public String batteryLevel;
}

View File

@ -0,0 +1,31 @@
/**
* Copyright (c) 2010-2024 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.flume.internal.api.dto;
import com.google.gson.annotations.SerializedName;
/**
* The {@link FlumeApiGetToken} dto for Get Token post.
*
* @author Jeff James - Initial contribution
*/
public class FlumeApiGetToken {
@SerializedName("grant_type")
public final String grantType = "password";
@SerializedName("client_id")
public String clientId;
@SerializedName("client_secret")
public String clientSecret;
public String username;
public String password;
}

View File

@ -0,0 +1,25 @@
/**
* Copyright (c) 2010-2024 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.flume.internal.api.dto;
import java.time.LocalDateTime;
/**
* The {@link FlumeApiQueryBucket} dto for query water usage.
*
* @author Jeff James - Initial contribution
*/
public class FlumeApiQueryBucket {
public LocalDateTime datetime; // "datetime": "2016-03-01 00:30:00"
public float value; // "value": 2.7943592
}

View File

@ -0,0 +1,44 @@
/**
* Copyright (c) 2010-2024 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.flume.internal.api.dto;
import java.time.LocalDateTime;
import org.openhab.binding.flume.internal.api.FlumeApi;
import com.google.gson.annotations.SerializedName;
/**
* The {@link FlumeApiQueryWaterUsage} dto for setting up query of water usage.
*
* @author Jeff James - Initial contribution
*/
public class FlumeApiQueryWaterUsage {
@SerializedName("request_id")
public String requestId;
@SerializedName("since_datetime")
public LocalDateTime sinceDateTime;
@SerializedName("until_datetime")
public LocalDateTime untilDateTime;
@SerializedName("tz")
public String timeZone;
public FlumeApi.BucketType bucket;
@SerializedName("device_id")
public String[] deviceId;
@SerializedName("group_multiplier")
public Integer groupMultiplier;
public FlumeApi.OperationType operation;
public FlumeApi.UnitType units;
@SerializedName("sort_direction")
public FlumeApi.SortDirectionType sortDirection;
}

View File

@ -0,0 +1,31 @@
/**
* Copyright (c) 2010-2024 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.flume.internal.api.dto;
import com.google.gson.annotations.SerializedName;
/**
* The {@link FlumeApiRefreshToken} dto for refresh token
*
* @author Jeff James - Initial contribution
*/
public class FlumeApiRefreshToken {
@SerializedName("grant_type")
public final String grantType = "refresh_token";
@SerializedName("client_id")
public String clientId;
@SerializedName("client_secret")
public String clientSecret;
@SerializedName("refresh_token")
public String refeshToken;
}

View File

@ -0,0 +1,31 @@
/**
* Copyright (c) 2010-2024 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.flume.internal.api.dto;
import com.google.gson.annotations.SerializedName;
/**
* The {@link FlumeApiToken} dto response for getToken.
*
* @author Jeff James - Initial contribution
*/
public class FlumeApiToken {
@SerializedName("token_type")
public String tokenType;
@SerializedName("access_token")
public String accessToken;
@SerializedName("expires_in")
public int expiresIn;
@SerializedName("refresh_token")
public String refreshToken;
}

View File

@ -0,0 +1,26 @@
/**
* Copyright (c) 2010-2024 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.flume.internal.api.dto;
import com.google.gson.annotations.SerializedName;
/**
* The {@link FlumeApiTokenPayload} dto for Token payload.
*
* @author Jeff James - Initial contribution
*/
public class FlumeApiTokenPayload {
@SerializedName("user_id")
public int userId;
public String type;
}

View File

@ -0,0 +1,35 @@
/**
* Copyright (c) 2010-2024 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.flume.internal.api.dto;
import java.time.Instant;
import com.google.gson.annotations.SerializedName;
/**
* The {@link FlumeApiUsageAlert} dto for querying usage alerts.
*
* @author Jeff James - Initial contribution
*/
public class FlumeApiUsageAlert {
public int id;
@SerializedName("device_id")
public String deviceId;
@SerializedName("triggered_datetime")
public Instant triggeredDateTime;
@SerializedName("flume_leak")
public boolean leak;
public FlumeApiQueryWaterUsage query;
@SerializedName("event_rule_name")
public String eventRuleName;
}

View File

@ -0,0 +1,29 @@
/**
* Copyright (c) 2010-2024 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.flume.internal.config;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* {@link FlumeCloudConnectorConfig} implements the http-based REST API to access the Flume Cloud
*
* @author Jeff James - Initial contribution
*/
@NonNullByDefault
public class FlumeCloudConnectorConfig {
public String username = "";
public String password = "";
public String clientId = "";
public String clientSecret = "";
public int pollingInterval;
}

View File

@ -0,0 +1,71 @@
/**
* Copyright (c) 2010-2024 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.flume.internal.discovery;
import static org.openhab.binding.flume.internal.FlumeBindingConstants.*;
import java.util.Set;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.flume.internal.handler.FlumeBridgeHandler;
import org.openhab.core.config.discovery.AbstractThingHandlerDiscoveryService;
import org.openhab.core.config.discovery.DiscoveryResult;
import org.openhab.core.config.discovery.DiscoveryResultBuilder;
import org.openhab.core.thing.ThingTypeUID;
import org.openhab.core.thing.ThingUID;
import org.openhab.core.thing.binding.ThingHandlerService;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.ServiceScope;
/**
* The {@link FlumeDiscoveryService} implements discovers service for bridge
*
* @author Jeff James - Initial contribution
*/
@Component(scope = ServiceScope.PROTOTYPE, service = FlumeDiscoveryService.class, configurationPid = "discovery.flume")
@NonNullByDefault
public class FlumeDiscoveryService extends AbstractThingHandlerDiscoveryService<FlumeBridgeHandler>
implements ThingHandlerService {
private static final Set<ThingTypeUID> DISCOVERABLE_THING_TYPES_UIDS = Set.of(THING_TYPE_METER);
public FlumeDiscoveryService() {
super(FlumeBridgeHandler.class, DISCOVERABLE_THING_TYPES_UIDS, 0, false);
}
@Override
public void initialize() {
thingHandler.registerDiscoveryListener(this);
super.initialize();
}
@Override
public void dispose() {
super.dispose();
thingHandler.unregisterDiscoveryListener();
}
@Override
protected synchronized void startScan() {
thingHandler.refreshDevices(true);
}
public void notifyDiscoveryDevice(String id) {
ThingUID bridgeUID = thingHandler.getThing().getUID();
ThingUID uid = new ThingUID(THING_TYPE_METER, bridgeUID, id);
DiscoveryResult result = DiscoveryResultBuilder.create(uid).withBridge(bridgeUID).withProperty(PROPERTY_ID, id)
.withRepresentationProperty(PROPERTY_ID).withLabel("Flume Meter Device").build();
thingDiscovered(result);
}
}

View File

@ -0,0 +1,298 @@
/**
* Copyright (c) 2010-2024 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.flume.internal.handler;
import static org.openhab.binding.flume.internal.FlumeBindingConstants.THING_TYPE_METER;
import java.io.IOException;
import java.io.InterruptedIOException;
import java.time.Duration;
import java.util.Collection;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import javax.measure.spi.SystemOfUnits;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.HttpClient;
import org.openhab.binding.flume.internal.FlumeBridgeConfig;
import org.openhab.binding.flume.internal.api.FlumeApi;
import org.openhab.binding.flume.internal.api.FlumeApiException;
import org.openhab.binding.flume.internal.api.dto.FlumeApiDevice;
import org.openhab.binding.flume.internal.discovery.FlumeDiscoveryService;
import org.openhab.core.cache.ExpiringCache;
import org.openhab.core.i18n.LocaleProvider;
import org.openhab.core.i18n.TranslationProvider;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
import org.openhab.core.thing.binding.BaseBridgeHandler;
import org.openhab.core.thing.binding.ThingHandlerService;
import org.openhab.core.types.Command;
import org.osgi.framework.Bundle;
import org.osgi.framework.FrameworkUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link FlumeBridgeHandler} implements the Flume bridge cloud connector
*
* @author Jeff James - Initial contribution
*/
@NonNullByDefault
public class FlumeBridgeHandler extends BaseBridgeHandler {
private final Logger logger = LoggerFactory.getLogger(FlumeBridgeHandler.class);
public FlumeBridgeConfig config = new FlumeBridgeConfig();
private static final Duration CACHE_EXPIRY = Duration.ofMinutes(30);
private ExpiringCache<List<FlumeApiDevice>> apiListDevicesCache = new ExpiringCache<>(CACHE_EXPIRY,
this::apiListDevicesAction);
private boolean logOnce = false;
private final FlumeApi api;
final SystemOfUnits systemOfUnits;
final TranslationProvider i18nProvider;
final LocaleProvider localeProvider;
final Bundle bundle;
public FlumeApi getApi() {
return api;
}
protected @Nullable ScheduledFuture<?> pollingJob;
private @Nullable FlumeDiscoveryService discoveryService;
/**
* Get the services registered for this bridge. Provides the discovery service.
*/
@Override
public Collection<Class<? extends ThingHandlerService>> getServices() {
return Set.of(FlumeDiscoveryService.class);
}
public boolean registerDiscoveryListener(FlumeDiscoveryService listener) {
if (discoveryService == null) {
discoveryService = listener;
return true;
}
return false;
}
public boolean unregisterDiscoveryListener() {
if (discoveryService != null) {
discoveryService = null;
return true;
}
return false;
}
public FlumeBridgeHandler(final Bridge bridge, SystemOfUnits systemOfUnits, HttpClient httpClient,
TranslationProvider i18nProvider, LocaleProvider localeProvider) {
super(bridge);
api = new FlumeApi(httpClient);
this.systemOfUnits = systemOfUnits;
this.i18nProvider = i18nProvider;
this.localeProvider = localeProvider;
this.bundle = FrameworkUtil.getBundle(this.getClass());
}
public FlumeBridgeConfig getFlumeBridgeConfig() {
return config;
}
@Override
public void initialize() {
config = getConfigAs(FlumeBridgeConfig.class);
if (config.clientId.isBlank() | config.clientSecret.isBlank() || config.password.isBlank()
|| config.username.isBlank()) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
"@text/offline.cloud-configuration-error");
return;
}
updateStatus(ThingStatus.UNKNOWN);
scheduler.execute(this::goOnline);
}
public void goOnline() {
try {
api.initialize(config.clientId, config.clientSecret, config.username, config.password,
this.getThing().getUID());
} catch (FlumeApiException | IOException | InterruptedException | TimeoutException | ExecutionException e) {
handleApiException(e);
return;
}
if (!refreshDevices(true)) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"@text/offline.cloud-configuration-error");
return;
}
int pollingPeriod = Math.min(config.refreshIntervalCumulative, config.refreshIntervalInstantaneous);
pollingJob = scheduler.scheduleWithFixedDelay(this::pollDevices, 0, pollingPeriod, TimeUnit.MINUTES);
updateStatus(ThingStatus.ONLINE);
}
@Nullable
public List<FlumeApiDevice> apiListDevicesAction() {
try {
return api.getDeviceList();
} catch (FlumeApiException | IOException | InterruptedException | TimeoutException | ExecutionException e) {
handleApiException(e);
return null;
}
}
/**
* update the listDevicesCache if expired or forcedUpdate. Will iterate through the list and
* either notify of discovery to discoveryService or, if the device is already configured, will update
* the device info.
*
* @param forcedUpdate force update
* @return true if successful in querying the API
*/
public boolean refreshDevices(boolean forcedUpdate) {
final FlumeDiscoveryService discovery = discoveryService;
if (forcedUpdate) {
apiListDevicesCache.invalidateValue();
}
@Nullable
List<FlumeApiDevice> listDevices = apiListDevicesCache.getValue();
if (listDevices == null) {
return false;
}
for (FlumeApiDevice dev : listDevices) {
if (dev.type == 2 && discovery != null) {
FlumeDeviceHandler deviceHandler = getFlumeDeviceHandler(dev.id);
if (deviceHandler == null) {
// output ID of discovered device to log once to identify ID so it can be used for textual
// configuration
if (!logOnce) {
logger.info("Flume Meter Device Discovered: ID: {}", dev.id);
logOnce = true;
}
discovery.notifyDiscoveryDevice(dev.id);
} else {
deviceHandler.updateDeviceInfo(dev);
}
}
}
return true;
}
/**
* iterates through the child things to find the handler with the matching id
*
* @param id of the Flume device thing to find
* @return FlumeDeviceHandler or null
*/
@Nullable
public FlumeDeviceHandler getFlumeDeviceHandler(String id) {
//@formatter:off
return getThing().getThings().stream()
.filter(t -> t.getThingTypeUID().equals(THING_TYPE_METER))
.map(t -> (FlumeDeviceHandler)t.getHandler())
.filter(Objects::nonNull)
.filter(h -> h.getId().equals(id))
.findFirst()
.orElse(null);
//@formatter:on
}
public void handleApiException(Exception e) {
if (e instanceof FlumeApiException flumeApiException) {
if (flumeApiException.isConfigurationIssue()) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
flumeApiException.getLocalizedMessage());
} else {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
flumeApiException.getLocalizedMessage());
}
} else if (e instanceof IOException) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getLocalizedMessage());
} else if (e instanceof InterruptedIOException) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getLocalizedMessage());
} else if (e instanceof InterruptedException) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getLocalizedMessage());
} else if (e instanceof TimeoutException) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getLocalizedMessage());
} else if (e instanceof ExecutionException) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getLocalizedMessage());
} else {
// capture in log since this is an unexpected exception
logger.warn("Unhandled Exception", e);
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.NONE, e.toString());
}
}
@Override
public void handleCommand(final ChannelUID channelUID, final Command command) {
// cloud handler has no channels
}
/**
* iterates through all child things to update usage
*/
private void pollDevices() {
if (getThing().getStatus() != ThingStatus.ONLINE) {
// try to go online if it is offline due to communication error
if (getThing().getStatusInfo().getStatusDetail() == ThingStatusDetail.COMMUNICATION_ERROR) {
goOnline();
}
return;
}
// refresh listDevicesCache if necessary
if (apiListDevicesCache.isExpired()) {
refreshDevices(true);
}
//@formatter:off
getThing().getThings().stream()
.forEach(t -> { if(t.getHandler() instanceof FlumeDeviceHandler handler) { handler.queryUsage(); } });
//@formatter:on
}
public @Nullable String getLocaleString(String key) {
return i18nProvider.getText(bundle, key, null, localeProvider.getLocale());
}
@Override
public synchronized void dispose() {
ScheduledFuture<?> localPollingJob = pollingJob;
if (localPollingJob != null) {
localPollingJob.cancel(true);
pollingJob = null;
}
}
}

View File

@ -0,0 +1,494 @@
/**
* Copyright (c) 2010-2024 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.flume.internal.handler;
import static org.openhab.binding.flume.internal.FlumeBindingConstants.*;
import java.io.IOException;
import java.io.InterruptedIOException;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeoutException;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.flume.internal.FlumeBridgeConfig;
import org.openhab.binding.flume.internal.FlumeDeviceConfig;
import org.openhab.binding.flume.internal.actions.FlumeDeviceActions;
import org.openhab.binding.flume.internal.api.FlumeApi;
import org.openhab.binding.flume.internal.api.FlumeApiException;
import org.openhab.binding.flume.internal.api.dto.FlumeApiCurrentFlowRate;
import org.openhab.binding.flume.internal.api.dto.FlumeApiDevice;
import org.openhab.binding.flume.internal.api.dto.FlumeApiQueryBucket;
import org.openhab.binding.flume.internal.api.dto.FlumeApiQueryWaterUsage;
import org.openhab.binding.flume.internal.api.dto.FlumeApiUsageAlert;
import org.openhab.core.cache.ExpiringCache;
import org.openhab.core.library.types.DateTimeType;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.library.unit.ImperialUnits;
import org.openhab.core.library.unit.Units;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
import org.openhab.core.thing.ThingStatusInfo;
import org.openhab.core.thing.binding.BaseThingHandler;
import org.openhab.core.thing.binding.BridgeHandler;
import org.openhab.core.thing.binding.ThingHandlerService;
import org.openhab.core.types.Command;
import org.openhab.core.types.RefreshType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link FlumeDeviceHandler} is the implementation the flume meter device.
*
* @author Jeff James - Initial contribution
*/
@NonNullByDefault
public class FlumeDeviceHandler extends BaseThingHandler {
private final Logger logger = LoggerFactory.getLogger(FlumeDeviceHandler.class);
// private final static String beginDateUsage = "2016-01-01 00:00:00";
private static final LocalDateTime BEGIN_DATE_USAGE = LocalDateTime.of(2016, 1, 1, 0, 0, 0);
private static final String QUERY_ID_CUMULATIVE_START_OF_YEAR = "cumulativeStartOfYear";
private static final String QUERY_ID_YEAR_TO_DATE = "usageYTD";
private ExpiringCache<FlumeApiDevice> apiDeviceCache = new ExpiringCache<FlumeApiDevice>(
Duration.ofMinutes(5).toMillis(), this::getDeviceInfo);
private FlumeDeviceConfig config = new FlumeDeviceConfig();
private float cumulativeStartOfYear = 0;
private float cumulativeUsage = 0;
private long expiryCumulativeUsage = 0;
private Duration refreshIntervalCumulative = Duration.ofMinutes(DEFAULT_POLLING_INTERVAL_CUMULATIVE);
private float instantUsage = 0;
private long expiryInstantUsage = 0;
private Duration refreshIntervalInstant = Duration.ofMinutes(DEFAULT_POLLING_INTERVAL_INSTANTANEOUS);
private LocalDateTime startOfYear = LocalDateTime.MIN;
private Instant lastUsageAlert = Instant.MIN;
private static final Duration USAGE_QUERY_FETCH_INTERVAL = Duration.ofMinutes(5);
private long expiryUsageAlertFetch = 0;
public FlumeDeviceHandler(Thing thing) {
super(thing);
}
@Override
public void initialize() {
config = getConfigAs(FlumeDeviceConfig.class);
updateStatus(ThingStatus.UNKNOWN);
scheduler.execute(this::goOnline);
}
public void goOnline() {
if (this.getThing().getStatus() == ThingStatus.ONLINE) {
return;
}
FlumeBridgeHandler bh = getBridgeHandler();
if (bh == null) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"@text/offline.configuration-error.bridge-missing");
return;
}
if (bh.getThing().getStatus() != ThingStatus.ONLINE) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
return;
}
FlumeBridgeConfig bridgeConfig = bh.getFlumeBridgeConfig();
refreshIntervalCumulative = Duration.ofMinutes(bridgeConfig.refreshIntervalCumulative);
refreshIntervalInstant = Duration.ofMinutes(bridgeConfig.refreshIntervalInstantaneous);
// always update the startOfYear number;
startOfYear = LocalDateTime.MIN;
FlumeApiDevice apiDevice = apiDeviceCache.getValue();
if (apiDevice != null) {
updateDeviceInfo(apiDevice);
}
try {
tryQueryUsage(true);
tryGetCurrentFlowRate(true);
} catch (FlumeApiException | IOException | InterruptedException | TimeoutException | ExecutionException e) {
handleApiException(e);
}
lastUsageAlert = Instant.now(); // don't retrieve any usage alerts prior to going online
updateStatus(ThingStatus.ONLINE);
}
@Override
public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) {
if (bridgeStatusInfo.getStatus() == ThingStatus.OFFLINE) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
} else if (bridgeStatusInfo.getStatus() == ThingStatus.ONLINE) {
goOnline();
}
}
/**
* Get the services registered for this bridge. Provides the discovery service.
*/
@Override
public Collection<Class<? extends ThingHandlerService>> getServices() {
return Set.of(FlumeDeviceActions.class);
}
public String getId() {
return config.id;
}
public void updateDeviceChannel(@Nullable FlumeApiDevice apiDevice, String channelUID) {
final Map<String, Integer> mapBatteryLevel = Map.of("low", 25, "medium", 50, "high", 100);
if (apiDevice == null) {
return;
}
Integer percent = mapBatteryLevel.get(apiDevice.batteryLevel);
if (percent == null) {
return;
}
switch (channelUID) {
case CHANNEL_DEVICE_BATTERYLEVEL:
updateState(CHANNEL_DEVICE_BATTERYLEVEL, new QuantityType<>(percent, Units.PERCENT));
break;
case CHANNEL_DEVICE_LOWBATTERY:
updateState(CHANNEL_DEVICE_LOWBATTERY, (percent <= 25) ? OnOffType.ON : OnOffType.OFF);
break;
case CHANNEL_DEVICE_LASTSEEN:
updateState(CHANNEL_DEVICE_LASTSEEN,
new DateTimeType(ZonedDateTime.ofInstant(apiDevice.lastSeen, ZoneId.systemDefault())));
break;
}
}
public void handleApiException(Exception e) {
if (e instanceof FlumeApiException flumeApiException) {
if (flumeApiException.isConfigurationIssue()) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
flumeApiException.getLocalizedMessage());
} else {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
flumeApiException.getLocalizedMessage());
}
} else if (e instanceof IOException) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR, e.getLocalizedMessage());
} else if (e instanceof InterruptedIOException) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getLocalizedMessage());
} else if (e instanceof InterruptedException) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getLocalizedMessage());
} else if (e instanceof TimeoutException) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getLocalizedMessage());
} else if (e instanceof ExecutionException) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getLocalizedMessage());
} else {
// capture in log since this is an unexpected exception
logger.warn("Unhandled Exception", e);
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.NONE, e.getLocalizedMessage());
}
}
/**
* Pools together several usage queries based on whether the value is expired and whether a channel is linked. Also,
* if necessary will update the usage from beginning to start of year so subsequent cumulative queries only need to
* ytd. Will update the values in the ExpiringCache as necessary.
*
* @throws FlumeApiException
* @throws IOException
* @throws InterruptedException
* @throws TimeoutException
* @throws ExecutionException
*/
protected void tryQueryUsage(boolean forceUpdate)
throws FlumeApiException, IOException, InterruptedException, TimeoutException, ExecutionException {
@Nullable
List<HashMap<String, List<FlumeApiQueryBucket>>> result;
boolean imperialUnits = isImperial();
LocalDateTime now = LocalDateTime.now();
List<FlumeApiQueryWaterUsage> listQuery = new ArrayList<FlumeApiQueryWaterUsage>();
// Get sum of all historical readings only when binding starts or its the start of a new year
// This is to reduce the time it takes on the periodic queries
if (startOfYear.equals(LocalDateTime.MIN) || (now.getYear() != startOfYear.getYear())) {
FlumeApiQueryWaterUsage query = new FlumeApiQueryWaterUsage();
query.bucket = FlumeApi.BucketType.YR;
query.sinceDateTime = BEGIN_DATE_USAGE;
query.untilDateTime = now.minusYears(1);
query.groupMultiplier = 100;
query.operation = FlumeApi.OperationType.SUM;
query.requestId = QUERY_ID_CUMULATIVE_START_OF_YEAR;
query.units = imperialUnits ? FlumeApi.UnitType.GALLONS : FlumeApi.UnitType.LITERS;
listQuery.add(query);
}
if (System.nanoTime() > this.expiryUsageAlertFetch) {
fetchUsageAlerts();
this.expiryUsageAlertFetch = System.nanoTime() + USAGE_QUERY_FETCH_INTERVAL.toNanos();
}
if (this.isLinked(CHANNEL_DEVICE_CUMULATIVEUSAGE)
&& ((System.nanoTime() > this.expiryCumulativeUsage) || forceUpdate)) {
FlumeApiQueryWaterUsage query = new FlumeApiQueryWaterUsage();
query.bucket = FlumeApi.BucketType.DAY;
query.untilDateTime = now;
query.sinceDateTime = now.withDayOfYear(1);
query.groupMultiplier = 400;
query.operation = FlumeApi.OperationType.SUM;
query.requestId = QUERY_ID_YEAR_TO_DATE;
query.units = imperialUnits ? FlumeApi.UnitType.GALLONS : FlumeApi.UnitType.LITERS;
listQuery.add(query);
}
if (listQuery.isEmpty()) {
return;
}
result = getApi().queryUsage(config.id, listQuery);
if (result == null) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
"@text/offline.cloud-connection-issue");
return;
}
Map<String, List<FlumeApiQueryBucket>> queryData = result.get(0);
List<FlumeApiQueryBucket> queryBuckets;
queryBuckets = queryData.get(QUERY_ID_CUMULATIVE_START_OF_YEAR);
if (queryBuckets != null) {
cumulativeStartOfYear = queryBuckets.get(0).value;
startOfYear = now.withDayOfYear(1);
}
queryBuckets = queryData.get(QUERY_ID_YEAR_TO_DATE);
if (queryBuckets != null) {
cumulativeUsage = queryBuckets.get(0).value + cumulativeStartOfYear;
updateState(CHANNEL_DEVICE_CUMULATIVEUSAGE,
new QuantityType<>(cumulativeUsage, imperialUnits ? ImperialUnits.GALLON_LIQUID_US : Units.LITRE));
this.expiryCumulativeUsage = System.nanoTime() + this.refreshIntervalCumulative.toNanos();
}
}
protected void tryGetCurrentFlowRate(boolean forceUpdate)
throws FlumeApiException, IOException, InterruptedException, TimeoutException, ExecutionException {
if (this.isLinked(CHANNEL_DEVICE_INSTANTUSAGE)
&& ((System.nanoTime() > this.expiryInstantUsage) || forceUpdate)) {
FlumeApiCurrentFlowRate currentFlowRate = getApi().getCurrentFlowRate(config.id);
if (currentFlowRate == null) {
return;
}
instantUsage = currentFlowRate.gpm;
updateState(CHANNEL_DEVICE_INSTANTUSAGE, new QuantityType<>(instantUsage, ImperialUnits.GALLON_PER_MINUTE));
this.expiryInstantUsage = System.nanoTime() + this.refreshIntervalInstant.toNanos();
}
}
protected @Nullable FlumeApiDevice tryGetDeviceInfo()
throws FlumeApiException, IOException, InterruptedException, TimeoutException, ExecutionException {
FlumeApiDevice deviceInfo = getApi().getDeviceInfo(config.id);
if (deviceInfo == null) {
return null;
}
return deviceInfo;
}
protected @Nullable FlumeApiDevice getDeviceInfo() {
try {
return this.tryGetDeviceInfo();
} catch (FlumeApiException | IOException | InterruptedException | TimeoutException | ExecutionException e) {
handleApiException(e);
return null;
}
}
protected void queryUsage() {
// Try to go online if the device was previously taken offline due to connection issues w/ cloud
if (getThing().getStatus() == ThingStatus.OFFLINE
&& getThing().getStatusInfo().getStatusDetail() == ThingStatusDetail.COMMUNICATION_ERROR) {
goOnline();
return;
}
try {
tryQueryUsage(false);
tryGetCurrentFlowRate(false);
} catch (FlumeApiException | IOException | InterruptedException | TimeoutException | ExecutionException e) {
this.handleApiException(e);
return;
}
if (System.nanoTime() > this.expiryUsageAlertFetch) {
fetchUsageAlerts();
this.expiryUsageAlertFetch = System.nanoTime() + USAGE_QUERY_FETCH_INTERVAL.toNanos();
}
}
public void fetchUsageAlerts() {
List<FlumeApiUsageAlert> resultList;
FlumeApiUsageAlert alert;
FlumeApiQueryWaterUsage query;
boolean imperialUnits = isImperial();
try {
resultList = getApi().fetchUsageAlerts(config.id, 1);
} catch (FlumeApiException | IOException | InterruptedException | TimeoutException | ExecutionException e) {
this.handleApiException(e);
return;
}
if (resultList.isEmpty()) {
return;
}
alert = resultList.get(0);
// alert has already been notified or occurred before the device went online
if (!alert.triggeredDateTime.isAfter(this.lastUsageAlert)) {
logger.trace("alert: {}, lastUsageAlert: {}", alert.triggeredDateTime, this.lastUsageAlert);
return;
}
lastUsageAlert = alert.triggeredDateTime;
String stringAlertFormat = Objects.requireNonNull(getBridgeHandler())
.getLocaleString("trigger.high-flow-alert");
if (stringAlertFormat == null) {
return;
}
query = alert.query;
query.bucket = FlumeApi.BucketType.MIN;
query.operation = FlumeApi.OperationType.AVG;
query.units = imperialUnits ? FlumeApi.UnitType.GALLONS : FlumeApi.UnitType.LITERS;
Float avgUsage;
try {
avgUsage = getApi().queryUsage(config.id, query);
} catch (FlumeApiException | IOException | InterruptedException | TimeoutException | ExecutionException e) {
this.handleApiException(e);
return;
}
long minutes = Duration.between(query.sinceDateTime, query.untilDateTime).toMinutes();
LocalDateTime localWhenTriggered = LocalDateTime.ofInstant(alert.triggeredDateTime, ZoneId.systemDefault());
String stringAlert = String.format(stringAlertFormat, alert.eventRuleName, localWhenTriggered.toString(),
minutes, avgUsage, imperialUnits ? "gallons" : "liters");
triggerChannel(CHANNEL_DEVICE_USAGEALERT, stringAlert);
}
@Override
public void handleCommand(ChannelUID channelUID, Command command) {
if (command instanceof RefreshType) {
switch (channelUID.getId()) {
case CHANNEL_DEVICE_CUMULATIVEUSAGE:
try {
tryQueryUsage(true);
} catch (FlumeApiException | IOException | InterruptedException | TimeoutException
| ExecutionException e) {
handleApiException(e);
return;
}
break;
case CHANNEL_DEVICE_INSTANTUSAGE:
try {
tryGetCurrentFlowRate(true);
} catch (FlumeApiException | IOException | InterruptedException | TimeoutException
| ExecutionException e) {
handleApiException(e);
return;
}
break;
case CHANNEL_DEVICE_BATTERYLEVEL:
updateDeviceChannel(apiDeviceCache.getValue(), CHANNEL_DEVICE_BATTERYLEVEL);
break;
case CHANNEL_DEVICE_LOWBATTERY:
updateDeviceChannel(apiDeviceCache.getValue(), CHANNEL_DEVICE_LOWBATTERY);
break;
case CHANNEL_DEVICE_LASTSEEN:
updateDeviceChannel(apiDeviceCache.getValue(), CHANNEL_DEVICE_LASTSEEN);
break;
}
}
}
public void updateDeviceInfo(FlumeApiDevice apiDevice) {
apiDeviceCache.putValue(apiDevice);
updateDeviceChannel(apiDevice, CHANNEL_DEVICE_BATTERYLEVEL);
updateDeviceChannel(apiDevice, CHANNEL_DEVICE_LOWBATTERY);
updateDeviceChannel(apiDevice, CHANNEL_DEVICE_LASTSEEN);
}
public boolean isImperial() {
return Objects.requireNonNull(getBridgeHandler()).systemOfUnits instanceof ImperialUnits;
}
public @Nullable FlumeBridgeHandler getBridgeHandler() {
Bridge bridge = this.getBridge();
if (bridge == null) {
return null;
}
if (bridge.getHandler() instanceof FlumeBridgeHandler bridgeHandler) {
return bridgeHandler;
}
return null;
}
public FlumeApi getApi() {
Bridge bridge = Objects.requireNonNull(getBridge());
BridgeHandler handler = Objects.requireNonNull(bridge.getHandler());
return ((FlumeBridgeHandler) handler).getApi();
}
}

View File

@ -0,0 +1,58 @@
/**
* Copyright (c) 2010-2024 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.flume.utils;
import java.lang.reflect.Type;
import java.time.Instant;
import java.time.format.DateTimeFormatter;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import com.google.gson.JsonDeserializationContext;
import com.google.gson.JsonDeserializer;
import com.google.gson.JsonElement;
import com.google.gson.JsonParseException;
import com.google.gson.JsonPrimitive;
import com.google.gson.JsonSerializationContext;
import com.google.gson.JsonSerializer;
/**
* {@link JsonInstantSerializer} implements gson serializer for Java Instant
*
* @author Jeff James - Initial contribution
*/
@NonNullByDefault
public class JsonInstantSerializer implements JsonSerializer<Instant>, JsonDeserializer<Instant> {
private DateTimeFormatter dtf;
public JsonInstantSerializer() {
dtf = DateTimeFormatter.ISO_INSTANT;
}
public JsonInstantSerializer(String format) {
dtf = DateTimeFormatter.ofPattern(format);
}
@Override
public JsonElement serialize(Instant src, Type typeOfSrc, JsonSerializationContext context) {
return new JsonPrimitive(dtf.format(src));
}
@Override
public @Nullable Instant deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context)
throws JsonParseException {
return dtf.parse(json.getAsString(), Instant::from);
}
}

View File

@ -0,0 +1,57 @@
/**
* Copyright (c) 2010-2024 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.flume.utils;
import java.lang.reflect.Type;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import com.google.gson.JsonDeserializationContext;
import com.google.gson.JsonDeserializer;
import com.google.gson.JsonElement;
import com.google.gson.JsonParseException;
import com.google.gson.JsonPrimitive;
import com.google.gson.JsonSerializationContext;
import com.google.gson.JsonSerializer;
/**
* The {@link JsonLocalDateTimeSerializer} implements gson serializer for Java LocalDateTime.
*
* @author Jeff James - Initial contribution
*/
@NonNullByDefault
public class JsonLocalDateTimeSerializer implements JsonSerializer<LocalDateTime>, JsonDeserializer<LocalDateTime> {
private DateTimeFormatter dtf;
public JsonLocalDateTimeSerializer() {
dtf = DateTimeFormatter.ISO_LOCAL_DATE_TIME;
}
public JsonLocalDateTimeSerializer(String format) {
dtf = DateTimeFormatter.ofPattern(format);
}
@Override
public JsonElement serialize(LocalDateTime src, Type typeOfSrc, JsonSerializationContext context) {
return new JsonPrimitive(dtf.format(src));
}
@Override
public @Nullable LocalDateTime deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context)
throws JsonParseException {
return dtf.parse(json.getAsString(), LocalDateTime::from);
}
}

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<addon:addon id="flume" 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>binding</type>
<name>Flume Binding</name>
<description>This is the binding for Flume water monitor.</description>
<connection>cloud</connection>
</addon:addon>

View File

@ -0,0 +1,96 @@
# add-on
addon.flume.name = Flume Binding
addon.flume.description = This is the binding for Flume water monitor.
# thing types
thing-type.flume.cloud.label = Flume Cloud Connector
thing-type.flume.cloud.description = Flume cloud connector.
thing-type.flume.meter-device.label = Flume Meter Device
thing-type.flume.meter-device.description = Flume water meter device.
# thing types config
thing-type.config.flume.cloud.clientId.label = Flume Client ID
thing-type.config.flume.cloud.clientId.description = Visit Flume cloud portal to get client ID
thing-type.config.flume.cloud.clientSecret.label = Flume Client Secret
thing-type.config.flume.cloud.clientSecret.description = Visit Flume cloud portal to get client secret
thing-type.config.flume.cloud.password.label = Flume Password
thing-type.config.flume.cloud.password.description = Flume cloud portal password
thing-type.config.flume.cloud.refreshIntervalCumulative.label = Cumulative Refresh Interval
thing-type.config.flume.cloud.refreshIntervalCumulative.description = Minutes between fetching cumulative usage from the cloud service (total cloud fetches is rate-limited to 120/hour)
thing-type.config.flume.cloud.refreshIntervalInstanteous.label = Instantaneous Refresh Interval
thing-type.config.flume.cloud.refreshIntervalInstanteous.description = Minutes between fetching current flow rate from the cloud service (total cloud fetches is rate-limited to 120/hour)
thing-type.config.flume.cloud.username.label = Flume Username
thing-type.config.flume.cloud.username.description = Flume cloud portal username
thing-type.config.flume.meter-device.id.label = ID
thing-type.config.flume.meter-device.id.description = Device ID
# channel types
channel-type.flume.cumulative-usage.label = Cumulative Used
channel-type.flume.cumulative-usage.description = Cumulative water used (volume)
channel-type.flume.instant-usage.label = Instant Water Usage
channel-type.flume.instant-usage.description = Instantaneous water flow rate (volume / minute)
channel-type.flume.last-seen.label = Last Seen
channel-type.flume.last-seen.description = Date/Time when device was last seen
channel-type.flume.usage-alert.label = Usage Alert
channel-type.flume.usage-alert.description = Trigger of a usage alert
# thing types
thing-type.flume.device.label = Flume Meter Device
thing-type.flume.device.description = Flume water meter device.
# thing types config
thing-type.config.flume.device.id.label = ID
thing-type.config.flume.device.id.description = Device ID
# channel types
channel-type.flume.cumulativeUsage.label = Cumulative Used
channel-type.flume.cumulativeUsage.description = Cumulative water used (volume)
channel-type.flume.instantUsage.label = Instant Water Usage
channel-type.flume.instantUsage.description = Instantaneous water flow rate (volume / minute)
channel-type.flume.lastSeen.label = Last Seen
channel-type.flume.lastSeen.description = Date/Time when device was last seen
channel-type.flume.usageAlert.label = Usage Alert
channel-type.flume.usageAlert.description = Trigger of a usage alert
# thing types config
thing-type.config.flume.cloud.refreshInterval.label = Refresh Interval
thing-type.config.flume.cloud.refreshInterval.description = Seconds between fetching values from the cloud service
# channel types
channel-type.flume.todayUsage.label = Today Used
channel-type.flume.todayUsage.description = Amount of water used today (volume)
# binding
binding.flume.name = Flume Binding
binding.flume.description = This is the binding for flume.
# thing status description
offline.cloud-configuration-error = Unable to connect to Flume cloud, please check cloud configuration
offline.cloud-connection-issue = Unable to connect to Flume cloud due to connection issues
offline.configuration-error.bridge-missing = Flume Cloud Connector bridge must be online
offline.device-configuration-error = Flume device configuration is invalid, please check device conviguration
# api error conditions
api.invalid-user-credentials = Invalid user credentials, please check configuration
api.retrieve-token-fail = Retrieve token fail
api.response-fail = API response fail
api.response-invalid = API response invalid
api.query-fail = API query fail
api.rate-limit-exceeded = API rate limit exceeded
api.bad-request = API error in request sent to the server
# misc
trigger.high-flow-alert = %s triggered at %s. Water has been running for %d minutes averaging %.1f %s every minute.

View File

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="UTF-8"?>
<thing:thing-descriptions bindingId="flume"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
<bridge-type id="cloud">
<label>Flume Cloud Connector</label>
<description>Flume cloud connector.</description>
<config-description>
<parameter name="username" type="text">
<label>Flume Username</label>
<description>Flume cloud portal username</description>
<required>true</required>
</parameter>
<parameter name="password" type="text">
<label>Flume Password</label>
<context>password</context>
<description>Flume cloud portal password</description>
<required>true</required>
</parameter>
<parameter name="clientId" type="text">
<label>Flume Client ID</label>
<description>Visit Flume cloud portal to get client ID</description>
<required>true</required>
</parameter>
<parameter name="clientSecret" type="text">
<label>Flume Client Secret</label>
<context>password</context>
<description>Visit Flume cloud portal to get client secret</description>
<required>true</required>
</parameter>
<parameter name="refreshIntervalInstanteous" type="integer" min="1" step="1" unit="min">
<label>Instantaneous Refresh Interval</label>
<description>Minutes between fetching current flow rate from the cloud service (total cloud fetches is rate-limited
to 120/hour)</description>
<required>false</required>
<advanced>true</advanced>
<default>1</default>
<unitLabel>minutes</unitLabel>
</parameter>
<parameter name="refreshIntervalCumulative" type="integer" min="1" step="5" unit="min">
<label>Cumulative Refresh Interval</label>
<description>Minutes between fetching cumulative usage from the cloud service (total cloud fetches is rate-limited
to 120/hour)</description>
<required>false</required>
<advanced>true</advanced>
<default>5</default>
<unitLabel>minutes</unitLabel>
</parameter>
</config-description>
</bridge-type>
</thing:thing-descriptions>

View File

@ -0,0 +1,62 @@
<?xml version="1.0" encoding="UTF-8"?>
<thing:thing-descriptions bindingId="flume"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
<thing-type id="meter-device">
<supported-bridge-type-refs>
<bridge-type-ref id="cloud"/>
</supported-bridge-type-refs>
<label>Flume Meter Device</label>
<description>Flume water meter device.</description>
<channels>
<channel id="instant-usage" typeId="instant-usage"/>
<channel id="cumulative-usage" typeId="cumulative-usage"/>
<channel id="battery-level" typeId="system.battery-level"/>
<channel id="last-seen" typeId="last-seen"/>
<channel id="low-battery" typeId="system.low-battery"/>
<channel id="usage-alert" typeId="usage-alert"/>
</channels>
<representation-property>id</representation-property>
<config-description>
<parameter name="id" type="text">
<label>ID</label>
<description>Device ID</description>
<required>true</required>
</parameter>
</config-description>
</thing-type>
<channel-type id="cumulative-usage">
<item-type>Number:Volume</item-type>
<label>Cumulative Used</label>
<description>Cumulative water used (volume)</description>
<state readOnly="true" pattern="%.0f %unit%"/>
</channel-type>
<channel-type id="instant-usage">
<item-type>Number:VolumetricFlowRate</item-type>
<label>Instant Water Usage</label>
<description>Instantaneous water flow rate (volume / minute)</description>
<state readOnly="true" pattern="%.1f %unit%"/>
</channel-type>
<channel-type id="last-seen">
<item-type>DateTime</item-type>
<label>Last Seen</label>
<description>Date/Time when device was last seen</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="usage-alert">
<kind>trigger</kind>
<label>Usage Alert</label>
<description>Trigger of a usage alert</description>
<event></event>
</channel-type>
</thing:thing-descriptions>

View File

@ -149,6 +149,7 @@
<module>org.openhab.binding.fenecon</module> <module>org.openhab.binding.fenecon</module>
<module>org.openhab.binding.fineoffsetweatherstation</module> <module>org.openhab.binding.fineoffsetweatherstation</module>
<module>org.openhab.binding.flicbutton</module> <module>org.openhab.binding.flicbutton</module>
<module>org.openhab.binding.flume</module>
<module>org.openhab.binding.fmiweather</module> <module>org.openhab.binding.fmiweather</module>
<module>org.openhab.binding.folderwatcher</module> <module>org.openhab.binding.folderwatcher</module>
<module>org.openhab.binding.folding</module> <module>org.openhab.binding.folding</module>