]> git.basschouten.com Git - openhab-addons.git/commitdiff
[govee] New Govee LAN-API Binding (#15696)
authorstefan-hoehn <mail@stefanhoehn.com>
Sun, 17 Dec 2023 12:38:31 +0000 (13:38 +0100)
committerGitHub <noreply@github.com>
Sun, 17 Dec 2023 12:38:31 +0000 (13:38 +0100)
Signed-off-by: Stefan Höhn <mail@stefanhoehn.com>
39 files changed:
CODEOWNERS
bom/openhab-addons/pom.xml
bundles/org.openhab.binding.govee/NOTICE [new file with mode: 0644]
bundles/org.openhab.binding.govee/README.md [new file with mode: 0644]
bundles/org.openhab.binding.govee/doc/channel-setup1.png [new file with mode: 0644]
bundles/org.openhab.binding.govee/doc/channel-setup2.png [new file with mode: 0644]
bundles/org.openhab.binding.govee/doc/channel-setup3.png [new file with mode: 0644]
bundles/org.openhab.binding.govee/doc/device-settings.png [new file with mode: 0644]
bundles/org.openhab.binding.govee/doc/govee-lights.png [new file with mode: 0644]
bundles/org.openhab.binding.govee/doc/ui-example.png [new file with mode: 0644]
bundles/org.openhab.binding.govee/pom.xml [new file with mode: 0644]
bundles/org.openhab.binding.govee/src/main/feature/feature.xml [new file with mode: 0644]
bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/CommunicationManager.java [new file with mode: 0644]
bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/GoveeBindingConstants.java [new file with mode: 0644]
bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/GoveeConfiguration.java [new file with mode: 0644]
bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/GoveeDiscoveryService.java [new file with mode: 0644]
bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/GoveeHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/GoveeHandlerFactory.java [new file with mode: 0644]
bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/model/Color.java [new file with mode: 0644]
bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/model/ColorData.java [new file with mode: 0644]
bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/model/DiscoveryData.java [new file with mode: 0644]
bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/model/DiscoveryMsg.java [new file with mode: 0644]
bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/model/DiscoveryResponse.java [new file with mode: 0644]
bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/model/EmptyValueQueryStatusData.java [new file with mode: 0644]
bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/model/GenericGoveeData.java [new file with mode: 0644]
bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/model/GenericGoveeMsg.java [new file with mode: 0644]
bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/model/GenericGoveeRequest.java [new file with mode: 0644]
bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/model/StatusData.java [new file with mode: 0644]
bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/model/StatusMsg.java [new file with mode: 0644]
bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/model/StatusResponse.java [new file with mode: 0644]
bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/model/ValueIntData.java [new file with mode: 0644]
bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/model/ValueStringData.java [new file with mode: 0644]
bundles/org.openhab.binding.govee/src/main/resources/OH-INF/addon/addon.xml [new file with mode: 0644]
bundles/org.openhab.binding.govee/src/main/resources/OH-INF/config/config.xml [new file with mode: 0644]
bundles/org.openhab.binding.govee/src/main/resources/OH-INF/i18n/govee.properties [new file with mode: 0644]
bundles/org.openhab.binding.govee/src/main/resources/OH-INF/thing/thing-types.xml [new file with mode: 0644]
bundles/org.openhab.binding.govee/src/test/java/org/openhab/binding/govee/internal/GoveeDiscoveryTest.java [new file with mode: 0644]
bundles/org.openhab.binding.govee/src/test/java/org/openhab/binding/govee/internal/GoveeSerializeTest.java [new file with mode: 0644]
bundles/pom.xml

index 5d1a045d195618a8754d6d34ff23f2d8c2124253..59a56ddd9f1d3375bda5de07002bbec8b4a96fcc 100644 (file)
 /bundles/org.openhab.binding.generacmobilelink/ @digitaldan
 /bundles/org.openhab.binding.globalcache/ @mhilbush
 /bundles/org.openhab.binding.goecharger/ @SamuelBrucksch
+/bundles/org.openhab.binding.govee/ @stefan-hoehn
 /bundles/org.openhab.binding.gpio/ @nils-bauer
 /bundles/org.openhab.binding.gpstracker/ @gbicskei
 /bundles/org.openhab.binding.gree/ @markus7017
index 5c9c312d92101e63f25eee89bf2318e2eed5b44b..71e415113a728023fdb0271c324ffc2fecce018b 100644 (file)
       <artifactId>org.openhab.binding.goecharger</artifactId>
       <version>${project.version}</version>
     </dependency>
+    <dependency>
+      <groupId>org.openhab.addons.bundles</groupId>
+      <artifactId>org.openhab.binding.govee</artifactId>
+      <version>${project.version}</version>
+    </dependency>
     <dependency>
       <groupId>org.openhab.addons.bundles</groupId>
       <artifactId>org.openhab.binding.gpio</artifactId>
diff --git a/bundles/org.openhab.binding.govee/NOTICE b/bundles/org.openhab.binding.govee/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.govee/README.md b/bundles/org.openhab.binding.govee/README.md
new file mode 100644 (file)
index 0000000..fca210a
--- /dev/null
@@ -0,0 +1,170 @@
+# Govee Lan-API Binding
+
+![govee](doc/govee-lights.png)
+
+This binding integrates Light devices from [Govee](https://www.govee.com/).
+Even though these devices are widely used, they are usually only accessable via the Cloud.
+Another option is using Bluetooth which, due to its limitation only allows to control devices within a small range.
+The Bluetooth approach is supported by the openHAB Govee Binding while this binding covers the LAN interface.
+
+Fortunately, there is a [LAN API](https://app-h5.govee.com/user-manual/wlan-guide) that allows to control the devices within your own network without accessing the Cloud.
+Note, though, that is somehow limited to a number of devices listed in the aforementioned manual.
+The binding is aware of all the devices that are listed in that document and even provides a product description during discovery.
+
+Note: By intent the Cloud API has not been implemented (so far) as it makes controlling Govee devices dependent by Govee service itself.
+
+## Supported Things
+
+The things that are supported are all lights.
+While Govee provides probably more than a hundred different lights, only the following are supported officially by the LAN API, even though others might works as well.
+
+Here is a list of the supported devices (the ones marked with * have been tested by the author)
+
+- H619Z RGBIC Pro LED Strip Lights
+- H6046 RGBIC TV Light Bars
+- H6047 RGBIC Gaming Light Bars with Smart Controller
+- H6061 Glide Hexa LED Panels (*)
+- H6062 Glide Wall Light
+- H6065 Glide RGBIC Y Lights
+- H6066 Glide Hexa Pro LED Panel
+- H6067 Glide Triangle Light Panels (*)
+- H6072 RGBICWW Corner Floor Lamp
+- H6076 RGBICW Smart Corner Floor Lamp (*)
+- H6073 LED Floor Lamp
+- H6078 Cylinder Floor Lamp
+- H6087 RGBIC Smart Wall Sconces
+- H6173 RGBIC Outdoor Strip Lights
+- H619A RGBIC Strip Lights With Protective Coating 5M
+- H619B RGBIC LED Strip Lights With Protective Coating
+- H619C LED Strip Lights With Protective Coating
+- H619D RGBIC PRO LED Strip Lights
+- H619E RGBIC LED Strip Lights With Protective Coating
+- H61A0 RGBIC Neon Rope Light 1M
+- H61A1 RGBIC Neon Rope Light 2M
+- H61A2 RGBIC Neon Rope Light 5M
+- H61A3 RGBIC Neon Rope Light
+- H61A5 Neon LED Strip Light 10
+- H61A8Neon Neon Rope Light 10
+- H618A RGBIC Basic LED Strip Lights 5M
+- H618C RGBIC Basic LED Strip Lights 5M
+- H6117 Dream Color LED Strip Light 10M
+- H6159 RGB Light Strip (*)
+- H615E LED Strip Lights 30M
+- H6163 Dreamcolor LED Strip Light 5M
+- H610A Glide Lively Wall Lights
+- H610B Music Wall Lights
+- H6172 Outdoor LED Strip 10m
+- H61B2 RGBIC Neon TV Backlight
+- H61E1 LED Strip Light M1
+- H7012 Warm White Outdoor String Lights
+- H7013 Warm White Outdoor String Lights
+- H7021 RGBIC Warm White Smart Outdoor String
+- H7028 Lynx Dream LED-Bulb String
+- H7041 LED Outdoor Bulb String Lights
+- H7042 LED Outdoor Bulb String Lights
+- H705A Permanent Outdoor Lights 30M
+- H705B Permanent Outdoor Lights 15M
+- H7050 Outdoor Ground Lights 11M
+- H7051 Outdoor Ground Lights 15M
+- H7055 Pathway Light
+- H7060 LED Flood Lights (2-Pack)
+- H7061 LED Flood Lights (4-Pack)
+- H7062 LED Flood Lights (6-Pack)
+- H7065 Outdoor Spot Lights
+- H70C1 Govee Christmas String Lights 10m (*)
+- H70C2 Govee Christmas String Lights 20m (*)
+- H6051 Aura - Smart Table Lamp
+- H6056 H6056 Flow Plus
+- H6059 RGBWW Night Light for Kids
+- H618F RGBIC LED Strip Lights
+- H618E LED Strip Lights 22m
+- H6168 TV LED Backlight
+
+## Discovery
+
+Discovery is done by scanning the devices in the Thing section.
+
+The devices _do not_ support the LAN API support out-of-the-box.
+To be able to use the device with the LAN API, the following needs to be done (also see the "Preparations for LAN API Control" section in the [Goveee LAN API Manual](https://app-h5.govee.com/user-manual/wlan-guide)):
+
+- Start the Govee APP and add / discover the device (via Bluetooth) as described by the vendor manual
+  Go to the settings page of the device
+  ![govee device settings](doc/device-settings.png)
+- Note that it may take several(!) minutes until this setting comes up.
+- Switch on the LAN Control setting.
+- Now the device can be used with openHAB.
+- The easiest way is then to scan the devices via the SCAN button in the thing section of that binding
+
+## Thing Configuration
+
+Even though binding configuration is supported via a thing file it should be noted that the IP address is required and there is no easy way to find out the IP address of the device.
+One possibility is to look for the MAC address in the Govee app and then looking the IP address up via:
+
+```shell
+arp -a | grep "MAC_ADDRESS"
+```
+
+### `govee-light` Thing Configuration
+
+| Name            | Type    | Description                           | Default | Required | Advanced |
+|-----------------|---------|---------------------------------------|---------|----------|----------|
+| hostname        | text    | Hostname or IP address of the device  | N/A     | yes      | no       |
+| macAddress      | text    | MAC address of the device             | N/A     | yes      | no       |
+| deviceType      | text    | The product number of the device      | N/A     | yes      | no       |
+| refreshInterval | integer | Interval the device is polled in sec. | 5       | no       | yes      |
+
+## Channels
+
+| Channel               | Type   | Description                     | Read/Write | Description          |
+|-----------------------|--------|---------------------------------|------------|----------------------|
+| color                 | Switch | On / Off                        | RW         | Power On / OFF       |
+|                       | Color  | HSB (Hue Saturation Brightness) | RW         |                      |
+|                       | Dimmer | Brightness Percentage           | RW         |                      |
+| color-temperature     | Dimmer | Color Temperature Percentage    | RW         |                      |
+| color-temperature-abs | Dimmer | Color Temperature Absolute      | RW         | in 2000-9000 Kelvin  |
+
+Note: you may want to set Unit metadata to "K" when creating a color-temperature-abs item.
+
+## UI Example for one device
+
+![ui-example.png](doc/ui-example.png)
+
+Thing channel setup:
+
+![channel-setup1.png](doc/channel-setup1.png)
+![channel-setup2.png](doc/channel-setup2.png)
+![channel-setup3.png](doc/channel-setup3.png)
+
+```java
+UID: govee:govee-light:33_5F_60_74_F4_08_77_21
+label: Govee H6159 RGB Light Strip H6159 (192.168.178.173)
+thingTypeUID: govee:govee-light
+configuration:
+  deviceType: H6159
+  wifiSoftwareVersion: 1.02.11
+  hostname: 192.168.162.233
+  macAddress: 33:5F:60:74:F4:08:66:21
+  wifiHardwareVersion: 1.00.10
+  refreshInterval: 5
+  productName: H6159 RGB Light Strip
+channels:
+  - id: color
+    channelTypeUID: system:color
+    label: Color
+    description: Controls the color of the light
+    configuration: {}
+  - id: color-temperature
+    channelTypeUID: system:color-temperature
+    label: Color Temperature
+    description: Controls the color temperature of the light from 0 (cold) to 100 (warm)
+    configuration: {}
+  - id: color-temperature-abs
+    channelTypeUID: govee:color-temperature-abs
+    label: Absolute Color Temperature
+    description: Controls the color temperature of the light in Kelvin
+    configuration: {}
+```
+
+## Additional Information
+
+Please provide any feedback regarding unlisted devices that even though not mentioned herein do work.
diff --git a/bundles/org.openhab.binding.govee/doc/channel-setup1.png b/bundles/org.openhab.binding.govee/doc/channel-setup1.png
new file mode 100644 (file)
index 0000000..de86255
Binary files /dev/null and b/bundles/org.openhab.binding.govee/doc/channel-setup1.png differ
diff --git a/bundles/org.openhab.binding.govee/doc/channel-setup2.png b/bundles/org.openhab.binding.govee/doc/channel-setup2.png
new file mode 100644 (file)
index 0000000..ab770d5
Binary files /dev/null and b/bundles/org.openhab.binding.govee/doc/channel-setup2.png differ
diff --git a/bundles/org.openhab.binding.govee/doc/channel-setup3.png b/bundles/org.openhab.binding.govee/doc/channel-setup3.png
new file mode 100644 (file)
index 0000000..52b4675
Binary files /dev/null and b/bundles/org.openhab.binding.govee/doc/channel-setup3.png differ
diff --git a/bundles/org.openhab.binding.govee/doc/device-settings.png b/bundles/org.openhab.binding.govee/doc/device-settings.png
new file mode 100644 (file)
index 0000000..053096c
Binary files /dev/null and b/bundles/org.openhab.binding.govee/doc/device-settings.png differ
diff --git a/bundles/org.openhab.binding.govee/doc/govee-lights.png b/bundles/org.openhab.binding.govee/doc/govee-lights.png
new file mode 100644 (file)
index 0000000..1917086
Binary files /dev/null and b/bundles/org.openhab.binding.govee/doc/govee-lights.png differ
diff --git a/bundles/org.openhab.binding.govee/doc/ui-example.png b/bundles/org.openhab.binding.govee/doc/ui-example.png
new file mode 100644 (file)
index 0000000..fd23c6b
Binary files /dev/null and b/bundles/org.openhab.binding.govee/doc/ui-example.png differ
diff --git a/bundles/org.openhab.binding.govee/pom.xml b/bundles/org.openhab.binding.govee/pom.xml
new file mode 100644 (file)
index 0000000..6a72456
--- /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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+
+  <modelVersion>4.0.0</modelVersion>
+
+  <parent>
+    <groupId>org.openhab.addons.bundles</groupId>
+    <artifactId>org.openhab.addons.reactor.bundles</artifactId>
+    <version>4.1.0-SNAPSHOT</version>
+  </parent>
+
+  <artifactId>org.openhab.binding.govee</artifactId>
+
+  <name>openHAB Add-ons :: Bundles :: Govee Binding</name>
+
+</project>
diff --git a/bundles/org.openhab.binding.govee/src/main/feature/feature.xml b/bundles/org.openhab.binding.govee/src/main/feature/feature.xml
new file mode 100644 (file)
index 0000000..8d0ae40
--- /dev/null
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<features name="org.openhab.binding.govee-${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-govee" description="Govee Binding" version="${project.version}">
+               <feature>openhab-runtime-base</feature>
+               <bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.govee/${project.version}</bundle>
+       </feature>
+</features>
diff --git a/bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/CommunicationManager.java b/bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/CommunicationManager.java
new file mode 100644 (file)
index 0000000..c3931dc
--- /dev/null
@@ -0,0 +1,258 @@
+/**
+ * Copyright (c) 2010-2023 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.govee.internal;
+
+import java.io.IOException;
+import java.net.DatagramPacket;
+import java.net.DatagramSocket;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.net.MulticastSocket;
+import java.net.NetworkInterface;
+import java.time.Instant;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.govee.internal.model.DiscoveryResponse;
+import org.openhab.binding.govee.internal.model.GenericGoveeRequest;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonParseException;
+
+/**
+ * The {@link CommunicationManager} is a thread that handles the answers of all devices.
+ * Therefore it needs to apply the information to the right thing.
+ *
+ * Discovery uses the same response code, so we must not refresh the status during discovery.
+ *
+ * @author Stefan Höhn - Initial contribution
+ * @author Danny Baumann - Thread-Safe design refactoring
+ */
+@NonNullByDefault
+@Component(service = CommunicationManager.class)
+public class CommunicationManager {
+    private final Gson gson = new Gson();
+    // Holds a list of all thing handlers to send them thing updates via the receiver-Thread
+    private final Map<String, GoveeHandler> thingHandlers = new HashMap<>();
+    @Nullable
+    private StatusReceiver receiverThread;
+
+    private static final String DISCOVERY_MULTICAST_ADDRESS = "239.255.255.250";
+    private static final int DISCOVERY_PORT = 4001;
+    private static final int RESPONSE_PORT = 4002;
+    private static final int REQUEST_PORT = 4003;
+
+    private static final int INTERFACE_TIMEOUT_SEC = 5;
+
+    private static final String DISCOVER_REQUEST = "{\"msg\": {\"cmd\": \"scan\", \"data\": {\"account_topic\": \"reserve\"}}}";
+
+    public interface DiscoveryResultReceiver {
+        void onResultReceived(DiscoveryResponse result);
+    }
+
+    @Activate
+    public CommunicationManager() {
+    }
+
+    public void registerHandler(GoveeHandler handler) {
+        synchronized (thingHandlers) {
+            thingHandlers.put(handler.getHostname(), handler);
+            if (receiverThread == null) {
+                receiverThread = new StatusReceiver();
+                receiverThread.start();
+            }
+        }
+    }
+
+    public void unregisterHandler(GoveeHandler handler) {
+        synchronized (thingHandlers) {
+            thingHandlers.remove(handler.getHostname());
+            if (thingHandlers.isEmpty()) {
+                StatusReceiver receiver = receiverThread;
+                if (receiver != null) {
+                    receiver.stopReceiving();
+                }
+                receiverThread = null;
+            }
+        }
+    }
+
+    public void sendRequest(GoveeHandler handler, GenericGoveeRequest request) throws IOException {
+        final String hostname = handler.getHostname();
+        final DatagramSocket socket = new DatagramSocket();
+        socket.setReuseAddress(true);
+        final String message = gson.toJson(request);
+        final byte[] data = message.getBytes();
+        final InetAddress address = InetAddress.getByName(hostname);
+        DatagramPacket packet = new DatagramPacket(data, data.length, address, REQUEST_PORT);
+        // logger.debug("Sending {} to {}", message, hostname);
+        socket.send(packet);
+        socket.close();
+    }
+
+    public void runDiscoveryForInterface(NetworkInterface intf, DiscoveryResultReceiver receiver) throws IOException {
+        synchronized (receiver) {
+            StatusReceiver localReceiver = null;
+            StatusReceiver activeReceiver = null;
+
+            try {
+                if (receiverThread == null) {
+                    localReceiver = new StatusReceiver();
+                    localReceiver.start();
+                    activeReceiver = localReceiver;
+                } else {
+                    activeReceiver = receiverThread;
+                }
+
+                if (activeReceiver != null) {
+                    activeReceiver.setDiscoveryResultsReceiver(receiver);
+                }
+
+                final InetAddress broadcastAddress = InetAddress.getByName(DISCOVERY_MULTICAST_ADDRESS);
+                final InetSocketAddress socketAddress = new InetSocketAddress(broadcastAddress, RESPONSE_PORT);
+                final Instant discoveryStartTime = Instant.now();
+                final Instant discoveryEndTime = discoveryStartTime.plusSeconds(INTERFACE_TIMEOUT_SEC);
+
+                try (MulticastSocket sendSocket = new MulticastSocket(socketAddress)) {
+                    sendSocket.setSoTimeout(INTERFACE_TIMEOUT_SEC * 1000);
+                    sendSocket.setReuseAddress(true);
+                    sendSocket.setBroadcast(true);
+                    sendSocket.setTimeToLive(2);
+                    sendSocket.joinGroup(new InetSocketAddress(broadcastAddress, RESPONSE_PORT), intf);
+
+                    byte[] requestData = DISCOVER_REQUEST.getBytes();
+
+                    DatagramPacket request = new DatagramPacket(requestData, requestData.length, broadcastAddress,
+                            DISCOVERY_PORT);
+                    sendSocket.send(request);
+                }
+
+                do {
+                    try {
+                        receiver.wait(INTERFACE_TIMEOUT_SEC * 1000);
+                    } catch (InterruptedException e) {
+                        Thread.currentThread().interrupt();
+                    }
+                } while (Instant.now().isBefore(discoveryEndTime));
+            } finally {
+                if (activeReceiver != null) {
+                    activeReceiver.setDiscoveryResultsReceiver(null);
+                }
+                if (localReceiver != null) {
+                    localReceiver.stopReceiving();
+                }
+            }
+        }
+    }
+
+    private class StatusReceiver extends Thread {
+        private final Logger logger = LoggerFactory.getLogger(CommunicationManager.class);
+        private boolean stopped = false;
+        private @Nullable DiscoveryResultReceiver discoveryResultReceiver;
+
+        private @Nullable MulticastSocket socket;
+
+        StatusReceiver() {
+            super("GoveeStatusReceiver");
+        }
+
+        synchronized void setDiscoveryResultsReceiver(@Nullable DiscoveryResultReceiver receiver) {
+            discoveryResultReceiver = receiver;
+        }
+
+        void stopReceiving() {
+            stopped = true;
+            interrupt();
+            if (socket != null) {
+                socket.close();
+            }
+
+            try {
+                join();
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+            }
+        }
+
+        @Override
+        public void run() {
+            while (!stopped) {
+                try {
+                    socket = new MulticastSocket(RESPONSE_PORT);
+                    byte[] buffer = new byte[10240];
+                    socket.setReuseAddress(true);
+                    while (!stopped) {
+                        DatagramPacket packet = new DatagramPacket(buffer, buffer.length);
+                        socket.receive(packet);
+                        if (stopped) {
+                            break;
+                        }
+
+                        String response = new String(packet.getData(), packet.getOffset(), packet.getLength());
+                        String deviceIPAddress = packet.getAddress().toString().replace("/", "");
+                        logger.trace("Response from {} = {}", deviceIPAddress, response);
+
+                        final DiscoveryResultReceiver discoveryReceiver;
+                        synchronized (this) {
+                            discoveryReceiver = discoveryResultReceiver;
+                        }
+                        if (discoveryReceiver != null) {
+                            // We're in discovery mode: try to parse result as discovery message and signal the receiver
+                            // if parsing was successful
+                            try {
+                                DiscoveryResponse result = gson.fromJson(response, DiscoveryResponse.class);
+                                if (result != null) {
+                                    synchronized (discoveryReceiver) {
+                                        discoveryReceiver.onResultReceived(result);
+                                        discoveryReceiver.notifyAll();
+                                    }
+                                }
+                            } catch (JsonParseException e) {
+                                // this probably was a status message
+                            }
+                        } else {
+                            final @Nullable GoveeHandler handler;
+                            synchronized (thingHandlers) {
+                                handler = thingHandlers.get(deviceIPAddress);
+                            }
+                            if (handler == null) {
+                                logger.warn("thing Handler for {} couldn't be found.", deviceIPAddress);
+                            } else {
+                                logger.debug("processing status updates for thing {} ", handler.getThing().getLabel());
+                                handler.handleIncomingStatus(response);
+                            }
+                        }
+                    }
+                } catch (IOException e) {
+                    logger.warn("exception when receiving status packet", e);
+                    // as we haven't received a packet we also don't know where it should have come from
+                    // hence, we don't know which thing put offline.
+                    // a way to monitor this would be to keep track in a list, which device answers we expect
+                    // and supervise an expected answer within a given time but that will make the whole
+                    // mechanism much more complicated and may be added in the future
+                } finally {
+                    if (socket != null) {
+                        socket.close();
+                        socket = null;
+                    }
+                }
+            }
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/GoveeBindingConstants.java b/bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/GoveeBindingConstants.java
new file mode 100644 (file)
index 0000000..280a08a
--- /dev/null
@@ -0,0 +1,47 @@
+/**
+ * Copyright (c) 2010-2023 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.govee.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.thing.ThingTypeUID;
+
+/**
+ * The {@link GoveeBindingConstants} class defines common constants, which are
+ * used across the whole binding.
+ *
+ * @author Stefan Höhn - Initial contribution
+ */
+@NonNullByDefault
+public class GoveeBindingConstants {
+
+    // Thing properties
+    public static final String MAC_ADDRESS = "macAddress";
+    public static final String IP_ADDRESS = "hostname";
+    public static final String DEVICE_TYPE = "deviceType";
+    public static final String PRODUCT_NAME = "productName";
+    public static final String HW_VERSION = "wifiHardwareVersion";
+    public static final String SW_VERSION = "wifiSoftwareVersion";
+    private static final String BINDING_ID = "govee";
+
+    // List of all Thing Type UIDs
+    public static final ThingTypeUID THING_TYPE_LIGHT = new ThingTypeUID(BINDING_ID, "govee-light");
+
+    // List of all Channel ids
+    public static final String CHANNEL_COLOR = "color";
+    public static final String CHANNEL_COLOR_TEMPERATURE = "color-temperature";
+    public static final String CHANNEL_COLOR_TEMPERATURE_ABS = "color-temperature-abs";
+
+    // Limit values of channels
+    public static final Double COLOR_TEMPERATURE_MIN_VALUE = 2000.0;
+    public static final Double COLOR_TEMPERATURE_MAX_VALUE = 9000.0;
+}
diff --git a/bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/GoveeConfiguration.java b/bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/GoveeConfiguration.java
new file mode 100644 (file)
index 0000000..319cb57
--- /dev/null
@@ -0,0 +1,27 @@
+/**
+ * Copyright (c) 2010-2023 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.govee.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link GoveeConfiguration} contains thing values that are used by the Thing Handler
+ *
+ * @author Stefan Höhn - Initial contribution
+ */
+@NonNullByDefault
+public class GoveeConfiguration {
+
+    public String hostname = "";
+    public int refreshInterval = 5; // in seconds
+}
diff --git a/bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/GoveeDiscoveryService.java b/bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/GoveeDiscoveryService.java
new file mode 100644 (file)
index 0000000..03aaee0
--- /dev/null
@@ -0,0 +1,198 @@
+/**
+ * Copyright (c) 2010-2023 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.govee.internal;
+
+import java.io.IOException;
+import java.net.NetworkInterface;
+import java.net.SocketException;
+import java.util.Collections;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Set;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.govee.internal.model.DiscoveryData;
+import org.openhab.binding.govee.internal.model.DiscoveryResponse;
+import org.openhab.core.config.discovery.AbstractDiscoveryService;
+import org.openhab.core.config.discovery.DiscoveryResult;
+import org.openhab.core.config.discovery.DiscoveryResultBuilder;
+import org.openhab.core.config.discovery.DiscoveryService;
+import org.openhab.core.i18n.LocaleProvider;
+import org.openhab.core.i18n.TranslationProvider;
+import org.openhab.core.thing.ThingTypeUID;
+import org.openhab.core.thing.ThingUID;
+import org.osgi.framework.Bundle;
+import org.osgi.framework.FrameworkUtil;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Reference;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Discovers Govee devices
+ *
+ * Scan approach:
+ * 1. Determines all local network interfaces
+ * 2. Send a multicast message on each interface to the Govee multicast address 239.255.255.250 at port 4001
+ * 3. Retrieve the list of devices
+ *
+ * Based on the description at https://app-h5.govee.com/user-manual/wlan-guide
+ *
+ * A typical scan response looks as follows
+ *
+ * <pre>{@code
+ * {
+ *   "msg":{
+ *     "cmd":"scan",
+ *     "data":{
+ *       "ip":"192.168.1.23",
+ *       "device":"1F:80:C5:32:32:36:72:4E",
+ *       "sku":"Hxxxx",
+ *       "bleVersionHard":"3.01.01",
+ *       "bleVersionSoft":"1.03.01",
+ *       "wifiVersionHard":"1.00.10",
+ *       "wifiVersionSoft":"1.02.03"
+ *     }
+ *   }
+ * }
+ * }
+ * </pre>
+ *
+ * Note that it uses the same port for receiving data like when receiving devices status updates.
+ *
+ * @see GoveeHandler
+ *
+ * @author Stefan Höhn - Initial Contribution
+ * @author Danny Baumann - Thread-Safe design refactoring
+ */
+@NonNullByDefault
+@Component(service = DiscoveryService.class, immediate = true, configurationPid = "discovery.govee")
+public class GoveeDiscoveryService extends AbstractDiscoveryService {
+    private final Logger logger = LoggerFactory.getLogger(GoveeDiscoveryService.class);
+
+    private CommunicationManager communicationManager;
+
+    private static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(GoveeBindingConstants.THING_TYPE_LIGHT);
+
+    @Activate
+    public GoveeDiscoveryService(@Reference TranslationProvider i18nProvider, @Reference LocaleProvider localeProvider,
+            @Reference CommunicationManager communicationManager) {
+        super(SUPPORTED_THING_TYPES_UIDS, 0, false);
+        this.i18nProvider = i18nProvider;
+        this.localeProvider = localeProvider;
+        this.communicationManager = communicationManager;
+    }
+
+    // for test purposes only
+    public GoveeDiscoveryService(CommunicationManager communicationManager) {
+        super(SUPPORTED_THING_TYPES_UIDS, 0, false);
+        this.communicationManager = communicationManager;
+    }
+
+    @Override
+    protected void startScan() {
+        logger.debug("starting Scan");
+
+        getLocalNetworkInterfaces().forEach(localNetworkInterface -> {
+            logger.debug("Discovering Govee devices on {} ...", localNetworkInterface);
+            try {
+                communicationManager.runDiscoveryForInterface(localNetworkInterface, response -> {
+                    DiscoveryResult result = responseToResult(response);
+                    if (result != null) {
+                        thingDiscovered(result);
+                    }
+                });
+                logger.trace("After runDiscoveryForInterface");
+            } catch (IOException e) {
+                logger.debug("Discovery with IO exception: {}", e.getMessage());
+            }
+            logger.trace("After try");
+        });
+    }
+
+    public @Nullable DiscoveryResult responseToResult(DiscoveryResponse response) {
+        final DiscoveryData data = response.msg().data();
+        final String macAddress = data.device();
+        if (macAddress.isEmpty()) {
+            logger.warn("Empty Mac address received during discovery - ignoring {}", response);
+            return null;
+        }
+
+        final String ipAddress = data.ip();
+        if (ipAddress.isEmpty()) {
+            logger.warn("Empty IP address received during discovery - ignoring {}", response);
+            return null;
+        }
+
+        final String sku = data.sku();
+        if (sku.isEmpty()) {
+            logger.warn("Empty SKU (product name) received during discovery - ignoring {}", response);
+            return null;
+        }
+
+        final String productName;
+        if (i18nProvider != null) {
+            Bundle bundle = FrameworkUtil.getBundle(GoveeDiscoveryService.class);
+            productName = i18nProvider.getText(bundle, "discovery.govee-light." + sku, null,
+                    localeProvider.getLocale());
+        } else {
+            productName = sku;
+        }
+        String nameForLabel = productName != null ? productName + " " + sku : sku;
+
+        ThingUID thingUid = new ThingUID(GoveeBindingConstants.THING_TYPE_LIGHT, macAddress.replace(":", "_"));
+        DiscoveryResultBuilder builder = DiscoveryResultBuilder.create(thingUid)
+                .withRepresentationProperty(GoveeBindingConstants.MAC_ADDRESS)
+                .withProperty(GoveeBindingConstants.MAC_ADDRESS, macAddress)
+                .withProperty(GoveeBindingConstants.IP_ADDRESS, ipAddress)
+                .withProperty(GoveeBindingConstants.DEVICE_TYPE, sku)
+                .withLabel(String.format("Govee %s (%s)", nameForLabel, ipAddress));
+
+        if (productName != null) {
+            builder.withProperty(GoveeBindingConstants.PRODUCT_NAME, productName);
+        }
+
+        String hwVersion = data.wifiVersionHard();
+        if (hwVersion != null) {
+            builder.withProperty(GoveeBindingConstants.HW_VERSION, hwVersion);
+        }
+        String swVersion = data.wifiVersionSoft();
+        if (swVersion != null) {
+            builder.withProperty(GoveeBindingConstants.SW_VERSION, swVersion);
+        }
+
+        return builder.build();
+    }
+
+    private List<NetworkInterface> getLocalNetworkInterfaces() {
+        List<NetworkInterface> result = new LinkedList<>();
+        try {
+            for (NetworkInterface networkInterface : Collections.list(NetworkInterface.getNetworkInterfaces())) {
+                try {
+                    if (networkInterface.isUp() && !networkInterface.isLoopback()
+                            && !networkInterface.isPointToPoint()) {
+                        result.add(networkInterface);
+                    }
+                } catch (SocketException exception) {
+                    // ignore
+                }
+            }
+        } catch (SocketException exception) {
+            return List.of();
+        }
+        return result;
+    }
+}
diff --git a/bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/GoveeHandler.java b/bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/GoveeHandler.java
new file mode 100644 (file)
index 0000000..1694541
--- /dev/null
@@ -0,0 +1,329 @@
+/**
+ * Copyright (c) 2010-2023 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.govee.internal;
+
+import static org.openhab.binding.govee.internal.GoveeBindingConstants.*;
+
+import java.io.IOException;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.govee.internal.model.Color;
+import org.openhab.binding.govee.internal.model.ColorData;
+import org.openhab.binding.govee.internal.model.EmptyValueQueryStatusData;
+import org.openhab.binding.govee.internal.model.GenericGoveeMsg;
+import org.openhab.binding.govee.internal.model.GenericGoveeRequest;
+import org.openhab.binding.govee.internal.model.StatusResponse;
+import org.openhab.binding.govee.internal.model.ValueIntData;
+import org.openhab.core.library.types.HSBType;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.PercentType;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.library.unit.Units;
+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.binding.BaseThingHandler;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.RefreshType;
+import org.openhab.core.util.ColorUtil;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonSyntaxException;
+
+/**
+ * The {@link GoveeHandler} is responsible for handling commands, which are
+ * sent to one of the channels.
+ *
+ * Any device has its own job that triggers a refresh of retrieving the external state from the device.
+ * However, there must be only one job that listens for all devices in a singleton thread because
+ * all devices send their udp packet response to the same port on openHAB. Based on the sender IP address
+ * of the device we can detect to which thing the status answer needs to be assigned to and updated.
+ *
+ * <ul>
+ * <li>The job per thing that triggers a new update is called <i>triggerStatusJob</i>. There are as many instances
+ * as things.</li>
+ * <li>The job that receives the answers and applies that to the respective thing is called <i>refreshStatusJob</i> and
+ * there is only one for all instances. It may be stopped and restarted by the DiscoveryService (see below).</li>
+ * </ul>
+ *
+ * The other topic that needs to be managed is that device discovery responses are also sent to openHAB at the same port
+ * as status updates. Therefore, when scanning new devices that job that listens to status devices must
+ * be stopped while scanning new devices. Otherwise, the status job will receive the scan discover UDB packages.
+ *
+ * Controlling the lights is done via the Govee LAN API (cloud is not supported):
+ * https://app-h5.govee.com/user-manual/wlan-guide
+ *
+ * @author Stefan Höhn - Initial contribution
+ */
+@NonNullByDefault
+public class GoveeHandler extends BaseThingHandler {
+
+    /*
+     * Messages to be sent to the Govee devices
+     */
+    private static final Gson GSON = new Gson();
+
+    private final Logger logger = LoggerFactory.getLogger(GoveeHandler.class);
+
+    @Nullable
+    private ScheduledFuture<?> triggerStatusJob; // send device status update job
+    private GoveeConfiguration goveeConfiguration = new GoveeConfiguration();
+
+    private CommunicationManager communicationManager;
+
+    private int lastOnOff;
+    private int lastBrightness;
+    private HSBType lastColor = new HSBType();
+    private int lastColorTempInKelvin = COLOR_TEMPERATURE_MIN_VALUE.intValue();
+
+    /**
+     * This thing related job <i>thingRefreshSender</i> triggers an update to the Govee device.
+     * The device sends it back to the common port and the response is
+     * then received by the common #refreshStatusReceiver
+     */
+    private final Runnable thingRefreshSender = () -> {
+        try {
+            triggerDeviceStatusRefresh();
+            if (!thing.getStatus().equals(ThingStatus.ONLINE)) {
+                updateStatus(ThingStatus.ONLINE);
+            }
+        } catch (IOException e) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
+                    "@text/offline.communication-error.could-not-query-device [\"" + goveeConfiguration.hostname
+                            + "\"]");
+        }
+    };
+
+    public GoveeHandler(Thing thing, CommunicationManager communicationManager) {
+        super(thing);
+        this.communicationManager = communicationManager;
+    }
+
+    public String getHostname() {
+        return goveeConfiguration.hostname;
+    }
+
+    @Override
+    public void initialize() {
+        goveeConfiguration = getConfigAs(GoveeConfiguration.class);
+
+        final String ipAddress = goveeConfiguration.hostname;
+        if (ipAddress.isEmpty()) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+                    "@text/offline.configuration-error.ip-address.missing");
+            return;
+        }
+        updateStatus(ThingStatus.UNKNOWN);
+        communicationManager.registerHandler(this);
+        if (triggerStatusJob == null) {
+            logger.debug("Starting refresh trigger job for thing {} ", thing.getLabel());
+
+            triggerStatusJob = scheduler.scheduleWithFixedDelay(thingRefreshSender, 100,
+                    goveeConfiguration.refreshInterval * 1000L, TimeUnit.MILLISECONDS);
+        }
+    }
+
+    @Override
+    public void dispose() {
+        super.dispose();
+
+        ScheduledFuture<?> triggerStatusJobFuture = triggerStatusJob;
+        if (triggerStatusJobFuture != null) {
+            triggerStatusJobFuture.cancel(true);
+            triggerStatusJob = null;
+        }
+        communicationManager.unregisterHandler(this);
+    }
+
+    @Override
+    public void handleCommand(ChannelUID channelUID, Command command) {
+        try {
+            if (command instanceof RefreshType) {
+                // we are refreshing all channels at once, as we get all information at the same time
+                triggerDeviceStatusRefresh();
+                logger.debug("Triggering Refresh");
+            } else {
+                logger.debug("Channel ID {} type {}", channelUID.getId(), command.getClass());
+                switch (channelUID.getId()) {
+                    case CHANNEL_COLOR:
+                        if (command instanceof HSBType hsbCommand) {
+                            int[] rgb = ColorUtil.hsbToRgb(hsbCommand);
+                            sendColor(new Color(rgb[0], rgb[1], rgb[2]));
+                        } else if (command instanceof PercentType percent) {
+                            sendBrightness(percent.intValue());
+                        } else if (command instanceof OnOffType onOffCommand) {
+                            sendOnOff(onOffCommand);
+                        }
+                        break;
+                    case CHANNEL_COLOR_TEMPERATURE:
+                        if (command instanceof PercentType percent) {
+                            logger.debug("COLOR_TEMPERATURE: Color Temperature change with Percent Type {}", command);
+                            Double colorTemp = (COLOR_TEMPERATURE_MIN_VALUE + percent.intValue()
+                                    * (COLOR_TEMPERATURE_MAX_VALUE - COLOR_TEMPERATURE_MIN_VALUE) / 100.0);
+                            lastColorTempInKelvin = colorTemp.intValue();
+                            logger.debug("lastColorTempInKelvin {}", lastColorTempInKelvin);
+                            sendColorTemp(lastColorTempInKelvin);
+                        }
+                        break;
+                    case CHANNEL_COLOR_TEMPERATURE_ABS:
+                        if (command instanceof QuantityType<?> quantity) {
+                            logger.debug("Color Temperature Absolute change with Percent Type {}", command);
+                            lastColorTempInKelvin = quantity.intValue();
+                            logger.debug("COLOR_TEMPERATURE_ABS: lastColorTempInKelvin {}", lastColorTempInKelvin);
+                            int lastColorTempInPercent = ((Double) ((lastColorTempInKelvin
+                                    - COLOR_TEMPERATURE_MIN_VALUE)
+                                    / (COLOR_TEMPERATURE_MAX_VALUE - COLOR_TEMPERATURE_MIN_VALUE) * 100.0)).intValue();
+                            logger.debug("computed lastColorTempInPercent {}", lastColorTempInPercent);
+                            sendColorTemp(lastColorTempInKelvin);
+                        }
+                        break;
+                }
+            }
+            if (!thing.getStatus().equals(ThingStatus.ONLINE)) {
+                updateStatus(ThingStatus.ONLINE);
+            }
+        } catch (IOException e) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
+                    "@text/offline.communication-error.could-not-query-device [\"" + goveeConfiguration.hostname
+                            + "\"]");
+        }
+    }
+
+    /**
+     * Initiate a refresh to our thing devicee
+     *
+     */
+    private void triggerDeviceStatusRefresh() throws IOException {
+        logger.debug("trigger Refresh Status of device {}", thing.getLabel());
+        GenericGoveeRequest lightQuery = new GenericGoveeRequest(
+                new GenericGoveeMsg("devStatus", new EmptyValueQueryStatusData()));
+        communicationManager.sendRequest(this, lightQuery);
+    }
+
+    public void sendColor(Color color) throws IOException {
+        lastColor = ColorUtil.rgbToHsb(new int[] { color.r(), color.g(), color.b() });
+
+        GenericGoveeRequest lightColor = new GenericGoveeRequest(
+                new GenericGoveeMsg("colorwc", new ColorData(color, 0)));
+        communicationManager.sendRequest(this, lightColor);
+    }
+
+    public void sendBrightness(int brightness) throws IOException {
+        lastBrightness = brightness;
+        GenericGoveeRequest lightBrightness = new GenericGoveeRequest(
+                new GenericGoveeMsg("brightness", new ValueIntData(brightness)));
+        communicationManager.sendRequest(this, lightBrightness);
+    }
+
+    private void sendOnOff(OnOffType switchValue) throws IOException {
+        lastOnOff = (switchValue == OnOffType.ON) ? 1 : 0;
+        GenericGoveeRequest switchLight = new GenericGoveeRequest(
+                new GenericGoveeMsg("turn", new ValueIntData(lastOnOff)));
+        communicationManager.sendRequest(this, switchLight);
+    }
+
+    private void sendColorTemp(int colorTemp) throws IOException {
+        lastColorTempInKelvin = colorTemp;
+        logger.debug("sendColorTemp {}", colorTemp);
+        GenericGoveeRequest lightColor = new GenericGoveeRequest(
+                new GenericGoveeMsg("colorwc", new ColorData(new Color(0, 0, 0), colorTemp)));
+        communicationManager.sendRequest(this, lightColor);
+    }
+
+    /**
+     * Creates a Color state by using the last color information from lastColor
+     * The brightness is overwritten either by the provided lastBrightness
+     * or if lastOnOff = 0 (off) then the brightness is set 0
+     *
+     * @see #lastColor
+     * @see #lastBrightness
+     * @see #lastOnOff
+     *
+     * @return the computed state
+     */
+    private HSBType getColorState(Color color, int brightness) {
+        PercentType computedBrightness = lastOnOff == 0 ? new PercentType(0) : new PercentType(brightness);
+        int[] rgb = { color.r(), color.g(), color.b() };
+        HSBType hsb = ColorUtil.rgbToHsb(rgb);
+        return new HSBType(hsb.getHue(), hsb.getSaturation(), computedBrightness);
+    }
+
+    void handleIncomingStatus(String response) {
+        if (response.isEmpty()) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
+                    "@text/offline.communication-error.empty-response");
+            return;
+        }
+
+        try {
+            StatusResponse statusMessage = GSON.fromJson(response, StatusResponse.class);
+            if (statusMessage != null) {
+                updateDeviceState(statusMessage);
+            }
+            updateStatus(ThingStatus.ONLINE);
+        } catch (JsonSyntaxException jse) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, jse.getMessage());
+        }
+    }
+
+    public void updateDeviceState(@Nullable StatusResponse message) {
+        if (message == null) {
+            return;
+        }
+
+        logger.trace("Receiving Device State");
+        int newOnOff = message.msg().data().onOff();
+        logger.trace("newOnOff = {}", newOnOff);
+        int newBrightness = message.msg().data().brightness();
+        logger.trace("newBrightness = {}", newBrightness);
+        Color newColor = message.msg().data().color();
+        logger.trace("newColor = {}", newColor);
+        int newColorTempInKelvin = message.msg().data().colorTemInKelvin();
+        logger.trace("newColorTempInKelvin = {}", newColorTempInKelvin);
+
+        newColorTempInKelvin = (newColorTempInKelvin < COLOR_TEMPERATURE_MIN_VALUE)
+                ? COLOR_TEMPERATURE_MIN_VALUE.intValue()
+                : newColorTempInKelvin;
+        int newColorTempInPercent = ((Double) ((newColorTempInKelvin - COLOR_TEMPERATURE_MIN_VALUE)
+                / (COLOR_TEMPERATURE_MAX_VALUE - COLOR_TEMPERATURE_MIN_VALUE) * 100.0)).intValue();
+
+        HSBType adaptedColor = getColorState(newColor, newBrightness);
+
+        logger.trace("HSB old: {} vs adaptedColor: {}", lastColor, adaptedColor);
+        // avoid noise by only updating if the value has changed on the device
+        if (!adaptedColor.equals(lastColor)) {
+            logger.trace("UPDATING HSB old: {} != {}", lastColor, adaptedColor);
+            updateState(CHANNEL_COLOR, adaptedColor);
+        }
+
+        // avoid noise by only updating if the value has changed on the device
+        logger.trace("Color-Temperature Status: old: {} K {}% vs new: {} K", lastColorTempInKelvin,
+                newColorTempInPercent, newColorTempInKelvin);
+        if (newColorTempInKelvin != lastColorTempInKelvin) {
+            logger.trace("Color-Temperature Status: old: {} K {}% vs new: {} K", lastColorTempInKelvin,
+                    newColorTempInPercent, newColorTempInKelvin);
+            updateState(CHANNEL_COLOR_TEMPERATURE_ABS, new QuantityType<>(lastColorTempInKelvin, Units.KELVIN));
+            updateState(CHANNEL_COLOR_TEMPERATURE, new PercentType(newColorTempInPercent));
+        }
+
+        lastOnOff = newOnOff;
+        lastColor = adaptedColor;
+        lastBrightness = newBrightness;
+    }
+}
diff --git a/bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/GoveeHandlerFactory.java b/bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/GoveeHandlerFactory.java
new file mode 100644 (file)
index 0000000..120610f
--- /dev/null
@@ -0,0 +1,63 @@
+/**
+ * Copyright (c) 2010-2023 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.govee.internal;
+
+import static org.openhab.binding.govee.internal.GoveeBindingConstants.THING_TYPE_LIGHT;
+
+import java.util.Set;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+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 GoveeHandlerFactory} is responsible for creating things and thing
+ * handlers.
+ *
+ * @author Stefan Höhn - Initial contribution
+ */
+@NonNullByDefault
+@Component(configurationPid = "binding.govee", service = ThingHandlerFactory.class)
+public class GoveeHandlerFactory extends BaseThingHandlerFactory {
+    private static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_LIGHT);
+
+    private CommunicationManager communicationManager;
+
+    @Activate
+    public GoveeHandlerFactory(@Reference CommunicationManager communicationManager) {
+        this.communicationManager = communicationManager;
+    }
+
+    @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_LIGHT.equals(thingTypeUID)) {
+            return new GoveeHandler(thing, communicationManager);
+        }
+
+        return null;
+    }
+}
diff --git a/bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/model/Color.java b/bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/model/Color.java
new file mode 100644 (file)
index 0000000..88c8dd4
--- /dev/null
@@ -0,0 +1,25 @@
+/**
+ * Copyright (c) 2010-2023 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.govee.internal.model;
+
+/**
+ *
+ * @param r red
+ * @param g green
+ * @param b blue
+ *
+ * @author Stefan Höhn - Initial contribution
+ */
+public record Color(int r, int g, int b) {
+}
diff --git a/bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/model/ColorData.java b/bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/model/ColorData.java
new file mode 100644 (file)
index 0000000..c46fded
--- /dev/null
@@ -0,0 +1,28 @@
+/**
+ * Copyright (c) 2010-2023 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.govee.internal.model;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Color Data
+ * 
+ * @param color
+ * @param colorTemInKelvin
+ *
+ * @author Stefan Höhn - Initial contribution
+ */
+@NonNullByDefault
+public record ColorData(Color color, int colorTemInKelvin) implements GenericGoveeData {
+}
diff --git a/bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/model/DiscoveryData.java b/bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/model/DiscoveryData.java
new file mode 100644 (file)
index 0000000..24d0411
--- /dev/null
@@ -0,0 +1,37 @@
+/**
+ * Copyright (c) 2010-2023 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.govee.internal.model;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Govee Message - Device information
+ *
+ * @param ip IP address of the device
+ * @param device mac Address
+ * @param sku article number
+ * @param bleVersionHard Bluetooth HW version
+ * @param bleVersionSoft Bluetooth SW version
+ * @param wifiVersionHard Wifi HW version
+ * @param wifiVersionSoft Wife SW version
+ *
+ * @author Stefan Höhn - Initial contribution
+ */
+@NonNullByDefault
+public record DiscoveryData(String ip, String device, String sku, String bleVersionHard, String bleVersionSoft,
+        String wifiVersionHard, String wifiVersionSoft) {
+    public DiscoveryData() {
+        this("", "", "", "", "", "", "");
+    }
+}
diff --git a/bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/model/DiscoveryMsg.java b/bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/model/DiscoveryMsg.java
new file mode 100644 (file)
index 0000000..f5bf218
--- /dev/null
@@ -0,0 +1,31 @@
+/**
+ * Copyright (c) 2010-2023 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.govee.internal.model;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Govee Message
+ * 
+ * @param cmd
+ * @param data
+ *
+ * @author Stefan Höhn - Initial contribution
+ */
+@NonNullByDefault
+public record DiscoveryMsg(String cmd, DiscoveryData data) {
+    public DiscoveryMsg() {
+        this("", new DiscoveryData());
+    }
+}
diff --git a/bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/model/DiscoveryResponse.java b/bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/model/DiscoveryResponse.java
new file mode 100644 (file)
index 0000000..a1b7ae5
--- /dev/null
@@ -0,0 +1,30 @@
+/**
+ * Copyright (c) 2010-2023 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.govee.internal.model;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Govee Message
+ * 
+ * @param msg message block
+ *
+ * @author Stefan Höhn - Initial contribution
+ */
+@NonNullByDefault
+public record DiscoveryResponse(DiscoveryMsg msg) {
+    public DiscoveryResponse() {
+        this(new DiscoveryMsg());
+    }
+}
diff --git a/bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/model/EmptyValueQueryStatusData.java b/bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/model/EmptyValueQueryStatusData.java
new file mode 100644 (file)
index 0000000..e04b4a0
--- /dev/null
@@ -0,0 +1,26 @@
+/**
+ * Copyright (c) 2010-2023 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.govee.internal.model;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Empty Govee Value Data
+ * Used to query device data
+ *
+ * @author Stefan Höhn - Initial contribution
+ */
+@NonNullByDefault
+public record EmptyValueQueryStatusData() implements GenericGoveeData {
+}
diff --git a/bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/model/GenericGoveeData.java b/bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/model/GenericGoveeData.java
new file mode 100644 (file)
index 0000000..bd8f6af
--- /dev/null
@@ -0,0 +1,27 @@
+/**
+ * Copyright (c) 2010-2023 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.govee.internal.model;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Govee Data Interface
+ *
+ * can hold different type of data content
+ *
+ * @author Stefan Höhn - Initial contribution
+ */
+@NonNullByDefault
+public interface GenericGoveeData {
+}
diff --git a/bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/model/GenericGoveeMsg.java b/bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/model/GenericGoveeMsg.java
new file mode 100644 (file)
index 0000000..89a5b31
--- /dev/null
@@ -0,0 +1,30 @@
+/**
+ * Copyright (c) 2010-2023 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.govee.internal.model;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Generic Govee Data
+ *
+ * can hold different types of data with the command
+ *
+ * @param cmd
+ * @param data
+ *
+ * @author Stefan Höhn - Initial contribution
+ */
+@NonNullByDefault
+public record GenericGoveeMsg(String cmd, GenericGoveeData data) {
+}
diff --git a/bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/model/GenericGoveeRequest.java b/bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/model/GenericGoveeRequest.java
new file mode 100644 (file)
index 0000000..f8bb47b
--- /dev/null
@@ -0,0 +1,27 @@
+/**
+ * Copyright (c) 2010-2023 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.govee.internal.model;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Govee Message
+ * 
+ * @param msg message block
+ *
+ * @author Stefan Höhn - Initial contribution
+ */
+@NonNullByDefault
+public record GenericGoveeRequest(GenericGoveeMsg msg) {
+}
diff --git a/bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/model/StatusData.java b/bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/model/StatusData.java
new file mode 100644 (file)
index 0000000..33941a2
--- /dev/null
@@ -0,0 +1,26 @@
+/**
+ * Copyright (c) 2010-2023 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.govee.internal.model;
+
+/**
+ *
+ * @param onOff on=1 off=0
+ * @param brightness brightness
+ * @param color rgb color
+ * @param colorTemInKelvin color temperature in Kelvin
+ *
+ * @author Stefan Höhn - Initial contribution
+ */
+public record StatusData(int onOff, int brightness, Color color, int colorTemInKelvin) {
+}
diff --git a/bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/model/StatusMsg.java b/bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/model/StatusMsg.java
new file mode 100644 (file)
index 0000000..8beb133
--- /dev/null
@@ -0,0 +1,25 @@
+/**
+ * Copyright (c) 2010-2023 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.govee.internal.model;
+
+/**
+ * Govee Message - Cmd
+ *
+ * @param cmd Query Command
+ * @param data Status data
+ *
+ * @author Stefan Höhn - Initial contribution
+ */
+public record StatusMsg(String cmd, StatusData data) {
+}
diff --git a/bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/model/StatusResponse.java b/bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/model/StatusResponse.java
new file mode 100644 (file)
index 0000000..19286ec
--- /dev/null
@@ -0,0 +1,24 @@
+/**
+ * Copyright (c) 2010-2023 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.govee.internal.model;
+
+/**
+ * Govee Message
+ * 
+ * @param msg message block
+ *
+ * @author Stefan Höhn - Initial contribution
+ */
+public record StatusResponse(StatusMsg msg) {
+}
diff --git a/bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/model/ValueIntData.java b/bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/model/ValueIntData.java
new file mode 100644 (file)
index 0000000..81dcf25
--- /dev/null
@@ -0,0 +1,28 @@
+/**
+ * Copyright (c) 2010-2023 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.govee.internal.model;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Simple Govee Value Data
+ * typically used for On / Off
+ * 
+ * @param value
+ *
+ * @author Stefan Höhn - Initial contribution
+ */
+@NonNullByDefault
+public record ValueIntData(int value) implements GenericGoveeData {
+}
diff --git a/bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/model/ValueStringData.java b/bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/model/ValueStringData.java
new file mode 100644 (file)
index 0000000..08ca5a0
--- /dev/null
@@ -0,0 +1,28 @@
+/**
+ * Copyright (c) 2010-2023 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.govee.internal.model;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Simple Govee Value Data
+ * typically used for On / Off
+ * 
+ * @param value
+ *
+ * @author Stefan Höhn - Initial contribution
+ */
+@NonNullByDefault
+public record ValueStringData(String value) implements GenericGoveeData {
+}
diff --git a/bundles/org.openhab.binding.govee/src/main/resources/OH-INF/addon/addon.xml b/bundles/org.openhab.binding.govee/src/main/resources/OH-INF/addon/addon.xml
new file mode 100644 (file)
index 0000000..111e51a
--- /dev/null
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<addon:addon id="govee" 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>Govee Lan-API Binding</name>
+       <description>This is the binding for handling Govee Lights via the LAN-API interface.</description>
+       <connection>local</connection>
+</addon:addon>
diff --git a/bundles/org.openhab.binding.govee/src/main/resources/OH-INF/config/config.xml b/bundles/org.openhab.binding.govee/src/main/resources/OH-INF/config/config.xml
new file mode 100644 (file)
index 0000000..e153efa
--- /dev/null
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<config-description:config-descriptions
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:config-description="https://openhab.org/schemas/config-description/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/config-description/v1.0.0 https://openhab.org/schemas/config-description-1.0.0.xsd">
+
+       <config-description uri="thing-type:govee:govee-light">
+               <parameter name="hostname" type="text" required="true">
+                       <context>network-address</context>
+                       <label>Hostname/IP Address</label>
+                       <description>Hostname or IP address of the device</description>
+               </parameter>
+               <parameter name="macAddress" type="text" required="true">
+                       <label>MAC Address</label>
+                       <description>MAC Address of the device</description>
+               </parameter>
+               <parameter name="refreshInterval" type="integer" unit="s">
+                       <label>Light Refresh Interval</label>
+                       <description>The amount of time that passes until the device is refreshed (in seconds)</description>
+                       <default>2</default>
+               </parameter>
+       </config-description>
+
+</config-description:config-descriptions>
diff --git a/bundles/org.openhab.binding.govee/src/main/resources/OH-INF/i18n/govee.properties b/bundles/org.openhab.binding.govee/src/main/resources/OH-INF/i18n/govee.properties
new file mode 100644 (file)
index 0000000..b600839
--- /dev/null
@@ -0,0 +1,80 @@
+# add-on
+
+addon.name = Govee Binding
+addon.description = This is the binding for handling Govee Lights via the LAN-API interface.
+
+# thing types
+
+thing-type.govee-light.label = Govee Light Thing
+thing-type.govee-light.description = Govee Light controllable via LAN API
+
+# thing types config
+
+thing-type.config.govee-light.refreshInterval.label = Light refresh interval (sec)
+thing-type.config.govee-light.refreshInterval.description = The amount of time that passes until the device is refreshed
+
+# product names
+
+discovery.govee-light.H619Z = H619Z RGBIC Pro LED Strip Lights
+discovery.govee-light.H6046 = H6046 RGBIC TV Light Bars
+discovery.govee-light.H6047 = H6047 RGBIC Gaming Light Bars with Smart Controller
+discovery.govee-light.H6061 = H6061 Glide Hexa LED Panels
+discovery.govee-light.H6062 = H6062 Glide Wall Light
+discovery.govee-light.H6065 = H6065 Glide RGBIC Y Lights
+discovery.govee-light.H6066 = H6066 Glide Hexa Pro LED Panel
+discovery.govee-light.H6067 = H6067 Glide Triangle Light Panels
+discovery.govee-light.H6072 = H6072 RGBICWW Corner Floor Lamp
+discovery.govee-light.H6076 = H6076 RGBICW Smart Corner Floor Lamp
+discovery.govee-light.H6073 = H6073 LED Floor Lamp
+discovery.govee-light.H6078 = H6078 Cylinder Floor Lamp
+discovery.govee-light.H6087 = H6087 RGBIC Smart Wall Sconces
+discovery.govee-light.H6173 = H6173 RGBIC Outdoor Strip Lights
+discovery.govee-light.H619A = H619A RGBIC Strip Lights With Protective Coating 5M
+discovery.govee-light.H619B = H619B RGBIC LED Strip Lights With Protective Coating
+discovery.govee-light.H619C = H619C LED Strip Lights With Protective Coating
+discovery.govee-light.H619D = H619D RGBIC PRO LED Strip Lights
+discovery.govee-light.H619E = H619E RGBIC LED Strip Lights With Protective Coating
+discovery.govee-light.H61A0 = H61A0 RGBIC Neon Rope Light 1M
+discovery.govee-light.H61A1 = H61A1 RGBIC Neon Rope Light 2M
+discovery.govee-light.H61A2 = H61A2 RGBIC Neon Rope Light 5M
+discovery.govee-light.H61A3 = H61A3 RGBIC Neon Rope Light
+discovery.govee-light.H61A5 = H61A5 Neon LED Strip Light 10
+discovery.govee-light.H61A8 = H61A8Neon Neon Rope Light 10
+discovery.govee-light.H618A = H618A RGBIC Basic LED Strip Lights 5M
+discovery.govee-light.H618C = H618C RGBIC Basic LED Strip Lights 5M
+discovery.govee-light.H6117 = H6117 Dream Color LED Strip Light 10M
+discovery.govee-light.H6159 = H6159 RGB Light Strip
+discovery.govee-light.H615E = H615E LED Strip Lights 30M
+discovery.govee-light.H6163 = H6163 Dreamcolor LED Strip Light 5M
+discovery.govee-light.H610A = H610A Glide Lively Wall Lights
+discovery.govee-light.H610B = H610B Music Wall Lights
+discovery.govee-light.H6172 = H6172 Outdoor LED Strip 10m
+discovery.govee-light.H61B2 = H61B2 RGBIC Neon TV Backlight
+discovery.govee-light.H61E1 = H61E1 LED Strip Light M1
+discovery.govee-light.H7012 = H7012 Warm White Outdoor String Lights
+discovery.govee-light.H7013 = H7013 Warm White Outdoor String Lights
+discovery.govee-light.H7021 = H7021 RGBIC Warm White Smart Outdoor String
+discovery.govee-light.H7028 = H7028 Lynx Dream LED-Bulb String
+discovery.govee-light.H7041 = H7041 LED Outdoor Bulb String Lights
+discovery.govee-light.H7042 = H7042 LED Outdoor Bulb String Lights
+discovery.govee-light.H705A = H705A Permanent Outdoor Lights 30M
+discovery.govee-light.H705B = H705B Permanent Outdoor Lights 15M
+discovery.govee-light.H7050 = H7050 Outdoor Ground Lights 11M
+discovery.govee-light.H7051 = H7051 Outdoor Ground Lights 15M
+discovery.govee-light.H7055 = H7055 Pathway Light
+discovery.govee-light.H7060 = H7060 LED Flood Lights (2-Pack)
+discovery.govee-light.H7061 = H7061 LED Flood Lights (4-Pack)
+discovery.govee-light.H7062 = H7062 LED Flood Lights (6-Pack)
+discovery.govee-light.H7065 = H7065 Outdoor Spot Lights
+discovery.govee-light.H6051 = H6051 Aura - Smart Table Lamp
+discovery.govee-light.H6056 = H6056 H6056 Flow Plus
+discovery.govee-light.H6059 = H6059 RGBWW Night Light for Kids
+discovery.govee-light.H618F = H618F RGBIC LED Strip Lights
+discovery.govee-light.H618E = H618E LED Strip Lights 22m
+discovery.govee-light.H6168 = H6168 TV LED Backlight
+
+# thing status descriptions
+
+offline.communication-error.could-not-query-device = Could not control/query device at IP address {0}
+offline.configuration-error.ip-address.missing = IP address is missing
+offline.communication-error.empty-response = Empty response received
diff --git a/bundles/org.openhab.binding.govee/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.govee/src/main/resources/OH-INF/thing/thing-types.xml
new file mode 100644 (file)
index 0000000..002cb29
--- /dev/null
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<thing:thing-descriptions bindingId="govee"
+       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="govee-light">
+               <label>Govee Light</label>
+               <description>Govee light controllable via LAN API</description>
+
+               <channels>
+                       <channel id="color" typeId="system.color"/>
+                       <channel id="color-temperature" typeId="system.color-temperature"/>
+                       <channel id="color-temperature-abs" typeId="color-temperature-abs"/>
+               </channels>
+               <config-description-ref uri="thing-type:govee:govee-light"/>
+       </thing-type>
+
+       <channel-type id="color-temperature-abs">
+               <item-type>Number:Temperature</item-type>
+               <label>Absolute Color Temperature </label>
+               <description>Controls the color temperature of the light in Kelvin</description>
+               <category>Temperature</category>
+               <tags>
+                       <tag>Control</tag>
+                       <tag>ColorTemperature</tag>
+               </tags>
+               <state min="2000" max="9000" pattern="%.0f K"/>
+       </channel-type>
+
+
+</thing:thing-descriptions>
diff --git a/bundles/org.openhab.binding.govee/src/test/java/org/openhab/binding/govee/internal/GoveeDiscoveryTest.java b/bundles/org.openhab.binding.govee/src/test/java/org/openhab/binding/govee/internal/GoveeDiscoveryTest.java
new file mode 100644 (file)
index 0000000..01c73d6
--- /dev/null
@@ -0,0 +1,64 @@
+/**
+ * Copyright (c) 2010-2023 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.govee.internal;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.Map;
+import java.util.Objects;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.junit.jupiter.api.Test;
+import org.openhab.binding.govee.internal.model.DiscoveryResponse;
+import org.openhab.core.config.discovery.DiscoveryResult;
+
+import com.google.gson.Gson;
+
+/**
+ * @author Stefan Höhn - Initial contribution
+ */
+@NonNullByDefault
+public class GoveeDiscoveryTest {
+
+    String response = """
+             {
+                "msg":{
+                   "cmd":"scan",
+                   "data":{
+                      "ip":"192.168.178.171",
+                      "device":"7D:31:C3:35:33:33:44:15",
+                      "sku":"H6076",
+                      "bleVersionHard":"3.01.01",
+                      "bleVersionSoft":"1.04.04",
+                      "wifiVersionHard":"1.00.10",
+                      "wifiVersionSoft":"1.02.11"
+                   }
+                }
+            }
+             """;
+
+    @Test
+    public void testProcessScanMessage() {
+        GoveeDiscoveryService service = new GoveeDiscoveryService(new CommunicationManager());
+        DiscoveryResponse resp = new Gson().fromJson(response, DiscoveryResponse.class);
+        Objects.requireNonNull(resp);
+        @Nullable
+        DiscoveryResult result = service.responseToResult(resp);
+        assertNotNull(result);
+        Map<String, Object> deviceProperties = result.getProperties();
+        assertEquals(deviceProperties.get(GoveeBindingConstants.DEVICE_TYPE), "H6076");
+        assertEquals(deviceProperties.get(GoveeBindingConstants.IP_ADDRESS), "192.168.178.171");
+        assertEquals(deviceProperties.get(GoveeBindingConstants.MAC_ADDRESS), "7D:31:C3:35:33:33:44:15");
+    }
+}
diff --git a/bundles/org.openhab.binding.govee/src/test/java/org/openhab/binding/govee/internal/GoveeSerializeTest.java b/bundles/org.openhab.binding.govee/src/test/java/org/openhab/binding/govee/internal/GoveeSerializeTest.java
new file mode 100644 (file)
index 0000000..1aededf
--- /dev/null
@@ -0,0 +1,57 @@
+/**
+ * Copyright (c) 2010-2023 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.govee.internal;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+import org.openhab.binding.govee.internal.model.Color;
+import org.openhab.binding.govee.internal.model.ColorData;
+import org.openhab.binding.govee.internal.model.EmptyValueQueryStatusData;
+import org.openhab.binding.govee.internal.model.GenericGoveeMsg;
+import org.openhab.binding.govee.internal.model.GenericGoveeRequest;
+import org.openhab.binding.govee.internal.model.ValueIntData;
+
+import com.google.gson.Gson;
+
+/**
+ * @author Stefan Höhn - Initial contribution
+ */
+@NonNullByDefault
+public class GoveeSerializeTest {
+
+    private static final Gson GSON = new Gson();
+    private final String lightOffJsonString = "{\"msg\":{\"cmd\":\"turn\",\"data\":{\"value\":0}}}";
+    private final String lightOnJsonString = "{\"msg\":{\"cmd\":\"brightness\",\"data\":{\"value\":100}}}";
+    private final String lightColorJsonString = "{\"msg\":{\"cmd\":\"colorwc\",\"data\":{\"color\":{\"r\":0,\"g\":1,\"b\":2},\"colorTemInKelvin\":3}}}";
+    private final String lightBrightnessJsonString = "{\"msg\":{\"cmd\":\"brightness\",\"data\":{\"value\":99}}}";
+    private final String lightQueryJsonString = "{\"msg\":{\"cmd\":\"devStatus\",\"data\":{}}}";
+
+    @Test
+    public void testSerializeMessage() {
+        GenericGoveeRequest lightOff = new GenericGoveeRequest(new GenericGoveeMsg("turn", new ValueIntData(0)));
+        assertEquals(lightOffJsonString, GSON.toJson(lightOff));
+        GenericGoveeRequest lightOn = new GenericGoveeRequest(new GenericGoveeMsg("brightness", new ValueIntData(100)));
+        assertEquals(lightOnJsonString, GSON.toJson(lightOn));
+        GenericGoveeRequest lightColor = new GenericGoveeRequest(
+                new GenericGoveeMsg("colorwc", new ColorData(new Color(0, 1, 2), 3)));
+        assertEquals(lightColorJsonString, GSON.toJson(lightColor));
+        GenericGoveeRequest lightBrightness = new GenericGoveeRequest(
+                new GenericGoveeMsg("brightness", new ValueIntData(99)));
+        assertEquals(lightBrightnessJsonString, GSON.toJson(lightBrightness));
+        GenericGoveeRequest lightQuery = new GenericGoveeRequest(
+                new GenericGoveeMsg("devStatus", new EmptyValueQueryStatusData()));
+        assertEquals(lightQueryJsonString, GSON.toJson(lightQuery));
+    }
+}
index 9d53ead3623efa7b4a678fff0e0042ce2d473673..a22c836ac6e2fb66b67ad1178660da519fe4b9d5 100644 (file)
     <module>org.openhab.binding.gce</module>
     <module>org.openhab.binding.generacmobilelink</module>
     <module>org.openhab.binding.goecharger</module>
+    <module>org.openhab.binding.govee</module>
     <module>org.openhab.binding.gpio</module>
     <module>org.openhab.binding.globalcache</module>
     <module>org.openhab.binding.gpstracker</module>