Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e6496a821f | |||
| c29498b3fa | |||
| d586be0fac |
@@ -184,65 +184,6 @@ switch ($action) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
case 'frame_config': {
|
|
||||||
$cfg_path = __DIR__ . '/frame-config.json';
|
|
||||||
$defaults = [
|
|
||||||
'mode' => 'today',
|
|
||||||
'hours' => 24,
|
|
||||||
'start_time' => '06:00',
|
|
||||||
'hold_overnight' => true,
|
|
||||||
'species_source' => '',
|
|
||||||
'zip' => '',
|
|
||||||
'bw_days' => 7,
|
|
||||||
'bw_country' => 'us',
|
|
||||||
'image_url' => '',
|
|
||||||
'shoot' => true,
|
|
||||||
'shoot_title' => 'avian visitors',
|
|
||||||
'shoot_subtitle' => 'Heard Today',
|
|
||||||
'shoot_headline_px' => 42,
|
|
||||||
'shoot_eyebrow_px' => 18,
|
|
||||||
'shoot_lowercase' => false,
|
|
||||||
'shoot_mat' => 0.04,
|
|
||||||
'shoot_small_floor' => 0.04,
|
|
||||||
'shoot_count_exp' => 0.65,
|
|
||||||
'mat' => 0.0,
|
|
||||||
'mat_opening_w' => 0,
|
|
||||||
'mat_opening_h' => 0,
|
|
||||||
'rotate' => 90,
|
|
||||||
'saturation' => 0.6,
|
|
||||||
'panel' => '',
|
|
||||||
'quiet_start' => 22,
|
|
||||||
'quiet_end' => 6,
|
|
||||||
'heal_hours' => 24,
|
|
||||||
'timeout' => 45,
|
|
||||||
'frame_host' => '',
|
|
||||||
'basic_user' => '',
|
|
||||||
'basic_pass' => '',
|
|
||||||
];
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|
||||||
$input = json_decode(file_get_contents('php://input'), true);
|
|
||||||
if (!is_array($input)) {
|
|
||||||
http_response_code(400);
|
|
||||||
echo json_encode(['error' => 'invalid JSON']);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
$current = file_exists($cfg_path) ? json_decode(file_get_contents($cfg_path), true) : $defaults;
|
|
||||||
if (!is_array($current)) $current = $defaults;
|
|
||||||
$allowed = array_keys($defaults);
|
|
||||||
foreach ($input as $k => $v) {
|
|
||||||
if (in_array($k, $allowed, true)) $current[$k] = $v;
|
|
||||||
}
|
|
||||||
file_put_contents($cfg_path, json_encode($current, JSON_PRETTY_PRINT) . "\n");
|
|
||||||
echo json_encode($current);
|
|
||||||
} else {
|
|
||||||
$current = file_exists($cfg_path) ? json_decode(file_get_contents($cfg_path), true) : $defaults;
|
|
||||||
if (!is_array($current)) $current = $defaults;
|
|
||||||
echo json_encode(array_merge($defaults, $current));
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
http_response_code(404);
|
http_response_code(404);
|
||||||
echo json_encode(['error' => 'unknown action']);
|
echo json_encode(['error' => 'unknown action']);
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ if (getenv('AV_REQUIRE_AUTH') === '1' && empty($_SERVER['HTTP_AUTHORIZATION']))
|
|||||||
echo json_encode([
|
echo json_encode([
|
||||||
'items' => [
|
'items' => [
|
||||||
['label' => 'settings', 'href' => '/#admin=settings', 'native' => true],
|
['label' => 'settings', 'href' => '/#admin=settings', 'native' => true],
|
||||||
['label' => 'frame', 'href' => '/#admin=frame', 'native' => true],
|
|
||||||
['label' => 'system', 'href' => '/#admin=system', 'native' => true],
|
['label' => 'system', 'href' => '/#admin=system', 'native' => true],
|
||||||
['label' => 'logs', 'href' => '/#admin=logs', 'native' => true],
|
['label' => 'logs', 'href' => '/#admin=logs', 'native' => true],
|
||||||
['label' => 'tools', 'href' => '/#admin=tools', 'native' => true],
|
['label' => 'tools', 'href' => '/#admin=tools', 'native' => true],
|
||||||
|
|||||||
+9
-242
@@ -160,29 +160,15 @@
|
|||||||
applySiteTitle(siteTitle);
|
applySiteTitle(siteTitle);
|
||||||
|
|
||||||
var winBtns = [].slice.call(winPick.querySelectorAll('button'));
|
var winBtns = [].slice.call(winPick.querySelectorAll('button'));
|
||||||
var START_HOUR = +(readLS('bird:todayStart', '6')) || 6;
|
var currentHours = +readLS('bird:window', '24') || 24;
|
||||||
var windowMode = readLS('bird:window', '24');
|
|
||||||
var currentHours = windowMode === 'today' ? todayHours() : (+windowMode || 24);
|
|
||||||
function todayHours() {
|
|
||||||
var now = new Date();
|
|
||||||
var start = new Date(now); start.setHours(START_HOUR, 0, 0, 0);
|
|
||||||
if (now < start) start.setDate(start.getDate() - 1);
|
|
||||||
return Math.max(1, (now - start) / 3600000);
|
|
||||||
}
|
|
||||||
function effectiveHours() {
|
|
||||||
if (windowMode === 'today') { currentHours = todayHours(); }
|
|
||||||
return currentHours;
|
|
||||||
}
|
|
||||||
winBtns.forEach(function (b) {
|
winBtns.forEach(function (b) {
|
||||||
var match = windowMode === 'today' ? b.dataset.h === 'today' : +b.dataset.h === currentHours;
|
b.setAttribute('aria-current', (+b.dataset.h === currentHours) ? 'true' : 'false');
|
||||||
b.setAttribute('aria-current', match ? 'true' : 'false');
|
|
||||||
});
|
});
|
||||||
winBtns.forEach(function (b) {
|
winBtns.forEach(function (b) {
|
||||||
b.addEventListener('click', function () {
|
b.addEventListener('click', function () {
|
||||||
winBtns.forEach(function (x) { x.setAttribute('aria-current', x === b ? 'true' : 'false'); });
|
winBtns.forEach(function (x) { x.setAttribute('aria-current', x === b ? 'true' : 'false'); });
|
||||||
windowMode = b.dataset.h;
|
currentHours = +b.dataset.h;
|
||||||
currentHours = windowMode === 'today' ? todayHours() : +windowMode;
|
writeLS('bird:window', String(currentHours));
|
||||||
writeLS('bird:window', windowMode);
|
|
||||||
syncPill(winPick);
|
syncPill(winPick);
|
||||||
// Actual data refresh is wired below via refreshRecent().
|
// Actual data refresh is wired below via refreshRecent().
|
||||||
});
|
});
|
||||||
@@ -864,10 +850,9 @@
|
|||||||
// a bare "window" with the span it actually covers. Thresholds match
|
// a bare "window" with the span it actually covers. Thresholds match
|
||||||
// the winPick buttons (1H / 12H / 24H / 7D / ALL).
|
// the winPick buttons (1H / 12H / 24H / 7D / ALL).
|
||||||
function windowLabel(h) {
|
function windowLabel(h) {
|
||||||
if (windowMode === 'today') return 'today';
|
|
||||||
if (h <= 1) return 'this hour';
|
if (h <= 1) return 'this hour';
|
||||||
if (h <= 12) return 'past 12h';
|
if (h <= 12) return 'past 12h';
|
||||||
if (h <= 24) return 'past 24h';
|
if (h <= 24) return 'today';
|
||||||
if (h <= 168) return 'this week';
|
if (h <= 168) return 'this week';
|
||||||
return 'all time';
|
return 'all time';
|
||||||
}
|
}
|
||||||
@@ -1396,8 +1381,8 @@
|
|||||||
// changes the picker again before it resolves - or a slower poll
|
// changes the picker again before it resolves - or a slower poll
|
||||||
// lands later - we discard the stale response so the collage
|
// lands later - we discard the stale response so the collage
|
||||||
// never reverts to a different window.
|
// never reverts to a different window.
|
||||||
var forHours = effectiveHours();
|
var forHours = currentHours;
|
||||||
return fetchJson('./avian/api/birdnet-api.php?action=recent&hours=' + Math.round(forHours))
|
return fetchJson('./avian/api/birdnet-api.php?action=recent&hours=' + forHours)
|
||||||
.then(function (j) {
|
.then(function (j) {
|
||||||
if (forHours !== currentHours) return; // window changed mid-flight
|
if (forHours !== currentHours) return; // window changed mid-flight
|
||||||
DATA.recent = j; renderWindowDependent(animate);
|
DATA.recent = j; renderWindowDependent(animate);
|
||||||
@@ -1405,13 +1390,13 @@
|
|||||||
.catch(function (e) { console.warn('recent fetch failed', e); });
|
.catch(function (e) { console.warn('recent fetch failed', e); });
|
||||||
}
|
}
|
||||||
function refreshAll(animate) {
|
function refreshAll(animate) {
|
||||||
var forHours = effectiveHours();
|
var forHours = currentHours;
|
||||||
return Promise.all([
|
return Promise.all([
|
||||||
fetchJson('./avian/api/birdnet-api.php?action=stats').catch(function () { return null; }),
|
fetchJson('./avian/api/birdnet-api.php?action=stats').catch(function () { return null; }),
|
||||||
fetchJson('./avian/api/birdnet-api.php?action=lifelist').catch(function () { return null; }),
|
fetchJson('./avian/api/birdnet-api.php?action=lifelist').catch(function () { return null; }),
|
||||||
fetchJson('./avian/api/birdnet-api.php?action=timeseries&days=30').catch(function () { return null; }),
|
fetchJson('./avian/api/birdnet-api.php?action=timeseries&days=30').catch(function () { return null; }),
|
||||||
fetchJson('./avian/api/birdnet-api.php?action=firstseen&limit=10').catch(function () { return null; }),
|
fetchJson('./avian/api/birdnet-api.php?action=firstseen&limit=10').catch(function () { return null; }),
|
||||||
fetchJson('./avian/api/birdnet-api.php?action=recent&hours=' + Math.round(forHours)).catch(function () { return null; }),
|
fetchJson('./avian/api/birdnet-api.php?action=recent&hours=' + forHours).catch(function () { return null; }),
|
||||||
]).then(function (parts) {
|
]).then(function (parts) {
|
||||||
DATA.stats = parts[0];
|
DATA.stats = parts[0];
|
||||||
DATA.lifelist = parts[1];
|
DATA.lifelist = parts[1];
|
||||||
@@ -1832,39 +1817,8 @@
|
|||||||
+ ' <input type="text" class="title-input" data-title-input maxlength="40" value="' + adminEsc(siteTitle) + '" placeholder="your birds">'
|
+ ' <input type="text" class="title-input" data-title-input maxlength="40" value="' + adminEsc(siteTitle) + '" placeholder="your birds">'
|
||||||
+ '</div>';
|
+ '</div>';
|
||||||
}
|
}
|
||||||
function todayStartRow() {
|
|
||||||
var h = START_HOUR;
|
|
||||||
var label = (h === 0 ? '12' : h > 12 ? '' + (h - 12) : '' + h) + ':00 ' + (h < 12 ? 'AM' : 'PM');
|
|
||||||
return ''
|
|
||||||
+ '<div class="slider-row">'
|
|
||||||
+ ' <div class="head">'
|
|
||||||
+ ' <div class="label-block">'
|
|
||||||
+ ' <span class="label">Today starts at</span>'
|
|
||||||
+ ' <span class="hint">when the TODAY window resets</span>'
|
|
||||||
+ ' </div>'
|
|
||||||
+ ' <span class="value" data-value-for="todayStart">' + label + '</span>'
|
|
||||||
+ ' </div>'
|
|
||||||
+ ' <div class="slider-track">'
|
|
||||||
+ ' <input type="range" min="0" max="12" step="1" value="' + h + '" data-today-start>'
|
|
||||||
+ ' </div>'
|
|
||||||
+ '</div>';
|
|
||||||
}
|
|
||||||
function wireSettingsControls(scope) {
|
function wireSettingsControls(scope) {
|
||||||
scope = scope || document;
|
scope = scope || document;
|
||||||
var todaySlider = scope.querySelector('[data-today-start]');
|
|
||||||
if (todaySlider) {
|
|
||||||
todaySlider.addEventListener('input', function () {
|
|
||||||
var h = +todaySlider.value;
|
|
||||||
var label = (h === 0 ? '12' : h > 12 ? '' + (h - 12) : '' + h) + ':00 ' + (h < 12 ? 'AM' : 'PM');
|
|
||||||
var valEl = scope.querySelector('[data-value-for="todayStart"]');
|
|
||||||
if (valEl) valEl.textContent = label;
|
|
||||||
});
|
|
||||||
todaySlider.addEventListener('change', function () {
|
|
||||||
START_HOUR = +todaySlider.value;
|
|
||||||
writeLS('bird:todayStart', String(START_HOUR));
|
|
||||||
if (windowMode === 'today') { currentHours = todayHours(); refreshRecent(true); }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
scope.querySelectorAll('.switch').forEach(function (sw) {
|
scope.querySelectorAll('.switch').forEach(function (sw) {
|
||||||
sw.addEventListener('click', function () {
|
sw.addEventListener('click', function () {
|
||||||
var on = sw.getAttribute('aria-checked') !== 'true';
|
var on = sw.getAttribute('aria-checked') !== 'true';
|
||||||
@@ -2367,7 +2321,6 @@
|
|||||||
var adminSect = null;
|
var adminSect = null;
|
||||||
var ADMIN_TITLES = {
|
var ADMIN_TITLES = {
|
||||||
settings: 'Settings',
|
settings: 'Settings',
|
||||||
frame: 'Frame',
|
|
||||||
system: 'System',
|
system: 'System',
|
||||||
logs: 'Logs',
|
logs: 'Logs',
|
||||||
tools: 'Tools',
|
tools: 'Tools',
|
||||||
@@ -2403,7 +2356,6 @@
|
|||||||
if (adminPollT) { clearInterval(adminPollT); adminPollT = null; }
|
if (adminPollT) { clearInterval(adminPollT); adminPollT = null; }
|
||||||
adminSect = section;
|
adminSect = section;
|
||||||
if (section === 'settings') renderAdminSettings();
|
if (section === 'settings') renderAdminSettings();
|
||||||
else if (section === 'frame') renderAdminFrame();
|
|
||||||
else if (section === 'system') renderAdminSystem();
|
else if (section === 'system') renderAdminSystem();
|
||||||
else if (section === 'logs') renderAdminLogs();
|
else if (section === 'logs') renderAdminLogs();
|
||||||
else if (section === 'tools') renderAdminTools();
|
else if (section === 'tools') renderAdminTools();
|
||||||
@@ -2426,190 +2378,6 @@
|
|||||||
return '<div class="admin-unreachable">Pi unreachable - ' + adminEsc(reason || 'no data') + '</div>';
|
return '<div class="admin-unreachable">Pi unreachable - ' + adminEsc(reason || 'no data') + '</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ---- Frame config admin panel ----
|
|
||||||
function renderAdminFrame() {
|
|
||||||
adminBody.innerHTML = '<p style="font:11px ui-monospace,monospace;color:var(--ink-soft);text-align:center">loading frame config...</p>';
|
|
||||||
fetch('./avian/api/birdnet-api.php?action=frame_config', { credentials: 'same-origin', cache: 'no-store' })
|
|
||||||
.then(function (r) { return r.ok ? r.json() : Promise.reject(r.status); })
|
|
||||||
.then(function (fc) {
|
|
||||||
adminBody.innerHTML =
|
|
||||||
'<div class="admin-settings">'
|
|
||||||
+ '<div class="menu-row" style="justify-content:center;padding:12px 0">'
|
|
||||||
+ ' <button type="button" id="frameRefreshBtn" class="frame-refresh-btn">Refresh Now</button>'
|
|
||||||
+ '</div>'
|
|
||||||
+ '<div class="frame-section-label">Display</div>'
|
|
||||||
+ frameSegmented('mode', 'Mode', 'how the time window works', fc.mode, [
|
|
||||||
{ v: 'today', label: 'today' },
|
|
||||||
{ v: 'window', label: 'window' },
|
|
||||||
])
|
|
||||||
+ frameSlider('hours', 'Window hours', 'rolling window size (window mode)', fc.hours, 1, 168, 1, 0)
|
|
||||||
+ frameInput('start_time', 'Start time', 'daily reset time (today mode)', fc.start_time)
|
|
||||||
+ frameToggle('hold_overnight', 'Hold overnight', 'keep old collage until first bird', fc.hold_overnight)
|
|
||||||
+ frameSlider('quiet_start', 'Quiet start', 'hour to stop refreshing', fc.quiet_start, 0, 23, 1, 0)
|
|
||||||
+ frameSlider('quiet_end', 'Quiet end', 'hour to resume refreshing', fc.quiet_end, 0, 23, 1, 0)
|
|
||||||
+ frameSlider('heal_hours', 'Heal hours', 'force refresh even if unchanged', fc.heal_hours, 1, 48, 1, 0)
|
|
||||||
+ '<div class="frame-section-label">Titles</div>'
|
|
||||||
+ frameInput('shoot_title', 'Title', 'shown above the collage', fc.shoot_title || '')
|
|
||||||
+ frameInput('shoot_subtitle', 'Subtitle', 'shown below the title', fc.shoot_subtitle || '')
|
|
||||||
+ frameToggle('shoot_lowercase', 'Lowercase', 'lowercase the title text', fc.shoot_lowercase)
|
|
||||||
+ '<div class="frame-section-label">Collage</div>'
|
|
||||||
+ frameSlider('shoot_count_exp', 'Size exponent', 'how much detection count affects bird size', fc.shoot_count_exp, 0.3, 1.0, 0.05, 2)
|
|
||||||
+ frameSlider('shoot_small_floor', 'Small floor', 'minimum size for rare birds', fc.shoot_small_floor, 0.01, 0.15, 0.01, 2)
|
|
||||||
+ frameSlider('shoot_mat', 'Collage padding', 'padding around the collage area', fc.shoot_mat, 0, 0.1, 0.01, 2)
|
|
||||||
+ frameSlider('shoot_headline_px', 'Headline size', 'title font size in px', fc.shoot_headline_px, 20, 80, 1, 0)
|
|
||||||
+ frameSlider('shoot_eyebrow_px', 'Eyebrow size', 'subtitle font size in px', fc.shoot_eyebrow_px, 10, 40, 1, 0)
|
|
||||||
+ '<div class="frame-section-label">Hardware</div>'
|
|
||||||
+ frameSegmented('rotate', 'Rotation', 'orientation of the frame', fc.rotate, [
|
|
||||||
{ v: 90, label: '90' },
|
|
||||||
{ v: 270, label: '270' },
|
|
||||||
])
|
|
||||||
+ frameSlider('saturation', 'Saturation', 'color intensity for e-ink', fc.saturation, 0.0, 1.0, 0.05, 2)
|
|
||||||
+ frameSlider('mat_opening_w', 'Mat width', 'matte opening width (inches, 0=default)', fc.mat_opening_w, 0, 12, 0.5, 1)
|
|
||||||
+ frameSlider('mat_opening_h', 'Mat height', 'matte opening height (inches, 0=default)', fc.mat_opening_h, 0, 14, 0.5, 1)
|
|
||||||
+ frameSlider('mat', 'Extra mat', 'global content shrink inside opening', fc.mat, 0, 0.1, 0.01, 2)
|
|
||||||
+ frameInput('frame_host', 'Frame address', 'IP or hostname of the frame Pi', fc.frame_host || '')
|
|
||||||
+ frameInput('panel', 'Panel driver', 'force driver (blank=auto)', fc.panel || '')
|
|
||||||
+ frameSlider('timeout', 'Timeout', 'seconds to wait for page render', fc.timeout, 10, 120, 5, 0)
|
|
||||||
+ '<div class="frame-section-label">Source</div>'
|
|
||||||
+ frameSegmented('species_source', 'Source', 'where bird data comes from', fc.species_source, [
|
|
||||||
{ v: '', label: 'local' },
|
|
||||||
{ v: 'birdweather', label: 'BirdWeather' },
|
|
||||||
])
|
|
||||||
+ frameInput('zip', 'ZIP code', 'for BirdWeather mode', fc.zip || '')
|
|
||||||
+ frameSlider('bw_days', 'BW lookback', 'BirdWeather days to look back', fc.bw_days, 1, 30, 1, 0)
|
|
||||||
+ frameInput('bw_country', 'BW country', 'geocoder country code', fc.bw_country || 'us')
|
|
||||||
+ frameInput('image_url', 'Image URL', 'direct image URL (overrides shoot)', fc.image_url || '')
|
|
||||||
+ frameToggle('shoot', 'Screenshot mode', 'capture collage via headless browser', fc.shoot)
|
|
||||||
+ '<div class="frame-section-label">Auth</div>'
|
|
||||||
+ frameInput('basic_user', 'Username', 'basic auth username', fc.basic_user || '')
|
|
||||||
+ frameInput('basic_pass', 'Password', 'basic auth password', fc.basic_pass || '', true)
|
|
||||||
+ '</div>';
|
|
||||||
wireFrameControls(fc);
|
|
||||||
})
|
|
||||||
.catch(function (err) {
|
|
||||||
adminBody.innerHTML =
|
|
||||||
'<div class="menu-row"><span class="label">Failed to load frame config <small class="hint">' + err + '</small></span></div>';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function frameToggle(key, label, hint, on) {
|
|
||||||
return ''
|
|
||||||
+ '<div class="menu-row">'
|
|
||||||
+ ' <div><span class="label">' + label + '</span>'
|
|
||||||
+ (hint ? '<span class="hint">' + hint + '</span>' : '')
|
|
||||||
+ ' </div>'
|
|
||||||
+ ' <button type="button" class="switch" role="switch" aria-checked="' + (on ? 'true' : 'false') + '" data-frame-key="' + key + '"></button>'
|
|
||||||
+ '</div>';
|
|
||||||
}
|
|
||||||
function frameSlider(key, label, hint, val, min, max, step, digits) {
|
|
||||||
return ''
|
|
||||||
+ '<div class="slider-row">'
|
|
||||||
+ ' <div class="head">'
|
|
||||||
+ ' <div class="label-block">'
|
|
||||||
+ ' <span class="label">' + label + '</span>'
|
|
||||||
+ (hint ? '<span class="hint">' + hint + '</span>' : '')
|
|
||||||
+ ' </div>'
|
|
||||||
+ ' <span class="value" data-frame-value="' + key + '">' + (+val).toFixed(digits) + '</span>'
|
|
||||||
+ ' </div>'
|
|
||||||
+ ' <div class="slider-track">'
|
|
||||||
+ ' <input type="range" min="' + min + '" max="' + max + '" step="' + step + '" value="' + val + '" data-frame-key="' + key + '" data-digits="' + digits + '">'
|
|
||||||
+ ' </div>'
|
|
||||||
+ '</div>';
|
|
||||||
}
|
|
||||||
function frameSegmented(key, label, hint, val, opts) {
|
|
||||||
var btns = opts.map(function (o) {
|
|
||||||
var match = String(o.v) === String(val);
|
|
||||||
return '<button type="button" data-v="' + o.v + '" aria-current="' + (match ? 'true' : 'false') + '">' + o.label + '</button>';
|
|
||||||
}).join('');
|
|
||||||
return ''
|
|
||||||
+ '<div class="menu-row">'
|
|
||||||
+ ' <div><span class="label">' + label + '</span>'
|
|
||||||
+ (hint ? '<span class="hint">' + hint + '</span>' : '')
|
|
||||||
+ ' </div>'
|
|
||||||
+ ' <div class="seg" data-frame-key="' + key + '">' + btns + '</div>'
|
|
||||||
+ '</div>';
|
|
||||||
}
|
|
||||||
function frameInput(key, label, hint, val, isPass) {
|
|
||||||
return ''
|
|
||||||
+ '<div class="menu-row">'
|
|
||||||
+ ' <div><span class="label">' + label + '</span>'
|
|
||||||
+ (hint ? '<span class="hint">' + hint + '</span>' : '')
|
|
||||||
+ ' </div>'
|
|
||||||
+ ' <input type="' + (isPass ? 'password' : 'text') + '" class="title-input" data-frame-key="' + key + '" value="' + adminEsc(val) + '" placeholder="' + label + '">'
|
|
||||||
+ '</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
var frameSaveTimer = null;
|
|
||||||
function saveFrameConfig(updates) {
|
|
||||||
clearTimeout(frameSaveTimer);
|
|
||||||
frameSaveTimer = setTimeout(function () {
|
|
||||||
fetch('./avian/api/birdnet-api.php?action=frame_config', {
|
|
||||||
method: 'POST',
|
|
||||||
credentials: 'same-origin',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(updates),
|
|
||||||
}).catch(function (e) { console.warn('frame config save failed', e); });
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
function wireFrameControls(fc) {
|
|
||||||
var pending = {};
|
|
||||||
var refreshBtn = document.getElementById('frameRefreshBtn');
|
|
||||||
if (refreshBtn) {
|
|
||||||
refreshBtn.addEventListener('click', function () {
|
|
||||||
refreshBtn.textContent = 'Refreshing...';
|
|
||||||
refreshBtn.disabled = true;
|
|
||||||
fetch('http://' + (fc.frame_host || 'birdpic.local') + ':8080/refresh', { method: 'POST', mode: 'cors' })
|
|
||||||
.then(function () { refreshBtn.textContent = 'Triggered'; })
|
|
||||||
.catch(function () { refreshBtn.textContent = 'Failed (check LAN)'; })
|
|
||||||
.finally(function () { setTimeout(function () { refreshBtn.textContent = 'Refresh Now'; refreshBtn.disabled = false; }, 3000); });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
adminBody.querySelectorAll('.switch[data-frame-key]').forEach(function (sw) {
|
|
||||||
sw.addEventListener('click', function () {
|
|
||||||
var on = sw.getAttribute('aria-checked') !== 'true';
|
|
||||||
sw.setAttribute('aria-checked', on ? 'true' : 'false');
|
|
||||||
pending[sw.dataset.frameKey] = on;
|
|
||||||
saveFrameConfig(pending);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
adminBody.querySelectorAll('input[type="range"][data-frame-key]').forEach(function (sl) {
|
|
||||||
sl.addEventListener('input', function () {
|
|
||||||
var digits = +(sl.dataset.digits || 0);
|
|
||||||
var valEl = adminBody.querySelector('[data-frame-value="' + sl.dataset.frameKey + '"]');
|
|
||||||
if (valEl) valEl.textContent = (+sl.value).toFixed(digits);
|
|
||||||
});
|
|
||||||
sl.addEventListener('change', function () {
|
|
||||||
var v = +sl.value;
|
|
||||||
pending[sl.dataset.frameKey] = v;
|
|
||||||
saveFrameConfig(pending);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
adminBody.querySelectorAll('.seg[data-frame-key]').forEach(function (seg) {
|
|
||||||
var key = seg.dataset.frameKey;
|
|
||||||
seg.querySelectorAll('button').forEach(function (btn) {
|
|
||||||
btn.addEventListener('click', function () {
|
|
||||||
seg.querySelectorAll('button').forEach(function (b) { b.setAttribute('aria-current', 'false'); });
|
|
||||||
btn.setAttribute('aria-current', 'true');
|
|
||||||
var v = btn.dataset.v;
|
|
||||||
if (v === 'true') v = true;
|
|
||||||
else if (v === 'false') v = false;
|
|
||||||
else if (!isNaN(+v) && v !== '') v = +v;
|
|
||||||
pending[key] = v;
|
|
||||||
saveFrameConfig(pending);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
adminBody.querySelectorAll('input[type="text"][data-frame-key], input[type="password"][data-frame-key]').forEach(function (inp) {
|
|
||||||
var key = inp.dataset.frameKey;
|
|
||||||
inp.addEventListener('change', function () {
|
|
||||||
pending[key] = inp.value;
|
|
||||||
saveFrameConfig(pending);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderAdminSettings() {
|
function renderAdminSettings() {
|
||||||
adminBody.innerHTML = '<p style="font:11px ui-monospace,monospace;color:var(--ink-soft);text-align:center">loading settings...</p>';
|
adminBody.innerHTML = '<p style="font:11px ui-monospace,monospace;color:var(--ink-soft);text-align:center">loading settings...</p>';
|
||||||
fetch('./avian/api/config.php', { credentials: 'same-origin', cache: 'no-store' })
|
fetch('./avian/api/config.php', { credentials: 'same-origin', cache: 'no-store' })
|
||||||
@@ -2621,7 +2389,6 @@
|
|||||||
'<div class="admin-settings">'
|
'<div class="admin-settings">'
|
||||||
+ themeRow()
|
+ themeRow()
|
||||||
+ titleRow()
|
+ titleRow()
|
||||||
+ todayStartRow()
|
|
||||||
+ settingsToggle('preserve', 'Preserve all recordings', "don't auto-delete", preserve)
|
+ settingsToggle('preserve', 'Preserve all recordings', "don't auto-delete", preserve)
|
||||||
+ settingsSlider('CONFIDENCE', 'Confidence threshold', 'min score to log a detection', v.CONFIDENCE, 0.1, 0.95, 0.05, 2)
|
+ settingsSlider('CONFIDENCE', 'Confidence threshold', 'min score to log a detection', v.CONFIDENCE, 0.1, 0.95, 0.05, 2)
|
||||||
+ settingsSlider('SENSITIVITY', 'Sensitivity', 'analyzer sensitivity', v.SENSITIVITY, 0.5, 1.5, 0.05, 2)
|
+ settingsSlider('SENSITIVITY', 'Sensitivity', 'analyzer sensitivity', v.SENSITIVITY, 0.5, 1.5, 0.05, 2)
|
||||||
|
|||||||
@@ -27,7 +27,6 @@
|
|||||||
<div class="window-pick" id="winPick" role="tablist">
|
<div class="window-pick" id="winPick" role="tablist">
|
||||||
<i class="seg-pill" aria-hidden="true"></i>
|
<i class="seg-pill" aria-hidden="true"></i>
|
||||||
<button data-h="1" type="button">1H</button>
|
<button data-h="1" type="button">1H</button>
|
||||||
<button data-h="today" type="button">TODAY</button>
|
|
||||||
<button data-h="12" type="button">12H</button>
|
<button data-h="12" type="button">12H</button>
|
||||||
<button data-h="24" type="button" aria-current="true">24H</button>
|
<button data-h="24" type="button" aria-current="true">24H</button>
|
||||||
<button data-h="168" type="button">7D</button>
|
<button data-h="168" type="button">7D</button>
|
||||||
|
|||||||
@@ -1622,29 +1622,3 @@
|
|||||||
* See avian/forwarding/. */
|
* See avian/forwarding/. */
|
||||||
body.av-local #dd-locked { display: none; }
|
body.av-local #dd-locked { display: none; }
|
||||||
body.av-local #dd-items { display: block; }
|
body.av-local #dd-items { display: block; }
|
||||||
|
|
||||||
.frame-section-label {
|
|
||||||
font: 600 11px/1 var(--sans, ui-sans-serif, system-ui, sans-serif);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
color: var(--ink-soft, #8a8078);
|
|
||||||
padding: 18px 0 6px;
|
|
||||||
border-bottom: 1px solid rgba(26,22,18,0.08);
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
.frame-section-label:first-child { padding-top: 4px; }
|
|
||||||
|
|
||||||
|
|
||||||
.frame-refresh-btn {
|
|
||||||
font: 600 13px/1 var(--sans, ui-sans-serif, system-ui, sans-serif);
|
|
||||||
padding: 10px 24px;
|
|
||||||
border: 1px solid var(--ink-soft, #8a8078);
|
|
||||||
border-radius: 6px;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--ink, #1a1612);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.15s, opacity 0.15s;
|
|
||||||
}
|
|
||||||
.frame-refresh-btn:hover { background: rgba(26,22,18,0.05); }
|
|
||||||
.frame-refresh-btn:disabled { opacity: 0.5; cursor: default; }
|
|
||||||
|
|
||||||
|
|||||||
+79
-8
@@ -45,6 +45,9 @@ DEFAULTS = {
|
|||||||
"bw_days": 7, # BirdWeather lookback window, in days
|
"bw_days": 7, # BirdWeather lookback window, in days
|
||||||
"bw_country": "us", # geocoder country for the ZIP
|
"bw_country": "us", # geocoder country for the ZIP
|
||||||
"hours": 24,
|
"hours": 24,
|
||||||
|
"mode": "window", # "window" = rolling hours; "today" = daily reset
|
||||||
|
"start_time": "06:00", # "today" mode: collage resets at this time
|
||||||
|
"hold_overnight": True, # "today" mode: keep overnight collage until first bird
|
||||||
"image": "", # local PNG written by the shooter
|
"image": "", # local PNG written by the shooter
|
||||||
"image_url": "", # or a published screenshot URL
|
"image_url": "", # or a published screenshot URL
|
||||||
"shoot": False, # or capture inline (needs a browser; the 3 A+ and Zero 2 W both handle it)
|
"shoot": False, # or capture inline (needs a browser; the 3 A+ and Zero 2 W both handle it)
|
||||||
@@ -109,6 +112,55 @@ def fetch_species(cfg, auth=None):
|
|||||||
return fetch_recent(cfg["base_url"], cfg["hours"], cfg["timeout"], auth)
|
return fetch_recent(cfg["base_url"], cfg["hours"], cfg["timeout"], auth)
|
||||||
|
|
||||||
|
|
||||||
|
def _today_hours(cfg):
|
||||||
|
"""Compute the hours window for 'today' mode.
|
||||||
|
|
||||||
|
Returns hours since start_time today (or since yesterday's start_time
|
||||||
|
if we haven't reached it yet today).
|
||||||
|
"""
|
||||||
|
now = datetime.now()
|
||||||
|
parts = cfg["start_time"].split(":")
|
||||||
|
start_h, start_m = int(parts[0]), int(parts[1]) if len(parts) > 1 else 0
|
||||||
|
start_today = now.replace(hour=start_h, minute=start_m, second=0, microsecond=0)
|
||||||
|
|
||||||
|
if now < start_today:
|
||||||
|
from datetime import timedelta
|
||||||
|
yesterday_start = start_today - timedelta(days=1)
|
||||||
|
hours = (now - yesterday_start).total_seconds() / 3600
|
||||||
|
return max(1, hours)
|
||||||
|
|
||||||
|
hours = (now - start_today).total_seconds() / 3600
|
||||||
|
return max(1, hours)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_remote_config(base, timeout, auth=None):
|
||||||
|
"""Fetch frame config from the BirdNET server's frame_config endpoint."""
|
||||||
|
url = f"{base.rstrip('/')}/avian/api/birdnet-api.php?action=frame_config"
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": "AvianVisitors-frame/1.0"})
|
||||||
|
if auth:
|
||||||
|
req.add_header("Authorization", auth)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||||
|
return json.loads(r.read(100_000))
|
||||||
|
except Exception as e:
|
||||||
|
print(f"remote config fetch failed: {e}", file=sys.stderr)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
DISPLAY_KEYS = (
|
||||||
|
"mode", "hours", "start_time", "shoot_title", "shoot_subtitle",
|
||||||
|
"shoot_headline_px", "shoot_eyebrow_px", "shoot_lowercase",
|
||||||
|
"shoot_mat", "shoot_small_floor", "shoot_count_exp",
|
||||||
|
"mat", "mat_opening_w", "mat_opening_h", "rotate", "saturation",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def config_signature(cfg):
|
||||||
|
"""Hash of display-affecting config values."""
|
||||||
|
items = [(k, cfg.get(k)) for k in DISPLAY_KEYS]
|
||||||
|
return hashlib.sha256(json.dumps(items).encode()).hexdigest()[:16]
|
||||||
|
|
||||||
|
|
||||||
# --- image ------------------------------------------------------------------
|
# --- image ------------------------------------------------------------------
|
||||||
def get_image(src, timeout, auth=None):
|
def get_image(src, timeout, auth=None):
|
||||||
if re.match(r"^https?://", src):
|
if re.match(r"^https?://", src):
|
||||||
@@ -295,15 +347,15 @@ def load_state(path):
|
|||||||
with open(os.path.expanduser(path)) as f:
|
with open(os.path.expanduser(path)) as f:
|
||||||
return json.load(f)
|
return json.load(f)
|
||||||
except Exception:
|
except Exception:
|
||||||
return {"signature": None, "last_refresh": 0}
|
return {"signature": None, "last_refresh": 0, "config_sig": None}
|
||||||
|
|
||||||
|
|
||||||
def save_state(path, sig, when):
|
def save_state(path, sig, when, cfg_sig=None):
|
||||||
path = os.path.expanduser(path)
|
path = os.path.expanduser(path)
|
||||||
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
||||||
tmp = path + ".tmp"
|
tmp = path + ".tmp"
|
||||||
with open(tmp, "w") as f:
|
with open(tmp, "w") as f:
|
||||||
json.dump({"signature": sig, "last_refresh": when}, f)
|
json.dump({"signature": sig, "last_refresh": when, "config_sig": cfg_sig}, f)
|
||||||
f.flush()
|
f.flush()
|
||||||
os.fsync(f.fileno())
|
os.fsync(f.fileno())
|
||||||
os.replace(tmp, path) # atomic: a power cut can't leave a half-written file
|
os.replace(tmp, path) # atomic: a power cut can't leave a half-written file
|
||||||
@@ -317,7 +369,7 @@ def in_quiet_hours(cfg, hour):
|
|||||||
|
|
||||||
|
|
||||||
# --- run --------------------------------------------------------------------
|
# --- run --------------------------------------------------------------------
|
||||||
def obtain_image(cfg, species=None):
|
def obtain_image(cfg, species=None, window_hours=None):
|
||||||
if cfg.get("species_source") == "birdweather":
|
if cfg.get("species_source") == "birdweather":
|
||||||
from shoot import shoot_birdweather
|
from shoot import shoot_birdweather
|
||||||
if species is None: # gate skipped (--no-signature): fetch the list to render
|
if species is None: # gate skipped (--no-signature): fetch the list to render
|
||||||
@@ -335,6 +387,7 @@ def obtain_image(cfg, species=None):
|
|||||||
headline_px=cfg["shoot_headline_px"], eyebrow_px=cfg["shoot_eyebrow_px"],
|
headline_px=cfg["shoot_headline_px"], eyebrow_px=cfg["shoot_eyebrow_px"],
|
||||||
lowercase=cfg["shoot_lowercase"], mat=cfg["shoot_mat"],
|
lowercase=cfg["shoot_lowercase"], mat=cfg["shoot_mat"],
|
||||||
small_floor=cfg["shoot_small_floor"], count_exp=cfg["shoot_count_exp"], timeout_ms=cfg["timeout"] * 1000,
|
small_floor=cfg["shoot_small_floor"], count_exp=cfg["shoot_count_exp"], timeout_ms=cfg["timeout"] * 1000,
|
||||||
|
window_hours=int(window_hours) if window_hours else None,
|
||||||
user=cfg["basic_user"], password=cfg["basic_pass"])
|
user=cfg["basic_user"], password=cfg["basic_pass"])
|
||||||
return Image.open(out).convert("RGB")
|
return Image.open(out).convert("RGB")
|
||||||
src = cfg["image_url"] or cfg["image"]
|
src = cfg["image_url"] or cfg["image"]
|
||||||
@@ -345,17 +398,35 @@ def obtain_image(cfg, species=None):
|
|||||||
|
|
||||||
def run(cfg, preview=None, force=False, use_signature=True, mat_box=False):
|
def run(cfg, preview=None, force=False, use_signature=True, mat_box=False):
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
remote = fetch_remote_config(cfg["base_url"], cfg["timeout"], _auth(cfg))
|
||||||
|
if remote:
|
||||||
|
for k, v in remote.items():
|
||||||
|
if k in cfg and k not in ("state", "cache", "base_url"):
|
||||||
|
cfg[k] = v
|
||||||
state = load_state(cfg["state"])
|
state = load_state(cfg["state"])
|
||||||
sig = None
|
sig = None
|
||||||
species = None
|
species = None
|
||||||
|
window_hours = None
|
||||||
|
if cfg["mode"] == "today":
|
||||||
|
window_hours = _today_hours(cfg)
|
||||||
if use_signature:
|
if use_signature:
|
||||||
try:
|
try:
|
||||||
species = fetch_species(cfg, _auth(cfg))
|
if cfg["mode"] == "today":
|
||||||
|
species = fetch_recent(cfg["base_url"], max(1, int(window_hours + 0.5)), cfg["timeout"], _auth(cfg))
|
||||||
|
if not species and cfg["hold_overnight"]:
|
||||||
|
print("today mode: no birds yet, holding overnight collage")
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
species = fetch_species(cfg, _auth(cfg))
|
||||||
sig = signature(species)
|
sig = signature(species)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"signature fetch failed: {e}", file=sys.stderr) # treat as no change
|
print(f"signature fetch failed: {e}", file=sys.stderr) # treat as no change
|
||||||
|
cfg_sig = config_signature(cfg)
|
||||||
|
config_changed = cfg_sig != state.get("config_sig")
|
||||||
|
if config_changed and not force and not preview:
|
||||||
|
print("config changed; forcing refresh")
|
||||||
heal_due = now - state.get("last_refresh", 0) >= cfg["heal_hours"] * 3600
|
heal_due = now - state.get("last_refresh", 0) >= cfg["heal_hours"] * 3600
|
||||||
changed = (not use_signature) or (sig is not None and sig != state.get("signature"))
|
changed = config_changed or (not use_signature) or (sig is not None and sig != state.get("signature"))
|
||||||
if not force and not preview:
|
if not force and not preview:
|
||||||
if in_quiet_hours(cfg, datetime.now().hour):
|
if in_quiet_hours(cfg, datetime.now().hour):
|
||||||
print("quiet hours; skip")
|
print("quiet hours; skip")
|
||||||
@@ -366,7 +437,7 @@ def run(cfg, preview=None, force=False, use_signature=True, mat_box=False):
|
|||||||
print("refresh:", "changed" if changed else "heal")
|
print("refresh:", "changed" if changed else "heal")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
img = fit_panel(obtain_image(cfg, species))
|
img = fit_panel(obtain_image(cfg, species, window_hours))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"could not get image: {e}", file=sys.stderr) # keep last panel image
|
print(f"could not get image: {e}", file=sys.stderr) # keep last panel image
|
||||||
return
|
return
|
||||||
@@ -384,7 +455,7 @@ def run(cfg, preview=None, force=False, use_signature=True, mat_box=False):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"panel push failed: {e}", file=sys.stderr)
|
print(f"panel push failed: {e}", file=sys.stderr)
|
||||||
return
|
return
|
||||||
save_state(cfg["state"], sig if sig is not None else state.get("signature"), now)
|
save_state(cfg["state"], sig if sig is not None else state.get("signature"), now, cfg_sig)
|
||||||
print("panel updated")
|
print("panel updated")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Tiny HTTP server that triggers a birdframe refresh on demand."""
|
||||||
|
|
||||||
|
import http.server
|
||||||
|
import subprocess
|
||||||
|
import json
|
||||||
|
|
||||||
|
PORT = 8080
|
||||||
|
|
||||||
|
class Handler(http.server.BaseHTTPRequestHandler):
|
||||||
|
def do_POST(self):
|
||||||
|
if self.path == "/refresh":
|
||||||
|
subprocess.Popen(["systemctl", "start", "birdframe.service"])
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Access-Control-Allow-Origin", "*")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(json.dumps({"status": "triggered"}).encode())
|
||||||
|
else:
|
||||||
|
self.send_response(404)
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
def do_OPTIONS(self):
|
||||||
|
self.send_response(204)
|
||||||
|
self.send_header("Access-Control-Allow-Origin", "*")
|
||||||
|
self.send_header("Access-Control-Allow-Methods", "POST, OPTIONS")
|
||||||
|
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
def log_message(self, fmt, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
server = http.server.HTTPServer(("0.0.0.0", PORT), Handler)
|
||||||
|
print(f"frame-trigger listening on :{PORT}")
|
||||||
|
server.serve_forever()
|
||||||
Reference in New Issue
Block a user