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 ──────────────────────────────────────────
|
// ── Push-Zeitplaner Hintergrund-Job ──────────────────────────────────────────
|
||||||
const db = require('./db');
|
const db = require('./db');
|
||||||
const koepiRoutes = require('./tools/koepi/routes');
|
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 = {}) {
|
async function sendPushoverMsg(userKey, appToken, message, opts = {}) {
|
||||||
try {
|
try {
|
||||||
@@ -282,6 +288,7 @@ setTimeout(() => {
|
|||||||
}, msToNextMinute);
|
}, msToNextMinute);
|
||||||
|
|
||||||
async function runScheduler() {
|
async function runScheduler() {
|
||||||
|
mqttClient.publishPresence();
|
||||||
try {
|
try {
|
||||||
const now = db.prepare("SELECT datetime('now','localtime') as t").get().t;
|
const now = db.prepare("SELECT datetime('now','localtime') as t").get().t;
|
||||||
const due = db.prepare(`
|
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 express = require('express');
|
||||||
const https = require('https');
|
const https = require('https');
|
||||||
const db = require('../../db');
|
const db = require('../../db');
|
||||||
|
const mqttClient = require('../../mqtt');
|
||||||
const { authenticate } = require('../../middleware/auth');
|
const { authenticate } = require('../../middleware/auth');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -311,6 +312,7 @@ async function runDailyCheck(forceNotify = false) {
|
|||||||
|
|
||||||
if (!changed && !forceNotify) {
|
if (!changed && !forceNotify) {
|
||||||
console.log('🍺 KöPi-Check: keine Änderungen an den Angeboten.');
|
console.log('🍺 KöPi-Check: keine Änderungen an den Angeboten.');
|
||||||
|
mqttClient.publishKoepiState({ offers, changed: false });
|
||||||
return { changed: false, sent: false, offerCount: offers.length };
|
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 (?, ?)')
|
db.prepare('INSERT OR REPLACE INTO admin_settings (key, value) VALUES (?, ?)')
|
||||||
.run(KOEPI_SETTINGS_KEY, currentSig);
|
.run(KOEPI_SETTINGS_KEY, currentSig);
|
||||||
console.log(`🍺 KöPi-Check: ${offers.length} Angebot(e)${changed ? ' geändert' : ' (Test, unverändert)'} – Pushover versendet.`);
|
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 };
|
return { changed, sent: true, offerCount: offers.length };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('KöPi Tages-Check Fehler:', e.message);
|
console.error('KöPi Tages-Check Fehler:', e.message);
|
||||||
|
|||||||
12
env.example
Normal file
12
env.example
Normal file
@@ -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
|
||||||
@@ -1,19 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "dickendock-frontend",
|
"name": "dickendock-backend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"scripts": {
|
"main": "src/index.js",
|
||||||
"prebuild": "node generate-icons.mjs",
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "vite build"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"qrcode": "^1.5.4",
|
"bcryptjs": "^2.4.3",
|
||||||
"react": "^18.2.0",
|
"better-sqlite3": "^9.4.3",
|
||||||
"react-dom": "^18.2.0",
|
"express": "^4.18.2",
|
||||||
"recharts": "^2.12.0"
|
"jsonwebtoken": "^9.0.2",
|
||||||
},
|
"multer": "^1.4.5-lts.1",
|
||||||
"devDependencies": {
|
"ws": "^8.17.1",
|
||||||
"@vitejs/plugin-react": "^4.0.0",
|
"puppeteer-core": "^22.0.0",
|
||||||
"vite": "^5.0.0"
|
"mqtt": "^5.10.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user