Files
dickendock/backend/src/mqtt.js

533 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ── 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".
//
// 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
// 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 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 = {
identifiers: ['dickendock'],
name: 'DickenDock',
manufacturer: 'Flo',
model: 'DockStation',
};
let client = null;
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() {
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.subscribe(MEDIA_ACK_ALL_CMD_TOPIC);
client.subscribe(MEDIA_ACK_SUBSCRIBE);
});
client.on('message', async (topic) => {
try {
if (topic === KOEPI_CHECK_CMD_TOPIC && typeof koepiCheckHandler === 'function') {
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);
}
});
client.on('error', (e) => console.error('MQTT Fehler:', e.message));
}
function publishDiscovery() {
if (!client) return;
const entities = [
// ── KöPi ──────────────────────────────────────────────────────────────
['sensor', 'dickendock_koepi_angebot', {
name: 'KöPi Angebote',
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_koepi_start', {
name: 'KöPi Angebot Start',
unique_id: 'dickendock_koepi_start',
state_topic: `${BASE_TOPIC}/koepi/start`,
device_class: 'date',
icon: 'mdi:calendar-start',
availability_topic: AVAILABILITY_TOPIC,
device: DEVICE,
}],
['sensor', 'dickendock_koepi_ende', {
name: 'KöPi Angebot Ende',
unique_id: 'dickendock_koepi_ende',
state_topic: `${BASE_TOPIC}/koepi/ende`,
device_class: 'date',
icon: 'mdi:calendar-end',
availability_topic: AVAILABILITY_TOPIC,
device: DEVICE,
}],
// ── Media ─────────────────────────────────────────────────────────────
['sensor', 'dickendock_media_anfragen', {
name: 'Media Anfragen offen',
unique_id: 'dickendock_media_anfragen',
state_topic: `${BASE_TOPIC}/media/anfragen`,
json_attributes_topic: `${BASE_TOPIC}/media/anfragen_attributes`,
icon: 'mdi:movie-open-star',
unit_of_measurement: 'Anfragen',
state_class: 'measurement',
availability_topic: AVAILABILITY_TOPIC,
device: DEVICE,
}],
['button', 'dickendock_media_ack_all', {
name: 'Media Alle quittieren',
unique_id: 'dickendock_media_ack_all',
command_topic: MEDIA_ACK_ALL_CMD_TOPIC,
icon: 'mdi:check-all',
availability_topic: AVAILABILITY_TOPIC,
device: DEVICE,
}],
// ── 3D ────────────────────────────────────────────────────────────────
['sensor', 'dickendock_3d_umsatz_monat', {
name: '3D Umsatz Monat',
unique_id: 'dickendock_3ddruck_umsatz_monat',
state_topic: `${BASE_TOPIC}/druck/umsatz_monat`,
icon: 'mdi:cash-multiple',
device_class: 'monetary', unit_of_measurement: '€', state_class: 'measurement',
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
}],
['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',
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 round2(n) { return Math.round((n || 0) * 100) / 100; }
function parsePrice(str) {
if (!str) return Infinity;
const n = parseFloat(String(str).replace(/[^0-9,.-]/g, '').replace(',', '.'));
return Number.isNaN(n) ? Infinity : n;
}
// Parst ein deutsches Datum "10.07." oder "10.07.2026" aus einem Textschnipsel.
// Fehlt das Jahr, wird das aktuelle Jahr angenommen (mit Jahreswechsel-Korrektur
// in beide Richtungen, z.B. bei Angeboten die über Silvester laufen).
function parseGermanDate(str, ref = new Date()) {
const m = String(str).match(/(\d{1,2})\.(\d{1,2})\.(\d{4})?/);
if (!m) return null;
const day = parseInt(m[1], 10);
const month = parseInt(m[2], 10) - 1;
let year = m[3] ? parseInt(m[3], 10) : ref.getFullYear();
const d = new Date(year, month, day);
if (!m[3]) {
const diffMonths = (d.getFullYear() - ref.getFullYear()) * 12 + (d.getMonth() - ref.getMonth());
if (diffMonths < -3) d.setFullYear(year + 1); // z.B. Ref=Dez, Datum=Jan → nächstes Jahr gemeint
else if (diffMonths > 3) d.setFullYear(year - 1); // z.B. Ref=Jan, Datum=Dez → vergangenes Jahr gemeint
}
return d;
}
// Ermittelt aus allen Angeboten den frühesten Start- und spätesten End-Termin
// (marktguru-Angebote für ein Produkt laufen i.d.R. im selben Wochenzeitraum)
function extractDateRange(offers) {
let start = null, end = null;
for (const o of offers) {
const text = o.dateRange || '';
const found = [...text.matchAll(/\d{1,2}\.\d{1,2}\.(?:\d{4})?/g)].map(m => m[0]);
if (found.length >= 2) {
const d1 = parseGermanDate(found[0]);
const d2 = parseGermanDate(found[1]);
if (d1 && (!start || d1 < start)) start = d1;
if (d2 && (!end || d2 > end)) end = d2;
} else if (found.length === 1) {
const d1 = parseGermanDate(found[0]);
if (d1 && (!end || d1 > end)) end = d1;
}
}
return { start, end };
}
function toIsoDate(d) {
if (!d) return null;
const pad = n => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
// Wird von koepi/routes.js nach jedem Check (Cron oder manueller Test) aufgerufen
function publishKoepiState({ offers, changed }) {
if (!client?.connected) return;
const list = [...(offers || [])].sort((a, b) => parsePrice(a.price) - parsePrice(b.price));
let state = list.length
? list.map(o => `${o.retailer}: ${o.price}`).join(' | ')
: 'keine Angebote';
if (state.length > 255) state = state.slice(0, 252) + '…';
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 });
const { start, end } = extractDateRange(list);
client.publish(`${BASE_TOPIC}/koepi/start`, toIsoDate(start) || 'unknown', { retain: true });
client.publish(`${BASE_TOPIC}/koepi/ende`, toIsoDate(end) || 'unknown', { 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 jede Minute vom Scheduler UND direkt nach jeder Quittierung aufgerufen.
// 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() {
if (!client?.connected) return;
const open = db.prepare(`
SELECT f.id, f.title, f.added_at, u.username
FROM movie_favorites f JOIN users u ON u.id = f.user_id
WHERE f.acknowledged = 0
ORDER BY f.added_at DESC
`).all();
client.publish(`${BASE_TOPIC}/media/anfragen`, String(open.length), { retain: true });
client.publish(`${BASE_TOPIC}/media/anfragen_attributes`, JSON.stringify({ anfragen: open }), { retain: true });
const currentIds = new Set(open.map(f => String(f.id)));
// Neue Buttons für neu hinzugekommene offene Anfragen anlegen
for (const fav of open) {
const id = String(fav.id);
if (!publishedMediaAckIds.has(id)) {
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 });
}
}
// Buttons für inzwischen quittierte/gelöschte Anfragen zurückziehen (leeres retained Payload)
for (const id of publishedMediaAckIds) {
if (!currentIds.has(id)) {
client.publish(`homeassistant/button/dickendock_media_ack_${id}/config`, '', { retain: true });
}
}
publishedMediaAckIds = currentIds;
}
// Umsatz eines einzelnen Auftrags — Festpreis falls gesetzt, sonst Summe der
// Positions-Festpreise (spiegelt die Logik aus statistik/routes.js)
function getOrderRevenue(order) {
if (order.custom_price != null && order.custom_price > 0) return parseFloat(order.custom_price);
const r = db.prepare(`
SELECT COALESCE(SUM(custom_price * stueckzahl), 0) AS s
FROM order_items WHERE order_id=? AND custom_price IS NOT NULL
`).get(order.id);
return r?.s || 0;
}
// Grundkosten (Material/Zeit-Basis) eines Auftrags — spiegelt statistik/routes.js
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() {
if (!client?.connected) return;
const now = new Date();
const pad = n => String(n).padStart(2, '0');
const monatsAnfang = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-01`;
const heute = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
const allOrders = db.prepare('SELECT * FROM orders').all();
const paidAll = allOrders.filter(o => !!o.bezahlt);
const paidMonat = paidAll.filter(o => {
const d = (o.bezahlt_am || o.created_at || '').slice(0, 10);
return d >= monatsAnfang && d <= heute;
});
const umsatzGesamt = paidAll.reduce((s, o) => s + getOrderRevenue(o), 0);
const umsatzMonat = paidMonat.reduce((s, o) => s + getOrderRevenue(o), 0);
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 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;
const nettoGesamt = rohgewinnGesamt - ausgabenGesamt;
const nettoMonat = rohgewinnMonat - ausgabenMonat;
const byStatus = { warteliste: 0, in_arbeit: 0, fertig: 0, bezahlt: 0, abgeschlossen: 0 };
for (const o of allOrders) {
if (o.bezahlt && o.abgeholt) byStatus.abgeschlossen++;
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, um Zirkel-Requires zu vermeiden
function setKoepiCheckHandler(fn) { koepiCheckHandler = fn; }
function setMediaAckHandler(fn) { mediaAckHandler = fn; }
function setMediaAckAllHandler(fn) { mediaAckAllHandler = fn; }
module.exports = {
connectMqtt,
publishKoepiState,
publishPresence,
publishMediaAnfragen,
publishDruckStatistik,
setKoepiCheckHandler,
setMediaAckHandler,
setMediaAckAllHandler,
};