]> git.basschouten.com Git - openhab-addons.git/blob
79761b0c56d0759ddcc596fd89bb4abb90a16f39
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
7  * This program and the accompanying materials are made available under the
8  * terms of the Eclipse Public License 2.0 which is available at
9  * http://www.eclipse.org/legal/epl-2.0
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.icloud.internal.handler;
14
15 import static java.util.concurrent.TimeUnit.*;
16
17 import java.io.IOException;
18 import java.util.Collections;
19 import java.util.HashSet;
20 import java.util.List;
21 import java.util.Set;
22 import java.util.concurrent.Callable;
23 import java.util.concurrent.ScheduledFuture;
24 import java.util.concurrent.TimeUnit;
25
26 import org.eclipse.jdt.annotation.NonNullByDefault;
27 import org.eclipse.jdt.annotation.Nullable;
28 import org.openhab.binding.icloud.internal.ICloudApiResponseException;
29 import org.openhab.binding.icloud.internal.ICloudDeviceInformationListener;
30 import org.openhab.binding.icloud.internal.ICloudService;
31 import org.openhab.binding.icloud.internal.RetryException;
32 import org.openhab.binding.icloud.internal.configuration.ICloudAccountThingConfiguration;
33 import org.openhab.binding.icloud.internal.handler.dto.json.response.ICloudAccountDataResponse;
34 import org.openhab.binding.icloud.internal.handler.dto.json.response.ICloudDeviceInformation;
35 import org.openhab.binding.icloud.internal.utilities.JsonUtils;
36 import org.openhab.core.cache.ExpiringCache;
37 import org.openhab.core.storage.Storage;
38 import org.openhab.core.thing.Bridge;
39 import org.openhab.core.thing.ChannelUID;
40 import org.openhab.core.thing.ThingStatus;
41 import org.openhab.core.thing.ThingStatusDetail;
42 import org.openhab.core.thing.binding.BaseBridgeHandler;
43 import org.openhab.core.types.Command;
44 import org.openhab.core.types.RefreshType;
45 import org.slf4j.Logger;
46 import org.slf4j.LoggerFactory;
47
48 import com.google.gson.JsonSyntaxException;
49
50 /**
51  * Retrieves the data for a given account from iCloud and passes the information to
52  * {@link org.openhab.binding.icloud.internal.discovery.ICloudDeviceDiscovery} and to the {@link ICloudDeviceHandler}s.
53  *
54  * @author Patrik Gfeller - Initial contribution
55  * @author Hans-Jörg Merk - Extended support with initial Contribution
56  * @author Simon Spielmann - Rework for new iCloud API
57  */
58 @NonNullByDefault
59 public class ICloudAccountBridgeHandler extends BaseBridgeHandler {
60
61     private final Logger logger = LoggerFactory.getLogger(ICloudAccountBridgeHandler.class);
62
63     private static final int CACHE_EXPIRY = (int) SECONDS.toMillis(10);
64
65     private @Nullable ICloudService iCloudService;
66
67     private @Nullable ExpiringCache<String> iCloudDeviceInformationCache;
68
69     private AuthState authState = AuthState.INITIAL;
70
71     private final Object synchronizeRefresh = new Object();
72
73     private Set<ICloudDeviceInformationListener> deviceInformationListeners = Collections
74             .synchronizedSet(new HashSet<>());
75
76     @Nullable
77     ScheduledFuture<?> refreshJob;
78
79     @Nullable
80     ScheduledFuture<?> initTask;
81
82     private Storage<String> storage;
83
84     private static final String AUTH_CODE_KEY = "AUTH_CODE";
85
86     /**
87      * The constructor.
88      *
89      * @param bridge The bridge to set
90      * @param storage The storage service to set.
91      */
92     public ICloudAccountBridgeHandler(Bridge bridge, Storage<String> storage) {
93         super(bridge);
94         this.storage = storage;
95     }
96
97     @Override
98     public void handleCommand(ChannelUID channelUID, Command command) {
99         logger.trace("Command '{}' received for channel '{}'", command, channelUID);
100
101         if (command instanceof RefreshType) {
102             refreshData();
103         }
104     }
105
106     @SuppressWarnings("null")
107     @Override
108     public void initialize() {
109         logger.debug("iCloud bridge handler initializing ...");
110
111         if (authState != AuthState.WAIT_FOR_CODE) {
112             authState = AuthState.INITIAL;
113         }
114
115         this.iCloudDeviceInformationCache = new ExpiringCache<>(CACHE_EXPIRY, () -> {
116             return callApiWithRetryAndExceptionHandling(() -> {
117                 // callApiWithRetryAndExceptionHanlding ensures that iCloudService is not null when the following is
118                 // called. Cannot use method local iCloudService instance here, because instance may be replaced with a
119                 // new
120                 // one during retry.
121                 return iCloudService.getDevices().refreshClient();
122             });
123
124         });
125
126         updateStatus(ThingStatus.UNKNOWN);
127
128         // Init has to be done async becaue it requires sync network calls, which are not allowed in init.
129         Callable<?> asyncInit = () -> {
130             callApiWithRetryAndExceptionHandling(() -> {
131                 logger.debug("Dummy call for initial authentication.");
132                 return null;
133             });
134             if (authState == AuthState.AUTHENTICATED) {
135                 ICloudAccountThingConfiguration config = getConfigAs(ICloudAccountThingConfiguration.class);
136                 this.refreshJob = this.scheduler.scheduleWithFixedDelay(this::refreshData, 0,
137                         config.refreshTimeInMinutes, MINUTES);
138             } else {
139                 cancelRefresh();
140             }
141             return null;
142         };
143         initTask = this.scheduler.schedule(asyncInit, 0, TimeUnit.SECONDS);
144         logger.debug("iCloud bridge handler initialized.");
145     }
146
147     private <@Nullable T> T callApiWithRetryAndExceptionHandling(Callable<T> wrapped) {
148         int retryCount = 1;
149         boolean success = false;
150         Throwable lastException = null;
151         synchronized (synchronizeRefresh) {
152             if (this.iCloudService == null) {
153                 ICloudAccountThingConfiguration config = getConfigAs(ICloudAccountThingConfiguration.class);
154                 final String localAppleId = config.appleId;
155                 final String localPassword = config.password;
156
157                 if (localAppleId != null && localPassword != null) {
158                     this.iCloudService = new ICloudService(localAppleId, localPassword, this.storage);
159                 } else {
160                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
161                             "Apple ID or password is not set!");
162                     return null;
163                 }
164             }
165
166             if (authState == AuthState.INITIAL) {
167                 success = checkLogin();
168             } else if (authState == AuthState.WAIT_FOR_CODE) {
169                 try {
170                     success = handle2FAAuthentication();
171                 } catch (IOException | InterruptedException | ICloudApiResponseException ex) {
172                     logger.debug("Error while validating 2-FA code.", ex);
173                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
174                             "Error while validating 2-FA code.");
175                     return null;
176                 }
177             }
178             if (authState != AuthState.AUTHENTICATED && !success) {
179                 return null;
180             }
181
182             do {
183                 try {
184                     if (authState == AuthState.AUTHENTICATED) {
185                         return wrapped.call();
186                     } else {
187                         checkLogin();
188                     }
189                 } catch (ICloudApiResponseException e) {
190                     logger.debug("ICloudApiResponseException with status code {}", e.getStatusCode());
191                     lastException = e;
192                     if (e.getStatusCode() == 450) {
193                         checkLogin();
194                     }
195                 } catch (IllegalStateException e) {
196                     logger.debug("Need to authenticate first.", e);
197                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, "Wait for login");
198                     return null;
199                 } catch (IOException e) {
200                     logger.warn("Unable to refresh device data", e);
201                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
202                     return null;
203                 } catch (Exception e) {
204                     logger.debug("Unexpected exception occured", e);
205                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
206                     return null;
207                 }
208
209                 retryCount++;
210                 try {
211                     Thread.sleep(200);
212                 } catch (InterruptedException e) {
213                     Thread.interrupted();
214                 }
215             } while (!success && retryCount < 3);
216             throw new RetryException(lastException);
217         }
218     }
219
220     private boolean handle2FAAuthentication() throws IOException, InterruptedException, ICloudApiResponseException {
221         logger.debug("Starting iCloud 2-FA authentication  AuthState={}, Thing={})...", authState,
222                 getThing().getUID().getAsString());
223         final ICloudService localICloudService = this.iCloudService;
224         if (authState != AuthState.WAIT_FOR_CODE || localICloudService == null) {
225             throw new IllegalStateException("2-FA authentication not initialized.");
226         }
227         ICloudAccountThingConfiguration config = getConfigAs(ICloudAccountThingConfiguration.class);
228         String lastTriedCode = storage.get(AUTH_CODE_KEY);
229         String code = config.code;
230         boolean success = false;
231         if (code == null || code.isBlank() || code.equals(lastTriedCode)) {
232             // Still waiting for user to update config.
233             logger.warn("ICloud authentication requires 2-FA code. Please provide code configuration for thing '{}'.",
234                     getThing().getUID().getAsString());
235             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
236                     "Please provide 2-FA code in thing configuration.");
237             return false;
238         } else {
239             // 2-FA-Code was requested in previous call of this method.
240             // User has provided code in config.
241             logger.debug("Code is given in thing configuration '{}'. Trying to validate code...",
242                     getThing().getUID().getAsString());
243             storage.put(AUTH_CODE_KEY, lastTriedCode);
244             success = localICloudService.validate2faCode(code);
245             if (!success) {
246                 authState = AuthState.INITIAL;
247                 logger.warn("ICloud token invalid.");
248                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Invalid 2-FA-code.");
249                 return false;
250             }
251             org.openhab.core.config.core.Configuration config2 = editConfiguration();
252             config2.put("code", "");
253             updateConfiguration(config2);
254
255             logger.debug("Code is valid.");
256         }
257         authState = AuthState.AUTHENTICATED;
258         updateStatus(ThingStatus.ONLINE);
259         logger.debug("iCloud bridge handler '{}' authenticated with 2-FA code.", getThing().getUID().getAsString());
260         return success;
261     }
262
263     @Override
264     public void handleRemoval() {
265         super.handleRemoval();
266     }
267
268     @Override
269     public void dispose() {
270         cancelRefresh();
271
272         final ScheduledFuture<?> localInitTask = this.initTask;
273         if (localInitTask != null) {
274             localInitTask.cancel(true);
275             this.initTask = null;
276         }
277         super.dispose();
278     }
279
280     private void cancelRefresh() {
281         final ScheduledFuture<?> localrefreshJob = this.refreshJob;
282         if (localrefreshJob != null) {
283             localrefreshJob.cancel(true);
284             this.refreshJob = null;
285         }
286     }
287
288     @SuppressWarnings("null")
289     public void findMyDevice(String deviceId) throws IOException, InterruptedException {
290         callApiWithRetryAndExceptionHandling(() -> {
291             // callApiWithRetryAndExceptionHanlding ensures that iCloudService is not null when the following is
292             // called. Cannot use method local iCloudService instance here, because instance may be replaced with a new
293             // one during retry.
294             iCloudService.getDevices().playSound(deviceId);
295             return null;
296         });
297     }
298
299     public void registerListener(ICloudDeviceInformationListener listener) {
300         this.deviceInformationListeners.add(listener);
301     }
302
303     public void unregisterListener(ICloudDeviceInformationListener listener) {
304         this.deviceInformationListeners.remove(listener);
305     }
306
307     /**
308      * Checks login to iCloud account. The flow is a bit complicated due to 2-FA authentication.
309      * The normal flow would be:
310      *
311      *
312      * <pre>
313         ICloudService service = new ICloudService(...);
314         service.authenticate(false);
315         if (service.requires2fa()) {
316             String code = ... // Request code from user!
317             System.out.println(service.validate2faCode(code));
318             if (!service.isTrustedSession()) {
319                 service.trustSession();
320             }
321             if (!service.isTrustedSession()) {
322                 System.err.println("Trust failed!!!");
323             }
324      * </pre>
325      *
326      * The call to {@link ICloudService#authenticate(boolean)} request a token from the user.
327      * This should be done only once. Afterwards the user has to update the configuration.
328      * In openhab this method here is called for several reason (e.g. config change). So we track if we already
329      * requested a code {@link #validate2faCode}.
330      */
331     private boolean checkLogin() {
332         logger.debug("Starting iCloud authentication (AuthState={}, Thing={})...", authState,
333                 getThing().getUID().getAsString());
334         final ICloudService localICloudService = this.iCloudService;
335         if (authState == AuthState.WAIT_FOR_CODE || localICloudService == null) {
336             throw new IllegalStateException("2-FA authentication not completed.");
337         }
338
339         try {
340             // No code requested yet or session is trusted (hopefully).
341             boolean success = localICloudService.authenticate(false);
342             if (!success) {
343                 authState = AuthState.USER_PW_INVALID;
344                 logger.warn("iCloud authentication failed. Invalid credentials.");
345                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Invalid credentials.");
346                 this.iCloudService = null;
347                 return false;
348             }
349             if (localICloudService.requires2fa()) {
350                 // New code was requested. Wait for the user to update config.
351                 logger.warn(
352                         "iCloud authentication requires 2-FA code. Please provide code configuration for thing '{}'.",
353                         getThing().getUID().getAsString());
354                 authState = AuthState.WAIT_FOR_CODE;
355                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
356                         "Please provide 2-FA code in thing configuration.");
357                 return false;
358             }
359
360             if (!localICloudService.isTrustedSession()) {
361                 logger.debug("Trying to establish session trust.");
362                 success = localICloudService.trustSession();
363                 if (!success) {
364                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Session trust failed.");
365                     return false;
366                 }
367             }
368
369             authState = AuthState.AUTHENTICATED;
370             updateStatus(ThingStatus.ONLINE);
371             logger.debug("iCloud bridge handler authenticated.");
372             return true;
373         } catch (Exception e) {
374             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
375             return false;
376         }
377     }
378
379     /**
380      * Refresh iCloud device data.
381      */
382     public void refreshData() {
383         logger.debug("iCloud bridge refreshing data ...");
384         synchronized (this.synchronizeRefresh) {
385             ExpiringCache<String> localCache = this.iCloudDeviceInformationCache;
386             if (localCache == null) {
387                 return;
388             }
389             String json = localCache.getValue();
390             logger.trace("json: {}", json);
391
392             if (json == null) {
393                 return;
394             }
395
396             try {
397                 ICloudAccountDataResponse iCloudData = JsonUtils.fromJson(json, ICloudAccountDataResponse.class);
398                 if (iCloudData == null) {
399                     return;
400                 }
401                 int statusCode = Integer.parseUnsignedInt(iCloudData.getICloudAccountStatusCode());
402                 if (statusCode == 200) {
403                     updateStatus(ThingStatus.ONLINE);
404                     informDeviceInformationListeners(iCloudData.getICloudDeviceInformationList());
405                 } else {
406                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
407                             "Status = " + statusCode + ", Response = " + json);
408                 }
409                 logger.debug("iCloud bridge data refresh complete.");
410             } catch (NumberFormatException | JsonSyntaxException e) {
411                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
412                         "iCloud response invalid: " + e.getMessage());
413             }
414         }
415     }
416
417     private void informDeviceInformationListeners(List<ICloudDeviceInformation> deviceInformationList) {
418         this.deviceInformationListeners.forEach(discovery -> discovery.deviceInformationUpdate(deviceInformationList));
419     }
420 }