""" TCKRTUIYO - Rainfall Station RTU Web Interface Flask Backend with SocketIO for Real-Time Updates """ import os import json import time import threading from datetime import datetime from flask import Flask, render_template, jsonify, request from flask_socketio import SocketIO, emit # Configuration app = Flask(__name__) app.config['SECRET_KEY'] = 'tckrtuiyo-secret-key-change-in-production' app.config['JSON_SORT_KEYS'] = False # SocketIO with eventlet for production performance socketio = SocketIO(app, cors_allowed_origins="*", async_mode='eventlet') # Data paths DATA_DIR = os.path.join(os.path.dirname(__file__), 'data') SETTINGS_FILE = os.path.join(DATA_DIR, 'settings.json') # Ensure data directory exists os.makedirs(DATA_DIR, exist_ok=True) # Default settings DEFAULT_SETTINGS = { "station": { "id": "TCKRTUIYO-001", "name": "Rainfall Station", "location": "Unknown" }, "datetime": { "timezone": "UTC", "ntp_enabled": True, "ntp_server": "pool.ntp.org" }, "network": { "ip": "192.168.1.100", "subnet": "255.255.255.0", "gateway": "192.168.1.1", "dns": "8.8.8.8", "mac": "00:00:00:00:00:00" }, "mobile": { "enabled": False, "apn": "", "pin": "" }, "adc": { "channels": [ {"id": 1, "type": "4-20mA", "name": "Rainfall RF1", "enabled": True}, {"id": 2, "type": "4-20mA", "name": "Rainfall RF2", "enabled": True}, {"id": 3, "type": "0-10vDC", "name": "Water Level", "enabled": True}, {"id": 4, "type": "0-10vDC", "name": "Solar Voltage", "enabled": True} ] }, "sensors": { "rainfall": { "rf1_id": "RF1", "rf2_id": "RF2", "bucket_size": 0.2 # mm per tip }, "water_level": { "threshold_warning": 80, "threshold_danger": 95 }, "voltage": { "battery_high": 14.0, "battery_normal": 12.0, "battery_low": 11.0, "solar_high": 15.0, "solar_normal": 12.0 } }, "evap": { "enabled": False, "coefficient": 0.0 } } # In-memory sensor state (would be populated by hardware in production) sensor_state = { "rainfall": { "today": 0.0, "hourly": 0.0, "mar": 0.0, "yearly": 0.0, "rf1_tips": 0, "rf2_tips": 0 }, "voltage": { "solar": 12.4, "battery": 12.8, "solar_status": "NORMAL", "battery_status": "HIGH" }, "adc": [ {"id": 1, "type": "4-20mA", "value": 4.2, "datum": 4.0, "reading": 0.5, "unit": "mm"}, {"id": 2, "type": "4-20mA", "value": 4.1, "datum": 4.0, "reading": 0.3, "unit": "mm"}, {"id": 3, "type": "0-10vDC", "value": 2.5, "datum": 0.0, "reading": 25.0, "unit": "cm"}, {"id": 4, "type": "0-10vDC", "value": 6.2, "datum": 0.0, "reading": 12.4, "unit": "V"} ], "communication": { "signal_strength": -65, "connection_status": "Connected", "rssi": "Good" }, "last_updated": None } # Software version SOFTWARE_VERSION = "1.0.0" BUILD_DATE = "2026-03-12" def load_settings(): """Load settings from JSON file or return defaults""" if os.path.exists(SETTINGS_FILE): try: with open(SETTINGS_FILE, 'r') as f: return json.load(f) except: pass return DEFAULT_SETTINGS.copy() def save_settings(settings): """Save settings to JSON file""" with open(SETTINGS_FILE, 'w') as f: json.dump(settings, f, indent=2) def update_sensor_readings(): """Simulate sensor readings (replace with actual hardware reads in production)""" global sensor_state # Simulate small rainfall occasionally import random if random.random() < 0.05: # 5% chance of rain tips = random.randint(1, 3) sensor_state["rainfall"]["today"] += tips * 0.2 sensor_state["rainfall"]["hourly"] += tips * 0.2 sensor_state["rainfall"]["mar"] += tips * 0.2 sensor_state["rainfall"]["yearly"] += tips * 0.2 sensor_state["rainfall"]["rf1_tips"] += tips # Simulate voltage fluctuation sensor_state["voltage"]["solar"] = round(12.0 + random.uniform(-0.5, 1.5), 1) sensor_state["voltage"]["battery"] = round(12.6 + random.uniform(-0.3, 0.3), 1) # Update status based on voltage settings = load_settings() vb = sensor_state["voltage"]["battery"] vs = sensor_state["voltage"]["solar"] if vb >= settings["sensors"]["voltage"]["battery_high"]: sensor_state["voltage"]["battery_status"] = "HIGH" elif vb >= settings["sensors"]["voltage"]["battery_normal"]: sensor_state["voltage"]["battery_status"] = "NORMAL" else: sensor_state["voltage"]["battery_status"] = "LOW" if vs >= settings["sensors"]["voltage"]["solar_normal"]: sensor_state["voltage"]["solar_status"] = "HIGH" else: sensor_state["voltage"]["solar_status"] = "NORMAL" # Update ADC readings for ch in sensor_state["adc"]: ch["value"] = round(ch["value"] + random.uniform(-0.1, 0.1), 2) if ch["type"] == "4-20mA": ch["reading"] = round((ch["value"] - ch["datum"]) / 16.0 * 100, 1) else: ch["reading"] = round(ch["value"] * 2.0, 1) # Update timestamp sensor_state["last_updated"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") def sensor_background_thread(): """Background thread to update sensor readings and emit via SocketIO""" while True: update_sensor_readings() # Emit to all connected clients socketio.emit('sensor_update', { 'rainfall': sensor_state["rainfall"], 'voltage': sensor_state["voltage"], 'last_updated': sensor_state["last_updated"] }) socketio.sleep(1) # ==================== ROUTES ==================== @app.route('/') def index(): """Main kiosk dashboard""" return render_template('dashboard.html', page='dashboard', version=SOFTWARE_VERSION, build_date=BUILD_DATE) @app.route('/settings') def settings(): """Settings page""" return render_template('settings.html', page='settings', version=SOFTWARE_VERSION, build_date=BUILD_DATE) @app.route('/calibration') def calibration(): """Calibration page""" return render_template('calibration.html', page='calibration', version=SOFTWARE_VERSION, build_date=BUILD_DATE) @app.route('/files') def files_page(): """Flash memory / Files page""" return render_template('files.html', page='files', version=SOFTWARE_VERSION, build_date=BUILD_DATE) # ==================== API ENDPOINTS ==================== @app.route('/api/status') def api_status(): """Get system status""" now = datetime.now() settings = load_settings() return jsonify({ "station_id": settings["station"]["id"], "station_name": settings["station"]["name"], "datetime": now.strftime("%H:%M:%S"), "date": now.strftime("%d-%m-%Y"), "software_version": SOFTWARE_VERSION, "build_date": BUILD_DATE, "connection_status": sensor_state["communication"]["connection_status"], "signal_strength": sensor_state["communication"]["signal_strength"], "rssi": sensor_state["communication"]["rssi"] }) @app.route('/api/sensors') def api_sensors(): """Get current sensor readings""" return jsonify({ "rainfall": sensor_state["rainfall"], "voltage": sensor_state["voltage"], "last_updated": sensor_state["last_updated"] }) @app.route('/api/settings') def api_settings_get(): """Get all settings""" return jsonify(load_settings()) @app.route('/api/settings', methods=['POST']) def api_settings_post(): """Update settings""" settings = load_settings() data = request.get_json() # Update specific sections if "station" in data: settings["station"].update(data["station"]) if "datetime" in data: settings["datetime"].update(data["datetime"]) if "network" in data: settings["network"].update(data["network"]) if "mobile" in data: settings["mobile"].update(data["mobile"]) if "adc" in data: settings["adc"].update(data["adc"]) if "sensors" in data: settings["sensors"].update(data["sensors"]) if "evap" in data: settings["evap"].update(data["evap"]) save_settings(settings) return jsonify({"success": True, "settings": settings}) @app.route('/api/calibration') def api_calibration(): """Get calibration data - live ADC readings""" return jsonify({ "adc": sensor_state["adc"], "rainfall": { "rf1_tips": sensor_state["rainfall"]["rf1_tips"], "rf2_tips": sensor_state["rainfall"]["rf2_tips"], "rf1_id": load_settings()["sensors"]["rainfall"]["rf1_id"], "rf2_id": load_settings()["sensors"]["rainfall"]["rf2_id"] } }) @app.route('/api/calibration/reset_rainfall', methods=['POST']) def api_calibration_reset(): """Reset rainfall counters""" sensor_state["rainfall"]["today"] = 0.0 sensor_state["rainfall"]["hourly"] = 0.0 sensor_state["rainfall"]["rf1_tips"] = 0 sensor_state["rainfall"]["rf2_tips"] = 0 return jsonify({"success": True}) # ==================== FILE MANAGEMENT ==================== # Logger directory - use /myvscada/logger or fall back to src/data/logger LOGGER_DIR = '/myvscada/logger' FALLBACK_LOGGER_DIR = os.path.join(os.path.dirname(__file__), 'data', 'logger') def get_logger_dir(): """Get the logger directory, creating fallback if needed""" if os.path.exists(LOGGER_DIR): return LOGGER_DIR # Use fallback and ensure it exists os.makedirs(FALLBACK_LOGGER_DIR, exist_ok=True) return FALLBACK_LOGGER_DIR @app.route('/api/files') def api_files_list(): """List all CSV files in the logger directory""" try: logger_dir = get_logger_dir() files = [] if os.path.exists(logger_dir): for filename in os.listdir(logger_dir): if filename.endswith('.csv'): filepath = os.path.join(logger_dir, filename) stat = os.stat(filepath) files.append({ 'name': filename, 'size': stat.st_size, 'modified': datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M:%S'), 'created': datetime.fromtimestamp(stat.st_ctime).strftime('%Y-%m-%d %H:%M:%S') }) # Sort by creation time, newest first files.sort(key=lambda x: x['created'], reverse=True) return jsonify({ 'files': files, 'total': len(files) }) except Exception as e: return jsonify({'error': str(e), 'files': [], 'total': 0}), 500 @app.route('/api/files/') def api_files_get(filename): """Get contents of a specific CSV file""" try: # Security: prevent path traversal filename = os.path.basename(filename) if not filename.endswith('.csv'): return jsonify({'error': 'Only CSV files are allowed'}), 400 logger_dir = get_logger_dir() filepath = os.path.join(logger_dir, filename) if not os.path.exists(filepath): return jsonify({'error': 'File not found'}), 404 with open(filepath, 'r') as f: content = f.read() lines = content.split('\n') # Limit preview to first 100 lines preview_lines = lines[:100] preview_content = '\n'.join(preview_lines) return jsonify({ 'name': filename, 'content': preview_content, 'lines': len(lines), 'truncated': len(lines) > 100 }) except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/files/', methods=['DELETE']) def api_files_delete(filename): """Delete a specific CSV file""" try: # Security: prevent path traversal filename = os.path.basename(filename) if not filename.endswith('.csv'): return jsonify({'success': False, 'error': 'Only CSV files are allowed'}), 400 logger_dir = get_logger_dir() filepath = os.path.join(logger_dir, filename) if not os.path.exists(filepath): return jsonify({'success': False, 'error': 'File not found'}), 404 os.remove(filepath) return jsonify({'success': True}) except Exception as e: return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/files/export', methods=['POST']) def api_files_export(): """Generate tidEDA formatted export from CSV file""" try: data = request.get_json() or {} filename = data.get('filename') if not filename: return jsonify({"error": "filename required"}), 400 # Validate filename (security: prevent path traversal) if '..' in filename or '/' in filename or '\\' in filename: return jsonify({"error": "invalid filename"}), 400 # Ensure .csv extension if not filename.endswith('.csv'): filename = filename + '.csv' # Get the logger directory logger_dir = get_logger_dir() file_path = os.path.join(logger_dir, filename) if not os.path.exists(file_path): return jsonify({"error": "file not found"}), 404 # Generate tidEDA format # Structure: # $STATION,{station_id} # $DATETIME,{timestamp} # $DATA # {original_csv_content} # $END settings = load_settings() station_id = settings.get("station", {}).get("id", "UNKNOWN") with open(file_path, 'r') as f: csv_content = f.read() tideda_content = f"$STATION,{station_id}\n" tideda_content += f"$DATETIME,{datetime.now().isoformat()}\n" tideda_content += "$DATA\n" tideda_content += csv_content tideda_content += "$END\n" return jsonify({ "filename": filename.replace('.csv', '.tideda'), "content": tideda_content, "format": "tidEDA v1.0" }) except Exception as e: return jsonify({"error": str(e)}), 500 # ==================== MAIN ==================== if __name__ == '__main__': # Start background sensor thread socketio.start_background_task(sensor_background_thread) # Run with eventlet for production performance on Pi Zero 2 W print(f"Starting TCKRTUIYO server on port 8080...") print(f"Software Version: {SOFTWARE_VERSION}") socketio.run(app, host='0.0.0.0', port=8080, debug=False)