]> git.basschouten.com Git - openhab-addons.git/commitdiff
[boschindego] Rewrite to avoid external dependencies (#12905)
authorJacob Laursen <jacob-github@vindvejr.dk>
Tue, 14 Jun 2022 20:51:26 +0000 (22:51 +0200)
committerGitHub <noreply@github.com>
Tue, 14 Jun 2022 20:51:26 +0000 (22:51 +0200)
* Rewrite to avoid external dependencies

Fixes #12720

* Improve session handling
* Avoid reauthorization for each command/poll
* Further improve session handling
* Refactor SSO cookie handling
* Optimize getting DeviceStatus for unknown status code

Signed-off-by: Jacob Laursen <jacob-github@vindvejr.dk>
36 files changed:
bundles/org.openhab.binding.boschindego/README.md
bundles/org.openhab.binding.boschindego/pom.xml
bundles/org.openhab.binding.boschindego/src/main/feature/feature.xml
bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/BoschIndegoBindingConstants.java
bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/BoschIndegoHandlerFactory.java
bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/DeviceStatus.java [new file with mode: 0644]
bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/IndegoController.java [new file with mode: 0644]
bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/IndegoSession.java [new file with mode: 0644]
bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/IndegoStateConstants.java [deleted file]
bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/config/BoschIndegoConfiguration.java [new file with mode: 0644]
bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/DeviceCommand.java [new file with mode: 0644]
bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/PredictiveAdjustment.java [new file with mode: 0644]
bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/PredictiveStatus.java [new file with mode: 0644]
bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/request/AuthenticationRequest.java [new file with mode: 0644]
bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/request/SetStateRequest.java [new file with mode: 0644]
bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/response/AuthenticationResponse.java [new file with mode: 0644]
bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/response/DeviceCalendarResponse.java [new file with mode: 0644]
bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/response/DeviceStateResponse.java [new file with mode: 0644]
bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/response/LocationWeatherResponse.java [new file with mode: 0644]
bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/response/PredictiveCuttingTimeResponse.java [new file with mode: 0644]
bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/response/calendar/DeviceCalendarDayEntry.java [new file with mode: 0644]
bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/response/calendar/DeviceCalendarDaySlot.java [new file with mode: 0644]
bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/response/calendar/DeviceCalendarEntry.java [new file with mode: 0644]
bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/response/runtime/DeviceStateRuntime.java [new file with mode: 0644]
bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/response/runtime/DeviceStateRuntimes.java [new file with mode: 0644]
bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/response/weather/Forecast.java [new file with mode: 0644]
bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/response/weather/Interval.java [new file with mode: 0644]
bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/response/weather/Location.java [new file with mode: 0644]
bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/response/weather/Weather.java [new file with mode: 0644]
bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/exceptions/IndegoAuthenticationException.java [new file with mode: 0644]
bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/exceptions/IndegoException.java [new file with mode: 0644]
bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/exceptions/IndegoInvalidCommandException.java [new file with mode: 0644]
bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/exceptions/IndegoInvalidResponseException.java [new file with mode: 0644]
bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/handler/BoschIndegoHandler.java
bundles/org.openhab.binding.boschindego/src/main/resources/OH-INF/i18n/boschindego.properties
bundles/org.openhab.binding.boschindego/src/main/resources/OH-INF/thing/thing-types.xml

index 5554ee33694992c88ec223509d8b386c070ee092..a3aa3889eb517e54fbf79dac2ee256acb3bc6c3c 100644 (file)
@@ -1,69 +1,83 @@
 # Bosch Indego Binding
 
 This is the Binding for Bosch Indego Connect lawn mowers.
-Thank´s to zazaz-de who found out how the API works. His [Java Library](https://github.com/zazaz-de/iot-device-bosch-indego-controller) made this Binding possible.
+Thank´s to zazaz-de who found out how the API works.
+His [Java Library](https://github.com/zazaz-de/iot-device-bosch-indego-controller) made this Binding possible.
 
-## Configuration of the Thing
+## Thing Configuration
 
-Currently the binding supports  ***indego***  mowers as a thing type with this parameters:
+Currently the binding supports  ***indego***  mowers as a thing type with these configuration parameters:
 
-| parameter | datatype | required                       |
-|-----------|----------|--------------------------------|
-| username  | String   | yes                            |
-| password  | String   | yes                            |
-| refresh   | integer  | no (default: 180, minimum: 60) |
-
-The refresh interval is specified in seconds.
-
-A possible entry in your thing file could be:
-
-```java
-boschindego:indego:lawnmower [username="mail@example.com", password="idontneedtocutthelawnagain", refresh=120]
-```
+| Parameter | Description                                                          |
+|-----------|----------------------------------------------------------------------|
+| username  | Username for the Bosch Indego account                                |
+| password  | Password for the Bosch Indego account                                |
+| refresh   | Specifies the refresh interval in seconds (default 180, minimum: 60) |
 
 ## Channels
 
-| item-type    | description |                                                                                                                                     |
+| Channel      | Item Type   | Description                                                                                                                         |
 |--------------|-------------|-------------------------------------------------------------------------------------------------------------------------------------|
 | state        | Number      | You can send commands to this channel to control the mower and read the simplified state from it (1=mow, 2=return to dock, 3=pause) |
-| errorcode    | Number      | Errorcode of the mower (0=no error, readonly)                                                                                       |
-| statecode    | Number      | Detailed state of the mower. I included English and German map-files to read the state easier (readonly)                            |
+| errorcode    | Number      | Error code of the mower (0=no error, readonly)                                                                                      |
+| statecode    | Number      | Detailed state of the mower (readonly)                                                                                              |
 | textualstate | String      | State as a text. (readonly)                                                                                                         |
 | ready        | Number      | Shows if the mower is ready to mow (1=ready, 0=not ready, readonly)                                                                 |
 | mowed        | Dimmer      | Cut grass in percent (readonly)                                                                                                     |
 
-For example you can use this sitemap entry to control the mower manually:
+### State Codes
+
+| Code  | Description                                 |
+|-------|---------------------------------------------|
+| 0     | Reading status                              |
+| 257   | Charging                                    |
+| 258   | Docked                                      |
+| 259   | Docked - Software update                    |
+| 260   | Docked                                      |
+| 261   | Docked                                      |
+| 262   | Docked - Loading map                        |
+| 263   | Docked - Saving map                         |
+| 513   | Mowing                                      |
+| 514   | Relocalising                                |
+| 515   | Loading map                                 |
+| 516   | Learning lawn                               |
+| 517   | Paused                                      |
+| 518   | Border cut                                  |
+| 519   | Idle in lawn                                |
+| 769   | Returning to dock                           |
+| 770   | Returning to dock                           |
+| 771   | Returning to dock - Battery low             |
+| 772   | Returning to dock - Calendar timeslot ended |
+| 773   | Returning to dock - Battery temp range      |
+| 774   | Returning to dock                           |
+| 775   | Returning to dock - Lawn complete           |
+| 776   | Returning to dock - Relocalising            |
+| 1025  | Diagnostic mode                             |
+| 1026  | End of life                                 |
+| 1281  | Software update                             |
+| 64513 | Docked                                      |
+
+## Full Example
 
-```perl
-Switch item=indegostate  mappings=[ 1="Mow", 2="Return",3="Pause" ]
+### `indego.things` File
+
+```
+boschindego:indego:lawnmower [username="mail@example.com", password="idontneedtocutthelawnagain", refresh=120]
 ```
 
-## Meaning of the numeric statecodes
+### `indego.items` File
 
-You can use this as .map file
+```
+Number Indego_State { channel="boschindego:indego:lawnmower:state" }
+Number Indego_ErrorCode { channel="boschindego:indego:lawnmower:errorcode" }
+Number Indego_StateCode { channel="boschindego:indego:lawnmower:statecode" }
+String Indego_TextualState { channel="boschindego:indego:lawnmower:textualstate" }
+Number Indego_Ready { channel="boschindego:indego:lawnmower:ready" }
+Dimmer Indego_Mowed { channel="boschindego:indego:lawnmower:mowed" }
+```
+
+### `indego.sitemap` File
 
-```text
-0=Reading status
-257=Charging
-258=Docked
-259=Docked - Software update
-260=Docked
-261=Docked
-262=Docked - Loading map
-263=Docked - Saving map
-513=Mowing
-514=Relocalising
-515=Loading map
-516=Learning lawn
-517=Paused
-518=Border cut
-519=Idle in lawn
-769=Returning to Dock
-770=Returning to Dock
-771=Returning to Dock - Battery low
-772=Returning to dock - Calendar timeslot ended
-773=Returning to dock - Battery temp range
-774=Returning to dock
-775=Returning to dock - Lawn complete
-776=Returning to dock - Relocalising
+```
+Switch item=Indego_State mappings=[1="Mow", 2="Return",3="Pause"]
 ```
index e570380daa6b51431dc5d88c36dc1c23824cff36..c75c093051a356a30db5778cd9de7249912a5e15 100644 (file)
 
   <name>openHAB Add-ons :: Bundles :: Bosch Indego Binding</name>
 
-  <properties>
-    <dep.noembedding>httpclient-osgi,httpcore-osgi,commons-codec</dep.noembedding>
-  </properties>
-
-  <dependencies>
-    <dependency>
-      <groupId>de.zazaz.iot.bosch.indego</groupId>
-      <artifactId>bosch-indego-controller-lib</artifactId>
-      <version>0.8</version>
-      <scope>compile</scope>
-    </dependency>
-    <dependency>
-      <groupId>org.apache.httpcomponents</groupId>
-      <artifactId>httpclient-osgi</artifactId>
-      <version>4.5.5</version>
-      <scope>compile</scope>
-    </dependency>
-    <dependency>
-      <groupId>org.apache.httpcomponents</groupId>
-      <artifactId>httpcore-osgi</artifactId>
-      <version>4.4.9</version>
-      <scope>compile</scope>
-    </dependency>
-    <dependency>
-      <groupId>commons-codec</groupId>
-      <artifactId>commons-codec</artifactId>
-      <version>1.10</version>
-      <scope>compile</scope>
-    </dependency>
-  </dependencies>
-
 </project>
index 5dfb5261481496d4d66a2c32303c91e1ae968b32..13655c46cfda8fb12a89c63dc96b0aed15cf5379 100644 (file)
@@ -4,10 +4,6 @@
 
        <feature name="openhab-binding-boschindego" description="Bosch Indego Binding" version="${project.version}">
                <feature>openhab-runtime-base</feature>
-               <feature dependency="true">openhab.tp-jackson</feature>
-               <bundle dependency="true">mvn:org.apache.httpcomponents/httpcore-osgi/4.4.9</bundle>
-               <bundle dependency="true">mvn:org.apache.httpcomponents/httpclient-osgi/4.5.5</bundle>
-               <bundle dependency="true">mvn:commons-codec/commons-codec/1.10</bundle>
                <bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.boschindego/${project.version}</bundle>
        </feature>
 </features>
index 36dcb580c878bfabc10bc7ddb40ef8c761de4368..5469157253717ac57dccb83f6c43ff0991b444e3 100644 (file)
@@ -12,6 +12,8 @@
  */
 package org.openhab.binding.boschindego.internal;
 
+import java.util.Set;
+
 import org.eclipse.jdt.annotation.NonNullByDefault;
 import org.openhab.core.thing.ThingTypeUID;
 
@@ -36,4 +38,6 @@ public class BoschIndegoBindingConstants {
     public static final String ERRORCODE = "errorcode";
     public static final String STATECODE = "statecode";
     public static final String READY = "ready";
+
+    public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_INDEGO);
 }
index 2bd7e1ae4f79c8aa392c3e0c22271094342cfc6e..de97ac1fd98b1dbc753055bf6b538f0acf873959 100644 (file)
@@ -14,16 +14,20 @@ package org.openhab.binding.boschindego.internal;
 
 import static org.openhab.binding.boschindego.internal.BoschIndegoBindingConstants.THING_TYPE_INDEGO;
 
-import java.util.Collections;
-import java.util.Set;
-
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.eclipse.jetty.client.HttpClient;
 import org.openhab.binding.boschindego.internal.handler.BoschIndegoHandler;
+import org.openhab.core.io.net.http.HttpClientFactory;
 import org.openhab.core.thing.Thing;
 import org.openhab.core.thing.ThingTypeUID;
 import org.openhab.core.thing.binding.BaseThingHandlerFactory;
 import org.openhab.core.thing.binding.ThingHandler;
 import org.openhab.core.thing.binding.ThingHandlerFactory;
+import org.osgi.service.component.ComponentContext;
+import org.osgi.service.component.annotations.Activate;
 import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Reference;
 
 /**
  * The {@link BoschIndegoHandlerFactory} is responsible for creating things and thing
@@ -31,22 +35,30 @@ import org.osgi.service.component.annotations.Component;
  *
  * @author Jonas Fleck - Initial contribution
  */
+@NonNullByDefault
 @Component(service = ThingHandlerFactory.class, configurationPid = "binding.boschindego")
 public class BoschIndegoHandlerFactory extends BaseThingHandlerFactory {
 
-    private static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Collections.singleton(THING_TYPE_INDEGO);
+    private final HttpClient httpClient;
+
+    @Activate
+    public BoschIndegoHandlerFactory(@Reference HttpClientFactory httpClientFactory,
+            ComponentContext componentContext) {
+        super.activate(componentContext);
+        this.httpClient = httpClientFactory.getCommonHttpClient();
+    }
 
     @Override
     public boolean supportsThingType(ThingTypeUID thingTypeUID) {
-        return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
+        return BoschIndegoBindingConstants.SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
     }
 
     @Override
-    protected ThingHandler createHandler(Thing thing) {
+    protected @Nullable ThingHandler createHandler(Thing thing) {
         ThingTypeUID thingTypeUID = thing.getThingTypeUID();
 
-        if (thingTypeUID.equals(THING_TYPE_INDEGO)) {
-            return new BoschIndegoHandler(thing);
+        if (THING_TYPE_INDEGO.equals(thingTypeUID)) {
+            return new BoschIndegoHandler(thing, httpClient);
         }
 
         return null;
diff --git a/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/DeviceStatus.java b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/DeviceStatus.java
new file mode 100644 (file)
index 0000000..b4a85e5
--- /dev/null
@@ -0,0 +1,112 @@
+/**
+ * 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.boschindego.internal;
+
+import static java.util.Map.entry;
+
+import java.util.Map;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.boschindego.internal.dto.DeviceCommand;
+
+/**
+ * {@link DeviceStatus} describes status codes from the device with corresponding
+ * ready state and associated command.
+ * 
+ * @author Jacob Laursen - Initial contribution
+ */
+@NonNullByDefault
+public class DeviceStatus {
+
+    private static final Map<Integer, DeviceStatus> STATUS_MAP = Map.ofEntries(
+            entry(0, new DeviceStatus("Reading status", false, DeviceCommand.RETURN)),
+            entry(257, new DeviceStatus("Charging", false, DeviceCommand.RETURN)),
+            entry(258, new DeviceStatus("Docked", true, DeviceCommand.RETURN)),
+            entry(259, new DeviceStatus("Docked - Software update", false, DeviceCommand.RETURN)),
+            entry(260, new DeviceStatus("Docked", true, DeviceCommand.RETURN)),
+            entry(261, new DeviceStatus("Docked", true, DeviceCommand.RETURN)),
+            entry(262, new DeviceStatus("Docked - Loading map", false, DeviceCommand.MOW)),
+            entry(263, new DeviceStatus("Docked - Saving map", false, DeviceCommand.RETURN)),
+            entry(513, new DeviceStatus("Mowing", false, DeviceCommand.MOW)),
+            entry(514, new DeviceStatus("Relocalising", false, DeviceCommand.MOW)),
+            entry(515, new DeviceStatus("Loading map", false, DeviceCommand.MOW)),
+            entry(516, new DeviceStatus("Learning lawn", false, DeviceCommand.MOW)),
+            entry(517, new DeviceStatus("Paused", true, DeviceCommand.PAUSE)),
+            entry(518, new DeviceStatus("Border cut", false, DeviceCommand.MOW)),
+            entry(519, new DeviceStatus("Idle in lawn", true, DeviceCommand.MOW)),
+            entry(769, new DeviceStatus("Returning to dock", false, DeviceCommand.RETURN)),
+            entry(770, new DeviceStatus("Returning to dock", false, DeviceCommand.RETURN)),
+            entry(771, new DeviceStatus("Returning to dock - Battery low", false, DeviceCommand.RETURN)),
+            entry(772, new DeviceStatus("Returning to dock - Calendar timeslot ended", false, DeviceCommand.RETURN)),
+            entry(773, new DeviceStatus("Returning to dock - Battery temp range", false, DeviceCommand.RETURN)),
+            entry(774, new DeviceStatus("Returning to dock", false, DeviceCommand.RETURN)),
+            entry(775, new DeviceStatus("Returning to dock - Lawn complete", false, DeviceCommand.RETURN)),
+            entry(776, new DeviceStatus("Returning to dock - Relocalising", false, DeviceCommand.RETURN)),
+            entry(1025, new DeviceStatus("Diagnostic mode", false, null)),
+            entry(1026, new DeviceStatus("End of life", false, null)),
+            entry(1281, new DeviceStatus("Software update", false, null)),
+            entry(64513, new DeviceStatus("Docked", true, DeviceCommand.RETURN)));
+
+    private String message;
+
+    private boolean isReadyToMow;
+
+    private @Nullable DeviceCommand associatedCommand;
+
+    private DeviceStatus(String message, boolean isReadyToMow, @Nullable DeviceCommand associatedCommand) {
+        this.message = message;
+        this.isReadyToMow = isReadyToMow;
+        this.associatedCommand = associatedCommand;
+    }
+
+    /**
+     * Returns a {@link DeviceStatus} instance describing the status code.
+     * 
+     * @param code the status code
+     * @return the {@link DeviceStatus} providing additional context for the code
+     */
+    public static DeviceStatus fromCode(int code) {
+        DeviceStatus status = STATUS_MAP.get(code);
+        if (status != null) {
+            return status;
+        }
+
+        DeviceCommand command = null;
+        switch (code & 0xff00) {
+            case 0x100:
+                command = DeviceCommand.RETURN;
+                break;
+            case 0x200:
+                command = DeviceCommand.MOW;
+                break;
+            case 0x300:
+                command = DeviceCommand.RETURN;
+                break;
+        }
+
+        return new DeviceStatus(String.format("Unknown status code %d", code), false, command);
+    }
+
+    public String getMessage() {
+        return message;
+    }
+
+    public boolean isReadyToMow() {
+        return isReadyToMow;
+    }
+
+    public @Nullable DeviceCommand getAssociatedCommand() {
+        return associatedCommand;
+    }
+}
diff --git a/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/IndegoController.java b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/IndegoController.java
new file mode 100644 (file)
index 0000000..eb7d929
--- /dev/null
@@ -0,0 +1,514 @@
+/**
+ * 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.boschindego.internal;
+
+import java.net.URI;
+import java.time.Instant;
+import java.util.Base64;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeoutException;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.eclipse.jetty.client.HttpClient;
+import org.eclipse.jetty.client.HttpResponseException;
+import org.eclipse.jetty.client.api.ContentResponse;
+import org.eclipse.jetty.client.api.Request;
+import org.eclipse.jetty.client.api.Response;
+import org.eclipse.jetty.client.util.StringContentProvider;
+import org.eclipse.jetty.http.HttpHeader;
+import org.eclipse.jetty.http.HttpMethod;
+import org.eclipse.jetty.http.HttpStatus;
+import org.openhab.binding.boschindego.internal.dto.DeviceCommand;
+import org.openhab.binding.boschindego.internal.dto.PredictiveAdjustment;
+import org.openhab.binding.boschindego.internal.dto.PredictiveStatus;
+import org.openhab.binding.boschindego.internal.dto.request.AuthenticationRequest;
+import org.openhab.binding.boschindego.internal.dto.request.SetStateRequest;
+import org.openhab.binding.boschindego.internal.dto.response.AuthenticationResponse;
+import org.openhab.binding.boschindego.internal.dto.response.DeviceCalendarResponse;
+import org.openhab.binding.boschindego.internal.dto.response.DeviceStateResponse;
+import org.openhab.binding.boschindego.internal.dto.response.LocationWeatherResponse;
+import org.openhab.binding.boschindego.internal.dto.response.PredictiveCuttingTimeResponse;
+import org.openhab.binding.boschindego.internal.exceptions.IndegoAuthenticationException;
+import org.openhab.binding.boschindego.internal.exceptions.IndegoException;
+import org.openhab.binding.boschindego.internal.exceptions.IndegoInvalidCommandException;
+import org.openhab.binding.boschindego.internal.exceptions.IndegoInvalidResponseException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonParseException;
+
+/**
+ * Controller for communicating with a Bosch Indego device through Bosch services.
+ * This class provides methods for retrieving state information as well as controlling
+ * the device.
+ * 
+ * The implementation is based on zazaz-de/iot-device-bosch-indego-controller, but
+ * rewritten from scratch to use Jetty HTTP client for HTTP communication and GSON for
+ * JSON parsing. Thanks to Oliver Schünemann for providing the original implementation.
+ * 
+ * @author Jacob Laursen - Initial contribution
+ */
+@NonNullByDefault
+public class IndegoController {
+
+    private static final String BASE_URL = "https://api.indego.iot.bosch-si.com/api/v1/";
+    private static final URI BASE_URI = URI.create(BASE_URL);
+    private static final String SERIAL_NUMBER_SUBPATH = "alms/";
+    private static final String SSO_COOKIE_NAME = "BOSCH_INDEGO_SSO";
+    private static final String CONTEXT_HEADER_NAME = "x-im-context-id";
+    private static final String CONTENT_TYPE_HEADER = "application/json";
+
+    private final Logger logger = LoggerFactory.getLogger(IndegoController.class);
+    private final String basicAuthenticationHeader;
+    private final Gson gson = new Gson();
+    private final HttpClient httpClient;
+
+    private IndegoSession session = new IndegoSession();
+
+    /**
+     * Initialize the controller instance.
+     * 
+     * @param username the username for authenticating
+     * @param password the password
+     */
+    public IndegoController(HttpClient httpClient, String username, String password) {
+        this.httpClient = httpClient;
+        basicAuthenticationHeader = "Basic "
+                + Base64.getEncoder().encodeToString((username + ":" + password).getBytes());
+    }
+
+    /**
+     * Authenticate with server and store session context and serial number.
+     * 
+     * @throws IndegoAuthenticationException if request was rejected as unauthorized
+     * @throws IndegoException if any communication or parsing error occurred
+     */
+    private void authenticate() throws IndegoAuthenticationException, IndegoException {
+        try {
+            Request request = httpClient.newRequest(BASE_URL + "authenticate").method(HttpMethod.POST)
+                    .header(HttpHeader.AUTHORIZATION, basicAuthenticationHeader);
+
+            AuthenticationRequest authRequest = new AuthenticationRequest();
+            authRequest.device = "";
+            authRequest.osType = "Android";
+            authRequest.osVersion = "4.0";
+            authRequest.deviceManufacturer = "unknown";
+            authRequest.deviceType = "unknown";
+            String json = gson.toJson(authRequest);
+            request.content(new StringContentProvider(json));
+            request.header(HttpHeader.CONTENT_TYPE, CONTENT_TYPE_HEADER);
+
+            if (logger.isTraceEnabled()) {
+                logger.trace("POST request for {}", BASE_URL + "authenticate");
+            }
+
+            ContentResponse response = sendRequest(request);
+            int status = response.getStatus();
+            if (status == HttpStatus.UNAUTHORIZED_401) {
+                throw new IndegoAuthenticationException("Authentication was rejected");
+            }
+            if (!HttpStatus.isSuccess(status)) {
+                throw new IndegoAuthenticationException("The request failed with HTTP error: " + status);
+            }
+
+            String jsonResponse = response.getContentAsString();
+            if (jsonResponse.isEmpty()) {
+                throw new IndegoInvalidResponseException("No content returned");
+            }
+            logger.trace("JSON response: '{}'", jsonResponse);
+
+            AuthenticationResponse authenticationResponse = gson.fromJson(jsonResponse, AuthenticationResponse.class);
+            if (authenticationResponse == null) {
+                throw new IndegoInvalidResponseException("Response could not be parsed as AuthenticationResponse");
+            }
+            session = new IndegoSession(authenticationResponse.contextId, authenticationResponse.serialNumber,
+                    getContextExpirationTimeFromCookie());
+            logger.debug("Initialized session {}", session);
+        } catch (JsonParseException e) {
+            throw new IndegoInvalidResponseException("Error parsing AuthenticationResponse", e);
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw new IndegoException(e);
+        } catch (TimeoutException | ExecutionException e) {
+            throw new IndegoException(e);
+        }
+    }
+
+    /**
+     * Get context expiration time as a calculated {@link Instant} relative to now.
+     * The information is obtained from max age in the Bosch Indego SSO cookie.
+     * Please note that this cookie is only sent initially when authenticating, so
+     * the value will not be subject to any updates.
+     * 
+     * @return expiration time as {@link Instant} or {@link Instant#MIN} if not present
+     */
+    private Instant getContextExpirationTimeFromCookie() {
+        return httpClient.getCookieStore().get(BASE_URI).stream().filter(c -> SSO_COOKIE_NAME.equals(c.getName()))
+                .findFirst().map(c -> {
+                    return Instant.now().plusSeconds(c.getMaxAge());
+                }).orElseGet(() -> {
+                    return Instant.MIN;
+                });
+    }
+
+    /**
+     * Wraps {@link #getRequest(String, Class)} into an authenticated session.
+     *
+     * @param path the relative path to which the request should be sent
+     * @param dtoClass the DTO class to which the JSON result should be deserialized
+     * @return the deserialized DTO from the JSON response
+     * @throws IndegoAuthenticationException if request was rejected as unauthorized
+     * @throws IndegoException if any communication or parsing error occurred
+     */
+    private <T> T getRequestWithAuthentication(String path, Class<? extends T> dtoClass)
+            throws IndegoAuthenticationException, IndegoException {
+        if (!session.isValid()) {
+            authenticate();
+        }
+        try {
+            logger.debug("Session {} valid, skipping authentication", session);
+            return getRequest(path, dtoClass);
+        } catch (IndegoAuthenticationException e) {
+            if (logger.isTraceEnabled()) {
+                logger.trace("Context rejected", e);
+            } else {
+                logger.debug("Context rejected: {}", e.getMessage());
+            }
+            session.invalidate();
+            authenticate();
+            return getRequest(path, dtoClass);
+        }
+    }
+
+    /**
+     * Sends a GET request to the server and returns the deserialized JSON response.
+     * 
+     * @param path the relative path to which the request should be sent
+     * @param dtoClass the DTO class to which the JSON result should be deserialized
+     * @return the deserialized DTO from the JSON response
+     * @throws IndegoAuthenticationException if request was rejected as unauthorized
+     * @throws IndegoException if any communication or parsing error occurred
+     */
+    private <T> T getRequest(String path, Class<? extends T> dtoClass)
+            throws IndegoAuthenticationException, IndegoException {
+        try {
+            Request request = httpClient.newRequest(BASE_URL + path).method(HttpMethod.GET).header(CONTEXT_HEADER_NAME,
+                    session.getContextId());
+            if (logger.isTraceEnabled()) {
+                logger.trace("GET request for {}", BASE_URL + path);
+            }
+            ContentResponse response = sendRequest(request);
+            int status = response.getStatus();
+            if (status == HttpStatus.UNAUTHORIZED_401) {
+                // This will currently not happen because "WWW-Authenticate" header is missing; see below.
+                throw new IndegoAuthenticationException("Context rejected");
+            }
+            if (!HttpStatus.isSuccess(status)) {
+                throw new IndegoAuthenticationException("The request failed with HTTP error: " + status);
+            }
+            String jsonResponse = response.getContentAsString();
+            if (jsonResponse.isEmpty()) {
+                throw new IndegoInvalidResponseException("No content returned");
+            }
+            logger.trace("JSON response: '{}'", jsonResponse);
+
+            @Nullable
+            T result = gson.fromJson(jsonResponse, dtoClass);
+            if (result == null) {
+                throw new IndegoInvalidResponseException("Parsed response is null");
+            }
+            return result;
+        } catch (JsonParseException e) {
+            throw new IndegoInvalidResponseException("Error parsing response", e);
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw new IndegoException(e);
+        } catch (TimeoutException e) {
+            throw new IndegoException(e);
+        } catch (ExecutionException e) {
+            Throwable cause = e.getCause();
+            if (cause != null && cause instanceof HttpResponseException) {
+                Response response = ((HttpResponseException) cause).getResponse();
+                if (response.getStatus() == HttpStatus.UNAUTHORIZED_401) {
+                    /*
+                     * When contextId is not valid, the service will respond with HTTP code 401 without
+                     * any "WWW-Authenticate" header, violating RFC 7235. Jetty will then throw
+                     * HttpResponseException. We need to handle this in order to attempt
+                     * reauthentication.
+                     */
+                    throw new IndegoAuthenticationException("Context rejected", e);
+                }
+            }
+            throw new IndegoException(e);
+        }
+    }
+
+    /**
+     * Wraps {@link #putRequest(String, Object)} into an authenticated session.
+     * 
+     * @param path the relative path to which the request should be sent
+     * @param requestDto the DTO which should be sent to the server as JSON
+     * @throws IndegoAuthenticationException if request was rejected as unauthorized
+     * @throws IndegoException if any communication or parsing error occurred
+     */
+    private void putRequestWithAuthentication(String path, Object requestDto)
+            throws IndegoAuthenticationException, IndegoException {
+        if (!session.isValid()) {
+            authenticate();
+        }
+        try {
+            logger.debug("Session {} valid, skipping authentication", session);
+            putRequest(path, requestDto);
+        } catch (IndegoAuthenticationException e) {
+            if (logger.isTraceEnabled()) {
+                logger.trace("Context rejected", e);
+            } else {
+                logger.debug("Context rejected: {}", e.getMessage());
+            }
+            session.invalidate();
+            authenticate();
+            putRequest(path, requestDto);
+        }
+    }
+
+    /**
+     * Sends a PUT request to the server.
+     * 
+     * @param path the relative path to which the request should be sent
+     * @param requestDto the DTO which should be sent to the server as JSON
+     * @throws IndegoAuthenticationException if request was rejected as unauthorized
+     * @throws IndegoException if any communication or parsing error occurred
+     */
+    private void putRequest(String path, Object requestDto) throws IndegoAuthenticationException, IndegoException {
+        try {
+            Request request = httpClient.newRequest(BASE_URL + path).method(HttpMethod.PUT)
+                    .header(CONTEXT_HEADER_NAME, session.getContextId())
+                    .header(HttpHeader.CONTENT_TYPE, CONTENT_TYPE_HEADER);
+            String payload = gson.toJson(requestDto);
+            request.content(new StringContentProvider(payload));
+            if (logger.isTraceEnabled()) {
+                logger.trace("PUT request for {} with payload '{}'", BASE_URL + path, payload);
+            }
+            ContentResponse response = sendRequest(request);
+            int status = response.getStatus();
+            if (status == HttpStatus.UNAUTHORIZED_401) {
+                // This will currently not happen because "WWW-Authenticate" header is missing; see below.
+                throw new IndegoAuthenticationException("Context rejected");
+            }
+            if (status == HttpStatus.INTERNAL_SERVER_ERROR_500) {
+                throw new IndegoInvalidCommandException("The request failed with HTTP error: " + status);
+            }
+            if (!HttpStatus.isSuccess(status)) {
+                throw new IndegoException("The request failed with error: " + status);
+            }
+        } catch (JsonParseException e) {
+            throw new IndegoInvalidResponseException("Error serializing request", e);
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw new IndegoException(e);
+        } catch (TimeoutException e) {
+            throw new IndegoException(e);
+        } catch (ExecutionException e) {
+            Throwable cause = e.getCause();
+            if (cause != null && cause instanceof HttpResponseException) {
+                Response response = ((HttpResponseException) cause).getResponse();
+                if (response.getStatus() == HttpStatus.UNAUTHORIZED_401) {
+                    /*
+                     * When contextId is not valid, the service will respond with HTTP code 401 without
+                     * any "WWW-Authenticate" header, violating RFC 7235. Jetty will then throw
+                     * HttpResponseException. We need to handle this in order to attempt
+                     * reauthentication.
+                     */
+                    throw new IndegoAuthenticationException("Context rejected", e);
+                }
+            }
+            throw new IndegoException(e);
+        }
+    }
+
+    /**
+     * Send request. This method exists for the purpose of avoiding multiple calls to
+     * the server at the same time.
+     * 
+     * @param request the {@link Request} to send
+     * @return a {@link ContentResponse} for this request
+     * @throws InterruptedException if send thread is interrupted
+     * @throws TimeoutException if send times out
+     * @throws ExecutionException if execution fails
+     */
+    private synchronized ContentResponse sendRequest(Request request)
+            throws InterruptedException, TimeoutException, ExecutionException {
+        return request.send();
+    }
+
+    /**
+     * Gets serial number of the associated Indego device
+     *
+     * @return the serial number of the device
+     * @throws IndegoAuthenticationException if request was rejected as unauthorized
+     * @throws IndegoException if any communication or parsing error occurred
+     */
+    public String getSerialNumber() throws IndegoAuthenticationException, IndegoException {
+        if (!session.isInitialized()) {
+            logger.debug("Session not yet initialized when serial number was requested; authenticating...");
+            authenticate();
+        }
+        return session.getSerialNumber();
+    }
+
+    /**
+     * Queries the device state from the server.
+     * 
+     * @return the device state
+     * @throws IndegoAuthenticationException if request was rejected as unauthorized
+     * @throws IndegoException if any communication or parsing error occurred
+     */
+    public DeviceStateResponse getState() throws IndegoAuthenticationException, IndegoException {
+        return getRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/state",
+                DeviceStateResponse.class);
+    }
+
+    /**
+     * Queries the calendar.
+     * 
+     * @return the calendar
+     * @throws IndegoAuthenticationException if request was rejected as unauthorized
+     * @throws IndegoException if any communication or parsing error occurred
+     */
+    public DeviceCalendarResponse getCalendar() throws IndegoAuthenticationException, IndegoException {
+        DeviceCalendarResponse calendar = getRequestWithAuthentication(
+                SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/calendar", DeviceCalendarResponse.class);
+        return calendar;
+    }
+
+    /**
+     * Sends a command to the Indego device.
+     * 
+     * @param command the control command to send to the device
+     * @throws IndegoAuthenticationException if request was rejected as unauthorized
+     * @throws IndegoInvalidCommandException if the command was not processed correctly
+     * @throws IndegoException if any communication or parsing error occurred
+     */
+    public void sendCommand(DeviceCommand command)
+            throws IndegoAuthenticationException, IndegoInvalidCommandException, IndegoException {
+        SetStateRequest request = new SetStateRequest();
+        request.state = command.getActionCode();
+        putRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/state", request);
+    }
+
+    /**
+     * Queries the predictive weather forecast.
+     * 
+     * @return the weather forecast DTO
+     * @throws IndegoAuthenticationException if request was rejected as unauthorized
+     * @throws IndegoException if any communication or parsing error occurred
+     */
+    public LocationWeatherResponse getWeather() throws IndegoAuthenticationException, IndegoException {
+        return getRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/weather",
+                LocationWeatherResponse.class);
+    }
+
+    /**
+     * Queries the predictive adjustment.
+     * 
+     * @return the predictive adjustment
+     * @throws IndegoAuthenticationException if request was rejected as unauthorized
+     * @throws IndegoException if any communication or parsing error occurred
+     */
+    public int getPredictiveAdjustment() throws IndegoAuthenticationException, IndegoException {
+        return getRequestWithAuthentication(
+                SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/useradjustment",
+                PredictiveAdjustment.class).adjustment;
+    }
+
+    /**
+     * Sets the predictive adjustment.
+     * 
+     * @param adjust the predictive adjustment
+     * @throws IndegoAuthenticationException if request was rejected as unauthorized
+     * @throws IndegoException if any communication or parsing error occurred
+     */
+    public void setPredictiveAdjustment(final int adjust) throws IndegoAuthenticationException, IndegoException {
+        final PredictiveAdjustment adjustment = new PredictiveAdjustment();
+        adjustment.adjustment = adjust;
+        putRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/useradjustment",
+                adjustment);
+    }
+
+    /**
+     * Queries predictive moving.
+     * 
+     * @return predictive moving
+     * @throws IndegoAuthenticationException if request was rejected as unauthorized
+     * @throws IndegoException if any communication or parsing error occurred
+     */
+    public boolean getPredictiveMoving() throws IndegoAuthenticationException, IndegoException {
+        final PredictiveStatus status = getRequestWithAuthentication(
+                SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive", PredictiveStatus.class);
+        return status.enabled;
+    }
+
+    /**
+     * Sets predictive moving.
+     * 
+     * @param enable
+     * @throws IndegoAuthenticationException if request was rejected as unauthorized
+     * @throws IndegoException if any communication or parsing error occurred
+     */
+    public void setPredictiveMoving(final boolean enable) throws IndegoAuthenticationException, IndegoException {
+        final PredictiveStatus status = new PredictiveStatus();
+        status.enabled = enable;
+        putRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive", status);
+    }
+
+    /**
+     * Queries predictive next cutting as {@link Instant}.
+     * 
+     * @return predictive next cutting
+     * @throws IndegoAuthenticationException if request was rejected as unauthorized
+     * @throws IndegoException if any communication or parsing error occurred
+     */
+    public Instant getPredictiveNextCutting() throws IndegoAuthenticationException, IndegoException {
+        final PredictiveCuttingTimeResponse nextCutting = getRequestWithAuthentication(
+                SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/nextcutting",
+                PredictiveCuttingTimeResponse.class);
+        return nextCutting.getNextCutting();
+    }
+
+    /**
+     * Queries predictive exclusion time.
+     * 
+     * @return predictive exclusion time DTO
+     * @throws IndegoAuthenticationException if request was rejected as unauthorized
+     * @throws IndegoException if any communication or parsing error occurred
+     */
+    public DeviceCalendarResponse getPredictiveExclusionTime() throws IndegoAuthenticationException, IndegoException {
+        final DeviceCalendarResponse calendar = getRequestWithAuthentication(
+                SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/calendar", DeviceCalendarResponse.class);
+        return calendar;
+    }
+
+    /**
+     * Sets predictive exclusion time.
+     * 
+     * @param calendar calendar DTO
+     * @throws IndegoAuthenticationException if request was rejected as unauthorized
+     * @throws IndegoException if any communication or parsing error occurred
+     */
+    public void setPredictiveExclusionTime(final DeviceCalendarResponse calendar)
+            throws IndegoAuthenticationException, IndegoException {
+        putRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/calendar", calendar);
+    }
+}
diff --git a/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/IndegoSession.java b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/IndegoSession.java
new file mode 100644 (file)
index 0000000..cc75149
--- /dev/null
@@ -0,0 +1,104 @@
+/**
+ * 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.boschindego.internal;
+
+import java.time.Duration;
+import java.time.Instant;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Session for storing Bosch Indego context information.
+ * 
+ * @author Jacob Laursen - Initial contribution
+ */
+@NonNullByDefault
+public class IndegoSession {
+
+    private static final Duration DEFAULT_EXPIRATION_PERIOD = Duration.ofSeconds(10);
+
+    private String contextId;
+    private String serialNumber;
+    private Instant expirationTime;
+
+    public IndegoSession() {
+        this("", "", Instant.MIN);
+    }
+
+    public IndegoSession(String contextId, String serialNumber, Instant expirationTime) {
+        this.contextId = contextId;
+        this.serialNumber = serialNumber;
+        this.expirationTime = expirationTime.equals(Instant.MIN) ? Instant.now().plus(DEFAULT_EXPIRATION_PERIOD)
+                : expirationTime;
+    }
+
+    /**
+     * Get context id for HTTP requests (headers "x-im-context-id: <contextId>" and
+     * "Cookie: BOSCH_INDEGO_SSO=<contextId>").
+     * 
+     * @return current context id
+     */
+    public String getContextId() {
+        return contextId;
+    }
+
+    /**
+     * Get serial number of device.
+     * 
+     * @return serial number
+     */
+    public String getSerialNumber() {
+        return serialNumber;
+    }
+
+    /**
+     * Get expiration time of session as {@link Instant}.
+     * 
+     * @return expiration time
+     */
+    public Instant getExpirationTime() {
+        return expirationTime;
+    }
+
+    /**
+     * Check if session is initialized, i.e. has serial number.
+     * 
+     * @see #isValid()
+     * @return true if session is initialized
+     */
+    public boolean isInitialized() {
+        return !serialNumber.isEmpty();
+    }
+
+    /**
+     * Check if session is valid, i.e. has not yet expired.
+     *
+     * @return true if session is still valid
+     */
+    public boolean isValid() {
+        return !contextId.isEmpty() && expirationTime.isAfter(Instant.now());
+    }
+
+    /**
+     * Invalidate session.
+     */
+    public void invalidate() {
+        contextId = "";
+        expirationTime = Instant.MIN;
+    }
+
+    @Override
+    public String toString() {
+        return String.format("%s (serialNumber %s, expirationTime %s)", contextId, serialNumber, expirationTime);
+    }
+}
diff --git a/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/IndegoStateConstants.java b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/IndegoStateConstants.java
deleted file mode 100644 (file)
index 51fc145..0000000
+++ /dev/null
@@ -1,29 +0,0 @@
-/**
- * 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.boschindego.internal;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-
-/**
- *
- * @author Jonas Fleck - Initial contribution
- */
-@NonNullByDefault
-public class IndegoStateConstants {
-
-    public static final int STATE_DOCKED_1 = 258;
-    public static final int STATE_DOCKED_2 = 260;
-    public static final int STATE_DOCKED_3 = 261;
-    public static final int STATE_PAUSED = 517;
-    public static final int STATE_IDLE_IN_LAWN = 519;
-}
diff --git a/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/config/BoschIndegoConfiguration.java b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/config/BoschIndegoConfiguration.java
new file mode 100644 (file)
index 0000000..9512d5d
--- /dev/null
@@ -0,0 +1,28 @@
+/**
+ * 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.boschindego.internal.config;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * Configuration for the Bosch Indego thing.
+ *
+ * @author Jacob Laursen - Initial contribution
+ */
+@NonNullByDefault
+public class BoschIndegoConfiguration {
+    public @Nullable String username;
+    public @Nullable String password;
+    public long refresh = 180;
+}
diff --git a/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/DeviceCommand.java b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/DeviceCommand.java
new file mode 100644 (file)
index 0000000..6ed530a
--- /dev/null
@@ -0,0 +1,39 @@
+/**
+ * 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.boschindego.internal.dto;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.boschindego.internal.dto.request.SetStateRequest;
+
+/**
+ * Commands supported by the device.
+ * 
+ * @author Jacob Laursen - Initial contribution
+ */
+@NonNullByDefault
+public enum DeviceCommand {
+
+    MOW(SetStateRequest.STATE_MOW),
+    PAUSE(SetStateRequest.STATE_PAUSE),
+    RETURN(SetStateRequest.STATE_RETURN);
+
+    private String actionCode;
+
+    DeviceCommand(String actionCode) {
+        this.actionCode = actionCode;
+    }
+
+    public String getActionCode() {
+        return actionCode;
+    }
+}
diff --git a/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/PredictiveAdjustment.java b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/PredictiveAdjustment.java
new file mode 100644 (file)
index 0000000..753b21c
--- /dev/null
@@ -0,0 +1,25 @@
+/**
+ * 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.boschindego.internal.dto;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * Request/response for user adjustment.
+ * 
+ * @author Jacob Laursen - Initial contribution
+ */
+public class PredictiveAdjustment {
+    @SerializedName("user_adjustment")
+    public int adjustment;
+}
diff --git a/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/PredictiveStatus.java b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/PredictiveStatus.java
new file mode 100644 (file)
index 0000000..839e61a
--- /dev/null
@@ -0,0 +1,22 @@
+/**
+ * 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.boschindego.internal.dto;
+
+/**
+ * Request/response for predictive status.
+ * 
+ * @author Jacob Laursen - Initial contribution
+ */
+public class PredictiveStatus {
+    public boolean enabled;
+}
diff --git a/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/request/AuthenticationRequest.java b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/request/AuthenticationRequest.java
new file mode 100644 (file)
index 0000000..26da929
--- /dev/null
@@ -0,0 +1,44 @@
+/**
+ * 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.boschindego.internal.dto.request;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * Request for authenticating with server
+ * 
+ * @author Jacob Laursen - Initial contribution
+ */
+public class AuthenticationRequest {
+
+    @SerializedName("accept_tc_id")
+    public String acceptTcId;
+
+    public String device;
+
+    @SerializedName("os_type")
+    public String osType;
+
+    @SerializedName("os_version")
+    public String osVersion;
+
+    @SerializedName("dvc_manuf")
+    public String deviceManufacturer;
+
+    @SerializedName("dvc_type")
+    public String deviceType;
+
+    public AuthenticationRequest() {
+        acceptTcId = "202012";
+    }
+}
diff --git a/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/request/SetStateRequest.java b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/request/SetStateRequest.java
new file mode 100644 (file)
index 0000000..5980157
--- /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.boschindego.internal.dto.request;
+
+/**
+ * Request for setting a new device state
+ * 
+ * @author Jacob Laursen - Initial contribution
+ */
+public class SetStateRequest {
+
+    public static final String STATE_MOW = "mow";
+
+    public static final String STATE_PAUSE = "pause";
+
+    public static final String STATE_RETURN = "returnToDock";
+
+    public String state;
+}
diff --git a/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/response/AuthenticationResponse.java b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/response/AuthenticationResponse.java
new file mode 100644 (file)
index 0000000..09a8292
--- /dev/null
@@ -0,0 +1,30 @@
+/**
+ * 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.boschindego.internal.dto.response;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * Response from authenticating with server.
+ * 
+ * @author Jacob Laursen - Initial contribution
+ */
+public class AuthenticationResponse {
+
+    public String contextId;
+
+    public String userId;
+
+    @SerializedName("alm_sn")
+    public String serialNumber;
+}
diff --git a/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/response/DeviceCalendarResponse.java b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/response/DeviceCalendarResponse.java
new file mode 100644 (file)
index 0000000..7d5b026
--- /dev/null
@@ -0,0 +1,31 @@
+/**
+ * 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.boschindego.internal.dto.response;
+
+import org.openhab.binding.boschindego.internal.dto.response.calendar.DeviceCalendarEntry;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * Response for device calendar.
+ * 
+ * @author Jacob Laursen - Initial contribution
+ */
+public class DeviceCalendarResponse {
+
+    @SerializedName("sel_cal")
+    public int selectedEntryNumber;
+
+    @SerializedName("cals")
+    public DeviceCalendarEntry[] entries;
+}
diff --git a/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/response/DeviceStateResponse.java b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/response/DeviceStateResponse.java
new file mode 100644 (file)
index 0000000..b8e4a9f
--- /dev/null
@@ -0,0 +1,63 @@
+/**
+ * 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.boschindego.internal.dto.response;
+
+import org.openhab.binding.boschindego.internal.dto.response.runtime.DeviceStateRuntimes;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * Response after querying the device status.
+ * 
+ * @author Jacob Laursen - Initial contribution
+ */
+public class DeviceStateResponse {
+
+    public int state;
+
+    public int error;
+
+    public boolean enabled;
+
+    @SerializedName("map_update_available")
+    public boolean mapUpdateAvailable;
+
+    public int mowed;
+
+    @SerializedName("mowmode")
+    public long mowMode;
+
+    public int xPos;
+
+    public int yPos;
+
+    public DeviceStateRuntimes runtime;
+
+    @SerializedName("mowed_ts")
+    public long mowedTimestamp;
+
+    @SerializedName("mapsvgcache_ts")
+    public long mapSvgCacheTimestamp;
+
+    @SerializedName("svg_xPos")
+    public int svgXPos;
+
+    @SerializedName("svg_yPos")
+    public int svgYPos;
+
+    @SerializedName("config_change")
+    public boolean configChange;
+
+    @SerializedName("mow_trig")
+    public boolean mowTrigger;
+}
diff --git a/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/response/LocationWeatherResponse.java b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/response/LocationWeatherResponse.java
new file mode 100644 (file)
index 0000000..5ec094d
--- /dev/null
@@ -0,0 +1,28 @@
+/**
+ * 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.boschindego.internal.dto.response;
+
+import org.openhab.binding.boschindego.internal.dto.response.weather.Weather;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * Response for weather forecast.
+ * 
+ * @author Jacob Laursen - Initial contribution
+ */
+public class LocationWeatherResponse {
+
+    @SerializedName("LocationWeather")
+    public Weather weather;
+}
diff --git a/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/response/PredictiveCuttingTimeResponse.java b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/response/PredictiveCuttingTimeResponse.java
new file mode 100644 (file)
index 0000000..27d78e4
--- /dev/null
@@ -0,0 +1,38 @@
+/**
+ * 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.boschindego.internal.dto.response;
+
+import java.time.Instant;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeParseException;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * Response for next cutting time.
+ * 
+ * @author Jacob Laursen - Initial contribution
+ */
+public class PredictiveCuttingTimeResponse {
+    @SerializedName("mow_next")
+    public String nextCutting;
+
+    public Instant getNextCutting() {
+        try {
+            return ZonedDateTime.parse(nextCutting).toInstant();
+        } catch (final DateTimeParseException e) {
+            // Ignored
+        }
+        return null;
+    }
+}
diff --git a/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/response/calendar/DeviceCalendarDayEntry.java b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/response/calendar/DeviceCalendarDayEntry.java
new file mode 100644 (file)
index 0000000..bfdf1da
--- /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.boschindego.internal.dto.response.calendar;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * Device calendar day entry.
+ * 
+ * @author Jacob Laursen - Initial contribution
+ */
+public class DeviceCalendarDayEntry {
+
+    @SerializedName("day")
+    public int number;
+
+    @SerializedName("slots")
+    public DeviceCalendarDaySlot[] slots;
+}
diff --git a/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/response/calendar/DeviceCalendarDaySlot.java b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/response/calendar/DeviceCalendarDaySlot.java
new file mode 100644 (file)
index 0000000..9c5eb2f
--- /dev/null
@@ -0,0 +1,38 @@
+/**
+ * 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.boschindego.internal.dto.response.calendar;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * Device calendar day slot.
+ * 
+ * @author Jacob Laursen - Initial contribution
+ */
+public class DeviceCalendarDaySlot {
+
+    @SerializedName("En")
+    public boolean enabled;
+
+    @SerializedName("StHr")
+    public int startHour;
+
+    @SerializedName("StMin")
+    public int startMinute;
+
+    @SerializedName("EnHr")
+    public int endHour;
+
+    @SerializedName("EnMin")
+    public int endMinute;
+}
diff --git a/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/response/calendar/DeviceCalendarEntry.java b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/response/calendar/DeviceCalendarEntry.java
new file mode 100644 (file)
index 0000000..b4c6509
--- /dev/null
@@ -0,0 +1,28 @@
+/**
+ * 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.boschindego.internal.dto.response.calendar;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * Device calendar entry.
+ * 
+ * @author Jacob Laursen - Initial contribution
+ */
+public class DeviceCalendarEntry {
+
+    @SerializedName("cal")
+    public int number;
+
+    public DeviceCalendarDayEntry[] days;
+}
diff --git a/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/response/runtime/DeviceStateRuntime.java b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/response/runtime/DeviceStateRuntime.java
new file mode 100644 (file)
index 0000000..0061ffb
--- /dev/null
@@ -0,0 +1,25 @@
+/**
+ * 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.boschindego.internal.dto.response.runtime;
+
+/**
+ * Detailed runtime information for {@link DeviceStateRuntimes}
+ * 
+ * @author Jacob Laursen - Initial contribution
+ */
+public class DeviceStateRuntime {
+    public long operate;
+
+    public long charge;
+}
diff --git a/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/response/runtime/DeviceStateRuntimes.java b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/response/runtime/DeviceStateRuntimes.java
new file mode 100644 (file)
index 0000000..dca9cb6
--- /dev/null
@@ -0,0 +1,25 @@
+/**
+ * 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.boschindego.internal.dto.response.runtime;
+
+/**
+ * Total/session runtime information for {@link DeviceStateResponse}
+ * 
+ * @author Jacob Laursen - Initial contribution
+ */
+public class DeviceStateRuntimes {
+    public DeviceStateRuntime total;
+
+    public DeviceStateRuntime session;
+}
diff --git a/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/response/weather/Forecast.java b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/response/weather/Forecast.java
new file mode 100644 (file)
index 0000000..4232f2f
--- /dev/null
@@ -0,0 +1,23 @@
+/**
+ * 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.boschindego.internal.dto.response.weather;
+
+/**
+ * Forecast.
+ * 
+ * @author Jacob Laursen - Initial contribution
+ */
+public class Forecast {
+
+    public Interval[] intervals;
+}
diff --git a/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/response/weather/Interval.java b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/response/weather/Interval.java
new file mode 100644 (file)
index 0000000..0cd3e38
--- /dev/null
@@ -0,0 +1,51 @@
+/**
+ * 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.boschindego.internal.dto.response.weather;
+
+import java.time.Instant;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeParseException;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * Interval.
+ * 
+ * @author Jacob Laursen - Initial contribution
+ */
+public class Interval {
+
+    @SerializedName("dateTime")
+    public String date;
+
+    public int intervalLength;
+
+    @SerializedName("prrr")
+    public int rain;
+
+    @SerializedName("tt")
+    public float temperature;
+
+    public void setDate(final Instant date) {
+        this.date = date.toString();
+    }
+
+    public Instant getDate() {
+        try {
+            return ZonedDateTime.parse(date).toInstant();
+        } catch (final DateTimeParseException e) {
+            // Ignored
+        }
+        return null;
+    }
+}
diff --git a/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/response/weather/Location.java b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/response/weather/Location.java
new file mode 100644 (file)
index 0000000..d7fa1db
--- /dev/null
@@ -0,0 +1,31 @@
+/**
+ * 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.boschindego.internal.dto.response.weather;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * Location.
+ * 
+ * @author Jacob Laursen - Initial contribution
+ */
+public class Location {
+
+    @SerializedName("name")
+    public String town;
+
+    public String country;
+
+    @SerializedName("tzn")
+    public String timeZone;
+}
diff --git a/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/response/weather/Weather.java b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/response/weather/Weather.java
new file mode 100644 (file)
index 0000000..8b73226
--- /dev/null
@@ -0,0 +1,24 @@
+/**
+ * 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.boschindego.internal.dto.response.weather;
+
+/**
+ * Weather.
+ * 
+ * @author Jacob Laursen - Initial contribution
+ */
+public class Weather {
+
+    public Location location;
+    public Forecast forecast;
+}
diff --git a/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/exceptions/IndegoAuthenticationException.java b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/exceptions/IndegoAuthenticationException.java
new file mode 100644 (file)
index 0000000..258e765
--- /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.boschindego.internal.exceptions;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * {@link IndegoAuthenticationException} is thrown on authentication failure, for example
+ * when username or password is wrong.
+ * 
+ * @author Jacob Laursen - Initial contribution
+ */
+@NonNullByDefault
+public class IndegoAuthenticationException extends IndegoException {
+
+    private static final long serialVersionUID = -9047922366108411751L;
+
+    public IndegoAuthenticationException(String message) {
+        super(message);
+    }
+
+    public IndegoAuthenticationException(String message, Throwable cause) {
+        super(message, cause);
+    }
+}
diff --git a/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/exceptions/IndegoException.java b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/exceptions/IndegoException.java
new file mode 100644 (file)
index 0000000..20473b1
--- /dev/null
@@ -0,0 +1,40 @@
+/**
+ * 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.boschindego.internal.exceptions;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * {@link IndegoException} is a generic Indego exception thrown in case
+ * of communication failure or unexpected response. It is intended to
+ * be derived by specialized exceptions.
+ * 
+ * @author Jacob Laursen - Initial contribution
+ */
+@NonNullByDefault
+public class IndegoException extends Exception {
+
+    private static final long serialVersionUID = 6673869982385647268L;
+
+    public IndegoException(String message) {
+        super(message);
+    }
+
+    public IndegoException(Throwable cause) {
+        super(cause);
+    }
+
+    public IndegoException(String message, Throwable cause) {
+        super(message, cause);
+    }
+}
diff --git a/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/exceptions/IndegoInvalidCommandException.java b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/exceptions/IndegoInvalidCommandException.java
new file mode 100644 (file)
index 0000000..024a9ef
--- /dev/null
@@ -0,0 +1,30 @@
+/**
+ * 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.boschindego.internal.exceptions;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * {@link IndegoInvalidCommandException} is thrown when a command is rejected by the device.
+ * 
+ * @author Jacob Laursen - Initial contribution
+ */
+@NonNullByDefault
+public class IndegoInvalidCommandException extends IndegoException {
+
+    private static final long serialVersionUID = -2946398731437793113L;
+
+    public IndegoInvalidCommandException(String message) {
+        super(message);
+    }
+}
diff --git a/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/exceptions/IndegoInvalidResponseException.java b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/exceptions/IndegoInvalidResponseException.java
new file mode 100644 (file)
index 0000000..d9b4495
--- /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.boschindego.internal.exceptions;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * {@link IndegoInvalidResponseException} is thrown in case of invalid response from the
+ * Bosch Indego service.
+ * 
+ * @author Jacob Laursen - Initial contribution
+ */
+@NonNullByDefault
+public class IndegoInvalidResponseException extends IndegoException {
+
+    private static final long serialVersionUID = -4236849226899489934L;
+
+    public IndegoInvalidResponseException(String message) {
+        super(message);
+    }
+
+    public IndegoInvalidResponseException(String message, Throwable cause) {
+        super(message, cause);
+    }
+}
index fcd77177c7444d143c6ce59fbb4713011b8457b1..868e1be56f03a38312aa9767a2d1d63dfe98fea3 100644 (file)
 package org.openhab.binding.boschindego.internal.handler;
 
 import static org.openhab.binding.boschindego.internal.BoschIndegoBindingConstants.*;
-import static org.openhab.binding.boschindego.internal.IndegoStateConstants.*;
 
-import java.math.BigDecimal;
-import java.util.LinkedList;
-import java.util.Map;
-import java.util.Queue;
 import java.util.concurrent.ScheduledFuture;
 import java.util.concurrent.TimeUnit;
 
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.eclipse.jetty.client.HttpClient;
+import org.openhab.binding.boschindego.internal.DeviceStatus;
+import org.openhab.binding.boschindego.internal.IndegoController;
+import org.openhab.binding.boschindego.internal.config.BoschIndegoConfiguration;
+import org.openhab.binding.boschindego.internal.dto.DeviceCommand;
+import org.openhab.binding.boschindego.internal.dto.response.DeviceStateResponse;
+import org.openhab.binding.boschindego.internal.exceptions.IndegoAuthenticationException;
+import org.openhab.binding.boschindego.internal.exceptions.IndegoException;
 import org.openhab.core.library.types.DecimalType;
 import org.openhab.core.library.types.PercentType;
 import org.openhab.core.library.types.StringType;
@@ -36,46 +41,83 @@ import org.openhab.core.types.UnDefType;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import de.zazaz.iot.bosch.indego.DeviceCommand;
-import de.zazaz.iot.bosch.indego.DeviceStateInformation;
-import de.zazaz.iot.bosch.indego.DeviceStatus;
-import de.zazaz.iot.bosch.indego.IndegoAuthenticationException;
-import de.zazaz.iot.bosch.indego.IndegoController;
-import de.zazaz.iot.bosch.indego.IndegoException;
-
 /**
  * The {@link BoschIndegoHandler} is responsible for handling commands, which are
  * sent to one of the channels.
  *
  * @author Jonas Fleck - Initial contribution
+ * @author Jacob Laursen - Refactoring, bugfixing and removal of dependency towards abandoned library
  */
+@NonNullByDefault
 public class BoschIndegoHandler extends BaseThingHandler {
 
     private final Logger logger = LoggerFactory.getLogger(BoschIndegoHandler.class);
-    private final Queue<DeviceCommand> commandQueue = new LinkedList<>();
-
-    private ScheduledFuture<?> pollFuture;
+    private final HttpClient httpClient;
 
-    // If false the request is already scheduled.
-    private boolean shouldReschedule;
+    private @NonNullByDefault({}) IndegoController controller;
+    private @Nullable ScheduledFuture<?> pollFuture;
+    private long refreshRate;
+    private boolean propertiesInitialized;
 
-    public BoschIndegoHandler(Thing thing) {
+    public BoschIndegoHandler(Thing thing, HttpClient httpClient) {
         super(thing);
+        this.httpClient = httpClient;
+    }
+
+    @Override
+    public void initialize() {
+        logger.debug("Initializing Indego handler");
+        BoschIndegoConfiguration config = getConfigAs(BoschIndegoConfiguration.class);
+        String username = config.username;
+        String password = config.password;
+
+        if (username == null || username.isBlank()) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
+                    "@text/offline.conf-error.missing-username");
+            return;
+        }
+        if (password == null || password.isBlank()) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
+                    "@text/offline.conf-error.missing-password");
+            return;
+        }
+
+        controller = new IndegoController(httpClient, username, password);
+        refreshRate = config.refresh;
+
+        updateStatus(ThingStatus.UNKNOWN);
+        this.pollFuture = scheduler.scheduleWithFixedDelay(this::refreshState, 0, refreshRate, TimeUnit.SECONDS);
+    }
+
+    @Override
+    public void dispose() {
+        logger.debug("Disposing Indego handler");
+        ScheduledFuture<?> pollFuture = this.pollFuture;
+        if (pollFuture != null) {
+            pollFuture.cancel(true);
+        }
+        this.pollFuture = null;
     }
 
     @Override
     public void handleCommand(ChannelUID channelUID, Command command) {
-        if (command instanceof RefreshType) {
-            // Currently manual refreshing is not possible in the moment
+        if (command == RefreshType.REFRESH) {
+            scheduler.submit(() -> this.refreshState());
             return;
-        } else if (channelUID.getId().equals(STATE) && command instanceof DecimalType) {
-            if (command instanceof DecimalType) {
+        }
+        try {
+            if (command instanceof DecimalType && channelUID.getId().equals(STATE)) {
                 sendCommand(((DecimalType) command).intValue());
             }
+        } catch (IndegoAuthenticationException e) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
+                    "@text/offline.comm-error.authentication-failure");
+        } catch (IndegoException e) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
         }
     }
 
-    private void sendCommand(int commandInt) {
+    private void sendCommand(int commandInt) throws IndegoException {
         DeviceCommand command;
         switch (commandInt) {
             case 1:
@@ -88,94 +130,62 @@ public class BoschIndegoHandler extends BaseThingHandler {
                 command = DeviceCommand.PAUSE;
                 break;
             default:
-                logger.error("Invalid command");
+                logger.warn("Invalid command {}", commandInt);
                 return;
         }
-        synchronized (commandQueue) {
-            // Add command to queue to avoid blocking
-            commandQueue.offer(command);
-            if (shouldReschedule) {
-                shouldReschedule = false;
-                reschedule();
-            }
+
+        DeviceStateResponse state = controller.getState();
+        DeviceStatus deviceStatus = DeviceStatus.fromCode(state.state);
+        if (!verifyCommand(command, deviceStatus, state.error)) {
+            return;
         }
+        logger.debug("Sending command {}", command);
+        updateState(TEXTUAL_STATE, UnDefType.UNDEF);
+        controller.sendCommand(command);
+        state = controller.getState();
+        updateStatus(ThingStatus.ONLINE);
+        updateState(state);
     }
 
-    private synchronized void poll() {
-        // Create controller instance
+    private void refreshState() {
         try {
-            IndegoController controller = new IndegoController(getConfig().get("username").toString(),
-                    getConfig().get("password").toString());
-            // Connect to server
-            controller.connect();
-            // Query the device state
-            DeviceStateInformation state = controller.getState();
-            DeviceStatus statusWithMessage = DeviceStatus.decodeStatusCode(state.getState());
-            int status = getStatusFromCommand(statusWithMessage.getAssociatedCommand());
-            int mowed = state.getMowed();
-            int error = state.getError();
-            int statecode = state.getState();
-            boolean ready = isReadyToMow(state.getState(), state.getError());
-            DeviceCommand commandToSend = null;
-            synchronized (commandQueue) {
-                // Discard older commands
-                while (!commandQueue.isEmpty()) {
-                    commandToSend = commandQueue.poll();
-                }
-                // For newer commands a new request is needed
-                shouldReschedule = true;
+            if (!propertiesInitialized) {
+                getThing().setProperty(Thing.PROPERTY_SERIAL_NUMBER, controller.getSerialNumber());
+                propertiesInitialized = true;
             }
-            if (commandToSend != null && verifyCommand(commandToSend, statusWithMessage.getAssociatedCommand(),
-                    state.getState(), error)) {
-                logger.debug("Sending command...");
-                updateState(TEXTUAL_STATE, UnDefType.UNDEF);
-                controller.sendCommand(commandToSend);
-                try {
-                    for (int i = 0; i < 30 && !Thread.interrupted(); i++) {
-                        DeviceStateInformation stateTmp = controller.getState();
-                        if (state.getState() != stateTmp.getState()) {
-                            state = stateTmp;
-                            statusWithMessage = DeviceStatus.decodeStatusCode(state.getState());
-                            status = getStatusFromCommand(statusWithMessage.getAssociatedCommand());
-                            mowed = state.getMowed();
-                            error = state.getError();
-                            statecode = state.getState();
-                            ready = isReadyToMow(state.getState(), state.getError());
-                            break;
-                        }
-                        Thread.sleep(1000);
-                    }
-
-                } catch (InterruptedException e) {
-                    // Nothing to do here
-                }
-            }
-            controller.disconnect();
-            updateStatus(ThingStatus.ONLINE);
-            updateState(STATECODE, new DecimalType(statecode));
-            updateState(READY, new DecimalType(ready ? 1 : 0));
-            updateState(ERRORCODE, new DecimalType(error));
-            updateState(MOWED, new PercentType(mowed));
-            updateState(STATE, new DecimalType(status));
-            updateState(TEXTUAL_STATE, new StringType(statusWithMessage.getMessage()));
 
+            DeviceStateResponse state = controller.getState();
+            updateStatus(ThingStatus.ONLINE);
+            updateState(state);
         } catch (IndegoAuthenticationException e) {
-            String message = "The login credentials are wrong or another client connected to your Indego account";
-            logger.warn(message, e);
-            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, message);
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
+                    "@text/offline.comm-error.authentication-failure");
         } catch (IndegoException e) {
-            logger.warn("An error occurred", e);
-            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
         }
     }
 
-    private boolean isReadyToMow(int statusCode, int error) {
-        // I don´t know why bosch uses different state codes for the same state.
-        return (statusCode == STATE_DOCKED_1 || statusCode == STATE_DOCKED_2 || statusCode == STATE_DOCKED_3
-                || statusCode == STATE_PAUSED || statusCode == STATE_IDLE_IN_LAWN) && error == 0;
+    private void updateState(DeviceStateResponse state) {
+        DeviceStatus deviceStatus = DeviceStatus.fromCode(state.state);
+        int status = getStatusFromCommand(deviceStatus.getAssociatedCommand());
+        int mowed = state.mowed;
+        int error = state.error;
+        int statecode = state.state;
+        boolean ready = isReadyToMow(deviceStatus, state.error);
+
+        updateState(STATECODE, new DecimalType(statecode));
+        updateState(READY, new DecimalType(ready ? 1 : 0));
+        updateState(ERRORCODE, new DecimalType(error));
+        updateState(MOWED, new PercentType(mowed));
+        updateState(STATE, new DecimalType(status));
+        updateState(TEXTUAL_STATE, new StringType(deviceStatus.getMessage()));
     }
 
-    private boolean verifyCommand(DeviceCommand command, DeviceCommand state, int statusCode, int errorCode) {
+    private boolean isReadyToMow(DeviceStatus deviceStatus, int error) {
+        return deviceStatus.isReadyToMow() && error == 0;
+    }
+
+    private boolean verifyCommand(DeviceCommand command, DeviceStatus deviceStatus, int errorCode) {
         // Mower reported an error
         if (errorCode != 0) {
             logger.error("The mower reported an error.");
@@ -183,24 +193,27 @@ public class BoschIndegoHandler extends BaseThingHandler {
         }
 
         // Command is equal to current state
-        if (command == state) {
+        if (command == deviceStatus.getAssociatedCommand()) {
             logger.debug("Command is equal to state");
             return false;
         }
         // Cant pause while the mower is docked
-        if (command == DeviceCommand.PAUSE && state == DeviceCommand.RETURN) {
-            logger.debug("Can´t pause the mower while it´s docked or docking");
+        if (command == DeviceCommand.PAUSE && deviceStatus.getAssociatedCommand() == DeviceCommand.RETURN) {
+            logger.debug("Can't pause the mower while it's docked or docking");
             return false;
         }
         // Command means "MOW" but mower is not ready
-        if (command == DeviceCommand.MOW && !isReadyToMow(statusCode, errorCode)) {
-            logger.debug("The mower is not ready to mow in the moment");
+        if (command == DeviceCommand.MOW && !isReadyToMow(deviceStatus, errorCode)) {
+            logger.debug("The mower is not ready to mow at the moment");
             return false;
         }
         return true;
     }
 
-    private int getStatusFromCommand(DeviceCommand command) {
+    private int getStatusFromCommand(@Nullable DeviceCommand command) {
+        if (command == null) {
+            return 0;
+        }
         int status;
         switch (command) {
             case MOW:
@@ -217,36 +230,4 @@ public class BoschIndegoHandler extends BaseThingHandler {
         }
         return status;
     }
-
-    @Override
-    public void dispose() {
-        super.dispose();
-        logger.debug("removing thing..");
-        if (pollFuture != null) {
-            pollFuture.cancel(true);
-        }
-    }
-
-    private void reschedule() {
-        logger.debug("rescheduling");
-
-        if (pollFuture != null) {
-            pollFuture.cancel(false);
-        }
-
-        int refreshRate = ((BigDecimal) getConfig().get("refresh")).intValue();
-        pollFuture = scheduler.scheduleWithFixedDelay(this::poll, 0, refreshRate, TimeUnit.SECONDS);
-    }
-
-    @Override
-    public void handleConfigurationUpdate(Map<String, Object> configurationParameters) {
-        super.handleConfigurationUpdate(configurationParameters);
-        reschedule();
-    }
-
-    @Override
-    public void initialize() {
-        updateStatus(ThingStatus.OFFLINE);
-        reschedule();
-    }
 }
index 129903570a82febbe2d9010aad324bd281155960..d665f90338b8292c0492e0afba6d86ead6fddcee 100644 (file)
@@ -54,5 +54,15 @@ channel-type.boschindego.statecode.state.option.772 = Returning to Dock - Calend
 channel-type.boschindego.statecode.state.option.773 = Returning to Dock - Battery temp range
 channel-type.boschindego.statecode.state.option.774 = Returning to Dock
 channel-type.boschindego.statecode.state.option.775 = Returning to Dock - Lawn complete
-channel-type.boschindego.statecode.state.option.775 = Returning to Dock - Relocalising
+channel-type.boschindego.statecode.state.option.776 = Returning to Dock - Relocalising
+channel-type.boschindego.statecode.state.option.1025 = Diagnostic mode
+channel-type.boschindego.statecode.state.option.1026 = End of life
+channel-type.boschindego.statecode.state.option.1281 = Software update
+channel-type.boschindego.statecode.state.option.64513 = Docked
 channel-type.boschindego.textualstate.label = Textual State
+
+# thing status descriptions
+
+offline.comm-error.authentication-failure = The login credentials are wrong or another client is connected to your Indego account
+offline.conf-error.missing-password = Password missing
+offline.conf-error.missing-username = Username missing
index 6521a1ddbf6a4eb9ee26ea3e9ed9d34b91b35607..7faeacbda86850e9a195b0094232940249e595d9 100644 (file)
@@ -48,7 +48,7 @@
                <item-type>Number</item-type>
                <label>Error Code</label>
                <description>0 = no error</description>
-               <state readOnly="false"></state>
+               <state readOnly="true"></state>
        </channel-type>
        <channel-type id="statecode" advanced="true">
                <item-type>Number</item-type>
                                <option value="773">Returning to Dock - Battery temp range</option>
                                <option value="774">Returning to Dock</option>
                                <option value="775">Returning to Dock - Lawn complete</option>
-                               <option value="775">Returning to Dock - Relocalising</option>
+                               <option value="776">Returning to Dock - Relocalising</option>
+                               <option value="1025">Diagnostic mode</option>
+                               <option value="1026">End of life</option>
+                               <option value="1281">Software update</option>
+                               <option value="64513">Docked</option>
                        </options>
                </state>
        </channel-type>