2 * Copyright (c) 2010-2023 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
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
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.icloud.internal.handler;
15 import static java.util.concurrent.TimeUnit.*;
17 import java.io.IOException;
18 import java.util.Collections;
19 import java.util.HashSet;
20 import java.util.List;
22 import java.util.concurrent.Callable;
23 import java.util.concurrent.ScheduledFuture;
24 import java.util.concurrent.TimeUnit;
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;
48 import com.google.gson.JsonSyntaxException;
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.
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
59 public class ICloudAccountBridgeHandler extends BaseBridgeHandler {
61 private final Logger logger = LoggerFactory.getLogger(ICloudAccountBridgeHandler.class);
63 private static final int CACHE_EXPIRY = (int) SECONDS.toMillis(10);
65 private @Nullable ICloudService iCloudService;
67 private @Nullable ExpiringCache<String> iCloudDeviceInformationCache;
69 private AuthState authState = AuthState.INITIAL;
71 private final Object synchronizeRefresh = new Object();
73 private Set<ICloudDeviceInformationListener> deviceInformationListeners = Collections
74 .synchronizedSet(new HashSet<>());
77 ScheduledFuture<?> refreshJob;
80 ScheduledFuture<?> initTask;
82 private Storage<String> storage;
84 private static final String AUTH_CODE_KEY = "AUTH_CODE";
89 * @param bridge The bridge to set
90 * @param storage The storage service to set.
92 public ICloudAccountBridgeHandler(Bridge bridge, Storage<String> storage) {
94 this.storage = storage;
98 public void handleCommand(ChannelUID channelUID, Command command) {
99 logger.trace("Command '{}' received for channel '{}'", command, channelUID);
101 if (command instanceof RefreshType) {
106 @SuppressWarnings("null")
108 public void initialize() {
109 logger.debug("iCloud bridge handler initializing ...");
111 if (authState != AuthState.WAIT_FOR_CODE) {
112 authState = AuthState.INITIAL;
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
121 iCloudService.getDevices().refreshClient()));
123 updateStatus(ThingStatus.UNKNOWN);
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.");
131 if (authState == AuthState.AUTHENTICATED) {
132 ICloudAccountThingConfiguration config = getConfigAs(ICloudAccountThingConfiguration.class);
133 this.refreshJob = this.scheduler.scheduleWithFixedDelay(this::refreshData, 0,
134 config.refreshTimeInMinutes, MINUTES);
140 initTask = this.scheduler.schedule(asyncInit, 0, TimeUnit.SECONDS);
141 logger.debug("iCloud bridge handler initialized.");
144 private <@Nullable T> T callApiWithRetryAndExceptionHandling(Callable<T> wrapped) {
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;
154 if (localAppleId != null && localPassword != null) {
155 this.iCloudService = new ICloudService(localAppleId, localPassword, this.storage);
157 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
158 "Apple ID or password is not set!");
163 if (authState == AuthState.INITIAL) {
164 success = checkLogin();
165 } else if (authState == AuthState.WAIT_FOR_CODE) {
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.");
175 if (authState != AuthState.AUTHENTICATED && !success) {
181 if (authState == AuthState.AUTHENTICATED) {
182 return wrapped.call();
186 } catch (ICloudApiResponseException e) {
187 logger.debug("ICloudApiResponseException with status code {}", e.getStatusCode());
189 if (e.getStatusCode() == 450) {
192 } catch (IllegalStateException e) {
193 logger.debug("Need to authenticate first.", e);
194 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, "Wait for login");
196 } catch (IOException e) {
197 logger.warn("Unable to refresh device data", e);
198 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
200 } catch (Exception e) {
201 logger.debug("Unexpected exception occured", e);
202 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
209 } catch (InterruptedException e) {
210 Thread.interrupted();
212 } while (!success && retryCount < 3);
213 throw new RetryException(lastException);
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.");
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.");
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);
243 authState = AuthState.INITIAL;
244 logger.warn("ICloud token invalid.");
245 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Invalid 2-FA-code.");
248 org.openhab.core.config.core.Configuration config2 = editConfiguration();
249 config2.put("code", "");
250 updateConfiguration(config2);
252 logger.debug("Code is valid.");
254 authState = AuthState.AUTHENTICATED;
255 updateStatus(ThingStatus.ONLINE);
256 logger.debug("iCloud bridge handler '{}' authenticated with 2-FA code.", getThing().getUID().getAsString());
261 public void handleRemoval() {
262 super.handleRemoval();
266 public void dispose() {
269 final ScheduledFuture<?> localInitTask = this.initTask;
270 if (localInitTask != null) {
271 localInitTask.cancel(true);
272 this.initTask = null;
277 private void cancelRefresh() {
278 final ScheduledFuture<?> localrefreshJob = this.refreshJob;
279 if (localrefreshJob != null) {
280 localrefreshJob.cancel(true);
281 this.refreshJob = null;
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
291 iCloudService.getDevices().playSound(deviceId);
296 public void registerListener(ICloudDeviceInformationListener listener) {
297 this.deviceInformationListeners.add(listener);
300 public void unregisterListener(ICloudDeviceInformationListener listener) {
301 this.deviceInformationListeners.remove(listener);
305 * Checks login to iCloud account. The flow is a bit complicated due to 2-FA authentication.
306 * The normal flow would be:
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();
318 if (!service.isTrustedSession()) {
319 System.err.println("Trust failed!!!");
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}.
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.");
337 // No code requested yet or session is trusted (hopefully).
338 boolean success = localICloudService.authenticate(false);
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;
346 if (localICloudService.requires2fa()) {
347 // New code was requested. Wait for the user to update config.
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.");
357 if (!localICloudService.isTrustedSession()) {
358 logger.debug("Trying to establish session trust.");
359 success = localICloudService.trustSession();
361 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Session trust failed.");
366 authState = AuthState.AUTHENTICATED;
367 updateStatus(ThingStatus.ONLINE);
368 logger.debug("iCloud bridge handler authenticated.");
370 } catch (Exception e) {
371 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
377 * Refresh iCloud device data.
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) {
386 String json = localCache.getValue();
387 logger.trace("json: {}", json);
394 ICloudAccountDataResponse iCloudData = JsonUtils.fromJson(json, ICloudAccountDataResponse.class);
395 if (iCloudData == null) {
398 int statusCode = Integer.parseUnsignedInt(iCloudData.getICloudAccountStatusCode());
399 if (statusCode == 200) {
400 updateStatus(ThingStatus.ONLINE);
401 informDeviceInformationListeners(iCloudData.getICloudDeviceInformationList());
403 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
404 "Status = " + statusCode + ", Response = " + json);
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());
414 private void informDeviceInformationListeners(List<ICloudDeviceInformation> deviceInformationList) {
415 this.deviceInformationListeners.forEach(discovery -> discovery.deviceInformationUpdate(deviceInformationList));