]> git.basschouten.com Git - openhab-addons.git/blob
3d796348edc41b404c67f47b511481dbde72083e
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2024 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.ipcamera.internal.onvif;
14
15 import static org.openhab.binding.ipcamera.internal.IpCameraBindingConstants.*;
16
17 import java.net.ConnectException;
18 import java.net.InetSocketAddress;
19 import java.nio.charset.StandardCharsets;
20 import java.security.MessageDigest;
21 import java.security.NoSuchAlgorithmException;
22 import java.security.SecureRandom;
23 import java.text.ParseException;
24 import java.text.SimpleDateFormat;
25 import java.util.ArrayList;
26 import java.util.Base64;
27 import java.util.Date;
28 import java.util.LinkedList;
29 import java.util.List;
30 import java.util.Random;
31 import java.util.TimeZone;
32 import java.util.concurrent.Executors;
33 import java.util.concurrent.ScheduledExecutorService;
34 import java.util.concurrent.TimeUnit;
35 import java.util.concurrent.atomic.AtomicInteger;
36 import java.util.concurrent.locks.ReentrantLock;
37
38 import org.eclipse.jdt.annotation.NonNullByDefault;
39 import org.eclipse.jdt.annotation.Nullable;
40 import org.openhab.binding.ipcamera.internal.Helper;
41 import org.openhab.binding.ipcamera.internal.handler.IpCameraHandler;
42 import org.openhab.core.library.types.OnOffType;
43 import org.openhab.core.thing.ChannelUID;
44 import org.openhab.core.types.StateOption;
45 import org.openhab.core.util.StringUtils;
46 import org.slf4j.Logger;
47 import org.slf4j.LoggerFactory;
48
49 import io.netty.bootstrap.Bootstrap;
50 import io.netty.buffer.ByteBuf;
51 import io.netty.buffer.Unpooled;
52 import io.netty.channel.Channel;
53 import io.netty.channel.ChannelFuture;
54 import io.netty.channel.ChannelFutureListener;
55 import io.netty.channel.ChannelInitializer;
56 import io.netty.channel.ChannelOption;
57 import io.netty.channel.ConnectTimeoutException;
58 import io.netty.channel.EventLoopGroup;
59 import io.netty.channel.nio.NioEventLoopGroup;
60 import io.netty.channel.socket.SocketChannel;
61 import io.netty.channel.socket.nio.NioSocketChannel;
62 import io.netty.handler.codec.http.DefaultFullHttpRequest;
63 import io.netty.handler.codec.http.FullHttpRequest;
64 import io.netty.handler.codec.http.HttpClientCodec;
65 import io.netty.handler.codec.http.HttpHeaderValues;
66 import io.netty.handler.codec.http.HttpMethod;
67 import io.netty.handler.codec.http.HttpVersion;
68 import io.netty.handler.timeout.IdleStateHandler;
69
70 /**
71  * The {@link OnvifConnection} This is a basic Netty implementation for connecting and communicating to ONVIF cameras.
72  *
73  * @author Matthew Skinner - Initial contribution
74  * @author Kai Kreuzer - Improve handling for certain cameras
75  */
76
77 @NonNullByDefault
78 public class OnvifConnection {
79     public enum RequestType {
80         AbsoluteMove,
81         AddPTZConfiguration,
82         ContinuousMoveLeft,
83         ContinuousMoveRight,
84         ContinuousMoveUp,
85         ContinuousMoveDown,
86         Stop,
87         ContinuousMoveIn,
88         ContinuousMoveOut,
89         CreatePullPointSubscription,
90         GetCapabilities,
91         GetDeviceInformation,
92         GetProfiles,
93         GetServiceCapabilities,
94         GetSnapshotUri,
95         GetStreamUri,
96         GetSystemDateAndTime,
97         Subscribe,
98         Unsubscribe,
99         PullMessages,
100         GetEventProperties,
101         RelativeMoveLeft,
102         RelativeMoveRight,
103         RelativeMoveUp,
104         RelativeMoveDown,
105         RelativeMoveIn,
106         RelativeMoveOut,
107         Renew,
108         GetConfigurations,
109         GetConfigurationOptions,
110         GetConfiguration,
111         SetConfiguration,
112         GetNodes,
113         GetStatus,
114         GotoPreset,
115         GetPresets
116     }
117
118     private final Logger logger = LoggerFactory.getLogger(getClass());
119     private ScheduledExecutorService threadPool = Executors.newScheduledThreadPool(2);
120     private @Nullable Bootstrap bootstrap;
121     private EventLoopGroup mainEventLoopGroup = new NioEventLoopGroup(2);
122     private ReentrantLock connecting = new ReentrantLock();
123     private String ipAddress = "";
124     private String user = "";
125     private String password = "";
126     private int onvifPort = 80;
127     public String deviceXAddr = "http://" + ipAddress + "/onvif/device_service";
128     private String eventXAddr = "http://" + ipAddress + "/onvif/device_service";
129     private String mediaXAddr = "http://" + ipAddress + "/onvif/device_service";
130     @SuppressWarnings("unused")
131     private String imagingXAddr = "http://" + ipAddress + "/onvif/device_service";
132     private String ptzXAddr = "http://" + ipAddress + "/onvif/ptz_service";
133     public String subscriptionXAddr = "http://" + ipAddress + "/onvif/device_service";
134     public String subscriptionId = "";
135     private boolean isConnected = false;
136     private int mediaProfileIndex = 0;
137     private String rtspUri = "";
138     private IpCameraHandler ipCameraHandler;
139     private boolean supportsEvents = false; // camera has replied that it can do events
140     // Use/skip events even if camera support them. API cameras skip, as their own methods give better results.
141     private boolean usingEvents = false;
142     public AtomicInteger pullMessageRequests = new AtomicInteger();
143
144     // These hold the cameras PTZ position in the range that the camera uses, ie
145     // mine is -1 to +1
146     private Float panRangeMin = -1.0f;
147     private Float panRangeMax = 1.0f;
148     private Float tiltRangeMin = -1.0f;
149     private Float tiltRangeMax = 1.0f;
150     private Float zoomMin = 0.0f;
151     private Float zoomMax = 1.0f;
152     // These hold the PTZ values for updating openHABs controls in 0-100 range
153     private Float currentPanPercentage = 0.0f;
154     private Float currentTiltPercentage = 0.0f;
155     private Float currentZoomPercentage = 0.0f;
156     private Float currentPanCamValue = 0.0f;
157     private Float currentTiltCamValue = 0.0f;
158     private Float currentZoomCamValue = 0.0f;
159     private String ptzNodeToken = "000";
160     private String ptzConfigToken = "000";
161     private int presetTokenIndex = 0;
162     private List<String> presetTokens = new LinkedList<>();
163     private List<String> presetNames = new LinkedList<>();
164     private List<String> mediaProfileTokens = new LinkedList<>();
165     private boolean ptzDevice = true;
166
167     public OnvifConnection(IpCameraHandler ipCameraHandler, String ipAddress, String user, String password) {
168         this.ipCameraHandler = ipCameraHandler;
169         if (!ipAddress.isEmpty()) {
170             this.user = user;
171             this.password = password;
172             getIPandPortFromUrl(ipAddress);
173         }
174     }
175
176     private String getXml(RequestType requestType) {
177         try {
178             switch (requestType) {
179                 case AbsoluteMove:
180                     return "<AbsoluteMove xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><ProfileToken>"
181                             + mediaProfileTokens.get(mediaProfileIndex) + "</ProfileToken><Position><PanTilt x=\""
182                             + currentPanCamValue + "\" y=\"" + currentTiltCamValue
183                             + "\" space=\"http://www.onvif.org/ver10/tptz/PanTiltSpaces/PositionGenericSpace\">\n"
184                             + "</PanTilt>\n" + "<Zoom x=\"" + currentZoomCamValue
185                             + "\" space=\"http://www.onvif.org/ver10/tptz/ZoomSpaces/PositionGenericSpace\">\n"
186                             + "</Zoom>\n" + "</Position>\n"
187                             + "<Speed><PanTilt x=\"0.1\" y=\"0.1\" space=\"http://www.onvif.org/ver10/tptz/PanTiltSpaces/GenericSpeedSpace\"></PanTilt><Zoom x=\"1.0\" space=\"http://www.onvif.org/ver10/tptz/ZoomSpaces/ZoomGenericSpeedSpace\"></Zoom>\n"
188                             + "</Speed></AbsoluteMove>";
189                 case AddPTZConfiguration: // not tested to work yet
190                     return "<AddPTZConfiguration xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><ProfileToken>"
191                             + mediaProfileTokens.get(mediaProfileIndex) + "</ProfileToken><ConfigurationToken>"
192                             + ptzConfigToken + "</ConfigurationToken></AddPTZConfiguration>";
193                 case ContinuousMoveLeft:
194                     return "<ContinuousMove xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><ProfileToken>"
195                             + mediaProfileTokens.get(mediaProfileIndex)
196                             + "</ProfileToken><Velocity><PanTilt x=\"-0.5\" y=\"0\" xmlns=\"http://www.onvif.org/ver10/schema\"/></Velocity></ContinuousMove>";
197                 case ContinuousMoveRight:
198                     return "<ContinuousMove xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><ProfileToken>"
199                             + mediaProfileTokens.get(mediaProfileIndex)
200                             + "</ProfileToken><Velocity><PanTilt x=\"0.5\" y=\"0\" xmlns=\"http://www.onvif.org/ver10/schema\"/></Velocity></ContinuousMove>";
201                 case ContinuousMoveUp:
202                     return "<ContinuousMove xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><ProfileToken>"
203                             + mediaProfileTokens.get(mediaProfileIndex)
204                             + "</ProfileToken><Velocity><PanTilt x=\"0\" y=\"-0.5\" xmlns=\"http://www.onvif.org/ver10/schema\"/></Velocity></ContinuousMove>";
205                 case ContinuousMoveDown:
206                     return "<ContinuousMove xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><ProfileToken>"
207                             + mediaProfileTokens.get(mediaProfileIndex)
208                             + "</ProfileToken><Velocity><PanTilt x=\"0\" y=\"0.5\" xmlns=\"http://www.onvif.org/ver10/schema\"/></Velocity></ContinuousMove>";
209                 case Stop:
210                     return "<Stop xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><ProfileToken>"
211                             + mediaProfileTokens.get(mediaProfileIndex)
212                             + "</ProfileToken><PanTilt>true</PanTilt><Zoom>true</Zoom></Stop>";
213                 case ContinuousMoveIn:
214                     return "<ContinuousMove xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><ProfileToken>"
215                             + mediaProfileTokens.get(mediaProfileIndex)
216                             + "</ProfileToken><Velocity><Zoom x=\"0.5\" xmlns=\"http://www.onvif.org/ver10/schema\"/></Velocity></ContinuousMove>";
217                 case ContinuousMoveOut:
218                     return "<ContinuousMove xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><ProfileToken>"
219                             + mediaProfileTokens.get(mediaProfileIndex)
220                             + "</ProfileToken><Velocity><Zoom x=\"-0.5\" xmlns=\"http://www.onvif.org/ver10/schema\"/></Velocity></ContinuousMove>";
221                 case CreatePullPointSubscription:
222                     return "<CreatePullPointSubscription xmlns=\"http://www.onvif.org/ver10/events/wsdl\"><InitialTerminationTime>PT600S</InitialTerminationTime></CreatePullPointSubscription>";
223                 case GetCapabilities:
224                     return "<GetCapabilities xmlns=\"http://www.onvif.org/ver10/device/wsdl\"><Category>All</Category></GetCapabilities>";
225
226                 case GetDeviceInformation:
227                     return "<GetDeviceInformation xmlns=\"http://www.onvif.org/ver10/device/wsdl\"/>";
228                 case GetProfiles:
229                     return "<GetProfiles xmlns=\"http://www.onvif.org/ver10/media/wsdl\"/>";
230                 case GetServiceCapabilities:
231                     return "<GetServiceCapabilities xmlns=\"http://docs.oasis-open.org/wsn/b-2/\"></GetServiceCapabilities>";
232                 case GetSnapshotUri:
233                     return "<GetSnapshotUri xmlns=\"http://www.onvif.org/ver10/media/wsdl\"><ProfileToken>"
234                             + mediaProfileTokens.get(mediaProfileIndex) + "</ProfileToken></GetSnapshotUri>";
235                 case GetStreamUri:
236                     return "<GetStreamUri xmlns=\"http://www.onvif.org/ver10/media/wsdl\"><StreamSetup><Stream xmlns=\"http://www.onvif.org/ver10/schema\">RTP-Unicast</Stream><Transport xmlns=\"http://www.onvif.org/ver10/schema\"><Protocol>RTSP</Protocol></Transport></StreamSetup><ProfileToken>"
237                             + mediaProfileTokens.get(mediaProfileIndex) + "</ProfileToken></GetStreamUri>";
238                 case GetSystemDateAndTime:
239                     return "<GetSystemDateAndTime xmlns=\"http://www.onvif.org/ver10/device/wsdl\"/>";
240                 case Subscribe:
241                     return "<Subscribe xmlns=\"http://docs.oasis-open.org/wsn/b-2/\"><ConsumerReference><Address>http://"
242                             + ipCameraHandler.hostIp + ":" + SERVLET_PORT + "/ipcamera/"
243                             + ipCameraHandler.getThing().getUID().getId()
244                             + "/OnvifEvent</Address></ConsumerReference></Subscribe>";
245                 case Unsubscribe:
246                     return "<Unsubscribe xmlns=\"http://docs.oasis-open.org/wsn/b-2/\"></Unsubscribe>";
247                 case PullMessages:
248                     return "<PullMessages xmlns=\"http://www.onvif.org/ver10/events/wsdl\"><Timeout>PT8S</Timeout><MessageLimit>1</MessageLimit></PullMessages>";
249                 case GetEventProperties:
250                     return "<GetEventProperties xmlns=\"http://www.onvif.org/ver10/events/wsdl\"/>";
251                 case RelativeMoveLeft:
252                     return "<RelativeMove xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><ProfileToken>"
253                             + mediaProfileTokens.get(mediaProfileIndex)
254                             + "</ProfileToken><Translation><PanTilt x=\"0.05000000\" y=\"0\" xmlns=\"http://www.onvif.org/ver10/schema\"/></Translation></RelativeMove>";
255                 case RelativeMoveRight:
256                     return "<RelativeMove xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><ProfileToken>"
257                             + mediaProfileTokens.get(mediaProfileIndex)
258                             + "</ProfileToken><Translation><PanTilt x=\"-0.05000000\" y=\"0\" xmlns=\"http://www.onvif.org/ver10/schema\"/></Translation></RelativeMove>";
259                 case RelativeMoveUp:
260                     return "<RelativeMove xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><ProfileToken>"
261                             + mediaProfileTokens.get(mediaProfileIndex)
262                             + "</ProfileToken><Translation><PanTilt x=\"0\" y=\"0.100000000\" xmlns=\"http://www.onvif.org/ver10/schema\"/></Translation></RelativeMove>";
263                 case RelativeMoveDown:
264                     return "<RelativeMove xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><ProfileToken>"
265                             + mediaProfileTokens.get(mediaProfileIndex)
266                             + "</ProfileToken><Translation><PanTilt x=\"0\" y=\"-0.100000000\" xmlns=\"http://www.onvif.org/ver10/schema\"/></Translation></RelativeMove>";
267                 case RelativeMoveIn:
268                     return "<RelativeMove xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><ProfileToken>"
269                             + mediaProfileTokens.get(mediaProfileIndex)
270                             + "</ProfileToken><Translation><Zoom x=\"0.0240506344\" xmlns=\"http://www.onvif.org/ver10/schema\"/></Translation></RelativeMove>";
271                 case RelativeMoveOut:
272                     return "<RelativeMove xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><ProfileToken>"
273                             + mediaProfileTokens.get(mediaProfileIndex)
274                             + "</ProfileToken><Translation><Zoom x=\"-0.0240506344\" xmlns=\"http://www.onvif.org/ver10/schema\"/></Translation></RelativeMove>";
275                 case Renew:
276                     return "<Renew xmlns=\"http://docs.oasis-open.org/wsn/b-2\"><TerminationTime>PT10S</TerminationTime></Renew>";
277                 case GetConfigurations:
278                     return "<GetConfigurations xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"></GetConfigurations>";
279                 case GetConfigurationOptions:
280                     return "<GetConfigurationOptions xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><ConfigurationToken>"
281                             + ptzConfigToken + "</ConfigurationToken></GetConfigurationOptions>";
282                 case GetConfiguration:
283                     return "<GetConfiguration xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><PTZConfigurationToken>"
284                             + ptzConfigToken + "</PTZConfigurationToken></GetConfiguration>";
285                 case SetConfiguration:// not tested to work yet
286                     return "<SetConfiguration xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><PTZConfiguration><NodeToken>"
287                             + ptzNodeToken
288                             + "</NodeToken><DefaultAbsolutePantTiltPositionSpace>AbsolutePanTiltPositionSpace</DefaultAbsolutePantTiltPositionSpace><DefaultAbsoluteZoomPositionSpace>AbsoluteZoomPositionSpace</DefaultAbsoluteZoomPositionSpace></PTZConfiguration></SetConfiguration>";
289                 case GetNodes:
290                     return "<GetNodes xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"></GetNodes>";
291                 case GetStatus:
292                     return "<GetStatus xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><ProfileToken>"
293                             + mediaProfileTokens.get(mediaProfileIndex) + "</ProfileToken></GetStatus>";
294                 case GotoPreset:
295                     return "<GotoPreset xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><ProfileToken>"
296                             + mediaProfileTokens.get(mediaProfileIndex) + "</ProfileToken><PresetToken>"
297                             + presetTokens.get(presetTokenIndex) + "</PresetToken></GotoPreset>";
298                 case GetPresets:
299                     return "<GetPresets xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><ProfileToken>"
300                             + mediaProfileTokens.get(mediaProfileIndex) + "</ProfileToken></GetPresets>";
301             }
302         } catch (IndexOutOfBoundsException e) {
303             if (!isConnected) {
304                 logger.debug("IndexOutOfBoundsException occured, camera is not connected via ONVIF: {}",
305                         e.getMessage());
306             } else {
307                 logger.debug("IndexOutOfBoundsException occured, {}", e.getMessage());
308             }
309         }
310         return "notfound";
311     }
312
313     public void processReply(RequestType requestType, String message) {
314         logger.trace("ONVIF {} reply is: {}", requestType, message);
315         switch (requestType) {
316             case CreatePullPointSubscription:
317                 supportsEvents = true;
318                 subscriptionXAddr = Helper.fetchXML(message, "SubscriptionReference>", "Address>");
319                 int start = message.indexOf("<dom0:SubscriptionId");
320                 int end = message.indexOf("</dom0:SubscriptionId>");
321                 if (start > -1 && end > start) {
322                     subscriptionId = message.substring(start, end + 22);
323                 }
324                 logger.debug("subscriptionXAddr={} subscriptionId={}", subscriptionXAddr, subscriptionId);
325                 sendOnvifRequest(RequestType.PullMessages, subscriptionXAddr);
326                 break;
327             case GetCapabilities:
328                 parseXAddr(message);
329                 sendOnvifRequest(RequestType.GetProfiles, mediaXAddr);
330                 break;
331             case GetDeviceInformation:
332                 break;
333             case GetProfiles:
334                 setIsConnected(true);
335                 parseProfiles(message);
336                 sendOnvifRequest(RequestType.GetSnapshotUri, mediaXAddr);
337                 sendOnvifRequest(RequestType.GetStreamUri, mediaXAddr);
338                 if (ptzDevice) {
339                     sendPTZRequest(RequestType.GetNodes);
340                 }
341                 if (usingEvents) {// stops API cameras from getting sent ONVIF events.
342                     sendOnvifRequest(RequestType.GetEventProperties, eventXAddr);
343                     sendOnvifRequest(RequestType.GetServiceCapabilities, eventXAddr);
344                 }
345                 break;
346             case GetServiceCapabilities:
347                 if (message.contains("WSSubscriptionPolicySupport=\"true\"")) {
348                     sendOnvifRequest(RequestType.Subscribe, eventXAddr);
349                 }
350                 break;
351             case GetSnapshotUri:
352                 String url = Helper.fetchXML(message, ":MediaUri", ":Uri");
353                 if (!url.isBlank()) {
354                     logger.debug("GetSnapshotUri: {}", url);
355                     if (ipCameraHandler.snapshotUri.isEmpty()
356                             && !"ffmpeg".equals(ipCameraHandler.cameraConfig.getSnapshotUrl())) {
357                         ipCameraHandler.snapshotUri = ipCameraHandler.getCorrectUrlFormat(url);
358                         if (ipCameraHandler.getPortFromShortenedUrl(url) != ipCameraHandler.cameraConfig.getPort()) {
359                             logger.warn(
360                                     "ONVIF is reporting the snapshot does not match the things configured port of:{}",
361                                     ipCameraHandler.cameraConfig.getPort());
362                         }
363                     }
364                 }
365                 break;
366             case GetStreamUri:
367                 String xml = StringUtils.unEscapeXml(Helper.fetchXML(message, ":MediaUri", ":Uri>"));
368                 if (xml != null) {
369                     rtspUri = xml;
370                     logger.debug("GetStreamUri: {}", rtspUri);
371                     if (ipCameraHandler.cameraConfig.getFfmpegInput().isEmpty()) {
372                         ipCameraHandler.rtspUri = rtspUri;
373                     }
374                 }
375                 break;
376             case GetSystemDateAndTime:
377                 setIsConnected(true);// Instar profile T only cameras need this
378                 parseDateAndTime(message);
379                 break;
380             case PullMessages:
381                 eventRecieved(message);
382                 sendOnvifRequest(RequestType.PullMessages, subscriptionXAddr);
383                 break;
384             case GetEventProperties:
385                 sendOnvifRequest(RequestType.CreatePullPointSubscription, eventXAddr);
386                 break;
387             case Renew:
388                 break;
389             case GetConfiguration:
390                 sendPTZRequest(RequestType.GetPresets);
391                 ptzConfigToken = Helper.fetchXML(message, "PTZConfiguration", "token=\"");
392                 logger.debug("ptzConfigToken={}", ptzConfigToken);
393                 sendPTZRequest(RequestType.GetConfigurationOptions);
394                 break;
395             case GetNodes:
396                 sendPTZRequest(RequestType.GetStatus);
397                 ptzNodeToken = Helper.fetchXML(message, "", "token=\"");
398                 logger.debug("ptzNodeToken={}", ptzNodeToken);
399                 sendPTZRequest(RequestType.GetConfigurations);
400                 break;
401             case GetStatus:
402                 processPTZLocation(message);
403                 break;
404             case GetPresets:
405                 parsePresets(message);
406                 break;
407             default:
408                 break;
409         }
410     }
411
412     /**
413      * The {@link removeIPandPortFromUrl} Will throw away all text before the cameras IP, also removes the IP and the
414      * PORT
415      * leaving just the URL.
416      *
417      * @author Matthew Skinner - Initial contribution
418      */
419     String removeIPandPortFromUrl(String url) {
420         int index = url.indexOf("//");
421         if (index != -1) {// now remove the :port
422             index = url.indexOf("/", index + 2);
423         }
424         if (index == -1) {
425             logger.debug("We hit an issue parsing url: {}", url);
426             return "";
427         }
428         return url.substring(index);
429     }
430
431     String extractIPportFromUrl(String url) {
432         int startIndex = url.indexOf("//") + 2;
433         int endIndex = url.indexOf("/", startIndex);// skip past any :port to the slash /
434         if (startIndex != -1 && endIndex != -1) {
435             return url.substring(startIndex, endIndex);
436         }
437         logger.debug("We hit an issue extracting IP:PORT from url: {}", url);
438         return "";
439     }
440
441     int extractPortFromUrl(String url) {
442         int startIndex = url.indexOf("//") + 2;// skip past http://
443         startIndex = url.indexOf(":", startIndex);
444         if (startIndex == -1) {// no port defined so use port 80
445             return 80;
446         }
447         int endIndex = url.indexOf("/", startIndex);// skip past any :port to the slash /
448         if (endIndex == -1) {
449             return 80;
450         }
451         return Integer.parseInt(url.substring(startIndex + 1, endIndex));
452     }
453
454     void parseXAddr(String message) {
455         // Normally I would search '<tt:XAddr>' instead but Foscam needed this work around.
456         String temp = Helper.fetchXML(message, "<tt:Device", "tt:XAddr");
457         if (!temp.isEmpty()) {
458             deviceXAddr = temp;
459             logger.debug("deviceXAddr: {}", deviceXAddr);
460         }
461         temp = Helper.fetchXML(message, "<tt:Events", "tt:XAddr");
462         if (!temp.isEmpty()) {
463             subscriptionXAddr = eventXAddr = temp;
464             logger.debug("eventsXAddr: {}", eventXAddr);
465         }
466         temp = Helper.fetchXML(message, "<tt:Media", "tt:XAddr");
467         if (!temp.isEmpty()) {
468             mediaXAddr = temp;
469             logger.debug("mediaXAddr: {}", mediaXAddr);
470         }
471
472         ptzXAddr = Helper.fetchXML(message, "<tt:PTZ", "tt:XAddr");
473         if (ptzXAddr.isEmpty()) {
474             ptzDevice = false;
475             logger.debug("Camera has no ONVIF PTZ support.");
476             List<org.openhab.core.thing.Channel> removeChannels = new ArrayList<>();
477             org.openhab.core.thing.Channel channel = ipCameraHandler.getThing().getChannel(CHANNEL_PAN);
478             if (channel != null) {
479                 removeChannels.add(channel);
480             }
481             channel = ipCameraHandler.getThing().getChannel(CHANNEL_TILT);
482             if (channel != null) {
483                 removeChannels.add(channel);
484             }
485             channel = ipCameraHandler.getThing().getChannel(CHANNEL_ZOOM);
486             if (channel != null) {
487                 removeChannels.add(channel);
488             }
489             ipCameraHandler.removeChannels(removeChannels);
490         } else {
491             logger.debug("ptzXAddr: {}", ptzXAddr);
492         }
493     }
494
495     private void parseDateAndTime(String message) {
496         Date openHABTime = new Date();
497         String minute = Helper.fetchXML(message, "UTCDateTime", "Minute>");
498         String hour = Helper.fetchXML(message, "UTCDateTime", "Hour>");
499         String second = Helper.fetchXML(message, "UTCDateTime", "Second>");
500         String day = Helper.fetchXML(message, "UTCDateTime", "Day>");
501         String month = Helper.fetchXML(message, "UTCDateTime", "Month>");
502         String year = Helper.fetchXML(message, "UTCDateTime", "Year>");
503         SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-M-d'T'H:m:s");
504         dateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
505         try {
506             String time = year + "-" + month + "-" + day + "T" + hour + ":" + minute + ":" + second;
507             Date cameraUTC = dateFormat.parse(time);
508             long timeOffset = cameraUTC.getTime() - openHABTime.getTime();
509             logger.debug("Camera  UTC dateTime is: {} openHAB time is {} time is offset by {}ms",
510                     dateFormat.format(cameraUTC.getTime()), dateFormat.format(openHABTime.getTime()), timeOffset);
511             if (timeOffset > 5000 || timeOffset < -5000) {
512                 logger.warn(
513                         "ONVIF time in camera does not match openHAB's time, this can cause authentication issues as ONVIF requires the time to be close to each other");
514             }
515         } catch (ParseException e) {
516             logger.debug("Cameras time and date could not be parsed");
517         }
518     }
519
520     private String getUTCdateTime() {
521         SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'");
522         format.setTimeZone(TimeZone.getTimeZone("UTC"));
523         return format.format(new Date());
524     }
525
526     String createNonce() {
527         Random nonce = new SecureRandom();
528         return "" + nonce.nextInt();
529     }
530
531     String encodeBase64(String raw) {
532         return Base64.getEncoder().encodeToString(raw.getBytes());
533     }
534
535     String createDigest(String nOnce, String dateTime) {
536         String beforeEncryption = nOnce + dateTime + password;
537         MessageDigest msgDigest;
538         byte[] encryptedRaw = null;
539         try {
540             msgDigest = MessageDigest.getInstance("SHA-1");
541             msgDigest.reset();
542             msgDigest.update(beforeEncryption.getBytes(StandardCharsets.UTF_8));
543             encryptedRaw = msgDigest.digest();
544         } catch (NoSuchAlgorithmException e) {
545         }
546         return Base64.getEncoder().encodeToString(encryptedRaw);
547     }
548
549     public void sendOnvifRequest(RequestType requestType, String xAddr) {
550         logger.trace("Sending ONVIF request: {} to {}", requestType, xAddr);
551         int port = extractPortFromUrl(xAddr);
552         String security = "";
553         String extraEnvelope = "";
554         String headerTo = "";
555         String getXmlCache = getXml(requestType);
556         if (requestType.equals(RequestType.CreatePullPointSubscription) || requestType.equals(RequestType.PullMessages)
557                 || requestType.equals(RequestType.Renew) || requestType.equals(RequestType.Unsubscribe)) {
558             headerTo = "<a:To s:mustUnderstand=\"1\">" + xAddr + "</a:To>";
559             extraEnvelope = " xmlns:a=\"http://www.w3.org/2005/08/addressing\"";
560         }
561         String headers;
562         if (!password.isEmpty() && !requestType.equals(RequestType.GetSystemDateAndTime)) {
563             String nonce = createNonce();
564             String dateTime = getUTCdateTime();
565             String digest = createDigest(nonce, dateTime);
566             security = "<Security s:mustUnderstand=\"1\" xmlns=\"http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd\"><UsernameToken><Username>"
567                     + user
568                     + "</Username><Password Type=\"http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordDigest\">"
569                     + digest
570                     + "</Password><Nonce EncodingType=\"http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary\">"
571                     + encodeBase64(nonce)
572                     + "</Nonce><Created xmlns=\"http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd\">"
573                     + dateTime + "</Created></UsernameToken></Security>";
574
575             if (requestType.equals(RequestType.PullMessages) || requestType.equals(RequestType.Renew)) {
576                 headers = "<s:Header>" + security + headerTo + subscriptionId + "</s:Header>";
577             } else {
578                 headers = "<s:Header>" + security + headerTo + "</s:Header>";
579             }
580         } else {// GetSystemDateAndTime must not be password protected as per spec.
581             headers = "";
582         }
583         FullHttpRequest request = new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, new HttpMethod("POST"),
584                 removeIPandPortFromUrl(xAddr));
585         String actionString = Helper.fetchXML(getXmlCache, requestType.toString(), "xmlns=\"");
586         request.headers().add("Content-Type",
587                 "application/soap+xml; charset=utf-8; action=\"" + actionString + "/" + requestType + "\"");
588         request.headers().add("Charset", "utf-8");
589         // Tapo brand have different ports for the event xAddr to the other xAddr, can't use 1 port for all ONVIF calls.
590         request.headers().set("Host", ipAddress + ":" + port);
591         request.headers().set("Connection", HttpHeaderValues.CLOSE);
592         request.headers().set("Accept-Encoding", "gzip, deflate");
593         String fullXml = "<s:Envelope xmlns:s=\"http://www.w3.org/2003/05/soap-envelope\"" + extraEnvelope + ">"
594                 + headers
595                 + "<s:Body xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\">"
596                 + getXmlCache + "</s:Body></s:Envelope>";
597         request.headers().add("SOAPAction", "\"" + actionString + "/" + requestType + "\"");
598         ByteBuf bbuf = Unpooled.copiedBuffer(fullXml, StandardCharsets.UTF_8);
599         request.headers().set("Content-Length", bbuf.readableBytes());
600         request.content().clear().writeBytes(bbuf);
601
602         Bootstrap localBootstap = bootstrap;
603         if (localBootstap == null) {
604             mainEventLoopGroup = new NioEventLoopGroup(2);
605             localBootstap = new Bootstrap();
606             localBootstap.group(mainEventLoopGroup);
607             localBootstap.channel(NioSocketChannel.class);
608             localBootstap.option(ChannelOption.SO_KEEPALIVE, true);
609             localBootstap.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10000);
610             localBootstap.option(ChannelOption.SO_SNDBUF, 1024 * 8);
611             localBootstap.option(ChannelOption.SO_RCVBUF, 1024 * 1024);
612             localBootstap.option(ChannelOption.TCP_NODELAY, true);
613             localBootstap.handler(new ChannelInitializer<SocketChannel>() {
614
615                 @Override
616                 public void initChannel(SocketChannel socketChannel) throws Exception {
617                     socketChannel.pipeline().addLast("idleStateHandler", new IdleStateHandler(0, 0, 18));
618                     socketChannel.pipeline().addLast("HttpClientCodec", new HttpClientCodec());
619                     socketChannel.pipeline().addLast(ONVIF_CODEC, new OnvifCodec(getHandle()));
620                 }
621             });
622             bootstrap = localBootstap;
623         }
624         if (!mainEventLoopGroup.isShuttingDown()) {
625             // Tapo brand have different ports for the event xAddr to the other xAddr, can't use 1 port for all calls.
626             localBootstap.connect(new InetSocketAddress(ipAddress, port)).addListener(new ChannelFutureListener() {
627
628                 @Override
629                 public void operationComplete(@Nullable ChannelFuture future) {
630                     if (future == null) {
631                         return;
632                     }
633                     if (future.isDone() && future.isSuccess()) {
634                         Channel ch = future.channel();
635                         OnvifCodec onvifCodec = (OnvifCodec) ch.pipeline().get(ONVIF_CODEC);
636                         onvifCodec.setRequestType(requestType);
637                         ch.writeAndFlush(request);
638                     } else { // an error occurred
639                         if (future.isDone() && !future.isCancelled()) {
640                             Throwable cause = future.cause();
641                             String msg = cause.getMessage();
642                             logger.debug("Connect failed - cause is: {}", cause.getMessage());
643                             if (cause instanceof ConnectTimeoutException) {
644                                 usingEvents = false;// Prevent Unsubscribe from being sent
645                                 ipCameraHandler.cameraCommunicationError(
646                                         "Camera timed out when trying to connect to the ONVIF port:" + port);
647                             } else if ((cause instanceof ConnectException) && msg != null
648                                     && msg.contains("Connection refused")) {
649                                 usingEvents = false;// Prevent Unsubscribe from being sent
650                                 ipCameraHandler.cameraCommunicationError(
651                                         "Camera refused to connect when using ONVIF to port:" + port);
652                             }
653                         } else {
654                             ipCameraHandler.cameraCommunicationError("Camera failed to connect due to being cancelled");
655                         }
656                     }
657                 }
658             });
659         } else {
660             logger.debug("ONVIF message not sent as connection is shutting down");
661         }
662     }
663
664     OnvifConnection getHandle() {
665         return this;
666     }
667
668     void getIPandPortFromUrl(String url) {
669         int beginIndex = url.indexOf(":");
670         int endIndex = url.indexOf("/", beginIndex);
671         if (beginIndex >= 0 && endIndex == -1) {// 192.168.1.1:8080
672             ipAddress = url.substring(0, beginIndex);
673             onvifPort = Integer.parseInt(url.substring(beginIndex + 1));
674         } else if (beginIndex >= 0 && endIndex > beginIndex) {// 192.168.1.1:8080/foo/bar
675             ipAddress = url.substring(0, beginIndex);
676             onvifPort = Integer.parseInt(url.substring(beginIndex + 1, endIndex));
677         } else {// 192.168.1.1
678             ipAddress = url;
679             deviceXAddr = "http://" + ipAddress + "/onvif/device_service";
680             logger.debug("No ONVIF Port found when parsing: {}", url);
681             return;
682         }
683         deviceXAddr = "http://" + ipAddress + ":" + onvifPort + "/onvif/device_service";
684     }
685
686     public void gotoPreset(int index) {
687         if (ptzDevice) {
688             if (index > 0) {// 0 is reserved for HOME as cameras seem to start at preset 1.
689                 if (presetTokens.isEmpty()) {
690                     logger.warn("Camera did not report any ONVIF preset locations, updating preset tokens now.");
691                     sendPTZRequest(RequestType.GetPresets);
692                 } else {
693                     presetTokenIndex = index - 1;
694                     sendPTZRequest(RequestType.GotoPreset);
695                 }
696             }
697         }
698     }
699
700     public void eventRecieved(String eventMessage) {
701         String topic = Helper.fetchXML(eventMessage, "Topic", "tns1:");
702         if (topic.isEmpty()) {
703             logger.trace("No ONVIF Events occured in the last 8 seconds");
704             return;
705         }
706         String dataName = Helper.fetchXML(eventMessage, "tt:Data", "Name=\"");
707         String dataValue = Helper.fetchXML(eventMessage, "tt:Data", "Value=\"");
708         logger.debug("ONVIF Event Topic: {}, Data: {}, Value: {}", topic, dataName, dataValue);
709         switch (topic) {
710             case "RuleEngine/CellMotionDetector/Motion":
711                 if ("true".equals(dataValue)) {
712                     ipCameraHandler.motionDetected(CHANNEL_CELL_MOTION_ALARM);
713                 } else if ("false".equals(dataValue)) {
714                     ipCameraHandler.noMotionDetected(CHANNEL_CELL_MOTION_ALARM);
715                 }
716                 break;
717             case "VideoAnalytics/Motion":
718                 if ("Trigger".equals(dataValue)) {
719                     ipCameraHandler.motionDetected(CHANNEL_MOTION_ALARM);
720                 } else if ("Normal".equals(dataValue)) {
721                     ipCameraHandler.noMotionDetected(CHANNEL_MOTION_ALARM);
722                 }
723                 break;
724             case "RuleEngine/tnsaxis:VMD3/vmd3_video_1":
725             case "RuleEngine/MotionRegionDetector/Motion":
726             case "VideoSource/MotionAlarm":
727                 if ("true".equals(dataValue) || "1".equals(dataValue)) {
728                     ipCameraHandler.motionDetected(CHANNEL_MOTION_ALARM);
729                 } else if ("false".equals(dataValue) || "0".equals(dataValue)) {
730                     ipCameraHandler.noMotionDetected(CHANNEL_MOTION_ALARM);
731                 }
732                 break;
733             case "AudioAnalytics/Audio/DetectedSound":
734                 if ("true".equals(dataValue)) {
735                     ipCameraHandler.audioDetected();
736                 } else if ("false".equals(dataValue)) {
737                     ipCameraHandler.noAudioDetected();
738                 }
739                 break;
740             case "RuleEngine/FieldDetector/ObjectsInside":
741                 if ("true".equals(dataValue)) {
742                     ipCameraHandler.motionDetected(CHANNEL_FIELD_DETECTION_ALARM);
743                 } else if ("false".equals(dataValue)) {
744                     ipCameraHandler.noMotionDetected(CHANNEL_FIELD_DETECTION_ALARM);
745                 }
746                 break;
747             case "RuleEngine/LineDetector/Crossed":
748                 if ("ObjectId".equals(dataName)) {
749                     ipCameraHandler.motionDetected(CHANNEL_LINE_CROSSING_ALARM);
750                 } else {
751                     ipCameraHandler.noMotionDetected(CHANNEL_LINE_CROSSING_ALARM);
752                 }
753                 break;
754             case "RuleEngine/TamperDetector/Tamper":
755                 if ("true".equals(dataValue)) {
756                     ipCameraHandler.changeAlarmState(CHANNEL_TAMPER_ALARM, OnOffType.ON);
757                 } else if ("false".equals(dataValue)) {
758                     ipCameraHandler.changeAlarmState(CHANNEL_TAMPER_ALARM, OnOffType.OFF);
759                 }
760                 break;
761             case "Device/tnsaxis:HardwareFailure/StorageFailure":
762             case "Device/HardwareFailure/StorageFailure":
763                 if ("true".equals(dataValue) || "1".equals(dataValue)) {
764                     ipCameraHandler.changeAlarmState(CHANNEL_STORAGE_ALARM, OnOffType.ON);
765                 } else if ("false".equals(dataValue) || "0".equals(dataValue)) {
766                     ipCameraHandler.changeAlarmState(CHANNEL_STORAGE_ALARM, OnOffType.OFF);
767                 }
768                 break;
769             case "VideoSource/ImageTooDark/AnalyticsService":
770             case "VideoSource/ImageTooDark/ImagingService":
771             case "VideoSource/ImageTooDark/RecordingService":
772                 if ("true".equals(dataValue)) {
773                     ipCameraHandler.changeAlarmState(CHANNEL_TOO_DARK_ALARM, OnOffType.ON);
774                 } else if ("false".equals(dataValue)) {
775                     ipCameraHandler.changeAlarmState(CHANNEL_TOO_DARK_ALARM, OnOffType.OFF);
776                 }
777                 break;
778             case "VideoSource/GlobalSceneChange/AnalyticsService":
779             case "VideoSource/GlobalSceneChange/ImagingService":
780             case "VideoSource/GlobalSceneChange/RecordingService":
781                 if ("true".equals(dataValue) || "1".equals(dataValue)) {
782                     ipCameraHandler.changeAlarmState(CHANNEL_SCENE_CHANGE_ALARM, OnOffType.ON);
783                 } else if ("false".equals(dataValue) || "0".equals(dataValue)) {
784                     ipCameraHandler.changeAlarmState(CHANNEL_SCENE_CHANGE_ALARM, OnOffType.OFF);
785                 }
786                 break;
787             case "VideoSource/ImageTooBright/AnalyticsService":
788             case "VideoSource/ImageTooBright/ImagingService":
789             case "VideoSource/ImageTooBright/RecordingService":
790                 if ("true".equals(dataValue)) {
791                     ipCameraHandler.changeAlarmState(CHANNEL_TOO_BRIGHT_ALARM, OnOffType.ON);
792                 } else if ("false".equals(dataValue)) {
793                     ipCameraHandler.changeAlarmState(CHANNEL_TOO_BRIGHT_ALARM, OnOffType.OFF);
794                 }
795                 break;
796             case "VideoSource/ImageTooBlurry/AnalyticsService":
797             case "VideoSource/ImageTooBlurry/ImagingService":
798             case "VideoSource/ImageTooBlurry/RecordingService":
799                 if ("true".equals(dataValue)) {
800                     ipCameraHandler.changeAlarmState(CHANNEL_TOO_BLURRY_ALARM, OnOffType.ON);
801                 } else if ("false".equals(dataValue)) {
802                     ipCameraHandler.changeAlarmState(CHANNEL_TOO_BLURRY_ALARM, OnOffType.OFF);
803                 }
804                 break;
805             case "RuleEngine/MyRuleDetector/Visitor":
806                 if ("true".equals(dataValue)) {
807                     ipCameraHandler.changeAlarmState(CHANNEL_DOORBELL, OnOffType.ON);
808                 } else if ("false".equals(dataValue)) {
809                     ipCameraHandler.changeAlarmState(CHANNEL_DOORBELL, OnOffType.OFF);
810                 }
811                 break;
812             case "RuleEngine/MyRuleDetector/VehicleDetect":
813                 if ("true".equals(dataValue)) {
814                     ipCameraHandler.changeAlarmState(CHANNEL_CAR_ALARM, OnOffType.ON);
815                 } else if ("false".equals(dataValue)) {
816                     ipCameraHandler.changeAlarmState(CHANNEL_CAR_ALARM, OnOffType.OFF);
817                 }
818                 break;
819             case "RuleEngine/MyRuleDetector/DogCatDetect":
820                 if ("true".equals(dataValue)) {
821                     ipCameraHandler.changeAlarmState(CHANNEL_ANIMAL_ALARM, OnOffType.ON);
822                 } else if ("false".equals(dataValue)) {
823                     ipCameraHandler.changeAlarmState(CHANNEL_ANIMAL_ALARM, OnOffType.OFF);
824                 }
825                 break;
826             case "RuleEngine/MyRuleDetector/FaceDetect":
827                 if ("true".equals(dataValue)) {
828                     ipCameraHandler.changeAlarmState(CHANNEL_FACE_DETECTED, OnOffType.ON);
829                 } else if ("false".equals(dataValue)) {
830                     ipCameraHandler.changeAlarmState(CHANNEL_FACE_DETECTED, OnOffType.OFF);
831                 }
832                 break;
833             case "RuleEngine/MyRuleDetector/PeopleDetect":
834                 if ("true".equals(dataValue)) {
835                     ipCameraHandler.changeAlarmState(CHANNEL_HUMAN_ALARM, OnOffType.ON);
836                 } else if ("false".equals(dataValue)) {
837                     ipCameraHandler.changeAlarmState(CHANNEL_HUMAN_ALARM, OnOffType.OFF);
838                 }
839                 break;
840             default:
841                 logger.debug("Please report this camera has an un-implemented ONVIF event. Topic: {}", topic);
842         }
843     }
844
845     public boolean supportsPTZ() {
846         return ptzDevice;
847     }
848
849     public void getStatus() {
850         if (ptzDevice) {
851             sendPTZRequest(RequestType.GetStatus);
852         }
853     }
854
855     public Float getAbsolutePan() {
856         return currentPanPercentage;
857     }
858
859     public Float getAbsoluteTilt() {
860         return currentTiltPercentage;
861     }
862
863     public Float getAbsoluteZoom() {
864         return currentZoomPercentage;
865     }
866
867     public void setAbsolutePan(Float panValue) {// Value is 0-100% of cameras range
868         if (ptzDevice) {
869             currentPanPercentage = panValue;
870             currentPanCamValue = ((((panRangeMin - panRangeMax) * -1) / 100) * panValue + panRangeMin);
871         }
872     }
873
874     public void setAbsoluteTilt(Float tiltValue) {// Value is 0-100% of cameras range
875         if (ptzDevice) {
876             currentTiltPercentage = tiltValue;
877             currentTiltCamValue = ((((panRangeMin - panRangeMax) * -1) / 100) * tiltValue + tiltRangeMin);
878         }
879     }
880
881     public void setAbsoluteZoom(Float zoomValue) {// Value is 0-100% of cameras range
882         if (ptzDevice) {
883             currentZoomPercentage = zoomValue;
884             currentZoomCamValue = ((((zoomMin - zoomMax) * -1) / 100) * zoomValue + zoomMin);
885         }
886     }
887
888     public void absoluteMove() { // Camera wont move until PTZ values are set, then call this.
889         if (ptzDevice) {
890             sendPTZRequest(RequestType.AbsoluteMove);
891         }
892     }
893
894     public void setSelectedMediaProfile(int mediaProfileIndex) {
895         this.mediaProfileIndex = mediaProfileIndex;
896     }
897
898     List<String> listOfResults(String message, String heading, String key) {
899         List<String> results = new LinkedList<>();
900         String temp = "";
901         for (int startLookingFromIndex = 0; startLookingFromIndex != -1;) {
902             startLookingFromIndex = message.indexOf(heading, startLookingFromIndex);
903             if (startLookingFromIndex >= 0) {
904                 temp = Helper.fetchXML(message.substring(startLookingFromIndex), heading, key);
905                 if (!temp.isEmpty()) {
906                     logger.trace("String was found: {}", temp);
907                     results.add(temp);
908                 } else {
909                     return results;// key string must not exist so stop looking.
910                 }
911                 startLookingFromIndex += temp.length();
912             }
913         }
914         return results;
915     }
916
917     void parsePresets(String message) {
918         List<StateOption> presets = new ArrayList<>();
919         int counter = 1;// Presets start at 1 not 0. HOME may be added to index 0.
920         presetTokens = listOfResults(message, "<tptz:Preset", "token=\"");
921         presetNames = listOfResults(message, "<tptz:Preset", "<tt:Name>");
922         if (presetTokens.size() != presetNames.size()) {
923             logger.warn("Camera did not report the same number of Tokens and Names for PTZ presets");
924             return;
925         }
926         for (String value : presetNames) {
927             presets.add(new StateOption(Integer.toString(counter++), value));
928         }
929         ipCameraHandler.stateDescriptionProvider
930                 .setStateOptions(new ChannelUID(ipCameraHandler.getThing().getUID(), CHANNEL_GOTO_PRESET), presets);
931     }
932
933     void parseProfiles(String message) {
934         mediaProfileTokens = listOfResults(message, "<trt:Profiles", "token=\"");
935         if (mediaProfileIndex >= mediaProfileTokens.size()) {
936             logger.warn(
937                     "You have set the media profile to {} when the camera reported {} profiles. Falling back to mainstream 0.",
938                     mediaProfileIndex, mediaProfileTokens.size());
939             mediaProfileIndex = 0;
940         }
941     }
942
943     void processPTZLocation(String result) {
944         logger.debug("Processing new PTZ location now");
945
946         int beginIndex = result.indexOf("x=\"");
947         int endIndex = result.indexOf("\"", (beginIndex + 3));
948         if (beginIndex >= 0 && endIndex >= 0) {
949             currentPanCamValue = Float.parseFloat(result.substring(beginIndex + 3, endIndex));
950             currentPanPercentage = (((panRangeMin - currentPanCamValue) * -1) / ((panRangeMin - panRangeMax) * -1))
951                     * 100;
952             logger.debug("Pan is updating to: {} and the cam value is {}", Math.round(currentPanPercentage),
953                     currentPanCamValue);
954         } else {
955             logger.warn(
956                     "Binding could not determin the cameras current PTZ location. Not all cameras respond to GetStatus requests.");
957             return;
958         }
959
960         beginIndex = result.indexOf("y=\"");
961         endIndex = result.indexOf("\"", (beginIndex + 3));
962         if (beginIndex >= 0 && endIndex >= 0) {
963             currentTiltCamValue = Float.parseFloat(result.substring(beginIndex + 3, endIndex));
964             currentTiltPercentage = (((tiltRangeMin - currentTiltCamValue) * -1) / ((tiltRangeMin - tiltRangeMax) * -1))
965                     * 100;
966             logger.debug("Tilt is updating to: {} and the cam value is {}", Math.round(currentTiltPercentage),
967                     currentTiltCamValue);
968         } else {
969             return;
970         }
971
972         beginIndex = result.lastIndexOf("x=\"");
973         endIndex = result.indexOf("\"", (beginIndex + 3));
974         if (beginIndex >= 0 && endIndex >= 0) {
975             currentZoomCamValue = Float.parseFloat(result.substring(beginIndex + 3, endIndex));
976             currentZoomPercentage = (((zoomMin - currentZoomCamValue) * -1) / ((zoomMin - zoomMax) * -1)) * 100;
977             logger.debug("Zoom is updating to: {} and the cam value is {}", Math.round(currentZoomPercentage),
978                     currentZoomCamValue);
979         } else {
980             return;
981         }
982     }
983
984     public void sendPTZRequest(RequestType requestType) {
985         if (!isConnected) {
986             logger.debug("ONVIF was not connected when a PTZ request was made, connecting now");
987             connect(usingEvents);
988         }
989         sendOnvifRequest(requestType, ptzXAddr);
990     }
991
992     public void sendEventRequest(RequestType requestType) {
993         sendOnvifRequest(requestType, eventXAddr);
994     }
995
996     public void connect(boolean useEvents) {
997         connecting.lock();
998         try {
999             if (!isConnected) {
1000                 logger.debug("Connecting {} to ONVIF", ipAddress);
1001                 threadPool = Executors.newScheduledThreadPool(2);
1002                 sendOnvifRequest(RequestType.GetSystemDateAndTime, deviceXAddr);
1003                 usingEvents = useEvents;
1004                 sendOnvifRequest(RequestType.GetCapabilities, deviceXAddr);
1005             }
1006         } finally {
1007             connecting.unlock();
1008         }
1009     }
1010
1011     public boolean isConnected() {
1012         connecting.lock();
1013         try {
1014             return isConnected;
1015         } finally {
1016             connecting.unlock();
1017         }
1018     }
1019
1020     public boolean getEventsSupported() {
1021         return supportsEvents;
1022     }
1023
1024     public void setIsConnected(boolean isConnected) {
1025         connecting.lock();
1026         try {
1027             this.isConnected = isConnected;
1028         } finally {
1029             connecting.unlock();
1030         }
1031     }
1032
1033     private void cleanup() {
1034         if (!isConnected && !mainEventLoopGroup.isShuttingDown()) {
1035             try {
1036                 mainEventLoopGroup.shutdownGracefully();
1037                 mainEventLoopGroup.awaitTermination(3, TimeUnit.SECONDS);
1038             } catch (InterruptedException e) {
1039                 logger.warn("ONVIF was not cleanly shutdown, due to being interrupted");
1040             } finally {
1041                 logger.debug("Eventloop is shutdown: {}", mainEventLoopGroup.isShutdown());
1042                 bootstrap = null;
1043                 threadPool.shutdown();
1044             }
1045         }
1046     }
1047
1048     public void disconnect() {
1049         connecting.lock();// Lock out multiple disconnect()/connect() attempts as we try to send Unsubscribe.
1050         try {
1051             if (bootstrap != null) {
1052                 if (isConnected && usingEvents && !mainEventLoopGroup.isShuttingDown()) {
1053                     // Only makes sense to send if connected
1054                     // Some cameras may continue to send events even when they can't reach a server.
1055                     sendOnvifRequest(RequestType.Unsubscribe, subscriptionXAddr);
1056                 }
1057                 // give time for the Unsubscribe request to be sent, shutdownGracefully will try to send it first.
1058                 threadPool.schedule(this::cleanup, 50, TimeUnit.MILLISECONDS);
1059             } else {
1060                 cleanup();
1061             }
1062
1063             isConnected = false;// isConnected is not thread safe, connecting.lock() used as fix.
1064         } finally {
1065             connecting.unlock();
1066         }
1067     }
1068 }