feat: Home Assistant MQTT Integration - KöPi Angebote, Änderungs-Sensor, Check-Button, Nutzer-online-Sensor
This commit is contained in:
@@ -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
144
backend/src/mqtt.js
Normal 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 };
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user