Build and push server image / build-and-push (push) Successful in 53s
calendar_week_start's fixed-weekday anchor ("start on the most recent
Monday") stops making sense once the view isn't a literal calendar
week, so a non-7-day week view now starts calendar_week_start_offset
days from today instead (0 = starts today, negative/positive = past/
future) -- calendar_week_start still governs at the default 7 days,
unchanged.
Also hides the Calendar tab's week-only fields (days to show, layout,
start offset) unless View is actually set to Week, and further hides
the new start-offset field specifically when Days to show is 7 (where
it has no effect). "Week starts on" stays visible for Month too, since
it actually still applies there.
406 lines
22 KiB
Python
406 lines
22 KiB
Python
"""SQLAlchemy models: users, sessions, frames, links, claims, battery log.
|
|
|
|
One deliberately WIDE `frames` row per frame (settings + state + telemetry
|
|
+ stats together): every device request touches exactly one row, so the
|
|
per-frame lock in db.frame_locked() keeps the old whole-config-lock
|
|
semantics trivially correct, and SQLite doesn't care about row width.
|
|
|
|
The queue/history/excluded/battery_history columns are MutableList-mapped
|
|
JSON: photo_queue.py mutates them in place (pop/append/insert), which a
|
|
plain JSON column would silently not persist -- MutableList marks the row
|
|
dirty on in-place changes.
|
|
|
|
The ORM attribute for the photo ordering setting is `order` (matching the
|
|
old FrameConfig field name so photo_queue.py ports unchanged) but the
|
|
column is named photo_order to stay clear of the SQL keyword.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
|
|
from sqlalchemy import JSON, Boolean, Float, ForeignKey, Index, Integer, String
|
|
from sqlalchemy.ext.mutable import MutableList
|
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
|
|
|
|
|
class Base(DeclarativeBase):
|
|
pass
|
|
|
|
|
|
class User(Base):
|
|
__tablename__ = "users"
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
# Normalized to lowercase in code before insert/lookup -- portable
|
|
# case-insensitive uniqueness without SQLite-only COLLATE NOCASE.
|
|
username: Mapped[str] = mapped_column(String, unique=True)
|
|
display_name: Mapped[str] = mapped_column(String, default="")
|
|
# Pluggable identity: "local" now; an OIDC provider later would set
|
|
# provider_subject and leave password_hash NULL.
|
|
identity_provider: Mapped[str] = mapped_column(String, default="local")
|
|
provider_subject: Mapped[str] = mapped_column(String, default="")
|
|
password_hash: Mapped[str | None] = mapped_column(String, nullable=True)
|
|
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
immich_url: Mapped[str] = mapped_column(String, default="")
|
|
immich_api_key: Mapped[str] = mapped_column(String, default="")
|
|
# Password-reset emails and battery-threshold alerts (frames.owner's
|
|
# email -- see routers/device.py's frame_battery) go here; blank = no
|
|
# email configured, both features silently no-op for this user.
|
|
email: Mapped[str] = mapped_column(String, default="")
|
|
# Personal ICS subscription URL (no OAuth) for calendar frame mode --
|
|
# see calendar_feed.py. Setting this alone shows up nowhere: a linked
|
|
# frame only pulls this user's events in once they've also added it
|
|
# on that frame's own Calendar tab (FrameCalendar below).
|
|
calendar_ics_url: Mapped[str] = mapped_column(String, default="")
|
|
# A CalDAV account (Nextcloud, Fastmail, iCloud, ...) alongside the
|
|
# plain ICS subscription above -- see caldav_client.py. calendar_url
|
|
# is the server's CalDAV entry point the user pasted in, not any one
|
|
# calendar's own URL; the individual calendars it exposes are
|
|
# discovered and cached below.
|
|
calendar_caldav_url: Mapped[str] = mapped_column(String, default="")
|
|
calendar_caldav_username: Mapped[str] = mapped_column(String, default="")
|
|
calendar_caldav_password: Mapped[str] = mapped_column(String, default="")
|
|
# [{"href", "display_name"}, ...] from the last successful
|
|
# caldav_client.discover_calendars() call, refreshed by Settings'
|
|
# "Discover calendars" button -- NULL until discovery has ever
|
|
# succeeded. This is what a frame's Calendar tab offers the user to
|
|
# add, without hitting the CalDAV server on every page load.
|
|
calendar_caldav_calendars: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
|
|
calendar_caldav_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
|
|
created_at: Mapped[float] = mapped_column(Float, default=time.time)
|
|
|
|
__table_args__ = (
|
|
Index(
|
|
"ix_users_provider_subject",
|
|
"identity_provider",
|
|
"provider_subject",
|
|
unique=True,
|
|
sqlite_where=provider_subject != "",
|
|
),
|
|
)
|
|
|
|
|
|
class UserSession(Base):
|
|
__tablename__ = "sessions"
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
token_hash: Mapped[str] = mapped_column(String, unique=True) # sha256 hex of cookie value
|
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
|
csrf_token: Mapped[str] = mapped_column(String)
|
|
created_at: Mapped[float] = mapped_column(Float, default=time.time)
|
|
expires_at: Mapped[float] = mapped_column(Float, index=True)
|
|
|
|
user: Mapped[User] = relationship()
|
|
|
|
|
|
class Frame(Base):
|
|
__tablename__ = "frames"
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
# 12 lowercase hex chars of the device's full STA MAC. NULL only for
|
|
# the migrated legacy frame until its device first reports an id.
|
|
device_id: Mapped[str | None] = mapped_column(String, unique=True, nullable=True)
|
|
name: Mapped[str] = mapped_column(String, default="")
|
|
# Renderer dispatch seam for future calendar/canva modes -- only
|
|
# "photos" is registered today (see routers/device.py RENDERERS).
|
|
mode: Mapped[str] = mapped_column(String, default="photos")
|
|
# Whose Immich library this frame pulls from; NULL = unclaimed.
|
|
owner_user_id: Mapped[int | None] = mapped_column(
|
|
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
|
)
|
|
# "Take control" soft lock -- only this user may mutate settings/queue.
|
|
controlled_by_user_id: Mapped[int | None] = mapped_column(
|
|
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
|
)
|
|
device_token: Mapped[str] = mapped_column(String)
|
|
# Device has authenticated with device_token at least once -- stop
|
|
# pushing it in /frame/config responses.
|
|
device_token_ack: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
manage_token: Mapped[str] = mapped_column(String, unique=True)
|
|
# Migration window: this frame also accepts the legacy shared
|
|
# MANAGEMENT_TOKEN (and no-id requests resolve to it). Only ever the
|
|
# migrated frame #1; cleared from /admin once the device is on
|
|
# per-frame auth.
|
|
legacy_token_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
claimed_at: Mapped[float | None] = mapped_column(Float, nullable=True)
|
|
created_at: Mapped[float] = mapped_column(Float, default=time.time)
|
|
|
|
# Migration staging only: Immich creds imported from the legacy
|
|
# config.json/env live here until /setup copies them to admin #1.
|
|
# Runtime resolution prefers owner creds, then env, then these (see
|
|
# routers/common.py immich_creds()).
|
|
immich_url: Mapped[str] = mapped_column(String, default="")
|
|
immich_api_key: Mapped[str] = mapped_column(String, default="")
|
|
|
|
# -- settings (attribute names match the old FrameConfig fields) --
|
|
album_id: Mapped[str] = mapped_column(String, default="")
|
|
order: Mapped[str] = mapped_column("photo_order", String, default="sequential")
|
|
refresh_interval_s: Mapped[int] = mapped_column(Integer, default=3600)
|
|
quiet_hours_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
quiet_hours_start: Mapped[str] = mapped_column(String, default="22:00")
|
|
quiet_hours_end: Mapped[str] = mapped_column(String, default="07:00")
|
|
timezone: Mapped[str] = mapped_column(String, default="UTC")
|
|
# How a photo's aspect ratio is reconciled with the panel's -- see
|
|
# image_pipeline.DISPLAY_MODES.
|
|
display_mode: Mapped[str] = mapped_column(String, default="crop_faces")
|
|
orientation: Mapped[str] = mapped_column(String, default="landscape")
|
|
queue_target_len: Mapped[int] = mapped_column(Integer, default=20)
|
|
# Advanced configuration: [[r,g,b], ...] x6 (black/white/yellow/red/
|
|
# blue/green, matching image_pipeline.PANEL_CODES order) overriding
|
|
# DEFAULT_PALETTE_RGB for this frame's actual panel. NULL = use the
|
|
# default -- most frames never touch this.
|
|
palette_rgb: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
|
|
# Advanced configuration: PIL ImageEnhance factors, 1.0 = unchanged
|
|
# (see image_pipeline.render_frame).
|
|
color_boost: Mapped[float] = mapped_column(Float, default=1.0)
|
|
contrast_boost: Mapped[float] = mapped_column(Float, default=1.0)
|
|
# 0.0-1.0, see image_pipeline._quantize -- 1.0 matches this project's
|
|
# original always-on full-strength Floyd-Steinberg dithering.
|
|
dither_strength: Mapped[float] = mapped_column(Float, default=1.0)
|
|
|
|
# -- calendar mode (see calendar_feed.py, calendar_render.py,
|
|
# routers/device.py's RENDERERS["calendar"]) --
|
|
calendar_view: Mapped[str] = mapped_column(String, default="agenda") # "agenda" | "week" | "month"
|
|
# 0=Monday..6=Sunday (matches date.weekday()/calendar.Calendar) --
|
|
# which day week/month views start their grid on.
|
|
calendar_week_start: Mapped[int] = mapped_column(Integer, default=0)
|
|
# Agenda view only; reuses this frame's existing photos-mode album/
|
|
# queue, not a separate photo setup.
|
|
calendar_photo_inlay: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
# How many periods (unit depends on calendar_view: days/weeks/months)
|
|
# NEXT/BACK have browsed from "today". Reset to 0 by the next normal
|
|
# (non-button) /frame/image request, and whenever calendar_view
|
|
# itself changes -- a stale offset means something different in a
|
|
# different view's units.
|
|
calendar_browse_offset: Mapped[int] = mapped_column(Integer, default=0)
|
|
# Throttled merge-fetch cache (see routers/common.py's
|
|
# get_or_refresh_calendar_events) -- same shape as the
|
|
# firmware_update_checked_at/firmware_gitea_latest_version pattern
|
|
# below. One shared cache for every included user's merged events,
|
|
# not per-user.
|
|
calendar_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
|
|
calendar_cached_events: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
|
|
# "" when the last merge-fetch fully succeeded, else e.g. "1 of 2
|
|
# calendars unavailable" -- never names which user's feed failed, a
|
|
# shared household display shouldn't call out a specific person's
|
|
# outage to everyone who looks at it.
|
|
calendar_fetch_summary: Mapped[str] = mapped_column(String, default="")
|
|
|
|
# Optional weather strip, agenda/today & tomorrow/week views only --
|
|
# never month, there's no room (see calendar_render.py's _BUILDERS).
|
|
# Off by default.
|
|
calendar_weather_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
calendar_weather_units: Mapped[str] = mapped_column(String, default="fahrenheit") # "fahrenheit" | "celsius"
|
|
# [{"label", "latitude", "longitude"}, ...] -- each geocoded once via
|
|
# weather.geocode_city() when added from the Calendar tab.
|
|
calendar_weather_cities: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
|
|
# Throttled per-city forecast cache (see routers/common.py's
|
|
# get_or_refresh_weather) -- same shape idiom as
|
|
# calendar_checked_at/calendar_cached_events above.
|
|
# [{"label", "days": {"YYYY-MM-DD": {"code","high","low"}}}, ...]
|
|
calendar_weather_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
|
|
calendar_weather_cached: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
|
|
|
|
# Week view: how many days to show (2-10, default 7 -- the original
|
|
# fixed behavior) and whether they're laid out as side-by-side
|
|
# columns or stacked bands (see calendar_render.py's _build_week).
|
|
calendar_week_days: Mapped[int] = mapped_column(Integer, default=7)
|
|
calendar_week_layout: Mapped[str] = mapped_column(String, default="horizontal") # "horizontal" | "vertical"
|
|
# Only used when calendar_week_days != 7 -- calendar_week_start's
|
|
# fixed-weekday anchor ("start on the most recent Monday") stops
|
|
# making sense once the view isn't a literal calendar week, so a
|
|
# non-7-day view instead starts this many days from today (0 =
|
|
# starts today, negative = starts in the past, positive = starts in
|
|
# the future). Ignored (calendar_week_start governs instead) at the
|
|
# default 7 days, so this has no effect until someone actually
|
|
# changes the day count.
|
|
calendar_week_start_offset: Mapped[int] = mapped_column(Integer, default=0)
|
|
|
|
# Optional task list, week view only -- takes the space of one day
|
|
# slot rather than adding an extra one (see calendar_render.py's
|
|
# _draw_tasks). CalDAV only (a task list is a VTODO collection, not
|
|
# something a plain ICS subscription meaningfully has); source is
|
|
# one specific linked user's own CalDAV calendar, same
|
|
# owner-controls-their-own-data permission split as FrameCalendar --
|
|
# see routers/api_frames.py's api_tasks_source. calendar_tasks_user_id
|
|
# SET NULL on the user's deletion clears the source rather than
|
|
# leaving a dangling reference (checked_at isn't reset by that, but
|
|
# the next refresh attempt finds no source and just returns []).
|
|
calendar_tasks_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
calendar_tasks_user_id: Mapped[int | None] = mapped_column(
|
|
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
|
)
|
|
calendar_tasks_calendar_key: Mapped[str | None] = mapped_column(String, nullable=True)
|
|
calendar_tasks_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
|
|
# [{"summary", "due" (ISO date/datetime string or None)}, ...],
|
|
# already filtered to outstanding (not-completed) tasks and sorted
|
|
# by due date -- see caldav_client.fetch_tasks.
|
|
calendar_tasks_cached: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
|
|
|
|
# -- state --
|
|
current_asset_id: Mapped[str] = mapped_column(String, default="")
|
|
current_asset_set_at: Mapped[float] = mapped_column(Float, default=0.0)
|
|
queue: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list)
|
|
queue_cursor: Mapped[int] = mapped_column(Integer, default=0)
|
|
history: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list)
|
|
excluded_asset_ids: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list)
|
|
|
|
# -- telemetry --
|
|
battery_percent: Mapped[int] = mapped_column(Integer, default=-1)
|
|
battery_as_of: Mapped[float] = mapped_column(Float, default=0.0)
|
|
# Current discharge cycle only (reset on recharge detection); the
|
|
# permanent record is the battery_log table.
|
|
battery_history: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list)
|
|
last_seen: Mapped[float] = mapped_column(Float, default=0.0)
|
|
device_firmware_version: Mapped[str] = mapped_column(String, default="")
|
|
device_board_variant: Mapped[str] = mapped_column(String, default="")
|
|
|
|
# Battery-low email alert (see routers/device.py's frame_battery).
|
|
# -1 = disabled. Sent to the owner's email once per discharge cycle
|
|
# (battery_alert_sent resets alongside battery_history whenever a
|
|
# recharge is detected, same trigger as stats_recharge_cycles).
|
|
battery_alert_threshold_pct: Mapped[int] = mapped_column(Integer, default=-1)
|
|
battery_alert_sent: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
|
|
# -- firmware / OTA (per frame; image lives at /data/firmware/<id>.bin) --
|
|
firmware_available_version: Mapped[str] = mapped_column(String, default="")
|
|
firmware_update_repo_url: Mapped[str] = mapped_column(String, default="")
|
|
firmware_auto_update: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
firmware_update_token: Mapped[str] = mapped_column(String, default="")
|
|
firmware_update_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
|
|
firmware_gitea_latest_version: Mapped[str] = mapped_column(String, default="")
|
|
|
|
# -- 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)
|
|
stats_photos_displayed: Mapped[int] = mapped_column(Integer, default=0)
|
|
stats_photos_removed: Mapped[int] = mapped_column(Integer, default=0)
|
|
stats_battery_reports: Mapped[int] = mapped_column(Integer, default=0)
|
|
stats_recharge_cycles: Mapped[int] = mapped_column(Integer, default=0)
|
|
stats_ota_updates_applied: Mapped[int] = mapped_column(Integer, default=0)
|
|
stats_config_saves: Mapped[int] = mapped_column(Integer, default=0)
|
|
|
|
owner: Mapped[User | None] = relationship(foreign_keys=[owner_user_id])
|
|
controlled_by: Mapped[User | None] = relationship(foreign_keys=[controlled_by_user_id])
|
|
|
|
|
|
class UserFrame(Base):
|
|
"""A user linked to a frame: sees it in their sidebar, may view its
|
|
pages, and may take control. Ownership (whose Immich creds the frame
|
|
renders from) is frames.owner_user_id, separate from linking."""
|
|
|
|
__tablename__ = "user_frames"
|
|
|
|
user_id: Mapped[int] = mapped_column(
|
|
ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
|
|
)
|
|
frame_id: Mapped[int] = mapped_column(
|
|
ForeignKey("frames.id", ondelete="CASCADE"), primary_key=True
|
|
)
|
|
created_at: Mapped[float] = mapped_column(Float, default=time.time)
|
|
|
|
|
|
class FrameCalendar(Base):
|
|
"""One calendar included on one frame -- calendar_key is "ics" (the
|
|
owner's single calendar_ics_url) or "caldav:<href>" (one of the
|
|
owner's CalDAV collections; href matches an entry in
|
|
User.calendar_caldav_calendars). Replaces the old single
|
|
UserFrame.calendar_included boolean now that a CalDAV account can
|
|
expose more than one calendar.
|
|
|
|
A row only ever gets created by its own owner (adding a calendar to
|
|
a frame is each person's own data-sharing choice, not something a
|
|
frame's controller decides on their behalf) -- but once it exists,
|
|
ANY user linked to the frame may flip included back to False, muting
|
|
a calendar they'd rather not see on a shared display even though
|
|
they don't own it. Only the owner may flip it back to True. See
|
|
routers/api_frames.py's api_calendar_select."""
|
|
|
|
__tablename__ = "frame_calendars"
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
frame_id: Mapped[int] = mapped_column(ForeignKey("frames.id", ondelete="CASCADE"))
|
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
|
calendar_key: Mapped[str] = mapped_column(String)
|
|
# Snapshot label for display -- so the list still reads sensibly even
|
|
# if the owner's CalDAV account later stops offering this calendar.
|
|
calendar_label: Mapped[str] = mapped_column(String, default="")
|
|
included: Mapped[bool] = mapped_column(Boolean, default=True)
|
|
# Index into image_pipeline.DEFAULT_PALETTE_RGB/PALETTE_LABELS (2-5:
|
|
# Yellow/Red/Blue/Green -- 0/1 are reserved, already the page's
|
|
# text/background) pinning this calendar's events to a specific
|
|
# panel color rather than calendar_render.py's old owner-name
|
|
# auto-cycle. NULL keeps the auto-cycle behavior. Only the calendar's
|
|
# owner may set this -- see routers/api_frames.py's api_calendar_color.
|
|
color_index: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None)
|
|
|
|
__table_args__ = (
|
|
Index("ix_frame_calendars_unique", "frame_id", "user_id", "calendar_key", unique=True),
|
|
)
|
|
|
|
|
|
class PendingClaim(Base):
|
|
"""A claim submitted before the frame's first check-in (the user beat
|
|
the device to the server after provisioning). Attached automatically
|
|
when a device with this id self-registers; expired rows are pruned
|
|
opportunistically."""
|
|
|
|
__tablename__ = "pending_claims"
|
|
|
|
device_id: Mapped[str] = mapped_column(String, primary_key=True)
|
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
|
created_at: Mapped[float] = mapped_column(Float, default=time.time)
|
|
expires_at: Mapped[float] = mapped_column(Float)
|
|
|
|
|
|
class ServerSettings(Base):
|
|
"""Singleton row (id always 1) holding operator-level SMTP config, set
|
|
from /admin -- not env vars, since this is infrastructure a household
|
|
admin configures once through the UI rather than at container
|
|
deploy time. Used for password-reset emails and battery-threshold
|
|
alerts (see app/mail.py). smtp_host empty = email sending disabled;
|
|
every send site checks that and no-ops rather than erroring."""
|
|
|
|
__tablename__ = "server_settings"
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
smtp_host: Mapped[str] = mapped_column(String, default="")
|
|
smtp_port: Mapped[int] = mapped_column(Integer, default=587)
|
|
smtp_username: Mapped[str] = mapped_column(String, default="")
|
|
smtp_password: Mapped[str] = mapped_column(String, default="")
|
|
smtp_from_address: Mapped[str] = mapped_column(String, default="")
|
|
# "none" (plaintext, port 25 typically), "starttls" (upgrades a
|
|
# plaintext connection, port 587 typically), or "ssl" (TLS from the
|
|
# first byte -- a different handshake entirely, not just starttls()
|
|
# skipped; port 465 typically). See app/mail.py.
|
|
smtp_encryption: Mapped[str] = mapped_column(String, default="starttls")
|
|
|
|
|
|
class PasswordResetToken(Base):
|
|
"""A single-use, time-limited "forgot password" link. token is the
|
|
URL-safe secret itself (not hashed, like PendingClaim/manage_token --
|
|
it's a short-lived bearer credential emailed once, not a long-lived
|
|
session secret)."""
|
|
|
|
__tablename__ = "password_reset_tokens"
|
|
|
|
token: Mapped[str] = mapped_column(String, primary_key=True)
|
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
|
created_at: Mapped[float] = mapped_column(Float, default=time.time)
|
|
expires_at: Mapped[float] = mapped_column(Float)
|
|
|
|
|
|
class BatteryLog(Base):
|
|
"""Every battery report ever, per frame -- the permanent record behind
|
|
the battery history chart (was a 20k-entry JSON array in config.json)."""
|
|
|
|
__tablename__ = "battery_log"
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
frame_id: Mapped[int] = mapped_column(ForeignKey("frames.id", ondelete="CASCADE"))
|
|
ts: Mapped[float] = mapped_column(Float)
|
|
percent: Mapped[int] = mapped_column(Integer)
|
|
|
|
__table_args__ = (Index("ix_battery_log_frame_ts", "frame_id", "ts"),)
|