Add simulator architecture separation and warning system
This commit is contained in:
+20
-55
@@ -1,51 +1,8 @@
|
||||
from flask import Flask, jsonify, render_template, request
|
||||
import random
|
||||
import time
|
||||
from esp32_sim import esp32
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
state = {
|
||||
"relays": {
|
||||
"starlink": False,
|
||||
"fridge": True
|
||||
},
|
||||
"wifi_override_until": 0
|
||||
}
|
||||
|
||||
|
||||
def fake_status():
|
||||
starlink_on = state["relays"]["starlink"]
|
||||
wifi_override_active = time.time() < state["wifi_override_until"]
|
||||
|
||||
return {
|
||||
"timestamp": int(time.time()),
|
||||
|
||||
"battery": {
|
||||
"soc": random.randint(76, 84),
|
||||
"voltage": round(random.uniform(13.0, 13.4), 2),
|
||||
"current": round(random.uniform(-8.0, -3.0), 1),
|
||||
"remaining_ah": round(random.uniform(76.0, 84.0), 1),
|
||||
"runtime_hours": round(random.uniform(9.5, 14.0), 1),
|
||||
"temperature_f": round(random.uniform(70.0, 82.0), 1)
|
||||
},
|
||||
|
||||
"temps": {
|
||||
"fridge_zone_1": round(random.uniform(34.0, 38.0), 1),
|
||||
"fridge_zone_2": round(random.uniform(8.0, 16.0), 1),
|
||||
"rear_seat": round(random.uniform(72.0, 86.0), 1),
|
||||
"outside": round(random.uniform(80.0, 96.0), 1)
|
||||
},
|
||||
|
||||
"relays": state["relays"],
|
||||
|
||||
"network": {
|
||||
"wifi_enabled": starlink_on or wifi_override_active,
|
||||
"wifi_override_active": wifi_override_active,
|
||||
"rs485_connected": True,
|
||||
"starlink_enabled": starlink_on
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
@@ -54,48 +11,48 @@ def index():
|
||||
|
||||
@app.route("/status")
|
||||
def status():
|
||||
return jsonify(fake_status())
|
||||
return jsonify(esp32.get_status())
|
||||
|
||||
|
||||
@app.route("/battery")
|
||||
def battery():
|
||||
return jsonify(fake_status()["battery"])
|
||||
return jsonify(esp32.get_status()["battery"])
|
||||
|
||||
|
||||
@app.route("/temps")
|
||||
def temps():
|
||||
return jsonify(fake_status()["temps"])
|
||||
return jsonify(esp32.get_status()["temps"])
|
||||
|
||||
|
||||
@app.route("/relays")
|
||||
def relays():
|
||||
return jsonify(state["relays"])
|
||||
return jsonify(esp32.relays)
|
||||
|
||||
|
||||
@app.route("/relay/<name>", methods=["POST"])
|
||||
def set_relay(name):
|
||||
if name not in state["relays"]:
|
||||
return jsonify({"success": False, "error": "Unknown relay"}), 404
|
||||
|
||||
data = request.get_json(force=True)
|
||||
state["relays"][name] = bool(data.get("state", False))
|
||||
success = esp32.set_relay(name, data.get("state", False))
|
||||
|
||||
if not success:
|
||||
return jsonify({"success": False, "error": "Unknown relay"}), 404
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
name: state["relays"][name]
|
||||
name: esp32.relays[name]
|
||||
})
|
||||
|
||||
|
||||
@app.route("/network")
|
||||
def network():
|
||||
return jsonify(fake_status()["network"])
|
||||
return jsonify(esp32.get_status()["network"])
|
||||
|
||||
|
||||
@app.route("/network/wifi", methods=["POST"])
|
||||
def enable_wifi():
|
||||
data = request.get_json(force=True)
|
||||
minutes = int(data.get("minutes", 10))
|
||||
state["wifi_override_until"] = time.time() + minutes * 60
|
||||
esp32.enable_wifi(minutes)
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
@@ -103,5 +60,13 @@ def enable_wifi():
|
||||
})
|
||||
|
||||
|
||||
@app.route("/vehicle/ignition", methods=["POST"])
|
||||
def toggle_ignition():
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"ignition_on": esp32.toggle_ignition()
|
||||
})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=5000, debug=True)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"alarms": {
|
||||
"rear_seat_warning": 85,
|
||||
"rear_seat_critical": 95,
|
||||
"fridge_zone_1_warm": 45,
|
||||
"fridge_zone_2_warm": 15,
|
||||
"battery_low": 20
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import json
|
||||
import random
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class ESP32Simulator:
|
||||
def __init__(self):
|
||||
self.relays = {
|
||||
"starlink": False,
|
||||
"fridge": True
|
||||
}
|
||||
|
||||
self.wifi_override_until = 0
|
||||
self.ignition_on = True
|
||||
self.soc = 82.0
|
||||
self.last_update = time.time()
|
||||
|
||||
def load_config(self):
|
||||
config_path = Path(__file__).parent / "config.json"
|
||||
with config_path.open() as f:
|
||||
return json.load(f)
|
||||
|
||||
def update_battery(self):
|
||||
now = time.time()
|
||||
elapsed_hours = (now - self.last_update) / 3600
|
||||
self.last_update = now
|
||||
|
||||
load_amps = 6.0
|
||||
if self.relays["starlink"]:
|
||||
load_amps += 4.5
|
||||
if self.relays["fridge"]:
|
||||
load_amps += 2.0
|
||||
|
||||
capacity_ah = 100
|
||||
self.soc -= (load_amps * elapsed_hours / capacity_ah) * 100
|
||||
self.soc = max(self.soc, 0)
|
||||
|
||||
return -load_amps
|
||||
|
||||
def get_status(self):
|
||||
current = self.update_battery()
|
||||
starlink_on = self.relays["starlink"]
|
||||
wifi_override_active = time.time() < self.wifi_override_until
|
||||
|
||||
temps = {
|
||||
"fridge_zone_1": self.sensor(36, 2),
|
||||
"fridge_zone_2": self.sensor(12, 3),
|
||||
"rear_seat": self.sensor(78, 8),
|
||||
"outside": self.sensor(88, 8)
|
||||
}
|
||||
|
||||
sensor_health = {
|
||||
"fridge_zone_1": True,
|
||||
"fridge_zone_2": True,
|
||||
"rear_seat": True,
|
||||
"outside": True
|
||||
}
|
||||
|
||||
remaining_ah = round(self.soc, 1)
|
||||
runtime_hours = round(remaining_ah / abs(current), 1) if current else 0
|
||||
|
||||
return {
|
||||
"timestamp": int(time.time()),
|
||||
|
||||
"vehicle": {
|
||||
"ignition_on": self.ignition_on
|
||||
},
|
||||
|
||||
"battery": {
|
||||
"soc": round(self.soc),
|
||||
"voltage": round(13.0 + (self.soc / 100) * 0.4, 2),
|
||||
"current": round(current, 1),
|
||||
"remaining_ah": remaining_ah,
|
||||
"runtime_hours": runtime_hours,
|
||||
"temperature_f": self.sensor(76, 4)
|
||||
},
|
||||
|
||||
"temps": temps,
|
||||
"sensor_health": sensor_health,
|
||||
|
||||
"relays": self.relays,
|
||||
|
||||
"network": {
|
||||
"wifi_enabled": starlink_on or wifi_override_active,
|
||||
"wifi_override_active": wifi_override_active,
|
||||
"rs485_connected": True,
|
||||
"starlink_enabled": starlink_on
|
||||
},
|
||||
|
||||
"config": self.load_config()
|
||||
}
|
||||
|
||||
def sensor(self, base, spread):
|
||||
return round(random.uniform(base - spread, base + spread), 1)
|
||||
|
||||
def set_relay(self, name, state):
|
||||
if name not in self.relays:
|
||||
return False
|
||||
|
||||
self.relays[name] = bool(state)
|
||||
return True
|
||||
|
||||
def enable_wifi(self, minutes):
|
||||
self.wifi_override_until = time.time() + minutes * 60
|
||||
|
||||
def toggle_ignition(self):
|
||||
self.ignition_on = not self.ignition_on
|
||||
return self.ignition_on
|
||||
|
||||
|
||||
esp32 = ESP32Simulator()
|
||||
@@ -239,3 +239,32 @@ nav button {
|
||||
color: white;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
|
||||
.warning-banner {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5000;
|
||||
background: #6b5200;
|
||||
color: white;
|
||||
padding: 14px;
|
||||
text-align: center;
|
||||
font-size: 1.1rem;
|
||||
font-weight: bold;
|
||||
border-bottom: 2px solid #d8b400;
|
||||
}
|
||||
|
||||
.warning-banner.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.kid-card {
|
||||
border-color: #777;
|
||||
}
|
||||
|
||||
.kid-card small {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 1rem;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="warningBanner" class="warning-banner hidden"></div>
|
||||
|
||||
<main>
|
||||
<header>
|
||||
<h1>Xterra Dashboard</h1>
|
||||
@@ -30,7 +32,11 @@
|
||||
<div class="grid">
|
||||
<div class="card"><h2>Fridge A</h2><p><span id="dashFridge1">--</span>°F</p></div>
|
||||
<div class="card"><h2>Fridge B</h2><p><span id="dashFridge2">--</span>°F</p></div>
|
||||
<div class="card"><h2>Rear Seat</h2><p><span id="dashRear">--</span>°F</p></div>
|
||||
<div class="card kid-card">
|
||||
<h2>👧 Kid Area</h2>
|
||||
<p><span id="dashRear">--</span>°F</p>
|
||||
<small id="kidStatus">Normal</small>
|
||||
</div>
|
||||
<div class="card"><h2>Outside</h2><p><span id="dashOutside">--</span>°F</p></div>
|
||||
</div>
|
||||
|
||||
@@ -74,8 +80,10 @@
|
||||
<div>RS-485 <strong id="sysRs485">--</strong></div>
|
||||
<div>WiFi <strong id="sysWifi">--</strong></div>
|
||||
<div>WiFi Override <strong id="sysWifiOverride">--</strong></div>
|
||||
<div>Ignition <strong id="sysIgnition">--</strong></div>
|
||||
</div>
|
||||
<button onclick="enableWifi()">Enable WiFi 10 min</button>
|
||||
<button onclick="toggleIgnition()">Toggle Ignition</button>
|
||||
|
||||
<h2>Alarm Settings</h2>
|
||||
<div class="settings-list">
|
||||
@@ -143,6 +151,7 @@ function onOff(value) {
|
||||
|
||||
function checkAlarms(data) {
|
||||
const alarms = [];
|
||||
const warnings = [];
|
||||
|
||||
if (data.temps.rear_seat >= alarmConfig.rear_seat_critical) {
|
||||
alarms.push({
|
||||
@@ -151,35 +160,28 @@ function checkAlarms(data) {
|
||||
message: `${data.temps.rear_seat}°F detected near car seat area`
|
||||
});
|
||||
} else if (data.temps.rear_seat >= alarmConfig.rear_seat_warning) {
|
||||
alarms.push({
|
||||
key: 'rear_seat_warning',
|
||||
title: 'REAR SEAT TEMP HIGH',
|
||||
message: `${data.temps.rear_seat}°F detected near car seat area`
|
||||
});
|
||||
warnings.push(`Rear seat temp high: ${data.temps.rear_seat}°F`);
|
||||
}
|
||||
|
||||
if (data.temps.fridge_zone_1 >= alarmConfig.fridge_zone_1_warm) {
|
||||
alarms.push({
|
||||
key: 'fridge_zone_1_warm',
|
||||
title: 'FRIDGE ZONE 1 WARM',
|
||||
message: `${data.temps.fridge_zone_1}°F`
|
||||
});
|
||||
warnings.push(`Fridge Zone 1 warm: ${data.temps.fridge_zone_1}°F`);
|
||||
}
|
||||
|
||||
if (data.temps.fridge_zone_2 >= alarmConfig.fridge_zone_2_warm) {
|
||||
alarms.push({
|
||||
key: 'fridge_zone_2_warm',
|
||||
title: 'FRIDGE ZONE 2 WARM',
|
||||
message: `${data.temps.fridge_zone_2}°F`
|
||||
});
|
||||
warnings.push(`Fridge Zone 2 warm: ${data.temps.fridge_zone_2}°F`);
|
||||
}
|
||||
|
||||
if (data.battery.soc <= alarmConfig.battery_low) {
|
||||
alarms.push({
|
||||
key: 'battery_low',
|
||||
title: 'BATTERY LOW',
|
||||
message: `${data.battery.soc}% remaining`
|
||||
});
|
||||
warnings.push(`Battery low: ${data.battery.soc}%`);
|
||||
}
|
||||
|
||||
const banner = document.getElementById('warningBanner');
|
||||
|
||||
if (warnings.length > 0) {
|
||||
banner.textContent = `⚠ ${warnings.join(' | ')}`;
|
||||
banner.classList.remove('hidden');
|
||||
} else {
|
||||
banner.classList.add('hidden');
|
||||
}
|
||||
|
||||
const unacked = alarms.find(alarm => !acknowledgedAlarms.has(alarm.key));
|
||||
@@ -255,6 +257,15 @@ async function loadStatus() {
|
||||
document.getElementById('dashFridge1').textContent = data.temps.fridge_zone_1;
|
||||
document.getElementById('dashFridge2').textContent = data.temps.fridge_zone_2;
|
||||
document.getElementById('dashRear').textContent = data.temps.rear_seat;
|
||||
|
||||
const kidStatus = document.getElementById('kidStatus');
|
||||
if (data.temps.rear_seat >= alarmConfig.rear_seat_critical) {
|
||||
kidStatus.textContent = 'CRITICAL';
|
||||
} else if (data.temps.rear_seat >= alarmConfig.rear_seat_warning) {
|
||||
kidStatus.textContent = 'Warning';
|
||||
} else {
|
||||
kidStatus.textContent = 'Normal';
|
||||
}
|
||||
document.getElementById('dashOutside').textContent = data.temps.outside;
|
||||
document.getElementById('dashStarlink').textContent = `Starlink: ${onOff(data.relays.starlink)}`;
|
||||
document.getElementById('dashFridgeRelay').textContent = `Fridge: ${onOff(data.relays.fridge)}`;
|
||||
@@ -277,6 +288,7 @@ async function loadStatus() {
|
||||
document.getElementById('sysRs485').textContent = data.network.rs485_connected ? 'Connected' : 'Disconnected';
|
||||
document.getElementById('sysWifi').textContent = data.network.wifi_enabled ? 'Enabled' : 'Disabled';
|
||||
document.getElementById('sysWifiOverride').textContent = data.network.wifi_override_active ? 'Active' : 'Inactive';
|
||||
document.getElementById('sysIgnition').textContent = data.vehicle.ignition_on ? 'ON' : 'OFF';
|
||||
|
||||
checkAlarms(data);
|
||||
}
|
||||
@@ -291,6 +303,16 @@ async function toggleRelay(name) {
|
||||
loadStatus();
|
||||
}
|
||||
|
||||
async function toggleIgnition() {
|
||||
await fetch('/vehicle/ignition', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
|
||||
loadStatus();
|
||||
}
|
||||
|
||||
async function enableWifi() {
|
||||
await fetch('/network/wifi', {
|
||||
method: 'POST',
|
||||
|
||||
Reference in New Issue
Block a user