Add simulator architecture separation and warning system

This commit is contained in:
root
2026-06-03 00:16:08 -06:00
parent 5f65a7cbb8
commit 06ef856210
5 changed files with 213 additions and 76 deletions
+20 -55
View File
@@ -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)
+9
View File
@@ -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
}
}
+112
View File
@@ -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()
+29
View File
@@ -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;
}
+43 -21
View File
@@ -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',