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