Files
bellsystems-cp/backend/mqtt/models.py
T
bonaminandClaude Sonnet 5 7c533b9245 feat(mqtt): add device health telemetry and migrate to v2 topic spec
Two efforts that landed together because the v2 topic work extends
tables the health-telemetry effort added days earlier in the same
files/functions, making them impractical to separate cleanly:

Health/diagnostics telemetry (schema, Jul 13-17):
- New Postgres tables: device_alert_events, device_boot_events,
  device_ping_samples, device_diagnostics_reports, plus a `source`
  column on device_logs to distinguish log origins
- Query/service layer in pg_mqtt.py and database/__init__.py for
  inserting and listing this history, plus a "latest metrics" endpoint
  combining most-recent diagnostics + ping RTT per device
- mqtt/router.py gains list endpoints for alert/boot/ping/diagnostics
  history, consumed by the upcoming Health tab

MQTT v2 topic migration (Sep 21):
- Heartbeat payload flattened per vesper_mqtt_topic_spec_v2.md, adding
  rssi/free_heap/state/ok fields
- Command replies move to control/ack, device-initiated events to
  control/reports; mqtt/client.py subscribes to the new topic set and
  runs a ping_loop (wired up in main.py) for RTT sampling
- mqtt/logger.py and pg_mqtt.py updated to parse and persist the new
  payload shape alongside the legacy fields for backwards compatibility

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 18:24:36 +03:00

210 lines
5.3 KiB
Python

from pydantic import BaseModel, Field
from typing import Optional, List, Any, Dict
from enum import Enum
class LogLevel(str, Enum):
INFO = "INFO"
WARN = "WARN"
ERROR = "ERROR"
class CommandStatus(str, Enum):
PENDING = "pending"
SUCCESS = "success"
ERROR = "error"
TIMEOUT = "timeout"
class MqttCommandRequest(BaseModel):
cmd: str = Field(..., description="Command name: ping, playback, file_manager, relay_setup, clock_setup, system_info, system")
contents: Dict[str, Any] = Field(default_factory=dict, description="Command payload contents")
class DeviceLogEntry(BaseModel):
id: int
device_serial: str
level: str
message: str
device_timestamp: Optional[int] = None
source: str = "log"
received_at: str
class HeartbeatEntry(BaseModel):
id: int
device_serial: str
device_id: Optional[str] = None
firmware_version: Optional[str] = None
ip_address: Optional[str] = None
gateway: Optional[str] = None
uptime_ms: Optional[int] = None
uptime_display: Optional[str] = None
rssi: Optional[int] = None
free_heap: Optional[int] = None
state: Optional[str] = None # "idle" | "playing" | "paused" | "error" | "booting" (v2 firmware only)
ok: Optional[bool] = None # overall device health, independent of playback state (v2 firmware only)
received_at: str
class CommandEntry(BaseModel):
id: int
device_serial: str
command_name: str
command_payload: Optional[str] = None
status: str
response_payload: Optional[str] = None
sent_at: str
responded_at: Optional[str] = None
class AlertEventEntry(BaseModel):
id: int
device_serial: str
subsystem: str
state: str
message: Optional[str] = None
occurred_at: str
class DeviceMqttStatus(BaseModel):
device_serial: str
online: bool
last_heartbeat: Optional[HeartbeatEntry] = None
seconds_since_heartbeat: Optional[int] = None
last_alert_event: Optional[AlertEventEntry] = None
class MqttStatusResponse(BaseModel):
devices: List[DeviceMqttStatus]
broker_connected: bool = False
class LogListResponse(BaseModel):
logs: List[DeviceLogEntry]
total: int
class HeartbeatListResponse(BaseModel):
heartbeats: List[HeartbeatEntry]
total: int
class CommandListResponse(BaseModel):
commands: List[CommandEntry]
total: int
class CommandSendResponse(BaseModel):
success: bool
command_id: int
message: str
class DeviceAlertEntry(BaseModel):
device_serial: str
subsystem: str
state: str
message: Optional[str] = None
updated_at: str
class DeviceAlertsResponse(BaseModel):
alerts: List[DeviceAlertEntry]
class AlertEventListResponse(BaseModel):
events: List[AlertEventEntry]
total: int
class BootEventEntry(BaseModel):
id: int
device_serial: str
boot_count: Optional[int] = None
reset_reason: Optional[str] = None
is_fault: bool = False
free_heap: Optional[int] = None
crash_task: Optional[str] = None
crash_pc: Optional[int] = None
crash_exc_cause: Optional[int] = None
crash_exc_vaddr: Optional[int] = None
occurred_at: str
class BootEventListResponse(BaseModel):
events: List[BootEventEntry]
total: int
class PingSampleEntry(BaseModel):
id: int
device_serial: str
rtt_ms: int
sampled_at: str
class PingSampleListResponse(BaseModel):
samples: List[PingSampleEntry]
total: int
class DiagnosticsReportEntry(BaseModel):
id: int
device_serial: str
cpu_temp_avg: Optional[float] = None
cpu_temp_min: Optional[float] = None
cpu_temp_max: Optional[float] = None
cpu_temp_samples: Optional[int] = None
wifi_reconnect_count: Optional[int] = None
wifi_last_disconnect_reason: Optional[str] = None
wifi_last_disconnect_uptime_ms: Optional[int] = None
ota_current_version: Optional[str] = None
ota_update_available: Optional[bool] = None
ota_available_version: Optional[str] = None
ota_last_check_uptime_ms: Optional[int] = None
ota_last_error: Optional[str] = None
stack_high_water: Optional[str] = None # JSON-encoded string — see insert_diagnostics_report
bell_strikes: Optional[str] = None # JSON-encoded string, {"0": count, ...} — v2 firmware only
bell_loads: Optional[str] = None # JSON-encoded string, {"0": load, ...} — v2 firmware only
cooling_active: Optional[bool] = None # v2 firmware only
received_at: str
class DiagnosticsReportListResponse(BaseModel):
reports: List[DiagnosticsReportEntry]
total: int
class DeviceReportEntry(BaseModel):
"""A control/reports event (currently only bell_overload) — see
project-vesper's vesper_mqtt_topic_spec_v2.md. Console-side storage is
intentionally light: history/audit only, not a real-time UI surface."""
id: int
device_serial: str
report_type: str
payload: Optional[str] = None # JSON-encoded string
occurred_at: str
class DeviceReportListResponse(BaseModel):
reports: List[DeviceReportEntry]
total: int
class LatestDiagnosticsEntry(BaseModel):
device_serial: str
cpu_temp_avg: Optional[float] = None
received_at: str
class LatestPingEntry(BaseModel):
device_serial: str
rtt_ms: int
sampled_at: str
class LatestMetricsResponse(BaseModel):
diagnostics: List[LatestDiagnosticsEntry]
pings: List[LatestPingEntry]