From efc103c4642801f3e3694f1b68b03f6f59b1d313 Mon Sep 17 00:00:00 2001 From: Bernd Weymann Date: Fri, 28 Feb 2025 14:32:23 +0100 Subject: [PATCH] [casokitchen] Initial contribution (#18243) * initial commit Signed-off-by: Bernd Weymann --- CODEOWNERS | 1 + bom/openhab-addons/pom.xml | 5 + .../org.openhab.binding.casokitchen/NOTICE | 13 + .../org.openhab.binding.casokitchen/README.md | 86 ++++++ .../org.openhab.binding.casokitchen/pom.xml | 17 ++ .../src/main/feature/feature.xml | 9 + .../internal/CasoKitchenBindingConstants.java | 55 ++++ .../internal/CasoKitchenHandlerFactory.java | 67 +++++ .../TwoZonesWinecoolerConfiguration.java | 29 ++ .../internal/dto/CallResponse.java | 28 ++ .../internal/dto/LightRequest.java | 33 +++ .../internal/dto/StatusRequest.java | 31 +++ .../internal/dto/StatusResult.java | 37 +++ .../handler/TwoZonesWinecoolerHandler.java | 250 ++++++++++++++++++ .../src/main/resources/OH-INF/addon/addon.xml | 11 + .../OH-INF/i18n/casokitchen.properties | 55 ++++ .../OH-INF/thing/bottom-zone-group.xml | 18 ++ .../resources/OH-INF/thing/channel-types.xml | 45 ++++ .../resources/OH-INF/thing/generic-group.xml | 14 + .../resources/OH-INF/thing/thing-types.xml | 33 +++ .../resources/OH-INF/thing/top-zone-group.xml | 18 ++ .../binding/caso/internal/CallbackMock.java | 166 ++++++++++++ .../binding/caso/internal/FactoryMock.java | 39 +++ .../binding/caso/internal/TestHandler.java | 174 ++++++++++++ .../src/test/resources/StatusResponse.json | 13 + bundles/pom.xml | 1 + 26 files changed, 1248 insertions(+) create mode 100644 bundles/org.openhab.binding.casokitchen/NOTICE create mode 100644 bundles/org.openhab.binding.casokitchen/README.md create mode 100644 bundles/org.openhab.binding.casokitchen/pom.xml create mode 100644 bundles/org.openhab.binding.casokitchen/src/main/feature/feature.xml create mode 100644 bundles/org.openhab.binding.casokitchen/src/main/java/org/openhab/binding/casokitchen/internal/CasoKitchenBindingConstants.java create mode 100644 bundles/org.openhab.binding.casokitchen/src/main/java/org/openhab/binding/casokitchen/internal/CasoKitchenHandlerFactory.java create mode 100644 bundles/org.openhab.binding.casokitchen/src/main/java/org/openhab/binding/casokitchen/internal/config/TwoZonesWinecoolerConfiguration.java create mode 100644 bundles/org.openhab.binding.casokitchen/src/main/java/org/openhab/binding/casokitchen/internal/dto/CallResponse.java create mode 100644 bundles/org.openhab.binding.casokitchen/src/main/java/org/openhab/binding/casokitchen/internal/dto/LightRequest.java create mode 100644 bundles/org.openhab.binding.casokitchen/src/main/java/org/openhab/binding/casokitchen/internal/dto/StatusRequest.java create mode 100644 bundles/org.openhab.binding.casokitchen/src/main/java/org/openhab/binding/casokitchen/internal/dto/StatusResult.java create mode 100644 bundles/org.openhab.binding.casokitchen/src/main/java/org/openhab/binding/casokitchen/internal/handler/TwoZonesWinecoolerHandler.java create mode 100644 bundles/org.openhab.binding.casokitchen/src/main/resources/OH-INF/addon/addon.xml create mode 100644 bundles/org.openhab.binding.casokitchen/src/main/resources/OH-INF/i18n/casokitchen.properties create mode 100644 bundles/org.openhab.binding.casokitchen/src/main/resources/OH-INF/thing/bottom-zone-group.xml create mode 100644 bundles/org.openhab.binding.casokitchen/src/main/resources/OH-INF/thing/channel-types.xml create mode 100644 bundles/org.openhab.binding.casokitchen/src/main/resources/OH-INF/thing/generic-group.xml create mode 100644 bundles/org.openhab.binding.casokitchen/src/main/resources/OH-INF/thing/thing-types.xml create mode 100644 bundles/org.openhab.binding.casokitchen/src/main/resources/OH-INF/thing/top-zone-group.xml create mode 100644 bundles/org.openhab.binding.casokitchen/src/test/java/org/openhab/binding/caso/internal/CallbackMock.java create mode 100644 bundles/org.openhab.binding.casokitchen/src/test/java/org/openhab/binding/caso/internal/FactoryMock.java create mode 100644 bundles/org.openhab.binding.casokitchen/src/test/java/org/openhab/binding/caso/internal/TestHandler.java create mode 100644 bundles/org.openhab.binding.casokitchen/src/test/resources/StatusResponse.json diff --git a/CODEOWNERS b/CODEOWNERS index b3a4a596800..0b2fbdf12c9 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -65,6 +65,7 @@ /bundles/org.openhab.binding.bticinosmarther/ @MrRonfo /bundles/org.openhab.binding.buienradar/ @gedejong /bundles/org.openhab.binding.caddx/ @jossuar +/bundles/org.openhab.binding.casokitchen/ @weymann /bundles/org.openhab.binding.cbus/ @jpharvey /bundles/org.openhab.binding.chatgpt/ @kaikreuzer /bundles/org.openhab.binding.chromecast/ @kaikreuzer diff --git a/bom/openhab-addons/pom.xml b/bom/openhab-addons/pom.xml index bc172373256..0c2158d9fd2 100644 --- a/bom/openhab-addons/pom.xml +++ b/bom/openhab-addons/pom.xml @@ -311,6 +311,11 @@ org.openhab.binding.caddx ${project.version} + + org.openhab.addons.bundles + org.openhab.binding.casokitchen + ${project.version} + org.openhab.addons.bundles org.openhab.binding.cbus diff --git a/bundles/org.openhab.binding.casokitchen/NOTICE b/bundles/org.openhab.binding.casokitchen/NOTICE new file mode 100644 index 00000000000..38d625e3492 --- /dev/null +++ b/bundles/org.openhab.binding.casokitchen/NOTICE @@ -0,0 +1,13 @@ +This content is produced and maintained by the openHAB project. + +* Project home: https://www.openhab.org + +== Declared Project Licenses + +This program and the accompanying materials are made available under the terms +of the Eclipse Public License 2.0 which is available at +https://www.eclipse.org/legal/epl-2.0/. + +== Source Code + +https://github.com/openhab/openhab-addons diff --git a/bundles/org.openhab.binding.casokitchen/README.md b/bundles/org.openhab.binding.casokitchen/README.md new file mode 100644 index 00000000000..37c91835ad1 --- /dev/null +++ b/bundles/org.openhab.binding.casokitchen/README.md @@ -0,0 +1,86 @@ +# CasoKitchen Binding + +Provides access towards CASO Smart Kitchen devices which are connected within the [CASO Control App](https://www.casocontrol.de/). + +## Supported Things + +- `winecooler-2z`: Wine cooler with two zones + +## Discovery + +There's no automatic discovery. + +## Thing Configuration + +You need a [CASO Account](https://www.casoapp.com/Account/Create) to get configuration parameters. +After register you'll get the + +- API key +- Device ID + +## Wine Cooler with 2 Zones + +### Configuration winecooler-2z + +| Name | Type | Description | Default | +|-----------------|---------|------------------------------------------------------|---------| +| apiKey | text | API obtained from thing configuration | N/A | +| deviceId | text | Device Id obtained from thing configuration | N/A | +| refreshInterval | integer | Interval the device is polled in minutes | 5 | + +### Channels winecooler-2z + +Channels are separated in 3 groups + +- `generic` group covering states for the whole device +- `top` and `bottom` group covering states related to top or bottom zone + +#### Generic Group + +Group name `generic`. + +| Channel | Type | Read/Write | Description | +|---------------|----------|------------|------------------------------| +| light-switch | Switch | RW | Control lights for all zones | +| last-update | DateTime | R | Date and Time of last update | +| hint | String | R | General command description | + +#### Zone Groups + +Group `top` and `bottom`. + +The `set-temperature` channel is holding the desired temperature controlled via buttons on the wine cooler device. +Currently it cannot be changed using the API. + +| Channel | Type | Read/Write | Description | +|------------------|-----------------------|------------|------------------------------| +| power | Switch | R | Zone Power | +| temperature | Number:Temperature | R | Current Zone Temperature | +| set-temperature | Number:Temperature | R | Desired Zone Temperature | +| light-switch | Switch | RW | Control lights for this zone | + +## Full Example + +### Thing Configuration + +```java +Thing casokitchen:winecooler-2z:whiny "Whiny Wine Cooler" [ apiKey="ABC", deviceId="XYZ" ] +``` + +### Item Configuration + +```java +Switch Whiny_Generic_LightSwitch {channel="casokitchen:winecooler-2z:whiny:generic#light-switch" } +DateTime Whiny_Generic_LastUpdate {channel="casokitchen:winecooler-2z:whiny:generic#last-update" } +String Whiny_Generic_Hint {channel="casokitchen:winecooler-2z:whiny:generic#hint" } + +Switch Whiny_Top_Power {channel="casokitchen:winecooler-2z:whiny:top#power" } +Number:Temperature Whiny_Top_CurrentTemperature {channel="casokitchen:winecooler-2z:whiny:top#temperature" } +Number:Temperature Whiny_Top_DesiredTemperature {channel="casokitchen:winecooler-2z:whiny:top#set-temperature" } +Switch Whiny_Top_LightSwitch {channel="casokitchen:winecooler-2z:whiny:top#light-switch" } + +Switch Whiny_Bottom_Power {channel="casokitchen:winecooler-2z:whiny:bottom#power" } +Number:Temperature Whiny_Bottom_CurrentTemperature {channel="casokitchen:winecooler-2z:whiny:bottom#temperature" } +Number:Temperature Whiny_Bottom_DesiredTemperature {channel="casokitchen:winecooler-2z:whiny:bottom#set-temperature" } +Switch Whiny_Bottom_LightSwitch {channel="casokitchen:winecooler-2z:whiny:bottom#light-switch" } +``` diff --git a/bundles/org.openhab.binding.casokitchen/pom.xml b/bundles/org.openhab.binding.casokitchen/pom.xml new file mode 100644 index 00000000000..497a5690b98 --- /dev/null +++ b/bundles/org.openhab.binding.casokitchen/pom.xml @@ -0,0 +1,17 @@ + + + + 4.0.0 + + + org.openhab.addons.bundles + org.openhab.addons.reactor.bundles + 5.0.0-SNAPSHOT + + + org.openhab.binding.casokitchen + + openHAB Add-ons :: Bundles :: CasoKitchen Binding + + diff --git a/bundles/org.openhab.binding.casokitchen/src/main/feature/feature.xml b/bundles/org.openhab.binding.casokitchen/src/main/feature/feature.xml new file mode 100644 index 00000000000..bcdbbbd598d --- /dev/null +++ b/bundles/org.openhab.binding.casokitchen/src/main/feature/feature.xml @@ -0,0 +1,9 @@ + + + mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features + + + openhab-runtime-base + mvn:org.openhab.addons.bundles/org.openhab.binding.casokitchen/${project.version} + + diff --git a/bundles/org.openhab.binding.casokitchen/src/main/java/org/openhab/binding/casokitchen/internal/CasoKitchenBindingConstants.java b/bundles/org.openhab.binding.casokitchen/src/main/java/org/openhab/binding/casokitchen/internal/CasoKitchenBindingConstants.java new file mode 100644 index 00000000000..3cbf8fb71f9 --- /dev/null +++ b/bundles/org.openhab.binding.casokitchen/src/main/java/org/openhab/binding/casokitchen/internal/CasoKitchenBindingConstants.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2010-2025 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.casokitchen.internal; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.thing.ThingTypeUID; + +import com.google.gson.Gson; + +/** + * The {@link CasoKitchenBindingConstants} class defines common constants, which are + * used across the whole binding. + * + * @author Bernd Weymann - Initial contribution + */ +@NonNullByDefault +public class CasoKitchenBindingConstants { + private static final String BINDING_ID = "casokitchen"; + + // List of all Thing Type UIDs + public static final ThingTypeUID THING_TYPE_WINECOOLER = new ThingTypeUID(BINDING_ID, "winecooler-2z"); + + // List of all Channel Group ids + public static final String TOP = "top"; + public static final String BOTTOM = "bottom"; + public static final String GENERIC = "generic"; + + // List of all Channel ids + public static final String TEMPERATURE = "temperature"; + public static final String TARGET_TEMPERATURE = "set-temperature"; + public static final String POWER = "power"; + public static final String LIGHT = "light-switch"; + public static final String HINT = "hint"; + public static final String LAST_UPDATE = "last-update"; + + public static final int MINIMUM_REFRESH_INTERVAL_MIN = 5; + public static final String EMPTY = ""; + + public static final String BASE_URL = "https://publickitchenapi.casoapp.com"; + public static final String LIGHT_URL = BASE_URL + "/api/v1.1/Winecooler/SetLight"; + public static final String STATUS_URL = BASE_URL + "/api/v1.1/Winecooler/Status"; + public static final String HTTP_HEADER_API_KEY = "x-api-key"; + + public static final Gson GSON = new Gson(); +} diff --git a/bundles/org.openhab.binding.casokitchen/src/main/java/org/openhab/binding/casokitchen/internal/CasoKitchenHandlerFactory.java b/bundles/org.openhab.binding.casokitchen/src/main/java/org/openhab/binding/casokitchen/internal/CasoKitchenHandlerFactory.java new file mode 100644 index 00000000000..7855a480c43 --- /dev/null +++ b/bundles/org.openhab.binding.casokitchen/src/main/java/org/openhab/binding/casokitchen/internal/CasoKitchenHandlerFactory.java @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2010-2025 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.casokitchen.internal; + +import static org.openhab.binding.casokitchen.internal.CasoKitchenBindingConstants.THING_TYPE_WINECOOLER; + +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.casokitchen.internal.handler.TwoZonesWinecoolerHandler; +import org.openhab.core.i18n.TimeZoneProvider; +import org.openhab.core.io.net.http.HttpClientFactory; +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 CasoKitchenHandlerFactory} is responsible for creating things and thing + * handlers. + * + * @author Bernd Weymann - Initial contribution + */ +@NonNullByDefault +@Component(configurationPid = "binding.casokitchen", service = ThingHandlerFactory.class) +public class CasoKitchenHandlerFactory extends BaseThingHandlerFactory { + + private static final Set SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_WINECOOLER); + private HttpClientFactory httpClientFactory; + private TimeZoneProvider timeZoneProvider; + + @Activate + public CasoKitchenHandlerFactory(final @Reference HttpClientFactory httpClientFactory, + final @Reference TimeZoneProvider tzp) { + this.httpClientFactory = httpClientFactory; + timeZoneProvider = tzp; + } + + @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_WINECOOLER.equals(thingTypeUID)) { + return new TwoZonesWinecoolerHandler(thing, httpClientFactory.getCommonHttpClient(), timeZoneProvider); + } + return null; + } +} diff --git a/bundles/org.openhab.binding.casokitchen/src/main/java/org/openhab/binding/casokitchen/internal/config/TwoZonesWinecoolerConfiguration.java b/bundles/org.openhab.binding.casokitchen/src/main/java/org/openhab/binding/casokitchen/internal/config/TwoZonesWinecoolerConfiguration.java new file mode 100644 index 00000000000..eff861a5a57 --- /dev/null +++ b/bundles/org.openhab.binding.casokitchen/src/main/java/org/openhab/binding/casokitchen/internal/config/TwoZonesWinecoolerConfiguration.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2010-2025 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.casokitchen.internal.config; + +import static org.openhab.binding.casokitchen.internal.CasoKitchenBindingConstants.*; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link TwoZonesWinecoolerConfiguration} class contains fields mapping thing configuration parameters. + * + * @author Bernd Weymann - Initial contribution + */ +@NonNullByDefault +public class TwoZonesWinecoolerConfiguration { + public String apiKey = EMPTY; + public String deviceId = EMPTY; + public int refreshInterval = MINIMUM_REFRESH_INTERVAL_MIN; +} diff --git a/bundles/org.openhab.binding.casokitchen/src/main/java/org/openhab/binding/casokitchen/internal/dto/CallResponse.java b/bundles/org.openhab.binding.casokitchen/src/main/java/org/openhab/binding/casokitchen/internal/dto/CallResponse.java new file mode 100644 index 00000000000..6c5de53f08c --- /dev/null +++ b/bundles/org.openhab.binding.casokitchen/src/main/java/org/openhab/binding/casokitchen/internal/dto/CallResponse.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2010-2025 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.casokitchen.internal.dto; + +import static org.openhab.binding.casokitchen.internal.CasoKitchenBindingConstants.EMPTY; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * {@link CallResponse} class wraps response values of an API call. + * + * @author Bernd Weymann - Initial contribution + */ +@NonNullByDefault +public class CallResponse { + public int status = -1; + public String responseString = EMPTY; +} diff --git a/bundles/org.openhab.binding.casokitchen/src/main/java/org/openhab/binding/casokitchen/internal/dto/LightRequest.java b/bundles/org.openhab.binding.casokitchen/src/main/java/org/openhab/binding/casokitchen/internal/dto/LightRequest.java new file mode 100644 index 00000000000..f195a4e617f --- /dev/null +++ b/bundles/org.openhab.binding.casokitchen/src/main/java/org/openhab/binding/casokitchen/internal/dto/LightRequest.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2010-2025 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.casokitchen.internal.dto; + +import static org.openhab.binding.casokitchen.internal.CasoKitchenBindingConstants.EMPTY; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link LightRequest} class contains fields mapping thing configuration parameters. + * + * @author Bernd Weymann - Initial contribution + */ +@NonNullByDefault +public class LightRequest { + public String technicalDeviceId = EMPTY; + public int zone = -1; + public boolean lightOn = false; + + public boolean isValid() { + return !technicalDeviceId.equals(EMPTY) && zone >= 0; + } +} diff --git a/bundles/org.openhab.binding.casokitchen/src/main/java/org/openhab/binding/casokitchen/internal/dto/StatusRequest.java b/bundles/org.openhab.binding.casokitchen/src/main/java/org/openhab/binding/casokitchen/internal/dto/StatusRequest.java new file mode 100644 index 00000000000..f24014d6381 --- /dev/null +++ b/bundles/org.openhab.binding.casokitchen/src/main/java/org/openhab/binding/casokitchen/internal/dto/StatusRequest.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2010-2025 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.casokitchen.internal.dto; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.casokitchen.internal.CasoKitchenBindingConstants; + +/** + * The {@link StatusRequest} class contains fields mapping thing configuration parameters. + * + * @author Bernd Weymann - Initial contribution + */ +@NonNullByDefault +public class StatusRequest { + + public StatusRequest(String intialValue) { + technicalDeviceId = intialValue; + } + + public String technicalDeviceId = CasoKitchenBindingConstants.EMPTY; +} diff --git a/bundles/org.openhab.binding.casokitchen/src/main/java/org/openhab/binding/casokitchen/internal/dto/StatusResult.java b/bundles/org.openhab.binding.casokitchen/src/main/java/org/openhab/binding/casokitchen/internal/dto/StatusResult.java new file mode 100644 index 00000000000..b986a26cced --- /dev/null +++ b/bundles/org.openhab.binding.casokitchen/src/main/java/org/openhab/binding/casokitchen/internal/dto/StatusResult.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2010-2025 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.casokitchen.internal.dto; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.casokitchen.internal.CasoKitchenBindingConstants; + +/** + * The {@link CasoConfiguration2} class contains fields mapping thing configuration parameters. + * + * @author Bernd Weymann - Initial contribution + */ +@NonNullByDefault +public class StatusResult { + public int temperature1 = -1; + public int targetTemperature1 = -1; + public boolean power1 = false; + public boolean light1 = false; + + public int temperature2 = -1; + public int targetTemperature2 = -1; + public boolean power2 = false; + public boolean light2 = false; + + public String logTimestampUtc = CasoKitchenBindingConstants.EMPTY; + public String hint = CasoKitchenBindingConstants.EMPTY; +} diff --git a/bundles/org.openhab.binding.casokitchen/src/main/java/org/openhab/binding/casokitchen/internal/handler/TwoZonesWinecoolerHandler.java b/bundles/org.openhab.binding.casokitchen/src/main/java/org/openhab/binding/casokitchen/internal/handler/TwoZonesWinecoolerHandler.java new file mode 100644 index 00000000000..6ce369578da --- /dev/null +++ b/bundles/org.openhab.binding.casokitchen/src/main/java/org/openhab/binding/casokitchen/internal/handler/TwoZonesWinecoolerHandler.java @@ -0,0 +1,250 @@ +/* + * Copyright (c) 2010-2025 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.casokitchen.internal.handler; + +import static org.openhab.binding.casokitchen.internal.CasoKitchenBindingConstants.*; + +import java.time.Instant; +import java.time.ZonedDateTime; +import java.util.Optional; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.client.api.ContentResponse; +import org.eclipse.jetty.client.api.Request; +import org.eclipse.jetty.client.util.StringContentProvider; +import org.eclipse.jetty.http.HttpHeader; +import org.openhab.binding.casokitchen.internal.config.TwoZonesWinecoolerConfiguration; +import org.openhab.binding.casokitchen.internal.dto.CallResponse; +import org.openhab.binding.casokitchen.internal.dto.LightRequest; +import org.openhab.binding.casokitchen.internal.dto.StatusRequest; +import org.openhab.binding.casokitchen.internal.dto.StatusResult; +import org.openhab.core.i18n.TimeZoneProvider; +import org.openhab.core.library.types.DateTimeType; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.library.types.QuantityType; +import org.openhab.core.library.types.StringType; +import org.openhab.core.library.unit.SIUnits; +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.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link TwoZonesWinecoolerHandler} is responsible for handling commands, which are + * sent to one of the channels. + * + * @author Bernd Weymann - Initial contribution + */ +@NonNullByDefault +public class TwoZonesWinecoolerHandler extends BaseThingHandler { + + private final Logger logger = LoggerFactory.getLogger(TwoZonesWinecoolerHandler.class); + + private final TimeZoneProvider timeZoneProvider; + private final HttpClient httpClient; + private Optional> refreshJob = Optional.empty(); + private Optional cachedResult = Optional.empty(); + private TwoZonesWinecoolerConfiguration configuration = new TwoZonesWinecoolerConfiguration(); + + public TwoZonesWinecoolerHandler(Thing thing, HttpClient hc, TimeZoneProvider tzp) { + super(thing); + httpClient = hc; + timeZoneProvider = tzp; + } + + @Override + public void initialize() { + configuration = getConfigAs(TwoZonesWinecoolerConfiguration.class); + String configInvalidReason = configValid(); + if (configInvalidReason.isEmpty()) { + updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.NONE, + "@text/casokitchen.winecooler-2z.status.wait-for-response"); + startSchedule(); + } else { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, configInvalidReason); + } + } + + private String configValid() { + if (configuration.apiKey.isBlank()) { + return "@text/casokitchen.winecooler-2z.status.api-key-missing"; + } else if (configuration.deviceId.isBlank()) { + return "@text/casokitchen.winecooler-2z.status.device-id-missing"; + } else if (configuration.refreshInterval < MINIMUM_REFRESH_INTERVAL_MIN) { + return "@text/casokitchen.winecooler-2z.status.refresh-interval [\"" + configuration.refreshInterval + + "\"]"; + } else { + return EMPTY; + } + } + + private void startSchedule() { + refreshJob.ifPresent(job -> { + job.cancel(false); + }); + refreshJob = Optional.of( + scheduler.scheduleWithFixedDelay(this::dataUpdate, 0, configuration.refreshInterval, TimeUnit.MINUTES)); + } + + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + String group = channelUID.getGroupId(); + if (group == null) { + return; // no channels without group defined! + } + if (command instanceof RefreshType) { + cachedResult.ifPresent(result -> { + // update channels from cached result if available + String channel = channelUID.getIdWithoutGroup(); + switch (group) { + case GENERIC: + switch (channel) { + case LIGHT: + updateState(new ChannelUID(thing.getUID(), GENERIC, LIGHT), + OnOffType.from(result.light1 && result.light2)); + break; + case LAST_UPDATE: + Instant timestamp = Instant.parse(result.logTimestampUtc); + updateState(new ChannelUID(thing.getUID(), GENERIC, LAST_UPDATE), + new DateTimeType(timestamp.atZone(timeZoneProvider.getTimeZone()))); + break; + case HINT: + updateState(new ChannelUID(thing.getUID(), GENERIC, HINT), + StringType.valueOf(result.hint)); + break; + } + break; + case TOP: + case BOTTOM: + switch (channel) { + case LIGHT: + updateState(new ChannelUID(thing.getUID(), group, LIGHT), + OnOffType.from(result.light2)); + break; + case POWER: + updateState(new ChannelUID(thing.getUID(), group, POWER), + OnOffType.from(result.power2)); + break; + case TEMPERATURE: + updateState(new ChannelUID(thing.getUID(), group, TEMPERATURE), + QuantityType.valueOf(result.temperature2, SIUnits.CELSIUS)); + break; + case TARGET_TEMPERATURE: + updateState(new ChannelUID(thing.getUID(), group, TARGET_TEMPERATURE), + QuantityType.valueOf(result.targetTemperature2, SIUnits.CELSIUS)); + break; + } + break; + } + }); + } else if (LIGHT.equals(channelUID.getIdWithoutGroup())) { + LightRequest lr = new LightRequest(); + lr.technicalDeviceId = configuration.deviceId; + if (command instanceof OnOffType) { + lr.lightOn = OnOffType.ON.equals(command); + switch (group) { + case GENERIC: + lr.zone = 0; + break; + case TOP: + lr.zone = 1; + break; + case BOTTOM: + lr.zone = 2; + break; + } + CallResponse cr = post(LIGHT_URL, lr); + if (cr.status == 200) { + updateState(new ChannelUID(thing.getUID(), group, LIGHT), OnOffType.from(lr.lightOn)); + } else { + logger.warn("Call to {} responded with status {} reason {}", LIGHT_URL, cr.status, + cr.responseString); + } + } + logger.debug("Cannot handle command {}", command); + } + } + + @Override + public void dispose() { + refreshJob.ifPresent(job -> { + job.cancel(true); + }); + } + + private void dataUpdate() { + StatusRequest requestContent = new StatusRequest(configuration.deviceId); + CallResponse cr = post(STATUS_URL, requestContent); + int responseStatus = cr.status; + String responseContent = cr.responseString; + if (responseStatus == 200) { + updateStatus(ThingStatus.ONLINE); + StatusResult statusResult = GSON.fromJson(responseContent, StatusResult.class); + if (statusResult != null) { + updateChannels(statusResult); + } + } else { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, + "@text/casokitchen.winecooler-2z.status.http-status [\"" + responseStatus + " - " + responseContent + + "\"]"); + } + } + + private void updateChannels(StatusResult result) { + cachedResult = Optional.of(result); + updateState(new ChannelUID(thing.getUID(), GENERIC, HINT), StringType.valueOf(result.hint)); + updateState(new ChannelUID(thing.getUID(), GENERIC, LIGHT), OnOffType.from(result.light1 && result.light2)); + updateState(new ChannelUID(thing.getUID(), TOP, TEMPERATURE), + QuantityType.valueOf(result.temperature1, SIUnits.CELSIUS)); + updateState(new ChannelUID(thing.getUID(), TOP, TARGET_TEMPERATURE), + QuantityType.valueOf(result.targetTemperature1, SIUnits.CELSIUS)); + updateState(new ChannelUID(thing.getUID(), TOP, POWER), OnOffType.from(result.power1)); + updateState(new ChannelUID(thing.getUID(), TOP, LIGHT), OnOffType.from(result.light1)); + updateState(new ChannelUID(thing.getUID(), BOTTOM, TEMPERATURE), + QuantityType.valueOf(result.temperature2, SIUnits.CELSIUS)); + updateState(new ChannelUID(thing.getUID(), BOTTOM, TARGET_TEMPERATURE), + QuantityType.valueOf(result.targetTemperature2, SIUnits.CELSIUS)); + updateState(new ChannelUID(thing.getUID(), BOTTOM, POWER), OnOffType.from(result.power2)); + updateState(new ChannelUID(thing.getUID(), BOTTOM, LIGHT), OnOffType.from(result.light2)); + + ZonedDateTime zdt = Instant.parse(result.logTimestampUtc).atZone(timeZoneProvider.getTimeZone()); + updateState(new ChannelUID(thing.getUID(), GENERIC, LAST_UPDATE), new DateTimeType(zdt)); + } + + private CallResponse post(String url, Object dto) { + Request req = httpClient.POST(url); + req.header(HttpHeader.CONTENT_TYPE, "application/json"); + req.header(HTTP_HEADER_API_KEY, configuration.apiKey); + req.content(new StringContentProvider(GSON.toJson(dto))); + CallResponse callResponse = new CallResponse(); + try { + ContentResponse cr = req.timeout(60, TimeUnit.SECONDS).send(); + callResponse.status = cr.getStatus(); + callResponse.responseString = cr.getContentAsString(); + } catch (InterruptedException | TimeoutException | ExecutionException e) { + String message = e.getMessage(); + callResponse.responseString = ((message != null) ? message : EMPTY); + } + return callResponse; + } +} diff --git a/bundles/org.openhab.binding.casokitchen/src/main/resources/OH-INF/addon/addon.xml b/bundles/org.openhab.binding.casokitchen/src/main/resources/OH-INF/addon/addon.xml new file mode 100644 index 00000000000..392baf398f1 --- /dev/null +++ b/bundles/org.openhab.binding.casokitchen/src/main/resources/OH-INF/addon/addon.xml @@ -0,0 +1,11 @@ + + + + binding + CASO Kitchen Binding + Binding to connect CASO Smart Kitchen devices + cloud + + diff --git a/bundles/org.openhab.binding.casokitchen/src/main/resources/OH-INF/i18n/casokitchen.properties b/bundles/org.openhab.binding.casokitchen/src/main/resources/OH-INF/i18n/casokitchen.properties new file mode 100644 index 00000000000..99257e3e72b --- /dev/null +++ b/bundles/org.openhab.binding.casokitchen/src/main/resources/OH-INF/i18n/casokitchen.properties @@ -0,0 +1,55 @@ +# add-on + +addon.casokitchen.name = CASO Kitchen Binding +addon.casokitchen.description = Binding to connect CASO Smart Kitchen devices + +# thing types + +thing-type.casokitchen.winecooler-2z.label = Wine Cooler 2 Zones +thing-type.casokitchen.winecooler-2z.description = Wine cooler with 2 cooling zones + +# thing types config + +thing-type.config.casokitchen.winecooler-2z.apiKey.label = API Key +thing-type.config.casokitchen.winecooler-2z.apiKey.description = API Key generated via CASO SMart Kitchen API +thing-type.config.casokitchen.winecooler-2z.deviceId.label = Device ID +thing-type.config.casokitchen.winecooler-2z.deviceId.description = Device ID from CASO connected devices +thing-type.config.casokitchen.winecooler-2z.refreshInterval.label = Refresh Interval +thing-type.config.casokitchen.winecooler-2z.refreshInterval.description = Interval the device is polled in minutes. + +# channel group types + +channel-group-type.casokitchen.bottom-values.label = Bottom Zone +channel-group-type.casokitchen.bottom-values.channel.power.label = Zone is Powered +channel-group-type.casokitchen.bottom-values.channel.power.description = Showing if zone is currently powered +channel-group-type.casokitchen.generic-values.label = Generic Values +channel-group-type.casokitchen.top-values.label = Top Zone +channel-group-type.casokitchen.top-values.channel.power.label = Zone is Powered +channel-group-type.casokitchen.top-values.channel.power.description = Showing if zone is currently powered + +# channel types + +channel-type.casokitchen.hint.label = Hint +channel-type.casokitchen.hint.description = Textual hint for device status +channel-type.casokitchen.last-update.label = Last Update +channel-type.casokitchen.last-update.description = Time stamp of latest device communication +channel-type.casokitchen.last-update.state.pattern = %1$tA, %1$td.%1$tm. %1$tH:%1$tM +channel-type.casokitchen.light-switch.label = Light Switch +channel-type.casokitchen.light-switch.description = Switching lights on and off +channel-type.casokitchen.set-temperature.label = Target Temperature +channel-type.casokitchen.set-temperature.description = Target Zone Temperature +channel-type.casokitchen.temperature.label = Temperature +channel-type.casokitchen.temperature.description = Current Zone Temperature + +# channel types + +channel-type.casokitchen.power.label = Zone is Powered +channel-type.casokitchen.power.description = Showing if zone is currently powered + +# status details + +casokitchen.winecooler-2z.status.api-key-missing = API Key is mandatory +casokitchen.winecooler-2z.status.device-id-missing = Device ID is mandatory +casokitchen.winecooler-2z.status.refresh-interval = Refresh interval {0} not supported +casokitchen.winecooler-2z.status.http-status = HTTP Status Code {0} +casokitchen.winecooler-2z.status.wait-for-response = Wait for response diff --git a/bundles/org.openhab.binding.casokitchen/src/main/resources/OH-INF/thing/bottom-zone-group.xml b/bundles/org.openhab.binding.casokitchen/src/main/resources/OH-INF/thing/bottom-zone-group.xml new file mode 100644 index 00000000000..bb58e777669 --- /dev/null +++ b/bundles/org.openhab.binding.casokitchen/src/main/resources/OH-INF/thing/bottom-zone-group.xml @@ -0,0 +1,18 @@ + + + + + + + + Showing if zone is currently powered + + + + + + + diff --git a/bundles/org.openhab.binding.casokitchen/src/main/resources/OH-INF/thing/channel-types.xml b/bundles/org.openhab.binding.casokitchen/src/main/resources/OH-INF/thing/channel-types.xml new file mode 100644 index 00000000000..0b612103b86 --- /dev/null +++ b/bundles/org.openhab.binding.casokitchen/src/main/resources/OH-INF/thing/channel-types.xml @@ -0,0 +1,45 @@ + + + + + Number:Temperature + + Current Zone Temperature + + Temperature + Measurement + + + + + Number:Temperature + + Target Zone Temperature + + Temperature + SetPoint + + + + + Switch + + Switching lights on and off + veto + + + String + + Textual hint for device status + + + + DateTime + + Time stamp of latest device communication + + + diff --git a/bundles/org.openhab.binding.casokitchen/src/main/resources/OH-INF/thing/generic-group.xml b/bundles/org.openhab.binding.casokitchen/src/main/resources/OH-INF/thing/generic-group.xml new file mode 100644 index 00000000000..13e5984a7e0 --- /dev/null +++ b/bundles/org.openhab.binding.casokitchen/src/main/resources/OH-INF/thing/generic-group.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + diff --git a/bundles/org.openhab.binding.casokitchen/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.casokitchen/src/main/resources/OH-INF/thing/thing-types.xml new file mode 100644 index 00000000000..f2f85877ca6 --- /dev/null +++ b/bundles/org.openhab.binding.casokitchen/src/main/resources/OH-INF/thing/thing-types.xml @@ -0,0 +1,33 @@ + + + + + + Wine cooler with 2 cooling zones + + + + + + + + + + + API Key generated via CASO SMart Kitchen API + + + + Device ID from CASO connected devices + + + + Interval the device is polled in minutes. + 5 + + + + diff --git a/bundles/org.openhab.binding.casokitchen/src/main/resources/OH-INF/thing/top-zone-group.xml b/bundles/org.openhab.binding.casokitchen/src/main/resources/OH-INF/thing/top-zone-group.xml new file mode 100644 index 00000000000..122a6cc0040 --- /dev/null +++ b/bundles/org.openhab.binding.casokitchen/src/main/resources/OH-INF/thing/top-zone-group.xml @@ -0,0 +1,18 @@ + + + + + + + + Showing if zone is currently powered + + + + + + + diff --git a/bundles/org.openhab.binding.casokitchen/src/test/java/org/openhab/binding/caso/internal/CallbackMock.java b/bundles/org.openhab.binding.casokitchen/src/test/java/org/openhab/binding/caso/internal/CallbackMock.java new file mode 100644 index 00000000000..04ab6b23d4d --- /dev/null +++ b/bundles/org.openhab.binding.casokitchen/src/test/java/org/openhab/binding/caso/internal/CallbackMock.java @@ -0,0 +1,166 @@ +/* + * Copyright (c) 2010-2025 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.caso.internal; + +import static org.junit.jupiter.api.Assertions.fail; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.config.core.ConfigDescription; +import org.openhab.core.config.core.Configuration; +import org.openhab.core.thing.Bridge; +import org.openhab.core.thing.Channel; +import org.openhab.core.thing.ChannelGroupUID; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingStatus; +import org.openhab.core.thing.ThingStatusInfo; +import org.openhab.core.thing.ThingTypeUID; +import org.openhab.core.thing.ThingUID; +import org.openhab.core.thing.binding.ThingHandlerCallback; +import org.openhab.core.thing.binding.builder.ChannelBuilder; +import org.openhab.core.thing.type.ChannelGroupTypeUID; +import org.openhab.core.thing.type.ChannelTypeUID; +import org.openhab.core.types.Command; +import org.openhab.core.types.State; +import org.openhab.core.types.TimeSeries; + +/** + * {@link CallbackMock} listener for handler updates + * + * @author Bernd Weymann - Initial contribution + */ +@NonNullByDefault +public class CallbackMock implements ThingHandlerCallback { + public Map states = new HashMap<>(); + public ThingStatus thingStatus = ThingStatus.UNINITIALIZED; + + @Override + public void stateUpdated(ChannelUID channelUID, State state) { + states.put(channelUID.toString(), state); + } + + public void waitForFullUpdate(int stateCount) { + Instant startWaiting = Instant.now(); + while (states.size() < stateCount && startWaiting.plus(5, ChronoUnit.SECONDS).isAfter(Instant.now())) { + try { + Thread.sleep(250); + } catch (InterruptedException e) { + fail(e.getMessage()); + } + } + if (!ThingStatus.ONLINE.equals(thingStatus)) { + fail(thingStatus.toString()); + } + } + + @Override + public void postCommand(ChannelUID channelUID, Command command) { + } + + @Override + public void sendTimeSeries(ChannelUID channelUID, TimeSeries timeSeries) { + } + + public void waitForOnline() { + synchronized (this) { + Instant startWaiting = Instant.now(); + while (!ThingStatus.ONLINE.equals(thingStatus) + && startWaiting.plus(5, ChronoUnit.SECONDS).isAfter(Instant.now())) { + try { + wait(250); + } catch (InterruptedException e) { + fail(e.getMessage()); + } + } + if (!ThingStatus.ONLINE.equals(thingStatus)) { + fail(thingStatus.toString()); + } + } + } + + @Override + public void statusUpdated(Thing thing, ThingStatusInfo thingStatusInfo) { + synchronized (this) { + thing.setStatusInfo(thingStatusInfo); + thingStatus = thingStatusInfo.getStatus(); + notifyAll(); + } + } + + @Override + public void thingUpdated(Thing thing) { + } + + @Override + public void validateConfigurationParameters(Thing thing, Map configurationParameters) { + } + + @Override + public void validateConfigurationParameters(Channel channel, Map configurationParameters) { + } + + @Override + public @Nullable ConfigDescription getConfigDescription(ChannelTypeUID channelTypeUID) { + return null; + } + + @Override + public @Nullable ConfigDescription getConfigDescription(ThingTypeUID thingTypeUID) { + return null; + } + + @Override + public void configurationUpdated(Thing thing) { + } + + @Override + public void migrateThingType(Thing thing, ThingTypeUID thingTypeUID, Configuration configuration) { + } + + @Override + public void channelTriggered(Thing thing, ChannelUID channelUID, String event) { + } + + @Override + public ChannelBuilder createChannelBuilder(ChannelUID channelUID, ChannelTypeUID channelTypeUID) { + return ChannelBuilder.create(new ChannelUID("test"), null); + } + + @Override + public ChannelBuilder editChannel(Thing thing, ChannelUID channelUID) { + return ChannelBuilder.create(new ChannelUID("test"), null); + } + + @Override + public List createChannelBuilders(ChannelGroupUID channelGroupUID, + ChannelGroupTypeUID channelGroupTypeUID) { + return List.of(); + } + + @Override + public boolean isChannelLinked(ChannelUID channelUID) { + return false; + } + + @Override + public @Nullable Bridge getBridge(ThingUID bridgeUID) { + return null; + } +} diff --git a/bundles/org.openhab.binding.casokitchen/src/test/java/org/openhab/binding/caso/internal/FactoryMock.java b/bundles/org.openhab.binding.casokitchen/src/test/java/org/openhab/binding/caso/internal/FactoryMock.java new file mode 100644 index 00000000000..80a58a80cdf --- /dev/null +++ b/bundles/org.openhab.binding.casokitchen/src/test/java/org/openhab/binding/caso/internal/FactoryMock.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2010-2025 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.caso.internal; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.casokitchen.internal.CasoKitchenHandlerFactory; +import org.openhab.core.i18n.TimeZoneProvider; +import org.openhab.core.io.net.http.HttpClientFactory; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.binding.ThingHandler; + +/** + * {@link FactoryMock} for creating unit test handlers + * + * @author Bernd Weymann - Initial contribution + */ +@NonNullByDefault +public class FactoryMock extends CasoKitchenHandlerFactory { + + public FactoryMock(HttpClientFactory httpFactory, final TimeZoneProvider tzp) { + super(httpFactory, tzp); + } + + @Override + public @Nullable ThingHandler createHandler(Thing thing) { + return super.createHandler(thing); + } +} diff --git a/bundles/org.openhab.binding.casokitchen/src/test/java/org/openhab/binding/caso/internal/TestHandler.java b/bundles/org.openhab.binding.casokitchen/src/test/java/org/openhab/binding/caso/internal/TestHandler.java new file mode 100644 index 00000000000..7106383a82d --- /dev/null +++ b/bundles/org.openhab.binding.casokitchen/src/test/java/org/openhab/binding/caso/internal/TestHandler.java @@ -0,0 +1,174 @@ +/* + * Copyright (c) 2010-2025 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.caso.internal; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.io.IOException; +import java.nio.charset.Charset; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.time.ZoneId; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.client.api.ContentResponse; +import org.eclipse.jetty.client.api.Request; +import org.junit.jupiter.api.Test; +import org.openhab.binding.casokitchen.internal.CasoKitchenBindingConstants; +import org.openhab.binding.casokitchen.internal.handler.TwoZonesWinecoolerHandler; +import org.openhab.core.config.core.Configuration; +import org.openhab.core.i18n.TimeZoneProvider; +import org.openhab.core.io.net.http.HttpClientFactory; +import org.openhab.core.library.types.DateTimeType; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.library.types.QuantityType; +import org.openhab.core.thing.ThingStatus; +import org.openhab.core.thing.ThingStatusDetail; +import org.openhab.core.thing.ThingStatusInfo; +import org.openhab.core.thing.binding.ThingHandler; +import org.openhab.core.thing.internal.ThingImpl; +import org.openhab.core.types.State; + +/** + * The {@link TestHandler} is testing handler functions + * + * @author Bernd Weymann - Initial contribution + */ +@NonNullByDefault +class TestHandler { + private static final int FULL_UPDATE_COUNT = 11; + + TimeZoneProvider tzp = new TimeZoneProvider() { + @Override + public ZoneId getTimeZone() { + return ZoneId.systemDefault(); + } + }; + + private HttpClientFactory prepareHttpResponse() { + // Prepare http response + HttpClientFactory httpFactory = mock(HttpClientFactory.class); + HttpClient httpClient = mock(HttpClient.class); + when(httpFactory.getCommonHttpClient()).thenReturn(httpClient); + Request httpStatusRequest = mock(Request.class); + when(httpClient.POST(CasoKitchenBindingConstants.STATUS_URL)).thenReturn(httpStatusRequest); + ContentResponse contentResponse = mock(ContentResponse.class); + when(contentResponse.getStatus()).thenReturn(200); + String content = CasoKitchenBindingConstants.EMPTY; + try { + content = Files.readString(Path.of("src/test/resources/", "StatusResponse.json"), Charset.defaultCharset()); + } catch (IOException e) { + fail(e.getMessage()); + } + when(contentResponse.getContentAsString()).thenReturn(content); + when(httpStatusRequest.timeout(anyLong(), any(TimeUnit.class))).thenReturn(httpStatusRequest); + try { + when(httpStatusRequest.send()).thenReturn(contentResponse); + } catch (InterruptedException | TimeoutException | ExecutionException e) { + fail(e.getMessage()); + } + return httpFactory; + } + + @Test + void testConfigErrors() { + ThingImpl thing = new ThingImpl(CasoKitchenBindingConstants.THING_TYPE_WINECOOLER, "test"); + + FactoryMock factory = new FactoryMock(prepareHttpResponse(), tzp); + ThingHandler handler = factory.createHandler(thing); + assertNotNull(handler); + assertTrue(handler instanceof TwoZonesWinecoolerHandler); + TwoZonesWinecoolerHandler winecoolerHandler = (TwoZonesWinecoolerHandler) handler; + CallbackMock callback = new CallbackMock(); + winecoolerHandler.setCallback(callback); + winecoolerHandler.initialize(); + + ThingStatusInfo tsi = thing.getStatusInfo(); + assertEquals(ThingStatus.OFFLINE, tsi.getStatus()); + assertEquals(ThingStatusDetail.CONFIGURATION_ERROR, tsi.getStatusDetail()); + assertEquals("@text/casokitchen.winecooler-2z.status.api-key-missing", tsi.getDescription()); + + Configuration config = new Configuration(); + config.put("apiKey", "abc"); + thing.setConfiguration(config); + winecoolerHandler.initialize(); + tsi = thing.getStatusInfo(); + assertEquals(ThingStatus.OFFLINE, tsi.getStatus()); + assertEquals(ThingStatusDetail.CONFIGURATION_ERROR, tsi.getStatusDetail()); + assertEquals("@text/casokitchen.winecooler-2z.status.device-id-missing", tsi.getDescription()); + + config.put("deviceId", "xyz"); + thing.setConfiguration(config); + winecoolerHandler.initialize(); + tsi = thing.getStatusInfo(); + assertEquals(ThingStatus.UNKNOWN, tsi.getStatus()); + assertEquals(ThingStatusDetail.NONE, tsi.getStatusDetail()); + assertEquals("@text/casokitchen.winecooler-2z.status.wait-for-response", tsi.getDescription()); + } + + @Test + void testHandler() { + // Prepare Thing + ThingImpl thing = new ThingImpl(CasoKitchenBindingConstants.THING_TYPE_WINECOOLER, "test"); + Configuration config = new Configuration(); + config.put("apiKey", "abc"); + config.put("deviceId", "xyz"); + thing.setConfiguration(config); + + // Prepare handler + FactoryMock factory = new FactoryMock(prepareHttpResponse(), tzp); + ThingHandler handler = factory.createHandler(thing); + assertNotNull(handler); + assertTrue(handler instanceof TwoZonesWinecoolerHandler); + TwoZonesWinecoolerHandler winecoolerHandler = (TwoZonesWinecoolerHandler) handler; + CallbackMock callback = new CallbackMock(); + winecoolerHandler.setCallback(callback); + winecoolerHandler.initialize(); + callback.waitForOnline(); + callback.waitForFullUpdate(FULL_UPDATE_COUNT); + + // generic + assertEquals(OnOffType.OFF, callback.states.get("casokitchen:winecooler-2z:test:generic#light-switch")); + State dateTime = callback.states.get("casokitchen:winecooler-2z:test:generic#last-update"); + assertTrue(dateTime instanceof DateTimeType); + Instant lastTimestamp = ((DateTimeType) dateTime).getInstant(); + assertEquals("2024-08-13T23:25:32.238209200Z", lastTimestamp.toString()); + + // top + State currentTopTemp = callback.states.get("casokitchen:winecooler-2z:test:top#temperature"); + assertTrue(currentTopTemp instanceof QuantityType); + assertEquals("9 °C", currentTopTemp.toFullString()); + State currentTopSetTemp = callback.states.get("casokitchen:winecooler-2z:test:top#set-temperature"); + assertTrue(currentTopSetTemp instanceof QuantityType); + assertEquals("9 °C", currentTopSetTemp.toFullString()); + assertEquals(OnOffType.ON, callback.states.get("casokitchen:winecooler-2z:test:top#power")); + assertEquals(OnOffType.OFF, callback.states.get("casokitchen:winecooler-2z:test:top#light-switch")); + + // bottom + State currentBottomTemp = callback.states.get("casokitchen:winecooler-2z:test:bottom#temperature"); + assertTrue(currentBottomTemp instanceof QuantityType); + assertEquals("10 °C", currentBottomTemp.toFullString()); + State currentBottomSetTemp = callback.states.get("casokitchen:winecooler-2z:test:bottom#set-temperature"); + assertTrue(currentBottomSetTemp instanceof QuantityType); + assertEquals("10 °C", currentBottomSetTemp.toFullString()); + assertEquals(OnOffType.ON, callback.states.get("casokitchen:winecooler-2z:test:bottom#power")); + assertEquals(OnOffType.OFF, callback.states.get("casokitchen:winecooler-2z:test:bottom#light-switch")); + } +} diff --git a/bundles/org.openhab.binding.casokitchen/src/test/resources/StatusResponse.json b/bundles/org.openhab.binding.casokitchen/src/test/resources/StatusResponse.json new file mode 100644 index 00000000000..5488dc1b477 --- /dev/null +++ b/bundles/org.openhab.binding.casokitchen/src/test/resources/StatusResponse.json @@ -0,0 +1,13 @@ +{ + "temperature1": 9, + "targetTemperature1": 9, + "temperature2": 10, + "targetTemperature2": 10, + "power1": true, + "power2": true, + "light1": false, + "light2": false, + "logTimestampUtc": "2024-08-13T23:25:32.2382092Z", + "temperatureUnit": "C", + "hint": "Note: Device status and content of this message may differ due to synchronization intervals of distributed systems." +} \ No newline at end of file diff --git a/bundles/pom.xml b/bundles/pom.xml index 9a4125eff6b..2aa1a17fb2f 100644 --- a/bundles/pom.xml +++ b/bundles/pom.xml @@ -98,6 +98,7 @@ org.openhab.binding.bticinosmarther org.openhab.binding.buienradar org.openhab.binding.caddx + org.openhab.binding.casokitchen org.openhab.binding.cbus org.openhab.binding.chatgpt org.openhab.binding.chromecast