Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 17 additions & 7 deletions bundles/org.openhab.binding.tado/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,23 @@ There are two ways to authenticate it as follows:
1. Online via the OAuth Device Code Grant Flow (RFC-8628) authentication process through the link provided at `http://[openhab-ip-address]:8080/tado`.
1. Enter `username` and `password` credentials in the thing configuration parameters as shown in the table below.

Note: after March 15th, 2025 online authentication is the tado° preferred method.
It is possible that the `username` and `password` method may cease to work some time after this date.

| Parameter | Required | Description |
|------------|----------|-----------------------------------------------------------|
| `username` | yes | Username used to log in at [my.tado](https://my.tado.com) |
| `password` | yes | Password of the username |
Note: after March 15th, 2025 online authentication is the tado° preferred (or even only) method.
In other words the `username` and `password` method has probably ceased to work after that date.

| Parameter | Optional | Description |
|---------------|----------|------------------------------------------------------------------------------------|
| `useRfc8628` | yes | Determines if the binding shall use oAuth RFC-8628 authentication |
| `rfcWithUser` | yes | Determines if the user name shall be included in the oAuth RFC-8628 authentication |
| `username` | yes | Username used to log in at [my.tado](https://my.tado.com) |
| `password` | yes | Password of the username |
| `homeId` | yes | Selects the Home Id to use (only needed if the account has multiple homes) |

The `rfcWithUser` setting is only needed if you have multiple tado° accounts.
It forces the binding to use different authentication tokens for each respective account `username`.

The `homeId` is only needed if you have multiple homes under a single tado° account.
It forces the binding to read and write the data for the respective Home Id.
If you do not have multiple homes, the binding always uses the first and only Home Id.

Example `tado.things`

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ public enum OperationMode {
// Configuration
public static final String CONFIG_ZONE_ID = "id";
public static final String CONFIG_MOBILE_DEVICE_ID = "id";
public static final String CONFIG_USE_RFC8628 = "useRfc8628";

// Properties
public static final String PROPERTY_ZONE_NAME = "name";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,6 @@ public class TadoHomeConfig {
public @Nullable String username;
public @Nullable String password;
public @Nullable Boolean useRfc8628;
public @Nullable Boolean rfcWithUser;
public @Nullable Integer homeId;
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import java.util.Hashtable;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;

import javax.servlet.ServletException;

Expand Down Expand Up @@ -56,6 +57,7 @@
* handlers.
*
* @author Dennis Frommknecht - Initial contribution
* @author Andrew Fiddian-Green - OAuth RFC18628 authentication
*/
@NonNullByDefault
@Component(configurationPid = "binding.tado", service = ThingHandlerFactory.class)
Expand All @@ -73,14 +75,13 @@ public class TadoHandlerFactory extends BaseThingHandlerFactory {
private final Logger logger = LoggerFactory.getLogger(TadoHandlerFactory.class);
private final Set<TadoHomeHandler> oAuthClientServiceSubscribers = new HashSet<>();
private final Map<ThingUID, ServiceRegistration<?>> discoveryServiceRegs = new HashMap<>();
private final Map<String, OAuthClientService> oAuthClientServices = new ConcurrentHashMap<>();

private final TadoStateDescriptionProvider stateDescriptionProvider;
private final HttpService httpService;
private final OAuthFactory oAuthFactory;
private final TadoAuthenticationServlet httpServlet;

private @Nullable OAuthClientService oAuthClientService;

@Activate
public TadoHandlerFactory(@Reference TadoStateDescriptionProvider stateDescriptionProvider,
@Reference HttpService httpService, @Reference OAuthFactory oAuthFactory) {
Expand All @@ -92,9 +93,8 @@ public TadoHandlerFactory(@Reference TadoStateDescriptionProvider stateDescripti

@Deactivate
public void deactivate() {
if (oAuthClientService != null) {
oAuthFactory.ungetOAuthService(THING_TYPE_HOME.toString());
}
oAuthClientServices.keySet().forEach(id -> oAuthFactory.ungetOAuthService(id));
oAuthClientServices.clear();
}

@Override
Expand All @@ -107,7 +107,7 @@ public boolean supportsThingType(ThingTypeUID thingTypeUID) {
ThingTypeUID thingTypeUID = thing.getThingTypeUID();

if (thingTypeUID.equals(THING_TYPE_HOME)) {
TadoHomeHandler tadoHomeHandler = new TadoHomeHandler((Bridge) thing, this, oAuthFactory);
TadoHomeHandler tadoHomeHandler = new TadoHomeHandler((Bridge) thing, this);
registerTadoDiscoveryService(tadoHomeHandler);
return tadoHomeHandler;
} else if (thingTypeUID.equals(THING_TYPE_ZONE)) {
Expand Down Expand Up @@ -148,10 +148,11 @@ protected synchronized void removeHandler(ThingHandler thingHandler) {
* Retrieves the pre-existing {@link OAuthClientService} if present, or creates a new one.
* If necessary also registers the {@link TadoAuthenticationServlet}.
*
* @param tadoHomeHandler
* @param tadoHomeHandler the subscribing thing handler
* @param user the optional user name (may be null)
* @return an {@link OAuthClientService}
*/
public OAuthClientService subscribeOAuthClientService(TadoHomeHandler tadoHomeHandler) {
public OAuthClientService subscribeOAuthClientService(TadoHomeHandler tadoHomeHandler, @Nullable String user) {
if (oAuthClientServiceSubscribers.isEmpty()) {
try {
httpService.registerServlet(TadoAuthenticationServlet.PATH, httpServlet, null, null);
Expand All @@ -162,16 +163,18 @@ public OAuthClientService subscribeOAuthClientService(TadoHomeHandler tadoHomeHa

oAuthClientServiceSubscribers.add(tadoHomeHandler);

OAuthClientService oAuthClientService = this.oAuthClientService;
OAuthClientService oAuthClientService = oAuthClientServices.get(getServiceId(user));
if (oAuthClientService == null) {
oAuthClientService = oAuthFactory.getOAuthClientService(THING_TYPE_HOME.toString());
this.oAuthClientService = oAuthClientService;
oAuthClientService = oAuthFactory.getOAuthClientService(getServiceId(user));
if (oAuthClientService != null) {
oAuthClientServices.put(getServiceId(user), oAuthClientService);
}
}

if (oAuthClientService == null) {
oAuthClientService = oAuthFactory.createOAuthClientService(THING_TYPE_HOME.toString(), //
OAUTH_TOKEN_URL, OAUTH_DEVICE_URL, OAUTH_CLIENT_ID, null, OAUTH_SCOPE, false);
this.oAuthClientService = oAuthClientService;
oAuthClientService = oAuthFactory.createOAuthClientService(getServiceId(user), OAUTH_TOKEN_URL,
OAUTH_DEVICE_URL, OAUTH_CLIENT_ID, null, OAUTH_SCOPE, false);
oAuthClientServices.put(getServiceId(user), oAuthClientService);
}

return oAuthClientService;
Expand All @@ -193,30 +196,35 @@ public void unsubscribeOAuthClientService(TadoHomeHandler tadoHomeHandler) {
/**
* Returns a nullable {@link AccessTokenResponse} if the OAuthClientService exists.
*
* @param user the optional user name (may be null)
* @return a nullable {@link AccessTokenResponse}.
* @throws OAuthException on any error
* @throws OAuthException
* @throws IOException
* @throws OAuthResponseException
*/
public @Nullable AccessTokenResponse getAccessTokenResponse() throws OAuthException {
OAuthClientService oAuthClientService = this.oAuthClientService;
public @Nullable AccessTokenResponse getAccessTokenResponse(@Nullable String user)
throws OAuthException, IOException, OAuthResponseException {
OAuthClientService oAuthClientService = oAuthClientServices.get(getServiceId(user));
if (oAuthClientService == null) {
throw new OAuthException("Missing OAuthClientService");
}
try {
return oAuthClientService.getAccessTokenResponse();
} catch (OAuthException | IOException | OAuthResponseException e) {
logger.debug("getAccessTokenResponse() error {}", e.getMessage(), e);
throw new OAuthException("OAuthClientService error" + e.getMessage(), e);
throw e;
}
}

/**
* Returns a non null DeviceCodeResponse from the OAuthClientService if it exists.
*
* @param user the optional user name (may be null)
* @return a {@link DeviceCodeResponseDTO}
* @throws OAuthException if it cannot return a non null result
*/
public DeviceCodeResponseDTO getDeviceCodeResponse() throws OAuthException {
OAuthClientService oAuthClientService = this.oAuthClientService;
public DeviceCodeResponseDTO getDeviceCodeResponse(@Nullable String user) throws OAuthException {
OAuthClientService oAuthClientService = oAuthClientServices.get(getServiceId(user));
if (oAuthClientService == null) {
throw new OAuthException("Missing OAuthClientService");
}
Expand All @@ -227,7 +235,21 @@ public DeviceCodeResponseDTO getDeviceCodeResponse() throws OAuthException {
return result;
}

public boolean hasOAuthClientService() {
return oAuthClientService != null;
/**
* Check if there is an OAuthClientService registered
*
* @param user the optional user name (may be null)
*/
public boolean hasOAuthClientService(@Nullable String user) {
return oAuthClientServices.containsKey(getServiceId(user));
}

/**
* Build a unique OAuth service id using the (optional) user name if present and not blank
*
* @param user the optional user name (may be null)
*/
private String getServiceId(@Nullable String user) {
return THING_TYPE_HOME.toString() + (user != null && !user.isBlank() ? ":" + user : "");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import java.util.Objects;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;

import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
Expand All @@ -38,7 +39,7 @@
import org.openhab.core.auth.client.oauth2.AccessTokenRefreshListener;
import org.openhab.core.auth.client.oauth2.AccessTokenResponse;
import org.openhab.core.auth.client.oauth2.OAuthClientService;
import org.openhab.core.auth.client.oauth2.OAuthFactory;
import org.openhab.core.config.core.Configuration;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.ChannelUID;
Expand All @@ -56,6 +57,7 @@
* The {@link TadoHomeHandler} is the bridge of all home-based things.
*
* @author Dennis Frommknecht - Initial contribution
* @author Andrew Fiddian-Green - OAuth RFC18628 authentication
*/
@NonNullByDefault
public class TadoHomeHandler extends BaseBridgeHandler implements AccessTokenRefreshListener {
Expand All @@ -64,7 +66,8 @@ public class TadoHomeHandler extends BaseBridgeHandler implements AccessTokenRef
private static final String CONF_ERROR_NO_HOME = "@text/tado.home.status.nohome";
private static final String CONF_ERROR_NO_HOME_ID = "@text/tado.home.status.nohomeid";
private static final String CONF_PENDING_USER_CREDS = "@text/tado.home.status.username";
private static final String CONF_PENDING_OAUTH_CREDS = "@text/tado.home.status.oauth [\"http(s)://<YOUROPENHAB>:<YOURPORT>%s\"]";
private static final String CONF_PENDING_OAUTH_CREDS = //
"@text/tado.home.status.oauth [\"http(s)://<YOUROPENHAB>:<YOURPORT>%s?%s=%s\"]";

// tado specific RFC-8628 oAuth authentication parameters
private static final ZonedDateTime OAUTH_MANDATORY_FROM_DATE = ZonedDateTime.parse("2025-03-15T00:00:00Z");
Expand All @@ -83,7 +86,7 @@ public class TadoHomeHandler extends BaseBridgeHandler implements AccessTokenRef
private @Nullable ScheduledFuture<?> initializationFuture;
private @Nullable OAuthClientService oAuthClientService;

public TadoHomeHandler(Bridge bridge, TadoHandlerFactory tadoHandlerFactory, OAuthFactory oAuthFactory) {
public TadoHomeHandler(Bridge bridge, TadoHandlerFactory tadoHandlerFactory) {
super(bridge);
this.batteryChecker = new TadoBatteryChecker(this);
this.configuration = getConfigAs(TadoHomeConfig.class);
Expand All @@ -100,24 +103,33 @@ public TemperatureUnit getTemperatureUnit() {
public void initialize() {
configuration = getConfigAs(TadoHomeConfig.class);

String userName = configuration.username;
String username = configuration.username;
String password = configuration.password;
boolean v1CredentialsMissing = userName == null || userName.isBlank() || password == null || password.isBlank();
boolean v1CredentialsMissing = username == null || username.isBlank() || password == null || password.isBlank();

boolean suggestRfc8628 = false;
suggestRfc8628 |= Boolean.TRUE.equals(configuration.useRfc8628);
suggestRfc8628 |= v1CredentialsMissing;
suggestRfc8628 |= ZonedDateTime.now().isAfter(OAUTH_MANDATORY_FROM_DATE);

if (suggestRfc8628) {
OAuthClientService oAuthClientService = tadoHandlerFactory.subscribeOAuthClientService(this);
String rfcUser = Boolean.TRUE.equals(configuration.rfcWithUser) //
? username != null && !username.isBlank() ? username : null
: null;
OAuthClientService oAuthClientService = tadoHandlerFactory.subscribeOAuthClientService(this, rfcUser);
oAuthClientService.addAccessTokenRefreshListener(this);
this.api = new HomeApiFactory().create(oAuthClientService);
this.oAuthClientService = oAuthClientService;
logger.trace("initialize() api v2 created");
confPendingText = CONF_PENDING_OAUTH_CREDS.formatted(TadoAuthenticationServlet.PATH);
confPendingText = CONF_PENDING_OAUTH_CREDS.formatted(TadoAuthenticationServlet.PATH,
TadoAuthenticationServlet.PARAM_NAME_USER, rfcUser != null ? rfcUser : "");
if (!Boolean.TRUE.equals(configuration.useRfc8628)) {
Configuration configuration = editConfiguration();
configuration.put(CONFIG_USE_RFC8628, Boolean.TRUE);
updateConfiguration(configuration);
}
} else {
api = new HomeApiFactory().create(Objects.requireNonNull(userName), Objects.requireNonNull(password));
api = new HomeApiFactory().create(Objects.requireNonNull(username), Objects.requireNonNull(password));
logger.trace("initialize() api v1 created");
confPendingText = CONF_PENDING_USER_CREDS;
}
Expand Down Expand Up @@ -156,7 +168,24 @@ private synchronized void initializeBridgeStatusAndPropertiesIfOffline() {
return;
}

/*
* If there is only one home, or if there is no valid configuration.homeId entry, then use the first
* home id. Otherwise use the configuration.homeId entry value (if one exists). If there is no valid
* configuration.homeId entry but there are multiple homes then log the available home to help the
* user set up the proper configuration.homeId entry.
*/
Integer firstHomeId = homes.get(0).getId();
if (homes.size() > 1) {
Integer configHomeId = configuration.homeId;
if (configHomeId == null || configHomeId == 0) {
logger.info("Trying first Home Id in the list [{}]", homes.stream()
.map(home -> String.valueOf(home.getId())).collect(Collectors.joining(",")));
} else {
firstHomeId = homes.stream().map(home -> home.getId()).filter(id -> configHomeId.equals(id))
.findFirst().orElse(null);
}
}

if (firstHomeId == null) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, CONF_ERROR_NO_HOME_ID);
return;
Expand Down
Loading