2 * Copyright (c) 2010-2024 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.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;
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;
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;
69 * The {@link OnvifConnection} This is a basic Netty implementation for connecting and communicating to ONVIF cameras.
71 * @author Matthew Skinner - Initial contribution
72 * @author Kai Kreuzer - Improve handling for certain cameras
76 public class OnvifConnection {
77 public enum RequestType {
87 CreatePullPointSubscription,
91 GetServiceCapabilities,
107 GetConfigurationOptions,
116 private final Logger logger = LoggerFactory.getLogger(getClass());
117 private ScheduledExecutorService threadPool = Executors.newScheduledThreadPool(2);
118 private @Nullable Bootstrap bootstrap;
119 private EventLoopGroup mainEventLoopGroup = new NioEventLoopGroup(2);
120 private ReentrantLock connecting = new ReentrantLock();
121 private String ipAddress = "";
122 private String user = "";
123 private String password = "";
124 private int onvifPort = 80;
125 private String deviceXAddr = "http://" + ipAddress + "/onvif/device_service";
126 private String eventXAddr = "http://" + ipAddress + "/onvif/device_service";
127 private String mediaXAddr = "http://" + ipAddress + "/onvif/device_service";
128 @SuppressWarnings("unused")
129 private String imagingXAddr = "http://" + ipAddress + "/onvif/device_service";
130 private String ptzXAddr = "http://" + ipAddress + "/onvif/ptz_service";
131 private String subscriptionXAddr = "http://" + ipAddress + "/onvif/device_service";
132 private boolean isConnected = false;
133 private int mediaProfileIndex = 0;
134 // private String snapshotUri = "";
135 private String rtspUri = "";
136 private IpCameraHandler ipCameraHandler;
137 private boolean usingEvents = false;
139 // These hold the cameras PTZ position in the range that the camera uses, ie
141 private Float panRangeMin = -1.0f;
142 private Float panRangeMax = 1.0f;
143 private Float tiltRangeMin = -1.0f;
144 private Float tiltRangeMax = 1.0f;
145 private Float zoomMin = 0.0f;
146 private Float zoomMax = 1.0f;
147 // These hold the PTZ values for updating openHABs controls in 0-100 range
148 private Float currentPanPercentage = 0.0f;
149 private Float currentTiltPercentage = 0.0f;
150 private Float currentZoomPercentage = 0.0f;
151 private Float currentPanCamValue = 0.0f;
152 private Float currentTiltCamValue = 0.0f;
153 private Float currentZoomCamValue = 0.0f;
154 private String ptzNodeToken = "000";
155 private String ptzConfigToken = "000";
156 private int presetTokenIndex = 0;
157 private List<String> presetTokens = new LinkedList<>();
158 private List<String> presetNames = new LinkedList<>();
159 private List<String> mediaProfileTokens = new LinkedList<>();
160 private boolean ptzDevice = true;
162 public OnvifConnection(IpCameraHandler ipCameraHandler, String ipAddress, String user, String password) {
163 this.ipCameraHandler = ipCameraHandler;
164 if (!ipAddress.isEmpty()) {
166 this.password = password;
167 getIPandPortFromUrl(ipAddress);
171 private String getXml(RequestType requestType) {
173 switch (requestType) {
175 return "<AbsoluteMove xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><ProfileToken>"
176 + mediaProfileTokens.get(mediaProfileIndex) + "</ProfileToken><Position><PanTilt x=\""
177 + currentPanCamValue + "\" y=\"" + currentTiltCamValue
178 + "\" space=\"http://www.onvif.org/ver10/tptz/PanTiltSpaces/PositionGenericSpace\">\n"
179 + "</PanTilt>\n" + "<Zoom x=\"" + currentZoomCamValue
180 + "\" space=\"http://www.onvif.org/ver10/tptz/ZoomSpaces/PositionGenericSpace\">\n"
181 + "</Zoom>\n" + "</Position>\n"
182 + "<Speed><PanTilt x=\"0.1\" y=\"0.1\" space=\"http://www.onvif.org/ver10/tptz/PanTiltSpaces/GenericSpeedSpace\"></PanTilt><Zoom x=\"1.0\" space=\"http://www.onvif.org/ver10/tptz/ZoomSpaces/ZoomGenericSpeedSpace\"></Zoom>\n"
183 + "</Speed></AbsoluteMove>";
184 case AddPTZConfiguration: // not tested to work yet
185 return "<AddPTZConfiguration xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><ProfileToken>"
186 + mediaProfileTokens.get(mediaProfileIndex) + "</ProfileToken><ConfigurationToken>"
187 + ptzConfigToken + "</ConfigurationToken></AddPTZConfiguration>";
188 case ContinuousMoveLeft:
189 return "<ContinuousMove xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><ProfileToken>"
190 + mediaProfileTokens.get(mediaProfileIndex)
191 + "</ProfileToken><Velocity><PanTilt x=\"-0.5\" y=\"0\" xmlns=\"http://www.onvif.org/ver10/schema\"/></Velocity></ContinuousMove>";
192 case ContinuousMoveRight:
193 return "<ContinuousMove xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><ProfileToken>"
194 + mediaProfileTokens.get(mediaProfileIndex)
195 + "</ProfileToken><Velocity><PanTilt x=\"0.5\" y=\"0\" xmlns=\"http://www.onvif.org/ver10/schema\"/></Velocity></ContinuousMove>";
196 case ContinuousMoveUp:
197 return "<ContinuousMove xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><ProfileToken>"
198 + mediaProfileTokens.get(mediaProfileIndex)
199 + "</ProfileToken><Velocity><PanTilt x=\"0\" y=\"-0.5\" xmlns=\"http://www.onvif.org/ver10/schema\"/></Velocity></ContinuousMove>";
200 case ContinuousMoveDown:
201 return "<ContinuousMove xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><ProfileToken>"
202 + mediaProfileTokens.get(mediaProfileIndex)
203 + "</ProfileToken><Velocity><PanTilt x=\"0\" y=\"0.5\" xmlns=\"http://www.onvif.org/ver10/schema\"/></Velocity></ContinuousMove>";
205 return "<Stop xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><ProfileToken>"
206 + mediaProfileTokens.get(mediaProfileIndex)
207 + "</ProfileToken><PanTilt>true</PanTilt><Zoom>true</Zoom></Stop>";
208 case ContinuousMoveIn:
209 return "<ContinuousMove xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><ProfileToken>"
210 + mediaProfileTokens.get(mediaProfileIndex)
211 + "</ProfileToken><Velocity><Zoom x=\"0.5\" xmlns=\"http://www.onvif.org/ver10/schema\"/></Velocity></ContinuousMove>";
212 case ContinuousMoveOut:
213 return "<ContinuousMove xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><ProfileToken>"
214 + mediaProfileTokens.get(mediaProfileIndex)
215 + "</ProfileToken><Velocity><Zoom x=\"-0.5\" xmlns=\"http://www.onvif.org/ver10/schema\"/></Velocity></ContinuousMove>";
216 case CreatePullPointSubscription:
217 return "<CreatePullPointSubscription xmlns=\"http://www.onvif.org/ver10/events/wsdl\"><InitialTerminationTime>PT600S</InitialTerminationTime></CreatePullPointSubscription>";
218 case GetCapabilities:
219 return "<GetCapabilities xmlns=\"http://www.onvif.org/ver10/device/wsdl\"><Category>All</Category></GetCapabilities>";
221 case GetDeviceInformation:
222 return "<GetDeviceInformation xmlns=\"http://www.onvif.org/ver10/device/wsdl\"/>";
224 return "<GetProfiles xmlns=\"http://www.onvif.org/ver10/media/wsdl\"/>";
225 case GetServiceCapabilities:
226 return "<GetServiceCapabilities xmlns=\"http://docs.oasis-open.org/wsn/b-2/\"></GetServiceCapabilities>";
228 return "<GetSnapshotUri xmlns=\"http://www.onvif.org/ver10/media/wsdl\"><ProfileToken>"
229 + mediaProfileTokens.get(mediaProfileIndex) + "</ProfileToken></GetSnapshotUri>";
231 return "<GetStreamUri xmlns=\"http://www.onvif.org/ver10/media/wsdl\"><StreamSetup><Stream xmlns=\"http://www.onvif.org/ver10/schema\">RTP-Unicast</Stream><Transport xmlns=\"http://www.onvif.org/ver10/schema\"><Protocol>RTSP</Protocol></Transport></StreamSetup><ProfileToken>"
232 + mediaProfileTokens.get(mediaProfileIndex) + "</ProfileToken></GetStreamUri>";
233 case GetSystemDateAndTime:
234 return "<GetSystemDateAndTime xmlns=\"http://www.onvif.org/ver10/device/wsdl\"/>";
236 return "<Subscribe xmlns=\"http://docs.oasis-open.org/wsn/b-2/\"><ConsumerReference><Address>http://"
237 + ipCameraHandler.hostIp + ":" + SERVLET_PORT + "/ipcamera/"
238 + ipCameraHandler.getThing().getUID().getId()
239 + "/OnvifEvent</Address></ConsumerReference></Subscribe>";
241 return "<Unsubscribe xmlns=\"http://docs.oasis-open.org/wsn/b-2/\"></Unsubscribe>";
243 return "<PullMessages xmlns=\"http://www.onvif.org/ver10/events/wsdl\"><Timeout>PT8S</Timeout><MessageLimit>1</MessageLimit></PullMessages>";
244 case GetEventProperties:
245 return "<GetEventProperties xmlns=\"http://www.onvif.org/ver10/events/wsdl\"/>";
246 case RelativeMoveLeft:
247 return "<RelativeMove xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><ProfileToken>"
248 + mediaProfileTokens.get(mediaProfileIndex)
249 + "</ProfileToken><Translation><PanTilt x=\"0.05000000\" y=\"0\" xmlns=\"http://www.onvif.org/ver10/schema\"/></Translation></RelativeMove>";
250 case RelativeMoveRight:
251 return "<RelativeMove xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><ProfileToken>"
252 + mediaProfileTokens.get(mediaProfileIndex)
253 + "</ProfileToken><Translation><PanTilt x=\"-0.05000000\" y=\"0\" xmlns=\"http://www.onvif.org/ver10/schema\"/></Translation></RelativeMove>";
255 return "<RelativeMove xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><ProfileToken>"
256 + mediaProfileTokens.get(mediaProfileIndex)
257 + "</ProfileToken><Translation><PanTilt x=\"0\" y=\"0.100000000\" xmlns=\"http://www.onvif.org/ver10/schema\"/></Translation></RelativeMove>";
258 case RelativeMoveDown:
259 return "<RelativeMove xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><ProfileToken>"
260 + mediaProfileTokens.get(mediaProfileIndex)
261 + "</ProfileToken><Translation><PanTilt x=\"0\" y=\"-0.100000000\" xmlns=\"http://www.onvif.org/ver10/schema\"/></Translation></RelativeMove>";
263 return "<RelativeMove xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><ProfileToken>"
264 + mediaProfileTokens.get(mediaProfileIndex)
265 + "</ProfileToken><Translation><Zoom x=\"0.0240506344\" xmlns=\"http://www.onvif.org/ver10/schema\"/></Translation></RelativeMove>";
266 case RelativeMoveOut:
267 return "<RelativeMove xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><ProfileToken>"
268 + mediaProfileTokens.get(mediaProfileIndex)
269 + "</ProfileToken><Translation><Zoom x=\"-0.0240506344\" xmlns=\"http://www.onvif.org/ver10/schema\"/></Translation></RelativeMove>";
271 return "<Renew xmlns=\"http://docs.oasis-open.org/wsn/b-2\"><TerminationTime>PT1M</TerminationTime></Renew>";
272 case GetConfigurations:
273 return "<GetConfigurations xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"></GetConfigurations>";
274 case GetConfigurationOptions:
275 return "<GetConfigurationOptions xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><ConfigurationToken>"
276 + ptzConfigToken + "</ConfigurationToken></GetConfigurationOptions>";
277 case GetConfiguration:
278 return "<GetConfiguration xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><PTZConfigurationToken>"
279 + ptzConfigToken + "</PTZConfigurationToken></GetConfiguration>";
280 case SetConfiguration:// not tested to work yet
281 return "<SetConfiguration xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><PTZConfiguration><NodeToken>"
283 + "</NodeToken><DefaultAbsolutePantTiltPositionSpace>AbsolutePanTiltPositionSpace</DefaultAbsolutePantTiltPositionSpace><DefaultAbsoluteZoomPositionSpace>AbsoluteZoomPositionSpace</DefaultAbsoluteZoomPositionSpace></PTZConfiguration></SetConfiguration>";
285 return "<GetNodes xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"></GetNodes>";
287 return "<GetStatus xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><ProfileToken>"
288 + mediaProfileTokens.get(mediaProfileIndex) + "</ProfileToken></GetStatus>";
290 return "<GotoPreset xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><ProfileToken>"
291 + mediaProfileTokens.get(mediaProfileIndex) + "</ProfileToken><PresetToken>"
292 + presetTokens.get(presetTokenIndex) + "</PresetToken></GotoPreset>";
294 return "<GetPresets xmlns=\"http://www.onvif.org/ver20/ptz/wsdl\"><ProfileToken>"
295 + mediaProfileTokens.get(mediaProfileIndex) + "</ProfileToken></GetPresets>";
297 } catch (IndexOutOfBoundsException e) {
299 logger.debug("IndexOutOfBoundsException occured, camera is not connected via ONVIF: {}",
302 logger.debug("IndexOutOfBoundsException occured, {}", e.getMessage());
308 public void processReply(String message) {
309 logger.trace("ONVIF reply is: {}", message);
310 if (message.contains("PullMessagesResponse")) {
311 eventRecieved(message);
312 } else if (message.contains("RenewResponse")) {
313 sendOnvifRequest(RequestType.PullMessages, subscriptionXAddr);
314 } else if (message.contains("GetSystemDateAndTimeResponse")) {// 1st to be sent.
315 setIsConnected(true);
316 parseDateAndTime(message);
317 logger.debug("openHAB UTC dateTime is: {}", getUTCdateTime());
318 } else if (message.contains("GetCapabilitiesResponse")) {// 2nd to be sent.
320 sendOnvifRequest(RequestType.GetProfiles, mediaXAddr);
321 } else if (message.contains("GetProfilesResponse")) {// 3rd to be sent.
322 setIsConnected(true);
323 parseProfiles(message);
324 sendOnvifRequest(RequestType.GetSnapshotUri, mediaXAddr);
325 sendOnvifRequest(RequestType.GetStreamUri, mediaXAddr);
327 sendPTZRequest(RequestType.GetNodes);
329 if (usingEvents) {// stops API cameras from getting sent ONVIF events.
330 sendOnvifRequest(RequestType.GetEventProperties, eventXAddr);
331 sendOnvifRequest(RequestType.GetServiceCapabilities, eventXAddr);
333 } else if (message.contains("GetServiceCapabilitiesResponse")) {
334 if (message.contains("WSSubscriptionPolicySupport=\"true\"")) {
335 sendOnvifRequest(RequestType.Subscribe, eventXAddr);
337 } else if (message.contains("GetEventPropertiesResponse")) {
338 sendOnvifRequest(RequestType.CreatePullPointSubscription, eventXAddr);
339 } else if (message.contains("CreatePullPointSubscriptionResponse")) {
340 subscriptionXAddr = Helper.fetchXML(message, "SubscriptionReference>", "Address>");
341 logger.debug("subscriptionXAddr={}", subscriptionXAddr);
342 sendOnvifRequest(RequestType.PullMessages, subscriptionXAddr);
343 } else if (message.contains("GetStatusResponse")) {
344 processPTZLocation(message);
345 } else if (message.contains("GetPresetsResponse")) {
346 parsePresets(message);
347 } else if (message.contains("GetConfigurationsResponse")) {
348 sendPTZRequest(RequestType.GetPresets);
349 ptzConfigToken = Helper.fetchXML(message, "PTZConfiguration", "token=\"");
350 logger.debug("ptzConfigToken={}", ptzConfigToken);
351 sendPTZRequest(RequestType.GetConfigurationOptions);
352 } else if (message.contains("GetNodesResponse")) {
353 sendPTZRequest(RequestType.GetStatus);
354 ptzNodeToken = Helper.fetchXML(message, "", "token=\"");
355 logger.debug("ptzNodeToken={}", ptzNodeToken);
356 sendPTZRequest(RequestType.GetConfigurations);
357 } else if (message.contains("GetDeviceInformationResponse")) {
358 logger.debug("GetDeviceInformationResponse received");
359 } else if (message.contains("GetSnapshotUriResponse")) {
360 String url = Helper.fetchXML(message, ":MediaUri", ":Uri");
361 if (!url.isBlank()) {
362 logger.debug("GetSnapshotUri: {}", url);
363 if (ipCameraHandler.snapshotUri.isEmpty()
364 && !"ffmpeg".equals(ipCameraHandler.cameraConfig.getSnapshotUrl())) {
365 ipCameraHandler.snapshotUri = ipCameraHandler.getCorrectUrlFormat(url);
366 if (ipCameraHandler.getPortFromShortenedUrl(url) != ipCameraHandler.cameraConfig.getPort()) {
367 logger.warn("ONVIF is reporting the snapshot does not match the things configured port of:{}",
368 ipCameraHandler.cameraConfig.getPort());
372 } else if (message.contains("GetStreamUriResponse")) {
373 String xml = StringUtils.unEscapeXml(Helper.fetchXML(message, ":MediaUri", ":Uri>"));
376 logger.debug("GetStreamUri: {}", rtspUri);
377 if (ipCameraHandler.cameraConfig.getFfmpegInput().isEmpty()) {
378 ipCameraHandler.rtspUri = rtspUri;
385 * The {@link removeIPandPortFromUrl} Will throw away all text before the cameras IP, also removes the IP and the
387 * leaving just the URL.
389 * @author Matthew Skinner - Initial contribution
391 String removeIPandPortFromUrl(String url) {
392 int index = url.indexOf("//");
393 if (index != -1) {// now remove the :port
394 index = url.indexOf("/", index + 2);
397 logger.debug("We hit an issue parsing url: {}", url);
400 return url.substring(index);
403 String extractIPportFromUrl(String url) {
404 int startIndex = url.indexOf("//") + 2;
405 int endIndex = url.indexOf("/", startIndex);// skip past any :port to the slash /
406 if (startIndex != -1 && endIndex != -1) {
407 return url.substring(startIndex, endIndex);
409 logger.debug("We hit an issue extracting IP:PORT from url: {}", url);
413 int extractPortFromUrl(String url) {
414 int startIndex = url.indexOf("//") + 2;// skip past http://
415 startIndex = url.indexOf(":", startIndex);
416 if (startIndex == -1) {// no port defined so use port 80
419 int endIndex = url.indexOf("/", startIndex);// skip past any :port to the slash /
420 if (endIndex == -1) {
423 return Integer.parseInt(url.substring(startIndex + 1, endIndex));
426 void parseXAddr(String message) {
427 // Normally I would search '<tt:XAddr>' instead but Foscam needed this work around.
428 String temp = Helper.fetchXML(message, "<tt:Device", "tt:XAddr");
429 if (!temp.isEmpty()) {
431 logger.debug("deviceXAddr: {}", deviceXAddr);
433 temp = Helper.fetchXML(message, "<tt:Events", "tt:XAddr");
434 if (!temp.isEmpty()) {
435 subscriptionXAddr = eventXAddr = temp;
436 logger.debug("eventsXAddr: {}", eventXAddr);
438 temp = Helper.fetchXML(message, "<tt:Media", "tt:XAddr");
439 if (!temp.isEmpty()) {
441 logger.debug("mediaXAddr: {}", mediaXAddr);
444 ptzXAddr = Helper.fetchXML(message, "<tt:PTZ", "tt:XAddr");
445 if (ptzXAddr.isEmpty()) {
447 logger.debug("Camera has no ONVIF PTZ support.");
448 List<org.openhab.core.thing.Channel> removeChannels = new ArrayList<>();
449 org.openhab.core.thing.Channel channel = ipCameraHandler.getThing().getChannel(CHANNEL_PAN);
450 if (channel != null) {
451 removeChannels.add(channel);
453 channel = ipCameraHandler.getThing().getChannel(CHANNEL_TILT);
454 if (channel != null) {
455 removeChannels.add(channel);
457 channel = ipCameraHandler.getThing().getChannel(CHANNEL_ZOOM);
458 if (channel != null) {
459 removeChannels.add(channel);
461 ipCameraHandler.removeChannels(removeChannels);
463 logger.debug("ptzXAddr: {}", ptzXAddr);
467 private void parseDateAndTime(String message) {
468 String minute = Helper.fetchXML(message, "UTCDateTime", "Minute>");
469 String hour = Helper.fetchXML(message, "UTCDateTime", "Hour>");
470 String second = Helper.fetchXML(message, "UTCDateTime", "Second>");
471 String day = Helper.fetchXML(message, "UTCDateTime", "Day>");
472 String month = Helper.fetchXML(message, "UTCDateTime", "Month>");
473 String year = Helper.fetchXML(message, "UTCDateTime", "Year>");
474 logger.debug("Camera UTC dateTime is: {}-{}-{}T{}:{}:{}", year, month, day, hour, minute, second);
477 private String getUTCdateTime() {
478 SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'");
479 format.setTimeZone(TimeZone.getTimeZone("UTC"));
480 return format.format(new Date());
483 String createNonce() {
484 Random nonce = new SecureRandom();
485 return "" + nonce.nextInt();
488 String encodeBase64(String raw) {
489 return Base64.getEncoder().encodeToString(raw.getBytes());
492 String createDigest(String nOnce, String dateTime) {
493 String beforeEncryption = nOnce + dateTime + password;
494 MessageDigest msgDigest;
495 byte[] encryptedRaw = null;
497 msgDigest = MessageDigest.getInstance("SHA-1");
499 msgDigest.update(beforeEncryption.getBytes(StandardCharsets.UTF_8));
500 encryptedRaw = msgDigest.digest();
501 } catch (NoSuchAlgorithmException e) {
503 return Base64.getEncoder().encodeToString(encryptedRaw);
506 public void sendOnvifRequest(RequestType requestType, String xAddr) {
507 logger.trace("Sending ONVIF request: {} to {}", requestType, xAddr);
508 int port = extractPortFromUrl(xAddr);
509 String security = "";
510 String extraEnvelope = "";
511 String headerTo = "";
512 String getXmlCache = getXml(requestType);
513 if (requestType.equals(RequestType.CreatePullPointSubscription) || requestType.equals(RequestType.PullMessages)
514 || requestType.equals(RequestType.Renew) || requestType.equals(RequestType.Unsubscribe)) {
515 headerTo = "<a:To s:mustUnderstand=\"1\">" + xAddr + "</a:To>";
516 extraEnvelope = " xmlns:a=\"http://www.w3.org/2005/08/addressing\"";
519 if (!password.isEmpty() && !requestType.equals(RequestType.GetSystemDateAndTime)) {
520 String nonce = createNonce();
521 String dateTime = getUTCdateTime();
522 String digest = createDigest(nonce, dateTime);
523 security = "<Security s:mustUnderstand=\"1\" xmlns=\"http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd\"><UsernameToken><Username>"
525 + "</Username><Password Type=\"http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordDigest\">"
527 + "</Password><Nonce EncodingType=\"http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary\">"
528 + encodeBase64(nonce)
529 + "</Nonce><Created xmlns=\"http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd\">"
530 + dateTime + "</Created></UsernameToken></Security>";
531 headers = "<s:Header>" + security + headerTo + "</s:Header>";
532 } else {// GetSystemDateAndTime must not be password protected as per spec.
535 FullHttpRequest request = new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, new HttpMethod("POST"),
536 removeIPandPortFromUrl(xAddr));
537 String actionString = Helper.fetchXML(getXmlCache, requestType.toString(), "xmlns=\"");
538 request.headers().add("Content-Type",
539 "application/soap+xml; charset=utf-8; action=\"" + actionString + "/" + requestType + "\"");
540 request.headers().add("Charset", "utf-8");
541 // Tapo brand have different ports for the event xAddr to the other xAddr, can't use 1 port for all ONVIF calls.
542 request.headers().set("Host", ipAddress + ":" + port);
543 request.headers().set("Connection", HttpHeaderValues.CLOSE);
544 request.headers().set("Accept-Encoding", "gzip, deflate");
545 String fullXml = "<s:Envelope xmlns:s=\"http://www.w3.org/2003/05/soap-envelope\"" + extraEnvelope + ">"
547 + "<s:Body xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\">"
548 + getXmlCache + "</s:Body></s:Envelope>";
549 request.headers().add("SOAPAction", "\"" + actionString + "/" + requestType + "\"");
550 ByteBuf bbuf = Unpooled.copiedBuffer(fullXml, StandardCharsets.UTF_8);
551 request.headers().set("Content-Length", bbuf.readableBytes());
552 request.content().clear().writeBytes(bbuf);
554 Bootstrap localBootstap = bootstrap;
555 if (localBootstap == null) {
556 mainEventLoopGroup = new NioEventLoopGroup(2);
557 localBootstap = new Bootstrap();
558 localBootstap.group(mainEventLoopGroup);
559 localBootstap.channel(NioSocketChannel.class);
560 localBootstap.option(ChannelOption.SO_KEEPALIVE, true);
561 localBootstap.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10000);
562 localBootstap.option(ChannelOption.SO_SNDBUF, 1024 * 8);
563 localBootstap.option(ChannelOption.SO_RCVBUF, 1024 * 1024);
564 localBootstap.option(ChannelOption.TCP_NODELAY, true);
565 localBootstap.handler(new ChannelInitializer<SocketChannel>() {
568 public void initChannel(SocketChannel socketChannel) throws Exception {
569 socketChannel.pipeline().addLast("idleStateHandler", new IdleStateHandler(20, 20, 20));
570 socketChannel.pipeline().addLast("HttpClientCodec", new HttpClientCodec());
571 socketChannel.pipeline().addLast("OnvifCodec", new OnvifCodec(getHandle()));
574 bootstrap = localBootstap;
576 if (!mainEventLoopGroup.isShuttingDown()) {
577 // Tapo brand have different ports for the event xAddr to the other xAddr, can't use 1 port for all calls.
578 localBootstap.connect(new InetSocketAddress(ipAddress, port)).addListener(new ChannelFutureListener() {
580 public void operationComplete(@Nullable ChannelFuture future) {
581 if (future == null) {
584 if (future.isSuccess()) {
585 Channel ch = future.channel();
586 ch.writeAndFlush(request);
587 } else { // an error occurred
588 if (future.isDone() && !future.isCancelled()) {
589 Throwable cause = future.cause();
590 String msg = cause.getMessage();
591 logger.debug("connect failed - cause {}", cause.getMessage());
592 if (cause instanceof ConnectTimeoutException) {
593 usingEvents = false;// Prevent Unsubscribe from being sent
594 ipCameraHandler.cameraCommunicationError(
595 "Camera timed out when trying to connect to the ONVIF port:" + port);
596 } else if ((cause instanceof ConnectException) && msg != null
597 && msg.contains("Connection refused")) {
598 usingEvents = false;// Prevent Unsubscribe from being sent
599 ipCameraHandler.cameraCommunicationError(
600 "Camera refused to connect when using ONVIF to port:" + port);
610 logger.debug("ONVIF message not sent as connection is shutting down");
614 OnvifConnection getHandle() {
618 void getIPandPortFromUrl(String url) {
619 int beginIndex = url.indexOf(":");
620 int endIndex = url.indexOf("/", beginIndex);
621 if (beginIndex >= 0 && endIndex == -1) {// 192.168.1.1:8080
622 ipAddress = url.substring(0, beginIndex);
623 onvifPort = Integer.parseInt(url.substring(beginIndex + 1));
624 } else if (beginIndex >= 0 && endIndex > beginIndex) {// 192.168.1.1:8080/foo/bar
625 ipAddress = url.substring(0, beginIndex);
626 onvifPort = Integer.parseInt(url.substring(beginIndex + 1, endIndex));
627 } else {// 192.168.1.1
629 deviceXAddr = "http://" + ipAddress + "/onvif/device_service";
630 logger.debug("No ONVIF Port found when parsing: {}", url);
633 deviceXAddr = "http://" + ipAddress + ":" + onvifPort + "/onvif/device_service";
636 public void gotoPreset(int index) {
638 if (index > 0) {// 0 is reserved for HOME as cameras seem to start at preset 1.
639 if (presetTokens.isEmpty()) {
640 logger.warn("Camera did not report any ONVIF preset locations, updating preset tokens now.");
641 sendPTZRequest(RequestType.GetPresets);
643 presetTokenIndex = index - 1;
644 sendPTZRequest(RequestType.GotoPreset);
650 public void eventRecieved(String eventMessage) {
651 String topic = Helper.fetchXML(eventMessage, "Topic", "tns1:");
652 if (topic.isEmpty()) {
653 sendOnvifRequest(RequestType.Renew, subscriptionXAddr);
656 String dataName = Helper.fetchXML(eventMessage, "tt:Data", "Name=\"");
657 String dataValue = Helper.fetchXML(eventMessage, "tt:Data", "Value=\"");
658 logger.debug("ONVIF Event Topic: {}, Data: {}, Value: {}", topic, dataName, dataValue);
660 case "RuleEngine/CellMotionDetector/Motion":
661 if ("true".equals(dataValue)) {
662 ipCameraHandler.motionDetected(CHANNEL_CELL_MOTION_ALARM);
663 } else if ("false".equals(dataValue)) {
664 ipCameraHandler.noMotionDetected(CHANNEL_CELL_MOTION_ALARM);
667 case "VideoSource/MotionAlarm":
668 if ("true".equals(dataValue)) {
669 ipCameraHandler.motionDetected(CHANNEL_MOTION_ALARM);
670 } else if ("false".equals(dataValue)) {
671 ipCameraHandler.noMotionDetected(CHANNEL_MOTION_ALARM);
674 case "AudioAnalytics/Audio/DetectedSound":
675 if ("true".equals(dataValue)) {
676 ipCameraHandler.audioDetected();
677 } else if ("false".equals(dataValue)) {
678 ipCameraHandler.noAudioDetected();
681 case "RuleEngine/FieldDetector/ObjectsInside":
682 if ("true".equals(dataValue)) {
683 ipCameraHandler.motionDetected(CHANNEL_FIELD_DETECTION_ALARM);
684 } else if ("false".equals(dataValue)) {
685 ipCameraHandler.noMotionDetected(CHANNEL_FIELD_DETECTION_ALARM);
688 case "RuleEngine/LineDetector/Crossed":
689 if ("ObjectId".equals(dataName)) {
690 ipCameraHandler.motionDetected(CHANNEL_LINE_CROSSING_ALARM);
692 ipCameraHandler.noMotionDetected(CHANNEL_LINE_CROSSING_ALARM);
695 case "RuleEngine/TamperDetector/Tamper":
696 if ("true".equals(dataValue)) {
697 ipCameraHandler.changeAlarmState(CHANNEL_TAMPER_ALARM, OnOffType.ON);
698 } else if ("false".equals(dataValue)) {
699 ipCameraHandler.changeAlarmState(CHANNEL_TAMPER_ALARM, OnOffType.OFF);
702 case "Device/HardwareFailure/StorageFailure":
703 if ("true".equals(dataValue)) {
704 ipCameraHandler.changeAlarmState(CHANNEL_STORAGE_ALARM, OnOffType.ON);
705 } else if ("false".equals(dataValue)) {
706 ipCameraHandler.changeAlarmState(CHANNEL_STORAGE_ALARM, OnOffType.OFF);
709 case "VideoSource/ImageTooDark/AnalyticsService":
710 case "VideoSource/ImageTooDark/ImagingService":
711 case "VideoSource/ImageTooDark/RecordingService":
712 if ("true".equals(dataValue)) {
713 ipCameraHandler.changeAlarmState(CHANNEL_TOO_DARK_ALARM, OnOffType.ON);
714 } else if ("false".equals(dataValue)) {
715 ipCameraHandler.changeAlarmState(CHANNEL_TOO_DARK_ALARM, OnOffType.OFF);
718 case "VideoSource/GlobalSceneChange/AnalyticsService":
719 case "VideoSource/GlobalSceneChange/ImagingService":
720 case "VideoSource/GlobalSceneChange/RecordingService":
721 if ("true".equals(dataValue)) {
722 ipCameraHandler.changeAlarmState(CHANNEL_SCENE_CHANGE_ALARM, OnOffType.ON);
723 } else if ("false".equals(dataValue)) {
724 ipCameraHandler.changeAlarmState(CHANNEL_SCENE_CHANGE_ALARM, OnOffType.OFF);
727 case "VideoSource/ImageTooBright/AnalyticsService":
728 case "VideoSource/ImageTooBright/ImagingService":
729 case "VideoSource/ImageTooBright/RecordingService":
730 if ("true".equals(dataValue)) {
731 ipCameraHandler.changeAlarmState(CHANNEL_TOO_BRIGHT_ALARM, OnOffType.ON);
732 } else if ("false".equals(dataValue)) {
733 ipCameraHandler.changeAlarmState(CHANNEL_TOO_BRIGHT_ALARM, OnOffType.OFF);
736 case "VideoSource/ImageTooBlurry/AnalyticsService":
737 case "VideoSource/ImageTooBlurry/ImagingService":
738 case "VideoSource/ImageTooBlurry/RecordingService":
739 if ("true".equals(dataValue)) {
740 ipCameraHandler.changeAlarmState(CHANNEL_TOO_BLURRY_ALARM, OnOffType.ON);
741 } else if ("false".equals(dataValue)) {
742 ipCameraHandler.changeAlarmState(CHANNEL_TOO_BLURRY_ALARM, OnOffType.OFF);
745 case "RuleEngine/MyRuleDetector/Visitor":
746 if ("true".equals(dataValue)) {
747 ipCameraHandler.changeAlarmState(CHANNEL_DOORBELL, OnOffType.ON);
748 } else if ("false".equals(dataValue)) {
749 ipCameraHandler.changeAlarmState(CHANNEL_DOORBELL, OnOffType.OFF);
752 case "RuleEngine/MyRuleDetector/VehicleDetect":
753 if ("true".equals(dataValue)) {
754 ipCameraHandler.changeAlarmState(CHANNEL_CAR_ALARM, OnOffType.ON);
755 } else if ("false".equals(dataValue)) {
756 ipCameraHandler.changeAlarmState(CHANNEL_CAR_ALARM, OnOffType.OFF);
759 case "RuleEngine/MyRuleDetector/DogCatDetect":
760 if ("true".equals(dataValue)) {
761 ipCameraHandler.changeAlarmState(CHANNEL_ANIMAL_ALARM, OnOffType.ON);
762 } else if ("false".equals(dataValue)) {
763 ipCameraHandler.changeAlarmState(CHANNEL_ANIMAL_ALARM, OnOffType.OFF);
766 case "RuleEngine/MyRuleDetector/FaceDetect":
767 if ("true".equals(dataValue)) {
768 ipCameraHandler.changeAlarmState(CHANNEL_FACE_DETECTED, OnOffType.ON);
769 } else if ("false".equals(dataValue)) {
770 ipCameraHandler.changeAlarmState(CHANNEL_FACE_DETECTED, OnOffType.OFF);
773 case "RuleEngine/MyRuleDetector/PeopleDetect":
774 if ("true".equals(dataValue)) {
775 ipCameraHandler.changeAlarmState(CHANNEL_HUMAN_ALARM, OnOffType.ON);
776 } else if ("false".equals(dataValue)) {
777 ipCameraHandler.changeAlarmState(CHANNEL_HUMAN_ALARM, OnOffType.OFF);
781 logger.debug("Please report this camera has an un-implemented ONVIF event. Topic: {}", topic);
783 sendOnvifRequest(RequestType.Renew, subscriptionXAddr);
786 public boolean supportsPTZ() {
790 public void getStatus() {
792 sendPTZRequest(RequestType.GetStatus);
796 public Float getAbsolutePan() {
797 return currentPanPercentage;
800 public Float getAbsoluteTilt() {
801 return currentTiltPercentage;
804 public Float getAbsoluteZoom() {
805 return currentZoomPercentage;
808 public void setAbsolutePan(Float panValue) {// Value is 0-100% of cameras range
810 currentPanPercentage = panValue;
811 currentPanCamValue = ((((panRangeMin - panRangeMax) * -1) / 100) * panValue + panRangeMin);
815 public void setAbsoluteTilt(Float tiltValue) {// Value is 0-100% of cameras range
817 currentTiltPercentage = tiltValue;
818 currentTiltCamValue = ((((panRangeMin - panRangeMax) * -1) / 100) * tiltValue + tiltRangeMin);
822 public void setAbsoluteZoom(Float zoomValue) {// Value is 0-100% of cameras range
824 currentZoomPercentage = zoomValue;
825 currentZoomCamValue = ((((zoomMin - zoomMax) * -1) / 100) * zoomValue + zoomMin);
829 public void absoluteMove() { // Camera wont move until PTZ values are set, then call this.
831 sendPTZRequest(RequestType.AbsoluteMove);
835 public void setSelectedMediaProfile(int mediaProfileIndex) {
836 this.mediaProfileIndex = mediaProfileIndex;
839 List<String> listOfResults(String message, String heading, String key) {
840 List<String> results = new LinkedList<>();
842 for (int startLookingFromIndex = 0; startLookingFromIndex != -1;) {
843 startLookingFromIndex = message.indexOf(heading, startLookingFromIndex);
844 if (startLookingFromIndex >= 0) {
845 temp = Helper.fetchXML(message.substring(startLookingFromIndex), heading, key);
846 if (!temp.isEmpty()) {
847 logger.trace("String was found: {}", temp);
850 return results;// key string must not exist so stop looking.
852 startLookingFromIndex += temp.length();
858 void parsePresets(String message) {
859 List<StateOption> presets = new ArrayList<>();
860 int counter = 1;// Presets start at 1 not 0. HOME may be added to index 0.
861 presetTokens = listOfResults(message, "<tptz:Preset", "token=\"");
862 presetNames = listOfResults(message, "<tptz:Preset", "<tt:Name>");
863 if (presetTokens.size() != presetNames.size()) {
864 logger.warn("Camera did not report the same number of Tokens and Names for PTZ presets");
867 for (String value : presetNames) {
868 presets.add(new StateOption(Integer.toString(counter++), value));
870 ipCameraHandler.stateDescriptionProvider
871 .setStateOptions(new ChannelUID(ipCameraHandler.getThing().getUID(), CHANNEL_GOTO_PRESET), presets);
874 void parseProfiles(String message) {
875 mediaProfileTokens = listOfResults(message, "<trt:Profiles", "token=\"");
876 if (mediaProfileIndex >= mediaProfileTokens.size()) {
878 "You have set the media profile to {} when the camera reported {} profiles. Falling back to mainstream 0.",
879 mediaProfileIndex, mediaProfileTokens.size());
880 mediaProfileIndex = 0;
884 void processPTZLocation(String result) {
885 logger.debug("Processing new PTZ location now");
887 int beginIndex = result.indexOf("x=\"");
888 int endIndex = result.indexOf("\"", (beginIndex + 3));
889 if (beginIndex >= 0 && endIndex >= 0) {
890 currentPanCamValue = Float.parseFloat(result.substring(beginIndex + 3, endIndex));
891 currentPanPercentage = (((panRangeMin - currentPanCamValue) * -1) / ((panRangeMin - panRangeMax) * -1))
893 logger.debug("Pan is updating to: {} and the cam value is {}", Math.round(currentPanPercentage),
897 "Binding could not determin the cameras current PTZ location. Not all cameras respond to GetStatus requests.");
901 beginIndex = result.indexOf("y=\"");
902 endIndex = result.indexOf("\"", (beginIndex + 3));
903 if (beginIndex >= 0 && endIndex >= 0) {
904 currentTiltCamValue = Float.parseFloat(result.substring(beginIndex + 3, endIndex));
905 currentTiltPercentage = (((tiltRangeMin - currentTiltCamValue) * -1) / ((tiltRangeMin - tiltRangeMax) * -1))
907 logger.debug("Tilt is updating to: {} and the cam value is {}", Math.round(currentTiltPercentage),
908 currentTiltCamValue);
913 beginIndex = result.lastIndexOf("x=\"");
914 endIndex = result.indexOf("\"", (beginIndex + 3));
915 if (beginIndex >= 0 && endIndex >= 0) {
916 currentZoomCamValue = Float.parseFloat(result.substring(beginIndex + 3, endIndex));
917 currentZoomPercentage = (((zoomMin - currentZoomCamValue) * -1) / ((zoomMin - zoomMax) * -1)) * 100;
918 logger.debug("Zoom is updating to: {} and the cam value is {}", Math.round(currentZoomPercentage),
919 currentZoomCamValue);
925 public void sendPTZRequest(RequestType requestType) {
927 logger.debug("ONVIF was not connected when a PTZ request was made, connecting now");
928 connect(usingEvents);
930 sendOnvifRequest(requestType, ptzXAddr);
933 public void sendEventRequest(RequestType requestType) {
934 sendOnvifRequest(requestType, eventXAddr);
937 public void connect(boolean useEvents) {
941 logger.debug("Connecting {} to ONVIF", ipAddress);
942 threadPool = Executors.newScheduledThreadPool(2);
943 sendOnvifRequest(RequestType.GetSystemDateAndTime, deviceXAddr);
944 usingEvents = useEvents;
945 sendOnvifRequest(RequestType.GetCapabilities, deviceXAddr);
952 public boolean isConnected() {
961 public void setIsConnected(boolean isConnected) {
964 this.isConnected = isConnected;
970 private void cleanup() {
971 if (!isConnected && !mainEventLoopGroup.isShuttingDown()) {
973 mainEventLoopGroup.shutdownGracefully();
974 mainEventLoopGroup.awaitTermination(3, TimeUnit.SECONDS);
975 } catch (InterruptedException e) {
976 logger.warn("ONVIF was not cleanly shutdown, due to being interrupted");
978 logger.debug("Eventloop is shutdown: {}", mainEventLoopGroup.isShutdown());
980 threadPool.shutdown();
985 public void disconnect() {
986 connecting.lock();// Lock out multiple disconnect()/connect() attempts as we try to send Unsubscribe.
988 if (bootstrap != null) {
989 if (isConnected && usingEvents && !mainEventLoopGroup.isShuttingDown()) {
990 // Only makes sense to send if connected
991 // Some cameras may continue to send events even when they can't reach a server.
992 sendOnvifRequest(RequestType.Unsubscribe, subscriptionXAddr);
994 // give time for the Unsubscribe request to be sent, shutdownGracefully will try to send it first.
995 threadPool.schedule(this::cleanup, 50, TimeUnit.MILLISECONDS);
1000 isConnected = false;// isConnected is not thread safe, connecting.lock() used as fix.
1002 connecting.unlock();