Skip to content
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -134,3 +134,4 @@ dmypy.json

# Misc
.idea/
recordings
2 changes: 1 addition & 1 deletion config.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,4 @@
"VA3EHJ": "Elias Hawa",
"VA3ZAJ": "Angus Jull"
}
}
}
20 changes: 18 additions & 2 deletions src/ground_station_v2/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import uvicorn
import asyncio
import json
from src.ground_station_v2.replay import Replay
from src.ground_station_v2.record import Record
from typing import Any
import logging
Expand All @@ -14,16 +13,25 @@

logger = logging.getLogger(__name__)

recorder = Record()

connected_clients: dict[str, WebSocket] = {}

async def broadcast_radio_packets():
logger.info("Starting broadcast_radio_packets")
config = load_config("config.json")

try:
async for packet in get_radio_packet():
recorder.init_mission("recordings")
recorder.start()

async for packet in get_radio_packet(True):
packet_hex = packet.hex()
parsed = parse_rn2483_transmission(packet_hex, config)

if (recorder.recording):
logger.info("Writing")
recorder.write(packet_hex, parsed)

if not parsed:
logger.warning(f"Failed to parse packet: {packet_hex}")
Expand All @@ -45,8 +53,14 @@ async def broadcast_radio_packets():

for client_id in disconnected:
connected_clients.pop(client_id, None)

recorder.stop()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would this recorder.stop() ever fire?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like the loop from the packets doesn't end. I think its good to have just in case anything abnormal from the packet function.

recorder.close_mission()
except Exception as e:
recorder.stop()
recorder.close_mission()
logger.error(f"Error in broadcast_radio_packets: {e}", exc_info=True)



# handles the lifespan of the app, creates and destroys async tasks
Expand Down Expand Up @@ -103,12 +117,14 @@ async def replay_goto(x_client_id: str = Header(alias="X-Client-ID")):
@app.post("/record_start")
async def record_start(x_client_id: str = Header(alias="X-Client-ID")):

recorder.start()
logger.info(f"Record start for client {x_client_id}")
return {"status": "ok"}

@app.post("/record_stop")
async def record_stop(x_client_id: str = Header(alias="X-Client-ID")):

recorder.stop()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure about this one but maybe stopping the recording should also just close the mission, what do you think

Copy link
Copy Markdown
Contributor Author

@incogiscool incogiscool Jan 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe can call the stop function inside of the close mission function, but not completely take away the stop function?

logger.info(f"Record stop for client {x_client_id}")
return {"status": "ok"}

Expand Down
4 changes: 2 additions & 2 deletions src/ground_station_v2/radio/packets/blocks.py
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You shouldn't import by path, look at the readme installation all the way to the bottom, you may have skipped the pip install -e . step

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fix this pattern everywhere in your files plz

Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
from dataclasses import dataclass, field
import struct

from ground_station_v2.radio.packets.headers import PacketHeader, BlockHeader, BlockType
from ground_station_v2.radio.packets.unit_conversions import *
from src.ground_station_v2.radio.packets.headers import PacketHeader, BlockHeader, BlockType
from src.ground_station_v2.radio.packets.unit_conversions import *
from typing import Any


Expand Down
48 changes: 44 additions & 4 deletions src/ground_station_v2/radio/packets/spec.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
from dataclasses import dataclass
from typing import List, Optional
import logging
import struct

from ground_station_v2.radio.packets.headers import (
from src.ground_station_v2.radio.packets.headers import (
PacketHeader,
BlockType,
parse_packet_header,
parse_block_header,
PACKET_HEADER_LENGTH,
BLOCK_HEADER_LENGTH,
CALLSIGN_LENGTH,
InvalidHeaderFieldValueError,
)
from ground_station_v2.radio.packets.blocks import Block, parse_block_contents, get_block_class, InvalidBlockContents
from ground_station_v2.config import Config
from src.ground_station_v2.radio.packets.blocks import Block, parse_block_contents, get_block_class, InvalidBlockContents
from src.ground_station_v2.config import Config
from time import time

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -64,7 +68,6 @@ def from_approved_callsign(pkt_hdr: PacketHeader, approved_callsigns: dict[str,
logger.warning(f"Incoming packet from unauthorized call sign {pkt_hdr.callsign}")
return False


def parse_blocks(packet_header: PacketHeader, encoded_blocks: bytes) -> List[Block]:
"""
Parses telemetry payload blocks from either parsed packets or stored replays. Block contents are a hex string.
Expand Down Expand Up @@ -104,3 +107,40 @@ def parse_blocks(packet_header: PacketHeader, encoded_blocks: bytes) -> List[Blo
encoded_blocks = encoded_blocks[BLOCK_HEADER_LENGTH + block_len :]

return parsed_blocks

# For now, returns the same packet.
def create_fake_packet() -> str:
"""
Creates a fake packet with sample telemetry data for testing purposes.
Returns a hex string representation of the raw packet bytes that can be
parsed by parse_rn2483_transmission().
"""
timestamp = int(time() * 1000) & 0xFFFF # Mask to 16 bits
packet_num = 1
block_count = 3

# Create packet header bytes (callsign padded to 9 chars + timestamp + block count + packet num)
callsign = "VA3ZAJ".ljust(CALLSIGN_LENGTH, '\x00')
packet_header_bytes = callsign.encode('ascii') + struct.pack("<HBB", timestamp, block_count, packet_num)

# Create block bytes
block_bytes = b""

# Temperature block: block header (type=0x02, count=1) + contents (time, temp)
block_bytes += struct.pack("<BB", BlockType.TEMPERATURE, 1) # Block header
block_bytes += struct.pack("<hi", 1000, 25000) # 1 second, 25°C (milli-degrees)

# Pressure block: block header (type=0x03, count=1) + contents (time, pressure)
block_bytes += struct.pack("<BB", BlockType.PRESSURE, 1) # Block header
block_bytes += struct.pack("<hI", 1500, 101325) # 1.5 seconds, ~1 atm

# Linear acceleration block: block header (type=0x04, count=1) + contents (time, x, y, z)
block_bytes += struct.pack("<BB", BlockType.LINEAR_ACCELERATION, 1) # Block header
block_bytes += struct.pack("<hhhh", 2000, 0, 0, 981) # 2 seconds, ~9.81 m/s² in z

# Combine and return as hex string
full_packet = packet_header_bytes + block_bytes
return full_packet.hex()



2 changes: 1 addition & 1 deletion src/ground_station_v2/radio/rn2483.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

from typing import Optional
from serial import Serial, EIGHTBITS, PARITY_NONE, SerialException
from ground_station_v2.config import RadioParameters
from src.ground_station_v2.config import RadioParameters

RN2483_BAUD: int = 57600 # The baud rate of the RN2483 radio
NUM_GPIO: int = 14 # Number of GPIO pins on the RN2483 module
Expand Down
42 changes: 24 additions & 18 deletions src/ground_station_v2/radio/serial.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import asyncio
from ground_station_v2.config import load_config
from ground_station_v2.radio.rn2483 import RN2483Radio
from src.ground_station_v2.config import load_config
from src.ground_station_v2.radio.rn2483 import RN2483Radio
from src.ground_station_v2.radio.packets.spec import create_fake_packet
import logging

logger = logging.getLogger(__name__)
Expand All @@ -9,23 +10,28 @@ def discover_port():
logger.error("Not implemented")
return ""

async def get_radio_packet():

port = discover_port()

async def get_radio_packet(fake: bool):
# TODO: remove this, just for testing
# port = "/dev/tty.usbserial-DP05O1XX"

config = load_config("config.json")
radio = RN2483Radio(port)

logger.info(f"Setting up radio on port {port}")
await asyncio.to_thread(radio.setup, config.radio_parameters)
logger.info("Radio setup complete")
if fake:
logger.info("Sending fake radio packet")
while True:
logger.info("Sending packet")
yield bytes.fromhex(create_fake_packet())
await asyncio.sleep(1)
else:
port = discover_port()
config = load_config("config.json")
radio = RN2483Radio(port)

logger.info(f"Setting up radio on port {port}")
await asyncio.to_thread(radio.setup, config.radio_parameters)
logger.info("Radio setup complete")

logger.info("Receiving radio packets")
while True:
message = await asyncio.to_thread(radio.receive)
logger.info(f"Received radio packet: {message}")
if message:
yield bytes.fromhex(message)
logger.info("Receiving radio packets")
while True:
message = await asyncio.to_thread(radio.receive)
logger.info(f"Received radio packet: {message}")
if message:
yield bytes.fromhex(message)
123 changes: 120 additions & 3 deletions src/ground_station_v2/record.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,22 @@
from pathlib import Path
from src.ground_station_v2.radio.packets.spec import ParsedTransmission
from time import time
from src.ground_station_v2.radio.packets.blocks import (
AltitudeAboveSeaLevel,
AltitudeAboveLaunchLevel,
Temperature,
Pressure,
LinearAcceleration,
AngularVelocity,
Humidity,
Coordinates,
Voltage,
MagneticField,
FlightStatus,
FlightError,
)
import csv


# saves data in the "recordings" dir
# recording dir has child dirs for each mission, the default name is the timestamp but it should have the ability to be renamed
Expand All @@ -7,9 +25,41 @@
# the parsed data should be a collection of csvs, one for each sensor type
# the recoding class is a singleton, which means it can only be instantiated once
# it's methods are async, which means we don't need threads or processes, just one asyncio task


from typing import TypedDict, Any
from io import TextIOWrapper

class FileConfig(TypedDict):
filename: str
file: TextIOWrapper | None
# Dictwriter gives type errors in ide, but when fixed gives type error on run :/
writer: Any | None
field_names: list[str]

class Record:
_instance = None

raw_file = None
mission_name = time()
recording = False

# Config for files
parsed_files: dict[Any, FileConfig] = {
AltitudeAboveSeaLevel: {"filename": "altitude_above_sea_level", "file": None, "writer": None, "field_names": ["measurement_time", "altitude"]},
AltitudeAboveLaunchLevel: {"filename": "altitude_above_launch_level", "file": None, "writer": None, "field_names": ["measurement_time", "altitude"]},
Temperature: {"filename": "temperature", "file": None, "writer": None, "field_names": ["measurement_time", "temperature"]},
Pressure: {"filename": "pressure", "file": None, "writer": None, "field_names": ["measurement_time", "pressure"]},
LinearAcceleration: {"filename": "linear_acceleration", "file": None, "writer": None, "field_names": ["measurement_time", "x_axis", "y_axis", "z_axis"]},
AngularVelocity: {"filename": "angular_velocity", "file": None, "writer": None, "field_names": ["measurement_time", "x_axis", "y_axis", "z_axis"]},
Humidity: {"filename": "humidity", "file": None, "writer": None, "field_names": ["measurement_time", "humidity"]},
Coordinates: {"filename": "coordinates", "file": None, "writer": None, "field_names": ["measurement_time", "latitude", "longitude"]},
Voltage: {"filename": "voltage", "file": None, "writer": None, "field_names": ["measurement_time", "voltage", "identifier"]},
MagneticField: {"filename": "magnetic_field", "file": None, "writer": None, "field_names": ["measurement_time", "x_axis", "y_axis", "z_axis"]},
FlightStatus: {"filename": "status_message", "file": None, "writer": None, "field_names": ["measurement_time", "flight_status"]},
FlightError: {"filename": "error_message", "file": None, "writer": None, "field_names": ["measurement_time", "proc_id", "error_code"]},
}

# singleton pattern
def __new__(cls):
if cls._instance is None:
Expand All @@ -18,8 +68,75 @@ def __new__(cls):
# create the recordings directory if it doesn't exist
Path("recordings").mkdir(exist_ok=True)
return cls._instance


def init_mission(self, recordings_path: str, mission_name: str | None = None):
if mission_name:
self.mission_name = mission_name

Path(f"{recordings_path}/{self.mission_name}").mkdir(exist_ok=True)
Path(f"{recordings_path}/{self.mission_name}/parsed").mkdir(exist_ok=True)

self.raw_file = open(recordings_path + f"/{self.mission_name}/raw", "w")

# When initializing files in init_mission:
for value in self.parsed_files.values():
filepath = f"{recordings_path}/{self.mission_name}/parsed/{value['filename']}.csv"

file = open(filepath, "w", newline='')

writer = csv.DictWriter(file, fieldnames=value['field_names'])
writer.writeheader()

# Store both file and writer
value['file'] = file
value['writer'] = writer



def close_mission(self):
if not self.raw_file:
raise FileExistsError("Mission not initialized")

self.raw_file.close()
for value in self.parsed_files.values():
if value["file"]:
value['file'].close()


def write(self, raw_packet: str, parsed_packet: ParsedTransmission | None):
if not self.raw_file:
raise FileExistsError("Mission not initialized")

self.raw_file.write(raw_packet + "\n")
self.raw_file.flush()


if not parsed_packet:
return

for block in parsed_packet.blocks:
block_type = type(block)

# Check if the block is a key inside of the parsed_files dict
if block_type in self.parsed_files:
print(f"Block type: {block_type.__name__}")

writer_entry = self.parsed_files[block_type]
writer = writer_entry['writer']
file = writer_entry['file']

# Return obj of keys/values not including keys that start with '_'
data = {k: v for k, v in vars(block).items() if not k.startswith('_')}

if writer and file:
writer.writerow(data)
file.flush()
else:
print(f"Unhandled block type: {type(block).__name__}")

def start(self):
self.recording = True

async def start(self):
pass
def stop(self):
pass
self.recording = False
Loading