// ── Home Assistant MQTT Integration ────────────────────────────────────────── // Verbindet sich zum Mosquitto-Broker (HA Add-on) und meldet DickenDock-Entitäten // per HA MQTT Discovery an. Kein Custom Component in HA nötig — Entitäten // erscheinen automatisch unter "Einstellungen → Geräte & Dienste → MQTT". // // Namenskonvention: alle Entity-Namen beginnen mit ihrem Bereich ("KöPi", "Media", // "3D", "System"), damit sie in Home Assistant alphabetisch sortiert gruppiert // untereinander stehen. // // Aktiv nur, wenn MQTT_HOST in der .env gesetzt ist. Ohne Konfiguration bleibt // der Rest der App unverändert lauffähig (alle publish-Funktionen sind No-Ops). const mqtt = require('mqtt'); const db = require('./db'); const BASE_TOPIC = process.env.MQTT_BASE_TOPIC || 'dickendock'; const AVAILABILITY_TOPIC = `${BASE_TOPIC}/status`; const KOEPI_CHECK_CMD_TOPIC = `${BASE_TOPIC}/koepi/check/set`; const MEDIA_ACK_ALL_CMD_TOPIC = `${BASE_TOPIC}/media/ack_all/set`; const MEDIA_ACK_TOPIC_PREFIX = `${BASE_TOPIC}/media/ack/`; // + {id}/set const MEDIA_ACK_SUBSCRIBE = `${BASE_TOPIC}/media/ack/+/set`; const DEVICE = { identifiers: ['dickendock'], name: 'DickenDock', manufacturer: 'Flo', model: 'DockStation', }; let client = null; let koepiCheckHandler = null; // wird von index.js gesetzt, um Zirkel-Requires zu vermeiden let mediaAckHandler = null; // (id) => void — einzelnen Favoriten quittieren let mediaAckAllHandler = null; // () => void — alle Favoriten quittieren // Zuletzt published dynamische Media-Quittier-Buttons (zum sauberen Retracten // wenn ein Favorit quittiert/gelöscht wurde und der Button verschwinden soll) let publishedMediaAckIds = new Set(); function connectMqtt() { if (!process.env.MQTT_HOST) { console.log('MQTT: MQTT_HOST nicht gesetzt – Home-Assistant-Integration deaktiviert.'); return; } client = mqtt.connect({ host: process.env.MQTT_HOST, port: parseInt(process.env.MQTT_PORT || '1883', 10), username: process.env.MQTT_USERNAME, password: process.env.MQTT_PASSWORD, will: { topic: AVAILABILITY_TOPIC, payload: 'offline', retain: true }, reconnectPeriod: 5000, }); client.on('connect', () => { console.log(`MQTT verbunden: ${process.env.MQTT_HOST}:${process.env.MQTT_PORT || 1883}`); client.publish(AVAILABILITY_TOPIC, 'online', { retain: true }); publishDiscovery(); client.subscribe(KOEPI_CHECK_CMD_TOPIC); client.subscribe(MEDIA_ACK_ALL_CMD_TOPIC); client.subscribe(MEDIA_ACK_SUBSCRIBE); }); client.on('message', async (topic) => { try { if (topic === KOEPI_CHECK_CMD_TOPIC && typeof koepiCheckHandler === 'function') { await koepiCheckHandler(); } else if (topic === MEDIA_ACK_ALL_CMD_TOPIC && typeof mediaAckAllHandler === 'function') { await mediaAckAllHandler(); } else if (topic.startsWith(MEDIA_ACK_TOPIC_PREFIX) && typeof mediaAckHandler === 'function') { const id = topic.slice(MEDIA_ACK_TOPIC_PREFIX.length).replace(/\/set$/, ''); if (id) await mediaAckHandler(id); } } catch (e) { console.error('MQTT Command-Fehler:', e.message); } }); client.on('error', (e) => console.error('MQTT Fehler:', e.message)); } function publishDiscovery() { if (!client) return; const entities = [ // ── KöPi ────────────────────────────────────────────────────────────── ['sensor', 'dickendock_koepi_angebot', { name: 'KöPi Angebote', unique_id: 'dickendock_koepi_bestes_angebot', state_topic: `${BASE_TOPIC}/koepi/state`, json_attributes_topic: `${BASE_TOPIC}/koepi/attributes`, icon: 'mdi:beer', availability_topic: AVAILABILITY_TOPIC, device: DEVICE, }], ['binary_sensor', 'dickendock_koepi_changed', { name: 'KöPi Angebot geändert', unique_id: 'dickendock_koepi_changed', state_topic: `${BASE_TOPIC}/koepi/changed`, payload_on: 'ON', payload_off: 'OFF', icon: 'mdi:bell-alert', availability_topic: AVAILABILITY_TOPIC, device: DEVICE, }], ['button', 'dickendock_koepi_check', { name: 'KöPi Check jetzt', unique_id: 'dickendock_koepi_check', command_topic: KOEPI_CHECK_CMD_TOPIC, icon: 'mdi:refresh', 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, }], // ── Media ───────────────────────────────────────────────────────────── ['sensor', 'dickendock_media_anfragen', { name: 'Media Anfragen offen', unique_id: 'dickendock_media_anfragen', state_topic: `${BASE_TOPIC}/media/anfragen`, json_attributes_topic: `${BASE_TOPIC}/media/anfragen_attributes`, icon: 'mdi:movie-open-star', unit_of_measurement: 'Anfragen', state_class: 'measurement', availability_topic: AVAILABILITY_TOPIC, device: DEVICE, }], ['button', 'dickendock_media_ack_all', { name: 'Media Alle quittieren', unique_id: 'dickendock_media_ack_all', command_topic: MEDIA_ACK_ALL_CMD_TOPIC, icon: 'mdi:check-all', availability_topic: AVAILABILITY_TOPIC, device: DEVICE, }], // ── 3D ──────────────────────────────────────────────────────────────── ['sensor', 'dickendock_3d_umsatz_monat', { name: '3D Umsatz Monat', unique_id: 'dickendock_3ddruck_umsatz_monat', state_topic: `${BASE_TOPIC}/druck/umsatz_monat`, icon: 'mdi:cash-multiple', device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement', availability_topic: AVAILABILITY_TOPIC, device: DEVICE, }], ['sensor', 'dickendock_3d_umsatz_gesamt', { name: '3D Umsatz Gesamt', unique_id: 'dickendock_3d_umsatz_gesamt', state_topic: `${BASE_TOPIC}/druck/umsatz_gesamt`, icon: 'mdi:cash-multiple', device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement', availability_topic: AVAILABILITY_TOPIC, device: DEVICE, }], ['sensor', 'dickendock_3d_grundkosten_monat', { name: '3D Grundkosten Monat', unique_id: 'dickendock_3d_grundkosten_monat', state_topic: `${BASE_TOPIC}/druck/grundkosten_monat`, icon: 'mdi:currency-eur-off', device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement', availability_topic: AVAILABILITY_TOPIC, device: DEVICE, }], ['sensor', 'dickendock_3d_grundkosten_gesamt', { name: '3D Grundkosten Gesamt', unique_id: 'dickendock_3d_grundkosten_gesamt', state_topic: `${BASE_TOPIC}/druck/grundkosten_gesamt`, icon: 'mdi:currency-eur-off', device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement', availability_topic: AVAILABILITY_TOPIC, device: DEVICE, }], ['sensor', 'dickendock_3d_rohgewinn_monat', { name: '3D Rohgewinn Monat', unique_id: 'dickendock_3d_rohgewinn_monat', state_topic: `${BASE_TOPIC}/druck/rohgewinn_monat`, icon: 'mdi:chart-line', device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement', availability_topic: AVAILABILITY_TOPIC, device: DEVICE, }], ['sensor', 'dickendock_3d_rohgewinn_gesamt', { name: '3D Rohgewinn Gesamt', unique_id: 'dickendock_3d_rohgewinn_gesamt', state_topic: `${BASE_TOPIC}/druck/rohgewinn_gesamt`, icon: 'mdi:chart-line', device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement', availability_topic: AVAILABILITY_TOPIC, device: DEVICE, }], ['sensor', 'dickendock_3d_ausgaben_monat', { name: '3D Ausgaben Monat', unique_id: 'dickendock_3d_ausgaben_monat', state_topic: `${BASE_TOPIC}/druck/ausgaben_monat`, icon: 'mdi:receipt-text-minus', device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement', availability_topic: AVAILABILITY_TOPIC, device: DEVICE, }], ['sensor', 'dickendock_3d_ausgaben_gesamt', { name: '3D Ausgaben Gesamt', unique_id: 'dickendock_3d_ausgaben_gesamt', state_topic: `${BASE_TOPIC}/druck/ausgaben_gesamt`, icon: 'mdi:receipt-text-minus', device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement', availability_topic: AVAILABILITY_TOPIC, device: DEVICE, }], ['sensor', 'dickendock_3d_nettogewinn_monat', { name: '3D Nettogewinn Monat', unique_id: 'dickendock_3d_nettogewinn_monat', state_topic: `${BASE_TOPIC}/druck/nettogewinn_monat`, icon: 'mdi:cash-check', device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement', availability_topic: AVAILABILITY_TOPIC, device: DEVICE, }], ['sensor', 'dickendock_3d_nettogewinn_gesamt', { name: '3D Nettogewinn Gesamt', unique_id: 'dickendock_3d_nettogewinn_gesamt', state_topic: `${BASE_TOPIC}/druck/nettogewinn_gesamt`, icon: 'mdi:cash-check', device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement', availability_topic: AVAILABILITY_TOPIC, device: DEVICE, }], ['sensor', 'dickendock_3d_bestellungen_offen', { name: '3D Bestellungen offen', unique_id: 'dickendock_bestellungen_offen', state_topic: `${BASE_TOPIC}/druck/bestellungen_offen`, icon: 'mdi:cube-send', unit_of_measurement: 'Bestellungen', state_class: 'measurement', availability_topic: AVAILABILITY_TOPIC, device: DEVICE, }], ['sensor', 'dickendock_3d_bestellungen_warteliste', { name: '3D Bestellungen Warteliste', unique_id: 'dickendock_3d_bestellungen_warteliste', state_topic: `${BASE_TOPIC}/druck/bestellungen_warteliste`, icon: 'mdi:timer-sand', unit_of_measurement: 'Bestellungen', state_class: 'measurement', availability_topic: AVAILABILITY_TOPIC, device: DEVICE, }], ['sensor', 'dickendock_3d_bestellungen_in_arbeit', { name: '3D Bestellungen In Arbeit', unique_id: 'dickendock_3d_bestellungen_in_arbeit', state_topic: `${BASE_TOPIC}/druck/bestellungen_in_arbeit`, icon: 'mdi:printer-3d-nozzle', unit_of_measurement: 'Bestellungen', state_class: 'measurement', availability_topic: AVAILABILITY_TOPIC, device: DEVICE, }], ['sensor', 'dickendock_3d_bestellungen_fertig', { name: '3D Bestellungen Fertig', unique_id: 'dickendock_3d_bestellungen_fertig', state_topic: `${BASE_TOPIC}/druck/bestellungen_fertig`, icon: 'mdi:check-circle-outline', unit_of_measurement: 'Bestellungen', state_class: 'measurement', availability_topic: AVAILABILITY_TOPIC, device: DEVICE, }], ['sensor', 'dickendock_3d_bestellungen_bezahlt', { name: '3D Bestellungen Bezahlt', unique_id: 'dickendock_3d_bestellungen_bezahlt', state_topic: `${BASE_TOPIC}/druck/bestellungen_bezahlt`, icon: 'mdi:cash-check', unit_of_measurement: 'Bestellungen', state_class: 'measurement', availability_topic: AVAILABILITY_TOPIC, device: DEVICE, }], ['sensor', 'dickendock_3d_bestellungen_abgeschlossen', { name: '3D Bestellungen Abgeschlossen', unique_id: 'dickendock_3d_bestellungen_abgeschlossen', state_topic: `${BASE_TOPIC}/druck/bestellungen_abgeschlossen`, icon: 'mdi:archive-check-outline', unit_of_measurement: 'Bestellungen', state_class: 'measurement', availability_topic: AVAILABILITY_TOPIC, device: DEVICE, }], ['sensor', 'dickendock_3d_bestellungen_gesamt', { name: '3D Bestellungen Gesamt', unique_id: 'dickendock_3d_bestellungen_gesamt', state_topic: `${BASE_TOPIC}/druck/bestellungen_gesamt`, icon: 'mdi:cube-outline', unit_of_measurement: 'Bestellungen', state_class: 'measurement', availability_topic: AVAILABILITY_TOPIC, device: DEVICE, }], // ── System ──────────────────────────────────────────────────────────── ['sensor', 'dickendock_system_online', { name: 'System Nutzer online', unique_id: 'dickendock_nutzer_online', state_topic: `${BASE_TOPIC}/presence/state`, json_attributes_topic: `${BASE_TOPIC}/presence/attributes`, icon: 'mdi:account-multiple', unit_of_measurement: 'Nutzer', state_class: 'measurement', availability_topic: AVAILABILITY_TOPIC, device: DEVICE, }], ]; for (const [component, objectId, payload] of entities) { client.publish(`homeassistant/${component}/${objectId}/config`, JSON.stringify(payload), { retain: true }); } } function round2(n) { return Math.round((n || 0) * 100) / 100; } function parsePrice(str) { if (!str) return Infinity; const n = parseFloat(String(str).replace(/[^0-9,.-]/g, '').replace(',', '.')); 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) { 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 || [])].sort((a, b) => parsePrice(a.price) - parsePrice(b.price)); 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({ angebote: list, anzahl: list.length, 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 function publishPresence() { if (!client?.connected) return; const online = db.prepare(` SELECT username FROM users WHERE last_active_at > datetime('now','localtime','-5 minutes') `).all(); client.publish(`${BASE_TOPIC}/presence/state`, String(online.length), { retain: true }); client.publish(`${BASE_TOPIC}/presence/attributes`, JSON.stringify({ nutzer: online.map(u => u.username), }), { retain: true }); } // Wird jede Minute vom Scheduler UND direkt nach jeder Quittierung aufgerufen. // Offene (unquittierte) Media-Favoriten, systemweit über alle Nutzer. // Legt zusätzlich pro offener Anfrage einen eigenen "Quittieren"-Button in HA an // und entfernt Buttons für Anfragen, die inzwischen quittiert/gelöscht wurden. function publishMediaAnfragen() { if (!client?.connected) return; const open = db.prepare(` SELECT f.id, f.title, f.added_at, u.username FROM movie_favorites f JOIN users u ON u.id = f.user_id WHERE f.acknowledged = 0 ORDER BY f.added_at DESC `).all(); client.publish(`${BASE_TOPIC}/media/anfragen`, String(open.length), { retain: true }); client.publish(`${BASE_TOPIC}/media/anfragen_attributes`, JSON.stringify({ anfragen: open }), { retain: true }); const currentIds = new Set(open.map(f => String(f.id))); // Neue Buttons für neu hinzugekommene offene Anfragen anlegen for (const fav of open) { const id = String(fav.id); if (!publishedMediaAckIds.has(id)) { const label = `Media Quittieren: ${fav.title}${fav.username ? ' (' + fav.username + ')' : ''}`.slice(0, 255); client.publish(`homeassistant/button/dickendock_media_ack_${id}/config`, JSON.stringify({ name: label, unique_id: `dickendock_media_ack_${id}`, command_topic: `${MEDIA_ACK_TOPIC_PREFIX}${id}/set`, icon: 'mdi:check-circle-outline', availability_topic: AVAILABILITY_TOPIC, device: DEVICE, }), { retain: true }); } } // Buttons für inzwischen quittierte/gelöschte Anfragen zurückziehen (leeres retained Payload) for (const id of publishedMediaAckIds) { if (!currentIds.has(id)) { client.publish(`homeassistant/button/dickendock_media_ack_${id}/config`, '', { retain: true }); } } publishedMediaAckIds = currentIds; } // Umsatz eines einzelnen Auftrags — Festpreis falls gesetzt, sonst Summe der // Positions-Festpreise (spiegelt die Logik aus statistik/routes.js) function getOrderRevenue(order) { if (order.custom_price != null && order.custom_price > 0) return parseFloat(order.custom_price); const r = db.prepare(` SELECT COALESCE(SUM(custom_price * stueckzahl), 0) AS s FROM order_items WHERE order_id=? AND custom_price IS NOT NULL `).get(order.id); return r?.s || 0; } // Grundkosten (Material/Zeit-Basis) eines Auftrags — spiegelt statistik/routes.js function getOrderBaseCost(orderId) { const r = db.prepare(` SELECT COALESCE(SUM(COALESCE(c.preis_freundschaft, oi.preis_freundschaft, 0) * oi.stueckzahl), 0) AS s FROM order_items oi LEFT JOIN calculations c ON c.id = oi.calculation_id WHERE oi.order_id = ? `).get(orderId); return r?.s || 0; } // Wird jede Minute vom Scheduler aufgerufen — umfangreiche 3D-Druck-Statistik, // systemweit über alle Nutzer, jeweils für den laufenden Monat und komplett (all-time) function publishDruckStatistik() { if (!client?.connected) return; const now = new Date(); const pad = n => String(n).padStart(2, '0'); const monatsAnfang = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-01`; const heute = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`; const allOrders = db.prepare('SELECT * FROM orders').all(); const paidAll = allOrders.filter(o => !!o.bezahlt); const paidMonat = paidAll.filter(o => { const d = (o.bezahlt_am || o.created_at || '').slice(0, 10); return d >= monatsAnfang && d <= heute; }); const umsatzGesamt = paidAll.reduce((s, o) => s + getOrderRevenue(o), 0); const umsatzMonat = paidMonat.reduce((s, o) => s + getOrderRevenue(o), 0); const kostenGesamt = paidAll.reduce((s, o) => s + getOrderBaseCost(o.id), 0); const kostenMonat = paidMonat.reduce((s, o) => s + getOrderBaseCost(o.id), 0); const rohgewinnGesamt = umsatzGesamt - kostenGesamt; const rohgewinnMonat = umsatzMonat - kostenMonat; const ausgabenGesamt = db.prepare('SELECT COALESCE(SUM(amount),0) AS s FROM expenses').get().s; const ausgabenMonat = db.prepare( 'SELECT COALESCE(SUM(amount),0) AS s FROM expenses WHERE date>=? AND date<=?' ).get(monatsAnfang, heute).s; const nettoGesamt = rohgewinnGesamt - ausgabenGesamt; const nettoMonat = rohgewinnMonat - ausgabenMonat; const byStatus = { warteliste: 0, in_arbeit: 0, fertig: 0, bezahlt: 0, abgeschlossen: 0 }; for (const o of allOrders) { if (o.bezahlt && o.abgeholt) byStatus.abgeschlossen++; else if (o.bezahlt) byStatus.bezahlt++; else if (o.status in byStatus) byStatus[o.status]++; } const offen = allOrders.length - byStatus.abgeschlossen; const pub = (topic, val) => client.publish(`${BASE_TOPIC}/${topic}`, String(val), { retain: true }); pub('druck/umsatz_monat', round2(umsatzMonat)); pub('druck/umsatz_gesamt', round2(umsatzGesamt)); pub('druck/grundkosten_monat', round2(kostenMonat)); pub('druck/grundkosten_gesamt', round2(kostenGesamt)); pub('druck/rohgewinn_monat', round2(rohgewinnMonat)); pub('druck/rohgewinn_gesamt', round2(rohgewinnGesamt)); pub('druck/ausgaben_monat', round2(ausgabenMonat)); pub('druck/ausgaben_gesamt', round2(ausgabenGesamt)); pub('druck/nettogewinn_monat', round2(nettoMonat)); pub('druck/nettogewinn_gesamt', round2(nettoGesamt)); pub('druck/bestellungen_offen', offen); pub('druck/bestellungen_warteliste', byStatus.warteliste); pub('druck/bestellungen_in_arbeit', byStatus.in_arbeit); pub('druck/bestellungen_fertig', byStatus.fertig); pub('druck/bestellungen_bezahlt', byStatus.bezahlt); pub('druck/bestellungen_abgeschlossen', byStatus.abgeschlossen); pub('druck/bestellungen_gesamt', allOrders.length); } // Wird von index.js gesetzt, um Zirkel-Requires zu vermeiden function setKoepiCheckHandler(fn) { koepiCheckHandler = fn; } function setMediaAckHandler(fn) { mediaAckHandler = fn; } function setMediaAckAllHandler(fn) { mediaAckAllHandler = fn; } module.exports = { connectMqtt, publishKoepiState, publishPresence, publishMediaAnfragen, publishDruckStatistik, setKoepiCheckHandler, setMediaAckHandler, setMediaAckAllHandler, };