965a672abe
- 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
200 lines
9.7 KiB
PHP
200 lines
9.7 KiB
PHP
<?php
|
|
/**
|
|
* Cron: pre-compute smart shopping list and save to cache.
|
|
* Install with: crontab -e
|
|
* *\/5 * * * * php /var/www/html/evershelf/api/cron_smart_shopping.php >> /var/www/html/evershelf/data/cron.log 2>&1
|
|
*/
|
|
|
|
// Only allow CLI execution — block HTTP access
|
|
if (PHP_SAPI !== 'cli') {
|
|
http_response_code(403);
|
|
exit('Forbidden');
|
|
}
|
|
|
|
// Define CRON_MODE before loading index.php so the router is skipped
|
|
define('CRON_MODE', true);
|
|
|
|
// Load all API functions without running the HTTP router
|
|
require_once __DIR__ . '/index.php';
|
|
|
|
const CACHE_FILE = __DIR__ . '/../data/smart_shopping_cache.json';
|
|
|
|
try {
|
|
$db = getDB();
|
|
|
|
// Capture the JSON output of smartShopping()
|
|
ob_start();
|
|
smartShopping($db);
|
|
$json = ob_get_clean();
|
|
|
|
$decoded = json_decode($json, true);
|
|
if (!$decoded || !isset($decoded['success'])) {
|
|
throw new RuntimeException('Invalid JSON from smartShopping(): ' . substr($json, 0, 200));
|
|
}
|
|
|
|
$decoded['cached_at'] = date('c');
|
|
$decoded['cached_ts'] = time();
|
|
|
|
if (file_put_contents(CACHE_FILE, json_encode($decoded, JSON_UNESCAPED_UNICODE)) === false) {
|
|
throw new RuntimeException('Cannot write cache file: ' . CACHE_FILE);
|
|
}
|
|
|
|
$itemCount = count($decoded['items'] ?? []);
|
|
echo '[' . date('Y-m-d H:i:s') . '] OK — ' . $itemCount . " items cached\n";
|
|
|
|
// ── Bring! server-side cleanup ────────────────────────────────────────
|
|
// After computing smart shopping, automatically remove stale Bring! items
|
|
// and add/update critical ones. This runs fully server-side every cron cycle.
|
|
try {
|
|
$cleanupResult = bringCleanupObsolete($db);
|
|
if (isset($cleanupResult['skipped'])) {
|
|
echo '[' . date('Y-m-d H:i:s') . '] Bring! cleanup skipped: ' . $cleanupResult['skipped'] . "\n";
|
|
} else {
|
|
echo '[' . date('Y-m-d H:i:s') . '] Bring! cleanup — removed: ' . ($cleanupResult['removed'] ?? 0)
|
|
. '/' . ($cleanupResult['candidates'] ?? 0) . ' candidates'
|
|
. ($cleanupResult['errors'] ? ', errors: ' . $cleanupResult['errors'] : '') . "\n";
|
|
}
|
|
|
|
$addResult = bringAutoAddCritical($db);
|
|
if (isset($addResult['skipped'])) {
|
|
echo '[' . date('Y-m-d H:i:s') . '] Bring! auto-add skipped: ' . $addResult['skipped'] . "\n";
|
|
} else {
|
|
echo '[' . date('Y-m-d H:i:s') . '] Bring! auto-add — added: ' . ($addResult['added'] ?? 0)
|
|
. ', updated specs: ' . ($addResult['updated'] ?? 0) . "\n";
|
|
}
|
|
} catch (Throwable $be) {
|
|
echo '[' . date('Y-m-d H:i:s') . '] Bring! sync warning: ' . $be->getMessage() . "\n";
|
|
}
|
|
|
|
// ── Shelf life pre-warming ────────────────────────────────────────────
|
|
// Pre-warm the opened shelf life cache for opened items not yet cached.
|
|
// Capped at 5 items per cron cycle to avoid Gemini rate limits.
|
|
try {
|
|
$prewarmResult = prewarmShelfLifeCache($db, 5);
|
|
if ($prewarmResult['warmed'] > 0) {
|
|
echo '[' . date('Y-m-d H:i:s') . '] Shelf life pre-warm — warmed: ' . $prewarmResult['warmed']
|
|
. ', skipped: ' . $prewarmResult['skipped'] . "\n";
|
|
}
|
|
} catch (Throwable $pe) {
|
|
echo '[' . date('Y-m-d H:i:s') . '] Shelf life pre-warm warning: ' . $pe->getMessage() . "\n";
|
|
}
|
|
|
|
// ── DB cleanup (retention policy) ────────────────────────────────────
|
|
// Delete old recipes and transactions based on .env retention settings.
|
|
try {
|
|
ob_start();
|
|
dbCleanup($db);
|
|
ob_end_clean();
|
|
echo '[' . date('Y-m-d H:i:s') . '] DB cleanup done'
|
|
. ' (recipes >' . env('RECIPE_RETENTION_DAYS','7') . 'd'
|
|
. ', tx >' . env('TRANSACTION_RETENTION_DAYS','90') . 'd' . ")\n";
|
|
} catch (Throwable $ce) {
|
|
echo '[' . date('Y-m-d H:i:s') . '] DB cleanup warning: ' . $ce->getMessage() . "\n";
|
|
}
|
|
|
|
// ── Daily incremental backup ──────────────────────────────────────────
|
|
// Create a local backup at most once every 23 h; also push to Google Drive
|
|
// if GDRIVE_ENABLED=true. The guard prevents multiple backups per day even
|
|
// though the cron runs every 5 minutes.
|
|
if (env('BACKUP_ENABLED', 'true') === 'true') {
|
|
try {
|
|
$lastBackupTs = 0;
|
|
if (file_exists(BACKUP_LAST_TS_PATH)) {
|
|
$lastData = json_decode(file_get_contents(BACKUP_LAST_TS_PATH), true) ?: [];
|
|
$lastBackupTs = (int)($lastData['ts'] ?? 0);
|
|
}
|
|
if (time() - $lastBackupTs >= 82800) { // 23 h
|
|
$backupResult = createLocalBackup($db);
|
|
if ($backupResult['success']) {
|
|
echo '[' . date('Y-m-d H:i:s') . '] Backup local: ' . $backupResult['filename']
|
|
. ' (' . $backupResult['size_kb'] . 'KB, purged ' . $backupResult['purged'] . " old)\n";
|
|
if (env('GDRIVE_ENABLED', 'false') === 'true') {
|
|
$gResult = backupToGDrive($db);
|
|
if ($gResult['success']) {
|
|
echo '[' . date('Y-m-d H:i:s') . '] Backup GDrive: OK'
|
|
. ' (purged remote: ' . ($gResult['purged_remote'] ?? 0) . ")\n";
|
|
} else {
|
|
echo '[' . date('Y-m-d H:i:s') . '] Backup GDrive warning: ' . ($gResult['error'] ?? 'unknown') . "\n";
|
|
}
|
|
}
|
|
} else {
|
|
echo '[' . date('Y-m-d H:i:s') . '] Backup warning: ' . ($backupResult['error'] ?? 'unknown') . "\n";
|
|
}
|
|
}
|
|
} catch (Throwable $be) {
|
|
echo '[' . date('Y-m-d H:i:s') . '] Backup error: ' . $be->getMessage() . "\n";
|
|
}
|
|
}
|
|
|
|
} catch (Throwable $e) {
|
|
$msg = $e->getMessage();
|
|
echo '[' . date('Y-m-d H:i:s') . '] ERROR: ' . $msg . "\n";
|
|
// Report to GitHub Issues (uses the same _phpErrorReport from index.php)
|
|
_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";
|
|
}
|
|
}
|