from pydantic import BaseModel, Field from typing import Any, Dict, List, Optional from enum import Enum # --- Enums --- class HelperMelodyTypes(str, Enum): orthodox = "orthodox" catholic = "catholic" all = "all" class HelperRingAlerts(str, Enum): disabled = "disabled" single = "single" multi = "multi" class DeviceTiers(str, Enum): basic = "basic" small = "small" mini = "mini" premium = "premium" vip = "vip" custom = "custom" # --- Nested Structs --- class DeviceNetworkSettings(BaseModel): hostname: str = "" useStaticIP: bool = False ipAddress: Any = [] gateway: Any = [] subnet: Any = [] dns1: Any = [] dns2: Any = [] class DeviceClockSettings(BaseModel): clockOutputs: List[int] = [] clockTimings: List[int] = [] ringAlertsMasterOn: bool = False ringAlerts: HelperRingAlerts = HelperRingAlerts.disabled ringIntervals: int = 0 hourAlertsBell: int = 0 halfhourAlertsBell: int = 0 quarterAlertsBell: int = 0 isDaySilenceOn: bool = False isNightSilenceOn: bool = False daySilenceFrom: str = "" daySilenceTo: str = "" nightSilenceFrom: str = "" nightSilenceTo: str = "" backlightTurnOnTime: str = "" backlightTurnOffTime: str = "" isBacklightAutomationOn: bool = False backlightOutput: int = 0 class DeviceAttributes(BaseModel): hasAssistant: bool = False hasClock: bool = False hasBells: bool = False totalBells: int = 0 bellOutputs: List[int] = [] hammerTimings: List[int] = [] bellGuardOn: bool = False bellGuardSafetyOn: bool = False warningsOn: bool = False towerClockTime: str = "" clockSettings: DeviceClockSettings = DeviceClockSettings() deviceLocale: HelperMelodyTypes = HelperMelodyTypes.all networkSettings: DeviceNetworkSettings = DeviceNetworkSettings() serialLogLevel: int = 0 sdLogLevel: int = 0 mqttLogLevel: int = 0 class DeviceSubInformation(BaseModel): subscrTier: DeviceTiers = DeviceTiers.basic subscrStart: str = "" subscrDuration: int = 0 maxUsers: int = 0 maxOutputs: int = 0 class DeviceStatistics(BaseModel): totalPlaybacks: int = 0 totalHammerStrikes: int = 0 perBellStrikes: List[int] = [] totalWarningsGiven: int = 0 warrantyActive: bool = False warrantyStart: str = "" warrantyPeriod: int = 0 maintainedOn: str = "" maintainancePeriod: int = 0 class HealthThresholdTier(BaseModel): """One severity tier's worth of metric thresholds. Two tiers (warning, critical) make up DeviceHealthSettings — see there for why.""" restarts_per_week_threshold: int rssi_floor_dbm: int free_heap_floor_kb: int cpu_temp_ceiling_c: int class DeviceHealthSettings(BaseModel): """Per-device alert thresholds shown/edited on the Health tab's Settings sub-tab. Purely advisory today — thresholds drive a client-computed ok/warning/critical status icon, not any server-side alerting yet (no email/push infra exists). Two severity tiers per metric (warning fires first, critical is the harder breach) so the header status icon can show orange vs. red rather than a single degraded/healthy binary. offline_timeout_sec also feeds the Health tab's chart gap detection (LineChart's gapThresholdMs) so both share one source of truth.""" warning: HealthThresholdTier = HealthThresholdTier( restarts_per_week_threshold=6, rssi_floor_dbm=-80, free_heap_floor_kb=30, cpu_temp_ceiling_c=65, ) critical: HealthThresholdTier = HealthThresholdTier( restarts_per_week_threshold=12, rssi_floor_dbm=-90, free_heap_floor_kb=15, cpu_temp_ceiling_c=80, ) offline_timeout_sec: int = 90 email_on_threshold: bool = False push_on_crash_boot: bool = False class MelodyMainItem(BaseModel): """Mirrors the Melody schema used in the melodies collection.""" information: dict = {} default_settings: dict = {} type: str = "" url: str = "" uid: str = "" pid: str = "" # --- Request / Response schemas --- class DeviceCreate(BaseModel): device_name: str = "" # Console-only friendly name — set by staff, independent of the name the # end user gave the device in the app. Never shown to app users, never # synced from/to device_name. Falls back to device_name when empty. console_name: str = "" device_photo: str = "" device_location: str = "" is_Online: bool = False device_attributes: DeviceAttributes = DeviceAttributes() device_subscription: DeviceSubInformation = DeviceSubInformation() device_stats: DeviceStatistics = DeviceStatistics() device_health_settings: DeviceHealthSettings = DeviceHealthSettings() events_on: bool = False device_location_coordinates: Any = None # GeoPoint dict {lat, lng} or legacy str device_melodies_all: List[MelodyMainItem] = [] device_melodies_favorites: List[str] = [] user_list: List[str] = [] websocket_url: str = "" churchAssistantURL: str = "" staffNotes: str = "" hw_family: str = "" hw_revision: str = "" tags: List[str] = [] serial_number: str = "" customer_id: str = "" mfg_status: str = "" class DeviceUpdate(BaseModel): device_name: Optional[str] = None console_name: Optional[str] = None device_photo: Optional[str] = None device_location: Optional[str] = None is_Online: Optional[bool] = None # Use raw dicts so only the fields actually sent are present — no Pydantic defaults device_attributes: Optional[Dict[str, Any]] = None device_subscription: Optional[Dict[str, Any]] = None device_stats: Optional[Dict[str, Any]] = None device_health_settings: Optional[Dict[str, Any]] = None events_on: Optional[bool] = None device_location_coordinates: Optional[Any] = None # dict {lat, lng} or legacy str device_melodies_all: Optional[List[MelodyMainItem]] = None device_melodies_favorites: Optional[List[str]] = None user_list: Optional[List[str]] = None websocket_url: Optional[str] = None churchAssistantURL: Optional[str] = None staffNotes: Optional[str] = None hw_family: Optional[str] = None hw_revision: Optional[str] = None tags: Optional[List[str]] = None customer_id: Optional[str] = None mfg_status: Optional[str] = None class DeviceInDB(DeviceCreate): id: str # Legacy field — kept for backwards compat; new docs use serial_number device_id: str = "" class DeviceListResponse(BaseModel): devices: List[DeviceInDB] total: int class DeviceNoteCreate(BaseModel): content: str created_by: str = "" class DeviceNoteUpdate(BaseModel): content: str class DeviceUserInfo(BaseModel): """User info resolved from device_users sub-collection or user_list.""" user_id: str = "" display_name: str = "" email: str = "" role: str = "" photo_url: str = "" class DeviceUsersResponse(BaseModel): users: List[DeviceUserInfo] total: int class ResetStatsRequest(BaseModel): """Which categories of QA/bench test data to clear before shipping a device to a customer. Each flag is independent — callers check only the boxes they want. device_stats (Firestore) and current_alerts both default to False since they need extra care (current_alerts especially — see ResetStatsResult.current_alerts note).""" bell_stats: bool = False # Firestore device_stats: totalHammerStrikes, perBellStrikes playback_stats: bool = False # Firestore device_stats: totalPlaybacks, totalWarningsGiven device_logs: bool = False # Postgres device_logs heartbeats: bool = False # Postgres heartbeats commands: bool = False # Postgres commands boot_events: bool = False # Postgres device_boot_events (console-side history) alert_history: bool = False # Postgres device_alert_events ping_samples: bool = False # Postgres device_ping_samples diagnostics_reports: bool = False # Postgres device_diagnostics_reports reports: bool = False # Postgres device_reports (control/reports history, e.g. bell_overload) current_alerts: bool = False # Postgres device_alerts (LIVE state — opt-in only) class ResetStatsResult(BaseModel): """Per-category outcome. Postgres/Firestore categories always run (device connectivity doesn't matter); counts are rows deleted, or null for categories that weren't requested.""" bell_stats: Optional[bool] = None playback_stats: Optional[bool] = None device_logs: Optional[int] = None heartbeats: Optional[int] = None commands: Optional[int] = None boot_events: Optional[int] = None alert_history: Optional[int] = None ping_samples: Optional[int] = None diagnostics_reports: Optional[int] = None reports: Optional[int] = None current_alerts: Optional[int] = None