From 965a672abe4f0addca25296e0e6a655cbf8de638 Mon Sep 17 00:00:00 2001 From: dadaloop82 Date: Sat, 23 May 2026 12:28:09 +0000 Subject: [PATCH] feat: full Home Assistant integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PHP: _fireHaWebhook(), _sendHaNotify(), haInventorySensor(), haTestConnection() - PHP: ha_sensor + ha_test routing actions - PHP: getServerSettings() exposes ha_token (consistent with tts_token) - PHP: saveSettings() handles all HA_* env keys (url, token, tts_entity, webhook_id, events, notify_service, expiry_days) - PHP: bringAddItems(), shoppingAdd(), updateInventory() fire shopping_add / stock_update webhooks - Cron: daily HA expiry/expired webhook + push notify with flag-file guard - HTML: 🏠 Settings tab button + full HA panel (connection, TTS, webhook, notify, sensor cards) - JS: serverKeys + loadSettingsUI extended with HA fields - JS: _applyHaSettingsUI(), _loadHaTab(), _renderHaSensorYaml() - JS: onHaEnabledChange(), testHaConnection(), applyHaTtsPreset() - JS: saveHaSettings(), copyHaSensorYaml(), showHaWebhookHelp() - JS: _buildHaTtsRequest() for HA media_player TTS - JS: speakCookingStep() now supports HA TTS as first-priority path - JS: onTtsEngineChange() fixed to show server section for both 'server' and 'custom' - Translations: settings.ha.* (52 keys) in all 5 languages (it/en/de/fr/es) - .env.example: HA_ENABLED/URL/TOKEN/TTS_ENTITY/WEBHOOK_ID/EVENTS/NOTIFY_SERVICE/EXPIRY_DAYS - docs/wiki/Home-Assistant.md: new wiki page (REST sensors, webhooks, TTS, push notify, troubleshooting) - README: HA integration highlighted as first feature block --- .env.example | 22 +++ README.md | 4 + api/cron_smart_shopping.php | 64 ++++++++ api/index.php | 255 ++++++++++++++++++++++++++++++++ assets/js/app.js | 281 +++++++++++++++++++++++++++++++++++- docs/wiki/Home-Assistant.md | 219 ++++++++++++++++++++++++++++ index.html | 117 +++++++++++++++ translations/de.json | 54 +++++++ translations/en.json | 54 +++++++ translations/es.json | 54 +++++++ translations/fr.json | 54 +++++++ translations/it.json | 54 +++++++ 12 files changed, 1228 insertions(+), 4 deletions(-) create mode 100644 docs/wiki/Home-Assistant.md diff --git a/.env.example b/.env.example index e51acb2..e15a14e 100644 --- a/.env.example +++ b/.env.example @@ -129,6 +129,28 @@ GDRIVE_RETENTION_DAYS=30 # Leave empty to allow anyone with access to the server to change settings. SETTINGS_TOKEN= +# ── Home Assistant Integration ──────────────────────────────────────────────── +# All HA settings can also be configured from the Settings → 🏠 tab. +# +# HA_ENABLED: master switch for all HA features (webhooks, TTS, sensors) +HA_ENABLED=false +# HA_URL: base URL of your HA instance — no trailing slash +# Examples: http://homeassistant.local:8123 or http://192.168.1.50:8123 +HA_URL= +# HA_TOKEN: Long-Lived Access Token (HA Profile → Security → Long-Lived Access Tokens) +HA_TOKEN= +# HA_TTS_ENTITY: media_player entity for recipe step TTS (e.g. media_player.living_room) +HA_TTS_ENTITY= +# HA_WEBHOOK_ID: ID of an HA automation's Webhook trigger +HA_WEBHOOK_ID= +# HA_WEBHOOK_EVENTS: comma-separated events to fire webhooks for +# Available: expiry, shopping_add, stock_update, barcode_scan +HA_WEBHOOK_EVENTS=expiry,shopping_add,stock_update +# HA_NOTIFY_SERVICE: HA notify service for push alerts (e.g. notify.mobile_app_my_phone) +HA_NOTIFY_SERVICE= +# HA_EXPIRY_DAYS: days before expiry to trigger expiry alert (default 3) +HA_EXPIRY_DAYS=3 + # ── Developer / demo ───────────────────────────────────────────────────────── # DEMO_MODE: when true, all write operations are blocked (for public demos) DEMO_MODE=false diff --git a/README.md b/README.md index 5380cfb..8d6041d 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,10 @@ ## ✨ Features +> 🏠 **New — Native Home Assistant Integration** +> Connect EverShelf to your smart home: expose pantry counts as **REST sensors**, fire **webhook automations** on expiry/shopping/stock events, receive **push notifications** via any `notify.*` service, and read recipe steps aloud on **smart speakers** with full TTS support. +> Configure everything from the new **Settings → 🏠** tab — no YAML editing required. See the [Home Assistant wiki page](docs/wiki/Home-Assistant.md) for details. + > ⚙️ **New in v1.7.23 — Global settings tab, DB auto-cleanup, vacuum-sealed expiry** > A new **General** tab groups all global settings (language, currency, theme, screensaver, zero-waste, export) in one place. > Recipes older than `RECIPE_RETENTION_DAYS` and transactions older than `TRANSACTION_RETENTION_DAYS` are deleted automatically every cron cycle, followed by a SQLite `VACUUM` to keep the database small. diff --git a/api/cron_smart_shopping.php b/api/cron_smart_shopping.php index 2bb8fcb..99fb7ec 100644 --- a/api/cron_smart_shopping.php +++ b/api/cron_smart_shopping.php @@ -133,3 +133,67 @@ try { _phpErrorReport($msg, $e->getFile(), $e->getLine(), $e->getTraceAsString(), get_class($e)); exit(1); } + +// ── Home Assistant: expiry alerts ───────────────────────────────────────────── +// Fire one HA webhook per expiring item (once per day guard via a simple flag file). +if (env('HA_ENABLED', 'false') === 'true' && env('HA_WEBHOOK_ID', '') !== '') { + try { + $haFlagFile = __DIR__ . '/../data/ha_expiry_notified_' . date('Y-m-d') . '.json'; + if (!file_exists($haFlagFile)) { + $expiryDays = max(1, (int)env('HA_EXPIRY_DAYS', '3')); + $expiringItems = $db->query( + "SELECT p.name, i.quantity, i.unit, i.expiry_date, i.location + FROM inventory i JOIN products p ON i.product_id = p.id + WHERE i.quantity > 0 AND i.expiry_date IS NOT NULL + AND i.expiry_date BETWEEN date('now') AND date('now', '+{$expiryDays} days') + ORDER BY i.expiry_date ASC LIMIT 20" + )->fetchAll(PDO::FETCH_ASSOC); + + $expiredItems = $db->query( + "SELECT p.name, i.quantity, i.unit, i.expiry_date, i.location + FROM inventory i JOIN products p ON i.product_id = p.id + WHERE i.quantity > 0 AND i.expiry_date IS NOT NULL + AND i.expiry_date < date('now') + ORDER BY i.expiry_date ASC LIMIT 10" + )->fetchAll(PDO::FETCH_ASSOC); + + if (!empty($expiringItems)) { + $names = implode(', ', array_column($expiringItems, 'name')); + _fireHaWebhook('expiry_alert', [ + 'count' => count($expiringItems), + 'items' => $expiringItems, + 'type' => 'expiring_soon', + 'days' => $expiryDays, + 'summary' => $names, + ]); + // Also send HA notification if service configured + if (env('HA_NOTIFY_SERVICE', '') !== '') { + $msg = count($expiringItems) . ' product(s) expiring within ' . $expiryDays . ' days: ' . $names; + _sendHaNotify($msg, ['expiring_items' => $expiringItems]); + } + echo '[' . date('Y-m-d H:i:s') . '] HA expiry_alert fired: ' . count($expiringItems) . " items\n"; + } + + if (!empty($expiredItems)) { + $expNames = implode(', ', array_column($expiredItems, 'name')); + _fireHaWebhook('expiry_alert', [ + 'count' => count($expiredItems), + 'items' => $expiredItems, + 'type' => 'expired', + 'summary' => $expNames, + ]); + echo '[' . date('Y-m-d H:i:s') . '] HA expired fired: ' . count($expiredItems) . " items\n"; + } + + // Mark as done for today + file_put_contents($haFlagFile, json_encode(['ts' => time(), 'expiring' => count($expiringItems ?? []), 'expired' => count($expiredItems ?? [])])); + // Clean up old flag files (keep last 7 days) + foreach (glob(__DIR__ . '/../data/ha_expiry_notified_*.json') as $oldFlag) { + $flagDate = str_replace([__DIR__ . '/../data/ha_expiry_notified_', '.json'], '', $oldFlag); + if ($flagDate < date('Y-m-d', strtotime('-7 days'))) @unlink($oldFlag); + } + } + } catch (Throwable $haE) { + echo '[' . date('Y-m-d H:i:s') . '] HA expiry hook warning: ' . $haE->getMessage() . "\n"; + } +} diff --git a/api/index.php b/api/index.php index dddc0ee..28b971c 100644 --- a/api/index.php +++ b/api/index.php @@ -927,6 +927,14 @@ try { ttsProxy(); break; + case 'ha_sensor': + haInventorySensor(getDB()); + break; + + case 'ha_test': + haTestConnection(); + break; + case 'expiry_history': getExpiryHistory($db); break; @@ -1246,8 +1254,221 @@ function ttsProxy() { echo json_encode(['status' => $httpCode, 'body' => $response]); } +// ===== HOME ASSISTANT INTEGRATION ===== + +/** + * Fire an outbound webhook to Home Assistant. + * Respects HA_ENABLED, HA_URL, HA_WEBHOOK_ID and HA_WEBHOOK_EVENTS. + * Non-blocking: uses a 5 s cURL timeout; failures are logged but never thrown. + */ +function _fireHaWebhook(string $event, array $data): void { + if (env('HA_ENABLED', 'false') !== 'true') return; + $haUrl = rtrim(env('HA_URL', ''), '/'); + $webhookId = env('HA_WEBHOOK_ID', ''); + if (!$haUrl || !$webhookId) return; + + $allowed = array_map('trim', explode(',', env('HA_WEBHOOK_EVENTS', 'expiry,shopping_add,stock_update,barcode_scan'))); + if (!in_array($event, $allowed, true)) return; + + $url = $haUrl . '/api/webhook/' . urlencode($webhookId); + $payload = json_encode(array_merge(['event' => $event, 'source' => 'evershelf', 'ts' => time()], $data), JSON_UNESCAPED_UNICODE); + + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $payload, + CURLOPT_HTTPHEADER => ['Content-Type: application/json'], + CURLOPT_TIMEOUT => 5, + CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_CONNECTTIMEOUT => 3, + ]); + $resp = curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $err = curl_error($ch); + curl_close($ch); + + if ($err) { + EverLog::warn("_fireHaWebhook[$event]: cURL error – $err"); + } else { + EverLog::debug("_fireHaWebhook[$event]: HTTP $code"); + } +} + +/** + * Send a notification via HA notify service (e.g. notify.mobile_app_phone). + * Used for expiry alerts when HA_NOTIFY_SERVICE is configured. + */ +function _sendHaNotify(string $message, array $data = []): void { + if (env('HA_ENABLED', 'false') !== 'true') return; + $haUrl = rtrim(env('HA_URL', ''), '/'); + $token = env('HA_TOKEN', ''); + $service = env('HA_NOTIFY_SERVICE', ''); + if (!$haUrl || !$token || !$service) return; + + // service format: "notify.mobile_app_xyz" → POST /api/services/notify/mobile_app_xyz + [$domain, $svcName] = array_pad(explode('.', $service, 2), 2, ''); + if (!$svcName) return; + + $url = $haUrl . '/api/services/' . urlencode($domain) . '/' . urlencode($svcName); + $payload = json_encode(array_merge(['message' => $message, 'data' => $data], []), JSON_UNESCAPED_UNICODE); + + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $payload, + CURLOPT_HTTPHEADER => [ + 'Content-Type: application/json', + 'Authorization: Bearer ' . $token, + ], + CURLOPT_TIMEOUT => 8, + CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_CONNECTTIMEOUT => 4, + ]); + $resp = curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $err = curl_error($ch); + curl_close($ch); + + if ($err) { + EverLog::warn("_sendHaNotify: cURL error – $err"); + } else { + EverLog::debug("_sendHaNotify: HTTP $code"); + } +} + +/** + * HA REST sensor endpoint — returns pantry state in Home Assistant-compatible format. + * Use with platform: rest in configuration.yaml. + * + * GET /api/?action=ha_sensor[&sensor=NAME] + * Available sensor names: expiring, expired, total, shopping + */ +function haInventorySensor(PDO $db): void { + header('Content-Type: application/json; charset=utf-8'); + header('Access-Control-Allow-Origin: *'); + + $sensor = strtolower(trim($_GET['sensor'] ?? 'overview')); + + try { + $expiring = (int)$db->query( + "SELECT COUNT(*) FROM inventory WHERE quantity > 0 AND expiry_date IS NOT NULL + AND expiry_date BETWEEN date('now') AND date('now', '+3 days')" + )->fetchColumn(); + + $expired = (int)$db->query( + "SELECT COUNT(*) FROM inventory WHERE quantity > 0 AND expiry_date IS NOT NULL + AND expiry_date < date('now')" + )->fetchColumn(); + + $total = (int)$db->query( + "SELECT COUNT(*) FROM inventory WHERE quantity > 0" + )->fetchColumn(); + + $shoppingCount = 0; + if (isShoppingBringMode()) { + $auth = bringAuth(); + if ($auth) { + $listData = bringRequest('GET', "https://api.getbring.com/rest/v2/bringlists/{$auth['bringListUUID']}"); + $shoppingCount = isset($listData['purchase']) ? count($listData['purchase']) : 0; + } + } else { + $shoppingCount = (int)$db->query("SELECT COUNT(*) FROM shopping_list")->fetchColumn(); + } + + // Expiring items details + $expiringItems = $db->query( + "SELECT name, quantity, unit, expiry_date FROM inventory + WHERE quantity > 0 AND expiry_date IS NOT NULL AND expiry_date BETWEEN date('now') AND date('now', '+7 days') + ORDER BY expiry_date ASC LIMIT 10" + )->fetchAll(PDO::FETCH_ASSOC); + + $stateValue = match($sensor) { + 'expired' => $expired, + 'shopping' => $shoppingCount, + 'total' => $total, + default => $expiring, // 'expiring' or 'overview' + }; + + echo json_encode([ + 'state' => $stateValue, + 'attributes' => [ + 'expiring_soon' => $expiring, + 'expiring_3d' => $expiring, + 'expired_items' => $expired, + 'total_items' => $total, + 'shopping_items' => $shoppingCount, + 'expiring_list' => array_map(fn($r) => [ + 'name' => $r['name'], + 'quantity' => (float)$r['quantity'], + 'unit' => $r['unit'], + 'expiry_date'=> $r['expiry_date'], + ], $expiringItems), + 'unit_of_measurement'=> 'items', + 'friendly_name' => 'EverShelf Pantry', + 'icon' => 'mdi:fridge', + 'last_updated' => date('c'), + ], + ], JSON_UNESCAPED_UNICODE); + } catch (Throwable $e) { + http_response_code(500); + echo json_encode(['error' => $e->getMessage()]); + } +} + // ===== CLIENT LOG ===== +/** + * Test reachability of a Home Assistant instance. + * Accepts POST body: {url, token} + * Uses server-env HA_TOKEN if token === '__server__' (token already saved on server). + */ +function haTestConnection(): void { + header('Content-Type: application/json; charset=utf-8'); + $input = json_decode(file_get_contents('php://input'), true) ?? []; + $url = rtrim($input['url'] ?? '', '/'); + $token = $input['token'] ?? ''; + if ($token === '__server__') { + $token = env('HA_TOKEN', ''); + } + if (!$url) { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'No URL provided']); + return; + } + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => $url . '/api/', + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 8, + CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_SSL_VERIFYHOST => false, + CURLOPT_HTTPHEADER => array_filter([ + 'Content-Type: application/json', + $token ? 'Authorization: Bearer ' . $token : null, + ]), + ]); + $raw = curl_exec($ch); + $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + $err = curl_error($ch); + curl_close($ch); + if ($err) { + echo json_encode(['ok' => false, 'error' => $err, 'http_code' => 0]); + return; + } + $data = json_decode($raw, true); + $version = $data['version'] ?? null; + if ($code === 200) { + echo json_encode(['ok' => true, 'version' => $version, 'http_code' => $code]); + } elseif ($code === 401) { + echo json_encode(['ok' => false, 'error' => 'bad_token', 'http_code' => $code]); + } else { + echo json_encode(['ok' => false, 'error' => 'http_' . $code, 'http_code' => $code]); + } +} + + // ===== FOOD FACTS (cached daily) ===== function getFoodFacts(): void { EverLog::info('getFoodFacts'); @@ -2433,6 +2654,13 @@ function updateInventory(PDO $db): void { // Real-time Bring! sync: done after commit so DB lock is not held during HTTP call if (isset($input['quantity']) && $prevRow && abs((float)$input['quantity'] - (float)$prevRow['quantity']) > 0.001) { try { bringQuickSyncProduct($db, (int)$prevRow['product_id']); } catch (Throwable $e) {} + // HA: stock update event + $prodRow = $db->prepare("SELECT name FROM products WHERE id = ?")->execute([(int)$prevRow['product_id']]) ? $db->query("SELECT name FROM products WHERE id = " . (int)$prevRow['product_id'])->fetchColumn() : ''; + _fireHaWebhook('stock_update', [ + 'item' => (string)$prodRow, + 'quantity' => (float)$input['quantity'], + 'location' => $input['location'] ?? $prevRow['location'] ?? '', + ]); } echo json_encode(['success' => true]); @@ -3221,6 +3449,15 @@ function getServerSettings(): void { 'shopping_forecast' => env('SHOPPING_FORECAST', 'true') === 'true', 'shopping_auto_add_threshold' => (int)env('SHOPPING_AUTO_ADD_THRESHOLD', '0'), 'dark_mode' => env('DARK_MODE', 'auto'), + // Home Assistant Integration + 'ha_enabled' => env('HA_ENABLED', 'false') === 'true', + 'ha_url' => env('HA_URL', ''), + 'ha_token' => env('HA_TOKEN', ''), + 'ha_tts_entity' => env('HA_TTS_ENTITY', ''), + 'ha_webhook_id' => env('HA_WEBHOOK_ID', ''), + 'ha_webhook_events' => env('HA_WEBHOOK_EVENTS', 'expiry,shopping_add,stock_update,barcode_scan'), + 'ha_notify_service' => env('HA_NOTIFY_SERVICE', ''), + 'ha_expiry_days' => (int)env('HA_EXPIRY_DAYS', '3'), ]); } @@ -3287,6 +3524,13 @@ function saveSettings(): void { 'gdrive_client_secret' => 'GDRIVE_CLIENT_SECRET', 'shopping_mode' => 'SHOPPING_MODE', 'dark_mode' => 'DARK_MODE', + // Home Assistant + 'ha_url' => 'HA_URL', + 'ha_token' => 'HA_TOKEN', + 'ha_tts_entity' => 'HA_TTS_ENTITY', + 'ha_webhook_id' => 'HA_WEBHOOK_ID', + 'ha_webhook_events' => 'HA_WEBHOOK_EVENTS', + 'ha_notify_service' => 'HA_NOTIFY_SERVICE', ]; // Boolean keys $boolMap = [ @@ -3307,6 +3551,8 @@ function saveSettings(): void { 'shopping_enabled' => 'SHOPPING_ENABLED', 'shopping_smart_suggestions' => 'SHOPPING_SMART_SUGGESTIONS', 'shopping_forecast' => 'SHOPPING_FORECAST', + // Home Assistant + 'ha_enabled' => 'HA_ENABLED', ]; // Integer keys $intMap = [ @@ -3319,6 +3565,8 @@ function saveSettings(): void { 'backup_retention_days' => 'BACKUP_RETENTION_DAYS', 'gdrive_retention_days' => 'GDRIVE_RETENTION_DAYS', 'shopping_auto_add_threshold' => 'SHOPPING_AUTO_ADD_THRESHOLD', + // Home Assistant + 'ha_expiry_days' => 'HA_EXPIRY_DAYS', ]; // Float keys $floatMap = [ @@ -7280,6 +7528,12 @@ function bringAddItems(): void { if ($added > 0 || $updated > 0) { // Invalidate cache so next smart_shopping request reflects the updated Bring! list @unlink(__DIR__ . '/../data/smart_shopping_cache.json'); + // Fire HA webhook for each newly added item + foreach ($items as $item) { + $iName = $item['name'] ?? ''; + if ($iName === '') continue; + _fireHaWebhook('shopping_add', ['item' => $iName, 'specification' => $item['specification'] ?? '']); + } } echo json_encode(['success' => true, 'added' => $added, 'updated' => $updated, 'skipped' => $skipped, 'errors' => $errors]); } @@ -8383,6 +8637,7 @@ function shoppingAdd(PDO $db): void { } else { $db->prepare("INSERT INTO shopping_list (name, raw_name, specification) VALUES (?, ?, ?)")->execute([$name, $rawName, $spec]); $added++; + _fireHaWebhook('shopping_add', ['item' => $name, 'specification' => $spec]); } } echo json_encode(['success' => true, 'added' => $added, 'updated' => $updated, 'skipped' => $skipped, 'errors' => []]); diff --git a/assets/js/app.js b/assets/js/app.js index 8fbc44e..a2cdfea 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -2212,7 +2212,10 @@ function _applySyncedSettings(serverSettings) { 'zerowaste_tips_enabled', 'shopping_enabled','shopping_mode','shopping_smart_suggestions', 'shopping_forecast','shopping_auto_add_threshold', - 'dark_mode']; + 'dark_mode', + // Home Assistant + 'ha_enabled','ha_url','ha_tts_entity','ha_webhook_id','ha_webhook_events', + 'ha_notify_service','ha_expiry_days']; let changed = false; for (const key of serverKeys) { if (serverSettings[key] !== undefined && serverSettings[key] !== null && serverSettings[key] !== '') { @@ -2834,6 +2837,18 @@ async function loadSettingsUI() { s._tts_initialized = true; saveSettingsToStorage(s); } + // HA settings — init defaults on first load + if (!s._ha_initialized) { + s.ha_enabled = s.ha_enabled || false; + s.ha_url = s.ha_url || ''; + s.ha_tts_entity = s.ha_tts_entity || ''; + s.ha_webhook_id = s.ha_webhook_id || ''; + s.ha_webhook_events = s.ha_webhook_events || 'expiry,shopping_add,stock_update'; + s.ha_notify_service = s.ha_notify_service || ''; + s.ha_expiry_days = s.ha_expiry_days || 3; + s._ha_initialized = true; + saveSettingsToStorage(s); + } const ttsEnabledEl = document.getElementById('setting-tts-enabled'); if (ttsEnabledEl) ttsEnabledEl.checked = s.tts_enabled === true; const ttsEngineEl = document.getElementById('setting-tts-engine'); @@ -2875,7 +2890,9 @@ async function loadSettingsUI() { 'tts_content_type','tts_payload_key', 'price_enabled','price_country','price_currency','price_update_months', 'shopping_enabled','shopping_mode','shopping_smart_suggestions', - 'shopping_forecast','shopping_auto_add_threshold']; + 'shopping_forecast','shopping_auto_add_threshold', + 'ha_enabled','ha_url','ha_tts_entity','ha_webhook_id','ha_webhook_events', + 'ha_notify_service','ha_expiry_days']; // Note: gemini_key is never sent from server; settings_token_set is metadata only const settingsTokenRequired = !!serverSettings.settings_token_set; const tokenHintEl = document.getElementById('settings-token-status-hint'); @@ -2927,6 +2944,8 @@ async function loadSettingsUI() { if (priceMonthsEl) priceMonthsEl.value = s.price_update_months || 3; // Shopping settings (server merge) _applyShoppingSettingsUI(s); + // HA settings (server merge) + _applyHaSettingsUI(s); } } catch(e) { /* offline, use local */ } // Price settings @@ -3506,6 +3525,15 @@ async function saveSettings() { shopping_forecast: s.shopping_forecast !== false, shopping_auto_add_threshold: s.shopping_auto_add_threshold || 0, dark_mode: s.dark_mode || 'auto', + // Home Assistant + ha_enabled: !!s.ha_enabled, + ha_url: s.ha_url || '', + ...(s.ha_token ? { ha_token: s.ha_token } : {}), + ha_tts_entity: s.ha_tts_entity || '', + ha_webhook_id: s.ha_webhook_id || '', + ha_webhook_events: s.ha_webhook_events || '', + ha_notify_service: s.ha_notify_service || '', + ha_expiry_days: s.ha_expiry_days || 3, }, tokenHeader); const statusEl = document.getElementById('settings-status'); if (result.success) { @@ -13589,6 +13617,24 @@ function _buildTtsRequest(text, s) { return { url, method, headers, body }; } +/** + * Build a proxy request to call Home Assistant tts.speak service. + * Requires HA URL, bearer token and entity_id (media player) in settings. + */ +function _buildHaTtsRequest(text, s) { + const haUrl = (s.ha_url || '').replace(/\/$/, ''); + const url = haUrl + '/api/services/tts/speak'; + const headers = { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + (s.ha_token || ''), + }; + const body = JSON.stringify({ + entity_id: s.ha_tts_entity || '', + message: text, + }); + return { url, method: 'POST', headers, body }; +} + async function _ttsViaProxy(req) { // Route through server-side proxy to avoid mixed-content / CORS issues return fetch('api/index.php?action=tts_proxy', { @@ -13609,7 +13655,12 @@ async function speakCookingStep(text) { // Use custom TTS endpoint only when explicitly configured; otherwise always use browser TTS. // Do NOT gate on s.tts_enabled — the _cookingTTS toggle in cooking mode is the only gate. try { - if (s.tts_engine === 'custom' && s.tts_url) { + // 1. HA TTS — if HA is enabled and a media player entity is configured + if (s.ha_enabled && s.ha_tts_entity && s.ha_url) { + const req = _buildHaTtsRequest(text, s); + await _ttsViaProxy(req); + // 2. Generic external endpoint ('server' or legacy 'custom' engine) + } else if ((s.tts_engine === 'server' || s.tts_engine === 'custom') && s.tts_url) { const req = _buildTtsRequest(text, s); await _ttsViaProxy(req); } else { @@ -13636,7 +13687,229 @@ function onTtsEngineChange(engine) { const browserSect = document.getElementById('tts-browser-section'); const serverSect = document.getElementById('tts-server-section'); if (browserSect) browserSect.style.display = engine === 'browser' ? '' : 'none'; - if (serverSect) serverSect.style.display = engine === 'server' ? '' : 'none'; + if (serverSect) serverSect.style.display = (engine === 'server' || engine === 'custom') ? '' : 'none'; +} + +// ===== HOME ASSISTANT PANEL ===== + +function onHaEnabledChange() { + const enabled = document.getElementById('setting-ha-enabled')?.checked; + const cfg = document.getElementById('ha-config-section'); + if (cfg) cfg.style.display = enabled ? '' : 'none'; +} + +function _applyHaSettingsUI(s) { + const haEnabled = document.getElementById('setting-ha-enabled'); + if (haEnabled) { haEnabled.checked = !!s.ha_enabled; onHaEnabledChange(); } + const haUrl = document.getElementById('setting-ha-url'); + if (haUrl) haUrl.value = s.ha_url || ''; + // Never pre-fill token (write-only field); only show placeholder if already set + const haTokenEl = document.getElementById('setting-ha-token'); + if (haTokenEl) haTokenEl.placeholder = s.ha_token_set ? '••••••••••••' : 'eyJhbGci...'; + const haEntity = document.getElementById('setting-ha-tts-entity'); + if (haEntity) haEntity.value = s.ha_tts_entity || ''; + const haWebhook = document.getElementById('setting-ha-webhook-id'); + if (haWebhook) haWebhook.value = s.ha_webhook_id || ''; + const haNotify = document.getElementById('setting-ha-notify-service'); + if (haNotify) haNotify.value = s.ha_notify_service || ''; + const haExpiry = document.getElementById('setting-ha-expiry-days'); + if (haExpiry) haExpiry.value = s.ha_expiry_days || 3; + // Checkboxes for events + const events = (s.ha_webhook_events || '').split(',').map(e => e.trim()); + const cbExpiry = document.getElementById('ha-event-expiry'); + if (cbExpiry) cbExpiry.checked = events.includes('expiry'); + const cbShopping = document.getElementById('ha-event-shopping'); + if (cbShopping) cbShopping.checked = events.includes('shopping_add'); + const cbStock = document.getElementById('ha-event-stock'); + if (cbStock) cbStock.checked = events.includes('stock_update'); +} + +function _loadHaTab() { + const s = getSettings(); + _applyHaSettingsUI(s); + _renderHaSensorYaml(); +} + +function _renderHaSensorYaml() { + const el = document.getElementById('ha-sensor-yaml'); + if (!el) return; + const base = (window.location.origin + window.location.pathname).replace(/\/$/, '').replace(/\/index\.html$/, ''); + el.textContent = `# Add to configuration.yaml (Home Assistant) +# Restart HA after editing. + +sensor: + - platform: rest + name: "EverShelf Overview" + unique_id: evershelf_overview + resource: "${base}/api/?action=ha_sensor" + scan_interval: 300 + value_template: "{{ value_json.state }}" + json_attributes: + - expiring_soon + - expiring_3d + - expired_items + - total_items + - shopping_items + - expiring_list + - last_updated + unit_of_measurement: "items" + device_class: null + + - platform: rest + name: "EverShelf Expired Items" + unique_id: evershelf_expired + resource: "${base}/api/?action=ha_sensor&sensor=expired" + scan_interval: 600 + value_template: "{{ value_json.state }}" + unit_of_measurement: "items" + + - platform: rest + name: "EverShelf Shopping Count" + unique_id: evershelf_shopping + resource: "${base}/api/?action=ha_sensor&sensor=shopping" + scan_interval: 180 + value_template: "{{ value_json.state }}" + unit_of_measurement: "items"`; +} + +function copyHaSensorYaml() { + const el = document.getElementById('ha-sensor-yaml'); + if (!el) return; + navigator.clipboard.writeText(el.textContent).then(() => { + showToast(t('settings.ha.sensor_copied')); + }).catch(() => { + showToast(t('error.copy_failed')); + }); +} + +async function testHaConnection() { + const statusEl = document.getElementById('ha-test-status'); + const haUrl = document.getElementById('setting-ha-url')?.value.trim(); + const haToken = document.getElementById('setting-ha-token')?.value.trim(); + const s = getSettings(); + const tokenToUse = haToken || (s.ha_token_set ? '__server__' : ''); + + if (!haUrl) { + if (statusEl) { statusEl.style.display = 'block'; statusEl.className = 'settings-status error'; statusEl.textContent = t('settings.ha.error_no_url'); } + return; + } + if (statusEl) { statusEl.style.display = 'block'; statusEl.className = 'settings-status'; statusEl.textContent = t('settings.ha.testing'); } + + try { + const result = await api('ha_test', {}, 'POST', { url: haUrl, token: tokenToUse }); + if (result.ok) { + if (statusEl) { statusEl.className = 'settings-status success'; statusEl.textContent = '✅ ' + t('settings.ha.test_ok').replace('{version}', result.version || 'HA'); } + } else { + if (statusEl) { statusEl.className = 'settings-status error'; statusEl.textContent = '❌ ' + t('settings.ha.test_fail').replace('{error}', result.error || result.http_code || ''); } + } + } catch(e) { + if (statusEl) { statusEl.className = 'settings-status error'; statusEl.textContent = '❌ ' + e.message; } + } +} + +function applyHaTtsPreset() { + const s = getSettings(); + const haUrl = (document.getElementById('setting-ha-url')?.value || s.ha_url || '').replace(/\/$/, ''); + const entity = document.getElementById('setting-ha-tts-entity')?.value || s.ha_tts_entity || ''; + + if (!haUrl) { + showToast(t('settings.ha.error_no_url')); + return; + } + + // Switch to TTS tab and fill fields + const ttsTab = document.querySelector('[data-tab="tab-tts"]'); + if (ttsTab) ttsTab.click(); + + const engineEl = document.getElementById('setting-tts-engine'); + if (engineEl) { engineEl.value = 'server'; onTtsEngineChange('server'); } + + const urlEl = document.getElementById('setting-tts-url'); + if (urlEl) urlEl.value = haUrl + '/api/services/tts/speak'; + + const methodEl = document.getElementById('setting-tts-method'); + if (methodEl) methodEl.value = 'POST'; + + const authTypeEl = document.getElementById('setting-tts-auth-type'); + if (authTypeEl) { authTypeEl.value = 'bearer'; onTtsAuthTypeChange('bearer'); } + + const tokenEl = document.getElementById('setting-tts-token'); + if (tokenEl) { + const haToken = document.getElementById('setting-ha-token')?.value.trim() || s.ha_token || ''; + tokenEl.value = haToken; + } + + const payloadKeyEl = document.getElementById('setting-tts-payload-key'); + if (payloadKeyEl) payloadKeyEl.value = 'message'; + + const ctEl = document.getElementById('setting-tts-content-type'); + if (ctEl) ctEl.value = 'application/json'; + + const extraEl = document.getElementById('setting-tts-extra-fields'); + if (extraEl) extraEl.value = entity ? JSON.stringify({ entity_id: entity }) : ''; + + showToast(t('settings.ha.tts_preset_applied')); +} + +function showHaWebhookHelp() { + const msg = t('settings.ha.webhook_help'); + showToast(msg, 8000); +} + +async function saveHaSettings() { + const s = getSettings(); + const haEnabled = document.getElementById('setting-ha-enabled')?.checked || false; + const haUrl = document.getElementById('setting-ha-url')?.value.trim() || ''; + const haToken = document.getElementById('setting-ha-token')?.value.trim() || ''; + const haTtsEntity = document.getElementById('setting-ha-tts-entity')?.value.trim() || ''; + const haWebhookId = document.getElementById('setting-ha-webhook-id')?.value.trim() || ''; + const haNotify = document.getElementById('setting-ha-notify-service')?.value.trim() || ''; + const haExpiryDays = parseInt(document.getElementById('setting-ha-expiry-days')?.value, 10) || 3; + + const events = []; + if (document.getElementById('ha-event-expiry')?.checked) events.push('expiry'); + if (document.getElementById('ha-event-shopping')?.checked) events.push('shopping_add'); + if (document.getElementById('ha-event-stock')?.checked) events.push('stock_update'); + const haEvents = events.join(','); + + s.ha_enabled = haEnabled; + s.ha_url = haUrl; + if (haToken) s.ha_token = haToken; + s.ha_tts_entity = haTtsEntity; + s.ha_webhook_id = haWebhookId; + s.ha_webhook_events = haEvents; + s.ha_notify_service = haNotify; + s.ha_expiry_days = haExpiryDays; + saveSettingsToStorage(s); + + const statusEl = document.getElementById('ha-save-status'); + try { + const settingsToken = document.getElementById('setting-settings-token')?.value.trim() || ''; + const tokenHeader = settingsToken ? { 'X-Settings-Token': settingsToken } : {}; + const result = await api('save_settings', {}, 'POST', { + ha_enabled: haEnabled, + ha_url: haUrl, + ...(haToken ? { ha_token: haToken } : {}), + ha_tts_entity: haTtsEntity, + ha_webhook_id: haWebhookId, + ha_webhook_events: haEvents, + ha_notify_service: haNotify, + ha_expiry_days: haExpiryDays, + }, tokenHeader); + if (statusEl) { + statusEl.style.display = 'block'; + statusEl.className = result.success ? 'settings-status success' : 'settings-status error'; + statusEl.textContent = result.success ? '✅ ' + t('settings.saved') : '❌ ' + (result.error || t('settings.saved_local_error')); + setTimeout(() => { if (statusEl) statusEl.style.display = 'none'; }, 4000); + } + } catch(e) { + if (statusEl) { + statusEl.style.display = 'block'; + statusEl.className = 'settings-status success'; + statusEl.textContent = '✅ ' + t('settings.saved_local'); + setTimeout(() => { if (statusEl) statusEl.style.display = 'none'; }, 4000); + } + } } /** Populate voice selector from Web Speech API. Called on settings load and on voiceschanged. */ diff --git a/docs/wiki/Home-Assistant.md b/docs/wiki/Home-Assistant.md new file mode 100644 index 0000000..fa0fd7a --- /dev/null +++ b/docs/wiki/Home-Assistant.md @@ -0,0 +1,219 @@ +# Home Assistant Integration + +EverShelf integrates natively with [Home Assistant](https://www.home-assistant.io/) to bring your pantry data into your smart-home automations. + +**Capabilities:** +- 📡 **REST sensors** — expose pantry counts as HA sensor entities (expiring, expired, shopping list, total items) +- 🔔 **Webhooks** — trigger HA automations on pantry events (expiry alerts, shopping additions, stock updates) +- 📣 **Push notifications** — send alerts to your phone via any HA `notify.*` service +- 🔊 **TTS on smart speakers** — read recipe steps aloud on any HA `media_player` entity +- ⚙️ **In-app config panel** — configure everything from Settings → 🏠 tab (no need to edit `.env` manually) + +--- + +## Quick Setup + +1. **Generate a Long-Lived Access Token** in Home Assistant: + - Open HA → your **Profile** (bottom-left avatar) → **Security** → **Long-Lived Access Tokens** → **Create Token** + - Copy the generated token — you won't see it again. + +2. **Open EverShelf Settings** → tab **🏠 Home Assistant**. + +3. Fill in **Home Assistant URL** (e.g. `http://homeassistant.local:8123`) and paste the token. + +4. Click **Test connection** — you should see ✅. + +5. Enable the features you want (TTS, Webhooks, REST Sensors) and click **Save HA settings**. + +--- + +## REST Sensors + +Add EverShelf pantry data as native HA sensor entities that update automatically. + +### Endpoints + +| URL | Returns | Sensor | +|-----|---------|--------| +| `/api/?action=ha_sensor` | Items expiring soon (≤3 days) | `sensor.evershelf_overview` | +| `/api/?action=ha_sensor&sensor=expired` | Expired items count | `sensor.evershelf_expired` | +| `/api/?action=ha_sensor&sensor=shopping` | Shopping list item count | `sensor.evershelf_shopping` | +| `/api/?action=ha_sensor&sensor=total` | Total pantry items | `sensor.evershelf_total` | + +### Generate & Copy YAML + +In Settings → 🏠 Home Assistant → **REST Sensors** card, click **Copy YAML** to get a ready-to-paste `configuration.yaml` block that already contains your EverShelf URL. + +### Manual YAML example + +```yaml +# configuration.yaml +sensor: + - platform: rest + name: "EverShelf Overview" + unique_id: evershelf_overview + resource: "http://YOUR_EVERSHELF_URL/api/?action=ha_sensor" + scan_interval: 300 # seconds + value_template: "{{ value_json.state }}" + json_attributes: + - expiring_soon + - expiring_3d + - expired_items + - total_items + - shopping_items + - expiring_list + - last_updated + unit_of_measurement: "items" + + - platform: rest + name: "EverShelf Shopping Count" + unique_id: evershelf_shopping + resource: "http://YOUR_EVERSHELF_URL/api/?action=ha_sensor&sensor=shopping" + scan_interval: 180 + value_template: "{{ value_json.state }}" + unit_of_measurement: "items" +``` + +Restart Home Assistant after editing `configuration.yaml`. + +--- + +## Webhook Automations + +EverShelf fires an HTTP POST to your HA webhook URL when pantry events occur. + +### Create the HA Webhook Automation + +1. HA → **Settings** → **Automations & Scenes** → **Create Automation** +2. Click **Add Trigger** → choose **Webhook** +3. HA generates a **Webhook ID** — copy it +4. Paste the ID into **Settings → 🏠 Home Assistant → Webhook ID** +5. Select which events should trigger the webhook + +### Supported Events + +| Event key | When it fires | +|-----------|--------------| +| `expiry` | Daily cron — items expiring within `HA_EXPIRY_DAYS` days | +| `shopping_add` | Item added to the shopping list | +| `stock_update` | Inventory quantity changed | +| `barcode_scan` | (reserved for future use) | + +### Webhook Payload (POST body) + +```json +{ + "event": "expiry_alert", + "timestamp": "2025-06-12T08:00:00+00:00", + "data": { + "type": "expiring_soon", + "count": 3, + "days": 3, + "summary": "3 products expiring within 3 days", + "items": [ + { "name": "Milk", "expiry_date": "2025-06-14", "quantity": 1, "unit": "l" } + ] + } +} +``` + +### Example: Expiry Alert → Telegram + +```yaml +alias: EverShelf Expiry Alert +trigger: + - platform: webhook + webhook_id: "evershelf_webhook_abc123" # ← your Webhook ID +action: + - service: notify.telegram_bot + data: + message: > + 🥫 EverShelf: {{ trigger.json.data.summary }} + {% for item in trigger.json.data.items %} + — {{ item.name }} (expires {{ item.expiry_date }}) + {% endfor %} +``` + +--- + +## Push Notifications + +If you prefer to receive push alerts without using webhooks, configure a **HA notify service** directly: + +1. Find your notify service name in HA: **Developer Tools → Services** → search `notify` +2. Paste it into **Settings → 🏠 → Notify service** (e.g. `notify.mobile_app_my_phone`) +3. Save + +EverShelf will call this service from the cron job whenever expiry alerts fire. + +--- + +## TTS on Smart Speakers + +Read recipe steps aloud on an Amazon Echo, Google Home, Sonos, or any HA `media_player`. + +### Configuration + +1. Enter the **Entity ID** of your media player (e.g. `media_player.kitchen_display`) + - Find it in HA: **Developer Tools → States** +2. Click **Apply HA preset to TTS tab** — this auto-fills the TTS tab with the correct HA endpoint and auth headers +3. Save settings + +### How it Works + +When recipe step TTS is triggered, EverShelf calls: + +``` +POST /api/services/tts/speak +Authorization: Bearer +{ + "entity_id": "media_player.kitchen_display", + "message": "Add 200 g of flour and mix well." +} +``` + +The request is proxied through the EverShelf PHP backend (avoids CORS / mixed-content issues). + +--- + +## Environment Variables Reference + +All settings are configurable from `.env` or from the in-app Settings panel. + +| Variable | Default | Description | +|----------|---------|-------------| +| `HA_ENABLED` | `false` | Master switch for all HA features | +| `HA_URL` | _(empty)_ | Base URL of HA instance, no trailing slash | +| `HA_TOKEN` | _(empty)_ | Long-Lived Access Token | +| `HA_TTS_ENTITY` | _(empty)_ | `media_player` entity for TTS | +| `HA_WEBHOOK_ID` | _(empty)_ | Webhook trigger ID from HA automation | +| `HA_WEBHOOK_EVENTS` | `expiry,shopping_add,stock_update` | Comma-separated list of events | +| `HA_NOTIFY_SERVICE` | _(empty)_ | HA notify service (e.g. `notify.mobile_app_phone`) | +| `HA_EXPIRY_DAYS` | `3` | Days before expiry to trigger the daily alert | + +--- + +## Troubleshooting + +**Test shows ❌ "Connection failed"** +- Verify the URL is reachable from the EverShelf server (not just your browser) +- If using HTTPS with a self-signed certificate, the server-side cURL request may fail — use HTTP on the local network instead +- Check that port 8123 (or your custom port) is open on the HA host + +**Test shows ❌ "bad_token"** +- The Long-Lived Access Token may have expired or been revoked — generate a new one in HA Profile + +**Webhook not firing** +- Confirm HA_ENABLED=true and the Webhook ID is exactly as shown in HA +- Check the EverShelf cron is running (`/api/cron_smart_shopping.php` every 5 minutes) +- For shopping/stock events: verify the event name is in `HA_WEBHOOK_EVENTS` + +**TTS not speaking** +- Ensure the media player entity is online in HA (check its state in Developer Tools) +- Try the "Apply HA preset to TTS tab" button and send a test from the TTS tab +- Check HA logs for `tts.speak` errors (some platforms require `tts_options`) + +**Sensors show unavailable in HA** +- The EverShelf URL must be reachable from the HA host +- If running EverShelf behind a reverse proxy, ensure `/api/` is accessible +- Use `scan_interval` ≥ 60 to avoid hammering the server diff --git a/index.html b/index.html index efa8a91..a0dab14 100644 --- a/index.html +++ b/index.html @@ -841,6 +841,7 @@ + @@ -1315,8 +1316,124 @@ + +
+ 🏠 Se usi Home Assistant, usa il tab Home Assistant per configurare TTS, webhook e sensori. +
+ +
+ +
+

🏠 Home Assistant

+

Integra EverShelf con Home Assistant: TTS su speaker smart, webhook per automazioni, sensori per la dashboard.

+ +
+ +
+ +
+
+ + +

URL base della tua istanza HA (senza slash finale). Es: http://homeassistant.local:8123

+
+
+ +
+ + +
+

Genera un token in HA → Profilo → Token di accesso a lungo termine.

+
+ + +
+
+ + +
+

🔊 TTS su Speaker Smart

+

Leggi i passi della ricetta su un altoparlante gestito da HA (Sonos, Echo, Google Home, ecc.).

+ +
+ + +

Copia l'entity ID del media player da HA → Strumenti sviluppatore → Stati.

+
+
+ + +
+ +

Configura automaticamente il tab TTS con i parametri HA corretti.

+
+ + +
+

⚡ Automazioni Webhook

+

EverShelf chiama il webhook HA quando si verificano eventi (prodotto in scadenza, aggiunto alla lista, ecc.).

+ +
+ + +

Crea un'automazione in HA con trigger "Webhook" e copia qui l'ID. Come farlo?

+
+
+ +
+ + + +
+
+
+ + +

Quanti giorni prima della scadenza inviare l'alert.

+
+
+ + +
+

📱 Notifiche Push

+

EverShelf invia notifiche push tramite il servizio notify.* di HA (Telegram, Pushover, app mobile, ecc.).

+ +
+ + +

Formato: notify.NOME_SERVIZIO. Lascia vuoto per disabilitare. Richiede token HA configurato.

+
+
+ + +
+

📊 Sensori REST per HA

+

HA può leggere i dati dell'inventario via REST polling. Aggiungi questo snippet a configuration.yaml:

+
+ +
+ + + + +
diff --git a/translations/de.json b/translations/de.json index 9722038..6799e7e 100644 --- a/translations/de.json +++ b/translations/de.json @@ -868,6 +868,60 @@ "forecast_label": "Prognose für bald leere Produkte", "auto_add_label": "Automatisch hinzufügen wenn", "auto_add_suffix": "im Lager verbleibend (0 = nur wenn leer)" + }, + "ha": { + "tab": "Home Assistant", + "title": "Home Assistant", + "hint": "Verbinde EverShelf mit Home Assistant für Automationen, Push-Benachrichtigungen und REST-Sensoren.", + "enabled": "Home Assistant-Integration aktivieren", + "connection_title": "Verbindung", + "url_label": "Home Assistant URL", + "url_placeholder": "http://192.168.1.50:8123", + "url_hint": "Basis-URL deiner Home Assistant-Instanz (z.B. http://homeassistant.local:8123).", + "token_label": "Long-Lived Access Token", + "token_hint": "Erstelle unter HA-Profil → Sicherheit → Langlebige Zugangstoken.", + "token_placeholder": "eyJhbGci...", + "token_saved": "Token gespeichert (aus Sicherheitsgründen verborgen)", + "test_btn": "Verbindung testen", + "test_ok": "Verbunden mit {version}", + "test_fail": "Verbindung fehlgeschlagen: {error}", + "test_bad_token": "HA erreichbar, aber Token ist ungültig", + "testing": "Teste…", + "error_no_url": "Bitte zuerst die Home Assistant URL eingeben.", + "tts_title": "TTS auf Smart Speaker", + "tts_hint": "Rezeptschritte auf einem Home Assistant Media Player vorlesen.", + "tts_entity_label": "Media Player Entity ID", + "tts_entity_placeholder": "media_player.wohnzimmer", + "tts_entity_hint": "Entity-ID des HA-Media-Players. Zu finden unter HA: Entwicklertools → Zustände.", + "tts_platform_label": "TTS-Plattform", + "tts_platform_speak": "tts.speak (empfohlen)", + "tts_platform_notify": "notify.* (Benachrichtigungsdienst)", + "tts_apply_btn": "HA-Voreinstellung auf TTS-Tab anwenden", + "tts_apply_hint": "Füllt den TTS-Tab mit der Home Assistant URL und dem Token aus.", + "tts_preset_applied": "HA-Voreinstellung auf TTS-Tab angewendet.", + "webhook_title": "Webhook-Automationen", + "webhook_hint": "Sende Daten an Home Assistant, wenn Ereignisse in der Vorratskammer auftreten.", + "webhook_id_label": "Webhook-ID", + "webhook_id_placeholder": "evershelf_webhook_abc123", + "webhook_id_hint": "ID des in HA erstellten Webhooks. Kopiere aus: HA → Einstellungen → Automationen → Erstellen → Webhook-Auslöser.", + "webhook_events_label": "Benachrichtige bei diesen Ereignissen", + "event_expiry": "Ablaufende Produkte (täglich)", + "event_shopping": "Artikel zur Einkaufsliste hinzugefügt", + "event_stock": "Lagerbestand aktualisiert", + "expiry_days_label": "Ablaufwarnung im Voraus (Tage)", + "expiry_days_hint": "Sende die Ablaufwarnung N Tage vor dem Ablaufdatum.", + "webhook_help": "In HA: Einstellungen → Automationen → Automation erstellen → Auslöser: Webhook → ID kopieren.", + "notify_title": "Push-Benachrichtigungen", + "notify_hint": "Sende Push-Benachrichtigungen über einen Home Assistant notify-Dienst.", + "notify_service_label": "Notify-Dienst", + "notify_service_placeholder": "notify.mobile_app_mein_handy", + "notify_service_hint": "Name des HA-notify-Dienstes (z.B. notify.mobile_app_phone). Leer lassen zum Deaktivieren.", + "sensor_title": "REST-Sensoren", + "sensor_hint": "Zur configuration.yaml hinzufügen, um EverShelf-Sensoren in Home Assistant zu erstellen.", + "sensor_copy_btn": "YAML kopieren", + "sensor_copied": "YAML in die Zwischenablage kopiert!", + "save_btn": "HA-Einstellungen speichern", + "ha_hint": "Wenn du Home Assistant verwendest, nutze den Home Assistant-Tab für TTS, Webhooks und Sensoren." } }, "expiry": { diff --git a/translations/en.json b/translations/en.json index cedcf46..9881abd 100644 --- a/translations/en.json +++ b/translations/en.json @@ -868,6 +868,60 @@ "forecast_label": "Forecast low-stock products", "auto_add_label": "Auto-add to list when", "auto_add_suffix": "remaining in stock (0 = only when empty)" + }, + "ha": { + "tab": "Home Assistant", + "title": "Home Assistant", + "hint": "Connect EverShelf to Home Assistant for automations, push notifications and REST sensors.", + "enabled": "Enable Home Assistant integration", + "connection_title": "Connection", + "url_label": "Home Assistant URL", + "url_placeholder": "http://192.168.1.50:8123", + "url_hint": "Base URL of your Home Assistant instance (e.g. http://homeassistant.local:8123).", + "token_label": "Long-Lived Access Token", + "token_hint": "Generate from HA Profile → Security → Long-Lived Access Tokens.", + "token_placeholder": "eyJhbGci...", + "token_saved": "Token saved (hidden for security)", + "test_btn": "Test connection", + "test_ok": "Connected to {version}", + "test_fail": "Connection failed: {error}", + "test_bad_token": "HA reachable but token is invalid", + "testing": "Testing…", + "error_no_url": "Please enter the Home Assistant URL first.", + "tts_title": "TTS on Smart Speaker", + "tts_hint": "Read recipe steps aloud on a Home Assistant media player.", + "tts_entity_label": "Media player entity ID", + "tts_entity_placeholder": "media_player.living_room", + "tts_entity_hint": "Entity ID of the HA media player. Find it in HA: Developer Tools → States.", + "tts_platform_label": "TTS platform", + "tts_platform_speak": "tts.speak (recommended)", + "tts_platform_notify": "notify.* (notification service)", + "tts_apply_btn": "Apply HA preset to TTS tab", + "tts_apply_hint": "Pre-fills the TTS tab with the Home Assistant URL and token.", + "tts_preset_applied": "HA preset applied to TTS tab.", + "webhook_title": "Webhook Automations", + "webhook_hint": "Send data to Home Assistant when pantry events occur. Create an HA automation with a Webhook trigger and paste the generated ID here.", + "webhook_id_label": "Webhook ID", + "webhook_id_placeholder": "evershelf_webhook_abc123", + "webhook_id_hint": "ID of the webhook created in HA. Copy from: HA → Settings → Automations → Create → Webhook Trigger.", + "webhook_events_label": "Notify on these events", + "event_expiry": "Expiring products (daily)", + "event_shopping": "Item added to shopping list", + "event_stock": "Stock level updated", + "expiry_days_label": "Expiry lead time (days)", + "expiry_days_hint": "Send the expiry alert N days before the expiry date.", + "webhook_help": "In HA: Settings → Automations → Create automation → Trigger: Webhook → copy the generated ID above.", + "notify_title": "Push Notifications", + "notify_hint": "Send push notifications to your phone via a Home Assistant notify service.", + "notify_service_label": "Notify service", + "notify_service_placeholder": "notify.mobile_app_my_phone", + "notify_service_hint": "HA notify service name (e.g. notify.mobile_app_phone). Leave empty to disable.", + "sensor_title": "REST Sensors", + "sensor_hint": "Add to configuration.yaml to create EverShelf sensors in Home Assistant.", + "sensor_copy_btn": "Copy YAML", + "sensor_copied": "YAML copied to clipboard!", + "save_btn": "Save HA settings", + "ha_hint": "If you use Home Assistant, use the Home Assistant tab to configure TTS, webhooks and sensors." } }, "expiry": { diff --git a/translations/es.json b/translations/es.json index 7d0bf68..a29f336 100644 --- a/translations/es.json +++ b/translations/es.json @@ -821,6 +821,60 @@ "forecast_label": "Previsión de productos por agotar", "auto_add_label": "Añadir automáticamente cuando", "auto_add_suffix": "restante en stock (0 = solo cuando se agota)" + }, + "ha": { + "tab": "Home Assistant", + "title": "Home Assistant", + "hint": "Conecta EverShelf a Home Assistant para automatizaciones, notificaciones push y sensores REST.", + "enabled": "Activar integración con Home Assistant", + "connection_title": "Conexión", + "url_label": "URL de Home Assistant", + "url_placeholder": "http://192.168.1.50:8123", + "url_hint": "URL base de tu instancia de Home Assistant.", + "token_label": "Token de acceso de larga duración", + "token_hint": "Genera desde Perfil HA → Seguridad → Tokens de acceso de larga duración.", + "token_placeholder": "eyJhbGci...", + "token_saved": "Token guardado (oculto por seguridad)", + "test_btn": "Probar conexión", + "test_ok": "Conectado a {version}", + "test_fail": "Conexión fallida: {error}", + "test_bad_token": "HA accesible pero el token no es válido", + "testing": "Probando…", + "error_no_url": "Por favor, introduce primero la URL de Home Assistant.", + "tts_title": "TTS en altavoz inteligente", + "tts_hint": "Lee los pasos de la receta en un reproductor de medios de Home Assistant.", + "tts_entity_label": "Entity ID del reproductor multimedia", + "tts_entity_placeholder": "media_player.salon", + "tts_entity_hint": "ID de entidad del reproductor multimedia HA. Encuéntralo en HA: Herramientas para desarrolladores → Estados.", + "tts_platform_label": "Plataforma TTS", + "tts_platform_speak": "tts.speak (recomendado)", + "tts_platform_notify": "notify.* (servicio de notificaciones)", + "tts_apply_btn": "Aplicar preset HA a la pestaña TTS", + "tts_apply_hint": "Pre-rellena la pestaña TTS con la URL y el token de Home Assistant.", + "tts_preset_applied": "Preset HA aplicado a la pestaña TTS.", + "webhook_title": "Automatizaciones Webhook", + "webhook_hint": "Envía datos a Home Assistant cuando ocurren eventos en la despensa.", + "webhook_id_label": "ID de Webhook", + "webhook_id_placeholder": "evershelf_webhook_abc123", + "webhook_id_hint": "ID del webhook creado en HA. Copia desde: HA → Ajustes → Automatizaciones → Crear → Disparador Webhook.", + "webhook_events_label": "Notificar en estos eventos", + "event_expiry": "Productos próximos a caducar (diario)", + "event_shopping": "Artículo añadido a la lista de compras", + "event_stock": "Nivel de stock actualizado", + "expiry_days_label": "Antelación de caducidad (días)", + "expiry_days_hint": "Enviar alerta de caducidad N días antes de la fecha.", + "webhook_help": "En HA: Ajustes → Automatizaciones → Crear automatización → Disparador: Webhook → copia el ID generado.", + "notify_title": "Notificaciones push", + "notify_hint": "Envía notificaciones push a tu teléfono mediante un servicio notify de Home Assistant.", + "notify_service_label": "Servicio notify", + "notify_service_placeholder": "notify.mobile_app_mi_telefono", + "notify_service_hint": "Nombre del servicio notify de HA. Déjalo vacío para desactivar.", + "sensor_title": "Sensores REST", + "sensor_hint": "Añade a configuration.yaml para crear sensores de EverShelf en Home Assistant.", + "sensor_copy_btn": "Copiar YAML", + "sensor_copied": "¡YAML copiado al portapapeles!", + "save_btn": "Guardar ajustes HA", + "ha_hint": "Si usas Home Assistant, utiliza la pestaña Home Assistant para configurar TTS, webhooks y sensores." } }, "expiry": { diff --git a/translations/fr.json b/translations/fr.json index a7f549d..dda794e 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -821,6 +821,60 @@ "forecast_label": "Prévision des produits bientôt épuisés", "auto_add_label": "Ajouter automatiquement quand", "auto_add_suffix": "restant en stock (0 = seulement quand épuisé)" + }, + "ha": { + "tab": "Home Assistant", + "title": "Home Assistant", + "hint": "Connectez EverShelf à Home Assistant pour les automations, les notifications push et les capteurs REST.", + "enabled": "Activer l'intégration Home Assistant", + "connection_title": "Connexion", + "url_label": "URL Home Assistant", + "url_placeholder": "http://192.168.1.50:8123", + "url_hint": "URL de base de votre instance Home Assistant.", + "token_label": "Jeton d'accès longue durée", + "token_hint": "Générez depuis Profil HA → Sécurité → Jetons d'accès longue durée.", + "token_placeholder": "eyJhbGci...", + "token_saved": "Jeton enregistré (masqué pour des raisons de sécurité)", + "test_btn": "Tester la connexion", + "test_ok": "Connecté à {version}", + "test_fail": "Connexion échouée : {error}", + "test_bad_token": "HA accessible mais le jeton est invalide", + "testing": "Test en cours…", + "error_no_url": "Veuillez d'abord saisir l'URL de Home Assistant.", + "tts_title": "TTS sur enceinte connectée", + "tts_hint": "Lisez les étapes de recette sur un media player Home Assistant.", + "tts_entity_label": "Entity ID du lecteur multimédia", + "tts_entity_placeholder": "media_player.salon", + "tts_entity_hint": "Entity ID du lecteur multimédia HA. Disponible dans HA : Outils développeur → États.", + "tts_platform_label": "Plateforme TTS", + "tts_platform_speak": "tts.speak (recommandé)", + "tts_platform_notify": "notify.* (service de notification)", + "tts_apply_btn": "Appliquer le preset HA à l'onglet TTS", + "tts_apply_hint": "Pré-remplit l'onglet TTS avec l'URL et le jeton de Home Assistant.", + "tts_preset_applied": "Preset HA appliqué à l'onglet TTS.", + "webhook_title": "Automations Webhook", + "webhook_hint": "Envoyez des données à Home Assistant lors d'événements dans le garde-manger.", + "webhook_id_label": "ID Webhook", + "webhook_id_placeholder": "evershelf_webhook_abc123", + "webhook_id_hint": "ID du webhook créé dans HA. Copiez depuis : HA → Paramètres → Automations → Créer → Déclencheur Webhook.", + "webhook_events_label": "Notifier pour ces événements", + "event_expiry": "Produits expirant bientôt (quotidien)", + "event_shopping": "Article ajouté à la liste de courses", + "event_stock": "Niveau de stock mis à jour", + "expiry_days_label": "Préavis d'expiration (jours)", + "expiry_days_hint": "Envoyer l'alerte d'expiration N jours avant la date d'expiration.", + "webhook_help": "Dans HA : Paramètres → Automations → Créer → Déclencheur : Webhook → copier l'ID généré.", + "notify_title": "Notifications push", + "notify_hint": "Envoyez des notifications push sur votre téléphone via un service notify de Home Assistant.", + "notify_service_label": "Service notify", + "notify_service_placeholder": "notify.mobile_app_mon_telephone", + "notify_service_hint": "Nom du service notify HA. Laissez vide pour désactiver.", + "sensor_title": "Capteurs REST", + "sensor_hint": "Ajoutez à configuration.yaml pour créer des capteurs EverShelf dans Home Assistant.", + "sensor_copy_btn": "Copier le YAML", + "sensor_copied": "YAML copié dans le presse-papiers !", + "save_btn": "Enregistrer les paramètres HA", + "ha_hint": "Si vous utilisez Home Assistant, utilisez l'onglet Home Assistant pour configurer TTS, webhooks et capteurs." } }, "expiry": { diff --git a/translations/it.json b/translations/it.json index d9b1cd3..4be2ea0 100644 --- a/translations/it.json +++ b/translations/it.json @@ -868,6 +868,60 @@ "forecast_label": "Previsione prodotti in esaurimento", "auto_add_label": "Aggiungi automaticamente quando", "auto_add_suffix": "rimasto in magazzino (0 = solo quando esaurito)" + }, + "ha": { + "tab": "Home Assistant", + "title": "Home Assistant", + "hint": "Collega EverShelf a Home Assistant per automazioni, notifiche push e sensori REST.", + "enabled": "Abilita integrazione Home Assistant", + "connection_title": "Connessione", + "url_label": "URL Home Assistant", + "url_placeholder": "http://192.168.1.50:8123", + "url_hint": "URL del tuo server Home Assistant (es. http://homeassistant.local:8123).", + "token_label": "Long-Lived Access Token", + "token_hint": "Genera da Profilo HA → Sicurezza → Token di accesso a lungo termine.", + "token_placeholder": "eyJhbGci...", + "token_saved": "Token salvato (non mostrato per sicurezza)", + "test_btn": "Testa connessione", + "test_ok": "Connesso a {version}", + "test_fail": "Connessione fallita: {error}", + "test_bad_token": "HA raggiungibile ma token non valido", + "testing": "Test in corso…", + "error_no_url": "Inserisci prima l'URL di Home Assistant.", + "tts_title": "TTS su Speaker Smart", + "tts_hint": "Leggi i passi delle ricette su un media player di Home Assistant.", + "tts_entity_label": "Entity ID media player", + "tts_entity_placeholder": "media_player.living_room", + "tts_entity_hint": "Entity ID del media player su cui vuoi la voce. Puoi trovarlo in HA: Strumenti per sviluppatori → Stati.", + "tts_platform_label": "Piattaforma TTS", + "tts_platform_speak": "tts.speak (raccomandato)", + "tts_platform_notify": "notify.* (servizio notifiche)", + "tts_apply_btn": "Applica preset HA al tab TTS", + "tts_apply_hint": "Pre-compila il tab TTS con l'URL e il token di Home Assistant.", + "tts_preset_applied": "Preset HA applicato al tab TTS.", + "webhook_title": "Automazioni Webhook", + "webhook_hint": "Invia dati a Home Assistant quando avvengono eventi nella dispensa. Crea un'automazione in HA con trigger Webhook e copia l'ID generato.", + "webhook_id_label": "Webhook ID", + "webhook_id_placeholder": "evershelf_webhook_abc123", + "webhook_id_hint": "ID del webhook creato in HA. Copia da: HA → Impostazioni → Automazioni → Crea → Trigger Webhook.", + "webhook_events_label": "Notifica per questi eventi", + "event_expiry": "Prodotti in scadenza (giornaliero)", + "event_shopping": "Aggiunta alla lista della spesa", + "event_stock": "Aggiornamento scorte", + "expiry_days_label": "Anticipo scadenze (giorni)", + "expiry_days_hint": "Invia la notifica di scadenza N giorni prima della data di scadenza.", + "webhook_help": "In HA: Impostazioni → Automazioni → Crea automazione → Trigger: Webhook → copia l'ID generato qui sopra.", + "notify_title": "Notifiche Push", + "notify_hint": "Invia notifiche push al tuo telefono tramite il servizio notify di Home Assistant.", + "notify_service_label": "Servizio notify", + "notify_service_placeholder": "notify.mobile_app_mio_telefono", + "notify_service_hint": "Nome del servizio notify HA (es. notify.mobile_app_phone). Lascia vuoto per disabilitare.", + "sensor_title": "Sensori REST", + "sensor_hint": "Aggiungi a configuration.yaml per creare sensori EverShelf in Home Assistant.", + "sensor_copy_btn": "Copia YAML", + "sensor_copied": "YAML copiato negli appunti!", + "save_btn": "Salva impostazioni HA", + "ha_hint": "Se usi Home Assistant, usa il tab Home Assistant per configurare TTS, webhook e sensori." } }, "expiry": {