- Copy app.py, templates, static, data, routes, services - Copy requirements.txt for v1 dependencies
484 lines
15 KiB
Python
484 lines
15 KiB
Python
"""
|
|
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/<filename>')
|
|
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/<filename>', 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)
|