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.tr064.internal;
15 import static org.openhab.binding.tr064.internal.Tr064BindingConstants.*;
18 import java.net.URISyntaxException;
19 import java.time.Duration;
20 import java.util.Arrays;
21 import java.util.Collection;
22 import java.util.Collections;
23 import java.util.HashMap;
24 import java.util.List;
26 import java.util.Objects;
27 import java.util.Optional;
29 import java.util.concurrent.ExecutionException;
30 import java.util.concurrent.ScheduledFuture;
31 import java.util.concurrent.TimeUnit;
32 import java.util.concurrent.TimeoutException;
33 import java.util.stream.Collectors;
34 import java.util.stream.Stream;
36 import javax.xml.soap.SOAPException;
37 import javax.xml.soap.SOAPMessage;
39 import org.eclipse.jdt.annotation.NonNullByDefault;
40 import org.eclipse.jdt.annotation.Nullable;
41 import org.eclipse.jetty.client.HttpClient;
42 import org.eclipse.jetty.client.api.Authentication;
43 import org.eclipse.jetty.client.api.AuthenticationStore;
44 import org.eclipse.jetty.client.api.ContentResponse;
45 import org.eclipse.jetty.client.util.DigestAuthentication;
46 import org.openhab.binding.tr064.internal.config.Tr064ChannelConfig;
47 import org.openhab.binding.tr064.internal.config.Tr064RootConfiguration;
48 import org.openhab.binding.tr064.internal.dto.scpd.root.SCPDDeviceType;
49 import org.openhab.binding.tr064.internal.dto.scpd.root.SCPDServiceType;
50 import org.openhab.binding.tr064.internal.dto.scpd.service.SCPDActionType;
51 import org.openhab.binding.tr064.internal.phonebook.Phonebook;
52 import org.openhab.binding.tr064.internal.phonebook.PhonebookProvider;
53 import org.openhab.binding.tr064.internal.phonebook.Tr064PhonebookImpl;
54 import org.openhab.binding.tr064.internal.soap.SOAPConnector;
55 import org.openhab.binding.tr064.internal.soap.SOAPRequest;
56 import org.openhab.binding.tr064.internal.soap.SOAPValueConverter;
57 import org.openhab.binding.tr064.internal.util.SCPDUtil;
58 import org.openhab.binding.tr064.internal.util.Util;
59 import org.openhab.core.cache.ExpiringCacheMap;
60 import org.openhab.core.thing.Bridge;
61 import org.openhab.core.thing.ChannelUID;
62 import org.openhab.core.thing.ThingStatus;
63 import org.openhab.core.thing.ThingStatusDetail;
64 import org.openhab.core.thing.ThingTypeUID;
65 import org.openhab.core.thing.ThingUID;
66 import org.openhab.core.thing.binding.BaseBridgeHandler;
67 import org.openhab.core.thing.binding.ThingHandlerCallback;
68 import org.openhab.core.thing.binding.ThingHandlerService;
69 import org.openhab.core.thing.binding.builder.ThingBuilder;
70 import org.openhab.core.types.Command;
71 import org.openhab.core.types.RefreshType;
72 import org.openhab.core.types.State;
73 import org.slf4j.Logger;
74 import org.slf4j.LoggerFactory;
77 * The {@link Tr064RootHandler} is responsible for handling commands, which are
78 * sent to one of the channels and update channel values
80 * @author Jan N. Klug - Initial contribution
83 public class Tr064RootHandler extends BaseBridgeHandler implements PhonebookProvider {
84 public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES = Set.of(THING_TYPE_GENERIC, THING_TYPE_FRITZBOX);
85 private static final int RETRY_INTERVAL = 60;
86 private static final Set<String> PROPERTY_ARGUMENTS = Set.of("NewSerialNumber", "NewSoftwareVersion",
89 private final Logger logger = LoggerFactory.getLogger(Tr064RootHandler.class);
90 private final HttpClient httpClient;
92 private @Nullable SCPDUtil scpdUtil;
93 private SOAPConnector soapConnector;
95 // these are set when the config is available
96 private Tr064RootConfiguration config = new Tr064RootConfiguration();
97 private String endpointBaseURL = "";
98 private int timeout = Tr064RootConfiguration.DEFAULT_HTTP_TIMEOUT;
100 private String deviceType = "";
102 private final Map<ChannelUID, Tr064ChannelConfig> channels = new HashMap<>();
103 // caching is used to prevent excessive calls to the same action
104 private final ExpiringCacheMap<ChannelUID, State> stateCache = new ExpiringCacheMap<>(Duration.ofMillis(2000));
105 private Collection<Phonebook> phonebooks = List.of();
107 private @Nullable ScheduledFuture<?> connectFuture;
108 private @Nullable ScheduledFuture<?> pollFuture;
109 private @Nullable ScheduledFuture<?> phonebookFuture;
111 private boolean communicationEstablished = false;
113 Tr064RootHandler(Bridge bridge, HttpClient httpClient) {
115 this.httpClient = httpClient;
116 this.soapConnector = new SOAPConnector(httpClient, endpointBaseURL, timeout);
120 public void handleCommand(ChannelUID channelUID, Command command) {
121 if (!communicationEstablished) {
122 logger.debug("Tried to process command, but thing is not yet ready: {} to {}", channelUID, command);
124 Tr064ChannelConfig channelConfig = channels.get(channelUID);
125 if (channelConfig == null) {
126 logger.trace("Channel {} not supported.", channelUID);
130 if (command instanceof RefreshType) {
131 SOAPConnector soapConnector = this.soapConnector;
132 State state = stateCache.putIfAbsentAndGet(channelUID,
133 () -> soapConnector.getChannelStateFromDevice(channelConfig, channels, stateCache));
135 updateState(channelUID, state);
140 if (channelConfig.getChannelTypeDescription().getSetAction() == null) {
141 logger.debug("Discarding command {} to {}, read-only channel", command, channelUID);
144 scheduler.execute(() -> soapConnector.sendChannelCommandToDevice(channelConfig, command));
148 public void initialize() {
149 config = getConfigAs(Tr064RootConfiguration.class);
150 if (!config.isValid()) {
151 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
152 "At least one mandatory configuration field is empty");
156 endpointBaseURL = "http://" + config.host + ":49000";
157 soapConnector = new SOAPConnector(httpClient, endpointBaseURL, timeout);
158 timeout = config.timeout;
159 updateStatus(ThingStatus.UNKNOWN);
161 connectFuture = scheduler.scheduleWithFixedDelay(this::internalInitialize, 0, RETRY_INTERVAL, TimeUnit.SECONDS);
165 * internal thing initializer (sets SCPDUtil and connects to remote device)
167 private void internalInitialize() {
169 scpdUtil = new SCPDUtil(httpClient, endpointBaseURL, timeout);
170 } catch (SCPDException e) {
171 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
172 "could not get device definitions from " + config.host);
176 if (establishSecureConnectionAndUpdateProperties()) {
177 removeConnectScheduler();
179 // connection successful, check channels
180 ThingBuilder thingBuilder = editThing();
181 thingBuilder.withoutChannels(thing.getChannels());
182 final SCPDUtil scpdUtil = this.scpdUtil;
183 final ThingHandlerCallback callback = getCallback();
184 if (scpdUtil != null && callback != null) {
185 Util.checkAvailableChannels(thing, callback, thingBuilder, scpdUtil, "", deviceType, channels);
186 updateThing(thingBuilder.build());
189 communicationEstablished = true;
191 updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE);
195 private void removeConnectScheduler() {
196 final ScheduledFuture<?> connectFuture = this.connectFuture;
197 if (connectFuture != null) {
198 connectFuture.cancel(true);
199 this.connectFuture = null;
204 public void dispose() {
205 communicationEstablished = false;
206 removeConnectScheduler();
213 * poll remote device for channel values
215 private void poll() {
217 channels.forEach((channelUID, channelConfig) -> {
218 if (isLinked(channelUID)) {
219 State state = stateCache.putIfAbsentAndGet(channelUID,
220 () -> soapConnector.getChannelStateFromDevice(channelConfig, channels, stateCache));
222 updateState(channelUID, state);
226 } catch (RuntimeException e) {
227 logger.warn("Exception while refreshing remote data for thing '{}':", thing.getUID(), e);
228 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
229 "Refresh exception: " + e.getMessage());
234 * establish the connection - get secure port (if available), install authentication, get device properties
236 * @return true if successful
238 private boolean establishSecureConnectionAndUpdateProperties() {
239 final SCPDUtil scpdUtil = this.scpdUtil;
240 if (scpdUtil != null) {
242 SCPDDeviceType device = scpdUtil.getDevice("")
243 .orElseThrow(() -> new SCPDException("Root device not found"));
244 SCPDServiceType deviceService = device.getServiceList().stream()
245 .filter(service -> service.getServiceId().equals("urn:DeviceInfo-com:serviceId:DeviceInfo1"))
246 .findFirst().orElseThrow(() -> new SCPDException(
247 "service 'urn:DeviceInfo-com:serviceId:DeviceInfo1' not found"));
249 this.deviceType = device.getDeviceType();
251 // try to get security (https) port
252 SOAPMessage soapResponse = soapConnector
253 .doSOAPRequest(new SOAPRequest(deviceService, "GetSecurityPort"));
254 if (!soapResponse.getSOAPBody().hasFault()) {
255 SOAPValueConverter soapValueConverter = new SOAPValueConverter(httpClient, timeout);
256 soapValueConverter.getStateFromSOAPValue(soapResponse, "NewSecurityPort", null)
257 .ifPresentOrElse(port -> {
258 endpointBaseURL = "https://" + config.host + ":" + port;
259 soapConnector = new SOAPConnector(httpClient, endpointBaseURL, timeout);
260 logger.debug("endpointBaseURL is now '{}'", endpointBaseURL);
261 }, () -> logger.warn("Could not determine secure port, disabling https"));
263 logger.warn("Could not determine secure port, disabling https");
266 // clear auth cache and force re-auth
267 AuthenticationStore authStore = httpClient.getAuthenticationStore();
268 URI endpointUri = URI.create(endpointBaseURL);
269 Authentication authentication = authStore.findAuthentication("Digest", endpointUri,
270 Authentication.ANY_REALM);
271 if (authentication != null) {
272 authStore.removeAuthentication(authentication);
274 Authentication.Result authResult = authStore.findAuthenticationResult(endpointUri);
275 if (authResult != null) {
276 authStore.removeAuthenticationResult(authResult);
278 authStore.addAuthentication(new DigestAuthentication(new URI(endpointBaseURL), Authentication.ANY_REALM,
279 config.user, config.password));
281 // check & update properties
282 SCPDActionType getInfoAction = scpdUtil.getService(deviceService.getServiceId())
283 .orElseThrow(() -> new SCPDException(
284 "Could not get service definition for 'urn:DeviceInfo-com:serviceId:DeviceInfo1'"))
285 .getActionList().stream().filter(action -> action.getName().equals("GetInfo")).findFirst()
286 .orElseThrow(() -> new SCPDException("Action 'GetInfo' not found"));
287 SOAPMessage soapResponse1 = soapConnector
288 .doSOAPRequest(new SOAPRequest(deviceService, getInfoAction.getName()));
289 SOAPValueConverter soapValueConverter = new SOAPValueConverter(httpClient, timeout);
290 Map<String, String> properties = editProperties();
291 PROPERTY_ARGUMENTS.forEach(argumentName -> getInfoAction.getArgumentList().stream()
292 .filter(argument -> argument.getName().equals(argumentName)).findFirst()
293 .ifPresent(argument -> soapValueConverter
294 .getStateFromSOAPValue(soapResponse1, argumentName, null).ifPresent(value -> properties
295 .put(argument.getRelatedStateVariable(), value.toString()))));
296 properties.put("deviceType", device.getDeviceType());
297 updateProperties(properties);
300 } catch (SCPDException | SOAPException | Tr064CommunicationException | URISyntaxException e) {
301 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
309 * get all sub devices of this root device (used for discovery)
313 public List<SCPDDeviceType> getAllSubDevices() {
314 final SCPDUtil scpdUtil = this.scpdUtil;
315 return (scpdUtil == null) ? List.of() : scpdUtil.getAllSubDevices();
319 * get the SOAP connector (used by sub devices for communication with the remote device)
321 * @return the SOAP connector
323 public SOAPConnector getSOAPConnector() {
324 return soapConnector;
328 * return the result of an (authenticated) GET request
330 * @param url the requested URL
332 * @return a {@link ContentResponse} with the result of the request
333 * @throws ExecutionException
334 * @throws InterruptedException
335 * @throws TimeoutException
337 public ContentResponse getUrl(String url) throws ExecutionException, InterruptedException, TimeoutException {
338 httpClient.getAuthenticationStore().addAuthentication(
339 new DigestAuthentication(URI.create(url), Authentication.ANY_REALM, config.user, config.password));
340 return httpClient.GET(URI.create(url));
344 * get the SCPD processing utility
346 * @return the SCPD utility (or null if not available)
348 public @Nullable SCPDUtil getSCPDUtil() {
353 * uninstall the polling
355 private void uninstallPolling() {
356 final ScheduledFuture<?> pollFuture = this.pollFuture;
357 if (pollFuture != null) {
358 pollFuture.cancel(true);
359 this.pollFuture = null;
361 final ScheduledFuture<?> phonebookFuture = this.phonebookFuture;
362 if (phonebookFuture != null) {
363 phonebookFuture.cancel(true);
364 this.phonebookFuture = null;
369 * install the polling
371 private void installPolling() {
373 pollFuture = scheduler.scheduleWithFixedDelay(this::poll, 0, config.refresh, TimeUnit.SECONDS);
374 if (config.phonebookInterval > 0) {
375 phonebookFuture = scheduler.scheduleWithFixedDelay(this::retrievePhonebooks, 0, config.phonebookInterval,
380 @SuppressWarnings("unchecked")
381 private Collection<Phonebook> processPhonebookList(SOAPMessage soapMessagePhonebookList,
382 SCPDServiceType scpdService) {
383 SOAPValueConverter soapValueConverter = new SOAPValueConverter(httpClient, timeout);
384 Optional<Stream<String>> phonebookStream = soapValueConverter
385 .getStateFromSOAPValue(soapMessagePhonebookList, "NewPhonebookList", null)
386 .map(phonebookList -> Arrays.stream(phonebookList.toString().split(",")));
387 return phonebookStream.map(stringStream -> (Collection<Phonebook>) stringStream.map(index -> {
389 SOAPMessage soapMessageURL = soapConnector
390 .doSOAPRequest(new SOAPRequest(scpdService, "GetPhonebook", Map.of("NewPhonebookID", index)));
391 return soapValueConverter.getStateFromSOAPValue(soapMessageURL, "NewPhonebookURL", null)
392 .map(url -> (Phonebook) new Tr064PhonebookImpl(httpClient, url.toString(), timeout));
393 } catch (Tr064CommunicationException e) {
394 logger.warn("Failed to get phonebook with index {}:", index, e);
396 return Optional.empty();
397 }).filter(Optional::isPresent).map(Optional::get).collect(Collectors.toList())).orElseGet(Set::of);
400 private void retrievePhonebooks() {
401 String serviceId = "urn:X_AVM-DE_OnTel-com:serviceId:X_AVM-DE_OnTel1";
402 SCPDUtil scpdUtil = this.scpdUtil;
403 if (scpdUtil == null) {
404 logger.warn("Cannot find SCPDUtil. This is most likely a programming error.");
407 Optional<SCPDServiceType> scpdService = scpdUtil.getDevice("").flatMap(deviceType -> deviceType.getServiceList()
408 .stream().filter(service -> service.getServiceId().equals(serviceId)).findFirst());
410 phonebooks = Objects.requireNonNull(scpdService.map(service -> {
412 return processPhonebookList(soapConnector.doSOAPRequest(new SOAPRequest(service, "GetPhonebookList")),
414 } catch (Tr064CommunicationException e) {
415 return Collections.<Phonebook> emptyList();
417 }).orElse(List.of()));
419 if (phonebooks.isEmpty()) {
420 logger.warn("Could not get phonebooks for thing {}", thing.getUID());
425 public Optional<Phonebook> getPhonebookByName(String name) {
426 return phonebooks.stream().filter(p -> name.equals(p.getName())).findAny();
430 public Collection<Phonebook> getPhonebooks() {
435 public ThingUID getUID() {
436 return thing.getUID();
440 public String getFriendlyName() {
441 String friendlyName = thing.getLabel();
442 return friendlyName != null ? friendlyName : getUID().getId();
446 public Collection<Class<? extends ThingHandlerService>> getServices() {
447 if (THING_TYPE_FRITZBOX.equals(thing.getThingTypeUID())) {
448 return Set.of(Tr064DiscoveryService.class, FritzboxActions.class);
450 return Set.of(Tr064DiscoveryService.class);
455 * get the backup configuration for this thing (only applies to FritzBox devices
457 * @return the configuration
459 public FritzboxActions.BackupConfiguration getBackupConfiguration() {
460 return new FritzboxActions.BackupConfiguration(config.backupDirectory,
461 Objects.requireNonNullElse(config.backupPassword, config.password));