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