feat: HA-Entitaeten mit Bereichs-Praefixen, Media-Anfragen quittierbar per Button, umfangreiche 3D-Statistik
This commit is contained in:
@@ -257,12 +257,23 @@ 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 mediaRoutes = require('./tools/media/routes');
|
||||||
const mqttClient = require('./mqtt');
|
const mqttClient = require('./mqtt');
|
||||||
|
|
||||||
mqttClient.connectMqtt();
|
mqttClient.connectMqtt();
|
||||||
// HA-Button "KöPi Check jetzt" löst denselben Check wie der 06:00-Cron aus
|
// 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)
|
// (respektiert den normalen Diff — Pushover geht nur bei echter Änderung raus)
|
||||||
mqttClient.setKoepiCheckHandler(() => koepiRoutes.runDailyCheck());
|
mqttClient.setKoepiCheckHandler(() => koepiRoutes.runDailyCheck());
|
||||||
|
// HA-Buttons "Media Quittieren" / "Media Alle quittieren" — danach sofort den
|
||||||
|
// Media-Anfragen-Sensor + die dynamischen Buttons in HA aktualisieren
|
||||||
|
mqttClient.setMediaAckHandler((id) => {
|
||||||
|
mediaRoutes.ackFavorite(id);
|
||||||
|
mqttClient.publishMediaAnfragen();
|
||||||
|
});
|
||||||
|
mqttClient.setMediaAckAllHandler(() => {
|
||||||
|
mediaRoutes.ackAllFavorites();
|
||||||
|
mqttClient.publishMediaAnfragen();
|
||||||
|
});
|
||||||
|
|
||||||
async function sendPushoverMsg(userKey, appToken, message, opts = {}) {
|
async function sendPushoverMsg(userKey, appToken, message, opts = {}) {
|
||||||
try {
|
try {
|
||||||
@@ -290,7 +301,6 @@ setTimeout(() => {
|
|||||||
async function runScheduler() {
|
async function runScheduler() {
|
||||||
mqttClient.publishPresence();
|
mqttClient.publishPresence();
|
||||||
mqttClient.publishMediaAnfragen();
|
mqttClient.publishMediaAnfragen();
|
||||||
mqttClient.publishBestellungen();
|
|
||||||
mqttClient.publishDruckStatistik();
|
mqttClient.publishDruckStatistik();
|
||||||
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;
|
||||||
|
|||||||
@@ -3,15 +3,22 @@
|
|||||||
// per HA MQTT Discovery an. Kein Custom Component in HA nötig — Entitäten
|
// per HA MQTT Discovery an. Kein Custom Component in HA nötig — Entitäten
|
||||||
// erscheinen automatisch unter "Einstellungen → Geräte & Dienste → MQTT".
|
// 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
|
// 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).
|
// der Rest der App unverändert lauffähig (alle publish-Funktionen sind No-Ops).
|
||||||
|
|
||||||
const mqtt = require('mqtt');
|
const mqtt = require('mqtt');
|
||||||
const db = require('./db');
|
const db = require('./db');
|
||||||
|
|
||||||
const BASE_TOPIC = process.env.MQTT_BASE_TOPIC || 'dickendock';
|
const BASE_TOPIC = process.env.MQTT_BASE_TOPIC || 'dickendock';
|
||||||
const AVAILABILITY_TOPIC = `${BASE_TOPIC}/status`;
|
const AVAILABILITY_TOPIC = `${BASE_TOPIC}/status`;
|
||||||
const KOEPI_CHECK_CMD_TOPIC = `${BASE_TOPIC}/koepi/check/set`;
|
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 = {
|
const DEVICE = {
|
||||||
identifiers: ['dickendock'],
|
identifiers: ['dickendock'],
|
||||||
@@ -21,7 +28,13 @@ const DEVICE = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let client = null;
|
let client = null;
|
||||||
let koepiCheckHandler = null; // wird von index.js gesetzt, um Zirkel-Requires zu vermeiden
|
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() {
|
function connectMqtt() {
|
||||||
if (!process.env.MQTT_HOST) {
|
if (!process.env.MQTT_HOST) {
|
||||||
@@ -43,12 +56,22 @@ function connectMqtt() {
|
|||||||
client.publish(AVAILABILITY_TOPIC, 'online', { retain: true });
|
client.publish(AVAILABILITY_TOPIC, 'online', { retain: true });
|
||||||
publishDiscovery();
|
publishDiscovery();
|
||||||
client.subscribe(KOEPI_CHECK_CMD_TOPIC);
|
client.subscribe(KOEPI_CHECK_CMD_TOPIC);
|
||||||
|
client.subscribe(MEDIA_ACK_ALL_CMD_TOPIC);
|
||||||
|
client.subscribe(MEDIA_ACK_SUBSCRIBE);
|
||||||
});
|
});
|
||||||
|
|
||||||
client.on('message', async (topic) => {
|
client.on('message', async (topic) => {
|
||||||
if (topic === KOEPI_CHECK_CMD_TOPIC && typeof koepiCheckHandler === 'function') {
|
try {
|
||||||
try { await koepiCheckHandler(); }
|
if (topic === KOEPI_CHECK_CMD_TOPIC && typeof koepiCheckHandler === 'function') {
|
||||||
catch (e) { console.error('MQTT KöPi-Check Fehler:', e.message); }
|
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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -58,6 +81,7 @@ function connectMqtt() {
|
|||||||
function publishDiscovery() {
|
function publishDiscovery() {
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
const entities = [
|
const entities = [
|
||||||
|
// ── KöPi ──────────────────────────────────────────────────────────────
|
||||||
['sensor', 'dickendock_koepi_angebot', {
|
['sensor', 'dickendock_koepi_angebot', {
|
||||||
name: 'KöPi Angebote',
|
name: 'KöPi Angebote',
|
||||||
unique_id: 'dickendock_koepi_bestes_angebot',
|
unique_id: 'dickendock_koepi_bestes_angebot',
|
||||||
@@ -103,17 +127,8 @@ function publishDiscovery() {
|
|||||||
availability_topic: AVAILABILITY_TOPIC,
|
availability_topic: AVAILABILITY_TOPIC,
|
||||||
device: DEVICE,
|
device: DEVICE,
|
||||||
}],
|
}],
|
||||||
['sensor', 'dickendock_online', {
|
|
||||||
name: 'DickenDock Nutzer online',
|
// ── Media ─────────────────────────────────────────────────────────────
|
||||||
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,
|
|
||||||
}],
|
|
||||||
['sensor', 'dickendock_media_anfragen', {
|
['sensor', 'dickendock_media_anfragen', {
|
||||||
name: 'Media Anfragen offen',
|
name: 'Media Anfragen offen',
|
||||||
unique_id: 'dickendock_media_anfragen',
|
unique_id: 'dickendock_media_anfragen',
|
||||||
@@ -125,25 +140,161 @@ function publishDiscovery() {
|
|||||||
availability_topic: AVAILABILITY_TOPIC,
|
availability_topic: AVAILABILITY_TOPIC,
|
||||||
device: DEVICE,
|
device: DEVICE,
|
||||||
}],
|
}],
|
||||||
['sensor', 'dickendock_bestellungen_offen', {
|
['button', 'dickendock_media_ack_all', {
|
||||||
name: 'Bestellungen offen',
|
name: 'Media Alle quittieren',
|
||||||
unique_id: 'dickendock_bestellungen_offen',
|
unique_id: 'dickendock_media_ack_all',
|
||||||
state_topic: `${BASE_TOPIC}/bestellungen/offen`,
|
command_topic: MEDIA_ACK_ALL_CMD_TOPIC,
|
||||||
json_attributes_topic: `${BASE_TOPIC}/bestellungen/attributes`,
|
icon: 'mdi:check-all',
|
||||||
icon: 'mdi:cube-send',
|
|
||||||
unit_of_measurement: 'Bestellungen',
|
|
||||||
state_class: 'measurement',
|
|
||||||
availability_topic: AVAILABILITY_TOPIC,
|
availability_topic: AVAILABILITY_TOPIC,
|
||||||
device: DEVICE,
|
device: DEVICE,
|
||||||
}],
|
}],
|
||||||
['sensor', 'dickendock_3ddruck_umsatz_monat', {
|
|
||||||
name: '3D-Druck Umsatz (Monat)',
|
// ── 3D ────────────────────────────────────────────────────────────────
|
||||||
|
['sensor', 'dickendock_3d_umsatz_monat', {
|
||||||
|
name: '3D Umsatz Monat',
|
||||||
unique_id: 'dickendock_3ddruck_umsatz_monat',
|
unique_id: 'dickendock_3ddruck_umsatz_monat',
|
||||||
state_topic: `${BASE_TOPIC}/druck/umsatz_monat`,
|
state_topic: `${BASE_TOPIC}/druck/umsatz_monat`,
|
||||||
json_attributes_topic: `${BASE_TOPIC}/druck/attributes`,
|
icon: 'mdi:cash-multiple',
|
||||||
icon: 'mdi:printer-3d',
|
device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement',
|
||||||
device_class: 'monetary',
|
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
|
||||||
unit_of_measurement: '€',
|
}],
|
||||||
|
['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',
|
state_class: 'measurement',
|
||||||
availability_topic: AVAILABILITY_TOPIC,
|
availability_topic: AVAILABILITY_TOPIC,
|
||||||
device: DEVICE,
|
device: DEVICE,
|
||||||
@@ -154,6 +305,8 @@ function publishDiscovery() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function round2(n) { return Math.round((n || 0) * 100) / 100; }
|
||||||
|
|
||||||
function parsePrice(str) {
|
function parsePrice(str) {
|
||||||
if (!str) return Infinity;
|
if (!str) return Infinity;
|
||||||
const n = parseFloat(String(str).replace(/[^0-9,.-]/g, '').replace(',', '.'));
|
const n = parseFloat(String(str).replace(/[^0-9,.-]/g, '').replace(',', '.'));
|
||||||
@@ -191,7 +344,6 @@ function extractDateRange(offers) {
|
|||||||
if (d1 && (!start || d1 < start)) start = d1;
|
if (d1 && (!start || d1 < start)) start = d1;
|
||||||
if (d2 && (!end || d2 > end)) end = d2;
|
if (d2 && (!end || d2 > end)) end = d2;
|
||||||
} else if (found.length === 1) {
|
} else if (found.length === 1) {
|
||||||
// Nur ein Datum im Text gefunden → als Enddatum werten
|
|
||||||
const d1 = parseGermanDate(found[0]);
|
const d1 = parseGermanDate(found[0]);
|
||||||
if (d1 && (!end || d1 > end)) end = d1;
|
if (d1 && (!end || d1 > end)) end = d1;
|
||||||
}
|
}
|
||||||
@@ -210,7 +362,6 @@ function publishKoepiState({ offers, changed }) {
|
|||||||
if (!client?.connected) return;
|
if (!client?.connected) return;
|
||||||
const list = [...(offers || [])].sort((a, b) => parsePrice(a.price) - parsePrice(b.price));
|
const list = [...(offers || [])].sort((a, b) => parsePrice(a.price) - parsePrice(b.price));
|
||||||
|
|
||||||
// State: alle Angebote als kompakte Zeile, auf HA-State-Limit (255 Zeichen) gekürzt
|
|
||||||
let state = list.length
|
let state = list.length
|
||||||
? list.map(o => `${o.retailer}: ${o.price}`).join(' | ')
|
? list.map(o => `${o.retailer}: ${o.price}`).join(' | ')
|
||||||
: 'keine Angebote';
|
: 'keine Angebote';
|
||||||
@@ -241,42 +392,48 @@ function publishPresence() {
|
|||||||
}), { retain: true });
|
}), { retain: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wird jede Minute vom Scheduler aufgerufen — offene (unquittierte) Media-Favoriten,
|
// Wird jede Minute vom Scheduler UND direkt nach jeder Quittierung aufgerufen.
|
||||||
// systemweit über alle Nutzer (spiegelt die Admin-Ansicht in "Media/Favoriten")
|
// 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() {
|
function publishMediaAnfragen() {
|
||||||
if (!client?.connected) return;
|
if (!client?.connected) return;
|
||||||
const offen = db.prepare('SELECT COUNT(*) AS n FROM movie_favorites WHERE acknowledged=0').get().n;
|
const open = db.prepare(`
|
||||||
const liste = db.prepare(`
|
SELECT f.id, f.title, f.added_at, u.username
|
||||||
SELECT f.title, u.username, f.added_at
|
|
||||||
FROM movie_favorites f JOIN users u ON u.id = f.user_id
|
FROM movie_favorites f JOIN users u ON u.id = f.user_id
|
||||||
WHERE f.acknowledged = 0
|
WHERE f.acknowledged = 0
|
||||||
ORDER BY f.added_at DESC LIMIT 20
|
ORDER BY f.added_at DESC
|
||||||
`).all();
|
`).all();
|
||||||
client.publish(`${BASE_TOPIC}/media/anfragen`, String(offen), { retain: true });
|
|
||||||
client.publish(`${BASE_TOPIC}/media/anfragen_attributes`, JSON.stringify({ anfragen: liste }), { retain: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wird jede Minute vom Scheduler aufgerufen — offene 3D-Druck-Bestellungen,
|
client.publish(`${BASE_TOPIC}/media/anfragen`, String(open.length), { retain: true });
|
||||||
// systemweit über alle Nutzer, Status-Verteilung als Attribut
|
client.publish(`${BASE_TOPIC}/media/anfragen_attributes`, JSON.stringify({ anfragen: open }), { retain: true });
|
||||||
function publishBestellungen() {
|
|
||||||
if (!client?.connected) return;
|
const currentIds = new Set(open.map(f => String(f.id)));
|
||||||
const orders = db.prepare('SELECT status, bezahlt, abgeholt FROM orders').all();
|
|
||||||
const byStatus = { warteliste: 0, in_arbeit: 0, fertig: 0, bezahlt: 0, abgeschlossen: 0 };
|
// Neue Buttons für neu hinzugekommene offene Anfragen anlegen
|
||||||
for (const o of orders) {
|
for (const fav of open) {
|
||||||
if (o.bezahlt && o.abgeholt) byStatus.abgeschlossen++;
|
const id = String(fav.id);
|
||||||
else if (o.bezahlt) byStatus.bezahlt++;
|
if (!publishedMediaAckIds.has(id)) {
|
||||||
else if (o.status in byStatus) byStatus[o.status]++;
|
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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const offen = orders.length - byStatus.abgeschlossen;
|
// Buttons für inzwischen quittierte/gelöschte Anfragen zurückziehen (leeres retained Payload)
|
||||||
client.publish(`${BASE_TOPIC}/bestellungen/offen`, String(offen), { retain: true });
|
for (const id of publishedMediaAckIds) {
|
||||||
client.publish(`${BASE_TOPIC}/bestellungen/attributes`, JSON.stringify({
|
if (!currentIds.has(id)) {
|
||||||
...byStatus,
|
client.publish(`homeassistant/button/dickendock_media_ack_${id}/config`, '', { retain: true });
|
||||||
gesamt: orders.length,
|
}
|
||||||
}), { retain: true });
|
}
|
||||||
|
publishedMediaAckIds = currentIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
function round2(n) { return Math.round((n || 0) * 100) / 100; }
|
|
||||||
|
|
||||||
// Umsatz eines einzelnen Auftrags — Festpreis falls gesetzt, sonst Summe der
|
// Umsatz eines einzelnen Auftrags — Festpreis falls gesetzt, sonst Summe der
|
||||||
// Positions-Festpreise (spiegelt die Logik aus statistik/routes.js)
|
// Positions-Festpreise (spiegelt die Logik aus statistik/routes.js)
|
||||||
function getOrderRevenue(order) {
|
function getOrderRevenue(order) {
|
||||||
@@ -288,8 +445,19 @@ function getOrderRevenue(order) {
|
|||||||
return r?.s || 0;
|
return r?.s || 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wird jede Minute vom Scheduler aufgerufen — 3D-Druck-Umsatz im laufenden Kalendermonat,
|
// Grundkosten (Material/Zeit-Basis) eines Auftrags — spiegelt statistik/routes.js
|
||||||
// systemweit über alle Nutzer (bezahlte Aufträge nach bezahlt_am, sonst created_at)
|
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() {
|
function publishDruckStatistik() {
|
||||||
if (!client?.connected) return;
|
if (!client?.connected) return;
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -298,32 +466,67 @@ function publishDruckStatistik() {
|
|||||||
const heute = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
const heute = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
||||||
|
|
||||||
const allOrders = db.prepare('SELECT * FROM orders').all();
|
const allOrders = db.prepare('SELECT * FROM orders').all();
|
||||||
const bezahltDiesenMonat = allOrders.filter(o => {
|
const paidAll = allOrders.filter(o => !!o.bezahlt);
|
||||||
if (!o.bezahlt) return false;
|
const paidMonat = paidAll.filter(o => {
|
||||||
const d = (o.bezahlt_am || o.created_at || '').slice(0, 10);
|
const d = (o.bezahlt_am || o.created_at || '').slice(0, 10);
|
||||||
return d >= monatsAnfang && d <= heute;
|
return d >= monatsAnfang && d <= heute;
|
||||||
});
|
});
|
||||||
const umsatzMonat = bezahltDiesenMonat.reduce((s, o) => s + getOrderRevenue(o), 0);
|
|
||||||
|
|
||||||
const ausgabenMonat = db.prepare(`
|
const umsatzGesamt = paidAll.reduce((s, o) => s + getOrderRevenue(o), 0);
|
||||||
SELECT COALESCE(SUM(amount), 0) AS s FROM expenses WHERE date>=? AND date<=?
|
const umsatzMonat = paidMonat.reduce((s, o) => s + getOrderRevenue(o), 0);
|
||||||
`).get(monatsAnfang, heute).s;
|
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 offeneAuftraege = allOrders.filter(o => !(o.bezahlt && o.abgeholt)).length;
|
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;
|
||||||
|
|
||||||
client.publish(`${BASE_TOPIC}/druck/umsatz_monat`, String(round2(umsatzMonat)), { retain: true });
|
const nettoGesamt = rohgewinnGesamt - ausgabenGesamt;
|
||||||
client.publish(`${BASE_TOPIC}/druck/attributes`, JSON.stringify({
|
const nettoMonat = rohgewinnMonat - ausgabenMonat;
|
||||||
ausgaben_monat: round2(ausgabenMonat),
|
|
||||||
netto_monat: round2(umsatzMonat - ausgabenMonat),
|
const byStatus = { warteliste: 0, in_arbeit: 0, fertig: 0, bezahlt: 0, abgeschlossen: 0 };
|
||||||
bezahlte_auftraege_monat: bezahltDiesenMonat.length,
|
for (const o of allOrders) {
|
||||||
offene_auftraege_gesamt: offeneAuftraege,
|
if (o.bezahlt && o.abgeholt) byStatus.abgeschlossen++;
|
||||||
}), { retain: true });
|
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, damit der HA-Button ohne Require-Zirkel den Check auslösen kann
|
// Wird von index.js gesetzt, um Zirkel-Requires zu vermeiden
|
||||||
function setKoepiCheckHandler(fn) { koepiCheckHandler = fn; }
|
function setKoepiCheckHandler(fn) { koepiCheckHandler = fn; }
|
||||||
|
function setMediaAckHandler(fn) { mediaAckHandler = fn; }
|
||||||
|
function setMediaAckAllHandler(fn) { mediaAckAllHandler = fn; }
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
connectMqtt, publishKoepiState, publishPresence, setKoepiCheckHandler,
|
connectMqtt,
|
||||||
publishMediaAnfragen, publishBestellungen, publishDruckStatistik,
|
publishKoepiState,
|
||||||
|
publishPresence,
|
||||||
|
publishMediaAnfragen,
|
||||||
|
publishDruckStatistik,
|
||||||
|
setKoepiCheckHandler,
|
||||||
|
setMediaAckHandler,
|
||||||
|
setMediaAckAllHandler,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -881,15 +881,34 @@ router.get('/favorites/unacked', authenticate, (req, res) => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
// POST /favorites/acknowledge-all – Admin: alle quittieren
|
// ── Quittier-Logik (wiederverwendbar: HTTP-Routen + MQTT-Buttons) ───────────
|
||||||
router.post('/favorites/acknowledge-all', authenticate, requireAdmin, (req, res) => {
|
function ackFavorite(id) {
|
||||||
// Betroffene Favoriten vor dem Update holen (für Pushover-Benachrichtigungen)
|
const fav = db.prepare('SELECT * FROM movie_favorites WHERE id=?').get(id);
|
||||||
|
if (!fav) return null;
|
||||||
|
db.prepare('UPDATE movie_favorites SET acknowledged=1, acknowledged_at=CURRENT_TIMESTAMP, user_notified=0 WHERE id=?').run(fav.id);
|
||||||
|
try {
|
||||||
|
const poCfg = db.prepare('SELECT user_key, app_token FROM pushover_settings WHERE user_id=?').get(fav.user_id);
|
||||||
|
if (poCfg?.app_token && poCfg?.user_key) {
|
||||||
|
fetch('https://api.pushover.net/1/messages.json', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
token: poCfg.app_token,
|
||||||
|
user: poCfg.user_key,
|
||||||
|
title: '✅ Favorit bestätigt',
|
||||||
|
message: `"${fav.title}" wurde von einem Admin bestätigt.`,
|
||||||
|
priority: 0,
|
||||||
|
}),
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return fav;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ackAllFavorites() {
|
||||||
const toAck = db.prepare('SELECT id, user_id, title FROM movie_favorites WHERE acknowledged=0').all();
|
const toAck = db.prepare('SELECT id, user_id, title FROM movie_favorites WHERE acknowledged=0').all();
|
||||||
db.prepare('UPDATE movie_favorites SET acknowledged=1, acknowledged_at=CURRENT_TIMESTAMP, user_notified=0 WHERE acknowledged=0').run();
|
db.prepare('UPDATE movie_favorites SET acknowledged=1, acknowledged_at=CURRENT_TIMESTAMP, user_notified=0 WHERE acknowledged=0').run();
|
||||||
res.json({ ok: true });
|
|
||||||
// Pushover an jeden betroffenen User (fire & forget, pro User gruppiert)
|
|
||||||
try {
|
try {
|
||||||
// User mit Pushover-Konfiguration ermitteln
|
|
||||||
const userIds = [...new Set(toAck.map(f => f.user_id))];
|
const userIds = [...new Set(toAck.map(f => f.user_id))];
|
||||||
for (const userId of userIds) {
|
for (const userId of userIds) {
|
||||||
const poCfg = db.prepare('SELECT user_key, app_token FROM pushover_settings WHERE user_id=?').get(userId);
|
const poCfg = db.prepare('SELECT user_key, app_token FROM pushover_settings WHERE user_id=?').get(userId);
|
||||||
@@ -911,6 +930,13 @@ router.post('/favorites/acknowledge-all', authenticate, requireAdmin, (req, res)
|
|||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
return toAck.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /favorites/acknowledge-all – Admin: alle quittieren
|
||||||
|
router.post('/favorites/acknowledge-all', authenticate, requireAdmin, (req, res) => {
|
||||||
|
ackAllFavorites();
|
||||||
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /favorites – Favorit hinzufügen (alle User inkl. Admin bekommen Pushover)
|
// POST /favorites – Favorit hinzufügen (alle User inkl. Admin bekommen Pushover)
|
||||||
@@ -958,27 +984,9 @@ router.delete('/favorites/:tmdb_id', authenticate, (req, res) => {
|
|||||||
|
|
||||||
// POST /favorites/:id/acknowledge – Admin: einzelnen quittieren
|
// POST /favorites/:id/acknowledge – Admin: einzelnen quittieren
|
||||||
router.post('/favorites/:id/acknowledge', authenticate, requireAdmin, (req, res) => {
|
router.post('/favorites/:id/acknowledge', authenticate, requireAdmin, (req, res) => {
|
||||||
const fav = db.prepare('SELECT * FROM movie_favorites WHERE id=?').get(req.params.id);
|
const fav = ackFavorite(req.params.id);
|
||||||
if (!fav) return res.status(404).json({ error: 'Nicht gefunden' });
|
if (!fav) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||||
db.prepare('UPDATE movie_favorites SET acknowledged=1, acknowledged_at=CURRENT_TIMESTAMP, user_notified=0 WHERE id=?').run(fav.id);
|
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
// Pushover an den User schicken falls eingerichtet (fire & forget)
|
|
||||||
try {
|
|
||||||
const poCfg = db.prepare('SELECT user_key, app_token FROM pushover_settings WHERE user_id=?').get(fav.user_id);
|
|
||||||
if (poCfg?.app_token && poCfg?.user_key) {
|
|
||||||
fetch('https://api.pushover.net/1/messages.json', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
token: poCfg.app_token,
|
|
||||||
user: poCfg.user_key,
|
|
||||||
title: '✅ Favorit bestätigt',
|
|
||||||
message: `"${fav.title}" wurde von einem Admin bestätigt.`,
|
|
||||||
priority: 0,
|
|
||||||
}),
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /favorites/:id/unacknowledge – Admin: Quittierung rückgängig machen
|
// POST /favorites/:id/unacknowledge – Admin: Quittierung rückgängig machen
|
||||||
@@ -1238,3 +1246,5 @@ router.get('/debug-xrel-tv', authenticate, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
module.exports.ackFavorite = ackFavorite;
|
||||||
|
module.exports.ackAllFavorites = ackAllFavorites;
|
||||||
|
|||||||
Reference in New Issue
Block a user