2 * Copyright (c) 2010-2021 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;
16 import java.io.IOException;
17 import java.net.InetSocketAddress;
18 import java.nio.charset.StandardCharsets;
19 import java.util.concurrent.TimeUnit;
21 import org.eclipse.jdt.annotation.NonNullByDefault;
22 import org.eclipse.jdt.annotation.Nullable;
23 import org.openhab.binding.ipcamera.internal.IpCameraBindingConstants.FFmpegFormat;
24 import org.openhab.binding.ipcamera.internal.handler.IpCameraHandler;
25 import org.slf4j.Logger;
26 import org.slf4j.LoggerFactory;
28 import io.netty.buffer.ByteBuf;
29 import io.netty.buffer.Unpooled;
30 import io.netty.channel.ChannelHandlerContext;
31 import io.netty.channel.ChannelInboundHandlerAdapter;
32 import io.netty.handler.codec.http.DefaultHttpResponse;
33 import io.netty.handler.codec.http.HttpContent;
34 import io.netty.handler.codec.http.HttpHeaderNames;
35 import io.netty.handler.codec.http.HttpHeaderValues;
36 import io.netty.handler.codec.http.HttpRequest;
37 import io.netty.handler.codec.http.HttpResponse;
38 import io.netty.handler.codec.http.HttpResponseStatus;
39 import io.netty.handler.codec.http.HttpVersion;
40 import io.netty.handler.codec.http.LastHttpContent;
41 import io.netty.handler.codec.http.QueryStringDecoder;
42 import io.netty.handler.stream.ChunkedFile;
43 import io.netty.handler.timeout.IdleState;
44 import io.netty.handler.timeout.IdleStateEvent;
45 import io.netty.util.ReferenceCountUtil;
48 * The {@link StreamServerHandler} class is responsible for handling streams and sending any requested files to openHABs
51 * @author Matthew Skinner - Initial contribution
55 public class StreamServerHandler extends ChannelInboundHandlerAdapter {
56 private final Logger logger = LoggerFactory.getLogger(getClass());
57 private IpCameraHandler ipCameraHandler;
58 private boolean handlingMjpeg = false; // used to remove ctx from group when handler is removed.
59 private boolean handlingSnapshotStream = false; // used to remove ctx from group when handler is removed.
60 private byte[] incomingJpeg = new byte[0];
61 private String whiteList = "";
62 private int recievedBytes = 0;
63 private boolean updateSnapshot = false;
64 private boolean onvifEvent = false;
66 public StreamServerHandler(IpCameraHandler ipCameraHandler) {
67 this.ipCameraHandler = ipCameraHandler;
68 whiteList = ipCameraHandler.getWhiteList();
72 public void handlerAdded(@Nullable ChannelHandlerContext ctx) {
76 public void channelRead(@Nullable ChannelHandlerContext ctx, @Nullable Object msg) throws Exception {
82 if (msg instanceof HttpRequest) {
83 HttpRequest httpRequest = (HttpRequest) msg;
84 if (!whiteList.equals("DISABLE")) {
85 String requestIP = "("
86 + ((InetSocketAddress) ctx.channel().remoteAddress()).getAddress().getHostAddress() + ")";
87 if (!whiteList.contains(requestIP)) {
88 logger.warn("The request made from {} was not in the whitelist and will be ignored.",
93 if ("GET".equalsIgnoreCase(httpRequest.method().toString())) {
94 logger.debug("Stream Server recieved request \tGET:{}", httpRequest.uri());
95 // Some browsers send a query string after the path when refreshing a picture.
96 QueryStringDecoder queryStringDecoder = new QueryStringDecoder(httpRequest.uri());
97 switch (queryStringDecoder.path()) {
98 case "/ipcamera.m3u8":
99 Ffmpeg localFfmpeg = ipCameraHandler.ffmpegHLS;
100 if (localFfmpeg == null) {
101 ipCameraHandler.setupFfmpegFormat(FFmpegFormat.HLS);
102 } else if (!localFfmpeg.getIsAlive()) {
103 localFfmpeg.startConverting();
105 localFfmpeg.setKeepAlive(8);
106 sendFile(ctx, httpRequest.uri(), "application/x-mpegurl");
109 // Allow files to be created, or you get old m3u8 from the last time this ran.
110 TimeUnit.MILLISECONDS.sleep(4500);
111 sendFile(ctx, httpRequest.uri(), "application/x-mpegurl");
113 case "/ipcamera.mpd":
114 sendFile(ctx, httpRequest.uri(), "application/dash+xml");
116 case "/ipcamera.gif":
117 sendFile(ctx, httpRequest.uri(), "image/gif");
119 case "/ipcamera.jpg":
120 if (!ipCameraHandler.snapshotPolling && ipCameraHandler.snapshotUri != "") {
121 ipCameraHandler.sendHttpGET(ipCameraHandler.snapshotUri);
123 if (ipCameraHandler.currentSnapshot.length == 1) {
124 logger.warn("ipcamera.jpg was requested but there is no jpg in ram to send.");
127 sendSnapshotImage(ctx, "image/jpg");
129 case "/snapshots.mjpeg":
130 handlingSnapshotStream = true;
131 ipCameraHandler.startSnapshotPolling();
132 ipCameraHandler.setupSnapshotStreaming(true, ctx, false);
134 case "/ipcamera.mjpeg":
135 ipCameraHandler.setupMjpegStreaming(true, ctx);
136 handlingMjpeg = true;
138 case "/autofps.mjpeg":
139 handlingSnapshotStream = true;
140 ipCameraHandler.setupSnapshotStreaming(true, ctx, true);
143 InstarHandler instar = new InstarHandler(ipCameraHandler);
144 instar.alarmTriggered(httpRequest.uri().toString());
147 case "/ipcamera0.ts":
149 if (httpRequest.uri().contains(".ts")) {
150 sendFile(ctx, queryStringDecoder.path(), "video/MP2T");
151 } else if (httpRequest.uri().contains(".gif")) {
152 sendFile(ctx, queryStringDecoder.path(), "image/gif");
153 } else if (httpRequest.uri().contains(".jpg")) {
154 // Allow access to the preroll and postroll jpg files
155 sendFile(ctx, queryStringDecoder.path(), "image/jpg");
156 } else if (httpRequest.uri().contains(".m4s") || httpRequest.uri().contains(".mp4")) {
157 sendFile(ctx, queryStringDecoder.path(), "video/mp4");
161 } else if ("POST".equalsIgnoreCase(httpRequest.method().toString())) {
162 switch (httpRequest.uri()) {
163 case "/ipcamera.jpg":
165 case "/snapshot.jpg":
166 updateSnapshot = true;
172 logger.debug("Stream Server recieved unknown request \tPOST:{}", httpRequest.uri());
177 if (msg instanceof HttpContent) {
178 HttpContent content = (HttpContent) msg;
179 if (recievedBytes == 0) {
180 incomingJpeg = new byte[content.content().readableBytes()];
181 content.content().getBytes(0, incomingJpeg, 0, content.content().readableBytes());
183 byte[] temp = incomingJpeg;
184 incomingJpeg = new byte[recievedBytes + content.content().readableBytes()];
185 System.arraycopy(temp, 0, incomingJpeg, 0, temp.length);
186 content.content().getBytes(0, incomingJpeg, temp.length, content.content().readableBytes());
188 recievedBytes = incomingJpeg.length;
189 if (content instanceof LastHttpContent) {
190 if (updateSnapshot) {
191 ipCameraHandler.processSnapshot(incomingJpeg);
192 } else if (onvifEvent) {
193 ipCameraHandler.onvifCamera.eventRecieved(new String(incomingJpeg, StandardCharsets.UTF_8));
194 } else { // handles the snapshots that make up mjpeg from rtsp to ffmpeg conversions.
195 if (recievedBytes > 1000) {
196 ipCameraHandler.sendMjpegFrame(incomingJpeg, ipCameraHandler.mjpegChannelGroup);
203 ReferenceCountUtil.release(msg);
207 private void sendSnapshotImage(ChannelHandlerContext ctx, String contentType) {
208 HttpResponse response = new DefaultHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK);
209 ipCameraHandler.lockCurrentSnapshot.lock();
211 ByteBuf snapshotData = Unpooled.copiedBuffer(ipCameraHandler.currentSnapshot);
212 response.headers().add(HttpHeaderNames.CONTENT_TYPE, contentType);
213 response.headers().set(HttpHeaderNames.CACHE_CONTROL, HttpHeaderValues.NO_CACHE);
214 response.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.CLOSE);
215 response.headers().add(HttpHeaderNames.CONTENT_LENGTH, snapshotData.readableBytes());
216 response.headers().add("Access-Control-Allow-Origin", "*");
217 response.headers().add("Access-Control-Expose-Headers", "*");
218 ctx.channel().write(response);
219 ctx.channel().write(snapshotData);
220 ByteBuf footerBbuf = Unpooled.copiedBuffer("\r\n", 0, 2, StandardCharsets.UTF_8);
221 ctx.channel().writeAndFlush(footerBbuf);
223 ipCameraHandler.lockCurrentSnapshot.unlock();
227 private void sendFile(ChannelHandlerContext ctx, String fileUri, String contentType) throws IOException {
228 File file = new File(ipCameraHandler.cameraConfig.getFfmpegOutput() + fileUri);
229 ChunkedFile chunkedFile = new ChunkedFile(file);
230 HttpResponse response = new DefaultHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK);
231 response.headers().add(HttpHeaderNames.CONTENT_TYPE, contentType);
232 response.headers().set(HttpHeaderNames.CACHE_CONTROL, HttpHeaderValues.NO_CACHE);
233 response.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.CLOSE);
234 response.headers().add(HttpHeaderNames.CONTENT_LENGTH, chunkedFile.length());
235 response.headers().add("Access-Control-Allow-Origin", "*");
236 response.headers().add("Access-Control-Expose-Headers", "*");
237 ctx.channel().write(response);
238 ctx.channel().write(chunkedFile);
239 ByteBuf footerBbuf = Unpooled.copiedBuffer("\r\n", 0, 2, StandardCharsets.UTF_8);
240 ctx.channel().writeAndFlush(footerBbuf);
244 public void channelReadComplete(@Nullable ChannelHandlerContext ctx) throws Exception {
248 public void exceptionCaught(@Nullable ChannelHandlerContext ctx, @Nullable Throwable cause) throws Exception {
249 if (ctx == null || cause == null) {
252 if (cause.toString().contains("Connection reset by peer")) {
253 logger.trace("Connection reset by peer.");
254 } else if (cause.toString().contains("An established connection was aborted by the software")) {
255 logger.debug("An established connection was aborted by the software");
256 } else if (cause.toString().contains("An existing connection was forcibly closed by the remote host")) {
257 logger.debug("An existing connection was forcibly closed by the remote host");
258 } else if (cause.toString().contains("(No such file or directory)")) {
260 "IpCameras file server could not find the requested file. This may happen if ffmpeg is still creating the file.");
262 logger.warn("Exception caught from stream server:{}", cause.getMessage());
268 public void userEventTriggered(@Nullable ChannelHandlerContext ctx, @Nullable Object evt) throws Exception {
272 if (evt instanceof IdleStateEvent) {
273 IdleStateEvent e = (IdleStateEvent) evt;
274 if (e.state() == IdleState.WRITER_IDLE) {
275 logger.debug("Stream server is going to close an idle channel.");
282 public void handlerRemoved(@Nullable ChannelHandlerContext ctx) {
288 ipCameraHandler.setupMjpegStreaming(false, ctx);
289 } else if (handlingSnapshotStream) {
290 handlingSnapshotStream = false;
291 ipCameraHandler.setupSnapshotStreaming(false, ctx, false);