feat(phase-1): implement Foundation & Kiosk UI

- Add Flask backend with SocketIO for real-time updates
- Implement sensor service with mock data generation
- Create Dashboard UI with rainfall, voltage, status displays
- Create Settings UI with station, network, ADC, sensor configuration
- Create Calibration UI with live ADC readings and rainfall reset
- Touch-optimized interface with 64px touch targets for 7" display

Implements: DASH-01 through DASH-09, UI-01 through UI-03,
SETT-01 through SETT-08, CAL-01 through CAL-04, BACK-01 through BACK-03
This commit is contained in:
2026-03-12 06:15:23 +08:00
parent 5876e61cbf
commit f839a24c27
6 changed files with 1849 additions and 0 deletions

321
src/app.py Normal file
View File

@@ -0,0 +1,321 @@
"""
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)
# ==================== 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})
# ==================== 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)