package eu.hgross.blaubot.blaubotcam.server.service;
import org.glassfish.grizzly.CompletionHandler;
import org.glassfish.grizzly.GrizzlyFuture;
import org.glassfish.grizzly.http.server.HttpHandler;
import org.glassfish.grizzly.http.server.HttpServer;
import org.glassfish.grizzly.http.server.Request;
import org.glassfish.grizzly.http.server.Response;
import org.glassfish.grizzly.http.util.ContentType;
import org.glassfish.grizzly.http.util.HttpStatus;
import java.io.IOException;
import java.io.OutputStream;
import java.util.Map;
import java.util.Observable;
import java.util.Observer;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import eu.hgross.blaubot.core.IBlaubotDevice;
import eu.hgross.blaubot.core.LifecycleListenerAdapter;
import eu.hgross.blaubot.messaging.BlaubotMessage;
import eu.hgross.blaubot.messaging.IBlaubotMessageListener;
import eu.hgross.blaubot.util.Log;
/**
* A MessageListener for the BlaubotCam channel that uses a grizzly http server to server the received
* ImageMessages as IP-Cam.
* Each cam is served by the url
*
* @author Henning Gross {@literal (mail.to@henning-gross.de)}
*/
public class IPCamServer extends LifecycleListenerAdapter implements IBlaubotMessageListener {
private static final String LOG_TAG = "IPCamServer";
private static final long MAX_HTTP_SERVER_SHUTDOWN_TIME = 5000;
public static final String HTTP_HEADER_SERVER_FIELD = "BlaubotIpCamServer";
private HttpServer server;
/**
* The uri pattern used to serve a cam.
* {{encodedeDeviceId}} will be replaced with the actual unqiue device id from the ImageMessage.
*/
private static final String URI_PATTERN = "/cams/{{encodedDeviceId}}/video.mjpeg";
private static final String URI_PATTERN_PARAM_ID = "{{encodedDeviceId}}";
/**
* If this ending is appended to the URI_PATTERN, a single image will be served instead of a mjpeg stream
*/
private static final String URI_ENDING_SINGLE_IMAGE = "/poll";
/**
* Encapsualtes an id and the blocking logic of offering and polling ImageMessages.
* Designed for
*/
private static class CamDevice extends Observable {
private String deviceId;
private eu.hgross.blaubot.blaubotcam.server.model.ImageMessage lastImageMessage;
public CamDevice(String deviceId) {
this.deviceId = deviceId;
}
public String getDeviceId() {
return deviceId;
}
public eu.hgross.blaubot.blaubotcam.server.model.ImageMessage getLastImageMessage() {
return lastImageMessage;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
CamDevice camDevice = (CamDevice) o;
return !(deviceId != null ? !deviceId.equals(camDevice.deviceId) : camDevice.deviceId != null);
}
@Override
public int hashCode() {
return deviceId != null ? deviceId.hashCode() : 0;
}
/**
* Puts the latest ImageMessage for this device
*
* @param msg the message
*/
public void putImageMessage(eu.hgross.blaubot.blaubotcam.server.model.ImageMessage msg) {
lastImageMessage = msg;
setChanged();
notifyObservers();
}
}
/**
* A mapping from uniqueDeviceId -> CamDevice
*/
private ConcurrentHashMap<String, CamDevice> mCamDevices;
/**
* @param httpPort the http port to server on
*/
public IPCamServer(int httpPort) {
mCamDevices = new ConcurrentHashMap<>();
synchronized (serverMonitor) {
server = HttpServer.createSimpleServer(null, httpPort);
server.getServerConfiguration().addHttpHandler(new IndexHandler(mCamDevices));
}
}
@Override
public void onDeviceLeft(IBlaubotDevice blaubotDevice) {
// check if we have cam devices using this unique device id
for (CamDevice device : mCamDevices.values()) {
if (device.getLastImageMessage() != null && device.getLastImageMessage().getUniqueDeviceId().equals(blaubotDevice.getUniqueDeviceID())) {
mCamDevices.remove(blaubotDevice.getUniqueDeviceID());
}
}
}
@Override
public void onMessage(BlaubotMessage blaubotMessage) {
eu.hgross.blaubot.blaubotcam.server.model.ImageMessage imageMessage = new eu.hgross.blaubot.blaubotcam.server.model.ImageMessage(blaubotMessage.getPayload());
final String uniqueDeviceId = imageMessage.getUniqueDeviceId();
CamDevice camDevice = mCamDevices.get(uniqueDeviceId);
final boolean added;
if (camDevice == null) {
added = mCamDevices.putIfAbsent(uniqueDeviceId, new CamDevice(uniqueDeviceId)) == null;
} else {
added = false;
}
camDevice = mCamDevices.get(uniqueDeviceId);
camDevice.putImageMessage(imageMessage);
if (added) {
// add the handler to the server
HttpHandler httpHandler = new LiveVideoHttpHandler(camDevice);
final String encodedDeviceId = EncodingUtil.encodeURIComponent(camDevice.getDeviceId());
final String mjpegUri = URI_PATTERN.replace(URI_PATTERN_PARAM_ID, encodedDeviceId);
final String singleJpegUri = URI_PATTERN.replace(URI_PATTERN_PARAM_ID, encodedDeviceId) + URI_ENDING_SINGLE_IMAGE;
server.getServerConfiguration().addHttpHandler(httpHandler, mjpegUri, singleJpegUri);
}
}
/**
* Serves a mjpeg stream for a CamDevice
*/
public static class LiveVideoHttpHandler extends HttpHandler {
private static final String LOG_TAG = "IPCamServer.LiveVideoHttpHandler";
private static final String MJPEG_BOUNDARY = "--hgross";
private static final String MULTIPART_CONTENT_TYPE = "multipart/x-mixed-replace;boundary=" + MJPEG_BOUNDARY;
private static final long INPUT_DATA_WAIT_TIME = 1500;
private int MAX_WAIT_COUNT = 10; // MAX_WAIT_COUNT * INPUT_DATA_WAIT_TIME = time before connection gets closed
private final CamDevice camDevice;
public LiveVideoHttpHandler(CamDevice camDevice) {
this.camDevice = camDevice;
}
private StringBuffer createHeader(int contentLength) {
StringBuffer header = new StringBuffer(100);
header.append("\r\n\r\n");
header.append(MJPEG_BOUNDARY);
header.append("\r\nContent-Type: image/jpeg\r\nContent-Length: ");
header.append(contentLength);
header.append("\r\n\r\n");
return header;
}
public String getClientIpAddr(Request request) {
String ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("Proxy-Client-IP");
}
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("WL-Proxy-Client-IP");
}
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("HTTP_CLIENT_IP");
}
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("HTTP_X_FORWARDED_FOR");
}
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
return ip;
}
@Override
public void service(Request request, Response response) throws Exception {
// check wether we should serve a single image or a mjpeg stream
if (request.getRequestURI().endsWith(URI_ENDING_SINGLE_IMAGE)) {
serveSingleJpeg(request, response);
} else {
serveMjpegStream(request, response);
}
}
/**
* Serves the latest image (if any) once.
* @param request
* @param response
*/
private void serveSingleJpeg(Request request, Response response) throws IOException {
final eu.hgross.blaubot.blaubotcam.server.model.ImageMessage lastImageMessage = camDevice.getLastImageMessage();
response.setHeader("Server", HTTP_HEADER_SERVER_FIELD);
if (lastImageMessage == null) {
response.setStatus(HttpStatus.NO_CONTENT_204);
return;
}
response.setContentType(ContentType.newContentType("image/jpeg"));
response.getOutputStream().write(lastImageMessage.getJpegData());
}
/**
* Serves the stream in mjpeg format
* @param request
* @param response
*/
private void serveMjpegStream(Request request, Response response) throws IOException, InterruptedException {
String clientIp = getClientIpAddr(request);
if (Log.logDebugMessages()) {
Log.d(LOG_TAG, "Client " + clientIp + " connected to VideoStream.");
}
OutputStream os = response.getOutputStream();
response.setContentType(MULTIPART_CONTENT_TYPE);
response.setHeader("Server", "BlaubotIpCamServer");
response.setHeader("Connection", "Close");
os.write(MJPEG_BOUNDARY.getBytes());
int waitCount = 0;
long waitTimeUntilConnectionClose = MAX_WAIT_COUNT * INPUT_DATA_WAIT_TIME;
eu.hgross.blaubot.blaubotcam.server.model.ImageMessage lastServedImageMessage = null;
while (true) {
eu.hgross.blaubot.blaubotcam.server.model.ImageMessage toServe = camDevice.getLastImageMessage();
// initially we wait a specified amount for image data to arrive
if (toServe == null) {
waitCount++;
Thread.sleep(INPUT_DATA_WAIT_TIME);
if (Log.logWarningMessages()) {
Log.w(LOG_TAG, "No image data to serve ... waiting -- maybe streaming is not started?.");
}
if (waitCount == MAX_WAIT_COUNT) {
if (Log.logWarningMessages()) {
Log.w(LOG_TAG, "Waited " + waitCount * INPUT_DATA_WAIT_TIME + " milliseconds but did not get any new video data - disconnecting stream.");
}
break;
}
continue;
} else if (lastServedImageMessage == toServe) {
// -- same ImageMessage as served previously, don't send but create a countdown latch that is counted down on the arrival of the next new image
final CountDownLatch newImageLatch = new CountDownLatch(1);
Observer o = new Observer() {
@Override
public void update(Observable o, Object arg) {
newImageLatch.countDown();
camDevice.deleteObserver(this);
}
};
camDevice.addObserver(o);
newImageLatch.await(INPUT_DATA_WAIT_TIME, TimeUnit.MILLISECONDS);
continue;
}
// -- we got data at least once, send it to the client
byte[] data = toServe.getJpegData();
waitCount = 0;
os.write(createHeader(data.length).toString().getBytes());
os.write(data, 0, data.length);
os.write(MJPEG_BOUNDARY.getBytes());
os.flush();
lastServedImageMessage = toServe;
}
if (Log.logDebugMessages()) {
Log.d(LOG_TAG, "Client " + clientIp + " disconnected from VideoStream.");
}
}
}
/**
* Serves the index page listing all cams
*/
public static class IndexHandler extends HttpHandler {
private static final String LOG_TAG = "IPCamServer.IndexHandler";
private final Map<String, CamDevice> deviceMap;
private static final String INDEX_PAGE_TMPL = "" +
"<html>" +
" <h1>BlaubotCam IPCam-Interface</h1>" +
" <p>Use the urls below with any mjpeg compatible viewer (VLC, Firefox, ...).</p>" +
" {{deviceList}}" +
"</html>";
private static final String REPLACE_DEVICE_LIST = "{{deviceList}}";
public IndexHandler(Map<String, CamDevice> deviceMap) {
this.deviceMap = deviceMap;
}
@Override
public void service(Request request, Response response) throws Exception {
response.setHeader("Server", HTTP_HEADER_SERVER_FIELD);
StringBuilder sb = new StringBuilder("<table>");
sb.append("<tr>");
sb.append(" <th>Device ID</th>");
sb.append(" <th>MJPEG stream</th>");
sb.append(" <th>Links</th>");
sb.append("</tr>");
for (CamDevice device : deviceMap.values()) {
final String deviceId = device.getDeviceId();
final String uriEncodedDeviceId = EncodingUtil.encodeURIComponent(deviceId);
// TODO escape html special chars in deviceId
final String mjpegStreamUri = URI_PATTERN.replace(URI_PATTERN_PARAM_ID, uriEncodedDeviceId);
final String singlePictureUri = URI_PATTERN.replace(URI_PATTERN_PARAM_ID, uriEncodedDeviceId) + URI_ENDING_SINGLE_IMAGE;
final eu.hgross.blaubot.blaubotcam.server.model.ImageMessage lastImageMessage = device.getLastImageMessage();
sb.append("<tr>");
sb.append(" <td><a href=\"").append(mjpegStreamUri).append("\">").append(deviceId).append("</a></td>");
sb.append(" <td>");
if (lastImageMessage != null) {
sb.append(" <a target=\"blank\" href=\"").append(singlePictureUri).append("\"><img style=\"max-height: 200px;\" src=\"").append(mjpegStreamUri).append("\"></a><br>");
sb.append(lastImageMessage.getTime().toString());
} else {
sb.append("never");
}
sb.append(" </td>");
sb.append(" <td>");
sb.append(" <ul>");
sb.append(" <li><a href=\"").append(singlePictureUri).append("\">").append("Single JPEG").append("</a></li>");
sb.append(" <li><a href=\"").append(mjpegStreamUri).append("\">").append("MJPEG-Stream").append("</a></li>");
sb.append(" </ul>");
sb.append(" </td>");
sb.append("</tr>");
}
sb.append("</ul>");
final String deviceList = sb.toString();
response.getWriter().append(INDEX_PAGE_TMPL.replace(REPLACE_DEVICE_LIST, deviceList));
}
}
private Object serverMonitor = new Object();
/**
* Starts the http server
*/
public void startHTTPServer() {
try {
synchronized (serverMonitor) {
server.start();
}
} catch (IOException e) {
e.printStackTrace();
}
}
/**
* stops the integrated http server
*/
public void stopHTTPServer() {
synchronized (serverMonitor) {
final CountDownLatch latch = new CountDownLatch(1);
final GrizzlyFuture<HttpServer> shutdownFuture = server.shutdown(MAX_HTTP_SERVER_SHUTDOWN_TIME, TimeUnit.MILLISECONDS);
shutdownFuture.addCompletionHandler(new CompletionHandler<HttpServer>() {
@Override
public void cancelled() {
}
@Override
public void failed(Throwable throwable) {
}
@Override
public void completed(HttpServer result) {
latch.countDown();
}
@Override
public void updated(HttpServer result) {
}
});
try {
final boolean timedOut = !latch.await(MAX_HTTP_SERVER_SHUTDOWN_TIME, TimeUnit.MILLISECONDS);
if (timedOut) {
throw new RuntimeException("Could not stop grizzly http server.");
}
} catch (InterruptedException e) {
}
}
}
}