From 8a3b6ab9125323bcde2841ee60197b9a35335433 Mon Sep 17 00:00:00 2001 From: Dicken Date: Fri, 10 Jul 2026 19:24:00 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20K=C3=B6Pi=20HA-Button=20sendet=20immer?= =?UTF-8?q?=20Pushover,=20Angebote-Sensor=20zeigt=20alle=20Angebote,=20neu?= =?UTF-8?q?e=20Start/Ende-Datum-Sensoren?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/mqtt.js | 81 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 75 insertions(+), 6 deletions(-) diff --git a/backend/src/mqtt.js b/backend/src/mqtt.js index dadba75..9bd13d5 100644 --- a/backend/src/mqtt.js +++ b/backend/src/mqtt.js @@ -59,7 +59,7 @@ function publishDiscovery() { if (!client) return; const entities = [ ['sensor', 'dickendock_koepi_angebot', { - name: 'KöPi bestes Angebot', + name: 'KöPi Angebote', unique_id: 'dickendock_koepi_bestes_angebot', state_topic: `${BASE_TOPIC}/koepi/state`, json_attributes_topic: `${BASE_TOPIC}/koepi/attributes`, @@ -85,6 +85,24 @@ function publishDiscovery() { availability_topic: AVAILABILITY_TOPIC, device: DEVICE, }], + ['sensor', 'dickendock_koepi_start', { + name: 'KöPi Angebot Start', + unique_id: 'dickendock_koepi_start', + state_topic: `${BASE_TOPIC}/koepi/start`, + device_class: 'date', + icon: 'mdi:calendar-start', + availability_topic: AVAILABILITY_TOPIC, + device: DEVICE, + }], + ['sensor', 'dickendock_koepi_ende', { + name: 'KöPi Angebot Ende', + unique_id: 'dickendock_koepi_ende', + state_topic: `${BASE_TOPIC}/koepi/ende`, + device_class: 'date', + icon: 'mdi:calendar-end', + availability_topic: AVAILABILITY_TOPIC, + device: DEVICE, + }], ['sensor', 'dickendock_online', { name: 'DickenDock Nutzer online', unique_id: 'dickendock_nutzer_online', @@ -108,14 +126,61 @@ function parsePrice(str) { return Number.isNaN(n) ? Infinity : n; } +// Parst ein deutsches Datum "10.07." oder "10.07.2026" aus einem Textschnipsel. +// Fehlt das Jahr, wird das aktuelle Jahr angenommen (mit Jahreswechsel-Korrektur +// in beide Richtungen, z.B. bei Angeboten die über Silvester laufen). +function parseGermanDate(str, ref = new Date()) { + const m = String(str).match(/(\d{1,2})\.(\d{1,2})\.(\d{4})?/); + if (!m) return null; + const day = parseInt(m[1], 10); + const month = parseInt(m[2], 10) - 1; + let year = m[3] ? parseInt(m[3], 10) : ref.getFullYear(); + const d = new Date(year, month, day); + if (!m[3]) { + const diffMonths = (d.getFullYear() - ref.getFullYear()) * 12 + (d.getMonth() - ref.getMonth()); + if (diffMonths < -3) d.setFullYear(year + 1); // z.B. Ref=Dez, Datum=Jan → nächstes Jahr gemeint + else if (diffMonths > 3) d.setFullYear(year - 1); // z.B. Ref=Jan, Datum=Dez → vergangenes Jahr gemeint + } + return d; +} + +// Ermittelt aus allen Angeboten den frühesten Start- und spätesten End-Termin +// (marktguru-Angebote für ein Produkt laufen i.d.R. im selben Wochenzeitraum) +function extractDateRange(offers) { + let start = null, end = null; + for (const o of offers) { + const text = o.dateRange || ''; + const found = [...text.matchAll(/\d{1,2}\.\d{1,2}\.(?:\d{4})?/g)].map(m => m[0]); + if (found.length >= 2) { + const d1 = parseGermanDate(found[0]); + const d2 = parseGermanDate(found[1]); + if (d1 && (!start || d1 < start)) start = d1; + if (d2 && (!end || d2 > end)) end = d2; + } else if (found.length === 1) { + // Nur ein Datum im Text gefunden → als Enddatum werten + const d1 = parseGermanDate(found[0]); + if (d1 && (!end || d1 > end)) end = d1; + } + } + return { start, end }; +} + +function toIsoDate(d) { + if (!d) return null; + const pad = n => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +} + // Wird von koepi/routes.js nach jedem Check (Cron oder manueller Test) aufgerufen function publishKoepiState({ offers, changed }) { if (!client?.connected) return; - const list = offers || []; - const best = list.length - ? [...list].sort((a, b) => parsePrice(a.price) - parsePrice(b.price))[0] - : null; - const state = best ? `${best.retailer}: ${best.price}` : 'keine Angebote'; + const list = [...(offers || [])].sort((a, b) => parsePrice(a.price) - parsePrice(b.price)); + + // State: alle Angebote als kompakte Zeile, auf HA-State-Limit (255 Zeichen) gekürzt + let state = list.length + ? list.map(o => `${o.retailer}: ${o.price}`).join(' | ') + : 'keine Angebote'; + if (state.length > 255) state = state.slice(0, 252) + '…'; client.publish(`${BASE_TOPIC}/koepi/state`, state, { retain: true }); client.publish(`${BASE_TOPIC}/koepi/attributes`, JSON.stringify({ @@ -124,6 +189,10 @@ function publishKoepiState({ offers, changed }) { letzter_check: new Date().toISOString(), }), { retain: true }); client.publish(`${BASE_TOPIC}/koepi/changed`, changed ? 'ON' : 'OFF', { retain: true }); + + const { start, end } = extractDateRange(list); + client.publish(`${BASE_TOPIC}/koepi/start`, toIsoDate(start) || 'unknown', { retain: true }); + client.publish(`${BASE_TOPIC}/koepi/ende`, toIsoDate(end) || 'unknown', { retain: true }); } // Wird jede Minute vom Scheduler in index.js aufgerufen