feat(quick-2): archive Flask v1 interface to src_v1/
- Copy app.py, templates, static, data, routes, services - Copy requirements.txt for v1 dependencies
This commit is contained in:
483
src_v1/app.py
Normal file
483
src_v1/app.py
Normal file
@@ -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/<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)
|
||||
Reference in New Issue
Block a user