#!/usr/bin/env python3 """ Water Level Alerting System with Matrix Integration """ import datetime import os from dataclasses import dataclass from enum import Enum from typing import Dict, List, Optional import requests try: from .config import Config from .database_adapters import create_database_adapter from .logging_config import get_logger except ImportError: import logging from config import Config from database_adapters import create_database_adapter def get_logger(name): return logging.getLogger(name) logger = get_logger(__name__) class AlertLevel(Enum): INFO = "info" WARNING = "warning" CRITICAL = "critical" EMERGENCY = "emergency" @dataclass class WaterAlert: station_code: str station_name: str alert_type: str level: AlertLevel water_level: float threshold: float discharge: Optional[float] = None timestamp: Optional[datetime.datetime] = None message: Optional[str] = None class MatrixNotifier: def __init__(self, homeserver: str, access_token: str, room_id: str): self.homeserver = homeserver.rstrip("/") self.access_token = access_token self.room_id = room_id self.session = requests.Session() def send_message(self, message: str, msgtype: str = "m.text") -> bool: """Send message to Matrix room""" try: # Add transaction ID to prevent duplicates txn_id = datetime.datetime.now().strftime("%Y%m%d_%H%M%S_%f") url = f"{self.homeserver}/_matrix/client/v3/rooms/{self.room_id}/send/m.room.message/{txn_id}" headers = {"Authorization": f"Bearer {self.access_token}", "Content-Type": "application/json"} data = {"msgtype": msgtype, "body": message} # Matrix API requires PUT when transaction ID is in the URL path response = self.session.put(url, headers=headers, json=data, timeout=10) response.raise_for_status() logger.info(f"Matrix message sent successfully: {response.json().get('event_id')}") return True except Exception as e: logger.error(f"Failed to send Matrix message: {e}") return False def send_alert(self, alert: WaterAlert) -> bool: """Send formatted water alert to Matrix""" emoji_map = { AlertLevel.INFO: "โ„น๏ธ", AlertLevel.WARNING: "โš ๏ธ", AlertLevel.CRITICAL: "๐Ÿšจ", AlertLevel.EMERGENCY: "๐Ÿ†˜", } emoji = emoji_map.get(alert.level, "๐Ÿ“Š") message = f"""{emoji} **WATER LEVEL ALERT** **Station:** {alert.station_code} ({alert.station_name}) **Alert Type:** {alert.alert_type} **Severity:** {alert.level.value.upper()} **Current Level:** {alert.water_level:.2f}m **Threshold:** {alert.threshold:.2f}m **Difference:** {(alert.water_level - alert.threshold):+.2f}m """ if alert.discharge: message += f"**Discharge:** {alert.discharge:.1f} cms\n" if alert.timestamp: message += f"**Time:** {alert.timestamp.strftime('%Y-%m-%d %H:%M:%S')}\n" if alert.message: message += f"\n**Details:** {alert.message}\n" # Add Grafana dashboard link grafana_url = ( "https://metrics.b4l.co.th/d/ac9b26b7-d898-49bd-ad8e-32f0496f6741/psql-water" "?orgId=1&from=now-30d&to=now&timezone=browser" ) message += f"\n๐Ÿ“ˆ **View Dashboard:** {grafana_url}" return self.send_message(message) class WaterLevelAlertSystem: # Stations upstream of Chiang Mai (and CNX itself) to monitor UPSTREAM_STATIONS = { "P.20", # Ban Chiang Dao "P.75", # Ban Chai Lat "P.92", # Ban Muang Aut "P.4A", # Ban Mae Taeng "P.67", # Ban Tae "P.21", # Ban Rim Tai "P.103", # Ring Bridge 3 "P.1", # Nawarat Bridge (Chiang Mai) } def __init__(self): self.db_adapter = None self.matrix_notifier = None self.thresholds = self._load_thresholds() # Matrix configuration from environment matrix_homeserver = os.getenv("MATRIX_HOMESERVER", "https://matrix.org") matrix_token = os.getenv("MATRIX_ACCESS_TOKEN") matrix_room = os.getenv("MATRIX_ROOM_ID") if matrix_token and matrix_room: self.matrix_notifier = MatrixNotifier(matrix_homeserver, matrix_token, matrix_room) logger.info("Matrix notifications enabled") else: logger.warning("Matrix configuration missing - notifications disabled") def _load_thresholds(self) -> Dict[str, Dict[str, float]]: """Load alert thresholds from config or database""" # Default thresholds for Northern Thailand stations return { "P.1": { # Zone-based thresholds for Nawarat Bridge (P.1) "zone_1": 3.7, "zone_2": 3.9, "zone_3": 4.0, "zone_4": 4.1, "zone_5": 4.2, "zone_6": 4.3, "zone_7": 4.6, "zone_8": 4.8, "newedge": 4.8, # Same as zone 8 or adjust as needed # Keep legacy thresholds for compatibility "warning": 3.7, "critical": 4.3, "emergency": 4.8, }, "P.4A": {"warning": 4.5, "critical": 6.0, "emergency": 7.5}, "P.20": {"warning": 3.0, "critical": 4.5, "emergency": 6.0}, "P.21": {"warning": 4.0, "critical": 5.5, "emergency": 7.0}, "P.67": {"warning": 6.0, "critical": 8.0, "emergency": 10.0}, "P.75": {"warning": 5.5, "critical": 7.5, "emergency": 9.5}, "P.103": {"warning": 7.0, "critical": 9.0, "emergency": 11.0}, # Default for unknown stations "default": {"warning": 4.0, "critical": 6.0, "emergency": 8.0}, } def connect_database(self): """Initialize database connection""" try: db_config = Config.get_database_config() self.db_adapter = create_database_adapter( db_config["type"], connection_string=db_config["connection_string"] ) if self.db_adapter.connect(): logger.info("Database connection established for alerting") return True else: logger.error("Failed to connect to database") return False except Exception as e: logger.error(f"Database connection error: {e}") return False def check_water_levels(self) -> List[WaterAlert]: """Check current water levels against thresholds""" alerts = [] if not self.db_adapter: logger.error("Database not connected") return alerts try: # Get latest measurements measurements = self.db_adapter.get_latest_measurements(limit=50) for measurement in measurements: station_code = measurement.get("station_code", "UNKNOWN") water_level = measurement.get("water_level") if not water_level: continue # Only alert for upstream stations and Chiang Mai if station_code not in self.UPSTREAM_STATIONS: continue # Get thresholds for this station station_thresholds = self.thresholds.get(station_code, self.thresholds["default"]) # Check each threshold level alert_level = None threshold_value = None alert_type = None # Special handling for P.1 with zone-based thresholds if station_code == "P.1" and "zone_1" in station_thresholds: # Check all zones in reverse order (highest to lowest) zones = [ ("zone_8", 4.8, AlertLevel.EMERGENCY, "Zone 8 - Emergency"), ("newedge", 4.8, AlertLevel.EMERGENCY, "NewEdge Alert Level"), ("zone_7", 4.6, AlertLevel.CRITICAL, "Zone 7 - Critical"), ("zone_6", 4.3, AlertLevel.CRITICAL, "Zone 6 - Critical"), ("zone_5", 4.2, AlertLevel.WARNING, "Zone 5 - Warning"), ("zone_4", 4.1, AlertLevel.WARNING, "Zone 4 - Warning"), ("zone_3", 4.0, AlertLevel.WARNING, "Zone 3 - Warning"), ("zone_2", 3.9, AlertLevel.INFO, "Zone 2 - Info"), ("zone_1", 3.7, AlertLevel.INFO, "Zone 1 - Info"), ] for zone_name, zone_threshold, zone_alert_level, zone_description in zones: if water_level >= zone_threshold: alert_level = zone_alert_level threshold_value = zone_threshold alert_type = zone_description break else: # Standard threshold checking for other stations if water_level >= station_thresholds.get("emergency", float("inf")): alert_level = AlertLevel.EMERGENCY threshold_value = station_thresholds["emergency"] alert_type = "Emergency Water Level" elif water_level >= station_thresholds.get("critical", float("inf")): alert_level = AlertLevel.CRITICAL threshold_value = station_thresholds["critical"] alert_type = "Critical Water Level" elif water_level >= station_thresholds.get("warning", float("inf")): alert_level = AlertLevel.WARNING threshold_value = station_thresholds["warning"] alert_type = "High Water Level" if alert_level: alert = WaterAlert( station_code=station_code, station_name=measurement.get("station_name_th", f"Station {station_code}"), alert_type=alert_type, level=alert_level, water_level=water_level, threshold=threshold_value, discharge=measurement.get("discharge"), timestamp=measurement.get("timestamp"), ) alerts.append(alert) except Exception as e: logger.error(f"Error checking water levels: {e}") return alerts def check_data_freshness(self, max_age_hours: int = 12) -> List[WaterAlert]: """Check if data is fresh enough""" alerts = [] if not self.db_adapter: return alerts try: measurements = self.db_adapter.get_latest_measurements(limit=20) cutoff_time = datetime.datetime.now() - datetime.timedelta(hours=max_age_hours) for measurement in measurements: timestamp = measurement.get("timestamp") if timestamp and timestamp < cutoff_time: station_code = measurement.get("station_code", "UNKNOWN") age_hours = (datetime.datetime.now() - timestamp).total_seconds() / 3600 alert = WaterAlert( station_code=station_code, station_name=measurement.get("station_name_th", f"Station {station_code}"), alert_type="Stale Data", level=AlertLevel.WARNING, water_level=measurement.get("water_level", 0), threshold=max_age_hours, timestamp=timestamp, message=f"No fresh data for {age_hours:.1f} hours", ) alerts.append(alert) except Exception as e: logger.error(f"Error checking data freshness: {e}") return alerts def check_rate_of_change(self, lookback_hours: int = 3) -> List[WaterAlert]: """Check for rapid water level changes over recent hours""" alerts = [] if not self.db_adapter: return alerts try: # Define rate-of-change thresholds (meters per hour) rate_thresholds = { "P.1": { "warning": 0.15, # 15cm/hour - moderate rise "critical": 0.25, # 25cm/hour - rapid rise "emergency": 0.40, # 40cm/hour - very rapid rise }, "default": {"warning": 0.20, "critical": 0.35, "emergency": 0.50}, } # Get recent measurements for each station cutoff_time = datetime.datetime.now() - datetime.timedelta(hours=lookback_hours) # Get unique stations from latest data latest = self.db_adapter.get_latest_measurements(limit=20) station_codes = set(m.get("station_code") for m in latest if m.get("station_code")) for station_code in station_codes: try: # Only alert for upstream stations and Chiang Mai if station_code not in self.UPSTREAM_STATIONS: continue # Get measurements for this station in the time window current_time = datetime.datetime.now() measurements = self.db_adapter.get_measurements_by_timerange( start_time=cutoff_time, end_time=current_time, station_codes=[station_code] ) if len(measurements) < 2: continue # Need at least 2 points to calculate rate # Sort by timestamp measurements = sorted(measurements, key=lambda m: m.get("timestamp")) # Get oldest and newest measurements oldest = measurements[0] newest = measurements[-1] oldest_time = oldest.get("timestamp") oldest_level = oldest.get("water_level") newest_time = newest.get("timestamp") newest_level = newest.get("water_level") # Convert timestamp strings to datetime if needed if isinstance(oldest_time, str): oldest_time = datetime.datetime.fromisoformat(oldest_time) if isinstance(newest_time, str): newest_time = datetime.datetime.fromisoformat(newest_time) # Calculate rate of change time_diff_hours = (newest_time - oldest_time).total_seconds() / 3600 if time_diff_hours == 0: continue level_change = newest_level - oldest_level rate_per_hour = level_change / time_diff_hours # Only alert on rising water (positive rate) if rate_per_hour <= 0: continue # Get station info from latest data station_info = next((m for m in latest if m.get("station_code") == station_code), {}) station_name = station_info.get("station_name_th", station_code) # Get thresholds for this station station_rate_threshold = rate_thresholds.get(station_code, rate_thresholds["default"]) alert_level = None threshold_value = None alert_type = None if rate_per_hour >= station_rate_threshold["emergency"]: alert_level = AlertLevel.EMERGENCY threshold_value = station_rate_threshold["emergency"] alert_type = "Very Rapid Water Level Rise" elif rate_per_hour >= station_rate_threshold["critical"]: alert_level = AlertLevel.CRITICAL threshold_value = station_rate_threshold["critical"] alert_type = "Rapid Water Level Rise" elif rate_per_hour >= station_rate_threshold["warning"]: alert_level = AlertLevel.WARNING threshold_value = station_rate_threshold["warning"] alert_type = "Moderate Water Level Rise" if alert_level: message = ( f"Rising at {rate_per_hour:.2f}m/h over last {time_diff_hours:.1f}h " f"(change: {level_change:+.2f}m)" ) alert = WaterAlert( station_code=station_code, station_name=station_name or f"Station {station_code}", alert_type=alert_type, level=alert_level, water_level=newest_level, threshold=threshold_value, timestamp=newest_time, message=message, ) alerts.append(alert) except Exception as station_error: logger.debug(f"Error checking rate of change for station {station_code}: {station_error}") continue except Exception as e: logger.error(f"Error checking rate of change: {e}") return alerts def send_alerts(self, alerts: List[WaterAlert]) -> int: """Send alerts via configured channels""" sent_count = 0 if not alerts: return sent_count if self.matrix_notifier: for alert in alerts: if self.matrix_notifier.send_alert(alert): sent_count += 1 # Could add other notification channels here: # - Email # - Discord # - Telegram # - SMS return sent_count def run_alert_check(self) -> Dict[str, int]: """Run complete alert check cycle""" if not self.connect_database(): return {"error": 1} # Check water levels water_alerts = self.check_water_levels() # Check data freshness data_alerts = self.check_data_freshness() # Check rate of change (rapid rises) rate_alerts = self.check_rate_of_change() # Combine alerts all_alerts = water_alerts + data_alerts + rate_alerts # Send alerts sent_count = self.send_alerts(all_alerts) logger.info(f"Alert check complete: {len(all_alerts)} alerts, {sent_count} sent") return { "water_alerts": len(water_alerts), "data_alerts": len(data_alerts), "rate_alerts": len(rate_alerts), "total_alerts": len(all_alerts), "sent": sent_count, } def main(): """Standalone alerting check""" import argparse parser = argparse.ArgumentParser(description="Water Level Alert System") parser.add_argument("--check", action="store_true", help="Run alert check") parser.add_argument("--test", action="store_true", help="Send test message") args = parser.parse_args() alerting = WaterLevelAlertSystem() if args.test: if alerting.matrix_notifier: test_message = ( "๐Ÿงช **Test Alert**\n\nThis is a test message from the Water Level Alert System.\n\n" "If you received this, Matrix notifications are working correctly!" ) success = alerting.matrix_notifier.send_message(test_message) print(f"Test message sent: {success}") else: print("Matrix notifier not configured") elif args.check: results = alerting.run_alert_check() print(f"Alert check results: {results}") else: print("Use --check or --test") if __name__ == "__main__": main()