diff --git a/CODEOWNERS b/CODEOWNERS index 1d0223de2abd..d9e2c7383f23 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -206,7 +206,7 @@ /bundles/org.openhab.binding.nikohomecontrol/ @mherwege /bundles/org.openhab.binding.novafinedust/ @t2000 /bundles/org.openhab.binding.ntp/ @marcelrv -/bundles/org.openhab.binding.nuki/ @mkatter +/bundles/org.openhab.binding.nuki/ @janvyb /bundles/org.openhab.binding.nuvo/ @mlobstein /bundles/org.openhab.binding.nzwateralerts/ @cossey /bundles/org.openhab.binding.oceanic/ @kgoderis diff --git a/bundles/org.openhab.binding.nuki/README.md b/bundles/org.openhab.binding.nuki/README.md index 19bd16cf6844..7ab814fbfaf2 100644 --- a/bundles/org.openhab.binding.nuki/README.md +++ b/bundles/org.openhab.binding.nuki/README.md @@ -1,15 +1,14 @@ # Nuki Binding This is the binding for the [Nuki Smart Lock](https://nuki.io). -This binding allows you to integrate, view, control and configure the Nuki Bridge and Nuki Smart Locks. +This binding allows you to integrate, view, control and configure the Nuki Bridge, Nuki Smart Lock and Nuki Opener. ## Prerequisites -1. At least one Nuki Smart Lock which is paired via Bluetooth with a Nuki Bridge. For this go and get either: - * a [Nuki Smart Lock](https://nuki.io/en/smart-lock/) and a [Nuki Bridge](https://nuki.io/en/bridge/) or - * the [Nuki Combo](https://nuki.io/en/shop/nuki-combo/) or - * a [Nuki Smart Lock](https://nuki.io/en/smart-lock/) and the Nuki [Nuki Software Bridge](https://play.google.com/store/apps/details?id=io.nuki.bridge) -2. The Bridge HTTP-API has to be enabled during [Initial Bridge setup](https://nuki.io/en/support/bridge/bridge-setup/initial-bridge-setup/). Note down the IP, Port and API token. +1. At least one Nuki Smart Lock or Nuki Opener which is paired via Bluetooth with a Nuki Bridge. For this go and get either: + * [Nuki Smart Lock](https://nuki.io/en/smart-lock/) and a [Nuki Bridge](https://nuki.io/en/bridge/) + * [Nuki Combo](https://nuki.io/en/shop/nuki-combo/) +2. The Bridge HTTP-API has to be enabled during [Initial Bridge setup](https://nuki.io/en/support/bridge/bridge-setup/initial-bridge-setup/). It is absolutely recommended to configure static IP addresses for both, the openHAB server and the Nuki Bridge! @@ -24,7 +23,7 @@ The Sheet [NukiBridgeAPI](https://docs.google.com/spreadsheets/d/1SGKWhqwqRyOGbv ## Supported Bridges -This binding supports just one bridge type: The Nuki Bridge. Create one `bridge` per Nuki Bridge available in your home automation environment. +This binding supports just one bridge type: The Nuki Bridge (`nuki:bridge`). Create one `bridge` per Nuki Bridge available in your home automation environment. The following configuration options are available: @@ -34,35 +33,139 @@ The following configuration options are available: | port | The Port which you configured during [Initial Bridge setup](https://nuki.io/en/support/bridge/bridge-setup/initial-bridge-setup/). | Default 8080 | | apiToken | The API Token which you configured during [Initial Bridge setup](https://nuki.io/en/support/bridge/bridge-setup/initial-bridge-setup/). | Required | | manageCallbacks | Let the Nuki Binding manage the callbacks on the Nuki Bridge. It will add the required callback on the Nuki Bridge. If there are already 3 callbacks, it **will delete** the callback with ID `0`. | Default true | +| secureToken | Whether hashed token should be used when communicating with Nuki Bridge. If disabled, API token will be sent in plaintext with each request. | Default true | -## Supported Things +### Bridge discovery -This binding support just one thing type: The Nuki Smart Lock. Create one `smartlock` per Nuki Smart Lock available in you home automation environment. +Bridges on local network can be discovered automatically if both Nuki Bridge and openHAB have working internet connection. You can check whether discovery +is working by checking [discovery API endpoint](https://api.nuki.io/discover/bridges). To discover bridges do the following: -The following configuration options are available: +* In openHAB UI add new thing, select Nuki Binding and start scan. LED on bridge should light up. +* Within 30s press button on Nuki Bridge you want to discover. +* Bridge should appear in inbox. -| Parameter | Description | Comment | -| --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | -| nukiId | The `Nuki-ID` of the Nuki Smart Lock. It is a 8-digit hexadecimal string. Look it up on the sticker on the back of the Nuki Smart Lock (remove mounting plate). | Required | -| unlatch | If set to `true` the Nuki Smart Lock will unlock the door but then also automatically pull the latch of the door lock. Usually, if the door hinges are correctly adjusted, the door will then swing open. | Default false | +Pressing bridge button is required for binding to obtain valid API token. If the button isn't pressed during discovery, bridge will +be created but token must be set manually for binding to work. -## Supported Channels +If bridge is connected to network but not discovered, enter [Manage Bridge](https://support.nuki.io/hc/en-us/articles/360016489018-Manage-Bridge) menu +in Nuki mobile app, check server connection then disconnect and let the bridge restart. -- **lock** (Switch) - Use this channel with a Switch Item to lock and unlock the door. +## Supported Things -- **lockState** (Number) - Use this channel if you want to execute other supported lock actions or to display the current lock state. - Supported Lock Actions are: `2` (Unlock), `7` (Unlatch), `1002` (Lock 'n' Go), `1007` (Lock 'n' Go with Unlatch) and `4` (Lock). - Supported Lock States are : `1` (Locked), `2` (Unlocking), `3` (Unlocked), `4` (Locking), `7` (Unlatching), `1002` (Unlocking initiated through Lock 'n' Go) and `1007` (Unlatching initiated through Lock 'n' Go with Unlatch). - Unfortunately the Nuki Bridge is not reporting any transition states (e.g. for Lock 'n' Go). +This binding supports 2 things - Nuki Smart Lock (`nuki:smartlock`) and Nuki Opener (`nuki:opener`). Both devices can be added using discovery after bridge they are +connected to is configured and online. -- **lowBattery** (Switch) - Use this channel to receive a low battery warning. +### Nuki Smart Lock + +The following configuration options are available: -- **doorsensorState** (Number) - Use this channel if you want to display the current door state provided by the door sensor. - Supported Door Sensor States are : `0` (Unavailable), `1` (Deactivated), `2` (Closed), `3` (Open), `4` (Unknown) and `5` (Calibrating). + | Parameter | Description | Comment | + |-----------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------| + | unlatch | If set to `true` the Nuki Smart Lock will unlock the door but then also automatically pull the latch of the door lock. Usually, if the door hinges are correctly adjusted, the door will then swing open. | Default false | + +#### Supported Channels + + | Channel | Type | Description | + |------------------|--------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| + | lock | Switch | Switch to lock and unlock doors. If `unlatch` configuration parameter is set, unlocking will also unlatch the door. | + | lockState | Number | Channel which accepts [Supported commands](#supported-lockstate-commands) for performing actions, and produces [supported values](#supported-lockstate-values) when lock state changes. | + | lowBattery | Switch | Low battery warning channel | + | keypadLowBattery | Switch | Indicates if keypad connected to Nuki Lock has low battery | + | batteryLevel | Number | Current battery level | + | batteryCharging | Swtich | Flag indicating if the batteries of the Nuki device are charging at the moment | + | doorsensorState | Number | Read only channel for monitoring door sensor state, see [supported values](#supported-doorsensorstate-values) | + +##### Supported lockState commands + +These values can be sent to _lockState_ channel as a commands: + + | Command | Name | + |---------|--------------------------| + | 1 | Unlock | + | 2 | Lock | + | 3 | Unlatch | + | 4 | Lock 'n' Go | + | 5 | Lock 'n' Go with Unlatch | + +##### Supported lockState values + + | State | Name | + |--------|--------------------------| + | 0 | Uncalibrated | + | 1 | Locked | + | 2 | Unlocking | + | 3 | Unlocked | + | 4 | Locking | + | 5 | Unlatched | + | 6 | Unlatched (Lock 'n' Go) | + | 7 | Unlatching | + | 254 | Motor blocked | + | 255 | Undefined | + +Unfortunately the Nuki Bridge is not reporting any transition states (e.g. for Lock 'n' Go). + +##### Supported doorSensorState values + + | State | Name | + |--------|--------------------------| + | 0 | Unavailable | + | 1 | Deactivated | + | 2 | Closed | + | 3 | Open | + | 4 | Unknown | + | 5 | Calibrating | + +### Nuki Opener + +Nuki Opener has no configuration properties. + +#### Supported channels + + | Channel | Type | Description | + |---------------------|----------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| + | openerState | Number | Channel for sending [supported commands](#supported-openerstate-commands) to Opener, produces one of [supported values](#supported-openerstate-values) when Opener state changes | + | openerMode | Number | Id of current Opener mode, see [Supported values](#supported-openermode-values) | + | openerLowBattery | Switch | Low battery warning channel | + | ringActionState | Trigger | Channel triggers 'RINGING' event when the doorbell is being rung. This can trigger at most once every 30s | + | ringActionTimestamp | DateTime | Timestamp of last time doorbell was rung. | + +##### Supported openerState commands + + | Command | Name | + |---------|----------------------------| + | 1 | Activate ring to open | + | 2 | Deactivate ring to open | + | 3 | Electric strike actuation | + | 4 | Activate continuous mode | + | 5 | Deactivate continuous mode | + +##### Supported openerState values + + | State | Name | + |--------|---------------------| + | 0 | Untrained | + | 1 | Online | + | 3 | Ring to open active | + | 5 | Open | + | 7 | Opening | + | 253 | Boot run | + | 255 | Undefined | + +##### Supported openerMode values + + | Mode | Name | + |--------|-----------------| + | 2 | Door mode | + | 3 | Continuous mode | + + +## Troubleshooting + +### Bridge and devices are offline with error 403 + +If secureToken property is enabled, make sure that time on device running openHAB and Nuki Bridge are synchronized. When secureToken +is enabled, all requests contain timestamp and bridge will only accept requests with small time difference. If it is not possible to +keep time synchronized, disable secureToken feature. ## Full Example diff --git a/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/NukiHandlerFactory.java b/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/NukiHandlerFactory.java index 3512d6997167..bc6b3f61a60c 100644 --- a/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/NukiHandlerFactory.java +++ b/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/NukiHandlerFactory.java @@ -15,15 +15,21 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.eclipse.jetty.client.HttpClient; +import org.openhab.binding.nuki.internal.constants.NukiBindingConstants; +import org.openhab.binding.nuki.internal.constants.NukiLinkBuilder; import org.openhab.binding.nuki.internal.dataexchange.NukiApiServlet; import org.openhab.binding.nuki.internal.handler.NukiBridgeHandler; +import org.openhab.binding.nuki.internal.handler.NukiOpenerHandler; import org.openhab.binding.nuki.internal.handler.NukiSmartLockHandler; +import org.openhab.core.config.core.Configuration; +import org.openhab.core.id.InstanceUUID; import org.openhab.core.io.net.http.HttpClientFactory; import org.openhab.core.net.HttpServiceUtil; import org.openhab.core.net.NetworkAddressService; import org.openhab.core.thing.Bridge; import org.openhab.core.thing.Thing; import org.openhab.core.thing.ThingTypeUID; +import org.openhab.core.thing.ThingUID; import org.openhab.core.thing.binding.BaseThingHandlerFactory; import org.openhab.core.thing.binding.ThingHandler; import org.openhab.core.thing.binding.ThingHandlerFactory; @@ -39,6 +45,7 @@ * handlers. * * @author Markus Katter - Initial contribution + * @contributer Jan Vybíral - Improved thing id generation */ @Component(service = ThingHandlerFactory.class, configurationPid = "binding.nuki") @NonNullByDefault @@ -46,18 +53,16 @@ public class NukiHandlerFactory extends BaseThingHandlerFactory { private final Logger logger = LoggerFactory.getLogger(NukiHandlerFactory.class); - private final HttpService httpService; private final HttpClient httpClient; private final NetworkAddressService networkAddressService; - private @Nullable String callbackUrl; - private @Nullable NukiApiServlet nukiApiServlet; + private NukiApiServlet nukiApiServlet; @Activate public NukiHandlerFactory(@Reference HttpService httpService, @Reference final HttpClientFactory httpClientFactory, @Reference NetworkAddressService networkAddressService) { - this.httpService = httpService; this.httpClient = httpClientFactory.getCommonHttpClient(); this.networkAddressService = networkAddressService; + this.nukiApiServlet = new NukiApiServlet(httpService); } @Override @@ -67,47 +72,45 @@ public boolean supportsThingType(ThingTypeUID thingTypeUID) { @Override protected @Nullable ThingHandler createHandler(Thing thing) { - logger.debug("NukiHandlerFactory:createHandler({})", thing); ThingTypeUID thingTypeUID = thing.getThingTypeUID(); if (NukiBindingConstants.THING_TYPE_BRIDGE_UIDS.contains(thingTypeUID)) { - callbackUrl = createCallbackUrl(); + String callbackUrl = createCallbackUrl(InstanceUUID.get()); NukiBridgeHandler nukiBridgeHandler = new NukiBridgeHandler((Bridge) thing, httpClient, callbackUrl); - if (!nukiBridgeHandler.isInitializable()) { - return null; - } - if (nukiApiServlet == null) { - nukiApiServlet = new NukiApiServlet(httpService); - } nukiApiServlet.add(nukiBridgeHandler); return nukiBridgeHandler; } else if (NukiBindingConstants.THING_TYPE_SMARTLOCK_UIDS.contains(thingTypeUID)) { return new NukiSmartLockHandler(thing); + } else if (NukiBindingConstants.THING_TYPE_OPENER_UIDS.contains(thingTypeUID)) { + return new NukiOpenerHandler(thing); } - logger.trace("No valid Handler found for Thing[{}]!", thingTypeUID); + logger.warn("No valid Handler found for Thing[{}]!", thingTypeUID); return null; } + @Override + protected @Nullable Thing createThing(ThingTypeUID thingTypeUID, Configuration configuration, ThingUID thingUID) { + return super.createThing(thingTypeUID, configuration, thingUID); + } + + @Override + public void removeThing(ThingUID thingUID) { + super.removeThing(thingUID); + } + @Override public void unregisterHandler(Thing thing) { super.unregisterHandler(thing); - logger.trace("NukiHandlerFactory:unregisterHandler({})", thing); - if (thing.getHandler() instanceof NukiBridgeHandler && nukiApiServlet != null) { - nukiApiServlet.remove((NukiBridgeHandler) thing.getHandler()); - if (nukiApiServlet.countNukiBridgeHandlers() == 0) { - nukiApiServlet = null; - } + ThingHandler handler = thing.getHandler(); + if (handler instanceof NukiBridgeHandler) { + nukiApiServlet.remove((NukiBridgeHandler) handler); } } - private @Nullable String createCallbackUrl() { - logger.trace("createCallbackUrl()"); - if (callbackUrl != null) { - return callbackUrl; - } + private @Nullable String createCallbackUrl(String id) { final String ipAddress = networkAddressService.getPrimaryIpv4HostAddress(); if (ipAddress == null) { - logger.warn("No network interface could be found."); + logger.warn("No network interface could be found to get callback address"); return null; } // we do not use SSL as it can cause certificate validation issues. @@ -116,7 +119,7 @@ public void unregisterHandler(Thing thing) { logger.warn("Cannot find port of the http service."); return null; } - String callbackUrl = String.format(NukiBindingConstants.CALLBACK_URL, ipAddress + ":" + port); + String callbackUrl = NukiLinkBuilder.callbackUri(ipAddress, port, id).toString(); logger.trace("callbackUrl[{}]", callbackUrl); return callbackUrl; } diff --git a/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/configuration/NukiBridgeConfiguration.java b/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/configuration/NukiBridgeConfiguration.java new file mode 100644 index 000000000000..4851a701c1c5 --- /dev/null +++ b/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/configuration/NukiBridgeConfiguration.java @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2010-2021 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.nuki.internal.configuration; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * Configuration for Nuki Bridge + * + * @author Jan Vybíral - Initial contribution + */ +@NonNullByDefault +public class NukiBridgeConfiguration { + @Nullable + public String ip; + @Nullable + public Integer port; + @Nullable + public String apiToken; + public boolean manageCallbacks = true; + public boolean secureToken = true; +} diff --git a/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/configuration/NukiDeviceConfiguration.java b/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/configuration/NukiDeviceConfiguration.java new file mode 100644 index 000000000000..b5acb45f3c19 --- /dev/null +++ b/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/configuration/NukiDeviceConfiguration.java @@ -0,0 +1,25 @@ +/** + * Copyright (c) 2010-2021 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.nuki.internal.configuration; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Configuration for Nuki devices + * + * @author Jan Vybíral - Initial contribution + */ +@NonNullByDefault +public class NukiDeviceConfiguration { + public String nukiId = ""; +} diff --git a/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/configuration/NukiSmartLockConfiguration.java b/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/configuration/NukiSmartLockConfiguration.java new file mode 100644 index 000000000000..909f48c96269 --- /dev/null +++ b/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/configuration/NukiSmartLockConfiguration.java @@ -0,0 +1,25 @@ +/** + * Copyright (c) 2010-2021 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.nuki.internal.configuration; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Configuration for Nuki Smart Lock + * + * @author Jan Vybíral - Initial contribution + */ +@NonNullByDefault +public class NukiSmartLockConfiguration extends NukiDeviceConfiguration { + public boolean unlatch = false; +} diff --git a/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/NukiBindingConstants.java b/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/constants/NukiBindingConstants.java similarity index 62% rename from bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/NukiBindingConstants.java rename to bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/constants/NukiBindingConstants.java index 5f9b8096e100..4354bb0a85c1 100644 --- a/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/NukiBindingConstants.java +++ b/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/constants/NukiBindingConstants.java @@ -10,13 +10,14 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.openhab.binding.nuki.internal; +package org.openhab.binding.nuki.internal.constants; import java.util.Collections; import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; +import org.eclipse.jdt.annotation.NonNullByDefault; import org.openhab.core.thing.ThingTypeUID; /** @@ -25,7 +26,9 @@ * * @author Markus Katter - Initial contribution * @contributer Christian Hoefler - Door sensor integration + * @contributer Jan Vybíral - Opener integration */ +@NonNullByDefault public class NukiBindingConstants { public static final String BINDING_ID = "nuki"; @@ -33,38 +36,53 @@ public class NukiBindingConstants { // List of all Thing Type UIDs public static final ThingTypeUID THING_TYPE_BRIDGE = new ThingTypeUID(BINDING_ID, "bridge"); public static final ThingTypeUID THING_TYPE_SMARTLOCK = new ThingTypeUID(BINDING_ID, "smartlock"); + public static final ThingTypeUID THING_TYPE_OPENER = new ThingTypeUID(BINDING_ID, "opener"); public static final Set THING_TYPE_BRIDGE_UIDS = Collections.singleton(THING_TYPE_BRIDGE); public static final Set THING_TYPE_SMARTLOCK_UIDS = Collections.singleton(THING_TYPE_SMARTLOCK); + public static final Set THING_TYPE_OPENER_UIDS = Collections.singleton(THING_TYPE_OPENER); public static final Set SUPPORTED_THING_TYPES_UIDS = Stream - .concat(THING_TYPE_BRIDGE_UIDS.stream(), THING_TYPE_SMARTLOCK_UIDS.stream()).collect(Collectors.toSet()); + .of(THING_TYPE_BRIDGE_UIDS, THING_TYPE_SMARTLOCK_UIDS, THING_TYPE_OPENER_UIDS).flatMap(Set::stream) + .collect(Collectors.toSet()); - // List of all Channel ids + // Device Types + public static final int DEVICE_SMART_LOCK = 0; + public static final int DEVICE_OPENER = 2; + public static final Set SUPPORTED_DEVICES = Set.of(DEVICE_OPENER, DEVICE_SMART_LOCK); + + // Properties + public static final String PROPERTY_WIFI_FIRMWARE_VERSION = "wifiFirmwareVersion"; + public static final String PROPERTY_HARDWARE_ID = "hardwareId"; + public static final String PROPERTY_SERVER_ID = "serverId"; + public static final String PROPERTY_FIRMWARE_VERSION = "firmwareVersion"; + public static final String PROPERTY_NAME = "name"; + public static final String PROPERTY_NUKI_ID = "nukiId"; + public static final String PROPERTY_BRIDGE_ID = "bridgeId"; + + // List of all Smart Lock Channel ids public static final String CHANNEL_SMARTLOCK_LOCK = "lock"; public static final String CHANNEL_SMARTLOCK_STATE = "lockState"; public static final String CHANNEL_SMARTLOCK_LOW_BATTERY = "lowBattery"; + public static final String CHANNEL_SMARTLOCK_KEYPAD_LOW_BATTERY = "keypadLowBattery"; + public static final String CHANNEL_SMARTLOCK_BATTERY_LEVEL = "batteryLevel"; + public static final String CHANNEL_SMARTLOCK_BATTERY_CHARGING = "batteryCharging"; public static final String CHANNEL_SMARTLOCK_DOOR_STATE = "doorsensorState"; + // List of all Opener Channel ids + public static final String CHANNEL_OPENER_STATE = "openerState"; + public static final String CHANNEL_OPENER_MODE = "openerMode"; + public static final String CHANNEL_OPENER_LOW_BATTERY = "openerLowBattery"; + public static final String CHANNEL_OPENER_RING_ACTION_STATE = "ringActionState"; + public static final String CHANNEL_OPENER_RING_ACTION_TIMESTAMP = "ringActionTimestamp"; + // List of all config-description parameters public static final String CONFIG_IP = "ip"; public static final String CONFIG_PORT = "port"; public static final String CONFIG_MANAGECB = "manageCallbacks"; public static final String CONFIG_API_TOKEN = "apiToken"; - public static final String CONFIG_NUKI_ID = "nukiId"; public static final String CONFIG_UNLATCH = "unlatch"; - - // Nuki Bridge API REST Endpoints - public static final String URI_INFO = "http://%s:%s/info?token=%s"; - public static final String URI_LOCKSTATE = "http://%s:%s/lockState?token=%s&nukiId=%s"; - public static final String URI_LOCKACTION = "http://%s:%s/lockAction?token=%s&nukiId=%s&action=%s"; - public static final String URI_CBADD = "http://%s:%s/callback/add?token=%s&url=%s"; - public static final String URI_CBLIST = "http://%s:%s/callback/list?token=%s"; - public static final String URI_CBREMOVE = "http://%s:%s/callback/remove?token=%s&id=%s"; - - // openHAB Callback Endpoint & Nuki Bridge Callback URL - public static final String CALLBACK_ENDPOINT = "/nuki/bcb"; - public static final String CALLBACK_URL = "http://%s" + CALLBACK_ENDPOINT; + public static final String CONFIG_SECURE_TOKEN = "secureToken"; // Nuki Bridge API Lock Actions public static final int LOCK_ACTIONS_UNLOCK = 1; @@ -96,4 +114,7 @@ public class NukiBindingConstants { public static final int DOORSENSOR_STATES_OPEN = 3; public static final int DOORSENSOR_STATES_UNKNOWN = 4; public static final int DOORSENSOR_STATES_CALIBRATING = 5; + + // trigger channel events + public static final String EVENT_RINGING = "RINGING"; } diff --git a/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/constants/NukiLinkBuilder.java b/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/constants/NukiLinkBuilder.java new file mode 100644 index 000000000000..bfb087f33725 --- /dev/null +++ b/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/constants/NukiLinkBuilder.java @@ -0,0 +1,148 @@ +/** + * Copyright (c) 2010-2021 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.nuki.internal.constants; + +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.Locale; + +import javax.ws.rs.core.UriBuilder; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.util.HexUtils; + +/** + * The {@link NukiLinkBuilder} class helps with constructing links to various Nuki APIs. + * Links to secured APIs will be created with all necessary authentication parameters. + * + * @author Jan Vybíral - Initial contribution + */ +@NonNullByDefault +public class NukiLinkBuilder { + public static final URI URI_BRIDGE_DISCOVERY = URI.create("https://api.nuki.io/discover/bridges"); + public static final String CALLBACK_ENDPOINT = "/nuki/bcb"; + + private static final String PATH_INFO = "/info"; + private static final String PATH_AUTH = "/auth"; + private static final String PATH_LOCKSTATE = "/lockState"; + private static final String PATH_LOCKACTION = "/lockAction"; + public static final String PATH_CBADD = "/callback/add"; + public static final String PATH_CBLIST = "/callback/list"; + public static final String PATH_CBREMOVE = "/callback/remove"; + public static final String PATH_LIST = "/list"; + + private final String host; + private final int port; + private final String token; + private final boolean secureToken; + private final SecureRandom random = new SecureRandom(); + private final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssX"); + + /** + * Create new instance of link builder + * + * @param host Hostname/ip address of Nuki bridge + * @param port Port of Nuki bridge + * @param token Token for authenticating API calls + */ + public NukiLinkBuilder(String host, int port, String token, boolean secureToken) { + this.host = host; + this.port = port; + this.token = token; + this.secureToken = secureToken; + } + + public static URI getAuthUri(String host, int port) { + return UriBuilder.fromPath(PATH_AUTH).host(host).port(port).scheme("http").build(); + } + + private UriBuilder builder(String path) { + return UriBuilder.fromPath(path).scheme("http").host(host).port(port); + } + + public URI info() { + return buildWithAuth(builder(PATH_INFO)); + } + + public URI lockState(String nukiId, int deviceType) { + return buildWithAuth(builder(PATH_LOCKSTATE).queryParam("nukiId", nukiId).queryParam("deviceType", deviceType)); + } + + public URI lockAction(String nukiId, int deviceType, int action) { + return buildWithAuth(builder(PATH_LOCKACTION).queryParam("deviceType", deviceType).queryParam("action", action) + .queryParam("nukiId", nukiId)); + } + + public URI callbackList() { + return buildWithAuth(builder(PATH_CBLIST)); + } + + public URI callbackAdd(String callback) { + String callbackEncoded = URLEncoder.encode(callback, StandardCharsets.UTF_8); + return buildWithAuth(builder(PATH_CBADD).queryParam("url", callbackEncoded)); + } + + public URI callbackRemove(int id) { + return buildWithAuth(builder(PATH_CBREMOVE).queryParam("id", id)); + } + + public URI list() { + return buildWithAuth(builder(PATH_LIST)); + } + + public static UriBuilder callbackPath(String callbackId) { + return UriBuilder.fromPath(CALLBACK_ENDPOINT).queryParam("callbackId", callbackId); + } + + public static URI callbackUri(String host, int port, String callbackId) { + return callbackPath(callbackId).host(host).port(port).scheme("http").build(); + } + + private URI buildWithAuth(UriBuilder builder) { + if (secureToken) { + return buildWithHashedToken(builder); + } else { + return buildWithPlainToken(builder); + } + } + + private URI buildWithHashedToken(UriBuilder builder) { + String ts = formatter.format(OffsetDateTime.now(ZoneOffset.UTC)); + Integer rnr = random.nextInt(65536); + String hashedToken = sha256(ts + "," + rnr + "," + token); + + return builder.queryParam("ts", ts).queryParam("rnr", rnr).queryParam("hash", hashedToken).build(); + } + + private URI buildWithPlainToken(UriBuilder builder) { + return builder.queryParam("token", token).build(); + } + + public static String sha256(String data) { + MessageDigest digest; + try { + digest = MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 Algorithm not supported", e); + } + byte[] rawHash = digest.digest(data.getBytes(StandardCharsets.UTF_8)); + return HexUtils.bytesToHex(rawHash).toLowerCase(Locale.ROOT); + } +} diff --git a/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/constants/OpenerAction.java b/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/constants/OpenerAction.java new file mode 100644 index 000000000000..5466a368d902 --- /dev/null +++ b/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/constants/OpenerAction.java @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2010-2021 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.nuki.internal.constants; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * Enumeration of all lock actions Nuki Opener accepts + * + * @author Jan Vybíral - Initial contribution + */ +@NonNullByDefault +public enum OpenerAction { + ACTIVATE_RING_TO_OPEN(1), + DEACTIVATE_RING_TO_OPEN(2), + ELECTRIC_STRIKE_ACTUATION(3), + ACTIVATE_CONTINUOUS_MODE(4), + DEACTIVATE_CONTINUOUS_MODE(5); + + private final int action; + + OpenerAction(int action) { + this.action = action; + } + + public static @Nullable OpenerAction fromAction(int action) { + for (OpenerAction value : values()) { + if (value.action == action) { + return value; + } + } + return null; + } + + public int getAction() { + return action; + } +} diff --git a/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/constants/SmartLockAction.java b/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/constants/SmartLockAction.java new file mode 100644 index 000000000000..595ddd04c509 --- /dev/null +++ b/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/constants/SmartLockAction.java @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2010-2021 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.nuki.internal.constants; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * Enumeration of all lock actions Nuki Smart Lock accepts + * + * @author Jan Vybíral - Initial contribution + */ +@NonNullByDefault +public enum SmartLockAction { + UNLOCK(1), + LOCK(2), + UNLATCH(3), + LOCK_N_GO(4), + LOCK_N_GO_WITH_UNLATCH(5); + + private final int action; + + SmartLockAction(int action) { + this.action = action; + } + + public static @Nullable SmartLockAction fromAction(int action) { + for (SmartLockAction value : values()) { + if (value.action == action) { + return value; + } + } + return null; + } + + public int getAction() { + return action; + } +} diff --git a/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/converter/LockActionConverter.java b/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/converter/LockActionConverter.java deleted file mode 100644 index d667a6df2980..000000000000 --- a/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/converter/LockActionConverter.java +++ /dev/null @@ -1,58 +0,0 @@ -/** - * Copyright (c) 2010-2021 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.nuki.internal.converter; - -import java.util.HashMap; -import java.util.Map; - -import org.openhab.binding.nuki.internal.NukiBindingConstants; - -/** - * The {@link LockActionConverter} is responsible for mapping Binding Lock States to Bridge HTTP-API Lock Actions. - * - * @author Markus Katter - Initial contribution - */ -public abstract class LockActionConverter { - - private static Map mapping; - - private static void setupMapping() { - mapping = new HashMap<>(); - mapping.put(NukiBindingConstants.LOCK_STATES_UNLOCKING, NukiBindingConstants.LOCK_ACTIONS_UNLOCK); - mapping.put(NukiBindingConstants.LOCK_STATES_LOCKING, NukiBindingConstants.LOCK_ACTIONS_LOCK); - mapping.put(NukiBindingConstants.LOCK_STATES_UNLATCHING, NukiBindingConstants.LOCK_ACTIONS_UNLATCH); - mapping.put(NukiBindingConstants.LOCK_STATES_UNLOCKING_LOCKNGO, - NukiBindingConstants.LOCK_ACTIONS_LOCKNGO_UNLOCK); - mapping.put(NukiBindingConstants.LOCK_STATES_UNLATCHING_LOCKNGO, - NukiBindingConstants.LOCK_ACTIONS_LOCKNGO_UNLATCH); - } - - public static int getLockActionFor(int lockState) { - if (mapping == null) { - setupMapping(); - } - return mapping.get(lockState); - } - - public static int getLockStateFor(int lockAction) { - if (mapping == null) { - setupMapping(); - } - for (Map.Entry entry : mapping.entrySet()) { - if (entry.getValue() == lockAction) { - return entry.getKey(); - } - } - return 0; - } -} diff --git a/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/dataexchange/BridgeCallbackListResponse.java b/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/dataexchange/BridgeCallbackListResponse.java index a9e1c848b909..8cd7a8ebc21c 100644 --- a/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/dataexchange/BridgeCallbackListResponse.java +++ b/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/dataexchange/BridgeCallbackListResponse.java @@ -12,8 +12,12 @@ */ package org.openhab.binding.nuki.internal.dataexchange; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; import org.openhab.binding.nuki.internal.dto.BridgeApiCallbackListCallbackDto; import org.openhab.binding.nuki.internal.dto.BridgeApiCallbackListDto; @@ -22,15 +26,19 @@ * * @author Markus Katter - Initial contribution */ +@NonNullByDefault public class BridgeCallbackListResponse extends NukiBaseResponse { - private List callbacks; + private List callbacks = Collections.emptyList(); - public BridgeCallbackListResponse(int status, String message, BridgeApiCallbackListDto bridgeApiCallbackListDto) { + public BridgeCallbackListResponse(int status, String message, + @Nullable BridgeApiCallbackListDto bridgeApiCallbackListDto) { super(status, message); if (bridgeApiCallbackListDto != null) { this.setSuccess(true); - this.callbacks = bridgeApiCallbackListDto.getCallbacks(); + if (bridgeApiCallbackListDto.getCallbacks() != null) { + this.callbacks = bridgeApiCallbackListDto.getCallbacks(); + } } } @@ -42,7 +50,11 @@ public List getCallbacks() { return callbacks; } - public void setCallbacks(List callbacks) { - this.callbacks = callbacks; + public void setCallbacks(@Nullable List callbacks) { + if (callbacks == null) { + this.callbacks = new ArrayList<>(); + } else { + this.callbacks = callbacks; + } } } diff --git a/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/dataexchange/BridgeListResponse.java b/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/dataexchange/BridgeListResponse.java new file mode 100644 index 000000000000..a2520e1185bd --- /dev/null +++ b/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/dataexchange/BridgeListResponse.java @@ -0,0 +1,54 @@ +/** + * Copyright (c) 2010-2021 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.nuki.internal.dataexchange; + +import java.util.Collections; +import java.util.List; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.nuki.internal.dto.BridgeApiListDeviceDto; + +/** + * The {@link BridgeListResponse} class wraps {@link BridgeApiListDeviceDto} class. + * + * @author Jan Vybíral - Initial contribution + */ +@NonNullByDefault +public class BridgeListResponse extends NukiBaseResponse { + + private final List devices; + + public BridgeListResponse(int status, @Nullable String message, @Nullable List devices) { + super(status, message); + setSuccess(devices != null); + this.devices = devices == null ? Collections.emptyList() : Collections.unmodifiableList(devices); + } + + public BridgeListResponse(NukiBaseResponse nukiBaseResponse) { + this(nukiBaseResponse.getStatus(), nukiBaseResponse.getMessage(), null); + } + + public List getDevices() { + return devices; + } + + public @Nullable BridgeApiListDeviceDto getDevice(String nukiId) { + for (BridgeApiListDeviceDto device : this.devices) { + if (device.getNukiId().equals(nukiId)) { + return device; + } + } + return null; + } +} diff --git a/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/dataexchange/BridgeLockStateResponse.java b/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/dataexchange/BridgeLockStateResponse.java index ee6eb5dadd32..2352af9afe98 100644 --- a/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/dataexchange/BridgeLockStateResponse.java +++ b/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/dataexchange/BridgeLockStateResponse.java @@ -22,55 +22,22 @@ */ public class BridgeLockStateResponse extends NukiBaseResponse { - private int state; - private String stateName; - private boolean batteryCritical; - private int doorsensorState; + private final BridgeApiLockStateDto bridgeApiLockStateDto; public BridgeLockStateResponse(int status, String message, BridgeApiLockStateDto bridgeApiLockStateDto) { super(status, message); + this.bridgeApiLockStateDto = bridgeApiLockStateDto; if (bridgeApiLockStateDto != null) { this.setSuccess(bridgeApiLockStateDto.isSuccess()); - this.setState(bridgeApiLockStateDto.getState()); - this.setStateName(bridgeApiLockStateDto.getStateName()); - this.setDoorsensorState(bridgeApiLockStateDto.getDoorsensorState()); - this.setBatteryCritical(bridgeApiLockStateDto.isBatteryCritical()); } } public BridgeLockStateResponse(NukiBaseResponse nukiBaseResponse) { super(nukiBaseResponse.getStatus(), nukiBaseResponse.getMessage()); + this.bridgeApiLockStateDto = null; } - public int getState() { - return state; - } - - public void setState(int state) { - this.state = state; - } - - public String getStateName() { - return stateName; - } - - public void setStateName(String stateName) { - this.stateName = stateName; - } - - public boolean isBatteryCritical() { - return batteryCritical; - } - - public void setBatteryCritical(boolean batteryCritical) { - this.batteryCritical = batteryCritical; - } - - public int getDoorsensorState() { - return doorsensorState; - } - - public void setDoorsensorState(int doorsensorState) { - this.doorsensorState = doorsensorState; + public BridgeApiLockStateDto getBridgeApiLockStateDto() { + return bridgeApiLockStateDto; } } diff --git a/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/dataexchange/NukiApiServlet.java b/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/dataexchange/NukiApiServlet.java index 76f5eb507cfe..658d72b73a72 100644 --- a/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/dataexchange/NukiApiServlet.java +++ b/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/dataexchange/NukiApiServlet.java @@ -12,30 +12,29 @@ */ package org.openhab.binding.nuki.internal.dataexchange; +import java.io.BufferedReader; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Dictionary; import java.util.Hashtable; import java.util.List; -import java.util.stream.Collectors; import javax.servlet.ServletException; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; -import org.eclipse.jdt.annotation.NonNull; +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; import org.eclipse.jetty.http.HttpStatus; -import org.openhab.binding.nuki.internal.NukiBindingConstants; +import org.openhab.binding.nuki.internal.constants.NukiBindingConstants; +import org.openhab.binding.nuki.internal.constants.NukiLinkBuilder; import org.openhab.binding.nuki.internal.dto.BridgeApiLockStateRequestDto; import org.openhab.binding.nuki.internal.dto.NukiHttpServerStatusResponseDto; +import org.openhab.binding.nuki.internal.handler.AbstractNukiDeviceHandler; import org.openhab.binding.nuki.internal.handler.NukiBridgeHandler; -import org.openhab.binding.nuki.internal.handler.NukiSmartLockHandler; -import org.openhab.core.library.types.DecimalType; -import org.openhab.core.library.types.OnOffType; -import org.openhab.core.thing.Channel; import org.openhab.core.thing.Thing; -import org.openhab.core.types.State; import org.osgi.service.http.HttpService; import org.osgi.service.http.NamespaceException; import org.slf4j.Logger; @@ -48,34 +47,28 @@ * * @author Markus Katter - Initial contribution * @contributer Christian Hoefler - Door sensor integration + * @contributer Jan Vybíral - Added Opener support, improved callback handling */ +@NonNullByDefault public class NukiApiServlet extends HttpServlet { private final Logger logger = LoggerFactory.getLogger(NukiApiServlet.class); private static final long serialVersionUID = -3601163473320027239L; - private static final String CHARSET = "utf-8"; private static final String APPLICATION_JSON = "application/json"; - private HttpService httpService; - private List nukiBridgeHandlers = new ArrayList<>(); - private String path; - private Gson gson; + private final HttpService httpService; + private final List nukiBridgeHandlers = new ArrayList<>(); + private final Gson gson; public NukiApiServlet(HttpService httpService) { - logger.debug("Instantiating NukiApiServlet({})", httpService); this.httpService = httpService; gson = new Gson(); } - public void add(@NonNull NukiBridgeHandler nukiBridgeHandler) { + public void add(NukiBridgeHandler nukiBridgeHandler) { logger.trace("Adding NukiBridgeHandler[{}] for Bridge[{}] to NukiApiServlet.", nukiBridgeHandler.getThing().getUID(), nukiBridgeHandler.getThing().getConfiguration().get(NukiBindingConstants.CONFIG_IP)); - if (!nukiBridgeHandler.isInitializable()) { - logger.debug("NukiBridgeHandler[{}] is not initializable, check required configuration!", - nukiBridgeHandler.getThing().getUID()); - return; - } if (nukiBridgeHandlers.isEmpty()) { this.activate(); } @@ -92,109 +85,83 @@ public void remove(NukiBridgeHandler nukiBridgeHandler) { } } - public int countNukiBridgeHandlers() { - return nukiBridgeHandlers.size(); - } - private void activate() { - logger.debug("Activating NukiApiServlet."); - path = NukiBindingConstants.CALLBACK_ENDPOINT; Dictionary servletParams = new Hashtable<>(); try { - httpService.registerServlet(path, this, servletParams, httpService.createDefaultHttpContext()); - logger.debug("Started NukiApiServlet at path[{}]", path); + httpService.registerServlet(NukiLinkBuilder.CALLBACK_ENDPOINT, this, servletParams, + httpService.createDefaultHttpContext()); + logger.debug("Started NukiApiServlet at path[{}]", NukiLinkBuilder.CALLBACK_ENDPOINT); } catch (ServletException | NamespaceException e) { - logger.error("ERROR: {}", e.getMessage(), e); + logger.error("Error activating NukiApiServlet: {}", e.getMessage(), e); } } private void deactivate() { - logger.trace("deactivate()"); - httpService.unregister(path); + httpService.unregister(NukiLinkBuilder.CALLBACK_ENDPOINT); } @Override - protected void service(HttpServletRequest request, HttpServletResponse response) - throws ServletException, IOException { + protected void service(HttpServletRequest request, HttpServletResponse response) throws IOException { logger.debug("Servlet Request at URI[{}] request[{}]", request.getRequestURI(), request); BridgeApiLockStateRequestDto bridgeApiLockStateRequestDto = getBridgeApiLockStateRequestDto(request); + + ResponseEntity responseEntity; if (bridgeApiLockStateRequestDto == null) { - logger.error("Could not handle Bridge CallBack Request - Discarding!"); - logger.error("Please report a bug, if this request was done by the Nuki Bridge!"); - setHeaders(response); - response.setStatus(HttpStatus.BAD_REQUEST_400); - response.getWriter().println(gson.toJson(new NukiHttpServerStatusResponseDto("Invalid BCB-Request!"))); - return; + logger.warn( + "Could not handle Bridge CallBack Request, Please report a bug, if this request was done by the Nuki Bridge! - {}", + request); + + responseEntity = new ResponseEntity(HttpStatus.BAD_REQUEST_400, + new NukiHttpServerStatusResponseDto("Invalid BCB-Request!")); + } else { + responseEntity = doHandle(bridgeApiLockStateRequestDto, request.getParameter("bridgeId")); } - String nukiId = String.format("%08X", (bridgeApiLockStateRequestDto.getNukiId())); - String nukiIdThing; + + setHeaders(response); + response.setStatus(responseEntity.getStatus()); + response.getWriter().write(gson.toJson(responseEntity.getData())); + } + + private ResponseEntity doHandle(BridgeApiLockStateRequestDto request, @Nullable String bridgeId) { + String nukiId = request.getNukiId().toString(); for (NukiBridgeHandler nukiBridgeHandler : nukiBridgeHandlers) { logger.trace("Searching Bridge[{}] with NukiBridgeHandler[{}] for nukiId[{}].", nukiBridgeHandler.getThing().getConfiguration().get(NukiBindingConstants.CONFIG_IP), nukiBridgeHandler.getThing().getUID(), nukiId); - List<@NonNull Thing> allSmartLocks = nukiBridgeHandler.getThing().getThings(); + List allSmartLocks = nukiBridgeHandler.getThing().getThings(); for (Thing thing : allSmartLocks) { - nukiIdThing = thing.getConfiguration().containsKey(NukiBindingConstants.CONFIG_NUKI_ID) - ? (String) thing.getConfiguration().get(NukiBindingConstants.CONFIG_NUKI_ID) - : null; - if (nukiIdThing != null && nukiIdThing.equals(nukiId)) { + String nukiIdThing = String + .valueOf(thing.getConfiguration().get(NukiBindingConstants.PROPERTY_NUKI_ID)); + if (nukiIdThing.equals(nukiId)) { logger.debug("Processing ThingUID[{}] - nukiId[{}]", thing.getUID(), nukiId); - NukiSmartLockHandler nsh = getSmartLockHandler(thing); - if (nsh == null) { - logger.debug("Could not update channels for ThingUID[{}] because Handler is null!", - thing.getUID()); - break; - } - Channel channel = thing.getChannel(NukiBindingConstants.CHANNEL_SMARTLOCK_LOCK); - if (channel != null) { - State state = bridgeApiLockStateRequestDto.getState() == NukiBindingConstants.LOCK_STATES_LOCKED - ? OnOffType.ON - : OnOffType.OFF; - nsh.handleApiServletUpdate(channel.getUID(), state); - } - channel = thing.getChannel(NukiBindingConstants.CHANNEL_SMARTLOCK_STATE); - if (channel != null) { - State state = new DecimalType(bridgeApiLockStateRequestDto.getState()); - nsh.handleApiServletUpdate(channel.getUID(), state); + AbstractNukiDeviceHandler nsh = getDeviceHandler(thing); + if (nsh != null) { + nsh.refreshState(request); + return new ResponseEntity(HttpStatus.OK_200, new NukiHttpServerStatusResponseDto("OK")); } - channel = thing.getChannel(NukiBindingConstants.CHANNEL_SMARTLOCK_LOW_BATTERY); - if (channel != null) { - State state = bridgeApiLockStateRequestDto.isBatteryCritical() ? OnOffType.ON : OnOffType.OFF; - nsh.handleApiServletUpdate(channel.getUID(), state); - } - channel = thing.getChannel(NukiBindingConstants.CHANNEL_SMARTLOCK_DOOR_STATE); - if (channel != null) { - State state = new DecimalType(bridgeApiLockStateRequestDto.getDoorsensorState()); - nsh.handleApiServletUpdate(channel.getUID(), state); - } - setHeaders(response); - response.getWriter().println(gson.toJson(new NukiHttpServerStatusResponseDto("OK"))); - return; } } } + logger.debug("Smart Lock with nukiId[{}] not found!", nukiId); - setHeaders(response); - response.setStatus(HttpStatus.NOT_FOUND_404); - response.getWriter().println(gson.toJson(new NukiHttpServerStatusResponseDto("Smart Lock not found!"))); + return new ResponseEntity(HttpStatus.NOT_FOUND_404, + new NukiHttpServerStatusResponseDto("Smart Lock not found!")); } - private BridgeApiLockStateRequestDto getBridgeApiLockStateRequestDto(HttpServletRequest request) { - logger.trace("getBridgeApiLockStateRequestDto(...)"); + private @Nullable BridgeApiLockStateRequestDto getBridgeApiLockStateRequestDto(HttpServletRequest request) { String requestContent = null; - try { - requestContent = request.getReader().lines().collect(Collectors.joining(System.lineSeparator())); + try (BufferedReader reader = request.getReader()) { + requestContent = readAll(reader); BridgeApiLockStateRequestDto bridgeApiLockStateRequestDto = gson.fromJson(requestContent, BridgeApiLockStateRequestDto.class); - if (bridgeApiLockStateRequestDto.getNukiId() != 0) { + if (bridgeApiLockStateRequestDto != null && bridgeApiLockStateRequestDto.getNukiId() != null) { logger.trace("requestContent[{}]", requestContent); return bridgeApiLockStateRequestDto; } else { - logger.error("Invalid BCB-Request payload data!"); - logger.error("requestContent[{}]", requestContent); + logger.debug("Invalid BCB-Request payload data! {}", requestContent); } } catch (IOException e) { - logger.error("Could not read payload from BCB-Request! Message[{}]", e.getMessage()); + logger.debug("Could not read payload from BCB-Request! Message[{}]", e.getMessage()); } catch (Exception e) { logger.error("Could not create BridgeApiLockStateRequestDto from BCB-Request! Message[{}]", e.getMessage()); logger.error("requestContent[{}]", requestContent); @@ -202,18 +169,45 @@ private BridgeApiLockStateRequestDto getBridgeApiLockStateRequestDto(HttpServlet return null; } - private NukiSmartLockHandler getSmartLockHandler(Thing thing) { - logger.trace("getSmartLockHandler(...) from thing[{}]", thing.getUID()); - NukiSmartLockHandler nsh = (NukiSmartLockHandler) thing.getHandler(); + private String readAll(BufferedReader reader) throws IOException { + StringBuilder sb = new StringBuilder(); + char[] buffer = new char[1024]; + int read = 0; + while ((read = reader.read(buffer)) != -1) { + sb.append(buffer, 0, read); + } + return sb.toString(); + } + + private @Nullable AbstractNukiDeviceHandler getDeviceHandler(Thing thing) { + AbstractNukiDeviceHandler nsh = (AbstractNukiDeviceHandler) thing.getHandler(); if (nsh == null) { - logger.debug("Could not get NukiSmartLockHandler for ThingUID[{}]!", thing.getUID()); + logger.debug("Could not get AbstractNukiDeviceHandler for ThingUID[{}]!", thing.getUID()); return null; } return nsh; } private void setHeaders(HttpServletResponse response) { - response.setCharacterEncoding(CHARSET); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); response.setContentType(APPLICATION_JSON); } + + private static class ResponseEntity { + private final int status; + private final Object data; + + private ResponseEntity(int status, Object data) { + this.status = status; + this.data = data; + } + + public int getStatus() { + return status; + } + + public Object getData() { + return data; + } + } } diff --git a/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/dataexchange/NukiBaseResponse.java b/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/dataexchange/NukiBaseResponse.java index c710c38784cc..fb8bd99858f1 100644 --- a/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/dataexchange/NukiBaseResponse.java +++ b/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/dataexchange/NukiBaseResponse.java @@ -14,19 +14,24 @@ import java.time.Instant; +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + /** * The {@link NukiBaseResponse} class is the base class for API Responses. * * @author Markus Katter - Initial contribution */ +@NonNullByDefault public class NukiBaseResponse { private int status; + @Nullable private String message; private boolean success; private Instant created; - public NukiBaseResponse(int status, String message) { + public NukiBaseResponse(int status, @Nullable String message) { this.status = status; this.message = message; this.created = Instant.now(); @@ -40,7 +45,7 @@ public void setStatus(int status) { this.status = status; } - public String getMessage() { + public @Nullable String getMessage() { return message; } diff --git a/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/dataexchange/NukiHttpClient.java b/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/dataexchange/NukiHttpClient.java index ffe56c2d0a01..11d6d7e32c75 100644 --- a/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/dataexchange/NukiHttpClient.java +++ b/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/dataexchange/NukiHttpClient.java @@ -13,25 +13,28 @@ package org.openhab.binding.nuki.internal.dataexchange; import java.io.InterruptedIOException; -import java.math.BigDecimal; import java.net.SocketException; -import java.net.URLEncoder; -import java.time.Instant; +import java.net.URI; +import java.util.Arrays; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeoutException; +import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jetty.client.HttpClient; import org.eclipse.jetty.client.HttpResponseException; import org.eclipse.jetty.client.api.ContentResponse; import org.eclipse.jetty.http.HttpStatus; -import org.openhab.binding.nuki.internal.NukiBindingConstants; +import org.openhab.binding.nuki.internal.constants.NukiBindingConstants; +import org.openhab.binding.nuki.internal.constants.NukiLinkBuilder; +import org.openhab.binding.nuki.internal.constants.OpenerAction; +import org.openhab.binding.nuki.internal.constants.SmartLockAction; import org.openhab.binding.nuki.internal.dto.BridgeApiCallbackAddDto; import org.openhab.binding.nuki.internal.dto.BridgeApiCallbackListDto; import org.openhab.binding.nuki.internal.dto.BridgeApiCallbackRemoveDto; import org.openhab.binding.nuki.internal.dto.BridgeApiInfoDto; +import org.openhab.binding.nuki.internal.dto.BridgeApiListDeviceDto; import org.openhab.binding.nuki.internal.dto.BridgeApiLockActionDto; import org.openhab.binding.nuki.internal.dto.BridgeApiLockStateDto; -import org.openhab.core.config.core.Configuration; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -41,45 +44,27 @@ * The {@link NukiHttpClient} class is responsible for getting data from the Nuki Bridge. * * @author Markus Katter - Initial contribution + * @contributer Jan Vybíral - Hashed token authentication */ +@NonNullByDefault public class NukiHttpClient { private final Logger logger = LoggerFactory.getLogger(NukiHttpClient.class); - private static final long CACHE_PERIOD = 5; - private HttpClient httpClient; - private Configuration configuration; - private Gson gson; - private BridgeLockStateResponse bridgeLockStateResponseCache; + private final HttpClient httpClient; + private final Gson gson; + private final NukiLinkBuilder linkBuilder; - public NukiHttpClient(HttpClient httpClient, Configuration configuration) { - logger.debug("Instantiating NukiHttpClient({})", configuration); - this.configuration = configuration; + public NukiHttpClient(HttpClient httpClient, NukiLinkBuilder linkBuilder) { + logger.debug("Instantiating NukiHttpClient"); this.httpClient = httpClient; + this.linkBuilder = linkBuilder; gson = new Gson(); } - private String prepareUri(String uriTemplate, String... additionalArguments) { - String configIp = (String) configuration.get(NukiBindingConstants.CONFIG_IP); - BigDecimal configPort = (BigDecimal) configuration.get(NukiBindingConstants.CONFIG_PORT); - String configApiToken = (String) configuration.get(NukiBindingConstants.CONFIG_API_TOKEN); - String[] parameters = new String[additionalArguments.length + 3]; - parameters[0] = configIp; - parameters[1] = configPort.toString(); - parameters[2] = configApiToken; - System.arraycopy(additionalArguments, 0, parameters, 3, additionalArguments.length); - String uri = String.format(uriTemplate, parameters); - logger.trace("prepareUri(...):URI[{}]", uri); - return uri; - } - - private synchronized ContentResponse executeRequest(String uri) + private synchronized ContentResponse executeRequest(URI uri) throws InterruptedException, ExecutionException, TimeoutException { logger.debug("executeRequest({})", uri); - try { - Thread.sleep(100); - } catch (InterruptedException e) { - } ContentResponse contentResponse = httpClient.GET(uri); logger.debug("contentResponseAsString[{}]", contentResponse.getContentAsString()); return contentResponse; @@ -87,19 +72,20 @@ private synchronized ContentResponse executeRequest(String uri) private NukiBaseResponse handleException(Exception e) { if (e instanceof ExecutionException) { - if (e.getCause() instanceof HttpResponseException) { - HttpResponseException cause = (HttpResponseException) e.getCause(); - int status = cause.getResponse().getStatus(); - String reason = cause.getResponse().getReason(); + Throwable cause = e.getCause(); + if (cause instanceof HttpResponseException) { + HttpResponseException causeException = (HttpResponseException) cause; + int status = causeException.getResponse().getStatus(); + String reason = causeException.getResponse().getReason(); logger.debug("HTTP Response Exception! Status[{}] - Reason[{}]! Check your API Token!", status, reason); return new NukiBaseResponse(status, reason); - } else if (e.getCause() instanceof InterruptedIOException) { + } else if (cause instanceof InterruptedIOException) { logger.debug( "InterruptedIOException! Exception[{}]! Check IP/Port configuration and if Nuki Bridge is powered on!", e.getMessage()); return new NukiBaseResponse(HttpStatus.REQUEST_TIMEOUT_408, "InterruptedIOException! Check IP/Port configuration and if Nuki Bridge is powered on!"); - } else if (e.getCause() instanceof SocketException) { + } else if (cause instanceof SocketException) { logger.debug( "SocketException! Exception[{}]! Check IP/Port configuration and if Nuki Bridge is powered on!", e.getMessage()); @@ -113,9 +99,8 @@ private NukiBaseResponse handleException(Exception e) { public BridgeInfoResponse getBridgeInfo() { logger.debug("getBridgeInfo() in thread {}", Thread.currentThread().getId()); - String uri = prepareUri(NukiBindingConstants.URI_INFO); try { - ContentResponse contentResponse = executeRequest(uri); + ContentResponse contentResponse = executeRequest(linkBuilder.info()); int status = contentResponse.getStatus(); String response = contentResponse.getContentAsString(); logger.debug("getBridgeInfo status[{}] response[{}]", status, response); @@ -133,43 +118,63 @@ public BridgeInfoResponse getBridgeInfo() { } } - public BridgeLockStateResponse getBridgeLockState(String nukiId) { - logger.debug("getBridgeLockState({}) in thread {}", nukiId, Thread.currentThread().getId()); - long timestampSecs = Instant.now().getEpochSecond(); - if (this.bridgeLockStateResponseCache != null - && timestampSecs < this.bridgeLockStateResponseCache.getCreated().getEpochSecond() + CACHE_PERIOD) { - logger.debug("Returning LockState from cache - now[{}] { + if (thingRegistry.get(bridge.getThingUid()) != null) { + logger.debug("Bridge {} already exists, skipping discovery", bridge.getThingUid()); + } else { + scheduler.execute(new BridgeInitializer(bridge)); + } + }); + } + + private void discoverBridge(WebApiBridgeDto bridgeData, String token) { + String name; + if (token.isBlank()) { + logger.debug("Nuki bridge {}({}) discovered without api token", bridgeData.getIp(), + bridgeData.getBridgeId()); + name = "Nuki Bridge (no API token)"; + } else { + logger.info("Nuki bridge {}({}) discovered and initialized", bridgeData.getIp(), bridgeData.getBridgeId()); + name = "Nuki Bridge"; + } + + DiscoveryResult result = DiscoveryResultBuilder.create(bridgeData.getThingUid()).withLabel(name) + .withProperty(NukiBindingConstants.PROPERTY_BRIDGE_ID, bridgeData.getBridgeId()) + .withProperty(NukiBindingConstants.CONFIG_IP, bridgeData.getIp()) + .withProperty(NukiBindingConstants.CONFIG_PORT, bridgeData.getPort()) + .withProperty(NukiBindingConstants.CONFIG_API_TOKEN, token) + .withRepresentationProperty(NukiBindingConstants.PROPERTY_BRIDGE_ID).build(); + thingDiscovered(result); + } + + private class BridgeInitializer implements Runnable { + private final WebApiBridgeDto bridge; + + private BridgeInitializer(WebApiBridgeDto bridge) { + this.bridge = bridge; + } + + @Override + public void run() { + logger.info("Discovered Nuki bridge {}({}) - obtaining API token, press button on bridge to complete", + bridge.getIp(), bridge.getBridgeId()); + try { + ContentResponse response = httpClient.GET(NukiLinkBuilder.getAuthUri(bridge.getIp(), bridge.getPort())); + String responseData = response.getContentAsString(); + if (response.getStatus() == HttpStatus.OK_200) { + BridgeApiAuthDto authResult = gson.fromJson(responseData, BridgeApiAuthDto.class); + if (authResult != null && authResult.isSuccess()) { + discoverBridge(bridge, authResult.getToken()); + } else { + logger.warn( + "Failed to get API token for bridge {}({}) - bridge did not return success response, make sure button on bridge was pressed during discovery", + bridge.getIp(), bridge.getBridgeId()); + discoverBridge(bridge, ""); + } + } else if (response.getStatus() == HttpStatus.FORBIDDEN_403) { + logger.warn( + "Failed to get API token for bridge {}({}) - bridge authentication is disabled, check settings", + bridge.getIp(), bridge.getBridgeId()); + discoverBridge(bridge, ""); + } else { + logger.warn("Failed to get API token for bridge {}({}) - invalid status {}: {}", bridge.getIp(), + bridge.getBridgeId(), response.getStatus(), responseData); + } + } catch (Exception e) { + logger.warn("Failed to get API token for bridge {}({})", bridge.getIp(), bridge.getBridgeId(), e); + } + } + } +} diff --git a/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/discovery/NukiDeviceDiscoveryService.java b/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/discovery/NukiDeviceDiscoveryService.java new file mode 100644 index 000000000000..ee9394aa80a9 --- /dev/null +++ b/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/discovery/NukiDeviceDiscoveryService.java @@ -0,0 +1,104 @@ +/** + * Copyright (c) 2010-2021 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.nuki.internal.discovery; + +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.nuki.internal.constants.NukiBindingConstants; +import org.openhab.binding.nuki.internal.dataexchange.BridgeListResponse; +import org.openhab.binding.nuki.internal.dto.BridgeApiListDeviceDto; +import org.openhab.binding.nuki.internal.handler.NukiBridgeHandler; +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.thing.ThingUID; +import org.openhab.core.thing.binding.ThingHandler; +import org.openhab.core.thing.binding.ThingHandlerService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Discovery service which uses Brige API to find all devices connected to bridges. + * + * @author Jan Vybíral - Initial contribution + */ +@NonNullByDefault +public class NukiDeviceDiscoveryService extends AbstractDiscoveryService implements ThingHandlerService { + + private final Logger logger = LoggerFactory.getLogger(NukiDeviceDiscoveryService.class); + @Nullable + private NukiBridgeHandler bridge; + + public NukiDeviceDiscoveryService() { + super(Set.of(NukiBindingConstants.THING_TYPE_SMARTLOCK), 5, false); + } + + @Override + protected void startScan() { + NukiBridgeHandler bridgeHandler = bridge; + if (bridgeHandler == null) { + logger.warn("Cannot start Nuki discovery - no bridge available"); + return; + } + + scheduler.execute(() -> { + bridgeHandler.withHttpClient(client -> { + BridgeListResponse list = client.getList(); + list.getDevices().stream() + .filter(device -> NukiBindingConstants.SUPPORTED_DEVICES.contains(device.getDeviceType())) + .map(device -> createDiscoveryResult(device, bridgeHandler)).forEach(this::thingDiscovered); + }); + }); + } + + private DiscoveryResult createDiscoveryResult(BridgeApiListDeviceDto device, NukiBridgeHandler bridgeHandler) { + return DiscoveryResultBuilder.create(getUid(device.getNukiId(), device.getDeviceType(), bridgeHandler)) + .withBridge(bridgeHandler.getThing().getUID()).withLabel(device.getName()) + .withRepresentationProperty(NukiBindingConstants.PROPERTY_NUKI_ID) + .withProperty(NukiBindingConstants.PROPERTY_NAME, device.getName()) + .withProperty(NukiBindingConstants.PROPERTY_NUKI_ID, device.getNukiId()) + .withProperty(NukiBindingConstants.PROPERTY_FIRMWARE_VERSION, device.getFirmwareVersion()).build(); + } + + private ThingUID getUid(String nukiId, int deviceType, NukiBridgeHandler bridgeHandler) { + if (deviceType == NukiBindingConstants.DEVICE_OPENER) { + return new ThingUID(NukiBindingConstants.THING_TYPE_OPENER, bridgeHandler.getThing().getUID(), nukiId); + } else { + return new ThingUID(NukiBindingConstants.THING_TYPE_SMARTLOCK, bridgeHandler.getThing().getUID(), nukiId); + } + } + + @Override + protected synchronized void stopScan() { + super.stopScan(); + removeOlderResults(getTimestampOfLastScan()); + } + + @Override + public void setThingHandler(@Nullable ThingHandler handler) { + if (handler instanceof NukiBridgeHandler) { + bridge = (NukiBridgeHandler) handler; + } + } + + @Override + public @Nullable ThingHandler getThingHandler() { + return bridge; + } + + @Override + public void deactivate() { + } +} diff --git a/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/dto/BridgeApiAuthDto.java b/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/dto/BridgeApiAuthDto.java new file mode 100644 index 000000000000..86fd94d4123f --- /dev/null +++ b/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/dto/BridgeApiAuthDto.java @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2010-2021 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.nuki.internal.dto; + +/** + * The {@link BridgeApiAuthDto} class defines the Data Transfer Object (POJO) + * for the Nuki Bridge API /auth endpoint. + * + * @author Jan Vybíral - Initial contribution + */ +public class BridgeApiAuthDto { + private String token; + private boolean success; + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public boolean isSuccess() { + return success; + } + + public void setSuccess(boolean success) { + this.success = success; + } +} diff --git a/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/dto/BridgeApiDeviceStateDto.java b/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/dto/BridgeApiDeviceStateDto.java new file mode 100644 index 000000000000..73502551411b --- /dev/null +++ b/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/dto/BridgeApiDeviceStateDto.java @@ -0,0 +1,120 @@ +/** + * Copyright (c) 2010-2021 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.nuki.internal.dto; + +/** + * Base class for responses with Nuki device state + * + * @author Jan Vybíral - Initial contribution + */ +public class BridgeApiDeviceStateDto { + private int mode; + private int state; + private String stateName; + private boolean batteryCritical; + private Boolean batteryCharging; + private Integer batteryChargeState; + private Boolean keypadBatteryCritical; + private Integer doorsensorState; + private String doorsensorStateName; + private String ringactionTimestamp; + private Boolean ringactionState; + + public int getMode() { + return mode; + } + + public void setMode(int mode) { + this.mode = mode; + } + + public int getState() { + return state; + } + + public void setState(int state) { + this.state = state; + } + + public String getStateName() { + return stateName; + } + + public void setStateName(String stateName) { + this.stateName = stateName; + } + + public boolean isBatteryCritical() { + return batteryCritical; + } + + public void setBatteryCritical(boolean batteryCritical) { + this.batteryCritical = batteryCritical; + } + + public Boolean getBatteryCharging() { + return batteryCharging; + } + + public void setBatteryCharging(Boolean batteryCharging) { + this.batteryCharging = batteryCharging; + } + + public Integer getBatteryChargeState() { + return batteryChargeState; + } + + public void setBatteryChargeState(Integer batteryChargeState) { + this.batteryChargeState = batteryChargeState; + } + + public Boolean getKeypadBatteryCritical() { + return keypadBatteryCritical; + } + + public void setKeypadBatteryCritical(Boolean keypadBatteryCritical) { + this.keypadBatteryCritical = keypadBatteryCritical; + } + + public Integer getDoorsensorState() { + return doorsensorState; + } + + public void setDoorsensorState(Integer doorsensorState) { + this.doorsensorState = doorsensorState; + } + + public String getRingactionTimestamp() { + return ringactionTimestamp; + } + + public void setRingactionTimestamp(String ringactionTimestamp) { + this.ringactionTimestamp = ringactionTimestamp; + } + + public Boolean getRingactionState() { + return ringactionState; + } + + public void setRingactionState(Boolean ringactionState) { + this.ringactionState = ringactionState; + } + + public String getDoorsensorStateName() { + return doorsensorStateName; + } + + public void setDoorsensorStateName(String doorsensorStateName) { + this.doorsensorStateName = doorsensorStateName; + } +} diff --git a/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/dto/BridgeApiListDeviceDto.java b/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/dto/BridgeApiListDeviceDto.java new file mode 100644 index 000000000000..256ee7bb8047 --- /dev/null +++ b/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/dto/BridgeApiListDeviceDto.java @@ -0,0 +1,68 @@ +/** + * Copyright (c) 2010-2021 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.nuki.internal.dto; + +/** + * The {@link BridgeApiListDeviceDto} class defines the Data Transfer Object (POJO) for the Nuki Bridge API /list + * endpoint. + * + * @author Jan Vybíral - Initial contribution + */ +public class BridgeApiListDeviceDto { + + private String nukiId; + private String firmwareVersion; + private int deviceType; + private String name; + private BridgeApiListDeviceLastKnownState lastKnownState; + + public String getNukiId() { + return nukiId; + } + + public void setNukiId(String nukiId) { + this.nukiId = nukiId; + } + + public int getDeviceType() { + return deviceType; + } + + public void setDeviceType(int deviceType) { + this.deviceType = deviceType; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public BridgeApiListDeviceLastKnownState getLastKnownState() { + return lastKnownState; + } + + public void setLastKnownState(BridgeApiListDeviceLastKnownState lastKnownState) { + this.lastKnownState = lastKnownState; + } + + public String getFirmwareVersion() { + return firmwareVersion; + } + + public void setFirmwareVersion(String firmwareVersion) { + this.firmwareVersion = firmwareVersion; + } +} diff --git a/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/dto/BridgeApiListDeviceLastKnownState.java b/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/dto/BridgeApiListDeviceLastKnownState.java new file mode 100644 index 000000000000..5dd3bed30d96 --- /dev/null +++ b/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/dto/BridgeApiListDeviceLastKnownState.java @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2010-2021 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.nuki.internal.dto; + +/** + * The {@link BridgeApiListDeviceLastKnownState} class defines the Data Transfer Object (POJO) for the Nuki Bridge API + * lastKnownState of {@link BridgeApiListDeviceDto}. + * + * @author Jan Vybíral - Initial contribution + */ +public class BridgeApiListDeviceLastKnownState extends BridgeApiLockStateDto { + + private String timestamp; + + public String getTimestamp() { + return timestamp; + } + + public void setTimestamp(String timestamp) { + this.timestamp = timestamp; + } +} diff --git a/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/dto/BridgeApiLockStateDto.java b/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/dto/BridgeApiLockStateDto.java index 6a5a9605e08b..bfbdefaad792 100644 --- a/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/dto/BridgeApiLockStateDto.java +++ b/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/dto/BridgeApiLockStateDto.java @@ -19,46 +19,9 @@ * @author Markus Katter - Initial contribution * @contributer Christian Hoefler - Door sensor integration */ -public class BridgeApiLockStateDto { - - private int state; - private String stateName; - private boolean batteryCritical; - private int doorsensorState; +public class BridgeApiLockStateDto extends BridgeApiDeviceStateDto { private boolean success; - public int getState() { - return state; - } - - public void setState(int state) { - this.state = state; - } - - public String getStateName() { - return stateName; - } - - public void setStateName(String stateName) { - this.stateName = stateName; - } - - public boolean isBatteryCritical() { - return batteryCritical; - } - - public void setBatteryCritical(boolean batteryCritical) { - this.batteryCritical = batteryCritical; - } - - public int getDoorsensorState() { - return doorsensorState; - } - - public void setDoorsensorState(int doorsensorState) { - this.doorsensorState = doorsensorState; - } - public boolean isSuccess() { return success; } diff --git a/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/dto/BridgeApiLockStateRequestDto.java b/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/dto/BridgeApiLockStateRequestDto.java index a62bfcf57335..b7fb310427ce 100644 --- a/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/dto/BridgeApiLockStateRequestDto.java +++ b/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/dto/BridgeApiLockStateRequestDto.java @@ -19,51 +19,15 @@ * @author Markus Katter - Initial contribution * @contributer Christian Hoefler - Door sensor integration */ -public class BridgeApiLockStateRequestDto { +public class BridgeApiLockStateRequestDto extends BridgeApiDeviceStateDto { - private int nukiId; - private int state; - private String stateName; - private boolean batteryCritical; - private int doorsensorState; + private Integer nukiId; - public int getNukiId() { + public Integer getNukiId() { return nukiId; } - public void setNukiId(int nukiId) { + public void setNukiId(Integer nukiId) { this.nukiId = nukiId; } - - public int getState() { - return state; - } - - public void setState(int state) { - this.state = state; - } - - public String getStateName() { - return stateName; - } - - public void setStateName(String stateName) { - this.stateName = stateName; - } - - public boolean isBatteryCritical() { - return batteryCritical; - } - - public void setBatteryCritical(boolean batteryCritical) { - this.batteryCritical = batteryCritical; - } - - public int getDoorsensorState() { - return doorsensorState; - } - - public void setDoorsensorState(int doorsensorState) { - this.doorsensorState = doorsensorState; - } } diff --git a/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/dto/WebApiBridgeDiscoveryDto.java b/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/dto/WebApiBridgeDiscoveryDto.java new file mode 100644 index 000000000000..49c22f0be009 --- /dev/null +++ b/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/dto/WebApiBridgeDiscoveryDto.java @@ -0,0 +1,54 @@ +/** + * Copyright (c) 2010-2021 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.nuki.internal.dto; + +import java.util.ArrayList; +import java.util.List; + +import org.eclipse.jdt.annotation.NonNull; + +/** + * The {@link WebApiBridgeDiscoveryDto} class defines the Data Transfer Object (POJO) for a response of + * the https://api.nuki.io/discover/bridges Web API. + * + * @author Jan Vybíral - Initial contribution + */ +public class WebApiBridgeDiscoveryDto { + private List bridges; + private Integer errorCode; + + @NonNull + public List getBridges() { + if (bridges == null) { + bridges = new ArrayList<>(); + } + return bridges; + } + + public void setBridges(List bridges) { + this.bridges = bridges; + } + + public Integer getErrorCode() { + return errorCode; + } + + public void setErrorCode(Integer errorCode) { + this.errorCode = errorCode; + } + + @Override + public String toString() { + return "WebApiBridgeDiscoveryDto{" + "bridges=" + bridges + ", errorCode=" + errorCode + '}'; + } +} diff --git a/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/dto/WebApiBridgeDto.java b/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/dto/WebApiBridgeDto.java new file mode 100644 index 000000000000..5dabacf39543 --- /dev/null +++ b/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/dto/WebApiBridgeDto.java @@ -0,0 +1,71 @@ +/** + * Copyright (c) 2010-2021 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.nuki.internal.dto; + +import org.openhab.binding.nuki.internal.constants.NukiBindingConstants; +import org.openhab.core.thing.ThingUID; + +/** + * The {@link WebApiBridgeDiscoveryDto} class defines the Data Transfer Object (POJO) for bridge object + * the https://api.nuki.io/discover/bridges Web API. + * + * @author Jan Vybíral - Initial contribution + */ +public class WebApiBridgeDto { + private String bridgeId; + private String ip; + private int port; + private String dateUpdated; + + public String getBridgeId() { + return bridgeId; + } + + public void setBridgeId(String bridgeId) { + this.bridgeId = bridgeId; + } + + public String getIp() { + return ip; + } + + public void setIp(String ip) { + this.ip = ip; + } + + public int getPort() { + return port; + } + + public void setPort(int port) { + this.port = port; + } + + public String getDateUpdated() { + return dateUpdated; + } + + public void setDateUpdated(String dateUpdated) { + this.dateUpdated = dateUpdated; + } + + @Override + public String toString() { + return "WebApiBridgeDto{" + "bridgeId='" + bridgeId + '\'' + ", ip='" + ip + '\'' + ", port=" + port + + ", dateUpdated='" + dateUpdated + '\'' + '}'; + } + + public ThingUID getThingUid() { + return new ThingUID(NukiBindingConstants.THING_TYPE_BRIDGE, getBridgeId()); + } +} diff --git a/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/handler/AbstractNukiDeviceHandler.java b/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/handler/AbstractNukiDeviceHandler.java new file mode 100644 index 000000000000..0ebe900a4fbe --- /dev/null +++ b/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/handler/AbstractNukiDeviceHandler.java @@ -0,0 +1,341 @@ +/** + * Copyright (c) 2010-2021 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.nuki.internal.handler; + +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeParseException; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.regex.Pattern; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.nuki.internal.configuration.NukiDeviceConfiguration; +import org.openhab.binding.nuki.internal.constants.NukiBindingConstants; +import org.openhab.binding.nuki.internal.dataexchange.BridgeListResponse; +import org.openhab.binding.nuki.internal.dataexchange.BridgeLockStateResponse; +import org.openhab.binding.nuki.internal.dataexchange.NukiBaseResponse; +import org.openhab.binding.nuki.internal.dataexchange.NukiHttpClient; +import org.openhab.binding.nuki.internal.dto.BridgeApiDeviceStateDto; +import org.openhab.binding.nuki.internal.dto.BridgeApiListDeviceDto; +import org.openhab.core.library.types.DateTimeType; +import org.openhab.core.library.types.DecimalType; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.thing.Bridge; +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.ThingStatusInfo; +import org.openhab.core.thing.binding.BaseThingHandler; +import org.openhab.core.thing.binding.BridgeHandler; +import org.openhab.core.thing.binding.ThingHandler; +import org.openhab.core.types.Command; +import org.openhab.core.types.RefreshType; +import org.openhab.core.types.State; +import org.openhab.core.types.UnDefType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link AbstractNukiDeviceHandler} is a base class for implementing ThingHandlers for Nuki devices + * + * @author Jan Vybíral - Initial contribution + */ +@NonNullByDefault +public abstract class AbstractNukiDeviceHandler extends BaseThingHandler { + + protected final Logger logger = LoggerFactory.getLogger(getClass()); + private static final int JOB_INTERVAL = 60; + private static final Pattern NUKI_ID_HEX_PATTERN = Pattern.compile("[A-F\\d]{8}", Pattern.CASE_INSENSITIVE); + + @Nullable + protected ScheduledFuture reInitJob; + protected T configuration; + @Nullable + private NukiHttpClient nukiHttpClient; + + private static String hexToDecimal(String hexString) { + return String.valueOf(Integer.parseInt(hexString, 16)); + } + + protected void withHttpClient(Consumer consumer) { + withHttpClient(client -> { + consumer.accept(client); + return null; + }, null); + } + + protected U withHttpClient(Function consumer, U defaultValue) { + NukiHttpClient client = this.nukiHttpClient; + if (client == null) { + logger.warn("Nuki HTTP client is null. This is a bug in Nuki Binding, please report it", + new IllegalStateException()); + return defaultValue; + } else { + return consumer.apply(client); + } + } + + public AbstractNukiDeviceHandler(Thing thing) { + super(thing); + this.configuration = getConfigAs(getConfigurationClass()); + // legacy support - check if nukiId is hexadecimal (which might have been set by previous binding version) + // and convert it to decimal + if (NUKI_ID_HEX_PATTERN.matcher(this.configuration.nukiId).matches()) { + logger.warn( + "SmartLock '{}' was created by old version of binding. It is recommended to delete it and discover again", + thing.getUID()); + this.thing.getConfiguration().put(NukiBindingConstants.PROPERTY_NUKI_ID, + hexToDecimal(configuration.nukiId)); + this.configuration = getConfigAs(getConfigurationClass()); + } + } + + @Override + public void initialize() { + scheduler.execute(this::initializeHandler); + } + + @Override + public void dispose() { + stopReInitJob(); + } + + private void initializeHandler() { + Bridge bridge = getBridge(); + if (bridge == null) { + initializeHandler(null, null); + } else { + initializeHandler(bridge.getHandler(), bridge.getStatus()); + } + } + + private void initializeHandler(@Nullable ThingHandler handler, @Nullable ThingStatus bridgeStatus) { + if (handler instanceof NukiBridgeHandler && bridgeStatus != null) { + NukiBridgeHandler bridgeHandler = (NukiBridgeHandler) handler; + if (bridgeStatus == ThingStatus.ONLINE) { + this.nukiHttpClient = bridgeHandler.getNukiHttpClient(); + withHttpClient(client -> { + BridgeListResponse bridgeListResponse = client.getList(); + if (handleResponse(bridgeListResponse, null, null)) { + BridgeApiListDeviceDto device = bridgeListResponse.getDevice(configuration.nukiId); + if (device == null) { + logger.warn("Configured Smart Lock [{}] not present in bridge device list", + configuration.nukiId); + } else { + updateStatus(ThingStatus.ONLINE); + refreshData(device); + stopReInitJob(); + } + } + }); + } else { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE); + stopReInitJob(); + } + } else { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_UNINITIALIZED); + stopReInitJob(); + } + } + + protected void refreshData(BridgeApiListDeviceDto device) { + updateProperty(NukiBindingConstants.PROPERTY_NAME, device.getName()); + if (device.getFirmwareVersion() != null) { + updateProperty(NukiBindingConstants.PROPERTY_FIRMWARE_VERSION, device.getFirmwareVersion()); + } + + if (device.getLastKnownState() != null) { + refreshState(device.getLastKnownState()); + } + } + + /** + * Method to refresh state of this thing. Implementors should read values from state and update corresponding + * channels. + * + * @param state Current state of this thing as obtained from Bridge API + */ + public abstract void refreshState(BridgeApiDeviceStateDto state); + + protected void updateState(String channelId, U state, Function transform) { + Channel channel = thing.getChannel(channelId); + if (channel != null) { + updateState(channel.getUID(), state, transform); + } + } + + protected void triggerChannel(String channelId, String event) { + Channel channel = thing.getChannel(channelId); + if (channel != null) { + triggerChannel(channel.getUID(), event); + } + } + + protected void updateState(ChannelUID channel, U state, Function transform) { + updateState(channel, state == null ? UnDefType.NULL : transform.apply(state)); + } + + protected State toDateTime(String dateTimeString) { + try { + ZonedDateTime date = OffsetDateTime.parse(dateTimeString).atZoneSameInstant(ZoneId.systemDefault()); + return new DateTimeType(date); + } catch (DateTimeParseException e) { + logger.debug("Failed to parse date from '{}'", dateTimeString); + return UnDefType.UNDEF; + } + } + + @Override + public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) { + scheduler.execute(() -> { + Bridge bridge = getBridge(); + if (bridge == null) { + initializeHandler(null, bridgeStatusInfo.getStatus()); + } else { + initializeHandler(bridge.getHandler(), bridgeStatusInfo.getStatus()); + } + }); + } + + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + logger.trace("handleCommand({}, {})", channelUID, command); + + if (getThing().getStatus() != ThingStatus.ONLINE) { + logger.debug("Thing is not ONLINE; command[{}] for channelUID[{}] is ignored", command, channelUID); + } else if (command instanceof RefreshType) { + scheduler.execute(() -> { + withHttpClient(client -> { + BridgeLockStateResponse bridgeLockStateResponse = client.getBridgeLockState(configuration.nukiId, + getDeviceType()); + if (handleResponse(bridgeLockStateResponse, channelUID.getAsString(), command.toString())) { + if (!doHandleRefreshCommand(channelUID, command, bridgeLockStateResponse)) { + logger.debug("Command[{}] for channelUID[{}] not implemented!", command, channelUID); + } + } + }); + }); + } else { + scheduler.execute(() -> { + if (!doHandleCommand(channelUID, command)) { + logger.debug("Unexpected command[{}] for channelUID[{}]!", command, channelUID); + } + }); + } + } + + /** + * Get type of this device + * + * @return Device type + */ + protected abstract int getDeviceType(); + + /** + * Get class of configuration + * + * @return Configuration class + */ + protected abstract Class getConfigurationClass(); + + /** + * Method to handle channel command - will not receive REFRESH command + * + * @param channelUID Channel which received command + * @param command Command received + * @return true if command was handled + */ + protected abstract boolean doHandleCommand(ChannelUID channelUID, Command command); + + /** + * Method for handlign {@link RefreshType} command + * + * @param channelUID Channel which received command + * @param command Command received, will always be {@link RefreshType} + * @param response Response from /lockState endpoint of Bridge API + * @return true if command was handled + */ + protected boolean doHandleRefreshCommand(ChannelUID channelUID, Command command, BridgeLockStateResponse response) { + refreshState(response.getBridgeApiLockStateDto()); + return true; + } + + protected boolean handleResponse(NukiBaseResponse nukiBaseResponse, @Nullable String channelUID, + @Nullable String command) { + if (nukiBaseResponse.getStatus() == 200 && nukiBaseResponse.isSuccess()) { + logger.debug("Command[{}] succeeded for channelUID[{}] on nukiId[{}]!", command, channelUID, + configuration.nukiId); + return true; + } else if (nukiBaseResponse.getStatus() != 200) { + logger.debug("Request to Bridge failed! status[{}] - message[{}]", nukiBaseResponse.getStatus(), + nukiBaseResponse.getMessage()); + } else if (!nukiBaseResponse.isSuccess()) { + logger.debug( + "Request from Bridge to Smart Lock failed! status[{}] - message[{}] - isSuccess[{}]. Check if Nuki Smart Lock is powered on!", + nukiBaseResponse.getStatus(), nukiBaseResponse.getMessage(), nukiBaseResponse.isSuccess()); + } + logger.debug("Could not handle command[{}] for channelUID[{}] on nukiId[{}]!", command, channelUID, + configuration.nukiId); + + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, nukiBaseResponse.getMessage()); + + updateState(NukiBindingConstants.CHANNEL_SMARTLOCK_LOCK, OnOffType.OFF, Function.identity()); + updateState(NukiBindingConstants.CHANNEL_SMARTLOCK_STATE, NukiBindingConstants.LOCK_STATES_UNDEFINED, + DecimalType::new); + updateState(NukiBindingConstants.CHANNEL_SMARTLOCK_DOOR_STATE, NukiBindingConstants.DOORSENSOR_STATES_UNKNOWN, + DecimalType::new); + + withBridgeAsync(bridge -> { + bridge.checkBridgeOnline(); + startReInitJob(); + }); + return false; + } + + private void withBridgeAsync(Consumer handler) { + Bridge bridge = getBridge(); + if (bridge != null) { + BridgeHandler bridgeHandler = bridge.getHandler(); + if (bridgeHandler instanceof NukiBridgeHandler) { + scheduler.execute(() -> handler.accept((NukiBridgeHandler) bridgeHandler)); + } + } + } + + private void startReInitJob() { + logger.trace("Starting reInitJob with interval of {}secs for Smart Lock[{}].", JOB_INTERVAL, + configuration.nukiId); + if (reInitJob != null) { + logger.trace("Already started reInitJob for Smart Lock[{}].", configuration.nukiId); + return; + } + reInitJob = scheduler.scheduleWithFixedDelay(this::initializeHandler, 1, JOB_INTERVAL, TimeUnit.SECONDS); + } + + private void stopReInitJob() { + logger.trace("Stopping reInitJob for Smart Lock[{}].", configuration.nukiId); + ScheduledFuture job = reInitJob; + if (job != null) { + job.cancel(true); + logger.trace("Stopped reInitJob for Smart Lock[{}].", configuration.nukiId); + } + reInitJob = null; + } +} diff --git a/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/handler/NukiBridgeHandler.java b/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/handler/NukiBridgeHandler.java index 1ae750797815..22c90091d858 100644 --- a/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/handler/NukiBridgeHandler.java +++ b/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/handler/NukiBridgeHandler.java @@ -12,25 +12,37 @@ */ package org.openhab.binding.nuki.internal.handler; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; import java.util.List; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import java.util.function.Function; +import javax.ws.rs.core.UriBuilder; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; import org.eclipse.jetty.client.HttpClient; import org.eclipse.jetty.http.HttpStatus; -import org.openhab.binding.nuki.internal.NukiBindingConstants; +import org.openhab.binding.nuki.internal.configuration.NukiBridgeConfiguration; +import org.openhab.binding.nuki.internal.constants.NukiBindingConstants; +import org.openhab.binding.nuki.internal.constants.NukiLinkBuilder; import org.openhab.binding.nuki.internal.dataexchange.BridgeCallbackAddResponse; import org.openhab.binding.nuki.internal.dataexchange.BridgeCallbackListResponse; import org.openhab.binding.nuki.internal.dataexchange.BridgeCallbackRemoveResponse; import org.openhab.binding.nuki.internal.dataexchange.BridgeInfoResponse; import org.openhab.binding.nuki.internal.dataexchange.NukiHttpClient; +import org.openhab.binding.nuki.internal.discovery.NukiDeviceDiscoveryService; import org.openhab.binding.nuki.internal.dto.BridgeApiCallbackListCallbackDto; -import org.openhab.core.config.core.Configuration; import org.openhab.core.thing.Bridge; import org.openhab.core.thing.ChannelUID; 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.ThingHandlerService; import org.openhab.core.types.Command; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -40,55 +52,70 @@ * sent to one of the channels. * * @author Markus Katter - Initial contribution + * @contributer Jan Vybíral - Improved callback handling */ +@NonNullByDefault public class NukiBridgeHandler extends BaseBridgeHandler { private final Logger logger = LoggerFactory.getLogger(NukiBridgeHandler.class); private static final int JOB_INTERVAL = 600; - private HttpClient httpClient; + private final HttpClient httpClient; + @Nullable private NukiHttpClient nukiHttpClient; - private String callbackUrl; + @Nullable + private final String callbackUrl; + @Nullable private ScheduledFuture checkBridgeOnlineJob; - private String bridgeIp; - private boolean manageCallbacks; - private boolean initializable; + private NukiBridgeConfiguration config = new NukiBridgeConfiguration(); - public NukiBridgeHandler(Bridge bridge, HttpClient httpClient, String callbackUrl) { + public NukiBridgeHandler(Bridge bridge, HttpClient httpClient, @Nullable String callbackUrl) { super(bridge); logger.debug("Instantiating NukiBridgeHandler({}, {}, {})", bridge, httpClient, callbackUrl); - this.httpClient = httpClient; this.callbackUrl = callbackUrl; - this.initializable = getConfig().get(NukiBindingConstants.CONFIG_IP) != null - && getConfig().get(NukiBindingConstants.CONFIG_API_TOKEN) != null; + this.httpClient = httpClient; } - public NukiHttpClient getNukiHttpClient() { - if (nukiHttpClient == null) { - nukiHttpClient = new NukiHttpClient(httpClient, getConfig()); - } - return nukiHttpClient; + public @Nullable NukiHttpClient getNukiHttpClient() { + return this.nukiHttpClient; } - public boolean isInitializable() { - return initializable; + public void withHttpClient(Consumer consumer) { + withHttpClient(client -> { + consumer.accept(client); + return null; + }, null); + } + + protected <@Nullable U> @Nullable U withHttpClient(Function consumer, U defaultValue) { + NukiHttpClient client = this.nukiHttpClient; + if (client == null) { + logger.warn("Nuki HTTP client is null. This is a bug in Nuki Binding, please report it", + new IllegalStateException()); + return defaultValue; + } else { + return consumer.apply(client); + } } @Override public void initialize() { - logger.debug("initialize() for Bridge[{}].", getThing().getUID()); - Configuration config = getConfig(); - bridgeIp = (String) config.get(NukiBindingConstants.CONFIG_IP); - manageCallbacks = (Boolean) config.get(NukiBindingConstants.CONFIG_MANAGECB); - if (bridgeIp == null) { + this.config = getConfigAs(NukiBridgeConfiguration.class); + String ip = config.ip; + Integer port = config.port; + String apiToken = config.apiToken; + + if (ip == null || port == null) { logger.debug("NukiBridgeHandler[{}] is not initializable, IP setting is unset in the configuration!", getThing().getUID()); updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "IP setting is unset"); - } else if (config.get(NukiBindingConstants.CONFIG_API_TOKEN) == null) { + } else if (apiToken == null || apiToken.isBlank()) { logger.debug("NukiBridgeHandler[{}] is not initializable, apiToken setting is unset in the configuration!", getThing().getUID()); updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "apiToken setting is unset"); } else { + NukiLinkBuilder linkBuilder = new NukiLinkBuilder(ip, port, apiToken, this.config.secureToken); + nukiHttpClient = new NukiHttpClient(httpClient, linkBuilder); scheduler.execute(this::initializeHandler); checkBridgeOnlineJob = scheduler.scheduleWithFixedDelay(this::checkBridgeOnline, JOB_INTERVAL, JOB_INTERVAL, TimeUnit.SECONDS); @@ -97,98 +124,169 @@ public void initialize() { @Override public void handleCommand(ChannelUID channelUID, Command command) { - logger.debug("handleCommand({}, {}) for Bridge[{}] not implemented!", channelUID, command, bridgeIp); + logger.debug("handleCommand({}, {}) for Bridge[{}] not implemented!", channelUID, command, this.config.ip); } @Override public void dispose() { logger.debug("dispose() for Bridge[{}].", getThing().getUID()); + if (this.config.manageCallbacks) { + unregisterCallback(); + } nukiHttpClient = null; - if (checkBridgeOnlineJob != null && !checkBridgeOnlineJob.isCancelled()) { - checkBridgeOnlineJob.cancel(true); + ScheduledFuture job = checkBridgeOnlineJob; + if (job != null) { + job.cancel(true); } checkBridgeOnlineJob = null; } - private synchronized void initializeHandler() { - logger.debug("initializeHandler() for Bridge[{}].", bridgeIp); - BridgeInfoResponse bridgeInfoResponse = getNukiHttpClient().getBridgeInfo(); - if (bridgeInfoResponse.getStatus() == HttpStatus.OK_200) { - if (manageCallbacks) { - manageNukiBridgeCallbacks(); - } - logger.debug("Bridge[{}] responded with status[{}]. Switching the bridge online.", bridgeIp, - bridgeInfoResponse.getStatus()); - updateStatus(ThingStatus.ONLINE); - } else { - logger.debug("Bridge[{}] responded with status[{}]. Switching the bridge offline!", bridgeIp, - bridgeInfoResponse.getStatus()); - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, bridgeInfoResponse.getMessage()); - } + @Override + public Collection> getServices() { + return Collections.singleton(NukiDeviceDiscoveryService.class); } - private void checkBridgeOnline() { - logger.debug("checkBridgeOnline():bridgeIp[{}] status[{}]", bridgeIp, getThing().getStatus()); - if (getThing().getStatus().equals(ThingStatus.ONLINE)) { - logger.debug("Requesting BridgeInfo to ensure Bridge[{}] is online.", bridgeIp); - BridgeInfoResponse bridgeInfoResponse = getNukiHttpClient().getBridgeInfo(); - int status = bridgeInfoResponse.getStatus(); - if (status == HttpStatus.OK_200) { - logger.debug("Bridge[{}] responded with status[{}]. Bridge is online.", bridgeIp, status); - } else if (status == HttpStatus.SERVICE_UNAVAILABLE_503) { - logger.debug( - "Bridge[{}] responded with status[{}]. REST service seems to be busy but Bridge is online.", - bridgeIp, status); + private synchronized void initializeHandler() { + withHttpClient(client -> { + BridgeInfoResponse bridgeInfoResponse = client.getBridgeInfo(); + if (bridgeInfoResponse.getStatus() == HttpStatus.OK_200) { + updateProperty(NukiBindingConstants.PROPERTY_FIRMWARE_VERSION, bridgeInfoResponse.getFirmwareVersion()); + updateProperty(NukiBindingConstants.PROPERTY_WIFI_FIRMWARE_VERSION, + bridgeInfoResponse.getWifiFirmwareVersion()); + updateProperty(NukiBindingConstants.PROPERTY_HARDWARE_ID, + Integer.toString(bridgeInfoResponse.getHardwareId())); + updateProperty(NukiBindingConstants.PROPERTY_SERVER_ID, + Integer.toString(bridgeInfoResponse.getServerId())); + if (this.config.manageCallbacks) { + manageNukiBridgeCallbacks(); + } + logger.debug("Bridge[{}] responded with status[{}]. Switching the bridge online.", this.config.ip, + bridgeInfoResponse.getStatus()); + updateStatus(ThingStatus.ONLINE); } else { - logger.debug("Bridge[{}] responded with status[{}]. Switching the bridge offline!", bridgeIp, status); + logger.debug("Bridge[{}] responded with status[{}]. Switching the bridge offline!", this.config.ip, + bridgeInfoResponse.getStatus()); updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, bridgeInfoResponse.getMessage()); } + }); + } + + public void checkBridgeOnline() { + logger.debug("checkBridgeOnline():bridgeIp[{}] status[{}]", this.config.ip, getThing().getStatus()); + if (getThing().getStatus().equals(ThingStatus.ONLINE)) { + + withHttpClient(client -> { + logger.debug("Requesting BridgeInfo to ensure Bridge[{}] is online.", this.config.ip); + BridgeInfoResponse bridgeInfoResponse = client.getBridgeInfo(); + int status = bridgeInfoResponse.getStatus(); + if (status == HttpStatus.OK_200) { + logger.debug("Bridge[{}] responded with status[{}]. Bridge is online.", this.config.ip, status); + } else if (status == HttpStatus.SERVICE_UNAVAILABLE_503) { + logger.debug( + "Bridge[{}] responded with status[{}]. REST service seems to be busy but Bridge is online.", + this.config.ip, status); + } else { + logger.debug("Bridge[{}] responded with status[{}]. Switching the bridge offline!", this.config.ip, + status); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, + bridgeInfoResponse.getMessage()); + } + }); } else { initializeHandler(); } } - private void manageNukiBridgeCallbacks() { - logger.debug("manageNukiBridgeCallbacks() for Bridge[{}].", bridgeIp); - BridgeCallbackListResponse bridgeCallbackListResponse = getNukiHttpClient().getBridgeCallbackList(); - List callbacks = bridgeCallbackListResponse.getCallbacks(); - boolean callbackExists = false; - int callbackCount = callbacks == null ? 0 : callbacks.size(); - if (callbacks != null) { - for (BridgeApiCallbackListCallbackDto callback : callbacks) { - if (callback.getUrl().equals(callbackUrl)) { - logger.debug("callbackUrl[{}] already existing on Bridge[{}].", callbackUrl, bridgeIp); - callbackExists = true; - continue; - } - if (callback.getUrl().contains(NukiBindingConstants.CALLBACK_ENDPOINT)) { - logger.debug("Partial callbackUrl[{}] found on Bridge[{}] - Removing it!", callbackUrl, bridgeIp); - BridgeCallbackRemoveResponse bridgeCallbackRemoveResponse = getNukiHttpClient() - .getBridgeCallbackRemove(callback.getId()); - if (bridgeCallbackRemoveResponse.getStatus() == HttpStatus.OK_200) { - logger.debug("Successfully removed callbackUrl[{}] on Bridge[{}]!", callbackUrl, bridgeIp); - callbackCount--; - } - } + private boolean isHttpClientNull() { + NukiHttpClient httpClient = getNukiHttpClient(); + if (httpClient == null) { + logger.debug("HTTP Client not configured, switching bridge to OFFLINE"); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "HTTP Client not configured"); + return true; + } else { + return false; + } + } + + private @Nullable List listCallbacks() { + if (isHttpClientNull()) { + return Collections.emptyList(); + } + + return withHttpClient(client -> { + BridgeCallbackListResponse bridgeCallbackListResponse = client.getBridgeCallbackList(); + if (bridgeCallbackListResponse.isSuccess()) { + return bridgeCallbackListResponse.getCallbacks(); + } else { + logger.debug("Failed to list callbacks for Bridge[{}] - status {}, message {}", this.config.ip, + bridgeCallbackListResponse.getStatus(), bridgeCallbackListResponse.getMessage()); + return null; } + }, null); + } + + private void manageNukiBridgeCallbacks() { + String callback = callbackUrl; + if (callback == null) { + logger.debug("Cannot manage callbacks - no URL available"); + return; } - if (!callbackExists) { - if (callbackCount == 3) { - logger.debug("Already 3 callback URLs existing on Bridge[{}] - Removing ID 0!", bridgeIp); - BridgeCallbackRemoveResponse bridgeCallbackRemoveResponse = getNukiHttpClient() - .getBridgeCallbackRemove(0); + + logger.debug("manageNukiBridgeCallbacks() for Bridge[{}].", this.config.ip); + + List callbacks = listCallbacks(); + if (callbacks == null) { + return; + } + + List callbacksToRemove = new ArrayList<>(3); + + // callback already registered - do nothing + if (callbacks.stream().anyMatch(cb -> cb.getUrl().equals(callback))) { + logger.debug("callbackUrl[{}] already existing on Bridge[{}].", callbackUrl, this.config.ip); + return; + } + // delete callbacks for this bridge registered for different host + String path = NukiLinkBuilder.callbackPath(getThing().getUID().getId()).build().toString(); + callbacks.stream().filter(cb -> cb.getUrl().endsWith(path)).map(BridgeApiCallbackListCallbackDto::getId) + .forEach(callbacksToRemove::add); + // delete callbacks for this bridge registered without bridgeId query (created by previous binding version) + String urlWithoutQuery = UriBuilder.fromUri(callback).replaceQuery("").build().toString(); + callbacks.stream().filter(cb -> cb.getUrl().equals(urlWithoutQuery)) + .map(BridgeApiCallbackListCallbackDto::getId).forEach(callbacksToRemove::add); + + if (callbacks.size() - callbacksToRemove.size() == 3) { + logger.debug("Already 3 callback URLs existing on Bridge[{}] - Removing ID 0!", this.config.ip); + callbacksToRemove.add(0); + } + + callbacksToRemove.forEach(callbackId -> { + withHttpClient(client -> { + BridgeCallbackRemoveResponse bridgeCallbackRemoveResponse = client.getBridgeCallbackRemove(callbackId); if (bridgeCallbackRemoveResponse.getStatus() == HttpStatus.OK_200) { - logger.debug("Successfully removed callbackUrl[{}] on Bridge[{}]!", callbackUrl, bridgeIp); - callbackCount--; + logger.debug("Successfully removed callbackUrl[{}] on Bridge[{}]!", callback, this.config.ip); } - } - logger.debug("Adding callbackUrl[{}] to Bridge[{}]!", callbackUrl, bridgeIp); - BridgeCallbackAddResponse bridgeCallbackAddResponse = getNukiHttpClient().getBridgeCallbackAdd(callbackUrl); + }); + }); + + withHttpClient(client -> { + logger.debug("Adding callbackUrl[{}] to Bridge[{}]!", callbackUrl, this.config.ip); + BridgeCallbackAddResponse bridgeCallbackAddResponse = client.getBridgeCallbackAdd(callback); if (bridgeCallbackAddResponse.getStatus() == HttpStatus.OK_200) { - logger.debug("Successfully added callbackUrl[{}] on Bridge[{}]!", callbackUrl, bridgeIp); - callbackExists = true; + logger.debug("Successfully added callbackUrl[{}] on Bridge[{}]!", callback, this.config.ip); } + }); + } + + private void unregisterCallback() { + List callbacks = listCallbacks(); + if (callbacks == null) { + return; } + + callbacks.stream().filter(callback -> callback.getUrl().equals(callbackUrl)) + .map(BridgeApiCallbackListCallbackDto::getId) + .forEach(callbackId -> withHttpClient(client -> client.getBridgeCallbackRemove(callbackId))); } } diff --git a/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/handler/NukiOpenerHandler.java b/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/handler/NukiOpenerHandler.java new file mode 100644 index 000000000000..b3f4cdc931af --- /dev/null +++ b/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/handler/NukiOpenerHandler.java @@ -0,0 +1,85 @@ +/** + * Copyright (c) 2010-2021 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.nuki.internal.handler; + +import java.time.Duration; +import java.time.Instant; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.nuki.internal.configuration.NukiDeviceConfiguration; +import org.openhab.binding.nuki.internal.constants.NukiBindingConstants; +import org.openhab.binding.nuki.internal.constants.OpenerAction; +import org.openhab.binding.nuki.internal.dataexchange.BridgeLockActionResponse; +import org.openhab.binding.nuki.internal.dto.BridgeApiDeviceStateDto; +import org.openhab.core.library.types.DecimalType; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.Thing; +import org.openhab.core.types.Command; + +/** + * Thing handler for Nuki Opener + * + * @author Jan Vybíral - Initial contribution + */ +@NonNullByDefault +public class NukiOpenerHandler extends AbstractNukiDeviceHandler { + + public NukiOpenerHandler(Thing thing) { + super(thing); + } + + private volatile Instant lastRingAction = Instant.EPOCH; + + @Override + public void refreshState(BridgeApiDeviceStateDto state) { + updateState(NukiBindingConstants.CHANNEL_OPENER_LOW_BATTERY, state.isBatteryCritical(), OnOffType::from); + updateState(NukiBindingConstants.CHANNEL_OPENER_STATE, state.getState(), DecimalType::new); + updateState(NukiBindingConstants.CHANNEL_OPENER_MODE, state.getMode(), DecimalType::new); + updateState(NukiBindingConstants.CHANNEL_OPENER_RING_ACTION_TIMESTAMP, state.getRingactionTimestamp(), + this::toDateTime); + + if (state.getRingactionState() && Duration.between(lastRingAction, Instant.now()).getSeconds() > 30) { + triggerChannel(NukiBindingConstants.CHANNEL_OPENER_RING_ACTION_STATE, NukiBindingConstants.EVENT_RINGING); + lastRingAction = Instant.now(); + } + } + + @Override + protected int getDeviceType() { + return NukiBindingConstants.DEVICE_OPENER; + } + + @Override + protected boolean doHandleCommand(ChannelUID channelUID, Command command) { + switch (channelUID.getId()) { + case NukiBindingConstants.CHANNEL_OPENER_STATE: + if (command instanceof DecimalType) { + OpenerAction action = OpenerAction.fromAction(((DecimalType) command).intValue()); + if (action != null) { + return withHttpClient(client -> { + BridgeLockActionResponse response = client.getOpenerAction(configuration.nukiId, action); + return handleResponse(response, channelUID.getAsString(), command.toString()); + }, false); + } + } + break; + } + return false; + } + + @Override + protected Class getConfigurationClass() { + return NukiDeviceConfiguration.class; + } +} diff --git a/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/handler/NukiSmartLockHandler.java b/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/handler/NukiSmartLockHandler.java index 2805853cf84e..2d0938873b47 100644 --- a/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/handler/NukiSmartLockHandler.java +++ b/bundles/org.openhab.binding.nuki/src/main/java/org/openhab/binding/nuki/internal/handler/NukiSmartLockHandler.java @@ -12,32 +12,17 @@ */ package org.openhab.binding.nuki.internal.handler; -import java.util.concurrent.ScheduledFuture; -import java.util.concurrent.TimeUnit; - -import org.openhab.binding.nuki.internal.NukiBindingConstants; -import org.openhab.binding.nuki.internal.converter.LockActionConverter; +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.nuki.internal.configuration.NukiSmartLockConfiguration; +import org.openhab.binding.nuki.internal.constants.NukiBindingConstants; +import org.openhab.binding.nuki.internal.constants.SmartLockAction; import org.openhab.binding.nuki.internal.dataexchange.BridgeLockActionResponse; -import org.openhab.binding.nuki.internal.dataexchange.BridgeLockStateResponse; -import org.openhab.binding.nuki.internal.dataexchange.NukiBaseResponse; -import org.openhab.binding.nuki.internal.dataexchange.NukiHttpClient; -import org.openhab.core.config.core.Configuration; +import org.openhab.binding.nuki.internal.dto.BridgeApiDeviceStateDto; import org.openhab.core.library.types.DecimalType; import org.openhab.core.library.types.OnOffType; -import org.openhab.core.thing.Bridge; -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.ThingStatusInfo; -import org.openhab.core.thing.binding.BaseThingHandler; -import org.openhab.core.thing.binding.ThingHandler; import org.openhab.core.types.Command; -import org.openhab.core.types.RefreshType; -import org.openhab.core.types.State; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; /** * The {@link NukiSmartLockHandler} is responsible for handling commands, which are @@ -45,245 +30,81 @@ * * @author Markus Katter - Initial contribution * @contributer Christian Hoefler - Door sensor integration + * @contributer Jan Vybíral - Refactoring, added more channels */ -public class NukiSmartLockHandler extends BaseThingHandler { - - private final Logger logger = LoggerFactory.getLogger(NukiSmartLockHandler.class); - private static final int JOB_INTERVAL = 60; - - private NukiHttpClient nukiHttpClient; - private ScheduledFuture reInitJob; - private String nukiId; - private boolean unlatch; +@NonNullByDefault +public class NukiSmartLockHandler extends AbstractNukiDeviceHandler { public NukiSmartLockHandler(Thing thing) { super(thing); - logger.debug("Instantiating NukiSmartLockHandler({})", thing); } @Override public void initialize() { - logger.debug("initialize() for Smart Lock[{}].", getThing().getUID()); - Configuration config = getConfig(); - nukiId = (String) config.get(NukiBindingConstants.CONFIG_NUKI_ID); - unlatch = (Boolean) config.get(NukiBindingConstants.CONFIG_UNLATCH); - if (nukiId == null) { - logger.debug("NukiSmartLockHandler[{}] is not initializable, nukiId setting is unset in the configuration!", - getThing().getUID()); - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "nukiId setting is unset"); - } else { - scheduler.execute(this::initializeHandler); - } + super.initialize(); } @Override - public void dispose() { - logger.debug("dispose() for Smart Lock[{}].", getThing().getUID()); - stopReInitJob(); - } - - private void initializeHandler() { - logger.debug("initializeHandler() for Smart Lock[{}]", nukiId); - Bridge bridge = getBridge(); - if (bridge == null) { - initializeHandler(null, null); - } else { - initializeHandler(bridge.getHandler(), bridge.getStatus()); - } - } - - private void initializeHandler(ThingHandler bridgeHandler, ThingStatus bridgeStatus) { - if (bridgeHandler != null && bridgeStatus != null) { - if (bridgeStatus == ThingStatus.ONLINE) { - nukiHttpClient = ((NukiBridgeHandler) bridgeHandler).getNukiHttpClient(); - BridgeLockStateResponse bridgeLockStateResponse = nukiHttpClient.getBridgeLockState(nukiId); - if (handleResponse(bridgeLockStateResponse, null, null)) { - updateStatus(ThingStatus.ONLINE); - for (Channel channel : thing.getChannels()) { - handleCommand(channel.getUID(), RefreshType.REFRESH); - } - stopReInitJob(); - } - } else { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE); - stopReInitJob(); - } - } else { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_UNINITIALIZED); - stopReInitJob(); - } + public void refreshState(BridgeApiDeviceStateDto state) { + updateState(NukiBindingConstants.CHANNEL_SMARTLOCK_LOCK, + state.getState() == NukiBindingConstants.LOCK_STATES_LOCKED, OnOffType::from); + updateState(NukiBindingConstants.CHANNEL_SMARTLOCK_BATTERY_LEVEL, state.getBatteryChargeState(), + DecimalType::new); + updateState(NukiBindingConstants.CHANNEL_SMARTLOCK_BATTERY_CHARGING, state.getBatteryCharging(), + OnOffType::from); + updateState(NukiBindingConstants.CHANNEL_SMARTLOCK_LOW_BATTERY, state.isBatteryCritical(), OnOffType::from); + updateState(NukiBindingConstants.CHANNEL_SMARTLOCK_KEYPAD_LOW_BATTERY, state.getKeypadBatteryCritical(), + OnOffType::from); + updateState(NukiBindingConstants.CHANNEL_SMARTLOCK_STATE, state.getState(), DecimalType::new); + updateState(NukiBindingConstants.CHANNEL_SMARTLOCK_DOOR_STATE, state.getDoorsensorState(), DecimalType::new); } @Override - public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) { - logger.debug("bridgeStatusChanged({}) for Smart Lock[{}].", bridgeStatusInfo, nukiId); - scheduler.execute(() -> { - Bridge bridge = getBridge(); - if (bridge == null) { - initializeHandler(null, bridgeStatusInfo.getStatus()); - } else { - initializeHandler(bridge.getHandler(), bridgeStatusInfo.getStatus()); - } - }); + protected int getDeviceType() { + return NukiBindingConstants.DEVICE_SMART_LOCK; } @Override - public void handleCommand(ChannelUID channelUID, Command command) { - logger.debug("handleCommand({}, {})", channelUID, command); - - if (getThing().getStatus() != ThingStatus.ONLINE) { - logger.debug("Thing is not ONLINE; command[{}] for channelUID[{}] is ignored", command, channelUID); - return; - } - - if (command instanceof RefreshType) { - handleCommandRefreshType(channelUID, command); - return; - } - - boolean validCmd = true; + protected boolean doHandleCommand(ChannelUID channelUID, Command command) { switch (channelUID.getId()) { case NukiBindingConstants.CHANNEL_SMARTLOCK_LOCK: if (command instanceof OnOffType) { - int lockAction; - if (unlatch) { - lockAction = (command == OnOffType.OFF ? NukiBindingConstants.LOCK_ACTIONS_UNLATCH - : NukiBindingConstants.LOCK_ACTIONS_LOCK); + final SmartLockAction action; + + if (command == OnOffType.OFF) { + action = configuration.unlatch ? SmartLockAction.UNLATCH : SmartLockAction.UNLOCK; } else { - lockAction = (command == OnOffType.OFF ? NukiBindingConstants.LOCK_ACTIONS_UNLOCK - : NukiBindingConstants.LOCK_ACTIONS_LOCK); - } - Channel channelLockState = thing.getChannel(NukiBindingConstants.CHANNEL_SMARTLOCK_STATE); - if (channelLockState != null) { - updateState(channelLockState.getUID(), - new DecimalType(LockActionConverter.getLockStateFor(lockAction))); + action = SmartLockAction.LOCK; } - BridgeLockActionResponse bridgeLockActionResponse = nukiHttpClient.getBridgeLockAction(nukiId, - lockAction); - handleResponse(bridgeLockActionResponse, channelUID.getAsString(), command.toString()); - } else { - validCmd = false; + + withHttpClient(client -> { + BridgeLockActionResponse bridgeLockActionResponse = client + .getSmartLockAction(configuration.nukiId, action); + handleResponse(bridgeLockActionResponse, channelUID.getAsString(), command.toString()); + }); + + return true; } break; case NukiBindingConstants.CHANNEL_SMARTLOCK_STATE: if (command instanceof DecimalType) { - int lockAction; - lockAction = ((DecimalType) command).intValue(); - lockAction = LockActionConverter.getLockActionFor(lockAction); - updateState(channelUID, new DecimalType(LockActionConverter.getLockStateFor(lockAction))); - BridgeLockActionResponse bridgeLockActionResponse = nukiHttpClient.getBridgeLockAction(nukiId, - lockAction); - handleResponse(bridgeLockActionResponse, channelUID.getAsString(), command.toString()); - } else { - validCmd = false; - } - break; - default: - validCmd = false; - break; - } - if (!validCmd) { - logger.debug("Unexpected command[{}] for channelUID[{}]!", command, channelUID); - } - } - - private void handleCommandRefreshType(ChannelUID channelUID, Command command) { - logger.debug("handleCommandRefreshType({}, {})", channelUID, command); - BridgeLockStateResponse bridgeLockStateResponse; - switch (channelUID.getId()) { - case NukiBindingConstants.CHANNEL_SMARTLOCK_LOCK: - bridgeLockStateResponse = nukiHttpClient.getBridgeLockState(nukiId); - if (handleResponse(bridgeLockStateResponse, channelUID.getAsString(), command.toString())) { - int lockState = bridgeLockStateResponse.getState(); - State state; - if (lockState == NukiBindingConstants.LOCK_STATES_LOCKED) { - state = OnOffType.ON; - } else if (lockState == NukiBindingConstants.LOCK_STATES_UNLOCKED) { - state = OnOffType.OFF; - } else { - logger.warn( - "Smart Lock returned lockState[{}]. Intentionally setting possibly wrong value 'OFF' for channel 'smartlockLock'!", - lockState); - state = OnOffType.OFF; + DecimalType cmd = (DecimalType) command; + SmartLockAction action = SmartLockAction.fromAction(cmd.intValue()); + if (action != null) { + withHttpClient(client -> { + BridgeLockActionResponse bridgeLockActionResponse = client + .getSmartLockAction(configuration.nukiId, action); + handleResponse(bridgeLockActionResponse, channelUID.getAsString(), command.toString()); + }); } - updateState(channelUID, state); + return true; } - break; - case NukiBindingConstants.CHANNEL_SMARTLOCK_STATE: - bridgeLockStateResponse = nukiHttpClient.getBridgeLockState(nukiId); - if (handleResponse(bridgeLockStateResponse, channelUID.getAsString(), command.toString())) { - updateState(channelUID, new DecimalType(bridgeLockStateResponse.getState())); - } - break; - case NukiBindingConstants.CHANNEL_SMARTLOCK_LOW_BATTERY: - bridgeLockStateResponse = nukiHttpClient.getBridgeLockState(nukiId); - if (handleResponse(bridgeLockStateResponse, channelUID.getAsString(), command.toString())) { - updateState(channelUID, bridgeLockStateResponse.isBatteryCritical() ? OnOffType.ON : OnOffType.OFF); - } - break; - case NukiBindingConstants.CHANNEL_SMARTLOCK_DOOR_STATE: - bridgeLockStateResponse = nukiHttpClient.getBridgeLockState(nukiId); - if (handleResponse(bridgeLockStateResponse, channelUID.getAsString(), command.toString())) { - updateState(channelUID, new DecimalType(bridgeLockStateResponse.getDoorsensorState())); - } - break; - default: - logger.debug("Command[{}] for channelUID[{}] not implemented!", command, channelUID); - return; } - } - - private boolean handleResponse(NukiBaseResponse nukiBaseResponse, String channelUID, String command) { - if (nukiBaseResponse.getStatus() == 200 && nukiBaseResponse.isSuccess()) { - logger.debug("Command[{}] succeeded for channelUID[{}] on nukiId[{}]!", command, channelUID, nukiId); - return true; - } else if (nukiBaseResponse.getStatus() != 200) { - logger.debug("Request to Bridge failed! status[{}] - message[{}]", nukiBaseResponse.getStatus(), - nukiBaseResponse.getMessage()); - } else if (!nukiBaseResponse.isSuccess()) { - logger.debug( - "Request from Bridge to Smart Lock failed! status[{}] - message[{}] - isSuccess[{}]. Check if Nuki Smart Lock is powered on!", - nukiBaseResponse.getStatus(), nukiBaseResponse.getMessage(), nukiBaseResponse.isSuccess()); - } - logger.debug("Could not handle command[{}] for channelUID[{}] on nukiId[{}]!", command, channelUID, nukiId); - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, nukiBaseResponse.getMessage()); - Channel channelLock = thing.getChannel(NukiBindingConstants.CHANNEL_SMARTLOCK_LOCK); - if (channelLock != null) { - updateState(channelLock.getUID(), OnOffType.OFF); - } - Channel channelLockState = thing.getChannel(NukiBindingConstants.CHANNEL_SMARTLOCK_STATE); - if (channelLockState != null) { - updateState(channelLockState.getUID(), new DecimalType(NukiBindingConstants.LOCK_STATES_UNDEFINED)); - } - Channel channelDoorState = thing.getChannel(NukiBindingConstants.CHANNEL_SMARTLOCK_DOOR_STATE); - if (channelDoorState != null) { - updateState(channelDoorState.getUID(), new DecimalType(NukiBindingConstants.DOORSENSOR_STATES_UNKNOWN)); - } - startReInitJob(); return false; } - private void startReInitJob() { - logger.trace("Starting reInitJob with interval of {}secs for Smart Lock[{}].", JOB_INTERVAL, nukiId); - if (reInitJob != null) { - logger.trace("Already started reInitJob for Smart Lock[{}].", nukiId); - return; - } - reInitJob = scheduler.scheduleWithFixedDelay(this::initializeHandler, JOB_INTERVAL, JOB_INTERVAL, - TimeUnit.SECONDS); - } - - private void stopReInitJob() { - logger.trace("Stopping reInitJob for Smart Lock[{}].", nukiId); - if (reInitJob != null && !reInitJob.isCancelled()) { - logger.trace("Stopped reInitJob for Smart Lock[{}].", nukiId); - reInitJob.cancel(true); - } - reInitJob = null; - } - - public void handleApiServletUpdate(ChannelUID channelUID, State newState) { - logger.trace("handleApiServletUpdate({}, {})", channelUID, newState); - updateState(channelUID, newState); + @Override + protected Class getConfigurationClass() { + return NukiSmartLockConfiguration.class; } } diff --git a/bundles/org.openhab.binding.nuki/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.nuki/src/main/resources/OH-INF/thing/thing-types.xml index e1884ee57b21..64fd2d76fed3 100644 --- a/bundles/org.openhab.binding.nuki/src/main/resources/OH-INF/thing/thing-types.xml +++ b/bundles/org.openhab.binding.nuki/src/main/resources/OH-INF/thing/thing-types.xml @@ -9,6 +9,13 @@ This bridge represents a Nuki Bridge on your local network. Nuki Smart Locks have to be paired via Bluetooth with it. + + + + + + + @@ -16,7 +23,7 @@ The IP address of the Nuki Bridge. Look it up on your router. It is recommended to set a static IP address lease for the Nuki Bridge (and for your openHAB server too) on your router. - + The Port which you configured during Initial Bridge setup (https://nuki.io/en/support/bridge/bridge-setup/initial-bridge-setup/). @@ -30,14 +37,31 @@ - Let the Nuki Binding manage the callback on the Nuki Bridge. + + Let the Nuki Binding manage the callback on the Nuki Bridge. Nuki bridge uses HTTP callback to notify + openHAB about changes in device properties (e.g. when doors are opened, unlocked, doorbell rings, battery level + changes etc.). If callback is not registered, binding will not work properly and channels will not be updated. If + this is enabled, binding will automatically register and unregister callback as necessary. If this is disabled, + user must register callback manually. It is recommended that this is turned on. + + true + + + + + Use hashed token when communicating with bridge. This increases security and prevents sniffing of + access token and replay attacks, since communication with bridge is not encrypted. For this feature to work, both + device running openHAB and Nuki Bridge must have synchronized time. When disabled, token is sent in plain text with + each bridge request. It is recommended that this is turned on unless there are problems with synchronizing time + between openHAB and Nuki Bridge. + true - + @@ -47,20 +71,54 @@ + + + + + + + + nukiId - - - The 8-digit hexadecimal string that identifies the Nuki Smart Lock. Look it up on the sticker on the - back of the Nuki Smart Lock (remove mounting plate). - If switched to On (or set to true) the Nuki Smart Lock will unlock the door but then also automatically pull the latch of the door lock. Usually, if the door hinges are correctly adjusted, the door will then swing open. false + + + The decimal string that identifies the Nuki Smart Lock. + + + + + + + + + + + Nuki Opener which is paired via Bluetooth to a Nuki Bridge. + + + + + + + + + + + + nukiId + + + + The decimal string that identifies the Nuki Opener. + @@ -101,13 +159,37 @@ + + + + + + + + + + veto + + + + Switch + + Use this channel to display the current state of charging + Energy + + + + + + + Number Use this channel to display the current state of the door sensor Door - + @@ -117,6 +199,69 @@ + veto + + + + Number + + Use this channel if you want to execute other supported opener actions or to display the current opener + state. + FrontDoor + + + + + + + + + + + + + + + + + + + + + veto + + + + Number + + Use this channel to display/set current mode of the opener + FrontDoor + + + + + + + veto + + + + trigger + + Channel is triggered when doorbell is rang, at most once every 30s + Siren + + + + + + + DateTime + + Time of last ring action + Siren + veto +