diff --git a/server/heartbeat_diagnostics_stub.py b/server/heartbeat_diagnostics_stub.py new file mode 100644 index 0000000..ea2a714 --- /dev/null +++ b/server/heartbeat_diagnostics_stub.py @@ -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", +} diff --git a/server/migrations/005_heartbeat_diagnostics.sql b/server/migrations/005_heartbeat_diagnostics.sql new file mode 100644 index 0000000..9a806ad --- /dev/null +++ b/server/migrations/005_heartbeat_diagnostics.sql @@ -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 < 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} diff --git a/server/test_heartbeat_diagnostics_stub.py b/server/test_heartbeat_diagnostics_stub.py new file mode 100644 index 0000000..cbf72fd --- /dev/null +++ b/server/test_heartbeat_diagnostics_stub.py @@ -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"