From 16eace739a8c710a1719e023da6e862f910afd6e Mon Sep 17 00:00:00 2001 From: marijo Date: Tue, 9 Jun 2026 14:08:12 +0000 Subject: [PATCH] MQTT publish to HA --- api.php | 80 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ app.js | 48 ++++++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+) diff --git a/api.php b/api.php index 13836fe..2ec4730 100644 --- a/api.php +++ b/api.php @@ -40,6 +40,11 @@ try { respond(['ok' => true, 'settings' => get_settings($db)]); break; + case 'publishHa': + publish_ha_state($payload['state'] ?? []); + respond(['ok' => true]); + break; + case 'saveCorrection': save_correction($db, (int)($payload['year'] ?? 0), (int)($payload['month'] ?? 0), $payload['value'] ?? null); respond(['ok' => true, 'corrections' => get_corrections($db)]); @@ -89,6 +94,81 @@ try { fail($e->getMessage(), 500); } +function app_config(): array +{ + $defaults = [ + 'name' => 'GO Vacation Planner', + 'topic_prefix' => 'go', + 'mqtt_host' => '192.168.0.203', + 'mqtt_port' => 1883, + ]; + $file = __DIR__ . '/config.php'; + if (is_file($file)) { + $config = require $file; + if (is_array($config)) return array_replace($defaults, $config); + } + return $defaults; +} + +function mqtt_pub(array $config, string $topic, string $payload, bool $retain = true): void +{ + $host = $config['mqtt_host']; + $port = (int)($config['mqtt_port'] ?? 1883); + $cmd = 'mosquitto_pub -h ' . escapeshellarg($host) + . ' -p ' . escapeshellarg((string)$port) + . ' -t ' . escapeshellarg($topic) + . ' -m ' . escapeshellarg($payload) + . ($retain ? ' -r' : ''); + @shell_exec($cmd . ' 2>/dev/null'); +} + +function publish_ha_discovery(array $config): void +{ + $p = trim($config['topic_prefix'], '/'); + $dev = ['identifiers' => ['go_vacation_planner'], 'name' => $config['name']]; + $sensors = [ + 'feriefridage' => ['name' => 'Feriefridage', 'unit' => 'd', 'topic' => "$p/feriefridage"], + 'vacation_days' => ['name' => 'Vacation days', 'unit' => 'd', 'topic' => "$p/vacation_days"], + 'flex_hours' => ['name' => 'FLEX hours', 'unit' => 'h', 'topic' => "$p/flex_hours"], + 'total_days' => ['name' => 'Total available days', 'unit' => 'd', 'topic' => "$p/total_days"], + 'upcoming_vacations' => ['name' => 'Upcoming vacations', 'unit' => '', 'topic' => "$p/upcoming_vacations", 'attributes_topic' => "$p/upcoming_vacations/attributes"], + ]; + + foreach ($sensors as $id => $sensor) { + $payload = [ + 'name' => $sensor['name'], + 'state_topic' => $sensor['topic'], + 'unique_id' => "go_$id", + 'device' => $dev, + ]; + if ($sensor['unit'] !== '') $payload['unit_of_measurement'] = $sensor['unit']; + if (isset($sensor['attributes_topic'])) $payload['json_attributes_topic'] = $sensor['attributes_topic']; + mqtt_pub($config, "homeassistant/sensor/go_$id/config", json_encode($payload, JSON_UNESCAPED_SLASHES)); + } +} + +function publish_ha_state(array $state): void +{ + $config = app_config(); + $p = trim($config['topic_prefix'], '/'); + publish_ha_discovery($config); + + $numeric = ['feriefridage', 'vacation_days', 'flex_hours', 'total_days']; + foreach ($numeric as $key) { + $value = isset($state[$key]) && is_numeric($state[$key]) ? round((float)$state[$key], 2) : 0; + mqtt_pub($config, "$p/$key", (string)$value); + } + + $upcoming = is_array($state['upcoming_vacations'] ?? null) ? $state['upcoming_vacations'] : []; + $first = $upcoming[0] ?? null; + $summary = is_array($first) + ? (($first['title'] ?? 'Vacation') . ' ' . ($first['start'] ?? '') . ' → ' . ($first['end'] ?? '')) + : 'none'; + mqtt_pub($config, "$p/upcoming_vacations", $summary); + mqtt_pub($config, "$p/upcoming_vacations/attributes", json_encode(['vacations' => $upcoming], JSON_UNESCAPED_SLASHES)); + mqtt_pub($config, "$p/state", json_encode($state, JSON_UNESCAPED_SLASHES)); +} + function db(): PDO { if (!is_dir(DB_DIR) && !mkdir(DB_DIR, 0775, true) && !is_dir(DB_DIR)) { diff --git a/app.js b/app.js index 97f26f5..d038743 100644 --- a/app.js +++ b/app.js @@ -93,6 +93,54 @@ function renderAll() { renderWarnings(); renderEntries(); renderPlanner(); + scheduleHaPublish(); +} + +function scheduleHaPublish() { + clearTimeout(scheduleHaPublish.timer); + scheduleHaPublish.timer = setTimeout(() => { + publishHaState().catch((error) => console.warn('HA publish failed', error)); + }, 600); +} + +async function publishHaState() { + if (!state.plan?.current) return; + const current = state.plan.current; + await post({ + action: 'publishHa', + state: { + feriefridage: Number(current.feriefridage || 0), + vacation_days: Number(current.vacation || 0), + flex_hours: Number(current.flex || 0), + total_days: Number(current.totalDays || 0), + upcoming_vacations: upcomingVacationsForHa(), + updated_at: new Date().toISOString(), + }, + }); +} + +function upcomingVacationsForHa() { + const today = toIsoDate(new Date()); + return state.entries + .filter((entry) => entry.end_date >= today) + .sort((a, b) => a.start_date.localeCompare(b.start_date) || a.id - b.id) + .map((entry) => { + const summary = state.plan.entrySummaries[entry.id] || { workdays: 0, usedVacation: 0, usedFeriefridage: 0, usedFlex: 0, usedFlexDays: 0 }; + return { + title: entry.title, + start: entry.start_date, + end: entry.end_date, + start_display: formatDate(entry.start_date), + end_display: formatDate(entry.end_date), + status: entry.status, + source: entry.source, + workdays: Number(summary.workdays || 0), + vacation_days: Number(summary.usedVacation || 0), + feriefridage: Number(summary.usedFeriefridage || 0), + flex_hours: Number(summary.usedFlex || 0), + flex_days: Number(summary.usedFlexDays || 0), + }; + }); } function fillSettingsForm() {