feat: KöPi HA-Button sendet immer Pushover, Angebote-Sensor zeigt alle Angebote, neue Start/Ende-Datum-Sensoren

This commit is contained in:
2026-07-10 19:24:00 +02:00
parent 6ee5689545
commit 8a3b6ab912

View File

@@ -59,7 +59,7 @@ function publishDiscovery() {
if (!client) return; if (!client) return;
const entities = [ const entities = [
['sensor', 'dickendock_koepi_angebot', { ['sensor', 'dickendock_koepi_angebot', {
name: 'KöPi bestes Angebot', name: 'KöPi Angebote',
unique_id: 'dickendock_koepi_bestes_angebot', unique_id: 'dickendock_koepi_bestes_angebot',
state_topic: `${BASE_TOPIC}/koepi/state`, state_topic: `${BASE_TOPIC}/koepi/state`,
json_attributes_topic: `${BASE_TOPIC}/koepi/attributes`, json_attributes_topic: `${BASE_TOPIC}/koepi/attributes`,
@@ -85,6 +85,24 @@ function publishDiscovery() {
availability_topic: AVAILABILITY_TOPIC, availability_topic: AVAILABILITY_TOPIC,
device: DEVICE, 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', { ['sensor', 'dickendock_online', {
name: 'DickenDock Nutzer online', name: 'DickenDock Nutzer online',
unique_id: 'dickendock_nutzer_online', unique_id: 'dickendock_nutzer_online',
@@ -108,14 +126,61 @@ function parsePrice(str) {
return Number.isNaN(n) ? Infinity : n; 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 // Wird von koepi/routes.js nach jedem Check (Cron oder manueller Test) aufgerufen
function publishKoepiState({ offers, changed }) { function publishKoepiState({ offers, changed }) {
if (!client?.connected) return; if (!client?.connected) return;
const list = offers || []; const list = [...(offers || [])].sort((a, b) => parsePrice(a.price) - parsePrice(b.price));
const best = list.length
? [...list].sort((a, b) => parsePrice(a.price) - parsePrice(b.price))[0] // State: alle Angebote als kompakte Zeile, auf HA-State-Limit (255 Zeichen) gekürzt
: null; let state = list.length
const state = best ? `${best.retailer}: ${best.price}` : 'keine Angebote'; ? 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/state`, state, { retain: true });
client.publish(`${BASE_TOPIC}/koepi/attributes`, JSON.stringify({ client.publish(`${BASE_TOPIC}/koepi/attributes`, JSON.stringify({
@@ -124,6 +189,10 @@ function publishKoepiState({ offers, changed }) {
letzter_check: new Date().toISOString(), letzter_check: new Date().toISOString(),
}), { retain: true }); }), { retain: true });
client.publish(`${BASE_TOPIC}/koepi/changed`, changed ? 'ON' : 'OFF', { 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 // Wird jede Minute vom Scheduler in index.js aufgerufen