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 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
121 return iCloudService.getDevices().refreshClient();
126 updateStatus(ThingStatus.UNKNOWN);
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.");
134 if (authState == AuthState.AUTHENTICATED) {
135 ICloudAccountThingConfiguration config = getConfigAs(ICloudAccountThingConfiguration.class);
136 this.refreshJob = this.scheduler.scheduleWithFixedDelay(this::refreshData, 0,
137 config.refreshTimeInMinutes, MINUTES);
143 initTask = this.scheduler.schedule(asyncInit, 0, TimeUnit.SECONDS);
144 logger.debug("iCloud bridge handler initialized.");
147 private <@Nullable T> T callApiWithRetryAndExceptionHandling(Callable<T> wrapped) {
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;
157 if (localAppleId != null && localPassword != null) {
158 this.iCloudService = new ICloudService(localAppleId, localPassword, this.storage);
160 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
161 "Apple ID or password is not set!");
166 if (authState == AuthState.INITIAL) {
167 success = checkLogin();
168 } else if (authState == AuthState.WAIT_FOR_CODE) {
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.");
178 if (authState != AuthState.AUTHENTICATED && !success) {
184 if (authState == AuthState.AUTHENTICATED) {
185 return wrapped.call();
189 } catch (ICloudApiResponseException e) {
190 logger.debug("ICloudApiResponseException with status code {}", e.getStatusCode());
192 if (e.getStatusCode() == 450) {
195 } catch (IllegalStateException e) {
196 logger.debug("Need to authenticate first.", e);
197 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, "Wait for login");
199 } catch (IOException e) {
200 logger.warn("Unable to refresh device data", e);
201 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
203 } catch (Exception e) {
204 logger.debug("Unexpected exception occured", e);
205 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
212 } catch (InterruptedException e) {
213 Thread.interrupted();
215 } while (!success && retryCount < 3);
216 throw new RetryException(lastException);
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.");
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.");
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);
246 authState = AuthState.INITIAL;
247 logger.warn("ICloud token invalid.");
248 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Invalid 2-FA-code.");
251 org.openhab.core.config.core.Configuration config2 = editConfiguration();
252 config2.put("code", "");
253 updateConfiguration(config2);
255 logger.debug("Code is valid.");
257 authState = AuthState.AUTHENTICATED;
258 updateStatus(ThingStatus.ONLINE);
259 logger.debug("iCloud bridge handler '{}' authenticated with 2-FA code.", getThing().getUID().getAsString());
264 public void handleRemoval() {
265 super.handleRemoval();
269 public void dispose() {
272 final ScheduledFuture<?> localInitTask = this.initTask;
273 if (localInitTask != null) {
274 localInitTask.cancel(true);
275 this.initTask = null;
280 private void cancelRefresh() {
281 final ScheduledFuture<?> localrefreshJob = this.refreshJob;
282 if (localrefreshJob != null) {
283 localrefreshJob.cancel(true);
284 this.refreshJob = null;
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
294 iCloudService.getDevices().playSound(deviceId);
299 public void registerListener(ICloudDeviceInformationListener listener) {
300 this.deviceInformationListeners.add(listener);
303 public void unregisterListener(ICloudDeviceInformationListener listener) {
304 this.deviceInformationListeners.remove(listener);
308 * Checks login to iCloud account. The flow is a bit complicated due to 2-FA authentication.
309 * The normal flow would be:
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();
321 if (!service.isTrustedSession()) {
322 System.err.println("Trust failed!!!");
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}.
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.");
340 // No code requested yet or session is trusted (hopefully).
341 boolean success = localICloudService.authenticate(false);
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;
349 if (localICloudService.requires2fa()) {
350 // New code was requested. Wait for the user to update config.
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.");
360 if (!localICloudService.isTrustedSession()) {
361 logger.debug("Trying to establish session trust.");
362 success = localICloudService.trustSession();
364 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Session trust failed.");
369 authState = AuthState.AUTHENTICATED;
370 updateStatus(ThingStatus.ONLINE);
371 logger.debug("iCloud bridge handler authenticated.");
373 } catch (Exception e) {
374 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
380 * Refresh iCloud device data.
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) {
389 String json = localCache.getValue();
390 logger.trace("json: {}", json);
397 ICloudAccountDataResponse iCloudData = JsonUtils.fromJson(json, ICloudAccountDataResponse.class);
398 if (iCloudData == null) {
401 int statusCode = Integer.parseUnsignedInt(iCloudData.getICloudAccountStatusCode());
402 if (statusCode == 200) {
403 updateStatus(ThingStatus.ONLINE);
404 informDeviceInformationListeners(iCloudData.getICloudDeviceInformationList());
406 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
407 "Status = " + statusCode + ", Response = " + json);
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());
417 private void informDeviceInformationListeners(List<ICloudDeviceInformation> deviceInformationList) {
418 this.deviceInformationListeners.forEach(discovery -> discovery.deviceInformationUpdate(deviceInformationList));