/** * Copyright (c) 2010-2020 Contributors to the openHAB project * * See the NOTICE file(s) distributed with this work for additional * information. * * This program and the accompanying materials are made available under the * terms of the Eclipse Public License 2.0 which is available at * http://www.eclipse.org/legal/epl-2.0 * * SPDX-License-Identifier: EPL-2.0 */ package org.openhab.binding.ipcamera.internal; import static org.openhab.binding.ipcamera.IpCameraBindingConstants.CONFIG_FFMPEG_OUTPUT; import java.io.File; import java.io.IOException; import java.net.InetSocketAddress; import java.nio.charset.StandardCharsets; import java.util.concurrent.TimeUnit; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.openhab.binding.ipcamera.handler.IpCameraHandler; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import io.netty.buffer.ByteBuf; import io.netty.buffer.Unpooled; import io.netty.channel.ChannelHandlerContext; import io.netty.channel.ChannelInboundHandlerAdapter; import io.netty.handler.codec.http.DefaultHttpResponse; import io.netty.handler.codec.http.HttpContent; import io.netty.handler.codec.http.HttpHeaderNames; import io.netty.handler.codec.http.HttpHeaderValues; import io.netty.handler.codec.http.HttpRequest; import io.netty.handler.codec.http.HttpResponse; import io.netty.handler.codec.http.HttpResponseStatus; import io.netty.handler.codec.http.HttpVersion; import io.netty.handler.codec.http.LastHttpContent; import io.netty.handler.codec.http.QueryStringDecoder; import io.netty.handler.stream.ChunkedFile; import io.netty.handler.timeout.IdleState; import io.netty.handler.timeout.IdleStateEvent; import io.netty.util.ReferenceCountUtil; /** * The {@link StreamServerHandler} class is responsible for handling streams and sending any requested files to Openhabs * features. * * @author Matthew Skinner - Initial contribution */ @NonNullByDefault public class StreamServerHandler extends ChannelInboundHandlerAdapter { private final Logger logger = LoggerFactory.getLogger(getClass()); private IpCameraHandler ipCameraHandler; private boolean handlingMjpeg = false; // used to remove ctx from group when handler is removed. private boolean handlingSnapshotStream = false; // used to remove ctx from group when handler is removed. private byte[] incomingJpeg = new byte[0]; String whiteList = ""; int recievedBytes = 0; int count = 0; boolean updateSnapshot = false; boolean onvifEvent = false; public StreamServerHandler(IpCameraHandler ipCameraHandler) { this.ipCameraHandler = ipCameraHandler; whiteList = ipCameraHandler.getWhiteList(); } @Override public void handlerAdded(@Nullable ChannelHandlerContext ctx) { logger.trace("Opening a StreamServerHandler."); } @Override public void channelRead(@Nullable ChannelHandlerContext ctx, @Nullable Object msg) throws Exception { if (ctx == null) { return; } @Nullable HttpContent content = null; try { if (msg instanceof HttpRequest) { HttpRequest httpRequest = (HttpRequest) msg; // logger.debug("Stream Server recieved request \t{}:{}", httpRequest.method(), httpRequest.uri()); if (!whiteList.equals("DISABLE")) { String requestIP = "(" + ((InetSocketAddress) ctx.channel().remoteAddress()).getAddress().getHostAddress() + ")"; if (!whiteList.contains(requestIP)) { logger.warn("The request made from {} was not in the whitelist and will be ignored.", requestIP); return; } } if ("GET".equalsIgnoreCase(httpRequest.method().toString())) { logger.debug("Stream Server recieved request \tGET:{}", httpRequest.uri()); // Some browsers send a query string after the path when refreshing a picture. QueryStringDecoder queryStringDecoder = new QueryStringDecoder(httpRequest.uri()); switch (queryStringDecoder.path()) { case "/ipcamera.m3u8": if (ipCameraHandler.ffmpegHLS != null) { if (!ipCameraHandler.ffmpegHLS.getIsAlive()) { if (ipCameraHandler.ffmpegHLS != null) { ipCameraHandler.ffmpegHLS.startConverting(); } } } else { ipCameraHandler.setupFfmpegFormat("HLS"); } if (ipCameraHandler.ffmpegHLS != null) { ipCameraHandler.ffmpegHLS.setKeepAlive(60); } sendFile(ctx, httpRequest.uri(), "application/x-mpegurl"); return; case "/ipcamera.mpd": // ipCameraHandler.setupFfmpegFormat("DASH"); // ipCameraHandler.ffmpegDASH.setKeepAlive(60);// setup must come first sendFile(ctx, httpRequest.uri(), "application/dash+xml"); return; case "/ipcamera.gif": sendFile(ctx, httpRequest.uri(), "image/gif"); return; case "/ipcamera.jpg": if (!ipCameraHandler.updateImageEvents.contentEquals("1")) { if (ipCameraHandler.snapshotUri != "") { ipCameraHandler.sendHttpGET(ipCameraHandler.snapshotUri); } else if (ipCameraHandler.ffmpegSnapshotGeneration) { logger.warn( "Snpahsot was requested but the updateImageNow channel is OFF and hence FFmpeg is not creating snapshots."); } if (ipCameraHandler.currentSnapshot.length == 1) {// no jpg received from camera. logger.warn("No jpg in ram to send"); break; } } sendSnapshotImage(ctx, "image/jpg"); return; case "/snapshots.mjpeg": handlingSnapshotStream = true; ipCameraHandler.setupSnapshotStreaming(true, ctx, false); return; case "/ipcamera.mjpeg": ipCameraHandler.setupMjpegStreaming(true, ctx); handlingMjpeg = true; return; case "/autofps.mjpeg": handlingSnapshotStream = true; ipCameraHandler.setupSnapshotStreaming(true, ctx, true); return; case "/instar": InstarHandler instar = new InstarHandler(ipCameraHandler); instar.alarmTriggered(httpRequest.uri().toString()); ctx.close(); return; case "/ipcamera0.ts": TimeUnit.SECONDS.sleep(6); default: if (httpRequest.uri().contains(".ts")) { sendFile(ctx, queryStringDecoder.path(), "video/MP2T"); } else if (httpRequest.uri().contains(".jpg")) { // Allow access to the preroll and postroll jpg files sendFile(ctx, queryStringDecoder.path(), "image/jpg"); } else if (httpRequest.uri().contains(".m4s")) { sendFile(ctx, queryStringDecoder.path(), "video/mp4"); } else if (httpRequest.uri().contains(".mp4")) { sendFile(ctx, queryStringDecoder.path(), "video/mp4"); } return; } } else if ("POST".equalsIgnoreCase(httpRequest.method().toString())) { switch (httpRequest.uri()) { case "/ipcamera.jpg": break; case "/snapshot.jpg": updateSnapshot = true; break; case "/OnvifEvent": onvifEvent = true; break; default: logger.debug("Stream Server recieved unknown request \tPUT:{}", httpRequest.uri()); break; } } } if (msg instanceof HttpContent) { content = (HttpContent) msg; int index = 0; if (recievedBytes == 0) { incomingJpeg = new byte[content.content().capacity()]; } else { byte[] temp = incomingJpeg; incomingJpeg = new byte[recievedBytes + content.content().capacity()]; for (; index < temp.length; index++) { incomingJpeg[index] = temp[index]; } } for (int i = 0; i < content.content().capacity(); i++) { incomingJpeg[index++] = content.content().getByte(i); } recievedBytes = incomingJpeg.length; if (content instanceof LastHttpContent) { if (updateSnapshot) { ipCameraHandler.lockCurrentSnapshot.lock(); ipCameraHandler.currentSnapshot = incomingJpeg; ipCameraHandler.lockCurrentSnapshot.unlock(); ipCameraHandler.processSnapshot(); } else if (onvifEvent) { ipCameraHandler.onvifCamera.eventRecieved(new String(incomingJpeg, StandardCharsets.UTF_8)); } else { if (recievedBytes > 1000) { ipCameraHandler.sendMjpegFrame(incomingJpeg, ipCameraHandler.mjpegChannelGroup); } } recievedBytes = 0; } } } finally { ReferenceCountUtil.release(msg); } } private void sendSnapshotImage(ChannelHandlerContext ctx, String contentType) throws IOException { HttpResponse response = new DefaultHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK); ipCameraHandler.lockCurrentSnapshot.lock(); ByteBuf snapshotData = Unpooled.copiedBuffer(ipCameraHandler.currentSnapshot); ipCameraHandler.lockCurrentSnapshot.unlock(); response.headers().add(HttpHeaderNames.CONTENT_TYPE, contentType); response.headers().set(HttpHeaderNames.CACHE_CONTROL, HttpHeaderValues.NO_CACHE); response.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.CLOSE); response.headers().add(HttpHeaderNames.CONTENT_LENGTH, snapshotData.readableBytes()); response.headers().add("Access-Control-Allow-Origin", "*"); response.headers().add("Access-Control-Expose-Headers", "*"); ctx.channel().write(response); ctx.channel().write(snapshotData); ByteBuf footerBbuf = Unpooled.copiedBuffer("\r\n", 0, 2, StandardCharsets.UTF_8); ctx.channel().writeAndFlush(footerBbuf); } private void sendFile(ChannelHandlerContext ctx, String fileUri, String contentType) throws IOException { File file = new File(ipCameraHandler.config.get(CONFIG_FFMPEG_OUTPUT).toString() + fileUri); ChunkedFile chunkedFile = new ChunkedFile(file); HttpResponse response = new DefaultHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK); response.headers().add(HttpHeaderNames.CONTENT_TYPE, contentType); response.headers().set(HttpHeaderNames.CACHE_CONTROL, HttpHeaderValues.NO_CACHE); response.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.KEEP_ALIVE); response.headers().add(HttpHeaderNames.CONTENT_LENGTH, chunkedFile.length()); response.headers().add("Access-Control-Allow-Origin", "*"); response.headers().add("Access-Control-Expose-Headers", "*"); ctx.channel().write(response); ctx.channel().write(chunkedFile); ByteBuf footerBbuf = Unpooled.copiedBuffer("\r\n", 0, 2, StandardCharsets.UTF_8); ctx.channel().writeAndFlush(footerBbuf); } @Override public void channelReadComplete(@Nullable ChannelHandlerContext ctx) throws Exception { } @Override public void exceptionCaught(@Nullable ChannelHandlerContext ctx, @Nullable Throwable cause) throws Exception { if (ctx == null || cause == null) { return; } if (cause.toString().contains("Connection reset by peer")) { logger.trace("Connection reset by peer."); } else if (cause.toString().contains("An established connection was aborted by the software")) { logger.debug("An established connection was aborted by the software"); } else if (cause.toString().contains("An existing connection was forcibly closed by the remote host")) { logger.debug("An existing connection was forcibly closed by the remote host"); } else if (cause.toString().contains("(No such file or directory)")) { logger.info( "IpCameras file server could not find the requested file. This may happen if ffmpeg is still creating the file."); } else { logger.warn("Exception caught from stream server:{}", cause); } ctx.close(); } @Override public void userEventTriggered(@Nullable ChannelHandlerContext ctx, @Nullable Object evt) throws Exception { if (ctx == null) { return; } logger.trace("Stream server:{}.", evt); if (evt instanceof IdleStateEvent) { IdleStateEvent e = (IdleStateEvent) evt; if (e.state() == IdleState.WRITER_IDLE) { logger.debug("Stream server is going to close an idle channel."); ctx.close(); } } } @Override public void handlerRemoved(@Nullable ChannelHandlerContext ctx) { if (ctx == null) { return; } logger.trace("Closing a StreamServerHandler."); if (handlingMjpeg) { ipCameraHandler.setupMjpegStreaming(false, ctx); } else if (handlingSnapshotStream) { handlingSnapshotStream = false; ipCameraHandler.setupSnapshotStreaming(false, ctx, false); } } }