Weiter zum Inhalt
  • Home
  • Aktuell
  • Tags
  • 0 Ungelesen 0
  • Kategorien
  • Unreplied
  • Beliebt
  • GitHub
  • Docu
  • Hilfe
Skins
  • Hell
  • Brite
  • Cerulean
  • Cosmo
  • Flatly
  • Journal
  • Litera
  • Lumen
  • Lux
  • Materia
  • Minty
  • Morph
  • Pulse
  • Sandstone
  • Simplex
  • Sketchy
  • Spacelab
  • United
  • Yeti
  • Zephyr
  • Dunkel
  • Cyborg
  • Darkly
  • Quartz
  • Slate
  • Solar
  • Superhero
  • Vapor

  • Standard: (Kein Skin)
  • Kein Skin
Einklappen
ioBroker Logo

Community Forum

donate donate
  1. ioBroker Community Home
  2. Deutsch
  3. Tester
  4. ...nicht in offiziellem Repo
  5. Test Adapter für Blink Kameras entwickelt mit KI

NEWS

  • Neues YouTube-Video: Visualisierung im Devices-Adapter
    BluefoxB
    Bluefox
    11
    1
    376

  • Neuer ioBroker-Blog online: Monatsrückblick März/April 2026
    BluefoxB
    Bluefox
    8
    1
    1.9k

  • Verwendung von KI bitte immer deutlich kennzeichnen
    HomoranH
    Homoran
    11
    1
    804

Test Adapter für Blink Kameras entwickelt mit KI

Geplant Angeheftet Gesperrt Verschoben ...nicht in offiziellem Repo
112 Beiträge 11 Kommentatoren 2.0k Aufrufe 12 Beobachtet
  • Älteste zuerst
  • Neuste zuerst
  • Meiste Stimmen
Antworten
  • In einem neuen Thema antworten
Anmelden zum Antworten
Dieses Thema wurde gelöscht. Nur Nutzer mit entsprechenden Rechten können es sehen.
  • PischleuderP Online
    PischleuderP Online
    Pischleuder
    schrieb am zuletzt editiert von Pischleuder
    #1
    Aktuelle Testversion 0.0.10
    Github Link https://github.com/Pischleuder1/ioBroker.blink
    NPM Link https://www.npmjs.com/package/iobroker.blink
    Veröffentlichungsdatum 23.05.2026

    In den letzten Jahren gab es diverse BLINK Adapter für die Amazon Kameras, die jedoch nicht weiterentwickelt wurden und alle, mehr oder weniger, auf blinkpy basierten. Als Alternative funktionierten temporär python scripte mit blinkpy oder die Option über IFTTT.

    Zuletzt sind die meisten sicherlich am HAM Adapter oder Homeassistant hängen geblieben, um die Kameras zu steuern. Da Amazon jedoch wieder einmal an den API herumbastelt, funktioniert das tlw. nur noch suboptimal.
    Die Idee mit blinkpy zu arbeiten habe ich auf anraten des Forums verworfen und einen Adapter (unter Zuhilfenahme von KI) erstellt, um die gesamte Login - Logik im Adapter nachzubauen.
    c49e2058-65ac-4a20-ad62-e068827483e1-image.jpeg
    3801838a-66ff-4fc0-be85-e12b886d227a-image.jpeg

    Funktionen:
    • Kameras und Sync-Modul werden ausgelesen und die entsprechenden States etc. angezeigt
    • Temperaturanzeige über die Kamera in Grad Celsius und Fahrenheit
    • Batterienanzeige der Kamera, umgerechnet in Volt
    • bei geringem Batteriestand wird / kann eine pushover oder telegram Info gesendet werden
    • Snapshot von Bildern über commands in einen state bzw. auch lokal in den Ordner /opt/iobroker/iobroker-data/blink
    • Snapshot als image_base64 mit Zeitstempel
    • automatische Erzeugung von Snapshots nach Zeit über admin Bereich
    • motion detect
    • aktuell, von Blink gespeicherte Videos werden in die entsprechenden Datenpunkte geschrieben oder per fetch geholt (dafür muss das javascript eingebunden sein)
    • Speicherort kann im Admin Bereich festgelegt werden
    • Video Doorbell wird unterstützt, jedoch keine Temperaturanzeige, da diese offensichtlich bei der Doorbell nicht verbaut ist
    • unterstützt "Smart Detection states" für bestimmte Bewegungsvorgänge wie Paket, Tier etc. (funktioniert nur bei Abo-Modell)
    • unterstützt Cloud und Lokal gespeicherte Videos (SyncModule 2 und XR) via Server auf port 8085 - JavaScript benötigt, siehe unten !

    Was funktioniert (noch) nicht:
    • kein Echtzeit Video (in Arbeit)

    Visualisierung
    • ffmpeg muss installiert sein
    • Es werden auf einem Pi4 relativ große Ressourcen benötigt, 4 GB und mehr empfohlen
    • dieses Javascript einbinden:

    // ============================================================
    // Blink Multi-Camera Server + Widget
    //   http://<host>:8085/                  → Single + 10-Slot History darunter
    //   http://<host>:8085/?camera=548730    → Single für eine fixe Kamera
    //   http://<host>:8085/grid              → Alle Kameras im Grid (mit History-Blättern)
    //   http://<host>:8085/history?camera=ID → Reine History-Ansicht, 10 Slots in Reihe
    //   http://<host>:8085/blink/<file>      → Video-Datei
    //   http://<host>:8085/cameras           → JSON mit allen Kameras (inkl. History-Datenpunkten)
    // ============================================================
    
    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 TS_STATE      = '.video.timestamp';
    const READY_STATE   = '.video.ready';
    const ERROR_STATE   = '.video.lastError';
    const HISTORY_SIZE  = 10;
    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}*${NAME_STATE}]`).each((id) => {
               const rest = id.slice(CAMERA_PREFIX.length);
               const camId = rest.split('.')[0];
               if (!seen.has(camId)) {
                   seen.add(camId);
                   const history = [];
                   for (let i = 0; i < HISTORY_SIZE; i++) {
                       history.push({
                           slot: i,
                           file_datapoint:      `${CAMERA_PREFIX}${camId}.video.history.${i}.file`,
                           timestamp_datapoint: `${CAMERA_PREFIX}${camId}.video.history.${i}.timestamp`,
                           id_datapoint:        `${CAMERA_PREFIX}${camId}.video.history.${i}.id`,
                           source_datapoint:    `${CAMERA_PREFIX}${camId}.video.history.${i}.source`
                       });
                   }
                   cams.push({
                       id: camId,
                       datapoint:       CAMERA_PREFIX + camId + VIDEO_STATE,
                       ts_datapoint:    CAMERA_PREFIX + camId + TS_STATE,
                       ready_datapoint: CAMERA_PREFIX + camId + READY_STATE,
                       error_datapoint: CAMERA_PREFIX + camId + ERROR_STATE,
                       history: history,
                       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).trim();
                   res();
               });
           }));
           Promise.all(promises).then(() => resolve(cams.sort((a, b) =>
               (a.name || a.id).localeCompare(b.name || b.id)
           )));
       });
    }
    
    // ============================================================
    // Gemeinsamer JS-Helper-Code
    // ============================================================
    const COMMON_JS = `
    const VIDEO_PREFIX = location.protocol + '//' + location.hostname + ':__VIDEO_PORT__' + '__VIDEO_BASE__';
    const IOBROKER_URL = location.protocol + '//' + location.hostname + ':__IOBROKER_PORT__';
    const HISTORY_SIZE = __HISTORY_SIZE__;
    
    function buildUrl(v, ts) {
     if (!v) return null;
     if (/^https?:\\/\\//.test(v)) return v;
     const fn = encodeURIComponent(String(v).split('/').pop());
     return VIDEO_PREFIX + fn + '?t=' + (ts || Date.now());
    }
    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 formatTs(isoOrMs) {
     if (!isoOrMs) return '';
     const d = new Date(isoOrMs);
     if (isNaN(d.getTime())) return '';
     return d.toLocaleString('de-DE', {
       day: '2-digit', month: '2-digit', year: 'numeric',
       hour: '2-digit', minute: '2-digit', second: '2-digit'
     });
    }
    function relativeTime(ms) {
     if (!ms) return '';
     const diff = Math.max(0, Date.now() - (typeof ms === 'string' ? new Date(ms).getTime() : 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');
    }
    
    function isVideoValid(value, ready, lastError) {
     if (!value) return false;
     if (lastError && String(lastError).trim() !== '' && String(lastError).toLowerCase() !== 'null') return false;
     if (ready === false) return false;
     return true;
    }
    `;
    
    // ============================================================
    // Widget 1: Single + History-Streifen darunter
    // ============================================================
    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:900px; 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; }
    .info .err { color:#e88; font-weight:500; }
    .info .source { display:inline-block; padding:2px 6px; border-radius:4px;
                   font-size:10px; background:#444; margin-left:6px; vertical-align:middle; }
    .info .source.cloud { background:#2d4a6a; }
    .info .source.local_storage { background:#4a3a2d; }
    .empty { padding:60px 20px; text-align:center; color:#777; }
    .empty .err-msg { color:#e88; font-size:13px; margin-top:8px; }
    .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; }
    
    /* History-Streifen */
    .history-section { border-top:1px solid #444; padding:12px 16px; background:#252525; }
    .history-section h3 { font-size:12px; color:#999; margin-bottom:8px; font-weight:500; }
    .history-strip { display:flex; gap:6px; overflow-x:auto; padding-bottom:4px; }
    .history-strip::-webkit-scrollbar { height:6px; }
    .history-strip::-webkit-scrollbar-thumb { background:#555; border-radius:3px; }
    .hslot { flex:0 0 130px; background:#1a1a1a; border-radius:6px; cursor:pointer;
            border:2px solid transparent; transition:border-color 0.15s, transform 0.15s; }
    .hslot:hover { border-color:#666; transform:translateY(-2px); }
    .hslot.active { border-color:#4a8; }
    .hslot.empty { opacity:0.3; cursor:not-allowed; }
    .hslot-thumb { position:relative; aspect-ratio:16/9; background:#000; border-radius:4px 4px 0 0;
                  overflow:hidden; }
    .hslot-thumb video { width:100%; height:100%; object-fit:cover; pointer-events:none; }
    .hslot-thumb .badge { position:absolute; top:4px; left:4px; background:rgba(0,0,0,0.7);
                        color:white; padding:1px 5px; border-radius:3px; font-size:10px; }
    .hslot-info { padding:4px 6px; font-size:10px; color:#aaa; line-height:1.3; }
    .hslot-info .time { color:#ddd; font-weight:500; }
    .hslot-info .src { font-size:9px; color:#888; }
    </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 class="history-section" id="history-section" style="display:none">
       <h3>📚 Verlauf (10 neueste)</h3>
       <div class="history-strip" id="history-strip"></div>
     </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');
    const $histSection = document.getElementById('history-section');
    const $histStrip = document.getElementById('history-strip');
    
    let socket, currentCam = null, cameras = [];
    let curValue = null, curTs = null, curReady = null, curError = null;
    // History-State pro Kamera-Wechsel neu aufgebaut
    let histSlots = []; // [{file, timestamp, id, source}, ...]
    let manualPick = null; // null = aktuellen Live-Clip zeigen, sonst Slot-Index
    
    function setStatus(t, c) { $status.textContent = t; $status.className = 'status' + (c?' '+c:''); }
    function updateRelativeTime() { $reltime.textContent = curTs ? relativeTime(curTs) : ''; }
    
    function renderEmpty(msg, errMsg) {
     let html = '<div class="empty">' + msg;
     if (errMsg) html += '<div class="err-msg">⚠ ' + errMsg + '</div>';
     html += '</div>';
     $player.innerHTML = html;
     $info.textContent = '';
    }
    
    function renderCurrent() {
     updateRelativeTime();
    
     // Wenn manuell ein History-Slot gewählt, den dort gezeigten Clip verwenden
     let showFile, showTs, showSource, showInfo;
     if (manualPick !== null && histSlots[manualPick] && histSlots[manualPick].file) {
       const s = histSlots[manualPick];
       showFile = s.file;
       showTs = s.timestamp;
       showSource = s.source;
       showInfo = 'Slot ' + manualPick;
     } else if (isVideoValid(curValue, curReady, curError)) {
       showFile = curValue;
       showTs = curTs;
       showSource = null;
       showInfo = 'Live';
     } else {
       const errText = curError && String(curError).trim() && String(curError).toLowerCase() !== 'null'
         ? String(curError) : null;
       renderEmpty('Kein aktuelles Video', errText);
       refreshHistoryStrip();
       return;
     }
    
     const url = buildUrl(showFile, showTs);
     if (!url) { renderEmpty('Kein Video verfügbar'); return; }
    
     const fn = String(showFile).split('/').pop();
    
     $player.innerHTML = '';
     const video = document.createElement('video');
     video.controls = true;
     video.autoplay = true;
     video.muted = true;
     video.playsInline = true;
     const source = document.createElement('source');
     source.setAttribute('src', url);
     source.setAttribute('type', 'video/mp4');
     video.appendChild(source);
     $player.appendChild(video);
     video.load();
    
     const ts = formatTs(showTs);
     let infoHtml = '<div class="ts">🕒 ' + (ts || '—') + ' · ' + showInfo;
     if (showSource) {
       infoHtml += '<span class="source ' + showSource + '">' + showSource + '</span>';
     }
     infoHtml += '</div><div>' + fn + '</div>';
     $info.innerHTML = infoHtml;
    
     refreshHistoryStrip();
    }
    
    function refreshHistoryStrip() {
     $histSection.style.display = '';
     $histStrip.innerHTML = '';
     for (let i = 0; i < HISTORY_SIZE; i++) {
       const s = histSlots[i] || {};
       const slot = document.createElement('div');
       slot.className = 'hslot' + (s.file ? '' : ' empty') + (manualPick === i ? ' active' : '');
       slot.dataset.slot = i;
    
       const thumb = document.createElement('div');
       thumb.className = 'hslot-thumb';
       const badge = document.createElement('div');
       badge.className = 'badge';
       badge.textContent = '#' + i;
       thumb.appendChild(badge);
    
       if (s.file) {
         const v = document.createElement('video');
         v.preload = 'metadata';
         v.muted = true;
         const sourceEl = document.createElement('source');
         sourceEl.setAttribute('src', buildUrl(s.file, s.timestamp));
         sourceEl.setAttribute('type', 'video/mp4');
         v.appendChild(sourceEl);
         thumb.appendChild(v);
       }
    
       const inf = document.createElement('div');
       inf.className = 'hslot-info';
       const t = document.createElement('div');
       t.className = 'time';
       t.textContent = s.timestamp ? formatTs(s.timestamp).replace(/, \\d{4}/, '') : '—';
       const src = document.createElement('div');
       src.className = 'src';
       src.textContent = s.source || (s.file ? '' : 'leer');
       inf.appendChild(t);
       inf.appendChild(src);
    
       slot.appendChild(thumb);
       slot.appendChild(inf);
    
       if (s.file) {
         slot.addEventListener('click', () => {
           manualPick = (manualPick === i) ? null : i;
           renderCurrent();
         });
       }
       $histStrip.appendChild(slot);
     }
    }
    
    $reload.addEventListener('click', () => { manualPick = null; renderCurrent(); });
    setInterval(updateRelativeTime, 30000);
    
    function unsubscribeCurrent() {
     if (!currentCam || !socket) return;
     socket.emit('unsubscribe', currentCam.datapoint);
     socket.emit('unsubscribe', currentCam.ts_datapoint);
     socket.emit('unsubscribe', currentCam.ready_datapoint);
     socket.emit('unsubscribe', currentCam.error_datapoint);
     currentCam.history.forEach(h => {
       socket.emit('unsubscribe', h.file_datapoint);
       socket.emit('unsubscribe', h.timestamp_datapoint);
       socket.emit('unsubscribe', h.id_datapoint);
       socket.emit('unsubscribe', h.source_datapoint);
     });
    }
    
    function switchCamera(cam) {
     unsubscribeCurrent();
     currentCam = cam;
     manualPick = null;
     histSlots = new Array(HISTORY_SIZE).fill(null).map(() => ({}));
     $title.textContent = '📹 ' + (cam.name || 'Kamera ' + cam.id);
     $player.innerHTML = '<div class="empty">Lade Video…</div>';
     $info.textContent = '';
     curValue = null; curTs = null; curReady = null; curError = null;
     updateRelativeTime();
    
     // Live-States: 4 parallel
     let pending = 4 + 4 * HISTORY_SIZE;
     const done = () => { if (--pending === 0) renderCurrent(); };
    
     socket.emit('getState', cam.ts_datapoint, (e, st) => {
       if (st && st.val) curTs = st.val;
       done();
     });
     socket.emit('getState', cam.ready_datapoint, (e, st) => {
       if (st) curReady = st.val;
       done();
     });
     socket.emit('getState', cam.error_datapoint, (e, st) => {
       if (st) curError = st.val;
       done();
     });
     socket.emit('getState', cam.datapoint, (e, st) => {
       if (st) curValue = st.val;
       done();
     });
    
     socket.emit('subscribe', cam.datapoint);
     socket.emit('subscribe', cam.ts_datapoint);
     socket.emit('subscribe', cam.ready_datapoint);
     socket.emit('subscribe', cam.error_datapoint);
    
     // History-States: 10 Slots × 4 Felder
     cam.history.forEach((h, idx) => {
       socket.emit('getState', h.file_datapoint, (e, st) => {
         histSlots[idx].file = st ? st.val : null; done();
       });
       socket.emit('getState', h.timestamp_datapoint, (e, st) => {
         histSlots[idx].timestamp = st ? st.val : null; done();
       });
       socket.emit('getState', h.id_datapoint, (e, st) => {
         histSlots[idx].id = st ? st.val : null; done();
       });
       socket.emit('getState', h.source_datapoint, (e, st) => {
         histSlots[idx].source = st ? st.val : null; done();
       });
       socket.emit('subscribe', h.file_datapoint);
       socket.emit('subscribe', h.timestamp_datapoint);
       socket.emit('subscribe', h.id_datapoint);
       socket.emit('subscribe', h.source_datapoint);
     });
    }
    
    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 findHistorySlot(id) {
     if (!currentCam) return -1;
     return currentCam.history.findIndex(h =>
       id === h.file_datapoint || id === h.timestamp_datapoint ||
       id === h.id_datapoint || id === h.source_datapoint);
    }
    
    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 (!state || !currentCam) return;
       if (id === currentCam.datapoint)        { curValue = state.val; renderCurrent(); return; }
       if (id === currentCam.ts_datapoint)     { if (state.val) curTs = state.val; renderCurrent(); return; }
       if (id === currentCam.ready_datapoint)  { curReady = state.val; renderCurrent(); return; }
       if (id === currentCam.error_datapoint)  { curError = state.val; renderCurrent(); return; }
    
       const slot = findHistorySlot(id);
       if (slot >= 0) {
         const h = currentCam.history[slot];
         if (id === h.file_datapoint)      histSlots[slot].file = state.val;
         else if (id === h.timestamp_datapoint) histSlots[slot].timestamp = state.val;
         else if (id === h.id_datapoint)        histSlots[slot].id = state.val;
         else if (id === h.source_datapoint)    histSlots[slot].source = state.val;
         refreshHistoryStrip();
       }
     });
    }
    </script>
    </body>
    </html>`;
    
    // ============================================================
    // Widget 2: Grid (alle Kameras, History per Klick durchblättern)
    // ============================================================
    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(300px, 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-content { display:block; position:relative; }
    .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-video-wrap .slot-badge {
     position:absolute; top:6px; left:6px;
     background:rgba(0,0,0,0.7); color:white;
     padding:2px 8px; border-radius:4px; font-size:11px; font-weight:500;
     pointer-events:none;
    }
    .cam-empty { aspect-ratio:16/9; display:flex; flex-direction:column;
                align-items:center; justify-content:center; gap:6px;
                color:#888; font-size:13px; background:#1a1a1a; padding:8px; text-align:center; }
    .cam-empty .err-msg { color:#e88; font-size:11px; }
    
    /* History-Navigation */
    .cam-nav { display:flex; align-items:center; justify-content:space-between;
              padding:6px 8px; background:#222; gap:6px; }
    .cam-nav button { background:#444; color:#eee; border:none;
                     padding:4px 10px; border-radius:4px; font-size:11px; cursor:pointer; }
    .cam-nav button:hover { background:#555; }
    .cam-nav button:disabled { opacity:0.4; cursor:not-allowed; }
    .cam-nav .label { font-size:11px; color:#bbb; flex:1; text-align:center;
                     overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
    .cam-nav .label .source { font-size:9px; padding:1px 4px; border-radius:3px;
                             background:#444; margin-left:4px; vertical-align:middle; }
    .cam-nav .label .source.cloud { background:#2d4a6a; }
    .cam-nav .label .source.local_storage { background:#4a3a2d; }
    </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, ready, error, hist[], pickIdx, root, contentHost, ...}
    
    function setStatus(t, c) { $status.textContent = t; $status.className = 'status' + (c?' '+c:''); }
    
    function buildCard(cam) {
     const card = document.createElement('div');
     card.className = 'cam';
     card.dataset.cam = cam.id;
    
     const head = document.createElement('div');
     head.className = 'cam-head';
     const nameSpan = document.createElement('span');
     nameSpan.className = 'cam-name';
     nameSpan.textContent = cam.name || ('Kamera ' + cam.id);
     const timeSpan = document.createElement('span');
     timeSpan.className = 'cam-time';
     head.appendChild(nameSpan);
     head.appendChild(timeSpan);
    
     const contentHost = document.createElement('div');
     contentHost.className = 'cam-content';
    
     const empty = document.createElement('div');
     empty.className = 'cam-empty';
     empty.textContent = 'Lade…';
     contentHost.appendChild(empty);
    
     // History-Navigation
     const nav = document.createElement('div');
     nav.className = 'cam-nav';
     const prevBtn = document.createElement('button');
     prevBtn.textContent = '◀';
     prevBtn.title = 'Älterer Clip';
     const label = document.createElement('div');
     label.className = 'label';
     label.textContent = 'Live';
     const nextBtn = document.createElement('button');
     nextBtn.textContent = '▶';
     nextBtn.title = 'Neuerer Clip';
     const liveBtn = document.createElement('button');
     liveBtn.textContent = '⏺ Live';
     liveBtn.title = 'Live-Ansicht';
     nav.appendChild(prevBtn);
     nav.appendChild(label);
     nav.appendChild(liveBtn);
     nav.appendChild(nextBtn);
    
     card.appendChild(head);
     card.appendChild(contentHost);
     card.appendChild(nav);
     $grid.appendChild(card);
    
     cards[cam.id] = {
       value: null, ts: null, ready: null, error: null,
       hist: new Array(HISTORY_SIZE).fill(null).map(() => ({})),
       pickIdx: null,  // null = Live, 0..9 = History-Slot
       root: card, contentHost: contentHost, timeEl: timeSpan,
       prevBtn, nextBtn, liveBtn, label,
       datapoint:       cam.datapoint,
       ts_datapoint:    cam.ts_datapoint,
       ready_datapoint: cam.ready_datapoint,
       error_datapoint: cam.error_datapoint,
       history:         cam.history,
       name: cam.name || ('Kamera ' + cam.id)
     };
    
     prevBtn.addEventListener('click', () => navigate(cam.id, +1));
     nextBtn.addEventListener('click', () => navigate(cam.id, -1));
     liveBtn.addEventListener('click', () => { cards[cam.id].pickIdx = null; renderCard(cam.id); });
    }
    
    function navigate(camId, delta) {
     const c = cards[camId];
     if (!c) return;
     let next = c.pickIdx == null ? 0 : c.pickIdx + delta;
     if (next < 0) { c.pickIdx = null; renderCard(camId); return; }
     if (next >= HISTORY_SIZE) next = HISTORY_SIZE - 1;
     // Suche nächsten Slot mit Datei
     while (next >= 0 && next < HISTORY_SIZE && !c.hist[next].file) next += delta;
     if (next >= 0 && next < HISTORY_SIZE) {
       c.pickIdx = next;
     } else {
       c.pickIdx = null;
     }
     renderCard(camId);
    }
    
    function setEmpty(c, text, errMsg) {
     while (c.contentHost.firstChild) c.contentHost.removeChild(c.contentHost.firstChild);
     const empty = document.createElement('div');
     empty.className = 'cam-empty';
     const main = document.createElement('div');
     main.textContent = text;
     empty.appendChild(main);
     if (errMsg) {
       const sub = document.createElement('div');
       sub.className = 'err-msg';
       sub.textContent = '⚠ ' + errMsg;
       empty.appendChild(sub);
     }
     c.contentHost.appendChild(empty);
    }
    
    function renderCard(camId) {
     const c = cards[camId];
     if (!c) return;
    
     if (c.timeEl) c.timeEl.textContent = c.ts ? relativeTime(c.ts) : '';
    
     let showFile, showTs, showSource, slotLabel;
     if (c.pickIdx !== null && c.hist[c.pickIdx] && c.hist[c.pickIdx].file) {
       const h = c.hist[c.pickIdx];
       showFile = h.file;
       showTs = h.timestamp;
       showSource = h.source;
       slotLabel = '#' + c.pickIdx;
     } else if (isVideoValid(c.value, c.ready, c.error)) {
       showFile = c.value;
       showTs = c.ts;
       showSource = null;
       slotLabel = null; // Live
       c.pickIdx = null;
     } else {
       const errText = c.error && String(c.error).trim() && String(c.error).toLowerCase() !== 'null'
         ? String(c.error) : null;
       setEmpty(c, 'Kein aktuelles Video', errText);
       updateNav(c);
       return;
     }
    
     const url = buildUrl(showFile, showTs);
     if (!url) { setEmpty(c, 'Kein Video'); updateNav(c); return; }
    
     const wrap = document.createElement('div');
     wrap.className = 'cam-video-wrap';
     wrap.dataset.cam = camId;
    
     const video = document.createElement('video');
     video.preload = 'metadata';
     video.muted = true;
     video.playsInline = true;
     video.dataset.cam = camId;
    
     const source = document.createElement('source');
     source.setAttribute('src', url);
     source.setAttribute('type', 'video/mp4');
     video.appendChild(source);
    
     const overlay = document.createElement('div');
     overlay.className = 'overlay';
     const playBtn = document.createElement('div');
     playBtn.className = 'play-btn';
     overlay.appendChild(playBtn);
    
     wrap.appendChild(video);
     wrap.appendChild(overlay);
    
     if (slotLabel) {
       const badge = document.createElement('div');
       badge.className = 'slot-badge';
       badge.textContent = slotLabel;
       wrap.appendChild(badge);
     }
    
     wrap.addEventListener('click', () => {
       if (video.paused) {
         video.controls = true;
         wrap.classList.add('playing');
         video.muted = false;
         video.play().catch(() => { 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'); });
    
     while (c.contentHost.firstChild) c.contentHost.removeChild(c.contentHost.firstChild);
     c.contentHost.appendChild(wrap);
     video.load();
    
     // Label + Quelle anzeigen
     let lbl = (slotLabel ? (slotLabel + ' · ') : 'Live · ') + (formatTs(showTs).replace(/, \\d{4}/, '') || '—');
     if (showSource) lbl += '<span class="source ' + showSource + '">' + showSource + '</span>';
     c.label.innerHTML = lbl;
    
     updateNav(c);
    }
    
    function updateNav(c) {
     // ◀ wäre älter (höhere Index-Zahl), ▶ wäre neuer
     const nextIdx = c.pickIdx == null ? 0 : c.pickIdx + 1;
     const prevIdx = c.pickIdx == null ? null : c.pickIdx - 1;
     c.prevBtn.disabled = !c.hist.slice(nextIdx).some(h => h && h.file);
     c.nextBtn.disabled = c.pickIdx === null;
    }
    
    function updateAllTimes() {
     Object.keys(cards).forEach(id => {
       const c = cards[id];
       if (c.timeEl) c.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 => {
         let pending = 4 + 4 * HISTORY_SIZE;
         const done = () => { if (--pending === 0) renderCard(cam.id); };
    
         socket.emit('getState', cam.ts_datapoint, (e, st) => {
           if (st && st.val) cards[cam.id].ts = st.val;
           done();
         });
         socket.emit('getState', cam.ready_datapoint, (e, st) => {
           if (st) cards[cam.id].ready = st.val;
           done();
         });
         socket.emit('getState', cam.error_datapoint, (e, st) => {
           if (st) cards[cam.id].error = st.val;
           done();
         });
         socket.emit('getState', cam.datapoint, (e, st) => {
           if (st) cards[cam.id].value = st.val;
           done();
         });
    
         socket.emit('subscribe', cam.datapoint);
         socket.emit('subscribe', cam.ts_datapoint);
         socket.emit('subscribe', cam.ready_datapoint);
         socket.emit('subscribe', cam.error_datapoint);
    
         cam.history.forEach((h, idx) => {
           socket.emit('getState', h.file_datapoint, (e, st) => {
             cards[cam.id].hist[idx].file = st ? st.val : null; done();
           });
           socket.emit('getState', h.timestamp_datapoint, (e, st) => {
             cards[cam.id].hist[idx].timestamp = st ? st.val : null; done();
           });
           socket.emit('getState', h.id_datapoint, (e, st) => {
             cards[cam.id].hist[idx].id = st ? st.val : null; done();
           });
           socket.emit('getState', h.source_datapoint, (e, st) => {
             cards[cam.id].hist[idx].source = st ? st.val : null; done();
           });
           socket.emit('subscribe', h.file_datapoint);
           socket.emit('subscribe', h.timestamp_datapoint);
           socket.emit('subscribe', h.id_datapoint);
           socket.emit('subscribe', h.source_datapoint);
         });
       });
     });
     socket.on('disconnect',    () => setStatus('Getrennt', 'err'));
     socket.on('connect_error', (e) => { setStatus('Verbindungsfehler', 'err'); console.error(e); });
     socket.on('stateChange', (id, state) => {
       if (!state) return;
       for (const cam of list) {
         if (id === cam.datapoint)        { cards[cam.id].value = state.val; cards[cam.id].ts = state.ts || cards[cam.id].ts; renderCard(cam.id); return; }
         if (id === cam.ts_datapoint)     { if (state.val) cards[cam.id].ts = state.val; renderCard(cam.id); return; }
         if (id === cam.ready_datapoint)  { cards[cam.id].ready = state.val; renderCard(cam.id); return; }
         if (id === cam.error_datapoint)  { cards[cam.id].error = state.val; renderCard(cam.id); return; }
         // History-States?
         for (let idx = 0; idx < HISTORY_SIZE; idx++) {
           const h = cam.history[idx];
           if (id === h.file_datapoint)      { cards[cam.id].hist[idx].file = state.val; renderCard(cam.id); return; }
           if (id === h.timestamp_datapoint) { cards[cam.id].hist[idx].timestamp = state.val; renderCard(cam.id); return; }
           if (id === h.id_datapoint)        { cards[cam.id].hist[idx].id = state.val; renderCard(cam.id); return; }
           if (id === h.source_datapoint)    { cards[cam.id].hist[idx].source = state.val; renderCard(cam.id); return; }
         }
       }
     });
    }).catch(e => { setStatus('Server-Fehler', 'err'); console.error(e); });
    </script>
    </body>
    </html>`;
    
    // ============================================================
    // Widget 3: History (10 Slots einer Kamera in einer Reihe)
    // ============================================================
    const HISTORY_HTML = `<!DOCTYPE html>
    <html lang="de">
    <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Blink History</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; flex-wrap:wrap; }
    .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; }
    select { background:#444; color:#eee; border:1px solid #555; padding:5px 8px;
            border-radius:6px; font-size:12px; }
    .player { background:#2a2a2a; border-radius:10px; overflow:hidden; margin-bottom:12px;
             box-shadow:0 4px 12px rgba(0,0,0,0.4); }
    .player video { width:100%; display:block; background:#000; max-height:540px; }
    .player .info { padding:10px 14px; font-size:12px; color:#bbb;
                   border-top:1px solid #444; display:flex; gap:12px; flex-wrap:wrap; align-items:center; }
    .player .info .src { padding:2px 6px; border-radius:4px; font-size:10px; background:#444; }
    .player .info .src.cloud { background:#2d4a6a; }
    .player .info .src.local_storage { background:#4a3a2d; }
    .player .empty { padding:80px 20px; text-align:center; color:#777; }
    
    .grid { display:grid; grid-template-columns:repeat(auto-fit, minmax(180px, 1fr));
           gap:10px; }
    .slot { background:#2a2a2a; border-radius:8px; overflow:hidden;
           cursor:pointer; border:2px solid transparent; transition:all 0.15s; }
    .slot:hover { border-color:#666; transform:translateY(-2px); }
    .slot.active { border-color:#4a8; }
    .slot.empty { opacity:0.3; cursor:not-allowed; }
    .slot-thumb { position:relative; aspect-ratio:16/9; background:#000; }
    .slot-thumb video { width:100%; height:100%; object-fit:cover; pointer-events:none; }
    .slot-thumb .badge { position:absolute; top:4px; left:4px;
                        background:rgba(0,0,0,0.7); color:white;
                        padding:2px 6px; border-radius:3px; font-size:10px; font-weight:500; }
    .slot-thumb .src-badge { position:absolute; top:4px; right:4px;
                            background:#444; color:white;
                            padding:2px 6px; border-radius:3px; font-size:9px; }
    .slot-thumb .src-badge.cloud { background:#2d4a6a; }
    .slot-thumb .src-badge.local_storage { background:#4a3a2d; }
    .slot-info { padding:6px 8px; font-size:11px; }
    .slot-info .time { color:#ddd; font-weight:500; }
    .slot-info .rel { color:#888; font-size:10px; }
    </style>
    </head>
    <body>
    <div class="topbar">
     <span class="title">📚 Verlauf</span>
     <select id="picker"></select>
     <span class="status" id="status">Verbinde…</span>
    </div>
    
    <div class="player" id="player">
     <div class="empty">Wähle einen Clip aus dem Verlauf</div>
    </div>
    
    <div class="grid" id="grid"></div>
    
    <script>
    __COMMON_JS__
    
    const params  = new URLSearchParams(location.search);
    const fixedCamera = params.get('camera');
    const $status = document.getElementById('status');
    const $picker = document.getElementById('picker');
    const $player = document.getElementById('player');
    const $grid   = document.getElementById('grid');
    
    let socket, currentCam = null, cameras = [];
    let histSlots = [];
    let pickIdx = null;
    
    function setStatus(t, c) { $status.textContent = t; $status.className = 'status' + (c?' '+c:''); }
    
    function renderPlayer() {
     if (pickIdx === null || !histSlots[pickIdx] || !histSlots[pickIdx].file) {
       $player.innerHTML = '<div class="empty">Wähle einen Clip aus dem Verlauf</div>';
       return;
     }
     const s = histSlots[pickIdx];
     const url = buildUrl(s.file, s.timestamp);
     if (!url) {
       $player.innerHTML = '<div class="empty">Datei nicht verfügbar</div>';
       return;
     }
     $player.innerHTML = '';
     const video = document.createElement('video');
     video.controls = true;
     video.autoplay = true;
     video.muted = false;
     video.playsInline = true;
     const source = document.createElement('source');
     source.setAttribute('src', url);
     source.setAttribute('type', 'video/mp4');
     video.appendChild(source);
     $player.appendChild(video);
     video.load();
     video.play().catch(() => { video.muted = true; video.play(); });
    
     const info = document.createElement('div');
     info.className = 'info';
     const slot = document.createElement('span');
     slot.innerHTML = '<strong>Slot #' + pickIdx + '</strong>';
     const time = document.createElement('span');
     time.textContent = '🕒 ' + (formatTs(s.timestamp) || '—');
     const rel = document.createElement('span');
     rel.textContent = relativeTime(s.timestamp);
     info.appendChild(slot);
     info.appendChild(time);
     info.appendChild(rel);
     if (s.source) {
       const src = document.createElement('span');
       src.className = 'src ' + s.source;
       src.textContent = s.source;
       info.appendChild(src);
     }
     $player.appendChild(info);
    }
    
    function renderGrid() {
     $grid.innerHTML = '';
     for (let i = 0; i < HISTORY_SIZE; i++) {
       const s = histSlots[i] || {};
       const slot = document.createElement('div');
       slot.className = 'slot' + (s.file ? '' : ' empty') + (pickIdx === i ? ' active' : '');
    
       const thumb = document.createElement('div');
       thumb.className = 'slot-thumb';
       const badge = document.createElement('div');
       badge.className = 'badge';
       badge.textContent = '#' + i;
       thumb.appendChild(badge);
    
       if (s.source) {
         const srcBadge = document.createElement('div');
         srcBadge.className = 'src-badge ' + s.source;
         srcBadge.textContent = s.source === 'cloud' ? '☁' : '📥';
         thumb.appendChild(srcBadge);
       }
    
       if (s.file) {
         const v = document.createElement('video');
         v.preload = 'metadata';
         v.muted = true;
         const sourceEl = document.createElement('source');
         sourceEl.setAttribute('src', buildUrl(s.file, s.timestamp));
         sourceEl.setAttribute('type', 'video/mp4');
         v.appendChild(sourceEl);
         thumb.appendChild(v);
       }
    
       const inf = document.createElement('div');
       inf.className = 'slot-info';
       const t = document.createElement('div');
       t.className = 'time';
       t.textContent = s.timestamp ? formatTs(s.timestamp).replace(/, \\d{4}/, '') : '—';
       const r = document.createElement('div');
       r.className = 'rel';
       r.textContent = relativeTime(s.timestamp) || (s.file ? '' : 'leer');
       inf.appendChild(t);
       inf.appendChild(r);
    
       slot.appendChild(thumb);
       slot.appendChild(inf);
    
       if (s.file) {
         slot.addEventListener('click', () => {
           pickIdx = (pickIdx === i) ? null : i;
           renderPlayer();
           renderGrid();
         });
       }
       $grid.appendChild(slot);
     }
    }
    
    function unsubscribeCurrent() {
     if (!currentCam || !socket) return;
     currentCam.history.forEach(h => {
       socket.emit('unsubscribe', h.file_datapoint);
       socket.emit('unsubscribe', h.timestamp_datapoint);
       socket.emit('unsubscribe', h.id_datapoint);
       socket.emit('unsubscribe', h.source_datapoint);
     });
    }
    
    function switchCamera(cam) {
     unsubscribeCurrent();
     currentCam = cam;
     pickIdx = null;
     histSlots = new Array(HISTORY_SIZE).fill(null).map(() => ({}));
     $player.innerHTML = '<div class="empty">Lade Verlauf…</div>';
     $grid.innerHTML = '';
    
     let pending = 4 * HISTORY_SIZE;
     const done = () => { if (--pending === 0) { renderGrid(); renderPlayer(); } };
    
     cam.history.forEach((h, idx) => {
       socket.emit('getState', h.file_datapoint, (e, st) => {
         histSlots[idx].file = st ? st.val : null; done();
       });
       socket.emit('getState', h.timestamp_datapoint, (e, st) => {
         histSlots[idx].timestamp = st ? st.val : null; done();
       });
       socket.emit('getState', h.id_datapoint, (e, st) => {
         histSlots[idx].id = st ? st.val : null; done();
       });
       socket.emit('getState', h.source_datapoint, (e, st) => {
         histSlots[idx].source = st ? st.val : null; done();
       });
       socket.emit('subscribe', h.file_datapoint);
       socket.emit('subscribe', h.timestamp_datapoint);
       socket.emit('subscribe', h.id_datapoint);
       socket.emit('subscribe', h.source_datapoint);
     });
    }
    
    fetch('/cameras').then(r => r.json()).then(list => {
     cameras = list;
     if (!cameras.length) {
       setStatus('Keine Kameras', 'err');
       return;
     }
     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);
     });
    
     const startCam = fixedCamera
       ? cameras.find(c => c.id === fixedCamera) || cameras[0]
       : cameras[0];
     $picker.value = startCam.id;
    
     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(startCam); });
     socket.on('disconnect', () => setStatus('Getrennt', 'err'));
     socket.on('connect_error', (e) => { setStatus('Verbindungsfehler', 'err'); console.error(e); });
     socket.on('stateChange', (id, state) => {
       if (!state || !currentCam) return;
       for (let idx = 0; idx < HISTORY_SIZE; idx++) {
         const h = currentCam.history[idx];
         if (id === h.file_datapoint)      { histSlots[idx].file = state.val; renderGrid(); return; }
         if (id === h.timestamp_datapoint) { histSlots[idx].timestamp = state.val; renderGrid(); return; }
         if (id === h.id_datapoint)        { histSlots[idx].id = state.val; renderGrid(); return; }
         if (id === h.source_datapoint)    { histSlots[idx].source = state.val; renderGrid(); return; }
       }
     });
    }).catch(e => { setStatus('Server-Fehler', 'err'); console.error(e); });
    </script>
    </body>
    </html>`;
    
    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)
           .replace(/__HISTORY_SIZE__/g,  HISTORY_SIZE);
    }
    const SINGLE_PAGE  = buildHTML(WIDGET_HTML);
    const GRID_PAGE    = buildHTML(GRID_HTML);
    const HISTORY_PAGE = buildHTML(HISTORY_HTML);
    
    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'
    };
    
    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',
                   'Cache-Control': 'no-cache, no-store, must-revalidate'
               });
               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 === '/history' || urlPath === '/history.html') {
           res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
           res.end(HISTORY_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;
    
           const noCacheHeaders = mimeType.startsWith('video/')
               ? {
                   'Cache-Control': 'no-cache, no-store, must-revalidate',
                   'Pragma':        'no-cache',
                   'Expires':       '0'
                 }
               : {};
    
           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, {
                   ...noCacheHeaders,
                   '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, {
                   ...noCacheHeaders,
                   '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 + History-Streifen)  ·  /grid (Multi mit Blättern)  ·  /history (Verlauf-Galerie)`);
    });
    server.on('error', (err) => log(`Blink-Server Fehler: ${err.message}`, 'error'));
    
    globalThis.__blinkServer = server;
    
    onStop(() => {
       if (server) { server.close(); log('Blink-Server gestoppt'); }
    }, 2000);
    

    2af22229-da6f-4a9c-999e-6655b43b3cc2-image.jpeg

    O sigi234S 2 Antworten Letzte Antwort
    3
    • HomoranH Homoran verschob dieses Thema von ioBroker Allgemein am
    • A Offline
      A Offline
      adarof
      schrieb am zuletzt editiert von
      #2

      Tut - einfach so. Danke!

      1 Antwort Letzte Antwort
      1
      • PischleuderP Pischleuder
        Aktuelle Testversion 0.0.10
        Github Link https://github.com/Pischleuder1/ioBroker.blink
        NPM Link https://www.npmjs.com/package/iobroker.blink
        Veröffentlichungsdatum 23.05.2026

        In den letzten Jahren gab es diverse BLINK Adapter für die Amazon Kameras, die jedoch nicht weiterentwickelt wurden und alle, mehr oder weniger, auf blinkpy basierten. Als Alternative funktionierten temporär python scripte mit blinkpy oder die Option über IFTTT.

        Zuletzt sind die meisten sicherlich am HAM Adapter oder Homeassistant hängen geblieben, um die Kameras zu steuern. Da Amazon jedoch wieder einmal an den API herumbastelt, funktioniert das tlw. nur noch suboptimal.
        Die Idee mit blinkpy zu arbeiten habe ich auf anraten des Forums verworfen und einen Adapter (unter Zuhilfenahme von KI) erstellt, um die gesamte Login - Logik im Adapter nachzubauen.
        c49e2058-65ac-4a20-ad62-e068827483e1-image.jpeg
        3801838a-66ff-4fc0-be85-e12b886d227a-image.jpeg

        Funktionen:
        • Kameras und Sync-Modul werden ausgelesen und die entsprechenden States etc. angezeigt
        • Temperaturanzeige über die Kamera in Grad Celsius und Fahrenheit
        • Batterienanzeige der Kamera, umgerechnet in Volt
        • bei geringem Batteriestand wird / kann eine pushover oder telegram Info gesendet werden
        • Snapshot von Bildern über commands in einen state bzw. auch lokal in den Ordner /opt/iobroker/iobroker-data/blink
        • Snapshot als image_base64 mit Zeitstempel
        • automatische Erzeugung von Snapshots nach Zeit über admin Bereich
        • motion detect
        • aktuell, von Blink gespeicherte Videos werden in die entsprechenden Datenpunkte geschrieben oder per fetch geholt (dafür muss das javascript eingebunden sein)
        • Speicherort kann im Admin Bereich festgelegt werden
        • Video Doorbell wird unterstützt, jedoch keine Temperaturanzeige, da diese offensichtlich bei der Doorbell nicht verbaut ist
        • unterstützt "Smart Detection states" für bestimmte Bewegungsvorgänge wie Paket, Tier etc. (funktioniert nur bei Abo-Modell)
        • unterstützt Cloud und Lokal gespeicherte Videos (SyncModule 2 und XR) via Server auf port 8085 - JavaScript benötigt, siehe unten !

        Was funktioniert (noch) nicht:
        • kein Echtzeit Video (in Arbeit)

        Visualisierung
        • ffmpeg muss installiert sein
        • Es werden auf einem Pi4 relativ große Ressourcen benötigt, 4 GB und mehr empfohlen
        • dieses Javascript einbinden:

        // ============================================================
        // Blink Multi-Camera Server + Widget
        //   http://<host>:8085/                  → Single + 10-Slot History darunter
        //   http://<host>:8085/?camera=548730    → Single für eine fixe Kamera
        //   http://<host>:8085/grid              → Alle Kameras im Grid (mit History-Blättern)
        //   http://<host>:8085/history?camera=ID → Reine History-Ansicht, 10 Slots in Reihe
        //   http://<host>:8085/blink/<file>      → Video-Datei
        //   http://<host>:8085/cameras           → JSON mit allen Kameras (inkl. History-Datenpunkten)
        // ============================================================
        
        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 TS_STATE      = '.video.timestamp';
        const READY_STATE   = '.video.ready';
        const ERROR_STATE   = '.video.lastError';
        const HISTORY_SIZE  = 10;
        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}*${NAME_STATE}]`).each((id) => {
                   const rest = id.slice(CAMERA_PREFIX.length);
                   const camId = rest.split('.')[0];
                   if (!seen.has(camId)) {
                       seen.add(camId);
                       const history = [];
                       for (let i = 0; i < HISTORY_SIZE; i++) {
                           history.push({
                               slot: i,
                               file_datapoint:      `${CAMERA_PREFIX}${camId}.video.history.${i}.file`,
                               timestamp_datapoint: `${CAMERA_PREFIX}${camId}.video.history.${i}.timestamp`,
                               id_datapoint:        `${CAMERA_PREFIX}${camId}.video.history.${i}.id`,
                               source_datapoint:    `${CAMERA_PREFIX}${camId}.video.history.${i}.source`
                           });
                       }
                       cams.push({
                           id: camId,
                           datapoint:       CAMERA_PREFIX + camId + VIDEO_STATE,
                           ts_datapoint:    CAMERA_PREFIX + camId + TS_STATE,
                           ready_datapoint: CAMERA_PREFIX + camId + READY_STATE,
                           error_datapoint: CAMERA_PREFIX + camId + ERROR_STATE,
                           history: history,
                           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).trim();
                       res();
                   });
               }));
               Promise.all(promises).then(() => resolve(cams.sort((a, b) =>
                   (a.name || a.id).localeCompare(b.name || b.id)
               )));
           });
        }
        
        // ============================================================
        // Gemeinsamer JS-Helper-Code
        // ============================================================
        const COMMON_JS = `
        const VIDEO_PREFIX = location.protocol + '//' + location.hostname + ':__VIDEO_PORT__' + '__VIDEO_BASE__';
        const IOBROKER_URL = location.protocol + '//' + location.hostname + ':__IOBROKER_PORT__';
        const HISTORY_SIZE = __HISTORY_SIZE__;
        
        function buildUrl(v, ts) {
         if (!v) return null;
         if (/^https?:\\/\\//.test(v)) return v;
         const fn = encodeURIComponent(String(v).split('/').pop());
         return VIDEO_PREFIX + fn + '?t=' + (ts || Date.now());
        }
        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 formatTs(isoOrMs) {
         if (!isoOrMs) return '';
         const d = new Date(isoOrMs);
         if (isNaN(d.getTime())) return '';
         return d.toLocaleString('de-DE', {
           day: '2-digit', month: '2-digit', year: 'numeric',
           hour: '2-digit', minute: '2-digit', second: '2-digit'
         });
        }
        function relativeTime(ms) {
         if (!ms) return '';
         const diff = Math.max(0, Date.now() - (typeof ms === 'string' ? new Date(ms).getTime() : 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');
        }
        
        function isVideoValid(value, ready, lastError) {
         if (!value) return false;
         if (lastError && String(lastError).trim() !== '' && String(lastError).toLowerCase() !== 'null') return false;
         if (ready === false) return false;
         return true;
        }
        `;
        
        // ============================================================
        // Widget 1: Single + History-Streifen darunter
        // ============================================================
        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:900px; 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; }
        .info .err { color:#e88; font-weight:500; }
        .info .source { display:inline-block; padding:2px 6px; border-radius:4px;
                       font-size:10px; background:#444; margin-left:6px; vertical-align:middle; }
        .info .source.cloud { background:#2d4a6a; }
        .info .source.local_storage { background:#4a3a2d; }
        .empty { padding:60px 20px; text-align:center; color:#777; }
        .empty .err-msg { color:#e88; font-size:13px; margin-top:8px; }
        .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; }
        
        /* History-Streifen */
        .history-section { border-top:1px solid #444; padding:12px 16px; background:#252525; }
        .history-section h3 { font-size:12px; color:#999; margin-bottom:8px; font-weight:500; }
        .history-strip { display:flex; gap:6px; overflow-x:auto; padding-bottom:4px; }
        .history-strip::-webkit-scrollbar { height:6px; }
        .history-strip::-webkit-scrollbar-thumb { background:#555; border-radius:3px; }
        .hslot { flex:0 0 130px; background:#1a1a1a; border-radius:6px; cursor:pointer;
                border:2px solid transparent; transition:border-color 0.15s, transform 0.15s; }
        .hslot:hover { border-color:#666; transform:translateY(-2px); }
        .hslot.active { border-color:#4a8; }
        .hslot.empty { opacity:0.3; cursor:not-allowed; }
        .hslot-thumb { position:relative; aspect-ratio:16/9; background:#000; border-radius:4px 4px 0 0;
                      overflow:hidden; }
        .hslot-thumb video { width:100%; height:100%; object-fit:cover; pointer-events:none; }
        .hslot-thumb .badge { position:absolute; top:4px; left:4px; background:rgba(0,0,0,0.7);
                            color:white; padding:1px 5px; border-radius:3px; font-size:10px; }
        .hslot-info { padding:4px 6px; font-size:10px; color:#aaa; line-height:1.3; }
        .hslot-info .time { color:#ddd; font-weight:500; }
        .hslot-info .src { font-size:9px; color:#888; }
        </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 class="history-section" id="history-section" style="display:none">
           <h3>📚 Verlauf (10 neueste)</h3>
           <div class="history-strip" id="history-strip"></div>
         </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');
        const $histSection = document.getElementById('history-section');
        const $histStrip = document.getElementById('history-strip');
        
        let socket, currentCam = null, cameras = [];
        let curValue = null, curTs = null, curReady = null, curError = null;
        // History-State pro Kamera-Wechsel neu aufgebaut
        let histSlots = []; // [{file, timestamp, id, source}, ...]
        let manualPick = null; // null = aktuellen Live-Clip zeigen, sonst Slot-Index
        
        function setStatus(t, c) { $status.textContent = t; $status.className = 'status' + (c?' '+c:''); }
        function updateRelativeTime() { $reltime.textContent = curTs ? relativeTime(curTs) : ''; }
        
        function renderEmpty(msg, errMsg) {
         let html = '<div class="empty">' + msg;
         if (errMsg) html += '<div class="err-msg">⚠ ' + errMsg + '</div>';
         html += '</div>';
         $player.innerHTML = html;
         $info.textContent = '';
        }
        
        function renderCurrent() {
         updateRelativeTime();
        
         // Wenn manuell ein History-Slot gewählt, den dort gezeigten Clip verwenden
         let showFile, showTs, showSource, showInfo;
         if (manualPick !== null && histSlots[manualPick] && histSlots[manualPick].file) {
           const s = histSlots[manualPick];
           showFile = s.file;
           showTs = s.timestamp;
           showSource = s.source;
           showInfo = 'Slot ' + manualPick;
         } else if (isVideoValid(curValue, curReady, curError)) {
           showFile = curValue;
           showTs = curTs;
           showSource = null;
           showInfo = 'Live';
         } else {
           const errText = curError && String(curError).trim() && String(curError).toLowerCase() !== 'null'
             ? String(curError) : null;
           renderEmpty('Kein aktuelles Video', errText);
           refreshHistoryStrip();
           return;
         }
        
         const url = buildUrl(showFile, showTs);
         if (!url) { renderEmpty('Kein Video verfügbar'); return; }
        
         const fn = String(showFile).split('/').pop();
        
         $player.innerHTML = '';
         const video = document.createElement('video');
         video.controls = true;
         video.autoplay = true;
         video.muted = true;
         video.playsInline = true;
         const source = document.createElement('source');
         source.setAttribute('src', url);
         source.setAttribute('type', 'video/mp4');
         video.appendChild(source);
         $player.appendChild(video);
         video.load();
        
         const ts = formatTs(showTs);
         let infoHtml = '<div class="ts">🕒 ' + (ts || '—') + ' · ' + showInfo;
         if (showSource) {
           infoHtml += '<span class="source ' + showSource + '">' + showSource + '</span>';
         }
         infoHtml += '</div><div>' + fn + '</div>';
         $info.innerHTML = infoHtml;
        
         refreshHistoryStrip();
        }
        
        function refreshHistoryStrip() {
         $histSection.style.display = '';
         $histStrip.innerHTML = '';
         for (let i = 0; i < HISTORY_SIZE; i++) {
           const s = histSlots[i] || {};
           const slot = document.createElement('div');
           slot.className = 'hslot' + (s.file ? '' : ' empty') + (manualPick === i ? ' active' : '');
           slot.dataset.slot = i;
        
           const thumb = document.createElement('div');
           thumb.className = 'hslot-thumb';
           const badge = document.createElement('div');
           badge.className = 'badge';
           badge.textContent = '#' + i;
           thumb.appendChild(badge);
        
           if (s.file) {
             const v = document.createElement('video');
             v.preload = 'metadata';
             v.muted = true;
             const sourceEl = document.createElement('source');
             sourceEl.setAttribute('src', buildUrl(s.file, s.timestamp));
             sourceEl.setAttribute('type', 'video/mp4');
             v.appendChild(sourceEl);
             thumb.appendChild(v);
           }
        
           const inf = document.createElement('div');
           inf.className = 'hslot-info';
           const t = document.createElement('div');
           t.className = 'time';
           t.textContent = s.timestamp ? formatTs(s.timestamp).replace(/, \\d{4}/, '') : '—';
           const src = document.createElement('div');
           src.className = 'src';
           src.textContent = s.source || (s.file ? '' : 'leer');
           inf.appendChild(t);
           inf.appendChild(src);
        
           slot.appendChild(thumb);
           slot.appendChild(inf);
        
           if (s.file) {
             slot.addEventListener('click', () => {
               manualPick = (manualPick === i) ? null : i;
               renderCurrent();
             });
           }
           $histStrip.appendChild(slot);
         }
        }
        
        $reload.addEventListener('click', () => { manualPick = null; renderCurrent(); });
        setInterval(updateRelativeTime, 30000);
        
        function unsubscribeCurrent() {
         if (!currentCam || !socket) return;
         socket.emit('unsubscribe', currentCam.datapoint);
         socket.emit('unsubscribe', currentCam.ts_datapoint);
         socket.emit('unsubscribe', currentCam.ready_datapoint);
         socket.emit('unsubscribe', currentCam.error_datapoint);
         currentCam.history.forEach(h => {
           socket.emit('unsubscribe', h.file_datapoint);
           socket.emit('unsubscribe', h.timestamp_datapoint);
           socket.emit('unsubscribe', h.id_datapoint);
           socket.emit('unsubscribe', h.source_datapoint);
         });
        }
        
        function switchCamera(cam) {
         unsubscribeCurrent();
         currentCam = cam;
         manualPick = null;
         histSlots = new Array(HISTORY_SIZE).fill(null).map(() => ({}));
         $title.textContent = '📹 ' + (cam.name || 'Kamera ' + cam.id);
         $player.innerHTML = '<div class="empty">Lade Video…</div>';
         $info.textContent = '';
         curValue = null; curTs = null; curReady = null; curError = null;
         updateRelativeTime();
        
         // Live-States: 4 parallel
         let pending = 4 + 4 * HISTORY_SIZE;
         const done = () => { if (--pending === 0) renderCurrent(); };
        
         socket.emit('getState', cam.ts_datapoint, (e, st) => {
           if (st && st.val) curTs = st.val;
           done();
         });
         socket.emit('getState', cam.ready_datapoint, (e, st) => {
           if (st) curReady = st.val;
           done();
         });
         socket.emit('getState', cam.error_datapoint, (e, st) => {
           if (st) curError = st.val;
           done();
         });
         socket.emit('getState', cam.datapoint, (e, st) => {
           if (st) curValue = st.val;
           done();
         });
        
         socket.emit('subscribe', cam.datapoint);
         socket.emit('subscribe', cam.ts_datapoint);
         socket.emit('subscribe', cam.ready_datapoint);
         socket.emit('subscribe', cam.error_datapoint);
        
         // History-States: 10 Slots × 4 Felder
         cam.history.forEach((h, idx) => {
           socket.emit('getState', h.file_datapoint, (e, st) => {
             histSlots[idx].file = st ? st.val : null; done();
           });
           socket.emit('getState', h.timestamp_datapoint, (e, st) => {
             histSlots[idx].timestamp = st ? st.val : null; done();
           });
           socket.emit('getState', h.id_datapoint, (e, st) => {
             histSlots[idx].id = st ? st.val : null; done();
           });
           socket.emit('getState', h.source_datapoint, (e, st) => {
             histSlots[idx].source = st ? st.val : null; done();
           });
           socket.emit('subscribe', h.file_datapoint);
           socket.emit('subscribe', h.timestamp_datapoint);
           socket.emit('subscribe', h.id_datapoint);
           socket.emit('subscribe', h.source_datapoint);
         });
        }
        
        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 findHistorySlot(id) {
         if (!currentCam) return -1;
         return currentCam.history.findIndex(h =>
           id === h.file_datapoint || id === h.timestamp_datapoint ||
           id === h.id_datapoint || id === h.source_datapoint);
        }
        
        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 (!state || !currentCam) return;
           if (id === currentCam.datapoint)        { curValue = state.val; renderCurrent(); return; }
           if (id === currentCam.ts_datapoint)     { if (state.val) curTs = state.val; renderCurrent(); return; }
           if (id === currentCam.ready_datapoint)  { curReady = state.val; renderCurrent(); return; }
           if (id === currentCam.error_datapoint)  { curError = state.val; renderCurrent(); return; }
        
           const slot = findHistorySlot(id);
           if (slot >= 0) {
             const h = currentCam.history[slot];
             if (id === h.file_datapoint)      histSlots[slot].file = state.val;
             else if (id === h.timestamp_datapoint) histSlots[slot].timestamp = state.val;
             else if (id === h.id_datapoint)        histSlots[slot].id = state.val;
             else if (id === h.source_datapoint)    histSlots[slot].source = state.val;
             refreshHistoryStrip();
           }
         });
        }
        </script>
        </body>
        </html>`;
        
        // ============================================================
        // Widget 2: Grid (alle Kameras, History per Klick durchblättern)
        // ============================================================
        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(300px, 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-content { display:block; position:relative; }
        .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-video-wrap .slot-badge {
         position:absolute; top:6px; left:6px;
         background:rgba(0,0,0,0.7); color:white;
         padding:2px 8px; border-radius:4px; font-size:11px; font-weight:500;
         pointer-events:none;
        }
        .cam-empty { aspect-ratio:16/9; display:flex; flex-direction:column;
                    align-items:center; justify-content:center; gap:6px;
                    color:#888; font-size:13px; background:#1a1a1a; padding:8px; text-align:center; }
        .cam-empty .err-msg { color:#e88; font-size:11px; }
        
        /* History-Navigation */
        .cam-nav { display:flex; align-items:center; justify-content:space-between;
                  padding:6px 8px; background:#222; gap:6px; }
        .cam-nav button { background:#444; color:#eee; border:none;
                         padding:4px 10px; border-radius:4px; font-size:11px; cursor:pointer; }
        .cam-nav button:hover { background:#555; }
        .cam-nav button:disabled { opacity:0.4; cursor:not-allowed; }
        .cam-nav .label { font-size:11px; color:#bbb; flex:1; text-align:center;
                         overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
        .cam-nav .label .source { font-size:9px; padding:1px 4px; border-radius:3px;
                                 background:#444; margin-left:4px; vertical-align:middle; }
        .cam-nav .label .source.cloud { background:#2d4a6a; }
        .cam-nav .label .source.local_storage { background:#4a3a2d; }
        </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, ready, error, hist[], pickIdx, root, contentHost, ...}
        
        function setStatus(t, c) { $status.textContent = t; $status.className = 'status' + (c?' '+c:''); }
        
        function buildCard(cam) {
         const card = document.createElement('div');
         card.className = 'cam';
         card.dataset.cam = cam.id;
        
         const head = document.createElement('div');
         head.className = 'cam-head';
         const nameSpan = document.createElement('span');
         nameSpan.className = 'cam-name';
         nameSpan.textContent = cam.name || ('Kamera ' + cam.id);
         const timeSpan = document.createElement('span');
         timeSpan.className = 'cam-time';
         head.appendChild(nameSpan);
         head.appendChild(timeSpan);
        
         const contentHost = document.createElement('div');
         contentHost.className = 'cam-content';
        
         const empty = document.createElement('div');
         empty.className = 'cam-empty';
         empty.textContent = 'Lade…';
         contentHost.appendChild(empty);
        
         // History-Navigation
         const nav = document.createElement('div');
         nav.className = 'cam-nav';
         const prevBtn = document.createElement('button');
         prevBtn.textContent = '◀';
         prevBtn.title = 'Älterer Clip';
         const label = document.createElement('div');
         label.className = 'label';
         label.textContent = 'Live';
         const nextBtn = document.createElement('button');
         nextBtn.textContent = '▶';
         nextBtn.title = 'Neuerer Clip';
         const liveBtn = document.createElement('button');
         liveBtn.textContent = '⏺ Live';
         liveBtn.title = 'Live-Ansicht';
         nav.appendChild(prevBtn);
         nav.appendChild(label);
         nav.appendChild(liveBtn);
         nav.appendChild(nextBtn);
        
         card.appendChild(head);
         card.appendChild(contentHost);
         card.appendChild(nav);
         $grid.appendChild(card);
        
         cards[cam.id] = {
           value: null, ts: null, ready: null, error: null,
           hist: new Array(HISTORY_SIZE).fill(null).map(() => ({})),
           pickIdx: null,  // null = Live, 0..9 = History-Slot
           root: card, contentHost: contentHost, timeEl: timeSpan,
           prevBtn, nextBtn, liveBtn, label,
           datapoint:       cam.datapoint,
           ts_datapoint:    cam.ts_datapoint,
           ready_datapoint: cam.ready_datapoint,
           error_datapoint: cam.error_datapoint,
           history:         cam.history,
           name: cam.name || ('Kamera ' + cam.id)
         };
        
         prevBtn.addEventListener('click', () => navigate(cam.id, +1));
         nextBtn.addEventListener('click', () => navigate(cam.id, -1));
         liveBtn.addEventListener('click', () => { cards[cam.id].pickIdx = null; renderCard(cam.id); });
        }
        
        function navigate(camId, delta) {
         const c = cards[camId];
         if (!c) return;
         let next = c.pickIdx == null ? 0 : c.pickIdx + delta;
         if (next < 0) { c.pickIdx = null; renderCard(camId); return; }
         if (next >= HISTORY_SIZE) next = HISTORY_SIZE - 1;
         // Suche nächsten Slot mit Datei
         while (next >= 0 && next < HISTORY_SIZE && !c.hist[next].file) next += delta;
         if (next >= 0 && next < HISTORY_SIZE) {
           c.pickIdx = next;
         } else {
           c.pickIdx = null;
         }
         renderCard(camId);
        }
        
        function setEmpty(c, text, errMsg) {
         while (c.contentHost.firstChild) c.contentHost.removeChild(c.contentHost.firstChild);
         const empty = document.createElement('div');
         empty.className = 'cam-empty';
         const main = document.createElement('div');
         main.textContent = text;
         empty.appendChild(main);
         if (errMsg) {
           const sub = document.createElement('div');
           sub.className = 'err-msg';
           sub.textContent = '⚠ ' + errMsg;
           empty.appendChild(sub);
         }
         c.contentHost.appendChild(empty);
        }
        
        function renderCard(camId) {
         const c = cards[camId];
         if (!c) return;
        
         if (c.timeEl) c.timeEl.textContent = c.ts ? relativeTime(c.ts) : '';
        
         let showFile, showTs, showSource, slotLabel;
         if (c.pickIdx !== null && c.hist[c.pickIdx] && c.hist[c.pickIdx].file) {
           const h = c.hist[c.pickIdx];
           showFile = h.file;
           showTs = h.timestamp;
           showSource = h.source;
           slotLabel = '#' + c.pickIdx;
         } else if (isVideoValid(c.value, c.ready, c.error)) {
           showFile = c.value;
           showTs = c.ts;
           showSource = null;
           slotLabel = null; // Live
           c.pickIdx = null;
         } else {
           const errText = c.error && String(c.error).trim() && String(c.error).toLowerCase() !== 'null'
             ? String(c.error) : null;
           setEmpty(c, 'Kein aktuelles Video', errText);
           updateNav(c);
           return;
         }
        
         const url = buildUrl(showFile, showTs);
         if (!url) { setEmpty(c, 'Kein Video'); updateNav(c); return; }
        
         const wrap = document.createElement('div');
         wrap.className = 'cam-video-wrap';
         wrap.dataset.cam = camId;
        
         const video = document.createElement('video');
         video.preload = 'metadata';
         video.muted = true;
         video.playsInline = true;
         video.dataset.cam = camId;
        
         const source = document.createElement('source');
         source.setAttribute('src', url);
         source.setAttribute('type', 'video/mp4');
         video.appendChild(source);
        
         const overlay = document.createElement('div');
         overlay.className = 'overlay';
         const playBtn = document.createElement('div');
         playBtn.className = 'play-btn';
         overlay.appendChild(playBtn);
        
         wrap.appendChild(video);
         wrap.appendChild(overlay);
        
         if (slotLabel) {
           const badge = document.createElement('div');
           badge.className = 'slot-badge';
           badge.textContent = slotLabel;
           wrap.appendChild(badge);
         }
        
         wrap.addEventListener('click', () => {
           if (video.paused) {
             video.controls = true;
             wrap.classList.add('playing');
             video.muted = false;
             video.play().catch(() => { 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'); });
        
         while (c.contentHost.firstChild) c.contentHost.removeChild(c.contentHost.firstChild);
         c.contentHost.appendChild(wrap);
         video.load();
        
         // Label + Quelle anzeigen
         let lbl = (slotLabel ? (slotLabel + ' · ') : 'Live · ') + (formatTs(showTs).replace(/, \\d{4}/, '') || '—');
         if (showSource) lbl += '<span class="source ' + showSource + '">' + showSource + '</span>';
         c.label.innerHTML = lbl;
        
         updateNav(c);
        }
        
        function updateNav(c) {
         // ◀ wäre älter (höhere Index-Zahl), ▶ wäre neuer
         const nextIdx = c.pickIdx == null ? 0 : c.pickIdx + 1;
         const prevIdx = c.pickIdx == null ? null : c.pickIdx - 1;
         c.prevBtn.disabled = !c.hist.slice(nextIdx).some(h => h && h.file);
         c.nextBtn.disabled = c.pickIdx === null;
        }
        
        function updateAllTimes() {
         Object.keys(cards).forEach(id => {
           const c = cards[id];
           if (c.timeEl) c.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 => {
             let pending = 4 + 4 * HISTORY_SIZE;
             const done = () => { if (--pending === 0) renderCard(cam.id); };
        
             socket.emit('getState', cam.ts_datapoint, (e, st) => {
               if (st && st.val) cards[cam.id].ts = st.val;
               done();
             });
             socket.emit('getState', cam.ready_datapoint, (e, st) => {
               if (st) cards[cam.id].ready = st.val;
               done();
             });
             socket.emit('getState', cam.error_datapoint, (e, st) => {
               if (st) cards[cam.id].error = st.val;
               done();
             });
             socket.emit('getState', cam.datapoint, (e, st) => {
               if (st) cards[cam.id].value = st.val;
               done();
             });
        
             socket.emit('subscribe', cam.datapoint);
             socket.emit('subscribe', cam.ts_datapoint);
             socket.emit('subscribe', cam.ready_datapoint);
             socket.emit('subscribe', cam.error_datapoint);
        
             cam.history.forEach((h, idx) => {
               socket.emit('getState', h.file_datapoint, (e, st) => {
                 cards[cam.id].hist[idx].file = st ? st.val : null; done();
               });
               socket.emit('getState', h.timestamp_datapoint, (e, st) => {
                 cards[cam.id].hist[idx].timestamp = st ? st.val : null; done();
               });
               socket.emit('getState', h.id_datapoint, (e, st) => {
                 cards[cam.id].hist[idx].id = st ? st.val : null; done();
               });
               socket.emit('getState', h.source_datapoint, (e, st) => {
                 cards[cam.id].hist[idx].source = st ? st.val : null; done();
               });
               socket.emit('subscribe', h.file_datapoint);
               socket.emit('subscribe', h.timestamp_datapoint);
               socket.emit('subscribe', h.id_datapoint);
               socket.emit('subscribe', h.source_datapoint);
             });
           });
         });
         socket.on('disconnect',    () => setStatus('Getrennt', 'err'));
         socket.on('connect_error', (e) => { setStatus('Verbindungsfehler', 'err'); console.error(e); });
         socket.on('stateChange', (id, state) => {
           if (!state) return;
           for (const cam of list) {
             if (id === cam.datapoint)        { cards[cam.id].value = state.val; cards[cam.id].ts = state.ts || cards[cam.id].ts; renderCard(cam.id); return; }
             if (id === cam.ts_datapoint)     { if (state.val) cards[cam.id].ts = state.val; renderCard(cam.id); return; }
             if (id === cam.ready_datapoint)  { cards[cam.id].ready = state.val; renderCard(cam.id); return; }
             if (id === cam.error_datapoint)  { cards[cam.id].error = state.val; renderCard(cam.id); return; }
             // History-States?
             for (let idx = 0; idx < HISTORY_SIZE; idx++) {
               const h = cam.history[idx];
               if (id === h.file_datapoint)      { cards[cam.id].hist[idx].file = state.val; renderCard(cam.id); return; }
               if (id === h.timestamp_datapoint) { cards[cam.id].hist[idx].timestamp = state.val; renderCard(cam.id); return; }
               if (id === h.id_datapoint)        { cards[cam.id].hist[idx].id = state.val; renderCard(cam.id); return; }
               if (id === h.source_datapoint)    { cards[cam.id].hist[idx].source = state.val; renderCard(cam.id); return; }
             }
           }
         });
        }).catch(e => { setStatus('Server-Fehler', 'err'); console.error(e); });
        </script>
        </body>
        </html>`;
        
        // ============================================================
        // Widget 3: History (10 Slots einer Kamera in einer Reihe)
        // ============================================================
        const HISTORY_HTML = `<!DOCTYPE html>
        <html lang="de">
        <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Blink History</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; flex-wrap:wrap; }
        .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; }
        select { background:#444; color:#eee; border:1px solid #555; padding:5px 8px;
                border-radius:6px; font-size:12px; }
        .player { background:#2a2a2a; border-radius:10px; overflow:hidden; margin-bottom:12px;
                 box-shadow:0 4px 12px rgba(0,0,0,0.4); }
        .player video { width:100%; display:block; background:#000; max-height:540px; }
        .player .info { padding:10px 14px; font-size:12px; color:#bbb;
                       border-top:1px solid #444; display:flex; gap:12px; flex-wrap:wrap; align-items:center; }
        .player .info .src { padding:2px 6px; border-radius:4px; font-size:10px; background:#444; }
        .player .info .src.cloud { background:#2d4a6a; }
        .player .info .src.local_storage { background:#4a3a2d; }
        .player .empty { padding:80px 20px; text-align:center; color:#777; }
        
        .grid { display:grid; grid-template-columns:repeat(auto-fit, minmax(180px, 1fr));
               gap:10px; }
        .slot { background:#2a2a2a; border-radius:8px; overflow:hidden;
               cursor:pointer; border:2px solid transparent; transition:all 0.15s; }
        .slot:hover { border-color:#666; transform:translateY(-2px); }
        .slot.active { border-color:#4a8; }
        .slot.empty { opacity:0.3; cursor:not-allowed; }
        .slot-thumb { position:relative; aspect-ratio:16/9; background:#000; }
        .slot-thumb video { width:100%; height:100%; object-fit:cover; pointer-events:none; }
        .slot-thumb .badge { position:absolute; top:4px; left:4px;
                            background:rgba(0,0,0,0.7); color:white;
                            padding:2px 6px; border-radius:3px; font-size:10px; font-weight:500; }
        .slot-thumb .src-badge { position:absolute; top:4px; right:4px;
                                background:#444; color:white;
                                padding:2px 6px; border-radius:3px; font-size:9px; }
        .slot-thumb .src-badge.cloud { background:#2d4a6a; }
        .slot-thumb .src-badge.local_storage { background:#4a3a2d; }
        .slot-info { padding:6px 8px; font-size:11px; }
        .slot-info .time { color:#ddd; font-weight:500; }
        .slot-info .rel { color:#888; font-size:10px; }
        </style>
        </head>
        <body>
        <div class="topbar">
         <span class="title">📚 Verlauf</span>
         <select id="picker"></select>
         <span class="status" id="status">Verbinde…</span>
        </div>
        
        <div class="player" id="player">
         <div class="empty">Wähle einen Clip aus dem Verlauf</div>
        </div>
        
        <div class="grid" id="grid"></div>
        
        <script>
        __COMMON_JS__
        
        const params  = new URLSearchParams(location.search);
        const fixedCamera = params.get('camera');
        const $status = document.getElementById('status');
        const $picker = document.getElementById('picker');
        const $player = document.getElementById('player');
        const $grid   = document.getElementById('grid');
        
        let socket, currentCam = null, cameras = [];
        let histSlots = [];
        let pickIdx = null;
        
        function setStatus(t, c) { $status.textContent = t; $status.className = 'status' + (c?' '+c:''); }
        
        function renderPlayer() {
         if (pickIdx === null || !histSlots[pickIdx] || !histSlots[pickIdx].file) {
           $player.innerHTML = '<div class="empty">Wähle einen Clip aus dem Verlauf</div>';
           return;
         }
         const s = histSlots[pickIdx];
         const url = buildUrl(s.file, s.timestamp);
         if (!url) {
           $player.innerHTML = '<div class="empty">Datei nicht verfügbar</div>';
           return;
         }
         $player.innerHTML = '';
         const video = document.createElement('video');
         video.controls = true;
         video.autoplay = true;
         video.muted = false;
         video.playsInline = true;
         const source = document.createElement('source');
         source.setAttribute('src', url);
         source.setAttribute('type', 'video/mp4');
         video.appendChild(source);
         $player.appendChild(video);
         video.load();
         video.play().catch(() => { video.muted = true; video.play(); });
        
         const info = document.createElement('div');
         info.className = 'info';
         const slot = document.createElement('span');
         slot.innerHTML = '<strong>Slot #' + pickIdx + '</strong>';
         const time = document.createElement('span');
         time.textContent = '🕒 ' + (formatTs(s.timestamp) || '—');
         const rel = document.createElement('span');
         rel.textContent = relativeTime(s.timestamp);
         info.appendChild(slot);
         info.appendChild(time);
         info.appendChild(rel);
         if (s.source) {
           const src = document.createElement('span');
           src.className = 'src ' + s.source;
           src.textContent = s.source;
           info.appendChild(src);
         }
         $player.appendChild(info);
        }
        
        function renderGrid() {
         $grid.innerHTML = '';
         for (let i = 0; i < HISTORY_SIZE; i++) {
           const s = histSlots[i] || {};
           const slot = document.createElement('div');
           slot.className = 'slot' + (s.file ? '' : ' empty') + (pickIdx === i ? ' active' : '');
        
           const thumb = document.createElement('div');
           thumb.className = 'slot-thumb';
           const badge = document.createElement('div');
           badge.className = 'badge';
           badge.textContent = '#' + i;
           thumb.appendChild(badge);
        
           if (s.source) {
             const srcBadge = document.createElement('div');
             srcBadge.className = 'src-badge ' + s.source;
             srcBadge.textContent = s.source === 'cloud' ? '☁' : '📥';
             thumb.appendChild(srcBadge);
           }
        
           if (s.file) {
             const v = document.createElement('video');
             v.preload = 'metadata';
             v.muted = true;
             const sourceEl = document.createElement('source');
             sourceEl.setAttribute('src', buildUrl(s.file, s.timestamp));
             sourceEl.setAttribute('type', 'video/mp4');
             v.appendChild(sourceEl);
             thumb.appendChild(v);
           }
        
           const inf = document.createElement('div');
           inf.className = 'slot-info';
           const t = document.createElement('div');
           t.className = 'time';
           t.textContent = s.timestamp ? formatTs(s.timestamp).replace(/, \\d{4}/, '') : '—';
           const r = document.createElement('div');
           r.className = 'rel';
           r.textContent = relativeTime(s.timestamp) || (s.file ? '' : 'leer');
           inf.appendChild(t);
           inf.appendChild(r);
        
           slot.appendChild(thumb);
           slot.appendChild(inf);
        
           if (s.file) {
             slot.addEventListener('click', () => {
               pickIdx = (pickIdx === i) ? null : i;
               renderPlayer();
               renderGrid();
             });
           }
           $grid.appendChild(slot);
         }
        }
        
        function unsubscribeCurrent() {
         if (!currentCam || !socket) return;
         currentCam.history.forEach(h => {
           socket.emit('unsubscribe', h.file_datapoint);
           socket.emit('unsubscribe', h.timestamp_datapoint);
           socket.emit('unsubscribe', h.id_datapoint);
           socket.emit('unsubscribe', h.source_datapoint);
         });
        }
        
        function switchCamera(cam) {
         unsubscribeCurrent();
         currentCam = cam;
         pickIdx = null;
         histSlots = new Array(HISTORY_SIZE).fill(null).map(() => ({}));
         $player.innerHTML = '<div class="empty">Lade Verlauf…</div>';
         $grid.innerHTML = '';
        
         let pending = 4 * HISTORY_SIZE;
         const done = () => { if (--pending === 0) { renderGrid(); renderPlayer(); } };
        
         cam.history.forEach((h, idx) => {
           socket.emit('getState', h.file_datapoint, (e, st) => {
             histSlots[idx].file = st ? st.val : null; done();
           });
           socket.emit('getState', h.timestamp_datapoint, (e, st) => {
             histSlots[idx].timestamp = st ? st.val : null; done();
           });
           socket.emit('getState', h.id_datapoint, (e, st) => {
             histSlots[idx].id = st ? st.val : null; done();
           });
           socket.emit('getState', h.source_datapoint, (e, st) => {
             histSlots[idx].source = st ? st.val : null; done();
           });
           socket.emit('subscribe', h.file_datapoint);
           socket.emit('subscribe', h.timestamp_datapoint);
           socket.emit('subscribe', h.id_datapoint);
           socket.emit('subscribe', h.source_datapoint);
         });
        }
        
        fetch('/cameras').then(r => r.json()).then(list => {
         cameras = list;
         if (!cameras.length) {
           setStatus('Keine Kameras', 'err');
           return;
         }
         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);
         });
        
         const startCam = fixedCamera
           ? cameras.find(c => c.id === fixedCamera) || cameras[0]
           : cameras[0];
         $picker.value = startCam.id;
        
         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(startCam); });
         socket.on('disconnect', () => setStatus('Getrennt', 'err'));
         socket.on('connect_error', (e) => { setStatus('Verbindungsfehler', 'err'); console.error(e); });
         socket.on('stateChange', (id, state) => {
           if (!state || !currentCam) return;
           for (let idx = 0; idx < HISTORY_SIZE; idx++) {
             const h = currentCam.history[idx];
             if (id === h.file_datapoint)      { histSlots[idx].file = state.val; renderGrid(); return; }
             if (id === h.timestamp_datapoint) { histSlots[idx].timestamp = state.val; renderGrid(); return; }
             if (id === h.id_datapoint)        { histSlots[idx].id = state.val; renderGrid(); return; }
             if (id === h.source_datapoint)    { histSlots[idx].source = state.val; renderGrid(); return; }
           }
         });
        }).catch(e => { setStatus('Server-Fehler', 'err'); console.error(e); });
        </script>
        </body>
        </html>`;
        
        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)
               .replace(/__HISTORY_SIZE__/g,  HISTORY_SIZE);
        }
        const SINGLE_PAGE  = buildHTML(WIDGET_HTML);
        const GRID_PAGE    = buildHTML(GRID_HTML);
        const HISTORY_PAGE = buildHTML(HISTORY_HTML);
        
        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'
        };
        
        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',
                       'Cache-Control': 'no-cache, no-store, must-revalidate'
                   });
                   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 === '/history' || urlPath === '/history.html') {
               res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
               res.end(HISTORY_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;
        
               const noCacheHeaders = mimeType.startsWith('video/')
                   ? {
                       'Cache-Control': 'no-cache, no-store, must-revalidate',
                       'Pragma':        'no-cache',
                       'Expires':       '0'
                     }
                   : {};
        
               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, {
                       ...noCacheHeaders,
                       '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, {
                       ...noCacheHeaders,
                       '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 + History-Streifen)  ·  /grid (Multi mit Blättern)  ·  /history (Verlauf-Galerie)`);
        });
        server.on('error', (err) => log(`Blink-Server Fehler: ${err.message}`, 'error'));
        
        globalThis.__blinkServer = server;
        
        onStop(() => {
           if (server) { server.close(); log('Blink-Server gestoppt'); }
        }, 2000);
        

        2af22229-da6f-4a9c-999e-6655b43b3cc2-image.jpeg

        O Offline
        O Offline
        oFbEQnpoLKKl6mbY5e13
        schrieb am zuletzt editiert von
        #3

        @Pischleuder

        Ganz herzlichen Dank für deinen Adapter! Und ganz großen Respekt für die Geschwindigkeit, in der du den Adapter entwickelt hast.

        Funktioniert auf Anhieb. Sehr ordentliche Objektstruktur.

        Batterieanzeige in % gibt die API nicht her?

        PischleuderP 1 Antwort Letzte Antwort
        1
        • O oFbEQnpoLKKl6mbY5e13

          @Pischleuder

          Ganz herzlichen Dank für deinen Adapter! Und ganz großen Respekt für die Geschwindigkeit, in der du den Adapter entwickelt hast.

          Funktioniert auf Anhieb. Sehr ordentliche Objektstruktur.

          Batterieanzeige in % gibt die API nicht her?

          PischleuderP Online
          PischleuderP Online
          Pischleuder
          schrieb am zuletzt editiert von
          #4

          @oFbEQnpoLKKl6mbY5e13

          Da bin ich noch dran, da es sich in der Regel aber um Lithium Batterien handelt wird das ggf. nicht so einfach. Deshalb kann im Admin Bereich ein Schwellenwert eingegeben werden. Zwischen 1,2 und 1,1 ist die Batterie fast leer.

          O 1 Antwort Letzte Antwort
          1
          • PischleuderP Pischleuder

            @oFbEQnpoLKKl6mbY5e13

            Da bin ich noch dran, da es sich in der Regel aber um Lithium Batterien handelt wird das ggf. nicht so einfach. Deshalb kann im Admin Bereich ein Schwellenwert eingegeben werden. Zwischen 1,2 und 1,1 ist die Batterie fast leer.

            O Offline
            O Offline
            oFbEQnpoLKKl6mbY5e13
            schrieb am zuletzt editiert von
            #5

            @Pischleuder

            Wir haben fast 40 Kameras und 5 Sync-Module. Auch eine größere Installation scheint deinem Adapter keine Probleme zu bereiten. 👍

            PischleuderP 1 Antwort Letzte Antwort
            0
            • PischleuderP Online
              PischleuderP Online
              Pischleuder
              schrieb am zuletzt editiert von Pischleuder
              #6

              freut mich sehr ! :-)

              1 Antwort Letzte Antwort
              1
              • O oFbEQnpoLKKl6mbY5e13

                @Pischleuder

                Wir haben fast 40 Kameras und 5 Sync-Module. Auch eine größere Installation scheint deinem Adapter keine Probleme zu bereiten. 👍

                PischleuderP Online
                PischleuderP Online
                Pischleuder
                schrieb am zuletzt editiert von Pischleuder
                #7

                @oFbEQnpoLKKl6mbY5e13

                Hast Du auch eine Amazon Doorbell, die funktioniert - die habe ich eben nicht.

                O 1 Antwort Letzte Antwort
                0
                • PischleuderP Pischleuder

                  @oFbEQnpoLKKl6mbY5e13

                  Hast Du auch eine Amazon Doorbell, die funktioniert - die habe ich eben nicht.

                  O Offline
                  O Offline
                  oFbEQnpoLKKl6mbY5e13
                  schrieb am zuletzt editiert von
                  #8

                  @Pischleuder

                  Wie witzig, genau damit habe ich mich gerade beschäftig. Ja, haben wir. Diese konnte über den HAM-Adapter nicht gesteuert werden. Leider mit deinem Adapter auch nicht:

                  Befehl fehlgeschlagen (blink.0.cameras.******.commands.motion_detect): HTTP 404: {"message":"Camera not found","error":null,"code":500}
                  
                  PischleuderP 1 Antwort Letzte Antwort
                  0
                  • O oFbEQnpoLKKl6mbY5e13

                    @Pischleuder

                    Wie witzig, genau damit habe ich mich gerade beschäftig. Ja, haben wir. Diese konnte über den HAM-Adapter nicht gesteuert werden. Leider mit deinem Adapter auch nicht:

                    Befehl fehlgeschlagen (blink.0.cameras.******.commands.motion_detect): HTTP 404: {"message":"Camera not found","error":null,"code":500}
                    
                    PischleuderP Online
                    PischleuderP Online
                    Pischleuder
                    schrieb am zuletzt editiert von
                    #9

                    @oFbEQnpoLKKl6mbY5e13

                    wird denn das dazugehörige sync-modul korrekt eingebunden ?

                    O 1 Antwort Letzte Antwort
                    0
                    • PischleuderP Pischleuder

                      @oFbEQnpoLKKl6mbY5e13

                      wird denn das dazugehörige sync-modul korrekt eingebunden ?

                      O Offline
                      O Offline
                      oFbEQnpoLKKl6mbY5e13
                      schrieb am zuletzt editiert von oFbEQnpoLKKl6mbY5e13
                      #10

                      @Pischleuder

                      Ja.

                      Edit:
                      Klingel

                      1 Antwort Letzte Antwort
                      0
                      • PischleuderP Online
                        PischleuderP Online
                        Pischleuder
                        schrieb am zuletzt editiert von
                        #11

                        ich habe zumindest eine Idee: vermutlich wird der Gerätetyp nicht korrekt ausgelesen. Wenn das richtig gemacht wird, dann wird auch die Doorbell erkannt. Ich nehme das einmal mit auf die To-Do Liste - habe aber keine Doorbell zum Testen , dafür musst Du dann herhalten :-)

                        O 1 Antwort Letzte Antwort
                        1
                        • PischleuderP Pischleuder

                          ich habe zumindest eine Idee: vermutlich wird der Gerätetyp nicht korrekt ausgelesen. Wenn das richtig gemacht wird, dann wird auch die Doorbell erkannt. Ich nehme das einmal mit auf die To-Do Liste - habe aber keine Doorbell zum Testen , dafür musst Du dann herhalten :-)

                          O Offline
                          O Offline
                          oFbEQnpoLKKl6mbY5e13
                          schrieb am zuletzt editiert von
                          #12

                          @Pischleuder

                          Mache ich selbstverständlich gerne!

                          1 Antwort Letzte Antwort
                          0
                          • PischleuderP Online
                            PischleuderP Online
                            Pischleuder
                            schrieb am zuletzt editiert von
                            #13

                            hab dir etwas per chat gesendet

                            1 Antwort Letzte Antwort
                            0
                            • A Offline
                              A Offline
                              adarof
                              schrieb am zuletzt editiert von adarof
                              #14

                              Hallo
                              Nun bleibt der Adapter bei mir Gelb - die Verbindung scheint mir aber da zu sein, wenn ich das Log richtig verstehe. Hat jemand eine Idee ?

                              Gruss -

                              blink.0
                              Zeit
                              silly
                              Nachricht
                              
                              blink.0
                              2026-04-25 12:41:17.408	silly	sendTo "send" to system.adapter.pushover.0 from system.adapter.blink.0
                              
                              blink.0
                              2026-04-25 12:41:17.318	silly	States user redis pmessage blink.0.cameras.*.commands.*/blink.0.cameras.1097548.commands.fetch_video:{"val":false,"ack":true,"ts":1777113677315,"q":0,"from":"system.adapter.blink.0","user":"system.user.admin","lc":1777057429539}
                              
                              blink.0
                              2026-04-25 12:41:17.270	silly	States user redis pmessage blink.0.cameras.*.commands.*/blink.0.cameras.1097548.commands.motion_detect:{"val":true,"ack":true,"ts":1777113677270,"q":0,"from":"system.adapter.blink.0","user":"system.user.admin","lc":1777057429574}
                              
                              blink.0
                              2026-04-25 12:41:17.232	silly	States user redis pmessage blink.0.cameras.*.commands.*/blink.0.cameras.928274.commands.fetch_video:{"val":false,"ack":true,"ts":1777113677230,"q":0,"from":"system.adapter.blink.0","user":"system.user.admin","lc":1777057429363}
                              
                              blink.0
                              2026-04-25 12:41:17.186	silly	States user redis pmessage blink.0.cameras.*.commands.*/blink.0.cameras.928274.commands.motion_detect:{"val":true,"ack":true,"ts":1777113677186,"q":0,"from":"system.adapter.blink.0","user":"system.user.admin","lc":1777057429400}
                              
                              blink.0
                              2026-04-25 12:41:17.074	silly	States user redis pmessage blink.0.cameras.*.commands.*/blink.0.cameras.897864.commands.fetch_video:{"val":false,"ack":true,"ts":1777113677071,"q":0,"from":"system.adapter.blink.0","user":"system.user.admin","lc":1777057429177}
                              
                              blink.0
                              2026-04-25 12:41:17.028	silly	States user redis pmessage blink.0.cameras.*.commands.*/blink.0.cameras.897864.commands.motion_detect:{"val":true,"ack":true,"ts":1777113677027,"q":0,"from":"system.adapter.blink.0","user":"system.user.admin","lc":1777057429219}
                              
                              blink.0
                              2026-04-25 12:41:16.934	silly	States user redis pmessage blink.0.cameras.*.commands.*/blink.0.cameras.851424.commands.fetch_video:{"val":false,"ack":true,"ts":1777113676931,"q":0,"from":"system.adapter.blink.0","user":"system.user.admin","lc":1777057429008}
                              
                              blink.0
                              2026-04-25 12:41:16.888	silly	States user redis pmessage blink.0.cameras.*.commands.*/blink.0.cameras.851424.commands.motion_detect:{"val":true,"ack":true,"ts":1777113676888,"q":0,"from":"system.adapter.blink.0","user":"system.user.admin","lc":1777057429037}
                              
                              blink.0
                              2026-04-25 12:41:16.797	silly	States user redis pmessage blink.0.cameras.*.commands.*/blink.0.cameras.786011.commands.fetch_video:{"val":false,"ack":true,"ts":1777113676795,"q":0,"from":"system.adapter.blink.0","user":"system.user.admin","lc":1777057428833}
                              
                              blink.0
                              2026-04-25 12:41:16.752	silly	States user redis pmessage blink.0.cameras.*.commands.*/blink.0.cameras.786011.commands.motion_detect:{"val":true,"ack":true,"ts":1777113676751,"q":0,"from":"system.adapter.blink.0","user":"system.user.admin","lc":1777057428867}
                              
                              blink.0
                              2026-04-25 12:41:16.655	silly	States user redis pmessage blink.0.sync.*.commands.*/blink.0.sync.548399.commands.armed:{"val":true,"ack":true,"ts":1777113676654,"q":0,"from":"system.adapter.blink.0","user":"system.user.admin","lc":1777057428695}
                              
                              blink.0
                              2026-04-25 12:41:16.586	silly	States user redis pmessage blink.0.sync.*.commands.*/blink.0.sync.395317.commands.armed:{"val":true,"ack":true,"ts":1777113676585,"q":0,"from":"system.adapter.blink.0","user":"system.user.admin","lc":1777057428656}
                              
                              blink.0
                              2026-04-25 12:41:15.241	silly	States system redis pmessage system.adapter.blink.0.logLevel/system.adapter.blink.0.logLevel:{"val":"silly","ack":true,"ts":1777113675236,"q":0,"from":"system.adapter.blink.0","lc":1777113573231}
                              
                              blink.0
                              2026-04-25 12:41:15.228	info	starting. Version 0.0.2 (non-npm: Pischleuder1/ioBroker.blink#10cdba4ba984acc72eb546e632d851ddfcc9ea6a) in /opt/iobroker/node_modules/iobroker.blink, node: v22.22.2, js-controller: 7.0.7
                              
                              1 Antwort Letzte Antwort
                              0
                              • PischleuderP Online
                                PischleuderP Online
                                Pischleuder
                                schrieb am zuletzt editiert von
                                #15

                                da sehe ich kein Problem - es startet alles ganz normal. Stell das Log doch einmal auf Info, oder starte den Adapter neu.

                                sigi234S A 2 Antworten Letzte Antwort
                                0
                                • PischleuderP Pischleuder

                                  da sehe ich kein Problem - es startet alles ganz normal. Stell das Log doch einmal auf Info, oder starte den Adapter neu.

                                  sigi234S Online
                                  sigi234S Online
                                  sigi234
                                  Forum Testing Most Active
                                  schrieb am zuletzt editiert von sigi234
                                  #16

                                  @Pischleuder

                                  Danke, läuft bei mir. 👍

                                  Erkannte Instanzen:    92 (73 aktiv, 19 inaktiv)
                                  Plattform:             Windows
                                  js-controller:         7.1.1
                                  Node.js:               v22.22.2
                                  npm:                   10.9.7
                                  RAM:                   ~4157 MB (alle Adapter)
                                  CPU:                   9.27 %
                                  Host:                  SmartHome
                                  Repository:            beta
                                  

                                  Bitte benutzt das Voting rechts unten im Beitrag wenn er euch geholfen hat.
                                  Immer Daten sichern!

                                  1 Antwort Letzte Antwort
                                  1
                                  • PischleuderP Online
                                    PischleuderP Online
                                    Pischleuder
                                    schrieb am zuletzt editiert von
                                    #17

                                    Mit @ofbeqnpolkkl6mby5e13 arbeite ich gerade daran, dass die Video Doorbell eingebunden werden kann. Motion detect geht wohl schon, auch die Batterieanzeige - jedoch wird die Temperatur noch nicht korrekt ausgelesen. Sobald wir das hinbekommen erscheint ein neues Release.

                                    1 Antwort Letzte Antwort
                                    0
                                    • PischleuderP Online
                                      PischleuderP Online
                                      Pischleuder
                                      schrieb am zuletzt editiert von
                                      #18

                                      Wer das Widget für die Videos benötigt, bitte das javascript und das html neu aus dem Startpost laden. Hier habe ich Veränderungen eingearbeitet, da der Chrome-Browser Probleme bereitet hatte und Überlagerungen entfernt wurden.

                                      1 Antwort Letzte Antwort
                                      0
                                      • PischleuderP Pischleuder

                                        da sehe ich kein Problem - es startet alles ganz normal. Stell das Log doch einmal auf Info, oder starte den Adapter neu.

                                        A Offline
                                        A Offline
                                        adarof
                                        schrieb am zuletzt editiert von
                                        #19

                                        @Pischleuder sagte:

                                        da sehe ich kein Problem - es startet alles ganz normal. Stell das Log doch einmal auf Info, oder starte den Adapter neu.

                                        Ich starte neu und es kommt genau die Ausgabe von oben. Es scheint auch keine Funktion zu geben (ausser dass die Kameras gefunden werden -> Also die Verbindung wohl da ist)

                                        1 Antwort Letzte Antwort
                                        0
                                        • PischleuderP Online
                                          PischleuderP Online
                                          Pischleuder
                                          schrieb am zuletzt editiert von Pischleuder
                                          #20

                                          stoppe den Adapter einmal, lösche dann Deinen Pin und starte ihn erneut. Dann solltest Du einen neuen Pin erhalten. Diesen wieder eingeben und neu starten.
                                          Die Kameras sind immer da, insofern er einmal den Adapter sauber gestartet hat. Die states werden dann aber nicht aktualisiert.

                                          1 Antwort Letzte Antwort
                                          0

                                          Hey! Du scheinst an dieser Unterhaltung interessiert zu sein, hast aber noch kein Konto.

                                          Hast du es satt, bei jedem Besuch durch die gleichen Beiträge zu scrollen? Wenn du dich für ein Konto anmeldest, kommst du immer genau dorthin zurück, wo du zuvor warst, und kannst dich über neue Antworten benachrichtigen lassen (entweder per E-Mail oder Push-Benachrichtigung). Du kannst auch Lesezeichen speichern und Beiträge positiv bewerten, um anderen Community-Mitgliedern deine Wertschätzung zu zeigen.

                                          Mit deinem Input könnte dieser Beitrag noch besser werden 💗

                                          Registrieren Anmelden
                                          Antworten
                                          • In einem neuen Thema antworten
                                          Anmelden zum Antworten
                                          • Älteste zuerst
                                          • Neuste zuerst
                                          • Meiste Stimmen


                                          Support us

                                          ioBroker
                                          Community Adapters
                                          Donate

                                          417

                                          Online

                                          32.9k

                                          Benutzer

                                          83.0k

                                          Themen

                                          1.3m

                                          Beiträge
                                          Community
                                          Impressum | Datenschutz-Bestimmungen | Nutzungsbedingungen | Einwilligungseinstellungen
                                          ioBroker Community 2014-2025
                                          logo
                                          • Anmelden

                                          • Du hast noch kein Konto? Registrieren

                                          • Anmelden oder registrieren, um zu suchen
                                          • Erster Beitrag
                                            Letzter Beitrag
                                          0
                                          • Home
                                          • Aktuell
                                          • Tags
                                          • Ungelesen 0
                                          • Kategorien
                                          • Unreplied
                                          • Beliebt
                                          • GitHub
                                          • Docu
                                          • Hilfe