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;
15 import static org.openhab.binding.ipcamera.internal.IpCameraBindingConstants.CHANNEL_START_STREAM;
18 import java.io.IOException;
19 import java.net.InetSocketAddress;
20 import java.nio.charset.StandardCharsets;
22 import org.eclipse.jdt.annotation.NonNullByDefault;
23 import org.eclipse.jdt.annotation.Nullable;
24 import org.openhab.binding.ipcamera.internal.handler.IpCameraGroupHandler;
25 import org.openhab.binding.ipcamera.internal.handler.IpCameraHandler;
26 import org.openhab.core.library.types.OnOffType;
27 import org.openhab.core.thing.ChannelUID;
28 import org.slf4j.Logger;
29 import org.slf4j.LoggerFactory;
31 import io.netty.buffer.ByteBuf;
32 import io.netty.buffer.Unpooled;
33 import io.netty.channel.ChannelHandlerContext;
34 import io.netty.channel.ChannelInboundHandlerAdapter;
35 import io.netty.handler.codec.http.DefaultHttpResponse;
36 import io.netty.handler.codec.http.HttpHeaderNames;
37 import io.netty.handler.codec.http.HttpHeaderValues;
38 import io.netty.handler.codec.http.HttpMethod;
39 import io.netty.handler.codec.http.HttpRequest;
40 import io.netty.handler.codec.http.HttpResponse;
41 import io.netty.handler.codec.http.HttpResponseStatus;
42 import io.netty.handler.codec.http.HttpVersion;
43 import io.netty.handler.codec.http.QueryStringDecoder;
44 import io.netty.handler.stream.ChunkedFile;
45 import io.netty.handler.timeout.IdleState;
46 import io.netty.handler.timeout.IdleStateEvent;
47 import io.netty.util.ReferenceCountUtil;
50 * The {@link StreamServerGroupHandler} class is responsible for handling streams and sending any requested files to
52 * features for a group of cameras instead of individual cameras.
54 * @author Matthew Skinner - Initial contribution
58 public class StreamServerGroupHandler extends ChannelInboundHandlerAdapter {
59 private final Logger logger = LoggerFactory.getLogger(getClass());
60 private IpCameraGroupHandler ipCameraGroupHandler;
61 private String whiteList = "";
63 public StreamServerGroupHandler(IpCameraGroupHandler ipCameraGroupHandler) {
64 this.ipCameraGroupHandler = ipCameraGroupHandler;
65 whiteList = ipCameraGroupHandler.groupConfig.getIpWhitelist();
69 public void handlerAdded(@Nullable ChannelHandlerContext ctx) {
72 private String resolveIndexToPath(String uri) {
73 if (!"i".equals(uri.substring(1, 2))) {
74 return ipCameraGroupHandler.getOutputFolder(Integer.parseInt(uri.substring(1, 2)));
77 // example is /1ipcameraxx.ts
81 public void channelRead(@Nullable ChannelHandlerContext ctx, @Nullable Object msg) throws Exception {
82 if (msg == null || ctx == null) {
86 if (msg instanceof HttpRequest) {
87 HttpRequest httpRequest = (HttpRequest) msg;
88 String requestIP = "("
89 + ((InetSocketAddress) ctx.channel().remoteAddress()).getAddress().getHostAddress() + ")";
90 if (!whiteList.contains(requestIP) && !"DISABLE".equals(whiteList)) {
91 logger.warn("The request made from {} was not in the whitelist and will be ignored.", requestIP);
93 } else if (HttpMethod.GET.equals(httpRequest.method())) {
94 // Some browsers send a query string after the path when refreshing a picture.
95 QueryStringDecoder queryStringDecoder = new QueryStringDecoder(httpRequest.uri());
96 switch (queryStringDecoder.path()) {
97 case "/ipcamera.m3u8":
98 if (ipCameraGroupHandler.hlsTurnedOn) {
99 String debugMe = ipCameraGroupHandler.getPlayList();
100 logger.debug("playlist is:{}", debugMe);
101 sendString(ctx, debugMe, "application/x-mpegurl");
105 "HLS requires the groups startStream channel to be turned on first. Just starting it now.");
106 String channelPrefix = "ipcamera:" + ipCameraGroupHandler.getThing().getThingTypeUID()
107 + ":" + ipCameraGroupHandler.getThing().getUID().getId() + ":";
108 ipCameraGroupHandler.handleCommand(new ChannelUID(channelPrefix + CHANNEL_START_STREAM),
112 case "/ipcamera.jpg":
113 sendSnapshotImage(ctx, "image/jpg");
116 if (httpRequest.uri().contains(".ts")) {
117 sendFile(ctx, resolveIndexToPath(httpRequest.uri()) + httpRequest.uri().substring(2),
119 } else if (httpRequest.uri().contains(".jpg")) {
120 sendFile(ctx, httpRequest.uri(), "image/jpg");
121 } else if (httpRequest.uri().contains(".m4s") || httpRequest.uri().contains(".mp4")) {
122 sendFile(ctx, httpRequest.uri(), "video/mp4");
128 ReferenceCountUtil.release(msg);
132 private void sendSnapshotImage(ChannelHandlerContext ctx, String contentType) {
133 HttpResponse response = new DefaultHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK);
134 if (ipCameraGroupHandler.cameraIndex >= ipCameraGroupHandler.cameraOrder.size()) {
135 logger.debug("WARN: Openhab may still be starting, or all cameras in the group are OFFLINE.");
138 IpCameraHandler handler = ipCameraGroupHandler.cameraOrder.get(ipCameraGroupHandler.cameraIndex);
139 handler.lockCurrentSnapshot.lock();
141 ByteBuf snapshotData = Unpooled.copiedBuffer(handler.currentSnapshot);
142 response.headers().add(HttpHeaderNames.CONTENT_TYPE, contentType);
143 response.headers().set(HttpHeaderNames.CACHE_CONTROL, HttpHeaderValues.NO_CACHE);
144 response.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.CLOSE);
145 response.headers().add(HttpHeaderNames.CONTENT_LENGTH, snapshotData.readableBytes());
146 response.headers().add("Access-Control-Allow-Origin", "*");
147 response.headers().add("Access-Control-Expose-Headers", "*");
148 ctx.channel().write(response);
149 ctx.channel().write(snapshotData);
150 ByteBuf footerBbuf = Unpooled.copiedBuffer("\r\n", 0, 2, StandardCharsets.UTF_8);
151 ctx.channel().writeAndFlush(footerBbuf);
153 handler.lockCurrentSnapshot.unlock();
157 private void sendFile(ChannelHandlerContext ctx, String fileUri, String contentType) throws IOException {
158 logger.trace("file is :{}", fileUri);
159 File file = new File(fileUri);
160 ChunkedFile chunkedFile = new ChunkedFile(file);
161 ByteBuf footerBbuf = Unpooled.copiedBuffer("\r\n", 0, 2, StandardCharsets.UTF_8);
162 HttpResponse response = new DefaultHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK);
163 response.headers().add(HttpHeaderNames.CONTENT_TYPE, contentType);
164 response.headers().set(HttpHeaderNames.CACHE_CONTROL, HttpHeaderValues.NO_CACHE);
165 response.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.CLOSE);
166 response.headers().add(HttpHeaderNames.CONTENT_LENGTH, chunkedFile.length());
167 response.headers().add("Access-Control-Allow-Origin", "*");
168 response.headers().add("Access-Control-Expose-Headers", "*");
169 ctx.channel().write(response);
170 ctx.channel().write(chunkedFile);
171 ctx.channel().writeAndFlush(footerBbuf);
174 private void sendString(ChannelHandlerContext ctx, String contents, String contentType) {
175 ByteBuf contentsBbuf = Unpooled.copiedBuffer(contents, 0, contents.length(), StandardCharsets.UTF_8);
176 HttpResponse response = new DefaultHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK);
177 response.headers().add(HttpHeaderNames.CONTENT_TYPE, contentType);
178 response.headers().set(HttpHeaderNames.CACHE_CONTROL, HttpHeaderValues.NO_CACHE);
179 response.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.CLOSE);
180 response.headers().add(HttpHeaderNames.CONTENT_LENGTH, contentsBbuf.readableBytes());
181 response.headers().add("Access-Control-Allow-Origin", "*");
182 response.headers().add("Access-Control-Expose-Headers", "*");
183 ByteBuf footerBbuf = Unpooled.copiedBuffer("\r\n", 0, 2, StandardCharsets.UTF_8);
184 ctx.channel().write(response);
185 ctx.channel().write(contentsBbuf);
186 ctx.channel().writeAndFlush(footerBbuf);
190 public void channelReadComplete(@Nullable ChannelHandlerContext ctx) throws Exception {
194 public void exceptionCaught(@Nullable ChannelHandlerContext ctx, @Nullable Throwable cause) throws Exception {
195 if (cause == null || ctx == null) {
198 if (cause.toString().contains("Connection reset by peer")) {
199 logger.debug("Connection reset by peer.");
200 } else if (cause.toString().contains("An established connection was aborted by the software")) {
201 logger.debug("An established connection was aborted by the software");
202 } else if (cause.toString().contains("An existing connection was forcibly closed by the remote host")) {
203 logger.debug("An existing connection was forcibly closed by the remote host");
204 } else if (cause.toString().contains("(No such file or directory)")) {
206 "IpCameras file server could not find the requested file. This may happen if ffmpeg is still creating the file.");
208 logger.warn("Exception caught from stream server:{}", cause.getMessage());
214 public void userEventTriggered(@Nullable ChannelHandlerContext ctx, @Nullable Object evt) throws Exception {
215 if (evt == null || ctx == null) {
218 if (evt instanceof IdleStateEvent) {
219 IdleStateEvent e = (IdleStateEvent) evt;
220 if (e.state() == IdleState.WRITER_IDLE) {
221 logger.debug("Stream server is going to close an idle channel.");
228 public void handlerRemoved(@Nullable ChannelHandlerContext ctx) {