diff --git a/bundles/org.openhab.binding.govee/README.md b/bundles/org.openhab.binding.govee/README.md index 459f17aa5f333..25d2c288c4675 100644 --- a/bundles/org.openhab.binding.govee/README.md +++ b/bundles/org.openhab.binding.govee/README.md @@ -20,68 +20,133 @@ While Govee provides probably more than a hundred different lights, only the fol Here is a list of the supported devices (the ones marked with * have been tested by the author) -- H619Z RGBIC Pro LED Strip Lights +- H6042 Govee TV Light Bar #2 +- H6043 Govee TV Light Bars #2 - H6046 RGBIC TV Light Bars - H6047 RGBIC Gaming Light Bars with Smart Controller +- H6051 Aura - Smart Table Lamp +- H6052 Govee Table Lamp +- H6056 H6056 Flow Plus +- H6059 RGBWW Night Light for Kids - H6061 Glide Hexa LED Panels (*) - H6062 Glide Wall Light +- H6063 Gaming Wall Light - H6065 Glide RGBIC Y Lights - H6066 Glide Hexa Pro LED Panel - H6067 Glide Triangle Light Panels (*) +- H606A Glide Hexa Light Panel Ultra - H6072 RGBICWW Corner Floor Lamp (*) -- H6076 RGBICW Smart Corner Floor Lamp (*) - H6073 LED Floor Lamp +- H6076 RGBICW Smart Corner Floor Lamp (*) - H6078 Cylinder Floor Lamp +- H607C Floor Lamp #2 - 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 -- H61C5 RGBIC LED Neon Rope Lights for Desks (*) -- H61D3 Neon Rope Light 2 3M (*) -- H61D5 Neon Rope Light 2 5M (*) -- 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 +- H6088 RGBIC Cube Wall Sconces +- H608A String Downlights 5M +- H608B String Downlights 3M +- H608C String Downlights 2M +- H608D String Downlights 10M +- H60A0 Ceiling Light +- H60A1 Smart Ceiling Light (*) +- H610A Glide Lively Wall Lights +- H610B Music Wall Lights +- H6110 2x5M Multicolor with Alexa - H6117 Dream Color LED Strip Light 10M +- H6141 5M Smart Multicolor Strip Light +- H6143 5M Strip Light +- H6144 2x5M Strip Light - H6159 RGB Light Strip (*) -- H615E LED Strip Lights 30M +- H615A 5M Light Strip with Alexa (*) +- H615B 10M Light Strip with Alexa +- H615C 15M Light Strip with Alexa +- H615D 20M Light Strip with Alexa +- H615E 30M Light Strip with Alexa - H6163 Dreamcolor LED Strip Light 5M -- H610A Glide Lively Wall Lights -- H610B Music Wall Lights +- H6167 TV Backlight 2.4M +- H6168 TV Backlight 2x0.7M+2x1.2M +- H616C Outdoor Strip 10M +- H616D Outdoor Strip 2x7.5M +- H616E Outdoor Strip 2x10M - H6172 Outdoor LED Strip 10m -- H61B2 RGBIC Neon TV Backlight +- H6173 RGBIC Outdoor Strip Lights +- H6175 RGBIC Outdoor Strip Lights 10M +- H6176 RGBIC Outdoor Strip Lights 30M +- H6182 WiFi Multicolor TV Strip Light +- H618A RGBIC Basic LED Strip Lights 5M +- H618C RGBIC Basic LED Strip Lights 5M +- H618E LED Strip Lights 22m +- H618F RGBIC LED Strip Lights +- H619A Strip Lights With Protective Coating 5M +- H619B Strip Lights With Protective Coating 7.5M +- H619C Strip Lights With Protective Coating with Alexa 10M +- H619D PRO LED Strip Lights with Alexa 2x7.5M +- H619E Strip Lights With Protective Coating with Alexa 2x10M +- H619Z Pro LED Strip Lights 3M +- H61A0 RGBIC Neon Rope Light 3M +- H61A1 RGBIC Neon Rope Light 2M +- H61A2 RGBIC Neon Rope Light 5M +- H61A3 RGBIC Neon Rope Light 4M +- H61A5 Neon LED Strip Light 10M +- H61A8 Neon Rope Light 10M +- H61A8 Neon Rope Light 20M +- H61B1 Strip Light with Cover 5M +- H61B2 RGBIC Neon TV Backlight 3M +- H61BA LED Strip Light 5M +- H61BC LED Strip Light 10M +- H61BE LED Strip Light 2x10M +- H61C2 Neon LED Strip Light 2M +- H61C2 Neon LED Strip Light 3M +- H61C2 Neon LED Strip Light 5M +- H61D3 Neon Rope Light 2 3m (*) +- H61D5 Neon Rope Light 2 5m (*) +- H61E0 LED Strip Light M1 - 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 +- H7033 LED-Bulb String Lights - 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 +- H7052 Outdoor Ground Lights 15M +- H7052 Outdoor Ground Lights 30M - H7055 Pathway Light +- H705A Permanent Outdoor Lights 30M +- H705B Permanent Outdoor Lights 15M +- H705C Permanent Outdoor Lights 45M +- H705D Permanent Outdoor Lights #2 15M +- H705E Permanent Outdoor Lights #2 30M +- H705F Permanent Outdoor Lights #2 45M - H7060 LED Flood Lights (2-Pack) - H7061 LED Flood Lights (4-Pack) - H7062 LED Flood Lights (6-Pack) +- H7063 Outdoor Flood Lights - 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 +- H7066 Outdoor Spot Lights +- H706A Permanent Outdoor Lights Pro 30M +- H706B Permanent Outdoor Lights Pro 45M +- H706C Permanent Outdoor Lights Pro 60M +- H7070 Outdoor Projector Light (*) +- H7075 Outdoor Wall Light +- H70B1 520 LED Curtain Lights +- H70BC 400 LED Curtain Lights +- H70C1 RGBIC String Light 10M (*) +- H70C2 RGBIC String Light 20M (*) +- H805A Permanent Outdoor Lights Elite 30M +- H805B Permanent Outdoor Lights Elite 15M +- H805C Permanent Outdoor Lights Elite 45M + +## Firewall + +Govee devices communicate via multicast and unicast messages over the LAN. +So you must ensure that any firewall on your openHAB server is configured to pass the following traffic: + +- Multicast UDP on 239.255.255.250 port 4001 +- Incoming unicast UDP on port 4002 +- Outgoing unicast UDP on port 4003 + ## Discovery Discovery is done by scanning the devices in the Thing section. @@ -108,11 +173,13 @@ 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 | -| refreshInterval | integer | Interval the device is polled in sec. | 5 | no | yes | +| 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 | +| refreshInterval | integer | Interval the device is polled in sec. | 5 | no | yes | +| minKelvin | integer | The minimum color temperature that the light supports in Kelvin. | N/A | no | yes | +| maxKelvin | integer | The maximum color temperature that the light supports in Kelvin. | N/A | no | yes | ## Channels 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 index c8cdee364b464..abecfb6ab1697 100644 --- 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 @@ -15,13 +15,23 @@ import java.io.IOException; import java.net.DatagramPacket; import java.net.DatagramSocket; +import java.net.Inet4Address; import java.net.InetAddress; import java.net.InetSocketAddress; -import java.net.MulticastSocket; import java.net.NetworkInterface; +import java.net.SocketAddress; +import java.net.StandardProtocolFamily; +import java.net.StandardSocketOptions; +import java.net.UnknownHostException; +import java.nio.ByteBuffer; +import java.nio.channels.ClosedByInterruptException; +import java.nio.channels.DatagramChannel; +import java.time.Duration; import java.time.Instant; -import java.util.HashMap; +import java.util.Collections; +import java.util.List; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; @@ -29,6 +39,7 @@ import org.openhab.binding.govee.internal.model.GenericGoveeRequest; import org.osgi.service.component.annotations.Activate; import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Deactivate; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -36,231 +47,259 @@ 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. + * The {@link CommunicationManager} component implements a sender to send commands to Govee devices, + * and implements a thread that handles the notifications from all devices. It applies the status + * information to the right Thing. It supports both discovery and status commands and notifications + * concurrently. * * @author Stefan Höhn - Initial contribution * @author Danny Baumann - Thread-Safe design refactoring + * @author Andrew Fiddian-Green - New threading model using java.nio channel */ @NonNullByDefault @Component(service = CommunicationManager.class) public class CommunicationManager { private final Logger logger = LoggerFactory.getLogger(CommunicationManager.class); 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 thingHandlers = new HashMap<>(); - @Nullable - private StatusReceiver receiverThread; + + // list of Thing handler listeners that will receive state notifications + private final Map thingHandlerListeners = new ConcurrentHashMap<>(); + + private @Nullable GoveeDiscoveryListener discoveryListener; + private @Nullable Thread serverThread; + private boolean serverStopFlag = false; + + private final Object paramsLock = new Object(); + private final Object serverLock = new Object(); + private final Object senderLock = new Object(); 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; + public static final int SCAN_TIMEOUT_SEC = 5; private static final String DISCOVER_REQUEST = "{\"msg\": {\"cmd\": \"scan\", \"data\": {\"account_topic\": \"reserve\"}}}"; - public interface DiscoveryResultReceiver { - void onResultReceived(DiscoveryResponse result); + private static final InetSocketAddress DISCOVERY_SOCKET_ADDRESS = new InetSocketAddress(DISCOVERY_MULTICAST_ADDRESS, + DISCOVERY_PORT); + + public interface GoveeDiscoveryListener { + void onDiscoveryResponse(DiscoveryResponse discoveryResponse); } @Activate public CommunicationManager() { + serverStart(); + } + + @Deactivate + public void deactivate() { + thingHandlerListeners.clear(); + discoveryListener = null; + serverStop(); } + /** + * Thing handlers register themselves to receive state updates when they are initialized. + */ public void registerHandler(GoveeHandler handler) { - synchronized (thingHandlers) { - thingHandlers.put(handler.getHostname(), handler); - if (receiverThread == null) { - receiverThread = new StatusReceiver(); - receiverThread.start(); - } - } + thingHandlerListeners.put(ipAddressFrom(handler.getHostname()), handler); } + /** + * Thing handlers unregister themselves when they are destroyed. + */ public void unregisterHandler(GoveeHandler handler) { - synchronized (thingHandlers) { - thingHandlers.remove(handler.getHostname()); - if (thingHandlers.isEmpty()) { - StatusReceiver receiver = receiverThread; - if (receiver != null) { - receiver.stopReceiving(); - } - receiverThread = null; - } - } + thingHandlerListeners.remove(ipAddressFrom(handler.getHostname())); } + /** + * Send a unicast command request to the device. + */ 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.trace("Sending {} to {}", message, hostname); - socket.send(packet); - socket.close(); + serverStart(); + synchronized (senderLock) { + try (DatagramSocket socket = new DatagramSocket()) { + socket.setReuseAddress(true); + String message = gson.toJson(request); + byte[] data = message.getBytes(); + String hostname = handler.getHostname(); + InetAddress address = InetAddress.getByName(hostname); + DatagramPacket packet = new DatagramPacket(data, data.length, address, REQUEST_PORT); + socket.send(packet); + logger.trace("Sent request to {} on {} with content = {}", handler.getThing().getUID(), + address.getHostAddress(), message); + } + } } - public void runDiscoveryForInterface(NetworkInterface intf, DiscoveryResultReceiver receiver) throws IOException { - synchronized (receiver) { - StatusReceiver localReceiver = null; - StatusReceiver activeReceiver = null; + /** + * Send discovery multicast pings on any ipv4 address bound to any network interface in the given + * list and then sleep for sufficient time until responses may have been received. + */ + public void runDiscoveryForInterfaces(List interfaces, GoveeDiscoveryListener listener) { + serverStart(); + try { + discoveryListener = listener; + Instant sleepUntil = Instant.now().plusSeconds(SCAN_TIMEOUT_SEC); - try { - if (receiverThread == null) { - localReceiver = new StatusReceiver(); - localReceiver.start(); - activeReceiver = localReceiver; - } else { - activeReceiver = receiverThread; - } + interfaces.parallelStream() // send on all interfaces in parallel + .forEach(interFace -> Collections.list(interFace.getInetAddresses()).stream() + .filter(address -> address instanceof Inet4Address).map(address -> address.getHostAddress()) + .forEach(ipv4Address -> sendPing(interFace, ipv4Address))); - if (activeReceiver != null) { - activeReceiver.setDiscoveryResultsReceiver(receiver); + Duration sleepDuration = Duration.between(Instant.now(), sleepUntil); + if (!sleepDuration.isNegative()) { + try { + Thread.sleep(sleepDuration.toMillis()); + } catch (InterruptedException e) { + // just return } + } + } finally { + discoveryListener = null; + } + } + + /** + * This method gets executed on the server thread. It uses a {@link DatagramChannel} to listen on port + * 4002 and it processes any notifications received. The task runs continuously in a loop until the + * thread is externally interrupted. + * + *
  • In case of status notifications it forwards the message to the Thing handler listener.
  • + *
  • In case of discovery notifications it forwards the message to the discovery listener.
  • + *
  • If there is neither a Thing handler listener, nor a discovery listener, it logs an error.
  • + */ + private void serverThreadTask() { + synchronized (serverLock) { + try { + logger.trace("Server thread started."); + ByteBuffer buffer = ByteBuffer.allocate(1024); - 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); + while (!serverStopFlag) { + try (DatagramChannel channel = DatagramChannel.open() + .setOption(StandardSocketOptions.SO_REUSEADDR, true) + .bind(new InetSocketAddress(RESPONSE_PORT))) { - 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); + while (!serverStopFlag) { + String sourceIp = ""; + try { + SocketAddress socketAddress = channel.receive(buffer.clear()); + if ((socketAddress instanceof InetSocketAddress inetSocketAddress) + && (inetSocketAddress.getAddress() instanceof InetAddress inetAddress)) { + sourceIp = inetAddress.getHostAddress(); + } else { + logger.debug("Receive() - bad socketAddress={}", socketAddress); + return; + } + } catch (ClosedByInterruptException e) { + // thrown if 'Thread.interrupt()' is called during 'channel.receive()' + logger.debug("Receive ClosedByInterruptException, isInterrupted={}, serverStopFlag={}", + Thread.currentThread().isInterrupted(), serverStopFlag); + Thread.interrupted(); // clear 'interrupted' flag + break; + } catch (IOException e) { + logger.debug("Receive unexpected exception={}", e.getMessage()); + break; + } - byte[] requestData = DISCOVER_REQUEST.getBytes(); + String message = new String(buffer.array(), 0, buffer.position()); + logger.trace("Receive from sourceIp={}, message={}", sourceIp, message); - DatagramPacket request = new DatagramPacket(requestData, requestData.length, broadcastAddress, - DISCOVERY_PORT); - sendSocket.send(request); - } + GoveeHandler handler = thingHandlerListeners.get(sourceIp); + boolean devStatus = message.contains("devStatus"); + if (handler != null && devStatus) { + logger.debug("Notifying status of thing={} on sourcecIp={}", + handler.getThing().getUID(), sourceIp); + handler.handleIncomingStatus(message); + continue; + } - do { - try { - receiver.wait(INTERFACE_TIMEOUT_SEC * 1000); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); + GoveeDiscoveryListener discoveryListener = this.discoveryListener; + if (!devStatus && discoveryListener != null) { + try { + DiscoveryResponse response = gson.fromJson(message, DiscoveryResponse.class); + if (response != null) { + logger.debug("Notifying discovery of device on sourceIp={}", sourceIp); + discoveryListener.onDiscoveryResponse(response); + } + } catch (JsonParseException e) { + logger.debug("Discovery notification parse exception={}", e.getMessage()); + } + continue; + } + + logger.warn( + "Unhandled message with sourceIp={}, devStatus={}, handler={}, discoveryListener={}", + sourceIp, devStatus, handler, discoveryListener); + } // end of inner while loop + } catch (IOException e) { + logger.debug("Datagram channel create exception={}", e.getMessage()); } - } while (Instant.now().isBefore(discoveryEndTime)); + } // end of outer while loop } finally { - if (activeReceiver != null) { - activeReceiver.setDiscoveryResultsReceiver(null); - } - if (localReceiver != null) { - localReceiver.stopReceiving(); - } + serverThread = null; + serverStopFlag = false; + logger.trace("Server thread terminated."); } } } - 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; + /** + * Get the resolved IP address from the given host name. + */ + private static String ipAddressFrom(String host) { + try { + return InetAddress.getByName(host).getHostAddress(); + } catch (UnknownHostException e) { } + return host; + } - void stopReceiving() { - stopped = true; - interrupt(); - if (socket != null) { - socket.close(); + /** + * Starts the server thread if it is not already running. + */ + private void serverStart() { + synchronized (paramsLock) { + Thread serverthread = serverThread; + if (serverthread == null) { + serverthread = new Thread(this::serverThreadTask, "OH-binding-" + GoveeBindingConstants.BINDING_ID); + serverThread = serverthread; + serverStopFlag = false; + serverthread.start(); } + } + } - try { - join(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); + /** + * Stops the server thread. + */ + private void serverStop() { + synchronized (paramsLock) { + serverStopFlag = true; + Thread serverthread = serverThread; + if (serverthread != null) { + serverthread.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); - if (!socket.isClosed()) { - socket.receive(packet); - } else { - logger.warn("Socket was unexpectedly closed"); - break; - } - 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) { - logger.debug( - "JsonParseException when trying to parse the response, probably a status message", - e); - } - } 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; - } - } - } + /** + * Send discovery ping multicast on the given network interface and ipv4 address. + */ + private void sendPing(NetworkInterface interFace, String ipv4Address) { + try (DatagramChannel channel = (DatagramChannel) DatagramChannel.open(StandardProtocolFamily.INET) + .setOption(StandardSocketOptions.SO_REUSEADDR, true) + .setOption(StandardSocketOptions.IP_MULTICAST_TTL, 64) + .setOption(StandardSocketOptions.IP_MULTICAST_IF, interFace) + .bind(new InetSocketAddress(ipv4Address, DISCOVERY_PORT)).configureBlocking(false)) { + channel.send(ByteBuffer.wrap(DISCOVER_REQUEST.getBytes()), DISCOVERY_SOCKET_ADDRESS); + logger.trace("Sent ping from {}:{} ({}) to {}:{} with content = {}", ipv4Address, DISCOVERY_PORT, + interFace.getDisplayName(), DISCOVERY_MULTICAST_ADDRESS, DISCOVERY_PORT, DISCOVER_REQUEST); + } catch (IOException e) { + logger.debug("Network error", e); } } } 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 index 35ca13bb647b1..713ac70e9bd51 100644 --- 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 @@ -31,7 +31,7 @@ public class GoveeBindingConstants { 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"; + public 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"); @@ -44,4 +44,7 @@ public class GoveeBindingConstants { // Limit values of channels public static final Double COLOR_TEMPERATURE_MIN_VALUE = 2000.0; public static final Double COLOR_TEMPERATURE_MAX_VALUE = 9000.0; + + public static final String PROPERTY_COLOR_TEMPERATURE_MIN = "minKelvin"; + public static final String PROPERTY_COLOR_TEMPERATURE_MAX = "maxKelvin"; } 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 index 23932e8440da3..fe2d0d6a8d197 100644 --- 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 @@ -13,6 +13,7 @@ package org.openhab.binding.govee.internal; import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; /** * The {@link GoveeConfiguration} contains thing values that are used by the Thing Handler @@ -24,4 +25,7 @@ public class GoveeConfiguration { public String hostname = ""; public int refreshInterval = 5; // in seconds + + public @Nullable Integer minKelvin; + public @Nullable Integer maxKelvin; } 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 index ef5a62b69fd6b..3d22c007f031e 100644 --- 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 @@ -12,16 +12,18 @@ */ 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 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.CommunicationManager.GoveeDiscoveryListener; import org.openhab.binding.govee.internal.model.DiscoveryData; import org.openhab.binding.govee.internal.model.DiscoveryResponse; import org.openhab.core.config.discovery.AbstractDiscoveryService; @@ -79,17 +81,22 @@ */ @NonNullByDefault @Component(service = DiscoveryService.class, immediate = true, configurationPid = "discovery.govee") -public class GoveeDiscoveryService extends AbstractDiscoveryService { +public class GoveeDiscoveryService extends AbstractDiscoveryService implements GoveeDiscoveryListener { + + private static final int BACKGROUND_SCAN_INTERVAL_SECONDS = 300; + private final Logger logger = LoggerFactory.getLogger(GoveeDiscoveryService.class); - private CommunicationManager communicationManager; + private final CommunicationManager communicationManager; + private @Nullable ScheduledFuture backgroundScanTask; private static final Set 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); + public GoveeDiscoveryService(final @Reference TranslationProvider i18nProvider, + final @Reference LocaleProvider localeProvider, + final @Reference CommunicationManager communicationManager) { + super(SUPPORTED_THING_TYPES_UIDS, CommunicationManager.SCAN_TIMEOUT_SEC, true); this.i18nProvider = i18nProvider; this.localeProvider = localeProvider; this.communicationManager = communicationManager; @@ -103,23 +110,8 @@ public GoveeDiscoveryService(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"); - }); + logger.debug("Starting scan"); + scheduler.schedule(this::doDiscovery, 0, TimeUnit.MILLISECONDS); } public @Nullable DiscoveryResult responseToResult(DiscoveryResponse response) { @@ -165,11 +157,11 @@ protected void startScan() { } String hwVersion = data.wifiVersionHard(); - if (hwVersion != null) { + if (!hwVersion.isEmpty()) { builder.withProperty(GoveeBindingConstants.HW_VERSION, hwVersion); } String swVersion = data.wifiVersionSoft(); - if (swVersion != null) { + if (!swVersion.isEmpty()) { builder.withProperty(GoveeBindingConstants.SW_VERSION, swVersion); } @@ -194,4 +186,41 @@ private List getLocalNetworkInterfaces() { } return result; } + + /** + * Command the {@link CommunicationManager) to run the scans. + */ + private void doDiscovery() { + communicationManager.runDiscoveryForInterfaces(getLocalNetworkInterfaces(), this); + } + + /** + * This method is called back by the {@link CommunicationManager} when it receives a {@link DiscoveryResponse} + * notification carrying information about potential newly discovered Things. + */ + @Override + public synchronized void onDiscoveryResponse(DiscoveryResponse discoveryResponse) { + DiscoveryResult discoveryResult = responseToResult(discoveryResponse); + if (discoveryResult != null) { + thingDiscovered(discoveryResult); + } + } + + @Override + protected void startBackgroundDiscovery() { + ScheduledFuture backgroundScanTask = this.backgroundScanTask; + if (backgroundScanTask == null || backgroundScanTask.isCancelled()) { + this.backgroundScanTask = scheduler.scheduleWithFixedDelay(this::doDiscovery, 0, + BACKGROUND_SCAN_INTERVAL_SECONDS, TimeUnit.SECONDS); + } + } + + @Override + protected void stopBackgroundDiscovery() { + ScheduledFuture backgroundScanTask = this.backgroundScanTask; + if (backgroundScanTask != null) { + backgroundScanTask.cancel(true); + this.backgroundScanTask = null; + } + } } 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 index 6c8f51b1ab069..7fd81c6953aea 100644 --- 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 @@ -15,6 +15,11 @@ import static org.openhab.binding.govee.internal.GoveeBindingConstants.*; import java.io.IOException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.Callable; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; @@ -24,10 +29,12 @@ 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.GenericGoveeData; 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.DecimalType; import org.openhab.core.library.types.HSBType; import org.openhab.core.library.types.OnOffType; import org.openhab.core.library.types.PercentType; @@ -40,6 +47,7 @@ import org.openhab.core.thing.binding.BaseThingHandler; import org.openhab.core.types.Command; import org.openhab.core.types.RefreshType; +import org.openhab.core.types.UnDefType; import org.openhab.core.util.ColorUtil; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -71,47 +79,73 @@ * https://app-h5.govee.com/user-manual/wlan-guide * * @author Stefan Höhn - Initial contribution + * @author Andrew Fiddian-Green - Added sequential task processing */ @NonNullByDefault public class GoveeHandler extends BaseThingHandler { - /* - * Messages to be sent to the Govee devices - */ private static final Gson GSON = new Gson(); + private static final int REFRESH_SECONDS_MIN = 2; + private static final int INTER_COMMAND_DELAY_MILLISEC = 100; private final Logger logger = LoggerFactory.getLogger(GoveeHandler.class); + protected ScheduledExecutorService executorService = scheduler; - @Nullable - private ScheduledFuture triggerStatusJob; // send device status update job + private @Nullable ScheduledFuture thingTaskSenderTask; private GoveeConfiguration goveeConfiguration = new GoveeConfiguration(); - private CommunicationManager communicationManager; + private final CommunicationManager communicationManager; + private final GoveeStateDescriptionProvider stateDescriptionProvider; + private final List> taskQueue = new ArrayList<>(); - private int lastOnOff; - private int lastBrightness; + private OnOffType lastSwitch = OnOffType.OFF; private HSBType lastColor = new HSBType(); - private int lastColorTempInKelvin = COLOR_TEMPERATURE_MIN_VALUE.intValue(); + + private int lastKelvin; + private int minKelvin; + private int maxKelvin; + + private int refreshIntervalSeconds; + private Instant nextRefreshDueTime = Instant.EPOCH; /** - * This thing related job thingRefreshSender 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 + * This thing related job thingTaskSender sends the next queued command (if any) + * to the Govee device. If there is no queued command and a regular refresh is due then + * sends the command to trigger a status refresh. + * + * The device may send a reply to the common port and if so the response is received by + * the refresh status receiver. */ - private final Runnable thingRefreshSender = () -> { - try { - triggerDeviceStatusRefresh(); - updateStatus(ThingStatus.ONLINE); - } catch (IOException e) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, - "@text/offline.communication-error.could-not-query-device [\"" + goveeConfiguration.hostname - + "\"]"); + private final Runnable thingTaskSender = () -> { + synchronized (taskQueue) { + if (taskQueue.isEmpty() && Instant.now().isBefore(nextRefreshDueTime)) { + return; // no queued command nor pending refresh + } + if (taskQueue.isEmpty()) { + taskQueue.add(() -> triggerDeviceStatusRefresh()); + nextRefreshDueTime = Instant.now().plusSeconds(refreshIntervalSeconds); + } else if (taskQueue.size() > 20) { + logger.info("Command task queue size:{} exceeds limit:20", taskQueue.size()); + } + try { + if (taskQueue.remove(0).call()) { + updateStatus(ThingStatus.ONLINE); + } + } catch (IndexOutOfBoundsException e) { + logger.warn("Unexpected List.remove() exception:{}", e.getMessage()); + } catch (Exception e) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, + "@text/offline.communication-error.could-not-query-device [\"" + goveeConfiguration.hostname + + "\"]"); + } } }; - public GoveeHandler(Thing thing, CommunicationManager communicationManager) { + public GoveeHandler(Thing thing, CommunicationManager communicationManager, + GoveeStateDescriptionProvider stateDescriptionProvider) { super(thing); this.communicationManager = communicationManager; + this.stateDescriptionProvider = stateDescriptionProvider; } public String getHostname() { @@ -128,140 +162,176 @@ public void initialize() { "@text/offline.configuration-error.ip-address.missing"); return; } + + minKelvin = Objects.requireNonNullElse(goveeConfiguration.minKelvin, COLOR_TEMPERATURE_MIN_VALUE.intValue()); + maxKelvin = Objects.requireNonNullElse(goveeConfiguration.maxKelvin, COLOR_TEMPERATURE_MAX_VALUE.intValue()); + if ((minKelvin < COLOR_TEMPERATURE_MIN_VALUE) || (maxKelvin > COLOR_TEMPERATURE_MAX_VALUE) + || (minKelvin >= maxKelvin)) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + "@text/offline.configuration-error.invalid-color-temperature-range"); + return; + } + + thing.setProperty(PROPERTY_COLOR_TEMPERATURE_MIN, Integer.toString(minKelvin)); + thing.setProperty(PROPERTY_COLOR_TEMPERATURE_MAX, Integer.toString(maxKelvin)); + stateDescriptionProvider.setMinMaxKelvin(new ChannelUID(thing.getUID(), CHANNEL_COLOR_TEMPERATURE_ABS), + minKelvin, maxKelvin); + + refreshIntervalSeconds = goveeConfiguration.refreshInterval; + if (refreshIntervalSeconds < REFRESH_SECONDS_MIN) { + logger.warn("Config Param refreshInterval={} too low, minimum={}", refreshIntervalSeconds, + REFRESH_SECONDS_MIN); + refreshIntervalSeconds = REFRESH_SECONDS_MIN; + } + updateStatus(ThingStatus.UNKNOWN); communicationManager.registerHandler(this); - if (triggerStatusJob == null) { - logger.debug("Starting refresh trigger job for thing {} ", thing.getLabel()); - triggerStatusJob = executorService.scheduleWithFixedDelay(thingRefreshSender, 100, - goveeConfiguration.refreshInterval * 1000L, TimeUnit.MILLISECONDS); + if (thingTaskSenderTask == null) { + logger.debug("Starting refresh trigger job for thing {} ", thing.getLabel()); + thingTaskSenderTask = executorService.scheduleWithFixedDelay(thingTaskSender, INTER_COMMAND_DELAY_MILLISEC, + INTER_COMMAND_DELAY_MILLISEC, TimeUnit.MILLISECONDS); } } @Override public void dispose() { super.dispose(); - - ScheduledFuture triggerStatusJobFuture = triggerStatusJob; - if (triggerStatusJobFuture != null) { - triggerStatusJobFuture.cancel(true); - triggerStatusJob = null; + taskQueue.clear(); + ScheduledFuture job = thingTaskSenderTask; + if (job != null) { + job.cancel(true); + thingTaskSenderTask = null; } communicationManager.unregisterHandler(this); } @Override - public void handleCommand(ChannelUID channelUID, Command command) { - try { + public void handleCommand(ChannelUID channelUID, Command commandParam) { + Command command = commandParam; + + synchronized (taskQueue) { + logger.debug("handleCommand({}, {})", channelUID, command); + 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"); + taskQueue.add(() -> triggerDeviceStatusRefresh()); } 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); + if (command instanceof HSBType hsb) { + taskQueue.add(() -> sendColor(hsb)); + command = hsb.getBrightness(); // fall through + } + if (command instanceof PercentType percent) { + taskQueue.add(() -> sendBrightness(percent)); + command = OnOffType.from(percent.intValue() > 0); // fall through + } + if (command instanceof OnOffType onOff) { + taskQueue.add(() -> sendOnOff(onOff)); + taskQueue.add(() -> triggerDeviceStatusRefresh()); } 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); + taskQueue.add(() -> sendKelvin(percentToKelvin(percent))); + taskQueue.add(() -> triggerDeviceStatusRefresh()); } 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); + if (command instanceof QuantityType genericQuantity) { + QuantityType kelvin = genericQuantity.toInvertibleUnit(Units.KELVIN); + if (kelvin == null) { + logger.warn("handleCommand() invalid QuantityType:{}", genericQuantity); + break; + } + taskQueue.add(() -> sendKelvin(kelvin.intValue())); + taskQueue.add(() -> triggerDeviceStatusRefresh()); + } else if (command instanceof DecimalType kelvin) { + taskQueue.add(() -> sendKelvin(kelvin.intValue())); + taskQueue.add(() -> triggerDeviceStatusRefresh()); } break; } } - 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 - * + * Initiate a refresh to our thing device */ - 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); + private boolean triggerDeviceStatusRefresh() throws IOException { + logger.debug("triggerDeviceStatusRefresh() to {}", thing.getUID()); + GenericGoveeData data = new EmptyValueQueryStatusData(); + GenericGoveeRequest request = new GenericGoveeRequest(new GenericGoveeMsg("devStatus", data)); + communicationManager.sendRequest(this, request); + return true; } - 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); + /** + * Send the normalized RGB color parameters. + */ + public boolean sendColor(HSBType color) throws IOException { + logger.debug("sendColor({}) to {}", color, thing.getUID()); + int[] normalRGB = ColorUtil.hsbToRgb(new HSBType(color.getHue(), color.getSaturation(), PercentType.HUNDRED)); + GenericGoveeData data = new ColorData(new Color(normalRGB[0], normalRGB[1], normalRGB[2]), 0); + GenericGoveeRequest request = new GenericGoveeRequest(new GenericGoveeMsg("colorwc", data)); + communicationManager.sendRequest(this, request); + return true; } - public void sendBrightness(int brightness) throws IOException { - lastBrightness = brightness; - GenericGoveeRequest lightBrightness = new GenericGoveeRequest( - new GenericGoveeMsg("brightness", new ValueIntData(brightness))); - communicationManager.sendRequest(this, lightBrightness); + /** + * Send the brightness parameter. + */ + public boolean sendBrightness(PercentType brightness) throws IOException { + logger.debug("sendBrightness({}) to {}", brightness, thing.getUID()); + GenericGoveeData data = new ValueIntData(brightness.intValue()); + GenericGoveeRequest request = new GenericGoveeRequest(new GenericGoveeMsg("brightness", data)); + communicationManager.sendRequest(this, request); + return true; } - 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); + /** + * Send the on-off parameter. + */ + private boolean sendOnOff(OnOffType onOff) throws IOException { + logger.debug("sendOnOff({}) to {}", onOff, thing.getUID()); + GenericGoveeData data = new ValueIntData(onOff == OnOffType.ON ? 1 : 0); + GenericGoveeRequest request = new GenericGoveeRequest(new GenericGoveeMsg("turn", data)); + communicationManager.sendRequest(this, request); + return true; } - 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); + /** + * Set the color temperature (Kelvin) parameter. + */ + private boolean sendKelvin(int kelvin) throws IOException { + logger.debug("sendKelvin({}) to {}", kelvin, thing.getUID()); + GenericGoveeData data = new ColorData(new Color(0, 0, 0), kelvin); + GenericGoveeRequest request = new GenericGoveeRequest(new GenericGoveeMsg("colorwc", data)); + communicationManager.sendRequest(this, request); + return true; } /** - * 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 + * Build an {@link HSBType} from the given normalized {@link Color} RGB parameters, brightness, and on-off state + * parameters. If the on parameter is true then use the brightness parameter, otherwise use a brightness of zero. * - * @see #lastColor - * @see #lastBrightness - * @see #lastOnOff + * @param normalRgbParams record containing the lamp's normalized RGB parameters (0..255) + * @param brightnessParam the lamp brightness in range 0..100 + * @param onParam the lamp on-off state * - * @return the computed state + * @return the respective HSBType */ - 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); + private static HSBType buildHSB(Color normalRgbParams, int brightnessParam, boolean onParam) { + HSBType normalColor = ColorUtil + .rgbToHsb(new int[] { normalRgbParams.r(), normalRgbParams.g(), normalRgbParams.b() }); + PercentType brightness = onParam ? new PercentType(brightnessParam) : PercentType.ZERO; + return new HSBType(normalColor.getHue(), normalColor.getSaturation(), brightness); } - void handleIncomingStatus(String response) { + public void handleIncomingStatus(String response) { if (response.isEmpty()) { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "@text/offline.communication-error.empty-response"); @@ -284,47 +354,52 @@ public void updateDeviceState(@Nullable StatusResponse message) { 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; - newColorTempInKelvin = (newColorTempInKelvin > COLOR_TEMPERATURE_MAX_VALUE) - ? COLOR_TEMPERATURE_MAX_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); + logger.debug("updateDeviceState() for {}", thing.getUID()); + + OnOffType sw = OnOffType.from(message.msg().data().onOff() == 1); + int brightness = message.msg().data().brightness(); + Color normalRGB = message.msg().data().color(); + int kelvin = message.msg().data().colorTemInKelvin(); + + logger.trace("Update values: switch:{}, brightness:{}, normalRGB:{}, kelvin:{}", sw, brightness, normalRGB, + kelvin); + + HSBType color = buildHSB(normalRGB, brightness, true); + + logger.trace("Compare hsb old:{} to new:{}, switch old:{} to new:{}", lastColor, color, lastSwitch, sw); + if ((sw != lastSwitch) || !color.equals(lastColor)) { + logger.trace("Update hsb old:{} to new:{}, switch old:{} to new:{}", lastColor, color, lastSwitch, sw); + updateState(CHANNEL_COLOR, buildHSB(normalRGB, brightness, sw == OnOffType.ON)); + lastSwitch = sw; + lastColor = color; } - // 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)); + logger.trace("Compare kelvin old:{} to new:{}", lastKelvin, kelvin); + if (kelvin != lastKelvin) { + logger.trace("Update kelvin old:{} to new:{}", lastKelvin, kelvin); + if (kelvin != 0) { + kelvin = Math.round(Math.min(maxKelvin, Math.max(minKelvin, kelvin))); + updateState(CHANNEL_COLOR_TEMPERATURE, kelvinToPercent(kelvin)); + updateState(CHANNEL_COLOR_TEMPERATURE_ABS, QuantityType.valueOf(kelvin, Units.KELVIN)); + } else { + updateState(CHANNEL_COLOR_TEMPERATURE, UnDefType.UNDEF); + updateState(CHANNEL_COLOR_TEMPERATURE_ABS, UnDefType.UNDEF); + } + lastKelvin = kelvin; } + } - lastOnOff = newOnOff; - lastColor = adaptedColor; - lastBrightness = newBrightness; + /** + * Convert PercentType to Kelvin. + */ + private int percentToKelvin(PercentType percent) { + return (int) Math.round((((maxKelvin - minKelvin) * percent.doubleValue() / 100.0) + minKelvin)); + } + + /** + * Convert Kelvin to PercentType. + */ + private PercentType kelvinToPercent(int kelvin) { + return new PercentType((int) Math.round((kelvin - minKelvin) * 100.0 / (maxKelvin - minKelvin))); } } 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 index 907c3d41426d9..a9b5ec5e9babc 100644 --- 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 @@ -38,11 +38,14 @@ public class GoveeHandlerFactory extends BaseThingHandlerFactory { private static final Set SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_LIGHT); - private CommunicationManager communicationManager; + private final CommunicationManager communicationManager; + private final GoveeStateDescriptionProvider stateDescriptionProvider; @Activate - public GoveeHandlerFactory(@Reference CommunicationManager communicationManager) { + public GoveeHandlerFactory(final @Reference CommunicationManager communicationManager, + final @Reference GoveeStateDescriptionProvider stateDescriptionProvider) { this.communicationManager = communicationManager; + this.stateDescriptionProvider = stateDescriptionProvider; } @Override @@ -55,7 +58,7 @@ public boolean supportsThingType(ThingTypeUID thingTypeUID) { ThingTypeUID thingTypeUID = thing.getThingTypeUID(); if (THING_TYPE_LIGHT.equals(thingTypeUID)) { - return new GoveeHandler(thing, communicationManager); + return new GoveeHandler(thing, communicationManager, stateDescriptionProvider); } return null; diff --git a/bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/GoveeStateDescriptionProvider.java b/bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/GoveeStateDescriptionProvider.java new file mode 100644 index 0000000000000..b35e3e8ef4e6f --- /dev/null +++ b/bundles/org.openhab.binding.govee/src/main/java/org/openhab/binding/govee/internal/GoveeStateDescriptionProvider.java @@ -0,0 +1,83 @@ +/** + * 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.govee.internal; + +import java.math.BigDecimal; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.events.EventPublisher; +import org.openhab.core.thing.Channel; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.binding.BaseDynamicStateDescriptionProvider; +import org.openhab.core.thing.events.ThingEventFactory; +import org.openhab.core.thing.i18n.ChannelTypeI18nLocalizationService; +import org.openhab.core.thing.link.ItemChannelLinkRegistry; +import org.openhab.core.thing.type.DynamicStateDescriptionProvider; +import org.openhab.core.types.StateDescription; +import org.openhab.core.types.StateDescriptionFragment; +import org.openhab.core.types.StateDescriptionFragmentBuilder; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; + +/** + * The {@link GoveeStateDescriptionProvider} provides state descriptions for different color temperature ranges. + * + * @author Andrew Fiddian-Green - Initial contribution + * + */ +@NonNullByDefault +@Component(service = { DynamicStateDescriptionProvider.class, GoveeStateDescriptionProvider.class }) +public class GoveeStateDescriptionProvider extends BaseDynamicStateDescriptionProvider { + + private final Map stateDescriptionFragments = new ConcurrentHashMap<>(); + + @Activate + public GoveeStateDescriptionProvider(final @Reference EventPublisher eventPublisher, + final @Reference ItemChannelLinkRegistry itemChannelLinkRegistry, + final @Reference ChannelTypeI18nLocalizationService channelTypeI18nLocalizationService) { + this.eventPublisher = eventPublisher; + this.itemChannelLinkRegistry = itemChannelLinkRegistry; + this.channelTypeI18nLocalizationService = channelTypeI18nLocalizationService; + } + + @Override + public @Nullable StateDescription getStateDescription(Channel channel, @Nullable StateDescription original, + @Nullable Locale locale) { + StateDescriptionFragment stateDescriptionFragment = stateDescriptionFragments.get(channel.getUID()); + return stateDescriptionFragment != null ? stateDescriptionFragment.toStateDescription() + : super.getStateDescription(channel, original, locale); + } + + /** + * Set the state description minimum and maximum values and pattern in Kelvin for the given channel UID + */ + public void setMinMaxKelvin(ChannelUID channelUID, long minKelvin, long maxKelvin) { + StateDescriptionFragment oldStateDescriptionFragment = stateDescriptionFragments.get(channelUID); + StateDescriptionFragment newStateDescriptionFragment = StateDescriptionFragmentBuilder.create() + .withMinimum(BigDecimal.valueOf(minKelvin)).withMaximum(BigDecimal.valueOf(maxKelvin)) + .withStep(BigDecimal.valueOf(100)).withPattern("%.0f K").build(); + if (!newStateDescriptionFragment.equals(oldStateDescriptionFragment)) { + stateDescriptionFragments.put(channelUID, newStateDescriptionFragment); + ItemChannelLinkRegistry itemChannelLinkRegistry = this.itemChannelLinkRegistry; + postEvent(ThingEventFactory.createChannelDescriptionChangedEvent(channelUID, + itemChannelLinkRegistry != null ? itemChannelLinkRegistry.getLinkedItemNames(channelUID) : Set.of(), + newStateDescriptionFragment, oldStateDescriptionFragment)); + } + } +} 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 index e153efa4d8067..9ddf0f4c14d61 100644 --- 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 @@ -14,11 +14,21 @@ MAC Address of the device - + The amount of time that passes until the device is refreshed (in seconds) 2 + + + The minimum color temperature that the light supports (in Kelvin) + true + + + + The maximum color temperature that the light supports (in Kelvin) + true + 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 index b0a015db28f82..d9c7d3a4b29bd 100644 --- 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 @@ -1,5 +1,28 @@ # add-on +addon.govee.name = Govee Lan-API Binding +addon.govee.description = This is the binding for handling Govee Lights via the LAN-API interface. + +# thing types + +thing-type.govee.govee-light.label = Govee Light +thing-type.govee.govee-light.description = Govee light controllable via LAN API + +# thing types config + +thing-type.config.govee.govee-light.hostname.label = Hostname/IP Address +thing-type.config.govee.govee-light.hostname.description = Hostname or IP address of the device +thing-type.config.govee.govee-light.macAddress.label = MAC Address +thing-type.config.govee.govee-light.macAddress.description = MAC Address of the device +thing-type.config.govee.govee-light.maxKelvin.label = Maximum Color Temperature +thing-type.config.govee.govee-light.maxKelvin.description = The maximum color temperature that the light supports (in Kelvin) +thing-type.config.govee.govee-light.minKelvin.label = Minimum Color Temperature +thing-type.config.govee.govee-light.minKelvin.description = The minimum color temperature that the light supports (in Kelvin) +thing-type.config.govee.govee-light.refreshInterval.label = Light Refresh Interval +thing-type.config.govee.govee-light.refreshInterval.description = The amount of time that passes until the device is refreshed (in seconds) + +# add-on + addon.name = Govee Binding addon.description = This is the binding for handling Govee Lights via the LAN-API interface. @@ -15,68 +38,127 @@ thing-type.config.govee-light.refreshInterval.description = The amount of time t # product names -discovery.govee-light.H619Z = H619Z RGBIC Pro LED Strip Lights +discovery.govee-light.H6042 = H6042 Govee TV Light Bar #2 +discovery.govee-light.H6043 = H6043 Govee TV Light Bars #2 discovery.govee-light.H6046 = H6046 RGBIC TV Light Bars discovery.govee-light.H6047 = H6047 RGBIC Gaming Light Bars with Smart Controller +discovery.govee-light.H6051 = H6051 Aura - Smart Table Lamp +discovery.govee-light.H6052 = H6052 Govee Table Lamp +discovery.govee-light.H6056 = H6056 H6056 Flow Plus +discovery.govee-light.H6059 = H6059 RGBWW Night Light for Kids discovery.govee-light.H6061 = H6061 Glide Hexa LED Panels discovery.govee-light.H6062 = H6062 Glide Wall Light +discovery.govee-light.H6063 = H6063 Gaming 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.H606A = H606A Glide Hexa Light Panel Ultra 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.H6076 = H6076 RGBICW Smart Corner Floor Lamp discovery.govee-light.H6078 = H6078 Cylinder Floor Lamp +discovery.govee-light.H607C = H607C Floor Lamp #2 discovery.govee-light.H6087 = H6087 RGBIC Smart Wall Sconces +discovery.govee-light.H6088 = H6088 RGBIC Cube Wall Sconces +discovery.govee-light.H608A = H608A String Downlights 5M +discovery.govee-light.H608B = H608B String Downlights 3M +discovery.govee-light.H608C = H608C String Downlights 2M +discovery.govee-light.H608D = H608D String Downlights 10M +discovery.govee-light.H60A0 = H60A0 Ceiling Light +discovery.govee-light.H60A1 = H60A1 Smart Ceiling Light +discovery.govee-light.H610A = H610A Glide Lively Wall Lights +discovery.govee-light.H610B = H610B Music Wall Lights +discovery.govee-light.H6110 = H6110 2x5M Multicolor with Alexa +discovery.govee-light.H6117 = H6117 Dream Color LED Strip Light 10M +discovery.govee-light.H6141 = H6141 5M Smart Multicolor Strip Light +discovery.govee-light.H6143 = H6143 5M Strip Light +discovery.govee-light.H6144 = H6144 2x5M Strip Light +discovery.govee-light.H6159 = H6159 RGB Light Strip +discovery.govee-light.H615A = H615A 5M Light Strip with Alexa +discovery.govee-light.H615B = H615B 10M Light Strip with Alexa +discovery.govee-light.H615C = H615C 15M Light Strip with Alexa +discovery.govee-light.H615D = H615D 20M Light Strip with Alexa +discovery.govee-light.H615E = H615E 30M Light Strip with Alexa +discovery.govee-light.H6163 = H6163 Dreamcolor LED Strip Light 5M +discovery.govee-light.H6167 = H6167 TV Backlight 2.4M +discovery.govee-light.H6168 = H6168 TV Backlight 2x0.7M+2x1.2M +discovery.govee-light.H616C = H616C Outdoor Strip 10M +discovery.govee-light.H616D = H616D Outdoor Strip 2x7.5M +discovery.govee-light.H616E = H616E Outdoor Strip 2x10M +discovery.govee-light.H6172 = H6172 Outdoor LED Strip 10m 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.H6175 = H6175 RGBIC Outdoor Strip Lights 10M +discovery.govee-light.H6176 = H6176 RGBIC Outdoor Strip Lights 30M +discovery.govee-light.H6182 = H6182 WiFi Multicolor TV Strip Light +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.H618E = H618E LED Strip Lights 22m +discovery.govee-light.H618F = H618F RGBIC LED Strip Lights +discovery.govee-light.H619A = H619A Strip Lights With Protective Coating 5M +discovery.govee-light.H619B = H619B Strip Lights With Protective Coating 7.5M +discovery.govee-light.H619C = H619C Strip Lights With Protective Coating with Alexa 10M +discovery.govee-light.H619D = H619D PRO LED Strip Lights with Alexa 2x7.5M +discovery.govee-light.H619E = H619E Strip Lights With Protective Coating with Alexa 2x10M +discovery.govee-light.H619Z = H619Z Pro LED Strip Lights 3M +discovery.govee-light.H61A0 = H61A0 RGBIC Neon Rope Light 3M 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.H61A3 = H61A3 RGBIC Neon Rope Light 4M +discovery.govee-light.H61A5 = H61A5 Neon LED Strip Light 10M +discovery.govee-light.H61A8 = H61A8 Neon Rope Light 10M +discovery.govee-light.H61A9 = H61A8 Neon Rope Light 20M +discovery.govee-light.H61B1 = H61B1 Strip Light with Cover 5M +discovery.govee-light.H61B2 = H61B2 RGBIC Neon TV Backlight 3M +discovery.govee-light.H61BA = H61BA LED Strip Light 5M +discovery.govee-light.H61BC = H61BC LED Strip Light 10M +discovery.govee-light.H61BE = H61BE LED Strip Light 2x10M +discovery.govee-light.H61C2 = H61C2 Neon LED Strip Light 2M +discovery.govee-light.H61C3 = H61C2 Neon LED Strip Light 3M +discovery.govee-light.H61C5 = H61C2 Neon LED Strip Light 5M discovery.govee-light.H61D3 = H61D3 Neon Rope Light 2 3m discovery.govee-light.H61D5 = H61D5 Neon Rope Light 2 5m -discovery.govee-light.H61A5 = H61A5 Neon LED Strip Light 10 -discovery.govee-light.H61A8 = H61A8 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.H61E0 = H61E0 LED Strip Light M1 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.H7033 = H7033 LED-Bulb String Lights 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.H7052 = H7052 Outdoor Ground Lights 15M +discovery.govee-light.H7053 = H7052 Outdoor Ground Lights 30M discovery.govee-light.H7055 = H7055 Pathway Light +discovery.govee-light.H705A = H705A Permanent Outdoor Lights 30M +discovery.govee-light.H705B = H705B Permanent Outdoor Lights 15M +discovery.govee-light.H705C = H705C Permanent Outdoor Lights 45M +discovery.govee-light.H705D = H705D Permanent Outdoor Lights #2 15M +discovery.govee-light.H705E = H705E Permanent Outdoor Lights #2 30M +discovery.govee-light.H705F = H705F Permanent Outdoor Lights #2 45M 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.H7063 = H7063 Outdoor Flood Lights 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 +discovery.govee-light.H7066 = H7066 Outdoor Spot Lights +discovery.govee-light.H706A = H706A Permanent Outdoor Lights Pro 30M +discovery.govee-light.H706B = H706B Permanent Outdoor Lights Pro 45M +discovery.govee-light.H706C = H706C Permanent Outdoor Lights Pro 60M +discovery.govee-light.H7070 = H7070 Outdoor Projector Light +discovery.govee-light.H7075 = H7075 Outdoor Wall Light +discovery.govee-light.H70B1 = H70B1 520 LED Curtain Lights +discovery.govee-light.H70BC = H70BC 400 LED Curtain Lights +discovery.govee-light.H70C1 = H70C1 RGBIC String Light 10M +discovery.govee-light.H70C2 = H70C2 RGBIC String Light 20M +discovery.govee-light.H805A = H805A Permanent Outdoor Lights Elite 30M +discovery.govee-light.H805B = H805B Permanent Outdoor Lights Elite 15M +discovery.govee-light.H805C = H805C Permanent Outdoor Lights Elite 45M # 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 +offline.configuration-error.invalid-color-temperature-range = Invalid color temperature range 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 index 1a49746b3158a..b96fbc8d57fc4 100644 --- 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 @@ -11,22 +11,14 @@ - + - - + + 1 + - - Number:Temperature - - Controls the color temperature of the light in Kelvin - ColorLight - - Control - ColorTemperature - - - + + diff --git a/bundles/org.openhab.binding.govee/src/main/resources/OH-INF/update/instructions.xml b/bundles/org.openhab.binding.govee/src/main/resources/OH-INF/update/instructions.xml new file mode 100644 index 0000000000000..9317395b6a8de --- /dev/null +++ b/bundles/org.openhab.binding.govee/src/main/resources/OH-INF/update/instructions.xml @@ -0,0 +1,14 @@ + + + + + + + system:color-temperature-abs + + + + + diff --git a/bundles/org.openhab.binding.govee/src/test/java/org/openhab/binding/govee/internal/GoveeHandlerMock.java b/bundles/org.openhab.binding.govee/src/test/java/org/openhab/binding/govee/internal/GoveeHandlerMock.java index 7f048867a118f..f3374b2453ea8 100644 --- a/bundles/org.openhab.binding.govee/src/test/java/org/openhab/binding/govee/internal/GoveeHandlerMock.java +++ b/bundles/org.openhab.binding.govee/src/test/java/org/openhab/binding/govee/internal/GoveeHandlerMock.java @@ -12,8 +12,7 @@ */ package org.openhab.binding.govee.internal; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.doAnswer; import java.util.concurrent.ScheduledExecutorService; @@ -26,14 +25,15 @@ /** * The {@link GoveeHandlerMock} is responsible for mocking {@link GoveeHandler} - * + * * @author Leo Siepel - Initial contribution */ @NonNullByDefault public class GoveeHandlerMock extends GoveeHandler { - public GoveeHandlerMock(Thing thing, CommunicationManager communicationManager) { - super(thing, communicationManager); + public GoveeHandlerMock(Thing thing, CommunicationManager communicationManager, + GoveeStateDescriptionProvider stateDescriptionProvider) { + super(thing, communicationManager, stateDescriptionProvider); executorService = Mockito.mock(ScheduledExecutorService.class); doAnswer((InvocationOnMock invocation) -> { diff --git a/bundles/org.openhab.binding.govee/src/test/java/org/openhab/binding/govee/internal/GoveeSerializeGoveeHandlerTest.java b/bundles/org.openhab.binding.govee/src/test/java/org/openhab/binding/govee/internal/GoveeSerializeGoveeHandlerTest.java index cb92a7478062f..46565a1a66a68 100644 --- a/bundles/org.openhab.binding.govee/src/test/java/org/openhab/binding/govee/internal/GoveeSerializeGoveeHandlerTest.java +++ b/bundles/org.openhab.binding.govee/src/test/java/org/openhab/binding/govee/internal/GoveeSerializeGoveeHandlerTest.java @@ -12,12 +12,8 @@ */ package org.openhab.binding.govee.internal; -import static org.mockito.ArgumentMatchers.argThat; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.spy; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; import java.util.Arrays; import java.util.List; @@ -87,7 +83,10 @@ private static Channel mockChannel(final ThingUID thingId, final String channelI private static GoveeHandlerMock createAndInitHandler(final ThingHandlerCallback callback, final Thing thing) { CommunicationManager communicationManager = mock(CommunicationManager.class); - final GoveeHandlerMock handler = spy(new GoveeHandlerMock(thing, communicationManager)); + GoveeStateDescriptionProvider stateDescriptionProvider = mock(GoveeStateDescriptionProvider.class); + + final GoveeHandlerMock handler = spy( + new GoveeHandlerMock(thing, communicationManager, stateDescriptionProvider)); handler.setCallback(callback); handler.initialize(); @@ -135,7 +134,7 @@ public void testInvalidResponseMessage() { verify(callback).stateUpdated( new ChannelUID(thing.getUID(), GoveeBindingConstants.CHANNEL_COLOR_TEMPERATURE_ABS), - getState(2000, Units.KELVIN)); + getState(9000, Units.KELVIN)); } finally { handler.dispose(); }