Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion app.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@
import threading
import json

from flask import Flask, request, Response, jsonify
from flask import Flask, request, Response, jsonify, stream_with_context
from flask_cors import CORS
from hardware import serial_connection, sensor_sentry, serial_lock, background_lifecycle_lock, firmware # Objects from hardware.py
from serial import SerialException
from stream import Stream
from threads import poll_dashboard_dump
from typing import List, Dict
from util import make_safe_number
Expand All @@ -29,7 +30,9 @@

# Globals for polling dashboard dump
stop_event = threading.Event()
stream_obj = Stream()
telemetry_obj = Telemetry()
telemetry_obj.register_rx_callback(stream_obj.sse_put_callback)
dashboard_dump_thread = threading.Thread(
target=poll_dashboard_dump,
args=(serial_connection, stop_event, telemetry_obj),
Expand Down Expand Up @@ -150,6 +153,36 @@ def wireless_stats():
else:
return Response("No wireless target available.", status=204)

@app.route("/stream", methods=["GET"])
def stream():
"""
Subscribes to a server-sent event stream that is populated by all
incoming messages.

Works for both the serial (USB) and over-the-air (LoRa) paths.

Integration Concerns:
SDEC-API <-> SDEC - App initialization needs to provide a
message_received callback to the telemetry object. SDEC needs
to invoke this callback upon receiving a new message. Null
or uninitialized callbacks must be handled.

SDEC-API <-> Client - Dashboard Dump must be initialized with
a POST request prior to subscribing to the stream, else no events
will be sent. The version number shall be incremented every time
an **existing** message type is changed. New message types do
not require upversioning.
"""
return Response(
stream_with_context(stream_obj.sse_broadcast_callback()),
mimetype="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no"
}
)
Comment thread
ETSells marked this conversation as resolved.

@app.route("/dashboard-dump", methods=["GET", "POST"])
def dashboard_dump():
global dashboard_dump_thread
Expand Down
85 changes: 85 additions & 0 deletions stream.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# SPDX-License-Identifier: BSD-3-Clause
# Copyright (c) 2025 Sun Devil Rocketry

import threading
import time
import json

from typing import Dict, Any
from util import make_safe_number

# CONSTANTS
ORIGIN_STRING = "SDEC-API"
STREAM_VERSION_STRING = "1.0"
RATE_LIMITER_HZ = 30
RATE_LIMITER_PERIOD = 1.0 / RATE_LIMITER_HZ


class Stream:
def __init__(self) -> None:
self.stream_cond = threading.Condition()
self.sequence_number = 0
self.terminate = False
self.data = None
Comment thread
coderabbitai[bot] marked this conversation as resolved.

def sse_put_callback(self, messageType: str, timestamp: float, msg: dict[str, Any]):
"""
Set up a message for all clients to broadcast.
"""

with self.stream_cond:
response = {
"origin": ORIGIN_STRING,
"version": STREAM_VERSION_STRING,
"timestamp": timestamp,
"messageType": messageType,
"payload": msg
}
Comment on lines +31 to +37

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

self.data = f"data: {json.dumps(response)}\n\n".encode("utf-8")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

json.dumps() running 500Hz is prob not the best performance wise, which is why you were having performance issues.

Probably move it from callback into sse_broadcast_callback s.t. json.dump serializes at broadcast rate instead at 500Hz. I assume within the while not self.terminate.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Also, self.data is single variable, which ig is fine for SDEC, but, at least for LQD-DAQ, I would have to use a queue (deque) to prevent last-message overriding, so it's rather a question if you're willing to lose some data.

Optional, just a note.

@ETSells ETSells Jul 21, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

it's not 500 hz, it's like 60-ish for the serial path and 25-ish for LoRa. Given that your rate is upwards of 500 hz though this should def be considered. I'm definitely willing to drop packets since my priority is just making sure the data I'm feeding is the most up to date and I have very little latency. The deque sounds great for you if you're trying to preserve every packet, just make sure you're not flooding the client at more than 60 hz. I personally recommend 30 for the clients' sake, since their refresh rate won't go very high. The one concern I have about this rate limiting is on the rolling pressure chart.

self.sequence_number += 1
self.stream_cond.notify_all()


def sse_broadcast_callback(self):
"""
Yields messages for the client with this request open.
"""
last_seq_num = 0 # scoped to this instance
last_time = time.monotonic()

# Send a message immediately on connect if one exists
with self.stream_cond:
if self.data is not None:
last_seq_num = self.sequence_number
data_to_yield = self.data
else:
data_to_yield = None

if data_to_yield is not None:
yield data_to_yield

# Wait for new messages to be put
while not self.terminate:

@MasterUser43 MasterUser43 Jul 20, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Just random thing, but may a try...finally clause in case you want to use finally: as a way to detect a browser tab closing or to decrement client counter if so choose to consider multiple connections.

Inno, just a neat thing instead of waiting time outs for disconnection.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The API itself doesn't track client state atm. This is handled behind some layers of abstraction by Flask & Werkzeug. The background processing layer has no idea how many clients are in the foreground. We may change this eventually if we need to throttle too many client connections, but I'm planning for ~4 simultaneous clients right now.

with self.stream_cond:
# Wait for sequence_number to change to avoid spurious wakeups
while self.sequence_number == last_seq_num and not self.terminate:
self.stream_cond.wait()
# delay the broadcast (yielding to other threads) if rate limiter
# indicates that messages are coming in too fast
curr_time = time.monotonic()
if curr_time < RATE_LIMITER_PERIOD + last_time:
# Release the lock if we have to wait for the timeout,
# then reacquire once we're back on the target rate.
self.stream_cond.release()
try:
time.sleep( RATE_LIMITER_PERIOD + last_time - curr_time )
finally:
self.stream_cond.acquire()
# end the broadcast entirely if the termination flag is set
if self.terminate:
break
last_seq_num = self.sequence_number
data_to_yield = self.data
last_time = time.monotonic()

yield data_to_yield
Comment thread
ETSells marked this conversation as resolved.