]> git.basschouten.com Git - openhab-addons.git/commitdiff
[flume] Initial contribution (#17152)
authorjsjames <jeff@james-online.com>
Sun, 8 Sep 2024 20:41:27 +0000 (13:41 -0700)
committerGitHub <noreply@github.com>
Sun, 8 Sep 2024 20:41:27 +0000 (22:41 +0200)
* Initial submission

Signed-off-by: Jeff James <jeff@james-online.com>
32 files changed:
bom/openhab-addons/pom.xml
bundles/org.openhab.binding.flume/NOTICE [new file with mode: 0644]
bundles/org.openhab.binding.flume/README.md [new file with mode: 0644]
bundles/org.openhab.binding.flume/pom.xml [new file with mode: 0644]
bundles/org.openhab.binding.flume/src/main/feature/feature.xml [new file with mode: 0644]
bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/FlumeBindingConstants.java [new file with mode: 0644]
bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/FlumeBridgeConfig.java [new file with mode: 0644]
bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/FlumeDeviceConfig.java [new file with mode: 0644]
bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/FlumeHandlerFactory.java [new file with mode: 0644]
bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/actions/FlumeDeviceActions.java [new file with mode: 0644]
bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/api/FlumeApi.java [new file with mode: 0644]
bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/api/FlumeApiException.java [new file with mode: 0644]
bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/api/dto/FlumeApiCurrentFlowRate.java [new file with mode: 0644]
bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/api/dto/FlumeApiDevice.java [new file with mode: 0644]
bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/api/dto/FlumeApiGetToken.java [new file with mode: 0644]
bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/api/dto/FlumeApiQueryBucket.java [new file with mode: 0644]
bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/api/dto/FlumeApiQueryWaterUsage.java [new file with mode: 0644]
bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/api/dto/FlumeApiRefreshToken.java [new file with mode: 0644]
bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/api/dto/FlumeApiToken.java [new file with mode: 0644]
bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/api/dto/FlumeApiTokenPayload.java [new file with mode: 0644]
bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/api/dto/FlumeApiUsageAlert.java [new file with mode: 0644]
bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/config/FlumeCloudConnectorConfig.java [new file with mode: 0644]
bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/discovery/FlumeDiscoveryService.java [new file with mode: 0644]
bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/handler/FlumeBridgeHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/internal/handler/FlumeDeviceHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/utils/JsonInstantSerializer.java [new file with mode: 0644]
bundles/org.openhab.binding.flume/src/main/java/org/openhab/binding/flume/utils/JsonLocalDateTimeSerializer.java [new file with mode: 0644]
bundles/org.openhab.binding.flume/src/main/resources/OH-INF/addon/addon.xml [new file with mode: 0644]
bundles/org.openhab.binding.flume/src/main/resources/OH-INF/i18n/flume.properties [new file with mode: 0644]
bundles/org.openhab.binding.flume/src/main/resources/OH-INF/thing/flume-cloud-connector.xml [new file with mode: 0644]
bundles/org.openhab.binding.flume/src/main/resources/OH-INF/thing/flume-device.xml [new file with mode: 0644]
bundles/pom.xml

index d9d76b04fe2f9c465f74c2d52832657a2b45b2ab..8acb4166cc3600c0164ad696853ba8a72287bec0 100644 (file)
       <artifactId>org.openhab.binding.flicbutton</artifactId>
       <version>${project.version}</version>
     </dependency>
+    <dependency>
+      <groupId>org.openhab.addons.bundles</groupId>
+      <artifactId>org.openhab.binding.flume</artifactId>
+      <version>${project.version}</version>
+    </dependency>
     <dependency>
       <groupId>org.openhab.addons.bundles</groupId>
       <artifactId>org.openhab.binding.fmiweather</artifactId>
diff --git a/bundles/org.openhab.binding.flume/NOTICE b/bundles/org.openhab.binding.flume/NOTICE
new file mode 100644 (file)
index 0000000..38d625e
--- /dev/null
@@ -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 (file)
index 0000000..2ba4f87
--- /dev/null
@@ -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 (file)
index 0000000..ed61641
--- /dev/null
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
+  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+
+  <modelVersion>4.0.0</modelVersion>
+
+  <parent>
+    <groupId>org.openhab.addons.bundles</groupId>
+    <artifactId>org.openhab.addons.reactor.bundles</artifactId>
+    <version>4.3.0-SNAPSHOT</version>
+  </parent>
+
+  <artifactId>org.openhab.binding.flume</artifactId>
+
+  <name>openHAB Add-ons :: Bundles :: Flume Binding</name>
+
+</project>
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 (file)
index 0000000..e357968
--- /dev/null
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<features name="org.openhab.binding.flume-${project.version}" xmlns="http://karaf.apache.org/xmlns/features/v1.4.0">
+       <repository>mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features</repository>
+
+       <feature name="openhab-binding-flume" description="Flume Binding" version="${project.version}">
+               <feature>openhab-runtime-base</feature>
+               <bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.flume/${project.version}</bundle>
+       </feature>
+</features>
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 (file)
index 0000000..28606c7
--- /dev/null
@@ -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 (file)
index 0000000..667dbc8
--- /dev/null
@@ -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 (file)
index 0000000..611c2c3
--- /dev/null
@@ -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 (file)
index 0000000..4cf4b44
--- /dev/null
@@ -0,0 +1,82 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.flume.internal;
+
+import static org.openhab.binding.flume.internal.FlumeBindingConstants.*;
+
+import java.util.Set;
+
+import javax.measure.spi.SystemOfUnits;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.flume.internal.handler.FlumeBridgeHandler;
+import org.openhab.binding.flume.internal.handler.FlumeDeviceHandler;
+import org.openhab.core.i18n.LocaleProvider;
+import org.openhab.core.i18n.TranslationProvider;
+import org.openhab.core.i18n.UnitProvider;
+import org.openhab.core.io.net.http.HttpClientFactory;
+import org.openhab.core.thing.Bridge;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingTypeUID;
+import org.openhab.core.thing.binding.BaseThingHandlerFactory;
+import org.openhab.core.thing.binding.ThingHandler;
+import org.openhab.core.thing.binding.ThingHandlerFactory;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Reference;
+
+/**
+ * The {@link FlumeHandlerFactory} is responsible for creating things and thing
+ * handlers.
+ *
+ * @author Jeff James - Initial contribution
+ */
+@NonNullByDefault
+@Component(configurationPid = "binding.flume", service = ThingHandlerFactory.class)
+public class FlumeHandlerFactory extends BaseThingHandlerFactory {
+    private static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_CLOUD, THING_TYPE_METER);
+
+    private final HttpClientFactory httpClientFactory;
+    private final TranslationProvider i18nProvider;
+    private final LocaleProvider localeProvider;
+    public final SystemOfUnits systemOfUnits;
+
+    @Activate
+    public FlumeHandlerFactory(@Reference UnitProvider unitProvider, @Reference HttpClientFactory httpClientFactory,
+            final @Reference TranslationProvider i18nProvider, final @Reference LocaleProvider localeProvider) {
+        this.systemOfUnits = unitProvider.getMeasurementSystem();
+        this.httpClientFactory = httpClientFactory;
+        this.i18nProvider = i18nProvider;
+        this.localeProvider = localeProvider;
+    }
+
+    @Override
+    public boolean supportsThingType(ThingTypeUID thingTypeUID) {
+        return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
+    }
+
+    @Override
+    protected @Nullable ThingHandler createHandler(Thing thing) {
+        ThingTypeUID thingTypeUID = thing.getThingTypeUID();
+
+        if (THING_TYPE_CLOUD.equals(thingTypeUID)) {
+            return new FlumeBridgeHandler((Bridge) thing, systemOfUnits, this.httpClientFactory.getCommonHttpClient(),
+                    i18nProvider, localeProvider);
+        } else if (THING_TYPE_METER.equals(thingTypeUID)) {
+            return new FlumeDeviceHandler(thing);
+        }
+
+        return null;
+    }
+}
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 (file)
index 0000000..1ea2f3f
--- /dev/null
@@ -0,0 +1,144 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.flume.internal.actions;
+
+import java.io.IOException;
+import java.time.LocalDateTime;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeoutException;
+
+import javax.measure.quantity.Volume;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.flume.internal.api.FlumeApi;
+import org.openhab.binding.flume.internal.api.FlumeApiException;
+import org.openhab.binding.flume.internal.api.dto.FlumeApiQueryWaterUsage;
+import org.openhab.binding.flume.internal.handler.FlumeDeviceHandler;
+import org.openhab.core.automation.annotation.ActionInput;
+import org.openhab.core.automation.annotation.ActionOutput;
+import org.openhab.core.automation.annotation.RuleAction;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.library.unit.ImperialUnits;
+import org.openhab.core.library.unit.Units;
+import org.openhab.core.thing.binding.ThingActions;
+import org.openhab.core.thing.binding.ThingActionsScope;
+import org.openhab.core.thing.binding.ThingHandler;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.ServiceScope;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link FlumeDeviceActions} class defines actions for the Flume Device
+ *
+ * @author Jeff James - Initial contribution
+ */
+@Component(scope = ServiceScope.PROTOTYPE, service = FlumeDeviceActions.class)
+@ThingActionsScope(name = "flume")
+@NonNullByDefault
+public class FlumeDeviceActions implements ThingActions {
+    private final Logger logger = LoggerFactory.getLogger(FlumeDeviceActions.class);
+    private static final String QUERYID = "action_query";
+
+    private @Nullable FlumeDeviceHandler deviceHandler;
+
+    @Override
+    public void setThingHandler(@Nullable ThingHandler handler) {
+        if (handler instanceof FlumeDeviceHandler deviceHandler) {
+            this.deviceHandler = deviceHandler;
+        }
+    }
+
+    @Override
+    public @Nullable ThingHandler getThingHandler() {
+        return deviceHandler;
+    }
+
+    /**
+     * Query water usage
+     */
+    @RuleAction(label = "query water usage", description = "Queries water usage over a period of time.")
+    public @Nullable @ActionOutput(name = "value", type = "QuantityType<Volume>") QuantityType<Volume> queryWaterUsage(
+            @ActionInput(name = "sinceDateTime", label = "Since Date/Time", required = true, description = "Restrict the query range to data samples since this datetime.") @Nullable LocalDateTime sinceDateTime,
+            @ActionInput(name = "untilDateTime", label = "Until Date/Time", required = true, description = "Restrict the query range to data samples until this datetime.") @Nullable LocalDateTime untilDateTime,
+            @ActionInput(name = "bucket", label = "Bucket size", required = true, description = "The bucket grouping of the data we are querying (MIN, HR, DAY, MON, YR).") @Nullable String bucket,
+            @ActionInput(name = "operation", label = "Operation", required = true, description = "The aggregate/accumulate operation to perform (SUM, AVG, MIN, MAX, CNT).") @Nullable String operation) {
+        logger.info("queryWaterUsage called");
+
+        FlumeApiQueryWaterUsage query = new FlumeApiQueryWaterUsage();
+
+        FlumeDeviceHandler localDeviceHandler = deviceHandler;
+        if (localDeviceHandler == null) {
+            logger.debug("querying device usage, but device is undefined.");
+            return null;
+        }
+
+        boolean imperialUnits = localDeviceHandler.isImperial();
+
+        if (operation == null || bucket == null || sinceDateTime == null || untilDateTime == null) {
+            logger.warn("queryWaterUsage called with null inputs");
+            return null;
+        }
+
+        if (!FlumeApi.OperationType.contains(operation)) {
+            logger.warn("Invalid aggregation operation in call to queryWaterUsage");
+            return null;
+        } else {
+            query.operation = FlumeApi.OperationType.valueOf(operation);
+        }
+
+        if (!FlumeApi.BucketType.contains(bucket)) {
+            logger.warn("Invalid bucket type in call to queryWaterUsage");
+            return null;
+        } else {
+            query.bucket = FlumeApi.BucketType.valueOf(bucket);
+        }
+
+        if (untilDateTime.isBefore(sinceDateTime)) {
+            logger.warn("sinceDateTime must be earlier than untilDateTime");
+            return null;
+        }
+
+        query.requestId = QUERYID;
+        query.sinceDateTime = sinceDateTime;
+        query.untilDateTime = untilDateTime;
+        query.bucket = FlumeApi.BucketType.valueOf(bucket);
+        query.units = imperialUnits ? FlumeApi.UnitType.GALLONS : FlumeApi.UnitType.LITERS;
+
+        Float usage;
+        try {
+            usage = localDeviceHandler.getApi().queryUsage(localDeviceHandler.getId(), query);
+        } catch (FlumeApiException | IOException | InterruptedException | TimeoutException | ExecutionException e) {
+            logger.warn("queryWaterUsage function failed - {}", e.getMessage());
+            return null;
+        }
+
+        if (usage == null) {
+            return null;
+        }
+
+        return new QuantityType<Volume>(usage, imperialUnits ? ImperialUnits.GALLON_LIQUID_US : Units.LITRE);
+    }
+
+    // Static method for Rules DSL backward compatibility
+    public static @Nullable QuantityType<Volume> queryWaterUsage(ThingActions actions,
+            @Nullable LocalDateTime sinceDateTime, @Nullable LocalDateTime untilDateTime, @Nullable String bucket,
+            @Nullable String operation) {
+        if (actions instanceof FlumeDeviceActions localActions) {
+            return localActions.queryWaterUsage(sinceDateTime, untilDateTime, bucket, operation);
+        } else {
+            throw new IllegalArgumentException("Instance is not a FlumeDeviceActions class.");
+        }
+    }
+}
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 (file)
index 0000000..9715728
--- /dev/null
@@ -0,0 +1,439 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.flume.internal.api;
+
+import java.io.IOException;
+import java.lang.reflect.Type;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import javax.ws.rs.core.MediaType;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.eclipse.jetty.client.HttpClient;
+import org.eclipse.jetty.client.api.ContentResponse;
+import org.eclipse.jetty.client.api.Request;
+import org.eclipse.jetty.client.util.StringContentProvider;
+import org.eclipse.jetty.http.HttpHeader;
+import org.eclipse.jetty.http.HttpMethod;
+import org.openhab.binding.flume.internal.api.dto.FlumeApiCurrentFlowRate;
+import org.openhab.binding.flume.internal.api.dto.FlumeApiDevice;
+import org.openhab.binding.flume.internal.api.dto.FlumeApiGetToken;
+import org.openhab.binding.flume.internal.api.dto.FlumeApiQueryBucket;
+import org.openhab.binding.flume.internal.api.dto.FlumeApiQueryWaterUsage;
+import org.openhab.binding.flume.internal.api.dto.FlumeApiRefreshToken;
+import org.openhab.binding.flume.internal.api.dto.FlumeApiToken;
+import org.openhab.binding.flume.internal.api.dto.FlumeApiTokenPayload;
+import org.openhab.binding.flume.internal.api.dto.FlumeApiUsageAlert;
+import org.openhab.binding.flume.utils.JsonInstantSerializer;
+import org.openhab.binding.flume.utils.JsonLocalDateTimeSerializer;
+import org.openhab.core.thing.ThingUID;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import com.google.gson.reflect.TypeToken;
+
+/**
+ * The {@link FlumeApi} implements the interface to the Flume cloud service (using http). The documentation for the API
+ * is located here: https://flumetech.readme.io/reference
+ *
+ * @author Jeff James - Initial contribution
+ */
+@NonNullByDefault
+public class FlumeApi {
+    private final Logger logger = LoggerFactory.getLogger(FlumeApi.class);
+
+    // --------------- Flume Cloud API
+    public static final String APIURL_BASE = "https://api.flumewater.com/";
+
+    public static final String APIURL_TOKEN = "oauth/token";
+    public static final String APIURL_GETUSERSDEVICES = "users/%s/devices?user=%s&location=%s";
+    public static final String APIURL_GETDEVICEINFO = "users/%s/devices/%s";
+    public static final String APIURL_QUERYUSAGE = "users/%s/devices/%s/query";
+    public static final String APIURL_FETCHUSAGEALERTS = "users/%s/usage-alerts?device_id=%s&limit=%d&sort_field=%s&sort_direction=%s";
+    public static final String APIURL_FETCHNOTIFICATIONS = "users/%s/notifications?device_id=%s&limit=%d&sort_field=%s&sort_direction=%s";
+    public static final String APIURL_GETCURRENTFLOWRATE = "users/%s/devices/%s/query/active";
+
+    private static final int API_TIMEOUT = 15;
+
+    // @formatter:off
+    public enum UnitType {
+        GALLONS, LITERS, CUBIC_FEET, CUBIC_METERS
+    }
+
+    public enum OperationType {
+        SUM, AVG, MIN, MAX, CNT;
+
+        public static boolean contains(String value) {
+            return Arrays.stream(values()).anyMatch((t) -> t.name().equals(value));
+        }
+    }
+
+    public enum BucketType {
+        YR, MON, DAY, HR, MIN;
+
+        public static boolean contains(String value) {
+            return Arrays.stream(values()).anyMatch((t) -> t.name().equals(value));
+        }
+    }
+
+    public enum SortDirectionType {
+        ASC, DESC
+    }
+    // @formatter:on
+
+    protected String clientId = "";
+    protected String clientSecret = "";
+    protected String username = "";
+    protected String password = "";
+    protected Gson gson;
+
+    private String accessToken = "";
+    private String refreshToken = "";
+    private int userId;
+    private LocalDateTime tokenExpiresAt = LocalDateTime.now();
+
+    private HttpClient httpClient;
+
+    public FlumeApi(HttpClient httpClient) {
+        this.httpClient = httpClient;
+        this.gson = new GsonBuilder()
+                .registerTypeAdapter(LocalDateTime.class, new JsonLocalDateTimeSerializer("yyyy-MM-dd HH:mm:ss")) // 2022-07-13
+                                                                                                                  // 20:14:00
+                .registerTypeAdapter(Instant.class, new JsonInstantSerializer()) // 2022-07-14T03:13:00.000Z
+                .create();
+    }
+
+    public String getClientId() {
+        return clientId;
+    }
+
+    public void initialize(String clientId, String clientSecret, String username, String password, ThingUID bridgeUID)
+            throws FlumeApiException, IOException, InterruptedException, TimeoutException, ExecutionException {
+        this.clientId = clientId;
+        this.clientSecret = clientSecret;
+        this.username = username;
+        this.password = password;
+
+        getToken();
+    }
+
+    private void getToken() throws FlumeApiException, IOException, InterruptedException, TimeoutException,
+            ExecutionException, NullPointerException {
+        FlumeApiGetToken getToken = new FlumeApiGetToken();
+
+        getToken.clientId = clientId;
+        getToken.clientSecret = clientSecret;
+        getToken.username = username;
+        getToken.password = password;
+
+        String url = APIURL_BASE + APIURL_TOKEN;
+        Request request = httpClient.newRequest(url).method(HttpMethod.POST)
+                .content(new StringContentProvider(gson.toJson(getToken)), MediaType.APPLICATION_JSON);
+
+        JsonObject jsonResponse = sendAndValidate(request, false);
+
+        final FlumeApiToken[] data = gson.fromJson(jsonResponse.get("data").getAsJsonArray(), FlumeApiToken[].class);
+
+        if (data == null) {
+            throw new FlumeApiException("@text/api.response-invalid", jsonResponse.get("code").getAsInt(), true);
+        }
+
+        processToken(data[0]);
+    }
+
+    private void refreshToken()
+            throws IOException, InterruptedException, TimeoutException, ExecutionException, FlumeApiException {
+        FlumeApiRefreshToken token = new FlumeApiRefreshToken();
+
+        token.clientId = clientId;
+        token.clientSecret = clientSecret;
+        token.refeshToken = refreshToken;
+
+        String url = APIURL_BASE + APIURL_TOKEN;
+        Request request = httpClient.newRequest(url).method(HttpMethod.POST)
+                .content(new StringContentProvider(gson.toJson(token)), MediaType.APPLICATION_JSON);
+
+        JsonObject jsonResponse = sendAndValidate(request, false);
+
+        final FlumeApiToken[] data = gson.fromJson(jsonResponse.get("data").getAsJsonArray(), FlumeApiToken[].class);
+
+        if (data == null || data.length < 1) {
+            throw new FlumeApiException("@text/api.response-invalid", jsonResponse.get("code").getAsInt(), true);
+        }
+
+        processToken(data[0]);
+    }
+
+    private void processToken(FlumeApiToken token) throws FlumeApiException {
+        accessToken = token.accessToken;
+
+        // access_token contains 3 parts: header, payload, signature - decode the payload portion
+        String accessTokenPayload[] = accessToken.split("\\.");
+        byte decoded[] = Base64.getDecoder().decode(accessTokenPayload[1]);
+
+        String jsonPayload = new String(decoded);
+
+        final FlumeApiTokenPayload payload = gson.fromJson(jsonPayload, FlumeApiTokenPayload.class);
+
+        if (payload == null) {
+            throw new FlumeApiException("@text/api.response-invalid", 0, true);
+        }
+
+        userId = payload.userId;
+
+        refreshToken = token.refreshToken;
+        tokenExpiresAt = LocalDateTime.now().plusSeconds(token.expiresIn * 2 / 3);
+
+        logger.debug("Token expires at: {}", tokenExpiresAt);
+    }
+
+    public void verifyToken()
+            throws FlumeApiException, IOException, InterruptedException, TimeoutException, ExecutionException {
+        if (LocalDateTime.now().isAfter(tokenExpiresAt)) {
+            refreshToken();
+        }
+    }
+
+    public List<FlumeApiDevice> getDeviceList()
+            throws FlumeApiException, IOException, InterruptedException, TimeoutException, ExecutionException {
+        String url = APIURL_BASE + String.format(APIURL_GETUSERSDEVICES, this.userId, false, false);
+        Request request = httpClient.newRequest(url).method(HttpMethod.GET);
+
+        JsonObject jsonResponse = sendAndValidate(request);
+
+        final FlumeApiDevice[] listDevices = gson.fromJson(jsonResponse.get("data").getAsJsonArray(),
+                FlumeApiDevice[].class);
+
+        return Arrays.asList(listDevices);
+    }
+
+    /**
+     * gets Flume device info
+     *
+     * @param deviceId for the device
+     * @return FlumeApiDevice dto structure
+     *
+     * @throws FlumeApiException
+     * @throws IOException
+     * @throws InterruptedException
+     * @throws TimeoutException
+     * @throws ExecutionException
+     */
+    public @Nullable FlumeApiDevice getDeviceInfo(String deviceId)
+            throws FlumeApiException, IOException, InterruptedException, TimeoutException, ExecutionException {
+        String url = APIURL_BASE + String.format(APIURL_GETDEVICEINFO, this.userId, deviceId);
+        Request request = httpClient.newRequest(url).method(HttpMethod.GET);
+
+        JsonObject jsonResponse = sendAndValidate(request);
+
+        final FlumeApiDevice[] apiDevices = gson.fromJson(jsonResponse.get("data").getAsJsonArray(),
+                FlumeApiDevice[].class);
+
+        return (apiDevices == null || apiDevices.length == 0) ? null : apiDevices[0];
+    }
+
+    /**
+     * makes a single query to the API.
+     *
+     * @param deviceID for the device
+     * @param query FlumeApiQueryWaterUsage class with query parameters
+     * @return the result of the single query
+     *
+     * @throws FlumeApiException
+     * @throws IOException
+     * @throws InterruptedException
+     * @throws TimeoutException
+     * @throws ExecutionException
+     */
+    public @Nullable Float queryUsage(String deviceID, FlumeApiQueryWaterUsage query)
+            throws FlumeApiException, IOException, InterruptedException, TimeoutException, ExecutionException {
+        List<FlumeApiQueryWaterUsage> listQuery = new ArrayList<FlumeApiQueryWaterUsage>();
+        List<HashMap<String, List<FlumeApiQueryBucket>>> queryData;
+
+        listQuery.add(query);
+
+        queryData = queryUsage(deviceID, listQuery);
+        if (queryData == null) {
+            return null;
+        }
+
+        Map<String, List<FlumeApiQueryBucket>> queryBuckets = queryData.get(0);
+
+        List<FlumeApiQueryBucket> queryBucket = queryBuckets.get(query.requestId);
+
+        return (queryBucket == null || queryBucket.isEmpty()) ? null : queryBucket.get(0).value;
+    }
+
+    /**
+     * makes multiple queries to the API combined into a single Rest API request.
+     *
+     * @param deviceID for the device
+     * @param listQuery a List of FlumeApiQueryWaterUsage query parameters
+     * @return a list of HashMap
+     *
+     * @throws FlumeApiException
+     * @throws IOException
+     * @throws InterruptedException
+     * @throws TimeoutException
+     * @throws ExecutionException
+     */
+    public @Nullable List<HashMap<String, List<FlumeApiQueryBucket>>> queryUsage(String deviceID,
+            List<FlumeApiQueryWaterUsage> listQuery)
+            throws FlumeApiException, IOException, InterruptedException, TimeoutException, ExecutionException {
+        if (listQuery.isEmpty()) {
+            return null;
+        }
+
+        String jsonQuery = "{\"queries\":" + gson.toJson(listQuery) + "}";
+
+        String url = APIURL_BASE + String.format(APIURL_QUERYUSAGE, this.userId, deviceID);
+        Request request = httpClient.newRequest(url).method(HttpMethod.POST)
+                .content(new StringContentProvider(jsonQuery), MediaType.APPLICATION_JSON);
+
+        logger.debug("METADATA: {}", jsonQuery);
+        JsonObject jsonResponse = sendAndValidate(request);
+
+        final Type queryResultType = new TypeToken<List<HashMap<String, List<FlumeApiQueryBucket>>>>() {
+        }.getType();
+
+        List<HashMap<String, List<FlumeApiQueryBucket>>> listQueryResult = gson.fromJson(jsonResponse.get("data"),
+                queryResultType);
+
+        return listQueryResult;
+    }
+
+    public @Nullable FlumeApiCurrentFlowRate getCurrentFlowRate(String deviceId)
+            throws FlumeApiException, IOException, InterruptedException, TimeoutException, ExecutionException {
+        String url = APIURL_BASE + String.format(APIURL_GETCURRENTFLOWRATE, this.userId, deviceId);
+        Request request = httpClient.newRequest(url).method(HttpMethod.GET);
+
+        JsonObject jsonResponse = sendAndValidate(request);
+
+        final FlumeApiCurrentFlowRate[] currentFlowRates = gson.fromJson(jsonResponse.get("data").getAsJsonArray(),
+                FlumeApiCurrentFlowRate[].class);
+
+        return (currentFlowRates == null || currentFlowRates.length < 1) ? null : currentFlowRates[0];
+    }
+
+    public List<FlumeApiUsageAlert> fetchUsageAlerts(String deviceId, int limit)
+            throws FlumeApiException, IOException, InterruptedException, TimeoutException, ExecutionException {
+        String url = APIURL_BASE
+                + String.format(APIURL_FETCHUSAGEALERTS, userId, deviceId, limit, "triggered_datetime", "DESC");
+        Request request = httpClient.newRequest(url).method(HttpMethod.GET);
+
+        JsonObject jsonResponse = sendAndValidate(request);
+
+        final FlumeApiUsageAlert[] listUsageAlerts = gson.fromJson(jsonResponse.get("data").getAsJsonArray(),
+                FlumeApiUsageAlert[].class);
+
+        return Arrays.asList(listUsageAlerts);
+    }
+
+    public List<FlumeApiUsageAlert> fetchNotificatinos(String deviceId, int limit)
+            throws FlumeApiException, IOException, InterruptedException, TimeoutException, ExecutionException {
+        String url = APIURL_BASE
+                + String.format(APIURL_FETCHNOTIFICATIONS, userId, deviceId, limit, "triggered_datetime", "DEC");
+        Request request = httpClient.newRequest(url).method(HttpMethod.GET);
+
+        JsonObject jsonResponse = sendAndValidate(request);
+
+        final FlumeApiUsageAlert[] listUsageAlerts = gson.fromJson(jsonResponse.get("data").getAsJsonArray(),
+                FlumeApiUsageAlert[].class);
+
+        return Arrays.asList(listUsageAlerts);
+    }
+
+    private JsonObject sendAndValidate(Request request)
+            throws FlumeApiException, InterruptedException, TimeoutException, ExecutionException, IOException {
+        return sendAndValidate(request, true);
+    }
+
+    /**
+     * does routine setup, validation and conversion to JsonObject for http requests
+     *
+     * @param request to be sent
+     * @param verifyToken whether the exisitng access token should be validate and refreshed if needed
+     * @return JsonObject from the Rest API call
+     *
+     * @throws FlumeApiException
+     * @throws InterruptedException
+     * @throws TimeoutException
+     * @throws ExecutionException
+     * @throws IOException
+     */
+    private JsonObject sendAndValidate(Request request, boolean verifyToken)
+            throws FlumeApiException, InterruptedException, TimeoutException, ExecutionException, IOException {
+        ContentResponse response;
+
+        if (verifyToken) {
+            verifyToken();
+        }
+
+        setHeaders(request);
+
+        logger.debug("REQUEST: {}", request.toString());
+        response = request.send();
+        logger.trace("RESPONSE: {}", response.getContentAsString());
+
+        switch (response.getStatus()) {
+            case 200:
+                break;
+            case 400:
+                // Flume API sense response code 400 (vs. normal 401) on invalid user credentials
+                throw new FlumeApiException("@text/api.invalid-user-credentials [\"" + response.getReason() + "\"]",
+                        response.getStatus(), true);
+            case 401:
+                throw new FlumeApiException("@text/api.invalid-user-credentials [\"" + response.getReason() + "\"]",
+                        response.getStatus(), true);
+            case 429:
+                logger.trace("rate limit response: {}", response.getContentAsString());
+                throw new FlumeApiException("@text/api.rate-limit-exceeded", 429, false);
+            default:
+                throw new FlumeApiException("", response.getStatus(), false);
+        }
+
+        JsonObject jsonResponse = JsonParser.parseString(response.getContentAsString()).getAsJsonObject();
+        boolean success = jsonResponse.get("success").getAsBoolean();
+
+        if (!success) {
+            String message = jsonResponse.get("message").getAsString();
+            throw new FlumeApiException("@text/api.query-fail [\"" + message + "\"]",
+                    jsonResponse.get("code").getAsInt(), false);
+        }
+
+        return jsonResponse;
+    }
+
+    private Request setHeaders(Request request) {
+        if (!accessToken.isEmpty()) {
+            request.header(HttpHeader.AUTHORIZATION, "Bearer " + accessToken);
+        }
+        request.timeout(API_TIMEOUT, TimeUnit.SECONDS);
+        return request;
+    }
+}
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 (file)
index 0000000..305fee5
--- /dev/null
@@ -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 (file)
index 0000000..df2d94c
--- /dev/null
@@ -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 (file)
index 0000000..eb96991
--- /dev/null
@@ -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 (file)
index 0000000..cd586d9
--- /dev/null
@@ -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 (file)
index 0000000..9dcdb2a
--- /dev/null
@@ -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 (file)
index 0000000..6bb8284
--- /dev/null
@@ -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 (file)
index 0000000..af0eaa9
--- /dev/null
@@ -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 (file)
index 0000000..5967849
--- /dev/null
@@ -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 (file)
index 0000000..4c97428
--- /dev/null
@@ -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 (file)
index 0000000..bea71d5
--- /dev/null
@@ -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 (file)
index 0000000..9cdef66
--- /dev/null
@@ -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 (file)
index 0000000..dad4606
--- /dev/null
@@ -0,0 +1,71 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.flume.internal.discovery;
+
+import static org.openhab.binding.flume.internal.FlumeBindingConstants.*;
+
+import java.util.Set;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.flume.internal.handler.FlumeBridgeHandler;
+import org.openhab.core.config.discovery.AbstractThingHandlerDiscoveryService;
+import org.openhab.core.config.discovery.DiscoveryResult;
+import org.openhab.core.config.discovery.DiscoveryResultBuilder;
+import org.openhab.core.thing.ThingTypeUID;
+import org.openhab.core.thing.ThingUID;
+import org.openhab.core.thing.binding.ThingHandlerService;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.ServiceScope;
+
+/**
+ * The {@link FlumeDiscoveryService} implements discovers service for bridge
+ *
+ * @author Jeff James - Initial contribution
+ */
+@Component(scope = ServiceScope.PROTOTYPE, service = FlumeDiscoveryService.class, configurationPid = "discovery.flume")
+@NonNullByDefault
+public class FlumeDiscoveryService extends AbstractThingHandlerDiscoveryService<FlumeBridgeHandler>
+        implements ThingHandlerService {
+    private static final Set<ThingTypeUID> DISCOVERABLE_THING_TYPES_UIDS = Set.of(THING_TYPE_METER);
+
+    public FlumeDiscoveryService() {
+        super(FlumeBridgeHandler.class, DISCOVERABLE_THING_TYPES_UIDS, 0, false);
+    }
+
+    @Override
+    public void initialize() {
+        thingHandler.registerDiscoveryListener(this);
+        super.initialize();
+    }
+
+    @Override
+    public void dispose() {
+        super.dispose();
+        thingHandler.unregisterDiscoveryListener();
+    }
+
+    @Override
+    protected synchronized void startScan() {
+        thingHandler.refreshDevices(true);
+    }
+
+    public void notifyDiscoveryDevice(String id) {
+        ThingUID bridgeUID = thingHandler.getThing().getUID();
+
+        ThingUID uid = new ThingUID(THING_TYPE_METER, bridgeUID, id);
+
+        DiscoveryResult result = DiscoveryResultBuilder.create(uid).withBridge(bridgeUID).withProperty(PROPERTY_ID, id)
+                .withRepresentationProperty(PROPERTY_ID).withLabel("Flume Meter Device").build();
+        thingDiscovered(result);
+    }
+}
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 (file)
index 0000000..f283110
--- /dev/null
@@ -0,0 +1,298 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.flume.internal.handler;
+
+import static org.openhab.binding.flume.internal.FlumeBindingConstants.THING_TYPE_METER;
+
+import java.io.IOException;
+import java.io.InterruptedIOException;
+import java.time.Duration;
+import java.util.Collection;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import javax.measure.spi.SystemOfUnits;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.eclipse.jetty.client.HttpClient;
+import org.openhab.binding.flume.internal.FlumeBridgeConfig;
+import org.openhab.binding.flume.internal.api.FlumeApi;
+import org.openhab.binding.flume.internal.api.FlumeApiException;
+import org.openhab.binding.flume.internal.api.dto.FlumeApiDevice;
+import org.openhab.binding.flume.internal.discovery.FlumeDiscoveryService;
+import org.openhab.core.cache.ExpiringCache;
+import org.openhab.core.i18n.LocaleProvider;
+import org.openhab.core.i18n.TranslationProvider;
+import org.openhab.core.thing.Bridge;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.ThingStatusDetail;
+import org.openhab.core.thing.binding.BaseBridgeHandler;
+import org.openhab.core.thing.binding.ThingHandlerService;
+import org.openhab.core.types.Command;
+import org.osgi.framework.Bundle;
+import org.osgi.framework.FrameworkUtil;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link FlumeBridgeHandler} implements the Flume bridge cloud connector
+ *
+ * @author Jeff James - Initial contribution
+ */
+@NonNullByDefault
+public class FlumeBridgeHandler extends BaseBridgeHandler {
+    private final Logger logger = LoggerFactory.getLogger(FlumeBridgeHandler.class);
+
+    public FlumeBridgeConfig config = new FlumeBridgeConfig();
+
+    private static final Duration CACHE_EXPIRY = Duration.ofMinutes(30);
+    private ExpiringCache<List<FlumeApiDevice>> apiListDevicesCache = new ExpiringCache<>(CACHE_EXPIRY,
+            this::apiListDevicesAction);
+
+    private boolean logOnce = false;
+
+    private final FlumeApi api;
+    final SystemOfUnits systemOfUnits;
+    final TranslationProvider i18nProvider;
+    final LocaleProvider localeProvider;
+    final Bundle bundle;
+
+    public FlumeApi getApi() {
+        return api;
+    }
+
+    protected @Nullable ScheduledFuture<?> pollingJob;
+    private @Nullable FlumeDiscoveryService discoveryService;
+
+    /**
+     * Get the services registered for this bridge. Provides the discovery service.
+     */
+    @Override
+    public Collection<Class<? extends ThingHandlerService>> getServices() {
+        return Set.of(FlumeDiscoveryService.class);
+    }
+
+    public boolean registerDiscoveryListener(FlumeDiscoveryService listener) {
+        if (discoveryService == null) {
+            discoveryService = listener;
+            return true;
+        }
+
+        return false;
+    }
+
+    public boolean unregisterDiscoveryListener() {
+        if (discoveryService != null) {
+            discoveryService = null;
+            return true;
+        }
+
+        return false;
+    }
+
+    public FlumeBridgeHandler(final Bridge bridge, SystemOfUnits systemOfUnits, HttpClient httpClient,
+            TranslationProvider i18nProvider, LocaleProvider localeProvider) {
+        super(bridge);
+
+        api = new FlumeApi(httpClient);
+        this.systemOfUnits = systemOfUnits;
+        this.i18nProvider = i18nProvider;
+        this.localeProvider = localeProvider;
+        this.bundle = FrameworkUtil.getBundle(this.getClass());
+    }
+
+    public FlumeBridgeConfig getFlumeBridgeConfig() {
+        return config;
+    }
+
+    @Override
+    public void initialize() {
+        config = getConfigAs(FlumeBridgeConfig.class);
+
+        if (config.clientId.isBlank() | config.clientSecret.isBlank() || config.password.isBlank()
+                || config.username.isBlank()) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
+                    "@text/offline.cloud-configuration-error");
+            return;
+        }
+
+        updateStatus(ThingStatus.UNKNOWN);
+
+        scheduler.execute(this::goOnline);
+    }
+
+    public void goOnline() {
+        try {
+            api.initialize(config.clientId, config.clientSecret, config.username, config.password,
+                    this.getThing().getUID());
+        } catch (FlumeApiException | IOException | InterruptedException | TimeoutException | ExecutionException e) {
+            handleApiException(e);
+            return;
+        }
+
+        if (!refreshDevices(true)) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+                    "@text/offline.cloud-configuration-error");
+            return;
+        }
+
+        int pollingPeriod = Math.min(config.refreshIntervalCumulative, config.refreshIntervalInstantaneous);
+        pollingJob = scheduler.scheduleWithFixedDelay(this::pollDevices, 0, pollingPeriod, TimeUnit.MINUTES);
+        updateStatus(ThingStatus.ONLINE);
+    }
+
+    @Nullable
+    public List<FlumeApiDevice> apiListDevicesAction() {
+        try {
+            return api.getDeviceList();
+        } catch (FlumeApiException | IOException | InterruptedException | TimeoutException | ExecutionException e) {
+            handleApiException(e);
+            return null;
+        }
+    }
+
+    /**
+     * update the listDevicesCache if expired or forcedUpdate. Will iterate through the list and
+     * either notify of discovery to discoveryService or, if the device is already configured, will update
+     * the device info.
+     *
+     * @param forcedUpdate force update
+     * @return true if successful in querying the API
+     */
+    public boolean refreshDevices(boolean forcedUpdate) {
+        final FlumeDiscoveryService discovery = discoveryService;
+
+        if (forcedUpdate) {
+            apiListDevicesCache.invalidateValue();
+        }
+        @Nullable
+        List<FlumeApiDevice> listDevices = apiListDevicesCache.getValue();
+
+        if (listDevices == null) {
+            return false;
+        }
+
+        for (FlumeApiDevice dev : listDevices) {
+            if (dev.type == 2 && discovery != null) {
+                FlumeDeviceHandler deviceHandler = getFlumeDeviceHandler(dev.id);
+
+                if (deviceHandler == null) {
+                    // output ID of discovered device to log once to identify ID so it can be used for textual
+                    // configuration
+                    if (!logOnce) {
+                        logger.info("Flume Meter Device Discovered: ID: {}", dev.id);
+                        logOnce = true;
+                    }
+                    discovery.notifyDiscoveryDevice(dev.id);
+                } else {
+                    deviceHandler.updateDeviceInfo(dev);
+                }
+            }
+        }
+
+        return true;
+    }
+
+    /**
+     * iterates through the child things to find the handler with the matching id
+     *
+     * @param id of the Flume device thing to find
+     * @return FlumeDeviceHandler or null
+     */
+    @Nullable
+    public FlumeDeviceHandler getFlumeDeviceHandler(String id) {
+        //@formatter:off
+        return getThing().getThings().stream()
+            .filter(t -> t.getThingTypeUID().equals(THING_TYPE_METER))
+            .map(t -> (FlumeDeviceHandler)t.getHandler())
+            .filter(Objects::nonNull)
+            .filter(h -> h.getId().equals(id))
+            .findFirst()
+            .orElse(null);
+        //@formatter:on
+    }
+
+    public void handleApiException(Exception e) {
+        if (e instanceof FlumeApiException flumeApiException) {
+            if (flumeApiException.isConfigurationIssue()) {
+                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
+                        flumeApiException.getLocalizedMessage());
+            } else {
+                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
+                        flumeApiException.getLocalizedMessage());
+            }
+        } else if (e instanceof IOException) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getLocalizedMessage());
+        } else if (e instanceof InterruptedIOException) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getLocalizedMessage());
+        } else if (e instanceof InterruptedException) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getLocalizedMessage());
+        } else if (e instanceof TimeoutException) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getLocalizedMessage());
+        } else if (e instanceof ExecutionException) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getLocalizedMessage());
+        } else {
+            // capture in log since this is an unexpected exception
+            logger.warn("Unhandled Exception", e);
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.NONE, e.toString());
+        }
+    }
+
+    @Override
+    public void handleCommand(final ChannelUID channelUID, final Command command) {
+        // cloud handler has no channels
+    }
+
+    /**
+     * iterates through all child things to update usage
+     */
+    private void pollDevices() {
+        if (getThing().getStatus() != ThingStatus.ONLINE) {
+            // try to go online if it is offline due to communication error
+            if (getThing().getStatusInfo().getStatusDetail() == ThingStatusDetail.COMMUNICATION_ERROR) {
+                goOnline();
+            }
+            return;
+        }
+
+        // refresh listDevicesCache if necessary
+        if (apiListDevicesCache.isExpired()) {
+            refreshDevices(true);
+        }
+
+        //@formatter:off
+        getThing().getThings().stream()
+            .forEach(t -> { if(t.getHandler() instanceof FlumeDeviceHandler handler) { handler.queryUsage(); } });
+        //@formatter:on
+    }
+
+    public @Nullable String getLocaleString(String key) {
+        return i18nProvider.getText(bundle, key, null, localeProvider.getLocale());
+    }
+
+    @Override
+    public synchronized void dispose() {
+        ScheduledFuture<?> localPollingJob = pollingJob;
+        if (localPollingJob != null) {
+            localPollingJob.cancel(true);
+            pollingJob = null;
+        }
+    }
+}
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 (file)
index 0000000..5933d1e
--- /dev/null
@@ -0,0 +1,494 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.flume.internal.handler;
+
+import static org.openhab.binding.flume.internal.FlumeBindingConstants.*;
+
+import java.io.IOException;
+import java.io.InterruptedIOException;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeoutException;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.flume.internal.FlumeBridgeConfig;
+import org.openhab.binding.flume.internal.FlumeDeviceConfig;
+import org.openhab.binding.flume.internal.actions.FlumeDeviceActions;
+import org.openhab.binding.flume.internal.api.FlumeApi;
+import org.openhab.binding.flume.internal.api.FlumeApiException;
+import org.openhab.binding.flume.internal.api.dto.FlumeApiCurrentFlowRate;
+import org.openhab.binding.flume.internal.api.dto.FlumeApiDevice;
+import org.openhab.binding.flume.internal.api.dto.FlumeApiQueryBucket;
+import org.openhab.binding.flume.internal.api.dto.FlumeApiQueryWaterUsage;
+import org.openhab.binding.flume.internal.api.dto.FlumeApiUsageAlert;
+import org.openhab.core.cache.ExpiringCache;
+import org.openhab.core.library.types.DateTimeType;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.library.unit.ImperialUnits;
+import org.openhab.core.library.unit.Units;
+import org.openhab.core.thing.Bridge;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.ThingStatusDetail;
+import org.openhab.core.thing.ThingStatusInfo;
+import org.openhab.core.thing.binding.BaseThingHandler;
+import org.openhab.core.thing.binding.BridgeHandler;
+import org.openhab.core.thing.binding.ThingHandlerService;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.RefreshType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link FlumeDeviceHandler} is the implementation the flume meter device.
+ *
+ * @author Jeff James - Initial contribution
+ */
+@NonNullByDefault
+public class FlumeDeviceHandler extends BaseThingHandler {
+    private final Logger logger = LoggerFactory.getLogger(FlumeDeviceHandler.class);
+
+    // private final static String beginDateUsage = "2016-01-01 00:00:00";
+    private static final LocalDateTime BEGIN_DATE_USAGE = LocalDateTime.of(2016, 1, 1, 0, 0, 0);
+    private static final String QUERY_ID_CUMULATIVE_START_OF_YEAR = "cumulativeStartOfYear";
+    private static final String QUERY_ID_YEAR_TO_DATE = "usageYTD";
+
+    private ExpiringCache<FlumeApiDevice> apiDeviceCache = new ExpiringCache<FlumeApiDevice>(
+            Duration.ofMinutes(5).toMillis(), this::getDeviceInfo);
+
+    private FlumeDeviceConfig config = new FlumeDeviceConfig();
+
+    private float cumulativeStartOfYear = 0;
+
+    private float cumulativeUsage = 0;
+    private long expiryCumulativeUsage = 0;
+    private Duration refreshIntervalCumulative = Duration.ofMinutes(DEFAULT_POLLING_INTERVAL_CUMULATIVE);
+
+    private float instantUsage = 0;
+    private long expiryInstantUsage = 0;
+    private Duration refreshIntervalInstant = Duration.ofMinutes(DEFAULT_POLLING_INTERVAL_INSTANTANEOUS);
+
+    private LocalDateTime startOfYear = LocalDateTime.MIN;
+
+    private Instant lastUsageAlert = Instant.MIN;
+
+    private static final Duration USAGE_QUERY_FETCH_INTERVAL = Duration.ofMinutes(5);
+    private long expiryUsageAlertFetch = 0;
+
+    public FlumeDeviceHandler(Thing thing) {
+        super(thing);
+    }
+
+    @Override
+    public void initialize() {
+        config = getConfigAs(FlumeDeviceConfig.class);
+
+        updateStatus(ThingStatus.UNKNOWN);
+        scheduler.execute(this::goOnline);
+    }
+
+    public void goOnline() {
+        if (this.getThing().getStatus() == ThingStatus.ONLINE) {
+            return;
+        }
+
+        FlumeBridgeHandler bh = getBridgeHandler();
+
+        if (bh == null) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+                    "@text/offline.configuration-error.bridge-missing");
+            return;
+        }
+
+        if (bh.getThing().getStatus() != ThingStatus.ONLINE) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
+            return;
+        }
+
+        FlumeBridgeConfig bridgeConfig = bh.getFlumeBridgeConfig();
+
+        refreshIntervalCumulative = Duration.ofMinutes(bridgeConfig.refreshIntervalCumulative);
+        refreshIntervalInstant = Duration.ofMinutes(bridgeConfig.refreshIntervalInstantaneous);
+
+        // always update the startOfYear number;
+        startOfYear = LocalDateTime.MIN;
+
+        FlumeApiDevice apiDevice = apiDeviceCache.getValue();
+        if (apiDevice != null) {
+            updateDeviceInfo(apiDevice);
+        }
+
+        try {
+            tryQueryUsage(true);
+            tryGetCurrentFlowRate(true);
+        } catch (FlumeApiException | IOException | InterruptedException | TimeoutException | ExecutionException e) {
+            handleApiException(e);
+        }
+
+        lastUsageAlert = Instant.now(); // don't retrieve any usage alerts prior to going online
+        updateStatus(ThingStatus.ONLINE);
+    }
+
+    @Override
+    public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) {
+        if (bridgeStatusInfo.getStatus() == ThingStatus.OFFLINE) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
+        } else if (bridgeStatusInfo.getStatus() == ThingStatus.ONLINE) {
+            goOnline();
+        }
+    }
+
+    /**
+     * Get the services registered for this bridge. Provides the discovery service.
+     */
+    @Override
+    public Collection<Class<? extends ThingHandlerService>> getServices() {
+        return Set.of(FlumeDeviceActions.class);
+    }
+
+    public String getId() {
+        return config.id;
+    }
+
+    public void updateDeviceChannel(@Nullable FlumeApiDevice apiDevice, String channelUID) {
+        final Map<String, Integer> mapBatteryLevel = Map.of("low", 25, "medium", 50, "high", 100);
+        if (apiDevice == null) {
+            return;
+        }
+
+        Integer percent = mapBatteryLevel.get(apiDevice.batteryLevel);
+        if (percent == null) {
+            return;
+        }
+
+        switch (channelUID) {
+            case CHANNEL_DEVICE_BATTERYLEVEL:
+                updateState(CHANNEL_DEVICE_BATTERYLEVEL, new QuantityType<>(percent, Units.PERCENT));
+                break;
+            case CHANNEL_DEVICE_LOWBATTERY:
+                updateState(CHANNEL_DEVICE_LOWBATTERY, (percent <= 25) ? OnOffType.ON : OnOffType.OFF);
+                break;
+            case CHANNEL_DEVICE_LASTSEEN:
+                updateState(CHANNEL_DEVICE_LASTSEEN,
+                        new DateTimeType(ZonedDateTime.ofInstant(apiDevice.lastSeen, ZoneId.systemDefault())));
+                break;
+        }
+    }
+
+    public void handleApiException(Exception e) {
+        if (e instanceof FlumeApiException flumeApiException) {
+            if (flumeApiException.isConfigurationIssue()) {
+                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
+                        flumeApiException.getLocalizedMessage());
+            } else {
+                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
+                        flumeApiException.getLocalizedMessage());
+            }
+        } else if (e instanceof IOException) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR, e.getLocalizedMessage());
+        } else if (e instanceof InterruptedIOException) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getLocalizedMessage());
+        } else if (e instanceof InterruptedException) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getLocalizedMessage());
+        } else if (e instanceof TimeoutException) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getLocalizedMessage());
+        } else if (e instanceof ExecutionException) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getLocalizedMessage());
+        } else {
+            // capture in log since this is an unexpected exception
+            logger.warn("Unhandled Exception", e);
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.NONE, e.getLocalizedMessage());
+        }
+    }
+
+    /**
+     * Pools together several usage queries based on whether the value is expired and whether a channel is linked. Also,
+     * if necessary will update the usage from beginning to start of year so subsequent cumulative queries only need to
+     * ytd. Will update the values in the ExpiringCache as necessary.
+     *
+     * @throws FlumeApiException
+     * @throws IOException
+     * @throws InterruptedException
+     * @throws TimeoutException
+     * @throws ExecutionException
+     */
+    protected void tryQueryUsage(boolean forceUpdate)
+            throws FlumeApiException, IOException, InterruptedException, TimeoutException, ExecutionException {
+        @Nullable
+        List<HashMap<String, List<FlumeApiQueryBucket>>> result;
+
+        boolean imperialUnits = isImperial();
+
+        LocalDateTime now = LocalDateTime.now();
+
+        List<FlumeApiQueryWaterUsage> listQuery = new ArrayList<FlumeApiQueryWaterUsage>();
+
+        // Get sum of all historical readings only when binding starts or its the start of a new year
+        // This is to reduce the time it takes on the periodic queries
+        if (startOfYear.equals(LocalDateTime.MIN) || (now.getYear() != startOfYear.getYear())) {
+            FlumeApiQueryWaterUsage query = new FlumeApiQueryWaterUsage();
+
+            query.bucket = FlumeApi.BucketType.YR;
+            query.sinceDateTime = BEGIN_DATE_USAGE;
+            query.untilDateTime = now.minusYears(1);
+            query.groupMultiplier = 100;
+            query.operation = FlumeApi.OperationType.SUM;
+            query.requestId = QUERY_ID_CUMULATIVE_START_OF_YEAR;
+            query.units = imperialUnits ? FlumeApi.UnitType.GALLONS : FlumeApi.UnitType.LITERS;
+
+            listQuery.add(query);
+        }
+
+        if (System.nanoTime() > this.expiryUsageAlertFetch) {
+            fetchUsageAlerts();
+            this.expiryUsageAlertFetch = System.nanoTime() + USAGE_QUERY_FETCH_INTERVAL.toNanos();
+        }
+
+        if (this.isLinked(CHANNEL_DEVICE_CUMULATIVEUSAGE)
+                && ((System.nanoTime() > this.expiryCumulativeUsage) || forceUpdate)) {
+            FlumeApiQueryWaterUsage query = new FlumeApiQueryWaterUsage();
+
+            query.bucket = FlumeApi.BucketType.DAY;
+            query.untilDateTime = now;
+            query.sinceDateTime = now.withDayOfYear(1);
+            query.groupMultiplier = 400;
+            query.operation = FlumeApi.OperationType.SUM;
+            query.requestId = QUERY_ID_YEAR_TO_DATE;
+            query.units = imperialUnits ? FlumeApi.UnitType.GALLONS : FlumeApi.UnitType.LITERS;
+
+            listQuery.add(query);
+        }
+
+        if (listQuery.isEmpty()) {
+            return;
+        }
+
+        result = getApi().queryUsage(config.id, listQuery);
+
+        if (result == null) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
+                    "@text/offline.cloud-connection-issue");
+            return;
+        }
+
+        Map<String, List<FlumeApiQueryBucket>> queryData = result.get(0);
+        List<FlumeApiQueryBucket> queryBuckets;
+
+        queryBuckets = queryData.get(QUERY_ID_CUMULATIVE_START_OF_YEAR);
+        if (queryBuckets != null) {
+            cumulativeStartOfYear = queryBuckets.get(0).value;
+            startOfYear = now.withDayOfYear(1);
+        }
+
+        queryBuckets = queryData.get(QUERY_ID_YEAR_TO_DATE);
+        if (queryBuckets != null) {
+            cumulativeUsage = queryBuckets.get(0).value + cumulativeStartOfYear;
+            updateState(CHANNEL_DEVICE_CUMULATIVEUSAGE,
+                    new QuantityType<>(cumulativeUsage, imperialUnits ? ImperialUnits.GALLON_LIQUID_US : Units.LITRE));
+            this.expiryCumulativeUsage = System.nanoTime() + this.refreshIntervalCumulative.toNanos();
+        }
+    }
+
+    protected void tryGetCurrentFlowRate(boolean forceUpdate)
+            throws FlumeApiException, IOException, InterruptedException, TimeoutException, ExecutionException {
+        if (this.isLinked(CHANNEL_DEVICE_INSTANTUSAGE)
+                && ((System.nanoTime() > this.expiryInstantUsage) || forceUpdate)) {
+            FlumeApiCurrentFlowRate currentFlowRate = getApi().getCurrentFlowRate(config.id);
+            if (currentFlowRate == null) {
+                return;
+            }
+
+            instantUsage = currentFlowRate.gpm;
+            updateState(CHANNEL_DEVICE_INSTANTUSAGE, new QuantityType<>(instantUsage, ImperialUnits.GALLON_PER_MINUTE));
+            this.expiryInstantUsage = System.nanoTime() + this.refreshIntervalInstant.toNanos();
+        }
+    }
+
+    protected @Nullable FlumeApiDevice tryGetDeviceInfo()
+            throws FlumeApiException, IOException, InterruptedException, TimeoutException, ExecutionException {
+        FlumeApiDevice deviceInfo = getApi().getDeviceInfo(config.id);
+        if (deviceInfo == null) {
+            return null;
+        }
+
+        return deviceInfo;
+    }
+
+    protected @Nullable FlumeApiDevice getDeviceInfo() {
+        try {
+            return this.tryGetDeviceInfo();
+        } catch (FlumeApiException | IOException | InterruptedException | TimeoutException | ExecutionException e) {
+            handleApiException(e);
+            return null;
+        }
+    }
+
+    protected void queryUsage() {
+        // Try to go online if the device was previously taken offline due to connection issues w/ cloud
+        if (getThing().getStatus() == ThingStatus.OFFLINE
+                && getThing().getStatusInfo().getStatusDetail() == ThingStatusDetail.COMMUNICATION_ERROR) {
+            goOnline();
+            return;
+        }
+
+        try {
+            tryQueryUsage(false);
+            tryGetCurrentFlowRate(false);
+        } catch (FlumeApiException | IOException | InterruptedException | TimeoutException | ExecutionException e) {
+            this.handleApiException(e);
+            return;
+        }
+
+        if (System.nanoTime() > this.expiryUsageAlertFetch) {
+            fetchUsageAlerts();
+            this.expiryUsageAlertFetch = System.nanoTime() + USAGE_QUERY_FETCH_INTERVAL.toNanos();
+        }
+    }
+
+    public void fetchUsageAlerts() {
+        List<FlumeApiUsageAlert> resultList;
+        FlumeApiUsageAlert alert;
+        FlumeApiQueryWaterUsage query;
+
+        boolean imperialUnits = isImperial();
+
+        try {
+            resultList = getApi().fetchUsageAlerts(config.id, 1);
+        } catch (FlumeApiException | IOException | InterruptedException | TimeoutException | ExecutionException e) {
+            this.handleApiException(e);
+            return;
+        }
+
+        if (resultList.isEmpty()) {
+            return;
+        }
+
+        alert = resultList.get(0);
+        // alert has already been notified or occurred before the device went online
+        if (!alert.triggeredDateTime.isAfter(this.lastUsageAlert)) {
+            logger.trace("alert: {}, lastUsageAlert: {}", alert.triggeredDateTime, this.lastUsageAlert);
+            return;
+        }
+
+        lastUsageAlert = alert.triggeredDateTime;
+
+        String stringAlertFormat = Objects.requireNonNull(getBridgeHandler())
+                .getLocaleString("trigger.high-flow-alert");
+        if (stringAlertFormat == null) {
+            return;
+        }
+
+        query = alert.query;
+        query.bucket = FlumeApi.BucketType.MIN;
+        query.operation = FlumeApi.OperationType.AVG;
+        query.units = imperialUnits ? FlumeApi.UnitType.GALLONS : FlumeApi.UnitType.LITERS;
+
+        Float avgUsage;
+        try {
+            avgUsage = getApi().queryUsage(config.id, query);
+        } catch (FlumeApiException | IOException | InterruptedException | TimeoutException | ExecutionException e) {
+            this.handleApiException(e);
+            return;
+        }
+        long minutes = Duration.between(query.sinceDateTime, query.untilDateTime).toMinutes();
+
+        LocalDateTime localWhenTriggered = LocalDateTime.ofInstant(alert.triggeredDateTime, ZoneId.systemDefault());
+
+        String stringAlert = String.format(stringAlertFormat, alert.eventRuleName, localWhenTriggered.toString(),
+                minutes, avgUsage, imperialUnits ? "gallons" : "liters");
+
+        triggerChannel(CHANNEL_DEVICE_USAGEALERT, stringAlert);
+    }
+
+    @Override
+    public void handleCommand(ChannelUID channelUID, Command command) {
+        if (command instanceof RefreshType) {
+            switch (channelUID.getId()) {
+                case CHANNEL_DEVICE_CUMULATIVEUSAGE:
+                    try {
+                        tryQueryUsage(true);
+                    } catch (FlumeApiException | IOException | InterruptedException | TimeoutException
+                            | ExecutionException e) {
+                        handleApiException(e);
+                        return;
+                    }
+
+                    break;
+                case CHANNEL_DEVICE_INSTANTUSAGE:
+                    try {
+                        tryGetCurrentFlowRate(true);
+                    } catch (FlumeApiException | IOException | InterruptedException | TimeoutException
+                            | ExecutionException e) {
+                        handleApiException(e);
+                        return;
+                    }
+                    break;
+                case CHANNEL_DEVICE_BATTERYLEVEL:
+                    updateDeviceChannel(apiDeviceCache.getValue(), CHANNEL_DEVICE_BATTERYLEVEL);
+                    break;
+                case CHANNEL_DEVICE_LOWBATTERY:
+                    updateDeviceChannel(apiDeviceCache.getValue(), CHANNEL_DEVICE_LOWBATTERY);
+                    break;
+                case CHANNEL_DEVICE_LASTSEEN:
+                    updateDeviceChannel(apiDeviceCache.getValue(), CHANNEL_DEVICE_LASTSEEN);
+                    break;
+            }
+        }
+    }
+
+    public void updateDeviceInfo(FlumeApiDevice apiDevice) {
+        apiDeviceCache.putValue(apiDevice);
+
+        updateDeviceChannel(apiDevice, CHANNEL_DEVICE_BATTERYLEVEL);
+        updateDeviceChannel(apiDevice, CHANNEL_DEVICE_LOWBATTERY);
+        updateDeviceChannel(apiDevice, CHANNEL_DEVICE_LASTSEEN);
+    }
+
+    public boolean isImperial() {
+        return Objects.requireNonNull(getBridgeHandler()).systemOfUnits instanceof ImperialUnits;
+    }
+
+    public @Nullable FlumeBridgeHandler getBridgeHandler() {
+        Bridge bridge = this.getBridge();
+        if (bridge == null) {
+            return null;
+        }
+
+        if (bridge.getHandler() instanceof FlumeBridgeHandler bridgeHandler) {
+            return bridgeHandler;
+        }
+
+        return null;
+    }
+
+    public FlumeApi getApi() {
+        Bridge bridge = Objects.requireNonNull(getBridge());
+        BridgeHandler handler = Objects.requireNonNull(bridge.getHandler());
+
+        return ((FlumeBridgeHandler) handler).getApi();
+    }
+}
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 (file)
index 0000000..39cc825
--- /dev/null
@@ -0,0 +1,58 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+
+package org.openhab.binding.flume.utils;
+
+import java.lang.reflect.Type;
+import java.time.Instant;
+import java.time.format.DateTimeFormatter;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+import com.google.gson.JsonDeserializationContext;
+import com.google.gson.JsonDeserializer;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonParseException;
+import com.google.gson.JsonPrimitive;
+import com.google.gson.JsonSerializationContext;
+import com.google.gson.JsonSerializer;
+
+/**
+ * {@link JsonInstantSerializer} implements gson serializer for Java Instant
+ *
+ * @author Jeff James - Initial contribution
+ */
+@NonNullByDefault
+public class JsonInstantSerializer implements JsonSerializer<Instant>, JsonDeserializer<Instant> {
+    private DateTimeFormatter dtf;
+
+    public JsonInstantSerializer() {
+        dtf = DateTimeFormatter.ISO_INSTANT;
+    }
+
+    public JsonInstantSerializer(String format) {
+        dtf = DateTimeFormatter.ofPattern(format);
+    }
+
+    @Override
+    public JsonElement serialize(Instant src, Type typeOfSrc, JsonSerializationContext context) {
+        return new JsonPrimitive(dtf.format(src));
+    }
+
+    @Override
+    public @Nullable Instant deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context)
+            throws JsonParseException {
+        return dtf.parse(json.getAsString(), Instant::from);
+    }
+}
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 (file)
index 0000000..f5afaf9
--- /dev/null
@@ -0,0 +1,57 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.flume.utils;
+
+import java.lang.reflect.Type;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+import com.google.gson.JsonDeserializationContext;
+import com.google.gson.JsonDeserializer;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonParseException;
+import com.google.gson.JsonPrimitive;
+import com.google.gson.JsonSerializationContext;
+import com.google.gson.JsonSerializer;
+
+/**
+ * The {@link JsonLocalDateTimeSerializer} implements gson serializer for Java LocalDateTime.
+ *
+ * @author Jeff James - Initial contribution
+ */
+@NonNullByDefault
+public class JsonLocalDateTimeSerializer implements JsonSerializer<LocalDateTime>, JsonDeserializer<LocalDateTime> {
+    private DateTimeFormatter dtf;
+
+    public JsonLocalDateTimeSerializer() {
+        dtf = DateTimeFormatter.ISO_LOCAL_DATE_TIME;
+    }
+
+    public JsonLocalDateTimeSerializer(String format) {
+        dtf = DateTimeFormatter.ofPattern(format);
+    }
+
+    @Override
+    public JsonElement serialize(LocalDateTime src, Type typeOfSrc, JsonSerializationContext context) {
+        return new JsonPrimitive(dtf.format(src));
+    }
+
+    @Override
+    public @Nullable LocalDateTime deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context)
+            throws JsonParseException {
+        return dtf.parse(json.getAsString(), LocalDateTime::from);
+    }
+}
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 (file)
index 0000000..1538a16
--- /dev/null
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<addon:addon id="flume" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:addon="https://openhab.org/schemas/addon/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/addon/v1.0.0 https://openhab.org/schemas/addon-1.0.0.xsd">
+
+       <type>binding</type>
+       <name>Flume Binding</name>
+       <description>This is the binding for Flume water monitor.</description>
+       <connection>cloud</connection>
+</addon:addon>
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 (file)
index 0000000..a855843
--- /dev/null
@@ -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 (file)
index 0000000..3ca7044
--- /dev/null
@@ -0,0 +1,54 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<thing:thing-descriptions bindingId="flume"
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
+
+       <bridge-type id="cloud">
+               <label>Flume Cloud Connector</label>
+               <description>Flume cloud connector.</description>
+
+               <config-description>
+                       <parameter name="username" type="text">
+                               <label>Flume Username</label>
+                               <description>Flume cloud portal username</description>
+                               <required>true</required>
+                       </parameter>
+                       <parameter name="password" type="text">
+                               <label>Flume Password</label>
+                               <context>password</context>
+                               <description>Flume cloud portal password</description>
+                               <required>true</required>
+                       </parameter>
+                       <parameter name="clientId" type="text">
+                               <label>Flume Client ID</label>
+                               <description>Visit Flume cloud portal to get client ID</description>
+                               <required>true</required>
+                       </parameter>
+                       <parameter name="clientSecret" type="text">
+                               <label>Flume Client Secret</label>
+                               <context>password</context>
+                               <description>Visit Flume cloud portal to get client secret</description>
+                               <required>true</required>
+                       </parameter>
+                       <parameter name="refreshIntervalInstanteous" type="integer" min="1" step="1" unit="min">
+                               <label>Instantaneous Refresh Interval</label>
+                               <description>Minutes between fetching current flow rate from the cloud service (total cloud fetches is rate-limited
+                                       to 120/hour)</description>
+                               <required>false</required>
+                               <advanced>true</advanced>
+                               <default>1</default>
+                               <unitLabel>minutes</unitLabel>
+                       </parameter>
+                       <parameter name="refreshIntervalCumulative" type="integer" min="1" step="5" unit="min">
+                               <label>Cumulative Refresh Interval</label>
+                               <description>Minutes between fetching cumulative usage from the cloud service (total cloud fetches is rate-limited
+                                       to 120/hour)</description>
+                               <required>false</required>
+                               <advanced>true</advanced>
+                               <default>5</default>
+                               <unitLabel>minutes</unitLabel>
+                       </parameter>
+               </config-description>
+       </bridge-type>
+</thing:thing-descriptions>
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 (file)
index 0000000..4f6f913
--- /dev/null
@@ -0,0 +1,62 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<thing:thing-descriptions bindingId="flume"
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
+
+       <thing-type id="meter-device">
+               <supported-bridge-type-refs>
+                       <bridge-type-ref id="cloud"/>
+               </supported-bridge-type-refs>
+
+               <label>Flume Meter Device</label>
+               <description>Flume water meter device.</description>
+
+               <channels>
+                       <channel id="instant-usage" typeId="instant-usage"/>
+                       <channel id="cumulative-usage" typeId="cumulative-usage"/>
+                       <channel id="battery-level" typeId="system.battery-level"/>
+                       <channel id="last-seen" typeId="last-seen"/>
+                       <channel id="low-battery" typeId="system.low-battery"/>
+                       <channel id="usage-alert" typeId="usage-alert"/>
+               </channels>
+
+               <representation-property>id</representation-property>
+
+               <config-description>
+                       <parameter name="id" type="text">
+                               <label>ID</label>
+                               <description>Device ID</description>
+                               <required>true</required>
+                       </parameter>
+               </config-description>
+       </thing-type>
+
+       <channel-type id="cumulative-usage">
+               <item-type>Number:Volume</item-type>
+               <label>Cumulative Used</label>
+               <description>Cumulative water used (volume)</description>
+               <state readOnly="true" pattern="%.0f %unit%"/>
+       </channel-type>
+
+       <channel-type id="instant-usage">
+               <item-type>Number:VolumetricFlowRate</item-type>
+               <label>Instant Water Usage</label>
+               <description>Instantaneous water flow rate (volume / minute)</description>
+               <state readOnly="true" pattern="%.1f %unit%"/>
+       </channel-type>
+
+       <channel-type id="last-seen">
+               <item-type>DateTime</item-type>
+               <label>Last Seen</label>
+               <description>Date/Time when device was last seen</description>
+               <state readOnly="true"/>
+       </channel-type>
+
+       <channel-type id="usage-alert">
+               <kind>trigger</kind>
+               <label>Usage Alert</label>
+               <description>Trigger of a usage alert</description>
+               <event></event>
+       </channel-type>
+</thing:thing-descriptions>
index 8cb255be2f87551da1500c9fc4d8a6180557b2dd..79a768be570924ed9b822187b15b7345472a2ecc 100644 (file)
     <module>org.openhab.binding.fenecon</module>
     <module>org.openhab.binding.fineoffsetweatherstation</module>
     <module>org.openhab.binding.flicbutton</module>
+    <module>org.openhab.binding.flume</module>
     <module>org.openhab.binding.fmiweather</module>
     <module>org.openhab.binding.folderwatcher</module>
     <module>org.openhab.binding.folding</module>