[casokitchen] Initial contribution (#18243)

* initial commit

Signed-off-by: Bernd Weymann <bernd.weymann@gmail.com>
pull/18343/head
Bernd Weymann 2025-02-28 14:32:23 +01:00 committed by GitHub
parent 9ff0eba7e7
commit efc103c464
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 1248 additions and 0 deletions

View File

@ -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

View File

@ -311,6 +311,11 @@
<artifactId>org.openhab.binding.caddx</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.casokitchen</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.cbus</artifactId>

View File

@ -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

View File

@ -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" }
```

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.addons.reactor.bundles</artifactId>
<version>5.0.0-SNAPSHOT</version>
</parent>
<artifactId>org.openhab.binding.casokitchen</artifactId>
<name>openHAB Add-ons :: Bundles :: CasoKitchen Binding</name>
</project>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<features name="org.openhab.binding.casokitchen-${project.version}" xmlns="http://karaf.apache.org/xmlns/features/v1.4.0">
<repository>mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features</repository>
<feature name="openhab-binding-casokitchen" description="CasoKitchen Binding" version="${project.version}">
<feature>openhab-runtime-base</feature>
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.casokitchen/${project.version}</bundle>
</feature>
</features>

View File

@ -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();
}

View File

@ -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<ThingTypeUID> 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;
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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<ScheduledFuture<?>> refreshJob = Optional.empty();
private Optional<StatusResult> 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;
}
}

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<addon:addon id="casokitchen" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:addon="https://openhab.org/schemas/addon/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/addon/v1.0.0 https://openhab.org/schemas/addon-1.0.0.xsd">
<type>binding</type>
<name>CASO Kitchen Binding</name>
<description>Binding to connect CASO Smart Kitchen devices</description>
<connection>cloud</connection>
</addon:addon>

View File

@ -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

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<thing:thing-descriptions bindingId="casokitchen"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
<channel-group-type id="bottom-values">
<label>Bottom Zone</label>
<channels>
<channel id="power" typeId="system.power">
<label>Zone is Powered</label>
<description>Showing if zone is currently powered</description>
</channel>
<channel id="temperature" typeId="temperature"/>
<channel id="set-temperature" typeId="set-temperature"/>
<channel id="light-switch" typeId="light-switch"/>
</channels>
</channel-group-type>
</thing:thing-descriptions>

View File

@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<thing:thing-descriptions bindingId="casokitchen"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
<channel-type id="temperature">
<item-type>Number:Temperature</item-type>
<label>Temperature</label>
<description>Current Zone Temperature</description>
<tags>
<tag>Temperature</tag>
<tag>Measurement</tag>
</tags>
<state pattern="%.1f %unit%" readOnly="true"/>
</channel-type>
<channel-type id="set-temperature">
<item-type>Number:Temperature</item-type>
<label>Target Temperature</label>
<description>Target Zone Temperature</description>
<tags>
<tag>Temperature</tag>
<tag>SetPoint</tag>
</tags>
<state pattern="%.1f %unit%"/>
</channel-type>
<channel-type id="light-switch">
<item-type>Switch</item-type>
<label>Light Switch</label>
<description>Switching lights on and off</description>
<autoUpdatePolicy>veto</autoUpdatePolicy>
</channel-type>
<channel-type id="hint">
<item-type>String</item-type>
<label>Hint</label>
<description>Textual hint for device status</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="last-update">
<item-type>DateTime</item-type>
<label>Last Update</label>
<description>Time stamp of latest device communication</description>
<state pattern="%1$tA, %1$td.%1$tm. %1$tH:%1$tM" readOnly="true"/>
</channel-type>
</thing:thing-descriptions>

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<thing:thing-descriptions bindingId="casokitchen"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
<channel-group-type id="generic-values">
<label>Generic Values</label>
<channels>
<channel id="light-switch" typeId="light-switch"/>
<channel id="hint" typeId="hint"/>
<channel id="last-update" typeId="last-update"/>
</channels>
</channel-group-type>
</thing:thing-descriptions>

View File

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<thing:thing-descriptions bindingId="casokitchen"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
<thing-type id="winecooler-2z">
<label>Wine Cooler 2 Zones</label>
<description>Wine cooler with 2 cooling zones</description>
<channel-groups>
<channel-group id="top" typeId="top-values"/>
<channel-group id="bottom" typeId="bottom-values"/>
<channel-group id="generic" typeId="generic-values"/>
</channel-groups>
<config-description>
<parameter name="apiKey" type="text" required="true">
<label>API Key</label>
<description>API Key generated via CASO SMart Kitchen API</description>
</parameter>
<parameter name="deviceId" type="text" required="true">
<label>Device ID</label>
<description>Device ID from CASO connected devices</description>
</parameter>
<parameter name="refreshInterval" type="integer" unit="m" min="5">
<label>Refresh Interval</label>
<description>Interval the device is polled in minutes.</description>
<default>5</default>
</parameter>
</config-description>
</thing-type>
</thing:thing-descriptions>

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<thing:thing-descriptions bindingId="casokitchen"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
<channel-group-type id="top-values">
<label>Top Zone</label>
<channels>
<channel id="power" typeId="system.power">
<label>Zone is Powered</label>
<description>Showing if zone is currently powered</description>
</channel>
<channel id="temperature" typeId="temperature"/>
<channel id="set-temperature" typeId="set-temperature"/>
<channel id="light-switch" typeId="light-switch"/>
</channels>
</channel-group-type>
</thing:thing-descriptions>

View File

@ -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<String, State> 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<String, Object> configurationParameters) {
}
@Override
public void validateConfigurationParameters(Channel channel, Map<String, Object> 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<ChannelBuilder> 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;
}
}

View File

@ -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);
}
}

View File

@ -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"));
}
}

View File

@ -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."
}

View File

@ -98,6 +98,7 @@
<module>org.openhab.binding.bticinosmarther</module>
<module>org.openhab.binding.buienradar</module>
<module>org.openhab.binding.caddx</module>
<module>org.openhab.binding.casokitchen</module>
<module>org.openhab.binding.cbus</module>
<module>org.openhab.binding.chatgpt</module>
<module>org.openhab.binding.chromecast</module>