Per-frame palette calibration + sidebar battery indicator
Build and push server image / build-and-push (push) Successful in 40s
Build and push server image / build-and-push (push) Successful in 40s
Advanced configuration (Configuration tab, collapsed <details> section):
a color picker per ink color (black/white/yellow/red/blue/green),
overriding image_pipeline.DEFAULT_PALETTE_RGB for that frame's actual
panel -- different units can vary enough from the documented
approximations to be worth calibrating once you can compare a rendered
photo against the real hardware. Stored as Frame.palette_rgb (NULL =
default, schema migration v4), threaded through render_frame/
render_placeholder/_quantize_and_pack (which now builds the PIL palette
image per call instead of once at import) so both photos and the
unclaimed/unconfigured placeholder screen respect it. "Reset to
defaults" clears back to NULL. Config-save validates exactly 6 #rrggbb
values, rejecting anything else with a 400.
Also: each frame's sidebar entry now shows its last-reported battery
percent (🔋NN%) next to the name, using the frame_dot's existing
recently-seen indicator conventions -- silent when never reported
(mains-only frames, or before the first report), matching how battery
is hidden everywhere else it's not applicable.
Verified against the same live-shaped database as the SMTP work: the
v3->v4 migration, save/reload/reset round trip through the real HTTP
route, an actual rendered image using a custom palette (confirmed via
its packed panel-code bytes), input validation, and the sidebar badge
against real battery data -- plus the standing legacy-device curl suite.
This commit is contained in:
+12
-5
@@ -153,7 +153,10 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
|
|||||||
`quiet_hours_*` + `timezone` (a pure server-side decision shaping
|
`quiet_hours_*` + `timezone` (a pure server-side decision shaping
|
||||||
what `refresh_interval_s` gets handed to the device),
|
what `refresh_interval_s` gets handed to the device),
|
||||||
`firmware_update_repo_url`, `firmware_auto_update`,
|
`firmware_update_repo_url`, `firmware_auto_update`,
|
||||||
`battery_alert_threshold_pct` -- percent, or `-1`/blank to disable).
|
`battery_alert_threshold_pct` -- percent, or `-1`/blank to disable --,
|
||||||
|
`palette` -- exactly 6 `#rrggbb` values in black/white/yellow/red/
|
||||||
|
blue/green order --, `palette_reset` -- `true` clears back to the
|
||||||
|
default palette).
|
||||||
- `POST .../take-control` -- always succeeds for a linked user.
|
- `POST .../take-control` -- always succeeds for a linked user.
|
||||||
- `GET .../stats`, `GET .../battery-log`, `GET .../thumbnail/{asset_id}`.
|
- `GET .../stats`, `GET .../battery-log`, `GET .../thumbnail/{asset_id}`.
|
||||||
- `POST .../firmware` (manual .bin upload, esp_app_desc_t-validated),
|
- `POST .../firmware` (manual .bin upload, esp_app_desc_t-validated),
|
||||||
@@ -193,10 +196,14 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
|
|||||||
to photos this frame is actually showing or has queued, not any
|
to photos this frame is actually showing or has queued, not any
|
||||||
Immich asset ID someone might guess -- a second layer a leaked device
|
Immich asset ID someone might guess -- a second layer a leaked device
|
||||||
token alone wouldn't bypass.
|
token alone wouldn't bypass.
|
||||||
- The 6-color palette RGB values in `app/image_pipeline.py` are
|
- The 6-color palette RGB values in `app/image_pipeline.py`
|
||||||
approximations, not measured values (Waveshare doesn't publish exact
|
(`DEFAULT_PALETTE_RGB`) are approximations, not measured values
|
||||||
color primaries for this panel) -- tune them once you can compare a
|
(Waveshare doesn't publish exact color primaries for this panel).
|
||||||
rendered test image against the real panel.
|
Each frame's Configuration tab has an **Advanced configuration**
|
||||||
|
section (collapsed by default) with a color picker per ink color --
|
||||||
|
tune them once you can compare a rendered photo against the real
|
||||||
|
panel, and "Reset to defaults" to go back. Different panel units can
|
||||||
|
vary enough to be worth calibrating per frame.
|
||||||
|
|
||||||
## Deploying a pre-built image
|
## Deploying a pre-built image
|
||||||
|
|
||||||
|
|||||||
@@ -45,39 +45,56 @@ def logical_to_native(x: float, y: float, orientation: str) -> tuple[int, int]:
|
|||||||
return int(logical_h - 1 - y), int(x)
|
return int(logical_h - 1 - y), int(x)
|
||||||
return int(x), int(y)
|
return int(x), int(y)
|
||||||
|
|
||||||
# Approximate sRGB for each of the panel's 6 ink colors. These are
|
# Approximate sRGB for each of the panel's 6 ink colors -- reasonable
|
||||||
# reasonable placeholders, not measured values -- Waveshare doesn't publish
|
# placeholders, not measured values (Waveshare doesn't publish exact
|
||||||
# exact color primaries for this panel. Tune them once you can compare a
|
# color primaries for this panel). This is the fallback for any frame
|
||||||
# rendered test image against the real panel.
|
# that hasn't tuned its own (Frame.palette_rgb, set from a frame's
|
||||||
PALETTE_RGB = [
|
# Configuration tab -- "Advanced configuration" -- once you can compare
|
||||||
# (0, 0, 0), # BLACK
|
# a rendered test image against the real panel; different panel units
|
||||||
# (255, 255, 255), # WHITE
|
# can vary enough to be worth calibrating per frame).
|
||||||
# (255, 219, 0), # YELLOW
|
DEFAULT_PALETTE_RGB = [
|
||||||
# (207, 0, 15), # RED
|
(0, 0, 0), # BLACK
|
||||||
# (0, 39, 133), # BLUE
|
(255, 255, 255), # WHITE
|
||||||
# (0, 133, 55), # GREEN
|
(255, 243, 56), # YELLOW
|
||||||
(0, 0, 0),
|
(191, 0, 0), # RED
|
||||||
(255, 255, 255),
|
(100, 64, 255), # BLUE
|
||||||
(255, 243, 56),
|
(67, 138, 28), # GREEN
|
||||||
(191, 0, 0),
|
|
||||||
(100, 64, 255),
|
|
||||||
(67, 138, 28)
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
PALETTE_LABELS = ["Black", "White", "Yellow", "Red", "Blue", "Green"]
|
||||||
|
|
||||||
# The panel's actual 4-bit color codes (see firmware/components/epd7in3e),
|
# The panel's actual 4-bit color codes (see firmware/components/epd7in3e),
|
||||||
# in the same order as PALETTE_RGB. 0x4 is intentionally unused upstream.
|
# in the same order as DEFAULT_PALETTE_RGB/PALETTE_LABELS -- fixed by the
|
||||||
|
# hardware protocol, never user-configurable. 0x4 is intentionally unused
|
||||||
|
# upstream.
|
||||||
PANEL_CODES = [0x0, 0x1, 0x2, 0x3, 0x5, 0x6]
|
PANEL_CODES = [0x0, 0x1, 0x2, 0x3, 0x5, 0x6]
|
||||||
|
|
||||||
|
|
||||||
def _build_palette_image() -> Image.Image:
|
def palette_to_hex(palette_rgb: list) -> list[str]:
|
||||||
|
"""[(0,0,0), ...] -> ["#000000", ...], for pre-filling the Advanced
|
||||||
|
configuration color pickers."""
|
||||||
|
return ["#%02x%02x%02x" % tuple(c) for c in palette_rgb]
|
||||||
|
|
||||||
|
|
||||||
|
def hex_to_rgb(hex_str: str) -> tuple[int, int, int] | None:
|
||||||
|
""""#1a2b3c" -> (26, 43, 60), or None for anything that isn't exactly
|
||||||
|
a 6-hex-digit color (what <input type="color"> always sends, but a
|
||||||
|
direct API call might not)."""
|
||||||
|
hex_str = hex_str.strip().lstrip("#")
|
||||||
|
if len(hex_str) != 6:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return (int(hex_str[0:2], 16), int(hex_str[2:4], 16), int(hex_str[4:6], 16))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_palette_image(palette_rgb: list) -> Image.Image:
|
||||||
pal_img = Image.new("P", (1, 1))
|
pal_img = Image.new("P", (1, 1))
|
||||||
pal_img.putpalette([channel for rgb in PALETTE_RGB for channel in rgb])
|
pal_img.putpalette([channel for rgb in palette_rgb for channel in rgb])
|
||||||
return pal_img
|
return pal_img
|
||||||
|
|
||||||
|
|
||||||
_PALETTE_IMAGE = _build_palette_image()
|
|
||||||
|
|
||||||
|
|
||||||
def _plain_center_crop_box(
|
def _plain_center_crop_box(
|
||||||
img_width: int, img_height: int, target_width: int, target_height: int
|
img_width: int, img_height: int, target_width: int, target_height: int
|
||||||
) -> tuple[float, float, int, int]:
|
) -> tuple[float, float, int, int]:
|
||||||
@@ -153,7 +170,7 @@ def _face_aware_crop_box(
|
|||||||
|
|
||||||
|
|
||||||
def render_frame(source: Image.Image, faces: list[dict] | None = None,
|
def render_frame(source: Image.Image, faces: list[dict] | None = None,
|
||||||
orientation: str = "landscape") -> bytes:
|
orientation: str = "landscape", palette_rgb: list | None = None) -> bytes:
|
||||||
"""Fits `source` to the panel's resolution, quantizes it to the 6-color
|
"""Fits `source` to the panel's resolution, quantizes it to the 6-color
|
||||||
palette with Floyd-Steinberg dithering, and packs 2 pixels/byte the way
|
palette with Floyd-Steinberg dithering, and packs 2 pixels/byte the way
|
||||||
epd7in3e.c expects. Always returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes.
|
epd7in3e.c expects. Always returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes.
|
||||||
@@ -164,6 +181,9 @@ def render_frame(source: Image.Image, faces: list[dict] | None = None,
|
|||||||
`orientation` (see ORIENTATION_TRANSPOSE) composes the photo for how
|
`orientation` (see ORIENTATION_TRANSPOSE) composes the photo for how
|
||||||
the frame physically hangs, then rotates into native panel space --
|
the frame physically hangs, then rotates into native panel space --
|
||||||
the output byte layout is identical either way.
|
the output byte layout is identical either way.
|
||||||
|
|
||||||
|
`palette_rgb` overrides DEFAULT_PALETTE_RGB (a frame's tuned colors,
|
||||||
|
see Frame.palette_rgb) -- None uses the default.
|
||||||
"""
|
"""
|
||||||
logical_w, logical_h = logical_render_size(orientation)
|
logical_w, logical_h = logical_render_size(orientation)
|
||||||
fitted = ImageOps.exif_transpose(source.convert("RGB"))
|
fitted = ImageOps.exif_transpose(source.convert("RGB"))
|
||||||
@@ -174,15 +194,16 @@ def render_frame(source: Image.Image, faces: list[dict] | None = None,
|
|||||||
else:
|
else:
|
||||||
fitted = ImageOps.fit(fitted, (logical_w, logical_h), method=Image.LANCZOS)
|
fitted = ImageOps.fit(fitted, (logical_w, logical_h), method=Image.LANCZOS)
|
||||||
|
|
||||||
return _quantize_and_pack(fitted, orientation)
|
return _quantize_and_pack(fitted, orientation, palette_rgb)
|
||||||
|
|
||||||
|
|
||||||
def _quantize_and_pack(logical_img: Image.Image, orientation: str) -> bytes:
|
def _quantize_and_pack(logical_img: Image.Image, orientation: str, palette_rgb: list | None = None) -> bytes:
|
||||||
"""The shared back half of rendering: 6-color Floyd-Steinberg
|
"""The shared back half of rendering: 6-color Floyd-Steinberg
|
||||||
quantization, rotation into native panel space, and 2-pixels/byte
|
quantization, rotation into native panel space, and 2-pixels/byte
|
||||||
packing. Takes an RGB image already composed at logical_render_size()
|
packing. Takes an RGB image already composed at logical_render_size()
|
||||||
for the orientation."""
|
for the orientation."""
|
||||||
quantized = logical_img.quantize(palette=_PALETTE_IMAGE, dither=Image.Dither.FLOYDSTEINBERG)
|
palette_image = _build_palette_image(palette_rgb or DEFAULT_PALETTE_RGB)
|
||||||
|
quantized = logical_img.quantize(palette=palette_image, dither=Image.Dither.FLOYDSTEINBERG)
|
||||||
transpose = ORIENTATION_TRANSPOSE.get(orientation)
|
transpose = ORIENTATION_TRANSPOSE.get(orientation)
|
||||||
if transpose is not None:
|
if transpose is not None:
|
||||||
quantized = quantized.transpose(transpose)
|
quantized = quantized.transpose(transpose)
|
||||||
@@ -201,7 +222,7 @@ def _quantize_and_pack(logical_img: Image.Image, orientation: str) -> bytes:
|
|||||||
|
|
||||||
|
|
||||||
def render_placeholder(lines: list[str], qr_url: str | None = None,
|
def render_placeholder(lines: list[str], qr_url: str | None = None,
|
||||||
orientation: str = "landscape") -> bytes:
|
orientation: str = "landscape", palette_rgb: list | None = None) -> bytes:
|
||||||
"""A readable full-panel message (plus an optional QR code) in the
|
"""A readable full-panel message (plus an optional QR code) in the
|
||||||
same packed format as render_frame -- what /frame/image serves for a
|
same packed format as render_frame -- what /frame/image serves for a
|
||||||
frame that isn't claimed or configured yet, so a fresh device shows
|
frame that isn't claimed or configured yet, so a fresh device shows
|
||||||
@@ -246,4 +267,4 @@ def render_placeholder(lines: list[str], qr_url: str | None = None,
|
|||||||
if qr_img:
|
if qr_img:
|
||||||
img.paste(qr_img, ((logical_w - qr_img.width) // 2, y + 14))
|
img.paste(qr_img, ((logical_w - qr_img.width) // 2, y + 14))
|
||||||
|
|
||||||
return _quantize_and_pack(img, orientation)
|
return _quantize_and_pack(img, orientation, palette_rgb)
|
||||||
|
|||||||
@@ -52,10 +52,18 @@ def _migration_3(conn) -> None:
|
|||||||
conn.execute(text("ALTER TABLE server_settings DROP COLUMN smtp_use_tls"))
|
conn.execute(text("ALTER TABLE server_settings DROP COLUMN smtp_use_tls"))
|
||||||
|
|
||||||
|
|
||||||
|
def _migration_4(conn) -> None:
|
||||||
|
"""Advanced configuration: a per-frame color palette override. NULL
|
||||||
|
for every existing row -- exactly "use the default", no behavior
|
||||||
|
change until a frame's Configuration tab sets one."""
|
||||||
|
conn.execute(text("ALTER TABLE frames ADD COLUMN palette_rgb TEXT"))
|
||||||
|
|
||||||
|
|
||||||
MIGRATIONS = [
|
MIGRATIONS = [
|
||||||
(1, _migration_1),
|
(1, _migration_1),
|
||||||
(2, _migration_2),
|
(2, _migration_2),
|
||||||
(3, _migration_3),
|
(3, _migration_3),
|
||||||
|
(4, _migration_4),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -124,6 +124,11 @@ class Frame(Base):
|
|||||||
smart_crop_faces: Mapped[bool] = mapped_column(Boolean, default=True)
|
smart_crop_faces: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
orientation: Mapped[str] = mapped_column(String, default="landscape")
|
orientation: Mapped[str] = mapped_column(String, default="landscape")
|
||||||
queue_target_len: Mapped[int] = mapped_column(Integer, default=20)
|
queue_target_len: Mapped[int] = mapped_column(Integer, default=20)
|
||||||
|
# Advanced configuration: [[r,g,b], ...] x6 (black/white/yellow/red/
|
||||||
|
# blue/green, matching image_pipeline.PANEL_CODES order) overriding
|
||||||
|
# DEFAULT_PALETTE_RGB for this frame's actual panel. NULL = use the
|
||||||
|
# default -- most frames never touch this.
|
||||||
|
palette_rgb: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
|
||||||
|
|
||||||
# -- state --
|
# -- state --
|
||||||
current_asset_id: Mapped[str] = mapped_column(String, default="")
|
current_asset_id: Mapped[str] = mapped_column(String, default="")
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ from sqlalchemy.orm import Session
|
|||||||
from .. import gitea_releases, photo_queue, quiet_hours
|
from .. import gitea_releases, photo_queue, quiet_hours
|
||||||
from ..auth import require_frame_control, require_frame_view, require_user_api
|
from ..auth import require_frame_control, require_frame_view, require_user_api
|
||||||
from ..db import frame_locked, get_db
|
from ..db import frame_locked, get_db
|
||||||
|
from ..image_pipeline import PALETTE_LABELS, hex_to_rgb
|
||||||
from ..firmware import firmware_path, parse_app_version
|
from ..firmware import firmware_path, parse_app_version
|
||||||
from ..models import BatteryLog, Frame
|
from ..models import BatteryLog, Frame
|
||||||
from .common import (
|
from .common import (
|
||||||
@@ -79,6 +80,8 @@ def api_config_save(
|
|||||||
firmware_update_repo_url: str | None = Form(None),
|
firmware_update_repo_url: str | None = Form(None),
|
||||||
firmware_auto_update: bool | None = Form(None),
|
firmware_auto_update: bool | None = Form(None),
|
||||||
battery_alert_threshold_pct: int | None = Form(None),
|
battery_alert_threshold_pct: int | None = Form(None),
|
||||||
|
palette: list[str] | None = Form(None),
|
||||||
|
palette_reset: bool | None = Form(None),
|
||||||
frame: Frame = Depends(require_frame_control),
|
frame: Frame = Depends(require_frame_control),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -124,6 +127,15 @@ def api_config_save(
|
|||||||
# A changed threshold should be able to fire again immediately,
|
# A changed threshold should be able to fire again immediately,
|
||||||
# not stay suppressed by a flag set under the old value.
|
# not stay suppressed by a flag set under the old value.
|
||||||
cfg.battery_alert_sent = False
|
cfg.battery_alert_sent = False
|
||||||
|
if palette_reset:
|
||||||
|
cfg.palette_rgb = None
|
||||||
|
elif palette is not None:
|
||||||
|
if len(palette) != len(PALETTE_LABELS):
|
||||||
|
raise HTTPException(400, f"Expected {len(PALETTE_LABELS)} palette colors, got {len(palette)}")
|
||||||
|
parsed = [hex_to_rgb(h) for h in palette]
|
||||||
|
if any(rgb is None for rgb in parsed):
|
||||||
|
raise HTTPException(400, "Palette colors must be #rrggbb hex values")
|
||||||
|
cfg.palette_rgb = [list(rgb) for rgb in parsed]
|
||||||
cfg.stats_config_saves += 1
|
cfg.stats_config_saves += 1
|
||||||
return {"status": "saved"}
|
return {"status": "saved"}
|
||||||
|
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ def render_asset(client: ImmichClient, frame: Frame, asset_id: str) -> bytes:
|
|||||||
logger.warning("Could not fetch faces for asset %s: %s", asset_id, e)
|
logger.warning("Could not fetch faces for asset %s: %s", asset_id, e)
|
||||||
|
|
||||||
source = Image.open(io.BytesIO(jpeg_bytes))
|
source = Image.open(io.BytesIO(jpeg_bytes))
|
||||||
return render_frame(source, faces=faces, orientation=frame.orientation)
|
return render_frame(source, faces=faces, orientation=frame.orientation, palette_rgb=frame.palette_rgb)
|
||||||
|
|
||||||
|
|
||||||
def battery_estimate_s(frame: Frame) -> int | None:
|
def battery_estimate_s(frame: Frame) -> int | None:
|
||||||
|
|||||||
@@ -54,16 +54,19 @@ def _setup_placeholder(frame: Frame, request: Request) -> bytes:
|
|||||||
["This frame isn't claimed yet", "Scan to link it to your account:"],
|
["This frame isn't claimed yet", "Scan to link it to your account:"],
|
||||||
qr_url=claim_url,
|
qr_url=claim_url,
|
||||||
orientation=frame.orientation,
|
orientation=frame.orientation,
|
||||||
|
palette_rgb=frame.palette_rgb,
|
||||||
)
|
)
|
||||||
if frame.owner_user_id is None:
|
if frame.owner_user_id is None:
|
||||||
return render_placeholder(
|
return render_placeholder(
|
||||||
["Almost there!", f"Open {base} to finish setting up this frame."],
|
["Almost there!", f"Open {base} to finish setting up this frame."],
|
||||||
orientation=frame.orientation,
|
orientation=frame.orientation,
|
||||||
|
palette_rgb=frame.palette_rgb,
|
||||||
)
|
)
|
||||||
return render_placeholder(
|
return render_placeholder(
|
||||||
["Almost there!", "Pick an album for this frame:", base],
|
["Almost there!", "Pick an album for this frame:", base],
|
||||||
qr_url=base,
|
qr_url=base,
|
||||||
orientation=frame.orientation,
|
orientation=frame.orientation,
|
||||||
|
palette_rgb=frame.palette_rgb,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from ..auth import can_view_frame, current_user
|
from ..auth import can_view_frame, current_user
|
||||||
from ..db import get_db
|
from ..db import get_db
|
||||||
|
from ..image_pipeline import DEFAULT_PALETTE_RGB, PALETTE_LABELS, palette_to_hex
|
||||||
from ..models import Frame
|
from ..models import Frame
|
||||||
from ..quiet_hours import ALL_TIMEZONES
|
from ..quiet_hours import ALL_TIMEZONES
|
||||||
from .common import shell_context
|
from .common import shell_context
|
||||||
@@ -40,7 +41,11 @@ def frame_photos_page(frame_id: int, request: Request, db: Session = Depends(get
|
|||||||
@router.get("/frames/{frame_id}/config", response_class=HTMLResponse)
|
@router.get("/frames/{frame_id}/config", response_class=HTMLResponse)
|
||||||
def frame_config_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
|
def frame_config_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
|
||||||
return _frame_page(
|
return _frame_page(
|
||||||
request, db, frame_id, "frame_config.html", "config", timezones=ALL_TIMEZONES
|
request, db, frame_id, "frame_config.html", "config",
|
||||||
|
timezones=ALL_TIMEZONES,
|
||||||
|
palette_labels=PALETTE_LABELS,
|
||||||
|
default_palette_rgb=DEFAULT_PALETTE_RGB,
|
||||||
|
palette_to_hex=palette_to_hex,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,41 @@ async function loadControl() {
|
|||||||
|
|
||||||
document.getElementById('take-control').addEventListener('click', takeControl);
|
document.getElementById('take-control').addEventListener('click', takeControl);
|
||||||
|
|
||||||
|
// ---- Advanced configuration: color palette ----
|
||||||
|
|
||||||
|
function paletteInputs() {
|
||||||
|
return Array.from(document.querySelectorAll('[id^="palette_"]'))
|
||||||
|
.sort((a, b) => Number(a.id.split('_')[1]) - Number(b.id.split('_')[1]));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePalette(body) {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${window.FRAME_API}/config`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(await apiError(resp));
|
||||||
|
showStatus(true, 'Saved.');
|
||||||
|
} catch (e) {
|
||||||
|
showStatus(false, e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('palette-save').addEventListener('click', () => {
|
||||||
|
const body = new URLSearchParams();
|
||||||
|
for (const input of paletteInputs()) {
|
||||||
|
body.append('palette', input.value);
|
||||||
|
}
|
||||||
|
savePalette(body);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('palette-reset').addEventListener('click', () => {
|
||||||
|
const inputs = paletteInputs();
|
||||||
|
window.DEFAULT_PALETTE_HEX.forEach((hex, i) => { inputs[i].value = hex; });
|
||||||
|
savePalette(new URLSearchParams({ palette_reset: 'true' }));
|
||||||
|
});
|
||||||
|
|
||||||
// ---- Battery alerts card ----
|
// ---- Battery alerts card ----
|
||||||
|
|
||||||
document.getElementById('battery-alert-save').addEventListener('click', async () => {
|
document.getElementById('battery-alert-save').addEventListener('click', async () => {
|
||||||
|
|||||||
@@ -168,6 +168,30 @@ summary.card-title { cursor: pointer; margin-bottom: 0; }
|
|||||||
details.card[open] summary.card-title { margin-bottom: 14px; }
|
details.card[open] summary.card-title { margin-bottom: 14px; }
|
||||||
details.card .sub { margin-top: 8px; }
|
details.card .sub { margin-top: 8px; }
|
||||||
|
|
||||||
|
.palette-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
.palette-swatch {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.palette-swatch input[type="color"] {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
padding: 2px;
|
||||||
|
margin-top: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
|
|||||||
@@ -34,7 +34,11 @@
|
|||||||
href="/frames/{{ f.id }}">
|
href="/frames/{{ f.id }}">
|
||||||
<span class="frame-dot" aria-hidden="true"></span>
|
<span class="frame-dot" aria-hidden="true"></span>
|
||||||
{{ f.name or ("Frame " ~ f.id) }}
|
{{ f.name or ("Frame " ~ f.id) }}
|
||||||
{% if f.owner_user_id is none %}<span class="nav-sub">unclaimed</span>{% endif %}
|
{% if f.owner_user_id is none %}
|
||||||
|
<span class="nav-sub">unclaimed</span>
|
||||||
|
{% elif f.battery_percent >= 0 %}
|
||||||
|
<span class="nav-sub battery-badge" title="Battery">🔋{{ f.battery_percent }}%</span>
|
||||||
|
{% endif %}
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% if not sidebar_frames %}
|
{% if not sidebar_frames %}
|
||||||
|
|||||||
@@ -112,6 +112,26 @@
|
|||||||
disable. Needs SMTP configured by an admin.</p>
|
disable. Needs SMTP configured by an admin.</p>
|
||||||
<button type="button" class="secondary" id="battery-alert-save">Save</button>
|
<button type="button" class="secondary" id="battery-alert-save">Save</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<details class="card">
|
||||||
|
<summary class="card-title">Advanced configuration</summary>
|
||||||
|
<p class="sub">Color-quantization values used when dithering photos
|
||||||
|
for this panel -- approximations by default, since exact primaries
|
||||||
|
aren't published. Tune them by comparing a rendered photo against
|
||||||
|
the physical panel; different panel units can vary enough to be
|
||||||
|
worth calibrating per frame.</p>
|
||||||
|
<div class="palette-grid">
|
||||||
|
{% set current_hex = palette_to_hex(frame.palette_rgb or default_palette_rgb) %}
|
||||||
|
{% for label in palette_labels %}
|
||||||
|
<label class="palette-swatch">
|
||||||
|
<input type="color" id="palette_{{ loop.index0 }}" value="{{ current_hex[loop.index0] }}">
|
||||||
|
{{ label }}
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<button type="button" class="secondary" id="palette-save">Save</button>
|
||||||
|
<button type="button" class="secondary" id="palette-reset">Reset to defaults</button>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -119,6 +139,9 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};</script>
|
<script>
|
||||||
|
window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};
|
||||||
|
window.DEFAULT_PALETTE_HEX = {{ palette_to_hex(default_palette_rgb) | tojson }};
|
||||||
|
</script>
|
||||||
<script src="/static/frame_config.js"></script>
|
<script src="/static/frame_config.js"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user