Server: Frame.panel_type (new column + migration) is auto-derived from the device's reported board (X-Frame-Board), never user-set -- the panel is a property of the hardware, not a picker in the UI. image_pipeline's packing/render pipeline is parameterized by panel geometry instead of hardcoded 800x480 globals, with the real confirmed 13.3in geometry (1600x1200) registered alongside the original 7.3in panel. Existing 7.3in frames are unaffected (column default + board mapping both resolve to the original panel). Board identifiers are also renamed (devkit/xiao -> devkit_esp32c6/ xiao_esp32c6, plus new "ee02") since the EE02 board also carries a XIAO module -- "xiao" alone stopped disambiguating hardware. The server keeps accepting the legacy bare names indefinitely for already-flashed devices. Firmware: scaffolds a third build target (ee02, ESP32-S3 -- a real chip-target change, not just a same-chip Kconfig variant like xiao) and a new epd13in3e driver component skeleton. The actual panel init/LUT/ refresh register sequence isn't ported from vendor demo code yet (none was available), so that component deliberately fails to compile (#error) rather than risk sending unverified register values to real hardware -- devkit/xiao are unaffected and build identically to before. CI's ee02 build step is continue-on-error for the same reason.
298 lines
17 KiB
HTML
298 lines
17 KiB
HTML
{% extends "app_base.html" %}
|
|
|
|
{% block title %}{{ frame.name or "Frame" }} · Configuration{% endblock %}
|
|
{% block page_title %}{% include "_frame_name_edit.html" %}{% endblock %}
|
|
|
|
{% block device_status %}{% include "_device_status_bar.html" %}{% endblock %}
|
|
{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %}
|
|
|
|
{% block content %}
|
|
<div id="control-banner" class="control-banner" style="display: none;">
|
|
<span id="control-holder"></span>
|
|
<button type="button" id="take-control" class="btn-inline">Take control</button>
|
|
</div>
|
|
|
|
<div class="layout">
|
|
<div class="main-col">
|
|
<section class="card">
|
|
<h2 class="card-title">Display settings</h2>
|
|
<form id="config-form">
|
|
<label>Orientation
|
|
<select id="orientation">
|
|
<option value="landscape" {% if frame.orientation == "landscape" %}selected{% endif %}>Landscape</option>
|
|
<option value="portrait" {% if frame.orientation == "portrait" %}selected{% endif %}>Portrait</option>
|
|
<option value="landscape_flipped" {% if frame.orientation == "landscape_flipped" %}selected{% endif %}>Landscape (flipped)</option>
|
|
<option value="portrait_flipped" {% if frame.orientation == "portrait_flipped" %}selected{% endif %}>Portrait (flipped)</option>
|
|
</select>
|
|
</label>
|
|
<label>Refresh interval (minutes)
|
|
<input type="number" id="refresh_interval_minutes" min="1" max="1440"
|
|
value="{{ (frame.refresh_interval_s // 60) or 60 }}" required>
|
|
</label>
|
|
<div class="checkbox-row">
|
|
<input type="checkbox" id="quiet_hours_enabled" {% if frame.quiet_hours_enabled %}checked{% endif %}>
|
|
<label for="quiet_hours_enabled">Quiet hours (don't wake overnight)</label>
|
|
</div>
|
|
<label>Quiet hours start
|
|
<input type="time" id="quiet_hours_start" value="{{ frame.quiet_hours_start }}">
|
|
</label>
|
|
<label>Quiet hours end
|
|
<input type="time" id="quiet_hours_end" value="{{ frame.quiet_hours_end }}">
|
|
</label>
|
|
<label>Timezone
|
|
<select id="timezone">
|
|
{% for tz in timezones %}
|
|
<option value="{{ tz }}" {% if tz == frame.timezone %}selected{% endif %}>{{ tz }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</label>
|
|
<p class="sub" style="margin-top: 8px;">Quiet hours times are
|
|
interpreted in this timezone. The device may still wake once right
|
|
at the start of quiet hours -- it can't know ahead of time -- but
|
|
goes right back to sleep until they end.</p>
|
|
<button type="submit">Save</button>
|
|
</form>
|
|
</section>
|
|
|
|
<section class="card">
|
|
<h2 class="card-title">Hold actions</h2>
|
|
<p class="sub">Holding NEXT or BACK past this duration runs a
|
|
frame-wide action instead of each widget's normal short-press
|
|
binding (set per-widget in that widget's own config dialog).
|
|
Not scoped to any widget -- e.g. cycling through your saved
|
|
layouts.</p>
|
|
<form id="hold-config-form">
|
|
<label>Hold duration (seconds)
|
|
<input type="number" id="hold_duration_s" min="3" max="10"
|
|
value="{{ (frame.hold_duration_ms // 1000) or 3 }}" required>
|
|
</label>
|
|
<label>NEXT held action
|
|
<select id="next_hold_action">
|
|
<option value="" {% if not frame.next_hold_action %}selected{% endif %}>(none)</option>
|
|
{% for action, label in global_action_labels.items() %}
|
|
<option value="{{ action }}" {% if frame.next_hold_action == action %}selected{% endif %}>{{ label }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</label>
|
|
<label>BACK held action
|
|
<select id="back_hold_action">
|
|
<option value="" {% if not frame.back_hold_action %}selected{% endif %}>(none)</option>
|
|
{% for action, label in global_action_labels.items() %}
|
|
<option value="{{ action }}" {% if frame.back_hold_action == action %}selected{% endif %}>{{ label }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</label>
|
|
<button type="submit">Save</button>
|
|
</form>
|
|
</section>
|
|
|
|
</div>
|
|
|
|
<div class="side-col">
|
|
<section class="card">
|
|
<h2 class="card-title">Firmware update</h2>
|
|
<p class="sub" id="firmware-board">
|
|
{% if frame.device_board_variant %}Detected board: {{ frame.device_board_variant }}
|
|
{% else %}Board not detected yet -- the frame reports it on its next check-in.{% endif %}
|
|
</p>
|
|
<p class="sub" id="firmware-panel">
|
|
{% if frame.device_board_variant %}Panel: {{ panel_labels.get(frame.panel_type, frame.panel_type) }}
|
|
{% else %}Panel not detected yet -- determined automatically from the frame's board.{% endif %}
|
|
</p>
|
|
<p class="sub" id="firmware-available">
|
|
{% if frame.firmware_available_version %}Uploaded: v{{ frame.firmware_available_version }} -- the frame
|
|
updates itself on its next wake if it's running something else.{% else %}No firmware uploaded yet.{% endif %}
|
|
</p>
|
|
<input type="file" id="firmware-file" accept=".bin">
|
|
<button type="button" class="secondary" id="firmware-upload">Upload firmware</button>
|
|
|
|
<div id="firmware-repo-display" style="margin-top: 16px; {% if not frame.firmware_update_repo_url %}display: none;{% endif %}">
|
|
<p class="sub">Gitea repo: <code id="firmware-repo-text">{{ frame.firmware_update_repo_url }}</code>
|
|
<button type="button" class="secondary btn-inline" id="firmware-repo-edit-btn">Edit</button>
|
|
</p>
|
|
</div>
|
|
<label id="firmware-repo-edit" style="{% if frame.firmware_update_repo_url %}display: none;{% endif %}">Gitea repo URL
|
|
<input type="text" id="firmware_update_repo_url" placeholder="https://git.example.com/owner/repo"
|
|
value="{{ frame.firmware_update_repo_url }}">
|
|
</label>
|
|
<div class="checkbox-row">
|
|
<input type="checkbox" id="firmware_auto_update" {% if frame.firmware_auto_update %}checked{% endif %}>
|
|
<label for="firmware_auto_update">Automatically apply updates</label>
|
|
</div>
|
|
<p class="sub" style="margin-top: 4px;">While on, this frame installs
|
|
whatever the repo above publishes next, with nobody reviewing it
|
|
first -- only point it at a repo you trust.</p>
|
|
<button type="button" class="secondary" id="firmware-settings-save">Save</button>
|
|
<p class="sub" id="firmware-gitea-status" style="display: none; margin-top: 10px;"></p>
|
|
<button type="button" class="secondary" id="firmware-check-now">Check now</button>
|
|
<button type="button" id="firmware-update-btn" style="display: none;">Update frame</button>
|
|
</section>
|
|
|
|
<section class="card">
|
|
<h2 class="card-title">Battery alerts</h2>
|
|
<label>Email me when battery drops below (%)
|
|
<input type="number" id="battery_alert_threshold_pct" min="0" max="100"
|
|
value="{% if frame.battery_alert_threshold_pct >= 0 %}{{ frame.battery_alert_threshold_pct }}{% endif %}"
|
|
placeholder="disabled">
|
|
</label>
|
|
<p class="sub" style="margin-top: 8px;">Sent once per discharge cycle
|
|
to the frame owner's email (set in Settings) -- clear the field to
|
|
disable. Needs SMTP configured by an admin.</p>
|
|
<button type="button" class="secondary" id="battery-alert-save">Save</button>
|
|
</section>
|
|
|
|
<details class="card">
|
|
<summary class="card-title">Advanced configuration</summary>
|
|
<p class="sub">Color-quantization values used for every widget
|
|
<strong>except photos</strong> (which has its own separate
|
|
settings below) -- approximations by default, since exact
|
|
primaries aren't published. Tune them by comparing a rendered
|
|
widget against the physical panel; different panel units can
|
|
vary enough to be worth calibrating per frame.</p>
|
|
<div class="palette-table-wrap">
|
|
<table class="palette-table">
|
|
<thead><tr><th></th><th>Color</th><th>Hex</th><th>R</th><th>G</th><th>B</th></tr></thead>
|
|
<tbody>
|
|
{% set current_palette = frame.palette_rgb or default_palette_rgb %}
|
|
{% set current_hex = palette_to_hex(current_palette) %}
|
|
{% for label in palette_labels %}
|
|
<tr>
|
|
<td><span class="palette-swatch-preview" data-index="{{ loop.index0 }}"
|
|
style="background: {{ current_hex[loop.index0] }};"></span></td>
|
|
<td>{{ label }}</td>
|
|
<td><input type="text" class="palette-hex" id="palette_{{ loop.index0 }}" data-index="{{ loop.index0 }}"
|
|
value="{{ current_hex[loop.index0] }}" maxlength="7" pattern="#[0-9a-fA-F]{6}"
|
|
spellcheck="false" autocomplete="off"></td>
|
|
<td><input type="number" class="palette-rgb palette-r" data-index="{{ loop.index0 }}"
|
|
min="0" max="255" value="{{ current_palette[loop.index0][0] }}"></td>
|
|
<td><input type="number" class="palette-rgb palette-g" data-index="{{ loop.index0 }}"
|
|
min="0" max="255" value="{{ current_palette[loop.index0][1] }}"></td>
|
|
<td><input type="number" class="palette-rgb palette-b" data-index="{{ loop.index0 }}"
|
|
min="0" max="255" value="{{ current_palette[loop.index0][2] }}"></td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<h2 class="card-title" style="margin-top: 20px;">Image adjustments</h2>
|
|
<label>Color enhancement <span class="slider-value" id="color_boost_value">{{ "%.2f" | format(frame.color_boost) }}</span>
|
|
<input type="range" id="color_boost" min="0" max="2" step="0.05" value="{{ frame.color_boost }}">
|
|
</label>
|
|
<label>Contrast <span class="slider-value" id="contrast_boost_value">{{ "%.2f" | format(frame.contrast_boost) }}</span>
|
|
<input type="range" id="contrast_boost" min="0" max="2" step="0.05" value="{{ frame.contrast_boost }}">
|
|
</label>
|
|
<label>Dithering strength <span class="slider-value" id="dither_strength_value">{{ "%.2f" | format(frame.dither_strength) }}</span>
|
|
<input type="range" id="dither_strength" min="0" max="1" step="0.05" value="{{ frame.dither_strength }}">
|
|
</label>
|
|
<p class="sub" style="margin-top: 8px;">1.00 is unchanged for color/
|
|
contrast. Dithering strength trades noise texture for smoother
|
|
gradients as it goes down; 0 is a flat, un-dithered quantization.
|
|
Use the preview below to compare.</p>
|
|
|
|
<button type="button" class="secondary" id="palette-save" style="margin-top: 16px;">Save</button>
|
|
<button type="button" class="secondary" id="palette-reset">Reset to defaults</button>
|
|
<button type="button" class="secondary" id="palette-load-calibrated">Load calibrated Spectra 6 preset</button>
|
|
<p class="sub" style="margin-top: 8px;">Experimental: a community-measured
|
|
starting point (not this specific panel) -- fills the table above,
|
|
doesn't save by itself. Real Spectra 6 ink is duller than the
|
|
idealized defaults; this may or may not match your actual unit.
|
|
Compare against the physical panel before keeping it.</p>
|
|
</details>
|
|
|
|
<details class="card">
|
|
<summary class="card-title">Theme</summary>
|
|
<p class="sub">A curated visual style for widgets using the
|
|
"modern" (experimental) render style -- font, corner radius,
|
|
shadow, and header accent. Widgets rendered in the classic
|
|
style are unaffected. Photos are unaffected too (see Photos
|
|
configuration below).</p>
|
|
<label>Theme
|
|
<select id="theme-select">
|
|
{% for key, t in themes.items() %}
|
|
<option value="{{ key }}" {% if frame.theme == key %}selected{% endif %}>{{ t.label }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</label>
|
|
<button type="button" class="secondary" id="theme-save" style="margin-top: 16px;">Save</button>
|
|
</details>
|
|
|
|
<details class="card">
|
|
<summary class="card-title">Photos configuration</summary>
|
|
<p class="sub">Palette and dithering used <strong>only</strong> by
|
|
the photos widget, independent of Advanced configuration above --
|
|
lets you tune the rest of this frame's widgets (e.g. a "modern"
|
|
HTML-rendered look) without changing what looks best for actual
|
|
photographs, or vice versa.</p>
|
|
<div class="palette-table-wrap">
|
|
<table class="palette-table">
|
|
<thead><tr><th></th><th>Color</th><th>Hex</th><th>R</th><th>G</th><th>B</th></tr></thead>
|
|
<tbody>
|
|
{% set current_photo_palette = frame.photo_palette_rgb or default_palette_rgb %}
|
|
{% set current_photo_hex = palette_to_hex(current_photo_palette) %}
|
|
{% for label in palette_labels %}
|
|
<tr>
|
|
<td><span class="photo-palette-swatch-preview" data-index="{{ loop.index0 }}"
|
|
style="background: {{ current_photo_hex[loop.index0] }};"></span></td>
|
|
<td>{{ label }}</td>
|
|
<td><input type="text" class="photo-palette-hex" id="photo_palette_{{ loop.index0 }}" data-index="{{ loop.index0 }}"
|
|
value="{{ current_photo_hex[loop.index0] }}" maxlength="7" pattern="#[0-9a-fA-F]{6}"
|
|
spellcheck="false" autocomplete="off"></td>
|
|
<td><input type="number" class="photo-palette-rgb photo-palette-r" data-index="{{ loop.index0 }}"
|
|
min="0" max="255" value="{{ current_photo_palette[loop.index0][0] }}"></td>
|
|
<td><input type="number" class="photo-palette-rgb photo-palette-g" data-index="{{ loop.index0 }}"
|
|
min="0" max="255" value="{{ current_photo_palette[loop.index0][1] }}"></td>
|
|
<td><input type="number" class="photo-palette-rgb photo-palette-b" data-index="{{ loop.index0 }}"
|
|
min="0" max="255" value="{{ current_photo_palette[loop.index0][2] }}"></td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<label>Dithering strength <span class="slider-value" id="photo_dither_strength_value">{{ "%.2f" | format(frame.photo_dither_strength) }}</span>
|
|
<input type="range" id="photo_dither_strength" min="0" max="1" step="0.05" value="{{ frame.photo_dither_strength }}">
|
|
</label>
|
|
<button type="button" class="secondary" id="photo-palette-save" style="margin-top: 16px;">Save</button>
|
|
<button type="button" class="secondary" id="photo-palette-reset">Reset to defaults</button>
|
|
</details>
|
|
|
|
<section class="card">
|
|
<h2 class="card-title">Preview</h2>
|
|
{% if photo_widget_id %}
|
|
<p class="sub">The current photo (from this frame's photo widget), and
|
|
exactly how it renders on the panel with this frame's saved settings
|
|
above.</p>
|
|
<div class="preview-compare">
|
|
<div class="preview-pane">
|
|
<p class="sub">Now displaying</p>
|
|
<img class="preview-img" id="preview-original" alt="Original photo">
|
|
</div>
|
|
<div class="preview-pane">
|
|
<p class="sub">How it will look on the frame</p>
|
|
<img class="preview-img" id="preview-rendered" alt="Rendered preview">
|
|
</div>
|
|
</div>
|
|
<button type="button" class="secondary" id="preview-refresh">Refresh preview</button>
|
|
{% else %}
|
|
<p class="sub">This frame doesn't have a photo widget to preview
|
|
against yet -- add one from the Layout tab.</p>
|
|
{% endif %}
|
|
</section>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="result"></div>
|
|
{% endblock %}
|
|
|
|
{% block scripts %}
|
|
<script>
|
|
window.FRAME_BASE_API = window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};
|
|
window.DEFAULT_PALETTE_HEX = {{ palette_to_hex(default_palette_rgb) | tojson }};
|
|
window.CALIBRATED_SPECTRA6_HEX = {{ calibrated_spectra6_hex | tojson }};
|
|
window.PHOTO_WIDGET_PREVIEW_API = {{ (("/api/frames/" ~ frame.id ~ "/widgets/" ~ photo_widget_id) | tojson) if photo_widget_id else "null" }};
|
|
</script>
|
|
<script src="/static/device_status_bar.js"></script>
|
|
<script src="/static/frame_header.js"></script>
|
|
<script src="/static/frame_config.js"></script>
|
|
{% endblock %}
|