diff --git a/simulator/app.py b/simulator/app.py index 99c58c4..a9a04f6 100644 --- a/simulator/app.py +++ b/simulator/app.py @@ -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/", 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) diff --git a/simulator/config.json b/simulator/config.json new file mode 100644 index 0000000..99cdde3 --- /dev/null +++ b/simulator/config.json @@ -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 + } +} diff --git a/simulator/esp32_sim.py b/simulator/esp32_sim.py new file mode 100644 index 0000000..02ace5a --- /dev/null +++ b/simulator/esp32_sim.py @@ -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() diff --git a/simulator/static/style.css b/simulator/static/style.css index 7a8f1c3..68a082a 100644 --- a/simulator/static/style.css +++ b/simulator/static/style.css @@ -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; +} diff --git a/simulator/templates/index.html b/simulator/templates/index.html index 8269a37..8276763 100644 --- a/simulator/templates/index.html +++ b/simulator/templates/index.html @@ -14,6 +14,8 @@ + +

Xterra Dashboard

@@ -30,7 +32,11 @@

Fridge A

--°F

Fridge B

--°F

-

Rear Seat

--°F

+
+

👧 Kid Area

+

--°F

+ Normal +

Outside

--°F

@@ -74,8 +80,10 @@
RS-485 --
WiFi --
WiFi Override --
+
Ignition --
+

Alarm Settings

@@ -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',