feat: add ha_info, ha_shopping_items endpoints and avahi mDNS service file for HACS integration

- api/index.php: new haGetInfo() endpoint (unique_id, version, instance, items count)
- api/index.php: new haGetShoppingItems() endpoint (Bring! + internal shopping list)
- api/index.php: haInventorySensor() now accepts ?expiry_days=N query param
- api/cron_smart_shopping.php: auto-register avahi mDNS service if avahi-daemon present
- docker/avahi-evershelf.xml: Zeroconf _evershelf._tcp service declaration
- .env.example: add INSTANCE_NAME variable (used by HA integration for device label)
This commit is contained in:
dadaloop82
2026-05-23 13:23:28 +00:00
parent 965a672abe
commit b2e0f6d683
4 changed files with 112 additions and 2 deletions
+5
View File
@@ -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.
#
+20
View File
@@ -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
}
}
+75 -2
View File
@@ -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');
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" standalone='no'?>
<!DOCTYPE service-group SYSTEM "avahi-service.dtd">
<service-group>
<name replace-wildcards="yes">EverShelf Pantry (%h)</name>
<service>
<type>_evershelf._tcp</type>
<port>80</port>
<txt-record>path=/api/</txt-record>
<txt-record>version=1.0</txt-record>
<txt-record>app=evershelf</txt-record>
</service>
</service-group>