Skip to content

Commit 32067de

Browse files
authored
Use Render (#116)
* Added sidebar * Added Settings page * Adjusted font, account screen, graphs, Google Maps window size * Integrated Vercel/Render for web app * updated vercel settings * add vercel urls
1 parent ad20d54 commit 32067de

26 files changed

Lines changed: 1592 additions & 121 deletions

.github/workflows/deploy-frontend.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ jobs:
3939
working-directory: Frontend
4040
run: npm run build
4141
env:
42+
REACT_APP_API_URL: ${{ vars.REACT_APP_API_URL }}
43+
REACT_APP_WS_URL: ${{ vars.REACT_APP_WS_URL }}
4244
NODE_OPTIONS: --openssl-legacy-provider
4345
CI: false
4446

Backend/config.py

Lines changed: 66 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,74 @@
11
import os
22

3-
DATAFORMAT_PATH = f'{os.getcwd()}/Data/sc1-data-format/format.json'
4-
HOST_PORT = 4001
3+
# ==================== DEPLOYMENT MODE ====================
4+
# Determines how the application runs:
5+
# - "local": Full features (Redis Stack, Serial, DataGenerator/Replayer)
6+
# - "cloud": Cloud-compatible mode (Convex, Upstash Redis, no serial)
7+
DEPLOYMENT_MODE = os.getenv("DEPLOYMENT_MODE", "local")
58

6-
REDIS_URL = '127.0.0.1'
7-
REDIS_PORT = 6379
8-
REDIS_DB = 0
9+
# ==================== PATHS ====================
10+
DATAFORMAT_PATH = os.getenv(
11+
"DATAFORMAT_PATH",
12+
f'{os.getcwd()}/Data/sc1-data-format/format.json'
13+
)
914

10-
# TCP SETTINGS
11-
CAR_IP = '192.168.1.15'
12-
LOCAL_IP = '127.0.0.1'
13-
DATA_PORT = 4003
15+
# ==================== SERVER SETTINGS ====================
16+
HOST_PORT = int(os.getenv("PORT", 4001)) # Render uses PORT env var
17+
HOST = os.getenv("HOST", "0.0.0.0")
1418

15-
#UDP SETTINGS
16-
UDP_PORT = 4003
19+
# ==================== REDIS SETTINGS ====================
20+
# Local mode: Redis Stack with TimeSeries module
21+
# Cloud mode: Upstash Redis (standard Redis, no TimeSeries)
22+
if DEPLOYMENT_MODE == "cloud":
23+
# Upstash Redis URL format: redis://default:password@host:port
24+
REDIS_URL = os.getenv("UPSTASH_REDIS_URL", "")
25+
REDIS_PORT = None # URL contains port
26+
REDIS_DB = 0
27+
USE_TIMESERIES = False
28+
else:
29+
REDIS_URL = os.getenv("REDIS_URL", "127.0.0.1")
30+
REDIS_PORT = int(os.getenv("REDIS_PORT", 6379))
31+
REDIS_DB = int(os.getenv("REDIS_DB", 0))
32+
USE_TIMESERIES = True
1733

18-
VPS_URL = 'http://live.bsr-dev.org'
34+
# ==================== CONVEX SETTINGS ====================
35+
# Convex is used as the primary cloud database for real-time data
36+
CONVEX_URL = os.getenv("CONVEX_URL", "")
37+
CONVEX_DEPLOY_KEY = os.getenv("CONVEX_DEPLOY_KEY", "")
1938

20-
#Filled by the program
39+
# ==================== DATA SOURCE SETTINGS ====================
40+
# Which data sources are enabled
41+
ENABLE_SERIAL = DEPLOYMENT_MODE == "local" # Serial only works locally
42+
ENABLE_UDP = DEPLOYMENT_MODE == "local" # UDP only works locally
43+
ENABLE_CONVEX = bool(CONVEX_URL) # Convex works in both modes
44+
45+
# ==================== NETWORK SETTINGS (Local Mode) ====================
46+
CAR_IP = os.getenv("CAR_IP", "192.168.1.15")
47+
LOCAL_IP = os.getenv("LOCAL_IP", "127.0.0.1")
48+
DATA_PORT = int(os.getenv("DATA_PORT", 4003))
49+
UDP_PORT = int(os.getenv("UDP_PORT", 4003))
50+
51+
# ==================== LEGACY VPS SETTINGS ====================
52+
VPS_URL = os.getenv("VPS_URL", "http://live.bsr-dev.org")
53+
54+
# ==================== RUNTIME DATA ====================
55+
# Filled by the program at startup
2156
FORMAT = dict()
57+
58+
# ==================== HELPER FUNCTIONS ====================
59+
def is_cloud_mode():
60+
return DEPLOYMENT_MODE == "cloud"
61+
62+
def is_local_mode():
63+
return DEPLOYMENT_MODE == "local"
64+
65+
def print_config():
66+
"""Print current configuration for debugging"""
67+
print(f"=== Chase Car Dashboard Configuration ===")
68+
print(f"Deployment Mode: {DEPLOYMENT_MODE}")
69+
print(f"Host: {HOST}:{HOST_PORT}")
70+
print(f"Redis TimeSeries: {'Enabled' if USE_TIMESERIES else 'Disabled'}")
71+
print(f"Serial Enabled: {ENABLE_SERIAL}")
72+
print(f"UDP Enabled: {ENABLE_UDP}")
73+
print(f"Convex Enabled: {ENABLE_CONVEX}")
74+
print(f"=========================================")

Backend/core/comms.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -313,8 +313,7 @@ def start_comms():
313313
# Start two live comm channels
314314
#vps_thread = threading.Thread(target=lambda : asyncio.run(telemetry.remote_db_fetch(config.VPS_URL)))
315315
#vps_thread.start()
316-
#socket_thread = threading.Thread(target=lambda: telemetry.listen_udp(config.UDP_PORT))
317-
#socket_thread.start()
318-
socket_thread = threading.Thread(target=lambda: telemetry.serial_read())
316+
socket_thread = threading.Thread(target=lambda: telemetry.listen_udp(config.UDP_PORT))
319317
socket_thread.start()
320-
318+
#socket_thread = threading.Thread(target=lambda: telemetry.serial_read())
319+
#socket_thread.start()

Backend/core/core_api.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,39 @@
22
import serial.tools.list_ports
33
from . import comms
44
from pydantic import BaseModel
5+
import asyncio
56

67
router = APIRouter()
78

9+
@router.websocket("/single-values")
10+
async def single_values_websocket(websocket: WebSocket):
11+
await websocket.accept()
12+
13+
while True:
14+
try:
15+
# Wait for client request
16+
await websocket.receive_text()
17+
18+
# Prepare data
19+
if comms.solar_car_connection['udp'] or comms.solar_car_connection['lte'] or comms.solar_car_connection['serial']:
20+
latest_data = comms.frontend_data.copy()
21+
latest_data['solar_car_connection'] = True
22+
latest_data['udp_status'] = comms.solar_car_connection['udp']
23+
latest_data['lte_status'] = comms.solar_car_connection['lte']
24+
latest_data['serial_status'] = comms.solar_car_connection['serial']
25+
latest_data['timestamps'] = f'{latest_data.get("tstamp_hr", 0):02d}:{latest_data.get("tstamp_mn", 0):02d}:' \
26+
f'{latest_data.get("tstamp_sc", 0):02d}.{latest_data.get("tstamp_ms", 0)}'
27+
format_data = {}
28+
for key in latest_data.keys():
29+
format_data[key] = [latest_data[key]]
30+
json_data = {'response': format_data}
31+
await websocket.send_json(json_data)
32+
else:
33+
await websocket.send_json({'response': None})
34+
except Exception as e:
35+
print(f"WebSocket error: {e}")
36+
break
37+
838
@router.get("/single-values")
939
async def single_values():
1040
if comms.solar_car_connection['udp'] or comms.solar_car_connection['lte'] or comms.solar_car_connection['serial']:
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
"""
2+
Data Sources Module
3+
4+
This module provides a unified interface for receiving telemetry data from multiple sources:
5+
- Serial (radio connection - local mode only)
6+
- UDP (local network - local mode only)
7+
- Convex (cloud database - both modes)
8+
9+
The DataSourceManager handles:
10+
- Receiving data from all enabled sources
11+
- Timestamp-based deduplication (latest wins)
12+
- Forwarding data to the cache layer
13+
"""
14+
15+
from .manager import DataSourceManager
16+
from .base import DataSource
17+
18+
__all__ = ['DataSourceManager', 'DataSource']

Backend/core/data_sources/base.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"""
2+
Base class for all data sources.
3+
"""
4+
5+
from abc import ABC, abstractmethod
6+
from typing import Dict, Any, Optional, Callable
7+
import threading
8+
9+
10+
class DataSource(ABC):
11+
"""
12+
Abstract base class for data sources.
13+
14+
All data sources (Serial, UDP, Convex) implement this interface
15+
to provide a consistent way of receiving telemetry data.
16+
"""
17+
18+
def __init__(self, name: str):
19+
self.name = name
20+
self.is_connected = False
21+
self.last_timestamp = 0
22+
self._on_data_callback: Optional[Callable[[Dict[str, Any], int], None]] = None
23+
self._thread: Optional[threading.Thread] = None
24+
self._running = False
25+
26+
@abstractmethod
27+
def connect(self) -> bool:
28+
"""
29+
Establish connection to the data source.
30+
Returns True if successful, False otherwise.
31+
"""
32+
pass
33+
34+
@abstractmethod
35+
def disconnect(self):
36+
"""
37+
Disconnect from the data source.
38+
"""
39+
pass
40+
41+
@abstractmethod
42+
def start_listening(self):
43+
"""
44+
Start listening for incoming data in a background thread.
45+
"""
46+
pass
47+
48+
def stop_listening(self):
49+
"""
50+
Stop the listening thread.
51+
"""
52+
self._running = False
53+
if self._thread and self._thread.is_alive():
54+
self._thread.join(timeout=2.0)
55+
56+
def set_data_callback(self, callback: Callable[[Dict[str, Any], int], None]):
57+
"""
58+
Set a callback function to be called when new data arrives.
59+
60+
Args:
61+
callback: Function that takes (data_dict, timestamp) as arguments
62+
"""
63+
self._on_data_callback = callback
64+
65+
def _emit_data(self, data: Dict[str, Any], timestamp: int):
66+
"""
67+
Emit data to the callback if set.
68+
69+
Args:
70+
data: The telemetry data dictionary
71+
timestamp: Unix timestamp in milliseconds
72+
"""
73+
self.last_timestamp = timestamp
74+
if self._on_data_callback:
75+
self._on_data_callback(data, timestamp, self.name)
76+
77+
@property
78+
def status(self) -> Dict[str, Any]:
79+
"""
80+
Get the current status of this data source.
81+
"""
82+
return {
83+
"name": self.name,
84+
"connected": self.is_connected,
85+
"last_timestamp": self.last_timestamp,
86+
"running": self._running
87+
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
"""
2+
Convex Data Source
3+
4+
Receives telemetry data from Convex.dev cloud database.
5+
This is the primary data source for cloud deployments and
6+
can also be used alongside serial in local mode.
7+
"""
8+
9+
import asyncio
10+
import threading
11+
import time
12+
from typing import Dict, Any, Optional
13+
from .base import DataSource
14+
import config
15+
16+
17+
class ConvexDataSource(DataSource):
18+
"""
19+
Receives telemetry data from Convex.dev.
20+
21+
Convex provides real-time subscriptions, making it ideal for
22+
receiving live telemetry data from the solar car via LTE.
23+
"""
24+
25+
def __init__(self):
26+
super().__init__("convex")
27+
self.convex_url = config.CONVEX_URL
28+
self._client = None
29+
self._subscription = None
30+
self._poll_interval = 0.5 # seconds
31+
32+
def connect(self) -> bool:
33+
"""
34+
Initialize Convex client.
35+
36+
Note: Full Convex integration requires the convex Python client.
37+
For now, this is a placeholder that can be implemented when
38+
you're ready to integrate with Convex.
39+
"""
40+
if not self.convex_url:
41+
print("[Convex] No Convex URL configured")
42+
return False
43+
44+
try:
45+
# TODO: Initialize actual Convex client
46+
# from convex import ConvexClient
47+
# self._client = ConvexClient(self.convex_url)
48+
49+
print(f"[Convex] Would connect to: {self.convex_url}")
50+
print("[Convex] Note: Full Convex integration pending")
51+
52+
# For now, mark as connected if URL is configured
53+
# Real implementation will verify connection
54+
self.is_connected = True
55+
return True
56+
57+
except Exception as e:
58+
print(f"[Convex] Failed to connect: {e}")
59+
self.is_connected = False
60+
return False
61+
62+
def disconnect(self):
63+
"""Disconnect from Convex."""
64+
if self._subscription:
65+
# Cancel subscription
66+
self._subscription = None
67+
68+
if self._client:
69+
self._client = None
70+
71+
self.is_connected = False
72+
print("[Convex] Disconnected")
73+
74+
def start_listening(self):
75+
"""Start listening for Convex updates."""
76+
if not self.convex_url:
77+
print("[Convex] Cannot start - no URL configured")
78+
return
79+
80+
self._running = True
81+
self._thread = threading.Thread(target=self._listen_loop, daemon=True)
82+
self._thread.start()
83+
84+
def _listen_loop(self):
85+
"""
86+
Main listening loop for Convex data.
87+
88+
This uses polling for now. When fully integrated,
89+
this should use Convex's real-time subscriptions.
90+
"""
91+
while self._running:
92+
try:
93+
if not self.is_connected:
94+
self.connect()
95+
if not self.is_connected:
96+
time.sleep(5)
97+
continue
98+
99+
# TODO: Replace with actual Convex subscription
100+
# For now, this is a placeholder polling loop
101+
#
102+
# Real implementation would look like:
103+
# data = await self._client.query("telemetry:getLatest")
104+
# if data:
105+
# timestamp = data.get('tstamp_unix', 0)
106+
# self._emit_data(data, timestamp)
107+
108+
time.sleep(self._poll_interval)
109+
110+
except Exception as e:
111+
print(f"[Convex] Error in listen loop: {e}")
112+
self.is_connected = False
113+
time.sleep(5)
114+
115+
async def push_data(self, data: Dict[str, Any]):
116+
"""
117+
Push data to Convex (for when we receive data from other sources).
118+
119+
This allows the dashboard to sync data from serial/UDP to Convex
120+
for other clients to access.
121+
"""
122+
if not self._client:
123+
return
124+
125+
try:
126+
# TODO: Implement actual Convex mutation
127+
# await self._client.mutation("telemetry:insert", data)
128+
pass
129+
except Exception as e:
130+
print(f"[Convex] Failed to push data: {e}")

0 commit comments

Comments
 (0)