diff --git a/bundles/org.openhab.binding.ipcamera/README.md b/bundles/org.openhab.binding.ipcamera/README.md index 9be185f4c7f12..ac7b6862eded4 100644 --- a/bundles/org.openhab.binding.ipcamera/README.md +++ b/bundles/org.openhab.binding.ipcamera/README.md @@ -41,7 +41,7 @@ Example: ``` Thing ipcamera:generic:Esp32Cam [ - ipAddress="192.168.1.181", serverPort=54322, + ipAddress="192.168.1.181", gifPreroll=1, snapshotUrl="http://192.168.1.181/capture", mjpegUrl="http://192.168.1.181:81/stream", @@ -114,7 +114,6 @@ Thing ipcamera:hikvision:West "West Camera" onvifPort=8000, //normally 80 check what it needs port=80, nvrChannel=4, - serverPort=54324, ffmpegOutput="/var/lib/openhab/ipcamera/West/", ffmpegInput="rtsp://192.168.0.XX:554/ISAPI/Streaming/channels/401" ] @@ -154,7 +153,7 @@ Example: The thing type for a camera with no ONVIF support is "generic". ## Thing Configuration -After a camera is added, the first step is to provide login details and a valid serverPort for your camera before it will come online. +After a camera is added, the first step is to provide login details for your camera before it will come online. If your camera is not ONVIF/API based, you will also need to provide the binding with the cameras URLs to the relevant config field/s. For ONVIF cameras that auto detect the wrong URL, these same fields can be used to force a URL of your choosing but leaving them blank will allow the binding to find the URL for you. @@ -169,7 +168,6 @@ If you do not specify any of these, the binding will use the default which shoul | `ipAddress`| The IP address or host name of your camera. | | `port`| This port will be used for HTTP calls for fetching the snapshot and any API calls. | | `onvifPort`| The port your camera uses for ONVIF connections. This is needed for PTZ movement, events, and the auto discovery of RTSP and snapshot URLs. | -| `serverPort`| The port that will serve the video streams and snapshots back to openHAB without authentication. You can choose any number, but it must be unique and unused for each camera that you setup. Setting the port to -1 (default), will turn all file serving off and some features will fail to work. | | `username`| Leave blank if your camera does not use login details. | | `password`| Leave blank if your camera does not use login details. | | `onvifMediaProfile`| 0 (default) is your cameras Mainstream and the numbers above 0 are the substreams. Any auto discovered URLs will use the streams that this indicates. You can always override the URLs should you wish to use something different for one of them. | @@ -364,16 +362,17 @@ There are a number of ways to use snapshots with this binding. + Use the cameras URL so it passes from the camera directly to your end device. ie a tablet. This is always the best option if it works. -+ Request a snapshot with the URL `http://192.168.xxx.xxx:54321/ipcamera.jpg`. -The IP is for your openHAB server not the camera, and 54321 is the `serverPort` number that you specified in the bindings setup. If you find the snapshot is old, you can set the `gifPreroll` to a number above 0 and this forces the camera to keep updating the stored JPG in RAM. ++ Request a snapshot with the URL `http://openhabIP:8080/ipcamera/{cameraUID}/ipcamera.jpg`. +The IP is for your openHAB server not the camera. +If you find the snapshot is old, you can set the `gifPreroll` to a number above 0 and this forces the camera to keep updating the stored JPG in RAM. The ipcamera.jpg can also be cast, as most cameras can not directly cast their snapshots. -+ Use the `http://192.168.xxx.xxx:54321/snapshots.mjpeg` to request a stream of snapshots to be delivered in MJPEG format. ++ Use the `http://openHAB:8080/ipcamera/{cameraUID}/snapshots.mjpeg` to request a stream of snapshots to be delivered in MJPEG format. + Use the record GIF action and use a `gifPreroll` value > 0. This creates a number of snapshots in the FFmpeg output folder called snapshotXXX.jpg where XXX starts at 0 and increases each `pollTime`. This allows you to get a snapshot from an exact amount of time before, on, or after starting the record to GIF action. Handy for cameras which lag due to slow processors, or if you do not want a hand blocking the image when the door bell was pushed. These snapshots can be fetched either directly as they exist on disk, or via this URL format. -`http://192.168.xxx.xxx:54321/snapshot0.jpg` +`http://openHAB:8080/ipcamera/{cameraUID}/snapshot0.jpg` + Also worth a mention is that you can off load cameras to a software package running on a separate server such as, Motion, Shinobi and Zoneminder. See this forum thread for examples of how to use snapshots and streams in a sitemap. @@ -396,9 +395,9 @@ sudo apt update && sudo apt install ffmpeg **IMPORTANT:** The binding has its own file server that works by allowing access to the snapshot and video streams with no user/password for requests that come from an IP located in the `ipWhitelist`. Requests from external IPs or internal requests that are not on the `ipWhitelist` will fail to get any answer. -If you prefer to use your own firewall instead, you can also choose to make the `ipWhitelist` equal "DISABLE" (the default since the feature also needs a valid serverPort set) to turn this feature off and then all internal IPs will have access. +If you prefer to use your own firewall instead, you can also choose to make the `ipWhitelist` equal "DISABLE" and then all internal IPs will have access. -There are multiple ways to get a moving picture, to use them just enter the URL into any browser using `http://192.168.xxx.xxx:serverPort/name.format` replacing the name.format with one of the options that are listed below: +There are multiple ways to get a moving picture, to use them just enter the URL into any browser using `http://openHAB:8080/ipcamera/{cameraUID}/name.format` replacing the name.format with one of the options that are listed below: + **ipcamera.m3u8** HLS (HTTP Live Streaming) which uses H.264 compression. This can be used to cast to Chromecast devices, or can display video in many browsers (some browsers require a plugin to be installed). @@ -430,9 +429,9 @@ The main cameras that can do MJPEG with very low CPU load are Amcrest, Dahua, Hi To set this up, see [Special Notes for Different Brands](#special-notes-for-different-brands). The binding can then distribute this stream to many devices around your home whilst the camera only sees a single open stream. -To request the MJPEG stream from the binding, all you need to do is use this link changing the IP to that of your openHAB server and the serverPort to match the settings in the bindings setup for that camera. +To request the MJPEG stream from the binding, all you need to do is use this link changing the IP to that of your openHAB server and the uniqueID of the camera. - + **Creating MJPEG with FFmpeg** @@ -456,16 +455,16 @@ The autofps.mjpeg feature will display a snapshot that updates every 8 seconds t This means lower traffic unless the picture is actually changing. Request the stream to be sent to an item with this URL. -NOTE: The IP is openHAB's not your cameras IP and the 54321 is what you have set as the serverPort. +NOTE: The IP is openHAB's not your cameras IP. -`http://192.168.xxx.xxx:54321/snapshots.mjpeg` +`http://openHAB:8080/ipcamera/{cameraUID}/snapshots.mjpeg` Use the following to display it in your sitemap. ``` -Video url="http://192.168.0.32:54321/autofps.mjpeg" encoding="mjpeg" +Video url="http://openHAB:8080/ipcamera/{cameraUID}/autofps.mjpeg" encoding="mjpeg" -Video url="http://192.168.0.32:54321/snapshots.mjpeg" encoding="mjpeg" +Video url="http://openHAB:8080/ipcamera/{cameraUID}/snapshots.mjpeg" encoding="mjpeg" ``` ## HLS (HTTP Live Streaming) @@ -478,14 +477,13 @@ The startup delay and the lag are two different things, with the startup delay e If the channel is OFF, the stream will start and stop automatically as required and the channel will reflect its current status. With a fast openHAB server it should only need to be requested once, but on slower ARM systems it takes a while for FFmpeg to get up and running at full speed. -It can be helpful sometimes to use this line in a rule to start the stream before it is needed further on in the rule `sendHttpGetRequest("http://192.168.0.2:54321/ipcamera.m3u8")` as the stream will stay running for 64 seconds. +It can be helpful sometimes to use this line in a rule to start the stream before it is needed further on in the rule `sendHttpGetRequest("http://openHAB:8080/ipcamera/{cameraUID}/ipcamera.m3u8")` as the stream will stay running for 64 seconds. This 64 second delay before the stream is stopped helps when you are moving back and forth in a UI, as the stream does not keep stopping and needing to start each time you move around in a UI. To use the HLS feature, you need to: + Ensure FFmpeg is installed. + For `generic` cameras, you will need to use the config `ffmpegInput` to provide a HTTP or RTSP URL. -+ Set a valid `serverPort` as the value of -1 will turn this feature off. + Consider using a SSD/HDD, zram location, or a tmpfs (ram drive) can be used if you only have micro SD/flash based storage. ### Ram Drive Setup @@ -544,9 +542,9 @@ The webview version allows you to zoom in on the video when using the iOS app, t ``` -Text label="HLS Video Stream" icon="camera"{Video url="http://192.168.1.9:54321/ipcamera.m3u8" encoding="hls"} +Text label="HLS Video Stream" icon="camera"{Video url="http://openHAB:8080/ipcamera/{cameraUID}/ipcamera.m3u8" encoding="hls"} -Text label="HLS Webview Stream" icon="camera"{Webview url="http://192.168.1.9:54321/ipcamera.m3u8" height=15} +Text label="HLS Webview Stream" icon="camera"{Webview url="http://openHAB:8080/ipcamera/{cameraUID}/ipcamera.m3u8" height=15} ``` @@ -565,16 +563,16 @@ Webview url="http://192.168.6.4:8080/static/html/file.html" height=5
-
-
-
-
@@ -616,7 +614,7 @@ String KitchenHomeHubPlayURI { channel="chromecast:chromecast:KitchenHomeHub:pla In a rule... ``` -KitchenHomeHubPlayURI.sendCommand("http://192.168.1.2:54321/ipcamera.m3u8") +KitchenHomeHubPlayURI.sendCommand("http://openHAB:8080/ipcamera/{cameraUID}/ipcamera.m3u8") ``` @@ -648,7 +646,7 @@ The snapshots are saved to disk and can be used as a feature that is described i You can request the GIF and MP4 by using this URL format, or by the direct path to where the file is stored: - + .items @@ -699,6 +697,7 @@ Some additional checks to get it working are: If you have 3 seconds worth of video segments in each cameras HLS stream, this is the max you can set the poll time of the group to. + All cameras in a group should have the same HLS segment size setting, 1 and 2 second long segments have been tested to work. + Mixing cameras with different aspect ratios may cause issues when cast. ++ The HLS files need to remain on disk for the number of cameras X pollTime, use the `-hls_delete_threshold` ffmpeg option to control this. ## Sitemap Example @@ -722,9 +721,9 @@ If you use the `Create Equipment from Thing` feature to auto create your items, Slider item=BabyCam_Zoom icon=zoom } Default item=BabyCam_StartHLSStream - Text label="Mjpeg Stream" icon="camera"{Video url="http://192.168.0.2:54321/ipcamera.mjpeg" encoding="mjpeg"} - Text label="HLS Stream" icon="camera"{Webview url="http://192.168.0.2:54321/ipcamera.m3u8" height=15} - Video url="http://192.168.0.2:54321/autofps.mjpeg" encoding="mjpeg" + Text label="Mjpeg Stream" icon="camera"{Video url="http://openHAB:8080/ipcamera/BabyCam/ipcamera.mjpeg" encoding="mjpeg"} + Text label="HLS Stream" icon="camera"{Webview url="http://openHAB:8080/ipcamera/BabyCam/ipcamera.m3u8" height=15} + Video url="http://openHAB:8080/ipcamera/BabyCam/autofps.mjpeg" encoding="mjpeg" } ``` diff --git a/bundles/org.openhab.binding.ipcamera/src/main/java/org/openhab/binding/ipcamera/internal/CameraConfig.java b/bundles/org.openhab.binding.ipcamera/src/main/java/org/openhab/binding/ipcamera/internal/CameraConfig.java index df2e5725d7624..375f46edf7b80 100644 --- a/bundles/org.openhab.binding.ipcamera/src/main/java/org/openhab/binding/ipcamera/internal/CameraConfig.java +++ b/bundles/org.openhab.binding.ipcamera/src/main/java/org/openhab/binding/ipcamera/internal/CameraConfig.java @@ -25,7 +25,6 @@ public class CameraConfig { private String ffmpegInputOptions = ""; private int port; private int onvifPort; - private int serverPort; private String username = ""; private String password = ""; private int onvifMediaProfile; @@ -142,10 +141,6 @@ public int getOnvifPort() { return onvifPort; } - public int getServerPort() { - return serverPort; - } - public String getIp() { return ipAddress; } diff --git a/bundles/org.openhab.binding.ipcamera/src/main/java/org/openhab/binding/ipcamera/internal/GroupConfig.java b/bundles/org.openhab.binding.ipcamera/src/main/java/org/openhab/binding/ipcamera/internal/GroupConfig.java index 7a098460b65bf..85232c2196f54 100644 --- a/bundles/org.openhab.binding.ipcamera/src/main/java/org/openhab/binding/ipcamera/internal/GroupConfig.java +++ b/bundles/org.openhab.binding.ipcamera/src/main/java/org/openhab/binding/ipcamera/internal/GroupConfig.java @@ -21,7 +21,7 @@ */ @NonNullByDefault public class GroupConfig { - private int pollTime, serverPort; + private int pollTime; private boolean motionChangesOrder = true; private String ipWhitelist = ""; private String ffmpegLocation = ""; @@ -63,10 +63,6 @@ public String getFfmpegOutput() { return ffmpegOutput; } - public int getServerPort() { - return serverPort; - } - public int getPollTime() { return pollTime; } diff --git a/bundles/org.openhab.binding.ipcamera/src/main/java/org/openhab/binding/ipcamera/internal/GroupTracker.java b/bundles/org.openhab.binding.ipcamera/src/main/java/org/openhab/binding/ipcamera/internal/GroupTracker.java index ca7a28bb827da..99a71da09f748 100644 --- a/bundles/org.openhab.binding.ipcamera/src/main/java/org/openhab/binding/ipcamera/internal/GroupTracker.java +++ b/bundles/org.openhab.binding.ipcamera/src/main/java/org/openhab/binding/ipcamera/internal/GroupTracker.java @@ -12,7 +12,8 @@ */ package org.openhab.binding.ipcamera.internal; -import java.util.ArrayList; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArraySet; import org.eclipse.jdt.annotation.NonNullByDefault; import org.openhab.binding.ipcamera.internal.handler.IpCameraGroupHandler; @@ -27,7 +28,7 @@ @NonNullByDefault public class GroupTracker { - public ArrayList listOfOnlineCameraHandlers = new ArrayList<>(1); - public ArrayList listOfGroupHandlers = new ArrayList<>(0); - public ArrayList listOfOnlineCameraUID = new ArrayList<>(1); + public Set listOfOnlineCameraHandlers = new CopyOnWriteArraySet<>(); + public Set listOfGroupHandlers = new CopyOnWriteArraySet<>(); + public Set listOfOnlineCameraUID = new CopyOnWriteArraySet<>(); } diff --git a/bundles/org.openhab.binding.ipcamera/src/main/java/org/openhab/binding/ipcamera/internal/InstarHandler.java b/bundles/org.openhab.binding.ipcamera/src/main/java/org/openhab/binding/ipcamera/internal/InstarHandler.java index 9cf6c0d9d2064..9c59fdbd3624b 100644 --- a/bundles/org.openhab.binding.ipcamera/src/main/java/org/openhab/binding/ipcamera/internal/InstarHandler.java +++ b/bundles/org.openhab.binding.ipcamera/src/main/java/org/openhab/binding/ipcamera/internal/InstarHandler.java @@ -23,7 +23,6 @@ import org.openhab.core.library.types.PercentType; import org.openhab.core.library.types.StringType; import org.openhab.core.thing.ChannelUID; -import org.openhab.core.thing.binding.ThingHandler; import org.openhab.core.types.Command; import org.openhab.core.types.RefreshType; @@ -43,8 +42,8 @@ public class InstarHandler extends ChannelDuplexHandler { private IpCameraHandler ipCameraHandler; private String requestUrl = "Empty"; - public InstarHandler(ThingHandler thingHandler) { - ipCameraHandler = (IpCameraHandler) thingHandler; + public InstarHandler(IpCameraHandler thingHandler) { + ipCameraHandler = thingHandler; } public void setURL(String url) { @@ -185,7 +184,7 @@ public void handleCommand(ChannelUID channelUID, Command command) { } } - void alarmTriggered(String alarm) { + public void alarmTriggered(String alarm) { ipCameraHandler.logger.debug("Alarm has been triggered:{}", alarm); switch (alarm) { case "/instar?&active=1":// The motion area boxes 1-4 diff --git a/bundles/org.openhab.binding.ipcamera/src/main/java/org/openhab/binding/ipcamera/internal/IpCameraBindingConstants.java b/bundles/org.openhab.binding.ipcamera/src/main/java/org/openhab/binding/ipcamera/internal/IpCameraBindingConstants.java index c8561291e9355..49ed7f536f896 100644 --- a/bundles/org.openhab.binding.ipcamera/src/main/java/org/openhab/binding/ipcamera/internal/IpCameraBindingConstants.java +++ b/bundles/org.openhab.binding.ipcamera/src/main/java/org/openhab/binding/ipcamera/internal/IpCameraBindingConstants.java @@ -44,6 +44,9 @@ public static enum FFmpegFormat { } public static final BigDecimal BIG_DECIMAL_SCALE_MOTION = new BigDecimal(5000); + public static final long HLS_STARTUP_DELAY_MS = 4500; + @SuppressWarnings("null") + public static final int SERVLET_PORT = Integer.getInteger("org.osgi.service.http.port", 8080); // List of all Thing Type UIDs public static final ThingTypeUID THING_TYPE_GROUP = new ThingTypeUID(BINDING_ID, "group"); diff --git a/bundles/org.openhab.binding.ipcamera/src/main/java/org/openhab/binding/ipcamera/internal/IpCameraHandlerFactory.java b/bundles/org.openhab.binding.ipcamera/src/main/java/org/openhab/binding/ipcamera/internal/IpCameraHandlerFactory.java index 7652bedb22933..6b090028e1ef8 100644 --- a/bundles/org.openhab.binding.ipcamera/src/main/java/org/openhab/binding/ipcamera/internal/IpCameraHandlerFactory.java +++ b/bundles/org.openhab.binding.ipcamera/src/main/java/org/openhab/binding/ipcamera/internal/IpCameraHandlerFactory.java @@ -27,6 +27,7 @@ import org.osgi.service.component.annotations.Activate; import org.osgi.service.component.annotations.Component; import org.osgi.service.component.annotations.Reference; +import org.osgi.service.http.HttpService; /** * The {@link IpCameraHandlerFactory} is responsible for creating things and thing @@ -40,12 +41,15 @@ public class IpCameraHandlerFactory extends BaseThingHandlerFactory { private final @Nullable String openhabIpAddress; private final GroupTracker groupTracker = new GroupTracker(); private final IpCameraDynamicStateDescriptionProvider stateDescriptionProvider; + private final HttpService httpService; @Activate public IpCameraHandlerFactory(final @Reference NetworkAddressService networkAddressService, - final @Reference IpCameraDynamicStateDescriptionProvider stateDescriptionProvider) { + final @Reference IpCameraDynamicStateDescriptionProvider stateDescriptionProvider, + final @Reference HttpService httpService) { openhabIpAddress = networkAddressService.getPrimaryIpv4HostAddress(); this.stateDescriptionProvider = stateDescriptionProvider; + this.httpService = httpService; } @Override @@ -58,9 +62,9 @@ public boolean supportsThingType(ThingTypeUID thingTypeUID) { ThingTypeUID thingTypeUID = thing.getThingTypeUID(); if (SUPPORTED_THING_TYPES.contains(thingTypeUID)) { - return new IpCameraHandler(thing, openhabIpAddress, groupTracker, stateDescriptionProvider); + return new IpCameraHandler(thing, openhabIpAddress, groupTracker, stateDescriptionProvider, httpService); } else if (GROUP_SUPPORTED_THING_TYPES.contains(thingTypeUID)) { - return new IpCameraGroupHandler(thing, openhabIpAddress, groupTracker); + return new IpCameraGroupHandler(thing, openhabIpAddress, groupTracker, httpService); } return null; } diff --git a/bundles/org.openhab.binding.ipcamera/src/main/java/org/openhab/binding/ipcamera/internal/StreamServerGroupHandler.java b/bundles/org.openhab.binding.ipcamera/src/main/java/org/openhab/binding/ipcamera/internal/StreamServerGroupHandler.java deleted file mode 100644 index 98d1971a70372..0000000000000 --- a/bundles/org.openhab.binding.ipcamera/src/main/java/org/openhab/binding/ipcamera/internal/StreamServerGroupHandler.java +++ /dev/null @@ -1,234 +0,0 @@ -/** - * Copyright (c) 2010-2021 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.internal.IpCameraBindingConstants.CHANNEL_START_STREAM; - -import java.io.File; -import java.io.IOException; -import java.net.InetSocketAddress; -import java.nio.charset.StandardCharsets; - -import org.eclipse.jdt.annotation.NonNullByDefault; -import org.eclipse.jdt.annotation.Nullable; -import org.openhab.binding.ipcamera.internal.handler.IpCameraGroupHandler; -import org.openhab.binding.ipcamera.internal.handler.IpCameraHandler; -import org.openhab.core.library.types.OnOffType; -import org.openhab.core.thing.ChannelUID; -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.HttpHeaderNames; -import io.netty.handler.codec.http.HttpHeaderValues; -import io.netty.handler.codec.http.HttpMethod; -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.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 StreamServerGroupHandler} class is responsible for handling streams and sending any requested files to - * Openhabs - * features for a group of cameras instead of individual cameras. - * - * @author Matthew Skinner - Initial contribution - */ - -@NonNullByDefault -public class StreamServerGroupHandler extends ChannelInboundHandlerAdapter { - private final Logger logger = LoggerFactory.getLogger(getClass()); - private IpCameraGroupHandler ipCameraGroupHandler; - private String whiteList = ""; - - public StreamServerGroupHandler(IpCameraGroupHandler ipCameraGroupHandler) { - this.ipCameraGroupHandler = ipCameraGroupHandler; - whiteList = ipCameraGroupHandler.groupConfig.getIpWhitelist(); - } - - @Override - public void handlerAdded(@Nullable ChannelHandlerContext ctx) { - } - - private String resolveIndexToPath(String uri) { - if (!"i".equals(uri.substring(1, 2))) { - return ipCameraGroupHandler.getOutputFolder(Integer.parseInt(uri.substring(1, 2))); - } - return "notFound"; - // example is /1ipcameraxx.ts - } - - @Override - public void channelRead(@Nullable ChannelHandlerContext ctx, @Nullable Object msg) throws Exception { - if (msg == null || ctx == null) { - return; - } - try { - if (msg instanceof HttpRequest) { - HttpRequest httpRequest = (HttpRequest) msg; - String requestIP = "(" - + ((InetSocketAddress) ctx.channel().remoteAddress()).getAddress().getHostAddress() + ")"; - if (!whiteList.contains(requestIP) && !"DISABLE".equals(whiteList)) { - logger.warn("The request made from {} was not in the whitelist and will be ignored.", requestIP); - return; - } else if (HttpMethod.GET.equals(httpRequest.method())) { - // 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 (ipCameraGroupHandler.hlsTurnedOn) { - String debugMe = ipCameraGroupHandler.getPlayList(); - logger.debug("playlist is:{}", debugMe); - sendString(ctx, debugMe, "application/x-mpegurl"); - return; - } else { - logger.warn( - "HLS requires the groups startStream channel to be turned on first. Just starting it now."); - String channelPrefix = "ipcamera:" + ipCameraGroupHandler.getThing().getThingTypeUID() - + ":" + ipCameraGroupHandler.getThing().getUID().getId() + ":"; - ipCameraGroupHandler.handleCommand(new ChannelUID(channelPrefix + CHANNEL_START_STREAM), - OnOffType.ON); - } - break; - case "/ipcamera.jpg": - sendSnapshotImage(ctx, "image/jpg"); - return; - default: - if (httpRequest.uri().contains(".ts")) { - sendFile(ctx, resolveIndexToPath(httpRequest.uri()) + httpRequest.uri().substring(2), - "video/MP2T"); - } else if (httpRequest.uri().contains(".jpg")) { - sendFile(ctx, httpRequest.uri(), "image/jpg"); - } else if (httpRequest.uri().contains(".m4s") || httpRequest.uri().contains(".mp4")) { - sendFile(ctx, httpRequest.uri(), "video/mp4"); - } - } - } - } - } finally { - ReferenceCountUtil.release(msg); - } - } - - private void sendSnapshotImage(ChannelHandlerContext ctx, String contentType) { - HttpResponse response = new DefaultHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK); - if (ipCameraGroupHandler.cameraIndex >= ipCameraGroupHandler.cameraOrder.size()) { - logger.debug("WARN: Openhab may still be starting, or all cameras in the group are OFFLINE."); - return; - } - IpCameraHandler handler = ipCameraGroupHandler.cameraOrder.get(ipCameraGroupHandler.cameraIndex); - handler.lockCurrentSnapshot.lock(); - try { - ByteBuf snapshotData = Unpooled.copiedBuffer(handler.currentSnapshot); - 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); - } finally { - handler.lockCurrentSnapshot.unlock(); - } - } - - private void sendFile(ChannelHandlerContext ctx, String fileUri, String contentType) throws IOException { - logger.trace("file is :{}", fileUri); - File file = new File(fileUri); - ChunkedFile chunkedFile = new ChunkedFile(file); - ByteBuf footerBbuf = Unpooled.copiedBuffer("\r\n", 0, 2, StandardCharsets.UTF_8); - 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.CLOSE); - 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); - ctx.channel().writeAndFlush(footerBbuf); - } - - private void sendString(ChannelHandlerContext ctx, String contents, String contentType) { - ByteBuf contentsBbuf = Unpooled.copiedBuffer(contents, 0, contents.length(), StandardCharsets.UTF_8); - 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.CLOSE); - response.headers().add(HttpHeaderNames.CONTENT_LENGTH, contentsBbuf.readableBytes()); - response.headers().add("Access-Control-Allow-Origin", "*"); - response.headers().add("Access-Control-Expose-Headers", "*"); - ByteBuf footerBbuf = Unpooled.copiedBuffer("\r\n", 0, 2, StandardCharsets.UTF_8); - ctx.channel().write(response); - ctx.channel().write(contentsBbuf); - 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 (cause == null || ctx == null) { - return; - } - if (cause.toString().contains("Connection reset by peer")) { - logger.debug("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.getMessage()); - } - ctx.close(); - } - - @Override - public void userEventTriggered(@Nullable ChannelHandlerContext ctx, @Nullable Object evt) throws Exception { - if (evt == null || ctx == null) { - return; - } - 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; - } - ctx.close(); - } -} diff --git a/bundles/org.openhab.binding.ipcamera/src/main/java/org/openhab/binding/ipcamera/internal/StreamServerHandler.java b/bundles/org.openhab.binding.ipcamera/src/main/java/org/openhab/binding/ipcamera/internal/StreamServerHandler.java deleted file mode 100644 index 0763115fcbc7b..0000000000000 --- a/bundles/org.openhab.binding.ipcamera/src/main/java/org/openhab/binding/ipcamera/internal/StreamServerHandler.java +++ /dev/null @@ -1,294 +0,0 @@ -/** - * Copyright (c) 2010-2021 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 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.internal.IpCameraBindingConstants.FFmpegFormat; -import org.openhab.binding.ipcamera.internal.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]; - private String whiteList = ""; - private int recievedBytes = 0; - private boolean updateSnapshot = false; - private boolean onvifEvent = false; - - public StreamServerHandler(IpCameraHandler ipCameraHandler) { - this.ipCameraHandler = ipCameraHandler; - whiteList = ipCameraHandler.getWhiteList(); - } - - @Override - public void handlerAdded(@Nullable ChannelHandlerContext ctx) { - } - - @Override - public void channelRead(@Nullable ChannelHandlerContext ctx, @Nullable Object msg) throws Exception { - if (ctx == null) { - return; - } - - try { - if (msg instanceof HttpRequest) { - HttpRequest httpRequest = (HttpRequest) msg; - if (!"DISABLE".equals(whiteList)) { - 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": - Ffmpeg localFfmpeg = ipCameraHandler.ffmpegHLS; - if (localFfmpeg == null) { - ipCameraHandler.setupFfmpegFormat(FFmpegFormat.HLS); - } else if (!localFfmpeg.getIsAlive()) { - localFfmpeg.startConverting(); - } else { - localFfmpeg.setKeepAlive(8); - sendFile(ctx, httpRequest.uri(), "application/x-mpegurl"); - return; - } - // Allow files to be created, or you get old m3u8 from the last time this ran. - TimeUnit.MILLISECONDS.sleep(4500); - sendFile(ctx, httpRequest.uri(), "application/x-mpegurl"); - return; - case "/ipcamera.mpd": - sendFile(ctx, httpRequest.uri(), "application/dash+xml"); - return; - case "/ipcamera.gif": - sendFile(ctx, httpRequest.uri(), "image/gif"); - return; - case "/ipcamera.jpg": - if (!ipCameraHandler.snapshotPolling && ipCameraHandler.snapshotUri != "") { - ipCameraHandler.sendHttpGET(ipCameraHandler.snapshotUri); - } - if (ipCameraHandler.currentSnapshot.length == 1) { - logger.warn("ipcamera.jpg was requested but there is no jpg in ram to send."); - return; - } - sendSnapshotImage(ctx, "image/jpg"); - return; - case "/snapshots.mjpeg": - handlingSnapshotStream = true; - ipCameraHandler.startSnapshotPolling(); - 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": - default: - if (httpRequest.uri().contains(".ts")) { - sendFile(ctx, queryStringDecoder.path(), "video/MP2T"); - } else if (httpRequest.uri().contains(".gif")) { - sendFile(ctx, queryStringDecoder.path(), "image/gif"); - } 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") || 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 \tPOST:{}", httpRequest.uri()); - break; - } - } - } - if (msg instanceof HttpContent) { - HttpContent content = (HttpContent) msg; - if (recievedBytes == 0) { - incomingJpeg = new byte[content.content().readableBytes()]; - content.content().getBytes(0, incomingJpeg, 0, content.content().readableBytes()); - } else { - byte[] temp = incomingJpeg; - incomingJpeg = new byte[recievedBytes + content.content().readableBytes()]; - System.arraycopy(temp, 0, incomingJpeg, 0, temp.length); - content.content().getBytes(0, incomingJpeg, temp.length, content.content().readableBytes()); - } - recievedBytes = incomingJpeg.length; - if (content instanceof LastHttpContent) { - if (updateSnapshot) { - ipCameraHandler.processSnapshot(incomingJpeg); - } else if (onvifEvent) { - ipCameraHandler.onvifCamera.eventRecieved(new String(incomingJpeg, StandardCharsets.UTF_8)); - } else { // handles the snapshots that make up mjpeg from rtsp to ffmpeg conversions. - if (recievedBytes > 1000) { - ipCameraHandler.sendMjpegFrame(incomingJpeg, ipCameraHandler.mjpegChannelGroup); - } - } - recievedBytes = 0; - } - } - } finally { - ReferenceCountUtil.release(msg); - } - } - - private void sendSnapshotImage(ChannelHandlerContext ctx, String contentType) { - HttpResponse response = new DefaultHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK); - ipCameraHandler.lockCurrentSnapshot.lock(); - try { - ByteBuf snapshotData = Unpooled.copiedBuffer(ipCameraHandler.currentSnapshot); - 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); - } finally { - ipCameraHandler.lockCurrentSnapshot.unlock(); - } - } - - private void sendFile(ChannelHandlerContext ctx, String fileUri, String contentType) throws IOException { - File file = new File(ipCameraHandler.cameraConfig.getFfmpegOutput() + 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.CLOSE); - 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.getMessage()); - } - ctx.close(); - } - - @Override - public void userEventTriggered(@Nullable ChannelHandlerContext ctx, @Nullable Object evt) throws Exception { - if (ctx == null) { - return; - } - 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; - } - ctx.close(); - if (handlingMjpeg) { - ipCameraHandler.setupMjpegStreaming(false, ctx); - } else if (handlingSnapshotStream) { - handlingSnapshotStream = false; - ipCameraHandler.setupSnapshotStreaming(false, ctx, false); - } - } -} diff --git a/bundles/org.openhab.binding.ipcamera/src/main/java/org/openhab/binding/ipcamera/internal/handler/IpCameraGroupHandler.java b/bundles/org.openhab.binding.ipcamera/src/main/java/org/openhab/binding/ipcamera/internal/handler/IpCameraGroupHandler.java index c089e77681b95..9b9ec1e942fc9 100644 --- a/bundles/org.openhab.binding.ipcamera/src/main/java/org/openhab/binding/ipcamera/internal/handler/IpCameraGroupHandler.java +++ b/bundles/org.openhab.binding.ipcamera/src/main/java/org/openhab/binding/ipcamera/internal/handler/IpCameraGroupHandler.java @@ -17,7 +17,6 @@ import java.io.IOException; import java.math.BigDecimal; import java.math.RoundingMode; -import java.net.InetSocketAddress; import java.nio.file.Files; import java.nio.file.Paths; import java.util.ArrayList; @@ -32,30 +31,19 @@ import org.openhab.binding.ipcamera.internal.GroupConfig; import org.openhab.binding.ipcamera.internal.GroupTracker; import org.openhab.binding.ipcamera.internal.Helper; -import org.openhab.binding.ipcamera.internal.StreamServerGroupHandler; +import org.openhab.binding.ipcamera.internal.servlet.GroupServlet; import org.openhab.core.library.types.OnOffType; import org.openhab.core.library.types.StringType; import org.openhab.core.thing.ChannelUID; import org.openhab.core.thing.Thing; import org.openhab.core.thing.ThingStatus; -import org.openhab.core.thing.ThingStatusDetail; import org.openhab.core.thing.binding.BaseThingHandler; import org.openhab.core.types.Command; import org.openhab.core.types.RefreshType; +import org.osgi.service.http.HttpService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import io.netty.bootstrap.ServerBootstrap; -import io.netty.channel.ChannelFuture; -import io.netty.channel.ChannelInitializer; -import io.netty.channel.EventLoopGroup; -import io.netty.channel.nio.NioEventLoopGroup; -import io.netty.channel.socket.SocketChannel; -import io.netty.channel.socket.nio.NioServerSocketChannel; -import io.netty.handler.codec.http.HttpServerCodec; -import io.netty.handler.stream.ChunkedWriteHandler; -import io.netty.handler.timeout.IdleStateHandler; - /** * The {@link IpCameraGroupHandler} is responsible for finding cameras that are part of this group and displaying a * group picture. @@ -66,14 +54,13 @@ @NonNullByDefault public class IpCameraGroupHandler extends BaseThingHandler { private final Logger logger = LoggerFactory.getLogger(getClass()); + private final HttpService httpService; public GroupConfig groupConfig; private BigDecimal pollTimeInSeconds = new BigDecimal(2); public ArrayList cameraOrder = new ArrayList(2); - private EventLoopGroup serversLoopGroup = new NioEventLoopGroup(); private final ScheduledExecutorService pollCameraGroup = Executors.newSingleThreadScheduledExecutor(); private @Nullable ScheduledFuture pollCameraGroupJob = null; - private @Nullable ServerBootstrap serverBootstrap; - private @Nullable ChannelFuture serverFuture = null; + private @Nullable GroupServlet servlet; public String hostIp; private boolean motionChangesOrder = true; public int serverPort = 0; @@ -86,7 +73,8 @@ public class IpCameraGroupHandler extends BaseThingHandler { private int discontinuitySequence = 0; private GroupTracker groupTracker; - public IpCameraGroupHandler(Thing thing, @Nullable String openhabIpAddress, GroupTracker groupTracker) { + public IpCameraGroupHandler(Thing thing, @Nullable String openhabIpAddress, GroupTracker groupTracker, + HttpService httpService) { super(thing); groupConfig = getConfigAs(GroupConfig.class); if (openhabIpAddress != null) { @@ -95,12 +83,26 @@ public IpCameraGroupHandler(Thing thing, @Nullable String openhabIpAddress, Grou hostIp = Helper.getLocalIpAddress(); } this.groupTracker = groupTracker; + this.httpService = httpService; } public String getPlayList() { return playList; } + private int getNextIndex() { + if (cameraIndex + 1 == cameraOrder.size()) { + return 0; + } + return cameraIndex + 1; + } + + public byte[] getSnapshot() { + // ask camera to fetch the next jpg ahead of time + cameraOrder.get(getNextIndex()).getSnapshot(); + return cameraOrder.get(cameraIndex).getSnapshot(); + } + public String getOutputFolder(int index) { IpCameraHandler handle = cameraOrder.get(index); return handle.cameraConfig.getFfmpegOutput(); @@ -165,65 +167,30 @@ int howManySegments(String m3u8File) { public void createPlayList() { String m3u8File = readCamerasPlaylist(cameraIndex); - if (m3u8File == "") { + if (m3u8File.isEmpty()) { return; } int numberOfSegments = howManySegments(m3u8File); - logger.debug("Using {} segmented files to make up a poll period.", numberOfSegments); + logger.trace("Using {} segmented files to make up a poll period.", numberOfSegments); m3u8File = keepLast(m3u8File, numberOfSegments); m3u8File = m3u8File.replace("ipcamera", cameraIndex + "ipcamera"); // add index so we can then fetch output path if (entries > numberOfSegments * 3) { playingNow = removeFromStart(playingNow, entries - (numberOfSegments * 3)); } playingNow = playingNow + "#EXT-X-DISCONTINUITY\n" + m3u8File; - playList = "#EXTM3U\n#EXT-X-VERSION:6\n#EXT-X-TARGETDURATION:5\n#EXT-X-ALLOW-CACHE:NO\n#EXT-X-DISCONTINUITY-SEQUENCE:" - + discontinuitySequence + "\n#EXT-X-MEDIA-SEQUENCE:" + mediaSequence + "\n" + playingNow; + playList = "#EXTM3U\n#EXT-X-VERSION:4\n#EXT-X-TARGETDURATION:6\n#EXT-X-ALLOW-CACHE:NO\n#EXT-X-DISCONTINUITY-SEQUENCE:" + + discontinuitySequence + "\n#EXT-X-MEDIA-SEQUENCE:" + mediaSequence + "\n" + + "#EXT-X-INDEPENDENT-SEGMENTS\n" + playingNow; } - private IpCameraGroupHandler getHandle() { - return this; - } - - @SuppressWarnings("null") - public void startStreamServer(boolean start) { - if (!start) { - serversLoopGroup.shutdownGracefully(8, 8, TimeUnit.SECONDS); - serverBootstrap = null; - } else { - if (serverBootstrap == null) { - try { - serversLoopGroup = new NioEventLoopGroup(); - serverBootstrap = new ServerBootstrap(); - serverBootstrap.group(serversLoopGroup); - serverBootstrap.channel(NioServerSocketChannel.class); - // IP "0.0.0.0" will bind the server to all network connections// - serverBootstrap.localAddress(new InetSocketAddress("0.0.0.0", serverPort)); - serverBootstrap.childHandler(new ChannelInitializer() { - @Override - protected void initChannel(SocketChannel socketChannel) throws Exception { - socketChannel.pipeline().addLast("idleStateHandler", new IdleStateHandler(0, 25, 0)); - socketChannel.pipeline().addLast("HttpServerCodec", new HttpServerCodec()); - socketChannel.pipeline().addLast("ChunkedWriteHandler", new ChunkedWriteHandler()); - socketChannel.pipeline().addLast("streamServerHandler", - new StreamServerGroupHandler(getHandle())); - } - }); - serverFuture = serverBootstrap.bind().sync(); - serverFuture.await(4000); - logger.info("IpCamera file server for a group of cameras has started on port {} for all NIC's.", - serverPort); - updateState(CHANNEL_MJPEG_URL, - new StringType("http://" + hostIp + ":" + serverPort + "/ipcamera.mjpeg")); - updateState(CHANNEL_HLS_URL, - new StringType("http://" + hostIp + ":" + serverPort + "/ipcamera.m3u8")); - updateState(CHANNEL_IMAGE_URL, - new StringType("http://" + hostIp + ":" + serverPort + "/ipcamera.jpg")); - } catch (Exception e) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, - "Exception occured when starting the streaming server. Try changing the serverPort to another number."); - } - } - } + public void startStreamServer() { + servlet = new GroupServlet(this, httpService); + updateState(CHANNEL_MJPEG_URL, new StringType("http://" + hostIp + ":" + SERVLET_PORT + "/ipcamera/" + + getThing().getUID().getId() + "/snapshots.mjpeg")); + updateState(CHANNEL_HLS_URL, new StringType("http://" + hostIp + ":" + SERVLET_PORT + "/ipcamera/" + + getThing().getUID().getId() + "/ipcamera.m3u8")); + updateState(CHANNEL_IMAGE_URL, new StringType("http://" + hostIp + ":" + SERVLET_PORT + "/ipcamera/" + + getThing().getUID().getId() + "/ipcamera.jpg")); } void addCamera(String UniqueID) { @@ -231,9 +198,9 @@ void addCamera(String UniqueID) { for (IpCameraHandler handler : groupTracker.listOfOnlineCameraHandlers) { if (handler.getThing().getUID().getId().equals(UniqueID)) { if (!cameraOrder.contains(handler)) { - logger.info("Adding {} to a camera group.", UniqueID); + logger.debug("Adding {} to a camera group.", UniqueID); if (hlsTurnedOn) { - logger.info("Starting HLS for the new camera."); + logger.debug("Starting HLS for the new camera added to group."); String channelPrefix = "ipcamera:" + handler.getThing().getThingTypeUID() + ":" + handler.getThing().getUID().getId() + ":"; handler.handleCommand(new ChannelUID(channelPrefix + CHANNEL_START_STREAM), OnOffType.ON); @@ -262,7 +229,7 @@ public void cameraOnline(String uid) { // Event based. This is called as each camera comes online after the group handler is registered. public void cameraOffline(IpCameraHandler handle) { if (cameraOrder.remove(handle)) { - logger.info("Camera {} went offline and was removed from a group.", handle.getThing().getUID().getId()); + logger.debug("Camera {} went offline and was removed from a group.", handle.getThing().getUID().getId()); } } @@ -310,6 +277,12 @@ void pollCameraGroup() { if (motionChangesOrder) { cameraIndex = checkForMotion(cameraIndex); } + GroupServlet localServlet = servlet; + if (localServlet != null) { + if (localServlet.snapshotStreamsOpen > 0) { + cameraOrder.get(cameraIndex).getSnapshot(); + } + } if (hlsTurnedOn) { discontinuitySequence++; createPlayList(); @@ -339,19 +312,10 @@ public void handleCommand(ChannelUID channelUID, Command command) { @Override public void initialize() { groupConfig = getConfigAs(GroupConfig.class); - serverPort = groupConfig.getServerPort(); pollTimeInSeconds = new BigDecimal(groupConfig.getPollTime()); pollTimeInSeconds = pollTimeInSeconds.divide(new BigDecimal(1000), 1, RoundingMode.HALF_UP); motionChangesOrder = groupConfig.getMotionChangesOrder(); - - if (serverPort == -1) { - logger.warn("The serverPort = -1 which disables a lot of features. See readme for more info."); - } else if (serverPort < 1025) { - logger.warn("The serverPort is <= 1024 and may cause permission errors under Linux, try a higher port."); - } - if (groupConfig.getServerPort() > 0) { - startStreamServer(true); - } + startStreamServer(); updateStatus(ThingStatus.ONLINE); pollCameraGroupJob = pollCameraGroup.scheduleWithFixedDelay(this::pollCameraGroup, 10000, groupConfig.getPollTime(), TimeUnit.MILLISECONDS); @@ -359,12 +323,15 @@ public void initialize() { @Override public void dispose() { - startStreamServer(false); groupTracker.listOfGroupHandlers.remove(this); Future future = pollCameraGroupJob; if (future != null) { future.cancel(true); } cameraOrder.clear(); + GroupServlet localServlet = servlet; + if (localServlet != null) { + localServlet.dispose(); + } } } diff --git a/bundles/org.openhab.binding.ipcamera/src/main/java/org/openhab/binding/ipcamera/internal/handler/IpCameraHandler.java b/bundles/org.openhab.binding.ipcamera/src/main/java/org/openhab/binding/ipcamera/internal/handler/IpCameraHandler.java index eacb18c1ecca9..c04077042e9ed 100644 --- a/bundles/org.openhab.binding.ipcamera/src/main/java/org/openhab/binding/ipcamera/internal/handler/IpCameraHandler.java +++ b/bundles/org.openhab.binding.ipcamera/src/main/java/org/openhab/binding/ipcamera/internal/handler/IpCameraHandler.java @@ -23,7 +23,6 @@ import java.net.InetSocketAddress; import java.net.MalformedURLException; import java.net.URL; -import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; @@ -56,8 +55,8 @@ import org.openhab.binding.ipcamera.internal.IpCameraBindingConstants.FFmpegFormat; import org.openhab.binding.ipcamera.internal.IpCameraDynamicStateDescriptionProvider; import org.openhab.binding.ipcamera.internal.MyNettyAuthHandler; -import org.openhab.binding.ipcamera.internal.StreamServerHandler; import org.openhab.binding.ipcamera.internal.onvif.OnvifConnection; +import org.openhab.binding.ipcamera.internal.servlet.CameraServlet; import org.openhab.core.OpenHAB; import org.openhab.core.library.types.DecimalType; import org.openhab.core.library.types.IncreaseDecreaseType; @@ -74,11 +73,11 @@ import org.openhab.core.types.Command; import org.openhab.core.types.RefreshType; import org.openhab.core.types.State; +import org.osgi.service.http.HttpService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import io.netty.bootstrap.Bootstrap; -import io.netty.bootstrap.ServerBootstrap; import io.netty.buffer.ByteBuf; import io.netty.buffer.Unpooled; import io.netty.channel.Channel; @@ -93,24 +92,18 @@ import io.netty.channel.group.DefaultChannelGroup; import io.netty.channel.nio.NioEventLoopGroup; import io.netty.channel.socket.SocketChannel; -import io.netty.channel.socket.nio.NioServerSocketChannel; import io.netty.channel.socket.nio.NioSocketChannel; import io.netty.handler.codec.base64.Base64; import io.netty.handler.codec.http.DefaultFullHttpRequest; -import io.netty.handler.codec.http.DefaultHttpResponse; import io.netty.handler.codec.http.FullHttpRequest; import io.netty.handler.codec.http.HttpClientCodec; 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.HttpMessage; import io.netty.handler.codec.http.HttpMethod; import io.netty.handler.codec.http.HttpResponse; -import io.netty.handler.codec.http.HttpResponseStatus; -import io.netty.handler.codec.http.HttpServerCodec; import io.netty.handler.codec.http.HttpVersion; import io.netty.handler.codec.http.LastHttpContent; -import io.netty.handler.stream.ChunkedWriteHandler; import io.netty.handler.timeout.IdleState; import io.netty.handler.timeout.IdleStateEvent; import io.netty.handler.timeout.IdleStateHandler; @@ -134,10 +127,10 @@ public class IpCameraHandler extends BaseThingHandler { public CameraConfig cameraConfig = new CameraConfig(); // ChannelGroup is thread safe - public final ChannelGroup mjpegChannelGroup = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE); - private final ChannelGroup snapshotMjpegChannelGroup = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE); - private final ChannelGroup autoSnapshotMjpegChannelGroup = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE); public final ChannelGroup openChannels = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE); + private final HttpService httpService; + private @Nullable CameraServlet servlet; + public String mjpegContentType = ""; public @Nullable Ffmpeg ffmpegHLS = null; public @Nullable Ffmpeg ffmpegRecord = null; public @Nullable Ffmpeg ffmpegGIF = null; @@ -151,10 +144,7 @@ public class IpCameraHandler extends BaseThingHandler { private @Nullable ScheduledFuture pollCameraJob = null; private @Nullable ScheduledFuture snapshotJob = null; private @Nullable Bootstrap mainBootstrap; - private @Nullable ServerBootstrap serverBootstrap; - private EventLoopGroup mainEventLoopGroup = new NioEventLoopGroup(); - private EventLoopGroup serversLoopGroup = new NioEventLoopGroup(); private FullHttpRequest putRequestWithBody = new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, new HttpMethod("PUT"), ""); private String gifFilename = "ipcamera"; @@ -168,7 +158,6 @@ public class IpCameraHandler extends BaseThingHandler { private LinkedList fifoSnapshotBuffer = new LinkedList(); private int snapCount; private boolean updateImageChannel = false; - private boolean updateAutoFps = false; private byte lowPriorityCounter = 0; public String hostIp; public Map channelTrackingMap = new ConcurrentHashMap<>(); @@ -180,9 +169,7 @@ public class IpCameraHandler extends BaseThingHandler { public boolean useDigestAuth = false; public String snapshotUri = ""; public String mjpegUri = ""; - private @Nullable ChannelFuture serverFuture = null; - private Object firstStreamedMsg = new Object(); - public byte[] currentSnapshot = new byte[] { (byte) 0x00 }; + private byte[] currentSnapshot = new byte[] { (byte) 0x00 }; public ReentrantLock lockCurrentSnapshot = new ReentrantLock(); public String rtspUri = ""; public boolean audioAlarmUpdateSnapshot = false; @@ -192,9 +179,7 @@ public class IpCameraHandler extends BaseThingHandler { private boolean firstMotionAlarm = false; public BigDecimal motionThreshold = BigDecimal.ZERO; public int audioThreshold = 35; - @SuppressWarnings("unused") - private @Nullable StreamServerHandler streamServerHandler; - private boolean streamingSnapshotMjpeg = false; + public boolean streamingSnapshotMjpeg = false; public boolean motionAlarmEnabled = false; public boolean audioAlarmEnabled = false; public boolean ffmpegSnapshotGeneration = false; @@ -254,9 +239,11 @@ public void channelRead(@Nullable ChannelHandlerContext ctx, @Nullable Object ms if (mjpegUri.equals(requestUrl)) { if (msg instanceof HttpMessage) { // very start of stream only - ReferenceCountUtil.retain(msg, 1); - firstStreamedMsg = msg; - streamToGroup(firstStreamedMsg, mjpegChannelGroup, true); + mjpegContentType = contentType; + CameraServlet localServlet = servlet; + if (localServlet != null) { + localServlet.openStreams.updateContentType(contentType); + } } } else { boundary = Helper.searchString(contentType, "boundary="); @@ -274,8 +261,13 @@ public void channelRead(@Nullable ChannelHandlerContext ctx, @Nullable Object ms if (msg instanceof HttpContent) { if (mjpegUri.equals(requestUrl)) { // multiple MJPEG stream packets come back as this. - ReferenceCountUtil.retain(msg, 1); - streamToGroup(msg, mjpegChannelGroup, true); + HttpContent content = (HttpContent) msg; + byte[] chunkedFrame = new byte[content.content().readableBytes()]; + content.content().getBytes(content.content().readerIndex(), chunkedFrame); + CameraServlet localServlet = servlet; + if (localServlet != null) { + localServlet.openStreams.queueFrame(chunkedFrame); + } } else { HttpContent content = (HttpContent) msg; // Found some cameras use Content-Type: image/jpg instead of image/jpeg @@ -421,7 +413,7 @@ public void userEventTriggered(@Nullable ChannelHandlerContext ctx, @Nullable Ob } public IpCameraHandler(Thing thing, @Nullable String ipAddress, GroupTracker groupTracker, - IpCameraDynamicStateDescriptionProvider stateDescriptionProvider) { + IpCameraDynamicStateDescriptionProvider stateDescriptionProvider, HttpService httpService) { super(thing); this.stateDescriptionProvider = stateDescriptionProvider; if (ipAddress != null) { @@ -430,6 +422,7 @@ public IpCameraHandler(Thing thing, @Nullable String ipAddress, GroupTracker gro hostIp = Helper.getLocalIpAddress(); } this.groupTracker = groupTracker; + this.httpService = httpService; } private IpCameraHandler getHandle() { @@ -520,6 +513,20 @@ public String getTinyUrl(String httpRequestURL) { return httpRequestURL; } + private void checkCameraConnection() { + Bootstrap localBootstrap = mainBootstrap; + if (localBootstrap != null) { + ChannelFuture chFuture = localBootstrap + .connect(new InetSocketAddress(cameraConfig.getIp(), cameraConfig.getPort())); + if (chFuture.awaitUninterruptibly(500)) { + chFuture.channel().close(); + return; + } + } + cameraCommunicationError( + "Connection Timeout: Check your IP and PORT are correct and the camera can be reached."); + } + // Always use this as sendHttpGET(GET/POST/PUT/DELETE, "/foo/bar",null)// // The authHandler will generate a digest string and re-send using this same function when needed. @SuppressWarnings("null") @@ -657,19 +664,6 @@ public void processSnapshot(byte[] incommingSnapshot) { lockCurrentSnapshot.unlock(); } - if (streamingSnapshotMjpeg) { - sendMjpegFrame(incommingSnapshot, snapshotMjpegChannelGroup); - } - if (streamingAutoFps) { - if (motionDetected) { - sendMjpegFrame(incommingSnapshot, autoSnapshotMjpegChannelGroup); - } else if (updateAutoFps) { - // only happens every 8 seconds as some browsers need a frame that often to keep stream alive. - sendMjpegFrame(incommingSnapshot, autoSnapshotMjpegChannelGroup); - updateAutoFps = false; - } - } - if (updateImageChannel) { updateState(CHANNEL_IMAGE, new RawType(incommingSnapshot, "image/jpeg")); } else if (firstMotionAlarm || motionAlarmUpdateSnapshot) { @@ -681,132 +675,27 @@ public void processSnapshot(byte[] incommingSnapshot) { } } - public void stopStreamServer() { - serversLoopGroup.shutdownGracefully(); - serverBootstrap = null; - } - - @SuppressWarnings("null") public void startStreamServer() { - if (serverBootstrap == null) { - try { - serversLoopGroup = new NioEventLoopGroup(); - serverBootstrap = new ServerBootstrap(); - serverBootstrap.group(serversLoopGroup); - serverBootstrap.channel(NioServerSocketChannel.class); - // IP "0.0.0.0" will bind the server to all network connections// - serverBootstrap.localAddress(new InetSocketAddress("0.0.0.0", cameraConfig.getServerPort())); - serverBootstrap.childHandler(new ChannelInitializer() { - @Override - protected void initChannel(SocketChannel socketChannel) throws Exception { - socketChannel.pipeline().addLast("idleStateHandler", new IdleStateHandler(0, 60, 0)); - socketChannel.pipeline().addLast("HttpServerCodec", new HttpServerCodec()); - socketChannel.pipeline().addLast("ChunkedWriteHandler", new ChunkedWriteHandler()); - socketChannel.pipeline().addLast("streamServerHandler", new StreamServerHandler(getHandle())); - } - }); - serverFuture = serverBootstrap.bind().sync(); - serverFuture.await(4000); - logger.debug("File server for camera at {} has started on port {} for all NIC's.", cameraConfig.getIp(), - cameraConfig.getServerPort()); - updateState(CHANNEL_MJPEG_URL, - new StringType("http://" + hostIp + ":" + cameraConfig.getServerPort() + "/ipcamera.mjpeg")); - updateState(CHANNEL_HLS_URL, - new StringType("http://" + hostIp + ":" + cameraConfig.getServerPort() + "/ipcamera.m3u8")); - updateState(CHANNEL_IMAGE_URL, - new StringType("http://" + hostIp + ":" + cameraConfig.getServerPort() + "/ipcamera.jpg")); - } catch (Exception e) { - cameraConfigError("Exception when starting server. Try changing the Server Port to another number."); - } - if (thing.getThingTypeUID().getId().equals(INSTAR_THING)) { - logger.debug("Setting up the Alarm Server settings in the camera now"); - sendHttpGET( - "/param.cgi?cmd=setmdalarm&-aname=server2&-switch=on&-interval=1&cmd=setalarmserverattr&-as_index=3&-as_server=" - + hostIp + "&-as_port=" + cameraConfig.getServerPort() - + "&-as_path=/instar&-as_queryattr1=&-as_queryval1=&-as_queryattr2=&-as_queryval2=&-as_queryattr3=&-as_queryval3=&-as_activequery=1&-as_auth=0&-as_query1=0&-as_query2=0&-as_query3=0"); - } + if (servlet == null) { + servlet = new CameraServlet(this, httpService); } + + updateState(CHANNEL_HLS_URL, new StringType("http://" + hostIp + ":" + SERVLET_PORT + "/ipcamera/" + + getThing().getUID().getId() + "/ipcamera.m3u8")); + updateState(CHANNEL_IMAGE_URL, new StringType("http://" + hostIp + ":" + SERVLET_PORT + "/ipcamera/" + + getThing().getUID().getId() + "/ipcamera.jpg")); + updateState(CHANNEL_MJPEG_URL, new StringType("http://" + hostIp + ":" + SERVLET_PORT + "/ipcamera/" + + getThing().getUID().getId() + "/ipcamera.mjpeg")); } - public void setupSnapshotStreaming(boolean stream, ChannelHandlerContext ctx, boolean auto) { - if (stream) { - sendMjpegFirstPacket(ctx); - if (auto) { - autoSnapshotMjpegChannelGroup.add(ctx.channel()); - lockCurrentSnapshot.lock(); - try { - sendMjpegFrame(currentSnapshot, autoSnapshotMjpegChannelGroup); - // iOS uses a FIFO? and needs two frames to display a pic - sendMjpegFrame(currentSnapshot, autoSnapshotMjpegChannelGroup); - } finally { - lockCurrentSnapshot.unlock(); - } - streamingAutoFps = true; - } else { - snapshotMjpegChannelGroup.add(ctx.channel()); - lockCurrentSnapshot.lock(); - try { - sendMjpegFrame(currentSnapshot, snapshotMjpegChannelGroup); - } finally { - lockCurrentSnapshot.unlock(); - } - streamingSnapshotMjpeg = true; - startSnapshotPolling(); - } - } else { - snapshotMjpegChannelGroup.remove(ctx.channel()); - autoSnapshotMjpegChannelGroup.remove(ctx.channel()); - if (streamingSnapshotMjpeg && snapshotMjpegChannelGroup.isEmpty()) { - streamingSnapshotMjpeg = false; - stopSnapshotPolling(); - logger.debug("All snapshots.mjpeg streams have stopped."); - } else if (streamingAutoFps && autoSnapshotMjpegChannelGroup.isEmpty()) { - streamingAutoFps = false; - stopSnapshotPolling(); - logger.debug("All autofps.mjpeg streams have stopped."); - } - } + public void openCamerasStream() { + threadPool.schedule(this::openMjpegStream, 500, TimeUnit.MILLISECONDS); } private void openMjpegStream() { sendHttpGET(mjpegUri); } - // If start is true the CTX is added to the list to stream video to, false stops - // the stream. - public void setupMjpegStreaming(boolean start, ChannelHandlerContext ctx) { - if (start) { - if (mjpegChannelGroup.isEmpty()) {// first stream being requested. - mjpegChannelGroup.add(ctx.channel()); - if (mjpegUri.isEmpty() || "ffmpeg".equals(mjpegUri)) { - sendMjpegFirstPacket(ctx); - setupFfmpegFormat(FFmpegFormat.MJPEG); - } else {// Delay fixes Dahua reboots when refreshing a mjpeg stream. - threadPool.schedule(this::openMjpegStream, 500, TimeUnit.MILLISECONDS); - } - } else if (ffmpegMjpeg != null) {// not first stream and we will use ffmpeg - sendMjpegFirstPacket(ctx); - mjpegChannelGroup.add(ctx.channel()); - } else {// not first stream and camera supplies the mjpeg source. - ctx.channel().writeAndFlush(firstStreamedMsg); - mjpegChannelGroup.add(ctx.channel()); - } - } else { - mjpegChannelGroup.remove(ctx.channel()); - if (mjpegChannelGroup.isEmpty()) { - logger.debug("All ipcamera.mjpeg streams have stopped."); - if ("ffmpeg".equals(mjpegUri) || mjpegUri.isEmpty()) { - Ffmpeg localMjpeg = ffmpegMjpeg; - if (localMjpeg != null) { - localMjpeg.stopConverting(); - } - } else { - closeChannel(getTinyUrl(mjpegUri)); - } - } - } - } - void openChannel(Channel channel, String httpRequestURL) { ChannelTracking tracker = channelTrackingMap.get(httpRequestURL); if (tracker != null && !tracker.getReply().isEmpty()) {// We need to keep the stored reply @@ -816,7 +705,7 @@ void openChannel(Channel channel, String httpRequestURL) { channelTrackingMap.put(httpRequestURL, new ChannelTracking(channel, httpRequestURL)); } - void closeChannel(String url) { + public void closeChannel(String url) { ChannelTracking channelTracking = channelTrackingMap.get(url); if (channelTracking != null) { if (channelTracking.getChannel().isOpen()) { @@ -856,39 +745,6 @@ public void storeHttpReply(String url, String content) { } } - // sends direct to ctx so can be either snapshots.mjpeg or normal mjpeg stream - public void sendMjpegFirstPacket(ChannelHandlerContext ctx) { - final String boundary = "thisMjpegStream"; - String contentType = "multipart/x-mixed-replace; boundary=" + boundary; - 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.CLOSE); - response.headers().add("Access-Control-Allow-Origin", "*"); - response.headers().add("Access-Control-Expose-Headers", "*"); - ctx.channel().writeAndFlush(response); - } - - public void sendMjpegFrame(byte[] jpg, ChannelGroup channelGroup) { - final String boundary = "thisMjpegStream"; - ByteBuf imageByteBuf = Unpooled.copiedBuffer(jpg); - int length = imageByteBuf.readableBytes(); - String header = "--" + boundary + "\r\n" + "content-type: image/jpeg" + "\r\n" + "content-length: " + length - + "\r\n\r\n"; - ByteBuf headerBbuf = Unpooled.copiedBuffer(header, 0, header.length(), StandardCharsets.UTF_8); - ByteBuf footerBbuf = Unpooled.copiedBuffer("\r\n", 0, 2, StandardCharsets.UTF_8); - streamToGroup(headerBbuf, channelGroup, false); - streamToGroup(imageByteBuf, channelGroup, false); - streamToGroup(footerBbuf, channelGroup, true); - } - - public void streamToGroup(Object msg, ChannelGroup channelGroup, boolean flush) { - channelGroup.write(msg); - if (flush) { - channelGroup.flush(); - } - } - private void storeSnapshots() { int count = 0; // Need to lock as fifoSnapshotBuffer is not thread safe and new snapshots can be incoming. @@ -1060,8 +916,8 @@ public void setupFfmpegFormat(FFmpegFormat format) { inputOptions += " -hide_banner -loglevel warning"; } ffmpegMjpeg = new Ffmpeg(this, format, cameraConfig.getFfmpegLocation(), inputOptions, rtspUri, - cameraConfig.getMjpegOptions(), - "http://127.0.0.1:" + cameraConfig.getServerPort() + "/ipcamera.jpg", + cameraConfig.getMjpegOptions(), "http://127.0.0.1:" + SERVLET_PORT + "/ipcamera/" + + getThing().getUID().getId() + "/ipcamera.jpg", cameraConfig.getUser(), cameraConfig.getPassword()); } Ffmpeg localMjpeg = ffmpegMjpeg; @@ -1079,8 +935,8 @@ public void setupFfmpegFormat(FFmpegFormat format) { inputOptions += " -threads 1 -skip_frame nokey -hide_banner -loglevel warning"; } ffmpegSnapshot = new Ffmpeg(this, format, cameraConfig.getFfmpegLocation(), inputOptions, rtspUri, - cameraConfig.getSnapshotOptions(), - "http://127.0.0.1:" + cameraConfig.getServerPort() + "/snapshot.jpg", + cameraConfig.getSnapshotOptions(), "http://127.0.0.1:" + SERVLET_PORT + "/ipcamera/" + + getThing().getUID().getId() + "/snapshot.jpg", cameraConfig.getUser(), cameraConfig.getPassword()); } Ffmpeg localSnaps = ffmpegSnapshot; @@ -1196,21 +1052,19 @@ private void sendPTZRequest() { @Override public void channelLinked(ChannelUID channelUID) { - if (cameraConfig.getServerPort() > 0) { - switch (channelUID.getId()) { - case CHANNEL_MJPEG_URL: - updateState(CHANNEL_MJPEG_URL, new StringType( - "http://" + hostIp + ":" + cameraConfig.getServerPort() + "/ipcamera.mjpeg")); - break; - case CHANNEL_HLS_URL: - updateState(CHANNEL_HLS_URL, - new StringType("http://" + hostIp + ":" + cameraConfig.getServerPort() + "/ipcamera.m3u8")); - break; - case CHANNEL_IMAGE_URL: - updateState(CHANNEL_IMAGE_URL, - new StringType("http://" + hostIp + ":" + cameraConfig.getServerPort() + "/ipcamera.jpg")); - break; - } + switch (channelUID.getId()) { + case CHANNEL_MJPEG_URL: + updateState(CHANNEL_MJPEG_URL, new StringType("http://" + hostIp + ":" + SERVLET_PORT + "/ipcamera/" + + getThing().getUID().getId() + "/ipcamera.mjpeg")); + break; + case CHANNEL_HLS_URL: + updateState(CHANNEL_HLS_URL, new StringType("http://" + hostIp + ":" + SERVLET_PORT + "/ipcamera/" + + getThing().getUID().getId() + "/ipcamera.m3u8")); + break; + case CHANNEL_IMAGE_URL: + updateState(CHANNEL_IMAGE_URL, new StringType("http://" + hostIp + ":" + SERVLET_PORT + "/ipcamera/" + + getThing().getUID().getId() + "/ipcamera.jpg")); + break; } } @@ -1464,7 +1318,14 @@ private void bringCameraOnline() { if (localFuture != null) { localFuture.cancel(false); } - + if (thing.getThingTypeUID().getId().equals(INSTAR_THING)) { + logger.debug("Setting up the Alarm Server settings in the camera now"); + sendHttpGET( + "/param.cgi?cmd=setmdalarm&-aname=server2&-switch=on&-interval=1&cmd=setalarmserverattr&-as_index=3&-as_server=" + + hostIp + "&-as_port=" + SERVLET_PORT + "&-as_path=/ipcamera/" + + getThing().getUID().getId() + + "/instar&-as_queryattr1=&-as_queryval1=&-as_queryattr2=&-as_queryval2=&-as_queryattr3=&-as_queryval3=&-as_activequery=1&-as_auth=0&-as_query1=0&-as_query2=0&-as_query3=0"); + } if (cameraConfig.getGifPreroll() > 0 || cameraConfig.getUpdateImageWhen().contains("1")) { snapshotPolling = true; snapshotJob = threadPool.scheduleWithFixedDelay(this::snapshotRunnable, 1000, cameraConfig.getPollTime(), @@ -1566,6 +1427,18 @@ void snapshotRunnable() { } } + public byte[] getSnapshot() { + if (!snapshotPolling && !ffmpegSnapshotGeneration) { + sendHttpGET(snapshotUri); + } + lockCurrentSnapshot.lock(); + try { + return currentSnapshot; + } finally { + lockCurrentSnapshot.unlock(); + } + } + public void stopSnapshotPolling() { Future localFuture; if (!streamingSnapshotMjpeg && cameraConfig.getGifPreroll() == 0 @@ -1607,13 +1480,12 @@ public void startSnapshotPolling() { void pollCameraRunnable() { // Snapshot should be first to keep consistent time between shots if (streamingAutoFps) { - updateAutoFps = true; if (!snapshotPolling && !ffmpegSnapshotGeneration) { // Dont need to poll if creating from RTSP stream with FFmpeg or we are polling at full rate already. sendHttpGET(snapshotUri); } } else if (!snapshotUri.isEmpty() && !snapshotPolling) {// we need to check camera is still online. - sendHttpGET(snapshotUri); + checkCameraConnection(); } // NOTE: Use lowPriorityRequests if get request is not needed every poll. if (!lowPriorityRequests.isEmpty()) { @@ -1688,14 +1560,6 @@ public void initialize() { cameraConfig .setFfmpegOutput(OpenHAB.getUserDataFolder() + "/ipcamera/" + this.thing.getUID().getId() + "/"); } - - if (cameraConfig.getServerPort() < 1) { - logger.warn( - "The Server Port is not set to a valid number which disables a lot of binding features. See readme for more info."); - } else if (cameraConfig.getServerPort() < 1025) { - logger.warn("The Server Port is <= 1024 and may cause permission errors under Linux, try a higher number."); - } - // Known cameras will connect quicker if we skip ONVIF questions. switch (thing.getThingTypeUID().getId()) { case AMCREST_THING: @@ -1745,11 +1609,8 @@ public void initialize() { } break; } - - // Onvif and Instar event handling needs the host IP and the server started. - if (cameraConfig.getServerPort() > 0) { - startStreamServer(); - } + // Onvif and Instar event handling need the host IP and the server started. + startStreamServer(); if (!thing.getThingTypeUID().getId().equals(GENERIC_THING)) { onvifCamera = new OnvifConnection(this, cameraConfig.getIp() + ":" + cameraConfig.getOnvifPort(), @@ -1801,7 +1662,6 @@ public void dispose() { } basicAuth = ""; // clear out stored Password hash useDigestAuth = false; - stopStreamServer(); openChannels.close(); Ffmpeg localFfmpeg = ffmpegHLS; @@ -1833,10 +1693,6 @@ public void dispose() { onvifCamera.disconnect(); } - public void setStreamServerHandler(StreamServerHandler streamServerHandler2) { - streamServerHandler = streamServerHandler2; - } - public String getWhiteList() { return cameraConfig.getIpWhitelist(); } diff --git a/bundles/org.openhab.binding.ipcamera/src/main/java/org/openhab/binding/ipcamera/internal/onvif/OnvifConnection.java b/bundles/org.openhab.binding.ipcamera/src/main/java/org/openhab/binding/ipcamera/internal/onvif/OnvifConnection.java index 6e03c9d4e4efb..be4f96e75ae37 100644 --- a/bundles/org.openhab.binding.ipcamera/src/main/java/org/openhab/binding/ipcamera/internal/onvif/OnvifConnection.java +++ b/bundles/org.openhab.binding.ipcamera/src/main/java/org/openhab/binding/ipcamera/internal/onvif/OnvifConnection.java @@ -231,7 +231,8 @@ private String getXml(RequestType requestType) { return ""; case Subscribe: return "
http://" - + ipCameraHandler.hostIp + ":" + ipCameraHandler.cameraConfig.getServerPort() + + ipCameraHandler.hostIp + ":" + SERVLET_PORT + "/ipcamera/" + + ipCameraHandler.getThing().getUID().getId() + "/OnvifEvent
"; case Unsubscribe: return ""; @@ -849,7 +850,6 @@ private void cleanup() { logger.warn("ONVIF was not cleanly shutdown, due to being interrupted"); } finally { logger.debug("Eventloop is shutdown:{}", mainEventLoopGroup.isShutdown()); - mainEventLoopGroup = new NioEventLoopGroup(); bootstrap = null; } } diff --git a/bundles/org.openhab.binding.ipcamera/src/main/java/org/openhab/binding/ipcamera/internal/servlet/CameraServlet.java b/bundles/org.openhab.binding.ipcamera/src/main/java/org/openhab/binding/ipcamera/internal/servlet/CameraServlet.java new file mode 100644 index 0000000000000..12ca6b259e899 --- /dev/null +++ b/bundles/org.openhab.binding.ipcamera/src/main/java/org/openhab/binding/ipcamera/internal/servlet/CameraServlet.java @@ -0,0 +1,242 @@ +/** + * Copyright (c) 2010-2021 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.servlet; + +import static org.openhab.binding.ipcamera.internal.IpCameraBindingConstants.HLS_STARTUP_DELAY_MS; + +import java.io.IOException; + +import javax.servlet.ServletInputStream; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.ipcamera.internal.Ffmpeg; +import org.openhab.binding.ipcamera.internal.InstarHandler; +import org.openhab.binding.ipcamera.internal.IpCameraBindingConstants.FFmpegFormat; +import org.openhab.binding.ipcamera.internal.handler.IpCameraHandler; +import org.osgi.service.http.HttpService; + +/** + * The {@link CameraServlet} is responsible for serving files for a single camera back to the Jetty server normally + * found on port 8080 + * + * @author Matthew Skinner - Initial contribution + */ +@NonNullByDefault +public class CameraServlet extends IpCameraServlet { + private static final long serialVersionUID = -134658667574L; + private final IpCameraHandler handler; + private int autofpsStreamsOpen = 0; + private int snapshotStreamsOpen = 0; + public OpenStreams openStreams = new OpenStreams(); + + public CameraServlet(IpCameraHandler handler, HttpService httpService) { + super(handler, httpService); + this.handler = handler; + } + + @Override + protected void doPost(@Nullable HttpServletRequest req, @Nullable HttpServletResponse resp) throws IOException { + if (req == null || resp == null) { + return; + } + String pathInfo = req.getPathInfo(); + if (pathInfo == null) { + return; + } + switch (pathInfo) { + case "/ipcamera.jpg": + // ffmpeg sends data here for ipcamera.mjpeg streams when camera has no native stream. + ServletInputStream snapshotData = req.getInputStream(); + openStreams.queueFrame(snapshotData.readAllBytes()); + snapshotData.close(); + break; + case "/snapshot.jpg": + snapshotData = req.getInputStream(); + handler.processSnapshot(snapshotData.readAllBytes()); + snapshotData.close(); + break; + case "/OnvifEvent": + handler.onvifCamera.eventRecieved(req.getReader().toString()); + break; + default: + logger.debug("Recieved unknown request \tPOST:{}", pathInfo); + break; + } + } + + @Override + protected void doGet(@Nullable HttpServletRequest req, @Nullable HttpServletResponse resp) throws IOException { + if (req == null || resp == null) { + return; + } + String pathInfo = req.getPathInfo(); + if (pathInfo == null) { + return; + } + logger.debug("GET:{}, received from {}", pathInfo, req.getRemoteHost()); + if (!"DISABLE".equals(handler.getWhiteList())) { + String requestIP = "(" + req.getRemoteHost() + ")"; + if (!handler.getWhiteList().contains(requestIP)) { + logger.warn("The request made from {} was not in the whiteList and will be ignored.", requestIP); + return; + } + } + switch (pathInfo) { + case "/ipcamera.m3u8": + Ffmpeg localFfmpeg = handler.ffmpegHLS; + if (localFfmpeg == null) { + handler.setupFfmpegFormat(FFmpegFormat.HLS); + } else if (!localFfmpeg.getIsAlive()) { + localFfmpeg.startConverting(); + } else { + localFfmpeg.setKeepAlive(8); + sendFile(resp, pathInfo, "application/x-mpegURL"); + return; + } + // Allow files to be created, or you get old m3u8 from the last time this ran. + try { + Thread.sleep(HLS_STARTUP_DELAY_MS); + } catch (InterruptedException e) { + return; + } + sendFile(resp, pathInfo, "application/x-mpegURL"); + return; + case "/ipcamera.mpd": + sendFile(resp, pathInfo, "application/dash+xml"); + return; + case "/ipcamera.gif": + sendFile(resp, pathInfo, "image/gif"); + return; + case "/ipcamera.jpg": + sendSnapshotImage(resp, "image/jpg", handler.getSnapshot()); + return; + case "/snapshots.mjpeg": + req.getSession().setMaxInactiveInterval(0); + snapshotStreamsOpen++; + handler.streamingSnapshotMjpeg = true; + handler.startSnapshotPolling(); + StreamOutput output = new StreamOutput(resp); + do { + try { + output.sendSnapshotBasedFrame(handler.getSnapshot()); + Thread.sleep(1005); + } catch (InterruptedException | IOException e) { + // Never stop streaming until IOException. Occurs when browser stops the stream. + snapshotStreamsOpen--; + if (snapshotStreamsOpen == 0) { + handler.streamingSnapshotMjpeg = false; + handler.stopSnapshotPolling(); + logger.debug("All snapshots.mjpeg streams have stopped."); + } + return; + } + } while (true); + case "/ipcamera.mjpeg": + req.getSession().setMaxInactiveInterval(0); + if (handler.mjpegUri.isEmpty() || "ffmpeg".equals(handler.mjpegUri)) { + if (openStreams.isEmpty()) { + handler.setupFfmpegFormat(FFmpegFormat.MJPEG); + } + output = new StreamOutput(resp); + openStreams.addStream(output); + } else if (openStreams.isEmpty()) { + logger.debug("First stream requested, opening up stream from camera"); + handler.openCamerasStream(); + output = new StreamOutput(resp, handler.mjpegContentType); + openStreams.addStream(output); + } else { + logger.debug("Not the first stream requested. Stream from camera already open"); + output = new StreamOutput(resp, handler.mjpegContentType); + openStreams.addStream(output); + } + do { + try { + output.sendFrame(); + } catch (InterruptedException | IOException e) { + // Never stop streaming until IOException. Occurs when browser stops the stream. + openStreams.removeStream(output); + if (openStreams.isEmpty()) { + if (output.isSnapshotBased) { + Ffmpeg localMjpeg = handler.ffmpegMjpeg; + if (localMjpeg != null) { + localMjpeg.stopConverting(); + } + } else { + handler.closeChannel(handler.getTinyUrl(handler.mjpegUri)); + } + logger.debug("All ipcamera.mjpeg streams have stopped."); + } + return; + } + } while (true); + case "/autofps.mjpeg": + req.getSession().setMaxInactiveInterval(0); + autofpsStreamsOpen++; + handler.streamingAutoFps = true; + output = new StreamOutput(resp); + int counter = 0; + do { + try { + if (handler.motionDetected) { + output.sendSnapshotBasedFrame(handler.getSnapshot()); + } // every 8 seconds if no motion or the first three snapshots to fill any FIFO + else if (counter % 8 == 0 || counter < 3) { + output.sendSnapshotBasedFrame(handler.getSnapshot()); + } + counter++; + Thread.sleep(1000); + } catch (InterruptedException | IOException e) { + // Never stop streaming until IOException. Occurs when browser stops the stream. + autofpsStreamsOpen--; + if (autofpsStreamsOpen == 0) { + handler.streamingAutoFps = false; + logger.debug("All autofps.mjpeg streams have stopped."); + } + return; + } + } while (true); + case "/instar": + InstarHandler instar = new InstarHandler(handler); + instar.alarmTriggered(pathInfo + "?" + req.getQueryString()); + return; + default: + if (pathInfo.endsWith(".ts")) { + sendFile(resp, pathInfo, "video/MP2T"); + } else if (pathInfo.endsWith(".gif")) { + sendFile(resp, pathInfo, "image/gif"); + } else if (pathInfo.endsWith(".jpg")) { + // Allow access to the preroll and postroll jpg files + sendFile(resp, pathInfo, "image/jpg"); + } else if (pathInfo.endsWith(".mp4")) { + sendFile(resp, pathInfo, "video/mp4"); + } + return; + } + } + + @Override + protected void sendFile(HttpServletResponse response, String filename, String contentType) throws IOException { + // Ensure no files can be sourced from parent or child folders + String truncated = filename.substring(filename.lastIndexOf("/")); + super.sendFile(response, handler.cameraConfig.getFfmpegOutput() + truncated, contentType); + } + + @Override + public void dispose() { + openStreams.closeAllStreams(); + super.dispose(); + } +} diff --git a/bundles/org.openhab.binding.ipcamera/src/main/java/org/openhab/binding/ipcamera/internal/servlet/GroupServlet.java b/bundles/org.openhab.binding.ipcamera/src/main/java/org/openhab/binding/ipcamera/internal/servlet/GroupServlet.java new file mode 100644 index 0000000000000..8cc8456fb9919 --- /dev/null +++ b/bundles/org.openhab.binding.ipcamera/src/main/java/org/openhab/binding/ipcamera/internal/servlet/GroupServlet.java @@ -0,0 +1,142 @@ +/** + * Copyright (c) 2010-2021 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.servlet; + +import static org.openhab.binding.ipcamera.internal.IpCameraBindingConstants.*; + +import java.io.File; +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.ipcamera.internal.handler.IpCameraGroupHandler; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.thing.ChannelUID; +import org.osgi.service.http.HttpService; + +/** + * The {@link GroupServlet} is responsible for serving files for a rotating feed of multiple cameras back to the Jetty + * server normally found on port 8080 + * + * @author Matthew Skinner - Initial contribution + */ +@NonNullByDefault +public class GroupServlet extends IpCameraServlet { + private static final long serialVersionUID = -234658667574L; + private final IpCameraGroupHandler handler; + public int snapshotStreamsOpen = 0; + + public GroupServlet(IpCameraGroupHandler handler, HttpService httpService) { + super(handler, httpService); + this.handler = handler; + } + + @Override + protected void doGet(@Nullable HttpServletRequest req, @Nullable HttpServletResponse resp) throws IOException { + if (req == null || resp == null) { + return; + } + String pathInfo = req.getPathInfo(); + if (pathInfo == null) { + return; + } + logger.debug("GET:{}, received from {}", pathInfo, req.getRemoteHost()); + if (!"DISABLE".equals(handler.groupConfig.getIpWhitelist())) { + String requestIP = "(" + req.getRemoteHost() + ")"; + if (!handler.groupConfig.getIpWhitelist().contains(requestIP)) { + logger.warn("The request made from {} was not in the whiteList and will be ignored.", requestIP); + return; + } + } + switch (pathInfo) { + case "/ipcamera.m3u8": + if (!handler.hlsTurnedOn) { + logger.debug( + "HLS requires the groups startStream channel to be turned on first. Just starting it now."); + String channelPrefix = "ipcamera:" + handler.getThing().getThingTypeUID() + ":" + + handler.getThing().getUID().getId() + ":"; + handler.handleCommand(new ChannelUID(channelPrefix + CHANNEL_START_STREAM), OnOffType.ON); + try { + TimeUnit.MILLISECONDS.sleep(HLS_STARTUP_DELAY_MS); + } catch (InterruptedException e) { + return; + } + } + String playList = handler.getPlayList(); + sendString(resp, playList, "application/x-mpegURL"); + return; + case "/ipcamera.jpg": + sendSnapshotImage(resp, "image/jpg", handler.getSnapshot()); + return; + case "/ipcamera.mjpeg": + case "/snapshots.mjpeg": + req.getSession().setMaxInactiveInterval(0); + snapshotStreamsOpen++; + StreamOutput output = new StreamOutput(resp); + do { + try { + output.sendSnapshotBasedFrame(handler.getSnapshot()); + Thread.sleep(1005); + } catch (InterruptedException | IOException e) { + // Never stop streaming until IOException. Occurs when browser stops the stream. + snapshotStreamsOpen--; + if (snapshotStreamsOpen == 0) { + logger.debug("All snapshots.mjpeg streams have stopped."); + } + return; + } + } while (true); + default: + // example is "/1ipcameraxx.ts" + if (pathInfo.endsWith(".ts")) { + sendFile(resp, pathInfo, "video/MP2T"); + } + } + } + + private String resolveIndexToPath(String uri) { + if (!"i".equals(uri.substring(1, 2))) { + return handler.getOutputFolder(Integer.parseInt(uri.substring(1, 2))); + } + return "notFound"; + } + + @Override + protected void sendFile(HttpServletResponse response, String filename, String contentType) throws IOException { + // Ensure no files can be sourced from parent or child folders + String truncated = filename.substring(filename.lastIndexOf("/")); + truncated = resolveIndexToPath(truncated) + truncated.substring(2); + File file = new File(truncated); + if (!file.exists()) { + logger.warn( + "HLS File {} was not found. Try adding a larger -hls_delete_threshold to each cameras HLS out options.", + file.getName()); + response.sendError(HttpServletResponse.SC_NOT_FOUND); + return; + } + super.sendFile(response, truncated, contentType); + } + + @Override + protected void sendSnapshotImage(HttpServletResponse response, String contentType, byte[] snapshot) { + if (handler.cameraIndex >= handler.cameraOrder.size()) { + logger.debug("All cameras in this group are OFFLINE and a snapshot was requested."); + return; + } + super.sendSnapshotImage(response, contentType, snapshot); + } +} diff --git a/bundles/org.openhab.binding.ipcamera/src/main/java/org/openhab/binding/ipcamera/internal/servlet/IpCameraServlet.java b/bundles/org.openhab.binding.ipcamera/src/main/java/org/openhab/binding/ipcamera/internal/servlet/IpCameraServlet.java new file mode 100644 index 0000000000000..9ce0efdf62206 --- /dev/null +++ b/bundles/org.openhab.binding.ipcamera/src/main/java/org/openhab/binding/ipcamera/internal/servlet/IpCameraServlet.java @@ -0,0 +1,134 @@ +/** + * Copyright (c) 2010-2021 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.servlet; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; + +import javax.servlet.ServletException; +import javax.servlet.ServletOutputStream; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletResponse; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.thing.binding.ThingHandler; +import org.osgi.service.http.HttpService; +import org.osgi.service.http.NamespaceException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link IpCameraServlet} is responsible for serving files to the Jetty + * server normally found on port 8080 + * + * @author Matthew Skinner - Initial contribution + */ +@NonNullByDefault +public abstract class IpCameraServlet extends HttpServlet { + protected final Logger logger = LoggerFactory.getLogger(this.getClass()); + private static final long serialVersionUID = 1L; + protected final ThingHandler handler; + protected final HttpService httpService; + + public IpCameraServlet(ThingHandler handler, HttpService httpService) { + this.handler = handler; + this.httpService = httpService; + startListening(); + } + + public void startListening() { + try { + httpService.registerServlet("/ipcamera/" + handler.getThing().getUID().getId(), this, null, + httpService.createDefaultHttpContext()); + } catch (NamespaceException | ServletException e) { + logger.warn("Registering servlet failed:{}", e.getMessage()); + } + } + + protected void sendSnapshotImage(HttpServletResponse response, String contentType, byte[] snapshot) { + response.setHeader("Access-Control-Allow-Origin", "*"); + response.setHeader("Access-Control-Expose-Headers", "*"); + response.setContentType(contentType); + if (snapshot.length == 1) { + logger.warn("ipcamera.jpg was requested but there was no jpg in ram to send."); + return; + } + try { + response.setContentLength(snapshot.length); + ServletOutputStream servletOut = response.getOutputStream(); + servletOut.write(snapshot); + } catch (IOException e) { + } + } + + protected void sendString(HttpServletResponse response, String contents, String contentType) { + response.setHeader("Access-Control-Allow-Origin", "*"); + response.setHeader("Access-Control-Expose-Headers", "*"); + response.setContentType(contentType); + response.setHeader("Pragma", "no-cache"); + response.setHeader("Cache-Control", "max-age=0, no-cache, no-store"); + byte[] bytes = contents.getBytes(); + try { + response.setContentLength(bytes.length); + ServletOutputStream servletOut = response.getOutputStream(); + servletOut.write(bytes); + servletOut.write("\r\n".getBytes()); + } catch (IOException e) { + } + } + + protected void sendFile(HttpServletResponse response, String filename, String contentType) throws IOException { + File file = new File(filename); + if (!file.exists()) { + response.sendError(HttpServletResponse.SC_NOT_FOUND); + return; + } + response.setBufferSize((int) file.length()); + response.setContentType(contentType); + response.setHeader("Access-Control-Allow-Origin", "*"); + response.setHeader("Access-Control-Expose-Headers", "*"); + response.setHeader("Content-Length", String.valueOf(file.length())); + response.setHeader("Pragma", "no-cache"); + response.setHeader("Cache-Control", "max-age=0, no-cache, no-store"); + BufferedInputStream input = null; + BufferedOutputStream output = null; + try { + input = new BufferedInputStream(new FileInputStream(file), (int) file.length()); + output = new BufferedOutputStream(response.getOutputStream(), (int) file.length()); + byte[] buffer = new byte[(int) file.length()]; + int length; + while ((length = input.read(buffer)) > 0) { + output.write(buffer, 0, length); + } + } finally { + if (output != null) { + output.close(); + } + if (input != null) { + input.close(); + } + } + } + + public void dispose() { + try { + httpService.unregister("/ipcamera/" + handler.getThing().getUID().getId()); + this.destroy(); + } catch (IllegalArgumentException e) { + logger.warn("Unregistration of servlet failed:{}", e.getMessage()); + } + } +} diff --git a/bundles/org.openhab.binding.ipcamera/src/main/java/org/openhab/binding/ipcamera/internal/servlet/OpenStreams.java b/bundles/org.openhab.binding.ipcamera/src/main/java/org/openhab/binding/ipcamera/internal/servlet/OpenStreams.java new file mode 100644 index 0000000000000..b3f4ff9d14a4a --- /dev/null +++ b/bundles/org.openhab.binding.ipcamera/src/main/java/org/openhab/binding/ipcamera/internal/servlet/OpenStreams.java @@ -0,0 +1,67 @@ +/** + * Copyright (c) 2010-2021 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.servlet; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link OpenStreams} Keeps track of all open mjpeg streams so the byte[] can be given to all FIFO buffers to allow + * 1 to many streams without needing to open more than 1 source stream. + * + * + * @author Matthew Skinner - Initial contribution + */ + +@NonNullByDefault +public class OpenStreams { + private List openStreams = Collections.synchronizedList(new ArrayList()); + + public synchronized void addStream(StreamOutput stream) { + openStreams.add(stream); + } + + public synchronized void removeStream(StreamOutput stream) { + openStreams.remove(stream); + } + + public synchronized int getNumberOfStreams() { + return openStreams.size(); + } + + public synchronized boolean isEmpty() { + return openStreams.isEmpty(); + } + + public synchronized void updateContentType(String contentType) { + for (StreamOutput stream : openStreams) { + stream.updateContentType(contentType); + } + } + + public synchronized void queueFrame(byte[] frame) { + for (StreamOutput stream : openStreams) { + stream.queueFrame(frame); + } + } + + public synchronized void closeAllStreams() { + for (StreamOutput stream : openStreams) { + stream.close(); + } + openStreams.clear(); + } +} diff --git a/bundles/org.openhab.binding.ipcamera/src/main/java/org/openhab/binding/ipcamera/internal/servlet/StreamOutput.java b/bundles/org.openhab.binding.ipcamera/src/main/java/org/openhab/binding/ipcamera/internal/servlet/StreamOutput.java new file mode 100644 index 0000000000000..c2a30bd9865e6 --- /dev/null +++ b/bundles/org.openhab.binding.ipcamera/src/main/java/org/openhab/binding/ipcamera/internal/servlet/StreamOutput.java @@ -0,0 +1,107 @@ +/** + * Copyright (c) 2010-2021 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.servlet; + +import java.io.IOException; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.BlockingQueue; + +import javax.servlet.ServletOutputStream; +import javax.servlet.http.HttpServletResponse; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link StreamOutput} Streams mjpeg out to a client + * + * @author Matthew Skinner - Initial contribution + */ + +@NonNullByDefault +public class StreamOutput { + private final HttpServletResponse response; + private final String boundary; + private String contentType; + private final ServletOutputStream output; + private BlockingQueue fifo = new ArrayBlockingQueue(6); + private boolean connected = false; + public boolean isSnapshotBased = false; + + public StreamOutput(HttpServletResponse response) throws IOException { + boundary = "thisMjpegStream"; + contentType = "multipart/x-mixed-replace; boundary=" + boundary; + this.response = response; + output = response.getOutputStream(); + isSnapshotBased = true; + } + + public StreamOutput(HttpServletResponse response, String contentType) throws IOException { + boundary = ""; + this.contentType = contentType; + this.response = response; + output = response.getOutputStream(); + if (!contentType.isEmpty()) { + sendInitialHeaders(); + connected = true; + } + } + + public void sendSnapshotBasedFrame(byte[] currentSnapshot) throws IOException { + String header = "--" + boundary + "\r\n" + "Content-Type: image/jpeg" + "\r\n" + "Content-Length: " + + currentSnapshot.length + "\r\n\r\n"; + if (!connected) { + sendInitialHeaders(); + // iOS needs to have two jpgs sent for the picture to appear instantly. + output.write(header.getBytes()); + output.write(currentSnapshot); + output.write("\r\n".getBytes()); + connected = true; + } + output.write(header.getBytes()); + output.write(currentSnapshot); + output.write("\r\n".getBytes()); + } + + public void queueFrame(byte[] frame) { + fifo.add(frame); + } + + public void updateContentType(String contentType) { + this.contentType = contentType; + if (!connected) { + sendInitialHeaders(); + connected = true; + } + } + + public void sendFrame() throws IOException, InterruptedException { + if (isSnapshotBased) { + sendSnapshotBasedFrame(fifo.take()); + } else if (connected) { + output.write(fifo.take()); + } + } + + private void sendInitialHeaders() { + response.setContentType(contentType); + response.setHeader("Access-Control-Allow-Origin", "*"); + response.setHeader("Access-Control-Expose-Headers", "*"); + } + + public void close() { + try { + output.close(); + } catch (IOException e) { + } + } +} diff --git a/bundles/org.openhab.binding.ipcamera/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.ipcamera/src/main/resources/OH-INF/thing/thing-types.xml index 576eeda0787db..81a64fedaefe7 100644 --- a/bundles/org.openhab.binding.ipcamera/src/main/resources/OH-INF/thing/thing-types.xml +++ b/bundles/org.openhab.binding.ipcamera/src/main/resources/OH-INF/thing/thing-types.xml @@ -82,13 +82,6 @@ true - - - The port that will serve any files back to openHAB without authentication. It must be unique and unused - for each camera. Setting the port to -1 will turn the feature off. - - - Enter any IP's inside (brackets) that you wish to allow to access the video stream. 'DISABLE' will @@ -291,13 +284,6 @@ true - - - The port that will serve any files back to openHAB without authentication. It must be unique for each - camera. - - - Enter any IP's inside (brackets) that you wish to allow to access the video stream. 'DISABLE' will @@ -558,13 +544,6 @@ 0 - - - The port that will serve any files back to openHAB without authentication. It must be unique and unused - for each camera. Setting the port to -1 will turn the feature off. - - - Enter any IP's inside (brackets) that you wish to allow to access the video stream. 'DISABLE' will @@ -772,13 +751,6 @@ true - - - The port that will serve any files back to openHAB without authentication. It must be unique and unused - for each camera. Setting the port to -1 will turn the feature off. - - - Enter any IP's inside (brackets) that you wish to allow to access the video stream. 'DISABLE' will @@ -1145,13 +1117,6 @@ - - - The port that will serve any files back to openHAB without authentication. It must be unique and unused - for each camera. Setting the port to -1 will turn the feature off. - - - Enter any IP's inside (brackets) that you wish to allow to access the video stream. 'DISABLE' will @@ -1344,13 +1309,6 @@ true - - - The port that will serve any files back to openHAB without authentication. It must be unique and unused - for each camera. Setting the port to -1 will turn the feature off. - - - Enter any IP's inside (brackets) that you wish to allow to access the video stream. 'DISABLE' will @@ -1615,13 +1573,6 @@ true - - - The port that will serve any files back to openHAB without authentication. It must be unique and unused - for each camera. Setting the port to -1 will turn the feature off. - - - Enter any IP's inside (brackets) that you wish to allow to access the video stream. 'DISABLE' will @@ -1911,13 +1862,6 @@ true - - - The port that will serve any files back to openHAB without authentication. It must be unique and unused - for each camera. Setting the port to -1 will turn the feature off. - - - Enter any IP's inside (brackets) that you wish to allow to access the video stream. 'DISABLE' will @@ -2194,13 +2138,6 @@ true - - - The port that will serve any files back to openHAB without authentication. It must be unique and unused - for each camera. Setting the port to -1 will turn the feature off. - - - Enter any IP's inside (brackets) that you wish to allow to access the video stream. 'DISABLE' will