// ============================================================
// Blink Multi-Camera Server + Widget
// http://<host>:8085/ → Dropdown alle Kameras
// http://<host>:8085/?camera=548730 → nur diese Kamera
// http://<host>:8085/grid → alle Kameras im Grid (Standbild)
// http://<host>:8085/blink/<file> → Video-Datei
// http://<host>:8085/cameras → JSON mit allen Kameras
// ============================================================
const http = require('http');
const fs = require('fs');
const path = require('path');
// ============= KONFIGURATION =============
const PORT = 8085;
const ROOT_DIR = '/opt/iobroker/iobroker-data/blink';
const VIDEO_BASE = '/blink/';
const CAMERA_PREFIX = 'blink.0.cameras.';
const VIDEO_STATE = '.video.file';
const NAME_STATE = '.info.name';
const IOBROKER_PORT = 8082;
// =========================================
if (typeof globalThis.__blinkServer !== 'undefined') {
try { globalThis.__blinkServer.close(); log('Vorherigen Blink-Server gestoppt'); }
catch (e) { /* ignore */ }
}
// ---------- Kameras automatisch entdecken ----------
function discoverCameras() {
return new Promise((resolve) => {
const cams = [];
const seen = new Set();
$(`state[id=${CAMERA_PREFIX}*${VIDEO_STATE}]`).each((id) => {
const rest = id.slice(CAMERA_PREFIX.length);
const camId = rest.split('.')[0];
if (!seen.has(camId)) {
seen.add(camId);
cams.push({ id: camId, datapoint: id, name: null });
}
});
const promises = cams.map(c => new Promise((res) => {
const nameDp = CAMERA_PREFIX + c.id + NAME_STATE;
getState(nameDp, (err, st) => {
if (!err && st && st.val) c.name = String(st.val);
res();
});
}));
Promise.all(promises).then(() => resolve(cams.sort((a, b) =>
(a.name || a.id).localeCompare(b.name || b.id)
)));
});
}
// ============================================================
// Gemeinsamer JS-Helper-Code für beide Widgets
// ============================================================
const COMMON_JS = `
const VIDEO_PREFIX = location.protocol + '//' + location.hostname + ':__VIDEO_PORT__' + '__VIDEO_BASE__';
const IOBROKER_URL = location.protocol + '//' + location.hostname + ':__IOBROKER_PORT__';
function buildUrl(v) {
if (!v) return null;
if (/^https?:\\/\\//.test(v)) return v;
return VIDEO_PREFIX + encodeURIComponent(String(v).split('/').pop());
}
function tsFromName(n) {
const m = n && n.match(/(\\d{4}-\\d{2}-\\d{2})T(\\d{2})-(\\d{2})-(\\d{2})/);
return m ? \`\${m[1]} \${m[2]}:\${m[3]}:\${m[4]}\` : null;
}
function dateFromName(n) {
const m = n && n.match(/(\\d{4})-(\\d{2})-(\\d{2})T(\\d{2})-(\\d{2})-(\\d{2})-(\\d{3})Z/);
if (!m) return null;
return new Date(Date.UTC(+m[1], +m[2]-1, +m[3], +m[4], +m[5], +m[6], +m[7]));
}
function relativeTime(ms) {
if (!ms) return '';
const diff = Math.max(0, Date.now() - ms);
const sec = Math.floor(diff / 1000);
if (sec < 60) return 'gerade eben';
const min = Math.floor(sec / 60);
if (min < 60) return 'vor ' + min + ' Min';
const h = Math.floor(min / 60);
if (h < 24) return 'vor ' + h + ' Std';
const d = Math.floor(h / 24);
if (d < 30) return 'vor ' + d + ' Tag' + (d===1?'':'en');
return new Date(ms).toLocaleDateString('de-DE');
}
`;
// ============================================================
// Widget 1: Single/Dropdown
// ============================================================
const WIDGET_HTML = `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Blink Video Player</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/2.3.0/socket.io.js"></script>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background:#1a1a1a; color:#eee; font-family:-apple-system,system-ui,sans-serif;
min-height:100vh; display:flex; flex-direction:column; align-items:center; padding:16px; }
.container { width:100%; max-width:800px; background:#2a2a2a; border-radius:12px;
overflow:hidden; box-shadow:0 4px 12px rgba(0,0,0,0.4); }
.header { padding:12px 16px; background:#333; display:flex; justify-content:space-between;
align-items:center; gap:12px; border-bottom:1px solid #444; flex-wrap:wrap; }
.title { font-size:14px; font-weight:600; flex:1; min-width:0;
overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.status { font-size:11px; padding:3px 8px; border-radius:10px; background:#555; flex-shrink:0; }
.status.ok { background:#2d6a3e; }
.status.err { background:#8b2d2d; }
select { background:#444; color:#eee; border:1px solid #555; padding:5px 8px;
border-radius:6px; font-size:12px; max-width:200px; }
video { width:100%; display:block; background:#000; max-height:600px; }
.info { padding:12px 16px; font-size:12px; color:#999; word-break:break-all;
border-top:1px solid #444; }
.info .ts { color:#ccc; font-weight:500; margin-bottom:4px; }
.empty { padding:60px 20px; text-align:center; color:#777; }
.controls { padding:8px 16px; border-top:1px solid #444; display:flex; gap:8px;
justify-content:space-between; align-items:center; }
.relative-ts { font-size:12px; color:#aaa; }
button { background:#444; color:#eee; border:none; padding:6px 12px; border-radius:6px;
font-size:12px; cursor:pointer; }
button:hover { background:#555; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<span class="title" id="title">📹 Blink</span>
<select id="picker" style="display:none"></select>
<span class="status" id="status">Verbinde…</span>
</div>
<div id="player"><div class="empty">Lade Kameras…</div></div>
<div class="info" id="info"></div>
<div class="controls">
<span class="relative-ts" id="reltime"></span>
<button id="reload">🔄 Neu laden</button>
</div>
</div>
<script>
__COMMON_JS__
const params = new URLSearchParams(location.search);
const fixedCamera = params.get('camera');
const $title = document.getElementById('title');
const $status = document.getElementById('status');
const $player = document.getElementById('player');
const $info = document.getElementById('info');
const $reload = document.getElementById('reload');
const $picker = document.getElementById('picker');
const $reltime = document.getElementById('reltime');
let socket, currentDp = null, currentValue = null, cameras = [];
let lastTimestamp = null;
function setStatus(t, c) { $status.textContent = t; $status.className = 'status' + (c?' '+c:''); }
function updateRelativeTime() { $reltime.textContent = lastTimestamp ? relativeTime(lastTimestamp) : ''; }
function render(value, stateTs) {
currentValue = value;
if (stateTs) lastTimestamp = stateTs;
else if (value) {
const d = dateFromName(String(value).split('/').pop());
lastTimestamp = d ? d.getTime() : null;
}
updateRelativeTime();
const url = buildUrl(value);
if (!url) { $player.innerHTML = '<div class="empty">Kein Video verfügbar</div>'; $info.textContent = ''; return; }
const fn = String(value).split('/').pop();
const ts = tsFromName(fn);
$player.innerHTML = '<video controls autoplay muted playsinline><source src="' + url + '" type="video/mp4">Browser unsupported.</video>';
$info.innerHTML = ts ? '<div class="ts">🕒 ' + ts + '</div><div>' + fn + '</div>' : '<div>' + fn + '</div>';
}
$reload.addEventListener('click', () => { if (currentValue) render(currentValue, lastTimestamp); });
setInterval(updateRelativeTime, 30000);
function switchCamera(cam) {
if (currentDp) socket.emit('unsubscribe', currentDp);
currentDp = cam.datapoint;
$title.textContent = '📹 ' + (cam.name || 'Kamera ' + cam.id);
$player.innerHTML = '<div class="empty">Lade Video…</div>';
$info.textContent = '';
lastTimestamp = null;
updateRelativeTime();
socket.emit('getState', currentDp, (err, state) => {
if (state && state.val) render(state.val, state.ts || null);
else { $player.innerHTML = '<div class="empty">Kein Video verfügbar</div>'; }
});
socket.emit('subscribe', currentDp);
}
fetch('/cameras').then(r => r.json()).then(list => {
cameras = list;
if (!cameras.length) {
setStatus('Keine Kameras', 'err');
$player.innerHTML = '<div class="empty">Keine Kameras gefunden</div>';
return;
}
if (fixedCamera) {
const cam = cameras.find(c => c.id === fixedCamera);
if (!cam) {
setStatus('Unbekannt', 'err');
$player.innerHTML = '<div class="empty">Kamera ' + fixedCamera + ' nicht gefunden</div>';
return;
}
connectAndStart(cam);
} else {
$picker.style.display = '';
cameras.forEach(c => {
const o = document.createElement('option');
o.value = c.id;
o.textContent = c.name || ('Kamera ' + c.id);
$picker.appendChild(o);
});
$picker.addEventListener('change', () => {
const cam = cameras.find(c => c.id === $picker.value);
if (cam) switchCamera(cam);
});
connectAndStart(cameras[0]);
}
}).catch(e => { setStatus('Server-Fehler', 'err'); console.error(e); });
function connectAndStart(cam) {
if (typeof io === 'undefined') { setStatus('Socket.IO Lib fehlt', 'err'); return; }
socket = io(IOBROKER_URL, { transports: ['websocket', 'polling'] });
socket.on('connect', () => { setStatus('Verbunden', 'ok'); switchCamera(cam); });
socket.on('disconnect', () => setStatus('Getrennt', 'err'));
socket.on('connect_error', (e) => { setStatus('Verbindungsfehler', 'err'); console.error(e); });
socket.on('stateChange', (id, state) => {
if (id === currentDp && state && state.val) render(state.val, state.ts || null);
});
}
</script>
</body>
</html>`;
// ============================================================
// Widget 2: Grid (alle Kameras)
// ============================================================
const GRID_HTML = `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Blink Cameras</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/2.3.0/socket.io.js"></script>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background:#1a1a1a; color:#eee; font-family:-apple-system,system-ui,sans-serif;
min-height:100vh; padding:12px; }
.topbar { display:flex; justify-content:space-between; align-items:center;
padding:0 4px 12px; gap:12px; }
.topbar .title { font-size:14px; font-weight:600; }
.status { font-size:11px; padding:3px 8px; border-radius:10px; background:#555; }
.status.ok { background:#2d6a3e; }
.status.err { background:#8b2d2d; }
.grid { display:grid; grid-template-columns:repeat(auto-fit, minmax(280px, 1fr));
gap:12px; }
.cam { background:#2a2a2a; border-radius:10px; overflow:hidden;
box-shadow:0 2px 6px rgba(0,0,0,0.3); display:flex; flex-direction:column; }
.cam-head { padding:8px 12px; background:#333; display:flex;
justify-content:space-between; align-items:center; gap:8px; }
.cam-name { font-size:13px; font-weight:600; flex:1; min-width:0;
overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.cam-time { font-size:11px; color:#aaa; flex-shrink:0; }
.cam-video-wrap { position:relative; background:#000; aspect-ratio:16/9; cursor:pointer; }
.cam-video-wrap video { width:100%; height:100%; display:block; object-fit:cover; }
.cam-video-wrap .overlay { position:absolute; inset:0; display:flex;
align-items:center; justify-content:center; pointer-events:none;
background:rgba(0,0,0,0.25); transition:opacity 0.2s; }
.cam-video-wrap.playing .overlay { opacity:0; }
.cam-video-wrap .play-btn {
width:56px; height:56px; border-radius:50%;
background:rgba(0,0,0,0.6); border:2px solid rgba(255,255,255,0.8);
display:flex; align-items:center; justify-content:center;
}
.cam-video-wrap .play-btn::after {
content:''; width:0; height:0; margin-left:4px;
border-top:10px solid transparent; border-bottom:10px solid transparent;
border-left:16px solid white;
}
.cam-empty { aspect-ratio:16/9; display:flex; align-items:center; justify-content:center;
color:#666; font-size:13px; background:#1a1a1a; }
</style>
</head>
<body>
<div class="topbar">
<span class="title">📹 Blink Kameras</span>
<span class="status" id="status">Verbinde…</span>
</div>
<div class="grid" id="grid"></div>
<script>
__COMMON_JS__
const $status = document.getElementById('status');
const $grid = document.getElementById('grid');
let socket;
const cards = {}; // camId → {value, ts, els}
function setStatus(t, c) { $status.textContent = t; $status.className = 'status' + (c?' '+c:''); }
function buildCard(cam) {
const card = document.createElement('div');
card.className = 'cam';
card.innerHTML =
'<div class="cam-head">' +
'<span class="cam-name">' + (cam.name || 'Kamera ' + cam.id) + '</span>' +
'<span class="cam-time" data-cam="' + cam.id + '"></span>' +
'</div>' +
'<div class="cam-empty" data-cam="' + cam.id + '">Lade…</div>';
$grid.appendChild(card);
cards[cam.id] = { value: null, ts: null, root: card, datapoint: cam.datapoint, name: cam.name || ('Kamera ' + cam.id) };
}
function renderCard(camId) {
const c = cards[camId];
if (!c) return;
const url = buildUrl(c.value);
const timeEl = c.root.querySelector('.cam-time');
timeEl.textContent = c.ts ? relativeTime(c.ts) : '';
const oldArea = c.root.querySelector('.cam-empty, .cam-video-wrap');
if (!url) {
if (!oldArea || !oldArea.classList.contains('cam-empty')) {
const empty = document.createElement('div');
empty.className = 'cam-empty';
empty.textContent = 'Kein Video';
if (oldArea) oldArea.replaceWith(empty); else c.root.appendChild(empty);
}
return;
}
// Video-Wrap mit Play-Overlay aufbauen
const wrap = document.createElement('div');
wrap.className = 'cam-video-wrap';
wrap.innerHTML =
'<video preload="metadata" muted playsinline>' +
'<source src="' + url + '#t=0.1" type="video/mp4">' + // #t=0.1 erzwingt Frame-Lade
'</video>' +
'<div class="overlay"><div class="play-btn"></div></div>';
const video = wrap.querySelector('video');
wrap.addEventListener('click', () => {
if (video.paused) {
video.controls = true;
wrap.classList.add('playing');
video.muted = false;
video.play().catch(err => {
// Falls Autoplay mit Sound blockiert: stumm starten
video.muted = true;
video.play();
});
}
});
video.addEventListener('ended', () => { wrap.classList.remove('playing'); video.controls = false; });
video.addEventListener('pause', () => { if (video.ended) wrap.classList.remove('playing'); });
if (oldArea) oldArea.replaceWith(wrap); else c.root.appendChild(wrap);
}
function updateAllTimes() {
Object.keys(cards).forEach(id => {
const c = cards[id];
const timeEl = c.root.querySelector('.cam-time');
if (timeEl) timeEl.textContent = c.ts ? relativeTime(c.ts) : '';
});
}
setInterval(updateAllTimes, 30000);
fetch('/cameras').then(r => r.json()).then(list => {
if (!list.length) {
setStatus('Keine Kameras', 'err');
$grid.innerHTML = '<div style="padding:40px;text-align:center;color:#777">Keine Kameras gefunden</div>';
return;
}
list.forEach(buildCard);
if (typeof io === 'undefined') { setStatus('Socket.IO Lib fehlt', 'err'); return; }
socket = io(IOBROKER_URL, { transports: ['websocket', 'polling'] });
socket.on('connect', () => {
setStatus('Verbunden', 'ok');
list.forEach(cam => {
socket.emit('getState', cam.datapoint, (err, state) => {
if (state && state.val) {
cards[cam.id].value = state.val;
cards[cam.id].ts = state.ts || (() => {
const d = dateFromName(String(state.val).split('/').pop());
return d ? d.getTime() : null;
})();
renderCard(cam.id);
} else {
renderCard(cam.id);
}
});
socket.emit('subscribe', cam.datapoint);
});
});
socket.on('disconnect', () => setStatus('Getrennt', 'err'));
socket.on('connect_error', (e) => { setStatus('Verbindungsfehler', 'err'); console.error(e); });
socket.on('stateChange', (id, state) => {
const cam = list.find(c => c.datapoint === id);
if (!cam || !state) return;
cards[cam.id].value = state.val;
cards[cam.id].ts = state.ts || null;
renderCard(cam.id);
});
}).catch(e => { setStatus('Server-Fehler', 'err'); console.error(e); });
</script>
</body>
</html>`;
// Common-JS in Widgets einsetzen + Konfig-Werte
function buildHTML(template) {
return template
.replace('__COMMON_JS__', COMMON_JS)
.replace(/__VIDEO_BASE__/g, VIDEO_BASE)
.replace(/__VIDEO_PORT__/g, PORT)
.replace(/__IOBROKER_PORT__/g, IOBROKER_PORT);
}
const SINGLE_PAGE = buildHTML(WIDGET_HTML);
const GRID_PAGE = buildHTML(GRID_HTML);
// ---------- Mime-Map ----------
const MIME = {
'.mp4':'video/mp4','.webm':'video/webm','.mov':'video/quicktime',
'.jpg':'image/jpeg','.jpeg':'image/jpeg','.png':'image/png',
'.html':'text/html; charset=utf-8','.json':'application/json'
};
// ---------- Server ----------
const server = http.createServer(async (req, res) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
const urlPath = req.url.split('?')[0];
if (urlPath === '/cameras') {
try {
const cams = await discoverCameras();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(cams));
} catch (e) {
log('Kamera-Discovery Fehler: ' + e.message, 'error');
res.writeHead(500); res.end('Error');
}
return;
}
if (urlPath === '/grid' || urlPath === '/grid.html') {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(GRID_PAGE);
return;
}
if (urlPath === '/' || urlPath === '/index.html') {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(SINGLE_PAGE);
return;
}
if (!urlPath.startsWith(VIDEO_BASE)) { res.writeHead(404); res.end('Not Found'); return; }
const filename = decodeURIComponent(urlPath.slice(VIDEO_BASE.length));
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
res.writeHead(403); res.end('Forbidden'); return;
}
const fullPath = path.join(ROOT_DIR, filename);
if (!fullPath.startsWith(ROOT_DIR)) { res.writeHead(403); res.end('Forbidden'); return; }
fs.stat(fullPath, (err, stat) => {
if (err || !stat.isFile()) { res.writeHead(404); res.end('File Not Found'); return; }
const ext = path.extname(filename).toLowerCase();
const mimeType = MIME[ext] || 'application/octet-stream';
const range = req.headers.range;
if (range && mimeType.startsWith('video/')) {
const parts = range.replace(/bytes=/, '').split('-');
const start = parseInt(parts[0], 10);
const end = parts[1] ? parseInt(parts[1], 10) : stat.size - 1;
res.writeHead(206, {
'Content-Range': `bytes ${start}-${end}/${stat.size}`,
'Accept-Ranges': 'bytes',
'Content-Length': end - start + 1,
'Content-Type': mimeType
});
fs.createReadStream(fullPath, { start, end }).pipe(res);
} else {
res.writeHead(200, {
'Content-Length': stat.size,
'Content-Type': mimeType,
'Accept-Ranges': 'bytes'
});
fs.createReadStream(fullPath).pipe(res);
}
});
});
server.listen(PORT, () => {
log(`Blink-Server läuft: http://<host>:${PORT}/ (Single) + /grid (Multi)`);
});
server.on('error', (err) => log(`Blink-Server Fehler: ${err.message}`, 'error'));
globalThis.__blinkServer = server;
onStop(() => {
if (server) { server.close(); log('Blink-Server gestoppt'); }
}, 2000);