Files
rtu_v5/src_v1/app.py
admin 65e8907307 feat(quick-2): archive Flask v1 interface to src_v1/
- Copy app.py, templates, static, data, routes, services
- Copy requirements.txt for v1 dependencies
2026-03-12 12:29:42 +08:00

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)