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",
|
||||||
|
}
|
||||||
14
server/migrations/005_heartbeat_diagnostics.sql
Normal file
14
server/migrations/005_heartbeat_diagnostics.sql
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
-- migrations/005_heartbeat_diagnostics.sql
|
||||||
|
-- Add v1.1.0 diagnostic columns to the existing heartbeats table.
|
||||||
|
-- Adjust the table name ("heartbeats") to match the real server's schema.
|
||||||
|
-- Apply: sqlite3 <db_file> < migrations/005_heartbeat_diagnostics.sql
|
||||||
|
--
|
||||||
|
-- sqlite's ALTER TABLE ADD COLUMN only takes one column per statement, so
|
||||||
|
-- each field is added separately. All columns are nullable, so firmware
|
||||||
|
-- v1.0.0 payloads (which omit these fields) remain accepted unchanged.
|
||||||
|
|
||||||
|
ALTER TABLE heartbeats ADD COLUMN reset_reason INTEGER;
|
||||||
|
ALTER TABLE heartbeats ADD COLUMN heap_free INTEGER;
|
||||||
|
ALTER TABLE heartbeats ADD COLUMN heap_min_free INTEGER;
|
||||||
|
ALTER TABLE heartbeats ADD COLUMN last_disconnect_code INTEGER;
|
||||||
|
ALTER TABLE heartbeats ADD COLUMN recent_events TEXT; -- JSON-serialized list of {t,d0,d1,ts,up}
|
||||||
156
server/test_heartbeat_diagnostics_stub.py
Normal file
156
server/test_heartbeat_diagnostics_stub.py
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
# server/test_heartbeat_diagnostics_stub.py
|
||||||
|
# Template tests for the heartbeat diagnostic-fields extension.
|
||||||
|
# Adapt imports and fixtures to match the actual server's test structure.
|
||||||
|
#
|
||||||
|
# To run against the actual server (once integrated):
|
||||||
|
# pytest server/test_heartbeat_diagnostics_stub.py -v
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
|
||||||
|
def _make_db() -> sqlite3.Connection:
|
||||||
|
"""In-memory sqlite fixture matching migrations/005_heartbeat_diagnostics.sql
|
||||||
|
applied on top of a minimal heartbeats table."""
|
||||||
|
db = sqlite3.connect(":memory:")
|
||||||
|
db.execute("""
|
||||||
|
CREATE TABLE heartbeats (
|
||||||
|
device_id TEXT PRIMARY KEY,
|
||||||
|
uptime INTEGER,
|
||||||
|
reset_reason INTEGER,
|
||||||
|
heap_free INTEGER,
|
||||||
|
heap_min_free INTEGER,
|
||||||
|
last_disconnect_code INTEGER,
|
||||||
|
recent_events TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
db.commit()
|
||||||
|
return db
|
||||||
|
|
||||||
|
|
||||||
|
def _v10_payload() -> dict:
|
||||||
|
"""Firmware v1.0.0-shape heartbeat: no diagnostic fields."""
|
||||||
|
return {"device_id": "dc-test-01", "uptime": 12345}
|
||||||
|
|
||||||
|
|
||||||
|
def _v11_payload() -> dict:
|
||||||
|
"""Firmware v1.1.0-shape heartbeat: includes all five diagnostic fields."""
|
||||||
|
return {
|
||||||
|
"device_id": "dc-test-01",
|
||||||
|
"uptime": 12345,
|
||||||
|
"reset_reason": 1,
|
||||||
|
"heap_free": 123456,
|
||||||
|
"heap_min_free": 100000,
|
||||||
|
"last_disconnect_code": 201,
|
||||||
|
"recent_events": [
|
||||||
|
{"t": 1, "d0": 1, "d1": 0, "ts": 1712000000, "up": 0},
|
||||||
|
{"t": 3, "d0": 255, "d1": 0, "ts": 1712000050, "up": 50},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_v10_shape_parses_with_new_fields_none():
|
||||||
|
"""A v1.0.0 heartbeat (no diagnostic fields) must parse cleanly; all new
|
||||||
|
fields default to None."""
|
||||||
|
from server.heartbeat_diagnostics_stub import HeartbeatRequestWithDiagnostics
|
||||||
|
|
||||||
|
hb = HeartbeatRequestWithDiagnostics(**_v10_payload())
|
||||||
|
assert hb.device_id == "dc-test-01"
|
||||||
|
assert hb.uptime == 12345
|
||||||
|
assert hb.reset_reason is None
|
||||||
|
assert hb.heap_free is None
|
||||||
|
assert hb.heap_min_free is None
|
||||||
|
assert hb.last_disconnect_code is None
|
||||||
|
assert hb.recent_events is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_v11_shape_populates_new_fields():
|
||||||
|
"""A v1.1.0 heartbeat populates each diagnostic field and the event list."""
|
||||||
|
from server.heartbeat_diagnostics_stub import HeartbeatRequestWithDiagnostics
|
||||||
|
|
||||||
|
hb = HeartbeatRequestWithDiagnostics(**_v11_payload())
|
||||||
|
assert hb.reset_reason == 1
|
||||||
|
assert hb.heap_free == 123456
|
||||||
|
assert hb.heap_min_free == 100000
|
||||||
|
assert hb.last_disconnect_code == 201
|
||||||
|
assert hb.recent_events is not None
|
||||||
|
assert len(hb.recent_events) == 2
|
||||||
|
assert hb.recent_events[0].t == 1
|
||||||
|
assert hb.recent_events[1].t == 3
|
||||||
|
assert hb.recent_events[1].d0 == 255 # 0xFF silent-death marker
|
||||||
|
assert hb.recent_events[1].ts == 1712000050
|
||||||
|
|
||||||
|
|
||||||
|
def test_store_heartbeat_diagnostics_writes_fields_and_json():
|
||||||
|
"""store_heartbeat_diagnostics must JSON-serialize recent_events and write
|
||||||
|
each integer field as submitted."""
|
||||||
|
from server.heartbeat_diagnostics_stub import (
|
||||||
|
HeartbeatRequestWithDiagnostics,
|
||||||
|
store_heartbeat_diagnostics,
|
||||||
|
)
|
||||||
|
|
||||||
|
db = _make_db()
|
||||||
|
# Seed the heartbeats row the base handler would have inserted first.
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO heartbeats (device_id, uptime) VALUES (?, ?)",
|
||||||
|
("dc-test-01", 12345),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
hb = HeartbeatRequestWithDiagnostics(**_v11_payload())
|
||||||
|
store_heartbeat_diagnostics(db, "dc-test-01", hb)
|
||||||
|
|
||||||
|
row = db.execute(
|
||||||
|
"""SELECT reset_reason, heap_free, heap_min_free,
|
||||||
|
last_disconnect_code, recent_events
|
||||||
|
FROM heartbeats
|
||||||
|
WHERE device_id = ?""",
|
||||||
|
("dc-test-01",),
|
||||||
|
).fetchone()
|
||||||
|
assert row[0] == 1
|
||||||
|
assert row[1] == 123456
|
||||||
|
assert row[2] == 100000
|
||||||
|
assert row[3] == 201
|
||||||
|
events = json.loads(row[4])
|
||||||
|
assert isinstance(events, list)
|
||||||
|
assert len(events) == 2
|
||||||
|
assert events[0] == {"t": 1, "d0": 1, "d1": 0, "ts": 1712000000, "up": 0}
|
||||||
|
assert events[1]["d0"] == 255
|
||||||
|
|
||||||
|
|
||||||
|
def test_store_heartbeat_diagnostics_v10_leaves_fields_null():
|
||||||
|
"""v1.0.0 payload: all diagnostic columns should remain NULL after store."""
|
||||||
|
from server.heartbeat_diagnostics_stub import (
|
||||||
|
HeartbeatRequestWithDiagnostics,
|
||||||
|
store_heartbeat_diagnostics,
|
||||||
|
)
|
||||||
|
|
||||||
|
db = _make_db()
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO heartbeats (device_id, uptime) VALUES (?, ?)",
|
||||||
|
("dc-test-01", 12345),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
hb = HeartbeatRequestWithDiagnostics(**_v10_payload())
|
||||||
|
store_heartbeat_diagnostics(db, "dc-test-01", hb)
|
||||||
|
|
||||||
|
row = db.execute(
|
||||||
|
"""SELECT reset_reason, heap_free, heap_min_free,
|
||||||
|
last_disconnect_code, recent_events
|
||||||
|
FROM heartbeats
|
||||||
|
WHERE device_id = ?""",
|
||||||
|
("dc-test-01",),
|
||||||
|
).fetchone()
|
||||||
|
assert row == (None, None, None, None, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_tag_decoder_labels():
|
||||||
|
"""Sanity check: decoder maps firmware tag values to the expected names."""
|
||||||
|
from server.heartbeat_diagnostics_stub import EVENT_TAG_DECODER, REBOOT_REASON_DECODER
|
||||||
|
|
||||||
|
assert EVENT_TAG_DECODER[1] == "EVT_BOOT"
|
||||||
|
assert EVENT_TAG_DECODER[3] == "EVT_WIFI_DOWN"
|
||||||
|
assert EVENT_TAG_DECODER[8] == "EVT_REBOOT"
|
||||||
|
assert REBOOT_REASON_DECODER[1] == "HEARTBEAT_MISS"
|
||||||
|
assert REBOOT_REASON_DECODER[4] == "WIFI_REPROV"
|
||||||
Reference in New Issue
Block a user