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

  • wichtiges UPDATE für controller 7.2.2 im stable
    HomoranH
    Homoran
    10
    1
    2.3k

  • Neues YouTube-Video: Visualisierung im Devices-Adapter
    BluefoxB
    Bluefox
    16
    1
    4.2k

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

Test Adapter für Blink Kameras entwickelt mit KI

Geplant Angeheftet Gesperrt Verschoben ...nicht in offiziellem Repo
175 Beiträge 15 Kommentatoren 10.8k Aufrufe 16 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 Pischleuder
    Aktuelle Testversion 0.0.19
    Github Link https://github.com/Pischleuder1/ioBroker.blink
    NPM Link https://www.npmjs.com/package/iobroker.blink
    Veröffentlichungsdatum 24.06.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), dies erfolgt mittlerweile automatisch seit 0.0.16
    • 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 !

    • NEU seit 0.0.16:

    • initialer Live View für viele Kameras :
    • ✅ Blink Mini / Mini-ähnliche Geräte
    • ✅ Blink Indoor / Outdoor neuere Generationen
    • ✅ Blink Video Doorbell
    • ✅ Kameras, bei denen Blink über die Cloud einen LiveView-Stream liefert
    • ❌ Blink XT
    • ❌ Blink XT2 / alte XT2
    • ❌ sehr alte Kameras mit anderem Stream-Protokoll
    • ein JavaScript zur Visualisierung muss nicht mehr händisch (siehe unten) eingetragen werden - es wird mit installiert - Standardport EigeneIP:8085
    • in der Visualisierung stehen 10 Slots pro Kamera mit den max. 10 letzten Videos zur Verfügung
    • LiveView Buttons für Kameras, die dazu fähig sind - bei den anderen wird dieser "ausgeblendet"
    • HTTP 202 wird jetzt korrekt als Blink-2FA/TSV-Challenge erkannt.
    • HTTP 429 / 2fa_rate_limit_exceeded wird abgefangen.
    • Der Adapter spammt bei 2FA-Rate-Limit nicht mehr weiter Login-Versuche.
    • Der 2FA/PIN-Code wird nach erfolgreichem Login automatisch aus der Adapter-Konfiguration gelöscht.
    • wenn ein JavaScript schon vorhanden ist, wird es nicht überschrieben (Name: blink-video-url-server), da ja ggf. eine eigene Konfiguration eingearbeitet ist. Das alte also ggf. löschen oder vorher umbenennen.
    • Alte PINs werden nicht mehr wiederverwendet und lösen weniger Rate-Limits aus.

    eine manuelle Konfiguration/Anpassung im JavaScript kann vorgenommen werden bspw. bei Docker-Systemen oder in Spezialfällen (normalerweise nicht notwendig) - gekennzeichnet mit "=hier=":

    // ============= KONFIGURATION =============
    const PORT          = 8085; // =hier=
    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 UNSUPPORTED_STATE = '.live.unsupported';
    const HISTORY_SIZE  = 10;
    const IOBROKER_PORT = 8082; // =hier=
    
    // Blink/API/IMMI-Dateien auf dem Pi
    const LIVEVIEW_DIR          = '/opt/iobroker/node_modules/iobroker.blink/lib';
    const LIVEVIEW_REST_SCRIPT  = path.join(LIVEVIEW_DIR, 'blink-liveview-iobroker.js');
    const LIVEVIEW_HLS_SCRIPT   = path.join(LIVEVIEW_DIR, 'immi-live-hls.js');
    const HLS_DIR               = '/tmp/blink_hls';
    const LIVEVIEW_RUNTIME_SEC  = 300;
    
    // Pfad zur ffmpeg-Binary. Leer lassen, wenn ffmpeg im PATH liegt.
    // In Docker-Setups, in denen ffmpeg manuell bereitgestellt wird, hier den
    // vollen Pfad eintragen, z. B. '/usr/local/bin/ffmpeg' oder '/opt/ffmpeg/ffmpeg'.
    const FFMPEG_PATH           = ''; // =hier=
    
    // Blink-Zugangsdaten werden automatisch aus der Adapter-Admin-Konfiguration gelesen.
    // Erwartete Config-Keys in admin/jsonConfig.json: email, password, pin
    const BLINK_INSTANCE        = 'blink.0';
    
    // Account-ID wird automatisch aus ioBroker-Objekten, Adapter-Config oder vorhandenen Session-Dateien gesucht.
    // Nur als optionaler Notfall-Fallback setzen; im Normalfall leer lassen.
    const DEFAULT_ACCOUNT_ID    = '133934'; // =hier=
    
    // Network-ID wird automatisch aus blink.0.sync.<networkId> gelesen.
    // Nur als optionaler Notfall-Fallback nutzen; bei mehreren Sync-Modulen NICHT blind setzen.
    const DEFAULT_NETWORK_ID    = ''; // =hier=
    
    // Sonderfälle überschreiben/ergänzen.
    // Normale Blink-Kameras brauchen keinen Eintrag mehr: fehlender Typ wird automatisch als "camera" behandelt.
    // Einträge sind nur nötig für owl/doorbell, falsche Seriennummern oder mehrere Sync-Module ohne Kamera-Zuordnung.
    const LIVEVIEW_CAMERA_OVERRIDES = {
        '773578':  { type: 'owl', serial: 'G8T1940153360515', name: 'Mini - 0515' } // =hier=
    };
    // =========================================
    

    Visualisierung
    • ffmpeg muss installiert sein
    • Javascript adapter
    • Web-Adapter
    • Es werden auf einem Pi4 relativ große Ressourcen benötigt, 4 GB und mehr empfohlen

    dieses Javascript einbinden (nur bis 0.0.14) :

    // ============================================================
    // 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);

    [/s]
    
    ![2af22229-da6f-4a9c-999e-6655b43b3cc2-image.jpeg](/assets/uploads/files/1779458485186-2af22229-da6f-4a9c-999e-6655b43b3cc2-image.jpeg)
    sigi234S Online
    sigi234S Online
    sigi234
    Forum Testing Most Active
    schrieb am zuletzt editiert von
    #157

    @Pischleuder

    Hallo, kannst noch eine Benachrichtigung per Mail einbauen?

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

    PischleuderP 1 Antwort Letzte Antwort
    0
    • sigi234S sigi234

      @Pischleuder

      Hallo, kannst noch eine Benachrichtigung per Mail einbauen?

      PischleuderP Offline
      PischleuderP Offline
      Pischleuder
      schrieb am zuletzt editiert von
      #158

      @sigi234 sagte:

      @Pischleuder

      Hallo, kannst noch eine Benachrichtigung per Mail einbauen?

      was benötigst Du genau ?

      sigi234S 1 Antwort Letzte Antwort
      0
      • PischleuderP Pischleuder

        @sigi234 sagte:

        @Pischleuder

        Hallo, kannst noch eine Benachrichtigung per Mail einbauen?

        was benötigst Du genau ?

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

        @Pischleuder sagte:

        was benötigst Du genau ?

        Bei Bewegung und Batterie

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

        1 Antwort Letzte Antwort
        0
        • PischleuderP Offline
          PischleuderP Offline
          Pischleuder
          schrieb am zuletzt editiert von
          #160

          nehme ich auf die Liste, wird dann aber in die Version mit Live-view einfliessen.

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

            Hallo
            Heute nacht hatte ich einen Internetausfall - sowohl Router sagt dass wie auch der Provider per Statusmails.
            Zeitgleich mit Wiederherstellen der Verbindung wurde ein neuer 2FA Code von Blink gesendet
            Kann es sein dass der Adapter das war?

            Er hat gerade auch nicht (orange) und ich musste ihn neustarten und den dann bekommenen 2FA Code eintragen. Nun tuts wieder.
            Kann es sein dass der Adapter bei verbindungsverlust unnötigerweise immer einen neuen 2FA Code anfordet?

            Gruss

            1 Antwort Letzte Antwort
            0
            • PischleuderP Offline
              PischleuderP Offline
              Pischleuder
              schrieb am zuletzt editiert von Pischleuder
              #162

              Der Adapter fordert immer dann einen neuen Pin an, wenn beim IoBroker der Session Cache verloren geht. In der Regel, wenn ein Neustart gemacht wird. Ich habe aber den Login auf drei Versuche beschränkt, um zu verhindern, dass der Account 24 Stunden gesperrt wird. Dafür musst du aber auf Version 0.0.14 sein.

              1 Antwort Letzte Antwort
              0
              • H Online
                H Online
                helfi9999
                schrieb am zuletzt editiert von
                #163

                Hi musste mein System neu starten und jetzt zeigt der Adapter folgendes an: Initialer Connect/Poll fehlgeschlagen: Blink Login fehlgeschlagen: HTTP 202 – Location="" Body={"next_time_in_secs":60,"phone":"+49xxxxxxxx99","tsv_methods":["sms","whatsapp","voice"],"tsv_state":"sms","user_id":121027091}Siehe Debug-Log: /tmp/blink_debug.log
                Was kann ich dagegen machen habe schon einen neuen code eingegeben aber es ändert sich nichts

                Intel NUC mit Iobroker

                Thomas BraunT PischleuderP 2 Antworten Letzte Antwort
                0
                • H helfi9999

                  Hi musste mein System neu starten und jetzt zeigt der Adapter folgendes an: Initialer Connect/Poll fehlgeschlagen: Blink Login fehlgeschlagen: HTTP 202 – Location="" Body={"next_time_in_secs":60,"phone":"+49xxxxxxxx99","tsv_methods":["sms","whatsapp","voice"],"tsv_state":"sms","user_id":121027091}Siehe Debug-Log: /tmp/blink_debug.log
                  Was kann ich dagegen machen habe schon einen neuen code eingegeben aber es ändert sich nichts

                  Thomas BraunT Online
                  Thomas BraunT Online
                  Thomas Braun
                  Most Active
                  schrieb am zuletzt editiert von
                  #164

                  @helfi9999 sagte:

                  Siehe Debug-Log: /tmp/blink_debug.log

                  Linux-Werkzeugkasten:
                  https://forum.iobroker.net/topic/42952/der-kleine-iobroker-linux-werkzeugkasten
                  NodeJS Fixer Skript:
                  https://forum.iobroker.net/topic/68035/iob-node-fix-skript
                  iob_diag: curl -sLf -o diag.sh https://iobroker.net/diag.sh && bash diag.sh

                  1 Antwort Letzte Antwort
                  0
                  • H helfi9999

                    Hi musste mein System neu starten und jetzt zeigt der Adapter folgendes an: Initialer Connect/Poll fehlgeschlagen: Blink Login fehlgeschlagen: HTTP 202 – Location="" Body={"next_time_in_secs":60,"phone":"+49xxxxxxxx99","tsv_methods":["sms","whatsapp","voice"],"tsv_state":"sms","user_id":121027091}Siehe Debug-Log: /tmp/blink_debug.log
                    Was kann ich dagegen machen habe schon einen neuen code eingegeben aber es ändert sich nichts

                    PischleuderP Offline
                    PischleuderP Offline
                    Pischleuder
                    schrieb am zuletzt editiert von
                    #165

                    @helfi9999 sagte:

                    Hi musste mein System neu starten und jetzt zeigt der Adapter folgendes an: Initialer Connect/Poll fehlgeschlagen: Blink Login fehlgeschlagen: HTTP 202 – Location="" Body

                    Hi,

                    der Fehler ist bisher noch nie aufgetaucht, werde ihn aber im nächsten Release berücksichtigen. Habe dir etwas per Chat gesendet, probiere das bitte einmal aus.

                    1 Antwort Letzte Antwort
                    0
                    • nograxN Offline
                      nograxN Offline
                      nograx
                      Developer
                      schrieb am zuletzt editiert von
                      #166

                      Habe exakt das selbe Problem, kam aus heiterem Himmel. One Time Code wird nicht akzeptiert

                      1 Antwort Letzte Antwort
                      0
                      • PischleuderP Offline
                        PischleuderP Offline
                        Pischleuder
                        schrieb am zuletzt editiert von
                        #167

                        Du hast Post

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

                          Hallo
                          Offensichtlich hatte ich heute nach wieder einen Inet Ausfall.
                          Dann habe ich ca 20 SMS für den 2FA bekommen und finde

                          Initialer Connect/Poll fehlgeschlagen: Blink Login fehlgeschlagen: HTTP 429 – Location="" Body={"error":"too_many_requests","error_cause":"2fa_rate_limit_exceeded","error_description":"2FA rate limit exceeded.","next_time_in_secs":86400}Siehe Debug-Log: /tmp/blink_debug.log
                          

                          ...das Log existiert aber nicht.

                          Oder ist es mittlerweile woanders?
                          Jedenfalls scheint der adapter nun nicht zu pausieren sondern immer wieder zu probieren bei diesem Fehler und damit natürlich das timeut immer wieder anzustossen:

                          2026-06-12 10:42:31.854	warn	Poll-Fehler: Blink Login fehlgeschlagen: HTTP 429 – Location="" Body={"error":"too_many_requests","error_cause":"2fa_rate_limit_exceeded","error_description":"2FA rate limit exceeded.","next_time_in_secs":86400} Siehe Debug-Log: /tmp/blink_debug.log
                          
                          blink.0
                          2026-06-12 10:41:31.896	warn	Poll-Fehler: Blink Login fehlgeschlagen: HTTP 429 – Location="" Body={"error":"too_many_requests","error_cause":"2fa_rate_limit_exceeded","error_description":"2FA rate limit exceeded.","next_time_in_secs":86400} Siehe Debug-Log: /tmp/blink_debug.log
                          
                          blink.0
                          2026-06-12 10:40:31.907	warn	Poll-Fehler: Blink Login fehlgeschlagen: HTTP 429 – Location="" Body={"error":"too_many_requests","error_cause":"2fa_rate_limit_exceeded","error_description":"2FA rate limit exceeded.","next_time_in_secs":86400} Siehe Debug-Log: /tmp/blink_debug.log
                          
                          blink.0
                          2026-06-12 10:39:31.882	warn	Poll-Fehler: Blink Login fehlgeschlagen: HTTP 429 – Location="" Body={"error":"too_many_requests","error_cause":"2fa_rate_limit_exceeded","error_description":"2FA rate limit exceeded.","next_time_in_secs":86400} Siehe Debug-Log: /tmp/blink_debug.log
                          
                          blink.0
                          2026-06-12 10:38:31.901	warn	Poll-Fehler: Blink Login fehlgeschlagen: HTTP 429 – Location="" Body={"error":"too_many_requests","error_cause":"2fa_rate_limit_exceeded","error_description":"2FA rate limit exceeded.","next_time_in_secs":86400} Siehe Debug-Log: /tmp/blink_debug.log
                          

                          EDIT:
                          Vielleicht habe ich auch noch einen anderen Fehler denn auch wenn ich nun (nach ablauf der 24h von oben) den 2FA SMS Code eintrage kommt immer wieder diese Meldung:

                          
                          blink.0
                          2026-06-13 21:21:02.242	warn	Poll-Fehler: Blink Login fehlgeschlagen: HTTP 202 – Location="" Body={"next_time_in_secs":60,"phone":"+49xxxxxxxx89","tsv_methods":["sms","whatsapp","voice"],"tsv_state":"sms","user_id":126275091}Siehe Debug-Log: /tmp/blink_debug.log
                          
                          blink.0
                          2026-06-13 21:20:01.054	error	Initialer Connect/Poll fehlgeschlagen: Blink Login fehlgeschlagen: HTTP 202 – Location="" Body={"next_time_in_secs":60,"phone":"+49xxxxxxxx89","tsv_methods":["sms","whatsapp","voice"],"tsv_state":"sms","user_id":126275091}Siehe Debug-Log: /tmp/blink_debug.log
                          
                          blink.0
                          2026-06-13 21:19:59.836	info	starting. Version 0.0.14 (non-npm: Pischleuder1/ioBroker.blink) in /opt/iobroker/node_modules/iobroker.blink, node: v22.22.3, js-controller: 7.0.7
                          
                          blink.0
                          2026-06-13 21:19:26.646	error	Initialer Connect/Poll fehlgeschlagen: Blink Login fehlgeschlagen: HTTP 202 – Location="" Body={"next_time_in_secs":60,"phone":"+49xxxxxxxx89","tsv_methods":["sms","whatsapp","voice"],"tsv_state":"sms","user_id":126275091}Siehe Debug-Log: /tmp/blink_debug.log
                          

                          ich bekomme auch jedesmal eine SMS (mit dem gleichen Code, den ich auch eingetragen habe in der Konfiguration).

                          Hat jemand eine Idee?

                          1 Antwort Letzte Antwort
                          0
                          • WinniW Offline
                            WinniW Offline
                            Winni
                            Most Active
                            schrieb am zuletzt editiert von
                            #169

                            Bei mir war es ähnlich. Hatte allerdings noch die 0.0.14 am laufen. Nach Installation der 0.0.16 funktioniert es wieder.

                            Es gibt nicht Gutes. Außer man tut es. Erich Kästner

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

                              Danke - in der Tat hat es sich geändert; nun wird der 2FA automatisch gelöscht, ich habe auch eine Mail bekommen, dass ein Login stattgefunden hat.
                              Sieht also besser aus.

                              Allerdings ist der Adapter weiter Gelb ohne dass ich eine Warnung o.ä. im Protokoll sehe ...
                              EDIT... er brauchte nur ne Minute -> nun ist grün!

                              DANKE!

                              1 Antwort Letzte Antwort
                              0
                              • WinniW Offline
                                WinniW Offline
                                Winni
                                Most Active
                                schrieb am zuletzt editiert von
                                #171

                                So war es bei mir auch 😀

                                Es gibt nicht Gutes. Außer man tut es. Erich Kästner

                                1 Antwort Letzte Antwort
                                0
                                • PischleuderP Offline
                                  PischleuderP Offline
                                  Pischleuder
                                  schrieb am zuletzt editiert von Pischleuder
                                  #172

                                  Moin,

                                  Version 0.0.16 ist online mit initialem LiveView (siehe Startbeitrag). Nicht wundern, wie bereits von @winni und @adarof geschrieben dauert der Start ein wenig (ca. 30-40 Sekunden), da viele Punkte geprüft werden müssen, u.a. :

                                  • HTTP 202 wird jetzt korrekt als Blink-2FA/TSV-Challenge erkannt.
                                  • HTTP 429 / 2fa_rate_limit_exceeded wird abgefangen.
                                  • Der Adapter spammt bei 2FA-Rate-Limit nicht mehr weiter Login-Versuche.
                                  • Der 2FA/PIN-Code wird nach erfolgreichem Login automatisch aus der Adapter-Konfiguration gelöscht.
                                  • wenn ein JavaScript (ggf. aus alter Version 0.0.14) noch vorhanden ist, wird es nicht überschrieben (Name: blink-video-url-server), dann das alte vorher umbenennen
                                  • Alte PINs werden dadurch nicht mehr wiederverwendet und lösen weniger Rate-Limits aus.
                                  1 Antwort Letzte Antwort
                                  2
                                  • PischleuderP Offline
                                    PischleuderP Offline
                                    Pischleuder
                                    schrieb am zuletzt editiert von Pischleuder
                                    #173

                                    bitte direkt auf 0.0.17 updaten, wenn sie durch ist, da durch die Änderung von "Der 2FA/PIN-Code wird nach erfolgreichem Login automatisch aus der Adapter-Konfiguration gelöscht." - der LiveView beschädigt wurde - ist nun gefixt.

                                    1 Antwort Letzte Antwort
                                    2
                                    • PischleuderP Offline
                                      PischleuderP Offline
                                      Pischleuder
                                      schrieb am zuletzt editiert von
                                      #174

                                      die 0.0.18 ist online:

                                      • manchmal wurde die Account ID nicht korrekt gelesen
                                      • zum Prüfen hier schauen, da sollte sie erscheinen: blink.0.info.account_id
                                      1 Antwort Letzte Antwort
                                      2
                                      • PischleuderP Offline
                                        PischleuderP Offline
                                        Pischleuder
                                        schrieb am zuletzt editiert von
                                        #175

                                        die 0.0.19 ist online:

                                        • XT und XT2 liefern ja keinen decodierbaren Stream und sollten nun automatisch auf "kein LiveView" schalten in der Grid-Ansicht
                                        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

                                        340

                                        Online

                                        33.0k

                                        Benutzer

                                        83.4k

                                        Themen

                                        1.3m

                                        Beiträge
                                        Community
                                        Impressum | Datenschutz-Bestimmungen | Nutzungsbedingungen | Einwilligungseinstellungen
                                        ioBroker Community 2014-2026
                                        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