Build and push server image / build-and-push (push) Successful in 38s
A report was flagged as "the battery got recharged" (resetting battery_history and stats_recharge_cycles, and re-arming the low-battery alert) whenever it came in >= RECHARGE_JUMP_PCT above the single immediately-previous report. That's exactly what a real recharge looks like, but it's also exactly what a normal reading looks like right after one noisy low report: e.g. 60, 59, 58, then a stray 53, then back to a perfectly normal 58 -- 58 >= 53+5 falsely read as a recharge. Now compared against the max of the last RECHARGE_LOOKBACK (3) reports instead of just the one before it, so a lone stray reading doesn't get to set the bar a normal reading then trips. A real recharge still needs to clear all of them, so genuine recharges are still caught immediately (verified: 18% -> 90% still triggers, history still resets). Paired with the firmware-side battery.c change (trimmed-mean ADC sampling) that reduces how often a stray reading like the 53 above happens in the first place.
455 lines
19 KiB
Python
455 lines
19 KiB
Python
"""Device-facing /frame/* routes. These paths are FROZEN -- they're baked
|
|
into deployed firmware -- so multi-frame support changes only how the
|
|
calling frame is resolved (see auth.require_device), never the paths or
|
|
response key names the deployed flat parser depends on
|
|
("refresh_interval_s", "firmware_version")."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import time
|
|
from datetime import datetime
|
|
|
|
import httpx
|
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
from fastapi.responses import FileResponse, RedirectResponse, Response
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import delete, func, select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from .. import mail, photo_queue, quiet_hours
|
|
from ..auth import get_server_settings, require_device
|
|
from ..db import frame_locked, get_db
|
|
from ..face_labels import compute_face_labels
|
|
from ..firmware import firmware_path
|
|
from ..image_pipeline import render_placeholder
|
|
from ..models import BatteryLog, Frame
|
|
from .common import (
|
|
BATTERY_HISTORY_MAX,
|
|
BATTERY_LOG_MAX,
|
|
RECHARGE_JUMP_PCT,
|
|
RECHARGE_LOOKBACK,
|
|
immich_client_for,
|
|
immich_creds,
|
|
list_assets,
|
|
render_asset,
|
|
require_configured,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
def _setup_placeholder(frame: Frame, request: Request) -> bytes:
|
|
"""What an unclaimed or not-yet-configured frame displays instead of a
|
|
photo -- instructions with a QR, rendered at 200 so the device treats
|
|
it as a perfectly normal image and never error-loops. The URLs are
|
|
built from the request's own base URL: whatever address the device
|
|
reached us at is by definition an address that works on this
|
|
network."""
|
|
base = str(request.base_url).rstrip("/")
|
|
if frame.owner_user_id is None and frame.device_id:
|
|
claim_url = f"{base}/claim?device_id={frame.device_id}"
|
|
return render_placeholder(
|
|
["This frame isn't claimed yet", "Scan to link it to your account:"],
|
|
qr_url=claim_url,
|
|
orientation=frame.orientation,
|
|
palette_rgb=frame.palette_rgb,
|
|
)
|
|
if frame.owner_user_id is None:
|
|
return render_placeholder(
|
|
["Almost there!", f"Open {base} to finish setting up this frame."],
|
|
orientation=frame.orientation,
|
|
palette_rgb=frame.palette_rgb,
|
|
)
|
|
return render_placeholder(
|
|
["Almost there!", "Pick an album for this frame:", base],
|
|
qr_url=base,
|
|
orientation=frame.orientation,
|
|
palette_rgb=frame.palette_rgb,
|
|
)
|
|
|
|
|
|
def _frame_configured(frame: Frame) -> bool:
|
|
url, key = immich_creds(frame)
|
|
return bool(url and key and frame.album_id)
|
|
|
|
|
|
# Renderer dispatch seam for future frame modes (calendar, canva, ...):
|
|
# /frame/image looks up the frame's mode here. Only photos exists today.
|
|
def _render_photos_mode(db: Session, frame: Frame, request: Request) -> bytes:
|
|
if not _frame_configured(frame):
|
|
return _setup_placeholder(frame, request)
|
|
client = immich_client_for(frame)
|
|
assets = list_assets(client, frame)
|
|
|
|
with frame_locked(db, frame.id) as locked:
|
|
photo_queue.get_current(locked, assets, in_quiet_hours=quiet_hours.in_quiet_hours(locked))
|
|
asset_id = locked.current_asset_id
|
|
|
|
return render_asset(client, frame, asset_id)
|
|
|
|
|
|
RENDERERS = {
|
|
"photos": _render_photos_mode,
|
|
}
|
|
|
|
|
|
@router.get("/frame/config")
|
|
def frame_config(request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
|
|
"""Device-facing settings, polled by the frame alongside its
|
|
reachability check. Always returns 200 with current settings -- no
|
|
Immich-configured gate, since this doubles as the "is the server up"
|
|
signal. Also captures the device's running firmware version and board
|
|
variant (X-Frame-Version/X-Frame-Board headers) and advertises the
|
|
available OTA image's version, so the device's update check costs
|
|
zero extra round trips."""
|
|
reported_version = request.headers.get("X-Frame-Version", "")
|
|
reported_board = request.headers.get("X-Frame-Board", "")
|
|
with frame_locked(db, frame.id) as locked:
|
|
if locked.stats_first_seen == 0:
|
|
locked.stats_first_seen = time.time()
|
|
locked.stats_device_wakes += 1
|
|
if reported_version:
|
|
if locked.device_firmware_version and reported_version != locked.device_firmware_version:
|
|
locked.stats_ota_updates_applied += 1
|
|
locked.device_firmware_version = reported_version
|
|
if reported_board:
|
|
locked.device_board_variant = reported_board
|
|
|
|
response = {
|
|
"refresh_interval_s": quiet_hours.effective_refresh_interval_s(locked),
|
|
"firmware_version": locked.firmware_available_version or None,
|
|
}
|
|
# Per-frame token push: only once the device has introduced itself
|
|
# by id (so the response to pure-legacy firmware stays byte-
|
|
# compatible with its 256-byte parse buffer), and only until the
|
|
# device has authenticated with the token once (device_token_ack).
|
|
if locked.device_id is not None and not locked.device_token_ack:
|
|
response["device_token"] = locked.device_token
|
|
return response
|
|
|
|
|
|
@router.get("/frame/image")
|
|
def frame_image(
|
|
request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)
|
|
):
|
|
"""Returns the frame's current image. For photos mode: idempotent --
|
|
only actually advances to the next photo once refresh_interval_s has
|
|
elapsed since the current one was set (see app/photo_queue.py) --
|
|
safe to call as often as the device wants, including after an
|
|
unplanned reboot, without skipping ahead in the album. An unclaimed/
|
|
unconfigured frame gets a rendered instruction placeholder (200, not
|
|
an error) so a fresh device never error-loops."""
|
|
renderer = RENDERERS.get(frame.mode, _render_photos_mode)
|
|
return Response(content=renderer(db, frame, request), media_type="application/octet-stream")
|
|
|
|
|
|
@router.post("/frame/advance")
|
|
def frame_advance(frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
|
|
"""Forces an immediate advance to the next photo, ignoring
|
|
refresh_interval_s, and resets the interval clock from now. Used by
|
|
the device's next-photo button."""
|
|
require_configured(frame)
|
|
client = immich_client_for(frame)
|
|
assets = list_assets(client, frame)
|
|
|
|
with frame_locked(db, frame.id) as locked:
|
|
photo_queue.advance_forced(locked, assets)
|
|
asset_id = locked.current_asset_id
|
|
|
|
return Response(content=render_asset(client, frame, asset_id), media_type="application/octet-stream")
|
|
|
|
|
|
@router.post("/frame/back")
|
|
def frame_back(frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
|
|
"""Returns to the previously-current photo (the mirror image of
|
|
/frame/advance -- see photo_queue.back_forced()), and resets the
|
|
interval clock from now. A no-op (still 200, current photo
|
|
unchanged) if there's no history to go back to -- same "always
|
|
returns something displayable" contract as /frame/advance, rather
|
|
than erroring. Used by the device's back-photo button."""
|
|
require_configured(frame)
|
|
client = immich_client_for(frame)
|
|
assets = list_assets(client, frame)
|
|
|
|
with frame_locked(db, frame.id) as locked:
|
|
photo_queue.back_forced(locked, assets)
|
|
asset_id = locked.current_asset_id
|
|
|
|
return Response(content=render_asset(client, frame, asset_id), media_type="application/octet-stream")
|
|
|
|
|
|
class BatteryReport(BaseModel):
|
|
percent: int
|
|
|
|
|
|
@router.post("/frame/battery")
|
|
def frame_battery(
|
|
body: BatteryReport, frame: Frame = Depends(require_device), db: Session = Depends(get_db)
|
|
):
|
|
"""Battery level reported by the device (only when running on battery
|
|
-- it stays silent on mains, where the charging voltage would read
|
|
misleadingly full). Stored with a timestamp plus a per-discharge-
|
|
cycle history that feeds the Device panel's "on battery for" and
|
|
"estimated remaining" numbers; every report also lands in the
|
|
permanent battery_log table behind the history chart."""
|
|
if not 0 <= body.percent <= 100:
|
|
raise HTTPException(400, "percent must be 0-100")
|
|
now = time.time()
|
|
should_alert = False
|
|
alert_email = ""
|
|
alert_frame_name = ""
|
|
with frame_locked(db, frame.id) as locked:
|
|
locked.stats_battery_reports += 1
|
|
# See RECHARGE_LOOKBACK: compared against the max of the last few
|
|
# reports, not just the single previous one, so a lone noisy dip
|
|
# can't make the next normal reading look like a recharge.
|
|
recent = locked.battery_history[-RECHARGE_LOOKBACK:]
|
|
recent_max = max((pct for _, pct in recent), default=None)
|
|
if recent_max is not None and body.percent >= recent_max + RECHARGE_JUMP_PCT:
|
|
# Percent jumped up meaningfully -- the battery was recharged
|
|
# (or swapped). Start a fresh discharge cycle so runtime and
|
|
# discharge-rate estimates never span a charge -- and let a
|
|
# low-battery alert fire again next time it actually gets low.
|
|
locked.battery_history = []
|
|
locked.stats_recharge_cycles += 1
|
|
locked.battery_alert_sent = False
|
|
locked.battery_history.append([now, body.percent])
|
|
locked.battery_history = locked.battery_history[-BATTERY_HISTORY_MAX:]
|
|
locked.battery_percent = body.percent
|
|
locked.battery_as_of = now
|
|
|
|
db.add(BatteryLog(frame_id=locked.id, ts=now, percent=body.percent))
|
|
# Safety bound, not a real limit at realistic report rates --
|
|
# mirrors the old JSON list's cap.
|
|
count = db.scalar(select(func.count()).select_from(BatteryLog).where(BatteryLog.frame_id == locked.id))
|
|
if count is not None and count >= BATTERY_LOG_MAX:
|
|
cutoff_ids = select(BatteryLog.id).where(BatteryLog.frame_id == locked.id).order_by(
|
|
BatteryLog.ts
|
|
).limit(count + 1 - BATTERY_LOG_MAX)
|
|
db.execute(delete(BatteryLog).where(BatteryLog.id.in_(cutoff_ids)))
|
|
|
|
# Once per discharge cycle (see the recharge reset above), not
|
|
# once per report -- a frame idling at 4% would otherwise get an
|
|
# email every wake.
|
|
if (
|
|
locked.battery_alert_threshold_pct >= 0
|
|
and body.percent <= locked.battery_alert_threshold_pct
|
|
and not locked.battery_alert_sent
|
|
and locked.owner is not None
|
|
and locked.owner.email
|
|
):
|
|
should_alert = True
|
|
alert_email = locked.owner.email
|
|
alert_frame_name = locked.name or f"Frame {locked.id}"
|
|
|
|
if should_alert:
|
|
# Network I/O outside the lock, same convention as everywhere
|
|
# else in this file -- then a short re-lock to record that it
|
|
# went out, only on actual success (an SMTP hiccup should let
|
|
# the next report's still-below-threshold reading try again
|
|
# rather than silently giving up for the rest of the cycle).
|
|
settings = get_server_settings(db)
|
|
sent = mail.send_email(
|
|
settings, alert_email, f"{alert_frame_name}: battery low",
|
|
f"{alert_frame_name}'s battery is at {body.percent}%.",
|
|
)
|
|
if sent:
|
|
with frame_locked(db, frame.id) as locked:
|
|
locked.battery_alert_sent = True
|
|
return {"status": "saved"}
|
|
|
|
|
|
@router.get("/frame/firmware")
|
|
def frame_firmware(frame: Frame = Depends(require_device)):
|
|
"""The frame's staged OTA image, streamed to the device
|
|
(esp_https_ota). 404 until something has been uploaded/fetched."""
|
|
path = firmware_path(frame.id)
|
|
if not path.exists():
|
|
raise HTTPException(404, "No firmware uploaded")
|
|
return FileResponse(path, media_type="application/octet-stream")
|
|
|
|
|
|
LOCATION_LINE_MAX_LEN = 14
|
|
|
|
US_STATE_ABBR = {
|
|
"alabama": "AL", "alaska": "AK", "arizona": "AZ", "arkansas": "AR", "california": "CA",
|
|
"colorado": "CO", "connecticut": "CT", "delaware": "DE", "florida": "FL", "georgia": "GA",
|
|
"hawaii": "HI", "idaho": "ID", "illinois": "IL", "indiana": "IN", "iowa": "IA",
|
|
"kansas": "KS", "kentucky": "KY", "louisiana": "LA", "maine": "ME", "maryland": "MD",
|
|
"massachusetts": "MA", "michigan": "MI", "minnesota": "MN", "mississippi": "MS", "missouri": "MO",
|
|
"montana": "MT", "nebraska": "NE", "nevada": "NV", "new hampshire": "NH", "new jersey": "NJ",
|
|
"new mexico": "NM", "new york": "NY", "north carolina": "NC", "north dakota": "ND", "ohio": "OH",
|
|
"oklahoma": "OK", "oregon": "OR", "pennsylvania": "PA", "rhode island": "RI", "south carolina": "SC",
|
|
"south dakota": "SD", "tennessee": "TN", "texas": "TX", "utah": "UT", "vermont": "VT",
|
|
"virginia": "VA", "washington": "WA", "west virginia": "WV", "wisconsin": "WI", "wyoming": "WY",
|
|
"district of columbia": "DC",
|
|
}
|
|
|
|
CA_PROVINCE_ABBR = {
|
|
"alberta": "AB", "british columbia": "BC", "manitoba": "MB", "new brunswick": "NB",
|
|
"newfoundland and labrador": "NL", "northwest territories": "NT", "nova scotia": "NS",
|
|
"nunavut": "NU", "ontario": "ON", "prince edward island": "PE", "quebec": "QC",
|
|
"saskatchewan": "SK", "yukon": "YT",
|
|
}
|
|
|
|
US_COUNTRY_NAMES = {"united states", "united states of america", "usa", "us"}
|
|
CA_COUNTRY_NAMES = {"canada"}
|
|
|
|
|
|
def _truncate(text: str, max_len: int) -> str:
|
|
if len(text) <= max_len:
|
|
return text
|
|
return text[: max_len - 3] + "..."
|
|
|
|
|
|
def _format_location(exif: dict) -> tuple[str, str] | None:
|
|
"""Returns (city_line, region_line), each independently truncated to
|
|
fit its own corner-overlay line, or None if Immich hasn't geocoded
|
|
this photo. region_line is the abbreviated state/province for US/CAN
|
|
locations (e.g. "CA", "ON"), else the full country name."""
|
|
city = exif.get("city")
|
|
if not city:
|
|
return None
|
|
|
|
state = exif.get("state")
|
|
country = exif.get("country")
|
|
country_key = (country or "").strip().lower()
|
|
|
|
if state and country_key in US_COUNTRY_NAMES:
|
|
region = US_STATE_ABBR.get(state.strip().lower(), state)
|
|
elif state and country_key in CA_COUNTRY_NAMES:
|
|
region = CA_PROVINCE_ABBR.get(state.strip().lower(), state)
|
|
elif country:
|
|
region = country
|
|
elif state:
|
|
region = state
|
|
else:
|
|
region = ""
|
|
|
|
return _truncate(city, LOCATION_LINE_MAX_LEN), _truncate(region, LOCATION_LINE_MAX_LEN)
|
|
|
|
|
|
def _format_taken_at(exif: dict) -> str | None:
|
|
raw = exif.get("dateTimeOriginal")
|
|
if not raw:
|
|
return None
|
|
try:
|
|
return datetime.fromisoformat(raw.replace("Z", "+00:00")).strftime("%m/%d/%y")
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
@router.get("/frame/photo-info")
|
|
def frame_photo_info(frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
|
|
"""Location/date-taken text for the manage-button overlay, plus the
|
|
asset id used to build the share-QR's target URL. Read-only, same
|
|
idempotent current-photo semantics as /frame/image -- doesn't advance
|
|
anything."""
|
|
require_configured(frame)
|
|
client = immich_client_for(frame)
|
|
assets = list_assets(client, frame)
|
|
|
|
with frame_locked(db, frame.id) as locked:
|
|
photo_queue.get_current(locked, assets, in_quiet_hours=quiet_hours.in_quiet_hours(locked))
|
|
asset_id = locked.current_asset_id
|
|
|
|
if not asset_id:
|
|
raise HTTPException(404, "No current photo")
|
|
|
|
try:
|
|
asset = client.get_asset(asset_id)
|
|
except httpx.HTTPError as e:
|
|
raise HTTPException(502, f"Could not reach Immich: {e}") from e
|
|
|
|
exif = asset.get("exifInfo") or {}
|
|
location = _format_location(exif)
|
|
return {
|
|
"asset_id": asset_id,
|
|
"location_line1": location[0] if location else None,
|
|
"location_line2": location[1] if location and location[1] else None,
|
|
"taken_at": _format_taken_at(exif),
|
|
# Last value this frame itself reported (see /frame/battery) --
|
|
# not a fresh reading. Good enough for a glance on the manage
|
|
# overlay, and lets the device skip a synchronous ADC read (which
|
|
# would otherwise need to happen before the overlay is composited,
|
|
# i.e. before the photo it's part of is even pushed to the panel)
|
|
# just to render this.
|
|
"battery_percent": frame.battery_percent,
|
|
}
|
|
|
|
|
|
@router.get("/frame/share/{asset_id}")
|
|
def frame_share(asset_id: str, frame: Frame = Depends(require_device)):
|
|
"""Creates a 30-minute public Immich share link for asset_id and
|
|
redirects to it -- what the manage overlay's bottom-left QR code
|
|
points to. The link is created lazily, when this actually gets hit
|
|
(i.e. when someone scans it), not when the manage button was
|
|
pressed, so the 30-minute window starts when it's actually used.
|
|
Also scoped to the photo currently showing or queued on THIS frame --
|
|
not any arbitrary Immich asset id -- as a second layer even a leaked
|
|
token wouldn't bypass."""
|
|
require_configured(frame)
|
|
|
|
if asset_id != frame.current_asset_id and asset_id not in frame.queue:
|
|
raise HTTPException(404, "That photo isn't currently showing or queued on this frame")
|
|
|
|
client = immich_client_for(frame)
|
|
try:
|
|
share_url = client.create_share_link(asset_id, expires_in_s=1800)
|
|
except httpx.HTTPError as e:
|
|
raise HTTPException(502, f"Could not create share link: {e}") from e
|
|
|
|
return RedirectResponse(share_url)
|
|
|
|
|
|
@router.get("/frame/face-labels")
|
|
def frame_face_labels(frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
|
|
"""Named-face positions for the manage button's escalated "level 2"
|
|
menu -- who's in the current photo, per Immich's own face
|
|
recognition (no detection/recognition happens here, see
|
|
app/face_labels.py). Response is a flattened, fixed-slot shape
|
|
(name_0/x_0/y_0, ...) rather than a JSON array, so the device's
|
|
hand-rolled parser can read it with the same flat-scalar helpers it
|
|
already has. Empty (count: 0) if no faces are named, or if anything
|
|
about fetching them fails -- this is a "nice to have" addition to
|
|
the overlay, not worth failing the whole menu over."""
|
|
require_configured(frame)
|
|
client = immich_client_for(frame)
|
|
assets = list_assets(client, frame)
|
|
|
|
with frame_locked(db, frame.id) as locked:
|
|
photo_queue.get_current(locked, assets, in_quiet_hours=quiet_hours.in_quiet_hours(locked))
|
|
asset_id = locked.current_asset_id
|
|
display_mode = locked.display_mode
|
|
orientation = locked.orientation
|
|
|
|
if not asset_id:
|
|
return {"count": 0}
|
|
|
|
try:
|
|
faces = client.get_asset_faces(asset_id)
|
|
except httpx.HTTPError as e:
|
|
logger.warning("Could not fetch faces for asset %s: %s", asset_id, e)
|
|
return {"count": 0}
|
|
|
|
if not any((face.get("person") or {}).get("name") for face in faces):
|
|
return {"count": 0} # skip the extra preview download in the common no-named-faces case
|
|
|
|
try:
|
|
preview_bytes = client.download_asset_preview(asset_id)
|
|
except httpx.HTTPError as e:
|
|
logger.warning("Could not download asset %s for face-label mapping: %s", asset_id, e)
|
|
return {"count": 0}
|
|
|
|
labels = compute_face_labels(preview_bytes, faces, display_mode, orientation)
|
|
|
|
result: dict[str, object] = {"count": len(labels)}
|
|
for i, label in enumerate(labels):
|
|
result[f"name_{i}"] = label["name"]
|
|
result[f"x_{i}"] = label["x"]
|
|
result[f"y_{i}"] = label["y"]
|
|
return result
|