diff --git a/bom/openhab-addons/pom.xml b/bom/openhab-addons/pom.xml index d9d76b04fe2f9..8acb4166cc360 100644 --- a/bom/openhab-addons/pom.xml +++ b/bom/openhab-addons/pom.xml @@ -566,6 +566,11 @@ org.openhab.binding.flicbutton ${project.version} + + org.openhab.addons.bundles + org.openhab.binding.flume + ${project.version} + org.openhab.addons.bundles org.openhab.binding.fmiweather diff --git a/bundles/org.openhab.binding.flume/NOTICE b/bundles/org.openhab.binding.flume/NOTICE new file mode 100644 index 0000000000000..38d625e349232 --- /dev/null +++ b/bundles/org.openhab.binding.flume/NOTICE @@ -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 diff --git a/bundles/org.openhab.binding.flume/README.md b/bundles/org.openhab.binding.flume/README.md new file mode 100644 index 0000000000000..2ba4f87004f4e --- /dev/null +++ b/bundles/org.openhab.binding.flume/README.md @@ -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 diff --git a/bundles/org.openhab.binding.flume/pom.xml b/bundles/org.openhab.binding.flume/pom.xml new file mode 100644 index 0000000000000..ed616416d3958 --- /dev/null +++ b/bundles/org.openhab.binding.flume/pom.xml @@ -0,0 +1,17 @@ + + + + 4.0.0 + + + org.openhab.addons.bundles + org.openhab.addons.reactor.bundles + 4.3.0-SNAPSHOT + + + org.openhab.binding.flume + + openHAB Add-ons :: Bundles :: Flume Binding + + diff --git a/bundles/org.openhab.binding.flume/src/main/feature/feature.xml b/bundles/org.openhab.binding.flume/src/main/feature/feature.xml new file mode 100644 index 0000000000000..e357968056ee9 --- /dev/null +++ b/bundles/org.openhab.binding.flume/src/main/feature/feature.xml @@ -0,0 +1,9 @@ + + + mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features + + + openhab-runtime-base + mvn:org.openhab.addons.bundles/org.openhab.binding.flume/${project.version} + + diff --git a/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/FlumeBindingConstants.java b/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/FlumeBindingConstants.java new file mode 100644 index 0000000000000..28606c7d311c9 --- /dev/null +++ b/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/FlumeBindingConstants.java @@ -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; +} diff --git a/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/FlumeBridgeConfig.java b/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/FlumeBridgeConfig.java new file mode 100644 index 0000000000000..667dbc8966770 --- /dev/null +++ b/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/FlumeBridgeConfig.java @@ -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; +} diff --git a/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/FlumeDeviceConfig.java b/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/FlumeDeviceConfig.java new file mode 100644 index 0000000000000..611c2c3bf61c5 --- /dev/null +++ b/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/FlumeDeviceConfig.java @@ -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 = ""; +} diff --git a/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/FlumeHandlerFactory.java b/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/FlumeHandlerFactory.java new file mode 100644 index 0000000000000..4cf4b444516dc --- /dev/null +++ b/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/FlumeHandlerFactory.java @@ -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 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; + } +} diff --git a/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/actions/FlumeDeviceActions.java b/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/actions/FlumeDeviceActions.java new file mode 100644 index 0000000000000..1ea2f3f0add80 --- /dev/null +++ b/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/actions/FlumeDeviceActions.java @@ -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") QuantityType 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(usage, imperialUnits ? ImperialUnits.GALLON_LIQUID_US : Units.LITRE); + } + + // Static method for Rules DSL backward compatibility + public static @Nullable QuantityType 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."); + } + } +} diff --git a/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/api/FlumeApi.java b/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/api/FlumeApi.java new file mode 100644 index 0000000000000..971572810b614 --- /dev/null +++ b/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/api/FlumeApi.java @@ -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 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 listQuery = new ArrayList(); + List>> queryData; + + listQuery.add(query); + + queryData = queryUsage(deviceID, listQuery); + if (queryData == null) { + return null; + } + + Map> queryBuckets = queryData.get(0); + + List 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>> queryUsage(String deviceID, + List 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>>>() { + }.getType(); + + List>> 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 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 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; + } +} diff --git a/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/api/FlumeApiException.java b/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/api/FlumeApiException.java new file mode 100644 index 0000000000000..305fee58070c9 --- /dev/null +++ b/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/api/FlumeApiException.java @@ -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(); + } +} diff --git a/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/api/dto/FlumeApiCurrentFlowRate.java b/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/api/dto/FlumeApiCurrentFlowRate.java new file mode 100644 index 0000000000000..df2d94c909248 --- /dev/null +++ b/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/api/dto/FlumeApiCurrentFlowRate.java @@ -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; +} diff --git a/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/api/dto/FlumeApiDevice.java b/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/api/dto/FlumeApiDevice.java new file mode 100644 index 0000000000000..eb969910d8928 --- /dev/null +++ b/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/api/dto/FlumeApiDevice.java @@ -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; +} diff --git a/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/api/dto/FlumeApiGetToken.java b/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/api/dto/FlumeApiGetToken.java new file mode 100644 index 0000000000000..cd586d913153a --- /dev/null +++ b/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/api/dto/FlumeApiGetToken.java @@ -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; +} diff --git a/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/api/dto/FlumeApiQueryBucket.java b/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/api/dto/FlumeApiQueryBucket.java new file mode 100644 index 0000000000000..9dcdb2ac6a80d --- /dev/null +++ b/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/api/dto/FlumeApiQueryBucket.java @@ -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 +} diff --git a/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/api/dto/FlumeApiQueryWaterUsage.java b/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/api/dto/FlumeApiQueryWaterUsage.java new file mode 100644 index 0000000000000..6bb828467c257 --- /dev/null +++ b/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/api/dto/FlumeApiQueryWaterUsage.java @@ -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; +} diff --git a/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/api/dto/FlumeApiRefreshToken.java b/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/api/dto/FlumeApiRefreshToken.java new file mode 100644 index 0000000000000..af0eaa9c4cc66 --- /dev/null +++ b/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/api/dto/FlumeApiRefreshToken.java @@ -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; +} diff --git a/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/api/dto/FlumeApiToken.java b/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/api/dto/FlumeApiToken.java new file mode 100644 index 0000000000000..5967849de9e3a --- /dev/null +++ b/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/api/dto/FlumeApiToken.java @@ -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; +} diff --git a/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/api/dto/FlumeApiTokenPayload.java b/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/api/dto/FlumeApiTokenPayload.java new file mode 100644 index 0000000000000..4c9742853bb12 --- /dev/null +++ b/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/api/dto/FlumeApiTokenPayload.java @@ -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; +} diff --git a/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/api/dto/FlumeApiUsageAlert.java b/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/api/dto/FlumeApiUsageAlert.java new file mode 100644 index 0000000000000..bea71d5929bb0 --- /dev/null +++ b/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/api/dto/FlumeApiUsageAlert.java @@ -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; +} diff --git a/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/config/FlumeCloudConnectorConfig.java b/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/config/FlumeCloudConnectorConfig.java new file mode 100644 index 0000000000000..9cdef66f455f6 --- /dev/null +++ b/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/config/FlumeCloudConnectorConfig.java @@ -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; +} diff --git a/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/discovery/FlumeDiscoveryService.java b/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/discovery/FlumeDiscoveryService.java new file mode 100644 index 0000000000000..dad4606a36a86 --- /dev/null +++ b/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/discovery/FlumeDiscoveryService.java @@ -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 + implements ThingHandlerService { + private static final Set 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); + } +} diff --git a/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/handler/FlumeBridgeHandler.java b/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/handler/FlumeBridgeHandler.java new file mode 100644 index 0000000000000..f28311004d7a0 --- /dev/null +++ b/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/handler/FlumeBridgeHandler.java @@ -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> 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> 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 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 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; + } + } +} diff --git a/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/handler/FlumeDeviceHandler.java b/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/handler/FlumeDeviceHandler.java new file mode 100644 index 0000000000000..5933d1e38afd9 --- /dev/null +++ b/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/handler/FlumeDeviceHandler.java @@ -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 apiDeviceCache = new ExpiringCache( + 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> getServices() { + return Set.of(FlumeDeviceActions.class); + } + + public String getId() { + return config.id; + } + + public void updateDeviceChannel(@Nullable FlumeApiDevice apiDevice, String channelUID) { + final Map 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>> result; + + boolean imperialUnits = isImperial(); + + LocalDateTime now = LocalDateTime.now(); + + List listQuery = new ArrayList(); + + // 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> queryData = result.get(0); + List 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 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(); + } +} diff --git a/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/utils/JsonInstantSerializer.java b/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/utils/JsonInstantSerializer.java new file mode 100644 index 0000000000000..39cc825aeed9b --- /dev/null +++ b/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/utils/JsonInstantSerializer.java @@ -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, JsonDeserializer { + 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); + } +} diff --git a/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/utils/JsonLocalDateTimeSerializer.java b/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/utils/JsonLocalDateTimeSerializer.java new file mode 100644 index 0000000000000..f5afaf9ce2308 --- /dev/null +++ b/bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/utils/JsonLocalDateTimeSerializer.java @@ -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, JsonDeserializer { + 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); + } +} diff --git a/bundles/org.openhab.binding.flume/src/main/resources/OH-INF/addon/addon.xml b/bundles/org.openhab.binding.flume/src/main/resources/OH-INF/addon/addon.xml new file mode 100644 index 0000000000000..1538a16275363 --- /dev/null +++ b/bundles/org.openhab.binding.flume/src/main/resources/OH-INF/addon/addon.xml @@ -0,0 +1,10 @@ + + + + binding + Flume Binding + This is the binding for Flume water monitor. + cloud + diff --git a/bundles/org.openhab.binding.flume/src/main/resources/OH-INF/i18n/flume.properties b/bundles/org.openhab.binding.flume/src/main/resources/OH-INF/i18n/flume.properties new file mode 100644 index 0000000000000..a85584326754b --- /dev/null +++ b/bundles/org.openhab.binding.flume/src/main/resources/OH-INF/i18n/flume.properties @@ -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. diff --git a/bundles/org.openhab.binding.flume/src/main/resources/OH-INF/thing/flume-cloud-connector.xml b/bundles/org.openhab.binding.flume/src/main/resources/OH-INF/thing/flume-cloud-connector.xml new file mode 100644 index 0000000000000..3ca7044df73e3 --- /dev/null +++ b/bundles/org.openhab.binding.flume/src/main/resources/OH-INF/thing/flume-cloud-connector.xml @@ -0,0 +1,54 @@ + + + + + + Flume cloud connector. + + + + + Flume cloud portal username + true + + + + password + Flume cloud portal password + true + + + + Visit Flume cloud portal to get client ID + true + + + + password + Visit Flume cloud portal to get client secret + true + + + + Minutes between fetching current flow rate from the cloud service (total cloud fetches is rate-limited + to 120/hour) + false + true + 1 + minutes + + + + Minutes between fetching cumulative usage from the cloud service (total cloud fetches is rate-limited + to 120/hour) + false + true + 5 + minutes + + + + diff --git a/bundles/org.openhab.binding.flume/src/main/resources/OH-INF/thing/flume-device.xml b/bundles/org.openhab.binding.flume/src/main/resources/OH-INF/thing/flume-device.xml new file mode 100644 index 0000000000000..4f6f913239536 --- /dev/null +++ b/bundles/org.openhab.binding.flume/src/main/resources/OH-INF/thing/flume-device.xml @@ -0,0 +1,62 @@ + + + + + + + + + + Flume water meter device. + + + + + + + + + + + id + + + + + Device ID + true + + + + + + Number:Volume + + Cumulative water used (volume) + + + + + Number:VolumetricFlowRate + + Instantaneous water flow rate (volume / minute) + + + + + DateTime + + Date/Time when device was last seen + + + + + trigger + + Trigger of a usage alert + + + diff --git a/bundles/pom.xml b/bundles/pom.xml index 8cb255be2f875..79a768be57092 100644 --- a/bundles/pom.xml +++ b/bundles/pom.xml @@ -149,6 +149,7 @@ org.openhab.binding.fenecon org.openhab.binding.fineoffsetweatherstation org.openhab.binding.flicbutton + org.openhab.binding.flume org.openhab.binding.fmiweather org.openhab.binding.folderwatcher org.openhab.binding.folding