Add Frame admin panel with full remote config
- New frame_config API endpoint (GET/POST) stores all frame display settings in frame-config.json on the BirdNET server - Frame menu item in the admin drawer opens a full settings panel with sections: Display, Titles, Collage, Hardware, Source, Auth - Changes auto-save via debounced POST; the frame Pi picks them up on its next 15-min timer cycle - TODAY window button in the time picker with configurable start hour (localStorage bird:todayStart, adjustable in Settings) Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
This commit is contained in:
@@ -184,6 +184,64 @@ 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,
|
||||||
|
'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,6 +31,7 @@ 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],
|
||||||
|
|||||||
@@ -2367,6 +2367,7 @@
|
|||||||
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',
|
||||||
@@ -2402,6 +2403,7 @@
|
|||||||
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();
|
||||||
@@ -2424,6 +2426,175 @@
|
|||||||
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="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('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();
|
||||||
|
})
|
||||||
|
.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() {
|
||||||
|
var pending = {};
|
||||||
|
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' })
|
||||||
|
|||||||
@@ -1622,3 +1622,15 @@
|
|||||||
* 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; }
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user