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();
215 * poll remote device for channel values
217 private void poll() {
219 channels.forEach((channelUID, channelConfig) -> {
220 if (isLinked(channelUID)) {
221 State state = stateCache.putIfAbsentAndGet(channelUID,
222 () -> soapConnector.getChannelStateFromDevice(channelConfig, channels, stateCache));
224 updateState(channelUID, state);
228 } catch (RuntimeException e) {
229 logger.warn("Exception while refreshing remote data for thing '{}':", thing.getUID(), e);
230 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
231 "Refresh exception: " + e.getMessage());
236 * establish the connection - get secure port (if available), install authentication, get device properties
238 * @return true if successful
240 private boolean establishSecureConnectionAndUpdateProperties() {
241 final SCPDUtil scpdUtil = this.scpdUtil;
242 if (scpdUtil != null) {
244 SCPDDeviceType device = scpdUtil.getDevice("")
245 .orElseThrow(() -> new SCPDException("Root device not found"));
246 SCPDServiceType deviceService = device.getServiceList().stream()
247 .filter(service -> service.getServiceId().equals("urn:DeviceInfo-com:serviceId:DeviceInfo1"))
248 .findFirst().orElseThrow(() -> new SCPDException(
249 "service 'urn:DeviceInfo-com:serviceId:DeviceInfo1' not found"));
251 this.deviceType = device.getDeviceType();
253 // try to get security (https) port
254 SOAPMessage soapResponse = soapConnector
255 .doSOAPRequest(new SOAPRequest(deviceService, "GetSecurityPort"));
256 if (!soapResponse.getSOAPBody().hasFault()) {
257 SOAPValueConverter soapValueConverter = new SOAPValueConverter(httpClient, timeout);
258 soapValueConverter.getStateFromSOAPValue(soapResponse, "NewSecurityPort", null)
259 .ifPresentOrElse(port -> {
260 endpointBaseURL = "https://" + config.host + ":" + port;
261 soapConnector = new SOAPConnector(httpClient, endpointBaseURL, timeout);
262 logger.debug("endpointBaseURL is now '{}'", endpointBaseURL);
263 }, () -> logger.warn("Could not determine secure port, disabling https"));
265 logger.warn("Could not determine secure port, disabling https");
268 // clear auth cache and force re-auth
269 AuthenticationStore authStore = httpClient.getAuthenticationStore();
270 authStore.clearAuthentications();
271 authStore.clearAuthenticationResults();
272 authStore.addAuthentication(new DigestAuthentication(new URI(endpointBaseURL), Authentication.ANY_REALM,
273 config.user, config.password));
275 // check & update properties
276 SCPDActionType getInfoAction = scpdUtil.getService(deviceService.getServiceId())
277 .orElseThrow(() -> new SCPDException(
278 "Could not get service definition for 'urn:DeviceInfo-com:serviceId:DeviceInfo1'"))
279 .getActionList().stream().filter(action -> action.getName().equals("GetInfo")).findFirst()
280 .orElseThrow(() -> new SCPDException("Action 'GetInfo' not found"));
281 SOAPMessage soapResponse1 = soapConnector
282 .doSOAPRequest(new SOAPRequest(deviceService, getInfoAction.getName()));
283 SOAPValueConverter soapValueConverter = new SOAPValueConverter(httpClient, timeout);
284 Map<String, String> properties = editProperties();
285 PROPERTY_ARGUMENTS.forEach(argumentName -> getInfoAction.getArgumentList().stream()
286 .filter(argument -> argument.getName().equals(argumentName)).findFirst()
287 .ifPresent(argument -> soapValueConverter
288 .getStateFromSOAPValue(soapResponse1, argumentName, null).ifPresent(value -> properties
289 .put(argument.getRelatedStateVariable(), value.toString()))));
290 properties.put("deviceType", device.getDeviceType());
291 updateProperties(properties);
294 } catch (SCPDException | SOAPException | Tr064CommunicationException | URISyntaxException e) {
295 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
303 * get all sub devices of this root device (used for discovery)
307 public List<SCPDDeviceType> getAllSubDevices() {
308 final SCPDUtil scpdUtil = this.scpdUtil;
309 return (scpdUtil == null) ? List.of() : scpdUtil.getAllSubDevices();
313 * get the SOAP connector (used by sub devices for communication with the remote device)
315 * @return the SOAP connector
317 public SOAPConnector getSOAPConnector() {
318 return soapConnector;
322 * return the result of an (authenticated) GET request
324 * @param url the requested URL
326 * @return a {@link ContentResponse} with the result of the request
327 * @throws ExecutionException
328 * @throws InterruptedException
329 * @throws TimeoutException
331 public ContentResponse getUrl(String url) throws ExecutionException, InterruptedException, TimeoutException {
332 httpClient.getAuthenticationStore().addAuthentication(
333 new DigestAuthentication(URI.create(url), Authentication.ANY_REALM, config.user, config.password));
334 return httpClient.GET(URI.create(url));
338 * get the SCPD processing utility
340 * @return the SCPD utility (or null if not available)
342 public @Nullable SCPDUtil getSCPDUtil() {
347 * uninstall the polling
349 private void uninstallPolling() {
350 final ScheduledFuture<?> pollFuture = this.pollFuture;
351 if (pollFuture != null) {
352 pollFuture.cancel(true);
353 this.pollFuture = null;
355 final ScheduledFuture<?> phonebookFuture = this.phonebookFuture;
356 if (phonebookFuture != null) {
357 phonebookFuture.cancel(true);
358 this.phonebookFuture = null;
363 * install the polling
365 private void installPolling() {
367 pollFuture = scheduler.scheduleWithFixedDelay(this::poll, 0, config.refresh, TimeUnit.SECONDS);
368 if (config.phonebookInterval > 0) {
369 phonebookFuture = scheduler.scheduleWithFixedDelay(this::retrievePhonebooks, 0, config.phonebookInterval,
374 @SuppressWarnings("unchecked")
375 private Collection<Phonebook> processPhonebookList(SOAPMessage soapMessagePhonebookList,
376 SCPDServiceType scpdService) {
377 SOAPValueConverter soapValueConverter = new SOAPValueConverter(httpClient, timeout);
378 Optional<Stream<String>> phonebookStream = soapValueConverter
379 .getStateFromSOAPValue(soapMessagePhonebookList, "NewPhonebookList", null)
380 .map(phonebookList -> Arrays.stream(phonebookList.toString().split(",")));
381 return phonebookStream.map(stringStream -> (Collection<Phonebook>) stringStream.map(index -> {
383 SOAPMessage soapMessageURL = soapConnector
384 .doSOAPRequest(new SOAPRequest(scpdService, "GetPhonebook", Map.of("NewPhonebookID", index)));
385 return soapValueConverter.getStateFromSOAPValue(soapMessageURL, "NewPhonebookURL", null)
386 .map(url -> (Phonebook) new Tr064PhonebookImpl(httpClient, url.toString(), timeout));
387 } catch (Tr064CommunicationException e) {
388 logger.warn("Failed to get phonebook with index {}:", index, e);
390 return Optional.empty();
391 }).filter(Optional::isPresent).map(Optional::get).collect(Collectors.toList())).orElseGet(Set::of);
394 private void retrievePhonebooks() {
395 String serviceId = "urn:X_AVM-DE_OnTel-com:serviceId:X_AVM-DE_OnTel1";
396 SCPDUtil scpdUtil = this.scpdUtil;
397 if (scpdUtil == null) {
398 logger.warn("Cannot find SCPDUtil. This is most likely a programming error.");
401 Optional<SCPDServiceType> scpdService = scpdUtil.getDevice("").flatMap(deviceType -> deviceType.getServiceList()
402 .stream().filter(service -> service.getServiceId().equals(serviceId)).findFirst());
404 phonebooks = Objects.requireNonNull(scpdService.map(service -> {
406 return processPhonebookList(soapConnector.doSOAPRequest(new SOAPRequest(service, "GetPhonebookList")),
408 } catch (Tr064CommunicationException e) {
409 return Collections.<Phonebook> emptyList();
411 }).orElse(List.of()));
413 if (phonebooks.isEmpty()) {
414 logger.warn("Could not get phonebooks for thing {}", thing.getUID());
419 public Optional<Phonebook> getPhonebookByName(String name) {
420 return phonebooks.stream().filter(p -> name.equals(p.getName())).findAny();
424 public Collection<Phonebook> getPhonebooks() {
429 public ThingUID getUID() {
430 return thing.getUID();
434 public String getFriendlyName() {
435 String friendlyName = thing.getLabel();
436 return friendlyName != null ? friendlyName : getUID().getId();
440 public Collection<Class<? extends ThingHandlerService>> getServices() {
441 if (THING_TYPE_FRITZBOX.equals(thing.getThingTypeUID())) {
442 return Set.of(Tr064DiscoveryService.class, FritzboxActions.class);
444 return Set.of(Tr064DiscoveryService.class);
449 * get the backup configuration for this thing (only applies to FritzBox devices
451 * @return the configuration
453 public FritzboxActions.BackupConfiguration getBackupConfiguration() {
454 return new FritzboxActions.BackupConfiguration(config.backupDirectory,
455 Objects.requireNonNullElse(config.backupPassword, config.password));