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