Skip to content

Commit d66fc0e

Browse files
MCLiiisbasu107
andauthored
Telemetry via serial port (#97)
* add frontend visual serial port selector and remove unnecessary prints * Make time out work * optimize parse data for lossy telemetry data --------- Co-authored-by: sbasu107 <40325803+sbasu107@users.noreply.github.com>
1 parent b97d137 commit d66fc0e

8 files changed

Lines changed: 226 additions & 62 deletions

File tree

Backend/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,6 @@ recordedData/processedData/*.csv
99

1010
# Python
1111
*.egg-info
12+
13+
# File sync
14+
Downloaded*/

Backend/core/comms.py

Lines changed: 82 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import aiohttp
66
import asyncio
77
import config
8+
import serial
89

910
import signal
1011
import sys
@@ -13,15 +14,17 @@
1314
from multiprocessing.managers import BaseManager
1415
from . import db
1516
from file_sync.file_sync_down.main import *
17+
import re
1618

1719
format_string = '<' # little-endian
1820
byte_length = 0
1921
properties = []
2022
frontend_data = {}
21-
solar_car_connection = {'lte': False, 'udp': False}
23+
solar_car_connection = {'lte': False, 'udp': False, 'serial': False}
2224
# Convert dataformat to format string for struct conversion
2325
# Docs: https://docs.python.org/3/library/struct.html
2426
types = {'bool': '?', 'float': 'f', 'char': 'c', 'uint8': 'B', 'uint16': 'H', 'uint64': 'Q'}
27+
serial_port = {"device": "", 'baud': 115200} # shared object with core_api for setting serial device from frontend
2528

2629
def set_format(file_path: str):
2730
global format_string, byte_length, properties
@@ -46,7 +49,7 @@ def unpack_data(data):
4649

4750

4851
class Telemetry:
49-
__tmp_data = {'tcp': b'', 'lte': b'', 'udp': b'', 'file_sync': b''}
52+
__tmp_data = {'tcp': b'', 'lte': b'', 'udp': b'', 'file_sync': b'', 'serial': b''}
5053
latest_tstamp = 0
5154

5255
def listen_udp(self, port: int):
@@ -141,6 +144,51 @@ def listen_tcp(self, server_addr: str, port: int):
141144
solar_car_connection['tcp'] = False
142145
break
143146

147+
def serial_read(self):
148+
global frontend_data, serial_port
149+
latest_tstamp = 0
150+
while True:
151+
curr_device = serial_port['device']
152+
curr_baud = serial_port['baud']
153+
if(curr_device):
154+
# Establish a serial connection)
155+
ser = serial.Serial(curr_device, curr_baud)
156+
# if device has been updated then exit loop and connect to new device
157+
while curr_device == serial_port['device'] and curr_baud == serial_port['baud']:
158+
if time.time() - latest_tstamp > 5:
159+
solar_car_connection['serial'] = False
160+
# Read data from serial port
161+
try:
162+
data = b''
163+
if(ser.in_waiting > 0):
164+
data = ser.read(ser.in_waiting)
165+
else:
166+
time.sleep(0.1)
167+
if not data:
168+
# No data received, continue listening
169+
continue
170+
packets = self.parse_packets(data, 'serial')
171+
for packet in packets:
172+
if len(packet) == byte_length:
173+
d = unpack_data(packet)
174+
latest_tstamp = time.time()
175+
try:
176+
frontend_data = d.copy()
177+
db.insert_data(d)
178+
except Exception as e:
179+
print(traceback.format_exc())
180+
continue
181+
solar_car_connection['serial'] = True
182+
except Exception:
183+
print(traceback.format_exc())
184+
solar_car_connection['serial'] = False
185+
serial_port['device'] = ""
186+
break
187+
else:
188+
solar_car_connection['serial'] = False
189+
# wait before retry
190+
time.sleep(1)
191+
144192
async def fetch(self, session, url):
145193
try:
146194
async with session.get(url, timeout=2) as response:
@@ -195,35 +243,40 @@ async def remote_db_fetch(self, server_url: str):
195243

196244
def parse_packets(self, new_data: bytes, tmp_source: str):
197245
"""
198-
Parse and check the length of each packet
199-
:param new_data: Newly received bytes from the comm channel
200-
:param tmp_source: Name of tmp data source, put comm channel name here e.g. tcp, lte
246+
Parse and check the length of each packet.
247+
248+
:param new_data: Newly received bytes from the comm channel.
249+
:param tmp_source: Name of tmp data source, put comm channel name here e.g. tcp, lte.
201250
"""
202-
header = b'<bsr>'
203-
footer = b'</bsr>'
251+
header = b"<bsr>"
252+
footer = b"<bsr"
253+
if tmp_source not in self.__tmp_data:
254+
self.__tmp_data[tmp_source] = b''
255+
256+
# Append new data to the temporary buffer
204257
self.__tmp_data[tmp_source] += new_data
258+
259+
# Regex pattern to match packets with <bsr> and </bsr> tags
260+
pattern = re.compile(b'<bsr>(.*?)</bsr>', re.DOTALL)
261+
205262
packets = []
206263
while True:
207-
# Search for the next complete data packet
208-
try:
209-
start_index = self.__tmp_data[tmp_source].index(header)
210-
end_index = self.__tmp_data[tmp_source].index(footer)
211-
except ValueError:
264+
match = pattern.search(self.__tmp_data[tmp_source])
265+
if not match:
212266
break
267+
# Extract the packet data
268+
packet = match.group(1)
269+
#remove headers and footers
270+
packets.append(packet)
213271

214-
# Extract a complete data packet
215-
packets.append(self.__tmp_data[tmp_source][start_index + len(header):end_index])
216-
# Update the remaining data to exclude the processed packet
217-
self.__tmp_data[tmp_source] = self.__tmp_data[tmp_source][end_index + len(footer):]
218-
219-
# If the remaining data is longer than the expected packet length,
220-
# there might be an incomplete packet, so log a warning.
221-
if len(self.__tmp_data[tmp_source]) >= byte_length:
222-
print("Warning: Incomplete or malformed packet ------------------------------------")
223-
self.__tmp_data[tmp_source] = b''
272+
if match.start(0) != 0:
273+
print(f"skipping {match.start(0)} bytes")
274+
# Remove the processed packet from the temporary buffer
275+
self.__tmp_data[tmp_source] = self.__tmp_data[tmp_source][match.end():]
224276

225277
return packets
226278

279+
227280
def fs_down_callback(self, data):
228281
# copied from listen_upd()
229282
if not data:
@@ -255,12 +308,13 @@ def sigint_handler(signal, frame):
255308

256309
def start_comms():
257310
# start file sync
258-
p.start()
259-
260-
311+
# p.start()
312+
261313
# Start two live comm channels
262-
vps_thread = threading.Thread(target=lambda : asyncio.run(telemetry.remote_db_fetch(config.VPS_URL)))
263-
vps_thread.start()
264-
socket_thread = threading.Thread(target=lambda: telemetry.listen_udp(config.UDP_PORT))
314+
#vps_thread = threading.Thread(target=lambda : asyncio.run(telemetry.remote_db_fetch(config.VPS_URL)))
315+
#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())
265319
socket_thread.start()
266320

Backend/core/core_api.py

Lines changed: 39 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,43 @@
11
from fastapi import APIRouter, WebSocket
2+
import serial.tools.list_ports
23
from . import comms
4+
from pydantic import BaseModel
5+
36
router = APIRouter()
47

5-
@router.websocket("/single-values")
6-
async def single_values(websocket: WebSocket):
7-
await websocket.accept()
8-
9-
try:
10-
while True:
11-
# wait for client to request more data to be send
12-
message_from_client = await websocket.receive_text()
13-
14-
if comms.solar_car_connection['udp'] or comms.solar_car_connection['lte']:
15-
latest_data = comms.frontend_data
16-
latest_data['solar_car_connection'] = True
17-
latest_data['udp_status'] = comms.solar_car_connection['udp']
18-
latest_data['lte_status'] = comms.solar_car_connection['lte']
19-
latest_data['timestamps'] = f'{latest_data["tstamp_hr"]:02d}:{latest_data["tstamp_mn"]:02d}:' \
20-
f'{latest_data["tstamp_sc"]:02d}.{latest_data["tstamp_ms"]}'
21-
format_data = {}
22-
for key in latest_data.keys():
23-
format_data[key] = [latest_data[key]]
24-
json_data = {'response': format_data}
25-
await websocket.send_json(json_data)
26-
else:
27-
await websocket.send_json({'response': None})
28-
except Exception as e:
29-
await websocket.send_json({'error': 'Error fetching data'})
30-
finally:
31-
await websocket.close()
8+
@router.get("/single-values")
9+
async def single_values():
10+
if comms.solar_car_connection['udp'] or comms.solar_car_connection['lte'] or comms.solar_car_connection['serial']:
11+
latest_data = comms.frontend_data
12+
latest_data['solar_car_connection'] = True
13+
latest_data['udp_status'] = comms.solar_car_connection['udp']
14+
latest_data['lte_status'] = comms.solar_car_connection['lte']
15+
latest_data['serial_status'] = comms.solar_car_connection['serial']
16+
latest_data['timestamps'] = f'{latest_data["tstamp_hr"]:02d}:{latest_data["tstamp_mn"]:02d}:' \
17+
f'{latest_data["tstamp_sc"]:02d}.{latest_data["tstamp_ms"]}'
18+
format_data = {}
19+
for key in latest_data.keys():
20+
format_data[key] = [latest_data[key]]
21+
json_data = {'response': format_data}
22+
return json_data
23+
return {'response': None}
24+
25+
26+
@router.get("/serial-info")
27+
async def list_serial_ports():
28+
"""return currently connected device and all available serial device"""
29+
ports = serial.tools.list_ports.comports()
30+
# Extract the device name from each port object
31+
return {'connected_device': {'device': comms.serial_port['device'], 'baud': comms.serial_port['baud']},
32+
'all_devices': [port.device for port in sorted(ports, key=lambda port: port.device)]
33+
}
34+
35+
class SerialDevice(BaseModel):
36+
device: str
37+
baud: int
38+
39+
@router.post("/connect-device")
40+
async def dev_conn(serial_device: SerialDevice):
41+
"""Connect to serial port, pass in empty device name for disconnect"""
42+
comms.serial_port['device'] = serial_device.device
43+
comms.serial_port['baud'] = serial_device.baud

Backend/main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,6 @@ async def startup():
1111
process.start_processes()
1212

1313
if __name__ == '__main__':
14-
uvicorn.run(app='main:app', host="0.0.0.0", port=config.HOST_PORT)
14+
uvicorn.run(app='main:app', host="0.0.0.0", port=config.HOST_PORT, log_level='critical')
1515

1616

Backend/setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,5 @@
99
author='Badger Solar Racing Software Team',
1010
author_email='',
1111
description='',
12-
install_requires=['uvicorn','fastapi','redis', 'requests', 'numpy', 'XlsxWriter', 'pandas', 'aiohttp']
12+
install_requires=['uvicorn','fastapi','redis', 'requests', 'numpy', 'XlsxWriter', 'pandas', 'aiohttp', 'pyserial']
1313
)

Frontend/src/Components/Communication/Communication.js

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,8 @@ export default function Communication(props) {
3838
<Flex flex='auto' direction='column' overflowY='auto' height='90%'>
3939
<HeadingCell fontSize='2.2vh' label='Communication'/>
4040
<Flex flex='inherit' direction='column' pl='2' pt='2' >
41-
<CommsLabel
42-
boolean={props.data?.solar_car_connection[0]}
43-
label='Solar Car Connection'
44-
/>
4541
<HStack>
46-
<Text fontSize='2vh' style={{ textIndent: 30 }}>&#160;Packet Delay: </Text>
42+
<Text fontSize='2vh'>&#160;Packet Delay: </Text>
4743
<Text fontSize='2vh' backgroundColor={bgColor}>{_getFormattedPacketDelay()}</Text>
4844
</HStack>
4945
<CommsLabel
@@ -56,6 +52,11 @@ export default function Communication(props) {
5652
boolean={props.data?.lte_status[0]}
5753
label='LTE'
5854
/>
55+
<CommsLabel
56+
indent={true}
57+
boolean={props.data?.serial_status[0]}
58+
label='Serial'
59+
/>
5960
<CommsLabel
6061
boolean={props.data?.mainIO_heartbeat[0]}
6162
label='Main IO Heartbeat'

Frontend/src/Components/Dashboard/Dashboard.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import GraphContainer from "./GraphContainer";
1111
import DataRecordingControl from "./DataRecordingControl";
1212
import dvOptions from "./dataViewOptions";
1313
import getColor from "../Shared/colors";
14+
import SerialSelector from "../SerialSelector/SerialSelector";
1415
import { ROUTES } from "../Shared/misc-constants";
1516
import FaultsView from "../Faults/FaultsView";
1617
import fauxQueue from "../Graph/faux-queue.json";
@@ -369,6 +370,18 @@ export default function Dashboard(props) {
369370
</Select>
370371
{switchDataView(dataView4)}
371372
</GridItem>
373+
<GridItem
374+
minH="min-content"
375+
rowStart={4}
376+
rowSpan={1}
377+
colStart={1}
378+
colSpan={2}
379+
borderColor={borderCol}
380+
borderWidth={1}
381+
p={1}
382+
>
383+
<SerialSelector/>
384+
</GridItem>
372385
</Grid>
373386
<GraphContainer
374387
flex="2 2 0"
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { useEffect, useState } from "react";
2+
import { Select, Flex, useInterval } from "@chakra-ui/react";
3+
4+
export default function SerialSelector() {
5+
const [allDevices, setAllDevices] = useState([]);
6+
const [selectedDevice, setSelectedDevice] = useState(""); // State to hold the selected device name
7+
const [selectedBaud, setSelectedBaud] = useState(115200);
8+
9+
const refresh = () => {
10+
fetch('/serial-info')
11+
.then((response) => {
12+
if (response.ok) {
13+
return response.json();
14+
} else {
15+
throw new Error(`Error fetching serial port with code ${response.status}`);
16+
}
17+
})
18+
.then((body) => {
19+
setSelectedDevice(body['connected_device']['device']); // Set default device
20+
setSelectedBaud(body['connected_device']['baud']);
21+
setAllDevices(body['all_devices']);
22+
}).catch(error => console.error('Fetch error:', error));
23+
};
24+
25+
useInterval(refresh, 3000);
26+
27+
useEffect(()=> {
28+
fetch("/connect-device", {
29+
method: "POST",
30+
body: JSON.stringify({
31+
device: selectedDevice,
32+
baud: selectedBaud
33+
}),
34+
headers: {
35+
"Content-type": "application/json"
36+
}
37+
});
38+
39+
}, [selectedBaud, selectedDevice])
40+
41+
const getSerialPort = () => {
42+
return (
43+
<Select
44+
placeholder='Select option'
45+
width={'30%'}
46+
padding={2}
47+
value={selectedDevice} // Controlled component with selectedDevice as the current value
48+
onChange={e => setSelectedDevice(e.target.value)} // Handler to update state on user selection
49+
>
50+
{allDevices.map(device => (
51+
<option key={device} value={device}>{device}</option>
52+
))}
53+
</Select>
54+
);
55+
};
56+
57+
const getBaud = () => {
58+
const defaultBaud = [4800, 9600, 14400, 19200, 38400, 57600, 115200, 128000, 256000];
59+
return (
60+
<Select
61+
width={'30%'}
62+
padding={2}
63+
value={selectedBaud}
64+
onChange={e => setSelectedBaud(e.target.value)}
65+
>
66+
{defaultBaud.map(baud => (
67+
<option key={baud} value={baud}>{baud}</option>
68+
))}
69+
</Select>
70+
)
71+
}
72+
73+
return (
74+
<Flex flex="auto" direction="row" alignItems="center" justifyContent="center">
75+
<p>Serial Device:</p>
76+
{getSerialPort()}
77+
<p>Baud:</p>
78+
{getBaud()}
79+
</Flex>
80+
);
81+
}

0 commit comments

Comments
 (0)