Compare commits
12
Commits
v1.4.1
...
d974e872ba
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d974e872ba | ||
|
|
dfe9d71971 | ||
|
|
05b417a29b | ||
|
|
a48c84ed4a | ||
|
|
d1f1968317 | ||
|
|
83994aab7b | ||
|
|
5866c2f040 | ||
|
|
dd038f8e46 | ||
|
|
3fdda096a9 | ||
|
|
575b3cfa61 | ||
|
|
aa4a382c1b | ||
|
|
684225422c |
@@ -11,6 +11,7 @@ mkdir -p "$SCRATCH"
|
||||
|
||||
DATABASE_URL="sqlite:///$SCRATCH/test.db" \
|
||||
CONFIG_PATH="$SCRATCH/config.json" \
|
||||
LOG_PATH="$SCRATCH/app.log" \
|
||||
.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port "$PORT" \
|
||||
> "$SCRATCH/server.log" 2>&1 &
|
||||
PID=$!
|
||||
|
||||
@@ -37,6 +37,22 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
# Split across several layers rather than one `pip install -r
|
||||
# requirements.txt` -- same Cloudflare single-blob/layer payload-size
|
||||
# limit as render-service's npm installs below. The single combined
|
||||
# layer was measured at ~113MB unpacked, over the limit on its own.
|
||||
# Isolating the three largest packages gets every layer's unpacked size
|
||||
# well clear of 100MB (sqlalchemy ~15MB, pillow ~19MB, pypdfium2 ~8MB,
|
||||
# the remaining `-r requirements.txt` layer ~71MB). Each package
|
||||
# version here still comes from requirements.txt (`pip install -r` for
|
||||
# everything that doesn't need its own layer skips these three, since
|
||||
# pip sees them already satisfied); the explicit versions below just
|
||||
# control *when* each installs -- same "single source of truth, just
|
||||
# splitting *when* it installs" tradeoff as the npm section's --no-save
|
||||
# comment below.
|
||||
RUN pip install --no-cache-dir sqlalchemy==2.0.51
|
||||
RUN pip install --no-cache-dir pillow==12.3.0
|
||||
RUN pip install --no-cache-dir pypdfium2==5.12.1
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# render-service/'s dependencies installed as several separate layers
|
||||
|
||||
+9
-1
@@ -91,11 +91,19 @@ algorithm itself -- it just streams the response straight to the panel.
|
||||
again until a recharge is detected and it crosses again. No SMTP
|
||||
configured, or no email on the relevant account, and both features
|
||||
silently no-op rather than erroring.
|
||||
- **Server logs.** `/admin/logs` shows the tail of the process's own
|
||||
log file (`LOG_PATH` env var, default `/data/server.log` -- the same
|
||||
`/data` volume as the database and legacy config, so it survives
|
||||
container restarts/redeploys; `LOG_LEVEL` env var, default `INFO`).
|
||||
Rotates at ~2MB x 3 backups; the page only reads the current file,
|
||||
"Download full log" streams it raw. There's no log shipping/
|
||||
aggregation beyond this -- it's a single-container deployment, so
|
||||
the file *is* the log.
|
||||
|
||||
## Endpoints
|
||||
|
||||
Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
|
||||
`/admin`, `/frames/{id}` (Photos), `/frames/{id}/config`,
|
||||
`/admin`, `/admin/logs`, `/frames/{id}` (Photos), `/frames/{id}/config`,
|
||||
`/frames/{id}/stats`, `/m/{manage_token}`.
|
||||
|
||||
### Device protocol (`/frame/*` -- paths frozen; auth = `?id=` + `?token=`)
|
||||
|
||||
@@ -496,9 +496,16 @@ def render_frame(source: Image.Image, faces: list[dict] | None = None,
|
||||
return _transpose_and_pack(quantized, orientation)
|
||||
|
||||
|
||||
def _png_bytes(img: Image.Image) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
img.convert("RGB").save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def render_panel(regions: list[tuple[tuple[int, int, int, int], Image.Image]], orientation: str = "landscape",
|
||||
palette_rgb: list | None = None, color_boost: float = 1.0, contrast_boost: float = 1.0,
|
||||
dither_strength: float = 1.0, manage: dict | None = None, as_png: bool = False) -> bytes:
|
||||
dither_strength: float = 1.0, manage: dict | None = None, as_png: bool = False,
|
||||
capture_snapshot: bool = False) -> bytes | tuple[bytes, bytes]:
|
||||
"""The widget system's compositor -- generalizes render_frame's tail
|
||||
(paste, enhance once, overlay once, quantize once, pack once) from
|
||||
"compose one photo" to "paste N already-rendered regions, then run
|
||||
@@ -530,7 +537,14 @@ def render_panel(regions: list[tuple[tuple[int, int, int, int], Image.Image]], o
|
||||
as_png=True returns a normal browser-viewable PNG in logical (upright)
|
||||
orientation instead of packed native-panel bytes, same convention as
|
||||
render_preview_png -- used for the web UI's live "how it's displaying"
|
||||
thumbnail."""
|
||||
thumbnail.
|
||||
|
||||
capture_snapshot=True (only meaningful alongside as_png=False) returns
|
||||
(packed_bytes, png_bytes) instead of just packed_bytes -- both derived
|
||||
from the same already-quantized canvas, so a device-facing render can
|
||||
also persist a browser-viewable copy (see routers/device.py's
|
||||
_record_last_displayed) without re-running composition/quantization a
|
||||
second time."""
|
||||
logical_w, logical_h = logical_render_size(orientation)
|
||||
canvas = Image.new("RGB", (logical_w, logical_h), LETTERBOX_BG)
|
||||
for (x, y, w, h), region_img in regions:
|
||||
@@ -540,10 +554,11 @@ def render_panel(regions: list[tuple[tuple[int, int, int, int], Image.Image]], o
|
||||
fitted = _apply_manage_overlay(fitted, manage)
|
||||
quantized = _quantize(fitted, palette_rgb, dither_strength)
|
||||
if as_png:
|
||||
buf = io.BytesIO()
|
||||
quantized.convert("RGB").save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
return _transpose_and_pack(quantized, orientation)
|
||||
return _png_bytes(quantized)
|
||||
packed = _transpose_and_pack(quantized, orientation)
|
||||
if capture_snapshot:
|
||||
return packed, _png_bytes(quantized)
|
||||
return packed
|
||||
|
||||
|
||||
def render_preview_png(source: Image.Image, faces: list[dict] | None = None,
|
||||
@@ -559,14 +574,13 @@ def render_preview_png(source: Image.Image, faces: list[dict] | None = None,
|
||||
fitted = _enhance(_compose(source, faces, orientation, display_mode), color_boost, contrast_boost)
|
||||
fitted = _apply_manage_overlay(fitted, manage)
|
||||
quantized = _quantize(fitted, palette_rgb, dither_strength)
|
||||
buf = io.BytesIO()
|
||||
quantized.convert("RGB").save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
return _png_bytes(quantized)
|
||||
|
||||
|
||||
def render_placeholder(lines: list[str], qr_url: str | None = None,
|
||||
orientation: str = "landscape", palette_rgb: list | None = None,
|
||||
manage: dict | None = None, as_png: bool = False) -> bytes:
|
||||
manage: dict | None = None, as_png: bool = False,
|
||||
capture_snapshot: bool = False) -> bytes | tuple[bytes, bytes]:
|
||||
"""A readable full-panel message (plus an optional QR code) in the
|
||||
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
|
||||
@@ -574,7 +588,8 @@ def render_placeholder(lines: list[str], qr_url: str | None = None,
|
||||
|
||||
`manage`, same as render_frame's -- lets the manage button still work
|
||||
(at minimum, the scan-to-manage QR) on a frame that isn't configured
|
||||
yet."""
|
||||
yet. `capture_snapshot`, same as render_panel's -- (packed, png)
|
||||
instead of just packed."""
|
||||
margin = 24
|
||||
logical_w, logical_h = logical_render_size(orientation)
|
||||
img = Image.new("RGB", (logical_w, logical_h), (255, 255, 255))
|
||||
@@ -638,7 +653,8 @@ def render_placeholder(lines: list[str], qr_url: str | None = None,
|
||||
img = _apply_manage_overlay(img, manage)
|
||||
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
|
||||
if as_png:
|
||||
buf = io.BytesIO()
|
||||
quantized.convert("RGB").save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
return _transpose_and_pack(quantized, orientation)
|
||||
return _png_bytes(quantized)
|
||||
packed = _transpose_and_pack(quantized, orientation)
|
||||
if capture_snapshot:
|
||||
return packed, _png_bytes(quantized)
|
||||
return packed
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Root-logger configuration: a rotating file handler under the same
|
||||
/data volume as the sqlite DB and legacy config.json, so the admin log
|
||||
viewer has something to read and log content survives container
|
||||
restarts -- a redeploy happens on every push to main touching
|
||||
server/**, which would make an in-memory-only log buffer nearly
|
||||
useless in practice. Before this, the root logger had no handler at
|
||||
all, so every module's logger.info() call (user creation, claims,
|
||||
password resets, ...) was silently dropped rather than merely
|
||||
un-viewable -- this fixes that too, not just adds a viewer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
|
||||
LOG_PATH = Path(os.environ.get("LOG_PATH", "/data/server.log"))
|
||||
|
||||
|
||||
def configure_logging() -> None:
|
||||
LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
handler = RotatingFileHandler(LOG_PATH, maxBytes=2_000_000, backupCount=3)
|
||||
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
|
||||
root = logging.getLogger()
|
||||
root.addHandler(handler)
|
||||
root.setLevel(os.environ.get("LOG_LEVEL", "INFO"))
|
||||
|
||||
|
||||
def read_log_tail(lines: int) -> str:
|
||||
if not LOG_PATH.exists():
|
||||
return ""
|
||||
text = LOG_PATH.read_text(errors="replace")
|
||||
return "\n".join(text.splitlines()[-lines:])
|
||||
+38
-2
@@ -16,14 +16,15 @@ pre-database config.json deployment on first boot."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import select
|
||||
|
||||
from . import migration
|
||||
from . import logging_setup, migration
|
||||
from .auth import (
|
||||
browser_token_valid,
|
||||
current_user,
|
||||
@@ -38,12 +39,40 @@ from .routers.common import shell_context
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Before anything else logs: a handler exists to catch it, and it lands in
|
||||
# the same persistent volume the admin log viewer reads from.
|
||||
logging_setup.configure_logging()
|
||||
|
||||
# Schema + legacy-config import, before the first request is served.
|
||||
migration.run_migrations()
|
||||
|
||||
app = FastAPI(title="ESPresso Frame Server")
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def log_device_requests(request: Request, call_next):
|
||||
"""Access log for the firmware-facing /frame/* protocol -- the admin
|
||||
log viewer otherwise only ever shows exceptions (device.py logs
|
||||
those, not successful requests), so a slow-but-200 request or a
|
||||
device hammering a stale/wrong token leaves no trace at all. Logs
|
||||
the device id (query param, not the token -- never log credentials)
|
||||
and wall time, which is exactly what's needed to spot a request that
|
||||
blew past the firmware's fixed HTTP timeout without technically
|
||||
failing server-side."""
|
||||
if not request.url.path.startswith("/frame/"):
|
||||
return await call_next(request)
|
||||
start = time.monotonic()
|
||||
device_id = request.query_params.get("id", "") or "-"
|
||||
response = await call_next(request)
|
||||
elapsed_ms = (time.monotonic() - start) * 1000
|
||||
logger.info(
|
||||
"%s %s id=%s -> %d (%.0fms)",
|
||||
request.method, request.url.path, device_id, response.status_code, elapsed_ms,
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||
|
||||
app.include_router(device.router)
|
||||
@@ -60,6 +89,13 @@ def health() -> dict:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/sw.js")
|
||||
def service_worker() -> FileResponse:
|
||||
# Served from / rather than /static/sw.js so its default scope is the
|
||||
# whole app -- a SW can only ever control paths at or below its own URL.
|
||||
return FileResponse("app/static/sw.js", media_type="application/javascript")
|
||||
|
||||
|
||||
def _device_credential_redirect(request: Request, db, allow_legacy: bool) -> str | None:
|
||||
"""The on-frame manage QR points at the server root with the device's
|
||||
own credentials (new firmware: ?id=&token=; deployed firmware:
|
||||
|
||||
@@ -774,6 +774,30 @@ def _migration_29(conn) -> None:
|
||||
conn.execute(text("ALTER TABLE frames ADD COLUMN last_cycled_layout_id INTEGER"))
|
||||
|
||||
|
||||
def _migration_30(conn) -> None:
|
||||
""""Now displaying" (models.Frame.last_displayed_image/
|
||||
last_displayed_at) -- the web UI's header preview pair needs a frozen
|
||||
record of exactly what the last device-facing render actually sent,
|
||||
separate from the always-live "up next" re-render (see
|
||||
routers/device.py's _record_last_displayed, api_frames.py's
|
||||
/now-displaying endpoint). NULL/0.0 for every existing frame until
|
||||
its next real device fetch -- no behavior change to what's served,
|
||||
only a new thing recorded alongside it.
|
||||
|
||||
Guarded per-column, same reasoning as migration 26/27/29's own
|
||||
comments: frames is a table test_migrations.py's pre-widget-system
|
||||
replay tests leave un-dropped, so it keeps the fresh-install
|
||||
create_all() copy -- which already has these columns -- when those
|
||||
tests replay migrations 17+ from schema_version 16. Without the
|
||||
guard, replaying this migration there re-adds a column that's already
|
||||
there and SQLite raises "duplicate column name"."""
|
||||
existing = {c["name"] for c in inspect(conn).get_columns("frames")}
|
||||
if "last_displayed_image" not in existing:
|
||||
conn.execute(text("ALTER TABLE frames ADD COLUMN last_displayed_image BLOB"))
|
||||
if "last_displayed_at" not in existing:
|
||||
conn.execute(text("ALTER TABLE frames ADD COLUMN last_displayed_at REAL NOT NULL DEFAULT 0.0"))
|
||||
|
||||
|
||||
MIGRATIONS = [
|
||||
(1, _migration_1),
|
||||
(2, _migration_2),
|
||||
@@ -804,6 +828,7 @@ MIGRATIONS = [
|
||||
(27, _migration_27),
|
||||
(28, _migration_28),
|
||||
(29, _migration_29),
|
||||
(30, _migration_30),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -328,6 +328,16 @@ class Frame(Base):
|
||||
# starts over from the first one, same as an unset value.
|
||||
last_cycled_layout_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
|
||||
# -- "now displaying" (see routers/device.py's _record_last_displayed,
|
||||
# api_frames.py's /now-displaying endpoint) -- exactly what the last
|
||||
# device-facing render (/frame/image, /frame/advance, /frame/back, or
|
||||
# a global hold action) actually sent, as an upright PNG, so the web
|
||||
# UI's header preview can show it frozen alongside a live "up next"
|
||||
# re-render instead of conflating the two. NULL until a real device
|
||||
# has fetched at least once.
|
||||
last_displayed_image: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True, default=None)
|
||||
last_displayed_at: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
|
||||
# -- stats (flattened from the old nested FrameStats) --
|
||||
stats_first_seen: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
stats_device_wakes: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
@@ -244,6 +244,14 @@ def api_status(
|
||||
"device": {
|
||||
"last_seen": frame.last_seen or None,
|
||||
"overdue": bool(frame.last_seen and now - frame.last_seen > overdue_gap),
|
||||
# When the device is next expected to check in, per the same
|
||||
# sleep duration frame_config() actually hands it (see
|
||||
# device.py's /frame/config) -- not the raw overdue_gap above,
|
||||
# which is deliberately generous (OVERDUE_FACTOR) to avoid
|
||||
# false alarms during quiet hours rather than a best guess.
|
||||
"expected_next_checkin": (
|
||||
frame.last_seen + quiet_hours.effective_refresh_interval_s(frame) if frame.last_seen else None
|
||||
),
|
||||
"firmware_version": frame.device_firmware_version or None,
|
||||
"firmware_available": frame.firmware_available_version or None,
|
||||
"battery": (
|
||||
@@ -271,6 +279,25 @@ def api_frame_preview(
|
||||
return Response(content=png, media_type="image/png")
|
||||
|
||||
|
||||
@router.get("/api/frames/{frame_id}/now-displaying")
|
||||
def api_frame_now_displaying(frame: Frame = Depends(require_frame_view)):
|
||||
"""Exactly what was last actually sent to this frame's device (see
|
||||
routers/device.py's _record_last_displayed) -- the frozen "now
|
||||
displaying" half of the header preview pair, as opposed to /preview's
|
||||
always-live "up next" re-render. 404 (not a placeholder image) until
|
||||
the device has fetched at least once, so the web UI can show its own
|
||||
empty state instead of a broken image. X-Displayed-At carries the
|
||||
capture time (unix seconds) for a "N ago" label -- a header, not the
|
||||
body, since the body is the raw PNG bytes."""
|
||||
if frame.last_displayed_image is None:
|
||||
raise HTTPException(404, "This frame hasn't displayed anything yet")
|
||||
return Response(
|
||||
content=frame.last_displayed_image,
|
||||
media_type="image/png",
|
||||
headers={"X-Displayed-At": str(frame.last_displayed_at)},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/frames/{frame_id}/battery-log")
|
||||
def api_battery_log(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
|
||||
rows = db.execute(
|
||||
@@ -384,6 +411,7 @@ def api_firmware_check(
|
||||
"board": frame.device_board_variant or None,
|
||||
"latest_version": frame.firmware_gitea_latest_version or None,
|
||||
"staged_version": frame.firmware_available_version or None,
|
||||
"running_version": frame.device_firmware_version or None,
|
||||
"update_available": update_available,
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,12 @@ MIN_ESTIMATE_SAMPLES = 5 # discharge steps needed before trusting the average
|
||||
# single noisy reading needs rejecting at the per-wake-drop level, not
|
||||
# just at the recharge-detection level.
|
||||
OUTLIER_MODIFIED_Z_THRESHOLD = 3.5
|
||||
# Readings considered on each side of a given reading when
|
||||
# _smooth_percents looks for local outliers. Needs to be at least half
|
||||
# the length of the longest bad-reading burst a noisy divider produces
|
||||
# (observed up to ~4 consecutive corrupted reports on one frame) so the
|
||||
# good neighbors still outnumber the bad ones in the window.
|
||||
BATTERY_SMOOTHING_WINDOW = 4
|
||||
|
||||
# "Overdue" threshold multiplier: the device should check in roughly every
|
||||
# refresh_interval_s; give it half again as long before flagging it.
|
||||
@@ -179,6 +185,50 @@ def _reject_outlier_drops(steps: list[tuple[int, float]]) -> list[tuple[int, flo
|
||||
return kept or steps # never filter down to nothing
|
||||
|
||||
|
||||
def _smooth_percents(percents: list[int]) -> list[float]:
|
||||
"""Replaces any reading that's a wild outlier against its own local
|
||||
neighborhood with that neighborhood's median, before per-wake drop
|
||||
steps are ever built from the series.
|
||||
|
||||
_reject_outlier_drops (above) only catches a bad reading by how much
|
||||
it distorts the *steps* immediately on either side of it -- which is
|
||||
exactly what one isolated glitch does, but a 1M-ohm divider (see
|
||||
firmware/main/battery.c) doesn't always misfire in isolation: several
|
||||
consecutive reports can drift or glitch together (a multi-minute
|
||||
crawl from 68 up into the high 70s with nothing charging, or a run of
|
||||
several ~40 reports spliced into an otherwise flat ~53 run). A step
|
||||
computed *between* two bad readings in the same burst looks like an
|
||||
ordinary small change, not an outlier, so it sails through
|
||||
_reject_outlier_drops untouched.
|
||||
|
||||
A Hampel identifier catches that instead: each reading is compared to
|
||||
the median of its own local window (not the whole series), using the
|
||||
same MAD-based modified z-score as _reject_outlier_drops so this
|
||||
adapts to how noisy a given frame's sensor actually is rather than a
|
||||
fixed percent-point cutoff. A window of BATTERY_SMOOTHING_WINDOW
|
||||
reports on each side tolerates a bad burst up to that long while
|
||||
still being outvoted by the surrounding good readings."""
|
||||
n = len(percents)
|
||||
smoothed = list(percents)
|
||||
for i in range(n):
|
||||
lo = max(0, i - BATTERY_SMOOTHING_WINDOW)
|
||||
hi = min(n, i + BATTERY_SMOOTHING_WINDOW + 1)
|
||||
neighborhood = percents[lo:hi]
|
||||
median = statistics.median(neighborhood)
|
||||
abs_devs = [abs(v - median) for v in neighborhood]
|
||||
# Unlike _reject_outlier_drops, no mean-of-abs-devs fallback here:
|
||||
# a burst can be a big enough share of this small a window that
|
||||
# the mean itself gets dragged up by the very values being
|
||||
# tested, hiding them. A flat 1-percentage-point floor -- this
|
||||
# project's smallest real unit of noise -- keeps the test from
|
||||
# dividing by zero without being skewed by the burst it's
|
||||
# checking.
|
||||
mad = statistics.median(abs_devs) or 1
|
||||
if abs(0.6745 * (percents[i] - median) / mad) > OUTLIER_MODIFIED_Z_THRESHOLD:
|
||||
smoothed[i] = median
|
||||
return smoothed
|
||||
|
||||
|
||||
def battery_estimate_s(frame: Frame, db: Session) -> int | None:
|
||||
"""Remaining-time estimate from a recency-weighted average of the
|
||||
*per-wake* percent drop, over the last BATTERY_ESTIMATE_SAMPLE_COUNT
|
||||
@@ -189,18 +239,21 @@ def battery_estimate_s(frame: Frame, db: Session) -> int | None:
|
||||
|
||||
Consecutive reports are assumed to be consecutive wakes (firmware
|
||||
reports battery on every wake while on battery), so each step's
|
||||
(prev_percent - next_percent) is that wake's cost. A step where
|
||||
percent went *up* is a recharge, not negative drain, and is skipped
|
||||
entirely rather than folded in as a weird outlier; a flat step
|
||||
(0% change) still counts as a real, cheap wake -- excluding those
|
||||
would systematically overstate the per-wake cost by only counting
|
||||
the wakes that happened to tick the percentage down. The remaining
|
||||
steps then get one more pass, _reject_outlier_drops, to catch the
|
||||
single-noisy-reading case that "percent went up" alone can't (see
|
||||
that function's docstring). Steps are weighted linearly by recency
|
||||
(step i of n gets weight i, 1-indexed) so a recent change in usage
|
||||
pattern shows up quickly instead of being washed out by a long flat
|
||||
history.
|
||||
(prev_percent - next_percent) is that wake's cost. Raw percents go
|
||||
through _smooth_percents first, which corrects readings (including
|
||||
short bursts of them) that are wild outliers against their own local
|
||||
neighborhood -- see that function's docstring for why that catches
|
||||
noise shapes _reject_outlier_drops can't. A step where percent went
|
||||
*up* is a recharge, not negative drain, and is skipped entirely
|
||||
rather than folded in as a weird outlier; a flat step (0% change)
|
||||
still counts as a real, cheap wake -- excluding those would
|
||||
systematically overstate the per-wake cost by only counting the
|
||||
wakes that happened to tick the percentage down. The remaining steps
|
||||
then get one more pass, _reject_outlier_drops, to catch whatever
|
||||
single-noisy-reading shape survives smoothing (see that function's
|
||||
docstring). Steps are weighted linearly by recency (step i of n gets
|
||||
weight i, 1-indexed) so a recent change in usage pattern shows up
|
||||
quickly instead of being washed out by a long flat history.
|
||||
|
||||
The resulting %/wake rate is then converted to wall-clock time using
|
||||
the frame's *current* refresh_interval_s and quiet-hours settings
|
||||
@@ -220,6 +273,7 @@ def battery_estimate_s(frame: Frame, db: Session) -> int | None:
|
||||
if len(rows) < MIN_ESTIMATE_SAMPLES + 1:
|
||||
return None
|
||||
percents = list(reversed(rows)) # chronological order
|
||||
percents = _smooth_percents(percents)
|
||||
|
||||
steps: list[tuple[int, float]] = [] # (recency_weight, drop_pct)
|
||||
for i in range(1, len(percents)):
|
||||
|
||||
+105
-27
@@ -14,6 +14,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import FileResponse, Response
|
||||
@@ -23,7 +24,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from .. import grid, mail, quiet_hours
|
||||
from ..auth import get_server_settings, require_device
|
||||
from ..db import frame_locked, get_db
|
||||
from ..db import SessionLocal, frame_locked, get_db
|
||||
from ..firmware import firmware_path
|
||||
from ..global_actions import GLOBAL_ACTIONS
|
||||
from ..image_pipeline import draw_widget_border, logical_render_size, render_panel, render_placeholder, resolve_border_color
|
||||
@@ -43,7 +44,7 @@ router = APIRouter()
|
||||
|
||||
|
||||
def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = None,
|
||||
as_png: bool = False) -> bytes:
|
||||
as_png: bool = False, capture_snapshot: bool = False) -> bytes | tuple[bytes, bytes]:
|
||||
"""What an unclaimed or widget-less frame displays instead of real
|
||||
content -- instructions with a QR, rendered at 200 so the device
|
||||
treats it as a perfectly normal image and never error-loops. The
|
||||
@@ -60,6 +61,7 @@ def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = Non
|
||||
palette_rgb=frame.palette_rgb,
|
||||
manage=manage,
|
||||
as_png=as_png,
|
||||
capture_snapshot=capture_snapshot,
|
||||
)
|
||||
if frame.owner_user_id is None:
|
||||
return render_placeholder(
|
||||
@@ -68,6 +70,7 @@ def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = Non
|
||||
palette_rgb=frame.palette_rgb,
|
||||
manage=manage,
|
||||
as_png=as_png,
|
||||
capture_snapshot=capture_snapshot,
|
||||
)
|
||||
return render_placeholder(
|
||||
["Almost there!", "Add a widget for this frame at", base],
|
||||
@@ -76,11 +79,46 @@ def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = Non
|
||||
palette_rgb=frame.palette_rgb,
|
||||
manage=manage,
|
||||
as_png=as_png,
|
||||
capture_snapshot=capture_snapshot,
|
||||
)
|
||||
|
||||
|
||||
def _render_one_widget(frame_id: int, widget_id: int, orientation: str, panel_w: int, panel_h: int,
|
||||
cell: tuple[int, int, int, int], is_normal_wake: bool,
|
||||
) -> tuple[tuple[int, int, int, int], object] | None:
|
||||
"""Renders exactly one widget on its own DB session, so several of
|
||||
these can run concurrently in a thread pool -- see app/db.py's
|
||||
module docstring: handlers already run multi-threaded (sync
|
||||
handlers in FastAPI's threadpool, one process), and frame_locked/
|
||||
widget_locked's per-frame threading.Lock is what makes that safe,
|
||||
not anything about which Session object is in play. A SQLAlchemy
|
||||
Session itself is never safe to share across threads, so each
|
||||
concurrent render gets a fresh one rather than reusing the
|
||||
request's. Most of a widget's render time is spent waiting on an
|
||||
external call (Immich, a weather provider, CalDAV) with the DB
|
||||
untouched, which is exactly the time this buys back."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
frame = db.get(Frame, frame_id)
|
||||
widget = db.get(Widget, widget_id)
|
||||
if widget is None:
|
||||
return None # deleted between the listing query and this fetch -- skip it, not a 500
|
||||
module = WIDGET_TYPES.get(widget.widget_type)
|
||||
if module is None:
|
||||
return None # unrecognized widget_type -- shouldn't happen, skip defensively rather than 500
|
||||
px, py, pw, ph = grid.cell_to_pixels(orientation, panel_w, panel_h, cell)
|
||||
img = module.render(db, frame, widget, pw, ph, is_normal_wake=is_normal_wake)
|
||||
draw_widget_border(
|
||||
img, widget.border_style, widget.border_thickness,
|
||||
resolve_border_color(widget.border_color_index, frame.palette_rgb),
|
||||
)
|
||||
return (px, py, pw, ph), img
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _render_widgets(db: Session, frame: Frame, manage: dict | None, is_normal_wake: bool,
|
||||
as_png: bool = False) -> bytes:
|
||||
as_png: bool = False, capture_snapshot: bool = False) -> bytes | tuple[bytes, bytes]:
|
||||
"""The widget-system compositor: renders every widget on this frame
|
||||
into its own region (see app/grid.py for grid-cell -> pixel math),
|
||||
draws that widget's own optional border directly onto its region
|
||||
@@ -89,34 +127,46 @@ def _render_widgets(db: Session, frame: Frame, manage: dict | None, is_normal_wa
|
||||
image_pipeline.render_panel for the single shared paste/enhance/
|
||||
overlay/quantize/pack pass. Replaces the old per-mode RENDERERS
|
||||
dict -- a frame can now show several widgets at once instead of
|
||||
exactly one mode owning the whole panel."""
|
||||
exactly one mode owning the whole panel.
|
||||
|
||||
Widgets render concurrently (_render_one_widget, each on its own DB
|
||||
session) rather than one at a time -- a layout with several
|
||||
network-backed widgets (photos, weather, calendar) previously paid
|
||||
their fetch latency serially, which could push a single /frame/*
|
||||
response past the firmware's fixed HTTP timeout and show a
|
||||
misleading "server failed" status screen even though the server
|
||||
was simply still working. Futures are submitted in sort_order and
|
||||
collected in that same order (not completion order) -- overlapping
|
||||
widgets must still paint in the original z-order."""
|
||||
all_widgets = db.scalars(
|
||||
select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order)
|
||||
).all()
|
||||
panel_w, panel_h = logical_render_size(frame.orientation)
|
||||
regions = []
|
||||
for widget in all_widgets:
|
||||
module = WIDGET_TYPES.get(widget.widget_type)
|
||||
if module is None:
|
||||
continue # unrecognized widget_type -- shouldn't happen, skip defensively rather than 500
|
||||
px, py, pw, ph = grid.cell_to_pixels(
|
||||
frame.orientation, panel_w, panel_h, (widget.x, widget.y, widget.w, widget.h)
|
||||
)
|
||||
img = module.render(db, frame, widget, pw, ph, is_normal_wake=is_normal_wake)
|
||||
draw_widget_border(
|
||||
img, widget.border_style, widget.border_thickness,
|
||||
resolve_border_color(widget.border_color_index, frame.palette_rgb),
|
||||
)
|
||||
regions.append(((px, py, pw, ph), img))
|
||||
if all_widgets:
|
||||
with ThreadPoolExecutor(max_workers=min(len(all_widgets), 8)) as pool:
|
||||
futures = [
|
||||
pool.submit(
|
||||
_render_one_widget, frame.id, widget.id, frame.orientation, panel_w, panel_h,
|
||||
(widget.x, widget.y, widget.w, widget.h), is_normal_wake,
|
||||
)
|
||||
for widget in all_widgets
|
||||
]
|
||||
for future in futures:
|
||||
result = future.result()
|
||||
if result is not None:
|
||||
regions.append(result)
|
||||
return render_panel(
|
||||
regions, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
|
||||
color_boost=frame.color_boost, contrast_boost=frame.contrast_boost,
|
||||
dither_strength=frame.dither_strength, manage=manage, as_png=as_png,
|
||||
capture_snapshot=capture_snapshot,
|
||||
)
|
||||
|
||||
|
||||
def _render_frame_content(db: Session, frame: Frame, request: Request | None, manage: dict | None,
|
||||
is_normal_wake: bool, as_png: bool = False) -> bytes:
|
||||
is_normal_wake: bool, as_png: bool = False,
|
||||
capture_snapshot: bool = False) -> bytes | tuple[bytes, bytes]:
|
||||
"""The top-level "what does this frame show right now" entry point.
|
||||
An unclaimed frame or one with no widgets yet gets the setup
|
||||
placeholder (needs `request` for its QR URLs -- only available on the
|
||||
@@ -134,11 +184,11 @@ def _render_frame_content(db: Session, frame: Frame, request: Request | None, ma
|
||||
if request is None:
|
||||
return render_placeholder(
|
||||
["Almost there!"], orientation=frame.orientation, palette_rgb=frame.palette_rgb,
|
||||
manage=manage, as_png=as_png,
|
||||
manage=manage, as_png=as_png, capture_snapshot=capture_snapshot,
|
||||
)
|
||||
return _setup_placeholder(frame, request, manage=manage, as_png=as_png)
|
||||
return _setup_placeholder(frame, request, manage=manage, as_png=as_png, capture_snapshot=capture_snapshot)
|
||||
|
||||
return _render_widgets(db, frame, manage, is_normal_wake, as_png=as_png)
|
||||
return _render_widgets(db, frame, manage, is_normal_wake, as_png=as_png, capture_snapshot=capture_snapshot)
|
||||
|
||||
|
||||
def render_frame_preview_png(db: Session, frame: Frame, request: Request) -> bytes:
|
||||
@@ -245,6 +295,17 @@ def _manage_flag(request: Request) -> bool:
|
||||
return request.query_params.get("manage") == "1"
|
||||
|
||||
|
||||
def _record_last_displayed(db: Session, frame: Frame, png_snapshot: bytes) -> None:
|
||||
"""Persists exactly what a device-facing render just sent (upright
|
||||
PNG, manage overlay included if present -- whatever's actually on the
|
||||
panel) as this frame's "now displaying" snapshot, the frozen half of
|
||||
the web UI's header preview pair (see api_frames.py's /now-displaying
|
||||
endpoint and its always-live "up next" counterpart, /preview)."""
|
||||
with frame_locked(db, frame.id) as locked:
|
||||
locked.last_displayed_image = png_snapshot
|
||||
locked.last_displayed_at = time.time()
|
||||
|
||||
|
||||
@router.get("/frame/image")
|
||||
def frame_image(
|
||||
request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)
|
||||
@@ -262,9 +323,14 @@ def frame_image(
|
||||
?manage=1 (the manage button) composites the manage overlay onto
|
||||
whatever this would have returned anyway -- see build_manage_content.
|
||||
This is also the "normal wake" that resets any calendar widget's
|
||||
browse position back to today (see app/widgets/calendar.py)."""
|
||||
browse position back to today (see app/widgets/calendar.py).
|
||||
|
||||
Also records what's returned as this frame's "now displaying"
|
||||
snapshot (see _record_last_displayed) -- every other device-facing
|
||||
render endpoint below does the same."""
|
||||
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
|
||||
content = _render_frame_content(db, frame, request, manage, is_normal_wake=True)
|
||||
content, snapshot = _render_frame_content(db, frame, request, manage, is_normal_wake=True, capture_snapshot=True)
|
||||
_record_last_displayed(db, frame, snapshot)
|
||||
return Response(content=content, media_type="application/octet-stream")
|
||||
|
||||
|
||||
@@ -277,7 +343,10 @@ def frame_advance(request: Request, frame: Frame = Depends(require_device), db:
|
||||
device's next-photo button."""
|
||||
_run_button_actions(db, frame, "next")
|
||||
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
|
||||
content = _render_frame_content(db, frame, request=None, manage=manage, is_normal_wake=False)
|
||||
content, snapshot = _render_frame_content(
|
||||
db, frame, request=None, manage=manage, is_normal_wake=False, capture_snapshot=True
|
||||
)
|
||||
_record_last_displayed(db, frame, snapshot)
|
||||
return Response(content=content, media_type="application/octet-stream")
|
||||
|
||||
|
||||
@@ -288,7 +357,10 @@ def frame_back(request: Request, frame: Frame = Depends(require_device), db: Ses
|
||||
with nothing to go back to. Used by the device's back-photo button."""
|
||||
_run_button_actions(db, frame, "back")
|
||||
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
|
||||
content = _render_frame_content(db, frame, request=None, manage=manage, is_normal_wake=False)
|
||||
content, snapshot = _render_frame_content(
|
||||
db, frame, request=None, manage=manage, is_normal_wake=False, capture_snapshot=True
|
||||
)
|
||||
_record_last_displayed(db, frame, snapshot)
|
||||
return Response(content=content, media_type="application/octet-stream")
|
||||
|
||||
|
||||
@@ -303,7 +375,10 @@ def frame_global_next(request: Request, frame: Frame = Depends(require_device),
|
||||
firmware/main/next_button.c for the short/long split."""
|
||||
_run_global_action(db, frame, "next")
|
||||
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
|
||||
content = _render_frame_content(db, frame, request=None, manage=manage, is_normal_wake=False)
|
||||
content, snapshot = _render_frame_content(
|
||||
db, frame, request=None, manage=manage, is_normal_wake=False, capture_snapshot=True
|
||||
)
|
||||
_record_last_displayed(db, frame, snapshot)
|
||||
return Response(content=content, media_type="application/octet-stream")
|
||||
|
||||
|
||||
@@ -312,7 +387,10 @@ def frame_global_back(request: Request, frame: Frame = Depends(require_device),
|
||||
"""The mirror of /frame/global-next, for a held BACK button."""
|
||||
_run_global_action(db, frame, "back")
|
||||
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
|
||||
content = _render_frame_content(db, frame, request=None, manage=manage, is_normal_wake=False)
|
||||
content, snapshot = _render_frame_content(
|
||||
db, frame, request=None, manage=manage, is_normal_wake=False, capture_snapshot=True
|
||||
)
|
||||
_record_last_displayed(db, frame, snapshot)
|
||||
return Response(content=content, media_type="application/octet-stream")
|
||||
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import logging
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -35,6 +35,7 @@ from ..auth import (
|
||||
verify_password,
|
||||
)
|
||||
from ..db import get_db
|
||||
from ..logging_setup import LOG_PATH, read_log_tail
|
||||
from ..models import Frame, PasswordResetToken, PendingClaim, User, UserFrame
|
||||
from .common import valid_http_url
|
||||
|
||||
@@ -569,6 +570,7 @@ def _render_admin(request: Request, db: Session, admin: User, notice: str | None
|
||||
"smtp": get_server_settings(db),
|
||||
"notice": notice,
|
||||
"error": error,
|
||||
"active_admin_tab": "main",
|
||||
})
|
||||
return templates.TemplateResponse("admin.html", ctx)
|
||||
|
||||
@@ -583,6 +585,35 @@ def admin_page(request: Request, db: Session = Depends(get_db)):
|
||||
return _render_admin(request, db, user)
|
||||
|
||||
|
||||
@router.get("/admin/logs", response_class=HTMLResponse)
|
||||
def admin_logs_page(request: Request, lines: int = 500, db: Session = Depends(get_db)):
|
||||
user = current_user(request, db)
|
||||
if user is None:
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
if not user.is_admin:
|
||||
raise HTTPException(403, "Admin only")
|
||||
from .common import shell_context
|
||||
|
||||
lines = max(50, min(lines, 5000))
|
||||
ctx = shell_context(request, db, user, active_nav="admin")
|
||||
ctx.update({
|
||||
"active_admin_tab": "logs",
|
||||
"log_exists": LOG_PATH.exists(),
|
||||
"log_path": str(LOG_PATH),
|
||||
"log_lines": lines,
|
||||
"log_text": read_log_tail(lines),
|
||||
})
|
||||
return templates.TemplateResponse("admin_logs.html", ctx)
|
||||
|
||||
|
||||
@router.get("/admin/logs/download")
|
||||
def admin_logs_download(request: Request, db: Session = Depends(get_db)):
|
||||
_require_admin_page(request, db)
|
||||
if not LOG_PATH.exists():
|
||||
raise HTTPException(404, "No log file yet")
|
||||
return FileResponse(LOG_PATH, filename="server.log", media_type="text/plain")
|
||||
|
||||
|
||||
@router.post("/admin/users", response_class=HTMLResponse)
|
||||
def admin_create_user(
|
||||
request: Request,
|
||||
|
||||
@@ -58,6 +58,15 @@
|
||||
}
|
||||
})();
|
||||
|
||||
// Registering this is what makes Chrome/Android offer the "Add to Home
|
||||
// screen" install prompt -- a manifest link alone isn't enough. Served
|
||||
// from /sw.js (not /static/sw.js) so its scope is the whole app.
|
||||
if ("serviceWorker" in navigator) {
|
||||
window.addEventListener("load", function () {
|
||||
navigator.serviceWorker.register("/sw.js");
|
||||
});
|
||||
}
|
||||
|
||||
// Shared display names for widget_type, everywhere one shows up in the
|
||||
// UI (Layout canvas, Add-a-widget buttons, button-assignment dropdowns).
|
||||
const WIDGET_LABELS = {
|
||||
|
||||
@@ -18,6 +18,14 @@ function renderDeviceStatusBar(device) {
|
||||
}
|
||||
const now = Date.now() / 1000;
|
||||
const rows = [];
|
||||
if (device.expected_next_checkin) {
|
||||
const remaining = device.expected_next_checkin - now;
|
||||
rows.push([
|
||||
'Expected in',
|
||||
remaining > 0 ? `~${formatDuration(remaining)}` : 'Any moment',
|
||||
device.overdue,
|
||||
]);
|
||||
}
|
||||
const ago = formatDuration(Math.max(0, now - device.last_seen));
|
||||
rows.push(['Last seen', `${ago} ago`, device.overdue]);
|
||||
if (device.firmware_version) {
|
||||
|
||||
@@ -339,8 +339,15 @@ async function loadFirmwareCheck(force) {
|
||||
statusEl.textContent = 'Waiting for the frame to check in before it can look up the right build.';
|
||||
btn.style.display = 'none';
|
||||
} else if (data.update_available) {
|
||||
statusEl.textContent = `Update available: v${data.latest_version}.`;
|
||||
statusEl.textContent = `Update available: v${data.latest_version}` +
|
||||
(data.running_version ? ` (currently running v${data.running_version}).` : '.');
|
||||
btn.style.display = 'inline-block';
|
||||
} else if (data.latest_version && data.running_version && data.running_version !== data.latest_version) {
|
||||
// Already staged (or auto-applied) but the frame hasn't woken up
|
||||
// and picked it up yet -- not "up to date" until it actually has.
|
||||
statusEl.textContent = `v${data.latest_version} staged -- applies next time the frame wakes ` +
|
||||
`(currently running v${data.running_version}).`;
|
||||
btn.style.display = 'none';
|
||||
} else if (data.latest_version) {
|
||||
statusEl.textContent = `Up to date (v${data.latest_version}).`;
|
||||
btn.style.display = 'none';
|
||||
|
||||
@@ -58,48 +58,31 @@
|
||||
});
|
||||
})();
|
||||
|
||||
// Live "how it's displaying" thumbnail. A real composite render (same
|
||||
// pipeline /frame/image uses), not a cached snapshot, so it's on a slow
|
||||
// poll rather than something tighter like the 10s device-status poll --
|
||||
// no need to hit Immich/calendar/whiteboard sources that often just for
|
||||
// a header thumbnail. Click enlarges it in a dialog (which also fetches
|
||||
// a fresh render); clicking the enlarged image refreshes it again.
|
||||
// Now-displaying / up-next header preview pair. "Up next" is a real
|
||||
// composite render (same pipeline /frame/image uses), not a cached
|
||||
// snapshot, so it's on a slow poll rather than something tighter like
|
||||
// the 10s device-status poll -- no need to hit Immich/calendar/
|
||||
// whiteboard sources that often just for a header thumbnail, and it
|
||||
// shows layout edits live as they're made. "Now displaying" is the
|
||||
// opposite: exactly the bytes last actually sent to the device (see
|
||||
// routers/device.py's _record_last_displayed), frozen until the
|
||||
// device's next real wake even while the layout is being edited live --
|
||||
// that contrast is the point of showing both side by side.
|
||||
(function () {
|
||||
var thumb = document.getElementById('frame-preview-thumb');
|
||||
var dialog = document.getElementById('frame-preview-dialog');
|
||||
var bigImg = document.getElementById('frame-preview-dialog-img');
|
||||
var closeBtn = document.getElementById('frame-preview-dialog-close');
|
||||
if (!thumb || !window.FRAME_BASE_API) return;
|
||||
var nextThumb = document.getElementById('frame-preview-thumb');
|
||||
var nextDialog = document.getElementById('frame-preview-dialog');
|
||||
var nextBigImg = document.getElementById('frame-preview-dialog-img');
|
||||
var nextCloseBtn = document.getElementById('frame-preview-dialog-close');
|
||||
var nowThumb = document.getElementById('frame-preview-now-thumb');
|
||||
var nowDialog = document.getElementById('frame-preview-now-dialog');
|
||||
var nowBigImg = document.getElementById('frame-preview-now-dialog-img');
|
||||
var nowCloseBtn = document.getElementById('frame-preview-now-dialog-close');
|
||||
if (!nextThumb || !window.FRAME_BASE_API) return;
|
||||
|
||||
function previewUrl() {
|
||||
return `${window.FRAME_BASE_API}/preview?t=${Date.now()}`;
|
||||
}
|
||||
function refreshThumb() {
|
||||
thumb.src = previewUrl();
|
||||
}
|
||||
// Opening the dialog (or clicking the big image inside it) fetches a
|
||||
// fresh render and keeps the header thumb in sync, so this single path
|
||||
// covers both "enlarge" and the old click-to-refresh behavior.
|
||||
function refreshBig() {
|
||||
var url = previewUrl();
|
||||
bigImg.src = url;
|
||||
thumb.src = url;
|
||||
}
|
||||
|
||||
thumb.addEventListener('click', function () {
|
||||
if (!dialog) { refreshThumb(); return; }
|
||||
refreshBig();
|
||||
dialog.showModal();
|
||||
});
|
||||
refreshThumb();
|
||||
setInterval(refreshThumb, 60000);
|
||||
|
||||
if (dialog && bigImg && closeBtn) {
|
||||
bigImg.addEventListener('click', refreshBig);
|
||||
closeBtn.addEventListener('click', function () { dialog.close(); });
|
||||
// Same backdrop-click-to-close trick as #widget-dialog: a click that
|
||||
// lands on the dialog element itself (not its content box) means the
|
||||
// backdrop was hit.
|
||||
// Same backdrop-click-to-close trick as #widget-dialog: a click that
|
||||
// lands on the dialog element itself (not its content box) means the
|
||||
// backdrop was hit.
|
||||
function closeOnBackdropClick(dialog) {
|
||||
dialog.addEventListener('click', function (e) {
|
||||
if (e.target !== dialog) return;
|
||||
var rect = dialog.getBoundingClientRect();
|
||||
@@ -107,4 +90,80 @@
|
||||
if (!inside) dialog.close();
|
||||
});
|
||||
}
|
||||
|
||||
function nextUrl() {
|
||||
return `${window.FRAME_BASE_API}/preview?t=${Date.now()}`;
|
||||
}
|
||||
function refreshNext() {
|
||||
nextThumb.src = nextUrl();
|
||||
}
|
||||
// Opening the dialog (or clicking the big image inside it) fetches a
|
||||
// fresh render and keeps the header thumb in sync, so this single path
|
||||
// covers both "enlarge" and the old click-to-refresh behavior.
|
||||
function refreshNextBig() {
|
||||
var url = nextUrl();
|
||||
nextBigImg.src = url;
|
||||
nextThumb.src = url;
|
||||
}
|
||||
|
||||
nextThumb.addEventListener('click', function () {
|
||||
if (!nextDialog) { refreshNext(); return; }
|
||||
refreshNextBig();
|
||||
nextDialog.showModal();
|
||||
});
|
||||
refreshNext();
|
||||
setInterval(refreshNext, 60000);
|
||||
|
||||
if (nextDialog && nextBigImg && nextCloseBtn) {
|
||||
nextBigImg.addEventListener('click', refreshNextBig);
|
||||
nextCloseBtn.addEventListener('click', function () { nextDialog.close(); });
|
||||
closeOnBackdropClick(nextDialog);
|
||||
}
|
||||
|
||||
// "Now displaying" fetches rather than sets .src directly: it needs to
|
||||
// tell a 404 (device hasn't fetched yet) apart from a real image to
|
||||
// show its own empty state instead of a broken-image icon, and reads
|
||||
// the capture time off X-Displayed-At for the "N ago" tooltip.
|
||||
if (nowThumb) {
|
||||
var nowObjectUrl = null;
|
||||
function refreshNow() {
|
||||
fetch(`${window.FRAME_BASE_API}/now-displaying?t=${Date.now()}`)
|
||||
.then(function (resp) {
|
||||
if (!resp.ok) {
|
||||
nowThumb.classList.add('frame-preview-thumb-empty');
|
||||
nowThumb.removeAttribute('src');
|
||||
nowThumb.title = "Now displaying -- hasn't shown anything yet";
|
||||
return null;
|
||||
}
|
||||
var displayedAt = resp.headers.get('X-Displayed-At');
|
||||
nowThumb.title = displayedAt
|
||||
? `Now displaying -- ${formatDuration(Math.max(0, Date.now() / 1000 - parseFloat(displayedAt)))} ago -- click to enlarge`
|
||||
: 'Now displaying -- click to enlarge';
|
||||
return resp.blob();
|
||||
})
|
||||
.then(function (blob) {
|
||||
if (!blob) return;
|
||||
nowThumb.classList.remove('frame-preview-thumb-empty');
|
||||
var url = URL.createObjectURL(blob);
|
||||
var old = nowObjectUrl;
|
||||
nowObjectUrl = url;
|
||||
nowThumb.src = url;
|
||||
if (old) URL.revokeObjectURL(old);
|
||||
})
|
||||
.catch(function () { /* transient failure -- leave the last-known thumb showing */ });
|
||||
}
|
||||
|
||||
nowThumb.addEventListener('click', function () {
|
||||
if (nowThumb.classList.contains('frame-preview-thumb-empty') || !nowDialog) return;
|
||||
nowBigImg.src = nowThumb.src;
|
||||
nowDialog.showModal();
|
||||
});
|
||||
refreshNow();
|
||||
setInterval(refreshNow, 60000);
|
||||
|
||||
if (nowDialog && nowCloseBtn) {
|
||||
nowCloseBtn.addEventListener('click', function () { nowDialog.close(); });
|
||||
closeOnBackdropClick(nowDialog);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 501 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.1 KiB |
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "ESPresso Frame",
|
||||
"short_name": "ESPresso",
|
||||
"description": "Manage your e-ink photo frames.",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f5f6f8",
|
||||
"theme_color": "#2563eb",
|
||||
"icons": [
|
||||
{ "src": "/static/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "/static/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// Presence-only service worker: satisfies the "installable" requirement
|
||||
// (Chrome/Android in particular checks for a controlling SW with a fetch
|
||||
// handler) without adding an offline cache -- every request just goes to
|
||||
// the network as normal. Served from / (see app/main.py's /sw.js route)
|
||||
// so its scope covers the whole app, not just /static/.
|
||||
self.addEventListener("install", () => self.skipWaiting());
|
||||
self.addEventListener("activate", (event) => event.waitUntil(self.clients.claim()));
|
||||
self.addEventListener("fetch", (event) => event.respondWith(fetch(event.request)));
|
||||
@@ -156,6 +156,24 @@ button.linklike:hover { color: var(--text); background: none; }
|
||||
.admin-inline-form { display: flex; gap: 8px; align-items: center; margin-top: 6px; }
|
||||
.admin-inline-form input[type="text"] { margin-top: 0; flex: 1; }
|
||||
|
||||
.log-view-controls { display: flex; gap: 10px; align-items: center; margin: 10px 0; font-size: 13px; }
|
||||
.log-view-controls a:not(.btn-inline) { color: var(--text-muted); }
|
||||
.log-view-controls a.active { color: var(--accent); font-weight: 600; }
|
||||
.log-view {
|
||||
background: var(--surface-alt);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
max-height: 65vh;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
font-family: ui-monospace, "SF Mono", Consolas, monospace;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
h2.card-title, summary.card-title {
|
||||
font-size: 14.5px;
|
||||
font-weight: 650;
|
||||
@@ -243,6 +261,36 @@ input[type="range"] {
|
||||
}
|
||||
.card + .card { margin-top: 20px; }
|
||||
|
||||
/* Installed as a standalone app, the boxed-card look reads as "still a
|
||||
website" -- flatten page-level cards into the page background so it
|
||||
feels native. Cards inside the widget dialog keep their box: they're
|
||||
grouping subsections of one form, not top-level page furniture. */
|
||||
@media (display-mode: standalone) {
|
||||
.card {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
padding: 20px 0;
|
||||
}
|
||||
.card + .card {
|
||||
margin-top: 4px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
#widget-dialog-body .card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 20px 22px 22px;
|
||||
}
|
||||
#widget-dialog-body .card + .card {
|
||||
margin-top: 20px;
|
||||
padding-top: 22px;
|
||||
border-top: none;
|
||||
}
|
||||
}
|
||||
|
||||
label { display: block; margin-top: 16px; font-size: 13px; font-weight: 600; color: var(--text); }
|
||||
label:first-child { margin-top: 0; }
|
||||
|
||||
@@ -719,13 +767,24 @@ code {
|
||||
}
|
||||
.frame-name-edit button { margin-top: 0; }
|
||||
|
||||
.frame-preview-pair {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-left: 12px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.frame-preview-arrow {
|
||||
color: var(--text-muted);
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
.frame-preview-thumb {
|
||||
height: 44px;
|
||||
width: auto;
|
||||
max-width: 130px;
|
||||
object-fit: contain;
|
||||
vertical-align: middle;
|
||||
margin-left: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--surface-alt);
|
||||
@@ -733,6 +792,13 @@ code {
|
||||
transition: opacity .12s ease;
|
||||
}
|
||||
.frame-preview-thumb:hover { opacity: 0.8; }
|
||||
.frame-preview-thumb-empty {
|
||||
opacity: 0.3;
|
||||
cursor: default;
|
||||
width: 60px;
|
||||
font-size: 0; /* no src yet -- suppresses the browser's fallback alt-text render */
|
||||
}
|
||||
.frame-preview-thumb-empty:hover { opacity: 0.3; }
|
||||
|
||||
.frame-preview-dialog {
|
||||
position: fixed;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<nav class="tabs">
|
||||
<a href="/admin" class="{% if active_admin_tab == 'main' %}active{% endif %}">Users & Frames</a>
|
||||
<a href="/admin/logs" class="{% if active_admin_tab == 'logs' %}active{% endif %}">Server Logs</a>
|
||||
</nav>
|
||||
@@ -7,10 +7,19 @@
|
||||
<button type="button" class="btn-inline" id="frame-name-save">Save</button>
|
||||
<button type="button" class="btn-inline secondary" id="frame-name-cancel">Cancel</button>
|
||||
</span>
|
||||
<img id="frame-preview-thumb" class="frame-preview-thumb" alt="Live preview of what the frame is displaying" title="What the frame is currently displaying -- click to enlarge">
|
||||
<span class="frame-preview-pair">
|
||||
<img id="frame-preview-now-thumb" class="frame-preview-thumb frame-preview-thumb-empty" alt="What the frame is currently displaying" title="Now displaying">
|
||||
<span class="frame-preview-arrow" aria-hidden="true">→</span>
|
||||
<img id="frame-preview-thumb" class="frame-preview-thumb" alt="Live preview of what the frame will show next" title="Up next -- live preview, updates as you edit the layout -- click to enlarge">
|
||||
</span>
|
||||
|
||||
<dialog id="frame-preview-now-dialog" class="frame-preview-dialog">
|
||||
<button type="button" id="frame-preview-now-dialog-close" class="icon-btn" aria-label="Close" title="Close">×</button>
|
||||
<img id="frame-preview-now-dialog-img" alt="What the frame is currently displaying">
|
||||
</dialog>
|
||||
|
||||
<dialog id="frame-preview-dialog" class="frame-preview-dialog">
|
||||
<button type="button" id="frame-preview-dialog-close" class="icon-btn" aria-label="Close" title="Close">×</button>
|
||||
<img id="frame-preview-dialog-img" alt="Live preview of what the frame is displaying" title="Click to refresh">
|
||||
<img id="frame-preview-dialog-img" alt="Live preview of what the frame will show next" title="Click to refresh">
|
||||
</dialog>
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
{% block page_title %}Administration{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include "_admin_tabs.html" %}
|
||||
|
||||
{% if notice %}<div class="status ok">{{ notice }}</div>{% endif %}
|
||||
{% if error %}<div class="status err">{{ error }}</div>{% endif %}
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
{% extends "app_base.html" %}
|
||||
|
||||
{% block title %}Server Logs{% endblock %}
|
||||
{% block page_title %}Administration{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include "_admin_tabs.html" %}
|
||||
|
||||
<section class="card">
|
||||
<div class="card-title-row">
|
||||
<h2 class="card-title">Server Logs</h2>
|
||||
<a href="/admin/logs/download" class="secondary btn-inline">Download full log</a>
|
||||
</div>
|
||||
|
||||
{% if not log_exists %}
|
||||
<p class="sub">No log file yet -- nothing has been logged since this server last started.</p>
|
||||
{% else %}
|
||||
<p class="sub">Last {{ log_lines }} lines of <code>{{ log_path }}</code>. Rotates at ~2MB
|
||||
(older entries roll into <code>{{ log_path }}.1</code>, etc. -- not shown here; use
|
||||
"Download full log" for just the current file).</p>
|
||||
<div class="log-view-controls">
|
||||
{% for n in [200, 500, 2000, 5000] %}
|
||||
<a href="/admin/logs?lines={{ n }}" class="{% if log_lines == n %}active{% endif %}">{{ n }}</a>
|
||||
{% endfor %}
|
||||
<a href="/admin/logs?lines={{ log_lines }}" class="secondary btn-inline">Refresh</a>
|
||||
</div>
|
||||
<pre class="log-view">{{ log_text }}</pre>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -17,6 +17,14 @@
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="/static/theme.css">
|
||||
<link rel="manifest" href="/static/manifest.json">
|
||||
<link rel="icon" href="/static/icons/favicon.png">
|
||||
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png">
|
||||
<meta name="theme-color" content="#2563eb">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||
<meta name="apple-mobile-web-app-title" content="ESPresso">
|
||||
{% block extra_head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -17,6 +17,14 @@
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="/static/theme.css">
|
||||
<link rel="manifest" href="/static/manifest.json">
|
||||
<link rel="icon" href="/static/icons/favicon.png">
|
||||
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png">
|
||||
<meta name="theme-color" content="#2563eb">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||
<meta name="apple-mobile-web-app-title" content="ESPresso">
|
||||
{% block extra_head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -29,6 +29,10 @@ from pathlib import Path
|
||||
|
||||
_tmp_dir = tempfile.mkdtemp(prefix="espresso_frame_tests_")
|
||||
os.environ["DATABASE_URL"] = f"sqlite:///{Path(_tmp_dir) / 'test.db'}"
|
||||
# Same reasoning as DATABASE_URL above: logging_setup.configure_logging()
|
||||
# also runs as an app.main import-time side effect and would otherwise
|
||||
# try to create the real /data directory.
|
||||
os.environ["LOG_PATH"] = str(Path(_tmp_dir) / "server.log")
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Permission boundary + basic content checks for the admin log viewer
|
||||
(routers/pages.py's admin_logs_page/admin_logs_download) -- see
|
||||
CLAUDE.md's note that anything gated by an admin/permission check needs
|
||||
a same-shape test: admin, non-admin logged in, logged out."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from app.logging_setup import LOG_PATH
|
||||
from app.models import Frame
|
||||
|
||||
from .conftest import login, make_user
|
||||
|
||||
|
||||
def _setup_admin_and_user(client, db_session) -> None:
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
make_user(db_session, "bob")
|
||||
|
||||
|
||||
def test_admin_can_view_logs(client, db_session):
|
||||
_setup_admin_and_user(client, db_session)
|
||||
login(client, "alice", "hunter22")
|
||||
logging.getLogger("app.test").info("marker-line-for-test")
|
||||
resp = client.get("/admin/logs")
|
||||
assert resp.status_code == 200
|
||||
assert "marker-line-for-test" in resp.text
|
||||
|
||||
|
||||
def test_non_admin_forbidden_from_logs(client, db_session):
|
||||
_setup_admin_and_user(client, db_session)
|
||||
login(client, "bob")
|
||||
resp = client.get("/admin/logs")
|
||||
assert resp.status_code == 403
|
||||
resp = client.get("/admin/logs/download")
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_logged_out_redirected_from_logs_page(client, db_session):
|
||||
_setup_admin_and_user(client, db_session)
|
||||
client.cookies.clear() # /setup itself logs alice in
|
||||
resp = client.get("/admin/logs")
|
||||
assert resp.status_code == 303
|
||||
assert resp.headers["location"] == "/login"
|
||||
|
||||
|
||||
def test_admin_can_download_log_file(client, db_session):
|
||||
_setup_admin_and_user(client, db_session)
|
||||
login(client, "alice", "hunter22")
|
||||
logging.getLogger("app.test").info("marker-line-for-download")
|
||||
resp = client.get("/admin/logs/download")
|
||||
assert resp.status_code == 200
|
||||
assert b"marker-line-for-download" in resp.content
|
||||
|
||||
|
||||
def test_device_requests_are_logged(client, db_session):
|
||||
_setup_admin_and_user(client, db_session)
|
||||
frame = db_session.get(Frame, 1)
|
||||
frame.device_id = "aabbccddeeff"
|
||||
db_session.commit()
|
||||
resp = client.get(f"/frame/config?id={frame.device_id}&token={frame.device_token}")
|
||||
assert resp.status_code == 200
|
||||
|
||||
login(client, "alice", "hunter22")
|
||||
log_resp = client.get("/admin/logs")
|
||||
# Jinja HTML-escapes the rendered <pre>, so "->" becomes "->".
|
||||
assert f"GET /frame/config id={frame.device_id} -> 200" in log_resp.text
|
||||
|
||||
|
||||
def test_download_404s_before_any_log_written(client, db_session, monkeypatch):
|
||||
_setup_admin_and_user(client, db_session)
|
||||
login(client, "alice", "hunter22")
|
||||
monkeypatch.setattr("app.routers.pages.LOG_PATH", LOG_PATH.parent / "does-not-exist.log")
|
||||
resp = client.get("/admin/logs/download")
|
||||
assert resp.status_code == 404
|
||||
@@ -1,11 +1,10 @@
|
||||
"""_reject_outlier_drops -- the outlier-rejection pass in the battery
|
||||
remaining-time estimate (see routers/common.py's battery_estimate_s).
|
||||
Pure function, no DB/HTTP -- (recency_weight, drop_pct) pairs in,
|
||||
filtered pairs out."""
|
||||
"""_reject_outlier_drops and _smooth_percents -- the two outlier-rejection
|
||||
passes in the battery remaining-time estimate (see routers/common.py's
|
||||
battery_estimate_s). Pure functions, no DB/HTTP."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.routers.common import _reject_outlier_drops
|
||||
from app.routers.common import _reject_outlier_drops, _smooth_percents
|
||||
|
||||
|
||||
def _steps(drops: list[float]) -> list[tuple[int, float]]:
|
||||
@@ -69,3 +68,37 @@ def test_never_filters_down_to_nothing():
|
||||
steps = _steps([1, 1, 1, 1, 10, 10, 10, 10])
|
||||
kept = _reject_outlier_drops(steps)
|
||||
assert len(kept) > 0
|
||||
|
||||
|
||||
def test_smooth_corrects_isolated_spike():
|
||||
percents = [70, 70, 70, 70, 70, 90, 70, 70, 70, 70, 70]
|
||||
smoothed = _smooth_percents(percents)
|
||||
assert smoothed[5] == 70
|
||||
assert smoothed[:5] == percents[:5]
|
||||
assert smoothed[6:] == percents[6:]
|
||||
|
||||
|
||||
def test_smooth_corrects_short_burst():
|
||||
"""The shape seen in production: several consecutive corrupted
|
||||
reports (a 1M-ohm divider glitching for a few reports in a row, not
|
||||
just one) spliced into an otherwise flat run. A step computed
|
||||
between two of these looks like an ordinary small change, which is
|
||||
exactly why _reject_outlier_drops alone can't catch this shape."""
|
||||
percents = [53, 53, 53, 53, 41, 40, 40, 42, 53, 53, 53, 53]
|
||||
smoothed = _smooth_percents(percents)
|
||||
assert smoothed[4:8] == [53, 53, 53, 53]
|
||||
assert smoothed[:4] == percents[:4]
|
||||
assert smoothed[8:] == percents[8:]
|
||||
|
||||
|
||||
def test_smooth_leaves_gradual_legitimate_trend_alone():
|
||||
"""A slow, steady climb (recharge) or decline spread over many
|
||||
reports is a real trend, not a local glitch -- each reading is close
|
||||
to its own neighborhood's median, so nothing should be flagged."""
|
||||
percents = list(range(80, 60, -2)) # 80, 78, 76, ... steady discharge
|
||||
assert _smooth_percents(percents) == percents
|
||||
|
||||
|
||||
def test_smooth_identical_readings_untouched():
|
||||
percents = [50] * 12
|
||||
assert _smooth_percents(percents) == percents
|
||||
|
||||
@@ -144,3 +144,50 @@ def test_two_widget_frame_composites_both_and_next_targets_calendar(client, db_s
|
||||
photo_cfg = db_session.get(PhotoWidgetConfig, photo_widget.id)
|
||||
assert cal_cfg.browse_offset == 1
|
||||
assert photo_cfg.current_asset_id == "" # untouched -- NEXT was never bound to it
|
||||
|
||||
|
||||
def test_widgets_render_concurrently(client, db_session, monkeypatch):
|
||||
"""Two independent, slow widgets on one frame should render in
|
||||
roughly the time of the slowest one, not the sum -- the actual fix
|
||||
for the "hold to cycle layouts times out and shows a false server-
|
||||
failed status screen" bug: several network-backed widgets (photos,
|
||||
weather, calendar) rendering one after another could push a single
|
||||
/frame/* response past the firmware's fixed HTTP timeout even though
|
||||
the server was simply still working."""
|
||||
monkeypatch.setattr(widgets.photos, "immich_client_for", lambda frame: object())
|
||||
monkeypatch.setattr(widgets.photos, "list_assets", lambda client, album_id: _ASSETS)
|
||||
|
||||
from PIL import Image
|
||||
source = Image.new("RGB", (100, 80), (10, 20, 30))
|
||||
|
||||
def _slow_fetch(client, mode, asset_id):
|
||||
time.sleep(0.25)
|
||||
return source, None
|
||||
|
||||
monkeypatch.setattr(widgets.photos, "fetch_source_and_faces", _slow_fetch)
|
||||
|
||||
frame = Frame(
|
||||
name="Concurrency Frame", device_id="112233445566", device_token="devtok-3",
|
||||
manage_token="mtok-3", orientation="landscape", created_at=time.time(),
|
||||
)
|
||||
db_session.add(frame)
|
||||
db_session.flush()
|
||||
|
||||
widget_a = Widget(frame_id=frame.id, widget_type="photos", x=0, y=0, w=4, h=5,
|
||||
sort_order=0, created_at=time.time())
|
||||
widget_b = Widget(frame_id=frame.id, widget_type="photos", x=4, y=0, w=4, h=5,
|
||||
sort_order=1, created_at=time.time())
|
||||
db_session.add_all([widget_a, widget_b])
|
||||
db_session.flush()
|
||||
db_session.add(PhotoWidgetConfig(widget_id=widget_a.id, album_id="album-a"))
|
||||
db_session.add(PhotoWidgetConfig(widget_id=widget_b.id, album_id="album-b"))
|
||||
db_session.commit()
|
||||
|
||||
start = time.monotonic()
|
||||
resp = client.get(f"/frame/image?id={frame.device_id}&token={frame.device_token}")
|
||||
elapsed = time.monotonic() - start
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.content) == EXPECTED_BYTES
|
||||
# Serial would be ~0.5s (2 x 0.25s); concurrent should land near 0.25s.
|
||||
assert elapsed < 0.45, f"widgets rendered serially, not concurrently ({elapsed:.2f}s)"
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"""POST /api/frames/{id}/firmware/check -- the "Check now" button's
|
||||
endpoint. update_available compares the latest Gitea release against
|
||||
what's *staged* (firmware_available_version), not what the device is
|
||||
actually running (device_firmware_version) -- those can differ once a
|
||||
release has been staged/auto-applied but the frame hasn't woken up and
|
||||
picked it up yet. running_version lets the UI tell "up to date" apart
|
||||
from "staged, waiting for the frame to apply it" instead of collapsing
|
||||
both into the same message."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app import gitea_releases
|
||||
from app.models import Frame
|
||||
|
||||
from .conftest import csrf_headers
|
||||
|
||||
|
||||
def _setup_frame(db_session, monkeypatch, latest_version, **overrides):
|
||||
"""firmware_update_checked_at starts at 0, so the endpoint always
|
||||
tries a real Gitea fetch on a fresh frame regardless of force= --
|
||||
stub it out rather than hitting the network."""
|
||||
monkeypatch.setattr(
|
||||
gitea_releases, "fetch_latest_release", lambda *a, **k: {"version": latest_version, "assets": {}}
|
||||
)
|
||||
frame = db_session.get(Frame, 1)
|
||||
frame.firmware_update_repo_url = "https://git.example.com/owner/repo"
|
||||
frame.device_board_variant = "devkit"
|
||||
for key, value in overrides.items():
|
||||
setattr(frame, key, value)
|
||||
db_session.commit()
|
||||
return frame
|
||||
|
||||
|
||||
def test_up_to_date_when_running_matches_latest(client, db_session, monkeypatch):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
_setup_frame(
|
||||
db_session,
|
||||
monkeypatch,
|
||||
"1.4.1",
|
||||
firmware_available_version="1.4.1",
|
||||
device_firmware_version="1.4.1",
|
||||
)
|
||||
|
||||
resp = client.post("/api/frames/1/firmware/check", headers=csrf_headers(client))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["update_available"] is False
|
||||
assert data["latest_version"] == "1.4.1"
|
||||
assert data["running_version"] == "1.4.1"
|
||||
|
||||
|
||||
def test_staged_but_not_yet_running_is_not_update_available(client, db_session, monkeypatch):
|
||||
"""A release already staged (e.g. by a previous auto-update) but not
|
||||
yet applied by the device isn't "an update is available" -- there's
|
||||
nothing left to fetch/stage -- but it also isn't silently "up to
|
||||
date" from the UI's perspective, since running_version still lags."""
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
_setup_frame(
|
||||
db_session,
|
||||
monkeypatch,
|
||||
"1.4.1",
|
||||
firmware_available_version="1.4.1",
|
||||
device_firmware_version="1.3.0",
|
||||
)
|
||||
|
||||
resp = client.post("/api/frames/1/firmware/check", headers=csrf_headers(client))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["update_available"] is False
|
||||
assert data["latest_version"] == "1.4.1"
|
||||
assert data["staged_version"] == "1.4.1"
|
||||
assert data["running_version"] == "1.3.0"
|
||||
|
||||
|
||||
def test_update_available_reports_running_version(client, db_session, monkeypatch):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
_setup_frame(
|
||||
db_session,
|
||||
monkeypatch,
|
||||
"1.4.1",
|
||||
firmware_available_version="1.3.0",
|
||||
device_firmware_version="1.3.0",
|
||||
)
|
||||
|
||||
resp = client.post("/api/frames/1/firmware/check", headers=csrf_headers(client))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["update_available"] is True
|
||||
assert data["running_version"] == "1.3.0"
|
||||
@@ -92,6 +92,7 @@ def test_expected_columns_exist_on_current_schema():
|
||||
button_action_indexes = {idx["name"] for idx in inspector.get_indexes("frame_button_actions")}
|
||||
assert "ix_frame_button_actions_widget_button" in button_action_indexes # migration 28
|
||||
assert {"hold_duration_ms", "next_hold_action", "back_hold_action", "last_cycled_layout_id"} <= frame_columns
|
||||
assert {"last_displayed_image", "last_displayed_at"} <= frame_columns # migration 30
|
||||
|
||||
|
||||
# --- widget system backfill (migration 16 + _ensure_widgets_backfilled) ---
|
||||
@@ -331,6 +332,25 @@ def test_migration_29_adds_hold_action_columns_to_an_existing_database(db_sessio
|
||||
assert frame.last_cycled_layout_id is None
|
||||
|
||||
|
||||
def test_migration_30_adds_now_displaying_columns_to_an_existing_database(db_session):
|
||||
"""Exercises _migration_30's real guarded ALTER path (frames isn't
|
||||
dropped/recreated by the pre-widget-system replay tests, so its
|
||||
columns must be added defensively, same reasoning as migration
|
||||
26/27/29's own comments)."""
|
||||
with db_module.engine.begin() as conn:
|
||||
conn.execute(text("UPDATE schema_version SET version = 29"))
|
||||
|
||||
run_migrations()
|
||||
|
||||
with db_module.engine.connect() as conn:
|
||||
version = conn.execute(text("SELECT version FROM schema_version")).scalar()
|
||||
assert version == MIGRATIONS[-1][0]
|
||||
|
||||
frame = db_session.get(Frame, 1)
|
||||
assert frame.last_displayed_image is None
|
||||
assert frame.last_displayed_at == 0.0
|
||||
|
||||
|
||||
def test_migration_17_and_18_extract_tasks_into_a_standalone_multi_list_widget(db_session):
|
||||
"""Exercises _migration_17 and _migration_18's actual data-extraction
|
||||
SQL back to back (the real "existing widget-system database
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""GET /api/frames/{id}/now-displaying -- the frozen half of the header
|
||||
preview pair (see routers/device.py's _record_last_displayed). Distinct
|
||||
from /preview (test_frame_preview.py): that one always live-renders,
|
||||
this one serves back exactly whatever bytes a device-facing endpoint
|
||||
last actually sent, recorded as a side effect of /frame/image,
|
||||
/frame/advance, and /frame/back."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from app.image_pipeline import logical_render_size
|
||||
from app.models import Frame
|
||||
|
||||
from .conftest import link_user, login, make_user
|
||||
|
||||
|
||||
def test_now_displaying_404s_before_any_device_fetch(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
|
||||
resp = client.get("/api/frames/1/now-displaying")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_frame_image_records_now_displaying(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
frame = db_session.get(Frame, 1)
|
||||
|
||||
resp = client.get("/frame/image")
|
||||
assert resp.status_code == 200
|
||||
|
||||
resp = client.get("/api/frames/1/now-displaying")
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["content-type"] == "image/png"
|
||||
assert "X-Displayed-At" in resp.headers
|
||||
assert float(resp.headers["X-Displayed-At"]) > 0
|
||||
|
||||
img = Image.open(io.BytesIO(resp.content))
|
||||
assert img.size == logical_render_size(frame.orientation)
|
||||
|
||||
|
||||
def test_advance_and_back_also_update_now_displaying(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
db_session.get(Frame, 1)
|
||||
|
||||
client.get("/frame/image")
|
||||
first = client.get("/api/frames/1/now-displaying")
|
||||
assert first.status_code == 200
|
||||
|
||||
resp = client.post("/frame/advance")
|
||||
assert resp.status_code == 200
|
||||
after_advance = client.get("/api/frames/1/now-displaying")
|
||||
assert after_advance.status_code == 200
|
||||
|
||||
resp = client.post("/frame/back")
|
||||
assert resp.status_code == 200
|
||||
after_back = client.get("/api/frames/1/now-displaying")
|
||||
assert after_back.status_code == 200
|
||||
assert float(after_back.headers["X-Displayed-At"]) >= float(first.headers["X-Displayed-At"])
|
||||
|
||||
|
||||
def test_now_displaying_visible_to_linked_user(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
bob = make_user(db_session, "bob")
|
||||
frame = db_session.get(Frame, 1)
|
||||
link_user(db_session, bob, frame)
|
||||
client.get("/frame/image")
|
||||
|
||||
client.cookies.clear()
|
||||
login(client, "bob")
|
||||
resp = client.get("/api/frames/1/now-displaying")
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
def test_now_displaying_hidden_from_unrelated_user(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
make_user(db_session, "mallory")
|
||||
client.get("/frame/image")
|
||||
|
||||
client.cookies.clear()
|
||||
login(client, "mallory")
|
||||
resp = client.get("/api/frames/1/now-displaying")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_now_displaying_requires_login(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
client.get("/frame/image")
|
||||
|
||||
client.cookies.clear()
|
||||
resp = client.get("/api/frames/1/now-displaying")
|
||||
assert resp.status_code in (401, 403)
|
||||
Reference in New Issue
Block a user