]> git.basschouten.com Git - openhab-addons.git/blob
d507e57a31e6342fece8451a8b22a32ccb2d2724
[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                 () -> 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                 iCloudService.getDevices().refreshClient()));
122
123         updateStatus(ThingStatus.UNKNOWN);
124
125         // Init has to be done async becaue it requires sync network calls, which are not allowed in init.
126         Callable<?> asyncInit = () -> {
127             callApiWithRetryAndExceptionHandling(() -> {
128                 logger.debug("Dummy call for initial authentication.");
129                 return null;
130             });
131             if (authState == AuthState.AUTHENTICATED) {
132                 ICloudAccountThingConfiguration config = getConfigAs(ICloudAccountThingConfiguration.class);
133                 this.refreshJob = this.scheduler.scheduleWithFixedDelay(this::refreshData, 0,
134                         config.refreshTimeInMinutes, MINUTES);
135             } else {
136                 cancelRefresh();
137             }
138             return null;
139         };
140         initTask = this.scheduler.schedule(asyncInit, 0, TimeUnit.SECONDS);
141         logger.debug("iCloud bridge handler initialized.");
142     }
143
144     private <@Nullable T> T callApiWithRetryAndExceptionHandling(Callable<T> wrapped) {
145         int retryCount = 1;
146         boolean success = false;
147         Throwable lastException = null;
148         synchronized (synchronizeRefresh) {
149             if (this.iCloudService == null) {
150                 ICloudAccountThingConfiguration config = getConfigAs(ICloudAccountThingConfiguration.class);
151                 final String localAppleId = config.appleId;
152                 final String localPassword = config.password;
153
154                 if (localAppleId != null && localPassword != null) {
155                     this.iCloudService = new ICloudService(localAppleId, localPassword, this.storage);
156                 } else {
157                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
158                             "Apple ID or password is not set!");
159                     return null;
160                 }
161             }
162
163             if (authState == AuthState.INITIAL) {
164                 success = checkLogin();
165             } else if (authState == AuthState.WAIT_FOR_CODE) {
166                 try {
167                     success = handle2FAAuthentication();
168                 } catch (IOException | InterruptedException | ICloudApiResponseException ex) {
169                     logger.debug("Error while validating 2-FA code.", ex);
170                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
171                             "Error while validating 2-FA code.");
172                     return null;
173                 }
174             }
175             if (authState != AuthState.AUTHENTICATED && !success) {
176                 return null;
177             }
178
179             do {
180                 try {
181                     if (authState == AuthState.AUTHENTICATED) {
182                         return wrapped.call();
183                     } else {
184                         checkLogin();
185                     }
186                 } catch (ICloudApiResponseException e) {
187                     logger.debug("ICloudApiResponseException with status code {}", e.getStatusCode());
188                     lastException = e;
189                     if (e.getStatusCode() == 450) {
190                         checkLogin();
191                     }
192                 } catch (IllegalStateException e) {
193                     logger.debug("Need to authenticate first.", e);
194                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, "Wait for login");
195                     return null;
196                 } catch (IOException e) {
197                     logger.warn("Unable to refresh device data", e);
198                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
199                     return null;
200                 } catch (Exception e) {
201                     logger.debug("Unexpected exception occured", e);
202                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
203                     return null;
204                 }
205
206                 retryCount++;
207                 try {
208                     Thread.sleep(200);
209                 } catch (InterruptedException e) {
210                     Thread.interrupted();
211                 }
212             } while (!success && retryCount < 3);
213             throw new RetryException(lastException);
214         }
215     }
216
217     private boolean handle2FAAuthentication() throws IOException, InterruptedException, ICloudApiResponseException {
218         logger.debug("Starting iCloud 2-FA authentication  AuthState={}, Thing={})...", authState,
219                 getThing().getUID().getAsString());
220         final ICloudService localICloudService = this.iCloudService;
221         if (authState != AuthState.WAIT_FOR_CODE || localICloudService == null) {
222             throw new IllegalStateException("2-FA authentication not initialized.");
223         }
224         ICloudAccountThingConfiguration config = getConfigAs(ICloudAccountThingConfiguration.class);
225         String lastTriedCode = storage.get(AUTH_CODE_KEY);
226         String code = config.code;
227         boolean success = false;
228         if (code == null || code.isBlank() || code.equals(lastTriedCode)) {
229             // Still waiting for user to update config.
230             logger.warn("ICloud authentication requires 2-FA code. Please provide code configuration for thing '{}'.",
231                     getThing().getUID().getAsString());
232             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
233                     "Please provide 2-FA code in thing configuration.");
234             return false;
235         } else {
236             // 2-FA-Code was requested in previous call of this method.
237             // User has provided code in config.
238             logger.debug("Code is given in thing configuration '{}'. Trying to validate code...",
239                     getThing().getUID().getAsString());
240             storage.put(AUTH_CODE_KEY, lastTriedCode);
241             success = localICloudService.validate2faCode(code);
242             if (!success) {
243                 authState = AuthState.INITIAL;
244                 logger.warn("ICloud token invalid.");
245                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Invalid 2-FA-code.");
246                 return false;
247             }
248             org.openhab.core.config.core.Configuration config2 = editConfiguration();
249             config2.put("code", "");
250             updateConfiguration(config2);
251
252             logger.debug("Code is valid.");
253         }
254         authState = AuthState.AUTHENTICATED;
255         updateStatus(ThingStatus.ONLINE);
256         logger.debug("iCloud bridge handler '{}' authenticated with 2-FA code.", getThing().getUID().getAsString());
257         return success;
258     }
259
260     @Override
261     public void handleRemoval() {
262         super.handleRemoval();
263     }
264
265     @Override
266     public void dispose() {
267         cancelRefresh();
268
269         final ScheduledFuture<?> localInitTask = this.initTask;
270         if (localInitTask != null) {
271             localInitTask.cancel(true);
272             this.initTask = null;
273         }
274         super.dispose();
275     }
276
277     private void cancelRefresh() {
278         final ScheduledFuture<?> localrefreshJob = this.refreshJob;
279         if (localrefreshJob != null) {
280             localrefreshJob.cancel(true);
281             this.refreshJob = null;
282         }
283     }
284
285     @SuppressWarnings("null")
286     public void findMyDevice(String deviceId) throws IOException, InterruptedException {
287         callApiWithRetryAndExceptionHandling(() -> {
288             // callApiWithRetryAndExceptionHanlding ensures that iCloudService is not null when the following is
289             // called. Cannot use method local iCloudService instance here, because instance may be replaced with a new
290             // one during retry.
291             iCloudService.getDevices().playSound(deviceId);
292             return null;
293         });
294     }
295
296     public void registerListener(ICloudDeviceInformationListener listener) {
297         this.deviceInformationListeners.add(listener);
298     }
299
300     public void unregisterListener(ICloudDeviceInformationListener listener) {
301         this.deviceInformationListeners.remove(listener);
302     }
303
304     /**
305      * Checks login to iCloud account. The flow is a bit complicated due to 2-FA authentication.
306      * The normal flow would be:
307      *
308      *
309      * <pre>
310         ICloudService service = new ICloudService(...);
311         service.authenticate(false);
312         if (service.requires2fa()) {
313             String code = ... // Request code from user!
314             System.out.println(service.validate2faCode(code));
315             if (!service.isTrustedSession()) {
316                 service.trustSession();
317             }
318             if (!service.isTrustedSession()) {
319                 System.err.println("Trust failed!!!");
320             }
321      * </pre>
322      *
323      * The call to {@link ICloudService#authenticate(boolean)} request a token from the user.
324      * This should be done only once. Afterwards the user has to update the configuration.
325      * In openhab this method here is called for several reason (e.g. config change). So we track if we already
326      * requested a code {@link #validate2faCode}.
327      */
328     private boolean checkLogin() {
329         logger.debug("Starting iCloud authentication (AuthState={}, Thing={})...", authState,
330                 getThing().getUID().getAsString());
331         final ICloudService localICloudService = this.iCloudService;
332         if (authState == AuthState.WAIT_FOR_CODE || localICloudService == null) {
333             throw new IllegalStateException("2-FA authentication not completed.");
334         }
335
336         try {
337             // No code requested yet or session is trusted (hopefully).
338             boolean success = localICloudService.authenticate(false);
339             if (!success) {
340                 authState = AuthState.USER_PW_INVALID;
341                 logger.warn("iCloud authentication failed. Invalid credentials.");
342                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Invalid credentials.");
343                 this.iCloudService = null;
344                 return false;
345             }
346             if (localICloudService.requires2fa()) {
347                 // New code was requested. Wait for the user to update config.
348                 logger.warn(
349                         "iCloud authentication requires 2-FA code. Please provide code configuration for thing '{}'.",
350                         getThing().getUID().getAsString());
351                 authState = AuthState.WAIT_FOR_CODE;
352                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
353                         "Please provide 2-FA code in thing configuration.");
354                 return false;
355             }
356
357             if (!localICloudService.isTrustedSession()) {
358                 logger.debug("Trying to establish session trust.");
359                 success = localICloudService.trustSession();
360                 if (!success) {
361                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Session trust failed.");
362                     return false;
363                 }
364             }
365
366             authState = AuthState.AUTHENTICATED;
367             updateStatus(ThingStatus.ONLINE);
368             logger.debug("iCloud bridge handler authenticated.");
369             return true;
370         } catch (Exception e) {
371             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
372             return false;
373         }
374     }
375
376     /**
377      * Refresh iCloud device data.
378      */
379     public void refreshData() {
380         logger.debug("iCloud bridge refreshing data ...");
381         synchronized (this.synchronizeRefresh) {
382             ExpiringCache<String> localCache = this.iCloudDeviceInformationCache;
383             if (localCache == null) {
384                 return;
385             }
386             String json = localCache.getValue();
387             logger.trace("json: {}", json);
388
389             if (json == null) {
390                 return;
391             }
392
393             try {
394                 ICloudAccountDataResponse iCloudData = JsonUtils.fromJson(json, ICloudAccountDataResponse.class);
395                 if (iCloudData == null) {
396                     return;
397                 }
398                 int statusCode = Integer.parseUnsignedInt(iCloudData.getICloudAccountStatusCode());
399                 if (statusCode == 200) {
400                     updateStatus(ThingStatus.ONLINE);
401                     informDeviceInformationListeners(iCloudData.getICloudDeviceInformationList());
402                 } else {
403                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
404                             "Status = " + statusCode + ", Response = " + json);
405                 }
406                 logger.debug("iCloud bridge data refresh complete.");
407             } catch (NumberFormatException | JsonSyntaxException e) {
408                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
409                         "iCloud response invalid: " + e.getMessage());
410             }
411         }
412     }
413
414     private void informDeviceInformationListeners(List<ICloudDeviceInformation> deviceInformationList) {
415         this.deviceInformationListeners.forEach(discovery -> discovery.deviceInformationUpdate(deviceInformationList));
416     }
417 }