feat: full Home Assistant integration
- 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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
+255
@@ -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' => []]);
|
||||
|
||||
+277
-4
@@ -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. */
|
||||
|
||||
@@ -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 <HA_TOKEN>
|
||||
{
|
||||
"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
|
||||
+117
@@ -841,6 +841,7 @@
|
||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-camera')" data-tab="tab-camera" title="Fotocamera">📷</button>
|
||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-security')" data-tab="tab-security" title="Sicurezza">🔒</button>
|
||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-tts')" data-tab="tab-tts" title="Voce (TTS)" data-i18n-title="settings.tab_tts">🔊</button>
|
||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-ha'); _loadHaTab();" data-tab="tab-ha" title="Home Assistant" data-i18n-title="settings.ha.tab">🏠</button>
|
||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-scale')" data-tab="tab-scale" title="Bilancia Smart" data-i18n-title="settings.scale.tab">⚖️</button>
|
||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-backup'); _loadBackupTab();" data-tab="tab-backup" data-i18n-title="settings.backup.tab" title="Backup">💾</button>
|
||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-info'); _loadInfoTab();" data-tab="tab-info" data-i18n-title="settings.info.tab" title="Info">ℹ️</button>
|
||||
@@ -1315,8 +1316,124 @@
|
||||
|
||||
<button class="btn btn-large btn-accent full-width mt-2" onclick="testTTS()" data-i18n="settings.tts.test_btn">🔊 Invia Test Vocale</button>
|
||||
<div id="tts-test-status" style="display:none;margin-top:8px"></div>
|
||||
<!-- HA TTS quick-fill hint -->
|
||||
<div style="margin-top:12px;padding:10px 12px;background:rgba(3,169,244,0.07);border:1px solid rgba(3,169,244,0.25);border-radius:8px;font-size:0.82rem">
|
||||
<span data-i18n="settings.tts.ha_hint">🏠 Se usi Home Assistant, usa il tab <strong>Home Assistant</strong> per configurare TTS, webhook e sensori.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Home Assistant Tab -->
|
||||
<div class="settings-panel" id="tab-ha">
|
||||
<!-- Connection card -->
|
||||
<div class="settings-card">
|
||||
<h4 data-i18n="settings.ha.title">🏠 Home Assistant</h4>
|
||||
<p class="settings-hint" data-i18n="settings.ha.hint">Integra EverShelf con Home Assistant: TTS su speaker smart, webhook per automazioni, sensori per la dashboard.</p>
|
||||
|
||||
<div class="form-group" style="margin-bottom:10px">
|
||||
<label class="toggle-row">
|
||||
<span data-i18n="settings.ha.enabled">✅ Abilita integrazione Home Assistant</span>
|
||||
<span class="toggle-switch">
|
||||
<input type="checkbox" id="setting-ha-enabled" onchange="onHaEnabledChange()">
|
||||
<span class="toggle-slider"></span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="ha-config-section">
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings.ha.url_label">🌐 Home Assistant URL</label>
|
||||
<input type="url" id="setting-ha-url" class="form-input" placeholder="http://192.168.1.50:8123">
|
||||
<p class="settings-hint" data-i18n="settings.ha.url_hint">URL base della tua istanza HA (senza slash finale). Es: <code>http://homeassistant.local:8123</code></p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings.ha.token_label">🔑 Long-Lived Access Token</label>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<input type="password" id="setting-ha-token" class="form-input" style="flex:1" placeholder="eyJhbGci...">
|
||||
<button class="btn btn-secondary" style="flex-shrink:0" onclick="togglePasswordVisibility('setting-ha-token')" data-i18n="btn.toggle_password">👁️</button>
|
||||
</div>
|
||||
<p class="settings-hint" data-i18n="settings.ha.token_hint">Genera un token in HA → Profilo → Token di accesso a lungo termine.</p>
|
||||
</div>
|
||||
<button class="btn btn-secondary full-width" onclick="testHaConnection()" data-i18n="settings.ha.test_btn">🔗 Testa connessione HA</button>
|
||||
<div id="ha-test-status" style="display:none;margin-top:8px" class="settings-status"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TTS via HA card -->
|
||||
<div class="settings-card">
|
||||
<h4 data-i18n="settings.ha.tts_title">🔊 TTS su Speaker Smart</h4>
|
||||
<p class="settings-hint" data-i18n="settings.ha.tts_hint">Leggi i passi della ricetta su un altoparlante gestito da HA (Sonos, Echo, Google Home, ecc.).</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings.ha.tts_entity_label">🔈 Entity ID del media player</label>
|
||||
<input type="text" id="setting-ha-tts-entity" class="form-input" placeholder="media_player.living_room">
|
||||
<p class="settings-hint" data-i18n="settings.ha.tts_entity_hint">Copia l'entity ID del media player da HA → Strumenti sviluppatore → Stati.</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings.ha.tts_platform_label">🎙️ Piattaforma TTS</label>
|
||||
<select id="setting-ha-tts-platform" class="form-input">
|
||||
<option value="tts.speak" data-i18n="settings.ha.tts_platform_speak">tts.speak (raccomandato)</option>
|
||||
<option value="notify" data-i18n="settings.ha.tts_platform_notify">notify.* (servizio notifiche)</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn btn-secondary full-width" onclick="applyHaTtsPreset()" data-i18n="settings.ha.tts_apply_btn">✅ Applica preset HA al TTS</button>
|
||||
<p class="settings-hint mt-2" data-i18n="settings.ha.tts_apply_hint">Configura automaticamente il tab TTS con i parametri HA corretti.</p>
|
||||
</div>
|
||||
|
||||
<!-- Webhook card -->
|
||||
<div class="settings-card">
|
||||
<h4 data-i18n="settings.ha.webhook_title">⚡ Automazioni Webhook</h4>
|
||||
<p class="settings-hint" data-i18n="settings.ha.webhook_hint">EverShelf chiama il webhook HA quando si verificano eventi (prodotto in scadenza, aggiunto alla lista, ecc.).</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings.ha.webhook_id_label">🔗 Webhook ID</label>
|
||||
<input type="text" id="setting-ha-webhook-id" class="form-input" placeholder="evershelf_events">
|
||||
<p class="settings-hint" data-i18n="settings.ha.webhook_id_hint">Crea un'automazione in HA con trigger "Webhook" e copia qui l'ID. <a href="#" onclick="showHaWebhookHelp();return false" style="color:var(--accent)">Come farlo?</a></p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings.ha.webhook_events_label">📋 Eventi da notificare</label>
|
||||
<div style="display:flex;flex-direction:column;gap:6px;margin-top:4px">
|
||||
<label style="display:flex;align-items:center;gap:8px;font-weight:normal">
|
||||
<input type="checkbox" id="ha-event-expiry" value="expiry"> <span data-i18n="settings.ha.event_expiry">Prodotti in scadenza (cron giornaliero)</span>
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:8px;font-weight:normal">
|
||||
<input type="checkbox" id="ha-event-shopping" value="shopping_add"> <span data-i18n="settings.ha.event_shopping">Aggiunta alla lista della spesa</span>
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:8px;font-weight:normal">
|
||||
<input type="checkbox" id="ha-event-stock" value="stock_update"> <span data-i18n="settings.ha.event_stock">Aggiornamento scorte (quantità modificata)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings.ha.expiry_days_label">📅 Giorni anticipo per scadenze</label>
|
||||
<input type="number" id="setting-ha-expiry-days" class="form-input" min="1" max="30" value="3">
|
||||
<p class="settings-hint" data-i18n="settings.ha.expiry_days_hint">Quanti giorni prima della scadenza inviare l'alert.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notify service card -->
|
||||
<div class="settings-card">
|
||||
<h4 data-i18n="settings.ha.notify_title">📱 Notifiche Push</h4>
|
||||
<p class="settings-hint" data-i18n="settings.ha.notify_hint">EverShelf invia notifiche push tramite il servizio <code>notify.*</code> di HA (Telegram, Pushover, app mobile, ecc.).</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings.ha.notify_service_label">📣 Servizio notify</label>
|
||||
<input type="text" id="setting-ha-notify-service" class="form-input" placeholder="notify.mobile_app_mio_telefono">
|
||||
<p class="settings-hint" data-i18n="settings.ha.notify_service_hint">Formato: <code>notify.NOME_SERVIZIO</code>. Lascia vuoto per disabilitare. Richiede token HA configurato.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sensor card (read-only info) -->
|
||||
<div class="settings-card">
|
||||
<h4 data-i18n="settings.ha.sensor_title">📊 Sensori REST per HA</h4>
|
||||
<p class="settings-hint" data-i18n="settings.ha.sensor_hint">HA può leggere i dati dell'inventario via REST polling. Aggiungi questo snippet a <code>configuration.yaml</code>:</p>
|
||||
<div id="ha-sensor-yaml" style="background:var(--bg-secondary,#f1f5f9);border-radius:8px;padding:12px;font-family:monospace;font-size:0.75rem;white-space:pre;overflow-x:auto;max-height:220px;overflow-y:auto;border:1px solid var(--border,#e2e8f0)"></div>
|
||||
<button class="btn btn-secondary full-width mt-2" onclick="copyHaSensorYaml()" data-i18n="settings.ha.sensor_copy_btn">📋 Copia YAML</button>
|
||||
</div>
|
||||
|
||||
<!-- Save button -->
|
||||
<button class="btn btn-large btn-accent full-width" onclick="saveHaSettings()" data-i18n="settings.ha.save_btn">💾 Salva impostazioni HA</button>
|
||||
<div id="ha-save-status" style="display:none;margin-top:8px" class="settings-status"></div>
|
||||
</div>
|
||||
<!-- Scale Tab -->
|
||||
<div class="settings-panel" id="tab-scale">
|
||||
<div class="settings-card">
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user