diff --git a/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/TuyaBindingConstants.java b/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/TuyaBindingConstants.java index 3170653e52..d237766267 100644 --- a/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/TuyaBindingConstants.java +++ b/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/TuyaBindingConstants.java @@ -43,12 +43,15 @@ public class TuyaBindingConstants { // List of all Thing Type UIDs public static final ThingTypeUID THING_TYPE_PROJECT = new ThingTypeUID(BINDING_ID, "project"); public static final ThingTypeUID THING_TYPE_TUYA_DEVICE = new ThingTypeUID(BINDING_ID, "tuyaDevice"); + public static final ThingTypeUID THING_TYPE_TUYA_SUB_DEVICE = new ThingTypeUID(BINDING_ID, "tuyaSubDevice"); + public static final ThingTypeUID THING_TYPE_TUYA_GATEWAY = new ThingTypeUID(BINDING_ID, "tuyaGateway"); public static final String PROPERTY_CATEGORY = "category"; public static final String PROPERTY_MAC = "mac"; public static final String CONFIG_LOCAL_KEY = "localKey"; public static final String CONFIG_DEVICE_ID = "deviceId"; + public static final String CONFIG_DEVICE_UUID = "deviceUuid"; public static final String CONFIG_PRODUCT_ID = "productId"; public static final ChannelTypeUID CHANNEL_TYPE_UID_COLOR = new ChannelTypeUID(BINDING_ID, "color"); diff --git a/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/TuyaDiscoveryService.java b/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/TuyaDiscoveryService.java index e599d628c1..cc1d705d46 100644 --- a/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/TuyaDiscoveryService.java +++ b/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/TuyaDiscoveryService.java @@ -13,13 +13,15 @@ package org.smarthomej.binding.tuya.internal; import static org.smarthomej.binding.tuya.internal.TuyaBindingConstants.CONFIG_DEVICE_ID; +import static org.smarthomej.binding.tuya.internal.TuyaBindingConstants.CONFIG_DEVICE_UUID; import static org.smarthomej.binding.tuya.internal.TuyaBindingConstants.CONFIG_LOCAL_KEY; import static org.smarthomej.binding.tuya.internal.TuyaBindingConstants.CONFIG_PRODUCT_ID; import static org.smarthomej.binding.tuya.internal.TuyaBindingConstants.PROPERTY_CATEGORY; import static org.smarthomej.binding.tuya.internal.TuyaBindingConstants.PROPERTY_MAC; import static org.smarthomej.binding.tuya.internal.TuyaBindingConstants.THING_TYPE_TUYA_DEVICE; +import static org.smarthomej.binding.tuya.internal.TuyaBindingConstants.THING_TYPE_TUYA_GATEWAY; +import static org.smarthomej.binding.tuya.internal.TuyaBindingConstants.THING_TYPE_TUYA_SUB_DEVICE; -import java.util.ArrayList; import java.util.Date; import java.util.HashMap; import java.util.List; @@ -34,7 +36,7 @@ import org.openhab.core.config.discovery.AbstractDiscoveryService; import org.openhab.core.config.discovery.DiscoveryResult; import org.openhab.core.config.discovery.DiscoveryResultBuilder; -import org.openhab.core.storage.Storage; +import org.openhab.core.thing.Thing; import org.openhab.core.thing.ThingTypeUID; import org.openhab.core.thing.ThingUID; import org.openhab.core.thing.binding.ThingHandler; @@ -43,8 +45,10 @@ import org.slf4j.LoggerFactory; import org.smarthomej.binding.tuya.internal.cloud.TuyaOpenAPI; import org.smarthomej.binding.tuya.internal.cloud.dto.DeviceListInfo; -import org.smarthomej.binding.tuya.internal.cloud.dto.DeviceSchema; +import org.smarthomej.binding.tuya.internal.cloud.dto.SubDeviceInfo; import org.smarthomej.binding.tuya.internal.handler.ProjectHandler; +import org.smarthomej.binding.tuya.internal.schema.DeviceSchemaMapper; +import org.smarthomej.binding.tuya.internal.schema.SchemaRegistry; import org.smarthomej.binding.tuya.internal.util.SchemaDp; import com.google.gson.Gson; @@ -56,14 +60,16 @@ */ @NonNullByDefault public class TuyaDiscoveryService extends AbstractDiscoveryService implements ThingHandlerService { - public static final Set SUPPORTED_THING_TYPES = Set.of(THING_TYPE_TUYA_DEVICE); + + public static final Set SUPPORTED_THING_TYPES = Set.of(THING_TYPE_TUYA_DEVICE, + THING_TYPE_TUYA_GATEWAY, THING_TYPE_TUYA_SUB_DEVICE); private static final int SEARCH_TIME = 5; private final Logger logger = LoggerFactory.getLogger(TuyaDiscoveryService.class); private final Gson gson = new Gson(); private @Nullable ProjectHandler bridgeHandler; - private @NonNullByDefault({}) Storage storage; + private @NonNullByDefault({}) SchemaRegistry schemaRegistry; private @Nullable ScheduledFuture discoveryJob; public TuyaDiscoveryService() { @@ -86,6 +92,44 @@ protected void startScan() { } processDeviceResponse(List.of(), api, bridgeHandler, 0); + bridgeHandler.getThingRegistry().stream().filter(t -> t.getThingTypeUID().equals(THING_TYPE_TUYA_GATEWAY)) + .forEach(t -> discoverSubDevices(t, api)); + } + + private void discoverSubDevices(Thing gatewayThing, TuyaOpenAPI api) { + Object gatewayId = gatewayThing.getConfiguration().get(CONFIG_DEVICE_ID); + if (gatewayId == null) { + logger.debug("There is no device id in gateway {} properties", gatewayThing.getUID()); + return; + } + api.getSubDevices(gatewayId.toString()) + .thenAccept(subDevices -> subDevices.forEach(d -> processSubDevice(d, api, gatewayThing.getUID()))); + } + + private void processSubDevice(SubDeviceInfo subDeviceInfo, TuyaOpenAPI api, ThingUID gatewayThingUid) { + Map properties = new HashMap<>(); + properties.put(PROPERTY_CATEGORY, subDeviceInfo.category); + properties.put(CONFIG_DEVICE_UUID, subDeviceInfo.nodeId); + properties.put(CONFIG_PRODUCT_ID, subDeviceInfo.productId); + properties.put(CONFIG_DEVICE_ID, subDeviceInfo.id); + + ThingUID thingUid = new ThingUID(THING_TYPE_TUYA_SUB_DEVICE, gatewayThingUid, subDeviceInfo.id); + + DiscoveryResult discoveryResult = DiscoveryResultBuilder.create(thingUid).withLabel(subDeviceInfo.name) + .withBridge(gatewayThingUid).withRepresentationProperty(CONFIG_DEVICE_ID).withProperties(properties) + .build(); + + refreshSchemaForDevice(subDeviceInfo.id, api); + thingDiscovered(discoveryResult); + } + + private void refreshSchemaForDevice(String deviceId, TuyaOpenAPI api) { + api.getDeviceSchema(deviceId).thenAccept(schema -> { + DeviceSchemaMapper deviceSchemaMapper = new DeviceSchemaMapper(gson); + List schemaDps = deviceSchemaMapper.covert(schema); + SchemaRegistry schemaRegistry = this.schemaRegistry; + schemaRegistry.add(deviceId, schemaDps); + }); } private void processDeviceResponse(List deviceList, TuyaOpenAPI api, ProjectHandler bridgeHandler, @@ -100,45 +144,50 @@ private void processDeviceResponse(List deviceList, TuyaOpenAPI private void processDevice(DeviceListInfo device, TuyaOpenAPI api) { api.getFactoryInformation(List.of(device.id)).thenAccept(fiList -> { - ThingUID thingUid = new ThingUID(THING_TYPE_TUYA_DEVICE, device.id); String deviceMac = fiList.stream().filter(fi -> fi.id.equals(device.id)).findAny().map(fi -> fi.mac) .orElse(""); + deviceMac = Objects.requireNonNull(deviceMac).replaceAll("(..)(?!$)", "$1:"); + if ("wfcon".equals(device.category)) { + processGateway(device, api, deviceMac); + } else if (!device.subDevice) { + processSimpleDevice(device, api, deviceMac); + } + }); + } - Map properties = new HashMap<>(); - properties.put(PROPERTY_CATEGORY, device.category); - properties.put(PROPERTY_MAC, Objects.requireNonNull(deviceMac).replaceAll("(..)(?!$)", "$1:")); - properties.put(CONFIG_LOCAL_KEY, device.localKey); - properties.put(CONFIG_DEVICE_ID, device.id); - properties.put(CONFIG_PRODUCT_ID, device.productId); + private void processGateway(DeviceListInfo device, TuyaOpenAPI api, String deviceMac) { + ThingUID thingUid = new ThingUID(THING_TYPE_TUYA_GATEWAY, device.id); - DiscoveryResult discoveryResult = DiscoveryResultBuilder.create(thingUid).withLabel(device.name) - .withRepresentationProperty(CONFIG_DEVICE_ID).withProperties(properties).build(); + Map properties = new HashMap<>(); + properties.put(PROPERTY_CATEGORY, device.category); + properties.put(PROPERTY_MAC, deviceMac); + properties.put(CONFIG_LOCAL_KEY, device.localKey); + properties.put(CONFIG_DEVICE_ID, device.id); + properties.put(CONFIG_DEVICE_UUID, device.uuid); + properties.put(CONFIG_PRODUCT_ID, device.productId); - api.getDeviceSchema(device.id).thenAccept(schema -> { - List schemaDps = new ArrayList<>(); - schema.functions.forEach(description -> addUniqueSchemaDp(description, schemaDps)); - schema.status.forEach(description -> addUniqueSchemaDp(description, schemaDps)); - storage.put(device.id, gson.toJson(schemaDps)); - }); + DiscoveryResult discoveryResult = DiscoveryResultBuilder.create(thingUid).withLabel(device.name) + .withRepresentationProperty(CONFIG_DEVICE_ID).withProperties(properties).build(); - thingDiscovered(discoveryResult); - }); + thingDiscovered(discoveryResult); } - private void addUniqueSchemaDp(DeviceSchema.Description description, List schemaDps) { - if (description.dp_id == 0 || schemaDps.stream().anyMatch(schemaDp -> schemaDp.id == description.dp_id)) { - // dp is missing or already present, skip it - return; - } - // some devices report the same function code for different dps - // we add an index only if this is the case - String originalCode = description.code; - int index = 1; - while (schemaDps.stream().anyMatch(schemaDp -> schemaDp.code.equals(description.code))) { - description.code = originalCode + "_" + index; - } + private void processSimpleDevice(DeviceListInfo device, TuyaOpenAPI api, String deviceMac) { + ThingUID thingUid = new ThingUID(THING_TYPE_TUYA_DEVICE, device.id); + + Map properties = new HashMap<>(); + properties.put(PROPERTY_CATEGORY, device.category); + properties.put(PROPERTY_MAC, deviceMac); + properties.put(CONFIG_LOCAL_KEY, device.localKey); + properties.put(CONFIG_DEVICE_ID, device.id); + properties.put(CONFIG_DEVICE_UUID, device.uuid); + properties.put(CONFIG_PRODUCT_ID, device.productId); + + DiscoveryResult discoveryResult = DiscoveryResultBuilder.create(thingUid).withLabel(device.name) + .withRepresentationProperty(CONFIG_DEVICE_ID).withProperties(properties).build(); - schemaDps.add(SchemaDp.fromRemoteSchema(gson, description)); + refreshSchemaForDevice(device.id, api); + thingDiscovered(discoveryResult); } @Override @@ -151,7 +200,7 @@ protected synchronized void stopScan() { public void setThingHandler(ThingHandler thingHandler) { if (thingHandler instanceof ProjectHandler) { this.bridgeHandler = (ProjectHandler) thingHandler; - this.storage = ((ProjectHandler) thingHandler).getStorage(); + this.schemaRegistry = ((ProjectHandler) thingHandler).getSchemaRegistry(); } } diff --git a/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/TuyaHandlerFactory.java b/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/TuyaHandlerFactory.java index a43ef24e97..6f3850a76a 100644 --- a/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/TuyaHandlerFactory.java +++ b/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/TuyaHandlerFactory.java @@ -14,36 +14,32 @@ import static org.smarthomej.binding.tuya.internal.TuyaBindingConstants.*; -import java.lang.reflect.Type; -import java.util.List; import java.util.Set; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.eclipse.jetty.client.HttpClient; import org.openhab.core.io.net.http.HttpClientFactory; -import org.openhab.core.storage.Storage; import org.openhab.core.storage.StorageService; +import org.openhab.core.thing.Bridge; import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingRegistry; 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.Deactivate; import org.osgi.service.component.annotations.Reference; import org.smarthomej.binding.tuya.internal.handler.ProjectHandler; import org.smarthomej.binding.tuya.internal.handler.TuyaDeviceHandler; -import org.smarthomej.binding.tuya.internal.local.UdpDiscoveryListener; -import org.smarthomej.binding.tuya.internal.util.SchemaDp; +import org.smarthomej.binding.tuya.internal.handler.TuyaGatewayHandler; +import org.smarthomej.binding.tuya.internal.handler.TuyaSubDeviceHandler; +import org.smarthomej.binding.tuya.internal.local.TuyaDeviceManagerFactory; +import org.smarthomej.binding.tuya.internal.schema.SchemaRegistry; import org.smarthomej.commons.SimpleDynamicCommandDescriptionProvider; import com.google.gson.Gson; -import com.google.gson.reflect.TypeToken; - -import io.netty.channel.EventLoopGroup; -import io.netty.channel.nio.NioEventLoopGroup; /** * The {@link TuyaHandlerFactory} is responsible for creating things and thing @@ -55,32 +51,27 @@ @Component(configurationPid = "binding.tuya", service = ThingHandlerFactory.class) @SuppressWarnings("unused") public class TuyaHandlerFactory extends BaseThingHandlerFactory { + private static final Set SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_PROJECT, - THING_TYPE_TUYA_DEVICE); - private static final Type STORAGE_TYPE = TypeToken.getParameterized(List.class, SchemaDp.class).getType(); + THING_TYPE_TUYA_GATEWAY, THING_TYPE_TUYA_DEVICE, THING_TYPE_TUYA_SUB_DEVICE); private final SimpleDynamicCommandDescriptionProvider dynamicCommandDescriptionProvider; private final HttpClient httpClient; private final Gson gson = new Gson(); - private final UdpDiscoveryListener udpDiscoveryListener; - private final EventLoopGroup eventLoopGroup; - private final Storage storage; + private final TuyaDeviceManagerFactory tuyaDeviceManagerFactory; + private final ThingRegistry thingRegistry; + private final SchemaRegistry schemaRegistry; @Activate public TuyaHandlerFactory(@Reference HttpClientFactory httpClientFactory, @Reference SimpleDynamicCommandDescriptionProvider dynamicCommandDescriptionProvider, - @Reference StorageService storageService) { + @Reference StorageService storageService, @Reference TuyaDeviceManagerFactory tuyaDeviceManagerFactory, + @Reference ThingRegistry thingRegistry, @Reference SchemaRegistry schemaRegistry) { this.httpClient = httpClientFactory.getCommonHttpClient(); this.dynamicCommandDescriptionProvider = dynamicCommandDescriptionProvider; - this.eventLoopGroup = new NioEventLoopGroup(); - this.udpDiscoveryListener = new UdpDiscoveryListener(eventLoopGroup); - this.storage = storageService.getStorage("org.smarthomej.binding.tuya.Schema"); - } - - @Deactivate - public void deactivate() { - udpDiscoveryListener.deactivate(); - eventLoopGroup.shutdownGracefully(); + this.tuyaDeviceManagerFactory = tuyaDeviceManagerFactory; + this.thingRegistry = thingRegistry; + this.schemaRegistry = schemaRegistry; } @Override @@ -93,12 +84,16 @@ public boolean supportsThingType(ThingTypeUID thingTypeUID) { ThingTypeUID thingTypeUID = thing.getThingTypeUID(); if (THING_TYPE_PROJECT.equals(thingTypeUID)) { - return new ProjectHandler(thing, httpClient, storage, gson); + return new ProjectHandler(thing, httpClient, schemaRegistry, gson, thingRegistry); + } else if (THING_TYPE_TUYA_GATEWAY.equals(thingTypeUID)) { + return new TuyaGatewayHandler((Bridge) thing, tuyaDeviceManagerFactory); + } else if (THING_TYPE_TUYA_SUB_DEVICE.equals(thingTypeUID)) { + return new TuyaSubDeviceHandler(thing, schemaRegistry.get(thing.getUID().getId()), + dynamicCommandDescriptionProvider); } else if (THING_TYPE_TUYA_DEVICE.equals(thingTypeUID)) { - return new TuyaDeviceHandler(thing, gson.fromJson(storage.get(thing.getUID().getId()), STORAGE_TYPE), gson, - dynamicCommandDescriptionProvider, eventLoopGroup, udpDiscoveryListener); + return new TuyaDeviceHandler(thing, schemaRegistry.get(thing.getUID().getId()), + dynamicCommandDescriptionProvider, tuyaDeviceManagerFactory); } - return null; } } diff --git a/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/cloud/TuyaOpenAPI.java b/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/cloud/TuyaOpenAPI.java index 122526b47e..2cb8bfd87f 100644 --- a/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/cloud/TuyaOpenAPI.java +++ b/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/cloud/TuyaOpenAPI.java @@ -41,6 +41,7 @@ import org.smarthomej.binding.tuya.internal.cloud.dto.FactoryInformation; import org.smarthomej.binding.tuya.internal.cloud.dto.Login; import org.smarthomej.binding.tuya.internal.cloud.dto.ResultResponse; +import org.smarthomej.binding.tuya.internal.cloud.dto.SubDeviceInfo; import org.smarthomej.binding.tuya.internal.cloud.dto.Token; import org.smarthomej.binding.tuya.internal.config.ProjectConfiguration; import org.smarthomej.binding.tuya.internal.util.CryptoUtil; @@ -163,6 +164,11 @@ public CompletableFuture getDeviceSchema(String deviceId) { .thenCompose(s -> processResponse(s, DeviceSchema.class)); } + public CompletableFuture> getSubDevices(String deviceId) { + return request(HttpMethod.GET, "/v1.0/devices/" + deviceId + "/sub-devices", Map.of(), null).thenCompose( + s -> processResponse(s, TypeToken.getParameterized(List.class, SubDeviceInfo.class).getType())); + } + public CompletableFuture sendCommand(String deviceId, CommandRequest command) { return request(HttpMethod.POST, "/v1.0/iot-03/devices/" + deviceId + "/commands", Map.of(), command) .thenCompose(s -> processResponse(s, Boolean.class)); diff --git a/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/cloud/dto/SubDeviceInfo.java b/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/cloud/dto/SubDeviceInfo.java new file mode 100644 index 0000000000..97c33bbb95 --- /dev/null +++ b/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/cloud/dto/SubDeviceInfo.java @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2021-2023 Contributors to the SmartHome/J 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.smarthomej.binding.tuya.internal.cloud.dto; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +import com.google.gson.annotations.SerializedName; + +/** + * Gateway sub device information + * + * @author Vitalii Herhel - Initial contribution + */ +@NonNullByDefault +public class SubDeviceInfo { + public String id = ""; + public String name = ""; + public String category = ""; + public String icon = ""; + public boolean online = false; + @SerializedName("node_id") + public String nodeId = ""; + @SerializedName("product_id") + public String productId = ""; + @SerializedName("owner_id") + public String ownerId = ""; + @SerializedName("active_time") + public long activeTime = 0; + @SerializedName("update_time") + public long updateTime = 0; + + @Override + public String toString() { + return "SubDeviceInfo{" + "id='" + id + '\'' + ", name='" + name + '\'' + ", category='" + category + '\'' + + ", icon='" + icon + '\'' + ", online=" + online + ", nodeId='" + nodeId + '\'' + ", productId='" + + productId + '\'' + ", ownerId='" + ownerId + '\'' + ", activeTime=" + activeTime + ", updateTime=" + + updateTime + '}'; + } +} diff --git a/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/handler/BaseTuyaDeviceHandler.java b/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/handler/BaseTuyaDeviceHandler.java new file mode 100644 index 0000000000..f9384721a0 --- /dev/null +++ b/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/handler/BaseTuyaDeviceHandler.java @@ -0,0 +1,463 @@ +/** + * Copyright (c) 2021-2023 Contributors to the SmartHome/J 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.smarthomej.binding.tuya.internal.handler; + +import static org.smarthomej.binding.tuya.internal.TuyaBindingConstants.CHANNEL_TYPE_UID_COLOR; +import static org.smarthomej.binding.tuya.internal.TuyaBindingConstants.CHANNEL_TYPE_UID_DIMMER; +import static org.smarthomej.binding.tuya.internal.TuyaBindingConstants.CHANNEL_TYPE_UID_NUMBER; +import static org.smarthomej.binding.tuya.internal.TuyaBindingConstants.CHANNEL_TYPE_UID_STRING; +import static org.smarthomej.binding.tuya.internal.TuyaBindingConstants.CHANNEL_TYPE_UID_SWITCH; +import static org.smarthomej.binding.tuya.internal.TuyaBindingConstants.SCHEMAS; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.cache.ExpiringCache; +import org.openhab.core.cache.ExpiringCacheMap; +import org.openhab.core.config.core.Configuration; +import org.openhab.core.library.types.DecimalType; +import org.openhab.core.library.types.HSBType; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.library.types.PercentType; +import org.openhab.core.library.types.StringType; +import org.openhab.core.thing.Channel; +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.ThingUID; +import org.openhab.core.thing.binding.BaseThingHandler; +import org.openhab.core.thing.binding.ThingHandlerCallback; +import org.openhab.core.thing.binding.builder.ChannelBuilder; +import org.openhab.core.thing.binding.builder.ThingBuilder; +import org.openhab.core.thing.type.ChannelTypeUID; +import org.openhab.core.types.Command; +import org.openhab.core.types.CommandOption; +import org.openhab.core.types.State; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.smarthomej.binding.tuya.internal.config.ChannelConfiguration; +import org.smarthomej.binding.tuya.internal.config.DeviceConfiguration; +import org.smarthomej.binding.tuya.internal.local.DeviceStatusListener; +import org.smarthomej.binding.tuya.internal.local.TuyaDevice; +import org.smarthomej.binding.tuya.internal.local.TuyaDeviceManager; +import org.smarthomej.binding.tuya.internal.util.ConversionUtil; +import org.smarthomej.binding.tuya.internal.util.SchemaDp; +import org.smarthomej.commons.SimpleDynamicCommandDescriptionProvider; + +/** + * The {@link BaseTuyaDeviceHandler} handles commands and state updates for devices with channels + * + * @author Vitalii Herhel - Initial contribution + */ +@NonNullByDefault +public class BaseTuyaDeviceHandler extends BaseThingHandler implements DeviceStatusListener { + + private static final List COLOUR_CHANNEL_CODES = List.of("colour_data"); + private static final List DIMMER_CHANNEL_CODES = List.of("bright_value", "bright_value_1", "bright_value_2", + "temp_value"); + + private final Logger logger = LoggerFactory.getLogger(BaseTuyaDeviceHandler.class); + + private final SimpleDynamicCommandDescriptionProvider dynamicCommandDescriptionProvider; + protected DeviceConfiguration configuration = new DeviceConfiguration(); + protected @Nullable TuyaDeviceManager tuyaDeviceManager; + private final List schemaDps; + private boolean oldColorMode = false; + + private @Nullable ScheduledFuture pollingJob; + + private final Map dpToChannelId = new HashMap<>(); + private final Map> dp2ToChannelId = new HashMap<>(); + private final Map channelIdToChannelTypeUID = new HashMap<>(); + private final Map channelIdToConfiguration = new HashMap<>(); + + private final ExpiringCacheMap deviceStatusCache = new ExpiringCacheMap<>( + Duration.ofSeconds(10)); + private final Map channelStateCache = new HashMap<>(); + + public BaseTuyaDeviceHandler(Thing thing, @Nullable List schemaDps, + SimpleDynamicCommandDescriptionProvider dynamicCommandDescriptionProvider) { + super(thing); + this.dynamicCommandDescriptionProvider = dynamicCommandDescriptionProvider; + this.schemaDps = Objects.requireNonNullElse(schemaDps, List.of()); + } + + private @Nullable TuyaDevice getTuyaDevice() { + TuyaDeviceManager tuyaDeviceManager = this.tuyaDeviceManager; + if (tuyaDeviceManager != null) { + return tuyaDeviceManager.getTuyaDevice(); + } else { + return null; + } + } + + @Override + public void processDeviceStatus(@Nullable String cid, Map deviceStatus) { + logger.trace("'{}' received status message '{}'", thing.getUID(), deviceStatus); + + if (deviceStatus.isEmpty()) { + // if status is empty -> need to use control method to request device status + Map commandRequest = new HashMap<>(); + dpToChannelId.keySet().forEach(dp -> commandRequest.put(dp, null)); + dp2ToChannelId.keySet().forEach(dp -> commandRequest.put(dp, null)); + + TuyaDevice tuyaDevice = getTuyaDevice(); + if (tuyaDevice != null) { + tuyaDevice.set(commandRequest); + } + + return; + } + + deviceStatus.forEach(this::addSingleExpiringCache); + deviceStatus.forEach(this::processChannelStatus); + } + + private void processChannelStatus(Integer dp, Object value) { + String channelId = dpToChannelId.get(dp); + if (channelId != null) { + + ChannelConfiguration configuration = channelIdToConfiguration.get(channelId); + ChannelTypeUID channelTypeUID = channelIdToChannelTypeUID.get(channelId); + + if (configuration == null || channelTypeUID == null) { + logger.warn("Could not find configuration or type for channel '{}' in thing '{}'", channelId, + thing.getUID()); + return; + } + + ChannelConfiguration channelConfiguration = channelIdToConfiguration.get(channelId); + if (channelConfiguration != null && Boolean.FALSE.equals(deviceStatusCache.get(channelConfiguration.dp2))) { + // skip update if the channel is off! + return; + } + + if (value instanceof String && CHANNEL_TYPE_UID_COLOR.equals(channelTypeUID)) { + oldColorMode = ((String) value).length() == 14; + updateState(channelId, ConversionUtil.hexColorDecode((String) value)); + return; + } else if (value instanceof String && CHANNEL_TYPE_UID_STRING.equals(channelTypeUID)) { + updateState(channelId, new StringType((String) value)); + return; + } else if (Double.class.isAssignableFrom(value.getClass()) + && CHANNEL_TYPE_UID_DIMMER.equals(channelTypeUID)) { + updateState(channelId, ConversionUtil.brightnessDecode((double) value, 0, configuration.max)); + return; + } else if (Double.class.isAssignableFrom(value.getClass()) + && CHANNEL_TYPE_UID_NUMBER.equals(channelTypeUID)) { + updateState(channelId, new DecimalType((double) value)); + return; + } else if (Boolean.class.isAssignableFrom(value.getClass()) + && CHANNEL_TYPE_UID_SWITCH.equals(channelTypeUID)) { + updateState(channelId, OnOffType.from((boolean) value)); + return; + } + logger.warn("Could not update channel '{}' of thing '{}' with value '{}'. Datatype incompatible.", + channelId, getThing().getUID(), value); + } else { + // try additional channelDps, only OnOffType + List channelIds = dp2ToChannelId.get(dp); + if (channelIds == null) { + logger.debug("Could not find channel for dp '{}' in thing '{}'", dp, thing.getUID()); + } else { + if (Boolean.class.isAssignableFrom(value.getClass())) { + OnOffType state = OnOffType.from((boolean) value); + channelIds.forEach(ch -> updateState(ch, state)); + return; + } + logger.warn("Could not update channel '{}' of thing '{}' with value {}. Datatype incompatible.", + channelIds, getThing().getUID(), value); + } + } + } + + @Override + public void onConnected() { + updateStatus(ThingStatus.ONLINE); + int pollingInterval = configuration.pollingInterval; + TuyaDevice tuyaDevice = getTuyaDevice(); + if (tuyaDevice != null && pollingInterval > 0) { + pollingJob = scheduler.scheduleWithFixedDelay(tuyaDevice::refreshStatus, pollingInterval, pollingInterval, + TimeUnit.SECONDS); + } + } + + @Override + public void onDisconnected(ThingStatusDetail thingStatusDetail, @Nullable String reason) { + updateStatus(ThingStatus.OFFLINE, thingStatusDetail, reason); + ScheduledFuture pollingJob = this.pollingJob; + if (pollingJob != null) { + pollingJob.cancel(true); + this.pollingJob = null; + } + } + + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + if (getThing().getStatus() != ThingStatus.ONLINE) { + logger.warn("Channel '{}' received a command but device is not ONLINE. Discarding command.", channelUID); + return; + } + + Map commandRequest = new HashMap<>(); + + ChannelTypeUID channelTypeUID = channelIdToChannelTypeUID.get(channelUID.getId()); + ChannelConfiguration configuration = channelIdToConfiguration.get(channelUID.getId()); + if (channelTypeUID == null || configuration == null) { + logger.warn("Could not determine channel type or configuration for channel '{}'. Discarding command.", + channelUID); + return; + } + + if (CHANNEL_TYPE_UID_COLOR.equals(channelTypeUID)) { + if (command instanceof HSBType) { + commandRequest.put(configuration.dp, ConversionUtil.hexColorEncode((HSBType) command, oldColorMode)); + ChannelConfiguration workModeConfig = channelIdToConfiguration.get("work_mode"); + if (workModeConfig != null) { + commandRequest.put(workModeConfig.dp, "colour"); + } + if (configuration.dp2 != 0) { + commandRequest.put(configuration.dp2, ((HSBType) command).getBrightness().doubleValue() > 0.0); + } + } else if (command instanceof PercentType) { + State oldState = channelStateCache.get(channelUID.getId()); + if (!(oldState instanceof HSBType)) { + logger.debug("Discarding command '{}' to channel '{}', cannot determine old state", command, + channelUID); + return; + } + HSBType newState = new HSBType(((HSBType) oldState).getHue(), ((HSBType) oldState).getSaturation(), + (PercentType) command); + commandRequest.put(configuration.dp, ConversionUtil.hexColorEncode(newState, oldColorMode)); + ChannelConfiguration workModeConfig = channelIdToConfiguration.get("work_mode"); + if (workModeConfig != null) { + commandRequest.put(workModeConfig.dp, "colour"); + } + if (configuration.dp2 != 0) { + commandRequest.put(configuration.dp2, ((PercentType) command).doubleValue() > 0.0); + } + } else if (command instanceof OnOffType) { + if (configuration.dp2 != 0) { + commandRequest.put(configuration.dp2, OnOffType.ON.equals(command)); + } + } + } else if (CHANNEL_TYPE_UID_DIMMER.equals(channelTypeUID)) { + if (command instanceof PercentType) { + int value = ConversionUtil.brightnessEncode((PercentType) command, 0, configuration.max); + if (value >= configuration.min) { + commandRequest.put(configuration.dp, value); + } + if (configuration.dp2 != 0) { + commandRequest.put(configuration.dp2, value >= configuration.min); + } + ChannelConfiguration workModeConfig = channelIdToConfiguration.get("work_mode"); + if (workModeConfig != null) { + commandRequest.put(workModeConfig.dp, "white"); + } + } else if (command instanceof OnOffType) { + if (configuration.dp2 != 0) { + commandRequest.put(configuration.dp2, OnOffType.ON.equals(command)); + } + } + } else if (CHANNEL_TYPE_UID_STRING.equals(channelTypeUID)) { + if (command instanceof StringType) { + commandRequest.put(configuration.dp, command.toString()); + } + } else if (CHANNEL_TYPE_UID_NUMBER.equals(channelTypeUID)) { + if (command instanceof DecimalType) { + commandRequest.put(configuration.dp, ((DecimalType) command).intValue()); + } + } else if (CHANNEL_TYPE_UID_SWITCH.equals(channelTypeUID)) { + if (command instanceof OnOffType) { + commandRequest.put(configuration.dp, OnOffType.ON.equals(command)); + } + } + + TuyaDevice tuyaDevice = getTuyaDevice(); + if (!commandRequest.isEmpty() && tuyaDevice != null) { + tuyaDevice.set(commandRequest); + } + } + + @Override + public void dispose() { + ScheduledFuture future = this.pollingJob; + if (future != null) { + future.cancel(true); + } + } + + @Override + public void initialize() { + // clear all maps + dpToChannelId.clear(); + dp2ToChannelId.clear(); + channelIdToChannelTypeUID.clear(); + channelIdToConfiguration.clear(); + + configuration = getConfigAs(DeviceConfiguration.class); + + // check if we have channels and add them if available + if (thing.getChannels().isEmpty()) { + // stored schemas are usually more complete + Map schema = SCHEMAS.get(configuration.productId); + if (schema == null) { + if (!schemaDps.isEmpty()) { + // fallback to retrieved schema + schema = schemaDps.stream().collect(Collectors.toMap(s -> s.code, s -> s)); + } else { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + "No channels added and schema not found."); + return; + } + } + + addChannels(schema); + } + + thing.getChannels().forEach(this::configureChannel); + } + + private void addChannels(Map schema) { + ThingBuilder thingBuilder = editThing(); + ThingUID thingUID = thing.getUID(); + ThingHandlerCallback callback = getCallback(); + + if (callback == null) { + logger.warn("Thing callback not found. Cannot auto-detect thing '{}' channels.", thingUID); + return; + } + + Map channels = new HashMap<>(schema.entrySet().stream().map(e -> { + String channelId = e.getKey(); + SchemaDp schemaDp = e.getValue(); + + ChannelUID channelUID = new ChannelUID(thingUID, channelId); + Map configuration = new HashMap<>(); + configuration.put("dp", schemaDp.id); + + ChannelTypeUID channeltypeUID; + if (COLOUR_CHANNEL_CODES.contains(channelId)) { + channeltypeUID = CHANNEL_TYPE_UID_COLOR; + } else if (DIMMER_CHANNEL_CODES.contains(channelId)) { + channeltypeUID = CHANNEL_TYPE_UID_DIMMER; + configuration.put("min", schemaDp.min); + configuration.put("max", schemaDp.max); + } else if ("bool".equals(schemaDp.type)) { + channeltypeUID = CHANNEL_TYPE_UID_SWITCH; + } else if ("enum".equals(schemaDp.type)) { + channeltypeUID = CHANNEL_TYPE_UID_STRING; + List range = Objects.requireNonNullElse(schemaDp.range, List.of()); + configuration.put("range", String.join(",", range)); + } else if ("string".equals(schemaDp.type)) { + channeltypeUID = CHANNEL_TYPE_UID_STRING; + } else if ("value".equals(schemaDp.type)) { + channeltypeUID = CHANNEL_TYPE_UID_NUMBER; + configuration.put("min", schemaDp.min); + configuration.put("max", schemaDp.max); + } else { + // e.g. type "raw", add empty channel + return Map.entry("", ChannelBuilder.create(channelUID).build()); + } + + return Map.entry(channelId, callback.createChannelBuilder(channelUID, channeltypeUID).withLabel(channelId) + .withConfiguration(new Configuration(configuration)).build()); + }).filter(c -> !c.getKey().isEmpty()).collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue))); + + List channelSuffixes = List.of("", "_1", "_2"); + List switchChannels = List.of("switch_led", "led_switch"); + channelSuffixes.forEach(suffix -> switchChannels.forEach(channel -> { + Channel switchChannel = channels.get(channel + suffix); + if (switchChannel != null) { + // remove switch channel if brightness or color is present and add to dp2 instead + ChannelConfiguration config = switchChannel.getConfiguration().as(ChannelConfiguration.class); + Channel colourChannel = channels.get("colour_data" + suffix); + Channel brightChannel = channels.get("bright_value" + suffix); + boolean remove = false; + + if (colourChannel != null) { + colourChannel.getConfiguration().put("dp2", config.dp); + remove = true; + } + if (brightChannel != null) { + brightChannel.getConfiguration().put("dp2", config.dp); + remove = true; + } + + if (remove) { + channels.remove(channel + suffix); + } + } + })); + + channels.values().forEach(thingBuilder::withChannel); + + updateThing(thingBuilder.build()); + } + + private void configureChannel(Channel channel) { + ChannelConfiguration configuration = channel.getConfiguration().as(ChannelConfiguration.class); + ChannelTypeUID channelTypeUID = channel.getChannelTypeUID(); + + if (channelTypeUID == null) { + logger.warn("Could not determine ChannelTypeUID for '{}'", channel.getUID()); + return; + } + + String channelId = channel.getUID().getId(); + + if (!configuration.range.isEmpty()) { + List commandOptions = toCommandOptionList( + Arrays.stream(configuration.range.split(",")).collect(Collectors.toList())); + dynamicCommandDescriptionProvider.setCommandOptions(channel.getUID(), commandOptions); + } + + dpToChannelId.put(configuration.dp, channelId); + channelIdToConfiguration.put(channelId, configuration); + channelIdToChannelTypeUID.put(channelId, channelTypeUID); + + // check if we have additional DPs (these are switch DP for color or brightness only) + if (configuration.dp2 != 0) { + List list = Objects + .requireNonNull(dp2ToChannelId.computeIfAbsent(configuration.dp2, ArrayList::new)); + list.add(channelId); + } + } + + private List toCommandOptionList(List options) { + return options.stream().map(c -> new CommandOption(c, c)).collect(Collectors.toList()); + } + + private void addSingleExpiringCache(Integer key, Object value) { + ExpiringCache<@Nullable Object> expiringCache = new ExpiringCache<>(Duration.ofSeconds(10), () -> null); + expiringCache.putValue(value); + deviceStatusCache.put(key, expiringCache); + } + + @Override + protected void updateState(String channelId, State state) { + channelStateCache.put(channelId, state); + super.updateState(channelId, state); + } +} diff --git a/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/handler/ProjectHandler.java b/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/handler/ProjectHandler.java index 46b508c741..89821997fb 100644 --- a/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/handler/ProjectHandler.java +++ b/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/handler/ProjectHandler.java @@ -22,9 +22,9 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.eclipse.jetty.client.HttpClient; -import org.openhab.core.storage.Storage; import org.openhab.core.thing.ChannelUID; import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingRegistry; import org.openhab.core.thing.ThingStatus; import org.openhab.core.thing.ThingStatusDetail; import org.openhab.core.thing.binding.BaseThingHandler; @@ -36,6 +36,7 @@ import org.smarthomej.binding.tuya.internal.cloud.dto.DeviceListInfo; import org.smarthomej.binding.tuya.internal.cloud.dto.DeviceSchema; import org.smarthomej.binding.tuya.internal.config.ProjectConfiguration; +import org.smarthomej.binding.tuya.internal.schema.SchemaRegistry; import com.google.gson.Gson; @@ -47,14 +48,17 @@ @NonNullByDefault public class ProjectHandler extends BaseThingHandler implements ApiStatusCallback { private final TuyaOpenAPI api; - private final Storage storage; + private final SchemaRegistry schemaRegistry; + private final ThingRegistry thingRegistry; private @Nullable ScheduledFuture apiConnectFuture; - public ProjectHandler(Thing thing, HttpClient httpClient, Storage storage, Gson gson) { + public ProjectHandler(Thing thing, HttpClient httpClient, SchemaRegistry schemaRegistry, Gson gson, + ThingRegistry thingRegistry) { super(thing); + this.schemaRegistry = schemaRegistry; + this.thingRegistry = thingRegistry; this.api = new TuyaOpenAPI(this, scheduler, gson, httpClient); - this.storage = storage; } @Override @@ -93,8 +97,12 @@ public TuyaOpenAPI getApi() { return api; } - public Storage getStorage() { - return storage; + public SchemaRegistry getSchemaRegistry() { + return schemaRegistry; + } + + public ThingRegistry getThingRegistry() { + return thingRegistry; } public CompletableFuture> getAllDevices(int page) { diff --git a/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/handler/TuyaDeviceHandler.java b/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/handler/TuyaDeviceHandler.java index 2bb464b858..a239dda0ee 100644 --- a/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/handler/TuyaDeviceHandler.java +++ b/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/handler/TuyaDeviceHandler.java @@ -12,497 +12,45 @@ */ package org.smarthomej.binding.tuya.internal.handler; -import static org.smarthomej.binding.tuya.internal.TuyaBindingConstants.*; - -import java.nio.charset.StandardCharsets; -import java.time.Duration; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.HashMap; import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.concurrent.ScheduledFuture; -import java.util.concurrent.TimeUnit; -import java.util.stream.Collectors; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; -import org.openhab.core.cache.ExpiringCache; -import org.openhab.core.cache.ExpiringCacheMap; -import org.openhab.core.config.core.Configuration; -import org.openhab.core.library.types.DecimalType; -import org.openhab.core.library.types.HSBType; -import org.openhab.core.library.types.OnOffType; -import org.openhab.core.library.types.PercentType; -import org.openhab.core.library.types.StringType; -import org.openhab.core.thing.Channel; -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.ThingUID; -import org.openhab.core.thing.binding.BaseThingHandler; -import org.openhab.core.thing.binding.ThingHandlerCallback; -import org.openhab.core.thing.binding.builder.ChannelBuilder; -import org.openhab.core.thing.binding.builder.ThingBuilder; -import org.openhab.core.thing.type.ChannelTypeUID; -import org.openhab.core.types.Command; -import org.openhab.core.types.CommandOption; -import org.openhab.core.types.State; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.smarthomej.binding.tuya.internal.config.ChannelConfiguration; -import org.smarthomej.binding.tuya.internal.config.DeviceConfiguration; -import org.smarthomej.binding.tuya.internal.local.DeviceInfoSubscriber; -import org.smarthomej.binding.tuya.internal.local.DeviceStatusListener; -import org.smarthomej.binding.tuya.internal.local.TuyaDevice; -import org.smarthomej.binding.tuya.internal.local.UdpDiscoveryListener; -import org.smarthomej.binding.tuya.internal.local.dto.DeviceInfo; -import org.smarthomej.binding.tuya.internal.util.ConversionUtil; +import org.smarthomej.binding.tuya.internal.local.TuyaDeviceManager; +import org.smarthomej.binding.tuya.internal.local.TuyaDeviceManagerFactory; import org.smarthomej.binding.tuya.internal.util.SchemaDp; import org.smarthomej.commons.SimpleDynamicCommandDescriptionProvider; -import com.google.gson.Gson; - -import io.netty.channel.EventLoopGroup; - /** * The {@link TuyaDeviceHandler} handles commands and state updates * * @author Jan N. Klug - Initial contribution */ @NonNullByDefault -public class TuyaDeviceHandler extends BaseThingHandler implements DeviceInfoSubscriber, DeviceStatusListener { - private static final List COLOUR_CHANNEL_CODES = List.of("colour_data"); - private static final List DIMMER_CHANNEL_CODES = List.of("bright_value", "bright_value_1", "bright_value_2", - "temp_value"); - - private final Logger logger = LoggerFactory.getLogger(TuyaDeviceHandler.class); - - private final Gson gson; - private final UdpDiscoveryListener udpDiscoveryListener; - private final SimpleDynamicCommandDescriptionProvider dynamicCommandDescriptionProvider; - private final EventLoopGroup eventLoopGroup; - private DeviceConfiguration configuration = new DeviceConfiguration(); - private @Nullable TuyaDevice tuyaDevice; - private final List schemaDps; - private boolean oldColorMode = false; - - private @Nullable ScheduledFuture reconnectFuture; - private @Nullable ScheduledFuture pollingJob; - - private boolean disposing = false; - - private final Map dpToChannelId = new HashMap<>(); - private final Map> dp2ToChannelId = new HashMap<>(); - private final Map channelIdToChannelTypeUID = new HashMap<>(); - private final Map channelIdToConfiguration = new HashMap<>(); - - private final ExpiringCacheMap deviceStatusCache = new ExpiringCacheMap<>( - Duration.ofSeconds(10)); - private final Map channelStateCache = new HashMap<>(); - - public TuyaDeviceHandler(Thing thing, @Nullable List schemaDps, Gson gson, - SimpleDynamicCommandDescriptionProvider dynamicCommandDescriptionProvider, EventLoopGroup eventLoopGroup, - UdpDiscoveryListener udpDiscoveryListener) { - super(thing); - this.gson = gson; - this.udpDiscoveryListener = udpDiscoveryListener; - this.eventLoopGroup = eventLoopGroup; - this.dynamicCommandDescriptionProvider = dynamicCommandDescriptionProvider; - this.schemaDps = Objects.requireNonNullElse(schemaDps, List.of()); - } - - @Override - public void processDeviceStatus(Map deviceStatus) { - logger.trace("'{}' received status message '{}'", thing.getUID(), deviceStatus); - - if (deviceStatus.isEmpty()) { - // if status is empty -> need to use control method to request device status - Map commandRequest = new HashMap<>(); - dpToChannelId.keySet().forEach(dp -> commandRequest.put(dp, null)); - dp2ToChannelId.keySet().forEach(dp -> commandRequest.put(dp, null)); - - TuyaDevice tuyaDevice = this.tuyaDevice; - if (tuyaDevice != null) { - tuyaDevice.set(commandRequest); - } - - return; - } - - deviceStatus.forEach(this::addSingleExpiringCache); - deviceStatus.forEach(this::processChannelStatus); - } - - private void processChannelStatus(Integer dp, Object value) { - String channelId = dpToChannelId.get(dp); - if (channelId != null) { - - ChannelConfiguration configuration = channelIdToConfiguration.get(channelId); - ChannelTypeUID channelTypeUID = channelIdToChannelTypeUID.get(channelId); - - if (configuration == null || channelTypeUID == null) { - logger.warn("Could not find configuration or type for channel '{}' in thing '{}'", channelId, - thing.getUID()); - return; - } +public class TuyaDeviceHandler extends BaseTuyaDeviceHandler { - ChannelConfiguration channelConfiguration = channelIdToConfiguration.get(channelId); - if (channelConfiguration != null && Boolean.FALSE.equals(deviceStatusCache.get(channelConfiguration.dp2))) { - // skip update if the channel is off! - return; - } + private final TuyaDeviceManagerFactory tuyaDeviceManagerFactory; - if (value instanceof String && CHANNEL_TYPE_UID_COLOR.equals(channelTypeUID)) { - oldColorMode = ((String) value).length() == 14; - updateState(channelId, ConversionUtil.hexColorDecode((String) value)); - return; - } else if (value instanceof String && CHANNEL_TYPE_UID_STRING.equals(channelTypeUID)) { - updateState(channelId, new StringType((String) value)); - return; - } else if (Double.class.isAssignableFrom(value.getClass()) - && CHANNEL_TYPE_UID_DIMMER.equals(channelTypeUID)) { - updateState(channelId, ConversionUtil.brightnessDecode((double) value, 0, configuration.max)); - return; - } else if (Double.class.isAssignableFrom(value.getClass()) - && CHANNEL_TYPE_UID_NUMBER.equals(channelTypeUID)) { - updateState(channelId, new DecimalType((double) value)); - return; - } else if (Boolean.class.isAssignableFrom(value.getClass()) - && CHANNEL_TYPE_UID_SWITCH.equals(channelTypeUID)) { - updateState(channelId, OnOffType.from((boolean) value)); - return; - } - logger.warn("Could not update channel '{}' of thing '{}' with value '{}'. Datatype incompatible.", - channelId, getThing().getUID(), value); - } else { - // try additional channelDps, only OnOffType - List channelIds = dp2ToChannelId.get(dp); - if (channelIds == null) { - logger.debug("Could not find channel for dp '{}' in thing '{}'", dp, thing.getUID()); - } else { - if (Boolean.class.isAssignableFrom(value.getClass())) { - OnOffType state = OnOffType.from((boolean) value); - channelIds.forEach(ch -> updateState(ch, state)); - return; - } - logger.warn("Could not update channel '{}' of thing '{}' with value {}. Datatype incompatible.", - channelIds, getThing().getUID(), value); - } - } - } - - @Override - public void connectionStatus(boolean status) { - if (status) { - updateStatus(ThingStatus.ONLINE); - int pollingInterval = configuration.pollingInterval; - TuyaDevice tuyaDevice = this.tuyaDevice; - if (tuyaDevice != null && pollingInterval > 0) { - pollingJob = scheduler.scheduleWithFixedDelay(tuyaDevice::refreshStatus, pollingInterval, - pollingInterval, TimeUnit.SECONDS); - } - } else { - updateStatus(ThingStatus.OFFLINE); - ScheduledFuture pollingJob = this.pollingJob; - if (pollingJob != null) { - pollingJob.cancel(true); - this.pollingJob = null; - } - TuyaDevice tuyaDevice = this.tuyaDevice; - ScheduledFuture reconnectFuture = this.reconnectFuture; - // only re-connect if a device is present, we are not disposing the thing and either the reconnectFuture is - // empty or already done - if (tuyaDevice != null && !disposing && (reconnectFuture == null || reconnectFuture.isDone())) { - this.reconnectFuture = scheduler.schedule(tuyaDevice::connect, 5000, TimeUnit.MILLISECONDS); - } - } - } - - @Override - public void handleCommand(ChannelUID channelUID, Command command) { - if (getThing().getStatus() != ThingStatus.ONLINE) { - logger.warn("Channel '{}' received a command but device is not ONLINE. Discarding command.", channelUID); - return; - } - - Map commandRequest = new HashMap<>(); - - ChannelTypeUID channelTypeUID = channelIdToChannelTypeUID.get(channelUID.getId()); - ChannelConfiguration configuration = channelIdToConfiguration.get(channelUID.getId()); - if (channelTypeUID == null || configuration == null) { - logger.warn("Could not determine channel type or configuration for channel '{}'. Discarding command.", - channelUID); - return; - } - - if (CHANNEL_TYPE_UID_COLOR.equals(channelTypeUID)) { - if (command instanceof HSBType) { - commandRequest.put(configuration.dp, ConversionUtil.hexColorEncode((HSBType) command, oldColorMode)); - ChannelConfiguration workModeConfig = channelIdToConfiguration.get("work_mode"); - if (workModeConfig != null) { - commandRequest.put(workModeConfig.dp, "colour"); - } - if (configuration.dp2 != 0) { - commandRequest.put(configuration.dp2, ((HSBType) command).getBrightness().doubleValue() > 0.0); - } - } else if (command instanceof PercentType) { - State oldState = channelStateCache.get(channelUID.getId()); - if (!(oldState instanceof HSBType)) { - logger.debug("Discarding command '{}' to channel '{}', cannot determine old state", command, - channelUID); - return; - } - HSBType newState = new HSBType(((HSBType) oldState).getHue(), ((HSBType) oldState).getSaturation(), - (PercentType) command); - commandRequest.put(configuration.dp, ConversionUtil.hexColorEncode(newState, oldColorMode)); - ChannelConfiguration workModeConfig = channelIdToConfiguration.get("work_mode"); - if (workModeConfig != null) { - commandRequest.put(workModeConfig.dp, "colour"); - } - if (configuration.dp2 != 0) { - commandRequest.put(configuration.dp2, ((PercentType) command).doubleValue() > 0.0); - } - } else if (command instanceof OnOffType) { - if (configuration.dp2 != 0) { - commandRequest.put(configuration.dp2, OnOffType.ON.equals(command)); - } - } - } else if (CHANNEL_TYPE_UID_DIMMER.equals(channelTypeUID)) { - if (command instanceof PercentType) { - int value = ConversionUtil.brightnessEncode((PercentType) command, 0, configuration.max); - if (value >= configuration.min) { - commandRequest.put(configuration.dp, value); - } - if (configuration.dp2 != 0) { - commandRequest.put(configuration.dp2, value >= configuration.min); - } - ChannelConfiguration workModeConfig = channelIdToConfiguration.get("work_mode"); - if (workModeConfig != null) { - commandRequest.put(workModeConfig.dp, "white"); - } - } else if (command instanceof OnOffType) { - if (configuration.dp2 != 0) { - commandRequest.put(configuration.dp2, OnOffType.ON.equals(command)); - } - } - } else if (CHANNEL_TYPE_UID_STRING.equals(channelTypeUID)) { - if (command instanceof StringType) { - commandRequest.put(configuration.dp, command.toString()); - } - } else if (CHANNEL_TYPE_UID_NUMBER.equals(channelTypeUID)) { - if (command instanceof DecimalType) { - commandRequest.put(configuration.dp, ((DecimalType) command).intValue()); - } - } else if (CHANNEL_TYPE_UID_SWITCH.equals(channelTypeUID)) { - if (command instanceof OnOffType) { - commandRequest.put(configuration.dp, OnOffType.ON.equals(command)); - } - } - - TuyaDevice tuyaDevice = this.tuyaDevice; - if (!commandRequest.isEmpty() && tuyaDevice != null) { - tuyaDevice.set(commandRequest); - } - } - - @Override - public void dispose() { - disposing = true; - ScheduledFuture future = reconnectFuture; - if (future != null) { - future.cancel(true); - } - future = this.pollingJob; - if (future != null) { - future.cancel(true); - } - if (configuration.ip.isEmpty()) { - // unregister listener only if IP is not fixed - udpDiscoveryListener.unregisterListener(this); - } - TuyaDevice tuyaDevice = this.tuyaDevice; - if (tuyaDevice != null) { - tuyaDevice.dispose(); - this.tuyaDevice = null; - } + public TuyaDeviceHandler(Thing thing, @Nullable List schemaDps, + SimpleDynamicCommandDescriptionProvider dynamicCommandDescriptionProvider, + TuyaDeviceManagerFactory tuyaDeviceManagerFactory) { + super(thing, schemaDps, dynamicCommandDescriptionProvider); + this.tuyaDeviceManagerFactory = tuyaDeviceManagerFactory; } @Override public void initialize() { - // clear all maps - dpToChannelId.clear(); - dp2ToChannelId.clear(); - channelIdToChannelTypeUID.clear(); - channelIdToConfiguration.clear(); - - configuration = getConfigAs(DeviceConfiguration.class); - - // check if we have channels and add them if available - if (thing.getChannels().isEmpty()) { - // stored schemas are usually more complete - Map schema = SCHEMAS.get(configuration.productId); - if (schema == null) { - if (!schemaDps.isEmpty()) { - // fallback to retrieved schema - schema = schemaDps.stream().collect(Collectors.toMap(s -> s.code, s -> s)); - } else { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, - "No channels added and schema not found."); - return; - } - } - - addChannels(schema); - } - - thing.getChannels().forEach(this::configureChannel); - - if (!configuration.ip.isBlank()) { - deviceInfoChanged(new DeviceInfo(configuration.ip, configuration.protocol)); - } else { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_PENDING, "Waiting for IP address"); - udpDiscoveryListener.registerListener(configuration.deviceId, this); - } - - disposing = false; + super.initialize(); + tuyaDeviceManager = tuyaDeviceManagerFactory.create(getThing(), this, scheduler); } @Override - public void deviceInfoChanged(DeviceInfo deviceInfo) { - logger.info("Configuring IP address '{}' for thing '{}'.", deviceInfo, thing.getUID()); - - TuyaDevice tuyaDevice = this.tuyaDevice; - if (tuyaDevice != null) { - tuyaDevice.dispose(); - } - updateStatus(ThingStatus.UNKNOWN); - - this.tuyaDevice = new TuyaDevice(gson, this, eventLoopGroup, configuration.deviceId, - configuration.localKey.getBytes(StandardCharsets.UTF_8), deviceInfo.ip, deviceInfo.protocolVersion); - } - - private void addChannels(Map schema) { - ThingBuilder thingBuilder = editThing(); - ThingUID thingUID = thing.getUID(); - ThingHandlerCallback callback = getCallback(); - - if (callback == null) { - logger.warn("Thing callback not found. Cannot auto-detect thing '{}' channels.", thingUID); - return; - } - - Map channels = new HashMap<>(schema.entrySet().stream().map(e -> { - String channelId = e.getKey(); - SchemaDp schemaDp = e.getValue(); - - ChannelUID channelUID = new ChannelUID(thingUID, channelId); - Map configuration = new HashMap<>(); - configuration.put("dp", schemaDp.id); - - ChannelTypeUID channeltypeUID; - if (COLOUR_CHANNEL_CODES.contains(channelId)) { - channeltypeUID = CHANNEL_TYPE_UID_COLOR; - } else if (DIMMER_CHANNEL_CODES.contains(channelId)) { - channeltypeUID = CHANNEL_TYPE_UID_DIMMER; - configuration.put("min", schemaDp.min); - configuration.put("max", schemaDp.max); - } else if ("bool".equals(schemaDp.type)) { - channeltypeUID = CHANNEL_TYPE_UID_SWITCH; - } else if ("enum".equals(schemaDp.type)) { - channeltypeUID = CHANNEL_TYPE_UID_STRING; - List range = Objects.requireNonNullElse(schemaDp.range, List.of()); - configuration.put("range", String.join(",", range)); - } else if ("string".equals(schemaDp.type)) { - channeltypeUID = CHANNEL_TYPE_UID_STRING; - } else if ("value".equals(schemaDp.type)) { - channeltypeUID = CHANNEL_TYPE_UID_NUMBER; - configuration.put("min", schemaDp.min); - configuration.put("max", schemaDp.max); - } else { - // e.g. type "raw", add empty channel - return Map.entry("", ChannelBuilder.create(channelUID).build()); - } - - return Map.entry(channelId, callback.createChannelBuilder(channelUID, channeltypeUID).withLabel(channelId) - .withConfiguration(new Configuration(configuration)).build()); - }).filter(c -> !c.getKey().isEmpty()).collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue))); - - List channelSuffixes = List.of("", "_1", "_2"); - List switchChannels = List.of("switch_led", "led_switch"); - channelSuffixes.forEach(suffix -> switchChannels.forEach(channel -> { - Channel switchChannel = channels.get(channel + suffix); - if (switchChannel != null) { - // remove switch channel if brightness or color is present and add to dp2 instead - ChannelConfiguration config = switchChannel.getConfiguration().as(ChannelConfiguration.class); - Channel colourChannel = channels.get("colour_data" + suffix); - Channel brightChannel = channels.get("bright_value" + suffix); - boolean remove = false; - - if (colourChannel != null) { - colourChannel.getConfiguration().put("dp2", config.dp); - remove = true; - } - if (brightChannel != null) { - brightChannel.getConfiguration().put("dp2", config.dp); - remove = true; - } - - if (remove) { - channels.remove(channel + suffix); - } - } - })); - - channels.values().forEach(thingBuilder::withChannel); - - updateThing(thingBuilder.build()); - } - - private void configureChannel(Channel channel) { - ChannelConfiguration configuration = channel.getConfiguration().as(ChannelConfiguration.class); - ChannelTypeUID channelTypeUID = channel.getChannelTypeUID(); - - if (channelTypeUID == null) { - logger.warn("Could not determine ChannelTypeUID for '{}'", channel.getUID()); - return; - } - - String channelId = channel.getUID().getId(); - - if (!configuration.range.isEmpty()) { - List commandOptions = toCommandOptionList( - Arrays.stream(configuration.range.split(",")).collect(Collectors.toList())); - dynamicCommandDescriptionProvider.setCommandOptions(channel.getUID(), commandOptions); - } - - dpToChannelId.put(configuration.dp, channelId); - channelIdToConfiguration.put(channelId, configuration); - channelIdToChannelTypeUID.put(channelId, channelTypeUID); - - // check if we have additional DPs (these are switch DP for color or brightness only) - if (configuration.dp2 != 0) { - List list = Objects - .requireNonNull(dp2ToChannelId.computeIfAbsent(configuration.dp2, ArrayList::new)); - list.add(channelId); + public void dispose() { + super.dispose(); + TuyaDeviceManager tuyaDeviceManager = this.tuyaDeviceManager; + if (tuyaDeviceManager != null) { + tuyaDeviceManager.dispose(); } } - - private List toCommandOptionList(List options) { - return options.stream().map(c -> new CommandOption(c, c)).collect(Collectors.toList()); - } - - private void addSingleExpiringCache(Integer key, Object value) { - ExpiringCache<@Nullable Object> expiringCache = new ExpiringCache<>(Duration.ofSeconds(10), () -> null); - expiringCache.putValue(value); - deviceStatusCache.put(key, expiringCache); - } - - @Override - protected void updateState(String channelId, State state) { - channelStateCache.put(channelId, state); - super.updateState(channelId, state); - } } diff --git a/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/handler/TuyaGatewayHandler.java b/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/handler/TuyaGatewayHandler.java new file mode 100644 index 0000000000..5cc2653668 --- /dev/null +++ b/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/handler/TuyaGatewayHandler.java @@ -0,0 +1,141 @@ +/** + * Copyright (c) 2021-2023 Contributors to the SmartHome/J 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.smarthomej.binding.tuya.internal.handler; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.thing.Bridge; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingStatus; +import org.openhab.core.thing.ThingStatusDetail; +import org.openhab.core.thing.binding.BaseBridgeHandler; +import org.openhab.core.thing.binding.ThingHandler; +import org.openhab.core.types.Command; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.smarthomej.binding.tuya.internal.TuyaBindingConstants; +import org.smarthomej.binding.tuya.internal.local.DeviceStatusListener; +import org.smarthomej.binding.tuya.internal.local.TuyaDeviceManager; +import org.smarthomej.binding.tuya.internal.local.TuyaDeviceManagerFactory; + +/** + * The {@link TuyaGatewayHandler} gateway bridge handler + * + * @author Vitalii Herhel - Initial contribution + */ +@NonNullByDefault +public class TuyaGatewayHandler extends BaseBridgeHandler implements DeviceStatusListener { + + private final Logger logger = LoggerFactory.getLogger(TuyaGatewayHandler.class); + private final TuyaDeviceManagerFactory tuyaDeviceManagerFactory; + private @Nullable TuyaDeviceManager tuyaDeviceManager; + + private final Map subDeviceHandlers = Collections.synchronizedMap(new HashMap<>()); + + public TuyaGatewayHandler(Bridge bridge, TuyaDeviceManagerFactory tuyaDeviceManagerFactory) { + super(bridge); + this.tuyaDeviceManagerFactory = tuyaDeviceManagerFactory; + } + + @Override + public void initialize() { + tuyaDeviceManager = tuyaDeviceManagerFactory.create(getThing(), this, scheduler); + } + + @Override + public void childHandlerInitialized(ThingHandler thingHandler, Thing thing) { + if (thingHandler instanceof TuyaSubDeviceHandler) { + registerSubDevice((TuyaSubDeviceHandler) thingHandler); + } else { + logger.warn("Unsupported sub device handler: " + thingHandler.getClass()); + } + } + + public void registerSubDevice(TuyaSubDeviceHandler subDeviceHandler) { + subDeviceHandler.setTuyaDeviceManager(tuyaDeviceManager); + Optional deviceUuid = Optional + .ofNullable(subDeviceHandler.getThing().getConfiguration().get(TuyaBindingConstants.CONFIG_DEVICE_UUID)) + .map(Objects::toString); + if (deviceUuid.isPresent()) { + subDeviceHandlers.put(deviceUuid.get(), subDeviceHandler); + } else { + logger.warn("Sub device handler " + subDeviceHandler.getThing().getUID() + " doesn't have deviceUuid"); + } + } + + @Override + public void childHandlerDisposed(ThingHandler thingHandler, Thing thing) { + if (thingHandler instanceof TuyaSubDeviceHandler) { + deregisterSubDevice((TuyaSubDeviceHandler) thingHandler); + } else { + logger.warn("Unsupported sub device handler: " + thingHandler.getClass()); + } + } + + public void deregisterSubDevice(TuyaSubDeviceHandler subDeviceHandler) { + subDeviceHandler.setTuyaDeviceManager(null); + Optional deviceUuid = Optional + .ofNullable(subDeviceHandler.getThing().getConfiguration().get(TuyaBindingConstants.CONFIG_DEVICE_UUID)) + .map(Objects::toString); + if (deviceUuid.isPresent()) { + subDeviceHandlers.remove(deviceUuid.get()); + } else { + logger.warn("Sub device handler " + subDeviceHandler.getThing().getUID() + " doesn't have deviceUuid"); + } + } + + @Override + public @Nullable Bridge getBridge() { + return super.getBridge(); + } + + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + } + + @Override + public void processDeviceStatus(@Nullable String cid, Map deviceStatus) { + TuyaSubDeviceHandler tuyaSubDeviceHandler = subDeviceHandlers.get(cid); + if (tuyaSubDeviceHandler != null) { + tuyaSubDeviceHandler.processDeviceStatus(cid, deviceStatus); + } else { + logger.debug("Received sub device status, but there is no sub device with cid " + cid); + } + } + + @Override + public void onConnected() { + updateStatus(ThingStatus.ONLINE); + } + + @Override + public void onDisconnected(ThingStatusDetail thingStatusDetail, @Nullable String reason) { + updateStatus(ThingStatus.OFFLINE, thingStatusDetail, reason); + subDeviceHandlers.values().forEach(s -> s.onDisconnected(thingStatusDetail, reason)); + } + + @Override + public void dispose() { + TuyaDeviceManager tuyaDeviceManager = this.tuyaDeviceManager; + if (tuyaDeviceManager != null) { + tuyaDeviceManager.dispose(); + } + } +} diff --git a/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/handler/TuyaSubDeviceHandler.java b/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/handler/TuyaSubDeviceHandler.java new file mode 100644 index 0000000000..b3cce8ad72 --- /dev/null +++ b/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/handler/TuyaSubDeviceHandler.java @@ -0,0 +1,74 @@ +/** + * Copyright (c) 2021-2023 Contributors to the SmartHome/J 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.smarthomej.binding.tuya.internal.handler; + +import java.util.List; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.thing.Bridge; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingStatus; +import org.openhab.core.thing.ThingStatusInfo; +import org.smarthomej.binding.tuya.internal.local.TuyaDeviceManager; +import org.smarthomej.binding.tuya.internal.util.SchemaDp; +import org.smarthomej.commons.SimpleDynamicCommandDescriptionProvider; + +/** + * The {@link TuyaSubDeviceHandler} handles commands and state updates for sub device + * + * @author Vitalii Herhel - Initial contribution + */ +@NonNullByDefault +public class TuyaSubDeviceHandler extends BaseTuyaDeviceHandler { + + public TuyaSubDeviceHandler(Thing thing, @Nullable List schemaDps, + SimpleDynamicCommandDescriptionProvider dynamicCommandDescriptionProvider) { + super(thing, schemaDps, dynamicCommandDescriptionProvider); + } + + @Override + public void initialize() { + super.initialize(); + if (tuyaDeviceManager != null) { + updateStatus(ThingStatus.ONLINE); + } else { + updateStatus(ThingStatus.OFFLINE); + } + } + + @Override + public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) { + Bridge bridge = getBridge(); + if (bridge != null) { + TuyaGatewayHandler gatewayHandler = (TuyaGatewayHandler) bridge.getHandler(); + if (gatewayHandler != null) { + if (bridgeStatusInfo.getStatus() == ThingStatus.ONLINE) { + gatewayHandler.registerSubDevice(this); + } else { + gatewayHandler.deregisterSubDevice(this); + } + } + } + } + + public BaseTuyaDeviceHandler setTuyaDeviceManager(@Nullable TuyaDeviceManager tuyaDeviceManager) { + this.tuyaDeviceManager = tuyaDeviceManager; + if (tuyaDeviceManager == null) { + updateStatus(ThingStatus.OFFLINE); + } else { + updateStatus(ThingStatus.ONLINE); + } + return this; + } +} diff --git a/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/local/DeviceStatusListener.java b/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/local/DeviceStatusListener.java index 05614009df..6197925037 100644 --- a/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/local/DeviceStatusListener.java +++ b/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/local/DeviceStatusListener.java @@ -15,6 +15,8 @@ import java.util.Map; import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.thing.ThingStatusDetail; /** * The {@link DeviceStatusListener} encapsulates device status data @@ -23,7 +25,9 @@ */ @NonNullByDefault public interface DeviceStatusListener { - void processDeviceStatus(Map deviceStatus); + void processDeviceStatus(@Nullable String cid, Map deviceStatus); - void connectionStatus(boolean status); + void onConnected(); + + void onDisconnected(ThingStatusDetail thingStatusDetail, @Nullable String reason); } diff --git a/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/local/TuyaDevice.java b/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/local/TuyaDevice.java index 8a30f86f1b..9f1443ba0d 100644 --- a/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/local/TuyaDevice.java +++ b/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/local/TuyaDevice.java @@ -27,6 +27,7 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.thing.ThingStatusDetail; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.smarthomej.binding.tuya.internal.local.handlers.HeartbeatHandler; @@ -157,11 +158,11 @@ public void operationComplete(@NonNullByDefault({}) ChannelFuture channelFuture) requestStatus(); } } else { + String message = Objects.requireNonNullElse(channelFuture.cause().getMessage(), ""); logger.debug("{}{}: Failed to connect: {}", deviceId, - Objects.requireNonNullElse(channelFuture.channel().remoteAddress(), ""), - channelFuture.cause().getMessage()); + Objects.requireNonNullElse(channelFuture.channel().remoteAddress(), ""), message); this.channel = null; - deviceStatusListener.connectionStatus(false); + deviceStatusListener.onDisconnected(ThingStatusDetail.COMMUNICATION_ERROR, message); } } diff --git a/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/local/TuyaDeviceManager.java b/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/local/TuyaDeviceManager.java new file mode 100644 index 0000000000..51ab03217d --- /dev/null +++ b/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/local/TuyaDeviceManager.java @@ -0,0 +1,131 @@ +/** + * Copyright (c) 2021-2023 Contributors to the SmartHome/J 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.smarthomej.binding.tuya.internal.local; + +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingStatusDetail; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.smarthomej.binding.tuya.internal.config.DeviceConfiguration; +import org.smarthomej.binding.tuya.internal.local.dto.DeviceInfo; + +import com.google.gson.Gson; + +import io.netty.channel.EventLoopGroup; + +/** + * {@link TuyaDevice} manager. It creates {@link TuyaDevice} and keeps it connected + * + * @author Vitalii Herhel - Initial contribution + */ +public class TuyaDeviceManager implements DeviceInfoSubscriber, DeviceStatusListener { + + private final Logger logger = LoggerFactory.getLogger(TuyaDeviceManager.class); + + private final Thing thing; + private final DeviceStatusListener deviceStatusListener; + private final Gson gson; + private final EventLoopGroup eventLoopGroup; + private final DeviceConfiguration configuration; + private final UdpDiscoveryListener udpDiscoveryListener; + private final ScheduledExecutorService scheduler; + private @Nullable TuyaDevice tuyaDevice; + private @Nullable ScheduledFuture reconnectFuture; + private boolean disposing = false; + + public TuyaDeviceManager(Thing thing, DeviceStatusListener deviceStatusListener, Gson gson, + EventLoopGroup eventLoopGroup, DeviceConfiguration configuration, UdpDiscoveryListener udpDiscoveryListener, + ScheduledExecutorService scheduler) { + this.thing = thing; + this.deviceStatusListener = deviceStatusListener; + this.gson = gson; + this.eventLoopGroup = eventLoopGroup; + this.configuration = configuration; + this.udpDiscoveryListener = udpDiscoveryListener; + this.scheduler = scheduler; + init(); + } + + private void init() { + if (!configuration.ip.isBlank()) { + deviceInfoChanged(new DeviceInfo(configuration.ip, configuration.protocol)); + } else { + deviceStatusListener.onDisconnected(ThingStatusDetail.CONFIGURATION_PENDING, "Waiting for IP address"); + udpDiscoveryListener.registerListener(configuration.deviceId, this); + } + } + + @Override + public void deviceInfoChanged(DeviceInfo deviceInfo) { + logger.info("Configuring IP address '{}' for thing '{}'.", deviceInfo, thing.getUID()); + + TuyaDevice tuyaDevice = this.tuyaDevice; + if (tuyaDevice != null) { + tuyaDevice.dispose(); + } + deviceStatusListener.onDisconnected(ThingStatusDetail.NONE, ""); + + this.tuyaDevice = new TuyaDevice(gson, this, eventLoopGroup, configuration.deviceId, + configuration.localKey.getBytes(StandardCharsets.UTF_8), deviceInfo.ip, deviceInfo.protocolVersion); + } + + public TuyaDevice getTuyaDevice() { + return tuyaDevice; + } + + @Override + public void processDeviceStatus(@Nullable String cid, Map deviceStatus) { + deviceStatusListener.processDeviceStatus(cid, deviceStatus); + } + + @Override + public void onConnected() { + deviceStatusListener.onConnected(); + } + + @Override + public void onDisconnected(ThingStatusDetail thingStatusDetail, @Nullable String reason) { + TuyaDevice tuyaDevice = this.tuyaDevice; + ScheduledFuture reconnectFuture = this.reconnectFuture; + // only re-connect if a device is present, we are not disposing the thing and either the reconnectFuture is + // empty or already done + if (tuyaDevice != null && !disposing && (reconnectFuture == null || reconnectFuture.isDone())) { + this.reconnectFuture = scheduler.schedule(tuyaDevice::connect, 5000, TimeUnit.MILLISECONDS); + } + deviceStatusListener.onDisconnected(thingStatusDetail, reason); + } + + public void dispose() { + disposing = true; + ScheduledFuture future = reconnectFuture; + if (future != null) { + future.cancel(true); + } + if (configuration.ip.isEmpty()) { + // unregister listener only if IP is not fixed + udpDiscoveryListener.unregisterListener(this); + } + TuyaDevice tuyaDevice = this.tuyaDevice; + if (tuyaDevice != null) { + tuyaDevice.dispose(); + this.tuyaDevice = null; + } + } +} diff --git a/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/local/TuyaDeviceManagerFactory.java b/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/local/TuyaDeviceManagerFactory.java new file mode 100644 index 0000000000..e3af7aa669 --- /dev/null +++ b/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/local/TuyaDeviceManagerFactory.java @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2021-2023 Contributors to the SmartHome/J 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.smarthomej.binding.tuya.internal.local; + +import java.util.concurrent.ScheduledExecutorService; + +import org.openhab.core.thing.Thing; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Deactivate; +import org.smarthomej.binding.tuya.internal.config.DeviceConfiguration; + +import com.google.gson.Gson; + +import io.netty.channel.EventLoopGroup; +import io.netty.channel.nio.NioEventLoopGroup; + +/** + * Has all the required dependencies and creates {@link TuyaDeviceManager} + * + * @author Vitalii Herhel - Initial contribution + */ +@Component(service = TuyaDeviceManagerFactory.class, configurationPid = "tuya.deviceManagerFactory") +public class TuyaDeviceManagerFactory { + + private final Gson gson = new Gson(); + private final EventLoopGroup eventLoopGroup; + private final UdpDiscoveryListener udpDiscoveryListener; + + @Activate + public TuyaDeviceManagerFactory() { + this.eventLoopGroup = new NioEventLoopGroup(); + this.udpDiscoveryListener = new UdpDiscoveryListener(eventLoopGroup); + } + + public TuyaDeviceManager create(Thing thing, DeviceStatusListener deviceStatusListener, + ScheduledExecutorService scheduler) { + return new TuyaDeviceManager(thing, deviceStatusListener, gson, eventLoopGroup, + thing.getConfiguration().as(DeviceConfiguration.class), udpDiscoveryListener, scheduler); + } + + @Deactivate + public void deactivate() { + udpDiscoveryListener.deactivate(); + eventLoopGroup.shutdownGracefully(); + } +} diff --git a/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/local/dto/TcpStatusPayload.java b/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/local/dto/TcpStatusPayload.java index c5561beec2..2aa40fe933 100644 --- a/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/local/dto/TcpStatusPayload.java +++ b/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/local/dto/TcpStatusPayload.java @@ -27,14 +27,15 @@ public class TcpStatusPayload { public String devId = ""; public String gwId = ""; public String uid = ""; + public String cid = ""; public long t = 0; public Map dps = Map.of(); public Data data = new Data(); @Override public String toString() { - return "TcpStatusPayload{protocol=" + protocol + ", devId='" + devId + "', gwId='" + gwId + "', uid='" + uid - + "', t=" + t + ", dps=" + dps + ", data=" + data + "}"; + return "TcpStatusPayload{protocol=" + protocol + ", devId='" + devId + "', gwId='" + gwId + "', cid='" + cid + + "', uid='" + uid + "', t=" + t + ", dps=" + dps + ", data=" + data + "}"; } public static class Data { diff --git a/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/local/handlers/TuyaMessageHandler.java b/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/local/handlers/TuyaMessageHandler.java index 734d4288ac..782b1cdb1a 100644 --- a/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/local/handlers/TuyaMessageHandler.java +++ b/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/local/handlers/TuyaMessageHandler.java @@ -17,6 +17,7 @@ import java.util.Objects; import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.thing.ThingStatusDetail; import org.openhab.core.util.HexUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -54,14 +55,14 @@ public TuyaMessageHandler(String deviceId, TuyaDevice.KeyStore keyStore, public void channelActive(@NonNullByDefault({}) ChannelHandlerContext ctx) throws Exception { logger.debug("{}{}: Connection established.", deviceId, Objects.requireNonNullElse(ctx.channel().remoteAddress(), "")); - deviceStatusListener.connectionStatus(true); + deviceStatusListener.onConnected(); } @Override public void channelInactive(@NonNullByDefault({}) ChannelHandlerContext ctx) throws Exception { logger.debug("{}{}: Connection terminated.", deviceId, Objects.requireNonNullElse(ctx.channel().remoteAddress(), "")); - deviceStatusListener.connectionStatus(false); + deviceStatusListener.onDisconnected(ThingStatusDetail.COMMUNICATION_ERROR, "Connection terminated"); } @Override @@ -72,16 +73,18 @@ public void channelRead(@NonNullByDefault({}) ChannelHandlerContext ctx, @NonNul MessageWrapper m = (MessageWrapper) msg; if (m.commandType == CommandType.DP_QUERY || m.commandType == CommandType.STATUS) { Map stateMap = null; + String cid = null; if (m.content instanceof TcpStatusPayload) { TcpStatusPayload payload = (TcpStatusPayload) Objects.requireNonNull(m.content); stateMap = payload.protocol == 4 ? payload.data.dps : payload.dps; + cid = payload.cid; } if (stateMap != null && !stateMap.isEmpty()) { - deviceStatusListener.processDeviceStatus(stateMap); + deviceStatusListener.processDeviceStatus(cid, stateMap); } } else if (m.commandType == CommandType.DP_QUERY_NOT_SUPPORTED) { - deviceStatusListener.processDeviceStatus(Map.of()); + deviceStatusListener.processDeviceStatus(null, Map.of()); } else if (m.commandType == CommandType.SESS_KEY_NEG_RESPONSE) { byte[] localKeyHmac = CryptoUtil.hmac(keyStore.getRandom(), keyStore.getDeviceKey()); byte[] localKeyExpectedHmac = Arrays.copyOfRange((byte[]) m.content, 16, 16 + 32); diff --git a/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/schema/DeviceSchemaMapper.java b/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/schema/DeviceSchemaMapper.java new file mode 100644 index 0000000000..797f8cd3e7 --- /dev/null +++ b/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/schema/DeviceSchemaMapper.java @@ -0,0 +1,58 @@ +/** + * Copyright (c) 2021-2023 Contributors to the SmartHome/J 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.smarthomej.binding.tuya.internal.schema; + +import java.util.ArrayList; +import java.util.List; + +import org.smarthomej.binding.tuya.internal.cloud.dto.DeviceSchema; +import org.smarthomej.binding.tuya.internal.util.SchemaDp; + +import com.google.gson.Gson; + +/** + * Maps {@link DeviceSchema} to {@link List} + * + * @author Vitalii Herhel - Initial contribution + */ +public class DeviceSchemaMapper { + + private final Gson gson; + + public DeviceSchemaMapper(Gson gson) { + this.gson = gson; + } + + public List covert(DeviceSchema schema) { + List schemaDps = new ArrayList<>(); + schema.functions.forEach(description -> addUniqueSchemaDp(description, schemaDps)); + schema.status.forEach(description -> addUniqueSchemaDp(description, schemaDps)); + return schemaDps; + } + + private void addUniqueSchemaDp(DeviceSchema.Description description, List schemaDps) { + if (description.dp_id == 0 || schemaDps.stream().anyMatch(schemaDp -> schemaDp.id == description.dp_id)) { + // dp is missing or already present, skip it + return; + } + // some devices report the same function code for different dps + // we add an index only if this is the case + String originalCode = description.code; + int index = 1; + while (schemaDps.stream().anyMatch(schemaDp -> schemaDp.code.equals(description.code))) { + description.code = originalCode + "_" + index; + } + + schemaDps.add(SchemaDp.fromRemoteSchema(gson, description)); + } +} diff --git a/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/schema/SchemaRegistry.java b/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/schema/SchemaRegistry.java new file mode 100644 index 0000000000..e57adb309b --- /dev/null +++ b/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/schema/SchemaRegistry.java @@ -0,0 +1,53 @@ +/** + * Copyright (c) 2021-2023 Contributors to the SmartHome/J 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.smarthomej.binding.tuya.internal.schema; + +import java.lang.reflect.Type; +import java.util.List; + +import org.openhab.core.storage.Storage; +import org.openhab.core.storage.StorageService; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.smarthomej.binding.tuya.internal.util.SchemaDp; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; + +/** + * Registry for device channels + * + * @author Vitalii Herhel - Initial contribution + */ +@Component(service = SchemaRegistry.class, configurationPid = "tuya.schemaService") +public class SchemaRegistry { + + private static final Type STORAGE_TYPE = TypeToken.getParameterized(List.class, SchemaDp.class).getType(); + + private final Gson gson = new Gson(); + private final Storage storage; + + @Activate + public SchemaRegistry(@Reference StorageService storageService) { + this.storage = storageService.getStorage("org.smarthomej.binding.tuya.Schema"); + } + + public void add(String id, List schema) { + storage.put(id, gson.toJson(schema)); + } + + public List get(String id) { + return gson.fromJson(storage.get(id), STORAGE_TYPE); + } +} diff --git a/bundles/org.smarthomej.binding.tuya/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.smarthomej.binding.tuya/src/main/resources/OH-INF/thing/thing-types.xml index ad292a8872..45b67a9e58 100644 --- a/bundles/org.smarthomej.binding.tuya/src/main/resources/OH-INF/thing/thing-types.xml +++ b/bundles/org.smarthomej.binding.tuya/src/main/resources/OH-INF/thing/thing-types.xml @@ -57,6 +57,71 @@ + + + A Tuya gateway. Can be a bridge for BLE and ZigBee devices + + + + + + + + + + + + + + Auto-detected if device is on same subnet or broadcast forwarding configured. + true + + + + + + + + + true + true + + + + + + + 0 + false + true + + + + + + + + + + + A Tuya device connected through a gateway. Can be extended with channels. + + + + + + + + + + + 0 + false + true + + + +