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