2 * Copyright (c) 2010-2020 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
14 package org.openhab.binding.ipcamera.internal.handler;
16 import static org.openhab.binding.ipcamera.internal.IpCameraBindingConstants.*;
18 import java.io.IOException;
19 import java.math.BigDecimal;
20 import java.math.RoundingMode;
21 import java.net.InetSocketAddress;
22 import java.nio.file.Files;
23 import java.nio.file.Paths;
24 import java.util.ArrayList;
25 import java.util.concurrent.Executors;
26 import java.util.concurrent.ScheduledExecutorService;
27 import java.util.concurrent.ScheduledFuture;
28 import java.util.concurrent.TimeUnit;
30 import org.eclipse.jdt.annotation.NonNullByDefault;
31 import org.eclipse.jdt.annotation.Nullable;
32 import org.openhab.binding.ipcamera.internal.GroupConfig;
33 import org.openhab.binding.ipcamera.internal.GroupTracker;
34 import org.openhab.binding.ipcamera.internal.Helper;
35 import org.openhab.binding.ipcamera.internal.StreamServerGroupHandler;
36 import org.openhab.core.library.types.OnOffType;
37 import org.openhab.core.library.types.StringType;
38 import org.openhab.core.thing.ChannelUID;
39 import org.openhab.core.thing.Thing;
40 import org.openhab.core.thing.ThingStatus;
41 import org.openhab.core.thing.ThingStatusDetail;
42 import org.openhab.core.thing.binding.BaseThingHandler;
43 import org.openhab.core.types.Command;
44 import org.openhab.core.types.RefreshType;
45 import org.slf4j.Logger;
46 import org.slf4j.LoggerFactory;
48 import io.netty.bootstrap.ServerBootstrap;
49 import io.netty.channel.ChannelFuture;
50 import io.netty.channel.ChannelInitializer;
51 import io.netty.channel.EventLoopGroup;
52 import io.netty.channel.nio.NioEventLoopGroup;
53 import io.netty.channel.socket.SocketChannel;
54 import io.netty.channel.socket.nio.NioServerSocketChannel;
55 import io.netty.handler.codec.http.HttpServerCodec;
56 import io.netty.handler.stream.ChunkedWriteHandler;
57 import io.netty.handler.timeout.IdleStateHandler;
60 * The {@link IpCameraGroupHandler} is responsible for finding cameras that are part of this group and displaying a
63 * @author Matthew Skinner - Initial contribution
67 public class IpCameraGroupHandler extends BaseThingHandler {
68 private final Logger logger = LoggerFactory.getLogger(getClass());
69 public GroupConfig groupConfig;
70 private BigDecimal pollTimeInSeconds = new BigDecimal(2);
71 public ArrayList<IpCameraHandler> cameraOrder = new ArrayList<IpCameraHandler>(2);
72 private EventLoopGroup serversLoopGroup = new NioEventLoopGroup();
73 private final ScheduledExecutorService pollCameraGroup = Executors.newSingleThreadScheduledExecutor();
74 private @Nullable ScheduledFuture<?> pollCameraGroupJob = null;
75 private @Nullable ServerBootstrap serverBootstrap;
76 private @Nullable ChannelFuture serverFuture = null;
78 private boolean motionChangesOrder = true;
79 public int serverPort = 0;
80 public String playList = "";
81 private String playingNow = "";
82 public int cameraIndex = 0;
83 public boolean hlsTurnedOn = false;
84 private int entries = 0;
85 private int mediaSequence = 1;
86 private int discontinuitySequence = 0;
87 private GroupTracker groupTracker;
89 public IpCameraGroupHandler(Thing thing, @Nullable String openhabIpAddress, GroupTracker groupTracker) {
91 groupConfig = getConfigAs(GroupConfig.class);
92 if (openhabIpAddress != null) {
93 hostIp = openhabIpAddress;
95 hostIp = Helper.getLocalIpAddress();
97 this.groupTracker = groupTracker;
100 public String getPlayList() {
104 public String getOutputFolder(int index) {
105 IpCameraHandler handle = cameraOrder.get(index);
106 return handle.cameraConfig.getFfmpegOutput();
109 private String readCamerasPlaylist(int cameraIndex) {
110 String camerasm3u8 = "";
111 IpCameraHandler handle = cameraOrder.get(cameraIndex);
113 String file = handle.cameraConfig.getFfmpegOutput() + "ipcamera.m3u8";
114 camerasm3u8 = new String(Files.readAllBytes(Paths.get(file)));
115 } catch (IOException e) {
116 logger.warn("Error occured fetching a groupDisplay cameras m3u8 file :{}", e.getMessage());
121 String keepLast(String string, int numberToRetain) {
122 int start = string.length();
123 for (int loop = numberToRetain; loop > 0; loop--) {
124 start = string.lastIndexOf("#EXTINF:", start - 1);
127 "Playlist did not contain enough entries, check all cameras in groups use the same HLS settings.");
131 entries = entries + numberToRetain;
132 return string.substring(start);
135 String removeFromStart(String string, int numberToRemove) {
136 int startingFrom = string.indexOf("#EXTINF:");
137 for (int loop = numberToRemove; loop > 0; loop--) {
138 startingFrom = string.indexOf("#EXTINF:", startingFrom + 27);
139 if (startingFrom == -1) {
141 "Playlist failed to remove entries from start, check all cameras in groups use the same HLS settings.");
145 mediaSequence = mediaSequence + numberToRemove;
146 entries = entries - numberToRemove;
147 return string.substring(startingFrom);
150 int howManySegments(String m3u8File) {
151 int start = m3u8File.length();
152 int numberOfFiles = 0;
153 for (BigDecimal totalTime = new BigDecimal(0); totalTime.intValue() < pollTimeInSeconds
154 .intValue(); numberOfFiles++) {
155 start = m3u8File.lastIndexOf("#EXTINF:", start - 1);
157 totalTime = totalTime.add(new BigDecimal(m3u8File.substring(start + 8, m3u8File.indexOf(",", start))));
159 logger.debug("Group did not find enough segments, lower the poll time if this message continues.");
163 return numberOfFiles;
166 public void createPlayList() {
167 String m3u8File = readCamerasPlaylist(cameraIndex);
168 if (m3u8File == "") {
171 int numberOfSegments = howManySegments(m3u8File);
172 logger.debug("Using {} segmented files to make up a poll period.", numberOfSegments);
173 m3u8File = keepLast(m3u8File, numberOfSegments);
174 m3u8File = m3u8File.replace("ipcamera", cameraIndex + "ipcamera"); // add index so we can then fetch output path
175 if (entries > numberOfSegments * 3) {
176 playingNow = removeFromStart(playingNow, entries - (numberOfSegments * 3));
178 playingNow = playingNow + "#EXT-X-DISCONTINUITY\n" + m3u8File;
179 playList = "#EXTM3U\n#EXT-X-VERSION:6\n#EXT-X-TARGETDURATION:5\n#EXT-X-ALLOW-CACHE:NO\n#EXT-X-DISCONTINUITY-SEQUENCE:"
180 + discontinuitySequence + "\n#EXT-X-MEDIA-SEQUENCE:" + mediaSequence + "\n" + playingNow;
183 private IpCameraGroupHandler getHandle() {
187 @SuppressWarnings("null")
188 public void startStreamServer(boolean start) {
190 serversLoopGroup.shutdownGracefully(8, 8, TimeUnit.SECONDS);
191 serverBootstrap = null;
193 if (serverBootstrap == null) {
195 serversLoopGroup = new NioEventLoopGroup();
196 serverBootstrap = new ServerBootstrap();
197 serverBootstrap.group(serversLoopGroup);
198 serverBootstrap.channel(NioServerSocketChannel.class);
199 // IP "0.0.0.0" will bind the server to all network connections//
200 serverBootstrap.localAddress(new InetSocketAddress("0.0.0.0", serverPort));
201 serverBootstrap.childHandler(new ChannelInitializer<SocketChannel>() {
203 protected void initChannel(SocketChannel socketChannel) throws Exception {
204 socketChannel.pipeline().addLast("idleStateHandler", new IdleStateHandler(0, 25, 0));
205 socketChannel.pipeline().addLast("HttpServerCodec", new HttpServerCodec());
206 socketChannel.pipeline().addLast("ChunkedWriteHandler", new ChunkedWriteHandler());
207 socketChannel.pipeline().addLast("streamServerHandler",
208 new StreamServerGroupHandler(getHandle()));
211 serverFuture = serverBootstrap.bind().sync();
212 serverFuture.await(4000);
213 logger.info("IpCamera file server for a group of cameras has started on port {} for all NIC's.",
215 updateState(CHANNEL_MJPEG_URL,
216 new StringType("http://" + hostIp + ":" + serverPort + "/ipcamera.mjpeg"));
217 updateState(CHANNEL_HLS_URL,
218 new StringType("http://" + hostIp + ":" + serverPort + "/ipcamera.m3u8"));
219 updateState(CHANNEL_IMAGE_URL,
220 new StringType("http://" + hostIp + ":" + serverPort + "/ipcamera.jpg"));
221 } catch (Exception e) {
222 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
223 "Exception occured when starting the streaming server. Try changing the serverPort to another number.");
229 void addCamera(String UniqueID) {
230 if (groupTracker.listOfOnlineCameraUID.contains(UniqueID)) {
231 for (IpCameraHandler handler : groupTracker.listOfOnlineCameraHandlers) {
232 if (handler.getThing().getUID().getId().equals(UniqueID)) {
233 if (!cameraOrder.contains(handler)) {
234 logger.info("Adding {} to a camera group.", UniqueID);
236 logger.info("Starting HLS for the new camera.");
237 String channelPrefix = "ipcamera:" + handler.getThing().getThingTypeUID() + ":"
238 + handler.getThing().getUID().getId() + ":";
239 handler.handleCommand(new ChannelUID(channelPrefix + CHANNEL_START_STREAM), OnOffType.ON);
241 cameraOrder.add(handler);
248 // Event based. This is called as each camera comes online after the group handler is registered.
249 public void cameraOnline(String uid) {
250 logger.debug("New camera {} came online, checking if part of this group", uid);
251 if (groupConfig.getFirstCamera().equals(uid)) {
253 } else if (groupConfig.getSecondCamera().equals(uid)) {
255 } else if (groupConfig.getThirdCamera().equals(uid)) {
257 } else if (groupConfig.getForthCamera().equals(uid)) {
262 // Event based. This is called as each camera comes online after the group handler is registered.
263 public void cameraOffline(IpCameraHandler handle) {
264 if (cameraOrder.remove(handle)) {
265 logger.info("Camera {} went offline and was removed from a group.", handle.getThing().getUID().getId());
269 boolean addIfOnline(String UniqueID) {
270 if (groupTracker.listOfOnlineCameraUID.contains(UniqueID)) {
277 void createCameraOrder() {
278 addIfOnline(groupConfig.getFirstCamera());
279 addIfOnline(groupConfig.getSecondCamera());
280 if (!groupConfig.getThirdCamera().isEmpty()) {
281 addIfOnline(groupConfig.getThirdCamera());
283 if (!groupConfig.getForthCamera().isEmpty()) {
284 addIfOnline(groupConfig.getForthCamera());
286 // Cameras can now send events of when they go on and offline.
287 groupTracker.listOfGroupHandlers.add(this);
290 int checkForMotion(int nextCamerasIndex) {
292 for (int index = nextCamerasIndex; checked < cameraOrder.size(); checked++) {
293 if (cameraOrder.get(index).motionDetected) {
296 if (++index >= cameraOrder.size()) {
300 return nextCamerasIndex;
303 void pollCameraGroup() {
304 if (cameraOrder.isEmpty()) {
307 if (++cameraIndex >= cameraOrder.size()) {
310 if (motionChangesOrder) {
311 cameraIndex = checkForMotion(cameraIndex);
314 discontinuitySequence++;
320 public void handleCommand(ChannelUID channelUID, Command command) {
321 if (!(command instanceof RefreshType)) {
322 switch (channelUID.getId()) {
323 case CHANNEL_START_STREAM:
324 if (OnOffType.ON.equals(command)) {
326 for (IpCameraHandler handler : cameraOrder) {
327 String channelPrefix = "ipcamera:" + handler.getThing().getThingTypeUID() + ":"
328 + handler.getThing().getUID().getId() + ":";
330 handler.handleCommand(new ChannelUID(channelPrefix + CHANNEL_START_STREAM), OnOffType.ON);
333 // TODO: Do we turn all controls OFF or do we remember the state before we turned them all on?
341 public void initialize() {
342 groupConfig = getConfigAs(GroupConfig.class);
343 serverPort = groupConfig.getServerPort();
344 pollTimeInSeconds = new BigDecimal(groupConfig.getPollTime());
345 pollTimeInSeconds = pollTimeInSeconds.divide(new BigDecimal(1000), 1, RoundingMode.HALF_UP);
346 motionChangesOrder = groupConfig.getMotionChangesOrder();
348 if (serverPort == -1) {
349 logger.warn("The serverPort = -1 which disables a lot of features. See readme for more info.");
350 } else if (serverPort < 1025) {
351 logger.warn("The serverPort is <= 1024 and may cause permission errors under Linux, try a higher port.");
353 if (groupConfig.getServerPort() > 0) {
354 startStreamServer(true);
356 updateStatus(ThingStatus.ONLINE);
357 pollCameraGroupJob = pollCameraGroup.scheduleAtFixedRate(this::pollCameraGroup, 10000,
358 groupConfig.getPollTime(), TimeUnit.MILLISECONDS);
362 public void dispose() {
363 startStreamServer(false);
364 groupTracker.listOfGroupHandlers.remove(this);
365 if (pollCameraGroupJob != null) {
366 pollCameraGroupJob.cancel(true);
367 pollCameraGroupJob = null;