diff --git a/.env.example b/.env.example index e15a14e..a44f0a9 100644 --- a/.env.example +++ b/.env.example @@ -129,6 +129,11 @@ GDRIVE_RETENTION_DAYS=30 # Leave empty to allow anyone with access to the server to change settings. SETTINGS_TOKEN= +# INSTANCE_NAME: display name for this EverShelf instance (used by the HA integration +# for Zeroconf discovery label and device name in Home Assistant). +# Defaults to the server hostname if left empty. +INSTANCE_NAME= + # ── Home Assistant Integration ──────────────────────────────────────────────── # All HA settings can also be configured from the Settings → 🏠 tab. # diff --git a/api/cron_smart_shopping.php b/api/cron_smart_shopping.php index 99fb7ec..b2c6ae0 100644 --- a/api/cron_smart_shopping.php +++ b/api/cron_smart_shopping.php @@ -197,3 +197,23 @@ if (env('HA_ENABLED', 'false') === 'true' && env('HA_WEBHOOK_ID', '') !== '') { echo '[' . date('Y-m-d H:i:s') . '] HA expiry hook warning: ' . $haE->getMessage() . "\n"; } } + +// ── Avahi/mDNS discovery registration ───────────────────────────────────────── +// If avahi-daemon is running on this host, register the _evershelf._tcp service +// so that Home Assistant can auto-discover this instance via Zeroconf. +if (function_exists('shell_exec')) { + try { + $avahiService = '/etc/avahi/services/evershelf.xml'; + // Only create/update if avahi-daemon is installed and the file doesn't exist yet + if (!file_exists($avahiService) && (shell_exec('which avahi-daemon 2>/dev/null') || shell_exec('which avahi-publish 2>/dev/null'))) { + $template = __DIR__ . '/../docker/avahi-evershelf.xml'; + if (file_exists($template)) { + $xml = file_get_contents($template); + @file_put_contents($avahiService, $xml); + echo '[' . date('Y-m-d H:i:s') . '] Avahi mDNS service registered at ' . $avahiService . "\n"; + } + } + } catch (Throwable $avahiE) { + // Non-fatal: avahi not available + } +} diff --git a/api/index.php b/api/index.php index 28b971c..560edda 100644 --- a/api/index.php +++ b/api/index.php @@ -931,6 +931,14 @@ try { haInventorySensor(getDB()); break; + case 'ha_info': + haGetInfo(getDB()); + break; + + case 'ha_shopping_items': + haGetShoppingItems(getDB()); + break; + case 'ha_test': haTestConnection(); break; @@ -1349,12 +1357,13 @@ function haInventorySensor(PDO $db): void { header('Content-Type: application/json; charset=utf-8'); header('Access-Control-Allow-Origin: *'); - $sensor = strtolower(trim($_GET['sensor'] ?? 'overview')); + $sensor = strtolower(trim($_GET['sensor'] ?? 'overview')); + $expiryDays = max(1, min(90, (int)($_GET['expiry_days'] ?? env('HA_EXPIRY_DAYS', 3)))); 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')" + AND expiry_date BETWEEN date('now') AND date('now', '+{$expiryDays} days')" )->fetchColumn(); $expired = (int)$db->query( @@ -1469,6 +1478,70 @@ function haTestConnection(): void { } +// ===== HA DISCOVERY INFO ===== + +/** + * Returns device info for HA Zeroconf discovery confirmation. + * GET /api/index.php?action=ha_info + * Response: { name, instance, version, unique_id, has_token, api_version, items_count } + */ +function haGetInfo(PDO $db): void { + header('Content-Type: application/json; charset=utf-8'); + header('Access-Control-Allow-Origin: *'); + // Stable unique_id derived from server identity (survives restarts) + $uniqueId = 'evershelf_' . substr(md5(__DIR__ . php_uname('n')), 0, 12); + $itemsCount = (int)$db->query("SELECT COUNT(*) FROM inventory WHERE quantity > 0")->fetchColumn(); + echo json_encode([ + 'name' => 'EverShelf', + 'instance' => env('INSTANCE_NAME', php_uname('n')), + 'version' => _appVersion(), + 'unique_id' => $uniqueId, + 'has_token' => !empty(env('SETTINGS_TOKEN', '')), + 'api_version' => 1, + 'items_count' => $itemsCount, + ], JSON_UNESCAPED_UNICODE); +} + +/** + * Returns shopping list items in a clean format suitable for HA todo entity. + * GET /api/index.php?action=ha_shopping_items + * Response: { items: [{id, name, note}], count, mode } + */ +function haGetShoppingItems(PDO $db): void { + header('Content-Type: application/json; charset=utf-8'); + header('Access-Control-Allow-Origin: *'); + try { + if (isShoppingBringMode()) { + $auth = bringAuth(); + if (!$auth) { + echo json_encode(['items' => [], 'count' => 0, 'mode' => 'bring']); + return; + } + $listData = bringRequest('GET', "https://api.getbring.com/rest/v2/bringlists/{$auth['bringListUUID']}"); + $items = array_map(fn($r) => [ + 'id' => $r['uuid'] ?? md5(($r['name'] ?? '') . uniqid()), + 'name' => $r['name'] ?? '', + 'note' => $r['specification'] ?? '', + ], $listData['purchase'] ?? []); + echo json_encode(['items' => $items, 'count' => count($items), 'mode' => 'bring'], JSON_UNESCAPED_UNICODE); + } else { + $rows = $db->query( + "SELECT rowid AS id, name, specification AS note FROM shopping_list ORDER BY sort_order ASC, added_at ASC" + )->fetchAll(PDO::FETCH_ASSOC); + $items = array_map(fn($r) => [ + 'id' => (string)$r['id'], + 'name' => $r['name'], + 'note' => $r['note'] ?? '', + ], $rows); + echo json_encode(['items' => $items, 'count' => count($items), 'mode' => 'internal'], JSON_UNESCAPED_UNICODE); + } + } catch (Throwable $e) { + http_response_code(500); + echo json_encode(['error' => $e->getMessage()]); + } +} + + // ===== FOOD FACTS (cached daily) ===== function getFoodFacts(): void { EverLog::info('getFoodFacts'); diff --git a/docker/avahi-evershelf.xml b/docker/avahi-evershelf.xml new file mode 100644 index 0000000..457e4b9 --- /dev/null +++ b/docker/avahi-evershelf.xml @@ -0,0 +1,12 @@ + + + + EverShelf Pantry (%h) + + _evershelf._tcp + 80 + path=/api/ + version=1.0 + app=evershelf + +