]> git.basschouten.com Git - openhab-addons.git/blob
17c42a2f2206c771632abcf342f0c18642523a4e
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.tr064.internal;
14
15 import static org.openhab.binding.tr064.internal.Tr064BindingConstants.*;
16
17 import java.net.URI;
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;
25 import java.util.Map;
26 import java.util.Objects;
27 import java.util.Optional;
28 import java.util.Set;
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;
35
36 import javax.xml.soap.SOAPException;
37 import javax.xml.soap.SOAPMessage;
38
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;
75
76 /**
77  * The {@link Tr064RootHandler} is responsible for handling commands, which are
78  * sent to one of the channels and update channel values
79  *
80  * @author Jan N. Klug - Initial contribution
81  */
82 @NonNullByDefault
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",
87             "NewModelName");
88
89     private final Logger logger = LoggerFactory.getLogger(Tr064RootHandler.class);
90     private final HttpClient httpClient;
91
92     private @Nullable SCPDUtil scpdUtil;
93     private SOAPConnector soapConnector;
94
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;
99
100     private String deviceType = "";
101
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();
106
107     private @Nullable ScheduledFuture<?> connectFuture;
108     private @Nullable ScheduledFuture<?> pollFuture;
109     private @Nullable ScheduledFuture<?> phonebookFuture;
110
111     private boolean communicationEstablished = false;
112
113     Tr064RootHandler(Bridge bridge, HttpClient httpClient) {
114         super(bridge);
115         this.httpClient = httpClient;
116         this.soapConnector = new SOAPConnector(httpClient, endpointBaseURL, timeout);
117     }
118
119     @Override
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);
123         }
124         Tr064ChannelConfig channelConfig = channels.get(channelUID);
125         if (channelConfig == null) {
126             logger.trace("Channel {} not supported.", channelUID);
127             return;
128         }
129
130         if (command instanceof RefreshType) {
131             SOAPConnector soapConnector = this.soapConnector;
132             State state = stateCache.putIfAbsentAndGet(channelUID,
133                     () -> soapConnector.getChannelStateFromDevice(channelConfig, channels, stateCache));
134             if (state != null) {
135                 updateState(channelUID, state);
136             }
137             return;
138         }
139
140         if (channelConfig.getChannelTypeDescription().getSetAction() == null) {
141             logger.debug("Discarding command {} to {}, read-only channel", command, channelUID);
142             return;
143         }
144         scheduler.execute(() -> soapConnector.sendChannelCommandToDevice(channelConfig, command));
145     }
146
147     @Override
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");
153             return;
154         }
155
156         endpointBaseURL = "http://" + config.host + ":49000";
157         soapConnector = new SOAPConnector(httpClient, endpointBaseURL, timeout);
158         timeout = config.timeout;
159         updateStatus(ThingStatus.UNKNOWN);
160
161         connectFuture = scheduler.scheduleWithFixedDelay(this::internalInitialize, 0, RETRY_INTERVAL, TimeUnit.SECONDS);
162     }
163
164     /**
165      * internal thing initializer (sets SCPDUtil and connects to remote device)
166      */
167     private void internalInitialize() {
168         try {
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);
173             return;
174         }
175
176         if (establishSecureConnectionAndUpdateProperties()) {
177             removeConnectScheduler();
178
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());
187             }
188
189             communicationEstablished = true;
190             installPolling();
191             updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE);
192         }
193     }
194
195     private void removeConnectScheduler() {
196         final ScheduledFuture<?> connectFuture = this.connectFuture;
197         if (connectFuture != null) {
198             connectFuture.cancel(true);
199             this.connectFuture = null;
200         }
201     }
202
203     @Override
204     public void dispose() {
205         communicationEstablished = false;
206         removeConnectScheduler();
207         uninstallPolling();
208         stateCache.clear();
209         scpdUtil = null;
210     }
211
212     /**
213      * poll remote device for channel values
214      */
215     private void poll() {
216         try {
217             channels.forEach((channelUID, channelConfig) -> {
218                 if (isLinked(channelUID)) {
219                     State state = stateCache.putIfAbsentAndGet(channelUID,
220                             () -> soapConnector.getChannelStateFromDevice(channelConfig, channels, stateCache));
221                     if (state != null) {
222                         updateState(channelUID, state);
223                     }
224                 }
225             });
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());
230         }
231     }
232
233     /**
234      * establish the connection - get secure port (if available), install authentication, get device properties
235      *
236      * @return true if successful
237      */
238     private boolean establishSecureConnectionAndUpdateProperties() {
239         final SCPDUtil scpdUtil = this.scpdUtil;
240         if (scpdUtil != null) {
241             try {
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"));
248
249                 this.deviceType = device.getDeviceType();
250
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"));
262                 } else {
263                     logger.warn("Could not determine secure port, disabling https");
264                 }
265
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);
273                 }
274                 Authentication.Result authResult = authStore.findAuthenticationResult(endpointUri);
275                 if (authResult != null) {
276                     authStore.removeAuthenticationResult(authResult);
277                 }
278                 authStore.addAuthentication(new DigestAuthentication(new URI(endpointBaseURL), Authentication.ANY_REALM,
279                         config.user, config.password));
280
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);
298
299                 return true;
300             } catch (SCPDException | SOAPException | Tr064CommunicationException | URISyntaxException e) {
301                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
302                 return false;
303             }
304         }
305         return false;
306     }
307
308     /**
309      * get all sub devices of this root device (used for discovery)
310      *
311      * @return the list
312      */
313     public List<SCPDDeviceType> getAllSubDevices() {
314         final SCPDUtil scpdUtil = this.scpdUtil;
315         return (scpdUtil == null) ? List.of() : scpdUtil.getAllSubDevices();
316     }
317
318     /**
319      * get the SOAP connector (used by sub devices for communication with the remote device)
320      *
321      * @return the SOAP connector
322      */
323     public SOAPConnector getSOAPConnector() {
324         return soapConnector;
325     }
326
327     /**
328      * return the result of an (authenticated) GET request
329      *
330      * @param url the requested URL
331      *
332      * @return a {@link ContentResponse} with the result of the request
333      * @throws ExecutionException
334      * @throws InterruptedException
335      * @throws TimeoutException
336      */
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));
341     }
342
343     /**
344      * get the SCPD processing utility
345      *
346      * @return the SCPD utility (or null if not available)
347      */
348     public @Nullable SCPDUtil getSCPDUtil() {
349         return scpdUtil;
350     }
351
352     /**
353      * uninstall the polling
354      */
355     private void uninstallPolling() {
356         final ScheduledFuture<?> pollFuture = this.pollFuture;
357         if (pollFuture != null) {
358             pollFuture.cancel(true);
359             this.pollFuture = null;
360         }
361         final ScheduledFuture<?> phonebookFuture = this.phonebookFuture;
362         if (phonebookFuture != null) {
363             phonebookFuture.cancel(true);
364             this.phonebookFuture = null;
365         }
366     }
367
368     /**
369      * install the polling
370      */
371     private void installPolling() {
372         uninstallPolling();
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,
376                     TimeUnit.SECONDS);
377         }
378     }
379
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 -> {
388             try {
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);
395             }
396             return Optional.empty();
397         }).filter(Optional::isPresent).map(Optional::get).collect(Collectors.toList())).orElseGet(Set::of);
398     }
399
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.");
405             return;
406         }
407         Optional<SCPDServiceType> scpdService = scpdUtil.getDevice("").flatMap(deviceType -> deviceType.getServiceList()
408                 .stream().filter(service -> service.getServiceId().equals(serviceId)).findFirst());
409
410         phonebooks = Objects.requireNonNull(scpdService.map(service -> {
411             try {
412                 return processPhonebookList(soapConnector.doSOAPRequest(new SOAPRequest(service, "GetPhonebookList")),
413                         service);
414             } catch (Tr064CommunicationException e) {
415                 return Collections.<Phonebook> emptyList();
416             }
417         }).orElse(List.of()));
418
419         if (phonebooks.isEmpty()) {
420             logger.warn("Could not get phonebooks for thing {}", thing.getUID());
421         }
422     }
423
424     @Override
425     public Optional<Phonebook> getPhonebookByName(String name) {
426         return phonebooks.stream().filter(p -> name.equals(p.getName())).findAny();
427     }
428
429     @Override
430     public Collection<Phonebook> getPhonebooks() {
431         return phonebooks;
432     }
433
434     @Override
435     public ThingUID getUID() {
436         return thing.getUID();
437     }
438
439     @Override
440     public String getFriendlyName() {
441         String friendlyName = thing.getLabel();
442         return friendlyName != null ? friendlyName : getUID().getId();
443     }
444
445     @Override
446     public Collection<Class<? extends ThingHandlerService>> getServices() {
447         if (THING_TYPE_FRITZBOX.equals(thing.getThingTypeUID())) {
448             return Set.of(Tr064DiscoveryService.class, FritzboxActions.class);
449         } else {
450             return Set.of(Tr064DiscoveryService.class);
451         }
452     }
453
454     /**
455      * get the backup configuration for this thing (only applies to FritzBox devices
456      *
457      * @return the configuration
458      */
459     public FritzboxActions.BackupConfiguration getBackupConfiguration() {
460         return new FritzboxActions.BackupConfiguration(config.backupDirectory,
461                 Objects.requireNonNullElse(config.backupPassword, config.password));
462     }
463 }