diff --git a/src_v1/app.py b/src_v1/app.py new file mode 100644 index 000000000..f50c7b2b6 --- /dev/null +++ b/src_v1/app.py @@ -0,0 +1,483 @@ +""" +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) diff --git a/src_v1/data/logger/test_001.csv b/src_v1/data/logger/test_001.csv new file mode 100644 index 000000000..8a613e0a9 --- /dev/null +++ b/src_v1/data/logger/test_001.csv @@ -0,0 +1,4 @@ +timestamp,rainfall_rf1,rainfall_rf2,water_level,solar_voltage,battery_voltage +2026-03-12 10:00:00,0.2,0.0,25.0,12.4,12.6 +2026-03-12 10:05:00,0.4,0.2,26.0,12.6,12.7 +2026-03-12 10:10:00,0.6,0.4,27.0,13.0,12.8 diff --git a/src_v1/requirements.txt b/src_v1/requirements.txt new file mode 100644 index 000000000..3fbdc4b48 --- /dev/null +++ b/src_v1/requirements.txt @@ -0,0 +1,18 @@ +# TCKRTUIYO - Rainfall Station RTU Web Interface +# Phase 1: Foundation & Kiosk UI + +# Web Framework +Flask==3.1.0 +Flask-SocketIO==5.6.0 + +# Async support for Flask-SocketIO +eventlet==0.40.0 + +# System monitoring +psutil==6.1.0 + +# Serial communication (for sensor hardware) +pyserial==3.5 + +# Settings storage +python-dotenv==1.0.0 diff --git a/src_v1/templates/calibration.html b/src_v1/templates/calibration.html new file mode 100644 index 000000000..d42a70d42 --- /dev/null +++ b/src_v1/templates/calibration.html @@ -0,0 +1,380 @@ + + + + + + TCKRTUIYO - Calibration + + + + + + + + +
+ + + + +
+
+ + Live ADC Readings +
+
+ + +
+

ADC Channels

+
+ +
+
+ + +
+

Rainfall Counters

+
+
+
RF1 Tips
+
0
+
+
+
RF2 Tips
+
0
+
+
+ +
+
+ + + + + + diff --git a/src_v1/templates/dashboard.html b/src_v1/templates/dashboard.html new file mode 100644 index 000000000..96f7d4e63 --- /dev/null +++ b/src_v1/templates/dashboard.html @@ -0,0 +1,339 @@ + + + + + + TCKRTUIYO - Dashboard + + + + + + + + +
+ +
+
--:--:--
+
--
+
+ + +
+ Loading... +
+ + +
+ +
+
+
Today
+
0.0
+ mm +
+
+
Hourly
+
0.0
+ mm +
+
+
MAR
+
0.0
+ mm +
+
+
Yearly
+
0.0
+ mm +
+
+ + +
+
Solar Voltage
+
+ -- + V +
+ -- +
+ + +
+
Battery Voltage
+
+ -- + V +
+ -- +
+ + +
+
Communication
+
+
+
+
+
+
+
+
+
Connected
+ -65 dBm +
+
+
+ + +
+
Software
+
+ v{{ version }} +
+ {{ build_date }} +
+
+ + +
+ Last Updated: -- +
+ + +
+ TCKRTUIYO - Rainfall Station RTU +
+
+ + + + + + diff --git a/src_v1/templates/files.html b/src_v1/templates/files.html new file mode 100644 index 000000000..ea7365c67 --- /dev/null +++ b/src_v1/templates/files.html @@ -0,0 +1,484 @@ + + + + + + TCKRTUIYO - Flash Memory + + + + + + + + +
+ + + + +
+ +
+ + +
+ Loading... + +
+ + +
+
+ +

No files found

+
+
+ + +
+ +
+
+ + + + + diff --git a/src_v1/templates/settings.html b/src_v1/templates/settings.html new file mode 100644 index 000000000..5f4e83ec2 --- /dev/null +++ b/src_v1/templates/settings.html @@ -0,0 +1,578 @@ + + + + + + TCKRTUIYO - Settings + + + + + + + + +
+ + + + +
+
+ Station +
+
+ Date/Time +
+
+ Network +
+
+ ADC +
+
+ Sensors +
+
+ + +
+
+

Station Information

+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+

Date & Time Settings

+
+ + +
+
+ +
+
+ + +
+
+
+ + +
+
+

Network Settings

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+

Mobile Network (4G)

+
+ +
+
+ + +
+
+ + +
+
+
+ + +
+
+

ADC Channel Configuration

+
+ +
+
+
+ + +
+
+

Rainfall Sensors

+
+ + +
+
+ + +
+
+ + +
+
+ +
+

Water Level Thresholds

+
+ + +
+
+ + +
+
+ +
+

Voltage Thresholds

+
+ + +
+
+ + +
+
+ + +
+
+ +
+

EVAP Settings

+
+ +
+
+ + +
+
+
+ + +
+ +
+
+ + + + +