"""device health tab — boot events, heartbeat free_heap, ping samples Adds three pieces of schema needed for the device Health tab: 1. device_boot_events — structured, insert-only history of the firmware's boot_report MQTT event (vesper/{uid}/status/info, type="boot_report"). Previously this payload was flattened into a single device_logs text line with all structured fields (boot_count, crash detail) discarded on arrival. This table gives the console a real timeline to query and chart against, instead of parsing log strings. 2. heartbeats.free_heap — firmware now includes free_heap on every 30s heartbeat (previously only available once per boot via boot_report), so the console can chart heap trend instead of seeing one point per boot. 3. device_ping_samples — backend-computed RTT samples. The firmware's ping command now echoes back a caller-supplied timestamp; the backend pings each online device on an interval and records (now - echoed_ts) here. A dedicated table rather than reusing `commands` because `commands` isn't shaped for time-series charting (mixed command types, no fast per-device time-range query path) and pruning ping history independently of other command history is desirable. Revision ID: f6a7b8c9d0e1 Revises: e5f6a7b8c9d0 Create Date: 2026-07-16 00:00:00.000000 """ from typing import Sequence, Union import sqlalchemy as sa from alembic import op revision: str = "f6a7b8c9d0e1" down_revision: Union[str, None] = "e5f6a7b8c9d0" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: op.create_table( "device_boot_events", sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True), sa.Column("device_serial", sa.String(128), nullable=False), sa.Column("boot_count", sa.Integer(), nullable=True), sa.Column("reset_reason", sa.String(64), nullable=True), sa.Column("is_fault", sa.Boolean(), nullable=False, server_default=sa.false()), sa.Column("free_heap", sa.Integer(), nullable=True), sa.Column("crash_task", sa.String(64), nullable=True), sa.Column("crash_pc", sa.BigInteger(), nullable=True), sa.Column("crash_exc_cause", sa.Integer(), nullable=True), sa.Column("crash_exc_vaddr", sa.BigInteger(), nullable=True), sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), ) op.create_index( "idx_device_boot_events_serial_occurred", "device_boot_events", ["device_serial", sa.text("occurred_at DESC")], ) op.add_column("heartbeats", sa.Column("free_heap", sa.Integer(), nullable=True)) op.create_table( "device_ping_samples", sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True), sa.Column("device_serial", sa.String(128), nullable=False), sa.Column("rtt_ms", sa.Integer(), nullable=False), sa.Column("sampled_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), ) op.create_index( "idx_device_ping_samples_serial_sampled", "device_ping_samples", ["device_serial", sa.text("sampled_at DESC")], ) def downgrade() -> None: op.drop_index("idx_device_ping_samples_serial_sampled", table_name="device_ping_samples") op.drop_table("device_ping_samples") op.drop_column("heartbeats", "free_heap") op.drop_index("idx_device_boot_events_serial_occurred", table_name="device_boot_events") op.drop_table("device_boot_events")