feat(server): heartbeat-diagnostics stub + migration for real server import
The real server lives in a separate repo; this repo carries reference stubs for each endpoint (see camera_endpoint.py precedent). Adds the Pydantic extension, persistence helper, migration 005, and tests that the real server can copy when adding diagnostic-field support. Matches the firmware v1.1.0 heartbeat payload shape. Old-shape payloads (firmware v1.0.0) continue to parse cleanly with the new fields defaulting to None.
This commit is contained in:
125
server/heartbeat_diagnostics_stub.py
Normal file
125
server/heartbeat_diagnostics_stub.py
Normal file
@@ -0,0 +1,125 @@
|
||||
# server/heartbeat_diagnostics_stub.py
|
||||
# Add these models and the persistence helper to the server's main.py alongside
|
||||
# the existing heartbeat endpoint (POST /api/v1/heartbeat).
|
||||
# Requires: diagnostic columns on the heartbeats table (see migrations/005_heartbeat_diagnostics.sql)
|
||||
#
|
||||
# Firmware v1.1.0 extends the heartbeat payload with five optional diagnostic
|
||||
# fields. v1.0.0-shape payloads (without these fields) must continue to parse
|
||||
# cleanly — every new field is Optional and defaults to None.
|
||||
#
|
||||
# IMPORTANT: Adjust the table name in store_heartbeat_diagnostics to match the
|
||||
# real server's schema if it differs from "heartbeats".
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class RecentEvent(BaseModel):
|
||||
t: int # EventLogTag (see EVENT_TAG_DECODER)
|
||||
d0: int # tag-specific datum 0
|
||||
d1: int # tag-specific datum 1
|
||||
ts: int # unix timestamp (seconds)
|
||||
up: int # seconds since boot when event was logged
|
||||
|
||||
|
||||
# Extend the existing HeartbeatRequest model in main.py by adding these five
|
||||
# optional fields. The rest of the heartbeat model (device_id, uptime, etc.)
|
||||
# stays as-is. Shown here as a standalone model for reference/testing.
|
||||
class HeartbeatDiagnosticsFields(BaseModel):
|
||||
reset_reason: Optional[int] = None
|
||||
heap_free: Optional[int] = None
|
||||
heap_min_free: Optional[int] = None
|
||||
last_disconnect_code: Optional[int] = None
|
||||
recent_events: Optional[List[RecentEvent]] = None
|
||||
|
||||
|
||||
# Example of the fully-extended heartbeat request model (merge into the
|
||||
# existing HeartbeatRequest in main.py rather than introducing a second class):
|
||||
class HeartbeatRequestWithDiagnostics(BaseModel):
|
||||
device_id: str
|
||||
uptime: int
|
||||
# ... existing fields from the v1.0.0 heartbeat model go here ...
|
||||
# New v1.1.0 diagnostic fields:
|
||||
reset_reason: Optional[int] = None
|
||||
heap_free: Optional[int] = None
|
||||
heap_min_free: Optional[int] = None
|
||||
last_disconnect_code: Optional[int] = None
|
||||
recent_events: Optional[List[RecentEvent]] = None
|
||||
|
||||
|
||||
# Call this inside the existing receive_heartbeat handler after the base
|
||||
# heartbeat row has been inserted/updated. It persists the diagnostic fields
|
||||
# on the same row keyed by device_id.
|
||||
def store_heartbeat_diagnostics(
|
||||
db: sqlite3.Connection,
|
||||
device_id: str,
|
||||
hb: HeartbeatRequestWithDiagnostics,
|
||||
) -> None:
|
||||
"""Persist the v1.1.0 diagnostic fields onto the heartbeats row for device_id.
|
||||
|
||||
recent_events is JSON-serialized into a TEXT column for flexibility;
|
||||
the other four fields are stored as INTEGERs. All fields are nullable
|
||||
and left untouched when the payload omits them (v1.0.0 compatibility).
|
||||
"""
|
||||
recent_events_json = (
|
||||
json.dumps([ev.model_dump() for ev in hb.recent_events])
|
||||
if hb.recent_events is not None
|
||||
else None
|
||||
)
|
||||
cursor = db.cursor()
|
||||
cursor.execute(
|
||||
"""UPDATE heartbeats
|
||||
SET reset_reason = ?,
|
||||
heap_free = ?,
|
||||
heap_min_free = ?,
|
||||
last_disconnect_code = ?,
|
||||
recent_events = ?
|
||||
WHERE device_id = ?""",
|
||||
(
|
||||
hb.reset_reason,
|
||||
hb.heap_free,
|
||||
hb.heap_min_free,
|
||||
hb.last_disconnect_code,
|
||||
recent_events_json,
|
||||
device_id,
|
||||
),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Decoders — use these in dashboards / alerting to label the integer tags the
|
||||
# firmware emits. Keep in sync with firmware/include/event_log.h.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# EventLogTag values (RecentEvent.t) -> human name.
|
||||
# Per-tag interpretation of d0/d1:
|
||||
# EVT_BOOT d0=esp_reset_reason()
|
||||
# EVT_WIFI_UP d0=RSSI (int16 cast to uint16)
|
||||
# EVT_WIFI_DOWN d0=disconnect reason (0xFF = silent-death)
|
||||
# EVT_HTTP_OK d0=path_hash, d1=elapsed_ms
|
||||
# EVT_HTTP_FAIL d0=path_hash, d1=http_status_or_errno
|
||||
# EVT_HEARTBEAT_MISS d0=consecutive_count
|
||||
# EVT_NTP_SYNC d0=seconds_since_boot (reserved, not emitted)
|
||||
# EVT_REBOOT d0=RebootReason (see REBOOT_REASON_DECODER)
|
||||
EVENT_TAG_DECODER = {
|
||||
1: "EVT_BOOT",
|
||||
2: "EVT_WIFI_UP",
|
||||
3: "EVT_WIFI_DOWN",
|
||||
4: "EVT_HTTP_OK",
|
||||
5: "EVT_HTTP_FAIL",
|
||||
6: "EVT_HEARTBEAT_MISS",
|
||||
7: "EVT_NTP_SYNC",
|
||||
8: "EVT_REBOOT",
|
||||
}
|
||||
|
||||
# EVT_REBOOT.d0 values -> human name. Firmware-initiated reboot reasons.
|
||||
REBOOT_REASON_DECODER = {
|
||||
1: "HEARTBEAT_MISS",
|
||||
2: "FACTORY_RESET",
|
||||
3: "OTA",
|
||||
4: "WIFI_REPROV",
|
||||
}
|
||||
Reference in New Issue
Block a user