feat: Home Assistant MQTT Integration - KöPi Angebote, Änderungs-Sensor, Check-Button, Nutzer-online-Sensor

This commit is contained in:
2026-07-10 17:53:35 +02:00
parent aa5c67d9f5
commit 5cbed956f6
5 changed files with 177 additions and 15 deletions

View File

@@ -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(`

144
backend/src/mqtt.js Normal file
View File

@@ -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 };

View File

@@ -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);