From 5cbed956f601870638fc523e24e23aa150bb886a Mon Sep 17 00:00:00 2001 From: Dicken Date: Fri, 10 Jul 2026 17:53:35 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Home=20Assistant=20MQTT=20Integration?= =?UTF-8?q?=20-=20K=C3=B6Pi=20Angebote,=20=C3=84nderungs-Sensor,=20Check-B?= =?UTF-8?q?utton,=20Nutzer-online-Sensor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/index.js | 7 ++ backend/src/mqtt.js | 144 ++++++++++++++++++++++++++++++ backend/src/tools/koepi/routes.js | 3 + env.example | 12 +++ frontend/package.json | 26 +++--- 5 files changed, 177 insertions(+), 15 deletions(-) create mode 100644 backend/src/mqtt.js create mode 100644 env.example diff --git a/backend/src/index.js b/backend/src/index.js index ab96910..c2a314f 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -257,6 +257,12 @@ server.listen(4000, () => console.log('🚀 Dicken Dock läuft auf Port 4000')); // ── Push-Zeitplaner Hintergrund-Job ────────────────────────────────────────── const db = require('./db'); const koepiRoutes = require('./tools/koepi/routes'); +const mqttClient = require('./mqtt'); + +mqttClient.connectMqtt(); +// HA-Button "KöPi Check jetzt" löst denselben Check wie der 06:00-Cron aus +// (respektiert den normalen Diff — Pushover geht nur bei echter Änderung raus) +mqttClient.setKoepiCheckHandler(() => koepiRoutes.runDailyCheck()); async function sendPushoverMsg(userKey, appToken, message, opts = {}) { try { @@ -282,6 +288,7 @@ setTimeout(() => { }, msToNextMinute); async function runScheduler() { + mqttClient.publishPresence(); try { const now = db.prepare("SELECT datetime('now','localtime') as t").get().t; const due = db.prepare(` diff --git a/backend/src/mqtt.js b/backend/src/mqtt.js new file mode 100644 index 0000000..dadba75 --- /dev/null +++ b/backend/src/mqtt.js @@ -0,0 +1,144 @@ +// ── 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". +// +// 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 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 + +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.on('message', async (topic) => { + if (topic === KOEPI_CHECK_CMD_TOPIC && typeof koepiCheckHandler === 'function') { + try { await koepiCheckHandler(); } + catch (e) { console.error('MQTT KöPi-Check Fehler:', e.message); } + } + }); + + client.on('error', (e) => console.error('MQTT Fehler:', e.message)); +} + +function publishDiscovery() { + if (!client) return; + const entities = [ + ['sensor', 'dickendock_koepi_angebot', { + name: 'KöPi bestes Angebot', + 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_online', { + name: 'DickenDock 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 parsePrice(str) { + if (!str) return Infinity; + const n = parseFloat(String(str).replace(/[^0-9,.-]/g, '').replace(',', '.')); + return Number.isNaN(n) ? Infinity : n; +} + +// 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'; + + 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 }); +} + +// 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 von index.js gesetzt, damit der HA-Button ohne Require-Zirkel den Check auslösen kann +function setKoepiCheckHandler(fn) { koepiCheckHandler = fn; } + +module.exports = { connectMqtt, publishKoepiState, publishPresence, setKoepiCheckHandler }; diff --git a/backend/src/tools/koepi/routes.js b/backend/src/tools/koepi/routes.js index 2bb038b..f0362fd 100644 --- a/backend/src/tools/koepi/routes.js +++ b/backend/src/tools/koepi/routes.js @@ -1,6 +1,7 @@ const express = require('express'); const https = require('https'); const db = require('../../db'); +const mqttClient = require('../../mqtt'); const { authenticate } = require('../../middleware/auth'); const router = express.Router(); @@ -311,6 +312,7 @@ async function runDailyCheck(forceNotify = false) { if (!changed && !forceNotify) { console.log('🍺 KöPi-Check: keine Änderungen an den Angeboten.'); + mqttClient.publishKoepiState({ offers, changed: false }); return { changed: false, sent: false, offerCount: offers.length }; } @@ -318,6 +320,7 @@ async function runDailyCheck(forceNotify = false) { db.prepare('INSERT OR REPLACE INTO admin_settings (key, value) VALUES (?, ?)') .run(KOEPI_SETTINGS_KEY, currentSig); console.log(`🍺 KöPi-Check: ${offers.length} Angebot(e)${changed ? ' geändert' : ' (Test, unverändert)'} – Pushover versendet.`); + mqttClient.publishKoepiState({ offers, changed }); return { changed, sent: true, offerCount: offers.length }; } catch (e) { console.error('KöPi Tages-Check Fehler:', e.message); diff --git a/env.example b/env.example new file mode 100644 index 0000000..598cf7d --- /dev/null +++ b/env.example @@ -0,0 +1,12 @@ +# Kopiere diese Datei zu .env und passe die Werte an +# .env wird NICHT ins Git eingecheckt + +JWT_SECRET=ersetze-mich-mit-einem-langen-zufaelligen-string + +# Home Assistant / Mosquitto MQTT (optional — ohne MQTT_HOST bleibt die +# Integration deaktiviert, der Rest der App läuft unverändert) +MQTT_HOST=192.168.1.222 +MQTT_PORT=1883 +MQTT_USERNAME=dickendock +MQTT_PASSWORD=ersetze-mich +MQTT_BASE_TOPIC=dickendock diff --git a/frontend/package.json b/frontend/package.json index 1935a6d..f7007f5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,19 +1,15 @@ { - "name": "dickendock-frontend", + "name": "dickendock-backend", "version": "1.0.0", - "scripts": { - "prebuild": "node generate-icons.mjs", - "dev": "vite", - "build": "vite build" - }, + "main": "src/index.js", "dependencies": { - "qrcode": "^1.5.4", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "recharts": "^2.12.0" - }, - "devDependencies": { - "@vitejs/plugin-react": "^4.0.0", - "vite": "^5.0.0" + "bcryptjs": "^2.4.3", + "better-sqlite3": "^9.4.3", + "express": "^4.18.2", + "jsonwebtoken": "^9.0.2", + "multer": "^1.4.5-lts.1", + "ws": "^8.17.1", + "puppeteer-core": "^22.0.0", + "mqtt": "^5.10.1" } -} +} \ No newline at end of file