Skip to content

Commit 22106a9

Browse files
author
gfer
committed
Кормушка: последняя выдача, кнопка Пожертвовать, DONATE_SETUP
- feed_service: сохранение last_dispense_at при успешной выдаче (MQTT/ESPHome) - API /feed/info: last_dispense_at, donate_url - FeedCard: отображение времени выдачи, кнопка Пожертвовать (всегда как заглушка) - general.donate_url в конфиге и настройках - docs/DONATE_SETUP.md — подсказки по Boosty, DonationAlerts, Ko-fi - docs/FEED_LAST_DISPENSE.md — время последней выдачи Made-with: Cursor
1 parent 124f707 commit 22106a9

13 files changed

Lines changed: 249 additions & 4 deletions

File tree

app/app_config/default_config.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ general:
88
contributor_password: ""
99
# Ссылка на установку BirdNET (BirdNET-Pi, BirdNET-Go). Пусто — иконка BirdNET не показывается.
1010
birdnet_url: ""
11+
# Ссылка на страницу пожертвований (Ko-fi, Buy Me a Coffee и т.д.). Пусто — кнопка не показывается.
12+
donate_url: ""
1113

1214
# Video/Audio processor settings
1315
processor:

app/ui/locales/en.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,8 @@
113113
"mqttHaDiscoveryHint": "Publish configs so HA auto-discovers Last Species, Bird at Feeder, etc.",
114114
"birdnetUrl": "BirdNET installation URL",
115115
"birdnetUrlHint": "URL to BirdNET-Pi/Go. Empty — icon hidden.",
116+
"donateUrl": "Donation page URL",
117+
"donateUrlHint": "Boosty, DonationAlerts (RU). Ko-fi, Buy Me a Coffee. Button always visible as placeholder.",
116118
"go2rtcUrl": "Go2RTC URL",
117119
"go2rtcUrlHint": "Host: http://IP:1984",
118120
"go2rtcUser": "Go2RTC login",
@@ -425,7 +427,10 @@
425427
"feederControl": "Feeder Control",
426428
"dispenseFeed": "Dispense Feed",
427429
"dispensing": "Dispensing...",
428-
"feedDispensed": "Feed dispensed"
430+
"feedDispensed": "Feed dispensed",
431+
"lastDispense": "Last dispense",
432+
"donate": "Donate",
433+
"donatePlaceholder": "Donate (set URL in Settings)"
429434
},
430435
"speciesSummary": {
431436
"totalDetectionStats": "Total Detection Stats",

app/ui/locales/ru.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,8 @@
113113
"mqttHaDiscoveryHint": "Публиковать config для автообнаружения HA (Last Species, Bird at Feeder и др.)",
114114
"birdnetUrl": "Ссылка на BirdNET",
115115
"birdnetUrlHint": "URL установки BirdNET-Pi/Go. Пусто — иконка не показывается.",
116+
"donateUrl": "Ссылка на пожертвования",
117+
"donateUrlHint": "Boosty, DonationAlerts, DONAT24 (РФ). Ko-fi, Buy Me a Coffee — за рубежом. Кнопка всегда видна как заглушка.",
116118
"go2rtcUrl": "Go2RTC URL",
117119
"go2rtcUrlHint": "Хост: http://IP:1984",
118120
"go2rtcUser": "Go2RTC логин",
@@ -425,7 +427,10 @@
425427
"feederControl": "Управление кормушкой",
426428
"dispenseFeed": "Выдать корм",
427429
"dispensing": "Выдача...",
428-
"feedDispensed": "Корм выдан"
430+
"feedDispensed": "Корм выдан",
431+
"lastDispense": "Последняя выдача",
432+
"donate": "Пожертвовать",
433+
"donatePlaceholder": "Пожертвовать (укажите ссылку в Настройках)"
429434
},
430435
"speciesSummary": {
431436
"totalDetectionStats": "Статистика обнаружений",

app/ui/src/api/api.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,14 @@ export const fetchStatus = async (): Promise<{
259259
return response.data;
260260
};
261261

262+
export const fetchFeedInfo = async (): Promise<{
263+
last_dispense_at: string | null;
264+
donate_url: string | null;
265+
}> => {
266+
const response = await axios.get(`${BASE_API_URL}/feed/info`);
267+
return response.data;
268+
};
269+
262270
export const dispenseFeed = async (): Promise<{ success: boolean; message?: string }> => {
263271
try {
264272
const response = await axios.post(`${BASE_API_URL}/feed/dispense`, {}, {

app/ui/src/components/FeedCard.tsx

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,43 @@ import Paper from '@mui/material/Paper';
33
import Stack from '@mui/material/Stack';
44
import Typography from '@mui/material/Typography';
55
import RestaurantIcon from '@mui/icons-material/Restaurant';
6+
import FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder';
67
import { useState } from 'react';
78
import { useTranslation } from 'react-i18next';
89
import { Link } from 'react-router-dom';
10+
import { useQuery, useQueryClient } from '@tanstack/react-query';
11+
import { dispenseFeed, fetchFeedInfo } from '../api/api';
912
import { useProtectedArea } from '../contexts/ProtectedAreaContext';
10-
import { dispenseFeed } from '../api/api';
13+
14+
function formatLastDispense(iso: string | null): string | null {
15+
if (!iso) return null;
16+
try {
17+
const d = new Date(iso);
18+
if (Number.isNaN(d.getTime())) return null;
19+
return d.toLocaleString(undefined, {
20+
day: 'numeric',
21+
month: 'short',
22+
hour: '2-digit',
23+
minute: '2-digit',
24+
});
25+
} catch {
26+
return null;
27+
}
28+
}
1129

1230
export const FeedCard = () => {
1331
const { t } = useTranslation();
32+
const queryClient = useQueryClient();
1433
const { isAdmin } = useProtectedArea();
1534
const [loading, setLoading] = useState(false);
1635
const [message, setMessage] = useState<{ text: string; success: boolean } | null>(null);
1736

37+
const { data: feedInfo } = useQuery({
38+
queryKey: ['feed-info'],
39+
queryFn: fetchFeedInfo,
40+
staleTime: 1000 * 30, // 30 sec
41+
});
42+
1843
const handleDispense = async () => {
1944
if (!isAdmin) return;
2045
setLoading(true);
@@ -25,8 +50,14 @@ export const FeedCard = () => {
2550
text: result.success ? t('feed.feedDispensed') : result.message || t('common.error'),
2651
success: result.success,
2752
});
53+
if (result.success) {
54+
queryClient.invalidateQueries({ queryKey: ['feed-info'] });
55+
}
2856
};
2957

58+
const lastDispenseStr = formatLastDispense(feedInfo?.last_dispense_at ?? null);
59+
const donateUrl = feedInfo?.donate_url;
60+
3061
return (
3162
<Paper sx={{ padding: 2, height: '100%' }}>
3263
<Stack spacing={2}>
@@ -48,11 +79,28 @@ export const FeedCard = () => {
4879
>
4980
{loading ? t('feed.dispensing') : t('feed.dispenseFeed')}
5081
</Button>
82+
{lastDispenseStr && (
83+
<Typography variant="caption" color="text.secondary">
84+
{t('feed.lastDispense')}: {lastDispenseStr}
85+
</Typography>
86+
)}
5187
{message && (
5288
<Typography variant="body2" color={message.success ? 'success.main' : 'error.main'}>
5389
{message.text}
5490
</Typography>
5591
)}
92+
<Button
93+
size="small"
94+
variant="outlined"
95+
startIcon={<FavoriteBorderIcon />}
96+
href={donateUrl || undefined}
97+
target={donateUrl ? '_blank' : undefined}
98+
rel={donateUrl ? 'noopener noreferrer' : undefined}
99+
disabled={!donateUrl}
100+
title={!donateUrl ? t('feed.donatePlaceholder') : undefined}
101+
>
102+
{donateUrl ? t('feed.donate') : t('feed.donatePlaceholder')}
103+
</Button>
56104
</Stack>
57105
</Paper>
58106
);

app/ui/src/pages/Settings/SettingsForm.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,20 @@ export const SettingsForm = ({
358358
)}
359359
</form.Field>
360360
</Grid>
361+
<Grid size={{ xs: 12, sm: 6 }}>
362+
<form.Field name="general.donate_url">
363+
{(field) => (
364+
<TextField
365+
fullWidth
366+
value={field.state.value ?? ''}
367+
onChange={(e) => field.handleChange(e.target.value)}
368+
label={t('settings.donateUrl')}
369+
placeholder="https://ko-fi.com/..."
370+
helperText={t('settings.donateUrlHint')}
371+
/>
372+
)}
373+
</form.Field>
374+
</Grid>
361375
<Grid size={{ xs: 12 }}>
362376
<form.Field name="video.go2rtc_url">
363377
{(field) => (

app/ui/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ export interface Settings {
100100
notification_excluded_species: string[];
101101
settings_password?: string;
102102
birdnet_url?: string; // URL to BirdNET installation; empty = no icon in UI
103+
donate_url?: string; // Donation page (Ko-fi, etc.); empty = no button
103104
};
104105
processor: {
105106
tracker: string; // Path to tracker config, e.g., "bytetrack.yaml"

app/web/routes/ui_routes.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
)
2626
from app_config.app_config import app_config
2727
from app_config.cameras import get_valid_cameras, cameras_for_api
28-
from services.feed_service import dispense_feed, check_mqtt_connected, check_esphome_reachable
28+
from services.feed_service import dispense_feed, get_last_dispense, check_mqtt_connected, check_esphome_reachable
2929
from services.visit_processor import VisitProcessor
3030
from services.report_service import get_monthly_report_data, build_monthly_report
3131
from services.xeno_canto_service import fetch_recordings, _search_term_from_species_name
@@ -179,6 +179,17 @@ def status_debug():
179179
'api_url_base_configured': bool(os.environ.get('API_URL_BASE', '').strip()),
180180
}
181181

182+
@app.route('/api/ui/feed/info', methods=['GET'])
183+
def feed_info():
184+
"""Last dispense time and donate URL. No auth required."""
185+
from app_config.app_config import app_config
186+
app_config.reload()
187+
donate_url = (app_config.get('general.donate_url') or '').strip()
188+
return {
189+
'last_dispense_at': get_last_dispense(),
190+
'donate_url': donate_url or None,
191+
}, 200
192+
182193
@app.route('/api/ui/feed/dispense', methods=['POST'])
183194
def feed_dispense():
184195
if not settings_check_access():

app/web/services/feed_service.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
"""Feed control service: MQTT or ESPHome for feeder dispense."""
2+
import json
23
import logging
34
import os
5+
from datetime import datetime, timezone
6+
from pathlib import Path
47
from urllib.parse import quote
58

69
import paho.mqtt.client as mqtt
@@ -10,6 +13,39 @@
1013

1114
logger = logging.getLogger(__name__)
1215

16+
_FEED_LAST_FILE = 'feed_last_dispense.json'
17+
18+
19+
def _feed_data_path():
20+
"""Path to feed state file in DATA_DIR."""
21+
data_dir = os.environ.get('DATA_DIR') or os.path.join(
22+
os.path.dirname(os.path.dirname(__file__)), '..', 'data'
23+
)
24+
return Path(data_dir) / _FEED_LAST_FILE
25+
26+
27+
def _save_last_dispense():
28+
"""Save current UTC timestamp when feed was dispensed."""
29+
try:
30+
path = _feed_data_path()
31+
path.parent.mkdir(parents=True, exist_ok=True)
32+
now = datetime.now(timezone.utc)
33+
path.write_text(json.dumps({'last_dispense_at': now.isoformat()}), encoding='utf-8')
34+
except OSError as e:
35+
logger.warning('Could not save last dispense time: %s', e)
36+
37+
38+
def get_last_dispense():
39+
"""Return last dispense ISO timestamp or None."""
40+
try:
41+
path = _feed_data_path()
42+
if not path.exists():
43+
return None
44+
data = json.loads(path.read_text(encoding='utf-8'))
45+
return data.get('last_dispense_at')
46+
except (OSError, json.JSONDecodeError, KeyError):
47+
return None
48+
1349
_mqtt_client = None
1450

1551

@@ -100,6 +136,7 @@ def dispense_feed():
100136
time.sleep(duration)
101137
client.publish(topic, 'OFF', qos=1)
102138
logger.info('Feed dispensed via MQTT (relay %ds)', duration)
139+
_save_last_dispense()
103140
return True, 'Feed dispensed'
104141
except Exception as e:
105142
logger.error('MQTT feed failed: %s', e)
@@ -131,6 +168,7 @@ def dispense_feed():
131168
f"{url.rstrip('/')}/switch/{entity_path}/turn_off", timeout=5
132169
)
133170
logger.info('Feed dispensed via ESPHome (switch %ds)', duration)
171+
_save_last_dispense()
134172
return True, 'Feed dispensed'
135173
except Exception as e:
136174
logger.error('ESPHome feed failed: %s', e)

docs/CONFIGURATION.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@
3434
| `enable_notifications` | Включить уведомления (глобально) |
3535
| `settings_password` | Пароль для доступа к настройкам. Пусто — без пароля |
3636
| `notification_excluded_species` | Виды, исключённые из уведомлений |
37+
| `donate_url` | Ссылка на страницу пожертвований. Пусто — кнопка-заглушка. |
38+
39+
**Платформы для РФ:** [Boosty](https://boosty.to), [DonationAlerts](https://donationalerts.com), [DONAT24](https://donat24.ru). За рубежом: Ko-fi, Buy Me a Coffee, GitHub Sponsors.
3740

3841
---
3942

0 commit comments

Comments
 (0)