]> git.basschouten.com Git - openhab-addons.git/commitdiff
[somfytahoma] added support for the control over the LAN (local mode) (#13411)
authorOndrej Pecta <opecta@gmail.com>
Sun, 11 Dec 2022 11:07:25 +0000 (12:07 +0100)
committerGitHub <noreply@github.com>
Sun, 11 Dec 2022 11:07:25 +0000 (12:07 +0100)
* [somfytahoma] added support for the control over the LAN (local mode)

Signed-off-by: Ondrej Pecta <opecta@gmail.com>
17 files changed:
bundles/org.openhab.binding.somfytahoma/README.md
bundles/org.openhab.binding.somfytahoma/src/main/java/org/openhab/binding/somfytahoma/internal/SomfyTahomaBindingConstants.java
bundles/org.openhab.binding.somfytahoma/src/main/java/org/openhab/binding/somfytahoma/internal/config/SomfyTahomaConfig.java
bundles/org.openhab.binding.somfytahoma/src/main/java/org/openhab/binding/somfytahoma/internal/discovery/SomfyTahomaItemDiscoveryService.java
bundles/org.openhab.binding.somfytahoma/src/main/java/org/openhab/binding/somfytahoma/internal/discovery/SomfyTahomaMDNSDiscoveryListener.java [new file with mode: 0644]
bundles/org.openhab.binding.somfytahoma/src/main/java/org/openhab/binding/somfytahoma/internal/handler/SomfyTahomaBridgeHandler.java
bundles/org.openhab.binding.somfytahoma/src/main/java/org/openhab/binding/somfytahoma/internal/handler/SomfyTahomaLightSensorHandler.java
bundles/org.openhab.binding.somfytahoma/src/main/java/org/openhab/binding/somfytahoma/internal/handler/SomfyTahomaTemperatureSensorHandler.java
bundles/org.openhab.binding.somfytahoma/src/main/java/org/openhab/binding/somfytahoma/internal/model/SomfyTahomaDevice.java
bundles/org.openhab.binding.somfytahoma/src/main/java/org/openhab/binding/somfytahoma/internal/model/SomfyTahomaDeviceDefinition.java
bundles/org.openhab.binding.somfytahoma/src/main/java/org/openhab/binding/somfytahoma/internal/model/SomfyTahomaError.java [new file with mode: 0644]
bundles/org.openhab.binding.somfytahoma/src/main/java/org/openhab/binding/somfytahoma/internal/model/SomfyTahomaLocalToken.java [new file with mode: 0644]
bundles/org.openhab.binding.somfytahoma/src/main/java/org/openhab/binding/somfytahoma/internal/model/SomfyTahomaLoginResponse.java
bundles/org.openhab.binding.somfytahoma/src/main/java/org/openhab/binding/somfytahoma/internal/model/SomfyTahomaStatusResponse.java
bundles/org.openhab.binding.somfytahoma/src/main/java/org/openhab/binding/somfytahoma/internal/model/SomfyTahomaTokenReponse.java [new file with mode: 0644]
bundles/org.openhab.binding.somfytahoma/src/main/resources/OH-INF/config/config.xml
bundles/org.openhab.binding.somfytahoma/src/main/resources/OH-INF/i18n/somfytahoma.properties

index caf52f72a583ff146751347c7e2168688a95141d..8dd228bc9f550769645e3dfad2fc52e0ad0dbfea 100644 (file)
@@ -45,7 +45,7 @@ Any home automation system based on the OverKiz API is potentially supported.
 - water heater system (monitor and control)
 - Yutaki heat pump consisting of heat pump, heating control and hot water tank (controls and a lot of states; it is tested with components RAS-4WHNPE, RWM-4.ONE, DHWT-300S-3.0H2E)
 
-Both Somfy Tahoma and Somfy Connexoon gateways have been confirmed working.
+Both Somfy Tahoma and Somfy Connexoon gateways have been confirmed working in the cloud mode.
 
 ## Discovery
 
@@ -59,7 +59,41 @@ If you are missing some device, check the debug log during the discovery and cre
 
 ## Thing Configuration
 
-To retrieve thing configuration and url parameter, just add the automatically discovered device from your inbox and copy its values from thing edit page. (the url parameter is visible on edit page only)
+### bridge
+
+| Parameter      | Parameter ID  | Required/Optional | Description                                                                          |
+|----------------|---------------|-------------------|--------------------------------------------------------------------------------------|
+| Cloud portal   | cloudPortal   | Optional          | Cloud portal to connect to                                                           |
+| Email address  | email         | Required          | Email address for the portal                                                         |
+| Password       | password      | Required          | Password for the portal                                                              |
+| Refresh        | refresh       | Optional          | Refresh time for polling events (in seconds)                                         |
+| Status timeout | statusTimeout | Optional          | Reconciliation timeout after which the status is refreshed (in seconds)              |
+| Retries        | retries       | Optional          | Specifies the number of retries when command execution                               |
+| Retry delay    | retryDelay    | Optional          | Delay in milliseconds between subsequent retries after a command failure             |
+| Developer mode | devMode       | Optional          | Enables the direct control of your devices over the lan using the local API endpoint |
+| Gateway IP     | ip            | Optional          | Local IP address of gateway, relevant only if developer mode is enabled              |
+| Gateway PIN    | pin           | Optional          | Gateway PIN in format ABCD-EFGH-IJKL, relevant only if developer mode is enabled     |
+| Local token    | token         | Optional          | Token for local communication, relevant only if developer mode is enabled            |
+
+For more information about the developer mode please see https://github.com/Somfy-Developer/Somfy-TaHoma-Developer-Mode.
+If the gateway ip or pin are not provided, the binding tries to detect it automatically and saves it into the configuration.
+If the local token is not provided, the binding creates the local token automatically and saves it into the configuration.
+Please note that the action groups (scenarios) control does not work in local mode due to missing support in the gateway firmware.
+The gateway support for the developer mode is limited as well, so far Connexoon gateways do not support the developer mode.
+
+### gateway
+
+| Parameter  | Parameter ID | Required/Optional | Description                               |
+|------------|--------------|-------------------|-------------------------------------------|
+| Gateway id | id           | Required          | ID of your gateway (sometimes called pin) |
+
+### other devices
+
+| Parameter  | Parameter ID | Required/Optional | Description                  |
+|------------|--------------|-------------------|------------------------------|
+| Device URL | url          | Required          | The identifier of the device |
+
+To retrieve the url parameter or gateway id, just add the automatically discovered device from your inbox and copy its values from thing edit page. (the url parameter is visible on edit page only)
 Please see the example below.
 
 ## Channels
@@ -73,7 +107,7 @@ Please see the example below.
 | gate                                                                               | gate_state                      | get state of your gate (open, closed, pedestrian)                                                                                                                                                                                 |
 | gate                                                                               | gate_position                   | get position (0-100%) of your gate (where supported)                                                                                                                                                                              |
 | roller shutter, shutter, screen, ven. blind, garage door, awning, pergola, curtain | control                         | device controller which reacts to commands UP/DOWN/ON/OFF/OPEN/CLOSE/MY/STOP + closure 0-100                                                                                                                                      |
-| roller shutter                                                                     | moving                          | Indicates if the device is currently operating a command                                                                                                                                                            |
+| roller shutter                                                                     | moving                          | Indicates if the device is currently operating a command                                                                                                                                                                          |
 | window                                                                             | control                         | device controller which reacts to commands UP/DOWN/ON/OFF/OPEN/CLOSE/STOP + closure 0-100                                                                                                                                         |
 | silent roller shutter                                                              | silent_control                  | similar to control channel but in silent mode                                                                                                                                                                                     |
 | venetian blind, adjustable slats roller shutter, bioclimatic pergola               | orientation                     | percentual orientation of the blind's slats, it can have value 0-100. For IO Homecontrol devices only (non RTS)                                                                                                                   |
@@ -150,7 +184,7 @@ Please see the example below.
 | hitachi (yutaki) air to water heating zone                                         | zone_mode                       | sets the zone mode (Auto, Manual)                                                                                                                                                                                                 |
 | hitachi (yutaki) air to water heating zone                                         | thermostat_setting_zone1        | controls the thermostat setting for the zone 1                                                                                                                                                                                    |
 | hitachi (yutaki) air to water heating zone                                         | wh_setting_temp_zone1           | controls the water heating setting temperature for the zone 1                                                                                                                                                                     |
-| hitachi (yutaki) air to water heating zone                                         | room_ambient_temp_zone1         | controls the room ambient temperature for the zone 1                                                                                                                                                                               |
+| hitachi (yutaki) air to water heating zone                                         | room_ambient_temp_zone1         | controls the room ambient temperature for the zone 1                                                                                                                                                                              |
 | hitachi (yutaki) domestic hot water                                                | anti_legionella                 | controls the anti legionella mode (Run, Stop)                                                                                                                                                                                     |
 | hitachi (yutaki) domestic hot water                                                | anti_legionella_temp            | controls the anti legionella temperature                                                                                                                                                                                          |
 | hitachi (yutaki) domestic hot water                                                | target_boost_mode               | controls the boost mode (No request, Enabled, Disabled)                                                                                                                                                                           |
index 394f18669c5e675c72d235d5762551dc66bd8605..4570882dc743ff36b1dbdf9b8cfdeead54c0cc7c 100644 (file)
@@ -291,7 +291,6 @@ public class SomfyTahomaBindingConstants {
     public static final String POWER_HEAT_PUMP = "power_heatpump";
     public static final String POWER_HEAT_ELEC = "power_heatelec";
     public static final String WATER_HEATER_MODE = "mode";
-    public static final String WATER_TEMPERATURE = "water_temperature";
     public static final String ELECTRIC_BOOSTER_OPERATING_TIME = "electric_booster_operating_time";
     public static final String SHOWERS = "showers";
 
@@ -367,12 +366,15 @@ public class SomfyTahomaBindingConstants {
     public static final String API_BASE_URL = "/enduser-mobile-web/enduserAPI/";
     public static final String EVENTS_URL = "events/";
     public static final String SETUP_URL = "setup/";
+
+    public static final String CONFIG_URL = "config/";
     public static final String GATEWAYS_URL = SETUP_URL + "gateways/";
     public static final String DEVICES_URL = SETUP_URL + "devices/";
     public static final String REFRESH_URL = DEVICES_URL + "states/refresh";
     public static final String EXEC_URL = "exec/";
     public static final String DELETE_URL = EXEC_URL + "current/setup/";
-    public static final String TAHOMA_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36";
+    public static final String LOCAL_TOKENS_URL = "/local/tokens/";
+    public static final String TAHOMA_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.82 Safari/537.36";
     public static final int TAHOMA_TIMEOUT = 5;
     public static final String UNAUTHORIZED = "Not logged in";
     public static final int TYPE_NONE = 0;
@@ -381,9 +383,13 @@ public class SomfyTahomaBindingConstants {
     public static final int TYPE_STRING = 3;
     public static final int TYPE_BOOLEAN = 6;
     public static final String UNAVAILABLE = "unavailable";
-    public static final String AUTHENTICATION_CHALLENGE = "HTTP protocol violation: Authentication challenge without WWW-Authenticate header";
-    public static final String AUTHENTICATION_OAUTH_GRANT_ERROR = "Provided Authorization Grant is invalid.";
+    public static final String TEMPORARILY_BANNED = "Too many attempts with an invalid token, temporarily banned.";
+
     public static final String TOO_MANY_REQUESTS = "Too many requests, try again later";
+    public static final String EVENT_LISTENER_TIMEOUT = "No registered event listener";
+    public static final String AUTHENTICATION_OAUTH_GRANT_ERROR = "Provided Authorization Grant is invalid.";
+    public static final String AUTHENTICATION_OAUTH_INVALID_GRANT = "error.invalid.grant";
+    public static final String OPENHAB_TOKEN = "openHAB token";
     public static final int SUSPEND_TIME = 120;
     public static final int RECONCILIATION_TIME = 600;
 
@@ -449,6 +455,7 @@ public class SomfyTahomaBindingConstants {
     public static final String RADIO_PART_BATTERY_STATE = "io:MaintenanceRadioPartBatteryState";
     public static final String SENSOR_PART_BATTERY_STATE = "io:MaintenanceSensorPartBatteryState";
     public static final String ZWAVE_SET_POINT_TYPE_STATE = "zwave:SetPointTypeState";
+    public static final String LUMINANCE_STATE = "core:LuminanceState";
 
     // supported uiClasses
     public static final String CLASS_ROLLER_SHUTTER = "RollerShutter";
index 46fe961773b172fb6cd130b0b644b3788298bfc4..04fa1c653b967125755d8a6ec91b622d46985c8f 100644 (file)
@@ -31,6 +31,10 @@ public class SomfyTahomaConfig {
     private int statusTimeout = 300;
     private int retries = 10;
     private int retryDelay = 1000;
+    private boolean devMode = false;
+    private String pin = "";
+    private String ip = "";
+    private String token = "";
 
     public String getCloudPortal() {
         return cloudPortal;
@@ -60,6 +64,22 @@ public class SomfyTahomaConfig {
         return retryDelay;
     }
 
+    public boolean isDevMode() {
+        return devMode;
+    }
+
+    public String getPin() {
+        return pin;
+    }
+
+    public String getIp() {
+        return ip;
+    }
+
+    public String getToken() {
+        return token;
+    }
+
     public void setCloudPortal(String cloudPortal) {
         this.cloudPortal = cloudPortal;
     }
@@ -79,4 +99,16 @@ public class SomfyTahomaConfig {
     public void setRetryDelay(int retryDelay) {
         this.retryDelay = retryDelay;
     }
+
+    public void setPin(String pin) {
+        this.pin = pin;
+    }
+
+    public void setIp(String ip) {
+        this.ip = ip;
+    }
+
+    public void setToken(String token) {
+        this.token = token;
+    }
 }
index c19c52075c65f70bc3cf6a1eefc5ce138ffc1df1..ae08b5c0007252f5f2e1c0bf36954bb419c78991 100644 (file)
@@ -104,7 +104,7 @@ public class SomfyTahomaItemDiscoveryService extends AbstractDiscoveryService
     protected void stopBackgroundDiscovery() {
         logger.debug("Stopping SomfyTahoma background discovery");
         ScheduledFuture<?> localDiscoveryJob = discoveryJob;
-        if (localDiscoveryJob != null && !localDiscoveryJob.isCancelled()) {
+        if (localDiscoveryJob != null) {
             localDiscoveryJob.cancel(true);
         }
     }
@@ -137,14 +137,17 @@ public class SomfyTahomaItemDiscoveryService extends AbstractDiscoveryService
                 gatewayDiscovered(gw);
             }
 
-            List<SomfyTahomaActionGroup> actions = localBridgeHandler.listActionGroups();
+            // local mode does not have action groups
+            if (!localBridgeHandler.isDevModeReady()) {
+                List<SomfyTahomaActionGroup> actions = localBridgeHandler.listActionGroups();
 
-            for (SomfyTahomaActionGroup group : actions) {
-                String oid = group.getOid();
-                String label = group.getLabel();
+                for (SomfyTahomaActionGroup group : actions) {
+                    String oid = group.getOid();
+                    String label = group.getLabel();
 
-                // actiongroups use oid as deviceURL
-                actionGroupDiscovered(label, oid, oid);
+                    // actiongroups use oid as deviceURL
+                    actionGroupDiscovered(label, oid);
+                }
             }
         } else {
             logger.debug("Cannot start discovery since the bridge is not online!");
@@ -154,7 +157,8 @@ public class SomfyTahomaItemDiscoveryService extends AbstractDiscoveryService
     private void discoverDevice(SomfyTahomaDevice device, SomfyTahomaSetup setup) {
         logger.debug("url: {}", device.getDeviceURL());
         String place = getPlaceLabel(setup, device.getPlaceOID());
-        switch (device.getUiClass()) {
+        String widget = device.getDefinition().getWidgetName();
+        switch (device.getDefinition().getUiClass()) {
             case CLASS_AWNING:
                 // widget: PositionableHorizontalAwning
                 // widget: DynamicAwning
@@ -180,7 +184,7 @@ public class SomfyTahomaItemDiscoveryService extends AbstractDiscoveryService
                 deviceDiscovered(device, THING_TYPE_GARAGEDOOR, place);
                 break;
             case CLASS_LIGHT:
-                if ("DimmerLight".equals(device.getWidget()) || "DynamicLight".equals(device.getWidget())) {
+                if ("DimmerLight".equals(widget) || "DynamicLight".equals(widget)) {
                     // widget: DimmerLight
                     // widget: DynamicLight
                     deviceDiscovered(device, THING_TYPE_DIMMER_LIGHT, place);
@@ -243,7 +247,7 @@ public class SomfyTahomaItemDiscoveryService extends AbstractDiscoveryService
                 if (device.getDeviceURL().startsWith("internal:")) {
                     // widget: TSKAlarmController
                     deviceDiscovered(device, THING_TYPE_INTERNAL_ALARM, place);
-                } else if ("MyFoxAlarmController".equals(device.getWidget())) {
+                } else if ("MyFoxAlarmController".equals(widget)) {
                     // widget: MyFoxAlarmController
                     deviceDiscovered(device, THING_TYPE_MYFOX_ALARM, place);
                 } else {
@@ -256,9 +260,9 @@ public class SomfyTahomaItemDiscoveryService extends AbstractDiscoveryService
                 }
                 break;
             case CLASS_HEATING_SYSTEM:
-                if ("SomfyThermostat".equals(device.getWidget())) {
+                if ("SomfyThermostat".equals(widget)) {
                     deviceDiscovered(device, THING_TYPE_THERMOSTAT, place);
-                } else if ("ValveHeatingTemperatureInterface".equals(device.getWidget())) {
+                } else if ("ValveHeatingTemperatureInterface".equals(widget)) {
                     deviceDiscovered(device, THING_TYPE_VALVE_HEATING_SYSTEM, place);
                 } else if (isOnOffHeatingSystem(device)) {
                     deviceDiscovered(device, THING_TYPE_ONOFF_HEATING_SYSTEM, place);
@@ -269,7 +273,7 @@ public class SomfyTahomaItemDiscoveryService extends AbstractDiscoveryService
                 }
                 break;
             case CLASS_EXTERIOR_HEATING_SYSTEM:
-                if ("DimmerExteriorHeating".equals(device.getWidget())) {
+                if ("DimmerExteriorHeating".equals(widget)) {
                     // widget: DimmerExteriorHeating
                     deviceDiscovered(device, THING_TYPE_EXTERIOR_HEATING_SYSTEM, place);
                 } else {
@@ -288,7 +292,7 @@ public class SomfyTahomaItemDiscoveryService extends AbstractDiscoveryService
                 deviceDiscovered(device, THING_TYPE_DOOR_LOCK, place);
                 break;
             case CLASS_PERGOLA:
-                if ("BioclimaticPergola".equals(device.getWidget())) {
+                if ("BioclimaticPergola".equals(widget)) {
                     // widget: BioclimaticPergola
                     deviceDiscovered(device, THING_TYPE_BIOCLIMATIC_PERGOLA, place);
                 } else {
@@ -315,7 +319,7 @@ public class SomfyTahomaItemDiscoveryService extends AbstractDiscoveryService
                 break;
             case CLASS_WATER_HEATING_SYSTEM:
                 // widget: DomesticHotWaterProduction
-                if ("DomesticHotWaterProduction".equals(device.getWidget())) {
+                if ("DomesticHotWaterProduction".equals(widget)) {
                     deviceDiscovered(device, THING_TYPE_WATERHEATINGSYSTEM, place);
                 } else {
                     logUnsupportedDevice(device);
@@ -340,13 +344,13 @@ public class SomfyTahomaItemDiscoveryService extends AbstractDiscoveryService
                 }
                 break;
             case CLASS_HITACHI_HEATING_SYSTEM:
-                if ("HitachiAirToWaterHeatingZone".equals(device.getWidget())) {
+                if ("HitachiAirToWaterHeatingZone".equals(widget)) {
                     // widget: HitachiAirToWaterHeatingZone
                     deviceDiscovered(device, THING_TYPE_HITACHI_ATWHZ, place);
-                } else if ("HitachiAirToWaterMainComponent".equals(device.getWidget())) {
+                } else if ("HitachiAirToWaterMainComponent".equals(widget)) {
                     // widget: HitachiAirToWaterMainComponent
                     deviceDiscovered(device, THING_TYPE_HITACHI_ATWMC, place);
-                } else if ("HitachiDHW".equals(device.getWidget())) {
+                } else if ("HitachiDHW".equals(widget)) {
                     // widget: HitachiDHW
                     deviceDiscovered(device, THING_TYPE_HITACHI_DHW, place);
                 } else {
@@ -354,7 +358,7 @@ public class SomfyTahomaItemDiscoveryService extends AbstractDiscoveryService
                 }
                 break;
             case CLASS_RAIN_SENSOR:
-                if ("RainSensor".equals(device.getWidget())) {
+                if ("RainSensor".equals(widget)) {
                     // widget: RainSensor
                     deviceDiscovered(device, THING_TYPE_RAINSENSOR, place);
                 } else {
@@ -391,8 +395,8 @@ public class SomfyTahomaItemDiscoveryService extends AbstractDiscoveryService
 
     private void logUnsupportedDevice(SomfyTahomaDevice device) {
         if (!isStateLess(device)) {
-            logger.debug("Detected a new unsupported device: {} with widgetName: {}", device.getUiClass(),
-                    device.getWidget());
+            logger.debug("Detected a new unsupported device: {} with widgetName: {}",
+                    device.getDefinition().getUiClass(), device.getDefinition().getWidgetName());
             logger.debug("If you want to add the support, please create a new issue and attach the information below");
             logger.debug("Device definition:\n{}", device.getDefinition());
 
@@ -417,11 +421,11 @@ public class SomfyTahomaItemDiscoveryService extends AbstractDiscoveryService
     }
 
     private boolean isSilentRollerShutter(SomfyTahomaDevice device) {
-        return "PositionableRollerShutterWithLowSpeedManagement".equals(device.getWidget());
+        return "PositionableRollerShutterWithLowSpeedManagement".equals(device.getDefinition().getWidgetName());
     }
 
     private boolean isUnoRollerShutter(SomfyTahomaDevice device) {
-        return "PositionableRollerShutterUno".equals(device.getWidget());
+        return "PositionableRollerShutterUno".equals(device.getDefinition().getWidgetName());
     }
 
     private boolean isOnOffHeatingSystem(SomfyTahomaDevice device) {
@@ -441,11 +445,10 @@ public class SomfyTahomaItemDiscoveryService extends AbstractDiscoveryService
         if (place != null && !place.isBlank()) {
             label += " (" + place + ")";
         }
-        deviceDiscovered(label, device.getDeviceURL(), device.getOid(), thingTypeUID,
-                hasState(device, RSSI_LEVEL_STATE));
+        deviceDiscovered(label, device.getDeviceURL(), thingTypeUID, hasState(device, RSSI_LEVEL_STATE));
     }
 
-    private void deviceDiscovered(String label, String deviceURL, String oid, ThingTypeUID thingTypeUID, boolean rssi) {
+    private void deviceDiscovered(String label, String deviceURL, ThingTypeUID thingTypeUID, boolean rssi) {
         Map<String, Object> properties = new HashMap<>();
         properties.put("url", deviceURL);
         properties.put(NAME_STATE, label);
@@ -455,17 +458,18 @@ public class SomfyTahomaItemDiscoveryService extends AbstractDiscoveryService
 
         SomfyTahomaBridgeHandler localBridgeHandler = bridgeHandler;
         if (localBridgeHandler != null) {
-            ThingUID thingUID = new ThingUID(thingTypeUID, localBridgeHandler.getThing().getUID(), oid);
+            ThingUID thingUID = new ThingUID(thingTypeUID, localBridgeHandler.getThing().getUID(),
+                    deviceURL.replaceAll("[^a-zA-Z0-9_]", ""));
 
-            logger.debug("Detected a/an {} - label: {} oid: {}", thingTypeUID.getId(), label, oid);
+            logger.debug("Detected a/an {} - label: {} device URL: {}", thingTypeUID.getId(), label, deviceURL);
             thingDiscovered(DiscoveryResultBuilder.create(thingUID).withThingType(thingTypeUID)
                     .withProperties(properties).withRepresentationProperty("url").withLabel(label)
                     .withBridge(localBridgeHandler.getThing().getUID()).build());
         }
     }
 
-    private void actionGroupDiscovered(String label, String deviceURL, String oid) {
-        deviceDiscovered(label, deviceURL, oid, THING_TYPE_ACTIONGROUP, false);
+    private void actionGroupDiscovered(String label, String deviceURL) {
+        deviceDiscovered(label, deviceURL, THING_TYPE_ACTIONGROUP, false);
     }
 
     private void gatewayDiscovered(SomfyTahomaGateway gw) {
diff --git a/bundles/org.openhab.binding.somfytahoma/src/main/java/org/openhab/binding/somfytahoma/internal/discovery/SomfyTahomaMDNSDiscoveryListener.java b/bundles/org.openhab.binding.somfytahoma/src/main/java/org/openhab/binding/somfytahoma/internal/discovery/SomfyTahomaMDNSDiscoveryListener.java
new file mode 100644 (file)
index 0000000..96ea85b
--- /dev/null
@@ -0,0 +1,84 @@
+/**
+ * Copyright (c) 2010-2022 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.somfytahoma.internal.discovery;
+
+import java.util.Enumeration;
+
+import javax.jmdns.ServiceEvent;
+import javax.jmdns.ServiceInfo;
+import javax.jmdns.ServiceListener;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.somfytahoma.internal.handler.SomfyTahomaBridgeHandler;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link SomfyTahomaMDNSDiscoveryListener} represents a mDNS listener
+ * for a mDNS discovery.
+ *
+ * @author Ondrej Pecta - Initial contribution
+ */
+@NonNullByDefault
+public class SomfyTahomaMDNSDiscoveryListener implements ServiceListener {
+
+    private final Logger logger = LoggerFactory.getLogger(SomfyTahomaMDNSDiscoveryListener.class);
+    private final SomfyTahomaBridgeHandler handler;
+
+    public SomfyTahomaMDNSDiscoveryListener(SomfyTahomaBridgeHandler handler) {
+        this.handler = handler;
+    }
+
+    @Override
+    public void serviceAdded(@Nullable ServiceEvent event) {
+        if (event != null) {
+            logger.trace("Service added: {}", event.getInfo());
+        }
+    }
+
+    @Override
+    public void serviceRemoved(@Nullable ServiceEvent event) {
+        if (event != null) {
+            logger.trace("Service removed: {}", event.getInfo());
+        }
+    }
+
+    @Override
+    public void serviceResolved(@Nullable ServiceEvent event) {
+        if (event == null || event.getInfo() == null) {
+            logger.debug("Null event received");
+            return;
+        }
+
+        ServiceInfo info = event.getInfo();
+        logger.trace("Service resolved: {}", info);
+        if (info.getInet4Addresses().length > 0) {
+            logger.debug("Server address: {}", info.getInet4Addresses()[0].getHostAddress());
+            handler.setGatewayIPAddress(info.getInet4Addresses()[0].getHostAddress());
+        }
+        Enumeration<String> e = info.getPropertyNames();
+        if (e != null) {
+            while (e.hasMoreElements()) {
+                String name = e.nextElement();
+                if ("gateway_pin".equals(name)) {
+                    String pin = info.getPropertyString(name);
+                    logger.debug("Gateway PIN: {}", pin);
+                    handler.setGatewayPin(pin);
+                    handler.updateConfiguration();
+                    break;
+                }
+            }
+        }
+    }
+}
index 950146b52e4521f49e1ac52d8b68bda89a171448..2f7e9d69f8fecfba6b71f2c5f9b4b0deddfcb7a8 100644 (file)
@@ -14,6 +14,8 @@ package org.openhab.binding.somfytahoma.internal.handler;
 
 import static org.openhab.binding.somfytahoma.internal.SomfyTahomaBindingConstants.*;
 
+import java.io.IOException;
+import java.net.InetAddress;
 import java.net.URLEncoder;
 import java.nio.charset.StandardCharsets;
 import java.time.Duration;
@@ -28,23 +30,29 @@ import java.util.concurrent.ScheduledFuture;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
 
+import javax.jmdns.JmDNS;
 import javax.ws.rs.core.MediaType;
 
 import org.eclipse.jdt.annotation.NonNullByDefault;
 import org.eclipse.jdt.annotation.Nullable;
 import org.eclipse.jetty.client.HttpClient;
+import org.eclipse.jetty.client.WWWAuthenticationProtocolHandler;
 import org.eclipse.jetty.client.api.ContentResponse;
 import org.eclipse.jetty.client.api.Request;
 import org.eclipse.jetty.client.util.StringContentProvider;
 import org.eclipse.jetty.http.HttpHeader;
 import org.eclipse.jetty.http.HttpMethod;
+import org.eclipse.jetty.util.ssl.SslContextFactory;
 import org.openhab.binding.somfytahoma.internal.config.SomfyTahomaConfig;
 import org.openhab.binding.somfytahoma.internal.discovery.SomfyTahomaItemDiscoveryService;
+import org.openhab.binding.somfytahoma.internal.discovery.SomfyTahomaMDNSDiscoveryListener;
 import org.openhab.binding.somfytahoma.internal.model.SomfyTahomaAction;
 import org.openhab.binding.somfytahoma.internal.model.SomfyTahomaActionGroup;
 import org.openhab.binding.somfytahoma.internal.model.SomfyTahomaApplyResponse;
 import org.openhab.binding.somfytahoma.internal.model.SomfyTahomaDevice;
+import org.openhab.binding.somfytahoma.internal.model.SomfyTahomaError;
 import org.openhab.binding.somfytahoma.internal.model.SomfyTahomaEvent;
+import org.openhab.binding.somfytahoma.internal.model.SomfyTahomaLocalToken;
 import org.openhab.binding.somfytahoma.internal.model.SomfyTahomaLoginResponse;
 import org.openhab.binding.somfytahoma.internal.model.SomfyTahomaOauth2Error;
 import org.openhab.binding.somfytahoma.internal.model.SomfyTahomaOauth2Reponse;
@@ -53,7 +61,9 @@ import org.openhab.binding.somfytahoma.internal.model.SomfyTahomaSetup;
 import org.openhab.binding.somfytahoma.internal.model.SomfyTahomaState;
 import org.openhab.binding.somfytahoma.internal.model.SomfyTahomaStatus;
 import org.openhab.binding.somfytahoma.internal.model.SomfyTahomaStatusResponse;
+import org.openhab.binding.somfytahoma.internal.model.SomfyTahomaTokenReponse;
 import org.openhab.core.cache.ExpiringCache;
+import org.openhab.core.config.core.Configuration;
 import org.openhab.core.io.net.http.HttpClientFactory;
 import org.openhab.core.thing.Bridge;
 import org.openhab.core.thing.ChannelUID;
@@ -86,7 +96,7 @@ public class SomfyTahomaBridgeHandler extends BaseBridgeHandler {
     /**
      * The shared HttpClient
      */
-    private final HttpClient httpClient;
+    private @Nullable HttpClient httpClient;
 
     /**
      * Future to poll for updates
@@ -103,6 +113,11 @@ public class SomfyTahomaBridgeHandler extends BaseBridgeHandler {
      */
     private @Nullable ScheduledFuture<?> reconciliationFuture;
 
+    /**
+     * Future for postponed login
+     */
+    private @Nullable ScheduledFuture<?> loginFuture;
+
     // List of futures used for command retries
     private Collection<ScheduledFuture<?>> retryFutures = new ConcurrentLinkedQueue<ScheduledFuture<?>>();
 
@@ -120,6 +135,9 @@ public class SomfyTahomaBridgeHandler extends BaseBridgeHandler {
     // Reconciliation flag
     private boolean reconciliation = false;
 
+    // Cloud fallback
+    private boolean cloudFallback = false;
+
     /**
      * Our configuration
      */
@@ -130,6 +148,8 @@ public class SomfyTahomaBridgeHandler extends BaseBridgeHandler {
      */
     private String eventsId = "";
 
+    private String localToken = "";
+
     private Map<String, SomfyTahomaDevice> devicePlaces = new HashMap<>();
 
     private ExpiringCache<List<SomfyTahomaDevice>> cachedDevices = new ExpiringCache<>(Duration.ofSeconds(30),
@@ -138,9 +158,11 @@ public class SomfyTahomaBridgeHandler extends BaseBridgeHandler {
     // Gson & parser
     private final Gson gson = new Gson();
 
+    private final HttpClientFactory httpClientFactory;
+
     public SomfyTahomaBridgeHandler(Bridge thing, HttpClientFactory httpClientFactory) {
         super(thing);
-        this.httpClient = httpClientFactory.createHttpClient("somfy_" + thing.getUID().getId());
+        this.httpClientFactory = httpClientFactory;
     }
 
     @Override
@@ -149,7 +171,24 @@ public class SomfyTahomaBridgeHandler extends BaseBridgeHandler {
 
     @Override
     public void initialize() {
+        updateStatus(ThingStatus.UNKNOWN);
         thingConfig = getConfigAs(SomfyTahomaConfig.class);
+        createHttpClient();
+
+        scheduler.execute(() -> {
+            login();
+            initPolling();
+            logger.debug("Initialize done...");
+        });
+    }
+
+    private void createHttpClient() {
+        // let's create the right http client
+        if (thingConfig.isDevMode()) {
+            this.httpClient = new HttpClient(new SslContextFactory.Client(true));
+        } else {
+            this.httpClient = httpClientFactory.createHttpClient("somfy_" + thing.getUID().getId());
+        }
 
         try {
             httpClient.start();
@@ -157,12 +196,8 @@ public class SomfyTahomaBridgeHandler extends BaseBridgeHandler {
             logger.debug("Cannot start http client for: {}", thing.getBridgeUID().getId(), e);
             return;
         }
-
-        scheduler.execute(() -> {
-            login();
-            initPolling();
-            logger.debug("Initialize done...");
-        });
+        // Remove the WWWAuth protocol handler since Tahoma is not fully compliant
+        httpClient.getProtocolHandlers().remove(WWWAuthenticationProtocolHandler.NAME);
     }
 
     /**
@@ -214,9 +249,9 @@ public class SomfyTahomaBridgeHandler extends BaseBridgeHandler {
         }
 
         reLoginNeeded = false;
+        cloudFallback = false;
 
         try {
-
             String urlParameters = "";
 
             // if cozytouch, must use oauth server
@@ -239,31 +274,37 @@ public class SomfyTahomaBridgeHandler extends BaseBridgeHandler {
 
             SomfyTahomaLoginResponse data = gson.fromJson(response.getContentAsString(),
                     SomfyTahomaLoginResponse.class);
+
             if (data == null) {
                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
                         "Received invalid data (login)");
-            } else if (data.isSuccess()) {
-                logger.debug("SomfyTahoma version: {}", data.getVersion());
+            } else if (!data.getErrorCode().isEmpty()) {
+                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, data.getError());
+                if (data.getError().startsWith(TOO_MANY_REQUESTS)) {
+                    setTooManyRequests();
+                }
+            } else {
+                if (thingConfig.isDevMode()) {
+                    initializeLocalMode();
+                }
+
                 String id = registerEvents();
                 if (id != null && !UNAUTHORIZED.equals(id)) {
                     eventsId = id;
                     logger.debug("Events id: {}", eventsId);
-                    updateStatus(ThingStatus.ONLINE);
+                    updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE,
+                            isDevModeReady() ? "LAN mode" : cloudFallback ? "Cloud mode fallback" : "Cloud mode");
                 } else {
                     logger.debug("Events id error: {}", id);
-                }
-            } else {
-                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
-                        "Error logging in: " + data.getError());
-                if (data.getError().startsWith(TOO_MANY_REQUESTS)) {
-                    setTooManyRequests();
+                    updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
+                            "unable to register events");
                 }
             }
         } catch (JsonSyntaxException e) {
             logger.debug("Received invalid data (login)", e);
             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Received invalid data (login)");
         } catch (ExecutionException e) {
-            if (isAuthenticationChallenge(e) || isOAuthGrantError(e)) {
+            if (isOAuthGrantError(e)) {
                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
                         "Error logging in (check your credentials)");
                 setTooManyRequests();
@@ -280,11 +321,106 @@ public class SomfyTahomaBridgeHandler extends BaseBridgeHandler {
         }
     }
 
+    public boolean isDevModeReady() {
+        return thingConfig.isDevMode() && !localToken.isEmpty() && !cloudFallback;
+    }
+
+    private void initializeLocalMode() {
+        if (thingConfig.getIp().isEmpty() || thingConfig.getPin().isEmpty()) {
+            discoverGateway();
+        }
+
+        if (!thingConfig.getIp().isEmpty() && !thingConfig.getPin().isEmpty()) {
+            try {
+                if (thingConfig.getToken().isEmpty()) {
+                    localToken = getNewLocalToken();
+                    logger.debug("Local token retrieved");
+                    activateLocalToken();
+                    updateConfiguration();
+                } else {
+                    localToken = thingConfig.getToken();
+                    activateLocalToken();
+                }
+                logger.debug("Local mode initialized, waiting for cloud sync");
+                Thread.sleep(3000);
+            } catch (InterruptedException ex) {
+                logger.debug("Interruption during local mode initialization, falling back to cloud mode", ex);
+                Thread.currentThread().interrupt();
+            } catch (ExecutionException | TimeoutException ex) {
+                logger.debug("Exception during local mode initialization, falling back to cloud mode", ex);
+                cloudFallback = true;
+            }
+        } else {
+            logger.debug("Cannot switch to developer mode - gateway not found on LAN");
+            cloudFallback = true;
+        }
+    }
+
+    private String getNewLocalToken() throws ExecutionException, InterruptedException, TimeoutException {
+        // Get list of local tokens
+        SomfyTahomaLocalToken[] tokens = invokeCallToURL(
+                CONFIG_URL + thingConfig.getPin() + LOCAL_TOKENS_URL + "devmode", "", HttpMethod.GET,
+                SomfyTahomaLocalToken[].class);
+
+        // Delete old OH tokens
+        for (SomfyTahomaLocalToken token : tokens) {
+            if (OPENHAB_TOKEN.equals(token.getLabel())) {
+                logger.debug("Deleting token: {}", token.getUuid());
+                sendDeleteToTahomaWithCookie(CONFIG_URL + thingConfig.getPin() + LOCAL_TOKENS_URL + token.getUuid());
+            }
+        }
+
+        // Generate a new token
+        SomfyTahomaTokenReponse tokenResponse = invokeCallToURL(
+                CONFIG_URL + thingConfig.getPin() + LOCAL_TOKENS_URL + "generate", "", HttpMethod.GET,
+                SomfyTahomaTokenReponse.class);
+
+        return tokenResponse.getToken();
+    }
+
+    private void discoverGateway() {
+        logger.debug("Starting mDNS discovery...");
+        JmDNS jmdns = null;
+
+        try {
+            // Create a JmDNS instance
+            jmdns = JmDNS.create(InetAddress.getLocalHost());
+            jmdns.addServiceListener("_kizboxdev._tcp.local.", new SomfyTahomaMDNSDiscoveryListener(this));
+
+            // Wait a bit
+            Thread.sleep(TAHOMA_TIMEOUT * 1000);
+        } catch (InterruptedException e) {
+            logger.debug("mDNS discovery interrupted", e);
+            Thread.currentThread().interrupt();
+        } catch (IOException e) {
+            logger.debug("Exception during mDNS discovery", e);
+        }
+
+        if (jmdns != null) {
+            jmdns.unregisterAllServices();
+            try {
+                jmdns.close();
+            } catch (IOException e) {
+                // ignore
+            }
+        }
+    }
+
+    private void activateLocalToken() throws ExecutionException, InterruptedException, TimeoutException {
+        String param = "{\"label\" : \"" + OPENHAB_TOKEN + "\",\"token\" : \"" + localToken
+                + "\",\"scope\" : \"devmode\"}";
+        String response = sendPostToTahomaWithCookie(CONFIG_URL + thingConfig.getPin() + "/local/tokens", param);
+        logger.trace("Local token activation: {}", response);
+    }
+
     private void setTooManyRequests() {
-        logger.debug("Too many requests or bad credentials for the cloud portal, suspending activity for {} seconds",
-                SUSPEND_TIME);
-        tooManyRequests = true;
-        scheduler.schedule(this::enableLogin, SUSPEND_TIME, TimeUnit.SECONDS);
+        if (!tooManyRequests) {
+            logger.debug(
+                    "Too many requests or bad credentials for the cloud portal, suspending activity for {} seconds",
+                    SUSPEND_TIME);
+            tooManyRequests = true;
+            loginFuture = scheduler.schedule(this::enableLogin, SUSPEND_TIME, TimeUnit.SECONDS);
+        }
     }
 
     private @Nullable String registerEvents() {
@@ -302,6 +438,10 @@ public class SomfyTahomaBridgeHandler extends BaseBridgeHandler {
     }
 
     private List<SomfyTahomaEvent> getEvents() {
+        if (eventsId.isEmpty()) {
+            return List.of();
+        }
+
         SomfyTahomaEvent[] response = invokeCallToURL(EVENTS_URL + eventsId + "/fetch", "", HttpMethod.POST,
                 SomfyTahomaEvent[].class);
         return response != null ? List.of(response) : List.of();
@@ -331,11 +471,24 @@ public class SomfyTahomaBridgeHandler extends BaseBridgeHandler {
         // cancel all scheduled retries
         retryFutures.forEach(x -> x.cancel(false));
 
-        try {
-            httpClient.stop();
-        } catch (Exception e) {
-            logger.debug("Error during http client stopping", e);
+        ScheduledFuture<?> localLoginFuture = loginFuture;
+        if (localLoginFuture != null) {
+            localLoginFuture.cancel(true);
+            loginFuture = null;
         }
+
+        HttpClient localHttpClient = httpClient;
+        if (localHttpClient != null) {
+            try {
+                localHttpClient.stop();
+            } catch (Exception e) {
+                logger.debug("Error during http client stopping", e);
+            }
+            httpClient = null;
+        }
+
+        // Clean access data
+        localToken = "";
     }
 
     @Override
@@ -351,16 +504,19 @@ public class SomfyTahomaBridgeHandler extends BaseBridgeHandler {
      */
     private void stopPolling() {
         ScheduledFuture<?> localPollFuture = pollFuture;
-        if (localPollFuture != null && !localPollFuture.isCancelled()) {
+        if (localPollFuture != null) {
             localPollFuture.cancel(true);
+            pollFuture = null;
         }
         ScheduledFuture<?> localStatusFuture = statusFuture;
-        if (localStatusFuture != null && !localStatusFuture.isCancelled()) {
+        if (localStatusFuture != null) {
             localStatusFuture.cancel(true);
+            statusFuture = null;
         }
         ScheduledFuture<?> localReconciliationFuture = reconciliationFuture;
-        if (localReconciliationFuture != null && !localReconciliationFuture.isCancelled()) {
+        if (localReconciliationFuture != null) {
             localReconciliationFuture.cancel(true);
+            reconciliationFuture = null;
         }
     }
 
@@ -404,7 +560,7 @@ public class SomfyTahomaBridgeHandler extends BaseBridgeHandler {
             if (!device.getPlaceOID().isEmpty()) {
                 SomfyTahomaDevice newDevice = new SomfyTahomaDevice();
                 newDevice.setPlaceOID(device.getPlaceOID());
-                newDevice.setWidget(device.getWidget());
+                newDevice.getDefinition().setWidgetName(device.getDefinition().getWidgetName());
                 devicePlaces.put(device.getDeviceURL(), newDevice);
             }
         }
@@ -637,10 +793,10 @@ public class SomfyTahomaBridgeHandler extends BaseBridgeHandler {
 
     private String sendMethodToTahomaWithCookie(String url, HttpMethod method, String urlParameters)
             throws InterruptedException, ExecutionException, TimeoutException {
-        logger.trace("Sending {} to url: {} with data: {}", method.asString(), getApiFullUrl(url), urlParameters);
+        logger.debug("Sending {} to url: {} with data: {}", method.asString(), getApiFullUrl(url), urlParameters);
         Request request = sendRequestBuilder(url, method);
         if (!urlParameters.isEmpty()) {
-            request = request.content(new StringContentProvider(urlParameters), "application/json;charset=UTF-8");
+            request = request.content(new StringContentProvider(urlParameters), "application/json");
         }
 
         ContentResponse response = request.send();
@@ -651,17 +807,44 @@ public class SomfyTahomaBridgeHandler extends BaseBridgeHandler {
 
         if (response.getStatus() < 200 || response.getStatus() >= 300) {
             logger.debug("Received unexpected status code: {}", response.getStatus());
+            if (response.getHeaders().contains(HttpHeader.CONTENT_TYPE)) {
+                if (response.getHeaders().getField(HttpHeader.CONTENT_TYPE).getValue()
+                        .equalsIgnoreCase(MediaType.APPLICATION_JSON)) {
+                    try {
+                        SomfyTahomaError error = gson.fromJson(response.getContentAsString(), SomfyTahomaError.class);
+                        throw new ExecutionException(error.getError(), null);
+                    } catch (JsonSyntaxException e) {
+
+                    }
+                }
+            }
+            throw new ExecutionException(
+                    "Unknown http error " + response.getStatus() + " while attempting to send a message.", null);
         }
         return response.getContentAsString();
     }
 
     private Request sendRequestBuilder(String subUrl, HttpMethod method) {
+        return isLocalRequest(subUrl) ? sendRequestBuilderLocal(subUrl, method)
+                : sendRequestBuilderCloud(subUrl, method);
+    }
+
+    private boolean isLocalRequest(String subUrl) {
+        return isDevModeReady() && !subUrl.startsWith(CONFIG_URL);
+    }
+
+    private Request sendRequestBuilderCloud(String subUrl, HttpMethod method) {
         return httpClient.newRequest(getApiFullUrl(subUrl)).method(method)
                 .header(HttpHeader.ACCEPT_LANGUAGE, "en-US,en").header(HttpHeader.ACCEPT_ENCODING, "gzip, deflate")
                 .header("X-Requested-With", "XMLHttpRequest").timeout(TAHOMA_TIMEOUT, TimeUnit.SECONDS)
                 .agent(TAHOMA_AGENT);
     }
 
+    private Request sendRequestBuilderLocal(String subUrl, HttpMethod method) {
+        return httpClient.newRequest(getApiFullUrl(subUrl)).method(method).accept("application/json")
+                .header(HttpHeader.AUTHORIZATION, "Bearer " + localToken);
+    }
+
     /**
      * Performs the login for Cozytouch using OAUTH2 authorization.
      *
@@ -720,7 +903,9 @@ public class SomfyTahomaBridgeHandler extends BaseBridgeHandler {
     }
 
     private String getApiFullUrl(String subUrl) {
-        return "https://" + thingConfig.getCloudPortal() + API_BASE_URL + subUrl;
+        return isLocalRequest(subUrl)
+                ? "https://" + thingConfig.getIp() + ":8443/enduser-mobile-web/1/enduserAPI/" + subUrl
+                : "https://" + thingConfig.getCloudPortal() + API_BASE_URL + subUrl;
     }
 
     public void sendCommand(String io, String command, String params, String url) {
@@ -781,7 +966,7 @@ public class SomfyTahomaBridgeHandler extends BaseBridgeHandler {
         if (device != null && !device.getPlaceOID().isEmpty()) {
             devicePlaces.forEach((deviceUrl, devicePlace) -> {
                 if (device.getPlaceOID().equals(devicePlace.getPlaceOID())
-                        && device.getWidget().equals(devicePlace.getWidget())) {
+                        && device.getDefinition().getWidgetName().equals(devicePlace.getDefinition().getWidgetName())) {
                     sendCommand(deviceUrl, command, params, url);
                 }
             });
@@ -832,6 +1017,7 @@ public class SomfyTahomaBridgeHandler extends BaseBridgeHandler {
     private boolean reLogin() {
         logger.debug("Doing relogin");
         reLoginNeeded = true;
+        localToken = "";
         login();
         return ThingStatus.OFFLINE != thing.getStatus();
     }
@@ -850,28 +1036,53 @@ public class SomfyTahomaBridgeHandler extends BaseBridgeHandler {
     }
 
     public void forceGatewaySync() {
-        invokeCallToURL(REFRESH_URL, "", HttpMethod.PUT, null);
+        // refresh is valid only if in a cloud mode
+        if (!thingConfig.isDevMode() || localToken.isEmpty()) {
+            invokeCallToURL(REFRESH_URL, "", HttpMethod.PUT, null);
+        }
     }
 
     public SomfyTahomaStatus getTahomaStatus(String gatewayId) {
-        SomfyTahomaStatusResponse data = invokeCallToURL(GATEWAYS_URL + gatewayId, "", HttpMethod.GET,
-                SomfyTahomaStatusResponse.class);
-        if (data != null) {
-            logger.debug("Tahoma status: {}", data.getConnectivity().getStatus());
-            logger.debug("Tahoma protocol version: {}", data.getConnectivity().getProtocolVersion());
-            return data.getConnectivity();
+        SomfyTahomaStatusResponse status = null;
+
+        if (isDevModeReady()) {
+            // Local endpoint does not have a method for specific gateway
+            SomfyTahomaStatusResponse[] data = invokeCallToURL(GATEWAYS_URL, "", HttpMethod.GET,
+                    SomfyTahomaStatusResponse[].class);
+            if (data != null) {
+                for (SomfyTahomaStatusResponse gatewayStatus : data) {
+                    if (gatewayStatus.getGatewayId().equals(gatewayId)) {
+                        status = gatewayStatus;
+                        break;
+                    }
+                }
+            }
+        } else {
+            status = invokeCallToURL(GATEWAYS_URL + gatewayId, "", HttpMethod.GET, SomfyTahomaStatusResponse.class);
+        }
+
+        if (status != null) {
+            logger.debug("Tahoma status: {}", status.getConnectivity().getStatus());
+            logger.debug("Tahoma protocol version: {}", status.getConnectivity().getProtocolVersion());
+            return status.getConnectivity();
         }
         return new SomfyTahomaStatus();
     }
 
-    private boolean isAuthenticationChallenge(Exception ex) {
+    private boolean isTempBanned(Exception ex) {
         String msg = ex.getMessage();
-        return msg != null && msg.contains(AUTHENTICATION_CHALLENGE);
+        return msg != null && msg.contains(TEMPORARILY_BANNED);
+    }
+
+    private boolean isEventListenerTimeout(Exception ex) {
+        String msg = ex.getMessage();
+        return msg != null && msg.contains(EVENT_LISTENER_TIMEOUT);
     }
 
     private boolean isOAuthGrantError(Exception ex) {
         String msg = ex.getMessage();
-        return msg != null && msg.contains(AUTHENTICATION_OAUTH_GRANT_ERROR);
+        return msg != null
+                && (msg.contains(AUTHENTICATION_OAUTH_GRANT_ERROR) || msg.contains(AUTHENTICATION_OAUTH_INVALID_GRANT));
     }
 
     @Override
@@ -915,7 +1126,10 @@ public class SomfyTahomaBridgeHandler extends BaseBridgeHandler {
             logger.debug("Received data: {} is not JSON", response, e);
             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Received invalid data");
         } catch (ExecutionException e) {
-            if (isAuthenticationChallenge(e)) {
+            if (isTempBanned(e)) {
+                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Temporarily banned");
+                setTooManyRequests();
+            } else if (isEventListenerTimeout(e)) {
                 reLogin();
             } else {
                 logger.debug("Cannot call url: {} with params: {}!", getApiFullUrl(url), urlParameters, e);
@@ -930,4 +1144,22 @@ public class SomfyTahomaBridgeHandler extends BaseBridgeHandler {
         }
         return null;
     }
+
+    public void setGatewayIPAddress(String gatewayIPAddress) {
+        thingConfig.setIp(gatewayIPAddress);
+    }
+
+    public void setGatewayPin(String gatewayPin) {
+        thingConfig.setPin(gatewayPin);
+    }
+
+    public void updateConfiguration() {
+        Configuration config = editConfiguration();
+        config.put("ip", thingConfig.getIp());
+        config.put("pin", thingConfig.getPin());
+        if (!localToken.isEmpty()) {
+            config.put("token", localToken);
+        }
+        updateConfiguration(config);
+    }
 }
index 120ec5f2c932cdc971aab7da5c1c26eb8fc60224..4f20be8e5bc4b86899628ca0f4af84827b1c1dc6 100644 (file)
@@ -28,7 +28,9 @@ public class SomfyTahomaLightSensorHandler extends SomfyTahomaBaseThingHandler {
 
     public SomfyTahomaLightSensorHandler(Thing thing) {
         super(thing);
-        stateNames.put(LUMINANCE, "core:LuminanceState");
+        stateNames.put(LUMINANCE, LUMINANCE_STATE);
         stateNames.put(SENSOR_DEFECT, SENSOR_DEFECT_STATE);
+        // override state type because the local server sends luminance in percent
+        cacheStateType(LUMINANCE_STATE, TYPE_DECIMAL);
     }
 }
index acb7ef86cd2c1d58257c647acd19a45695a4632a..503593da58ef7c65111ccdf1b73eab298b396e7c 100644 (file)
@@ -12,8 +12,7 @@
  */
 package org.openhab.binding.somfytahoma.internal.handler;
 
-import static org.openhab.binding.somfytahoma.internal.SomfyTahomaBindingConstants.TEMPERATURE;
-import static org.openhab.binding.somfytahoma.internal.SomfyTahomaBindingConstants.TYPE_DECIMAL;
+import static org.openhab.binding.somfytahoma.internal.SomfyTahomaBindingConstants.*;
 
 import org.eclipse.jdt.annotation.NonNullByDefault;
 import org.openhab.core.thing.Thing;
@@ -29,9 +28,9 @@ public class SomfyTahomaTemperatureSensorHandler extends SomfyTahomaBaseThingHan
 
     public SomfyTahomaTemperatureSensorHandler(Thing thing) {
         super(thing);
-        stateNames.put(TEMPERATURE, "core:TemperatureState");
+        stateNames.put(TEMPERATURE, TEMPERATURE_STATE);
 
         // override state type because the cloud sends both percent & decimal
-        cacheStateType("core:TemperatureState", TYPE_DECIMAL);
+        cacheStateType(TEMPERATURE_STATE, TYPE_DECIMAL);
     }
 }
index 21eb5864389d96e5ab6f9ff5fbd6547e4cd767b8..1f8b7700e66fa96aaa4accc4a7bf96333e416bb3 100644 (file)
@@ -27,8 +27,6 @@ import org.eclipse.jdt.annotation.NonNullByDefault;
 @NonNullByDefault
 public class SomfyTahomaDevice {
 
-    private String uiClass = "";
-    private String widget = "";
     private String deviceURL = "";
     private String label = "";
     private String oid = "";
@@ -49,18 +47,6 @@ public class SomfyTahomaDevice {
         return oid;
     }
 
-    public String getUiClass() {
-        return uiClass;
-    }
-
-    public String getWidget() {
-        return widget;
-    }
-
-    public void setWidget(String widget) {
-        this.widget = widget;
-    }
-
     public SomfyTahomaDeviceDefinition getDefinition() {
         return definition;
     }
index a816f9904080ba782b882ed03ee0c18340f02085..774b1eff1f49702cbb20fe02e94909ccf1271686 100644 (file)
@@ -36,6 +36,22 @@ public class SomfyTahomaDeviceDefinition {
         return states;
     }
 
+    private String widgetName = "";
+
+    private String uiClass = "";
+
+    public String getWidgetName() {
+        return widgetName;
+    }
+
+    public String getUiClass() {
+        return uiClass;
+    }
+
+    public void setWidgetName(String widgetName) {
+        this.widgetName = widgetName;
+    }
+
     @Override
     public String toString() {
         StringBuilder sb = new StringBuilder();
diff --git a/bundles/org.openhab.binding.somfytahoma/src/main/java/org/openhab/binding/somfytahoma/internal/model/SomfyTahomaError.java b/bundles/org.openhab.binding.somfytahoma/src/main/java/org/openhab/binding/somfytahoma/internal/model/SomfyTahomaError.java
new file mode 100644 (file)
index 0000000..f8a3354
--- /dev/null
@@ -0,0 +1,35 @@
+/**
+ * Copyright (c) 2010-2022 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.somfytahoma.internal.model;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link SomfyTahomaError} is used to parse error from API server.
+ *
+ * @author Ondrej Pecta - Initial contribution
+ */
+@NonNullByDefault
+public class SomfyTahomaError {
+
+    private String error = "";
+    private String errorCode = "";
+
+    public String getError() {
+        return error;
+    }
+
+    public String getErrorCode() {
+        return errorCode;
+    }
+}
diff --git a/bundles/org.openhab.binding.somfytahoma/src/main/java/org/openhab/binding/somfytahoma/internal/model/SomfyTahomaLocalToken.java b/bundles/org.openhab.binding.somfytahoma/src/main/java/org/openhab/binding/somfytahoma/internal/model/SomfyTahomaLocalToken.java
new file mode 100644 (file)
index 0000000..0d67e30
--- /dev/null
@@ -0,0 +1,34 @@
+/**
+ * Copyright (c) 2010-2022 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.somfytahoma.internal.model;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link SomfyTahomaLocalToken} is used to parse a local token.
+ *
+ * @author Ondrej Pecta - Initial contribution
+ */
+@NonNullByDefault
+public class SomfyTahomaLocalToken {
+    String uuid = "";
+    String label = "";
+
+    public String getUuid() {
+        return uuid;
+    }
+
+    public String getLabel() {
+        return label;
+    }
+}
index 21edc539a5164f7fcf6d6485cf7d9e6565643cd0..6e8d9cd79c4d63c36d889211a3a4220c513cba5c 100644 (file)
@@ -27,6 +27,8 @@ public class SomfyTahomaLoginResponse {
     private String version = "";
     private String error = "";
 
+    private String errorCode = "";
+
     public boolean isSuccess() {
         return success;
     }
@@ -38,4 +40,8 @@ public class SomfyTahomaLoginResponse {
     public String getError() {
         return error;
     }
+
+    public String getErrorCode() {
+        return errorCode;
+    }
 }
index ecab7a6a5efa2ef7f977f9e40956c042e33d3604..3fb3b41128d7735c115c3280c2516a8b321c6726 100644 (file)
@@ -23,9 +23,14 @@ import org.eclipse.jdt.annotation.NonNullByDefault;
 @NonNullByDefault
 public class SomfyTahomaStatusResponse {
 
+    private String gatewayId = "";
     private SomfyTahomaStatus connectivity = new SomfyTahomaStatus();
 
     public SomfyTahomaStatus getConnectivity() {
         return connectivity;
     }
+
+    public String getGatewayId() {
+        return gatewayId;
+    }
 }
diff --git a/bundles/org.openhab.binding.somfytahoma/src/main/java/org/openhab/binding/somfytahoma/internal/model/SomfyTahomaTokenReponse.java b/bundles/org.openhab.binding.somfytahoma/src/main/java/org/openhab/binding/somfytahoma/internal/model/SomfyTahomaTokenReponse.java
new file mode 100644 (file)
index 0000000..dcf900d
--- /dev/null
@@ -0,0 +1,29 @@
+/**
+ * Copyright (c) 2010-2022 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.somfytahoma.internal.model;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link SomfyTahomaTokenReponse} holds information about generated token
+ *
+ * @author Ondrej Pecta - Initial contribution
+ */
+@NonNullByDefault
+public class SomfyTahomaTokenReponse {
+    private String token = "";
+
+    public String getToken() {
+        return token;
+    }
+}
index 5bffde7cc5398c2d1e9abd3d06eca9bbd2265adf..3fec91f7b4345c85c1e55b3be9a506aea87087f9 100644 (file)
                        <description>Specifies the delay in milliseconds between subsequent retries after a command failure</description>
                        <default>1000</default>
                </parameter>
+
+               <parameter name="devMode" type="boolean" required="false">
+                       <label>Developer mode</label>
+                       <description>Enables the direct control of your devices over the lan using the local API endpoint. See
+                               https://github.com/Somfy-Developer/Somfy-TaHoma-Developer-Mode</description>
+                       <default>false</default>
+                       <advanced>true</advanced>
+               </parameter>
+
+               <parameter name="ip" type="text" required="false">
+                       <label>Gateway IP</label>
+                       <description>Local IP address of gateway. Relevant only if developer mode is enabled. If not provided, the binding
+                               will try to autodetect it</description>
+                       <advanced>true</advanced>
+               </parameter>
+
+               <parameter name="pin" type="text" required="false">
+                       <label>Gateway PIN</label>
+                       <description>Gateway PIN in format ABCD-EFGH-IJKL. Relevant only if developer mode is enabled. If not provided, the
+                               binding will try to autodetect it</description>
+                       <advanced>true</advanced>
+               </parameter>
+
+               <parameter name="token" type="text" required="false">
+                       <label>Local token</label>
+                       <description>Local token. Relevant only if developer mode is enabled. If not provided, the binding
+                               will try to
+                               generate it using the gateway IP and PIN</description>
+                       <advanced>true</advanced>
+               </parameter>
        </config-description>
 </config-description:config-descriptions>
index a08acf6d16386d86435232cc6a68ee2a202bad29..d3a8e8739aab4317cb533669126cde0c0e305312 100644 (file)
@@ -80,6 +80,14 @@ bridge-type.config.somfytahoma.bridge.retryDelay.label = Retry delay
 bridge-type.config.somfytahoma.bridge.retryDelay.description = Specifies the delay in milliseconds between subsequent retries after a command failure
 bridge-type.config.somfytahoma.bridge.statusTimeout.label = Status Timeout
 bridge-type.config.somfytahoma.bridge.statusTimeout.description = Specifies the timeout in seconds after which the status is got from the cloud
+bridge-type.config.somfytahoma.bridge.devMode.label = Developer mode
+bridge-type.config.somfytahoma.bridge.devMode.description = Enables the direct control of your devices over the lan using the local API endpoint. See https://github.com/Somfy-Developer/Somfy-TaHoma-Developer-Mode
+bridge-type.config.somfytahoma.bridge.ip.label = Gateway IP
+bridge-type.config.somfytahoma.bridge.ip.description = Local IP address of gateway. Relevant only if developer mode is enabled. If not provided, the binding will try to autodetect it
+bridge-type.config.somfytahoma.bridge.pin.label = Gateway PIN
+bridge-type.config.somfytahoma.bridge.pin.description = Gateway PIN in format ABCD-EFGH-IJKL. Relevant only if developer mode is enabled. If not provided, the binding will try to autodetect it
+bridge-type.config.somfytahoma.bridge.token.label = Local token
+bridge-type.config.somfytahoma.bridge.token.description = Local token. Relevant only if developer mode is enabled. If not provided, the binding will try to generate it using the gateway IP and PIN
 thing-type.config.somfytahoma.device.url.label = Somfy Tahoma Device URL
 thing-type.config.somfytahoma.device.url.description = The identifier of this Somfy device
 thing-type.config.somfytahoma.gateway.id.label = Somfy Tahoma Gateway ID