2 * Copyright (c) 2010-2023 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.ipcamera.internal.onvif;
15 import static org.openhab.binding.ipcamera.internal.IpCameraBindingConstants.*;
17 import java.net.InetSocketAddress;
18 import java.nio.charset.StandardCharsets;
19 import java.security.MessageDigest;
20 import java.security.NoSuchAlgorithmException;
21 import java.security.SecureRandom;
22 import java.text.SimpleDateFormat;
23 import java.util.ArrayList;
24 import java.util.Base64;
25 import java.util.Date;
26 import java.util.LinkedList;
27 import java.util.List;
28 import java.util.Random;
29 import java.util.TimeZone;
30 import java.util.concurrent.Executors;
31 import java.util.concurrent.ScheduledExecutorService;
32 import java.util.concurrent.TimeUnit;
33 import java.util.concurrent.locks.ReentrantLock;
35 import org.eclipse.jdt.annotation.NonNullByDefault;
36 import org.eclipse.jdt.annotation.Nullable;
37 import org.openhab.binding.ipcamera.internal.Helper;
38 import org.openhab.binding.ipcamera.internal.handler.IpCameraHandler;
39 import org.openhab.core.library.types.OnOffType;
40 import org.openhab.core.thing.ChannelUID;
41 import org.openhab.core.types.StateOption;
42 import org.slf4j.Logger;
43 import org.slf4j.LoggerFactory;
45 import io.netty.bootstrap.Bootstrap;
46 import io.netty.buffer.ByteBuf;
47 import io.netty.buffer.Unpooled;
48 import io.netty.channel.Channel;
49 import io.netty.channel.ChannelFuture;
50 import io.netty.channel.ChannelFutureListener;
51 import io.netty.channel.ChannelInitializer;
52 import io.netty.channel.ChannelOption;
53 import io.netty.channel.EventLoopGroup;
54 import io.netty.channel.nio.NioEventLoopGroup;
55 import io.netty.channel.socket.SocketChannel;
56 import io.netty.channel.socket.nio.NioSocketChannel;
57 import io.netty.handler.codec.http.DefaultFullHttpRequest;
58 import io.netty.handler.codec.http.FullHttpRequest;
59 import io.netty.handler.codec.http.HttpClientCodec;
60 import io.netty.handler.codec.http.HttpHeaderValues;
61 import io.netty.handler.codec.http.HttpMethod;
62 import io.netty.handler.codec.http.HttpVersion;
63 import io.netty.handler.timeout.IdleStateHandler;
66 * The {@link OnvifConnection} This is a basic Netty implementation for connecting and communicating to ONVIF cameras.
70 * @author Matthew Skinner - Initial contribution
74 public class OnvifConnection {
75 public static enum RequestType {
85 CreatePullPointSubscription,
89 GetServiceCapabilities,
105 GetConfigurationOptions,
114 private final Logger logger = LoggerFactory.getLogger(getClass());
115 private ScheduledExecutorService threadPool = Executors.newScheduledThreadPool(2);
116 private @Nullable Bootstrap bootstrap;
117 private EventLoopGroup mainEventLoopGroup = new NioEventLoopGroup(2);
118 private ReentrantLock connecting = new ReentrantLock();
119 private String ipAddress = "";
120 private String user = "";
121 private String password = "";
122 private int onvifPort = 80;
123 private String deviceXAddr = "http://" + ipAddress + "/onvif/device_service";
124 private String eventXAddr = "http://" + ipAddress + "/onvif/device_service";
125 private String mediaXAddr = "http://" + ipAddress + "/onvif/device_service";
126 @SuppressWarnings("unused")
127 private String imagingXAddr = "http://" + ipAddress + "/onvif/device_service";
128 private String ptzXAddr = "http://" + ipAddress + "/onvif/ptz_service";
129 private String subscriptionXAddr = "http://" + ipAddress + "/onvif/device_service";
130 private boolean isConnected = false;
131 private int mediaProfileIndex = 0;
132 private String snapshotUri = "";
133 private String rtspUri = "";
134 private IpCameraHandler ipCameraHandler;
135 private boolean usingEvents = false;
137 // These hold the cameras PTZ position in the range that the camera uses, ie
139 private Float panRangeMin = -1.0f;
140 private Float panRangeMax = 1.0f;
141 private Float tiltRangeMin = -1.0f;
142 private Float tiltRangeMax = 1.0f;
143 private Float zoomMin = 0.0f;
144 private Float zoomMax = 1.0f;
145 // These hold the PTZ values for updating Openhabs controls in 0-100 range
146 private Float currentPanPercentage = 0.0f;
147 private Float currentTiltPercentage = 0.0f;
148 private Float currentZoomPercentage = 0.0f;
149 private Float currentPanCamValue = 0.0f;
150 private Float currentTiltCamValue = 0.0f;
151 private Float currentZoomCamValue = 0.0f;
152 private String ptzNodeToken = "000";
153 private String ptzConfigToken = "000";
154 private int presetTokenIndex = 0;
155 private List<String> presetTokens = new LinkedList<>();
156 private List<String> presetNames = new LinkedList<>();
157 private List<String> mediaProfileTokens = new LinkedList<>();
158 private boolean ptzDevice = true;
160 public OnvifConnection(IpCameraHandler ipCameraHandler, String ipAddress, String user, String password) {
161 this.ipCameraHandler = ipCameraHandler;
162 if (!ipAddress.isEmpty()) {
164 this.password = password;
165 getIPandPortFromUrl(ipAddress);
169 private String getXml(RequestType requestType) {
171 switch (requestType) {
173 return "<AbsoluteMove xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><ProfileToken>"
174 + mediaProfileTokens.get(mediaProfileIndex) + "</ProfileToken><Position><PanTilt x=\""
175 + currentPanCamValue + "\" y=\"" + currentTiltCamValue
176 + "\" space=\"http://www.onvif.org/ver10/tptz/PanTiltSpaces/PositionGenericSpace\">\n"
177 + "</PanTilt>\n" + "<Zoom x=\"" + currentZoomCamValue
178 + "\" space=\"http://www.onvif.org/ver10/tptz/ZoomSpaces/PositionGenericSpace\">\n"
179 + "</Zoom>\n" + "</Position>\n"
180 + "<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"
181 + "</Speed></AbsoluteMove>";
182 case AddPTZConfiguration: // not tested to work yet
183 return "<AddPTZConfiguration xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><ProfileToken>"
184 + mediaProfileTokens.get(mediaProfileIndex) + "</ProfileToken><ConfigurationToken>"
185 + ptzConfigToken + "</ConfigurationToken></AddPTZConfiguration>";
186 case ContinuousMoveLeft:
187 return "<ContinuousMove xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><ProfileToken>"
188 + mediaProfileTokens.get(mediaProfileIndex)
189 + "</ProfileToken><Velocity><PanTilt x=\"-0.5\" y=\"0\" xmlns=\"http://www.onvif.org/ver10/schema\"/></Velocity></ContinuousMove>";
190 case ContinuousMoveRight:
191 return "<ContinuousMove xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><ProfileToken>"
192 + mediaProfileTokens.get(mediaProfileIndex)
193 + "</ProfileToken><Velocity><PanTilt x=\"0.5\" y=\"0\" xmlns=\"http://www.onvif.org/ver10/schema\"/></Velocity></ContinuousMove>";
194 case ContinuousMoveUp:
195 return "<ContinuousMove xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><ProfileToken>"
196 + mediaProfileTokens.get(mediaProfileIndex)
197 + "</ProfileToken><Velocity><PanTilt x=\"0\" y=\"-0.5\" xmlns=\"http://www.onvif.org/ver10/schema\"/></Velocity></ContinuousMove>";
198 case ContinuousMoveDown:
199 return "<ContinuousMove xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><ProfileToken>"
200 + mediaProfileTokens.get(mediaProfileIndex)
201 + "</ProfileToken><Velocity><PanTilt x=\"0\" y=\"0.5\" xmlns=\"http://www.onvif.org/ver10/schema\"/></Velocity></ContinuousMove>";
203 return "<Stop xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><ProfileToken>"
204 + mediaProfileTokens.get(mediaProfileIndex)
205 + "</ProfileToken><PanTilt>true</PanTilt><Zoom>true</Zoom></Stop>";
206 case ContinuousMoveIn:
207 return "<ContinuousMove xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><ProfileToken>"
208 + mediaProfileTokens.get(mediaProfileIndex)
209 + "</ProfileToken><Velocity><Zoom x=\"0.5\" xmlns=\"http://www.onvif.org/ver10/schema\"/></Velocity></ContinuousMove>";
210 case ContinuousMoveOut:
211 return "<ContinuousMove xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><ProfileToken>"
212 + mediaProfileTokens.get(mediaProfileIndex)
213 + "</ProfileToken><Velocity><Zoom x=\"-0.5\" xmlns=\"http://www.onvif.org/ver10/schema\"/></Velocity></ContinuousMove>";
214 case CreatePullPointSubscription:
215 return "<CreatePullPointSubscription xmlns=\"http://www.onvif.org/ver10/events/wsdl\"><InitialTerminationTime>PT600S</InitialTerminationTime></CreatePullPointSubscription>";
216 case GetCapabilities:
217 return "<GetCapabilities xmlns=\"http://www.onvif.org/ver10/device/wsdl\"><Category>All</Category></GetCapabilities>";
219 case GetDeviceInformation:
220 return "<GetDeviceInformation xmlns=\"http://www.onvif.org/ver10/device/wsdl\"/>";
222 return "<GetProfiles xmlns=\"http://www.onvif.org/ver10/media/wsdl\"/>";
223 case GetServiceCapabilities:
224 return "<GetServiceCapabilities xmlns=\"http://docs.oasis-open.org/wsn/b-2/\"></GetServiceCapabilities>";
226 return "<GetSnapshotUri xmlns=\"http://www.onvif.org/ver10/media/wsdl\"><ProfileToken>"
227 + mediaProfileTokens.get(mediaProfileIndex) + "</ProfileToken></GetSnapshotUri>";
229 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>"
230 + mediaProfileTokens.get(mediaProfileIndex) + "</ProfileToken></GetStreamUri>";
231 case GetSystemDateAndTime:
232 return "<GetSystemDateAndTime xmlns=\"http://www.onvif.org/ver10/device/wsdl\"/>";
234 return "<Subscribe xmlns=\"http://docs.oasis-open.org/wsn/b-2/\"><ConsumerReference><Address>http://"
235 + ipCameraHandler.hostIp + ":" + SERVLET_PORT + "/ipcamera/"
236 + ipCameraHandler.getThing().getUID().getId()
237 + "/OnvifEvent</Address></ConsumerReference></Subscribe>";
239 return "<Unsubscribe xmlns=\"http://docs.oasis-open.org/wsn/b-2/\"></Unsubscribe>";
241 return "<PullMessages xmlns=\"http://www.onvif.org/ver10/events/wsdl\"><Timeout>PT8S</Timeout><MessageLimit>1</MessageLimit></PullMessages>";
242 case GetEventProperties:
243 return "<GetEventProperties xmlns=\"http://www.onvif.org/ver10/events/wsdl\"/>";
244 case RelativeMoveLeft:
245 return "<RelativeMove xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><ProfileToken>"
246 + mediaProfileTokens.get(mediaProfileIndex)
247 + "</ProfileToken><Translation><PanTilt x=\"0.05000000\" y=\"0\" xmlns=\"http://www.onvif.org/ver10/schema\"/></Translation></RelativeMove>";
248 case RelativeMoveRight:
249 return "<RelativeMove xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><ProfileToken>"
250 + mediaProfileTokens.get(mediaProfileIndex)
251 + "</ProfileToken><Translation><PanTilt x=\"-0.05000000\" y=\"0\" xmlns=\"http://www.onvif.org/ver10/schema\"/></Translation></RelativeMove>";
253 return "<RelativeMove xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><ProfileToken>"
254 + mediaProfileTokens.get(mediaProfileIndex)
255 + "</ProfileToken><Translation><PanTilt x=\"0\" y=\"0.100000000\" xmlns=\"http://www.onvif.org/ver10/schema\"/></Translation></RelativeMove>";
256 case RelativeMoveDown:
257 return "<RelativeMove xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><ProfileToken>"
258 + mediaProfileTokens.get(mediaProfileIndex)
259 + "</ProfileToken><Translation><PanTilt x=\"0\" y=\"-0.100000000\" xmlns=\"http://www.onvif.org/ver10/schema\"/></Translation></RelativeMove>";
261 return "<RelativeMove xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><ProfileToken>"
262 + mediaProfileTokens.get(mediaProfileIndex)
263 + "</ProfileToken><Translation><Zoom x=\"0.0240506344\" xmlns=\"http://www.onvif.org/ver10/schema\"/></Translation></RelativeMove>";
264 case RelativeMoveOut:
265 return "<RelativeMove xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><ProfileToken>"
266 + mediaProfileTokens.get(mediaProfileIndex)
267 + "</ProfileToken><Translation><Zoom x=\"-0.0240506344\" xmlns=\"http://www.onvif.org/ver10/schema\"/></Translation></RelativeMove>";
269 return "<Renew xmlns=\"http://docs.oasis-open.org/wsn/b-2\"><TerminationTime>PT1M</TerminationTime></Renew>";
270 case GetConfigurations:
271 return "<GetConfigurations xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"></GetConfigurations>";
272 case GetConfigurationOptions:
273 return "<GetConfigurationOptions xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><ConfigurationToken>"
274 + ptzConfigToken + "</ConfigurationToken></GetConfigurationOptions>";
275 case GetConfiguration:
276 return "<GetConfiguration xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><PTZConfigurationToken>"
277 + ptzConfigToken + "</PTZConfigurationToken></GetConfiguration>";
278 case SetConfiguration:// not tested to work yet
279 return "<SetConfiguration xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><PTZConfiguration><NodeToken>"
281 + "</NodeToken><DefaultAbsolutePantTiltPositionSpace>AbsolutePanTiltPositionSpace</DefaultAbsolutePantTiltPositionSpace><DefaultAbsoluteZoomPositionSpace>AbsoluteZoomPositionSpace</DefaultAbsoluteZoomPositionSpace></PTZConfiguration></SetConfiguration>";
283 return "<GetNodes xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"></GetNodes>";
285 return "<GetStatus xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><ProfileToken>"
286 + mediaProfileTokens.get(mediaProfileIndex) + "</ProfileToken></GetStatus>";
288 return "<GotoPreset xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><ProfileToken>"
289 + mediaProfileTokens.get(mediaProfileIndex) + "</ProfileToken><PresetToken>"
290 + presetTokens.get(presetTokenIndex) + "</PresetToken></GotoPreset>";
292 return "<GetPresets xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><ProfileToken>"
293 + mediaProfileTokens.get(mediaProfileIndex) + "</ProfileToken></GetPresets>";
295 } catch (IndexOutOfBoundsException e) {
297 logger.debug("IndexOutOfBoundsException occured, camera is not connected via ONVIF: {}",
300 logger.debug("IndexOutOfBoundsException occured, {}", e.getMessage());
306 public void processReply(String message) {
307 logger.trace("Onvif reply is:{}", message);
308 if (message.contains("PullMessagesResponse")) {
309 eventRecieved(message);
310 } else if (message.contains("RenewResponse")) {
311 sendOnvifRequest(RequestType.PullMessages, subscriptionXAddr);
312 } else if (message.contains("GetSystemDateAndTimeResponse")) {// 1st to be sent.
319 parseDateAndTime(message);
320 logger.debug("Openhabs UTC dateTime is:{}", getUTCdateTime());
321 } else if (message.contains("GetCapabilitiesResponse")) {// 2nd to be sent.
323 sendOnvifRequest(RequestType.GetProfiles, mediaXAddr);
324 } else if (message.contains("GetProfilesResponse")) {// 3rd to be sent.
331 parseProfiles(message);
332 sendOnvifRequest(RequestType.GetSnapshotUri, mediaXAddr);
333 sendOnvifRequest(RequestType.GetStreamUri, mediaXAddr);
335 sendPTZRequest(RequestType.GetNodes);
337 if (usingEvents) {// stops API cameras from getting sent ONVIF events.
338 sendOnvifRequest(RequestType.GetEventProperties, eventXAddr);
339 sendOnvifRequest(RequestType.GetServiceCapabilities, eventXAddr);
341 } else if (message.contains("GetServiceCapabilitiesResponse")) {
342 if (message.contains("WSSubscriptionPolicySupport=\"true\"")) {
343 sendOnvifRequest(RequestType.Subscribe, eventXAddr);
345 } else if (message.contains("GetEventPropertiesResponse")) {
346 sendOnvifRequest(RequestType.CreatePullPointSubscription, eventXAddr);
347 } else if (message.contains("CreatePullPointSubscriptionResponse")) {
348 subscriptionXAddr = Helper.fetchXML(message, "SubscriptionReference>", "Address>");
349 logger.debug("subscriptionXAddr={}", subscriptionXAddr);
350 sendOnvifRequest(RequestType.PullMessages, subscriptionXAddr);
351 } else if (message.contains("GetStatusResponse")) {
352 processPTZLocation(message);
353 } else if (message.contains("GetPresetsResponse")) {
354 parsePresets(message);
355 } else if (message.contains("GetConfigurationsResponse")) {
356 sendPTZRequest(RequestType.GetPresets);
357 ptzConfigToken = Helper.fetchXML(message, "PTZConfiguration", "token=\"");
358 logger.debug("ptzConfigToken={}", ptzConfigToken);
359 sendPTZRequest(RequestType.GetConfigurationOptions);
360 } else if (message.contains("GetNodesResponse")) {
361 sendPTZRequest(RequestType.GetStatus);
362 ptzNodeToken = Helper.fetchXML(message, "", "token=\"");
363 logger.debug("ptzNodeToken={}", ptzNodeToken);
364 sendPTZRequest(RequestType.GetConfigurations);
365 } else if (message.contains("GetDeviceInformationResponse")) {
366 logger.debug("GetDeviceInformationResponse recieved");
367 } else if (message.contains("GetSnapshotUriResponse")) {
368 snapshotUri = removeIPfromUrl(Helper.fetchXML(message, ":MediaUri", ":Uri"));
369 logger.debug("GetSnapshotUri:{}", snapshotUri);
370 if (ipCameraHandler.snapshotUri.isEmpty()
371 && !"ffmpeg".equals(ipCameraHandler.cameraConfig.getSnapshotUrl())) {
372 ipCameraHandler.snapshotUri = snapshotUri;
374 } else if (message.contains("GetStreamUriResponse")) {
375 rtspUri = Helper.fetchXML(message, ":MediaUri", ":Uri>");
376 logger.debug("GetStreamUri:{}", rtspUri);
377 if (ipCameraHandler.cameraConfig.getFfmpegInput().isEmpty()) {
378 ipCameraHandler.rtspUri = rtspUri;
384 * The {@link removeIPfromUrl} Will throw away all text before the cameras IP, also removes the IP and the PORT
385 * leaving just the URL.
387 * @author Matthew Skinner - Initial contribution
389 String removeIPfromUrl(String url) {
390 int index = url.indexOf("//");
391 if (index != -1) {// now remove the :port
392 index = url.indexOf("/", index + 2);
395 logger.debug("We hit an issue parsing url:{}", url);
398 return url.substring(index);
401 String extractIPportFromUrl(String url) {
402 int startIndex = url.indexOf("//") + 2;
403 int endIndex = url.indexOf("/", startIndex);// skip past any :port to the slash /
404 if (startIndex != -1 && endIndex != -1) {
405 return url.substring(startIndex, endIndex);
407 logger.debug("We hit an issue extracting IP:PORT from url:{}", url);
411 int extractPortFromUrl(String url) {
412 int startIndex = url.indexOf("//") + 2;// skip past http://
413 startIndex = url.indexOf(":", startIndex);
414 if (startIndex == -1) {// no port defined so use port 80
417 int endIndex = url.indexOf("/", startIndex);// skip past any :port to the slash /
418 if (endIndex == -1) {
421 return Integer.parseInt(url.substring(startIndex + 1, endIndex));
424 void parseXAddr(String message) {
425 // Normally I would search '<tt:XAddr>' instead but Foscam needed this work around.
426 String temp = Helper.fetchXML(message, "<tt:Device", "tt:XAddr");
427 if (!temp.isEmpty()) {
429 logger.debug("deviceXAddr:{}", deviceXAddr);
431 temp = Helper.fetchXML(message, "<tt:Events", "tt:XAddr");
432 if (!temp.isEmpty()) {
433 subscriptionXAddr = eventXAddr = temp;
434 logger.debug("eventsXAddr:{}", eventXAddr);
436 temp = Helper.fetchXML(message, "<tt:Media", "tt:XAddr");
437 if (!temp.isEmpty()) {
439 logger.debug("mediaXAddr:{}", mediaXAddr);
442 ptzXAddr = Helper.fetchXML(message, "<tt:PTZ", "tt:XAddr");
443 if (ptzXAddr.isEmpty()) {
445 logger.debug("Camera has no ONVIF PTZ support.");
446 List<org.openhab.core.thing.Channel> removeChannels = new ArrayList<>();
447 org.openhab.core.thing.Channel channel = ipCameraHandler.getThing().getChannel(CHANNEL_PAN);
448 if (channel != null) {
449 removeChannels.add(channel);
451 channel = ipCameraHandler.getThing().getChannel(CHANNEL_TILT);
452 if (channel != null) {
453 removeChannels.add(channel);
455 channel = ipCameraHandler.getThing().getChannel(CHANNEL_ZOOM);
456 if (channel != null) {
457 removeChannels.add(channel);
459 ipCameraHandler.removeChannels(removeChannels);
461 logger.debug("ptzXAddr:{}", ptzXAddr);
465 private void parseDateAndTime(String message) {
466 String minute = Helper.fetchXML(message, "UTCDateTime", "Minute>");
467 String hour = Helper.fetchXML(message, "UTCDateTime", "Hour>");
468 String second = Helper.fetchXML(message, "UTCDateTime", "Second>");
469 String day = Helper.fetchXML(message, "UTCDateTime", "Day>");
470 String month = Helper.fetchXML(message, "UTCDateTime", "Month>");
471 String year = Helper.fetchXML(message, "UTCDateTime", "Year>");
472 logger.debug("Cameras UTC dateTime is:{}-{}-{}T{}:{}:{}", year, month, day, hour, minute, second);
475 private String getUTCdateTime() {
476 SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'");
477 format.setTimeZone(TimeZone.getTimeZone("UTC"));
478 return format.format(new Date());
481 String createNonce() {
482 Random nonce = new SecureRandom();
483 return "" + nonce.nextInt();
486 String encodeBase64(String raw) {
487 return Base64.getEncoder().encodeToString(raw.getBytes());
490 String createDigest(String nOnce, String dateTime) {
491 String beforeEncryption = nOnce + dateTime + password;
492 MessageDigest msgDigest;
493 byte[] encryptedRaw = null;
495 msgDigest = MessageDigest.getInstance("SHA-1");
497 msgDigest.update(beforeEncryption.getBytes(StandardCharsets.UTF_8));
498 encryptedRaw = msgDigest.digest();
499 } catch (NoSuchAlgorithmException e) {
501 return Base64.getEncoder().encodeToString(encryptedRaw);
504 public void sendOnvifRequest(RequestType requestType, String xAddr) {
505 logger.trace("Sending ONVIF request:{}", requestType);
506 String security = "";
507 String extraEnvelope = "";
508 String headerTo = "";
509 String getXmlCache = getXml(requestType);
510 if (requestType.equals(RequestType.CreatePullPointSubscription) || requestType.equals(RequestType.PullMessages)
511 || requestType.equals(RequestType.Renew) || requestType.equals(RequestType.Unsubscribe)) {
512 headerTo = "<a:To s:mustUnderstand=\"1\">" + xAddr + "</a:To>";
513 extraEnvelope = " xmlns:a=\"http://www.w3.org/2005/08/addressing\"";
516 if (!password.isEmpty() && !requestType.equals(RequestType.GetSystemDateAndTime)) {
517 String nonce = createNonce();
518 String dateTime = getUTCdateTime();
519 String digest = createDigest(nonce, dateTime);
520 security = "<Security s:mustUnderstand=\"1\" xmlns=\"http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd\"><UsernameToken><Username>"
522 + "</Username><Password Type=\"http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordDigest\">"
524 + "</Password><Nonce EncodingType=\"http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary\">"
525 + encodeBase64(nonce)
526 + "</Nonce><Created xmlns=\"http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd\">"
527 + dateTime + "</Created></UsernameToken></Security>";
528 headers = "<s:Header>" + security + headerTo + "</s:Header>";
529 } else {// GetSystemDateAndTime must not be password protected as per spec.
532 FullHttpRequest request = new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, new HttpMethod("POST"),
533 removeIPfromUrl(xAddr));
534 String actionString = Helper.fetchXML(getXmlCache, requestType.toString(), "xmlns=\"");
535 request.headers().add("Content-Type",
536 "application/soap+xml; charset=utf-8; action=\"" + actionString + "/" + requestType + "\"");
537 request.headers().add("Charset", "utf-8");
538 request.headers().set("Host", ipAddress + ":" + onvifPort);
539 request.headers().set("Connection", HttpHeaderValues.CLOSE);
540 request.headers().set("Accept-Encoding", "gzip, deflate");
541 String fullXml = "<s:Envelope xmlns:s=\"http://www.w3.org/2003/05/soap-envelope\"" + extraEnvelope + ">"
543 + "<s:Body xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\">"
544 + getXmlCache + "</s:Body></s:Envelope>";
545 request.headers().add("SOAPAction", "\"" + actionString + "/" + requestType + "\"");
546 ByteBuf bbuf = Unpooled.copiedBuffer(fullXml, StandardCharsets.UTF_8);
547 request.headers().set("Content-Length", bbuf.readableBytes());
548 request.content().clear().writeBytes(bbuf);
550 Bootstrap localBootstap = bootstrap;
551 if (localBootstap == null) {
552 mainEventLoopGroup = new NioEventLoopGroup(2);
553 localBootstap = new Bootstrap();
554 localBootstap.group(mainEventLoopGroup);
555 localBootstap.channel(NioSocketChannel.class);
556 localBootstap.option(ChannelOption.SO_KEEPALIVE, true);
557 localBootstap.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10000);
558 localBootstap.option(ChannelOption.SO_SNDBUF, 1024 * 8);
559 localBootstap.option(ChannelOption.SO_RCVBUF, 1024 * 1024);
560 localBootstap.option(ChannelOption.TCP_NODELAY, true);
561 localBootstap.handler(new ChannelInitializer<SocketChannel>() {
564 public void initChannel(SocketChannel socketChannel) throws Exception {
565 socketChannel.pipeline().addLast("idleStateHandler", new IdleStateHandler(0, 0, 70));
566 socketChannel.pipeline().addLast("HttpClientCodec", new HttpClientCodec());
567 socketChannel.pipeline().addLast("OnvifCodec", new OnvifCodec(getHandle()));
570 bootstrap = localBootstap;
572 if (!mainEventLoopGroup.isShuttingDown()) {
573 localBootstap.connect(new InetSocketAddress(ipAddress, extractPortFromUrl(xAddr)))
574 .addListener(new ChannelFutureListener() {
577 public void operationComplete(@Nullable ChannelFuture future) {
578 if (future == null) {
581 if (future.isSuccess()) {
582 Channel ch = future.channel();
583 ch.writeAndFlush(request);
584 } else { // an error occured
585 logger.debug("Camera is not reachable when using xAddr:{}.", xAddr);
593 logger.debug("ONVIF message not sent as connection is shutting down");
597 OnvifConnection getHandle() {
601 void getIPandPortFromUrl(String url) {
602 int beginIndex = url.indexOf(":");
603 int endIndex = url.indexOf("/", beginIndex);
604 if (beginIndex >= 0 && endIndex == -1) {// 192.168.1.1:8080
605 ipAddress = url.substring(0, beginIndex);
606 onvifPort = Integer.parseInt(url.substring(beginIndex + 1));
607 } else if (beginIndex >= 0 && endIndex > beginIndex) {// 192.168.1.1:8080/foo/bar
608 ipAddress = url.substring(0, beginIndex);
609 onvifPort = Integer.parseInt(url.substring(beginIndex + 1, endIndex));
610 } else {// 192.168.1.1
612 deviceXAddr = "http://" + ipAddress + "/onvif/device_service";
613 logger.debug("No Onvif Port found when parsing:{}", url);
616 deviceXAddr = "http://" + ipAddress + ":" + onvifPort + "/onvif/device_service";
619 public void gotoPreset(int index) {
621 if (index > 0) {// 0 is reserved for HOME as cameras seem to start at preset 1.
622 if (presetTokens.isEmpty()) {
623 logger.warn("Camera did not report any ONVIF preset locations, updating preset tokens now.");
624 sendPTZRequest(RequestType.GetPresets);
626 presetTokenIndex = index - 1;
627 sendPTZRequest(RequestType.GotoPreset);
633 public void eventRecieved(String eventMessage) {
634 String topic = Helper.fetchXML(eventMessage, "Topic", "tns1:");
635 if (topic.isEmpty()) {
636 sendOnvifRequest(RequestType.Renew, subscriptionXAddr);
639 String dataName = Helper.fetchXML(eventMessage, "tt:Data", "Name=\"");
640 String dataValue = Helper.fetchXML(eventMessage, "tt:Data", "Value=\"");
641 logger.debug("Onvif Event Topic:{}, Data:{}, Value:{}", topic, dataName, dataValue);
643 case "RuleEngine/CellMotionDetector/Motion":
644 if ("true".equals(dataValue)) {
645 ipCameraHandler.motionDetected(CHANNEL_CELL_MOTION_ALARM);
646 } else if ("false".equals(dataValue)) {
647 ipCameraHandler.noMotionDetected(CHANNEL_CELL_MOTION_ALARM);
650 case "VideoSource/MotionAlarm":
651 if ("true".equals(dataValue)) {
652 ipCameraHandler.motionDetected(CHANNEL_MOTION_ALARM);
653 } else if ("false".equals(dataValue)) {
654 ipCameraHandler.noMotionDetected(CHANNEL_MOTION_ALARM);
657 case "AudioAnalytics/Audio/DetectedSound":
658 if ("true".equals(dataValue)) {
659 ipCameraHandler.audioDetected();
660 } else if ("false".equals(dataValue)) {
661 ipCameraHandler.noAudioDetected();
664 case "RuleEngine/FieldDetector/ObjectsInside":
665 if ("true".equals(dataValue)) {
666 ipCameraHandler.motionDetected(CHANNEL_FIELD_DETECTION_ALARM);
667 } else if ("false".equals(dataValue)) {
668 ipCameraHandler.noMotionDetected(CHANNEL_FIELD_DETECTION_ALARM);
671 case "RuleEngine/LineDetector/Crossed":
672 if ("ObjectId".equals(dataName)) {
673 ipCameraHandler.motionDetected(CHANNEL_LINE_CROSSING_ALARM);
675 ipCameraHandler.noMotionDetected(CHANNEL_LINE_CROSSING_ALARM);
678 case "RuleEngine/TamperDetector/Tamper":
679 if ("true".equals(dataValue)) {
680 ipCameraHandler.changeAlarmState(CHANNEL_TAMPER_ALARM, OnOffType.ON);
681 } else if ("false".equals(dataValue)) {
682 ipCameraHandler.changeAlarmState(CHANNEL_TAMPER_ALARM, OnOffType.OFF);
685 case "Device/HardwareFailure/StorageFailure":
686 if ("true".equals(dataValue)) {
687 ipCameraHandler.changeAlarmState(CHANNEL_STORAGE_ALARM, OnOffType.ON);
688 } else if ("false".equals(dataValue)) {
689 ipCameraHandler.changeAlarmState(CHANNEL_STORAGE_ALARM, OnOffType.OFF);
692 case "VideoSource/ImageTooDark/AnalyticsService":
693 case "VideoSource/ImageTooDark/ImagingService":
694 case "VideoSource/ImageTooDark/RecordingService":
695 if ("true".equals(dataValue)) {
696 ipCameraHandler.changeAlarmState(CHANNEL_TOO_DARK_ALARM, OnOffType.ON);
697 } else if ("false".equals(dataValue)) {
698 ipCameraHandler.changeAlarmState(CHANNEL_TOO_DARK_ALARM, OnOffType.OFF);
701 case "VideoSource/GlobalSceneChange/AnalyticsService":
702 case "VideoSource/GlobalSceneChange/ImagingService":
703 case "VideoSource/GlobalSceneChange/RecordingService":
704 if ("true".equals(dataValue)) {
705 ipCameraHandler.changeAlarmState(CHANNEL_SCENE_CHANGE_ALARM, OnOffType.ON);
706 } else if ("false".equals(dataValue)) {
707 ipCameraHandler.changeAlarmState(CHANNEL_SCENE_CHANGE_ALARM, OnOffType.OFF);
710 case "VideoSource/ImageTooBright/AnalyticsService":
711 case "VideoSource/ImageTooBright/ImagingService":
712 case "VideoSource/ImageTooBright/RecordingService":
713 if ("true".equals(dataValue)) {
714 ipCameraHandler.changeAlarmState(CHANNEL_TOO_BRIGHT_ALARM, OnOffType.ON);
715 } else if ("false".equals(dataValue)) {
716 ipCameraHandler.changeAlarmState(CHANNEL_TOO_BRIGHT_ALARM, OnOffType.OFF);
719 case "VideoSource/ImageTooBlurry/AnalyticsService":
720 case "VideoSource/ImageTooBlurry/ImagingService":
721 case "VideoSource/ImageTooBlurry/RecordingService":
722 if ("true".equals(dataValue)) {
723 ipCameraHandler.changeAlarmState(CHANNEL_TOO_BLURRY_ALARM, OnOffType.ON);
724 } else if ("false".equals(dataValue)) {
725 ipCameraHandler.changeAlarmState(CHANNEL_TOO_BLURRY_ALARM, OnOffType.OFF);
728 case "RuleEngine/MyRuleDetector/Visitor":
729 if ("true".equals(dataValue)) {
730 ipCameraHandler.changeAlarmState(CHANNEL_DOORBELL, OnOffType.ON);
731 } else if ("false".equals(dataValue)) {
732 ipCameraHandler.changeAlarmState(CHANNEL_DOORBELL, OnOffType.OFF);
735 case "RuleEngine/MyRuleDetector/VehicleDetect":
736 if ("true".equals(dataValue)) {
737 ipCameraHandler.changeAlarmState(CHANNEL_CAR_ALARM, OnOffType.ON);
738 } else if ("false".equals(dataValue)) {
739 ipCameraHandler.changeAlarmState(CHANNEL_CAR_ALARM, OnOffType.OFF);
742 case "RuleEngine/MyRuleDetector/DogCatDetect":
743 if ("true".equals(dataValue)) {
744 ipCameraHandler.changeAlarmState(CHANNEL_ANIMAL_ALARM, OnOffType.ON);
745 } else if ("false".equals(dataValue)) {
746 ipCameraHandler.changeAlarmState(CHANNEL_ANIMAL_ALARM, OnOffType.OFF);
749 case "RuleEngine/MyRuleDetector/FaceDetect":
750 if ("true".equals(dataValue)) {
751 ipCameraHandler.changeAlarmState(CHANNEL_FACE_DETECTED, OnOffType.ON);
752 } else if ("false".equals(dataValue)) {
753 ipCameraHandler.changeAlarmState(CHANNEL_FACE_DETECTED, OnOffType.OFF);
756 case "RuleEngine/MyRuleDetector/PeopleDetect":
757 if ("true".equals(dataValue)) {
758 ipCameraHandler.changeAlarmState(CHANNEL_HUMAN_ALARM, OnOffType.ON);
759 } else if ("false".equals(dataValue)) {
760 ipCameraHandler.changeAlarmState(CHANNEL_HUMAN_ALARM, OnOffType.OFF);
764 logger.debug("Please report this camera has an un-implemented ONVIF event. Topic:{}", topic);
766 sendOnvifRequest(RequestType.Renew, subscriptionXAddr);
769 public boolean supportsPTZ() {
773 public void getStatus() {
775 sendPTZRequest(RequestType.GetStatus);
779 public Float getAbsolutePan() {
780 return currentPanPercentage;
783 public Float getAbsoluteTilt() {
784 return currentTiltPercentage;
787 public Float getAbsoluteZoom() {
788 return currentZoomPercentage;
791 public void setAbsolutePan(Float panValue) {// Value is 0-100% of cameras range
793 currentPanPercentage = panValue;
794 currentPanCamValue = ((((panRangeMin - panRangeMax) * -1) / 100) * panValue + panRangeMin);
798 public void setAbsoluteTilt(Float tiltValue) {// Value is 0-100% of cameras range
800 currentTiltPercentage = tiltValue;
801 currentTiltCamValue = ((((panRangeMin - panRangeMax) * -1) / 100) * tiltValue + tiltRangeMin);
805 public void setAbsoluteZoom(Float zoomValue) {// Value is 0-100% of cameras range
807 currentZoomPercentage = zoomValue;
808 currentZoomCamValue = ((((zoomMin - zoomMax) * -1) / 100) * zoomValue + zoomMin);
812 public void absoluteMove() { // Camera wont move until PTZ values are set, then call this.
814 sendPTZRequest(RequestType.AbsoluteMove);
818 public void setSelectedMediaProfile(int mediaProfileIndex) {
819 this.mediaProfileIndex = mediaProfileIndex;
822 List<String> listOfResults(String message, String heading, String key) {
823 List<String> results = new LinkedList<>();
825 for (int startLookingFromIndex = 0; startLookingFromIndex != -1;) {
826 startLookingFromIndex = message.indexOf(heading, startLookingFromIndex);
827 if (startLookingFromIndex >= 0) {
828 temp = Helper.fetchXML(message.substring(startLookingFromIndex), heading, key);
829 if (!temp.isEmpty()) {
830 logger.trace("String was found:{}", temp);
833 return results;// key string must not exist so stop looking.
835 startLookingFromIndex += temp.length();
841 void parsePresets(String message) {
842 List<StateOption> presets = new ArrayList<>();
843 int counter = 1;// Presets start at 1 not 0. HOME may be added to index 0.
844 presetTokens = listOfResults(message, "<tptz:Preset", "token=\"");
845 presetNames = listOfResults(message, "<tptz:Preset", "<tt:Name>");
846 if (presetTokens.size() != presetNames.size()) {
847 logger.warn("Camera did not report the same number of Tokens and Names for PTZ presets");
850 for (String value : presetNames) {
851 presets.add(new StateOption(Integer.toString(counter++), value));
853 ipCameraHandler.stateDescriptionProvider
854 .setStateOptions(new ChannelUID(ipCameraHandler.getThing().getUID(), CHANNEL_GOTO_PRESET), presets);
857 void parseProfiles(String message) {
858 mediaProfileTokens = listOfResults(message, "<trt:Profiles", "token=\"");
859 if (mediaProfileIndex >= mediaProfileTokens.size()) {
861 "You have set the media profile to {} when the camera reported {} profiles. Falling back to mainstream 0.",
862 mediaProfileIndex, mediaProfileTokens.size());
863 mediaProfileIndex = 0;
867 void processPTZLocation(String result) {
868 logger.debug("Processing new PTZ location now");
870 int beginIndex = result.indexOf("x=\"");
871 int endIndex = result.indexOf("\"", (beginIndex + 3));
872 if (beginIndex >= 0 && endIndex >= 0) {
873 currentPanCamValue = Float.parseFloat(result.substring(beginIndex + 3, endIndex));
874 currentPanPercentage = (((panRangeMin - currentPanCamValue) * -1) / ((panRangeMin - panRangeMax) * -1))
876 logger.debug("Pan is updating to:{} and the cam value is {}", Math.round(currentPanPercentage),
880 "Binding could not determin the cameras current PTZ location. Not all cameras respond to GetStatus requests.");
884 beginIndex = result.indexOf("y=\"");
885 endIndex = result.indexOf("\"", (beginIndex + 3));
886 if (beginIndex >= 0 && endIndex >= 0) {
887 currentTiltCamValue = Float.parseFloat(result.substring(beginIndex + 3, endIndex));
888 currentTiltPercentage = (((tiltRangeMin - currentTiltCamValue) * -1) / ((tiltRangeMin - tiltRangeMax) * -1))
890 logger.debug("Tilt is updating to:{} and the cam value is {}", Math.round(currentTiltPercentage),
891 currentTiltCamValue);
896 beginIndex = result.lastIndexOf("x=\"");
897 endIndex = result.indexOf("\"", (beginIndex + 3));
898 if (beginIndex >= 0 && endIndex >= 0) {
899 currentZoomCamValue = Float.parseFloat(result.substring(beginIndex + 3, endIndex));
900 currentZoomPercentage = (((zoomMin - currentZoomCamValue) * -1) / ((zoomMin - zoomMax) * -1)) * 100;
901 logger.debug("Zoom is updating to:{} and the cam value is {}", Math.round(currentZoomPercentage),
902 currentZoomCamValue);
908 public void sendPTZRequest(RequestType requestType) {
910 logger.debug("ONVIF was not connected when a PTZ request was made, connecting now");
911 connect(usingEvents);
913 sendOnvifRequest(requestType, ptzXAddr);
916 public void sendEventRequest(RequestType requestType) {
917 sendOnvifRequest(requestType, eventXAddr);
920 public void connect(boolean useEvents) {
924 logger.debug("Connecting {} to ONVIF", ipAddress);
925 threadPool = Executors.newScheduledThreadPool(2);
926 sendOnvifRequest(RequestType.GetSystemDateAndTime, deviceXAddr);
927 usingEvents = useEvents;
928 sendOnvifRequest(RequestType.GetCapabilities, deviceXAddr);
935 public boolean isConnected() {
944 private void cleanup() {
945 if (!isConnected && !mainEventLoopGroup.isShuttingDown()) {
947 mainEventLoopGroup.shutdownGracefully();
948 mainEventLoopGroup.awaitTermination(3, TimeUnit.SECONDS);
949 } catch (InterruptedException e) {
950 logger.warn("ONVIF was not cleanly shutdown, due to being interrupted");
952 logger.debug("Eventloop is shutdown:{}", mainEventLoopGroup.isShutdown());
954 threadPool.shutdown();
959 public void disconnect() {
960 connecting.lock();// Lock out multiple disconnect()/connect() attempts as we try to send Unsubscribe.
962 isConnected = false;// isConnected is not thread safe, connecting.lock() used as fix.
963 if (bootstrap != null) {
964 if (usingEvents && !mainEventLoopGroup.isShuttingDown()) {
965 // Some cameras may continue to send events even when they can't reach a server.
966 sendOnvifRequest(RequestType.Unsubscribe, subscriptionXAddr);
968 // give time for the Unsubscribe request to be sent, shutdownGracefully will try to send it first.
969 threadPool.schedule(this::cleanup, 50, TimeUnit.MILLISECONDS);