Files
espresso_frame/server/app/templates/frame_config.html
T
tfaour 474b92a282
Build and push server image / test (push) Successful in 45s
Firmware build check / build-check (push) Successful in 2m50s
Build and push server image / build-and-push (push) Successful in 4m36s
Build and push server image / deploy (push) Failing after 1m34s
Add server-side support for a second panel (13.3in Spectra 6 / EE02) and scaffold its firmware target
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.
2026-08-04 20:08:22 +00:00

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 %}