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
    14
    1
    2.4k

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

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

Test Adapter für Blink Kameras entwickelt mit KI

Geplant Angeheftet Gesperrt Verschoben ...nicht in offiziellem Repo
174 Beiträge 15 Kommentatoren 4.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 Online
    PischleuderP Online
    Pischleuder
    schrieb am zuletzt editiert von Pischleuder
    #97

    Dank @intruder7 konnten wir nun auch ein Blink Sync2 Modul ohne Abo einbinden, welches die Video Dateien nur lokal auf einen USB-Stick schreibt.
    D.h. in der nächsten Version erkennt der Adapter, ob er aus der Cloud oder Lokal suchen muss. Das wird aber voraussichtlich erst in der 0.0.7 erscheinen.

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

      Bin gerade dabei eine neue Version vorzubereiten, die auch das SyncModul2 und XR unterstützt, also für diejenigen, die lokal auf SD-Karte oder USB speichern.

      Die Visualisierung wird etwas vereinfacht :

      • die letzten 10 gespeicherten Video aus der Cloud oder auch lokale (von der sd-Karte) werden angezeigt und können angesehen werden (für jede Kamera)
      • Indikator, ob die Videos aus der Cloud oder Lokal abgerufen wurden
      • Darstellung: einzelne Kamera, oder alle über ein Grid sowie die gesamte Historie dazu
      • Kameras werden automatisch gefunden und müssen nicht mehr manuell eingebunden werden (d.h. die widget scripte vom Startpost werden überflüssig) --> siehe neues Javascript im Startpost
      • Umsetzung erfolgt über einen Server, der per diesem Javascript gestartet wird :
      // ============================================================
      // 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)
      // ============================================================
      

      Beispiel:
      a53d7015-bbee-4594-8b6b-b6a2bfa9dcf9-image.jpeg

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

        Version 0.0.10 ist über github und npm online.

        1 Antwort Letzte Antwort
        1
        • H Offline
          H Offline
          helfi9999
          schrieb am zuletzt editiert von
          #100

          die Uhrzeit passt nicht wie bekomme ich die hin

          Intel NUC mit Iobroker

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

            Vielleicht erläuterst Du das ein wenig mehr ? Meinst Du bei den Videos, oder wo ?

            1 Antwort Letzte Antwort
            0
            • H Offline
              H Offline
              helfi9999
              schrieb am zuletzt editiert von helfi9999
              #102

              ja wenn ich einen snapshot hole. Der Zeitstempel stimmt nicht

              Intel NUC mit Iobroker

              Thomas BraunT 1 Antwort Letzte Antwort
              0
              • H helfi9999

                ja wenn ich einen snapshot hole. Der Zeitstempel stimmt nicht

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

                @helfi9999 sagte:

                Der Zeitstempel stimmt nicht

                Wie hoch ist die Abweichung?

                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 Offline
                  H Offline
                  helfi9999
                  schrieb am zuletzt editiert von
                  #104

                  2 Stunden

                  Intel NUC mit Iobroker

                  Thomas BraunT 1 Antwort Letzte Antwort
                  0
                  • PischleuderP Online
                    PischleuderP Online
                    Pischleuder
                    schrieb am zuletzt editiert von Pischleuder
                    #105

                    was sagt denn

                    timedatectl
                    

                    in der Konsole

                    1 Antwort Letzte Antwort
                    0
                    • H helfi9999

                      2 Stunden

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

                      @helfi9999 sagte:

                      2 Stunden

                      Dann sind nicht auf allen beteiligten Systemen die richtigen Zeitzonen eingestellt.

                      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 Offline
                        H Offline
                        helfi9999
                        schrieb am zuletzt editiert von
                        #107

                        timedatectl zeigt : timedatectl
                        Local time: Sa 2026-05-23 21:44:28 CEST
                        Universal time: Sa 2026-05-23 19:44:28 UTC
                        RTC time: Sa 2026-05-23 19:44:28
                        Time zone: Europe/Berlin (CEST, +0200)
                        System clock synchronized: yes
                        NTP service: active
                        RTC in local TZ: no

                        Intel NUC mit Iobroker

                        Thomas BraunT 1 Antwort Letzte Antwort
                        0
                        • H helfi9999

                          timedatectl zeigt : timedatectl
                          Local time: Sa 2026-05-23 21:44:28 CEST
                          Universal time: Sa 2026-05-23 19:44:28 UTC
                          RTC time: Sa 2026-05-23 19:44:28
                          Time zone: Europe/Berlin (CEST, +0200)
                          System clock synchronized: yes
                          NTP service: active
                          RTC in local TZ: no

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

                          @helfi9999

                          Das ist richtig. Wie steht denn die Zeitzone auf der Kamera?

                          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 Offline
                            H Offline
                            helfi9999
                            schrieb am zuletzt editiert von
                            #109

                            Zeitzone:: Europe/Berlin

                            Intel NUC mit Iobroker

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

                              kannst du bitte einmal einen Screenshot von der entsprechenden Kamera nach einem Auslöse-Event machen ?
                              Bzw. wie erhälst Du den, über ein manuellen fetch oder per Javascript ?

                              H 1 Antwort Letzte Antwort
                              0
                              • PischleuderP Pischleuder

                                kannst du bitte einmal einen Screenshot von der entsprechenden Kamera nach einem Auslöse-Event machen ?
                                Bzw. wie erhälst Du den, über ein manuellen fetch oder per Javascript ?

                                H Offline
                                H Offline
                                helfi9999
                                schrieb am zuletzt editiert von
                                #111

                                @Pischleuder !Habe den Snapshot um 9:35 Uhr gemacht6fcac95a-6467-4969-9438-c72c9aee5a9e-image.jpeg

                                Intel NUC mit Iobroker

                                Thomas BraunT 1 Antwort Letzte Antwort
                                0
                                • H helfi9999

                                  @Pischleuder !Habe den Snapshot um 9:35 Uhr gemacht6fcac95a-6467-4969-9438-c72c9aee5a9e-image.jpeg

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

                                  @helfi9999

                                  Das ist die 'Z'-Zeit. Steht ja auch dran. Also vollkommen korrekt.
                                  Wird das von der Kamera schon so angeliefert?

                                  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
                                  • PischleuderP Online
                                    PischleuderP Online
                                    Pischleuder
                                    schrieb am zuletzt editiert von
                                    #113

                                    @helfi9999
                                    Du arbeitest aber noch mit dem "alten" Widget script. Bist Du auf die aktuelle blink Version hoch gegangen und hast einmal das im Startpost angehängte Javascript eingebunden ? Da sind einige Änderungen drin.

                                    H 1 Antwort Letzte Antwort
                                    0
                                    • PischleuderP Pischleuder

                                      @helfi9999
                                      Du arbeitest aber noch mit dem "alten" Widget script. Bist Du auf die aktuelle blink Version hoch gegangen und hast einmal das im Startpost angehängte Javascript eingebunden ? Da sind einige Änderungen drin.

                                      H Offline
                                      H Offline
                                      helfi9999
                                      schrieb am zuletzt editiert von
                                      #114

                                      @Pischleuder ja das andere Skript habe ich genommen

                                      Intel NUC mit Iobroker

                                      PischleuderP 1 Antwort Letzte Antwort
                                      0
                                      • H helfi9999

                                        @Pischleuder ja das andere Skript habe ich genommen

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

                                        @helfi9999 sagte:

                                        @Pischleuder ja das andere Skript habe ich genommen

                                        Nein, leider nicht, da die neue Oberfläche anders ausschaut. Stell dies bitte einmal zurück, denn ich arbeite gerade daran, auch einen live-stream zu erhalten. Bei einer Blink-Mini funktioniert das bereits.

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

                                          @helfi9999

                                          Schau doch noch einmal bitte in der Instanz von blink, ob Du die Version 0.0.10 geladen hast. Wenn nicht, dann über Adpater -> Katzensymbol -> Von NPM -> Blinksystem (Pischleuder1) installieren.
                                          Dann noch einmal nachsehen, ob das richtige Javascript bei Dir läuft:

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


                                          Das "alte" Javascript natürlich löschen bzw. deaktivieren.
                                          Danach : Deine-IP:8095/grid

                                          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

                                          559

                                          Online

                                          32.9k

                                          Benutzer

                                          83.2k

                                          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